C, C++/C++ 언어 / / 2024. 7. 26.

[C++] 람다 표현식( lamda expression )에 대한 설명

람다 표현식( lamda expression )이란

줄여서 람다( lamda )라고도 하는 람다 표현식은 익명의 함수 객체를 정의하고 사용하기 위한 표기법입니다.

이 표현식은 간결한 기능을 구현하는데 너무 많은 손이 가는 것을 막고자 하는 목적으로 C++11부터 도입되었습니다.

 

다음의 예제는 vector의 원소들을 특정 값에 더하는 간단한 코드입니다.

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int sum = 0;	// 전역 변수

void add_func(int val){
    sum += val;
}

int main(){

    vector<int> vec = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

    for_each( vec.begin(), vec.end(), add_func);    // 일반 함수 사용

    cout << sum;
}

여기서, for_each 함수는 iterator로 정의되는 구간의 모든 원소에 대해 지정한 함수를 적용시키는 함수입니다.

이 함수를 쓰면 for 구문을 통해 원소들을 순환하는 방법보다 간결합니다.

 

for_each 함수에 관한 내용은 여기에서 볼 수 있습니다.

 

[C++] for_each 함수 사용법

for_each 함수for_each는 주어진 구간 내에 있는 원소들에 대하여 지정한 함수 객체를 적용하는 함수입니다. 이 함수를 사용하려면 다음의 헤더를 포함해야 합니다.#include  함수의 정의는 다음과 같

codingembers.co.kr

 

그러나, 이 for_each 함수는 단일 매개 변수를 사용하는 함수를 인자로 필요하기 때문에, 인자인 add_func 함수는 전역 변수( 여기서는 sum )의 도움이 필요합니다.

또한, 이 간단한 문장이 하는 일을 알아내려면, add_func 함수의 내용을 찾아봐야 하는 불편함이 있습니다.

 

그래서, 대안으로 사용할 수 있는 것이 있습니다.

바로, 상태 정보를 저장할 수 있는 함수 객체( function object )입니다.

struct add_func_obj{	// 함수 객체
    int& sum ;

    add_func_obj(int& s) : sum(s){}

    void operator()(int val){
        sum += val;
    }
};

int main(){

    vector<int> vec = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

    int sum = 0;
    for_each( vec.begin(), vec.end(), add_func_obj(sum));    // 함수 객체 사용
    
    cout << sum;
}

그런데, 이것은 뭔가 잘못된 것 같다는 생각이 듭니다.

함수 객체( function object )는 기능을 확장하는 면에서는 간결하고 편리하지만, 이 경우는 일반 함수보다 더 불편합니다.

 

하지만, 람다 표현식을 사용한다면 다음과 같이 구현할 수 있습니다.

int main(){

    vector<int> vec = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

    int sum = 0;
    
    // 람다 표현식 사용
    for_each( vec.begin(), vec.end(), [&sum](int val){ sum += val;} );    
    
    cout << sum;
}

이 구현은 전역 변수를 사용하지도 않고, 긴 클래스를 정의할 필요도 없습니다.

게다가, 사용자 함수를 찾아보기 위해 코드를 뒤질 필요도 없습니다.

 

 

람다 표현식의 선언

다음은 입력된 값에 특정한 값을 곱해서 반환하는 함수입니다. 

double multiplier = 20.0;
double multiply(int val){
    return multiplier * val;
}

이 함수를 람다 표현식으로 바꾸면 다음과 같습니다.

double multiplier = 20.0;
[multiplier] (int val) ->double { return multiplier * val; };

 

이 람다 표현식은 아래와 같이 4 부분으로 나눠집니다.

lamda expression의 구조

 

캡처( capture )

double multiplier = 20.0;
[multiplier] (int val) ->double { return multiplier * val; };

위의 [ multiplier ] 부분은 람다 표현식 외부의 지역변수를 받아들이는 부분입니다.

그래서, 람다 표현식을 구현할 때 받아들인 외부 지역변수를 사용할 수 있습니다.

좀 더 정확히 말하자면, 외부 지역변수와 같은 이름의 변수를 선언하고, 이 변수를 사용하는 것입니다.

 

