람다는 흔히 익명 함수라고 불린다.
람다 표현식을 사용하면 함수의 이름을 따로 정의하지 않고도 코드 중간에서 간단하게 함수를 만들고 바로 사용할 수 있다.
문법도 간단하고 코드도 훨씬 깔끔해진다.
가독성이 좋아지고 작성하는 입장에서도 부담이 적다.
특히 매개변수를 넘겨주고 값을 리턴하거나, 외부 스코프에 있는 변수들을 값으로 캡처하거나 참조로 캡처하는 등 유연한 기능을 많이 제공하는 것도 큰 장점이다.
게다가 템플릿처럼도 활용 가능하기 때문에 상황에 따라 다양하게 응용할 수 있다.
실제로 코드를 작성해 보면 람다의 편리함을 금방 체감하게 된다.
람다 표현식은 람다 선언자라 부르는 대괄호 []로 시작하고, 람다 표현식의 본문을 담는 중괄호 {}가 나온다.
그리고 매개변수를 받고 싶다면, 람다 표현식의 매개변수는 대괄호 뒤에 일반 함수와 마찬가지로 소괄호 ()로 묶어서 표현한다.
컴파일러는 모든 람다 표현식을 자동으로 함수 객체(람다 클로저)로 변환하며, 이 클로저는 고유한 이름을 갖는 클래스처럼 작동한다.
auto basicLambda = []() { cout << "Hello from Lambda" << endl; };
basicLambda(); // 결과 : Hello from Lambda
class CompilerGeneratedName
{
public:
auto operator()() const { cout << "Hello from Lambda" << endl; }
};
위에 나와있는 람다와 CompilerGeneratedName 클래스가 만든 연산자는 같은 역할을 한다.
람다 표현식은 값을 리턴할 수도 있다.
리턴 타입은 트레일링 리턴 타입이라 부르는 화살표(->)로 표기한다.
auto returningLambda = [](int a, int b) -> int { return a + b; };
int sum { returningLambda(11, 22) }; // 결과 : 33
리턴 타입은 생략할 수도 있으며, 이 경우 컴파일러가 추론한다.
하지만 추론 시 const, & 같은 한정자는 제거된다는 점에 주의해야 한다.
class Person
{
public:
Person(string name) : m_name { move(name) } {}
const string& getName() const { return m_name; }
private:
string m_name;
};
auto returnName = [](const Person& person) { return person.getName(); };
그래서 다음 returnName의 리턴 타입은 string으로 추론된다.
이 부분을 잘 유념하면서 람다를 사용하길 바란다.
물론 트레일링 리턴 타입과 decltype(auto)를 함께 사용해서 추론된 타입이 getName()의 리턴 타입과 같은 const string&으로 만들 수 있다.
auto returnName = [](const Person& person) -> decltype(auto) { return person.getName(); };
지금까지 살펴본 람다 표현식은 외부 변수를 사용하지 않는 stateless 형태였다.
반면, 람다 안에서 외부 변수를 사용하는 경우에는 stateful 라고 부른다.
아래는 외부 변수를 값으로 캡처하는 예시다.
double data { 1.35 };
auto capturingLambda = [data]() { cout << "Data = " << data << endl; };
여기서 대괄호 []는 캡처 블록이라고 부르며, 해당 블록에 들어가는 변수는 람다 본문 안에서 사용할 수 있다.
기본적으로 변수는 값 방식으로 복사되어 캡처되며, &를 붙이면 레퍼런스 방식으로 캡처된다.
컴파일러는 이런 람다 표현식을 내부적으로 클로저 객체로 변환한다. 위 코드는 아래처럼 동작한다.
class CompilerGeneratedName
{
public:
CompilerGeneratedName(const double& d) : data { d } {}
auto operator()() const { cout << "Data = " << data << endl; }
private:
double data;
};
operator 부분을 보면 특이한 점이 하나 있다.
우리는 람다 표현식에서 비 const 변수를 값 방식으로 캡처를 했음에도 불구하고 const가 붙어 있다는 것이다.
람다 클로저는 오버로드한 함수 호출 연산자를 가지며 디폴트로 const을 가진다.
그래서 복사된 data 값을 람다 안에서 변경할 수 없다.
하지만 다음과 같이 람다 표현식을 mutable로 지정하면 함수 호출 연산자를 비 const로 만들 수 있다.
double data { 1.35 };
// 문제없이 잘 작동
auto capturingLambda = [data]() mutable { data *= 2; cout << "Data = " << data << endl; };
참고로 mutable을 지정할 때는 매개변수가 없더라도 소괄호를 반드시 적어야 한다.
mutable을 쓰면 람다 내부의 복사본을 수정할 수 있지만, 외부 변수의 원본은 영향을 받지 않는다. 반대로 원본 값을 직접 바꾸고 싶다면 참조로 캡처하면 된다.
double data { 1.35 };
auto capturingLambda = [&data]() { data *= 2; };
capturingLambda(); // data : 2.7
변수를 레퍼런스로 캡처한다면 람다 표현식을 실행하는 시점에 레퍼런스가 유효한지 반드시 확인해야 한다.
다음으로 캡처 블록에 대한 예시를 보자.
위 두 가지 방법을 캡처 디폴트라 부른다.
여기서 주의할 점 몇 가지가 있다.
[=]이나 [&] 같은 디폴트 캡처를 지정했으면, 같은 변수에 대해 [=, x]처럼 중복 캡처하면 안 된다.
람다 안에서는 객체의 멤버 변수를 직접 캡처할 수 없다. 이를 위해선 람다 캡처 표현식이 필요하다.
그리고 객체의 데이터 멤버는 뒤에서 설명할 람다 캡처 표현식을 사용하지 않고서는 캡처할 수 없다.
전역 변수는 값을 기준으로 캡처하라는 요청이 있더라도 항상 레퍼런스 방식으로 캡처한다.
또한 전역 변수를 명시적으로 캡처할 수 없다.
다행히 위 사항은 컴파일러가 에러를 발생시켜 주기 때문에 쉽게 알 수 있다.
int global { 42 };
int main()
{
auto lambda = [=]() { global = 2; };
lambda(); // global : 2
auto lambda2 = [global]() { global = 2; }; // 컴파일 에러!!
}
마지막으로 람다 표현식의 문법을 정리하면 다음과 같다.
[캡처 블록] <템플릿_매개변수> (매개변수) mutable constexpr noexcept_지정자 속성
-> 리턴_타입 requires [본문]
람다 표현식을 함수의 인수로 전달하는 방법은 두 가지이다.
첫 번째는 std::function을 이용해 함수 시그니처와 동일한 형태로 전달하는 방식이다.
이 방법은 람다 표현식의 시그니처를 정확히 명시할 수 있어 명확하다.
두 번째는 템플릿 타입 매개변수를 사용하는 방법이다.
이 방법은 컴파일 타임에 타입이 결정되므로 성능이 뛰어나고 오버헤드가 없다.
뒤에 나올 템플릿 람다 표현식에서 자세히 설명하겠다.
vector values1 { 1, 2, 3, 4, 5 };
vector values2 { 6, 7, 8, 9, 10 };
findNumber(values1, values2, num,
[](int value1, int value2) { return value1 == num || value2 == num; },
printNum);
C++11에서 람다 표현식이 처음 도입됐을 때는 기본적인 기능만 제공되어 사용에 제약이 많았다.
매개변수의 타입이 다르면 새로운 버전을 만들어야 했고, STL과 함께 사용하기에도 다소 불편했다.
// C++11 Typed Lambda
auto sumTyped = [](int a, int b) { return a + b; };
C++14부터는 람다 표현식에 auto를 사용할 수 있게 되면서 훨씬 유연해졌다.
매개변수 타입을 명시하지 않고 컴파일러가 자동 추론하게 하는 방식인데, 이를 제네릭 람다(Generic-Lambda) 라고 부른다.
// C++14 Generic Lambda
auto sumGeneric = [](auto a, auto b) { return a + b; };
// 후자가 전자로 변환 가능해야 함
auto sumDeclType = [](auto a, decltype(a) b) { return a + b; };
위 첫 번째 제네릭 람다 표현식을 람다 클로저로 표현하면 다음과 같다.
class CompilerGeneratedName
{
public:
template <typename T1, typename T2>
auto operator()(const T1& a, const T2& b) const
{
return a + b;
}
};
C++20부터는 템플릿 람다 표현식이 정식으로 지원된다.
제네릭 람다와 비슷해 보일 수 있지만, 이 방식은 람다 매개변수의 타입뿐만 아니라 람다 자체에 템플릿 매개변수를 선언할 수 있다는 점에서 더 유연하다.
C++20 이전에는 STL의 요소 타입을 처리하기 위해 decltype()과 std::decay_t를 써야 했고, 코드가 꽤 복잡해졌다.
// C++14 방식
auto lambda = [](const auto& values)
{
using V = decay_t<decltype(values)>; // 벡터의 실제 타입
using T = typename V::value_type; // 벡터의 원소 타입
T someValue { };
T::some_static_function();
};
vector values { 1, 2, 3, 4, 5};
lambda(values);
하지만 C++20부터는 다음과 같이 훨씬 간단하고 직관적으로 작성할 수 있다.
// C++20 Template Lambda
auto sumTemplate = []<typename T>(T a, T b) { return a + b; };
// 실전에서 사용
auto GetVectorSize = []<typename T>(const vector<T> v) { return v.size(); };
vector<int> v{ 1, 2, 3, 4, 5 };
vector<double> v2{ 1.2, 2.3, 3.4 };
auto s1 = GetVectorSize(v); // 결과 : 5
auto s2 = GetVectorSize(v2); // 결과 : 3
[] <typename T> (const vector<T>& values)
{
T someValue { };
T:: some_static_function();
};
템플릿 람다는 벡터처럼 외형은 같지만 내부 요소 타입이 다른 컨테이너를 유연하게 처리할 수 있어 실용적이다.
그리고 여담으로 저번에 배운 Concept의 requires 구문으로 템플릿 타입에 대한 제약사항을 지정할 수 있다.
// 두 매개변수는 반드시 int형이어야만 함
[] <typename T>(const T& value1, const T& value2) requires integral<T> { }
C++에서는 람다 표현식을 함수의 반환값으로 사용할 수 있다.
이를 위해 일반적으로 std::function을 이용한다.
function<int(void)> multiplyLambda(int x)
{
return [x]() { return 2 * x; };
}
// 컴파일 에러
auto multiplyLambda2(int x)
{
return [&x]() { return 2 * x; };
}
int main()
{
auto fn = multiplyLambda(5);
cout << fn() << endl; // 결과 10
}
이 함수의 리턴 타입은 인수를 받지 않고 정수를 리턴하는 함수인 function<int(void)>다.
중요한 점은 x를 값으로 캡처하고 있다는 것이다.
이렇게 하면 multiplyLambda() 함수가 끝난 뒤에도 람다 안에서 안전하게 x 값을 사용할 수 있다.
참고로 x를 레퍼런스 캡처를 시도한다면 미정의 동작이 발생한다.
리턴한 람다 표현식은 대부분 이 함수가 끝난 뒤에 사용된다.
그러므로 multiplyLambda() 함수의 스코프는 더 이상 존재하지 않기 때문에 x에 대한 레퍼런스는 이상한 값을 가리키게 된다.
C++20부터는 람다 표현식 자체를 타입으로 사용할 수 있다.
이는 비평가 문맥(unevaluated context), 즉 컴파일 타임에 타입만 필요한 상황에서 유용하다. 대표적인 예가 decltype이다.
using LambdaType = decltype([](int a, int b) { return a + b; });
위 코드는 { ... } 형태의 람다 표현식의 타입을 가져와 LambdaType으로 정의한다.
이는 실제로 람다를 실행하지 않고 타입 정보만 가져오는 것이기 때문에 런타임이 아닌 컴파일 타임에만 사용된다.
C++20 이전에는 이런 방식은 허용되지 않았지만, 이제는 람다 자체를 템플릿 인수나 타입 추론에서 활용할 수 있는 길이 열렸다.
이번에는 C++20에서 새롭게 추가된 기능뿐만 아니라, 모던 C++ 초기부터 존재하던 람다 표현식(Lambda)에 대해 살펴봤다.
물론 모든 세부 사항을 다 다루지는 못했기 때문에, 더 깊은 개념을 알고 싶다면 공식 문서를 참고하는 것을 추천한다.
람다를 다시 공부하면서 필자 역시 그동안 몰랐던 개념을 새로 배웠고, 그 과정이 꽤 즐거웠다.
특히 람다를 매개변수나 리턴 타입으로 사용하는 방식은 실전에서 써먹을 수 있는 유용한 기술이었다.
또한 C++20에서 추가된 템플릿 람다 표현식은 STL과 함께 사용하기 훨씬 편리해졌다는 점에서 꽤 인상 깊었다.
앞으로 C++20을 기반으로 프로젝트를 새로 시작하게 된다면, 이번에 배운 람다 관련 개념들을 적극적으로 활용하게 될 것 같다.
Inflearn [Rookiss][C++20 훑어보기]
전문가를 위한 C++(개정5판) P1003~P1016
C++공식문서