C++11의 람다 함수

프로그래밍을 하다 보면, 어떤 컨테이너 자료구조의 내부에 있는 모든 원소들을 순회하면서 각 원소에 대해 뭔가 동일한 처리를 하고 싶은 때가 빈번히 발생한다.
그 절차를 추상화하기 위해 C++ 라이브러리에는 algorithm이라는 헤더에 for_each라는 템플릿 함수가 있다. 다음은 이 함수의 로직을 나타낸 C++ 코드이다. 딱히 로직이라 할 것도 없이 아주 직관적이고 간단하다.

template<typename T, typename F>
void For_Each_Counterfeit(T a, T b, F& c)
{
    for(T i=a; i!=b; ++i) c(*i);
}

C++은 템플릿과 연산자 오버로딩을 통해 자료구조에 대한 상당 수준의 추상화를 달성했다.
iterator에 해당하는 a와 b는 그렇다 치더라도, 여기서 핵심은 c이다.
F가 무엇인지는 모르겠지만, 어쨌든 c는 함수 호출 연산자 ()를 적용할 수가 있는 대상이어야 한다.
그럼 무엇이 가능할까? 여러 후보들이 있다.

일단 C언어라면 함수 포인터가 떠오를 것이다. 함수 포인터는 코드를 추상화하는 데 지금까지 고전적으로 쓰여 온 기법이다.

void foo(char p);

char t[]="Hello, world!";
For_Each_Counterfeit(t, t+strlen(t), foo);

C++에서는 클래스가 존재하는 덕분에 더 다양한 카드가 생겼다. 클래스가 자체적으로 함수 호출을 흉내 내는 연산자를 갖출 수 있기 때문이다.

class MyObject {
public:
    void operator()(char x);
};

For_Each_Counterfeit(t, t+strlen(t), MyObject());

그리고 더욱 기괴한 경우이지만, 클래스 자신이 함수의 포인터로 형변환이 가능해도 된다.

class MyObject {
public:
    typedef void (*FUNC)(char);
    operator FUNC();
};

For_Each_Counterfeit(t, t+strlen(t), MyObject());

C++ 라이브러리에는 functor 등 다양한 개념들이 존재하지만, 그 밑바닥은 결국은 C++ 언어의 이런 특성들을 사용해서 구현되어 있다.
여기서 재미있는 점이 있다. 다른 자료형과는 달리 함수 포인터로 형변환하는 연산자 오버로드 함수는, 자신이 가리키는 함수의 prototype을 typedef로 미리 만들어 놓고 반드시 typedef된 명칭으로 선언되어야 한다는 제약이 있다. 이것은 C++ 표준에도 공식적으로 명시되어 있는 제약이라 한다.

이런 어정쩡한 제약이 존재하는 이유는 아마도 함수 선언문에다가 다른 함수를 선언하는 문법까지 덧붙이려다 보니, 토큰의 나열이 너무 지저분해지고 컴파일러를 만들기도 힘들어서인 것 같다. 이 부분에서는 아마 C++ 위원회에서도 꽤 고민을 하지 않았을까.
안 그랬으면 형변환 연산자 함수의 prototype은 아래와 비슷한 괴상한 모양이 됐을 것이다. 실제로 이 함수의 full name을 undecorate한 결과는 이것처럼 나온다.

    operator void (*)(char)();

비주얼 C++에서는 함수를 저렇게 선언하면 그냥 * 부분에서 '문법 에러'라는 불친절한 말만 반복할 뿐이지만, xcode에 기본 내장되어 있는 최신형 llvm 컴파일러는 놀랍게도 나의 의도를 간파하더이다. “함수 포인터로 형변환하려면 반드시 typedef를 써야 합니다”라는 권고를 딱 하는 걸 보고 적지 않게 놀랐다. 이런 차이도 맥북을 안 쓰고 오로지 비주얼 C++ 안에서만 틀어박혀 지냈다면 경험하기 쉽지 않았을 것이다. 우왕~

