一、多态
1.1 什么是多态
多态是面向对象编程中的一个重要概念,它允许在不同类型的对象上执行相同的操作,并根据对象的实际类型来决定具体执行哪个操作。通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
1.2 多态构成的条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同行为。比如Student继承了Person,Person对象买票全价,Student对象买票半价。
那么在继承中要构成多态还有两个条件:
必须通过基类的指针或者引用调用虚函数。
被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
二、虚函数
2.1 什么是虚函数
虚函数是在基类中声明为虚拟(virtual)的成员函数,其目的是通过动态绑定来在面向对象编程中实现多态性。多态性允许我们在编写代码时,使用基类指针或引用来处理各种不同的派生类对象,而不需要考虑对象的具体类型。
只有非静态的成员函数前加virtual才能是虚函数。
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
// 这里BuyTicket就是虚函数了
private:
int _a;
char _ch;
};
2.2 虚函数的重写
2.2.1 如何实现重写
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
注意:
在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法并不规范,不建议这样使用。
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student :public Person
{
public:
// 比如这里不加virtual也行,但是不规范。
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps); // "买票-全价"
Func(st); // "买票-半价"
return 0;
}
满足多态时:函数调用跟对象有关,指向哪个对象就调用他的虚函数。
不满足多态时:函数调用对类型有关,调用的类型是谁,调用就是谁的。
2.2.2 被重写的部分是什么
继承下来的是函数名、类型、返回值,重写的是函数体。
class A
{
public:
virtual void func(int val = 1)
{
cout << "A->" << val << endl;
}
virtual void test() { func(); }
};
class B :public A
{
public:
void func(int val = 0)
{
cout << "B->" << val << endl;
//继承下来的是函数名、类型、返回值
//重写的是函数体
}
};
int main()
{
B* p = new B;
p->test();
//继承
return 0;
}
2.2.3 重载、重写、重定义的辨析
重载:
定义:重载指的是在同一个作用域内使用相同的函数名,但参数列表不同的情况。
特点:
(1)函数名称相同,但参数列表不同(数量、顺序、类型)。
(2)返回类型可以相同也可以不同。
(3)可以发生在同一个类中,也可以是类的不同成员函数。
作用:通过提供不同的参数列表,可以实现对不同类型或数量的参数进行处理。
重写:
定义:重写是指在派生类中重新定义(覆盖)基类的虚函数。
特点:
(1)函数名称、参数列表和返回类型都必须与基类中的虚函数完全相同。
(2)在派生类中使用 override 关键字显式声明重写。
(3)基类的函数必须被声明为虚函数。
作用:通过重写基类的虚函数,可以实现对派生类特有行为的定制化,从而实现多态。
重定义:
定义:重定义指的是在派生类中定义一个与基类中的函数同名但不是虚函数的函数。
特点:
(1)函数名称和参数列表必须与基类中的函数相同,但不能使用 override 关键字。
(2)返回类型可以相同也可以不同。
(3)不需要在基类中将该函数声明为虚函数。
作用:重定义是一种在派生类中重新实现基类函数的方式,但它不支持多态性。
总结:
重载是指在同一个作用域内使用相同函数名但参数列表不同的情况,用于处理不同类型或数量的参数。
重写是指在派生类中重新定义(覆盖)基类的虚函数,用于定制派生类特有行为。
重定义是指在派生类中定义一个与基类中函数同名但不是虚函数的函数,不支持多态性。
2.3 virtual使用情况的辨析
1.在继承中:
可以在菱形继承中,去完成虚继承,解决数据冗余和二义性。(下面附之前博客)
C++ 继承详解
2.在多态中:
修饰原函数,为了完成虚函数的重写,使其满足多态的条件之一。
两个地方使用了同一个关键字,但他们互相之间没有关联。
三、C++11的override和final
override 和 final 是 C++11 中引入的两个关键字,用于在派生类中对基类的虚函数进行重写或者限制。
3.1 override
override 关键字用于指示派生类中的函数是对基类中的虚函数进行重写(override)。它可以帮助编译器检查派生类中的函数是否正确地重写了基类中的虚函数。如果使用了 override 关键字但实际上没有重写成功(比如你重写了虚函数但是函数名写错了你却没发现,导致实际上并没有完成虚函数的重写),则编译器会给出错误提示。
简单说它就是用来检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Base
{
public:
virtual void f()
{
// ...
}
};
class Derived : public Base
{
public:
void f() override // 使用 override 关键字检查
{
// ...
}
};
3.2 finnal
final 关键字用于限制派生类继承或者覆盖某个函数。当我们将一个函数声明为 final 时,它将无法被派生类再次重写或者覆盖。这样可以确保某个函数在派生类中保持不变,提高程序的安全性和稳定性。
final 关键字只能用于修饰虚函数和派生类。
(1)final:修饰虚函数,表示该虚函数不能再被继承。
(2)final修饰派生类,表示派生类类不再能被继承。
class Base
{
public:
virtual void f() final
{ // 使用 final 关键字限制虚函数不可重写
// ...
}
};
class Derived : public Base
{
public:
// 下面的代码将导致编译错误,因为派生类无法重写被声明为 final 的函数
void f() override
{
// ...
}
};
在C++中,final 关键字不能直接用于修饰基类。final 关键字的作用是防止派生类进一步继承或者覆盖某个函数。它用于在派生类中声明一个函数是最终版本,不允许再次重写或者覆盖。这样可以提高代码的安全性和稳定性,确保某个函数或者类不会被修改或扩展。
对于基类来说,并没有特定的关键字用于标记基类为最终类。基类本身就具有被继承的特性,其他类可以通过继承基类来创建新的子类。如果不希望基类被继承,可以采用以下两种方式之一:
1.私有构造函数,禁止直接继承
class Base
{
private:
Base() { }
// ...
};
2.将基类声明为抽象类。抽象类不能直接实例化,只能作为接口供其他类进行继承和实现。
class Base
{
public:
virtual void foo() = 0; // 纯虚函数,使基类成为抽象类
// ...
};
四、抽象类
4.1 什么是抽象类
在虚函数的后面写上=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类的特点是不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
纯虚函数规范了派生类必须重写,因为派生类只有重写后才能实例化出对象,另外纯虚函数更体现出了接口继承。
所以在基类中使用纯虚函数的作用:
强制继承它的子类必须去完成对虚函数的重写。
表示抽象的类型,即在现实中没有对应的实体。
#include <iostream>
using namespace std;
// 抽象类
class AbstractClass
{
public:
// 纯虚函数
virtual void f() = 0;
// 普通成员函数
void Print()
{
cout << "抽象类-P" << endl;
}
};
// 抽象类的派生类
class ConcreteClass : public AbstractClass
{
public:
// 实现纯虚函数
virtual void f() override
{
cout << "具体类-f" << endl;
}
};
int main()
{
// 抽象类不能被实例化
// AbstractClass a; // 错误
// 使用派生类创建对象
ConcreteClass concrete;
// 调用纯虚函数和普通成员函数
concrete.f(); // 输出:"具体类-f"
concrete.Print(); // 输出:"抽象类-P"
return 0;
}
4.2 接口继承和实现继承
在面向对象编程中,接口继承和实现继承是两种不同的继承方式。
1.接口继承
接口继承是指一个类继承另一个类的接口,它主要关注类之间的接口兼容性。继承下来的是函数名、类型、返回值,通过接口继承,子类可以拥有与父类相同的接口,从而实现多态性和代码的灵活性。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。
在C++中,接口继承通常通过抽象类来实现。父类是一个抽象类,其中包含纯虚函数作为接口,子类继承该抽象类并实现其纯虚函数。通过这种方式,子类可以共享一个公共的接口,并根据自身的需求来实现具体的功能。接口继承使得子类能够以相同的方式被使用,提高了代码的可重用性和可扩展性。
2.实现继承
普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
五、多态的原理
5.1 虚函数表
当基类中的成员函数被声明为虚函数时,它们的调用就可以在运行时根据对象的动态类型来确定。这个过程称为动态绑定或后期绑定,它能够保证在运行时选择正确的函数实现。
C++ 中实现多态的关键机制是虚表(vftable)和虚表指针(vfptr)。当一个类声明了虚函数时,编译器会为该类生成一个虚表,其中存储了指向虚函数地址的指针。同时,编译器还会在每个对象中添加一个虚表指针,指向该对象对应的虚表。
规定:由于虚表中可能存有多个虚函数的地址,所以规定了虚表结束后会加空指针标记结束。
验证一下虚表指针的存在:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
virtual void Func2()
{
cout << "Func2()" << endl;
}
private:
int _a = 1;
};
int main()
{
Base b;
cout << sizeof(b) << endl; // 8(32位下)
return 0;
}
为什么b的大小是8呢?按理说函数最终都编译成了指令放到了代码段,所以说只需要计算a的大小,再考虑内存对齐就可以了。
实际原因是b中多了一个虚表指针,这个指针指向了一个表,虚表实际就是一个存储了虚函数地址的指针数组。在这里虚函数是F1和F2,那么虚表中也就会存储了它们的地址。
5.2 多态的实现
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
int _p = 1;
};
class Student :public Person
{
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
int _s = 2;
};
int main()
{
Person p;
Student st;
return 0;
}
上面代码Student继承了Person,Person中声明了买票的虚函数,当Student完成了对它的重写之后,子类中存储的虚表就发生了变化。
当满足了多态的两个条件后:
1.虚函数的重写
2.父类的指针或者引用调用
程序在运行时就会到指向的对象的虚表中查找要调用的虚函数的地址来进行调用。
注意,必须要满足这两个条件才能构成多态。
下面我们来实现一下:
#include <iostream>
using namespace std;
class Person
{
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
int _p = 1;
};
class Student :public Person
{
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
int _s = 2;
};
void f(Person& p)
{
p.BuyTicket();
}
int main()
{
Person p;
Student st;
f(p); // 买票-全价
f(st); // 买票-全价
return 0;
}
p指向person类型的对象就调用person虚函数, p指向student类型的对象调用的就是student虚函数
p如果指向父类,就会到父类的虚函数表中去找,找到的就是父类的虚函数。p如果指向子类,会发生切片(将子类中属于父类的一部分赋给父类),那么对于该引用看到的还是一个父类对象,只不过这个对象是子类切出来的。但是此时虚表指针指向指向的虚表里面已经是子类的虚函数了,因此会调用子类的虚函数。
因此指向谁就调用谁的真正原理就是指向谁就到谁的虚表里去找对应的虚函数, 而这个虚函数已经是写好了的,重写了之后其实就是虚表里面会变成子类虚函数的地址。
因此重写还有另一个概念是覆盖,重写意为只继承了父类的函数名参数返回值,然后把函数的实现给重写了, 而覆盖在原理层指明了是将虚函数表给覆盖了,没有完成重写也就不会覆盖。
5.3 静态绑定与动态绑定
静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载。
动态绑定又称为后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。
一般我们说的多态指的是动态多态。
5.4 虚基表与虚表的区别
虚表(vtable)用于实现单一继承中的动态绑定,存储了虚函数地址,通过虚指针找到对应的虚表来调用虚函数。
虚基表(virtual base table)用于解决多重继承中虚基类的共享问题(菱形继承问题),存储了虚基类的偏移量和其他相关信息,通过虚基表指针来处理虚基类的访问和布局。
两者不能混为一谈。