* 정의

 

람다 표현식(lambda expression)(람다(lambda))는 호출되거나 함수에 대한 인수로 전달되는 위치에서 익명 함수 객체를 정의하는 편리한 방법이다.

 

 

* 사용 이유

 

알고리즘 또는 비동기 함수들에 전달되는 몇 줄의 코드를 캡슐화하는 데 사용된다.

 

다음과 같은 곳에서 유용하게 사용된다.

  • _if 알고리즘 : std::find_if, std::remove_if, std::count_if
  • 비교 함수로 커스텀화할 수 있는 알고리즘 : std::sort, std::nth_element, std::lower_bound
  • std::unique_ptr, std::shared_ptr를 위한 삭제자
  • 콜백 함수
  • 인터페이스 적응 함수
  • 일회성 호출을 위한 문맥 국한적 함수

 

 

* 동작 방식

 

- 용어 정리

 

* 람다 표현식(lambda expression)

 : 하나의 표현식으로 소스 코드의 일부이다.

 

* 클로저(closure)

 : 람다에 의해 만들어진 실행시점 객체이다. 캡처 모드(capture mode)에 따라 클로저가 갈무리된 자료의 복사본을 가질 수도 있고 그 자료에 대한 참조를 가질 수도 있다.

 

* 클로저 클래스

 : 클로저를 만드는 데 쓰인 클래스를 말한다. 각각의 람다에 대해 컴파일러는 고유한 클로저 클래스를 만들어 낸다. 람다 안의 문장들은 해당 클로저 클래스의 멤버 함수들 안의 실행 가능한 명령들이 된다.

 

void Lambda_Test(void)
{
	int x = 3;

	// c1은 람다에 의해 만들어진 클로저의 복사본
	auto c1 = [x](int y) { return x * y; };

	// c2는 c1의 복사본
	auto c2 = c1;
}

 

 

- 간단한 동작 방식

 

람다 표현식은 컴파일러가 하나의 클래스(클로저 클래스)를 자동으로 작성해서 그 클래스의 객체(클로저)를 생성하게 만든다.

 

 

* 기본 캡처 모드(default capture mode)

 

C++11의 기본 캡처 모드(default capture mode)는 다음과 같이 두 가지가 있다.

  • 참조에 의한(by-reference) 캡처 모드
  • 값에 의한(by-value) 캡처 모드

기본 참조 캡처 모드에서는 참조가 대상을 잃는 문제가 있을 수 있고, 기본 값 캡처 모드에서는 자기 완결적(self-contained)이지 않을 수 있다. 

 

 

- 기본 참조 캡처 주의 사항

 

참조 캡처를 사용하는 클로저는 지역 변수 또는 람다가 정의된 범위에서 볼 수 있는 매개변수에 대한 참조를 가지게 된다. 하지만 다음과 같은 코드에서는 참조가 대상을 잃는 문제가 발생한다.

std::vector<std::function<bool(int)>> Filters;

void AddDivisorFilter(void)
{
	// ...
	int divisor = Compute();

	// 참조 캡처를 사용하는 과정에서 divisor에 대한 참조가 대상을 잃는다.
	//Filters.emplace_back([&](int value) { return (value % divisor) == 0; });
	Filters.emplace_back([&divisor](int value) { return (value % divisor) == 0; });
}

void Lambda_CaptureMode(void)
{
	std::vector<std::function<bool(int)>> filters;

	AddDivisorFilter(filters);
	
	// 추가 하는 과정에서의 참조 대상 divisor는 지역변수이기 때문에 여기서는 유효하지 않다.
	filters[0](2);
}

람다에 의해 생성된 클러저의 수명이 그 지역 변수나 매개변수의 수명보다 길기 때문에 클로저 안의 참조는 대상을 잃는 현상이 발생한 것이다. [&], [&divisor] 어떤 형식을 사용하여도 마찬가지이다. 하지만 [&divisor]과 같이 명시적으로 써주면 람다의 클로저의 유효성을 좀 더 직관적으로 판단할 수 있다. (divisor가 람다의 클로저 이상으로 오래 살아있어야 한다고 직관적으로 알 수 있다.)

 

 

- 기본 값 캡처 주의 사항

 

위의 참조 캡처의 문제점을 해결하기 위해 다음과 같이 기본 값 캡처 모드를 사용할 수 있다.

void AddDivisorFilter(void)
{
	// ...
	int divisor = Compute();

	Filters.emplace_back([=](int value) { return (value % divisor) == 0; });
}

 

 

@ 포인터에 대한 값 캡처

 