() 연산자 오버로딩은 this 포인터가 존재하는 C++ 클래스 멤버 함수이며 static 형태가 있을 수 없다.
그러나 함수 포인터로의 형변환 연산자 오버로딩은 this가 없으며 C 스타일의 static 함수와 같은 위상이라는 차이가 존재한다.
두 오버로딩이 모두 존재하면 어떻게 될까? 혹시 모호성 오류라도 나는 걸까?

그런 개체에 함수 호출 ()가 적용되는 경우, () 연산자가 먼저 선택되며, 그게 없을 때에 한해서 함수 포인터 형변환이 차선책으로 선택된다. 모호성 오류가 나지는 않는다.
포인터 형변환 연산자와 [] 연산자가 같이 있을 때 개체에 배열 첨자 참조 []가 적용되는 경우, 역시 [] 가 먼저 선택되고 그게 없을 때 포인터 형변환이 차선으로 선택되는 것과 비슷한 맥락이라 볼 수 있다.

그래서 클래스와 연산자 오버로딩 덕분에 저런 문법이 가능해졌는데, C++11에서는 그걸로도 모자라 또 새로운 문법이 추가되었다. 이른바 람다 함수.

For_Each_Counterfeit(t, t+strlen(t), [](char x) { /* TODO: add your code here */ } );

람다 함수는 코드가 들어가야 할 곳에 함수나 클래스의 작명 따위를 신경쓰지 않고 코드 자체만을 직관적으로 곧장 집어넣기 위해 고안되었다. 세상에 C++에서 OCaml 같은 데서나 볼 수 있을 법한 개념이 들어가는 날이 오다니, 신기하지 않은가?

덕분에 C++은 C언어 같은 저수준 하드웨어 지향성에다가 성능과 이념을 적당히 절충한 수준의 객체지향을 가미했고, 90년대 중반에는 템플릿 메타프로그래밍 개념을 집어넣더니, 이제는 함수형 언어의 개념까지 맛보기로 도입한 가히 멀티 패러다임 짬뽕 언어가 되었다.

함수를 값처럼 표현하기 위해서 lambda 같은 예약어가 별도로 추가된 게 아니다. C/C++은 태생상 예약어를 함부로 추가하는 걸 별로 안 좋아하는 언어이다. (그 대신 문법에 혼동이 생기지 않는 한도 내에서 기호 짬뽕을 좋아하며 그래서 사람이나 컴파일러나 코드를 파싱하는 난이도도 덩달아 상승-_-) 보아하니 타입을 선언하는 부분에서는 배열 첨자가 먼저 오는 일이 결코 없기 때문에 []를 람다 함수 선언부로 사용했다.

람다 함수는 다른 변수에 대입되어서 두고두고 재활용이 가능하다. 그래서 C/C++에서는 전통적으로 가능하지 않은 걸로 여겨지는 함수 내부에서의 함수 중첩 선언을 이걸로 대체할 수 있다.

어떤 함수 안에서 특정 코드가 반복적으로 쓰이긴 하지만 별도의 함수로 떼어내기는 싫을 때가 있다. 굳이 함수 호출 오버헤드 때문이 아니더라도, 해당 코드가 그 함수 내부의 지역변수를 많이 쓰기 때문에 그걸 일일이 함수의 매개변수로 떼어내기가 귀찮아서 그런 것일 수도 있다.
이때 흔히 사용되는 방법은 그냥 #define 매크로 함수밖에 없었는데 이때도 람다 함수가 더 깔끔하고 좋은 해결책이 될 수 있다. 람다 함수는 선언할 때 캡처라 하여 주변의 다른 변수들을 참조하는 메커니즘도 언어 차원에서 제공하기 때문이다.

