学习资料
2025 冬季 InfiniTensor 训练营|C++ 实践课(三)
发表于:

2025 冬季 InfiniTensor 训练营|C++ 实践课(三)

第一节课写的程序,数据输入(input)和计算(calculate)混合在一起,第二节课我们把这两块功能拆成了独立的函数,代码一下子清爽了不少。

更关键的一步是引入了 struct(结构体)——把原来散落在各处的 datasize 两个变量打包进了一个结构体里。它让数据的组织方式从"散装"变成了"整装",也为今天要讲的面向对象编程埋好了伏笔。

在上节课的基础上继续往前推——先搭一个操作服务,能接收一串数字输入,然后对它们求和。这节课要做的,就是把代码重构得更规范、更接近真实项目中的写法。

VLA 与 const 关键字

我们尝试进行一项操作——将数组初始化为零:

int data[size] = {0};

结果编译器报错:

error: variable "data" may not be initialized

等等,我明明写了 = {0},怎么还说"未初始化"?

问题出在 VLA(变长数组,Variable Length Array) 上。

什么是 VLA?

VLA 是指使用运行时确定的动态大小来创建数组的方式。而上文例子中的 size 虽然使用 size_t 类型初始化为 3,但对编译器来说,它是一个运行时才能确定的变量——它可以是 3、5、10或任何值。

编译器的逻辑很简单:我在编译阶段就得知道你要分配多大的内存,你给我一个运行时才能确定的 size,我怎么分?

解决方案?加 const:

const size_t size = 3;
int data[size] = {0};  // 编译通过

在这个例子中,const size_t size = 3 是编译期常量表达式,因此可以作为数组长度

C99 之后的 C 语言其实支持 VLA,但 C++ 标准不支持。这算是两门语言之间一个细微差别。

动态数组与 capacity

现实开发中,数据量从来不是固定的。今天处理 3 个 5 个,明天可能要处理成百上千个。固定大小的数组显然不够用。

于是我们引入了一个关键概念——capacity(容量)。

struct Vector {
    double* data;   // 存储数据的指针
    int size;       // 当前实际存储数据个数
    int capacity;   // 总容量
};

size ≠ capacity

  • size:当前已存储的数据个数,即数组中实际有意义的元素数量(实际用了多少)
  • capacity:数组的总容量,即为数据分配的实际内存空间能容纳多少元素(最多能装多少)

例如,你可以把 capacity 设为 100,而 size 根据实际存储数据量动态变化。只要不超过容量,就不用重新分配内存。

为了使代码语义更加清晰,我们将结构体名称由 numbers 重命名为 vector(向量),这是一个动态变化的数据结构。当然,你也可以叫它"动态数组(dynamic array)",本质上一样。

动态内存管理

new 与 delete 操作符

有了 capacity,接下来要解决的就是:内存从哪来?

C++ 的答案是 newdelete

double* data = new double[capacity];  // 分配
delete[] data;                         // 释放

⚠️ 释放数组内必须用 delete[],不能用 delete

如果用 delete 释放 new[] 分配的数组,会导致未定义行为,可能出现析构不完整、内存泄漏、程序崩溃等问题。

构造函数与析构函数

手动 new / delete 太容易忘、容易出错。于是 C++ 引入了构造函数和析构函数:

class Vector {
public:
    Vector() {    // 构造函数:对象创建时自动执行,负责初始化
    }
    ~Vector() {   // 析构函数:对象销毁时自动执行,负责清理
    }
};
  • 构造函数(Constructor):名字和类名一样,无返回值,用于初始化。
  • 析构函数(Destructor):名字前加 ~,无返回值,用于清理资源。

还有一个更简洁高效的写法——初始化列表,在构造函数参数列表后面使用冒号,然后列出所有成员变量及其初始值,用逗号分隔:

Vector() : data(nullptr), size(0), capacity(0) {
    // 构造函数体可为空
}

类的封装与访问控制

public 与 private

