Devin.KR

ranges 와 views - 지연 파이프라인으로 데이터 거르기

개발자KR 조회 8

이 장에서 배우는 것

앞 장에서 다룬 컴파일 타임 계산은 실행 전에 결정할 수 있는 일을 옮기는 방법이었다. 이번에는 실행 중 들어오는 주문을 필요한 만큼만 읽는 방법을 다룬다. 주문 목록에서 검증을 통과한 주문을 고르고, 체결 로그 형태로 바꾸고, 처리 건수를 제한하는 흐름을 하나의 식으로 표현한다.

범위(range)는 시작과 끝으로 순회할 수 있는 대상을 나타낸다. 뷰(view)는 그런 대상에 대한 순회 규칙을 가볍게 구성하는 범위다. 뷰를 만들었다고 해서 필터링한 주문이나 변환한 로그가 즉시 저장되는 것은 아니다. 이 차이를 이해해야 코드의 실행 시점, 자원 수명, 실제 비용을 함께 판단할 수 있다.

  • views::filter, views::transform, views::take를 파이프 연산자로 연결한다.
  • 지연 평가가 시작되는 시점과 어댑터 순서에 따른 결과 차이를 설명한다.
  • 원본 컨테이너, 뷰, 람다 캡처의 수명을 구분하고 댕글링을 피한다.
  • 범위 알고리즘과 투영을 사용해 체결 로그를 처리한다.
  • 중간 저장을 생략하는 이점과 반복 계산, 탐색, 복사의 비용을 구별한다.

문제 상황

작은 주문 처리 엔진이 한 번에 주문 여러 건을 받는다. 이미 검증 표시가 붙은 주문 중에서도 수량과 가격이 허용 범위에 있는 주문만 이번 처리 대상으로 삼는다. 접수 순서대로 최대 세 건을 골라 체결 로그를 만들고, 화면에는 금액이 큰 로그부터 보여 주어야 한다.

이를 단계마다 벡터를 만드는 방식으로 구현하면 유효 주문 벡터, 로그 벡터, 세 건만 남긴 벡터가 생길 수 있다. 각 단계는 읽기 쉽지만 최종적으로 세 건만 필요해도 앞 단계가 전체 주문을 복사하거나 변환할 수 있다. 원본이 커지고 로그 생성에 문자열 복사가 들어가면 불필요한 일이 늘어난다.

반대로 한 반복문에 조건 검사, 건수 제한, 로그 생성, 출력까지 모두 넣으면 처리 규칙을 바꾸기 어렵다. “세 건”이 접수된 주문 세 건인지, 조건을 통과한 주문 세 건인지도 제어 흐름을 따라가며 확인해야 한다.

여기서는 대상을 선택하는 규칙을 뷰로 표현한다. 선택된 로그는 한 번 벡터에 저장한다. 화면 정렬과 주문 번호 검색은 그 벡터에 수행한다. 실제 거래소 연결은 없으며, 조건에 맞는 주문을 체결된 것으로 가정해 로그를 만드는 예제다.

파이프로 연결하는 선택과 변환

표준 라이브러리의 범위 기능은 주로 <ranges>에 들어 있다. std::views의 어댑터는 입력 범위에 순회 규칙을 덧붙인다. |는 이 문맥에서 왼쪽 범위를 오른쪽 어댑터에 전달한다. 정수의 비트 연산을 수행하는 것이 아니라, 범위와 어댑터를 위한 연산자 오버로드를 사용한다.

auto selected = orders
    | std::views::filter(eligible)
    | std::views::transform(make_execution)
    | std::views::take(3);

이 식은 왼쪽부터 “조건을 만족하는 주문을 통과시키고, 로그로 바꾸고, 앞의 세 건까지만 노출한다”라고 읽는다. 어댑터를 연결한 결과도 범위이므로 범위 기반 반복문이나 범위 알고리즘에 전달할 수 있다. 이 연결을 파이프라인(pipeline)이라고 부른다.

