Nechte testovat překladač

11. 1. 2025 17:26 (aktualizováno) Ondřej Novák

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

Když testuje překladač

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:

  • Překladač je striktnější a neprojde vám mnoho prasáren
  • Překladač odhalí mnoho míst, kde nemáte ošetřené UB
  • Překladač odhalí přístup mimo pole a buffer overrun
  • Překladač odhalí memory leaky
  • Constexpr třídy lze instanciovat během překladu – zrychlí to boot vaší aplikace
  • Každý test má izolované běhové prostředí

Abych byl spravedlivý, zmíním ještě nevýhody

  • Ne všechno lze přepsat do 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.
  • Třída může chtít používat jinou knihovnu, která není constexpr. Opět to lze řešit nějakou maketou té druhé knihovny
  • Ne všechny STL operace jsou constexpr, 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
  • V 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)
  • Korutiny v constexpr nejsou a nebudou. (že by? P3367R0)
  • Vlákna v constexpr testovat nejde a nemůžete tedy testovat ani sdílený přístup k resourcům.
  • Totéž se týká atomických proměnných, i když tam to lze obejít například s nahrazením atomické proměnné obyčejnou proměnnou pro účel testu. Není to jednoduché, ale jde to (někdy příště). (a také: P3309)
  • Nejde používat 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ý. 
  • Neprojdou vám prasárny typu aliasing u unionu. Samotné uniony lze používat bez omezení, ale počítejte s tím, že překladače sledují, která proměnná je během vyhodnocování aktivní i když to na tom unionu „není nikde napsané“. Pokud přistoupíte na proměnnou, která aktivní není, dostanete error.

Jak psát testy

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. 

Jak vytáhnout podrobnější diagnostiku?

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'

Další možnosti jak získat diagnostiku

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)

Vypsání celočíselné proměnné během konstantní evaluace

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]'

Testování implementační části

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áží.

Závěr

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ů.

Constexpr everything talk 

Sdílet