【C++】面向对象编程
面向对象概念
面向对象编程(Object-Oriented Programming,OOP)是一种编程范式,它通过“对象”来组织代码,其中对象是类的实例。
用一个生活中的简单例子来类比:汽车。
类(Class):汽车的设计图,比如“轿车”或“卡车”,它定义了汽车的属性和行为。
对象(Object):根据设计图制造出来的具体汽车,比如“我的红色轿车”或“朋友的蓝色卡车”。
属性(Attributes):汽车的特征,比如颜色、品牌、速度。
方法(Methods):汽车能做的动作,比如启动、加速、刹车。
封装(Encapsulation):把汽车的内部结构(比如发动机)隐藏起来,只提供方向盘、油门等接口。
继承(Inheritance):卡车可以继承轿车的一些特性(比如有轮子、能跑),但增加自己的特性(比如能载货)。
多态(Polymorphism):不管是轿车还是卡车,都可以用“启动”这个动作,但具体怎么启动由它们自己决定。
如果不使用面向对象编程(OOP),汽车程序可能会采用过程式编程的方式。过程式编程以函数和全局变量为核心,通过一系列的函数调用和状态管理来完成任务。
没有类和对象:汽车不再是一个类,而是通过一组函数和全局变量来模拟。
数据和行为分离:汽车的状态(如颜色、速度)存储在全局变量中,行为(如启动、加速)通过函数实现。
缺乏封装:汽车的内部状态可以直接被任何函数访问和修改,没有访问控制。
面向过程编程:实现两个npc被攻击
首先定义两个NPC名字和血量

然后npc1被攻击的函数

npc2被攻击的函数

调用函数实现受伤

面向对象编程
从上文来看,如果有很多npc那么我们需要一直重复定义下去,非常麻烦
如果我们将封装Npc类,将会省去很多重复工作的时间

类
类型名称一般首字母大写+每个单词首字母大写
class 类型名称
{
};
声明成员变量,默认作用域是私有的,只能在类内部访问,公开需主动声明
class 类型名称
{
public:
int x{0}; //C++11开始可以在声明时赋初值
};
在类中,所有的变量只是声明,并没有内存空间的花费,当进行类的定义时,才进行内存的创建

用指针访问类,此方法是在堆中访问,记得删除内存

类成员函数
类成员函数(也称为方法)是定义在类中的函数,用于操作类的成员变量(属性)或实现类的行为。
函数的声明和定义是放在一起的也称作内联函数

非内联函数声明和定义是分开的

对比
| 特性 | 内联函数 | 普通成员函数 |
|---|---|---|
| 定义位置 | 类定义体内直接定义,或在类体外使用 inline 关键字定义 | 类定义体外定义 |
| 编译器处理 | 编译器会尝试将函数调用替换为函数体代码(内联展开) | 编译器生成函数调用代码,通过栈和跳转指令执行 |
| 代码膨胀 | 可能导致代码体积增大,因为每个调用点都会插入函数体代码 |
代码体积相对较小,因为函数体只定义一次 |
| 执行效率 | 通常更快,因为减少了函数调用的开销 |
相对较慢,因为涉及函数调用的栈操作和跳转 |
| 调试 | 调试可能更困难,因为调用点被替换为函数体代码,调试器可能无法在调用点设置断点 |
调试更容易,因为可以在函数调用处设置断点 |
| 递归函数 | 不适合递归函数,因为递归调用会导致无限展开 | 适合递归函数 |
| 头文件依赖 | 通常定义在头文件中,因为需要在每个包含该头文件的编译单元中可见 |
定义在实现文件中(.cpp),头文件中只声明函数原型 |
| 使用场景 | 适用于短小、频繁调用的函数,如访问器、修改器和简单计算函数 |
适用于复杂逻辑、长函数或不需要频繁调用的函数 |
| 编译器优化 | 内联是一个建议,编译器可能会根据具体情况决定是否内联 | 编译器会生成标准的函数调用代码,不涉及内联优化 |
this指针
this指针存放当前类对象的地址

类对象是指类的实例

作用
#include <iostream>
class MyClass {
private:
int value;
public:
MyClass(int value) {
this->value = value; // 使用 this 指针区分成员变量和参数
}
void printValue() const {
std::cout << "Value: " << this->value << std::endl; // 使用 this 指针访问成员变量
}
MyClass& setValue(int value) {
this->value = value;
return *this; // 返回当前对象的引用
}
};
int main() {
MyClass obj(10);
obj.printValue(); // 输出: Value: 10
obj.setValue(20).printValue(); // 链式调用,输出: Value: 20
return 0;
}
静态成员变量
声明与定义

