본문 바로가기
프로그래밍/C & C++

[C++] Lambda Expression (람다 표현식)

by 별준 2022. 2. 23.

References

Contents

  • Lambda Expression

람다 표현식(Lambda expression)이란 함수나 함수 객체를 별도로 정의하지 않고 필요한 지점에서 곧바로 함수를 직접 만들어 사용할 수 있는 일종의 익명 함수(anonymous function)입니다. 람다 표현식을 사용하면 익명 함수를 인라인으로 작성할 수 있습니다. 문법은 매우 쉽고, 코드를 깔끔하게 만들 수 있으며, 읽기도 쉬워집니다. 특히 다른 함수에 전달되는 짧은 콜백 함수를 인라인으로 작성할 때 유용합니다. 

 

1. 문법

먼저 람다 표현식의 문법을 살펴보겠습니다. 다음 예제는 콘솔에 문자열을 출력하는 람다 표현식을 정의하고 있습니다. 람다 표현식은 lambda introducer(람다 소개자)라고 부르는 대괄호([])로 시작하고, 그 뒤에 람다 표현식의 본문을 담는 중괄호({})가 나옵니다. 아래 예제는 람다 표현식이 auto 타입 변수인 basicLambda에 대입됩니다. 이렇게 정의한 람다 표현식은 두 번째 줄처럼 일반 함수를 호출하는 방식으로 실행됩니다.

auto basicLambda{ []{ std::cout << "Hello from Lambda" << std::endl; } };
basicLambda();

위 코드를 실행하면 "Hello from Lambda"를 출력합니다.

 

컴파일러는 자동으로 람다 표현식을 함수 객체로 변환하며 이를 컴파일러에 의해 생성되는 이름인 람다 클로저(lambda closure)라고 부릅니다. 위의 경우에서 람다 표현식은 다음의 함수 객체처럼 행동하는 함수 객체로 변환됩니다.

class CompilerGeneratedName
{
public:
    auto operator()() const { std::cout << "Hello from Lambda" << std::endl; }
};

여기서 함수 호출 연산자는 const 메소드이며, 컴파일러가 메소드 바디를 기반으로 자동으로 리턴 타입을 추론하기 때문에 auto 리턴 타입을 갖는다는 것에 주의합니다.

이렇게 컴파일러에 의해 생성되는 람다 클로저의 이름은 __Lambda_17Za 처럼 이상합니다. 물론 이 이름을 알 필요는 없습니다.

 

람다 표현식은 파라미터를 받을 수 있습니다. 람다 표현식의 파라미터는 일반 함수와 마찬가지로 소괄호 ()로 묶어서 표현합니다. 매개변수가 여러 개라면 각각을 콤마로 구분하고, 매개변수가 하나일 때는 다음과 같이 작성합니다.

auto parametersLambda {
    [](int value){ std::cout << "The value is " << value << std::endl; } };
parametersLambda(42);

만약 람다 표현식에서 매개변수를 받지 않을 때는 빈 소괄호만 적거나 생략해도 됩니다.

 

이 람다 표현식으로부터 컴파일러에 의해 생성되는 함수 객체에서 파라미터는 오버로딩된 함수 호출 연산자에서의 파라미터로 간단하게 변환됩니다.

class CompilerGeneratedName
{
public:
    auto operator()(int value) const { std::cout << "The value is " << value << std::endl; }
};

 

람다 표현식은 값을 리턴할 수 있습니다. 리턴 타입은 다음과 같이 화살표 뒤에 지정합니다. 이러한 표기법을 trailing return type(후행(후위) 리턴 타입)이라고 부릅니다. 다음 코드는 두 매개변수로부터 받은 정수를 더한 결과를 리턴하는 람다 표현식을 보여줍니다.

auto returningLambda{ [](int a, int b) -> int { return a + b; } };
int sum{ returningLambda(11, 22) };

리턴 타입은 생략할 수 있습니다. 그러면 컴파일러는 일반 함수의 리턴 타입 추론 규칙에 따라 람다 표현식의 리턴 타입을 추론합니다. 따라서 위 코드에서 리턴 타입을 생략하여 다음과 같이 작성할 수 있습니다.

