代码扩展到多行代码和淹没在低级细节中通常会阻碍表达。但是把所有的东西都塞进一条语句里也不总是正确的做法。
作为一个例子,下面是我的同事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])中解释的。
我认为这是调用者尊重契约的责任。否则,行为是未定义的,你在这里看到的是。有些东西是有效的,有时是无效的,求值顺序只是提出这个问题的一种方式。