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.
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.
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
./appA 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ő.
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.
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.
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.
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.
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.
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.
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?
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.
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.
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.
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;
};
#endifA 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.
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:
-D makrók azonossága minden TU-ra,
lehetőség szerint a compile_commands.json alapján;#if/#ifdef feltételek
áttekintése, különösen a classlayoutot érintő helyeken;-E -dM minden TU-ra, a makrók összehasonlítása;static_assert(sizeof(...)) és offsetof a
kritikus típusokra minden TU-ban;nm -C és objdump -t a szimbólumokra, az
azonos nevű, eltérő méretű vagy eltérő linkage-ű szimbólumok
keresése;-Wodr futtatása kiegészítő jelleggel, a
figyelmeztetés hiányát nem tekintve bizonyítéknak.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.