Effective C++笔记

发布时间 2023-03-28 23:11:45作者: WuYunTaXue

Effective C++ Third Edition

改善程序与设计的55个具体做法

导读

除非有理由允许构造函数被用于隐式类型转换,否则‘我’会把它声明为explicit(阻止隐式类型转换)

class tmp{
public:
    explicit tmp(int a) : numa(a){
    }
    int numa;
};

注意拷贝构造和赋值运算符的区别;拷贝构造函数比较重要,它决定这个对象如何以值(Passd-by-value)

class Persion{
    ...
};

Persion A;
Persion B = A; //调用了拷贝构造函数
A = B;  //调用了赋值运算符函数

自己习惯C++

01-视c++为一个语言联邦

C++中包含了太多特性,当处于不同次语言(sublanguage)中时,守则也会有所不同。

大致可以分为四个次语言:

  • C 传统的C语言

  • Object-Oriented C++ 面向对象

  • Template C++ 模板

  • STL 标准库

代码段在这四个语言切换时,编程守则也要跟着改变。

例如:对内置类型(C-like)而言,值传递(pass-by-value)通常比引用传递(pass-by-reference)高效。

但是,当代码从C part of C++转移到Object-Oriented C++时,由于用户会自定义构造/析构函数,所以const引用传递(pass-by-reference-to-const)会更好。在Template C++时更是如此。

然而STL中的迭代器和函数对象都是在C指针上塑造出来的,所以对这两项来说,pass-by-value更合适。

02-尽量以const,enum,inline替代define

  • 对于单纯常量,最好以const对象或enum替代#define
#define DEFAULT_SAMPLING_RATE 10000
//这个在预处理阶段,编译器会将DEFAULT_SAMPLING_RATE替换为10000,
//当需要调试或出现编译错误的的时候,只能找到10000这个数字,不利于追踪
  • 对于形似函数的宏(macros),最好使用inline函数替代
#define CALL_WITH_MAX(a,b) f((a) > (b) ? (a) : (b))
//a,b中的最大者作为实参,调用函数f

虽然这种形式,不会带来函数调用的开销,但当有人不按照常理传入参数时,会产生不可思议的问题,比如:

int a=5, b=0;
CALL_WITH_MAX(++a, b);  //a会++两次
CALL_WITH_MAX(++a, b+10);  //a只自加一次

可以使用如下方式替代:

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

03-尽可能使用const

const出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量。

如果表示被指物是常量,有些人会将const写在类型之前,有些人会把const写在类型之后、星号之前。这两种写法表示的意义相同。

void f1(const int* dat);
void f2(int const * dat);

在一个类中的const函数里,不允许修改类内的成员,但可以使用关键字mutable修饰变量,这样变量是易变的,即使在const函数中。

class TextBlock{
private:
    mutable int a;
    int b;
public:
    void test() const {
        a = 10;  //正确的
        b = 20;  //编译报错
    }
};

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

  • 为内置型对象进行手工初始化,C++不保证初始化它们

  • 构造函数利用好成员初始值列表,而不是在构造函数中对成员变量赋值

class Text{
public:
    Text(std::string n, int p): name(n), page(p){
        //成员的初始化顺序,取决于在类内的定义顺序,和初始化列表的顺序无关
        name2 = n;  //这是赋值操作,在此之前name2已经调用了默认构造函数
        //而初始化列表里的成员调用的是构造函数完成的初始化
    }
private:
    std::string name;
    std::string name2;
    int page;
};
  • 避免“跨编译单元之间初始化次序”的问题,以local static对象替代non-local static对象
class Directory{
    //...
};

//在首次调用这个函数时,Directory对象才会被初始化,
//可以避免声明成全局static对象时,初始化顺序的疑问
Directory& DirInstance(){
    static Directory td;
    return td;
}

构造、析构、赋值

05-了解C++默默编写并调用的那些函数

即使是空类,编译器也会生成几个默认的函数

默认构造函数、析构函数、拷贝构造函数、拷贝运算符重载函数

(好像还有其他函数:重载取指运算符、重载取指运算符const函数、移动构造函数(c++11)、重载移动赋值操作符(c++11))

  • 如果不声明构造函数,编译器会生成默认构造函数;如果已经声明了带参的构造函数,编译器不会生成默认构造了

  • 编译器默认生成的析构是no-vritual的,除非基类的析构是虚函数

  • 编译器会尝试生成‘拷贝构造函数’和‘拷贝运算符重载函数’。默认是执行每个成员的拷贝构造/拷贝运算符重载。如果有成员不符合规则(如:有const成员、reference成员),则不会生成默认的拷贝构造和拷贝运算符重载。

