본문 바로가기

C++/C++ 문법

C++ 람다 표현식 lambda expression

728x90
728x90

이 게시글에서는 C++11부터 등장한 람다 표현식을 배운다.

C++ lambda의 간략한 개요

함수형 프로그래밍의 패러다임을 따라 C++도 JS나 파이썬처럼 익명 함수를 구현하는 문법으로 도입되었다.

C++ lambda 기본

C++ 람다는 다음과 같은 형태를 지니고 있다.

 

[캡처] (매개변수) ->반환형{ 람다의 내용 } (해당 익명 함수를 즉시 호출할 경우 실제 전달하는 인자);

 

이렇게만 보면 이해가 어려울 수 있으니 다음 예시 코드를 통해 실체를 보도록 하자.

 

#include <iostream>

void print1()
{
  std::cout << "Hello!\n";
}

int main()
{
  print1();

  []()->void{
    std::cout << "Hello!\n";
  } ();
}

 

이 코드를 컴파일 후 실행하면 Hello!가 두 줄로 출력된다. 위의 코드에 있는 람다 표현식을 주석과 함께 살펴보도록 하자.

 

/*
일단 캡처는 신경쓰지 말자.
이 함수는 단순히 IO만 하는 함수이므로 매개변수가 필요 없어서 빈 괄호 ()가 들어간다.
역시 출력만 하므로 타입도 void 타입이다.
*/
[]()->void{
  // 중괄호 안에는 이렇게 람다 표현식을 호출 했을때의 실행 내용이 들어있다.
  // 이 코드 같은 경우는 단순히 Hello!를 출력하고 개행을 한다.
  std::cout << "Hello!\n";
} (); // 괄호를 붙이면 즉시 실행을 하게 된다. 매개변수가 없는 함수이므로 당연히 호출할 때 인자를 넘기지 않는다.
// 람다 표현식을 끝맺을 때는 세미콜론을 붙이도록 한다.

 

람다 표현식은 function이 아닌 expression이므로 함수를 다룰 때의 그 문법과 동일하지 않다는 것에 유의하라.

 

위 코드만 봐도 람다 표현식을 끝낼 때는 세미콜론을 붙이도록 되어있다.

 

이렇게 람다 표현식의 기본적인 구조를 보면서 어느 정도 틀을 잡았을 것이다.

 

위에서 마지막 소괄호는 표현식을 즉시 호출할 때 전달하는 인자라고 했는데 이 소괄호가 없으면 어떻게 될까?

 

[]()->void{
  std::cout << "Hello!\n";
};

 

당연히 호출을 하지 않으니 아무것도 출력하지 않는다. 그냥 표현식을 적은 거라 생각하면 된다.

 

이에 대한 필요는 뒤에서 설명하도록 한다.

 

그리고 이 람다 표현식에서는 매개변수 또는 반환형을 생략할 수 있다.

 

[] {
  std::cout << "Good day!\n";
} ();

 

굳이 반환형을 명시할 이유가 없고 매개변수 역시 필요하지 않다면 위처럼 생략하는 게 편하다.

 

void 말고 return 값을 가지는 다른 람다 표현식도 만들어보자.

 

std::cout << [](int a, int b) { return a + b; } (10, 20);

 

두 정수를 입력 받아 그 합을 리턴하는 람다 표현식이다.

 

이처럼 함수형 프로그래밍을 하고자 할 때 람다 표현식을 쓰면 그 구현을 편하게 할 수 있다.

Lambda expression capture

이제 람다 표현식 제일 처음에 등장하는 [] 캡처에 대해 알아보자.

 

캡처는 람다 외부의 지역 변수를 이용할 수 있게 캡처하는 버킷으로 이해할 수 있다.

 

캡처에 담지 않은 지역 변수는 사용할 수 없다. 다음은 에러를 일으키는 람다 표현식이다.

 

int a = 20;

[]() {
  a += 5;
};

// 람다는 외부 지역 변수 a를 쓸 수 없다. 에러가 발생한다.

 

전역 변수는 사용할 수 있다.

 

#include <bits/stdc++.h>

const int adder = 200;

int main()
{
  [](int x) {
    return x + adder;
  };
}

 

C++의 람다는 외부에 존재하는 지역 변수를 활용하기 위해 캡처로 여러 clause를 제공한다. 다음은 그 clause들의 종류다.

 

Clause 기능
[=] 모든 람다 외 지역 변수들을 const로 복사한다.
[&] 모든 람다 외 지역 변수들을 참조한다.
[val1, &val2] 지역 변수 val1을 const로 복사하고 val2를 참조한다. (이렇게 특정 지역 변수만 취하는 것이 가능)
[초기화 절] C++14부터 지원. 람다 표현식에서만 사용할 변수나 인스턴스를 초기화할 수 있다.
[=, &val1] val1을 제외 모든 외부 지역 변수를 const로 복사하고 val1은 참조한다.
[&, val1] val1을 제외 모든 외부 지역 변수를 참조하고 val1은 복사한다.
[args...] Variadic template을 복사한다.
[this] 해당 클래스를 참조한다.
[*this] C++17부터 지원. 해당 클래스를 복사한다.

 

