C++ 虚函数和多态性

虚函数和多态性

C++ 虚函数和多态性_第1张图片

多态性的概念

C++提供了完整的解决方案来处理这种需要更大的编码灵活性的场合,这就是多态性(polymorphism)
多态性指的是一个接口,多种实现
赋予了程序员控制复杂程序的灵活性
在C++中,多态有静态动态两种之分,且都是通过函数重载实现的。
1.静态多态性
静态多态性的实现是在编译时完成的。普通的函数重载(包括运算符函数重载)就是典型的例子。

void f();
void f(int x);
void f(double x);
void f(int x, int y);

这些声明充分体现了多态的一个原则:一个接口(名字),多个实现
在继承树上,静态多态性也能得到体现。考虑前一章我们设计的各种形体类,如果有对象定义:

tiger t;
t.what();

虽然tiger继承了其基类felid的what()成员(也就是说,它内部有两个原型完全一样的成员),但是在上述语境中,编译器在处理成员函数调用t.what()时,将会直接在tiger的作用域中查找名字what,并且成功找到,那么就一定会调用tiger类自己的成员函数,而不是调用基类的同名函数。
编译时就确定下来多态的称为“静态多态性(早期匹配)”

2.动态多态性
静态多态性能够在一定程度上给程序设计带来灵活性,但它只覆盖了编译阶段的多态情况,而对更灵活的要求,例如运行时多态,却无能为力。
在C++中,动态多态性是依赖虚函数(virtual function)来实现的

实现多态性的基石——虚函数

虚函数的概念和特性

一个指向基类的指针(或引用)可用来指向从基类派生的任何对象
如果有多个或者多层派生类,通过一个基类指针(或引用)可以访问所有派生类对象的成员函数,这样就可以实现一个接口,多个实现的访问了
设计的Quadrangle类及其派生类,如果要使类似于quad->area()这样的成员函数调用能取代基类的同名函数
方法:使派生类的area()覆盖(override)基类的同名函数

1. 虚函数的概念
要使派生类的成员函数覆盖基类的同名成员,那么必须将基类的成员函数说明成是虚函数,其语法如下:

class 类名
{
public:
	virtual 成员函数名(参数列表);
};

关键字virtual明确地告诉编译器:该类派生类中的同名成员函数将覆盖基类已定义的函数

一个声明了虚函数,或者其祖先类中包含了虚函数的类称为“多态类(polymorphic class)”。
一旦基类的某个成员函数被说明称虚的,那么我们称该函数具有“虚特性”。虚特性有这样特点:
① 虚特性必须赋予给类的成员函数
② 虚函数不能是全局函数,也不能是类的静态成员函数
不能将友元说明为虚函数,但虚函数可以是另一个类的友元;
④ 虚特性能够被继承。如果派生类原型一致地重载了基类的某个虚函数,那么即使在派生类中没有将这个函数显式说明成是虚的,它也会被编译器认为是虚函数。
在继承树上,每一个设计良好(well-formed)的多态类都有一个最终覆盖函数(final overrider)。
如果没有,那么该派生类将从其祖先那里继承过来。然而,这并不是一种好的设计,而是病态的(ill-formed)。
这里需要澄清一个事实。虽然说派生类中的成员覆盖了基类中的同名虚成员,但这并不意味着后代成员的代码取代了祖先的代码。事实上,在后代中,祖先的虚成员仍然是存在的、可访问的,使用名字限定可以完成对祖先同名成员的访问。例如:

std::string tiger::what() const 
{ 
	return felid::what() + "::" + "tiger";
}

但这种访问只能在派生类中进行。

2. 虚函数的使用:里氏替换原则

if (typeid(*fs[i]) == typeid(tiger))
{
    tiger *p = reinterpret_cast<tiger *>(fs[i]);
    std::cout << p->what() << std::endl;
}
else if (typeid(*fs[i]) == typeid(cat))
{
    cat *p = reinterpret_cast<cat *>(fs[i]);
    std::cout << p->what() << std::endl;
}

这段代码能正确工作,但却程序代码试图通过基类的设施(指针)去了解派生类,它违背了OOD设计原则中的“里氏替换原则(LSP,Liskov Substitution Principle)”。LSP原则可以简述为:在使用基类指针或引用的场合,派生类对象可以完全替换基类对象,并且程序实体并不能察觉这种替换

如果要在代码中使用f(),那么应该做到:
① 在其派生类中提供f()的覆盖版本
② 定义祖先类X的指针或引用pr;
③ 定义派生类对象o,并使pr指向它;
④ 通过类似于pr->f()或pr.f()的方式访问虚函数,而不是通过o.f()的方式去调用。

class felid
{
public:
    virtual std::string what() const { return "felid"; }
};

3. 虚特性的继承
虚特性是可以被继承的。在继承树上,一旦基类中的某个函数被声明成是虚的,那么其所有后代中原型相同的函数都将是虚的。
如果派生类中重载了一个原型不同的函数,那么这个函数将会怎样来影响虚特性的遗传呢?

#include
#include

class felid
{
public:
	virtual std::string what() const { return "felid"; }
};

