Jste zde

Zajímavosti z C++: Proč volání metody přes nulový ukazatel může projít

Když vám váš program padá, tak je potřeba jej odladit a nalézt příčinu. Při ladění jste se již mohli setkat se situací, kdy program padá v metodě, která byla volaná přes nulový ukazatel. Přišlo vám však divné, že k pádu dojde až v těle metody, nikoliv již při volání metody. Popřípadě volání metody přes nulový ukazatel projde bez pádu. V dnešním příspěvku se dozvíte, proč k této situaci může dojít.

Začněme příkladem

Vezměte si následující kód:

#include <iostream>
 
class A {
    public:
        void foo() {
            std::cerr << "foo called\n";
        }
 
        void bar() {
            std::cerr << "bar called\n";
            i = 5;
        }
 
    private:
        int i;
};
 
int main() {
    A *a = nullptr; // nebo 0 či NULL v C++98
    a->foo();
    a->bar();
}

Když jej přeložíme a spustíme, dostaneme takovýto výstup (na použitém standardu nezáleží):

$ g++ -std=c++14 -pedantic -o prog prog.cpp && ./prog
foo called
bar called
Segmentation fault (core dumped)

Při ladění zjistíte, že pád nastal až na řádku i = 5;. Otázka je, proč volání foo() a bar() prošlo až tak daleko, když byly tyto dvě metody volány na nulovém ukazateli? Proč to nespadlo už při volání metody foo()?

Nedefinované chování

Volání metody přes nulový ukazatel na objekt je v C++ nedefinované chování. Pokud by vás zajímalo zdůvodnění s odkazy na normu, mrkněte např. zde. Tedy přísně řečeno, kód uvedený výše se může chovat libovolně, protože obsahuje konstrukci s nedefinovaným chováním.

Proč to ale většinou dopadne takto?

Když tedy přejdeme ono nedefinované chování, tak se naskýtá otázka, proč to obvykle dopadne tak, jak to dopadlo v našem příkladu. Důvodem je, že překladače onen kód přeloží typicky takto:

struct _A {
    int i;
};
 
void _ZN1A3fooEv(_A *this) { // A::foo()
    // [..] výpis "foo called"
}
 
void _ZN1A3barEv(_A *this) { // A::bar()
    // [..] výpis "bar called"
    this->i = 5; // přesněji (*this).i = 5
}
 
int main() {
    A *a = 0;
    _ZN1A3fooEv(a);
    _ZN1A3barEv(a);
}

K samotné dereferenci tedy poprvé dojde až na řádku this->i = 5;, což je důvodem, proč program spadne až tam. Že je tomu opravdu tak lze vidět z výstupu disassembleru (přeložte si program s ladicími informacemi, tj. s přepínačem -g a mrkněte na výstup z objdump -S prog).

Co se stane, pokud je metoda virtuální?

Jak si možná pamatujete z knih či kurzů C++, pokud třída obsahuje virtuální metodu, tak je ke každému objektu přiřazen ukazatel na tabulku virtuálních metod. Je to z toho důvodu, aby překladač mohl zaručit, že se za běhu programu zavolá korektní metoda v případech, kdy při překladu není zřejmé, jaký je typ skutečného objektu, na který daný ukazatel/reference odkazuje. Onen ukazatel se typicky nachází buď před daty objektu, nebo až za nimi.

Dejme tomu, že náš původní kód změníme takto:

virtual void foo() { // nyní se jedná o virtuální metodu
    std::cerr << "foo called\n";
}

Pokud nedojde k optimalizaci při překladu, tak překladač typicky vygeneruje něco takového (umístění ukazatele na tabulku virtuálních metod se liší překladač od překladače):

struct _A {
    /* Interní typ VTABLE */ *_vtable;
    int i;
}
 
/* Interní typ VTABLE */ _VTABLE_A[1] = {
    _ZN1A3fooEv // A::foo()
}
 
// ...
 
A *a = 0;
a->_vtable[0](a); // či přesněji (*a)._vtable[0](a);

V tomto případě dojde k pádu programu již při volání metody foo() z důvodu přístupu k tabulce virtuálních metod a program tak nic nevypíše. Opět se ale jedná o nedefinované chování a záleží na konkrétním překladači.

Jinak, v oné tabulce virtuálních metod se toho obecně může nacházet více, např. ukazatel na příslušnou type_info strukturu pro využití při RTTI (detaily).

Komentáře

Ten první příklad je pěkná ukázka toho, kdy by se hodil ubsan (gcc 4.9+):

$ g++ -std=c++14 -fsanitize=undefined e.C
$ ./a.out 
e.C:20:11: runtime error: member call on null pointer of type 'struct A'
foo called
e.C:21:11: runtime error: member call on null pointer of type 'struct A'
bar called
e.C:11:18: runtime error: member access within null pointer of type 'struct A'
Segmentation fault
</code.

Díky za info! O tomto detektoru jsem neslyšel.

Začali jsme ho psát pro GCC 4.9, ale hodně nových věcí/optimalizací bude až v GCC 5. Něco jsem o tom psal v blogpostu tady: http://developerblog.redhat.com/2014/10/16/gcc-undefined-behavior-saniti... - snad to někomu přijde vhod.

Jo jo, ten příspěvek na blogu jsem nedávno četl a UBSAN zkoušel. Parádní věcička. Jsem zvědavý na GCC 5.

Přidat komentář