모두의 코드
씹어먹는 C++ 토막글 ② - 람다(lambda) 함수
이 글은 http://ciere.com/cppnow12/lambda.pdf 에서 가져왔고 한국말로 번역되었습니다. 또한 저의 개인적인 C++ 능력 향상과 ' 저의 모토인 지식 전파'를 위해 모든 이들에게 공개하도록 하겠습니다.
이 글을 이해하기 위해서는 초보 이상의 C++ 지식이 필요합니다. 아직 C++ 에 친숙하지 않다면 [씹어먹는 C++ 강좌](http://itguru.tistory.com/135)는 어때요?
안녕하세요? 이 글은 지난번에 우측값 레퍼런스에 관련한 글에 이어서 두 번째로 쓰는 C++ 토막글 입니다. C++ 토막글에서는 주로 C++ 11 에 추가된 최신 기술들을 다루고 있는데요, 아직 국내에 자료가 많이 부족하다 보니 체계적으로 쓰인 외국 자료들을 번역하는 형태로 제공하고 있습니다. 이 글은 http://ciere.com/cppnow12/lambda.pdf 에 올라온 문서 자료를 바탕으로 번역된 글입니다.
사실 이 문서는 내용은 없고 소스만 있는 발표 자료이지만, 제가 발표자가 되었다고 가정해서 내용을 소개해보고자 합니다. 이 글이 C++ 11 의 강력한 기술인 람다를 이해하는데 많은 도움이 되기를 바라겠습니다 :)
서론
어떤 벡터의 원소들의 모든 곱을 계산하는 코드를 구성한다고 생각해봅시다. 아마 가장 초보적으로 이 코드를 구성하는 방법은 아마 아래와 같을 것입니다.
vector<int>::const_iterator iter = cardinal.begin(); vector<int>::const_iterator iter_end = cardinal.end(); int total_elements = 1; while (iter != iter_end) { total_elements *= *iter; ++iter; }
위는 반복자(iterator) 를 이용해서 cardinal
이라는 vector<int>
의 각 원소들을 순차적으로 참조해가며 total_elements
에 곱해나가는 코드입니다.
다른 방법으로는 Functor
를 이용해서 아래와 같은 코드를 짤 수 도 있습니다.
int total_elements = 1; for_each(cardinal.begin(), cardinal.end(), product<int>(total_elements)); template <typename T> struct product { product(T& storage) : value(storage) {} template <typename V> void operator()(V& v) { value *= v; } T& value; };
for_each 를 사용해서 이전 코드의 while
문 부분을 싸그리 없앨 수 있지만, 이를 위해 필요한 Functor
를 구성하는 코드가 훨씬 깁니다. 마치 배보다 배꼽이 더 큰 격이군요.
물론 전체적인 코드의 질이 높아졌다고 볼 수 있지만, Functor
을 이용하기 위해 product
라는 구조체를 생성하면서 구질구질하게 생성자도 만들고, 또 void operator()
도 정의해주어야겠죠. 상당히 귀찮은 일이 아닐 수 없습니다.
int total_elements = 1; for_each(cardinal.begin(), cardinal.end(), [&total_elements](int i) { total_elements *= i; });
자. 그럼 위 코드를 한번 봅시다. 짧고 간결하며, 무엇 보다도 while
문이나 functor
와 같은 구질구질한 코드 없이 깔끔하게 for_each 의 특징을 그대로 살려주었다고 볼 수 있습니다. 즉 Functor
에 들어갈 내용을 product
라는 구조체를 정의하면서 쭉 써내려갈 내용을 한 번에 깔끔하게 정리해놓은 것이지요. 이것이 바로 람다 의 위력입니다.
간단히 Functor
를 이용한 코드와 람다 를 이용한 코드를 비교해 보아도 그 차이를 실감할 수 있을 것입니다.
// Functor 사용 struct mod { mod(int m) : modulus(m) {} int operator()(int v) { return v % modulus; } int modulus; }; int my_mod = 8; transform(in.begin(), in.end(), out.begin(), mod(my_mod));
// Lambda 사용 int my_mod = 8; transform(in.begin(), in.end(), out.begin(), [my_mod](int v) -> int { return v % my_mod; });
람다(Lambda) 의 구성
자 그럼 람다 를 사용하기 위해 람다 를 어떻게 C++ 에서 정의하는지 살펴보도록 합시다.