그렇다면 의문이 생긴다.
람다 함수는 그럼 완전히 새로운 type인가?
기존 C/C++에 존재하는 함수 포인터와는 어떤 관계일까?
정답부터 말하자면 이렇다. 람다 함수는 비록 어쩌다 보니 () 연산을 받아 주고 함수 포인터가 하는 일과 비슷한 일을 하게 됐지만, 활용 형태는 함수 포인터하고 아무 관련이 없으며 그보다 더 상위 계층의 개념이다.

템플릿과 연동해서 쓰인다는 점에서 알 수 있듯, 람다 함수는 함수 포인터와는 달리 calling convension (_stdcall, _cdecl, _pascal 나부랭이 기억하시는가?)이고 리턴값이 나발이고간에 아무 상관이 없다. 그저 코드상에서 함수를 값처럼 다루는 걸 돕기 위해 존재하는 추상적인 개념일 뿐이다. 뭔가 새로운 type이 아니기 때문에 람다 함수를 변수에다 지정할 때는 auto만을 쓸 수 있다. 즉, 다른 자료형이 아닌 람다에 대해서는 auto가 선택이 아니라 필수라는 뜻이다.

auto square=[](int x) { return x*x; };
int n = square(9); //81

square는 템플릿 같은 함수가 아니다. 이 함수의 리턴값은 x*x로부터 자동으로 int라고 유추되었을 뿐이다. [](int x) -> int 라고 명시적으로 리턴 타입을 지정해 줄 수도 있다. 구조체 포인터의 멤버 참조 연산자이던 -> 가 여기서 또 화려하게 변신을 한 셈임. 우와!

또한, sizeof(square)을 한다고 해서 포인터의 크기가 나오는 게 아니다. 사실, 람다 함수에다가 sizeof를 하는 건 void에다가 sizeof를 하는 것만큼이나 에러가 나와야 정상이 아닌가 싶다. 그런 개념하고는 아무 관계가 없기 때문이다.

람다는 함수 포인터가 아니기 때문에, square에다가 자신과 프로토타입이 같은 다른 람다 함수를 대입할 수 있는 건 아니다. 함수 포인터의 역할과 개념을 대체할 뿐, 그 직접적인 디테일한 기능을 대체하지는 못한다. 그렇기 때문에 콜백 함수를 받는 문맥에서

qsort(n, arrsize, sizeof(int), [](const void *a, const void *b) { return *((int*)a) - *((int*)b); } );

구닥다리 C 함수에다가 최신 C++11 문법이라니, 내가 생각해도 정말 변태 같은 극단적인 예이다만,
이런 식으로 람다 함수를 집어넣을 수도 없다.

요컨대 람다 함수는 코드의 추상화에 도움을 주고 종전의 함수 포인터 내지 #define, 콜백 함수 등의 역할을 대체할 수 있는 획기적인 개념이다. C++ 철학대로 디자인된 여타 C++ 라이브러리와 함께 사용하면 굉장한 활용 가능성이 있다. 그러나 이것은 함수 포인터에 대한 syntatic sugar는 절대 아니라는 걸 유의하면 되겠다.

Posted by 사무엘

2012/05/24 08:38 2012/05/24 08:38
, , ,
Response
No Trackback , 15 Comments
RSS :
http://moogi.new21.org/tc/rss/response/686

Trackback URL : http://moogi.new21.org/tc/trackback/686

