C++ 람다 표현식: 캡처·mutable·제네릭 람다부터 deducing this 재귀까지

sort나 find_if에 넘기는 짧은 로직은 호출 지점에 람다 한 블록으로 쓰는 것이 가장 읽기 좋습니다. [=]와 [&]는 바깥 변수를 어떻게 가져올지만 정하는 문법이고, 스레드나 지연 콜백처럼 나중에 실행되는 람다에는 [&]로 지역 변수를 캡처하지 말고 값 캡처를 써야 합니다. 이 글은 기초 문법부터 C++23 deducing this까지 다루며, 초기화 캡처와 완벽 전달 같은 심화 주제는 람다 심화(#10-2)에서 이어집니다.

람다가 필요한 순간

“나이가 25 이상인 첫 번째 사람”을 찾으려면 std::find_if에 predicate를 넘겨야 합니다. 별도 함수나 함수 객체를 만들면 정의가 파일 위쪽에 떨어져 있어 호출부만 보고는 조건을 알 수 없습니다. std::thread에 여러 지역 변수를 조합한 로직을 넘길 때도, 람다 이전에는 구조체로 묶거나 std::bind를 써야 했습니다.

나중에 호출될 콜백은 더 까다롭습니다. 버튼 클릭 핸들러나 타이머 콜백, 네트워크 요청이 끝난 뒤의 콜백에서 요청 시점의 requestId를 쓰려면, 참조로 넘기면 요청을 보낸 함수의 스택이 해제된 뒤 댕글링이 되고, 값으로 복사하면 바깥의 수정이 반영되지 않습니다. 람다의 캡처 모드([=], [&], [x, &y])는 이 선택을 호출 지점에서 명시적으로 드러내는 문법입니다.

람다(lambda)는 이름 없는 작은 함수를 그것을 쓰는 자리에서 바로 정의하는 문법입니다. 컴파일러는 람다마다 고유한 클래스(클로저 타입)를 만들고, 캡처한 변수는 그 클래스의 멤버가, 람다 본문은 operator()가 됩니다. 예를 들어 std::sort에 비교 기준을 넘기기 위해 함수 객체를 직접 만들면 다음과 같습니다.

struct CompareByAge {
    bool operator()(const Person& a, const Person& b) const {
        return a.age < b.age;
    }
};
std::vector<Person> people = /* ... */;
std::sort(people.begin(), people.end(), CompareByAge());

정렬 기준이 하나뿐인데 타입을 하나 정의하는 것은 과하고, 기준을 바꿀 때마다 그 타입을 찾아 고쳐야 합니다. 람다로 쓰면 age로 비교한다는 의도가 호출부에 그대로 보이고, 나중에 name으로 바꾸는 것도 람다 본문만 고치면 됩니다.

std::vector<Person> people = /* ... */;
std::sort(people.begin(), people.end(),
          [](const Person& a, const Person& b) {
              return a.age < b.age;
          });

캡처와 실행 시점의 관계를 요약하면 아래와 같습니다.

flowchart TB
  subgraph capture[캡처 시점]
    A[람다 정의] --> B{캡처 모드}
    B --> C["[=] 값 복사"]
    B --> D["[&] 참조"]
    B --> E["[x, &y] 혼합"]
  end
  subgraph exec[실행 시점]
    F[람다 호출] --> G{캡처 타입}
    G --> H["값: 스냅샷 사용"]
    G --> I["참조: 현재 값 접근"]
  end
  capture --> exec

람다 문법: 캡처, 매개변수, 반환 타입

기본 문법

람다는 캡처 [ ], 매개변수 ( ), 반환 타입 -> return_type(생략 가능), 본문 { } 네 부분으로 이루어집니다. 캡처는 주변 변수를 람다 안에서 어떻게 쓸지 정하며, 매개변수와 본문은 일반 함수와 비슷합니다. 람다를 변수에 담으면 이름이 있는 호출 가능 객체처럼 사용할 수 있습니다.

// 기본 형태
[capture](parameters) -> return_type {
    // 함수 본문
}
// g++ -std=c++17 -o lambda_basic lambda_basic.cpp && ./lambda_basic
#include <iostream>
int main() {
    auto add = [](int a, int b) -> int {
        return a + b;
    };
    int result = add(3, 5);  // 8
    std::cout << result << "\n";
    return 0;
}

실행하면 8이 한 줄 출력됩니다.

반환 타입 생략

-> return_type을 쓰지 않으면 컴파일러가 return 문의 식으로부터 반환 타입을 추론합니다. return이 여러 개라면 모두 같은 타입으로 추론되어야 합니다.

auto add = [](int a, int b) {
    return a + b;  // int 반환
};
auto multiply = [](double a, double b) {
    return a * b;  // double 반환
};

매개변수 없는 람다

매개변수가 없으면 ()만 쓰거나 괄호를 아예 생략할 수 있습니다. 다만 C++20까지는 mutable이나 -> 반환타입, noexcept를 붙일 때 빈 괄호 ()가 필요하고, C++23부터는 이 경우에도 생략할 수 있습니다.

auto sayHello = []() {
    std::cout << "Hello!\n";
};
sayHello();  // Hello!

// 괄호 생략 가능
auto sayWorld = [] {
    std::cout << "World!\n";
};

즉시 실행 (IIFE)

람다를 정의한 뒤 바로 (인자)를 붙여 호출하면 한 번만 쓰는 함수를 그 자리에서 실행할 수 있습니다. IIFE(Immediately Invoked Function Expression)는 JavaScript에서 온 용어로, C++에서는 복잡한 초기화 식을 한 번만 계산해 변수에 넣을 때, 임시 변수를 바깥 스코프에 노출하고 싶지 않을 때, 그리고 const 변수를 분기에 따라 다르게 초기화할 때 유용합니다.

// 람다를 정의하고 즉시 호출
int result = [](int x) {
    return x * x;
}(5);  // 25

// 초기화에 사용
auto data = []() {
    std::vector<int> vec;
    for (int i = 0; i < 10; ++i) {
        vec.push_back(i * i);
    }
    return vec;
}();

// 스코프 분리: temp1, temp2는 바깥에 노출되지 않음
int value = [&]() {
    int temp1 = computeA();
    int temp2 = computeB();
    return temp1 + temp2;
}();

// const 변수를 조건에 따라 다르게 초기화
bool useCache = true;
const std::string config = [&]() {
    if (useCache) return std::string("cache_enabled");
    return std::string("cache_disabled");
}();

const 분기 초기화는 IIFE를 쓰는 가장 흔한 이유입니다. IIFE 없이 쓰면 std::string config;로 선언한 뒤 if 안에서 대입해야 해서 const를 포기하게 되고, 삼항 연산자로 쓰기엔 분기가 복잡한 경우가 많습니다. 여러 return이 있을 때는 모두 같은 타입이어야 하므로 위 예제처럼 std::string(...)으로 타입을 맞춰 줍니다("..." 리터럴만 반환하면 const char*로 추론됩니다).


[=], [&], this, 초기화 캡처 비교

값 캡처 [=]

[=]는 람다 본문에서 사용하는 주변 변수들을 람다가 만들어지는 시점에 값으로 복사해 둡니다. 람다 안의 x, y는 그때의 스냅샷이므로, 나중에 바깥에서 x를 바꿔도 람다를 호출하면 예전 값이 나옵니다. 본문에서 쓰지 않는 변수는 [=]라도 복사되지 않습니다.

int x = 10;
int y = 20;
// 본문에서 쓰는 외부 변수를 값으로 캡처
auto lambda = [=]() {
    std::cout << x << ", " << y << "\n";  // 10, 20
};
x = 100;  // 람다 내부의 x는 변하지 않음
lambda();  // 10, 20

참조 캡처 [&]

[&]는 주변 변수를 참조로 캡처합니다. 람다 안에서 x, y를 수정하면 원본이 바뀌고, 람다가 나중에 실행되면 그때의 값을 봅니다. 람다가 변수의 스코프를 벗어난 뒤에 호출될 수 있으면 참조가 이미 무효일 수 있으므로(댕글링) 수명을 꼭 확인해야 합니다.

int x = 10;
int y = 20;
auto lambda = [&]() {
    x += 5;
    y += 10;
};
lambda();
std::cout << x << ", " << y << "\n";  // 15, 30

선택적 캡처

캡처할 변수를 이름으로 나열하면 [x]는 값으로, [&y]는 참조로 캡처합니다. 나열하지 않은 지역 변수는 람다 안에서 쓸 수 없습니다. 필요한 것만 골라서 캡처하면 람다가 무엇에 의존하는지가 캡처 목록에 드러납니다.

int x = 10;
int y = 20;
int z = 30;
// x는 값, y는 참조, z는 캡처 안 함
auto lambda = [x, &y]() {
    std::cout << x << ", " << y << "\n";
    // std::cout << z;  // 에러: z 캡처 안 됨
};

혼합 캡처

[=, &y]는 기본은 값 캡처이고 y만 참조로, [&, x]는 기본은 참조 캡처이고 x만 값으로 캡처합니다. 기본 캡처와 같은 방식을 다시 명시할 수는 없습니다([=, x]나 [&, &y]는 에러).

int x = 10;
int y = 20;
int z = 30;
// 기본은 값, y만 참조
auto lambda1 = [=, &y]() {
    std::cout << x << ", " << y << ", " << z << "\n";
};
// 기본은 참조, x만 값
auto lambda2 = [&, x]() {
    std::cout << x << ", " << y << ", " << z << "\n";
};

this 캡처

멤버 함수 안에서 람다를 쓰고 멤버 변수에 접근하려면 [this]로 현재 객체를 캡처합니다. 람다 안에서 count처럼 멤버를 쓰면 this를 통해 접근하는 것이므로, 객체가 소멸한 뒤에 람다가 호출되면 댕글링이 됩니다. C++17부터는 [*this]로 객체 자체를 복사해 캡처할 수 있습니다. 참고로 [=]가 this를 암묵적으로 캡처하는 동작은 C++20에서 deprecated되었으므로, 멤버를 쓸 때는 [=, this]처럼 명시하는 편이 좋습니다.

class Counter {
    int count = 0;

public:
    void increment() {
        auto lambda = [this]() {
            count++;  // this->count
        };
        lambda();
    }

    int getCount() const { return count; }
};

캡처 모드 비교표

캡처 문법의미수명 안전사용 시점
[]아무것도 캡처 안 함항상 안전외부 변수 불필요
[=]사용하는 변수를 값 복사나중 호출 시 안전(포인터·this 제외)비동기·스레드·저장용 콜백
[&]사용하는 변수를 참조즉시 호출만 안전동기 콜백, sort·find_if
[x]x만 값 복사나중 호출 시 안전필요한 변수만 선택
[&y]y만 참조즉시 호출만 안전수정 필요할 때
[=, &y]기본 값, y만 참조y 수명 주의대부분 복사, y만 갱신
[&, x]기본 참조, x만 값x 외에는 수명 주의대부분 참조, x만 스냅샷
[this]현재 객체 포인터객체 수명 주의멤버 함수 내부
[*this] (C++17)객체 전체 복사나중 호출 시 안전스레드로 객체 넘길 때
[p = std::move(ptr)]move로 초기화소유권 이전unique_ptr, 큰 객체

초기화 캡처 (C++14)

[이름 = 식]은 람다 전용 멤버를 하나 만들어 식의 결과로 초기화합니다. [p = std::move(ptr)]처럼 이동으로 가져올 수 있어서 unique_ptr이나 큰 객체를 람다로 넘길 때 유용하고, 기존 변수와 다른 이름을 써도 됩니다.

int x = 10;
// 새 변수 생성
auto lambda = [y = x + 5]() {
    std::cout << y << "\n";  // 15
};
// move 캡처
auto ptr = std::make_unique<int>(42);
auto lambda2 = [p = std::move(ptr)]() {
    std::cout << *p << "\n";
};

mutable과 예외 지정

mutable 람다

클로저의 operator()는 기본적으로 const이므로, 값으로 캡처한 변수는 람다 안에서 수정할 수 없습니다. mutable을 붙이면 람다 객체가 들고 있는 복사본을 수정할 수 있게 되지만, 원본 x는 변하지 않습니다. 복사본은 람다 객체의 멤버이므로 호출 사이에 값이 유지됩니다.

int x = 0;
// 값 캡처는 기본적으로 const
auto lambda1 = [x]() {
    // x++;  // 에러: const
    std::cout << x << "\n";
};
// mutable: 값 캡처를 수정 가능
auto lambda2 = [x]() mutable {
    x++;  // 복사본 수정
    std::cout << x << "\n";
};
lambda2();  // 1
lambda2();  // 2
std::cout << x << "\n";  // 0 (원본은 변하지 않음)

noexcept 지정

noexcept를 붙이면 이 람다가 예외를 던지지 않는다고 선언하는 것입니다. noexcept는 함수 타입의 일부이므로 noexcept(f()) 같은 검사나 noexcept 함수 포인터로의 변환에 반영되며, 실제로 예외가 빠져나오려 하면 std::terminate가 호출됩니다.

auto lambda = []() noexcept {
    return 42;
};
static_assert(noexcept(lambda()));

속성 지정

C++23부터는 람다 도입부 바로 뒤에 속성을 붙여 호출 연산자에 적용할 수 있습니다(P2173). () 뒤에 쓰는 []() [[nodiscard]]는 호출 연산자가 아니라 함수 타입에 붙는 속성이라, nodiscard 경고를 기대했다면 동작하지 않거나 컴파일러가 무시한다는 경고를 냅니다.

// C++23: 속성은 [] 바로 뒤에
auto lambda = [] [[nodiscard]] () {
    return 42;
};
lambda();  // 반환값 무시 경고

람다와 클로저 타입: 컴파일러가 생성하는 것

캡처별 클로저 크기, 같은 본문 람다의 타입 차이, mutable 동작, 비mutable 람다에서 값 캡처를 수정할 때의 컴파일 오류

그림: 실제 실행 결과 — 캡처가 없으면 클로저 크기는 1바이트, [x]는 4, [&y]는 8, [x, &y]는 16이다(Apple clang, arm64). 본문이 같은 두 람다도 다른 타입이고, mutable 람다의 복사본만 11, 12로 누적되며 바깥 x는 10이다. mutable 없이 값 캡처를 수정하면 컴파일되지 않는다. 크기는 구현마다 다를 수 있다. (환경: macOS 27.0.1 · Apple clang 21.0.0)

고유한 클로저 타입

람다 표현식마다 컴파일러는 서로 다른 이름 없는 클래스 타입을 생성합니다. 그 타입의 객체가 클로저(closure)이며, auto x = [...] { ... };에 담기는 것이 그 인스턴스입니다. 표준이 정한 모델을 요약하면 다음과 같습니다.

  1. 값으로 캡처한 변수는 클로저 타입의 이름 없는 비정적 데이터 멤버가 됩니다. 참조로 캡처한 변수가 멤버로 저장되는지는 구현에 맡겨져 있지만, 개념적으로는 참조를 들고 있다고 보면 됩니다.
  2. 호출 연산자 operator()가 생성되며, 기본적으로 const 멤버 함수입니다. 그래서 값으로 캡처한 복사본은 본문 안에서 수정할 수 없습니다.
  3. mutable을 붙이면 operator()가 비const 멤버 함수가 되어 클로저 안에 저장된 복사본을 수정할 수 있습니다. 바깥의 원본이 바뀌는 것은 아닙니다.

개념적으로는 아래와 비슷한 코드가 생성된다고 생각하면 됩니다(실제 이름과 멤버 배치는 구현마다 다릅니다).

// 개념적 모델 (의사 코드)
struct /* 고유 이름 없는 타입 */ {
    int x_copy;           // [x] 값 캡처
    int& y_ref;           // [&y] 참조 캡처

    auto operator()(int a) const {
        return x_copy + y_ref + a;
    }
};

타입·복사·이동

서로 다른 람다 표현식은 본문이 글자 하나까지 같아도 서로 다른 타입입니다. 그래서 decltype(lam1)과 decltype(lam2)는 일치하지 않습니다.

C++17까지 클로저 타입에는 기본 생성자와 복사 대입 연산자가 없습니다. C++20부터는 캡처가 없는 람다에 한해 기본 생성과 대입이 가능해져, std::map<K, V, decltype(cmp)>처럼 비교자 타입만 넘기고 객체는 생략할 수 있게 되었습니다. 람다를 저장할 때는 보통 템플릿 매개변수로 타입을 그대로 받고, 타입 이름이 꼭 필요한 곳에서만 std::function이나 함수 포인터를 씁니다.

클로저 객체를 복사하면 캡처 멤버가 복사되고, 이동하면 각 멤버가 이동됩니다. 큰 std::vector를 값으로 캡처한 람다를 여기저기 복사하면 그때마다 벡터 전체가 복사됩니다.

auto 매개변수와 템플릿 람다

auto 매개변수

C++14부터 람다 매개변수에 auto를 쓸 수 있습니다. 이런 제네릭 람다의 operator()는 템플릿이므로, 호출되는 인자 타입마다 별도로 인스턴스화됩니다.

auto print = [](auto value) {
    std::cout << value << "\n";
};
print(42);           // int
print(3.14);         // double
print("hello");      // const char* (값으로 받으므로 배열이 포인터로 decay)

여러 타입 매개변수

auto add = [](auto a, auto b) {
    return a + b;
};
std::cout << add(1, 2) << "\n";        // 3
std::cout << add(1.5, 2.5) << "\n";    // 4 (double 4.0이지만 cout 기본 형식으로는 4)
std::cout << add(std::string("Hello"), std::string(" World")) << "\n";

템플릿 람다 (C++20)

C++20에서는 람다에 템플릿 매개변수 목록을 직접 쓸 수 있습니다. []<typename T>(T value)처럼 쓰면 본문에서 T를 이름으로 사용할 수 있어서, 두 매개변수가 같은 타입이어야 한다는 제약을 걸거나 std::vector<T>처럼 타입의 일부를 꺼내야 할 때 유용합니다.

auto lambda = []<typename T>(T value) {
    std::cout << typeid(T).name() << ": " << value << "\n";
};
lambda(42);      // int: 42
lambda(3.14);    // double: 3.14

typeid(T).name()의 출력은 구현마다 다릅니다(GCC는 i, d처럼 맹글링된 이름을 출력). 위 주석은 의미를 보여 주기 위한 것입니다.

제네릭 람다 + if constexpr

auto 매개변수와 if constexpr를 함께 쓰면 타입별로 다른 처리를 한 람다 안에 담을 수 있습니다. 선택되지 않은 분기는 인스턴스화되지 않으므로, 해당 타입에서 컴파일되지 않는 코드가 있어도 괜찮습니다.

auto process = [](auto value) {
    if constexpr (std::is_integral_v<decltype(value)>) {
        return value * 2;
    } else if constexpr (std::is_floating_point_v<decltype(value)>) {
        return value * 1.5;
    } else {
        return value;
    }
};
std::cout << process(10) << "\n";    // 20
std::cout << process(10.0) << "\n";  // 15

STL 알고리즘, 스레드, 콜백, RAII 헬퍼에서 람다 쓰기

패턴 1: STL 알고리즘

find_if, count_if, all_of, transform 같은 알고리즘은 조건이나 변환을 함수로 받습니다. 이런 자리에 람다를 넘기면 “짝수인지”, “제곱” 같은 로직을 호출 지점에 바로 적을 수 있습니다.

// g++ -std=c++17 -o lambda_stl lambda_stl.cpp && ./lambda_stl
#include <algorithm>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
struct Person {
    std::string name;
    int age;
};
int main() {
    std::vector<Person> people = {
        {"Alice", 25}, {"Bob", 30}, {"Charlie", 20}, {"Diana", 25}
    };
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    // find_if: 나이 25 이상 첫 번째
    auto it = std::find_if(people.begin(), people.end(),
        [](const Person& p) { return p.age >= 25; });
    if (it != people.end())
        std::cout << "Found: " << it->name << ", " << it->age << "\n";
    // count_if: 나이 25 이상 인원 수
    auto count = std::count_if(people.begin(), people.end(),
        [](const Person& p) { return p.age >= 25; });
    std::cout << "Count (age>=25): " << count << "\n";
    // all_of: 모두 성인(18세 이상)?
    bool allAdult = std::all_of(people.begin(), people.end(),
        [](const Person& p) { return p.age >= 18; });
    std::cout << "All adult: " << (allAdult ? "yes" : "no") << "\n";
    // transform: 제곱
    std::vector<int> squares;
    std::transform(numbers.begin(), numbers.end(),
        std::back_inserter(squares),
        [](int x) { return x * x; });
    std::cout << "Squares: ";
    for (int s : squares) std::cout << s << " ";
    std::cout << "\n";
    return 0;
}

실행 결과는 Found: Alice, 25 / Count (age>=25): 3 / All adult: yes / Squares: 1 4 9 16 25 36 49 64 81 100입니다.

패턴 2: 정렬

sort의 세 번째 인자로 비교 함수를 넘기면 “a가 b보다 앞에 와야 하는가”를 람다로 줄 수 있습니다. 비교 함수는 strict weak ordering을 만족해야 하므로 < 대신 <=를 쓰면 미정의 동작이 된다는 점에 주의합니다.

struct Person {
    std::string name;
    int age;
};
std::vector<Person> people = {
    {"Alice", 25},
    {"Bob", 30},
    {"Charlie", 20}
};
// 나이순 정렬
std::sort(people.begin(), people.end(),
          [](const Person& a, const Person& b) {
              return a.age < b.age;
          });
// 이름 길이순
std::sort(people.begin(), people.end(),
          [](const Person& a, const Person& b) {
              return a.name.length() < b.name.length();
          });

패턴 3: std::thread와 람다

스레드에 로직을 넘길 때 람다를 쓰면 지역 변수를 캡처로 넘길 수 있습니다. 아래처럼 바로 join()한다면 참조 캡처도 동작하지만, 스레드를 detach()하거나 다른 곳에 넘겨 나중에 합류시키는 코드로 바뀌는 순간 댕글링이 되므로 스레드에는 값 캡처를 기본으로 쓰는 편이 안전합니다.

// g++ -std=c++17 -pthread -o lambda_thread lambda_thread.cpp && ./lambda_thread
#include <iostream>
#include <thread>
#include <vector>
int main() {
    std::vector<int> data = {1, 2, 3, 4, 5};
    int multiplier = 10;
    // 값 캡처: data, multiplier 복사본을 스레드가 소유
    std::thread t([data, multiplier]() {
        for (int x : data) {
            std::cout << x * multiplier << " ";
        }
        std::cout << "\n";
    });
    t.join();
    return 0;
}

실행하면 10 20 30 40 50이 출력됩니다.

패턴 4: 콜백

버튼 클릭처럼 나중에 호출될 동작을 저장할 때는 std::function에 람다를 담습니다. [&clickCount]로 참조 캡처하면 바깥 변수를 갱신할 수 있지만, clickCount가 콜백보다 오래 살 때만 안전합니다.

#include <iostream>
#include <functional>
#include <utility>
class Button {
    std::function<void()> onClick;

public:
    void setOnClick(std::function<void()> callback) {
        onClick = std::move(callback);
    }

    void click() {
        if (onClick) onClick();
    }
};
int main() {
    Button button;

    int clickCount = 0;
    button.setOnClick([&clickCount]() {
        clickCount++;
        std::cout << "Clicked " << clickCount << " times\n";
    });

    button.click();  // Clicked 1 times
    button.click();  // Clicked 2 times
}

[&clickCount] 대신 [clickCount]() mutable로 값 캡처하면 콜백 안의 카운터는 계속 올라가지만 main의 clickCount는 0으로 남습니다. 콜백이 가진 복사본만 바뀌기 때문입니다. 수명과 상태 공유를 동시에 만족해야 한다면 auto count = std::make_shared<int>(0);을 만들어 [count]로 캡처하거나, 상태를 클래스 멤버로 옮기는 편이 안전합니다.

패턴 4-1: mutable 상태를 가진 람다

초기화 캡처와 mutable을 함께 쓰면 호출될 때마다 상태가 바뀌는 작은 생성기를 클래스 없이 만들 수 있습니다. 상태는 람다 객체 안에 있으므로, 람다를 복사하면 카운터도 따로 복사됩니다.

auto counter = [n = 0]() mutable {
    return ++n;
};
std::cout << counter() << "\n";  // 1
std::cout << counter() << "\n";  // 2
auto copy = counter;              // n == 2인 상태로 복사
std::cout << copy() << "\n";     // 3
std::cout << counter() << "\n";  // 3 (원본과 복사본이 독립)

이 성질 때문에 알고리즘에 상태 있는 람다를 넘길 때 주의해야 합니다. 표준 알고리즘은 함수 객체를 값으로 받아 내부에서 복사할 수 있으므로, 호출 횟수를 람다 안에 세어 두고 나중에 원본 람다에서 읽으면 기대한 값이 나오지 않을 수 있습니다.

패턴 4-2: [*this]로 객체를 복사해 스레드에 넘기기 (C++17)

[this]는 포인터만 복사하므로 객체가 먼저 소멸하면 댕글링입니다. 스레드가 객체의 스냅샷만 필요하다면 [*this]로 객체 전체를 복사해 넘길 수 있습니다. 객체가 크거나 복사할 수 없다면 shared_from_this()를 사용하는 방식(아래 오류 5)을 고려하세요.

#include <iostream>
#include <thread>
struct Worker {
    int id = 0;
    std::thread runAsync() {
        // [*this]: 객체 복사본을 람다가 소유
        return std::thread([*this]() {
            std::cout << "Worker " << id << " running\n";
        });
    }
};
int main() {
    std::thread t;
    {
        Worker w;
        w.id = 42;
        t = w.runAsync();
    }  // w는 여기서 소멸하지만 스레드는 복사본을 사용
    t.join();
}

패턴 5: RAII 헬퍼

스코프를 벗어날 때 반드시 실행되어야 하는 정리 코드(파일 닫기, 잠금 해제 등)를 람다로 넘겨 ScopeGuard에 넣습니다. 생성자에서 람다를 받고 소멸자에서 실행하며, dismiss()로 “이미 처리했으니 실행하지 말라”고 표시할 수 있어서 예외 경로와 정상 경로를 한 곳에서 다룰 수 있습니다.

template <typename Func>
class ScopeGuard {
    Func func;
    bool active = true;

public:
    explicit ScopeGuard(Func f) : func(std::move(f)) {}
    ScopeGuard(const ScopeGuard&) = delete;
    ScopeGuard& operator=(const ScopeGuard&) = delete;

    ~ScopeGuard() {
        if (active) func();
    }

    void dismiss() { active = false; }
};
template <typename Func>
auto makeScopeGuard(Func func) {
    return ScopeGuard<Func>(std::move(func));  // C++17 보장된 복사 생략으로 복사 생성자 불필요
}
void processFile(const std::string& filename) {
    FILE* file = fopen(filename.c_str(), "r");
    if (!file) return;

    // 스코프 종료 시 자동으로 파일 닫기
    auto guard = makeScopeGuard([file]() {
        fclose(file);
        std::cout << "File closed\n";
    });

    // 파일 처리...
}

복사를 금지하는 이유는, 가드가 복사되면 두 객체의 소멸자가 각각 fclose를 호출해 같은 파일을 두 번 닫게 되기 때문입니다. C++17에서는 makeScopeGuard가 prvalue를 반환하므로 복사 생성자 없이도 컴파일됩니다.

패턴 6: 지연 실행

Lazy는 처음 호출될 때만 람다를 실행하고 그 결과를 캐시합니다. 이후 호출에서는 캐시된 값을 반환하므로, 비용이 큰 계산을 필요할 때 한 번만 하게 할 수 있습니다.

#include <iostream>
#include <utility>
template <typename Func>
class Lazy {
    Func func;
    mutable std::optional<decltype(func())> cached;

public:
    Lazy(Func f) : func(std::move(f)) {}

    auto operator()() const {
        if (!cached) {
            cached = func();
        }
        return *cached;
    }
};
int main() {
    Lazy expensive([]() {
        std::cout << "Computing...\n";
        return 42;
    });

    std::cout << expensive() << "\n";  // Computing... 42
    std::cout << expensive() << "\n";  // 42 (캐시됨)
}

Lazy expensive(...)처럼 템플릿 인자를 생략할 수 있는 것은 C++17의 클래스 템플릿 인자 추론(CTAD) 덕분입니다. 이 구현은 스레드 안전하지 않으므로, 여러 스레드가 동시에 호출한다면 std::call_once로 감싸야 합니다.

패턴 7: 재귀 람다

람다가 자기 자신을 호출하려면 이름이 필요한데, 람다는 기본적으로 이름이 없습니다. C++11부터 쓸 수 있는 방법은 std::function에 담고 그 변수를 참조로 캡처하는 것이고, C++23에서는 this auto self로 자기 자신을 매개변수로 받을 수 있습니다.

// C++11: std::function 사용
std::function<int(int)> factorial = [&](int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
};
std::cout << factorial(5) << "\n";  // 120

// C++23: 명시적 객체 매개변수
auto factorial2 = [](this auto self, int n) -> int {
    return n <= 1 ? 1 : n * self(n - 1);
};

C++23 버전에서 -> int를 생략하면 안 됩니다. 반환 타입을 추론해야 하는 함수가, 반환 타입이 정해지기 전에 자기 자신을 호출하는 셈이 되어 컴파일 에러가 납니다(앞에 재귀가 없는 return 1; 문이 먼저 나오면 추론이 가능합니다).

std::function 방식에서는 factorial을 반드시 참조로 캡처해야 합니다. [=]나 [factorial]로 값 캡처하면 캡처 시점의 factorial은 아직 람다가 대입되기 전의 빈 std::function이라, 재귀 호출 시 std::bad_function_call 예외가 발생합니다. 또 참조로 캡처한 이 람다를 다른 곳에 복사해 반환하면, 원래 factorial 변수가 사라진 뒤 댕글링이 됩니다. 재귀 로직이 길어지면 이름 있는 일반 함수로 빼는 편이 읽기에도 디버깅에도 낫습니다. C++23 이전의 다른 우회법과 deducing this 비교는 아래 C++17/20/23 최신 기능: constexpr·consteval·재귀 람다 절에서 다룹니다.


댕글링 참조, 루프 변수 캡처, this 수명 오류

오류 1: 댕글링 참조

참조로 캡처한 지역 변수가 스코프를 벗어나 소멸한 뒤 람다가 실행되면 미정의 동작입니다. 프로그램이 크래시하거나 이상한 값이 출력되며, 디버그 빌드에서는 멀쩡하다가 최적화 빌드에서만 문제가 드러나기도 합니다. 함수 밖으로 나가는 람다(반환, 컨테이너 저장, 스레드 전달)에서는 필요한 값만 값으로 캡처합니다.

// 잘못된 예: 람다를 반환하면서 참조 캡처
std::function<void()> createBroken() {
    int x = 42;
    return [&x]() { std::cout << x << "\n"; };  // x는 createBroken() 종료 시 소멸
}
// 올바른 예: 값 캡처
std::function<void()> createSafe() {
    int x = 42;
    return [x]() { std::cout << x << "\n"; };
}

오류 2: for 루프 변수 캡처

[&i]로 참조 캡처하면 모든 람다가 같은 i를 가리킵니다. 스레드가 실행되는 시점에 따라 이미 증가한 값을 읽고, 루프가 끝난 뒤에는 i 자체가 소멸하므로 데이터 레이스이자 미정의 동작입니다.

// 잘못된 예
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
    threads.emplace_back([&i]() {
        std::cout << i << "\n";  // 예측할 수 없는 값, 미정의 동작
    });
}
// 올바른 예: 값 캡처
for (int i = 0; i < 5; ++i) {
    threads.emplace_back([i]() {
        std::cout << i << "\n";  // 0~4가 한 번씩 (순서는 스레드 스케줄에 따라 다름)
    });
}
for (auto& t : threads) t.join();