세 어댑터가 순회에 추가하는 규칙
어댑터하는 일주의할 점
filter(pred)조건을 만족하는 원소만 노출한다.다음 원소를 찾기 위해 여러 입력을 검사할 수 있다.
transform(fn)원소에 함수를 적용한 결과를 노출한다.변환 결과를 자동으로 저장하지 않는다.
take(n)앞에서 최대 n개를 노출한다.입력이 적으면 그 개수만 제공한다.

어댑터의 순서는 의미의 일부다. filter(eligible) | take(3)은 조건을 통과한 주문 중 앞의 세 건을 고른다. take(3) | filter(eligible)은 접수된 앞의 세 건만 검사한다. 앞의 세 건 중 한 건만 유효하다면 두 번째 식의 결과는 한 건이다. 이후에 유효한 주문이 더 있어도 보충하지 않는다.

transform은 반환 형식도 바꿀 수 있다. 이번 예제는 const Order&를 받아 Execution 값을 반환한다. 따라서 결과 원소는 원본 주문을 가리키는 참조가 아니라 새로 만든 로그 값이다. 변환 함수가 참조를 반환하도록 작성했다면 수명과 수정 가능성도 달라지므로, 변환한다는 표현만 보고 복사가 있다고 단정하면 안 된다.

filter 다음에 take를 놓으면 조건을 통과한 주문을 기준으로 개수를 제한한다

완성 코드에서는 주문 번호 101, 104, 106이 처음 세 개의 유효 주문이다. 107도 유효하지만 로그 생성 대상에는 포함하지 않는다. take는 입력이 세 건 이상이라고 보장하는 기능이 아니므로, 결과를 사용할 때도 항상 실제 원소 수를 기준으로 처리한다.

지연 평가와 수명을 함께 읽기

지연 평가(lazy evaluation)는 결과가 필요할 때 계산한다는 뜻이다. 위 파이프라인을 만드는 시점에는 주문을 모두 검사하거나 로그를 모두 생성하지 않는다. filter의 시작 반복자를 구할 때 첫 통과 원소를 찾고, 다음 위치로 이동할 때 다시 조건에 맞는 원소를 찾는다. transform의 변환 함수는 그 반복자를 역참조할 때 호출된다.

그러므로 뷰 생성 직후의 상태와 순회 시점의 상태가 다르면 결과도 달라질 수 있다. 뷰는 일반적으로 생성 순간의 데이터를 복제한 결과 목록이 아니다. 또한 변환 함수를 한 번 호출한 결과를 기억해 두는 장치도 아니다. 같은 위치를 다시 역참조하면 변환이 다시 실행될 수 있다.

뷰가 모든 경우에 원본을 빌리는 것은 아니다. 원소를 직접 소유하는 뷰도 있다. 다만 이번처럼 이름 있는 벡터를 파이프라인에 넣으면 원본 벡터를 참조하는 형태로 연결된다. 뷰를 복사해도 주문 벡터가 복제되거나 벡터의 수명이 연장되는 것은 아니다.

auto make_selection(const std::vector<Order>& orders)
{
    return orders | std::views::filter(eligible);
}

이 함수는 호출자가 가진 주문을 빌려서 보는 결과를 반환한다. 반환된 뷰를 순회하는 동안 원본 벡터가 살아 있고 필요한 반복자가 유효해야 한다. 함수 안에서 지역 벡터를 만든 뒤 그것을 참조하는 뷰를 반환하는 경우에는 이 조건을 충족하지 못한다.

원본을 빌리는 뷰는 원본보다 오래 사용할 수 없지만 값을 소유한 로그 벡터는 독립적으로 사용할 수 있다

