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、为虚函数调用插入代码,所有这些都是自动发生的,所以我们不必担心这些。利用虚函数机制,派生类合适的函数就能被调用,哪怕在编译器还不知道这个对象的类型的情况下。
那么,一个成员函数什么时候需要声明为虚函数呢?主要考虑以下几点:
• 首先考虑成员函数所在的类是否会做为基类。然后看成员函数在类的继承后有无功能被修改?如果希望修改其功能,一般将它声明为虚函数
• 如果成员函数在类被继承之后功能不需要修改,或派生类中用不到该函数,则不要把它声明为虚函数
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还是抽象类吗?
当然是
提问:如果在继承树上,某代派生类完成了所有的覆盖工作(即它不再是抽象类),那么这个派生类的后代可以不必进行纯虚函数的覆盖了吗?
答案是可以。但这仍是一种病态设计。
类的设计遵循的原则
• 单一责任原则
• 开闭原则
• 组合/聚集复用原则
• 里氏替换原则
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.最少知识原则
考虑这样的问题:有四种设备,它们之间可以互相通信,交换数据。如果我们用四个类去模拟设备的工作时,这些类之间的关联关系可以如下所示
在系统中增加一个新的设备类时,关联关系需要在一定程度上重构,而已有设备类的源码可能还需要修改以适应新设备。因此,这种设计的可扩展性是相当差的
当软件模块(例如类)中的一个发生修改时,要尽量少地影响其他模块,这样扩展就会相对容易。这要求对软件实体之间通信的广度和深度要有限制。用一句话总结,就是一个软件实体应当尽可能少地与其他实体发生相互作用。这就是OOD原则中的“迪米特法则(LoD,Low of Demeter)”。
Lod又称为“最少知识原则(LKP, Least Knowledge Principle)”。这条原则有多种转述形式,其中比较容易理解的就是:不要和“陌生人”说话、只与你的直接朋友通信。那么,谁是陌生人,谁又是朋友呢?
对于一个对象,其朋友包括以下几类:
① 当前对象本身(this);
② 以参数形式传入到当前对象方法中的对象;
③ 当前对象的成员对象;
④ 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
⑤ 当前对象所创建的对象。
任何不满足上述条件的对象就是陌生人。