본문 바로가기

개발자/C++(Linux, Window)

C++ Lambda 사용 이유 및 정리

반응형

C++에는 lambda라는 문법이 존제합니다.

 

원래 이 lambda는 boost라는 라이브러리에서 제공하는 함수였지만

 

지금은 modern c++로 넘어가면서 이 기능을 기본적으로 지원하게 되었습니다.

 

출처 : https://modoocode.com/196

 

 

 [] 캡쳐 블록 (사용시 외부 변수를 캡쳐해 람다 몸통에서 사용 가능)

 

 () 전달 인자

 

 -> 반환 타입

 

 {} 함수 몸통

 

(Lambda는 기본적으로 캡쳐 블록'[]', 전달인자 '()', return type을 생략할 수 있습니다.)

 

우선!!

왜 사용하는지 부터 설명해 보겠습니다.

 

람다는 함수 객채와 함수 포인터의 장점만을 가지고 있습니다.

 

1. 함수 객채와는 다르게 class를 선언할 필요가 없다.

(즉, 코드의 길이가 줄어든다.)

 

2. 함수 포인터의 단점은 "함수의 인라인화가 불가능하다." 입니다. 

하지만 람다는 "함수의 인라인 화가 가능합니다."

(단, 정확히 명시되어 있는 람다 함수만 인라인화가 가능합니다. 이 부분에 대해선 아래에 설명하겠습니다.)

 

 

 

아래 예시는 함수 객체를 이용하여 만든 정렬 코드 입니다.

 

 

실행 결과

 

#include<iostream>

using namespace std;

class UP {
public:
    bool operator()(int a, int b) {
        return (a > b ? true : false);
    }
};

class DOWN {
public:
    bool operator()(int a, int b) {
        return (a < b ? true : false);
    }
};

template <class T>
void simple_sort(int *arr, int n, T cmp) {
    for (int i = 0; i < n - 1; i++) {
        for (int j = i + 1; j < 5; j++) {
            if (cmp(arr[i], arr[j]))
                arr[i] ^= arr[j] ^= arr[i] ^= arr[j];
        }
    }
}

void sort_print(int *arr, int n) {
    for (int i = 0; i < 5; i++)
        cout << arr[i] << " ";
    cout << endl;
}

int main(void) {
    int arr[5] = { 10, 5, 41, 100, 2 };

    DOWN down;
    UP up;

    simple_sort(arr, 5, down);
    sort_print(arr, 5);

    simple_sort(arr, 5, up);
    sort_print(arr, 5);


    return 0;
}

 

아래 예시는 람다를 사용한 예시 입니다.

 

#include<iostream>

using namespace std;

template <class T>
void simple_sort(int *arr, int n, T cmp) {
    for (int i = 0; i < n - 1; i++) {
        for (int j = i + 1; j < 5; j++) {
            if (cmp(arr[i], arr[j]))
                arr[i] ^= arr[j] ^= arr[i] ^= arr[j];
        }
    }
}

void sort_print(int *arr, int n) {
    for (int i = 0; i < 5; i++)
        cout << arr[i] << " ";
    cout << endl;
}

int main(void) {
    int arr[5] = { 10, 5, 41, 100, 2 };

    simple_sort(arr, 5, [](int a, int b) {
        return (a < b ? true : false); });
    sort_print(arr, 5);

    simple_sort(arr, 5, [](int a, int b) {
        return (a > b ? true : false); });
    sort_print(arr, 5);


    return 0;
}
​

 

실행 결과는 함수 객체의 예시와 동일합니다.

 

하지만 함수 객체의 예시와 비교해 보면 많이 간결해진걸 볼 수 있습니다.

 

또한 저렇게 될 경우 컴파일러는 자동으로 인라인화 시켜줍니다.

 

 

 

그럼 자동 인라인화가 되는 람다의 예시를 설명하겠습니다.

 

#include<iostream>
#include<functional>

int main(void) {

    auto f1 = [](int a, int b) {                    //인라인화 OK!!
        return a + b;
    };

    int(*f2)(int, int) = [](int a, int b) {            //인라인화 Fail!!
        return a + b;
    };

    std::function<int(int, int)> f3 = [](int a, int b) {    //인라인화 Fail!!
        return a + b;
    };

    return 0;
}​

 

우선!! 설명하기에 앞서 한 가지 아셔야 할 사실이 있습니다.

 

※람다는 이름은 없지만 고유한 객체 입니다.

 

다시말해서 프로그래머는 람다의 이름과 어떤 타입으로 정의 되었는지 알 수 없지만 컴파일 과정에서 람다라는 객체를 생성한다는 말입니다.

 

때문에 람다가 인라인화 가능한 이유입니다.

 