람다의 캡처도 따로 살펴야 한다. 필터 뷰가 조건 함수를 보관하더라도, 조건 함수가 참조로 캡처한 지역 변수까지 소유하지는 않는다. 뷰가 원본 컨테이너보다 먼저 소멸하더라도 캡처한 값의 수명이 먼저 끝났다면 순회가 잘못될 수 있다. 작은 기준값은 값으로 캡처하는 편이 수명 관계를 단순하게 만든다.

반복자가 뷰 자체에 의존하는 경우도 있다. 특히 필터 반복자는 다음 원소를 찾을 때 뷰가 가진 조건 함수를 사용한다. 원본 벡터가 살아 있다는 이유만으로, 이미 소멸한 필터 뷰에서 얻은 반복자를 계속 사용할 수 있다고 판단해서는 안 된다.

이번 예제는 원본을 const 벡터로 유지하고, 뷰가 살아 있는 동안 한 번 순회한다. 결과 로그의 종목명은 std::string으로 소유한다. 로그에 std::string_view를 저장했다면 벡터에 옮겨 담아도 문자열 내용까지 독립적으로 소유하는 것은 아니다. 값을 저장했다는 사실과 그 값 내부의 참조 관계를 함께 확인해야 한다.

범위 알고리즘과 실제 비용

std::ranges의 알고리즘에는 반복자 쌍 대신 범위를 통째로 받는 오버로드가 있다. 예를 들어 std::ranges::for_each(selected, fn)은 뷰가 노출하는 원소를 순회한다. 범위 알고리즘은 <algorithm>에서 제공하므로 <ranges>만 포함하는 것으로 충분하다고 생각하면 안 된다.

여러 알고리즘은 투영(projection)도 받는다. 투영은 비교나 검색에 사용할 부분을 원소에서 꺼내는 함수다. 멤버 포인터를 전달하면 짧게 표현할 수 있다.

std::ranges::sort(logs, std::ranges::greater{},
                  &Execution::gross);

auto found = std::ranges::find(logs, 106,
                               &Execution::order_id);

정렬은 금액을 꺼내 내림차순으로 비교하고, 검색은 주문 번호를 꺼내 106과 비교한다. 투영은 정렬 결과를 별도 숫자 목록으로 바꾸지 않는다. 정렬되는 대상은 여전히 Execution 원소다.

범위라고 해서 모든 알고리즘에 전달할 수 있는 것은 아니다. 필터 뷰는 원본이 벡터여도 임의 위치로 일정한 비용에 이동하는 능력을 제공하지 않는다. sort가 요구하는 임의 접근과 원소 교환 조건을 이번 파이프라인은 충족하지 않는다. 그래서 선택 결과를 벡터에 저장한 뒤 정렬한다.

이번 코드는 C++20에서 사용할 수 있도록 반복 처리로 로그 벡터를 채운다. 더 뒤의 표준에 추가된 수집 기능에 기대지 않는다. reserve(3)은 저장 공간을 미리 확보할 뿐 원소 세 개를 만드는 것이 아니다. 실제 로그 수는 파이프라인을 순회한 결과로 정해진다.

선택 파이프라인의 비용을 판단하는 기준
작업예상 비용설계에 미치는 영향
뷰 구성전체 원소를 순회하지 않는다.조건 함수와 원본 연결 정보를 보관한다.
유효 주문 탐색최악에는 원본 N개를 검사한다.결과가 적어도 검색 비용은 클 수 있다.
로그 변환역참조할 때마다 실행한다.문자열 복사와 반복 계산을 고려한다.
K개 결과 저장·정렬K개 저장 공간과 O(K log K) 비교가 필요하다.반복 사용하거나 순서를 바꿀 결과를 확정한다.

take(3)을 붙였다고 조건 함수가 정확히 세 번 호출되는 것은 아니다. 유효 주문 세 건을 찾기까지 여러 주문을 건너뛸 수 있다. 또한 일반적인 순회는 세 번째 결과를 처리한 뒤 반복자를 증가시킨다. 이때 아래쪽 필터가 다음 통과 원소를 찾느라 추가 조건 검사를 할 수 있다. 처리한 로그 수와 조건 검사 횟수를 같은 수로 가정하지 않는다.