기본 값 캡처 방식은 완전한 해결책은 되지 못한다. 예를 들어 포인터를 값으로 캡처하면 포인터가 람다에 의해 생성된 클로저 안으로 복사되는데, 람다 바깥에서 그 포인터가 삭제될 수 있기 때문이다. 

 

또한 포인터의 값이 복사되었다는 것을 인지하기 어려운 경우도 존재한다. 예를 들어 클래스에서 람다를 사용하는 경우에 그렇다.

class Widget
{
public:
	void addFilter(void) const
	{
		Filters.emplace_back([=](int value) { return (value % _divisor) == 0; });
	}

public:
	int _divisor;
};

얼핏보면 _divisor의 값을 복사해서 사용하는 것처럼 생각할 수 있다. 하지만 캡처는 오직 람다가 생성된 범위 안에서 보이는 static이 아닌 지역 변수(매개변수 포함)에만 적용된다.

 

위의 코드에서 지역변수도 매개변수도 아닌 _divisor에 접근했는데 컴파일 되는 이유는 클래스 내부에서 암묵적으로 this라는 포인터를 사용하고 있기 때문이다. 위의 코드를 명시적으로 표현하면 다음과 같이 표현된다.

void addFilter(void) const
{
    auto currentObjectPtr = this;
    Filters.emplace_back([currentObjectPtr](int value) { return (value % currentObjectPtr->_divisor) == 0; });
}

만약 this를 가리키는 Widget 객체가 소멸한후 람다를 사용한다면 미정의 행동을 유발하게 된다.

 

이를 해결하기 위해서는 지역 변수에 멤버 변수의 값을 복사하고 지역 변수를 값으로 전달하면 된다. 또한 일반화된 람다 갈무리를 사용해서도 해결할 수 있다.

// 지역 변수에 복사한 후 값 캡처
void addFilter(void) const
{
	auto divisorCopy = _divisor;
	Filters.emplace_back([divisorCopy](int value) { return (value % divisorCopy) == 0; });
}

// 일반화된 람다 캡처
void addFilter(void) const
{
	Filters.emplace_back([divisor = _divisor](int value) { return (value % divisor) == 0; });
}

 

 

@ 자기 완결적으로 보이지만 그렇지 않을 수 있다.

 

void AddDivisorFilter(void)
{
	// ...
	static int divisor = Compute();

	Filters.emplace_back([=](int value) { std::cout << divisor << '\n'; return (value % divisor) == 0; });
	++divisor;
}

다음과 같은 코드를 보면 [=]에서 복사된다고 생각할 수 있다. 하지만 이 람다는 어떤 비정적 지역 변수도 사용하지 않기 때문에 캡처 할 수 없다. 캡처는 오직 람다가 생성된 범위 안에서 보이는 static이 아닌 지역 변수(매개변수 포함)에만 적용된다.

 

즉 람다의 코드는 static 변수 divisor을 가리키게 되고 해당 람다를 필터에 추가한 시점에서의 divisor값이 아닌 사용 시점의 divisor값을 사용하게 된다.

 

 

* 초기화 캡처(init capture)(일반화된 람다 캡처(generalized lambda capture))

 

이동 전용 객체(std::unique_ptr, std::future 등)의 경우에는 클로저 안으로 들어가야 하고, 복사는 비싸지만 이동은 저렴한 객체들 역시 클로저 안으로 들여오는 것이 좋다. C++14부터 객체를 클로저 안으로 이동하는 수단을 직접 제공한다.

 

이를 실현시키는 수단은 초기화 캡처(init capture)라고 불리는 방법이다. 이 방법은 C++11의 캡처에서 할 수 있는 모든 것들을 할 수 있으며 그 외 여러 가지 기능도 지원한다.

 

 

- 초기화 캡처(C++14)

 

초기화 캡처로 다음과 같은 것들을 지정할 수 있다.

  • 람다로부터 생성되는 클로저 클래스에 속한 자료 멤버의 이름
  • 그 자료 멤버를 초기화하는 표현식
void Lambda_InitCapture(void)
{
	auto pw = std::make_unique<Widget>();

	auto func = [pw = std::move(pw)]{ return pw->isValidated(); };
}

위와 같은 코드에서 좌변 pw는 클로저 클래스 안의 자료 멤버를 지칭하고 우변 pw은 람다 이전에 선언된 객체를 뜻한다. 이를 종합해보면 다음과 같다.

클로저 안에서 자료 멤버 pw를 생성하되, 지역 변수 pw에 std::move를 적용한 결과로 그 자료 멤버를 초기화 하라.

