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

如果我在一个应用程序中遇到执行 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 的呼叫站点; 不可避免地会遗漏一些,引入新的 bug,这些 bug 将在下一个 开发中随机出现。

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

考虑到它在技术上是未定义行为的,那么 if (!this)在实践中有多危险呢?这是否值得人工周的劳动修复,或者可以指望 MSVC 和海湾合作委员会安全返回?

我们的应用程序在 MSVC 和 GCC 上编译,在 Windows、 Ubuntu 和 MacOS 上运行。可移植性与其他平台无关。所讨论的函数保证永远不会是虚拟的。

编辑: 我想要的客观答案是这样的

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

前者意味着代码很糟糕,但不太可能中断; 后者意味着在编译器升级后需要进行测试; 后者需要立即采取行动,即使代价很高。

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

6006 次浏览

It's my personal opinion that you should fail as early as possible to alert you to problems. In that case, I'd unceremoniously remove each and every occurrence of if(!this) I could find.

I would leave it alone. This might have been a deliberate choice as an old-fashioned version of the SafeNavigationOperator. As you say, in new code, I wouldn't recommend it, but for existing code, I'd leave it alone. If you do end up modifying it, I'd make sure that all calls to it are well-covered by tests.

Edit to add: you could choose to remove it only in debug versions of your code via:

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

Thus, it wouldn't break anything on production code, while giving you a chance to test it in debug mode.

Future versions of the compiler are likely to more aggressively optimize in cases of formally undefined behavior. I wouldn't worry about existing deployments (where you know the behavior the compiler actually implemented), but it should be fixed in the source code in case you ever use a different compiler or different version.

If it's something that's bothering you today, it'll bother you a year from now. As you pointed out, changing it will almost certainly introduce some bugs -- but you can begin by retaining the return NULL functionality, add a bit of logging, let it run in the wild for a few weeks, and find how many times it even gets hit?

this is something that's called 'a smart and ugly hack'. note: smart != wise.

finding all the call sites without any refactoring tools should be easy enough; break GetLookup() somehow so it doesn't compile (e.g. change signature) so you can identify misusage statically. then add a function called DoLookup() which does what all this hacks are doing right now.

Like all undefined behavior

if (!this)
{
   return NULL;
}

this is a bomb waiting to go off. If it works with your current compilers, you are kind-of lucky, kind-of unlucky!

The next release of the same compilers might be more aggressive and see this as dead code. As this can never be null, the code can "safely" be removed.

I think it is better if you removed it!

You can safely fix this today by returning a failed lookup object.

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...
}

This code still works:

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

It may not crash in most compilers since non-virtual functions are typically either inlined or translated into non-member functions taking "this" as a parameter. However, the standard specifically says that calling a non-static member function outside the lifetime of the object is undefined, and the lifetime of an object is defined as beginning when memory for the object has been allocated and the constructor has completed, if it has non-trivial initialization.

The standard only makes an exception to this rule for calls made by the object itself during construction or destruction, but even then one must be careful because the behavior of virtual calls can differ from the behavior during the object's lifetime.

TL:DR: I'd kill it with fire, even if it will take a long time to clean up all the call sites.

In this case I'd suggest removing the NULL check from the member function and create a non-member function

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

Then it should be easy enough to find every call to the Lookup member function and replace it with the safe non-member function.

If you have many GetLookup functions return NULL, then you're better off fixing code that calls methods using a NULL pointer. First, replace

if (!this) return NULL;

with

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;
}

Now, carry on with your other work, but fix these as they roll in. Replace:

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

with

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

Where conver_to_proxy does returns the pointer unchanged, unless it's NULL, in which case it returns a FailedLookupObject as in my other answer.

This is a "ticking bomb" only if you are pedantic about the wording of the specification. However, regardless, it is a terrible, ill-advised approach because it obscures a program error. For that reason alone, I would remove it, even if it means considerable work. It is not an immediate (or even middle-term) risk, but it just isn't a good approach.

Such error hiding behavior really isn't something you want to rely on, either. Imagine you rely on this behavior (i.e. it doesn't matter whether my objects are valid, it will work anyway!) and then, by some hazard, the compiler optimizes out the if statement in a particular case because it can prove that this is not a null pointer. That is a legitimate optimization not just for some hypothetical future compiler, but for very real, present-time compilers as well.
But of course, since your program isn't well-formed, it happens that at some point you pass it a null this around 20 corners. Bang, you're dead.
That's very contrieved, admittedly, and it won't happen, but you cannot be 100% certain that it still cannot possibly happen.

