[C++]iter_const_reference_tの型の決定について

先日zennに投降した、views::as_constについての記事を書いているときに調べた、const_iteratorの要素型(参照型)の決定過程に関するメモです。

以下、views::as_constに関しては知っているものとして説明しません(この記事ないし他の解説をご覧ください)。

また、上記記事及び以降でも、イテレータの間接参照結果の事を指してイテレータの要素だとか要素型だとか言っていますが、それは正しくは要素の参照あるいは参照型(iter_reference_t)の事です。本来の意味の要素型(iter_value_t)とは異なり、views::as_constconst_iteratorは要素の間接参照結果をconst化するのであって要素型そのものをconst化するわけではありません。

views::as_constconstant_range

views::as_constは入力範囲の要素がすでにconstになっているかどうかを判定してからそうなっていない場合に何か作業を行います。その判定に関わっているのはviews::as_constと共にC++23で追加されたstd::ranges::constant_rangeコンセプトです。

// constant_rangeの定義
template<class T>
concept constant_range = 
  input_range<T> && 
  constant-iterator<iterator_t<T>>;

constant_rangeの主要な部分を担っているのがconstant-iteratorというコンセプトです。これは説明専用の(規格文書内で規定のために使用される)もので、次のように定義されています。

template<class It>
concept constant-iterator =
  input_iterator<It> && 
  same_as<iter_const_reference_t<It>, iter_reference_t<It>>;

constant-iteratorコンセプトの主要な部分は2つ目の制約式で、この制約式は入力のイテレータItに対して、そのiter_const_reference_titer_reference_tが一致することを求めています。iter_reference_tイテレータの間接参照(*)の直接の結果型のことで、どうやらそれがconstになっていればこの制約式を満たせるように見えます。

このiter_const_reference_tviews::as_constと共にC++23で追加されたエイリアステンプレートで、次のように定義されています

template<indirectly_readable It>
using iter_const_reference_t =
  common_reference_t<const iter_value_t<It>&&, iter_reference_t<It>>;

イテレータItに対して、その要素型(std::iter_value_t)をconst参照化したものと参照型のcommon_referenceを求めています。

common_referenceの決定過程は複雑ですが、雰囲気的にはこの結果は常にconstが付いた型になりそうに見えます。とりあえず今はそれを認めることにして、iter_const_reference_t<I>は常にconstであることを仮定しておきます。

std::ranges::cbegin()/cend()

views::as_constの入力範囲がconstant_rangeではない場合、views::as_constは次にその型を何とかこねくり回して要素のconst化が達成できないかを試行します。それが叶わない場合、入力範囲をas_const_viewに渡して返すことで要素型のconst変換を行います。

views::as_constas_const_viewを使用する場合、そのイテレータstd::ranges::cbegin()から取得されます。ranges::cbegin()C++23で確実に要素がconstになっているイテレータを返すように改修されており、型Tの式Eとそれの評価結果の左辺値をtとしてranges::cbegin(E)のように呼ばれた時次のようなことを行います

  • enable_borrowed_range<remove_cv_t<T>> == falseならば、ill-formed
  • そうではない場合、式Uranges​::​begin(possibly-const-range(t))として、const_iterator<decltype(U)>(U)を返す

possibly-const-rangeは説明専用の関数であり、次のように定義されています

template<input_range R>
constexpr auto& possibly-const-range(R& r) {
  if constexpr (constant_range<const R> && !constant_range<R>) {
    return const_cast<const R&>(r);
  } else {
    return r;
  }
}

ここで行われていることは、入力範囲Rについて入力範囲を単にconst化すればその要素もconst化する(const Rconstant_rangeとなる)場合はそうして、そうでない場合、及びRが既にconstant_rangeである場合は入力をそのまま返す、という事をしています。

ranges::cbegin()は、こうして返された範囲オブジェクトからranges::begin()によってイテレータを取得し、それをstd::const_iteratorに通して返します。

