종종 값 캡쳐모드나 참조 캡쳐모드 둘 다 사용이 마땅치 않은 경우가 있다. 예를 들어 이동 전용 객체(unique_ptr, future 등)를 클로저 안으로 들여오려는 경우이다.

C++11에서는 방법이 없지만 C++14에서는 초기화 캡쳐라는것을 지원함으로써 가능해졌다. 초기화 캡쳐는 기본 캡쳐모드를 표현할 수 없지만 애초에 직전 항목에서 언급했다시피 기본 캡쳐모드는 피해야 하기 때문에 큰 의미가 없다.

 

초기화 캡쳐는 클로저 클래스에 속한 자료 멤버의 이름과 그 자료 멤버를 초기화하는 표현식을 지정할 수 있다.

 

class Widget {
...
};

auto pw = std::make_unique<Widget>();

auto func = [pw = std::move(pw)]{ ... };

캡쳐 부분에서 '=' 를 기준으로 좌변은 클로저 클래스의 자료 멤버, 우변은 초기화 표현식이다.

둘다 이름이 같지만 좌변과 우변의 범위가 다르다. 좌변은 클로저 클래스의 범위이고 우변은 람다가 정의되는 지점의 범위와 동일하다.

 

auto func = [pw = std::make_unique<Widget>()]{ ... };

별다른 사용 없이 바로 클로저에서 사용할거라면 위와 같이 초기화도 가능하다.

C++11에서는 어떤 표현식의 결과를 캡쳐하는것이 불가능했지만 C++14는 가능해졌다. 

 

만약 C++11에서 이동 캡쳐를 구현하고 싶다면 방법이 존재한다.

캡쳐할 객체를 std::bind가 산출하는 함수 객체로 이동하고 캡쳐된 객체에 대한 참조를 람다에 넘겨주면 된다.

 

std::vector<double> data;

auto func = [data = std::move(data)]() { ... }; // C++14

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

bind에 의해 함수 객체가 산출되고(바인드 객체) 바인드 객체가 호출되면 이동 생성된 data의 복사본이 람다에 전달된다.

data는 move에 의해 오른값이 되었지만 람다의 매개변수가 왼값 참조이기 때문에 이동 생성된 data의 복사본이 전달되게된다.

 

기본적으로 람다로부터 만들어진 클로저 클래스의 operator() 멤버 함수는 const이다. 그래서 람다 본문 안에서 클로저의 모든 자료 멤버는 const가 되지만 이동 생성된 data의 복사본은 const가 아니다.

변경 가능한 람다를 사용하고 싶다면 mutable로 선언하면 된다. 이 경우 매개변수의 const 한정자도 없애줘야 한다.

 

바인드 객체는 전달된 모든 인수의 복사본을 저장하므로 클로저와 바인드 객체의 수명은 동일하다.

사실 std::bind보다 람다를 선호하는 편이 좋다. C++14 이상이라면 맘 편하게 초기화 캡쳐를 사용하면 된다.

 

◾ 객체를 클로저 안으로 이동할 때에는 C++14의 초기화 캡쳐를 사용하라.

◾ C++11에서는 직접 작성한 클래스나 std::bind로 초기화 캡쳐를 흉내 낼 수 있다.

참조 캡쳐를 사용하는 경우에 클로저가 지역 변수나 매개변수의 수명보다 오래 지속되면 클로저 안의 참조는 대상을 잃게된다.

 

using FilterContainer = std::vector<std::function<bool(int)>>;

FilterContainer filters;

void addDivisorFilter() {
    auto calc1 = computeSomeValue1();
    auto calc2 = computeSomeValue2();
    
    auto divisor = computeDivisor(calc1, calc2);
    filters.emplace_back([&](int value) { return value % divisor == 0; });
} // divisor 소멸

지역변수 divisor를 참조하여 클로저를 filters에 추가해주었는데 addDivisorFilter가 반환되면 divisor 역시 소멸하기 때문에 미정의 행동을 유발하게 된다.

 

