[C++] Iterator를 활용하여 느긋하게 수열 생성하기

3 minute read

https://github.com/Othereum/LazySequenceGenerator

우리는 보통 vector에 수열을 채울 때 다음과 같이 한다 (C++20 Ranges 라이브러리가 나오면 어떻게 될지는 모르겠지만, 적어도 C++17 까지는):

vector<int> v;
for (int i = 0; i < 10; ++i)
    v.push_back(i);

그런데 vector의 생성자 중에는 반복자 2개를 받는 버전이 있다:

template <class InputIt>
vector(InputIt first, InputIt last);

이를 활용하여 문법을 간소화할 수 있지 않을까? 라는 생각이 들었다. 그렇게 생각해낸 것이 iterator를 활용한 수열 생성기. 이 방법을 사용하면 한 번에 모든 수열을 생성하지 않고 필요할 때만 그때 그때 하나씩 생성한다.

vector 생성에 다음과 같이 사용할 수 있을 것이다:

Arithmetic it;
vector<int> v(it, it + 10);

처음에 만든 구조는 다음과 같다:

template <class T, class U, U C, class SeqFn>
class SeqItBase
{
public:
    using iterator_category = std::random_access_iterator_tag;
    SeqItBase(T init = T{}, SeqFn Fn = SeqFn{});
    ...
}

template <class T = int, class U = int, U C = 1, class SeqFn = std::plus<>>
using Arithmetic = SeqItBase<T, U, C, SeqFn>;

처음에는 타입 안정성 및 최적화를 위해 공차(혹은 공비) C를 템플릿 매개변수로 사용하려고 했으나, 이렇게 하면 C는 템플릿 매개변수이기 때문에 runtime에 설정할 수 없고, 정수형 외의 타입도 사용할 수 없었다. 또, 범용성을 위해 수열 생성 함수 T SeqFn(T, U)도 매개변수로 받으려고 했으나, 이렇게 하면 역순열을 구하기 위해 역함수도 받아야 하고, random access가 불완전했다 (가능은 하다).

그래서 다음과 같이 변경했다:

template <class T = int>
class Arithmetic
{
public:
    using iterator_category = std::random_access_iterator_tag;
    constexpr Arithmetic(T Init = T{}, T CD = 1);
    ...
}

공차를 멤버변수로 넣었다. 덕분에 유연성은 좋아졌으나, 공차가 다른 것끼리 비교 연산을 수행하는 것은 미정의 동작이다. 또한, 종류 자체가 다른 수열은 아예 다른 클래스로 따로 만들기로 했다. 대표적인 수열들을 만들어볼까 한다. 등차수열 만들었고, 등비수열, 피보나치 수열, 등등..

여기서 끝내긴 아쉬워서 하나를 더 만들기로 했다. Python에서는 다음과 같은 문법을 사용할 수 있다:

for i in range(5):
    print(i)

for i in range(5, 10):
    print(i)

for i in range(1, 10, 3):
    print(i)

for i in range(20, 10, -2):
    print(i)

같은 코드를 C++에서는 다음과 같이 작성한다:

for (int i = 0; i < 5; ++i)
    cout << i;

for (int i = 5; i < 10; ++i)
    cout << i;

for (int i = 1; i < 10; i += 3)
    cout << i;

for (int i = 20; i > 10; i -= 2)
    cout << i;

확실히 python쪽이 깔끔하고 가독성도 좋다. 왠지 진 기분이 들었다. 하지만 괜찮다. C++에서도 가능하니까!

C++11에 들어온 range-based for loop는 다음과 같이 사용할 수 있다:

int arr[]{ 15, 3, 24, 51, -5, 6 };
for (int n : arr)
    cout << n;

vector<int> v{ 15, 3, 24, 51, -5, 6 };
for (int n : v)
    cout << n;

for (int n : { 15, 3, 24, 51, -5, 6 })
    cout << n;

즉, 다음과 같다:

for (range_decl : range_expr) loop_statement

range_expr에는 크게 세 가지가 들어갈 수 있다.

  1. C 스타일 정적 배열
  2. InputIterator를 반환하는 beginend 멤버 함수를 가지고 있음
  3. ADL 규칙에 따라 begin(range_expr)end(range_expr) 표현식이 유효함

여기서 주목할 것은 2번이다. 저 조건만 만족하면 range-based for loop에 사용할 수 있다. 이를 만족하는 Range 클래스를 정의해보면 다음과 같다:

template <class T>
class Range
{
public:
    constexpr Range(T Last);
    constexpr Range(T First, T Last, T CD = 1);
    constexpr Arithmetic<T> begin() const { return { First, CD }; }
    constexpr Arithmetic<T> end() const { return { Last, CD }; }
}

이는 이렇게 쓸 수 있다:

for (auto i : Range(5))
    cout << i;

for (auto i : Range(5, 10))
    cout << i;

for (auto i : Range(1, 10, 3))
    cout << i;

for (auto i : Range(20, 10, -2))
    cout << i;

와! 이정도면 파이썬 1도 부럽지 않다. 또한 vector에 수열을 채우는 것도 가능하다:

Range r(10); // [0, 10)
vector v(r.begin(), r.end());

마지막으로, 다음과 같은 시나리오를 고려해볼 수 있다:

vector v;
for (auto i : Range(2, v.size()))
{ ... }

두 인자의 타입이 다르기 때문에 타입 추론 에러가 난다. 이는 흔히 발생하는 상황이다. 해결하려면 Range<size_t>(2, v.size()) 뭐 이런 식으로 해야할 것이다. 참으로 불편하기 짝이 없다. 이러한 불편을 C++17에 생긴 user-defined template argument deduction guide를 사용하여 해결할 수 있다 (보통 줄여서 deduction guide 라고 한다):

template <class T, class U>
Range(T, U)->Range<std::common_type_t<T, U>>;

template <class T, class U, class V>
Range(T, U, V)->Range<std::common_type_t<std::common_type_t<T, U>, V>>;

상당히 간단한데, Range의 생성자가 임의의 타입 2개, 3개의 인자로 호출되었을 때 -> 이러한 템플릿 인자를 사용하라는 것이다. common_type_t는 일반 산술 변환 규칙에 따라 두 타입 중 더 큰 타입을 반환한다. 이제 다음과 같은 문법이 허용된다:

Range a{ 3u, 6, 4.52 }; // Range<double>
for (auto n : Range(3u, 5)); // Range<unsigned int>

이상이다! GitHub 저장소는 여기 있다: https://github.com/Othereum/LazySequenceGenerator

Categories:

Updated:

Comments