【C++】4、多态

一、虚函数

1、虚函数的概念

在类成员函数之前加上 virtual 关键字修饰,使对应函数存储在虚表当中,调用过程中由虚表指针在虚表中查询对应的虚函数进行执行,以更好实现覆盖和重写操作。

2、虚函数的语法

若是直接在类中定义,则:virtual 数据类型 函数名(参数列表){函数体}

若是在类中声明,类外定义,则在类外定义的时候可以省略掉 virtual

virtual 数据类型 函数名(参数列表);
数据类型 函数名(参数列表){函数体}

3、虚函数的特点

  • 若一个类中包含虚函数,那么这个类的对象会包含一个虚表指针 vper ,来指向程序中原本存放数据的内存(栈中对应的位置)。存放虚指针的是一个数组,我们称之为虚表,显然,虚表是一个函数数组指针,由于虚表指针仍然是指针,所以其大小还是8个字节
  • 虚表指针保存在空间对象的最前面,然后再存放数据
  • 虚表中存储的都是类中虚函数的地址
  • 类对象调用虚函数的时候,会通过虚表指针选择函数地址然后自动执行函数

4、虚函数在继承中的使用

(1)、虚表中同名函数覆盖

在没有虚函数的情况下,对象调用的是自己类的对应函数

在采用虚函数后,有了虚表的存在,后进的同名函数覆盖掉了之前的,对象指针调用的是最新的同名函数

#include <iostream>

using namespace std;

class Base
{
public:
    Base()
    {
        cout << "Base()\n";
    }
    ~Base()
    {
        cout << "~Base()\n";
    }

    // 普通函数
    void info()
    {
        cout << "Base::info()\n";
    }


    // 虚函数
    virtual void show()
    {
        cout << "Base::show()\n";
    }

};

class D : public Base
{
public:
    D()
    {
        cout << "D()\n";
    }
    ~D()
    {
        cout << "~D()\n";
    }

    // 普通函数
    void info()
    {
        cout << "D::info()\n";
    }

    // 派生类实现同名接口
    void show()
    {
        cout << "D::show()\n";
    }

};

class E : public Base
{
public:
    E()
    {
        cout << "E()\n";
    }
    ~E()
    {
        cout << "~E()\n";
    }

    // 普通函数
    void info()
    {
        cout << "E::info()\n";
    }

    // 派生类实现同名接口
    void show()
    {
        cout << "E::show()\n";
    }

};

int main()
{
    Base base;
    base.info();

    D d;
    d.info();

    E e;
    e.info();

    // 将派生类对象赋值给基类指针-1
    Base* per = &d;
    cout << endl << endl;
    cout << "---\n";
    per->info();
    cout << "---\n";
    per->show();
    cout << "---\n\n\n\n\n\n";

    // 将派生类对象赋值给基类指针-2
    Base* desk = &e;
    cout << "---\n";
    desk->info();
    cout << "---\n";
    desk->show();
    cout << "---\n";

    return 0;
}

对于这里的 Base* per = &d;,意思是基类指针指向了派生类对象,但由运行结果来反推其过程,此处其实是基类指针指向了派生类对象中继承自基类的那一部分,当那一部分的虚函数由于贯穿于基类派生类的虚表中的同名函数被重写刷新时,才会显现出属于派生类的那一部分属性。

当基类指针指向派生类对象时,它只能访问和操作派生类对象中继承自基类的部分。这意味着通过基类指针,只能访问派生类对象中基类的成员变量和成员函数,而不能访问派生类特有的成员变量和成员函数。

在运行时,当基类指针通过虚函数调用时,会根据对象的动态类型确定调用的函数。如果派生类重写了基类的虚函数,那么通过基类指针调用该虚函数时将调用派生类中的重写函数。

因此,基类指针可以用于实现多态性,通过基类指针调用虚函数时,最终调用的是指向的派生类对象中对应的重写函数。


    // 将派生类对象赋值给基类指针-1
    Base* per = &d;
    cout << endl << endl;
    cout << "---\n";
    per->info();
    cout << "---\n";
    per->show();
    cout << "---\n\n\n\n\n\n";

这段代码展示了将派生类对象赋值给基类指针,并通过该指针调用成员函数的过程。