그 4 개의 부분은 각각 개시자 (introducer), 인자(parameters), 반환 타입 (return type), 그리고 함수의 몸통 (statement) 라 합니다.
일단, 람다 맨 처음에 나타나는 []
는 개시자로, 그 안에 어떤 외부 변수를 써 넣는다면 람다 함수가 이를 캡쳐 해서, 이 변수를 람다 내부에서 이용할 수 있게 됩니다 (이에 대한 이야기는 뒤에서...) 위 경우 my_mod
라는 변수를 람다 내부에서 이용할 수 있게 됩니다.
그 다음의 ()
는 람다가 실행시 받을 인자들을 써 넣습니다. 위 람다는 int
형의 v_
를 인자로 받는 군요. 여기는 그냥 실제로 함수에서 사용하는 인자 리스트와 동일하게 적어주면 됩니다.
이제, 그 옆으로 보면 ->
가 있고 반환 타입을 적어주시면 됩니다. 위 람다의 경우 int
를 리턴합니다.
마지막으로 람다 내부에서 실행할 내용을 적어주면 되는데, 위 람다의 경우 v_
와 my_mod
를 모듈러 연산해서 그 결과를 리턴하네요.
만일 우리가
[my_mod](int v_) -> int { return v_ % my_mod; }
위와 같이 코드 상에 람다 를 썼다고 해봅시다. 그러면 런타임시 이름은 없지만, 메모리 상에 임시적으로 존재하는 클로져 (Closure) 객체가 생성됩니다. 이 클로져 객체는 함수 객체(function object) 처럼 행동합니다.
그렇다면
[]() { cout << "foo" << endl; }()
를 실행하였을 때 어떠한 결과가 나올까요? 일단
[]() { cout << "foo" << endl; }
로 임시적인 클로져 객체가 생성되었는데 () 를 붙여서 바로 이 임시 클로져 객체를 실행시켜 버리지요. 위 람다는 캡쳐 하는 변수들도 없고, 인자로 받는 것도 없고 리턴 타입도 없고 (참고로 리턴 타입이 void
일 경우 ->
를 생각 가능합니다) 함수 몸통만 덜렁 있기에 특별히 생각할 것도 없이 함수 몸통만 덜렁 실행되서

라고 나오게 됩니다.
그러면 조금 더 복잡한 예제를 살펴볼까요?
[](int v) { cout << v << "*6=" << v * 6 << endl; }(7);
는 어떨까요.
[](int v) { cout << v << "*6=" << v * 6 << endl; }
부분에서 인자로 v
를 받는 클로져가 생성되었는데, (7) 로 이 클로져에 인자로 7 을 전달시키면서 실행시켜버립니다. 따라서 모두가 예상 하였던 결과인

가 나오겠네요.
람다 자체가 함수 처럼 자유롭게 사용할 수 있는 것이기 때문에 인자로 (당연히) 레퍼런스 들도 전달 가능합니다. 예를 들어
int i = 7; [](int& v) { v *= 6; }(i); cout << "the correct value is: " << i << endl;
를 실행해보면

