1. 실체화가 무엇이고 왜 필요한가
솔직히 말하면 "실체화"라는 단어는 마치 평행우주에서 컨테이너를 소환하는 것처럼 들립니다. 하지만 의미는 더 단순합니다: "계산의 설명"이라고 말하던 것을 "준비된 데이터"라고 말하는 순간입니다. ranges 세계에서는 특히 중요합니다. 왜냐하면 view는 지연(eager)이 아닌 lazy하고 종종 소유하지 않기 때문입니다. 따라서 때때로 결과를 의도적으로 복사하여 "확정"해야 합니다.
실체화는 다음과 같은 연산입니다:
range/view → 컨테이너 (보통 std::vector, 때때로 std::string 등)
다음과 같은 파이프라인이 있다고 상상해 보세요:
tasks | filter(...) | transform(...) | take(...)
이것이 view인 동안에는 조리법과 비슷합니다: "작업을 가져와 필터링하고 변환한 뒤 처음 N개를 가져와라". 조리법 자체는 음식이 아닙니다. 실제로 조리를 해서 view를 순회하고 결과를 컨테이너에 넣을 때 음식이 생깁니다.
언제 실체화가 실제로 필요한가
사유 목록 대신 상황을 통해 직관을 고정합시다.
첫 번째 상황 — 소유하는 결과가 필요할 때. 결과가 원본 컨테이너와 독립적으로 살아야 합니다. 예를 들어, 오늘의 작업 제목 목록을 만들고 이를 함수에서 std::vector<std::string>로 반환하고 싶을 때. 함수에서 View를 반환할 수는 있지만, 원본 데이터가 view보다 오래 살아남는다고 보장해야 합니다 — 초보자에게는 거의 항상 너무 취약한 계약입니다.
두 번째 상황 — 컨테이너가 필요한 알고리즘을 사용하려 할 때, 예를 들어 결과 정렬, 요소 삭제, 인덱싱, 여러 곳에서 재사용 등입니다. view로는 종종 불가능하거나 가독성이 떨어집니다.
세 번째 상황 — "안정화 포인트"가 필요할 때: 그 지점 전에는 계산을 지연으로 기술했고, 그 지점 이후에는 고정된 데이터로 작업하며 dangling이나 무효화에 대해 걱정하지 않습니다.
마지막으로 네 번째 상황 — 반복 순회. View는 아무것도 캐시하지 않습니다: 파이프라인을 두 번 순회하면 작업을 두 번 수행합니다. 가끔은 괜찮지만, 때로는 예상치 못하게 비용이 높거나(혹은 내부에 부작용이 있으면) 로직을 망가뜨릴 수 있습니다.
어디서 "마법"이 일어나는지 고정하기 위한 간단한 도식:
flowchart LR
A[소유 컨테이너<br/>std::vector<Task>] --> B[view-파이프라인<br/>filter/transform/take]
B -->|range-for / ranges::copy로 순회| C[실체화<br/>std::vector<string>]
C --> D[그다음 일반 데이터처럼 작업]
2. range 또는 view의 실체화 방법
실무에서는 두 가지 옵션이 필요합니다: "보기 좋은 방법"과 "철근콘크리트 방법".
std::ranges::to: 보기 좋지만 모든 곳에 있는 건 아님
std::ranges::to는 의미상 깔끔한 함수입니다: "range를 받아 특정 타입의 컨테이너로 바꿔라"라는 느낌입니다.
문제는 아이디어 자체가 아니라 현실입니다: -std=c++23를 사용하더라도, 사용하는 표준 라이브러리가 ranges::to를 완전히 지원하지 않거나 전혀 지원하지 않을 수 있습니다. 그래서 우리는 "보기 좋은 방법"과 "대체 방법"을 함께 배웁니다.
예: ranges::to로 숫자 실체화하기
간단한 int 예제로 시작해서 모델에 신경 쓰지 않겠습니다.
#include <iostream>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6};
auto pipeline = v
| std::views::filter([](int x) { return x % 2 == 0; })
| std::views::transform([](int x) { return x * x; });
// std::vector<int> out = std::ranges::to<std::vector<int>>(pipeline);
// std::cout << out.size() << '\n'; // (주석을 해제하면) 3
}
여기서 pipeline은 view이고, out(만약 사용하는 라이브러리가 ranges::to를 지원한다면)는 이미 정상적인 std::vector<int>로 값 {4, 16, 36}를 저장합니다.
교수법적 포인트: ranges::to 라인은 주석 처리되어 있습니다. 이는 조롱이 아니라 실용적 배려입니다: 환경에 따라 to가 없으면 코드가 빌드되지 않기 때문에, 동작하는 대체 방법을 보여 주려는 것입니다.
간단 표: std::ranges::to의 장단점
| 속성 | |
|---|---|
| 가독성 | 우수: to<vector>(range)는 영어처럼 읽힙니다 |
| 코드 양 | 최소 |
| 컴파일러/stdlib 이식성 | 일부 환경에서 문제일 수 있음 |
| 삽입 제어/reserve() | 보통 충분하지만 가끔 직접 제어하고 싶을 수 있음 |
std::ranges::copy + std::back_inserter: 신뢰할 수 있는 방법
이 방법은 덜 "마법적"이지만 매우 이식성이 높고 정직합니다: "range에서 요소를 가져와 컨테이너에 복사하라"라고 명시적으로 말합니다. 이 방법은 사용하는 라이브러리에 ranges::to 구현 여부에 의존하지 않습니다: std::ranges::copy와 std::back_inserter는 오랫동안 표준 라이브러리에 존재해 왔습니다.
정신적으로 이렇게 생각하면 도움이 됩니다: ranges::to는 편리한 래퍼이고, ranges::copy + back_inserter는 거의 항상 작동하는 기본적인 블록입니다.
기본 예: view를 std::vector로 모으기
#include <algorithm>
#include <iterator>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6};
auto pipeline = v | std::views::filter([](int x) { return x % 2 == 0; });
std::vector<int> out;
std::ranges::copy(pipeline, std::back_inserter(out));
}
이 시점에서 out은 짝수들로 구성된 소유 컨테이너입니다.
back_inserter가 중요한 이유
초보자는 종종 비어 있는 out에 대해 "out.begin()으로 복사하자"라고 쓰고 싶어합니다. 하지만 비어 있는 벡터에는 삽입할 위치가 없습니다. std::back_inserter(out)은 "출력 반복자"를 만들어 할당될 때마다 out.push_back(value)를 호출합니다.
즉, back_inserter는 push_back을 반복자 인터페이스로 바꿔 주는 어댑터입니다. 약간 추상적으로 들리지만 효과는 간단합니다: 수동 루프 없이 빈 벡터로 복사할 수 있습니다.
"더 빠르게 할 수 있나?" — 네, reserve()로
결과의 상한을 대략 알 수 있다면 reserve()를 사용하는 것이 유용합니다. 이는 선택 사항이지만 성능 측면에서 유리합니다: 재할당이 줄고 벡터 내부의 복사/이동이 줄어듭니다.
#include <algorithm>
#include <iterator>
#include <ranges>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5, 6};
auto pipeline = v | std::views::filter([](int x) { return x % 2 == 0; });
std::vector<int> out;
out.reserve(v.size()); // 상한 추정: 짝수 개수는 전체 요소 수보다 클 수 없음
std::ranges::copy(pipeline, std::back_inserter(out));
}
여기서 reserve(v.size())는 "안전한 상한"입니다: 결과는 원래 컨테이너보다 클 수 없습니다.
3. 실습: TaskPlanner와 '안정화 포인트'
실체화가 단순한 '요술'처럼 보이지 않도록 친숙한 문맥에 통합해 보겠습니다. 학습용 콘솔 앱 TaskPlanner가 있다고 합시다: 이 앱은 작업을 std::vector<Task>에 보관하고, 완료/미완료를 필터링할 수 있으며 때때로 우선순위별로 "톱 작업"을 보여 줍니다.
지금 중요한 것은 기능을 확장하는 것이 아니라 좋은 스타일을 보여 주는 것입니다: 언제 view가 임시 파이프라인으로 적절한지, 언제 안정적인 결과로 컨테이너가 필요한지입니다.
우리의 모델 Task
#include <string>
struct Task {
int id = 0;
std::string title;
int priority = 0; // 값이 클수록 더 중요
bool done = false;
};
"미완료 상위 3개"와 문자열 목록
우리는 제목들의 std::vector<std::string>을 얻고 싶습니다. 여기서는 실체화가 특히 자연스럽습니다: 문자열 목록은 독립적인 결과로서 출력하거나 다른 함수에 전달하거나 비교하기에 편리합니다.
먼저 우선순위(내림차순)로 정렬한 뒤 view를 만듭니다: "미완료 → 3개 가져오기 → 제목(title) 가져오기".
#include <algorithm>
#include <iostream>
#include <ranges>
#include <string>
#include <vector>
struct Task {
int id = 0;
std::string title;
int priority = 0;
bool done = false;
};
int main() {
std::vector<Task> tasks{
{1, "보고서 작성", 5, false},
{2, "컵 씻기", 1, true},
{3, "프로젝트 제출", 10, false},
{4, "ranges 공부하기", 7, false}
};
std::ranges::sort(tasks, [](const Task& a, const Task& b) {
return a.priority > b.priority;
});
auto topTitlesView = tasks
| std::views::filter([](const Task& t) { return !t.done; })
| std::views::transform([](const Task& t) { return t.title; })
| std::views::take(3);
for (const std::string& s : topTitlesView) {
std::cout << s << '\n'; // 프로젝트 제출 / ranges 공부하기 / 보고서 작성
}
}
이 코드는 이미 유용합니다: 우리는 계산을 지연으로 기술하고 즉시 결과를 출력했습니다.
하지만 이제 상상해 보세요: 상위 3개를 출력한 뒤 새 작업을 추가하고, 그 다음에 "같은 상위 3개"를 다시 출력해서 변경 전 스냅샷을 보고 싶다고 합시다. topTitlesView를 그대로 view로 두고 tasks를 수정하면 놀라운 결과를 얻을 위험이 있습니다(지난 수업에서 수명 관련 문제로 왜 "놀라움"이 버그 리포트로 끝나는지 설명했습니다).
이럴 때 "안정화 포인트"가 등장합니다: 우리는 제목 목록을 실체화합니다.
ranges::copy로 실체화하기
#include <algorithm>
#include <iostream>
#include <iterator>
#include <ranges>
#include <string>
#include <vector>
struct Task {
int id = 0;
std::string title;
int priority = 0;
bool done = false;
};
int main() {
std::vector<Task> tasks{
{1, "보고서 작성", 5, false},
{2, "컵 씻기", 1, true},
{3, "프로젝트 제출", 10, false},
{4, "ranges 공부하기", 7, false}
};
std::ranges::sort(tasks, [](const Task& a, const Task& b) {
return a.priority > b.priority;
});
auto view = tasks
| std::views::filter([](const Task& t) { return !t.done; })
| std::views::transform([](const Task& t) { return t.title; })
| std::views::take(3);
std::vector<std::string> snapshot;
snapshot.reserve(tasks.size());
std::ranges::copy(view, std::back_inserter(snapshot));
tasks.push_back({5, "긴급히 전부 다시하기", 100, false}); // 원본 데이터 변경
std::cout << snapshot.size() << '\n'; // 3 (스냅샷은 변하지 않음)
}
이제 snapshot은 더 이상 tasks에 의존하지 않습니다. 문자열을 소유하므로 마음의 부담이 줄고 유지보수가 쉬워집니다.
헬퍼 to_vector: 하나의 인터페이스—하나의 익숙한 호출
실제 학습 프로젝트나 실전 코드에서는 "어떤 range든 vector로 모으는" 단일 함수를 갖는 것이 편리합니다. 매번 copy + back_inserter를 쓰기 귀찮을 때가 많습니다. C++에서는 이를 몇 줄로 구현할 수 있으며, 이는 "보기 좋음"과 "이식성" 사이의 좋은 타협입니다.
항상 ranges::copy를 사용하는 헬퍼를 만들어 보겠습니다. 이해하기 쉽고 이식 가능합니다.
#include <algorithm>
#include <iterator>
#include <ranges>
#include <vector>
template <typename T, std::ranges::input_range R>
std::vector<T> to_vector(R&& r) {
std::vector<T> out;
std::ranges::copy(r, std::back_inserter(out));
return out;
}
라이브러리가 ranges::to를 지원할 때 "보기 좋은 브랜치"를 추가하고 싶다면 feature-test macro로 조건부 컴파일을 할 수 있습니다. 이건 조금 더 고급이지만 유용합니다: 환경의 기능에 스스로 적응하는 코드를 쓰기 시작합니다.
#include <algorithm>
#include <iterator>
#include <ranges>
#include <utility>
#include <vector>
template <typename T, std::ranges::input_range R>
std::vector<T> to_vector(R&& r) {
#if defined(__cpp_lib_ranges_to_container)
return std::ranges::to<std::vector<T>>(std::forward<R>(r));
#else
std::vector<T> out;
std::ranges::copy(r, std::back_inserter(out));
return out;
#endif
}
학습 프로젝트에서 이 함수의 목적은 간단합니다: 애플리케이션 코드에서는 to_vector<Type>(range)라고 쓰면 되고, 실체화 로직이 프로젝트 전역에 흩어지지 않습니다.
TaskPlanner에서 사용하기
#include <algorithm>
#include <iostream>
#include <iterator>
#include <ranges>
#include <string>
#include <vector>
struct Task {
int id = 0;
std::string title;
int priority = 0;
bool done = false;
};
template <typename T, std::ranges::input_range R>
std::vector<T> to_vector(R&& r) {
std::vector<T> out;
std::ranges::copy(r, std::back_inserter(out));
return out;
}
int main() {
std::vector<Task> tasks{{1, "프로젝트 제출", 10, false}, {2, "커피", 1, false}};
auto titlesView = tasks | std::views::transform([](const Task& t) { return t.title; });
auto titles = to_vector<std::string>(titlesView);
std::cout << titles.size() << '\n'; // 2
}
결과적으로 간결하고 읽기 쉬우며 "마법"이 없습니다: view는 변환을 기술하고, to_vector는 결과를 확정합니다.
4. ranges/views 실체화 시 전형적인 실수
여기서 잠깐 속도를 늦출 필요가 있습니다: 실체화는 단순한 연산이지만 그 주변의 실수는 보통 문법적 오류가 아닙니다. 코드가 컴파일되고 "가끔은" 동작하다가 갑자기 추리 소설의 주인공이 되는 경우가 많습니다: "내 반복자를 누가 죽였나".
실수 #1: "항상 실체화하자, 그러면 마음이 편하다".
컨테이너는 안정성을 줍니다. 하지만 매번 모든 곳에서 std::vector를 "만약을 대비해" 만들면 ranges의 핵심 이점인 표현력과 지연성을 잃습니다. 좋은 습관은 다음과 같습니다: view는 계산의 임시 설명으로 두고, 소유, 재사용, API 경계, dangling 보호 등 계약상 정당화되는 경우에만 실체화합니다.
실수 #2: 반복자 생성자(constructor from iterators)로 실체화하고 실패에 놀라기.
때로는 std::vector<T> out(view.begin(), view.end()); 같은 코드를 쓰고 싶습니다. 어떤 경우에는 동작하지만 어떤 경우에는 작동하지 않거나 view의 반복자 타입에 대해 더 많은 지식이 필요할 수 있습니다. ranges/view에 대해선 안전하고 명확한 관용구인 std::ranges::copy(view, std::back_inserter(out));를 기억하세요.
실수 #3: <iterator> 또는 <algorithm> 포함을 잊어 컴파일러와 실랑이하기.
std::back_inserter는 <iterator>에 정의되어 있고, std::ranges::copy는 <algorithm>에 정의되어 있습니다. <ranges>만 포함하면 컴파일러가 "모르겠다"라고 말할 수 있습니다. 컴파일러는 옳습니다.
실수 #4: 참조나 뷰를 반환하는 view를 실체화하여 원하지 않는 것을 고정하기.
예를 들어 transform이 참조나 std::string_view를 반환할 수 있습니다. 그러면 참조나 string_view로 이루어진 컨테이너를 얻게 되어 원본 데이터에 의존하게 됩니다. 형식상으로는 "vector를 만들었다" 하더라도 실질적인 소유권을 얻지 못한 것입니다. 독립적인 결과를 원하면 데이터를 소유하는 타입(대부분 std::string 등)으로 실체화하세요.
실수 #5: 실체화한 뒤에도 여전히 "지연"이라고 생각하기.
실체화 이후에는 지연성이 없습니다: 이미 작업을 수행했고, 메모리를 할당했으며 요소들을 복사/이동했습니다. 이는 정상입니다. 다만 "계산의 설명"(view)과 "준비된 데이터"(컨테이너)를 혼동하면 안 됩니다. "왜 느려졌지?"라고 생각되면 종종 원인은 불필요한 실체화입니다.
GO TO FULL VERSION