Szintaktikai szörnyűségek

Czirkos Zoltán · 2022.06.21.

A C nyelv nyelvtana nem a legegyszerűbbek közé tartozik, ha gépi elemzésről van szó. A C++ erre még rátett egy lapáttal. Ez az írás néhány többféleképpen értelmezhető, illetve félreérthető kódrészletet mutat be.

1. Pointer vagy szorzás?

A szorzást és az indirekciót is a * operátor jelöli. Vajon mit jelent az alábbi kódrészlet?

A * B;

A válasz: mindkettő lehet. Ha A egy típus, akkor B egy ilyenre mutató pointer lesz: int * p, tehát a sor egy objektumdefiníció. Ha A egy változó, akkor B-nek már létező változónak kell lennie, és a kód egy szorzást jelent.

Vegyük észre, mekkora különbség van a kettő között! Az első esetben ez a sor hozza létre a B változót. A második esetben már léteznie kell. Sőt ez a sor akár ugyanabban a függvényben kétszer is szerepelhet, két különböző értelemben:

#include <iostream>

class X {};
void operator*(X a, X b) {
    std::cout << "szorzas\n";
}

X A, B;

int main() {
    A * B;

    using A = int;
    A * B;
    B = new int;
}

Ez azért nehezíti meg a fordítók (íróinak) életét, mert a sorról szintaktikai szabályok alapján nem dönthető el, hogy mit jelent, csak a szimbólumtábla (változónevek, típusnevek stb.) vizsgálatával.

2. Belső típus vagy statikus változó?

Adott az alábbi kódrészlet:

template <typename T>
class A {
    /* ... */
};

template <typename T>
void fv() {
    A<T>::B * C;
}

Tehát adott egy sablon osztály, és egy sablon függvény. A függvény példányosítja a sablon osztályt valamilyen típussal, aztán ::B és * C. Az eddigiekből tudhatjuk, hogy ez gyanús: valami * valami lehet szorzás és pointer is. Vajon melyik? Azt is mondtuk, hogy ez attól függ, mi van a * előtt, típus vagy változó. De vajon melyik? Ez meg attól függ, hogy mi van az A sablon osztályban, statikus változó (mert azt is a ::B szintaxissal érjük el), vagy belső típus (azt is ::B szintaxissal). Ha változó, akkor ez egy szorzás, ha típus, akkor C egy pointer lesz. Tehát például az alábbi kódrészletben pointer lesz, gondolhatnánk:

template <typename T>
class A {
  public:
    using B = char;
};

template <typename T>
void fv() {
    A<T>::B * C;           // = char * C?
}

A helyzet az, hogy nem. Ebből a kódrészletből nem látszik egyértelműen, hogy C pointer lesz. Ugyanis attól még, hogy az alap sablon osztálynak (base template) egy B nevű belső típusa van, nem biztos, hogy nem lesz olyan specializációja, amin belül B nem egy statikus változó:

template <>
class A<int> {
  public:
    static int const B = 3;
};

Ebben az esetben A<double>::B a char típus szinonimája, A<int>::B pedig egy konstans, aminek az értéke három. Ugyanez a helyzet az fv() sablonnal is: double és int példányosítás esetén látszólag mást jelent a kód. Az A<T>::B ezért egy ún. függő név (dependent name).

A félreérthetőséget úgy oldották fel, hogy az ilyen függő neveket alapesetben statikus tagváltozónak tekintik. A fordítónak külön jelezni kell, ha ilyenkor típusról beszélünk, a typename kulcsszóval:

template <typename T>
void fv1() {
    A<T>::B * C;           // szorzás
}

template <typename T>
void fv() {
    typename A<T>::B * C;  // pointer
}

Erre sablon kódban mindig figyelnünk kell.

3. Statikus változó vagy globális változó?

Nézzük meg az A<B>::C alakú kódrészletet egy kicsit jobban. Vajon mit jelent az alábbi kód?

A<B>::C

Ez egy A nevű sablonosztály lenne példányosítva B típussal, amin belül C egy statikus változó? Vagy inkább az A < B kifejezés értékét vizsgáljuk, hogy nagyobb-e, mint a globális ::C változó? Ez megint attól függ, hogy az A típus (sablon osztály) vagy változó neve.

4. Ahányféle zárójel...

