Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

関数を呼び出すまではアセンブリに直されない? (C++ と Rust を見比べた)

もしかすると一般的な話なのかもしれませんが,おもしろかったのでメモ書き程度に残しておきます *1ソースコードはすべてアセンブリに直されているものだとばかり思っていましたが,そうではないんですね.

使ったツールは,Compiler Explorer というサイトです.

ちなみに,Rust のゼロコスト抽象化 (zero cost abstraction / zero overhead principle) について,アセンブラではどのような処理がなされているのかを調査していた最中に見つけました (ですが,今回はゼロコスト抽象化は関係のない話です.これはまた別途記事にしようと思います.).

C++

C++ で,次のようなコードをコンパイルさせて,アセンブリがどのように生成されるのかを見ていました.初めて見たんですが.

class A {
    private:
    int value;

    public:
    A(int a) : value(a) {}

    int get() {
        return value;
    }

    void set(int a) {
        value = a;
    }
};

int main() {
    A a_stack_cpp(5);
}

すると,つぎのようなアセンブリが生成されるはずです (以降,すべて x86-64, gcc 8.1 です).

A::A(int):
        pushq   %rbp
        movq    %rsp, %rbp
        movq    %rdi, -8(%rbp)
        movl    %esi, -12(%rbp)
        movq    -8(%rbp), %rax
        movl    -12(%rbp), %edx
        movl    %edx, (%rax)
        nop
        popq    %rbp
        ret
main:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $16, %rsp
        leaq    -4(%rbp), %rax
        movl    $5, %esi
        movq    %rax, %rdi
        call    A::A(int)
        movl    $0, %eax
        leave
        ret

クラス用のコンストラクタと,main 関数に関するアセンブリが生成されているようですね.ここで,「setgetアセンブリはじゃあどうなるんだろう?」という点が気になりました.C++アセンブラの出力内容を見るのは初めてだったので,あまり想像がつかなかったのでやってみました.

ということで, get を使う記述を追加します.

class A {
    private:
    int value;

    public:
    A(int a) : value(a) {}

    int get() {
        return value;
    }

    void set(int a) {
        value = a;
    }
};

int main() {
    A a_stack_cpp(5);
    a_stack_cpp.get(); // 追加した
}

すると,アセンブリはつぎのように出力されました.

A::A(int):
        pushq   %rbp
        movq    %rsp, %rbp
        movq    %rdi, -8(%rbp)
        movl    %esi, -12(%rbp)
        movq    -8(%rbp), %rax
        movl    -12(%rbp), %edx
        movl    %edx, (%rax)
        nop
        popq    %rbp
        ret
A::get():
        pushq   %rbp
        movq    %rsp, %rbp
        movq    %rdi, -8(%rbp)
        movq    -8(%rbp), %rax
        movl    (%rax), %eax
        popq    %rbp
        ret
main:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $16, %rsp
        leaq    -4(%rbp), %rax
        movl    $5, %esi
        movq    %rax, %rdi
        call    A::A(int)
        leaq    -4(%rbp), %rax
        movq    %rax, %rdi
        call    A::get()
        movl    $0, %eax
        leave
        ret

これは興味深いですね.get メソッドを呼び出す記述を追加すると,同時にアセンブラにも get メソッドに関するアセンブリが追加されました.要するに,メソッドを使用するタイミングになってはじめて,必要な分だけアセンブリを生成するようになっているという感じでしょうか.逆に,set に関するアセンブリは一切生成されていません.無駄がなくていいですね.

Rust

ここで普段使っている Rust がどうなっているのかも知りたくなってきますね.ということで,似たようなコードを書いてどういう動きをするのかを見てみましょう.(以下,rustc 1.27.1 です)

struct A {
    value: i32,
}

impl A {
    fn new(value: i32) -> A {
        A { value }
    }

    fn get(self) -> i32 {
        self.value
    }

   // set はちょっとめんどうだったので省略しました…
}

pub fn main() {
    let a = A::new(5);
}

アセンブリに直してみましょう.

example::A::new:
  pushq %rbp
  movq %rsp, %rbp
  subq $4, %rsp
  movl %edi, -4(%rbp)
  movl -4(%rbp), %eax
  addq $4, %rsp
  popq %rbp
  retq

example::main:
  pushq %rbp
  movq %rsp, %rbp
  subq $16, %rsp
  movl $5, %edi
  callq example::A::new
  movl %eax, -4(%rbp)
  addq $16, %rsp
  popq %rbp
  retq

C++ と似たような匂いがしてきました.main 関数の中では A の生成しか呼び出していないので,A の生成部分しかアセンブリが生成されていません.こちらも無駄がなさそうです.

代わりに get を一度だけ呼び出してみます.普段はこういう書き方しないけど.

struct A {
    value: i32,
}

impl A {
    fn new(value: i32) -> A {
        A { value }
    }

    fn get(self) -> i32 {
        self.value
    }
}

pub fn main() {
    let a = A::new(5);
    a.get();
}

すると

example::A::new:
  pushq %rbp
  movq %rsp, %rbp
  subq $4, %rsp
  movl %edi, -4(%rbp)
  movl -4(%rbp), %eax
  addq $4, %rsp
  popq %rbp
  retq

example::A::get:
  pushq %rbp
  movq %rsp, %rbp
  movl %edi, %eax
  popq %rbp
  retq

example::main:
  pushq %rbp
  movq %rsp, %rbp
  subq $16, %rsp
  movl $5, %edi
  callq example::A::new
  movl %eax, -4(%rbp)
  movl -4(%rbp), %edi
  callq example::A::get
  movl %eax, -8(%rbp)
  addq $16, %rsp
  popq %rbp
  retq

無事,get 関数に関するアセンブリが追加されたことがわかりました *2

まとめ

  • C++ や Rust においては,メソッド (あるいは関数) に関するアセンブリは,1回以上呼び出されたもののみ生成される.
  • 言語にもよりますが通常,関数やメソッドは意味解析の段階でコンパイラ側(あるいはインタプリタ側の)の仮想のスタックに積まれて,そのスタック内で順繰りに評価が走って実行されます.積まれた順に各言語のVM用の命令に直されます.そもそも関数呼び出しが起きなければスタックに積まれずそこに対する処理が走らないため,結果的にアセンブリが生成されなかったという話なんですかね.gcc も rustc もまったく詳しくないのでそうなってるかはわかりませんが.
  • ちなみに C++ 側で,変数を volatile にしてみても結果は変わらなかったので,最適化とは関係なさそうというところまではわかっています.その先がよくわからないので,上の解釈が正しいかは微妙なところですね….

*1:あと,アセンブラアセンブリアセンブルというややこしい3つの用法があってるか自信がないので,雰囲気で感じ取っていただけますと嬉しいです.

*2:example というのは,多分 crate 名が Compiler Explorer 上では example になっているからだと思います.