C++ Under the covers of capture-less lambda

JunTak Lee·2023년 6월 28일
0

C++ Lambda

목록 보기
3/3

cpp의 lambda expression을 열심히 공부할 때였다
열심히 뒤져보다가 이런 재미난 글을 찾을 수 있었다
https://andreasfertig.blog/2020/10/under-the-covers-of-cpp-lambdas-part-1-the-static-invoker/

해당 글은 CppInsights의 개발자가 작성한 글인데 상당이 흥미롭다
lambda expression이 function object와 왜 다른지를 설명한다
그래서 글과 관련해서 포스팅을 해보기로 결정했다

lambda에 대한 전반적인 설명은 지난 글에서 어느정도 하였으니 해당 부분을 다루지는 않을것이다
이번 글은 capture-less lambda 그 중에서도 static invoker에 관한 내용이다


Static Invoker

capture-less lambda의 설명을 보다보면 이런 설명이 존재한다

The value returned by the conversion function is a pointer to a function when invoked, has the same effect as ...

즉, function pointer로 변환이 가능하다는 소리다
그냥 그런가보다 하고 넘어가기에는 뭔가 좀 많이 이상해보인다
왜냐하면 member function pointer와 function pointer는 엄연히 다른 type이기 때문이다
그렇다면 conversion function은 무엇이고, 이게 어떻게 동작하는 것일까

해당 내용을 알아보기 위해 적당한 예제를 검색하던 중 다음과 같은 예제를 찾을 수 있었다
사실 해당 글에서는 lambda가 function pointer와 사용이 불가능하다고 되어있다
왜 그렇게 설명한건지는 모르겠는데 내가 잘못 이해한거라 생각한다
아무튼 해당 예제에서 conversion function을 확인해 볼 수 있다

#include <iostream>

void foo(int _1, int _2, void(*printer)(int)) {
    printer(_1 + _2);
}

int main() {
    foo(10, 20, [](int val){ std::cout << val << std::endl; });
}

Clang 16.0.0 -O0

foo(int, int, void (*)(int)):                          
		...
        mov     qword ptr [rbp - 16], rdx
        mov     rax, qword ptr [rbp - 16]
		...
        call    rax
		...
        
main:                                  
		...
        lea     rdi, [rbp - 8]
        call    main::$_0::operator void (*)(int)() const
        mov     rdx, rax
		...
        call    foo(int, int, void (*)(int))
		...
        
main::$_0::operator void (*)(int)() const:              
		...
        mov     qword ptr [rbp - 8], rdi
        lea     rax, [rip + main::$_0::__invoke(int)]
		...
        
main::$_0::__invoke(int):             
		...
        lea     rdi, [rbp - 8]
        call    main::$_0::operator()(int) const
		...
        
main::$_0::operator()(int) const:                   
		...

compile 결과가 너무 길어서 parameter 관련 부분을 다 날렸다
어짜피 parameter가 어떻게 넘어가지는 궁금하지 않기 때문이다

assembly를 보면 조금은 갸우뚱해진다
그냥 operator ()을 call하면 될거 같은데 안그런다
이걸 이해하기 위해 CppInsights를 활용해보았다

#include <iostream>

void foo(int _1, int _2, void (*printer)(int))
{
  printer(_1 + _2);
}


int main()
{
      
  class __lambda_8_17
  {
    public: 
    inline /*constexpr */ void operator()(int val) const
    {
      std::cout.operator<<(val).operator<<(std::endl);
    }
    
    using retType_8_17 = void (*)(int);
    inline constexpr operator retType_8_17 () const noexcept
    {
      return __invoke;
    }
    
    private: 
    static inline /*constexpr */ void __invoke(int val)
    {
      __lambda_8_17{}.operator()(val);
    }
    
    
    public:
    // /*constexpr */ __lambda_8_17() = default;
    
  };
  
  foo(10, 20, static_cast<void (*)(int)>(__lambda_8_17{}.operator __lambda_8_17::retType_8_17()));
  return 0;
}

operator ()에 우리가 정의한 lambda expression의 body 부분이 들어간다
operator void (*)(int)에서는 __invoke function pointer을 return한다
__invoke function에서 우리가 원하는 operator ()을 call한다

