閉じる

【C言語】値渡しだと変わらない? 参照渡しとの違いと落とし穴

C言語の関数は原則として値渡しで呼び出されます。

そのため「関数に値を渡したのに呼び出し元の変数が変わらない」という現象が起きます。

本記事では、値渡しの正体(コピー)とタイミング、配列で生じやすい誤解、ポインタを使って参照渡し風に値を書き換える方法、そして典型的な落とし穴と対策を順に解説します。

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

値渡しだと変数が変わらない理由

引数はローカル変数になる

関数が受け取る仮引数は、その関数の内部だけで有効なローカル変数として確保されます。

呼び出し元の変数とは別のメモリ領域を持つため、両者のアドレスが異なります。

アドレスが異なるということは、物理的に別の箱に値が入っていると理解できます。

代入しても呼び出し元に影響しない

ローカル変数に過ぎないため、関数内で代入やインクリメントを行っても、その変更は関数が終了すると同時に消えます。

呼び出し元の元の変数に対して何の変更も行っていないため、当然ながら呼び出し元の値はそのままです。

配列引数で起きる誤解

配列を引数にすると「関数内で配列の中身を変えたら呼び出し元も変わった。

値渡しではないのか」と戸惑うことがあります。

実は、配列は関数の仮引数として受け取るとポインタに暗黙変換(デカイ)されます。

つまり、配列の「先頭要素へのアドレス」が値渡しされるだけです。

アドレス自体はコピー(値渡し)なのですが、そのアドレスが指す先は同じ配列領域なので、中身を書き換えれば呼び出し元の配列に反映されます。

この点が誤解のもとです。

次の例は、配列がポインタに見なされることをサイズの違いで確認しつつ、要素を書き換えると呼び出し元に反映されることを示します。

C言語
#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++など)のような「参照型」はありません。

関数に「参照渡し」をしたいときは、対象変数のアドレス(ポインタ)を値渡しで渡します。

ポインタ自体はコピーですが、コピーされたポインタは同じ対象を指すため、その対象を書き換えれば呼び出し元の変数に反映されます。

アドレスを渡して値を書き換える

変数のアドレスを渡し、関数内で間接参照(演算子*)で中身を変更すれば、呼び出し元の変数を更新できます。

これが実質的な参照渡しの手法です。

ポインタ引数のサンプルコード

値渡しとポインタ渡しの違いを並べて確認します。

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を渡さなければなりません。

&を付け忘れると未定義動作になり、クラッシュや不正な値の読み取りにつながります。

危険な例(絶対にしてはいけません):

C言語
// 危険: &がないため、scanfは不正なアドレスに書き込もうとする
int x;
scanf("%d", x);  // 誤り

正しい例:

C言語
#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チェックを行うのが安全です。

C言語
#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

大きな構造体はポインタ渡しが有利な場合

大きな構造体を値渡しすると、呼び出しのたびに全体がコピーされるため、実行速度やスタック使用量の面で不利になることがあります。

変更が必要なときはもちろん、読み取り中心でもポインタ渡しを検討すると効率的です(ただし、関数内で不用意に書き換えないように設計上の注意が必要です)。

次の例では、値渡しとポインタ渡しで挙動とコストの違い(コピーの有無)を観察します。

C言語
#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プログラムへとつながります。

この記事を書いた人
エーテリア編集部
エーテリア編集部

プログラミングの基礎をしっかり学びたい方向けに、C言語の基本文法から解説しています。ポインタやメモリ管理も少しずつ理解できるよう工夫しています。

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

URLをコピーしました!