告别 C 语言:现代 C++ 引用、RAII 思想与高频 STL
上一章你已经摸清了 C 语言的底层真相:指针、内存、结构体对齐。虽然 C 语言能写一切,但如果你真的用纯 C 来开发一整套视觉识别 + 串口通信系统,你会发现每一行代码都是在手动搬砖——
malloc完了不敢忘free,数组多大得自己记,换一个新数据源就得重写一堆重复逻辑。
现代 C++ 就是把这些苦力活自动化的武器。这一章讲的是视觉上位机开发中真正天天在用的特性。
一、引用(Reference):干掉大部分裸指针
1.1 什么是引用?
引用就是给一个已存在的变量起一个别名。它和指针最大的区别是:引用一旦绑定就不能改,而且不存在”空引用”。
int a = 42;int &ref = a; // ref 是 a 的别名,它们就是同一个东西
ref = 100;printf("a = %d\n", a); // 100,改 ref 就是改 a你可以把引用理解为”指针的语法糖”——底层干的事几乎一样(传地址),但表面上你不需要写
*和&来解引用和取地址了,代码干净很多。
1.2 函数传参:引用 vs 指针
上一章讲了 C 语言想在函数里修改外部变量必须传指针。在 C++ 中,用引用可以做到同样的事,但写起来和用普通变量一模一样:
// C 风格:传指针void detect_c(float *out_x, float *out_y) { *out_x = 1.5f; *out_y = 2.3f;}
// C++ 风格:传引用(推荐)void detect_cpp(float &out_x, float &out_y) { out_x = 1.5f; // 直接赋值,不需要 * 解引用 out_y = 2.3f;}
int main() { float x = 0, y = 0;
detect_c(&x, &y); // C:调用时要取地址 detect_cpp(x, y); // C++:直接传变量名,干净!
return 0;}1.3 const 引用:只读保证与传参法则
如果你不希望函数修改传入的数据,加上 const:
// 结构体或大对象:使用 const 引用避免深拷贝void process_target(const Target &t) { // 只能读取 t 的成员,试图修改 t.x = 10 会在编译期直接报错 t.print();}传参黄金法则(高频避坑)
- 原生基本类型(
int,float,double,bool等):直接传值!
它们只有 1~8 字节,直接通过 CPU 寄存器传递极快。如果写成const float &x,反而强制生成了一个 8 字节的指针地址,CPU 还需要一次额外的内存解引用寻址,纯属负优化; - 复合大对象(结构体、
std::string、std::vector等):必须传const &!
避免每次函数调用都拷贝整段数据; - 关于 OpenCV
cv::Mat的特别说明:
后面第六章我们会详细剖析:cv::Mat赋值是浅拷贝(只复制约 96 字节的矩阵头,并不拷贝底层的兆级像素数据)。但按值传递依然会触发一次矩阵头拷贝,并对内部的原子引用计数进行一次增减操作(涉及 CPU 内存屏障)。在 60~100 FPS 的高频视觉循环中,依然推荐使用const cv::Mat &img传参,消除一切原子操作开销并明确只读语义。
二、类(Class)与 RAII:资源自动管理
2.1 从结构体到类
C 语言的 struct 只能装数据。C++ 的 class 可以同时装数据 + 操作这些数据的函数(方法):
class Target {public: int color; float x, y, z; float confidence;
// 成员函数:计算到相机的距离 float distance() const { return std::sqrt(x * x + y * y + z * z); }
// 成员函数:打印目标信息 void print() const { printf("[目标] 颜色:%d 坐标:(%.2f, %.2f, %.2f) 距离:%.2f米 置信度:%.0f%%\n", color, x, y, z, distance(), confidence * 100); }};
int main() { Target t1 = {1, 0.5f, 1.2f, 3.0f, 0.95f}; t1.print(); return 0;}
public:表示这些成员谁都能访问。还有private:(只有类自己的成员函数能访问)和protected:。在初学阶段先全部用public,后面实际项目中需要保护内部状态时再用private。
2.2 构造函数与析构函数
构造函数:对象被创建时自动调用,用来初始化。
析构函数:对象被销毁时自动调用,用来清理资源。
#include <cstdio>
class SerialPort {public: int fd; // 文件描述符(Linux 下串口用 fd 打开)
// 构造函数:对象一创建就打开串口 SerialPort(const char *device) { printf("打开串口: %s\n", device); // fd = open(device, O_RDWR); // 真实代码 fd = 42; // 模拟 }
// 析构函数:对象销毁时自动关闭串口 ~SerialPort() { printf("关闭串口 fd=%d\n", fd); // close(fd); // 真实代码 }
// 工业级规范:独占资源禁止拷贝! // 防止 SerialPort p2 = p1 导致同一个 fd 被析构两次 (Double Close 灾难) SerialPort(const SerialPort&) = delete; SerialPort& operator=(const SerialPort&) = delete;
void send(const uint8_t *data, int len) { printf("发送 %d 字节\n", len); // write(fd, data, len); // 真实代码 }};
int main() { { SerialPort serial("/dev/ttyUSB0"); // 构造:自动打开 uint8_t packet[] = {0xA5, 0x01, 0x02}; serial.send(packet, 3); } // 出了这个大括号,serial 对象销毁,析构函数自动调用 → 自动关闭串口!
printf("串口已经被自动关闭了\n"); return 0;}2.3 RAII:现代 C++ 最核心的思想
上面的例子就是 RAII(Resource Acquisition Is Initialization,资源获取即初始化) 的体现:
- 资源在构造函数中获取(打开文件、分配内存、连接相机);
- 资源在析构函数中释放(关闭文件、释放内存、断开相机);
- 只要对象的生命周期结束,资源保证被释放,不需要你手动
close()、free()。
这就是为什么现代 C++ 几乎不需要手动
malloc/free!所有资源(内存、文件、串口、相机连接)都可以绑定到一个对象上,让 C++ 的作用域机制帮你自动管理。
如果你记住一句话就够了:在 C++ 中,永远不要在外面裸写new/delete或malloc/free,把它们藏进对象的构造和析构函数里。
三、STL 容器:C++ 的标准军火库
STL(Standard Template Library,标准模板库)是 C++ 自带的数据结构和算法集合。用了 STL,你再也不需要手写动态数组、链表、排序算法。
3.1 std::vector:动态数组(用得最多的容器,没有之一)
C 语言的数组大小必须编译时确定,或者手动 malloc 扩容——极其痛苦。std::vector 帮你全部搞定:
#include <vector>#include <cstdio>
int main() { // 创建一个空的 float 动态数组 std::vector<float> distances;
// 往末尾追加元素(自动扩容,不用管内存) distances.push_back(1.5f); distances.push_back(2.3f); distances.push_back(0.8f);
// 获取大小 printf("共 %zu 个距离值\n", distances.size()); // 3
// 用下标访问(和普通数组一样) printf("第一个: %.1f\n", distances[0]); // 1.5
// 遍历 for (int i = 0; i < (int)distances.size(); i++) { printf("distances[%d] = %.1f\n", i, distances[i]); }
// 不需要 free!vector 出了作用域自动释放内存(RAII!) return 0;}在视觉代码中的典型用法
// 存储识别到的所有目标struct Target { int color; float x, y, z;};
std::vector<Target> targets;
// 每识别到一个目标就 push_back 进去targets.push_back({1, 0.5f, 1.2f, 3.0f});targets.push_back({2, 1.0f, 0.8f, 2.5f});
// 按距离排序、找最近的那个、删除不合格的……全部有现成方法
vector可以替代你 90% 的 C 语言动态数组需求。从现在开始,忘掉malloc数组,全部用vector。
3.2 std::string:告别 char[] 的噩梦
#include <string>#include <iostream>
int main() { std::string name = "red_block";
// 拼接 std::string msg = "检测到: " + name; std::cout << msg << std::endl; // "检测到: red_block"
// 长度 std::cout << "长度: " << name.length() << std::endl; // 9
// 比较(不用 strcmp 了!) if (name == "red_block") { std::cout << "匹配!" << std::endl; }
// 查找子串 if (name.find("block") != std::string::npos) { std::cout << "包含 block" << std::endl; }
return 0;}再也不用担心
char[]缓冲区溢出了。std::string自动管理内存,拼接、比较、查找全是现成方法。
3.3 std::pair 与 std::tuple:临时打包多个返回值
#include <utility> // pair#include <tuple> // tuple
// 返回目标的像素坐标 (u, v)std::pair<int, int> get_pixel_center() { return {320, 240}; // 直接用花括号构造}
// 返回三维坐标std::tuple<float, float, float> get_3d_position() { return {0.5f, 1.2f, 3.0f};}
int main() { auto [u, v] = get_pixel_center(); // C++17 结构化绑定 auto [x, y, z] = get_3d_position(); printf("像素: (%d, %d), 空间: (%.1f, %.1f, %.1f)\n", u, v, x, y, z); return 0;}3.4 std::queue:先进先出队列
后面学工业相机多线程取流时会用到——相机线程往队列里塞图像帧,算法线程从队列里取最新帧:
#include <queue>
std::queue<int> frame_queue;
// 入队(相机线程塞帧)frame_queue.push(1);frame_queue.push(2);frame_queue.push(3);
// 出队(算法线程取帧)while (!frame_queue.empty()) { int frame_id = frame_queue.front(); // 看队头 frame_queue.pop(); // 弹出队头 printf("处理帧 %d\n", frame_id);}并发安全警示:
std::queue本身不是线程安全的!如果在两个不同的线程中同时调用push和pop,会发生数据竞争导致程序段错误崩溃。后续在第九章讲工业相机多线程取流时,我们会用互斥锁(std::mutex)将它封装为线程安全的单缓冲队列。
3.5 std::map:键值对映射
#include <map>#include <string>
// 存储颜色编号到名称的映射std::map<int, std::string> color_names;color_names[1] = "红色";color_names[2] = "蓝色";color_names[3] = "黄色";
printf("编号1对应: %s\n", color_names[1].c_str());四、现代 C++ 语法糖:写更少的代码做更多的事
4.1 auto:让编译器帮你推导类型
auto a = 42; // intauto b = 3.14f; // floatauto s = std::string("hello"); // std::string
// 最常用的场景:迭代器类型太长了,用 auto 省心std::vector<Target> targets;// 不用 auto:// std::vector<Target>::iterator it = targets.begin();// 用 auto:auto it = targets.begin(); // 编译器自动推导
auto不是弱类型!编译期就已经确定了具体类型,只是你不用手写罢了。
4.2 范围 for 循环(Range-based for)
C 风格遍历数组要写下标和 size(),范围 for 一行搞定:
std::vector<float> distances = {1.5f, 2.3f, 0.8f};
// C 风格for (int i = 0; i < (int)distances.size(); i++) { printf("%.1f\n", distances[i]);}
// 现代 C++ 风格(推荐)for (float d : distances) { printf("%.1f\n", d);}
// 如果要修改元素,加引用for (float &d : distances) { d *= 100; // 将单位从米转为厘米}
// 只读遍历大对象,用 const 引用避免拷贝std::vector<Target> targets;for (const Target &t : targets) { printf("(%.2f, %.2f, %.2f)\n", t.x, t.y, t.z);}4.3 Lambda 表达式:匿名小函数
Lambda 最常见的用途是给 std::sort 等算法传自定义比较规则:
#include <algorithm>#include <vector>
struct Target { int color; float x, y, z; float distance() const { return std::sqrt(x*x + y*y + z*z); }};
int main() { std::vector<Target> targets = { {1, 0.5f, 1.2f, 3.0f}, {2, 1.0f, 0.8f, 1.5f}, {1, 0.2f, 0.3f, 0.5f}, };
// 按距离从近到远排序 std::sort(targets.begin(), targets.end(), [](const Target &a, const Target &b) { return a.distance() < b.distance(); // 返回 true 则 a 排在 b 前面 } );
// 现在 targets[0] 就是最近的那个目标 printf("最近目标距离: %.2f 米\n", targets[0].distance());
return 0;}Lambda 的基本语法:[捕获列表](参数) { 函数体 }
[]:不捕获外部变量。[&]:按引用捕获所有外部变量。[=]:按值捕获所有外部变量。[&dist_threshold]:只按引用捕获特定变量。
五、智能指针:彻底告别 new/delete
5.1 为什么需要智能指针?
上一章说了 malloc 忘 free 会内存泄漏。C++ 的 new/delete 同理:
Target *t = new Target{1, 0.5f, 1.2f, 3.0f, 0.9f};// 用完了...delete t; // 忘了这行就泄漏!智能指针的思路和 RAII 一脉相承——用一个对象来”持有”堆上的内存,对象销毁时自动释放。
5.2 std::unique_ptr:独占所有权(最常用)
#include <memory>
int main() { // 创建一个 unique_ptr,独占管理一个 Target auto target = std::make_unique<Target>(); target->color = 1; target->x = 0.5f; target->y = 1.2f; target->z = 3.0f;
target->print(); // 正常使用,和裸指针一样用 ->
// 不需要 delete! // target 出了作用域时,unique_ptr 的析构函数自动 delete 内部指针 return 0;}
unique_ptr是”独占”的,不能被复制(拷贝),但可以被”移动”(转让所有权)。这保证了同一块内存在任意时刻只有一个管理者,杜绝了双重释放(double free)的问题。
5.3 std::shared_ptr:共享所有权
有时多个地方需要同时持有同一块数据(比如多个模块都要读取同一帧图像)。shared_ptr 内部维护一个引用计数,最后一个 shared_ptr 销毁时才真正释放内存:
#include <memory>
void process_frame(const std::shared_ptr<FrameData>& frame) { // 传 const 引用:避免原子引用计数的无谓增减与内存屏障开销 // 处理图像...}
int main() { auto frame = std::make_shared<FrameData>(); // 引用计数 = 1 process_frame(frame); // frame 出作用域时引用计数归 0 → 自动释放 return 0;}在初学阶段,优先用
unique_ptr。只有在”确实需要多个地方共享同一份数据”时才考虑shared_ptr。
另外在性能敏感的热点代码(如 60 FPS 视觉循环)中,如果函数只是只读读取数据,优先传const std::shared_ptr<T>&或直接传解引用后的对象引用const T&,避免频繁触发原子引用计数的增减。
六、#include 与命名空间
6.1 C++ 标准头文件
你可能注意到了 C++ 的头文件不带 .h:
#include <cstdio> // C++ 版的 stdio.h#include <cstdlib> // C++ 版的 stdlib.h#include <cmath> // C++ 版的 math.h#include <cstdint> // C++ 版的 stdint.h#include <vector> // C++ 独有#include <string> // C++ 独有#include <memory> // C++ 独有(智能指针)#include <algorithm> // C++ 独有(sort 等算法)C 语言的头文件(如
<stdio.h>)在 C++ 里也能用,但推荐用<cstdio>这种 C++ 包装版本——它把所有函数放进了std命名空间里。
6.2 命名空间(Namespace)
// 标准库的所有东西都在 std 命名空间里std::vector<int> v;std::string s;std::cout << "hello" << std::endl;
// 觉得写 std:: 太烦?可以在文件顶部声明(不推荐在头文件中这样做)using namespace std;vector<int> v;string s;在
.cpp源文件里写using namespace std;无所谓。但绝对不要在.h头文件里写——它会污染所有包含这个头文件的文件的命名空间,容易引发名字冲突。
七、C 与 C++ 的编译差异
| 方面 | C | C++ |
|---|---|---|
| 源文件后缀 | .c | .cpp |
| 编译器 | gcc | g++ |
| 头文件 | <stdio.h> | <cstdio> |
| 布尔类型 | _Bool(需 <stdbool.h>) | bool(内置) |
| 字符串 | char[] + strlen/strcmp | std::string |
| 动态数组 | malloc/realloc/free | std::vector |
| 内存管理 | 手动 free | RAII + 智能指针 |
编译命令的变化:
# C 语言gcc main.c target.c -o my_robot -lm
# C++(只需换成 g++,加上 C++ 标准版本)g++ -std=c++17 main.cpp target.cpp -o my_robot从现在开始,我们的所有代码文件都使用
.cpp后缀,编译器使用g++。下一章会用 CMake 来管理编译,彻底告别手动敲g++长命令。
🎯 本章通关小作业
作业 1:用类封装一个目标管理器
创建一个 TargetManager 类:
- 内部用
std::vector<Target>存储所有识别到的目标; - 提供
void add(Target t)方法往里添加目标; - 提供
Target get_nearest()方法返回距离相机最近的那个(用std::sort+ Lambda); - 提供
void print_all()方法打印所有目标的信息; - 在
main()中创建对象,添加 3~5 个假数据目标,调用get_nearest()并打印结果。
防御性编程进阶思考:
在真实比赛现场,某一帧画面里一个道具都没检测到(targets.empty() == true)是家常便饭。如果直接访问targets[0]程序会瞬间越界崩溃!
思考:当容器为空时,get_nearest()应该怎么处理?(提示:可以返回一个特殊标志的无效 Target,或者在 C++17 中使用std::optional<Target>)。
作业 2:体验 RAII 与智能指针
- 创建一个
CameraSimulator类,构造函数里打印"相机已连接",析构函数里打印"相机已断开"; - 在
main()中用std::make_unique<CameraSimulator>()创建对象; - 观察程序运行结束后析构函数是否自动调用——你不需要写任何手动释放代码。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!