首先,将派生类 D 的对象 d 的地址赋值给基类指针 per,即 Base* per = &d;。这样做是合法的,因为派生类对象可以被当作基类对象来处理。

然后,通过 per 指针调用成员函数 info()show()

  • per->info() 调用的是基类 Baseinfo() 函数,因为 info() 不是虚函数,调用的是指针所指向的对象的静态类型的函数版本。在这种情况下,即使 per 指针指向的是派生类对象,仍然会调用基类的函数。
  • per->show() 调用的是派生类 Dshow() 函数,因为 show() 是一个虚函数。虚函数根据对象的动态类型来确定调用哪个版本的函数,在这种情况下,根据 per 指针指向的派生类对象的动态类型,即 D 类,调用了 D 类的 show() 函数。

(2)、虚表的继承

虚函数使用在继承过程中,若在基类里面声明了virtual函数,在继承的派生类中,无论有没有virtual函数声明,那么在派生类中也会存在一个同原型(相同函数名、相同参数列表、相同返回值)的virtual接口。

下面我对此进行验证:

首先我在基类中写一个虚函数而不在派生类中写,然后用派生类的对象去尝试调用基类中的虚函数,发现可行:

#include <iostream>

using namespace std;

class A
{
public:
    A()
    {
        cout << "A()\n";
    }
    ~A()
    {
        cout << "~A()\n";
    }

    void show()
    {
        cout << "show()\n";
    }

    virtual void info()
    {
        cout << "vir-info()\n";
    }

};

class B: public A
{
public:
    B()
    {
        cout << "B()\n";
    }
    ~B()
    {
        cout << "~B()\n";
    }

};


int main()
{
    B b;
    b.show();
    b.info();
    return 0;
}

发现这样是可行的,正好讲师教了我们最基础的debug,我们debug看看:

然而我过于菜,在这似乎也不能看出些啥来,采用录屏上的方法,在A和B中多写几个虚函数然后debug,如果上个虚表指针是指向B的,在B中新加的虚函数应该会和我新加的在一起。

但结果跟我的猜测还是有出入的,结合录屏内容,得出结论:继承是把虚表给一起继承的

而且,这种继承本身算是一种缺省,当我们重新初始化其中的虚函数,会产生覆盖,这种情况被我们称之为重写。

5、虚函数的一些tips

(1)、构造函数不可以被声明为虚函数

参考自博客:https://blog.51cto.com/u_12204415/3804254,下面的解释几乎就是直接搬的这个博客里的

首先进行一下尝试,可以看到,编译器给出的结果是最直观的,我们给构造函数加了virtual直接就报错了,而加在析构函数上没有事情。

这自然不是没有原因的,相反,原因还真不少:

  1. 对于含有虚函数的类来说,它会有一个虚函数表(vtable),存储了指向虚函数的指针。虚函数表存在于对象的内存空间内,对应着每个对象的动态类型。虚函数的调用会使用虚函数表来定位并调用正确的函数。

  2. 如果构造函数是虚的,那么在创建对象时系统是无法直接调用构造函数的。这是因为构造函数在对象创建的过程中被调用,而对象的类型直到构造函数执行完毕后才确定。因此,构造函数不能是虚函数。

  3. 关于析构函数,它可以是虚函数。虚析构函数的作用是确保在通过基类指针删除派生类对象时,会正确调用派生类的析构函数以释放资源。虚析构函数在析构对象时的多态行为与虚函数类似。

  4. 虚函数的调用是动态联编(运行时多态)。它是通过父类的指针或引用调用虚函数时,根据对象的实际类型来确定调用哪个函数。在创建一个对象时,构造函数的执行过程中,对象的实际类型尚未确定,因此无法使用动态联编来调用虚函数。

(2)、析构函数可以被声明为虚函数

虚析构还是很有必要的,在下半部分的时候我写了一些情况,在这里只说一下结论:

析构函数最好定义为虚析构,如果不定义为虚析构,那么就只会销毁基类的对象,派生类就不管了,无法对其析构,这样就会造成内存泄漏,是很危险的,

(3)、虚函数是非静态成员函数

