본문 바로가기

진리는어디에/C++

[C++] 람다(lambda) 표현식 #2

들어가며

이전 포스트([C++] 람다(lambda) 표현식 #1)에서는 람다 표현식의 기본적인 내용을 살펴 보았다. 이번 포스트는 이전 포스트에 이어 람다 표현식에 대해 좀 더 자세히 다뤄 보도록 하겠다.

람다 = 임시 객체?

C++에서 람다의 정의를 보면 다음과 같다.

"an unnamed function object" capable of capturing variables in scope.

이전 포스트에서도 이야기 했듯이 람다는 이름 없는 함수 객체의 일종이다. 정확히 번역하자면 '범위 안에 있는 변수를 캡쳐 할 수 있는 이름 없는 객체'를 람다라고 한다.

이게 무슨 이야기인지 설명하기 위에 이전 포스트의 예제를 상기해보자.

int main()
{
    std::vector<int> v = { 1, 3, 2, 5, 4 };
    
    std::sort(v.begin(), v.end(), [](int a, int b) -> int {
        return a < b;
    });

    // ...
}

위 예제는 vector를 정렬하기 위해 정렬 정책으로 람다를 사용하고 있다. 위 코드 처럼 람다 표현식을 작성하면 컴파일러는 람다 표현식을 다음과 유사한 형식으로 변경 한다.

int main()
{
    // ... 생략 ...
    
    class __CompilerGeneratedName // 컴파일러에 의해 생성된 함수 객체 클래스
    {
    public :
        inline auto operator()(int a, int b) const
        {
            return a < b;
        }

        // ... 기타 등등 컴파일러가 추가 생성하는 코드
    };

    std::sort(v.begin(), v.end(), __CompilerGeneratedName{}); // 람다를 임시 객체로 변경
}

컴파일러는 괄호 연산자를 정의한 클래스 코드를 생성하고 람다 표현식이 사용 되는 부분을 컴파일러가 생성한 클래스의 임시 객체로 대체한다(정확한 대체 코드를 확인하고 싶다면 본 포스트의 맨 아래 부록 부분을 참고 하자).

람다 표현식에서 지역 변수를 캡쳐 했을 때 원리

우리는 앞서 람다 표현식이 컴파일러에 의해 괄호 연산자를 정의한 클래스가 컴파일러에 의해 생성된다는 것을 알았다. 이번 섹션에서는 람다 표현식에서 지역 변수를 캡쳐 했을때 일어나는 일에 대해 살펴 보도록 한다.

int main()
{
  int v1 = 10, v2 = 20;
  
  auto f1 = [v1, v2] (int a) { return a + v1 + v2; };
  
  f1(5);
}

먼저 위와 같은 간단한 람다 표현식이 있다고 가정하자. 위 코드는 앞에서 살펴 본대로 아래 처럼 컴파일러에 의해 괄호 연산을 정의한 클래스로 바뀔 것이다. 단, 차이점이 있다면 캡처에 사용된 v1, v2에 대한 멤버 변수가 추가로 생성 되고 그에 따른 생성자도 만들어 진다는 것이다.

int main()
{
    // ... 생략 ...
    
    class __CompilerGeneratedName // 컴파일러에 의해 생성된 함수 객체 클래스
    {
    private :
        int v1;                   // 지역 변수 캡쳐를 위해 생성된 멤버 변수
        int v2;                   // 지역 변수 캡쳐를 위해 생성된 멤버 변수
    public :
        __CompilerGeneratedName(int& v1, int& v2)
            : v1{v1}, v2{v2}
        {
        }
        
        inline auto operator()(int a) const
        {
            return a + v1 + v2;
        }

        // ... 기타 등등 컴파일러가 추가 생성하는 코드
    };

    auto f1 = __CompilerGeneratedName{v1, v2};
    f1(5);
}

결론적으로 여러분이 지역 변수를 캡쳐하면 캡처된 지역 변수를 보관하기 위해서 멤버 변수가 생성되고, 해당 변수를 초기화할 수 있는 생성자도 만들어 진다는 것이다.

이번에는 단순 캡쳐가 아닌 캡처한 변수를 변경할 수 있도록 'capture by reference'로 캡처해야 한다.

int v1 = 10, v2 = 20;
  
auto f1 = [&v1, &v2] (int a) { v1 = a; v2 = a; };

위와 같이 레퍼런스 캡처를 사용하면 복사 캡처를 사용하던 클래스의 멤버 변수가 참조 타입으로 변경 되어 생성 된다.

class __CompilerGeneratedName // 컴파일러에 의해 생성된 함수 객체 클래스
{
private :
    int& v1;                   // 참조 타입으로 변경 되어 생성
    int& v2;                   // 참조 타입으로 변경 되어 생성
public :
    inline auto operator()(int a) const
    {
        return a + v1 + v2;
    }
};

마치며

이상 이전 포스트에 이어 람다의 컴파일러에 의한 코드 변환과 캡처시 일어나는 일들에 대해 살펴 보았다. 다음 포스트(람다(lambda) 표현식 #3)에서는 다양한 람다의 캡쳐 형태에 대해 이어 살펴 보도록 하겠다.

부록 1. 람다 표현식에 의해 생성 되는 코드

아래 코드는 앞에서 개략적으로 설명 된 람다 표현식을 컴파일러가 어떻게 클래스로 표현하는지 보여 준다

※ 위 코드의 출처는 C++ Insight(https://cppinsights.io/)라는 사이트로서, C++ Insight는 우리가 작성하는 C++ 코드를 컴파일러 입장에서 어떻게 표현하는지 웹브라우저에서 직접 보여준다.

부록 2. 람다 함수 내에서 캡쳐한 값을 바꾸지 못하는 이유

auto f = [v1, v2] (int a) { v1 = a; v2 = a; };

위와 같이 캡쳐한 변수를 람다 함수 내에서 변경하게 되면 컴파일 에러가 발생하게 된다. 그 이유는 컴파일러가 생성하는 클래스의 괄호 연산자는 const 형태로 구현 되기 때문이다.

class __CompilerGeneratedName // 컴파일러에 의해 생성된 함수 객체 클래스
{
private :
    int v1;                   // 지역 변수 캡쳐를 위해 생성된 멤버 변수
    int v2;                   // 지역 변수 캡쳐를 위해 생성된 멤버 변수
public :
    // ... 생략 ...        
    
    inline auto operator()(int a) const // <-- !!!!!0
    {
        v1 = a; // error
        v2 = a; // error
    }

    // ... 생략 ...        
};

만일 const 한정자가 삭제된 괄호 연산자를 원한다면 mutable 람다를 선언하면 된다. 

auto f = [v1, v2] (int a) mutable { v1 = a; v2 = a; };

위와 같은 형식으로 mutable 한정자를 람다 표현식에 추가하면 괄호 연산자 오버로딩에서 const 한정자를 제외하여 오버로딩 된 함수 내에서 값을 변경할 수 있도록 허용한다. 하지만 람다에 의해 생성되는 클래스의 임시 객체는 캡쳐된 변수를 멤버 변수로써 복사해서 가지고 있으므로 클래스 내에서 값을 수정한다고 해도 원본 변수에는 아무런 영향을 미치지 못한다는 것을 명심해야 한다.

부록 3. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!