이렇게 외부의 변수를 값( call by value ) 또는 참조 ( call by reference ) 방식으로 수용하는 것을 캡처( capture )라고 합니다.

 

이 캡처 부분에선 캡처 리스트를 통해서, 변수 하나뿐이 아니라, 원하는 개수만큼의 변수를 캡처하는 것이 가능합니다.

아래에 캡처 리스트 선언하는 방식과 의미를 정리했습니다.

 

  • [ ] : 캡처할 변수가 없는 경우라도 람다 표현식을 표시하기 위해서 [ ]를 생략해서는 안됩니다.
  • [=] : 모든 지역 변수들을 값으로 복사합니다.
  • [&] : 모든 지역 변수들을 참조합니다.
  • [ capture-list ] : 캡처하고자 하는 변수들을 나열합니다. ( 예를 들어, [ a, b, c ] )
  • [ &, capture-list] : 명시한 변수들은 값으로, 나머지 변수들은 참조로 캡처합니다.
  • [ =, &capture-list] : 명시한 변수들은 참조로, 나머지 변수들은 값으로 캡처합니다.

 

그리고, C++ 14부터는 초기화 캡처( initialization capture )가 가능합니다.

이것은 캡처 부분에서 정의한 변수를, 값을 표현하는 식을 통해 초기화하는 기능을 말합니다.

 

예를 들어, 위의 예제를 다음과 같이 변경할 수 있습니다.

double multiplier = 20.0;
[x = multiplier] (int val) ->double { return x * val; };	// 초기화 캡처

이 코드는 외부 지역변수를 사용해서 변수 x를 선언하고 초기화하는 방법을 보여줍니다.

이때, 변수 x의 타입은 초기화에 사용되었던 표현식으로부터 추론합니다.

multiplier의 타입이 double이므로, x의 타입도 double이 됩니다.

 

이 초기화 캡처는 지역 변수뿐만이 아니라, C++ 11에서는 불가능했던 표현식으로도 초기화할 수 있기 때문에 좀 더 일반화되었다는 의미로 일반화된 람다 캡처( generalized lamda capture )라고 부르기도 합니다.

 

초기화 캡처는 아예 아래와 같이 할 수도 있습니다.

double ret = [x = 20.0](int val) ->double { return x * val; }(5);

이 경우, 더 이상 외부의 변수가 필요 없습니다.

 

그리고, 다음과 같이 참조 변수를 초기화할 수도 있습니다.

int a = 5;
[&x = a](){ x += 2; }();

cout << a << endl;	// 7

위에서, 참조 x가 a를 참조하도록 초기화하고 있습니다.

이 코드를 실행시키면, a의 값은 7이 됩니다.

 

주의할 점은, 캡처는 지역 변수( local variable )만을 대상으로 한다는 것입니다.

double global = 2.0;    // 전역 변수

int main(){
    
    static double multiplier = 20.0;    // 정적 변수
    [=] (int val) ->double { return global * multiplier * val; };
}

위에서, [=] 때문에 외부 변수들이 캡처된 것처럼 보이지만, 이 경우 실제로 캡처된 것은 하나도 없습니다.

 

매개 변수

double multiplier = 20.0;
[multiplier] (int val) ->double { return multiplier * val; };

위의 ( int val ) 부분은 매개 변수 부분입니다. 

만약, 매개 변수가 필요하지 않은 경우 생략이 가능합니다.

 

그리고, C++14부터는 generic 람다 표현식이 가능해졌습니다.

이 이전에는 매개 변수의 타입을 반드시 명시해야 했습니다.

그렇지만, 이제는 매개 변수에도 auto 키워드를 사용할 수 있습니다.

 

auto는 변수를 초기화하는 데 사용되는 표현식으로부터, 변수의 타입을 추론하는 키워드입니다.

auto가 변수의 타입을 추론하는 규칙은 여기서 볼 수 있습니다.

 

[C++] auto의 형식 추론( type deduction ) 규칙

auto의 형식 추론auto는 컴파일러가 초기화 표현식으로부터 변수의 타입을 추론하도록 하는 키워드입니다.물론, 변수의 타입을 추론하는데 기준이 되는 규칙이 있습니다.정말 다행인 것은, 이 규

codingembers.co.kr

 

