C言語で抽象引数を使う
目次
C言語で抽象引数
ちなみに抽象引数というのは私の造語である。こんな言葉はない(と思う)。
いきなりPythonの話しで申し訳ないが、Pythonには関数にキーワード引数というのが使える。
これは↓のようなものだ。
:::python
def func(arg1, arg2):
print(arg1, arg2)
func(arg1=1, arg2=2)
func(arg1=1, arg2=2)
という関数の呼び出し部分の引数を見て欲しい。キーに対して値を代入し、それをカンマで区切っている。これがキーワード引数だ。
このキーワード引数は非常に便利な機能だ。たとえば関数の引数が多くなった時に、キーワード引数を使えば関数呼び出しをわかりやすく書くことが出来る。そして、キーワードを使っているので引数の順番が多少違っていても関係なく機能する。便利だ。
Cでキーワード引数っぽいこと
C言語でこれができないだろうか? 私は考えた。
考えた結果、構造体を使えばそれっぽいことができそうだと判明した。
たとえば関数funcの引数をfunc_args_tとして定義する。
:::c
typedef struct {
int arg1;
int arg2;
} func_args_t;
あとはこの構造体を引数に取る関数を定義する。
:::c
void func(func_args_t *args) {
printf("%d %d\n", args->arg1, args->arg2);
}
そして、この関数を呼び出すときは↓のようにする。
:::c
func(&(func_args_t) {
.arg1 = 1,
.arg2 = 2,
});
どうだろう。キーワード引数っぽい呼び出しになった。
私は便宜的にこの引数渡しを抽象引数と読んでいる。
問題は関数ごとに構造体を定義しなくてはいけないところだが、そこは今回は目をつぶろう。
関数ファミリーが多くなって、共通の引数を使いまわしたい時などは便利かもしれない。
私もCapという自分のプロダクトでこの抽象引数を試しに使っている。
結論から言うと、メリットもあるが、デメリットも多い使い方と言える。
メリット
メリットとしては、複数の関数の引数を抽象化出来るということだ。
例えばfunc1とfunc2がある。
:::c
void
func1(func_args_t *args) {
printf("%d\n", args->a);
}
void
func2(func_args_t *args) {
int d = args->b + args->c;
printf("%d\n", d);
}
func1ではメンバaを使用し、func2ではメンバb, cを使用している。
使用する引数の値は違うが、関数の引数のところは共通している。
構造体は↓のようになる。
:::c
typedef struct {
int a; // func1
int b; // func2
int c; // func2
} func_args_t;
このように引数を抽象化しているので、例えばfunc1の引数dを増やしてみたとする。
しかし、関数の宣言などは修正せずに、関数の中だけ変更すればいいことになる。
:::c
void
func1(func_args_t *args) {
// 引数は変更せずに関数の中だけ変更すれば済む
int e = args->a + args->d;
printf("%d\n", e);
}
また、構造体のポインタで引数が渡されるので、下レイヤーの関数に処理を委譲したい時などは、ポインタをそのまま渡すだけで済む。
:::c
void
func1(func_args_t *args) {
func2(args); // 委譲
}
デメリット
しかし抽象化している上のデメリットもある。
まず1つは、テンポラリーオブジェクトの作成コストだ。
抽象引数を使った場合、関数の呼び出しは↓のようになるが、
:::c
func1(&(func_args_t) { .a = 1 .d = 2 });
func2(&(func_args_t) { .b = 1 .c = 2 });
このテンポラリーオブジェクトの作成では、メンバがすべて初期化される。すべてということは、構造体のメンバが増えればそれだけ初期化に時間がかかるということだ。
これの詳細については後半のパフォーマンスの計測で取り上げている。
それから、引数の詳細が構造体に隠蔽されているので、パッと見で関数がなんの引数を必要としているのか、実装者側からわかりづらいというのもデメリットだろう。
:::c
void
func1(func_args_t *args) {
// この関数が必要としているargsの中のメンバ(引数)は関数の実装を見ないとわからない
}
デメリットとしてはこんなところだが、1つ目のテンポラリーオブジェクトの初期化はコンパイラの最適化に頼れば何とかなるかも知れない。
2つ目の明示的な引数宣言の問題は、そもそも引数を抽象化しているのである意味当然のデメリットである。
パフォーマンス
この抽象引数のパフォーマンスについて検討したいと思う。
比べるのは普通の引数の関数と、抽象引数を使った関数の性能だ。
結論から言うと、抽象引数はパフォーマンスがわるいことがある。というのも、構造体をいちいち初期化しているから当然なわけだが、最適化によってはこのパフォーマンスは改善される。
計測に使ったのは↓のコードだ。
:::c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
typedef struct {
int arg1;
int arg2;
int arg3;
int arg4;
int arg5;
} func_args_t;
void
func1(int arg1, int arg2, int arg3, int arg4, int arg5) {
}
void
func2(func_args_t *args) {
}
int
main(int argc, char *argv[]) {
if (argc < 2) {
return 1;
}
long n = atol(argv[1]);
clock_t start, end;
start = clock();
for (long i = 0; i < n; i++) {
func1(1, 2, 3, 4, 5);
}
end = clock();
printf("func1: time %.4f seconds\n", (double) (end - start) / CLOCKS_PER_SEC);
start = clock();
for (long i = 0; i < n; i++) {
func2(&(func_args_t) {
.arg1 = 1,
.arg1 = 2,
.arg1 = 3,
.arg1 = 4,
.arg1 = 5,
});
}
end = clock();
printf("func2: time %.4f seconds\n", (double) (end - start) / CLOCKS_PER_SEC);
return 0;
}
最適化しないで普通の引数のfunc1と、抽象引数を使ったfunc2の両方で20億回、関数を呼び出すと↓のような結果になる。
$ gcc c.c
$ .\a.exe 2000000000
func1: time 3.6380 seconds
func2: time 3.7500 seconds
抽象引数を使ったfunc2のほうが遅い。ちなみに構造体のメンバ変数を増やすほどこの差は顕著に出る。これは、テンポラリーオブジェクトの作成に時間がかかっているためだと推測できる(&(func_args_t){}
というところでテンポラリーオブジェクトを作成している)。
最適化するとどうなるか? 最適化すると結果は↓のようになる。
$ gcc c.c -O3
$ .\a.exe 2000000000
func1: time 0.0000 seconds
func2: time 0.0000 seconds
なんと0秒になってしまった。これでは計測もへったくれもない。
最適化した場合のアセンブラを確認してみよう。
:::asm
.file "c.c"
.section .text.unlikely,"x"
.LCOLDB0:
.text
.LHOTB0:
.p2align 4,,15
.globl func1
.def func1; .scl 2; .type 32; .endef
.seh_proc func1
func1:
.seh_endprologue
ret
.seh_endproc
.section .text.unlikely,"x"
.LCOLDE0:
.text
.LHOTE0:
.section .text.unlikely,"x"
.LCOLDB1:
.text
.LHOTB1:
.p2align 4,,15
.globl func2
.def func2; .scl 2; .type 32; .endef
.seh_proc func2
func2:
.seh_endprologue
ret
.seh_endproc
.section .text.unlikely,"x"
.LCOLDE1:
.text
.LHOTE1:
.def __main; .scl 2; .type 32; .endef
.section .rdata,"dr"
.LC3:
.ascii "func1: time %.4f seconds\12\0"
.LC4:
.ascii "func2: time %.4f seconds\12\0"
.section .text.unlikely,"x"
.LCOLDB5:
.section .text.startup,"x"
.LHOTB5:
.p2align 4,,15
.globl main
.def main; .scl 2; .type 32; .endef
.seh_proc main
main:
pushq %rsi
.seh_pushreg %rsi
pushq %rbx
.seh_pushreg %rbx
subq $56, %rsp
.seh_stackalloc 56
movaps %xmm6, 32(%rsp)
.seh_savexmm %xmm6, 32
.seh_endprologue
movl %ecx, %ebx
movq %rdx, %rsi
call __main
cmpl $1, %ebx
movl $1, %eax
jle .L4
movq 8(%rsi), %rcx
call atol
call clock
movl %eax, %ebx
call clock
pxor %xmm0, %xmm0
subl %ebx, %eax
movsd .LC2(%rip), %xmm6
leaq .LC3(%rip), %rcx
cvtsi2sd %eax, %xmm0
divsd %xmm6, %xmm0
movapd %xmm0, %xmm1
movq %xmm0, %rdx
call printf
call clock
movl %eax, %ebx
call clock
pxor %xmm0, %xmm0
subl %ebx, %eax
leaq .LC4(%rip), %rcx
cvtsi2sd %eax, %xmm0
divsd %xmm6, %xmm0
movapd %xmm0, %xmm1
movq %xmm0, %rdx
call printf
xorl %eax, %eax
.L4:
movaps 32(%rsp), %xmm6
addq $56, %rsp
popq %rbx
popq %rsi
ret
.seh_endproc
.section .text.unlikely,"x"
.LCOLDE5:
.section .text.startup,"x"
.LHOTE5:
.section .rdata,"dr"
.align 8
.LC2:
.long 0
.long 1083129856
.ident "GCC: (tdm64-1) 5.1.0"
.def atol; .scl 2; .type 32; .endef
.def clock; .scl 2; .type 32; .endef
.def printf; .scl 2; .type 32; .endef
アセンブラを見ると関数呼び出しそのものが省略されてるのがわかる。
つまり、さきほどのコードは最適化の検証用としては機能していないことになる。
しかし、-O3
を検証するコードはなかなか難しい。私は書けなかった。
結果的に、最適化を最大に効かせた場合、普通の引数も抽象引数も大した差は生まれないという結論になる。