【C++】Rangesについて調べてみました
こんにちは、情熱開発部プログラム課の青柳です。
最近なくしものが多くなってきました、と思いましたがものをなくすのは昔からなので改めて注意したいと思います。
イヤホンのパーツをなくすのはもうこりごり。
さておき今回はC++20で導入されたRangesについて調べてみました。
動作環境はWindows11 Visual Studio 2022(Version 17.14.27 (February 2026))
C++言語標準を ISO C++20標準(/stdc++20)
AddressSanitizerを有効にして行いました。
目次
Rangesの概要
rangesでは、イテレータの組ではなく、コンテナや配列、部分的なコンテナなどの範囲(Range)を直接扱うライブラリを提供する。
とあります。従来std::algorithmを使う際にはイテレータのbegin, endを渡す必要がありました。
Rangesではコンテナを直接algorithmに渡せるようになります。
#include <iostream>
#include <ranges>
#include <vector>
#include <algorithm>
#include <concepts>
std::vector<int> v{ 1, 3, 5, 2, 4 };
// C++20 以降では、std::ranges::sort を使用してベクターをソートできます。
std::ranges::sort(v);
// C++20 より前のバージョンの書き方
//std::sort(v.begin(), v.end());
std::cout << "ソートされたベクター: ";
// ソートされたベクターを表示
for (int i : v) {
std::cout << i << " ";
}
// 実行結果: ソートされたベクター: 1 2 3 4 5
std::cout << std::endl;
std::ranges::sort(v, std::greater());
std::cout << "降順ソートされたベクター: ";
// 降順ソートされたベクターを表示
for (int i : v) {
std::cout << i << " ";
}
// 実行結果: 降順ソートされたベクター: 5 4 3 2 1またパイプライン演算子”|”が導入され、コンテナに対する処理を数珠つなぎのように表現する事が可能になりました。
更にデータコピーをしないRangeアダプタ(view)という概念を導入する事により、この結果を軽量に受け取る事が出来ます。
// C++20 以前のコード、3の倍数をフィルタリングしてから、平方を計算する
// 3の倍数をフィルタリングした結果を受け取るための中間コンテナが必要
//std::vector<int> input = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
//std::vector<int> intermediate, output;
//std::copy_if(input.begin(), input.end(), std::back_inserter(intermediate), [](const int i) { return i % 3 == 0; });
//std::transform(intermediate.begin(), intermediate.end(), std::back_inserter(output), [](const int i) {return i * i; });
std::vector<int>* input = new std::vector{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// C++20 以降のコード、フィルタリングと変換をチェーンして行うことができる
// フィルタリングと変換の結果を受け取るための中間コンテナは必要ない
auto output = *input
| std::views::filter([](const int n) {return n % 3 == 0; })
| std::views::transform([](const int n) {return n * n; });
for(const auto& n : output)
{
std::cout << n << " ";
}
std::cout << std::endl;
// 実行結果: 0 9 36 81C#を使われてる方はLinqを思い出されるのではないでしょうか。
もう少し具体的な例と注意点
もう少し具体的な例としてユーザー定義クラスでの使用方法を見てみます
以下のようなクラスとメソッドを定義しました
struct Entity
{
int id;
bool isVisible;
bool isActive;
// 三方比較演算子
friend auto operator<=>(const Entity&, const Entity&) = default;
void update()
{
std::cout << "update: " << id << std::endl;
}
void lateUpdate()
{
std::cout << "lateUpdate: " << id << std::endl;
}
};
template <std::ranges::input_range R>
requires std::same_as< std::ranges::range_value_t<R>, Entity >
void Update(R&& entities)
{
for (auto& entity : entities)
{
entity.update();
}
}
template <std::ranges::input_range R>
requires std::same_as< std::ranges::range_value_t<R>, Entity >
void LateUpdate(R&& entities)
{
for (auto& entity : entities)
{
entity.lateUpdate();
}
}さらりとConceptsを使用していますが、Conceptsに関しては弊社ブログ記事をご参照ください。
Conceptsを使えばstd::vectorもパイプライン演算子で抽出したviewも同じように流し込めるメソッドをわかりやすく定義できます。
このクラスとメソッドを使って以下のような例で動作を見てみましょう。
auto input = std::make_unique<std::vector<Entity>>();
for (int i = 0; i < 10; ++i)
{
Entity element{};
element.id = i;
element.isActive = i % 2 == 0;
element.isVisible = i % 3 == 0;
input->push_back(element);
}
// activeでvisibleなものを抽出
auto output = *input
| std::views::filter([](Entity& val) { std::cout << "id: " << val.id << ", active: " << val.isActive << std::endl; return val.isActive; })
| std::views::filter([](Entity& val) { std::cout << "id: " << val.id << ", visible: " << val.isVisible << std::endl; return val.isVisible; });
// すべて実行
//Update(*input);
//LateUpdate(*input);
// 6の倍数だけ実行
Update(output);
LateUpdate(output);少し長いですが実行結果は以下のようになります。
id: 0, active: 1
id: 0, visible: 1
update実行!: 0
id: 1, active: 0
id: 2, active: 1
id: 2, visible: 0
id: 3, active: 0
id: 4, active: 1
id: 4, visible: 0
id: 5, active: 0
id: 6, active: 1
id: 6, visible: 1
update実行!: 6
id: 7, active: 0
id: 8, active: 1
id: 8, visible: 0
id: 9, active: 0
lateUpdate実行!: 0
id: 1, active: 0
id: 2, active: 1
id: 2, visible: 0
id: 3, active: 0
id: 4, active: 1
id: 4, visible: 0
id: 5, active: 0
id: 6, active: 1
id: 6, visible: 1
lateUpdate実行!: 6
id: 7, active: 0
id: 8, active: 1
id: 8, visible: 0
id: 9, active: 0パイプライン演算子でつないだstd::views::filterは要素毎に処理され、左から順番に評価されることが分かります。
なんとなく想像した通りの動きをしているようです。
注意点
viewの動きについては少し注意すべき点があります。
- 評価結果はキャッシュされない
評価結果は保存されることはなく、LateUpdate実行時にも同じ処理が行われます。
※lateupdateのid: 0番がログが出てないのですが、これはどうもbegin()の結果だけキャッシュされるようです、環境依存でしょうか、、、。
次に注意すべき点は元のコンテナに変更が加わった場合、その変更が反映される事です。
- コンテナを解放した場合
viewは参照だけを提供する為、仮に途中でコンテナを開放してしまう場合は未定義動作になります。
以下の例は事前に削除しているため未定義になります。
これはAddressSanitizerを有効にしていない場合、Debugビルドなら実行時にエラーになりますが、Releaseビルドでは検知されませんでした。
今回はAddressSanitizerを有効にしているのでどちらも検知されます。
input.reset();
// 6の倍数だけ実行
// Address Sanitizer のエラー: 割り当てを解除されたメモリの使用
// ReleaseビルドではAddressSanitizerを有効にしないと検知されない
Update(output);
std::cout << std::endl;
LateUpdate(output);- clearした場合
また評価は遅延実行されるため、以下の例は評価前にクリアされているので範囲For文が実行されません。
input->clear();
// 6の倍数だけ実行
// 空なので実行されない
Update(output);
std::cout << std::endl;
LateUpdate(output);viewの実装の中を雑に推察する
Rangesの実装詳細の核にはviewsの存在がある事は想像できます。
これがどのような実装になっているのか軽く確認していきましょう。
動作を見るにstd::views::filterは以下の要素を持ったクラスと言えそうです
- 元のコンテナへの参照
- 処理を行うための関数
参照と評価関数を持っているだけでこれ自体は何もしていません。
範囲For文などで実際に評価される際に初めてPredが実行されます。
特殊なイテレーターが定義されていそうです。疑似コードとしては以下のようになるでしょうか。
※実際にはrequiresを使って満たすべき条件を規定しているようです。
template<class Iter, class Predicate>
class MyFilterIterator
{
Iter current_;
Iter end_;
Predicate* pred_;
MyFilterIterator& operator++()
{
++current_;
while(current_ != end_)
{
if((*pred_)(*current_))// 評価実施
break;
++current_;// 満たさなければ自動で次へ
}
return *this;
}
};std::views::filterは参照を保持しておく入れ物で、実際の処理はイテレーターがoperator++などを実行する事で行われる。
という理解で間違いなさそうです。
これならviewsは軽量で何もしない(コンテナなどではない)、遅延評価される。というのも納得です。
イテレートする際に行われる事について
C++リファレンスには以下のようにあります
また、従来のイテレータの組は基本的に同じ型であることが期待されていたが、C++20のRangesでは end で得られるものは番兵(sentinel)と規定され、イテレータと同じ型でなくてもよくなった。
end()がsentinelで規定されるとはどういう事でしょうか。
例えばstd::views::iota(0)は単調増加する無限長列を表します。
std::ranges::iota_viewリファレンス
これはsentinelに要求されることが
iterator == sentinel
iterator != senttinelが出来る事をだけであり、iterator != senttinelが常にTrueを返すのであれば無限に評価される、という事になります。
範囲For文を例にしてどのような事かを具体的に考えてみます。
C++20になった事で範囲For文が以下のように展開されるようになったと言えそうです。
auto r =
std::views::iota(0)
| std::views::take(10);// 10まで抽出
auto begin = std::ranges::begin(r);
auto end = std::ranges::end(r);
for (; begin != end; ++begin)// sentinelとiteratorの比較とiteratorの進行
{
std::cout << *begin << ' ';
}
// 実行結果: 0 1 2 3 4 5 6 7 8 9この時beginの型はstd::counted_iterator::type、endの型はstd::ranges::take_view::_Sentinelとなっていて、二つの型は異なっています。
このようにC++20以降においてbegin()とend()は別の型である事が許されるようになりました。
実際にどこの部分で使った方がいいか検討
色々書いてきましたが実際にはどのような部分で使うべきでしょうか。
注意すべき点で上げた通り抽出結果は保存されないので、結果を何度も使いそうなところには使いにくいです。
Entityを例にしましたがこういうものには使いにくそうですね、、、。IsVisibleの更新も軽いとは言えませんし。
またコンテナをうっかり開放してしまった際にReleaseビルドだと何も言わない事にも細心の注意が必要です。
逆を言えば、抽出結果を1回しか使わない場合、一番最初の例のようにTemporaryのコンテナを省略できる場合、その上でデータの流れを記述したい場合に使うのがよさそうです
例えば未ロードのアセットの抽出をする場合に
auto preloadAssets =
assets
| std::views::filter(IsUsedInCurrentLevel)
| std::views::filter(IsNotLoaded)
| std::views::take(MaxPreloadCount);のように書ける場合があります。処理が上から下にわかりやすくなっているのではないでしょうか。
個人的にはデータの流れを記述するのによさそうに感じました。
終わりに
ざっと見るだけでもコード量が多くなってしまいました。間違いなどあれば優しく教えて頂けると幸いです。
C++20で導入されたRangesですがC++23, C++26でさらに拡張され便利になっていく予定があるそうです。
まだまだRangesの入り口に立ったばかり、という事で機会があれば積極的に使っていきたいと思います。
Rangesの実際の使用例があるSiv3D v0.8のコードを見てみましょう。
std::views::で検索してと、、ふむふむ、なるほど。私にはまだ早いようですね、、、。
参考にさせて頂いたサイト、スライド
C++日本語リファレンス ranges
MSDN
CEDEC 2024『ゲーム開発者のための C++17~C++23, 近年の C++ 規格策定の動向』
次期 Siv3D(Siv3D v0.8)で採用した技術
Siv3D
【免責事項】
本サイトでの情報を利用することによる損害等に対し、
株式会社ロジカルビートは一切の責任を負いません。