【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的实现

调用

析构中添加内存释放

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

posted @ 2025-04-28 19:38  plusu  阅读(91)  评论(0)    收藏  举报