《C++覆辙录》——1.7:无视基础语言的精妙之处

1.7:无视基础语言的精妙之处

大多数C++软件工程师都自信满满地认为自己对所谓C++的“基础语言”,也就是C++继承自C语言的那部分了如指掌。实际情况是,即使经验丰富的C++软件工程师,有时也会对最基础的C/C++语句和运算符的某些妙用一无所知。

逻辑运算符不能算难懂,对吗?但刚入行的C++软件工程师却总是不能让它们物尽其用。你看到下面的代码时是不是会怒从胆边生?

bool r = false;
if( a < b )
  r = true;```
正解如下:

bool r = a`

int ctr = 0;
for( int i = 0; i < 8; ++i )
  if( options & 1<<(8+i) )
    if( ctr++ ) {
      cerr << "Too many options selected";
      break;
    }```
何必这样如此费心地逐位比较?你忘记位屏蔽算法了吗?

typedef unsigned short Bits;
inline Bits repeated( Bits b, Bits m )
  { return b & m & (b & m)-1; }
// ...
if( repeated( options, 0XFF00 ) )
  cerr << "Too many options selected";`
咳,现在的年轻人都怎么了,连这点布尔逻辑常识都没能好好掌握。

还有,很多软件工程师都把“如果条件运算符表达式的两个选择结果都是左值,那么这个表达式本身就是个左值”这回事儿抛在脑后了(有关左值的讨论,参见常见错误6)。所以必然有些人就会写出如此代码:

// 版本1
if( a < b )
  a = val();
else if( b < c )
  b = val();
else
  c = val();
// 版本2
a```
而对C++怀有正确观念的熟手稍加点化,上述代码马上变得短小精悍,简直酷毙了:

// 版本3
(a`
如果你觉得这个貌似武林秘笈的小贴士似乎只是和布尔逻辑毫不相干的花拳绣腿,那么我得提醒你,在C++代码的很多上下文(比如构造函数的成员初始化列表,或抛出异常时throw表达式,等等)中,除了表达式别无选择。

另有一点需要特别引起重视,就是在前两个版本中,val这个实体出现了不止一次,而在最后一个版本里,它只出现了一次。要是val是个函数的名字,那还好说。如果它是个函数宏,它的多次出现就极有可能带来非预期的副作用(常见错误26有更详细的讨论)。这种场合下,使用条件表达式而非if语句就并非可有可无的细节了。说实在的,我也不甚提倡这种结构被普遍使用,但我确实要大声呼吁这种结构要被普遍了解。对于想晋级专家级C++ 软件工程师的人们而言,这种用法必须在它能够大显身手时成为能够想到的工具之一。这也解释了它何以没有从C语言中被去掉而成为C++语言的一部分。

让人惊讶的是,像内建的索引运算符居然也经常被误解。我们都知道数组的名字和指针都能够使用索引运算符。

int ary[12];
int *p = &ary[5];
p[2] = 7;```
此内建的索引运算符只是对于某些指针算术和提领运算符的一种简写法。像上面p[2]这个表达式和`*(p+2)`是完全等价的。从C的年代就一直在摸爬滚打的C++软件工程师都知道索引运算符的操作数可以是负数,所以p[-2]这样的表达式是有合式定义的,它不过就等价于`*(p-2)`,如果你愿意写成`*(p+-2)`也没问题。不过,似乎不是每个工程师都学好了加法交换律,否则为什么好多C++软件工程师看到下面这个表达式会吃惊不小?

 `(-2)[p] = 6;`
背后的变换极为平凡:p[-2]等价于`*(p+-2)`,后者等价于`*(-2+p)`,而`*(-2+p)`不就等价于`(-2)[p]`吗(上式中的圆括号不能省略,因为索引运算符的优先级比单目减法运算符要高)?

这究竟有何值得一提?的确值得一提!首先,此交换律仅适用于内建的索引运算符。所以,当我们看到形如6[p]的表达式时,我们就知道这里的索引运算符是内建的而不是用户自定义的(尽管p可能并不是指针或数组名)。还有,这样的表达式是你在鸡尾酒会上显摆的好谈资。当然了,在你一时冲动地把这种语法用到产品代码中之前,还是先静下心来看看常见错误11为妙。

大多数C++软件工程师都觉得`switch`语句是非常基础的。可惜他们不知道它能基础到何种地步。其实`switch`语句的形式语法就是如此平凡:
`
switch( expression ) statement`
这平凡无奇的语法却能导出出人意料的推论。

典型情况是,`switch`表达式后面跟着一个语句区块。在这个语句区块里有一系列`case`标记的语句,然后根据计算决定跳转到这个语句区块的某个语句处执行。`C/C++`新手遇到的第一块绊脚石就是“直下式”(`fallthrough`)计算。也就是说和绝大多数语言不同的是,switch语句根据表达式的计算结果把执行点转到相应的`case`语句以后,它就甩手不管了。接下来执行什么,完全是软件工程师的事:

switch( e ) {
default:
theDefault:
  cout << "default" << endl;
  // 直下式计算
case 'a':
case 0:
  cout << "group 1" << endl;
  break;
case max-15:
case Select<(MAX>12),A,B>::Result::value:
  cout << "group 2" << endl;
  goto theDefault;
}`
如果是有意去利用直下式计算的话——更多的人可能是由于疏忽才不小心让直下式计算引起了错误的执行流——我们习惯上要在适当的地方加上注释,提醒将来的维护工程师,我们这里使用直下式计算是有意为之的。不然,维护工程师就会像条件反射一样以为我们是漏掉了break语句,并给我们添上,这样就错了。

