目录
面向对象的核心思想
C++ 通过以下关键特性来支持面向对象编程:
-
类(Classes)和对象(Objects):
- 类是创建对象的蓝图或模板。它定义了对象将包含的数据类型和可以执行的操作。
- 对象是类的具体实例。通过类可以创建多个具有相同结构但可能不同数据的对象。
- 类是实现封装的基础。
-
封装(Encapsulation):
- 将数据和操作数据的函数捆绑在一个类中。
- 通过访问修饰符(
public
、private
、protected
)控制类成员的可见性和访问权限,隐藏内部实现细节,只对外暴露必要的接口。这有助于保护数据的完整性并提高代码的安全性。
-
继承(Inheritance):
- 允许一个类(子类/派生类)继承另一个类(父类/基类)的属性和行为。
- 子类可以重用父类的代码,并可以添加新的功能或修改父类的行为。
- 继承建立了类之间的“is-a”(是一个)关系,例如,一个
Dog
类可以继承自一个Animal
类。
-
多态(Polymorphism):
-
多态的字面意思“多种形态”在面向对象编程中,正是通过让不同类的对象对同名函数(方法)调用做出不同的响应来体现的。
当你调用一个函数名一样的方法时,表面上看是在调用同一个函数,但实际上,由于多态性的存在,真正执行的函数体(即函数内部的代码逻辑)会根据对象的实际类型而有所不同。
- C++ 通过 函数,运算符重载,模板,继承和 虚函数 等机制来实现多态。
- 多态使得程序更加灵活和可扩展,能够以统一的方式处理不同类型的对象。
- 当我们提到“多态”这个词,一般指的都是运行时多态(通过继承和虚函数实现)
-
虚函数(virtual)
普通虚函数&纯虚函数
普通虚函数:
这是最常见的虚函数。
在基类中声明为 virtual 关键字,并且需要提供一个默认的实现(函数体)。
派生类可以选择重写这个函数,提供自己的特定实现;派生类也可以不重写这个函数,这样可以直接继承基类的默认实现。
主要目的是实现运行时多态性,允许通过基类指针或引用调用派生类对象的相应方法。
纯虚函数 :
在普通虚函数声明的末尾加上 = 0,例如:virtual void someFunction() = 0;。
纯虚函数在基类中没有具体的实现,它只是声明了一个接口,要求所有继承自该基类的具体(非抽象)派生类都必须提供该函数的实现。
包含至少一个纯虚函数的类被称为抽象类,抽象类不能实例化对象。
如果继承抽象类的派生类,没有将抽象类中的虚函数全部实现的话,这个派生类也是个抽象类,这个派生类也不能实例化对象。
什么函数可以设置成虚函数?
你可能会说,声明为虚函数的话不就是在这个函数名前面加上一个virtual 关键字吗?
是不是所有的成员函数都可以被声明为虚函数?
emm,我只能说:确实声明成虚函数的门槛比较低,但也不是所有函数都可以声明成虚函数的。
可以声明为虚函数的:
- 普通的非静态成员函数: 这是
virtual
关键字的主要应用场景 - 析构函数
不可以声明为虚函数的:
- 静态成员函数(
static
member functions): 静态成员函数属于类本身,而不是类的任何特定对象。虚函数的调用是基于对象的实际类型在运行时决定的,而静态成员函数不与任何对象关联,因此不能是虚函数。
- 构造函数(Constructors): 构造函数负责对象的创建和初始化。当创建一个对象时,对象的类型是确定的,因此没有必要将构造函数声明为虚函数来实现多态。此外,虚函数的调用需要依赖虚函数表,而虚函数表是在对象的构造过程中建立的,这会形成一个循环依赖。
- 友元函数(
friend
functions): 友元函数不是类的成员函数,它们只是被授予访问类的私有和保护成员的权限。由于友元函数不属于类的继承体系,因此不能声明为虚函数。
重写(override)
重写的特点以及要求
重写(Overriding) 是面向对象编程中一个非常重要的概念,它发生在继承 的关系中。具体来说,重写指的是:
当一个派生类(子类)从一个基类(父类)继承了一个虚函数(virtual function)时,派生类可以提供该虚函数自己的特定实现。这个过程就叫做重写。
以下是重写的一些关键特点和要点:
- 继承关系: 重写只能发生在派生类继承自基类的情况下。
- 虚函数: 基类中被重写的函数必须声明为
virtual
。virtual
关键字允许在运行时根据对象的实际类型来调用适当的函数版本(这种机制称为动态绑定或运行时多态)。
- 相同的函数签名: 派生类中重写的函数必须与基类中的虚函数具有完全相同的函数签名,包括:
- 函数名
- 参数列表(参数的类型、数量和顺序)
- 返回类型(有一些例外情况,比如协变返回类型,但通常要求返回类型相同)
const
修饰符(如果基类中的虚函数是const
,派生类中重写的版本也应该是const
)
override
关键字(C++11): 在 C++11 及以后的标准中,建议在派生类中重写函数时使用override
关键字。这个关键字不是必需的,但是我们推荐写,但它可以帮助编译器检查你是否真的重写了一个基类的虚函数。并且可以帮助编译器进行类型检查,从而避免一些潜在的错误。
实现多态性: 重写是实现多态性的关键机制。通过重写,你可以让不同的派生类对象在调用相同的函数时表现出不同的行为。
后面我们将会详细介绍到多态。
重写简单示例
class Shape {
public:
virtual void draw() {
std::cout << "Drawing a shape." << std::endl;
}
};
class Circle : public Shape {
public:
// 重写基类的 draw 函数
void draw() override {
std::cout << "Drawing a circle." << std::endl;
}
};
总结来说,重写是在继承关系中,派生类为了提供特定于自身的行为,对基类中声明为 virtual
的虚函数进行重新实现的过程。它保证了在运行时能够根据对象的实际类型调用正确的函数版本,是实现多态性的重要手段。
重写书写格式推荐:
在 C++11 之前: 存在一种观点认为,即使派生类重写虚函数时 virtual 关键字不是必需的,但为了代码的清晰性和可读性,建议显式地加上 virtual。
在现代 C++(C++11 及更高版本)中,强烈推荐并且广泛使用 override 关键字来显式地标记派生类中重写的虚函数。因为这样不仅更可读,加上override 后,编译器还会在编译时帮忙检查重写的虚函数格式。
所以现在派生类中重写虚函数可以不加virtual了,推荐使用override 关键字,既用override又用virtual语法上没错,但是有点冗余,推荐只用override就可以!
c++多态
多态的含义
多态,通俗来讲就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。比如,在买票这一行为,普通人买票是全价买票,学生买票是半价买票,而军人买票是优先买票;再比如动物园的动物叫这个行为,不同的动物叫声是不一样的。这些都是生活中多态的例子。
- 字面意思是“多种形态”。在 OOP 中,它指的是不同类的对象可以对同一个消息(方法调用)做出不同的响应。
- C++ 通过 函数/运算符重载 ,继承和 虚函数,重写 等机制来实现多态。
- 多态使得程序更加灵活和可扩展,能够以统一的方式处理不同类型的对象。
C++ 中的多态主要分为两种:编译时多态 (静态多态) , 运行时多态(动态多态)
下面将详细介绍一下这两种多态:
编译时多态(静态多态)
- 这种多态在程序编译阶段就已经确定了要调用的具体函数。
- 主要通过函数重载(Function Overloading) ,运算符重载(Operator Overloading) 和模板(Templates)来实现。
- 编译器会根据函数或运算符调用的参数类型和数量,在编译时决定匹配哪个重载的版本。
函数重载:
- 这是静态多态最直接的体现。你可以在同一个作用域内定义多个同名函数,但它们的参数列表(参数的类型、数量或顺序)必须不同。
- 当你调用这些函数时,编译器在编译阶段会根据你提供的实际参数的类型和数量,选择最匹配的函数版本进行调用。这个选择过程发生在编译时,因此称为静态绑定或编译时多态。
- 尽管名称相同,但这些重载的函数实际上是不同的函数,只是共享一个名字。
运算符重载:
- 运算符重载允许你为自定义的类型(例如类或结构体)重新定义已有的运算符(例如
+
,-
,*
,==
等)的行为。 - 你可以为同一个运算符定义多个不同的实现,只要这些实现的操作数类型不同。
- 当你在代码中使用被重载的运算符时,编译器在编译阶段会根据操作数的类型来决定调用哪个重载版本的运算符函数。这个过程与函数重载非常相似,都是在编译时完成的静态绑定。
int i=1;
double d=2.2;
cout<<i<<endl;
cout<<d<<endl;
<iostream> 标准库为 std::ostream 类(cout 是它的一个对象)重载了 << 运算符,使其能够方便地输出各种不同的数据类型,包括 int 和 double
正是因为 std::ostream 类对 << 运算符进行了多次重载,针对不同的数据类型提供了不同的实现,所以我们才能使用相同的语法 cout << 数据 来输出不同类型的数据。编译器会根据右操作数的类型自动选择调用合适的重载版本。
运算符函数的概念
对于内置类型(如
int
、double
等),这些运算符的行为是由编译器预先定义的,你可以认为编译器内部已经为这些类型实现了相应的“运算符函数”。当你为自定义类型重载运算符时,你就是在为这些类型提供自己的“运算符函数”实现
模板:
- 模板允许你编写通用的函数或类,这些函数或类可以操作多种不同的数据类型,而无需在编写代码时指定具体的类型。
- 当你使用一个模板函数或类时,编译器在编译阶段会根据你提供的类型参数,生成特定类型的代码。这个过程称为模板实例化。
- 对于函数模板,编译器会根据你传递的参数类型推导出模板参数,并生成相应的函数代码。这使得同一个模板函数能够像处理多种类型一样工作,实现了静态的多态性。
关于模板的介绍可以看:c++中的模板和函数重载-CSDN博客
运行时多态(动态多态)重点
当我们提到“多态”这个词,一般指的都是运行时多态(通过继承和虚函数实现)
下面我们谈到的多态都是指的动态多态。
动态多态是在运行中实现的,当一个父类对象的引用或者指针接收不同的对象(父类对象or子类对象)后,调用相同的函数会调用不同的函数体。
动态多态的核心思想:使用基类指针管理派生类对象,提高代码的灵活性、可扩展性和可维护性
多态的构成条件
在继承中要构成多态还有两个条件:
1. 必须通过基类的指针或者引用(一定得是基类的!)调用虚函数
2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}
用父类的引用或者指针(这里使用的是Person& p)来接收不同类型的对象(p1和p2),该引用或指针调用相同的函数(都调用了p.BuyTicket()),都调用了各自类中不同的函数(打印的结果不同)。我们将这一过程称为动态多态。
在上面的动态多态示例中我们还要注意两点:
- 接受类型为父类的指针或者引用,传递的对象是父类实例化的对象,就调用父类的函数;传递的对象是子类实例化的对象,就调用子类的函数。
在 C++11 之前: 存在一种观点认为,即使派生类重写虚函数时 virtual 关键字不是必需的,但为了代码的清晰性和可读性,建议显式地加上 virtual。
在现代 C++(C++11 及更高版本)中,强烈推荐并且广泛使用 override 关键字来显式地标记派生类中重写的虚函数。因为这样不仅更可读,加上override 后,编译器还会在编译时帮忙检查重写的虚函数格式。
所以现在派生类中重写虚函数可以不加virtual了,推荐使用override 关键字,既用override又用virtual语法上没错,但是有点冗余,推荐只用override就可以!
判断是否满足多态的几个例子
主要想通过下面几个例子,让大家对于 在继承中构成多态的条件 理解更透彻!
示例一:(通过基类的指针来调用虚函数)
#include <iostream>
using namespace std;
class A
{
public:
virtual void func(int val)
{
cout << "A->" << val << std::endl;
}
};
class B : public A
{
public:
virtual void func(int val) override
{
cout << "B->" << val << std::endl;
}
};
int main()
{
A* p = new B;
p->func(1);
return 0;
}
上述的代码调用构成多态吗?上述的代码运行结果是什么呢?
答案是:构成多态。运行结果为:
B->1
分析:
-
继承: 类
B
通过public
关键字继承了类A
。 -
虚函数: 类
A
中的func
函数被声明为virtual
。 -
函数重写: 类
B
中也定义了一个与类A
中func
函数具有相同名称、参数列表和返回类型的函数,并且也使用了virtual
关键字(虽然在派生类中virtual
关键字不是必需的,但为了清晰起见通常会加上)。这表示B
提供了func
函数自己的特定实现,从而重写了基类A
的func
函数。 -
基类指针指向派生类对象: 在
main
函数中,我们创建了一个指向B
类型对象的指针p
,但这个指针的类型是基类A*
。 -
通过基类指针调用虚函数: 当我们通过基类指针
p
调用func(1)
时,由于func
是一个虚函数,C++ 的运行时机制(通常通过虚函数表)会判断p
实际指向的对象是B
类型的,因此会调用B
类中重写后的func
函数。
示例二:(没有通过基类的指针或引用调用虚函数)
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person p)
{
p.BuyTicket();
}
int main()
{
Person ps;
Student st;
Func(ps);
Func(st);
return 0;
}
上述的代码调用构成多态吗?上述的代码运行结果是什么呢?
答案是:不构成多态,因为这里不满足通过基类的指针或者引用调用虚函数
运行结果:
买票-全价
买票-全价
这个代码就只是删除了一下&就不满足多态,因为不满足通过基类的指针或者引用调用虚函数这个条件,如果还不明白通过基类指针或者引用调用虚函数这句话的意思,可以再看一下示例三
void Func(Person p) //void Func(Person& p)
{
p.BuyTicket();
}
示例三:派生类对象在栈上创建,通过基类引用调用虚函数实现多态
#include <iostream>
class A {
public:
virtual void func() {
std::cout << "A::func()" << std::endl;
}
};
class B : public A {
public:
void func() override {
std::cout << "B::func()" << std::endl;
}
};
int main() {
B b_obj; // 在栈上创建 B 类对象
A& ref_a = b_obj; // 创建一个基类 A 的引用 ref_a,引用栈上的 B 类对象
ref_a.func(); // 调用的是 B 类的 func(),体现了多态
return 0;
}
在这个例子中,b_obj 是在栈上创建的,但是我们通过基类 A 的引用 ref_a 来调用 func(),结果仍然是调用了 B 类的 func(),这体现了多态性。
多态的用处
图形界面 (GUI) 编程: 例如,一个基类 Shape
可以有派生类 Circle
, Rectangle
, Triangle
。你可以通过 Shape
指针来统一管理和绘制不同的形状。
游戏开发: 例如,一个基类 GameObject
可以有派生类 Player
, Enemy
, Item
。你可以通过 GameObject
指针来统一管理和更新游戏中的各种对象。
此时你可能会想,为什么不能定义Circle
, Rectangle
, Triangle
的专用指针Circle* , Rectangle*, Triangle*来分别管理这些形状。
- 统一管理: 想象一下,你需要在屏幕上绘制多个形状,这些形状可能是圆形、矩形、三角形的混合。如果你使用各自类型的指针,你需要创建多个不同的容器(例如,一个
std::vector<Circle*>
, 一个std::vector<Rectangle*>
, 一个std::vector<Triangle*>
) 来分别存储它们。这使得管理和操作这些形状变得复杂。
- 通用处理: 如果你有一个需要对所有形状执行的操作,比如“绘制”,你需要为每种形状类型编写不同的处理逻辑。使用
Shape*
,你可以创建一个std::vector<Shape*>
来存储所有形状,然后遍历这个容器,对每个元素调用draw()
方法。多态性会确保调用的是每个形状对象自身实现的draw()
方法。
用基类指针管理派生类对象的核心优势在于通过抽象实现统一操作和管理,提高代码的灵活性、可扩展性和可维护性。
虚函数重写的两个例外
协变返回类型
在之前我们介绍虚函数重写的时候,提到了派生类重写基类的虚函数时候:
函数名,函数签名(参数的类型、数量和顺序),函数返回值必须一样。
协变返回类型描述了在虚函数重写时,派生类返回的指针或引用类型可以是基类返回类型的子类型
并且协变返回类型,只适用于虚函数返回值为类的指针或类的引用的情况。
关于协变返回类型平时用的很少,这里就不多介绍。
析构函数的重写
析构函数的“重写”(声明为 virtual
):
- 使用频率:非常高,几乎是强制性的最佳实践。
- 原因: 当你使用基类指针或引用指向派生类对象,并且需要通过这个基类指针或引用来删除该对象时,如果基类的析构函数不是
virtual
的,那么只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类中分配的资源(例如,通过new
分配的内存)无法被正确释放,从而造成内存泄漏。
- 重要性: 为了确保在多态场景下能够正确地清理派生类对象的资源,基类(特别是那些作为其他类的基类,可能被继承和用于多态的类)的析构函数几乎总是应该声明为
virtual
。这并不是严格意义上的“重写”,而是通过在基类中声明为virtual
,使得派生类的析构函数在通过基类指针删除对象时也能被正确调用。
应该将析构函数设置为虚析构函数的情况:
当你的类打算作为基类并且可能会被用于多态时。 这是最关键的情况。如果你期望通过基类指针或引用来操作派生类对象,并且在删除这些对象时需要调用派生类的析构函数以清理派生类特有的资源,那么基类的析构函数必须是 virtual
的。
当你的类不打算作为基类使用时。 如果一个类是独立的,没有子类。那么此时将析构函数声明为 virtual
是没有必要的,虽然不会为代码带来错误,但是会带来一些小的额外开销。这个开销来自于虚函数表 (vtable) 的维护。
重载,重写,隐藏的对比
重载 (Overloading)
- 定义: 在同一个作用域内(可以是同一个类中,也可以是同一个命名空间中),可以定义多个函数名相同但参数列表不同的函数。参数列表的不同指的是参数的类型、数量或顺序不同。
- 目的: 提供一种方便的方式来使用同一个函数名执行不同的操作,这些操作在逻辑上是相似的,但需要处理不同类型或数量的数据。
- 解析时间: 重载函数的调用是在编译时根据函数调用时提供的参数类型和数量来确定的。编译器会选择最匹配的重载版本。
- 返回值类型: 重载函数可以有不同的返回值类型,但这本身并不是重载的决定因素。两个函数如果只有返回值类型不同而参数列表相同,是不能构成重载的。
重写 (Overriding)
- 定义: 发生在派生类中,当派生类中的一个函数与基类中的虚函数具有相同的签名(函数名、参数列表、
const
限定符和引用限定符)和兼容的返回类型(包括协变返回类型)时,派生类的函数被称为重写了基类的虚函数。
- 目的: 实现动态多态性。通过基类的指针或引用调用这个虚函数时,会根据对象的实际类型(是基类对象还是派生类对象)在运行时决定执行哪个版本的函数。
- 要求:
- 基类中的函数必须是
virtual
的。- 派生类中的函数必须与基类中的虚函数具有相同的签名。
- 派生类中的函数可以使用
override
关键字(C++11 引入)显式声明其意图是重写。
- 解析时间: 重写函数的调用是在运行时通过虚函数表(vtable)来确定的。
隐藏 (Hiding)
- 定义: 发生在派生类中,当派生类中声明了一个与基类中同名的成员(函数或变量),但基类的这个成员不是虚函数时,派生类的成员会隐藏基类的同名成员。即使派生类中的函数签名与基类中的函数签名完全相同,也不会构成重写。
- 目的: 虽然隐藏在语法上是允许的,但通常被认为是不良的编程实践,因为它可能导致混淆和意外的行为,我们应该尽量避免隐藏行为!!!!!
- 解析时间: 隐藏的成员函数调用是在编译时根据作用域来确定的。当通过派生类对象调用该名称的成员时,编译器首先在派生类的作用域中查找,找到后就停止查找。
- 访问被隐藏的成员: 可以使用作用域解析运算符
::
来显式地访问基类中被隐藏的成员。
大家如果想了解:c++多态的底层实现,虚函数表,虚函数表指针,虚函数的调用过程
可以移步至我的另一篇文章:
c++多态的进一步了解,类的虚函数表,对象的虚函数表指针,虚函数调用过程-CSDN博客
参考文章: