Lambda 란?

람다란 평균적으로 람다 함수라고 생각하게 된다. 여기서 람다 함수는 이름 없는 함수. 즉, 익명 함수 (Anonymous Function) 또는 클로저 (Closure) 를 말하고, 말 그대로 함수의 이름이 없는 상태로 std::function이 호출되는 함수의 내용을 즉석에서 구현하는 문법이라고 봐야한다.

사실 람다 함수라는 단어 자체도 원래는 존재하지 않아야 한다고 한다. 우리가 평소에 사용하는 [](){}로 묶인 람다 함수라고 불리우는 것들은 정식적인 명칭으로는 람다 표현식(Lambda Expression)이라고 불러야 한다. 그로 인해서 탄생한 함수를 만드는 문법이기 때문에, 클로저를 만드는 문법 또는 기술이라고 봐야한다. 하지만, 람다 함수라고 하면 일맥상통하니 별로 상관은 없다.

람다 표현식 (Lambda Expressions)

[](){} 이게 최소한의 내용으로 만든 람다 표현식이다.

[](){}는 std::function<void()> 가 가리키는 함수 내용을 즉석에서 구현하는 것이다. 예를들어

std::function<bool<int, Object::Body*)>

라고 되어있는 식을 C++에서 람다 표현식으로 풀어내면

[](int, Object::Body*)->bool{}

위와같이 만들수도 있다.

기본 문법

람다에는 크게 4가지의 부분으로 나눌수 있다.

  • [] : 캡처
  • () : 매개변수
  • -> : 리턴형
  • {} : 바디
//기본 문법 표현
[ capture-list ] ( params ) -> returnType { body }

//예제
auto lfunc = [count](int x)->int{ x++; }

[] 캡처 (Capture)

람다 표현식이 사용된 중괄호 내에 있는 지역변수를 바디 내에서 사용할 수 있도록 해주는 일종의 통로같은 역할을 해준다.

예를 들어 이런 코드가 있다고 가정해보자.

#include <iostream>

void CaptureTest()
{
	int data = 0;
        //OK 지역변수를 캡처에 넣어 사용가능
	auto lambda1 = [data]() {std::cout << data << std::endl; };
        //ERROR 캡처에 아무것도 없음 사용 불가
	auto lambda2 = []() {std::cout << data << std::endl; };
}

주석에서 볼 수 있듯 []로 이루어진 캡처 쪽에 지역변수의 값을 넣어줘야만 함수 바디 안에서 사용할 수 있다.

캡처에도 4가지 문법이 있다.

  • [=] : 값에 의한 캡처
  • [&] : 참조에 의한 캡처
  • [localVar] : 특정 변수값에 의한 캡처
  • [&localVar] : 특정 변수를 레퍼런스에 의한 캡처

위의 4가지의 문법을 기본 캡처(Default Capture)라고 부른다.

[=] : 값에 의한 캡처

[=] 람다 표현식은 바디 안에서 사용할 수 있는 모든 데이터(지역 변수 & 전역 변수)들을 값으로 얻어 사용한다.

값으로 가져온 캡처 변수는 기본적으로 const 속성으로 가져와 수정이 불가능하다.

클래스의 멤버 변수를 람다 바디 안에서 사용했다면, 이는 this 포인터가 생략되어 있는 것이다.

[&] : 참조에 의한 캡처

[&] 람다 표현식은 바디 안에서 사용할 수 있는 모든 데이터(지역 변수 & 전역 변수)들을 참조로 얻어 사용한다.

참조로 가져온 캡처된 레퍼런스는 수정이 가능할 수 있다.

클래스의 멤버 변수를 람다의 바디 안에서 사용했다면, 이는 this 포인터가 생략되어 있는 것이다.

[localVar] : 특정 변수값에 의한 캡처

[localVar] 람다 표현식은 바디안에서 사용할 수 있는 특정 변수를 명시적으로 선언해 값으로 얻어 사용한다.

그 이외의 조건은 [=]와 같으며, [&, localVar] 식으로 혼합해 사용할 수 있다.

[&localVar] : 특정 변수를 레퍼런스에 의한 캡처

[&localVar] 람다 표현식은 바디 안에서 사용할 수 있는 특정 변수를 명시적으로 선언하여 참조로 얻어 사용한다.

