Devin.KR

C++ · 기본

C++ 객체와 자원 관리

C++ 알고리즘과 람다 - sort find_if ranges 와 캡처 수명 (C++ 객체와 자원 관리 7장)

센서 기록을 정렬·검색·집계하며 표준 알고리즘과 C++20 ranges 를 익힌다. 람다 캡처 방식과, 참조 캡처가 함수보다 오래 살 때 생기는 버그를 확인한다.

개발자 · 원고 갱신

이 장에서 배우는 것

반복문으로 짠 코드는 읽는 사람이 루프 본문을 따라가며 "이게 찾기인지, 세기인지, 걸러 내기인지"를 추론해야 한다. 표준 알고리즘은 그 의도를 함수 이름으로 드러낸다. 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';
}

값 캡처 [limit] 은 람다 객체 안에 복사본을 두고, 참조 캡처 [&alarms] 는 바깥 변수를 가리키는 참조를 둔다. 참조 캡처는 바깥 변수가 살아 있는 동안만 안전하다.

줄별 해설

람다의 모양

[캡처](매개변수) { 본문 }이다. 컴파일러는 람다마다 이름 없는 클래스를 만들고, 캡처한 변수를 그 클래스의 멤버로, 본문을 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

register_handlers 가 끝나면 threshold 가 있던 스택 자리를 busy_work 가 7777 로 덮는다. 참조로 캡처한 람다는 그 자리를 그대로 읽어 7777 을 출력한다. 값 캡처는 50 을 출력한다.

두 람다 모두 기준값 50을 붙잡았는데, 참조 캡처 쪽은 7777을 출력했다. thresholdregister_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_ifstd::ranges::find_if
조건에 맞는 개수std::count_ifstd::ranges::count_if
합계·누적std::accumulate(C++23 fold_left)
변환해 다른 곳에 쓰기std::transformstd::ranges::transform
조건에 맞는 원소 지우기erase + remove_ifstd::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로 잘린다. 경고도 나오지 않는 경우가 많다.

연습 문제

  1. std::vector<int> v{1, -2, 3, -4};std::remove_if(v.begin(), v.end(), [](int x) { return x < 0; });만 실행했다. v.size()는 몇이고, 돌려받은 위치는 앞에서 몇 번째인가?
  2. int limit = 10; auto over = [limit](int x) { return x > limit; }; limit = 100; 다음 over(50)true인가 false인가? 캡처를 [&limit]로 바꾸면?
  3. 센서 이름 {"gps", "lidar", "imu", "cam", "sonar"}를 길이 순으로 정렬하되, 길이가 같으면 원래 순서를 유지하고 싶다. ranges 알고리즘 한 줄로 써라. 결과는 어떻게 되는가?

정답

  1. size()는 여전히 4이고, 돌려받은 위치는 앞에서 2번째 원소 다음(begin() + 2)이다. 앞 두 칸에 1, 3이 모이고 뒤 두 칸은 정해지지 않은 값이다. 검증 프로그램에서 크기 4와 거리 2를 확인했다.
  2. true다. 값 캡처는 람다를 만들 때의 10을 복사해 두었으므로 50 > 10이다. [&limit]이면 부르는 시점의 limit(100)을 읽어 false가 된다. 어느 쪽이 맞는지는 의도에 달렸지만, 참조라면 limit이 람다보다 오래 산다는 보장이 있어야 한다.
  3. 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는 "문자열을 참조로 캡처한 것"과 같다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.