가 나옵니다.
참고로 받는 인자가 없을 경우, 예컨대
[]() { cout << "foo" << endl; }
의 경우 인자 () 를 생략 할 수 있습니다. 즉,
[] { cout << "foo" << endl; }
도 동일한 의미입니다. (하지만 []
는 지울 수 없습니다!)
Capture
사실 많은 경우 우리의 람다 안에서 람다 밖에 있는 변수들에게 접근하고 싶을 때가 있을 것입니다. 물론 "그렇다면 그 변수들을 그냥 인자로 받아버리면 되자나!" 라고 반문할 수 도 있겠지만, for_each 나 fill, transform
등의 C++ 의 파워풀한 STL
을 수행하기 위해서는 인자들을 맞추어 주어야 하는데 이 때문에 함수 내부로 전하고 싶어도 전달하지 못하는 인자들이 있기 마련 입니다.
따라서 이를 방지하기 위해, 람다 내부와 소통할 수 있는 또다른 문, 캡쳐를 제공하고 있습니다. 캡쳐 하고자 하는 내용은 앞에서 말했듯이 []
안에 들어오게 되는데, 대표적으로 아래의 4 개의 형태들이 있습니다.
[&]() { /* */ }
외부의 모든 변수들을 레퍼런스로 가져온다. (함수의Call - by - reference
를 생각)[=]() { /* */ }
외부의 모든 변수들을 값으로 가져온다. (함수의Call - by - value
를 생각)[=, &x, &y] { /* */ }
, 혹은[&, x, y] { /* */ }
외부의 모든 변수들을 값/레퍼런스로 가져오되,x
와y
만 레퍼런스/값으로 가져온다[x, &y, &z] { /* */ }
지정한 변수들을 지정한 바에 따라 가져온다.
그렇다면 한 번 예제를 살펴볼까요.
int total_elements = 1; vector<int> cardinal; cardinal.push_back(1); cardinal.push_back(2); cardinal.push_back(4); cardinal.push_back(8); for_each(cardinal.begin(), cardinal.end(), [&](int i) { total_elements *= i; }); cout << "total elements : " << total_elements << endl;
위 코드에서 cardinal
에는 1, 2, 4, 8
이라는 원소들이 들어있고 그것을 for_each 를 통해 순회하면서 total_elements
에 곱하게 됩니다. 이 때 캡쳐는 &
로 이므로 total_elements
를 캡쳐 할 수 있고, 람다 외부 변수인 total_elements
를 성공적으로 바꿀 수 있었던 것이죠. 위 코드를 실행하면 예상하던대로

가 나오게 됩니다.
이번에는 조금 더 복잡한 예제를 살펴보도록 합시다.
template <typename T> void fill(vector<int>& v, T done) { int i = 0; while (!done()) { v.push_back(i++); } } vector<int> stuff; fill(stuff, [&]() -> bool { return stuff.size() >= 8; }); for_each(stuff.begin(), stuff.end(), [](int i) { cout << i << " "; });
참고로 클로져 객체는 분명 특정 타입의 객체 이므로 위와 같이 template
에서 typename T
로 받을 수 있습니다. 위의 fill 함수는 특정 타입 T
의 변수 done
으로 클로져 객체를 받았습니다. 이 때, 클로져 객체 자체는 이미 stuff
를 캡쳐 해서 stuff
에 대한 레퍼런스를 계속 가지고 있는 상태이고, fill 의 while
문에서 돌면서 stuff
의 크기가 8 이하 일 때 까지 수행되게 됩니다. 따라서

로 출력됩니다.
void fill(vector<int>& v, T done) { int i = 0; while (!done()) { v.push_back(i++); } } vector<int> stuff; fill(stuff, [&]() -> bool { int sum = 0; for_each(stuff.begin(), stuff.end(), [&](int i) { sum += i; }); return sum >= 10; }); for_each(stuff.begin(), stuff.end(), [](int i) { cout << i << " "; });
머리를 쫌만 굴려보면, 현재 stuff
의 원소 합이 10 이하일 때 까지 stuff
의 원소를 추가하는 람다라고 볼 수 있습니다.
당연히 그 결과는

한 가지 흥미로운 점은 캡쳐를 레퍼런스가 아닌 값으로 할 때 언제 캡쳐가 되냐는 것입니다.
int v = 42; auto func = [=] { cout << v << endl; }; v = 8; func();
과연 위 소스에서 v
는 func
이 처음 정의될 때, 즉 클로져 객체가 생성될 때 캡쳐 될까요, 아니면 func
이 실행될 때 일까요? 만일 전자라면 42 가 출력될 것이고 후자라면 8 이 출력될 것입니다.
과연!

