31.5 Multidimenziós view és numeric utility térkép

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.

31.5.1 Extent, stride és az indexelés szerződése

Egy nn-dimenziós, E0×E1×…×En−1E_0\times E_1\times\dots\times E_{n-1} 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

offset(i0,…,in−1)=∑k=0n−1ik⋅sk,\operatorname{offset}(i_0,\dots,i_{n-1}) = \sum_{k=0}^{n-1} i_k \cdot s_k ,

ahol sks_k a kk-adik dimenzió lépése. A row-major (sorfolytonos) elrendezésben az utolsó dimenzió lépése 1, és sk=∏j>kEjs_k = \prod_{j>k} E_j; a column-major (oszlopfolytonos) elrendezésben s0=1s_0 = 1 és sk=∏j<kEjs_k = \prod_{j<k} E_j. Kétdimenziós esetben ez a két jól ismert képletet adja:

offsetRM(i,j)=i⋅E1+j,offsetCM(i,j)=i+j⋅E0.\operatorname{offset}_{RM}(i,j) = i\cdot E_1 + j, \qquad \operatorname{offset}_{CM}(i,j) = i + j\cdot E_0 .

A szerződés feltételei: (1) minden 0≤ik<Ek0 \le i_k < E_k; (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 (1⋅4+21\cdot 4+2) 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).

31.5.2 C++23 mdspan: extents, layout és accessor

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;
}
#endif

Ké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 (1+2⋅3=71 + 2\cdot 3 = 7, szemben a row-major 1⋅4+2=61\cdot 4 + 2 = 6-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.

31.5.3 C++20 fallback: span és stride-alapú saját nézet

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.

31.5.4 Mátrixindex overflow és bounds szerződés

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 2642^{64} 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 (R−1)⋅s+(C−1)(R-1)\cdot s + (C-1); ez akkor ábrázolható std::size_t-ben, ha

(R−1)≤⌊SIZE_MAX−(C−1)s⌋.(R-1) \le \left\lfloor \frac{\text{SIZE\_MAX} - (C-1)}{s} \right\rfloor .

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.

31.5.5 Numeric utility térkép

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 2642^{64} 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.

31.5.6 Tipikus hibák és javításuk

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.

31.5.7 Feladatok

  1. (számítás) Egy 4×54\times 5-ös double mátrix column-major tárolású. Add meg a lépésvektort, a (2,3)(2,3) elem offsetjét, a teljes elemszámot és a legnagyobb előforduló offsetet. Hasonlítsd össze a row-major offsettel.

  2. (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.

  3. (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.

  4. (elemzés) Egy numerikus kernel egy N×NN\times N 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.

31.5.8 Megoldások

1. megoldás. Column-major esetén s0=1s_0=1, s1=E0=4s_1=E_0=4. Az offset (2,3)(2,3)-ra: 2⋅1+3⋅4=142\cdot 1 + 3\cdot 4 = 14. Teljes elemszám 4⋅5=204\cdot 5=20, a legnagyobb offset (3,4)(3,4)-nél 3+4⋅4=19=20−13 + 4\cdot 4 = 19 = 20-1, ami konzisztens a sűrű kitöltéssel. Row-majorban ugyanez 2⋅5+3=132\cdot 5 + 3 = 13, 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: 0≤offset<E0E10 \le \operatorname{offset} < E_0 E_1 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 NN 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 (N−1)s+(N−1)(N-1)s + (N-1) á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.

31.5.9 Összefoglaló

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.