ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从C语言栈到C++类:封装如何重塑代码结构与边界

从C语言栈到C++类:封装如何重塑代码结构与边界

1. 为什么“从 C 的栈到 C++ 的类”是一条必经之路

入行头几年写 C,我一度觉得“结构体 + 函数”已经够用了。直到有一次给老项目加新功能,改完一个全局数组,连带炸了三四个不相关的模块,才彻底体会到:C 语言把数据和组织结构交到你手里,也把维护的成本一并丢了过来。那个项目里,设备状态全部堆在几个全局结构体里,配套的几十个函数散落在不同文件,谁都敢直接读写状态字段,最后没一个人能说清楚某个字段到底被谁改过、什么时候改的。这种代码不是不能跑,是跑得让人提心吊胆。

后来切到 C++,在重写同一个模块的过程中,我第一次意识到“从栈上分配一个结构体”和“构造一个类对象”之间的差别,不光是语法层面的,更是思考方式层面的。栈还在,对象也在栈上,但数据不再是一块谁都能碰的内存,而是被一个类壳子包住、有行为、有边界的实体。这个转变解决的不是性能问题——栈上分配和类对象的栈上分配开销几乎一样——它解决的是代码结构问题:谁拥有数据、谁能改数据、初始化怎么保证、释放怎么保证。

不少朋友问过我同一个问题:C 和 C++ 在底层不就是同一套内存模型吗,为什么要费劲去学类的封装?我会说,底层确实是同一套内存模型,栈、堆、寄存器都一样,但代码组织方式决定了十人团队半年后还能不能顺畅改这个项目。封装不是 C++ 给你多出来的花活,它是用来约束“人”的,不是用来约束“机器”的。机器不需要封装,散着写也能跑;人需要封装,因为人会忘、会偷懒、会在三个月后忘记自己当初为什么把那个字段设为 public。

在这篇文章里,我会带你完整走一遍这个转变过程:先看 C 语言中最典型的、基于栈和全局数据的代码结构是怎么腐化出问题的,再对照同一个例子在 C++ 下用类重写后会有什么不同,最后聊几个我实际踩过坑才总结出来的设计经验和判断标准。如果你正处于“C 写得很熟、刚接触 C++ 类”的阶段,或者你已经在用 C++ 但总是写成了“带类的 C”,这篇内容应该对你有直接帮助。

2. C 语言时期典型的“栈与散装函数”代码结构,问题出在哪里

2.1 一个典型的设备管理模块是怎么被写坏的

为了更好地说明问题,我构造一个虚构的“设备管理模块”来演示。这个模块管理若干台外设,记录每台设备的编号、状态、当前速率和累计运行时间,需要支持设备初始化、状态更新、速率修改、统计上报四个功能,这在 C 语言里是非常常规的场景。

第一版结构是这样的:

typedef struct { int device_id; int status; // 0=idle, 1=busy, 2=error double rate; // 当前速率 long run_time; // 累计运行时间(秒) } DeviceInfo; #define MAX_DEVICES 32 DeviceInfo devices[MAX_DEVICES]; int device_count = 0;

看这个定义本身,似乎没什么问题:结构体清晰,全局数组存放所有设备,配套的操作函数如下:

void device_init_all() { ... } void device_set_status(int index, int status) { devices[index].status = status; } void device_set_rate(int index, double rate) { devices[index].rate = rate; } void device_report(int index) { ... }

问题在哪里?表面上函数和数据是分离的,数据放在结构体里,函数操作结构体,这已经是 C 里比较讲究的写法了。但一旦项目变大,这里面的结构性缺陷就会逐个暴露:

  • 谁都能改数据。device_set_status和device_set_rate只是约定的函数,没有任何编译期机制阻止其他模块直接写devices[3].status = 2。一旦有人绕过函数直接操作字段,后续的所有统计逻辑都可能被破坏,而且查不出是谁改的。
  • 初始化和销毁没有保证。device_count的初始值、devices数组的初始化、status字段的默认状态,全部依赖某个人记得在程序早期调用device_init_all()。调用顺序错了,整个系统跑起来都是脏状态。
  • 数据约束散落在各个函数里。比如rate不应该为负数、status只能是 0/1/2、run_time不能被外部随意清零。这些规则写在注释里、写在某个函数的 if 判断里,但没有任何一处把它们固化为“这个结构体自身就保证不出非法状态”。

2.2 栈上的结构体与全局数据:生命周期和所有权都是模糊的

再往底层看,DeviceInfo devices[MAX_DEVICES]如果是全局变量,它不在栈上,而在静态存储区;如果某个函数在栈上声明一个局部DeviceInfo dev,那它的生命周期只在当前函数内。C 语言里这是完全由程序员控制的两件套:全局数据处处共享,栈上数据随函数消失,堆上数据手动malloc/free。

C 的栈强调的其实是“函数调用过程中的临时性”:局部变量随着函数调用压栈、随函数返回弹栈,它的生命周期规则极其简单,简单到所有人都能理解。但这种简单的生命周期管理也有一个副作用——它让程序员把“数据”理解为一种临时的、朴素的、随用随丢的东西。你声明一个局部结构体,填充几个字段,传给函数,函数返回后就无所谓了。久而久之,代码里到处都是“临时结构体 + 散函数”,真正需要长期维护状态的实体(设备、订单、连接、配置)也被当成临时数据来搬来搬去。

这种思维在小型项目里没有任何问题,但在上千个函数、几十万行代码的系统里就会出现一个典型征兆:你在一个函数里改了一个结构体字段,发现从别的函数传进来的同样的字段已经被覆盖成奇怪的值。你在栈上调了一个函数,传进去一个结构体指针,这个函数内部又把这个指针存到全局,函数返回后栈帧已经注销,指针悬空。这类问题不是 C 独有的,但 C 的“数据任你处置”风格会大幅放大它们的发生概率。

2.3 封装缺位时,修改代码就像拆炸弹

还有一个我在 C 项目里反复体会到的痛点:一旦数据结构定义为公开可写的,后续任何逻辑调整都会变成拆炸弹。举个例子,如果业务方突然要求“status 为 error 的时候不允许修改 rate”,你在 C 里能做的是在所有写rate的地方加判断。如果只有两处还好,如果这个字段被十几处代码直接引用,那你只能靠全局搜索一个不漏地找齐,然后祈祷后面不会再冒出新的写点。

我经历过一次线上事故,原因就是某人为了临时调试,直接在某个函数里写了dev.rate = 999,完全绕过了上一版加的检查逻辑。那行代码在产品代码里存活了两周,导致统计报表里的速率数据全部异常,排查花了两天。这个教训让我认定一条原则:C 语言项目里,跨文件共享的数据结构必须把所有“写操作”收敛到函数,并且用命名规范强约束——比如所有直接访问全局数据的代码一律不得出现在模块外的文件里。但这毕竟是“君子协定”,编译器并不会帮你把关。真正能把这些约束固化下来的,就是 C++ 的类。

3. C++ 类到底改变了什么:从“别人可以碰的数据”到“有行为边界的对象”

3.1 用同一个例子做一次面向对象的改写

上一节那个设备管理模块,用 C++ 类来写,直观的变化长这样:

class DeviceManager { public: DeviceManager() : device_count_(0) {} bool add_device(int id); bool set_status(int index, int status); bool set_rate(int index, double rate); void report(int index) const; private: struct DeviceInfo { int device_id; int status; double rate; long run_time; }; DeviceInfo devices_[MAX_DEVICES]; int device_count_; static constexpr int MAX_DEVICES = 32; };

乍一看,好像只是把函数挪进了类里面、把字段加上了private。但这里的每一处小小改动,背后都代表一个层次的约束:

  • DeviceInfo被移到了private区域里,外部代码根本无法构造一个“裸的设备信息结构体”,只能通过DeviceManager的接口去创建、修改和查询设备。数据结构不再是人人可用的公共资源,而是类内部的实现细节。
  • set_status和set_rate变成了成员函数,它们在类内部可以直接访问私有字段,但外部调用时只能走这两个口子。这样一来,合法性检查只需要写在这两个函数的函数体里,任何人想改状态都必须经过检查,没有“绕过”的路径。
  • 构造函数负责把device_count_初始化为 0,类的对象一旦创建,内部状态就是确定的。不再存在“忘了初始化”这种问题——只要DeviceManager对象存在,它就是就绪的。

3.2 封装的本质:把“不变量”从注释里搬进代码里

“封装”这个词经常被解释成数据隐藏,但我觉得准确的理解是:封装把一套不变量(invariant)的维护责任从调用者转移到了类自身。

在 C 的写法里,“status 只能是 0/1/2”“rate 不能为负”这些不变量,是分散在注释和各个调用方的 if 判断中的。一旦有人忘了,系统就会处于非法状态;在 C++ 的写法里,这些不变量应该在类的接口实现中被强制维护。

比如set_rate可以这样写:

bool DeviceManager::set_rate(int index, double rate) { if (index < 0 || index >= device_count_) return false; if (rate < 0.0) return false; // 速率不允许为负 if (devices_[index].status == 2) return false; // error 状态不允许改速率 devices_[index].rate = rate; return true; }

这样的好处非常实际:以后所有要修改速率的代码,都经过这个函数,所有验证逻辑只维护这一份。这不代表 C++ 程序就不会出现非法状态——如果有人故意写一个绕过类的全局指针,那谁也拦不住——但在正常的代码路径里,编译器帮你挡住了 90% 以上的裸奔访问。

再讨论一下 public/protected/private 这几级访问控制怎么选。我的建议很简单:能 private 就 private,protected 只在确定要派生的场景下用,public 只给真正需要对外暴露的操作。字段一律私有,外部通过成员函数访问,这是一个非常朴素但极其有效的规则。偶尔会有朋友跟我说“这个结构体只是用来传数据的,全 public 就行”,我不反对,但我会补一句:如果它确实只是一个聚合数据的传输载体,用 C 的结构体或 C++ 的 struct 都行;一旦它有需要维护的业务规则,就应该马上切换成 class。类不是用来炫技的,是用来“看守规则”的。

3.3 从栈的角度再看一次:对象在栈上,边界却在编译期

回到标题里的“栈”。DeviceManager manager;这行代码如果在某个函数里声明,它依然是在栈上分配内存——大小取决于devices_数组和device_count_字段,没有额外的堆分配。所以从内存布局上看,C 和 C++ 真的没什么翻天覆地的区别:栈还是那个栈,地址还是那个地址,压栈弹栈的规则也没变。

但有一个关键变化发生在编译期:在 C 里,栈上那块内存里放了什么,只有运行起来才知道;在 C++ 里,那块内存的类型是什么、可以通过哪些方法操作它、哪些成员不允许被触碰,编译器在编译阶段就把边界画好了。当你写下manager.rate = 5.0的时候,编译器会直接报错,告诉你device_rate(或者说devices_内部的字段)是私有的,不可访问。这种“编译期就能拦住”的能力,比任何代码审查和团队规范都可靠。因为审查要看人的认真程度,而编译器的检查是 100% 执行、不会有任何人漏掉。

所以我的理解是:C 的栈给了你“数据在函数调用间的临时性”这个直觉,C++ 的类则在这个直觉之上加了一层“数据属于哪个对象”的归属感。这种归属感一旦建立,你在写代码时会本能地思考“这个字段归谁管、谁能改、怎么保证它一直是合法的”,而不是“这个字段放在某个全局位置,谁需要谁就来取”。

4. 不只是语法糖:构造函数、RAII 和对象组合带来的结构升级

4.1 构造函数消灭了“半初始化”状态

C 语言里最常见的 bug 之一,就是结构体声明了但没初始化。写DeviceInfo dev;然后直接打印dev.rate,得到的是一个不确定值,可能在 Debug 和 Release 下表现还不一样。这种“结构体是合法可用的”假设,往往在运行很久之后才被一个不经意间的分支触发。

C++ 的构造函数把初始化变成对象创建的一部分:只要你敢创建对象,构造函数就必定以某个形式执行。无论是默认构造、带参构造还是拷贝构造,对象在正式可用之前,已经经历了构造函数里那些赋值和检查。用我常跟新人讲的话说:对象一出生就是完整的,不存在“先让你看看一个半成品”的状态。

比如DeviceManager的构造函数里把device_count_置 0,之后add_device才会从设备 0 开始加。你不需要在程序入口处记得调用device_init_all(),因为DeviceManager对象的创建本身就完成了这个动作。这样就从根上消除了“忘初始化”这一类问题。另一个很典型的例子是文件句柄或锁:与其写open()函数然后靠调用方记得close(),不如把打开动作放进构造函数、关闭动作放进析构函数,这就是 RAII 的基本思想。对象构造时拿资源、析构时释放资源,资源生命周期和对象生命周期绑定,即使中间 throw 异常,只要栈上有这个对象,析构就会执行,资源就不会泄漏。

4.2 RAII 是用类管理资源的真正艺术

说到 RAII,我再多展开一点,因为这是“封装的艺术”里最值得体会的部分。在 C 语言里管理动态内存、文件指针、Mutex 锁,模式基本都是成对出现的:

FILE* fp = fopen(path, "r"); // ... 各种可能提前 return 的代码 ... fclose(fp);

问题就出在中间那些“可能提前 return 的代码”。函数里有 10 个分支,其中 7 个分支都有可能提前 return,那你就得在 7 个地方都写fclose(fp)。少写一个就是泄漏,多写一个就是重复关闭,写错位置就是 double-free。这是 C 程序里非常高频的出错场景。

如果用类包装一下,RAII 的做法是:

class ScopedFile { public: ScopedFile(const char* path, const char* mode) { fp_ = std::fopen(path, mode); } ~ScopedFile() { if (fp_) std::fclose(fp_); } FILE* get() const { return fp_; } private: FILE* fp_; };

在某个函数里只要声明ScopedFile file("config.ini", "r");,无论函数从哪个分支返回,file的析构函数一定会被调用,fclose一定会执行。这种“靠着栈对象的自动析构来管理资源”的能力,是 C 语言很难优雅做到的。C 的goto err风格确实也能集中释放,但面对复杂的多资源获取场景,代码很快就会长满goto标签。

所以 RAII 可以说是从 C 走向 C++ 后最有实际回报的一课。它不是花架子,它是用“对象在栈上自动析构”这个简单规则,取代了“人必须记得在每个出口释放资源”这个不靠谱的假设。把这层逻辑理顺之后,再回头写 C,你也会不由自主地在函数开头集中声明资源、在出口统一释放,算是思维方式被矫正过来的副作用。

4.3 对象组合比继承更符合“类的艺术”的气质

聊封装,很容易绕到继承和多态。但是一个常见的误区是:一上手就用继承去解构所有问题,结果造出一大坨脆弱的类层次。我有一个很朴素的原则:组合优先,继承次之,接口继承优于实现继承。大多数业务实体之间的关系,用“有一个”比“是一个”更自然。

还是用设备管理举例。一台设备可能关联一个温度传感器、一个通信模块,用组合可以是:

class Device { public: void set_temperature_monitor(const TemperatureMonitor& tm) { temp_ = tm; } void set_comm_module(const CommModule& cm) { comm_ = cm; } private: TemperatureMonitor temp_; CommModule comm_; DeviceStatus status_; };

这种写法的好处是:每个子模块是独立封装的类,各有各的不变量;Device不需要知道TemperatureMonitor内部细微的实现,只保留一个成员对象。相比继承,组合的关系更稳定,不会因为基类的某个字段变化而导致所有派生类连锁爆炸。

封装的核心思想其实就是控制边界,而边界不是越复杂越好,越简单越好。如果一个类对外只有三五个方法,别人用起来很顺手,内部再怎么折腾都不影响外部,那它就是一个设计得不错的类。一件艺术品不是把装饰堆满,而是把该藏起来的全部藏好,只露出最必要的接口。

5. 从“写结构体”到“设计类”的实操路径,以及我总结的几条判断标准

5.1 什么时候该继续用结构体,什么时候该上类

很多从 C 转 C++ 的人会困惑:那我是不是要把所有结构体全部改成类?答案当然是否定的。在我现在的编码习惯里,判断标准非常明确:

  • 如果这个类型只是纯粹地装载数据,没有任何行为规则,比如一个坐标点、一个 RGB 颜色、一组统计结果,我用struct,公开字段,直接访问。它就是一个数据的聚合容器,不需要看门狗。
  • 如果这个类型有业务规则,比如速率不能是负数、状态必须是枚举中的合法值、金额必须精确到分且不能超限,那一定用class,字段私有,通过成员函数保证规则。它有自己的“看门狗”职责。

这个标准能解决 90% 的纠结。剩下的 10%,我会问自己:“如果这个类型的某个字段被随便赋一个非法值,会不会导致系统出错?”会,就封装;不会,就保持简单。做一个不业务规则的纯数据载体,硬套上private和一堆 getter/setter,反而是过度设计。

5.2 渐进式迁移:在不推翻现有 C 代码的前提下引入类

如果你手上是一个存量很大的 C 项目,不要想着一次性全部改写成 C++ 类,那样风险巨大。我建议走渐进式路线,先挑选边界相对清晰的模块作为试点,逐步渗透,操作层面可以分四步走:

  1. 先把模块内部的全局数据搬进一个类中,成员先设为public,因为此刻目标是“改变组织方式”,不是“立刻收紧访问权限”。这一步主要解决“数据散落、职责不清”的问题。
  2. 把操作这些数据的函数改成成员函数。将原本传入结构体指针的那一长串参数全部去掉,因为现在数据直接在this指针对应的对象里,不需要每次传参,函数调用的意图也清晰很多。
  3. 逐步把字段从public改为private,同时为每个字段提供受控的访问接口。这一步是关键,因为一旦字段私有,所有外部直接读写都会变成编译错误,处理这些编译错误的过程就是帮你找出所有“违规访问点”的过程。
  4. 最后引入构造函数、析构函数,处理初始化和资源释放。如果模块原本依赖大量手动init/cleanup函数,这一步会让代码简洁很多。

这个过程可能会比较痛苦,尤其是第 3 步会迎来一波编译报错的轰炸——好消息是,这些报错恰恰是编译器在用一种粗暴但有效的方式告诉你:“这些地方以前在裸碰数据,现在不行了。”修复一处,项目的结构安全就多一分。

5.3 封装边界的设计心法

最后分享几条我踩坑踩出来的经验,也是我审查别人代码时特别关注的几条:

第一,接口尽量少。一个类的 public 方法超过十个,我就会怀疑它的职责是不是太杂了。接口少意味着调用方需要理解的概念少,出错的概率自然低。如果一个类实在需要很多操作,考虑拆成多个职责单一的类,而不是把所有方法堆在一个大而全的类里。

第二,返回值尽量用bool表示成功失败,不要用裸指针或引用返回内部数据。如果你把内部数组的引用或指针直接返回出去,调用方就能绕过你的检查逻辑,随手改内部状态。这等于把一个看似私有的字段又从后门放出去了。我在早期就做过这种事,当时图方便写了DeviceInfo& get_device(int index),结果调用方拿到引用后直接改字段,检查逻辑全部形同虚设。后来改成返回const DeviceInfo&,非 const 的操作全部走受控接口,这才堵住了后门。

第三,构造函数能做的事情就尽量做完,不留“半初始化”的钩子。很多项目喜欢在构造之后还要调init(),这基本等于把 C 的“忘了初始化”问题又搬了回来。如果确实有初始化失败的可能,抛异常或者用工厂模式处理,不要在对象创建完以后还留下一堆需要手动补的步骤。

第四,不要急着为每个类写 getter 和 setter。更多时候,外部的需求是“做一件事”,而不是“读某个字段再判断再写某个字段”。比如“把设备状态置为 error”和“读取 status 再根据 status 判断是否允许下一个操作”是两回事。前者应该直接封装成一个方法set_error(),后者可以暴露一个is_available()之类的能力。面向“行为”设计接口,编程体验会比面向“字段”设计接口好得多。

6. 从 C 到 C++,我最看重的那一点变化

如果把这篇文章压缩成一句话,我想说的是:从 C 的栈到 C++ 的类,本质上不是内存模型的改变,而是代码边界的改变。C 给了你最大限度的自由,自由到每个模块都能随便碰其他模块的数据;C++ 的类则把数据、行为和不变量绑在一起,让编译器帮你守住边界。

我自己在写了不少项目之后,慢慢形成了一种条件反射:每当看到一个结构体被十几个函数传来传去、字段到处被直接赋值,就会意识到,这个模块该从“数据结构”升级成“类”了。这种升级不会让程序跑得更快,但会让程序在三个月后还能被顺利理解、半年后还能被大胆重构,一年后新人接手的时候不会一脸懵。

最后再分享一个小技巧:如果你正在从 C 转 C++,找一个小项目(百行到千行之间的那种)做一次全面重写,刻意把所有结构体都重新审视一遍,决定哪些继续做纯数据聚合,哪些升级成带有私有字段和成员函数的类。这个练习逼迫你建立“数据归属感”,也会让你体会到封装带来的结构约束。练完之后再回去写 C,你至少会在每个结构体旁边多思考一会儿——这个字段应该谁拥有、谁能改、通过什么方式改、如何保证它一直合法。这些思考,就是代码结构的艺术。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进