《Thinking in C++, 2nd Edition》笔记-第十五章(Polymophism & Virtual Functions)

函数调用绑定

如果一个子类指针向上转型为基类指针,然后用这个基类指针调用某个函数,那么调用的将会是基类版本的函数,但是其实它是一个子类类型,理应调用子类版本的函数应该更合理。这是因为编译器在只有一个基类指针的时候并不知道调用正确的函数,它是早绑定。

将函数调用连接到函数体叫做绑定。在程序运行之前由编译器执行的绑定叫做早绑定。基于对象的类型,在运行时执行的绑定叫做晚绑定,也叫动态绑定或运行时绑定。

在C++中,在基类中使用virtual关键字来声明某个函数以引发这个函数的晚绑定。晚绑定只会在使用一个基类的地址,并且该类中有virtual函数时才会起作用。

如果一个函数在基类中被声明为virtual,那么在所有的派生类中它都是virtual的。在派生类中virtual函数的重定义通常称为覆盖(overriding)

下面的例子与之前的例子相比,只是在函数前面加了个virtual,但输出的结果是“Wind::play”——正是我们希望的结果。

#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
}

这样的程序是可扩展的,因为可以通过从公共基类继承新的类而增加新功能。操作基类接口的函数完全不需要改变就可以适合于这些新类。

C++如何实现晚绑定

关键字virtual告诉编译器不执行早捆绑,相反,它应该自动安装实现晚捆绑所必须的所有机制。

为此,编译器对每个包含虚函数的类创建一个表(称为VTABLE)。在VTABLE中,编译器放置特定类的虚函数地址。在每个带有虚函数的类中,编译器秘密地置一个指针,称为vpointer(缩写为VPTR),指向这个对象的VTABLE。通过基类指针做虚函数调用时(也就是做多态调用时),编译器静态地插入取VPTR、在VTABLE表中查找函数地址的代码,这样就能调用正确的函数,使晚捆绑发生。
为每个类设置VTABLE、初始化VPTR、为虚函数调用插入代码,所有这些都是自动发生的,编程者不必担心这些。利用虚函数,哪怕在编译器还不知道这个对象的类型的情况下,这个对象的合适的函数都能被调用。

通过下面的例子,我们可以看出,带虚函数的类的大小比不带虚函数的类要大一个VOID指针的长度:编译器在类对象中插入的一个指针(VPTR),而且类中有一个还是两个虚函数并不影响类的大小,因为VPTR指向一个存放地址的表,只需要一个指针,所有的虚函数地址是存在这个表中的。

// 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;
}


虚函数表

#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.adjust(1); }
// Upcasting during array initialization:
Instrument* A[] = {
	new Wind,
	new Percussion,
	new Stringed,
	new Brass,
};

如上图所示,指针数组A[]没有特殊类型信息,它的每一个元素指向一个类型为instrument 的对象。wind 、percussion、string和brass都是从instrument派生来的(并且和instrument有相同的接口和响应相同的消息),因此,它们的地址也自然能放进这个数组里。编译器只知道它们是instrument对象,通常调用所有函数的基类版本。但所有这些函数都被用virtual声明,所以出现了不同的情况。每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就为这个类创建一个VTABLE,如上图的右面所示。在这个表中,编译器放置了这个类中或在它的基类中所有已声明为virtual的函数的地址。如果在这个派生类中没有对在基类中声明为virtual的函数进行重新定义,编译器就使用基类的这个虚函数地址。(在brass的VTABLE中,adjust的入口就是这种情况。)然后编译器在这个类中放置VPTR。当使用简单继承时,对于每个对象只有一个VPTR。VPTR必须被初始化为指向相应的VTABLE(在构造函数中)。一旦VPTR被初始化为指向对应的VTABLE,对象就“知道”它自己是什么类型,但只有当虚函数被调用时这种信息才有用。

通过instrument指针对brass调用adjust( )。instrument引用产生如下结果:

instrument指针指向这个对象的起始地址,所有的instrument对象或由instrument派生的对象都有自己的VPTR,它位于对象的固定相对位置(常常在对象的开头),所以编译器能够取出这个对象的VPTR。而VPTR指向VTABLE的开始地址,所有类型的对象的VTABLE有相同的顺序:play( )是第一个, what ( )是第二个,adjust ( )是第三个。所以编译器知道adjust( )函数必在VPTR + 2处。这样,不是“以instrument : : adjust地址调用这个函数”(这是早捆绑,是错误的),而是产生代码:“调用在VPTR + 2处的这个函数”。因为在运行时才对读取VPTR、确定实际函数地址,所以就实现了的晚绑定。于是,向这个对象发送消息,这个对象能断定它应当做什么。

