Effective Cpp 条例解析

Effective 条例解析

该文主要对《Effective C++》一书中的条例进行解析,主要解析为什么要强调这样的编程准测,以及可能出错的地方。必要的时候给出demo

一 、让自己习惯C++

1.1 视C++为一个语言联邦

不要把C++当作某一种特定的编程语言,说到底C++是一种多范式编程语言。可以说C++是四个“子语言”的集合。这四种“子语言”或者说“次语言”包括:

  • C语言。C++是以C语言为基础的。它兼容C语言,可以说C++对为题的解法不过是高级的C语言解法。
  • Object-Oriented C++.这部分实际上就是面向对象范式的部分,也是C with Class的诉求
  • Template C++ 该部分是范型编程的部分。
  • STL。 这是一个工具集合,使用C++避免不了使用STL。这时候就需要遵守STL的编程规约。

1.2 尽量以const、enum、inline 替换define

理由如下:

  • 使用define定义的所谓“常量”实际上没有被编译器看到,没有进入符号表,而是在预处理阶段被移除。这样编译错误时由于编译器没有看到符号,难以给出具体提示,从而难以定位错误。
  • 使用enum时可以避免向该枚举取地址。
  • 在使用宏函数的时候需要小心翼翼处理参数,比如说必须加括号保证参数的完整性。例如
//定一个乘法宏函数
#define MUL(x, y) (x*y)
//...中间省略若干代码
int L = 10;
int R = 10;
MUL(L+1,R);//展开宏是 L+1*R = 20

//使用inline
template<class T>
inline T MUL(T x, T y){
    return x*y;
}
//...省略代码若干
MUL(L+1, R);//返回值110

1.3 尽可能使用const

使用const可以为自己的代码施加更强有力的约束,减少出错的机会,增加代码的健壮性。这是一条编程的普遍条例,我们尽可能地约束代码,尽可能减少不需要的操作和属性。但我们确定一个符号不允许被修改的时候,即只读,应当将对变量只读属性的约束交给编译器去完成,而不是依靠程序员自觉遵守“不修改该符号”的规则。
除了C语言中const一致的内同之外注意以下几点
+ const 修饰的是符号本身。如果对于一个符号没有赋值等显式的写操作,那么就是允许的,这样就可以通过对该符号的引用或者指将其修改.【注:实际上现在的编译器已经能处理这种情况了】

//下面这段代码在过去的情况下是允许的,但现在的编译器会报错
const int intArray[] = {1, 3, 5, 7, 9};
int *intPtr = &intArray[0];
  • 对于一个可能在const成员函数中修改的变量,应该声明成multible.
class constTest{
public:
    int &getData() const {return data;}
private:
    int data;
};
/*上面这个类的声明是错误的,以为当getData的声明为一个const成员函数的时候,其返回值为int。这时相当于使用一个const int data去初始化构造一个int 匿名引用返回值,这显然是违法的。*/
class constTest{
public:
    int &getData() const {return data;}
private:
    mutible int data;
};
/*上面这个声明是合法的,尽管getData声明了const属性,但是由于mutible int data的声明,使得即使data在getData const成员函数中,仍然不具有const属性。*/
  • 使用一个const 成员来实现一个非const成员,可以减少代码重复。
class TextBlock{
public:
    //...省略代码若干
    const char &operator[](std::size_t position) const{  //一个const成员
        return text[position];
    }

    char &operator[](std::size_t position){
        //为了返回non-const char&而转型
        return const_cast<char &>
               //为了能调用const版本的operator[]进行转型
                (static_cast<const TextBlock &>(*this)
                                                [position]);
    }
}

必须要注意的是,不能使用non-const版本的成员函数实现const版本的成员函数,因为一个const版本的成员函数承诺了不修改对象,而在调用non-const版本的时候有可能修改对象。

1.4 确保对象在使用前已经被初始化

  • 这条条例强调,除了内置类型以外的其他成员,类的构造函数都有义务对它的所有成员进行初始化。

注: 事实上,当class B包含了一个class A成员,而class A成员具有默认构造函数,这时候如果class B中那些没有显式为class A初始化的构造函数都会插入对class A默认构造函数的调用,这是符合语意的:当一个class A被声明定义的时候它的默认构造函数就会被调用,即使是在class B中声明的.例如:

class A{
public :
    A():data(10){std::cout << "A constructor" <<std::endl;}
private:
    int data;
};

class B{
public:
    B():data(12){std::cout << "B constrcutor" << std::endl;}
private:
    int data;
    A a;
};

在以上代码中,class B包含了一个A a成员,此时虽然class B的默认构造函数没有显式调用class A的默认构造函数,但编译器会插入调用class A的默认构造函数的代码。所以当写下new B时,事实上会输出:
A constructor
B constrcutor
但是让编译器自动插入代码这是非常不好的习惯。我个人并不推荐你冒这样的风险,初始化A成员是B类的任务,它必须显示执行。如:

class B{
public:
    B():data(12), a(){std::cout << "B constrcutor" << std::endl;}
private:
    int data;
    A a;
};
  • 注意不同编译单元内的non-local static对象初始化
    这是比较难以发现的一点。考虑以下例子
//FileSystem.cpp
class FileSystem{
public:
    //...
    std::size_t numDisks() const;
    //...
}

//Directory.cpp
extern FileSystem tfs;
class Directroy{
public:
   //...
   Directory(){
        std::size_t disks = tfs.numDisks();
   };
   //...
}
//main.cpp
Directory tmpDir; //这里可能出现问题

这里的问题在于,当用户写下Directory tmpDir这样的语句时,无法保证在tmpDir被构造之前,FileSystem tfs已经被构造出来。我们没有什么理由说tfs会在tmpDir之前构造,编译器也没有保证这一点。这个问题的解决方法在于:单例模式,解决实现是,我们不直接使用tfs对象,而是通过一个函数或者类来保证这个对象的初始化。用函数的单例模式实现如下:

扫描二维码关注公众号,回复: 11599563 查看本文章
//FileSystem.cpp
class FileSystem{
public:
    //...
    std::size_t numDisks() const;
    //...
}
FileSystem &tfs(){
    static FileSystem fs;//一旦进入到该函数,局部静态变量就会被初始化
    return fs;
}

//Directory.cpp
class Directroy{
public:
   //...
   Directory(){
        std::size_t disks = tfs().numDisks();//
   };
   //...
}
//main.cpp
Directory tmpDir; //

这时候当用户写下Directory tmpDir; 这样的代码时,一旦使用到tfs函数,在函数体中,局部静态变量保证被初始化。

二、构造、析构、赋值运算

2.1 了解C++默默编写并调用哪些函数

  • 如果你没有自己声明,编译器就会自己生成一个copy构造函数,一个copy assignment操作符和一个析构函数
    编译器生成的copy constructor就会将类成员逐个复制过来。当类展现memberwise copy和bitwise copy语意的时候合成的拷贝构造函数又不一样。以下四种情况会展现memberwise copy语意

    1. 当类中有其它类成员(非内置类型),并且该类具有拷贝构造函数,无论该copy constructor是设计者有意编写还是编译器自动合成的。
    2. 当class继承自一个父类并且该父类具有一个copy constructor,无论该copy constructor是设计者明确设计还是编译器自动生成的。
    3. 当class 声明有一个virtual function时
    4. 当class 派生自一个virtual base class继承链时

    在1、2中情况下,编译器合成的拷贝构造函数中,会调用需要初始化的类的拷贝构造函数(成员类,或者父类)。在3、4的情况下,会插入一些对vptr以及VTT的初始化代码。
    如果不是上面四种情况之一,就不会展现memberwise语意,此时“合成的拷贝构造函数”做的事情是直接逐位拷贝内存,该拷贝构造函数表现就好像使用了一个memcopy函数一样。

  • 如果你已经自己声明了一个copy构造函数,如果没有在该copy构造函数中调用成员类的copy constructor,那么成员类就会使用它本身的默认构造函数对类进行构造。

2.2 如果不想使用编译器提供的函数,应当明确拒绝

很多时候我们并不想使用编译器自动提供的函数。例如一个class A不允许被copy。那么我们就应该把class A的copy constructor声明为private。这样就可以明确拒绝了该类的拷贝行为。

2.3 为多态基类声明virtual析构函数

考虑以下代码

class Parent{
public:
    Parent(){std::cout << "Base constructor" << std::endl;}
    ~Parent(){std::cout << "Base deconstructor" << std::endl;}    
}

class Child : public Parent{
public:
    Child(){std::cout << "Child constructor" << std::endl;}
    ~Child(){std::cout << "Child constructor" << std::endl;}    
}
//...
Child *pChild = new Child;
func(pChild);//这里出现问题

void func(Base *pParent){
    delete pParent;//这里出现问题,Child部分没有被delete
}

在上述代码中,应该注意到,class Child继承了一个Parent类。对class Parent中构造函数的调用的代码会被插入Child中的每一个构造函数。并且构造顺序为Base优先,Child次之。而对class Parent的析构函数的调用的代码也会被插入class Child的析构函数当中,并且析构顺序为Child优先,Base次之。上述代码的问题在于,在func函数中,执行delete pParent时,调用的是Parent的析构函数,但实际上pParen指向的是Child对象,于是Child对象的析构函数没有被调用,Child对象部分从而没能被析构。
改写代码如下:

class Parent{
public:
    Parent(){std::cout << "Base constructor" << std::endl;}
    virtual ~Parent(){std::cout << "Base deconstructor" << std::endl;}    
}