auto returningLambda{ [](int a, int b){ return a + b; } };
int sum{ returningLambda(11, 22) };

이 람다 표현식의 클로저는 다음과 같이 동작합니다.

class CompilerGeneratedName
{
public:
    auto operator()(int a, int b) const { return a + b; }
};

 

리턴 타입 추론은 레퍼런스와 const를 지워버립니다. 예를 들어, 다음의 Person 클래스가 있다고 가정해봅시다.

class Person
{
public:
    Person(std::string name) : m_name{ std::move(name) } {}
    const std::string& getName() const { return m_name; }
private:
    std::string m_name;
};

그렇다면 다음의 람다 표현식에서 비록 getName()이 const string& 타입을 리턴하더라도 리턴 타입은 string으로 추론 되고 m_name의 복사가 발생합니다.

[](const Person& person) { return person.getName(); }

후행 리턴 타입을 decltype(auto)처럼 혼합하여 사용하면 추론된 타입을 실제 getName()의 타입과 일치시킬 수 있습니다.

[](const Person& person) -> decltype(auto) { return person.getName(); }

 

람다 표현식은 자신이 속한 스코프에 있는 변수에 접근할 수 있습니다. 예를 들면 다음 코드는 data라는 변수를 람다 표현식의 본문에서 사용합니다.

double data{ 1.23 };
auto capturingLambda{ [data]{ std::cout << "Data = " << data << std::endl; } };

여기서 대괄호 부분을 람다 캡처 블록(capture block)이라고 합니다. 방금 나온 코드처럼 어떤 변수를 대괄호 안에 지정하여 람다 표현식의 본문 내에서 그 변수를 사용하게 만들 수 있습니다. 이를 캡처한다(capturing)라고 표현합니다. 캡처 블록을 []와 같이 비워두면 람다 표현식이 속한 스코프에 있는 변수를 캡처하지 않습니다. 방금 코드처럼 캡처 블록에 변수 이름만 쓰면 그 변수를 값으로 캡처합니다.

 

캡처한 변수는 람다 클로저의 데이터 멤버가 됩니다. 값으로 캡처된 변수는 펑터의 데이터 멤버로 복사됩니다. 이 데이터 멤버들은 캡처한 변수의 const 속성을 그대로 이어받습니다. 위에서 살펴본 capturingLambda 예제에서 펑터는 캡처한 변수가 con-const이기 때문에 non-const 데이터 멤버(data)를 갖게 됩니다. 컴파일러에 의해 생성되는 펑터는 다음과 같이 동작합니다.

class CompilerGeneratedName
{
public:
    ComplierGeneratedName(const double& d) : data{ d } {}
    auto operator()() const { std::cout << "Data = " << data << std::endl; }
private:
    double data;
};

 

하지만 다음 코드에서는 캡처한 변수가 const이기 때문에 펑터는 const 데이터 멤버를 갖게 됩니다.

const double data{ 1.23 };
auto capturingLambda{ [data] { std::cout << "Data = " << data << std::endl; } };

펑터마다 함수 호출 연산자인 operator()가 구현되어 있는데, 람다 표현식의 경우 이 연산자는 기본적으로 const로 설정됩니다. 따라서 non-const 변수를 람다 표현식에 값으로 캡처해도 람다 표현식 안에서 이 값의 복사본을 수정할 수 없습니다. 하지만 다음과 같이 람다 표현식을 mutable로 지정하면 함수 호출 연산자를 non-const로 만들 수 있습니다.

double data{ 1.23 };
auto capturingLambda{
    [data]() mutable { data *= 2; std::cout << "Data = " << data << std::endl; } };

이 코드에서는 non-const data 변수를 값으로 캡처했습니다. 따라서 data의 복사본이 생성되는 펑터의 데이터 멤버는 non-const 입니다. 여기서 mutable 키워드를 지정해주었기 때문에 함수 호출 연산자도 non-const입니다. 따라서 람다 표현식의 본문에서 data의 복사본을 수정할 수 있습니다. 참고로 mutable을 지정할 때는 매개변수가 없더라도 소괄호를 반드시 적어주어야 합니다.

 

