閉じる

【C言語】関数呼び出しの基本と落とし穴|引数・戻り値・スタック動作を理解

C言語で本格的なプログラムを書こうとすると、避けて通れないのが関数呼び出しです。

単に「呼び出すだけ」と思われがちですが、引数の渡し方や戻り値、そして裏側で動いているスタックの仕組みを正しく理解していないと、バグやクラッシュ、謎の挙動に悩まされてしまいます。

本記事では、C言語の関数呼び出しの基本から落とし穴までを、図解とサンプルコード付きで丁寧に解説します。

C言語の関数呼び出しとは

C言語における関数の役割とメリット

関数は、処理のまとまりに名前を付けて再利用可能にしたものです。

C言語では、プログラムの分割・再利用・可読性向上のために関数を積極的に使います。

関数を使う主なメリットは、次のような点にあります。

1つ目に、処理のまとまりごとに関数に切り出すことで、コードの見通しが良くなります。

たとえば「入力処理」「計算処理」「出力処理」をそれぞれ別の関数にすることで、main関数が読みやすくなります。

2つ目に、同じ処理を何度も書かずに済むことです。

一度関数として定義しておけば、必要な場所から繰り返し呼び出すだけで済みます。

3つ目に、テストや保守がしやすくなる点です。

個々の関数を単体でテストしやすくなるため、バグの切り分けが容易になります。

次の図のように、1つの大きな処理を複数の関数に分割して構成するイメージを持つと分かりやすいです。

このように、関数を使うことで、main関数では「処理の流れ」だけを記述し、細かい実装は各関数に任せるという役割分担が実現できます。

main関数から他の関数を呼び出す基本

C言語における関数呼び出しの最も基本的な形は、関数名(引数リスト)という書き方です。

main関数から他の関数を呼び出す簡単な例を見てみます。

C言語
#include <stdio.h>

// 関数プロトタイプ宣言(事前に関数の型をコンパイラに知らせる)
int add(int a, int b);  // aとbを足して結果を返す関数

int main(void) {
    int x = 3;
    int y = 5;

    // add関数を呼び出し、その結果をresultに代入
    int result = add(x, y);

    printf("x + y = %d\n", result);
    return 0;  // 正常終了を示す戻り値
}

// 関数定義
int add(int a, int b) {
    int sum = a + b;  // 引数aとbを足し合わせる
    return sum;       // 結果を呼び出し元に返す
}
実行結果
x + y = 8

この例では、add関数を定義して、main関数から呼び出す基本的な流れを示しています。

ポイントは次の3つです。

1つ目に、関数を使う前に、プロトタイプ宣言か、定義そのものをコンパイラに見せておく必要があります。

ここではソースの先頭にint add(int a, int b);という宣言を置いています。

2つ目に、main関数からadd(x, y)と書くことで、実際の変数xyを引数として渡しています。

3つ目に、add関数内のreturn sum;が、呼び出し元であるmain関数に値を返しています。

この「引数を渡して、戻り値を受け取る」という流れが、C言語の関数呼び出しの基本です。

引数の基本と落とし穴

値渡し(call by value)の仕組み

C言語の関数引数は、基本的にすべて値渡し(call by value)です。

これは、引数として渡されるのは「値のコピー」であり、元の変数そのものではないという意味です。

次のサンプルを見てみてください。

C言語
#include <stdio.h>

void change_value(int a) {
    // 引数aは呼び出し元の変数の「コピー」
    a = 100;  // この代入は呼び出し元の変数には影響しない
    printf("change_value内のa = %d\n", a);
}

int main(void) {
    int x = 10;
    printf("呼び出し前のx = %d\n", x);

    change_value(x);  // xの値(10)がコピーされてaに渡される

    printf("呼び出し後のx = %d\n", x);  // xは変わらない
    return 0;
}
実行結果
呼び出し前のx = 10
change_value内のa = 100
呼び出し後のx = 10

結果から分かるように、関数内で引数aを書き換えても、呼び出し元のxには影響しません。

