{{ label!='' ? 'Label : ' : (q!='' ? '검색 : ' : '전체 게시글') }} {{ label }} {{ q }} {{ ('('+(pubs|date:'yyyy-MM')+')') }}

[C++] The C++ Programming Language: 11. Select Operations (1) - Lambda Expressions

 11.4 Lambda Expressions

  람다 표현식(lambda expression)은 람다 함수 혹은 그냥 람다라고 하기도 합니다. 익명 함수 객체를 이야기하는 것이죠. operator()를 지닌 이름 있는 클래스를 정의하고 객체를 생성한 뒤 비로소 그것을 실행하는 것 대신 간단하게 사용할 수 있습니다. 특정한 알고리즘을 인자로서 전달하고 싶을 때 유용하게 사용할 수 있습니다. 가령 GUI에서 콜백이라고 불리는 연산들과 같이요.

  람다 표현식은 다음으로 구성되어 있습니다.

  • 비어있을 수 있는 캡처 리스트(capture list). 람다 표현식의 바디에서 사용될 수 있는 정의 환경에서의 이름을 명세합니다. 참조로서 접근하거나 복사될 수 있습니다. capture list는 []로 구분됩니다.
  • 선택적인 매개변수 리스트(parameter list). 람다 표현식이 필요로 하는 인자들을 명세합니다. ()로 구분됩니다.
  • 선택적인 mutable 지시자. 람다 표현식의 바디가 변경될 수도 있음을 표현합니다.
  • 선택적인 noexcept 지시자.
  • 선택적인 반환 타입. 다음과 같은 형태를 지닙니다: -> type
  • 바디. 실행될 코드이며, {}로 구분됩니다.

  이들의 세부 사항은 나중에 이야기할 것이며, 지역 변수의 "캡처"라는 개념은 함수에 제공되는 것이 아닙니다. 이는 람다가 지역 함수로서 행위할 수 있다는 것을 의미합니다. 함수가 그렇지 않더라도요.


11.4.1 Implementation Model

  람다 표현식은 다양한 방법으로 구현될 수 있습니다. 그들을 최적화할 수 있는 효과적인 방법들도 있죠. 람다의 의미를 이해하기 위해서 함수 객체의 정의와 사용을 고려해볼 수 있죠. 다음 예제를 봅시다:

1
2
3
4
5
6
7
void print_modulo(const vector<int>& v, ostream& os, int m)
    // output v[i] to os if v[i]%m==0
{
    for_each(begin(v),end(v),
        [&os,m](int x) { if (x%m==0) os << x << '\n'; }
    );
}
cs

  다음은 이를 동일하게 구현하기 위해 함수 객체를 정의한 것입니다:

1
2
3
4
5
6
7
8
class Modulo_print {
    ostream& os; // members to hold the capture list
    int m;
public:
    Modulo_print(ostream& s, int mm) :os(s), m(mm) {} // capture
    void operator()(int x) const
        { if (x%m==0) os << x << '\n'; }
};
cs

  캡처 리스트인 [&os,m]은 두 멤버 변수가 되었고 생성자는 이들을 초기화합니다. os 앞의 &는 참조로 이를 저장한다는 것이며, m 앞에 &가 없는 것은 복사한다는 의미입니다. 이는 함수 인자의 선언을 그대로 가져다 쓴 것입니다.

  람다의 바디는 operator()()의 바디가 되었습니다. 람다는 값을 반환하지 않으므로, operator()()는 void입니다. 기본적으로 operator()()는 const이며, 따라서 람다의 바디는 캡처된 변수들을 변경하지 않습니다. 이것이 가장 흔한 경우입니다. 만약 여러분들이 람다 바디를 이용해서 상태를 변경하고 싶다면, 람다는 mutable으로 선언되어야 합니다. 이는 곧 const로 선언되지 않은 operator()()와 대응되겠죠.

  람다로부터 생성된 클래스의 객체를 클로저 객체(closure object)라고 말합니다. 만약 람다가 모든 지역 변수를 참조로서 캡처할 가능성(캡처 리스트 [&]를 이용)이 있다면, 클로저는 단순히 해당하는 스택 프레임의 포인터를 포함하도록 최적화될 것입니다.


