Erős típusok használata
Czirkos Zoltán, Pohl László, Dobra Gábor · 2026.09.06.
Egy erősen típusos nyelvben a típusok és a függvénynév túlterhelések segítségével egyszerűbben, célratörőbben, biztonságosabban fogalmazhatjuk meg a mondanivalónkat. Az egyes részfeladatainkat, megkötéseinket is új típusokkal modellezhetjük. Ez az írás néhány példát mutat meg, hogyan lehet ezeket az eszközöket használni.
Gyengén vagy erősen típusos a nyelv?
A típusokat az egyes programozási nyelvek eltérően kezelik. Ezek alapján a nyelveket gyakran nevezzük erősen típusosnak (strongly typed) vagy gyengén típusosnak (weakly typed).
A két fogalmat elég pongyolán szokás használni. Egyes helyeken a C és C++ nyelveket erősen típusosnak szokták nevezni, mert
minden változónak előre meg kell határozni a típusát, összemosva így a fogalmat a statikus és a dinamikus típusossággal. Máskor
gyengén típusosnak nevezik őket, mondván, hogy az egyes beépített típusok között nagyon sok az automatikus, akár adatvesztéses
konverzió: az int→char és a double→int például ilyenek.
Sose gondoljuk azt, hogy a gyengén vagy erősen típusos nyelvek jobbak a másiknál. Hogy melyik előnyösebb, az mindig a felhasználási területen múlik, a konkrét feladaton. Inkább tekintsünk egy nyelv típusosságának „mértékére” úgy, mint egyfajta kompromisszumra. Az erősen típusos nyelvek szigorúak: a változókat típus szerint deklarálni kell, a konverziókat külön kiírni – ez hosszabb, részletesebb programszöveget eredményez. Többet gépelünk, cserébe több ellenőrzést kapunk. A gyengén típusos nyelvek programkódjai általában rövidek, némelyek az érthetőség határáig tömörítettek; a kódot olvasva nagyon kell ismernünk a nyelvet, hogy rájöjjünk, mi történik. Cserébe ezekben sokkal természetesebb például a generikus programozás.
A C++ a típusokat, konverziókat szigorúbban kezeli, mint a C. Ugyanakkor lehetőséget biztosít a függvénynevek és az operátorok átdefiniálásán (overload) keresztül arra, hogy tömören fejezzük ki magunkat a kódban. Közben a statikusan ismert típusok miatt mégis minden lépésünk fordítási időben ellenőrzött lehet.
A típusokat okosan használó programban minden különálló feladatot egy típusra (osztályra) bízunk, Ez nem akadályozza, hanem
kifejezetten segíti és biztonságosabbá teszi a munkát. Legközismertebb példa erre az std::string, még ha nem is
gondoltunk rá eddig ilyen szempontból. Mennyivel egyszerűbb azt használni, mint egy char*-ot!
A sztring ötletét tovább is lehet vinni. Képzeljük el, hogy egy nevet el kell tárolnunk a programban. Hogyan tesszük ezt?
Első gondolatunk egy sima std::string lenne. Azonban kiköthetjük, hogy a név nem lehet üres sztring. Kérdés ezek után,
hogy egy ilyen függvény esetén kinek a dolga ellenőrzni, üres-e a kapott sztring?
std::string get_name();
Bújhatjuk a kommenteket és a dokumentációt...
Ha ezt a sztringet több függvényen keresztül adogatjuk tovább, előbb-utóbb a programunk tele lesz if (name != "")
vizsgálatokkal. Ennél sokkal jobb ötlet külön típussal modellezni a problémát:
class Name {
private:
std::string name;
public:
explicit Name(std::string name) : name(name) {
if (this->name == "")
throw std::invalid_argument("a name cannot be empty");
}
};
Mit válaszolunk ezek után arra a kérdésre, hogy térhet vissza a következő függvény üres sztringgel? Nyilvánvalóan lehetetlen, hogy ilyen történjen!
Name get_name();
A technikát erős típusoknak (strong types) nevezik – ez nem keverendő a nyelv fentebb említett erősen típusos (strongly typed) voltával. Ez az írás a technika használatára mutat példákat. Megint elő fog jönni pár C++11 nyelvi elem. De ne csak ezekre koncentráljunk, hanem a módszerekre, ötletekre is!
The fact that
gotocan do anything is exactly why we don't use it.– Bjarne Stroustrup
Az okos pointeres előadáson láttuk, hogy RAII-val a pointerek használata általában egyértelművé válik, nincs kérdés, hogy az adott pointert ki fogja felszabadítani:
T * foo(); // fel kell szabadítani? nem kell? free? delete? delete[]?
UniquePtr<T> foo(); // nincs kérdés
Van azonban olyan eset, amikor továbbra sem egyértelmű:
void handle_expression(Expression* expr); // Fel kell szabadítani?
Az Expression típus esetén tudjuk, hogy egy absztrakt ősosztály, tehát érték szerint nem adható át.
Akár lehetne referencia szerint, de az se feltétlenül praktikus: az öröklés miatt alapból pointer
szemantika a szokásos, a referencia pedig nem lehet null. Marad tehát a pointer.
Viszont ebből nem látjuk, hogy milyen memóriakezelés tartozik hozzá. Valószínűleg nem kell dinamikusan felszabadítani, különben okos pointer lenne, de a kódból olvasva nem egyértelmű. Ahogy az sem, hogy egyetlen elemre mutató pointert vár, vagy egy tömb első elemére mutató pointert?
Erre gyakori megoldás, ha okos pointert kap a függvény referencia szerint:
void handle_expression(std::shared_ptr<Expression> const& expr); // és ha nem shared?
Ezzel a memóriakezelés ugyan egyértelművé vált, de túlzottan egyértelművé: mi van akkor,
ha a hívó nem shared_ptr, hanem unique_ptr vagy más szemantika szerint szeretné
tárolni az objektumot? A kód így sokkal kevésbé használható általánosan.
Nem csak függvényparaméternél, hanem adattagnál (és az ahhoz tartozó konstruktorparaméternél) is problémás: ott ugyanis a referencia (szinte) fel sem merül, nem szeretjük a referencia adattagokat, helyette mindig pointert használunk (akkor is, ha szemantikailag sose lehet null).
class Bar {
/* ... */
private:
Expression* expr_; // milyen memóriakezelésű?
};
A sima pointer általában egy code smell. Erre találták ki azt az "okos pointert", ami igazából buta pointer: az ObserverPtr.
Arra van, hogy a kódot olvasva egyértelmű legyen, hogy tulajdonlás nélküli, csak "megtekintő" szemantikájú pointerről van szó:
void handle_expression(ObserverPtr<Expression> expr);
class Bar {
/* ... */
private:
ObserverPtr<Expression> expr_;
};
Az implementációja igazából ujjgyakorlat, pont ugyanolyan, mint a többi okos pointeré, csak memóriakezelés nélkül.
Annyiban is különbözik a sima T*-tól, hogy nem enged pointeraritmetikát vagy indexelést, tehát mindenképpen 1 db objektumot reprezentál. (Lett hasonló osztály tömbhöz C++20-tól, std::span-nek hívják.)
template <typename T>
class ObserverPtr {
public:
explicit ObserverPtr(T *ptr = nullptr): ptr_(ptr) {}
T & operator*() const { return *ptr_; }
T * operator->() const { return ptr_; }
T * get() const { return ptr_; }
private:
T *ptr_;
};
Ez a típus sajnos nem került be a szabványba.
Miért nincs C++-ban string split?
A C++ mindennapi használhatóságára nézve jogosan merült fel a kritika, hogy még string split sincs benne. (C++20-tól egyébként lett, de erről majd a tároló osztályokról szóló előadásban lesz szó.) Persze, mindenki megírta magának, nem is bonyolult:
std::vector<std::string> split(std::string s, const std::string& delimiter) {
std::vector<std::string> tokens;
size_t pos = 0;
std::string token;
while ((pos = s.find(delimiter)) != std::string::npos) {
token = s.substr(0, pos);
tokens.push_back(token);
s.erase(0, pos + delimiter.length());
}
tokens.push_back(s);
return tokens;
}
Ennek egyébként volt oka: az std::string-től ha rész-sztringet (substring-et) kérünk, akkor
std::string objektumot kapunk: egy dinamikusan foglalt másolatot. A split pedig egy nagy sztringből
sok-sok kicsit hoz létre, és a sok dinamikus memóriafoglalás egy nagyságrenddel drágább, mint maga a sztringkezelő kód.
Vegyük észre, hogy valójában az std::string memóriakezelésére itt nincsen szükségünk,
hiszen a hívó a tulajdonosa a sztringnek, és a végeredményt is ő kapja.
Ezért nincsen szükség másolatra se. Egy olyan objektum kell csak,
amelyik hivatkozik egy karaktertömre a memóriában, de nem ő maga kezeli annak élettartamát.
Ebből az derült ki, hogy hiányzott egy osztály az STL-ből: a dinamikus memória nélküli sztring.
Az std::string a sztringkezelést (összehasonlítás, keresés, darabolás, iterálás, stb.)
és a RAII-t együtt valósítja meg, a Single Responsibility Principle ellenében.
Sokáig emiatt az ilyen esetekben char const*-ot használtunk, ami több szempontból is problémás,
például mindenképp nullával lezárt karaktereket jelent, egyéb esetben a méretet tárolnunk kell hozzá.
Viszont szemantikailag egy ehhez hasonló szerepű osztályra van szükségünk.
A probléma megoldása az std::string_view lett, amelyik a C++17 óta létezik. A legegyszerűbb használatához a következőket kell tudni:
- Automatikusan, konverzióval létrehozható sztringből – a nyers pointerrel ellentétben.
- Nem végez memóriakezelést – ahogy a nyers pointernél is ez teljesül.
- Eltárol egy hivatkozást egy sztringhez, de nem másolja le azt. Tudja, hogy hol van egy karaktertömb eleje és vége
a memóriában, de nem ő foglalja, és nem is ő szabadítja fel azt. Úgy képzelhetjük el, mint egy szöveg referenciáját
– erre utal a
viewszó a nevében. - Emiatt ingyen lehet érték szerint kezelni, nem kell referencia szerint.
- Nem feltétlenül nullával lezárt sztringeket reprezentál: ezért tud a substring függvénye visszaadni egy másik
std::string_view-t. - Van
std::string_view(char const*)konstruktora, tehát képes "becsomagolni" egy nullával lezárt karaktertömböt. Ilyenkor megkeresi a lezáró nullát, és továbbra is a sztring elejét és méretét tárolja! - Létrehozható belőle
std::string, de csak explicit konstruktorral, ha tényleges másolatra van szükségünk. - Egyébiránt sztringként viselkedik, pl.
==operátorral egyenlőségre vizsgálható,[]operátorral indexelhető stb.
#include <string_view>
std::vector<std::string_view> split(std::string_view str, std::string_view delimiter) {
std::string_view remainder = str;
std::vector<std::string_view> tokens;
size_t pos = 0;
while ((pos = remainder.find(delimiter)) != std::string::npos) {
tokens.push_back(remainder.substr(0, pos));
remainder = remainder.substr(pos + delimiter.length());
}
tokens.push_back(remainder);
return tokens;
}
A használatakor arra kell ügyelni, hogy a string_view objektum ne élje túl a karaktertömböt,
azaz a hivatkozott sztring objektumot. Leginkább emiatt függvényparaméternek alkalmas.
Ezzel az egyszerű osztállyal elkerültük rengeteg std::string foglalását, amire amúgy sem volt szükség, hiszen a bemeneti sztringben már el voltak tárolva a számunkra érdekes karakterek.
A kódunk még mindig foglal dinamikus memóriát a vektornak, amire nincs feltétlenül szükség.
Erre a C++20-tól bevezetett ranges adja a megoldást, amiről a tároló osztályos előadásban lesz szó.
A C és a C++ nyelvben a const jelzővel ellátott értékek nem igazi fordítási idejű konstansok. Ellentmondásnak
tűnik, de a konstans változók is csak változók, amelyekre ugyanolyan szabályok vonatkoznak, mint a többi változóra. Például
C++-ban nem lehet konstans változóval megadni egy tömb méretét, de nem lehet sablonparaméter sem:
const int i = 12;
int arr[i]; // HIBÁS (kivéve 1-2 kontextust)
const unsigned j = 13;
SomeType<j> x; // HIBÁS
A const nem azt jelenti, hogy egy érték megváltoztathatatlan, hanem csak egy ígéretet a többi programrész számára: azt az értéket azon a változónéven keresztül nem fogjuk megváltoztatni.
Érezzük azonban, hogy van sok olyan helyzet, amikor valami tényleg konstans, megváltoztathatatlan; sőt akár fordítási időben
is meg lehetne határozni az értékét. A fordítási idejű konstansokat a C++11-ben a constexpr kulcsszóval lehet
megjelölni. A constexpr azt jelenti a fordító számára: fordítási időben kiértékelendő. Az így megjelölt változók
már bárhol használhatók, ahol eddig konstansokból álló kifejezéseket várt a fordító, innen jön a nevük is:
constexpr int i = 12;
int arr[i]; // OK
constexpr unsigned j = 13;
SomeType<j> x; // OK
constexpr lehet lokális és globális változó is. constexpr lehet osztály statikus változója is, és
ez esetben már megengedett, hogy az osztály belsejében adjuk meg annak értékét, nem csak akkor, ha egész típusú:
class X {
static constexpr double x = 0.19;
};
A constexpr jelzőt lehet függvényekre is használni, ilyenkor azt jelenti, hogy akár fordítási időben is kiértékelhető.
Csak akkor történik fordítási időben a kiértékelés, ha constexpr kontextusból hívjuk a függvényt
(vagy a fordító magától úgy dönt).
constexpr int duplaz(int a) {
return a * 2;
}
int main() {
SomeType<duplaz(3)> x;
int y = rand();
std::cout << duplaz(y) << std::endl;
}
A pontos szabályok, hogy mit szabad constexpr függvényben, és mit nem, szabványonként folyamatosan változtak:
- C++11-ben csak olyan függvény lehetett, aminek a törzse egyetlen
returnutasításból áll - C++14 óta lehet constexpr függvényben:
- alapvető vezérlési szerkezeteket (if, switch, for) használni, ciklusokat írni,
- lokális változókat deklarálni, és a lokális változók állapotát megváltoztatni (!),
- más
constexprfüggvényeket, tagfüggvényeket hívni,constexprkonstruktorú objektumot deklarálni, - lokális objektumon nem-const
constexprtagfüggvényt meghívni, throwutasítást használni, viszont haconstexprkontextusból hívva ráfut a fordító, az fordítási hiba,- szóval tulajdonképpen bármit, ami fordítási időben ésszerűen elvégezhető, tipikusan NEM ilyen a kivételkezelés és a dinamikus memóriafoglalás.
- C++17 óta lambda függvényeket is szabad definiálni (lásd később).
- C++20 óta foglalhat dinamikus memóriát is (ha a scope-on belül fel is szabadul),
így akár
std::stringésstd::vectoris használható. - Ezzel párhuzamosan az STL osztályok és függvények közül is egyre több kapott
constexprminősítőt, sok esetben azonban később, mint a nyelv maga engedné. Például azstd::string_viewlétrehozása és legtöbb művelete isconstexpr.
Így például ez is leírható:
constexpr int fib(int n) {
if (n < 0)
throw std::invalid_argument("fib: negativ n");
if (n <= 1)
return n;
int prev1 = 1;
int prev2 = 0;
int curr = 0;
for (int i = 2; i <= n; i++) {
curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return curr;
}
constexpr int x = fib(6);
Sőt akár osztályt is tehetünk köré:
class Fibonacci {
int a = 0;
int b = 1;
public:
constexpr Fibonacci() = default;
constexpr Fibonacci(int a, int b) : a(a), b(b) {}
constexpr int operator()(int n) const {
if (n < 0)
throw std::invalid_argument("fib: negativ n");
if (n <= 1)
return n;
int prev1 = b;
int prev2 = a;
int curr = 0;
for (int i = 2; i <= n; i++) {
curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
return curr;
}
};
constexpr Fibonacci fib;
constexpr int x = fib(6);
Érdekesség: a C++20 bevezetett két új kulcsszót: a consteval azokra a függvényekre, amik csak fordítási időben hívhatók,
és constinit azokra a globális változókra, amik fordítási időben inicializálódnak, de később az értékük megváltoztatható.
Sokszor ha fizikai mennyiségeket szeretnénk a programban kifejezni, egyszerűen valós számokat használunk. Ez azonban elég
könnyen hibákhoz vezethet. Ha azt írjuk, double ido = 1.2 és hogy double hossz = 1.7, a
hossz/ido kifejezés értelmes, és egy sebességet ad. A hossz+ido kifejezés viszont értelmetlen. De csak nekünk az,
sajnos a fordítónak nem, mert az csak annyit lát ebből, hogy össze szeretnénk adni két valós számot. A mértékegységeket már
eldobtuk.
Gyakori probléma az is a mérnökségben, hogy eltérő mértékegységrendszereket használunk. Hosszakat ki lehet fejezni méterben,
hüvelykben vagy mérföldben is. Az átváltásokra külön kell figyelni, és tudjuk, hogy nem egyszer adódott már ebből probléma (lásd a Gimli Glider és a Mars Climate Orbiter történetét). Ráadásul ez nem csak mértékegységekkel
rendelkező, hanem mértékegység nélküli számoknál is gond. A szögeket mértékegység nélküli számmal mérjük, és mégis eltérő
„mértékegységekkel” is kifejezzük őket néha: fokban és radiánban. Mindenki beleütközött már abba a problémába, hogy a
sin() függvény (vagy épp a zsebszámológépe) radiánt vár fok helyett.
Az alábbi programban, kihasználva az erősen típusos C++ adta lehetőségeket, egy sablon osztály segítségével a mérőszámokhoz (magnitude) mértékegységeket (multitude, unit) társítunk. A mennyiség (quantity) objektumok egy egyszerű valós szám formájában tartalmazzák a mérőszámokat, a mértékegységet pedig a típusuk mutatja meg, méghozzá SI (MKS: méter, kilogramm, másodperc) mértékegységrendszerben. Mivel így minden mennyiség eltérő típusként jelenik meg a programban, függvényeket adhatunk meg hozzájuk, amelyeket a fordító automatikusan tud kezelni. Így nemcsak hogy nem lehet majd eltérő típusú mértékeket összeadni, de szorzások és osztások esetén a fordító ki fogja találni a keletkező mennyiség mértékegységét!
A mértékegységrendszerek közötti átváltásokhoz pedig literális operátorokat használunk, amely a C++11 nyelvi újdonságai közül az egyik. A végeredmény az, hogy ilyesmiket írhatunk a programban:
Length s = 56.8_km;
Time t = 1.2_h;
Speed v = s/t;
Mindeközben a fizikát a fordító intézi a háttérben. Mindennek futási idejű költsége nulla, mivel a sablonok körüli összes
munkát a fordító fordítási időben elvégzi. A lefordított program ugyanúgy néz ki, mintha nyers double számokkal
írtuk volna meg, de az összes átváltás és ellenőrzés automatikus!
A mennyiség osztály
Itt is arra a következtetésre juthatunk, hogy egy sablon osztályt kell létrehoznunk a mennyiségek számára. Minden mennyiség hasonlóan működik (pl. az összeadás, kivonás műveletek mindegyiknél egyformák), azonban a célunk az, hogy a különféle mértékegységekkel (típusokkal) rendelkező osztályokon ne lehessen elvégezni ezeket a műveleteket.
A különféle mértékegységek az egyes alapmértékegységekből építhetők fel. Ezek hatványozhatóak: m, m2, m3 a hossz, terület, térfogat mértékegységei; de negatív hatványok is léteznek: m-1 jelentése: méterenként, s-1 jelentése: másodpercenként. Különféle alapmértékegységek is keverhetők, pl. m1s-1: méter másodpercenként (sebesség), vagy kg1m-3: kilogramm köbméterenként (sűrűség). Mint látjuk, ezek mind leírhatóak három egész számmal, a m, kg és s alapmértékegységek hatványkitevőivel. Ezért ez a három szám lesz a sablonparaméter:
template <int M, int KG, int S>
class Quantity {
public:
double magnitude;
explicit Quantity(double magnitude): magnitude{magnitude} {}
};
A három alap-mértékegység (M, KG, S) nyilván összetartozik, külön ösztállyal érdmes összefogni:
struct Unit {
int M;
int KG;
int S;
};
Régebbi C++ szabványok nem engedték meg, hogy "non-type" template paraméterként használhassunk egy ilyen típust,
és traits class-t kellett használni erre. C++20 azonban engedi, hogy bizonyos feltételek mellett
egy objektum, pl. a Unit lehessen template paraméter:
template <Unit U>
class Quantity {
public:
double magnitude;
explicit Quantity(double magnitude): magnitude{magnitude} {}
};
Az osztály semmi egyebet nem tartalmaz, mint egy mérőszámot. Az egyszerűség kedvéért maradjon ez most publikus adattag.
Minden egyéb információ a típusába van kódolva. Az
egyparaméteres konstruktor megkapta az explicit jelzőt, hogy a fordító ne használja konverzióra: így nem fog
automatikusan semmilyen valós számhoz mértékegységet társítani, hanem nekünk kell minden esetben megadnunk azt.
Az egyszerű használathoz megadhatunk rövid neveket is az egyes típusoknak, pl.:
using Length = Quantity<Unit{1, 0, 0}>; /* hossz, m */
using Area = Quantity<Unit{2, 0, 0}>; /* terület, m^2 */
using Mass = Quantity<Unit{0, 1, 0}>; /* tömeg, kg */
using Time = Quantity<Unit{0, 0, 1}>; /* idő, s */
using Speed = Quantity<Unit{1, 0, -1}>; /* sebesség, m/s */
using Acceleration = Quantity<Unit{1, 0, -2}>; /* gyorsulás, m/s^2 */
using Force = Quantity<Unit{1, 1, -2}>; /* erő, N=m*kg/s^2 */
using Energy = Quantity<Unit{2, 1, -2}>; /* energia, J=m^2*kg/s^2 */
using Power = Quantity<Unit{2, 1, -3}>; /* teljesítmény, Watt = Joule/s = m^2*kg/s^3 */
Length l1{1.2}; /* 1.2 m */
Time t1{3.7}; /* 3.7 s */
Két mennyiség akkor adható össze, ha megegyezik a mértékegységük. Ez így van bármilyen mértékegységnél. Ezt egy sablon összeadó operátor függvénnyel fejezhetjük ki. Ez azt fejezi ki, hogy két egyforma dimenzió mennyiség (paraméterek) összege egy ugyanolyan dimenziójú mennyiség (visszatérési érték), amely úgy áll elő, hogy a két mérőszámot összeadjuk. A kivonás ugyanígy működik:
template <Unit U>
Quantity<U> operator+(Quantity<U> a, Quantity<U> b) {
return Quantity<U>{a.magnitude + b.magnitude};
}
template <Unit U>
Quantity<U> operator-(Quantity<U> a, Quantity<U> b) {
return Quantity<U>{a.magnitude - b.magnitude};
}
A szorzás és az osztás trükkösebb. Szorozni és osztani nem csak egyforma dimenziójú mennyiségeket, hanem bármely két mennyiséget lehet, csak az eredmény dimenziója más lesz. A kapott mennyiség dimenzióját azonban a két tényezőből származtatni tudjuk: szorzás esetén a dimenziók is összeszorzódnak, osztás esetén elosztódnak. A mértékegységeket az alapmértékegységek hatványkitevőivel reprezentáljuk, ez a szorzásnál a kitevők összegzéseként fog megjelenni, pl. hossz×hossz=terület, m1×m1=m1+1=m2, osztásnál pedig kivonásként:
constexpr Unit operator*(Unit a, Unit b) {
return Unit{a.M + b.M, a.KG + b.KG, a.S + b.S};
}
constexpr Unit operator/(Unit a, Unit b) {
return Unit{a.M - b.M, a.KG - b.KG, a.S - b.S};
}
template <Unit U1, Unit U2>
Quantity<U1*U2> operator*(Quantity<U1> a, Quantity<U2> b) {
return Quantity<U1*U2>{a.magnitude * b.magnitude};
}
template <Unit U1, Unit U2>
Quantity<U1/U2> operator/(Quantity<U1> a, Quantity<U2> b) {
return Quantity<U1/U2>{a.magnitude / b.magnitude};
}
Egy mennyiség kiírása általában (valamennyire szépítve):
template <Unit U>
std::ostream & operator<<(std::ostream & os, Quantity<U> m) {
os << m.magnitude << ' ';
bool elso = true;
if (U.M != 0) {
elso = false;
os << "m^" << U.M;
}
if (U.KG != 0) {
if (!elso) os << '*';
elso = false;
os << "kg^" << U.KG;
}
if (U.S != 0) {
if (!elso) os << '*';
elso = false;
os << "s^" << U.S;
}
return os;
}
Egyes mértékegységeknek saját nevük van, ezért a kiírás specializálható. Például az erő (force) mértékegysége a Newton (N):
template <>
std::ostream & operator<<(std::ostream & os, Force f) {
os << f.magnitude << " N"; /* Newton */
return os;
}
Ezekkel már megoldhatunk egy feladatot: „Egy 1450 kg-os Ferrari 0-ról 100 km/h-ra 4 s alatt gyorsul. Mekkora ez a sebesség m/s-ban kifejezve, és átlagosan mekkora erő gyorsítja az autót?”
int main() {
Length l{100 * 1000}; /* 100 km */
Time hour{3600}; /* óra = 3600 s */
Time t{4}; /* gyorsulás: 4 s alatt */
Mass m{1450}; /* az autó tömege: 1450 kg */
Speed v = l/hour;
std::cout << "100 km/h = " << v << std::endl;
Acceleration a = v/t;
Force f = m*a; /* Newton törvénye */
std::cout << "F = " << f << std::endl;
}
Ebben a kis példaprogramban a legfontosabb az, ami nem látszik, amit nem csinál. Az, hogy nem lehet elrontani. Ha
bármelyik képletet elrontanánk, pl. F=m*a helyett F=m/a-t írnánk, a program nem lenne lefordítható.
Fordítási időben kiderülne a hiba!
Az eddigi program: quantity1.cpp.
A Quantity összehasonlító operátoraiból mind a hatot meg kellene valósítanunk: egyenlő-nem egyenlő, kisebb-nagyobb-vagy egyenlő. Ezt gyakorlatilag minden ilyen osztálynál sormintával lehetett csak megvalósítani. Egészen C++20-ig, amikor bevezették a spaceship
(űrhajó) operátort:
int main() {
double a = -0.0;
double b = 0.0;
auto eredmeny = a <=> b; // a háromutas (three-way) összehasonlítás
if (eredmeny < 0)
std::cout << "a<b" << std::endl;
else if (eredmeny > 0)
std::cout << "a>b" << std::endl;
else if (eredmeny == 0)
std::cout << "a és b ekvivalens" << std::endl;
else
std::cout << "a és b sorrendje nem megállapítható" << std::endl;
}
Ez lehetővé teszi, hogy egy művelettel megállapítsuk a sorrendet két objektum közül, ne kelljen
például többször végigiterálni két sztringen hozzá. Az eredmény egyébként pont az strcpy által használt
konvenciót követi, nullánál kisebb, nagyobb vagy egyenlő.
Ha ezt, és az egyenlőség operátort a saját osztályunkra megvalósítjuk, a fordító automatikusan legenerálja mind a hat összehasonlító operátort:
template <Unit U>
class Quantity {
public:
double magnitude;
explicit Quantity(double magnitude): magnitude{magnitude} {}
bool operator==(Quantity const& other) const {
return magnitude == other.magnitude;
}
std::partial_ordering operator<=>(Quantity const& other) const {
return magnitude <=> other.magnitude;
}
};
Külön figyelmet igényel a visszatérési érték típusa. Többféle típus megadható, mindegyiknek más a jelentése.
A strong_ordering azt jelenti, hogy egyértelműen megállapítható a sorrend két objektum között, a három
lehetséges sorrendből pontosan az egyik igaz lesz, és két egyenlő objektum teljesen egyenértékű (equal):
strong_ordering::less: a < bstrong_ordering::greater: a > bstrong_ordering::equal: a == b
Ennél eggyel gyengébb viszonyt ír le a weak_ordering, ami nem követeli meg, hogy két "egyenlő" objektum
teljes mértékben egyforma legyen. Ilyen lehet például egy Ember osztály, ha két embert a neve alapján hasonlítunk össze.
weak_ordering::less: a < bweak_ordering::greater: a > bweak_ordering::equivalent: a és b ekvivalens
Ez sem mindig teljesíthető, példál valós típusoknál az értékek sorrendje nem mindig egyértelmű.
A -0.0 és a 0.0 nem egyenlő egymással, ha a tárolt biteket hasonlítjuk össze, mégis ugyanazt az értéket
jelentik, ezért ekvivalensek. Ha egy művelet eredménye NaN (Not a Number, pl. 0-val osztás),
akkor az semmivel sem egyenlő, még egy másik NaN-nal sem. Ha az összehasonlított
értékek között van NaN, akkor az eredmény unordered.
partial_ordering::less: a < bpartial_ordering::greater: a > bpartial_ordering::equivalent: a és b ekvivalenspartial_ordering::unordered: a sorrend nem megállapítható
A Quantity osztálynál akár implementálhatnánk a strong_ordering-et is, erre a laborfeladat mutat példát.
Olyan osztályoknál, ahol a sorrend nem megállapítható, és csak egyenlőségnek van értelme, elég az egyenlőség operátort megvalósítani, így a nem egyenlő operátort és adott esetben a fordított irányú összehasonlítást is adja a fordító:
class MyType {
/* ... */
bool operator==(int) const;
};
int main() {
MyType a { ... };
if (a == 6) std::cout << "a == 6" << std::endl;
if (6 == a) std::cout << "6 == a" << std::endl;
if (a != 6) std::cout << "a != 6" << std::endl;
if (6 != a) std::cout << "6 != a" << std::endl;
}
Default összehasonlító operátorok
Ahogy az első C++ szabvány óta az értékadó operátor (operator=) magától létrejön, és a tagváltozókat másolja át,
ha nem írjuk meg, akár lehetne == (és a <=>) operátor is. C++20-tól kezdve kérhetjük a fordítót, hogy
implementáljon összehasonlító operátorokat adattagonként:
struct Unit {
int M;
int KG;
int S;
constexpr bool operator==(Unit const& other) const = default;
};
template <Unit U>
class Quantity {
public:
double magnitude;
constexpr explicit Quantity(double magnitude): magnitude{magnitude} {}
constexpr bool operator==(Quantity const& other) const = default;
constexpr std::partial_ordering operator<=>(Quantity const& other) const = default;
};
Kis kiegészítés a template <Unit U>-hoz: a fordítónak tudnia kell kezelni a template specializációkat,
ezért csak olyan osztályt enged non-type template paraméterként használni, amiről maga is el tudja dönteni,
hogy egyformák-e, azaz "elég egyértelmű" lenne a default összehasonlítás.
Az eddig megírt class Quantity működik, típusbiztos, már csak egy gond van: nem szép a kód. Sőt elég nehéz olvasni; a megszokott l=100
km helyett l{100*1000}-at kell írni. Ha órában adunk meg valamit, 3600-zal kell szorozni, ha lóerőben, akkor
745,7-del. Bár ezeknek az értékeknek adhatunk nevet, a szorzásra nekünk kell emlékezni.
Már a C is támogatott néhány előtagot és utótagot (prefix, suffix) a beépített típusú értékek megadásánál. Az 1.0
érték double típusú, az 1.0f pedig float. A 255 érték tízes számrendszerben
adott, a 0377 nyolcasban, a 0xFF pedig tizenhatosban. A C++11 lehetővé teszi azt, hogy saját
utótagokat adjunk meg a programba épített literálisokhoz (user-defined literal). Ezeket globális operátor függvényként kell
megvalósítani (literal suffix operator).
Ezeket az operátorfüggvényeket az „üres sztring operátorral” kell jelölni: operator "", utána pedig le kell írni
az utótagot. Paraméterként a literálist kapják, visszatérési értékük pedig az előállítandó objektum kell legyen. Például ha azt
akarjuk, hogy a méter mértékegységet lehessen használni a programban:
constexpr Length operator "" _m (long double magnitude) {
return Length(magnitude);
}
Length l = 1.2_m; /* 1.2 méter */
A Length objektum inicializálásánál a konstruktort kerek zárójellel () hívja a
függvény a kapcsos zárójel helyett {}. Ezt azért kell így írni, mert azon a ponton számítási pontosság veszik el
(narrowing conversion), a long double→double konverzió miatt. A kapcsos zárójeles inicializálás
nem engedi ezt meg. A long double-re az utótag operátorokra vonatkozó szabályok miatt van szükség, lásd lentebb.
Ezeknél gyakran előkerül a constexpr kulcsszó is, bár ez nem törvényszerű. A constexpr jelző olyan függvényt vagy változót jelöl meg, amelynek értéke már fordítási időben meghatározható.
Így nem futási időben fog megtörténni az átalakítás (hiszen 1.2 méter mindig 1.2 méter lesz, akárhányszor kiértékeljük), hanem
már fordításkor. Ehhez a Quantity konstruktora is constexpr kell legyen.
Ha szeretnénk kilométerben megadni egy hosszúságot, esetleg órában az időt:
constexpr Length operator "" _km (long double magnitude) {
return Length(magnitude * 1000.0);
}
constexpr Time operator "" _h (long double magnitude) {
return Time(magnitude * 3600);
}
Ezekkel és hasonló függvényekkel együtt a Ferrari gyorsulását kiszámító program így írható:
Speed v = 100.0_km / 1.0_h; /* 100 km/h */
std::cout << "100 km/h = " << v << std::endl;
Acceleration a = v / 4.0_s; /* az adott sebességre 4 s alatt */
Force f = 1450.0_kg * a; /* Newton */
std::cout << "F = " << f << std::endl;
A felhasználó által definiált literálisok
A literális utótag operátorok feladata, hogy valamilyen objektumot hozzanak létre a programkódban megadott literális alapján.
Ezeket az operator "" (üres sztring) globális függvénnyel adjuk meg, az alábbi formában:
OutputType operator "" _suffix(ParamType p);
OutputType x = 123_suffix;
Előtag operátort nem lehet definiálni, kizárólag utótagot. Ezek neve az alulvonás _ (underscore) karakterrel kell
kezdődjön; minden más szabványosításra van fenntartva. A C++14-be néhány be is került.
Az operátor a paraméterében kapja meg azt az értéket, ami után írtuk. Számok esetén az értéket két formában kérhetjük, nyersen
(raw) és előkészítve (cooked). A nyers forma sztringet, azaz nullával lezárt karaktertömböt jelent: a fenti példában
"123" lenne char const *-ként. Az előkészített forma pedig unsigned long long vagy
long double típust. Ez mindig a létező legnagyobb ábrázolási tartományú, a 123 így simán 123 lenne
unsigned long long-ként.
Sztringek esetén az értéket mindig nullával lezárt karaktertömbként kapjuk, az utótag operátornak azonban ilyenkor nem egy,
hanem két paramétere van. Az első egy char const * (vagy más karakter típus, pl. wchar_t), ez tartalmazza
a sztringet. A másodikban pedig a karakterek számát kapjuk egy size_t típus értékben – így akár null karakterek is
lehetnek a sztringben.
| Kódrészlet | operator"" _xyz(... milyen paraméterezéssel ...)
|
|---|---|
123_xyz
|
(unsigned long long) vagy (char const *)
|
123.0_xyz
|
(long double) vagy (char const *)
|
"hello"_xyz
|
(char const *, size_t)
|
Első példa. Az alábbi függvény egy _deg utótagot ad meg, amely egy fokban megadott szöget radiánba vált át. Ez
előkészített (cooked) formában veszi át a valós számot, hogy ne kelljen bíbelődni a számjegyek értelmezésével:
long double operator "" _deg(long double degree) {
return degree * 3.141592653589793238462643383276/180; /* pi/180 */
}
std::cout << 90.0_deg << ' ' << sin(180.0_deg);
Második példa. std::string-gé alakító _str utótag; ilyen van C++14-ben operator""s
néven:
std::string operator"" _str(char const *str, size_t len) {
return std::string(str, len);
}
std::cout << "hello"_str + "vilag"_str;
Úgy tűnhet, hogy ezt a nyelvi elemet kevés helyen lehet használni, esetleg csak tudományos vagy mérnöki feladatokat ellátó programokban. De eredetileg is erre szánták. A C++-nak nagy a részesedése ezekben az alkalmazásokban.
Megjegyzések:
- Az egész számok előkészített formában mindig előjel nélküliek, mert a literálisok mindig nemnegatívak. A
-1egy kifejezés, az1literálissal és az egyoperandusú-, azaz negálás operátorral. Az előkészítésbe a számrendszer megadását is bele kell érteni;0xFF_xesetén a_xutótag operátor azunsigned long longtípusú 255 értéket fogja megkapni. - A számok átvételének van egy harmadik formája, amikor egy sablon függvény sablonparamétereiként kapjuk a karaktereket. Erről majd később lesz szó.
- Ha számot veszünk át raw formában, csak
char const *paramétere van a függvénynek. Ha sztringet, akkorchar const *éssize_t– ez különbözteti meg a kettőt. - Ahol lehet, adjunk
constexprjelzőt az utótag operátornak. Amit lehet, fordítási időben kell kiértékelni. - Általában pedig: hasonlók a saját literálisok, mint a saját operátorok. Ne erőltessük a használatukat! Csak akkor használjunk ilyet, ha érthetőbb lesz tőle a kód, ne az legyen a cél, hogy tömörebb legyen!
A constexpr értékekkel hívott constexpr függvény értéke is constexpr. Így lehetnek az
egyes mértékegységekhez tartozó utótag operátor függvények constexpr minősítésűek. És így csökken nullára a futási
idejű költség, mert így a mennyiség objektumokat a fordító fordítási időben előállítja, és bit szinten előkészítve kerülnek a
lefordított programba.
A program második változata: quantity2.cpp.
A most megírt programunk sok nagyon általános nevű típust definiált, pl. Time, Speed, ezek bárhol
előfordulhatnak. Az ilyen nevek ütközésének elkerülésére találták ki a névtereket. Érdemes ezért a teljes kódot egy
Units nevű névtérbe tenni.
A névterek használatánál a háttérben egy érdekes szabály munkálkodik, amiről kevesen tudnak*, és ez a Koenig Lookup:
Koenig Lookup (Argument dependent lookup, ADL)
Andrew Koenig nagy szerepet vállalt a C++ kidolgozásában, Bjarne Stroustrup mellett. Ő találta ki ezt a szabályt: ha egy függvényhívásban paraméterként egy valamilyen osztálybeli objektumot adunk meg, akkor a fordító a megadott nevű függvényt az objektum osztályát magában foglaló névtérben is keresi.
* Hogy a szabályról kevesen tudnak, az nem sértés akar lenni: egyszerűen úgy van kitalálva, hogy kényelmessé és természetessé teszi a névterek használatát. Ezért legtöbbször fel sem tűnik, hogy egyáltalán létezik.
Kódban ez a következőt jelenti:
namespace NS {
class A {};
void f(A a) {
}
}
int main() {
NS::A x;
NS::f(x); /* NS::f-et hívja */
f(x); /* using namespace nélkül is NS::f-et hívja!
* mert az x típusa NS-beli */
}
Ebben a példában az f(x) függvényhívásnál is rájön a fordító, hogy az NS-beli f()
függvényről van szó, anélkül hogy külön kiírtuk volna a hívásnál a névteret, vagy using namespace-t használtunk
volna. Mégpedig azért, mert a függvénynek adott objektum az NS névtérből származik, ezért számítani lehet rá, hogy
az NS névtérbeli függvénynek szeretnénk paraméterként adni.
A Koenig Lookup akkor fontos igazán, ha olyan függvényhívást szeretnénk csinálni, ahol nem írhatjuk ki a névteret. Ilyenek
például az operátorok. Ha van két Math::Matrix típusú változónk, x és y, akkor az
x+y kifejezést leírva a fordítónak rá kell jönnie, hogy az operator+() függvényt a Math
névtérben is keresse. Ezt nem tudjuk név szerint kiírni (a Math::+ b?! Math::operator+(a, b)...), és
ha nem lenne ez a névkeresési szabály, az a használhatóság rovására menne.
A mértékegységes példában így érdemes a teljes kódot a Units névtérbe tenni, a literálisok operátorait
pedig még azon belül is a Literals névtérbe:
namespace Units { // Units
struct Unit {
/* ... */
};
template <Unit U>
class Quantity {
/* ... */
};
template <Unit U>
std::ostream & operator<<(std::ostream & os, Quantity<U> m) {
/* ... */
}
namespace Literals { // Units::Literals
constexpr Length operator "" _m (long double magnitude) {
return Length(magnitude);
}
/* ... */
}
/* ... */
}
Ez nem rontja a használhatóságot. A főprogram így a következőképp nézhet ki:
int main() {
using namespace Units::Literals;
std::cout << "Ferrari ===" << std::endl;
Units::Speed v = 100.0_km / 1.0_h;
std::cout << "100 km/h = " << v << std::endl;
Units::Acceleration a = v / 4.0_s;
Units::Force f = 1450.0_kg * a; /* Newton */
std::cout << "F = " << f << std::endl;
}
Az egyes mennyiségek típusait minősítjük a névtér nevével, így nincs ütközés a hasonló nevekkel. A kiírásoknál a
Units névtérben lévő, mértékegységekhez megadott kiíró operátort a fordító megtalálja a Koenig Lookup miatt, és
ugyanez a helyzet a többi operátorral is. A literálisokat kezelő operátort pedig a using namespace
Units::Literals-szal láthatóvá tesszük a függvényen belül – de lehetőleg semmiképp sem globálisan.
Az így kialakított változat: quantity3.cpp.
A fent bemutatott Quantity osztályhoz hasonlóan működik az std::chrono névtérben lévő idő-kezelő könyvtár. Fontosabb osztályai:
class ratio: egy Tört osztály, ami template paraméterében kapja a számlálót és a nevezőttemplate<class RatioT> class duration: időtartam osztály, pl.std::chrono::second- Többféle "clock" osztály:
class system_clock, steady_clock, high_resolution_clock template<class Clock> class time_point: időpont osztály, ami az adott óra szerinti időpontot tárolja- különböző mértékegységek közti váltáshoz szükséges függvények, alapműveletek.
Ezzel sokkal könnyebb típusbiztosan kezelni a különféle felbontású, működésű időpontokat, időtartamokat:
#include <chrono>
int main() {
using namespace std::chrono_literals;
auto now = std::chrono::system_clock::now();
constexpr auto d1 = 2s; // std::chrono::seconds
auto later = now + d1;
std::cout << " " << now << " " << later << std::endl;
return 0;
}
Arra is jól használható, ha egy általunk használt rendszer saját időformátumot talált fel
(bár ne tette volna), és azt szeretnénk
egyszerűen, mégis típusbiztosan kezelni, és a többi formátum között oda-vissza konvertálni: írhatunk saját "clock" típust, akár egyedi felbontással és epoch-kal, a chrono azt is jól kezeli.
C++20-ban a dátumkezelés is a szabvány része lett,
Howard Hinnant date-je került be kisebb módosításokkal,
ami előtte is a legelterjedtebb és legjobb date könyvtár volt.
- Arne Mertz: Use Stronger Types!
- Strong types for strong interfaces
- Vincent Zalzal: Getting the Benefits of Strong Typing in C++ at a Fraction of the Cost
- Jonathan Boccara: Implementation of strong types in C++
- Bjarne Stroustrup: C++11 Style – A Touch of Class, Going Native 2012 Keynote Speech (Youtube).
- Herb Sutter: GotW #30: Name Lookup.
- Gimli Glider (Wikipedia).
- Mars Climate Orbiter (Wikipedia).
- The Problem with Time & Timezones - Computerphile (Youtube).