오류 3: mutable 없이 값 캡처 수정 시도

값 캡처된 변수는 람다 내부에서 const로 취급되므로, 수정하려 하면 GCC에서는 increment of read-only variable 'x' 같은 컴파일 에러가 납니다.

int x = 0;
// 잘못된 예
// auto lambda = [x]() { x++; };  // 컴파일 에러

// 올바른 예
auto lambda = [x]() mutable {
    x++;  // 복사본만 수정, 원본 x는 그대로
};

오류 4: 람다 타입을 함수 매개변수에 적을 수 없음

람다마다 고유한 타입이 생기므로, 템플릿이 아닌 함수가 “아무 람다나” 받으려면 std::function처럼 타입을 지우는 래퍼가 필요합니다. std::function<void()>는 캡처가 있는 람다도 받을 수 있지만, 큰 클로저는 힙에 할당될 수 있고 호출이 간접 호출이 됩니다. 콜백을 저장하지 않고 받아서 바로 쓴다면 템플릿으로 람다를 그대로 받는 편이 좋습니다.

// std::function은 캡처 람다도 OK
std::function<void()> f = [x = 42]() { std::cout << x << "\n"; };
// 저장하지 않는다면: 템플릿으로 람다 직접 전달
template <typename Func>
void run(Func&& f) { f(); }
run([]() { std::cout << "no type erasure\n"; });