11.4.2 Alternatives to Lambdas

  최종 print_modulo()는 꽤나 매력적입니다. 애매한 연산에 이름을 짓는 건 좋은 아이디어죠. 따로 정의된 별도의 클래스를 사용한다는 것은, 곧 이를 이해하기 위한 주석의 공간 역시 늘어난다는 것입니다.

  하지만 많은 람다들은 아주 작고, 단 한 번만 쓰입니다. 람다를 이용한 코드와 동일한 클래스 사용의 예시는 사실 다음처럼 쓰여야 맞습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void print_modulo(const vector<int>& v, ostream& os, int m)
    // output v[i] to os if v[i]%m==0
{
    class Modulo_print {
        ostream& os; // members to hold the capture list
        int m;
    public:
        Modulo_print (ostream& s, int mm) :os(s), m(mm) {} // capture
        void operator()(int x) const
            { if (x%m==0) os << x << '\n'; }
    };

    for_each(begin(v),end(v),Modulo_print{os,m});
}
cs

  이렇게 보면, 람다의 경우가 명확한 승자죠. 만약 여러분이 정말로 함수에게 이름을 짓고 싶어 죽겠다면, 다음처럼 할 수도 있습니다:

1
2
3
4
5
6
void print_modulo(const vector<int>& v, ostream& os, int m)
    // output v[i] to os if v[i]%m==0
{
    auto Modulo_print = [&os,m] (int x) { if (x%m==0) os << x << '\n'; };
    for_each(begin(v),end(v),Modulo_print);
}
cs

  람다에게 이름을 지어주는 것은 좋은 아이디어입니다. 연산의 설계를 좀 더 신중히 고려하도록 만들죠. 람다는 코드의 구성을 단순하게 만들며 재귀도 허용합니다.

  for 루프를 쓰는 것은 람다와 함께 for_each()를 사용하는 것으로 대체할 수 있습니다:

1
2
3
4
5
6
void print_modulo(const vector<int>& v, ostream& os, int m)
    // output v[i] to os if v[i]%m==0
{
    for (auto x : v)
        if (x%m==0) os << x << '\n';
}
cs

  많은 사람들이 이런 코드가 람다보다 더 명료하다는 것을 알게 될 것입니다. 하지만, for_each는 다소 특수한 알고리즘이며, vector<int>는 아주 특수한 컨테이너입니다. print_modulo()를 임의의 컨테이너들에 대해 생성하는 것을 생각해보죠:

1
2
3
4
5
6
7
template<class C>
void print_modulo(const C& v, ostream& os, int m)
    // output v[i] to os if v[i]%m==0
{
    for (auto x : v)
        if (x%m==0) os << x << '\n';
}
cs

  이 버전은 map에 대해서도 완벽하게 동작합니다. C++ range-for 구문은 처음부터 끝을 순회하는 경우에 특히 적합합니다. STL 컨테이너는 이런 것들을 쉽게 할 수 있도록 되어있죠. 예를 들어, for 구문을 이용해서 map 자료형을 순회하는 것은 깊이 우선 탐색에 해당합니다. 너비 우선 탐색은 어떻게 할 수 있을까요? for 루프 버전의 print_modulo()는 변경하기 어려우므로, 이를 위한 알고리즘을 재작성해봅시다:

1
2
3
4
5
6
7
8
template<class C>
void print_modulo(const C& v, ostream& os, int m)
    // output v[i] to os if v[i]%m==0
{
    breadth_first(begin(v),end(v),
        [&os,m](int x) { if (x%m==0) os << x << '\n'; }
    );
}
cs

  따라서, 람다는 일반화된 루프/순회에서 바디로서 활용될 수 있습니다. breath_first 대신 for_each를 사용하면 깊이 우선 탐색을 수행할 수 있죠.

  순회 알고리즘의 인자로 활용되는 람다의 성능은 그것을 그대로 수행하는 루프와 동일합니다. 저는 구현과 플랫폼에 걸쳐 상당히 일관성이 있다는 사실을 발견했습니다. 무슨 의미냐면, "알고리즘과 람다", "for 문과 바디" 중 하나를 선택해야 하는 문제에 직면한다는 것입니다.