int main(){

    auto findMax = [](auto x, auto y){ 	// generic 람다 표현식
        return x > y ? x : y;
    };

    int a = 10, b = 5;
    cout << findMax(a, b) << endl;	// int 타입

    double da = 20.0, db = 50.0;
    cout << findMax(da, db) << endl;	// double 타입
}

위의 findMax 람다 표현식은 매개 변수에 auto를 사용했기 때문에, 어떤 타입이라도 전달이 가능합니다.

 

반환 타입

double multiplier = 20.0;
[multiplier] (int val) ->double { return multiplier * val; };

위의 ->double 부분은 람다 함수가 반환하는 값의 타입을 나타냅니다.

만약, 람다 표현식의 반환 값이 없거나 반환 값이 있더라도 컴파일러가 타입을 추론할 수 있다면 생략이 가능합니다.

 

위의 람다 표현식에는 반환 값이 있습니다.

return multiplier * val;	// double * int

 

그렇지만, 컴파일러가  반환 타입이 double임을 추론할 수 있기 때문에 생략이 가능합니다.

double multiplier = 20.0;
[x = multiplier](int val){ return x * val; };	// ->double 생략 가능

 

 

클로저( closure )

람다 표현식은 이름 그대로 하나의 표현식으로, 소스 코드의 일부분입니다.

그리고, 이 표현식으로부터 컴파일러는 고유한 클로저 클래스를 작성합니다.

 

이 클로저 클래스는 실행 시, 임시 함수 객체를 생성합니다.

double multiplier = 20.0;
[=] (int val) ->double { return multiplier * val; };	// 클로저

이 실행시점 객체를 클로저( closure )라고 부릅니다.

 

클로저는 다음과 같이 실행할 수 있습니다.

double multiplier = 20.0;
double computed = [=] (int val) ->double { return multiplier * val; }(15);    // 300.0

 

그리고, 클로저를 복사해서 재사용할 수도 있습니다.

double multiplier = 20.0;
auto multiply = [=] (int val) ->double { return multiplier * val; };  // closure 복사 

double computed1 = multiply(15); 
double computed2 = multiply(7);

위 코드는 multiply 변수에 임시 클로저를 복사합니다.

이전 항목에서 설명했듯이, auto 키워드는 선언된 변수 또는 람다 표현식의 매개 변수 초기화 식을 사용하여, 해당 형식을 추론하도록 컴파일러에 지시하는 키워드입니다.

 

클로저를 저장하는 다른 방식으로, std::function 객체를 사용하는 방법도 있습니다.

#include <iostream>
#include <functional>	// std::function 객체
using namespace std;

int main(){

    double multiplier = 20.0;
    std::function< double(int) > multiply;  // function 객체
    
    multiply = [=] (int val) mutable ->double { return multiplier * val; };

    double computed1 = multiply(16);    // 160
    cout << "computed1: " << computed1 << endl;
}

std::function 객체는, 호출할 수 있는(callable) 임의의 객체들( 함수, 함수 객체, 클로저...)을 저장할 수 있는 표준 라이브러리 객체입니다.

이 객체는 다음과 같이 선언할 수 있습니다.

std::function< return_type ( parameter...) > function_name;

 

이 객체에는 반환 타입과 매개 변수의 타입만 일치하면, 캡처 리스트가 다르더라도 클로저를 저장할 수 있습니다.

std::function< double(int) > multiply;  // function 객체
    
double multiplier = 20.0;
multiply = [multiplier](int val)->double { return multiplier * val; };

multiply = [x = 20.0](int val)->double { return x * val; };

 

std::function에 관한 자세한 내용은 여기에서 볼 수 있습니다.

 

[C++] 함수 포인터를 확장한 std::function 사용법

std::functionC++ 표준 라이브러리에서 제공하는 function 객체는 callable이라고 불리는 모든 대상을 저장하고 실행하는 객체입니다.여기서 callable은 ()를 붙여서 호출할 수 있는 대상을 말합니다. 예를

codingembers.co.kr

 

 

mutable 키워드

람다 표현식에서 변수를 값으로 캡처하게 되면, 그 변수는 const 속성을 가지게 됩니다.

이 const 속성을 제거할 필요가 있을 때 사용할 수 있는 키워드가 mutable입니다.

