⛓️

[C++] <ranges>のviewを見る18 - split_view

2021/11/26に公開

split_view

split_viewは文字列分割に特化したlazy_split_viewです。

#include <ranges>
#include <iostream>
#include <string_view>

int main() {
  // ホワイトスペースをデリミタとして文字列を切り出す
  std::ranges::split_view sv{"split_view takes a view and a delimiter", ' '};
  
  // split_viewは切り出した文字列のシーケンスとなる
  // その要素は容易に文字列として扱う事ができる
  // string_viewへの直接変換はC++23から
  for (std::string_view str : sv) {
    std::cout << str << '\n';
  }
  /*
    split_view
    takes
    a
    view
    and
    a
    delimiter
  */
}

split_viewも分割した部分文字列を表すrangeを要素とするシーケンスを返します。しかし、lazy_split_viewと異なり内側のrangeは入力rangeのイテレータを利用したstd::ranges::subrangeとなるため、文字列であれば内側rangecontiguous_rangeかつcommon_rangeとなり、かなり扱いやすくなっています。

さらに、std::string_viewにはC++23からrangeコンストラクタが追加されているので、そのままストレートに変換する事ができます。C++20でも、forの内部で変換する1行のコードを足すだけで切り出した部分文字列をstd::string_viewとして扱うことができます。

#include <ranges>
#include <iostream>
#include <string_view>

int main() {
  // ホワイトスペースをデリミタとして文字列を切り出す
  std::ranges::split_view sv{"split_view takes a view and a delimiter", ' '};
  
  for (auto inner_range : sv) {
    // 内側rangeからstring_viewへの変換
    std::string_view str{inner_range.begin(), inner_range.end()};
    std::cout << str << '\n';
  }
}

遅延評価

split_viewもまた遅延評価によって分割処理とviewの生成を行いますが、lazy_split_viewとは少し異なります。

split_viewでは、.begin()による外側イテレータの取得時に最初のデリミタ位置を探索し文字列先頭位置とともに記録しておきます。そして、外側イテレータのインクリメントのタイミングでデリミタ位置と文字列先頭位置の更新を行なっていきます。外側イテレータの間接参照では、現在の文字列先頭位置と次のデリミタ位置のイテレータから切り出す文字列をsubrangeによって返すため、内側rangeは特別なことを何もしません。外側イテレータの終端チェックでは、デリミタ列に一致する文字列が終端に来ているケースを考慮するために少し判定が増えていますが、lazy_split_viewと比べると単純になっています。

std::ranges::split_view sv{"split_view takes a view and a delimiter", ' '};
// 構築時点では何もしない

auto outer_it = ranges::begin(sv);
// 外側イテレータの取得時、最初にデリミタ列に一致する部分範囲を計算
// 外側イテレータは、文字列先頭位置と次に出現するデリミタ位置をイテレータで管理している
// 現在の文字列先頭位置をp, 次に出現するデリミタ先頭位置をeとすると
// [p, e)の範囲が常に部分文字列に一致する

++outer_it;
// 外側イテレータのインクリメント時、文字列先頭位置pを前のデリミタ位置eで更新
// 次に出現するデリミタ列を探しeを更新
// [p, e)の範囲が次の部分文字列へ進む
// 内側rangeは"split_view"->"takes"へ進む

bool b = outer_it == ranges::end(sv); // false
// 外側range終端チェック
// 元のシーケンス上での終端チェックと
// 現在の位置でデリミタが出現しているかどうかを調べる

auto inner_range = *outer_it;
// 内側rangeの取得、subrangeを作って返す
// [p, e)の範囲をそのままsubrange{p, e}で返す

auto inner_it = ranges::begin(inner_range);
// 内側イテレータは元のrangeのイテレータを返す

++inner_it;
// 内側イテレータのインクリメントは、元の範囲のイテレータのインクリメント

char c = *inner_it; // a
// 元のシーケンスのイテレータのデリファレンスと等価

lazy_split_viewの様に過度な一般化と過度な遅延評価を行わない事によって、文字列分割時に結果を文字列に簡単に変換可能である様に実装されています。一方、begin()の呼び出しで最初のデリミタ位置の探索を行っているため、rangeコンセプトの意味論要件を満たすために常にキャッシュが使用されます。これによって、split_viewの外側rangeconst-iterableでもborrowed_rangeでもなくなります(とはいえそれはlazy_split_viewとほぼ同じことです)。

views::split

split_viewに対応するRangeアダプタオブジェクトがviews::splitです。

#include <ranges>
#include <iostream>
#include <string_view>

int main() {
  const auto str = std::string_view("split_view takes a view and a delimiter");

  for (auto inner_range : views::split(str, ' ')) {
    for (char c : inner_range) {
      std::cout << c;
    }
    std::cout << '\n';
  }

  // パイプラインスタイル
  for (auto inner_range : str | views::split(' ')) {
    for (char c : inner_range) {
      std::cout << c;
    }
    std::cout << '\n';
  }
}

views::splitはカスタマイゼーションポイントオブジェクトであり、引数の式r, pによってviews::split(r, p)のように呼び出されたとき、split_view(r, p)を返します。

文字列による文字列の分割

文字列に特化したと言いつつ入力となるrangeforward_rangeであればよく、デリミタもforward_rangeであればOKです。例えばstd::listなどを、あるいはそれによって分割する事ができるわけです。ただし、input_rangeの分割をサポートしていません。

例えば、文字列を文字列で分割することができます。

#include <ranges>
#include <iostream>
#include <string_view>

using namespace std::literals;

int main() {
  std::string_view input = "1, 12434, 5, 0000, 3942";

  // カンマとホワイトスペースで区切りたい
  // そのままだとナル文字\0が入るのでうまくいかない
  for (std::string_view str : input | std::views::split(", ")) {
    std::cout << str << '\n';
  }
  // 1, 12434, 5, 0000, 3942
  
  std::cout << '\n';

  // デリミタ文字列を2文字分の範囲にする
  // string_viewsの示す範囲は\0を含めない
  for (std::string_view str : input | std::views::split(", "sv)) {
    std::cout << str << '\n';
  }
  /*
    1
    12434
    5
    0000
    3942
  */
}

通常の文字列リテラルの示す範囲は文字列終端の\0を含んでしまうため、文字列で文字列を分割する際は問題となります。例えばstd::string_viewを通すと文字列範囲として\0を含まなくなるので意図通りになりますが、分かりづらいところなので結構罠です。

C++20とlazy_split_viewsplit_view

C++20では当初、lazy_split_viewsplit_viewという名前で追加されていました。当初のsplit_viewは現在のlazy_split_viewと同等のもので、とてもジェネリックな分割を担うものだったわけです。ところが、それは文字列分割には扱いづらかったため、P2210R2によってsplit_viewは文字列分割に特化され、当初のsplit_viewlazy_split_viewとして分離されました。

これはC++20規格完成後に行われており、P2210R2は欠陥報告(defect report : DR)としてC++20に遡って適用されました。そのため、C++20規格(N4861)にはこのsplit_viewの記載はないのですが、さも最初からそうであったかのように歴史修正されています。そして、rangeライブラリ周りではこの種の歴史修正(DR)が他にもたくさんあったりしています・・・

Discussion