C++ Lambda Expressions

JunTak Lee·2023년 7월 4일
0

C++ Lambda

목록 보기
2/3

C++11에 등장한 lambda expression은 굉장한 관심을 받았다
그도 그럴것이 쓰기도 편한데 성능도 상당했기 때문이다
당장에 구글링해봐도 엄청난 양의 글들이 쏟아진다

이러한 관심속에 lambda expression은 매 standard마다 발전하고 있다
몇달전에 완료된 C++23에서도 lambda expression에 관련된 몇가지 변경점을 찾을 수 있다
활용방법도 상당해서 이것만 공부해도 몇일은 걸릴지경이다
이러한 변경점들이나 활용방법을 다루는 것도 재미있는 일일 것이다.
하지만 나는 조금 다른 이야기를 해보고 싶다

지난 lambda calculus라는 글에서 lambda가 어떻게 동작하는지에 대한 궁금증이 생겼다
오늘은 이 궁금증에 대한 개인적인 답변을 다루고 있다


Definition

CppReference에 의하면 lambda expression은 다음과 같은 정의를 가진다
https://en.cppreference.com/w/cpp/language/lambda

Constructs a closure: an unnamed function object capable of capturing variables in scope

여기서 function object라는 개념이 등장한다
CppReference에 의하면 function object는 다음과 같은 정의를 가진다
https://en.cppreference.com/w/cpp/utility/functional

A function object is any object for which the function call operator is defined.

사실 개념적인 부분을 파다보면 정말 밑도끝도 없다는걸 알 수 있다
그래서 구현에 중점을 두고 생각해보았을 때, 우리는 다음과 같은 결론을 얻을 수 있다

  • lambda expression은 call operator가 정의된 unnamed object를 생성한다

사실 생략된 부분도 많고 다소 과격한 일반화이기는 한데 아무튼 중요 맥락은 그렇다
그렇다면, 여기서 말하는 call operator가 정의된 unnamed object란 무엇일까
이 정체를 확인하기 위해서는 compile된 결과를 확인해봐야 한다

사실 시간 순서상 다음 글을 먼저 적고나서 현재 글을 적고 있는 중이다
따라서 다음 글에서 쓰던 예제를 그대로 들고와 조금 변경해보았다

#include <iostream>
#include <concepts>

template <typename Printer>
    requires std::invocable<Printer, int>
void foo(int _1, int _2, const Printer& func) {
    func(_1 + _2);
}

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

별건 없고 그냥 호출 가능한 객체라는걸 말하기 위해 std::invocable만 제약으로 걸어놨다
아무튼 이걸 compile하면 다음과 같은 결과가 나온다
Clang 16.0.0 --std=c++2b -O0

main:                                   
		...
        lea     rdx, [rbp - 8]
        call    void foo<main::$_0>(int, int, main::$_0 const&)
		...
        
void foo<main::$_0>(int, int, main::$_0 const&):            
		...
        mov     qword ptr [rbp - 16], rdx
        mov     rdi, qword ptr [rbp - 16]
		...
        call    main::$_0::operator()(int) const
		...
        
main::$_0::operator()(int) const:                   
		...
        mov     qword ptr [rbp - 8], rdi
		...
        mov     rdi, qword ptr [rip + std::cout@GOTPCREL]
        call    std::basic_ostream<char, std::char_traits<char> >::operator<<(int)@PLT
		...

잘 보면 객체를 생성하고 parameter로 넘겨서 사용한다
정확히는 main function에서 offset [rbp - 8]에 생성되었다
그리고 이것을 call하기 위해 call operator가 존재하는 것을 볼 수 있다

사실 위 예제에서는 저게 lambda object가 맞나 싶을 수 있다
왜냐하면 function에 넘어가긴 하는데 별로 하는게 없기 때문이다
이것을 좀더 직관적으로 확인하기 위해 이번에는 capture가 존재하는 lambda를 만들었다

template <typename Printer>
    requires std::invocable<Printer, int>
void foo(int _1, int _2, const Printer& func) {
    func(_1 + _2);
}

int main() {
    int x = 5;
    foo(10, 20, [capture_val=x](int val){
        std::cout << capture_val << ' ' << val << std::endl; 
        });
}

여기서 capture value가 어떻게 지나가는지만 확인하면 되기에 나머지는 다 날렸다
Clang 16.0.0 --std=c++2b -O0

main:                                 
		...
        mov     dword ptr [rbp - 4], 5
        mov     eax, dword ptr [rbp - 4]
        mov     dword ptr [rbp - 8], eax
		...
        lea     rdx, [rbp - 8]
        call    void foo<main::$_0>(int, int, main::$_0 const&)
		...
        
