Tech </> tech.log
· - ·
C++

[C++] Lambda

C++에서 로직을 사용하는 강력한 방법

Coding Test 준비를 위해 GyuChul 코딩테스트 준비를 하며 백준 문제를 풀다보면 종종 등장하는 낯선 녀석이 바로 std::sort 뒤에 따라오는 []로 시작하는 기묘한 문법, **람다(Lambda)**이다.

처음 C++을 배울 때는 단순히 코드를 짧게 줄여주는 '문법적 설탕(Syntactic Sugar)' 정도로 생각하기 쉽다. 하지만 컴파일러의 시선으로 바라본 람다는 단순한 단축키 정도가 아니다.

그 작은 괄호 뒤에는 **익명 클래스(Anonymous Class)**의 생성, 템플릿 인스턴스화, 그리고 시스템 프로그래밍의 핵심인 인라인 최적화메모리 스코프의 미묘한 줄타기가 숨어 있다.

따라서 Lambda를 사용할 때 컴파일러가 부리는 마법과, 그 뒤에 숨겨진 '스코프의 함정'까지 알아보도록 하겠다.

1. 람다의 해부학: 정의와 호출의 분리

람다를 사용할 때는 두 가지 변수가 사용될 수 있다.

  1. 이미 지역변수로 사용되고 있는 변수를 람다 객체 내부로 가져오는 Capture된 변수
  2. 람다를 호출하는 시점에 새로 지정되어 사용하는 변수
int factor = 10;
// [1. 정의 단계] 캡처(Capture) vs 매개변수(Parameter)
auto lambda = [factor](int n) { return n * factor; }; 

// [2. 호출 단계]
int result = lambda(5);
  • [factor] (Capture): 람다 생성 시점에 외부 변수를 람다 객체 내부로 가져온다. (객체의 멤버 변수 초기화)
  • (int n) (Parameter): 람다 호출 시점에 스택을 통해 전달받는 인자이다.

2. 컴파일러의 시선: 람다의 실체는 '객체'다

2-1. 클로저 타입(Closure Type)과 클로저 객체(Closure Object)

람다를 이해하는 핵심 키워드는 클로저(Closure)이다.

컴파일러 입장에서 클로저 타입과 클로저 객체에 대해서 조금 다루고 넘어가겠다.

  • 클로저 타입 (The Type):

    람다 표현식을 위해 컴파일러가 생성하는 고유하고 이름 없는 클래스 타입.

    소스 코드 어디에도 이름이 없기 때문에 우리가 auto 키워드를 써서 타입을 추론하게 만드는 주범이다.

  • 클로저 객체 (The Instance):

    그 클래스 타입으로 생성된 실제 메모리상의 객체.

    이 객체는 캡처된 변수들의 '복사본' 또는 '참조'를 품고 있다.

2-2. 컴파일러 생성 코드 상세 분석

앞서 보여드린 개념적 코드를 한 줄씩 뜯어보며, 람다의 각 요소가 클래스의 어떤 부품으로 변하는지 매핑해 보자.

// [작성한 코드]
int factor = 10;
auto lambda = [factor](int n) { return n * factor; };

① 멤버 변수: 캡처 블록 `[factor]`의 실체

private:
    int __factor; // 캡처된 factor가 클래스의 '데이터 멤버'가 됨

람다는 함수처럼 보이지만, 사실 상태(State)를 가질 수 있는 객체이다. [] 안에 들어가는 모든 변수는 이 클래스의 멤버 변수로 등록된다. 람다가 정의되는 순간, 당시의 factor 값이 이 멤버 변수로 복사된다.

② 생성자: 데이터 주입

public:
    __Lambda_Unique_Name_X(int _f) : __factor(_f) {}

람다 객체가 생성될 때(코드 흐름이 람다 정의부를 지날 때), 외부의 factor 값을 클래스 내부 멤버 변수(__factor)로 전달해주는 역할을 한다.

③ 함수 호출 연산자: 본문 `{ ... }`의 실체

