33.3 ODR, inline és több fordítási egység

A több fordítási egységből álló C++ program egyik legfontosabb, mégis legkönnyebben megsérthető szerződése a One Definition Rule (ODR). A C-ben a linker szintjén a külső definíciók egyedisége a fő szabály; a C++ ezt kiterjeszti a classokra, template-ekre, inline entitásokra és bizonyos compile-time konstrukciókra. A C++20 [basic.def.odr] szabálya szerint egy entitásnak a programban pontosan egy definíciója lehet, kivéve azokat az eseteket, amelyeket a szabvány kifejezetten megenged. Az ODR megsértése gyakran nem diagnosztizált: a fordító csak egy fordítási egységet lát, a linker pedig jellemzően nem ismeri a forrásszintű szemantikát. Ezt a helyzetet nevezi a szabvány „ill-formed, no diagnostic required” (IFNDR) állapotnak. A fejezet szabványhivatkozásai tájékoztató jellegűek; a konkrét bekezdésszámok és a compiler viselkedése a laborprofilban ellenőrizendő.

DevOps szempontból az ODR nem elméleti kuriózum. A header-only könyvtárak, a template-ek, az inline változók és a különböző TU-kra eltérően definiált makrók mind tipikus build- és üzemeltetési hibákhoz vezetnek. A hiba sokszor nem a fordításkor, hanem ritka futásidejű memóriahibaként vagy ABI-eltérésként jelenik meg. Ezért a build rendszernek gondoskodnia kell arról, hogy a header-definíciók minden TU-ban azonosak legyenek, és a konfiguráció ne változtassa meg észrevétlenül a classok layoutját.

33.3.1 Az ODR és az odr-use alapja

Az ODR két szinten fogalmaz meg követelményt. Egyrészt minden fordítási egységben legfeljebb egy definíciója lehet egy definiálható entitásnak. Másrészt a teljes programban pontosan egy definíció megengedett, kivéve az inline függvényeket, inline változókat, classokat, enumokat, template-eket és néhány további esetet. Ezeknél több TU-ban is szerepelhet definíció, de a definícióknak tokenre azonosnak kell lenniük, és a bennük szereplő neveknek ugyanarra az entitásra kell feloldódniuk. A tokenazonosság a preprocesszor kiterjesztése után értendő, tehát a makrók és a feltételes fordítás is része a szerződésnek.

Az odr-use azt jelöli, hogy egy entitás definíciójára a program szemantikájához ténylegesen szükség van. Egy függvény például akkor odr-used, ha meghívják vagy a címét veszik. Egy változó akkor, ha a címét veszik, referenciát kötnek hozzá, vagy olyan kifejezésben szerepel, amelyhez a definíció kell. Nem minden névhasználat odr-use: egy konstans kifejezésben értékként használt const változó bizonyos feltételek mellett nem igényel definíciót. Az inline függvényt viszont minden TU-ban definiálni kell, ahol odr-used; ha a definíció csak egy .cpp fájlban van, a többi TU számára nem elérhető, és a program viselkedése nem lesz helyes.

A C-hez viszonyítva a különbség lényeges. A C17 6.9 szakasza a külső definíciók egyediségét szabályozza, de a C-nek nincs a C++éhoz hasonló, classokra és template-ekre kiterjedő ODR-je. A C inline függvény C99-es szemantikája külön inline és külső definíciót ismer; a C++ inline egyszerűbben garantálja, hogy a függvény több TU-ban definiálható, és a programban egy példánya lesz. A C-ben nincs inline változó; a headerben használt static változó minden TU-ban külön példány. A C++17 óta az inline változó ezt a problémát oldja meg.

33.3.2 Inline függvény és inline változó: a tokenazonosság szerződése

Az inline kulcsszó nem „optimalizálási kérés” a modern C++-ban, hanem ODR-engedély: a függvény vagy változó több TU-ban is definiálható, feltéve, hogy a definíciók tokenre azonosak, és minden TU-ban elérhetők, ahol odr-used. A classon belül definiált tagfüggvény implicit inline; a constexpr és a consteval függvény szintén implicit inline. A constexpr static adattag C++17 óta implicit inline, de a névtér-scope-ban deklarált constexpr változó nem az: headerben inline constexpr kell ahhoz, hogy egy példánya legyen.

A következő példa ezt mutatja be. A stats.hpp inline függvényt, inline változót és template-et tartalmaz; a stats.cpp egy explicit instantiationt ad; a a.cpp és b.cpp külön TU-kból használja a közös definíciókat.

// stats.hpp
#ifndef STATS_HPP
#define STATS_HPP

inline int call_count = 0;

inline int bump(int x) { return x + 1; }

template<class T>
T twice(T x) { return x + x; }

extern template int twice<int>(int);

