HN 日本語サマリー

← 一覧へ戻る
プログラミング

GCCのネスト関数実装(C++ラムダ関数との比較)

Implementation of GCC's Nested Functions (vs. C++ Lambdas) (uecker.codeberg.page)

66 pointsby uecker11 コメント

要約

この記事では、GCCにおけるネスト関数の実装方法について解説しています。GCCは、親関数の変数にアクセスするネスト関数を、アクセスされる変数を集めた構造体へのポインタを隠し引数として渡す方法で実装しており、これはC++のラムダ関数の実装と類似しています。

全文翻訳

GCCのネスト関数(C++ラムダ関数との比較)の実装

Martin Uecker, 2026-09-05

はじめに

ここでは、GCCのネスト関数がどのように実装されているかを説明します。ネスト関数のアドレスを取得することについては、ここでは扱いません。このトピックについては、すでにいくつかの以前のブログ記事で議論し、その回避策についても触れました。今回は、親関数の変数をアクセスするために使用される基本的なメカニズムを説明したいと思います。

ネスト関数

まず、非常に簡単な例から始めましょう。

	int foo(int k)
	{
		int bar(int x) { return x + 1; }
		return bar(k);
	}
	

この例では、ネスト関数は親関数の変数を一切アクセスしていません。この場合、ネスト関数は親関数から単純に切り離され、独立した関数としてコンパイルできます。このような関数は、小さなヘルパー関数を定義する場合や、ローカルで型を定義してそれをネスト関数で使用する場合に役立ちます。WG14は現在、staticストレージクラスで定義されたキャプチャしないローカル関数を許可する提案N3884を検討中です。

しかし、ネスト関数が親関数の変数をアクセスする例を考えてみましょう。

	int foo(int k)
	{
		int bar(int x) { return x + k; }
    		return bar(1);
	}
	

実行時、ネスト関数は親関数の変数kを見つける必要があります(ここでは完全に最適化されないと仮定します)。従来、これは親のスタックフレームへのポインタを渡すことで実装されていました。これにより、親のスタックフレームの正しいスロットにある変数にアクセスできました。これらの技術はPASCALや類似の言語で使用されており、x86にはこれをサポートするための特別な命令(enterおよびleave)さえあります。しかし、これは今日、GCCがこの機能を実装する方法ではありません。

GCCでは、ネスト関数は初期のミドルエンドパスで低レベル化されます。このパス中に、ネスト関数によってアクセスされる親のすべての変数が単一の合成構造体に収集され、この構造体へのポインタが隠し引数としてネスト関数に渡されます。そのような変数へのアクセスは、この構造体の対応するメンバーにアクセスするように書き換えられます。結果として得られるコードは、本質的に以下のようになります(Godboltの例)。

	struct frame {
		int k;
	};

	static int bar(struct frame *f, int x)
	{
		return x + f->k;
	}

	int foo(int k)
	{
	    struct frame frame = { k };
	    return bar(&frame, 1);
	}
	

このアプローチの主な利点は、ネスト関数の実装をコンパイラの他の部分から切り離せることです。コンパイラは、単純に静的ポインタを、通常の構造体を指す追加の隠し引数として扱うことができます。子によってアクセスされない親関数の他の変数は、全く影響を受けません。また、フレーム構造体自体も、プログラムに存在する他の構造体と同様に最適化できます。例えば、上記の例は、ネスト関数について何も知らない汎用オプティマイザコードによって、単純な加算に簡略化されます。

	"foo":
        	lea     eax, [rdi+1]
        	ret
	

複数のネストレベルがある場合、構造体は1つ上のフレーム構造体へのリンクも含むため、フレーム構造体のリスト(チェーン)が作成されますが、これはまれにしか必要ありません。

C++のラムダ機能との比較

これをC++のラムダがどのように機能するかと比較するのは興味深いことです。言語レベルでの機能の公開方法には、もちろんいくつかの表層的な違いがあります。ラムダは名前がなく、式である関数リテラルですが、GCCのネスト関数はネストされたコンテキストに現れる通常の関数定義です。しかし、実装の観点からはこれは根本的な違いではありません。

言語レベルでのもう一つの違いは、ネスト関数の可視な型が通常の関数型であることです。対照的に、C++ではラムダの型はVoldemort型、つまり名前を付けられないユニークな匿名型です。

これらの2つの違いを除けば、ネスト関数のセマンティクスはC++のラムダのサブセットです。実際、上記の例はラムダオブジェクトを使用することでC++に簡単に書き換えることができます。

	int foo(int k)
	{
		auto bar = [&](int x) -> int { return x + k; };
    		return bar(1);
	}
	

もう少し深く見ると、GCCのネスト関数の背後にある実装メカニズムも、C++コンパイラがラムダを呼び出し可能なオブジェクトに変換する方法とそれほど違いはありません。C++のラムダも、キャプチャした変数のコピーまたは参照を含む構造体(より正確にはC++の呼び出し可能なオブジェクト)に変換されます。

	struct bar_anonymous {
		int &k;
		int operator() (int x);
	};

	inline int bar_anonymous::operator() (int x)
	{
		return x + k;
	}

	int foo(int k)
	{
		bar_anonymous bar(k);
		return bar(1);
	}
	

まだ一つ残っている違いがあり、これは2つのネスト関数がある例で最もよく説明できます。

	int foo(int k)
	{	
		int bar1(int x) { return x + 2 * k; }
		int bar2(int x) { return x + 3 * k; }

    		return bar1(1) + bar2(1);
	}
	

この場合、GCCはkを含む単一のフレーム構造体を親関数内に作成し、両方のネスト関数は、この共有環境への全く同じポインタを受け取ります。

	struct frame {
		int k;
	};

	static int bar1(struct frame *f, int x)
	{
		return x + 2 * f->k;
	}

	static int bar2(struct frame *f, int x)
	{
		return x + 3 * f->k;
	}

	int foo(int k)
	{
	    struct frame frame = { k };
	    return bar1(&frame, 1) + bar2(&frame, 1);
	}
	

対照的に、C++コンパイラは、各ラムダ式に対して2つの別々のオブジェクトを生成し、それぞれがスタック上の同じk変数への参照を含みます。

	struct bar1_anonymous {
		int &k;
		int operator() (int x);
	};

	inline int bar1_anonymous::operator() (int x)
	{
		return x + k;
	}

	struct bar2_anonymous {
		int &k;
		int operator() (int x);
	};

	inline int bar2_anonymous::operator() (int x)
	{
		return x + k;
	}

	int foo(int k)
	{
		bar1_anonymous bar1(k);
		bar2_anonymous bar2(k);

		return bar1(1) + bar2(1);
	}
	

この実装上の違いにもかかわらず、この例のGNU CとC++のバージョンは全く同じセマンティクスを持っています。

結論

GCCのネスト関数はC++のラムダの小さなセマンティックサブセットに対応しており、歴史的に異なるアプローチから進化してきたにもかかわらず、その実装は根本的に異なっているわけではありません。すでにC++を実装しているコンパイラは、ラムダのサポートに基づいて、GCCのネスト関数と同じ構文とセマンティクスを持つ機能を公開できるでしょう。

文献