06-若不想使用编译器自动生成的函数,就该明确拒绝

  • 如果不想使用编译器自动生成函数,可将相应的成员函数声明为private并不予实现

  • 或者继承一个阻止赋值运算符和拷贝构造的类

即使不声明,编译器也会尝试自动生成“拷贝构造”和“拷贝运算符重载函数”,如果想明确避免这两个函数被使用,可以将其声明为private成员。但其‘友元’(friend)或成员还是可以访问,避免这个问题,可以只声明,不实现,则友元在调用时会报错(linkage error)。

class HomeSale{
public:
    int area;
    ...
private:
    HomeSale(const HomeSale&);  //只有声明
    HomeSale& operator=(const HomeSale&);
};

在类外部企图调用private成员,编译阶段会报错;友元或者内部成员调用时,只能在运行阶段报错(因为没有实现)。

将连接错误移到编译阶段,是可能的:

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

class HomeSale : private Uncopyable{
    ...
    //HomeSale不再声明拷贝构造和拷贝运算符
};

这样,当调用拷贝构造时,编译器默认生成的拷贝构造函数,会尝试调用基类的拷贝构造,就会发生报错,而不论调用者是否是内部成员或友元。

但要注意,由于Uncopeable类总扮演这一角色,可能会出现多重继承等问题。

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

  • 带多态性质的基类应该声明一个virtual析构函数。如果类中带有任何的virtual函数(通常意味着被设计成可以由派生类定制实现),它就应该拥有一个virtual析构函数。

在多态中,如果基类的析构不是虚函数,而其派生类对象由基类指针被delete释放时,会产生未定义的结果。实际执行时:通常是对象的派生部分没有被销毁,派生类的析构函数也未被调用,但其基类部分会被销毁,造成一个“局部销毁”的对象。

所以如果基类被设计应用在多态场景,其析构函数需要定义为虚函数,这样才能保证delete对象时销毁整个对象。

析构函数定义为纯虚函数的话,必须提供一份定义(纯虚函数也可以有定义)。

以一个工厂函数的应用场景为例:

class TimeKeeper{
    TimeKeeper();
    ~TimeKeeper();
    ...
};
class AtomicClock: public TimeKeeper{...};
class WaterClock:  public TimeKeeper{...};
class WristWatch:  public TimeKeeper{...};

//返回一个指针,指向TimeKeeper的派生类的动态分配对象
TimeKeeper* getTimeKeeper(int mode){
    if(mode == 0) 
        return new AtomicClock();
    else
        return new WaterClock();
}

//基类指针指向派生类对象
TimeKeeper* pkt = getTimeKeeper(t);
...
delete ptk; //通过基类指针释放派生类,基类不是虚析构,会释放不全
  • 如果class的设计目的不是作为基类或者多态使用,则不该声明virtual析构函数

欲实现虚函数,类需要携带vptr(virtual table pointer)指针,vptr指向一个由函数指针构成的数组,称为vtbl(virtual table)。当调用某一虚函数时,实际被调用的函数取决于vptr所指的vtbl的函数。

如果添加了虚函数,类的体积就会增加(存放虚函数指针vptr)。在一些场景中就和拥有相同声明的C语言结构体,不再有一样的结构。

C++11有final关键字,可以指出不允许该类作为基类。

08-别让异常逃离析构函数

  • 析构函数绝对不要吐出异常。如果一个析构函数调用的函数有可能抛出异常,析构函数应该捕获这个异常,然后吞下它们(不传递)或结束程序。

  • 如果客户需要对某个函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)。

09-不在构造析构过程中调用virtual函数

  • 在构造或析构期间不要调用virtual函数,因为构造析构内的调用不下降至其派生类。
class BaseLog{
public:
    BaseLog(){
        LogCreate();
    }
    virtual ~BaseLog();
    virtual void LogCreate();
    ...
};

class ShellLog : public BaseLog{
public:
    ShellLog();
    ~ShellLog();

    virtual void LogCreate();
    ...
};

//当执行如下操作时
ShellLog flog;

定义ShellLog类时,会先调用其基类(BaseLog)的构造函数,再调用其自身的构造函数。而基类的构造中调用了虚函数LogCreate,此时派生类(ShellLog)尚不存在。虽然这行代码是定义了ShellLog类,但BaseLog构造里LogCreate执行的是自身基类版本的函数。即在基类构造期间,虚函数还不是虚函数。

如果这个虚函数还是纯虚函数,则编译器则会报错。