记住,case语句的标签必须是整型常量性的表达式。换句话说,编译器必须能在编译期就算出这些表达式的值来。不过从上面这个例子你也应该能够看出,这些常量性的表达式能够用多么丰富多彩的写法来书写。而switch表达式本身一定是整型,或者有能够转换到整型的其他型别也可以。比如上面这个e就可以是个带有型别转换运算符的、能够转型到整型的class对象。

同样要记住,switch语句的平凡语法暗示着我们能够把语句区块写成比上面的例子更非结构化的形式。在switch语句里的任何地方都能用case标记,而且不一定要在同一个嵌套层级里:

switch( expr )
  default:
  if( cond1 ) {
    case 1: stmt1;
    case 2: stmt2;
  }
  else {
    if( cond2 )
      case 3:stmt2;
    else
      case 0: ;
  }```
这样的代码看起来有点傻(容我直言,确实很傻),但是这种对于基础语言边角部分的理解,有时候相当有用。比如利用上述的`switch`语句的性质,就曾在C++编译器中做出了一个复杂数据结构内部迭代的有效 实现:

1.jpg gotcha07/iter.cpp

bool Postorder::next() {
 switch( pc )
 case START:
 while( true )
   if( !lchild() ) {
   pc = LEAF;
   return true;
   case LEAF:
       while( true )
         if( sibling() )
          break;
         else
          if( parent() ) {
            pc = INNER;
            return true;
   case INNER: ;
         }
         else {
           pc = DONE;
   case DONE:    return false;
         }
    }
}`
在上述代码中,我们使用了switch语句低级的、少见的语义来实现了树遍历操作。

每当我使用上面这样的结构时,我总能收到强烈的、负面的甚至是骂骂咧咧的反应。而且我确实同意这种代码可不适合给维护工程师中的新手来打理,但是这样的结构——尤其是封装良好的、文档化了的版本——确实在对性能要求甚高或非常特殊的编码中有自己的一席之地。一句话,对基础语言难点的熟练掌握会对你大有裨益。

时间: 2024-09-20 06:15:05

《C++覆辙录》——1.7:无视基础语言的精妙之处的相关文章

《C++覆辙录》——导读

前言 C++覆辙录 本书之渊薮乃是近20年的小小挫折.大错特错.不眠之夜和在键盘的敲击中不觉而过的无数周末.里面收集了普遍的.严重的或有意思的C++常见错误,共计九十有九.其中的大多数,(实在惭愧地说)都是我个人曾经犯过的. 术语"gotcha"1有其云谲波诡的形成历史和汗牛充栋的不同定义.但在本书中,我们将它定义为C++范畴里既普遍存在又能加以防范的编码和设计问题.这些常见错误涵盖了从无关大局的语法困扰,到基础层面上的设计瑕疵,再到源自内心的离经叛道等诸方面. 大约10年前,我开始在

《C++覆辙录》——常见错误1:过分积极的注释

第1章 基础问题 C++覆辙录 说一个问题是基础的,并不就是说它不是严重的或不是普遍存在的.事实上,本章所讨论的基础问题的共同特点比起在以后章节讨论的技术复杂度而言,可能更侧重于使人警醒.这里讨论的问题,由于它们的基础性,在某种程度上可以说它们普遍存在于几乎所有的C++代码中. 常见错误1:过分积极的注释 很多注释都是画蛇添足,它们只会让源代码更难读,更难维护,并经常把维护工程师引入歧途.考虑下面的简单语句: a = b; // 将b赋值给a 这个注释难道比代码本身更能说明这个语句的意义吗?因而