별도로 저장하지 않고 해당 함수 내에서 즉시 사용하고 클로저가 소멸된다면 참조가 대상을 잃는 일은 발생하지 않는다.

그렇다고 하더라도 다른 사람이 코드가 유용해보여서 복사해서 가져다 쓰는 경우에는 참조를 잃는 일이 발생할 가능성이 다시 생긴다. 명시적인 캡쳐가 아니라 아닌 기본 캡쳐이기 때문에 않기 때문에 divisor의 수명을 한눈에 알아보기 어렵기 때문이다.

 

이를 해결하는 한 가지 방법은 기본 캡쳐모드를 참조(&)가 아닌 값(=)으로 사용하는 것이다.

그런데 또 완벽하지도 않은게 값으로 캡쳐한 대상이 포인터라면 외부에서 삭제할 가능성이 존재하기 때문이다.

 

class Widget {
public:
    void addFilter() const;
    
private:
    int divisor;
};

void Widget::addFilter() const {
    filters.emplace_back([=](int value) { return value % divisor == 0; });
}

우선은 각설하고 다시 본론으로 돌아와서 이번에는 참조가 아닌 값으로 캡쳐한다.

divisor의 값이 클로저 안으로 복사되기 때문에 안전하지 않을까? 싶겠지만 전혀 아니다.

 

캡쳐는 오직 람다가 생성된 범위 안에서 보이는 static이 아닌 지역 변수에만 적용된다. divisor는 멤버 변수이기 때문에 캡쳐가 되지 않는다. 기본 캡쳐이든 명시적 캡쳐이든 전혀 상관이 없다. 오히려 기본 캡쳐를 빼버리면 컴파일 에러가 발생한다.

 

캡쳐도 되지않고 그렇다고 캡쳐를 빼버리면 컴파일 에러가 발생하는데 그 이유는 컴파일러가 클로저 안의 divisor를 this->divisor로 취급하기 때문이다.

분명히 값으로 캡쳐했지만 내부적으로 this포인터로 참조하기 때문에 divisor는 객체의 수명에 의해 제한되어버린다.

 

해결법은 없을까?

 

void Widget::adFilter() const {
    auto divisorCopy = divisor;
    
    filters.emplace_back([divisorCopy](int value){ return value % divisorCopy == 0; });
    // [divisor = divisor](int value){ return value % divisorCopy == 0; } C++14
    // divisor를 클로저에 복사
}

의외로 간단하다. 지역 복사본을 만들어서 그걸 넘겨주면 되는 것이다. C++14는 초기화 캡쳐가 가능하다.

 

기본 값 캡쳐 모드는 클로저 밖에서 일어나는 자료의 변화로부터 격리되어 있을거라는 오해를 부를 수 있는데 전혀 아니다. 전역 또는 이름공간에서 정의된 클래스나 객체, static으로 선언된 객체는 캡쳐 모드와 무관하게 클로저 안에서도 사용할 수 있다.

 

◾ 기본 참조 캡쳐모드는 참조가 대상을 잃을 위험이 있다.

◾ 기본 값 캡쳐모드는 포인터(특히 this)가 대상을 잃을 수 있으며, 람다가 자기 완결적이라는 오해를 부를 수 있다.

완벽 전달은 단순히 객체들을 전달하는 것만이 아니라 왼값 또는 오른값 여부, const, volatile 여부까지도 전달하는 것을 의미한다. 그리고 이를 위해서는 보편 참조 매개변수가 필요하다.

 

template<typename T>
void fwd(T&& param) {
    f(std::forward<T>(param));
}

 

위와 같이 f에 인수를 전달하는 함수 템플릿이 있을 때, 가변인수 템플릿으로 개념을 확장시켜도 논리가 성립된다.

 

template<typename... Ts>
void fwd(Ts&&... params) {
    f(std::forward<Ts>(params)...);
}

이런 형태는 표준 컨테이너의 emplace류 함수들이나 std::make_shared, std::make_unique에서 볼 수 있다.

 

위의 예제처럼 함수 템플릿이 만들어진 상황에서

 

f(/*표현식*/);
fwd(/*표현식*/);

동일한 인수로 두 함수를 호출했을 때, 일어나는 일이 다르다면 완벽 전달은 실패한 것이다.

 

이번 항목에서 완벽 전달이 실패하는 인수의 종류를 알아보도록 하자.

 

 

중괄호 초기치

void f(const std::vector<int>& v);

f({1, 2, 3}); // 암묵적으로 std::vector<int>로 변환
fwd({1, 2, 3}); // 컴파일 에러

f를 직접 호출하는 경우 컴파일러가 호출 지점에서 전달된 인수들의 타입과 f에 선언된 매개변수의 타입을 비교하여 호환성을 파악하고 필요에 따라 적절한 암묵적 변환을 수행하여 호출을 성사시킨다.

 

그런데 전달 함수 템플릿 fwd를 통해 f가 간접적으로 호출된다면 fwd의 호출 지점에서 전달된 인수들과 f에 선언된 매개변수를 직접 비교할 수 없게 된다.

대신 fwd에 전달되는 인수들의 타입을 추론하고 그것들을 f의 매개변수의 타입과 비교한다.

이 때, 다음 두 조건 중 하나라도 만족되면 완벽 전달이 실패한다.

 

◾ fwd이 매개변수들 중 하나 이상에 대해 컴파일러가 타입을 추론하지 못한다. 이 경우 코드는 컴파일되지 못한다.

◾ fwd의 매개변수들 중 하나 이상에 대해 컴파일러가 타입을 잘못 추론한다. 이 경우 코드가 컴파일되지 못하거나 의도와는 다르게 행동한다.

 

이번에 언급된 상황은 auto 지역 변수를 선언하여 std::initializer_list 객체로 만든 뒤 fwd에 전달하면 문제를 우회할 수 있다.

 

 

널 포인터를 뜻하는 0 또는 NULL

0이나 NULL을 널 포인터로 템플릿에 넘겨주려고 하면 포인터 타입이 아니라 정수 타입으로 추론하기 때문에 문제가 생긴다. nullptr을 사용함으로써 간단하게 해결할 수 있다.

 

 

선언만 된 정수 static const 및 constexpr 멤버 변수

일반적으로 정수 static const 멤버 변수와 static constexpr 멤버 변수는 클래스 안에서 정의할 필요 없이 선언만 하면 된다. 컴파일러가 const 전파(constant propagation)를 적용해서 별도의 메모리를 따로 마련하지 않고 상수를 직접 사용하도록 하기 때문이다.

 

class Widget {
public:
    static constexpr std::size_t MinVals = 28; // 선언만 한다
};

MinVals가 적용된 모든 곳에 상수값을 배치하게 된다.

그런데 MinVals의 주소를 참조하려는 코드를 작성하게 되면 MinVals는 메모리에 존재하지 않기 때문에 컴파일은 되지만 정의가 없어서 링크에러가 발생한다.

 

f(Widget::MinVals); // f(28)로 처리된다
fwd(Widget::MinVals); // 링크 에러 발생

이 값으로 f를 직접 호출하는 경우 f(28)로 처리되어 문제가 없지만 전달함수를 거쳐서 호출하려고 하면 링크 에러가 발생한다.

 

포인터와 참조는 본질적으로 같은 것이기 때문에 주소가 없는 MinVals를 전달 함수를 통해 참조로 전달하려고 하면 링크 에러가 발생하는 것이다.

 

근데 무조건 링크 에러가 발생하는 것은 아니고 컴파일러에 따라 다를 수도 있다.

이러한 문제를 확실히 회피하려면 정의를 제공하면 된다.

 

 

오버로딩된 함수 이름과 템플릿 이름

void f(int (*pf)(int)); // void f(int pf(int));

int processVal(int value);
int processVal(int value, int priority);

