본문 바로가기

진리는어디에/C++

[C++20] 람다 표현식의 변화

들어가며

이번 포스트에서는 C++20의 람다 표현식에 추가된 사항들을 살펴 볼 것이다. 간략하게 요약하면 아래 다섯 가지 정도로 정리 할 수 있다 :

  • 템플릿 람다 추가 : 람다 표현식에서 템플릿 사용 가능
  • 평가 되지 않은 표현식(unevaluated expression)에서 람다 표현식 사용 가능
  • 캡쳐를 사용하지 않는 람다 표현식에서 디폴트 생성자와 대입 연산자 사용 가능
  • 암시적인 this 캡쳐가 컴파일 시 경고 처리 됨(앞으로 deprecated 될 것이라고 경고)
  • Parametaer Pack 캡쳐 가능

템플릿 람다 표현식(template lambda expression)

처음 살펴 볼 변경 사항은, C++20 부터 람다 표현식에도 템플릿의 사용이 가능해졌다는 것이다(기존 C++17까지는 람다 표현식에 템플릿을 사용 할 수는 없었다). 과연 람다 표현식에 템플릿을 사용한다는 것이 무슨 의미이고 무엇에 도움이 될까? 먼저 아래 코드를 살펴 보자.

auto add1 = [](int a, int b) { return a + b; }
auto add2 = [](auto a, auto b) { return a + b; }

std::cout << add1(1, 2) << std::endl; // 3
std::cout << add1(1.1, 2.2) << std::endl; // 컴파일 에러는 없지만 데이터 손실이 발생하여 3

add2의 auto는 '제네릭 람다 표현식'이라고 C++14 부터 추가 된 기능이다. 위와 같은 람다 표현식을 작성하면 컴파일러는 내부적으로 ()연산자를 오버로딩한 다음과 같은 코드를 생성하여 add2는 ClosureType의 객체가 된다.

NOTE : [C++] 제네릭 람다 표현식 참고
class ClosureType
{
public :
    template<typename T1, typename T2>
    
    inline constexpr
    auto operator()(T1 a, T2 b) const
    {
        return a + b;
    }
    // ...
}

auto add2 = [](auto /* T1 */ a, auto /* T2 */ b) { return a + b; }

핵심은 같은 타입이라고 하더라도 하나의 템플릿 인자가 선언 되는 것이 아니라, T1, T2 처럼 각 인자에 다른 템플릿 타입이 선언 된다는 것이다.

std::cout << add2(1, 2) << std::endl; // int + int, 3
std::cout << add2(1.1, 2.2) << std::endl; // double + double, 3.3
std::cout << add2(1, 2.2) std::endl; // int + double, 3.2

add와 같은 경우 다른 타입을 인자로 받는 것이 자연스럽지만, swap은 어떨까? 서로 다른 타입을 swap한다는 것은 이상하니 같은 타입의 값을 교환 할수 있도도록 동일 타입에 대해서만 동작하게 만드는 것이 좋을 것이다. 

두 개의 인자가 같은 타입만 사용할 수 있게 만들 수 있는 방법은 없을까?

첫번째 생각해볼수 있는 방법은 첫번째 인자를 auto로 하고 두번째 인자를 decltype을 이용하여 동일 타입을 선언하게 할 수 있다.

auto add3 = [](auto a, decltype(a) b) { return a + b; }

std::cout << add3(1, 2) << std::endl; // int + int, 3
std::cout << add3(1.1, 2.2) << std::endl; // double + double, 3.3
std::cout << add3(1, 2.2) << std::endl; // int + int, 3

두 인자를 같은 타입으로 선언하게 하는 것은 성공했지만 세번째 호출되는 add3()에서 2.2는 int로 암시적 다운 캐스팅 되어 3이 나온다. 우리가 원하는 것은 이렇게 타입이 다른 경우 컴파일 타임에 에러를 발생 시키는 것인데 제네릭 람다 표현식으로는 해결을 할 수가 없다.

하지만 C++20 부터는 람다에 템플릿 선언을 적용하여 위 문제를 해결 할 수 있다.

auto addT = []<typename T>(T a, T b) { return a + b; } // since C++20

// Generic Lambda Expression C++20
clsss ClosureType
{
public :
    template<typename T>
    inline constexpr
    auto operator()(T a, T b) const
    {
        return a + b;
    }
}
// 두개의 인자가 서로 같은 타입을 가지도록 만들어 줄수 있다.

std::cout << addT(1, 2) << std::endl; // int + int, 3
std::cout << addT(1.1, 2.2) << std::endl; // double + double, 3.3
std::cout << addT(1, 2.2) << std::endl; // int or double + double or int?? error!!

세번째 호출 'addT(1, 2.2)' 에서 컴파일러는 템플릿을 int로 해야 할지 double로 해야 할지 알 수 없기 때문에 우리의 의도대로 서로 다른 타입의 인자에 대해 에러를 발생 시킨다.