하지만 이런 람다도 포인터를 사용해 간접 전달이 되거나 명확하게 명시되어 있지 않으면(람다를 담을 변수가 상수화 되지 않으면) 

일반 함수와 다를게 없어집니다.

 

왜냐하면 컴파일러가 인라인하는 기준은 명확하게 명시되어 있는 함수만을 인라인(치환) 하기 때문입니다.

(여기서 명확하게라는 의미는 상수를 뜻합니다. 즉, 변경이 불가능한 변수들 말이죠.)

 

 

 

6행을 보면 auto 선언으로 람다 함수를 정의하고 있습니다.

저기서 auto는 어떤 type으로 정의되어 있는지 프로그래머는 알지 못합니다.

이건 코드 내부적으로 컴파일러가 알아서 만들기 때문에 그렇습니다.

때문에 저렇게 선언한 후 f1변수는 변경이 불가능 합니다.

 

이미 상수화가 되버려서 저 코드는 컴파일러가 인라인화를 진행하게 됩니다.

 

 

 

10행의 f2변수는 함수 포인터로 선언된 변수 입니다. 람다는 이름은 없지만 고유의 객체 임으로 해당 객체의 주소 값을 

함수 포인터 변수로 전달할 수 있습니다. 

 

하지만 f2는 '포인터 변수'이기 때문에 다른 값을 재 참조가 가능합니다. 즉, 프로그램 실행 중 f2값이 변경될 수 있다는 뜻입니다.

(값의 변경은 참조하는 주소 값의 변경입니다.)

때문에 람다를 함수 포인터로 전달해 주면 함수 포인터로써의 의미만을 지니며 인라인화가 될 수 없습니다.

 

 

 

 

마지막 14행의 f3변수를 보겠습니다.

많이들 착각하시는게 6행의 auto가 std::function<>이랑 같다는 생각입니다.

즉, std::function<>을  auto가 대신한다고 생각하는데, 이는 잘못된 생각입니다.

 

visual studio로 코드를 짜 봐도 auto로 선언한 람다 변수는 형식에 대한 의미만 보여주지 저것의 명확한 타입을 말해주진 않습니다.

또한 std::function<>는 모든 함수를 담을 수 있는 템플릿 객체 입니다.

 

내부 구현도 복잡하게 되어 있고 함수, 함수 객체, 람다 등을 담을 수 있도록 만들어져 있습니다.

따라서 std::function<>으로 람다 함수를 담을 순 있지만 람다가 std::function<>에 의해 정의되는 것은 결코 아닙니다.

 

std::function<>은 모든 함수를 담을 수 있도록 설계되어 있기 때문에 f3변수의 경우도 inline화가 되지 않습니다.

왜냐하면 f3변수는 f2변수와 마찬가지로 값의 변경이 가능하기 때문입니다.

 

 

 

https://stackoverflow.com/questions/41121441/type-of-a-lambda-function-using-auto

 

https://stackoverflow.com/questions/16729941/are-stdfunctions-inlined-by-the-c11-compiler

 

(위 링크는 stackoverflow에서 Lambda과 std::function의 관계에 대해 찾은 내용입니다.)

 

 

이제 lambda의 사용방법에 대해 설명하겠습니다.

 