析构函数中调用虚函数也是类似道理:一旦派生类的析构函数开始执行,对象内的派生类成员便呈现出未定义状态,在进入基类的析构时,对象就会成为一个基类对象。

当虚函数由多次函数包含调用出现在构造析构中时,编译器可能不会报错,但执行时可能会出现问题。

10-operator=返回reference to *this

  • 令赋值操作符(拷贝运算符)重载函数返回一个当前对象的引用

  • 这个建议也适用于,operator+= , operator-= , operator*=

以应对可能出现的连续赋值

class A,B,C;
A = B = C = D;

11-operator=处理自我赋值

  • 确保当对象自我赋值时有良好行为。可做的包括:比较来源和自身地址、精心的语句顺序、及copy-and-swap.

  • 确保任何函数操作多个对象时,如果其中多个对象是同一个对象时,其行为仍然正确。

示例:

class Widget{
public:
    ...
private:
    void* pb; //指向在堆上申请的空间
};

//不安全的自我赋值
Widget& Widget::operator=(const Widget& rhs) {
    delete pb;  //释放原有空间
    pb = new Bitmap(*rhs.pb);  //申请一块空间,将rhs.pb指向的内容填入新申请的空间
    return *this;
}
//当rhs是自身时,this->pb和rhs.pb是相同的,pb已经先被释放,之后的操作使用的rhs.pb已经指向了不存在的空间

//做证同测试
Widget& Widget::operator=(const Widget& rhs) {
    if( &rhs == this ){ return *this;}
    delete pb;  //释放原有空间
    pb = new Bitmap(*rhs.pb);  //申请一块空间,将rhs.pb指向的内容填入新申请的空间
    return *this;
}

//调整代码执行顺序
Widget& Widget::operator=(const Widget& rhs) {
    void * tmpPb = pb;
    pb = new Bitmap(*rhs.pb);  //即使new时出现异常,this维护的资源也没被释放
    delete tmpPb;
    return *this;
}

//和rhs的副本进行数据交换

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

  • copying函数应该确保复制“所有成员变量”及“所有的base class成分”

  • 不要尝试用某个copying函数实现另一个copying函数。应将共同机能放进第三个函数中以供调用。

copying函数是指:‘拷贝构造’ 和 ‘拷贝运算符重载’

应该在拷贝构造中,使用初始化成员列表调用成员、基类的拷贝构造函数;在拷贝运算符重载中,使用成员、基类的拷贝运算符。

资源管理

这里的资源是指,使用之后需要归还给系统的资源。比如:动态内存、文件描述符、锁、socket、数据库连接等。

13-以对象管理资源

以工厂函数为例,当调用者使用了函数返回的对象后,就需要将其释放。
但创建对象到delete的过程不一定顺利,有可能提前return,或者抛出异常。

建议使用对象来管理资源,在对象析构时释放资源。
C++提供了智能指针,可以胜任工作。(书中提到的auto_ptr在C++11中已经不推荐使用,被unique_ptr代替)

#include <memory>
class Widget{

};

Widget* createWidget() { return new Widget;}

{
    Widget* pW = createWidget();
    ...
    delete pW;
    //在new和delete之间,可能会发生意外
}

{
    std::shared_ptr<Widget> pW( createWidget() );
    ...
    //经由shared_ptr的析构释放
}

14-在资源管理类中小心copying行为

  • 复制‘资源管理类’,必须要复制它管理的资源(深拷贝)。

  • 而开发中常见的copying行为是抑制资源复制、施行引用计数(因为有时数据的复制会增大开销,程序只需要原始数据,也并无必要复制数据)。

15-在资源管理类中提供对原始资源的访问

  • 资源管理类应该提供获取原始资源的接口,例如shared_ptr会有get接口访问原始指针。

16-成对使用new和delete时要采样相同形式

  • 如果在new中使用 [],也要在对应的delete中使用 []
  • 尽量不要对数组typedef,因为new时看不到明显的 [],但delete时需要使用 []
//正确的做法
std::string* sptr1 = new std::string("hello");
std::string* sptr2 = new std::string[10];

delete sptr1;
delete []sptr2;

//不建议typedef数组,应该使用vector等容器替代
typedef std::string Sarry[100];
std::string* saptr = new Sarry;

delete []saptr;

17-以独立语句将newed对象置入智能指针

  • 如果不是独立语句,在语句内的执行顺序无法保证,假如中间发生异常,new的对象没有来的及置入智能指针来管理,则会造成内存泄漏

设计与声明

18-让接口容易被正确使用

  • 防止参数类型一样的参数误用:将原类型封装成特有的类型、限制类型操作等

  • 减少让客户承担资源释放的责任:例如之前的工厂函数,返回new的指针,可以改成返回智能指针

  • shared_ptr可以自定义释放函数,可以防止cross-DLL-problem

