1. 开场白
《Effective C++》这本书一直觉得有些难度,已经反复看了好几次了,每次看都能发现一些以前没有注意到的知识点。建议在看这本书前先看看《C++编程思想》或者是《C++ Primer》,另外,如果看过一些《设计模式》或者《敏捷设计开发》的会稍微好些,书中一些条款涉及了设计模式,虽然并不是在讲设计模式,但是一些设计模式中的思维模式影响了这些条款的内容。
最后,我不知道是我个人语文水平不行的原因,还是说侯捷大大翻译的原因。我总觉得这本书翻译的有些拗口,不像我以前读《STL源码剖析》时候那么有感觉,我后面也读了《More Effective C++》,也是侯捷大大翻译的,感觉理解起来也是很吃力,一些语句得停下来一点点的抠才知道他说的是什么意思。额,不提这些了,接下来就整理一些条款的心得体会。
2. 记录
条款1. 视C++为联邦语言
大意就是C++很难,但是很通用,学会了C++学其他语言都不吃力,要好好学C++。
条款2. 尽量以const,enum,line替换#define
首先说明#define,其作用就是做一些宏定义,这些宏定义在编译过程(更确切的说是预处理过程)中会进行宏展开,而所谓的宏展开就是“文本替换”,在linux下可以使用:gcc -c input.c -o output 查看一个宏展开后的结果,更详细可以翻阅《链接、装载与库》这本书,书中详细介绍了预处理过程。
既然#define定义的内容在编译期间会被宏展开,它好处很明显,可以让代码便于阅读,但是又不会带来函数调用的开销。那它有什么缺点:
- #define没有作用域的概念,也就是说namespace和class的作用域限定对于#define定义的宏是不起作用的,可以看下面的这个代码。
namespace Foo {
#define TEMP "hello world"
}
class Base {
#define NAME "Base"
};
int main() {
cout << NAME << TEMP << endl;
return 1;
}
- #define在预处理过程中处理,不利于调试。#define一旦宏展开,宏名称再也找不到了,那调试的时候并不会告诉我们是宏定义出现了错误。例如下面的这个例子,:
#define MAX(a, b) (a > b ? a : b)
int main() {
int a = 1, b = 1;
cout << MAX(++a, b) << endl;
return 1;
}
这里的++a被替换了两次,所以宏展开以后的结果是:(++a > b ? ++a : b),这样一看就知道有什么问题了,但是没有调试的时候却很不容易发现这种问题。
这样就很容易理解为什么希望用const、enum和inline替换#define了吧。
const和enum可以定义常量而且还可以先定这些常量的作用域范围。
用inline则可以定义函数式的宏,却不会引起上述这样的问题。
条款3. 尽可能使用const
首先需要熟悉这里的const会出现在哪些地方,const虽然总体来说是常量(不愿意被修改),但是在不同的位置中语义又存在一些细微的区别,下面列举了一些const出现的位置,在不同的位置出现的位置,其中关注几点:
- 它修饰的对象是谁?
- 它的作用域范围?
- 它的语义是什么?
在《C++编程思想》一书中对const进行了详细的描述,具体内容可以翻阅这本书,这里就不赘述了。
const int a = 1;
const static int b = 2;
class Base {
public:
const string &getName(const string &index) const {
const int tmp = 10;
}
const static string name = "Base";
const int idx;
const int * const foo;
};
使用const的理由有:
- 避免作为等式左值,虽然稍微熟悉点的程序员都会避免这么做,但是不排除在写条件语句的时候不小心将“==”错误的输入成了“=”,当然我也见过初学者直接将这个作为函数左值的情况出现。
- 告诉调用者以及自己,使用const修饰的这个对象希望被保护起来,我们不愿意其被修改。虽然在这些情况下不使用const也是可以的,但是问题就在于总有那么些人和自己捣乱,与其一次次的和他们说这里不能动,那里不能改,不如事先用const修饰一下,然后其用的时候就知道“哦,这里用const修饰了,我不应该去改它”。
但是const的保护并不“完美”,它没办法避免一些人别有用心的去修改它,感觉它就和互斥锁一样,仅仅只是大家见的“君子协议”,如果真的有人不遵守,那就没办法了。所以,在后面的条款28就告诉我们:既然我们没办法阻止一些人总希望绕过const来修改内部的值,我们就尽量不要将对象内部数据的引用返回给调用者,这样用private保护起来的对象属性,外部就无法获取到了。
条款4. 确定对象使用前已被初始化
在C++中,有几个术语的定义总容易被搞混,比如“声明”、“定义”、“初始化”,这些术语的定义有些精细,如果没有弄清楚这些词具体说的是什么,那看《C++编程思想》《Effective C++》这种书的时候就有些吃力,因为有许多地方作者精确描述了它们,但是我们却没有理解作者在说什么。
“初始化”这个词在不同的场景下的语义有些细微的区别:
- 如果对一个类对象说初始化,意思主要是指这个类已经“准备完毕”,可以正常工作,那这里的准备完毕就有很多含义了,比如tcp客户端的套接字的“准备完毕”--可能指的就是已经与服务器端完成链接,也已经完成服务器端的地址信息配置,也可能是指已经套接字已经申请完成,这里的初始化语义就很丰富了,总体来说,只要这个对象在后面的代码中可以正常调用了,那就是已经初始化完成了;
- 但是对一个对象内部的成员属性的初始化定义就十分精细:在成员初始化列表中完成初始化过程。
作者在条款4中,作者前后讨论的其实就是两类情况下的初始化:类对象实例内部成员属性的初始化;相互依赖的类对象间的初始化。
- 对于类对象内部的成员属性的初始化,第一个需要注意的就是构造函数内部的过程并不是”初始化“,初始化这时候已经完成,确切的初始化应该是在初始化成员列表中的初始化;第二个需要注意的就是,内置对象(int、char、long、double等)的初始化值是不明确的,根据具体的编译器相关,所以最好在初始化列表中进行初始化,对于引用,const变量只能在初始化列表中进行初始化,对于是一个类对象的成员属性,如果不希望调用默认构造函数,那也需要在初始化列表中初始化(为了保证效率);第三个需要注意的就是初始化的顺序问题,基类首先被初始化(如果存在的话,如果是多重继承,那先初始化虚继承,然后再依次初始化),然后就是依据声明的次序依次初始化,如果初始化列表中存在定义,则调用初始化列表中的方法进行初始化,如果没有在初始化列表中初始化,那对于基本类型就根据编译器而定,类对象则调用其默认构造函数进行初始化(如果存在的话,如果不存在就必须在初始化列表中初始化)。
- 第二种情况是类对象相互依赖的情况,作者想说明情况是使用”单例模式“保证获取的一个静态变量是被初始化的。
条款5. 了解C++默认编写并调用那些函数
这个很重要,就是了解我们编写了一个空的类,c++的编译器会偷偷的帮我们做什么事情,第二个就是一些赋值和拷贝操作的时候,C++又偷偷用了哪些函数。尤其是构造函数和析构函数,后面大量的条款全部都是在讨论这两个位置的方法要注意哪些问题。
- C++默认为我们生产了默认构造函数、拷贝构造函数、赋值操作符、析构函数这4个函数,第一个注意的是,这些方法都是非虚的,条款7中就讨论了非虚对析构函数造成了什么影响,然后就是构造函数不能声明为虚函数。第二个需要注意的就是,虽然C++中如果不使用public和protected修饰的属性和方法都默认为private的,但是唯独这4个方法,除非显示使用private修饰,否则它们都是public的。
class Base {
public:
// 以下方法是默认提供的
Base() {}
Base(const Base &b) {}
~Base() {}
Base &operator=(const Base &b) {}
};
- 然后需要注意的就是,什么时候调用哪个方法,这样我们在初始化的时候才能心中有数,可以参考下面的几种写法,清晰的知道自己正在做什么:
int main() {
Base b1; // 默认构造函数
Base b2 = b1; // 拷贝构造函数
Base b3(b1); // 拷贝构造函数
Base b4; // 默认构造函数
b4 = b1; // 赋值操作符
Base *b5 = new Base; // 默认构造函数
Base *b6 = new Base(b1); // 拷贝构造函数
delete b6; // 析构函数
Base *b7 = (Base *)malloc(sizeof(Base)); // 不会调用构造函数
free(b7); // 不会调用析构函数
return 1;
}
条款6. 若不想使用编译器自动生成函数,就应该明确拒绝
在之前我说到,C++中唯独只有这4个自动生成的函数是默认为public的,所以这个条款想说的意思就是:如果不希望别人调用这4个自动生成的函数,那就应该将其用private进行修饰。
第一个需要注意的问题就是,我们不可能是使用private修饰析构方法(不然这个对象就没办法执行析构了,程序必然出错);
第二个问题就是,构造函数,拷贝构造函数和赋值操作符既然可以声明为private,单例模式就是这个条款的一个典型应用场景了。至于书中后面提到的那个Uncopyable例子嘛,我是觉得使用场景太狭窄了,作为一种技巧还行,不适合作为一种惯用法。
条款7. 为多态基类声明virtual析构函数
要理解这个条款,我感觉需要深刻的理解C++中”早捆绑“与”晚捆绑“的区别,然后第二个需要理解C++中如何使用虚函数表的方式实现”晚捆绑“,这些在《C++编程思想》一书中给出了详细的解释。如果还想更加深入的理解那就需要再看《链接、装载与库》一书中关于C++函数名生成的内容,然后自己写例子查看汇编源码以及符号表。这样才算对这个条款有一些相对清晰的认识。
使用非虚函数,编译器在解析的过程就会使用”早捆绑“的方式进行解析,意思就是在编译过程中,就确定好了调用哪个函数。反之,如果使用了虚函数,编译器就会使用”晚捆绑“的方式进行解析,这样在执行过程中,在调用虚函数的地方会进入一个查虚函数表的例程中,这个例程就会帮我们选择使用对应的函数方法。
在下面的这个例子中,编译器在执行到delete语句时,会认为b是一个Base对象,然后Base对象的析构函数为非虚函数,这样编译器就会采用”早捆绑“的方式,认为这里调用的其实就是Base的析构函数,这样Derive的析构函数就不会被执行,但是我们实际的意图是希望调用派生类中的析构函数。
class Base {};
class Derive : public Base {};
int main() {
Base *b = new Derive;
delete b;
return 1;
}
如果出现上面这种情况就容易发生局部析构的危险,当然在上面这个例子中是不会出现局部析构的危险了,但是如果在Derive中有一个指针指向了堆中的一块内存,此时如果仅仅调用Base类中的析构函数,则就不会执行这个Derive中的析构,这块内存就泄露了。这就是它最大的一个危害。
使用下面的这种方式就不怕出现上述的问题了,因为编译器会知道这个地方需要采用”晚捆绑“,这样在执行delete过程中,就会进入到虚函数表的查询例程中开始执行派生类中的析构函数。(当然这里不用担心,会不会只执行派生类中的析构而没有执行基类中的析构,因为C++保证了,只要调用了一个类的析构,那就回递归的调用其子类中的析构)
class Base {
public:
virtual ~Base() {} // 虚析构函数,确保了可以调用派生类中的析构
};
class Derive : public Base {};
int main() {
Base *b = new Derive;
delete b;
return 1;
}
那什么时候需要使用virtual析构函数就很明显了:只要这个类有可能被作为基类而存在,那就它的析构函数必须是virtual的。当然书中也给出了一个相对容易判断的准则:只要出现了virtual关键字修饰的方法,就必须将析构设置为virtual的(这个道理很容易解释,因为我只要设计了virtual函数,那我就是希望可以有一个派生类继承这个类,所以析构就需要为virtual的了)。
当然,如果可以肯定这个类不会被派生,那不设置virtual析构也是可以的。不过书中也有提到,在C++中没有提供像Java这样的final关键字用于修饰一个类,表示这个类不可被继承,所以在C++中我们没办法避免我们有时候去继承一些不带有虚析构函数的类,典型的如std::string类。
至于书中最后提到的将一个类的析构设置为纯虚函数,感觉还是比较有趣的,它的应用场景就是:我希望一个类不能被实例化,但是我又不希望提供任何虚函数结构(这都啥子玩意呀),那弄到最后,我只能将析构函数设置为纯虚函数了。但这里还是有一点需要注意,将普通函数方法设置成纯虚函数后,不实现这个函数的定义是可以的。将析构函数设置为纯虚函数,无论如何都需要定义个析构函数,因为派生类在执行析构函数的时候总是要调用基类的析构,所以代码应该如下:
class Base {
public:
virtual ~Base() = 0;
};
Base::~Base() {}
class Derive : public Base {};
int main() {
Base *b = new Derive;
delete b;
return 1;
}
条款8. 别让异常逃离析构
这个条款看着迷迷糊糊,但是其实说的道理也很简单:我们不能将清理工作做一半就不做了。
执行析构就是在执行各种清理过程(比如释放堆上内存,关闭文件描述符,关闭套接字\数据库连接等等),异常就是一个打断机制,如果不捕捉异常,这个异常就会让析构函数直接结束,将异常提交给外部进行处理。这时候问题就出来了,类对象内部的很多数据外部是访问不到的,如果析构过程将异常抛出给外部人解决,外部又没办法获取这些数据,那这些未完成的清理就没办法完成了。
所以”吐出异常“就意味着我们干活弄了一半,没有将剩下的一半完成。
条款9. 绝不在构造和析构过程中调用virtual函数
不这么做的原因不在于这里会出现什么语法错误,不调用的原因就在于:在构造和析构的时候,编译器将虚函数看做非虚的,也就是说,在基类中调用虚函数,不会调用到派生类中的覆写的那个虚函数,就是调用基类中的那个函数。那这就意味着:我们根本没办法利用虚函数机制在构造和析构中调用派生类中的方法。所以抱着这个目的都可以回头了,方法行不通。
当然也不是没办法在将派生类中的数据传递给基类,书中就介绍了如何依靠静态方法进行实现。
条款10. 另optertor=返回一个reference to *this
这个条款就是告诉我们,如果要覆写赋值操作符,最好返回*this的引用,这样做的好处就是外部可以使用:a = b = c;这样的操作,毕竟主流都认为应该具备这种语法操作。所以这个条款就是告诉我们一个主流的做法而已。
条款11. 在operator=中处理自我赋值
这个条款就是提醒我们注意这种畸形调用的情况:
int main() {
Base b;
b = b;
return 1;
}
最容易出现的一种意外情况就是:
class Base {
public:
Base &operator=(const Base &in) {
delete name;
name = new string(*in.name);
return *this;
}
private:
string *name;
};
一旦出现自我赋值的情况下,这个代码必然出现问题了。
只要看明白了这个问题,那基本上就懂这个条款具体要说什么了。当然条款也给出了这种情况下的几种处理方案,如”证同测试“和”copy and swap“技术等。
条款12. 复制对象时勿忘记其每一个成分
这个条款说的内容也很简单,如果我们要自己覆写拷贝构造函数和赋值函数,那就千万不要忘记复制基类中的那些数据。否则就会造成对象的局部拷贝。很容易理解,书上例题基本上一看就懂。
条款13. 以对象管理资源
并不是所有对象都是在栈上分配的,在堆上分配的对象如果不显式调用delete方法,那一旦无法获取这个对象的地址那就造成了内存泄露。如果大家都记得这个对象什么时候应该释放,那一切都很简单,但问题就在于我们经常会忘记。所以这个条款就给出了一些场景,告诉我们这些场景下应该用什么样的智能指针。
其实要理解这个条款,我觉得以下东西需要弄明白:
- 智能指针的生命周期:这直接决定了我们可以在哪里申请我们的智能指针。
- 对象的生命周期:这个直接决定了我们希望使用哪种类型智能指针。
- 智能指针管理下的对象生命周期:不同的智能指针管理的对象的生命周期是不一样的,虽然智能指针被析构了,但是它所管理的对象不一定会被释放,所以弄清楚这点很重要。
- 指针使用的注意事项:这个和相关智能指针的实现相关,智能指针在使用的时候总有各种各样的限制,如不能在容器中使用auto_ptr,auto_ptr无法进行值传递操作,shared_ptr无法解决环状引用问题,auto_ptr和shared_ptr不能用于管理数组对象等等。要弄明白为什么会有这些限制,看看这些智能指针内部的机制或许是一个更好的选择。
最后给出一些,我觉得需要使用智能指针的地方:
- 对象在函数内部分配(当然必须是在堆上分配),但是函数尾部不释放而是希望将其传出给外部(工厂方法就是常见的一种情况),这时候可以考虑使用shared_ptr指针。
- 对象在函数内部分配(当然必须是在堆上分配),但是希望这个对象在函数返回的时候自动释放,这种时候可以考虑使用auto_ptr,使用auto_ptr还有一个好处,就是即使函数内部存在异常抛出的情况,这个对象也可以自动的被析构。
- 在类中的成员属性,但是这个属性成员指向的数据是被多个多想共享的(例如数据库连接),那可以考虑使用shared_ptr。
反正,好好利用智能指针,可以简化许多内存管理工作。
(当然,我还是希望能够有一个类似Java堆管理这样的机制了,虽然堆管理没有十全十美的,但是总感觉比智能指针这种的好用)。
条款14. 在资源管理中小心copying行为
条款15. 在资源管理类中提供对原始资源的访问
条款16. 成对使用new和delete是要采取相同型式
条款17. 以独立语句将newed对象置入智能指针
条款18. 让接口容易被正确使用,不易被误用
“接口”这个术语在不同的场景中表示的含义也有一些微妙的区别,条款18中的接口的定义和条款34中对于接口的定义是不一样的,前者侧重描述一个函数方法的参数与返回值的设计,而后者则侧重于由多个函数方法封装后的整体。
条款18的目标就是要告诉我们如何去设计一个函数——除去函数内部逻辑部分以外——输入参数、输出参数、函数名(一个函数其实就是由这三部分+访问权限+函数逻辑构成),最后这个条款得到的结论就是:
- 函数名:保持函数名的一致性,也就是说具有相同功能的函数最好命名是一致的,这样设计的理由就是让别人(包括自己)用起来不会因为命名混乱而错误使用。
- 输入参数:语义清晰、尽可能保证参数不会被错误调用、保证传入参数的效率。做到这点并不容易,如要求语义清晰,我们可以使用精确的命名,让调用者可以直接明白参数的含义,也可以利用const关键字标识传输参数是否会被修改;此外要保证参数不会被错误调用,比如如果输入的整数是特定的几个值,就可以利用枚举实现;要保证传输参数的效率,那就是最好采用引用的方式,这样可以避免1次的构造和析构操作。此外还有很多,比如采用默认参数,告诉调用者某些参数是可选的;
- 返回值:返回值也很重要,如果希望返回一个函数内部生成的对象,那就要想办法避免用户忘记释放内存而造成内存泄露,一种比较好的方法就是利用shared_ptr指针。
个人的感觉就是要设计一个好的接口(函数)其实挺难的,要考虑的事情还是相对比较多。
条款19. 设计class犹如设计type
感觉这个条款很鸡肋呀,要达到这个条款,不仅仅需要对C++语言基础有深厚的功底,还需要有大量架构设计的经验,这样才能设计出一个良好的类,当然良好设计的类都符合条款中提出的那些要求。所以看这个条款以后,不妨看看《设计模式》《分析模式》《编写有效用例》《面向对象的软件设计模式》这些书,看完以后相比会对这个条款会有更清醒的认识了。
条款20. 宁以pass-by-reference-to-const替换pass-by-value
对象传递有三种方式:值传递、引用传递(还有一种忘记叫什么,现在基本见不到)。这个条款就是告诉我们函数的传入参数以及返回值,都尽可能的使用引用传递的方式。其实这个条款并不难理解,如果事先看过《链接、装载与库》一书中关于函数调用的说明,那这个条款看起来真的是手到擒来。
- 对于内置对象(int、char、long、double等),如果不希望在函数内部进行修改,那就直接使用值传递的方式,它的效率反而比引用传递的效率更好(更确切的说是在函数内部访问的时候效率更高);如果希望在函数内部对其进行修改,那使用引用传递,但是不要加入const描述,这就是涉及形参和实参的区别了。
- 对于类对象,使用引用传递的好处就是它传递的只是一个指针(在系统看来引用和指针就是一个东西,对程序员会存在一些语义上的区别),这种方式避免了传入时调用类的拷贝构造函数,以及在函数返回时调用析构函数。至于加入const关键字是为了告诉外部人员,我内部不会对这个类对象实例进行任何修改。
- 而且使用值传递的方式传递还有一点很重要的就是它可以避免“对象切割”问题,这个问题也很很好理解,就是因为函数向上转型导致了派生类中的内容被切割了。
- 最后还有一个需要注意的就是,对于STL这样的容器而言,其还是使用值传递的方式,所以平时得知道自己每次调用容器到底执行了哪些操作了。
条款21. 必须返回对象时,别妄想返回其reference
条款22. 将成员变量声明为private
将成员变量声明成public就意味着我们放弃了对这个成员变量的控制权力,让其暴露在所有人的目光下,不用想也知道这样是多么的危险;将成员变量设计成private可以说是惯例了,对成员变量的访问一般就是通过getter/setter方法来(我比较喜欢这种),对此Java在这点上感觉做的更好一些了,C#做的就更漂亮了。
条款23. 宁以non-member、non-friend替换member函数
条款24. 若所有参数皆需类型转换,请为此采用non-member函数
条款25. 考虑写出一个不抛出异常的swap函数
条款26. 尽可能延后变量定义式的出现时间
这个条款不适合在编码过程中考虑,不然会增加自己的思维负担,这个条款更适合在重构过程中使用,这时候已经编码完成,可以对变量的定义时间进行评估,然后选取最优的方案。
这个条款我感觉是针对类对象实例的,如果是内置类型和指针,如果去看汇编源码,是否延迟其实汇编指令差不多,反而是类对象实例的定义,总需要耗费构造与析构函数的时间,所以才会有这个条款的产生。至于怎么做:
- 将变量的定义延迟到使用前一刻
- 合理评估构造与析构的时间,例如书上给出的例子,方法A需要1次构造+1次析构+n次赋值,方法B需要n次构造+n次析构,个人感觉方法A在大多数情况下比方法B效率要高,当然也不排除例外了(书上说有可能B更好,我是想不出来了)
// 方法A
Base b;
for (int i = 0; i < n; i++)
b = ...
// 方法B
for (int i = 0; i < n; i++)
Base b = ...
条款27. 尽量少做转型动作
C++提供了比C更丰富的转型方式:
- C风格的转型:适合内置对象类型和指针
- const_cast:去除类型的常量性
- dynamic_cast:向下类型转型
- reinterpret_cast:低级转型,一般不用,因为不具有平台的可移植性
- static_cast:类似C风格的转型
C++转型方式很多,所以:
- 第一点就是要根据需要选择合理的转型方式,例如内置对象类型可以使用static_cast,去除常量性使用const_cast等。
- 第二点就是尽可能保持风格统一,这个是为了便于代码阅读而提出的,所以如果决定采用C++风格的转型,那就尽量不用C风格的转型了。
条款28. 避免返回handles指向对象内部成分
这个条款也很容易理解,其存在两个危害:
- 因为只要让外部可以获取到内部对象的一个“句柄”,那我们就无法预估外部会做出什么样的操作,即使加入const进行修饰,他们也可能通过很多手段绕过const的限制,进行对内部成分的修改。
- 第二种危害就是生命周期获取会不同,对象内部成分的生命周期很可能比外部希望使用的时间要短,那一旦内部的对象死亡了,外部却无法获取到内部对象死亡的信息。
所以,从这两个角度出发,我们很容易理解这个条款希望说明什么。
条款29. 为“异常安全”而努力是值得的
条款30. 透彻了解inline的里里外外
条款31. 将文件间的编译依存关系降至最低
看这个条款前,首先需要区分两个名词的具体区别:“声明”、“定义”。具体的区别可以参考《C++编程思想》,书中给出了详细的介绍。当然可以查看下面的例子。
class Base {
public:
Base(); // 这个是声明
int key; // 这个是声明
void fun() { // 这个是定义,并且隐含内联
cout << "hello world\n";
}
};
Base::Base(){ // 这个是定义
cout << "base" << endl;
}
为什么需要区分这么详细呢?我们一步步来看,我们将Base的声明以及定义放在Base.h文件中。
#ifndef BASE_H
#define BASE_H
class Base {
public:
void fun();
};
void Base::fun() {
cout << "hello world";
}
#endif // BASE_H
然后是main函数的代码:
#include <iostream>
using namespace std;
#include "Base.h"
int main() {
Base b;
b.fun();
return 1;
}
这时候执行编译,可以得到如下的编译输出:
g++ -c -g -frtti -fexceptions -mthreads -Wall -DUNICODE -DQT_LARGEFILE_SUPPORT -I'../test' -I'.' -I'c:/QtSDK/Desktop/Qt/4.8.1/mingw/mkspecs/win32-g++' -o debug/main.o ../test/main.cpp
g++ -Wl,-subsystem,console -mthreads -o debug/test.exe debug/main.o
如果对编译工具链有所了解,就知道这里编译的时候使用 -I 用于指定Base.h的目录,这样在编译的时候导入Base.h文件。
这时候,将Base类中函数定义部分的内容剥离到一个不同的Base.cpp文件中:
#include "Base.h"
#include <iostream>
void Base::fun() {
std::cout << "hello world";
}
这时候重新编译,编译信息就完全不一样了。
g++ -c -g -frtti -fexceptions -mthreads -Wall -DUNICODE -DQT_LARGEFILE_SUPPORT -I'../test' -I'.' -I'c:/QtSDK/Desktop/Qt/4.8.1/mingw/mkspecs/win32-g++' -o debug/main.o ../test/main.cpp
g++ -c -g -frtti -fexceptions -mthreads -Wall -DUNICODE -DQT_LARGEFILE_SUPPORT -I'../test' -I'.' -I'c:/QtSDK/Desktop/Qt/4.8.1/mingw/mkspecs/win32-g++' -o debug/Base.o ../test/Base.cpp
g++ -Wl,-subsystem,console -mthreads -o debug/test.exe debug/main.o debug/Base.o
这时候首先会编译生成Base.o文件,最后再进行链接。
前后两者的区别就在于效率上,将定义于声明剥离,只要声明不变,每次只要重新生成对应的object文件就可以了,不会引起连锁式的反应,更详细的内容可以参考《链接、装载与库》这本书。
从个人的经验来看,这种缺失将编译效率提高了不少,只是每次都要在头文件与源文件间来回切换太烦了。
这个条款后半部分介绍的内容是关于接口类的定义问题,这个可以在看条款34的时候再返回查看了。
条款32. 确定你的public继承塑模出is-a关系
在C++中有两种设计的技巧:继承和组合。继承描述的就是is-a的关系,比如B继承于A,也就是B是A的子类,那B与A的关系就是B is-a A;组合描述的则是has-a关系,比如B中含有A的实例,则B与A的关系就是B has-a A。
如果希望更深入的理解这个条款,建议阅读《设计模式》《UML精粹》这两本书,基本上读完就知道这个条款想说什么意思了。
条款33. 避免遮掩继承而来的名称
好好查看下面的几个例子,我们在继承一个类的时候,需要多么的小心:
- 情况1、基类中的方法是非虚的,但是在派生类中覆写了这个方法
class Base {
public:
void name() { cout << "base" << endl; }
};
class Derive : public Base {
public:
void name() { cout << "derive" << endl; }
};
int main() {
Base *base = new Derive;
base->name(); // 其实我们是希望获取derive的名字
return 1;
}
我们使用基类指针指向一个派生类的对象实例,我们希望调用name方法的时候调用的其实是派生类中的name方法。但是很不幸,这时候调用的却是基类中的name方法。
所以遮掩继承而来的函数的危险就出来了,它不如我们所希望的那样工作。
class Base {
public:
int key;
Base() : key(100) {}
};
class Derive : public Base {
public:
int key;
Derive() : key(200) {}
};
int main() {
Base *base = new Derive;
cout << base->key << endl; // 其实我们是希望获取derive中的key
return 1;
}
这时候,我们或许会认为这里的key值应该是200,但是很不幸结果却是100。我们不妨看看这个派生类的大小:
int main() {
cout << sizeof(Derive) << endl; // 8
return 1;
}
可以得到派生类大小为8,基类和派生类的int各占4字节,但是最后获取的是哪个key值,则需要看指向key的指针是Base还是Derive类型的了。
这样和我们遮掩函数方法带来的恶劣影响是一样的,它们也不如我们所希望的那样工作了。
- 情况3、 在派生类中重载基类中的同名方法(这里不关心基类中同名方法是否为虚方法)
class Base {
public:
virtual void name() { cout << "base" << endl; } // 这里用不用virtual关键字都要出问题
};
class Derive : public Base {
public:
void name(int) { cout << "derive" << endl; }
};
int main() {
Derive *base = new Derive;
base->name(); // 这里编译就不通过了
return 1;
}
- 情况4、 基类中函数如果出现重载,在派生类中也要将所有重载方法覆写,否则下面的这种调用就无效了
class Base {
public:
virtual void name() { cout << "base" << endl; }
virtual void name(int) { cout << "base.int\n"; }
};
class Derive : public Base {
public:
void name(int) { cout << "derive" << endl; }
};
int main() {
Derive *base = new Derive;
base->name(); // 这里编译就不通过了
return 1;
}
如果希望这里可以改对,可以使用using关键字:
class Derive : public Base {
public:
using Base::name; // 这个关键,让Derive可以见到Base中的所有name方法
void name(int) { cout << "derive" << endl; }
};
整理一下,关于,上述的几种情况想说的就是:
- 不要在派生类中声明与基类中同名的成员变量
- 不要覆写基类中任何非虚方法
- 派生类中如果要覆写基类中的虚方法,最好将该方法的所有重载覆写
条款34. 区分接口继承和实现继承
这个条款其实就是在介绍基类中纯虚函数、虚函数和非虚函数所具备的语义,当然如果对Java有所了解,那这里的内容就更加容易理解了:
- 纯虚函数:表明派生类必须重新实现该方法,基类中不提供相应实现
- 虚函数:表明基类提供了默认实现,但是派生类中可以覆写它,进行自定义
- 非虚函数:表明基类不希望派生类对其进行覆写,原因参考条款33
条款35. 考虑virtual函数以外的其它选择
条款36.绝不重新定义继承而来的non-virtual函数
参考条款33中的例子,造成这些问题的根本原因就在于编译器会将这些看做是“早捆绑”(也可以说是静态绑定)
条款37. 绝不重新定义继承而来的缺省参数值
参考条款33中的例子,造成这些问题的根本原因就在于编译器会将这些看做是“早捆绑”(也可以说是静态绑定)
条款38. 通过复合塑模出has-a或“根据某物实现出”
条款39. 明智而审慎地使用private继承
条款40. 明智而审慎的使用多重继承