☰
C++类与对象设计:真正理解封装、构造与拷贝控制
2026/10/20 18:03:25 网站建设 项目流程

先说一个我最近反复遇到的场景。不少从 C 语言转过来写 C++ 的同事,第一次看到“类”这个概念时,第一反应往往是“这不就是带函数的 struct 么”。如果只是这样理解,后面写出来的代码十有八九是一堆对外公开的成员变量、加一堆打补丁式的函数。类与对象这组概念,入门教程里翻来覆去讲的语法无非是访问控制、构造函数、析构函数、拷贝控制这些,但真正决定代码质量的,是你是否理解这些语法在解决哪些现实问题。

这篇文章我不打算按教科书的顺序复述一遍语法,而是想梳理一下我在实际项目中理解类与对象的路径,以及为什么很多人写了好几年 C++,设计出来的类依然很别扭。无论你是刚学完语法、准备写第一个正经类的新手,还是已经写了几年、想重新梳理设计思路的开发者,应该都能从里面找到点值得琢磨的东西。

1. 从一段 C 写法说起:类要解决的问题在今天依然很真实

先从一个具体的例子说起。在 C 语言里,如果我想把“形状”和“绘制”绑在一起,通常的做法是定义一个结构体,再塞一个函数指针进去。换一种说法,就是用 C 去模拟面向对象。