19-设计class犹如设计type

  • 新type的对象应该如何被创建和销毁(构造、析构等的设计)
  • 对象的初始化和对象的赋值该有什么差别(构造函数和赋值操作符的差异)
  • 新type的对象如果passed by value(以值传递),意味着什么(copy构造的设计,和浅拷贝还是深拷贝)
  • 什么是新type的“合法值”(成员变量的有效值范围,如何进行约束)
  • 新type需要配合某个继承关系吗(注意virtual的影响,尤其是析构函数)
  • 新type需要什么样的转换(是否允许其他类型隐式、强制转换)
  • 什么样的操作符和函数对此新type是合理的
  • 什么样的标准函数应该驳回(可以声明为private,防止类外调用默认生成的函数)
  • 谁该取用新的type成员(成员可见度的设计)
  • 什么是新type的 “未声明接口” (undeclared interface)
  • 你的新type有多一般化
  • 你真的需要一个新type吗

20-传引用替代传值

  • 尽量以传引用替代传值,前者通常更高效,还可以避免切割问题

  • 内置类型并不适用上条规则

切割问题演示

class Window{
public:
    virtual ~Window() {}
    virtual void display() {
        std::cout << "display windows" << std::endl;
    }
};

class SubWindow : public Window {
public:
    virtual void display() {
        std::cout << "display sub windows" << std::endl;
    }
};

///即使传入SubWindow,传递时调用的是其基类的copy构造,
///而派生类的部分被切割了,没有copy,这里会调用Window::display,而非派生类的。
void MyDisplay(Window w) {
    w.display();
}

///传递引用,传递时是什么类型,就是什么类型
void MyDisplay2(Window& w) {
    w.display();
}

当窥视C++编译器的底层,你会发现,reference往往以指针实现出来

21-必须返回对象时,别返回引用

  • 不要返回一个指针或者引用指向局部变量,函数退出后,局部变量被销毁,指针或引用就变成未定义的
  • 也不要返回引用指向在堆上申请的空间(是为了防止用户不释放/多次释放?)
  • 如果需要同时使用多个返回值,则返回指针或引用指向局部static变量会不符合设计(类的实例函数是这么设计的,和这条提醒中的场景不一样,条款四)

22-成员变量声明为private

  • 将成员变量声明为private,需要用户访问的变量在public提供相应的函数。这样可以保证访问数据的一致性(用户不用担心是否要加括号)、可细微划分访问控制(变量的读写等),并提供class作者在修改时的弹性(只要保持接口函数不变,具体实现修改不影响用户调用)。
  • protected并不比public更具有封装性(派生类的数量也同样不可控制)。

23-以non-member non-friend函数替换member函数

  • 成员函数可以访问到private,意味着会减少封装性。

以下面例子说明:

//把一个web看作一个类,提供清除cookie、history、cache接口
class WebExample {
public:
    void clearCookie();
    void clearHistory();
    void clearCache();
};

//而有用户需要一个清除所有的接口,可有两种实现方案
//1,添加一个public函数,在函数内调用这三个函数
void WebExample::clearAll() {
    clearCookie();
    clearHistory();
    clearCache();
}

//2,设计一个非成员函数
void clearAll(WebExample& w) {
    w.clearCookie();
    w.clearHistory();
    w.clearCache();
}

在这个例子中,设计一个非成员函数更合适(非成员是指非WebExample成员,可以是别的类成员)

  • 没有破坏class的封装性
  • 有利于以后按照此方式拓展

常见的使用方式是,定义一个非成员函数,并将这个函数和class放在同一namespace下(方便识别管理,namespace可以分布在多个文件里,也方便拓展或客户选择自己需要的头文件)。

24-所有参数皆需类型转换,采用non-member函数

  • 如果你需要为某个函数的所有参数(包括被 this 指针所指的那个隐喻参数)进行类型转换,那么这个函数必须是个 non-member
    这一条在使用模板时存在争议

我看懂了这条中提供的例子,却总结不出道理,也许需要以后再来读一遍

class Rational {
public:
    Rational(int numerator=0, int denominator=1);  //没有使用explicit,编译器可以为其执行隐式转换
    int numerator() const {return numerator;}     //分子
    int denominator() const {return denominator;} //分母
    ... ...

    // 为这个类提供*运算符重载
    Rational operator*(const Rational& rhs);
};

Rational num1(2,3);

Rational num2 = num1 * 2;  //2按照num1的类型发生了隐式转换
Rational num3 = 3 * num1;  //数字3在前,编译器找不到对应的隐式转换方式,无法通过编译

