50

我正在试验 C++,发现下面的代码很奇怪。

class Foo{
public:
    virtual void say_virtual_hi(){
        std::cout << "Virtual Hi";
    }

    void say_hi()
    {
        std::cout << "Hi";
    }
};

int main(int argc, char** argv)
{
    Foo* foo = 0;
    foo->say_hi(); // works well
    foo->say_virtual_hi(); // will crash the app
    return 0;
}

我知道虚拟方法调用会崩溃,因为它需要 vtable 查找并且只能使用有效对象。

我有以下问题

  1. 非虚方法如何say_hi在 NULL 指针上工作?
  2. 对象在哪里foo分配?

有什么想法吗?

4

8 回答 8

84

该对象foo是一个类型为 的局部变量Foo*main就像任何其他局部变量一样,该变量可能会在函数的堆栈上分配。但是存储的foo是一个空指针。它没有指向任何地方。任何地方都没有Foo表示类型的实例。

要调用虚函数,调用者需要知道该函数是在哪个对象上被调用的。那是因为对象本身告诉我们应该真正调用哪个函数。(这通常通过给对象一个指向 vtable 的指针、一个函数指针列表来实现,而调用者只知道它应该调用列表中的第一个函数,而事先不知道该指针指向的位置。)

但是要调用一个非虚拟函数,调用者不需要知道所有这些。编译器确切地知道将调用哪个函数,因此它可以生成CALL机器代码指令以直接转到所需的函数。它只是将一个指向函数调用对象的指针作为隐藏参数传递给函数。换句话说,编译器将您的函数调用转换为:

void Foo_say_hi(Foo* this);

Foo_say_hi(foo);

现在,由于该函数的实现从不引用其this参数所指向的对象的任何成员,因此您有效地避开了取消引用空指针的子弹,因为您从不取消引用空指针。

正式地,在空指针上调用任何函数——甚至是非虚拟函数——都是未定义的行为。未定义行为的允许结果之一是您的代码似乎完全按照您的预期运行。应该依赖它,尽管有时您会从编译器供应商那里找到依赖它的库。但是编译器供应商的优势在于能够为原本未定义的行为添加进一步的定义。不要自己做。

于 2009-03-21T18:53:42.817 回答
17

say_hi()成员函数通常由编译器实现为

void say_hi(Foo *this);

由于您不访问任何成员,因此您的调用成功(即使您根据标准输入未定义的行为)。

Foo根本没有被分配。

于 2009-03-21T18:45:07.130 回答
7

取消引用 NULL 指针会导致“未定义的行为”,这意味着任何事情都可能发生 - 您的代码甚至可能看起来正常工作。但是,您不能依赖于此 - 如果您在不同的平台上(甚至可能在同一平台上)运行相同的代码,它可能会崩溃。

在您的代码中没有 Foo 对象,只有一个以 NULL 值初始化的指针。

于 2009-03-21T18:44:21.797 回答
5

这是未定义的行为。但是如果您不访问成员变量和虚拟表,大多数编译器都会做出正确处理这种情况的指令。

让我们看看 Visual Studio 中的反汇编,了解会发生什么

   Foo* foo = 0;
004114BE  mov         dword ptr [foo],0 
    foo->say_hi(); // works well
004114C5  mov         ecx,dword ptr [foo] 
004114C8  call        Foo::say_hi (411091h) 
    foo->say_virtual_hi(); // will crash the app
004114CD  mov         eax,dword ptr [foo] 
004114D0  mov         edx,dword ptr [eax] 
004114D2  mov         esi,esp 
004114D4  mov         ecx,dword ptr [foo] 
004114D7  mov         eax,dword ptr [edx] 
004114D9  call        eax  

如您所见, Foo:say_hi 像往常一样调用函数,但在 ecx 寄存器中使用了this 。为简化起见,您可以假设this作为隐式参数传递,我们在您的示例中从未使用过。
但在第二种情况下,我们计算函数到期虚拟表的地址 - 到期 foo 地址并获取核心。

于 2009-03-21T19:00:08.387 回答
2

a) 它之所以有效,是因为它不会通过隐含的“this”指针取消引用任何内容。一旦你这样做,繁荣。我不是 100% 确定,但我认为空指针取消引用是通过 RW 保护前 1K 的内存空间完成的,所以如果你只取消引用它超过 1K 行(即一些实例变量这将被分配得很远,例如:

 class A {
     char foo[2048];
     int i;
 }

那么当 A 为空时 a->i 可能未被捕获。

b)无处,您只声明了一个指针,该指针分配在 main():s 堆栈上。

于 2009-03-21T18:49:43.920 回答
2

对 say_hi 的调用是静态绑定的。所以计算机实际上只是简单地对一个函数进行标准调用。该函数不使用任何字段,因此没有问题。

对 virtual_say_hi 的调用是动态绑定的,因此处理器会转到虚拟表,由于那里没有虚拟表,它会随机跳转到某个地方并使程序崩溃。

于 2009-03-21T18:53:28.623 回答
2

重要的是要认识到这两个调用都会产生未定义的行为,并且这种行为可能会以意想不到的方式表现出来。即使电话似乎奏效,它也可能正在埋下雷区。

考虑对您的示例进行这个小改动:

Foo* foo = 0;
foo->say_hi(); // appears to work
if (foo != 0)
    foo->say_virtual_hi(); // why does it still crash?

由于第一次调用fooif 为 null 时启用未定义的行为foo,编译器现在可以自由地假设它foo不为null。这使得if (foo != 0)冗余,编译器可以优化它!你可能认为这是一个非常无意义的优化,但是编译器编写者已经变得非常激进,并且在实际代码中也发生过类似的事情。

于 2015-04-24T17:49:57.963 回答
1

在 C++ 的最初时代,C++ 代码被转换为 C。对象方法被转换为像这样的非对象方法(在你的情况下):

foo_say_hi(Foo* thisPtr, /* other args */) 
{
}

当然,名称 foo_say_hi 是简化的。有关更多详细信息,请查看 C++ 名称修饰。

如您所见,如果 thisPtr 从未被取消引用,则代码很好并且成功。在您的情况下,没有使用实例变量或任何依赖于 thisPtr 的东西。

但是,虚函数是不同的。有很多对象查找来确保正确的对象指针作为参数传递给函数。这将取消引用 thisPtr 并导致异常。

于 2009-03-21T18:59:31.323 回答