int operator()(int n) const {
    return n * __factor; 
}

이 부분이 핵심이다. 람다를 lambda(5)처럼 호출할 수 있는 이유는 컴파일러가 () 연산자를 오버로딩해주기 때문인 것이다.

  • int n: 람다의 매개변수 (int n)가 그대로 연산자의 인자로 들어온다.
  • const: 람다는 기본적으로 캡처한 변수를 수정할 수 없는 const 함수로 생성된다. (수정하려면 mutable 키워드 필요)
  • 본문: 우리가 {} 안에 적은 로직이 이 연산자 내부에 생성

이렇게 컴파일러가 생성한 클래스 정의 자체가 “클로저 타입”이 되고,

auto lambda = __Lambda_Unique_Name_X(factor);

이와 같이 실제로 Instance를 찍어내면 메모리에 공간을 차지하는 변수(lambda) 그 자체가 된다.

2-3. 왜 굳이 '클래스'로 만들어야 할까? (The "Why")

단순히 함수 포인터로 처리하지 않고 복잡하게 클래스를 만드는 이유는 "상태(Context)와 로직의 결합" 때문이다.

  1. 독립적인 메모리 공간: 함수 포인터는 외부 변수를 가질 수 없지만, 클래스 객체는 멤버 변수를 통해 외부 환경을 기억할 수 있다.
  2. 타입 안전성: 컴파일러는 모든 람다에 고유한 타입을 부여한다. 똑같은 코드의 람다를 두 번 써도 타입이 다르다. 이는 이어서 설명할 **템플릿 최적화(Inlining)**를 가능하게 하는 원동력이 된다. (여기서 말하는 Type이 다르다는 것은 실제 int, char 등의 Type과는 차이가 있다. 인라인 최적화에서 이어서 설명하도록 하겠다.)

3. 템플릿과 인라인 최적화 (Why Lambda is Fast)

std::sort 같은 템플릿 함수에 함수 포인터 대신 람다를 넘기면 왜 빨라질까?

단순한 함수 포인터와 람다의 대결에서 람다가 승리하는 비결은, 역설적으로 **"모든 람다는 세상에 단 하나뿐인 고유한 타입을 가진다"**는 설계에 있다.

3-1. 타입이 곧 '코드'인 이유

C++에서 intchar 같은 기본 타입은 규격이 정해진 기성품이다.

하지만 람다의 타입은 컴파일러가 그 자리에서 즉석으로 설계하고 찍어내는 '한정판 익명 클래스'이다.

컴파일러는 설령 코드가 100% 일치하는 람다를 두 번 보더라도, 각각에게 전혀 다른 이름을 부여한다.

  • 람다 A: class __Lambda_UUID_123
  • 람다 B: class __Lambda_UUID_456

이렇게 타입을 엄격하게 구분하는 이유는 **"타입을 알면 실행할 코드(본문)를 100% 확신할 수 있기 때문"**이다.

3-2. 함수 포인터 vs 람다: 컴파일러의 시야 차이

Case A: 함수 포인터 (안개가 낀 시야)

std::sort에 함수 포인터를 넘기면, 컴파일러는 bool (*comp)(int, int)라는 범용적인 '투입구'만 본다.

  • 컴파일러의 고민: "지금 들어온 이 주소(0x401000)에 무슨 코드가 있지? 런타임에 다른 주소가 들어올 수도 있나?" → 여러 가능성을 열어두어야하네
  • 결과: 컴파일러는 불확실성 때문에 코드를 합치지 못하고, 매번 정렬할 때마다 해당 주소로 점프하여 함수를 호출하는 비용(Call/Return)을 지불해야 한다.

Case B: 람다 (투명한 시야)

