虚函数的概念与使用
C 中的虚函数和多态是面向对象编程中的重要概念。虚函数允许在派生类中重写基类的函数,并且在运行时根据对象的实际类型来调用函数。这一点和Java中的重写(Override)函数类似。虚函数是实现多态的基础。
虚函数的概念
-
在C 中,通过在基类函数声明前面加上关键字
virtual
来定义虚函数。 -
派生类可以重写基类的虚函数,使用
override
关键字来确保正确的重写。 -
派生类中的虚函数必须具有与基类中的虚函数相同的函数签名(包括函数名、参数列表和返回类型)
-
当通过基类指针或引用调用虚函数时,将根据对象的实际类型来调用正确的函数。
虚函数的案例
下面是一个电商场景的案例,在Product
基类中定义了2个函数,普通成员函数displayInfo
展示商品信息,虚函数discount
获取商品打折后的价格。在派生类Book
中也定义了基类中的这2个函数
class Product {
public:
Product(double price) : _price(price) {}
void displayInfo() {
cout << "Product: displayInfo()" << endl;
}
virtual double discount() {
//默认不打折
cout << "Product: discount()" << endl;
return _price;
}
protected:
double _price; //商品原价
};
class Book : public Product {
public:
Book(double price) : Product(price) {}
void displayInfo() { //对基类中displayInfo函数的隐藏
cout << "Book: displayInfo()" << endl;
}
double discount() override { //对基类中discount函数的重写(覆盖)
double salePrice = _price * 0.8; //折扣价
cout << "Book: discount price: " << salePrice << endl;
return salePrice;
}
};
先来看下displayInfo
函数,和基类中的这个函数有相同的函数签名,这种方式叫函数隐藏,也就是对基类中displayInfo
函数的隐藏。再来看下discount
函数,也是和基类一样的函数签名,但是多了一个override
关键字,这种是覆盖(重写)。
看下main
方法
int main() {
Book book(20);
book.displayInfo(); //输出Book: displayInfo()
book.discount(); //输出Book: discount price: 16
//通过指针对象调用
Product *p = &book;
p->displayInfo(); //输出Product: displayInfo()
p->discount(); //输出Book: discount price: 16
return 0;
}
这里先创建派生类对象book,这种子类对象调用函数的结果想必大家没有什么疑问。然后创建一个基类指针对象,指向刚才创建的派生类对象,再去调用函数。可以看到displayInfo
调用的是基类的方法,而discount
调用的是派生类的对象
隐藏与虚函数小结
隐藏是指子类中的成员函数隐藏了父类中同名的成员函数。当子类中定义了与父类中同名的成员函数时,父类中的同名成员函数将被隐藏,无法通过子类对象直接访问到父类中的同名成员函数。这种隐藏关系是静态的,即在编译时就确定了。
虚函数是通过在父类中使用
virtual
关键字声明的成员函数。虚函数允许在运行时根据对象的实际类型来调用相应的函数。当通过父类指针或引用调用虚函数时,实际调用的是对象的动态类型所对应的函数。这种多态行为使得可以通过父类指针或引用来调用子类中重写(override)的虚函数。只有当基类中的函数需要被子类重写的时候,才需要被设计成虚函数,否则应该是非虚函数。
虚析构函数
虚析构函数案例分析
先来看如下demo,回顾下之前文章中讲的构造函数与析构函数
class Base {
public:
Base() {
cout << "Base()" << endl;
}
~Base() {
cout << "~Base()" << endl;
}
};
class Derive : public Base {
public:
Derive() {
cout << "Derive()" << endl;
}
~Derive() {
cout << "~Derive()" << endl;
}
};
看下main
方法
int main() {
Derive derive;
return 0;
}
执行结果如下:
Base()
Derive()
~Derive()
~Base()
相信这个结果,大家都没有什么疑问。
再来看如下代码:
int main() {
Base *base = new Derive();
delete base;
return 0;
}
这里通过基类指针指向派生类对象的方式,然后删除指针对象。
结果如下:
Base()
Derive()
~Base()
这里就很奇怪了,只调用了基类的析构函数,没有调用派生类的析构函数。 这样是有问题的,可能导致派生类中的内存泄漏啊。
解决方式如下:
在基类的析构函数中加上virtual
关键字,变成虚析构函数就可以了。
virtual ~Base() {
cout << "~Base()" << endl;
}
为什么会这样呢?原因如下:
因为在C 中,通过基类指针删除派生类对象时,编译器只知道指针的类型是基类,因此只会调用基类的析构函数。如果基类的析构函数不是虚函数,那么编译器无法在运行时动态绑定到正确的派生类析构函数上。
通过将基类的析构函数声明为虚函数,我们告诉编译器在删除对象时动态绑定到正确的析构函数上。这样,当我们使用基类指针删除派生类对象时,会调用派生类的析构函数,确保派生类对象的资源被正确释放。
虚析构函数小结
因此,为了正确地释放派生类对象的资源,当我们使用基类指针指向派生类对象时,基类的析构函数通常应该声明为虚函数。这是一种良好的实践,以确保在删除对象时调用正确的析构函数,避免内存泄漏和未定义行为。
多态的实际应用的案例
多态案例
对上面的demo稍加改造,新增一个派生类Cloth
,也是重写discount
方法
class Cloth : public Product {
public:
Cloth(double price) : Product(price) {}
void displayInfo() { //对基类中displayInfo函数的隐藏
cout << "Cloth: displayInfo()" << endl;
}
double discount() override { //对基类中discount函数的重写(覆盖)
double salePrice = _price * 0.6; //折扣价
cout << "Cloth: discount price: " << salePrice << endl;
return salePrice;
}
};
再加一个新的接口,传入一个基类对象的引用,就能调用虚函数
//获取商品实际售卖价格
double getSalePrice(Product &product) {
return product.discount();
}
在main
方法中,基类指针分别指向了2个派生类对象,然后就能调用派生类中重写的虚函数了。
int main() {
Product *p1 = new Book(20);
Product *p2 = new Cloth(100);
getSalePrice(*p1);
getSalePrice(*p2);
delete p1;
delete p2;
return 0;
}
从这个案例来看,是不是和java中多态是类似的?确实这样,从设计思想来说,c 的多态和java多态是类似的,只不过实现上有所不同。
多态总结
多态是指在运行时根据对象的实际类型来调用适当的函数。通过使用虚函数和基类指针或引用,可以实现多态。这种多态性使得在编译时不需要知道对象的具体类型,而可以根据对象的实际类型来调用正确的函数。
不要滥用虚函数
前面讲过,如果一个函数需要被子类重写才需要被设计成虚函数,否则应该设计成非虚函数。
但是有人会有疑问,如果不被重写的函数也设计成虚函数,貌似也没有问题,因为使用和运行结果都是一样的,而且如果后期需求变化需要扩展,还能直接在子类重写。这种说法听上去还挺有道理,实际上不然,因为2者在运行机制上有本质区别。
如果函数是非虚函数,采用的是静态联编,在编译阶段就能确定函数的具体调用;如果是虚函数,采用的是动态联编,函数的调用需要在程序运行时才能确定;后者的执行效率是要低于前者的。关于动态联编的原理,是在后面文章中讲虚函数表和动态绑定的时候来解释。
纯虚函数和抽象类
什么是纯虚函数
在C 中,纯虚函数和虚函数比较类似,只不过它的声明形式是在函数声明后面加上 = 0
,而且没有函数体。
class AbstractClass {
public:
virtual void pureVirtualFunction() = 0; //纯虚函数的申明
};
和虚函数类似,纯虚函数也是要在派生类中重写。说到这里,有人就好奇了,那两者有什么区别呢?先来看虚函数,通常在基类中定义的虚函数是可以有自己的实现的,也能被派生类重写;再看纯虚函数,它在基类中只能申明,并且不能有函数体(语法就是这样),纯虚函数在子类中应该
被重写,但是我更喜欢称这种方式为实现,因为java中类似的对接口方法和抽象方法都被叫做实现。 注意这里说的是应该,而不是必须,原因后面解释。
什么是抽象类
只要这个类中至少有一个纯虚函数,那它就是抽象类。 抽象类不能被实例化,只能被用作其他类的基类。如果一个类继承自抽象类,它必须实现基类中的所有纯虚函数,否则它也会成为一个抽象类。
现在来看,c 中的抽象类和java的抽象类在定义上没什么区别,c 中的纯虚函数不就是java中的抽象函数吗?所以抽象类除了有纯虚函数外,还可以拥有普通函数。
下面是个商品打折的场景,在基类Product
中申明了纯虚函数discount
计算商品打折后的价格,由于不同的商品折扣不一样,所以需要每个派生类去实现这个纯虚函数。
class Product {
public:
Product(const string &name, double price) : _name(name), _price(price) {}
virtual double discount() const = 0; // 纯虚函数,商品打折
protected:
string _name;
double _price;
};
class Book : public Product {
public:
Book(string name, double price) : Product(name, price) {}
double discount() const override {
return _price * 0.8;
}
};
class Cloth : public Product {
public:
Cloth(string name, double price) : Product(name, price) {}
double discount() const override {
return _price * 0.6;
}
};
再来看main
方法
int main() {
Book book("一千零一夜", 30);
Cloth cloth("海澜之家", 500);
cout<<"Book discount price: "<<book.discount()<<endl;
cout<<"Cloth discount price: "<<cloth.discount()<<endl;
return 0;
}
//输出:
Book discount price: 24
Cloth discount price: 300
抽象类小结:
- c 中的抽象类不需要用
abstract
关键字申明- 类中至少包含一个纯虚函数(通过在函数声明后面加上
= 0
来声明)。- 抽象类不能被实例化,只能用作其他类的基类。
最佳实践
1.可以通过抽象类指针指向派生类,实现多态
int main() {
Product *p1 = new Book("一千零一夜", 30);
Product *p2 = new Cloth("海澜之家", 500);
delete p1;
delete p2;
return 0;
}
2.在实际开发中,类的申明在头文件,定义在源文件。那抽象类和派生类该如何做呢?
在抽象类Abstractclass.h
头文件中
class Abstractclass {
virtual void pureVirtualFunc() = 0;
};
派生类DeriveClass.h
(头文件)
#include "Abstractclass.h"
class DeriveClass : public Abstractclass {
public:
void pureVirtualFunc() override;
};
DeriveClass.cpp
(源文件)
#include "derive1.h"
using namespace std;
void DeriveClass::pureVirtualFunc() {
cout << "DeriveClass: pureVirtualFunc()";
}