구간 라이브러리(Ranges Library를 배워 보자)
by
gg582 · 2026-07-09 08:17:06 · 127 views
구간 라이브러리(Ranges Library)에 대하여
우선 우리는 라이너 그림(Rainer Grimm)의 C++20 교재를 풀어서 이것을 학습하려고 한다.
첫째로, 구간 라이브러리는 반복자(iterator) 없이 컨테이너 안의 특정 구간에 접근할 수 있다.
둘째로, 지연 평가(lazy evaluation)를 지원한다.
셋째로, 합성(composition)이 가능하다.
이번 시간에는 이 합성을 중심으로 구간 라이브러리를 다루도록 하겠다.
구간 라이브러리의 기본 문법
우리는 간단한 한 개의 예시를 사용할 것이다.
먼저, 우리가 순회하기 위한 컨테이너 타입을 하나 준비하자.
간략하게 0부터 5까지의 정수를 담은 컨테이너를 만든다.
// 구 표준으로는 #include <vector>
import std; // 이것을 통해 std 네임스페이스의 모든 것을 한 번에 가져옴
std::vector<int> ints {0, 1, 2, 3, 4, 5};
이후, 우리는 인덱스를 순회하기 위한 조건으로 (인덱스 % 2) == 0를 사용하겠다.
이 조건을 이용해서 순회하면 다음과 같은 인덱스들이 선택된다.
| 인덱스 | 값 | 연산 결과 |
|---|---|---|
| 0 | 0 | (0 % 2) == 0 |
| 1 | 1 | (1 % 2) == 1 |
| 2 | 2 | (2 % 2) == 0 |
| 3 | 3 | (3 % 2) == 1 |
| 4 | 4 | (4 % 2) == 0 |
| 5 | 5 | (5 % 2) == 1 |
그리고 구간 라이브러리를 사용할 시에, 현재 표준으로는 import std이나 구식으로 할 수도 있다. 그렇게 할 경우 아래 헤더를 가져 온다.
#include <ranges>
이제 해당 구간을 집어 내는 함수를 작성한다.
함수는 해당 구간의 요소들만 집어 내어, 컨테이너 타입을 반환한다.
auto even = [](int i){ return i % 2 == 0; };
이 even 함수가 특정 컨테이너로부터 결과를 걸러 내게 하려면 아래와 같이 파이프 연산을 이용해 함수를 합성한다.
for (int i : ints | std::views:filter(even)) {
std::cout << i << ' ';
}
그렇다면 이 결과는 어떻게 나올까?
우선, int i : ints 까지를 하나의 함수로 보자.
그렇게 하면 이것은 아래와 같다.
f(x) = x
따라서, 주어진 컨테이너에 대해서는 아래와 같은 결과를 반환한다.
| x값 | f(x) 값 |
|---|---|
| 0 | f(0) = 0 |
| 1 | f(1) = 1 |
| 2 | f(2) = 2 |
| 3 | f(3) = 3 |
| 4 | f(4) = 4 |
| 5 | f(5) = 5 |
그리고, 이제 2번째이다. 우리가 정의한 even 함수를 수학적으로 나타내 보자.
g(x) = \begin{cases} x & \text{if } x \equiv 0 \pmod 2 \\ \text{undefined} & \text{if } x \not\equiv 0 \pmod 2 \end{cases}
막상 latex으로 보니 더 난해하다. 의사 코드로 보자.
IF X mod 2 IS 0
THEN
X EXISTS; RETURN X
ELSE
X DOES NOT EXIST
그렇다면 이 조각정의함수를 f(x) = x와 합성하면 어떻게 될까?
수학적으로는 아래와 같이 표기할 수 있다.
(g ∘ f)(x) = x (x ∈ ℤ, x ≡ 0 (mod 2))
그러나 이렇게 보면 시각적으로 잘 들어오지 않는다. 절차적으로 풀어 보자.
X = {0, 1, 2, 3, 4, 5}
Y = f(X)
Z = g(Y)
이 때 X의 정의역은 {0, 1, 2, 3, 4, 5}일 때, Y 역시 {0, 1, 2, 3, 4, 5}의 공역을 가지며, 치역도 동일하다. X의 정의역과 Y의 치역, Y의 공역이 모두 동일하게 {0, 1, 2, 3, 4, 5}이다.
달리 말해, g(Y)의 값은 g(X)와 같다.
각 원소들을 대입하며 g(X)의 범위를 보자.
| X값 | g(x) 값 |
|---|---|
| 0 | g(0) = 0 |
| 1 | x ≢ 0 (1 mod 2), 해가 없음 |
| 2 | g(2) = 2 |
| 3 | x ≢ 0 (3 mod 2), 해가 없음 |
| 4 | g(4) = 4 |
| 5 | x ≢ 0 (5 mod 2), 해가 없음 |
따라서 g(X)의 치역은 {0, 2, 4} 이다.
이제 코드를 작성해 보자.
코드
/** C++20부터는 import 구문을 통해 특정 네임스페이스의 함수, 타입 등을
* 한 번에 가져올 수 있습니다.
*/
import std;
int main() {
/**
* 포스트에서 언급한대로 컨테이너 타입을 정의합니다.
*/
std::vector<int> ints{0, 1, 2, 3, 4, 5};
/**
* 함수의 반환값이 참일 때
* 해가 있다고 간주하고, 반환값이 거짓일 때는
* 해가 없다고 간주합니다.
*/
auto even = [](int i) { return i % 2 == 0; };
for (int i : ints | std::views::filter(even)) {
/**
* 0 % 2 == 0
* 2 % 2 == 0
* 4 % 2 == 0
*
* 따라서 출력되는 결과는
* "0 2 4 "
*/
std::cout << i << ' ';
}
/** 출력을 깔끔하게 만들기 위해 줄바꿈을 해 줍니다.
* 환경에 따라 줄바꿈을 하지 않을 시 콘솔 창의 글자들에 겹쳐져
* 출력이 더러울 수 있습니다.
*/
std::cout << std::endl;
///최종적으로 stdout에 전송되는 스트링: "0 2 4 \n"
return 0;
}
빌드 방법
CXX ?= g++
CXXFLAGS = -std=c++20 -fmodules
TARGET = main
SRC = main.cpp
STD_MODULE = $(wildcard /usr/include/c++/$(shell $(CXX) -dumpversion)/bits/std.cc)
all: $(TARGET)
$(TARGET): $(SRC) gcm.cache/std.gcm
$(CXX) $(CXXFLAGS) -o $@ $<
gcm.cache/std.gcm: $(STD_MODULE)
@mkdir -p gcm.cache
$(CXX) $(CXXFLAGS) -c $< -o gcm.cache/std.o
clean:
rm -f $(TARGET)
rm -rf gcm.cache
.PHONY: all clean
C++20의 모듈 시스템은 좋지만, 각 모듈에 해당하는 경로를 찾는 과정이 번거롭습니다.
개인적으로는 저것까지 모두 뒤로 숨겨서 std=c++20 -fmodules만으로 해독할 수 있으면 좋겠습니다.
응용
이제 이 함수의 합성을 응용해서, 현재 예제의 출력값을 제곱해서 출력하려면 어떻게 하는 것이 좋을까?
먼저, 필터가 적용된 결과인 {0, 2, 4}의 제곱을 계산해 두자.
| X | X² |
|---|---|
| 0 | 0 |
| 2 | 4 |
| 4 | 16 |
그리고 우리의 기존 출력 포맷을 다시 이해해야 한다.
출력 포맷은 다음과 같다.
- X ← 정의된 조건에 따라 반복문으로 순회:
- 정수 X가 주어질 때
- 표준 출력(stdout)에 X를 출력합니다.
- 표준 출력한 X 바로 다음 칸에 ' '를 출력합니다.
- 정수 X가 주어질 때
- 표준 출력에 줄바꿈 문자를 출력합니다.
우리가 모델링할 때에는 기존에 정의된 조건을 바꾸게 된다.
기존에 정의된 조건에 추가될 것은 제곱 함수이다. 제곱의 정의는 f(x) = x × x이다.
C++는 실수형 거듭제곱에 대해 함수를 제공한다.
이것은 cppreference.com에 따르면 아래와 같은 포맷이다.
// base는 밑, exp는 지수
float pow ( float base, float exp);
따라서 이것에 맞게 대입하면 int->float로 암묵적 캐스팅되고, 우리는 의도와 다르게 실수형을 얻게 된다.
그러나, 아래와 같은 캐스팅은 반환 결과가 int 범위를 넘어갈 경우 오버플로우가 발생하기 때문에 권장하지 않는다.
import std;
auto square = [](int i) { return (int) std::pow(i, 2); };
따라서 표준에 있는 함수라도 목적에 맞지 않으면 억지로 쓰면 캐스팅 과정에서 예기치 못한 오류가 발생할 가능성이 있다.
아래와 같이 직접 오버플로우 안전하며, int를 받아 int를 반환하는 함수를 구현한다.
단, 이 경우에는 반환 값이 std::optional<int>이기 때문에 곧바로 구간 라이브러리에서 사용할 수 없고, int만 남겨서 넘기는 과정이 필요하다.
import std;
auto square = [](int x) -> std::optional<int> {
int result;
/** __builtin_mul_overflow를 이용해서 거듭제곱하여 오버플로우를 방지합니다.
*
* 이 함수는 제곱 결과가 정수 범위를 넘어갈 경우 참을 반환하고,
* 그렇지 않을 경우 거짓을 반환합니다.
*/
if (__builtin_mul_overflow(x, x, &result))
return std::nullopt;
return result;
};
이제 이 함수를 기존의 코드에 통합해 보자.
/** C++20부터는 import 구문을 통해 특정 네임스페이스의 함수, 타입 등을
* 한 번에 가져올 수 있습니다.
*/
import std;
int main() {
/**
* 포스트에서 언급한대로 컨테이너 타입을 정의합니다.
*/
std::vector<int> ints{0, 1, 2, 3, 4, 5};
/**
* 함수의 반환값이 참일 때
* 해가 있다고 간주하고, 반환값이 거짓일 때는
* 해가 없다고 간주합니다.
*/
auto even = [](int i) { return i % 2 == 0; };
/**
* 이곳에 기존에 추가한 함수를 삽입합니다.
*/
auto square = [](int x) -> std::optional<int> {
int result;
if (__builtin_mul_overflow(x, x, &result))
return std::nullopt;
return result;
};
/**
* square 함수의 반환 결과가 존재하는지 걸러 주는 필터가 필요합니다.
* 이곳에 x가 존재할 때 한정으로 구간을 좁혀 주는 함수가 들어갑니다.
*/
auto x_has_value = [](auto const &x) { return x.has_value(); };
/**
* 위 함수에서 필터링된 값은 여전히 std::optional<int>입니다.
* 이 타입은 int를 내부 값으로 감싸고 있기 때문에,
* std::optional<int>는 int가 아닙니다.
* 따라서 아래의 함수를 통해서 std::optional<int>로 감싸진 내부 int 값을 꺼냅니다.
*/
auto x_unwrap = [](auto const &x) { return *x; };
/** x의 값을 제곱하는 것, std::optional<int>에서 정수를 꺼내는 것
* 둘 모두가 단순한 필터가 아닌 값 변경입니다.
* 따라서 std::views:transform을 쓰는 것이 자연스럽습니다.
*
* 그 이외 구간을 필터링하는 경우 여전히 std::views::filter에 전달합니다.
*/
for (int i : ints
| std::views::filter(even)
| std::views::transform(square)
| std::views::filter(x_has_value)
| std::views::transform(x_unwrap)) {
/**
* 0 % 2 == 0, 0*0 == 0
* 2 % 2 == 0, 2*2 == 4
* 4 % 2 == 0, 4*4 == 16
* 따라서 출력되는 결과는
* "0 4 16 "
*/
std::cout << i << ' ';
}
/** 출력을 깔끔하게 만들기 위해 줄바꿈을 해 줍니다.
* 환경에 따라 줄바꿈을 하지 않을 시 콘솔 창의 글자들에 겹쳐져
* 출력이 더러울 수 있습니다.
*/
std::cout << std::endl;
///최종적으로 stdout에 전송되는 스트링: "0 4 16 \n"
return 0;
}
위와 같이 매우 길고 장황한 코드가 되기 일쑤이다. 많은 C++ 프로젝트는 구문의 간결함을 위해서 잠재적으로 위험한 함수를 내버려 두고, 이것이 메모리 안전성 문제와 연관되어 다양한 정의되지 않은 동작(Undefined Behavior, UB)을 유발하기도 한다.
이런 구문의 복잡성을 단순화하기 위해 Rust와 같은 언어들이 문법 설탕(Syntax sugar)를 달고 나오지만, 대개 그 규칙이 너무 엄격하여 장단점이 뚜렷하다.
이러한 특성을 고려하면 모던 C++와 Rust가 왜 동시에 살아남으며, 조직의 코드 스타일에 따라 Rust로의 재작성이 아닌 모던 C++로의 재작성이 이루어지는지 알 수 있다.
그리고 이러한 최신 표준은 한 가지 의문을 남긴다.
사실 장황함이란 명시적이라는 말의 다른 표현이 아닐까?