C ++成员函数中的“if(!this)”有多糟糕?

时间:2012-01-12 21:52:17

标签: c++ visual-c++ gcc

如果我遇到在应用中执行if (!this) return;的旧代码,那么风险有多严重?这是一个危险的滴答作响的定时炸弹需要立即在应用程序范围内搜索并摧毁努力,还是更像是一种可以安静地留在原点的代码气味?

当然,我不打算编写代码来执行此操作。相反,我最近在我们的应用程序的许多部分使用的旧核心库中发现了一些东西。

想象一下,CLookupThingy类具有非虚拟 CThingy *CLookupThingy::Lookup( name )成员函数。显然,那些牛仔时代的程序员遇到了很多崩溃事件,其中NULL CLookupThingy *是从函数传递的,而不是修复数百个调用站点,他悄悄地修复了Lookup():

CThingy *CLookupThingy::Lookup( name ) 
{
   if (!this)
   {
      return NULL;
   }
   // else do the lookup code...
}

// now the above can be used like
CLookupThingy *GetLookup() 
{
  if (notReady()) return NULL;
  // else etc...
}

CThingy *pFoo = GetLookup()->Lookup( "foo" ); // will set pFoo to NULL without crashing

本周早些时候我发现了这颗宝石,但现在我是否应该修复它。这是我们所有应用程序使用的核心库。其中一些应用程序已经发送给数百万客户,它似乎工作正常;该代码没有崩溃或其他错误。删除查找函数中的if !this意味着修复数千个可能传递NULL的调用站点;不可避免地会有一些错过,引入新的错误,这些错误将在下一个的开发中随机出现。

所以除非绝对必要,否则我倾向于不管它。

鉴于技术上未定义的行为,if (!this)在实践中有多危险?是否值得花费数周的劳动力,或者MSVC和GCC能否安全返回?

我们的应用程序在MSVC和GCC上编译,并在Windows,Ubuntu和MacOS上运行。对其他平台的可移植性无关紧要。有问题的功能保证永远不会是虚拟的。

编辑:我正在寻找的客观答案类似于

  • “当前版本的MSVC和GCC使用ABI,其中非虚拟成员实际上是静态的,带有隐含的'this'参数;因此即使'this'为NULL,它们也会安全地转换为函数”或
  • “即将推出的GCC版本将更改ABI,以便即使非虚拟函数也需要从类指针加载分支目标”或
  • “当前的GCC 4.5有一个不一致的ABI,有时它会将非虚拟成员编译为带有隐式参数的直接分支,有时也会作为类偏移函数指针。”

前者意味着代码很臭但不太可能破坏;第二个是在编译器升级后要测试的东西;后者即使在高成本下也需要立即采取行动。

显然,这是一个等待发生的潜在错误,但是现在我只关心降低我们特定编译器的风险。

11 个答案:

答案 0 :(得分:39)

我会不管它。这可能是作为SafeNavigationOperator的老式版本的慎重选择。如你所说,在新代码中,我不推荐它,但对于现有代码,我会不管它。如果你最终修改它,我会确保所有对它的调用都被测试完全覆盖。

编辑添加:您可以选择仅在代码的调试版本中通过以下方式将其删除:

CThingy *CLookupThingy::Lookup( name ) 
{
#if !defined(DEBUG)
   if (!this)
   {
      return NULL;
   }
#endif
   // else do the lookup code...
}

因此,它不会破坏生产代码上的任何内容,同时让您有机会在调试模式下对其进行测试。

答案 1 :(得分:20)

与所有未定义的行为一样

if (!this)
{
   return NULL;
}

这个 一颗等待灭火的炸弹。如果它适用于您当前的编译器,那么您很幸运,有点不走运!

相同编译器的下一个版本可能更具攻击性,并将其视为死代码。由于this永远不能为空,因此可以“安全地”删除代码。

我认为如果删除它会更好!

答案 2 :(得分:12)

如果你有很多GetLookup函数返回NULL,那么你最好修复使用NULL指针调用方法的代码。首先,替换

if (!this) return NULL;

if (!this) {
  // TODO(Crashworks): Replace this case with an assertion on July, 2012, once all callers are fixed.
  printf("Please mail the following stack trace to myemailaddress. Thanks!");
  print_stacktrace();
  return NULL;
}

