编写高质量代码:改善C++程序的150个建议(十三)

建议25:尽量用const、enum、inline替换#define

  在建议4中,我们已经详细说明了在使用宏时应注意的一些问题。“表面似和善、背后一长串”绝对是对宏的形象表述。宏的使用具有一些优点:能减少代码量(比如简单字符替换重复的代码),在某种程度上提供可阅读性(比如MFC的消息映射),提高运行效率(比如没有函数调用开销)。

  然而谈到宏,绝对绕不开预处理器。把C/C++源码从源文件的形式变成可执行的二进制文件通常需要三个主要步骤:预处理→编译→链接。在预处理阶段,预处理器会完成宏替换。因为此过程并不在编译过程中进行,所以难以发现潜在的错误及其他代码维护问题,这会使代码变得难以分析,繁于调试。所以,宏—这个C语言中的“大明星”在C++的世界里却变成了程序员深恶痛绝的东西。因为#define 的内容不属于语言自身的范畴,所以C++设计者为我们提供了替代宏的几大利器,建议我们尽量使用编译器管制下的const、enum、inline来实现#define的几大功能。如此看来,本建议的名称换做“尽量把工作交给编译器而非预处理器”或许更合适。

  接下来分析一下#define的弊端,请看下面的代码片段:

#define PI 3.1415926

  在预处理阶段,预处理器就完成了代码中符号PI的全部替换,因为这个过程发生在源代码编译以前,所以编译器根本接触不到PI这个符号名,这个符号名更不会被编译器列入到符号表中。如果因为在代码中使用了这个常量PI而引起问题,那这个错误将可能变得不易察觉,难以找到问题,出错信息只会涉及3.1415926,对PI则只字未提。

  如果PI是在某个大家并不熟悉的或出自别人之手的头文件中定义的,那么寻找数值3.1415926的出处就如同大海捞针,费时费力。不过这一切也并非是不可避免的,解决的办法很简单,就是“使用常量来代替宏定义”:

const double PI = 3.1415926;

  作为语言层面的常量,PI肯定会被编译器看到,并且会确保其进入符号表中,也就不会出现类似“3.1415926有错误”这样模糊不清的错误信息了。当出现问题时,我们也会有章可循,可以通过符号名顺藤摸瓜,消灭错误。另外,使用常量可以避免目标码的多份复制,也就是说生成的目标代码会更小。这是由于预处理器会对目标代码中出现的所有宏 PI复制出一份3.1415926,而使用常量时只会为其分配一块内存。

  在使用普通常量时,有一种特殊情形会让我们感觉棘手,那就是常量指针。用const去修饰指针的方式有多种,诸如:

  1. const char* bookName = "150 C++ Tips";  
  2. char* const bookName = "150 C++ Tips";  
  3. const char* const bookName = "150 C++ Tips";

  应该使用哪一种方式确实是一个需要明确的问题。const修饰指针的规则可以简单地描述为:如果const出现在*左边,表示所指数据为常量;如果出现在*右边,表示指针自身是常量。需要注意的是,在头文件中定义常量指针时,是将指针声明为const了,而不是指针指向的数据。所以,如果定义一个指向常量字符串的常量指针,我们选择的就是最后一种,需要用两个const进行修饰。然而在定义指向常量字符串的常量指针时,用两个const修饰并不是我们推荐的形式。我们推荐使用更加安全、更加高级的const string形式:

const string bookName("150 C++ Tips");

  作为C++中最重要的概念,class与很多其他的关键字都产生了联系,const也肯定不会放过纠缠这个C++主角的机会,所以就有了常量数据成员。定义常量数据成员的主要目的就是为了将常量的作用域限制在一个特定的类里,为了让限制常量最多只有一份,还必须将该常量用static进行修饰,例如:

  1. class CStudent  
  2. {  
  3. private:  
  4.      static const int NUM_LESSONS = 5; //声明常量  
  5.      int scores[NUM_LESSONS];          //使用常量  
  6. };

  注意,上述注释中说的是“声明常量”,而非“定义常量”,并且在声明的同时,完成了“特殊形式”的初始化。之所以谓之“特殊形式”,是因为我们熟悉的一般形式的初始化是不允许放在声明里的。这种“特殊形式”的初始化在C++中被称为“类内初始化”。还有一点需要明确的是,在不同的编译器中对类内初始化的支持情况也不尽相同。在VC++ 2010中,并不是所有的内置类型都可以实现类内初始化,它只对整数类型(比如int、char、bool)的静态成员常量才有效。如果静态成员变量是上述类型之外的其他类型,如double型,那么需要将该类的初始化放到其实现文件该变量的定义处,如下所示:

  1. /* VC++ 2010 */  
  2. // CMathConstants声明文件(.h)  
  3. class CMathConstants  
  4. {  
  5. private:  
  6.      static const double PI;  
  7. };  
  8. // CMathConstants实现文件(.cpp)  
  9. const double CMathConstants::PI = 3.1415926;

 而在GCC编译器中,内置的float、double类型的静态成员常量都可以采用类内初始化,如下所示:

  1. /* Gcc 4.3 */  
  2. // CMathConstants声明文件(.h)  
  3. class CMathConstants  
  4. {  
  5. private:  
  6.      static const double PI = 3.1415926;  
  7. };

  当然,如果不习惯类内初始化,讨厌其破坏了静态成员常量声明、定义的统一形式,可以选择将类内初始化全部搬到类实现文件中去,这也是我们比较推荐的形式。更何况早期的编译器可能不接受在声明一个静态的类成员时为其赋初值,那又何必去惹这些不必要的麻烦呢?

  另外,如果编译器不支持类内初始化,而此时类在编译期又恰恰需要定义的成员常量值,身处如此左右为难的境地,我们该采取怎样的措施?那就求助于enum!巧用enum来解决这一问题。这一技术利用了这一点:枚举类型可以冒充整数给程序使用。代码如下所示:

  1. // CStudent声明文件(.h)  
  2. class CStudent  
  3. {  
  4. private:  
  5.      enum{ NUM_LESSONS = 5 };  
  6.      int scores[NUM_LESSONS];  
  7. };

  需要说明的一点是,类内部的静态常量是绝对不可以使用#define来创建的,#define的世界中没有域的概念。这不仅意味着#define不能用来定义类内部的常量,同时还说明它无法为我们带来任何封装效果。

  #define的另一个普遍的用法是“函数宏”,即将宏定义得和函数一样,就像建议4中的:

  1. #define ADD( a, b )  ((a)+(b))  
  2. #define MULTIPLE( a, b )  ((a)*(b))

  这样的“函数宏”会起到“空间换时间”的效果,用代码的膨胀换取函数调用开销的减少。这样的宏会带来数不清的缺点,建议4中已经说得很清晰。如果使用宏,必须为此付出精力,而这是毫无意义的。幸运的是,C++中的内联函数给我们带来了福音:使用内联函数的模板,既可以得到宏的高效,又能保证类型安全,不必为一些鸡毛蒜皮的小问题耗费宝贵的精力。

  1. template<typename T> 
  2. inline T Add(const T& a, const T& b)  
  3. {  
  4.      Return (a+b);  
  5. }  
  6.  
  7. template<typename T> 
  8. inline T Multiple(const T& a, const T& b)  
  9. {  
  10.      Return (a*b);  
  11. }

  这一模板创建了一系列的函数,方便高效,而且没有宏所带来的那些无聊问题。与此同时,由于Add和Multiple都是真实函数,它也遵循作用域和访问权的相关规则。宏在这个方面上确实是望尘莫及。

  虽然建议尽量把工作交给编译器而非预处理器,而且C++也为我们提供了足以完全替代#define的新武器,但是预处理器并未完全退出历史舞台,并没有完全被抛弃。因为#include在我们的C/C++程序中依旧扮演着重要角色,头文件卫士#ifdef/#ifndef还在控制编译过程中不遗余力地给予支持。但是如果将来这些问题有了更加优秀的解决方案,那时预处理器也许就真的该退休了。

  请记住:

  对于简单的常量,应该尽量使用const对象或枚举类型数据,避免使用#define。对于形似函数的宏,尽量使用内联函数,避免使用#define。总之一句话,尽量将工作交给编译器,而不是预处理器。

建议26:用引用代替指针

  指针,可以通向内存世界,让我们具备了对硬件直接操作的超级能力。C++意识到了强大指针所带来的安全隐患,所以它适时地引入了一个新概念:引用。引用,从逻辑上理解就是“别名”,通俗地讲就是“外号”。在建立引用时,要用一个具有类型的实体去初始化这个引用,建立这个“外号”与实体之间的对应关系。

  对于引用的理解与使用,主要存在两个的问题:

  它与指针之间的区别。

  未被充分利用。

  引用并非指针。引用只是其对应实体的别名,能对引用做的唯一操作就是将其初始化,而且必须是在定义时就初始化。对引用初始化的必须是一个内存实体,否则,引用便成为了无根之草。一旦初始化结束,引用就是其对应实体的另一种叫法了。与指针不同,引用与地址没有关联,甚至不占任何存储空间。代码如下所示:

  1. int  iNum = 12;  
  2. int &rNum = iNum;  
  3. int *pNum = &rNum;    // 等同于int *pNum = & iNum;  
  4. iNum = 2011;          // rNum的值也为2011

  由于引用没有地址,因此就不存在引用的引用、指向引用的指针或引用的数组这样的定义。据说尽管C++标准委员会已经在讨论,认为应在某些上下文环境里允许引用的引用。但那都是将来的事,至少现在不可以,将来的事谁又说得准呢?

  因为是别名,与实体所对应,所以引用不可能带有常量性和可挥发性。所以,下面的代码在编译时会出现问题:

  1. int r = 10;  
  2. int & volatile s = r;  
  3. int & const m = r;  
  4. volatile int& t = r;  
  5. const int& n = r;

  之所以说是出现问题,而不是错误,最主要的原因是各厂商编译器对于上述语法的容忍程度不同,有的会直接抛出错误并且编译失败,有的却只给出一个警告,比如:

  gcc 4.3给出错误