ranges::cend()はこの手順内のranges::begin()ranges::end()に置き換えたことを行い、最後にstd::const_sentinelを通して返します。

std::const_iterator/const_sentinel

std::const_iteratorエイリアステンプレートであり、入力の型Iによって次のどちらかの型を返します

  • Iconstant-iteratorならばI
  • そうでないならば、basic_const_iterator<I>

ここで出てくるconstant-iteratorコンセプトは、constant_rangeで使用されていたものと同じものを指しています。すなわち、Iの要素型がconstではない場合にのみIstd::basic_const_iteratorにラップして返します。

std::const_sentinelエイリアステンプレートであり、入力の型Sによって次のどちらかの型を返します

  • Sinput_iteratorならばconst_iterator<S>
  • そうでないならば、S

これはどちらも、C++23でviews::as_constと共に導入されたものです。

std::basic_const_iterator

std::basic_const_iteratorC++23でviews::as_constおよびranges::cbegin()のために追加されたイテレータラッパであり、入力イテレータの間接参照結果をconst化する事だけを行います。

前述のように、イテレータIに対していつもstd::const_iterator<I>を使用するようにすれば、std::basic_const_iteratorstd::basic_const_iteratorでラップするような二重const化を回避してイテレータ要素のconst化を達成できます。

std::basic_const_iteratorの見どころはほぼそのoperator*のみで、それは内部のイテレータitに対してreturn static_cast<reference>(*it)を返します。referencestd::basic_const_iterator入れ子型として説明専用として定義されるもので、次のように定義されています

template<input_iterator Iterator>
class basic_const_iterator {
  ...
  
  // 説明専用型referenceの定義
  using reference = iter_const_reference_t<Iterator>;
  
  ...
};

結局ここでも、iter_const_reference_tが出てきます。

std::iter_const_reference_t

こうして、役所もびっくりのたらい回しの果てに、views::as_constの要素型の決定にはstd::iter_const_reference_tというエイリアステンプレートが深く関与していること分かりました。views::as_constの分岐を考えると、これが直接介さない場合でもviews::as_constの結果のviewの要素型(参照型)は入力範囲のイテレータ型をstd::iter_const_reference_tに通して得られた型と同じになるはずです(たぶん)。

従って、views::as_constの結果範囲の要素型はstd::iter_const_reference_tがどう振舞うかを調べればわかることになります。std::iter_const_reference_tの定義は次のようになっており

template<indirectly_readable It>
using iter_const_reference_t =
  common_reference_t<const iter_value_t<It>&&, iter_reference_t<It>>;

その決定には2つの型のcommon_referenceが計算されています。

その1つ目の型const iter_value_t<It>&&では、iter_value_t<It>が参照型ではない場合に結果は必ずconstになり、かつ常に参照型になります。とはいえiter_value_t<It>は通常修飾なしの型(prvalue)であるはずで、std::indirectly_readable_traitsをよく見るとremove_cvされるうえに参照型だとvalue_typeが定義されないことが分かるため、いつも修飾なしの型であるとします(もう一つiterator_traitsの経路だと必ずしもそうではありませんが)。すると、const iter_value_t<It>&&とはいつもconst右辺値参照になります。