변수 이름 앞에 &를 붙이면 레퍼런스로 캡처합니다. 다음 코드는 data 변수를 레퍼런스로 캡처해서 람다 표현식 안에서 data를 직접 수정하고 있습니다.

double data{ 1.23 };
auto capturingLambda{ [&data] { data *= 2; } };

변수를 레퍼런스로 캡처하려면 람다 표현식을 실행하는 시점에 레퍼런스가 유효한지 반드시 확인해야 합니다.

 

람다 표현식이 속한 스코프의 변수를 모두 캡처할 수도 있는데, 다음의 2가지 방법이 있습니다.

  • [=] : 스코프에 있는 변수를 모두 값으로 캡처합니다.
  • [&] : 스코프에 있는 변수를 모두 레퍼런스로 캡처합니다.

캡처 리스트(capture list)를 지정하면 캡처할 변수를 골라서 지정할 수 있습니다. 이때 캡처 디폴트(cpature default)도 함께 지정할 수 있습니다. 캡처 리스트에서 앞에 &가 붙은 변수는 레퍼런스로 캡처합니다. &가 없는 변수는 값으로 캡처합니다. 변수 이름 앞에 &나 =을 붙이려면 반드시 캡처 리스트의 첫 번째 원소가 캡처 디폴트(& 또는 =)이어야 합니다. 캡처 블록에 대한 예를 몇 가지 들면 다음과 같습니다.

  • [&x] : 변수 x만 레퍼런스로 캡처
  • [x] : 변수 x만 값으로 갭처
  • [=, &x, &y] : x와 y는 레퍼런스로 캡처하고, 나머지는 값으로 캡처
  • [&, x] : x만 값으로 캡처하고, 나머지는 레퍼런스로 캡처
  • [&x, &x] : 식별자(변수 이름 등)을 중복해서 지정했으므로 잘못된 표현
  • [this] : 현재 객체를 갭처. 람다 표현식의 본문 안에서 이 객체에 접근할 때 this->를 붙이지 않아도 됨
  • [*this] : 현재 객체의 복사본을 갭처. 람다 표현식을 실행하는 시점에 객체가 살아 있지 않을 때 유용
  • (C++20) [=, this] : this 포인터를 명시적으로 캡처하고, 나머지는 값으로 캡처. C++20 이전에는 [=]로 지정하면 암시적으로 this 포인터를 캡처했지만, C++20에서 이 기능은 폐기되었습니다. 만약 필요하다면 명시적으로 this를 캡처해주어야 합니다.

캡처 블록을 사용할 때는 몇 가지 주의할 점이 있습니다.

  • =나 &를 캡처 디폴트로 지정하면, [=, x]나 [&, &x]는 유효하지 않습니다.
  • 객체의 데이터 멤버는 캡처할 수 없습니다. 이는 밑에서 다루도록 하겠습니다.
  • this를 캡처할때, this 포인터를 복사하거나 현재 객체를 복사함으로써 람다 표현식은 캡처한 객체의 모든 public, protected, private 데이터 멤버와 메소드에 액세스할 수 있습니다.
캡처 디폴트가 람다 표현식에서 실제로 사용된 변수만 캡처하더라도 캡처 디폴트는 사용하지 않는 것이 좋습니다. = 캡처 디폴트를 사용하면 자칫 복사 연산이 발생할 수 있는데, 그러면 성능이 큰 영향을 미칩니다. & 캡처 디폴트를 사용할 때는 스코프에 있는 변수를 실수로 수정해버릴 수 있습니다. 가능하면 캡처할 변수를 직접 지정해주어야 합니다.

 

전역 변수는 비록 값으로 캡처하더라도 항상 레퍼런스러 캡처됩니다. 예를 들어, 다음 코드는 값으로 캡처하도록 캡처 디폴트를 사용했지만, 전역 변수 global은 레퍼런스로 캡처되며 람다를 실행한 후에 실제 값이 변경됩니다.
int global{ 42 };
int main()
{
    auto lambda{ [=] { global = 2; } };
    lambda();
    // global now has the value 2
}​

