2026/10/17 15:37:19

C++类与对象进阶:拷贝构造、运算符重载与const/static核心机制解析

C++类与对象进阶:拷贝构造、运算符重载与const/static核心机制解析 C的类与对象学到“下”这个阶段才算是真正摸到了这门语言的门槛。上篇那些构造函数、析构函数、访问限定符说白了还是语法层面的东西。而“下篇”要处理的拷贝构造、运算符重载、const成员、静态成员每一个都直接关系到你的程序能不能跑得稳、能不能跑得对更是面试环节里被问烂了的高频考点。这篇把类与对象下半部分的核心机制拆开揉碎结合我实际写代码时踩过的坑给新手一条能直接照着走的路线。1. 拷贝构造函数深浅拷贝的生死局1.1 什么时候会触发拷贝构造很多人在学拷贝构造函数时只背了一个定义用一个已经存在的对象去初始化一个新对象。但真正写起代码来触发时机远比想象中多。下面这四种场景每一个都绕不开拷贝构造用一个对象初始化另一个对象比如A a2(a1);或者等价的A a2 a1;。函数传参时以值传递方式传入对象比如void func(A a) { ... }调用时实参会拷贝一份给形参。函数返回值时以值传递方式返回对象比如A func() { A tmp; return tmp; }。编译器在特定场景下生成的临时对象初始化。这里要特别强调一下第2点和第3点。很多新手写函数时习惯直接void func(A a)根本不关心拷贝了几次。等程序跑起来内存爆了、析构函数被调用了好几次才意识到问题出在哪。实际上传参和返回值这两个场景是最容易引发性能问题的也是后面理解移动语义的入口。另外有一个细节赋值操作不会触发拷贝构造。A a1; A a2; a2 a1;这里走的是赋值运算符重载不是拷贝构造。区分“初始化”和“赋值”很重要初始化是给新对象分配内存并构造赋值是给已有对象的内存重新填值。1.2 浅拷贝的坑与深拷贝的实现浅拷贝的坑我当年第一次踩的时候用了整整一个下午才排查出来。当时写了一个字符串类成员变量就是一个char*指针和一个int长度。默认的拷贝构造函数做的是逐字节复制也就是说两个对象的指针指向了同一块堆内存。class MyString { public: MyString(const char* str) { m_size strlen(str); m_data new char[m_size 1]; strcpy(m_data, str); } ~MyString() { delete[] m_data; } private: char* m_data; int m_size; };这种写法一旦执行MyString s2 s1;两个对象的m_data指向同一块地址。等作用域结束s1和s2的析构函数依次执行第一遍delete[]把内存还回去了第二遍delete[]就变成了对一块已释放内存的重复释放。在 Windows 上直接弹崩溃框在 Linux 上大概率报double free或者segmentation fault。深拷贝的本质就是在拷贝时把对方指向的那块动态内存重新申请一份一模一样的。简单说就是让两个对象各自持有独立的内存互不干扰。MyString(const MyString other) { m_size other.m_size; m_data new char[m_size 1]; strcpy(m_data, other.m_data); }写深拷贝时必须注意三点。第一用m_size 1给\0留位置。第二拷贝的是内容不是指针。第三如果类里没有动态内存比如只有int、double这种内置类型成员浅拷贝完全够用不需要手动写拷贝构造。1.3 拷贝构造的两种写法与 default 的妙用深拷贝的写法除了传统的newmemcpy/strcpy之外还有更现代、更安全的写法。C11 之后可以用std::string、std::vector这种自带内存管理的容器来管理成员这种情况下编译器自动生成的拷贝构造函数就能正确完成深拷贝不需要手写一行代码。class SafeString { public: SafeString(const char* str) : m_data(str) {} private: std::string m_data; };这个类你完全不写拷贝构造函数编译器生成的默认拷贝构造会去调用std::string的拷贝构造而std::string内部自己做深拷贝。这就是“软件工程能用库就不自己造轮子”的思想在类设计里的体现。另外 default这个关键字值得单独说。当你手动写了析构函数或者某个构造函数后编译器就不会自动生成拷贝构造函数了。如果你仍然想用编译器默认版本可以直接写MyString(const MyString other) default;这行代码的意思是“让编译器生成一个默认的拷贝构造”通常用在你自己写了其他构造函数、但拷贝构造又不需要特殊处理的场景。反过来如果你明确不想让对象被拷贝C11 推荐用 deleteMyString(const MyString other) delete;这样任何试图拷贝该对象的代码都会在编译期报错。这个技巧在单例模式、管理独占资源的类里非常常用。相比以前把拷贝构造函数声明在private区搞“声明但不定义”的老办法 delete的表达意图更清晰报错信息也更友好。注意拷贝构造函数的参数必须是引用类型通常是const T。如果写成MyString(const MyString other)调用时会无限递归拷贝编译直接报错。你不需要背原因只要记住这个结论就行。2. 运算符重载让自定义类型拥有内置类型的手感2.1 运算符重载的语法与规则运算符重载其实是把“运算符”当成一个函数来写。比如号本质是一个函数调用a b在编译器眼里等价于a.operator(b)成员函数形式或者operator(a, b)全局函数形式。写运算符重载前得先搞清楚哪些运算符能重载哪些不能。.、::、?:、sizeof这四个不能重载原因很简单它们的语义被语言锁死了强行重载会造成歧义。其余大部分运算符都可以重载但要注意保持语义合理没人会想看到operator实际执行的是减法逻辑。成员函数和全局函数两种形式各有适用场景。以二元运算符为例如果左操作数是当前类的对象用成员函数形式如果左操作数不是当前类对象比如1 obj这种情况只能用全局函数形式。最经典的例子就是operator左操作数是std::ostream对象不是你的类所以它必须写成全局函数。class Fraction { public: Fraction(int num, int den) : m_num(num), m_den(den) {} Fraction operator(const Fraction other) const { return Fraction(m_num * other.m_den other.m_num * m_den, m_den * other.m_den); } private: int m_num; int m_den; };这段代码里成员函数形式的operator会修改对象的语义吗不会它返回一个新对象原对象保持不变。写运算符重载时有个原则叫“不改变操作数”除非重载的是自增自减这种本就该修改对象状态的运算符。2.2 赋值运算符重载最容易被忽略的特殊性赋值运算符operator和拷贝构造很容易弄混但它们的实现逻辑有明显差异。拷贝构造面对的是“新的空白对象”直接分配资源就行。赋值运算符面对的是“一个已经存在的对象”必须先把旧资源释放掉再重新分配。MyString operator(const MyString other) { if (this other) return *this; delete[] m_data; m_size other.m_size; m_data new char[m_size 1]; strcpy(m_data, other.m_data); return *this; }上面这段代码里有三个新手最容易踩的坑。第一个是自赋值检查a a这种写法虽然看起来荒谬但实际代码里因为别名多个指针或引用指向同一对象的存在完全可能发生。如果不做检查先delete[] m_data把唯一的资源释放了然后去strcpy(other.m_data)时读取的已经是被释放的内存行为未定义。第二个是必须先释放旧资源再分配新资源顺序反了会导致内存泄漏。第三个是返回值必须是引用而且返回*this这样才能支持a b c的链式赋值。额外的还有一个细节赋值运算符需要处理自赋值问题因此我们采用“拷贝并交换”的惯用法可以提高异常安全性。一种更安全的实现是基于拷贝构造加 swapMyString operator(const MyString other) { if (this ! other) { MyString tmp(other); std::swap(m_data, tmp.m_data); std::swap(m_size, tmp.m_size); } return *this; }这里临时对象tmp先持有新资源的深拷贝交换后旧资源被tmp的析构函数自动释放整个过程即使new抛异常原对象也保持一致。这也是我后来比较推荐的一种写法代码量少了也不用手动delete。2.3 前置与后置的实现差异自增运算符有个奇特的规定后置版本为了和前置版本区分必须在参数列表里加一个int占位符。这个int没有实际含义纯粹是语法层面用来区分重载的。Fraction operator() { m_num m_den; return *this; } Fraction operator(int) { Fraction tmp(*this); (*this); return tmp; }前置直接修改自身并返回自身效率高。后置必须先生成一份拷贝修改自身后再把之前的旧值返回回去这就多了一次拷贝构造的开销。所以写循环时能前置就前置for (auto it v.begin(); it ! v.end(); it)这个习惯在容器遍历时能省下不少性能。很多新手在这里纠结“返回引用”和“返回值”的区别。前置返回Fraction是因为返回的是自身可以继续被修改后置返回Fraction是因为返回的是临时旧值不能返回引用引用一个即将销毁的临时对象那是悬垂引用。这里讲清楚了写起来就不会错。3. const成员与static成员常被忽略但面试必问3.1 const成员函数到底修饰了什么const成员函数指的是在函数声明后面加const表示这个函数不会修改对象的数据成员。比如int getSize() const { return m_size; }。但有个关键细节很容易被误解const修饰的是this指针指向的内容。普通成员函数的this类型是T* const指向的内容可以改const成员函数的this类型是const T* const指向的内容不能改。也就是说在const成员函数里给成员变量赋值编译器会直接报错。为什么要区分const和非const成员函数因为const对象只能调用const成员函数。假设你写了一个void print() const那么const MyString s; s.print();是合法的如果忘了加const这行代码直接编译失败。本质上这是C把“逻辑不变性”编码进类型系统让编译期就能拦截掉错误修改。两个常见的坑。第一个是const成员函数里调用非const成员函数编译不过。第二个是返回成员引用时要小心const char operator[](size_t index) const { return m_data[index]; } char operator[](size_t index) { return m_data[index]; }第一个版本给const对象用返回const引用只读第二个版本给普通对象用返回可修改的引用。这是运算符重载里经典的“const版本和非const版本共存”设计。底层代码可以复用非const版本内部可以调const版本然后去掉const属性。3.2 mutable的用法与逻辑常量性mutable这个关键字解决的是一个很难受的问题某些成员变量虽然在const成员函数里需要被修改但它不影响对象的“逻辑状态”。最常见的例子是缓存和锁。class DataCache { public: int getValue() const { if (!m_cacheValid) { m_cacheValue compute(); m_cacheValid true; } return m_cacheValue; } private: mutable int m_cacheValue; mutable bool m_cacheValid; };getValue是const函数因为从外部看它只是读取数据不应该改变对象的可观察状态。但内部缓存机制确实需要写m_cacheValue。没有mutable这段代码编译都过不了。加了mutable语义就变成了“这个成员不受const约束”。mutable不是让你乱用的。如果一个成员变量用mutable修饰后在const函数里被随意修改整个const机制就形同虚设。真正需要它的场合很有限缓存、引用计数、线程同步原语这些。写业务代码时如果发现自己频繁想用mutable停下来想想设计是不是有问题。3.3 static成员的本质与初始化静态成员属于类不属于任何一个对象。所有对象共享同一个静态成员变量它存放在静态存储区而不是对象内部。这也是为什么sizeof一个包含静态成员的对象静态成员不占对象大小的原因。静态成员变量必须在类外定义并初始化这是新手必踩的坑。类内的声明只是“声明”不分配空间类外的定义才真正分配空间并初始化。class Counter { public: static int getCount() { return m_count; } private: static int m_count; }; int Counter::m_count 0;静态成员函数有一个限制它没有this指针所以不能直接访问非静态成员变量和非静态成员函数。想访问只能通过对象或者传进来的对象引用来访问。这是很多面试官爱问的点回答时直接说“静态成员函数没有this指针”就点到本质了。C17 之后静态成员变量可以用inline直接在类内初始化省去了在外面写的麻烦。这个特性在定义静态常量成员时特别方便class Config { public: inline static int timeout 30; };用法上有一个偷懒但实用的技巧给类内部维护一个静态实例指针就能轻松实现单例模式。这也是static成员最常见的工程用途之一。4. 友元与内部类打破封装的两把钥匙4.1 友元函数与友元类为什么需要以及怎么用友元函数是一个全局函数或者另一个类的成员函数在当前类里用friend关键字声明后就可以访问当前类的私有成员。说白了就是“我主动给你开个后门”。为什么要设计这样一个破坏封装的东西最典型的场景是运算符重载。比如重载operator时如果写成成员函数左操作数必须是自己的类对象没法见std::cout obj这种形式。所以你得写成全局函数但全局函数又无法访问私有成员这时候友元就是唯一解法。class Point { friend std::ostream operator(std::ostream os, const Point p); public: Point(int x, int y) : m_x(x), m_y(y) {} private: int m_x; int m_y; }; std::ostream operator(std::ostream os, const Point p) { os ( p.m_x , p.m_y ); return os; }同类对象之间的访问权限也值得注意一个类的成员函数可以访问另一个同类对象的私有成员。假设实现Point::distance(const Point other)传进来的other虽然是另一个对象但在Point的成员函数里访问other.m_x是合法的。这个特性在实现拷贝构造、赋值运算符、比较运算符时非常实用本质上是因为相同类型的访问控制是“类级别”而不是“对象级别”的。友元类的声明方式和友元函数类似friend class B;这样写后B类的所有成员函数都可以访问当前类的私有成员。但要注意友元关系不能传递。A是B的友元B是C的友元不代表A能访问C的私有成员。友元关系也不能继承。建议能用public接口解决的事就别用友元。一个类里的friend声明越少设计越安全。友元只是为了解决特定语法限制而存在的补丁工具不是常规设计手段。4.2 内部类与外部类的关系内部类就是定义在另一个类内部的类。内部类天生是外部类的友元可以访问外部类的私有成员但外部类不能访问内部类的私有成员。内部类和外部类的关系有个容易混淆的点内部类对象和外部类对象是两个独立对象内部类对象不“属于”某个外部类对象。也就是说创建内部类对象不需要先创建外部类对象。class Outer { public: class Inner { public: void print(const Outer o) { cout o.m_secret endl; } }; private: int m_secret 42; };内部类存在的意义通常是为了实现“只在这一个类内部使用的辅助类”或者把逻辑上强相关、但又不适合暴露给外界的类藏起来。比如某个数据结构内部的迭代器类放到外部类内部作为内部类能明确表达归属关系。从工程角度说内部类也算一种命名空间管理手段。内部类的一个细节它无法直接访问外部类的非静态成员因为内部类没有外部类对象。想访问必须像上面代码里一样传一个外部类对象进去。5. 初始化列表与匿名对象写代码时的细节功夫5.1 初始化列表的必要性初始化列表是在构造函数参数列表后面跟一个冒号然后依次初始化成员变量。很多人以为这不过是种风格选择实际上它在某些场景下是唯一的选择。第一种场景是const成员变量。const成员一旦在构造函数体内被赋值编译直接报错因为它只能在初始化时赋值不能先默认构造再赋值。第二种场景是引用成员变量原理类似。第三种场景是没有默认构造函数的类类型成员如果这个成员类只有带参构造函数在构造函数体里没法调用它的默认构造只能通过初始化列表直接传参。class Member { public: Member(int v) : m_v(v) {} private: int m_v; }; class Holder { public: Holder(int v) : m_member(v), m_constVal(10) {} private: Member m_member; const int m_constVal; };初始化顺序这个问题几乎每次都要强调成员变量是按声明顺序初始化的不是按初始化列表里的书写顺序。如果声明顺序是int a; int b;初始化列表写的是b(1), a(2)实际执行时还是先初始化a再初始化b。所以初始化列表的书写顺序最好和声明顺序一致否则编译器会警告代码也容易产生隐蔽bug。另外现代C里更推荐在类内直接给成员变量默认值需要初始化列表的成员可以保留初始化列表其余成员用默认值即可这样构造函数体可以写得很短逻辑更清晰。5.2 explicit关键字阻止隐式转换C的构造函数默认支持隐式转换。比如你有一个MyString(int size)构造函数那么写MyString s 10;时会自动调用这个构造函数创建一个长度为10的字符串对象。这种隐式转换在有些场景下是便利在更多场景下是灾难。class MyString { public: explicit MyString(int size) { /* 分配size字节 */ } }; void func(MyString s) {} func(10); // 如果构造函数没加explicit这行代码合法加了explicit后编译报错func(10)到底在干什么是把整数10偷偷转换成了一个MyString对象。代码阅读者如果不仔细看完全无法察觉这里发生了一次隐式构造。这种“不自觉地产生临时对象”的行为不但造成性能损耗还会掩盖逻辑错误。加了explicit之后构造函数就不能再用于隐式转换只能显式调用。上面的func(10)会直接编译失败逼着你写func(MyString(10))意图明确。一个需要补充的知识点C11 里explicit还可以用在转换运算符上比如explicit operator bool() const这样对象到bool的转换必须显式进行避免在if (obj)这类场景中因为隐式转换产生诡异行为。5.3 匿名对象的生命周期匿名对象是指构造函数调用时不指定对象名直接Fraction(1, 2)这样的写法。匿名对象创建后会立即在当前表达式结束时析构它是一个右值通常用于“创建一个临时对象传给函数”或者“链式调用成员函数”。void print(const Fraction f); print(Fraction(1, 2)); // 临时对象作为参数传入 Fraction(3, 4).print(); // 临时对象调用成员函数两个注意点。第一个是匿名对象不能绑定到非const左值引用Fraction ref Fraction(3, 4);编译失败因为临时对象是右值。第二个是匿名对象生命周期很短如果存了指向它内部资源的指针或引用表达式结束后的访问就是悬垂引用。配合移动语义匿名对象在C11之后经常被用作触发移动构造的入口。std::move的本质是把左值转换成右值引用让编译器选择移动构造而不是拷贝构造省掉不必要的深拷贝。理解移动语义之前先理解匿名对象和右值是一条捷径。6. 常见问题与排查技巧实录6.1 拷贝构造传值导致栈溢出症状程序一运行就崩溃或者编译报错“copy constructor takes reference but not value”。原因拷贝构造函数的参数如果是值类型那么在调用拷贝构造时需要先把实参拷贝给形参在拷贝过程中又需要调用拷贝构造函数参数要再拷贝一次如此无限递归直到栈溢出。排查检查类里手动写的拷贝构造函数参数是不是const T。同理赋值运算符的参数也必须是引用类型。6.2 双重释放double free症状程序在退出时崩溃报错double free detected或者segmentation fault。原因浅拷贝导致两个对象指向同一块堆内存析构时被释放两次。排查方法有一个很实用的技巧在析构函数里打印日志或者加一个静态计数器统计构造和析构次数哪个对象的析构次数多于构造次数基本就是它出问题。这种排查方案比盯着代码看高效得多。6.3 赋值运算符的自赋值问题症状运行时报错而且这类报错往往是随机出现的——只有在a a或者两个别名指向同一对象时才会触发。大部分时间程序正常让人完全摸不着头脑。原因赋值运算符先释放了自己的资源再去读取源对象的资源但源对象和自己是同一个对象资源已经被自己释放了。排查在所有operator实现的第一行加if (this other) return *this;养成习惯从根源上杜绝。也可以用拷贝交换惯用法它会自动避开这个问题。6.4 const对象调用了非const成员函数症状编译报错提示cannot convert this pointer from const ClassName to ClassName。原因const对象只能调用const成员函数。如果某个成员函数不修改任何数据成员但没有声明为constconst对象就无法调用它。排查把所有可以声明为const的成员函数都加上const。这也是一个好习惯能帮助编译器帮你发现错误。尤其是“读操作”类型的接口比如查看器、getter、打印函数都应该默认加const。6.5 常见问题速查表问题现象根源解决方案拷贝构造传值编译不过或栈溢出无限递归拷贝参数改为 const T双重释放退出时崩溃浅拷贝共享堆内存手写深拷贝/使用容器自赋值随机崩溃先释放再读取自赋值检查/Copy-Swapconst调用失败编译错误成员函数无const加上const限定编译器生成了拷贝构造函数却深拷贝错误内存泄漏/数据错乱类含指针成员且未深拷贝深拷贝或禁用拷贝静态成员未定义链接错误缺类外定义类外初始化/C17 inline6.6 一个综合排查实例有一回项目里出现了内存泄漏怎么查都查不到。用内存检测工具跑了一遍指出某个类构造后一直没有被释放。最后发现问题出在一个挺隐蔽的地方类里有个std::vector成员而这个类没有自定义析构函数和拷贝构造vector 本身管理自己内存所以没问题问题是类里另一个裸指针成员在拷贝时被浅拷贝了vector 扩容拷贝时把旧指针也拷贝了一份结果旧对象析构时释放了内存新对象的指针成了悬垂指针再次访问就崩溃。这个案例说明了一个核心道理类里有裸指针成员就一定要考虑深拷贝问题。即使是管理员想偷懒用编译器默认的拷贝也会在某个不起眼的角落里炸给你看。我现在的习惯是类成员优先用std::string、std::vector、std::unique_ptr这类自带明确所有权语义的类型实在需要裸指针就明确写拷贝构造、赋值运算符和析构函数或者直接用 delete禁止拷贝。class SafeHolder { public: SafeHolder(int* ptr) : m_ptr(ptr) {} ~SafeHolder() { delete m_ptr; } SafeHolder(const SafeHolder) delete; SafeHolder operator(const SafeHolder) delete; SafeHolder(SafeHolder other) noexcept : m_ptr(other.m_ptr) { other.m_ptr nullptr; } SafeHolder operator(SafeHolder other) noexcept { if (this ! other) { delete m_ptr; m_ptr other.m_ptr; other.m_ptr nullptr; } return *this; } private: int* m_ptr; };这个代码里用了移动构造和移动赋值来转移指针所有权让资源只能被移动、不能被拷贝。这是现代C处理“唯一所有权”资源的标准做法比手写深拷贝更安全也更高效。写到这里想起一个有趣的类比对象管理内存就像人管理房间钥匙。浅拷贝好比两个人拿着同一把钥匙一个出门锁门另一个就进不去了深拷贝是各自配了一把新钥匙互不干扰移动语义则是直接把钥匙递交给新住客自己手上不留备份。把这三个概念在脑子里过上几遍类与对象的核心机制基本就通了。接下来你可以把手头的类都检查一遍看看哪些可以加const哪些需要写拷贝构造哪些应该用 delete禁掉拷贝——每一个改动背后都能看到这半篇知识在起作用。