ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [C++] 람다 표현식
    C++/이것이 C++이다 2021. 12. 6. 16:49

    람다 표현식 (Lambda Expression)은 람다식 (Lambda)으로 자주 불리는 함수 선언 방식입니다.

    람다식은 이전 글(함수)에서 살펴본 함수 객체, 함수 포인터와 유사하게 함수를 전달하는 데 주로 사용됩니다.

     

    람다식은 다음과 같은 형태로 구성됩니다.

    [] () mutable throw() -> int
    {
        int a;
        a++
        
        return a;
    }

    [] : Capture clause, Labmda introducer라고 합니다. 람다식 외부 변수에 접근하기 위해 사용합니다.

    () : Parameter list입니다. 파라미터가 없을 경우 생략 가능합니다.

    mutable : Capture를 통해 참조, 복사 한 외부 변수의 상수성을 제거하는 데 사용됩니다. 필요한 경우에만 기술하는 속성입니다.

    throw(), noexcept : 예외 처리 정책입니다. 기술하지 않거나, 특정 예외를 발생시키거나, 예외 발생 시 런타임 에러를 발생시킬 수 있습니다. 필요한 경우에만 기술하는 속성입니다.

    -> int : 람다식의 반환 타입입니다. 반환 타입이 없을 경우 생략 가능합니다.

     

    이번 글에서는 람다식의 구성 요소와, 람다식의 특징에 대해 가볍게 살펴보도록 하겠습니다.

     


     

    1. Capture clause

     

    더보기

    람다식 내부는 기본적으로 외부와 분리되어 있습니다.

    같은 함수, 같은 클래스에서 선언이 되었더라도 선언된 스코프 내의 변수에 대한 접근이 불가합니다.

    Capture clause는 람다식 내부에서 외부 변수에 접근하기 위한 선언입니다.

     

    람다 캡쳐는 크게 복사 캡쳐, 참조 캡쳐의 두 종류로 나뉘게 됩니다.

    각 캡쳐의 사용방법은 이하와 같습니다. 

    복사 캡쳐 해당 변수를 기술합니다. (예시 : [ {변수명} ] )
    디폴트 복사 캡쳐 '=' 문자를 넣는 것으로, 현재 스코프의 모든 변수를 복사 캡쳐합니다.
    (예시 : [ = ] )
    참조 캡쳐 '&' 문자를 해당 변수 앞에 기술합니다. (예시 : [ &{변수명} ] )
    디폴트 참조 캡쳐 '&' 문자를 단독 사용하는 것으로, 현재 스코프의 모든 변수를 참조 캡쳐합니다.
    (예시 : [ & ] ) 

    각 캡쳐는 중복 사용이 가능하지만, 한 변수가 다른 방식으로 중복 캡쳐되는 것은 할 수 없습니다.

     

    예제를 살펴보겠습니다.

    #include <iostream>
    using namespace std;
    
    class number {
    public:
        number(int init) : value(init) { cout << "Init number : " << value << "\n"; }
        number(const number& rhs) : value(rhs.value) { cout << "Copy construct : " << value << "\n"; }
        number& operator++() { value++; return *this; }
        friend ostream& operator<<(ostream& os, const number& data);
    private:
        int value;
    };
    
    ostream& operator<<(ostream& os, const number& data) {
        os << data.value;
        return os;
    }
    
    int main() {
        number a(5);
        number b(10);
        cout << "\n";
        [&a, b]() {cout << ++a << " " << b << "\n\n"; }();
        [a, &b]() {cout << a << " " << ++b << "\n\n"; }();
        [=, &b]() {cout << a << " " << ++b << "\n\n"; }();
        [=]() {cout << a << " " << b << "\n\n"; }();
    
    
        return 0;
    }
    Init number : 5
    Init number : 10
    
    Copy construct : 10
    6 10
    
    Copy construct : 6
    6 11
    
    Copy construct : 6
    6 12
    
    Copy construct : 6
    Copy construct : 12
    6 12

    정수를 담는 클래스 number와, cout 및 단항 증가 연산을 위한 함수와 클래스를 선언했습니다.

    mian함수의 각 람다 캡쳐는 아래와 같습니다.

    [&a, b] : a는 참조 캡쳐, b는 복사 캡쳐를 했습니다.

    [a, &b] : a는 복사 캡쳐, b는 참조 캡쳐를 했습니다.

    [=, &b] : 모든 변수를 복사 캡쳐하지만, b는 참조 캡쳐 했습니다.

    [=] : 모든 변수를 복사 캡쳐 했습니다.

    출력을 통해 복사 캡쳐를 할 경우 복사 생성자를 호출하는 것을 확인할 수 있습니다.

     

    2. Const qualification (mutable)

     

    더보기

    위 문단에서, 람다 캡쳐를 통해 캡쳐한 외부 변수는 크게 두 종류인 것을 알아보았습니다.

    이 중에서, 복사 캡쳐는 람다식 내부에서 변경 할 수 없습니다.

     

    예제를 살펴보겠습니다.

    #include <iostream>
    using namespace std;
    
    int main() {
        int a = 10;
        int b = 20;
    
        [a, &b] {
            cout << ++a << "\n";
            cout << ++b << "\n";
        }();
    
        return 0;
    }
    C3491 : 'a':변경 불가능한 람다에서 복사 방식 캡처를 수정할 수 없습니다.

     위 예제는 참조 캡쳐만 사용했을 경우 정상적으로 컴파일되고 작동합니다.

     

    복사 캡쳐한 변수는 상수처럼 취급된다는 것을 알 수 있습니다.

    상수처럼 취급되는 복사 캡쳐된 변수들을 변경하는 예약어가 mutable 입니다.

    mutable 예약어는 이전 글 에서 알아본 mutable과 용도가 일맥상통 합니다.

    람다식에 붙이는 것으로, 그 람다식이 복사 캡쳐하는 변수들을 수정할 수 있게 합니다.

     

    예제를 살펴보겠습니다.

    #include <iostream>
    using namespace std;
    
    int main() {
        int a = 10;
        int b = 20;
    
        [&] {
            cout << ++a << " " << ++b << "\n";
        }();
        cout << a << " " << b << "\n";
    
        [=]() mutable {
            cout << ++a << " " << ++b << "\n";
        }();
        cout << a << " " << b << "\n";
    
        return 0;
    }
    11 21
    11 21
    12 22
    11 21

    mutable 예약어를 통해 복사 캡쳐된 변수도 값의 수정이 가능함을 볼 수 있습니다.

    하지만 복사이기 때문에, 람다식 외부의 원본 변수에는 영향이 없습니다.

     

    3. Excpetion handling (throw, noexcept)

     

    더보기

    람다식 또한 예외처리가 가능합니다.

    이 때, 기존의 예외처리와 같이 예외 try-catch 블록에서 처리를 할 수도 있지만

    noexcept 예약어를 통해, 예외 처리를 하지 않는 람다식을 만드는 것도 가능합니다.

     

    예제를 살펴보겠습니다.

    #include <iostream>
    using namespace std;
    
    int main() {
        try {
            [] () throw() {
                throw 5;
            }();
        }
        catch (int i) {
            cout << "Exception[i] : " << i << "\n";
        }
    
        try {
            []() noexcept {
                cout << "No Exception\n";
            }();
        }
        catch (int i) {
            cout << "Exception[i] : " << i << "\n";
        }
    }
    Exception[i] : 5
    No Exception

    noexcept 예약어가 있는 람다식에서 예외가 발생할 경우, 프로그램이 종료됩니다.

    throw()로 예외처리가 있는 람다식 또한 처리되지 않은 예외가 발생할 경우, 프로그램이 종료됩니다.

    두 예약어 모두 (처리되지 않은) 예외가 발생할 경우 프로그램이 종료된다는 특징이 있지만, 차이점이 존재합니다.

    이에 관해서는 StackOverflow의 글을 인용하도록 하겠습니다.

    Is there any difference between noexcept and empty throw specification for an lambda expression?

    throw()와 noexcept이 std::terminate()를 호출하기 까지의 과정이 다른 것과

    noexcept이 throw()가 발생시킬 수 있는 런타임 오버헤드를 줄일 수 있다는 내용입니다.

     

    4. 함수 객체 vs 람다식

     

    더보기

    함수 객체와 람다식은 서로 유사한 점이 많습니다.

    특정 상황에 맞는 기능을 정의하는 데 사용하는 부분에서는, 함수 객체와 람다식은 동일한 기능을 할 수 있습니다.

    하지만, 함수 객체와 람다식은 서로를 완전히 대체할 수 없습니다.

     

    예제를 살펴보겠습니다.

    #include <iostream>
    #include <vector>
    #include <algorithm>
    using namespace std;
    
    class Functor {
    public:
        Functor() {  cout << "Class init\n"; }
    
        void operator()(int i) const {
            cout << i;
            if (i % 2 == 0)
                cout << " is even | ";
            else
                cout << " is odd | ";
        }
    };
    
    int main() {
        std::vector<int> arr;
        
        for (int i = 1; i < 10; i++) {
            arr.push_back(i);
        }
    
        std::for_each(arr.begin(), arr.end(), [](int i) {
            cout << i;
            if (i % 2 == 0)
                cout << " is even | ";
            else
                cout << " is odd | ";
        });
    
        cout << "\n";
    
        std::for_each(arr.begin(), arr.end(), Functor());
    }
    1 is odd | 2 is even | 3 is odd | 4 is even | 5 is odd | 6 is even | 7 is odd | 8 is even | 9 is odd |
    Class init
    1 is odd | 2 is even | 3 is odd | 4 is even | 5 is odd | 6 is even | 7 is odd | 8 is even | 9 is odd |

    위 예제는 1~9까지의 정수 배열에서 짝수와 홀수를 출력하는 예제입니다.

    vector는 배열과 유사한 역할을 하는 STL입니다.

    for_each는 STL의 algorithm 헤더에 있는 함수입니다. 배열의 지정된 범위 내에서 3번째 인자로 지정한 연산을 수행합니다.

    동일한 기능을 하는 코드를 람다식과 함수 객체를 이용해서 각각 하나씩 작성했습니다.

     

    위 코드가 서로 분리되어 있다고 가정할 경우, 람다식은 소스코드가 매우 직관적이게 됩니다.

    또한, 람다식은 함수 객체를 이용할 때 필연적으로 발생하는 생성자, 소멸자 호출에 대한 오버헤드가 없습니다.

    하지만 람다식이 함수 객체보다 좋은 것은 아닙니다.

    람다식은 그 특성상 규모가 커지는 것이 곤란합니다.

    복잡한 기능을 하거나, 비슷한 기능 (유사 기능의 매개변수만 다른 기능) 을 하는 함수를 정의하는 등

    함수 객체는 람다식보다 확장에 용이합니다.

     


     

    람다식은 직관적이고, 간결합니다.

    함수 포인터의 포인터 관련 문제나, 함수 객체의 객체 초기화, 소멸 오버헤드가 없습니다.

    하지만 람다식이 다른 함수 전달 방식보다 우월한 것은 아닙니다.

    설계에 따라 얼마든지 함수 객체를 써야 하는 상황이 있을 것 입니다.

    특정 상황에 맞는 방법을 사용할 수 있도록 양쪽 모두 사용할 수 있도록 합시다.

     

    읽어주셔서 감사합니다.

    'C++ > 이것이 C++이다' 카테고리의 다른 글

    [C++] 함수  (0) 2021.11.28
    [C++] 예외 처리  (0) 2021.11.25
    [C++] 스마트 포인터  (0) 2021.11.03
    [C++] 템플릿  (0) 2021.10.26
    [C++] 객체 간 관계  (0) 2021.10.19

    댓글

Designed by Tistory.