추가로 전역 변수를 명시적으로 다음과 같이 캡처하는 것은 허용되지 않으며 컴파일 에러가 발생합니다.
auto lambda{ [global] { global = 2; } };​

 

람다 표현식의 문법을 정리하면 다음과 같습니다.

[capture block] <template_params> (parameters) mutable constexpr
    noexcept_specifier attributes
    -> return_type requires { body }

각각의 구성 요소는 다음과 같습니다.

  • Capture block(캡처 블록) : 스코프에 있는 변수를 캡처하는 방식을 지정하고, 람다 표현식에서 본문에서 그 변수에 접근할 수 있도록 만들어 줍니다.
  • Template parameters(C++20) : 템플릿 람다 표현식을 작성할 수 있도록 해줍니다. 밑에서 자세히 살펴보겠습니다.
  • Parameters(생략 가능) : 람다 표현식에 대한 매개변수 목록입니다. 매개변수를 받지 않고 mutable, constexpr, noexcept 지정자, 속성, 리턴 타입을 지정하지 않는다면 생략해도 됩니다. 나머지는 일반 변수와 동일합니다.
  • mutable(생략 가능) : 람다 표현식을 mutable로 지정합니다. 자세한 설명은 위의 내용을 참조하길 바랍니다.
  • constexpr(생략 가능) : 람다 표현식을 const로 지정합니다. const를 지정하면 컴파일 시간에 평가됩니다. 명시적으로 지정하지 않더라도 람다 표현식이 일정한 요건을 충족하면 내부적으로 const로 처리됩니다.
  • attributes(생략 가능) : 람다 표현식에 attributes를 지정할 수 있습니다.
  • return type(생략 가능) : 리턴값의 타입을 지정합니다. 생략하면 컴파일러가 추론하며, 그 방법은 일반 함수의 리턴 타입을 추론할 때와 같습니다.
  • Requires clause(C++20, 생략 가능) : 람다 클로저의 함수 호출 연산자에 대한 템플릿 타입 제약을 추가합니다. 이는 다음에 템플릿에 관한 포스팅에서 다루도록 하겠습니다.

2. Lambda Expressions as Parameters

람다 표현식은 2가지 방식으로 함수의 파라미터로 전달될 수 있습니다. 하나는 std::function 타입의 함수 파라미터에 람다 표현식을 전달할 수 있고, 다른 하나는 템플릿 타입 파라미터를 사용하는 것입니다.

template<typename Matcher, typename MatchHandler>
void findMatches(std::vector<int>& values1, std::vector<int>& values2,
    Matcher matcher, MatchHandler handler)
{
    if (values1.size() != values2.size()) { return; } // must be same size
    for (size_t i = 0; i < values1.size(); ++i) {
        if (matcher(values1[i], values2[i])) {
            handler(i, values1[i], values2[i]);
        }
    }
}
void printMatch(size_t position, int value1, int value2)
{
    std::cout << "Mismatch found at position " << position << " ("
        << value1 << ", " << value2 << ")\n";
}

예를 들어, 위와 같은 findMatches() 함수가 있을 때, 람다 표현식을 다음과 같이 전달할 수 있습니다.

std::vector values1{ 2, 5, 6, 9, 10, 1, 1 };
std::vector values2{ 4, 4, 2, 9, 0, 3, 1 };
findMatches(values1, values2,
    [](int value1, int value2) { return value1 == value2; },
    printMatch);

3. Generic Lambda Expressions

람다 표현식에서 매개변수의 타입을 구체적으로 지정하지 않고 auto 타입 추론을 적용할 수 있습니다. 매개변수에 auto 타입 추론을 적용하려면 타입 자리에 auto라고만 쓰면 됩니다. 이때 적용되는 타입 추론 규칙은 템플릿 인수 추론과 같습니다.

 