오류 5: this 캡처 후 객체 소멸

멤버 함수에서 [this] 람다를 스레드나 작업 큐에 넘긴 뒤 객체가 먼저 소멸하면, 람다는 해제된 객체에 접근합니다.

// 위험: 객체가 먼저 소멸할 수 있음
class Worker {
public:
    void start() {
        std::thread t([this]() {
            this->doWork();  // Worker 소멸 후 호출 시 UB
        });
        t.detach();
    }
    void doWork();
};
// 안전: shared_from_this로 수명 연장 (또는 C++17 [*this]로 복사)
class SafeWorker : public std::enable_shared_from_this<SafeWorker> {
public:
    void start() {
        auto self = shared_from_this();
        std::thread t([self]() { self->doWork(); });
        t.detach();
    }
    void doWork();
};

shared_from_this()는 객체가 이미 std::shared_ptr로 관리되고 있을 때만 쓸 수 있습니다. 스택에 만든 객체에서 호출하면 C++17부터 std::bad_weak_ptr 예외가 발생합니다(그 이전에는 미정의 동작).

오류 6: std::function 시그니처 불일치

std::function의 반환·인자 타입으로 람다를 호출할 수 없으면 conversion from '<lambda()>' to non-scalar type 'std::function<int()>' requested 같은 긴 컴파일 에러가 납니다. 예를 들어 반환 타입이 void인 람다를 std::function<int()>에 담으려 하면 변환할 방법이 없습니다. 반대로 int를 반환하는 람다를 std::function<void()>에 담는 것은 반환값을 버리는 것으로 허용됩니다.

