ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++构造和析构中调用virtual函数的陷阱与工程替代方案

C++构造和析构中调用virtual函数的陷阱与工程替代方案 有段时间我在写交易系统的订单基类想统一在基类构造函数里登记一条审计日志。因为所有子类都需要这条记录把登记逻辑放在基类看起来最省事于是直接在构造函数里调用了一个virtual函数。结果测试一跑日志里清一色全是基类的名字子类信息一个都没有。当时我还以为是自己代码里传参传错了排查了半天才反应过来这正是《Effective C》条款09讲的问题绝不在构造和析构过程中调用virtual函数。这个条款我后来在不少项目里反复遇到过变体。它不是那种“写了就会崩”的错误而是静默地给数据记错、给资源配错、给监控报错等发现问题时已经污染了很大一片。这篇文章我就把整个条款从头拆开讲透先用一个最小复现把现象钉死再解释C对象模型里为什么会有这种行为然后给出工程上真正可用的替代方案最后聊聊我在真实项目里踩过的拷贝构造、回调逃逸等几个变体坑。1. 问题现场基类构造函数里调用virtual日志里为什么全是基类名字1.1 最小复现一段能编译能运行但结果错误的代码先看一段最简单的继承代码。我故意让基类构造函数调用一个virtual函数然后创建一个子类对象看看会发生什么。#include iostream #include string class Transaction { public: Transaction() { // 危险在构造函数里调用virtual函数 logTransaction(); } virtual void logTransaction() const { std::cout Transaction::logTransaction std::endl; } }; class BuyTransaction : public Transaction { public: virtual void logTransaction() const override { std::cout BuyTransaction::logTransaction std::endl; } }; int main() { BuyTransaction bt; return 0; }运行结果是什么输出的是Transaction::logTransaction也就是说创建一个BuyTransaction对象时基类构造函数里调用的logTransaction()最终解析到了Transaction自己的版本而不是BuyTransaction的override版本。这个行为符合C标准但绝大多数第一次遇到的人都会觉得反直觉我明明写了virtual对象类型也是BuyTransaction编译器为什么会绕开派生类关键点在于“对象类型是BuyTransaction”这句话在构造过程的不同阶段含义并不一样。后面讲对象模型的时候再展开。1.2 纯虚函数版本编译器用链接错误来提醒你别这么写如果logTransaction()是纯虚函数情况会更刺激。把上面的基类改一下class Transaction { public: Transaction() { logTransaction(); // 调用纯虚函数 } virtual void logTransaction() const 0; }; class BuyTransaction : public Transaction { public: virtual void logTransaction() const override { std::cout BuyTransaction::logTransaction std::endl; } };这段代码在编译阶段往往能通过某些编译器会报警告但在链接阶段会报undefined reference to Transaction::logTransaction()因为基类构造函数内对纯虚函数的调用在构造期间的动态类型解析中指向的是Transaction自身而Transaction::logTransaction没有定义。这反而是一件好事链接错误明明白白告诉你“这条路走不通”比上面那种能跑但结果错的版本容易排查得多。所以第二条经验就是一旦你看到链接期出现“构造函数调用的纯虚函数未定义”别急着骂链接器它其实是在替你拦下一条歧路。1.3 真正的危险在于静默错误普通virtual版本之所以危险是因为它完全符合你写代码时的“语法习惯”——编译不报警最多有个warning、运行不崩溃、程序逻辑照常推进但产生的数据是错的。在最常见的日志场景里你得到的是清一色父类名称在资源管理场景里你登记的资源类型可能全是基类类型在初始化状态机场景里你启动时走到的是基类的默认分支。这类错误不会像段错误一样把你按在原地它会混进业务数据里直到下游分析的人发现“为什么全是基类”你再回头翻代码翻半天才明白是构造期间virtual调用在作祟。因此这个条款在实战里的价值不是防止“崩溃”而是防止“静默的系统性偏差”。2. 规则背后的对象模型为什么构造和析构期间动态类型会“收窄”2.1 构造顺序与动态类型的变化C对象的构造顺序是严格分层的先构造虚基类如果有然后构造直接基类再初始化本类的数据成员最后执行本类构造函数体。派生类永远排在基类后面。这意味着在Transaction构造函数执行的时刻BuyTransaction的那部分对象根本还不存在。你可以想象一个叠房子的过程BuyTransaction的构造函数还没开始Transaction这一层地基刚浇完。此时如果你调用virtual函数编译器只能按照“当前正在构造的这一层”来做动态类型解析也就是Transaction而不是整个对象的最终类型BuyTransaction。很多文章把这个叫“动态类型收窄”我更喜欢管它叫“对象的身份分批解锁”。在基类构造期间对象的身份是基类基类构造完成后如果还有中间层基类身份升级到中间层等所有基类和成员都构造完进入派生类构造函数体身份才完整解锁为最终的派生类。virtual函数调用在每一步只能看到当前已经解锁的身份。2.2 析构阶段的镜像过程析构时恰好反过来而且危险程度更高。析构顺序是先执行派生类析构函数体再析构本类成员然后析构基类层层向下。也就是说当执行到Transaction::~Transaction时BuyTransaction的成员已经全部销毁派生类的那部分对象已经不存在了。此时如果基类析构函数里调用一个virtual函数动态类型解析到的同样是Transaction自身。试想一下如果解析到BuyTransaction的override版本会发生什么那个版本很可能要访问BuyTransaction的成员而这些成员刚刚已经被析构访问它们就是典型的悬空访问。C正是通过“在析构期间动态类型也是当前正在析构的类”这个规则从语言层面堵住了这条死路。从设计角度理解构造和析构是对象生命周期的两个半程状态。在这两个半程里对象还没有形成或已经不再是一个完整的派生类对象强行要求它表现出完整的多态性本身就是一种不合理的期待。2.3 两个常见的误解误解一以为virtual调用只是“编译期决定”的。实际上虚函数解析依赖运行时动态类型而动态类型在构造/析构期间会暂时固定在当前类。编译期能确定的只是“这是一个虚调用”具体落到哪个override则是运行时的事。误解二以为加了override关键字就能让子类版本生效。override只是静态提醒“这个函数覆盖了基类的虚函数”它不会改变构造/析构期间动态类型收窄的规则。我见过同事在子类override函数里打日志验证结果基类构造期间压根没进入子类函数白白排查了很久。3. 工程替代方案让派生类把信息传上来而不是基类回头查3.1 方案A构造参数直接携带类型信息最简单直接的做法是基类构造函数接收一个描述类型或职责的参数派生类在自己的初始化列表里把信息传给基类。基类构造函数只处理这个参数不再回头调用任何与类型相关的virtual函数。#include iostream #include string class TransactionBase { public: explicit TransactionBase(const char* typeTag) : typeTag_(typeTag) { // 这里不再调用virtual而是直接使用参数做登记 RegisterAudit(typeTag_); } virtual ~TransactionBase() default; protected: const char* typeTag_; std::string AuditMessage() const { return std::string([audit] ) typeTag_; } private: void RegisterAudit(const char* tag) const { std::cout [audit] tag std::endl; } }; class BuyTransaction : public TransactionBase { public: BuyTransaction() : TransactionBase(BuyTransaction) { } }; class SellTransaction : public TransactionBase { public: SellTransaction() : TransactionBase(SellTransaction) { } };这样在TransactionBase构造函数里typeTag_已经由派生类提供登记逻辑用的就是正确类型。整个过程中基类不需要知道任何派生类的细节更没有virtual调用问题自然不存在。这里有个容易踩的小坑如果你用typeid(*this).name()来生成类型名在基类构造期间拿到的仍然是基类的运行时类型结果和直接调用virtual一样错。所以不要偷懒用typeid老老实实让派生类自己传名字或者使用后面讲的静态辅助函数。3.2 方案B静态辅助函数在初始化列表里执行如果类型信息需要经过一定计算可以在派生类的初始化列表里调用它自己的静态成员函数。静态成员函数不依赖对象实例在基类构造函数执行之前就能安全运行。class DerivedTransaction : public TransactionBase { public: DerivedTransaction() : TransactionBase(MakeTypeTag()) { } private: static const char* MakeTypeTag() { // 这里只能使用编译期或类级数据不能访问任何this成员 return DerivedTransaction; } };为什么强调不能访问this成员因为初始化列表执行时派生类数据成员还没初始化这个时候访问它们同样属于“半生对象上操作”。但静态函数本身是个安全入口它给基类准备了正确的参数。我会把“所有需要类型信息的复杂逻辑”都塞进这种静态函数里返回一个纯值或一个小型配置对象给基类而不是让基类“反向询问”派生类。这样基类构造期间只处理已经到手的参数整个链路简单清晰。3.3 方案C把真正需要多态的操作推迟到构造完成之后有些场景下构造时需要执行的动作强烈依赖完整对象的信息单纯传几个参数不够。这时可以接受“两阶段构造”也就是把初始化动作拆成构造函数 Init()方法让调用方在对象构造完成后显式调用Init()。class Connection { public: Connection() default; void Init() { // 此时对象已经完整可以安全调用virtual OnInited(); } virtual void OnInited() { std::cout Connection::OnInited std::endl; } static std::shared_ptrConnection Create() { auto conn std::shared_ptrConnection(new Connection()); conn-Init(); return conn; } };用静态工厂方法Create()把“构造”和“初始化”两步包起来调用方拿到的一定是初始化好的对象避免了“忘记调Init”的问题。这个做法的缺点是构造函数本身无法完全保证对象处于可用状态所以在团队里必须有统一规范凡是提供Init()的类都要配套工厂方法。两阶段构造不是首选但在需要完整多态状态的场景里它比硬在构造函数里碰virtual安全得多。3.4 为什么CRTP不能简单解决这个结界有朋友问过我用CRTP奇异递归模板模式把派生类作为模板参数传给基类基类构造函数里直接调用T::log()是不是就绕开了virtual的动态类型问题表面看确实绕开了虚表机制但要注意在BaseT构造函数里调用T::log()时T的构造函数同样还没执行T的数据成员和虚函数机制都还没有进入可用状态。如果log()实现访问了T的成员照样是未定义行为。CRTP能规避的是“虚函数解析不到正确子类”的表象规避不了“子类尚未构造完成就被调用”的本质。所以哪怕用CRTP仍然应该坚持把信息从派生类传给基类而不是让基类去主动调用派生类的函数。这个原则和条款09的内核完全一致。4. 边界情况与常见误用拷贝构造、两阶段构造和回调逃逸4.1 拷贝构造的“时机”同样受约束条款09说的是“构造”和“析构”过程这里的构造当然包括拷贝构造函数和移动构造函数。很多人写深拷贝时习惯在基类里放一个Clone()之类的虚函数试图让派生类重写拷贝逻辑。问题在于如果在基类拷贝构造函数内部调用Clone()构造期间的动态类型仍然停留在基类Clone()根本不会解析到派生类版本。正确的做法是把多态拷贝函数放在基类由派生类override然后由外部代码在对象完整构造完成之后调用#include memory class Base { public: virtual ~Base() default; virtual std::unique_ptrBase Clone() const { return std::make_uniqueBase(*this); } }; class Derived : public Base { public: std::unique_ptrBase Clone() const override { return std::make_uniqueDerived(*this); } };外部拿到一个Base*指针时直接调用Clone()虚函数解析到Derived::Clone()复制出来的就是完整的Derived对象。整个过程中拷贝不是发生在“构造期间”而是发生在“对象已完整存在”的阶段所以多态行为正常。这个例子也解释了为什么“拷贝构造函数调用时机”这么容易跟条款09搅在一起一旦你想在拷贝构造内部做多态拷贝就等于踩进了同一个坑。拷贝构造时期拷贝源对象可能是完整的但目标对象还处于基类构造阶段它的动态类型同样是基类。4.2 两阶段构造能用但别滥用两阶段构造在服务端代码里比较常见因为它能解决“构造函数里拿不到完整多态状态”的问题。但我在项目里见过不少滥用有人在构造函数里分配了一堆资源然后把业务初始化放进Init()如果Init()失败析构函数还要处理“半初始化对象”清理逻辑变得复杂且容易遗漏。如果非要用两阶段构造我建议遵循三条规矩必须提供静态工厂方法禁止裸用newInit()。Init()只做业务状态初始化不重复分配构造函数已分配的资源。构造函数保持“只要构造成功析构函数就能清理”的强保证。另外析构阶段如果确实需要通知外部“对象即将销毁”不要在基类析构里调用virtual函数通知。更好的做法是由最外层派生类的析构函数完成通知或者把通知信息通过基类构造时传入的注册表统一维护。4.3 构造期间把this传出去的回调逃逸陷阱比在类内部调用virtual函数更隐蔽的是在构造函数里把this指针传给外部对象外部对象收到指针后回调这个对象的virtual函数。你可能会想“我在构造函数外面通过基类指针调用virtual总该是正常多态了吧”但如果回调发生在构造完成之前结果依然踩雷。考虑一个事件订阅场景class EventSubscriber : public Base { public: EventSubscriber(EventCenter* center) { center-Register(this); // 把this传出去 } void HandleEvent() override { ... } };如果EventCenter在收到注册后立刻回调HandleEvent()而此时EventSubscriber的基类都还没构造完回调里执行的任何虚函数都不可依赖访问成员更是危险。更麻烦的是这种问题往往不会每次触发只有特定事件时序下才出现排查成本极高。我的原则很简单构造函数里绝不允许把this外传除非你明确知道接收方绝对不会在当前构造链中调用回任何方法。现实中“明确知道”很难保证尤其代码库大了之后调用链随时会长出新的分支。所以干脆一刀切构造期间不外传this。5. 经验沉淀我如今怎么设计多态类的构造和析构5.1 评审多态类代码时我会重点盯这几处经历了前面这些坑之后我现在评审代码时只要看到“构造函数/析构函数 virtual函数调用”的组合无论蓬头垢面与否都会直接标红。重点检查这几类构造函数体内是否有任何形式的虚调用包括直接调用、通过辅助函数间接调用、通过回调触发。析构函数体内是否调用清理型虚函数比如Clear()、Release()、Close()。这类调用即使当下解析到基类版本没问题也容易让子类清理逻辑被跳过。拷贝/移动构造函数里是否依赖Clone()或任何返回派生类类型的多态机制。构造函数是否把this注册进外部容器且容器可能立即回调。标红不是一刀切说“绝对禁止任何变体”而是要求解释清楚在对象生命周期未完整的情况下这个调用怎么可能安全如果解释不清按存在的问题对待。5.2 一个稳定可用的基类注册式设计为了让构造/析构过程中“必须登记某些元信息”的需求更优雅我常用一个简单的注册表设计。核心思路还是那条派生类在初始化列表里把类型标识传给基类基类负责统一入表析构时统一出表整个过程完全不触碰virtual。#include string #include map class RegisteredBase { public: explicit RegisteredBase(const std::string typeTag) : typeTag_(typeTag) { GetRegistry().emplace(typeTag_, this); } virtual ~RegisteredBase() { GetRegistry().erase(typeTag_); } static const std::mapstd::string, RegisteredBase* GetRegistry() { return registry_; } protected: const std::string TypeTag() const { return typeTag_; } private: static std::mapstd::string, RegisteredBase* registry_; protected: std::string typeTag_; }; std::mapstd::string, RegisteredBase* RegisteredBase::registry_;注意GetRegistry()在析构函数里可以被安全调用因为静态成员和基类成员在这个阶段仍然有效。派生类只需要在初始化列表里传一个字符串字面量比如RegisteredBase(Order)就完成了注册。后续任何人想遍历所有订单子类实例直接遍历静态注册表即可。这个设计的好处是把“类型信息传递”完全显式化不依赖任何运行时多态技巧。衍生出来的注册表还能直接用于序列化、监控统计、动态工厂等场景。5.3 设计心法总结生命周期里不做多态跳转多写几个项目之后我越来越觉得条款09背后的设计心法可以扩展成一句更普遍的话不要在对象生命周期的中间状态下做多态跳转。构造函数是对象“从无到有”的中间状态析构函数是对象“从有到无”的中间状态这两个阶段都不具备完整的多异性强行让它多态就是在做假动作。正确的动作应该是把派生类拥有而基类需要的信息提前由下往上传递把需要在完整对象状态下执行的操作推迟到完整状态之后。落地的工具无非是构造参数、静态辅助函数、两阶段工厂方法、静态注册表选哪个取决于具体场景。所以那句老话对工程仍然有效在对象出生和死亡的道路上别期待多态奇迹。我的习惯是评审代码时看到构造/析构里有virtual调用就立刻标红不是因为它一定崩溃而是它背后通常反映了设计者没想清楚对象生命周期。把信息从子类流到基类让基类在构造和析构阶段只做自己能确定的事这样写出来的继承体系才会像真正的砖墙一样稳而不是垒到一半才发现某块砖在指望着还没砌到的那一层。
返回列表