흥미롭게도 람다는 클로져 객체가 처음 생성될때 변수들의 값을 캡쳐 합니다.
캡쳐를 값으로 할 때 주의점은 그 변수들에는 자동으로 const
속성이 붙는 다는 것입니다. 즉 값으로 캡쳐 시 그 변수들의 내용을 바꿀 수 없습니다. 따라서 아래와 같은 코드는
int i = 10; auto two_i = [=]() -> int { i *= 2; return i; }; cout << "2i:" << two_i() << " i:" << i << endl;
아래와 같은 컴파일 오류가 발생합니다.
컴파일 오류
'i': a by-value capture cannot be modified in a non-mutable lambda
위 코드에서 i
는 분명히 값으로 받았으므로 const
인데, i *= 2
를 통해 i
의 값을 바꾸려 하고 있으니 오류가 발생한 것입니다.
하지만, 함수 내부에서 i
의 값을 바꾸고자 하면 어떨까요? (물론 실제 외부의 i
의 값은 바뀌지 않습니다. 함수 내부에서만 - 마치 지역 변수처럼 말이죠)
답은 간단합니다. 람다에 mutable
속성을 추가해주면 됩니다.
int i = 10; auto two_i = [=]() mutable -> int { i *= 2; return i; }; cout << "2i:" << two_i() << " i:" << i << endl;
이로써 람다 내부에서 i
의 값을 변경할 수 있습니다. 물론, 다시 말하지만 외부의 i
의 값이 바뀌는 것이 아닙니다. 오직 람다 함수 내에서 '어떤 다른 i
' 의 값이 두 배가 되는 것이지요. 그 결과

로 나타남을 알 수 있습니다.
이제 그럼 조금 복잡한 코드를 살펴볼까요.
class gorp { vector<int> values; int m_; public: gorp(int mod) : m_(mod) {} gorp& put(int v) { values.push_back(v); return *this; } int extras() { int count = 0; for_each(values.begin(), values.end(), [=, &count](int v) { count += v % m_; }); return count; } }; gorp g(4); g.put(3).put(7).put(8); cout << "extras: " << g.extras();
사실 위 코드는 상당히 재미있는 코드입니다. extras
함수를 호출하면 람다가 각 원소를 4 로 나눈 나머지들의 합을 구해서 더해주는데요, 한 가지 궁금한점! 과연 람다에서 어떻게 m_
을 capture
할 수 있었을까요? 람다는 여기서 암묵적으로 클래스의 this
를 캡쳐했기 때문에 m_
을 접근할 수 있었던 것입니다.
따라서 위 코드는

으로 성공적인 결과를 보여줍니다.
이렇게 this
를 암묵적으로 캡쳐 할 수 있기에 아래와 같은 놀라운 일도 발생할 수 있습니다.
struct foo { foo() : i(0) {} void amazing() { [=] { i = 8; }(); } int i; 는 }; foo f; f.amazing(); cout << "f.i : " << f.i;
위 코드를 언뜻 보면 i
를 값으로 capture
햇는데 어떻게 8 을 대입할 수 있냐고 물을 수 있는데, 사실 this
를 캡쳐해서 this.i = 8
을 통해 mutable
없이도 값을 바꿀 수 있습니다. 왜냐하면 분명 this.i = 8
에서 상수인 this
를 변경한 것은 아니기 때문이죠.
캡쳐의 범위
캡쳐 되는 개체들은 모두 람다가 정의된 위치에서 접근 가능해야만 합니다. 예를 들어
int i = 8; { int j = 2; auto f = [=] { cout << i / j; }; f(); }
의 코드는 람다의 위치에서 i, j
모두 접근 가능하기 때문에 캡쳐가 가능하므로 정상적으로