조건 검사나 변환에 외부 전송, 체결 확정, 카운터 갱신 같은 효과를 섞으면 이 차이가 업무 동작에 영향을 줄 수 있다. 조건 함수는 안정적인 판정에 사용하고, 실제 처리는 명시적인 소비 단계에 둔다. 이번 코드의 변환도 로그 값을 구성할 뿐 원본 주문의 상태를 바꾸지 않는다.

지연 처리는 중간 컨테이너를 줄이지만 언제나 실행 시간이 짧다는 뜻은 아니다. 조건 분기, 연속적이지 않은 통과 위치, 반복 변환이 비용을 만든다. 같은 결과를 여러 번 사용한다면 한 번 저장하는 쪽이 유리할 수 있다. 측정할 때는 뷰를 만드는 시간만 재지 말고 결과를 끝까지 소비하는 시간까지 포함해야 한다.

완성 코드

다음 프로그램을 main.cpp로 저장한다. 수량과 단가에 상한을 두어 예제의 곱셈 범위를 제한한다. 금액은 원 단위 정수로 계산하고, 부동소수점 형식에 의존하지 않는다.

#include <algorithm>
#include <cstdint>
#include <functional>
#include <iostream>
#include <ranges>
#include <string>
#include <utility>
#include <vector>

struct Order {
    int id;
    std::string symbol;
    std::int64_t quantity;
    std::int64_t unit_price;
    bool validated;
};

struct Execution {
    int order_id;
    std::string symbol;
    std::int64_t quantity;
    std::int64_t gross;
};

bool eligible(const Order& order)
{
    return order.validated
        && order.quantity > 0
        && order.quantity <= 1'000
        && order.unit_price > 0
        && order.unit_price <= 1'000'000;
}

Execution make_execution(const Order& order)
{
    return Execution{
        order.id,
        order.symbol,
        order.quantity,
        order.quantity * order.unit_price
    };
}