각 function들의 역할을 이해하고 다시 assembly로 돌아와보자
assembly의 실행순서를 정리한다면 다음과 같을 것이다

  • operator void (*)(int)에서 __invoke function pointer을 받아온다
  • 해당 function pointer을 foo의 parameter로 넘겨준다
  • foo에서는 function pointer을 call한다
  • __invoke가 호출되고 다시 operator ()을 call한다
  • 우리가 원하는 lambda expression의 body가 실행된다

이로써 우리는 conversion function의 정체를 확인할 수 있다
이는 다른 이름으로 static invoker라고도 불린다
그런데 이러한 부분은 그저 compiler들이 맘대로 지어낸 것일까
원글을 살펴본다면 standard에서 해당 부분을 정의했다는 내용이 나온다


What Standard Says

사실 엄밀히 말하면 working draft 단계라 ISO standard가 아니긴 하다
근데 아직 C++23 ISO standard가 안나온 상황이라 그점은 무시하고 봐보자

the value returned by this conversion function is the address of a function F that, when invoked, has the same effect as invoking the closure type’s function call operator on a default-constructed instance of the closure type.
cpp draft: N4917

draft에선 function F라 명시하고 있다
그리고 function pointer로 converting시 F의 주소가 return 되어야한다고 명시되어있다
이때 F는 closure의 call operator을 호출하는 것과 동일한 효과가 있어야한다고 명시되어있다
즉, 동일한 효과만 있으면 될뿐 어떻게 구현할지는 관심이 없다는 것이다

그러니까 이 모든 것이 standard에 정의되어 있다
그리고 compiler는 그 정의를 따를 뿐이다
물론 implementation은 자유이긴한데, 사실상 거의 다 비슷하게 한다

한가지 재미있는 점은 해당 부분이 lambda expression이 등장할때 부터 존재했다는 것이다
물론 다 동일한건 아니고 상당히 많은 부분들이 추가되었다
CppReference를 살펴보면 C++23까지 매 버전마다 바뀐것을 확인할 수 있다
그래도 해당 부분만큼은 맥락에 있어 동일함을 유지하고 있다
https://en.cppreference.com/w/cpp/language/lambda

아마도 type과 관련되어 문제가 되기에 이렇게 만든게 아닐까 싶다
사실 compiler는 아래서도 설명하겠지만 member function을 object 없이도 호출할 수 있다
물론 해당 경우에는 member variable을 사용하지 않는다는 확신이 우선 필요하지만 말이다
이렇게 된다면 static invoker고 뭐고 다 필요없다
그런데 그렇게 만들지 않은 이유는 아마 function pointer type을 위해서 그런것이 아닐까 싶다


Difference with Function Object

사실 변경점을 설명한 이유가 현재는 compile 결과가 compiler 없이도 구현가능한 수준이기 때문이다
그런데 원글을 잘 살펴보면 해당 부분에서 compiler가 말도안되는 짓을 벌인다고 한다
무엇이 다른건지 알아보기 위해 과거로의 여행을 해보았다

어느 버전까지 내려갈까하다가 그냥 땡기는 버전을 선택해보았다
사실 더 내려가 3.5 버전에서도 동일한 결과가 나오기는 했었는데 실행이 안되는 관계로 해당 버전을 선택했다

우선 lambda expresion을 compile 했을때의 결과이다
Clang 7.1.0 --std=c++11 -O0

foo(int, int, void (*)(int)):                          
		...
        mov     qword ptr [rbp - 16], rdx
        mov     rdx, qword ptr [rbp - 16]
		...
        call    rdx
		...
        
main:                                   
		...
        lea     rdi, [rbp - 8]
        call    main::$_0::operator void (*)(int)() const
		...
        mov     rdx, rax
        call    foo(int, int, void (*)(int))
		...
        
main::$_0::operator void (*)(int)() const:               
		...
        mov     qword ptr [rbp - 8], rdi
        movabs  rax, offset main::$_0::__invoke(int)
		...
        
main::$_0::__invoke(int):             
		...
        call    main::$_0::operator()(int) const
		...
        
main::$_0::operator()(int) const:                    
		...

CppInsights의 결과를 compile 했을때의 결과이다
Clang 7.1.0 --std=c++11 -O0

foo(int, int, void (*)(int)):                          
		...
        mov     qword ptr [rbp - 16], rdx
        mov     rdx, qword ptr [rbp - 16]
        ...
        call    rdx
        ...
        
