19.5 Overload, konverzió és explicit interfész

A C-ben egy függvénynév egyetlen szignatúrához tartozik: ha kétfajta paraméterrel akarunk ugyanarra a logikai műveletre hívni, külön nevet adunk (print_int, print_str), vagy _Generic-et használunk, amely a vezérlő kifejezés típusa alapján kiválaszt, de nem rangsorol (lásd static inline, _Generic és headeres eszköztár (10.3)). A C++ ugyanezt a döntést a fordítóra bízza, de nem szabadon: az overload resolution egy szigorúan definiált, fordítás idejű rangsorolás. Ez egyszerre kényelem és kockázat. Kényelem, mert a hívó oldalon nem kell típusnevet írni; kockázat, mert minden implicit konverzió részt vesz a döntésben, és a „legjobb” jelölt nem feltétlenül az, amit a szerző szándékozott. Ennek az egységnek a célja, hogy a jelöltválasztást mechanikusan le tudd vezetni, felismerd a csapdákat (különösen a 0/NULL és a pointer→bool esetét), és olyan interfészt tervezz, amelyben a konverzió ára látható: explicit, erős típusok, lista-inicializálás. A C++20 alapú tárgyalás a korábbi részekre épül: az integrál promóciók és a signedness már C-ben is átírták a típusokat (Promóciók, signedness és túlcsordulás (4.2)), a típuslevezetés pedig C++-ban tovább tágítja a jelöltek körét (auto, decltype és típuslevezetés (19.4)).

19.5.1 Overload candidate, viability és conversion ranking

Három fogalmat kell élesen szétválasztani. A jelöltlista (candidate set) azokból a deklarációkból áll, amelyeket a névfeloldás a hívás helyén talál; a lista a hívás előtt, a nevek alapján áll össze, tehát egy nem megfelelő paraméterezésű függvény is bekerülhet. Az életképesség (viability) szűrője: az argumentumszám egyeztethető (a default argumentumok beszámítanak), és minden argumentumhoz létezik implicit konverziós sorozat (implicit conversion sequence, ICS). A legjobb életképes jelölt kiválasztásának első és leggyakrabban döntő lépése az argumentumonkénti összehasonlítás: az a jelölt jobb, amelyik minden argumentumban nem rosszabb a másiknál, és legalább egyben szigorúan jobb. Ez azonban egyszerűsítés, nem a teljes definíció: ha a konverziós sorozatok alapján döntetlen van, a szabvány függvényszintű döntő szabályokkal folytatja (lásd lent, [C++20] [over.match.best]). Ha a végén sincs egyetlen legjobb jelölt, a hívás kétértelmű (ambiguous), és ez fordítási hiba, nem futásidejű döntés — ellentétben például a dinamikus dispatch-csel.

Az ICS rangsora kategóriákból áll. A kategória az első szintű szűrő: a jobb kategóriájú sorozat mindig nyer. Azonos kategórián belül viszont további, döntő szabályok következnek (lásd lent):

szint mi tartozik ide példa
Exact Match identity, lvalue→rvalue, tömb→pointer, függvény→pointer, minősítés-konverzió int→int, int*→const int*
Promotion integrál promóció, lebegőpontos promóció char→int, float→double
Conversion integrál konverzió, lebegő↔︎integrál, pointerkonverzió, boolean konverzió unsigned→int, double→int, T*→void*
User-defined pontosan egy felhasználói konverzió (konstruktor vagy konverziós operátor) const char*→std::string
Ellipsis ... —

Két fontos következmény. Egyrészt a promóció olcsóbb a konverziónál, ezért a char argumentum int paramétert választ a long paraméter helyett — ugyanaz a szabály, amely C-ben a printf varargsát is mozgatja. Másrészt az azonos kategória önmagában nem dönt. Ha unsigned int argumentummal hívunk egy int, egy long és egy double paraméterű overloadot, mindhárom Conversion, és ezek közül egyik döntő szabály sem emel ki egyet, tehát a hívás kétértelmű. Ezt a fordító jelzi; a javítás nem az, hogy „valamelyiket mégis válassza”, hanem hogy a hívó oldalon típusossá tesszük az értéket, vagy megszüntetjük a fölösleges overloadot. A kétértelműség itt a konkrét jelöltekből következik, nem abból, hogy azonos rangon belül soha nem lenne preferencia.

