std::mdspan
(C++ Reference)是 C++23 中,個人相當期待的一個新的標準函示庫容器;它的名稱是「multi-dimensional span」、基本上算是 C++20 的 std::span
的多維度版本。
簡單講,它可以讓開發者把一個連續的資料(陣列)當成二維、三維、甚至更多維度的資料來存取;這點對於要做矩陣計算、影像處理(二維陣列)的人來說,是相當方便的東西!
而由於之前 Heresy 習慣使用的編譯器(g++)都沒有支援,所以 Heresy 也還沒有去玩過;而微軟在 Visual Studio 2022 17.9 也終於在標準函式庫裡面加入這個模組了,所以也就可以來玩看看了!
基本使用
首先,他最簡單的使用情境大概會是下面的樣子:
#include <array> #include <mdspan> #include <iostream> int main() { // 1D data std::array<char, 24> aData; for (size_t i = 0; i < aData.size(); ++i) aData[i] = 'a' + i; // 2D view as matrix std::mdspan matrix(aData.data(), 4, 6); // output matrix for (size_t i = 0; i != matrix.extent(0); ++i) { for (size_t j = 0; j != matrix.extent(1); ++j) std::cout << matrix[i, j] << " "; std::cout << "\n"; } }
這樣的程式最後輸出的結果會是下面的樣子:
a b c d e f g h i j k l m n o p q r s t u v w x
在上面的例子裡面,是先定義一個長度是 24 的一維陣列資料 aData
出來,然後裡面填入 a – x 的字元。
接下來,則是透過 std::mdspan
把 aData
從新封裝成一個 4×6 的二維陣列 matrix
。
這邊要注意的是,和 std::span
一樣,他並沒有資料的所有權、只是一個「view」而已。
這邊宣告的型別雖然只有寫 std::mdspan
,但是實際上他是 template 型別,他會根據初始化條件和預設值來決定 template 參數;在 MSVC 他實際上的型別會是
std::mdspan<char,
std::extents<unsigned __int64, -1, -1>,
std::layout_right,
std::default_accessor<char>>
不過細節的部份,就之後再說明了。
在把 std::array
的資料封裝成 std::mdspan
之後呢,就可以把 matrix
當作二維陣列來操作、進行存取了!
這邊可以透過 extent()
這個函式、來取得指定軸向的大小;而之後,則可以透過 operator[]
來存取資料,使用上應該還算直覺。
Visual Studio 的支援不足
不過很可惜的,雖然 Visual Studio 2022 17.9 已經支援了 std::mdspan
,但是上面的程式是編譯不過的。
這是因為就算是目前最新的 Visual Studio 2022 17.11、C++ 核心語言的部分還是不支援多維度 subscript operator([]
) ,所以 matrix[ i, j ]
這樣的存取方法是不能使用的… orz
現階段要使用 MSVC 的 std::mdspan
需要把索引值先封裝成 std::array
或是使用 std::span
才行,也就是說要存取 (i, j)
的時候,需要透過 matrix[std::array{i,j}]
這樣的形式才行。
這邊大概就只能看微軟什麼時候要支援了?
更高維度
std::mdspan
的支援性不限於二維,也可以應用在更高維度。像是如果以上面同樣的 aData
來說,這邊也可以封裝成三維的形式來存取。
// 3D view as volume std::mdspan volume(aData.data(), 2, 3, 4); // output volume for (size_t i = 0; i != volume.extent(0); ++i) { for (size_t j = 0; j != volume.extent(1); ++j) { for (size_t k = 0; k != volume.extent(2); ++k) std::cout << volume[i, j, k] << " "; std::cout << "\n"; } std::cout << "\n"; }
這邊基本上就是把大小是 24 的一維陣列、封裝成 2 x 3 x 4 的三維陣列了~它的結果會是:
a b c d e f g h i j k l m n o p q r s t u v w x
型別的細節說明
上面算是最基本的使用情境。而實際上,std::mdspan
也是一個彈性滿大的 template 容器,也有相當大的使用彈性。
他實際上的型別是:
template< class T, class Extents, class LayoutPolicy = std::layout_right, class AccessorPolicy = std::default_accessor<T> > class mdspan;
他的 template 參數總共有四個:
- 代表資料型別的
T
- 代表維度資訊的
Extents
- 代表記憶體布局方法的
LayoutPolicy
- 代表存取方式的
AccessorPolicy
在上面的使用情境,基本上這些參數就都是根據建構時的參數來自動推論出來的了。
下面則是一些說明:
維度資訊(extents)
維度資訊的部分,主要是透過 std::extents<>
來指定有幾個維度、每個維度的大小(參考);大小的部分可以在編譯階段決定(static)、也可以在執行階段決定(dynamic)。
比如說前面的例子,同樣是要用 2×3 的二維陣列來看,可以寫成下面幾種形式:
std::mdspan view1(aData.data(), 2, 3); std::mdspan<char, std::dextents<int, 2>> view2(aData.data(), 2, 3); std::mdspan<char, std::extents<int, 2, 3>> view3(aData.data()); std::mdspan<char, std::extents<int, 2, std::dynamic_extent>> view4(aData.data(), 3);
其中,view1
和 view2
都是兩個維度的大小都是動態的、可以在執行階段(runtime)決定大小;而 view3
則是兩個維度的大小都是靜態的、需要在編譯階段就決定大小。
至於 view4
則是比較特別的用法,可以其中某些維度是靜態的、有的是動態的。
另外,extent 的資訊也可以當作建構子的參數來傳遞,像是 view2
和 view3
也可以寫成下面的形式:
std::mdspan view2(aData.data(), std::dextents< int, 2 >(2, 3)); std::mdspan view3(aData.data(), std::extents< int, 2, 3 >());
而這邊 std::extents<>
的第一個 template 型別(這邊是 int
)代表他的索引值的型別;他同時也會是 extent()
這個函式回傳的型別,有需要也可以自己調整。
不過一般的狀況下,應該可以不用這麼複雜,直接用第一種方法就可以了。
資料配置(layout)
在 LayoutPolicy
的部分(文件),基本使用時有代表 row-major 的 std::layout_right
和 column-major 的 std::layout_left
兩種;預設不指定的話,會是使用 row-major 的 std::layout_right
。
下面是使用時的狀況:
std::mdspan view1(aData.data(), 2, 3); // a b c // d e f std::mdspan<char, std::extents<int,2,3>, std::layout_left> view2(aData.data()); // a c e // b d f
不過,寫起來感覺就變麻煩了。 XD
除了基本的 layout_right
和layout_left
外,如果遇到部分的資料可能會需要固定跳過某些元素的話,則也可以透過 std::layout_stride
來做一定程度的客製化,這部分就之後再說吧。
而 C++26 還預計加入 layout_left_padded
和 layout_right_padded
,這個要能用大概就得好一段時間了。
存取方法(accessor)
請注意存取方式
最後,這邊再提一下 std::mdspan
的軸向的順序。
如果是要把一維陣列當作二維陣列存取的時候,Heresy 這邊其實是習慣用 x + y * width
這樣的計算公式。比較簡單的例子,大概會是下面這樣:
std::array<char, 12> aData; for (size_t i = 0; i < aData.size(); ++i) aData[i] = 'a' + i; size_t width = 4, height = 3; std::cout << "Matrix " << width << '/' << height << ":\n"; for (size_t y = 0; y < height; ++y) { for (size_t x = 0; x < width; ++x) std::cout << aData[x + y * width] << " "; std::cout << "\n"; }
這樣的結果會是:
Matrix 4/3: a b c d e f g h i j k l
而使用 std::mdspan
的時候,如果很直覺地寫成下面的型式的話:
std::mdspan matrix(aData.data(), 4, 3); std::cout << "Matrix " << matrix.extent(0) << " / " << matrix.extent(1) << "\n"; for (size_t y = 0; y != matrix.extent(1); ++y) { for (size_t x = 0; x != matrix.extent(0); ++x) std::cout << matrix[std::array{ x, y }] << " "; std::cout << "\n"; }
結果會是:
Matrix 4 / 3 a d g j b e h k c f i l
可以看到內容是方向是反的。
如果要按照本來的習慣使用的話,一個方法就是改用 std::layout_left
:
std::mdspan<char, std::extents<size_t, 4, 3>, std::layout_left> matrix(aData.data());
否則就是要把 X / Y 的定義反過來、也就是把第一個 extent 當作 Y、第二個 extent 當成 X 了~
這部分基本上有點偏向於習慣的問題,要使用的話最好還是確認一下是不是和自己預期的一樣了。(OpenCV 的 cv::Mat
或是 Eigen 的 Matrix 應該都和 mdspan
是一樣的形式?)
理論上,std::mdspan
普及之後,應該是可以像 std::sapn
一樣,作為一個統一、單一的陣列傳遞型別、來方便開發以及使用。(不過話說,g++ 到現在還沒支援…)
比較理想的狀況,是當我們要寫一個用來處理矩陣的函式的時候,可以統一變成傳 std::mdspan
進去;而遇到其他含式庫提供的矩陣型別(例如 Eigen 或 glm),都可以簡單地轉換過去。
但是實際上,考慮到矩陣本身的性質(row-major/column-major 等等),其實要做到快速轉換其實應該是沒那麼方便就是了…
另外,C++26 也還會加入可以快速存取局部區域的 submdspan()
,理論上也是會讓這類型的資料在操作上更方便的~
參考: