学C++的人,多半会在“类与对象(下)”这一章体会到一件事:类和对象不是只靠构造函数和析构函数就能撑起来的。上篇讲的封装、访问限定、构造析构,解决的是“怎么把一个类型描述清楚”;到了下篇,问题变成了“怎么让这个类型真正好用、可控、能放进工程里跑起来”。静态成员、友元、运算符重载、初始化列表、拷贝控制,这些内容单独看都不难,但凑在一起,才是一个自定义类型从“练习作业”走向“可用代码”的分水岭。
这篇文章按常见C++教材里“类与对象(下)”的知识范围来展开,但不会照着目录念。我会从实际编码的角度,把每个知识点背后的“为什么”讲清楚,给出能直接运行的代码示例,再补充一些教材上一笔带过、但你在写真实项目时一定会踩到的坑。适合已经学完类与对象基础、开始写综合练习或小项目的读者,也适合准备校招面试想系统梳理C++对象模型的人。
1. 对象为什么需要“属于类本身”的数据:static成员的两副面孔
1.1 静态成员变量:所有对象只有一份的数据
先看一个几乎每个C++教材都会用的例子:统计某个类当前有多少个对象存活。
class Account { public: Account() { ++totalCount; } ~Account() { --totalCount; } static int totalCount; // 声明:这个变量属于类,不属于某个对象 }; int Account::totalCount = 0; // 定义并初始化:这是旧标准里必须写的一行totalCount就是静态成员变量。普通成员变量每个对象各有一份,互相独立;静态成员变量在整个程序里只有一份,所有对象共享它。什么时候会需要这种“共享一份的数据”?典型场景就是对象计数、全局配置项、程序级别的缓存,以及单例模式中保存唯一实例指针。
很多初学者不理解,为什么成员变量在类里声明了,还要在类外面再写一次定义。原因在于:类定义只是“图纸”,描述成员长什么样,编译器不会为图纸里的变量分配内存。totalCount是静态存储期变量,必须有一个唯一的地址,所以需要在命名空间作用域给一次真正的定义。要是头文件里声明了静态成员变量但没定义,链接时通常会报 undefined reference。
C++17 之后情况变简单了,可以直接在类内给静态成员变量一个 inline 的初始值:
class Account { public: inline static int totalCount = 0; // C++17 起可用 };inline static允许头文件里直接完整定义,不用再单独去 .cpp 里写一次。如果是新项目,优先用这种方式;如果是需要兼容老标准的老代码,还是老老实实在类外定义。
1.2 静态成员函数:没有 this 指针的成员函数
静态成员函数最大的特点是没有this指针。这意味着它不能访问任何普通的成员变量,也不能调用普通成员函数,只能操作静态成员或调用其它静态成员函数。
class Logger { public: static void debug(const std::string& msg) { // 只能访问 static 成员 logCount++; std::cout << "[DEBUG] " << msg << std::endl; } static int getLogCount() { return logCount; } private: inline static int logCount = 0; };调用方式有两种,一种是类名直接调用:
Logger::debug("hello");另一种是通过某个对象调用:
Logger logger; logger.debug("hello"); // 语法上合法,但其实是“用对象借到的类名”,跟对象本身毫无关系这里有个很反直觉的细节:即使用一个nullptr指针调用静态成员函数,在 C++ 里通常也不会崩溃,因为静态成员函数不涉及对象解散引用。不过别耍这种小聪明,语义太容易把人绕晕,正常代码统一用类名调用就好。
静态成员函数最常见的用途有两类:一类是工厂函数,比如NetworkManager::createSocket();另一类是全局状态访问入口,比如获取配置、计数器、实例句柄。坦白说,早期很多“类静态函数”本质上就是披着类外衣的全局函数,但它比裸全局函数强在:名字被类限定,避免污染命名空间,而且能访问类的私有静态数据,封装性更好。
2. 友元机制:把封装撕开一个小口子的代价与收益
2.1 友元函数为什么是“必要之恶”
面向对象的第一原则是封装——私有成员不能被外部访问。但有些操作天然“不站在某个对象的角度”,比如复数相加。假设复数类只有operator+成员函数,那么c1 + c2等价于c1.operator+(c2),左侧一定是 Complex 对象,没问题。但如果写2.0 + c1,左侧是 double,它没有operator+成员函数能接受 Complex,就编译不过了。
解决办法是提供一个非成员版本的operator+。可是非成员函数拿不到re、im这两个私有成员,除非类对外提供 getter。提供 getter 当然也行,但为了一个运算符,把数据成员全部暴露出来,接口看着很累赘。于是 C++ 给出了友元:让某个外部函数或外部类,获得访问当前类私有成员的特权。
class Complex { public: Complex(double r = 0, double i = 0) : re(r), im(i) {} friend Complex operator+(const Complex& a, const Complex& b); private: double re; double im; }; Complex operator+(const Complex& a, const Complex& b) { return Complex(a.re + b.re, a.im + b.im); }friend声明可以写在 public、protected、private 任意一个区域里,效果完全一样,它不受访问限定符影响。这点和普通成员声明不同,刚学时容易搞混。
2.2 友元类、友元的传递性,以及使用尺度
除了友元函数,还有一个语法是友元类。例如一个“序列化器”类要读取某个业务类的私有状态:
class Business { friend class Serializer; // Serializer 可以访问 Business 的私有成员 public: Business(int s, int e) : start(s), end(e) {} private: int start; int end; }; class Serializer { public: std::string dump(const Business& b) { // 这里可以直接读 b.start, b.end return "[" + std::to_string(b.start) + "," + std::to_string(b.end) + "]"; } };友元关系有三个必须记住的性质:不可传递、不可继承、不是双向的。A是B的友元,不代表B是A的友元;A是B的友元,也不代表A的子类是B的友元。这三条一旦在代码评审时被问起来,是最容易答错的地方。
工程上我对友元的态度是:能不用就不用,但需要的时候也别拧巴。类设计得足够好,大部分场景都可以通过公有接口解决;可一旦你能写出的公共接口变得非常扭曲——比如为了给序列化器暴露数据,硬写了五个 getter——那么一个友元类反而让代码更清楚。友元的正确用法是“给最少的访问权限”,而不是把一个类的所有私有成员一次性敞开。
3. 运算符重载:让自定义类型拥有“原生类型”的体验
3.1 能重载什么、不能重载什么
运算符重载的本质是语法糖,把一个运算符表达式映射到函数调用上。比如a + b可以变成a.operator+(b)或operator+(a, b)。但 C++ 对着重载范围有明确限制。
可以重载的运算符包括:
| 类别 | 运算符 |
|---|---|
| 算术 | + - * / % ++ -- |
| 关系 | == != < > <= >= |
| 赋值与复合 | = += -= *= /= %= |
| 流操作 | << >> |
| 下标 | [] |
| 函数调用 | () |
| 内存操作 | new delete new[] delete[] |
| 其它 | -> ->* , & |
不能重载的运算符有这些:
| 运算符 | 含义 | 为什么不能重载 |
|---|---|---|
:: | 作用域解析 | 左侧是作用域,不是表达式 |
. | 成员访问 | 无法改变“通过对象取成员”的核心语义 |
.* | 成员指针访问 | 同理,语义过于底层 |
?: | 三元条件 | 求值顺序和短路逻辑是语言规定 |
sizeof、typeid、alignof | 类型查询 | 依赖编译器静态信息,不能交给用户函数 |
noexcept | 异常说明 | 编译器专用 |
3.2 成员函数版本还是友元版本:一张选型表
运算符重载的实现有两种形式:成员函数或非成员(通常是友元)函数。选择规则不复杂,核心看“左侧操作数是谁”。
| 运算符 | 推荐形式 | 理由 |
|---|---|---|
+=、-=、*=等复合赋值 | 成员函数 | 需要修改左侧对象自身 |
二元+、-、* | 友元或非成员 | 让double + Complex这类左侧不是本类型的写法也能编译 |
<<、>> | 友元 | 左侧是 ostream/istream,不是本类型 |
[]、() | 成员函数 | 先取到 this,再执行下标/调用语义 |
==、<等关系运算 | 友元或非成员 | 保持两侧对称的语义 |
一个容易漏掉的点是流运算符。std::cout << c1左侧是cout,你不可以去给ostream类增加成员函数,所以只能重载为非成员函数。而<<要想访问Complex的私有成员,友元正是必要选择。
另外一个经典坑是自增自减的前置和后置区分:
class Counter { public: Counter& operator++() { // 前置 ++ value++; return *this; } Counter operator++(int) { // 后置 ++,注意这个 int 只是占位参数 Counter tmp = *this; value++; return tmp; } private: int value = 0; };后置版本返回旧值的副本,所以性能上通常比前置差一点。如果你不需要旧值,优先写前置++c。这个细节在写迭代器时特别重要,很多性能洁癖的人会一直盯着这里看。
3.3 默认拷贝行为与深浅拷贝:对象管理的核心雷区
这是“类与对象(下)”里最容易被轻视、工程上爆炸率最高的部分。如果类里有指针、句柄、文件描述符这类“资源”成员,而你没有自定义拷贝构造函数和赋值运算符,编译器会生成按成员逐位拷贝的默认版本。后果是两个对象的指针成员指向同一块堆内存,析构时各释放一次,直接双倍释放崩溃。
class MyString { public: MyString(const char* s) { len = strlen(s); data = new char[len + 1]; strcpy(data, s); } ~MyString() { delete[] data; } // 如果没有自定义拷贝构造,则 m2 = m1 时 data 指向同一块内存 MyString(const MyString& other) { len = other.len; data = new char[len + 1]; strcpy(data, other.data); } MyString& operator=(const MyString& other) { if (this != &other) { // 先处理自我赋值 delete[] data; // 释放旧资源 len = other.len; data = new char[len + 1]; strcpy(data, other.data); } return *this; } private: char* data; size_t len; };这就是“三/五法则”的来源:如果你需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,基本上三个都要一起改。现代 C++ 更推荐的方案是改用std::string、std::vector、std::unique_ptr这类 RAII 容器,让编译器生成的默认拷贝/析构自动帮你管理资源,就能避免手写深拷贝。但老代码、底层库、面试题里裸指针到处都是,这个原理仍然值得彻底理解。
4. 初始化列表:对象成员的“出生方式”不可将就
4.1 没有初始化列表,有些成员根本构造不了
构造函数有两个阶段:先是初始化列表阶段,所有成员对象在这里被构造;然后才是函数体阶段,进入函数体之后成员已经全部完成了初始化,你只能给它们赋值。所以如果某个成员没有默认构造函数,你不在初始化列表里给它传参,编译直接失败。
class Member { public: explicit Member(int v) : value(v) {} private: int value; }; class Container { public: // 错误写法:在函数体内 m = Member(10),但 m 没有默认构造函数,第一步就不过 // Container(int x) { m = Member(x); } // 正确写法:必须在初始化列表里传给 m explicit Container(int x) : m(x) {} private: Member m; };const成员和引用成员也必须在初始化列表里初始化,这一点非常容易在写 GUI 组件、配置类时踩到。比如类里有一个const int id_,你希望构造函数参数传进来初始化它,如果写成函数体赋值id_ = id;,编译器会报错:const 成员不能在构造函数体内赋值。
4.2 构造顺序:按声明顺序,不是按初始化列表顺序
初始化列表里的顺序并不决定成员的构造顺序。真正的构造顺序,是成员在类定义中的声明顺序。这一点如果不注意,很容易写出“看起来合理,实际是访问未初始化成员”的代码。
class Order { public: // 声明顺序:先 a,后 b Order(int v) : b(v), a(b + 1) {} // 危险!初始化 a 时,b 还没构造 int getA() const { return a; } int getB() const { return b; } private: int a; // 先构造 int b; // 后构造 };这里初始化列表中先写b(v),后写a(b + 1),但实际构造时先执行a的初始化,此时b还是未初始化的垃圾值。GCC 和 Clang 开启-Wreorder会给出警告,MSVC 也有类似警告。我的建议是:初始化列表里的书写顺序,永远和类成员的声明顺序保持一致,这样看代码的人不容易被误导,编译器也安静。如果你的类里有多个成员有复杂的构造依赖,优先考虑把需要相互依赖的成员拆出来组成一个子结构体或内部类,而不是在初始化列表里玩顺序技巧。
4.3 explicit:把隐式转换这个“暗门”关掉
单个参数的构造函数,在 C++ 里天然具备隐式类型转换能力。比如:
class Score { public: Score(int s) : value(s) {} // 未加 explicit int value; }; void printScore(const Score& s) {} printScore(85); // 隐式转换:整数 85 被悄悄包装成一个 Score有些场景下这个隐式转换很舒服,比如Complex c = 2.5;如果是有意设计,就还行。但多数时候它会在函数重载解析、运算符调用时产生“意外匹配”,让错误莫名其妙,代码可读性也直线下降。C++ 想关掉这扇门,就是在构造函数前加explicit。
class Score { public: explicit Score(int s) : value(s) {} int value; }; // printScore(85); // 编译失败,必须显式构造 printScore(Score(85)); // 正确在综合实例里,我会给复数类的单参构造函数加explicit,因为我不希望Complex c = 3.0这种隐式包一层的行为出现在表达式里,它会让后续重载匹配变得非常不可控。
5. 综合实例:一个带对象计数的复数类,把上面的机制串起来
5.1 需求与设计思路
用代码把这些机制全部串一次,是最好的检验方式。我做一个小的复数类:
- 用初始化列表初始化实部、虚部;
- 构造函数和拷贝构造函数都让存活计数
liveCount增加; - 析构函数让计数减少;
operator+用友元实现,支持c1 + c2;operator<<用友元实现,方便输出;- 自定义赋值运算符,处理自我赋值,且不改变对象数量;
- 单参构造加
explicit,禁止隐式转换。
这里最容易踩的考点是:如果只写普通构造函数里liveCount++,而默认拷贝构造函数又会逐成员复制,那么拷贝产生的对象不会增加计数,导致“对象明明活着,统计却少了一个”。所以必须自定义拷贝构造函数。
5.2 完整代码与关键注释
#include <iostream> #include <ostream> class Complex { public: explicit Complex(double r = 0, double i = 0) : re(r), im(i) { ++liveCount; // 普通构造函数:计数 +1 } Complex(const Complex& other) : re(other.re), im(other.im) { ++liveCount; // 拷贝构造函数:同样要 +1 } Complex& operator=(const Complex& other) { if (this != &other) { // 防止自我赋值 re = other.re; im = other.im; } return *this; // 赋值不改变对象数量 } ~Complex() { --liveCount; } friend Complex operator+(const Complex& a, const Complex& b); friend std::ostream& operator<<(std::ostream& os, const Complex& c); static int getLiveCount() { return liveCount; } private: double re; double im; inline static int liveCount = 0; // C++17 静态成员 }; Complex operator+(const Complex& a, const Complex& b) { return Complex(a.re + b.re, a.im + b.im); } std::ostream& operator<<(std::ostream& os, const Complex& c) { os << "(" << c.re << "," << c.im << ")"; return os; }测试代码如下:
int main() { Complex c1(1.0, 2.0); Complex c2(3.0, 4.0); std::cout << "c1 = " << c1 << ", live = " << Complex::getLiveCount() << std::endl; Complex c3 = c1 + c2; // 调用 operator+,产生临时对象再拷给 c3 std::cout << "c3 = " << c3 << ", live = " << Complex::getLiveCount() << std::endl; Complex c4 = c3; // 拷贝构造 std::cout << "c4 = " << c4 << ", live = " << Complex::getLiveCount() << std::endl; c1 = c4; // 普通赋值,对象数量不变 std::cout << "赋值后 c1 = " << c1 << ", live = " << Complex::getLiveCount() << std::endl; return 0; }5.3 从运行结果读懂对象生命周期
上面代码预期运行结果大致如下:
c1 = (1,2), live = 1 c3 = (4,6), live = 3 c4 = (4,6), live = 4 赋值后 c1 = (4,6), live = 4注意中间那行live = 3,很可能和很多人的直觉不一样。c1、c2 是两个对象,计数是 2;c1 + c2返回一个临时 Complex 对象,计数加 1 变成 3;然后临时对象被用于构造c3,根据现代编译器的复制省略(copy elision)优化,临时对象和 c3 可能合并成一个,所以最终计数停在 3 而不是 4。不同编译器、不同优化等级下,中间数字可能略有不同,但最终逻辑是一致的,关键是你能解释每多出来的一个对象是怎么产生的。
这就是把静态计数和拷贝控制放在一起的价值:它会强迫你认真数对象的构造和析构,而不是只在理论上理解“拷贝构造会被调用”。你还可以在构造函数、析构函数里各加一行std::cout << ...,观察每个临时对象何时产生、何时销毁,这对建立 C++ 对象模型直觉极有帮助。
6. 除了语法,写类的时候真正该有的几条心法
6.1 区分“值语义”类型和“资源管理”类型
写类之前先问自己:这个类是一个普通的值,还是持有资源的对象?如果是坐标、配置项、数值包装这类简单值,不需要自定义析构和拷贝,编译器默认行为就够了,按值传递也没问题。如果是文件、连接、缓冲区这种资源持有者,要么遵守三/五法则手写完整管理,要么直接换成std::unique_ptr、std::shared_ptr、std::vector这些现成的 RAII 组件。最怕的是“半吊子资源管理”:类里既有裸指针又没有一个像样的拷贝控制,出问题只是时间问题。
6.2 构造函数的职责要单一,别在函数体里做太多事情
构造函数应该只负责把对象初始化为一个合法状态。能放进初始化列表的,就不要塞进函数体。常见的反面例子是在构造函数里读文件、连数据库、启动线程,结果一个异常就把对象构造流程搅得一团糟。C++ 的构造函数如果抛出异常,对象是不存在的,但已经构造完成的成员会被正常析构,这个规则本身不复杂,可一旦构造函数体里有一堆带副作用的操作,排查问题就会非常痛苦。
6.3 运算符重载要克制,保持“直觉一致”
运算符重载最大的风险不是语法不会写,而是设计滥用。一个operator+如果内部做的是“拼接”而不是“加法”,读代码的人一定会被坑。“重载的行为要尽量贴近内置运算符的直觉语义”,这句话值得贴在显示器上。比如两个对象相加,通常不应该修改操作数,而是返回一个新对象;operator+=才修改左侧对象。如果某个操作用命名函数表达更清楚,那就别硬塞给运算符。好的运算符重载,应该是让代码像在用内置类型一样自然,而不是搞出一堆只有作者能读懂的“魔法符号”。
6.4 现代 C++ 里,检查你的默认成员行为
C++11 引入了default和delete关键字,可以让编译器显式生成或删除默认的特殊成员函数。比如你不希望某个类被拷贝,就明确写:
class UniqueResource { public: UniqueResource() = default; UniqueResource(const UniqueResource&) = delete; UniqueResource& operator=(const UniqueResource&) = delete; };这比“声明私有但不定义”的古老做法清晰得多,也是现代代码 review 中很常见的写法。遇到资源类、单例类、工具类,第一时间考虑delete掉拷贝,能省去后面大量烦恼。
我自己写类的时候,有一个坚持了很多年的习惯:不管类多简单,都先列出“我允许这个对象怎么被创建、怎么被拷贝、怎么被销毁”,再动手写成员函数。类与对象(下)这章教的所有语法,本质都是在回答这三个问题。把这些问题想清楚了,静态成员、友元、运算符重载、初始化列表这些手段,自然就有地方安放,写出来的类也会比那些逐条背规则拼出来的代码稳得多。