面向新手的《Effective C++》笔记

转载请声明出处哦~,本篇文章发布于luozhiyun的博客:https://www.luozhiyun.com/archives/715

最近在读《Effective C++》这本书,发现里面很多概念如果只是看过一遍很可能就忘了,所以简单整理摘录下来。本文主要是摘录的比较简单容易操作的一些条例。

赋值和构造

class Weight {
public :
    Weight();                               // defualt构造函数
    Weight(const Weight& rhs);              // copy构造函数
    Weight& operator=(const Weight& rhs);   // copy assignment 操作符
    ...
}

Weight w1;                                  // 调用defualt构造函数
Weight w2(w1);                              // 调用 copy构造函数
w1 = w2;                                    // 调用 copy assignment 操作符
Weight w3 = w2;                             // 调用copy构造函数

需要注意第四个:

Weight w3 = w2;

这个调用的是调用copy构造函数。

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

比如定义了下面的一个 define:

#define ASPECT_RATIO 1.653

这个记号会在编译器开始处理源码之前就被预处理器替换掉了,所以当运用这常量但获得一个编译错误信息时,也许会提到 1.653 而不是 ASPECT_RATIO,因此可能追踪它而浪费不必要的时间。

并且如果在多个地方使用了 ASPECT_RATIO 那么会被预处理器替换为多份,而用常量则不会出现这种情况。

另一方面就是 #define 无法提供封装性,没有所谓的private#define这样的东西,但是const可以被封装。

再来就是形似函数的宏最好该用 inline 函数替换,因为如果使用这样的宏在使用的时候有太多问题,可能会导致意向不到的结果。

尽量使用 const

const可以添加一个强制约束,这个约束可以降低代码的理解成本。比如const作用于成员函数,可以得知哪个函数可以改动对象内容而哪个函数不行。

const语法尽管变化很多,但是基本遵守以下规则:

  • 如果关键字const出现在星号左边,表示被指物是常量;
  • 如果出现在星号右边,表示指针自身是常量;
  • 如果出现在星号两边,表示被指物和指针两者都是常量。

const 还可以和函数返回值、各参数、函数自身产生关联。比如令函数返回一个常量值,可以降低因客户错误而造成的以外,而又不至于放弃安全性和高效性。

Rational a,b,c;
...
if (a * b = c) // 本来想键入 == 却变成 = ,const可以避免这样的错误

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

因为c++不保证对象在使用的时候一定被初始化,例如:

int x;

class Point{
    int x, y;
}
...
Point p;

上面这些代码在某些语境下会被初始化,有时候不会,c++并不会做初始化的保证,所以在使用之前最好手动初始化。

初始化的时候最好使用 initialization list 进行初始化,而不是赋值初始化。初始化的操作会早于赋值的操作,所以初始化效率更高,基于赋值的操作会先调用 default 构造函数为 theName、theAddress 设初值,然后立刻再对他们赋予新值。

成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。所以最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。

对于 non-local static 的初始化来说,c++也没规定不同编译单元内的 non-local static 对象的初始化次序,所以某个 non-local static 对象使用了另一个编译单元内的某个 non-local static 对象时,这个对象可能未初始化。所以我们可以使用 local static 替换 non-local static 对象。

class FileSystem {
public:
    ...
    std::size_t numDisks() const;
}
extern FileSystem tfs; //预备给其他单元使用的对象

------------------------------------- 
//另一个编译单元
class Directory{
public 
    ...
    Directory(params);
    ...
}
Direcotry::Directory(params){
    ...
    std::size_t disk=tfs.numDisks(); //使用 tfs 对象,可能未被初始化
}

上面这种情况就是 Directory 引用了 tfs 对象,但是由于再不同编译单元,所以可能未被初始化。

class FileSystem {
public:
    ...
    std::size_t numDisks() const;
}
FileSystem& tfs()
{
    static FileSystem fs; //初始化并返回一个local static 对象
    return fs;
}

------------------------------------- 
//另一个编译单元
class Directory{
public 
    ...
    Directory(params);
    ...
}
Direcotry::Directory(params){
    ...
    std::size_t disk=tfs().numDisks(); //使用 tfs 对象,可能未被初始化
}

上面这里将 non-local static 对象改为了 local static 对象避免了初始化的问题。

C++ 默认函数

一个空类,C++ 会默认生成一个 copy 构造函数、一个 default 构造函数、一个 copy assignment 操作符和一个析构函数。