Comments List

  1. 주의사신 2012/05/24 09:22 # M/D Reply Permalink

    Closure와 변수 캡처에 대한 내용은 들어가지 않았군요...

    Closure를 사용하면, 지역 변수를 함수 내의 전역 변수처럼 사용할 수 있다는....

    Closure 처음 보면 진짜 이상합니다. 그런데 알고 나면 무척 편합니다.

    1. 사무엘 2012/05/24 10:21 # M/D Permalink

      네, 본문에서는 그런 게 있다는 것만 언급하고 그것까지 구체적인 예를 들지는 (귀찮아서-_-) 않았습니다.
      그렇게 자신 바깥의 context까지 끌어들이는 개념이 있기 때문에 람다 함수는 더욱 함수 포인터와 동등한 개념이 될 수 없고 그걸 초월하여 더 상위에 있는 개념이 되겠죠.
      어떤 함수 안에서 또 반복되는 코드 블록을 간추릴 필요가 있을 때 #define의 역할을 잘 대체할 수 있습니다.

  2. 김 기윤 2012/05/24 10:10 # M/D Reply Permalink

    람다는 기존에는 없었던 생소한 개념이지만, 익숙해지면 정말 편하죠.

    어떠한 함수 안에서 이 함수 안에서만 쓰이는 것이라면 람다로 즉석 선언 ㄳ

    PPL 라이브러리나 STL과의 결합도도 ㄳ

    아, 그리고 람다에 대해서는 auto 가 필수가 아닙니다.

    #include <functional>
    으로 functional 을 인클루드 한 뒤에,
    std::function<리턴형(인수1, 인수2, 인수3)>
    형식으로 선언하면, 같은 형식의 람다함수를 저장할 수 있습니다.(당연히 using namespace std; 했다면, std:: 는 생략가능)

    예를 들어,
    std::function<void(int, int, Player&, Player&, int, int)> command;
    이런 식으로 정의를 했다면,
    command = [](int self, int target, Player& player, Player& foes, int currentAbilityIndex, int turn) { /* ... */ }
    는 식으로 대입이 가능합니다.
    저는 이런 식으로 클래스 멤버변수로 람다함수를 가지는 코드도 만든 전적이 있습니다. (전략 패턴으로 쓰기 위해서 ㄳ. 특징상 전략 패턴에 쓰기에도 딱 맞더군요)

    따라서 이런 응용도 가능합니다.

    typedef function<pair<int, int>(pair<int, int>)> func;
    func modifier[3] =
    {
    [] (pair<int, int> p) -> pair<int, int> {
    // 가로, 세로 유지
    return p;
    },
    [] (pair<int, int> p) -> pair<int, int> {
    // 가로, 세로 반전
    return make_pair<int, int>(p.second, p.first);
    },
    [] (pair<int, int> p) -> pair<int, int> {
    // "가로, 세로" 를 "섹션별, 섹션내" 로
    return make_pair<int, int>(p.first / 3 * 3 + p.second / 3, p.first % 3 * 3 + p.second % 3);
    }
    };
    for(int f = 0; f < 3; f++)
    {
    // ..
    pair<int, int> point = modifier(i, j);
    // ..
    }


    잘 생각해보니 다른 응용으로는 람다 함수를 뱉어내는 팩토리도 만들 수 있을 듯 합니다 ㅎㄷㄷ

    1. 사무엘 2012/05/24 10:21 # M/D Permalink

      보충 설명에 감사합니다.
      람다가 auto 말고 다른 곳에 대입 가능하다는 건 완전 처음 듣네요...!
      리턴형(인수1, 인수2, 인수3) ... 이건 템플릿 인자로만 지정 가능한 타입이고 전적으로 람다를 위해서 C++11에서 새로 추가된 문법인 거겠죠?
      C++ 라이브러리도 다 C++ 문법 위에서 만들어진 코드일 테니 저는 언어적인 근간을 더 먼저 따져 보는 걸 좋아합니다.

    2. 김 기윤 2012/05/24 10:37 # M/D Permalink

      MSDN에 찾아보니(..어쩌다가 사용법만 알고 MSDN은 지금 처음 가봤습니다 ㄳ) 딱히 람다만들 위한 건 아닌 것 같습니다.
      http://msdn.microsoft.com/ko-kr/library/bb982519

      리턴형(인수1, 인수2, 인수3) 은 저도 여기에서 처음 본 타입이기에 저도 생소한데, 더 알아봐야겠습니다.

    3. Lyn 2012/05/24 13:59 # M/D Permalink

      http://www.devpia.com/Maeul/Contents/Detail.aspx?BoardID=51&MAEULNO=20&no=8580&page=1

      사무엘님 제가 얼마전 올린 글입니다. 참고삼아 보셔도 괜찬을것 같습니다.

      C++11에 새로 추가된 문법은 아니고 boost에서 개발하여 c++11에 정식으로 추가된 범용 함수객체와 바인더, 플레이스홀더 라이브러리 입니다.
      문법이 아니라 템플릿의 타입추론을 이용하여 라이브러리 레벨에서 구현된겁니다.
      C++11을 지원하는 컴파일러엔 std::function 으로 사용 가능하고 그 이전의 컴파일러라면 boost 설치 후 boost::function 으로 사용 가능합니다

  3. Lyn 2012/05/24 14:00 # M/D Reply Permalink

    그런데 왜 내가 쓴 주소는 링크가 안걸리는거야 Orz

  4. 사무엘 2012/05/25 14:47 # M/D Reply Permalink

    흐.. 덕분에 난생 처음 보는 C++ 문법을 새로 알게 됐군요. C++의 세계는 너무 오묘합니다.

    template<typename T>
    class MyObject {
    public:
    T m_dat;
    };

    이런 템플릿 클래스에다가 타입을 아래와 같이 명시해 주면 m_dat는 멤버 변수가 아니고 함수 포인터도 아닌 정식 멤버 함수가 됩니다. 이 문법 자체는 C++11 이전부터 존재해 온 듯합니다.

    MyObject<int(int)> mp;
    int n=mp.m_dat(1);

    MyObject<void(double, double)> mq;
    mq.m_dat(1.0, 2.0);

    “타입( [타입 [, 타입]] )”과 같은 타입 지정자는 여타 타입 지정과는 달리, 템플릿 인자로만 줄 수 있는 문법이겠지요.
    그렇게 해 준 뒤에는 사용되는 타입별로 m_dat 함수를 따로 구현해 줘야 합니다.

    template<>
    int MyObject<int(int)>::m_dat(int x)
    { return x*x; }

    template<>
    void MyObject<void(double, double)>::m_dat(double x, double y)
    { return x*y; }

    결국 템플릿 인자와 함수 자체의 인자에 동일한 함수 프로토타입이 두 번 명시되는 꼴이군요.
    이걸 안 하고서 m_dat 호출만 하면 코드가 컴파일은 되지만 결국 링크 에러가 납니다.

    템플릿 함수라도 타입이 명확하게 지정되어 있는 특화된 몸체는 다른 번역 단위(소스 코드)에 있어도 괜찮습니다.
    그에 반해 typename이라는 것만 명시되어 있는 추상적인 몸체는, 사용되는 모든 번역 단위에서 일일이 인클루드되어야 사용 가능합니다.

    그것도 외부 번역 단위에서 알아서 끌어다 쓸 수 있게 하려고 제안되었던 기능이 export 키워드인데 완전히 흑역사로 전락했고, C++11에서는 보류 정도가 아니라 삭제되어 버린 것 아시죠?
    단순히 변수 타입 정도가 아니라 멤버 함수와 람다까지 왔다갔다 할 수 있는 템플릿은 그 유연함이 가히 #define에 맞먹는 수준인데, 그 모든 가능성을 그것도 기계어 코드 컴파일 언어에서 export 가능하려면 컴파일러와 링커를 다 새로 만들지 않고서는 불가능할 겁니다.

    아무리 생각해도 T가 멤버 함수까지 가리킬 수 있는 건 좀 흉악(?)한 것 같습니다.
    typename이라는 키워드가 있는 것처럼 funcname 같은 다른 힌트 지정자라도 있는 게 낫지 않을까 싶기도 하군요.
    또한, C++ 멤버 함수와 람다 함수는 직접적으로 관계가 있는 게 아닌데 std::function 클래스는 아무리 템플릿이라 해도 어떻게 람다와 곧장 직결이 가능한지 모르겠습니다.

    함수 호출 규약이나 기계어 메모리 구조와 관련된 트릭을 쓰기라도 한 건지, 라이브러리 소스를 봐서는 모르겠고요. (온갖 외계어 같은 #define과 typedef 꼼수들.. -_-)
    그 기능만을 위한 문법이 따로 존재하는 게 아니라면 어떤 형태로든 꼼수가 안 들어갈 수는 없을 텐데요.

    그리고 xcode에서는 MyObject<int(int)>와 같은 문법이 컴파일이 안 되더군요. #include <functional>을 해도 std::function라는 명칭 자체도 없고요. C++11 표준이 일부 반영되어 있음에도 불구하고 그쪽은 지원되지 않는 듯합니다.

    그나저나,
    1. 본이 아니게 댓글이 무진장 길어졌는데, 이거 조만간 본문으로 옮길 예정.
    2. 링크가 걸리는 URL도 있고, 안 걸리는 URL도 경험상 있는데, 정확한 차이는 저도 잘 모르겠습니다.

    1. 김 기윤 2012/05/26 00:56 # M/D Permalink

      후후(?).. 덕분에 저도 즐겁게 배우고 갑니다.

      댓글에서 즐겁게 놀아본 건 오랜만인 듯 하기도 하구요.

    2. Lyn 2012/05/29 10:04 # M/D Permalink

      C++의 람다도 std::function<형식> f = 람다식; 처럼 가능합니다.

      C++에서의 클로저는... 그냥 functor랑 내부구현은 비슷하다고 보셔도 틀리지 않을겁니다.
      캡쳐한 변수를 멤버로 가지는 구조체를 만들고 this->func() 을 호출해버리는거죠

  5. 김재주 2012/05/25 20:07 # M/D Reply Permalink

    람다 함수는 뭐 자바스크립트 루비 파이썬 등등 요새 많이 사용되는 언어에는 거의 있죠. 근데 C++에도 들어가게 되다니 이거 참...... 제가 컴파일러 쪽은 잘 모르다보니 어떻게 인터프리터 언어도 아닌 C++에서 어떻게 클로져를 구현한 건지 모르겠네요. 생각 좀 해봐야 되나

  6. 김재주 2012/05/26 10:40 # M/D Reply Permalink

    아 그런데 C++의 람다함수는 자기 자신을 호출할 방법이 없나요? 자바스크립트의 경우는

    var f = function factorial(n) { if(n==1) return 1; else return factorial(n-1) * n;} 같은 식으로 선언이 가능한데요

    1. 사무엘 2012/05/26 16:48 # M/D Permalink

      아무래도 이름이 없는 함수라면 자기 자신을 일컬을 방법이 특별한 예약어라도 있지 않으면 없겠지요?
      그건 저도 잘 모르겠네요.

  7. Scavenger 2012/06/01 10:58 # M/D Reply Permalink

    잘보고 갑니다. 다음에는 C++11의 큰 변화 중 하나인 R-Value 에 대해서도 다루어주세요.

    1. 사무엘 2012/06/01 12:19 # M/D Permalink

      반갑습니다. R-value 참조자에 대해서는 바로 얼마 전에 쓴 글에 다뤄져 있습니다. 참고하세요. ^^
      http://moogi.new21.org/tc/683

Leave a comment
« Previous : 1 : ... 1612 : 1613 : 1614 : 1615 : 1616 : 1617 : 1618 : 1619 : 1620 : ... 2204 : Next »

블로그 이미지

그런즉 이제 애호박, 단호박, 늙은호박 이 셋은 항상 있으나, 그 중에 제일은 늙은호박이니라.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/11   »
          1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30

Site Stats

Total hits:
2991967
Today:
978
Yesterday:
2549