class tiger :public felid
{
public:
	std::string what(int) const { return "tiger"; }
};

class bengalTiger :public tiger
{
public:
	std::string what() const{return "bengal tiger";}
};

int main()
{
	tiger t;

	felid& rt = t;
	std::cout << rt.what() << std::endl;
	//std::cout << rt.what(0) << std::endl;//error

	bengalTiger tb;
	felid& rtb = tb;
	std::cout << rtb.what() << std::endl;

	return 0;
}

在这里插入图片描述

可以看到,tiger类中的what(int)由于与基类中的同名函数原型不同,因此失去了虚特性,而且我们不能用rt.what(0)的方式来调用,这会引起一个编译时错误;而在bengalTiger类中,原型一致地重载了felid类的虚函数what(),所以虚特性仍然得以保持。

4.虚析构函数
类的析构函数可以被说明成是虚函数,并且也应该是虚的。而类的构造函数不能为虚函数。(思考:why?http://blog.sina.com.cn/s/blog_620882f401016ri2.html)

#include
#include

class felid
{
public:
	felid() { std::cout << "felid" << std::endl; }
	~felid(){ std::cout << "~felid" << std::endl;}
};

class tiger :public felid
{
public:
	tiger() { std::cout << "tiger" << std::endl; }
	~tiger() { std::cout << "~tiger" << std::endl; }
};

int main()
{
	felid* p = new tiger;
	delete p;

	return 0;
}

在这里插入图片描述
解决问题的方法有两个:
• 通过强制类型转换将指针p转换为派生类指针,具体做法如下:
delete (tiger *)(ptr);
• 将felid类的析构函数说明成是虚的:
virtual ~felid() { … }
这里我们不能使用dynamic_cast运算符进行down-casting,因为例中定义的felid不是多态类,否则将会引起一个编译时错误。
注意这个事实:析构函数是不能被继承的,但其虚特性却可以遗传。

虚函数的实现机制

动态多态可以发生要感谢C++编译器。所有的工作都由编译器在幕后完成。当我们告诉创建了一个多态类,编译器就会为该类安装必要的动态多态机制。那么这种机制的核心是什么呢?
编译器首先要对每个包含虚函数的类创建一个虚表(VTABLE)。在VTABLE中,编译器放置该类的虚函数地址。在每个带有虚函数的类中,编译器秘密地设置一个指针(就是多出的那部分数据),称为“虚指针(vpointer/VPTR)”,指向这个对象的VTABLE。
为每个类设置VTABLE、初始化VPTR、为虚函数调用插入代码,所有这些都是自动发生的,所以我们不必担心这些。利用虚函数机制,派生类合适的函数就能被调用,哪怕在编译器还不知道这个对象的类型的情况下。

那么,一个成员函数什么时候需要声明为虚函数呢?主要考虑以下几点:
• 首先考虑成员函数所在的类是否会做为基类。然后看成员函数在类的继承后有无功能被修改?如果希望修改其功能,一般将它声明为虚函数
• 如果成员函数在类被继承之后功能不需要修改,或派生类中用不到该函数,则不要把它声明为虚函数

override运算符和final运算符

1.	override
class X
{
public:
    virtual void f() {}
};
class Y : public X
{
public:
    void f() override {} //OK
    void f(int) override {} //error,基类中没有这个成员
};


2. final
class X
{
public:
    virtual void f() {}
    virtual void g() {}
};
 
class Y : public X
{
public:
    void f() final {} //OK,这是最终版本,派生类不能覆盖
    void g() override {} //OK
};
 
class Z final : public Y //Z是最终派生类,它不能再有派生类了
{
public:
    void f() override {} //error
    void g() override {} //OK
};
 
class W : public Z {}; //error


纯虚函数和抽象类

• 纯虚(pure virtual)函数的引入
• 基类往往表示一些抽象的概念。例如,Quadrangle是一个基类,它表示一个没有具象的四边形,从Quadrangle可以派生出其它具体的形体类。在这棵继承树上,基类Quadrangle体现了一个抽象的概念
• 在这种情况下,基类的有些虚函数没有定义是很正常的,但是要求派生类必须重定义这些虚函数,以使派生类有意义
纯虚函数是一个在基类中说明的虚函数,它在该基类中没有定义,要求任何派生类都必须定义自己的版本
为说明一个纯虚函数,使用下列一般形式:

class felid
{
public:
	virtual string what() = 0;
};

在类的构造和析构函数中可以调用普通成员函数甚至普通虚成员,但如果直接或间接调用了纯虚成员,那么该类的行为是不可预知的。因此,应该杜绝这类行为。
如果一个类至少有一个纯虚函数,那么就称该类为“抽象类(abstract class) ”
假设类A是一个抽象类,那么它的特点是:
不能创建抽象类A的对象。抽象类A只能用作其它类的基类。例如:

	A  obj; //error
	class B : public A {}; //OK

在抽象类的派生类中,如果纯虚函数的最终覆盖函数仍是一个纯虚函数(即仍未提供一个函数体),那么该派生类仍是一个抽象类

class A
{ public:
    virtual void f1() = 0;
    virtual void f2() {}
    virtual void f3() = 0;
    virtual void f4() = 0;
}
//请问A是抽象类吗?
class B : public A
{ public:
    void f1() {}
}
//请问B是抽象类吗?
class C : public B
{ public:
    void f2() {}
    void f3() {}
}
//请问C是抽象类吗?
class D : public C
{ public:
    void f4() {}
}
//请问D是抽象类吗?
class E : public D
{ public:
    void f5() {}
}
//请问E是抽象类吗?

• 抽象类不能用作函数的参数类型、返回类型。例如:

int f(A a); //error
A g() {}; //error

可以声明抽象类的指针和引用,它们可以作为函数的参数或返回类型。例如:

A &r = obj, *p = &obj;  //OK
int h(A *, A&); //OK
A* k(); //OK
A& s(); //OK

• 抽象类不能作为显式转换的类型。例如:

class C
{
public:
	operator A() {}
};

提问1:在类中声明了纯虚函数后,可以为该函数定义函数体吗?
答案是肯定的。例如:

class felid
{
public:
	virtual string what() = 0;
};

这种做法在语法上没有任何问题,但几乎是毫无意义:felid类仍然是一个抽象类,它的各代派生类中必须有显式定义的what()的最终覆盖函数,否则该派生类还是抽象类。这是一种病态设计
提问2:那么felid还是抽象类吗?
当然是

提问:如果在继承树上,某代派生类完成了所有的覆盖工作(即它不再是抽象类),那么这个派生类的后代可以不必进行纯虚函数的覆盖了吗?
答案是可以。但这仍是一种病态设计。

类的设计:OOD原则

类的设计遵循的原则
• 单一责任原则
• 开闭原则
• 组合/聚集复用原则
• 里氏替换原则

1. 依赖倒置原则

class keyboard
{
public:
    std::string input();
};
 
class monitor
{
public:
 void display(std::string content);
}; 
class calculator
{
private:
    keyboard kbd;
    monitor mntr;
 
    std::string calculate(std::string expression);
public:
    void work();
};

但当我们将应用的范围扩大,例如:计算可以使用其它种类的输入设备(如触屏等)和输出设备(如打印机)等,那么这些类就不能适应应用的需求了,因为这种设计的可扩展性较差
考察类的设计,可以发现,主要类calculator的实现依赖于具体的设备,这正是它的应用受限的根本原因。解决问题的方法是让该类与具体设备无关,这就需要使用抽象
基于抽象原则,我们可以重新设计类(接口)及其继承层次:
• 设计抽象类device,用于描述统一的设备接口
• 设计抽象类inputdev,它是device的派生类,用于描述统一输入设备接口;类keyboard是该类的派生类,它实现了输入接口
• 设计抽象类outputdev,它是device的派生类,用于描述统一输出设备接口;类monitor是该类的派生类,它实现了输出接口
• 类calculator内部不包含具体设备类的实例,而是包含对抽象设备的引用。这样,它就不依赖于具体设备,而是依赖于抽象

2. 接口隔离原则
打印传真一体机(faxPrinter)是这样一种办公设备:它既可以作为打印机(printer)使用,也可以作为传真机(faxm)使用。那么,当我们用类/接口去模拟实现这些概念时,为了使faxPrinter可以同时拥有printer和faxm的功能,可以考虑这么做:printer是祖先类;faxm是其派生类;faxPrinter是faxm的派生类。
faxm接口的设计出了问题:如果要设计一个faxm的派生类(它仍然只是传真机),那么它不得不实现一个对它来说完全无用的接口print()。显然,这么做是不合理的。可以这么说,faxm接口被“污染”了。
违背了“接口隔离原则(ISP,Interface Segregation Principle)”:客户端不应该依赖它不需要的接口一个类对另一个类的依赖应该建立在最小的接口上

3.最少知识原则
考虑这样的问题:有四种设备,它们之间可以互相通信,交换数据。如果我们用四个类去模拟设备的工作时,这些类之间的关联关系可以如下所示
C++ 虚函数和多态性_第2张图片
在系统中增加一个新的设备类时,关联关系需要在一定程度上重构,而已有设备类的源码可能还需要修改以适应新设备。因此,这种设计的可扩展性是相当差
当软件模块(例如类)中的一个发生修改时,要尽量少地影响其他模块,这样扩展就会相对容易。这要求对软件实体之间通信的广度和深度要有限制。用一句话总结,就是一个软件实体应当尽可能少地与其他实体发生相互作用。这就是OOD原则中的“迪米特法则(LoD,Low of Demeter)”。

Lod又称为“最少知识原则(LKP, Least Knowledge Principle)”。这条原则有多种转述形式,其中比较容易理解的就是:不要和“陌生人”说话、只与你的直接朋友通信。那么,谁是陌生人,谁又是朋友呢?
对于一个对象,其朋友包括以下几类:
① 当前对象本身(this);
② 以参数形式传入到当前对象方法中的对象;
③ 当前对象的成员对象;
④ 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
⑤ 当前对象所创建的对象。
任何不满足上述条件的对象就是陌生人。

你可能感兴趣的:(C++学习)