C++/標準ライブラリ/ranges
はじめに
[編集]C++20から導入されたrangesライブラリは、コード記述の簡潔さと表現力を格段に向上させる新機能です。rangesライブラリでは、コンテナやイテレータなどの更に抽象化された概念として「範囲(range)」が導入されています。
範囲とは、始点と終点によって定義される任意の要素の集合を表すものです。 std::vector
や std::list
などの標準コンテナはもちろん、C言語スタイルの配列、独自に定義した反復可能な型など、あらゆる集合を範囲として扱うことができます。
従来のイテレータベースのアルゴリズムと比べ、rangesライブラリの利点は以下のようなものがあります:
- 簡潔な記述が可能
- 安全性が高い(範囲チェックなど)
- 合成性が高い(独自の範囲の作成が容易)
- 遅延実行によるパフォーマンス向上
例えば、ベクトル配列内の偶数要素のみに2を乗じる処理は、従来のイテレータ記法では以下のようになります:
std::vector<int> v = ...; std::vector<int> result; result.reserve(v.size()/2); for (auto it = v.begin(); it != v.end(); ++it) { if (*it % 2 == 0) { result.push_back(*it * 2); } }
一方、rangesライブラリを使えば、こう書けます:
std::vector<int> v = ...; auto result = v | std::views::filter(&[](int x) { return x % 2 == 0; }) | std::views::transform(&[](int x) { return x * 2; });
このように、範囲とビュー(view)を使うと、ロジックをシンプルに記述できます。
範囲とビュー
[編集]rangesライブラリの核心概念が「範囲(range)」と「ビュー(view)」です。
範囲(range)とは、始点と終点によって定義される任意の要素の集合を表すものです。C++20では、この範囲を表すための共通の概念的要件が定義されています。
rangeの種類には主に2つあります。
- 生範囲(borrowed range)
- 別の所有権下にあるデータから範囲を借用するもの(例 :
std::vector<T>
) - 所有範囲(owned range)
- 範囲自身がデータを所有するもの(例 :
std::string_view
)
生範囲の例として、std::vector
を見てみましょう。
std::vector<int> v = {1, 2, 3, 4, 5}; auto r = std::ranges::subrange(v.begin()+1, v.end()-1); // r = {2, 3, 4}
std::vector
オブジェクト v
から部分範囲 r
を作成しました。 r
は元の vector
データを範囲参照しています。
一方、所有範囲の例は std::string_view
です。
std::string_view sv = "Hello, World!"; auto r = sv.substr(7); // r = "World!"
string_viewオブジェクトsvから部分範囲rを作成しましたが、rは元のsvデータをコピーして所有しています。
ビュー(view) は、別の範囲に基づく新しい「ビュー範囲」を生成するものです。ビューは範囲に様々な操作を適用し、パイプライン処理を実現します。
std::vector<int> v = {1, 2, 3, 4, 5, 6}; auto r1 = v | std::views::filter(&[](int i) { return i % 2 == 0; }); // r1 = {2, 4, 6} auto r2 = r1 | std::views::transform(&[](int i) { return i * i; }); // r2 = {4, 16, 36}
最初のビュー r1
は、元の範囲vから偶数の要素のみを抽出(filter)したビューです。次のビュー r2
は、 r1
に基づき、更にそれぞれの要素を2乗する変換(transform)を適用しています。このように、ビューはパイプライン処理を実現する強力なツールとなります。
このように、rangesライブラリでは範囲とビューの概念を組み合わせることで、イテレーションや変換処理を合成的に記述できるようになりました。
範囲アダプター
[編集]rangesライブラリには、様々な範囲アダプターが用意されています。範囲アダプターは、範囲に対して何らかの操作を適用し、新しいビュー範囲を生成するものです。
代表的な範囲アダプターには以下のようなものがあります。
filter
- 条件に合う要素のみを抽出
transform
- 各要素に変換関数を適用
join
- 範囲の範囲を1つの範囲にフラット化
split
- 範囲を条件に従って分割
chunk
- 範囲を均等なチャンクに分割
これらの範囲アダプターを使うと、イテレーション処理をパイプライン形式で記述できます。
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 偶数を2乗し、9以下の値のみ残す auto r = v | std::views::filter(&[](int i) { return i % 2 == 0; }) | std::views::transform(&[](int i) { return i * i; }) | std::views::filter(&[](int i) { return i <= 81; }); for (auto i : r) { std::cout << i << " "; // 出力: 4 16 64 }
このように、 filter
、 transform
、 filter
を合成して適用しています。
さらに、rangesライブラリではラムダ式を使った範囲アダプターの合成も可能です。
auto square_odds = [](std::ranges::view auto r) { return r | std::views::filter(&[](int i) { return i % 2 != 0; }) | std::views::transform(&[](int i) { return i * i; }); }; std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; for (auto i : square_odds(v)) { std::cout << i << " "; // 出力: 1 9 25 49 81 }
このように、rangesライブラリの範囲アダプターを利用することで、関数型プログラミングスタイルのコード記述が可能になります。
範囲アルゴリズム
[編集]rangesライブラリには、範囲を引数として取る様々なアルゴリズムが用意されています。
for_each
[編集]ranges::for_each
は、範囲の全要素に対して操作を適用するアルゴリズムです。
std::vector<int> v{1, 2, 3, 4, 5}; ranges::for_each(v, [](int i) { std::cout << i*i << " "; }); // 出力: 1 4 9 16 25
要素へのアクセス
[編集]範囲の先頭や末尾、任意の位置の要素にアクセスするには、ranges::begin、ranges::endなどが使えます。
std::vector<int> v{1, 2, 3, 4, 5}; auto r = std::views::reverse(v); std::cout << *ranges::begin(r); // 出力: 5 std::cout << *ranges::next(ranges::begin(r), 2); // 出力: 3
変換と射影
[編集]ranges::transform
は、範囲の各要素に関数を適用し、その結果を新しい範囲として返します。
std::vector<int> v{1, 2, 3, 4, 5}; auto r = ranges::transform(v, [](int i) { return i * i; }); // r = {1, 4, 9, 16, 25}
範囲と従来のアルゴリズム
[編集]従来のアルゴリズム( std::copy
、 std::sort
、 std::find
など)も、rangesライブラリによって範囲に対応しました。rangeアダプターと組み合わせることで、より表現力の高いコードが書けます。
std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8}; std::vector<int> result; auto r = v | std::views::filter(&[](int i) { return i % 2 == 0; }); std::ranges::copy(r, std::back_inserter(result)); // result = {2, 4, 6, 8}
また、従来のコンテナと範囲を相互運用することも可能です。例えば、範囲からコンテナを構築できます。
std::vector v{1, 2, 3, 4, 5}; std::list l(std::ranges::begin(v), std::ranges::end(v));
このように、rangesは従来のアルゴリズムやコンテナともスムーズに連携できるよう設計されています。
ビューの詳細
[編集]ここまでビュー(view)の概念と基本的な使い方を説明してきました。ここからは、よく使われるビューの詳細について見ていきましょう。
filter_view
[編集]filter_view
は、指定された条件を満たす要素のみを抽出するビューです。前述の例でも使用しました。
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; auto r = v | std::views::filter(&[](int i) { return i % 2 == 0; }); // r = {2, 4, 6, 8, 10}
filter_view
はイテレータの間を進む際に、条件を評価して該当する要素のみを返します。
transform_view
[編集]transform_view
は、範囲の各要素に指定された関数を適用し、その結果を新しい範囲として返すビューです。
std::vector<int> v = {1, 2, 3, 4, 5}; auto r = v | std::views::transform(&[](int i) { return i * i; }); // r = {1, 4, 9, 16, 25}
関数はラムダ式、関数ポインタ、関数オブジェクトなどで渡せます。
join_view
[編集]join_view
は、範囲の範囲(範囲の集合)を1つのフラットな範囲に展開するビューです。
std::vector<std::string> v = {"apple", "banana", "cherry"}; auto r = v | std::views::join; for (char c : r) { std::cout << c; // 出力: applebananacherry }
このようにjoin_viewは、ネストされたコンテナをフラット化するのに便利です。
独自のビューの作成
[編集]C++20のrangesライブラリでは、独自のビューを作成することもできます。ビュー・インタフェースを実装するだけで、範囲の合成に独自のビューを組み込めます。
template<typename R> class odd_view : public std::ranges::view_interface<odd_view<R>> { private: R base; public: odd_view() = default; constexpr explicit odd_view(R r) : base(std::move(r)) {} constexpr auto begin() { auto start = std::ranges::begin(base); return std::ranges::find_if(start, std::ranges::end(base), &[](int i) { return i % 2 != 0; }); } constexpr auto end() { return std::ranges::end(base); } }; template<typename R> inline constexpr odd_view<std::views::all_t<R>> operator|(R&& r, odd_view<std::views::all_t<R>> v) { return odd_view(std::forward<R>(r)); } int main() { std::vector<int> v = {1, 2, 3, 4, 5, 6}; for (auto i : v | odd_view()) { std::cout << i << " "; // 出力: 1 3 5 } }
このodd_viewは、範囲から奇数のみを取り出すビューです。rangesの見かけ上の仕組みを利用して、独自のビューをシームレスに作成できます。
enumerate_view
[編集]enumerate_view
は、範囲の各要素にインデックス値を付与したビューを生成します。つまり、(index, value)のペアを範囲の要素として返します。
std::vector<std::string> names = {"Alice", "Bob", "Charlie", "Dave"}; for (auto [idx, name] : std::views::enumerate(names)) { std::cout << idx << ": " << name << std::endl; } // 出力: // 0: Alice // 1: Bob // 2: Charlie // 3: Dave
ここではstructuredなbindingを使って、ペアのindex、valueをそれぞれ受け取っています。
インデックス値の型は std::ranges::range_difference_t<R>
で決まります。rangeのサイズの型と同じ型が使われます。
enumerate_view
は、コンテナのインデックスにアクセスする場合に便利です。例えば、リストをインデックス付きで出力する場合などです。
また、enumerate_viewを介さずに直接enumerate関数を使うこともできます。
std::vector<int> nums = {3, 1, 4}; for (auto [idx, num] : std::ranges::enumerate(nums)) { std::cout << idx << ": " << num << std::endl; } // 出力: // 0: 3 // 1: 1 // 2: 4
このように enumerate
関数を使えば、ビューを介さずにインデックスとペアにアクセスできます。
enumerate_view
や enumerate
関数は、rangesライブラリでよく使われる便利な機能の1つです。コンテナの要素とインデックスを同時に扱う場合に役立ちます。
その他の機能
[編集]rangesにはほかにも様々な機能があります。ここでは、いくつかの重要な機能を紹介します。
範囲とコンストラクタ
[編集]rangesではコンテナのコンストラクタに範囲を渡せます。
std::vector data{1, 2, 3, 4, 5}; std::vector squares(data | std::views::transform(&[](int x) { return x * x; })); // squares = {1, 4, 9, 16, 25}
範囲の分割
[編集]chunk
や chunk_by
を使えば、範囲を分割できます。
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; for (auto subrange : std::views::chunk(v, 3)) { for (auto x : subrange) { std::cout << x << " "; // 出力: 1 2 3 4 5 6 7 8 9 10 } std::cout << "| "; // 出力: | | | }
chunk_by
を使えば、指定された条件に従って範囲を分割できます。
範囲の消費
[編集]std::ranges::elements_view
を使うと、範囲全体の要素をリテラルとして取得できます。
std::vector v = {1, 2, 3, 4, 5}; auto r = std::ranges::elements_view(v); // r = {1, 2, 3, 4, 5}
また、範囲をコンテナに格納するには std::ranges::to
が使えます。
std::vector v = {1, 2, 3, 4, 5}; std::list l = std::ranges::to<std::list>(v); // l = {1, 2, 3, 4, 5}
最適化とパフォーマンス
[編集]rangesの優れた点の1つが、遅延実行によるパフォーマンス向上です。ビュー操作はイテレーション時に実際に適用されるため、無駄な中間コピーが発生しません。また、ビューを永続化して再利用することもできます。
遅延実行
[編集]以下のようなコードを考えましょう。
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; auto r = v | std::views::filter(&[](int i) { return i % 2 == 0; }) | std::views::transform(&[](int i) { return i * i; }); std::vector<int> squares(r.begin(), r.end());
ここで、filterとtransformによる処理は、実際にrの要素をイテレートする際に行われます。つまり、無駄な中間コピーが発生しません。この「遅延実行」が、rangesの重要なメリットです。
範囲の永続化
[編集]ビューは一時的なものですが、 std::ranges::subrange
でビューを永続化できます。
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; auto r = std::ranges::subrange( v | std::views::filter(...) | std::views::transform(...)); // 後でこのrを使う
subrange
で永続化したビュー範囲rは、後で使い回せます。
パフォーマンスの考慮事項
[編集]ranges機能を使う上で、いくつかのパフォーマンスの考慮事項があります。
- ビューの合成にはオーバーヘッドがあるため、無駄な合成は避ける
- filterやtransformなどでは、関数オブジェクトの代入コピーが発生するため注意
- 範囲アダプターの中にはO(N)の操作が含まれる場合があり、大きな入力では遅くなる
- ビューは一時的なので、再利用が必要な場合は永続化(subrange)する
例えば、以下のようなコードでは無駄な合成が行われています。
auto r = v | std::views::transform(&square) | std::views::filter(&is_even) | std::views::transform(&square_root);
この場合、最初のtransform後にfilterを適用すればよいため、2つ目のtransformは不要です。
このように、ranges機能を適切に使うことで、パフォーマンスの最適化が図れます。
{{コラム|C++のrangeと関数型プログラミング|2=C++20のrangesライブラリは、関数型プログラミングのコンセプトをネイティブでサポートしています。この点が、rangesの大きな特徴の1つです。
関数型プログラミングでは、状態を持たない純粋な関数を組み合わせることで、プログラムを記述します。rangesライブラリの範囲アダプターやビューは、この関数合成のための仕組みを提供しています。
例えば、以下のようなコードを見てみましょう。
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 偶数を2乗し、9以下の値のみ残す auto r = v | std::views::filter(&[](int i) { return i % 2 == 0; }) | std::views::transform(&[](int i) { return i * i; }) | std::views::filter(&[](int i) { return i <= 81; }); for (int i : r) { std::cout << i << " "; // 出力: 4 16 64 }
ここでは、filterとtransformという2つの関数が合成されています。1つ目のfilterで偶数を抽出し、transformで2乗し、2つ目のfilterで81以下の値を残しています。
このように、rangesではパイプライン記法を使って、関数を合成することができます。各関数は状態を持たず、ただ与えられた引数から結果を計算するだけです。これは関数型プログラミングの典型的なスタイルです。
さらに、rangesではラムダ式を使った高階関数の記述も可能です。
auto square_odds = [](std::ranges::view auto r) { return r | std::views::filter(&[](int i) { return i % 2 != 0; }) | std::views::transform(&[](int i) { return i * i; }); }; std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; for (int i : square_odds(v)) { std::cout << i << " "; // 出力: 1 9 25 49 81 }
ここでは、square_oddsという高階関数を定義しています。この関数は、与えられた範囲に対し、奇数を抽出し2乗するロジックを適用します。関数合成とラムダ式によって、ロジックを関数オブジェクトとしてカプセル化できます。
また、rangesによってC++にジェネレータが実質的に導入されました。ビューは遅延実行されるため、イテレータがビューを評価する際に、要素が1つずつ生成(ジェネレート)されていきます。
auto ints = std::views::iota(0) | std::views::transform([](int i) { return i * i; }); for (int i : std::ranges::take_view(ints, 5)) { std::cout << i << " "; // 0 1 4 9 16 }
ここでは、iota viewとtransform viewを合成して、無限の2乗数列を生成しています。take_viewで最初の5つの要素だけを取り出しています。
このように、rangesライブラリはC++に関数型プログラミングの機能を取り入れ、イテレーションの抽象化、合成性の向上、ジェネレータなどを実現しています。シンプルでわかりやすいコードを書けるだけでなく、新しいプログラミングスタイルの可能性も切り開いています。 }}
まとめと課題
[編集]ここまでrangesライブラリの概要と基本的な使い方を説明してきました。最後に、本章をまとめると共に、rangesの課題やベストプラクティスについて触れます。
rangesの利点のまとめ
[編集]- コードの簡潔性と表現力が向上する
- 関数型プログラミングスタイルが適用できる
- 安全性が高い(範囲チェックなど)
- パフォーマンスが向上する(遅延実行、無駄なコピーの削減)
- 独自の範囲アダプターやビューを作れる
課題とベストプラクティス
[編集]- 範囲の合成が複雑になりすぎないよう注意
- 無駄な範囲の合成は避け、パフォーマンスに気をつける
- ビューを再利用する場合はsubrangeで永続化する
- 範囲アダプターの詳細を理解し、適切に使い分ける
- 範囲は参照を返すため、ダングリング参照に注意
- 範囲ベースのコードは一般にわかりにくいため、コメントを心がける
今後の展望
[編集]- C++23ではrangesライブラリに更なる拡張が加えられる予定
- 範囲に対する並列アルゴリズムの導入が検討されている
- 範囲に関する標準ライブラリの充実が進むと期待される
rangesライブラリは、C++プログラミングのスタイルを一新する革新的な機能です。シンプルかつ表現力の高いコードを実現しつつ、アプリケーションのパフォーマンスも向上できます。一方で、一部の機能の複雑さや学習コストなどの課題もあります。今後、より安全で使いやすい範囲ライブラリが提供されることを期待しましょう。
以上がrangesライブラリの解説でした。皆さんのC++プログラミングの幅が広がることを願っています。