Ez az alfejezet arról szól, hogyan adjunk többdimenziós indexelést
egy folytonos pufferre anélkül, hogy elveszítenénk a tulajdonlás és az
élettartam tisztaságát. A kiindulópont a C mátrixlayout, amelyet a Többdimenziós tömbök, VLA és layout részben már
rögzítettünk: a C tömbök elemei garantáltan összefüggően helyezkednek
el, és a tömb típusa maga kódolja a kiterjedéseket. A C++20 utáni
eszköztár ugyanezt a leképezést választja szét három független
fogalomra: a kiterjedésre (extent), a lépésre (stride) és a hozzáférésre
(access). A fejezet célja, hogy az olvasó a mátrixnézetet ne
„mátrixosztályként”, hanem indexleképezési szerződésként tudja
megtervezni, és világosan lássa, mikor elérhető a standard
std::mdspan, és mi a teendő C++20-on.
Egy -dimenziós, kiterjedésű tömbnél az extent az egyes dimenziók elemszáma, a stride pedig azt mondja meg, hány elemnyit kell ugrani, ha az adott indexet eggyel növeljük. Általános esetben a lineáris eltolás
ahol a -adik dimenzió lépése. A row-major (sorfolytonos) elrendezésben az utolsó dimenzió lépése 1, és ; a column-major (oszlopfolytonos) elrendezésben és . Kétdimenziós esetben ez a két jól ismert képletet adja:
A szerződés feltételei: (1) minden ; (2) a számított offset kifejezhető abban az indextípusban, amellyel számolunk; (3) a hivatkozott elem ténylegesen létezik abban a tárolóban, amelyre a nézet mutat. Az (1) megsértése nem definiált viselkedés (undefined behavior, UB), nem pedig „rossz érték”. A (2) feltétel a gyakorlatban a legtöbbször figyelmen kívül hagyott pont, erről külön alszakasz szól.
C-ben a többdimenziós tömb típusa ezt a leképezést beégeti. A
következő példa a -std=c17 profilon mutatja, hogy a layout
nem konvenció, hanem nyelvi garancia:
/* c_matrix.c -- gcc -std=c17 -Wall -Wextra -pedantic c_matrix.c -o c_matrix */
#include <stddef.h>
#include <stdio.h>
enum { ROWS = 3, COLS = 4 };
static int a[ROWS][COLS];
int main(void)
{
/* a teljes tomb egyetlen osszefuggo int-tombkent viselkedik */
size_t total = sizeof a / sizeof a[0][0];
/* a TELJES a objektum reprezentaciojan belul szamolunk: base az a elso
bajtja, az eltolas a sorfolytonos layoutbol (i*COLS + j) * sizeof(int) */
const unsigned char *base = (const unsigned char *)&a;
size_t off = (1u * COLS + 2u) * sizeof a[0][0];
/* csak egyenloseget vizsgalunk, kulon reszobjektumokat nem vonunk ki */
int same = (const void *)(base + off) == (const void *)&a[1][2];
printf("total=%zu off(1,2)=%zu same=%d\n", total, off / sizeof a[0][0], same);
return 0;
}Buildjegyzőkönyv (lab-gcc profil). A
kimenet total=12 off(1,2)=6 same=1: a row-major képletből
()
kiszámolt bájteltolás a teljes a objektum első bájtjától
pontosan az a[1][2] elemre mutat. A példa minden
pointerművelete a teljes a objektum reprezentációján belül
marad (unsigned char pointer az a elejétől,
legfeljebb sizeof a bájtnyira), és nem von ki két külön
részobjektumra mutató pointert; int* aritmetika sem lép át
sorhatárt.
A C tömbmodell két korlátját érdemes szétválasztani. A
futásidejű méret kifejezhető: a VLA-ból származó,
változóan módosított (VM) típusokkal egy size_t cols
paraméterrel megadott int (*)[cols] pointer futásidejű
sorhosszú mátrixot ír le (a részleteket és a C11/C17/C23 támogatási
szabályokat a Többdimenziós tömbök, VLA és layout
(7.2) tárgyalja):
/* vla_matrix.c -- gcc -std=c17 -Wall -Wextra -pedantic vla_matrix.c -o vla_matrix */
#include <stdio.h>
#include <stdlib.h>
/* futasideju meretu matrix: a parameter tipusa variably modified, int (*)[cols] */
static void fill(size_t rows, size_t cols, int (*m)[cols])
{
for (size_t i = 0; i < rows; ++i)
for (size_t j = 0; j < cols; ++j)
m[i][j] = (int)(i * cols + j);
}
int main(int argc, char **argv)
{
size_t rows = 3, cols = (argc > 1) ? (size_t)atoi(argv[1]) : 4;
int (*m)[cols] = malloc(rows * sizeof *m); /* heapen, nem VLA-objektum */
if (!m) return 1;
fill(rows, cols, m);
printf("m[1][2]=%d sizeof *m=%zu\n", m[1][2], sizeof *m);
free(m);
return 0;
}Buildjegyzőkönyv (lab-gcc profil).
gcc -std=c17 -Wall -Wextra -pedantic vla_matrix.c
figyelmeztetés nélkül fordul; argumentum nélkül
m[1][2]=6 sizeof *m=16, 5-tel
m[1][2]=7 sizeof *m=20 a kimenet. Ami a C tömbtípusokkal
nem fejezhető ki, az a tetszőleges lépésközű
(stride-os) alterület: egy mátrix 2×2-es belső blokkjára nem adható
olyan tömbtípus, amelynek sorlépése a teljes mátrix sorhossza, de
kiterjedése a blokké. Ezt a hiányt tölti be a nézet (view).
A std::mdspan egy nem tulajdonló, többdimenziós nézet. A
típus négy paramétere négy külön döntés:
std::mdspan<T, Extents, LayoutPolicy, AccessorPolicy>.
Az Extents lehet statikus
(std::extents<std::size_t, 3, 4>) vagy dinamikus
(std::dextents<std::size_t, 2>); dinamikus kiterjedés
esetén az értékek a konstruktorba kerülnek. A LayoutPolicy
felel az offset-képletért: std::layout_right a row-major,
std::layout_left a column-major,
std::layout_stride pedig tetszőleges, futásidejű
lépésvektort enged meg — ez utóbbi az alterület-nézetek és a
transzponált nézetek eszköze. Az AccessorPolicy
(alapértelmezésben std::default_accessor<T>) azt
dönti el, hogy egy offsetből milyen hivatkozás lesz; saját accessorral
például másoló, számított vagy maszkolt hozzáférést lehet adni.
A fontos szemantikai korlát: az mdspan nem owner. A
mögötte álló backing storage-nak túl kell élnie a nézetet, és a
nézet létrehozása nem hosszabbítja meg annak élettartamát. Ha a nézetet
egy lokális std::vector-ra építjük és kifelé adjuk,
dangling nézetet kapunk; ez ugyanaz a hibaosztály, mint a span, byte és bounds-szerződések részben tárgyalt
std::span esetén, csak több dimenzióval. A másik kötelező
előfeltétel: az accessor által hivatkozott elemeknek létezniük kell,
azaz a legnagyobb offset is a lefoglalt tartományon belül kell legyen;
ennek megsértése UB, nem kivétel.
Az mdspan a C++23 könyvtár része, ezért elérhetőségét
nem szabad feltételezni. A helyes minta a feature-test macro ellenőrzése
a <version> headerből, még a
<mdspan> include előtt:
// mdspan_demo.cpp -- g++ -std=c++23 -O2 -Wall -Wextra mdspan_demo.cpp -o mdspan_demo
#include <version>
#if defined(__cpp_lib_mdspan)
#include <cstddef>
#include <cstdio>
#include <vector>
#include <mdspan>
int main()
{
std::vector<double> storage(12, 0.0); // 3 x 4, row-major
using ext2 = std::dextents<std::size_t, 2>;
std::mdspan<double, ext2> right(storage.data(), 3, 4); // layout_right
std::mdspan<double, ext2, std::layout_left> left(storage.data(), 3, 4);
right[1, 2] = 5.0; // C++23 tobbargumentumu operator[]
std::printf("right[1,2]=%f left[1,2]=%f\n", right[1, 2], left[1, 2]);
return 0;
}
#else
int main()
{
std::puts("mdspan nem elerheto ezen a toolchain-en");
return 0;
}
#endifKét megjegyzés a példához. Egyrészt a right[1,2]
többargumentumú operator[] szintén C++23-as nyelvi elem;
C++20-ban a többdimenziós indexeléshez függvényhívás-operátor kell.
Másrészt a két nézet ugyanarra a pufferre mutat, ezért a
left[1,2] más offsetre esik
(,
szemben a row-major
-tal):
ez nem hiba, hanem a layout-policy hatása, de ugyanazon elemek két
különböző indexelésű írása könnyen logikai hibává válik. Éles kódban ne
tartsunk egyszerre két, eltérő layoutú írható nézetet ugyanarra a
pufferre.
Ha a toolchain nem kínál mdspan-t, a szerződés kézzel is
felírható. A minimum egy nyers pointer, két kiterjedés és egy sorlépés;
a bounds-ellenőrzés assert-tel afelett van, és a lépést
elemszámban (nem byte-ban) érdemes tárolni, mert így a pointeraritmetika
a fordítóra marad. A std::span önmagában nem elég, mert
csak egydimenziós és folytonos; a stride-ot külön kell vinni.
// matrix_view.cpp -- g++ -std=c++20 -O2 -Wall -Wextra -pedantic matrix_view.cpp -o matrix_view
#include <cassert>
#include <cstddef>
#include <cstdio>
#include <limits>
#include <vector>
template <class T>
class MatrixView2D {
public:
MatrixView2D(T* data, std::size_t rows, std::size_t cols, std::size_t stride)
: data_(data), rows_(rows), cols_(cols), stride_(stride)
{
assert(data_ != nullptr || rows_ == 0 || cols_ == 0);
// a legnagyobb offset: (rows-1)*stride + (cols-1) -- nem csordulhat at
if (rows_ > 0 && cols_ > 0) {
const std::size_t r = rows_ - 1, c = cols_ - 1;
const std::size_t max = std::numeric_limits<std::size_t>::max();
assert(stride_ == 0 || r <= (max - c) / stride_);
}
}
T& operator()(std::size_t r, std::size_t c)
{
assert(r < rows_ && c < cols_);
return data_[r * stride_ + c];
}
const T& operator()(std::size_t r, std::size_t c) const
{
assert(r < rows_ && c < cols_);
return data_[r * stride_ + c];
}
std::size_t rows() const { return rows_; }
std::size_t cols() const { return cols_; }
std::size_t stride() const { return stride_; }
private:
T* data_;
std::size_t rows_, cols_, stride_;
};
int main()
{
constexpr std::size_t R = 4, C = 6;
std::vector<int> buf(R * C, 0);
MatrixView2D<int> full(buf.data(), R, C, C); // row-major teljes matrix
// alterulet: az 1..2 sor es az 1..3 oszlop; a lepes tovabbra is C
MatrixView2D<int> block(buf.data() + 1 * C + 1, 2, 3, C);
block(0, 0) = 7; // a full(1,1) elemet irja
assert(full(1, 1) == 7);
std::printf("full=%zu block=%zu\n", full.rows() * full.cols(), block.rows() * block.cols());
return 0;
}Itt a block ugyanazt a tulajdonost (buf)
nézi, csak más báziscímmel és kisebb kiterjedéssel; a lépés
változatlanul C, ezért a sorok közti ugrás helyes marad. Az
owner egyértelmű: a std::vector. A nézet invalidáló
művelete a buf újraallokálása vagy megszűnése, illetve
bármely push_back, amely a tárolót átmozgatja — ezért a
nézetet soha nem tároljuk hosszú élettartamú objektumban a vektor
mellett, mert a
[std::array, std::vector és folytonos tárolás](#s138)
részben tárgyalt invalidációs szabályok itt is érvényesek.
A row-major offset kiszámítása r * cols + c alakban
történik. Két külön hiba lehetséges. Az első a szorzási
túlcsordulás: ha r * cols meghaladja az indextípus
maximumát, a std::size_t aritmetika modulo
szerint wrappel, és a kapott offset egy érvényesnek látszó, de idegen
indexre mutat. A második a bounds-sértés:
r >= rows vagy c >= cols esetén a cím
már egy másik objektumra esik. Előfeltétel-ellenőrzés nélkül mindkettő
UB.
A túlcsordulás ellenőrzése osztással végezhető el, mert az nem
csordulhat túl. A legnagyobb tényleges offset
;
ez akkor ábrázolható std::size_t-ben, ha
A fenti MatrixView2D konstruktor pontosan ezt a
feltételt ellenőrzi. Ha a kiterjedések szorzata (a teljes elemszám) is
érdekes, azt ugyanígy, lépésenként kell ellenőrizni:
E0 != 0 && E1 > max / E0 esetén már a szorzat
sem fér el. Érdemes tudni, hogy az std::mdspan
alapértelmezett index típusa std::size_t, tehát a nem
negatív, de nagy indexek a nézet sajátjai; a negatív indexelést nem ez a
típus hivatott kifejezni, és a ptrdiff_t-re váltás csak
akkor segít, ha a felső korlát is ehhez igazodik.
A <limits>, <complex>,
<valarray> és <random> nem
alternatívái egymásnak; más-más kérdésre válaszolnak, és a választás
indoka a szerződés, nem a kényelem.
A std::numeric_limits<T> a típus tulajdonságait
kérdezi le: max(), min(),
lowest(), is_signed, digits,
epsilon(). Vizsgálati és előfeltétel-ellenőrzési eszköz,
nem futásidejű elágazás helyettesítője: a fenti overflow-guardban a
max() használata pontosan ezért jó, mert nem tartalmaz
beégetett
konstanst. Figyelni kell arra, hogy nem minden típusra garantált
specializáció: a GCC __int128 típusára például
kiterjesztésként van, nem hordozható módon.
A std::complex<T> aritmetikai típus, nem tároló. A
float, double és long double
specializációkra a szabvány speciális layout-garanciát ad (a valós rész
elöl), ezért ezeknél a C API-val való két T-s átadás
definiált; más T-re ez a garancia nem áll. A
std::valarray<T> elemenkénti vektoraritmetikát és
szeletet (slice) kínál, de korlátozott tárolóinterfésszel rendelkezik;
új kódban inkább std::vector és algoritmusok használatosak,
a valarray főleg numerikus örökség.
A <random> két külön fogalmat ad: motort (engine,
pl. std::mt19937) és eloszlást (distribution, pl.
std::uniform_int_distribution). A motor nyers bitsorozatot
ad, az eloszlás alakítja a kívánt tartományra; a kettőt nem szabad
összemosni (engine() % n torzít). A seedelés és a
reprodukálhatóság kérdése külön döntés: a
std::random_device viselkedése implementációfüggő lehet,
kriptográfiai célra a <random> önmagában nem
elegendő. A numerikus algoritmusok kiértékelési sorrendjéről a Numerikus algoritmusok és kiértékelési sorrend rész
szól; itt csak annyi a kapcsolat, hogy egy nézeten keresztül végzett
párhuzamos módosítás ugyanazokat a versenyfeltételeket örökli, mint
bármely más iterátoros algoritmus.
A leggyakoribb hiba az owner és a view összemosása: a
mdspan-t vagy a MatrixView2D-t visszaadó
függvény lokális pufferről dangling nézetet ad. Javítás: a nézetet csak
olyan függvény adja vissza, amelynek a puffere a hívó tulajdonában van,
vagy a nézetet és az ownert egy wrapper fogja össze.
A második: a sor- és oszlopindex felcserélése, amikor a stride-ot nem
a layout-policy, hanem beidegződés alapján adjuk meg. Ez különösen
column-major forrásból (BLAS, Fortran, OpenGL) érkező adatoknál okoz
csendes hibát. Javítás: a nézetet mindig a lépésvektorral együtt
teszteljük, és a layout_left-es esetet külön tesztesettel
fedjük le.
A harmadik: assert használata release buildben
bounds-ellenőrzésre. Az assert NDEBUG mellett
eltűnik, így a védelem is; ha a határ valódi szerződésszegés esetén is
kezelendő, explicit hibavisszatérés vagy kivétel kell, és ezt a
hibaútvonalat tesztelni kell.
A negyedik: a layout_stride-ot folytonosnak tekinteni,
és data_handle()-re memcpy-t hívni. Csak akkor
helyes, ha a lépésvektor pontosan a sűrű row-major vagy column-major
leképezést adja; ezt ellenőrizni kell, nem feltételezni.
(számítás) Egy
-ös
double mátrix column-major tárolású. Add meg a
lépésvektort, a
elem offsetjét, a teljes elemszámot és a legnagyobb előforduló offsetet.
Hasonlítsd össze a row-major offsettel.
(kódolás) Egészítsd ki a MatrixView2D-t egy
subview(r0, c0, rows, cols) tagfüggvénnyel, amely
alterületet ad vissza, és amely a kért alterületre is elvégzi a
bounds-ellenőrzést. Írj hozzá assert-alapú
tesztet.
(diagnosztika) Készíts olyan parancssori ellenőrzést,
amely eldönti, hogy az adott fordító és standard library kínál-e
mdspan-t. Fogalmazd meg, mi a teendő, ha nem.
(elemzés) Egy numerikus kernel egy
mátrixot jár be. Indokold, miért nem mindegy, hogy az offsetet
int, std::size_t vagy ptrdiff_t
típusban számoljuk, és add meg a helyes előfeltételt.
1. megoldás. Column-major esetén , . Az offset -ra: . Teljes elemszám , a legnagyobb offset -nél , ami konzisztens a sűrű kitöltéssel. Row-majorban ugyanez , tehát a két layout ugyanarra az indexre más elemet ad — ezért kell a nézetnek a layoutot explicit módon hordoznia. Ellenőrzési feltétel: minden érvényes indexre.
2. megoldás. Az alterület bázisa
data_ + r0 * stride_ + c0, kiterjedése rows,
cols, lépése változatlanul stride_. Az
ellenőrzés: r0 + rows <= rows_,
c0 + cols <= cols_, és a két összeadás nem csordulhat
túl (r0 <= rows_ - rows alakban írva ez elkerülhető). A
visszaadott nézet ugyanarra a tulajdonosra mutat, ezért az owner
élettartamára vonatkozó szerződés változatlan. Teszt: alterület írása
után a teljes nézeten a megfelelő (r0+i, c0+j) elem
változik meg; ellenőrzési feltétel, hogy a teljes mátrixon kívül eső
elem nem változik.
3. megoldás. A __cpp_lib_mdspan macro a
<version> (vagy a <mdspan>)
beolvasása után definiált, ha a könyvtár támogatja. Fordítási próba:
cat > mdspan_probe.cpp <<'EOF'
#include <version>
int main() {
#ifdef __cpp_lib_mdspan
return 0;
#else
return 1;
#endif
}
EOF
g++ -std=c++23 -fsyntax-only mdspan_probe.cpp && echo "fordul" || echo "nem fordul"A -fsyntax-only nem gyárt futtatható fájlt, ezért a
labor más részeit nem befolyásolja. Ha a macro hiányzik, két út van:
C++20-as stride-alapú saját nézet (mint fent), vagy a toolchain
frissítése; a standard verziójelölés önmagában nem bizonyíték arra, hogy
a könyvtár is támogatja. Ellenőrzési feltétel: a macro értékét is
naplózzuk, mert a puszta létezés nem feltétlenül a kívánt felületet
jelenti.
4. megoldás. int esetén
néhány tízezres értékénél már a szorzat is túlcsordul, és a hiba signed
esetben UB, nem pedig kiszámítható wrap. A std::size_t
modulo-aritmetikát ad, tehát a túlcsordulás csendesen rossz indexet
eredményez — ez a legveszélyesebb változat, mert „működni látszik”. A
ptrdiff_t szélesebb, de negatív indexet is megenged, ami a
bounds-szerződést gyengíti. Helyes előfeltétel: az indexelés
std::size_t-ben történik, a legnagyobb offset
ábrázolható, és ezt a konstrukció idején ellenőrizzük; a belső ciklusban
futó ellenőrzés helyett a szerződést a nézet létrehozásakor egyszer
érdemes kikényszeríteni.
A többdimenziós nézet három független döntésből áll: kiterjedés,
lépés és hozzáférés. A C tömb ezeket beégeti a típusba, ezért
biztonságos, de merev; a std::mdspan ugyanezt futásidejű
extentsszel, választható layout-policyval és accessorral kínálja, de nem
owner, és csak C++23 könyvtárral érhető el. C++20-on a pointer +
kiterjedés + stride hármas adja ugyanazt a szerződést, feltéve, hogy a
bounds- és overflow-ellenőrzést a konstrukció idejére tesszük. A
numerikus könyvtárak térképén a numeric_limits az
előfeltételekhez, a complex a numerikus aritmetikához, a
random a mintavételhez tartozik; egyik sem helyettesíti a
layout és az élettartam explicit leírását.
Verzió- és forráskorlát: ebben az alfejezetben a
szabványhelyekre közvetlen hivatkozást nem adunk, mert a fejezethez nem
került feldolgozott elsődleges forrás. A mdspan és a
többargumentumú operator[] C++23-as státusza, a
__cpp_lib_mdspan macro konkrét értéke, valamint a
std::complex layout-garanciájának pontos szövege ezért a
saját toolchain és a megfelelő verziójú nyilvános szabványtervezet
ellenőrzésével megerősítendő. Az N4950 nyilvános munkatervezet, nem az
ISO kiadott szabványa.