함수 포인터를 매개변수로 받는 함수와 오버로딩된 함수가 있다고 할 때,

 

f(processVal);

위의 코드는 문제가 없다. 컴파일러가 f의 선언을 보고 일치하는 함수를 골라서 f에 넘겨주게 된다.

 

fwd(processVal);

근데 전달 함수를 통해 참조 전달을 하게되면 문제가 생긴다.

f와 달리 fwd에는 필요한 타입에 대한 정보가 전혀 없기때문에 컴파일러가 어떤 함수를 선택해야 할 지 결정하지 못한다.

processVal 자체에는 타입이 없고 타입이 없으면 타입 추론도 없다. 그리고 타입 추론이 없다는 것이 바로 완벽 전달이 실패하는 또 다른 경우이다.

 

template<typename T>
T workOnVal(T param) { ... }

fwd(workOnVal); // 어떤 인스턴스인지?

함수 템플릿을 전달할때도 workOnVal의 어떤 인스턴스인지 알 수 없기 때문에 동일한 문제가 발생한다.

 

using ProcessFuncType = int(*)(int);
ProcessFuncType processValPtr = processVal;

fwd(processValPtr); // ok
fwd(static_cast<ProcessFuncType>(workOnVal)); // ok

모호함에서 발생하는 문제이기 때문에 오버로딩된 함수나 템플릿 인스턴스를 명시적으로 지정해서 넘겨주면 해결된다.

 

 

비트필드

마지막으로 비트필드가 함수 인수로 쓰일 때이다.

C++의 표준에 비const 참조는 절대로 비트필드에 묶이지 않아야 한다고 명확하게 지정되어있다.

임의의 비트들을 가리키는 포인터를 생성하는 방법이 존재하지 않기 때문이다.

 

다만 비트필드의 완벽 전달을 가능하게 하는 우회방법이 있다.

비트필드를 인수로 받는 함수는 해당 비트필드 값의 복사본을 받게 된다는 점을 알면 쉽게 이해할 수 있다.

값으로 전달하거나 const에 대한 참조로 전달하면 된다. 비트필드를 매개변수에 전달하는 단 두가지의 방법이다.

 

값으로 전달하는 것은 복사본을 받게된다는것이 확실하니 넘어가고 const 참조는 비트필드 자체에 묶이는 것이 아니라 비트필드 값이 복사된 보통 객체에 묶이게 되서 매개변수로 전달할 수 있게 된다.

 

f(h.totalLength); // 값에 의한 전달이 이루어져 복사가 됨

fwd(h.totalLength); // 에러

auto length = static_cast<std::uint16_t>(h.totalLength); // 복사
fwd(length); // 복사본 전달. ok

 

 

결론

대부분의 경우 완벽 전달은 그대로 작동한다. 완벽 전달 여부를 고민해야 하는 경우는 거의 없지만 이번 항목처럼 완벽 전달이 작동하지 않는 경우가 분명히 존재하기 때문에 알아두어야만 문제가 발생시 파악할 수 있다.

 

◾ 완벽 전달은 템플릿 타입 추론이 실패하거나 틀린 타입을 추론했을 때 실패한다.

◾ 인수가 중괄호 초기치이거나 0 또는 NULL로 표현된 널 포인터, 선언만 된 정수 static const 및 constexpr 멤버 변수, 템플릿 및 오버로딩된 함수 이름, 비트필드이면 완벽 전달이 실패한다.

복사보다 이동이 상대적으로 저렴하다는 것은 이제 알고 있다.

이동에 대해 좀 더 근거있는 기대를 가지게 하기 위해 몇 가지 사항을 짚고 갈 필요가 있다.

 

C++11에 들어서며 C++98을 개선하여 이동이 복사보다 연산이 빠른 타입에 대해서는 이동 연산을 제공해주게 되었다.

그런데 사용자의 프로그램이 C++11에 맞춰서 완벽히 개정되지 않았다면 그러한 혜택을 받지 못할 수도 있다.

 

 