「関数に渡したら中で勝手に変わってしまう」ことは原則としてありません

この性質は安全ですが、一方で「関数の中から元の変数を書き換えたい」ときには工夫が必要になります。

それが後述するポインタ引数です。

変数名と実引数・仮引数の関係

関数呼び出しでは、呼び出し側の引数実引数(actual argument)関数定義側のパラメータ仮引数(formal argument)と呼びます。

たとえば先ほどのchange_value(x)では、xが実引数、関数定義側のint aaが仮引数です。

重要なのは、実引数と仮引数は別々の変数であり、名前もメモリアドレスも異なるという点です。

変数名が違っても、型と順番が一致していれば対応が取られます。

このように、変数名に意味はなく、型と位置で対応することを理解しておくと、関数の設計や読みやすさの上でも役に立ちます。

配列引数とポインタの扱い方

C言語では、配列は関数に渡すときに「先頭要素のポインタ」に自動変換されるという性質があります。

これが、値渡しのルールと組み合わさることで少しややこしく見える原因です。

次の例を見てください。

C言語
#include <stdio.h>

// 配列を受け取る関数(書き方1: int arr[] と書く)
void print_array(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        printf("arr[%d] = %d\n", i, arr[i]);
    }
}

// 配列を受け取る関数(書き方2: int *arr と書く) ※意味は同じ
void set_zero(int *arr, int size) {
    for (int i = 0; i < size; i++) {
        arr[i] = 0;  // 呼び出し元の配列の中身を書き換える
    }
}

int main(void) {
    int data[3] = {1, 2, 3};

    printf("変更前:\n");
    print_array(data, 3);

    set_zero(data, 3);  // 配列の先頭ポインタが渡される

    printf("変更後:\n");
    print_array(data, 3);
    return 0;
}
実行結果
変更前:
arr[0] = 1
arr[1] = 2
arr[2] = 3
変更後:
arr[0] = 0
arr[1] = 0
arr[2] = 0

関数set_zeroの中でarr[i] = 0;と書き換えた結果が、呼び出し元のdataにも反映されていることが分かります。

これは、関数に渡されるarr配列そのものではなく「配列先頭要素へのポインタのコピー」だからです。

ここで注意すべき落とし穴は、「配列のサイズ情報は自動で渡されない」という点です。

そのため、上記のようにsizeを別引数として同時に渡すのがCの定番パターンです。

ポインタ引数で値を更新する方法

冒頭で述べたように、C言語の引数は値渡しなので、普通にintなどを渡しただけでは関数内から呼び出し元の変数を変更できません。

そこで使うのがポインタ引数です。

「変数そのもの」ではなく「変数へのポインタ(アドレス)」を渡すことで、関数内から元の変数を操作できるようになります。

C言語
#include <stdio.h>

// int型の値を2倍にする関数(ポインタで受け取る)
void double_value(int *p) {
    // *p は「ポインタpが指す先の値」を意味する
    *p = *p * 2;
}

int main(void) {
    int x = 10;
    printf("呼び出し前のx = %d\n", x);

    double_value(&x);  // xのアドレスを渡す

    printf("呼び出し後のx = %d\n", x);  // xの値が20に変わる
    return 0;
}
実行結果
呼び出し前のx = 10
呼び出し後のx = 20

ここでも、p自体は値渡し(ポインタ値のコピー)です。

しかし、コピーされたポインタが指している先(元の変数)は同じなので、「間接的に」呼び出し元の変数を更新できるわけです。

const修飾子と引数の安全性

関数の引数にconstを付けることで、「この引数経由で値を書き換えません」とコンパイラと呼び出し側に約束することができます。

特に、ポインタ引数や配列を読むだけの関数では、constを活用することで安全性と可読性が高まります。

C言語
#include <stdio.h>

// constを付けることで、この関数は配列の中身を変更しないと明示
void print_array_const(const int *arr, int size) {
    for (int i = 0; i < size; i++) {
        // arr[i] = 0;  // これはコンパイルエラーになる(書き換え禁止)
        printf("arr[%d] = %d\n", i, arr[i]);
    }
}

