紧凑的代码如何变成充满bug的代码:通过求值顺序发现

本文讨论了C++中函数参数的求值顺序不确定性带来的潜在问题,通过示例展示了如何导致未定义行为或内存泄漏。强调了编译器优化与代码可读性的平衡,提出了在编写代码时避免依赖于特定求值顺序的建议。同时,提到了C++17之前的某些版本中,参数求值顺序的不确定性可能导致的错误,以及C++17对此做出的改进。文章提醒开发者注意代码的紧凑性和潜在风险,特别是在涉及智能指针和资源管理时。
摘要由CSDN通过智能技术生成

代码扩展到多行代码和淹没在低级细节中通常会阻碍表达。但是把所有的东西都塞进一条语句里也不总是正确的做法。

作为一个例子,下面是我的同事Benoît发现并修复的一个错误代码(在代码中上下文被混淆了)。感谢Benoît提出如此重要的话题。

void f(Data const& firstData, int someNumber, std::auto_ptr<Data> secondData);

std::auto_ptr<Data> data = ... // initialization of data
f(*data, 42, data);

不管有什么问题的设计,即使这段代码使用了已被弃用的std::auto_ptr,同样的问题也可以用std::unique_ptr来重现,尽管可能更显式一些:

void f(Data const& firstData, int someNumber, std::unique_ptr<Data> secondData);

std::unique_ptr<Data> data = ... // initialization of data
f(*data, 42, move(data));

你能看出这两段代码中会出现什么错误吗?

事实上,这种行为在一段时间内是正确的,直到它崩溃。当它出现问题时,它只发生在特定的平台上,并可以继续在其他平台上运行。不用说,确定问题的根源并不容易。

有一些优化的余地

问题在于传递参数给函数f。在C++中,函数参数的求值顺序是不确定的。有些编译器可以决定从左到右求值,有些从右到左求值,有些则完全不同。这在不同的编译器中是不同的,一个给定的编译器甚至可以对不同的调用位置有不同的求值顺序。

求值顺序
在上述情况下,如果参数从右到左求值,那么*data将在移动智能指针后求值。移动智能指针(或者为auto_ptr复制它),清空它,在里面留下一个空指针。访问*数据会导致未定义的行为(顺便说一句,如果你想阅读更多关于智能指针的内容,在Fluent C++上有一系列专门的文章)。

另一方面,如果参数是从左到右求值的,那么*data在智能指针被移出之前就被求值,因此它在被访问时仍然有效。

这种语言给予编译器这种自由(以及许多其他自由)的原因是允许它们进行优化。实际上,按照特定的顺序重新安排指令可能会导致更高效的汇编代码。(虽然我不怀疑这是真的,但我找不到任何具体的例子来说明这一点。有人有吗?)

修订:正如Patrice Roy所指出的,不确定的求值顺序呈现出另一个优势。确定一个命令将使在实例化参数时依靠相互关联的副作用成为可能。这将迫使我们检查函数内部这些副作用是什么,以便理解代码在做什么,这将导致代码更加复杂。

调用和子调用

事实上,参数求值的顺序可能比上面的例子更加混乱。

下面的例子摘自Scott Meyers的《Effective C++》第17项:

int priority();
void processWidget(std::shared_ptr<Widget> pw, int priority);

processWidget(std::shared_ptr<Widget>(new Widget), priority());

(我在这里冒昧地使用了std::shared_ptr,而不是这本书在C++ 11之前使用的tr1组件——但其含义没有改变)

没有指定所有参数的求值顺序。甚至是函数调用的子调用中的参数。例如,编译器可以按照以下顺序生成代码:

  • 调用new Widget,
  • 调用priority(),
  • 调用std::shared_ptr!

如果对priority的调用抛出异常,Widget将泄漏,因为它还没有存储到共享指针中。出于这个原因,Scott Meyers建议将新对象存储在智能指针中,并在独立语句中存储。但即使这样也不能在一开始就修复代码。