我们在学习this指针的时候就知道,静态变量的优先级是大于对象的,即在对象创建之前静态变量就已经存在,而虚表储存在对象的内存空间中,也就是说,静态变量在虚函数之前存在,那么,虚函数自然只能是非静态成员函数。

(4)、内联成员函数不能声明为虚函数

因为内联函数的执行代码是确定的,不能被改变

简单回忆一下内联函数:当编译器发现某段代码在调用一个内联函数时,它不是去调用该函数,而是将该函数的代码,整段插入到当前位置。

二、多态

讲师的笔记过于凌乱,下面的内容参照引用了博客:
https://www.jb51.net/article/194529.htm

1、多态的概念

同一个操作可以作用于不同的操作对象,可以有不同的解释,产生不同的结果,在运行的时候,通过基类指针或者引用,来调用派生类里面实现的覆盖虚函数。

多态分为静态多态和动态多态,函数重载就是属于静态多态,在编译时就可以确定使用的接口,而运行时多态是动态多态,具体引用的接口在运行时才能确定。

2、多态的作用

封装可以使得代码模块化,继承可以扩展已存在的代码,他们的目的都是为了代码复用。

而多态的目的差不多,只是具体到了具体的函数上,为的是接口复用。静态多态将同一个接口进行不同的实现,根据传入不同的参数列表,来实现调用的不同实现。而动态多态,无论传递进来的是哪个类的对象,函数都能通过同一个接口调用到各自对象。

3、静态多态

这里随便举一个例子:

#include <iostream>

using namespace std;

class A
{
    int a;
public:
    A()
    {
        cout << "A()_1\n";
    }
    A(int a): a(a)
    {
        cout << "A()_2\n";
    }
    ~A()
    {
        cout << "~A()\n";
    }

};

int main()
{
    A q1, q2(11);

    return 0;
}

4、动态多态

动态多态性利用了继承和虚函数的特性。为了实现动态多态性,我们需要确保完成以下两个步骤:

  1. 在派生类中对基类中具有相同原型的虚函数接口进行重写(覆盖)操作,以提供针对派生类对象的特定实现。
  2. 通过基类指针或引用来调用虚函数,实现对实际对象类型的动态绑定(多态)。这样,在运行时根据实际对象的类型确定调用的是派生类中的虚函数实现。

请注意,虚函数必须在基类中声明,以便在派生类中进行重写。


这里首先解释一下基类指针,指针在后,说明实质上是一个指针,叫做基类指针,即指向基类的指针,参考一下结构体指针(指向结构体变量),基类指针自然不是针对基类本身的,而是针对基类的对象的,

#include <iostream>

using namespace std;

class A
{
public:
    A()
    {
        cout << "A()_1\n";
    }
    ~A()
    {
        cout << "~A()\n";
    }

    virtual void show()
    {
        cout << "A::show()\n";
    }

};

class B: public A
{
public:
    B()
    {
        cout << "B()_1\n";
    }
    ~B()
    {
        cout << "~B()\n";
    }

    void show()
    {
        cout << "B::show()\n";
    }

};

int main()
{
    cout << "\n\n-----\n";
    A* a1 = new B;
    a1->show();

    cout << "\n\n-----\n";
    B b;
    A* a2 = &b;
    a2->show();

    cout << "\n\n-----\n";
    A& a3 = b;
    a3.show();

    cout << "\n\n-----\n";
    return 0;
}


A* a1 = new B;

创建指向基类的指针 a1,并通过new B创建了派生类的对象,并将其赋值给指针 a1。这是一种多态的体现,因为派生类的对象可以赋值给基类的指针。

通过指针 a1 调用虚函数 show(),根据多态的原则,将根据实际对象的类型来确定调用的是基类中的函数还是派生类中的函数。在这里,输出的字符串是 B::show()


B b;
A* a2 = &b;

创建派生类 B 的对象 b,并创建指向基类 A 的指针 a2,将指针 a2 指向对象 b。同样地,通过指针 a2 调用虚函数 show(),此时输出的字符串是 "B::show()".

还要注意的是,在代码中的&代表的取地址,没想到吧,指针指向一个对象还要取地址哈哈哈。


A& a3 = b;
a3.show();

