Outdated/Core Language

[C++ Core] 함수 개체(function object)와 람다 표현식(lambda expression)

해달 2018. 9. 7. 12:30

[C++ Core] 함수 개체(function object)와 람다 표현식(lambda expression)

함수 개체

함수 개체(function object)란, operator()를 오버로드한 개체를 의미한다. 펑터(functor)라고도 한다. 다음은 가장 간단한 함수 개체이다.

struct Foo
{
    void operator()() { }
};

// 실제 사용법
Foo a;
a();

그럼 이와 같은 함수 개체는 왜 사용하는 것일까? 바로, 성능 때문에 사용하는 것이다. 특히, 함수 개체는 STL 알고리즘을 이용할 때 유용하다. STL 알고리즘의 어떤 버전은 마지막 인자로 함수를 받는다. 가령 std::for_each()나 std::count_if() 말이다. 여기에 함수 개체를 넘겨주면 그 바디가 인라인(inline) 되어 성능에 상당한 이점을 가져갈 수 있다.

하지만, 함수 개체의 가장 큰 단점은 기술하기 귀찮다는 것이다. 다음을 보자.

void PrintModulo(const std::vector<int>& v, std::ostream& os, int m)
{
    // 해당 함수 개체는 일회성이다.
    class ModuloPrintFunctor
    {
    public:
        ModuloPrintFunctor(std::ostream& o, int mm) : os{ o }, m{ mm } { }
        void opreator()(int x) const
        {
            if (x & m == 0)
            {
                os << x << std::endl;
            }
        }
    private:
        std::ostream& os;
        int m;
    };

    std::for_each(v.cbegin(), v.cend(), ModuloPrintFunctor(os, m));
}

해당 함수 개체가 여러 번 사용된다면 모르겠지만, 단 한번의 사용을 위해 기술하긴 여간 귀찮은게 아니다. 이 불편함을 없애기 위해 람다 표현식(lambda expression)이 등장하였다.

람다 표현식

람다 표현식은 익명 함수 개체를 정의하고 사용하기 위한 간단한 표기법이다. 아까의 함수 개체를 람다 표현식으로 기술해보겠다.

void PrintModulo(const std::vector<int>& v, std::ostream& os, int m)
{
    // 아까의 코드보다 짧아지고 명확히 보인다.
    std::for_each(v.cbegin(), v.cend(), [&os, m](int x)
    {
        if (x & m == 0)
        {
            os << x << std::endl;
        }
    });
}

이렇게 람다로 만들어진 개체를 클로저(closure)라고 한다. 람다는 다음처럼 구성된다.

[capture-list] (parameter-list) mutable noexcept -> return_type { some expression }

기본적으로 함수와 비슷하다는 것을 볼 수 있다. 왜냐하면 람다의 대부분의 규칙은 클래스 및 함수에서 빌려온 것이기 때문이다. 다른 점은 두 가지이다. 첫 번째, 매개변수 리스트는 매개변수가 존재하지 않는다면 생략할 수 있다. 즉, [] { some expression }이 가능하다. 두 번째, 반환 타입은 반환문에 의해 추론된다. 이 때, 반환문이 단일이 아니라면 반환 타입을 명시적으로 기술하여야 한다. 이 때에는 매개변수 리스트를 생략할 수 없다.

mutable 한정자는 캡처한 변수를 수정할 때 이용된다. 람다를 통해 만들어진 함수는 기본적으로 const이기 때문에, 수정할 수 없다. 하지만, mutable 한정자를 붙여주면 const가 아닌 함수가 되어, 수정할 수 있다.

noexcept 한정자는 해당 람다에서는 예외가 발생하지 않는다는 것을 컴파일러에게 알려준다.

캡처 리스트가 람다의 핵심이라고 할 수 있는데, 한번 자세히 살펴보자.

캡처 리스트

캡처 리스트는 어떤 지역 변수에 어떤 방법으로 접근할 수 있는지를 나타내는 리스트로서, [] 람다 소개자에 의해 기술된다. 아까의 [&os, m]이 여기에 해당하는 데, 이는 os를 참조를 통해 사용하겠다는 의미이고, m은 값을 통해 사용하겠다는 의미이다. 기본적으로, &가 붙으면 참조를, 그 외에는 값을 의미한다. 될 수 있는 형태는 다음과 같다.

  • [] : 아무 지역 변수도 사용하지 않는다.
  • [&] : 암시적으로 모든 지역 변수를 참조로 사용한다.
  • [=] : 암시적으로 모든 지역 변수를 값으로 사용한다.
  • [capture-list] : 명시적으로 사용할 변수를 나열한다. this와 ...을 사용할 수 있다.
  • [&, 캡처 리스트] : 명시된 변수를 값으로, 이외는 참조로 캡처한다. this를 사용할 수 있다.
  • [=, 캡처 리스트] : 명시된 변수를 참조로, 이외는 값으로 캡처한다. this를 사용할 수 없다.

this

캡처 리스트에 this를 쓰는 것은 클래스의 멤버를 캡처하겠다는 뜻이 된다. 예시를 살펴보자.

class Temp
{
public:
    Temp(int ii) : i{ ii } { }

    int GetNumber() const
    {
        auto getNumber = [this] { return i; };
        return getNumber();
    }
private:
    int i;
};

C++은 일급(first-class) 함수를 지원하는 언어이기 때문에 람다를 변수에 할당할 수 있다. 람다의 타입은 보통 최적화를 위해 정의되어 있지 않아 auto를 쓴다. 하지만, 필요하다면 C 스타일의 함수 포인터나 std::function으로 가리킬 수 있다. 이는, 재귀적인 람다 함수를 만들 때 유용하다.

auto lambda = [](int x, int y) { return x + y; };
int (*p)(int, int) = lambda;
std::function<int(int, int)> fp = lambda;

다시 돌아와서, 아까의 [this] 예제에서 주의해야 할 것은 멀티 쓰레드 프로그램에서는 경쟁이 일어날 수 있기 때문에, 그때에는 [this] 대신 [=]을 쓰는 것이 좋을 것이다.

...

가변 템플릿 인자를 캡처하기 위해 ...을 사용할 수 있다.

template <typename... Arg>
void Algorithm(int i, Arg... v)
{
    auto helper = [&i, &v...] { return i * (h1(v...) + h2(v...)); };
    // do something
}

유의할 점

변수를 캡처할 때, 람다와 캡처된 변수의 수명 주기가 다를 수도 있음에 주의해야 한다.

void Setup(Menu& m)
{
    // ...

    Point p1, p2, p3;
    m.add("Draw triangle", [&]{ m.draw(p1, p2, p3); }); // oops

    // ...
}

람다는 호출된 곳 외에서도 살아있을 수 있기 때문에, 저런 상황에서는 참조로 캡처하는 대신 값으로 캡처하는 것이 좋다.

또, 컨테이너의 전체를 순회할 때, 람다 + 알고리즘 조합대신 for 루프를 사용할 수도 있는데 둘의 성능은 비슷하기 때문에 선호하는 쪽으로 사용하면 된다.