《C++编程规范》四、函数与操作符


第25条 正确地选择通过值、(智能)指针或者引用传递参数

正确选择参数:分清输入参数、输出参数和输入/输出参数,分清值参数和引用参数。正确地传递参数。

选择如何传递参数时,应该遵循以下准则。

对于只输入(input-only)参数:

  • 始终用const限制所有指向只输入参数的指针和引用;
  • 优先通过值来取得原始类型(如 char、float)和复制开销比较低的值对象(如 Point、complex)的输入;
  • 优先按const的引用取得其他用户定义类型的输入;
  • 如果函数需要其参数的副本,则可以考虑通过值传递代替通过引用传递。这在概念上等同于通过const引用传递加上一次复制,能够帮助编译器更好地优化掉临时变量。

对于输出参数或者输入/输出参数:

  • 如果参数是可选的(这样调用者可以传递null表示“不适用的”或“无需关心的”值),或者函数需要保存这个指针的副本或者操控参数的所有权,那么应该优先通过(智能)指针传递
  • 如果参数是必需的,而且函数无需保存指向参数的指针,或者无需操控其所有权,那么应该优先通过引用传递。这表明参数是必需的,而且调用者必须提供有效对象。
  • 不要使用C语言风格的可变长参数(见第98条)。


第26条 保持重载操作符的自然语义

程序员讨厌意外情况:只在有充分理由时才重载操作符,而且应该保持其自然语义;如果做到这一点很困难,那么你可能已经误用了操作符重载。



第27条 优先使用算术操作符和赋值操作符的标准形式

如果要定义 a+b,也应该定义 a+=b:在定义二元算术操作符时,也应该提供操作符的赋值形式,并且应该尽量减少重复,提高效率。

一般而言,对于某个二元操作符@(可能是+、-、*等),应该定义其赋值形式,使a @= b和a = a @ b具有相同的含义(只不过第一种形式可能更高效,它只计算一次a)。实现这一目标的标准方法就是用@=来定义@,如下所示:

T& T::operator@=( const T& ) {
  // ……具体的实现代码……
  return *this;
}

T operator@( const T& lhs, const T& rhs ) {
  T temp( lhs );
  return temp @= rhs;
}

这两个函数是协同工作的。赋值形式完成实际工作并返回其左参数。非赋值形式从lhs创建一个临时变量,然后调用赋值形式修改该变量,并返回它。

请注意这里** operator@是非成员函数,因此将具有一种属性:能够同样接受左参数和右参数的隐式转换(见第44条)**。例如,如果定义了一个类String,其隐式构造函数的参数为一个char,那么将operator+( const String&, const String& )指定为非成员函数可以使得char + String和String+ char都能工作,而成员函数版本的String::operator+( const String& )只能接受后者。注重效率的实现方式可能会选择定义多个operator@的非成员重载版本,以避免出现由于转换而导致的临时变量激增的情况(见第29条)。



第28条 优先使用++和--的标准形式。优先调用前缀形式

如果定义++c,也要定义 c++:递增和递减操作符很麻烦,因为它们都有前缀和后缀形式,而两种形式语义又略有不同。定义operator++和operator--时,应该模仿它们对应的内置操作符。如果不需要原值,应该优先调用前缀版本。

对于++和--而言,后缀形式返回的是原值,而前缀形式返回的是新值。应该用前缀形式实现后缀形式。标准形式是:

T&T::operator++(){      T&T::operator--(){    // 前缀形式:
  // 执行递增           // 执行递减       // - 完成任务
  return*this;         return*this;      // - 总是返回 *this;
}                 }

T T::operator++(int){      T T::operator--(int){     // 后缀形式:
  T old(*this);           T old(*this);       // - 保存旧值
  ++*this;               --*this;           // - 调用前缀版本
  return old;                 return old;          // - 返回旧值
}                          }

在调用代码时,要优先使用前缀形式,除非确实需要后缀形式返回的原值。前缀形式在语义上与后缀形式是等价的,输入工作量也相当,只是效率会经常略高一些,因为前缀形式少创建了一个对象。这不是不成熟的优化,这是在避免不成熟的劣化(见第9条)