int main()
{
    const std::vector<Order> orders{
        {101, "ALPHA", 2, 15'000, true},
        {102, "BETA", 0, 9'000, true},
        {103, "GAMMA", 4, 7'000, false},
        {104, "DELTA", 5, 8'000, true},
        {105, "EPSILON", 1, -500, true},
        {106, "ZETA", 3, 12'000, true},
        {107, "ETA", 1, 50'000, true}
    };

    constexpr int batch_limit = 3;

    auto selected = orders
        | std::views::filter(eligible)
        | std::views::transform(make_execution)
        | std::views::take(batch_limit);

    std::vector<Execution> logs;
    logs.reserve(batch_limit);

    std::ranges::for_each(selected, [&logs](Execution log) {
        logs.push_back(std::move(log));
    });

    std::ranges::sort(
        logs, std::ranges::greater{}, &Execution::gross);

    std::cout << "executions=" << logs.size() << '\n';

    std::int64_t total = 0;
    for (const auto& log : logs) {
        std::cout << log.order_id << ' '
                  << log.symbol << " qty=" << log.quantity
                  << " gross=" << log.gross << '\n';
        total += log.gross;
    }
    std::cout << "total=" << total << '\n';

    const auto found = std::ranges::find(
        logs, 106, &Execution::order_id);

    if (found != logs.end()) {
        std::cout << "found=" << found->order_id
                  << " gross=" << found->gross << '\n';
    }
}

줄별 해설

#include <algorithm>은 순회, 정렬, 검색 알고리즘 선언을 가져온다. <ranges>는 뷰 어댑터를, <functional>은 비교 함수 객체를, <utility>는 이동에 사용하는 std::move를 제공한다. 다른 헤더가 우연히 함께 포함해 주는 선언에 기대지 않는다.

Order의 validated는 앞선 주문 검증 단계의 결과다. 이번 선택에서는 이 표시와 수량·단가의 허용 범위를 함께 확인한다. 수량과 단가는 std::int64_t이므로 곱셈도 이 정수 형식으로 수행된다.

Execution은 주문에서 필요한 정보를 복사해 보관한다. symbol도 소유 문자열이므로 로그가 원본 주문의 문자열을 빌리지 않는다. gross는 수량에 단가를 곱한 금액이다.

eligible의 첫 줄은 검증 표시를 확인한다. 이어지는 네 비교는 양수 여부와 상한을 검사한다. 조건을 통과한 주문의 최대 금액은 10억이며, 세 건의 합도 std::int64_t 범위 안이다. 이 범위 보장은 필터를 통과한 주문에 적용된다. make_execution 자체가 모든 입력을 다시 검증하는 것은 아니다.

make_execution의 order.symbol은 원본의 문자열을 새 로그로 복사한다. 이 비용은 뷰를 선언할 때 발생하지 않는다. 변환된 원소를 소비할 때 발생한다. 나머지 멤버는 정수 값이므로 각 값을 복사해 로그를 구성한다.

const std::vector<Order> orders는 처리 도중 원본을 변경하지 않는다는 의도를 드러낸다. 102는 수량, 103은 검증 표시, 105는 가격 때문에 제외된다. 107은 조건을 만족하지만 처리 한도 뒤에 있다.

batch_limit는 이번 처리에서 소비할 최대 결과 수다. 세 어댑터를 연결한 selected는 로그 목록이 아니라 로그를 얻는 규칙이다. auto를 사용하면 중첩된 뷰 형식의 이름을 직접 적지 않아도 된다.

selected 자체에는 const를 붙이지 않았다. 필터 뷰는 원본이 변경 불가능하더라도 시작 위치의 캐시 등 순회 상태를 관리할 수 있다. 특히 이 예제의 필터가 포함된 뷰에 const를 붙이면 같은 방식으로 순회할 수 없다. 원본을 읽기 전용으로 다루는 것과 뷰 객체를 읽기 전용으로 선언하는 것은 구별한다.

logs.reserve(batch_limit)은 최대 세 건을 저장할 공간을 준비한다. for_each가 파이프라인을 소비하면서 변환된 로그 값을 람다의 log 매개변수로 받는다. std::move(log)는 그 값을 벡터로 옮기도록 한다. 이동 후의 지역 매개변수는 다시 사용하지 않는다.

[&logs]는 벡터를 참조로 캡처한다. 이 경우 알고리즘 호출이 끝나기 전에 모든 처리가 끝나고, 그동안 벡터도 살아 있으므로 수명 문제가 없다. 이 람다를 나중에 실행할 목적으로 반환하거나 보관하는 상황과는 조건이 다르다.

sort는 저장된 로그만 금액 내림차순으로 재배치한다. 원본 주문 순서는 바뀌지 않는다. 접수 순서로 세 건을 선택한 다음 화면 표시 순서를 바꾸므로, 전체 주문에서 금액이 큰 세 건을 고르는 프로그램과 결과가 다르다.

출력 반복문의 const auto&는 저장된 로그를 다시 복사하지 않고 읽는다. 마지막 find는 주문 번호를 기준으로 찾은 반복자를 반환한다. 검색 실패일 수 있으므로 logs.end()와 비교한 뒤 역참조한다.

실행 결과

C++20 범위 기능을 지원하는 컴파일러와 표준 라이브러리가 필요하다. macOS 또는 Linux에서 다음 명령으로 빌드하고 실행한다.

c++ -std=c++20 -Wall -Wextra -pthread main.cpp -o orders
./orders

예상 출력은 다음과 같다. 선택 순서는 101, 104, 106이지만 출력은 금액순이다.

executions=3
104 DELTA qty=5 gross=40000
106 ZETA qty=3 gross=36000
101 ALPHA qty=2 gross=30000
total=106000
found=106 gross=36000

107의 금액은 50,000원으로 선택된 어느 로그보다 크다. 그래도 출력되지 않는다. 세 건을 고르는 기준이 금액이 아니라 접수 순서이기 때문이다. 정렬 위치를 바꾸는 것은 단순한 성능 조정이 아니라 업무 규칙 변경이 될 수 있다.

실무에서 자주 틀리는 것

지역 컨테이너를 참조하는 뷰를 반환한다

다음 코드는 함수가 끝나면 사라지는 벡터에 연결된 뷰를 반환한다. 뷰 객체가 반환되어 살아 있더라도 원본 주문은 이미 소멸했다. 이러한 댕글링(dangling) 상태에서 원소를 읽으면 정의되지 않은 동작이다.

auto make_batch_bad()
{
    std::vector<Order> local{
        {201, "ALPHA", 2, 15'000, true}
    };
    return local | std::views::filter(eligible);
}

함수 밖에서 독립적으로 사용할 결과가 필요하다면 소유하는 컨테이너를 반환한다. 반환값 최적화나 이동을 활용할 수 있으므로, 수명을 깨뜨리면서 지역 벡터의 복사를 피하려고 할 필요가 없다.

std::vector<Execution> make_batch_good()
{
    const std::vector<Order> local{
        {201, "ALPHA", 2, 15'000, true}
    };

    auto selected = local | std::views::filter(eligible);
    std::vector<Execution> result;

    for (const Order& order : selected) {
        result.push_back(make_execution(order));
    }
    return result;
}

호출자의 원본을 계속 빌려야 하는 API라면 원본을 매개변수로 받고 수명 조건을 명시한다. 그런 API의 호출자가 임시 컨테이너를 전달한 뒤 반환된 뷰를 보관하는 일도 피해야 한다. 임시 범위의 소유 여부를 추측하기보다 실제 연결 방식과 사용 기간을 확인한다.

반환할 뷰의 조건에서 지역 변수를 참조한다

다음 함수에서 주문 벡터는 호출자가 소유하지만, 기준 수량은 함수의 지역 매개변수다. 참조 캡처는 매개변수의 수명을 늘리지 않는다.

auto quantity_view_bad(const std::vector<Order>& orders,
                       std::int64_t minimum)
{
    return orders | std::views::filter(
        [&minimum](const Order& order) {
            return order.quantity >= minimum;
        });
}

작은 기준값은 값으로 캡처한다. 그러면 뷰에 보관되는 조건 함수가 기준값도 함께 보관한다. 원본 주문 벡터에 대한 수명 조건은 여전히 남는다.

auto quantity_view_good(const std::vector<Order>& orders,
                        std::int64_t minimum)
{
    return orders | std::views::filter(
        [minimum](const Order& order) {
            return order.quantity >= minimum;
        });
}

값 캡처도 캡처 대상의 성격에 따라 의미가 달라진다. 포인터나 string_view를 값으로 캡처하면 주소와 길이 같은 정보가 복사될 뿐, 대상 데이터의 소유권이 생기지는 않는다.

순회를 시작한 뒤 원본 벡터를 확장한다

아래 조각의 orders는 변경 가능한 벡터라고 가정한다. push_back에서 재할당이 일어나면 앞서 얻은 반복자가 무효화된다. 필터 뷰 내부에 기억된 시작 위치도 문제가 될 수 있다.

auto selected = orders | std::views::filter(eligible);
auto it = selected.begin();

orders.push_back(Order{208, "THETA", 1, 6'000, true});

if (it != selected.end()) {
    std::cout << it->id << '\n';
}

원본 변경을 먼저 끝내고 새 뷰를 만든다. 변경 이전의 반복자를 이어서 사용하지 않는다.

orders.push_back(Order{208, "THETA", 1, 6'000, true});

auto selected = orders | std::views::filter(eligible);
for (const Order& order : selected) {
    std::cout << order.id << '\n';
}

재할당이 없어도 조건에 관여하는 값을 순회 사이에 바꾸면 주의해야 한다. 벡터를 보는 필터 뷰는 첫 통과 위치를 캐시할 수 있으므로, 앞쪽의 탈락 주문을 유효하게 바꿨다고 기존 뷰가 처음부터 다시 검사한다고 기대해서는 안 된다. 필터가 제공한 원소를 수정해 조건을 만족하지 않게 만드는 사용도 피한다. 조건 관련 변경 후에는 새 뷰로 새 순회를 시작하는 편이 명확하다.

뷰를 계산 결과의 저장소로 생각한다

다음 코드는 로그를 출력한 뒤 같은 뷰를 다시 순회한다. 결과를 재사용하려는 의도였다면 잘못된 비용 가정이다. 두 번째 순회에서도 로그를 다시 만들며 종목 문자열도 다시 복사할 수 있다.

auto selected = orders
    | std::views::filter(eligible)
    | std::views::transform(make_execution)
    | std::views::take(3);

for (const auto& log : selected) {
    std::cout << log.order_id << '\n';
}

std::int64_t total = 0;
for (const auto& log : selected) {
    total += log.gross;
}

한 번 만든 로그를 재사용하려면 저장 단계를 둔다. 결과를 한 번만 소비하고 출력과 합계를 함께 계산해도 된다. 선택은 이후 작업이 결과 목록을 필요로 하는지에 달려 있다.

std::vector<Execution> saved;
saved.reserve(3);

std::ranges::for_each(selected, [&saved](Execution log) {
    saved.push_back(std::move(log));
});

std::int64_t total = 0;
for (const Execution& log : saved) {
    std::cout << log.order_id << '\n';
    total += log.gross;
}

필터의 시작 위치 캐시는 변환 결과의 저장과 다르다. 시작점을 재탐색하지 않는 경우가 있어도, 로그 값 전체가 캐시된 것은 아니다. 함수 호출 횟수에 업무 의미를 부여하지 말고 필요한 처리 횟수를 소비 코드에서 드러낸다.

한눈에 보기

주문 선택 코드를 검토할 때 확인할 사항
관심사판단 기준이번 코드의 선택
처리 순서제한이 검사 전인지 검사 후인지 확인한다.검사 후 세 건을 고른다.
평가 시점뷰 구성과 결과 소비를 구분한다.for_each에서 로그를 만든다.
수명원본, 캡처, 뷰의 사용 기간을 확인한다.원본을 유지한 채 뷰를 소비한다.
소유권저장한 원소 내부의 참조도 확인한다.로그가 문자열을 소유한다.
알고리즘범위가 요구하는 순회·수정 능력을 확인한다.벡터에 저장한 뒤 정렬한다.
성능탐색, 변환, 저장 비용을 따로 본다.중간 주문 벡터는 생략하고 로그는 보관한다.

뷰는 선택 규칙을 조합하는 수단이고, 컨테이너는 결과를 소유하는 수단이다. 이번 엔진은 두 역할을 연결해 접수 순서의 선택과 금액순의 표시를 분리했다. 다음 장에서는 처리할 데이터의 존재 여부와 실패 이유를 어떤 값으로 표현할지 다룬다.

연습 문제

  1. 완성 코드의 파이프라인을 take(3) | filter(eligible) | transform(make_execution) 순서로 바꾸면 어떤 주문이 로그로 저장되는지 구하라. 전체 예상 출력도 작성하라.
  2. 접수 순서를 유지하면서 수량이 3 이상인 유효 주문을 최대 두 건 고르도록 수정하라. 최소 수량을 지역 변수로 두고 람다가 이를 안전하게 보관하도록 작성하라. 선택되는 주문 번호와 총금액을 구하라.
  3. 로그를 벡터에 저장하거나 정렬하지 않고, 선택 순서대로 출력하면서 총금액을 한 번의 순회로 계산하라. 원래 프로그램과 출력 순서가 어떻게 다른지 설명하라.
  4. 선택 결과가 비었는지 확인하려고 std::ranges::distance(selected)를 호출한 뒤 다시 로그를 저장하려 한다. 필터 뷰에서 거리 계산의 비용과 이후 순회의 비용을 설명하고, 이번 프로그램에 맞는 대안을 제시하라.

정답과 해설

  1. 앞의 세 주문은 101, 102, 103이다. 이 중 조건을 통과하는 주문은 101뿐이다. 나머지 주문은 검사 대상에 들어가지 않으며, 주문 106을 찾는 출력도 나오지 않는다.

    executions=1
    101 ALPHA qty=2 gross=30000
    total=30000

    유효한 주문을 세 건까지 채우려면 원래처럼 필터를 제한 앞에 놓아야 한다.

  2. 기준 수량을 값으로 캡처하는 조건을 추가한다. 기존 유효성 조건과 수량 조건을 하나의 필터에 합쳐도 된다.

    const std::int64_t minimum = 3;
    
    auto selected = orders
        | std::views::filter(eligible)
        | std::views::filter([minimum](const Order& order) {
              return order.quantity >= minimum;
          })
        | std::views::transform(make_execution)
        | std::views::take(2);

    101은 수량 조건에서 제외된다. 선택되는 주문은 104와 106이며, 금액은 각각 40,000원과 36,000원이다. 총금액은 76,000원이다. 값 캡처는 기준값의 수명을 해결하며, 원본 벡터는 순회가 끝날 때까지 유지해야 한다.

  3. 기존 selected를 한 번 순회하면서 출력과 합계를 함께 처리한다. 벡터 저장과 정렬 부분을 다음 코드로 바꿀 수 있다. 기존 벡터에 의존하는 검색 부분은 이 변형에서 제거한다.

    std::int64_t total = 0;
    
    for (const auto& log : selected) {
        std::cout << log.order_id << ' '
                  << log.symbol << " qty=" << log.quantity
                  << " gross=" << log.gross << '\n';
        total += log.gross;
    }
    std::cout << "total=" << total << '\n';

    출력 순서는 101, 104, 106이고 합계는 106,000원으로 같다. 반복문의 참조는 해당 반복에서 만든 로그 값을 읽는 동안 유효하다. 그 주소나 참조를 다음 반복 이후까지 보관해서는 안 된다. 나중에 다시 사용할 값이 필요하면 소유하는 저장소에 옮겨야 한다.

  4. 필터가 노출하는 원소 수는 원본 크기만으로 알 수 없다. 이 파이프라인의 거리 계산은 제한된 결과의 끝을 찾기 위해 순회하며, 그 과정에서 필터 조건을 검사한다. 유효 주문이 적다면 원본 전체를 검사할 수 있다. 거리를 세는 일은 변환 결과를 읽는 일이 아니므로, 로그 변환이 모든 위치에서 실행된다고 가정해서도 안 된다.

    이후 로그를 저장하면 필터 순회가 다시 일어난다. 첫 통과 위치가 캐시될 수 있어도 나머지 탐색과 실제 로그 변환 비용은 남는다. 이번처럼 어차피 저장할 프로그램에서는 먼저 한 번 저장하고 logs.empty()로 확인하면 된다.

    std::ranges::for_each(selected, [&logs](Execution log) {
        logs.push_back(std::move(log));
    });
    
    if (logs.empty()) {
        std::cout << "no executions\n";
    }

    이 조각은 기존 저장 호출을 대체한다. 뒤에 추가하면 같은 로그를 두 번 저장한다. 저장이 필요 없는 상황에서 빈 결과만 확인하려면 시작과 끝을 비교할 수 있지만, 필터의 시작 위치를 찾는 과정 자체는 여러 주문을 검사할 수 있다.

댓글 0

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

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