람다 본문의 코드는 클로저 클래스의 범위 안에 있으므로 본문에 있는 pw는 클로저 클래스의 해당 자료 멤버를 지칭한다.

 

이처럼 C++14에서는 어떤 표현식의 결과를 캡처할 수 있도록 지원하고 있는 것이다. 이를 일반화된 람다 캡처(generalized lambda capture)라고 부르기도 한다.

 

위의 람다식에 의해 생성되는 클래스는 다음과 비슷하게 만들어진다.

class ClosureClass
{
public:
	explicit ClosureClass(std::unique_ptr<Widget>&& ptr)
		: pw(std::move(ptr)) {}

	bool operator()() const
	{
		return pw->isValidated();
	}

private:
	std::unique_ptr<Widget> pw;
};

 

 

- 초기화 캡처 흉내(C++11)

 

C++11에서 이를 흉내내기 위해서는 다음과 같은 방식을 사용해야한다.

  • 캡처할 객체를 std::bind가 산출하는 함수 객체로 이동시킨다.
  • 캡처 객체에 대한 참조를 람다에 넘겨준다.

 

C++14와 비교해서 보면 다음과 같다.

void Lambda_InitCaptureCopy(void)
{
	std::vector<double> data;
	// ...
	
	// C++14
	auto func1 = [data = std::move(data)]{ /* data[index] ... */ };

	// C++11
	auto func2 = std::bind([](const std::vector<double>& data) { /* data[index] ... */ },
						   std::move(data));
}

 

위의 상황을 그림으로 다음과 같이 나타낼 수 있다.

 

여기서 다음과 같은 사실을 확인할 수 있다.

 

  • std::bind는 호출 가능한 함수 객체(바인드 객체)를 돌려준다.
  • std::bind의 첫 인수는 호출 가능한 객체이고, 나머지 인수는 그 객체에 전달할 값들을 나타낸다.
  • std::bind에 전달된 모든 인수의 복사본들을 포함한다.
  • 람다가 생성한 클로저 또한 복사되어 바인드 객체에 저장된다.
  • C++11에서는 C++14에서 처럼 클로저 안으로 객체를 이동 생성하는 것이 불가능하기 때문에 바인드 객체에 저장한다.
  • 바인드 객체에 저장된 객체는 클로저에 참조로 전달한다.
  • 복사된 클로저와 바인드 객체의 수명은 동일하기 때문에 바인드 객체 안의 객체들을 마치 클로저 안에 있는 것처럼 취급할 수 있다. 
  • std::bind는 전달된 인수에 대하여 왼값은 복사 생성된 객체, 오른값은 이동 생성된 객체를 가지고 있다.
  • 람다로부터 만들어진 클로저 클래스의 operator(), 모든 자료 멤버는 const이다.
  • 바인드 객체 안의 이동 생성된 data 복사본은 const가 아니다. 만약 data를 변경해야한다면 클로저의 operator함수를 non-const함수로 변경할 필요가 있다. 이는 람다를 작성할 때 mutable키워드를 붙여서 작성함으로써 실현된다.

 

 

* 람다의 매개변수와 auto : 일반적 람다(generic lambda)

 

람다 표현식의 매개변수에도 auto 키워드를 사용할 수 있다. 이를 일반적 람다(generic lambda)라고 한다.

일반적인 auto를 사용한 람다 표현식은 다음과 같다.

auto f1 = [](auto x) { return normalize(x); };

 

 

만약 완벽 전달을 수행하려면 보편 참조 형식으로 바꿔주면 된다. 다만 매개변수를 다른 곳으로 넘겨줄 때 템플릿에서의 보편참조와 같이 std::forward<T>를 사용하지 못한다. (T라는 것을 알지 못하기 때문에)

 

하지만 그냥 자료형 그대로 넘겨주어도 문제가 없다. 참조 축약 규칙에 의해 오른값(int&& 등)을 넘겨주어도 오른값(int&& 등)을 돌려받는다.

auto f2 = [](auto&& x) { return normalize(std::forward<decltype(x)>(x)); };

 

 

여기서 많은 가변 매개변수를 받고 싶다면 다음과 같이 수정하면 된다.

auto f3 = [](auto&&... xs) { return normalize(std::forward<decltype(xs)>(xs)...); };

 

 

* 람다 vs std::bind

 

결론부터 말하자면 람다가 가독성, 편의성 등 모든 면에서 뛰어나다고 볼 수 있다.

 

예시로 어떤 지점에서 한 시간 후부터 30초간 소리를 내는 함수가 있다고 가정한다.