// std::function<int()> fn = []() { std::cout << "hi"; };  // 에러: 람다는 void 반환
std::function<void()> fn = []() { std::cout << "hi\n"; };  // OK

오류 7: 초기화 캡처가 다른 캡처를 참조한다고 착각

초기화 캡처 [a = 식]의 식은 람다를 감싸는 바깥 스코프에서 평가되므로, 같은 캡처 목록의 다른 초기화 캡처를 가리킬 수 없습니다. 바깥에 같은 이름이 없으면 'y' was not declared in this scope 같은 에러가 나고, 바깥에 같은 이름이 있으면 그 바깥 변수를 조용히 쓰게 됩니다.

int x = 10;
// 에러: y는 바깥 스코프에 없음 (같은 목록의 y = x를 가리키지 않음)
// auto bad = [z = y, y = x]() { return z + y; };
// 바깥 변수만 사용
auto good = [y = x, z = x * 2]() { return y + z; };

오류 8: mutable 람다를 여러 스레드에서 공유

mutable 람다의 캡처 변수는 람다 객체의 멤버입니다. 여러 스레드가 같은 람다 객체를 참조로 공유하면 그 멤버를 동시에 수정하게 되어 데이터 레이스가 됩니다.

auto counter = [n = 0]() mutable { return ++n; };
// 잘못된 예: 참조로 공유 → 데이터 레이스
std::thread t1([&counter]() { for (int i = 0; i < 100; ++i) counter(); });
std::thread t2([&counter]() { for (int i = 0; i < 100; ++i) counter(); });
// 스레드마다 독립 복사본 (공유 카운터가 필요하면 std::atomic<int>를 참조로)
std::thread t3([counter]() mutable { for (int i = 0; i < 100; ++i) counter(); });
t1.join(); t2.join(); t3.join();