int main(void) {
    int data[3] = {1, 2, 3};
    print_array_const(data, 3);
    return 0;
}
実行結果
arr[0] = 1
arr[1] = 2
arr[2] = 3

constには大きく2つの意味があります。

1つ目は、自分自身を守ることです。

間違って書き換えコードを書いてしまったときに、コンパイラがエラーを出してくれます。

2つ目は、呼び出し元に対する契約です。

「この関数はそれを変更しない」と宣言することで、安心してその関数を利用できるようになります。

特に、const char *strのように文字列を受け取る関数などでは、constを付けておくことが推奨されます。

戻り値の基本と注意点

return文と戻り値の型

関数は戻り値の型を指定できます。

戻り値の型は、関数定義の先頭に書かれる型で表されます。

たとえばint add(int a, int b)であれば、intが戻り値の型です。

関数から値を返すときは、return 式;と書きます。

C言語
#include <stdio.h>

// 2つの整数の平均値をdoubleで返す関数
double average(int a, int b) {
    double sum = (double)a + (double)b;  // 型変換してから足す
    return sum / 2.0;                    // double型の値を返す
}

int main(void) {
    int x = 3;
    int y = 5;
    double avg = average(x, y);  // 戻り値を受け取る
    printf("平均値 = %f\n", avg);
    return 0;
}
実行結果
平均値 = 4.000000

戻り値の型とreturnで返す式の型は整合している必要があります。

多くの場合は暗黙の型変換が行われますが、意図しない変換はバグの原因になるので、必要なら明示的にキャストするか、そもそも適切な型設計を行うべきです。

void関数と戻り値なしの呼び出し

戻り値を返さない関数は、void型として宣言します。

このような関数ではreturn文は省略するか、return;とだけ書きます。

C言語
#include <stdio.h>

// 戻り値を返さない関数
void print_hello(void) {
    printf("Hello, world!\n");
    // return;  // 書いても書かなくてもよい
}

int main(void) {
    print_hello();  // 戻り値を受け取らない呼び出し
    return 0;
}
実行結果
Hello, world!

戻り値がない関数を、何かの値として使うことはできません

たとえばint x = print_hello();のような書き方は誤りです。

戻り値で返せるもの・返せないもの

C言語の戻り値では、値として扱えるものは概ね返すことができます

具体的には次のようなものです。

  • 整数型(int, long など)
  • 浮動小数点型(float, double など)
  • ポインタ型
  • 構造体(struct)や共用体(union)も返せます

一方で、配列そのものは値として返すことはできません

ただし、配列の先頭要素へのポインタを返すことはできます。

C言語
#include <stdio.h>

// NG例: 配列そのものを戻り値にしようとしている(コンパイルエラー)
// int[3] make_array() { ... } // こういう宣言はできない

// OK例: 配列の先頭要素へのポインタを返す関数
int *get_array_head(int arr[], int size) {
    // 引数arrは先頭へのポインタに変換されている
    return arr;  // 先頭要素へのポインタを返す
}

int main(void) {
    int data[3] = {10, 20, 30};
    int *p = get_array_head(data, 3);
    printf("*p = %d\n", *p);  // 先頭要素10が表示される
    return 0;
}
実行結果
*p = 10

戻り値で配列そのものを返せないため、「大きな配列を返したい場合」は、呼び出し元で配列を用意して、その配列へのポインタを引数として渡すスタイルがよく使われます。

ローカル変数のアドレスを返す危険性

戻り値でポインタを返すときに、絶対にやってはいけない典型的なミスが、ローカル変数のアドレスを返してしまうことです。

C言語
#include <stdio.h>

int *dangerous_function(void) {
    int x = 42;     // ローカル変数(スタック上に確保)
    return &x;      // 危険: ローカル変数のアドレスを返している
}

