閉じる

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

C言語の関数呼び出しは基本がわかれば怖くありませんが、値渡しとポインタ(いわゆる参照渡し風)を混同すると予期せぬ挙動になります。

本記事ではC言語は関数引数がすべて値渡しであることを出発点に、参照渡しとの違い、配列や構造体での落とし穴、対策パターンまで丁寧に解説します。

C言語の関数呼び出しは値渡しのみ

C言語では関数引数は常に値渡しです。

つまり、呼び出し側から渡された値のコピーが関数に届けられます。

ポインタを渡す場合でもポインタという値(アドレス)のコピーが渡される点をまず押さえておきます。

値渡しの仕組み(引数はコピー)

関数の仮引数は、呼び出し時に実引数のコピーで初期化されます。

そのため、関数内で仮引数を変更しても、呼び出し側の変数には影響しません。

C言語
#include <stdio.h>

// x は「呼び出し側 a のコピー」
void add_ten(int x) {
    x += 10; // コピー側だけが変わる
    printf("add_ten内: x = %d\n", x);
}

int main(void) {
    int a = 5;
    add_ten(a);
    printf("main側: a = %d\n", a); // 変わらない
    return 0;
}
実行結果
add_ten内: x = 15
main側: a = 5

この例のポイントは「引数はコピー」という一点に尽きます。

コピーをいくら書き換えても、元の変数は変化しません。

呼び出し側の変数は変わらない

変数を直接いじったつもりでも、実際にはコピーをいじっているだけなので元の値は変わりません。

C言語
#include <stdio.h>

void set_zero(int x) {
    x = 0; // コピーを0にしているだけ
}

int main(void) {
    int a = 7;
    set_zero(a);
    printf("a = %d\n", a); // 7のまま
    return 0;
}
実行結果
a = 7

関数内で呼び出し元の変数を変えたいなら、アドレス(ポインタ)を使う必要があります。

これについては後半で詳しく扱います。

戻り値で結果を返す基本パターン

値渡しでは呼び出し元を直接変えられないため、「戻り値で結果を返す」のが基本パターンです。

新しい値を戻り値として返し、呼び出し元で受け取って代入します。

C言語
#include <stdio.h>

int add(int a, int b) {
    // a, b はコピーだが、結果を戻り値で返す
    return a + b;
}

int main(void) {
    int x = 10, y = 32;
    int sum = add(x, y);
    printf("sum = %d\n", sum);
    return 0;
}
実行結果
sum = 42

「戻り値で返す」設計は副作用が少なく、読みやすいコードになりやすいという利点があります。

ローカル変数の寿命とアドレスの扱い

関数内の通常の変数(自動記憶域期間)は関数を抜けると寿命が尽きるため、そのアドレスを返すのは未定義動作になります。

C言語
// 実行しないこと: 未定義動作の例
#include <stdio.h>

int* bad_address(void) {
    int local = 42;          // 関数終了で消える
    return &local;           // ダングリングポインタ(危険)
}

int main(void) {
    int *p = bad_address();  // ここで p は無効なアドレス
    // printf("%d\n", *p);   // 未定義動作。実行しないこと。
    return 0;
}

安全な代替は次のいずれかです。

  • 戻り値で値そのものを返す
  • 呼び出し側が用意したバッファ(ポインタ)に書き込む
  • 動的メモリ確保して返す(使い終わったらfree)
  • static変数のアドレスを返す(ただし再入性や並行性に注意)
C言語
#include <stdio.h>
#include <stdlib.h>

int make_value(void) {
    int v = 42;
    return v; // 値で返す(安全)
}

void fill_out_param(int *out) {
    if (out) *out = 42; // outに書き込む
}

int* make_heap_value(void) {
    int *p = malloc(sizeof *p);
    if (p) *p = 42;
    return p; // 動的確保して返す(呼び出し側でfree)
}