创建基类 A 的引用 a3,将其引用对象 b。通过引用 a3 调用虚函数 show(),同样输出的字符串是 "B::show()"

5、纯虚函数

参考博客:https://zhuanlan.zhihu.com/p/270614081,写的很好理解,我下面的部分会有一大部分解释摘自此博客

(1)、如何理解纯虚函数

首先需要明白,定义一个函数为虚函数,不代表函数为不被实现的函数,而是为了允许用基类指针调用派生类继承下来的的这个虚函数

相对的,定义一个函数纯虚函数,才代表函数没有被实现,因为我们定义纯虚函数的意义仅仅在于实现一个接口,很大程度上不是因为功能上的原因,反而更像是对代码的优化,提高了可读性。

如何理解这里的优化呢?上面的那篇博客,沐歌老哥举的一个例子我觉得还是很不错的:动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理
,就拿这个例子来说,专门从动物这个基类中抓出一直叫大蛇丸的蛇还是有些不合适的,若是专门从蛇这个派生类中去找,显然合理一些,蛇也属于一个类,狮子也是,大猩猩也可能是,此时为了规范一下接口,他们都是属于某种大类的动物,身为动物这个基类的话,此时就有了专门管理这些大类的必要,让其更为规范化显然很有必要,于是纯虚函数也就有了存在的必要了。

(2)、纯虚函数的写法

写法还是很人(会)(偷)(懒)的,直接在虚函数后面赋值为 0 即可,此时原本的虚函数就成了纯虚函数。

#include <iostream>

using namespace std;

class A
{
public:
    A(){cout << "A()\n";}
    ~A(){cout << "~A()\n";}

    virtual void show() = 0; // 纯虚函数
};

class B:public A
{
public:
    B(){cout << "B()\n";}
    ~B(){cout << "~B()\n";}

    void show(){cout << "B::show()\n";}
};

int main(int argc, char const *argv[])
{
    cout << "\n\n-----\n";
    A* a1 = new B;
    a1->show();

    return 0;
}

(3)、抽象类

  • 带有纯虚函数的类称为抽象类
  • 抽象类只能作为基类使用,抽象类可以派生,但不能创建对象
  • 若派生类没有实现纯虚函数接口覆盖,那么派生类依旧是抽象类

对于第三条,做一些解释:

  • 如果派生类没有实现从基类继承的纯虚函数接口,那么派生类仍然是一个抽象类。这意味着派生类无法直接实例化为对象,因为它没有提供对基类中纯虚函数的具体实现。

当一个类从一个抽象类派生时,它继承了抽象类中的纯虚函数接口,这些纯虚函数在派生类中仍然是纯虚函数,需要在派生类中提供具体实现才能使派生类变为可以实例化的具体类。如果派生类没有提供对这些纯虚函数的实现,那么派生类仍然会继承基类中的纯虚函数,并且派生类仍然被视为抽象类。

换句话说,派生类需要提供实现来覆盖基类中的纯虚函数,只有这样,派生类才能成为可以实例化的具体类,而不是抽象类。

请注意,只有在派生类中提供对所有继承的纯虚函数的具体实现后,派生类才能被实例化为对象。否则,派生类仍然被视为抽象类,并且无法直接实例化为对象。

(4)、纯虚函数的应用

我们在满足以下两个条件的时候,可以考虑将该方法申明为纯虚函数(pure virtual function)

  • 当想在基类中抽象出一个方法,且该基类只能被继承,而不能被实例化
  • 这个方法必须在派生类(derived class)中被实现

例程:计算图形面积

#include <iostream>

using namespace std;

// 抽象基类
class Shape
{
public:
    virtual double getArea() const = 0; // 纯虚函数,用于计算面积
};

// 派生类:矩形
class Rectangle:public Shape
{
    double length;
    double width;

public:
    Rectangle(double l, double w):length(l), width(w) {}
    double getArea() const{return length*width;}
};

// 派生类:圆形
class Circle:public Shape
{
    double radius;

public:
    Circle(double r):radius(r) {}
    double getArea() const{return 3.14*radius*radius;}
};