위 타입의 모호함을 제거하기 위해 마치 템플릿 함수를 호출하는것 처럼 람다 호출에 타입을 명시적으로 선언 하는 방법을 생각 할수 있다. 하지만 addT는 함수가 아니라 () 연산자를 오버로딩 한 클래스의 객체다(사실 좀 더 복잡하지만 '함수가 아니라 객체' 라는 것에 집중하자).

그래서 마치 템플릿 함수 처럼 addT<int>(1, 2.2)라는 것은 객체에 템플릿 인자를 적용하는 것이라 에러가 발생한다. 우회적인 방법으로 템플릿 함수를 호출 하듯이 ()연산자를 호출하면서 템플릿 인자를 명시적으로 지정 할 수 있다.

addT.operator()<int>(1, 2.2); // int + int, 3
정리 : 람다 표현식은 결국 ()연산자가 재 정의 되는 것이다

그러면 람다 표현식에는 템플릿을 사용 했는데, 템플릿에는 람다 표현식을 사용 할 수 없는가? 물론 가능하다(하지만 C++20 부터 가능하다). 아래 스마트 포인터에 커스텀 삭제자를 넘겨주는 예제를 살펴 보자.

struct Freer1
{
    inline void operator()(void* p) const noexcept
    {
        std::cout << "free" << std::endl;
        free(p);
    }
};

int main()
{
    std::unique_ptr<int> up1(new int);
    
    std::unique_ptr<int, Freer> up2(static_cast<int*>(malloc(100)));
    
    auto freer2 = [](void* p) { free(p); };
    std::unique_ptr<int, decltype(freer2)> up3(static_cast<int*>(malloc(100)));
}

기존 C++17까지는 템플릿 인자에 람다 표현식을 사용 할 수 없었기 때문에 함수 객체를 만들어서 타입을 넘기거나 람다 객체를 사용하더라도 직접적으로 사용 할 수 없고 객체를 만들고, decltype을 이용해 삭제자 타입을 넘겨 줘야만 했다. 하지만 C++20 부터는 아래와 같이 바로 람다를 넘길 수 있다.

std::unique_ptr<int, decltype([](void* p) { free(p); })> up2(static_cast<int*>(malloc(100)));

평가되지 않은 표현식에서 람다 표현식 사용가능(unevaluated expression)

평가되지 않은 표현식이란?

런타임에 실행 되지 않고 컴파일 타임에 조사(타입 추론)를 위해서만 사용되는 식

C++에는 총 네 가지의 평가되지 않는 표현식이 있다 :

  • sizeof(exp) : 표현식 결과 타입의 크기
  • decltype(exp) : 표현식 결과의 타입
  • typeid(exp) : 표현식 결과의 타입 정보
  • noecpt(exp) : 표현식 예외 여부

자세한 설명을 위해 아래 예제를 살펴 보자 :

int add(int a, int b) { return a + b; }

std::cout << sizeof(int) << std::endl; //4
std::cout << sizeof(add(1, 2)) << std::endl; // sizeof(함수 호출식) : 리턴 타입의 크기
    
decltype(add(1, 2)) n; // int n, 4

sizeof(add(1, 2))는 컴파일 타임에 add(1, 2) 에서 리턴 하는 타입의 사이즈를 추론한다. sizeof() 안에 있는 함수가 과연 런타임에 호출 될 것인가? 답은 아니오다.

sizeof는 컴파일 타임에 계산 되며 add() 함수에서 리턴하는 값을 조사해서 해당 함수의 리턴 값의 타입의 사이즈를 추론한다. 괄호 안에 있는 함수는 런타임에 실행 되지는 않는다. 이것이 바로 평가되지 않은 표현식이다.

위 add() 함수에서 몸체를 구현하지 않아도 타입 추론에만 사용되고 실행을 하지는 않으므로 컴파일 에러가 발생하지는 않는다.

C++20 부터는 저 평가 되지 않는 표현식 안에 람다 표현식을 사용 할 수 있다.

std::cout << sizeof([](int a, int b) { return a + b }) << std::endl;
// 람다 표현식이 자체가 들어갔기 때문에 1이 출력 된다

std::cout << sizeof([](int a, int b) { return a + b }(1, 2)) << std::endl;
// 람다 표현식을 호출하고 있고, 그 리턴 타입은 int이기 때문에 4가 출력된다

첫번째 sizeof는 람다 표현식 자체가 들어갔기 때문에 1이 출력 된다. 람다 표현식이 리턴하는 타입에 대해 추론하게 하기 위해서는 두번째 처럼 람다를 호출 해야 한다.

캡쳐하지 않은 람다 표현식에서 디폴트 생성자와 대입 연산자 사용 가능

C++17까지는 디폴트 생성자를 지원하지 않았기 때문에 디폴트 생성자가 필요한 decltype에 람다 객체를 넣는 경우 에러가 발생했다. 하지만 C++20 부터는 지원한다.

// 함수 객체, 디폴트 생성자 있음
class FunctorAdd
{
public:
    FunctorAdd() = default;

