effective C++ reading notes
1.Accustoming Youself to C++
item 1主要介绍了C++ 四个基本组成部分:C,面向对象,模版,STL,或者说也可以将其视为4个子语言.我们在进行开发时,几乎不可能将所有子语言都使用上,这更不是我们所鼓励的.比如说muduo从某种程度上来说,连面向对象(多态)都没有用上.但这部分又联系紧密,尤其是C部分的基础几乎总是贯彻在其他部分,比如说STL迭代器中对于指针的封装.
item
2希望我们少使用#define.#define
的最大特点在于是当与处理阶段就完成了替换,所以一个宏定义是不会加入到符号表里的,如果真要进行debug的时候,看到的是他被替换之后的值,这增大了调试的难度.之所以考虑使用inline
来替代#define
,是因为#define
很多时候被期望达到一个函数的效果,其实很多时候有一些问题.比如说书中的max的例子,如果参数采用a++这种方式,就会导致意外地累加.而对于enum的使用.
item
3着重介绍了const
.const
基础能力在于从编译的层面上指出一些不该变化的变量改变的问题,是一种语义层面的约束.其中const最重要的用法体现在函数中,可以修饰函数参数,返回值,以及函数本身(更准确地说是成员函数隐含的this指针).
2.构造,析构,赋值
5)了解C++默默调用的函数
如果我们在定义一个类时,copy,copy assignment,destroy还有constant,都是编译器可以为我们默认定义的(即使我们没有声明或定义).并且都是public,inline.
注意默认的copy只是将数据成员(非静态的)直接拷贝一遍,copy assignment与之如出一辙.
构造函数一旦被我们定义,就不会有默认的将我们所定义的覆盖掉.
当一个类中含有引用数据成员时,copy assignment易导致的问题?
人的本意是,使得这个类中的这个引用所指的对象变为另一个.但是C++不允许“reference改指向不同的对象”.这也就是说,引用机制下的直接assign,我们应该搞清楚.
string temp="hello";
string temp2="world";
string &reftemp=temp;//初始化引用,只能在初始化时与一个变量绑定.之后的绑定无效.
reftemp=temp2;//仅仅是改变reftemp所指对象的值,而不是改为指向temp2.
这也体现了指针和引用的一个重要区别,指针可以改变所引用的对象,而引用只能绑定第一个.
如果想要在一个内含reference成员的类中支持assign操作,应该自己定义一个copy assignment函数.
所以需要注意拷贝赋值函数和reference成员的坑!
6)如果不想明确使用编译器自动生成的函数,就应该明确拒绝.
这一般用于我们想要禁用某种对象的复制,仅仅我们不声明还是不够的,因为一旦调用复制编译器还是会声明默认的,所以如果需要进行就还要禁止编译器的默认生成.
直接的方法就是声明为private,但是对于友元或者成员函数来说还是可以调用.
class Home{
public:
...
private:
Home(const Home&);//copy
Home& operator=(const Home&);//copy assignment
}
如果在成员函数或者friend函数中,去调用了,就会触发链接错误,因为我们没有将这个拷贝函数定义.
除此之外,还可以构造一个noncopyable
的base
class,因此可以将链接错误也提前到编译期间,但是这种写法有可能导致多继承.
7)为多态基类声明virtual析构函数
我们可以通过指针或者引用来使用多态机制进行抽象.我们通过基类类型的指针指向派生类类型的对象.这个时候会发生动态绑定,当我们通过一个这种指针调用内部函数时,直到运行时才会决定调用的是派生类的还是基类的.
对于non-virtual的析构函数.有可能发生未定义行为,导致调用的析构函数只是基类的析构函数,而不支持调用派生类的析构函数,因为多态的情景下,往往需要析构函数也执行派生类各自的版本.
只要在基类上加入virtual即可.如果不想将一个类当作base class,就不声明.
8)被让异常逃出析构函数
如果在析构函数中抛出异常,倘若直接离开这个析构函数,因此造成问题.
有两种方法:
- 结束程序,abort.
DBConn::~DBConn(){
try{
db.close();
}
catch(...){
std::abort();//这种方式能够杜绝“不明确行为”的发生.
}
}
- 吞下异常,保持程序运行.
9)绝不在构造和析构过程中调用virtual函数
这样可能带来预想之外的结果.首先我们应该明白,一个派生类的构造函数被调用时,首先完成的是基类部分的构造,然后才到派生类部分.
如果再某个类的构造函数和析构函数中调用了某个虚函数,那么这个虚函数不是动态绑定的,就是当前所运行的构造/虚构函数所在的虚函数版本.因此此函数可以说不是virtual的.
这样设计的理由,可以说:当调用base class时,如果运行的是派生类版本的虚函数,那么由于派生类部分还没有执行构造,因此不下降到派生类可以避免这种未定义行为.或者也可以说,一个对象在调用一个派生类对象之前不会被解析为一个派生类对象.
对于析构函数来说,也是这个道理,如果在一个base class部分被析构时,虚函数被解析成派生类的版本,而此时派生类已经被析构完了!
所以这一条,主要告诉我们析构函数和构造函数中的虚函数不会呈现多态,这与析构与构造函数在继承体系中执行的顺序密切相关!
10)令operator= 返回一个reference to *this
我们在使用等号时,可以:
int x,y,z;
x=y=z=15;
x=(y=(z=15));
为了实现连锁赋值,我们应该这样重载
class Widget{
public:
//...
Widget& operator=(const Widget& rhs){
//...
return *this;
}
//....
}
对于+=,*=等,也同样适用.
11)在operator=中处理自我赋值
首先是没有考虑自我赋值的代码:
Widget&
Widget::operator=(const Widget& rhs){
delete pb;
pb=new Bitmap(*rhs.pb);
return *this;
}//如果rhs就是自己,由于pb被delete掉,那么*rhs.pb也将无效
第一种考虑自我处理的版本:
Widget&
Widget::operator=(const Widget& rhs){
if(this==&rhs) return *this;
delete pb;
pb=new Bitmap(*rhs.pb);
return *this;
}
这个版本的缺点在于new如果导致异常,Widget就会持有一个只想一块被释放Bitmap的指针.
Widget&
Widget::operator=(const Widget& rhs){
Bitmap *pOrig=pb;
pb=new Bitmap(*rhs.pb);//即使new抛出异常,pb保持原状.
delete pOrig;
return *this;
}
这种方法是,先保存旧的,在拷贝,最后销毁旧的.
还有一种“copy and swap”的高效方法.
class Widget{
void swap(Widget &rhs);
Widget& operator=(const Widget& rhs){
Widget temp(rhs);
swap(temp);
return *this;
}
}
12)复制对象时勿忘每一个成分
当我们在一个class增加一个数据成员时,即使相关的coping和coping assignment没有进行对应的增加,编译器也不会提示.
尤其是对于派生类来说,对于基类中的成分最容易遗漏.这个时候一般要借助基类的coping构造函数.
PriorityCustomer::PriorityCustomer(const PriorityCustomer &rhs)
:Customer(rhs),
priority(rhs.priority){
//....
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer &rhs){
Customer::operator=(rhs);
priority=rhs.priority;
return *this;
}
有时候我们发现拷贝构造和拷贝赋值的一些逻辑高度重复时,会考虑调用另一个来实现,比如说调用拷贝赋值来实现拷贝构造,这种方法不可以,可以尝试写一个新的成员函数,来供拷贝构造和拷贝赋值调用.
3.资源管理
对于在堆空间上创建的对象,我们需要在不使用时释放内存.防止内存泄露.容易导致人忘记释放内存的原因很多,比如说异常,函数那多重回传路径,程序改动的变化等.
主要介绍RAII机制,即利用class的构造,拷贝构造和析构函数对资源进行管理.
13)以对象管理资源
有时在函数中途的某个return,continue或者异常,都有可能让我们遗漏对应的内存释放,所以不要过分的高估自己对于内存释放的细心!,此外还需要考虑别人对代码进行维护的问题,手动管理资源更是给别人的维护工作增加了许多负担.
RAII机制,借助当离开某个对象的作用域时,就一定会调用起析构函数,这个析构函数中涉及到对资源的管理与释放.
基本想法有两个:
- 获取资源后立即包裹在对象中.
- 借助析构函数的自动调用确保资源被释放.
std::unique_ptr<D> p = std::make_unique<D>();
std::shared_ptr<Base> p = std::make_shared<Derived>();
注意的是,如果通过copy和copy assignment复制unique所维护的对象,那么就会报错.它必须维持,一个资源的所有权属于一个对象.
而shared维持一个引用计数,能够支持多个对象对某个资源的引用,不过它容易导致循环引用问题.
除此之外,它们都不能维护动态数组形式的资源,因为他们所触发的析构函数是delete,而不是delete[],容易导致内存泄漏,不过vector往往是替代动态数组的良好选择.
14) RAII类中的coping行为
首先我们考虑设计一个管理互斥锁的类,这个类遵循RAII守则,即在构造时获得,在析构期释放.比如
class Lock{
public:
Lock(Mutex *pm)
:mutexPtr(pm){
lock(mutexPtr);
}
~Lock(){
unlock(mutexPtr);
}
private:
Mutex *mutexPtr;
}
//在用户使用时
Mutex m;
{
Lock m1(&m);
//critical
}//离开时,自动释放锁.
这个时候,我们考虑一下,一个RAII对象被复制时发生的问题,处理方式通常有:
- 禁止复制.将copy声明为private.在modern C++ 可以通过=delete禁止拷贝构造函数.
- 使用引用计数法.这个可以借助shared_ptr的deleter定义.如下:
class Lock{
public:
Lock(Mutex *pm)
:mutexPtr(pm,unlock){
lock(mutexPtr.get());//在构造函数中,将unlock传入删除器.
}
private:
std::shared_ptr<Mutex> mutexPtr;
}//当离开临界区时,Lock的析构函数会调用mutexPtr的析构函数,进而触发unlock,释放资源
- 也可以考虑进行深拷贝或者转移所有权.
15)在资源管理类中提供对原始资源的访问
其实有很多API的参数不支持我们使用RAII类,所以这个时候就需要使用RAII对资源的原始接口.
或者对于一些自定义的RAII类,我们可以提供一个显示转换函数,类似于get(),或者像shared_ptr,auto_ptr
等方法具有一些重载”*,->“等方法.
或者可以提供隐式转换函数,这种方法可以避免显式调用带来的不便.
class Font{
public:
operator FontHandle() const//将隐式转换重载了.
{ return f;}
};
Font f1(getFont());
FontHandle f2=f1;//首先f1转换成一个FontHandle,然后复制.
有人因此而怀疑这种暴漏原始资源的方式破坏了封装的设计理念,但是RAII的意义不在于将一些资源封装,而是确保资源的释放一定能发生.
资源管理类的意义在于确保资源释放行为一定会发生,不必刻意死板地追求封装.
16) 成对使用new和delete形式要相同
首先我们应该明确的是new和delete的行为.这两个都是操作符,前者首先分配内存,然后调用构造函数,后者首先调用析构函数,然后释放内存.
在这里有一个问题,就是一个指针指向的是一个对象还是一个数组.我们或许对于一个指针,容易搞不清它所指向的究竟是对象还是动态数组.如果只是一个对象的话,只要调用好该对象的析构函数就好了,如果是动态数组则需要调用多个.很多时候这两种的情况的内存布局如下:
这里给出的建议就是对于new出来的就用delete,new []出来的就调用delete[]处理.
倘若对一个对象指针调用了delete[],则会将其中的一部分内存上的数据读取成了“n”,然后对后面的内存进行处理,进而引发错误.
如果是对一个动态数组数组调用了delete,会导致内存泄漏.
书中给出了一个很容易犯错的例子,就是将一个动态数组进行typedef,导致它看起来很像是一个对象,因此在通过new,delete进行处理时就会出错.
17)以独立语句将newed置入智能指针
如果我们这样:
processWidget(new Widget(),priority());//这样做是错的,因为智能指针构造函数是explit的
processWidget(std::shared_ptr<Widget>(new Widget),priority());
这一部分与传参时所执行的函数的非原子性有关,也就是说,就在传参时:会不确定顺序地发生3个动作:priority,new Widget,shared_ptr构造函数.如果其中某一步有异常发生,比如priority异常,那么之后的动作就会中断,如果此时new已经发生,那么由于prioruty的异常导致这个对象的指针也就不会封到shared_ptr对象中,因此造成内存泄露的隐患.如果采用下面的写法则会避免该问题.
std::shared_ptr<Widget> pw(new Widget);
processWidget(pw,priority());
所以说,我们一定要考虑好,如果某一步发生异常,会对接下来的动作会产生什么不良影响.
这进一步提醒我们,如果调用一个函数时,参数有多个表达式组成时,一定要考虑到如果其中一个表达式在求出来时触发异常的结果,有可能导致后续的表达式及传参中断.
4.设计与声明
19)设计class犹如设计type
设计types需要考虑的问题:
- 创建和销毁(构造,析构,operator new[],operator new,operator delete,operator delete[]).
- 赋值与初始化(构造函数,赋值操作符).
- passed by valued,与copy 构造有关.
- 约束条件.
- 需要的类型转换.
- 需要的操作符和函数.
- 应当拒绝的标准函数.
20)尽可能用pass-by-reference-to-const替换pass-by-value.
如果是pass-by-value,对于对象来说,将会触发拷贝构造,尤其是多重继承的,会更加复杂,成本很大.
bool validateStudent(const Student& s);
除了节省成本,还可以避免被切割,毕竟多态是以引用或者指针为基础的进行的.
对于一些内置类型,比如说STL的迭代器和函数对象,也许pass-by-value更适合.
21)必须返回对象时,别妄想返回其reference
关于返回和传参问题,引用并不是银弹,下面关于返回类型会有很多问题.
比如说代码:
const Rational &operator *(const Rational &lhs,const Rational &rhs){
Rational result(lhs.n*rhs.n,lhs.d*rhs.d);
return result;
}
这个代码是糟糕的,因为引用所指向的对象是一个临时对象,当离开作用域就已经从栈上弹出了.所以应该尽可能地避免reference指向某个local对象.
如果我们在堆上创建一个对象,返回这个对象的引用,那么就会引发新的问题,就是内存泄漏的问题.尤其是对于一个连等式"w = x * y * z".中间过程中产生的对象,已经没有办法释放了.
如果我们避免使用构造函数,返回的引用指向一个static对象会怎么样?
const Rational& operator (const Rational &lhs,const Rational &rhs){
static Rational result;
result=....;
return result;
}
除了有多线程处理的问题.但还有有这么问题,if((a*b)==(c*d));
.这个结果永远相等,因为定义的运算符的结果永远指向现在的值,这样的函数,将不会起到有效保存某个值的状况.
所以综上所述,这些情况下,不如不使用reference.在这种情况下,所付出的拷贝构造的成本时必要的.
inline const Rational operator *(const Rational &lhs,const Rational &rhs){
return Rational(lhs.n*rhs.n,lhs.d*rhs.d);
}
22)成员变量声明为private
首先设想,如果将所有的成员变量都设置成public,那么也就意味着所有的成员变量对外是既可读也可写的,那么对于改变量的访问控制也就无从谈起了,但如果是private,我们也就可以借助getter,setter实现只可读,只可写等,因此我们也就有了更细微更灵活的读写控制.因此,可以为后续对变量的访问控制留下更多发挥的余地.
这一条主要结合private来讲了讲访问权限和封装性的关系.
在封装性上,可以为“所有可能的实现”提供弹性.封装对于用户隐藏成员变量,因此可以确保class的约束条件得到维护.
封装性的重要特点?
某些东西的封装性与“其内容改变时”可能造成的代码破坏量成反比.
对于一个public成员变量,当其取消时,所有使用这个变量的客户代码都会被破坏.
对于一个protected来说,所有derived变量都会被破坏.
封装性差的代码一旦某些地方修改,要进而进行重构的地方将会更多.
23)宁以non-member,non-friend替代member函数
比如说:
class WebBroswer{
public:
//...
void clearCache();
void clearHistory();
void removeCookies();
};
假设我们想要一下子执行所有动作:
class WebBroswer{
public:
// . . .
void clearEverything();
// . . .
};
//或者是以一个non-member函数在外面定义
void clearBrowser(WebBrowser &wb){
Wb.clearCache();
Wb.clearHistory();
Wb.removeCookies();
}
其实相对来说,后者的封装更好,可拓展的弹性余地也很大。书中反驳了“数据及其操作数据的函数应当被捆绑在一起,因此提倡尽可能使用member”的观点.很多时候,member函数不如no-member函数封装性好.
比较自然的做法就是利用namespace:
namespace WebBrowerStuff{
class WebBrowser { };
void clearBrowser(WebBrowser &wb);
}
24)如果所有参数都需要隐式转换,请为此使用non-member。
如果我们想要支持加法,乘法等运算,我们应该坚持面向对象的精神。
对于下面这种有理数的重载,一般采用non-member
的情况比较好,这样使得其能够支持对于运算交换律的支持.假若,我们不采用non-member的方式:
class Rational{
public:
Rational(int number=0,int denominator=1);//不为explicit
//允许int-to-Rational的隐式转换.
const Rational operator* (const Rational& rhs) const;
};
//容易出现的问题
Rational result=onself*2;//ok
Rational result=2*oneself//error
之所以会出错,是因为“2”并不是一个Rational对象。而对于第一个来说,其实发生了隐式转换(与non-explicit构造函数有关),类似于根据2构造了一个Rational的对象,然后相乘。而non-explicit函数的隐式转换只对参数列表中的有效。
总的来说,这和我们想要支持混合运算有关,比如说对象与整型数的混合计算。这个时候最好定义non-member的运算函数。
const Rational operator*(const Rational& lhs,const Rational& rhs);
这样就对前面所列举的两种混合运算都没有问题了。
总体来说,这个准则主要是用于支持混合运算的运算符重载,前提是non-explicit类型的构造函数支持隐式转换。总的来说,对于这种需要实现混合运算的情况,支持隐式转换 + 采用non-member比较好,如果不采用non-member,即使构造函数支持隐式转换,那么也就只有处于参数列表中的参数可以转换,而隐含的参数却不可以.
5.实现
26)尽可能延长定义式的出现时间
在一个对象未被使用的情况下.其构造和析构成本是多余的,我们应该尽可能避免这种多余的成本.
在此我们需要考虑,有哪些情况使得一个函数或者控制流终止,比如说异常和return等等.如果在真正使用某个对象之前就发生了这些行为,该对象的构造和析构成本就被浪费了.因此根据这里我们可以理解,延后对象的意义是等到非得使用它的时候.
首先对于一些需要构造新的对象,并且牵扯到一些异常处理的过程,我们应该尽可能将异常处理提前在对象的构造之前,这样是一定程度减少了该对象因为异常终止而没有被充分使用的状况。
除此之外,不仅仅需要将该对象的定义延后到“必须使用该对象”为止,最好在构造函数中就定义好,而不是一般的default的构造。这种情况下,构造函数是需要参数值的,毕竟一个对象的构造往往是需要参数的,这个时候我们所谓的“延后”,也是为了等到需要的参数值都准备好了之后.
而对于循环的状况,对于只在循环内部使用的临时变量呢,是在循环外部定一个对象,还是将对象定义在循环体之内呢?
简而言之两种方式的成本如下:
- 定义在外部:1构造+1析构+n赋值。
- 定义在内部:n构造+n析构。
考虑赋值成本和“构造+析构”的成本对比。
总的来说,这个条款考虑了三个情况: 1. 由异常和return所导致的未使用对象. 2. 等待到所有参数准备就绪后定义. 3. 在for循环外部定义还是内部.
27)尽量少做转型动作
C++中几种新式转型动作:
const_cast<T> (expression);//用于常量性移除
dynamic_cast<T> (expression);//安全地向上转型,用于继承体系中,将某一个基类转成派生类(指针或者引用)
reinterpret_cast<T> (exprssion);//执行低级转型
static_cast<T> (expression);//强迫隐式转型
新式转型容易辨认并且目标精确.
转型不仅仅是单纯地告诉编译器将某种类型视为另外一种类型,会真的有动作执行.在设计某个函数的接口时,应该避免User需要将对象进行类型转换才能使用情形,应当将类型转换放在函数的实现中.
28)避免返回handles指向对象的内部成分
首先我们搞清楚handles是什么,handles就是引用,指针,迭代器等能够得到原对象进行修改控制的东西,就像是号码牌.
对于一些类,如果其private成员是一些指向某些对象的指针,那么我们在设计对外接口的时候,如果出现了返回这些对象引用或者指针的,那么就使得用户获取了控制private数据对象的权利,破坏了封装性,危险性比较大.
主要问题在于:
打破private不可被外界访问的规则.
cosnt类型的成员函数不应该使得调用者有修改内部数据成员的可能.
如果非得返回handle,,应该使用const reference.
class Rectangle{
public:
const Point& upperLeft() const { return pData->ulhc; }
const Point& lowerRight() const { return pData->lrhc; }
};
这样,即使用户拿到了handles也没有修改对象的权利了.但即使这样,用户拿到的handle还是有悬空引用的危险的.
从体来说, 这一部分主要考虑的是保证封装性, 使得const函数真的是const, 并且防止悬垂引用.
30)inlining的里里外外
inlining调用起来像是函数,但是却没有一般函数所有的开销.内联函数的核心观念和宏定义函数很像, 相当于替换代码.但是有可能造成替换后, 代码体积膨胀.
首先我们需要明确的是inline的定义方式,分为隐式地或者显式地,前者是在class作用域内部的,后者是在通过关键字inline的.关于不会被转化到inline的函数,要么是太复杂的函数(带有循环和递归或者virtual函数).
这一条款的核心在于inline只是一个对编译器的申请,而不是强制性的,也就是说带有inline的函数并不一定真的是内联的,对于inline的处理(将函数调用动作转化为该函数的主体)是在编译期间进行的,如果没有被inline的话就会给出警告信息.
内联函数也不是完美的,因为其总体思想在于“将对某个函数的调用”,都用函数本体替换,导致代码量增加,所消耗内存加大,但如果本身就是很简短的函数,那么产出的代码就会比较小.
构造函数和析构函数往往不是理想的inline函数候选者.
此外如果一个程序关于内联函数中的具体内容发生变化时,整个程序都需要进行重新编译,如果不是内联函数,就会只需要重新链接即可.(.h中的文件往往是编译器直接完成, .c源文件中的内容往往需要编译完,再进行链接)
书中还给出了一个建议,不要忙目地将函数定义为内联函数,最好一开始都不使用内联函数(或者有绝对把握的).往往是在优化的过程中,根据2/8原则,确定程序中的热点后,对这些热点代码进行优化时,考虑将其中一部分代码改成inline的.
31)将文件间的编译依存关系降到最低
这一条主要讨论了class定义(#include)和向前声明对于编译依存关系的不同影响.
对于前者来说,如果我们通过#include的方式引用了某个class的定义,一旦这个class对应的.h文件被修改,就会导致所有引用该.h文件的代码都被重新编译,这带来了较大的编译负担.这时可以考虑只使用向前声明而非定义,但是这会导致一个class因为其中的某些object是声明而不是定义导致的占用空间未知的情况,因此这种方法对于一个class中含有object的情况并不适用,但是我们可以将object的使用改成指针或者引用类型,这样这个class总体所占空间的大小就是已知的了.
因此书中给出了建议:
- 如果使用object reference或者pointer就可以完成任务, 就不要使用objects.如果想要定义出某种对象的reference或者pointer只凭借class的声明(向前声明)就可以, 但如果定义对象, 就必须要对象的定义式(#include).
- 多使用class声明式能够有效降低编译依存关系.如果只是函数中涉及到某个class, 就是是pass/return by value, 也可以只靠对象声明式.
如果涉及到函数需要用某个class的对象时,可以只依附于声明而非定义,即使是pass by value也可以.
6.继承与面向对象设计
32)确定public继承符合is-a的关系
public继承是一种“is-a”的关系.
如果B public继承了A,那么B将严格地接受A的一切.
一些不那么严格,容易出错的关系,比如过bird和Penguin的关系.如果我们认为所有鸟都会飞,那么Penguin的存在则是破坏了is-a的关系.但是,一些复杂的问题究竟考不考虑,也取决于程序设计时的需求.
对于企鹅和鸟的问题,要么是基类bird没有fly这个虚函数,要么让保留fly函数,但是在Penguin的实现中返回错误(后者相当于,所有鸟都会飞,企鹅也是鸟,但是企鹅会飞是不可行的).
另一个例子也是,正方形确实是一种矩形,但是并非所有矩形的操作都适用于正方形.
33)避免遮掩继承而来的名称
首先引入一个作用域中的名称问题:
int x;
void someFunc(){
double x;
std::cin>>s;//这里引用的是local变量x.
}
在这个例子中,出现对x符号的引用,但是local作用域却把global作用域掩盖了.作用域的关系上,someFunc(local)的作用域被嵌套在了global中.
在继承体系中会怎样呢?实际上,派生类作用域被嵌套在了base的作用域中.
比如说,如果我们在派生类中的某个函数中定义了一个符号,首先查找local,然后查找整个派生类作用域,然后是Base作用域,最后是个global.
在一个更复杂的例子中:
class Base{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
virtual void mf3(double);
};
class Derived: public Base{
public:
virtual void mf1();
void mf3();
void mf4();
};
在这个例子中,派生类中mf1把基类中的两个mf1都掩盖掉了.mf3也是.这也可以说mf1和mf3根本就没有被继承.
我们应该防止在派生类中,继承基类中的重载函数,如果使用public继承而又不继承那些重载函数,就是违背“is-a”.
这种情况,主要发生于我们想要在派生类中增加对被继承函数的重载函数,而“遮掩”就是派生类中新增重载函数,导致原基类中的函数无效的情况.
对于这种问题,一种解决方法:
class Derived: public Base{
public:
using Base::mf1;
using Base::mf3;
virtual void mf1();
void mf3();
void mf4();
};//这样既可以使用派生类中新增的函数,也可以使用被遮盖的基类中的函数.
另一种是使用转交函数,这里主要介绍了一种借助private继承,来“有选择继承”的方法:
class Base{
public:
virtual void mf1() = 0;
virtual void mf1(int);
};
class Derived:private Base{
public:
virtual void mf1(){
Base::mf1();
}
};//这种方法中,只有无参的被继承了,如果调用有参的版本,会报错.
34)区分接口继承和实现继承
public继承体系中分为:接口继承和实现继承.
需求分为:
- 希望只继承接口.
- 希望即继承也继承实现,并且可以override实现.
- 都继承,但是不可以override.
我们也可以将接口分为三类:
- 纯虚,对应只继承接口,派生类必须给出实现.
- 非纯虚,对应继承接口也继承实现,可以override.
- non-virtual,对应上面的第三种情况.
35) 考虑virtual之外的其他选择
36)绝不重新定义继承而来的non-virtual函数
假如说B定义了一个public成员函数(non-virtual),C对B进行public继承.
我们理想的情况下:
B *pb=&x;
D *pd=&x;
pb->mf();
pd->mf();//两者执行mf函数的行为应该是一样的
首先我们会说,对于non-virtual类型的函数都是静态绑定的,当pb和pd执行mf时,pb执行的就是B所定义的版本,而pd执行的就是D所定义的版本.
但如果是动态绑定,当mf时virtual时,就一定会执行D::mf.
一个继承而来的函数,应该重新定义.
总体来说不要重定义继承而来的non-virtual.函数.
37)绝不重新定义继承而来的缺项参数值
粗略地说,我们可以将函数划分为两种,一种时static和non-static.
virtual函数是关于动态绑定,缺省参数值是静态研究的.
38)通过复合塑造出has-a或者“根据某物实现”
复合这样一种关系:某种对象中含有其他类型的对象.
复合的意义有两种:一种是has-a,另一种是根据某物来实现.
对于抽象的事物,我们可以分为应用域(对真实世界某种事物的抽象),也可以分实现域(用于实现某种细节的组件).
假设我们需要一个template制造出classes表现不重复的sets,我们可以借助复用(根据某物实现出),可以在sets中借助一个平衡查找树或者list,同样的情况还有,stl中的queue内部有一个deque,这都是典型的复用.这样看来似乎继承也可以实现这种功能?
确实可以实现,但存在一定的错误,使用继承也就是“is-a”的关系,sets不是list.此外list所含有的一些性质不能适用于sets上.因此采用继承实现“is-a”不可以.
所以我们应该注意“is-a”和“根据某物实现”是不同的
39)明智而审慎地使用private继承
private的继承关系究竟意味着什么:
- 不会有派生类到基类的转换的关系.
class Person{...};
class Student: private Person {...};
void eat(const Person &p);
Person p;
Student s;
eat(p);//正确
eat(s);//错误,派生类对象不会被编译器自动转化成基类对象
- 在派生类中,从基类中继承而来的成员会变成private属性.
其实其意义,代表“根据某物实现出”.和上一节说的“复合”,更推荐使用复合.
40) 明智而谨慎地使用多重继承
8.定制new和delete
49) 了解new-handle的行为
new-handle有程序员定义,用于当内存分配不满足内存需求时所调用.
如果我们调用operator new,因为内存需求不能满足而抛出异常,这个时候先调用一个new-handle处理函数.