int main(int argc, char const *argv[])
{
    // 创建对象数组,存放不同的形状
    Shape* shape[2];
    shape[0] = new Rectangle(5, 4);
    shape[1] = new Circle(3);

    // 计算并输出各个图形的面积
    for (int i = 0; i < 2; i++)
        cout << "Area:\t" << shape[i]->getArea() << endl;

    // 释放对象内存
    for (int i = 0; i < 2; i++)
    {
        delete shape[i];
    }
    

    return 0;
}

在上述例程中,我们定义了一个抽象基类 Shape,其中包含一个纯虚函数 getArea(),用于计算形状的面积。然后,我们派生了两个具体的形状类 RectangleCircle,它们分别实现了 getArea() 方法。

main() 函数中,我们创建了一个对象数组 shapes,其中存放了不同的形状对象,包括矩形和圆形。通过基类指针 Shape*,我们可以调用 getArea() 方法来计算各个形状的面积,而无需关心具体是哪个派生类的对象。这就体现了多态性的特点。

通过使用纯虚函数,我们在基类中定义了一个抽象方法,要求派生类必须实现该方法,以提供具体的行为。这样,在使用基类指针操作对象时,可以通过动态绑定的方式调用派生类的实现,实现了基类的通用接口和派生类的具体行为分离,提高了代码的可扩展性和灵活性。

6、虚析构

我很早就发现了一个问题,就是采用虚函数的时候,析构函数存在不执行的情况,具体说来就是在程序完结后没有执行析构函数。

这种情况会导致内存泄漏,即申请的内存没有释放的情况

#include <iostream>

using namespace std;

class A
{
public:
    A()
    {
        cout << "A()_1\n";
    }
    ~A()
    {
        cout << "~A()\n";
    }

    virtual void show() = 0;


};

class B: public A
{
public:
    B()
    {
        cout << "B()_1\n";
    }
    ~B()
    {
        cout << "~B()\n";
    }

    void show()
    {
        cout << "B::show()\n";
    }

};

int main()
{
    cout << "\n\n-----\n";
    A* a1 = new B;

    a1->show();

}


看讲师的笔记,我还发现了第二种情况,就是使用了new、delete,却只是执行了析构函数的析构,派生类的析构却没有执行。


虽然不知道原因和原理,但是解决两种情况的方法却是找到了,就是虚析构,即把基类的析构函数设置为虚函数。

#include <iostream>

using namespace std;

class A
{
public:
    A()
    {
        cout << "A()_1\n";
    }
    virtual ~A()
    {
        cout << "~A()\n";
    }

    virtual void show() = 0;


};

class B: public A
{
public:
    B()
    {
        cout << "B()_1\n";
    }
    ~B()
    {
        cout << "~B()\n";
    }

    void show()
    {
        cout << "B::show()\n";
    }

};

int main()
{
    cout << "\n\n-----\n";
    A* a1 = new B;

    a1->show();

    delete a1;

}

上面是针对第二种情况的,第一种情况的错因在我没消除堆空间,从而少了一次析构造成了失误,比理想的错误情况更错一步。


接下来看看一种理应正确,我自己被自己刚刚傻到的情况,我不用虚函数和堆空间,就是正常情况,此时啥都是正常的,而我此时反而觉得很有意思了哈哈哈

#include <iostream>

using namespace std;

class A
{
public:
    A()
    {
        cout << "A()_1\n";
    }
    ~A()
    {
        cout << "~A()\n";
    }

    virtual void show() = 0;


};

class B: public A
{
public:
    B()
    {
        cout << "B()_1\n";
    }
    ~B()
    {
        cout << "~B()\n";
    }

    void show()
    {
        cout << "B::show()\n";
    }

};

int main()
{
    cout << "\n\n-----\n";
    B b;
    A* a1 = &b;

    a1->show();


}

所以说,析构函数最好定义为虚析构,如果不定义为虚析构,那么就只会销毁基类的对象,派生类就不管了,无法对其析构,这样就会造成内存泄漏,是很危险的。

7、静态联编和动态联编

(1)、什么是联编

在C++中,联编是指一个计算机程序的不同部分彼此关联的过程。

按照联编所进行的阶段不同,可以分为两种:静态联编和动态联编。

(2)、静态编译

静态编译是指联编工作在编译阶段完成,这种联编过程是在程序运行之前就完成的,又称为早期联编。