类(class 中的成员(包括变量和函数)可以设置为不同的访问级别,主要有两种:

  • public(公有):可以被类的外部代码直接访问
  • private(私有):只能被该类的成员函数访问,类的外部代码无法直接访问

封装可以保护数据不被意外破坏,同时可以在成员函数中添加必要的检查和验证逻辑。

class Vector {
private:       // 私有:外部代码无法直接访问
    double* data;
    int size;
    int capacity;

public:      // 公有:提供对外接口
    Vector();
    ~Vector();    // 向 Vector 中添加元素
    void push_back(double value);    // 获取指定位置的元素
    double get(int index);
};

this 指针

在类的成员函数中,存在一个特殊的指针——this 指针,指向当前对象(即调用该成员函数的对象)。遇到命名冲突时,this-> 可以明确告诉编译器:我要的是成员变量,不是参数。

void Vector::push_back(double value) {  // 通过 this-> 明确访问当前对象的成员
    this->data[this->size] = value;
    this->size++;
}

RAII 模式

把上面这些串起来,就得到了 C++ 里重要的编程惯用法(idiom)之一——RAII(Resource Acquisition Is Initialization,资源获取即初始化)。

核心思想就一句话:

资源的获取绑定构造函数,资源的释放绑定析构函数。

RAII 模式带来三个巨大好处:

  • 自动管理资源:不用手写释放代码,忘记释放的概率大幅降低;
  • 异常安全:即使程序中途异常,栈上的局部对象也会被正确析构;
  • 避免内存泄漏:资源生命周期 = 对象生命周期,对象销毁了,资源就不会泄露

newdelete
mallocfree
成对出现,缺一不可。

模板类(Template)

为什么需要模板?

现在的 Vector 只能存 double。想存 int?再写一遍。想存 string?再写一遍。这显然不可接受。

模板(Template) 就是来解决这个问题的。

定义模板类

使用 template 关键字可以定义模板类,将具体的类型替换为类型参数 T(也可以使用其他名称,如 U、ValueType 等)

template <typename T>
class Vector {
private:
    T* data;     // T 是占位符,表示任意类型
    int size;
    int capacity;
public:
    void push_back(T value);    // 参数类型为 T
    T get(int index);    // 返回类型为 T
};

typename 关键字用于声明一个类型参数。在模板声明中,classtypename 可互换,即 template<class T> template<typename T> 是等价的。

模板像是一个“类型工厂”——你给它一个类型,它给你生成一整套对应的代码。

使用模板类

实例化模板类时,需要在尖括号中指定具体的类型:

Vector<double> doubleVec;    // 存储 double 类型
Vector<int> intVec;       // 存储 int 类型
Vector<std::string> strVec;    // 存储字符串

模板类的声明和实现通常都要放在 头文件(.h/.hpp 里。因为编译器在实例化时需要看到完整定义,这与普通类将声明和实现分离不同,是模板机制的特殊要求。

头文件与源文件的分离

传统的 .h 和 .cpp 分离

对于非模板类,通常将类的声明放在头文件(.h/.hpp)中,将实现放在源文件(.cpp)中。这种分离有几个好处:

  • 代码组织清晰:声明与实现分离,便于管理
  • 编译加速:修改实现文件不需要重新编译使用该类的代码
  • 信息隐藏:向使用者隐藏实现细节

模板类的特殊性

由于编译器在实例化模板时需要看到完整的定义,模板类需要特殊处理,声明和实现通常都放在头文件中。

这种做法虽然打破了传统的分离原则,但能确保编译器在实例化模板时能够访问完整的代码。这也是 C++ 模板系统的一个独特之处。

Git 版本控制基础

暂存与提交

在开发过程中,使用 Git 进行版本控制是一个好习惯。我们通常遵循以下流程:

  • git add:将文件添加到暂存区
  • git commit:将暂存区的修改提交到本地仓库
  • 每节课结束时提交一次,这样就可以清晰地知道上一节课结束时的代码状态

git stash 命令

git stash 可以暂存当前未提交的修改,以便切换到其他分支或进行其他操作。

git stash 默认会暂存已跟踪文件的修改,包括已暂存和未暂存的修改,但不会包含未跟踪文件。若要同时暂存未跟踪文,可使用 git stash -ugit stash --include-untracked 来包含未跟踪的文件。

课后 Q&A

Q1:波浪线(~)在构造函数和析构函数中有什么区别?

构造函数不加波浪线,其名称与类名相同。析构函数在名称前加波浪线(~),表示这是与构造函数对应的析构函数。

Q2:include 与 Python 的 import 类似吗?

不完全类似,但功能相近。include 更接近于在预处理阶段把目标文件内容“复制粘贴”到当前文件中;而 Python 的 import 是模块加载机制,不是简单的文本插入。。

Q3:模板参数只能是类型吗?

不是。模板参数除了类型参数(如 typename T)外,还可以是非类型的常量参数(如 template<int N>)。不过非类型参数有一些限制,实际使用中类型参数更为常见。

更多课程细节查看

本次课程回放可访问 InfiniTensor 官网:https://www.infinitensor.com 查看,或点击 【阅读原文】 跳转至 InfiniTensor 官网。

觉得有用就转发给也在学 C++ 的朋友吧! 新一期训练营也将于近期开启报名,欢迎关注公众号,第一时间获取课程与报名信息📢

关注与交流