在空指针上访问类成员

52

我正在尝试使用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 对象在哪里分配?

有什么想法吗?


5
参见此处关于这个问题语言的说明。两者都是未定义行为。 - GManNickG
8个回答

86
变量 foo 是一个类型为Foo*的本地变量。该变量可能会像任何其他局部变量一样在main函数中的堆栈上分配。但存储在foo中的是一个null指针,它不指向任何位置,也就是说并没有表示Foo类型的任何实例。
要调用虚函数,调用者需要知道函数所调用的对象是哪个。这是因为对象本身可以告诉应该真正调用哪个函数。(通常通过给对象一个指向虚函数表(vtable)的指针来实现,虚函数表是一个函数指针列表,调用者只需要调用列表中的第一个函数而无需预先知道该指针指向哪里。)
但要调用非虚函数,调用者并不需要知道所有这些细节。编译器确切地知道将调用哪个函数,因此它可以生成一条CALL机器码指令直接跳转到所需的函数。它只需将函数被调用时所在对象的指针作为隐藏参数传递给函数即可。换句话说,编译器将您的函数调用转换为以下内容:
void Foo_say_hi(Foo* this);

Foo_say_hi(foo);

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

正式地说,调用任何函数(即使是非虚函数)都是未定义行为,其中一种允许的未定义行为结果是您的代码似乎按照您预期的方式运行。不应该依赖这一点,尽管有时您会发现来自编译器供应商的库确实依赖于此。但编译器供应商具有能够对本来是未定义行为进行进一步定义的优势。不要自己这样做。


似乎存在混淆,即函数代码和对象数据是两个不同的东西。请看这个https://dev59.com/tHI-5IYBdhLWcg3wNle1。在这种情况下,由于空指针,初始化后对象数据不可用,但是代码一直在内存中的其他位置可用。 - Loki
FYI,这是从[C++11: 9.3.1/2]中得出的:“如果为不属于类型X或派生自X的类型的对象调用类X的非静态成员函数,则行为未定义。” 显然,*foo不是类型Foo(因为它不存在)。 - Lightness Races in Orbit
2
实际上,回想起来,它更直接地源自[C++11: 5.2.5/2]:“表达式E1->E2被转换为等价形式 (*(E1)).E2”,然后是明显的未定义行为,当E1不是有效指针时对其解引用(包括[C++11: 3.8/2])。 - Lightness Races in Orbit
你能告诉我在哪里看到这个问题的引用吗,@Lightness?在过去的一天里,我已经得到了超过20票,我想知道为什么它突然引起了这么多关注。 - Rob Kennedy
@RobKennedy:昨天有人在freenode##c++上链接到它,可能还有其他地方。我的评论也可能让它短暂地出现在前面的页面上。 - Lightness Races in Orbit

18

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

void say_hi(Foo *this);

由于您没有访问任何成员,所以您的调用成功了(尽管根据标准,您正在进入未定义的行为)。

Foo根本没有被分配。


谢谢。如果 Foo 没有被分配,调用会发生什么?我有点困惑。 - Navaneeth K N
1
处理器或汇编本身并不了解代码的高级语言细节。C++中的非虚函数仅仅是普通函数,并且有一个约定,即'this'指针位于给定位置(寄存器或堆栈,取决于编译器)。只要你不访问'this'指针,一切都很好。 - arul
我遇到了这样一种情况,即使访问了一个数据字段,空指针引用也没有崩溃。我认为应该将此类崩溃标准化。 - Charlie
实现方式各不相同,但是在大多数平台上,要求在每个地方都进行空指针检查会使得指针引用对于C++核心设计目标来说过于昂贵。 - Pontus Gagge

7

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

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


谢谢。你认为第二个问题怎么样?Foo 被分配在哪里? - Navaneeth K N
foo不是一个对象,它是一个指针。该指针在堆栈上分配(就像任何未标记为“静态”的变量或使用“new”分配的变量一样)。它永远不会指向有效的对象。 - jalf

6

这是未定义行为,但大多数编译器会生成指令来正确处理此情况,如果您既不访问成员变量也不访问虚表。

让我们看看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 被当做普通函数调用,但是使用了 this 寄存器来传递参数。为了简化问题,你可以认为 this 是一个隐式参数,在本例中没有被使用。
但是在第二个情况中,由于虚表的存在,必须计算函数的地址 - 这需要确保 foo 的地址是有效的,否则会导致崩溃。


谢谢。你能告诉我如何在Visual Studio中获取这个反汇编吗?我正在使用VS2008。 - Navaneeth K N
2
调试器下的调试->窗口->反汇编 - bayda

3

请注意,两种调用都会产生未定义的行为,并且该行为可能表现出意外的方式。即使调用似乎有效,它也可能会设置一个地雷。

考虑对您示例的这个小更改:

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

由于对空指针foo的第一次调用会导致未定义行为,因此编译器现在可以自由地假设foo不是null。这使得if (foo != 0)变得多余,编译器可以将其优化掉!你可能认为这是一种非常无意义的优化,但编译器作者已经变得非常激进,实际代码中发生了类似的情况。


3

a) 它之所以能够工作,是因为它没有通过隐式的“this”指针解除引用任何内容。一旦你这样做了,就会出问题。我不确定,但我认为空指针解引用是通过保护前1K内存空间来完成的,因此如果你只在1K行后解引用它(例如:某个实例变量被分配得非常远),那么有一小部分可能性是无法捕获到空引用。

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

如果 A 为 null,那么 a->i 可能未被捕获。

b) 没有地方会分配内存,你只是声明了一个指针,它被分配在 main() 的堆栈上。


2

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

virtual_say_hi的调用是动态绑定的,因此处理器会去虚拟表中查找,由于没有虚拟表,它会跳转到随机位置并导致程序崩溃。


那很有道理。谢谢。 - Navaneeth K N

1
在C++的早期版本中,C++代码会被转换为C语言。对象方法也被转换成非对象方法,例如(在你的情况下):
foo_say_hi(Foo* thisPtr, /* other args */) 
{
}

当然,foo_say_hi的名称是简化的。要了解更多详细信息,请查阅C++名称重载。

正如您所看到的,如果未对thisPtr进行解引用,则代码是正确的并且成功。在您的情况下,未使用任何实例变量或依赖于thisPtr的内容。

但是,虚函数是不同的。需要进行大量对象查找,以确保将正确的对象指针作为参数传递给函数。这将解引用thisPtr并导致异常。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接