达到平衡

给编译器留一些空间来进行优化当然是一件好事,但是太多的自由会造成程序不相信程序员所认为的方式的风险。出于这个原因,对于开发人员来说,有必要使用一些规则在优化和易用性之间取得平衡。

有些规则在C++中一直存在,甚至在C中也存在。例如,调用&&||或者,在两个布尔值上总是先对左边求值,然后(如果有必要)对右边求值。

有些代码实际上依赖于此,例如:

void f(const int * pointer)
{
   if (pointer && *pointer != 0)
   {
       ...

在这段代码中,指针被怀疑为空,所以在解引用之前要检查它(这是否是一个好的实践还存在争议,但这是另一个争议)。这段代码依赖于这样一个事实:指针总是出现在*pointer != 0之前。否则,执行检查的目的将完全失败。

顺便说一下,出于这个原因,Scott Meyers建议不要在自定义类型上重载操作符&&、操作符||和操作符,,以便它们的行为与原生类型保持一致(参见《More Effective C++》第7项)。

同样,在表达式中

a ? b : c

很自然地,A需要在b和c之前求值。

更多现代C++规则

C++ 11、C++ 14和C++ 17增加了更多的规则来确定表达式的各个子部分的求值顺序。但是,函数参数的求值顺序仍然是未指定的。它被认为是解决这个问题的办法,但这个提议最终被否决了。

你可能想知道后来又增加了什么。事实上,在很多情况下,求值的相对顺序都很重要。以调用一个只有一个参数的函数为例。函数本身可能是求值的结果。例如:

struct FunctionObject
{
    FunctionObject() { /* Code #1 */ }
    void operator()(int value) {}
};

int argument()
{
    /* Code #2 */
}

// Main call
FunctionObject()(argument());

在C++ 17之前,代码#1和代码#2之间的相对顺序是不确定的。C++ 17通过确保在求参数之前确定要调用的函数来改变这一点。事实上,现代C++增加了相当多的新规则,可以在这里找到

注意

作为结束,我认为必须警惕使用相互依赖的参数的简洁代码,并尽可能避免使用它。实际上,一些无害的代码可能成为难以诊断的错误的来源。例如,在下面的代码行中:

a[i] = i++;

该行为在C++ 17之前是未定义的。甚至不是未指定的,未定义的。这意味着结果不限于各种可能的求值顺序。结果可以是任何东西,包括应用程序的立即(或稍后)崩溃。事实上,只有在C++ 17中,赋值操作的右边运算才需要在左边运算之前进行。

随着语言发展节奏的加快,我们可能会比以前更频繁地升级编译器,每次都要冒着改变代码生成和优化方式的风险。让我们警惕代码中的这种技巧。

评论

Andrey Upadyshev:

谢谢你的好文章。我想添加的是,在大多数情况下(所有情况从我的经验),最好通过右值引用传递unique_ptr。那么你的例子是安全的:)。但这不只是关于这个特殊的案子。通过引用传递允许被调用者决定什么时候进行实际的移动,例如,在异常情况下,被调用者可以不移动指针,调用者仍然拥有对象,可以对它做一些其他操作或其他什么。如果unique_ptr是按值传递的,这是不可能的。

Jeremy Demeule :

我不认为代码的紧凑性和求值顺序与你试图公开的问题有什么关系。
当函数以引用/指针作为形参时,它希望形参在调用期间是有效的,并且是独立的。这是默认/隐式契约。这并不是什么新鲜事,memcpy和memmove(在C99之前使用相同的原型)禁止重叠参数,并分别接受重叠参数。
这也是Herb Sutter在2015年CppCon的主题演讲(Writing Good c++ 14…By Default[幻灯片50])中解释的。
我认为这是调用者尊重契约的责任。否则,行为是未定义的,你在这里看到的是。有些东西是有效的,有时是无效的,求值顺序只是提出这个问题的一种方式。

原文

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值