C++ 虚函数,虚析构函数与多态,纯虚函数与抽象

虚函数的概念与使用

C++中的虚函数和多态是面向对象编程中的重要概念。虚函数允许在派生类中重写基类的函数,并且在运行时根据对象的实际类型来调用函数。这一点和Java中的重写(Override)函数类似。虚函数是实现多态的基础。

虚函数的概念

  1. 在C++中,通过在基类函数声明前面加上关键字virtual来定义虚函数。

  2. 派生类可以重写基类的虚函数,使用override关键字来确保正确的重写。

  3. 派生类中的虚函数必须具有与基类中的虚函数相同的函数签名(包括函数名、参数列表和返回类型)

  4. 当通过基类指针或引用调用虚函数时,将根据对象的实际类型来调用正确的函数。

虚函数的案例

下面是一个电商场景的案例,在Product基类中定义了2个函数,普通成员函数displayInfo展示商品信息,虚函数discount获取商品打折后的价格。在派生类Book中也定义了基类中的这2个函数

cpp 复制代码
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方法

cpp 复制代码
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,回顾下之前文章中讲的构造函数与析构函数

cpp 复制代码
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方法

csharp 复制代码
int main() {
    Derive derive;
    return 0;
}

执行结果如下:

Base()

Derive()

~Derive()

~Base()

相信这个结果,大家都没有什么疑问。

再来看如下代码:

cpp 复制代码
int main() {
    Base *base = new Derive();
    delete base;

    return 0;
}

这里通过基类指针指向派生类对象的方式,然后删除指针对象。

结果如下:

Base()

Derive()

~Base()

这里就很奇怪了,只调用了基类的析构函数,没有调用派生类的析构函数。 这样是有问题的,可能导致派生类中的内存泄漏啊。

解决方式如下: 在基类的析构函数中加上virtual关键字,变成虚析构函数就可以了。

cpp 复制代码
virtual ~Base() {
        cout << "~Base()" << endl;
    }

为什么会这样呢?原因如下:

因为在C++中,通过基类指针删除派生类对象时,编译器只知道指针的类型是基类,因此只会调用基类的析构函数。如果基类的析构函数不是虚函数,那么编译器无法在运行时动态绑定到正确的派生类析构函数上。

通过将基类的析构函数声明为虚函数,我们告诉编译器在删除对象时动态绑定到正确的析构函数上。这样,当我们使用基类指针删除派生类对象时,会调用派生类的析构函数,确保派生类对象的资源被正确释放。

虚析构函数小结

因此,为了正确地释放派生类对象的资源,当我们使用基类指针指向派生类对象时,基类的析构函数通常应该声明为虚函数。这是一种良好的实践,以确保在删除对象时调用正确的析构函数,避免内存泄漏和未定义行为。

多态的实际应用的案例

多态案例

对上面的demo稍加改造,新增一个派生类Cloth,也是重写discount方法

cpp 复制代码
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;
    }
};

再加一个新的接口,传入一个基类对象的引用,就能调用虚函数

cpp 复制代码
//获取商品实际售卖价格
double getSalePrice(Product &product) {
    return product.discount();
}

main方法中,基类指针分别指向了2个派生类对象,然后就能调用派生类中重写的虚函数了。

cpp 复制代码
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 ,而且没有函数体。

cpp 复制代码
class AbstractClass {
public:
    virtual void pureVirtualFunction() = 0; //纯虚函数的申明
};

和虚函数类似,纯虚函数也是要在派生类中重写。说到这里,有人就好奇了,那两者有什么区别呢?先来看虚函数,通常在基类中定义的虚函数是可以有自己的实现的,也能被派生类重写;再看纯虚函数,它在基类中只能申明,并且不能有函数体(语法就是这样),纯虚函数在子类中应该被重写,但是我更喜欢称这种方式为实现,因为java中类似的对接口方法和抽象方法都被叫做实现。 注意这里说的是应该,而不是必须,原因后面解释。

什么是抽象类

只要这个类中至少有一个纯虚函数,那它就是抽象类。 抽象类不能被实例化,只能被用作其他类的基类。如果一个类继承自抽象类,它必须实现基类中的所有纯虚函数,否则它也会成为一个抽象类。

现在来看,c++中的抽象类和java的抽象类在定义上没什么区别,c++中的纯虚函数不就是java中的抽象函数吗?所以抽象类除了有纯虚函数外,还可以拥有普通函数。

下面是个商品打折的场景,在基类Product中申明了纯虚函数discount计算商品打折后的价格,由于不同的商品折扣不一样,所以需要每个派生类去实现这个纯虚函数。

cpp 复制代码
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方法

cpp 复制代码
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

抽象类小结:

  1. c++中的抽象类不需要用abstract关键字申明
  2. 类中至少包含一个纯虚函数(通过在函数声明后面加上 = 0 来声明)。
  3. 抽象类不能被实例化,只能用作其他类的基类。

最佳实践

1.可以通过抽象类指针指向派生类,实现多态

ini 复制代码
int main() {
    Product *p1 = new Book("一千零一夜", 30);
    Product *p2 = new Cloth("海澜之家", 500);
    delete p1;
    delete p2;

    return 0;
}

2.在实际开发中,类的申明在头文件,定义在源文件。那抽象类和派生类该如何做呢?

在抽象类Abstractclass.h头文件中

csharp 复制代码
class Abstractclass {
    virtual void pureVirtualFunc() = 0;
};

派生类DeriveClass.h(头文件)

arduino 复制代码
#include "Abstractclass.h"

class DeriveClass : public Abstractclass {
public:
    void pureVirtualFunc() override;
};

DeriveClass.cpp(源文件)

arduino 复制代码
#include "derive1.h"  
  
using namespace std;  
  
void DeriveClass::pureVirtualFunc() {  
    cout << "DeriveClass: pureVirtualFunc()";  
}
相关推荐
捕鲸叉3 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer3 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq3 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
青花瓷4 小时前
C++__XCode工程中Debug版本库向Release版本库的切换
c++·xcode
幺零九零零6 小时前
【C++】socket套接字编程
linux·服务器·网络·c++
捕鲸叉6 小时前
MVC(Model-View-Controller)模式概述
开发语言·c++·设计模式
Dola_Pan7 小时前
C++算法和竞赛:哈希算法、动态规划DP算法、贪心算法、博弈算法
c++·算法·哈希算法
yanlou2337 小时前
KMP算法,next数组详解(c++)
开发语言·c++·kmp算法
小林熬夜学编程7 小时前
【Linux系统编程】第四十一弹---线程深度解析:从地址空间到多线程实践
linux·c语言·开发语言·c++·算法
阿洵Rain8 小时前
【C++】哈希
数据结构·c++·算法·list·哈希算法