可以通过类名直接访问,因为静态成员变量属于类本身,而不是类的某个对象
普通成员变量:
成员变量是类的每个对象所拥有的数据,多个类对象之间对应多个成员变量
静态成员变量:不管多少类对象,都只有一个

私有的静态成员变量只能在类内部被更改
优点:遵守类成员访问规则(私有、受保护、公开)

静态成员函数
不关联到任何对象
无this指针

不能访问非静态成员变量

可以通过类名来访问

应用场景:单件模式(单例模式)
单件模式:确保一个类只有一个实例,并提供全局访问点

MyClass&:
返回类型是 MyClass&,表示函数返回一个 MyClass 类型对象的引用。在单件模式中,我们希望全局只有一个 MyClass 实例。
如果返回一个对象(非引用),调用者会得到一个拷贝,而不是对原始实例的引用。这会导致破坏单件模式。
返回引用可以确保调用者访问的是唯一的实例。
static MyClass mc;
是一个局部静态变量,它只会在第一次调用 Instance() 时被初始化。
C++11 标准之后,局部静态变量的初始化是线程安全的,因此无需额外同步。
return mc:
返回的是 mc 的引用,确保整个程序中只有一个 MyClass 实例。
MyClass::Instance()
获取单件实例,然后调用其
Test方法,输出 100。
应用场景2:访问私有成员函数

类的构造函数
构造函数是一种特殊的成员函数,用于
在创建类的对象时自动调用,以初始化对象的数据成员。
构造函数的定义与特点
名称与类名相同:
构造函数的名称必须与类名完全相同。
无返回值:
构造函数没有返回类型(甚至没有 void)。
自动调用:
在创建对象时,构造函数会被自动调用,无需显式调用。
可以重载:
一个类可以有多个构造函数,只要它们的参数列表不同(参数类型或数量不同)。

类的析构函数
析构函数是一种特殊的成员函数,用于在
对象的生命周期结束时自动调用,以执行清理操作
析构函数的定义与特点
名称:
析构函数的名称与类名相同,但前面加上波浪号 (~)。
无参数:
析构函数不接受任何参数,因此不能重载。
无返回值:
析构函数没有返回类型(甚至没有 void)。
自动调用:
当对象的生命周期结束时(例如,对象超出作用域或被显式删除时),析构函数会被自动调用。
默认析构函数:
如果用户没有定义析构函数,编译器会生成一个默认的析构函数,该函数不执行任何操作。

用构造和析构分析创建与释放
构造函数在创建对象时调用,析构函数在生命周期结束后调用(超出{}后)
getchar()是 C 标准库中的一个函数,用于从键盘读取一个字符。此处当作手动断点,输入一个字符后该作用域就结束了

全局变量在main之前调用,在main之后释放

静态成员变量在创建时调用,在main之后释放

堆区动态对象构造与析构
堆区的优点是动态分配空间
缺点是要手动释放内存,速度较慢
在分配空间时调用,在delete后释放

释放空间后记得设置空指针

类成员初始化
类成员初始化器
即在类中直接进行初始化
class MyClass
{
int x{0};
}

构造函数的初始化成员列表
在构造函数后边:成员变量,注意是圆括号:x (i)
class Myclass
{
int i{ 0 };
int x;
Myclass():x (i) //初始化x为i的值
{
}
};

该方法优先级高于类成员初始化器

构造函数赋值
开销较大,减少使用,优先级最高

构造函数参数
构造函数中可以定义多个参数,而不同参数决定构造函数可以有多个
class MyClass
{
MyClass()
{}
MyClass(int)
{}
MyClass(int,int)
{}
};

在调用时,通过参数来确定调用哪个构造函数

用{}传递参数是C++11后新的选择(建议)

用堆(指针)的方法调用,记得清空内存和指针

用{}传递参数同样是C++11后新的选择(建议)