#endif
// stats.cpp
#include "stats.hpp"
template int twice<int>(int);
// a.cpp
#include "stats.hpp"
int a_use() {
    ++call_count;
    return bump(1);
}
// b.cpp
#include "stats.hpp"
int b_use() {
    ++call_count;
    return twice(2);
}
// main.cpp
#include <iostream>
#include "stats.hpp"

int a_use();
int b_use();

int main() {
    const int r = a_use() + b_use();
    std::cout << r << ' ' << call_count << '\n';
}

A laborprofilban rögzített GCC vagy Clang esetén a buildparancs a következő lehet; a c++ helyére a rögzített compilert kell írni, és a nyelvi módot mindig explicit meg kell adni:

c++ -std=c++20 -O2 -Wall -Wextra -c stats.cpp -o stats.o
c++ -std=c++20 -O2 -Wall -Wextra -c a.cpp -o a.o
c++ -std=c++20 -O2 -Wall -Wextra -c b.cpp -o b.o
c++ -std=c++20 -O2 -Wall -Wextra -c main.cpp -o main.o
c++ stats.o a.o b.o main.o -o app
./app

A várt kimenet 6 2: a bump(1) értéke 2, a twice(2) értéke 4, a call_count pedig kétszer növekszik. A fejezet nem állítja, hogy a példa lefutott; a tényleges futtatás a labor feladata. Ha a call_count deklarációja static int call_count = 0; lenne, a main a saját példányát látná, a a_use és a b_use pedig a sajátjukat növelné, így a kimenet 6 0 lenne. Ez nem ODR-violation, mert a belső linkage entitás TU-nként különálló, de a szemantika megváltozik. Ha a stats.cpp kimarad a linkelésből, a twice<int> explicit instantiationja hiányzik, és a linkelés hibával leáll. Ha az extern template deklaráció marad el, a b.cpp is instantálja a twice<int>-et; az ODR ezt megengedi, a linker összevonja, de a fordítási idő nő.

33.3.3 Template-definíciók több fordítási egységben

A template definíciójának a headerben kell lennie, mert a fordítónak a használat helyén látnia kell a sablon testét az implicit instantiationhöz. Az ODR megengedi, hogy ugyanaz a specializáció több TU-ban is létrejöjjön, de a definícióknak tokenre azonosnak kell lenniük, és a template argumentumoknak ugyanarra a típusra kell vezetniük. Az explicit instantiation (template int twice<int>(int);) egy TU-ban generálja a kódot; az extern template a headerben megakadályozza, hogy a többi TU implicit módon instantálja ugyanazt a specializációt. Ez csökkenti a kódduplikációt és a fordítási időt, de csak akkor működik, ha valahol tényleg van explicit instantiation.

Az explicit specializáció deklarációjának minden TU-ban elérhetőnek kell lennie, ahol a specializációt használják. Ha ez elmarad, a fordító a primary template-et használhatja, miközben egy másik TU a specializációt látja; ez ODR-violation, és a program viselkedése nem definiált. Az ilyen hibák tipikusan nem fordítási hibaként, hanem eltérő futásidejű viselkedésként jelennek meg. A template-ek és az explicit instantiation részleteit a Specialization, overload és explicit instantiation tárgyalta; itt az ODR-következmények a lényegesek.

33.3.4 Rejtett ODR-violation: makrófüggő class layout

A leggyakoribb rejtett ODR-violation nem a függvénydefiníciókban, hanem a classok layoutjában keletkezik. Ha a class definíciója #ifdef-fel változik, a preprocesszor TU-nként más tokeneket ad, és a classok layoutja eltér. A fordító ezt nem látja, mert csak a saját TU-ját ismeri; a linker pedig jellemzően nem ellenőrzi a class méretét. A következő példa ezt szemlélteti.

// bad_config.hpp
#ifndef BAD_CONFIG_HPP
#define BAD_CONFIG_HPP

struct Config {
#ifdef ENABLE_CACHE
    int cache_size;
#endif
    int timeout;
};

#endif
// a.cpp
#define ENABLE_CACHE
#include "bad_config.hpp"
static_assert(sizeof(Config) == 8, "a.cpp layout");
int a_size() { return sizeof(Config); }
// b.cpp
#include "bad_config.hpp"
static_assert(sizeof(Config) == 4, "b.cpp layout");
int b_size() { return sizeof(Config); }

Egy x86-64 System V ABI, 4 bájtos int és szokásos igazítás melletti illusztráció:

Fordítási egység ENABLE_CACHE sizeof(Config) offsetof(Config, timeout)
a.cpp definiálva 8 4
b.cpp nincs 4 0

