C++ 알고리즘과 람다 - sort find_if ranges 와 캡처 수명 (C++ 객체와 자원 관리 7장)
이 장에서 배우는 것
반복문으로 짠 코드는 읽는 사람이 루프 본문을 따라가며 "이게 찾기인지, 세기인지, 걸러 내기인지"를 추론해야 한다. 표준 알고리즘은 그 의도를 함수 이름으로 드러낸다. find_if는 찾기, count_if는 세기, sort는 정렬이다. 조건은 람다로 그 자리에서 넘긴다. 람다는 편하지만 바깥 변수를 붙잡는(캡처) 방식에 따라 수명 버그를 만든다.
- 센서 기록을
stable_sort,ranges::sort(투영),find_if,count_if,accumulate,transform으로 다룬다. - 값 캡처
[x], 참조 캡처[&x], 초기화 캡처[n = 0],mutable의 차이를 익힌다. - 참조로 캡처한 람다가 그 변수보다 오래 살아서 엉뚱한 값을 읽는 버그를 실제로 재현한다.
문제 상황
로봇이 틱(tick)마다 온도(temp)와 전압(volt)을 기록한다. 필요한 작업은 이렇다. 값이 큰 순으로 정렬해 보기, 시간 순으로 다시 정렬하기, 처음으로 과열(50도 초과)된 기록 찾기, 온도 평균 구하기, 경보 개수 세기, 화면 표시용 라벨 만들기. 반복문으로 짜면 비슷하게 생긴 for가 여섯 개 생기고, 그중 하나에서 인덱스를 잘못 쓰면 찾기 어렵다.
또 하나의 문제는 이벤트 처리다. 설정 함수에서 이벤트 처리기(콜백)를 등록해 두고, 나중에 이벤트가 오면 부른다. 콜백이 설정 함수의 지역 변수를 참조로 붙잡으면, 부를 때쯤에는 그 변수가 없다.
완성 코드
algo.cpp로 저장한다.
#include <algorithm>
#include <iostream>
#include <numeric>
#include <string>
#include <vector>
struct Reading {
std::string sensor;
int tick;
double value;
};
void print(const char* label, const std::vector<Reading>& rs) {
std::cout << label << '\n';
for (const auto& r : rs) std::cout << " " << r.sensor << " t=" << r.tick << " v=" << r.value << '\n';
}
int main() {
std::vector<Reading> log{
{"temp", 3, 41.5}, {"volt", 1, 11.9}, {"temp", 1, 39.0},
{"volt", 2, 12.4}, {"temp", 2, 55.2}, {"volt", 3, 12.4},
};
std::stable_sort(log.begin(), log.end(),
[](const Reading& a, const Reading& b) { return a.value > b.value; });
print("값 내림차순(stable_sort):", log);
std::ranges::sort(log, {}, &Reading::tick);
print("tick 오름차순(ranges::sort + 투영):", log);
double limit = 50.0;
auto hot = std::ranges::find_if(log, [limit](const Reading& r) { return r.value > limit; });
if (hot != log.end()) std::cout << "첫 과열: " << hot->sensor << " t=" << hot->tick << '\n';
auto temps = std::ranges::count_if(log, [](const Reading& r) { return r.sensor == "temp"; });
double sum = std::accumulate(log.begin(), log.end(), 0.0,
[](double acc, const Reading& r) { return r.sensor == "temp" ? acc + r.value : acc; });
std::cout << "temp 평균: " << sum / temps << " (" << temps << "개)\n";
int alarms = 0;
std::ranges::for_each(log, [&alarms, limit](const Reading& r) {
if (r.value > limit) ++alarms;
});
std::cout << "경보 수: " << alarms << '\n';
std::vector<std::string> labels(log.size());
std::ranges::transform(log, labels.begin(),
[n = 0](const Reading& r) mutable { return std::to_string(++n) + ":" + r.sensor; });
std::cout << "라벨:";
for (const auto& l : labels) std::cout << ' ' << l;
std::cout << '\n';
}
줄별 해설
람다의 모양
[캡처](매개변수) { 본문 }이다. 컴파일러는 람다마다 이름 없는 클래스를 만들고, 캡처한 변수를 그 클래스의 멤버로, 본문을 operator()로 넣는다. 그래서 람다는 "함수처럼 부를 수 있는 작은 객체"이고, 2장과 4장에서 본 수명·복사 규칙이 그대로 적용된다.
std::stable_sort(..., [](const Reading& a, const Reading& b) { return a.value > b.value; })
비교 함수는 "a가 b보다 앞에 와야 하면 true"를 돌려준다. >를 썼으니 내림차순이다. stable_sort는 비교 결과가 같은 원소(12.4인 전압 두 개)의 원래 순서를 유지한다. 비교 함수에 >=를 쓰면 안 된다. 같은 값에서 양쪽 모두 true가 되어 정렬 알고리즘의 전제(엄격한 약순서)가 깨지고, 구현에 따라 범위 밖 접근까지 일어날 수 있다.
std::ranges::sort(log, {}, &Reading::tick) — 투영
C++20 ranges 알고리즘은 컨테이너를 통째로 받고(begin/end를 따로 쓰지 않는다), 세 번째 인자로 투영(projection)을 받는다. "비교하기 전에 각 원소에서 tick을 꺼내라"는 뜻이다. 두 번째 인자 {}는 기본 비교(std::ranges::less)를 쓰겠다는 표시다. 비교 람다를 직접 쓰는 것보다 짧고 실수할 곳이 적다. sort는 안정 정렬이 아니므로 tick이 같은 temp와 volt의 순서는 보장되지 않는다.
[limit](const Reading& r) { return r.value > limit; } — 값 캡처
limit의 값을 람다 객체 안에 복사해 둔다. 람다를 만든 뒤 바깥 limit을 바꿔도 람다 안의 값은 그대로다. 람다가 만든 곳보다 오래 살아도 안전하다.
std::accumulate(..., 0.0, ...)
초깃값의 타입이 누적 타입을 정한다. 0이라고 쓰면 int로 누적되어 소수점이 잘린다. 실수를 더할 때는 반드시 0.0이다.
[&alarms, limit] — 참조 캡처
alarms는 람다 밖의 변수를 직접 바꿔야 하므로 참조로 붙잡았다. for_each는 이 줄에서 람다를 다 쓰고 끝나므로 alarms보다 오래 살 일이 없다. 참조 캡처는 이렇게 그 자리에서 바로 쓰고 버리는 람다에서만 안전하다.
[n = 0](const Reading& r) mutable — 초기화 캡처
람다 안에서만 쓰는 새 멤버 n을 0으로 만든다. 람다의 operator()는 기본이 const 멤버 함수(1장)라 멤버를 바꿀 수 없는데, mutable을 붙이면 바꿀 수 있다. 호출할 때마다 ++n이 되어 1부터 번호가 붙는다.
실제 실행 결과
$ clang++ -std=c++20 -Wall -Wextra algo.cpp -o algo && ./algo
값 내림차순(stable_sort):
temp t=2 v=55.2
temp t=3 v=41.5
temp t=1 v=39
volt t=2 v=12.4
volt t=3 v=12.4
volt t=1 v=11.9
tick 오름차순(ranges::sort + 투영):
temp t=1 v=39
volt t=1 v=11.9
temp t=2 v=55.2
volt t=2 v=12.4
temp t=3 v=41.5
volt t=3 v=12.4
첫 과열: temp t=2
temp 평균: 45.2333 (3개)
경보 수: 1
라벨: 1:temp 2:volt 3:temp 4:volt 5:temp 6:volt
값 내림차순에서 12.4인 두 전압이 원래 순서(t=2, t=3)를 지켰다. tick 정렬 뒤 find_if는 시간상 처음 과열된 t=2의 온도를 찾았다. 온도 세 개의 평균은 (39 + 55.2 + 41.5) / 3 = 45.2333이다.
참조 캡처가 함수보다 오래 살면
#include <functional>
#include <iostream>
#include <vector>
std::vector<std::function<void()>> handlers;
void register_handlers() {
int threshold = 50;
handlers.push_back([&threshold] { std::cout << "기준값(참조 캡처): " << threshold << std::endl; });
handlers.push_back([threshold] { std::cout << "기준값(값 캡처): " << threshold << std::endl; });
}
void busy_work() {
volatile int noise[64];
for (int i = 0; i < 64; ++i) noise[i] = 7777;
}
int main() {
register_handlers();
busy_work();
for (auto& h : handlers) h();
}
$ clang++ -std=c++20 -Wall -Wextra lambda_dangling.cpp -o lambda_dangling && ./lambda_dangling
기준값(참조 캡처): 7777
기준값(값 캡처): 50
두 람다 모두 기준값 50을 붙잡았는데, 참조 캡처 쪽은 7777을 출력했다. threshold는 register_handlers의 지역 변수라서 함수가 끝나면 사라진다. 람다 안의 참조는 그 빈자리를 가리키고, 다음에 불린 busy_work가 같은 스택 자리에 7777을 채워 넣었다. 1장의 매달린 참조와 같은 버그인데, std::function에 담기면 컴파일러가 경고하지 못한다. 이번에는 7777이라는 눈에 띄는 값이 나왔지만, 실제 코드라면 그럴듯한 숫자가 나와 한참 뒤에야 발견된다.
표 7-1. 람다 캡처 방식
| 캡처 | 뜻 | 안전한 경우 |
|---|---|---|
[x] | 값 복사 | 대부분. 람다가 오래 살아도 안전 |
[&x] | 참조 | 그 자리에서 바로 쓰고 버리는 람다(알고리즘 인자) |
[=], [&] | 쓰는 것 전부 값/참조 | 짧은 람다. 무엇을 붙잡는지 안 보여서 긴 람다에는 비추천 |
[p = std::move(ptr)] | 초기화 캡처(이동) | unique_ptr 같은 이동 전용 자원을 람다에 넘길 때 |
[this] | 객체 포인터 | 람다가 객체보다 먼저 사라질 때만. 비동기 콜백에서 흔한 사고 원인 |
표 7-2. 반복문을 알고리즘으로 바꾸기
| 하려는 일 | 알고리즘 | C++20 ranges |
|---|---|---|
| 조건에 맞는 첫 원소 | std::find_if | std::ranges::find_if |
| 조건에 맞는 개수 | std::count_if | std::ranges::count_if |
| 합계·누적 | std::accumulate | (C++23 fold_left) |
| 변환해 다른 곳에 쓰기 | std::transform | std::ranges::transform |
| 조건에 맞는 원소 지우기 | erase + remove_if | std::erase_if |
실무에서 자주 틀리는 것
1. remove_if만 부르고 끝낸다
std::remove_if는 남길 원소를 앞으로 모으고 "새 끝" 위치를 돌려줄 뿐, 컨테이너 크기를 줄이지 않는다. 뒤에는 쓰레기 원소가 남는다. v.erase(std::remove_if(...), v.end())로 이어 쓰거나, C++20에서는 6장의 std::erase_if를 쓴다.
2. 비교 함수에 <=나 >=
위에서 본 대로 정렬 알고리즘의 전제를 깨뜨린다. 같은 값이면 반드시 false를 돌려줘야 한다.
3. 콜백에 [&]를 습관처럼 쓴다
스레드에 넘기는 람다, 타이머·네트워크 콜백, 나중에 부를 std::function에는 참조 캡처를 쓰지 않는다. 값으로 복사하거나, 공유가 필요하면 shared_ptr을 값으로 캡처하거나, 객체가 사라졌을 수 있으면 weak_ptr을 캡처해 lock()으로 확인한다(5장).
4. accumulate 초깃값의 타입
std::accumulate(v.begin(), v.end(), 0)에 double 벡터를 넘기면 매 단계 결과가 int로 잘린다. 경고도 나오지 않는 경우가 많다.
연습 문제
std::vector<int> v{1, -2, 3, -4};에std::remove_if(v.begin(), v.end(), [](int x) { return x < 0; });만 실행했다.v.size()는 몇이고, 돌려받은 위치는 앞에서 몇 번째인가?int limit = 10; auto over = [limit](int x) { return x > limit; }; limit = 100;다음over(50)은true인가false인가? 캡처를[&limit]로 바꾸면?- 센서 이름
{"gps", "lidar", "imu", "cam", "sonar"}를 길이 순으로 정렬하되, 길이가 같으면 원래 순서를 유지하고 싶다. ranges 알고리즘 한 줄로 써라. 결과는 어떻게 되는가?
정답
size()는 여전히 4이고, 돌려받은 위치는 앞에서 2번째 원소 다음(begin() + 2)이다. 앞 두 칸에 1, 3이 모이고 뒤 두 칸은 정해지지 않은 값이다. 검증 프로그램에서 크기 4와 거리 2를 확인했다.true다. 값 캡처는 람다를 만들 때의 10을 복사해 두었으므로 50 > 10이다.[&limit]이면 부르는 시점의limit(100)을 읽어false가 된다. 어느 쪽이 맞는지는 의도에 달렸지만, 참조라면limit이 람다보다 오래 산다는 보장이 있어야 한다.std::ranges::stable_sort(names, {}, [](const std::string& s) { return s.size(); });결과는gps imu cam lidar sonar다. 길이 3인 세 개가 원래 순서(gps, imu, cam)를 유지하고 길이 5인 두 개가 뒤에 온다.ranges::sort를 쓰면 같은 길이끼리의 순서는 보장되지 않는다. 검증 프로그램에서 결과 문자열을 확인했다.
다음 장에서는 람다 캡처와 똑같은 모양의 수명 문제가 문자열에서 어떻게 나타나는지 본다. std::string_view는 "문자열을 참조로 캡처한 것"과 같다.