main:                                   
		...
        lea     rdi, [rbp - 8]
        call    main::__lambda_8_17::operator void (*)(int)() const
		...
        mov     rdx, rax
        call    foo(int, int, void (*)(int))
		...
        
main::__lambda_8_17::operator void (*)(int)() const:    
		...
        mov     qword ptr [rbp - 8], rdi
        movabs  rax, offset main::__lambda_8_17::__invoke(int)
		...
        
main::__lambda_8_17::__invoke(int):
		...
        lea     rdi, [rbp - 8]
        call    main::__lambda_8_17::operator()(int) const
		...
        
main::__lambda_8_17::operator()(int) const:         
		...

잘 살펴보면 딱 한줄이 다르다
바로 __invoke 부분에서 객체를 생성하는 과정이다
lambda-expression을 잘보면 객체를 생성하지도 않고 member function을 호출한다
이 부분이 바로 원글에서 설명하는 부분이다
정말이지 compiler이기에 가능한 짓이라고 밖에는 설명이 안된다..
하여튼 이로써 compiler는 객체 생성을 한번 덜한다


Necessity of Static Invoker

그런데 문뜩 애초에 저게 왜 필요한가라는 생각이 들었다
그냥 call operator을 static으로 만들어버리면 되는거 아닌가
알다싶이 C++23 전까지는 call operator가 static일 수 없었다
아마 그래서 저런 standard가 나온게 아닌가 싶다

그런데 이제는 static call operator을 쓸 수 있다
그러면 이제 바꿀 수 있는거 아닌가 싶을 수 있는데 아쉽지만 그렇지 않다
해당 부분에 관련된 설명이 proposal에 친절하게 설명되어 있다
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2022/p1169r4.html#lambdas

이미 10년이 넘는 너무 긴 시간이 흘러버렸고 많은 곳에서 static invoker을 사용하고 있을 것이다
문제는 이걸 맘대로 바꾸면 ABI break가 발생할 수도 있다는 것이다
그리고 알다싶이 C++는 ABI break에 굉장히 보수적인 입장이다..

하여튼 그래도 대안이 나오긴했다
위 proposal을 읽다보면 흥미로운 부분이 보인다
바로 specifier에 static을 사용하는 방법이다
위 예제를 그대로 적용해볼 경우 아래와 같이 바뀐다

foo(int, int, void (*)(int)):                         
		...
        mov     qword ptr [rbp - 16], rdx
        mov     rax, qword ptr [rbp - 16]
		...
        call    rax
		...
        
main:                                 
		...
        lea     rdi, [rbp - 8]
        call    main::$_0::operator void (*)(int)() const
        mov     rdx, rax
		...
        call    foo(int, int, void (*)(int))
		...
        
main::$_0::operator void (*)(int)() const:               
		...
        mov     qword ptr [rbp - 8], rdi
        lea     rax, [rip + main::$_0::operator()(int)]
		...
        
main::$_0::operator()(int):                  
		...

놀랍게도 static invoker 자체가 사라진것을 확인할 수 있다
이처럼 이제 더 이상 static invoker 없이 구현할 수 있게 되었다
다만 기존에 존재하는 code base는..뭐 아쉽게 되었다..


capture-less lambda가 function pointer로 convert될 수 있다는건 하나의 큰 장점이다
아직도 c 베이스 legacy code에서는 function pointer 밖에 사용을 못하니 말이다
사실 static call operator가 C++11에 등장했거나 위원회가 다른 스탠스를 취했다면 아마 없었을 부분이다
하지만 오늘날 C++ 발전과정 중간에 위치해버린 또하나의 부산물이 아닐까 싶다

사실 또 하나의 트릭이 아닌가 싶은데 이번 트릭은 써먹을 수 있을것 같다
어쨌든 function object를 function pointer로 바꿀 수 있는 방법이니까
그리고 performance 부분도 다룰까하다가 안다뤘는데 사실 너무 당연하다
lambda가 너무 길지만 않으면 바로 inline 되어 버리니까 말이다

Compile 결과가 너무 생략되어 문맥이 파악하기 힘든데, 전체 code를 원할경우 아래 link로 들어가면 나온다
https://godbolt.org/z/K9Tf6EvGM

0개의 댓글