effectivecpp

来源:互联网 发布:杭州开创网络 编辑:程序博客网 时间:2024/06/06 01:36

  • 将C视为一个语言联邦
  • 尽量以constenuminline替换define
    • 1 用const和enum替换不带参宏
    • 2 用inline替换带参的宏
  • 尽可能使用const
    • 1 修饰变量使之不能改变
    • 2 修饰指针
    • 3 修饰迭代器
    • 4 在类中修饰成员函数
    • 5 在const和非const成员函数中避免重复
  • 确定对象被使用前已经初始化
    • 1 内置类型
    • 2 STL
    • 3 自定义类

1 将C++视为一个语言联邦

  1. c
    C++从语法和代码风格上以C为基础,这也正是C++命名的由来,是在C的基础上++。有意思的是,在《C++ Primer》这本书里有一个问题,问为什么C++叫“C++”而不叫“++C”呢?原因很简单,C++具有深厚的C的底蕴,++C也许得到的是D,象征着另一种语言,而C++返回的还是C,表明它是站在C这个巨人的肩膀之上的
  2. 面向对象
    面向对象编程是C++不同于C的显著部分,C++引入类和对象的概念,对事物提供了很好的抽象途径,在一个class里面不仅仅可以像C中的结构体一样定义成员变量,而且可以提供方便快捷的成员函数,而不用像在C的结构体中用函数指针来实现。
  3. 模板
    C++的模板,亦即泛型编程堪称一绝,有了模板,就不用手工提供所有类型的重载版本了,而交由编译器自行生成,大大减少了代码的冗长
  4. STL
    STL是优秀的C++模板库,里面集成了大量实用的库函数,比如string,vector,list,map等(唯一可惜的是没有提供hash相关的库,同时也要留意一些接口的不一致,比如string大量使用index作为接口,而vector等其他容器则是用iterator作为接口的)

2 尽量以const,enum,inline替换#define

2.1 用const和enum替换不带参宏

宏定义#define发生在预编译期,而const,enum定义的常量发生在编译期,两者的重要差别在于编译期里的变量是进符号表的,而预编译期的宏是简单的替换,不进符号表。因此,const, enum定义的常量具有以下优势:

(1)支持类型检查

(2)支持访问权限

第(1)条优势,其实在Visual Studio编译器也已经对宏也引入了类型检查了,但不是所有的编译器都这样;第(2)条优势是指可以把这些常量放在类中,从而避免了全局的作用域,而宏定义只能是全局的(全局变量在多线程中容易出问题,一份优秀的代码里是几乎不出现全局变量的),所以这条优势其实是const和enum替换宏定义最好的理由。在书中还提到了,用const和enum定义的常量名还会出现在编译器的提示信息里,而宏定义在编译器中显示的就不是宏定义名了,而直接是一个数字量了,这样就不方便调试了。

那么什么时候用const,什么时候用enum呢?const适合于单个常量,比如double const PI = 3.1415926,而enum适合于一组相关的常量,比如星期:

enum DayOfWeek {Sunday,Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,};

这段枚举定义了Sunday = 0, Monday = 1, …, Saturday = 6(去掉DayOfWeek也行,这时就定义了匿名的枚举类型了)。

2.2 用inline替换带参的宏

不带参的宏还说得过,带参的宏本身就是一个坑,可谓是bug四伏,一个不小心,就掉坑里了。举个最简单的例子:

#define square(a) a * a
在main函数中调用时,可能会这样square(3),计算的是3的平方,这是OK的,但如果换成是square(1+2),计算的还是3的平方吗?注意这时编译器解释成1 + 2 * 1 + 2,这样2 * 1的优先级高一些,所以先做了,这就出问题了。
好的,这是常见的错误,有些程序员可能认为多加括号就行,比如:
#define square(a) (a) * (a)
这的确可以避免上面所说的优先级的问题,但万一调用时是这样写的呢?

#define squar(a) (a)*(a)int main(){int var =3;squar(++var);return 0;}

本意是想求4的平方,但编译器会翻译成(++v)*(++v),这样v就被加了两次,这个结果肯定不是你想要的!
一句话,带参的宏很容易出问题,特别是针对复合操作(一句话做了不止一件事情)时,bug频出。
解决这个问题的方法是:用函数替换!因为函数调用时,若实参是表达式,总是会先计算这个表达式的值,再去进行调用的,不会出现宏展开时的bug。

template< class T >T square(const T& v){return v * v;}

就是一种比较好的解决方案(注意这里v不用加括号了,也不用担心参数被求值多次,而且提供了可靠的作用域限制),但函数调用有一个保存现场和恢复现场的过程(本质是栈的压入和弹出),频繁地调用会导致性能低下,解决方法是在函数前面加上inline关键字,像这样:

template < class T >inline T square(const T& v){return v * v;}

这样就告诉编译器了,我想牺牲空间换时间——把这段函数体部分直接替换到原代码中,就不要保存现场和恢复现场了。但这里注意,inline并不是强制的,就算你用了inline,编译器也不一定100%地进行代码替换,比如递归函数,编译器会直接忽略掉你加的inline。所以,inline只是向编译器建议进行代码内联而已,inline适合于函数体本身短小(无递归)且频繁调用的场景。

3 尽可能使用const

3.1 修饰变量,使之不能改变

举个例子:

constintvar = 3;

此时var的值就不能改变了。也正是因为const的变量不能轻易修改存储的值,所以在声明的时候就要初始化,这样就是不行的:

constintvar;

编译器就会报错。

3.2 修饰指针

指针是特殊的变量,有时我们希望对它所指向的对象操作,而有时我们又希望对指针本身进行操作。同样,const应用于指针也有两个含义:一个是指向常量(指向的内容不可更改),一个是常量指针(指针的指向不可更改)。看下面这两个例子:

const int* p = &a; 与int const *p含义相同/* p为指向常量的指针,即p指向的对象是常量,不可以通过*p = 3 来修改a的值,但这时p = &b换个指向还是可以的 */int * const p = &a;/* p为常量指针,即p的指向不可更改,不可以通过p = &b来修改p的指向,但这时*p = 3改变a的值还是可以的 */const int* const p = &a;/* p为指向常量的常量指针,p的指向以及指向的对象都不可以更改,无论是*p = 3,还是p = &b都是错误的 */

当实参为const时,比如const char* msg = “hello”,此时fun1(msg)是可以的,但fun2(msg)会报编译错,说是无法将const char*转成char*;而当实参为普通变量时,比如char* msg = “hello”,fun1(msg)和fun2(msg)都是OK的。这是为什么呢?因为当const的变量传递给非const的变量会不安全(非const的变量可以修改原来定义为常量的东西了!),所以C++限制了这种用法(需用强制类型转换来告诉编译器,编译器才会放行);而反过来,当非const的变量传递给const变量时,不存在安全问题,所以C++编译器总是会放行的。因此,如果在函数体内确实不改变形参a的值,那么采用带const的fun1的写法会更好,适用性更强。

3.3 修饰迭代器

vector<T>::const_iterator 指向常量的迭代器。
const vector<T>::iterator表示这个迭代器的指向不可以更改,即表示的是常量迭代器。

3.4 在类中修饰成员函数

const放在类中成员函数的后面,表示这个成为函数不会修改类的成员变量,比如:

class A{private:int a;double b;public:void fun1() const;void fun2();};

注意这里的fun1()函数后面有一个const,表示这个函数不会修改类的成员变量(在它的函数体里面出现任何改变a或b的值的操作,均不能通过编译);另一方面fun2()函数后面没有const,表示这个函数可能修改类的成员变量,注意这里用的词是“可能”,fun2()可以修改也可以不修改,但为了增强安全性,所以良好的编程风格一般会把不改动成员变量的成员函数修饰为const的。有一点要切记:有无const是可以构成成员函数的重载的!

在本书中还提到了一个尖锐的问题,如果假定类是这样的:

class B{private:int* p;public:};

我们看到,类的成员函数是指针,假定它在构造函数时会被初始化,而指向一段内存空间。那么如果不改变p本身(即指向不变),但是改变了p指向的内容(比如*p = 3),这样到底算不算对成员变量进行改动了呢?
读者可以在VS环境中写一下测试用例,可以发现VS编译器对这种情况是放行的,*p = 3完全可以通过,但是p = &b就不可以了。
虽然编译器是放过你了,但这也许并不是你的本意,本书中推荐的是“从逻辑上看”,就要交由写代码的你去好好思量一下。如果在某个函数里确实改动了p所指向的内容,那么最好就不要加上const;反过来,如果加上了const就不要改变成员变量,包括它所指向的值。

3.5 在const和非const成员函数中避免重复

我觉得这是一个非常重要的内容,有没有加const是构成函数重载的,但通常这种重载的相似度很高,就用书上的例子:

class TestBlock{private:string text;public:...const char & operator[](size_t position) const{…return text[position];}char& operator[](size_t position){…return text[position];}};

可以看到两个重载函数里面的操作都是一样的,别因此认为可以用ctrl+c,ctrl+v而省事了,如果你要改动其中一个函数体里的内容,另一个就要同步更新,而万一你忘记了更新,后果是非常严重的!
一个好的方法来实现同步——在非const的函数中调用const函数!这样来修改:

char & operator[] (size_t position){return const_cast<char&> (static_cast <const TestBlock&> (*this)[postion]);}

说白了,就进行两次转换,一次是把非const的对象(就是自己(*this)转成const对象),但注意返回值要求是非const的,所以用const_cast再进行一次转换就OK了。关于C++转换可以参照本博客的

4 确定对象被使用前已经初始化

4.1 内置类型

C++中的内置基本类型,比如int,double,float等,初值都是垃圾值,即声明int i,i的初值是一个垃圾值。本书建议的最佳处理方法是:永远在使用对象之前将之初始化。比如:

int x = 0; const char* test = “hello world”; double d; cin >> d;

4.2 STL

C++提供了丰富的容器,比如vector,list,deque,map和set等,这些容器已经写好了构造函数,所以总会自动初始化成默认值,程序员可以直接使用,比如:
vector vt; vt.push_back(3);

4.3 自定义类

C++在类中有专门初始化成员变量的构造函数,程序员可以写出合适的构造函数,比如:

  class A  {  private:           int a;           double b;          string text;  public:         A():a(0), b(0), text("hello world"){} //构造函数  };

当声明A obj时,obj的成员变量a,b和text就已经获得了初值,分别是0,0和hello world。
这里注意一下,有些C++的初学者喜欢这样写:

  class A  {    private:          int a;          double b;          string text;    public:          A()          {                    a = 0;                    b = 0;                    text = "hello world";          }  };

效果虽然和上一个例子一样,都获得了指定的初值,但执行的效率却不如上个例子。上一个例子中使用了成员初始化列表的方式,即在冒号后面逐一初始化,但本例却在函数体内进行了初始化。事实上,本例其实不能严格称为“初始化”,因为在进入构造函数的函数体时,这些成员变量已经被初始化了,a和b初始化成垃圾值,string因为是STL,调用默认的构造函数初始化为空字符串,在函数体内进行的操作实为“赋值”,也就是用新值覆盖旧值。这也正是说它的执行效率不高的原因,既进行了初始化,又在之后进行了赋值,不像上一个例子,只有初始化,一步到位。在有些特殊情况下,比如成员变量是const的或者是reference的,进行初始化后值就不可以改变了,这时只能用初始化列表,不能在函数体内赋值。
所以,应该尽可能地采用有冒号的成员初始化列表。注意这里的用词“尽可能地”,表示也不是所有情况都用这个初始化列表,当想构造的内容复杂,或者已经模块化为函数了,这时不能用初始化列表的方式,就采用在函数体内赋值的方式为好。
这里还有一个问题要注意下,当使用初始化列表时,初始成员变量的顺序与列表排列的顺序没有关系,只取决于声明这些成员变量的顺序,还是那个例子,将之改成:

  class A  {  private:        int a;        double b;        string text;  public:        A():b(0), a(0), text("hello world"){}  };

虽然初始化列表的顺序是b在先,但编译器只会根据声明变量时的先后顺序,所以还是a被先初始化,想要用以下这种方式初始化的同学要当心:

  class A  {  private:           int a;           double b;           string text;  public:           A():b(0), a(b), text("hello world"){} };

这一定不会得到你想要的结果的,因为a会首先初始化,而这时b尚未初始化,所以会有bug。本书中建议,为了避免这种顺序不一致的晦涩错误,“当你在成员初值列中条列各个成员时,最好总是以声明次序为次序”。
不同编译单元内定义的non-local static 对象
这里先解释一下名词,static是静态的意思,表示这个变量不由栈分配,而存储在特有的全局变量/静态变量区域中,具有长寿命的特点(从被构造出来,直到程序结束时,才会由系统释放资源);而non-local则是说这个对象是全局的,而不是函数内的静态变量,是说它的作用范围广。本博客中
介绍了有关函数作域和生存周期的区别。
比如在文件1中定义了
int a = 1;
而在文件2中又会去使用:
extern int a;
int b = a *3;
可以看到文件1应在文件2之后执行,这样a才能获得初值,否则b得到的将是垃圾值,但事实上C++对于不同文件执行的相对次序并无明确定义,这样b究竟得到的是垃圾值还是3就不能确定。
解决这个问题是方法是不要使变量有全局的作用域,可以在文件1中定义:

 int& GetA() {      static int a = 1;      return a; }

而在文件2中调用
int b = GetA();
这样就一定保证a的初始化在先了。
总结一下

  1. 为内置型对象进行手工初始化,因为C++不保证初始化它们;
  2. 构造函数最好使用成员初始化列表(实际初始化顺序不与列表的排列顺序有关,只取决于类中的声明顺序),而不要在构造函数体内使用赋值操作;
  3. 未避免“跨编译单元的初始化次序”问题,请用local static代替non-local static对象。