
脱开 IDE 的跳转单看一条报错no matching function for call to Derived::log(const char [8])很多人第一反应是我明明写了那个重载啊。加断点、翻头文件、重启索引折腾半小时才发现问题不在重载决议而在更前面的一步名字查找。这就是《Effective C》条款 33 讲的东西——继承体系中派生类的名字会遮掩hide基类中同名的名字注意是遮掩而不是覆盖override也不是重载overload。这三个词在中文里太像了可它们在 C 里对应三套完全不同的机制混一次就要交一次学费。这篇东西把我这些年在这条规则上踩过的坑、修过的代码、写进团队评审清单的条目整理出来。它不是条款原文的翻译条款本身很短短到很多人读完觉得懂了然后在下一个项目里继续被咬。适合谁看已经能写继承、能分清虚函数和普通成员函数但还没把作用域—名字查找—重载决议—虚函数分派这条链路彻底理顺的 C 开发者。如果你正在维护一个有几层继承、几个重载集的类库这篇大概率能帮你省掉几个下午。1. 名称遮掩到底在遮掩什么1.1 一句话规则由内向外找找到就停C 的名字查找规则朴素得有点粗暴编译器在某个作用域里看到一个名字就从最内层的作用域开始往外一层一层找一旦在某一层找到了这个名字查找立刻停止不再往外看。它不会问外层的那个是不是更合适也不会把内外层的候选放进同一个池子里比一比。找到收工。类的作用域是嵌套的派生类的作用域嵌在基类的作用域里面。所以在派生类的成员函数里写f()编译器先看函数体所在的块作用域再看派生类自己找到了就结束派生类里没有任何f才轮到基类。问题就出在找到了就结束这半句上——派生类里哪怕只有一个f基类里那七个f全部作废一个都看不见。这个机制和访问权限无关private的、protected的、public的只要名字在那一层出现过就会挡住外面的同名者。它也和是不是虚函数无关虚不虚是后面分派阶段的事查找阶段一视同仁。它更和参数列表无关——名字相同就遮参数列表不参与查找只在后面的重载决议里才有意义。用一个生活化的类比你去某栋楼找财务部前台告诉你三楼有财务部你上了三楼。至于一楼其实还有个财务部分部前台根本不会提。楼里的指示牌只认名字不认你要办什么业务。1.2 为什么语言要这么设计第一次被这个规则坑到的人都会问为什么不做成内外层合并成一个重载集多省事这个设计背后有两个考量。第一是可预测性。作用域是 C 最基本的组织单位块作用域、类作用域、命名空间作用域都遵循同一套由内向外、找到即停的规则。如果成员函数特殊化处理把基类的同名函数合并进来那么这段代码里的f指谁就变成一个需要知道整个继承链才能回答的问题。而现在的规则下你只需要看当前作用域和它的直接外层。第二个是演进安全。假设基类当初只有f(int)你派生类写了f(int)。某天基类维护者加了个f(double)如果内外层自动合并你的派生类行为会悄悄改变——原本走f(int)的double实参现在可能改走基类新加的f(double)。名字查找在编译期就锁死候选集反而让基类新增重载不会污染派生类的已有调用。代价也很明确基类新增一个重载你的派生类代码可能编译不过或者行为变化因为新名字把旧名字遮得更彻底了。这叫脆弱的基类问题在名称层面的体现。条款 33 给的对策就是using声明本质上是在派生类作用域里手动把基类的名字拉进来让它们重新组成一个重载集。注意using Base::f;不是把函数复制到派生类它只是让基类的名字在派生类作用域中可见。被调用的函数体仍然是基类那一份this指针的类型转换由编译器处理。1.3 遮掩、重载、覆盖三张脸别认错这三个概念必须一次讲清否则后面全是浆糊。我用一张表对齐后面所有例子都建立在这张表上。概念英文发生条件绑定时机典型后果重载overload同一作用域内函数名相同、参数列表不同编译期静态编译器挑最匹配的那个覆盖override派生类中与基类虚函数签名完全相同运行期动态经虚表通过基类指针调用走派生类实现遮掩hide派生类出现与基类同名者不管签名是否相同编译期查找阶段基类所有同名者全部不可见关键点在于遮掩先于覆盖发生。一个函数要想覆盖基类的虚函数前提是它能在派生类作用域里被找到并成功匹配签名而它一旦存在就会顺手把基类里其余的同名重载全部遮掉。所以部分覆盖是常态——覆盖了一个遮掉了一堆。还有一个常被忽略的细节遮掩不仅发生在函数之间。派生类的成员变量、类型别名、枚举名、静态数据成员只要和基类的名字撞了同样会遮。比如基类有using Size std::size_t;派生类写了成员函数void Size();那在派生类作用域里Size就变成了函数名Size x;直接编译失败。这种坑在重构时特别容易踩因为两边名字看起来毫不相干。2. 四个翻车现场看完就忘不掉光讲规则太干我按踩坑难度排了四个现场都是我在实际项目或代码评审里真实见过的形态。代码做了简化但结构没改。2.1 现场一加一个重载老代码集体报错这是最经典的形态。基类提供两个日志接口一个收错误码一个收字符串派生类只想改一改错误码那条的处理逻辑。#include string #include iostream class Logger { public: virtual void log(int code) { std::cout [base] code code \n; } virtual void log(const std::string s) { std::cout [base] s \n; } virtual ~Logger() default; }; class FileLogger : public Logger { public: void log(int code) override { // 只想改这一条 std::cout [file] code code \n; } }; int main() { FileLogger fl; fl.log(404); // ok调 FileLogger::log(int) fl.log(open failed); // 编译错误 }fl.log(open failed)报的是找不到匹配函数。原因很直白FileLogger作用域里有一个log查找在这一层就停了Logger::log(const std::string)从头到尾没被考虑过。const char[12]转不了int于是报错。诡异之处在于如果你写成Logger r fl; r.log(open failed);编译完全正常。同一个对象换个静态类型就从不认识变成认识这就是查找发生在静态类型作用域里的直接证据。很多人第一次遇到这种指针能调、对象不能调的现象会怀疑是编译器 bug。修法就是加一行class FileLogger : public Logger { public: using Logger::log; // 把基类所有 log 拉进本作用域 void log(int code) override { /* ... */ } };加了using之后FileLogger作用域里的候选集变成{FileLogger::log(int), Logger::log(int), Logger::log(const string)}三个一起做重载决议log(open failed)正常走字符串版本。同时FileLogger::log(int)仍然覆盖Logger::log(int)虚函数语义不受影响。这一条要记住using只是把名字拉进来参与重载决议不改变覆盖关系。2.2 现场二静默的行为差异比编译错误可怕十倍上一个例子好歹报错编译器替你发现了问题。真正要命的是那些能编过的。class Config { public: virtual void setValue(double v) { /* 存双精度 */ } virtual ~Config() default; }; class DeviceConfig : public Config { public: void setValue(int v) { /* 存整数精度截断 */ } }; int main() { DeviceConfig dc; dc.setValue(3.9); // 走 DeviceConfig::setValue(int)3.9 变成 3 Config r dc; r.setValue(3.9); // 走 Config::setValue(double)没被覆盖 }这里没有任何编译错误。DeviceConfig::setValue(int)和Config::setValue(double)签名不同所以它不构成覆盖但它照样遮住了基类版本。同一个对象、同一个调用语义通过不同静态类型调用会进两个不同的函数体而且其中一个还会静默截断精度。我在一个设备参数配置模块里见过这个结构问题在生产环境暴露UI 上设置 3.9 的阈值存进配置对象后变成 3读回来再比较就差了 0.9。排查时先怀疑序列化再怀疑浮点误差最后才定位到这一行setValue(int)。修法是给派生类的函数加上overrideclass DeviceConfig : public Config { public: void setValue(int) override; // 编译错误不匹配任何基类虚函数 };override在这里的价值不是保证覆盖而是保证不匹配时立刻报错。它逼着你面对我到底是想覆盖还是想重载这个问题。如果想要重载就写using Config::setValue;加上setValue(int)并明确知道setValue(3.9)会走哪个。心得凡是派生类里出现与基类虚函数同名的函数override和using二选一别两个都不写。两个都不写就是上面这种静默事故的温床。2.3 现场三模板基类的名字编译器压根不去找这个和条款 33 的主线略有区别但它在实际工程里的发病率比前两个还高而且报错信息更迷惑。template typename T class Storage { public: void init() { /* ... */ } void put(const T v) { /* ... */ } T value{}; }; template typename T class Cache : public StorageT { public: void warmUp() { init(); // 编译错误找不到 init put(value); // 编译错误找不到 put 和 value } };报错大致是 there are no arguments to init that depend on a template parameter。原因是模板的两阶段查找init()是一个不依赖模板参数的名字编译器在定义期就要解析它而此时基类StorageT具体是什么还没确定标准规定此时不去依赖基类里找。于是只能在全局作用域里找找不到就报错。修法有三种语义上略有差别但效果一致template typename T class Cache : public StorageT { public: using StorageT::init; // 方式一显式引入名字 using StorageT::put; using StorageT::value; // 成员变量也能这样引入 void warmUp() { this-init(); // 方式二this- 让它变成依赖名 StorageT::put(value); // 方式三全限定 } };方式一最省事一次引入、全类可用而且它和条款 33 的using是同一个语法。方式二、三每次调用都要写代码里显得啰嗦但好处是局部影响、一眼能看出这是基类成员。我个人的习惯是调用次数多就用using只有一两处就用this-。顺便说一句很多团队会在派生类模板里统一写using BaseT::Base;来继承构造函数——那是 C11 的另一套机制虽然长得像但不参与重载集别和上面的混为一谈。构造函数不能被遮掩这个概念覆盖它压根不参与名字查找。2.4 现场四多继承下的同名using也救不了多继承时每个基类都是独立的作用域派生类的查找规则是在所有基类里找找到多个就报歧义。struct Reader { void run(); }; struct Writer { void run(); }; struct Duplex : Reader, Writer { using Reader::run; // 只引入 Reader 的 using Writer::run; // 我全都要 }; int main() { Duplex d; d.run(); // 编译错误ambiguous }两个using把两个签名相同的run都拉进了Duplex作用域重载决议面对两个一模一样的候选无法选择报歧义。如果两个run签名不同比如一个run(int)、一个run(std::string)那它们能组成合法的重载集按参数正常挑选。正确做法取决于意图只想暴露其中一个就只写一条using想暴露两个但语义不同就改签名如果两个都要按上下文选就在派生类里写一个明确的转发struct Duplex : Reader, Writer { using Reader::run; using Writer::run; void run(int n) { Reader::run(); (void)n; } // 显式定义压过两个基类 };派生类自己定义的run(int)优先级最高查找在这一层就停了两个基类的run()被遮住——这次是我想要遮掩属于合理利用规则。这里就引出后面的原则遮掩不总是坏事关键看是不是你主动选的。3. 三种解法与选型对照3.1using声明默认首选但要知道边界using Base::name;是条款 33 的正解语法简单、零运行开销、意图明确。它能一次把一个名字下的所有重载都拉进来也能拉类型别名、静态成员、成员变量。使用时有几个边界必须清楚。边界一不能引入基类的private成员。如果你写了using Base::f;而Base::f是私有的编译直接失败报 is private within this context。protected的可以引入而且引入后它的访问权限由using声明所在的位置决定——把它放在public:区基类的protected函数就能被外部调用。这个特性偶尔被用来有控制地开放基类接口但更多时候是个隐患评审时看到这种写法要问一句是不是基类设计没想清楚。边界二using声明引入的是整个重载集不能只挑一个。如果你只想让基类的f(int)可见、不想让f(double)可见using做不到只能写转发函数。边界三using不改变函数体也不改变虚函数归属。被引入的f仍然是Base::f它内部访问的成员、使用的this类型转换全部按基类来。有个细节在多继承下会体现如果那个基类不是主基类调用时编译器需要做指针偏移这发生在调用点是编译期算好的偏移量。边界四using声明的位置会影响访问权限。放在private:区的using Base::f;是合法的作用是把基类的f在派生类里降级为私有——这其实是实现部分接口收窄的一个技巧。3.2 转发函数基类成员够不着时的唯一选择有时候using用不了最典型的是基类成员在派生类里不可访问——比如私有继承或者基类把那个重载设成了私有但你又有正当理由要暴露。class Codec { // 私有继承的典型场景实现复用而非 is-a public: void encode(const char* buf, int len); void encode(const std::string s); // 这个不想暴露 }; class PacketCodec : private Codec { public: void encode(const char* buf, int len) { Codec::encode(buf, len); } };私有继承下Codec的公有成员在PacketCodec内部是私有的但派生类内部照样能调所以转发函数可行。using Codec::encode;在这里也能用因为私有继承下成员在派生类里是 privateusing放在 public 区可以提权但要注意那会把encode(const std::string)一起暴露出来。想精细控制就写转发。转发函数的代价是多一层调用。写在类内联定义里通常会被内联掉零成本但如果函数体复杂、编译单元之间没有 LTO可能真的多一次 call。我的做法是转发函数一律inline且函数体只有一行让编译器没有理由不内联。还有个容易忽略的点转发函数返回引用时要小心。T operator[](int)这类如果转发时写成return base::operator[](i);返回的引用指向基类子对象里的存储这在非虚继承下没问题一旦涉及虚继承基类子对象的位置在运行期才确定写法就要更谨慎。这种情况我一般直接让派生类用using不自己转发。3.3 全限定与this-局部止痛别当常规药d.Base::f(x)和this-Base::f(x)能绕过遮掩直接指定从哪一层开始找。它们在两种场景下很有用调试时确认到底调的是哪一个以及派生类里只想在某一处调用基类版本。但它不该成为常规手段。理由很简单全限定调用会显式绕开虚函数分派。this-Base::f()直接静态绑定到Base::f即使f是虚函数也不会走到派生类实现。有些人拿它来调用父类实现Java 里的super.f()习惯在 C 里这样做会丢失多态性用在模板方法模式里多半是错的。真正需要调用父类版本的地方通常是派生类覆盖了虚函数、想做前置处理再交给基类class TimedLogger : public Logger { public: void log(int code) override { auto t0 now(); Logger::log(code); // 显式调用基类实现这是有意为之 record(elapsed(t0)); } };这里的Logger::log(code)就是对的——它的语义是调用基类的那个实现而不是通过基类接口多态调用。写的时候脑子里要清楚这是静态绑定。3.4 选型对照表场景首选方案备选不推荐派生类要新增同名重载using Base::f;转发函数无基类成员是private转发函数改基类设计using编不过只想暴露部分重载转发函数拆接口using会全暴露模板基类成员查找不到using BaseT::f;this-f()全限定啰嗦多继承同名歧义明确using一条或定义自己的版本改签名两条using硬拉派生类里调父类实现Base::f()—误以为有多态这张表我贴在团队 wiki 上过新人问为什么编译不过的时候先对着表自查一遍能解决八成问题。4. 把规则吃透编译器视角的逐步推演4.1 名字查找和重载决议是前后两步很多人把这两件事混成一件导致对报错信息的误读。实际顺序是先查找lookup确定候选集再重载决议overload resolution从候选集里挑一个最后做访问控制检查。这个顺序解释了很多现象。d.f(3.14)报 no matching function不是因为没有参数匹配的版本而是因为候选集里压根没有那个版本。候选集是在查找阶段确定的只包含派生类作用域里能找到的f。重载决议面对一个空荡荡或类型不匹配的候选集只能报没有匹配。再看一个更隐蔽的void print(int); struct Printer { void print(const char*); void go() { print(42); // 编译错误 } };go()里写print(42)编译器先在成员函数作用域找没有在Printer类作用域找找到print(const char*)停止。全局的::print(int)永远没被考虑。42转不了const char*报错。修法是::print(42);两个冒号明确指向全局作用域。这个例子说明遮掩不只是继承体系内部的事全局名字同样会被类作用域遮住。在头文件里定义自由函数、又在类里定义了同名成员函数就很容易撞上。顺带说一句 ADL参数依赖查找。对于非限定函数调用f(x)如果普通查找在类作用域、命名空间作用域都找不到编译器还会去x的类型所在的命名空间里找。但 ADL 是最后一步只要类作用域里找到了fADL 就不会发生。所以我明明在同名命名空间里定义了f这种辩解在成员函数遮蔽的场景下不成立。4.2 虚函数分派发生在查找之后p-f(x)的执行流程可以拆成四步确定p的静态类型进入该类型的作用域查找f得到候选集重载决议从候选集里选出唯一的f得到它的签名检查访问权限看调用点能不能访问这个f如果选中的f是虚函数通过对象的虚表指针做动态分派调用最终覆盖版本否则直接静态调用。第 1 步解释了为什么基类指针能调、派生类对象不能调。第 4 步解释了为什么using不影响多态——它只影响第 1 步的候选集第 4 步的虚表查找照旧。这里有个反直觉的推论虚函数的分派目标是最终覆盖者但候选集是由静态类型决定的。所以如果静态类型是Derived而Derived把某个基类虚函数遮住了那么即使实际对象调用的那个函数在基类有覆盖者也永远走不到。还有一个陷阱中的陷阱——默认参数是静态绑定的。哪怕最终调用的是派生类覆盖版本默认参数仍取自静态类型那一版class Base { public: virtual void draw(int layer 1) { /* ... */ } }; class Derived : public Base { public: void draw(int layer 3) override { /* ... */ } }; Derived d; Base r d; r.draw(); // 调 Derived::draw但 layer 是 1 d.draw(); // 调 Derived::drawlayer 是 3这不是条款 33 的内容那是条款 37 的领域但它和名字查找经常一起出现排查时容易搞混一个是选哪个函数一个是参数取哪个默认值。4.3 访问控制为什么排在重载决议之后这个顺序安排有实际意义。假设基类有两个f一个是public一个是private参数不同。派生类using引入了名字调用d.f(...)如果先检查访问权限编译器看到候选集里有私有成员就直接报不可访问但你实际想调的是公开那个实际顺序是重载决议先挑出唯一匹配再检查它是否可访问。挑中的是公开的调用成功。所以报错信息的措辞能帮你定位阶段说 no matching function 是查找/决议阶段的问题说 is private/protected within this context 是访问阶段的问题两个一起说说明候选集里有但都不可访问。这个细节对读错误信息很有用。GCC 的 no matching function for call to 后面通常会列出候选者那个列表就是查找阶段得到的候选集——列出来的东西就是编译器能看见的全部。如果候选列表里没有你期望的基类版本说明它被遮住了不用再纠结参数类型。5. 工程落地约定、检查清单与反例5.1 接口设计层面的三条约定规则本身是语言机制落不到规范上就等于没有。我给团队定过三条执行下来效果不错。约定一派生类新增与基类同名的函数必须显式写using或override。目的不是强制合并重载集而是强制开发者做一次选择。写了其中一个就说明我知道这里有同名评审时也有据可查。两个都不写一律视为可疑代码。约定二基类接口尽量避免同名多签名的重载集跨层传递。如果一个虚函数族有多个重载考虑把它们拆成不同名字logCode/logText或者统一用一个参数类型。重载在单一类里很好用跨越继承层次就变成负担。命名成本远低于排查成本。约定三模板基类的成员统一用using引入到派生类顶部。一劳永逸避免每个函数里写this-。写法固定成template typename T class Cache : public StorageT { using StorageT::init; using StorageT::put; using StorageT::value; public: // ... };放在private:区是为了让这些名字只在类内部可见对外接口仍然只有派生类自己定义的。这个小技巧很多人不知道其实很实用。5.2 代码评审与 CI 清单下面这几条我会在评审时逐条扫也写进了静态检查规则里clang-tidy 的自定义检查或者简单的正则匹配都能做。检查项风险处理派生类有与基类虚函数同名的函数但没写override静默不覆盖补override让编译器判定派生类有与基类同名的函数但没写using基类重载被遮补using或加注释说明有意遮掩基类是模板派生类直接调用基类成员两阶段查找失败加using或this-多继承中有两个using引入同签名函数调用歧义只保留一条或定义自己的版本派生类里出现using Base::f;且Base是以private方式继承对外接口意外扩大改用私有区using或转发函数补充一句override不是万能的。如果两个函数参数类型不同比如f(int)和f(long)编译器会认为你是想重载override会报错但你可能只想覆盖其中一个——这时候override反而帮你发现了签名写错了的问题。我遇到过最离谱的一次是把const std::string写成std::string值传递版本不构成覆盖但代码能编过因为不写override虚调用始终走基类。加了override之后立刻暴露。5.3 什么时候该主动遮掩条款 33 的标题是避免遮掩但避免的前提是你知道自己在遮掩。有些场合遮掩是设计意图不该被消灭。场合一派生类想禁用基类的某些重载。基类提供了void open(const char*)派生类只支持字符串版本不想让裸指针版本被调用。做法是在派生类里定义一个void open(const char*) delete;用删除函数遮住基类版本。class FileHandle : public Handle { public: using Handle::open; void open(const char*) delete; // 明确禁用误用直接编译失败 };这个写法比运行期检查强得多属于让错误发生在编译期的典型。注意 delete的函数也参与重载决议被选中后报use of deleted function信息非常明确。场合二命名屏蔽。基类的某个成员在新派生类里语义完全不同故意不让人调到。这时候不写using就是最简洁的表达但一定要写注释否则下一个人会当 bug 修掉。场合三非虚接口NVI模式。基类把虚函数设成private只暴露非虚的公有接口。派生类无法通过名字查找直接调用到基类虚函数因为private这种隐藏是设计的一部分。这类代码里出现using反而要警惕。判断标准很简单遮掩之后派生类的接口是否更清晰、误用是否更容易被发现是就保留不是就加using。6. 常见问题速查与踩坑实录6.1 报错信息与真实病因对照报错简化真实病因快速验证no matching function for call to D::f(...)基类重载被遮候选集里没有看报错里列出的候选缺哪个就是被遮了同上一句但换成基类指针就正常查找在静态类型作用域进行把变量声明成基类引用试一次ambiguous多继承同名或两条using引入同签名数一下候选来源f is private within this contextusing引入了不可访问成员检查基类成员的访问级别there are no arguments to f that depend on a template parameter模板基类的非依赖名查找加this-或usingcannot convert const char* to int常常是上一条的连带症状看候选集是否只有一个版本use of deleted function派生类主动禁用检查是否有 delete我最常用来快速验证的一招把对象的静态类型改成基类引用看看能不能编过。能编过说明是遮掩问题不能编过说明是真没定义这个函数。三十秒能定性比翻文档快。6.2 几个反直觉的细节细节一using引入的函数虚函数语义完全保留。有人以为using相当于在派生类里又声明了一份会导致两次分派。不会。using不产生新的函数实体。细节二using声明不影响函数重载决议的最佳匹配判定顺序。引入之后派生类自己定义的版本和基类版本是平等的候选不因为在派生类定义而优先。很多人以为派生类的更近所以优先其实只有在查找阶段才有近的概念进了候选集就一视同仁。细节三using引入的名字可以在派生类里继续被遮蔽。从基类拉进来的f(double)如果派生类里又有同签名的定义后者胜出。细节四typedef/using别名同样遵守遮掩规则。基类有using Handle int;派生类有成员变量int Handle;那派生类里Handle就是变量名类型用法全部失效。编译报错往往指向一行看起来完全无关的模板实例化。细节五using namespace和using声明是两回事。前者把整个命名空间的名字引入当前作用域后者只引入一个具体名字。前者在头文件里用会污染全局后者相对安全。但两者都不影响类继承的查找规则——using namespace不会把命名空间里的函数塞进类的重载集这一点经常被误以为可以。细节六友元函数和成员函数的查找路径不同。友元函数定义在类内但不在类作用域里它是命名空间成员。所以派生的成员函数不会遮住基类的友元函数名字根本不在类作用域里。这条在写运算符重载时能省不少事。6.3 我的排错顺序遇到函数明明有、就是调不到的情况我按这个顺序走基本三步内出结果。第一步看报错的候选列表。GCC 和 Clang 都会把查找阶段得到的候选打出来候选列表就是编译器眼中的全部世界。缺哪个就是被谁遮了。第二步确认静态类型。指针、引用、值对象、模板实例化后的类型逐个确认。特别留意auto推导出来的类型以及函数参数按值传递时的退化const char*变成const char*数组退化成指针。第三步加using试一下。能编过就说明是遮掩问题然后回头想清楚是加using合并重载集还是加override让编译器告诉你签名不匹配。这两个方向是相反的选错了会把问题从编译不过变成行为不对。至于行为不对的情况那是另一个排查路径了。我的经验是先固定静态类型写下期望调用的完整签名含参数类型再对照虚表——用gdb打断点或者在关键函数里打日志确认实际进了哪个函数体。静态类型和实际执行的函数对不上八成是覆盖没成功九成是签名差了个const或引用。最后再分享一个我在实际项目中反复验证的小技巧写派生类时先把基类的公有接口原样抄一遍注释在派生类顶部然后逐个决定每个函数是覆盖、是using、还是故意不暴露。这个动作花五分钟能避免后面几小时的排查。遮掩这回事最怕的不是规则复杂是压根没意识到它存在。至于 delete那招我现在基本每个派生类都会用上一两次。与其在运行期加断言、写日志、抛异常不如让编译器在编译期把错误路径堵死。名字查找是编译期的事那就让编译期替你把关。