【C】可変長引数の回避と引数の数による関数の呼び分け

2022-11-23

例えばPOSIXの open 関数は引数を2つ、または3つ与える形になっている。 このような関数を自作C言語上で実装する場合に可変長引数を使用すると、内部的に引数をスタック上にストアして取り出す処理が行われるため、コストがかかってしまう。 それを回避する方法を考える。

標準のopen関数の宣言

C言語ではオプショナルな引数みたいなことはできないので、open 関数のように引数を2個または3個受け付ける関数は ... を使って可変長引数関数にすることになる:

int open(const char *pathname, int flags, /*mode_t mode*/...);

3個目の引数の型がチェックされない・4個以上も受け付けてしまうという問題はあるが、ちゃんと利用できる。

open関数の実装方法

引数の数が可変の関数を実装する場合、正攻法としてはstdarg.hをインクルードして扱う。 ただし可変長引数を扱う関数は通常の関数よりはコストがかかる。 x86_64などの64ビット環境では関数への引数がレジスタ渡しされるが、一旦スタック上にストアして取り出すことになる。

printfのように場合によって異なる型を受け付けるのであれば可変長引数を使用する必要があると思うが、 openのように引数が基本2つで場合によっては追加でint1つという固定の用途ではペナルティがデカすぎるように感じてしまう。

そこで手としては、関数定義側では可変長引数のプロトタイプ宣言されているヘッダファイルをインクルードせずに、...を使わずに3引数を受け取る関数を定義してやる。 そうすることによって引数のスタックへのストアを行わずに済む。

上記の問題点

上記の方法は使用環境の呼出規約によっては問題が出る。 MacOSでは可変長部分の引数はレジスタ渡しではなくスタック経由で渡されるため、宣言と定義がずれていると引数が正しく受け渡せなくなってしまう。

引数の数で関数を呼び分ける

openを可変長引数関数にしないためにヘッダのプロトタイプ宣言でも...を指定せずに3引数関数として宣言するが、 呼び出し側は引数が2つでも受け付けないといけない。 C言語では引数の数が違う同じ名前の関数を定義したり、それらの関数の呼び分けすることはできないが、プリプロセッサのマクロによるトリックを駆使すると引数の数によって切り替えることができる。

マクロも可変長引数にすることができて、...__VA_ARGS__として受け取れるということを使ってゴニョゴニョすると、引数の数によって切り替えることができる (参考:[備忘録]マクロの引数の数でオーバーロード - Qiita)。

それを利用すると、

int open(const char *pathname, int flags, mode_t mode);

#define open(...) _OPEN_OVERLOAD(__VA_ARGS__, open, ((int(*)(const char*, int))open))(__VA_ARGS__)
#define _OPEN_OVERLOAD(_1, _2, _3, NAME, ...) NAME

open関数は3引数としてヘッダで宣言し、マクロで呼び分けられるようにする。 3つ引数が与えられた場合はそのまま、2つの場合にはキャストで引数の数を偽装して、3番目の引数は与えずに(不定値!)呼び出すようにする。

マクロが同じ名前でもマクロ展開は1度しかされないので、意図通りopen関数を呼び出すことができる。

実装側は宣言ヘッダをインクルードしないか、マクロを#undefしてやればよい。

問題としては、2つか3つ以外の引数を与えると意味不明なエラーメッセージが出てしまう。

細かい話

gccやclangのコンパイラオプション-pedanticで厳密なチェックを有効にしていると、引数が3つの場合_OPEN_OVERLOADでエラーになる。 ...が空というのが許されないらしい(可変長だけの場合は空でもいいのに、なんでや…)。 _OPEN_OVERLOADマクロ呼び出しに余分に引数を渡してやるか、コンパイルオプションに-Wno-gnu-zero-variadic-macro-argumentsも指定すると回避できる。

真っ当な道

可変長引数として定義する関数でも内部でva_argが限定された使われ方かどうかを判定して、そういった場合には引数をスタックに展開しないように最適化するのが筋 (gcc/clangで最適化をかけて出力したソースを見るとそうなっている模様)。