多态性(polymorphis)提供了接口与具体实现之间的另一层隔离,从而将”what”与”how”分离开来。多态性改善了代码的组织性和可读性,同时也使创建的程序具有可扩展性。
15.1 C++程序员的演变
c程序员可以用三步演变为C++程序员
- 简单地把C++作为一个更好的C:更强的类型检查等
- 进入基于对象的C++:将数据结构和操作绑定,更清晰地组织代码
- 使用虚函数:这是面向对象设计的精髓
15.2 向上类型转换
让我们回到第14章的问题,我传递了一个Wind类的对象引用到tune()函数里,但得到的确实调用了基类Instrument的play()方法,这显然不是我想要的。
//: C15:Instrument2.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Inheritance & upcasting
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Eflat }; // Etc.
class Instrument {
public:
void play(note) const {
cout << "Instrument::play" << endl;
}
};
// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {
public:
// Redefine interface function:
void play(note) const {
cout << "Wind::play" << endl;
}
};
void tune(Instrument& i) {
// ...
i.play(middleC);
}
int main() {
Wind flute;
tune(flute); // Upcasting
} ///:~
15.3 问题
上述代码最终运行的合理性我们在上一章中已经解释过了,但这却并不符合我们的预期。因为我们知道这个对象实际上是Wind而不是Instrument,我们是希望函数tune()最终调用派生类的版本的。
15.3.1 函数调用捆绑
把函数体与函数调用相联系称为捆绑(binding)。当捆绑在程序运行之前由编译器和连接器完成时,这称为早捆绑(early binding)。c语言中只有一种函数调用方式,那就是早捆绑。
上面函数中的问题就是由早捆绑引起的,因为编译器在只有Instrument地址时并不知道要调用的正确函数。
解决方法是晚捆绑,这意味着捆绑根据对象的类型,发生在运行时,所以又称为动态捆绑。因此在编译时,需要有一种机制来通知编译器对某个函数进行晚捆绑。
15.4 虚函数
为了引起晚捆绑,C++要求在基类中声明这个函数时使用virtual关键字,晚捆绑只对virtual函数起作用,而且只在使用含有virtual函数的基类的地址时发生。仅仅在声明函数时使用关键字virtual,定义时并不需要,如果一个函数在基类中被声明为virtual,那么在所有的派生类中它都是virtual的。现在我们可以重新写代码了,解决上面的问题。
//: C15:Instrument3.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Late binding with the virtual keyword
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
class Instrument {
public:
virtual void play(note) const {
cout << "Instrument::play" << endl;
}
};
// Wind objects are Instruments
// because they have the same interface:
class Wind : public Instrument {
public:
// Override interface function:
void play(note) const {
cout << "Wind::play" << endl;
}
};
void tune(Instrument& i) {
// ...
i.play(middleC);
}
int main() {
Wind flute;
tune(flute); // Upcasting
} ///:~
这次结果不同了,tune()函数调用了派生类Wind的play()版本
15.4.1 扩展性
通过将play()在基类中定义为virtual,不用改变tune()函数就可以在系统中随意增加新函数。只与基类接口通信,这是一种良好的OOP设计风格。这样的程序是可扩展的,因为可以通过从公共基类继承新数据类型而增加新功能。
//: C15:Instrument4.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Extensibility in OOP
#include <iostream>
using namespace std;
enum note { middleC, Csharp, Cflat }; // Etc.
class Instrument {
public:
virtual void play(note) const {
cout << "Instrument::play" << endl;
}
virtual char* what() const {
return "Instrument";
}
// Assume this will modify the object:
virtual void adjust(int) {}
};
class Wind : public Instrument {
public:
void play(note) const {
cout << "Wind::play" << endl;
}
char* what() const { return "Wind"; }
void adjust(int) {}
};
class Percussion : public Instrument {
public:
void play(note) const {
cout << "Percussion::play" << endl;
}
char* what() const { return "Percussion"; }
void adjust(int) {}
};
class Stringed : public Instrument {
public:
void play(note) const {
cout << "Stringed::play" << endl;
}
char* what() const { return "Stringed"; }
void adjust(int) {}
};
class Brass : public Wind {
public:
void play(note) const {
cout << "Brass::play" << endl;
}
char* what() const { return "Brass"; }
};
class Woodwind : public Wind {
public:
void play(note) const {
cout << "Woodwind::play" << endl;
}
char* what() const { return "Woodwind"; }
};
// Identical function from before:
void tune(Instrument& i) {
// ...
i.play(middleC);
}
// New function:
void f(Instrument& i) {
i.play(CSharp);
i.adjust(1);
}
// Upcasting during array initialization:
Instrument* A[] = {
new Wind,
new Percussion,
new Stringed,
new Brass,
};
int main() {
Wind flute;
Percussion drum;
Stringed violin;
Brass flugelhorn;
Woodwind recorder;
tune(flute);
tune(drum);
tune(violin);
tune(flugelhorn);
tune(recorder);
f(flugelhorn);
} ///:~
Brass类继承了Wind类,它仍然有一个void play(note)的重写版本,而且它也仍然是虚函数,它也仍然是一个Instrument类。因此在调用tune(flugelhorn);
时会调用Brass类中的play()版本。
同时也注意到,Brass类中并没有定义adjust()方法,却还是能正确地调用。这种情况,编译器会自动地调用继承层次中“最近”的定义——编译器保证对于虚函数总是有某种定义,所以绝不会出现最终调用不与函数体捆绑的情况。也就是说,Brass类的虚函数表中的adjust()方式是其上一个继承层次,也就是Wind类中的版本。(马上将谈到虚函数表)
15.5 C++如何实现晚捆绑
当通过创建虚函数(即使用关键字virtual)来告诉编译器要实现晚捆绑,编译器安装必要的晚捆绑机制。典型的编译器对每个包含虚函数的类创建一个表,称为VTABLE,俗称虚函数表。在VTABLE中,编译器放置特定的虚函数的地址。在每个带有虚函数的类中,编译器秘密地放置一个指针,称为vpointer,指向这个对象的VTABLE。当通过基类指针做虚函数调用时,编译器静态地插入能取得这个VPTR并在VTABLE表中查找函数地址的代码,这样就能调用正确的函数并引起晚捆绑。
15.5.5.1 存放类型信息
可以看到,在任何类中不存在显式的类型信息。那么c++如何通过虚函数机制来存放类型信息的呢?看下面
//: C15:Sizes.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Object sizes with/without virtual functions
#include <iostream>
using namespace std;
class NoVirtual {
int a;
public:
void x() const {}
int i() const { return 1; }
};
class OneVirtual {
int a;
public:
virtual void x() const {}
int i() const { return 1; }
};
class TwoVirtuals {
int a;
public:
virtual void x() const {}
virtual int i() const { return 1; }
};
int main() {
cout << "int: " << sizeof(int) << endl;
cout << "NoVirtual: "
<< sizeof(NoVirtual) << endl;
cout << "void* : " << sizeof(void*) << endl;
cout << "OneVirtual: "
<< sizeof(OneVirtual) << endl;
cout << "TwoVirtuals: "
<< sizeof(TwoVirtuals) << endl;
} ///:~
NoVirutal类的大小就是一个int类型的大小,OneVirtual类型比它多出一个void指针的长度,而TwoVirtual跟OneVirtual一样。这说明:如果有一个或多个虚函数,编译器都只在这个结构里插入一个单个指针。
如果一个类是空的,对它求大小会怎么样?大小会是0吗?使用上面这个NoVirtual的类,把int a去掉,再用sizeof()去求大小,会得到结果为1。如果没有数据成员,C++编译器会强制这个对象是非零长度,因为每个对象必须有一个相互区别的地址,这就是原因。否则当你寻址到某个地址时,发现这里存在两个长度为0的类的对象,那么编译器该执行哪个类的操作呢?
15.5.2 虚函数功能图示
每当创建一个包含有虚函数的类或包含从虚函数的类派生一个类时,编译器就为这个类创建一个唯一的VTABLE。在这个表中,编译器放置了在这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用这个基类的虚函数地址。然后VPTR被放置在这个类中,并且它指向这个对象的VTABLE的起始地址。
以Brass类的对象调用并不存在与该类中的方法adjust()为例,Instrument指向这个对象的起始地址,对于所有的Instrument对象或由Instrument派生的对象,它们的VPTR都在对象的相同位置(通常是在对象的开头),所以编译器能够取出这个VPTR。VPTR指向的VTABLE的起始地址,而所有的VTABLE具有相同的顺序,不管何种类型的对象。只有如此,VPTR才能知道应该去调用哪个函数。下面将会看到,VPTR是在运行时去确定实际函数的地址的。
15.5.3 撩开面纱
Bruce Eckel大师给了一段汇编代码,i.adjust(1);
的汇编如下:
push 1
push si
mov bx, word ptr[si]
call word ptr[bx+4]
add sp, 4
函数调用时,首先将参数从右向左进栈,因此先将1压栈;对于c++,有一个this指针,它指向了这个对象的首地址,必须要使用它才能知道该调用什么函数,因此它也将被压栈(当然类的static成员函数不需要this指针)。对于大多数编译器,VPTR就在this指针的位置,因此mov bx, word ptr[si]
就把VPTR加载到了bx寄存器里,在Bruce大师的内存模式下,每个指针占用两个字节长度,而adjust又在第2个位置(下标从0开始算),因此call word ptr[bx+4]
就调用了adjust()方法。
15.5.4 安装vpointer
总之,VPTR和VTABLE精确地保证了多态调用的逻辑正确性。同时也要看到,在初始化VPTR之前,绝对不能调用虚函数。在Instrument的例子中,编译器创建了一个默认构造函数,它做了初始化VPTR的工作。
15.5.5 对象是不同的
向上类型转换仅处理地址
向上类型转换仅处理地址
向上类型转换仅处理地址
重要的事情说3遍。正是因为地址不具有类型信息,所以才能引起基类指针的多态调用。而如果编译器有一个它知道确切类型的对象,那么任何函数都不再使用晚捆绑。
//: C15:Early.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Early binding & virtual functions
#include <iostream>
#include <string>
using namespace std;
class Pet {
public:
virtual string speak() const { return ""; }
};
class Dog : public Pet {
public:
string speak() const { return "Bark!"; }
};
int main() {
Dog ralph;
Pet* p1 = &ralph;
Pet& p2 = ralph;
Pet p3;
// Late binding for both:
cout << "p1->speak() = " << p1->speak() <<endl;
cout << "p2.speak() = " << p2.speak() << endl;
// Early binding (probably):
cout << "p3.speak() = " << p3.speak() << endl;
} ///:~
p1->speak()和p2.speak()使用地址来操作对象,就意味着信息不完整,p1和p2可能表示Pet的地址,也可能表示其派生类的对象的地址,所以必须用虚函数调用。然而p3是一个确切的Pet类型,编译器就可以做早捆绑,直接调用基类的方法。
15.6 为什么需要虚函数
虚函数提供了如此方便的特性,以帮助我们组织代码,那为什么不直接默认使用呢?原因是显而易见的:虚函数调用并不是对于绝对地址的一个简单的CALL,而是需要两条以上的复杂的汇编指令。这既需要代码空间,又需要执行时间。简而言之:效率。但是这确实是一个好的特性,当确定程序的运行效率瓶颈不在虚函数时,便可以放心地使用这个特性。
15.7 抽象基类和纯虚函数
在设计时,常常希望基类仅仅作为其派生类的一个借口。仅想对基类进行向上类型转换,使用它的接口,而不希望用户实际地创建一个基类的对象。这可以使用纯虚函数,来使基类成为抽象类。在声明函数前加上virtual,后面加上=0。不能生成一个抽象类对象!!!当继承一个抽象类时,必须实现所有的纯虚函数,否则继承出的类也将是一个抽象类。建立公共接口的惟一原因是他能对每个不同子类有不同的表示,将所有具体实现类的操作抽象出来,减少耦合性。
声明纯虚函数的语法virtual void f()=0;
这样做等于告诉编译器在VTABLE中为函数保留一个位置,但在这个特定的位置中不放地址。这就是不能创建创建一个抽象类对象的原因:因为VTABLE是不完整的,编译器不知道函数在什么位置,因此它不允许这么做。
注意,纯虚函数禁止对抽象类的函数以传值方式调用。这是防止对象切片的一种方法。通过抽象类,可以保证在向上类型转换期间总是使用指针或引用。
15.7.1 纯虚定义
在基类中,对纯虚函数的提供定义是可能的。
//: C15:PureVirtualDefinitions.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Pure virtual base definitions
#include <iostream>
using namespace std;
class Pet {
public:
virtual void speak() const = 0;
virtual void eat() const = 0;
// Inline pure virtual definitions illegal:
//! virtual void sleep() const = 0 {}
};
// OK, not defined inline
void Pet::eat() const {
cout << "Pet::eat()" << endl;
}
void Pet::speak() const {
cout << "Pet::speak()" << endl;
}
class Dog : public Pet {
public:
// Use the common Pet code:
void speak() const { Pet::speak(); }
void eat() const { Pet::eat(); }
};
int main() {
Dog simba; // Richard's dog
simba.speak();
simba.eat();
} ///:~
纯虚函数不能定义为内联的,也就是说不能写在类中。在类中只能给出声明,定义需要类外部进行。
Pet的VTABLE仍然是空的,但是其派生类实现了这两个接口。
15.8 继承和VTABLE
当实现继承和重定义一些虚函数时,编译器对新类创建一个新VTABLE,并且插入新函数的地址,对于没有重新定义的函数使用基类函数的地址。无论如何,对于可被创建的对象,在VTABLE中总有一个函数地址的全集。
但是在派生类中继承或增加新的虚函数时会发生什么?
//: C15:AddingVirtuals.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Adding virtuals in derivation
#include <iostream>
#include <string>
using namespace std;
class Pet {
string pname;
public:
Pet(const string& petName) : pname(petName) {}
virtual string name() const { return pname; }
virtual string speak() const { return ""; }
};
class Dog : public Pet {
string name;
public:
Dog(const string& petName) : Pet(petName) {}
// New virtual function in the Dog class:
virtual string sit() const {
return Pet::name() + " sits";
}
string speak() const { // Override
return Pet::name() + " says 'Bark!'";
}
};
int main() {
Pet* p[] = {new Pet("generic"),new Dog("bob")};
cout << "p[0]->speak() = "
<< p[0]->speak() << endl;
cout << "p[1]->speak() = "
<< p[1]->speak() << endl;
//! cout << "p[1]->sit() = "
//! << p[1]->sit() << endl; // Illegal
} ///:~
新类Dog中有一个新的虚函数virtual string sit() const;
但是在main函数中,却将Dog类对象的指针放到Pet类的指针数组中去,这在以前看来是准备要进行多态调用。但是编译器却不会让p[1]->sit()编译通过,因为作为Pet类指针的p[1]只知道Pet类中定义的虚函数,以及Dog类中重写的基类中的虚函数,但并不知道Dog类中新定义的虚函数。这里我们知道p[1] 是由Dog*向上类型转换来的,因此也有足够的理由将其转换会去,这样写((Dog*)p[1])->sit();
是完全可以的,但大部分情况是不知道的。
运行时类型辨认有助于我们解决这个问题,英文术语叫RTTI。RTTI是有关向下类型转换基类指针到派生类的问题,直觉上这是不安全的,因为编译器没法凭空知道该基类指针是哪种派生类的指针。RTTI的问题放到后面讨论。
15.8.1 对象切片
当多态地处理对象时,传地址与传值有明显的不同。基类地址的大小和派生类地址的大小,在同一台计算机里肯定是相同的,但是传递对象就不同了。看下面的例子:
//: C15:ObjectSlicing.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
#include <iostream>
#include <string>
using namespace std;
class Pet {
string pname;
public:
Pet(const string& name) : pname(name) {}
virtual string name() const { return pname; }
virtual string description() const {
return "This is " + pname;
}
};
class Dog : public Pet {
string favoriteActivity;
public:
Dog(const string& name, const string& activity)
: Pet(name), favoriteActivity(activity) {}
string description() const {
return Pet::name() + " likes to " +
favoriteActivity;
}
};
void describe(Pet x) { // Slices the object
cout << x.description() << endl;
}
int main() {
Pet p("Alfred");
Dog d("Fluffy", "sleep");
describe(p);
describe(d);
} ///:~
describe(Pet x);
接受一个Pet对象作为参数,但我们并没有明确地为Pet类创建复制构造函数,因此这是编译器自动创建的。调用describe(p);
时,因为p也是Pet类对象,因此,调用复制构造函数,在函数中的生存期内,临时创建了一个Pet对象x,由x调用了它的方法description(),当退出函数时,x的析构函数被调用,x对象被销毁。整个过程中p都没有被操作(仅仅被复制了)。
但是下面一句describe(b);
,传了一个更大的对象进来,它里面也包含Pet的部分,但是还有其他成员。这种情况下,编译器会把不属于Pet的部分丢弃掉,只拷贝这个对象的Pet部分,切除这个对象的派生部分,当然也会调用Pet的复制构造函数。
因此两次调用都使用了基类的description()版本,对象在被切片了!变成了了一个Pet对象。
这种隐藏的向上类型转换动作要小心一个问题:基类的对象不能有纯虚函数,否则编译器不会允许我们这么做的,因为无法为一个抽象类创建对象。
15.9 重载和重新定义
这又涉及到名字隐藏的问题。看下面的代码:
//: C15:NameHiding2.cpp
// From Thinking in C++, 2nd Edition
// Available at http://www.BruceEckel.com
// (c) Bruce Eckel 2000
// Copyright notice in Copyright.txt
// Virtual functions restrict overloading
#include <iostream>
#include <string>
using namespace std;
class Base {
public:
virtual int f() const {
cout << "Base::f()\n";
return 1;
}
virtual void f(string) const {}
virtual void g() const {}
};
class Derived1 : public Base {
public:
void g() const {}
};
class Derived2 : public Base {
public:
// Overriding a virtual function:
int f() const {
cout << "Derived2::f()\n";
return 2;
}
};
class Derived3 : public Base {
public:
// Cannot change return type:
//! void f() const{ cout << "Derived3::f()\n";}
};
class Derived4 : public Base {
public:
// Change argument list:
int f(int) const {
cout << "Derived4::f()\n";
return 4;
}
};
int main() {
string s("hello");
Derived1 d1;
int x = d1.f();
d1.f(s);
Derived2 d2;
x = d2.f();
//! d2.f(s); // string version hidden
Derived4 d4;
x = d4.f(1);
//! x = d4.f(); // f() version hidden
//! d4.f(s); // string version hidden
Base& br = d4; // Upcast
//! br.f(1); // Derived version unavailable
br.f(); // Base version available
br.f(s); // Base version abailable
} ///:~
- 在Derived3中,编译器不允许我们改变重新定义过的函数的返回值(如果f()不是虚函数,则是允许的)。
- 如果重新定义了一个重载成员函数(这里说的是修改参数列表),则在派生类中的其他重载函数将会被隐藏。但是如果把d4向上类型转换为Base,则只有基类版本是可行的,派生类版本不可行。
(待续)