要实现静态联编,在编译过程中必须确定程序中的相关操作函数,确定数据和函数的关系。

其优点是效率高,但是灵活性低。


ChatGPT:

静态联编(Static Binding)和动态联编(Dynamic Binding)是面向对象编程中的两个重要概念,用于描述函数或方法的绑定方式。

静态联编(Static Binding):

  • 静态联编是在编译时进行的绑定过程。也称为早期绑定(Early Binding)或静态绑定。
  • 在静态联编中,函数或方法的调用在编译时就确定了,根据函数或方法的声明和调用的上下文来确定实际调用的函数或方法。
  • 静态联编通过函数或方法的名称、参数的类型和数量来确定具体调用的函数或方法。
  • 静态联编适用于非虚函数、全局函数和静态成员函数等。
  • 静态联编的优点是效率高,因为函数调用在编译时就确定了,不需要在运行时进行额外的查找或判断。

(3)、动态编译

动态编译(Dynamic Dispatch)是指在程序运行时根据对象的实际类型来确定调用哪个同名函数的接口,实际上是通过运行时虚函数表来实现。这种动态绑定也称为晚期绑定。编译器在编译阶段无法确定调用哪个函数,而是在运行时根据对象的类型进行动态分派,选择正确的函数实现。这使得程序能够根据运行时的情况动态地决定执行哪个函数,提供了更大的灵活性和多态性。

这种联编又称为晚期联编,动态联编的优点是灵活性强,但是效率低。

实现动态联编需要的条件:

  • 必须把动态联编的行为定义为类的虚函数
  • 类之间应满足父子类型关系,通常需要从一个类进行公有继承而来
  • 必须使用基类指针指向子类的对象,然后直接或者间接使用基类指针调用虚函数接口

动态联编(Dynamic Binding):

  • 动态联编是在运行时进行的绑定过程。也称为晚期绑定(Late Binding)或动态绑定。
  • 在动态联编中,函数或方法的调用在运行时根据对象的实际类型确定。
  • 动态联编适用于虚函数或纯虚函数,通过虚函数表(vtable)和虚函数指针来实现。
  • 动态联编通过对象的实际类型来确定调用的函数或方法,即使基类指针或引用指向派生类对象,也能正确调用派生类中的函数。
  • 动态联编的优点是多态性,允许在运行时根据对象的实际类型调用相应的函数,实现多态性和灵活的代码设计。

总结:

静态联编在编译时确定函数或方法的调用,效率高但缺乏灵活性;动态联编在运行时根据对象的实际类型确定函数或方法的调用,实现多态性和灵活的代码设计。虚函数是实现动态联编的重要机制,通过虚函数表和虚函数指针实现动态绑定,实现在运行时根据对象类型调用正确的函数。

8、重载、覆盖、隐藏

重载:函数重载,需要在同一个作用域实现,要求函数名相同且参数列表不同,父子类没有重载概念

覆盖:基类的虚函数接口在派生类中被重新实现,虚表中基类的虚函数将被派生类虚函数所覆盖

隐藏:派生类中实现了基类中相同的函数接口,此时基类中的函数接口将被派生类的函数所隐藏


重载(Overloading):函数重载是指在同一个作用域中定义多个具有相同名称但参数列表不同的函数。重载函数根据参数的类型、数量或顺序的不同,可以具有不同的实现。重载函数的选择是在编译时静态确定的,根据调用时提供的参数类型或数量来选择匹配的函数。

覆盖(Override):覆盖是指派生类中重新实现基类的虚函数。当基类中的函数被声明为虚函数时,派生类可以提供自己的实现来覆盖基类的虚函数。在使用基类指针或引用调用虚函数时,根据实际对象的类型来确定调用的是基类的实现还是派生类的实现。覆盖发生在运行时,通过动态联编实现。

隐藏(Hiding):隐藏是指派生类中定义了与基类中相同名称的函数,使得基类中的函数被隐藏。当派生类中定义了与基类中函数名称相同的函数时,基类中的函数将被派生类的函数所隐藏。隐藏发生在编译时,根据名称的匹配来确定调用的是派生类中的函数还是基类中的函数。

