Dnešní příspěvek je o tom, jak namísto sofistikovaných testovacích nástrojů používat k testování samotný překladač.
I když si testy náhodou nepíšete, s testy různých knihoven jste se určitě už setkali. Leckteré knihovny, které se nedají nainstalovat z balíčkových systémů, ale musí se instalovat ručně, mají často tento doporučený postup instalace do systému:
$ mkdir build $ cd build $ cmake .. $ make all $ make test $ sudo make install
Build má zpravidla připravené testy spouštěné právě přes příkaz make test
. A nezřídka se stává, že veškerý build projde bez potíží a instalace selže až v části s testy – což zpravidla člověka nejprve naštve, a pak stejně knihovnu nainstaluje, protože „nemá jinou možnost“.
V moderních verzích C++ se přitom nabízí možnost se při vývoji vlastních knihoven vyhnout nutnosti vytvářet „testovací fázi“ a používat externí testovací nástroje. Tím neříkám, že se vyhneme psaní testů úplně, ale pro testování použijeme překladač. Má to své výhody a nevýhody
První, co je třeba vzít v úvahu je, že testovat překladačem lze constexpr
kód. Pokud kód takto napsaný není, testování překladačem není možné. Na druhou stranu, naléhavě doporučuji všem C++ programátorům, aby vzali do úvahy možnost své třídy navrhovat jako constexpr
. Ono to má mnoho výhod. Například:
Abych byl spravedlivý, zmíním ještě nevýhody
constexpr
. Zejména třídy komunikující s vnějším světem. Tam musíme vnější svět nahradit „maketou“ – ale tím se jen odsouvá problém, protože jak se pak bude testovat ta část nahrazená maketou? Na druhou stranu to donutí programátora rozdělit kód na část, která něco počítá a část zajišťující komunikaci a vyhnout se špagetovému návrhu.constexpr
. Opět to lze řešit nějakou maketou té druhé knihovnyconstexpr
, ačkoliv je to verze od verze lepší. v C++17 je rozsah operací minimální, v C++20 lze použít většinu knihovny, včetně vektorů, map, ale nemůžete použít třeba std::string (string_view ano). Stále chybí podpora constexpr
matematiky, protože se C++ pořád spoléhá na <math.h>. Tohle je stále pupenční šňura ke starému C a já tleskám tomu, že dochásí k postupnému odpojení, protože v novějších verzích už je většina matematiky constexpr
constexpr
nelze používat výjimky – přesněji, kód může používat výjimky, ale nedají se testovat. Pokud překladač při evaluaci constexpr narazí na výjimku, tak se evaluace ukončí chybou, přestože je výjimka odchycená. Toto je v plánu pro C++26, ale jestli se to tam dostane ještě není jasné. (P3068R6)constexpr
nejsou a nebudou. (že by? P3367R0)constexpr
testovat nejde a nemůžete tedy testovat ani sdílený přístup k resourcům.reinterpret_cast
. Určitou slabou náplastí je std::bit_cast ten ale neřeší konverze pointerů, například z char *
na unsigned char *
který bývá velmi častý. Oproti sofistikovaným nástrojům samotné C++ toho moc nenabízí. Ke spuštění a ověření testu budeme používat příkaz static_assert
constexpr bool run_test_1() { //... sem napište test return true; // test prošel } static_assert(run_test_1());
Pokud takový kód se pokusíte přeložit, tak překlad proběhne jen v případě, že testy projdou. Pokud některý test neprojde, objeví se chybové hlášení na řádku s patřičým static_assert
, který selhal.
Jako příklad uvedu třídu, která počítá SHA1. Ta třída vznikla pro můj projekt kotle řízeného Arduinem, protože pro podporu WebSockets musí být server schopen spočítat SHA1 řetězce z hlavičky handshake, jinak se s ním prohlížeč nebude bavit. Na Arduinu OpenSSL nenajdete (i když jsou knihovny na cryptooperace, třeba zde)
Kód SHA1 třídy najdete zde (https://godbolt.org/z/cfTbxjeGo). Jasně, že kód jsem nevymýšlel, vzal jsem nějakou existující implementaci v C a přepsal pro C++. Tento kód jde constexpr
testovat už od C++17, přestože tato norma nabízí jen velmi omezenou podporu ze strany STL. Navíc v GCC-7.2 je chyba při práci se string_view
, který nelze v constexpr přímo instanciovat a z toho důvodu existuje funkce make_constexpr_stringview
, která chybu obchází. V novější verzi GCC už je funkce nadbytečná. Pokud si budete v Compiler Exploreru hrát s příkladem, tak nezapomeňte, že linker bude upozorňovat na chybějící – to je úspěšný překlad – protože main
tam skutečně není, a dále, že je třeba pro MSVC uvést „/std:c++17“ – MS neuznává CLI známé z GCC/Clang
Pokud test selže – což můžeme nasimulovat tím, že v příkladu upravíme zprávu bez změny hashe – překladač vypíše chybu
MSVC: <source>(304): error C2607: static assertion failed CLANG: <source>:304:15: error: static assertion failed due to requirement ... GCC: <source>:304:72: error: static assertion failed
Diagnostika v tomto směru je velice strohá, nedozvíme se co přesně selhalo. V příkazu static_assert
lze pouze vracet true
nebo false
.
Příkaz static_assert
lze obohatit textem, který se vypíše spolu s chybou.
static_assert(run_test_1(), "Selhal test na Hello world"); MSVC: <source>(304): error C2338: static_assert failed: 'Selhal test na Hello world' CLANG: <source>:304:15: error: static assertion failed due to requirement '...' : Selhal test na Hello world GCC: <source>:304:72: error: static assertion failed: Selhal test na Hello world
Tohle je sice hezký, ale moc diagnostiky to nepřidá. Jistě je dobré popsat, co selhalo, ale to stejně tak dobře mohu napsat jako komentář, protože většina moderních IDE umístí kurzor (nebo podtrhne červeně) tu řádku, kde došlo k selhání testu.
S určitou možností přichází až C++26, který umožňuje, aby zpráva u static_assert
nebyla konstantou, postačí, aby zpráva byla generována během constexpr
funkcí
#include <string> constexpr bool run_test_1() { return false; } constexpr std::string error_msg() { return "test1_selhal"; } static_assert(run_test_1(), error_msg()); ------------------------------------------------- <source>:12:25: error: static assertion failed: test1_selhal
Tento kód najdete zde: (https://godbolt.org/z/56eo6nPGM). Možnost generovat zprávu pomocí kódu umožňuje do zprávy vygenerovat nějakou diagnostiku, jako v následujícím příkladě selže test s neočekávanou hodnotou a tuto hodnotu si necháme vypsat
constexpr unsigned int run_test_5() { return 42; } constexpr std::string to_string(unsigned int v) { char buff[20]; auto r = std::to_chars(buff, buff+sizeof(buff), v, 10); return std::string(buff, r.ptr - buff); } static_assert(run_test_5() == 0, to_string(run_test_5())); -------------------------------------------------------------- <source>:51:28: error: static assertion failed: 42
Poznámka ke kód: C++26 ještě nepodporuje constexpr std::to_string
a tak jsem narychlo vymýšlel jednoduchý způsob převodu čísla na řetězec přes std::to_chars. Taky se mi tento kód nelíbí.
V příkazu static_assert
je vidět, že se test vlastně spouští 2×. Poprvé aby se ověřilo, zda test prošel a podruhé, aby se vrácená hodnota vypsala.
Víc příkladů jsem si připravil zde: https://godbolt.org/z/KMeMThGbj
Povšimněte si, že napříkl GCC-14 přidává diagnostiku u testu, který obsahuje porovnávání celých čísel.
<source>:51:28: note: the comparison reduces to '(42 == 0)'
Tato diagnostika je k dispozici už od verze 12 a je k dispozici i pro C++20 (vlastně nezáleží na verzi C++). Clang něco podobného přidává od verze 17. MSVC žádnou diagnostiku nevypisuje (bohužel)
Nicméně je to jeden ze způsobů, jak psát testy s nějakou základní diagnostikou při vývoji v linuxu. (nebo tak, kde se nebude používat MSVC).
static_assert(run_test_X() == predpokladany_vysledek); ----------------------------------------------------- note: the comparison reduces to '(xx == yy)'
Namísto aby test vracel bool
, může vracet přímo hodnotu a ta hodnota nechť se testuje v samotném static_assert. Z funkce lze vracet nejen celé číslo, ale i číslo float nebo znak. V případě znaku překladač Clang je schopen vypsat daný znak, v případě GCC se vypíše jeho ascii kód. Pokud bude funkce vracet enum, tak GCC nevypisuje žádnou diagnostiku, zatímco Clang převede enumy na čísla a vypíše to jako číselný výsledek
<source>:50:15: error: static assertion failed due to requirement 'run_test_4() == TestResult::ok' <source>:49:28: note: expression evaluates to '2 == 0'
Jedna cesta vede přes použití šablony. Pokud totiž selže nějaký assert v šabloně, pak překladače mají snahu vypsat co nejvíc informací o šabloně. A v té se může objevit výsledek testu
Následující příklad je jeden z předchozích příkladů linkovaných na Compiler Explorer
template<TestResult x> constexpr bool validate() { static_assert(x == TestResult::ok); return true; } static_assert(validate<run_test_2()>());
V tomto případě překlad selže ve funkci validate, nikoliv v samotném globálním static_assert
. Příkaz static_assert
na globální úrovni se zde používá jen ke spuštění validate. Protože assert selhal v šabloně, překladač vypíše víc informací o šabloně. Tohle funguje i v MSVC (tučně je zvýrazněn důvod selhání testu)
MSVC: <source>(31): error C2338: static_assert failed: 'x == TestResult::ok' <source>(31): note: the template instantiation context (the oldest one first) is <source>(38): note: see reference to function template instantiation 'bool validate<TestResult::invalid_result>(void)' being compiled CLANG: <source>:31:19: error: static assertion failed due to requirement '(TestResult)1 == TestResult::ok' <source>:38:15: note: in instantiation of function template specialization 'validate<TestResult::invalid_result>' requested here <source>:31:21: note: expression evaluates to '1 == 0' GCC: <source>: In instantiation of 'constexpr bool validate() [with TestResult x = TestResult::invalid_result]' <source>:38:37: required from here <source>:31:21: error: static assertion failed <source>:31:21: note: '(TestResult::invalid_result == TestResult::ok)' evaluates to false
Ilustrační příklad najdete zde: https://godbolt.org/z/E6bnb1×KW
Je třeba dodat, že výše uvedený postup funguje pouze s integrálními konstantami (cokoliv co jde použít jako parametr šablony)
Následující technika se hodí v situaci, kdy test selhává a my nevíme proč, ale zcela jistě je to kvůli hodnotě nějaké proměnné. Constexpr nelze krokovat v debuggeru – tedy lze, ale je třeba vyřadit test a pustit to jako obyčejný program – což nelze v situaci, kdy constexpr
používáme na generování během překladu. Možná by stačilo proměnnou jen vypsat a pomohlo by to odhalit problém.
Během constexpr
evaluace nelze nikam nic vypisovat, žádný console.log
, printf
ani std::cout
. Pokud jde kód přepsat s použitím static_assert, tak by možná šlo v C++26 použít
static_assert(false, to_string(var)); //selže a vypíše var
Výše zmíněný příklad selže protože
<source>:56:26: error: the message in a static assertion must be produced by a constant expression <source>:56:36: note: read of non-const variable 'var' is not allowed in a constant expression
… protože v místě, kde s proměnnou pracujeme, nejde o constexpr
evaluaci. Tohle je dost často problém v pochopení kontextu obecně. Programátor často selže právě v neuvědomění si kontextu a perspektivy. Funkce constexpr
která je právě evaluovaná není považována za constexpr
, tedy i proměnné a dokonce parametry funkce nejsou constexpr
. Proměnná může být považována za constexpr
pouze pokud je výsledkem constexpr
funkce v době evaluace.
Jednou z cest, jak nechat vypsat obsah proměnné je záměrný přístup mimo rozsah pole.
constexpr bool test_x() { int var = run_test_5(); char c[1]; c[var]=1; return true; } static_assert(test_x());
Výše zmíněný test selže s následující diagnostikou (tučně opět hledaná informace, proměná má hodnotu 42)
<source>:61:21: error: non-constant condition for static assertion <source>:61:21: in 'constexpr' expansion of 'test_x()' <source>:57:10: error: array subscript value '42' is outside the bounds of array 'c' of type 'char [1]'
Používání constexpr
testování vás bude nutit psát třídy jako header-only, protože constexpr
funkce musí být definována před zavoláním. To výrazně redukuje možnosti při snaze oddělit implementaci od interface za použití souborů CPP a H.
Soubory CPP lze sice psát tak, že testy jsou součástí implementace, ale H nemůžeme použít constexpr
když implementace zůstává skrytá v CPP souboru.
Tady si musíme pomoci použitím (fuj) C maker. Já používám makro CONSTEXPR_TESTABLE
//foo.h #ifndef CONSTEXPR_TESTABLE #define CONSTEXPR_TESTABLE #endif class Foo { public: CONSTEXPR_TESTABLE bool bar(); }; ------------------------------------------- //foo.cpp #include "foo.h" CONSTEXPR_TESTABLE bool Foo::bar() { //implementace funkce return true; } ------------------------------------------- //foo_test.cpp #define CONSTEXPR_TESTABLE constexpr #include "foo.cpp" constexpr bool testFoo() { Foo f; return f.bar(); } static_assert(testFoo());
Tímto způsobem zachovám možnost rozdělit kód na interface a implementaci, a zároveň mohu využít všechny výhody constexpr
testování. Je to trochu hack, a už se těším, až se C++ dostane do stavu, kdy začneme používat moduly, které samozřejmě tenhle problém řeší. V modulech totiž „header-only“ implementace ničemu nepřekáží.
Testování kódu pomocí překladače přináší výhody zejména v úplné validaci kódu včetně ověření problémů, které se v runtime testech špatně ověřují, například přístup mimo pole, různé UB stavy, které se nemusí projevit, nebo memory leaky. Psaní constexpr kódu nutí programátora psát kód čistěji, protože překladač je víc striktní a neumožňuje některé nebezpečné operace. Nevýhodou ovšem je, že ne všechno lze do constexpr přepsat a bez použití modulů se obtížně testuje kód rozdělený do dvou souborů.
Proto si myslím, že tento způsob se hodí pro testování dílčích tříd představující základní stavební kameny kódu, jako je například výpočet hashe (SHA1), převod na base64, výpočové funkce, a podobně. Já například mám takto implementované testy na knihovnou WebSockets, zejména parsování a sestavování rámců.
co odporucate tam, kde sa "constexpr" neda pouzit alebo len niekto nie je fanusikom "constexpr"? toto mi pride skor ako taka zaujimavost alebo overkill ako realne pouzitie.
constexpr určitě u šablon, pokud tam není nějaká překážka, třeba šablony předpokládající synchronizaci mezi vlákny se blbě dělají constexpr.
Kritické částí kódu, kde by hrozil nějaký UB, buffer overrun, memory leak, typicky tam kde hodně se používá "unsafe" část C++, nějaká práce s raw bufferama, pokud se tohle podaří napsat a otestovat constexpr, tak to dost zvýší bezpečnost (a u mě důvěru) k tomu kódu.
Ať už je tu diskuze o bezpečnostni programovacích jazyků, (typicky Rust vs C vs C++), tak constexpr testování je cesta, jak tu bezpečnost na úrovni C++ násobně zvýšit.
Již nějakou dobu píšu veškerý svůj kód jako `constexpr`. Jak jsem k tomu došel? Začal jsem (z nějakého důvodu) psát v C89, poté jsem si řekl "jak těžké by bylo zavést do tohoto kódu constexpr?“. No, těžké to nebylo, přidal jsem pár maker a můj kód byl C++14 constexpr kompatibilní. K testování: Běžně používám fuzz testy (clang a MSVC). A všude v kódu mám milion assertů, takže chybu implementace nějakého algoritmu snadno a rychle odhalím. Jenže já jsem chtěl fuzz testovat i v constexpr režimu. K tomu je potřeba generátor náhodných čísel. Ten jsem implementoval konzumací maker typu __FILE__, __LINE__, __DATE__ a podobných. Pošlu je do SHA-3 a pomocí SHAKE128 funkce můžu generovat nekonečné množství náhodných čísel. Popřípadě můžu použít BLAKE3 místo SHAKE128. Dále jsem řešil podobný problém jako autor s rozdělením na hlavičkové a implementační soubory. Vyřešil jsem je tím, že každý header úplně na konci podmíněně (macro) includuje korespondující cpp soubor. Toto ale nedoporučuji, protože překlad trvá velmi, velmi, velmi dlouho a kompiler spotřebuje velmi, velmi, velmi hodně paměti RAM (desítky GB).
Toto mi zavana uz kusok snahou o property based testing. Ten mam rad. Nieje na to nejaka pekna podpora v C++ svete? Je to to, kam smerujete?
Je potřeba u testů generátor náhodných čísel? Nestačil by pseudonáhodný generátor. Problém asi je, že celý <random> není deklarován constexpr, ale napsat si vlastní generátor co bude constexpr asi není problém
Jinak __FILE__ a __LINE__ se už nemusí používat, protože mám std::source_location, což je třída, která má
https://en.cppreference.com/w/cpp/utility/source_location
static consteval source_location current() noexcept
Ta vrací aktuální polohu v kódu. Mám v backlogu článek na tohle téma
Pokud testy vyžadují čas a paměť, pak je samozřejmě lepší je překládat zvlášť.
Intenzivně se zabývám programováním zejména v jazyce C++. Vyvíjím vlastní knihovny, vzory, techniky, používám šablony, to vše proto, aby se mi usnadnil život při návrhu aplikací. Pracoval jsem jako programátor ve společnosti Seznam.cz. Nyní jsem se usadil v jednom startupu, kde vyvíjím serverové komponenty a informační systémy v C++
Přečteno 53 535×
Přečteno 25 360×
Přečteno 23 676×
Přečteno 22 165×
Přečteno 20 728×