class Child : public Parent{
public:
    Child(){std::cout << "Child constructor" << std::endl;}
    ~Child(){std::cout << "Child constructor" << std::endl;}    
}
//...
Child *pChild = new Child;
func(pChild);//没问题

void func(Base *pParent){
    delete pParent;//这里多态机制发挥作用,实际上调用的是~Child()。
}

上述代码在func函数中,执行delete pParent时,由于多态机制发挥作用调用的是Child的析构函数,而如前文所述,Child的析构函数中会调用~Parent(),从而Parent和Child都能被正确析构。

2.4 别让异常逃离析构函数

  • 析构函数应该运行到底,而不是因为异常而辗转退出。因为析构函数不应该导致调用析构函数的函数退出。
  • 析构函数中如果调用的函数可能引发异常,它应该检测到所有异常并且“吞下”这些异常,不然异常逃离析构函数。

2.5 绝不在构造和析构函数中调用virtual函数

该准则基于这样一个事实:virtual函数的机制是在构造函数中建立起来的。并且virtual函数机制会在析构函数中被销毁。看下面一个例子

class Parent{
public:
    Parent(){sayName();}
    virtual void sayName(){std::cout << "Parent" << std::endl;}
}

class Child: public Parent{
public:
    Child(){}
    virtual void sayName(){std::cout << "Child" << std::endl;}
}

//...main.cpp
Child *pCh = new Child;

在上述代码中,输出的是Parent而不是Child,虽然sayName是一个虚函数,虽然Child已经继承并且覆盖了它。但是,在Parent函数构造完成之前virtual函数机制没有被建立起来,准确地说是vtable中Parent.sayNmae还没有被Child.sayName覆盖,于是在Parent中调用的sayName是Parent.sayName,而不是多态版本的Child.sayName.

2.6 operator = 返回一个*this的引用

该准则只是一个建议,为了对连等号表达式的支持而已,如

A = B = C = D;

2.7 在operator = 中处理自我赋值

来看一段代码

class Widget{
//...省略若干代码
private:
    Resource *pResource;
}

Widget & Widget::opeartor=(const Widget &rhs){
    delete pResource;//释放自己所持有的资源 !!埋下出错的伏笔
    pResource = new Resource(*rhs.pResource);//复制rhs的资源!!这里可能出错
    return *this;
}

上述代码危险的地方在于:
1. 当rhs和this都引用的是同一个对象时,也就是出现了自我赋值的情况。在delete pResource一句中释放了this的资源后,rhs.pResource也不复存在,这时pResource = new Resource(*rhs.pResource)几乎一定导致pResource指向NULL或者其它未知情况。
2. 我们必须注意到,即使没有出现自我赋值的情况,new操作有可能失败的,所以pResource此时也会是一个悬空的指针。

为了处理上述两种情况我们改写代码如下:

Widget & Widget::opeartor=(const Widget &rhs){
    if (this == &ths) return *this; //应对情况 1
    pResource *tmp = pResource;//应对情况2
    pResource = new Resource(*rhs.pResource);
    delete tmp;    
    return *this;
}

作为一名C++程序员,一定要有的良好素养:

  • 对指针和引用保持良好的敏感嗅觉,当看到指针或者引用时,应当想到“引用了谁?”
  • 对任何可能抛出异常的地方都保持警觉,特别强调容易被忽略的new操作符。

2.8 复制对象时勿忘每一个成分

这一准则的出现,其内在原理在我们之前对拷贝构造函数讨论拷贝构造函数讨论中有所提及。

  • 程序员声明的拷贝构造函数中,如果没有对父类进行显式初始化,那么编译器会插入对父类默认构造函数调用的代码。即使用默认构造函数对父类进行初始化。

来看下面的代码:

class Parent{
public:
    Parent():data(10){}
    Parent(const Parent &rhs):data(rhs.data){}
    void setData(int d){data = d;}
private:
    int data;
}

class Child : public Parent{
public:
    Child():name("Child"){}
    Child(const Child &rhs):name(rhs.name){}
    string name;
}

乍一看Child::Child(const Child &)已经复制了Child的所有成员了。但这里我们容易忽略的是:Child::Child(const Child &)并没有复制父类的成员,而是使用了父类的默认构造函数初始化了父类,此时无论如何Child.data的初始值都是10。所以上述代码应该改为

//...同上
class Child : public Parent{
public:
    Child():name("Child"){}
    Child(const Child &rhs): Parent(rhs), name(rhs.name){}
    string name;
}

所以当自己设计一个拷贝构造函数或者是operator =时请确保以下两点:

  • 保证所有父类被拷贝
  • 保证所有的成员变量被拷贝

猜你喜欢

转载自blog.csdn.net/u013298300/article/details/50479826
cpp