黃爸爸狗園

本園只有sanitizer,沒有狗籠

0%

Lambda vs std::function vs Function Pointer

本文為C++ Weekly Ep 332的note

這次我們要來看C++下三種傳遞function的方法: 1. function pointer 2. lambda 3. std::function

function pointer

從原古C語言時代就存在的傳遞手段,基本上就是把function所在的address存起來,要呼叫的時候直接跳過去,我們來看個code:

1
2
3
4
5
6
7
8
9
10
// Type your code here, or load an example.
int square(int num) {
return num * num;
}

int main() {
int (*func)(int) = square ;
func(12) ;
return 0 ;
} // main()

上面的程式展示了最基本的function pointer使用方法,我們再進一步來看出來的asm長怎樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
square(int):                             # @square(int)
push rbp
mov rbp, rsp
mov dword ptr [rbp - 4], edi
mov eax, dword ptr [rbp - 4]
imul eax, dword ptr [rbp - 4]
pop rbp
ret
main: # @main
push rbp
mov rbp, rsp
sub rsp, 16
mov dword ptr [rbp - 4], 0
movabs rax, offset square(int)
mov qword ptr [rbp - 16], rax
mov edi, 12
call qword ptr [rbp - 16]
xor eax, eax
add rsp, 16
pop rbp
ret

上面的程式我為了方便說明原理,使用-O0編譯,我們不難看出function pointer的行為其實很簡單:

  1. 把square()的address放進rax
  2. 再放進[rbp - 16]
  3. call [rbp - 16]

Cool, 基本上這就是最簡單的型態了,我們接著再來看看lambda會讓問題變複雜多少

lambda

lambda是C++11引入的新功能,我們可以把它想像成一種匿名函數(Anonymous Function),而且最酷的是lambda可以做到capture,不過我們先來看一個最基礎的lambda:

1
2
3
4
5
6
7
8
int main() {
auto func = [](int num) {
return num*num;
} ;

func(12) ;
return 0 ;
} // main()

不過想要搞清楚到底發生了什麼,我們可以先從cpp insight的輸出開始看起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int main() {
class __lambda_2_17 {
public:
inline /*constexpr */ int operator()(int num) const {
return num * num;
}

using retType_2_17 = int (*)(int);
inline constexpr operator retType_2_17 () const noexcept {
return __invoke;
};

private:
static inline /*constexpr */ int __invoke(int num) {
return __lambda_2_17{}.operator()(num);
}

public:
// /*constexpr */ __lambda_2_17() = default;
};

__lambda_2_17 func = __lambda_2_17{};
func.operator()(12);
return 0;
} // main()

我們可以發現一件很酷的事情,lambda實際上是被轉換成一個class,我們去call一個lambda實際上是兩個步驟:

  1. 先宣告一個lambda物件func
  2. 去呼叫func的method "operator()"

OK, 那實際上出來的asm會長怎樣?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
main:                                   # @main
push rbp
mov rbp, rsp
sub rsp, 16
mov dword ptr [rbp - 4], 0
lea rdi, [rbp - 8]
mov esi, 12
call main::$_0::operator()(int) const
xor eax, eax
add rsp, 16
pop rbp
ret
main::$_0::operator()(int) const: # @"main::$_0::operator()(int) const"
push rbp
mov rbp, rsp
mov qword ptr [rbp - 8], rdi
mov dword ptr [rbp - 12], esi
mov eax, dword ptr [rbp - 12]
imul eax, dword ptr [rbp - 12]
pop rbp
ret

看起來與function pointer版本差不多,但這是因為我們沒有使用到capture的原因,那用了會怎樣?

  • 我們來稍微修改一下lambda的內容

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    int main() {
    int num = 12 ;
    auto func = [&]() {
    num += 1 ;
    return num*num;
    } ;

    func() ;
    return num ;
    } // main()
    然後再看看出來的asm長怎樣:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    main:                                   # @main
    push rbp
    mov rbp, rsp
    sub rsp, 16
    mov dword ptr [rbp - 4], 0
    mov dword ptr [rbp - 8], 12
    lea rax, [rbp - 8]
    mov qword ptr [rbp - 16], rax
    lea rdi, [rbp - 16]
    call main::$_0::operator()() const
    mov eax, dword ptr [rbp - 8]
    add rsp, 16
    pop rbp
    ret
    main::$_0::operator()() const: # @"main::$_0::operator()() const"
    push rbp
    mov rbp, rsp
    mov qword ptr [rbp - 8], rdi
    mov rcx, qword ptr [rbp - 8]
    mov rax, qword ptr [rcx]
    mov edx, dword ptr [rax]
    add edx, 1
    mov dword ptr [rax], edx
    mov rax, qword ptr [rcx]
    mov eax, dword ptr [rax]
    mov rcx, qword ptr [rcx]
    imul eax, dword ptr [rcx]
    pop rbp
    ret

    仔細看(8.)的mov,之所以會多出這一道指令是因為capture的實現方式是這樣的,我們來看cpp insight:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    int main() {
    int num = 12;

    class __lambda_3_17 {
    public:
    inline /*constexpr */ int operator()() const {
    return num * num;
    }

    private:
    int & num; // << HERE!

    public:
    __lambda_3_17(int & _num)
    : num{_num} // AND HERE!
    {}
    };

    __lambda_3_17 func = __lambda_3_17{num};
    func.operator()();
    return 0;
    } // main()

    原來capture是透過class member variable實現的,所以才會多出一個mov指令

  • 另外,一個沒有capture的lambda是可以被隱式轉型成function pointer的,見以下範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int main() {
int (*fptr)(int) = nullptr ;
auto func = [](int num) {
num += 1 ;
return num*num;
} ;

auto func_cap = [&](int num) {
num += 1 ;
return num*num;
} ;

fptr = func ; // OK
fptr = func_cap; // ERROR: cannot convert 'main()::<lambda(int)>' to 'int (*)(int)' in assignment

return func(1) ;
} // main()

std::function

  • std::function與上面提到的兩個東西有一個根本上的不同,std::function是一個function wrapper,實現原理可參見A Simplified std::function Implementation,這裡要提的是std::function可以包裝上面的兩個東西之外,任何具有operator()的牛鬼蛇神都可以包,為程式開發提供了極大的彈性

  • But, 如果我們沒有給std::function綁定一個callable就直接call他會怎樣?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <functional>

    int main() {
    std::function<int (int)> func;

    auto numnum = [](int num) {
    num += 1 ;
    return num*num;
    } ;

    return func(1) ;
    } // main()

  • 執行結果是直接拋出例外:

    • terminate called after throwing an instance of 'std::bad_function_call' what(): bad_function_call
  • 為了提供開發彈性,std::function背地裡做了很多......非常多的努力

    • 確保你提供的callable的return type與std::function預期的相同
    • 確保你提供的callable不會遇到生命週期的問題
      • 根據實作的不同,有可能是把你的callable直接複製一份到heap上......
      • 可能比較厲害一點的實作會動用到SOO(small object optimization)
  • 那到底用不用?

    • 有需要就用,好比說......
      • 你在寫一個遊戲引擎,你需要用std::function來存你的script engine產生出的function(lua:叫我嗎?)
    • 我要塞function進去container(vector, stack, std::array.....)
    • 各種彈性 > 效能的場景

延伸閱讀

First-class function What is the performance overhead of std::function?

結語