Note that when I shout out "remove!", that does not mean the whole lot of them must be removed immediately or in one massive manpower operation. You could remove these checks one by one as you encounter them (when you change something in the same file anyway, avoid recompilations), or just text-search for one (preferrably in a highly used function), and remove that one.

Since you are using GCC, you may be intersted in __builtin_return_address, which may help you remove these checks without massive manpower and totally disrupting the whole workflow and rendering the application entirely unusable.
Before removing the check, modify it to to output the caller's address, and addr2line will tell you the location in your source. That way, you should be able to quickly identify all the locations in the application that are behaving wrongly, so you can fix these.

So instead of

if(!this) return 0;

change one location at a time to something like:

if(!this) { __builtin_printf("!!! %p\n", __builtin_return_address(0)); return 0; }

That lets you identify the invalid call sites for this change while still letting the program "work as intended" (you can also query the caller's caller if needed). Fix every ill-behaved location, one by one. The program will still "work" as normal.
Once no more addresses come up, remove the check alltogether. You might still have to fix one or the other crash if you are unlucky (because it didn't show while you tested), but that should be a very rare thing to happen. In any case, it should prevent your co-worker from shouting at you.
Remove one or two checks per week, and eventually none will be left. Meanwhile, life goes on and nobody notices what you're doing at all.

TL;DR
As for "current versions of GCC", you are fine for non-virtual functions, but of course nobody can tell what a future version might do. I deem it however highly unlikely that a future version will cause your code to break. Not few existing projects have this kind of check (I remember we had literally hundreds of them in Code::Blocks code completion at some time, don't ask me why!). Compiler makers probably don't want to make dozens/hundreds of major project maintainers unhappy on purpose, only to prove a point.
Also, consider the last paragraph ("from a logical point of view"). Even if this check will crash and burn with a future compiler, it will crash and burn anyway.

The if(!this) return; statement is somewhat useless insofar as this cannot ever be a null pointer in a well-formed program (it means you called a member function on a null pointer). This does not mean, of course, that it couldn't possibly happen. But in this case, the program should crash hard or abort with an assertion. Under no conditions should such a program continue silently.
On the other hand, it is perfectly possible to call a member function on an invalid object that happens to be not null. Checking whether this is the null pointer obviously doesn't catch that case, but it is the exact same UB. So, apart from hiding wrong behavior, this check also only detects one half of the problematic cases.

If you are going by the wording of the speficication, using this (which includes checking whether it's a null pointer) is undefined behavior. Insofar, strictly speaking, it is a "time bomb". However, I would not reasonably deem that a problem, both from a practical point of view and from a logical one.

  • From a practical point of view, it doesn't really matter whether you read a pointer that is not valid as long as you do not dereference it. Yes, strictly to the letter, this is not allowed. Yes, in theory someone might build a CPU which will check invalid pointers when you load them, and fault. Alas, this isn't the case, if you're being real.
  • From a logical point of view, assuming that the check will blow up, it still isn't going to happen. For this statement to be executed, the member function must be called, and (virtual or not, inlined or not) is using an invalid this, which it makes available inside the function body. If one illegitimate use of this blows up, the other will, too. Thus, the check is being obsoleted because the program already crashes earlier.


n.b.: This check is very similar to the "safe delete idiom" which sets a pointer to nullptr after deleting it (using a macro or a templated safe_delete function). Presumably, this is "safe" because it doesn't crash deleting the same pointer twice. Some people even add a redundant if(!ptr) delete ptr;.
As you know, operator delete is guaranteed to be a no-op on a null pointer. Which means no more and no less than by setting a pointer to the null pointer, you have successfully eliminated the only chance to detect double deletion (which is a program error that needs to be fixed!). It is not any "safer", but it instead hides incorrect program behavior. If you delete an object twice, the program should crash hard.
You should either leave a deleted pointer alone, or, if you insist on tampering, set it to a non-null invalid pointer (such as the address of a special "invalid" global variable, or a magic value like -1 if you will -- but you should not try to cheat and hide the crash when it is to occur).