44.4 Migrációs labor: a C++ záróprojekt átállítása C++23-ra

A C++23-ra való migráció ebben a laborban nem a záróprojekt teljes újraírását jelenti, hanem a C++ core: parser, erőforrások és értéktípusok (39.2) és CMake, tesztek és mérési csomag (39.4) során felépített projekt fokozatos átállítását mérhető, visszagörgethető lépésekben. A cél, hogy a projekt kritikus komponenseit C++23-as szabványos eszközökkel váltsuk ki, miközben a C++20-as build továbbra is működik, és a viselkedés nem változik. A migráció során minden új képesség elérhetőségét feature-test makróval és compile probe-bal ellenőrizzük, és minden új eszközhöz fallbacket adunk. A labor eredménye egy kétprofilos build, egy CI-mátrix, egy migrációs jelentés és egy release notes. A fejezet az Elavult, eltávolított és viselkedést váltó elemek C++23-ban (44.3) során rögzített elavulási és eltávolítási listára épül, de nem ismétli meg annak teljes tartalmát.

44.4.1 A kiinduló állapot és a viselkedési szerződés rögzítése

A migráció első lépése a kiinduló állapot (baseline) és a viselkedési szerződés (behavioral contract) rögzítése. A viselkedési szerződés a projekt azon megfigyelhető tulajdonságainak összessége, amelyekre a felhasználók és a tesztek támaszkodnak: a CLI kimenete, a hibakódok, a protokoll üzenetek, a szolgáltatás életciklusa és a timing. A szerződés nem tartalmazza a belső implementáció részleteit, például azt, hogy egy result típus hogyan tárolja a hibát, vagy hogy egy tároló milyen iterátorokat használ. A migráció során a szerződés nem változhat, kivéve, ha a változás dokumentált, tesztelt és a release notes-ben jelölt.

A baseline rögzítése a következő lépésekből áll. Először a C++20-as buildet le kell fordítani a CMake, tesztek és mérési csomag (39.4) szerinti CMake-profillal, és le kell futtatni a teljes CTest-készletet, beleértve a CTest, negatív tesztek és sanitizer CI (38.4) szerinti negatív teszteket és sanitizeres futtatásokat. Az eredményt rögzíteni kell: hány teszt fut, mennyi a fordítási idő, mekkora a bináris mérete. Másodszor a CLI kimenetét golden fájlokban kell rögzíteni a tipikus bemenetekre, beleértve a hibaeseteket is. Harmadszor a hibakódokat és a hibaüzeneteket kell dokumentálni, mert a felhasználók ezekre támaszkodhatnak. Negyedszer a hálózati protokoll üzeneteit kell rögzíteni, ha a projekt ilyet használ. Ötödször a mérési pontokat kell kijelölni: a parser futásideje, a szolgáltatás válaszideje, a memóriahasználat és a bináris méret. A méréseket a CMake, tesztek és mérési csomag (39.4) mérési csomagjával kell elvégezni, és az eredményeket verziókezelt fájlban kell tárolni.

A baseline rögzítése után a migráció minden lépését ugyanezekkel a tesztekkel és mérésekkel kell ellenőrizni. Ha egy lépés megváltoztatja a viselkedést, vagy nem hoz mérhető javulást, vissza kell vonni. A visszagörgethetőség érdekében minden lépés külön commit legyen, és a build profilok külön könyvtárban legyenek.

44.4.2 Kétprofilos build és CI-mátrix

A kétprofilos build azt jelenti, hogy ugyanaz a forráskód két nyelvi módban fordul: C++20 és C++23. A CMake-ben ezt egy opcióval vezéreljük. A következő példa a projekt gyökerében lévő CMakeLists.txt releváns részét mutatja. A példa illusztratív, a CMake és a compiler verzióját a laborprofilban rögzíteni kell.

cmake_minimum_required(VERSION 3.28)
project(diag LANGUAGES CXX)

option(DIAG_USE_CXX23 "Build with C++23" OFF)