반면 람다를 넘기면 std::sort__Lambda_UUID_123이라는 구체적인 타입 전용으로 새롭게 찍혀 나온다(Template Instantiation).

  • 컴파일러의 확신: "이 타입의 객체(comp)가 호출되면, 무조건 내가 아까 정의한 그 코드가 실행될 거야!"
  • 결과: 컴파일러는 함수 호출이라는 중간 단계마저 생략하고, 람다의 본문 로직을 sort 알고리즘 내부에 직접 박아넣는 인라인(Inline) 최적화가 가능하다.

즉, 함수 포인터는 런타임에 호출되는 형태에 따라 달라질 수 있으므로 매번 호출해야하는 형태여야 하지만, 람다는 본인만의 고유한 타입이므로 무조건 사용하는 로직이 고정되어 있어 그것을 그냥 Inline으로 박아두면 호출 오버헤드를 삭제할 수 있는 것이다.

3-3. 이름 없는 타입을 다루는 법: `decltype`

람다의 타입은 컴파일러만 아는 암호 같은 이름이라 우리가 직접 타이핑할 수는 없다. 이때 필요한 도구가 바로 decltype이다.

decltype은 "이 변수의 타입을 그대로 따와줘"라고 요청하는 키워드이다.

auto lambda = [](int a, int b) { return a < b; };

// 람다의 타입을 직접 적을 순 없지만, decltype으로 훔쳐올 순 있습니다.
using LambdaType = decltype(lambda); 

// 이제 이 고유한 타입을 활용해 다른 곳에서도 타입을 명시할 수 있죠.
std::vector<int, LambdaType> customVec;

결국 람다가 intchar와 다른 점은, 타입 그 자체가 곧 실행될 특정한 '로직'을 상징한다는 점에 있다.

모든 람다의 타입이 다르다는 것은, 컴파일러가 모든 호출처마다 최적화된 맞춤형 코드를 선물해줄 수 있다는 뜻이기도 하다.


4. Deep Dive: 스코프(Scope)의 함정과 진실

여기까지만 본다면 람다를 쓰면서

"캡처 목록 [=]을 썼으니 현재 스코프의 모든 외부 변수가 안전하게 복사되었겠지?"

라고 생각할 수 있다.

하지만 이는 반만 맞고 반은 틀린 생각이다.

컴파일러는 매우 효율적으로 움직이며, 필요 없는 것은 절대 캡처하지 않는다.

4-1. 전역 변수와 정적 변수의 비밀 (Non-Capturable)

람다가 캡처할 수 있는 대상은 오직 **지역 변수(Local Variable)**뿐이다.

전역 변수나 static 변수는 캡처 대상에서 아예 제외된다.

static int staticVar = 10;

void test() {
    // [=]을 썼지만, staticVar는 람다 객체 내부에 복사되지 않습니다.
    auto lambda = [=](int n) { 
        return n + staticVar; 
    };
}

여기서 컴파일러는 아주 냉철하게 판단한다.

컴파일러의 생각:
"staticVar는 스택(Stack)이 아니라 메모리의 **데이터 영역(Data Segment)**에 박혀 있어. 프로그램이 끝날 때까지 주소가 안 변하는데, 굳이 람다 객체 안에 복사해서 메모리를 낭비할 필요가 있나? 그냥 그 주소를 직접 참조하게 코드를 짜면 되지."

따라서 위 람다는 내부적으로 멤버 변수를 생성하지 않고 전역 주소를 직접 읽는다.

즉, 람다 외부에서 staticVar 값을 바꾸면 람다의 실행 결과도 실시간으로 바뀐다는 뜻이다.

"복사본(Snapshot)"을 기대했다면 뒤통수를 맞을 수 있는 지점이다.

4-2. 클래스 멤버 변수 캡처의 함정: 'this'의 습격

시스템 프로그래밍에서 가장 치명적인 함정ㅇ 다. 클래스 멤버 함수 안에서 람다를 정의할 때, 우리는 흔히 멤버 변수를 직접 캡처한다고 착각할 수 있다.

class Scale {
    int value = 100;
public:
    void process() {
        // 의도: value의 현재 값을 복사해서 람다에 저장하고 싶음
        auto lambda = [=](int n) { 
            return n + value; 
        };
    }
};