다음 코드는 areEqual이라는 제너릭 람다 표현식을 정의하고 있습니다. 이 람다 표현식은 위에서 본 findMatches() 함수의 콜백으로 사용됩니다.

// Define a generic lambda expression to find equal value.
auto areEqual{ [](const auto& value1, const auto& value2) {
    return value1 == value2; } };
// Use the generic lambda expression in a call to findMatches().
std::vector values1{ 2, 5, 6, 9, 10, 1, 1 };
std::vector values2{ 4, 4, 2, 9, 0, 3, 1 };
findMatches(values1, values2, areEqual, printMatch);

이 제너릭 람다 표현은 다음과 같은 컴파일러 생성한 펑터와 같이 동작합니다.

class CompilerGeneratedName
{
public:
    template<typename T1, typename T2>
    auto operator()(const T1& value1, const T2& value2) const
    { return value1 == value2; }
};

만약 findMatches 함수가 int 타입이 아닌 다른 타입도 지원하도록 수정되어야 하더라도, areEqual 제너릭 람다 표현식은 수정될 필요가 없습니다.

 

 


4. Lambda Capture Expressions

람다 캡처 표현식(lambda capture expression)은 표현식에 사용할 캡처 변수를 초기화합니다. 이는 스코프에 있는 변수 중에서 캡처하지 않았던 것을 람다 표현식에 가져오는데 사용할 수 있습니다. 예를 들어 다음 코드를 보면 람다 캡처 표현식으로 myCapture란 변수를 "Pi:"로 초기화하고, 람다 표현식과 같은 스코프에 있던 pi 변수를 캡처합니다. 참고로 myCapture처럼 non-reference 캡처 변수를 캡처 이니셜라이저(capture initializer)로 초기화할 때는 복사 방식으로 생성됩니다. 따라서 const 지정자가 사라집니다.

double pi = 3.1415;
auto myLambda{ [myCapture = "Pi: ", pi] { std::cout << myCapture << pi; } };

 

람다 캡처 변수는 std::move를 비롯한 모든 종류의 표현식으로 초기화할 수 있습니다. unique_ptr처럼 복사할 수 없고 이동만 가능한 객체를 다룰 때 이점을 반드시 명심해야 합니다. 기본적으로 값으로 캡처하면 복사 방식이 적용됩니다. 그래서 unique_ptr을 람다 표현식에 값으로 캡처할 수 없습니다. 하지만 람다 캡처 표현식을 사용하면 다음과 같이 이동 방식으로 복사할 수 있습니다.

auto myPtr{ std::make_unique<double>(3.1415) };
auto myLambda{ [p = std::move(myPtr)] { std::cout << *p; } };

 

권장하는 방법은 아니지만 캡처 대상인 변수 이름과 캡처해서 람다 표현식 안에서 사용할 변수 이름을 똑같이 정해도 됩니다. 위 코드를 수정하면 다음과 같습니다.

auto myPtr{ std::make_unique<double>(3.1415) };
auto myLambda{ [myPtr = std::move(myPtr)] { std::cout << *myPtr; } };

 


5. Lambda Expressions as Return Type

std::function을 사용하면 람다 표현식을 리턴하도록 함수를 작성할 수 있습니다. 예를 들어 다음 코드를 살펴보겠습니다.

std::function<int(void)> multiplyBy2Lambda(int x)
{
    return [x]{ return 2 * x; };
}

이 함수의 본문을 보면 스코프에 있는 x라는 변수를 값으로 캡처하고 multiplyBy2Lambda의 인수에 2를 곱한 정수값을 리턴하는 람다 표현식을 생성합니다. 이 함수의 리턴 타입은 인수를 받지 않고 정수를 리턴하는 함수인 std::function<int(void)> 입니다. 이 함수의 본문에서 정의한 람다 표현식은 함수 프로토타입과 정확히 일치합니다. 변수 x는 값으로 캡처하기 때문에 이 함수가 람다 표현식을 리턴하기 전에 람다 표현식 안의 x는 x값의 복사본에 바인딩됩니다. 이 함수를 호출하는 방법은 다음과 같습니다.

