본문 바로가기

진리는어디에/C++

[C++20] Ranges

이번 장에서는 C++20에서 추가된 range 라이브러리에 대해서 알아 보겠습니다.

처음에 새로운 라이브러리가 추가 되었다고 해서 새로운 패러다임이 가미된 엄청 복잡한 뭔가일 것이라 지레 겁을 먹고 시작했지만..사실 std::list, std::vector와 같은 자료 구조에 범위기반 알고리즘을 제공하는 편리하고 간단한 라이브러리 입니다.

C++11에서 auto 키워드와 ranged for를 추가하여 길고 번거로운 iterator를 이용한 순회 코드를 간단하게 만들어 준것 처럼 C++20에서는 더 간단한 범위 기반 연산 함수들을 제공합니다.

Range?

C++20에서 부터 추가 되는 std 라이브러리 range는 아래와 같이 정의 되고 있습니다.

  • 아이템들의 추상적인 집합
  • 순회 가능해야 한다.
  • begin(), end()를 사용 할수 있을 것
    임의의 타입이 range 네임스페이스 안에 있는 begin과 end의 인자로 사용 할 수 있으면 range라고 합니다.

C++20 표준에서 제공하는 concept을 이용해 range 여부 알아낼 수 있습니다.

template < typename T>
concept range = requires(T& t)
{
    std::ranges::begin(t);
    std::ranges::end(t);
};

std::ranges::range<int>; // false
std::ranges::range<int[5]>; // true
std::ranges::range<std::vector<int>>; // true

Range라고 할수 있는 것들 :

  • stl 컨테이너
  • 배열
  • C++20에서 추가된 다양한 view들
    (뷰에 대한 설명은 아래에 나옵니다)

Range를 왜 써야 하나요?

이미 iterator로 다 할수 있는데 range를 굳이 왜 써야 할까요?

  1. 편의성 : 코드가 엄청 간결해 집니다.
  1. 게으른(느긋한? 지연된?) 연산(Lazy Operation)
    불필요한 계산 회피
    무한 범위 표현 가능
  1. 조합성
    자동으로 이전 연산의 결과를 다음 연산의 입력으로 사용함으로써 연산들을 손쉽게 연결 할 수 있음
    직관적인 표현 가능

Range의 기본 사용법

이번 장에서는 C++20 ranges 라이브러리에서 제공하는 기능들의 세세한 사용법을 살펴보지는 않을 것입니다. 그런 부분들은 메뉴얼 페이지에 예제 코드들과 함께 더 자세히 설명 되어 있습니다.

대신 개념과 용어들을 소개함으로써 여러분들이 메뉴얼을 볼 때 보다 쉽게 이해 하실 수 있게 도움을 드리고, 제가 공부하면서 실수 했던 부분을 집고 넘어 가면서 여러 분들은 저와 같은 삽질을 피하게 하는 것이 이번장의 목적입니다.

자세한 설명에 앞서 예제 코드를 먼저 보시죠

#include <vector>
#include <ranges>
#include <iostream>
#include <algorithm>

int main()
{
    std::vector<int> v = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
    auto r = v | std::views::filter([](int a) { return a % 2 == 0; }) | std::views::take(3);

    std::cout << "output : ";
    for (auto n : r)
    {
        std::cout << n << " ";
    }
    std::cout << std::endl;

    std::ranges::reverse(r);

    std::cout << "reverse : ";
    for (auto n : r)
    {
        std::cout << n << " ";
    }
    std::cout << std::endl;

    std::cout << "original : ";
    for (auto n : v)
    {
        std::cout << n << " ";
    }
    std::cout << std::endl;
}

하이라이트로 강조된 9라인과 18라인이 Ranges 라이브러리를 사용하는 코드입니다. 

마치 리눅스의 파이프를 사용하는 것 처럼 생겼고, 동작 방식도 비슷합니다. 코드에 대해 간단히 설명 하자면 

  1. 벡터 v가 입력 값이 되어 오른쪽 filter에게 넘어 갑니다.
  2. filter는 v의 모든 요소에 대해서 filter에 등록된 람다를 적용해 결과값이 true인 요소들만 오른쪽의  take에게 넘깁니다.
  3. take는 필터링 된 요소들에 대해 가장 앞에 있는 3개만 선택해서 r 이라는 객체를 만듭니다.

결과는 아래와 같습니다.

Output :

output : 2 4 6
reverse : 6 4 2
original : 1 6 3 4 5 2 7 8 9 0

