ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] Lambda : Default capture의 위험성
    C++/Effective Modern C++ 2022. 8. 30. 12:22

    람다에 관한 기본적인 내용은 다른 글(람다 표현식)에 기술되어 있습니다.

    람다의 기본 캡쳐 모드(Default capture mode)는 두 가지로, 참조 캡쳐와 값 캡쳐가 그것입니다.

    이 기본 캡쳐 모드는 다음과 같은 이유로 안전하지 않습니다.

    • 기본 참조 캡쳐는 참조가 대상을 잃을 위험성이 있습니다.
    • 기본 값 캡쳐 또한 대상을 잃을 수 있고, Self-contained하지 않습니다.

    이번 글에서는 위의 내용에 조금 살을 붙여, 기본 캡쳐 모드의 위험성에 대해 살펴보도록 하겠습니다.

     


     

    1. 기본 참조 캡쳐가 대상을 잃는 경우

     

    더보기

    참조 캡쳐를 사용하는 클로저는 지역 변수 혹은 람다가 정의된 범위에서 볼 수 있는 매개변수에 대한 참조를 가지게 됩니다. 그런데 이 때, 클로저의 수명이 대상 지역 변수, 매개변수의 수명보다 길어질 경우 클로저의 참조는 대상을 잃게 됩니다.

    다음 예제를 살펴보도록 하겠습니다.

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

    FilterContainer는 함수들을 담는 vector 컨테이너 입니다.

    이 컨테이너에는 int를 받고, 해당 int가 조건을 만족하는지의 여부를 나타내는 함수들이 들어갑니다.

    filters.emplace_back(
        [](int value) { return value % 5 == 0; }
    );

    컨테이너에 필터 함수를 추가하는 코드입니다.

    이 함수는 전달되는 int가 5의 배수인지 여부를 반환하는 함수입니다.

    그런데 5의 배수에서, 5를 하드코딩 할 수 없는 상황 (5가 아니라 유동적인 값이 필요할 경우) 이 있을 수 있습니다.

    이 경우 실행시점에서 값을 계산해야 하고, 그 경우 코드는 아래와 같이 될 수 있습니다.

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

    filters객체는 addDivisorFilter가 반환된 이후에도 사용될 수 있습니다.

    하지만 addDivisorFilter가 반환된 이후에는 람다에서 참조한 변수들이 존재하지 않게 됩니다.

    따라서 이후 filters에서 사용되는 함수는 미정의 행동을 유발합니다.

     

    람다가 대상을 잃지 않는 것이 보장되는 상황 (클로저가 즉시 사용되고, 복사되지 않음이 보장된 상황)이라도, 기본 참조 캡쳐는 사용하지 않는 것이 좋습니다.

    template<typename C>
    void workWithContainer(const C& container) {
        auto calc1 = computeSomeValue();
        auto calc2 = computeSomeValue();
        
        auto divisor = computeDivisor(calc1, calc2);
        
        using ContElemT = typename C::value_type;
        using std::begin;
        using std::end;
        
        if(std::all_of(begin(container), end(container),
            [&](const ContElemT& value) { return value % divisor == 0; })
            ){
            ...
        } else {
            ...
        }
    }

    위 코드는 람다를 함수 내부의 std::all_of에서만 사용하는 코드입니다.

    참조 캡쳐를 사용했지만 람다의 사용이 함수 내부에서 이루어져, 대상을 잃을 위험이 없는 안전한 코드입니다.

    하지만 위 코드의 일부를 재사용 하기 위해 람다를 복사해서 이용하게 될 경우, 그 안정성이 깨질 위험이 있습니다.

     

    위 상황은 참조 캡쳐가 아닌 값 캡쳐를 통해 해결할 수 있습니다.

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

    이번 경우에는 위와 같이 기본 값 캡쳐로 해결 가능하지만, 서론에 언급하였듯 기본 값 캡쳐도 문제가 발생할 수 있습니다.

    이에 관한 내용은 다음 문단에서 이어집니다.

     

    2. 기본 값 캡쳐가 대상을 잃는 경우

     

    더보기

    값 캡쳐는 캡쳐하는 대상을 복사하는 것인데, 이것이 대상을 잃을 수 있다는 것은 넌센스라고 볼 수 있습니다.

    하지만 그와 같은 경우는 실제로 일어납니다.

    포인터를 복사하는 경우가 바로 그 경우입니다. 포인터를 복사하는 경우, delete로 해제하지 않는다면 람다에 복사된 포인터가 대상을 잃을 수 있습니다.

    개발자가 모든 포인터들에 대하여 스마트 포인터를 사용한다고 해도, 보이지 않는 곳에서 생 포인터가 사용될 수 있습니다.

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

    이 함수는 위 문단의 filters컨테이너에 요소를 추가하는 클래스와 멤버 함수입니다.

    위 람다식의 기본 값 캡쳐는 얼핏보면 Widget클래스의 divisor를 복사하는 것으로 보이겠지만, 실상은 그렇지 않습니다. divisor는 람다의 스코프 밖에 있는, 클래스의 멤버이기 때문입니다.

    하지만 그렇다고 '='를 지우거나, '='를 'divisor'로 바꾸면 컴파일이 되지 않습니다.

    이 문맥에서 복사되는 것은 divisor가 아닌 this포인터 입니다. 캡쳐는 this포인터를 복사하고, 문맥에 사용된 divisor는 암묵적으로 this->divisor이 됩니다.

    그렇기 때문에 위 람다는 해당 객체의 수명에 의해 제한됩니다.

     

    이 문제는 함수의 지역 변수로 클래스의 멤버를 복사하는 것으로 해결할 수 있습니다.

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

    이 방식을 사용한다면 기본 값 캡쳐도 사용할 수 있지만, 의도하지 않은 값이 복사되었던 것, 가독성 등을 고려했을 경우 명시적으로 캡쳐하는 값을 지정하는 것이 좋겠습니다.

    만약 C++14이후의 버전일 경우, 더 명확하게 표현하는 방법이 있습니다.

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

    이것을 일반회된 람다 캡쳐 (Generalized lambda capture)라고 합니다.

     

     

    3. 기본 값 캡쳐의 수명에 대한 오해

     

    더보기

    기본 값 캡쳐를 사용한 클로저는 값을 복사하기 때문에 클로저 바깥의 변화로부터 격리되어 있다고 생각할 수 있습니다.

    이러한 생각은 오해입니다.

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

    예를 들면 위 코드에서, 기본 값 캡쳐는 아무것도 캡쳐하지 않습니다.

    람다 내부의 divisor는 값 캡쳐에 의해 복사되지 않은, addDivisorFilter스코프에 있는 static auto divisor입니다.

    따라서 함수의 마지막에 있는 ++divisor구문에 의해, 위 람다는 addDivisorFilter함수가 호출될 때 마다 다른 행동을 보이게 됩니다.

     


     

    기본 캡쳐가 보여줄 수 있는 문제들에 대하여 살펴보았습니다.

    직관적으로 생각한 것과는 다른 결과가 나타나는 경우와, 미처 생각하지 못할 수 있는 상황에 발생하는 문제였습니다.

    결론적으로는, 기본 캡쳐를 사용하는 것 보다는 명시적으로 지정하는 것이 좋겠습니다.

     

    감사합니다.

    댓글

Designed by Tistory.