std::function<int(void)> fn = multiplyBy2Lambda(5);
std::cout << fn() << std::endl;

auto 키워드를 사용하면 더 간결하게 표현할 수 있습니다.

auto fn = multiplyBy2Lambda(5);
std::cout << fn() << std::endl;

위 코드를 실행하면 10이 출력됩니다.

 

함수 리턴 타입 추론을 활용하면 multiplyBy2Lambda() 함수를 다음과 같이 더 간결하게 작성할 수 있습니다.

auto multiplyBy2Lambda(int x)
{
    return [x]{ return 2 * x; };
}

multiplyBy2Lambda() 함수는 변수 x를 값으로 캡처합니다([x]). 만약 다음과 같이 x를 레퍼런스로 캡처하면([&x]) 문제가 생깁니다. 여기서 리턴한 람다 표현식은 대부분 이 함수가 끝난 뒤에 사용됩니다. 그래서 multiplyBy2Lambda() 함수의 스코프는 더 이상 존재하지 않기 때문에 x에 대한 레퍼런스는 이상한 값을 가리키게 됩니다.

auto multiplyBy2Lambda(int x)
{
    return [&x]{ return 2 * x; }; // BUG!
}

 


6. Templated Lambda Expressions

C++20부터 템플릿 람다 표현식이 지원됩니다. 아직 C++20을 쓰는 곳은 별로 없을 것 같아 다루지 않으려고 했으나, 간단하게 살펴만 보도록 하겠습니다.

 

예를 들어, 파라미터로 vector를 전달받는 람다 표현식이 있다고 가정해보겠습니다. 그러나 vector 원소의 타입은 어떤 것이든지 될 수 있습니다. 그러므로 이는 auto를 사용하여 제너릭 람다 표현식이라고 볼 수 있습니다. 이때 람다 표현식의 본문에서는 벡터의 원소의 타입이 무엇인지 파악하길 원합니다.

C++20 이전에는 decltype()과 std::decay_t를 사용해야만 이를 수행(type trait)할 수 있었습니다. 저도 자세히 아는 부분은 아니지만, C++20 이전에서는 다음과 같이 작성해야 했습니다.

auto lambda{ [](const auto& values) {
    using V = std::decay_t<decltype(values)>; // The real type of the vector
    using T = typename V::value_type;         // The type of the elements of the vector
    T someValue{};
    // ...
} };

위 람다 표현식은 다음과 같이 호출할 수 있습니다.

std::vector values{ 1,2,100,5,6 };
lambda(values);

 

하지만 템플릿 람다 표현식을 사용하면 훨씬 더 쉽게 작성할 수 있습니다. 다음 람다 표현식은 위와 똑같은 람다 표현식을 템플릿 타입 파라미터를 사용하여 작성하였습니다.

auto lambda{ [] <typename T> (const std::vector<T>& values) {
    T someValue{};
    // ...
} };

 

 


7. Lambda Expressions in Unevaluated Contexts

C++20에서는 람다 표현식이 소위 unevaluated contexts에서 사용될 수 있도록 합니다. 예를 들어, decltype()에 전달되는 인수는 오직 컴파일 시간에 사용되며 절대로 평가되지 않습니다. 그래서 다음의 코드는 C++17에서는 유효하지 않으나, C++20부터는 유효합니다.

using LambdaType = decltype([](int a, int b) { return a + b; });

 

 


8. Default Construction, Copying, and Assigning

C++20부터 stateless 람다 표현식은 기본적으로 생성되고 복사되고 대입될 수 있습니다.

간단한 예는 다음과 같습니다.

auto lambda{ [](int a, int b) { return a + b; } }; // A stateless lambda.
decltype(lambda) lambda2; // default construction
auto copy{ lambda };      // copy construction
coyp = lambda2;           // copy assignment

unevalated contexts와 람다 표현식을 혼합하여 사용하면 다음과 같은 코드가 유효합니다.

using LambdaType = decltype([](int a, int b) { return a + b; }); // unevaluated

LambdaType getLambda()
{
    return LambdaType{}; // Default construction
}

 

댓글