《C++覆辙录》——第2章 语法问题2.1:数组定义和值初始化的语法形式混淆

第2章 语法问题 C++覆辙录C++语言的语法和词法结构博大精深.此复杂性的一部分是从C语言那里继承而来的,另一部分则是为支撑某些特定的语言特性所要求的. 本章中我们将考察一组语法相关的头疼问题.其中有些属于常见的手误,但是错误的代码仍然能够通过编译,只不过会以出人意料的方式运行罢了.另外一些则是由于一段代码的语法结构及它们的运行期行为不再互为表里.其余的部分,我们主要研究语法层面的灵活余地带来的问题:明明是一字不差的代码,不同的软件工程师能从中得出大相径庭的结论来. 2.1:数组定义和值初始化

《C++覆辙录》——1.9:使用糟糕的语言

1.9:使用糟糕的语言 当一个更大的世界入侵了C++社群原本悠然自得的乐土之时,它们带来了一些足堪天谴的语言和编码实践.本节乃是为了厘清返璞归真的C++语言所使用的正确适当.堪称典范之用语和行为. 用语 表1-1列出了最常见的用语错误,以及它们对应的正确形式. 表1-1 常见用语错误及其对应正确用语 没有什么所谓"纯虚基类".纯虚函数是有的,而包含有或是未能改写(override)此种函数的类,我们并不叫它"纯虚基类",而是叫它"抽象类". C+

《C++覆辙录》——2.6:声明饰词次序的小聪明

2.6:声明饰词次序的小聪明 就语言本身所限,声明饰词孰先孰后纯属无关紧要的形而上之争:`int const extern size = 1024; // 合法,但有离奇不经之嫌 `无论如何,如果没有令人信服的理由去背离习惯用法,那么顶好还是接受有关声明饰词次序事实上的标准:先写连接饰词,再写量化饰词,再写型别. extern const int size = 1024; // 正常下面这个指针的型别是什么呀? int const *ptr = &size; ``` 对,这是一个指涉到常量整数型

《C++覆辙录》——1.5:对引用的认识误区

1.55:对引用的认识误区 对于引用的使用,主要存在两个常见的问题.首先,它们经常和指针搞混.其次,它们未被充分利用.好多在C++工程里使用的指针实际上只是C阵营那些老顽固的杰作,该是引用翻身的时候了. 引用并非指针.引用只是其初始化物的别名.记好了,能对引用做的唯一操作就是初始化它.一旦初始化结束,引用就是其初始化物的另一种写法罢了(凡事皆有例外,请看常见错误44).引用是没有地址的,甚至它们有可能不占任何存储: int a = 12; int &ra = a; int *ip = &r

《C++覆辙录》——1.3:全局变量

1.3:全局变量 很难找到任何理由去硬生生地声明什么全局变量.全局变量阻碍了代码重用,而且使代码变得更难维护.它们阻碍重用是因为任何使用了全局变量的代码就立刻与之耦合,这使得全局变量一改它们也非得跟着改,从而使任何重用都不可能了.它们使代码变得更难维护的原因是很难甄别出哪些代码用了某个特定的全局变量,因为任何代码都有访问它们的权限. 全局变量增加了模块间的耦合,因为它们往往作为幼稚的模块间消息传递机制的设施存在.就算它们能担此重任,从实践角度来说8,要从大型软件的源代码中去掉任何全局变量都几乎不

《C++覆辙录》——1.4:未能区分函数重载和形参默认值

1.4:未能区分函数重载和形参默认值 函数重载和形参默认值之间其实并无干系.不过,这两个独立的语言特征有时会被混淆,因为它们会模塑出语法上非常相像的函数用法接口.当然,看似一样的接口其背后的抽象意义却大相径庭: class C1 { public: void f1( int arg = 0 ); // ... };``` // ... C1 a; a.f1(0);a.f1();`型别C1的设计者决定给予函数f1()一个形参的默认值.这样一来,C1的使用者就有了两个选择:要么显式地给函数f1()一

《C++覆辙录》——1.8:未能区分可访问性和可见性

1.8:未能区分可访问性和可见性 C++语言压根儿没有实现什么数据隐藏,它实现了的是访问层级.在class中具有protected和private访问层级并非不可见,只是不能访问罢了.如同一切可见而不可及的事物一样(经理的形象跃入脑海),他们总是惹出各种麻烦. 最显而易见的问题就是即使是class的实现仅仅更改了一些貌似不可见的部分,也会带来必须重新编译代码的苦果.考虑一个简单的class,我们为其添加一个新的数据成员: class C { public: C( int val ) : a_(