本文为Effective C++的学习笔记,第一遍学习有很多不理解的地方,后续需要复习。
0 导读
术语
-
声明(declaration)
-
告诉编译器某个东西的名称和类型,但略去细节;
-
每个函数的声明揭示其签名式(signature),也就是参数和返回类型。
-
-
定义(definition)
- 提供编译器一些声明式所遗漏的细节。
-
初始化(initialization)
-
给予对象初值的过程;
-
对用户自定义的类型而言,初始化由构造函数执行。
1 让自己习惯C++
条款01:视C++为一个语言联邦
-
C++是多重范型编程语言,包括4种次语言:
- C;
- Object-Oriented C++;
- Template C++;
- STL(template程序库,包括容器、迭代器、算法和函数对象)。
-
C++高效编程视状况而变化,取决于使用C++的哪一部分。
条款02:尽量以const,enum,inline替换 #define
- 对于单纯常量,最好以const对象或enums替换#defines;
- 对于形似函数的宏,最好改用inline函数替换#defines。
条款03:尽可能使用const
-
如果关键词const出现在星号左边,表示被指物是常量(底层const);如果出现在右边,表示指针自身是常量(顶层const);
-
令函数返回一个常量值,可以预防无意义的赋值动作;
-
const成员函数:
-
两个成员函数如果只是常量性不同,可以被重载;
//const成员函数说明隐式this指针指向一个const const char& operator[](std::size_t position) const//常量性声明,注意位置
-
const对象只能访问const成员函数,而非const对象可以访问任意的成员函数;
-
const成员函数不能修改对象的数据成员,const对象的成员变量不可以修改(mutable修饰的数据成员除外)。
-
-
当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本避免代码重复(使用转型,条款27提及)。
条款03:确认对象被使用前已先被初始化
-
对象成员变量的初始化动作发生在进入构造函数本体之前;
-
为内置型对象进行手工初始化,内置类型以外构造函数负责初始化;
-
构造函数最好使用成员初值列 ,而不使用赋值操作 ,最好总是以声明次序为其次序;
-
为免除“跨编译单元之初始化次序”问题,最好以local static对象替换non-local static对象。
2 构造/析构/赋值运算
条款05 了解C++默默编写并调用了哪些函数
- 如果自己不声明, 编译器就会暗自为class创建一个default构造函数、一个copy构造函数、一个copy assignment操作符(代码合法有意义时编译器才会生成),以及一个析构函数;
- 如果用户声明了构造函数,则编译器不会再为它创建default构造函数;
- base class如果把copy构造函数或copy assignment操作符设置为private,derived class将拒绝生成copy构造函数或copy assignment操作符;
- 如果打算再一个内含“reference”成员的class内支持赋值操作,必须自己定义copy assignment运算符。
条款06 若不想使用编译器自动生成的函数,就该明确拒绝
-
为驳回编译器暗自提供的机能,可以将相应的成员函数声明为private而且不实现他们,或者使用像Uncopyable这样的base class;
class HomeForSale { public: ... private: ... HomeForSale(const HomeForSale&); //只有声明 HomeForSale& operator=(const HomeForSale&); }
class Uncopyable { protected: Uncopyable(){} //允许派生类对象构造和析构 ~Uncopyable(){} private: Uncopyable(const Uncopyable&); //但阻止copying Uncopyable& operator=(const Uncopyable&); }; //为求阻止HomeForSale对象被拷贝,我们可以继承Uncopyable: class HomeForSale: private Uncopyable { };
条款07:为多态基类声明virtual析构函数
- polymorphic(带多态性质的)base classes才应该声明一个virtual析构函数(免除“局部销毁”问题),不为多态用途的base classes不该声明virtual 析构函数。
条款08:别让异常逃离析构函数
- 析构函数绝对不能抛出异常;如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序;
- 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么类应该提供一个普通函数(而非在析构函数中)执行该操作。
条款09:绝不在构造和析构过程中调用virtual函数
- base class构造函数的执行更早于derived class构造函数;
- 由于无法使用virtual函数从base classes向下调用,可以令derived classes将必要的构造信息向上传递至base class构造函数。
条款10:令operator= 返回一个reference to *this
- 赋值采用右结合律。
条款11:在operator= 中处理“自我赋值”
- 加一个**“证同测试”**使具有“自我赋值安全性”;
- 精心安排的语句可以使代码具有“异常安全性”(自动获得“自我赋值安全性”):在复制构造之前别删除原指针
- 另一种替代方案是**“copy and swap”**。
条款12:复制对象时务忘其每一个成分
- 复制所有的local成员变量以及所有base class成分;
- 不要尝试以一个copying函数实现另一个copying函数(copying函数包括copy构造函数和copy assignment运算符)。
3 资源管理
- 资源:一旦用了它,将来必须还给系统。
条款13:以对象管理资源
- 关键思想:获得资源后立刻放进管理对象,管理对象运用析构函数确保资源被释放;
- 以对象管理资源的观念常被称为“资源取得时机便是初始化时机”,RAII;
- 使用智能指针管理资源:
- auto_ptr:被销毁会自动删除它所指之物,复制所得的指针将获得资源的唯一拥有权;
- 引用计数型智慧指针(RCSP)如shared_ptr:持续追踪多少个指针指向该资源,无人指向他时自动删除该资源。
条款14:在资源管理类小心copy行为
- 复制RAII对象必须一并复制它所管理的资源;
- 一般资源管理类复制时可以选择以下做法:
- 禁止复制(复制不合理)
- “引用计数法”(使用shared_ptr指定“删除器”阻止引用次数为0时的删除行为)
- 复制底层资源(“深度拷贝”)
- 转移底部资源的拥有权(auto_ptr)
条款15:在资源管理类中提供对原始资源的访问
-
get成员函数实现显式转换(安全,受欢迎):
- 如shared_ptr和auto_ptr 都提供一个get成员函数,用来执行显示转换,也就是它会返回智能指针内部的原始指针(的复件)。
-
隐式转换函数(方便)。
条款16:成对使用new和delete要采用相同的格式
- new和delete对应,new[ ]和delete[ ]对应;
- 尽量不要对数组形式做typedef动作。
条款17:以独立的语句将new对象置入智能指针
-
在一个语句中编译器拥有重新排列操作的自由,如此一来可能被异常干扰,发生资源泄露,因此以独立语句将newed对象置入智能指针内:
processWidget (std::trl::shared_ptr<Widget>(new Widget), prority()); //若先执行new,再调用priority,再调用shared_ptr构造函数:万一对priority的调用导致异常,则new返回的指针将会遗失,因为它尚未置入shared_ptr内; std::trl::shared_ptr<Widget> pw(new Widget);//在单独语句内以智能指针存储new对象 processWidget(pw, priority());//不会导致泄露
4 设计与声明
条款18:让接口容易被正确使用,不易被误用
-
好的接口很容易被正确使用,不容易被误用:
- 接口的一致性(STL所有容器都有一致的size()方法,而Java有的是size(),有的是length());
- 与内置类型的行为兼容:自定义的types的行为应该与内置types的行为一致…
-
“促进正确使用”的办法包括接口的一致性,以及与内置类型的行为兼容;“防治误用”的办法包括建立新类型,限制类型上的操作,束缚对象值,以及消除用户的资源管理责任;
-
tr1::shared_ptr支持定制型删除器,可预防DLL问题,可被用来自动解除互斥锁等等==(没太看懂)==。
条款19: 设计class犹如设计type
- 新type的对象应该如何被创建和销毁:
- 构造函数、析构函数、内存分配和释放函数;
- 对象的初始化和对象的赋值该有什么样的差别:
- copy构造函数、copy assignment 运算符的具体行为及其差异,初始化和赋值的差异;
- 新type的对象如果被passed by value,意味着什么?
- copy构造函数可用来定义一个type的passed by value该如何实现;
- 什么是新type的合法值:
- 这个决定了成员变量的约束条件、在成员函数中应该进行的错误检查;
- 新type需要什么样的转换:
- 隐式转换、显示转换、explicit等;
- 谁该取用新type的成员:
- 决定哪个成员为public、protected、private;
- 新type一般化的程度:
- 或许并非定义一个新type,而是一整个types家族,那么就应该定义一个新的type template;
- 是否真的需要一个新type:
- 如果只是定义新的derived class以便为现有的class添加机能,那么单纯定义一个或多个non-member函数或templates更能够实现目标。
条款20:宁以pass-by-reference-to-const替换pass-by-value
-
pass-by-value的缺点
- 值传递对象参数时,需要先调用对象的copy构造函数复制对象,造成很高的成本;
- 对象切割问题:当子类对象作为实参传递给父类形参时,复制参数时调用的将会是父类的copy构造函数,从而最终传递进去的仅仅是子类对象中的父类部分。
-
尽量以pass-by-reference-to-const替换pass-by-value,前者通常更加高效,可以避免切割问题;
-
以上规则并不适用于内置类型,STL的迭代器和函数对象,对他们而言,pass-by-value比较合适。
条款21:必须返回对象时,别妄想返回其reference
- 不要返回pointer或者reference指向一个local stack对象;
- 不要返回pointer 或者reference指向一个heap-allocated对象。
条款22:将成员变量声明为private
- 成员变量的封装性与“成员变量的内容改变时所破坏代码的数量”成反比;
- 将成员变量声明为private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证、并提供class作者以充分的实现弹性;
- protected并不比public更具有封装性。
条款23:宁以non-member、non-friend替换member函数
- 若某些东西被封装,它就不再可见。越多东西被封装,越少人可以看见它,而越少人看见它,就有越大的弹性去变化它;
- 越多函数可以访问它,数据的封装性就越低;
- 宁可拿non-member non-friend函数替换member函数,这样做可以增加封装性。
条款24:若所有参数皆需类型转换,请为此采用non-member函数
-
假设有这样的有理数类:
class Rational { public: //构造函数不为explicit;允许int-to-Rational的隐式转换(分子为某int,分母为1) Rational (int numerator = 0, int denominator = 1); int numerator() const;//分子和分母的访问函数 int denominator() const; private: ... }
若operator*为成员函数:
class Rational { ..... // 运算符重在,定义两个Rational对象的乘法 const Rational operator*(const Rational& rhs)const; }
此时若调用
Rational oneHalf(1,2); Rational result1=oneHalf*2; // => 正确 Rational result2=2*oneHalf; // => 错误
本质上,上述两个调用过程是这样的:
result1=oneHalf.operator*(2); result2=2.operator*(oneHalf); /*重载的运算符其实有两个参数,一个是传入的`rhs`,另一个是隐含的`this`; 第一个调用直接将2隐式转换为了Rational对象; 但是第二个调用时2在前面,它并没有相应的class,也就没有operator*成员函数,从而无法进行类型转换。*/
-
解决方法:将operator*()这个成员函数改为non-member函数:
// 不属于任何类/对象 const Rational operator*(const Rational& lhs,const Rational& rhs){ // ................ }
于是就可以调用
result2=2.operator*(oneHalf);
了。 -
menber函数的反面是non-member函数,而不是friend函数;
-
无论何时,都该
尽量避免friend函数
;
条款25:考虑写出一个不抛出异常的swap函数==(不太懂)==
-
swap是STL中的标准函数,用于交换两个对象的数值。后来swap成为异常安全编程(exception-safe programming,条款29)的脊柱,也是实现自我赋值(条款11)的一个常见机制。swap的实现如下:
namespace std { template<typename T> void swap(T& a, T& b) { T temp(a); a=b; b=temp; } }
只要T支持copying函数(copy构造函数和copy assignment操作符)就能允许swap函数。
-
首先,如果如果swap的缺省实现对我们的class或class template效率可以接受,那么无需做任何事;
-
其次,如果swap缺省实现版 的效率不足(例如,你的class或template使用了某种pimple(pointer to implementation)手法),试着做以下事情:
- 提供一个public swap成员函数,让它高效置换两个对象值;
- 在class或template所在命名空间提供一个非成员swap函数,并令它调用上述swap成员函数;
- 若编写的是class(不是class template),为该class 特化std::swap,并令它调用你的swap成员函数。
-
调用swap时,针对std::swap使用using形式,然后调用swap并且不带任何命名空间资格修饰。
-
为“用户定义类型”进行std template全特化时,不要试图在std内加入某些对std而言是全新的东西。
5 实现
条款26:尽可能延后边变量定义式的出现时间
- 原因:
对于对象而言,过早地定义变量,但是如果最终没有使用该变量程序就离开了其作用域,那么就耗费了该变量构造析构的成本,还可以避免无意义的default构造行为; - 常见做法:
- 直到真正需要变量时才定义它;
- 尽量直接在定义时指定初值(效率高于先定义再赋值);
- 循环内定义视情况可提到循环外定义(要计算是否可能有效率提升)。
条款27:尽量少做转型动作
-
C++中的四种转型动作:
- const_cast:将const变量转换为non-const变量;
- dynamic_cast:主要用于向下转型,即将一个基类对象指针(或引用)转换到继承类指针,它是唯一无法用C语言的类型转换替代的动作;
- reinterpret_cast:对原始对象的位模式提供较低层次上的重新解释;
- static_cast:强迫进行类型转换,没有运行时类型检查来保证转换的安全性。
-
C++单一对象可能拥有多个地址
class Base{....} class Derived:public Base{...} Derived d; Base* pd=&d;
pd和&d的地址可能并不相同,这种情况几乎在多重继承上总是会发生。
-
尽量避免类型转换,尤其是dynamic_cast,因为其效率很低;
-
如果转型是必要的,试着将它隐藏与某个函数背后,客户随后可以调用该函数,而不需将转型放进他们自己的代码内。
条款28:避免返回handles指向对象内部成分
- 原因:返回引用、指针、迭代器这三类代表对象内部数据的handle,可能导致降低对象封装性的风险,因为可以通过这些handle修改对象内部的数据;
- 可以通过在返回handle上加上const降低风险,但可能由于局部变量被销毁间接导致空悬、虚吊。
条款29:为"异常安全"而努力是值得的
- 异常安全函数:即使发生异常也不会资源泄漏(比如没有释放内存)或者数据结构败坏(比如数据被改到了非预期的值或指针指向被删除的对象);
- 异常安全函数提供以下三个保证之一:
- 基本型:如果异常被抛出,程序内的任何事物仍然保持在有效状态(但不一定是调用之前的原始状态);
- 强烈型:如果异常抛出,程序状态不改变(如果函数成功,就是完全成功;如果函数失败,程序会回复到调用之前的状态):
- 可使用智能指针实现,这种以对象管理资源的方式可以所见函数代码的长度;
- 强烈保证能够以copy-and-swap实现:为打算修改的对象复制一个副本,然后在副本上修改。若有修改动作抛出异常,原对象仍保持未改变状态,待所有改变都成功后,再将修改过的副本和原对象在一个不抛出异常的操作中置换;
- 不抛异常型:承诺绝不抛出异常,总是能够完成成功的功能。
条款30:透彻了解inline的里里外外
- inline函数的整体观念:将“对此函数的每一个调用”都以函数本体替换;
- inline可能增加目标代码大小,因为它会将所有被调用的“函数”替换为实现源码;
- inline只是对编译器的一个申请,不是强制命令,最终是否被内联,由编译器视情况而定;
- inline申请:
- 隐式的inline申请:将函数定义在class内,这样的函数通常是成员函数;
- 显示的inline申请:使用inline关键字。
- inline函数和template通常都被定义与头文件,但不意味着函数模板一定要声明为inline;
- inline的一些限制:
- 将大多数inlining限制在小型、被频繁调用的函数上;
- 通常不对构造函数和析构函数调用inline, 因为编译器会在其中添加很多代码;
- 编译器通常不对“通过函数指针而进行的调用”及“对virtural的调用”实施inline(因为需要具象化);
- incline函数无法随着程序库的升级而升级,所有调用了inline的地方必须重新编译链接;
条款31:将文件间的编译依存关系降至最低
-
编译依存关系:如果对C++程序的某个类做了轻微的修改,修改的不是接口而是实现,当编译时,很多关联的文件都需要重新编译。
-
编译依存性最小化的核心:接口与实现分离:
- Handle Classes(pImpl手法):先声明一个Class作为接口,它内部有一个指针指向具体的实现(即ImplClass类/对象);具体的实现在ImplClass中;
- Interface Classes:先定义一个Base类并将其中的函数都置为纯虚函数,然后定义Derived类继承Base并实现其虚函数。
-
Handle Classes:可以将对象实现细目隐藏在一个指针背后。针对Person,可以把它分为两个classes,一个负责提供接口,另一个负责实现该接口。
#include<string> #include<memory> class PersonImpl; //前置声明 class Date; class Address; class Person { public: Person(const std::string& name, const Date& birthday, const Address& addr); std::string name() const; std::string birthDate() const; std::string address() const; …… private: //实现细目 std::tr1::shared_ptr<PersonImpl> pImpl;//指针,指向实现 };
- 这样的设计称为pimpl idiom(pimpl:pointer to implementation);
- Person的客户和Date、Address以及Person的实现细目分离了,classes的任何实现修改都不要客户端重新编译;
- 成员函数通过implementation pointer取得对象数据,为访问增加了一层间接性,内存也增加了pointer的大小;implementation pointer的初始化带了动态开辟内存的额外开销,蒙受遭遇bad_alloc异常的可能性。
-
Interface class:令Person成为一种特殊的abstract base class(抽象基类),称作Interface class,只是描述derived classes接口。
- 这个class的客户必须使用Person的pointers或references,因为内含pure virtual函数的class无法实例化。这样一来,只要Interface class的接口不被修改,其他客户就不需要重新编译;
- 支持Interface class接口的那个具体类(concrete classes)在真正的构造函数调用之前要被定义好,例如,有个RealPerson继承了Person,然后实现出接口所覆盖的函数。
- 在Interface classes身上,每个函数都是virtual的,所以每次调用要付出一个间接跳跃成本。
-
编译依存性最小化的本质:用“声明的依存性”替换了“定义的依存性”,让头文件尽可能自我满足,万一做不到,则让它与其他文件内的声明(不是定义)相依;
- 如果使用object references或object pointers可以完成任务,就不要使用object。因为,使用references或pointers只需要一个声明,而定义objects需要使用该类型的定义;
- 如果可以,尽量以class声明式替换class定义式;
- 为声明式和定义式提供两个不同的头文件,程序中,不应该让客户给出前置声明,程序作者一般提供两个头文件,一个用于声明式,一个用于定义式。
6 继承与面向对象设计
条款32:确定你的public继承模塑出is-a关系
- classes之间的关系:
- is-a:是一个;
- has-a:有一个;
- is-implemented-in-terms-of:实现。
- public继承的is-a语义:
- 能够施行于base class对象身上的每件事情,也可以施行于derived class身上,因为每个derived class对象也是一个base class对象;
- base class对象表现出更一般化的概念,derived class表现出更特殊化的概念。
- 两个示例问题:
- 企鹅
is a
鸟,但鸟可以飞,企鹅却不能飞?- 为企鹅重新声明fly函数,令它产生一个运行期错误;
- 不在鸟类及企鹅类中定义fly函数。
- class正方形应该以public形式继承矩形吗?
- 不应该,矩形可以同时修改高度和宽度,但如果正方形这样修改,它就不是正方形了。
- 企鹅
条款33:避免遮掩继承而来的名称
-
名称查找规则:先查局部,找不到再查更大范围 ;
-
继承中的名称遮掩示例:
class Base{ private: int x; public: virtual void mf1()=0; virtual void mf1(int); virtual void mf2(); void mf3(); void mf3(double); ..... }; class Derived:public Base{ public: virtual void mf1(); // 将遮盖父类中的两个mf1函数(即使参数类型不同) void mf3(); // 将遮盖父类中的两个mf3函数(即使参数类型不同) void mf4(); };
-
解决方法:
-
using 声明式:指定子类中可见父类的某些名称;
class Derived:public Base{ public: using Base::mf1; // 让Base内的名为mf1和mf3的所有东西,在Derived作用域内都可见 using Base::mf3; virtual void mf1(); void mf3(); void mf4(); };
-
转交函数:不想继承base class中的所有函数(使用private继承+inline);
class Derived:private Base{ public: virtual void mf1() {Base::mf1();}; // 此时相当于继承了base class中的mf1()版本,而mf1(int) 重载版本被遮掩。 ...... };
-
条款34:区分接口继承和实现继承
- 接口继承与实现继承:
- 接口继承:继承父类中函数的声明;
- 实现继承:继承父类中函数的实现;
- public继承之下,成员函数的接口总是会被继承。
- pure-virtual:只继承接口
- pure-virtual函数必须被任何“继承了他们”的具象class重新声明;
- ure-virtual函数在抽象基类中通常没有定义。
- impure-virtual:继承接口和缺省实现
- 基类中有virtual的实现时,子类可以选择覆盖它,也可以直接使用父类的实现;
- 如果基类没有virtual函数的缺省实现,子类必须实现它;
- 有时允许impure-virtual同时制定函数声明和函数缺省行为,有可能造成危险,此时应切断“virtual函数”和“缺省实现”之间的连接(如将缺省实现声明为protected,并进行inline调用;或在基类中提供pure-virtual的具体实现,并在derived class中进行inline调用或重新定义)
- non-virtual:继承接口及一份强制实现
- non-virtual函数代表的意义是不变性凌驾特异性,所以它绝不该在derived class中被重新定义;
- 不应将所有函数声明为non-virtual(除非不成为base class);
- 也不应将所有函数声明为virtual,这样立场不坚定。
条款35:考虑virtual函数以外的其他选择
-
当在为解决问题寻找某个设计方法时,可以考虑virtual的几种替代方案;
-
NVI(non-virtual interface)手法:令客户通过public non-virtual成员函数间接调用private virtual函数,相当对virtual函数进行一层的包装,可以称为是virtual函数的外覆器(warpper)。
- NVI是template method设计模式的一种特殊形式,template method主要思想是基类提供抽象接口,具体细节让派生类实现。
-
将virtual函数替换为“函数指针成员变量”:让每个任务的构造函数接受一个函数指针,这样可以更容易实现动态的改变;(没太看懂)
- 该方法是Strategy模式的一种实现,Strategy模式将某些关键的算法从实现类中解耦,让调用者可以选择不同的算法灵活地应用于实现类。
- 如果需要用到private变量,只能将相应的函数声明为friend,会降低程序的封装性。
-
由tr1::function完成的Strategy模式:用function定义一个可调用类型,这样我们能便能传入更多的可调用对象,只要这些可调用对象能够转换为我们定义的可调用对象类型即可。
- 这种方式更为灵活,这种方式还能将类的成员函数绑定为计算方式。
-
古典的Strategy模式:将一个继承体系中的virtual函数替换为另一个继承体系的中的virtual函数,然后再用一个基类的指针指向另一个体系:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7aBqCxSn-1653957385741)(img/Effective C++/2021033116260655.png)]
条款36:绝不重新定义继承而来的non-virtual函数
//情况一
class B{
public:
void mf();
...
};
class D:public B{...};
D x;
B *pB = &x;
pB -> mf();//经由该指针调用 B::mf
D *pD = &x;
pD -> mf();//经由该指针调用B::mf
//情况二
class B{
public:
void mf();
...
};
class D: public B{
public:
void mf(); //遮掩了B::mf;
...
}
D x;
B *pB = &x;
pB -> mf();//调用B::mf
D *pD = &x;
pD -> mf();//调用D::mf
- 情况二却在预想之外,因为两个不同类型的指针指向的是同一个对象,但是发起函数调用时却调用了不同版本的函数。
- 原因:
- non-virtual函数都是静态绑定的,即:假如P是基类指针,无论P指向的基类对象还是派生类对象,它发起函数调用的版本都是基类的版本。
- virtual函数是动态绑定的,假如P是基类指针,如果P指向的是基类对象,由P发起的函数调用则是基类版本的,如果P指向派生类,那么由P发起的函数调用则是派生类版本的。
- 绝不重新定义继承而来的non-virtual函数。
条款37:绝不重新定义继承而来的缺省参数值
-
静态类型和动态类型
- 静态类型:被声明时采用的类型 ,程序执行过程中不能改变;
- 动态类型:目前所指对象的类型 ,可在程序执行过程中改变。
-
non-virtual:
- non-virtual函数是静态绑定的,即重新定义继承而来的non-virtual是错误的;
- 修改继承的non-virtual的缺省值也是错误的;
-
virtual:
- virtual函数是动态绑定的,即调用一个virtual函数时,究竟调用哪一份函数实现代码,取决与发出调用的哪个对象的动态类型;
- 缺省参数值是静态绑定的,从而可能在调用一个定义于derived class内的virtual函数时,使用的却是base class给他指定的缺省参数值。
条款38:通过复合模塑出has-a或is-implemented-in-terms-of
-
复合
-
复合是类型之间的一种关系,当某种类型的对象内含其他类型的对象,便是复合关系;
-
在应用域,复合意味着“has-a” :
class Person{ private: Address addr; PhoneNumber voiceNumber; PhoneNumber faxNumber; // ...... };
-
在实现域,复合意味着 “is-implemented-in-terms-of”:
比如用list实现Set,Set类中对类型std::list< T> 的包含就属于“is-implemented-in-terms-of”
template<class T> class Set{ public: void member(const T& item) const; void insert(const T& item); void remove(const T& item); std::size_t size()const; private: std::list<T> myset; };
-
条款39:明智而审慎地使用private继承
- private继承的一些规则
- 如果classes之间的继承关系是private,编译器不会自动将一个derived class对象转换为一个Base对象, 这就决定了private继承不是is-a关系;
- 由private base class继承而来的所有成员,在derived class中都会变成private属性,即使他们在base class中原本是protected或者public。
- private继承的含义:“implemented-in-terms-of” ,即如果D以private形式继承B,意思是D对象是根据B对象实现而得,除此没有其他含义;
- 在implemented-in-terms-of语义下,尽可能使用复合, 必要时才使用private继承:
- 当派生类需要访问protected的基类成员,或需重新定义继承而来的virtual函数==(不太懂)==。
条款40:明智而审慎地使用多重继承
-
多重继承:
class A { public: void check() { cout << "A" << endl; } }; class B { private: void check() const { cout << "B" << endl; } }; class C : public A, public B { }; int main(){ C c; c.check(); }
-
多重继承带来的问题
-
歧义:程序可能从一个以上的base classes继承相同名称(如函数、typedef等),那会导致较多的歧义,此时需要通过类名指明要调用的具体成员;
-
空间冗余:如果不是virtual继承方式,哪么某个父类的成分可能经过多条路径出现在子类中,造成空间冗余。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5cDPQWwM-1653957385743)(img/Effective C++/20200726104440612.png)]
-
-
virtual继承:会在对象大小、速度、初始化、赋值造成成本增加;
- 非必要不使用virtual bases,平时应使用non-virtual继承;
- 如果必须使用virtual base classes,尽可能避免在其中放置数据。
-
多重继承比单一继承复杂,可能导致新的歧义性,以及对virtual继承的需要;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-50VH2DN9-1653957385743)(img/Effective C++/20200726105644910.png)]
-
多重继承的确有正当用途,其中一个情节涉及“public继承某个Interface class”和“priavate继承某个协助实现的class”的两相组合。
模板与泛型编程
条款41:了解隐式接口和编译期多态
-
classe和template都支持接口和多态;
-
对class而言:
-
接口是显式的,由函数**签名式(函数名称、参数类型、返回类型)**构成;
class Widget { public: Widget(); virtual ~Widget(); virtual std::size_t size()const; virtual void normalize(); void swap(Widget& other); };
-
多态是通过virtual函数发生于运行期。
-
-
对template而言
-
接口是隐式的,由有效表达式组成;
template<typename T> void doProcessing(T& w) { if (w.size() > 10 && w != someNastyWidget) { ...... } }
-
多态是通过template具现化和函数重载解析发生于编译期。
-
条款42:了解typename的双重意义
-
声明template参数时,前缀关键字class和typename可以互换;
template<class T> class Widget; template<typename T> class Widget;
-
使用typename标识嵌套从属类型名称
-
从属名称:模板内的一个名称依赖于template的某个参数;
-
嵌套从属名称:当从属名称属于class,如
T::const_iterator
; -
编译器在template中遭遇一个嵌套从属名称,它便假设这名称不是个类型,而是个变量名;
-
**为嵌套从属名称加上关键字typename,**以显式地告诉编译器某种东西是一个类型:
template<typename C> void print2nd(const C& container) { if (container.size() >= 2) { //使用typename,显式告诉编译器,const_iterator是一个类型 typename C::const_iterator iter(container.begin()); ++iter; int value = *iter; std::cout << value; } }
-
不得在base class lists或member initialization list内作为base class修饰符。
-
条款43:学习处理模板化基类内的名称
-
在模板类中,如果一个派生类在其方法中调用了基类的接口,那么这段代码可能无法编译通过:
- 编译器知道基类可能有一个特化版本,而特化版本可能不提供和一半性template相同的接口,因此不会去基类中进行查找。
- 注意:在编写非模板类的时,在派生类中调用某函数时,如果在本类中没有查找到该函数,那么就会向基类的作用域中去查找函数;但是模板类中,如果在本类中没有查找到该函数,那么其不会继续向基类中进行查找。
-
解决方法:
-
**使用this指针:**使用this指针调用这些函数,告诉编译器这些函数是属于自身类的;
this->sendClear(info);
-
使用using声明式:让编译器去基类中查找这个函数;
using MsgSender<Company>::sendClearMsg;
-
明确指出被调用的函数位于base class中:**不太建议,因为:**被调用的函数可能是virtual函数,这种修饰符会关闭“virtual绑定行为”:
Base<Company>::sendClear(info);
-
条款44:将与参数无关的代码抽离templates==(不太懂)==
本文中部分内容系转载,原文地址为:https://blog.csdn.net/qq_41453285/article/details/104856465
- 代码重复与对应策略
- 对于函数而言
- 假设编写某些函数(不止一个),这些函数当有有部分代码都是相同的,因此我们认为这份代码对于程序来说是重复的
- 那么此时我可以将这些函数共同的部分抽取出来,然后放在另一个函数中,让原本那些函数调用这个新函数,达到缩减代码的效果。
- 对于类而言
- 假设编写的一组类中有些部分都是相同的,那么这份代码对于程序来说是重复的;
- 那么此时我们可以将这些类中共同的部分抽取出来,建立一个新类,然后让原本那些类继承于这个新类,达到缩减代码的效果。
- 对于template(函数模板/类模板)而言
- 只有当template被使用时才会进行代码的实例化,并且针对每一个实例化,在代码中都会生成相对应的代码,因此针对于每一个实例化,它们之间也可能会产生代码重复的现象;
- 对于普通函数/类来说,代码重复是很容易看出来的(通过代码)。但是对于template来说,其是不容易发现的,因为只有template被实例化时才知道template产生了什么内容。
- 对于函数而言
- Templates生成多个classes和多个函数,所以任何template代码都不该与某个造成膨胀的template参数产生相依关系;
- 因非类型模板参数而造成的代码膨胀,往往可消除,做法是以函数参数或class成员变量替换template参数;
- 因类型参数而造成的代码膨胀,往往可降低,做法是让带有完全相同的二进制表述的具现类型共享实现码。
条款45:运用成员函数模板接受所有兼容类型
-
例:设计一个模板,用来模仿智能指针类,并且希望智能指针能像普通指针一样进行类型转换:
-
**一种低效的做法:**在SmartPtr模板中针对于每一个派生类定义一个拷贝构造函数和拷贝赋值运算符。这种做法针对每一个派生类设计相对应的拷贝构造函数和拷贝赋值运算符会使class膨胀,并且如果将来加入新的派生类,那么还需要继续添加新的成员函数。
-
为SmartPtr模板添加一个成员函数模板:根据下面的拷贝构造函数,我们可以对任何类型T和任何类型U,将一个
SmartPtr<U>
转换为SmartPtr<T>
:template<typename T> class SmartPtr { public: //拷贝构造函数,是一个成员函数模板 typename<typename U> SmartPtr(const SmartPtr<U>& other); };
-
-
约束成员函数模板的行为
- 对于类继承来说,派生类指针可以转换为基类指针,但是基类指针不能转换为派生类指针;
- 对于普通类型来说,我们不能将int转换为double。
-
成员函数模板不改变语言规则,在class内声明一个泛化的拷贝构造函数(是个成员模板)并不会阻止编译器生成自己的拷贝构造函数。
-
如果你声明member templates用于“泛化copy构造”或“泛化assignment操作”,你还是需要声明正常的copy构造函数和copy assignment操作符。
条款46:需要类型转换时请为模板定义非成员函数
-
函数模板在混合式运算时可能出错:
template<typename T> class Rational { public: Rational(const T& numerator = 0, const T& denominator = 1); const T numerator()const; const T denominator()const; }; template<typename T> const Rational<T> operator*(const Rational<T>& lhs, const Rational<T>& rhs) {} Rational<int> oneHalf(1, 2); //在条款24中,Rational和operator*为非模板,此处代码可编译通过 //此处,Rational和operator*为模板,onehalf和2类型不同,为混合运算,编译不通过 Rational<int> result = oneHalf * 2;
- **错误的原因在于:**上面的“oneHalf2”没有找到匹配的operator()函数,从而导致出错;
- oneHalf的类型为Rational,因此其匹配到operator*()函数模板,并将operator*()函数模板的模板类型T实例化为int;
- 但是当运行到第二个参数的时候,2为int,operator*()函数模板无法根据int推断出T的类型。
- **在非模板的operator*()中,*operator()可以将2隐式转换为Rational对象(Rational提供了隐式转换,详情再去看条款24)。但是在模板中,这种隐式转换不被允许。
- **错误的原因在于:**上面的“oneHalf2”没有找到匹配的operator()函数,从而导致出错;
-
解决方法一:将模板函数声明为friend
- 当我们编写一个class template,而它所提供之“与此template相关的”函数支持“所有参数之隐式类型转换”时,请将那些函数定义为“class template内部的friend函数”。
template<typename T> class Rational { public: //声明的时候,同时实现该函数(备注,该函数虽然在类中定义,但不属于成员函数) friend const Rational operator*(const Rational& lhs, const Rational& rhs) { return Rational(lhs.numerator()*rhs.numerator(), lhs.denominator()*lhs.denominator()); } };
-
解决方法二:令friend函数调用辅助函数(与inline相关)
//声明 template<typename T> class Rational //函数声明(定义在下) template<typename T> const Rational<T> doMultiply(const Rational<T>& lhs, const Rational<T>& rhs); template<typename T> class Rational { public: friend const Rational operator*(const Rational& lhs, const Rational& rhs) { //在其中调用doMultiply(),可使inline最优化 return doMultiply(lhs, rhs); } }; //完成原本operator*()要完成的功能 template<typename T> const Rational<T> doMultiply(const Rational<T>& lhs, const Rational<T>& rhs) { return Rational<T>(lhs.numerator()*rhs.numerator(), lhs.denominator()*lhs.denominator()); }
条款47:请使用traits classes表现类型信息==(不太懂)==
-
迭代器分为五种:
- **Input Iterator:**这种迭代器所指的对象,不允许外界改变。只读;
- **Output Iterator:**只写;
- **Forward Iterator:**允许“写入型”算法(如replace())在此迭代器所形成的区间上进行读写操作;
- **Bidirectional Iterator:**可双向移动。某些算法需要逆向遍历某个迭代器区间(例如逆向拷贝某范围内的元素),可以使用这种类型的迭代器;
- **Random Access Iterator:**这种迭代器涵盖了上面4中迭代器所有的功能。
-
traits技术,它们允许你在编译期间获取某些类型信息;
-
对于迭代器,标准库又定义了一个名为iterator_traits的类,用来萃取迭代器的类型:
//用来处理迭代器的信息 template<typename T> struct iterator_traits;
-
其根据传入的迭代器,然后萃取迭代器中的iterator_category成员,然后定义为自己的iterator_category成员;
-
iterator_category成员最初是迭代器的类型,因此,iterator_traits中的iterator_category就代表迭代器的类型;
template<typename IterT> struct iterator_traits { //因为iterator_category为数据类型,因此需要用typename typedef typename IterT::iterator_category iterator_category; };
-
如何萃取:
- 我们使用迭代器来初始化iterator_traits,例如iterator_traits;
- 然后iterator_traits内含有一个名为iterator_category,这个用来表示迭代器IterT的类型。
-
-
使用traits class的总结:
- 建立一组重载或函数模板(例如doAdvance),彼此间的差异只在于各自的traits参数。令每个函数实现码与其接受之traits信息相对应;
- 建立一个空值函数或函数模板(例如advance),它调用上述那些doAdvance()函数,并传递traits class所提供的信息。
-
Traits广泛应用于标准程序库:
- 其中当然有上述讨论的iterator_traits,除了供应iterator_categoty()还提供了另外四份迭代器相关信息(其中最有用的是value_type,见条款42);
- char_traits用来保存字符类型的相关信息;
- numeric_limits用来保存数值类型的相关信息,例如某数值类型可表现之最小值和最大值等。
-
Traits classes使得“类型相关信息”在编译期可用,它们以templates和“templates特化”完成实现;
-
整合重载技术后,traits classes有可能在编译期对类型执行if…else测试。
条款48:认识template元编程==(不太懂)==
- 模板元编程可将工作由运行期移至编译期,因而得以实现早期错误侦测和更高的执行效率,可能导致较小的可执行文件,较短的运行期,较少的内存需求,可以解决不少问题。
定制new和delete==(不太懂)==
条款49:了解new-handler的行为
-
set_new_handler
-
**该函数的作用是:**当new操作或new[]操作失败时调用参数所指的new_p函数
-
该函数定义于头文件中,实际上是一个typedef;
Typedef void (*new_handler)(); new_handler set_new_handler(new_handler new_p) throw(); //C++98 new_handler set_new_handler (new_handler new_p) noexcept; //C++11
-
该函数接受一个new_handler参数(这个参数通常指向一个函数),并返回一个new_handler数据;
-
new_p为当new操作或new[]操作失败时调用的函数,参数列表为空,返回值类型为void;
-
-
演示案例:定义一个函数,在operator new无法分配足够内存时被调用:
void outOfMem() { std::cerr << "Unable to satisfy request for memory\n"; std::abort(); } int main() { //绑定函数 std::set_new_handler(outOfMem); //如果此处内存分配失败,将会调用outOfMem() int *pBigDataArray = new int[100000000L]; return 0; }
-
new_handler函数的设计原则
- 如果内存不足时,new_handler函数被执行,但是我们同时又在new_handler函数中进行了动态内存分配,那么后果不可预期;
- 一个设计良好的new_handler函数有以下几项原则:
- 让更多内存可被使用:这种做法时,程序在一开始执行就分配一大块内存,而后当new_handler函数第一次被执行时,将它们释放给系统;
- 安装另一个new_handler:如果当前new_handler无法取得更多可用内存,或许它知道另外哪个new_handler有此能力,目前这个new_handler就可以安装另外的new_handler来替换自己;
- 卸除new_handler:将null指针传给set_new_handler;
- 不返回:通常调用abort()或exit。
-
为类设置new-handler行为:每个类单独设置new-handler行为,不同的类分配操作失败就调用相对应的new-handler函数。
-
实现class的new-handler行为:
- 为class设置operator new()内存分配函数,和set_new_handler()函数;
- 然后添加一个new_handler成员函数,代表本class在new失败时的调用函数;
class Widget { public: static std::new_handler set_new_handler(std::new_handler p)throw(); static void* operator new(std::size_t size)throw(std::bad_alloc); private: static std::new_handler currentHandler;//static变量需在类外定义 }; std::new_handler Widget::currentHandler = 0; std::new_handler Widget::set_new_handler(std::new_handler p)throw() { std::new_handler oldHandler = currentHandler; currentHandler = p; return oldHandler; }
-
如何使用:
void outOfMem(); int main() { //设定outOfMem()为Widget的new-handler函数 Widget::set_new_handler(outOfMem); //如果内存分配失败,调用outOfMem() Widget* pw1 = new Widget; //Widget::operator new()执行结束后还会将全局new-handler设置回来 //如果内存分配失败,调用全局new-handler函数 std::string* ps = new std::string; //设定Widget的new-handler函数为null Widget::set_new_handler(0); //如果内存分配失败,立刻抛出异常(因为Widget的new-handler函数置空了) Widget* pw2 = new Widget; return 0; }
-
-
封装一个new-handler行为的base class:为了使每种class拥有自己的new-handler行为,我们将该类设置为模板,然后让各自的class继承于这个模板,当class在定义时,就会实例化模板,因此不同的类将会拥有各自的new-handler行为。
-
nothrow说明:
- 当opertor new分配内存时,
- 旧C++标准中,让operator new返回null,现代C++标准中,operator new抛出异常(bad_alloc),并且该异常不能被operator new捕获,因此会被传播到内存索求处;
- C++提供nothrow关键字,能让operator new()在内存分配失败时返回null,而不抛出bad_alloc异常。
-
set_new_handler允许客户指定一个函数,在内存分配无法获得满足时被调用。
-
Nothrow new是一个颇为局限的工具,因为它只适用于内存分配;后继的构造函数调用还是可能会抛出异常。
条款50:了解new和delete的合理替换时机
- 重载operator new和operator delete的理由:
- 用来检测运用上的错误;
- new分配内存,但是没有delete掉,导致内存泄漏;
- 对new所分配的内存,进行多次delete那么会导致不明确的行为;
- 为了强化效能:编译器自带的operator new和operator delete可能对程序不太友好;
- 为了收集使用上的统计数据:分配区块的大小分布、寿命分布、分配和归还次序等;
- 为了增加分配和归还速度:系统提供的new往往(虽然并不总是)比自己定义的new慢;
- 为了降低缺省内存管理器带来的空间额外开销;
- 为了弥补缺省分配器中的非最佳齐位;
- 为了弥补缺省分配器中的非最佳齐位;
- 为了获得非传统的行为。
- 用来检测运用上的错误;
条款51:编写new和delete时需固守常规
-
operator new所要遵守的规则
-
必须拥有正确的返回值:如果申请成功就返回指针指向于那块内存,如果不足就抛出bad_alloc异常(未设置new-handler的情况下);
-
内存不足时调用new-hander函数;
-
必须针应对申请零内存的情况:C++允许用户申请0byte的内存,如返回1byte的内存;
-
operator new的伪代码形式:
-
总结:operator new内含一个无限循环,并在其中尝试分配内存,如果它无法满足内存需求,就该调用new-handler。它也应该有能力处理0 bytes申请。class专属版本则还应该处理**“比正确大小更大的(错误)申请”**。
-
-
operator new在继承中的注意事项:
-
对于一个成员函数版本的operator new来说,如果其有派生类,那么派生类的new可能会产生不明确的行为:如Base中的new(),函数中的相关操作是针对于参数为sizeof(Base)大小而设定的,但如果是Derived调用new(),传入的大小为sizeof(Derived),但是该new()不是针对于参数大小为sizeof(Derived)而设定的。
-
因此需要在基类的operator new()函数中对申请进行判断:
void* Base::operator new(std::size_t size)throw(std::bad_alloc) { //如果不是Base调用,那么调用标准库的operator new if (size != sizeof(Base)) return ::operator new(size); //... }
-
-
operator new[]:分配一块未加工内存。
-
operator delete所要遵守的规则:保证“删除null指针”是正确的。
-
operator delete在继承中的注意事项:
-
成员函数版本的**delete函数需要两个参数,**其中第二个参数用来检查删除的大小;
-
第二个参数与成员函数版本的operator new的参数概念相同,需要用这个大小来比较删除对象的大小。**因为其delete可能会被派生类使用,**而派生类的大小与基类的大小不一致。
void Base::operator delete(void *rawMemory, std::size_t size)throw() { if (rawMemory == 0) //删除空指针,直接返回 return; //删除的不是Base,可能是其派生类调用 //那么就调用全局delete进行删除 if (size != sizeof(Base)) { ::operator delete(rawMemory); return; } //...执行内存归还 }
-
条款52:写了placement new也要写placement delete
-
new表达式先后调用operator new和default构造函数;
- 程序如果在new未抛出异常而构造函数抛出异常时,会自动调用delete来释放内存;
-
**placement new:**对于一般的new函数而言,其只有一个参数(size_t),但是如果我们为new()函数多添加一个参数,那么我们就称这个new为placement new;
-
例如下面是一个Widget,其有:一个placement new(非正常形式的),其参数2用来记录相关分配信息:
class Widget { public: //placement new(因为其带有一个ostream的参数) static void* operator new(std::size_t size,std::ostream& logStream) throw(std::bad_alloc); //正常签名式的delete static void operator delete(void *rawMemory, std::size_t size) throw(); };
-
placement new可能导致内存泄漏:运行期系统寻找“参数个数和类型都与operator new相同”的某个operator delete,若没有对应的placement delete,那么当new的内存分配动作需要取消并恢复旧观时就没有任何operator delete会被调用,导致内存泄漏。
-
-
对于自定义的placement new,我们也需要自定义一个placement delete:
-
class Widget { public: //placement new static void* operator new(std::size_t size, std::ostream& logStream) throw(std::bad_alloc); //placement delete(与上面的new是配对的) static void operator delete(void* pMemory, std::ostream& logStream)throw(); //其他代码同上 };
-
-
对于new来说:
- 当class定义了正常签名式的new时,全局的正常签名式的new和placement new都将被隐藏,其创建对象只能使用自己的new创建对象;
- 当class定义了placement new时,全局的正常签名式的new和placement new都将被隐藏,其创建对象只能使用自己的placement new创建对象;
-
可以封装一个Base class,使其包含所有正常形式的new和delete:
class StandardNewDeleteForms{ public: //正常签名式的new和delete static void* operator new(std::size_t size)throw(std::bad_alloc){ return ::operator new(size); } static void operator delete(void* pMemory)throw() { ::operator delete(pMemory); } //placement new和placement delete static void* operator new(std::size_t size,void* ptr)throw(std::bad_alloc){ return ::operator new(size,ptr); } static void operator delete(void* pMemory, void* ptr)throw() { ::operator delete(pMemory, ptr); } //nothrow new/nothrow delete static void* operator new(std::size_t size, const std::nothrow_t& nt)throw(){ return ::operator new(size,nt); } static void operator delete(void* pMemory, const std::nothrow_t& nt)throw() { ::operator delete(pMemory); } };
-
客户端自己的类可以继承于这个类,获取其所有new与delete,如果自己定义了new和delete,为了防止隐藏StandardNewDeleteForms中的new与delete,可以使用using声明:
class Widget :public StandardNewDeleteForms { public: //使基类中所有new和delete在派生类中可见 using StandardNewDeleteForms::operator new; using StandardNewDeleteForms::operator delete; //再定义自己的new与delete static void* operator new(std::size_t size, std::ostream& logStream)throw(std::bad_alloc); static void operator delete(void* pMemory, std::ostream& logStream)throw(); };
-
杂项讨论
条款53:不要轻忽编译器的警告
- 严肃对待编译器发出的警告信息。努力在你的编译器的最高(最苛刻)警告级别下争取“无任何警告”;
- 不要过度依赖编译器的警告能力,因为不同的编译器对待事情的态度不同,一旦移植到另一个编译器上,你原本依赖的警告信息可能会消失。
条款54:让自己熟悉包括TR1在内的标准程序库
- C++标准的历史
-
TR1标准:TR1详细叙述了14个新组建,统统放在std命名空间内,然后定义在tr1中,因此书写时既可以加std::限定符,也可以加std::tr1::限定符。
-
其他TR1组件划分为两组
-
第一组是提供彼此互不相干的独立机能。例如:
-
第二组TR1组件由更精巧的template编程技术(包括模板元编程,见条款48)构成:
-
条款55:让自己熟悉Boost
- Boost是一个社群,也是一个网站。致力于免费、源码开放、同僚复查的C++程序库开发。Boost在C++标准化过程中扮演深具影响力的角色;
- Boost提供许多TR1组件实现品,以及其他许多程序库。