Brief Notes of 《Effective C++》
2021-01-27 本文已影响0人
老杜振熙
本文为学习《Effective C++》各个条款之后的一点概要式的总结。
github博客地址
条款2 尽量以const, enum, inline替代#define
- 宁可用编译器替代预处理器。以#define定义的记号是不会记录到符号表中的;
- #define没有封装性可言。
- enum hack。
enum {tmp=5};
对应的tmp
一定在编译期就可以得到并且不会导致非必要的内存分配。
条款3 尽可能使用const
- 调用const成员函数以实现孪生non-const成员函数。通过使用
const_cast
和static_cast
来达到目的,优点是避免了代码重复。 - 调用non-const成员函数实现const成员函数是错误的。因为这破坏了const的语义约束。
条款5 了解C++默认编写并调用哪些函数
- 如果自定义了需要实参的构造函数,则编译器不会自动生成default ctor
- 如果class内部包含有带有
&
引用类型或者const
常量类型,则编译器不会自动生成copy assignment;因为编译器不知道该怎么处理
条款7 为多态基类声明virtual析构函数
- 每一个带有virtual函数的class都拥有一个指向virtual table的指针,virtual table中包含了所有对应virtual函数的函数指针
- 不要尝试继承任何标准库容器(比如
std::string
),因为它们都没有virtual dtor。这会导致未定义行为 - 没有多态性质的base class也不要声明virtual dtor,比如说
boost::noncopyable
,virtual并无必要,且浪费空间的 - 如果明确了一个类具有多态性质,且作为base class使用,则应该声明virtual dtor
条款8 别让异常逃离析构函数
- 绝对不能让dtor吐出异常,因为很可能会造成资源泄露。对于有可能在dtor中发生的异常,应该将其吞下或者提前终止程序
- 更合适的做法是为客户代码提供一个接口,使得客户有机会去处理可能发生的异常
条款11 在operator=中处理“自我赋值”
核心其实就是不能让指针指向一个未获取的资源;存在3类方法,各有各的优势
- 赋值之前先比较lhs和rhs的地址是否相同,如果相同,则直接返回;
- 先记住之前本身的资源(可以设一个
pOrigin
指针指向旧资源),随后拷贝一份rhs的资源,并令lhs指向新资源,最后再释放掉lhs的旧资源(即delete pOrigin
)(这其实就是copy and swap的步骤...); - copy and swap。先拷贝rhs指向的资源,再令lhs指向的资源和这份拷贝之后的资源进行交换;
条款12 复制对象是勿忘其每一个成分
- 自行编写copy ctor或者operator=是一项重大的责任,因为要考虑到各种细节。而也正是因为这样的原因,当自行编写时,编译器会认定你是一个足够强大的程序员,因此不会对自定义copy ctor和operator=的不好的地方做出任何警告;
- 确保每一个成员变量都被正确拷贝;
- 当目标是derived class时,其base class的成员变量也要被正确拷贝。这需要通过调用base class的copy ctor和operator=来实现;
- 切记,copy ctor和operator=不能相互调用。这从语义上就行不通
条款14 在资源管理类中小心copying行为
对RAII对象执行复制,是需要万分小心的行为,因为它涉及到的资源的最佳处理方式不甚相同;常见的方式包括:
- 禁止复制。很多情况下这是比较科学的做法,因为行为表现的像指针这样的数据类型是不应该重复进行delete的;如果不禁止复制,则必须做到对指涉到的资源也进行复制;
- 引用计数。不多说了,就是智能指针那一套;
条款15 在资源管理类中提供对原始资源的访问
- 诸如
std::shared_ptr
和std::unique_ptr
都会提供get()
成员函数来访问其指涉的底层资源;这不是破坏封装性,而仅仅是一种接口风格; - 访问底层资源的接口,一般而言就两种:①
get()
这样的成员函数,②隐式转换。一般来说还是①更好一点,因为更安全;
条款16 以独立语句将newed对象置入智能指针
- 本条款在《Effective Modern C++》中也有讲述;
- 核心的一点就是在单条语句内,编译器是有着重新编排执行顺序的自由的;
- 因此,诸如
std::shared_ptr<XXX> sp(new XXX);
这样的语句应该单独成句,而不应该嵌入到其他语句中; - 其实现代C++的话,更好的做法是使用
std::make_shared
或者std::make_unique
;它们使用完美转发,且很安全;
条款19 设计class犹如设计type
不多说了,在编写类代码的时候多看看本条款,思考条款中列出的问题;
条款23 宁以non-member、non-friend替换member 函数
- 要理解这个条款,就得明确namespace的作用:①可以跨越多个源码文件;②在实现类似于utility所提供的功能时,更具有优势(因为语义更清晰);③在提供了所需功能基础上达到编译依赖最低,封装性最好
- 书中所举的例子:任务是调用class中的三个成员函数。那么方法大致为两种:①再写一个成员函数,内容就是调用那三个函数;②将新的函数放在class的外部(非成员函数),但位于同一个namespace中;
- 基于上面所陈述的原因,使用第二个方法是更好的方式
条款24 若所有参数皆需要类型转换,请为此采用non-member函数
- member函数的反面是non-member,而不是friend;friend在OOP中能避免则避免,因为太破坏封装性了
- 只有当参数被置于参数列时,这个参数才是隐式类型转换的合格参与者;也就是说,当调用成员函数时,lhs实际上没有被置于参数列中,而是this
条款26 尽可能延后变量定义式的出现时间
- 应该尽可能在要用到某个变量的时候才去定义它(这很显然嘛)
- 关于循环体中的变量的下述两种定义方式,一般情况下,除非明确知道赋值操作的消耗小于构造加析构的时候才使用第一种;因为第一种方式扩大了变量的生命期;
// 第一种
{
...
Weight tmp;
for(int i = 0; i < N; ++i){
tmp = Weight(i);
}
}
// 第二种
{
for(int i = 0; i < N; ++i){
Weight tmp= Weight(i);
...
}
}
条款29 为“异常安全”而努力是值得的
- 所谓的异常安全函数,其实就是发生异常也不会导致资源泄露和数据败坏;包括三类:
- 基本保证:如果函数发生异常,则对应的对象不一定还能还原为调用前的状态,但至少保证还是正常可用的;
- 强烈保证:即使函数发生异常,对象还是能够还原为原来的状态,即只有两种状态:成功调用和不调用;这通常通过copy-and-swap来实现,即先将原来的对象复制一个副本,随后对副本执行相应的改变,如果执行成功,则原对象和副本执行swap;如果发生异常,原对象也未发生任何改变
- 不抛掷(nothrow)保证:即保证函数不发生异常;这通常办不到。。。只要涉及到了动态内存的分配,都是有可能发生异常的
- 可以看出,级别越高,其实实现是越困难的,并且带来的开销也会越高;因此,应该挑选的是现实可实施下的最高等级
- 异常安全性是遵循木桶原理的,只要函数调用了等级较低的函数,那么它的异常安全性也会降低
条款30 透彻了解inlining的里里外外
-
inline
在大多数C++程序中都是编译期行为; -
inline
仅仅是一个申请,并不保证一定会内联; - 是否真正内联还取决于函数的调用方式;(如果以函数指针进行调用,那么就不可能被内联了);
-
inline
的优势是避免调用开销,但也存在以下问题:- 代码膨胀:毕竟,如果在多处都调用了该函数,那么就会有多份该函数体的副本;
- 编译依赖:如果
inline
函数发生了改变,那么所有客户代码都必须重新编译;反之,如果不是内联的,那么仅仅重新链接一下就行
条款31 将文件间的编译依存关系降至最低
C++中降低文件间的编译依赖,主要就是两种手段:handle class以及interface class
- 如果客户代码所使用的的头文件中,直接包含的是要使用的class的具体实现(包括各个函数定义),那么就形成了依赖关系;
- 所谓依赖关系,就是指,只要一个class改变了一点点实现,那么所有使用它的客户代码都需要重新编译;
-
handle class:
- 所谓的handle class,实际上意味着一个负责声明的class和一个负责具体实现的class(假设为
class Widget
和class WidgetImpl
);两者的接口全部一致,而客户代码使用的是class Widget
- class Widget中不对任何方法进行具体实现,只声明类接口;且涉及到非基本类型的自定义类型成员变量(比如此处的
class WidgetImpl
),都使用前置声明和(智能)指针来进行指涉; - 标准库组件无需也不应该被前置声明;直接
#include
就行; - 这样一来,class Widget的头文件中不会
#include
任何其他的头文件(除了标准库);而这,也就杜绝了客户代码对除了class Widget头文件之外的文件产生任何依赖; - 至于class Widget的接口实现,则在其.cpp文件中去
#include "WidgetImpl"
,然后调用class WidgetImpl的接口即可;
- 所谓的handle class,实际上意味着一个负责声明的class和一个负责具体实现的class(假设为
-
interface class
- 即类似于Java中的interface,不过实现方式是定义成虚基类;面向派生谱系的多态技术;
条款33 避免遮掩继承而来的名称
- C++应对派生谱系中的函数调用,归根结底就是以名称为准进行匹配;
- 无论是变量还是函数,是重载还是重写,是否是虚函数,甚至也无论函数的参数列表是什么形式,都没有任何关系;编译器只要在当前的域中找到了对应的名称,就直接结束匹配;
- 这意味着:如果base class中定义了一组重载函数,而后又在derived class中定义了一个同名的函数,那么当用derived class类型(或引用、指针)来调用这个名称的函数时,基类的重载函数统统被覆盖;
- 克服这个问题的方法:在派生类中加入using声明:
class Base{
public:
// 重载函数
void f(int);
void f();
};
class Derived : public Base {
public:
using Base::f; // OK,基类的重载函数不会被覆盖了
void f(int, int);
};
- 如何实现仅继承部分基类接口?很简单,使用private继承+转接函数;
- 所谓的转接函数就是派生类中的公共接口,但这些公共接口只是去调用基类的函数;
- 基类因为被private继承了,所以其所有接口也就被隐藏了;
条款34 区分接口继承和实现继承
- 在继承谱系中,虚函数,纯虚函数,普通函数之间的根本区别就是对待接口继承和实现继承的方式不同;
- 纯虚函数:只继承接口;
- 虚函数:继承接口和一份缺省实现
- 普通函数:继承接口和一份强制实现======》
- 这意味着任何derived class都不应该重新定义base class中的普通函数;
- 条款36就是在陈述这一点;本质上就是因为普通函数实施的是静态绑定,相同的对象会因为其指针或引用的类型的不同而执行不同的函数体(有可能是基类的函数体,也可能是派生类的函数体);这造成了不确定性(另一方面,单个基类指针,即使指向不同类型的派生类,其调用普通函数时,也只会执行基类函数体,造成了程序错误);
- 注:C++的虚函数模型在二进制兼容性(ABI)方面的负面影响是极大的。如果一个程序会设计为一个动态库,客户代码对其进行加载调用,如果后续动态库进行了升级,在某个类中加入了新的虚函数,那么如果客户代码不重新编译的话,会直接调用不同的函数,造成错误,因为客户代码在编译结束以后,就直接以虚表指针加偏移的形式去调用函数,而动态库的各个函数的偏移可能在升级之后就完全改变了。
条款37 绝不重新定义继承而来的缺省参数值
- 虽然虚函数实行的是动态绑定,但虚函数(实际上是任何函数)中的参数缺省值却是静态绑定的;
- 这意味着函数的参数缺省值不应该被重新定义;理由还是一样的,这会因为指针或引用的类型不同而造成不确定性;
- 如果需要为虚函数定义参数缺省值,则更好的做法是:
- 定义一个普通函数,有缺省值;
- 实际的虚函数变为private,且无缺省值;
- 使用普通函数去调用虚函数;
- 这样就避免了代码在派生谱系中的依赖性;
条款38 通过复合塑膜出has-a或“根据某物实现”
- 关键就是理解复合(Composition)二字;复合包含应用域和实现域两种关系;
- 应用域:即把一个class作为组件;比如说
class People
的一个组件是class PhoneNumber
;这就是所谓的has-a关系; - 实现域:即某个class需要通过另一个class进行实现,但两者并不存在完美的继承关系;比如说通过一个
std::vector<int>
来实现一个class Stack<int>
;这就是所谓的Is-implemented-int-terms-of关系
条款39 明智而审慎地使用private继承
- private继承并不具备“软件设计”层面的意义,其仅仅是一种“软件实现”的技术;
- 条款38中已经阐述过"Is-implemented-in-terms-of"关系,事实上,private继承也是这种意义;
- “private继承”和“复合”的区别就在于:
- 一般情况下,能使用复合就使用复合;
- 只有当明确是Is-implemented-in-terms-of关系的同时,需要重写基类的虚函数或者访问protect变量时,才使用private继承;因为这是复合无法做到的;
- EBO(empty-base-optimization):C++中一个空类的size不等于0,而是1;而继承一个空类不会加大size;这就是private的另一个优势;
条款40 明智而审慎地使用多重继承
- 总的来说,多重继承还是有用的,但却是也存在很多的限制;
- 条款中所涉及的“虚继承”概念是比较重要的:
- 多重继承很可能会发生所谓的菱形继承:即某一个基类和某一个派生类之间存在多条继承路径;
- 如果使用非虚继承的话,派生类将会保存同一个基类的多个副本;但实际上一份副本就足够了;这造成了空间浪费;更糟糕的则是因为多份副本导致的命名冲突;
- 虚继承是解决这个问题的唯一方法;它使得派生类可以只保留基类的一份副本;
- 但虚继承也有自己的缺点:最突出的就是加大了运行时消耗;因为采取虚继承的话,class的size和内存模型就只能在运行期才能知晓了;(C++中虚函数、虚继承内存模型 - 知乎 (zhihu.com))
条款41 了解隐式接口和编译器多态
- 基于模板的泛型编程其实也隐含着“接口”的概念,但是是隐式的。这和派生谱系中的接口机制有很大不同;
- 隐式接口是基于:必须满足模板代码中隐含的一组约束。比如书中给出的例子:
if(w.size() > 10 && w != someNastyWidget){...}
,w
的类型为typename T
,那么就必须满足:if中给出的表达式能够转换为bool类型。 - 所谓的编译器多态就是:编译器根据隐式接口去决定需要(生成)调用哪一个重载函数以及具现化模板。
条款42 了解typename的双重意义
- 当用于模板参数的时候,typaname和class没有区别;
- 如果某个名称是嵌套从属名称(nested-dependent-names),即它的性质(是变量名还是类型名)需要由模板参数来决定,那么如果它确实是一个类型名的话,就需要加上
typename
;(因为编译器不知道它到底是什么东西); - 萃取器:即traits,通过模板以及模板偏特化技术,将传递进去的类型的一些相关特征给萃取出来。比如说
typename std::iterator_traits<iteT>::value_type
表示的就是iteT类型的迭代器所指涉的元素类型;萃取器的优势在于任何类型的迭代器(甚至是原生指针)都能萃取出想要的特征;
条款43 学习处理模板化基类的名称
- 模板化基类(templatized-base-class):也就是说继承来的基类是一个模板,其具体是哪一个类暂时无法确定;
- 当模板化类继承自一个模板化基类时,编译器就默认基类中的所有名称是无法得知的;除非显式指出;
- 编译器之所以这样做,是因为由于模板偏特化以及全特化的存在,使得模板化基类不一定会拥有模板中所写的所用名称;
- 显示指出的方法有3类:使用
this->name
;使用using BaseClass<T>::name;
;显式调用BaseClass<T>::name
;其中,第3种方法会丧失动态绑定特性,因此不是很推荐;
条款44 将与参数无关的代码抽离templates
- 如果模板类中的某些函数与模板参数没有关系,那么多个具现化的实体类则会拥有相同的函数体,这无疑使得目标码变得冗余;
- 更好的做法是将这些与模板参数无关的代码抽离出来,变成基类代码或者其他,然后不同的模板的具现化class去共同调用这些相同的代码(此时这些代码就只有一份实体了);
- 当然,这样也会存在一定问题。简而言之,谁好谁坏,还是得由具体的运行环境去决定;
条款45 运用成员函数模板接受所有兼容类型
比如对于如下的一个模板类,很多时候,我们可能需要使用TmpDemo<int>
去初始化一个tmpDemo<double>
对象。这完全是合理的,但问题是,在模板编程的世界里,TmpDemo<int>
和TmpDemo<double>
是完全没有任何关系的。或者可以直接在模板类中定义这样一个构造函数,但如果遭遇了其他的需求呢?比如说int变为了char,又或者,现在的typename是一个继承谱系中的各种类型。显然,单一的成员函数是解决不了问题的。
template <typename T>
class TmpDemo{
// ...
};
- 成员模板函数是解决这个问题的唯一方法;在成员函数中再声明typename,来让编译器来处理各种需求;
- 泛化构造函数是成员模板函数的一种,它解决的是通过
TmpDemo<U>
来初始化TmpDemo<T>
的问题; - 即使声明了泛化构造函数,也还是要去自定义拷贝构造函数,这一点需要注意;
条款46 需要类型转换时请为模板定义非成员函数
- 该条款和条款24的思想是一致的,也就是当函数的所有参数都涉及隐式转换时,它最好是一个非成员函数(因为this是无法转换的);
- 和条款24的不同之处在于,本条款涉及到的是模板类;即,某个函数的各个参数是模板类型;
- 很显然,这种函数也需要定义为非成员函数;
- 不同之处在于:因为涉及到了模板,那么在进行函数模板的模板参数推导时,绝对无法进行隐式转换,比如说对于如下的代码,直接调用
int ans = addFunc(tmp, 3);
是无法通过编译的,因为这涉及到了从3
到TmpDemo<T>(3)
的隐式转换;而这在函数模板参数推导中是绝对禁止的;
template <typename T>
class TmpDemo{
public:
TmpDemo(const T& num){value = num;}
private:
T num;
};
template <typename T>
const T addFunc(const TmpDemo<T> &t1, const TmpDemo<T> &t2){
return t1.num * t2.num;
}
TmpDemo<int> tmp(2);
- 解决方法就是把非成员函数定义在模板类的内部,并声明为friend。因为模板类会将typename信息进行硬编码,就可以直接进行转换了。
条款49 了解new-handler的行为
- new-handler:一个函数指针类型
typedef void (*new_handler) ( );
,并对应一个global的函数指针,由用户通过new_handler std::set_new_handler(new_handler p)
填充其值(可能会有系统默认值);当new无法分配出足够的空间时,系统就会在抛出异常之前先调用这个函数; - 通常情况下,拥有以下几种行为的new-handler是更好的:
- ①可以使得下一次调用new时有更大概率成功;这可以通过预先分配一块大内存,随后每次调用new-handler时归还部分内存;
- ②安装其他new-handler和卸载本地的new-handler:各个class有可能会定义自己的new-handler,因此最好的做法是new不同的class的时候,调用各自不同的new-handler,并在调用完毕后将new-handler进行恢复;
- ③抛出
std::bad_alloc
或者直接退出exit()
或std::abort()
- 如何实现方式②?答:自定义
operator new
以及使用基于CRTP(curiously recursive template pattern)的模板技术- 为一个需要设置new-handler的class自定义一个operator new和set_new_handler,而在operator new内部的流程就是:先调用
std::set_new_handler
设置自己的new-handler,随后调用系统的new,再之后就是恢复new-handler到系统原本的值了; - 由于设置和恢复完全适配于一个RAII,因此更优秀的做法便是再设置一个资源管理类,在构造函数内保存之前的new-handler,并在析构函数内恢复之前的new-handler;
- 接下来就是考虑这样一个问题了,如果不同的class都需要自定义new-handler的话,而又由于自定义new-handler其实是一套完全一致的流程,除了各自的new-handler不一样;因此CRTP就派上用场了,以下代码就是完整的实例。
- 为一个需要设置new-handler的class自定义一个operator new和set_new_handler,而在operator new内部的流程就是:先调用
class HandleHolder{
public:
HandleHolder(const HandleHoldr &) = delete; // 禁止拷贝
HandleHolder &operator=(const HandleHolder &) = delete;
HandleHolder(std::new_handler p): oldHandler(p) {}
~HandleHolder(){std::set_new_handler(oldHandler);}
private:
std::new_handler oldHandler;
};
template <typename T>
class NewHandlerHelper{ // 此处没有定义自己的set_new_handler了,感觉没有必要
public:
NewhandlerHelper(std::new_handler p): myHandler(p) {}
static void *operator new(size_t size) throw(std::bad_alloc){ // 每个class对应一个operator new
HandleHolder tmp(std::set_new_handler(myHandler)); // std::set_new_handler会返回之前的new-handler
return ::operator new(size);
// tmp被析构,new-handler也就得以恢复
}
private:
static std::new_handler myHandler; // 每个class对应一个new_handler
};
template <typename T>
std::new_handler NewHandlerHelper<T>::myHandler = nullptr; // static变量要记得初始化
class Widget : public NewHandlerHelper<Widget> { // 自己继承自己,虽然看起来很奇怪,但实际上是行得通的;本质上只是让不同的class拥有不同的myHandler
/**
* ...
* Widge只要在构造函数处给NewHandlerHelper提供自己的new-handler即可
* ...
*/
};
条款52 写了placement new也要写placement delete
- 当代码中使用
new
表达式之后,发生了两件事情:- ①调用
void *operator new(size_t size)
来获取一块原始内存(raw memory); - ②调用class的ctor以构造对应的对象
- ①调用
- 因为有两个步骤的存在,因此,如果在第2个阶段发生了异常,就有可能产生内存泄漏;
- 为了避免可能的内存泄漏,当发生上述情况时,由系统来负责回收对应的内存;
- 这就引出了一个问题,系统如何知道应该调用哪一个版本的delete呢?系统的原则是,使用和
operator new
的参数列表一致的operator delete
- 因此就有了本条条款的原则:定义了一个placement new,就需要定义对应的placement delete;所谓的placement就是参数列表除了
size_t
以外还包括其他的参数;