需要注意的是,函数重载、覆盖和隐藏都是面向对象编程中的重要概念,用于实现多态性和灵活的代码设计。重载适用于同一类中的函数,覆盖适用于基类和派生类之间的虚函数,而隐藏适用于派生类中与基类中函数名称相同的函数。

三、数据类型转化

C++相比于C是一门面向对象的语言,为了更好的使用多态性,就避免不了要使用指针和引用,在此过程中,就存在转化的问题。

1、转换字符串的种类

  • const_cast<new type>(experssion);
  • static_cast<new type>(experssion);
  • dynamic_cast<new type>(experssion);
  • reinterpret_cast<new type>(experssion);

2、const_cast

const_cast 用来移除变量的 const 或 volatile 限定符,常用于:

  • 常量指针被转化为非常量指针,并且仍然指向原来的对象
  • 常量的引用被转化为非常量引用,并且仍然指向原来的对象

const_cast 一般用于修改底层指针,如 const char *p 的形式,注意,用于去除变量的只读属性,简单来说就是跟C语言中的强转做同样的事,但是由于C++中多了引用这玩意,于是多一个叫做const_cast的玩意就有了必要。


处理指针:

#include <iostream>

using namespace std;


int main()
{
    const int a = 10;
    const int b = 10;

    // C语言的做法
    const int* p = &a; // 欲将p的指向给p,p必须同样是const
    int* ptr = (int*)p;  // 与将已经被const过的p给ptr,可以通过强转
    *ptr = 100;
    cout << *ptr << endl;

    // C++的做法
    const int* q = &b;
    int* qtr = const_cast<int*>(q);
    *qtr = 100;
    cout << *qtr << endl;


    return 0;
}


处理引用:

#include <iostream>

using namespace std;

int main()
{
    const int b = 10;

    const int& q = b;
    int& qtr = const_cast<int&>(q);
    qtr = 100;
    cout << qtr << endl;

    return 0;
}


但使用const_cast的情况还是会出一些毛病的,如果我们试图将一个指向常量的指针转换为一个普通指针,再通过这个普通指针去修改原来的常量时,会产生未定义的行为:

#include<iostream>
using namespace std;

int main()
{
    int a = 10;
    const int ac = 10;
    const int* pa = &ac;

    int* p = const_cast<int*>(pa);
    *p = 11;
    cout << *p << endl;
    cout << *pa << endl;
    cout << ac << endl;

    cout << p << endl;
    cout << pa << endl;
    cout << &ac << endl;
    return 0;
}

可以看到,地址还是那个地址,但是值并没有改变。

3、static_cast

任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast,这几乎就是传统类型的替换,比如:

double a = 10;
int b = static_cast<int>(a);

相比于传统的 int b = (int)a,使用static_cast更像是告诉编译器:“我知道转换会发生精度上的损失,但是我还是要进行转换”,所以使用static_cast的情况下,编译器也不会发出警告。

但是如果想使用static_cast来进行底层const的转换,就会产生问题,比如:

这里报错的理由是static_cast无法丢掉常量或其他类型限定符。


相比之下,传统的类型转换也可以进行这样的转换,即使转换后在使用上会有些问题,而且这问题还挺眼熟,仔细一看,跟const_cast的那个错误还是很相似的,都是存在相同地址时候直接取值和解引用指针取值不同的现象。

#include<iostream>
using namespace std;

int main()
{
    const double a = 10;
    const double* pa = &a;
    int* p = (int*)pa;
    *p = 11;

    cout << *p << endl;
    cout << a << endl;
    return 0;
}

当然,这种问题也不是C++独有的,C里面也是存在的。

4、dynamic_cast

用于将继承过程中将基类指针转换为派生类指针,具体一点说,就是将一个基类对象指针或引用转化到继承类指针。

本质上也是属于C++的一种强转,但是比起前两种,还是多了写东西的,就是返回值,在这个角度上他更像是一个函数,所以我们可以用使用函数的方法来用它。

还需要注意一点,也是我在这篇博客中看到的:多态类型才能使用dynamic_cast,所以对应的类中需要添加虚函数

用一个例子来说明吧:

#include<iostream>
using namespace std;