add_executable(diag
  src/main.cpp
  src/parser.cpp
  src/service.cpp
)

if(DIAG_USE_CXX23)
  target_compile_features(diag PRIVATE cxx_std_23)
else()
  target_compile_features(diag PRIVATE cxx_std_20)
endif()

target_compile_options(diag PRIVATE -Wall -Wextra -Wpedantic)

A build parancsok a két profilra:

cmake -S . -B build-cxx20 -G Ninja -DDIAG_USE_CXX23=OFF -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build-cxx20
ctest --test-dir build-cxx20 --output-on-failure

cmake -S . -B build-cxx23 -G Ninja -DDIAG_USE_CXX23=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build-cxx23
ctest --test-dir build-cxx23 --output-on-failure

A CI-mátrix két dimenziója a fordító (GCC, Clang) és a nyelvi mód (C++20, C++23). Minden kombinációhoz rögzíteni kell a konkrét compiler- és standard library-verziót, a build parancsot és a teszt parancsot. A mátrix minden elemén futnak a CTest-tesztek, a sanitizeres futtatások és a golden fájlok ellenőrzése. A C++23-as buildben a feature-test makrók alapján kapcsolódnak be a komponensek; a C++20-as buildben a fallbacknek kell fordulnia. A CI-mátrix akkor tekinthető teljesnek, ha a C++20-as és a C++23-as build ugyanazokat a viselkedési teszteket teljesíti.

44.4.3 Komponensenkénti csere méréssel és fallbackkal

A migrációt komponensenként végezzük. Minden komponensnél meg kell határozni a motivációt, a pontos cserét, a fallbacket és a mérési szempontokat. A javasolt sorrend: std::expected, std::print, ranges::to, std::flat_map. A sorrend a kockázat és a fallback költsége szerint növekszik.

44.4.3.1 Saját result típus helyett std::expected

A C++ core: parser, erőforrások és értéktípusok (39.2) során a projekt hibakezelése egy saját result típusra épült. A C++23-as std::expected<T, E> szabványos, monadikus műveleteket (and_then, or_else, transform) kínál, és jobban illeszkedik a standard library többi részéhez. A csere nem jelentheti a hívási helyek teljes újraírását: vezessünk be egy DiagResult aliast, amely a C++23-as buildben a std::expected-et, a C++20-as buildben a meglévő típust használja.

// feature.hpp
#pragma once

#if __has_include(<version>)
#  include <version>
#endif

#if defined(__cpp_lib_expected) && __cpp_lib_expected >= 202202L
#  include <expected>
#  define DIAG_HAVE_EXPECTED 1
#else
#  define DIAG_HAVE_EXPECTED 0
#endif
// result.hpp
#include "feature.hpp"
#include <string>

#if DIAG_HAVE_EXPECTED
template<class T, class E = std::string>
using DiagResult = std::expected<T, E>;
#else
template<class T, class E = std::string>
using DiagResult = diag::legacy_result<T, E>;
#endif

A hívási helyeken a DiagResult-ot kell használni. A monadikus láncokat csak ott érdemes átírni, ahol a std::expected szemantikája egyértelmű; a régi result típus eltérő hibakezelése miatt a viselkedést tesztekkel kell ellenőrizni. A tipikus hiba, hogy a std::expected nem dob kivételt, a régi result típus viszont igen; a hibakezelést a hívási helyeken egységesíteni kell. A mérési szempontok: a fordítási idő, a bináris méret és a hibaút futásideje.

44.4.3.2 Naplózás: printf/iostream helyett std::print

A projekt naplózása jelenleg printf vagy iostream alapon működik. A C++23-as std::print típusbiztos, formázott kimenetet ad, és a formázási szintaxisa a std::format-tal azonos. A fallback a meglévő printf vagy std::format + std::cout marad. A naplózó függvényt érdemes egyetlen interfész mögé rejteni, hogy a hívók ne lássák a különbséget.

// log.hpp
#include "feature.hpp"
#include <string_view>

namespace diag {
void log(std::string_view msg);
}
// log.cpp
#include "log.hpp"