캡처 비용 줄이기와 std::function 대신 템플릿

캡처 비용

[s]처럼 값으로 캡처하면 람다가 만들어지는 순간 복사가 일어납니다. int나 포인터 같은 작은 타입은 복사 비용을 신경 쓸 필요가 없지만, 큰 std::string이나 vector라면 람다를 즉시 호출하는 경우에는 참조 캡처로, 람다로 옮긴 뒤 원본을 더 쓰지 않는다면 초기화 캡처 [s = std::move(str)]로 이동하는 편이 좋습니다. [=]는 본문에서 쓰는 변수만 복사하지만, 본문을 고치다 큰 객체를 하나 더 쓰게 되면 그 복사가 조용히 추가되므로, 비용이 신경 쓰이는 곳에서는 캡처를 명시하는 편이 변화를 알아차리기 쉽습니다.

std::string big(10000, 'x');
std::size_t threshold = 10;
// big 전체 복사
auto copying = [=]() { return big.size() > threshold; };
// 즉시 호출한다면: 참조 + 값
auto referencing = [&big, threshold]() { return big.size() > threshold; };
// 소유권을 넘긴다면: 이동
auto moving = [s = std::move(big), threshold]() { return s.size() > threshold; };

std::function 대신 템플릿

std::function은 타입 소거를 위해 힙 할당을 할 수 있고, 호출이 간접 호출이 됩니다. 콜백을 받아서 저장하지 않는다면 template<typename F>로 람다를 직접 받는 편이 좋습니다.

