
前言模板类的继承在语法上和普通类几乎一样但一旦基类依赖模板参数名字查找规则就会让很多「明明写在基类里」的成员突然找不到。典型症状是把普通类改成模板后原来能编译的foo()调用报「未声明标识符」或者派生类里写typename T::value_type却忘了typename编译器把类型当成静态成员再或者调用基类的成员模板时setint(x)被解析成「小于」比较。这些都不是编译器 bug而是 C 两阶段名字查找two-phase name lookup的必然结果。另一类问题出在访问控制上。C 的继承有public、protected、private三种方式它们改变的是「基类成员在派生类外部看来是什么访问级别」而不是「基类成员本身是否可见」。很多人把 private 继承和「基类成员在派生类里不可访问」混为一谈实际上 private 继承下派生类内部仍能访问基类的 public/protected 成员只是外部不能把派生类当基类用。本文按「依赖名查找 → 成员访问写法 → 类型与模板消歧 → 继承方式与访问控制」的顺序展开示例以 C17 为基准。所有结论都来自标准语义不依赖某一款编译器的扩展行为。一、两阶段查找与依赖基类C 模板的名字查找分两个阶段。第一阶段在模板定义时进行不依赖模板参数的普通名字如全局函数、前面声明的类型在这里绑定。第二阶段在模板实例化时进行依赖模板参数的名字dependent names才去查。这样做的好处是模板定义处就能发现拼写错误代价是编译器在定义时不会去看「依赖基类」里有什么。考虑下面的结构// 适用于 C17#include iostreamtemplate typename Tclass Base {public:void greet() { std::cout hello from Base\n; }T value{};};template typename Tclass Derived : public BaseT {public:void call() {greet(); // 错误定义时找不到 greet}};在DerivedT的定义处BaseT是依赖基类dependent base class因为T未知编译器不知道BaseT到底有哪些成员——T甚至可能触发特化让BaseT变成完全不同的类。因此第一阶段查找不会进入BaseTgreet就找不到。这不是「继承没生效」而是查找阶段的问题。解决方法有三种语义上等价写法上各有适用场景this-greet()this的类型是DerivedT*依赖T所以整个表达式延迟到实例化阶段再查届时基类成员可见。BaseT::greet()显式限定到基类同样延迟到实例化。using BaseT::greet;在派生类里写一条 using 声明把名字引入当前作用域之后可以直接写greet()。第三种最适合需要反复调用、或者希望派生类对外暴露基类重载集的场景。前两种适合单次调用。写法何时解析适用场景greet()定义时非依赖基类普通类继承this-greet()实例化时依赖基类的普通成员BaseT::greet()实例化时需要明确指定基类或多基类消歧using BaseT::greet;引入后按普通名字要恢复被隐藏的重载集二、名字隐藏与 using 声明即使不是模板派生类声明一个与基类同名的成员也会隐藏基类中所有同名成员而不只是签名相同的那个。这一点在模板里更隐蔽因为基类可能有多个重载派生类只写了一个其余全部不可见。// 适用于 C17#include iostreamstruct Base {void show(int x) { std::cout int x \n; }void show(double x) { std::cout double x \n; }};struct Derived : Base {void show(const char* s) { std::cout str s \n; }};int main() {Derived d;d.show(hi);// d.show(42); // 错误Base::show 被隐藏d.Base::show(42); // 显式限定可以调用return 0;}在Derived里加一行using Base::show;两个基类重载就会和Derived::show(const char*)一起参与重载解析。模板派生类中写using BaseT::show;同理。这条规则也适用于成员类型别名派生类定义了同名value_type基类的就被隐藏。还有一类隐藏来自虚函数。派生类重写虚函数后基类中同名的其他重载同样被隐藏需要using引入。这与「重写」是两回事override只保证签名匹配的那个函数覆盖成功不负责把其他重载带进来。三、依赖类型typename 与 template当成员是类型时编译器默认不知道T::something是类型还是静态成员标准规定在模板中依赖名的::后面默认按非类型处理除非显式写typename。// 适用于 C17#include vectortemplate typename Tclass Container {public:// 错误写法typename 缺失编译器认为 value_type 是静态成员// using value_type T::value_type;using value_type typename T::value_type;void add(const value_type v) { data_.push_back(v); }private:T data_;};// 使用Containerstd::vectorinttypename在这里不是「声明一个类型」的意思而是告诉编译器「后面这个依赖名是类型」。C20 起在某些明确期望类型的上下文如函数返回类型、别名声明右侧可以省略typename但为了可读性和兼容 C17建议照写。对称地当依赖名是成员模板时需要用template关键字消除歧义// 适用于 C17#include iostreamtemplate typename Tstruct Holder {template typename Uvoid set(U value) {std::cout value \n;}};template typename Tvoid configure(HolderT holder) {// holder.setint(42); // 被解析为 (holder.set int) (42)holder.template setint(42); // 正确}这里holder的类型依赖Tset是未知类型的成员编译器默认把当作小于号。加template明确告诉它「后面是模板实参列表」。注意template关键字只在这种「依赖对象的成员模板」场景需要holder.set(42)让实参推导则不需要。四、继承方式、访问控制与 CRTP继承方式决定基类的 public/protected 成员在派生类中的最高访问级别继承方式基类 public 成员在派生类中基类 protected 成员外部能否转换为基类publicpublicprotected能protectedprotectedprotected仅派生类内部privateprivateprivate仅派生类内部private 继承表达的是「用基类实现」而不是「是一个基类」常被称为 implemented-in-terms-of。派生类内部仍可访问基类的 public/protected 成员但外部既不能访问这些成员也不能把Derived*隐式转成Base*。如果确实需要转换必须在派生类内部提供一个成员函数做显式转换。关于 protected有一个容易忽略的规则派生类成员函数访问 protected 成员时必须通过「派生类类型或派生自它的类型」的对象不能通过一个任意的基类对象。写Base b; b.protected_member;在派生类里也是不允许的。这条规则防止派生类去动别人的基类子对象。CRTPcuriously recurring template pattern奇异递归模板模式是模板继承的经典用法派生类把自己作为模板参数传给基类基类通过static_castDerived*(this)调用派生类的方法实现编译期多态避免虚函数开销。// 适用于 C17#include iostreamtemplate typename Derivedclass Counter {public:void tick() {static_castDerived*(this)-onTick();}int count() const { return count_; }protected:int count_ 0;};class Ticks : public CounterTicks {public:void onTick() { count_; }};int main() {Ticks t;t.tick();t.tick();std::cout t.count() \n; // 按语义应为 2return 0;}CRTP 的前提是Derived确实继承自CounterDerived否则static_cast行为未定义。它适合「基类提供通用算法、派生类提供少量钩子」的场景如果需要在运行期替换实现仍应使用虚函数。五、完整示例一个可扩展的数值容器下面的例子把前面几点合在一起模板基类提供size()与print()派生类引入基类重载、访问依赖类型、并用using恢复被隐藏的名字。// 适用于 C17#include cstddef#include iostream#include vectortemplate typename Tclass SequenceBase {public:using value_type T;void print() const {for (const T v : data_) {std::cout v ;}std::cout \n;}std::size_t size() const { return data_.size(); }protected:std::vectorT data_;};template typename Tclass Sequence : public SequenceBaseT {public:using typename SequenceBaseT::value_type; // 引入依赖类型using SequenceBaseT::print; // 引入被隐藏的名字void print(const char* tag) const {std::cout tag : ;this-print(); // 调用基类版本}void push(const value_type v) {this-data_.push_back(v);}std::size_t count() const {return this-size(); // 依赖基类成员}};int main() {Sequenceint seq;seq.push(1);seq.push(2);seq.push(3);seq.print(values);std::cout seq.count() \n;return 0;}这里using typename SequenceBaseT::value_type;的写法是「using 声明 typename」的组合用于把依赖基类的类型别名引入派生类作用域。using SequenceBaseT::print;把无参版本带进来否则Sequence::print(const char*)会把它隐藏。this-data_与this-size()都因为依赖基类而必须加限定。常见坑点❌ 在模板派生类里直接调用基类成员greet()。✅ 写this-greet()、BaseT::greet()或用using BaseT::greet;引入。❌ 写using value_type T::value_type;漏掉typename。✅ 写using value_type typename T::value_type;。❌ 调用依赖对象的成员模板写obj.setint(x)。✅ 写obj.template setint(x)。❌ 派生类定义同名函数后以为基类的其他重载仍然可用。✅ 用using Base::name;把整个重载集引入。❌ 把 private 继承当成「派生类不能访问基类成员」。✅ private 继承只改变外部访问与隐式转换派生类内部仍可访问基类 public/protected 成员。❌ 在派生类里通过任意Base对象访问 protected 成员。✅ 只能通过派生类类型或更派生类型的对象访问。❌ CRTP 里对不继承自该基类的类型做static_cast。✅ 确保继承关系存在必要时加static_assert(std::is_base_of_vCounterDerived, Derived)需要 C17。❌ 用override之后又声明一个参数不同的同名函数以为构成重载。✅ 派生类中的同名函数会隐藏基类版本需要重载就用using引入并逐个检查签名。总结问题原因解决基类成员找不到依赖基类不参与第一阶段查找this-、BaseT::或using类型名被当作值依赖名默认按非类型解析加typename成员模板解析错误被当作小于号加template重载集丢失派生类同名成员隐藏基类全部同名项using Base::name;访问级别混乱混淆继承方式与成员可见性按 public/protected/private 语义区分运行期多态开销虚函数需要静态多态时用 CRTP模板类继承的难点集中在「名字在哪一阶段解析」这一条主线上依赖基类、依赖类型、依赖模板分别对应this-、typename、template三个限定符。把这三个限定符和using声明用熟再区分清楚继承方式改变的是访问级别而不是可见性绝大多数编译错误都能一次定位。