A táblázat feltételezi a 4 bájtos int-et; a pontos értéket a target ABI és a fordítási profil rögzíti. A két static_assert külön-külön igaz, mert mindegyik TU a saját definícióját látja. Ha a main átad egy Config-ot a.cpp és b.cpp között, az egyik oldal 8, a másik 4 bájtos objektumot feltételez; ez memóriahibához és nem definiált viselkedéshez vezet. A hiba nem feltétlenül jelenik meg linkeléskor, mert a szimbólumok nevei azonosak.

A megoldás az, hogy a layoutot nem szabad makrótól függővé tenni. A konfigurációt a build rendszerben egy helyen kell rögzíteni minden TU-ra, a class layoutját pedig fixen kell tartani; a funkció külön típusba vagy futásidejű flagbe kerüljön. A típusos compile-time konfiguráció lehetőségeit a Compile-time adatok és típusos konfiguráció tárgyalta. Ha a cache_enabled értéket inline constexpr bool-ként headerbe tesszük, és a makró TU-nként eltér, az if constexpr-t használó inline függvények definíciója is tokenre eltérhet, ami szintén ODR-violation. Ezért a feature flagnek minden TU-ban ugyanazt az értéket kell látnia, vagy a különbséget típusparaméterrel kell kifejezni.

33.3.5 Internal linkage és anonymous namespace

A static függvény, a static változó és az anonymous namespace belső linkage-et ad. Az ilyen entitás csak a saját TU-jában definiálható, ezért nem sérti meg az ODR-t. A headerben mégis problémás lehet: minden TU saját példányt kap, a függvény címe TU-nként eltér, a benne lévő static állapot pedig külön életet él. Ha a függvény címét template argumentumként vagy callbackként használjuk, a különböző címek eltérő viselkedést okozhatnak. A headerben static függvény emellett kóddagadást eredményez, mert minden TU külön másolatot fordít.

A C++ válasza a headerben definiált közös entitásra az inline: az inline függvény és az inline változó egy példányt ad a programban. A C-ben a headeres static inline továbbra is minden TU-ban külön példányt ad; ez a C és a C++ inline szemantikájának egyik fontos különbsége. A namespace és a névfeloldás alapjait a Namespace, header és névfeloldás tárgyalta; itt a linkage és az ODR kapcsolata a lényeg.

33.3.6 LTO és diagnosztika: korlátozott bizonyíték

A link time optimization (LTO) a linkeléskor az összes TU köztes reprezentációját látja, ezért képes lehet olyan ODR-eltéréseket észlelni, amelyeket a külön fordított TU-k nem. A GCC -Wodr kapcsolója LTO-val használható; más fordítóknál a támogatás verziófüggő. A figyelmeztetés azonban nem teljes körű: az ODR-violation a szabvány szerint IFNDR, a fordító nem köteles diagnosztizálni. A -Wodr hiánya nem bizonyítja, hogy a program ODR-konform. A végső bizonyíték a forrás- és build-szintű azonosság: ugyanazok a header-definíciók, ugyanazok a makrók, ugyanazok a fordítási opciók minden TU-ra.

A fordítási idő önálló erőforrás. Az inline és template kódduplikáció növeli a fordítandó kód mennyiségét; az extern template és a nem template-es API csökkentheti. Az LTO nem csökkenti a fordítási időt, sőt jellemzően növeli. A mérés a labor feladata: GCC-nél -ftime-report, Clangnál -ftime-trace, a headerfüggőségekhez -H, a makrók ellenőrzéséhez -E -dM. Ezek a kapcsolók a rögzített compiler verziójától függően elérhetők; a fejezet nem állítja, hogy bármelyiket lefuttatta. A feature-test makrók és a standard library capability vizsgálata a következő alfejezetre tartozik; a C++20 modules és a header unitok szintén ott kerülnek sorra. A modulokat mint stabil cross-compiler bináris formátumot itt nem tárgyaljuk.

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

A leggyakoribb hiba a headerben használt static függvény vagy anonymous namespace, amikor a cél egy közös definíció lenne; ilyenkor inline a helyes válasz. A második az inline függvény definíciójának .cpp fájlba helyezése: az inline függvényt minden TU-ban definiálni kell, ahol odr-used, különben a program nem lesz helyes. A harmadik az extern template használata explicit instantiation nélkül, ami linkelési hibát okoz. A negyedik a #ifdef-fel változó class layout, amely a static_assert és a linker ellenére is rejtett ODR-violation marad. Az ötödik a TU-nként eltérő -D flag, amely ugyanazt a headert különböző definícióvá alakítja. Hatodikként említhető a constexpr változó inline nélküli használata headerben C++17 előtt, illetve a constexpr static adattag és a constexpr függvény implicit inline voltának összekeverése. Végül tipikus hiba, hogy valaki a LTO -Wodr figyelmeztetésének hiányából a program ODR-helyességére következtet.

33.3.8 Összefoglaló