error: volatile/const限定符不能应用到'int&'上

  VC++ 2010给出警告

warning C4227: 使用了记时错误: 忽略引用上的限定符

  对于加在引用类型前面的const或volatile修饰词,它们是符合编译器规则的,或者说被编译器选择性忽略了,没有什么问题(无error或warning)。而对于指针,上述使用绝对不存在任何的问题。

  C阵营中那帮“顽固派”习惯在C++工程里使用指针,并且以此为傲,现在该是为引用翻身的时候了。先看一个简单的示例:

  1. void SwapData1(int a, int b)  
  2. {  
  3.      int temp = a;  
  4.      a = b;  
  5.      b = temp;  
  6. }  
  7.  
  8. void SwapData2(int* a, int* b)  
  9. {  
  10.      int temp = *a;  
  11.      *a = *b;  
  12.      *b = temp;  
  13. }  
  14.  
  15. void SwapData3(int& a, int& b)  
  16. {  
  17.      int temp = a;  
  18.      a = b;  
  19.      b = temp;  
  20. }

 上述代码要实现的功能极为简单,就是交换两个数据的值。SwapData1()是不能实现所设定的功能的,主要原因是函数内交换的只是实参的副本;而SwapData2()和SwapData3()则正确地实现了作者意图,其汇编代码如下:

  1.      SwapData1(a,b);  
  2. 00F431EC  mov         eax,dword ptr [b]  
  3. 00F431EF  push        eax  
  4. 00F431F0  mov         ecx,dword ptr [a]  
  5. 00F431F3  push        ecx  
  6. 00F431F4  call        SwapData1 (0F4114Fh)  
  7. 00F431F9  add         esp,8  
  8.      SwapData2(&a,&b);  
  9. 00F431FC  lea         eax,[b]  
  10. 00F431FF  push        eax  
  11. 00F43200  lea         ecx,[a]  
  12. 00F43203  push        ecx  
  13. 00F43204  call        SwapData2 (0F411EFh)  
  14. 00F43209  add         esp,8  
  15.      SwapData3(a,b);  
  16. 00F4320C  lea         eax,[b]  
  17. 00F4320F  push        eax  
  18. 00F43210  lea         ecx,[a]  
  19. 00F43213  push        ecx  
  20. 00F43214  call        SwapData3 (0F4101Eh)  
  21. 00F43219  add         esp,8

  正如汇编代码中所示的那样,SwapData2()和SwapData3()其实是一样的,都是对源数据进行操作。但是相较于SwapData2()而言,SwapData3()的实现代码更加简洁清晰。这就是引用在传递函数参数时所具有的巨大优势。

  再来看函数返回值方面,如果其返回值是引用类型,那么就意味着可以对该函数的返回值重新赋值。就像下面代码所示的数组索引函数一样:

  1. template <typename T, int n> 
  2. class Array  
  3. {  
  4.    public:  
  5.        T &operator [](int i)  
  6.          {  
  7.            return a_[i];  
  8.        }  
  9.        // ...  
  10.    private:  
  11.        T a_[n];  
  12. };  
  13.  
  14. Array<int, 10> iArray;  
  15. for(int i=0; i<10; i++)  
  16.          iArray[i] = i*2;

  当然,上述代码可以使用指针重新实现,并且可以保证实现相同的功能,但是相比指针实现版,引用返回值使对数组索引函数的操作在语法上颇为自然,更容易让人接受。

  也许有人认为,指针功能更强大,因为还有指向数组的指针、指向函数的指针,这里我要说的是,引用同样可以。引用在指向数组时还能够保留数组的大小信息,关于这方面的内容,在此就不多讲了。

  请记住:

  从编码实践角度来看,指针和引用并无太多不同。在大多情况下,指针可由索引类型完美代替,并且其实现代码更简洁清晰,更加易于理解。



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值