int* static_address(void) {
    static int s = 42; // 静的記憶域期間
    return &s;         // 生存期間はプログラム終了まで
}

int main(void) {
    int a = make_value();
    printf("a = %d\n", a);

    int b;
    fill_out_param(&b);
    printf("b = %d\n", b);

    int *c = make_heap_value();
    if (c) {
        printf("c = %d\n", *c);
        free(c);
    }

    int *d = static_address();
    printf("d = %d\n", *d);

    return 0;
}
実行結果
a = 42
b = 42
c = 42
d = 42

上記サンプルコードはC++コンパイラでコンパイルするとエラーになります(void*の暗黙変換がC++では認められていない)。

必ずgccなどC言語用のコンパイラを使用しましょう。

「値で返す」か「呼び出し側が用意した記憶域へ書く」が基本であり、ローカル変数のアドレスを返さないことが重要です。

参照渡しとの違いとC言語のやり方

C++などの「参照渡し」と比較すると、Cの立ち位置が明確になります。

参照渡しとは(概念の比較)

参照渡しは、呼び出し側の変数そのものを別名で扱える仕組みです。

一方Cは言語仕様として参照を持たず、すべて値渡しです。

以下は概念比較の表です。

観点値渡し(C)参照渡し(C++など)
関数に渡るもの値のコピー変数そのもの(別名)
関数内書き換え呼び出し元に影響しない呼び出し元が変わる
記述方法通常の型(例: int)参照型(例: int&)
Cでの実現法ポインタの値渡しで間接参照言語機能として参照

Cで参照渡し風にしたい場合はポインタを使うのが定石です。

C言語はポインタの値渡しで実現

Cでは「ポインタ(アドレス)の値渡し」により、関数内から呼び出し側のオブジェクトを間接的に操作します。

ここで重要なのはポインタ自体は値渡しであり、*pを通じて対象物を書き換える点です。

C言語
#include <stdio.h>

void increment(int *p) {
    if (p) {
        (*p)++; // アドレス先の値を変更 => 呼び出し元に反映
    }
}

int main(void) {
    int a = 10;
    increment(&a);
    printf("a = %d\n", a); // 11
    return 0;
}
実行結果
a = 11

swapが動かない理由(値渡しの落とし穴)

「値渡し」ゆえに、以下のようなswapは動きません。

a, bはコピーなので、関数内だけで入れ替わります。

C言語
#include <stdio.h>

void swap_bad(int a, int b) {
    int t = a; a = b; b = t; // コピーの交換
}

int main(void) {
    int x = 1, y = 2;
    swap_bad(x, y);
    printf("x = %d, y = %d\n", x, y); // 変わらない
    return 0;
}
実行結果
x = 1, y = 2

「変えたい対象のアドレス」を渡す必要があります。

ポインタ渡しでswapを実装

正しいswapはポインタでアドレスを受け取り、間接参照で値を入れ替えます。

C言語
#include <stdio.h>

void swap(int *a, int *b) {
    if (!a || !b) return;
    int t = *a; *a = *b; *b = t;
}

int main(void) {
    int x = 1, y = 2;
    swap(&x, &y);
    printf("x = %d, y = %d\n", x, y); // 入れ替わる
    return 0;
}
実行結果
x = 2, y = 1

「値を変えたいならポインタ」「値を読みたいだけなら値渡し」という切り替えが基本です。

値渡しの落とし穴と対策

値渡しは明快ですが、配列や構造体、文字列では落とし穴があります。

ここでは典型例と健全な対処をまとめます。

配列の引数はポインタに退化

関数引数に配列を書いても、配列はポインタに退化します。

そのため配列全体のサイズや長さ情報は失われる点に注意が必要です。

C言語
#include <stdio.h>

void show_in_func(int a[5]) { // 実際の型は int* に等価
    printf("関数内 sizeof(a) = %zu (ポインタの大きさ)\n", sizeof a);
    a[0] = 99; // 要素は書き換わる(同じ配列領域を指す)
}