void foo<main::$_0>(int, int, main::$_0 const&):             
		...
        mov     qword ptr [rbp - 16], rdx
        mov     rdi, qword ptr [rbp - 16]
		...
        call    main::$_0::operator()(int) const
		...
        
main::$_0::operator()(int) const:                   
		...
        mov     qword ptr [rbp - 8], rdi
		...
        mov     rax, qword ptr [rbp - 8]
        mov     esi, dword ptr [rax]
        mov     rdi, qword ptr [rip + std::cout@GOTPCREL]
        call    std::basic_ostream<char, std::char_traits<char> >::operator<<(int)@PLT
        ...

사실 capture이 없는 버전과 비교해보았을 때 상당히 유사하다
이번에도 lambda object가 offset [rbp - 8]에 생성되었다
한가지 차이가 있는데 바로 value capture의 유무이다
value capture이 존재하는 경우 해당 값이 lambda object에 저장되는 것을 확인할 수 있다
또한 해당 값을 필요로 할때 function object에서 꺼내서 사용한 것을 확인할 수 있다
이렇듯 cpp에서 lambda의 개념은 function object을 통해 구현이 된다


Imitating Lambda Object

위 내용을 정리해보면 lambda expression이 마법이 아니란걸 알 수 있다
그렇다면 한걸음 더 나아가 우리가 cpp에서 이걸 흉내낼 순 없을까
사실 불가능하지만, 어느 정도는 흉내낼 수 있다

우선 위 예제를 이용해서 동일한 assembly가 나오도록 만들어 보았다
사실 CppInsights를 거의 베끼다 싶이 했다

#include <iostream>
#include <concepts>

template <typename Printer>
    requires std::invocable<Printer, int>
void foo(int _1, int _2, const Printer& func) {
    func.operator()(_1 + _2);
}

int main()
{   
  class $_1 
  {
    public: 
    inline void operator()(int val) const
    {
      std::cout.operator<<(val).operator<<(std::endl);
    }
  };
  
  foo(10, 20, $_1{});
  return 0;
}

놀랍게도 딱 한줄 빼고 모두 동일하다고 나온다
근데 아직도 저기에 0을 왜 넣는지는 모르겠다

그렇다면 capture가 존재하는 lambda는 어떨까
사실 capture이 존재하는 lambda의 경우 이게 좀 애매하다
정확하게 compiler가 하는걸 똑같이 하자면 아래와 같을 것이다

#include <iostream>
#include <concepts>

template<typename Printer>
    requires std::invocable<Printer, int>
void foo(int _1, int _2, const Printer & func)
{
  func(_1 + _2);
}

int main()
{
  int x = 5;
    
  class $_0
  {
    public: 
    inline void operator()(int val) const
    {
      std::operator<<(std::cout.operator<<(capture_val), ' ').operator<<(val).operator<<(std::endl);
    }
    
    private: 
    int capture_val;
  };

  $_0 _0;
  *(int*)&_0 = x;
  
  foo(10, 20, _0);
  return 0;
}

이번에는 뭔가 좀 다르다고 나오기는 하는데 그래봤자 offset 차이다
그러니까 위와 동일하게 mov [rbp - 4], 0을 제외하면 동일하다는 소리다
문제는 x value를 capture하기 위해 이상한 짓을 하고 있긴 한데..
이건 나중에 재대로 다루고 싶으니 조금 묵혀놓는 걸로 하자

이렇듯 우리는 compiler가 하는 짓을 흉내정도는 낼 수 있다


사실 이 글을 쓰게된 이유는 일반적으로 lambda에 대한 시각 때문이다
구글링을 많이하다보면 cpp의 lambda에 대해 극명한 의견을 볼 수 있다
한쪽은 초반부에 말했던 것처럼 간단한 문법을 장점으로 내세우며 상당히 긍정적으로 평가한다
반면에 다른쪽에서는 function object랑 다를게 없다며 상당히 부정적으로 평가한다
심지어 어떤 글에서는 람다조무사라는 표현까지 사용할 정도다..

난 여기서 lambda expression을 평가하고자 하는 것이 아니다
단지 틀린 정보를 바로잡고 싶었을 뿐이다
물론 lambda expression이 function object가 아니라는 것이 아니다
단지, 성능을 위한 많은 노력들은 모두 무시한채 function object와 동급으로 치부해서는 안된다는 것이다

올바른 정보에 근거해야 올바른 판단을 하고 적절한 상황에 활용할 수 있을 것이다
그리고 lambda도 그렇게 사용되어야 한다
근데 거의 모든 상황에서 Compiler에다가 -O3 옵션 넣고 걍 남발해도 된다..

0개의 댓글