资讯动态

C++对象协作实战:继承多态、拷贝移动与生命周期管理

发布时间:2026/10/15 6:22:57 来源:尧图企业网站定制
上一篇我们把类的定义、访问控制、构造函数和析构函数这些地基打完了。这篇继续往深处走重点讲对象之间的协作方式继承与多态怎么搭才不别扭运算符重载怎么设计才顺手拷贝和移动怎么管才不会崩静态成员和友元到底该在什么时候用。内容偏工程实践适合已经在写类但经常被编译错误和运行时崩溃困扰的读者。C的类和对象最折腾人的地方从来不是语法本身而是一个对象在什么时机、以什么方式被创建、复制、销毁这一整条链路。本篇我尽量用实际项目里的例子来讲不再重复教科书里那些话。1. 从封装到继承先把复用这件事想明白1.1 继承解决的真正问题很多人学继承是从动物-狗-猫这种例子开始的但工作里遇到的场景往往更拧巴。我记得某个模拟项目里要维护一组不同类型的消息有的带时间戳有的带优先级有的两者都带。第一时间想到的就是建一个基类Message再派生出TimestampedMessage和PriorityMessage。代码一开始很顺后面才逐渐意识到继承的真正价值不是复用成员变量而是让不同的对象可以在同一套接口下工作。继承解决的核心问题是两个一是公共字段和公共行为的集中管理二是让上层代码能够用基类指针或引用统一操作不同派生类对象。第一点是顺带的便利第二点才是多态的根基。如果你的代码只是想把几个公共函数抽出来那继承未必是首选组合往往更适合。1.2 继承方式与访问修饰符的交互C有三种继承方式public、protected、private。大部分场景只用public继承。protected和private继承在学术上有意义实际项目里如果你不是在做模板元编程或者特殊策略设计基本可以不用。继承方式会影响基类成员在派生类里的访问级别这是个大坑。比如class Base { protected: int value; }; class DerivedPrivate : private Base { // 在DerivedPrivate里value仍可访问但对DerivedPrivate的外部使用者不可见 }; class DerivedPublic : public Base { // value对DerivedPublic的派生类可见外部依旧不可见 };很多初学者以为protected成员在派生类内部就能随便改这没错。但真正的问题是一旦字段暴露给整个继承体系任何派生类都能修改它代码就变得难以追踪。我现在的习惯是基类里的数据成员一律private需要给派生类用的接口用protected函数包装。这样即使派生类很多数据修改的入口依然可控。1.3 构造与析构的真实执行顺序继承体系下的构造和析构顺序是最容易产生隐晦问题的点。规则其实很固定构造先基类再成员对象最后派生类自己的构造函数体。析构先派生类析构函数体再成员对象析构最后基类析构。但很多人记住了顺序没理解背后的原因。基类先构造是为了保证派生类构造函数体执行时基类部分已经完整可用析构反过来是为了保证派生类在销毁自己特有资源时基类部分还未被破坏。实际项目中最危险的情况是基类构造函数里调用了一个virtual函数。别这么做因为在基类构造期间虚函数分派不会进入派生类版本此时派生类成员还没构造调用它就是自找麻烦。1.4 组合优于继承的判断标准业界常说的composition over inheritance大多数情况下是对的。判断标准对我来说很简单派生类和基类之间是否满足是一种的关系。如果不是就不该继承。比如汽车和引擎是组合关系引擎作为汽车的一个成员对象更自然而正方形和长方形教科书说是继承但实际继承后会遇到严重的一致性麻烦。很多看似复用代码的好机会用组合反而更省心。我在项目里经常这样处理把可变区域的数据用组合方式聚合把需要暴露统一策略的接口用继承实现。要不要继承取决于你的调用方是不是打算只认基类。2. 多态背后的机制虚函数、虚表与实际工程里的重写规则2.1 虚函数不是免费午餐C的多态通过虚函数实现而虚函数通常靠虚表vtable完成运行时派发。每个含虚函数的类会生成一张虚表里面存了虚函数的地址每个对象内部隐藏一个虚表指针。调用虚函数时程序先取虚表指针再跳到对应函数地址。这是运行时多态的代价一次间接跳转外加对象体积增加一个指针。这些性能损耗在普通业务代码里几乎可以忽略但你要清楚它存在。极致的性能场景比如每个对象都要拷贝亿万次的底层组件就要考虑是否必须用虚函数有没有可能用std::variant或模板替代。2.2 重写与重载是两回事重载overload是同一作用域下同名不同参数列表的函数重写override是派生类重新实现基类的虚函数。很多编译问题来自混淆这两者。C11起我建议无脑加override关键字。class Base { public: virtual void print() const; }; class Derived : public Base { public: void print() const override; // 编译器检查是否真的重写了基类虚函数 };不加override的代码也能编译但一旦基类函数签名改变派生类的重写可能静默变成隐藏函数直到运行时才发现调用的是错误版本。加override之后签名对不上直接编译报错。2.3 为什么析构函数建议声明为虚函数基类的析构函数要不要加virtual是新人最容易纠结的问题。答案很简单只要这个类会被继承基类析构函数就应该声明为虚函数。原因很直接Base* p new Derived(); delete p;如果Base的析构函数不是虚函数delete p只会调用Base::~Base()派生类部分申请的资源就泄漏了。加上virtual后析构调用会从派生类开始一路向上析构。这是多态在对象销毁阶段的关键作用。有人觉得加虚析构会引入虚表性能受损。但大多数场景下失去正确析构导致的资源泄漏远比一次虚调用严重。2.4 纯虚函数与抽象基类的价值纯虚函数让类变成抽象类不能实例化只能作为接口使用。它真正的价值是定义契约派生类必须实现某些能力否则无法编译通过。class Serializer { public: virtual void serialize(std::ostream os) const 0; virtual ~Serializer() default; };写到这里你可能会有个想法既然接口可以约束行为那把所有公共接口都做成纯虚函数不就好了千万别。抽象基类的设计要注意稳定接口一旦作为公共契约发布后期改动成本极高。所以抽象基类里应该只放那些真正稳定、语义清晰的操作。2.5 运行时多态在工程里的边界我踩过的最大一个坑是在某个跨平台消息解析模块里把所有消息类型都弄成多态继承结果代码结构看着很面向对象性能和可维护性却很差。每次新增消息类型都要新增一个派生类消息的字段差别还特别大导致基类接口越来越臃肿。后来重构时换了一种方案消息类型用枚举标识字段用通用容器保存解析逻辑用switch分支外加策略函数处理。对于类型数量有限、行为相对集中的场景这种偏数据的方案更实用。多态适合的是行为随类型变化且变化频繁的场景而不是什么都往上套。3. 运算符重载把自定义类型做得像内置类型3.1 合理重载的边界运算符重载的意义在于让自定义类型的使用方式贴近内置类型。但这不是让你把所有运算符都堆上去。设计原则只有一条重载后的语义要让读者感到自然不能违背普遍认知。比如自定义一个Money类重载表示金额相加合理重载*表示数量乘以金额也可以但如果重载表示颜色更深读者就会困惑。运算符本身就是一种可读性载体滥用它等于给自己埋雷。3.2 成员函数还是友元运算符重载可以做成成员函数也可以做成非成员友元函数。关键差别在于左操作数是否一定是当前对象。最典型的例子是operator和operator左操作数是std::ostream不是你的类所以必须做成非成员函数。而赋值、下标、调用等运算符必须做成成员函数。class Point { public: Point(int x, int y) : x_(x), y_(y) {} Point operator(const Point rhs) const { return Point(x_ rhs.x_, y_ rhs.y_); } private: int x_; int y_; };如果希望1 obj这种写法也成立比如int加Point成员函数版本就无法处理因为左操作数不是Point。这时需要非成员函数版本。我通常的做法是让类提供必要的访问接口把运算符重载写成独立的非成员函数避免把类本身搞得过于臃肿。3.3 必写的典型运算符赋值、比较、输出赋值运算符operator属于默认构造的成员函数但有指针成员时一定要自定义否则会出现浅拷贝问题。比较运算符operator在放入容器、做查找和测试时几乎必写。输出运算符operator则让调试和日志输出方便很多。class User { public: User(std::string name, int age) : name_(std::move(name)), age_(age) {} friend bool operator(const User lhs, const User rhs) { return lhs.name_ rhs.name_ lhs.age_ rhs.age_; } friend std::ostream operator(std::ostream os, const User u) { return os u.name_ ( u.age_ ); } private: std::string name_; int age_; };比较和输出做成友元很方便因为可以直接访问私有成员。有人担心友元破坏封装其实友元只是在类定义处明确指定你是自己人封装边界并没有被突破只是扩展了可信名单。3.4 自增自减的前置后置写法自增自减有两种版本写法上容易出错。前置版本返回引用后置版本返回旧值并且后置版本需要带一个无名的int参数作为区分标记class Counter { public: Counter operator() { count_; return *this; } Counter operator(int) { Counter old *this; count_; return old; } private: int count_ 0; };后置版本要保存旧值通常多一次拷贝性能略差。所以在循环里如果只是推进迭代器优先使用前置版本。3.5 不要重载的运算符有几个运算符我建议永远不要重载逗号运算符、operator、operator||、operator、operator-*。这些运算符一旦重载原本的短路求值、取地址等语义就会被破坏代码可读性断崖式下跌。尤其是和||的短路行为是C控制流的一部分重新定义只会让读代码的人想打人。4. 拷贝、赋值与移动对象搬运工的三板斧4.1 拷贝构造和拷贝赋值的区别拷贝构造函数创建新对象时用另一个对象初始化它拷贝赋值运算符把一个已存在对象的内部状态覆盖为另一个对象的状态。很多人会混淆但在代码里它俩的语义完全不同。User a(alice, 25); User b a; // 拷贝构造b还不存在 User c(bob, 30); c a; // 拷贝赋值c已存在需要先释放自己原来的资源实现拷贝赋值时最重要的细节是自赋值安全和异常安全。自赋值就是obj obj如果没有判断就直接释放原资源会把自己毁掉。4.2 深浅拷贝与指针成员浅拷贝对成员逐位复制。对int、double、std::string这类值没问题但对原始指针浅拷贝会让两个对象指向同一块内存析构时就会出现双重释放。所以凡是类里有new出来的成员都要自定义复制逻辑做深拷贝。深拷贝的实现很简单为目标分配新内存再逐个复制数据。但真正麻烦的是后续维护因为每次新增成员都要同步修改拷贝构造、拷贝赋值和析构函数。所以现在很多新代码直接使用std::string、std::vector、std::shared_ptr这些自带正确复制语义的成员从根源上消灭裸指针和手写拷贝逻辑。4.3 三条/五条规则老话说三法则如果自定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个那么另外两个通常也需要自定义。因为这说明你的类在管理某种资源默认的逐位拷贝不合适。C11之后扩展成五法则还要考虑移动构造函数和移动赋值运算符。实际操作中我建议优先记住零法则通过让成员都是管理自己资源的类型比如std::string、std::vector、std::unique_ptr那么类自动默认构造、拷贝、移动、析构全都正确你的代码几乎不需要手写特殊成员函数。只有当不可避免要管理裸资源时再去用五法则。4.4 C11移动语义把复制变成搬移动语义解决的问题很简单临时对象的资源与其复制不如直接搬走。看一个例子std::vectorstd::string v; std::string s a very long string; v.push_back(std::move(s)); // s的资源被转移到vector中的元素移动之后s理论上处于有效但未指定的状态可以重新赋值但不要再依赖它的原内容。std::move只是把左值转换为右值引用真正的移动动作发生在移动构造函数或移动赋值运算符里。给自己的类写移动构造时记得把源对象的指针成员置空否则源对象析构时会把已经转移的资源释放掉。这一步经常被忽略也是不少崩溃的根源。4.5 实际案例带日志记录的资源类我之前维护过一个资源类既要深拷贝又要移动class Buffer { public: Buffer(size_t size) : size_(size), data_(new char[size]) {} Buffer(const Buffer other) : size_(other.size_), data_(new char[other.size_]) { std::copy(other.data_, other.data_ size_, data_); } Buffer(Buffer other) noexcept : size_(other.size_), data_(other.data_) { other.size_ 0; other.data_ nullptr; } Buffer operator(Buffer other) { swap(other); return *this; } ~Buffer() { delete[] data_; } void swap(Buffer other) noexcept { std::swap(size_, other.size_); std::swap(data_, other.data_); } private: size_t size_; char* data_; };这里赋值运算符参数按值传递先构造一份临时副本再通过swap交换天然支持自赋值安全也支持移动赋值。代码很简洁是偏工程化的写法。noexcept也很关键否则容器扩容时不敢使用移动构造反而退化成拷贝。5. 静态成员、const成员函数与友元三个容易被忽略的设计点5.1 静态成员变量的初始化静态成员变量属于类而不是某个对象。在C17之前非整型的静态成员变量必须在类外定义初始化很多人会漏掉这一步导致链接错误。C17之后可以用inline简化class AppConfig { public: static inline std::string version 1.2.0; };使用静态成员变量时要谨慎它在整个程序里是一份全局状态。全局状态越多测试越难写、并发越难控制。我一般的习惯是静态成员只用来保存与具体对象无关的类级信息比如版本号、默认配置、实例计数。5.2 静态成员函数与this静态成员函数不接收this指针因此不能访问普通非静态成员变量。它本质上是一个放在类命名空间里的普通函数好处是可以访问类的私有静态成员。如果某个函数逻辑上属于这个类又不需要访问对象状态就可以做成静态成员函数。这带来一个设计上的启示静态成员函数适合做工厂函数。比如class Logger { public: static Logger createWithConsole() { return Logger(std::cout); } };工厂函数不需要已有对象却可以控制对象的构造方式比外部直接调用构造函数多了封装层次。5.3 const成员函数与mutable在成员函数声明后面加const表示这个函数不会修改对象状态。编译器会检查你在这个函数里有没有修改成员变量。它不只是语法约束还能让.const对象使用这些函数也方便容器返回常量引用时调用。class Page { public: const std::string url() const { return url_; } private: std::string url_; mutable size_t access_count_ 0; };mutable提供了一个例外窗口它允许const函数修改个别成员常用于缓存计数、锁状态这类逻辑上不影响对象值语义的字段。但别滥用用多了等于绕过const约束。5.4 友元的使用边界友元函数和友元类可以访问类的私有成员。这是一种打破封装的机制但它是显式的、克制的而不是一个漏洞。我在两种情况下会使用友元一是运算符重载需要对称访问两个对象二是某些打包访问函数如序列化、测试辅助工具需要完整读取内部状态。使用的时候我都会在类定义处明确列出友元声明让代码审查的人一眼看到哪些外部实体被授予了特殊访问权。友元不传递、不继承、不交换。A是B的友元不代表A的派生类能访问B的私有成员B是A的友元不代表C和B是友元时C能访问A。这些细节经常被误解大家记住即可。6. 对象生命周期与真实代码容器、智能指针和组合现形记6.1 对象数组的初始化对象数组的默认构造要求类必须有可访问的默认构造函数。如果类没有默认构造函数直接声明数组就会编译失败。class Item { public: Item(int id) : id_(id) {} private: int id_; }; Item items[10]; // 错误没有默认构造函数 Item* items new Item[10]; // 同样错误解决办法通常是使用std::vectorItem然后在初始化时传入参数。现代C里直接管理new[]/delete[]的场景越来越少容器已经把大部分工作接管了。6.2 容器存储对象还是存储指针存对象还是存指针核心看两点对象是否支持拷贝或移动对象在容器调整大小时是否需要保持地址稳定。存对象时vector扩容会导致所有元素被移动或拷贝地址会变化如果其他代码长期持有指向这些元素的指针就会悬空。存指针可以解决地址稳定问题但要自己管理生命周期。最稳妥的做法是存std::unique_ptrstd::vectorstd::unique_ptrItem items; items.push_back(std::make_uniqueItem(42));这样对象在堆上分配地址不会随容器扩容变化同时析构时自动释放不用写一堆手工清理代码。6.3 unique_ptr与shared_ptr的选择unique_ptr表达唯一所有权最适合工厂函数返回值、局部动态对象。shared_ptr用于多个所有者共同持有同一对象比如观察者列表里的共享配置对象。选择的原则是默认用unique_ptr只有当确实需要共享所有权时才升级为shared_ptr。shared_ptr不是没有代价它内部需要原子化的引用计数拷贝和析构的开销明显高于普通指针。某些极端场景下滥用shared_ptr甚至成为性能热点。另外两个shared_ptr互相引用会导致循环引用内存永远无法释放。解决办法是其中一边改用weak_ptr。6.4 RAII思想才是真正的护身符很多人学过类和对象却没有真正理解RAII资源获取即初始化。它的本质是把资源的获取放在对象构造中把释放放在对象析构中让资源的生命周期绑定在对象生命周期上。最典型的RAII对象是容器、智能指针、std::lock_guard、文件流。一旦你习惯这种写法好多崩溃和泄漏问题都能在设计阶段避免。比如我之前某次排查一个偶发崩溃最后发现是某段代码用裸指针管理对象异常抛出后指针没有释放其他线程还在访问已释放的内存。改成unique_ptr后问题直接消失。类与对象的价值不仅在于组织数据更在于借助构造和析构这两个确定时机把资源管理变得自动和可靠。我自己在实战里的体会是写C类时最应该优先思考的不是语法特性多炫而是这个对象的生命周期从哪来、到哪去、有没有可能被意外复制移动后是否还能安全析构。把这些问题在写代码之前想清楚编译和运行时给你的惊喜会少很多。最后建议每个读者都做一个练习挑一个你之前用C语言或弱语言风格写的资源管理模块用类、构造函数、析构函数和智能指针重写一遍。做完之后你会对类和对象的力量有完全不同的感受。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