typedef struct { int type; // 1=圆形 2=矩形 void *data; // 各自的数据,没办法,只能用 void* void (*draw)(void *); // 回调函数指针 } Shape; void draw_circle(void *self) { // 取出圆的半径,画圆 } void draw_rect(void *self) { // 取出宽和高,画矩形 } Shape *create_circle(int radius) { Shape *s = (Shape *)malloc(sizeof(Shape)); s->type = 1; s->data = malloc(sizeof(int)); // 假设存 radius *(int *)(s->data) = radius; s->draw = draw_circle; return s; }

这种写法在实际工程里很常见,但问题不少。

最大的问题是“初始化靠自觉”。如果我哪次创建 Shape 的时候忘了给 draw 赋值,调用方执行shape->draw(shape)时就会触发空指针崩溃,而且错误往往要等到运行到那个分支才能发现。

第二个麻烦是类型信息完全丢失。所有自定义数据都塞进void*,绘制函数里只能通过 type 字段去判断,然后手动把void*转成具体类型,转错了就是未定义行为,调试起来非常痛苦。

第三个问题是“边界不存在”。结构体里的所有字段都是公开的,任何代码都可以把 type 改成 3,也可以把一个本来存 int 的指针重新解释成 double 数组。类的第一个价值就在这里:把数据和操作数据的行为放在同一个作用域里,并且对调用方隐藏内部细节。

用 C++ 来表达同样的问题,通常会这样:

class Shape { public: virtual ~Shape() = default; virtual void draw() const = 0; }; class Circle final : public Shape { private: int radius_; public: explicit Circle(int radius) : radius_(radius) {} void draw() const override { // 画一个圆,半径是 radius_ } };

对比之下你会发现,C++ 的类解决的其实是三个非常实在的问题:行为与数据绑定在一起、构造时自动完成初始化、调用时按实际类型分派。不是“漂亮”或者“高级”,而是这些语法从根上消除了 C 写法里那批日常事故。所以我一直觉得,理解类与对象的核心,要先理解它在替代什么样的坏代码,而不是去背“封装继承多态”三个名词。

2. 访问控制不是摆设:封装的价值在实际改动时才会显现

访问控制这套东西讲起来实在太简单了,public、private、protected 三个关键字,背完五分钟。但很多新手会问一个问题:“我把成员变量设成 public,大家直接访问,不也一样跑得好好的吗?”

只写一个模块、代码长度几百行的时候,确实一样跑。但代码是会长大的。用一个真实的例子来说明。

假设你在写一个银行账户的类,一开始是这样:

class BankAccount { public: double balance; };

调用方想扣款就直接account.balance -= 100;。第一天没问题,到了第二个月,产品提需求了:每次余额变动要记审计日志,还要校验金额上限。这时候你发现,所有直接操作 balance 的地方全部要改,你根本不知道哪些代码绕过了你加的校验。

如果一开始就把 balance 设成 private,通过接口提供操作,情况完全不同:

class BankAccount { public: bool deposit(double amount); bool withdraw(double amount); double balance() const; private: double balance_; };

这个例子里,private 的核心价值不是“防黑客”,而是“防自己人”——阻断调用方对内部状态的无序修改,给实现留出演进空间。即使你把余额改成以分为单位的整型存储、加数据库落盘、加并发锁,只要接口不变,调用方完全不需要知道。这种“改内部实现不影响外部代码”的能力,才是封装真正值钱的地方。

三个访问级别的边界我整理了一个表,平时写代码容易混淆的人可以留着看:

访问级别类内访问派生类访问外部访问典型用途
public允许允许允许对外接口,调用方唯一能触碰的部分
protected允许允许禁止给派生类扩展准备的钩子
private允许禁止禁止内部实现细节,外部完全不可见

我见过不少项目有一个坏习惯:分不清哪个成员该放哪个区间,为了图省事,把所有成员全部塞进 public,代码里到处是.balance_、.name_这样直接改数据的调用。与其说这是“实用主义”,不如说是把 C 的写法原封不动搬进了 C++。改起 bug 来牵一发而动全身,等代码到几千行的时候,想重构都无从下手。

再聊聊友元。友元函数可以访问类的私有成员,确实有它存在的场景,比如重载operator<<时打印对象的私有信息,或者写单元测试时需要验证内部状态。但它本质上是在扒开封装的口子。能用公共接口解决的问题,就别轻易用友元。如果用了,最好在声明旁边写清楚理由,避免后来的维护者一头雾水。

3. 构造和析构:生命周期管理决定资源是否会泄漏

类里面最有价值的两个成员函数,我始终认为是构造函数和析构函数。因为它们回答了一个关键问题:一个对象从出生到消失,到底由谁负责、按什么顺序做哪些事。

构造函数里面第一个值得注意的细节是初始化列表。很多新手会写出函数体内赋值的版本:

class HttpRequest { private: const int version_; std::string url_; public: HttpRequest(int v, std::string url) { version_ = v; // 编译会报错:const 成员不能赋值 url_ = std::move(url); } };

这里有两个问题。第一个,const 成员和引用成员必须在初始化列表中初始化,在构造函数体里赋值是编译不过的。第二个,即使非 const 成员能赋值,那也意味着成员先被默认构造一次,再被赋一次值,白白多一次无意义的操作。正确的写法是:

HttpRequest(int v, std::string url) : version_(v), url_(std::move(url)) {}

有个细节经常被忽略:初始化列表的赋值顺序是按照成员在类中声明的顺序,而不是初始化列表里写的顺序。如果你在类里写的是int b_; int a_;,却在初始化列表里先写a_(...), b_(...),实际构造顺序还是 b 先于 a。把初始化列表的顺序写成和声明一致,能减少很多疑惑。

再来看析构函数。构造函数负责获取资源,析构函数负责释放资源,这个思想在 C++ 里叫 RAII,是所有现代 C++ 资源管理的基石。我不打算重复教科书定义,直接用例子说明价值。

以一个持有文件指针的类为例:

class File { private: FILE* fp_; public: explicit File(const char* path) : fp_(fopen(path, "rb")) { if (!fp_) { throw std::runtime_error("cannot open file"); } } ~File() { if (fp_) fclose(fp_); } // 禁止拷贝,避免两个对象释放同一个文件 File(const File&) = delete; File& operator=(const File&) = delete; };

使用者在正常路径上完全不需要记得 fclose,因为对象离开作用域时析构函数一定会执行。更关键的是,如果代码块中间发生了异常,栈展开过程中析构函数依然会被调用——这一点在堆叠裸指针的开发方式里很难保证。换句话说,RAII 让“发生异常时不泄漏”从一种良好愿望变成了语言机制层面的必然。

析构顺序也值得记一下。单个类的析构顺序是:先执行析构函数体,再按成员声明的逆序析构成员,最后析构基类。这跟构造顺序正好完全相反。理解这一点对排查成员间依赖问题很有用。

这里有一个常见的坑:如果构造函数在函数体内抛出了异常,那么这个对象的析构函数不会被调用。所以如果你在构造函数里用裸new分配了一段资源,后一步初始化又抛了异常,这段资源就会泄漏。解决办法跟之前一样——把资源放进std::unique_ptr这样的 RAII 成员里,让已经构造完成的成员在异常时自行析构。

4. 拷贝与移动:类设计里最容易翻车的两个副本

C++ 的对象和 C 的结构体一样,天生是“可以复制”的。但一旦类里面持有动态分配的资源,默认的行拷贝就会带来灾难。我见过太多次因为浅拷贝导致的 double free,几乎成了 C++ 面试的必考题型。

核心问题在这里:

class Buffer { private: char* data_; size_t size_; public: explicit Buffer(size_t n) : data_(new char[n]), size_(n) {} ~Buffer() { delete[] data_; } };

这个类看起来没毛病。但当我写出Buffer a(1024); Buffer b(a);时,b 的data_和 a 的data_指向同一块内存。a 和 b 析构时会各自调用一次delete[],同一块内存被释放两次,程序直接崩溃。

解决办法就是实现真正的深拷贝,也就是拷贝构造函数里额外分配一块内存,把数据复制过去:

Buffer(const Buffer& other) : data_(new char[other.size_]), size_(other.size_) { std::memcpy(data_, other.data_, size_); }

这就牵出了 C++ 里著名的“三/五法则”:如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个,通常意味着它正在管理某种资源,那么剩下的几个多半也需要一起处理。到了 C++11,加上移动构造函数和移动赋值运算符,就变成了五个。

移动语义我个人觉得值得多讲两句。它的本质不是“拷贝出一个新对象”,而是“把资源所有权交换给另一个对象,让源对象变成空壳”。比如之前那个 Buffer,可以这样写:

Buffer(Buffer&& other) noexcept : data_(other.data_), size_(other.size_) { other.data_ = nullptr; other.size_ = 0; }

这里&&是右值引用,std::move能把左值强制转换成右值,从而匹配到移动构造函数而不是拷贝构造函数。看起来很小的一个语法点,实际工程影响却很大。举个例子,std::vector 里存放这种资源型对象时,扩容过程需要把所有元素搬到新内存。如果类只支持拷贝构造,扩容时每个元素都得完整复制一遍;如果支持移动构造,大多数成员变量只是几个指针和整数换手,代价极低。

但移动构造有个条件必须注意:noexcept。向 vector 插入元素、或者 vector 扩容时,标准库会先判断移动构造函数是否声明为 noexcept。如果它可能抛出异常,标准库宁愿退回到拷贝构造,也不愿意在移动一半时抛出异常导致容器处于不一致状态。所以给移动构造和移动赋值加上 noexcept 不是炫技,而是让它真正派上用场的必要条件。

结合 vector 的例子,你可以想象一下对象的生命周期有多频繁地发生:临时对象构造出来、拷贝或移动进容器、临时对象析构、容器扩容时再次移动……如果每一步都有不必要的拷贝,性能损耗非常直观。这也是我为什么总建议,凡是可能放进容器的类,拷贝和移动接口要一次性设计好。

5. 类设计中的工程考量:从能跑通到好维护

把语法点都过完,剩下的问题只有一个:怎么写才能让代码能持续维护。我做代码评审的时候,看到最多的问题集中在下面几个地方。

构造函数中抛出的异常。很多人对“构造函数可以抛异常”感到不安,但事实上,构造函数失败时没有任何返回值可以报告错误,异常几乎是唯一合理的通道。只要记住前面说的,用 RAII 管理所有已获取的资源,即使构造函数中途失败,已经完成的成员也会正常析构,整体就是安全的。真正危险的写法是在构造函数里直接new一块裸内存,又在后续步骤里抛异常。

基类析构函数必须是 virtual。这条规则几乎没有任何例外。如果一个类将来可能被继承,而它的析构函数不是 virtual,那么通过基类指针delete派生类对象时,派生类的析构函数不会被调用,资源泄漏悄然发生。更麻烦的是这种泄漏还不容易通过工具察觉,因为内存大体上还在,只是析构逻辑没执行。

单参数构造函数记得加 explicit。看这个例子:

class Point { private: int x_; int y_; public: Point(int xy) : x_(xy), y_(xy) {} // 没加 explicit }; void draw(Point p) { /* ... */ } draw(3); // 编译器悄悄把 3 转成了一个 Point(3, 3)

如果这个隐式转换不是你明确想要的,它就是一个潜伏的 bug。所以我的习惯很简单:所有单参数构造函数一律写 explicit,除非我确实需要隐式转换。这种习惯成本极低,但能挡掉很多莫名其妙的编译通过但行为诡异的调用。

关于 this 指针和静态成员,我只提两点容易混淆的。成员函数隐式携带 this 指针,指向调用该函数的对象;静态成员函数没有 this 指针,所以只能访问静态成员。静态成员属于类而非对象,是所有实例共享的一份数据,常用于统计实例数量或者做类级别的配置。初始化静态成员时要注意顺序问题,跨文件初始化的依赖很容易翻车,能避免就尽量避免。

类设计的内聚性也被反复低估。我见过一些“万能类”,里面塞了几十个方法和十几个成员变量,最后谁改谁都怕。一个类应该尽量只做好一件事。判断标准很朴素:你能不能用一句话说清楚这个类对外提供的价值?如果能,接口边界就近清晰;如果说不清,多半是在扮演多个角色。优先用组合而不是继承,也是我一直以来的倾向,因为组合的依赖关系是显式的,而继承的隐式耦合更容易在后期爆发。

最后再聊一点代码评审中我的个人观察。很多人在写新类时习惯先写成员变量、再写方法,把能用的全部接口先列出来,然后慢慢裁剪。我自己的习惯恰好相反:先写出这个类的调用方将要使用的完整接口,也就是 public 那一块,假设内部实现已经存在,把使用场景的代码写顺,再去填充 private 成员和实现细节。这样倒推出来的类,往往更符合“面向调用方设计”的原则。类的本质是你与代码之间的边界——边界画在正确的位置,后续五年的维护都会轻松很多;边界画歪了,所有读代码、改代码的时间和成本都会成倍上升。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询