int main(void) {
    int a[5] = {1,2,3,4,5};
    printf("main側 sizeof(a) = %zu (配列全体)\n", sizeof a);
    show_in_func(a);
    printf("a[0] = %d\n", a[0]); // 99 に変化
    return 0;
}
実行結果
出力例(64bitの一例)
main側 sizeof(a) = 20 (配列全体)
関数内 sizeof(a) = 8 (ポインタの大きさ)
a[0] = 99

配列長が必要なときは長さも別引数で渡すか、配列を構造体に包んで長さとセットで扱うのが安全です。

文字列(char配列)とconstの扱い

Cの文字列はヌル終端されたchar配列です。

文字列リテラルは書き換え不可なので、const charで受けるのが原則です。

一方、呼び出し側のバッファ内容を変更する関数はcharで受けます。

C言語
#include <stdio.h>
#include <ctype.h>

// 読むだけ => const char*
void print_msg(const char *s) {
    if (s) printf("%s\n", s);
}

// 書き換える => char*
void to_upper_inplace(char *s) {
    if (!s) return;
    for (; *s; ++s) {
        *s = (char)toupper((unsigned char)*s);
    }
}

int main(void) {
    const char *lit = "Hello, world"; // リテラル(不変)
    print_msg(lit);

    // to_upper_inplace(lit); // だめ: リテラルは書き換え不可

    char buf[] = "mutable";
    to_upper_inplace(buf); // バッファは書き換え可能
    printf("%s\n", buf);

    return 0;
}
実行結果
Hello, world
MUTABLE

「書き換えない引数はconst」「書き換える可能性があるものは非const」を徹底すると、バグと未定義動作を避けられます。

構造体の値渡しは全体コピー(性能注意)

構造体を値渡しすると全体が丸ごとコピーされます。

大きな構造体ではコストになりますし、関数内での変更は元に反映されません。

C言語
#include <stdio.h>

typedef struct {
    int data[1000]; // 大きめ
} Big;

void init_by_value(Big b) {      // 全体コピーが発生
    b.data[0] = 123;             // 呼び出し元は変わらない
}

void init_by_pointer(Big *b) {   // アドレスだけ渡す
    if (b) b->data[0] = 456;     // 呼び出し元に反映
}

int main(void) {
    Big x = {0};

    printf("sizeof(Big) = %zu\n", sizeof(Big));
    init_by_value(x);
    printf("after by_value: x.data[0] = %d\n", x.data[0]); // 0のまま

    init_by_pointer(&x);
    printf("after by_pointer: x.data[0] = %d\n", x.data[0]); // 456

    return 0;
}
実行結果
sizeof(Big) = 4000
after by_value: x.data[0] = 0
after by_pointer: x.data[0] = 456

大きなデータはポインタで渡すのが一般的です。

読み取り専用ならconst Big*を用いると意図が明確になります。

関数内配列のアドレスを返すのは危険

関数内配列は関数終了時に寿命が尽きるため、その配列の先頭アドレスを返すのは危険です。

C言語
// 実行しないこと: 未定義動作の例
int* make_array_bad(void) {
    int a[3] = {1,2,3}; // ローカル配列(関数終了で消える)
    return a;           // ダングリングポインタ(危険)
}

安全策は前述の通りです。

呼び出し側がバッファを用意する、動的確保して返す、staticを使う(注意付き)などです。

ポインタ自体を変更するなら二重ポインタ

ポインタ自体を新しいアドレスに差し替えたい場合、ポインタも値渡しのため二重ポインタが必要です。

単なるint*引数にp = 新アドレス;としても、呼び出し元のポインタは変わりません。

C言語
#include <stdio.h>
#include <stdlib.h>