Az ODR a C++ több TU-s programjának alapszerződése: egy definíció, kivéve az explicit módon megengedett inline és template eseteket, ahol a definíciók tokenre azonosak. Az odr-use dönti el, hogy egy entitás definíciójára szükség van-e. Az inline függvény és az inline változó egy példányt ad; a static és az anonymous namespace belső linkage-je nem ODR-violation, de eltérő címeket és állapotot eredményez. A template definíciója a headerben él, az explicit instantiation és az extern template a kódduplikációt szabályozza. A makrófüggő class layout rejtett ODR-violation, amelyet a static_assert önmagában nem fog meg. Az LTO és a -Wodr hasznos kiegészítő, de nem teljes bizonyíték; a build- és forrásszintű konzisztencia az elsődleges.

33.3.9 Feladatok

  1. Magyarázó feladat. Egy headerben static int next_id() szerepel. Magyarázd el, miért nem ODR-violation ez, és miért lehet mégis hibás a program. Milyen eszközzel ellenőriznéd a szimbólumok linkage-jét?

  2. Kódolási feladat. Adott a bad_config.hpp, amelyben a Config struct #ifdef ENABLE_CACHE esetén egy extra int cache_size tagot kap. Alakítsd át úgy, hogy a Config layoutja ne függjön a makrótól, a cache használata viszont továbbra is konfigurálható legyen. Add meg a buildparancsot és az ellenőrzési feltételeket.

  3. Diagnosztikai feladat. Két TU-t LTO-val linkelsz, és nem kapsz -Wodr figyelmeztetést. Bizonyítja-e ez, hogy nincs ODR-violation? Tervezz olyan ellenőrzési listát, amely a fordítási és linkelési folyamat szintjén keresi a rejtett eltéréseket.

33.3.10 Megoldások

33.3.10.1 1. feladat — internal linkage

A static int next_id() belső linkage-es entitás, ezért csak a saját TU-jában definiálható; a több TU-ban való előfordulás nem ugyanannak a külső entitásnak a többszörös definíciója, hanem több különálló belső entitás. Ezért nem ODR-violation. A hiba abból ered, hogy minden TU saját példányt kap: a next_id címe TU-nként eltér, a benne lévő static számláló külön állapot, és ha a függvény címét template argumentumként használjuk, a különböző címek eltérő specializációt vagy viselkedést okozhatnak. A headerben ráadásul minden TU külön másolatot fordít, ami kóddagadáshoz vezet. A helyes megoldás a headerben inline int next_id(). Ellenőrzés: a laborban nm -C vagy objdump -t segítségével összevethető, hogy a static függvény szimbóluma lokális, az inline függvényé pedig gyenge vagy globális; a két TU-ból vett függvénycím összehasonlítása is megmutatja a különbséget. A tényleges futtatás a labor feladata.

33.3.10.2 2. feladat — layout fix, konfiguráció külön

A Config layoutját fixen kell tartani, a cache beállítását pedig külön típusba vagy futásidejű állapotba kell tenni. Egy lehetséges megoldás:

// config.hpp
#ifndef CONFIG_HPP
#define CONFIG_HPP

struct Config {
    int timeout;
};

struct CacheConfig {
    int cache_size;
};

#endif

A Config mérete és a timeout offsetje minden TU-ban azonos; a CacheConfig csak ott kerül használatba, ahol a cache tényleg kell. A #ifdef ENABLE_CACHE legfeljebb a CacheConfig használatára vonatkozhat, a Config definíciójára nem. A buildparancs minden TU-ra ugyanazt a -DENABLE_CACHE értéket adja, ha a cache-t engedélyezni akarjuk; a lényeg, hogy a Config layoutja ettől független. Ellenőrzés: minden TU-ban static_assert(sizeof(Config) == 4) és static_assert(offsetof(Config, timeout) == 0); a -E -dM kimenetében a makrók halmaza azonos; a nm -C nem mutat eltérő méretű, azonos nevű szimbólumokat; a LTO -Wodr csak kiegészítő ellenőrzés. A Config és a CacheConfig együttes használatakor a határon explicit módon kell jelezni, melyik típust adjuk át.

33.3.10.3 3. feladat — LTO és ellenőrzés

Nem, a -Wodr figyelmeztetés hiánya nem bizonyítja az ODR-helyességet. Az ODR-violation a szabvány szerint IFNDR; a fordító nem köteles diagnosztizálni, és az LTO sem lát minden releváns információt. Az ellenőrzési lista:

A végső bizonyíték a forrás- és build-szintű azonosság: ugyanazok a header-definíciók, ugyanazok a makrók és ugyanazok a fordítási opciók minden TU-ban. A mérési és diagnosztikai parancsok futtatása a labor feladata; a fejezet nem állítja, hogy ezeket végrehajtotta.