现在,继续你的其他工作,但在他们滚动时修复这些。替换:

GetLookup(x)->Lookup(y)...

convert_to_proxy(GetLookup(x))->Lookup(y)...

其中conver_to_proxy确实返回指针不变,除非它是NULL,在这种情况下它返回FailedLookupObject,就像在我的另一个答案中一样。

答案 3 :(得分:9)

它可能不会在大多数编译器中崩溃,因为非虚函数通常被内联或转换为非成员函数,并将“this”作为参数。但是,该标准特别指出,在对象的生命周期之外调用非静态成员函数是未定义的,并且对象的生命周期被定义为在分配了对象的内存并且构造函数已完成时开始,如果它具有非平凡的初始化。

该标准仅对构造或销毁期间对象本身进行的调用规则例外,但即使这样,也必须小心,因为虚拟调用的行为可能与对象生命周期内的行为不同。

TL:DR:即使清理所有呼叫站点需要很长时间,我还是会用火来杀死它。

答案 4 :(得分:8)

在正式未定义的行为的情况下,编译器的未来版本可能会更积极地进行优化。我不担心现有的部署(你知道编译器实际实现的行为),但是如果你使用不同的编译器或不同的版本,它应该在源代码中修复。

答案 5 :(得分:6)

这是一种被称为“聪明而丑陋的黑客”的东西。注意:聪明!=明智。

在没有任何重构工具的情况下找到所有呼叫站点应该很容易;以某种方式打破GetLookup()所以它不会编译(例如更改签名),因此您可以静态地识别错误。然后添加一个名为DoLookup()的函数,它可以完成所有这些现在正在做的事情。

答案 6 :(得分:5)

在这种情况下,我建议从成员函数中删除NULL检查并创建非成员函数

CThingy* SafeLookup(CLookupThing *lookupThing) {
  if (lookupThing == NULL) {
    return NULL;
  } else {
    return lookupThing->Lookup();
  }
}

然后,应该很容易找到对Lookup成员函数的每次调用,并用安全的非成员函数替换它。

答案 7 :(得分:4)

如果今天有什么事情困扰着你,那么一年后你会感到烦恼。正如您所指出的那样,更改它几乎肯定会引入一些错误 - 但您可以先保留return NULL功能,添加一些日志记录,让它在野外运行几周,然后查找多少它甚至被击中了?

答案 8 :(得分:2)

您可以通过返回失败的查找对象来安全地解决此问题

class CLookupThingy: public Interface {
  // ...
}

class CFailedLookupThingy: public Interface {
 public:
  CThingy* Lookup(string const& name) {
    return NULL;
  }
  operator bool() const { return false; }  // So that GetLookup() can be tested in a condition.
} failed_lookup;

Interface *GetLookup() {
  if (notReady())
    return &failed_lookup;
  // else etc...
}

此代码仍有效:

CThingy *pFoo = GetLookup()->Lookup( "foo" ); // will set pFoo to NULL without crashing

答案 9 :(得分:2)

这是一个"滴答作响的炸弹"只有你对规范的措辞很迂腐。然而,无论如何,这是一种可怕的,不明智的方法,因为它掩盖了程序错误。仅仅因为这个原因,我会删除,即使它意味着相当多的工作。这不是直接(甚至是中期)风险,但它不是一个好方法。