// 可以提供非类成员的重载函数
Rational operator*(const Rational& rhs1, const Rational& rhs2) {
    return Rational( rhs1.numerator() * rhs2.numerator(),
        rhs1.denominator() * rhs2.denominator());
}

//这时数字在前的乘法式可以通过编译

25-考虑写出一个不抛出异常的swap函数

  • 当std::swap对自定义的类型效率不高时,提供一个swap成员函数,并确定这个函数不抛出异常。(例如只需要互换指针的资源类)

  • 当你提供了一个成员swap函数时,应该再提供一个非成员的swap函数,在函数中调用前者。对于class(非templates),需要特化std::swap

  • 调用swap时应针对 std::swap使用 using声明式,然后调用 swap并且不带任何“命名空间资格修饰”

  • 不要在swap中抛出异常(?在条款29描述)

namespace RAT{

class Rational {
public:
    Rational(){ }

    //成员函数
    void swap(Rational& rhs) {
        std::cout << "member swap" << std::endl;
        std::swap( this->pBuff, rhs.pBuff);
    }
private:
    void *pBuff;
};

//同一命名空间下non-member版本
void swap(Rational& rhs1, Rational& rhs2) {
    std::cout << "non-member swap" << std::endl;
    rhs1.swap(rhs2);
}

}

//对这个类特例化swap
namespace std {
    template<>
    void swap<RAT::Rational>(RAT::Rational &a, RAT::Rational &b) {
        std::cout << "namespace std swap<Rational>" << std::endl;
        a.swap(b);
    }
}

int main() {
    RAT::Rational num1, num2;

    //正确的使用方式
    {
        using namespace std;
        swap(num1, num2);  
        //调用优先级:non-member版本 > std特例化版本 > std通用版本
    }

    std::cout << "--------" << std::endl;
    //不合理的使用方式
    std::swap(num1, num2);  
    //调用优先级: std特例化版本 > std通用版本,这里不会调用non-member版本
}

实现

26-尽可能延后变量定义的出现时间

  • 在使用变量前(避免程序中途退出,造成变量不必要的构造、析构)
  • 或可以使用初值构造时(避免:定义时default构造+赋值)定义变量

其他:
在循环中的变量,A是在循环外定义一次;B或在每次在循环中定义。
如果在处理效率敏感的代码段,或明确构造、析构的成本大于赋值的成本,则使用A方式;
否则应使用B方式(相对A有更好的可理解性和易维护性)

27-尽量少做转型动作

  • 如果可以,尽量避免转型,特别是在注重效率的代码中避免 dynamic_cast
  • 如果转型是必要的,试着将它隐藏于某个函数里面

旧式转型:
(T)Widget 或 T(Widget) 将Widget转为T类型

新式转型

const_cast<T>( expression )
dynamic_cast<T>( expression )
reinterpret_cast<T>( expression )
static_cast<T>( expression )
  • const_cast: 目标类型只能是指针或者引用,作用是去掉类型的const或volatile属性
  • dynamic_cast: 用于多态类之间的类型转换,类层次间的上行转换和下行转换
  • reinterpret_cast: 不同类型的指针类型转换(低层的转换,不同编译器上表现不同)
  • static_cast: 强迫隐式转换

28-避免返回handles指向对象内部成员

这里的handles指:指针或迭代器或 reference

  • 增加封装性
  • 最主要的是一旦通过返回值传出这种handles,就要面临所指的成员比handles更早释放的问题。

29-为‘异常安全’而努力是值得的

  • 异常安全函数(Exception-safe functions)即使发生异常也不会泄漏资源或破坏数据结构。

例如:使用智能指针保证资源可以被释放,使用unique_lock和lock_guard保证抛出异常时锁不会被一直占用。

30-透彻了解inlining的里里外外

  • 隐式inline,class内定义的函数;显式inline,加inline关键字。

  • 将大多数 inlining 限制在小型、被频繁调用的函数身上。要权衡 潜在的代码膨胀问题(重复代码会被铺开) 和 程序运行速度提升(不用函数调用的产生的资源消耗)。

  • inline关键字只是对编译器的建议,编译器可以忽略。大部分编译器拒绝将太过复杂的函数inlining,而几乎所有发virtual函数也不会inling(virtual函数意味着运行时才能确定执行哪个版本)。

  • 有时候同一和inline函数会在程序不同地方采用(inline/outline)不同的版本,比如:之间调用inline函数,编译器使用的是inline版本;利用指针调用inline函数,编译器会使用outline版本。

  • 大部分调试器面对 inline 函数都束手无策,许多环境调试版本会禁止inlining。

  • 动态库的接口不要设计成inline函数。后续修改时需要全部重新编译。