#if DIAG_HAVE_PRINT
#  include <print>
void diag::log(std::string_view msg) {
  std::print("{}\n", msg);
}
#else
#  include <cstdio>
void diag::log(std::string_view msg) {
  std::printf("%.*s\n", static_cast<int>(msg.size()), msg.data());
}
#endif

A tipikus hiba, hogy a std::print nem flush-el automatikusan minden esetben; a naplózás végén szükség lehet std::flush-re. A formázási sztringeket egységesen {} helyőrzőkkel kell írni, és a fallbackben át kell alakítani %-re, vagy a fallbackben is std::format-et kell használni, ha elérhető. A mérési szempontok: a naplózási throughput, a bináris méret és a fordítási idő.

44.4.3.3 Materializálás: kézi gyűjtés helyett ranges::to

A projekt több helyen kézi ciklussal gyűjt egy tartományból vektort. A C++23-as std::ranges::to<std::vector>() tömörebb és kifejezőbb, és a standard range-ekkel működik. A fallback egy egyszerű ciklus.

// collect.hpp
#include "feature.hpp"
#include <ranges>
#include <vector>

template<std::ranges::range R>
auto collect(R&& r) {
#if DIAG_HAVE_RANGES_TO
  return std::forward<R>(r) | std::ranges::to<std::vector>();
#else
  std::vector<std::ranges::range_value_t<R>> out;
  for (auto&& e : r) out.push_back(std::forward<decltype(e)>(e));
  return out;
#endif
}

A tipikus hiba, hogy a bemeneti tartomány nem forward range, és a ranges::to többször is bejárhatja; ilyenkor a tartományt előbb materializálni kell, vagy a fallbacket kell használni. A mérési szempontok: az allokációk száma, a futásidejű teljesítmény és a bináris méret.

44.4.3.4 Rendezett tároló: std::map helyett std::flat_map

A projekt rendezett asszociatív tárolóként std::map-et használ. A C++23-as std::flat_map folytonos tárolásban tartja a kulcs-érték párokat, ami jobb locality-t és kisebb memóriahasználatot ad, de a beszúrás és a törlés drágább, és az iterátorok a beszúrás után érvénytelenné válnak. A fallback a std::map.

Tulajdonság std::map std::flat_map
Tárolás csomópontos folytonos
Keresés O(logn)O(\log n) O(logn)O(\log n)
Beszúrás O(logn)O(\log n) O(n)O(n)
Iterátor stabilitás beszúrás után is érvényes beszúrás után érvénytelen
Memória nagyobb kisebb
Locality gyengébb jobb
// map.hpp
#include "feature.hpp"
#include <map>

#if DIAG_HAVE_FLAT_MAP
template<class K, class V>
using DiagMap = std::flat_map<K, V>;
#else
template<class K, class V>
using DiagMap = std::map<K, V>;
#endif

A tipikus hiba, hogy a kód iterátort tárol a tárolóban, és a beszúrás után azt használja; ez std::flat_map esetén dangling iterátorhoz vezet. A cserét csak olyan helyen szabad elvégezni, ahol a kód nem támaszkodik az iterátorok stabilitására. A mérési szempontok: a keresési idő, a beszúrási idő, a memóriahasználat és a bináris méret.

44.4.4 Feature-test, compile probe és fallback stratégia

A feature-test makrók a fordító és a szabványos könyvtár által definiált makrók, amelyek egy adott nyelvi vagy könyvtári képesség elérhetőségét jelzik. A __cpp_lib_* makrók a könyvtári képességeket jelzik, és a <version> headerben érhetők el. A laborban használt makrók: __cpp_lib_expected, __cpp_lib_print, __cpp_lib_ranges_to_container, __cpp_lib_flat_map. A pontos értékek verziófüggők; a kódban a szabványos minimális értékre kell összehasonlítani, de az elérhetőséget a compiler és a standard library dokumentációja szerint kell ellenőrizni.