11.4.3 Capture

  람다의 주된 용례는 특정 코드를 인자로 전달되도록 하는 것입니다. 람다는 함수에 이름을 짓지 않더라도 "인라인"으로 기능할 수 있게 합니다. 몇몇 람다는 그들의 로컬 환경에 접근할 필요가 없기도 합니다. 이러한 람다는 빈 유도자(introducer, 소개자) []를 활용하게 됩니다:

1
2
3
4
5
6
7
void algo(vector<int>& v)
{
    sort(v.begin(),v.end()); // sort values
    // ...
    sort(v.begin(),v.end(),[](int x, int y) { return abs(x)<abs(y); }); // sort absolute values
    // ...
}
cs

  만약 지역 이름에 접근하고 싶다면, 꼭 유도자에 적어야 합니다. 그렇지 않으면 오류가 나게 됩니다:

1
2
3
4
5
6
7
8
9
void f(vector<int>& v)
{
    bool sensitive = true;
    // ...
    sort(v.begin(),v.end(),
        [](int x, int y) { return sensitive ? x<: abs(x)<abs(y); } // error : can’t access sensitive
    );
}
cs

  위 코드들은 람다 유도자 []를 활용한 경우입니다. 호출자의 환경에 있는 이름에 접근할 수 없도록 하는 가장 단순한 유도자입니다. 람다의 첫번째 글자는 반드시 [입니다. 람다 유도자느 다음과 같이 다양한 형태가 존재합니다:

  • []: 빈 캡처 리스트. 람다 바디에서 해당 맥락의 로컬 이름을 활용하지 않을 것임을 암시합니다. 이러한 람다 표현식의 경우, 데이터는 인자로 혹은 지역 변수가 아닌 변수(nonlocal variable)로부터 얻어집니다.
  • [&]: 참조에 의한 암시적 캡처. 모든 지역 이름은 활용될 수 있습니다. 모든 로컬 변수는 참조에 의해 접근됩니다.
  • [=]: 값에 의한 암시적 캡처: 모든 지역 이름은 활용될 수 있습니다. 모든 이름은 지역 변수를 복사해서 참조할 수 있으며, 람다 표현식의 호출 시 복사됩니다.
  • [capture-list]: 명시적 캡처. 지역 변수의 이름들을 나열해 캡처합니다. 이름 앞에 &를 붙이면 참조로 캡처하며, 그렇지 않으면 값으로 캡처됩니다. 캡처 리스트는 this를 포함할 수 있으며, 이름 뒤에 ...가 요소로 포함될 수 있습니다.
  • [&, capture-list]: 암시적으로 캡처 리스트에 없는 모든 지역 변수를 참조로 캡처. 캡처 리스트는 this를 포함할 수 있습니다. 리스트의 이름들은 &를 앞에 쓸 수 없습니다. 캡처 리스트에 있는 변수들은 값으로 캡처됩니다.
  • [=, capture-list]: 암시적으로 캡처 리스트에 없는 모든 지역 변수를 값으로 캡처. 캡처 리스트는 this를 포함할 수 없습니다. 리스트의 이름들은 &를 앞에 반드시 써야 합니다. 캡처 리스트에 있는 변수들은 참조로 캡처됩니다.

  &가 붙은 지역 이름들은 언제든지 참조로 캡처되며, 그렇지 않은 지역 이름들은 값으로 캡처됩니다. 참조에 읳나 캡처만이 호출 환경의 변수 변경을 허용합니다.

  캡처 리스트는 호출 환경에서 어떤 이름들이 어떻게 사용되는지에 대한 세밀한 제어를 할 수 있게 해줍니다:

1
2
3
4
5
6
7
8
void f(vector<int>& v)
{
    bool sensitive = true;
    // ...
    sort(v.begin(),v.end()
        [sensitive](int x, int y) { return sensitive ? x<: abs(x)<abs(y); }
    );
}
cs

  sensitive를 캡처 리스트에 표기함으로써, 람다에서 그것을 접근할 수 있게 되었습니다. 다른 것들을 명세하지 않아도, sensitive는 값으로서 참조된다는 것을 명확히 할 수 있습니다. 

  다른 스레드에 람다를 전달하는 것이라면, 값으로서 캡처([=])하는 것이 일반적으로 좋습니다. 다른 스레드의 스택을 참조 혹은 포인터로서 접근하는 것은 문제가 될 수 있습니다. 오류를 찾기도 어렵죠.

  여러분이 가변 템플릿 인자를 캡처하고 싶다면, ...를 활용하세요:

1
2
3
4
5
6
template<typename... Var>
void algo(int s, Var... v)
{
    auto helper = [&s,&v...] { return s∗(h1(v...)+h2(v...)); }
    // ...
}
cs


11.4.3.1 Lambda and Lifetime

  람다는 호출자보다 더 오래 존재할 수 있습니다. 다른 스레드에 람다가 전달되거나, 피호출자가 람다를 나중에 사용하기 위해 어딘가에 저장해놓는 경우가 있을 수 있죠:

1
2
3
4
5
6
7
8
void setup(Menu& m)
{
    // ...
    Point p1, p2, p3;
    // compute positions of p1, p2, and p3
    m.add("draw triangle",[&]{ m.draw(p1,p2,p3); }); // probable disaster
    // ...
}
cs

  add()는 (이름, 행위) 쌍을 메뉴에 더하는 연산입니다. draw() 연산을 하는 것이 의미가 있는 작업이라면, 이는 시한 폭탄인 셈입니다. setup()이 완료되고 나중에, 사용자는 draw triangle 버튼을 누를 것입니다. 람다는 이미 한참 전에 생명을 다한 로컬 변수를 참조하게 되겠죠. 

  만약 람다가 호출자보다 오래 존재할 수 있다면, 클로저 객체에 지역 정보들을 전부 복사하고 그것들과 적절한 인자들로부터 반환 값이 결정되도록 해야 합니다. setup()은 다음처럼 쉽게 바꿀 수 있겠죠.

1
m.add("draw triangle",[=]{ m.draw(p1,p2,p3); })
cs

  

11.4.3.2 Namespace Names

  우리는 네임스페이스 변수들을 캡처할 필요가 없습니다. 언제든지 접근할 수 있기 때문이죠. 가령:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<typename U, typename V>
ostream& operator<<(ostream& os, const pair<U,V>& p)
{
    return os << '{' << p.first << ',' << p.second << '}';
}

void print_all(const map<string,int>& m, const string& label)
{
    cout << label << ":\n{\n";
    for_each(m.begin(),m.end(),
        [](const pair<string,int>& p) { cout << p << '\n'; }
    );
    cout << "}\n";
}
cs

  우리는 cout이나 pair를 캡처할 필요가 업습니다.


11.4.3.3 Lambda and this

  멤버 함수에서 사용된 람다에서 클래스 객체의 멤버에 어떻게 접근할 수 있을까요? 우리는 this를 캡처 리스트에 추가함으로써 해당 객체의 클래스 멤버에 접근할 수 있습니다. 멤버 함수의 구현에 람다가 사용될 때 유용하게 쓸 수 있습니다.

  멤버들은 언제나 참조로 캡처됩니다. 이에 주의하도록 하세요! 불행하게도, [this]와 [=]는 호환되지 않습니다. 멀티 스레드 프로그램에서의 경쟁 조건을 방지하기 위함입니다.


11.4.3.4 mutable Lambdas

  보통 우리는 함수 객체(클로저)의 상태가 변경되지 않기를 바랍니다. 일반적으로는 할 수 없죠. 생성된 함수 객체의 operator()()는 const 멤버 함수입니다. 때때로는 그 상태를 변경하고 싶을 때가 있습니다. 캡처된 변수들의 상태가 변하는 것과는 다른 개념으로 말이죠. 우리는 이 때 mutable을 선언할 수 있습니다:

1
2
3
4
5
6
7
void algo(vector<int>& v)
{
    int count = v.siz e();
    std::generate(v.begin(),v.end(),
        [count]()mutable{ return −−count; }
    );
}
cs

  --count는 클로저에 저장된 v의 크기를 감소시킵니다.


11.4.4 호출과 반환

  람다에 인자를 전달하는 규칙은 함수와 동일합니다. 결과를 반환하는 것도 마찬가지죠. 사실은, 캡처에 대한 규칙을 제외하고 대부분의 규칙은 함수와 클래스로부터 차용된 것입니다. 하지만 다음 두 개는 반드시 명심하세요:

  1. 람다 표현식이 어떤 인자도 받지 않는다면, 인자 리스트는 생략될 수 있습니다. 가장 간단한 람다 표현식은 []{}가 될 것입니다.
  2. 람다 표현식의 반환형은 바디로부터 추론될 수 있습니다. 함수의 경우는 아니죠.

  람다 바디에 return 문이 없다면, 람다의 반환형은 void입니다. 단 하나의 return 문만 존재한다면, 람다의 반환형은 return 문의 반환형입니다. 둘 다 아닌 경우에는 명시적으로 반환형을 제공해줘야 합니다:

1
2
3
4
5
6
7
8
9
void g(double y)
{
    [&]{ f(y); } // return type is void
    auto z1 = [=](int x){ return x+y; } // return type is double
    auto z2 = [=,y]{ if (y) return 1; else return 2; } // error : body too complicated
    // for return type deduction
    auto z3 =[y]() { return 1 : 2; } // return type is int
    auto z4 = [=,y]()−>int { if (y) return 1; else return 2; } // OK: explicit return type
}
cs

  반환 형을 표기한 경우, 인자 리스트는 생략할 수 없습니다.


11.4.5 The Type of a Lambda

  람다 표현식의 최적화된 버전을 위해, 람다 표현식의 자료형은 정의되지 않습니다. 하지만, 11.4.1절에서 표현된 함수 객체의 형태로 정의되어 있습니다. 우리는 이러한 자료형을 클로저 타입(closure type)이라고 합니다. 람다들에게 고유하며, 두 람다는 같은 타입이 아닙니다. 같은 타입을 지닌 두 람다가 있다면, 템플릿 초기화 메커니즘은 혼란에 빠질 것입니다. 람다는 생성자가 있는 지역 클래스 타입이며 const 멤버 함수 operator()()가 있습니다. 인자로서의 람다 활용에서, 우리는 변수를 auto 혹은 std::function<R(AL)>으로 초기화할 수 있습니다. R은 람다의 반환 형이며, AL은 타입들의 인자 리스트입니다.

  예를 들어, 저는 C-스타일 문자열을 뒤집는 람다를 작성해볼 수 있습니다:

1
2
auto rev = [&rev](char∗ b, char∗ e)
{ if (1<e−b) { swap(∗b,∗−−e); rev(++b,e); } }; // error
cs

  하지만, 이는 불가능합니다. 람다의 타입이 추론되기 이전에 auto 변수(rev)를 함수 내에서 사용했기 때문입니다. 이를 다음처럼 이름을 이용해 사용할 수 있습니다:

1
2
3
4
5
6
7
void f(string& s1, string& s2)
{
    function<void(char∗ b, char∗ e)> rev =
        [&](char∗ b, char∗ e) { if (1<e−b) { swap(∗b,∗−−e); rev(++b,e); } }
    rev(&s1[0],&s1[0]+s1.size());
    rev(&s2[0],&s2[0]+s2.sie());
}
cs

  이제 rev의 타입은 사용되기 이전에 결정되었습니다.

  만약 재귀가 아닌 단순히 람다의 이름을 사용하고 싶은 경우라면, auto가 이를 단순화할 수 있습니다.

1
2
3
4
5
6
void g(vector<string>& vs1, vector<string>& vs2)
{
    auto rev = [&](char∗ b, char∗ e) { while (1<e−b) swap(∗b++,∗−−e); };
    rev(&s1[0],&s1[0]+s1.size());
    rev(&s2[0],&s2[0]+s2.size());
}
cs

  아무것도 캡처하지 않는 람다는 적절한 타입의 함수 포인터로 할당될 수 있습니다:

1
2
3
double (∗p1)(double) = [](double a) { return sqrt(a); };
double (∗p2)(double) = [&](double a) { return sqrt(a); }; // error : the lambda captures
double (∗p3)(int) = [](int a) { return sqrt(a); }; // error : argument types do not match
cs


댓글

이 블로그의 인기 게시물

[코딩의탑] 4층: 툰 쉐이딩

[코딩의탑] 3층: 바다 렌더링

[코딩의탑] 5층: 포탈(Portal), 더 나아가기