가 나옵니다.
그렇다면 아래 코드는 어떨까요?
int i = 8; auto f = [i]() { int j = 2; auto m = [=] { cout << i / j; }; m(); }; f();
바깥의 람다에서 i
를 캡쳐 하였기에, 바깥의 람다 몸통 안에서 i
를 사용할 수 있겠지요. 따라서 내부의 람다는 i
를 캡쳐 할 수 있게 됩니다. 그렇기에, 위와 동일한 결과인

가 나오게 됩니다. 하지만, 만일 바깥의 람다에서 i
를 캡쳐 하지 않았다면 어떨까요.
int i = 8; auto f = []() { int j = 2; auto m = [=] { cout << i / j; }; m(); }; f();
그러면 예상했던 대로 컴파일 오류
컴파일 오류
error C3493: 'i' cannot be implicitly captured because no default capture mode has been specified
가 나오게 됩니다.
조금 더 복잡한 예로 아래의 코드를 살펴봅시다.
int i = 8; auto f = [=]() { int j = 2; auto m = [&] { i /= j; }; m(); cout << "inner: " << i; }; f(); cout << " outer: " << i;
일단 바깥의 람다는 i
를 값으로 캡쳐 하였기 때문에 바깥의 람다(f
) 몸통에서는 i
에 const
속성이 붙습니다. 그런데, 내부의 람다(m
) 가 그 i
를 레퍼런스로 캡쳐 해서 값을 변경하려고 했습니다. 그렇다면, 당연히 오류가 나겠지요. 실제로 컴파일 오류
컴파일 오류
'i': a by-value capture cannot be modified in a non-mutable lambda
가 발생하게 됩니다.
이를 해결하려면, 당연히도 mutable
속성을 붙여주면 됩니다.
int i = 8; auto f = [i]() mutable { int j = 2; auto m = [&, j]() mutable { i /= j; }; m(); cout << "inner: " << i; }; f(); cout << " outer: " << i;
i
자체가 값으로 입력 되었기 때문에 outer i
의 값은 바뀌지 않고 8 로 남아 있고, 값으로 받은 i
가 m
에 의해서 2 로 나눠지므로 4 가 됩니다. 따라서, 그 결과

로 나오게 되죠.
클로져 객체의 복사 생성자와 소멸자
모든 클로져 객체들은 암묵적으로 정의된 복사 생성자와 소멸자를 가지고 있습니다. 이 때 클로져 객체가 복사 생성 될 때 값으로 캡쳐 된 것들의 복사 생성이 일어나겠지요. 아래의 예를 한번 보도록 하겠습니다.
일단
struct trace { trace() : i(0) { cout << "construct\n"; } trace(trace const&) { cout << "copy construct\n"; } ~trace() { cout << "destroy\n"; } trace& operator=(trace&) { cout << "assign\n"; return *this; } int i; };
와 같이 생성, 복사 생성, 소멸, 그리고 대입 연산을 확인할 수 있는 trace
라는 구조체를 정의해놓고
trace t; int i = 8; auto f = [=]() { return i / 2; };
를 한다면 어떻게 나올까요? f
에서 t
를 사용하지 않았으므로, t
를 캡쳐하지 않게 됩니다. 따라서 그냥

이 나오게 됩니다.
그렇다면 아래의 예는 어떨까요
trace t; int i = 8; auto m1 = [=]() { int i = t.i; }; cout << " --- make copy --- " << endl; auto m2 = m1;
먼저 m1
을 생성하면서, 람다가 t
를 캡쳐하였으므로 t
의 복사 생성자가 호출되게 됩니다. 왜냐하면 값으로 받았기 때문이지요. 만일 레퍼런스로 받았다면 복사 생성자가 호출되지 않았을 것입니다 (확인해보세요!)
그리고 아래의 auto m2 = m1;
에서 클로져 객체의 복사 생성이 일어나는데, 이 때, 클로져 객체의 복사 생성자가 값으로 캡쳐된 객체들을 똑같이 복사 생성 해주게 됩니다. 따라서 또 한번 t
의 복사 생성자가 호출되겠지요. 그 결과 아래와 같이 출력됩니다.