()和{}的区别
A a3(1, 2);
语法形式:
直接调用构造函数,使用圆括号 ()。
语义:
明确表示调用类的构造函数。
如果构造函数被重载,编译器会根据参数类型和数量选择匹配的构造函数。适用场景:
更传统、更直观的方式,与函数调用语法一致。
在某些情况下(如最宽泛匹配或歧义性时),使用圆括号可能会更清晰。
A a4{1, 2};
语法形式:
使用花括号 {} 初始化,称为列表初始化(List Initialization)。
语义:
使用列表初始化语法,可以避免某些类型的隐式转换(如窄化转换)。
如果构造函数存在 explicit 修饰符,则列表初始化可能不会触发隐式转换,而圆括号可能会。
特性:更严格:
编译器会检查类型是否完全匹配,避免隐式转换带来的潜在问题。
如果构造函数是 explicit 的,则圆括号初始化可能会失败,而列表初始化通常不会(除非类型不匹配)。
主要区别
| 特性 | A a3(1, 2); | A a4{1, 2}; |
|---|---|---|
| 语法形式 | 使用圆括号 () | 使用花括号 {} |
| 隐式转换容忍度 | 容忍隐式转换(如果构造函数允许) | 更严格,避免隐式转换(除非明确匹配) |
| explicit 的影响 | 不会受 explicit 的限制(可能触发隐式转换) | 受 explicit 的限制(通常更安全) |
| C++ 标准支持 | 从 C++98 开始支持 | 从 C++11 开始支持 |
示例
假设类 A 的构造函数如下
class A {
public:
A(int x, int y) {}
explicit A(double x, double y) {} // explicit 构造函数
};
使用 A a3(1, 2);
如果调用 A(int, int),则正常。
如果调用 A(double, double),由于 explicit 的限制,可能导致编译错误(因为圆括号会尝试隐式转换将int 1,2转换为double1,2,而 explicit 禁止隐式转换)。
使用 A a4{1, 2};
如果调用 A(int, int),则正常。
如果调用 A(double, double),由于 {} 不会触发隐式转换,可能直接报错(类型不匹配)。
explicit 显式转换构造函数

explicit不支持编译器自动的隐式转换

总结
explicit和{}是为了避免编译器进行隐式转换,防止隐式转换带来的不确定问题,从而从类型层面避免出现bug
继承
基本概念
创建一个类作为父类、基类,其中包括构造函数、析构函数、成员变量、成员函数

使用:public Base使得派生类、子类A继承父类Base

通过子类可以直接访问父类中的变量

在子类中添加自己的函数和变量

子类和父类构造函数和析构函数调用顺序如图所示

派生类中的内存分配也是父类成员变量分配在前,子类在后的顺序

访问权限
public 访问说明符
类的成员被声明为public后,可以在类的外部直接访问。
派生类可以直接访问基类的 public 成员。
private 访问说明符
类的成员被声明为private后,只能在类的内部访问。
派生类不能直接访问基类的 private 成员。
访问说明符protected
protected 是一种访问控制修饰符,用于定义类成员(包括数据成员和成员函数)的访问级别。
protected 成员在类本身及其派生类(子类)中是可访问的,但在类的外部是不可访问的。
继承方式:公有继承
基类的公有成员在派生类中仍然是公有成员。
基类的保护成员在派生类中仍然是保护成员。
基类的私有成员在派生类中不可直接访问,但可以通过基类的公有或保护成员函数间接访问。
通过派生类对象可以访问基类的公有成员。

继承方式:保护继承
基类的公有成员和保护成员在派生类中都变为保护成员。
基类的私有成员在派生类中不可直接访问,但可以通过基类的公有或保护成员函数间接访问。
通过派生类对象不能访问基类的任何成员(因为它们都变成了保护成员)。

继承方式:私有继承
基类的公有成员和保护成员在派生类中都变为私有成员。
基类的私有成员在派生类中不可直接访问,但可以通过基类的公有或保护成员函数间接访问。
通过派生类对象不能访问基类的任何成员(因为它们都变成了私有成员)。

多态
多态是面向对象编程(OOP)的三大核心特性之一(继承、封装、多态),指同一操作作用于不同的对象时,会产生不同的执行结果。其本质是通过继承或接口实现,让子类以父类的形式出现,并在运行时动态调用实际对象的方法。
从汽车的角度理解多态,可以将其类比为“不同车型共享同一操作接口,但行为表现不同”。
同一操作:例如“启动汽车”、“加速”。
不同行为:不同车型(如电动车、燃油车)对同一操作的具体实现不同。
动态绑定:运行时根据实际车型调用对应的方法。
定义一个父类

