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