int main(void) {
    int *p = dangerous_function();
    // ここで*pを使うと未定義動作(クラッシュするかもしれない)
    printf("*p = %d\n", *p);  // 実行環境によっては42に見えることもあるが保証はない
    return 0;
}
実行結果
(出力は実行環境に依存し、42になるとは限らない)

このコードが危険な理由は、関数が終了すると、その関数のローカル変数は無効になり、スタック領域が再利用されてしまうからです。

そのため、返されたポインタ&xは、もはや有効なデータを指していません。

ローカル変数のアドレスを返してはいけないというルールは、C言語プログラミングの基本的かつ重要な安全原則です。

配列や構造体などを返す必要がある場合は、次のような方法を検討します。

  • 呼び出し元で領域を確保し、そのポインタを引数として渡す
  • staticな領域を使う(ただしスレッド安全性や再入性の問題に注意)
  • 動的メモリ確保(mallocなど)を使い、呼び出し元でfreeする

構造体の戻り値とパフォーマンス

C言語では、構造体(struct)を戻り値として返すことも可能です。

古い解説書などでは「構造体を値で返すのは重いからポインタで返せ」と書かれていることもありますが、現代のコンパイラでは最適化が進んでいて、実務でも普通に使われます。

C言語
#include <stdio.h>

typedef struct {
    int x;
    int y;
} Point;

// 構造体を戻り値で返す
Point make_point(int x, int y) {
    Point p;
    p.x = x;
    p.y = y;
    return p;  // 構造体が値として返される
}

int main(void) {
    Point p1 = make_point(3, 5);
    printf("p1 = (%d, %d)\n", p1.x, p1.y);
    return 0;
}
実行結果
p1 = (3, 5)

内部的には、構造体の戻り値はスタックやレジスタ、あるいは呼び出し規約に応じた専用のやり方で渡されます。

最近のコンパイラはRVO(Return Value Optimization)などの最適化を行うため、単純なケースでは余計なコピーが発生しないようにしてくれます。

ただし、非常に大きな構造体を頻繁に値として返すと、パフォーマンスに影響を与える可能性があります。

その場合は、次のような設計を検討します。

  • 構造体を関数引数としてポインタ経由で受け取り、そこに結果を書き込む
  • 構造体へのconstポインタを引数に取り、読み取り専用にする

用途や性能要件に応じて、読みやすさとパフォーマンスのバランスを考えた設計が重要です。

スタック動作とメモリの理解

関数呼び出しとスタックフレーム

関数呼び出しの裏側では、スタック(stack)と呼ばれるメモリ領域が使われます。

スタックは「後入れ先出し(LIFO)」のデータ構造で、関数を呼び出すたびにスタックフレームと呼ばれる領域が積み上がり、関数から戻るときに元に戻されます。

スタックフレームには、次のような情報が格納されます。

  • 戻りアドレス(どこに戻るか)
  • 関数の引数(一部はレジスタの場合もある)
  • ローカル変数
  • 保存されたレジスタなど(呼び出し規約に依存)

関数が呼び出されると、新しいスタックフレームが積まれ、ローカル変数用の領域がそこで確保されます

関数がreturnで戻ると、そのフレームは破棄され、ローカル変数は無効になります。

これが前述の「ローカル変数のアドレスを返してはいけない」理由です。

ローカル変数とスタック領域の寿命

ローカル変数(自動変数)の寿命は、その変数が属するブロック(通常は関数)の実行中のみです。

C言語
#include <stdio.h>

void foo(void) {
    int a = 10;  // fooのローカル変数
    printf("foo内: a = %d\n", a);
}

int main(void) {
    foo();
    // ここではaは存在しない(アクセスできない)
    // printf("%d\n", a); // コンパイルエラー: aは未定義
    return 0;
}
実行結果
foo内: a = 10

変数がスコープ外になると、その変数を参照するコードはコンパイルエラーになりますし、たとえポインタが残っていても、その指す先のメモリ内容は保証されません。

また、ローカル変数は関数が呼び出されるたびに新しく作られるので、再帰呼び出しや同じ関数の複数回の呼び出しでも、互いに干渉しません