아래 3개를 제외한 clause들의 사용 예시를 보도록 하자.

[=]

int a = 20;

int result = [=](int x) {
  return x + a;
} (40);

result에 인자로 들어온 40과 a를 더하는 람다 표현식이다. 이 람다 표현식의 결과는 60이 됨을 알 수 있다.

 

다음 경우는 에러를 일으킨다.

int a = 20;
[=]() { a = 40; };
error: assignment of read-only variable ‘a’

=를 통해 복사된 지역 변수는 const 이기 때문에 값을 변경할 수 없다.

[&]

int a = -1, b = -10;
[&]() { a = -10; } ();
std::cout << a << ' ' << b << '\n';
-10 -10

모든 지역 변수들을 참조했기 때문에 a의 값을 변경할 수 있다.

 

위 코드에서는 a의 값을 -10으로 변경해서 출력할 때 -10 -10이라는 결과를 볼 수 있다.

 

위에서도 언급했지만 캡처로 어떤 변수가 복사될 때 그 변수는 const가 된다.

 

이는 캡처로 복사된 값이 주소 공간의 data 영역에 올라간다는 뜻이 된다.

int x = 200;

std::cout << x << '\n';
std::cout << &x << '\n';

[&]() {
  x = 2000;
  std::cout << x << '\n';
  std::cout << &x << '\n';
} ();

[=]() {
  std::cout << x << '\n';
  std::cout << &x << '\n';
} ();
200
0x7ffd6adea8ac
2000
0x7ffd6adea8ac
2000
0x7ffd6adea8b0

 

출력에서 확인할 수 있듯이 [=]에서 x는 메모리에 새로 할당되어 다른 주소를 가지게 된다.

[val1, &val2]

int a = 1, b = 5;

int result = [&a, b]() {
  a = 3;
  return a * b;
} ();

std::cout << result;
15

a를 참조하여 그 값을 3으로 바꾸고 b를 곱한 값을 result로 리턴했다.

 

그 결과 result의 값은 15가 된다. 위 코드에서 b의 값은 바꿀 수 없음을 명심하자.

[초기화 절]

다음은 람다 표현식에서만 scope를 가지는 변수 y를 캡처에서 초기화 후 출력하는 코드이다.

int x = 777;

[y = sqrt(x)]() {
  std::cout << y << '\n';
} ();
27.8747

 

먼저 y를 보면 람다 표현식 내에서 한정적으로 $\sqrt x$ 값을 가지도록 캡처에서 초기화를 한 상태다.

 

람다 표현식에서 y를 출력하면 적절한 타입으로 생성되었음을 확인할 수 있다.

[=, &val1], [&, val1]

int a = 1, b = 2, c = 3, d = 4;

[=, &c](int arg) {
  c = arg;
  std::cout << a << " " << b << " " << c << " " << d << '\n';
} (15);

std::cout << c << '\n';
1 2 15 4
15

 

a, b, c, d를 가져오되 c는 참조 형식으로 가져오는 캡처다.

 

c를 전달받은 인자 15로 변경하고 각 값과 람다 표현식이 끝난 후 c의 값을 출력하고 있다.

 

캡처에 = 대신 &를 할 때는 반대로 생각하면 된다. a, b, c, d를 가져오는데 c의 값만 가져오고 나머지는 참조가 표현식으로 넘어온다.

 

이렇게 각 캡처 clause를 어떻게 사용하는지 간략한 예시로 알아봤다. 그런데...

람다 표현식에서 캡처를 사용하는 의의

매개변수로 참조 변수를 전달할 수 있는데 뭐하러 캡처를 따로 두냐는 것이다.

 

이것은 STL를 사용할 때에는 std::sort에서 사용되는 비교 함수처럼 제한된 매개변수로 구성하는 함수에서 추가적인 값이 필요한 경우 유연하게 캡처에서 끌어다 사용할 수 있다.

람다 표현식 응용

람다 표현식을 알게 되었지만 위의 예시대로만 써서는 쓸모가 없다. 람다 표현식의 강점은 1회성 함수를 만들어서 사용할 수 있다는 것인데, 콜백 구현에서 위력을 발휘한다.

 

내가 어떤 사유로 인해 vector를 각기 다른 기준으로 두 번 정렬해야 한다고 해보자. 단 두 번의 람다 표현식 사용으로 이를 깔끔하게 구현할 수 있다.

#include <bits/stdc++.h>

int main()
{
  using std::vector;
  using std::pair;
  using std::sort;

  vector<pair<int, int>> v;

  v.push_back({3, 8});
  v.push_back({10, -7});
  v.push_back({-23, -19});
  v.push_back({99, 10});
  v.push_back({-5, 10});

  sort(v.begin(), v.end(), [](const pair<int, int> &l, const pair<int, int> &r) {
    int lval = std::max(l.first, l.second) - std::min(l.first, l.second);
    int rval = std::max(r.first, r.second) - std::min(r.first, r.second);
    return lval < rval;
  });

  std::cout << v[0].first << ' ' << v[0].second << '\n';

  sort(v.begin(), v.end(), [](const pair<int, int> &l, const pair<int, int> &r) {
    int lval = l.first + l.second;
    int rval = r.first + r.second;
    return abs(lval) < abs(rval);
  });

  std::cout << v[0].first << ' ' << v[0].second << '\n';
}
-23 -19
10 -7

 