A compile probe egy olyan minimális forrás, amelynek sikeres fordítása igazolja egy képesség elérhetőségét. A feature-test makrók mellett akkor hasznos, ha a makró nem megbízható, például részleges implementáció esetén. Például:

// probe_expected.cpp
#include <expected>
int main() {
  std::expected<int, int> e = 42;
  return e.value();
}

A fallback stratégia lényege, hogy a hívók csak a projekt saját absztrakcióját használják, és a feature-test makrók alapján a build válassza ki az implementációt. A fallbacknek ugyanazt a viselkedést kell biztosítania, mint az új implementációnak. A release notes-ben dokumentálni kell, hogy melyik komponens milyen feature-test makrót igényel, mi a fallback, és milyen viselkedésbeli eltérés van. A release notes a migráció visszagörgethetőségének is az alapja.

44.4.5 Teljesítmény és kódméret összehasonlítása

A migráció mérhető, ezért minden lépés előtt és után mérni kell. A metrikák: a fordítási idő, a bináris méret, a futásidejű benchmark, a memóriahasználat és a tesztek száma. A méréshez használható eszközök: time a fordítási időhöz, size vagy bloaty a bináris mérethez, /usr/bin/time -v a memóriahasználathoz, és egy saját benchmark vagy hyperfine a futásidejű teljesítményhez. A méréseket azonos környezetben, azonos bemenettel és többszöri futtatással kell végezni; az eredmények mediánját érdemes rögzíteni.

A mérési terv része a döntési feltétel is. Ha egy komponens cseréje nem hoz mérhető javulást, vagy rontja a viselkedést, vissza kell vonni. Például a std::flat_map bevezetését csak akkor érdemes megtartani, ha a keresési idő javul, a beszúrási idő nem romlik jelentősen, és a kód nem támaszkodik az iterátorok stabilitására. A mérések eredményét a migrációs jelentésben kell dokumentálni. A labor során a tanuló végzi a méréseket; a fejezet nem állítja, hogy a példák mért eredményeket tartalmaznak.

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

Az első hiba, hogy a feature-test makrót a header include előtt ellenőrizzük. A helyes sorrend: #include <version>, majd a makró ellenőrzése, majd a szükséges header includolása. A második hiba, hogy a std::print nem flush-el, és a napló késleltetve jelenik meg. A javítás: a naplózó függvény végén std::flush vagy a kimenet lezárása. A harmadik hiba, hogy a std::flat_map iterátorait a beszúrás után is használjuk; a javítás: az iterátorokat a beszúrás után újra kell szerezni, vagy std::map-et kell használni. A negyedik hiba, hogy a ranges::to többször bejárja a bemeneti tartományt; a javítás: a tartományt előbb materializálni kell, vagy a fallbacket kell használni. Az ötödik hiba, hogy a C++20-as buildben a fallback hiányzik, és a build nem fordul; a javítás: minden új képességhez kötelező a fallback. A hatodik hiba, hogy a CMake verziója nem ismeri a cxx_std_23 feature-t; a javítás: a laborprofilban rögzített CMake-verziót kell használni, vagy a CXX_STANDARD tulajdonságot kell beállítani, ha az támogatott.

44.4.7 Feladatok

  1. Készíts döntési táblázatot a négy komponens (std::expected, std::print, ranges::to, std::flat_map) migrációs sorrendjéről. Szempontok: kockázat, mérési lehetőség, fallback költsége és függőségek. Indokold a sorrendet.

  2. Írj egy feature detection headert és egy DiagResult aliast, amely a C++23-as std::expected-et használja, ha elérhető, különben a meglévő diag::legacy_result-ot. Add meg a build parancsot mindkét profilra.

  3. A C++20-as build a következő hibát adja: error: 'expected' is not a member of 'std'. Mi a hiba oka, és hogyan javítod? Írd le a javítás lépéseit.

  4. Alakítsd át a naplózást std::print-re, és biztosíts fallbacket. A naplózó függvény szignatúrája void log(std::string_view). Add meg a kódot és a build parancsot.

  5. Tervezz egy mérési tervet a std::flat_map bevezetéséhez. Milyen metrikákat mérsz, milyen eszközökkel, milyen környezetben, és hogyan döntesz a bevezetésről?