// 간접 호출, 인라인 어려움
void sortWithFunc(std::vector<int>& v, std::function<bool(int, int)> cmp);
// 인라인 가능
template <typename Compare>
void sortWithLambda(std::vector<int>& v, Compare cmp) {
    std::sort(v.begin(), v.end(), cmp);
}
방식인라인 가능힙 할당호출 비용사용 시점
람다 (템플릿으로 전달)가능없음직접 호출콜백을 받아 바로 쓸 때
람다 (std::function에 저장)대체로 어려움큰 클로저면 가능간접 호출서로 다른 콜백을 한 타입으로 저장할 때
함수 포인터상황에 따라없음간접 호출C API 연동 (캡처 없는 람다는 함수 포인터로 변환 가능)
함수 객체가능없음직접 호출상태와 여러 멤버 함수가 필요할 때

“std::function은 느리다”는 말은 절반만 맞습니다. 호출 한 번의 간접 비용보다 문제가 되는 건 인라인이 막힌다는 점입니다. std::sort처럼 비교 함수를 수백만 번 호출하는 루프에서는 비교 로직이 루프 안으로 인라인되느냐가 차이를 만들고, 이벤트 핸들러처럼 가끔 호출되는 콜백에서는 거의 체감되지 않습니다. 그래서 “저장해야 하면 std::function, 받아서 바로 쓰면 템플릿”이 실용적인 기준입니다.

