为什么需要组件化
写一个功能不难。难的是把这个功能给别人用的时候,对方只需要一行 #include,不需要读你的源码,不需要改你的代码,编译通过,性能不比你手写的差。
C++ 社区里,能做到这点的库都有一个共同特征:编译期的活绝不拖到运行期。
这篇文章讲四个核心手法,它们是所有「即插即用 C++ 组件」的基础。每个手法配一个能跑的实例,读完你就能理解为什么 std::make_unique 比你手写 new 更好、为什么 STL 的 std::sort 比 C 的 qsort 快。
1. 模板:不只是泛型
从函数重载说起
假设你要写一个取最大值的函数。不用模板的话,得这样:
int max(int a, int b) { return a > b ? a : b; }
float max(float a, float b) { return a > b ? a : b; }
double max(double a, double b) { return a > b ? a : b; }
// ... 每加一种类型多写一遍
注意这三行代码除了类型名之外一模一样。模板就是让编译器帮你写这些重复代码:
template <typename T>
T max(T a, T b) {
return a > b ? a : b;
}
调用 max(3, 5) 时,编译器看到两个 int 参数,自动推导出 T = int,然后生成一份 int 版本的代码。这个过程叫模板实例化,发生在编译期,不花任何运行时的开销。
类模板:容器背后的东西
函数模板之外,类模板才是组件化的主力。标准库的 std::vector<int>、std::map<std::string, int> 都是类模板。你给不同的类型参数,编译器生成不同的类。
template <typename T>
class Box {
T value;
public:
explicit Box(T v) : value(std::move(v)) {}
const T& get() const { return value; }
};
Box<int> i(42);
Box<std::string> s("hello");
Box<int> 和 Box<std::string> 是两个完全不同的类——它们不共享代码,不共享静态变量。这是模板和 Java/C# 泛型的核心区别:C++ 的模板是为每种类型单独编译一份,不是用一个通用字节码擦除类型信息。
这个区别有一个重要后果:模板可以针对特定类型做完全不同的实现。
特化:对不同类型区别对待
// 通用版本
template <typename T>
class Storage {
public:
void store(const T& val) { /* 通用存储逻辑 */ }
};
// 针对指针的特化版本
template <typename T>
class Storage<T*> {
public:
void store(T* val) {
if (val) delete *val; // 指针版本:自动管理所有权
// ...
}
};
这种「同一套接口、不同类型不同实现」的能力,后面 Policy-Based Design 会大量用到。
非类型模板参数
模板参数不一定是类型,也可以是值:
template <typename T, size_t Capacity>
class StaticBuffer {
T data_[Capacity]; // 栈上分配,编译期确定大小
public:
constexpr size_t capacity() const { return Capacity; }
T& operator[](size_t i) { return data_[i]; }
};
StaticBuffer<int, 16> buf; // 16 个 int,不堆分配
这就是 std::array 的原理。数组大小在编译期确定,不会有动态内存分配。
2. CRTP:用继承来获得编译期多态
虚函数的代价
运行时多态靠虚函数表(vtable)实现。每调一次虚函数,程序要走三步:
- 从对象里找到 vtable 指针
- 从 vtable 里找到函数地址
- 间接跳转过去
单次调用这点开销无所谓。但如果在一个几百万次的循环里调虚函数,vtable 查找和间接跳转加起来就不是小数目了。更关键的是:编译器无法内联虚函数调用,因为它在编译期不知道实际对象是什么类型。
CRTP 怎么干
CRTP(Curiously Recurring Template Pattern,奇异递归模板模式)用一个模板基类来替代虚函数。名字很唬人,看代码就明白:
template <typename Derived>
class Counter {
public:
void increment() {
// static_cast<Derived*>(this) → 把自己当成子类
static_cast<Derived*>(this)->doIncrement();
}
int value() const {
return static_cast<const Derived*>(this)->doValue();
}
};
class AtomicCounter : public Counter<AtomicCounter> {
std::atomic<int> count_{0};
public:
void doIncrement() { count_.fetch_add(1, std::memory_order_relaxed); }
int doValue() const { return count_.load(std::memory_order_relaxed); }
};
class SimpleCounter : public Counter<SimpleCounter> {
int count_{0};
public:
void doIncrement() { ++count_; }
int doValue() const { return count_; }
};
使用时:
AtomicCounter ac;
ac.increment(); // 编译期就知道调用 AtomicCounter::doIncrement()
关键:static_cast<Derived*>(this) 在编译期确定类型,编译器可以直接内联 doIncrement() 到 increment() 里。没有虚函数,没有 vtable,零运行时开销。
CRTP 的核心思想
基类模板写通用逻辑(比如 increment 的调用流程),通过 static_cast 把调用分发到子类的具体实现。子类只需要提供几个约定的方法名(doIncrement、doValue),这就是编译期接口——不靠虚函数,靠编译期类型确定。
前面设计模式课程里的 Singleton 组件就是 CRTP 的经典应用:
template <typename T>
class Singleton {
public:
static T& instance() {
static T inst;
return inst;
}
protected:
Singleton() = default;
};
class ConfigManager : public Singleton<ConfigManager> { /* ... */ };
ConfigManager 继承 Singleton<ConfigManager>,直接获得全局单例能力。CRTP 让基类能用子类的类型信息,这是虚函数做不到的。
3. Policy-Based Design:编译期策略组合
一个 Logger 的三种策略
假设要写 Logger:输出到 stdout、文件、网络。传统做法:
class IOutput { virtual void write(string_view msg) = 0; };
class StdoutOutput : public IOutput { /* ... */ };
class FileOutput : public IOutput { /* ... */ };
class Logger {
unique_ptr<IOutput> sink_;
public:
void log(string_view msg) { sink_->write(msg); } // 每次调用都是虚函数
};
Policy-Based 换个思路:把输出策略当模板参数传进去。
struct StdoutPolicy {
void write(std::string_view msg) {
printf("%s\n", msg.data()); // 或者 fwrite
}
};
struct FilePolicy {
FILE* f_;
FilePolicy(const char* path) : f_(fopen(path, "a")) {}
~FilePolicy() { if (f_) fclose(f_); }
void write(std::string_view msg) {
if (f_) fprintf(f_, "%s\n", msg.data());
}
};
template <typename OutputPolicy>
class Logger : private OutputPolicy { // 私有继承,空策略不占空间
public:
using OutputPolicy::OutputPolicy; // 继承构造函数
void log(std::string_view msg) {
std::lock_guard lock(mutex_);
this->write(msg); // 编译期已确定调用哪个 write
}
private:
std::mutex mutex_;
};
// 使用
Logger<StdoutPolicy> logger1; // 写终端
Logger<FilePolicy> logger2("/var/log/a.log"); // 写文件
Logger<StdoutPolicy>::log() 和 Logger<FilePolicy>::log() 是两个不同的函数,各自内联了对应策略的 write()。零虚函数开销。
Policy-Based 的要诀
- 策略是普通类,不继承任何接口。只要提供约定的方法(
write(string_view)),编译器不管你类名叫什么。 - 组合通过模板参数:
template <typename Policy1, typename Policy2, ...>。 - 私有继承策略:
class Host : private Policy,这样策略如果是空类(没有成员变量),编译器可以优化掉它占用的空间。
这种设计在 STL 里到处都是:std::unique_ptr<T, Deleter> 的 Deleter 就是一个 Policy,std::unordered_map 的 Hash 和 KeyEqual 也是。
4. 类型擦除:把不同类型装进同一个容器
什么时候需要类型擦除
Policy-Based 很强,但它有一个限制:策略在编译期确定,运行时不能换。
有时候你需要把不同类型的回调塞进一个 std::vector,或者运行时决定用哪个策略。这时候就需要把不同类型「擦」成一个统一的接口。
std::function 就在做这件事
int add(int a, int b) { return a + b; }
auto mul = [](int a, int b) { return a * b; };
std::vector<std::function<int(int,int)>> ops;
ops.push_back(add); // 函数指针
ops.push_back(mul); // lambda(每个 lambda 是不同类型)
ops.push_back(std::plus<>{}); // 函数对象
// 运行时遍历调用
for (auto& op : ops) {
std::cout << op(3, 4) << '\n'; // 7, 12, 7
}
add 是一个函数指针,mul 是一个编译器生成的匿名 lambda 类,std::plus<> 是另一个类——三种完全不同的类型,全部被 std::function<int(int,int)> 擦成同一个接口。
std::function 怎么做到的
简化版实现展示原理:
// 步骤 1:定义一个统一的虚基类
template <typename Ret, typename... Args>
struct FuncBase {
virtual Ret call(Args... args) = 0;
virtual ~FuncBase() = default;
};
// 步骤 2:模板子类包装具体类型
template <typename Fn, typename Ret, typename... Args>
struct FuncImpl : FuncBase<Ret, Args...> {
Fn fn_;
FuncImpl(Fn fn) : fn_(std::move(fn)) {}
Ret call(Args... args) override { return fn_(std::forward<Args>(args)...); }
};
// 步骤 3:外壳类持有基类指针
template <typename Signature>
class Function; // 略
template <typename Ret, typename... Args>
class Function<Ret(Args...)> {
std::unique_ptr<FuncBase<Ret, Args...>> impl_;
public:
template <typename Fn>
Function(Fn fn)
: impl_(new FuncImpl<Fn, Ret, Args...>(std::move(fn))) {}
Ret operator()(Args... args) {
return impl_->call(std::forward<Args>(args)...);
}
};
三层结构:
FuncBase:统一接口(虚函数)FuncImpl<Fn>:模板适配器,把具体的Fn适配到FuncBaseFunction:外壳类,持有FuncBase指针,提供干净的调用语法
类型擦除 = 一个虚基类 + 一个模板适配器 + 一个外壳。 虚函数只在调用的那一刻发生一次,后面没有连锁的虚函数调用。
类型擦除的应用场景
- 信号槽 / 观察者:把各种 lambda、函数指针、成员函数指针擦成统一的 slot 类型
- 命令行参数解析:不同类型参数的值存储
- 插件系统:运行时加载不同类型的插件
四块基础的关系
graph TD
A[模板 Template] --> B[CRTP]
A --> C[Policy-Based Design]
C --> D[类型擦除 Type Erasure]
B --> C
B -.-> E["编译期多态<br/>零虚函数开销"]
C -.-> F["编译期策略组合<br/>鸭子类型接口"]
D -.-> G["运行时灵活性<br/>统一接口包装"]
一层套一层:模板是基础 → CRTP 用模板实现编译期多态 → Policy-Based 用模板实现策略组合 → 当需要运行时灵活性时,用类型擦除在 Policy-Based 外面包一层。
理解这四块,后面的设计模式组件化、Concepts 约束、编译期测试,全部建立在这个基础上。
下一篇
系列文章:C++ 组件化基础 | 作者:Logic