纯虚函数

如果有一个抽象类的对象几乎总是没有意义的,也就是说,含义只表示接口,不表示特例实现。C++提供了一种机制,称为纯虚函数。下面是它的声明语法:
virtual void x() = 0;
这样做,等于告诉编译器在VTABLE中为函数保留一个间隔,但在这个特定间隔中不放地址。只要有一个函数在类中被声明为纯虚函数,则VTABLE就是不完全的。包含有纯虚函数的类称为纯抽象基类。

如果一个类的VTABLE是不完全的,当某人试图创建这个类的对象时,编译器做什么呢?由于它不能安全地创建一个纯抽象类的对象,所以如果我们试图制造一个纯抽象类的对象,编译器就发出一个出错信息。这样,编译器就保证了抽象类的纯洁性,我们就不用担心误用它了。

注意,纯虚函数可以防止对纯抽象类的函数以传值方式调用。这样,它也是防止对象意外使用值向上映射的一种方法。这样就能保证在向上映射期间总是使用指针或引用。

定义纯虚函数

纯虚函数防止产生VTABLE,但这并不意味着我们不希望对它产生函数体。在基类中,对纯虚函数提供定义是可能的。我们仍然告诉编译器不要允许纯抽象基类的对象,而且纯虚函数在派生类中必须定义,以便于创建对象。然而,我们可能希望一块代码对于一些派生类定义能共同使用,不希望在每个函数中重复这段代码,如下所示:

// Pure virtual base definitions
#include <iostream>
using namespace std;
class Pet {
public:
	virtual void speak() const = 0;
	virtual void eat() const = 0;
	// 内联的纯虚函数定义是非法的:
	//! virtual void sleep() const = 0 {}
};
// 非内联的定义  
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();
}

对象截断

如果在多态时用对象传值而不是用传引用或指针,对象被向上转型,对象将被截断。

#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 p) { // Slices the object
	cout << p.description() << endl;
}
int main() {
	Pet p("Alfred");
	Dog d("Fluffy", "sleep");
	describe(p);
	describe(d);
}


构造函数与虚函数

当创建一个含有虚函数的对象时,必须初始化它的VPTR以指向相应的VTABLE。编译器在构造函数的开头部分秘密地插入能初始化VPTR的代码。即使没有对一个类创建构造函数,编译器也会创建一个带有相应VPTR初始化代码的构造函数(如果有虚函数)。因此,如果把构造函数定义为内联来节省效率时,需要考虑隐藏的初始化VPTR代码对效率的影响。

对于在构造函数中调用一个虚函数的情况,被调用的只是这个函数的本地版本。也就是说,虚机制在构造函数中不工作。在构造函数中,对象可能只是部分形成,它也不知道谁会继承自它,如果调用了它的继承类的方法(而它还没初始化),显然会出现错误。另一方面,从基类到派生类的构造顺序中,每个构造只设置VPTR指向自己的VPTR(而不管它的派生类),如果函数调用使用虚机制,它将只产生通过它自己的VTABLE的调用,而不是最后的VTABLE(所有构造函数被调用后才会有最后的VTABLE)。

析构函数与虚函数

如果用一个基类指针指向一个派生类,然后删除这个指针,那么只会调用基类的析构函数,这与多态的初衷显然不符,解决办法是将基类的析构函数定义为虚函数,这样在删除基类指针时,会先调用派生类的析构函数,然后调用基类的析构函数。

标准C++中纯虚析构函数是合法的,但必须提供函数体,因此在类层次中所有的析构函数都会被调用。如下例所示:

#include <iostream>
using namespace std;
class Pet {
public:
	virtual ~Pet() = 0;
};
Pet::~Pet() {
	cout << "~Pet()" << endl;
}
class Dog : public Pet {
public:
	~Dog() {
		cout << "~Dog()" << endl;
	}
};
int main() {
	Pet* p = new Dog; // Upcast
	delete p; // Virtual destructor call
}

类中有虚函数的时候我们就应当直接增加虚析构函数(即便它什么事也不做),这样能保证以后不发生意外。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值