構造体を関数に渡す方法には大きく分けて「値渡し」と「ポインタ渡し」があります。
本記事では、違いと選び方を初心者向けに丁寧に解説します。
コピーが発生するのか、元のデータが変更されるのか、そしてパフォーマンスの考え方まで、動くサンプルと実行結果で理解を深めます。
構造体を関数に渡す基本
値渡しとポインタ渡しの違い
構造体を関数に渡すとき、最初に理解すべきなのは「値渡しは構造体の中身そのものがコピーされる」「ポインタ渡しは構造体の場所(アドレス)が渡される」という点です。
どちらも正解ですが、用途と意図に応じて使い分ける必要があります。
以下に違いを整理します。
観点 | 値渡し | ポインタ渡し |
---|---|---|
渡すもの | 構造体の値そのもの | 構造体のアドレス |
コピー | あり(全メンバー分) | なし(アドレスのみコピー) |
関数内の変更 | 呼び出し元に影響しない | 呼び出し元の構造体が変わる |
演算子 | . でメンバーアクセス | -> でメンバーアクセス |
呼び出し | func(s) | func(&s) |
主な利点 | シンプルで安全、副作用がない | 大きなデータでも高速に渡せる、更新が簡単 |
主な注意点 | 大きい構造体はコピーが重い | NULL参照に注意、意図しない書き換えに注意 |
値渡しはコピー、ポインタ渡しはアドレス
値渡しは「複製を渡す」イメージです。
関数の引数として受け取った構造体は、呼び出し元の構造体とは別物になります。
対してポインタ渡しは「場所への案内票(アドレス)を渡す」イメージです。
関数の中で参照しているのは呼び出し元の実体なので、書き換えるとそのまま呼び出し元が変化します。
初心者が最初に覚えるポイント
- 読み取りだけなら値渡しが基本です。関数内で安心して触れます。
- 変更したいならポインタ渡しです。
&
でアドレスを渡し、->
でアクセスします。 - 大きい構造体はコピーがコストです。サイズが大きければポインタ渡しを検討します。
構造体の値渡し
値渡しとは
値渡しは、関数呼び出し時に構造体の「中身」が丸ごとコピーされる方法です。
引数として受け取った側はローカルなコピーを扱うので、メンバーアクセスは.
演算子を使い、元のデータに影響はありません。
関数内の変更は呼び出し元に影響しない
値渡しでは、関数の中でメンバーを書き換えても、それは「コピーに対する変更」にすぎません。
関数が終わればコピーは破棄され、呼び出し元は元のままです。
「変更が外に漏れない」のが大きな特徴です。
小さな構造体に向くメリット
構造体が小さい場合(例えば数個の整数や小さな配列程度)は、コードが読みやすく、安全で、余計なNULLチェックも不要です。
読み取り専用の処理に適しています。
パフォーマンスの注意点
一方で、大きな構造体を頻繁に値渡しすると無視できないコピーコストが発生します。
スタックにコピーされるため、メモリアクセスも増えます。
サイズが大きい構造体はポインタ渡しで回避するのが一般的です。
値渡しが「コピー」であることを、アドレス表示とともに確認します。
関数内のアドレス(&p
)が呼び出し元(&taro
)と異なる点に注目してください。
#include <stdio.h>
#include <string.h>
// 小さめの構造体を用意します
typedef struct {
char name[32];
int age;
} Person;
// 値渡し: Personがコピーされて渡される
void celebrate_birthday_value(Person p) {
// 関数内では「コピー」を操作している
printf("[value] &p = %p (local copy)\n", (void*)&p);
// コピーに対して変更を加える
p.age++;
// バッファサイズを指定して安全に書き換える
snprintf(p.name, sizeof(p.name), "Mr. %s", p.name);
// この表示は関数内のコピーの内容
printf("[value] inside: name=%s, age=%d\n", p.name, p.age);
}
int main(void) {
Person taro = {"Taro", 20};
printf("[main ] &taro = %p (original)\n", (void*)&taro);
printf("before: name=%s, age=%d\n", taro.name, taro.age);
// 値渡し。taroの中身がコピーされて関数へ渡る
celebrate_birthday_value(taro);
// 呼び出し元は変化しない
printf("after : name=%s, age=%d\n", taro.name, taro.age);
return 0;
}
[main ] &taro = 0x7ffc12345678 (original)
before: name=Taro, age=20
[value] &p = 0x7ffc12345610 (local copy)
[value] inside: name=Mr. Taro, age=21
after : name=Taro, age=20
よくあるミスと対策
値渡しでは「関数内で変えたのに元が変わらない」という誤解が起きやすいです。
元を変更したいならポインタ渡しにするか、更新したコピーを返り値で受け取るようにします。
// 値渡しで更新結果を返すパターン
Person set_age_value(Person p, int new_age) {
p.age = new_age; // コピーに対する変更
return p; // 変更済みのコピーを返す
}
// 呼び出し側
// taro = set_age_value(taro, 30); // 返り値を必ず受け取ること
返り値を捨てると変更が反映されません。
また、値渡しでは.
演算子を使う点も混同しないようにしましょう。
構造体のポインタ渡し
ポインタ渡しとは
ポインタ渡しは、構造体がメモリ上にある場所(アドレス)を渡す方法です。
関数はそのアドレスをたどって元のデータに直接アクセスします。
メンバーアクセスには->
演算子を使います。
関数内の変更が呼び出し元に反映される
同じ実体を共有しているため、関数内での代入や変更がそのまま呼び出し元にも反映されます。
意図した更新処理に向いています。
大きな構造体に向くメリット
ポインタ渡しでは構造体本体のコピーが発生しないため、サイズが大きい構造体でもオーバーヘッドが小さいです。
更新が必要な処理や、頻繁に呼び出す性能クリティカルな箇所に適しています。
NULLチェックの基本
NULLポインタを参照すると実行時エラーになります。
受け取ったポインタは最初にNULLチェックする習慣をつけましょう。
void update(Person *p) {
if (p == NULL) {
// 何もしない、もしくはエラーを返す
return;
}
// 以降、安全に p->... にアクセスできる
}
ポインタ渡しで元の構造体が更新される様子を確認します。
#include <stdio.h>
#include <string.h>
typedef struct {
char name[32];
int age;
} Person;
// ポインタ渡し: 元のデータを直接更新する
void celebrate_birthday_ptr(Person *p) {
if (p == NULL) {
fprintf(stderr, "[ptr ] NULL pointer received\n");
return;
}
// 関数内のポインタ値は「元の場所」を指す
printf("[ptr ] p = %p (points to original)\n", (void*)p);
// 元のデータを直接更新
p->age++;
// 名前の末尾に " Jr." を安全に付与
size_t len = strlen(p->name);
if (len + 4 < sizeof(p->name)) { // 4は" Jr."の長さ
strncat(p->name, " Jr.", sizeof(p->name) - len - 1);
}
printf("[ptr ] inside: name=%s, age=%d\n", p->name, p->age);
}
int main(void) {
Person taro = {"Taro", 20};
printf("[main ] &taro = %p (original)\n", (void*)&taro);
printf("before: name=%s, age=%d\n", taro.name, taro.age);
// ポインタ渡し。&taro でアドレスを渡す
celebrate_birthday_ptr(&taro);
// 呼び出し元のデータが更新されている
printf("after : name=%s, age=%d\n", taro.name, taro.age);
return 0;
}
[main ] &taro = 0x7ffc12345678 (original)
before: name=Taro, age=20
[ptr ] p = 0x7ffc12345678 (points to original)
[ptr ] inside: name=Taro Jr., age=21
after : name=Taro Jr., age=21
よくあるミスと対策
- & を付け忘れると値渡しになり、変更が反映されません。
func(&obj)
の形を習慣化しましょう。 - ポインタからのメンバーアクセスに
.
を使ってしまうミス。p->member
を使います。 - NULLや未初期化ポインタの参照は危険です。最初に
if (p == NULL) return;
。 - ローカル変数のアドレスを返してしまうのはNGです。関数終了とともに無効になります。
値渡しとポインタ渡しの使い分け
読み取りだけなら値渡し
構造体を読むだけで、更新する必要がない場面では値渡しが分かりやすく安全です。
ただしサイズが大きい場合は、const
付きポインタ(const StructType *p
)で「読み取り専用」を表すのも有効です。
値を更新したいならポインタ渡し
関数内で書き換えた結果を呼び出し元に反映したいときはポインタ渡しを選びます。
副作用を伴うため、関数名に意図を込めると読み手に優しいです。
サイズが大きいならポインタ渡し
メンバー数が多い、大きな配列を含むなどでコピーコストが気になる構造体はポインタ渡しが向きます。
一般に「数十バイトを超えてくるなら検討」くらいの感覚で、最終的には計測で判断します。
関数名や引数名で意図を示す
意図が伝わる命名はバグの予防につながります。
// 読み取り専用(大きいならconstポインタ)
void user_print(const struct User *user);
// 新しい値を返す(値渡しで副作用なし)
struct User user_with_age(struct User user, int new_age);
// その場で更新(ポインタ渡しで副作用あり)
void user_set_age(struct User *user, int new_age);
呼び出し例で比較
以下は「値渡し」と「ポインタ渡し」を同じプログラム内で比べる例です。
値渡しは外に影響しない、ポインタ渡しは外に反映されることが一目で分かります。
#include <stdio.h>
// 小さな構造体
typedef struct {
int count;
} Counter;
// 値渡し: コピーを受け取り、外には影響しない
void add_value(Counter c, int delta) {
c.count += delta; // コピーに加算
printf("[value] inside: count=%d\n", c.count);
}
// ポインタ渡し: 元の実体を更新
void add_inplace(Counter *c, int delta) {
if (c == NULL) return;
c->count += delta;
printf("[ptr ] inside: count=%d\n", c->count);
}
int main(void) {
Counter c = {10};
printf("start : count=%d\n", c.count);
// 値渡し(外には影響しない)
add_value(c, 5);
printf("after value : count=%d\n", c.count);
// ポインタ渡し(外へ反映)
add_inplace(&c, 5);
printf("after pointer: count=%d\n", c.count);
return 0;
}
start : count=10
[value] inside: count=15
after value : count=10
[ptr ] inside: count=15
after pointer: count=15
まとめ
本記事では、C言語における構造体の「値渡し」と「ポインタ渡し」の違いを、概念、コード、実行結果で解説しました。
値渡しはコピーで安全だが大きいと重い、ポインタ渡しはアドレスを渡して更新や高速化に向くがNULLや副作用に注意というのが要点です。
読み取り専用なら値渡し、更新や大型構造体ならポインタ渡し、という基本指針を持ちつつ、関数名やconst修飾で意図を明確化し、必要に応じて計測で判断してください。
今日のサンプルを自分で書いて確かめることが、理解を定着させる最短ルートです。