第29条 考虑重载以避免隐含类型转换

如无必要勿增对象(奥卡姆剃刀原理 [1] ):隐式类型转换提供了语法上的便利(但另见第40条)。但是如果创建临时对象的工作并不必要而且适于优化(见第8条),那么可以提供签名与常见参数类型精确匹配的重载函数,而且不会导致转换。

[1] .奥卡姆剃刀原理(Occam's Razor)是由 14世纪逻辑学家、圣方济各会修士、来自奥卡姆的威廉(William of Occam)提出的一个原理。奥卡姆在英格兰的萨里郡,是威廉的出生地。这个原理的英文原型就是“Entities should not be multiplied beyond necessity”(如无必要,勿增实体),也可以简化为“最简单的解释通常最佳”,它构成了简化主义的基础。更现代的变体包括所谓KISS(Keep It Simple,Stupid)原理和爱因斯坦的名言“理论应该尽量简单,但是不能太简单了”。中华文化中“大音希声”、“大象无形”和“大道至简”等说法都庶几近之。

如果你在办公室里用完了打印纸,该怎么办呢?当然了,可以到复印机那里复印几张白纸来用。
这听上去很愚蠢,但是隐式转换经常与此如出一辙:不必要地经历创建临时变量的麻烦,只是为了执行一些不重要的操作,然后就把它们丢弃(见第40条)。常见的例子是字符串比较:

class String {// ……
  String(const char*text);            // 允许隐式转换
};

bool operator==( const String&, const String& );
// ……代码中某处……
if( someString == "Hello" ) {... }

遇到如上定义后,编译器将编译比较操作,就好像我们编写了someString==String("Hello")一样。这可能很浪费,因为并不需要只为了读取而复制字符。

这一问题的解决办法很简单:定义重载以避免转换。例如:

bool operator==(const String&lhs,const String&rhs);  //#1
bool operator==(const String&lhs,const char*rhs);  //#2
bool operator==(const char*lhs,const String&rhs);  //#3

这看似有重复代码,实则只是“签名重复”而已,因为所有三个重载通常都使用相同的后端函数。这样的简单重载,使你不可能掉入不成熟的优化的陷阱(见第8条),而且提供它们只是小菜一碟,尤其是在设计程序库的时候,这时想要提前预测在性能敏感的代码中将出现哪些常见类型是很困难的。



第30条 避免重载&&、||或,(逗号)

明智就是知道何时应该适可而止:内置的&&、|| 和 ,(逗号)得到了编译器的特殊照顾。如果重载它们,它们就会变成普通函数,具有完全不同的语义(这将违反第26条和第31条),这肯定会引入微妙的错误和缺陷。不要轻率地重载这些操作符。

不能重载operator&&、operator||或operator ,(逗号)的主要原因是,无法在三种情况下实现内置操作符的完整语义,而程序员通常都会需要这些语义。说得更具体一些,内置版本的特殊之处在于:从左到右求值,而&&和||还使用短路求值。

内置版本的&&和||首先计算左边的表达式,如果这完全能够决定结果(对&&而言是false,对||而言是 true),就无需计算右边的表达式了——而且能够保证不需要。我们都非常习惯这种方便的特性了,以至于经常会让右边表达式的正确性依赖于左边表达式的成功:

Employee* e = TryToGetEmployee();
if( e && e->Manager() )
// ……

这段代码的正确性依赖于这样的事实:如果e为空,则e->Manager() 就不会进行运算。这极为常见而且非常令人满意——除非所使用的&&是重载operator&&,因为此时含有&&的表达式将转而遵循以下函数规则。

函数调用将总是在执行之前对所有参数进行求值。

函数参数的求值顺序是不确定的。(另见第31条。)

让我们来看看以上代码片段的现代版本(使用了智能指针):

some_smart_ptr<Employee> e = TryToGetEmployee();
if( e && e->Manager() )
// ……

现在,如果这段代码碰巧调用了一个重载operator&&(由some_smart_ptr或者 Employee的作者提供),虽然看起来这并没有什么问题,但是在e为空时,仍有可能(而且是灾难性地)调用e->Manager()。

另一些代码即使存在这样的立即求值问题,也不会引起核心转储,但是如果它也依赖于两个表达式的求值顺序的话,那么会由于其他原因而不能正确运行。其效果当然也可能是有害的。考虑下面的代码:

if( DisplayPrompt() && GetLine() )
// ……

如果operator&&是用户定义的操作符,那么DisplayPrompt和GetLine哪一个先调用是不确定的。程序可能将无可避免地出现这样的运行结果:在显示解释性的提示信息之前,就等待用户输入。

当然,这样的代码可能看上去能够通过当前编译器和构建设置。但它仍是脆弱的。编译器能够(而且的确会)选择最适合某次调用的顺序,考虑诸如所生成的代码大小、可用寄存器、表达式复杂性等因素。因此,同样的调用,其表现可能会因为编译器版本、编译器开关设置甚至调用周围的语句的不同而不同。

逗号操作符也存在同样的脆弱性。与&&和||一样,内置逗号保证其表达式是从左到右求值的(与&&和||不同的是,它总是要对两个表达式都求值)。用户定义的逗号操作符无法保证从左到右求值,通常会产生出乎意料之外的结果。例如,如果以下代码调用的是用户定义的逗号操作符,则无法确定g得到的值是0还是1。

int i = 0;
f(i++),g(i);           // 另见第31条

示例

例 用带有重载 operator, 的初始化库,用于对序列进行初始化。这个程序库试图通过重载逗号操作符,简化了向容器中增加多个值的操作,只需一条语句。例如,要在一个vectorletters中添加值:

set_cont(letters)+="a","b";      // 有问题

这看起来似乎不错,但是一旦调用者这样编写时问题就出来了:

set_cont(letters)+=getstr(),getstr();  // 使用重载的逗号操作符时顺序不确定

如果getstr要执行一些操作,比如获取用户控制台输入,而用户依序输入了字符串"c"和"d",那么实际上可能会按任意顺序追加字符串。这当然很奇怪,因为内置的序列operator,不会出现这种问题。

string s;s==getstr(),getstr();     // 使用内置的逗号操作符时,顺序是确定的


第31条 不要编写依赖于函数参数求值顺序的代码

保持(求值)顺序:函数参数的求值顺序是不确定的,因此不要依赖具体的顺序。

在C语言的早期,处理器中的寄存器是一种宝贵的资源,为了给高级语言中的复杂表达式高效地分配寄存器,编译器承受着很大压力。为了能够生成更快的代码,C语言的创造者们赋予寄存器分配器额外的自由度:在调用函数时,其参数的求值顺序是悬而未定的。这种动机对于今天的处理器而言当然已经不那么重要了(这么说恐怕还有争议),但在 C++中求值顺序不确定仍然是事实,而且因为编译器的不同具体情况变化也很大(另见第30条)。

这种情况会使粗心大意的人遇上大麻烦。考虑下面的代码:

void Transmogrify( int, int );
int count = 5;
Transmogrify(++count,++count);     // 求值顺序未知

我们惟一能够确定的,就是运行一旦进入Transmogrify的函数体,count的值就会变为7——但是我们不知道它的参数哪个是6,哪个是7。这种不确定性也会发生在不那么明显的情况里,比如要附带修改参数(或者某个全局状态)的函数:

int Bump( int& x ) {return ++x; }
Transmogrify(Bump(count),Bump(count));  // 仍然未知

按照第10条中的叙述,应该首先避免使用全局变量和共享变量。但是即使不使用这些变量,其他人的代码也可能会使用。

例如,某些标准函数就会执行这种附带操作(比如strtok,以ostream为参数的operator<<的各种重载)。

这种问题解决起来其实很简单——使用命名对象控制求值顺序(见第13条)。

int bumped = ++count;
Transmogrify(bumped,++count);     //ok
posted @   guanyubo  阅读(11)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示