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)).
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:
int* argumentumnál az int* paraméter
(identitás) jobb a const int* paraméternél (identitás +
minősítés-konverzió), noha mindkettő Exact Match;bool-ra konvertálása rosszabb
minden más konverziónál: int* argumentumnál a
void* és a bool paraméter is
Conversion, mégis a void* nyer;&&) jobb a
const&-nél; lvalue-hoz pedig a &&
nem is köthető, így ott csak a const& életképes.// 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:
<=>/fordított) jelölt jobb az átírtnál.// 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_rankBuildjegyző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.
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 matchEz 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üggA 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á.
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)).
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 objektumA 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.
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 hibaA {} 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)).
NULL-lal hívott pointer/egész
overload. Tünet: fordítónként változó vagy kétértelmű hívás.
Javítás: nullptr, illetve a két overload közül az egyik
megszüntetése.bool paraméter. Tünet:
sztring literál „igaz” ágba fut. Javítás: enum class,
= delete guard, vagy tag-típus.unsigned argumentumnál kétértelműség. Javítás: típusos
hívás, nem új overload.const csendben
lekerül, vagy pointerből egész lesz. Javítás: nevesített kaszt,
-Wold-style-cast.const/ref-minősítés a
tagfüggvényen).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.
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.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á.void open(int fd, int flags = 0);
void open(int fd);
open(3);
int n{2.5};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 csendbenEllenő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 vanEllenő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ó.