본문으로 바로가기

C++20) Ranges - 1

category C++/Modern 2021. 6. 15. 02:05
1장에서는 Ranges의 기본적인 개요와 view를 다룹니다.
2장에서는 Action을 다룹니다.
C++20 에서 Actions가 추가되지 않았습니다. ( 추가가 안된 것은 아니고, namespace 가 추가되지 않음 )

그에 따라, action은 자세하게 설명하진 않고, 부분적으로 설명합니다.
actions에 관한 정보는 이곳을 확인해주세요.
2장에서는 총망라한 내용과 Algorithm을 다룹니다.

 

Ranges가 무엇일까요?

Ranges는 객체입니다. 어떠한 객체의 집합들입니다.

begin()을 이용해 처음의 객체를 얻어낼 수 있고, end()를 이용해 마지막 인자 다음의 끝을 알아낼 수 있습니다.

즉 Ranges는 begin과 end가 필수로 필요하며, 이를 통해 순회할 수 있어야 합니다.

이미 전에 ranges를 사용해본 적이 있습니다. 바로 range-based for loop ( 범위기반 for문 ) 입니다.

지레 겁먹을 필요가 없습니다. 그저 기존에 하던 행동들을 더욱 더 간편하게 사용할 수 있다고 생각하시면 됩니다.

 

Ranges는 view, action, algorithm 이렇게 세 가지를 기반으로 합니다.

algorithm은 STL과 동일하지만, 알고리즘에 iterator를 사용하는 함수 뿐만 아니라 ranges를 사용하는 함수 또한 오버로딩 되어 있습니다. 

view는 lazy operation입니다. ranges의 범위를 나타냅니다.

action은 컨테이너나 배열같은 객체의 모음을 제자리에서 변경하고, 추가 처리를 하는 컨테이너에 algorithm을 적용하는 것입니다.

 

사용하는 이유?

Ranges는 Iterator의 확장이라고 보는 것도 좋습니다.

그렇다면 iterator로 STL의 모든 작업을 할 수 있는데 왜 사용할까요?

  • 편의성과 간결함 : 우리가 beginend를 제공하여 작성했던 함수들의 매개변수들이 대폭 줄어듭니다.
    '파이프 구문( | )'를 이용하여 계산식을 나타내며, 계산들을 조합하여 나타낼 수 있습니다.
    왼쪽에서 오른쪽으로 읽어나가기 때문에 보기또한 간편합니다.
  • 지연된 연산 가능 ( lazy operation ) : 불 필요한 계산은 회피합니다. 필요할 때만 계산합니다.

개요

우리가 vector에서 가장 작은 값을 찾는다고 해봅시다. ranges 이전에서는 다음과 같이 찾아낼 수 있습니다.

std::vector<int> vec {getInput()}; // 5, 22, 88, -12, 14