C-ben a (), [], {} zárójeleket használtuk különféle célokra. C++-ban a <> karakterpárok is zárójelekként funkcionálnak sablon kódrészletekben. A gond a szokásos: ezeknek a karaktereknek más jelentése is van, operátorok nevei.

Tegyük fel, hogy X egy sablon osztály, egy darab, int típusú sablonparaméterrel. Van két fordítási idejű egész konstansunk, A és B. Az X osztályt ezek közül a nagyobbiknak az értékével szeretnénk példányosítani:

template <int N>
class X {};

enum { A = 5, B = 7 };

int main() {
    X< A>B ? A : B > x;     // syntax error
}

Ez nem fog menni. Az első > karaktert a fordító a sablon bezáró zárójelének hiszi. Így, látszólag fölöslegesen, zárójeleznünk kell:

X< (A>B ? A : B) > x;       // ok

5. Hány dolgot jelent a > karakter?

A > karakter nem csak a sablon kód bezáró zárójele és összehasonlító operátor. Két egymás utáni kacsacsőr >> egy másik operátort, a bitenkénti léptetés operátort reprezentáló token. Vagy mégsem.

Az alábbi kódrészlet C++98-ban helytelennek számított, mert a fordító a két bezáró kacsacsőrt léptető operátornak hitte. Csak úgy lehetett lefordítani, ha szóközt tettünk közéjük.

std::vector<std::vector<int>> v;

C++11-ben már le szabad írni ezt szóköz nélkül. A szintaktikai elemzés után dől csak el, hogy a >> karaktersorozatot hogyan értelmezi a fordító, egy operátorként vagy két zárójelként.

6. A template kulcsszó függvényhívásoknál

Adott az alábbi kódsor. Ez ránézésre egy objektumdefiníció: az std névtérben megtalálható function osztálysablont példányosítja int() függvénytípussal, és az így megadott osztályból létrehoz egy objektumot f néven:

std::function<int()> f;

No igen, de mindez csak akkor igaz, ha tényleg ilyen típusokról van szó. Mi a helyzet akkor, ha a function és az f nevek definíciója az alábbi? Ebben az esetben a kódsor egy kifejezés! std::function, a globális változó, kisebb-e int() nullánál, és az így kapott érték nagyobb-e f-nél.

namespace std {
    int function = 0;
};

int main() {
    int f = 0;
    std::function< int() > f;
}

Ez ugyanaz a probléma, mint a typename kulcsszó esetében: ha nem tudjuk, mi az az std, akkor azt sem tudhatjuk, hogy az abban lévő function egy sablon, amit a kacsacsőrök példányosítanak. Lehetne változó is, és akkor a kacsacsőrök összehasonlítást jelentenének. Márpedig sablon kódnál nem tudhatjuk! Ezért ha egy sablonosztály statikus függvényét, vagy akár egy sablon típus sablon tagfüggvényét szeretnénk meghívni, akkor a template kulcsszóval jelezni kell, hogy egy sablonról van szó (innen fogja tudni a fordító, mit jelentenek a kacsacsőrök):

template <typename T>
void func(T obj, T* pobj) {
    T::template f<int>();       // statikus fv

    obj.template g<int>();      // tagfv
    pobj->template g<int>();
}

7. Függvény vagy objektum?

C++98-ban az osztályok nevei függvényként is használhatóak. Az OsztályNeve(kifejezések...) alakú kifejezés konstruktorhívást jelent. Ugyanígy kerek zárójellel kell megadni a névvel is rendelkező objektumok definíciója esetén a konstruktorparamétereket. Csakhogy...

class Complex {
    Complex(double re = 0, double im = 0);
};

Complex c1(2, 3);   // objektumdefiníciók
Complex c2(2);

Complex c3();       // függvénydeklaráció

Figyeljük meg az utolsó sort! Ez típusnév név(); alakú. int fv(); – ismerős? Akár egy függvénydeklaráció is lehetne. Sőt tényleg az, mert a szintaktikai kétértelműséget úgy kerülték meg, hogy azt mondták, ami függvénydeklaráció és objektumdefiníció is lehetne (tehát amire ráillik mindkét nyelvtani szabály), az függvénydeklaráció. A paraméter nélküli esetben, ha objektumot szeretnénk, el kell hagynunk az üres zárójelpárt (amit amúgy függvénynél nem tehetnénk meg). Ez csak azokon a helyeken van így, ahol nem biztos, hogy konstruktorhívásról van szó:

Complex c3;                     // objektumdefiníció, tilos ()

cout << Complex();              // temportális objektum, kötelező ()

Complex* p1 = new Complex;      // itt mindkettő jó
Complex* p2 = new Complex();

Ez a hírhedt „most vexing parse” probléma, a C++ egyik legbosszantóbb szintaktikai kétértelműsége. Sok esetben lefordítható kódhoz juthatunk mindkét változattal:

std::string s1;
std::cout << s1;     // sztring kiírása

std::string s2();
std::cout << s2;     // bool(true) kiírása, mert függvénypointer->bool cast

Egy összetettebb példa, ami szintén függvény lesz, lent látható. A felső sor is lefordul (hiába nincs két komplex számot átvevő konstruktor!), mert érthető függvénydeklarációként. Az így írt függvénydeklaráció igazi jelentését az alsó sor mutatja: egy komplex számot, és egy függvénypointert átvevő függvény deklarációja. Az első azért lehetséges, mert ebben a kontextusban Complex(c1) és Complex c1 ugyanazt jelenti; a c1 nevű paraméter típusa komplex szám. (A zárójelnek itt precedenciamódosító hatása lenne, de épp nem csinál semmit. Kifejezésekben bárhova írhatunk zárójelet! A main() függvény fejlécét is írhatjuk int (main)(int (argc), char** (argv)) alakban.) A második rész, a Complex() egy függvény típust ad meg (Complex visszatérési értékű, paraméter nélküli függvény), de paraméterátadáskor a függvény helyett függvényre mutató pointert értünk. Így végülis az egész érthető függvénydeklarációként.

Complex c4(Complex(c1), Complex());       // a kód

Complex c4(Complex c1, Complex(*)());     // a jelentése

Complex c4((Complex(c1)), Complex());     // javítva: c4 objektum definíciója

Javítani a szokásos módon, zárójelezéssel lehet. (Complex(c1)), ezt nem lehet egy függvény paramétereként érteni, mert a formális paraméter megadása egy deklaráció típusnévvel kell kezdődjön, nem zárójellel. A fenti kódrészlet erőltetettnek tűnik, de ugyanilyen probléma bármikor könnyen előjöhet. A következő kódrészletben egy fájlból (itt: std::cin) olvasnánk be egész számokat egy vektorba, istream_iterator-ok segítségével. De nem megy, mert a jelzett sor szintaktikailag ugyanaz, mint a fenti: egy függvénydeklaráció.

using namespace std;

// ez hibás...
vector<int> numbers(istream_iterator<int>(cin), istream_iterator<int>());

copy(numbers.begin(), numbers.end(), ostream_iterator<int>(cout, "\n"));

Javítani zárójelezéssel, vagy C++11-ben {} szintaxissal lehet:

// így már jó
vector<int> numbers((istream_iterator<int>(cin)), istream_iterator<int>());

// meg így is
vector<int> numbers{istream_iterator<int>(cin), istream_iterator<int>()};

8. Makrók és függvények

Van egy sablon osztályunk, amelynek két int paramétere van. Mint pl. az alábbi Max osztály: ez statikus adattagként tartalmazza a két szám közül a nagyobbikat. Tesztelésképp kiírjuk az összehasonlítás eredményét (print()). Aztán szeretnénk automatizálni is a tesztet: az assert makró megszakítja a programot, ha hamis kifejezést kap.

#include <cassert>
#include <iostream>

template <int A, int B>
struct Max {
    static const int value = A>B ? A:B;
};

void print(bool b) {
    std::cout << (b ? "igaz" : "hamis");
}

int main() {
    print(Max<5, 7>::value == 7);
    assert(Max<5, 7>::value == 7);
}

A print()-es sorral semmi baja a fordítónak, azonban az assert()-es sor szintaktikai hibás. Miért? Pedig az assert() makrónak egy paramétere van!

Hát igen, egy paramétere, de az előfeldolgozó nem ismeri a sablonokat, és az azok által használt <> zárójelezést. Ezért az azt hiszi, hogy az assert() makrót két paraméterrel próbáljuk hívni, az első Max<5, a második pedig 7>::value == 7. Innen jön a hibaüzenet. A javításhoz be kell zárójeleznünk a kifejezést:

assert((Max<5, 7>::value == 7));