이동을 명시적으로 지원하는 타입에서도 의외로 성능상 이점이 크지 않을 수도 있다.

예를 들어 C++11 STL의 모든 컨테이너는 이동을 지원하지만 모든 컨테이너의 이동이 저렴하다고 가정해서는 안된다.

 

std::array를 제외한 다른 컨테이너들은 내용을 힙에 저장하고 그 힙 메모리를 가리키는 포인터만 담고 있기 때문에 컨테이너 전체 내용을 상수 시간으로 이동할 수 있을 뿐만 아니라 원본 컨테이너의 힙 메모리를 가리키는 포인터만 대상 컨테이너로 복사하고 원본 컨테이너의 포인터를 널로 설정하기만 하면 컨테이너 내용 전체가 옮겨진것과 같은 결과가 된다.

 

그에 비해 std::array는 내장 배열에 STL 인터페이스를 씌운 것에 불과하기 때문에 내용이 std::array 객체 자체에 직접 저장된다. std::array에 저장된 타입의 이동 연산이 복사 연산보다 빠르다면 std::array를 복사하는 것보다 빠른것은 사실이지만 std::array는 이동이나 복사나 둘다 계산 복잡도가 선형이다. 포인터를 복사하는 것만큼 저렴하지는 않은 것이다.

 

 

string은 상수 시간 이동과 선형 시간 복사를 제공한다. 당연히 상수 시간에 이루어지는 이동이 항상 빠를 것 같지만 아주 작은 문자열의 경우 작은 문자열 최적화(SSO)를 사용하는 경우가 많기 때문에 '항상' 성립하지는 않는다.

 

 

항목 14에서 다뤘듯이 이동 연산이 예외를 던지지 않는다는 것이 확실한 경우에만 복사 연산을 이동 연산으로 대체한다. 이동 연산이 noexcept로 선언되어있지 않으면 컴파일러는 계속 복사 연산을 호출할 수 있다.

 

C++11의 이동 의미론이 도움되지 않는 시나리오

◾ 이동 연산이 없다 : 복사 요청으로 대체된다.

◾ 이동이 더 빠르지 않다 : 복사 연산보다 빠르지 않다.

◾ 이동을 사용할 수 없다 : 이동 연산이 noexcept로 선언되어 있지 않다.

◾ 원본 객체가 왼값이다 : 아주 드문 경우(항목 25)이지만 오른값만 이동 연산의 원본이 될 수 있는 경우도 있다.

 

 

핵심은 항목의 이름처럼 이동 연산들이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라는 것이다.

템플릿을 작성할 때에는 코드에 쓰이는 모든 타입을 알 수 없기 때문에 대체적으로 그런 가정이 사실이다.

그런 경우에는 이동 의미론이 존재하기 전처럼 객체의 복사를 보수적으로 다루어야 한다.

 

◾ 이동 연산들이 존재하지 않고, 저렴하지 않고, 적용되지 않을 것이라고 가정하라.

◾ 타입들과 이동 의미론 지원 여부를 미리 알 수 있는 경우에는 그런 가정을 둘 필요가 없다.

template<typename T>
void func(T&& param);

템플릿 매개변수 T에는 param으로 전달된 인수가 왼값이었는지 오른값이었는지에 대한 정보가 부호화되어 저장되어있다.

부호화 매커니즘은 간단하다. 왼값 인수가 전달되면 T는 왼값 참조로 추론되고 오른값 인수가 전달되면 T는 비참조 타입으로 추론된다.

 

Widget widgetFactory(); // 오른값을 돌려주는 함수
Widget w;

func(w); // 왼값으로 호출. Widget&으로 추론
func(widgetFactory()); // 오른값으로 호출. Widget으로 추론

 

int x;
auto& & rx = x; // 참조에 대한 참조는 선언할 수 없다

C++에서 참조에 대한 참조는 위법이다.

 

template<typename T>
void func(T&& param);

func(w);

