C言語の関数は原則として値渡しで呼び出されます。
そのため「関数に値を渡したのに呼び出し元の変数が変わらない」という現象が起きます。
本記事では、値渡しの正体(コピー)とタイミング、配列で生じやすい誤解、ポインタを使って参照渡し風に値を書き換える方法、そして典型的な落とし穴と対策を順に解説します。
C言語の関数呼び出しは値渡しが基本
値渡しとは(引数は値のコピー)
C言語の関数呼び出しでは、呼び出し側で計算された引数の値が関数の仮引数にコピーされます。
仮引数は関数の中だけで有効な別個の変数(ローカル変数)であり、呼び出し元の変数とは別物です。
この「別の入れ物に同じ値を複製して渡す」のが値渡しの本質です。
コピーが行われるタイミング
値渡しのコピーは、関数へ入る直前に行われると考えると理解しやすいです。
一般的な流れは次の通りです。
引数の式が呼び出し側で評価され、それぞれの結果が仮引数(ローカル変数)の初期値としてコピーされます。
その後、関数本体が実行され、最後に仮引数を含むローカル変数は破棄されます。
したがって、関数内で仮引数を書き換えても、呼び出し元の変数には影響が及びません。
サンプルコード(基本の関数呼び出し)
次の例は、値渡しで引数がコピーされるため、呼び出し元の変数が変わらないことを示します。
アドレスも併せて表示し、別の変数であることを確認します。
#include <stdio.h>
// 値渡し: 引数xは呼び出し元aの「コピー」
void increment(int x) {
printf(" [increment] 受け取ったx=%d\n", x);
printf(" [increment] xのアドレス=%p\n", (void*)&x);
x++; // ここで増えるのは「コピー」のx
printf(" [increment] インクリメント後x=%d\n", x);
}
int main(void) {
int a = 10;
printf("[main] 呼び出し前 a=%d\n", a);
printf("[main] aのアドレス=%p\n", (void*)&a);
increment(a); // aの値(10)がxにコピーされる
printf("[main] 呼び出し後 a=%d\n", a); // aは変わらない
return 0;
}
実行結果(アドレスは環境により異なります):
[main] 呼び出し前 a=10
[main] aのアドレス=0x7ffee9b7c9ac
[increment] 受け取ったx=10
[increment] xのアドレス=0x7ffee9b7c98c
[increment] インクリメント後x=11
[main] 呼び出し後 a=10
値渡しだと変数が変わらない理由
引数はローカル変数になる
関数が受け取る仮引数は、その関数の内部だけで有効なローカル変数として確保されます。
呼び出し元の変数とは別のメモリ領域を持つため、両者のアドレスが異なります。
アドレスが異なるということは、物理的に別の箱に値が入っていると理解できます。
代入しても呼び出し元に影響しない
ローカル変数に過ぎないため、関数内で代入やインクリメントを行っても、その変更は関数が終了すると同時に消えます。
呼び出し元の元の変数に対して何の変更も行っていないため、当然ながら呼び出し元の値はそのままです。
配列引数で起きる誤解
配列を引数にすると「関数内で配列の中身を変えたら呼び出し元も変わった。
値渡しではないのか」と戸惑うことがあります。
実は、配列は関数の仮引数として受け取るとポインタに暗黙変換(デカイ)されます。
つまり、配列の「先頭要素へのアドレス」が値渡しされるだけです。
アドレス自体はコピー(値渡し)なのですが、そのアドレスが指す先は同じ配列領域なので、中身を書き換えれば呼び出し元の配列に反映されます。
この点が誤解のもとです。
次の例は、配列がポインタに見なされることをサイズの違いで確認しつつ、要素を書き換えると呼び出し元に反映されることを示します。
#include <stdio.h>
#include <stddef.h>
void print_array(const int *arr, size_t n) {
// 表示のためのユーティリティ(配列は変更しない)
for (size_t i = 0; i < n; ++i) {
printf("%d%s", arr[i], (i + 1 == n) ? "\n" : " ");
}
}
// 配列引数は「int *arr」と同義。arrは配列の先頭要素のアドレスを指すポインタ。
void set_to_zero(int arr[], size_t n) {
printf(" [set_to_zero] arr(ポインタ)=%p, sizeof(arr)=%zu\n",
(void*)arr, sizeof(arr)); // ここはポインタのサイズ(例: 8バイト)
for (size_t i = 0; i < n; ++i) {
arr[i] = 0; // アドレスの指す先(呼び出し元の配列)を書き換える
}
}
int main(void) {
int a[5] = {1, 2, 3, 4, 5};
printf("[main] a(先頭アドレス)=%p, sizeof(a)=%zu\n",
(void*)a, sizeof(a)); // ここは配列全体のサイズ(例: 20バイト)
printf("[main] 変更前: ");
print_array(a, 5);
set_to_zero(a, 5);
printf("[main] 変更後: ");
print_array(a, 5);
return 0;
}
実行結果(ビット数によりサイズ表示は変化します):
[main] a(先頭アドレス)=0x7ffee9b7c970, sizeof(a)=20
[main] 変更前: 1 2 3 4 5
[set_to_zero] arr(ポインタ)=0x7ffee9b7c970, sizeof(arr)=8
[main] 変更後: 0 0 0 0 0
配列の先頭アドレスそのものは値渡しでコピーされていますが、配列の中身を指しているため、結果として呼び出し元に反映されるのがポイントです。
参照渡し(ポインタ渡し)の基本と違い
C言語に参照渡しはない(ポインタで実現)
C言語には他言語(C++など)のような「参照型」はありません。
関数に「参照渡し」をしたいときは、対象変数のアドレス(ポインタ)を値渡しで渡します。
ポインタ自体はコピーですが、コピーされたポインタは同じ対象を指すため、その対象を書き換えれば呼び出し元の変数に反映されます。
アドレスを渡して値を書き換える
変数のアドレスを渡し、関数内で間接参照(演算子*
)で中身を変更すれば、呼び出し元の変数を更新できます。
これが実質的な参照渡しの手法です。
ポインタ引数のサンプルコード
値渡しとポインタ渡しの違いを並べて確認します。
#include <stdio.h>
void set_zero_by_value(int x) {
// xは呼び出し元のコピー。ここで代入しても呼び出し元には影響しない
x = 0;
}
void set_zero_by_pointer(int *px) {
// pxは「アドレスのコピー」だが、指す先は呼び出し元の変数
printf(" [set_zero_by_pointer] 受け取ったアドレス=%p\n", (void*)px);
*px = 0; // 呼び出し元の変数を書き換える
}
int main(void) {
int a = 42;
printf("[main] 初期 a=%d, &a=%p\n", a, (void*)&a);
set_zero_by_value(a);
printf("[main] 値渡し後 a=%d\n", a); // 変わらない
set_zero_by_pointer(&a);
printf("[main] ポインタ渡し後 a=%d\n", a); // 0に更新される
return 0;
}
[main] 初期 a=42, &a=0x7ffee9b7c99c
[main] 値渡し後 a=42
[set_zero_by_pointer] 受け取ったアドレス=0x7ffee9b7c99c
[main] ポインタ渡し後 a=0
次の表は要点の比較です。
項目 | 値渡し | ポインタ渡し(参照渡し風) |
---|---|---|
渡すもの | 値そのもの | 変数のアドレス(ポインタ) |
呼び出し元への影響 | なし | あり(間接参照で中身を変更) |
シグニチャの例 | void f(int x) | void f(int *px) |
代表例 | 算術計算用の一時値 | scanf 、配列の更新、構造体の効率的操作 |
値渡し/参照渡しの落とし穴と対策
scanfで&を付け忘れる
scanf
は入力値を書き込むために、書き込み先のアドレス(ポインタ)を必要とします。
整数だと%d
に対してint
のアドレス&x
を渡さなければなりません。
&
を付け忘れると未定義動作になり、クラッシュや不正な値の読み取りにつながります。
危険な例(絶対にしてはいけません):
// 危険: &がないため、scanfは不正なアドレスに書き込もうとする
int x;
scanf("%d", x); // 誤り
正しい例:
#include <stdio.h>
int main(void) {
int x;
printf("整数を入力してください: ");
if (scanf("%d", &x) != 1) {
printf("読み取りに失敗しました\n");
return 1;
}
printf("入力されたx=%d\n", x);
return 0;
}
実行結果(例):
整数を入力してください: 123
入力されたx=123
NULLや未初期化ポインタを渡す
ポインタ渡しでは、無効なアドレスを渡してしまうと未定義動作になります。
特に、未初期化ポインタ(どこを指しているか不明)やNULL
を誤って渡すミスが頻出です。
防ぐには、ポインタは必ず初期化し、関数側でもNULL
チェックを行うのが安全です。
#include <stdio.h>
// 安全にインクリメントする。無効なポインタを弾く
void safe_increment(int *px) {
if (px == NULL) {
fprintf(stderr, "エラー: NULLポインタを受け取りました\n");
return;
}
(*px)++;
}
int main(void) {
int *p = NULL; // 明示的にNULLで初期化
safe_increment(p); // エラー扱い
int v = 10;
safe_increment(&v); // 有効なアドレスを渡す
printf("v=%d\n", v);
return 0;
}
エラー: NULLポインタを受け取りました
v=11
大きな構造体はポインタ渡しが有利な場合
大きな構造体を値渡しすると、呼び出しのたびに全体がコピーされるため、実行速度やスタック使用量の面で不利になることがあります。
変更が必要なときはもちろん、読み取り中心でもポインタ渡しを検討すると効率的です(ただし、関数内で不用意に書き換えないように設計上の注意が必要です)。
次の例では、値渡しとポインタ渡しで挙動とコストの違い(コピーの有無)を観察します。
#include <stdio.h>
#include <string.h>
typedef struct {
int data[1000]; // およそ4KB
} Big;
// 値渡し: Big全体がコピーされる。bの変更は呼び出し元に反映されない
void init_by_value(Big b) {
b.data[0] = 999; // コピー側を書き換え
printf(" [by value] &b=%p, sizeof(b)=%zu\n", (void*)&b, sizeof(b));
}
// ポインタ渡し: コピーされるのはアドレスのみ。呼び出し元の実体にアクセス
void init_by_pointer(Big *pb) {
pb->data[0] = 999; // 呼び出し元の実体を書き換え
printf(" [by pointer] pb=%p, sizeof(*pb)=%zu\n",
(void*)pb, sizeof(*pb));
}
int main(void) {
Big x;
memset(&x, 0, sizeof x);
printf("[main] &x=%p, sizeof(x)=%zu\n", (void*)&x, sizeof x);
init_by_value(x); // 大きなコピーが発生
printf("[main] 値渡し後 x.data[0]=%d\n", x.data[0]); // 0のまま
init_by_pointer(&x); // アドレスのみコピー
printf("[main] ポインタ渡し後 x.data[0]=%d\n", x.data[0]); // 999に更新
return 0;
}
[main] &x=0x7ffee9b7c8e0, sizeof(x)=4000
[by value] &b=0x7ffee9b7c6f0, sizeof(b)=4000
[main] 値渡し後 x.data[0]=0
[by pointer] pb=0x7ffee9b7c8e0, sizeof(*pb)=4000
[main] ポインタ渡し後 x.data[0]=999
このように、値渡しは「安全に独立したコピーで扱える」利点がある一方、大きなデータではコストがかかります。
データ量や意図(変更の要否)に応じて、値渡しとポインタ渡しを使い分けることが大切です。
まとめ
C言語の関数呼び出しはすべて値渡しであり、仮引数は呼び出し元とは別のローカル変数としてコピーが作られるため、関数内の代入は呼び出し元に影響しません。
配列引数ではポインタに変換されるため、中身の変更が呼び出し元に反映される点が誤解の元です。
参照渡しを実現したい場合は、対象のアドレス(ポインタ)を値渡しで渡し、間接参照して更新します。
実用面では、scanf
の&
忘れ、NULL
や未初期化ポインタの誤用といった落とし穴に注意し、必要に応じてポインタ渡しで効率よくデータを扱いましょう。
値渡しとポインタ渡しの性質と違いを正しく理解することが、予期しない不具合を防ぎ、読みやすく安全なCプログラムへとつながります。