enum class Sound { Beep, Siren, Whistle };
using Time = std::chrono::steady_clock::time_point;
using Duration = std::chrono::steady_clock::duration;


void SetAlarm(Time t, Sound s, Duration d) { /* ... */ }

void Lambda_Bind(void)
{
	auto setSoundByLambda = [](Sound s)
	{
		using namespace std::chrono;
		using namespace std::literals;

		SetAlarm(steady_clock::now() + 1h,
				 s,
				 30s);
	};

	using namespace std::chrono;
	using namespace std::literals;
	using namespace std::placeholders;

	auto setSoundByBind =
		std::bind(SetAlarm,
				  steady_clock::now() + 1h,
				  _1,
				  30s);
}

 

람다와 바인드를 사용해 구현하면 위와 같이 구현된다.

 

- 람다는 바인드에 비해 명확하고 가독성이 좋다.

 

바인드는 자리표(placeholder)라는 것을 통해 setSoundByBind의 첫인수를 SetAlarm의 두번째 인수로 전달하는 역할을 한다. 바인드 객체를 통해 인수가 어떤 SetAlarm의 어떤 매개변수로 들어가는지 신경써야한다. 람다는 간단히 봐도 정확하게 파악할 수 있다.

 

한가지 예를 들어보자면 다음과 같다.

void Lambda_Bind(void)
{
	int lowVal = 3;
	int highVal = 10;

	auto betweenByLambda = [lowVal, highVal](const auto& val)
	{ return lowVal <= val && val <= highVal; };

	using namespace std::placeholders;
	auto betweenByBind =
		std::bind(std::logical_and<bool>(),
				  std::bind(std::less_equal<int>(), lowVal, _1),
				  std::bind(std::less_equal<int>(), _1, highVal));

}

 

 

- 바인드 사용 시, 바인드 시점에 값이 결정되지 않도록 지연시킬 필요가 있다.

 

위의 코드에서 람다는 setSoundByLambda를 호출할 때 시간이 계산되어 한 시간 후 부터 30초간 알람이 울린다. 하지만 바인드의 경우 std::bind를 호출할 때 시간이 계산되어 이 시점기준으로 한 시간 후 30초간 알람이 울리게 된다. 이를 방지하기 위해서 다음과 같은 코드를 작성해야한다. 

	auto setSoundByBind =
		std::bind(SetAlarm,
				  std::bind(std::plus</*steady_clock::time_point*/>(),
							std::bind(steady_clock::now),
							1h),
				  _1,
				  30s);

 

 

- 바인드 사용 시, 함수의 이름 밖에 알지 못하기 때문에 중복적재된 함수를 적절하게 선택할 수 없다.

 

이를 명시적으로 std::bind에 알려줘야 한다.

void SetAlarm(Time t, Sound s, Duration d) { /* ... */ }
// 중복적재 추가
void SetAlarm(Time t, Sound s, Duration d, int factor) { /* ... */ }

void Lambda_Bind(void)
{
	// ...
	using namespace std::chrono;
	using namespace std::literals;
	using namespace std::placeholders;
	using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);
    
	auto setSoundByBind =
		std::bind(static_cast<SetAlarm3ParamType>(SetAlarm),
				  std::bind(std::plus</*steady_clock::time_point*/>(),
							std::bind(steady_clock::now),
							1h),
				  _1,
				  30s);
}

 

 

- 바인드 사용 시, 함수 포인터를 통해 호출이 일어나기 때문에 컴파일러가 인라인화할 가능성이 더 낮다.

 

- 바인드 사용 시, 매개변수의 저장 방식(std::bind에서 매개변수로 받은 값들을 저장할 때)에 대해 알아야한다.(값으로 전달된다.) 람다에서는 캡처 방식에 따라서 명확하게 확인할 수 있다.

 

- 바인드 사용 시, 바인드 객체를 호출 시 인수의 전달 방식을 알야아 한다.(참조로 전달된다.)

 

 

바인드는 C++11에서만 가끔 쓰이는 경우가 있는데(C++14에서는 쓰이지 않는다.) 쓰이는 경우는 다음 두 가지이다.

  1. 이동 캡처 구현
  2. 다형적 함수 객체(다양한 인수들을 받을 수 있게 하기 위해)

 

'C++' 카테고리의 다른 글

가변인자 템플릿(Variadic Template)  (0) 2022.05.23
템플릿(Template)  (0) 2022.05.23
완벽 전달(Perfect forwarding)의 실패  (0) 2022.03.31
참조 축약  (0) 2022.03.31
보편 참조에 대한 중복적재 대안  (0) 2022.03.31

+ Recent posts