2つ目の型iter_reference_t<It>イテレータの関節参照の結果型であり、これは

  • 修飾なしの型(prvalue
  • 参照型
    • 左辺値参照(&
    • 右辺値参照(&&
  • 上2つのconst修飾

のいずれかであるはずです(volatileは無視で・・・)。

それらの型の間のcommon_referenceの決定は複雑ですがこれらの仮定を用いるとある程度求めやすくなります。特に、1つ目の型は常にconst右辺値参照型なので、問題となるのは2つ目の型が参照型かどうかです。std::common_reference<const T&&, U>とすると、この結果は次のどちらかになりそうです(あまり自信がない・・・)

  • Uが参照型(U&/U&&) : decltype(false ? declval<const T&(&)()>()() : declval<const U&(&)()>()())
  • UprvalueU) : decltype(false ? declval<const T&&(&)()>()() : declval<U(&)()>()())

とはいえこの型がどうなるかも難しいものがあり(そもそもパースがムズカシイ)、Uconstがついてると少し正しくない部分があるなどやはり難しいものがあります。なので、簡単なテスターを作って実際の振る舞いを確かめてみます。

template<typename T>
using test = std::common_reference_t<const std::remove_cvref_t<T>&&, T>;

これを使うと、std::iter_const_reference_tの型をイテレータ型を介さず直接調べることができます。

int main() {
  static_assert(std::same_as<test<int&>, const int&>);
  static_assert(std::same_as<test<int&&>, const int&&>);
  static_assert(std::same_as<test<const int&>, const int&>);
  static_assert(std::same_as<test<int>, int>);
  static_assert(std::same_as<test<const int>, int>);
}

イテレータIの参照型(iter_reference_t<I>)をT(+修飾)とし、型Tに対して特にbasic_common_reference特殊化が行われていないとして、これによるとTによってstd::iter_const_reference_t<I>は次のようになります

iter_reference_t<I> iter_const_reference_t<I>
T& const T&
T&& const T&&
const T& const T&&
const T&& const T&&
T T
const T T

Tが参照型である場合は適切にconst化されていますが、Tprvalueである場合はそうなってはいません。それはまあ当然というか、prvalueconst参照でラップしたらそれはダングリング参照になりますし、prvalueconstを付加することにはほぼ意味がありません。

これは一般の型Tに対しての結果ですが、意図的にcommon_referenceが(basic_common_referenceによって)カスタムされている一部の型が標準ライブラリにも存在し、それを使用するようなrange型ではその要素型と参照型の景色が少し異なっていたりします。たとえば

  • std::vector<bool>
    • range_value_t : bool
    • range_reference_t : std::vector<bool>::reference
  • views::zipとそのファミリ
    • range_value_t : std::tuple<T, U>
    • range_reference_t : std::tuple<T&, U&>
  • views::enumrate
    • range_value_t : std::tuple<D, T>
    • range_reference_t : std::tuple<D, T&>

などがあります。

ここでのT, Uviews::zipviews::enumarateの入力範囲のrange_value_t/range_reference_t/range_difference_tによって変化するためそのプレースホルダーとします。T, Uは任意の型の可能性がありますがここでは参照修飾は考えないことにして、Dprvalueの整数型です。

これらの型の場合は、値型(range_value_t)・参照型(range_reference_t)・const要素型(range_const_reference_t)はそれぞれ以下のようになります

range range_value_t<I> range_reference_t<I> range_const_reference_t<I>
std::vector<bool> bool std::vector<bool>::reference bool
const std::vector<bool> bool bool bool
views::zip std::tuple<T, U> std::tuple<T&, U&> std::tuple<const int&, const double&>
views::enumrate std::tuple<D, T> std::tuple<D, T&> std::tuple<D, const T&>

range_const_reference_titer_const_reference_trange版で、iter_const_reference_t<iterator_t<R>>として定義されています。

zipenumrateの結果をみると想像がつくかもしれませんが、std::tuple<Ts...>std::tuple<Us...>common_referenceとは、std::tuple<std::common_reference_t<Ts, Us>...>のように、tuple要素型の対応する型同士のcommon_referenceを求める形になります。なので、std::tupleの他のケースはその要素型について先程の一般の型に対する結果を参照すると簡単に求められます。また、その場合はtuple自身のconst/参照修飾は無視されます。

iter_const_reference_tはこのように、とても巧妙に入力イテレータ型の要素型をそれに応じて適切にconst化します。しかも、prvalueの場合は余計なことをしないなど、本当によくできすぎていることが分かります。

参考文献

この記事のMarkdownソース