개발자는 value가 람다 내부의 멤버 변수(int __value)로 복사되었다고 생각하겠지만, 사실 컴파일러는 **value**를 캡처한 적이 없다.

실제 컴파일러가 생성하는 코드:

// 사실은 value가 아니라 [this]를 캡처한 것입니다.
auto lambda = [this](int n) { 
    return n + this->value; 
};

앞선 섹션에서 람다는 **'익명 클래스의 객체'**라고 표현했다.

람다 객체 입장에서 value는 자신의 멤버가 아니라, 자신을 만든 상위 객체(Scale 객체)의 멤버다.

그래서 컴파일러는 value를 복사하는 대신, 상위 객체의 주소인 this 포인터를 몰래 복사해둔다.

🚨 위험 시나리오: Dangling Pointer

만약 process()가 이 람다를 리턴하여 외부로 내보냈는데, 그 사이 Scale 객체가 소멸(Destruct)되었다면 어떻게 될까?

람다는 이미 사라진 객체의 주소(this)를 붙잡고 value를 읽으려 시도합니다. 결과는 참혹한 Segmentation Fault 혹은 Undefined Behavior가 발생하는 것이다.


4-3. 해결책: C++14 일반화된 캡처 (Generalized Capture)

이 수명 문제를 해결하기 위해 C++14에서는 **초기화 캡처(Init-capture)**라는 강력한 도구를 도입했다.

이제 람다 객체 내부에 진짜 새로운 멤버 변수를 만들어서 값을 복사할 수 있다.

// C++14: "새로운 변수 val을 정의하고, 현재 value 값을 복사해서 넣어줘!"
auto lambda = [val = value](int n) { 
    return n + val; 
};

이 코드를 통해 컴파일러는 비로소 this 포인터에 의존하지 않는, 독립적인 int val 멤버 변수를 가진 클로저 타입을 생성한다.

이제 Scale 객체가 죽든 살든, 람다는 자신이 복사해온 val을 가지고 안전하게 연산할 수 있다.


5. 캡처(Capture) 방식: 스택을 넘나드는 기술

마지막으로 스택 메모리와의 상호작용을 정리하자.

`[=]` (Value Capture): 스냅샷 찍기

  • 동작: 현재 스택 프레임의 변수 값을 **복사(memcpy)**하여 람다 객체의 멤버 변수로 저장한다.

  • 특징: 독립적인 "스냅샷"이므로 원본이 사라져도 안전하다.

  • mutable: 기본적으로 람다의 operator()const이므로 내부에서 캡처한 변수를 수정하려면 mutable 키워드가 필요하다.

    (예: [cnt]() mutable { cnt++; })

`[&]` (Reference Capture): 주소 연결하기

  • 동작: 스택 메모리의 **주소(Pointer)**를 저장한다.

  • 위험성: "람다의 수명 > 변수의 수명"인 경우, Dangling Reference가 발생한다.

    따라서 비동기 프로그래밍에서 [&]를 남발하면 디버깅 지옥을 맛볼 수 있을 듯 ,,,


6. 마치며

우리는 편의를 위해 []를 사용하지만, 그 이면에는 컴파일러가 만들어내는 익명 클래스, this 포인터의 묵시적 캡처, 그리고 스택과 힙을 오가는 데이터의 흐름이 존재한다.

  1. 람다는 객체다. (함수가 아니다)
  2. 전역/정적 변수는 캡처되지 않고 직접 접근한다.
  3. 클래스 멤버 함수에서 **[=]** **this**를 캡처하는 것이다. (객체 수명 주의!)

람다를 사용하기 위해서는 위의 세가지 포인트를 분명히 이해하고 활용해야 목적에 맞게, 에러 없이 사용할 수 있을 것이다.

참고자료

https://en.cppreference.com/w/cpp/language/lambda.html

https://en.cppreference.com/w/cpp/language/inline.html