class A
{
public:
    A()
    {
        cout << "A()\n";
    }
    virtual ~A()
    {
        cout << "~A()\n";
    }

    virtual void show()
    {
        cout << "A::show()\n";
    }

};

class B: public A
{
public:
    B()
    {
        cout << "B()\n";
    }
    ~B()
    {
        cout << "~B()\n";
    }

    void show()
    {
        cout << "B::show()\n";
    }
};

int main()
{
    // 定义不同方式基类指针初始化
    A* A1 = new A; // 基类初始化
    A* A2 = new B; // 派生类初始化

    // 强转基类指针为派生类指针
    B* B1 = (B*)A1;
    if(B1 == nullptr)
    {
        cout << "B1 is nullptr\n";
    }
    else
    {
        cout << "B1 change success!\n";
    }

    // 用dynamic_cast来检验强转是否成功
    B1 = dynamic_cast<B*>(A1);
    if(B1 == nullptr)
    {
        cout << "B1 is nullptr\n";
    }
    else
    {
        cout << "B1 change success!\n";
    }

	// 验证强转基类指针为派生类指针不能生效
	B1 = (B*)A1;
    if(B1 == nullptr)
    {
        cout << "B1 is nullptr\n";
    }
    else
    {
        cout << "B1 change success!\n";
    }

    // 用dynamic_cast来检测已经被派生类初始化的基类指针
    if(B2 == nullptr)
    {
        cout << "B2 is nullptr\n";
    }
    else
    {
        cout << "B2 change success!\n";
    }
    delete A1;
    delete A2;
    return 0;
}


首先可以看到,C语言中的强转是一种很粗犷的方法,粗犷在他并不去判断强转后的东西有没有缺陷,而dynamic_cast作为C++的强转方法,对此做了一些改进,使我们可以通过返回值来判断到底成功了没有。

拿上面的例子来说,dynamic_cast若转换成功,则说明指向的有内容,反之为空,我们直接用if-else判断即可,C++里面的空表示为nullptr,我们把它写在有条件的if括号里,然后用else兜底即可。

5、reinterpret_cast

reinterpret的意思是“重新解释”,而 reinterpret_cast也是C++中与C风格类型类型转换最接近的类型转换运算符,他让程序语言能将一种对象类型转换为另一种,不管它们是否相关。

再看reinterpret_cast<new type>(experssion);,区中的type必须是一个指针、引用、函数指针或者类成员指针,用于指针类型、整数或者指针之间的转化。

reinterpret_cast 提供了一种底层的转换方式,它会直接重新解释底层数据的位模式,而不进行任何类型检查或转换操作。因此,使用 reinterpret_cast 需要非常谨慎,因为它的安全性很低,可能导致未定义行为或潜在的错误。

在实际编程中,应该尽量避免使用 reinterpret_cast,除非确实需要处理底层的位模式,或者在特定情况下需要进行一些非常规的类型转换。对于常规的类型转换,建议使用更安全和类型检查的转换运算符,如 static_castdynamic_castconst_cast,它们提供了更严格的类型检查和语义约束,可以减少潜在的错误和问题。


#include <iostream>

int main() {
    int x = 10;

    // 使用 reinterpret_cast 将整数类型转换为指针类型
    int* p = reinterpret_cast<int*>(&x);

    // 修改指针所指向的内存内容
    *p = 20;

    // 输出原始整数和修改后的值
    std::cout << "Original value of x: " << x << std::endl;
    std::cout << "Modified value through pointer: " << *p << std::endl;

    return 0;
}

在这个例子中,我们定义了一个整数变量 x,然后使用 reinterpret_cast 将其地址转换为整型指针 p。接着,我们通过修改指针 p 所指向的内存,改变了 x 的值。最后,我们分别输出了原始的 x 的值和通过指针修改后的值。

需要注意的是,这个例子属于非常特殊的情况,使用 reinterpret_cast 进行整型到指针的转换是非常危险的,因为它直接重新解释了内存的位模式,而不考虑类型的语义和约束。这种转换容易导致未定义行为和错误,因此在实际开发中应该慎重使用 reinterpret_cast

posted @ 2023-07-02 17:56  fhzy  阅读(164)  评论(0)    收藏  举报