람다의 전달 및 저장
람다를 저장 및 전달하는 방식으로 앞에서 두 가지 방법을 보았습니다. 바로
template <typename T> void foo(T f) auto f = [] {};
이지요. 우리가 만들어낸 클로져 객체의 타입이 정확히 무엇인지 몰라도 위와 같은 방법으로 성공적으로 처리할 수 있습니다.
또 다른 방법으로는 함수 포인터를 이용하는 방법이 있는데요, 이 경우 람다가 캡쳐하는 것이 없어야만 합니다.
typedef int (*f_type)(int); f_type f = [](int i) -> int { return i + 20; }; cout << f(8);
(참고로 위 기능은 Visual Studio 2010
에서 지원되지 않습니다 - 그 후의 버전에서만 가능합니다)
위 역시 성공적으로 28 을 출력함을 알 수 있습니다.
그런데, C++ 11 에서는 클로져 객체를 전달하고 또 저장할 수 있는 막강한 기능이 제공됩니다. 바로 std::function
인데요, 그 어떤 클로져 객체나 함수 등을 모두 보관할 수 있는 만능 저장소 입니다. (참고로 std::function
은 Visual Studio 2010 에서 <functional>
을 포함시켜야 합니다)
std::function<int(std::string const &)> f; f = [](std::string const &s) -> int { return s.size(); }; int size = f("http://itguru.tistory.com"); cout << size << endl;
std::function
은 위와 같이 std::function<반환 타입 ( 인자 )>
와 같은 형태로 쓰며, 캡쳐가 있어도 상관이 없습니다. 물론 위 코드는 실행하면

와 같이 잘 나오지요.
이 std::function
을 통해 아래와 같이 재밌는 코드도 짤 수 있습니다.
std::function<int(int)> f1; std::function<int(int)> f2 = [&](int i) -> int { cout << i << " "; if (i > 5) { return f1(i - 2); } }; f1 = [&](int i) -> int { cout << i << " "; return f2(++i); }; f1(10);
이것이 가능한 이유는 만일 auto
를 이용하였더라면 auto f1
을 한 시점에서 f1
이 명확히 구현이 되어야 컴파일러에서 타입을 추정할 수 있는데, 위와 같은 경우 f1
을 구현하려면 f2
를 먼저 구현해야 하고, 또 f2
를 구현하려면 다시 f1
을 먼저 구현해야 하는 순환적인 논리 딜레마에 빠지게 됩니다. 따라서 function
을 이용해서 f1
을 선언만 해 놓은 뒤, f2
를 구현하고, 다시 f1
을 구현하면 됩니다.
위 코드를 실행하면

와 같이 잘 나옴을 알 수 있습니다.
마찬가지로 아래와 같은 재귀 호출 함수도 구현할 수 있습니다.
std::function<int(int)> fact; fact = [&fact](int n) -> int { if (n == 0) { return 1; } else { return (n * fact(n - 1)); } }; cout << "factorial(4) : " << fact(4) << endl;
이 역시 auto
를 이용했더라면, 처음 캡쳐 부분에서 캡쳐 하는 대상의 타입이 명확히 정해지지 않은 상태이므로 컴파일러가 타입을 추정할 수 없게 됩니다. 하지만 function
을 이용해서 성공적으로 구현할 수 있습니다. 위 계산 결과는 당연히

가 나오겠지요.
마치며
C++ 에 새롭게 추가된 람다는 기존의 C++ 과 전혀 다른 새로운 개념 입니다. 하지만 람다를 이용하면 수십줄의 코드도 한 두 줄로 간추릴 수 있는, 엄청난 기능이 아닐 수 없습니다.
이제 여러분들 손에는 람다라는 막강한 도구가 주어졌습니다. 이를 어떻게 사용하느냐는 여러분의 몫이지요 :)
그리고 이런 훌륭한 강의를 제공해주신 Michael Caisse
님에게 감사의 말을 전합니다.

댓글을 불러오는 중입니다..