effective c++_【阅读笔记】Effective C++()

全文参考自Effective C++, Scott Meyers
程序来自本人
https://github.com/hxz1998/ccl

1. 让自己习惯C++

C++高效编程守则视状况而变化,取决于使用C++的哪一部分。

C++四大块:

  • C
  • Object-Oriented C++:面向对象
  • Template C++:泛型编程,模板元编程
  • STL:容器,迭代器,算法,函数对象

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

  • 对于单纯常量,最好使用 const 或者 enums 来替换 #define
  • 对于形似函数的宏(macros),最好使用 inline 函数来替换 #define

有一种方法,可以获得宏带来的效率,以及一般函数带来的可预料行为以及类型安全性(Type Safety),例如:

template<typename T>inline T callWithMax(const T &a, const T &b) {
    return a > b ? a : b;
}

int main() {
    int a = callWithMax<int>(1, 2);
    cout <endl;
}

3. 尽可能使用 const

  • 声明为 const 可以让编译器帮助检查错误。
  • const 可以施加于任何作用域内的对象、函数参数、函数返回类型、成员函数。
  • 编译器强制实施bitwise constness,但编写程序时,应该使用“概念上的常量性”conceptual constness
  • constnon-const 成员函数有实质等价的实现时,要用 non-const 版本去调用 const 版本,这样可减少代码重复。

const 如果出现在 * 左边,那么表示被指物是常量;如果在 * 右边,那么表示指针是常量;如果出现在两边,那么表示指针和被指物都是常量。例如:

int main() {
    int a = 0, b = 1;
    int const *p1 = &a;
    int *const p2 = &a;

    *p1 = 2;    // 不行!因为 p1 指向的内容是常量
    *p2 = 2;    // 可以,p2 自身是常量,p2 只能指向a,但是 a 中的内容可以变
    p1 = &b;    // 可以,p1 指向另一个内容,并声称这个内容不可变
    p2 = &b;    // 不可以,p2 自身是常量,不能指向其他东西了
}

试着习惯这样的写法:

void f1(const int *i); // 指向一个不能修改内容的 i
void f2(int const *i); // 一猫猫一样

这俩写法效果是一样的,都是指向一个内容不可变的数据(指针本身可以再修改指向的对象)。

对于第三点,一个很好的例子如下:

class Text {
private:
    std::string text;
public:
    const char &operator[](std::size_t pos) const {
        return text[pos];
    }

    char &operator[](std::size_t pos) {
        return const_cast<char &> (         // 使用 const_cast 去掉 const 声明
                static_cast<const Text &>   // 使用 static_cast 把 *this 转换成 const 对象
                (*this)[pos]);              // 使用 (*this)[] 方法返回(这个时候是 const 结果,经过 const_cast 去掉 const 声明
    }
};

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

  • 为内置对象进行手工初始化,因为C++并不能保证完全初始化好它们。
  • 构造函数最好使用成员初值列(member initialization list),而不要在构造函数本体内使用赋值操作(assignment)。
  • 初始列列出的成员变量,其排列顺序要和它们在类声明中的一致。
  • 为免除“跨编译单元初始化次序”问题,使用 local static 对象来代替 non-local static 对象。

如果成员变量是 const 的或者 references 的,那么它们一定需要有初值,不能被赋值。例如:

class X {
    const int val;
    int &re_val;
public:
    X(int val_, int &re_val_) : val(val_), re_val(re_val_) {}
};

基类总是比派生类要先初始化好,例如:

class X {
    const int val;
    int &re_val;
public:
    X(int val_, int &re_val_) : val(val_), re_val(re_val_) {
        cout <"X initialization..." <endl;
    }
};

class Y : public X {
public:
    Y(int val, int &re_val) : X(val, re_val) {
        cout <"Y initialization..." <endl;
    };
};

int main() {
    int re_v = 1;
    Y y(1, re_v);
    // >: X initialization...
    //    Y initialization...
}

由于定义于不同编译单元内的 non-local static 对象的初始化顺序并未明确定义,因此会出现这样情况:

  • 定义在 File1.hh 中一个静态全局变量 tfs
  • File2.cc 中使用 tfs

那么如果 File1.hh 中的 tfs 还没初始化好呢,File2.cc 中就想使用了,那么就会出现大问题!例如下面这个例子:

// File1.hh

#include 
using namespace std;
class FileSystem {
public:
    size_t numDisks() const { return 0; }
};
extern FileSystem tfs;

// File2.cc

#include 
#include "File1.hh"
using namespace std;
class Directory {
    size_t disks;
public:
    Directory() { disks = tfs.numDisks(); }
    size_t getDisks() const { return disks; }
};
int main() {
    Directory directory;
    cout <}

这个时候编译器就会报错:

CMakeFiles\local_static.dir/objects.a(File2.cc.obj):File2.cc:(.rdata$.refptr.tfs[.refptr.tfs]+0x0): undefined reference to `tfs'

很明显,在 local_static 目录中没有找到该引用,因此报错了,那么该怎样做呢?

使用方法(类似于工厂方法)来获取这个值,而不是依赖编译器初始化

例如:

// File1.hh

class FileSystem {
public:
    size_t numDisks() const { return 0; }
};
FileSystem &getFS() {
    static FileSystem tfs;
    return tfs;
}

// File2.cc
class Directory {
    size_t disks;
public:
    Directory() { disks = getFS().numDisks(); }
    size_t getDisks() const { return disks; }
};
int main() {
    Directory directory;
    cout <}

这样一来,就不用担心了☺。

不过,这样还是有另外一个问题,例如多线程环境下还是有不确定情况,处理这种麻烦情况的做法之一是:在单线程启动阶段,手动调用一遍所有的 reference-returning 方法。这样可以消除与初始化有关的“竞速形式(race conditions)”

5. 了解C++默默编写并调用哪些函数?

编译器可以暗自为 class 创建 default 构造函数、copy 构造函数,copy assignment 操作符,以及析构函数。

首先,开门见山地说,C++默认编写了默认构造函数、默认析构函数、拷贝构造函数,以及拷贝赋值函数,而且它们默认都是 inline 的。当然,这些函数的默认创建在一定时期是失效的,例如:

  • 默认构造函数:当提供了一个构造函数后,编译器不再为类提供默认构造函数,而且默认。
  • 默认析构函数:当提供了一个析构函数后,编译器就不再提供默认析构函数,默认析构函数是 non-virtual 的。
  • 拷贝构造函数:只要没提供,而且满足可拷贝构造的条件,那么就提供,否则不提供。
  • 拷贝赋值函数:只要没提供,而且满足可拷贝复制的条件,那么就提供,否则不提供。

上面说了两个条件,那么具体是什么条件呢?

5.1 可拷贝构造&可拷贝赋值?

先来看一下满足这俩条件的例子:

template<typename T>
class NamedObject {
private:
    T objectValue;
    string name;
public:
    NamedObject(string n, T val) : name(n), objectValue(val) {}
    NamedObject(const NamedObject &rhs) {
        objectValue = rhs.objectValue;
        name = rhs.name + " copy ";
    }friend ostream &operator<const NamedObject &rhs) {
        os <" " <        return os;
    }
    NamedObject &operator=(const NamedObject &rhs) {
        objectValue = rhs.objectValue;
        name = rhs.name + " = ";return *this;
    }
};int main() {string newDog = "newDog";string oldDog = "oldDog";NamedObject<int> od(oldDog, 1);NamedObject<int> nd(newDog, 2);cout <" : " <endl;  // >: 2 newDog : 1 oldDog
    nd = od;    cout <// >: 1 oldDog =
}

那么此时,即便自己不提供拷贝构造以及拷贝赋值构造操作符,编译器也会对成员变量进行递归的拷贝赋值过来。但是在遇到成员变量是 const 或者 reference 类型时,编译器就两手一摊,无能为力了(具体可参考Effective C++, 3th, P37)。

例如下面的例子:

template<typename T>
class NamedObject {
private:
    const T objectValue;
    string& name;
public:
    // 其他函数都一样
    NamedObject(const NamedObject &rhs) {
        objectValue = rhs.objectValue;
        name = rhs.name + " copy ";
    }
    NamedObject &operator=(const NamedObject &rhs) {
        objectValue = rhs.objectValue; // 不能对一个 const 对象赋值!
        name = rhs.name + " = ";return *this;
    }
};int main() {string newDog = "newDog";string oldDog = "oldDog";NamedObject<int> od(oldDog, 1);NamedObject<int> nd(newDog, 2);
    nd = od;    // >: error: use of deleted function 'NamedObject& NamedObject::operator=(const NamedObject&)'
}

当然,这只是编译器不再提供了而已,用户自己还是可以设计如何去复制拷贝以及构造拷贝的,这完全取决于自己怎么处理成员变量。

除此之外,如果基类把拷贝构造函数设置成了 private 那么在派生类中也是没办法操作的。

6. 若不想使用编译器自动生成的函数,那该明确拒绝

为驳回编译器自动(暗自)提供的机能,可将相应的成员函数声明为 private 并且不予实现。或者使用继承 Uncopyable 这样的基类。

如果不想让一个类支持拷贝构造或者赋值构造,那么我们可以将函数声明但不实现,例如这样子:

class Uncopyable {
private:
    Uncopyable(const Uncopyable&);
    Uncopyable& operator=(const Uncopyable&);
};

当然,对于每一个想实现这个功能的类都能去单独这样声明,不过,还可以使用继承方法去实现,例如:

class Uncopyable {
protected:
    Uncopyable() = default;
private:
    Uncopyable(const Uncopyable &);
    Uncopyable &operator=(const Uncopyable &);
};
class SubClass : public Uncopyable {
    // 默认不允许拷贝构造和赋值运算符
};
int main() {
    SubClass s1, s2;
    s1 = s2;    // error!
}

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

  • 带多态性质的基类应该声明一个 virtual 析构函数。
  • 如果类带有任何 virtual 函数,那么它就应该拥有一个 virtual 析构函数。
  • 如果类的设计目的不是用来做基类的,那么就不应该声明 virtual 析构函数。

当使用基类指针指向派生类对象时,没有问题,但是要是想把这个基类指针给删掉,这时候问题就来了,例如下面这个例子:

class BaseClass {
private:
    char *name;
public:
    BaseClass(int size) {
        name = new char[size];
        for (int i = 0; i 'a';
    }
 // 基类的析构函数
    ~BaseClass() { delete name; }
};

class DeriveClass : public BaseClass {
private:
    char *count;
public:
    DeriveClass(int size) : BaseClass(size) {
        count = new char[size];
        for (int i = 0; i 'b';
    }
    // 派生类的析构函数
    ~DeriveClass() { delete count; }
};
int main() {
    // 多态用法,基类指针指向派生类对象,没毛病
    BaseClass *obj = new DeriveClass(16);
    // 删除基类指针,出现了问题!
    delete obj;
    return 0;
}

上面的程序乍一看看不出个毛病来,现在对 BaseClass *obj = new DeriveClass(16); 设置断点,进行单步调试,可以观察到构造函数过程是:

new DeriveClass(16)
    |
BaseClass(16)
    |
DeriveClass(16)
    |
    end

这个顺序完全正确,先构造基类再构造派生类嘛,执行完后,内存状态是这样的:

bbcdf21075f9e0e906f96c561ad33bfa.png

可以得知操作系统给这两个对象中的成员分配内存到了 name : 0x1061980count : 0x10619c0

那么执行 delete obj; 时,顺序是这样的:

delete obj
    |
~BaseClass()
    |
    end

从上面可以看出来,竟然只执行了基类的析构函数,而没有执行派生类的析构函数,那么这时的内存表示是怎么样的?见下图:

c88b3092b0666e7c328ed7d0637506a1.png

由此可见,在不经意间,就造成了内存泄漏问题,那么该如何解决这个问题呢?

很简单,只需要把基类的析构函数声明为 virtual 就可以了,这样强制去执行子类的析构函数。

不过,这样还是有两种结果,例如下面是一种结果:

class BaseClass {
    // 其他都一样
    virtual ~BaseClass() { delete name; }
};
class DeriveClass : public BaseClass {
    // 其他都一样
    ~DeriveClass() override { delete count; }
};

这个时候,是先执行的派生类析构函数,再执行基类析构函数。

另一种结果是:

class BaseClass {
    virtual ~BaseClass() { delete name; }
};
class DeriveClass : public BaseClass {
    // 删掉了自己的析构函数
};

这个情况下,才是先执行基类析构函数,再执行派生类析构函数。

8. 别让异常逃离析构函数

  • 析构函数绝对不要抛出异常,如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下他们(不传播)或结束程序。
  • 如果接口使用者需要对某个操作函数运行期间抛出的异常做出反应,那么 class 应该提供一个普通函数(而不是在析构函数中)执行操作。

即便C++允许析构函数抛出异常,但是最好不要这样做。当然,吞掉异常也是有争议的,比如“草率地结束程序”可能会带来更严重的问题,或者“不明确的行为带来的风险”可能会带来不安全的问题等等,具体问题具体分析是比较好的。

但是,通常可以提供一个让用户在析构函数前控制异常的机会,例如使用“双重保险”来尽最大化确保问题得到解决。

9. 绝不在构造函数和析构函数过程中调用virtual函数

在构造和析构期间不要调用 virtual 函数,因为这类调用从不下降至派生类。

不管怎样,都不应该在构造函数和析构函数内部去调用一个 virtual 函数,因为这样的操作是不可预估的,带来意想不到的结果。为什么这样?因为在基类中,构造函数执行阶段或者析构函数执行阶段只能看到基类的内容,所以在派生类中实现的程序,是不可用的。下面这句话直白且有效的指出了问题的所在:

在基类(base-class)构造期间,virtual 函数不是 virtual 函数。

也正是因为这样一个“对象在 derived class 构造函数开始执行前,不会成为一个 derived class 对象”的规则,所以最好在构造期间对 virtual 函数视而不见。

那么如何科学有效地去解决这个问题?当然是在基类中把需要在构造函数内执行地函数设置成非 virtual 函数。

总之就是,在基类构造和析构期间调用的 virtual 函数不可下降至派生类。

10. 令 operator= 返回一个 reference to *this

令赋值(assignment)操作符(=)返回一个 reference to *this

为什么这样做呢?是因为可以实现类似于这样的程序:

a = b = c = 10;

因此,我们在编写类的 operator= 操作符时,可以写成:

class BaseClass {
public:
    BaseClass &operator=(const BaseClass &rhs) {
        // 随便干点什么
        return *this; // 关键在于这里
    }
};

当然啦,也可以不做返回,不过既然这是一个好的实践,那么没有确切的理由不去做,最好就去做。

11. 在 operator= 中处理“自我赋值”

  • 确保当对象自我赋值时,operator= 有可预估的行为。其中需要注意的包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序以及拷贝交换。
  • 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。

简而言之,就是需要考虑操作符两边是否是同一个对象,因为如果是同一个对象,会出现类似下面的问题:

class BaseClass {
private:
    char *data;
public:
    BaseClass &operator=(const BaseClass &rhs) {
        delete data;
        data = rhs.data;
        return *this;
    }
};
int main() {
    BaseClass baseClass;
    baseClass = baseClass;
}

自己给自己赋值,没毛病,但是在运算符函数的 delete data 却带来了问题,因为它删除掉了自己的内存空间,却在下面那行 data = rhs.data 又想用了,而这时系统已经收回了这块空间,这样一来操作系统肯定是不干的,所以程序就报错了。

那么该如何解决呢?这样:

class BaseClass {
private:
    char *data;
public:
    BaseClass &operator=(const BaseClass &rhs) {
        // 多一个检查是否是自己的操作就可以了,也称证同测试
        if (&rhs == this) return *this;
        delete data;
        data = rhs.data;
        return *this;
    }
};

12. 复制对象时勿忘其每一个成分

  • 拷贝函数应该确保复制了“对象内的所有成员变量”以及“所有的 base class 成员”。
  • 不要尝试以某个拷贝函数去实现另一个拷贝函数,应该将两者共同的部分抽取到一个新的函数中去完成,然后由两个拷贝函数共用。

一般而言,如果自己不声明拷贝构造函数和拷贝赋值操作符,那么编译器会帮自己生成的,但是!重点来了!如果选择了自己去声明定义,那么麻烦事就来了(因为即便可能出错编译器也不会告诉你)。

尤其是一个类派生自基类的时候,就需要小心谨慎地去处理基类的对象,然而有些是 private 的,因此复制起来比较麻烦,这个时候可以使用这样的方式来解决问题:

  • 对于拷贝构造函数,在初始化列表中显式地去调用基类的拷贝构造函数,然后在子类的拷贝构造函数内部处理好自己的问题。
  • 对于赋值拷贝操作符,在合适的位置显式调用基类的 operator=() 函数。

具体例子见下面:

class BaseClass {
private:
    string name;
public:
    BaseClass() = default;
    BaseClass(int sz, char c) { name = string(sz, c); }
    BaseClass(const BaseClass &rhs) : name(rhs.name) {}
    BaseClass &operator=(const BaseClass &rhs) {
        if (this == &rhs) return *this;
        this->name = rhs.name;
        return *this;
    }
    friend ostream &operator<const BaseClass &rhs) {
        os <        return os;
    }
};
class DeriveClass : public BaseClass {
private:
    int age;
public:
    DeriveClass(int a, int sz, char c) : BaseClass(sz, c), age(a) {}
    // 必须要调用基类的拷贝构造函数,否则不会拷贝构造完全
    DeriveClass(const DeriveClass &rhs) : age(rhs.age), BaseClass(rhs) {}
    DeriveClass &operator=(const DeriveClass &rhs) {
        if (&rhs == this) return *this;
        age = rhs.age;
        // 如果不调用下面这句,将会出现没有拷贝基类 name 值的问题!
        BaseClass::operator=(rhs);
        return *this;
    }
    friend ostream &operator<const DeriveClass &rhs) {
        os <"\t" <        return os;
    }
};
int main() {
    DeriveClass d1(18, 3, '1');
    DeriveClass d2(20, 5, '2');
    DeriveClass d3(d1);
    d1 = d2;
    cout <endl <endl <    /**
     * 正常输出:
     * 22222    20
     * 22222    20
     * 111      18
     */
    /**
     * 如果按照前两点建议,那么出现这样的情况概不负责;
     * 111      20
     * 22222    20
     *          18
     */
}

总而言之,一旦选择了自己去完成拷贝构造函数复制拷贝操作符,那么就别怪编译器不厚道了,需要自己去谨慎操作。


软考之后终于可以静下心来看看书了?(开)?(心)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值