プログラミング
GCCのネスト関数実装(C++ラムダ関数との比較)
Implementation of GCC's Nested Functions (vs. C++ Lambdas) (uecker.codeberg.page)
要約
この記事では、GCCにおけるネスト関数の実装方法について解説しています。GCCは、親関数の変数にアクセスするネスト関数を、アクセスされる変数を集めた構造体へのポインタを隠し引数として渡す方法で実装しており、これはC++のラムダ関数の実装と類似しています。
全文翻訳
ここでは、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);
}
まだ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のネスト関数と同じ構文とセマンティクスを持つ機能を公開できます。
参考文献
GCC, Nested Functions
Jens Gustedt, N3884: Wording for "Local functions"
Raynmond Chen, The mysterious second parameter to the x86 ENTER instruction