C++进阶02 多态性

听课笔记简单整理,供小伙伴们参考~🥝🥝

  • 第1版:听课的记录代码~🧩🧩

**编辑:**梅头脑🌸

**审核:**文心一言


目录

🐳课程来源

🐳前言

🐋运算符重载

[🐋8.1 双目运算符:复数类加减法运算](#🐋8.1 双目运算符:复数类加减法运算)

[🐋8.2 单目运算符:++时钟类成员函数](#🐋8.2 单目运算符:++时钟类成员函数)

[🐋8.3 输出运算符](#🐋8.3 输出运算符)

🐋虚函数

[🐋8.4 虚函数](#🐋8.4 虚函数)

[🐋8.5 析构虚拟函数](#🐋8.5 析构虚拟函数)

🐳抽象类

[🐋8.6 抽象类](#🐋8.6 抽象类)

🐳override与final

[🐋8.7 override](#🐋8.7 override)

[🐋8.8 final](#🐋8.8 final)

🔚结语


🐳课程来源

🐳前言

📇相关概念

多态性是C++中一个非常重要的概念,它允许我们使用共同的接口来处理不同类型的对象。这让我想起了刚刚在短视频里看到的海洋魔术师------拟态章鱼🐙。这只戏精章鱼可以根据环境和需要,通过伪装来变化自己的形态,从而有效地躲避捕食者。

在C++编程中,多态性也展现了类似的特点。它允许我们根据程序的实际需求,以多种形态或方式来执行同一功能。这主要是通过虚函数、继承和动态绑定等机制来实现的。

  • 虚函数:多态性的实现基础之一。通过在基类中声明虚函数,并在派生类中重写这个函数,我们可以实现当通过基类指针或引用调用该函数时,实际执行的是派生类中的函数版本;
  • 继承:通过继承,派生类不仅可以继承基类的数据和函数,还可以重写基类的虚函数以实现特定的功能或行为;
  • 动态绑定:在使用基类指针或引用调用虚函数时,程序不会在编译时确定函数调用的目标,而是在运行时根据对象的实际类型进行绑定。这样,就可以确保调用的是正确版本的函数。

多态性不仅提高了代码的灵活性和可重用性,还使得程序能够更加模块化和易于维护。它是面向对象编程中的一个核心概念,为我们提供了一种高效、灵活的处理不同对象的方式。

此外,本节课还有一些关于函数重载的内容~

  • 函数重载:允许我们为同一个函数名定义多个版本,每个版本接受不同类型或数量的参数。例如,我们可以为类设计一个 ++ 运算符的重载版本,使时钟的秒针增加一秒~

拟态章鱼,一只庞大的戏精

🐋运算符重载

这里的内容主要是关于怎么为我们的类设计运算符,使其实现我们想要的功能~

🐋8.1 双目运算符:复数类加减法运算

在写代码时,我们发现加减法通常只能作用于实数。如果需要虚数做加减法怎么办呢?没错,自己写一个加法。注意,重载运算符的话,函数名的结构为:

类名 operator 运算符(参数类型,参数名)

注意:

  • 如果运算符为双目运算符(运算需要两个操作数),那么参数名写一个,因为默认第一个操作数是我们指定类名的对象;
  • 如果运算符为单目运算符,那么参数名可以空着不写;

以下是个简单的例子,为Complex类重载了+-运算符,使它们能够用于复数对象的加法和减法~

⌨️复数与复数的加减法代码

cpp 复制代码
#include <iostream>
using namespace std;
class Complex {
public:
    Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) { }
    //运算符+重载成员函数
    Complex operator + (const Complex& c2) const;
    //运算符-重载成员函数
    Complex operator - (const Complex& c2) const;
    void display() const;   //输出复数
private:
    double real;    //复数实部
    double imag;    //复数虚部
};

Complex Complex::operator+(const Complex & c2) const {
    //创建一个临时无名对象作为返回值 ,当前对象的实部与参数的实部相加,当前对象的虚部与参数的虚部相加~
    return Complex(real + c2.real, imag + c2.imag);
}

Complex Complex::operator-(const Complex& c2) const {
    //创建一个临时无名对象作为返回值
    return Complex(real - c2.real, imag - c2.imag);
}

void Complex::display() const {
    cout << "(" << real << ", " << imag << ")" << endl;
}

int main() {
    Complex c1(5, 4), c2(2, 10), c3;
    cout << "c1 = "; c1.display();
    cout << "c2 = "; c2.display();
    c3 = c1 - c2;   //使用重载运算符完成复数减法
    cout << "c3 = c1 - c2 = "; c3.display();
    c3 = c1 + c2;   //使用重载运算符完成复数加法
    cout << "c3 = c1 + c2 = "; c3.display();
    return 0;
}

📇执行结果

那如果想实现实数与复数相加减,可能需要考虑 实数在前,实数在后的两种情况。

  • 实数在后:与类相加的情况是类似的,把第二个操作数改为 int 即可运行;
  • 实数在前:把第一个操作数指定为 int,第二个操作数 指定为类,是不可行的(除非,是将虚数隐式地转化为实数加减法),编译器会报错:它很不讲道理,认为第一个操作数就应该是类;这样与我们的需求就会有冲突,所以需要将重载函数实现为非成员函数(友元函数)。

综合以上情况,我们可以写这样的代码~

⌨️复数与实数的加法代码

cpp 复制代码
#include <iostream>  
using namespace std;

class Complex {
public:
    Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}

    // 复数与复数相加  
    Complex operator+(const Complex& c2) const;
    // 复数与整数相加  
    Complex operator+(const int x) const;
    // 输出复数  
    void display() const;

    // 友元函数:整数与复数相加  
    friend Complex operator+(const int x, const Complex& c);

private:
    double real;    // 复数实部  
    double imag;    // 复数虚部  
};

// 复数与复数相加的实现  
Complex Complex::operator+(const Complex& c) const {
    return Complex(real + c.real, imag + c.imag);
}

// 复数与整数相加的实现  
Complex Complex::operator+(const int x) const {
    return Complex(real + x, imag);
}

// 整数与复数相加的友元函数实现  
Complex operator+(const int x, const Complex& c) {
    return Complex(x + c.real, c.imag);
}

// 输出复数的实现  
void Complex::display() const {
    cout << "(" << real << ", " << imag << ")" << endl;
}

int main() {
    Complex c1(5, 4), c2(2, 10), c3;
    int x = 3;
    int y = 5;
    cout << "c1 = ";
    c1.display();
    cout << "c2 = ";
    c2.display();
    c3 = c1 + c2;  // 复数与复数相加  
    cout << "c3 = c1 + c2 = ";
    c3.display();
    c3 = c1 + x;   // 复数与整数相加  
    cout << "c3 = c1 + " << x << " = ";
    c3.display();
    c3 = y + c2;   // 整数与复数相加  
    cout << "c3 = " << y << " + c2 = ";
    c3.display();
    return 0;
}

📇执行结果

🐋8.2 单目运算符:++时钟类成员函数

⌨️8.2 重载前置++和后置++为时钟类成员函数代码

cpp 复制代码
#include <iostream>
using namespace std;

class Clock {//时钟类定义
public:
    Clock(int hour = 0, int minute = 0, int second = 0);
    void showTime() const;
    //前置单目运算符重载
    Clock& operator ++ ();
    //后置单目运算符重载,名称无法与前置区分,因此需要通过 形参int 区分两个函数 
    Clock operator ++ (int);
private:
    int hour, minute, second;
};

Clock::Clock(int hour, int minute, int second) {
    if (0 <= hour && hour < 24 && 0 <= minute && minute < 60
        && 0 <= second && second < 60) {
        this->hour = hour;
        this->minute = minute;
        this->second = second;
    }
    else
        cout << "Time error!" << endl;
}
void Clock::showTime() const {  //显示时间
    cout << hour << ":" << minute << ":" << second << endl;
}

Clock & Clock::operator ++ () {
    second++;
    if (second >= 60) {
        second -= 60;  minute++;
        if (minute >= 60) {
            minute -= 60; hour = (hour + 1) % 24;
        }
    }
    return *this;   // 返回自己,因为前置++ 的功能为先自增,后赋值;
}

Clock Clock::operator ++ (int) {
    //注意形参表中的整型参数
    Clock old = *this;
    ++(*this);  //调用前置"++"运算符,统一两边的算法;但返回的是old,也就是也复制,后自增;
    return old;
}

int main() {
    Clock myClock(23, 59, 59);
    cout << "First time output: ";
    myClock.showTime();
    cout << "Show myClock++:    ";
    (myClock++).showTime();
    cout << "Show ++myClock:    ";
    (++myClock).showTime();
    return 0;
}

📇执行结果

📇代码说明

以上代码实现了增加1秒钟的运算~

前置单目运算符:

  • 重载函数没有形参;
  • 秒钟自增,若满足进位条件就开始进位计算;
  • 最后返回自己的指针;

后置单目运算符:

  • 重载函数需要有一个int形参(若不与前置单目运算符在参数表有区分,编译器会完全不知道该执行哪个...);
  • 记录自己现在的值;
  • 调用前置单目运算符,完成自增的同时实现了代码重用,方便管理;
  • 但是返回的值,是自增前的值。而内存存储的实际值,是自增后的值;

因此,在结果上:

  • 先执行后置自增,返回23,59,59,内存的实际值0,0,0;
  • 再执行前置自增,返回0,0,1,内存的实际值0,0,1;

🐋8.3 输出运算符

在执行复数的加减法时,我们使用成员函数display来显示复数的值,如下所示:

cpp 复制代码
void Complex::display() const {
    cout << "(" << real << ", " << imag << ")" << endl;
}

然而,如果我们认为每次调用display()函数都比较麻烦,我们也可以实现类的级联输出,它允许我们连续输出多个对象而不需要中断。

为了实现级联输出,我们需要重载"<<"运算符。重载后的"<<"运算符将返回一个ostream对象,这样它就可以被连续调用,从而实现级联输出。

在本例中,我们重载了"<<"运算符,使其能够输出Complex对象,就像这样:

cpp 复制代码
ostream& operator<<(ostream& out, const Complex& c) {  
    // 通过重载<<运算符,我们实现了Complex对象的级联输出  
    out << "(" << c.real << ", " << c.imag << ")"; // 输出复数的实部和虚部  
    return out; // 返回ostream对象以实现连续调用  
}

因此,我们通常将<<运算符重载为全局函数,并将其声明为类的友元函数,以便访问类的私有成员,而无需通过类的公共接口。这就是级联输出通常不作为成员函数实现的原因。

⌨️输出运算符代码

cpp 复制代码
#include <iostream>
using namespace std;

class Complex {
public:
    Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) { }
    friend Complex operator+(const Complex& c1, const Complex& c2); // 不再是类的成员函数,而是一个全局函数
    friend Complex operator-(const Complex& c1, const Complex& c2);
    friend ostream& operator<<(ostream& out, const Complex& c);
private:
    double real;  //复数实部
    double imag;  //复数虚部
};

Complex operator+(const Complex& c1, const Complex& c2) {
    return Complex(c1.real + c2.real, c1.imag + c2.imag);   // 实部与虚部相加减,返回临时无名对象
}
Complex operator-(const Complex& c1, const Complex& c2) {
    return Complex(c1.real - c2.real, c1.imag - c2.imag);
}

ostream& operator<<(ostream& out, const Complex& c) {       // 通过ostream,重载输出运算符,使之级联输出
    out << "(" << c.real << ", " << c.imag << ")";
    return out;
}

int main() {
    Complex c1(5, 4), c2(2, 10), c3;
    cout << "c1 = " << c1 << endl;
    cout << "c2 = " << c2 << endl;
    c3 = c1 - c2;   //使用重载运算符完成复数减法
    cout << "c3 = c1 - c2 = " << c3 << endl;
    c3 = c1 + c2;   //使用重载运算符完成复数加法
    cout << "c3 = c1 + c2 = " << c3 << endl;
    return 0;
}

📇执行结果

🐋虚函数

🐋8.4 虚函数

关于虚继承,这个之前我们有介绍过,篇幅很长就不再赘述了,感兴趣可以看向这里~

🌸C++进阶01 继承与派生-CSDN博客

当类声明了虚函数时,编译器会为该类生成一个虚函数表(vtable),表中包含指向虚函数实现的指针。类的实例中则包含一个指向这个虚函数表的指针(通常称为vptr)。在运行时,当通过基类的指针或引用调用虚函数时,会根据vptr来查找虚函数表,并进而调用与对象实际类型相匹配的函数实现。

虚函数的主要用途是实现动态绑定(dynamic binding),这样程序就能在运行时根据对象的实际类型来确定应该调用哪个函数实现。在涉及多继承的复杂情况中,虚函数表和相关的调用机制确保始终调用正确的函数,避免了因继承结构而导致的调用歧义。

🐋8.5 析构虚拟函数

大多数情况下,编译器自动生成的析构函数就足够用了。但是,有时我们需要自己定义一个析构函数,特别是在涉及到动态内存分配或者需要执行一些特殊清理任务的时候~

⌨️没有析构虚函数的代码

cpp 复制代码
#include <iostream>
using namespace std;
class Base {
public:
	Base();
    ~Base(); //不是虚函数
};
Base::Base() {
	cout << "Base constructor" << endl;
}
Base::~Base() {
    cout << "Base destructor" << endl;
}
class Derived : public Base {
public:
    Derived();
    ~Derived(); //不是虚函数
private:
    int* p;
};
Derived::Derived() {
	cout << "Derived constructor" << endl;
	p = new int(0);
}
Derived::~Derived() {
	cout << "Derived destructor" << endl;
	delete p;
}
void func(Base* b) {
	delete b;   // 静态绑定,只会调用Base的析构函数,不会调用Derived的析构函数
}
int main() {
	Base* b = new Derived();
	func(b);
	return 0;
}

📇执行结果

📇代码解释

通过本行代码"Base* b = new Derived();",我们创造了一个Derived对象,并通过一个Base类的指针来指向它。然而,在销毁这个对象的时候,只有Base类的析构函数被调用,而Derived类的析构函数却没有被调用。这意味着Derived对象中动态分配的内存(通过new操作符分配的int)没有被正确释放,从而导致了内存泄漏。

⌨️含有析构虚函数的代码

cpp 复制代码
#include <iostream>
using namespace std;
class Base {
public:
    virtual ~Base();	// 虚析构函数
};
Base::~Base() {
    cout << "Base destructor" << endl;
}
class Derived : public Base {
public:
    Derived();
    virtual ~Derived();
private:
    int* p;
};
Derived::Derived() {
	p = new int(0);
}
Derived::~Derived() {
	cout << "Derived destructor" << endl;
	delete p;
}
void func(Base* b) {
	delete b;			 // 动态绑定,会调用Derived的析构函数
}
int main() {
	Base* b = new Derived();
	func(b);
	return 0;
}

📇执行结果

当我们在基类中将析构函数声明为虚函数时,就可以确保在删除指向派生类对象的基类指针时,首先调用派生类的析构函数,然后调用基类的析构函数。这样可以避免内存泄漏和其他潜在的清理问题。

通过引入虚析构函数,我们可以确保动态绑定的正确执行,即在运行时确定应该调用哪个类的析构函数。这就像为基类指针提供了一个"哆啦A梦的传送门",让它能够正确地找到并销毁派生类对象。


🐳抽象类

🐋8.6 抽象类

📇相关概念

还有一些时候,我们的基类不想定义具体的类。例如,动物园里有狮子、熊猫、猴子等,我们可以想象到狮子、熊猫、猴子是什么样子的,他们都属于动物。但是说到动物这个很抽象的名词,就很难想得到它长什么样子,它是一种泛指,也就类似于我们的抽象基类。

抽象基类的实现如下,我们通过代码展示如何创建抽象类,以及如何通过继承调用派生类的函数~

⌨️代码

cpp 复制代码
#include <iostream>
using namespace std;

class Base1 {
public:
    virtual void display() const = 0;   // 抽象类,纯虚函数,不能定义对象,只能通过派生类的对象调用
};

class Base2 : public Base1 {
public:
    virtual void display() const;       // 覆盖基类的虚函数,可以定义对象
};
void Base2::display() const {
    cout << "Base2::display()" << endl;
}

class Derived : public Base2 {
public:
    virtual void display() const;       // 覆盖基类的虚函数,可以定义对象
};
void Derived::display() const {
    cout << "Derived::display()" << endl;
}
void fun(Base1* ptr) {
    ptr->display();
}
int main() {
    Base2 base2;
    Derived derived;
    fun(&base2);
    fun(&derived);
    return 0;
}

📇执行结果

  • 抽象类 :包含一个或多个纯虚函数的类被称为抽象类。纯虚函数是在基类中声明但没有实现的虚函数,其声明形式为 virtual 函数类型 函数名(参数列表) = 0;。抽象类不能被直接实例化来创建对象。在例子中,Base1 是一个抽象类,因为它有一个纯虚函数 display()

  • 继承与实现 :其他类可以通过继承抽象类来成为它的派生类,并提供纯虚函数的实现。在例子中,Base2Derived 都继承了 Base1 并实现了 display() 函数,因此它们都可以被实例化。

  • 多态性 :通过基类的指针或引用调用虚函数时,会调用相应对象实际类型的虚函数实现,这就是多态性。在例子中,fun() 函数接受一个指向 Base1 的指针作为参数,并调用其 display() 函数。由于 Base2Derived 都提供了这个函数的实现,并且都继承自 Base1,所以可以传入指向这两个类中任何一个的指针,fun() 函数都会正确地调用相应的 display() 函数实现。这就是多态性的一个例子。


🐳override与final

🐋8.7 override

📇相关概念

有的时候,我们自己想写一个派生类实现对基类的覆盖,但是往往差那么一点点,例如只差了一个const,结果导致编译器把两个同名函数认为成两个不同的函数。这类型错误不属于编译型错误,很难排查。

这个时候就可以借助override,在写代码的时候拜托编译器帮助我们检查一下,有没有正确覆盖同名基类的函数,没有的话他就会提醒我们语法可能有问题,保证我们可以正确地实现多态性~

⌨️错误代码

cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
    virtual void f1(int) const;
    virtual ~Base() { };
};

void Base::f1(int) const {
	cout << "Base::f1" << endl;
    return;
}

class Derived : public Base {
public:
    void f1(int);   // 错误:是否含有const会影响覆盖,void f1(int); 与 void f1(int) const于不同的函数,影响调用的多态性
	~Derived() { };
};

void Derived::f1(int) {
	cout << "Derived::f1" << endl;
	return;
}

int main()
{
    Base *b;
	b = new Base;
	b->f1(1);
	b = new Derived;
	b->f1(1);
	return 0;
}

📇执行结果

两次都调用了基类函数,而非调用一个基类函数,一个派生类函数。emm...这是因为编译器还没办法区分"virtual void f1(int) const;"与"void f1(int);",因为差一个const,所以编译器认为,写f1()的去找基类,写f1 const()的才可以找派生类。

⌨️正确代码

cpp 复制代码
#include <iostream>
using namespace std;

class Base {
public:
	virtual void f1(int) const;
	virtual ~Base() { };
};

void Base::f1(int) const {
	cout << "Base::f1" << endl;
	return;
}

class Derived : public Base {
public:
	void f1(int) const override;  // 正确,使用override关键字可以检查是否覆盖了基类的虚函数
	~Derived() { };
};

void Derived::f1(int) const{
	cout << "Derived::f1" << endl;
	return;
}

int main()
{
	Base* b;
	b = new Base;
	b->f1(1);
	b = new Derived;
	b->f1(1);
	return 0;
}

📇执行结果

成功输出了一个基类一个派生类,实现了多态化。

**override 关键字:**这是 C++11 引入的一个特性,用于显式地指出派生类中的成员函数意图覆盖基类中的虚函数。这样做有两个好处:

  1. 它使你的意图更清晰,提高了代码的可读性。
  2. 编译器会检查你是否正确地覆盖了基类中的虚函数。如果你声明了一个与基类虚函数不匹配(函数签名不同)的成员函数,并试图使用 override 关键字,编译器会报错。

🐋8.8final

📇相关概念

当你觉得你这个代码不想被别人修改使用(无论是因为代码本身比较重要,亦或是因为你本人比较任性),那么可以用final,防止别人继承自己的类或虚函数~

⌨️代码

cpp 复制代码
// 8.8 final.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。

struct Base1 final {};

struct Derived1 : Base1 {}; // 类继承编译错误:Base1 已经被声明为 final,不能被继承

struct Base2 {
	virtual void f() final;
};

struct Derived2 : Base2 {
	void f() override;	    // 函数继承编译错误:Base2::f 已经被声明为 final,不能被覆盖
};

🔚结语

博文到此结束了,写得模糊或者有误之处,期待小伙伴留言讨论与批评,督促博主优化内容{例如有错误、难理解、不简洁、缺功能}等,博主会顶锅前来修改~~😶‍🌫️😶‍🌫️

我是梅头脑,本片博文若有帮助,欢迎小伙伴动动可爱的小手默默给个赞支持一下,感谢点赞小伙伴对于博主的支持~~🌟🌟

同系列的博文: 🌸数据结构_梅头脑_的博客-CSDN博客

同博主的博文: 🌸随笔03 笔记整理-CSDN博客

相关推荐
JSU_曾是此间年少11 分钟前
数据结构——线性表与链表
数据结构·c++·算法
许野平24 分钟前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
也无晴也无风雨27 分钟前
在JS中, 0 == [0] 吗
开发语言·javascript
狂奔solar36 分钟前
yelp数据集上识别潜在的热门商家
开发语言·python
密码小丑1 小时前
11月4日(内网横向移动(一))
笔记
此生只爱蛋1 小时前
【手撕排序2】快速排序
c语言·c++·算法·排序算法
blammmp1 小时前
Java:数据结构-枚举
java·开发语言·数据结构
何曾参静谧1 小时前
「C/C++」C/C++ 指针篇 之 指针运算
c语言·开发语言·c++
暗黑起源喵2 小时前
设计模式-工厂设计模式
java·开发语言·设计模式
鸭鸭梨吖2 小时前
产品经理笔记
笔记·产品经理