// 如果写下
class Empty {};

// 默认会带上下面这些函数
class Empty {
public:
    Empty() { ... }
    Empty(const Empty& rhs) { ... }
    ~Empty() { ... }
    Empty& operator=(const Empty& rhs) { ... }
};

如果一个类含有 const 成员或 reference 成员时不会生成 copy assignment 操作符:

template<class T>
class NamedObject {
public:
    NamedObject(std::string & name, const T& value);
private:
    std::string & nameValue;
    const T objectValue;
};

int main() 
{ 
    std::string newDog("perse");
    std::string oldDog("satch");
    NamedObject<int> p(newDog ,2);
    NamedObject<int> s(oldDog ,36);
    p = s;
} 

编译的时候会报错:

/home/luozhiyun/data/cpptest/main.cpp:6:7: error: non-static reference member ‘std::string& NamedObject<int>::nameValue’, can’t use default assignment operator
/home/luozhiyun/data/cpptest/main.cpp:6:7: error: non-static const member ‘const int NamedObject<int>::objectValue’, can’t use default assignment operator

因为会默认生成一些函数,所以当不想使用默认函数的时候应该明确拒绝。比如声明一个 private 的 copy 构造器函数或者再父类中阻止 copy ,如下:

class Uncopyable {
protected:
    Uncopyable() {}             //允许构造和析构
    ~Uncopyable() {}
private:
    Uncopyable(const Uncopyable&); //阻止 copy
    Uncopyable& operator=(const Uncopyable&);
}

class HomeForSale:private Uncopyable{ 
 ...
}

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

如果设计了一个基类析构函数不是 virtual 的,然后有很多子类继承这个基类,然后利用多态的特性动态分配了一个基类的指针指向子类,然后释放这个指针,这个子类对象很可能并不会被销毁。

class TimeKeeper {
public:
    TimeKeeper();
    ~TimeKeeper();
    ...
};

class AtomicClock : public TimeKeeper{...} // 原子钟继承 TimeKeeper

然后利用多态返回一个基类指针对象:

TimeKeeper* ptk = getTimeKeeper(); //获得一个动态分配对象
...                                // 运行
delete ptk;                        // 释放,可能会导致子类对象并没有销毁

上面的 ptk 指针实际上指向 AtomicClock 对象,基类有一个 non-virtual 析构函数,这个时候由基类的指针执行销毁会造成 ptk 指向的子类对象没有销毁,从而形成资源泄漏。

所以当一个基类是为了做多态性使用的时候它应该拥有一个 virtual 析构函数。如果该基类不是作为多态使用,像上面的 Uncopyable 类,就不用声明 virtual 析构函数。

析构函数不要吐出异常

如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下他们或结束程序。

class DBConn {
public:
 ...
 void close()
 {
    db.close();
    closed = true;
 }
 ~DBConn()
 {
    if(!closed) {
        try {
            db.close();
        }catch(...) {
            ...
        }
    }
 }
private:
    DBConnection db;
    bool closed;
}

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

如果有一个 base class Transaction,在构造函数中调用了 virtual 函数,如下:

class Transaction {
public:
    Transaction();
    virtual void logTransaction() const ; 
};

void Transaction::logTransaction() const{
    cout<< "Transaction" << endl;
}

Transaction::Transaction()
{ 
    logTransaction();
}

然后有 BuyTransaction 继承了 Transaction:

class BuyTransaction: public Transaction {
public:
    virtual void logTransaction() const; 
};

void BuyTransaction::logTransaction() const{
    cout<< "BuyTransaction" << endl;
}

然后执行 BuyTransaction 构造函数:

BuyTransaction b;

这个时候由于 Transaction 的构造函数会早于 BuyTransaction 构造函数先执行,所以 logTransaction 实际上被调用的是 Transaction 的版本,而不是 BuyTransaction 的版本。

Copy 对象的时候记得复制基类

比如说有一个 PriorityCustomer 对象继承了 Customer 对象作为基类,里面有 copy 构造函数和 copy assignment 操作符,那么这两个函数在实现的时候记得复制基类对象,否则回执行缺省初始化动作。

class PriorityCustomer: public Customer {
public:
    ...
    PriorityCustomer(const PriorityCustomer& rhs);
    PriorityCustomer& operator=(const PriorityCustomer& rhs);
    ...
private:
    int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
    : Customer(rhs),  //调用基类 copy构造函数
    priority(rhs.priority)
{
    logcall("PriorityCustomer copy constructor");
}

PriorityCustomer & PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
    logcall("PriorityCustomer copy assignment operator");
    Customer::operator=(rhs); //对基类成员变量进行复制
    priority = rhs.priority;
    return *this;
}