这种错误隐藏行为确实不是你想要依赖的东西。想象一下,你依赖于这种行为(即无论我的对象是否有效,无论如何都无效!)然后,由于某种危险,编译器会优化{{1}在特定情况下的语句,因为它可以证明if不是空指针。这是一个合理的优化,不仅适用于某些假设的未来编译器,也适用于非常真实的现在编译器 但是,当然,由于你的程序格式不正确,所以发生,在某些时候你会在20个角落传递一个空this。砰,你已经死了。
无可否认,这是非常有意义的,并且它不会发生,但你不能100%确定它仍然不可能发生。

请注意,当我喊出"删除!"时,这并不意味着必须立即删除所有这些或在一次大规模的人力操作中删除。您可以在遇到它们时逐个删除这些检查(当您在同一文件中更改某些内容时,避免重新编译),或者只搜索一个(最好是在高度使用的函数中),然后删除它。

由于您使用的是GCC,因此您可以参与__builtin_return_address,这可能会帮助您在没有大量人力的情况下删除这些检查,并完全破坏整个工作流程并使应用程序完全无法使用。
删除支票之前,将其修改为输出来电者的地址,this会告诉您来源中的位置。这样,您应该能够快速识别应用程序中行为错误的所有位置,以便您可以解决这些问题。

所以而不是

addr2line

一次将一个位置更改为:

if(!this) return 0;

这使您可以识别此更改的无效呼叫站点,同时仍然让程序按预期工作" (如果需要,您还可以查询呼叫者的呼叫者)。一个接一个地修复每个不良行为的位置。该计划仍将“工作”#34;照常。
一旦没有更多地址出现,请完全取消检查。如果你不幸(因为它在你测试时没有表现出来),你可能仍然需要修复一个或另一个崩溃,但这应该是一件非常罕见的事情。无论如何,它应该防止你的同事对你大喊大叫 每周删除一次或两次检查,最终不会留下任何一项检查。与此同时,生活还在继续,没有人注意到你在做什么。

<强> TL; DR
对于&#34;当前版本的GCC&#34;,您可以使用非虚拟功能,但当然没有人能够知道未来版本可能会做什么。我认为未来版本不太可能导致您的代码中断。很少有现有的项目有这种检查(我记得我们在Code :: Blocks代码中完成了数百个,但不要问我为什么!)。编译器制造商可能不希望让数十个/数百个主要项目维护者故意不高兴,只是为了证明一点。
另外,考虑最后一段(&#34;从逻辑的角度来看&#34;)。即使这个检查会在未来的编译器中崩溃并烧毁,它仍会崩溃并烧毁。

if(!this) { __builtin_printf("!!! %p\n", __builtin_return_address(0)); return 0; } 语句在某种程度上是无用的,因为if(!this) return;在格式良好的程序中不能是空指针(这意味着你在空指针上调用了一个成员函数)。当然,这并不意味着它不可能发生。但在这种情况下,程序应该崩溃或中断断言。在任何情况下,这样的计划都不应该默默地继续下去 另一方面,完全可以在无效对象上调用成员函数,该对象恰好是 not null 。检查this是否为空指针显然不会捕获该情况,但它是完全相同的UB。因此,除了隐藏错误行为之外,此检查还仅检测有问题案例的一半。

如果您按照指示的措辞,使用this(包括检查它是否为空指针)是未定义的行为。严格来说,这是一个&#34;定时炸弹&#34;。但是,从实际角度和逻辑角度来看,我都不会合理认为这是一个问题。

  • 实用的角度来看,只要你不,你一个无效的指针并不重要取消引用它。是的,严格到信,这是不允许的。是的,理论上有人可能会构建一个CPU,它会在加载时检查无效指针,并且会出错。唉,如果你是真的,情况并非如此。
  • 逻辑的角度来看,假设检查爆炸,它仍然不会发生。要执行此语句,必须调用成员函数,并且(虚拟或非虚拟,内联或不内联)使用无效的this,它在函数体内可用。如果一个非法使用this爆炸,另一个也会爆炸。因此,检查已经过时,因为程序早先已经崩溃了。

<小时/> n.b。:此检查非常类似于&#34;安全删除习惯用法&#34;在删除它之后设置指向this的指针(使用宏或模板nullptr函数)。据推测,这是安全的&#34;因为两次删除同一个指针不会崩溃。有些人甚至添加了多余的safe_delete 如您所知,if(!ptr) delete ptr;保证在空指针上是无操作。这意味着没有更多也不少于通过设置指针指向空指针,您已经成功地消除了检测双重删除的唯一机会(这是一个需要修复的程序错误!)。它不是更安全的#34;但它隐藏了不正确的程序行为。如果您删除对象两次,程序严重崩溃 您应该单独留下删除的指针,或者,如果您坚持篡改,请将其设置为非空的无效指针(例如特殊&#34;无效&#34;全局变量的地址,或类似魔术值operator delete如果你愿意的话 - 你不应该试图作弊并在发生崩溃时隐藏它。)

答案 10 :(得分:1)

我个人认为你应该尽早失败,提醒你注意问题。在那种情况下,我会毫不客气地删除我能找到的if(!this)的每一次出现。