이름 없는 작은 함수가 필요한 순간
프로젝트가 조금만 커져도 "딱 여기에서만 쓸 짧은 동작"이 자주 등장합니다. 예를 들어 학생 목록을 점수순으로 정렬하고 싶습니다. 문자열 목록에서 길이가 5보다 긴 것만 찾고 싶습니다. 버튼이 눌렸을 때 실행할 코드를 등록하고 싶습니다. 이런 코드는 함수로 따로 빼기에는 너무 작고, 그렇다고 반복문 안에 계속 풀어 쓰기에는 의도가 잘 드러나지 않습니다.
이때 현대 C++이 꺼내는 도구가 람다(lambda)입니다. 람다는 필요한 위치에서 바로 정의하는 이름 없는 함수 객체입니다. 말은 조금 낯설지만 감각은 단순합니다. "지금 이 자리에서만 쓸 작은 함수를 즉석에서 만든다"라고 생각하면 됩니다.
#include <iostream>
int main() {
auto sayHello = []() {
std::cout << "Hello, lambda!n";
};
sayHello();
}[]() { ... } 부분이 람다입니다. 이 람다를 sayHello라는 변수에 담았고, 함수처럼 sayHello()로 호출했습니다.
처음 보면 "함수와 뭐가 다르지?"라는 생각이 들 수 있습니다. 차이는 위치와 목적에 있습니다. 일반 함수는 프로그램 어딘가에 이름을 붙여 선언하고 여러 곳에서 호출하기 좋습니다. 람다는 특정 문맥 안에서 짧게 만들고 바로 넘기기에 좋습니다.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores{80, 95, 70, 100, 60};
std::sort(scores.begin(), scores.end(), [](int a, int b) {
return a > b; // 내림차순
});
for (int score : scores) {
std::cout << score << ' ';
}
}여기에서 std::sort는 "두 값을 어떤 기준으로 비교할 것인가?"를 필요로 합니다. 그 기준을 람다로 바로 전달했습니다. 따로 compareScoreDescending 같은 함수를 만들 수도 있지만, 이 비교 기준이 이 정렬에서만 쓰인다면 람다가 더 자연스럽습니다.

