[Modern C++] 항목 32: 객체를 클로저 안으로 이동하려면 초기화 캡처를 사용하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩
👉🏻 항목 32: 객체를 클로저 안으로 이동하려면 초기화 캡처를 사용하라
🔍 문제 상황
- 값 캡처도, 참조 캡처도 마땅치 않은 경우가 있다.
std::unique_ptr,std::future같은 이동 전용 객체를 클로저 안으로 들여오려는 경우- 복사는 비싸고 이동은 저렴한 객체(표준 라이브러리 컨테이너 대부분)를 복사 대신 이동으로 들여오려는 경우
- C++11에는 이를 위한 방법이 없다. C++14는 객체를 클로저 안으로 이동하는 수단을 직접 제공한다. C++11에서는 이동 캡처를 흉내 내는 방법이 있다.
- C++14는 이동 캡처만 추가한 것이 아니라 훨씬 유연한 새 캡처 메커니즘인 초기화 캡처(init capture)를 도입했다. 이동 캡처는 그 메커니즘으로 할 수 있는 일 중 하나일 뿐이다.
- C++11의 캡처 모드가 할 수 있는 모든 것을 할 수 있다. 단, 초기화 캡처로 표현할 수 없는 것은 기본 캡처 모드인데, 항목 31에서 설명했듯 어차피 피해야 한다.
🔍 초기화 캡처 (C++14)
- 초기화 캡처로는 다음 두 가지를 지정할 수 있다.
- 람다로부터 생성되는 클로저 클래스에 속한 자료 멤버의 이름
- 그 자료 멤버를 초기화하는 표현식
class Widget { // 어떤 유용한 타입
public:
…
bool isValidated() const;
bool isProcessed() const;
bool isArchived() const;
private:
…
};
auto pw = std::make_unique<Widget>(); // Widget을 생성한다;
// std::make_unique에 관해서는 항목 21을 보라
… // 여기서 *pw를 조작한다
auto func = [pw = std::move(pw)] // 클로저의 자료 멤버를
{ return pw->isValidated() // std::move(pw)로
&& pw->isArchived(); };// 초기화한다
"="의 좌변은 클로저 클래스 안의 자료 멤버 이름이고, 우변은 그것을 초기화하는 표현식이다.- 좌변과 우변의 범위(scope)가 다르다.
- 좌변의 범위는 해당 클로저 클래스의 범위다.
- 우변의 범위는 람다가 정의되는 지점의 범위와 동일하다.
- 즉
pw = std::move(pw)는 “클로저 안에서 자료 멤버pw를 생성하되, 지역 변수pw에std::move를 적용한 결과로 초기화하라”는 뜻이다. 람다 본문의pw는 클로저 클래스의 해당 자료 멤버를 가리킨다. "여기서 *pw를 조작한다"는 주석은std::make_unique로 생성한 시점과 람다로 캡처하는 시점 사이에Widget을 수정함을 뜻한다. 그런 수정이 필요 없다면 지역 변수pw는 필요 없고, 클로저의 자료 멤버를std::make_unique로 직접 초기화하면 된다.
auto func = [pw = std::make_unique<Widget>()] // 클로저 안의 자료 멤버를
{ return pw->isValidated() // make_unique 호출
&& pw->isArchived(); }; // 결과로 초기화
- C++11에서는 임의의 표현식의 결과를 캡처하는 것이 불가능했지만 C++14에서는 가능하다. C++14의 ‘캡처’ 개념이 C++11보다 훨씬 일반화되었기 때문에, 초기화 캡처를 일반화된 람다 캡처(generalized lambda capture)라고도 부른다.
벡터 이동 예제 (C++14)
std::vector<double> data; // 클로저 안으로 이동할 객체
… // data에 자료를 채운다
auto func = [data = std::move(data)] // C++14의 초기화 캡처
{ /* 여기서 data를 사용 */ };
🔍 C++11에서 이동 캡처 흉내 내기 ① — 직접 작성한 클래스
- 람다 표현식은 컴파일러가 클래스를 자동으로 작성해서 그 객체를 생성하게 하는 수단일 뿐이다. 람다로 할 수 있는 일은 그런 클래스를 직접 만들어서 수행하는 것도 가능하다.
class IsValAndArch { // 유효성 및 보관 여부
public: // (validated and archived)를 판별
using DataType = std::unique_ptr<Widget>;
explicit IsValAndArch(DataType&& ptr) // std::move는
: pw(std::move(ptr)) {} // 항목 25에서 설명한다
bool operator()() const
{ return pw->isValidated() && pw->isArchived(); }
private:
DataType pw;
};
auto func = IsValAndArch(std::make_unique<Widget>());
- 람다보다 타자량이 많지만, C++11에서 자료 멤버의 이동 초기화를 지원하는 클래스가 필요할 때 키보드와 조금만 더 시간을 보내면 원하는 바를 이룰 수 있다.
- (역주: C++11에는
std::make_unique가 없다. 필요하면 직접 구현해서 사용해야 한다. 항목 21 도입부에 예제 구현이 있다.)
🔍 C++11에서 이동 캡처 흉내 내기 ② — std::bind
- 다음 두 단계로 흉내 낼 수 있다.
- 캡처할 객체를
std::bind가 생성하는 함수 객체로 이동한다. - 그 ‘캡처된’ 객체에 대한 참조를 람다에 넘겨준다.
- 캡처할 객체를
std::vector<double> data; // 이전과 동일
… // 이전과 동일
auto func =
std::bind( // C++11에서 초기화
[](const std::vector<double>& data) // 캡처를 흉내 내는
{ /* 여기서 data를 사용 */ }, // 방법
std::move(data)
);
std::bind도 람다처럼 함수 객체(바인드 객체)를 생성한다. 첫 인수는 호출 가능한 객체이고, 나머지 인수들은 그 객체에 전달할 값들이다.- 바인드 객체는
std::bind에 전달된 모든 인수의 복사본을 포함한다. lvalue 인수는 복사 생성되고, rvalue 인수는 이동 생성된다. 이 예에서 둘째 인수는 rvalue(std::move의 결과)이므로data는 바인드 객체 안으로 이동된다. 이 이동 생성이 이동 객체 흉내의 핵심이며, rvalue의 이동이 불가능하다는 C++11 클로저의 한계를 우회한다. - 바인드 객체가 ‘호출’되면 저장된 인수들이
std::bind호출 시 첫 인수로 지정한 호출 가능 객체에 전달된다. 이 예에서는func가 호출되면func에 저장된data의 복사본(이동 생성된)이 람다에 인수로 전달된다. - 이 람다는 C++14 버전과 같되, 이동 캡처 흉내용 객체에 해당하는
data매개변수가 추가되었다. 이 매개변수는 바인드 객체 안의data복사본에 대한 lvalue 참조다(data의 복사본을 초기화하는 표현식std::move(data)는 rvalue지만,data의 복사본 자체는 lvalue다). 따라서 람다 본문에서data를 사용하는 코드는 바인드 객체 안의 이동 생성된data복사본을 사용한다. - 기본적으로 람다로부터 만들어진 클로저 클래스의
operator()멤버 함수는const이므로, 람다 본문 안에서 클로저의 모든 자료 멤버는const가 된다. 그러나 바인드 객체 안의 이동 생성된data복사본은const가 아니다. 람다 안에서data복사본이 수정되지 않게 하려면 람다의 매개변수를const에 대한 참조로 선언해야 한다. 만일 람다 안에서 수정이 가능해야 한다면, 람다를mutable로 선언해서 클로저 클래스의operator()가const로 선언되지 않게 하고, 람다의 매개변수 선언에서const를 제거해야 한다.
auto func =
std::bind(
[](std::vector<double>& data) mutable // C++11에서 mutable
{ /* 여기서 data를 사용 */ }, // 람다의 초기화 캡처를
std::move(data) // 흉내 내는 방법
);
- 바인드 객체는
std::bind에 전달된 모든 인수의 복사본을 저장하므로, 이 예의 바인드 객체는 람다가 생성한 클로저(std::bind의 첫 인수)의 복사본도 저장한다. 따라서 그 클로저의 수명은 바인드 객체의 수명과 같다. 클로저가 존재하는 한 이동 캡처를 흉내 내는 객체를 담은 바인드 객체도 존재함을 뜻한다.
C++11 바인드 객체에 대한 핵심 요점
- 객체를 C++11 클로저 안으로 이동 생성하는 것은 불가능하나, 객체를 C++11 바인드 객체 안으로 이동 생성하는 것은 가능하다.
- C++11에서 이동 캡처를 흉내 내는 방법은 객체를 바인드 객체 안으로 이동 생성하고, 이동 생성된 객체를 람다에 참조로 전달하는 것이다.
- 바인드 객체의 수명이 클로저의 수명과 같으므로, 바인드 객체 안의 객체들을 마치 클로저 안에 있는 것처럼 취급하는 것이 가능하다.
std::unique_ptr 예제 (C++14 → C++11)
// C++14: 클로저 안에서 pw를 생성한다
auto func = [pw = std::make_unique<Widget>()]
{ return pw->isValidated()
&& pw->isArchived(); };
// C++11: 이를 흉내 낸 것
auto func = std::bind(
[](const std::unique_ptr<Widget>& pw)
{ return pw->isValidated()
&& pw->isArchived(); },
std::make_unique<Widget>()
);
- C++11 람다의 한계를 우회하기 위해
std::bind를 사용하는 방법을 제시하는 것은 다소 모순적이다. 항목 34에서는std::bind보다는 람다를 선호하라고 조언하기 때문이다. 그러나 그 항목에서도 C++11에서std::bind가 유용한 경우가 존재함을 지적하며, 지금이 바로 그런 경우 중 하나다. (C++14에서는 초기화 캡처와auto매개변수 덕분에 그런 경우들이 없다.)
🧐 정리
- 객체를 클로저 안으로 이동할 때에는 C++14의 초기화 캡처를 사용하라.
- C++11에서는 직접 작성한 클래스나
std::bind로 초기화 캡처를 흉내 낼 수 있다.
댓글남기기