그런데 이런 경우를 한번 보자. 보편 참조를 받는 함수 템플릿에 왼값을 넘겨주면 아마 아래와 같은 형태일 것이다.

 

void func(Widget& && param);

// void func(Widget& param);
// 실제 만들어지는 함수 형태

참조에 대한 참조는 위법이라고 했으나, 실제로는 참조 축약에 의해 특정 문맥에서는 컴파일러가 참조에 대한 참조를 산출하는 것을 허용한다. 템플릿 인스턴스화는 그런 문맥 중 하나이다.

참조는 왼값과 오른값이 존재하므로 총 4가지이며 아래 규칙에 따라 하나의 참조로 축약된다.

 

"만일 두 참조 중 하나라도 왼값 참조이면 결과는 왼값 참조이다. 그렇지 않으면 결과는 오른값 참조이다."

 

위와 같은 참조 축약 덕분에 std::forward가 작동할 수 있다.

 

template<typename T>
void f(T&& fParam) {
    ...
    someFunc(std::forward<T>(fParam));
}

T&&는 보편 참조이고 전달된 인수가 오른값인 경우 둘 다 오른값이기 때문에 이 경우에만 std::forward가 fParam을 오른값으로 캐스팅하는 것이다.

 

template<typename T> // C++11
T&& forward(typename remove_reference<T>::type& param) {
    return static_cast<T&&>(param);
}

template<typename T> // C++14
T&& forward(remove_reference_t<T>& param) {
    return static_cast<T&&>(param);
}

몇 가지 인터페이스가 생략되었지만 forward의 구현은 대략적으로 이렇게 되어있다.

참조 축약은 반환 타입과 캐스팅에도 적용되며 참조 축약 규칙에 의해 인수의 타입이 왼값인 경우 항상 왼값을 반환하게 된다.

 

참조 축약이 일어나는 문맥은 네가지이고 첫번째 문맥은 가장 흔한 경우이며 이미 다룬 템플릿 인스턴스화이다.

두번째 문맥은 auto 변수에 대한 타입 추론이다. 템플릿 매개변수와 auto의 타입 추론은 본질적으로 같기 때문에 첫번째 경우와 동일하게 적용된다.

 

◾ 타입 추론에서 왼값과 오른값이 구분된다

◾ 참조 축약이 적용된다

 

보편 참조는 새로운 종류의 참조가 아니라 위의 두 가지 조건이 만족되는 문맥에서는 오른쪽 참조가 되는 것이다.

 

세번째 문맥은 typedef와 using의 지정 및 사용이다.

typedef가 지정 또는 평가되는 도중에 참조에 대한 참조가 발생하면 참조 축약이 끼어들어서 참조에 대한 참조를 제거한다.

 

template<typename T>
class Widget {
public:
    typedef T&& RvalueRefToT;
    ...
};

Widget<int&> w; // int& && RvalueRefToT;

참조에 대한 참조가 발생하여 참조 축약이 끼어들고 왼값 참조가 된다.

이런 경우는 오히려 이름때문에 혼란을 줄 수 있게된다.

 

마지막 문맥은 decltype 사용이다.

컴파일러가 decltype에 관여하는 타입을 분석하는 도중에 참조에 대한 참조가 발생하면 참조 축약이 끼어든다.

 

◾ 참조 축약은 템플릿 인스턴스화, auto 타입 추론, typedef와 using의 지정 및 사용, decltype의 지정 및 사용이라는 네 가지 문맥에서 일어난다.

◾ 컴파일러가 참조 축약 문맥에서 참조에 대한 참조를 만들어 내면, 그 결과는 하나의 참조가 된다. 원래의 두 참조 중 하나라도 왼값 참조이면 결과는 왼값 참조이고, 그렇지 않으면 오른값 참조이다.

◾ 타입 추론이 왼값과 오른값을 구분하는 문맥과 참조 축약이 일어나는 문맥에서 보편 참조는 오른값 참조이다.

+ Recent posts