31-文件间的编译依存关系降至最低

Handle classes
没太看明白书里说的,我理解是这样:
使用一个对象时,需要知道它的定义;但只定义一个它的指针,则只用知道它的声明即可

class Data;

class Persion {
    Data *d;
    //Data d; 这是不允许的
};
... ...
/// 而在Persion的具体实现函数中,调用Data指针时,是需要知道Data的定义的

Interface classes
在头文件中设计一个接口类,里面只包含所需接口的纯虚/虚函数、创建实现类的接口,具体的实现类需要继承这个接口类。
当修改具体实现细节时,调用这个接口的程序就无需再次编译。

继承与面向对象设计

32-确定你的public继承塑模出‘is-a’关系

“public继承” 意味着 is-a ,适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个base class对象。
就是做派生类是基类的一个特殊版本的理解。

33-避免遮掩继承而来的名称

不是虚函数重写的情况下,避免派生类和基类的函数名或变量名相同。
派生类作用域内,会覆盖掉基类的同名函数,这么做不容易理解代码。

class Base{
public:
    void func1() {std::cout << "base func1" << std::endl;}
    void func1(int a) {std::cout << "base func1, a=" << a << std::endl;}
};

class Derived : public Base {
public:
    void func1() {std::cout << "derived func1" << std::endl;}

    void func2() {
        func1();  //调用的自己的func1
        //func1(1);  //基类的func1重载也被派生类的func1覆盖了,这里会无法通过编译
        Base::func1(1);  //或指定作用域
    }
};

34-区分接口继承和实现继承

  • 纯虚函数表示必须要重写的接口,虚函数则表示派生类可以自己实现的接口。

  • 如果想让派生类必须实现某个接口,同时又想提供缺省值。则可以把接口在基类定义成纯虚函数,并提供实现函数(纯虚函数也可以有实现)。

    • 这样当派生类未定义接口时,编译器会报错;当想使用默认实现时,可以在接口函数内调用基类的实现。

35-考虑virtual函数以外的方法

  • non-virtual interface(NVI)方式,在非虚函数内调用protected或private的虚函数,并作为类的接口。
    相当于对虚函数做了一层包裹,便于在调用虚函数前/后,添加这个类所需的特殊处理。(template method设计模式的一种特殊形式)

  • 以函数指针成员替换virtual函数。根据不同情况,让指针指向不同的实现函数。(strategy设计模式的一种形式)
    要注意,函数如果需要访问private成员而设置firend,可能会破坏对象的封装性。

  • 以std::function<>替换virtual函数。可以传入参数列表,更加灵活。

  • 将继承体系内的虚函数,替换成另一个继承体系的虚函数(或干脆是它整个对象)。

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

普通函数是静态绑定,会根据指针类型来调用函数;虚函数是动态绑定,根据指针所指调用函数。

避免编程时混乱这个问题,应当不要重新定义继承来的普通函数。

37-不重定义继承来的缺省参数值

  • 如果基类的virtual函数的参数设置有默认值,派生类就不该重写这个默认值。因为virtual函数虽然是动态绑定,但参数默认值则是静态绑定。

例:

class Base {
public:
    virtual void printA(int a=10) { std::cout << "baseA, " << a << std::endl;}
};

class derived : public Base {
public:
    virtual void printA(int a=100) { std::cout << "derivedA, " << a << std::endl;}
};

int main() {
    Base* tmp = new derived;

    tmp->printA();

    delete tmp;
    return 0;
}

执行结果是:
derivedA, 10

关于静态/动态 类型/绑定:
静态类型/绑定是在编译阶段指定的,动态类型/绑定是在运行时确定的。

这个例子中tmp的静态类型是Base* ,动态类型是derived。virtual函数是动态绑定,但参数默认值是静态绑定。
所以tmp->printA函数会跟着tmp的动态类型不同而绑定不同的函数;而参数默认值一直是静态类型对应的。

38-通过复合塑模出 has-a

之前(条款32)提出的is-a的概念是继承发生时,派生类意味着是一个(is-a)特殊的基类。

这里的has-a是指在编程中,会有很多对象并不需要/适合做继承,这时就需要把其他对象定义成自己的成员。

39-明智而审慎的使用private继承

  • private继承意味着has-a的模型,这种情况可以在类中声明对应的成员对象替代(条款38)。
    • 但当派生类需要访问其protected成员或重写virtual函数,但又不想在自身开放这个对象的接口,private继承是合理的。