// overload_rank.cpp — C++20
#include <cstdio>
#include <string_view>

void pick(int)              { std::puts("pick(int)"); }
void pick(long)             { std::puts("pick(long)"); }
void pick(double)           { std::puts("pick(double)"); }
void pick(std::string_view) { std::puts("pick(string_view)"); }

int main() {
    pick('a');   // char -> int promóció
    pick(0);     // int, exact match
    pick(0L);    // long, exact match
    pick(1.0f);  // float -> double promóció
    pick("x");   // csak a string_view életképes (user-defined)
    // pick(0u); // HIBA: unsigned -> int/long/double mind Conversion
}

Azonos rangon belül a szabvány több döntő szabályt ad ([C++20] [over.ics.rank]). A három leggyakoribb:

// overload_same_rank.cpp — C++20: azonos rang, mégis egyértelmű választás
#include <cstdio>
#include <string>

void q(int*)        { std::puts("q(int*)"); }
void q(const int*)  { std::puts("q(const int*)"); }

void v(void*)       { std::puts("v(void*)"); }
void v(bool)        { std::puts("v(bool)"); }

void r(const std::string&) { std::puts("r(const std::string&)"); }
void r(std::string&&)      { std::puts("r(std::string&&)"); }

int main() {
    int x = 0;
    int* p = &x;
    q(p);                 // mindkettő Exact Match; a minősítés nélküli nyer
    v(p);                 // mindkettő Conversion; a pointer->bool rosszabb
    std::string s = "a";
    r(s);                 // lvalue: csak a const& köthető
    r(std::string{"b"});  // rvalue: mindkettő identitás-kötés; a && nyer
}

A program kimenete a lab-gcc profilban sorrendben q(int*), v(void*), r(const std::string&), r(std::string&&). Ugyanitt az overload_rank.cpp kimenete pick(int), pick(int), pick(long), pick(double), pick(string_view), a kikommentezett pick(0u) sor visszakapcsolva pedig call of overloaded 'pick(unsigned int)' is ambiguous fordítási hibát ad.

Ha az argumentumonkénti összehasonlítás döntetlen, mert a jelöltek konverziós sorozatai páronként egyformák, a szabvány ([C++20] [over.match.best]) további, függvényszintű szabályokat alkalmaz. A legfontosabbak:

// overload_tiebreak.cpp — C++20: azonos konverziós sorozat, függvényszintű döntés
#include <concepts>
#include <cstdio>

void f(int)                    { std::puts("f(int) — nem sablon"); }
template<class T> void f(T)    { std::puts("f(T) — sablon"); }

template<class T> void g(T)    { std::puts("g(T)"); }
template<class T> void g(T*)   { std::puts("g(T*) — specializáltabb"); }

template<class T> void h(T)                  { std::puts("h(T)"); }
template<std::integral T> void h(T)          { std::puts("h(integral T) — megkötöttebb"); }

