- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- DB保存
同じ書き方でも、何が起きるかはその言語が約束した分しか決まっていません。今回は「変数がいつまで生きているか」の約束を見ます。
まず結論
変数をいつまで使ってよいか(寿命)は言語が約束し、スタックとヒープのどちらに置くか(置き場所)は処理系が選びます。
局所変数のアドレスを関数の外へ返す同じ書き方が、C では寿命の外を指す未定義動作になり、gcc 13.5.0 は警告のうえ NULL を返すコードに置き換えました。Go は変数をヒープへ移して動かし、Rust はコンパイルを拒否します。
C で `int *make(void) { int x = 42; return &x; }` の戻り値を受け取り、`*p` を読みます。gcc 13.5.0(-O0、最適化なし)でコンパイルして実行すると、何が起きたでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
呼び出しスタックと、寿命の約束
関数を呼ぶと、その1回の呼び出しのための区画が積まれます。これをフレーム、積み重なりを呼び出しスタックと呼びます。局所変数はこの区画に置かれうる値です。関数が戻ると区画は取り除かれ、次に呼ばれた関数が同じ場所を使いえます。後に呼んだ関数ほど先に戻るので、第11期で見た「直前に入れたものから取り出す」スタックの規則がそのまま当てはまります。呼び出しの出入りに縛られない置き場はヒープです。
ここで2つの問いを分けます。**いつまで使ってよいか(寿命)**と、**どこに置くか(置き場所)**です。C 規格は寿命をこう定めます。
The lifetime of an object is the portion of program execution during which storage is guaranteed to be reserved for it. An object exists, has a constant address, and retains its last-stored value throughout its lifetime. If an object is referred to outside of its lifetime, the behavior is undefined.
(N3220 §6.2.4 p2。脚注番号は除去)。値が保たれるのは寿命の間だけで、寿命の外を参照すれば未定義動作です。「区画は消えても中身はしばらく残る」は、規格が約束していない話です。
- ・寿命は言語が約束する。置き場所をスタックにするかヒープにするかは処理系が選ぶ
gcc 13.5.0 は、返す値を 0 に差し替えた
以下は Docker 上の aarch64、gcc 13.5.0 での実測です。
/w/d.c: In function 'make':/w/d.c:2:38: warning: function returns address of local variable [-Wreturn-local-addr]2 | int *make(void) { int x = 42; return &x; }| ^~compile_exit=0
警告は出ますが、コンパイルは通ります。main は p を %p で表示し、続けて *p を表示します。実行すると次のとおりです(パイプ経由では異常終了で printf の出力が失われたため、標準出力を無バッファにして再実行した生ログ)。
### run 1 gcc:13 -O0p=(nil)Segmentation faultrun_exit=139
p は NULL で、*p の参照で落ちます。139 は、シェルが致命的なシグナル N を 128+N で表す規約(第10期で Bash の規約として見たもの)で 128+11、11 は SIGSEGV です。42 は表示されません。make の中身を見ると理由が分かります。
make:.LFB0:sub sp, sp, #16mov w0, 42str w0, [sp, 12]mov x0, 0add sp, sp, 16ret
42 はスタックに書いていますが、戻り値のレジスタに入れているのは 0 です。-O2 では mov x0, 0 と ret の2命令だけになり、x86_64(同じ 13.5.0)でも -O0 が movl $0, %eax、-O2 が xorl %eax, %eax で、4通りすべて p=(nil) と exit 139 でした。
最適化の前の段階で決まっています。-fdump-tree-original で最適化前の木を出すと、return 文がすでに書き換わっています。
;; Function make (null){int x = 42;return &x;, 0B;}
&x を評価したうえで 0 を返す、という形です。gcc 13 ブランチの C フロントエンド(c-typeck.cc の c_finish_return)を読むと、警告の直後で 0 を連結しています。
warning_at (loc, OPT_Wreturn_local_addr,"function returns address of local variable");tree zero = build_zero_cst (TREE_TYPE (res));t = build2 (COMPOUND_EXPR, TREE_TYPE (res), t, zero);
-Wno-return-local-addr で警告を消してもコードは同じ mov x0, 0 でした。一方、GCC 13.5.0 の文書はこのオプションを「警告しない」と説明するだけで、NULL への置き換えには触れていません。NULL は文書の約束ではなく、実測とソースから分かった gcc 13 の実装の選択です。 規格は未定義動作とするだけなので、clang など他の処理系や他の版で同じになるとは言えません(未測)。
gcc 13.5.0 が -O0 でも NULL を返すコードを出した理由として、確かめられた範囲で正しいのはどれですか。
参照がある限り生きると約束する言語では、置き場所は処理系が決める
C では、局所変数は呼び出しの間だけ生きる、という寿命を守るのはプログラマです。寿命を「参照がある限り」と約束する言語では事情が変わります。Go FAQ です。
How do I know whether a variable is allocated on the heap or the stack? From a correctness standpoint, you don’t need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.
寿命は「参照がある限り」と言語が約束し、置き場所は実装が選びます。ただし直後に The storage location does have an effect on writing efficient programs. と続き、コンパイラの方針も書かれています。
When possible, the Go compilers will allocate variables that are local to a function in that function’s stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors.
寿命の約束を守るために、処理系が置き場所を変えます。 関数の外へ出ないと証明できればスタック、できなければヒープです。この判定がエスケープ解析です。
同じ形を Go と Rust で書く
go1.27.1 で、&t を返す make1 と、new(T) を関数の中だけで使う sum を並べ、go build -gcflags=-m で判定を出させました。
# command-line-arguments./main.go:4:6: can inline make1./main.go:5:6: can inline sum./main.go:6:25: inlining call to make1./main.go:6:52: inlining call to sum./main.go:6:40: inlining call to fmt.Println./main.go:4:19: moved to heap: t./main.go:5:26: new(T) does not escape./main.go:6:40: ... argument does not escape./main.go:6:44: p.v[0] escapes to heap./main.go:6:52: ~r0 escapes to heap./main.go:6:52: new(T) does not escapebuild_exit=042 1run_exit=0
&t を返す t はヒープへ移され、42 が読めます。new(T) で作っても外へ出なければ does not escape です。「new で作ればヒープ」ではありません。 なお、この文言は -m の診断で仕様ではなく、FAQ も In the current compilers と限定しています。
Rust(rustc 1.98.1)は同じ形を通しません。
error[E0515]: cannot return reference to local variable `x`--> /w/main.rs:1:41|1 | fn make() -> &'static i32 { let x = 42; &x }| ^^ returns a reference to data owned by the current function
Rust Book は the compiler will ensure that the data will not go out of scope before the reference to the data does と書きます。寿命の約束を、コンパイル時の検査で守らせる方式です(Book の例 fn dangle() -> &String は寿命の指定が無いため、別の E0106 になります)。
Go で `t := new(T)` と書いた変数について、go1.27.1 の `-gcflags=-m` はどう報告しましたか。
C(gcc 13.5.0)
局所変数 x の寿命は make の呼び出しで尽きます。寿命の外を指すのは未定義動作で、gcc 13.5.0 は警告を出したうえで 0 を返すコードにしました。
int *make(void) { int x = 42; return &x; }-> 警告 [-Wreturn-local-addr]、p=(nil)、exit 139
Go(go1.27.1)
t は参照がある限り生きると言語が約束しています。コンパイラは外へ出る t をヒープに置き、呼び出し側は 42 を読めました。
func make1() *T { t := T{}; t.v[0] = 42; return &t }-> moved to heap: t、42 を表示、exit 0
同じ形のコードが、一方では落ち、一方では動きます。何が違うのでしょうか。
「x や t をいつまで使ってよいか」を、誰が決めているかを比べてください。
この章のまとめ
- 寿命(いつまで使ってよいか)は言語が約束し、置き場所(スタックかヒープか)は処理系が選ぶ。
- 関数を呼ぶとフレームが積まれ、戻ると取り除かれる。C の局所変数の寿命は呼び出しの間だけで、寿命の外の参照は未定義動作。
- gcc 13.5.0 は局所変数のアドレスを返す式を、C フロントエンドで「0 を返す」に書き換えた。-O0/-O2、aarch64/x86_64 のすべてで
p=(nil)と exit 139。文書の約束ではなく実装の選択で、他の処理系は未測。 - Go は参照がある限り生きると約束し、外へ出る変数をヒープへ移す(
moved to heap: t)。new(T)でも外へ出なければdoes not escape。 - Rust は寿命を越える参照をコンパイル時に拒否する(E0515)。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
Go FAQ は、変数がスタックとヒープのどちらに置かれるかを知る必要について何と書いていますか。
用語集(5語)
- 寿命 (lifetime)
- 変数やオブジェクトを使ってよい期間。C 規格は、記憶域が確保されていることが保証される実行の部分と定義する。言語が決める約束。
- 呼び出しスタックとフレーム
- 関数を呼ぶたびに積まれ、戻ると取り除かれる区画の並び。1回の呼び出しの区画をフレームと呼び、局所変数が置かれうる。
- ヒープ
- 呼び出しの出入りに縛られない置き場。Go では GC が回収する。どの変数をここへ置くかは処理系が選ぶ。
- エスケープ解析
- 変数が関数から戻った後も参照されうるかを調べる、コンパイラの解析。Go は参照されないと証明できない変数をヒープに置く。
- ダングリングポインタ
- 寿命の終わった、または解放された領域を指したままのポインタ。
出典(一次ソース)
- Go FAQ — How do I know whether a variable is allocated on the heap or the stack? ↗
- ISO/IEC 9899 working draft N3220 — §6.2.4 Storage durations of objects ↗
- GCC 13.5.0 Manual — Warning Options(-Wno-return-local-addr) ↗
- GCC releases/gcc-13 ブランチ gcc/c/c-typeck.cc(c_finish_return) ↗
- The Rust Programming Language — 4.2 References and Borrowing(Dangling References) ↗