설명 내용은 cppreference를 참고했습니다.(https://en.cppreference.com/w/cpp/language/lambda#Lambda_capture)

 

 

 

Lambda Capture 설명

[capture](params) -> return  

 

우선 lambda의 capture부분 입니다.

 

 

 

우선 [=] 예시부터 보겠습니다.

 

기본적으로 capture 연산자 '=' 로 외부 변수를 갖고오면 const형태로 읽기만 가능합니다.

 

 

#include<iostream>

using namespace std;

int main(void) {

    int x = 10;
    double y = 5.4;
    string s = "hwan";


    auto l1 = [=](){
        int cp_x = x;
        double cp_y = y;
        string cp_s = s;
        return x + y;
    }();

    auto l2 = [=, x = x + 1](){
        cout << "l2함수의 x의 주소 값 : " << &x << endl;
        return x * x;
    };


    cout << "l1 = " << l1 << endl;
    
    cout << "l2 = " << l2() << endl;
    cout << "l2 = " << l2() << endl;
    cout << "x = " << x << endl;
    cout << "main함수의 x 주소 값 : " << &x << endl;

    return 0;
}​

 

 

실행 결과

 

 

 

 

12행을 보면 '[=]' 만 표시하고 있습니다.

그리고 함수 몸통 뒤에 소괄호'()' 가 있지요.

 

함수 몸통 뒤에 소괄호'()'를 붙이냐 안붙이냐에 따라 실행하는 매커니즘이 살짝 달라짐니다.

이것에 대해선 아래 '&'연산자에서 말씀드리겠습니다.

 

l1 람다 함수에선 변수 선언과 외부 변수(main변수)를 가져와 사용하고 있습니다.

(대입만 하고 있습니다. 외부 변수는 const형태로 변경이 불가능 합니다.)

 

실행 결과 첫번째를 보시면 'l1 = 15.4' 가 보일 겁니다.

즉, 반환 값은 double로 정해졌다는 뜻이죠.

 

 

 

다음 19행을 보겠습니다. 

[=, x = x + 1]이라고 선언되어 있는데,

이건 x라는 변수를 선언과 동시에 초기화 하겠다는 뜻입니다.

그리고 실행 결과를 보면 l2함수의 'x' 주소 값과 main 함수의 'x' 주소 값이 다른걸 알 수 있습니다.

 

 연달아 호출 해도 람다에서 한번 선언된 x변수의 주소 값은 변하지 않습니다.

 

 

이를 통해 알 수 있는 2가지 사실이 있습니다.

 

첫째, 람다에서 선언된 변수는 프로그램이 끝날 때 까지 변수가 사라지지 않는다.

즉, 람다는 객체 라는말이 성립이 되는 것입니다.

 

 

둘째, 외부 변수와 같은 변수 이름으로 초기화를 한다면 해당 외부 변수는 사용되지 않는다.

 

이런 식으로 되버려서 외부변수 'x'값은 람다 함수 내부에선 더 이상 사용이 불가능 하게됩니다.

때문에 결과 값도 "10 * 10"이 아닌 "11 * 11" 값인 '121'을 보여주고 있습니다.

 

또한 저렇게 Capture블록 에서 초기화된 변수는 const로 선언이 됩니다.

따라서 초기화된 변수는 변경이 불가능 합니다.

 

 

그럼 Capture 연산자인 '&'에 대해 설명하겠습니다.

 

#include <iostream>

using namespace std;

int main() {
    int x = 10;

    auto l1 = [&](){
        x = 5;
        return x;
    };

    auto l2 = [&, x = x + 100](){
        return x;   
    };

    cout << l1() << endl;
    cout << "main  x  : " << x << endl;;
    cout << l2() << endl;
    cout << "main  x  : " << x << endl;;

    return 0;
}
​

 

 

 

 

 

 

 

실행결과

 

'&'연산자는 외부 값을 참조 형태로 받아옵니다.

때문에 '=' 연산자와는 다르게 '&'연산자는 외부 변수의 값을 변경할 수 있습니다.

 

8행의 l1 람다를 보면 x 값을 변경해 주고 있습니다.

따라서 x 값은 5로 변경 되었습니다.

 

 

 

13행을 봅시다. 'x = x + 100' 으로 되어 있습니다.

이상하지 않습니까??

105가 출력 되어야 할 것 같은데 110이 출력되고 있습니다.

 

 

그 이유는......

람다는 기본적으로 const로 변수 값을 읽어오는 곳이 있습니다.

바로 Capture에서 초기화된 변수와 람다 함수 정의 후 몸통 뒤에 호출 인자인 소괄호'()' 부분 입니다.

 

 

const로 선언된 변수는 컴파일 후 메모리로 올라가게 되는데 이때 Data영역으로 들어가게 됩니다.

따라서 const로 선언된 변수는 실행 중 바뀔 수 없죠.(mutable 제외)

 

 

함수 몸통 뒤에 소괄호 '()' 부분은 람다의 실행을 의미함과 동시에 매개 변수를 전달하는 역할을 합니다.

 
auto l1 = [](int x) -> int{
    x++;
    return x;
}(5);

////////////////////////
l1의 출력 값 :
6
////////////////////////


////////////////////////
int z;

cin >> z;

auto l2 = [](int y) -> int{
    y++;
    return y;
}(z);

int num1 = l2; // ok
int num2 = l2(30); //error

////////////////////////

위 예제의 l1람다 함수의 출력 값은 6입니다.

그리고 저렇게 선언하면 l1을 몇번 호출하든 6의 결과 값이 나옵니다.

l2 부분을 보면 변수 z에 사용자가 변수 값을 입력 받을 수 있도록 했습니다.

저기서 사용자가 10을 입력했다고 가정 했을 때 결과 값은 11이 나오게 됩니다.

하지만 l2함수가 실행되고 z의 값을 변경한 후, l2함수를 몇번을 실행하던 결과 값은 11이 나옵니다.

왜냐하면 '()' 이부분은 const로 선언되고 한번 정의되면 변경되지 않기 때문입니다.

 

 

하지만 함수 몸통 뒤에 소괄호'()'가 없다면??

이는 매개 변수로 어떠한 값이 들어올지 모르기 때문에 프로그램이 실행되면 함수의 정의만 이뤄질 뿐 

실질적인 실행은 해당 함수의 호출인 l1(), 또는 l2(), 로 이뤄지게 됩니다.

때문에 소괄호'()'를 붙이지 않은 람다 함수를 호출하면 람다 함수 전체를 실행하는걸 볼 수 있습니다.

(Capture 설명의 '=' 연산자 부분 예제를 보시면 이해하실 겁니다.↑↑↑↑↑↑)

 

 

 

 

본론으로 돌아가서 

결과 값이 110이 나온 이유는 람다 함수가 정의 될 때 이미 const 선언에 의해 미리 x값을 갖고 시작하기 때문입니다.

따라서 실질적으로 x값이 바뀌는 부분은 16행인 l1() 함수를 호출할 때 이고, 

다음 l2()를 호출해도 초기 값은 실행하기에 앞서 미리 정의되었음으로 변경된 'x = 5' 가 아닌, 초기 값 'x = 10'을 가져오게 됩니다.

 

 

 

 

마지막으로 Capture 부분은 생략이 가능합니다. 따라서 위 예제인 l2나 l3 람다 함수 처럼 사용이 가능합니다.

 

 

 

 

 

 

Lambda Params 설명

 

[capture](params) -> return  

 

params는 매개 변수를 의미 합니다.

일반적인 함수를 정의할 때 사용하는 매개변수랑 같다고 보시면 됩니다.

 

 

즉, 해당 함수로 전달하고 싶은 매개 변수가 있으면 선언하면 되고 해당 변수는 일반 변수로 선언 됩니다.

 

 

#include <iostream>

using namespace std;

int main() {

    int num = 10;

    auto l1 = [](int a, int b) {
        a++;
        b--;

        return a + b;
    };

    auto l2 = [](int a, int b) {
        a++;
        b--;

        return a + b;
    }(num, 2);

    cout << l1(10, 5) << endl;
    cout << l2 << endl;

    return 0;
}

 

실행 결과

 

9행을 보면 l1 람다 함수로 부터 매개변수 a와 b를 받도록 되어 있습니다.

그 후23행에서 호출할 때 매개 변수로 10과 5를 전달해 주고 있죠.

l1 람다 함수는 호출 될 때마다 매개 변수값이 달라질 수 있음으로 함수를 처음부터 끝까지 실행하게 됩니다.

 

하지만 16행의 l2 람다 함수를 보면 함수 몸통 뒤에 소괄호'()'로 매개 변수를 지정해서 보내주고 있습니다.

num과 5로 말이죠.

이녀석들은 const로 전달되어 이미 결과 값이 정해저 있습니다.

때문에 24행에서 l2 람다 함수를 호출하면 정해진 결과 값 12가 호출되고 그 후 몇번을 호출해도 결과 값은 바뀌지 않습니다.

또한 프로그램 실행시 16행에서 람다 끝 부분인 21행까지 쫙 훑으면서 실행하지만, 

그 다음 l2함수를 호출하게 되면 결과 값만 반환해 출력해 주는 모습을 볼 수 있을 겁니다.

(main의 num 값을 변경해도 말이죠.)

 

 

마지막으로 Params 부분역시 생략이 가능합니다.

 

 

 

 

Lambda return 설명

 

[capture](params) -> return  

 

return 부분은 람다 함수의 반환 값(return)의 type을 지정해 주는 역할을 합니다.

 

예시를 보겠습니다.

#include <iostream>

using namespace std;

int main() {

    double num = 10;

    auto l1 = [](double a, int b) ->int {
        
        return a + b;
    };

    auto l2 = [](int a, int b)->auto {
        a++;
        b--;

        return a + b;
    }(num, 5);

    int(*fc_1)() = []() -> int {
        return 100;
    };

    int(*fc_2)() = [](){
        return 10000;
    };

    cout << l1(10.1, 5) << endl;
    cout << 12 << endl;
    cout << fc_1() << endl;
    cout << fc_2() << endl;

    return 0;
}
​

 

 

 

실행 결과

 

 

9행을 보면 인자 값으로 double 변수 a와 int 변수 b를 매개변수로 받고 있습니다.

때문에 원래 반환 값은 double로 반환 되지만, return type을 int로 선언해서 실행 결과는 정수 15가 출력이 됩니다.

 

14행 처럼 return type을 auto로 선언해 줄 수 있습니다.

 

21행처럼 함수 포인터의 형식에 맞게 return type을 결정해 줄 수 있고

이렇게 선언하면 프로그래머는 한눈에 return type을 알 수 있기에 좋습니다.

 

25행처럼 return type을 생략해 줄 수도 있습니다.

 

 

 

 

 

설명하다보니 글이 길어져서 보기 불편하시겠지만, 이 글을 읽고 도움이 되었다면 좋겠습니다.

더 다양한 예제는 https://en.cppreference.com/w/cpp/language/lambda 부분을 참고하시면 되겠습니다.

반응형