void reassign_bad(int *p) {
    // p は呼び出し元ポインタの「コピー」
    int *tmp = malloc(3 * sizeof *tmp);
    if (tmp) {
        tmp[0] = 10; tmp[1] = 20; tmp[2] = 30;
    }
    p = tmp; // 呼び出し元のポインタは変わらない(無効)
}

void reassign_good(int **pp) {
    // pp 経由で呼び出し元のポインタを書き換える
    int *tmp = malloc(3 * sizeof *tmp);
    if (tmp) {
        tmp[0] = 1; tmp[1] = 2; tmp[2] = 3;
    }
    *pp = tmp; // 呼び出し元に反映
}

int main(void) {
    int *arr = NULL;

    reassign_bad(arr);  // 効果なし
    printf("after bad: arr = %p\n", (void*)arr);

    reassign_good(&arr); // arr が新しいアドレスに更新される
    if (arr) {
        printf("after good: arr = %p, [%d,%d,%d]\n",
               (void*)arr, arr[0], arr[1], arr[2]);
        free(arr);
    }
    return 0;
}
実行結果
after bad: arr = (nil)
after good: arr = 0x55f2d8..., [1,2,3]

「指す先の値を変える」なら単一ポインタ、「ポインタそのものを変える」なら二重ポインタと覚えてください。

使い分けと設計指針(値渡し/ポインタ渡し)

最後に、値渡しとポインタ渡しの選び方を実装とAPI設計の観点で整理します。

小さな値は値渡しで明快に

整数や浮動小数点など小さなスカラー値は値渡しでシンプルに扱うのが読みやすく、安全です。

戻り値で結果を返せる形に設計すると副作用が減ります。

例えばint clamp(int x, int lo, int hi)のような関数は値渡しが最適です。

大きなデータや出力はポインタ渡し

配列、構造体、バッファ出力などではポインタ渡しが有利です。

入力専用ならconst T、出力ならT、入出力ならT*を使い、必要なら長さも別引数で渡します。

メモリ割り当てを行ってポインタ自体を返すなら二重ポインタを検討します。

副作用を最小化するAPI設計と命名

副作用がある関数は呼び出し側の予想とズレやすいため、可能な限り戻り値で返す純粋な形にし、副作用が避けられない場合は命名で意図を明確化します。

例えばfill_bufferswap_valuesのように、バッファを書き換える、値を入れ替えるといった意図が伝わる名前にします。

constで意図を明確に

constは「変更しない」という契約を表現します。

読み取り専用の入力はconst、出力や入出力では外し、ポインタレベルにも注意します。

C言語
#include <stdio.h>

// 入力のみ: 配列内容は読めるが変えない
int sum_array(const int *arr, size_t n) {
    int s = 0;
    for (size_t i = 0; i < n; ++i) s += arr[i];
    return s;
}

// 入出力: 配列内容を更新する
void scale_array(int *arr, size_t n, int k) {
    for (size_t i = 0; i < n; ++i) arr[i] *= k;
}

int main(void) {
    int a[5] = {1,2,3,4,5};
    printf("sum = %d\n", sum_array(a, 5));
    scale_array(a, 5, 2);
    printf("a[0] = %d\n", a[0]);
    return 0;
}
実行結果
sum = 15
a[0] = 2

constはコンパイル時に意図違反を検出する助けになり、バグの早期発見に有効です。

まとめ

本記事では、C言語の関数引数は常に値渡しであることを軸に、参照渡しとの違い、配列のポインタ退化、文字列とconst、構造体コピーのコスト、ローカルの寿命、そして二重ポインタの必要性まで段階的に解説しました。

重要なのは「何を値で返すか」「どの記憶域を誰が所有し、どこで変更するか」を設計段階で明確にすることです。

小さな値は値渡しでシンプルに、大きなデータや出力はポインタ(必要なら二重ポインタ)で扱い、constで契約を表現すれば、落とし穴を避けつつ読みやすく安全なCコードを書けます。

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

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

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

URLをコピーしました!