auto iter {std::min_element(vec.begin(), vec.end()}; // -12

vector의 시작, 끝 iterator를 이용하여 찾아냅니다. 이는 매우 익숙합니다.

이를 iterator를 사용하지 않고 ranges를 사용하여 다시 찾아내 봅시다.

std::vector<int> vec {getInput()}; // 5, 22, 88, -12, 14

auto iter {std::ranges::min_element(vec)};

vec의 iterator들은 std::ranges::min_element에 자동으로 전달됩니다.

 

종종 getInput()을 통해서 std::ranges::min_element같은 알고리즘을 적용하고 싶을때가 있습니다.

auto iter {std::ranges::min_element(getInput())};

이런일은 발생해선 안됩니다.

 

1. getInput()으로 인해 반환되는 객체가 begin과 end를 지원하지 않을수도 있습니다. 이 경우에는 concept에 의해 자동적으로 에러를 발생할 것입니다.

2. 만약 getInput()으로 반환되는 객체가 begin과 end를 지원하더라도, getInput()은 임시객체 이므로 iter 변수가 만들어 지는 순간, iterator는 dangling iterator가 되고 맙니다.

이 때, std::ranges::dangling이 됩니다.

auto iter {std::ranges::min_element(getInput())};
std::cout << *iter;
//error: no match for 'operator*'
//(operand type is 'std::ranges::dangling')

이와 같이 역참조를 할 때, 에러가 발생합니다.

이처럼 std::ranges::dangling을 통해 오류가 무엇인지 쉽게 찾아낼 수 있습니다.

std::ranges::dangling은 iterator의 수명을 관리합니다.

 

Views

view는 '요소를 소유하지 않은 범위'라고 합니다.

조금 더 공식적으로, view는 다음과 같습니다.

  • 기본 생성 가능 ( is default constructible )
  • constant-time의 이동 연산과, 파괴 연산 ( destruction operations )이 있습니다.
  • (만약 복사 가능한 경우) constant-time의 복사 연산이 있습니다.

이것들은 속성입니다. 각 연산들은 해당 '컨테이너', '배열' 등 객체의 집합 요소 수와 무관하게 상수 시간 복잡도 ( Time Complexity ) 을 가집니다. 비용에 대해 걱정하지 않아도 됩니다.

이러한 속성들이 의미하는 것은, 가치별로 view를 전달할 수 있다는 것입니다.

viewsborrowed rangesviewable ranges라고 불립니다.

 

borrowed ranges ?

borrowed ranges (차용 범위, 빌린 범위 ?) 란 ranges가 소개되면서 함께 등장한 개념입니다.
safe_range라고도 불립니다.
borrowed ranges란 ranges가 스코프 ( {}로 감싸진 것 )를 벗어나더라도 반복자가 살아있는 (dangling이 아니거나, 반복자를 역참조 즉, operator*했을때 객체가 살아 있는 경우) ranges를 뜻합니다.
(즉, 반복자가 'value' ranges 또는 'enable_borrowed_range'와 함께 'rvalue'를 사용하지 않는 범위

view와는 조금 다릅니다. view는 constant-time의 copy / move / destroy가 있으며 view_base를 상속했거나 enable_view를 사용한 경우 입니다.

borrowed ranges에는 두 가지 경우가 있습니다.

1. lvalue 참조(T&)에 대한 ranges는 항상 'borrowed ranges' 입니다. lvalue 참조는 값 자체가 아니므로, 소멸되어도 괜찮습니다.
2. 'enable_borrowed_range'가 false로 기본 설정되어 있고 borrowed 된 것으로 간주되도록 반드시 선택해야 하는 범위

자세한 내용은 이곳을 확인해주세요.

 

모든 집합 들은 view로 변환이 가능합니다.

std::vector<int> vec {getInput()};

auto view {std::views::all(vec)};

vec에서 begin과 end를 가지고 view를 꺼내고 있습니다. 이 모든것들은 lvalue이며 reference 형태입니다.

 

반복자는 항상 값을 제대로 들고 있어야 합니다. 다음과 같은 경우를 살펴봅시다.

auto vec{getVector()};

auto v1{vec | views::transform(func)};
// OK: vec은 lvalue입니다.

auto v2{getSpan() | views::transform(func)};
// OK : span은 borrowed 입니다. ( span은 보통 view 입니다. )

auto v3{subrange(vec.begin(), vec.end()) | views::transform(func)};
// OK : subrange는 borrowed 입니다. ( view 입니다. )

auto v4{getVector() | views::transform(func)};
// ERROR : getVector()는 rvalue vector입니다. 
// view도 아니고, borrowed range도 아닙니다.

우리는 vec이라는 lvalue vector를 가지고 있습니다.

v1은 vec을 사용하고 있습니다. lvalue를 사용하고 있습니다. lvalue를 사용하므로 잘 작동합니다.

v2는 getSpan()을 사용합니다. 일반적으로 span은 view입니다. rvalue이지만 view이므로 잘 작동합니다.

v3는 subrange()를 사용합니다. (subrange는 ranges 라이브러리가 추가되면서 추가된 함수입니다.)

기본적으로 view이므로 잘 작동합니다.

v4는 getVector() rvalue (prvalue)를 사용합니다. view도 아니고 borrowed range도 아닙니다.

반복자가 안전하게 가리킬 수 없기 때문에 사용할 수 없습니다.

 

view에 대한 또 하나의 예시를 봅시다.

우리는 배열에 있는 값들을 제곱하여 합을 구하고 싶습니다.

이전 STL이라면 다음과 같이 작성할 것입니다.

auto vec {getInput()}; // [-2, -1, 0, 1, 2]

std::transform(vec.begin(), vec.end(), vec.begin(),
    [](int i) {return i * i;});

auto sumsq {std::accumulate(vec.begin(), vec.end(), 0)};

std::transform을 통하여 모든 값들을 제곱값으로 변경한 후, std::accumulate를 통하여 모든 값을 더할 것입니다.

이러한 '표준 변환'을 '표준 범위 변환'으로 대체할 수 있습니다.

auto vec {getInput()}; [-2, -1, 0, 1, 2]

ranges::transform(vec, vec.begin(),
    [](int i) { return i * i; });

auto sumsq {std::accumulate(vec.begin(), vec.end(), 0)};

더 이상 begin()과 end()가 필요 없습니다. 출력을 받기 때문에 출력의 위치는 작성해 줘야 하지만 확실히 간편해졌습니다.

하지만 이것을 좀 더 좋게 작성할 수 있습니다. view를 사용하는 것입니다.

auto vec {getInput()}; [-2, -1, 0, 1, 2]

auto view {views::transform(vec,
    [](int i) { return i * i; });

auto sumsq {std::accumulate(view.begin(), view.end(), 0)};

lazy transform view 라고도 합니다. 이것은 lazy operation 입니다.

필요할 때만 계산을 합니다. 입력 범위에서 증가를 시키고, 합치게 됩니다.

 

ranges는 파이프 연산자 ( | )를 지원합니다. 이것을 이용해 작성해보면

auto vec {getInput()};

auto view { vec
    | views::transform([](int i) { return i * i; })
    | views::common;

auto sumsq {std::accumulate(view.begin(), view.end(), 0)};

이렇게 변합니다.

 

views::common은 ranges의 iterator를 감시하는 역할이라고 보면 됩니다.

iterator/sentinel이 주어질 때, 다른 유형의 'view'가 존재 한다면, 그 'view'들을 동일한 유형으로 바꾸는 작업을 합니다.

C++20 이후 ranges / iterator 라이브러리에서는 begin()으로 얻는 것을 iterator, end()로 얻는것을 sentinel이라고 합니다.

 

예를 들어, 어떠한 range를 받아 vector로 변경하는 함수가 있다고 칩시다.

template <std::ranges::range R>
auto toVector(R&& r) {
    auto r_common {r | std::views::common};
    return std::vector(r_common.begin(), r_common.end());
}

이 처럼 r의 iterator를 통하여 std::vector를 만들어 낼 수 있는지 확인하게 됩니다.

views::common은 range 객체 하나를 받고 그것이 이미 common_range라면 std::views::all(r)의 결과를 주고,

common_range가 아니라면 r를 토대로 common_view를 구축하고 반환합니다.

common_view는 C++20 이후의 iterator (range 라이브러리 view등의 반복자들)를 C++17 이전의 iterator로 변환하는 작업을 합니다. 

 

<algorithm>에는 C++20 이전 iterator를 사용하는 함수도 있습니다. (물론 ranges 네임 스페이스 안에 있는 동명의 함수를 사용하면 common을 사용하지 않아도 됩니다..)

그들과의 호환성(결합?)을 위해서라면 사용되어야 합니다. ( 사실 안되는 경우를 보진 못했습니다..ㅎ )

 

ranges를 이용하여 멋진 일을 한번 해봅시다.

일단 std::views::common이 맘에 들지 않습니다. 새로운 accumulate를 작성해봅시다!

 

template <input_iterator I, sentinel_for<I> S,
	typename Init = iter_value_t<I>,
	typename Op = std::plus<>>
	Init accumulate(I first, S last, Init init = Init{}, Op op = Op{}) {
	while (first != last) {
		init = std::invoke(op, std::move(init), *first);
		++first;
	}
	return init;
}

하는 일은 그저 값을 받고 함수를 invoke 하여 처리하는 것 뿐입니다.

하지만 sentinel_for<I> S 라는 concept를 사용하여 들어오는 값이 iterator/sentinel 임을 제한했습니다.

이제 다음과 같이 작성이 가능합니다.

auto vec{ getInput() };

auto view = vec |
	std::views::transform([](int i) { return i * i; });

auto accsq{ accumulate(view.begin(), view.end()) };

하지만 view의 iterator/sentinel을 작성하는 것이 맘에 들지 않습니다.

새로운 accumulate를 오버로딩 합니다.

template <std::ranges::input_range R,
	typename Init = std::ranges::range_value_t<R>,
	typename Op = std::plus<>>
	Init accumulate(R&& range, Init init = Init{}, Op op = Op{}){
	
	return accumulate(ranges::begin(range), ranges::end(range), std::move(init), std::move(op));
}

std::ranges::input_range 라는 concept를 사용하여 들어오는 값이 range 임을 제한했습니다.

range (view)를 받아 이를 통해 accumulate를 호출합니다.

다시 작성된 코드를 보면

auto vec{ getInput() };

auto view = vec |
	std::views::transform([](int i) { return i * i; });

auto accsq{ accumulate(view) };

다음과 같이 줄어들었습니다.

 

이젠 view를 만드는 것과 vec이라는 변수를 만드는 것 자체도 멋져보이지 않을 수 있습니다.

이럴땐 투영 (Projection)을 사용합니다.

ranges algorithm 대부분은 projection을 지원합니다.

projection은 기본적으로 알고리즘을 스스로 호출합니다. projection은 callable 입니다.

 

위를 다시 간결하게 작성하면..

template <input_iterator I, sentinel_for<I> S,
	typename Init = iter_value_t<I>,
	typename Op = std::plus<>,
	typename Proj = std::identity>
	Init accumulate(I first, S last,
		Init init = Init{},
		Op op = Op{},
		Proj proj = Proj{}) {
	while (first != last) {
		init = std::invoke(op, std::move(init), std::invoke(proj, *first));
		++first;
	}
	return init;
}

template <std::ranges::input_range R,
	typename Init = std::ranges::range_value_t<R>,
	typename Op = std::plus<>,
	typename Proj = std::identity>
	Init accumulate(R&& range, Init init = Init{}, Op op = Op{}, Proj proj = Proj{}) {
	
	return accumulate(ranges::begin(range), ranges::end(range), std::move(init), std::move(op), std::move(proj));
}

std::vector<int> getInput() {
	return { -2, -1, 0, 1, 2 };
}

int main() {
	auto accsq{ accumulate(getInput(), {}, {}, [](int i) { return i * i; }) };
}

이렇게 변합니다.

 

'C++ > Modern' 카테고리의 다른 글

C++20) Atomic Smart Pointers / std::atomic_flag  (0) 2021.07.23
C++20) Ranges - 2  (0) 2021.07.19
C++20) Modules ( 모듈 ) - 2  (5) 2021.03.14
C++20) Modules ( 모듈 ) - 1  (0) 2021.03.14
C++20) Concepts ( 콘셉트, 개념 ) - 4  (0) 2020.11.29