C++ 学习笔记(关联CG)#
一、前言#
- 为何要写这篇总结性笔记:我们学校大一的程序设计与算法课程是基于C语言进行的,在 C 语言基础扎实的情况下,加之学习 CG 不可避免会使用C++,于是我打算好好系统地学习一下,尤其是许多C++的现代特性,这在 CG 里有重要用途。这篇文章可以相当于是一份给有相同背景的人的参考路径吧,里面也记录了我踩过的坑,也包含了一些 CG 里 C++的常见应用场景,我尽量不写成长篇大论的语法字典。
- 学习方式:我给自己制定了一个适合 CG 方向学习 C++ 的路线(有些部分会比较浅显,因为对于我来说这不是重点)。其中基础的知识点我会折叠起来,同时用 [点击展开] 进行了标注,一是避免增加篇幅,二是让会的人能快速跳过,以免影响观感。
注:有不当之处欢迎在评论区指正!
二、C++ 与 C 的基础语法的不同点#
引用#
引用在 CG 里的用途主要是在函数传参,不用再拷贝一份,直接可进行修改,或者加上 const 变成只读。
1void draw(const Mesh& mesh); // 不修改对象,且不复制对象2void setShader(const Shader& shader);3void update(Camera& camera); // 修改namespace#
在引擎里可以看到如下结构,很方便区分哪个函数是哪个模块
WARNING不要在头文件里面写
using namespace,别人包含你头文件后会被迫卷入这一切!
1namespace Engine::Render2{3 void Draw();4}5//调用时6Engine::Render::Draw();7Engine::Math::Normalize();string#
std::string 比C中的 char[] 字符串更加方便,本质已经是一个类。
string 的常见操作 [点击展开]
-
创建
string:
std::string s1 = "Hello!";或者std::string s2("world!"); -
常用成员函数
1s.size(); // 长度2s.empty(); // 是否为空3s.clear(); // 清空4s.push_back(); // 尾部添加字符5s.pop_back(); // 删除最后一个字符6s.substr(); // 截取子串7s.find(); // 查找字符串8s.c_str(); // 转 const char*
下面是更重要的一点: 由于在 OpenGL 中 Shder / modelPath / texturePath 等的存储一般是字符串
1std::string Shader = "...";但是很多函数(比如 OpenGL、GLFW、Assimp 等 C 风格接口时)需要的是 const char*,这就需要通过 shader.c_str()与C字符串互换,返回值就是 const char*。
inline#
内联一般用在数学库里面,许多函数比如 dot()、cross()、normalize() 等,建议编译器将函数直接展开,减少函数调用开销。
IMPORTANT
- 类里面定义的成员函数,默认都是 inline
- inline 允许函数定义到头文件,当有多个
.cpp都包含该头文件时,不会发生函数重复定义
函数重载与默认参数#
默认参数写在参数列表末尾,可以替换简单的函数重载,让图形学 API更加简洁。在 CG 中加载纹理 LoadTexture(path) 内部可能就是 LoadTexture(path, true),这里的 true表示默认翻转图片。
nullptr#
为什么不用 NULL:
因为很多编译器就是 #define NULL 0,本质上 NULL 是0,可能会导致函数重载有问题。
enum class#
enum class 是现代 C++ 推荐的枚举类型,必须通过 类型名::成员名 访问,不会污染命名空间。
例如纹理过滤
1enum class FilterMode // 纹理过滤方式2{3 Nearest,4 Linear5};6// 调用7texture.SetFilter(FilterMode::Linear);面向对象#
struct 与 class#
C++ 的 struct 可以包有成员函数、构造函数、析构函数、继承……几乎和 class 一样。两者的区别在于默认权限不同。
IMPORTANT现代 C++ 有个约定:
struct表示纯数据,class表示有行为的对象。
this 指针与 const 成员函数#
根据 this 指针可以返回自己的特性,可以设计链式调用(Method Chaining)。
- C++ 风格
1class Camera2{3public:4 Camera& Move(/*参数*/)5 {6 position += ...;7 return *this; // 函数返回 camera自己的引用(Camera&)8 }9};10// 调用11camera.Move(1.0f, 0.0f, 0.0f).Rotate(0.1f, 0.0f);- C 风格相似写法
1Camera* Move(Camera* self); // 类似 C 风格的“面向对象”const 成员函数背后真正的实现原理 [点击展开]
每一个非静态成员函数,都隐藏着一个 this 指针。 所以成员函数内部之所以能直接访问 position,其实都是通过 this->position 做到的。
如果是 const 成员函数,下面举个例子:
1class Camera {2public:3 int position;4
5 void Print() const {6 // 这句其实等价于 this->position = 10;7 position = 10; // 编译错误!8 // 错误原因:this 的类型是 const Camera*,9 // 所以 this->position 是 const int,不能修改。10 }11};*this 的类型是 Camera* const (顶层const,this 不能变,*this 可以变)
而成员函数末尾的 const 修饰的是 *this,因此 this 类型变成了 const Camera* const,指向的对象从“可修改”变成了“只读”。
构造函数与成员初始化列表#
在 CG 里面,我们创建一个 Shader 对象,Texture 对象。
1Shader shader("lighting.vert", "lighting.frag");2Texture texture("wall.png");在 Shader 构造函数里面就可能已经完成了读取 Shader 文件,编译 Shader,链接程序。在 Texture 构造函数里可能已经完成加载图片,上传 GPU,保存纹理 ID。
IMPORTANT请了解
有三种成员变量必须用初始化列表[可展开详情]
const 成员变量(常量,初始化后不能改)
引用成员变量(引用必须绑定,不能空着再“赋值”)
没有默认构造函数的类类型成员(它必须马上被构造,不能等到函数体再初始化)
static 成员#
非静态成员函数需要创建一个对象后才能调用,而静态成员函数可以通过 类名::函数() 直接调用。本质还是静态成员函数没有 this 指针,这也导致了 static 成员函数没法访问普通成员函数与普通成员变量。
C++17 后,static 成员的声明与定义可以直接写在类里面,会用到 inline 关键字,目的是告诉编译器和链接器这个变量可能在多个编译单元(.cpp 文件)中被定义,要帮助我合并成同一个,避免报重定义错误。
其实可以用更简单的 constexpr 修饰,constexpr 隐含 inline,那么它其实一直都可以在类内初始化。
1// Camera.h (C++17)2class Camera {3public:4 inline static int count = 0; // 声明 + 定义,可以直接赋值5
6 // C++17 后,constexpr 隐含 inline,所以对于 const 成员更推荐这样写7 constexpr static int maxFov = 120;8};对象生命周期#
记住这个准则:资源的生命周期一定要比使用它的对象长。
简单举个 OpenGL 里的一个例子,Shader 和源码字符串
1const char* vertexSrc = R"(...着色器代码...)"; // 源码字符串是“资源”2GLuint shader = glCreateShader(GL_VERTEX_SHADER);3glShaderSource(shader, 1, &vertexSrc, nullptr); // 只是告诉 OpenGL 源码在哪,关键在于这里的 &vertexSrc4glCompileShader(shader);5
6// 危险:如果 vertexSrc 在这个点之前被释放(比如出了作用域),glCompileShader 可能读到垃圾7// 安全做法:保证 vertexSrc 在 glCompileShader 调用时依然有效这样就诞生了 RAII 的自动化管理流程:对象生命周期管理资源生命周期。
对象组合#
现代 CG 和游戏引擎更偏向组合而不是复杂的继承,记住下面几点即可
成员对象选构造,再构造外层对象,析构相反。
成员对象几乎都通过初始化列表完成初始化。
explicit#
只要构造函数只有一个参数,优先考虑加上 explicit 来禁止隐式转换,防止编译器偷偷创建对象。
深入了解隐式转换 [点击展开]
假如我有一个 `Point` 类,可以用两个坐标构造1class Point {2public:3 Point(float x, float y) {}4};下面这种写法在不加 explicit 是合法的
1Point p = {1.0f, 2.0f};2Point p = Point(1.0f, 2.0f);3// 编译器偷偷把代码变成了这样,实际上创建了一个临时对象,再用这个对象初始化 p。4// 不过,实际上编译器几乎一定会把这个过程优化掉,并不会真的生成了一个临时对象,这个优化叫做复制消除拷贝构造函数#
标准写法:
1ClassName(const ClassName& other); // 拷贝构造2
3// 调用时4Camera c1;5Camera c2 = c1; // 这里是初始化不是赋值!6Camera c2(c1); // 上式等于这个浅拷贝和深拷贝 [点击展开]
先来看一个危险的浅拷贝1class Camera {2public:3 float fov;4 float* config; // 注意这个指针!!指向一段动态内存(比如一堆额外参数)5
6 Camera(float f) : fov(f) {7 config = new float[100]; // 从堆上分配100个float8 }9
10 ~Camera() {11 delete[] config; // 释放这块内存12 }13};14// 如果这样写15Camera c1(90.0f);16Camera c2 = c1; // 调用编译器自动生成的拷贝构造函数编译器自动生成的拷贝构造函数做的是“浅拷贝”,只会机械复制所有成员变量,这就导致 c1.config 和 c2.config 现在指向堆里的同一块内存,当函数结束时,c1 和 c2 都会被销毁,于是同一块内存被释放了两次。
于是有了深拷贝
1// 在类里自己写拷贝构造函数2 // 深拷贝的拷贝构造函数3 Camera(const Camera& other) : fov(other.fov)4 {5 config = new float[100]; // 1. 先给自己分配一块全新的内存6 for (int i = 0; i < 100; ++i)7 {8 config[i] = other.config[i]; // 2. 把对方内存里的数据逐字节复制过来9 }10 }在现代 C++ 中,只要不用裸指针(float*),改用智能指针或直接存容器(如 std::vector<float>),标准库的默认拷贝构造函数就是深拷贝,不用写 new/delete,也不用写拷贝构造函数,这就是零原则。
在 CG 中,Texture、Shader、Mesh 等资源类通常都需要认真设计拷贝行为。因为它们管理的不只是几个变量,而是 GPU 资源或堆内存。很多现代引擎宁愿禁止拷贝(= delete),也不会让编译器默认进行浅拷贝,以避免资源重复释放或状态混乱。
拷贝赋值#
两个对象都必须已经存在,当 c1 = c2 时,调用的是拷贝赋值运算符
在 CG 中,资源类通常都会自己实现 operator=,或者直接禁用拷贝。
深入了解拷贝赋值运算符(深拷贝)[点击展开]
1class Camera {2 float* config; // 动态分配的内存3 size_t size;4public:5 // 正确的拷贝赋值6 Camera& operator=(const Camera& other)7 {8 if (this == &other) // 1. 自赋值检查9 return *this;10
11 delete[] config; // 2. 释放旧资源12
13 size = other.size;14 config = new float[size]; // 3. 分配新资源15 for (size_t i = 0; i < size; ++i) // 4. 深拷贝数据16 config[i] = other.config[i];17
18 return *this;19 }20};在上面的例子中,Camera& operator=(const Camera& other) 叫拷贝赋值运算符,重载了 = 号。当把一个对象赋值给另一个已经存在的对象时调用。
返回 Camera& 的原因:为了支持链式赋值。
这是一个深拷贝。
重载运算符#
数学库(如 GLM)大量使用运算符重载,使代码更接近数学表达式。
最经典的重载 + 以满足向量加法:
1class Vec32{3public:4 float x, y, z;5
6 Vec3 operator+(const Vec3& other) const7 {8 return {x + other.x,9 y + other.y,10 z + other.z};11 }12};现在 Vec3 c = a + b; 实际就是 Vec3 c = a.operator+(b);
继承#
组合优于继承
虚函数与虚析构函数(override关键字)#
虚函数基础知识点 [点击展开]
先看没有虚函数的类
1class Animal2{3public:4 void Speak() { std::cout << "Animal";}5};6
7class Dog : public Animal8{9public:10 void Speak() {std::cout << "Dog";}11};12
13// 创建 Dog 类并试图调用 Dog 的 Speak()14Animal* animal = new Dog();15// 这里是在堆上分配一块足够大的内存,用来存放一个 Dog 对象。16// 括号 () 就是告诉编译器:用默认构造函数来初始化这个对象。17
18animal->Speak();最后其实 animal->Speak(); 调用的是 Animal 类里面的 Speak(),而不是 Dog 里的 Speak() 。
这是因为:
没有 virtual 时,编译器是根据“指针的类型”来决定调用哪个函数,而不是根据“指针指向的对象究竟是什么”。因此,无论把什么派生类对象塞给这个指针,调用的永远是 Animal::Speak。这便是 “静态绑定”,这也是 C++ 出于对性能的考量才这样设计,如果每个函数调用都要去猜对象的真实类型,程序运行就会慢很多。
于是我们对 Animal 类里面的 Speak() 前加上 virtual,来实现动态绑定,之后再调用 animal->Speak();指向的就是 Dog 里的 Speak() 了。
1class Animal2{3public:4 virtual void Speak() { std::cout << "Animal"; }5};从内存角度理解虚析构函数 [点击展开]
当我确定这个类会被别人继承时,那就要用虚析构函数。
还是上面的例子,如果没有 vietual,当我想 delete animal 时,编译器只会 ~Animal(),而没有 ~Dog(),导致内存泄漏。
这里还要注意一下析构顺序,先派生类,再基类。因为如果先释放了基类,基类的指针的都没了,派生类也没法清理了
虚析构函数标准答案:
1virtual ~Shape() = default;TIP在派生类中重写一个基类的虚函数时,建议在函数声明末尾加上override关键字。
void Speak() final override;
假如我重写时将函数名不小心拼写错误,编译器可以直接报错,提醒你你的函数名有问题,实际上并没有实现重写。
虚函数就是实现多态的核心机制。它让你可以用统一的基类接口(如 Draw()、Update()、Render())操作不同类型的对象,而真正执行哪个函数,由对象的实际类型在运行时决定。这也是游戏引擎、渲染框架和 GUI 系统广泛采用的设计方式。
多态#
继承是基础,虚函数与基类指针(或引用)是工具,多态才是最终目的。
多态 = 用统一的接口,操作不同类型的对象,而对象自己决定执行哪种行为。 从而渲染器、场景管理器、游戏循环等系统只依赖统一的接口,而不用关心对象到底是谁,这正是现代游戏引擎和图形学框架能够不断扩展新对象类型,而核心代码几乎不用修改的关键。
纯虚函数与抽象类#
纯虚函数是啥 [点击展开]
纯虚函数只有接口,没有实现,标志如下:
1virtual void Draw() = 0;很简单,比如基类 Shape 有一个 Draw()函数:
1class Shape2{3public:4 virtual void Draw()5 {6 std::cout << "Draw Shape";7 }8};9
10// 调用11Shape shape;12shape.Draw();这当然能运行,可是现实中 Shape 是什么并不知道,真正知道该具体画什么的的是 Cube(画个立方体)、Plane(画地板) 这些 Shape 的派生类,所以我将基类的 Draw 设置成纯虚函数,意思变成 “我不知道怎么画,你们子类自己决定。”
WARNING抽象类(含有一个纯虚函数的类)无法进行实例化对象。编译器直接禁止的。
抽象类的最大作用是规定接口,至于怎么实现,子类决定。
Rule of Zero#
现代 C++ 更推荐 Rule of Zero:尽量使用 std::vector、std::string、智能指针等 RAII 类型,让标准库帮你管理类。
STL#
vector#
CG 里的常用成员函数:
- reserve(): 在 CG 中
reserve()很重要,特别是在每帧动态生成粒子里,为避免反复扩容,建议先申请好内存。 - data(): 获取底层数组指针。
- size()
- push_back()
简单来说,顶点数据(Vertex)、索引(Index)、纹理坐标、法线、实例数据等几乎都会存放在 vector 中;构建数据时用 push_back(),提前知道数量时用 reserve(),上传到 OpenGL 时用 data() 获取底层数组指针。
pair#
std::pair 就是把两个不同类型(或相同类型)的值打包成一个对象。相当于一个轻量级二元组,常用于临时返回两个值、保存一对关联数据。
创建:
1std::pair<std::string, int> p = {"纹理路径", 1}; // 纹理路径 -> OpenGL Texture ID2auto p = std::make_pair("纹理路径", 1); // 现代项目中,如果类型很容易推导,这个也比较常见3
4// 访问通过 first,second 访问5p.first;6p.second;TIP数据多就不要写
pair了,pair<pair<...>>非常难读,直接写struct。
array#
std::array 在游戏引擎里没有 vector 常见,但会出现在:
- 固定数量的颜色、方向、顶点
- 数学工具类
- 引擎内部的小型固定缓存
deque#
std::deque 是 STL 已经帮你实现好的双端队列。与 vector 的主要区别在于 deque 是分段连续内存,而 vector 是连续内存,这也导致 deque 不能用 data()。
deque 在图形学项目里不常见,一般在:
- 输入事件队列
- 日志缓冲
- 最近 N 帧数据统计
queue#
注意:
queue 不能访问中间元素,不能遍历。
pop() 不返回元素!
deque 是真正的容器,可以遍历,访问,进行队列头尾操作;
queue 只是容器适配器,只能进行队列操作
stack#
注意事项与 queue 相同,主要用于场景树 DFS
priority_queue#
std::priority_queue = 自动帮你排序的队列,每次取出的都是优先级最高(默认最大)的元素。也是一个容器适配器。
底层逻辑默认是最大堆,复杂度 O(log n)
CG 中的应用:
- A* 路径规划(最为经典)
- 任务调度
map#
map 的底层是红黑树,查找复杂度 O(log n)
CG 中非常常见
- Shader 管理
1std::map<std::string, Shader> shaders;2shaders["PBR"]; // 直接得到 PBR 相关 shader- 资源管理器
IMPORTANT
operator[]会创建元素0,因此想要查看元素是否存在用find()。- map 不是按插入顺序排布
unordered_map#
底层是哈希表,不用排序,查找更快,用法几乎和 map 一样,现代图形学项目更偏爱 unordered_map
set 与 unordered_set#
自动去重与排序的容器 注意:
set没有push_back(),因为它不是顺序容器,没有所谓的最后一个元素,只有insert()。- 里面的元素默认是只读的。
- 没有下标,即没有
s[0]
迭代器#
现代写法:auto + iterator
1for(auto it = v.begin(); it != v.end(); it++)2for(auto &x : v)为什么讲 begin 和 end 设计成左闭右开?因为 size = end - begin 这样可以减少很多 BUG。
解引用用 *it,map / set 用 it->first / it->second。
iterator 失效 [点击展开]
- vector 失效
1auto it = v.begin();2v.push_back(10); // 可能扩容vector 扩容后,会重新分配内存,旧 iterator 指向旧内存导致无效
- erase 失效
1v.erase(it); // 错误写法2it = v.erase(it); // 正确写法erase 会返回下一个有效 iterator
map/set 的 iterator 相对稳定,是树结构,不会整体失效。
STL 算法#
现代 C++#
范围 for#
三种重要写法(值拷贝,引用,const 引用)[点击展开]
- 值拷贝:
1std::vector<int> vec = {1,2,3};2for(auto x : vec)3{4 x++; // 这里 x 是复制品,vec 里的元素最后并未改变5}- 引用:
1for(auto& x : vec)2{3 x++; // x 是容器内元素的别名,直接操控4}- const 引用
1for(const auto& x : vec)2{3 std::cout << x;4}不复制、不修改、效率高,默认首选
范围 for 是现代 C++ 最常见的遍历方式。在图形学和游戏引擎中,遍历顶点、纹理、Shader、场景节点时,最常见的写法就是 for (const auto& x : container)
Lambda#
Lambda 就是”临时创建一个函数”,用一次,不需要专门写函数名。也叫做匿名函数。
基本写法:
1auto f = [capture](/*参数*/) {/*函数体*/}2f();capture 与传参的区别在于调用者是否知晓要用哪些变量
现阶段只用知晓捕获的四种:[]、[a]、[&a]、[&]。
Lambda 在现代 C++ 中几乎无处不在:
- STL 算法(sort、find_if、for_each)
- ImGui 回调
- 线程(std::thread)
- 异步任务
- 事件系统
constexpr#
constexpr 表示:如果可以,就在编译时把结果算出来。
与 const 的区别就在于 constexpr 确保常量的编译期常量。对于定义窗口大小,数学常量经常使用
TIP推荐定义常量用
constexpr而非宏定义常量#define。因为前者有类型,有作用域,支持编译期计算,行为完全可预期
把 constexpr 当成”编译期常量工具”即可。现代图形学项目会用它定义窗口尺寸、数学常量、矩阵大小等固定配置。
左值、右值#
TIP在 C++ 里,判断左值/右值有一条非常实用的经验法则:
如果一个表达式你可以用&对它取地址,它就是左值;如果不行,它就是右值。
std::move() 与移动构造函数#
std::move 做的事就是把一个左值强行标成右值引用,让你可以把它当作右值一样窃取内部资源,从而避免了昂贵的拷贝。
移动构造函数的内部实现:[点击展开]
1class Mesh {2public:3 std::vector<float> vertices; // 用 vector 管理一堆顶点数据4
5 // 移动构造:直接偷走 other 的资源6 Mesh(Mesh&& other) noexcept : vertices(std::move(other.vertices))7 {8 // other.vertices 现在变成空的了,但可以安全析构9 }10};- 移动构造函数接收一个右值引用作为参数(就是
&&) noexcept:几乎所有的移动构造函数都应该标记为 noexcept,这样标准库容器(如 std::vector)在扩容时会更高效地使用移动而不是拷贝。- 初始化列表里
vertices(std::move(other.vertices)):直接调用了std::vector自己的移动构造函数,把other.vertices内部指向堆内存的指针“偷”了过来,然后other.vertices变成空容器。
noexcept#
noexcept 用来承诺函数不会抛出异常。可使容器移动更高效
以 std::vector 扩容为例:当 vector 需要重新分配更大的内存,并把旧元素转移到新空间时,它会检查元素类型的移动构造函数是否 noexcept。
-
如果
noexcept为真:vector会放心地使用移动构造来转移每个元素,这样扩容时只是指针交换,速度极快。 -
如果没有
noexcept:vector为了安全,会退化为拷贝构造,老老实实复制每一份数据。
RAII#
利用 C++ 对象一定会被销毁(调用析构函数)的规则,把资源的生命周期和对象的生命周期绑定在一起。
智能指针#
智能指针基本知识
std::unique_ptr: 任何时候都只有一个unique_ptr实例可以指向并拥有那块内存。
对应 CG 里的例子,一个对象对应一个 GPU 资源
1class Texture {2 GLuint id;3public:4 Texture() { glGenTextures(1, &id); }5 ~Texture() { glDeleteTextures(1, &id); }6};7
8auto tex = std::make_unique<Texture>(); // 创建,独占纹理9auto tex2 = std::move(tex); // 转移所有权,不能拷贝std::shared_ptr共享纹理、材质是典型场景:
1class Material {2 std::shared_ptr<Texture> diffuse;3 std::shared_ptr<Texture> normal;4};5
6// 多个材质可以共享同一张贴图7Material mat1, mat2;8mat1.diffuse = std::make_shared<Texture>("brick.png");9mat2.diffuse = mat1.diffuse; // 同一张贴图WARNING循环引用导致泄漏:A 持有 B 的
shared_ptr,B 也持有 A 的shared_ptr,计数永远不为 0。 共享资源才用它,否则优先unique_ptr,因为有额外开销(控制块、原子操作)。
std::weak_ptr
文件#
std::ifstream#
基本用法
1#include <fstream>2#include <string>3#include <sstream>4
5// 打开文件6std::ifstream file("vertex_shader.glsl");7if (!file.is_open()) {8 // 处理错误9}10
11// 一次性读完整内容(最常用在Shader)12std::stringstream buffer;13buffer << file.rdbuf();14std::string shaderSource = buffer.str();15
16// 逐行读(处理配置文件)17std::string line;18while (std::getline(file, line)) {19 // ...20}21// file 离开作用域,自动调用 fclose用完后重新打开同一个文件,记得先 close() 或让它离开作用域重建。
ofstream#
基本用法
1#include <fstream>2#include <string>3
4// 创建并打开文件(默认会创建新文件,若存在则清空)5std::ofstream out("log.txt");6if (!out.is_open()) {7 // 处理错误8}9
10// 就像用 cout 一样写入11out << "Render time: " << deltaTime << " ms\n";12out << "FPS: " << fps << std::endl;13
14// 也支持二进制写入(如图形截图原始数据)15// std::ofstream bin("screenshot.raw", std::ios::binary);16// bin.write(reinterpret_cast<const char*>(pixels), size);17
18// out 离开作用域,自动关闭文件默认打开模式 std::ios::out 会清空已存在的文件,想追加内容用 std::ios::app
二进制文件#
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