定义两个子类,且有与父类相同的函数

单独调用每个类中的函数

定义两个函数,参数不同,一个指针传参一个引用传参

此时如果调用每个类中的函数,只会调用父类的函数

虚函数
通过在基类中将成员函数声明为virtual,允许派生类重写Override该函数,并在运行时
根据对象的实际类型调用对应的函数版本。
父类中加上关键字“virtual”

子类中加上关键字“override”

此时就可以实现调用子类函数了

纯虚函数和抽象类
纯虚函数是一种特殊的虚函数,它没有函数实现,仅在基类中声明,并要求派生类必须提供具体的实现。

包含纯虚函数的类称为抽象类(Abstract Class),不能直接实例化。
抽象类是包含至少一个纯虚函数的类。
包含纯虚函数的类无法直接实例化,只能作为基类被派生类继承。

子类必须实现所有继承的纯虚函数,才能成为具体类,即可以被实例化。

析构函数的虚函数
给两个类添加析构函数
注意
析构函数不能是私有的,如果析构函数是私有的,外部代码将无法调用它,从而导致资源无法正确释放,进而引发内存泄漏或其他资源管理问题。

调用正常

当我们用堆区创建对象调用时,正常
此时xt的自动变量类型为
XTask*

当使用XThread*变量类型调用时,并没有调用子类的析构函数

为了防止这种问题,养成习惯将虚构函数设置为虚函数

UML图
UML(Unified Modeling Language)是一种标准化的图形化建模语言,用于描述、可视化、构建和记录软件系统的结构和行为。
下图是一个UML图

继承关系,如图中LogFileOutPut和LogConsoleOutput继承了LogOutPut

父类是定义了接口,子类进行具体实现

由Logger将父类组合起来,实现目标功能

在设计过程中,每一条箭头都是一个依赖,对于项目,我们应该减少互相依赖,尽量单向依赖
LogFac是一个工厂类,用于组合很多个方法最终实现一个系统,Logger是其中一个方法。

组合
组合(Composition)是一种对象之间的关系,其中一个类(称为“组合类”或“整体类”)包含另一个类(称为“成员类”或“部分类”)的对象作为其成员。
组合是面向对象编程中实现“has-a”关系的一种方式,与继承(“is-a”关系)相对。
根据上文的UML图,创建两个类,其中一个类包含另一个类的对象

调用组合类,构造和析构顺序如下

用单件模式修改LogFac类
将构造函数私有,防止外部实例化

此时无法在外部实例化

获取单件实例的静态方法
利用了 C++11 引入的
局部静态变量初始化的线程安全性特性。
static LogFac fac;定义了一个局部静态变量
fac。局部静态变量在函数第一次被调用时初始化,并且在程序的生命周期内只会被初始化一次。返回引用:
函数返回
对 fac 的引用,确保调用者获得的是同一个实例。

访问该函数可进行实例化

委托
委托(Delegation)通过组合和函数指针来实现的,与指针不同的是,委托不包含另一个类的对象,而是
包含另一个类的指针
根据UML图,创建LogOutPut类,该类是抽象类,只声明一个函数当作接口,不提供具体实现。
virtual void Output(const string& log) = 0;
纯虚函数的语法是通过在函数声明后加上 = 0 来实现的。
virtual 是一个关键字,用于实现多态性。
纯虚函数的作用是要求所有继承自 LogOutPut 的派生类必须实现该函数,否则派生类本身也会变成抽象类,无法实例化。
const string& log:
函数 Output 接受一个 const string& 类型的参数,表示需要输出的日志内容。
使用 const 和引用(&)的方式,可以避免不必要的字符串拷贝,提高效率,同时保证传入的字符串不会被修改。

在Logger中进行委托
Write 方法用于将日志信息传递给output_ 指针指向的对象进行输出。
output_是一个指向LogOutPut的指针,调用其Output 方法实现日志输出。
由于
LogOutPut是一个抽象基类,output_实际上指向的是其某个派生类的对象。
调用
output_->Output(log)时,会根据output_ 的实际类型调用相应的派生类实现。
SetOutPut 方法用于设置 output_ 指针,允许动态更改日志输出方式。

进行OutPut的实现

调用

析构中添加内存释放

以上的方法旨在降低代码的耦合

浙公网安备 33010602011771号