람다 표현식은 익명의 함수를 정의하는 표현식을 의미한다. 람다 표현식을 통해 클로져(Closure) 클래스가 정의되며 런타임에 인스턴스화된 클로져가 임시 객체로 반환된다. 일반적으로 람다 표현식, 클로져 클래스, 클로져를 엄밀히 구분하여 지칭하지 않고 람다로 통칭하는 경향이 있지만 이 문서에서는 이를 구분하여 설명한다.
auto f = [캡쳐문](파라미터) -> 리턴타입 {
//함수 몸체
}
function<void(int)> g = [](int param) { //function 템플릿을 통해 형식지정 기능
//...
};
C++의 람다 표현식은 위와 같은 구조를 가지고, 리턴 타입의 명시는 생략할 수 있다. 함수 몸체에서 return문이 없거나 리턴 값 없이 return;만 등장한다면 void 타입으로 추론되며, 함수 몸체에 등장한 모든 return문이 같은 타입의 리턴 값을 갖는다면 그 타입으로 추론된다. 만약 return문이 여러번 등장하는데 리턴타입이 일치하지 않는다면 리턴타입을 생략할 수 없으며, 리턴 타입을 명시하더라도 각각의 리턴값이 명시한 리턴타입으로 묵시적 캐스팅되는 것이 불가능하다면 컴파일 오류이다.
캡쳐문은 함수 몸체에서 외부 변수에 접근하기 위해 쓰인다. 람다 표현식을 정의한 곳에서의 스코프에 있는 변수나 함수들을 캡쳐할 수 있는데 이때 참조로 캡쳐할 수도 있고 값으로 캡쳐할 수도 있다. 캡쳐할 이름 앞에 &를 붙이면 참조로 캡쳐할 것임을 의미하고 그렇지 않으면 값으로 캡쳐할 것임을 의미한다. 이때 값으로 캡쳐한 것은 묵시적으로 const로 취급된다.
{
//...
int x = 0, y = 1;
auto f = [&x, y]() {
//++y; //오류(값 캡쳐는 const로 됨)
return x += y;
}
}
컴파일러는 위의 람다표현식을 통해 아래와 같은 클로져 클래스를 컴파일 타임에 만들어 내며, 런타임에 람다 표현식이 평가될 때 그 클로져 클래스의 인스턴스(클로져)를 생성한다.
class closureClass {
int& x;
const int y;
public:
closureClass (int& inX, const int& inY) : x(inX), y(inY) {}
int operator()() {
return x += y;
}
};
결국 람다표현식의 캡쳐 구문은 클로져 클래스의 멤버변수, 클로져 클래스의 생성자의 파라미터, 이니셜라이저 등을 정의하는 것이고 람다표현식의 파라미터 목록 및 함수 바디는 클로져 클래스의 operator() 연산자 오버라이딩인 셈이다.
캡쳐는 댕글링 포인터 버그가 발생할 가능성이 있으니 유의할 필요가 있다. 참조로 캡쳐한 변수가 소멸한 후에 클로져가 실행되는 경우, 값 캡쳐한 포인터가 가리키는 대상이 소멸한 후에 클로져가 실행되는 경우, 이렇게 두 가지 경위로 댕글링 포인터 버그가 발생할 수 있다.
캡쳐하는 방식 중에는 기본 캡쳐 모드라는 것이 있는데, 이것은 스코프 내의 모든 이름을 일괄적으로 캡쳐하는 것이다. 즉, 람다표현식이 위치한 블럭에 노출된 심볼들을 모두 캡쳐한다.
int x = 0, y = 1;
auto f = [&](){ //기본 캡쳐 모드 (참조)
++x;
++y;
};
auto g = [=](){ //기본 캡쳐 모드 (값)
return x + y;
};
auto h = [&, y](){ //기본 캡쳐 모드는 참조이고 y는 값으로 캡쳐
return x += y;
};
기본 캡쳐 모드를 정하고 추가적으로 몇몇 이름에 대해 다른 방법으로 캡쳐하는 것도 가능하다. 기본 캡쳐 모드는 캡쳐할 대상을 일일이 나열하지 않기 때문에 어떤 것들이 캡쳐되고 있는지 제대로 파악하지 않고 사용하게 될 가능성이 높고, 기본 값 캡쳐 모드의 경우 클로져가 외부에서의 변화와 무관하게 자기 완결적이라고 오해하기 쉽다.
#include <iostream>
#include <memory>
#include <functional>
using namespace std;
struct Test {
int x;
auto returnClosure()
{
return [=]() { return x; } //x가 아닌 this 포인터가 캡쳐됨
}
};
int main()
{
function<int()> f;
{
auto tPtr = make_unique<Test>();
tPtr->x = 1;
f = tPtr->returnClosure();
} //tPtr이 참조하고 있는 Test형 객체는 여기서 소멸
cout << f(); //댕글링 포인터 버그
}
예를 들어 전역 변수 또는 같은 블럭 스코프 내의 static 변수 등은 캡쳐할 필요도 없고 캡쳐가 불가능하다. 그러나 기본 값 캡쳐 모드를 사용하였을 때 이러한 전역 변수나 static 변수들이 값으로 복사되었다고 생각하기 쉽다.
포인터가 값 캡쳐되어 복사된 경우는 개념적으로는 참조가 복사된 것이므로 참조 캡쳐와 마찬가지로 댕글링 포인터 버그가 발생할 가능성이 있다. 특히 어떤 클래스의 메서드 내에서 람다 표현식을 통해 기본 값 캡쳐 모드로 캡쳐한 경우 this 포인터가 값으로서 캡쳐되고 있다는 사실을 인지하지 못할 수 있어 유의해야 한다.
람다 캡쳐 시 값 캡쳐도 참조 캡쳐도 마땅치 않는 경우가 있다. 이동 전용 객체를 클로져 안으로 들여오려는 경우가 그렇다. 이동 전용 객체는 복사가 삭제되어 있어 값 캡쳐가 불가능하다. 참조 캡쳐는 가능하지만 참조 대상 객체가 먼저 소멸하는 경우가 있을 수 있다. 이런 경우에는 C++14에서 추가된 초기화 캡쳐를 사용해야 한다. 초기화 캡쳐는 클로져 클래스의 멤버변수를 직접 초기화할 수 있도록 해주는데 이때 캡쳐 대상을 오른값으로 캐스팅하여 이동 생성 되도록 유도할 수 있다.
class Widget {
public:
//...
bool isValidated() const; //구현 생략
bool isArchived() const; //구현 생략
private:
//...
};
auto pw = std::make_unique<Widget>(); //Widget을 생성함과 동시에 unique_ptr로 가리킴
auto func = [cpw = std::move(pw)] { //초기화 캡쳐
return cpw -> isValidated() && cpw->isArchived();
};
클로져를 담는 형식으로는 auto 또는 function 템플릿 인스턴스가 될 수 있는데 auto를 사용하는 것이 여러가지 면에서 더 낫다. 우선 auto는 컴파일러만 알고있는 클로져 클래스 형식으로 추론된다. 따라서 정확하게 필요한 만큼의 메모리만 사용한다. 반면 function 인스턴스는 그 크기가 임의의 시그니처에 대해 고정되어 있다. 만약 그 크기가 실제 클로져보다 크다면 불필요한 메모리를 소비하고 있는 것이며 더 작다면 function 객체는 힙메모리를 할당받아 클로져를 담게 되므로 function 객체 자체의 메모리는 사용하지도 않으면서 차지하게 되는 메모리가 되고 추가로 힙메모리 사용에 대한 오버헤드까지 발생한다. 게다가 function의 구현 상세상 인라이닝이 제한하고 간접함수를 호출하기 때문에 auto로 선언된 객체를 통해 호출하는 것보다 항상 느리다.
다만 다음과 같잉 클로져 자체가 재귀함수인 경우에서는 auto로 선언하는 것이 불가능하다.
auto f = [&f](){ //컴파일 에러
f();
};
function<void()> g = [&g]() {
g();
};
클로져 내부에서 f를 호출하기 위해서는 f를 캡쳐해야만 한다. 그러나 캡쳐문을 컴파일하는 시점에서는 f의 형식이 불완전하기 때문에 캡쳐가 불가능하다. f의 형식이 결정되는 것은 람다 표현식을 모두 파싱한 이후이기 때문이다. 따라서 이런 경우에는 g와 같이 function 템플릿을 쓰는 수 밖에 없다.