    inline constexpr
        int operator()(int a, int b) const
    {
        return a + b;
    }
};

// 람다 표현식, C++17까지 디폴트 생성자 없음
auto LambdaAdd = [](int a, int b) {
	return a + b;
};

int main()
{
    FunctorAdd fAdd;
    decltype(fAdd) f2;      // f1과 동일한 타입의 객체를 만든다. 
    decltype(fAdd) f3 = f1; // 복사 생성자
    f3 = fAdd;              // 대입 연산자
    
    decltype(lAdd) f4;      // C++20 이전에는 에러. 
    decltype(lAdd) f3 = f4; // 복사 생성자
    f4 = lAdd;              // 대입 연산자
    
    int n = 10;
    auto f5 = [n](int a, int b) { return a + b; } //캡쳐를 하는 람다
    
    decltype(f5) f6;       // 에러
    decltype(f5) f7 = f5;  // 복사 생성자, 성공
    f7 = f5;               // 에러 
 }

위의 FunctorAdd의 경우에는 디폴트 생성자가 있기 때문에 C++20 이전 컴파일러에서도 정상적으로 컴파일이 되지만, 이전 버전 컴파일러의 경우 "lambda []int (int a, int b)->int"의 기본 생성자를 참조할 수 없습니다. 삭제된 함수입니다."라는 컴파일 에러를 발생 시킨다.

또한 주의 해야 할 것이 '캡쳐'를 사용한 람다는 복사 생성자 외에는 지원이 되지 않는다.

암시적인 this 캡쳐가 deprecated 됨

람다 표현식을 사용 할 때 this 포인터를 암시적으로 캡처하게 되면 의도치 않게 발생 할 수있는 정의되지 않은 상황에 대해서 알아채기가 힘든 경우가 있다. 그래서 C++20 부터는 암시적인 캡쳐를 하는 경우 컴파일러에서 곧 deprecated 될 기능이니 사용하지 말고, 필요하면 명시적으로 this를 사용 하라는 경고가 발생한다. 어떤 경우에 암시적 this 포인터 캡쳐가 문제를 일으킬 수 있는지 아래 예제를 살펴 보자.

struct Sample
{
    int value = 0;
    auto foo()
    {
        int n = 10;
        // [=] : [=, this] 모든 지역 변수를 캡쳐하고, 암시적으로 this도 캡쳐 한다
        auto f = [=](int a) { return a + n + value; };
        std::cout << sizeof(f) << std::endl; // 8(int n과 this의 포인터)
        return f;
    }
};

std::function<int(int)> f;

void goo()
{
    Sample s;
    f = s.foo();
    std::cout << f(10) << std::endl; // 문제 없음
}

int main()
{
    goo();
    std::cout << f(10) << std::endl; // 문제!!
}

Sample 클래스를 살펴 보면 foo() 함수 내에서 모든 로컬 변수를 캡쳐하는 람다 객체를 생성하여 리턴한다. 멤버 함수 안에서 [=] 캡쳐는 모든 지역 변수 뿐만 아니라 암시적으로 this 포인터도 캡쳐 하겠다는 뜻이다. 그리고 람다 안에서는 캡쳐된 this를 이용해 참조하는 value 변수가 있다.

main 함수 안에서 goo()를 호출하면 Sample 클래스의 로컬 변수가 생성 되고, 그 로컬 변수의 주소를 참조하는 람다 객체를 생성하여 전역 변수 f 저장했다. 객체 s가 살아 있는 동안인 goo() 함수 안에서 f(10)을 호출 할 때는 아무런 문제가 없지만, 함수가 종료 되어 객체가 파괴 되고 난 뒤인 main() 함수에서의  f(10) 호출은 이미 지워져 버린 객체 s의 this를 참조 하고 있으므로 정의 되지 않은 상태가 발생한다.

그래서 암시적인 this는 어지간하면 사용하지 말라고, 필요하다면 사용자가 인지한 상태에서 명시적으로 지정하여 사용하라고 경고를 띄우는 것이다.
※ 컴파일객체가 파괴될 상황이지만 꼭 필요 하다면 [=, *this] 와 같이 this를 값 타입으로 캡쳐해서 사용한다면 문제가 없긴 하다.

Parameter pack 캡쳐 가능

#include <iostream>

// Capture Parameter pack by value
template<typename ...Args> auto f1(Args&&... args)
{
    return [...args = std::forward<Args>(args)]() { std::cout << ... << args); };
}

// Capture Parameter pack by reference
template<typename ...Args> auto f2(Args&&... args)
{
    return [&...args = std::forward<Args>(args)]() { std::cout << ... << args); };
}

int main()
{
    f1(1, 2, 3, 4, 5)();
    
    std::cout << std::endl;
    
    int a = 1, b = 2, c = 3;
    f2(a, b, c)();
}

별거 없다. 저렇게 캡쳐가 된다.

안된다. '...'을(는) 예기치 않은 토큰입니다. 필요한 토큰은 '식'입니다. 라는 오류를 뱉어 내고 컴파일 안된다.

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

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