再帰呼び出しとスタックオーバーフロー

再帰関数は、自分自身を呼び出す関数です。

便利な一方で、呼び出しが深くなりすぎるとスタックオーバーフローを起こす危険があります。

C言語
#include <stdio.h>

// nから1までカウントダウンする再帰関数
void countdown(int n) {
    printf("n = %d\n", n);
    if (n <= 1) {
        return;  // 再帰終了条件
    }
    countdown(n - 1);  // 自分自身を呼び出す
}

int main(void) {
    countdown(5);
    return 0;
}
実行結果
n = 5
n = 4
n = 3
n = 2
n = 1

この程度の浅い再帰なら問題ありませんが、countdown(1000000)のように非常に深い再帰を行うと、スタックに大量のスタックフレームが積まれ、限界を超えた時点でスタックオーバーフローとなり、プログラムがクラッシュすることがあります。

再帰を使うときは、終了条件が確実に満たされることと、呼び出しの深さが現実的な範囲に収まることを意識して設計する必要があります。

可変長配列とスタック使用量

C99以降のC言語では、可変長配列(VLA: Variable Length Array)をローカル変数として宣言できます。

配列のサイズを実行時に決められる便利な機能ですが、スタックを大量に消費する可能性があるので注意が必要です。

C言語
#include <stdio.h>

// nの大きさの配列をローカルに確保して使用
void use_vla(int n) {
    int arr[n];  // 可変長配列(VLA)
    for (int i = 0; i < n; i++) {
        arr[i] = i;
    }
    printf("arr[0] = %d, arr[%d] = %d\n", arr[0], n - 1, arr[n - 1]);
}

int main(void) {
    use_vla(10);       // これは安全
    // use_vla(100000000); // 非常に大きな配列を確保するとスタック不足の危険
    return 0;
}
実行結果
arr[0] = 0, arr[9] = 9

スタックサイズには上限があります。

OSや設定にもよりますが、例えば1MB〜数MB程度であることが多いです。

非常に大きな可変長配列をローカル変数として宣言すると、一気にスタックを使い切ってしまい、スタックオーバーフローを引き起こします

大きな配列や長期的に使うデータは、ヒープ(動的メモリ)で確保することを検討してください。

具体的にはmalloccallocなどを使います。

引数・戻り値とABI(呼び出し規約)の関係

関数呼び出しの裏側では、ABI(Application Binary Interface)と呼ばれる取り決めに従って、引数の受け渡しや戻り値の返却が行われます。

これはコンパイラやCPUアーキテクチャ、OSによって異なりますが、概ね次のようなルールがあります。

  • いくつかの引数はレジスタで渡される
  • それ以外の引数はスタックに積まれる
  • 戻り値は特定のレジスタ(例えばx86_64ならRAX)で返される
  • 誰がどのレジスタを保存・復元するか(呼び出し側か呼び出される側か)

通常のCプログラミングでは、ABIの細かい仕様を意識せずにコーディングできますが、次のような場合には重要になります。

  • Cとアセンブリを混在させるとき
  • 異なるコンパイラや言語間でバイナリ互換を保ちたいとき
  • 高度な最適化やパフォーマンスチューニングを行うとき

また、可変長引数関数(printfなど)では、引数の数や型をランタイム時に解釈するため、ABI上も特別な扱いになることがあります。

まとめ

本記事では、C言語における関数呼び出しの基本から、引数・戻り値・スタックの動作、そしてよくある落とし穴までを一通り解説しました。

「Cの引数はすべて値渡し」「配列はポインタに変換される」「ローカル変数のアドレスは返さない」「スタックの寿命とサイズを意識する」といったポイントを押さえておけば、多くのバグを未然に防ぐことができます。

実際のコードを書きながら、本記事のサンプルと図解を思い出し、関数呼び出しの裏側で何が起きているかをイメージできるようになると、Cプログラムの理解と品質が一段と向上します。

クラウドSSLサイトシールは安心の証です。

URLをコピーしました!