40-明智而审慎的使用多重继承

多重继承容易出现菱形继承的情况,使用虚继承可以保证菱形上的基类只有一份。

  • 多重继承比单一继承复杂。它可能导致新的歧义性,以及对 virtual 继承的需要。
  • virtual继承会增加大小、速度、初始化(及赋值)复杂度等等成本。如果 virtual base classes 不带任何数据,将是最具实用价值的情况。
  • 多重继承的确有正当用途。其中一个情节涉及“public继承某个Interface class” 和 “private 继承某个协助实现的 class”的两相组合。

模板与泛型编程

41-了解隐式接口和编译期多态

class和template都支持接口和多态

  • class的接口是显示的;通过继承和virtual函数实现多态,发生在运行阶段
  • template的接口是隐式的;通过template的具象话来实现多态,发生在编译阶段

关于template的接口是隐式的理解:

template<typename T>
void test_func(T &t) {
    if (t.size() > 10) {
        t.resize();
    }
}
//这里模板T隐式的有size()和resize()接口,如果传入的类型没有这两个接口,则编译报错。
//而对T和其成员的类型要求并不严格,如果可以通过隐式转换成需要的类型,就可以通过编译。

42-了解typename的双重意义

  • 声明模板参数时,class和typename可以互换,意义相同
  • typename用来标识“嵌套从属类型名称”,但在基类列表、成员初始值列表不使用
template<typename T>  //这里typename和class关键字可以互换
class Derived : public Base<T>::NEsted {   //基类列表不使用typename
    Derived(int x) : Base<T>::NEsted(x) {  //成员初始值列表不使用
        typename Base<T>::NEsted tmp(x);   //嵌套从属类型,需要加typename关键字,否则编译器不认为这是个类型
        typename Base<T>::NEsted::iter it; //涉及到模板内部的类型,使用时需要加typename
    }
}

43-学习处理模板化基类内的名称

  • 模板化基类的成员在派生类无法直接访问,可以通过this指针或指定作用域访问。
  • 模板化会让继承不再畅行无阻
template<typename T>
class Base {
public:
    virtual void printA(T a) { std::cout << "baseA, " << a << std::endl;}
    void printB(T b) {std::cout << "baseB, " << b << std::endl;}
};

template<typename T>
class Derived : public Base<T> {
public:
    virtual void printA(T a) { std::cout << "derivedA, " << a << std::endl;}

    void derived_print(T c) {
        //printB(c);  //编译报错,基类带模板参数时,编译器不去未具象化的基类查找函数定义
        this->printB(c);  //可以通过this指出
        Base<T>::printB(c); //或指定作用域
    }
};

int main() {
    Base<std::string>* d1 = new Derived<std::string>;
    d1->printA("qwer");
    d1->printB("123");
    //d1->derived_print("qw");  //这里竟然undefined
    delete d1;

    Derived<std::string> d2;
    d2.printA("fesd");
    d2.printB("123");
    d2.derived_print("zxc");

    return 0;
}

44-将与参数无关的代码抽离template

  • 非类型模板参数可能会导致模板生成的代码膨胀。

例如:

template<typename T, size_t n>
class Widge {
... ...
};

Widge<std::string, 10> w1;
Widge<std::string, 100> w2;

这样w1和w2成了两个类型,但绝大部分处理中10和100所属的size_t类型处理方式是一致的。
而size_t和模板类型无关,是属于非类型模板参数。

解决方案有:模板类只保留模板参数,在派生类传入不同的size_t。

45-运用成员函数模板接受所有兼容类型

  • 使用成员函数模板接受所有兼容类型。我理解为可以不加explicit关键字,让兼容类型发生隐式转换;或者明确声明所有兼容参数的函数。
  • 看的我实在是头疼。

46-需要类型转换时请为模板定义非成员函数

条款24介绍了需要/允许参数隐式转换的函数要定义成类的非成员函数。
对于模板也是这样,但条款24的形式不适用模板。

我这里进行了测试,模板类Rational不再对int进行隐式转换,无论重写乘法的函数定义在类内或类外。
但函数定义在类内或类外都可以通过编译。

template<typename T>
class Rational {
public:
    Rational(T num, T den) : numerator_(num), denominator_(den) {

    }
    T numerator() const {return numerator_;}     //分子
    T denominator() const {return denominator_;} //分母

    Rational<T> operator*(const Rational<T>& rhs) {
        std::cout << "member func" << std::endl;
        return Rational<T>( this->numerator() * rhs.numerator(),
            this->denominator() * rhs.denominator());
    }

private:
    T numerator_;
    T denominator_;
};

