多态性和虚函数【C++】
一、多态性的概念
1、绑定
把函数体和函数调用相联系称为绑定。程序自身彼此关联的过程,也就是把一个标识符名和
一个存储地址联系在一起的过程。按照绑定进行的阶段不同,可以分为两种不同的绑定方法:静态
绑定和动态绑定。
2、静态绑定(静态联编)
联编工作出现在编译阶段,用对象名或者类名来限定要调用的函数。
C++ 中,默认的函数调用绑定方式为早绑定,也叫静态绑定,即在程序运行之前,由编译器
和连接器实现。
3、动态绑定(动态联编)
联编工作在程序运行时执行,在程序运行时才确定将要调用的函数。
晚绑定将绑定推迟到程序运行时,在程序运行时,获知实际接收消息的对象的类型,根据这时
的类型信息绑定函数调用,又称动态绑定或运行时绑定。
4、实例说明
在 payroll() 函数中辅,对 salary() 的调用实施早绑定。
class employee { public: void salary() {}; }; class manager : public employee { public: void salary() {}; // 经理工资的计算和发放 }; class programmer : public employee { public: void salary() {}; // 程序员工资的计算和发放 }; class parttime : public employee { public: void salary() {}; // 兼职人员工资的计算和发放 }; void payroll(employee& re) { // payroll函数 re.salary(); }
晚绑定将绑定推迟到程序运行时,在程序运行时,获知实际接收消息的对象的类型,根据这时
的类型信息绑定函数调用,又称动态绑定或运行时绑定。如下:
// 如果使用晚绑定,运行下面的代码之前 re.salary() 没有和任何函数体联系 manager Harry; programmer Ron; // 真正执行 re.salary() 时进行动态函数绑定 // re 指向 Harry, 将 re.salary() 动态绑定 manager::salary() payroll(Harry); // re 指向 Ron, 将 re.salary() 动态绑定 programmer::salary payroll(Ron);
二、虚函数
- 在 C++ 语言中,是通过将一个函数定义成虚函数来实现运行时的多态的。虚函数是动态绑定的基础。
- 是非静态的成员函数。
- 在类的声明中,在函数原型之前写 virtual。virtual 只用来说明类声明中的原型,不能用在函数实现时。
- 具有继承性,基类中声明了虚函数,派生类中无论是否说明,同原型函数都自动为虚函数。
- 本质:不是重载声明而是覆盖。
- 调用方式:通过基类指针或引用,执行时会根据指针指向的对象的类,决定调用哪个函数。
【例1】对上面的例子,定义虚函数,分析运行结果。
#include <iostream> using namespace std; class employee { public: virtual void salary() {}; }; class manager : public employee { public: // 经理工资的计算和发放 void salary() { cout << "经理工资的计算和发放" << endl; }; }; class programmer : public employee { public: // 程序员工资的计算和发放 void salary() { cout << "程序员工资的计算和发放" << endl; }; }; class parttime : public employee { public: // 兼职人员工资的计算和发放 void salary() { cout << "兼职人员工资的计算和发放" << endl; }; }; void payroll(employee& re) { // payroll函数 re.salary(); } int main() { manager Tom; payroll(Tom); programmer Andy; payroll(Andy); parttime(Saly); payroll(Saly); return 0; }
【例2】
#include <iostream> using namespace std; class Person { public: virtual void Print() { // 基类中的虚函数 cout << "Person!" << endl; } }; class Worker :public Person { private: int worker; public: void Print() { // 在派生类 Worker 中重新定义 cout << "Worker!" << endl; } void Print1() { cout << "Other information of Worker" << endl; } }; class Teacher :public Person { public: void Print() { // 在派生类 Teacher 中重新定义 cout << "Teacher!" << endl; } void Print2() { cout << "Other information of Teacher" << endl; } private: int teacher; }; class Driver :public Person { private: int driver; }; int main() { Person* p; Worker w; Teacher t; Driver d; p = &w; p->Print(); p = &t; p->Print(); p = &d; p->Print(); t.Print(); return 0; }
三、动态联编的工作机制
编译器在执行过程中遇到 virtual 关键字的时候,将自动安装动态联编需要的机制。
首先为这些包含 virtual 函数的类(注意不是对象)建立一张虚拟函数表 VTABLE。在这些虚拟函数表中,编译器将依次按照函数声明次序放置类的特定虚函数的地址。
同时在每个带有虚函数的类中放置一个称之为 vpointer 的指针,简称 vptr,这个指针指向这
个类的 VTABLE。
vptr 一般置于对象的起始位置,在对象的构造函数中将 vptr 初始化为本类的 VTABLE 的地
址。C++ 编译程序时候按下面的步骤进行工作:
(1)为各类建立虚拟函数表,如果没有虚函数则不建立;
(2)暂时不连接虚函数,而是将各个虚函数的地址放入虚拟函数表中;
(3)直接连接各静态函数。
【代码理解】
class shape { public: virtual double area() const { return 0; } virtual void draw(){} }; class rectangle :public shape { public: double area() const { return height * width; } void draw(){} protected: double height, width; }; class square :public shape { public: void draw(){} }; class circle :public shape { public: double area() const { return PI * radius * radius; } void draw(){} private: double radius; }; shape *sa[]={new circle,new rectangle,new rectangle,new square}
【代码分析】
当通过基类指针调用一个虚函数时,例如上面的 sa[0],它指向 circle 对象的起始地址。
编译器从 sa[0] 指向的对象中取出 VPTR,根据 VPTR 找到相应的虚函数表 VTABLE。再根
据函数在 VTABLE 中的偏移,找到到适当的函数。
因此不是根据指针的类型 shape* 决定调用 shape::area(),而是调用 “ VPTR+偏移量 ”
处的函数。因为获取 VPTR 和确定实际的函数地址发生在运行时,所以就实现了晚绑定。
派生类如果重定义了基类中的虚函数,就在派生类的 VTABLE 中保存新版本虚函数的
地址,没有重定义的仍使用基类虚函数的地址。同一个虚函数在派生类和基类的 VTABLE 中
处于相同的位置。如果派生类增加了新的虚函数,则编译器先将基类的虚函数准确地映射到
派生类的 VTABLE 中,再加入新增加的虚函数地址。只存在于派生类中的虚函数不能通过基
类指针调用。
【例题理解】
#include <iostream> using namespace std; class B0 { public: virtual void display() { cout << "B0::dispaly()" << endl; } }; class B1 :public B0 { public: void display() { cout << "B1::display()" << endl; } }; class D1 : public B1 { public: void display() { cout << "D1::display()" << endl; } }; void fun(B0* ptr) { ptr->display(); } int main() { B0 b0, * p; //声明基类对象和指针 B1 b1; //声明派生类对象 D1 d1; //声明派生类对象 p = &b0; fun(p); //调用基类B0函数成员 p = &b1; fun(p); //调用派生类B1函数成员 p = &d1; fun(p); //调用派生类D1函数成员 return 0; }
注意:
1、关于虚函数:
(1)当基类中把成员函数定义为虚函数后,要达到动态联编的效果,派生类和基类的对应成员函
数不仅名字相同,而且返回类型、参数个数和类型也必须相同。否则不能实现运行时多态。
(2)基类中虚函数前的 virtual 不能省略,派生类中的虚函数的 virtual 关键字可以省略,缺省后仍
为虚函数。
(3)内联函数不能使虚函数。即使在类的内部定义,编译时仍将其看作非内联的。
(4)虚函数必须是类的成员函数,不能是友员函数,也不能是静态成员函数。
(5)不能将构造函数定义为虚函数,但可将析构函数定义为虚函数。
2、虚函数和重载函数的比较
(1)重载函数要求函数有相同的函数名,并有不同的参数序列;而虚函数则要求这三项(函数
名、返回值类型和参数序列)完全相同。
(2)重载函数可以是成员函数或友员函数,而虚函数只能是成员函数。
(3)重载函数的调用是以所传递参数序列的差别作为调用不同函数的依据;虚函数是根据对象的
不同去调用不同类的虚函数。
(4)虚函数在运行时表现出多态功能,这是 C++ 的精髓;而重载函数则在编译时表现出多态性。
【例】
#include <iostream> #include <cstring> using namespace std; class A { public: virtual int f() const { cout << "Base::f()" << endl; return 1; } virtual void f(string) const {} virtual void g() const {} }; class B1 :public A { public: void g() const{} }; class B2 :public A { public: int f() const { cout << "D2::f()" << endl; return 2; } }; /* class B3 :public A { public: void f()const { // 错误,不能修改虚函数的返回类型 cout << "D3::f()" << endl; } }; */ class B4 :public A { public: int f(int)const { cout << "D4::f()" << endl; return 4; } }; int main() { string s = "Hello"; B1 d1; int x = d1.f(); d1.f(s); B2 d2; x = d2.f(); // d2.f(s); // 错误,f(string) 被隐藏 B4 d4; x = d4.f(1); // x = d4.f(); // 错误,f() 被隐藏 // d4.f(s); // 错误,f(string) 被隐藏 A& br = d4; // 向上类型转换 // br.f(1); // 错误,基类接口没有 f(int),基类指针不能调用派生类的成员 br.f(); br.f(s); return 0; }
四、虚析构函数
在 C++ 中,不能声明虚构造函数,因为在构造函数执行时,对象还没有完全构造好,不能按
虚函数方式进行调用。但是可以声明虚析构函数,如果用基类指针指向一个 new 生成的派生类对
象,通过 delete 作用于基类指针删除派生类对象时,有以下两种情况:
(1)如果基类析构函数不为虚析构函数,则只会调用基类的析构函数,而派生类的析构函数不会
被调用,因此派生类对象中派生的那部分内存空间无法析构释放。
(2)如果基类析构函数为虚析构函数,则释放基类指针的时候会调用基类和派生类中的所有析构
函数,派生类对象中所有的内存空间都将被释放,包括继承基类的部分。所以 C++ 中的析构函数
通常是虚析构函数。
【例】虚析构函数的用法示例。
#include <iostream> using namespace std; class B1 { public: ~B1() { cout << "使用 B1 的析构函数" << endl; } }; class D1 :public B1 { public: ~D1() { cout << "使用 D1 的析构函数" << endl; } }; class B2 { public: virtual ~B2() { cout << "使用 B2 的析构函数" << endl; } }; class D2 :public B2 { public: ~D2() { cout << "使用 D2 的析构函数" << endl; } }; int main() { B1* b1 = new D1; delete b1; B2* b2 = new D2; delete b2; return 0; }
实现多态性的步骤:
(1)在基类中将需要多态调用的成员函数声明为 virtual;
(2)在派生类中覆盖基类的虚函数,实现各自需要的功能;
(3)基类的指针或引用指向派生类对象,通过指针或引用调用虚函数。
这样,会根据基类的指针或引用实际指向的(派生类)对象来调用派生类中的虚函数版本。
如果派生类没有覆盖基类的虚函数,则基类中的虚函数被调用。
需要注意的是,只有通过基类的指针或引用才能实现虚函数的多态调用,通过对象调用虚函
数不会引起多态调用。
C++ 为了减少程序运行时的开销,提高效率,编译时能确定的信息不会推迟到运行时处理。
对虚函数的绑定是根据实施调用的对象类型确定的,如果使用指针或引用调用虚函数,那么要到运
行时才知道它们指向的实际对象类型,因而会进行晚绑定。如果直接使用对象调用虚函数,对象的
类型在编译时就知道了,不会进行晚绑定。
【举例】
#include <iostream> using namespace std; class Base { public: virtual void vfunc() { cout << "Base::vfunc()" << endl; } }; class Derived1 : public Base { public: void vfunc() { cout << "Derived1::vfunc()" << endl; } }; class Derived2 : public Base { public: void vfunc() { cout << "Derived2::vfunc()" << endl; } }; int main() { Base b; Derived1 d1; Derived2 d2; b.vfunc(); // 已知 b 是 Base 类型的对象,编译时绑定到 Base::vfunc() d1.vfunc(); // 同上, 编译时绑定到 Derived1::vfunc() d2.vfunc(); // 同上, 编译绑定到 Derived2::vfunc() b = d1; b.vfunc(); // 对象调用,没有多态性,仍然是编译时绑定到Base::vfunc() Base* pb = &b; pb->vfunc(); // 运行时绑定到 pb 这时指向 b 所属类型 Base 的 vfunc() pb = &d1; pb->vfunc(); // 运行时绑定到 pb 这时指向d1所属类型 Derived1 的 vfunc() pb = &d2; pb->vfunc(); // 运行时绑定到 pb 这时指向 d2 所属类型 Derived2 的 vfunc() }
注意:基类指针(引用)即使在指向派生类对象时,也只能调用基类接口中出现的成员函数,不能
调用派生类中增加的成员函数,即使是虚函数。
【动态绑定的实现】
#include <iostream> using namespace std; class B1 { // static int n; int m; public: void f() {} }; class B2 { int m; public: virtual void f() {} }; class D1 :public B1 { int n; public: void f() {} }; class D2 :public B2 { int n; public: void f() {} }; class B3 { int n; public: virtual void g() {} virtual void h() {} }; int main() { cout << "sizeof(B1) = " << sizeof(B1) << endl; cout << "sizeof(B2) = " << sizeof(B2) << endl; cout << "sizeof(D1) = " << sizeof(D1) << endl; cout << "sizeof(D2) = " << sizeof(D2) << endl; cout << "sizeof(B3) = " << sizeof(B3) << endl; return 0; }
【对象的布局】
五、纯虚函数和抽象类
面向对象设计中经常需要对一组具体类的共性进行抽象,自下而上形成更一般的基类,描述
这组类的公共接口。
在这种向上抽象的过程中,我们发现,越上层的基类其抽象程度越高,有时甚至难以对它们
的某些操作给出具体描述。这些基类存在的目的已经不再是用来创建实例,而只是描述类层次中派
生类的共同特性,为这些派生类提供一个公共接口。这样的类是“抽象”的,与之相对的概念是“具体
类”。
例如,汽车、火车、轮船都是具体类,它们都有实例存在,都可以“驾驶”;可以用“交通工具”
类描述汽车、火车、轮船等类的共性,可以说“驾驶交通工具”,但是难以描述这个“驾驶”操作到底
是怎样的,因而这个操作是抽象操作,而这个交通工具类是抽象类。
1、纯虚函数
C++ 中用纯虚函数定义类中的抽象操作,纯虚函数没有实现。
声明纯虚函数的一般形式为:
class 类名 {
virtual 类型 函数名(参数表)= 0; // 纯虚函数
......
}
注意:
(1)纯虚函数没有函数体。
(2)最后面的 “ =0 ” 并不表示函数返回值为 0,它只起形式上的作用,即这是纯虚函数。
(3)这是一个声明语句,最后应有分号。
(4)如果在一个类中声明了纯虚函数,而在其派生类中没有对该函数定义,则该虚函数在派生类
中仍然为纯虚函数。
2、抽象类
如果一个类中至少有一个纯虚函数,那么这个类被称为抽象类(abstract class)。
抽象类中不仅包括纯虚函数,也可包括虚函数。抽象类中的纯虚函数可能是在抽象类中定义
的,也可能是从它的抽象基类中继承下来且重定义的。
抽象类有一个重要特点,即抽象类必须用作派生其他类的基类,而不能用于直接创建对象实例。
抽象类的两个主要用途:
(1)支持一般性的通用概念。如图形、交通工具等,这些概念自己没有实例,只是使用它们的具
体派生类实例。
(2)为一组派生类提供公共接口,而接口的实现由各个派生类提供。我们如果知道飞机一种交通
工具,就知道它可以驾驶,但如何驾驶则要 “飞机” 类自己实现了。
【用虚函数做出不同的销售报表】
#include <iostream> #include <cstring> using namespace std; class Datebase { public: int number; char goods_name[20]; float price; char sale; Datebase(int n, char *goods, float p, char s) { number = n; strcpy(goods_name,goods); price = p; sale = s; } float couter() { float t; t = price * number; return t; } virtual void reporter() = 0; }; class reporter1 :public Datebase { public: reporter1(int n, char *goods, float p, char s) :Datebase(n, goods, p, s) {} void reporter() { cout << number << " " << goods_name << endl; } }; class reporter2 :public reporter1 { public: reporter2(int n, char* goods, float p, char s) :reporter1(n, goods, p, s) {} void reporter() { cout << number << " " << goods_name << " " << price << " " << sale << endl; } }; class reporter3 :public reporter2 { public: reporter3(int n, char* goods, float p, char s) :reporter2(n, goods, p, s) {} void reporter() { cout << number << " " << goods_name << " " << couter() << endl; } }; int main() { int k; reporter3 p1(200, "pen", 9.0, 's'); reporter3 p2(20, "paper", 10.0, 's'); reporter3 p3(180, "ink", 1.0, 's'); cout << "Input the reporter number you want:" << endl; cout << "1.simple style" << endl; cout << "2.completely style" << endl; cout << "3.counting style" << endl; cin >> k; if (k == 1) { reporter1* p; cout << "number goods_name" << endl; cout << "---------------------" << endl; p = &p1; p->reporter(); p = &p2; p->reporter(); p = &p3; p->reporter(); } else if (k == 2) { cout << "number goods_name price saleornot" << endl; cout << "------------------------------------------------" << endl; reporter2* p; p = &p1; p->reporter(); p = &p2; p->reporter(); p = &p3; p->reporter(); } else if (k == 3) { reporter3* p; cout << "number goods_name total_incoming" << endl; cout << "-----------------------------------------" << endl; p = &p3; p->reporter(); p = &p2; p->reporter(); p = &p1; p->reporter(); } return 0; }
使用抽象类注意以下方面:
(1)如果一个类中包含至少一个纯虚函数,这个类就是抽象类。只能作为基类使用。如果一个抽
象类中的所有成员函数都是纯虚函数,这个类称为纯抽象类。
(2)当继承一个抽象类时, 必须在派生类中实现 (覆盖) 所有的纯虚函数,否则派生类也被看
作是一个抽象类。
(3)不能创建抽象类的实例,但可以创建由抽象类派生的具体子类的实例,也可以定义抽象类的
指针或引用,它们指向具体派生类的对象。
Example:
int main() {
Shape s; //编译器报告错误
Rectangle r(4, 5);
Shape* ps = &r; //OK,可以声明Shape类指针,指向具体派生类实例
cout << ps->area();
Shape& rs = r; //OK,可以声明Shape类引用,引用具体派生类对象
cout << rs.perimeter();
}
(4)抽象类不能用做函数参数类型,函数返回值类型,或显示转换的类型。
Example:
class shape { // 抽象类
public:
virtual void draw() = 0; // 纯虚函数
};
shape x; // 错误,抽象类不能建立对象
shape* p; // 正确,可以声明抽象类的指针
shape f(); // 错误, 抽象类不能作为返回类型
void g(shape); // 正确, 抽象类不能作为参数类型
(5)抽象类也可以包含普通成员函数, 在普通成员函数中可以调用纯虚函数,因为纯虚函数被推
迟到某个具体派生类中实现,由于虚函数的晚捆绑,在派生对象实施这个调用时会体现为具体操作
对具体操作的调用。
Example:
#include <iostream>
using namespace std;
class abstract { //抽象基类
public:
virtual void pf() = 0; //纯虚函数
void f() { //普通成员函数
cout << "abstract::f()" << endl;
pf(); //由调用 f() 的对象类型确定调用哪个类的 pf() 实现
}
};
class concrete : public abstract {
public:
//实现基类接口中的纯虚函数
void pf() {
cout << "concrete::pf()" << endl;
}
};
int main() {
concrete c;
c.f();
return 0;
}
六、综合应用
【问题】
利用虚函数机制编写一个简化的菜单驱动程序。当运行此程序时,它在屏幕上显示下列四行
信息:
1 — dispaly “Good!”
2 — display “Better!”
3 — display “Best!”
0 — Exit
然后程序读入用户输入的数字。如果输入为 0 则退出,输入为 1 则显示Good!,输入为 2
则显示 Better!,输入为 3 则显示 Best!。最后再次显示上列四行信息,并且等待用户的另
一次输入。
【参考代码】
#include <iostream> using namespace std; class Show { public: virtual void display() {} }; class display1 :public Show { public: void display() { cout << "Dood!" << endl; } }; class display2 :public Show { public: void display() { cout << "Better!" << endl; } }; class display3 :public Show { public: void display() { cout << "Best!" << endl; } }; class display0 :public Show { public: void display() { exit(0); } }; int main() { int key; Show* p[4]; display1 good; display2 better; display3 best; display0 exit; p[1] = &good; p[2] = &better; p[3] = &best; p[0] = &exit; for (;;) { cout << " 1 — dispaly “Good! " << endl; cout << " 2 — display “Better! " << endl; cout << " 3 — display “Best! " << endl; cout << " 0 — Exit " << endl; cin >> key; p[key]->display(); } return 0; }
类层次结构设计:
封装、继承和多态性是面向对象程序设计的强大机制。面向对象设计的一项主要工作就是设
计类,通过各种关系组织这些类。为了利用多态性带来的优点,在更高的抽象层次上编写灵活、通
用的代码,面向对象程序中通常会利用继承关系建立一些类层次结构。
建立类层次的一般方式有两种:
(1)是自顶向下,先设计描述通用特性和操作的基类,然后继承基类,得到各种派生类以解决特定问题;
(2)是自底向上,先完成底层类的设计,再根据需要抽象出它们的共性,得到描述它们共同接口的公共基类,最后面向基类在更高的抽象层次上编写程序。
在设计中同时使用这两种方式的情况也很常见。