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