즉시 실행 람다로 복잡한 초기화

여러 단계를 거쳐 만든 객체를 const로 두고 싶을 때 즉시 실행 람다가 유용합니다. 반환은 NRVO나 이동으로 처리되므로 추가 복사가 생기지 않습니다.

const auto config = [&]() {
    Config c;
    c.loadFromFile("config.json");
    c.merge(defaults);
    return c;
}();

std::async, ScopeGuard, 재시도 핸들러 주입

패턴 A: 스레드에 안전하게 인자 전달

detach()하는 스레드에 넘기는 람다는 함수가 먼저 반환해도 동작해야 하므로, 필요한 데이터를 값으로 복사해 스레드가 소유하게 합니다. 아래에서 input은 호출자의 문자열에 대한 참조이므로, 참조로 캡처하면 호출자가 문자열을 해제한 뒤에도 스레드가 그것을 읽을 수 있습니다.

void processInBackground(const std::string& input) {
    // 값 캡처: input 복사본을 스레드가 소유
    std::thread t([input]() {
        auto result = expensiveComputation(input);
        saveResult(result);
    });
    t.detach();
}

패턴 B: std::async와 람다

std::async에 넘긴 람다는 future.get() 전에 다른 스레드에서 실행될 수 있으므로, 람다가 쓰는 데이터는 그동안 살아 있어야 합니다. 초기화 캡처로 데이터를 람다 안으로 옮겨 두면 호출한 쪽의 지역 변수 수명과 무관해집니다.

auto future = std::async(std::launch::async, [data = prepareData()]() {
    return process(data);
});
// ... 다른 작업 ...
auto result = future.get();

패턴 C: 실패 시 정리하는 ScopeGuard

임시 파일에 먼저 쓰고 성공하면 최종 경로로 옮기는 패턴에서, 중간에 예외가 나면 임시 파일을 지워야 합니다. ScopeGuard에 정리 로직을 넘기고, 성공 경로 끝에서 dismiss()로 정리를 취소합니다.

#include <filesystem>
#include <fstream>
#include <stdexcept>
void writeWithBackup(const std::string& path, const std::string& data) {
    std::string tmpPath = path + ".tmp";
    std::ofstream out(tmpPath);
    if (!out) throw std::runtime_error("Cannot open " + tmpPath);
    // 실패 시 임시 파일 삭제
    auto guard = makeScopeGuard([tmpPath]() {
        std::error_code ec;
        std::filesystem::remove(tmpPath, ec);  // 소멸자에서 호출되므로 예외를 던지지 않는 오버로드 사용
    });
    out << data;
    out.close();
    if (!out) throw std::runtime_error("Write failed: " + tmpPath);
    std::filesystem::rename(tmpPath, path);  // 성공 시 최종 경로로 이동
    guard.dismiss();  // 성공했으므로 삭제하지 않음
}

정리 람다가 예외를 던지면 소멸자에서 예외가 빠져나가 std::terminate가 호출되므로, std::error_code를 받는 오버로드를 써야 합니다.

패턴 D: 조건부 로직을 람다로 캡슐화

성공과 실패 시의 동작을 람다로 주입하면 같은 분기 뼈대를 여러 곳에서 재사용할 수 있습니다.

template <typename OnSuccess, typename OnFailure>
void tryOperation(OnSuccess&& onOk, OnFailure&& onFail) {
    if (doSomething()) {
        onOk();
    } else {
        onFail();
    }
}
tryOperation(
    []() { log("OK"); commit(); },
    []() { log("Failed"); rollback(); }
);

패턴 E: 재시도 로직에 에러 핸들러 주입

재시도와 로깅 정책을 람다로 주입하면 같은 재시도 뼈대를 여러 작업에 재사용할 수 있습니다. 작업 람다는 tryWithRetry 안에서 즉시 호출되므로 [&] 캡처도 안전합니다.

template <typename Op, typename OnError>
auto tryWithRetry(Op op, OnError onError, int maxRetries = 3) {
    for (int i = 0; i < maxRetries; ++i) {
        try { return op(); }
        catch (const std::exception& e) {
            onError(i, e.what());
            if (i == maxRetries - 1) throw;
        }
    }
    throw std::runtime_error("unreachable");
}
auto body = tryWithRetry(
    [&]() { return fetch(url); },
    [](int attempt, const char* msg) { std::cerr << "retry " << attempt << ": " << msg << "\n"; });

C++17/20/23 최신 기능: constexpr·consteval·재귀 람다

C++17: 암묵적 constexpr 람다

C++17부터 람다의 호출 연산자는 constexpr 함수의 요건을 만족하면 암묵적으로 constexpr입니다. 상수 표현식이 필요한 문맥에서 호출하면 컴파일 타임에 평가되고, 일반 문맥에서는 런타임에 호출됩니다.

constexpr auto factorial = [](int n) {
    int result = 1;
    for (int i = 2; i <= n; ++i)
        result *= i;
    return result;
};

// 컴파일 타임 계산 (constexpr 변수, 템플릿 인자)
constexpr int val = factorial(5);   // 120
std::array<int, factorial(4)> arr;  // 크기 24 배열

// 런타임에도 사용 가능
int n = read_input();
int runtime_val = factorial(n);