위 예제에서  std::ranges::reverse( r ) 주목해봅시다. 리턴 값 r에 알고리즘 reverse를 적용하면 우리의 예상 대로 6, 4, 2가 나옵니다. 뜬금없이 reverse를 꺼낸 이유는. 세번째 결과 때문인데요. 저 reverse 적용 후 출력한 v의 값이 1 6 3 4 5 2 7 8 9 0로 원본과 다릅니다. r 은 벡터 v의 복사본을 가지고 있는 것이 아니라 참조를 가지고 있다는 뜻입니다.

저의 경우는 결과 값이 복사본을 가지고 있을 것이라 생각하고 코딩하다 삽질을 좀 했습니다. 여러분들은 저와 같은 실수를 하지 않길 바랍니다.

그래서 range 연산의 결과는 무슨 타입 인가요?

auto r 이라고 정의 되어 있어서 도데체 이게 무슨 타입의 객체인지 궁금하실 겁니다.(아니라구요? 저는 궁금해서 미칠뻔 했습니다.)

설명하기 위해 아래 코드를 살펴보죠.

std::vector<int> v = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
auto r = v | std::views::take(3)

std::cout << typeid(r).name() << std::endl;

'|'(파이프연산자)는 단순히 코드 편의성을 위해 연산자 재정의 테크닉으로 만든것에 불과하고, 실제 위의 코드는 std::views::take_view tv(v, 3) 와 동일한 동작을 하는 코드입니다. 

정확하게는 std::views::take는 take_view클래스의 객체를 리턴하는 함수 객체입니다.

아래와 같이 객체 r의 타입을 출력해보면 class std::ranges::take_view라고 출력 됩니다. 이 외에도 filter_view, transform_view, ref_view 등 많은 뷰가 있습니다. 본 포스트에서는 이 다양한 뷰들을 통칭하여 그냥 뷰라고 부르겠습니다.

// typeid를 이용해 출력한 r의 타입

std::ranges::take_view<
    std::ranges::ref_view<
        std::vector<int, std::allocator<int> > 
    >
>

뷰(View)

위에서 언급한 조합성과  게으른(느긋한? 지연된?) 연산은 이 뷰를 가리키는 말이었습니다. 이전 예제에서 보셨듯이 뷰의 결과를 다음 뷰의 입력으로, 또 그 결과를 다음 뷰의.. 이런식으로 뷰 끼리 계속 연결 해서 계산이 가능 합니다.

그리고 lazy operation, 예를 들면 std::ranges:;transform_view trv(tv, [](int a) return { a * 2; });코드에서 객체를 생성 할 때 람다를 실행하는 것이 아니라, 일단 값을 들고 있다가 실제 호출 될때 연산합니다.

View를 생성하는 방법

  1. view class template를 직접 생성
  2. range adapter object를 사용
    ※ 위에서 std::views::take 처럼 뷰클래스 템플릿을 만들어 주는 함수객체들을 range adapter object 라고 부릅니다.

Range를 사용하는 방법

view class template을 직접 생성

std::vector<int> v = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
std::ranges::filter_view  view1(v, [](int n) { return n %2 == 0; });
std::ranges::take_view  view2(view1, 3);
std::ranges::reverse_view view3(view2);

range adapter object 사용

std::vector<int> v = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
auto view1 = std::views::filter(v, [](int n) { return  n%2 == 0;}
auto view2 = std::views::take(view1, 3);
auto view3 = std::views::reverse(view2);

파이프 연산자 사용

auto view3 = v | std::views::filter([](int n) { return  n%2 == 0;})
                    | std::views::take(3)
                    | std::views::reverse;

// 인자가 없다면 그냥 객체의 이름을 사용한다. 함수 처럼 ()를 붙여 주면 컴파일 실패한다. 
// 다시 한번말하지만 쟤들은 함수가 아니라 객체다.

이상으로 C++20에서 새로이 추가된 ranges 라이브러리에 대해 알아 보았습니다. 정리를 하면 ranges는 새로운 패러다임이 아니라 기존 코드를 좀 더 간단하고 쉽게 사용 할 수 있게 도와 주는 라이브러리다, View라는 클래스들이 추가 되었고, 이것들을 이용해 범위 연산을 쉽게 할 수 있다. View 클래스는 직접 생성 할 수도 있고, range adapter object를 이용 할 수도 있다  정도로 요약 될 수 있겠습니다.

보다 자세한 사용법은 https://en.cppreference.com/w/cpp/ranges 참고 하시어 필요한 view를 골라 적재적소에 사용하면 보다 튼튼하고 간견한 코드를 만드실수 있을 겁니다.

감사합니다.

부록 1. 같이 읽으면 좋은 글

 

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!