이번 장에서는 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를 굳이 왜 써야 할까요?
- 편의성 : 코드가 엄청 간결해 집니다.
- 게으른(느긋한? 지연된?) 연산(Lazy Operation)
불필요한 계산 회피
무한 범위 표현 가능
- 조합성
자동으로 이전 연산의 결과를 다음 연산의 입력으로 사용함으로써 연산들을 손쉽게 연결 할 수 있음
직관적인 표현 가능
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 라이브러리를 사용하는 코드입니다.
마치 리눅스의 파이프를 사용하는 것 처럼 생겼고, 동작 방식도 비슷합니다. 코드에 대해 간단히 설명 하자면
- 벡터 v가 입력 값이 되어 오른쪽 filter에게 넘어 갑니다.
- filter는 v의 모든 요소에 대해서 filter에 등록된 람다를 적용해 결과값이 true인 요소들만 오른쪽의 take에게 넘깁니다.
- 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를 생성하는 방법
- view class template를 직접 생성
- 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. 같이 읽으면 좋은 글