以独立的语句将对象置入智能指针

比如对于一个这样的方法:

void processWidget(std::shared_ptr<Widget> pw, int priority);

如果写成这样是可以通过编译的:

processWidget(std::shared_prt<Widget>(new Widget), priority());

这段代码会执行 new Widget、调用 priority 函数、调用 shared_prt 构造。它们的执行顺序实际上不定的。

如果是先执行的 new Widget,然后再执行 priority 调用的时候抛了异常,那么创建的对象很可能发生泄漏。最好的做法应该是:

std::shared_prt<Widget> pw(new Widget)
processWidget(pw, priority());

尽量用 pass-by-reference-to-const替换 pass-by-value

pass-by-value 是极其昂贵的操作,默认会调用该对象的 copy 构造函数以及父类的构造函数。

如下:

class Person {
public: 
    Person();
    virtual ~Person();
    virtual std::string say() const;
    ...
private:
    std::string name;
    std::string address;
};

class Student: public Person {
public:
    Student();
    ~Student();
    virtual std::string say() const;
    ...
private:
    std::string schoolName;
    std::string schoolAddress;
}

如果这个时候设定了一个函数需要 pass-by-value :

bool validateStudent(Student s);

那么在调用这个函数的时候,会调用 Student 的 copy 构造函数,Student 内 两个成员变量的构造函数,以及父类 Person 的构造函数和它的成员变量构造。因此会调用总共 6 次 copy 构造函数。

并且 pass-by-value 还有对象切割的问题,比如我再写一个函数:

void saySomething(Person p){
    p.say();
}

Student s;
saySomething(s);

上面声明了一个函数 saySomething ,然后创建了对象 Student,但实际上由于是 pass-by-value 所以 Student 的特化信息会被切除, saySomething 函数里面会最终调用的是 Person 的 say 方法。

上面的解决方法都可以通过 pass-by-reference-to-const 解决:

void saySomething(const Person& p){ // nice ,参数不会被切割
    p.say();
}

因为 pass-by-reference 实际传递的是一个指针,那么它就有高效和不受切割问题影响的优点。

这几种情况不要返回 reference

  • 不要返回 pointer 或 reference 指向一个 local stack 对象,这很好理解, local stack 对象在函数返回之后会被销毁;

  • 不要返回 reference 指向一个 heap-allocated 对象,有可能会造成内存泄漏

    const Rational& operator* (const Rational& lhs, const Rational& rhs)
    {
    Rational* result = new Rational(lhs.n * rhs.n,lhs.d * rhs.d);
    return *result;
    }
    
    Rational w,x,y,z;
    w = x * y * z;

    上面这个例子中调用了两次 operator*,因此调用了两次 new 但是只返回了一个对象,那么另一个对象无法 delete,那么就会泄漏;

  • 不要返回 pointer 或reference 指向一个 local static 对象而同时需要多个这样的对象,local static 是唯一的

    const Rational& operator* (const Rational& lhs, const Rational& rhs)
    {
    static Rational  result;
    result = ...;
    return result;
    }
    
    bool operator==(const Rational& lhs, const Rational& rhs);
    
    Rational a,b,c,d;
    if ((a * b) == (c *d)) {// 这里会恒为 true,operator*返回的 static 对象唯一
    
    }

尽量避免转型

  • 如果可以,尽量避免转型,特别是对性能有要求的,避免使用 dynamic_casts;
  • 不要使用旧式转型,尽量使用 C++ style 转型;

不要返回对象内部的句柄

不要返回对象私有成员的句柄。这里的“句柄”(handle)包括引用、指针和迭代器。 这样可以增加类的封装性、使得const函数更加const, 也避免了空引用的创建(dangling handles)。

比如:

class Rectangle {
private:
  int _left, _top;
public:
  int& left() { return _left; }
};
Rectangle rec;
rec.left() = 3; //导致内部对象被篡改,破坏了封装性

还比如,一个函数返回了临时对象,但是在临时对象被销毁之后依然持有这个临时对象导致的空引用的问题;

异常安全的函数