int main() {
    int x = 0;
    f(x);    // mindkét jelölt Exact Match: a nem sablon nyer
    g(&x);   // mindkettő Exact Match: a specializáltabb sablon nyer
    h(x);    // mindkettő Exact Match: a megkötöttebb (constrained) nyer
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic overload_tiebreak.cpp figyelmeztetés nélkül fordul; a kimenet sorrendben f(int) — nem sablon, g(T*) — specializáltabb, h(integral T) — megkötöttebb.

A sablonok levezetését és részleges rendezését a Függvénysablon, instantiation és deduction (26.1), a megkötések szerinti választást a Concepts, requires és constrained overload (27.2) tárgyalja részletesen.

A pick("x") eset érdekes: a const char* nem konvertálható int-té, mert a standard konverziós sorozat nem tartalmazhat két lépést (pointer→bool→int nem megengedett lánc), így egyetlen életképes jelölt marad, és a user-defined sorozat nyer — versenytárs nélkül a legalacsonyabb szint is elég. A fordítás és a futtatás a tanuló laborjában:

g++ --version
g++ -std=c++20 -Wall -Wextra -Wpedantic -Wconversion -Wold-style-cast \
    overload_rank.cpp -o overload_rank

Buildjegyzőkönyv (lab-gcc profil). Mindkét program a fenti kapcsolókkal figyelmeztetés nélkül fordult, a kimeneteket fentebb közöltük. A -Wconversion hasznos, de nem helyettesíti a brace-inicializálást: az assignment konverziókat figyelmeztetésként jelzi, a brace-init a narrowingot hibaként tiltja.

19.5.2 Default argumentum, nullptr és a 0/NULL csapda

A default argumentum nem része a függvény típusának, csak a hívás helyén, a statikus típus alapján egészíti ki az argumentumlistát. Overload szempontjából viszont részt vesz az életképességben, ezért két látszólag különböző szignatúra összeérhet:

void connect(int fd, int timeout_ms = 0);
void connect(int fd);
// connect(3);  // kétértelmű: mindkettő életképes, mindkettő exact match

Ez a fajta kétértelműség különösen rossz, mert a hiba a fejléc módosításakor keletkezik, a hívó kódban. A helyes minta: a default argumentumot ne kombináld azzal a szignatúrával, amelyből a default elhagyásával ugyanaz a hívás lenne.

A nullpointer körül három különböző dolog él együtt. A nullptr típusa std::nullptr_t, amely bármely pointer típusra konvertálható, egész típusra viszont nem; ezért a pointer paramétert választja, és soha nem esik bele az egész overloadba. A 0 literál típusa int, tehát az int paramétert választja exact match-csel, és ha nincs int overload, akkor a pointer felé konverzió minősül, ami versenyben lehet más konverziókkal. A NULLmakró definíciója implementációfüggő: gyakran 0, 0L vagy GCC alatt __null, ezért ugyanaz a hívás különböző fordítókon mást választhat, vagy kétértelmű lehet.

void send(const char*);   // 1
void send(int);           // 2
send(0);        // 2: int, exact match
send(nullptr);  // 1: nullptr_t -> const char*
// send(NULL);  // hordozhatatlan: a NULL definíciójától függ

A tanulság nem az, hogy „a NULL rossz”, hanem hogy ne terhelj túl egyszerre pointer és egész paraméterre ugyanabban aszerepben. Ha mégis muszáj, a hívó oldalon nullptr-t írj, az egész oldalt pedig tedd erős típussá (enum class, struct Port { explicit Port(int); }). Ugyanide tartozik a pointer→bool csapda, amely a legtöbb C++ knnyvtárban előfordul:

void log(bool enabled);                 // 1
void log(const std::string& message);   // 2
log("indítás");  // 1-et választja! const char* -> bool standard konverzió

A const char*→bool standard konverzió, a const char*→std::string user-defined; a rangsor szerint az előbbi nyer. A std::string_view paraméter sem segít, mert az is user-defined sorozat. A javítás szerkezeti: a bool paramétert ne hagyd pointerrel versenyezni — használj enum class-t, vagy void log(const char*) = delete; deklarációval tedd a hívást szándékosan hibássá.

19.5.3 Explicit interfész: explicit konstruktor és konverzió

Az egyparaméteres, nem explicit konstruktor egyben konverziós operátor is: részt vesz az implicit konverziós sorozatokban, és ezzel a jelöltválasztásban. Az explicit ezt kiveszi a játékból — az explicit konstruktor csak direkt inicializálással hívható, implicit konverzióként nem. Ugyanez igaz az explicit operator bool() alakú konverziós operátorra, azzal a kivétellel, hogy a kontextuális bool-konverzió (if, while, !, &&) továbbra is működik, mert ott a szabvány kifejezetten engedi.

struct Port {
    explicit Port(int number) : number_{number} {}
    int value() const noexcept { return number_; }
private:
    int number_;
};

Port a{8080};        // OK: direkt inicializálás
// Port b = 8080;    // hiba: explicit miatt nincs implicit konverzió

A szabály arra késztet, hogy a döntést írásban hozd meg: az explicit akkor maradjon el, ha a konverzió szemantikailag azonos (pl. std::string const char*-ból), egyébként alapértelmezésben explicit. C++20-ban az explicit(bool) feltételes explicitét is használhatod, ha a típusparamétertől függ, hogy a konverzió veszélyes-e; ennek részletei az inicializálásról szóló részhez tartoznak (Az inicializálás különböző alakjai (20.1)).

19.5.4 A C++ kasztok kategóriái

A C-stílusú kaszt ((T)x) több kasztot próbál egymás után, ezért elrejti a szándékot. A C++ négy nevesített kasztja más-más szerződést fejez ki, más előfeltétellel és más hiba móddal:

kaszt tipikus használat előfeltétel hiba módja
static_cast numerikus konverzió, void*→T*, explicit konstruktor, fel/lefele kaszt lefele kasztnál a dinamikus típus valóban a leszármazott rossz dinamikus típusnál nem definiált viselkedés
const_cast const/volatile levétele az objektum nem valóban const módosítás esetén nem definiált viselkedés
reinterpret_cast pointer↔︎egész, bájtszintű újraértelmezés a reprezentáció értelmezése dokumentált aliasing-szabálysértés, nem hordozható
dynamic_cast polimorf típus ellenőrzött kasztja polimorf alaposztály, RTTI engedélyezve pointerre nullptr, referenciára std::bad_cast
struct Base { virtual ~Base() = default; };
struct Derived : Base { void only_derived(); };

void f(Base* b) {
    if (auto* d = dynamic_cast<Derived*>(b)) d->only_derived(); // ellenőrzött
    // static_cast<Derived*>(b)->only_derived();  // feltételez: ha nem Derived, UB
}

const int c = 7;
int* p = const_cast<int*>(&c);
// *p = 9;  // UB: c valóban const objektum

A reinterpret_cast pointer és std::uintptr_t között használható címként, de típus-punningra nem: két különböző típusú objektum ugyanazon a tárolón való átértelmezése aliasing-szabályba ütközik, helyette std::memcpy kell (Effective type, aliasing és objektumreprezentáció (5.4)). A dynamic_cast futásidejű költsége valós, ezért forró útvonalon nem ez a hibakezelés eszköze; ott std::variant/visit a természetes alternatíva.

19.5.5 Narrowing és lista-inicializálás

A brace-inicializálás ({}) szigorúbb szerződést kínál, mert a narrowing konverziókat fordítási hibává teszi. Narrowing például: lebegőpontosból egészbe; hosszabb lebegőpontosból rövidebbre; olyan egész→egész, amely nem tudja a forrás minden értékét ábrázolni; és egész→lebegőpontos — kivéve, ha a forrás konstans kifejezés, és az érték pontosan ábrázolható a célban.

int a = 3.9;    // a == 3, csendben csonkol (nem UB)
// int b{3.9};  // hiba: narrowing
// char c{300}; // hiba: 300 nem fér el char-ban
double d{1};    // OK: 1 pontosan ábrázolható
Port p{70000};  // ha Port int-et vár: OK; ha std::uint16_t-t: narrowing hiba

A {} tehát nem minden konverziót tilt meg (az int→long például megengedett), csak az értékvesztőket; és nem véd a user-defined konverziókkal szemben sem. Két további következmény: az üres {} érték-inicializál, a {} pedig előnyben részesíti az initializer_list konstruktorokat — ezért a std::vector<int> v(3) és v{3} jelentése eltér; a tárolók tárgyalására a későbbi részben térünk vissza (std::array, std::vector és folytonos tárolás (28.1)).

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

19.5.7 Összefoglaló

Az overload resolution névfeloldás → életképesség → konverziós rangsor lépésekből áll, és a döntés fordítás idejű; az azonos kategóriájú konverziók között nincs preferencia, ezért a kétértelműség valódi tervezési hiba. A default argumentum életképessé tesz olyan szignatúrákat, amelyek ütközhetnek; a nullptr, a 0 és a NULL három különböző dolog, és a pointer→bool konverzió a legtöbb meglepetés forrása. Az explicit és az erős típusok az implicit konverzió részvételét szűkítik, a brace-init a narrowingot tiltja, a nevesített kasztok pedig a kasztolás előfeltételét teszik olvashatóvá. Ebben az egységben nem állt rendelkezésre forrásjegyzet, ezért a fenti szabályok a C++20 szerinti megfogalmazást követik; a konkrét compiler-viselkedést a rögzített verziójú laborban kell ellenőrizni.

19.5.8 Feladatok

  1. Predikció. Add meg, melyik pick overload fut az overload_rank.cpp-ben a pick('a'), pick(0), pick(0L), pick(1.0f), pick(true), pick(0u), pick("x") hívásokra, vagy indokold, miért fordítási hiba.
  2. Interfész-javítás. Adott a void set_flag(bool) és a void set_flag(const std::string&) pár. Írd le, mit választ a set_flag("experimental") hívás, majd tervezz olyan interfészt, amelyben a sztring literál nem konvertálódhat csendben bool-lá.
  3. Diagnosztika. Az alábbi részlet nem fordul le. Azonosítsd a hibákat, add meg a javított változatot, és a fordítási parancsot, amellyel ellenőrizhető, hogy a javítás valóban lefordul.
void open(int fd, int flags = 0);
void open(int fd);
open(3);
int n{2.5};

19.5.9 Megoldások

1. megoldás. pick('a'): char→int promóció, a long/double felé konverzió, tehát pick(int). pick(0): exact match, pick(int). pick(0L): exact match, pick(long). pick(1.0f): float→double promóció, pick(double). pick(true): bool→int promóció, pick(int). pick(0u): unsigned→int, →long, →double mind Conversion, és a string_view felé nincs sorozat, ezért kétértelmű, fordítási hiba; ezért van kommentben. pick("x"): csak a string_view életképes, user-defined sorozattal. Ellenőrzés: a fenti g++ parancs hibátlanul lefut, a pick(0u) sor kommentből való kivétele viszont hibát ad — a hibaüzenetnek tartalmaznia kell az „ambiguous” megfogalmazást.

2. megoldás. A const char*→bool standard konverzió (boolean konverzió), a const char*→std::string user-defined, ezért a hívás a bool overloadot választja, és a szöveg elveszik. A std::string_view bevezetése nem javít, mert az is user-defined sorozat. Helyes megoldás: a logikai kapcsolót tegyük erős típussá, és a pointer felé mutató utat tegyük szándékosan hibássá:

enum class Flag { on, off };
void set_flag(Flag f);
void set_flag(const std::string& s);
void set_flag(const char*) = delete;  // a literál nem eshet át csendben

Ellenőrzési feltétel: set_flag("x") fordítási hibát ad (a törölt overload életképes, ezért a hívás hibás), set_flag(Flag::on) és set_flag(std::string{"x"}) viszont lefordul. A = delete itt nem „eltávolítás”, hanem diagnosztikai szerződés: a hívó szándéka láthatóvá válik.

3. megoldás. Két hiba van. Egyrészt az open(3) kétértelmű: az open(int,int = 0) és az open(int) is életképes, mindkettő exact match-csel az első argumentumban, ezért nincs győztes. Másrészt az int n{2.5}; narrowing miatt hibás. Javítás: hagyjuk el a rövidebb overloadot (a default argumentum már kiszolgálja a hívást), és tegyük a konstanst típusossá:

void open(int fd, int flags = 0);
// open(int) törölve: a default argumentummal együtt ütközne
open(3);          // OK
int n{2};         // egész konstans
double d{2.5};    // a nem egész érték ott marad, ahol van

Ellenőrzés: g++ -std=c++20 -Wall -Wextra -Wpedantic -Wconversion prog.cpp -o prog hibátlanul lefut. Ha a 2.5-öt visszaírod int n{...} alakba, a fordítónak narrowing hibát kell adnia; ha mégsem, akkor a fordítópipeline nem a várt szabvány-módot használja, és a laborprofil felülvizsgálandó.