그 이외의 조건은 [&]와 같으며, [=, &localVar] 식으로 혼합해 사용할 수 있다.

() 매개변수 (Parameter)

그냥 별거 없다. 우리가 평소에 흔히 쓰는 함수의 매개변수와 동일한 부분이다.

->ret 리턴형 (Return Type)

리턴형을 설정한다. 기본은 ->void로 되어있다. 바디에서 무얼 리턴하냐에 따라 리턴형이 그에 맞춰 생기며 암묵적으로 생략되어있다. 하지만, 만약 리턴형이 존재할 경우에 무조건 리턴형을 명시해두는 것이 좋은 선택이다.

{} 함수 바디 {Function Body)

일반적인 함수 바디와 똑같다. 함수 로직을 작성해넣으면 된다.

*주의할 점*

람다 함수는 자기 완결적(self-contained) 되었다는 환상을 심어줄 수 있다. 람다 표현식으로 작성된 std::function은 그 객체 하나 만으로 완벽한 하나의 함수로 생각할 수 있겠지만, 잘 못 사용하면 고아 포인터(Dangling Pointer) 즉, 런타임 에러의 원천이 된다.

문제의 예

//TestClass라는 클래스는 생성자에서 멤버로 가지고 있는 벡터 m_vecData를 초기화한다.
TestClass::TestClass():
m_nData(10)
{
    m_vecData.clear();
    m_vecData.push_back(25);
}
 
void TestClass::createLambdaAndInsertAtMap(const std::string& strFncName)
{
    //멤버변수 m_vecData를 람다의 바디에서 사용하기 위해 디폴트 캡쳐 =를 사용한다.
    auto fncData1 = [=](){                          
        printf("m_vecData = %d\n", m_vecData[0]);   //m_vecData 앞에는 this-> 가 생략되어 있다.
    };
    
    //해당 클로저를 전역 변수로 사용되는 map에 insert한다.
    commonData::getInstance()->getMapdata()[strFncName] = fncData1; 
};

이제 메인 함수에서 조그마한 코드를 작성해 람다 함수를 호출해보자.

int main(int argc, const char * argv[])
{
    auto instance = new TestClass; // 인스턴스 동적할당
    
    //람다 클로저를 전역 변수로 사용되는 map에 "ok"라는 키값으로 insert 했다.
    instance->createLambdaAndInsertAtMap("ok"); 
 
    
    delete instance;     //동적할당된 인스턴스 삭제
    instance = nullptr;
    
    //전역 변수로 사용되는 map 에 "ok" 라는 키값의 std::function을 호출한다.
    commonData::getInstance()->getMapdata()["ok"](); 
    
    return 0;
}

생성자에서 push_back 해둔 25가 출력되는 것을 예상하기 쉽다. 하지만, 벡터의 사이즈는 0이 되었고 이 데이터가 없는 0번 인덱스를 호출하니, 미정의 행동(Undefined behavior)가 발생한다.

std::fucntion은 사실 암묵적으로 다음과 같은 람다를 생성했다.

void TestClass::createLambdaAndInsertAtMap(const std::string& strFncName)
{
    auto pThis = this;
    auto fncData1 = [pThis](){            //this를 캡처
 
        //[=] 로 캡쳐된 클래스의 맴버데이터의 메모리는 this에 의존적이다.
        printf("m_vecData = %d\n", pThis->m_vecData[0]);   
    };
    commonData::getInstance()->getMapdata()[strFncName] = fncData1;
};

std::function은 클로저를 생성하면서 캡처에 들어온 this 자체의 메모리를 어떻게든 복사해서 메모리는 살려뒀지만, this가 가지고 있던 벡터의 메모리는 보장되지 않았다. 생성자에서 초기화를 해주는 멤버 데이터임에도 말이다.

기본 생성자는 물론이고, 복사, 대입, 이동 생성자도 작동하지 않는다.

조금 더 세심한 정보가 필요함을 느껴 구글느님의 Google C++ Style Guide 페이지를 참조했다.

Lambdas were introduced in C++11 along with a set of utilities for working with function objects, such as the polymorphic wrapper std::function.