//定义成类外函数
template<typename T>
Rational<T> operator*(const Rational<T>& rhs1, const Rational<T>& rhs2) {
    std::cout << "non-member func" << std::endl;
    return Rational<T>( rhs1.numerator() * rhs2.numerator(),
        rhs1.denominator() * rhs2.denominator());
}

int main() {
    Rational<int> num1(2,3);

    Rational<int> num2 = num1 * Rational<int>(2,0);  //2按照num1的类型发生了隐式转换
    // Rational<int> num2 = num1 * 2;  //不会隐式转换,编译报错
    // Rational<int> num3 = 3 * num1;

    std::cout << num2.numerator() << std::endl;
}

47-请使用 Traits classes 表现类型信息

  • Traits classes 使得“类型相关信息”在编译期可用,它们以 templates 和 “templates特化” 完成实现。
  • 使用重载,traits classes 有可能在编译期对类型执行if...else 测试。

这一节理解的不清楚,并且也是我第一次听说这个概念。

Traits classes 为使用者提供类型信息。例如标准库中的容器模板都有一个value_type,指示容器元素的类型。

48-认识template元编程

TMP的思想是把一些工作从运行期移至编译期,获得编译时的侦错、提高运行期的效率。

//计算阶乘的模板

template<unsigned T>
struct Factorial {
    enum { value = T * Factorial<T-1>::value };
};

//特例化模板
template<>
struct Factorial<0> {
    enum { value = 1 };
};

int main() {
    std::cout << Factorial<10>::value << std::endl;
    std::cout << Factorial<5>::value << std::endl;
    return 0;
}
//TMP 系以“递归模板具现化”(recursive template instantiation) 取代循环,每个具现体有自己的一份 value, 而每个value有其循环内的适当值。

定制new和delete

49-了解new-handler的行为

  • std::set_new_handler(new_handler)函数可以指定一个函数,在new失败时调用。
    • new-handler函数应该抛出bad_alloc异常、不返回并调用abort或exit
//节选自c++/{version}/new
  /** If you write your own error handler to be called by @c new, it must
   *  be of this type.  */
  typedef void (*new_handler)();

  /// Takes a replacement handler as the argument, returns the
  /// previous handler.
  new_handler set_new_handler(new_handler) throw();

50-了解new和delete的合理替换时机

new和delete可以重载成自定义的函数

void* operator new(std::size_t size) {
    std::cout << "hello new: size= " << size << std::endl;
    void* p = malloc(size);
    return p;
}

这么做的主要目的有:

  • 为了检测运行错误。
  • 为了对内存的使用进行统计。
  • 增加分配和归还的速度。
  • 为了降低缺省内存管理器带来的空间额外开销。
  • 为了弥补缺省分配器中的非最佳齐位(suboptimal alignment)。
  • 为了将相关对象成簇集中。
  • 为了获得非传统的行为。

51-编写new和delete时需固守常规

  • 即使用户申请size_t为0的空间,也需要正确返回。重载new时需要做特殊处理。

  • delete空指针需要是安全的。同上,重载delete时需要对空指针做判断。

  • operator new 应该内含一个无穷循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用 new-handler, 它也应该有能力处理 0 bytes 申请。Class专属版本则还应该处理“比正确大小更大的(错误)申请”。

  • operator delete 应该在收到 null 指针时不做任何事。Class 专属版本则还应该处理“比正确大小更大的(错误)申请”。

52-写了 placement new 也要写 placement delete

  • 如果一个带额外参数的 operator new没有“带相同额外参数”的对应版 operator delete, 那么当new的内存分配动作需要取消并恢复旧观时就没有任何 operator delete 会被调用。——造成内存泄漏。

    • 当new失败时,c++需要负责释放掉已经申请的内存。
  • 重载new和delete时,不要在不需要的地方掩盖默认的new和delete函数。

杂项讨论

53-不要轻易忽略编译器的警告

  • 严肃对待编译器警告,尽量减少警告。

    • 个人经验:函数没有返回值的警告!尤其需要重视,可能会导致程序崩溃。可以使用编译选项:-Werror=return-type,将没有返回值的waring指定为error。
  • 不要过度依赖编译器警告,在不同平台上警告的表现可能会不同。

54-让自己熟悉标准库

55-让自己熟悉boost

boost.org

done

感悟:
这本书从去年年底就开始看,中间断断续续,二三月份才开始认真阅读。有些章节读着确实是痛苦不堪,自己偶尔也会心浮气躁。
但总体还是收获不少,记下这些笔记,希望以后没印象的时候可以再回头翻看,温故知新。