컴파일 타임 계산은 조회 테이블(LUT)을 만들 때 자주 쓰입니다.

// 컴파일 타임 LUT 생성 (C++17부터 std::array의 비const operator[]도 constexpr)
constexpr auto generate_lut = []() {
    std::array<int, 256> lut{};
    for (int i = 0; i < 256; ++i)
        lut[i] = (i * i) % 256;  // 예시용 계산
    return lut;
};

constexpr auto lookup_table = generate_lut();

int fast_lookup(int x) {
    return lookup_table[x & 0xFF];
}

C++20: consteval 람다

consteval 람다는 즉시 함수(immediate function)라서 모든 호출이 컴파일 타임에 평가되어야 하며, 런타임 값으로 호출하면 컴파일 에러입니다. consteval은 변수 선언 앞이 아니라 람다의 매개변수 목록 뒤에 지정자로 붙입니다(consteval auto f = [...]처럼 변수에 붙이면 문법 오류).

constexpr auto square = [](int n) consteval {
    return n * n;
};

constexpr int a = square(5);  // OK: 컴파일 타임
// int b = square(runtime_value);  // 컴파일 에러

// 컴파일 타임 검증
constexpr auto validate_config = [](const char* str) consteval {
    if (str[0] == '\0')
        throw "Invalid config";  // 이 분기에 도달하면 상수 평가 실패 → 컴파일 에러
    return true;
};

constexpr bool ok = validate_config("prod");

throw를 컴파일 타임 검증에 쓰는 것은 흔한 관용구입니다. 상수 평가 중에 throw에 도달하면 그 식은 상수 표현식이 아니게 되고, consteval 호출이므로 곧바로 컴파일 에러로 드러납니다.

case 레이블은 상수 표현식이어야 하므로, 문자열 해시를 consteval 람다로 계산해 switch에 쓸 수 있습니다.

constexpr auto compile_time_hash = [](std::string_view sv) consteval {
    std::size_t hash = 0;
    for (char c : sv)
        hash = hash * 31 + static_cast<unsigned char>(c);
    return hash;
};

switch (runtime_event_id) {  // runtime_event_id도 같은 해시로 계산된 std::size_t
    case compile_time_hash("user_login"):
        handle_login();
        break;
    case compile_time_hash("user_logout"):
        handle_logout();
        break;
}

두 문자열의 해시가 우연히 같으면 case 레이블이 중복되어 컴파일 에러가 나므로, 충돌은 컴파일할 때 드러납니다. 다만 런타임 문자열이 등록되지 않은 다른 문자열인데 해시만 같은 경우는 잡아내지 못합니다.

C++23: Deducing This로 재귀 람다 해결

C++20까지 람다 재귀에는 우회법이 필요했습니다. 람다를 담은 변수는 초기화가 끝나기 전에는 타입이 정해지지 않으므로, 본문에서 그 이름을 쓸 수 없습니다.

// 컴파일 에러: 람다는 자기 이름을 볼 수 없음
// auto fact0 = [](int n) {
//     return n <= 1 ? 1 : n * fact0(n - 1);  // use of 'fact0' before deduction of 'auto'
// };

// 우회법 1: std::function (간접 호출)
std::function<int(int)> fact1 = [&](int n) -> int {
    return n <= 1 ? 1 : n * fact1(n - 1);
};

// 우회법 2: Y-combinator (자기 자신을 인자로 전달)
auto Y = [](auto f) {
    return [=](auto... args) {
        return f(f, args...);
    };
};
auto fact2 = Y([](auto self, int n) -> int {
    return n <= 1 ? 1 : n * self(self, n - 1);
});

C++23의 명시적 객체 매개변수(deducing this)를 쓰면 람다가 자기 자신을 첫 매개변수로 받을 수 있습니다.

auto factorial = [](this auto&& self, int n) -> int {
    return n <= 1 ? 1 : n * self(n - 1);
};

std::cout << factorial(5);  // 120

// 트리 순회
struct Node {
    int value;
    std::vector<Node*> children;
};

auto traverse = [](this auto&& self, const Node* node, int depth) -> void {
    std::cout << std::string(depth * 2, ' ') << node->value << '\n';
    for (const auto* child : node->children)
        self(child, depth + 1);
};

Node root{1, {new Node{2, {}}, new Node{3, {}}}};
traverse(&root, 0);

트리 예제의 new Node는 설명을 줄이기 위한 것이라 해제하지 않습니다. 실제 코드에서는 std::vector<std::unique_ptr<Node>> 같은 소유 구조를 쓰세요.

방법호출 비용코드 복잡도C++ 버전
std::function간접 호출, 인라인 어려움간단C++11+
Y-combinator / self 인자 전달인라인 가능호출부가 번거로움C++14+
Deducing this인라인 가능간단C++23

C++23을 쓸 수 있으면 deducing this가 가장 깔끔합니다. C++20 이하에서는 호출 빈도가 높은 곳이라면 self 인자 전달 방식을, 가독성이 우선이라면 std::function을 고르면 됩니다.

가장 단순한 self 인자 전달 형태는 람다가 자기 자신을 첫 인자로 받는 방식입니다. 호출할 때마다 factorial(factorial, 5)처럼 자기 자신을 넘겨야 해서 번거롭지만, std::function 없이 동작합니다.

auto factorial = [](auto self, int n) -> int {
    if (n <= 1) return 1;
    return n * self(self, n - 1);
};
std::cout << factorial(factorial, 5) << "\n";  // 120

deducing this는 재귀적인 디렉터리 탐색처럼 결과를 모아 반환하는 경우에도 그대로 쓸 수 있습니다.

namespace fs = std::filesystem;
auto find_files = [](this auto&& self, const fs::path& dir,
                     const std::string& ext) -> std::vector<fs::path> {
    std::vector<fs::path> results;
    for (const auto& entry : fs::directory_iterator(dir)) {
        if (entry.is_directory()) {
            auto sub = self(entry.path(), ext);
            results.insert(results.end(), sub.begin(), sub.end());
        } else if (entry.path().extension() == ext) {
            results.push_back(entry.path());
        }
    }
    return results;
};

auto cpp_files = find_files("/project/src", ".cpp");

이 예제는 재귀 람다를 보여 주기 위한 것이며, 실제로는 fs::recursive_directory_iterator 하나로 같은 일을 할 수 있습니다. 또 디렉터리 심볼릭 링크가 순환하면 이 재귀는 끝나지 않을 수 있습니다.

참고 자료


같이 보면 좋은 글