44.4.8 Megoldások

44.4.8.1 Az első feladat megoldása

A javasolt sorrend: std::expected, std::print, ranges::to, std::flat_map. Az std::expected kockázata a legkisebb, mert egy alias mögé rejthető, és a fallback a meglévő típus. A std::print szintén jól elkülöníthető, a fallback egy függvény. A ranges::to lokális csere, a fallback egy ciklus. A std::flat_map kockázata a legnagyobb, mert az iterátorok invalidációja és az API eltérése miatt a kód több helyen is módosítást igényelhet. Az ellenőrzési feltétel: minden lépés után a C++20-as és a C++23-as build is fordul, és a viselkedési tesztek zöldek.

44.4.8.2 A második feladat megoldása

A feature detection header és az alias a fenti feature.hpp és result.hpp példa szerint épül fel. A build parancsok:

cmake -S . -B build-cxx20 -G Ninja -DDIAG_USE_CXX23=OFF
cmake --build build-cxx20
cmake -S . -B build-cxx23 -G Ninja -DDIAG_USE_CXX23=ON
cmake --build build-cxx23

Az ellenőrzési feltétel: a C++20-as buildben a DiagResult a diag::legacy_result, a C++23-as buildben a std::expected típusra oldódik fel, és a tesztek mindkét profilban zöldek.

44.4.8.3 A harmadik feladat megoldása

A hiba oka, hogy a C++20-as buildben a std::expected nem elérhető, mert a __cpp_lib_expected makró nem definiált, vagy a <expected> header nem elérhető. A javítás lépései: először a feature detection headerben ellenőrizni kell a __cpp_lib_expected makrót; másodszor a DiagResult aliast a diag::legacy_result-ra kell irányítani; harmadszor a hívási helyeken nem szabad közvetlenül std::expected-et használni, csak a DiagResult-ot. Az ellenőrzési feltétel: a C++20-as build fordul, és a hibakezelési tesztek zöldek.

44.4.8.4 A negyedik feladat megoldása

A naplózó függvény a fenti log.hpp és log.cpp példa szerint épül fel. A build parancsok a második feladatnál megadottak. Az ellenőrzési feltétel: a napló kimenete mindkét profilban ugyanaz, és a std::print esetében a kimenet flush-elődik. A fallback a printf-et használja, amely a stringet és az új sort kiírja.

44.4.8.5 Az ötödik feladat megoldása

A mérési terv: metrikák a keresési idő, a beszúrási idő, az iterációs idő, a memóriahasználat és a bináris méret. Eszközök: saját benchmark a műveletekhez, /usr/bin/time -v a memóriahasználathoz, size a bináris mérethez. A környezet: rögzített CPU, memória, compiler-verzió és optimalizálási szint. A döntés: ha a keresési idő javul, a beszúrási idő nem romlik jelentősen, és a kód nem támaszkodik az iterátorok stabilitására, a std::flat_map bevezethető; különben a std::map marad. A döntést a release notes-ben dokumentálni kell.

44.4.9 Összefoglaló

A migrációs labor a záróprojekt C++23-ra állítását mérhető, visszagörgethető lépésekben végzi. A kiinduló állapot és a viselkedési szerződés rögzítése után a kétprofilos build és a CI-mátrix biztosítja, hogy a C++20-as és a C++23-as build ugyanazt a viselkedést nyújtsa. A komponensenkénti csere során a saját result típust std::expected-re, a naplózást std::print-re, a kézi materializálást ranges::to-ra, a rendezett tárolót std::flat_map-re cseréljük, minden esetben feature-test makróval, compile probe-bal és fallbackkal. A teljesítmény- és kódméret-összehasonlítás alapján döntünk a megtartásról vagy a visszavonásról. A tipikus hibák elkerülése és a release notes vezetése a migráció sikerének feltétele.