C言語で抽象引数を使う

327, 2020-05-25

目次

C言語で抽象引数

ちなみに抽象引数というのは私の造語である。こんな言葉はない(と思う)。
いきなり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として定義する。

typedef struct {
    int arg1;
    int arg2;
} func_args_t;

あとはこの構造体を引数に取る関数を定義する。

void func(func_args_t *args) {
    printf("%d %d\n", args->arg1, args->arg2);
}

そして、この関数を呼び出すときは↓のようにする。

func(&(func_args_t) {
    .arg1 = 1,
    .arg2 = 2,
});

どうだろう。キーワード引数っぽい呼び出しになった。
私は便宜的にこの引数渡しを抽象引数と読んでいる。

問題は関数ごとに構造体を定義しなくてはいけないところだが、そこは今回は目をつぶろう。
関数ファミリーが多くなって、共通の引数を使いまわしたい時などは便利かもしれない。
私もCapという自分のプロダクトでこの抽象引数を試しに使っている。
結論から言うと、メリットもあるが、デメリットも多い使い方と言える。

メリット

メリットとしては、複数の関数の引数を抽象化出来るということだ。
例えばfunc1とfunc2がある。

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を使用している。
使用する引数の値は違うが、関数の引数のところは共通している。
構造体は↓のようになる。

typedef struct {
    int a;  // func1
    int b;  // func2
    int c;  // func2
} func_args_t;

このように引数を抽象化しているので、例えばfunc1の引数dを増やしてみたとする。
しかし、関数の宣言などは修正せずに、関数の中だけ変更すればいいことになる。

void
func1(func_args_t *args) {
    // 引数は変更せずに関数の中だけ変更すれば済む
    int e = args->a + args->d;
    printf("%d\n", e);
}

また、構造体のポインタで引数が渡されるので、下レイヤーの関数に処理を委譲したい時などは、ポインタをそのまま渡すだけで済む。

void
func1(func_args_t *args) {
    func2(args);  // 委譲
}

デメリット

しかし抽象化している上のデメリットもある。
まず1つは、テンポラリーオブジェクトの作成コストだ。
抽象引数を使った場合、関数の呼び出しは↓のようになるが、

func1(&(func_args_t) { .a = 1 .d = 2 });
func2(&(func_args_t) { .b = 1 .c = 2 });

このテンポラリーオブジェクトの作成では、メンバがすべて初期化される。すべてということは、構造体のメンバが増えればそれだけ初期化に時間がかかるということだ。
これの詳細については後半のパフォーマンスの計測で取り上げている。

それから、引数の詳細が構造体に隠蔽されているので、パッと見で関数がなんの引数を必要としているのか、実装者側からわかりづらいというのもデメリットだろう。

void
func1(func_args_t *args) {
    // この関数が必要としているargsの中のメンバ(引数)は関数の実装を見ないとわからない
}

デメリットとしてはこんなところだが、1つ目のテンポラリーオブジェクトの初期化はコンパイラの最適化に頼れば何とかなるかも知れない。
2つ目の明示的な引数宣言の問題は、そもそも引数を抽象化しているのである意味当然のデメリットである。

パフォーマンス

この抽象引数のパフォーマンスについて検討したいと思う。
比べるのは普通の引数の関数と、抽象引数を使った関数の性能だ。
結論から言うと、抽象引数はパフォーマンスがわるいことがある。というのも、構造体をいちいち初期化しているから当然なわけだが、最適化によってはこのパフォーマンスは改善される。
計測に使ったのは↓のコードだ。

#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秒になってしまった。これでは計測もへったくれもない。
最適化した場合のアセンブラを確認してみよう。

    .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を検証するコードはなかなか難しい。私は書けなかった。

結果的に、最適化を最大に効かせた場合、普通の引数も抽象引数も大した差は生まれないという結論になる。

投稿者名です。64字以内で入力してください。

必要な場合はEメールアドレスを入力してください(全体に公開されます)。

投稿する内容です。

スポンサーリンク

スポンサーリンク

スポンサーリンク

スポンサーリンク