Performanced C++ 经验规则
http://www.cnblogs.com/ccdev/archive/2012/12/27/2836448.html
Performanced C++,意为“高性能C++“编程,是笔者和所在团队多年C++编程总结的经验规则,按条款方式讲述(参考了《Effective C++》的方式),希望能对初入C++的程序员提供帮助,少走弯路,站在前人的肩膀上,看得更高走的更远。我们也同样是脚踩许许多多大牛的经典著作,还有无数默默付出的程序员的辛劳,以及自己许许多多惨痛的编程体验,才有了这些“规则”。
自从更新这个系列,得到了许许多多的园友支持,也得到了许许多多园友提出的改进意见,在这里衷心地感谢大家,希望大家继续支持,大家的支持是我写作的巨大动力。也特别制作了这一篇为目录页,由于写作时间匆忙,虽然每一篇中的代码我都实际进行编译、运行来测试,但也难以保证没有任何错误。欢迎拍砖指正,共同进步。
作者:icc(Jone Zhang,张峻崇,Homepage: http://ccixx.com)
原创内容,转载请注明出处,保留追究一切责任的权利。
Performanced C++ 经验规则
前言:Performanced C++,意为“高性能C++“编程,是笔者和所在团队多年C++编程总结的经验规则,按条款方式讲述(参考了《Effective C++》的方式),希望能对初入C++的程序员提供帮助,少走弯路,站在前人的肩膀上,看得更高走的更远。我们也同样是脚踩许许多多大牛的经典著作,还有无数默默付出的程序员的辛劳,以及自己许许多多惨痛的编程体验,才有了这些“规则”。
icc(JoneZhang,张峻崇)原创内容,难免有错误,欢迎拍砖指正,共同进步。转载请注明出处,保留追究一切责任的权利。
============================================================================================
第一条:你不知道的构造函数(上)
首先来看,我们“知道”的构造函数,C++构造函数究竟做了哪些事情?
1、创建一个类的对象时,编译器为对象分配内存空间,然后调用该类的构造函数;
2、构造函数的目的,是完成对象非静态成员的初始化工作(静态成员如何初始化?记住以下要点:在类外进行、默认值为0、在程序开始时、在主函数之前、单线程方式、主线程完成),记住:C++类非静态成员是没有默认值的(可对比Java)。
3、如果构造函数有初始化列表,则先按照成员声明顺序(非初始化列表中的顺序)执行初始化列表中的内容,然后再进入构造函数体。这里又有疑问了,如果类本身没有非虚拟的基类,应显式地调用直接基类的某个构造函数,否则,将会自动其直接基类的默认构造函数(如果此时直接基类没有默认构造函数,得到编译错误);如果类本身有虚拟基类,也应显式地调用虚拟基类的某个构造函数,否则,将会自动调用虚拟基类的默认构造函数;如果成员有其它类的对象,则应显式地调用成员所属类的相应构造函数,否则对于没有在初始化列表中出现的类成员,也会自动调用其默认的构造函数。
注意上述调用顺序,编程时应按照“先祖再客最后自己”的原则进行,即,首先完成自身包含的“祖先对象”的初始化,之后,完成自身包含的成员是其它类型(客人)的初始化,最后才是自身非类类型成员的初始化工作。
再注意,上面多次提到了术语“默认构造函数”,默认构造函数是指:无参构造函数或每个参数均有默认值的构造函数。当且仅当,一个类没有声明任何构造函数时,可认为编译器会自动为该类创建一个默认构造函数(无参的,注意“可认为”,即实际情况并非如此,编译器并不一定总是会自动创建默认构造函数,除非必要,这涉及到更深的汇编层面。当然,在写代码的时候,这个“可认为”是正确的)。
这一小部分内容可能信息量过大,让我们看一段代码以加深理解。
1 #include <iostream>
2 using namespace std;
3
4 class Base
5 {
6 private:
7 int _x;
8 public:
9 Base(int x) : _x(x) { cout << "Base(x) _x=" << _x << endl; }
10 Base() {}
11 };
12
13 class DerivedA :virtual public Base
14 {
15 int _y;
16 public:
17 DerivedA(int x = 0, int y = 1) : Base(x), _y(y)
18 { cout << "DerivedA(x,y) _y=" << _y << endl; }
19 };
20
21 class DerivedB :virtual public Base
22 {
23 int _z;
24 public:
25 DerivedB(int x = 0, int z = 2) : Base(x), _z(z)
26 { cout << "DerivedB(x,z) _z=" << _z << endl; }
27 };
28
29 class Other
30 {
31 int _o;
32 public:
33 Other() : _o(3) { cout << "Other() _o=" << _o << endl; }
34 };
35
36 class DerivedFinal : public DerivedB, public DerivedA
37 {
38 int _xyz;
39 Other _other;
40 public:
41 DerivedFinal(int x = 10, int y = 20, int z = 30, int o = 50) : DerivedA(x,y), DerivedB(x,z), Base(x), _xyz(x * y * z)
42 { cout << "DerivedFinal(x,y,z,o) _xyz=" << _xyz << endl; }
43 };
44
45 int main(int argc, char** argv)
46 {
47 DerivedFinal df;
48 return 0;
49 }
输出结果(Ubuntu 12.04 + gcc 4.6.3):
Base(x) _x=10
DerivedB(x,z) _z=30
DerivedA(x,y) _y=20
Other() _o=3
DerivedFinal(x,y,z,o) _xyz=6000
和你心中的答案是否一致呢?
一切从DerivedFinal的调用顺序说起,首先,这是虚继承,故虚基类Base的构造函数将首先被调用,尽管它在DerivedFinal构造函数的初始化列表顺序中排在后面的位置(再次记住,调用顺序与初始化列表中的顺序无关),接下来是DerivedB(x,z),因为它先被继承;之后是DerivedA(x,z),再之后,DerivedFinal自身非类类型成员_xyz被初始化,最后是Other(),other成员并没有出现在DerivedFinal的初始化列表中,所以它的默认构造函数将被自动调用。另外,如果不是虚继承,调用间接基类Base的构造函数将是非法的,但此处是虚继承,必须这样做。
接下来继续讨论,上面提到,编译器不一定总是会产生默认构造函数,虽然在编写代码时,你“可以这么认为”,这听起来太玄乎了,那么,到底什么时候,编译器才会真正在你没有定义任何构造函数时,为你产生一个默认构造函数呢?有以下三种情况,编译器一定会产生默认构造函数:
(1)该类、该类的基类或该类中定义的类类型成员对象中,有虚函数存在。
发生这种情况时,由于必须要完成对象的虚表初始化工作(关于虚函数的原理,笔者建议参考陈皓的《C++虚函数表解析》),所以编译器在没有任何构造函数的时候,会产生一个默认构造函数来完成这部分工作;然而,如果已经有任何构造函数,编译器则把初始化虚表这部分工作“合成”到你已定义的构造函数之中(用心良苦)。
让我们稍稍进入汇编领域(笔者强烈建议,要精通C/C++,一定的汇编和反汇编能力是必须的,能精通更好)看一下,一个有虚函数的类,构造函数的x86反汇编代码:
class VirtualTest
{
public:
virtual void foo(int x) { cout << x << endl; }
}; int main(int argc, char** argv)
{
VirtualTest vt; 00401340 lea ecx, [ebp-4] ;获取对象首地址
00401344 call @ILT+15(VitrualTest::VirtualTest) (0048A500)
;调用构造函数,由于该类没有定义任何构造函数又包含虚函数,编译器产生了一个默认构造函数并调用 return 0;
} //下面是默认构造函数反汇编 004013D0 55 push ebp 004013D1 8B EC mov ebp,esp 004013D3 51 push ecx
;头三句,初始化函数调用过程,详见汇编知识 004013D4 89 4D FC mov dword ptr [ebp-4],ecx
;获取对象首地址,即this指针 004013D7 8B 45 FC mov eax,dword ptr [this]
;取出this指针,这个地址将会作为指针保存到虚表首地址 004013DA C7 00 60 68 40 00 mov dword ptr [eax],offset VirtualTest::`vftable' (0042201c)
;取虚表首地址,保存到虚表指针中(即对象头4字节) 004013E0 8B 45 FC mov eax,dword ptr [this]
;再次取出this指针地址,返回函数调用,即得到对象
004013E3 8B E5 mov esp,ebp 004013E5 5D pop ebp 004013E6 C3 ret
由该汇编代码还可以看出,虚表指针初始化,在构造函数初始化列表之后,进入构造函数体代码之前。
(2)该类、该类的基类中所定义的类类型成员对象中,带有构造函数。
发生这种情况时,由于需要显式地调用这些类类型成员的构造函数,编译器在没有任何构造函数的时候,也会产生一个默认构造函数来完成这个过程;同样,如果你已经定义一个构造函数但没有对这些类类型成员显式调用构造函数,编译器则把这部分工作“合成"到你定义的构造函数中(调用它们的默认构造函数,再次用心良苦)。
(3)该类拥有虚基类。
发生这种情况,需要维护“独此一份"的虚基类继承而来的对象,所以也需要通过构造函数完成。方式同(1)(2)。
除上述3种情况外,“可认为在没有任何构造函数时候,编译器产生一个默认构造函数”是不对的,因为这样的默认构造函数是“无用”的,编译器也就不会再用心良苦去做没用的工作。这部分涉及汇编较多,如果想详细了解,建议阅读钱林松所著的《C++反汇编与逆向分析技术揭秘》,机械工业出版社,2012.5。
这里只要记住结论就可以了。
终于讲述完了,进入构造函数体之前的奥秘,你是否觉得不过瘾呢?不着急,下一篇将讲述C++进入构造函数体之后,那些你不知道的内容。
第二条:你不知道的构造函数(中)
上一篇你不知道的构造函数(上)主要讲述了,C++构造函数在进入构造函数体之前,你可能不知道的一些细节。这一篇将讲述,进入构造函数体后,又发生了什么。
4、虚表初始化
上一篇曾提到,如果一个类有虚函数,那么虚表的初始化工作,无论构造函数是你定义的还是由编译器产生的,这部分工作都将由编译器隐式“合成”到构造函数中,以表示其良苦用心。上一篇还提到,这部分工作,在“刚”进入构造函数的时候,就开始了,之后,编译器才会理会,你构造函数体的第一行代码。这一点,通过反汇编,我们已经看的非常清楚。
虚表初始化的主要内容是:将虚表指针置于对象的首4字节;用该类的虚函数实际地址替换虚表中该同特征标(同名、同参数)函数的地址,以便在调用的时候实现多态,如果有新的虚函数(派生类中新声明的),则依次添加至虚表的后面位置。
5、构造函数中有虚特性(即多态、即动态绑定、晚绑定)产生吗?
这个问题,看似简单,答案却比较复杂,正确答案是:对于构造函数,构造函数中没有虚特性产生(在C++中答案是NO,但在Java中,答案是YES,非常的奇葩)。
先从基类构造函数说起,为什么要提基类构造函数呢,因为,派生类总是要调用一个基类的构造函数(无论是显式调用还是由编译器隐式地调用默认构造函数,因为这里讨论的是有虚函数的情况,所以一定会有基类构造函数产生并调用),而此时,在基类构造函数中,派生类对象根本没有创建,也就是说,基类根本不知道派生类中产生了override,即多态,故没有虚特性产生。
这一段非常让人疑惑。让我们再看一小段代码,事实胜于雄辩。
1 #include <iostream>
2 using namespace std;
3
4 class Base
5 {
6 public:
7 Base() { foo(); }
8 virtual void foo(void) { cout << "Base::foo(void)" << endl; }
9 virtual void callFoo(void) { foo(); }
10 };
11
12 class Derived : public Base
13 {
14 public:
15 Derived() { foo(); }
16 void foo(void) { cout << "Derived::foo(void)" << endl; }
17 };
18
19 int main(int argc, char** argv)
20 {
21 Base* pB = new Derived;
22 pB->callFoo();
23 if(pB)
24 delete pB;
25 return 0;
26 }
在Ubuntu 12.04 + gcc 4.6.3输出结果如下:
1 Base::foo(void)
2 Derived::foo(void)
3 Derived::foo(void)
这个结果可以很好的解释上述问题,第一行,由于在Base构造函数中,看不到Derived的存在,所以根本不会产生虚特性;而第二行,虽然输出了Derived::foo(void),但因为在派生类直接调用方法名,调用的就是本类的方法,(当然,也可认为在Derived构造函数中,执行foo()前,虚表已经OK,故产生多态,输出的是派生类的行为)。再看第三行,也产生多态,因为,此时,派生类对象已经构建完成,虚表同样也已经OK,所以产生多态是必然。
这个问题其实是C++比较诟病的陷阱问题之一,但我们只要记住结论:不要在构造函数内调用其它的虚成员函数,否则,当这个类被继承后,在构造函数内调用的这些虚成员函数就没有了虚特性(丧失多态性)。(非虚成员函数本来就没有多态性,不在此讨论范围)
解决此类问题的方法,是使用“工厂模式”,在后续篇幅中笔者会继续提到,这也是《Effective C++》中阐述的精神:尽可能以工厂方法替换公有构造函数。
另外,有兴趣的同学,可以将上述代码稍加修改成Java跑一跑,你会惊喜的发现,三个输出都是Derived::foo(void),也就是说,JVM为你提供了一种未卜先知的超自然能力。
6、构造函数中调用构造函数、析构函数
上面已经提到,不要在构造函数内调用其它成员函数,那么调用一些“特殊”的函数,情况又如何呢?我知道,有同学想到了,在构造函数中调用本类的析构函数,情况如何?如下面的代码
1 #include <iostream>
2 using namespace std;
3
4 class A
5 {
6 public:
7 ~A() { cout << hex << (int)this <<"destructed!" << endl; }
8 A() { cout << hex << (int)this << "constructed!" << endl;
9 ~A(); }
10
11 };
12
13 int main(int argc, char** argv)
14 {
15 A a;
16 return 0;
17 }
虽然我对有这种想法的同学有强拖之去精神病院的冲动,但还是本着研究精神,把上述“疯子”代码跑一遍,还特地把析构函数的定义提到构造函数之前以防构造函数不认识它。结论是:构造函数中调用析构函数,编译器拒绝接受~A()是析构函数,从而拒绝这一不讲理行为。此时编译器认为,你是在重载~操作符,并给出没有找到operator ~()声明的错误提示。其实,无论是在构造函数A()里面调用~A()不行,在成员函数里,也是不行的(编译器仍认为你要调用operator ~(),而你并没有声明这个函数)。但是,有个小诡计,却可以编译通过,就是通过this->~A()来调用析构函数,这将导致对象a被析构多次,隐藏着巨大的安全隐患。
总之,在构造函数中调用析构函数,是十分不道德的行为,应严格禁止。
好了,接下来是,构造函数中,调用构造函数,情况又如何呢?
(1)首先,如果构造函数中递归调用本构造函数,产生无限递归调用,很快就栈溢出(栈上分配)或其它crash,应严格禁止;
(2)如果构造函数中,调用另一个构造函数,情况如何?
1 #include <iostream>
2 using namespace std;
3
4 class ConAndCon
5 {
6 public:
7 int _i;
8 ConAndCon( int i ) : _i(i){}
9 ConAndCon()
10 {
11 ConAndCon(0);
12 }
13 };
14
15 int main(int argc, char** argv)
16 {
17 ConAndCon cac;
18 cout << cac._i << endl;
19 return 0;
20 }
上面代码,输出为0吗?
答案是:不一定。输出结果是不确定的。根据C++类非静态成员是没有默认值的规则,可以推定,上述代码里,在无参构造函数中调用另一个构造函数,并没有成功完成对成员的初始化工作,也就是说,这个调用,是不正确的。
那么,由ConAndCon产生的对象哪里去了?如果用gdb跟踪调试或在上述类的构造、析构函数中打印出对象信息就会发现,在构造函数中调用另一个构造函数,会产生一个匿名的临时对象,然后这个对象又被销毁,而调用它的cac对象,仍未得到本意的初始化(设置_i为0)。这也是应严格禁止的。
通常解决此问题的三个方案是:
方案一,我们称为一根筋方案,即,我仍要继续在构造函数中调用另一个构造函数,还要让它正确工作,即“一根筋”,解决思路:不要产生新分配的对象,即在第一个构造函数产生了对象的内存分配之后,仍在此内存上调用另一个构造函数,通过布局new操作符(replacement new)可以做到:
//标准库中replacement new操作符的定义:
//需要#include <new> inline void *__cdecl operator new(size_t, void *_P)
{
return (_P);
} //那么修改ConAndCon()为: ConAndCon()
{
new (this)ConAndCon(0);
}
即在第一次分配好的内存上再次分配。
某次在Ubuntu 12.04 + gcc 4.6.3运行结果如下(修改后的代码):
1 #include <iostream>
2 #include <new>
3 using namespace std;
4
5 class ConAndCon
6 {
7 public:
8 int _i;
9 ConAndCon( int i ) : _i(i){cout << hex << (int)this <<"constructed!" << endl;}
10 ConAndCon()
11 {
12 cout << hex << (int)this <<"constructed!" << endl;
13 new (this)ConAndCon(0);
14 }
15 ~ConAndCon() { cout << hex << (int)this <<"destructed!" << endl; }
16 };
17
18 int main(int argc, char** argv)
19 {
20 ConAndCon cac;
21 cout << cac._i << endl;
22 return 0;
23 }
24
25 //运行结果:
26 bfd1ae9cconstructed!
27 bfd1ae9cconstructed!
28 0
29 bfd1ae9cdestructed!
可以看到,成功在第一次分配的内存上调用了另一个构造函数,且无需手动为replacement new调用析构函数(此处不同于在申请的buffer上应用replacement new,需要手动调用对象析构函数后,再释放申请的buffer)
方案二,我们称为“AllocAndCall"方案,即构造函数只完成对象的内存分配和调用初始化方法的功能,即把在多个构造函数中都要初始化的部分“提取”出来,通常做为一个private和非虚方法(为什么不能是虚的参见上面第5点),然后在每个构造函数中调用此方法完成初始化。通常,这样的方法取名为init,initialize之类。
1 class AllocAndCall
2 {
3 private:
4 void initial(...) {...} //初始化集中这里
5 public:
6 AllocAndCall() { initial(); ...}
7 AllocAndCall(int x) { initail(); ...}
8 };
这个方案和后面要详述的“工厂模式”,在一些思想上类似。
这个方案最大的不足,是在于,initial()初始化方法不是构造函数而不能使用初始化列表,对于非静态const成员的初始化将无能为力。也就是说,如果该类包含非静态的const成员(静态的成员初始化参看上一篇中的第2点),则对这些非静态const成员的初始化,必须要在每个构造函数的初始化列表完成,无法“抽取“到初始化方法中。
方案三,我们称为“C++ 0x“方案,这是C++ 0x中的新特性,叫做“委托构造函数”,通过在构造函数的初始化列表(注意不是构造函数体内)中调用其它构造函数,来得到相应目的。感谢C++ 0x!
1 class CPerson
2 {
3 public:
4 CPerson() : CPerson(0, "") { NULL; }
5 CPerson(int nAge) : CPerson(nAge, "") { NULL; }
6 CPerson(int nAge, const string &strName)
7 {
8 stringstream ss;
9 ss << strName << "is " << nAge << "years old.";
10 m_strInfo = ss.str();
11 }
12
13 private:
14 string m_strInfo;
15 };
其实,对于这样的问题,笔者认为,最好的解决方式,没有在这几种方案中讨论,仍是——使用“工厂模式”,替换公有构造函数。
中篇到此结束,下一篇将会有更多精彩内容——in C++ Constructor!。谢谢大家!
第三条:你不知道的构造函数(下)
前面两篇,我们已经讨论了C++构造函数中诸多细枝末节,但百密一疏,还有一些地方我们没有考虑到。这一篇将对这些问题进行完结。
7、构造函数中的异常
当你在构造函数中写代码的时候,你有没有想过,如果构造函数中出现异常(别告诉我,你不抛异常。“必要”时系统会替你抛的),那会出现怎样的情况?
对象还能构建完成吗?构造函数中已经执行的代码产生的负面效应(如动态分配内存)如何解决?对象退出其作用域时,其析构函数能被调用吗?
上述这些问题,正是构造函数中产生异常要面临的问题。让我们先看结论,再分析过程:尽可能不要在构造函数中产生(抛出)异常,否则,一定会产生问题。
我们先看一段代码:
1 #include <iostream>
2 #include <exception>
3 #include <stdexcept>
4 using namespace std;
5
6
7 class ConWithException
8 {
9 public:
10 ConWithException() : _pBuf(NULL)
11 {
12 _pBuf = new int[100];
13 throw std::runtime_error("Exception in Constructor!");
14 }
15
16 ~ConWithException()
17 {
18 cout << "Destructor!" << endl;
19 if( _pBuf != NULL )
20 {
21 cout << "Delete buffer..." << endl;;
22 delete[] _pBuf;
23 _pBuf = NULL;
24 }
25 }
26
27 private:
28 int* _pBuf;
29 };
30
31 int main(int argc, char** argv)
32 {
33 ConWithException* cwe = NULL;
34 try
35 {
36 cwe = new ConWithException;
37 }
38 catch( std::runtime_error& e )
39 {
40 cout<< e.what() << endl;
41 }
42
43 delete cwe;
44
45 return 0;
46 }
47
这段代码运行结果是什么呢?
输出
1 Exception in Constructor!
输出“Exception in Constructor!"说明,我们抛出的异常已经成功被捕获,但有没有发现什么问题呢?有一个很致命的问题,那就是,对象的析构函数没有被调用!也就是说,delete cwe这一句代码没有起任何作用,相当于对delete NULL指针。再往上推,我们知道cwe值还是初始化的NULL,说明对象没有成功的构建出来,因为在构造函数中抛出了异常,终止了构造函数的正确执行,没有返回对象。即使我们把cwe = new ConWithException换成在栈中分配(ConWithException cwe;),仍是相同的结果,但cwe退出其作用域时,其析构函数也不会被调用,因为cwe根本不是一个正确的对象!继续看,在这个构造函数中,为成员指针_pBuf动态申请了内存,并计划在析构函数中释放这一块内存。然而,由于构造函数抛出异常,没有返回对象,析构函数也没有被调用,_pBuf指向的内存就发生了泄露!每调用一次这个构造函数,就泄露一块内存,产生严重的问题。现在,你知道了,为什么不能在构造函数中抛出异常,即使没有_pBuf这样需要动态申请内存的指针成员存在。
然而很多时候,异常并不是由你主动抛出的,也就是说,将上述构造函数改造成这样:
ConWithException() : _pBuf(NULL)
{
_pBuf = new int[100];
}
这是我们十分熟悉的格式吧?没错,但是,这样的写法仍然可能产生异常,因为这取决于编译器的实现。当动态内存分配失败时,编译器可能返回一个NULL指针(这也是惯用方式),OK,那没有问题。但是,有些编译器也有可能引发bad_alloc异常,如果对异常进行捕获(通常也不会这样做),结果将同上述例子所示。而如果未对异常进行捕获,结果更加糟糕,这将产生Uncaught exception,通常将导致程序终止。并且,此类问题是运行阶段可能出现的问题,这将更难发现和处理。
说了半天,就是认为上述写法,还不够好,不OK,接下来讲述解决方案。
解决方案一:使用智能指针shared_ptr(c++0x后STL提供,c++0x以前可采用boost),注意,在此处不能使用auto_ptr(因为要申请100个int,而即使申请的是单个对象,也不建议使用auto_ptr,关于智能指针,本系列后面的规则会有讲述);
解决方案二:就是前面多次提到的,采用"工厂模式"替换公有构造函数,从而尽可能使构造函数“轻量级“。
class ConWithException //为和前面比对,类名没改,糟糕的类名
{
public:
ConWithException* factory(some parameter...)
{
ConWithException* cwe = new ConWithException;
if(cwe)
{
cwe->_pBuf = new int[100];
//other initialization...
}
return cwe;
} ~ConWithException()
{
if(cwe->_pBuf)
{
delete[] cwe->_pBuf;
_pBuf = NULL;
}
//other destory process...
}
private:
ConWithException() : _pBuf(NULL) {} //如果有非静态const成员还需要在初始化列表中进行初始化,否则什么也不做
int* _pBuf;
};
使用“工厂模式”的好处是显而易见的,上述构造函数中异常的问题可以得到完美解决?why?因为构造函数十分轻量级,可轻松的完成对象的构建,“重量级”的工作都交由“工厂”(factory)方法完成,这是一个公有的普通成员函数,如果在这个函数中产生任何异常,因为对象已经正确构建,可以完美的进行异常处理,也能保证对象的析构函数被正确地调用,杜绝memory leak。构造函数被声明为私有,以保证从工厂“安全”地产生对象,使用“工厂模式”,还可以禁止从栈上分配对象(其实Java、Objective-C都是这么做的),在必要的时候,这会很有帮助。
8、构造函数不能被继承:虽然子类对象中包含了基类对象,但并不能代表构造函数被继承,即,除了在子类构造函数的初始化列表里,你可以显式地调用基类的构造函数,在子类的其它地方调用父类的构造函数都是非法的。
9、当类中有需要动态分配内存的成员指针时,需要使用“深拷贝“重写拷贝构造函数和赋值操作符,杜绝编译器“用心良苦”的产生自动生成版本,以防资源申请、释放不正确。
10、除非必要,否则最好在构造函数前添加explicit关键字,杜绝隐式使构造函数用作自动类型转换。
终于写完了,这三篇有关构造函数的“经验”之谈,其实,这些问题,也是老生常谈了。经过这三篇的学习,为敲开C++的壁垒,我们又添加了一把强有力的斧头。
第四条:静态和多态,亦敌亦友
这一篇,我们讨论C++中静态和多态的关系。我们都知道,C++并不是一门“动态”语言,虽然它提供了同样强大于其它动态语言的多态性,但很多时候,我们之所以选择C++,看重中正是其“静态”所带来的High Performance。所谓静态,通常是指,在程序运行的过程,是“静止”不变,固定的(特别是内存地址),当然“多态”就是与之对立的概念。这一篇我们并不讨论静态(成员)变量或静态(成员)函数有什么作用,而是讨论“静态”的行为,对比“多态”。我们这里所说的静态,是指:compiler time,即编译时绑定、早绑定、静态联编;而“多态”就是真正的runtime绑定、晚绑定、动态联编。
很奇妙,这一组对立的概念,却可以在C++中和平共存,时而协同工作。
老规矩,还是一小段代码提出问题,当一个虚成员函数(多态性)在其子类中被声明为静态成员函数时(或相反过来),会发生什么?
1、当虚函数遭遇静态函数
1 #include <iostream>
2 using namespace std;
3
4 class Base
5 {
6 public:
7 virtual void foo(void){ cout << "Base::foo()" << endl; }
8 };
9
10 class Derived : public Base
11 {
12 public:
13 void foo(void){ cout << "Derived::foo()" << endl; }
14 } ;
15
16 class DerivedAgain : public Derived
17 {
18 public:
19 static void foo(void){ cout << "DerivedAgain::foo()"<< endl; }
20 } ;
21
22 int main(int argc, char** argv)
23 {
24 DerivedAgain da;
25 Base* pB = &da;
26
27
28 da.foo();
29 pB->foo();
30 return 0;
31 }
上述代码运行结果是什么?等等,你确定上述代码能通过编译?在笔者Ubuntu 12.04 + gcc 4.6.3的机器上,上述代码编译不能通过。显示如下信息:
1 stawithvir.cpp:19:17: error: ‘static void DerivedAgain::foo()’ cannot be declared
2 stawithvir.cpp:13:10: error: since ‘virtual void Derived::foo()’ declared in base class
很明显,编译不能通过的原因,是在DerivedAgain类中将虚函数声明为static,编译器拒绝此“静态”与“多态”的和平共处。此时理由很简单,static成员函数,是类级共享的,不属于任何对象,也不会传入this指针,不能访问非静态成员;然而,虚函数的要求与此正相反,需要绑定对象(this指针),进而获得虚表,然后进行调用。如此矛盾的行为,编译器情何以堪,因为选择报错来表达其不满。我们可以暂时记住结论:不能将虚函数声明为静态的。
接下来你可能会问,编译都不能通过的东西,对错不是明摆着的吗?为什么还要拿来讨论,这是因为,在某些编译器上(可以在VC6,VC2008等尝试),该代码能编译通过,并输出结果,不可思议?不过这些编译器同时也给出了一个警告(参与MSDN warning c4526),指出静态函数不能用做虚函数进行调用。虽然通过了编译,但思想与上述Gcc是一致的。
1 //输出结果
2 DerivedAgain::foo()
3 Derived::foo()
da.foo()输出DerivedAgain::foo()没有疑问(通过对象调用方法,无论是否虚方法,本来就不会产生动态绑定,即无虚特性);而pB->foo()输出Derived::foo()则需要解释一下,因为pB是指针调用虚方法,产生“多态”,动态绑定时发现pB指向的对象类型为DerivedAgain,于是去查找DerivedAgain对象虚表中foo()的地址,但此时发现DerivedAgain的虚表中foo()的地址其实是Derived::foo(),因为DerivedAgain中的foo已经被声明为static,不会更新此函数在虚表中的地址(实际上,由于DerivedAgain没有声明任何新的虚函数,它对象的虚表同Derived对象是完全一样的,如果有兴趣,可以通过汇编查看),所以输出的是Derived::foo(),也从一个侧面证明了:在继承链中,使用最"新"的虚函数版本。
至此,这个问题已经解释清楚,再次记住结论:静态成员函数,不能同时也是虚函数。
2、重载(overload)并非真正的多态,其本质是静态行为
笔者曾不止一次的看到,许多书籍、资料,在谈到C++多态性的时候,经常把“重载”(overload)归入多态行为中。这种说法看似也没什么不正确,实际上我认为十分不妥。虽然重载,通过区分特征标的不同(注意,同函数名而参数不同、或同函数名但是否是const成员函数,都是重载依据),而使相同函数名的方法调用产生了不同的行为,确实体现了“多态”的思想,但重载的本质是静态绑定,是编译期就能确定调用哪个方法,而非动态绑定,所以不是真正的多态。所以,头脑要清醒,即如果两个(或多个)方法之间的关系是“重载”(overload),那么就不会有真正的多态行为产生。
3、何时产生真正的多态?
讨论重载之后,就要谈到,何时产生真正的多态行为,即动态绑定呢?笔者归纳三个必要条件如下:
(1)方法是虚的;
(2)有覆盖(override)产生;
(3)通过指针或引用调用相应的虚方法,而非通过对象调用;通过对象调用方法,无论方法是否是虚方法,均是静态联编行为。
条件(1)(2)很明显,如果方法是虚的也没有覆盖,何来“多”的“态”?而条件(3)容易被新手忽视,因为通过对象调用,对象的类型已经确知,所以静态绑定,不会再产生多态。而通过指针或引用调用相应虚方法,由于在编译期不能确定指针或引用指向的具体类型,所以只能动态联编,从而产生多态。
4、不正确的代码将阻止多态行为
好了,接下来我们看一小段代码,来自《C++ Primer Plus》:
1 class Base
2 {
3 public:
4 virtual void foo(void) {...}
5 ...
6 };
7
8 class Derived : public Base
9 {
10 public:
11 void foo(void) {...}
12 ...
13 };
14
15 //版本1
16 void show1(const Base& b)
17 {
18 b.foo();
19 }
20
21 //版本2
22 void show2(Base b)
23 {
24 b.foo();
25 }
26
27 int main(int argc, char** argv)
28 {
29 Derived d;
30 show1(d);
31 show2(d);
32 return 0;
33 }
上述代码有什么问题?我们看到,两个版本的show函数唯一不同之处,就是版本1按引用传递对象,版本2按值传递对象。在main函数中,新建了一个Derived对象并传给版本1函数,由于版本1中的参数b是引用类型,OK,没有问题,b.foo()将按照b实际指向的对象调用,即可以正确调用Derived::foo();而版本2参数b是对象类型(b是Base(const Base&)拷贝构造创建的一个Base对象,自动向上的强制类型转换使得基类拷贝构造函数可以引用一个子类对象),根据上述第3点,则b.foo()将按对象类型(Base)调用到Base::foo(),不产生多态行为。即,由于按值传递,在此处阻止了动态绑定,阻止了多态行为。
说到这里的话,又是老生常谈的问题,即除非必须要这样做,否则不要按值方式传递参数,而应选择指针或引用,关于这个问题,本系列后面还会再谈。
第五条:再谈重载、覆盖和隐藏
在C++中,无论在类作用域内还是外,两个(或多个)同名的函数,可能且仅可能是以下三种关系:重载(Overload)、覆盖(Override)和隐藏(Hide),因为同名,区分这些关系则是根据参数是否相同、是否带有const成员函数性质、是否有virtual关键字修饰以及是否在同一作用域来判断。在第四条中,我们曾提到了一些关于重载、覆盖的概念,但只是一带而过,也没有提到隐藏,这一篇我们将详细讨论。
1、首先说的是重载,有一个前提必须要弄清楚的是,如果不在类作用域内进行讨论,两个(或多个)同名函数之间的关系只可能是重载或隐藏,这里先说重载。考虑以下事实:
1 int foo(char c){...}
2 void foo(int x){...}
这两个函数之间的关系是重载(overload),即相同函数名但参数不同,并注意返回类型是否相同并不会对重载产生任何影响。
也就是说,如果仅仅是返回类型不相同,而函数名和参数都完全相同的两个函数,不能构成重载,编译器会告知"ambiguous"(二义性)等词以表达其不满:
1 //Can't be compiled!
2
3 int fooo(char c){...}
4 void fooo(char c){...}
5
6 char c = 'A';
7 fooo(c); // Which one? ambiguous
在第四条中,已经讲述过,重载是编译期绑定的静态行为,不是真正的多态性,那么,编译器是根据什么来进行静态绑定呢?又是如何确定两个(或多个)函数之间的关系是重载呢?
有以下判定依据:
(1)相同的范围:即作用域,这里指在同一个类中,或同一个名字空间,即C++的函数重载不支持跨越作用域进行(读者可再次对比Java在这问题上的神奇处理,既上次Java给我们提供了未卜先知的动态绑定能力后,Java超一流的意识和大局观再次给Java程序员提供了跨类重载的能力,如有兴趣可详细阅读《Thinking in Java》的相关章节,其实对于学好C++来讲,去学一下Java是很有帮助的,它会告诉你,同样或类似的问题,为什么Java要做这样的改进),这也是区别重载和隐藏的最重要依据。
关于“C++不能支持跨类重载”,稍后笔者会给出代码来例证这一点。
(2)函数名字相同(基本前提)
(3)函数参数不同(基本前提,否则在同一作用域内有两个或多个同名同参数的函数,将产生ambiguous,另外注意,对于成员函数,是否是const成员函数,即函数声明之后是否带有const标志, 可理解为“参数不同“),第(2)和第(3)点统称“函数特征标”不同
(4)virtual关键字可有可无不产生影响(因为第(1)点已经指出,这是在同一个类中)
即“相同的范围,特征标不同(当然同名是肯定的),发生重载“。
2、覆盖(override),真正的多态行为,通过虚函数来实现,所以,编译器根据以下依据来进行判定两个(注意只可能是两个,即使在继承链中,也只是最近两个为一组)函数之间的关系是覆盖:
(1)不同的范围:即使用域,两个函数分别位于基类和派生类中
(2)函数名字相同(基本前提)
(3)函数参数也相同(基本前提),第(2)和第(3)点统称“函数特征标”相同
(4)基类函数必须用virtual关键字修饰
即“不同的范围,特征标相同,且基类有virtual声明,发生覆盖“。
3、隐藏(Hide),即:
(1)如果派生类函数与基类函数同名,但参数不同(特征标不同),此时,无论是否有virtual关键字,基类的所有同名函数都将被隐藏,而不会重载,因为不在同一个类中;
(2)如果派生类函数与基类函数同名,且参数也相同(特征标相同),但基类函数没有用virtual关键字声明,则基类的所有同名函数都将被隐藏,而不会覆盖,因为没有声明为虚函数。
即“不同的范围,特征标不同(当然同名是肯定的),发生隐藏”,或"不同的范围,特征标相同,但基类没有virtual声明,发生隐藏“。
可见有两种产生隐藏的情况,分别对应不能满足重载和覆盖条件的情况。
另外必须要注意的是,在类外讨论时,也可能发生隐藏,如在名字空间中,如下述代码所示:
1 #include <iostream>
2 using namespace std;
3
4 void foo(void) { cout << "global foo()" << endl; }
5 int foo(int x) { cout << "global foo(int)" << endl; return x; }
6 namespace a
7 {
8 void foo(void) { cout << "a::foo()" << endl; }
9 void callFoo(void)
10 { foo();
11 // foo(10); Can't be compiled! }
12 }
13
14 int main(int argc, char** argv)
15 {
16 foo();
17 a::callFoo();
18 return 0;
19 }
输出结果:
1 global foo()
2 a::foo()
注意,名字空间a中的foo隐藏了其它作用域(这里是全局作用域)中的所有foo名称,foo(10)不能通过编译,因为全局作用域中的int foo(int)版本也已经被a::foo()隐藏了,除非使用::foo(10)显式进行调用。
这也告诉我们,无论何时,都使用完整名称修饰(作用域解析符调用函数,或指针、对象调用成员函数)是一种好的编程习惯。
好了,上面零零散散说了太多理论的东西,我们需要一段实际的代码,来验证上述所有的结论:
1 #include <iostream>
2 using namespace std;
3
4 class Other
5 {
6 void* p;
7 };
8
9 class Base
10 {
11 public:
12 int iBase;
13 Base():iBase(10){}
14 virtual void f(int x = 20){ cout << "Base::f()--" << x << endl; }
15 virtual void g(float f) { cout << "Base::g(float)--" << f << endl; }
16 void g(Other& o) { cout << "Base::g(Other&)" << endl; }
17 void g(Other& o) const { cout << "Base::g(Other&) const" << endl;}
18 };
19
20 class Derived : public Base
21 {
22 public:
23 int iDerived;
24 Derived():iDerived(100){}
25 void f(int x = 200){ cout << "Derived::f()--" << x << endl; }
26 virtual void g(int x) { cout << "Derived::g(int)--" << x << endl; }
27 };
28
29 int main(int argc, char** argv)
30 {
31 Base* pBase = NULL;
32 Derived* pDerived = NULL;
33 Base b;
34 Derived d;
35 pBase = &b;
36 pDerived = &d;
37 Base* pBD = &d;
38 const Base* pC = &d;
39 const Base* const pCCP = &d;
40 Base* const pCP = &d;
41
42 int x = 5;
43 Other o;
44 float f = 3.1415926;
45
46 b.f();
47 pBase->f();
48 d.f();
49 pDerived->f();
50 pBD->f();
51
52 b.g(x);
53 b.g(o);
54 d.g(x);
55 d.g(f);
56 // Can't be compiled!
57 // d.g(o);
58
59 pBD->g(x);
60 pBD->g(f);
61 pC->g(o);
62 pCCP->g(o);
63 pCP->g(o);
64
65 return 0;
66 }
在笔者Ubuntu 12.04 + gcc 4.6.3运行结果:
1 Base::f()--20 //b.f(),通过对象调用,无虚特性,静态绑定
2 Base::f()--20 //基类指针指向基类对象,虽然是动态绑定,但没有使用到覆盖
3 Derived::f()--200 //d.f,通过对象调用,无虚特性,静态绑定
4 Derived::f()--200 //子类指针指向子类对象,虽然是动态绑定,但没有使用到覆盖
5 Derived::f()--20 //基类指针指向子类对象,动态绑定,子类f()覆盖基类版本。但函数参数默认值,是静态联编行为,pBD的类型是基类指针,所以使用了基类的参数默认值,注意此处!
6
7 Base::g(float)--5 //通过对象调用,int被提升为float
8 Base::g(Other&) //没什么问题,基类中三个g函数之间的关系是重载
9 Derived::g(int)--5 //没什么问题
10 Derived::g(int)--3 //注意基类的g(float)已经被隐藏!所以传入的float参数调用的却是子类的g(int)方法!
11
12 Base::g(float)--5 //注意!pBD是基类指针,虽然它指向了子类对象,但基类中的所有g函数版本它是可见的!所以pBD->g(5)调用到了g(float)!虽然产生了动态联编也发生了隐藏,但子类对象的虚表中,仍可以找到g(float)的地址,即基类版本!
13 Base::g(float)--3.14159 //原理同上
14
15 //d.g(o)
16 //注意此处!再注意代码中被注释了的一行,d.g(o)不能通过编译,因为d是子类对象,在子类中,基类中定义的三个g函数版本都被隐藏了,编译时不可见!不会重载
17
18 Base::g(Other&) const //pC是指向const对象的指针,将调用const版本的g函数
19 Base::g(Other&) const //pCCP是指向const对象的const指针,也调用const版本的g函数
20 Base::g(Other&) //pCP是指向非cosnt对象的const指针,由于不指向const对象,调用非const版本的g函数
上述结果,是否和预想的是否又有些出入呢?问题主要集中于结果的第5、12、13和15行。
第5行输出结果证明:当函数参数有默认值,又发生多态行为时,函数参数默认值是静态行为,在编译时就已经确定,将使用基类版本的函数参数默认值而不是子类的。
而第12、13、15行输出结果则说明,尽管已经证明我们之前说的隐藏是正确的(因为d.g(o)不可以通过编译,确实发生了隐藏),但却可以利用基类指针指向派生类对象后,来绕开这种限制!也就是说,编译器根据参数匹配函数原型的时候,是在编译时根据指针的类型,或对象的类型来确定,指针类型是基类,那么基类中的g函数版本就是可见的;指针类型是子类,由于发生了隐藏,基类中的g函数版本就是不可见的。而到动态绑定时,基类指针指向了子类对象,在子类对象的虚函数表中,就可以找到基类中g虚函数的地址。
写到这里,不知道读者是否已经明白,这些绕来绕去的关系。在实际代码运用中,可能并不会写出含有这么多“陷阱”的测试代码,我们只要弄清楚重载、覆盖和隐藏的具体特征,并头脑清醒地知道,我现在需要的是哪一种功能(通常也不会需要隐藏),就能写出清析的代码。上面的代码其实是一个糟糕的例子,因为在这个例子中,重载、覆盖、隐藏并存,我们编写代码,就是要尽可能防止这种含混不清的情况发生。
记住一个原则:每一个方法,功能和职责尽可能单一,否则,尝试将它拆分成为多个方法。
Performanced C++ 经验规则的更多相关文章
- 用happen-before规则重新审视DCL(转)
编写Java多线程程序一直以来都是一件十分困难的事,多线程程序的bug很难测试,DCL(Double Check Lock)就是一个典型,因此对多线程安全的理论分析就显得十分重要,当然这决不是说对多线 ...
- 用happen-before规则重新审视DCL(转载)
编写Java多线程程序一直以来都是一件十分困难的事,多线程程序的bug很难测试,DCL(Double Check Lock)就是一个典型,因此对多线程安全的理论分析就显得十分重要,当然这决不是说对多线 ...
- 对一致性Hash算法,Java代码实现的深入研究
一致性Hash算法 关于一致性Hash算法,在我之前的博文中已经有多次提到了,MemCache超详细解读一文中"一致性Hash算法"部分,对于为什么要使用一致性Hash算法.一致性 ...
- To Java程序员:切勿用普通for循环遍历LinkedList
ArrayList与LinkedList的普通for循环遍历 对于大部分Java程序员朋友们来说,可能平时使用得最多的List就是ArrayList,对于ArrayList的遍历,一般用如下写法: p ...
- [Math] 常见的几种最优化方法
我们每个人都会在我们的生活或者工作中遇到各种各样的最优化问题,比如每个企业和个人都要考虑的一个问题“在一定成本下,如何使利润最大化”等.最优化方法是一种数学方法,它是研究在给定约束之下如何寻求某些因素 ...
- java经典题目
/***********Ryear.java begin********************/ import java.util.Scanner;public class Ryear { /** ...
- Testing - 测试基础 - 理解
理解 目的 测试就是要找到关键信息,有关项目和产品的关键决策都是根据这些信息做出. 对产品质量做出总体评估. 找出并报告团队所有可能会对产品价值产生消极影响的问题(但并不意味着能发现所有问题). 重心 ...
- 说说web 2.0生态圈的那些事
先来说一道面试题吧,“说一下,web 2.0 和web 1.0的区别?” 官方的解释是这样的: Web1.0 的主要特点在于用户通过浏览器获取信息,Web2.0 则更注重用户的交互作用,用户既是网站内 ...
- Redis学习笔记2-使用 Redis 作为 LRU 缓存
当 Redis 作为缓存使用时,当你添加新的数据时,有时候很方便使 Redis 自动回收老的数据.LRU 实际上是被唯一支持的数据移除方法.Redis 的 maxmemory 指令,用于限制内存使用到 ...
随机推荐
- python之selectors
selectors是select模块的包装器,ptython文档建议大部分情况使用selectors而不是直接使用selectors 样例代码如下 # -*- coding: utf-8 -*- __ ...
- 基于 UIImagePickerController 的拓展封装 - iOS
基于 UIImagePickerController 的拓展,分别支持调用相机.相册的功能,其中相机可以设置默认调用前后摄像头; 简单对此进行了封装和实现,其中还有很多点可以继续挖掘和优化,该版本具体 ...
- call、apply和bind的用法
在改变 this 指向的时候,经常会把这三个方法混淆,下面就详细的整理一下三者的用法和区别 call() 方法 call() 方法可以有无数个参数 第一个参数是改变 this 指向的对象 后面的参数直 ...
- poj 3694 Network : o(n) tarjan + O(n) lca + O(m) 维护 总复杂度 O(m*q)
/** problem: http://poj.org/problem?id=3694 问每加一条边后剩下多少桥 因为是无向图,所以使用tarjan缩点后会成一棵树并维护pre数组 在树上连一条边(a ...
- CentOS7下rsync服务的基本详解和使用
第1章 Rsync基本概述 1.1 什么是Rsync rsync是一款开源,快速,多功能的可实现增量的本地或远程的数据镜像同步备份的优秀工具.适用于多个平台.从软件名称可以看出来是远程同步的意思(re ...
- I/O流、序列化
1)流序列化对象ObjectOutputStream调用writerObject写出序列化对象,ObjectInputStream调用readObject读取序列化对象,序列化的对象必须要实现Seri ...
- 微信小程序scroll-viwe遇到的问题
1.当使用scroll-view的时候里面不可以使用某些标签 2.当使用scroll-view的时候会出现,子元素中滑动的时候会出现滚动的情况,我遇到的是因为view设置了高度和行高,一旦设置了这个, ...
- 003---Python基本数据类型--列表
列表 .caret, .dropup > .btn > .caret { border-top-color: #000 !important; } .label { border: 1px ...
- C++ vector二维数组
C++ 构建二维动态数组 int **p; p = ]; //注意,int*[10]表示一个有10个元素的指针数组 ; i < ; ++i) { p[i] = ]; } 这样就构成10*5的数组 ...
- 如何将h5网页改成微信网页
1.如何将h5网页改成微信网页 1.设置安全域名 先登录微信公众平台进入“公众号设置”的“功能设置”里填写“JS接口安全域名”. 备注:登录后可在“开发者中心”查看对 ...