当异常被抛出时,异常安全的函数会不泄漏任何资源,不允许数据被破坏。异常安全函数提供以下三个保证之一:

  • 基本承诺:如果异常被抛出,程序内的任何事物仍然保持在有效状态下。没有对象或数据结构会因此而被破坏;
  • 强烈保证:如果异常被抛出,程序状态不改变。即保持原子性,要么函数成功,要么函数失败;
  • 不抛异常保证:承诺绝不抛出异常,因为它们总能完成它们原先承诺的功能。

inline

inline 一般来说可以令程序运行速度变快,但也不是绝对的。它会使程序体积增大,如果过度热衷 inlining 导致程序体积太大,那么会导致程序出现 swap page 行为,降低缓存的命中率,这样反而会降低运行效率。

inline 只是对编译器的一个深情,不是强制命令。它一般定义在头文件内,在编译过程中会奖一个函数调用替换为被调用函数的本体。

所以编译器必须知道函数长什么样子,对于 virtual 函数来说是无效的,因为它要等到运行期才确定调用哪个函数。

由于即使是一个空的构造函数,它也会默认初始化所有成员,以及调用父类构造,所以看起来代码量很小,实际上编译出来的代码量取决于类的成员变量,以及是否有继承。所以构造函数被 inline 可能会造成代码膨胀很多,通常 inline 构造函数不是一个好的实践。

还有就是不要因为 function templates 出现在头文件,就将它们声明为 inline。而是应该你认为所有根据此 template 具现出来的函数都应该 inlined 才声明为 inline。

注意继承的掩盖问题

class Base {
public:
    virtual void mf1() = 0;
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
};

class Test : public Base{
public:
    virtual void mf1();
    void mf3();
    void mf4();
};

int main()
{
  Test t;
  int x;

  t.mf1();   //yes
  t.mf1(x);  //no, Test::mf1 掩盖了 Base::mf1
  t.mf2();   //yes
  t.mf3();   //yes
  t.mf3(x);  //no,Test::mf3 掩盖了 Base::mf3
}

编译器首先会根据名称去 local 找,然后去父类 Base 找,如果还没有就会去内含 Base 的 namespace 作用域去找,最后去 global 作用域找。

除此之外还有个名称掩盖规则,子类中含有的函数会掩盖父类中相同名称的函数,所以 Base::mf1 和 Base::mf3 不再被继承。

如果想要继续继承可以使用 using:

class Test : public Base{
public:
    using Base::mf1;
    using Base::mf3;
    virtual void mf1();
    void mf3();
    void mf4();
};

这样 Base::mf1 和 Base::mf3 继承可以继续使用。

区分接口继承和实现继承

  • 声明一个 pure virtual 函数的目的是为了让派生类只继承函数的接口。可以做到强制派生类实现这个函数;
  • 声明 impure virtual 函数的目的是让派生类继承该函数的接口和缺省实现。如果派生类没有实现该函数,则默认使用父类的函数实现;
  • 声明 non-virtual 函数的目的是为了令派生类继承函数的接口及一份强制性实现。由于 non-virtual 函数代表的意义是不变性(invariant)凌驾特异性(specialization),所以它绝不该在派生类中被重新定义。

绝不重新定义继承而来的 non-virtual 函数

如下:

class B {
public:
    void mf();
    ...
}; 

如果这时候有个 D 代码继承 B,并在 D 里面也实现了 mf 方法:

class D: public B {
public:
    void mf();
    ...
};

D x;
B* pb = &x;
D* pd = &x;

pb->mf(); //调用 B::mf
pd->mf(); //调用 D::mf

那么由于 mf 是 no n-virtual 的,所以都是静态绑定,实际上就会很诧异的各自调用到各自的方法中。所以千万不要新定义继承而来的 non-virtual 函数。

注意模板带来的代码膨胀

比如:

template<typename T, int n>
class Square{
public:
    void invert();
};

Square<double, 5> s1;
Square<double, 10> s2;
s1.invert();    
s2.invert();

Square模板会实例化两个类:Square<double, 5>Square<double, 10>,它们会各自拥有相同的invert方法,这就导致了代码膨胀。

解决办法可以将膨胀的代码进行抽离:

template<typename T>
class SquareBase{
protected:
    void invert(int size);
};

template<typename T, int n>
class Square:private SquareBase<T>{
private:
    using SquareBase<T>::invert;
public:
    void invert(){ this->invert(n); }
}

上面这个代码SquareBase::invert是公用的,所以在上面的例子中只会出现一份代码 SquareBase<double>::invert

扫码_搜索联合传播样式-白色版 1