int main(){

    double multiplier = 20.0;
    auto multiply = [=] (int val) mutable ->double { // 값으로 변수를 캡처
        if ( val % 2 == 0 ) 
            multiplier /= 2.0;  // mutable 변수의 값을 변경
        return multiplier * val; 
    };

    double computed1 = multiply(16);    // 160
    double computed2 = multiply(7);     // 140 ??    

    cout << "computed1: " << computed1 << endl;
    cout << "computed2: " << computed2 << endl;
}

▼출력

computed1: 160
computed2: 70

람다 표현식 내에서 mutable 사용을 통해 캡처된 multiplier변수가  const 속성을 잃어버렸습니다.

그래서, 16을 인자로 입력했을 때, 16은 짝수이므로 multiplier의 값을 10으로 변경할 수 있었습니다.

 

주의할 점은, 이 람다 객체의 multiplier는 외부의 변수를 복사했으므로, 외부 변수 multiplier의 값은 변경되지 않습니다.

그리고, 이 객체를 다시 실행했을 때도 캡처된 multiplier가 클래스 멤버 변수같이 변경된 값을 유지한다는 것입니다.

 

위에서 multiply를 두 번째 실행했을 때의 인자값 7은 짝수가 아니지만, multiplier의 값은 첫 번째 실행했을 때 이미 변경되어 있습니다.

그래서, 두 번째의 결과 값은 70입니다.

 

 

클래스 내의 람다 표현식과 this

아래의 람다 표현식에서는 Add_Scale 함수의 범위( scope )를 넘어서 클래스의 멤버 변수를 직접 캡처할 수 없습니다.

class ScaledAdd{
    double m_div;	// 멤버 변수

public:

    double Add_Scale( int a, int b){
       return [a, b, m_div](){ return (a + b)/m_div; }();   // error !
    }
};

따라서, 위에서 m_div 멤버 변수를 사용한 코드는 오류입니다.

 

그렇지만, 암시적인 this를 캡처하면, 이를 통해서 m_div에 접근할 수 있습니다.

class ScaledAdd{
    double m_div = 2.0;
    double m_result = 0;
public:

    double Add_Scale( int a, int b){

       //return [a, b, this](){ return m_result = (a + b)/m_div; }();   // ok !

       return [=](){ return m_result = (a + b)/m_div; }();   // ok !
    }

    double GetResult(){ return m_result;}
};

int main(){

    ScaledAdd obj;
    obj.Add_Scale( 10, 20);
    cout << obj.GetResult();
}

▼출력

15

this를 명시적으로 캡처해도 되고, [=]를 사용해도 모든 지역 변수를 캡처하기 때문에, this가 캡처됩니다.

( Add_Scale 함수의 지역 변수에 암시적인 this가 존재합니다. )

게다가, this를 const 속성으로 캡처하더라도 ( 값으로 캡처 시의 const 속성 ), this의 멤버 변수가 const 속성을 부여받은 것이 아니기 때문에 멤버 변수 m_result의 값을 변경할 수도 있습니다.

 

 

람다 표현식의 타입

std::map을 선언할 때, map의 원소들을 정렬하는 방법을 지정할 수 있습니다.

그때 필요한 것이, 정렬할 때 사용할 함수 객체의 타입입니다.

template <class Key,
    class Type,
    class Traits = less<Key>,	
    class Allocator=allocator<pair <const Key, Type>>>
class map;

위 선언의 세 번째 매개 변수 Traits에, 컨테이너의 원소들을 비교하는 함수 객체의 타입을 요구하고 있는 것을 볼 수 있습니다.

 

그런데, 람다 표현식( 좀 더 정확하게는 클로저 클래스 )에는 이름이 없으며, 각각의 타입은 고유합니다.

따라서, 이러한 람다 표현식의 타입을 아는 방법은 decltype을 사용하는 것뿐입니다.

auto comp = []( const string& s1, const string& s2){
    return s1 < s2;
};

// decltype을 사용해 람다 객체의 타입 전달
std::map< string, int, decltype(comp) > table(comp);

그리고, map의 생성자를 통해, 실제로 사용하게 될, 비교 기능이 구현된 comp 객체를 map에 전달해야 합니다.

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유