vector를 각각 다른 기준으로 정렬을 하려고 할 때 정렬 함수에 람다 표현식을 전달해서 정렬을 하는 코드이다.

 

이렇게 람다 표현식을 인자로 전달하게 되면 함수 포인터를 전달할 때와 비교하여 함수가 어떻게 구현되어 있는지 찾을 필요 없이 바로 인자로 전달된 람다 표현식의 구현을 보면 되므로 가독성이 높아진다.

람다 표현식의 저장

물론 람다 표현식을 저장해서 여러 번 사용할 수 있다.

 

이를 알아보기 전에 클로저(closures)에 대해 간략히 소개한다.

 

https://lunchballer.com/archives/284

 

[C++] 클로져(Closures)가 그래서 무엇인가요

Effective Modern C++ 책에서 Lambdas (람다) 섹션쪽을 읽다 보면 Closures (클로져) 얘기가 많이 나온다.  C++ 에서 클로져가 정확히 뭘까.  어떤 이는 람다가 클로져라고 하기도 하고, 어떤 이는 캡쳐와 클

lunchballer.com

 

간단히 요약하면 클로저는 람다의 인스턴스로 이해하면 된다.

 

또한 람다 표현식을 저장한다는 것은 클로저의 복사본을 저장하는 것으로 이해할 수 있다.

 

람다 표현식을 저장하는 방법을 알아보자.

auto 사용

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

std::cout << func(10, 20);

 

auto 키워드를 사용하면 람다의 클로저를 아주 간단하게 저장할 수 있다.

 

클로저를 저장할 때 람다 표현식의 맨 뒤에 소괄호 ()를 붙이면 안 된다.

 

괄호를 붙여 인자를 넘기면(혹은 그렇지 않더라도) 해당 표현식을 바로 실행시키게 되므로 클로저가 아니라 클로저의 반환 값을 받게 된다.

 

이렇게 저장된 클로저는 함수를 사용하듯이 쓰면 된다.

 

이렇게 auto로 저장한 클로저 객체는 상수화가 이루어져 변경이 불가능하므로 컴파일러에서 inline 화를 가능케 한다.

함수 포인터 사용

int (*func)(int, int) = [](int a, int b) {
    return a * b;
};

std::cout << func(5, -40);

 

이렇게 클로저를 함수 포인터로 할당하는 방법도 있다.

 

다만 함수 포인터의 경우 변경이 가능하므로 컴파일러에서 이 코드를 inline 형태로 바꿔줄 수 없고 캡처도 지정할 수 없다.

 

즉, 위 두 가지를 비교했을 때 함수 포인터를 꼭 써야 하는 상황이 아니라면 auto 키워드로 클로저를 저장하는 게 좋다.

클로저를 사용할 때 유의점

재사용이 필요하다고 판단되는 람다의 경우 클로저로 저장하면 매우 유용하게 사용될 수 있지만 주의할 점이 있다.

 

위에서 clause를 설명할 때 const 복사된 변수는 메모리에 새로 할당된다고 했었다. 이게 클로저에서는 어떻게 될까? 다음 코드를 보자.

int wow = 999999;

auto func1 = [&]()->int{
  return wow = 2000;
};

auto func2 = [hoho = wow]()->int{
  return hoho;
};

std::cout << func1() << ' ' << func2() << '\n';
std::cout << wow << '\n';

 

이 코드에서 func1과 func2는 각각 어떤 값을 리턴할까?

 

일단 func1은 의심의 여지없이 2000을 반환한다. func2는 어떨까? func1에서 wow의 값이 바뀌었으니 func2 역시 2000을 반환할까?

 

답은 999999이다.

 

값을 const로 캡처할 때 data 영역에 새로 올라간다는 것을 기억해보자. 이는 런타임이 아닌 컴파일 시간에 결정되므로 당연히 hoho는 wow의 초기값인 999999가 들어갈 것이다.

 

따라서 외부 지역 변수를 여러 번 캡처할 경우 위와 같은 상황을 조심하도록 한다.

마치며

지금까지 C++11부터 지원하는 람다 표현식에 대해 알아보는 시간을 가졌다.

 

여기에 작성한 내용 말고도 람다 표현식과 관련된 문법이나 응용은 무척 많다.

 

이 게시물을 통해 람다 표현식에 대해 어느정도 이해하게 되었으면 마이크로소프트 독스cppreference 에서 깊게 공부하면 큰 도움이 될 것이다.

728x90
728x90

'C++ > C++ 문법' 카테고리의 다른 글

C++ if statement with initializer  (0) 2021.11.14
C++ Structured Binding(구조적 바인딩)  (0) 2021.10.30