람다는 C++ 11 에서 std::function같은 다형성 레퍼인 함수 오브젝트 유틸리티들과 함께 소개되었다.

장점

  • Lambdas are much more concise than other ways of defining function objects to be passed to STL algorithms, which can be a readability improvement.
  • Appropriate use of default captures can remove redundancy and highlight important exceptions from the default.
  • Lambdas, std::function, and std::bind can be used in combination as a general purpose callback mechanism; they make it easy to write functions that take bound functions as arguments.

단점

  • Variable capture in lambdas can be a source of dangling-pointer bugs, particularly if a lambda escapes the current scope.
  • Default captures by value can be misleading because they do not prevent dangling-pointer bugs. Capturing a pointer by value doesn’t cause a deep copy, so it often has the same lifetime issues as capture by reference. This is especially confusing when capturing ‘this’ by value, since the use of ‘this’ is often implicit.
  • It’s possible for use of lambdas to get out of hand; very long nested anonymous functions can make code harder to understand.

사용 적합성 판단

  • Use lambda expressions where appropriate, with formatting as described below.
  • Prefer explicit captures if the lambda may escape the current scope. For example, instead of:

나쁜 예:

{
	Foo foo;
	...   
	
	executor->Schedule([&] { Frobnicate(foo); })
	...
} 
// BAD! The fact that the lambda makes use of a reference to `foo` and 
// possibly `this` (if `Frobnicate` is a member function) may not be 
// apparent on a cursory inspection. If the lambda is invoked after 
// the function returns, that would be bad, because both `foo` 
// and the enclosing object could have been destroyed.
// 나쁘다! 람다가 호출되는 시점에 'Frobnicate'는 멤버 함수이며 foo는 소멸되었을 수도 있다.

괜찮은 예:

{   
	Foo foo;
	...   
	executor->Schedule([&foo] { Frobnicate(foo); })   ... } 
// BETTER - The compile will fail if `Frobnicate` is a member 
// function, and it's clearer that `foo` is dangerously captured by reference. 
//한결 낫다. 람다는 this를 캡처하지 않는다. 그리고 명시적으로 레퍼런스 캡처를 했고 이는 더 보기 쉽고 정확하게 체크하기에 좋다.

구글에서 추천하는 람다 표현식은 다음과 같다.

  • Use default capture by reference ([&]) only when the lifetime of the lambda is obviously shorter than any potential captures.
  • Use default capture by value ([=]) only as a means of binding a few variables for a short lambda, where the set of captured variables is obvious at a glance. Prefer not to write long or complex lambdas with default capture by value.
  • Specify the return type of the lambda explicitly if that will make it more obvious to readers, as with auto.
  • 캡처와 리턴타입을 명시적으로 작성해라
  • this가 포함되는 기본 캡처(=, &)를 사용하지 마라.
  • 람다의 내용은 가능한 짧고 간단하게 써라.

사실 이마저도 오류의 원인파악을 조금 더 빨리 하게 만들어줄 뿐 큰 코드베이스에서 람다는 양날의 검이다.

클로저 (Closures)

클로저는 다른말로 익명 함수라고 한다.

보통의 함수는 외부에서 인자를 받아 로직을 처리한다. 하지만 클로저는 자신을 둘러싼 context 내의 변수에 접근할 수 있다. 즉, 일급 객체 함수(first-class functions)의 개념을 이용해 외부 범위의 변수를 함수 내부로 바인딩하는 기술이다.

특이한점은 자신을 둘러싼 외부 함수가 종료되더라도 이 값이 유지된다는 것 (기억한다는 것)이다. 함수에서 사용하는 값들은 클로저가 생성되는 시점에서 정의되고 함수 자체가 복사되어 따로 존재하기 떄문이다.

예를 들면 이러하다.

function startAt(x){
    function incrementBy(y){
        return x + y
    }
    return incrementBy
}
    
var closure1 = startAt(1)
var closure2 = startAt(2)

각각의 closure1과 closure2는 y+1과 y+2 값을 반환하는 함수의 역할이다.

이 코드에서 incrementBy 함수는 코드 블록에 속해 있으므로, 인수인 x에 대한 접근이 가능하다. startAt 함수는 x의 값이나 참조를 복사한 클로저를 반환한다.