mdspanとmdarray
C++23で追加されたstd::mdspanは多次元配列の汎用的なビューを表現できるクラス型です。
#include <mdspan> #include <print> template<typename T> using mat33 = std::mdspan<T, std::extents<std::size_t, 3, 3>>; int main() { int storage[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8 }; mat33<int> array{storage}; for (std::size_t y = 0; y < array.extent(0); ++y) { for (std::size_t x = 0; x < array.extent(1); ++x) { std::print("{} ", array[y, x]); } std::println(""); } }
しかしこの例にあるように、std::mdspanはビューなので配列のデータ領域を参照し所有しません。参照する領域は別に用意する必要があり、かつそこを別に管理する必要があります。
ビューではない多次元配列クラスの要望もあったため、std::mdarrayというstd::mdspanとインターフェース互換で領域を所有する多次元配列クラスの提案も行われていますが、C++29以降の機能になります。
#include <mdarray> #include <print> template<typename T> using mat33 = std::mdarray<T, std::extents<std::size_t, 3, 3>>; int main() { std::vector<int> storage { 0, 1, 2, 3, 4, 5, 6, 7, 8 }; mat33<int> array{std::extents<std::size_t, 3, 3>{}, std::move(storage)}; for (std::size_t y = 0; y < array.extent(0); ++y) { for (std::size_t x = 0; x < array.extent(1); ++x) { std::print("{} ", array[y, x]); } std::println(""); } }
これは実行できれば先ほどと同じ結果になります。std::mdarrayはデフォルトではstd::vectorを内部に保持して、その領域をstd:mdspanとほぼ同じインターフェースによってアクセスできるようにすることで非ビューの多次元配列クラスとなっています。
C++23でもこのmdarrayのようなものが欲しくなることもあるでしょう。std::mdspanの柔軟なカスタマイズ性を活用すると、割と近いものを作ることができます。
mdspanの構造と要件
std:mdspanのクラス構造はおおむね次のようになっています
// mdspanクラス構造概要 namespace std { template< class T, // 要素型 class Extents, // エクステント型(次元数と次元ごとの要素数を指定する) class LayoutPolicy = layout_right, // レイアウトポリシー型 class AccessorPolicy = default_accessor<T> // アクセサポリシー型 > class mdspan { public: // アクセサポリシー型 using accessor_type = AccessorPolicy; // レイアウトマッピングクラス型 using mapping_type = typename LayoutPolicy::template mapping<extents_type>; // データハンドル型 using data_handle_type = typename AccessorPolicy::data_handle_type; ... private: accessor_type acc_; mapping_type map_; data_handle_type ptr_; // 参照する領域のデータハンドル }; }
これら各型の詳細などは以前の記事をご覧ください
具体的なテンプレートパラメータが全て与えられたstd::mdspanの特殊化の型をMDSとすると、MDSは次の要件を満たしている必要があります
copyableのモデルとなるis_nothrow_move_constructible_v<MDS>がtrueis_nothrow_move_assignable_v<MDS>がtrueis_nothrow_swappable_v<MDS>がtrue
上記クラス構造からわかるように、MDSがこれらの性質を満たすにはaccessor_type・mapping_type・data_handle_typeのすべてがこれらの性質を満たしている必要があります(このことはレイアウトポリシーとアクセサポリシーに求められる要件によって別に指定されます)。
これらメンバのうち、data_handle_typeと言われているものがmdspanの対象領域を参照するもので、通常はポインタ型が使用されます。
アクセサポリシーのカスタマイズ
data_handle_typeがどのような型であるべきかはアクセサポリシー要件の中で指定されており、その条件は先ほどのstd::mdspanの具体的な特殊化MDSに求められていたものと同じになります。そして、data_handle_typeはstd::mdspan<T, ...>に対してT*である必要はなく、先ほどの条件を満たす型でありさえすれば任意の型が使用できます。
この意図としてはファンシーポインタ型の様な型をサポートすることを意図しており、例えばstd::share_ptr<T[]>が使用できます。ただもっと直接的に、std::vectorも使用できます。
すなわち、std::mdspanのデータハンドル型をカスタマイズすることでmdarrayの様な動作を得ることができます。そして、データハンドル型のカスタマイズはアクセサポリシーのカスタマイズによって行えます。
std::vectorが要件を満たすこと
std::vector<T>が先ほどの要件
copyableのモデルとなるis_nothrow_move_constructible_v<std::vector<T>>がtrueis_nothrow_move_assignable_v<std::vector<T>>がtrueis_nothrow_swappable_v<std::vector<T>>がtrue
を満たすことを一応確認しておきましょう。
copyableは言うまでもなく、is_nothrow_move_constructible_v<std::vector<T>>は標準コンテナのムーブコンストラクタは無条件noexceptが基本なので問題ありません。
残った2つは実は少し込み入っています。
namespace std { template<class T, class Allocator = allocator<T>> class vector { ... constexpr vector& operator=(vector&& x) noexcept(allocator_traits<Allocator>::propagate_on_container_move_assignment::value || allocator_traits<Allocator>::is_always_equal::value); ... constexpr void swap(vector&) noexcept(allocator_traits<Allocator>::propagate_on_container_swap::value || allocator_traits<Allocator>::is_always_equal::value); ... }; }
どちらも結局アロケータ次第になります。
propagate_on_container_move_assignment: ムーブ代入時にアロケータを伝播させるかpropagate_on_container_swap: スワップ時にアロケータを交換するかis_always_equal: アロケータがオブジェクトによらず同等であるか(アロケータが状態を持つか)
デフォルトのアロケータstd::allocator<T>はこれらのうちpropagate_on_container_move_assignmentとis_always_equalがtrue(true_typeとして定義される)なので、どちらの条件も満たすことができ、上記の要件をすべてクリアできます。
ただ、std::pmr::polymorphic_allocatorの場合だと逆にすべて満たさなかったりするので、アロケータのカスタマイズには注意が必要です。
アクセサポリシーのカスタマイズ
アクセサポリシー型はアクセサポリシー要件を満たす任意の型を使用できます。その詳細はcpprefjpのAccessorPolicy等を見ていただくとして、おおむねstd::default_accessorを真似すればokです。
namespace std { // mdspanのデフォルトアクセサポリシー // データハンドルはポインタを使用する template<class ElementType> struct default_accessor { using offset_policy = default_accessor; using element_type = ElementType; using reference = ElementType&; using data_handle_type = ElementType*; constexpr default_accessor() noexcept = default; template<class OtherElementType> constexpr default_accessor(default_accessor<OtherElementType>) noexcept; constexpr reference access(data_handle_type p, size_t i) const noexcept; constexpr data_handle_type offset(data_handle_type p, size_t i) const noexcept; }; }
std::mdspanで使用されるデータハンドル型をカスタマイズするにはここのメンバ型data_handle_typeをカスタマイズすればいいわけです。
名前をvector_accessorにするとして、実装は次のようになります
template<class ElementType> struct vector_accessor { using offset_policy = std::default_accessor<ElementType>; using element_type = ElementType; using reference = const ElementType&; using data_handle_type = std::vector<ElementType>; constexpr vector_accessor() noexcept = default; // 変換は基本的に考慮しないものとする constexpr reference access(const data_handle_type& p, size_t i) const noexcept { return p[i]; } constexpr offset_policy::data_handle_type offset(const data_handle_type& p, size_t i) const noexcept { return p.date() + i; } };
先ほど見たように、データハンドルそのものはstd::mdspan内部で保存されています。要素アクセス時にはそれとレイアウトポリシーから計算された1次元インデックスによってここのaccess()関数が呼ばれることで要素アクセスが行われます。
offset_policy/offset()はstd::submdspan(C++26)でmdspanから部分ビュー(スライス)を取得する際に領域のオフセット計算をするカスタマイズポイントです。std::vectorをコピーして返すという事もできなくは無いと思いますが、それはもうスライスではないので、デフォルトのstd::mdspan(ポインタ+default_accessor)にフォールバックしておきます。
このカスタムアクセサポリシーを使用するには、std::mdspan<T, E, L, A>のAに入れてやればokです。vector_accessorはステートレスなのでstd::mdspanのコンストラクタから渡す必要もありません。
このようなエイリアスを作っておくと便利かもしれません
template<typename T, typename E, typename L = std::layout_right> using my_mdarray = std::mdspan<T, E, L, vector_accessor<T>>;
先ほどのmdarrayの動かないサンプルコードをこれで動かしてみると
#include <mdspan> #include <print> template<typename T, typename E, typename L = std::layout_right> using my_mdarray = std::mdspan<T, E, L, vector_accessor<T>>; template<typename T> using mat33 = my_mdarray<T, std::extents<std::size_t, 3, 3>>; int main() { std::vector<int> storage { 0, 1, 2, 3, 4, 5, 6, 7, 8 }; mat33<int> array{std::move(storage)}; for (std::size_t y = 0; y < array.extent(0); ++y) { for (std::size_t x = 0; x < array.extent(1); ++x) { std::print("{} ", array[y, x]); } std::println(""); } }
このmy_mdarrayオブジェクトはコンストラクタで渡されたstd::vectorを所有しており、コピーもムーブも自由にできます。通常のstd::mdspanと異なり参照先領域が先に寿命が尽きないように計らう必要もありません。
制限
こうしてできたmy_mdarrayはほとんどstd::mdarrayと同じように扱うことができます。というかstd::mdarrayの実装は実質これと同じです(アクセサポリシーを介せず独自のクラス型として定義しているが)。例えば、std::mdarrayは内部コンテナを自動で拡張したりしません。
ただしstd::mdspan固有の制限があり、その点が少し異なります。
- 要素の変更ができない
- 内部
std::vectorはコンストラクタから渡さなければならない
要素の変更ができないのはちょっと不便かもしれません・・・
ちなみに、データハンドルとして使用可能なものはインデックスアクセスさえできればいいので、単にrandom_access_rangeなstd::dequeや、前述のようにstd::shared_ptrを使用できます。少し特性が変わるものの、std::shared_ptrを使用する場合は要素の変更ができる領域所有std::mdspanを作成できます。
#include <mdspan> #include <memory> #include <print> #include <numeric> template<class ElementType> struct shared_ptr_accessor { using offset_policy = std::default_accessor<ElementType>; using element_type = ElementType; using reference = ElementType&; // referenceは非const using data_handle_type = std::shared_ptr<element_type[]>; constexpr shared_ptr_accessor() noexcept = default; constexpr reference access(const data_handle_type& p, size_t i) const noexcept { return p[i]; // ここでconst伝播を切ることができる } constexpr offset_policy::data_handle_type offset(const data_handle_type& p, size_t i) const noexcept { return p.get() + i; } }; template<typename T, typename E> using shared_mdarray = std::mdspan<T, E, std::layout_right, shared_ptr_accessor<T>>; int main() { const std::size_t N = 3 * 3; auto p = std::make_shared<int[]>(N); std::ranges::iota(p.get(), p.get() + N, 0); shared_mdarray<int, std::extents<std::size_t, 3, 3>> ms1{p}; for (std::size_t y = 0; y < ms1.extent(0); ++y) { for (std::size_t x = 0; x < ms1.extent(1); ++x) { std::cout << ms1[y, x] << " "; } std::cout << '\n'; } }