람다의 기본 형태
람다의 기본 형태는 다음과 같습니다.
[capture](parameters) -> return_type {
body
}모든 부분을 항상 써야 하는 것은 아닙니다. 가장 단순한 람다는 캡처도 없고, 매개변수도 없고, 반환값도 없습니다.
auto printLine = []() {
std::cout << "----------------n";
};매개변수가 있으면 일반 함수처럼 괄호 안에 적습니다.
auto add = [](int a, int b) {
return a + b;
};
std::cout << add(3, 4) << 'n'; // 7반환 타입은 대개 컴파일러가 추론합니다. 위 예제에서 return a + b;의 결과가 int이므로 람다의 반환 타입도 int로 추론됩니다. 필요하면 직접 쓸 수도 있습니다.
auto divide = [](double a, double b) -> double {
return a / b;
};람다를 읽을 때는 왼쪽부터 차례대로 해석하면 됩니다. []는 바깥 변수를 가져오는 방식을 나타내는 캡처 목록입니다. ()는 매개변수 목록입니다. {} 안에는 실제 실행할 코드가 들어갑니다.
초보자가 가장 자주 헷갈리는 부분은 []입니다. 이 빈 대괄호는 "아무 변수도 바깥에서 가져오지 않겠다"는 뜻입니다.
int limit = 10;
// auto f = []() {
// std::cout << limit; // 오류: limit을 캡처하지 않았음
// };람다는 기본적으로 자신이 만들어진 주변의 지역 변수를 마음대로 사용할 수 없습니다. 사용하려면 명시적으로 캡처해야 합니다.
람다는 함수가 아니라 함수 객체다
람다는 겉으로는 함수처럼 호출됩니다. 하지만 C++에서 람다는 정확히 말하면 함수 객체(function object)를 만들어 내는 문법입니다. 함수 객체란 operator()를 가진 객체입니다. 즉, 객체인데 함수처럼 호출할 수 있는 객체입니다.
다음 코드를 봅시다.
auto twice = [](int x) {
return x * 2;
};
std::cout << twice(5) << 'n';twice는 함수 이름이 아니라 람다 객체를 담은 변수입니다. 이 객체가 twice(5)처럼 호출될 수 있을 뿐입니다.
이 차이는 사소해 보이지만 중요합니다. 람다가 객체이기 때문에 값을 가질 수 있고, 바깥 변수를 캡처해서 내부 상태처럼 들고 있을 수 있습니다.
int base = 10;
람다 auto addBase = [base](int x) {
return base + x;
};
std::cout << addBase(5) << 'n'; // 15위 코드에서 addBase는 base 값을 기억하는 작은 객체입니다. 이 감각이 람다를 이해하는 핵심입니다. 람다는 단순히 "이름 없는 함수"이기도 하지만, 현대 C++에서는 "필요한 상태를 함께 들고 다니는 작은 동작 객체"로 더 자주 사용됩니다.
다만 위 코드에는 오타처럼 보이는 불필요한 단어가 들어가면 안 됩니다. 실제 코드는 다음처럼 써야 합니다.
int base = 10;
auto addBase = [base](int x) {
return base + x;
};람다는 문법 기호가 조금 많기 때문에 처음에는 괄호와 대괄호 위치를 자주 틀립니다. [], (), {}의 역할을 나누어 보는 습관을 들이면 훨씬 편해집니다.
값 캡처: 그 순간의 값을 복사한다
람다의 첫 번째 대괄호 []는 캡처 목록입니다. 캡처란 람다 바깥에 있는 변수를 람다 안으로 가져오는 일입니다.
가장 안전하고 자주 쓰는 방식은 값 캡처입니다.
#include <iostream>
int main() {
int limit = 10;
auto isGreaterThanLimit = [limit](int value) {
return value > limit;
};
limit = 100;
std::cout << std::boolalpha;
std::cout << isGreaterThanLimit(50) << 'n'; // true
}조금 이상해 보일 수 있습니다. limit을 100으로 바꿨는데 50 > limit이 true입니다. 이유는 람다가 만들어질 때 limit의 값 10이 람다 안으로 복사되었기 때문입니다. 이후 바깥의 limit을 바꿔도 람다 내부에 복사된 값은 바뀌지 않습니다.
값 캡처는 안전합니다. 람다가 나중에 실행되더라도 자신이 필요한 값을 이미 들고 있기 때문입니다. 특히 콜백처럼 "언제 실행될지 모르는 코드"에서는 값 캡처가 큰 장점이 됩니다.
std::string prefix = "[LOG] ";
auto logger = [prefix](const std::string& message) {
std::cout << prefix << message << 'n';
};
logger("started");여기서 logger는 prefix 값을 자신의 내부에 복사해 둡니다. 바깥의 prefix가 사라지거나 바뀌어도, logger가 들고 있는 복사본은 유지됩니다.
단, 값 캡처는 복사를 의미합니다. 큰 객체를 값으로 캡처하면 비용이 들 수 있습니다. 예를 들어 매우 큰 std::vector를 값으로 캡처하면 람다 객체 안에 벡터가 복사됩니다. 의도한 것이라면 괜찮지만, 실수라면 성능 문제가 될 수 있습니다.
std::vector<int> data(1'000'000, 1);
// data 전체가 복사될 수 있음
auto countLarge = [data]() {
return data.size();
};이럴 때는 정말 복사가 필요한지 먼저 생각해야 합니다. 람다가 즉시 실행되고 바깥 객체의 수명이 확실하다면 참조 캡처를 고려할 수 있습니다.
참조 캡처: 바깥 변수를 그대로 바라본다
참조 캡처는 바깥 변수를 복사하지 않고 참조합니다. 캡처 목록에서 변수 이름 앞에 &를 붙입니다.
int count = 0;
auto increase = [&count]() {
++count;
};
increase();
increase();
std::cout << count << 'n'; // 2increase는 바깥의 count를 직접 수정합니다. 값 캡처였다면 람다 안의 복사본만 바뀌었겠지만, 참조 캡처이므로 원래 변수에 변화가 생깁니다.
참조 캡처는 복사 비용이 없고, 바깥 상태를 수정할 수 있어서 편리합니다. 하지만 그만큼 조심해야 합니다. 참조는 대상 객체가 살아 있을 때만 안전합니다.
#include <functional>
#include <iostream>
std::function<void()> makeCallback() {
int local = 42;
return [&local]() {
std::cout << local << 'n'; // 위험!
};
}
int main() {
auto callback = makeCallback();
callback(); // local은 이미 사라짐
}makeCallback 함수가 끝나면 지역 변수 local은 사라집니다. 그런데 반환된 람다는 local을 참조하고 있습니다. 이것은 이미 철거된 건물의 주소를 들고 찾아가는 것과 같습니다. 실행 결과가 우연히 정상처럼 보일 수도 있지만, 프로그램은 잘못된 상태입니다.
이런 문제를 댕글링 참조(dangling reference)라고 합니다. 참조가 가리키는 대상이 이미 사라진 상태를 뜻합니다.
콜백으로 저장되거나 함수 밖으로 반환되는 람다에서는 참조 캡처를 특히 조심해야 합니다. 이 경우에는 값 캡처가 더 안전한 선택인 경우가 많습니다.
std::function<void()> makeCallback() {
int local = 42;
return [local]() {
std::cout << local << 'n'; // 안전: 값이 복사됨
};
}람다 캡처를 선택할 때는 다음 기준을 기억하면 좋습니다.
람다가 즉시 실행되고 바깥 변수의 수명이 확실하면 참조 캡처를 사용할 수 있습니다.
람다가 나중에 실행되거나 저장될 수 있으면 값 캡처를 우선 고려합니다.
바깥 변수를 수정해야 한다면 참조 캡처가 필요하지만, 수정 범위를 작게 유지해야 합니다.
기본 캡처와 명시적 캡처
캡처할 변수가 많으면 하나씩 쓰기 번거로울 수 있습니다. C++은 기본 캡처를 제공합니다.
int minScore = 60;
int bonus = 5;
std::vector<int> scores{55, 60, 70, 90};
int count = std::count_if(scores.begin(), scores.end(), [=](int score) {
return score + bonus >= minScore;
});[=]는 람다 안에서 사용하는 바깥 변수를 값으로 캡처하겠다는 뜻입니다.
반대로 [&]는 사용하는 바깥 변수를 참조로 캡처합니다.
int total = 0;
std::for_each(scores.begin(), scores.end(), [&](int score) {
total += score;
});기본 캡처는 편리하지만, 초보자에게는 양날의 검입니다. 람다 안에서 어떤 변수가 들어오는지 한눈에 보이지 않기 때문입니다. 짧은 람다에서는 괜찮지만, 람다 본문이 길어지면 의존 관계가 흐려집니다.
다음처럼 필요한 것만 명시하면 더 읽기 쉽습니다.
int minScore = 60;
int bonus = 5;
int count = std::count_if(scores.begin(), scores.end(), [minScore, bonus](int score) {
return score + bonus >= minScore;
});값 캡처와 참조 캡처를 섞을 수도 있습니다.
int limit = 80;
int passed = 0;
std::for_each(scores.begin(), scores.end(), [limit, &passed](int score) {
if (score >= limit) {
++passed;
}
});여기서 limit은 읽기만 하므로 값으로 캡처했고, passed는 증가시켜야 하므로 참조로 캡처했습니다. 이렇게 캡처 목록을 보면 람다가 무엇을 읽고 무엇을 바꾸는지 바로 드러납니다.
실무에서는 짧고 명확한 람다라면 [&]나 [=]를 쓰기도 합니다. 하지만 처음에는 명시적 캡처를 습관으로 삼는 편이 좋습니다. 람다의 힘은 간결함에 있지만, 간결함이 의도를 숨기기 시작하면 오히려 독이 됩니다.
값 캡처한 변수를 바꾸고 싶다면 mutable
값 캡처한 변수는 기본적으로 람다 안에서 수정할 수 없습니다.
int count = 0;
// auto f = [count]() {
// ++count; // 오류
// };값으로 캡처한 count는 람다 객체 안에 복사됩니다. 그런데 람다의 호출 연산자는 기본적으로 const처럼 동작하므로 내부 복사본을 수정할 수 없습니다. 내부 복사본을 바꾸고 싶다면 mutable을 붙입니다.
int count = 0;
auto f = [count]() mutable {
++count;
std::cout << "inside: " << count << 'n';
};
f(); // inside: 1
f(); // inside: 2
std::cout << "outside: " << count << 'n'; // outside: 0중요한 점은 바깥의 count가 바뀌지 않는다는 것입니다. mutable은 값으로 캡처한 람다 내부의 복사본을 바꿀 수 있게 해 줄 뿐입니다.
이 기능은 상태를 가진 작은 함수 객체를 만들 때 유용합니다.
auto makeCounter() {
int value = 0;
return [value]() mutable {
return ++value;
};
}
int main() {
auto counter = makeCounter();
std::cout << counter() << 'n'; // 1
std::cout << counter() << 'n'; // 2
std::cout << counter() << 'n'; // 3
}makeCounter가 끝나도 람다 객체 안에 value 복사본이 남아 있습니다. 이 람다는 호출될 때마다 자신의 내부 값을 증가시킵니다. 작은 카운터 객체를 클래스로 만들 수도 있지만, 이 정도 규모라면 람다가 더 간단합니다.
매개변수와 반환 타입
람다는 일반 함수처럼 매개변수를 받을 수 있습니다.
auto isEven = [](int n) {
return n % 2 == 0;
};
std::cout << isEven(10) << 'n'; // 1반환 타입은 보통 자동으로 추론됩니다. 하지만 여러 return 문이 있고 타입이 애매하면 직접 지정하는 편이 좋습니다.
auto safeDivide = [](int a, int b) -> double {
if (b == 0) {
return 0.0;
}
return static_cast<double>(a) / b;
};-> double은 이 람다가 double을 반환한다는 뜻입니다. 일반 함수에서 반환 타입을 앞에 쓰는 것과 달리, 람다에서는 매개변수 목록 뒤에 화살표로 씁니다. 이런 형태를 후행 반환 타입(trailing return type)이라고 부릅니다.
C++14 이후로는 람다 매개변수에도 auto를 사용할 수 있습니다. 이를 흔히 제네릭 람다라고 부릅니다.
auto print = [](const auto& value) {
std::cout << value << 'n';
};
print(10);
print(3.14);
print(std::string{"hello"});const auto&는 어떤 타입이든 const 참조로 받겠다는 뜻입니다. 템플릿 함수와 비슷한 효과를 냅니다. 짧은 출력 함수나 비교 함수, 변환 함수에서 매우 유용합니다.
다만 auto가 모든 것을 해결해 주지는 않습니다. 타입이 드러나지 않아 읽기 어려운 경우도 있습니다. 예를 들어 알고리즘에 넘기는 짧은 조건식에서는 auto가 편하지만, 복잡한 비즈니스 로직을 처리하는 긴 람다라면 매개변수 타입을 명시하는 쪽이 더 나을 수 있습니다.
STL 알고리즘과 람다
람다가 가장 빛나는 곳은 STL 알고리즘입니다. 알고리즘은 "어떻게 순회할 것인가?"를 이미 알고 있습니다. 우리가 제공해야 하는 것은 "무엇을 기준으로 판단할 것인가?", "어떻게 바꿀 것인가?", "무엇을 찾을 것인가?"입니다. 람다는 이 작은 정책을 전달하기에 딱 맞습니다.
정렬 기준 전달하기
학생 목록을 점수순으로 정렬해 봅시다.
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct Student {
std::string name;
int score;
};
int main() {
std::vector<Student> students{
{"Jisoo", 82},
{"Minjun", 95},
{"Seoyeon", 95},
{"Doyun", 70}
};
std::sort(students.begin(), students.end(), [](const Student& a, const Student& b) {
return a.score > b.score;
});
for (const auto& student : students) {
std::cout << student.name << ": " << student.score << 'n';
}
}std::sort의 세 번째 인자는 비교 함수입니다. a가 b보다 앞에 와야 하면 true를 반환합니다. 위 람다는 점수가 높은 학생이 앞에 오도록 만듭니다.
점수가 같을 때 이름순으로 정렬하고 싶다면 조건을 조금 더 넣습니다.
std::sort(students.begin(), students.end(), [](const Student& a, const Student& b) {
if (a.score != b.score) {
return a.score > b.score;
}
return a.name < b.name;
});이 비교 기준은 "점수 내림차순, 점수가 같으면 이름 오름차순"입니다. 람다를 사용하면 정렬 기준이 정렬 코드 바로 옆에 붙어 있어서 읽기 쉽습니다.
조건으로 찾기
std::find는 값이 정확히 같은 원소를 찾을 때 사용합니다. 하지만 "점수가 90점 이상인 첫 학생"처럼 조건으로 찾고 싶다면 std::find_if를 사용합니다.
auto it = std::find_if(students.begin(), students.end(), [](const Student& student) {
return student.score >= 90;
});
if (it != students.end()) {
std::cout << "first high score: " << it->name << 'n';
}람다의 반환값은 bool입니다. 조건을 만족하면 true, 아니면 false를 반환합니다. 이런 식의 람다를 흔히 조건자(predicate)라고 부릅니다.
조건자 람다는 count_if, remove_if, all_of, any_of, none_of 같은 알고리즘에서도 자주 등장합니다.
int highScoreCount = std::count_if(students.begin(), students.end(), [](const Student& student) {
return student.score >= 90;
});이 코드는 "90점 이상인 학생 수를 센다"는 의도를 거의 그대로 읽을 수 있습니다. 반복문을 직접 쓰면 인덱스, 조건, 증가 로직이 섞이지만, 알고리즘과 람다를 함께 쓰면 코드가 더 선언적으로 변합니다. 선언적이라는 말은 "어떻게 반복할지"보다 "무엇을 원하는지"가 더 잘 보인다는 뜻입니다.
변환 로직 전달하기
std::transform은 원소를 다른 값으로 변환할 때 사용합니다.
std::vector<int> scores{80, 95, 70};
std::vector<std::string> labels;
std::transform(scores.begin(), scores.end(), std::back_inserter(labels), [](int score) {
if (score >= 90) {
return std::string{"A"};
}
if (score >= 80) {
return std::string{"B"};
}
return std::string{"C"};
});각 점수를 등급 문자열로 바꾸는 로직을 람다로 전달했습니다. std::back_inserter는 결과를 labels 뒤에 계속 추가하게 해 주는 반복자 어댑터입니다.
간단한 숫자 변환도 람다와 잘 어울립니다.
std::vector<int> numbers{1, 2, 3, 4};
std::vector<int> doubled;
std::transform(numbers.begin(), numbers.end(), std::back_inserter(doubled), [](int n) {
return n * 2;
});반복문으로 써도 되지만, transform을 보면 "이 코드는 각 원소를 변환한다"는 목적이 바로 드러납니다.
삭제 조건 전달하기
벡터에서 조건에 맞는 원소를 삭제할 때는 std::remove_if와 erase를 함께 쓰는 패턴을 자주 사용합니다.
std::vector<int> scores{100, 40, 75, 30, 90};
scores.erase(
std::remove_if(scores.begin(), scores.end(), [](int score) {
return score < 60;
}),
scores.end()
);이 코드는 60점 미만인 점수를 제거합니다. remove_if는 실제 컨테이너 크기를 줄이지 않고, 남길 원소들을 앞쪽으로 모읍니다. 그래서 뒤쪽의 불필요한 구간을 erase로 잘라야 합니다. 이 패턴은 처음에는 이상하지만, STL에서 매우 자주 보게 됩니다.
람다의 역할은 명확합니다. "무엇을 제거할 것인가?"라는 조건을 표현합니다.
캡처와 알고리즘을 함께 쓰기
실제 코드에서는 조건값이 고정되어 있지 않은 경우가 많습니다. 예를 들어 사용자가 입력한 기준 점수 이상인 학생만 세고 싶다고 해 봅시다.
int threshold = 85;
int count = std::count_if(students.begin(), students.end(), [threshold](const Student& student) {
return student.score >= threshold;
});threshold를 값으로 캡처했습니다. 이 람다는 "기준 점수 이상인가?"라는 조건을 들고 알고리즘으로 들어갑니다.
만약 결과를 외부 변수에 누적하고 싶다면 참조 캡처를 사용할 수도 있습니다.
int total = 0;
std::for_each(students.begin(), students.end(), [&total](const Student& student) {
total += student.score;
});하지만 누적에는 알고리즘 라이브러리의 다른 도구가 더 적합한 경우도 있습니다. 예를 들어 std::accumulate를 사용할 수 있습니다.
#include <numeric>
int total = std::accumulate(students.begin(), students.end(), 0, [](int sum, const Student& student) {
return sum + student.score;
});람다가 있다고 해서 무조건 모든 일을 람다 안에 밀어 넣어야 하는 것은 아닙니다. 알고리즘이 이미 제공하는 의미를 잘 고르고, 람다는 그 알고리즘에 필요한 작은 정책만 전달하는 편이 좋습니다.
콜백으로 사용하는 람다
람다는 STL 알고리즘뿐 아니라 콜백에서도 많이 사용됩니다. 콜백(callback)은 "지금 실행하지 않고, 어떤 일이 일어났을 때 나중에 호출할 함수"입니다.
예를 들어 간단한 버튼 클래스를 생각해 봅시다.
#include <functional>
#include <iostream>
#include <string>
class Button {
public:
void setOnClick(std::function<void()> callback) {
onClick_ = std::move(callback);
}
void click() {
if (onClick_) {
onClick_();
}
}
private:
std::function<void()> onClick_;
};
int main() {
Button saveButton;
saveButton.setOnClick([]() {
std::cout << "save button clickedn";
});
saveButton.click();
}std::function<void()>은 "매개변수가 없고 반환값도 없는 호출 가능한 대상"을 저장할 수 있는 표준 라이브러리 타입입니다. 일반 함수, 함수 객체, 람다를 담을 수 있습니다.
이제 버튼을 누를 때 특정 파일명을 출력하고 싶다고 해 봅시다.
std::string fileName = "config.txt";
saveButton.setOnClick([fileName]() {
std::cout << "saving " << fileName << 'n';
});여기서는 값 캡처가 자연스럽습니다. 버튼 콜백은 나중에 실행될 수 있기 때문입니다. 만약 fileName을 참조로 캡처했는데 버튼이 눌릴 때 이미 fileName이 사라졌다면 문제가 됩니다.
반대로 콜백이 어떤 객체의 상태를 바꿔야 할 때도 있습니다.
int clickCount = 0;
saveButton.setOnClick([&clickCount]() {
++clickCount;
std::cout << "clicked: " << clickCount << 'n';
});이 코드는 clickCount가 버튼보다 오래 살아 있다는 확신이 있을 때만 안전합니다. 작은 main 함수 안에서는 문제가 없어 보이지만, 실제 GUI 프로그램이나 게임 엔진에서는 콜백의 수명이 훨씬 복잡해질 수 있습니다.
콜백 람다를 작성할 때는 "이 람다는 언제 실행되는가?"를 먼저 질문해야 합니다. 즉시 실행되는 람다와 저장되었다가 나중에 실행되는 람다는 안전 기준이 다릅니다.
this 캡처와 객체 수명
클래스 멤버 함수 안에서 람다를 만들면 멤버 변수에 접근하고 싶을 때가 있습니다. 이때 this를 캡처할 수 있습니다.
#include <functional>
#include <iostream>
#include <string>
class Dialog {
public:
Dialog(std::string title) : title_(std::move(title)) {}
std::function<void()> makePrinter() {
return [this]() {
std::cout << title_ << 'n';
};
}
private:
std::string title_;
};[this]는 현재 객체의 주소를 람다 안에 저장합니다. 그래서 람다 안에서 title_ 같은 멤버에 접근할 수 있습니다.
하지만 여기에도 수명 문제가 있습니다.
std::function<void()> callback;
{
Dialog dialog{"Settings"};
callback = dialog.makePrinter();
}
callback(); // 위험: dialog 객체는 이미 사라짐this 캡처는 객체 자체를 복사하는 것이 아니라 객체의 주소를 들고 있는 것입니다. 객체가 사라진 뒤 람다가 실행되면 위험합니다.
객체의 값이 필요하다면 필요한 멤버만 값으로 복사해 캡처하는 편이 더 안전할 수 있습니다.
class Dialog {
public:
Dialog(std::string title) : title_(std::move(title)) {}
std::function<void()> makePrinter() {
std::string title = title_;
return [title]() {
std::cout << title << 'n';
};
}
private:
std::string title_;
};이제 반환된 람다는 Dialog 객체가 사라져도 title 복사본을 가지고 있습니다.
스마트 포인터를 사용하는 객체라면 std::shared_ptr나 std::weak_ptr와 결합해 수명을 관리하는 패턴도 있습니다. 이 내용은 실전 프로젝트와 더 큰 설계에서 자주 등장합니다. 지금 단계에서는 하나만 기억하면 충분합니다. this 캡처는 편리하지만, 람다가 객체보다 오래 살 수 있다면 위험합니다.
람다와 auto
람다의 실제 타입은 컴파일러가 만들어 내는 익명의 타입입니다. 우리는 그 타입 이름을 직접 쓸 수 없습니다. 그래서 람다를 변수에 담을 때는 보통 auto를 사용합니다.
auto square = [](int n) {
return n * n;
};auto는 "컴파일러가 알아서 타입을 정해 달라"는 뜻입니다. 람다와 auto는 거의 한 쌍처럼 자주 등장합니다.
함수 매개변수로 람다를 받고 싶을 때는 몇 가지 방법이 있습니다. 가장 단순한 방법은 std::function을 사용하는 것입니다.
#include <functional>
void repeat(int times, const std::function<void(int)>& action) {
for (int i = 0; i < times; ++i) {
action(i);
}
}
int main() {
repeat(3, [](int index) {
std::cout << "index: " << index << 'n';
});
}std::function<void(int)>은 int 하나를 받고 반환값이 없는 호출 가능한 대상을 의미합니다. 이해하기 쉽고 콜백 저장에도 유용합니다.
성능이 중요하거나 템플릿을 사용할 수 있는 상황에서는 함수 템플릿으로 받을 수도 있습니다.
template <typename Func>
void repeat(int times, Func action) {
for (int i = 0; i < times; ++i) {
action(i);
}
}이 방식은 std::function보다 더 직접적이고 최적화에 유리한 경우가 많습니다. 다만 템플릿 문법이 들어가므로 코드 구조에 따라 선택해야 합니다.
처음에는 이렇게 이해하면 됩니다. 람다를 변수에 담을 때는 auto, 여러 종류의 콜백을 저장해야 할 때는 std::function, 성능과 제네릭 코드가 중요할 때는 템플릿이 자주 사용됩니다.
즉시 실행 람다
람다는 만들자마자 바로 실행할 수도 있습니다.
int value = []() {
int a = 10;
int b = 20;
return a + b;
}();
std::cout << value << 'n'; // 30마지막의 ()가 람다를 즉시 호출합니다. 이런 코드는 복잡한 초기화 로직을 하나의 표현식으로 묶고 싶을 때 사용됩니다.
예를 들어 설정값에 따라 초기 점수를 계산한다고 해 봅시다.
bool premiumUser = true;
int level = 3;
int startPoint = [premiumUser, level]() {
int point = 100;
if (premiumUser) {
point += 50;
}
point += level * 10;
return point;
}();반복문이나 조건문이 필요한 초기화도 람다 안에 넣고 결과만 받을 수 있습니다. 다만 즉시 실행 람다가 너무 길어지면 독자가 "왜 굳이 이렇게 썼지?"라고 느낄 수 있습니다. 복잡한 로직이라면 이름 있는 함수로 분리하는 편이 더 좋습니다.
람다를 남용하지 않는 법
람다는 코드를 짧고 강력하게 만들어 줍니다. 그러나 강력한 도구일수록 남용하기 쉽습니다. 람다가 많아지면 코드가 마치 작은 익명 함수들의 미로처럼 보일 수 있습니다.
다음 코드는 람다 자체가 나쁜 것은 아니지만, 읽기가 쉽지 않습니다.
auto result = process(data, [limit, &logger](const auto& item) {
return transform(item, [limit](const auto& value) {
return value.score > limit ? value.name : std::string{};
}, [&logger](const auto& message) {
logger.write(message);
});
});람다가 중첩되고, 캡처가 섞이고, 내부에서 또 다른 람다를 넘기면 흐름을 따라가기 어려워집니다. 이럴 때는 일부 로직에 이름을 붙이는 것이 좋습니다.
auto isHighScore = [limit](const Student& student) {
return student.score > limit;
};또는 아예 함수로 분리할 수도 있습니다.
bool isPassingScore(const Student& student) {
return student.score >= 60;
}람다와 일반 함수 중 무엇을 선택할지는 코드의 수명과 재사용성에 따라 결정하면 됩니다.
특정 알고리즘 호출 한 곳에서만 쓰는 짧은 조건은 람다가 잘 어울립니다.
여러 곳에서 반복되는 규칙은 이름 있는 함수로 분리하는 편이 좋습니다.
캡처가 많고 본문이 길어지는 람다는 별도 함수나 클래스로 옮기는 것이 좋습니다.
람다의 목적은 코드를 숨기는 것이 아니라 의도를 가까운 곳에 놓는 것입니다. 정렬 기준은 정렬 코드 옆에, 필터 조건은 필터 알고리즘 옆에, 콜백 동작은 콜백 등록 코드 옆에 두면 읽기 쉽습니다. 반대로 람다가 너무 길어져 주변 코드를 밀어내기 시작하면 이미 역할을 넘어서고 있는 것입니다.
작은 실전 예제: 작업 목록 필터링과 정렬
이제 람다를 이용해 간단한 작업 목록을 다뤄 봅시다. 앞으로 만들 할 일 관리 프로그램에서도 비슷한 패턴을 사용할 수 있습니다.
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct Task {
std::string title;
int priority;
bool done;
};
void printTasks(const std::vector<Task>& tasks) {
for (const auto& task : tasks) {
std::cout << "[" << (task.done ? 'x' : ' ') << "] "
<< task.title << " (priority: " << task.priority << ")n";
}
}
int main() {
std::vector<Task> tasks{
{"write report", 2, false},
{"fix bug", 5, false},
{"read email", 1, true},
{"prepare meeting", 4, false}
};
int minimumPriority = 3;
std::cout << "important unfinished tasksn";
std::vector<Task> filtered;
std::copy_if(tasks.begin(), tasks.end(), std::back_inserter(filtered),
[minimumPriority](const Task& task) {
return !task.done && task.priority >= minimumPriority;
}
);
std::sort(filtered.begin(), filtered.end(), [](const Task& a, const Task& b) {
return a.priority > b.priority;
});
printTasks(filtered);
}이 예제에서 람다는 두 번 등장합니다. 첫 번째 람다는 copy_if에 전달되어 "완료되지 않았고 우선순위가 기준 이상인 작업"만 고릅니다. 두 번째 람다는 sort에 전달되어 우선순위가 높은 작업이 앞에 오도록 정렬합니다.
람다를 사용하지 않았다면 필터 조건 함수와 정렬 함수가 따로 필요했을 것입니다. 물론 그 함수들이 여러 곳에서 재사용된다면 분리하는 편이 좋습니다. 하지만 이 예제처럼 특정 흐름 안에서만 쓰는 기준이라면 람다가 코드를 더 응집력 있게 만듭니다.
여기서 중요한 점은 람다 본문이 짧다는 것입니다. 조건 하나, 비교 하나. 람다가 가장 읽기 좋은 순간은 바로 이럴 때입니다.
람다를 읽는 연습
람다는 처음 배울 때 문법이 복잡해 보입니다. 하지만 다음 순서로 읽으면 어렵지 않습니다.
[minimumPriority](const Task& task) {
return !task.done && task.priority >= minimumPriority;
}먼저 [minimumPriority]를 봅니다. 바깥 변수 minimumPriority를 값으로 가져옵니다. 그다음 (const Task& task)를 봅니다. Task 객체를 const 참조로 하나 받습니다. 마지막으로 본문을 봅니다. 완료되지 않았고 우선순위가 기준 이상이면 true를 반환합니다.
즉, 이 람다는 "이 작업을 선택할 것인가?"를 판단하는 조건자입니다.
이번에는 정렬 람다를 읽어 봅시다.
[](const Task& a, const Task& b) {
return a.priority > b.priority;
}[]이므로 바깥 변수를 사용하지 않습니다. Task 두 개를 받아서 a가 b보다 앞에 와야 하는지 판단합니다. a.priority > b.priority이므로 우선순위가 큰 작업이 앞에 옵니다.
람다를 읽을 때는 문법 전체를 한 번에 이해하려고 하지 말고 캡처, 매개변수, 본문을 나누어 보면 됩니다.
람다가 현대 C++에서 중요한 이유
현대 C++은 표준 라이브러리와 함께 사용할 때 힘이 커집니다. vector, map, sort, find_if, transform, remove_if 같은 도구들은 이미 많은 반복과 처리를 대신해 줍니다. 여기에 람다를 더하면 사용자가 원하는 기준과 동작을 짧게 끼워 넣을 수 있습니다.
객체지향 프로그래밍에서는 클래스로 역할을 나누고 상태를 캡슐화했습니다. 템플릿과 STL에서는 타입과 알고리즘을 분리했습니다. 람다는 이 둘 사이의 빈틈을 메웁니다. 작은 동작을 객체처럼 전달할 수 있고, 필요한 상태를 캡처할 수 있으며, 알고리즘과 이벤트 흐름을 자연스럽게 연결합니다.
그래서 현대 C++ 코드를 읽다 보면 람다는 피할 수 없는 문법입니다. 정렬 기준, 필터 조건, 변환 로직, 이벤트 콜백, 지연 실행 코드에서 계속 등장합니다.
하지만 좋은 람다 코드는 짧고 분명합니다. 캡처 목록은 의존성을 보여 주고, 매개변수는 입력을 보여 주며, 본문은 하나의 작은 의도를 표현합니다. 이 균형을 지키면 람다는 코드를 어렵게 만드는 장식이 아니라, 코드의 의도를 가장 가까운 곳에서 밝혀 주는 조명이 됩니다.
정리
람다는 필요한 위치에서 바로 정의하는 이름 없는 함수 객체입니다. auto와 함께 변수에 담아 호출할 수 있고, STL 알고리즘이나 콜백에 전달해 짧은 동작을 표현할 수 있습니다.
값 캡처는 람다가 만들어지는 순간의 값을 복사하므로 안전한 경우가 많지만, 큰 객체를 복사할 수 있습니다. 참조 캡처는 복사 비용이 없고 바깥 변수를 수정할 수 있지만, 대상 객체의 수명이 끝나면 위험합니다. 특히 콜백처럼 나중에 실행되는 람다에서는 캡처 수명을 반드시 생각해야 합니다.
STL 알고리즘과 람다는 매우 잘 어울립니다. 정렬 기준은 std::sort에, 필터 조건은 std::find_if나 std::copy_if에, 변환 로직은 std::transform에 람다로 전달할 수 있습니다. 이 패턴을 익히면 반복문을 직접 쓰는 코드보다 의도가 더 선명한 코드를 작성할 수 있습니다.
다음 장에서는 타입 안전성을 높이는 또 하나의 현대 C++ 문법인 enum class를 살펴봅니다. 람다가 "동작을 안전하고 가깝게 표현하는 도구"라면, enum class는 "상태와 선택지를 타입으로 안전하게 표현하는 도구"입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
