1. 模板基础:函数模板与类模板
1.1 为什么需要模板
假设你要写一个函数来比较两个值的大小:
cpp
int Max(int a, int b) { return a > b ? a : b; }
double Max(double a, double b) { return a > b ? a : b; }
std::string Max(std::string a, std::string b) { return a > b ? a : b; }
逻辑完全相同,只是类型不同。这就是代码重复 ,违反了 DRY(Don't Repeat Yourself)原则。模板的核心目的就是:让编译器帮你根据类型自动生成代码。
1.2 函数模板
cpp
template<typename T>
T Max(T a, T b) {
return a > b ? a : b;
}
// 使用
int x = Max(3, 5); // 编译器推导 T = int
double y = Max(3.14, 2.71); // 编译器推导 T = double
auto z = Max<std::string>("hello", "world"); // 显式指定 T
关键概念:
template<typename T>声明了一个类型参数T。typename和class在这里完全等价,但推荐用typename,因为T不一定是 class。- 模板实例化 :编译器看到
Max(3, 5)时,会生成一个int Max(int, int)的具体函数。这个过程叫实例化(instantiation)。 - 模板参数推导 :编译器可以从函数参数推导出
T的类型,无需显式指定。 - **显式指定模板参数:当然也可以通过<>**来显式指定模板参数进而实例化生成指定参数的函数。
1.2.1 多个模板参数
cpp
template<typename T, typename U>
auto Add(T a, U b) -> decltype(a + b) {
return a + b;
}
// 1、decltype语法:
// decltype 是 C++11 引入的关键字,作用很简单:给它一个表达式,它告诉你这个表达式的类型是什么。需要注意的是:decltype 是编译期的操作,它不会真的执行表达式,只是分析表达式的类型。
// decltype解决的问题:例如在上面的模板例子中,你经常不知道运算结果的类型是什么(int + double 结果是 double,int + int 结果是 int,string + string 结果是 string。你没法写死返回类型。),decltype 就是用来解决这个问题的------让编译器自己推导。
// 2、尾置返回类型语法(trailing return type)
// 如果上面的例子直接用decltype(a + b)来代替auto,即为decltype(a + b) Add(T a, U b); 会直接编译报错;原因是编译器从左往右读,读到decltype(a + b)时,参数a和b还没有被声明,编译器不认识他们。
// 为了解决这个"参数还没声明就要用"的问题,C++11 引入了尾置返回类型语法------把返回类型挪到参数列表后面:auto Add(T a, U b) -> decltype(a + b);
// auto → 占位符,告诉编译器"返回类型在后面"
// Add(T a, U b) → 参数列表(先声明参数)
// -> → 箭头,引出真正的返回类型,尾置返回类型的语法标记
// decltype(a+b) → 真正的返回类型(这里 a、b 已经声明了,可以用)
// C++14 起可以简化为
template<typename T, typename U>
auto Add(T a, U b) {
return a + b; // 返回类型自动推导
}
auto result = Add(3, 4.5); // T=int, U=double, 返回 double
1.2.2 非类型模板参数
模板参数不一定是类型,也可以是值(必须是编译期常量):
cpp
template<typename T, int N>
struct Array {
T data[N];
int size() const { return N; }
};
Array<int, 10> arr; // 编译期确定大小为 10 的 int 数组
应用:固定大小的环形缓冲区、编译期确定的消息 ID 范围。
1.3 类模板
cpp
template<typename T>
class Stack {
private:
std::vector<T> data_;
public:
void push(const T& value) {
data_.push_back(value);
}
T pop() {
T top = data_.back();
data_.pop_back();
return top;
}
bool empty() const {
return data_.empty();
}
};
// 使用
Stack<int> intStack;
intStack.push(42);
int val = intStack.pop();
Stack<std::string> strStack;
strStack.push("hello");
1.4 模板的默认参数
cpp
// 类模板可以有默认参数
template<typename T, typename Container = std::vector<T>>
class Stack {
Container data_;
public:
void push(const T& value) { data_.push_back(value); }
// ...
};
Stack<int> s1; // 使用默认的 vector<int>
Stack<int, std::deque<int>> s2; // 使用 deque<int>
1.5 成员函数模板
类的成员函数也可以是模板,独立于类模板参数:
cpp
class Converter {
public:
template<typename Target, typename Source>
Target convert(Source value) {
return static_cast<Target>(value);
}
};
Converter c;
int x = c.convert<int>(3.14); // Target=int, Source=double(推导)
double y = c.convert<double>(42); // Target=double, Source=int(推导)
关键理解 :模板不是函数或类本身,而是编译器生成函数或类的蓝图。只有在使用时(实例化时),编译器才会生成真正的代码。
2. 模板特化与偏特化
2.1 为什么需要特化
通用模板不一定适合所有类型。比如你写了一个通用的比较函数,但对于 C 风格字符串(const char*),你不想比较指针地址,而是比较字符串内容:
cpp
template<typename T>
bool IsEqual(T a, T b) {
return a == b; // 对于 const char*,这比较的是指针地址!
}
这时就需要特化------为特定类型提供不同的实现。
2.2 全特化(Full Specialization)
为某个完全确定的类型提供特殊实现:
cpp
// 主模板(Primary Template)
template<typename T>
bool IsEqual(T a, T b) {
return a == b;
}
// 全特化版本:专门处理 const char*
template<>
bool IsEqual<const char*>(const char* a, const char* b) {
return std::strcmp(a, b) == 0;
}
// 使用
IsEqual(3, 3); // 走主模板
IsEqual("hello", "hello"); // 走 const char* 特化版本
语法要点:
- 一定要有主模板,再有特化版
template<>表示这是一个全特化(模板参数列表为空,因为所有参数都被确定了)。IsEqual<const char*>明确指定了特化的类型。
2.2.1 类模板的全特化
cpp
// 主模板
template<typename T>
class Serializer {
public:
static std::string serialize(const T& value) {
// 通用实现:假设 T 有 toString 方法
return value.toString();
}
};
// 全特化:int 类型
template<>
class Serializer<int> {
public:
static std::string serialize(const int& value) {
return std::to_string(value);
}
};
// 全特化:std::string 类型
template<>
class Serializer<std::string> {
public:
static std::string serialize(const std::string& value) {
return "\"" + value + "\"";
}
};
2.3 偏特化(Partial Specialization)
只确定部分 模板参数,或者对模板参数施加某种约束 。注意:函数模板不支持偏特化,只有类模板支持。
cpp
// 主模板:两个参数
template<typename T, typename U>
class Pair {
public:
T first;
U second;
void print() {
std::cout << "Generic Pair: " << first << ", " << second << std::endl;
}
};
// 偏特化:当两个参数类型相同时
template<typename T>
class Pair<T, T> {
public:
T first;
T second;
void print() {
std::cout << "Same-type Pair: " << first << ", " << second << std::endl;
}
};
// 偏特化:当第二个参数是指针时
template<typename T, typename U>
class Pair<T, U*> {
public:
T first;
U* second;
void print() {
std::cout << "Pointer Pair: " << first << ", " << *second << std::endl;
}
};
// 使用
Pair<int, double> p1{1, 2.0}; // 走主模板
Pair<int, int> p2{1, 2}; // 走 <T, T> 偏特化
int val = 42;
Pair<int, int*> p3{1, &val}; // 走 <T, U*> 偏特化,U=int,不是int*
2.4 偏特化的常见形式
cpp
// 假设主模板如下:
template<typename T, typename U = void>
class Handler { /* 通用实现 */ };
// 形式 1:指针特化
template<typename T>
class Handler<T*> { /* 专门处理指针类型 */ };
// 形式 2:引用特化
template<typename T>
class Handler<T&> { /* 专门处理引用类型 */ };
// 形式 3:容器特化
template<typename T>
class Handler<std::vector<T>> { /* 专门处理 vector */ };
// 形式 4:多参数中固定部分
template<typename T>
class Handler<T, int> { /* 第二个参数固定为 int */ };
2.5 特化的匹配优先级
编译器选择模板实例时遵循「最特殊匹配」原则:
全特化 > 偏特化 > 主模板
如果有多个偏特化都能匹配,但没有一个「比其他所有都更特殊」,编译器会报二义性错误。
2.6 应用
cpp
// 序列化框架:根据类型选择不同的序列化策略
template<typename T>
struct NetSerializer {
// 主模板:要求类型自带 Serialize 方法
static void Write(Buffer& buf, const T& value) {
value.Serialize(buf);
}
static void Read(Buffer& buf, T& value) {
value.Deserialize(buf);
}
};
// 特化基本类型
template<>
struct NetSerializer<int32_t> {
static void Write(Buffer& buf, const int32_t& value) {
buf.WriteInt32(value);
}
static void Read(Buffer& buf, int32_t& value) {
value = buf.ReadInt32();
}
};
template<>
struct NetSerializer<std::string> {
static void Write(Buffer& buf, const std::string& value) {
buf.WriteUint16(static_cast<uint16_t>(value.size()));
buf.WriteBytes(value.data(), value.size());
}
static void Read(Buffer& buf, std::string& value) {
uint16_t len = buf.ReadUint16();
value.resize(len);
buf.ReadBytes(value.data(), len);
}
};
// 偏特化:vector<T> 自动序列化
template<typename T>
struct NetSerializer<std::vector<T>> {
static void Write(Buffer& buf, const std::vector<T>& vec) {
buf.WriteUint16(static_cast<uint16_t>(vec.size()));
for (const auto& item : vec) {
NetSerializer<T>::Write(buf, item); // 递归调用元素的序列化
}
}
static void Read(Buffer& buf, std::vector<T>& vec) {
uint16_t count = buf.ReadUint16();
vec.resize(count);
for (auto& item : vec) {
NetSerializer<T>::Read(buf, item);
}
}
};
这样你可以直接 NetSerializer<std::vector<int32_t>>::Write(buf, myVec) 来序列化一个整数向量,编译器会自动组合 vector 偏特化和 int32_t 全特化。
3. 模板的编译模型与工程实践
3.1 模板代码必须在头文件中
这是初学者最容易踩的坑。普通函数可以在 .h 中声明、.cpp 中定义。但模板不行:
cpp
// math_utils.h
template<typename T>
T Max(T a, T b); // 只有声明
// math_utils.cpp
template<typename T>
T Max(T a, T b) { return a > b ? a : b; } // 定义
// main.cpp
#include "math_utils.h"
int x = Max(3, 5); // ❌ 链接错误!undefined reference to Max<int>
为什么会链接失败?
编译器编译 math_utils.cpp 时,没有看到任何对 Max<int> 的调用,所以不会实例化 Max<int>。编译 main.cpp 时,编译器看到了调用,知道需要 Max<int>,但看不到模板定义(只有声明),所以只生成了一个外部符号引用。链接时找不到 Max<int> 的定义,报错。
解决方案 1(最常用):把定义也放在头文件中
cpp
// math_utils.h
template<typename T>
T Max(T a, T b) {
return a > b ? a : b;
}
解决方案 2:显式实例化
如果你知道模板只会被少数几个类型使用,可以在 .cpp 中显式实例化:
cpp
// math_utils.cpp
template<typename T>
T Max(T a, T b) { return a > b ? a : b; }
// 显式实例化 int 和 double 版本
template int Max<int>(int, int);
template double Max<double>(double, double);
这样链接器就能找到 Max<int> 和 Max<double> 的定义。但如果有人用了 Max<float>,依然会链接失败。
3.2 extern template(C++11)
当一个模板在多个翻译单元(.cpp 文件)中被实例化为相同的类型时,每个 .cpp 都会生成一份实例。链接器会去重,但编译时间被浪费了。extern template 可以告诉编译器:「这个实例化别在这里做,别的翻译单元已经做了。」
cpp
// widget.h
template<typename T>
class Widget {
public:
void doSomething() { /* ... */ }
};
// 告诉其他翻译单元:不要在你那里实例化 Widget<int>
extern template class Widget<int>;
// widget.cpp
#include "widget.h"
// 在这里实例化一次
template class Widget<int>;
应用 :大型项目中的 Serializer<PlayerData>、ObjectPool<Bullet> 这类频繁使用的模板实例化,用 extern template 能显著减少编译时间。
3.3 模板导致的编译时间问题
模板是编译期展开的,大量模板会导致:
- 编译时间膨胀 :每个使用模板的
.cpp都要做实例化。 - 二进制膨胀:不同翻译单元中相同的模板实例会生成重复代码(链接器可去重但不总是完美)。
- 错误信息爆炸:模板错误信息非常长且难读。
实践建议:
- 不要在头文件中 include 过多的模板头文件。
- 使用前向声明减少头文件依赖。
- 复杂的模板逻辑,考虑用 Pimpl 或类型擦除(后面会讲)隐藏模板细节。
- 使用
extern template减少重复实例化。
3.4 阅读模板编译错误的技巧
模板错误信息通常很长。核心技巧:
- 从第一个错误开始看。后面的错误往往是连锁反应。
- 找
required from here。这告诉你是哪行代码触发了模板实例化。 - 找
note: candidate template ignored。这告诉你编译器尝试了哪些模板但都不匹配。 - 用
static_assert给出清晰的错误信息:
cpp
template<typename T>
void Process(T value) {
static_assert(std::is_integral_v<T>,
"Process() 只接受整数类型,你传入的类型不是整数");
// ...
}
4. 类型萃取 type_traits
4.1 是什么
<type_traits> 是 C++11 引入的标准库头文件,提供了一组编译期的类型查询和类型变换工具。你可以在编译期问这些问题:
- 这个类型是整数吗?是浮点数吗?是指针吗?
- 这个类型能不能拷贝?能不能移动?
- 这个类型是不是某个类的子类?
- 把这个类型的 const 去掉后是什么类型?
4.2 类型查询(Type Predicates)
类型查询工具都继承自 std::true_type 或 std::false_type,有一个静态成员 value:
cpp
#include <type_traits>
// 基本类型判断
std::is_integral<int>::value; // true - int 是整数类型
std::is_integral<double>::value; // false
std::is_floating_point<double>::value;// true
std::is_pointer<int*>::value; // true
std::is_reference<int&>::value; // true
// C++17 简写:加 _v 后缀,省略 ::value
std::is_integral_v<int>; // true(推荐用这种写法)
// 是否可以 trivially copy(直接 memcpy 安全吗?)
std::is_trivially_copyable_v<int>; // true
std::is_trivially_copyable_v<std::string>; // false(string 有动态内存)
// 是否是某个类的子类
struct Base {};
struct Derived : Base {};
std::is_base_of_v<Base, Derived>; // true
std::is_base_of_v<Derived, Base>; // false
// 两个类型是否相同
std::is_same_v<int, int32_t>; // true(在大多数平台上)
std::is_same_v<int, long>; // 取决于平台
// 是否有默认构造函数
std::is_default_constructible_v<int>; // true
std::is_default_constructible_v<std::mutex>; // true
std::is_copy_constructible_v<std::unique_ptr<int>>; // false
4.3 类型变换(Type Transformations)
cpp
// 去掉 const
using T1 = std::remove_const_t<const int>; // int
// 去掉引用
using T2 = std::remove_reference_t<int&>; // int
using T3 = std::remove_reference_t<int&&>; // int
// 去掉 const 和引用(组合使用)
using T4 = std::remove_const_t<std::remove_reference_t<const int&>>; // int
// decay:模拟函数参数传值时的类型退化
// 去掉引用、去掉顶层 const/volatile、数组退化为指针、函数退化为函数指针
// const:告诉编译器变量只读,禁止修改(编译期约束)
// volatile:告诉编译器变量会被意外修改,禁止优化(运行期约束)
using T5 = std::decay_t<const int&>; // int
using T6 = std::decay_t<int[10]>; // int*
using T7 = std::decay_t<int(double)>; // int(*)(double)
// 条件类型选择
using T8 = std::conditional_t<true, int, double>; // int
using T9 = std::conditional_t<false, int, double>; // double
// 添加指针/引用
using T10 = std::add_pointer_t<int>; // int*
using T11 = std::add_lvalue_reference_t<int>; // int&
4.4 实际应用
应用 1:安全的 memcpy 序列化
cpp
template<typename T>
void WriteToBuffer(Buffer& buf, const T& value) {
if constexpr (std::is_trivially_copyable_v<T>) {
// POD 类型:直接 memcpy,最快
buf.Append(reinterpret_cast<const char*>(&value), sizeof(T));
} else {
// 非 POD 类型:调用自定义序列化方法
value.Serialize(buf);
}
}
// constexpr是c++11引入的核心关键字,核心作用是强制【编译期求值】,让编译器在编译阶段就计算出表达式的数值,而不是等到程序运行时。它是现代 C++ 性能优化、类型安全、模板元编程的基石,代码里的 if constexpr 就是它最经典的实战用法之一。
// constexpr 与 const:
// const作用是只读(不可修改),在运行期生效,变量值可以是运行时确定,只是不能改;
// constexpr作用强制编译器常量且天然就是const,只读不可修改,在编译期生效,强制在编译阶段求值,值是编译期常量。
// 例子:
// const:运行期只读,值可以是运行时输入
int x;
std::cin >> x;
const int a = x; // ✅ 合法,a 只读,但值是运行时确定
// constexpr:必须编译期能算出值
constexpr int b = x; // ❌ 编译报错!x 是运行时变量,编译期算不出
constexpr int c = 10 + 20; // ✅ 合法,编译期直接算出 30
// 作用:
// 1、修饰变量:强制编译期常量:
// 必须是编译期可计算的表达式
constexpr int BUF_SIZE = 1024;
constexpr double PI = 3.1415926;
constexpr int MAX(int a, int b) { return a > b ? a : b; }
constexpr int LIMIT = MAX(100, 200); // ✅ 编译期算出 200
// 被 constexpr 修饰的变量,天然就是 const,只读不可修改。
// 必须用编译期可计算的表达式初始化,否则直接编译报错
// 2、修饰函数:强制编译期可调用
// constexpr 函数的核心要求:输入是编译期常量时,输出也必须是编译期常量。
// constexpr 函数:编译期/运行期都能调用
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int f5 = factorial(5); // ✅ 编译期算出 120,无运行开销
int x;
std::cin >> x;
int fx = factorial(x); // ✅ 运行期正常调用,兼容运行时场景
// C++11 要求 constexpr 函数只能有一条 return 语句;C++14 放开限制,支持循环、分支等完整逻辑。
// 函数内部不能有无法在编译期执行的操作(如 std::cout、动态内存分配)
// 3、 修饰构造函数:编译期构造对象
// 让自定义类型可以在编译期创建实例,是现代 C++ 高性能编程的常用技巧:
struct Point {
int x, y;
constexpr Point(int a, int b) : x(a), y(b) {} // constexpr 构造函数
};
constexpr Point p(1, 2); // ✅ 编译期构造对象,p 是编译期常量
// 4、if constexpr:编译期分支(你代码里的核心用法)
// 这是 C++17 引入的编译期条件判断,是模板编程的「神技」,上面那段代码的核心:
if constexpr (编译期常量表达式) {
// 分支1:条件为真时,保留这段代码,编译
} else {
// 分支2:条件为假时,直接丢弃这段代码,不编译
}
// 普通 if 的问题:如果用普通 if,两个分支都会被编译。
// if constexpr 的优势:编译期根据 T 的类型,直接丢弃不满足条件的分支。
// 总结:constexpr 就是让编译器帮你在编译时干活,把能提前算的都算好,让程序运行更快、更安全;if constexpr 则是让模板代码能根据类型自动裁剪,实现「一份代码,最优适配所有类型」。
应用 2:编译期断言保障安全
cpp
template<typename T>
class ObjectPool {
static_assert(std::is_default_constructible_v<T>,
"ObjectPool 要求类型 T 必须有默认构造函数");
static_assert(!std::is_abstract_v<T>,
"ObjectPool 不能存储抽象类(不能实例化)");
// ...
};
// static_assert(常量表达式, "错误提示字符串");
// static_assert(常量表达式);
// 第一个参数必须是编译期可计算的常量表达式,不能是运行时变量、函数返回值。第二个参数必须是字符串字面量(c++11/14必须写,17以上可以不写)
// static_assert 是编译期断言,在编译阶段检查条件是否成立,不运行程序;如果条件不满足,直接编译报错并输出自定义提示信息。
// 它和运行时的assert完全不同,assert是程序运行时检查,不符合就崩溃报错;static_assert是程序编译时检查,直接编译失败。
// 常用于:类型检查(例如模板编程)、数值范围检查(版本)、数组/内存大小检查
// 注意:static_assert是编译期指令,不是可执行语句,它可以写在全局作用域,命名空间,类、函数里,完全不受函数限制。
// 编译期 vs 运行期,例如:
// ① static_assert(...)
// 编译阶段执行
// 编译器看到它就检查条件
// 不生成可执行代码
// 可以写在任何能放声明 / 定义的地方(全局、函数外、类里、函数里)
// ② EXPECT_EQ(...) / 普通代码
// 运行阶段执行
// 必须放在函数内部(比如测试函数、main 里)
// 不能写在全局 / 函数外面
应用 3:通用的哈希函数组合
cpp
template<typename T>
size_t HashValue(const T& value) {
if constexpr (std::is_integral_v<T>) {
return std::hash<T>{}(value);
} else if constexpr (std::is_floating_point_v<T>) {
return std::hash<T>{}(value);
} else if constexpr (std::is_enum_v<T>) {
return std::hash<std::underlying_type_t<T>>{}(
static_cast<std::underlying_type_t<T>>(value));
} else {
return value.GetHashCode(); // 自定义类型需要提供 GetHashCode
}
}
4.5 自定义 type_trait
你可以自己写 type_trait。其原理就是模板特化:
cpp
// 判断一个类型是否是我们游戏框架中的消息类型
// 主模板:默认不是
template<typename T>
struct is_game_message : std::false_type {};
// 特化:LoginReq 是
struct LoginReq { /* ... */ };
template<>
struct is_game_message<LoginReq> : std::true_type {};
// 特化:MoveReq 是
struct MoveReq { /* ... */ };
template<>
struct is_game_message<MoveReq> : std::true_type {};
// 使用
static_assert(is_game_message<LoginReq>::value, "LoginReq 是消息类型");
static_assert(!is_game_message<int>::value, "int 不是消息类型");
// 更优雅的方式:检测是否有某个特定的成员
// 利用 SFINAE(下一节会详细讲)
template<typename T, typename = void>
struct has_msg_id : std::false_type {};
template<typename T>
struct has_msg_id<T, std::void_t<decltype(T::MSG_ID)>> : std::true_type {};
4.6 常用 type_traits 速查表
| trait | 用途 | 示例 |
|---|---|---|
is_integral |
是否是整数类型 | int, char, bool, uint64_t |
is_floating_point |
是否是浮点数 | float, double |
is_arithmetic |
是否是算术类型(整数或浮点) | int, double |
is_enum |
是否是枚举 | enum Color {...} |
is_pointer |
是否是指针 | int*, void* |
is_reference |
是否是引用 | int&, int&& |
is_same |
两个类型是否相同 | is_same_v<int, int> = true |
is_base_of |
A 是否是 B 的基类 | is_base_of_v<Base, Derived> |
is_trivially_copyable |
能否安全 memcpy | int yes, string no |
is_default_constructible |
有无默认构造 | |
is_copy_constructible |
能否拷贝构造 | unique_ptr no |
is_abstract |
是否是抽象类 | 有纯虚函数的类 |
remove_const |
去掉 const | const int → int |
remove_reference |
去掉引用 | int& → int |
decay |
完整类型退化 | const int& → int |
conditional |
编译期三元选择 | conditional_t<cond, A, B> |
underlying_type |
枚举的底层类型 | enum E : uint8_t → uint8_t |
void_t (C++17) |
检测表达式合法性 | SFINAE 辅助 |
5. SFINAE 与 std::enable_if
5.1 SFINAE 是什么
SFINAE 全称 Substitution Failure Is Not An Error (替换失败不是错误)。这是 C++ 编译器在进行模板参数推导/替换时的一条规则:
当编译器用具体类型替换模板参数时,如果产生了无效的类型或表达式,编译器不会报错 ,而是默默地忽略这个模板,继续尝试其他候选模板。
举个例子:
cpp
template<typename T>
typename T::value_type GetFirst(const T& container) {
return container[0];
}
template<typename T>
T GetFirst(T value) {
return value;
}
int x = GetFirst(42); // 会发生什么?
编译器尝试第一个模板:用 int 替换 T,需要 int::value_type------但 int 没有 value_type!按照 SFINAE 规则,编译器不报错 ,而是忽略这个模板,转而尝试第二个模板。第二个模板匹配成功,调用 GetFirst<int>(42)。
5.2 std::enable_if
std::enable_if 是 让编译器[有条件地启用/禁用]某个函数或模板,它是C++里的编译期开关:条件成立->这个函数有效,条件不成立->这个函数直接消失;它的作用:让不同类型走不同函数,不冲突,不报错。
std::enable_if<条件表达式,定义给type的类型(如果未指定默认就是void)>::type 或者C++17简写 std::enable_if_t<条件表达式>;
cpp
// 定义在头文件<type_traits>
// std::enable_if原型
template<bool B, class T = void>
struct enable_if;
// C++14简化写法,引入了别名模板std::enable_if_t,省去了typename 和 ::type 的繁琐写法
template<bool B, class T = void>
using enable_if_t = typename enable_if<B, T>::type;
规则:
- 如果条件为true -> type是一个合法类型;
- 如果条件为false -> type不存在,这个"不存在"利用的就是SFINAE机制(替换失败不是错误)。
例子:让「整数类型」走函数 1,「浮点类型」走函数 2
cpp
#include <type_traits>
// 只有 T 是整数时,这个函数才生效
template <typename T>
typename std::enable_if<std::is_integral<T>::value, void>::type
func(T val) {
std::cout << "整数: " << val << std::endl;
}
// 只有 T 是浮点时,这个函数才生效
template <typename T>
typename std::enable_if<std::is_floating_point<T>::value, void>::type
func(T val) {
std::cout << "浮点: " << val << std::endl;
}
// 使用
func(10); // 整数版本
func(3.14); // 浮点版本
/*
为什么不会冲突,因为编译器会:
1、尝试匹配第一个函数
2、条件不满足 → type 不存在 → 函数被丢弃
3、再尝试第二个
4、总有一个匹配
5、这就是 enable_if 的魔力:自动选择正确的重载
*/
std::enable_if 是利用 SFINAE 原理 做条件编译的标准工具:
cpp
// std::enable_if 的简化实现
// 主模板
template<bool Condition, typename T = void>
struct enable_if {}; // 条件为 false 时,没有 type 成员
// 偏特化,为什么采用偏特化的方式? 因为匹配规则是:全特化 > 偏特化 > 主模版,当第一个模板参数为true时,更加匹配偏特化版本,就走偏特化的逻辑,这样就达成了实现std::enable_if的逻辑
template<typename T>
struct enable_if<true, T> {
using type = T; // 条件为 true 时,有 type 成员
};
核心思想:当条件为 false 时,enable_if<false>::type 不存在,触发 SFINAE 让这个模板被排除。
用法 1:在返回类型上使用
cpp
// 只有当 T 是整数类型时,这个函数才存在
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
DoubleValue(T value) {
return value * 2;
}
// 只有当 T 是浮点类型时,这个函数才存在
template<typename T>
std::enable_if_t<std::is_floating_point_v<T>, T>
DoubleValue(T value) {
return value * 2.0;
}
DoubleValue(42); // 调用整数版本
DoubleValue(3.14); // 调用浮点版本
// DoubleValue("hello"); // 编译错误:没有匹配的函数(两个都被 SFINAE 排除了)
用法 2:在模板参数上使用(更推荐)
cpp
// 更干净的写法:把 enable_if 放在模板参数中
template<typename T,
std::enable_if_t<std::is_integral_v<T>, int> = 0>
void Process(T value) {
std::cout << "整数处理: " << value << std::endl;
}
template<typename T,
std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void Process(T value) {
std::cout << "浮点数处理: " << value << std::endl;
}
这里的 int = 0 是一个匿名的非类型模板参数,默认值为 0 。当条件为 true 时,enable_if_t 产生 int 类型,这个参数合法。当条件为 false 时,enable_if_t 不存在,触发 SFINAE。
用法3:作为函数参数(较少用)
通过函数参数的默认值来触发SFINAE。
cpp
// 示例:仅接受浮点数类型的函数
template <typename T>
void print_float(T t, typename std::enable_if<std::is_floating_point<T>::value>::type* = nullptr) {
std::cout << "浮点数: " << t << std::endl;
}
int main() {
print_float(3.14f); // 正常调用
// print_float(42); // 编译错误:无匹配的重载
return 0;
}
// 什么是多余参数?
// 多余参数 = 函数根本不用它、调用时也不用传、只是用来 "占位置" 实现特殊目的的参数。它在代码里存在,但没用,纯粹是为了满足语法 / 实现技巧。
// 例如:
void print_float(double t, void* = nullptr){}
// 第二个参数 void* = nullptr 就是多余参数。
// 多余参数函数体内用不着;调用时完全不用传参;对函数行为没有影响
// 对于普通函数,多余参数毫无意义,纯属多余;
// 对于模板+enable_if中,这个多余参数是用来触发SFINAE的工具,是实现类型约束的关键。
5.3 std::void_t 检测表达式合法性
std::void_t(C++17)是一个很巧妙的工具:无论给它什么模板参数,它的结果都是 void。如果参数中的某个表达式无效,整体替换失败,触发 SFINAE。
cpp
// void_t 的定义极其简单
template<typename...> // 1. 可变模板参数:接收任意数量、任意类型的模板参数
using void_t = void; // 2. 别名模板:把所有输入都映射为 void
用它来检测类型是否拥有某个成员:
cpp
#include <type_traits>
// 主模板:默认 T 没有 begin()
template<typename T, typename = void>
struct has_begin : std::false_type {};
// 偏特化版本:仅当 T 有 begin() 时,才会匹配这个版本
template<typename T>
struct has_begin<T, std::void_t<decltype(std::declval<T>().begin())>>
: std::true_type {};
// 这里模板的匹配规则解析:
// 前提:全特化 > 偏特化 > 主模版
// 情况 1:T = std::vector<int>(有 begin(),合法)
// 编译器尝试匹配偏特化版本:
// 计算 decltype(std::declval<T>().begin()):std::vector<int>::iterator,完全合法;
// 把这个类型传给 std::void_t<...>,void_t 成功实例化为 void;
// 偏特化版本的第二个模板参数是 void,和主模板的默认参数 void 匹配,偏特化优先级更高,所以最终 has_begin<std::vector<int>> 继承 std::true_type,value 为 true。
// 情况 2:T = int(没有 begin(),非法)
// 编译器尝试匹配偏特化版本:
// 计算 decltype(std::declval<int>().begin()):int 没有 begin() 成员,表达式非法;
// 传给 void_t 的参数非法,导致 void_t<...> 模板实例化失败;
// 触发 SFINAE:偏特化版本被排除,编译器只能匹配主模板;
// 最终 has_begin<int> 继承 std::false_type,value 为 false。
// 主模板:默认不能相加
template<typename T, typename U, typename = void>
struct can_add : std::false_type {};
// 偏特化:仅当 T + U 合法时匹配
template<typename T, typename U>
struct can_add<T, U, std::void_t<decltype(std::declval<T>() + std::declval<U>())>>
: std::true_type {};
// 测试
static_assert(can_add<int, double>::value); // ✅ 合法,int+double 可以
static_assert(!can_add<std::string, int>::value); // ✅ 非法,string+int 不行
原理拆解:
std::declval<T>()创建一个T类型的"假对象"(不需要构造函数)。decltype(std::declval<T>().begin())获取调用begin()的返回类型。- 如果
T没有begin()方法,decltype(...)会失败。 std::void_t<...>把失败传导为替换失败(SFINAE),退回到主模板(false_type)。- 如果
T有begin()方法,void_t<...>产生void,偏特化匹配成功(true_type)。
void_t 相比 enable_if 的巧妙之处:
enable_if只能做布尔条件判断 (比如std::is_integral<T>::value);void_t可以做任意表达式的合法性判断 (比如 "有没有某个成员函数 / 成员变量 / 是否支持某种运算符",配合decltype和declval转换成类型提供给void_t),这是enable_if做不到的。
void_t 总结:
std::void_t 是 C++17 引入的SFINAE 辅助工具 ,它的本质是:用一个 "永远返回 void" 的模板,把「表达式是否合法」的问题,转化为「模板是否能成功实例化」的问题,从而触发 SFINAE,实现编译期的表达式合法性检测。
5.4 应用
cpp
// 消息处理器:只有注册了 MSG_ID 的类型才能作为消息
template<typename MsgType,
std::enable_if_t<has_msg_id<MsgType>::value, int> = 0>
void RegisterHandler(std::function<void(Connection*, const MsgType&)> handler) {
handlers_[MsgType::MSG_ID] = [handler](Connection* conn, const void* data) {
handler(conn, *static_cast<const MsgType*>(data));
};
}
// 如果传入一个没有 MSG_ID 的类型,编译期就报错
// RegisterHandler<int>(...); // 编译失败:int 没有 MSG_ID
5.5 SFINAE vs if constexpr
在 C++17 以前,SFINAE 是实现编译期条件分支的主要方式。C++17 引入 if constexpr 后,很多场景可以用更简单的方式实现。但 SFINAE 在函数重载选择这个场景下依然不可替代:
cpp
// if constexpr:只能在一个函数体内分支
template<typename T>
void Process(T value) {
if constexpr (std::is_integral_v<T>) {
// 整数逻辑
} else {
// 其他逻辑
}
}
// SFINAE / enable_if:可以选择不同的函数重载
// 这在需要不同参数列表、不同返回类型的场景下更合适
template<typename T, std::enable_if_t<std::is_integral_v<T>, int> = 0>
int Process(T value) { return value * 2; }
template<typename T, std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
double Process(T value) { return value * 2.0; }
6. Tag Dispatch(标签分发)
6.1 是什么
Tag Dispatch 是一种利用函数重载 来实现编译期分支选择 的技术。核心思想是:用空结构体做"标签"(tag),通过函数重载来选择不同的实现。
cpp
// 定义标签
struct TrivialCopyTag {};
struct CustomCopyTag {};
// 实际实现:通过标签做重载
template<typename T>
void CopyImpl(T* dest, const T* src, size_t count, TrivialCopyTag) {
// 可以安全 memcpy 的类型
std::memcpy(dest, src, count * sizeof(T));
}
template<typename T>
void CopyImpl(T* dest, const T* src, size_t count, CustomCopyTag) {
// 需要逐个拷贝构造的类型
for (size_t i = 0; i < count; ++i) {
new (dest + i) T(src[i]);
}
}
// 对外接口:根据类型特征自动选择标签
template<typename T>
void Copy(T* dest, const T* src, size_t count) {
using Tag = std::conditional_t<
std::is_trivially_copyable_v<T>,
TrivialCopyTag,
CustomCopyTag
>;
CopyImpl(dest, src, count, Tag{});
}
6.2 与 SFINAE 的对比
Tag Dispatch 的优势在于可读性更好。对比:
cpp
// SFINAE 方式(不太直观)
template<typename T, std::enable_if_t<std::is_trivially_copyable_v<T>, int> = 0>
void Copy(T* dest, const T* src, size_t count) { /* memcpy */ }
template<typename T, std::enable_if_t<!std::is_trivially_copyable_v<T>, int> = 0>
void Copy(T* dest, const T* src, size_t count) { /* 逐个构造 */ }
// Tag Dispatch 方式(清晰直观)
template<typename T>
void CopyImpl(T* dest, const T* src, size_t count, TrivialCopyTag) { /* memcpy */ }
template<typename T>
void CopyImpl(T* dest, const T* src, size_t count, CustomCopyTag) { /* 逐个构造 */ }
6.3 标准库中的 Tag Dispatch
STL 中最经典的 Tag Dispatch 应用是迭代器分类:
cpp
// 标准库中的迭代器标签
struct input_iterator_tag {};
struct forward_iterator_tag : input_iterator_tag {};
struct bidirectional_iterator_tag : forward_iterator_tag {};
struct random_access_iterator_tag : bidirectional_iterator_tag {};
// std::advance 的实现(简化版)
template<typename It>
void advance_impl(It& it, int n, std::random_access_iterator_tag) {
it += n; // O(1):随机访问迭代器可以直接跳
}
template<typename It>
void advance_impl(It& it, int n, std::input_iterator_tag) {
for (int i = 0; i < n; ++i) ++it; // O(n):只能逐步前进
}
template<typename It>
void advance(It& it, int n) {
// 用迭代器的 category 做标签分发
advance_impl(it, n, typename std::iterator_traits<It>::iterator_category{});
}
6.4 应用
cpp
// 不同类型的网络消息使用不同的发送策略
struct ReliableTag {}; // 可靠消息(TCP 语义)
struct UnreliableTag {}; // 不可靠消息(UDP 语义)
struct OrderedTag {}; // 有序消息
template<typename MsgType>
void SendImpl(Connection* conn, const MsgType& msg, ReliableTag) {
// TCP 发送或 KCP 可靠通道
conn->SendReliable(Serialize(msg));
}
template<typename MsgType>
void SendImpl(Connection* conn, const MsgType& msg, UnreliableTag) {
// 原始 UDP 发送
conn->SendUnreliable(Serialize(msg));
}
// 消息类型自带发送策略标签
struct MoveReq {
using SendPolicy = UnreliableTag; // 移动消息不需要可靠传输
float x, y, z;
};
struct LoginReq {
using SendPolicy = ReliableTag; // 登录必须可靠
std::string username;
};
template<typename MsgType>
void Send(Connection* conn, const MsgType& msg) {
SendImpl(conn, msg, typename MsgType::SendPolicy{});
}
7. 万能引用与完美转发
7.1 引用折叠规则
在讲万能引用之前,需要先理解引用折叠(Reference Collapsing)。C++ 不允许"引用的引用"直接出现在代码中,但在模板实例化过程中会产生这种情况。编译器通过引用折叠规则来处理:
cpp
// 引用折叠规则(记忆口诀:只要有左值引用参与,结果就是左值引用)
T& & → T& // 左值引用 + 左值引用 → 左值引用
T& && → T& // 左值引用 + 右值引用 → 左值引用
T&& & → T& // 右值引用 + 左值引用 → 左值引用
T&& && → T&& // 右值引用 + 右值引用 → 右值引用(唯一产生右值引用的情况)
只有右值引用 + 右值引用才能折叠出右值引用,其他任何组合都折叠为左值引用。这条规则是万能引用和完美转发的理论基石。
7.2 万能引用(Forwarding Reference)
当 T&& 出现在模板参数推导的上下文中 时,它不是右值引用,而是万能引用(也叫转发引用,Forwarding Reference):
cpp
// 这是万能引用(T 是待推导的模板参数)
template<typename T>
void wrapper(T&& arg) { /* ... */ }
// 这不是万能引用,是普通的右值引用(类型已确定)
void func(int&& arg) { /* ... */ }
// 这也不是万能引用(虽然在模板类中,但 T 已在类实例化时确定)
template<typename T>
class Foo {
void bar(T&& arg); // 这是 T 的右值引用,不是万能引用
};
万能引用的推导规则:
cpp
template<typename T>
void wrapper(T&& arg);
int x = 42;
const int cx = 42;
// 传入左值:T 推导为 左值引用类型
wrapper(x); // T = int&, T&& = int& && = int& (引用折叠)
wrapper(cx); // T = const int&, T&& = const int& && = const int&
// 传入右值:T 推导为 非引用类型
wrapper(42); // T = int, T&& = int&&
wrapper(std::move(x)); // T = int, T&& = int&&
总结:传左值 → T 推导为左值引用 → 参数类型折叠为左值引用;传右值 → T 推导为非引用类型 → 参数类型为右值引用。 万能引用因此能同时接受左值和右值,名副其实的"万能"。
7.3 std::forward 完美转发
问题来了:万能引用虽然能接受任何值类别的参数,但函数参数本身永远是左值(因为它有名字、有地址)。如果直接把参数传给下一个函数,右值属性就丢失了:
cpp
template<typename T>
void wrapper(T&& arg) {
// arg 是一个左值(它有名字),无论外部传入的是左值还是右值
innerFunc(arg); // 永远以左值传递------右值属性丢失了!
innerFunc(std::move(arg)); // 永远以右值传递------左值也被移动了,危险!
innerFunc(std::forward<T>(arg)); // ✅ 完美转发:保持原始值类别
}
std::forward<T>(arg) 的作用是:如果原始参数是右值,就把 arg 转为右值;如果原始参数是左值,就保持 arg 为左值 。它依赖的就是 T 的推导结果和引用折叠规则:
cpp
// std::forward 的简化实现原理
template<typename T>
T&& forward(std::remove_reference_t<T>& arg) {
return static_cast<T&&>(arg);
}
// 当外部传入左值时:T = int&
// forward<int&>(arg) → static_cast<int& &&>(arg) → static_cast<int&>(arg) → 返回左值引用
// 当外部传入右值时:T = int
// forward<int>(arg) → static_cast<int&&>(arg) → 返回右值引用
7.4 std::move 与 std::forward 的区别
这两个经常被混淆,但它们的语义完全不同:
cpp
// std::move:无条件转为右值("我不要了,你拿去吧")
// 不管传入的是什么,都强制转为右值引用
template<typename T>
decltype(auto) move(T&& arg) {
return static_cast<std::remove_reference_t<T>&&>(arg);
}
// std::forward:有条件转发("它来的时候是什么样,我原样传下去")
// 根据模板参数 T 的推导结果决定是左值还是右值
| 特性 | std::move |
std::forward<T> |
|---|---|---|
| 语义 | 无条件转为右值 | 有条件保持原始值类别 |
| 使用场景 | 明确表示要移动资源 | 模板中转发参数 |
| 需要模板参数 | 不需要 | 必须传 T |
| 典型用法 | return std::move(local); |
inner(std::forward<T>(arg)); |
7.5 典型应用模式
工厂函数(最经典的应用)
cpp
// 完美转发工厂:参数原样传递给构造函数
template<typename T, typename... Args>
std::unique_ptr<T> MakeUnique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
// 使用
auto p1 = MakeUnique<Player>("Alice", 100); // 字符串字面量(右值)被转发
std::string name = "Bob";
auto p2 = MakeUnique<Player>(name, 200); // name(左值)被转发,不会被移动
auto p3 = MakeUnique<Player>(std::move(name), 300); // 显式移动
包装函数 / 代理函数
cpp
// 计时包装器:测量任意函数调用的耗时
template<typename Func, typename... Args>
decltype(auto) TimeIt(const std::string& label, Func&& func, Args&&... args) {
auto start = std::chrono::high_resolution_clock::now();
// decltype(auto) 保持返回值的精确类型(包括引用)
decltype(auto) result = std::forward<Func>(func)(std::forward<Args>(args)...);
auto elapsed = std::chrono::high_resolution_clock::now() - start;
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
std::cout << "[" << label << "] " << ms << " ms" << std::endl;
return result;
}
// 使用
auto player = TimeIt("CreatePlayer", MakePlayer, "Alice", 100);
游戏服务器应用:消息构造与发送
cpp
class Connection {
public:
// 用完美转发直接在发送缓冲区中构造消息,避免拷贝
template<typename MsgType, typename... Args>
void SendMessage(Args&&... args) {
MsgType msg(std::forward<Args>(args)...); // 原地构造
auto buf = Serialize(msg);
doSend(MsgType::MSG_ID, buf);
}
};
// 使用
conn.SendMessage<MoveReq>(100.0f, 200.0f, 0.0f); // 直接传参构造,无需先创建 MoveReq 对象
7.6 注意事项
- 不要对同一个对象多次 forward:forward 可能把对象转为右值,如果已经被移动,再次使用就是未定义行为。
cpp
template<typename T>
void bad(T&& arg) {
foo(std::forward<T>(arg));
bar(std::forward<T>(arg)); // ❌ 危险!arg 可能已被移动
}
template<typename T>
void good(T&& arg) {
foo(arg); // 先以左值使用
bar(std::forward<T>(arg)); // 最后一次使用时再转发
}
- 万能引用只在模板参数推导时生效 :
auto&&也是万能引用(因为auto也涉及类型推导),但vector<int>&&不是(类型已确定)。
cpp
auto&& val = someExpr; // val 是万能引用
for (auto&& item : container) { /* ... */ } // item 也是万能引用,range-for 的最佳实践
8. 变参模板 Variadic Templates
8.1 是什么
变参模板允许模板接受任意数量的参数:
cpp
template<typename... Args> // Args 是一个模板参数包(parameter pack)
void Print(Args... args); // args 是一个函数参数包
... 在类型左边表示「打包」,在右边表示「展开」。
8.2 参数包的基本操作
cpp
template<typename... Args>
void Info() {
// sizeof...(Args) 返回参数包中的参数个数(编译期常量)
std::cout << "参数个数: " << sizeof...(Args) << std::endl;
}
Info<int, double, char>(); // 输出: 参数个数: 3
Info<>(); // 输出: 参数个数: 0
8.3 参数包展开方式
方式 1:递归展开(C++11 经典方式)
cpp
// 递归终止条件(base case)
void Print() {
std::cout << std::endl;
}
// 递归展开:每次处理第一个参数,剩余的递归处理
template<typename First, typename... Rest>
void Print(First first, Rest... rest) {
std::cout << first << " ";
Print(rest...); // 递归调用,参数包逐步缩小
}
Print(1, 3.14, "hello", 'A');
// 展开过程:
// Print(1, 3.14, "hello", 'A') → 打印 1,调用 Print(3.14, "hello", 'A')
// Print(3.14, "hello", 'A') → 打印 3.14,调用 Print("hello", 'A')
// Print("hello", 'A') → 打印 hello,调用 Print('A')
// Print('A') → 打印 A,调用 Print()
// Print() → 打印换行,结束
方式 2:逗号运算符展开(C++11 技巧)
cpp
template<typename... Args>
void Print(Args... args) {
// 利用逗号运算符在初始化列表中展开
int dummy[] = { (std::cout << args << " ", 0)... };
(void)dummy; // 防止未使用变量警告
std::cout << std::endl;
}
原理 :(expr, 0)... 会对参数包中的每个参数展开一次 expr,逗号运算符保证 expr 执行后返回 0,这些 0 用来初始化 dummy 数组。
方式 3:折叠表达式(C++17,下一节详讲)
cpp
template<typename... Args>
void Print(Args... args) {
((std::cout << args << " "), ...);
std::cout << std::endl;
}
8.4 变参类模板
cpp
// 编译期类型列表
template<typename... Types>
struct TypeList {};
using GameMessages = TypeList<LoginReq, MoveReq, AttackReq, ChatReq>;
// 获取类型列表的长度
template<typename List>
struct TypeListSize;
template<typename... Types>
struct TypeListSize<TypeList<Types...>> {
static constexpr size_t value = sizeof...(Types);
};
static_assert(TypeListSize<GameMessages>::value == 4);
8.5 完美转发与变参模板
变参模板配合完美转发是工厂函数和容器的 emplace 系列方法的基础:
cpp
template<typename T, typename... Args>
std::unique_ptr<T> MakeUnique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
// 使用:参数完美转发给 T 的构造函数
auto player = MakeUnique<Player>("Alice", 100, Vector3{0, 0, 0});
std::forward<Args>(args)... 展开方式:
如果 Args 是 int, const string&, double,那么展开为:
cpp
std::forward<int>(arg1), std::forward<const string&>(arg2), std::forward<double>(arg3)
每个参数都独立地转发,保持各自的左值/右值属性。
8.6 应用
事件系统
事件发射器是什么?可以理解为 EventEmitter = 一个万能的「消息中转站 + 广播喇叭」
举个例子:玩家被击杀 -> 要通知很多系统
- 日志系统要记录
- 界面系统要弹出提示
- 任务系统要判断是否完成
- 战斗系统要结算
如果不用发射器,写出来的代码强耦合:
玩家死亡时:
日志记录();
弹出提示();
任务更新();
战斗结算();
// 耦合严重!改一个地方全要动。
如果使用发射器EventEmitter之后:
-
发射器 = 中间传话的人
- 谁发生了事 → 告诉发射器
- 谁关心这事 → 去发射器订阅
- 发射器负责传话
-
三个角色
-
事件(Event) = 发生了什么事(事件)
例:
PlayerDeath(玩家死了) -
On (监听) = 谁想听这个事(监听事件)
例:界面、日志、任务系统都来注册:
"玩家死了记得叫我!"
-
Emit (发射 / 触发) = 事情真的发生了(触发事件)
告诉发射器:"玩家死啦!"
-
发射器自动通知所有监听的人。
总的来说就是: EventEmitter 的意思 = 统一的消息广播中心, 让 "发生事情的人" 和 "处理事情的人" 互不认识,却能自动通信。
- 谁要发消息 → 调用
Emit - 谁要收消息 → 调用
On - 中间不用互相认识、不用互相调用
- 发射器负责自动转发
cpp
// 泛型事件发射器
// 1、定义任意事件结构体(比如玩家死亡、玩家升级、物品拾取)
// 2、给事件注册回调函数(事件发生时要执行的逻辑)
// 3、触发事件时,自动调用所有注册的回调,并传递事件数据
// 4、完全类型安全:不同事件不会混淆,编译器自动校验类型
class EventEmitter
{
// 基类处理器:统一包装所有事件回调
using HandlerBase = std::function<void(const void *)>;
// 事件映射表:key=事件类型ID,value=该类型所有回调函数列表
// 按事件类型分组,存储各个类型事件的回调函数
std::unordered_map<size_t, std::vector<HandlerBase>> handlers_;
public:
// 注册事件处理器(用于注册)
// EventType:事件类型
// Handler &&handler:完美转发,保留传入lambda/函数的属性(左值/右值),避免拷贝开销
template <typename EventType, typename Handler>
void On(Handler &&handler)
{
// 1、获取事件类型的唯一ID(typeid运行时类型信息),同个事件类型ID也相同
size_t typeId = typeid(EventType).hash_code();
// 2、把用户传入的回调,包装成统一的handlerBase并存入容器
handlers_[typeId].push_back([h = std::forward<Handler>(handler)](const void *data)
{
// 3、回调函数执行时:void*转回真实的事件类型指针,调用用户函数
h(*static_cast<const EventType *>(data));
});
}
// 触发事件:用变参模板直接构造事件对象(用于触发)
template <typename EventType, typename... Args>
void Emit(Args &&...args)
{
// 1、直接用参数构造事件对象(无需手动new)
EventType event(std::forward<Args>(args)...);
// 2、获取事件类型ID
size_t typeId = typeid(EventType).hash_code();
// 3、查找该类型的所有回调
auto it = handlers_.find(typeId);
if (it != handlers_.end())
{
// 4、遍历所有回调,执行回调函数(传递事件对象)
for (auto &handler : it->second)
{
handler(&event);
}
}
}
};
// 使用
struct PlayerDeath
{
uint64_t playerId;
uint64_t killerId;
PlayerDeath(uint64_t pid, uint64_t kid) : playerId(pid), killerId(kid) {}
};
int main()
{
EventEmitter emitter;
// 注册事件回调函数
// 当事件触发后,显示击杀并回血
emitter.On<PlayerDeath>([](const PlayerDeath &e) { std::cout << "Player " << e.playerId << " killed by " << e.killerId << std::endl; });
emitter.On<PlayerDeath>([](const PlayerDeath &e) { std::cout << "Player " << e.playerId << " add " << 10 << " HP" << std::endl; });
// 触发事件
emitter.Emit<PlayerDeath>(1001, 2002);
emitter.Emit<PlayerDeath>(1001, 2003);
emitter.Emit<PlayerDeath>(1001, 2004);
}
// 关于typeid:
// typeid是C++ 运行时类型识别(RTTI) 关键字,用来获取一个类型 / 对象的「唯一身份标识」,你这段代码里用它就是为了:给每个事件类型生成一个唯一 ID,区分不同事件
// 例如代码中:typeid(EventType):拿到事件类型的类型信息(比如 PlayerDeath);.hash_code():生成一个数字 ID(size_t 整数);作用:每个类型对应唯一数字,当作哈希表的 key,让 PlayerDeath 的回调只处理 PlayerDeath 事件
// typeid的返回值:typeid(类型/对象) 返回一个 const std::type_info& 对象,里面存着这个类型的信息:
// 类型名字、唯一哈希值、类型大小等
// 两种用法:
// typeid(类型).name():返回类型名字(比如 "PlayerDeath")
// typeid(对象).hash_code():返回对象哈希值(比如 123456789)
// 核心能力:同类型一定相同,不同类型一定不同
// typeid(int).hash_code() == typeid(int).hash_code() // true,同类型
// typeid(int).hash_code() != typeid(double).hash_code() // true,不同类型
// typeid(PlayerDeath).hash_code() != typeid(PlayerUpgrade).hash_code() // true
// 特性:
// 1、编译期确认:对于基础类型(int、结构体),编译期就能确认类型。
// 2、运行时确认:对于多态类(带虚函数),运行时识别真实类型。
// 3、类型安全:不同类型不会混淆,编译器自动校验类型。
// typeid运行时识别:
// 典型列子:有类型Animal、Dog、Cat,基类时Animal,派生类时Dog、Cat
// 如果有代码:Animal* a = new Dog(); // 编译时编译器只知道a是Animal*,但实际上运行时真实类型是Dog*
// 这时候typeid就能识别出来:typeid(*a).name(); // 返回是"Dog";这就是运行时识别RTTI
日志格式化
cpp
template<typename... Args>
void LogInfo(const char* fmt, Args&&... args) {
// 将参数转发给 fmt 库或 spdlog
spdlog::info(fmt, std::forward<Args>(args)...);
}
LogInfo("Player {} entered scene {} at ({}, {})", playerId, sceneId, x, y);
9. 折叠表达式 Fold Expressions(C++17)
9.1 是什么
折叠表达式是 C++17 引入的语法糖,让你可以用一个运算符把参数包中的所有元素"折叠"起来,无需递归。
9.2 四种折叠形式
cpp
// 假设参数包 args 展开为 a, b, c, d
// 折叠展开的逻辑可以理解为:单个参数 op (剩余整体),然后剩余整体又可以是 单个参数 op (剩余整体)......这样循环进去,知道最后一个参数
// 1. 一元右折叠(Unary Right Fold)
(args op ...) → a op (b op (c op d))
// 2. 一元左折叠(Unary Left Fold)
(... op args) → ((a op b) op c) op d
// 3. 二元右折叠(Binary Right Fold)
(args op ... op init) → a op (b op (c op (d op init)))
// 4. 二元左折叠(Binary Left Fold)
(init op ... op args) → (((init op a) op b) op c) op d
9.3 实际示例
cpp
// 求和
template<typename... Args>
auto Sum(Args... args) {
return (args + ...); // 一元右折叠
}
Sum(1, 2, 3, 4); // 1 + (2 + (3 + 4)) = 10
// 带初始值的求和(防止空参数包)
template<typename... Args>
auto SafeSum(Args... args) {
return (0 + ... + args); // 二元左折叠,初始值为 0
}
SafeSum(); // 0(空参数包不会出错)
// 打印所有参数
template<typename... Args>
void Print(Args... args) {
((std::cout << args << " "), ...); // 逗号运算符折叠
std::cout << std::endl;
}
// 逻辑运算
template<typename... Args>
bool AllTrue(Args... args) {
return (args && ...); // 所有参数都为 true 才返回 true
}
template<typename... Args>
bool AnyTrue(Args... args) {
return (args || ...); // 有一个为 true 就返回 true
}
9.4 与逗号运算符结合
折叠表达式最强大的用法是和逗号运算符结合,可以对参数包中的每个元素执行任意操作:
cpp
// 对每个参数执行一个操作
template<typename... Handlers>
void RegisterAll(Handlers&&... handlers) {
(Register(std::forward<Handlers>(handlers)), ...);
// 展开为:Register(h1), Register(h2), Register(h3), ...
}
// 把多个值插入容器
template<typename Container, typename... Values>
void InsertAll(Container& c, Values&&... values) {
(c.push_back(std::forward<Values>(values)), ...);
}
std::vector<int> v;
InsertAll(v, 1, 2, 3, 4, 5); // v = {1, 2, 3, 4, 5}
9.5 应用
cpp
// 批量注册消息处理器
template<typename... MsgTypes>
void RegisterMessages() {
(RegisterHandler<MsgTypes>(), ...);
}
RegisterMessages<LoginReq, MoveReq, AttackReq, ChatReq, UseItemReq>();
// 组合多个条件检查
template<typename... Validators>
bool ValidateAll(const PlayerAction& action, Validators... validators) {
return (validators(action) && ...);
}
bool isValid = ValidateAll(action,
CheckCooldown,
CheckMana,
CheckRange,
CheckLineOfSight
);
10. if constexpr 编译期分支(C++17)
10.1 是什么
if constexpr 是编译期条件判断。与普通 if 不同,不满足条件的分支在编译期就被丢弃 ,不会被编译。
cpp
template<typename T>
std::string ToString(const T& value) {
if constexpr (std::is_integral_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_floating_point_v<T>) {
char buf[32];
snprintf(buf, sizeof(buf), "%.2f", static_cast<double>(value));
return buf;
} else if constexpr (std::is_same_v<T, std::string>) {
return value;
} else {
// 兜底:要求类型有 toString() 方法
return value.toString();
}
}
10.2 为什么普通 if 做不到
因为对于普通的if,无论分支是否满足,每个分支都会进行编译,这就导致了:当需要根据模板参数的不同类型,来走不同的逻辑时,如果指定的类型不能同时兼顾其他逻辑分支的编译问题,就会编译报错(因为是所有分支逻辑都进行编译);
但if constexpr不同,不满足条件的分支在编译期就被丢弃,**不会被编译,**这也就意味着可以按照指定的类型写对应类型的操作就好了,无需兼顾其他逻辑分支的编译问题(因为不满足并不会被编译)
cpp
template<typename T>
std::string ToString(const T& value) {
if (std::is_integral_v<T>) {
return std::to_string(value); // 当 T=string 时,这行编译失败!
} else {
return value.toString(); // 当 T=int 时,这行编译失败!
}
}
普通 if 的两个分支都会被编译 ,即使条件永远为 false。所以当 T=std::string 时,std::to_string(value) 不合法(to_string 不接受 string),编译失败。
if constexpr 会丢弃不走的分支,根本不编译它,所以不会有问题。
10.3 if constexpr 的规则
- 条件必须是编译期常量表达式。
- 不满足条件的分支不会被实例化,但语法必须合法(除非在模板依赖的上下文中)。
if constexpr可以嵌套使用。if constexpr可以有else if constexpr和else。
10.4 经典应用场景
递归变参模板的终止
cpp
// C++11 方式:需要单独的 base case 函数
void Print() {} // base case
template<typename First, typename... Rest>
void Print(First first, Rest... rest) {
std::cout << first << " ";
Print(rest...);
}
// C++17 方式:if constexpr 替代 base case
template<typename First, typename... Rest>
void Print(First first, Rest... rest) {
std::cout << first << " ";
if constexpr (sizeof...(Rest) > 0) {
Print(rest...);
}
}
编译期选择不同的数据存储方式
cpp
template<typename T>
class SmartBuffer {
// 小对象放栈上,大对象放堆上
static constexpr bool UseStack = sizeof(T) <= 64;
// 编译期选择存储方式
struct StackStorage { T data; };
struct HeapStorage { std::unique_ptr<T> data; };
std::conditional_t<UseStack, StackStorage, HeapStorage> storage_;
public:
T& get() {
if constexpr (UseStack) {
return storage_.data;
} else {
return *storage_.data;
}
}
};
10.5 if constexpr vs SFINAE vs Tag Dispatch
| 技术 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
if constexpr |
一个函数体内的分支选择 | 最简洁、最直观 | 不能改变函数签名(参数列表、返回类型) |
| SFINAE/enable_if | 函数重载选择 | 可以有不同的返回类型和参数 | 语法晦涩 |
| Tag Dispatch | 基于类型特征的多路分发 | 可读性好、易扩展 | 需要定义标签类型 |
| Concepts (C++20) | 函数重载/模板约束 | 最清晰、错误信息最好 | 需要 C++20 |
实践建议 :在 C++17 中,优先用 if constexpr(最简洁)。如果需要不同的返回类型或参数列表,用 SFINAE。在 C++20 中,用 Concepts 替代 SFINAE。
11. CRTP 奇异递归模板模式
11.1 是什么
CRTP(Curiously Recurring Template Pattern)是 C++ 中最重要的模板设计模式之一。形式是:派生类把自己作为模板参数传给基类。
cpp
template<typename Derived>
class Base {
// 基类"知道"自己的派生类是谁
};
class MyClass : public Base<MyClass> {
// 把自己传给基类
};
看起来像递归(基类的参数是派生类,但派生类又继承自基类),所以叫「奇异递归」。
11.2 核心用途:静态多态
虚函数实现的是运行时多态 ,通过 vtable(虚函数表) 间接调用,有一定的性能开销(一次间接寻址 + 可能的 cache miss)。CRTP 实现的是编译期多态(静态多态),没有虚函数开销。
这里解释一下,vtable、vptr、vtable间接调用,为什么会有额外的性能开销(一次间接寻址 + 可能的cache miss)? vtable 全称 Virtual Function Table(虚函数表),vptr 全称 Virtual Pointer(虚函数表指针);这两个是C++实现**运行时多态(动态绑定)*的核心底层机制。vtable 是*编译器在编译阶段自动生成的、存储类虚函数地址的函数指针数组,可以理解为一张「虚函数导航表」。
- 每个包含虚函数的类 ,会生成唯一的一张 vtable,同类的所有对象共享这张表(不是每个对象一份)
- 表中按虚函数的声明顺序,存储该类所有虚函数的入口地址
- 存储位置:程序的只读数据段(.rodata),防止运行时被意外修改
同时,编译器会给该类的每个对象 ,在内存布局的起始位置,插入一个隐藏的虚表指针(vptr,virtual pointer),这个指针指向所属类的 vtable,是对象和虚表的「桥梁」。
cpp
// 运行时多态(虚函数)
class Shape {
public:
virtual double area() const = 0; // 纯虚函数
virtual ~Shape() = default; // 虚析构函数
void printArea() const {
std::cout << "Area: " << area() << std::endl; // 虚函数调用
}
};
// 派生类 Circle,继承 Shape 并重写 area()
class Circle : public Shape {
double radius_;
public:
Circle(double r) : radius_(r) {}
double area() const override { return 3.14159 * radius_ * radius_; }
};
// 1. 编译阶段:生成 vtable
// Shape 类的 vtable:存储 Shape::area()(纯虚函数占位)、Shape::~Shape() 的地址
// Circle 类的 vtable:继承 Shape 的表结构,将 area() 条目替换为 Circle::area() 的地址,析构函数条目替换为 Circle::~Circle()
// 2. 对象创建:插入 vptr
// 当你创建 Circle c(5.0); 时:
// 对象 c 的内存开头,会被编译器插入一个 vptr,指向 Circle 类的 vtable
// 基类 Shape 的指针 / 引用 Shape* s = &c; 会持有这个 vptr
// 3. 运行时:动态绑定(多态的核心)
// 当执行 s->printArea() 时:
// printArea() 中调用 area(),编译器不会直接绑定 Shape::area()
// 程序通过 s 指向对象的 vptr,找到 Circle 的 vtable
// 从 vtable 中取出 area() 对应的函数指针,调用 Circle::area()
// 最终输出圆的面积,实现「基类指针调用派生类函数」的多态效果
// 性能开销问题:
// 虚函数实现的是运行时多态,通过 vtable 间接调用,有一定的性能开销(一次间接寻址 + 可能的 cache miss)
// 我们拆解这两个开销:
// 一次间接寻址:调用虚函数时,需要先通过 vptr 找 vtable,再从表中取函数指针,比普通函数的直接调用多了两次内存访问(取 vptr、取函数地址)
// Cache Miss 风险:vtable 存储在只读数据段,和对象内存(栈 / 堆)不连续,频繁跨段访问可能导致 CPU 缓存失效,降低性能
// 这也是 CRTP(奇异递归模板模式)实现 编译期多态(静态多态) 的优势:没有 vtable 和 vptr,编译时直接绑定函数,无运行时开销。
// 编译期多态(CRTP)
template<typename Derived>
class Shape {
public:
double area() const {
// static_cast 在编译期完成,零运行时开销
return static_cast<const Derived*>(this)->areaImpl();
}
void printArea() const {
std::cout << "Area: " << area() << std::endl; // 无虚函数调用
}
};
class Circle : public Shape<Circle> {
double radius_;
public:
Circle(double r) : radius_(r) {}
double areaImpl() const { return 3.14159 * radius_ * radius_; }
};
class Rectangle : public Shape<Rectangle> {
double w_, h_;
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
double areaImpl() const { return w_ * h_; }
};
关键区别 :CRTP 方式中,Shape<Circle> 和 Shape<Rectangle> 是不同的类型 ,不能放在同一个容器里(不像虚函数方式可以用 Shape*)。这就是编译期多态的代价------失去了运行时类型统一。
11.3 CRTP 的典型应用
应用 1:静态接口强制
cpp
template<typename Derived>
class Serializable {
public:
std::string serialize() const {
return static_cast<const Derived*>(this)->doSerialize();
}
void deserialize(const std::string& data) {
static_cast<Derived*>(this)->doDeserialize(data);
}
// 如果 Derived 没有实现 doSerialize,编译期就会报错
};
class PlayerData : public Serializable<PlayerData> {
public:
std::string doSerialize() const {
return "player_data";
}
void doDeserialize(const std::string& data) {
// ...
}
};
应用 2:实例计数器
cpp
template<typename Derived>
class Counter {
static inline int count_ = 0; // C++17 inline 静态变量
public:
Counter() { ++count_; }
~Counter() { --count_; }
static int getCount() { return count_; }
};
// 每个派生类有自己独立的计数器
class Monster : public Counter<Monster> {};
class Bullet : public Counter<Bullet> {};
// Monster::getCount() 和 Bullet::getCount() 是独立的
Monster m1, m2, m3;
Bullet b1;
std::cout << Monster::getCount(); // 3
std::cout << Bullet::getCount(); // 1
// 关于类内静态成员变量inline:
// 在 C++17 之前,类内的静态成员变量只能「声明」,不能「定义」,代码类似:
template<typename Derived>
class Counter {
static int count_; // 仅声明,无初始化
};
// 必须在类外单独定义(分配内存)
template<typename Derived>
int Counter<Derived>::count_ = 0;
// ❌ 直接在类内写 static int count_ = 0; 是编译错误,因为这违反了「声明 / 定义分离」的规则。
// C++17 引入了 inline 静态成员变量,彻底改变了这个规则:允许在类内直接定义并初始化 inline static 成员变量,编译器会自动处理「单实例」问题,无需类外定义。
11.4 CRTP 的限制和注意事项
- 不能用基类指针做多态 :
Shape<Circle>*和Shape<Rectangle>*是不同类型。如果需要运行时多态,还是用虚函数。 - 基类必须用
static_cast:不能用dynamic_cast(没有虚函数表)。 - 防止误用:可以把基类的构造函数设为 private,只让 Derived 通过 friend 访问:
cpp
template<typename Derived>
class Base {
friend Derived;
Base() = default; // 只有 Derived 能构造
};
// 防止误写成 class Wrong : public Base<Other> {} // 这个牛
12. Mixin 模式:模板与继承的结合
12.1 是什么
Mixin 模式通过模板参数作为基类 ,实现功能的链式组合。与传统的多继承不同,Mixin 形成的是线性继承链,没有菱形继承问题。
cpp
// Mixin:功能模块通过模板参数继承自上一个模块
template<typename Base>
struct WithLogging : Base {
void log(const std::string& msg) {
std::cout << "[LOG] " << msg << std::endl;
}
};
template<typename Base>
struct WithTimestamp : Base {
uint64_t getTimestamp() {
return std::chrono::steady_clock::now().time_since_epoch().count();
}
};
template<typename Base>
struct WithEncryption : Base {
std::string encrypt(const std::string& data) {
// 简单 XOR 加密示例
std::string result = data;
for (auto& c : result) c ^= 0x42;
return result;
}
};
// 基础连接类
struct RawConnection {
void send(const std::string& data) {
std::cout << "Sending: " << data << std::endl;
}
};
// 组合!继承链:WithLogging -> WithEncryption -> WithTimestamp -> RawConnection
using SecureLoggedConnection = WithLogging<WithEncryption<WithTimestamp<RawConnection>>>;
SecureLoggedConnection conn;
conn.send("hello"); // 来自 RawConnection
conn.log("connected"); // 来自 WithLogging
conn.encrypt("secret"); // 来自 WithEncryption
auto ts = conn.getTimestamp(); // 来自 WithTimestamp
12.2 增强版:Mixin 覆盖基类行为
Mixin 的真正威力在于可以拦截和增强基类的行为:
cpp
struct RawConnection {
void send(const std::string& data) {
std::cout << "Raw send: " << data << std::endl;
}
};
template<typename Base>
struct WithEncryption : Base {
void send(const std::string& data) {
std::string encrypted = data;
for (auto& c : encrypted) c ^= 0x42;
Base::send(encrypted); // 调用基类的 send,形成处理链
}
};
template<typename Base>
struct WithCompression : Base {
void send(const std::string& data) {
std::string compressed = "[compressed]" + data;
Base::send(compressed); // 调用基类的 send
}
};
// 组合:发送时先压缩再加密
using MyConnection = WithCompression<WithEncryption<RawConnection>>;
// 调用链:WithCompression::send → WithEncryption::send → RawConnection::send
MyConnection conn;
conn.send("hello");
// 输出:Raw send: [compressed] 加密后的 hello
12.3 应用
cpp
// 基础消息处理器
struct BasicHandler {
void handleMessage(uint16_t msgId, const Buffer& data) {
// 基础消息分发逻辑
dispatch(msgId, data);
}
};
// 加上频率限制
template<typename Base>
struct WithRateLimit : Base {
std::unordered_map<uint16_t, int64_t> lastCallTime_;
void handleMessage(uint16_t msgId, const Buffer& data) {
auto now = GetTimeMs();
if (now - lastCallTime_[msgId] < 100) { // 100ms 内不能重复
return; // 丢弃
}
lastCallTime_[msgId] = now;
Base::handleMessage(msgId, data); // 通过检查,继续处理
}
};
// 加上日志记录
template<typename Base>
struct WithAuditLog : Base {
void handleMessage(uint16_t msgId, const Buffer& data) {
LOG_INFO("Received msg: {}, size: {}", msgId, data.size());
Base::handleMessage(msgId, data);
}
};
// 组合:先日志 → 再限频 → 最后处理
using GameHandler = WithAuditLog<WithRateLimit<BasicHandler>>;
13. 类型擦除 Type Erasure
13.1 是什么
类型擦除是一种技术,让你在运行时隐藏具体类型信息 ,同时保持类型安全的接口。std::function 就是最经典的类型擦除例子------它可以持有任何可调用对象(函数指针、lambda、仿函数),而使用者不需要知道具体类型。
13.2 为什么需要
模板的问题是:不同的模板参数产生不同的类型 。你没法把 Handler<LoginReq> 和 Handler<MoveReq> 放在同一个容器里。
cpp
template<typename MsgType>
class Handler {
void handle(const MsgType& msg);
};
// 想存在一起?做不到------它们类型不同
// std::vector<Handler<???>> handlers; // ??? 填什么?
类型擦除解决这个问题:把模板参数"擦掉",让不同类型的对象可以统一存储和使用。
13.3 类型擦除的实现原理
核心思想是:用非模板的基类/接口 + 模板派生类来桥接。
cpp
// 第一步:定义非模板的接口
class IHandler {
public:
virtual void handle(const void* data) = 0;
virtual ~IHandler() = default;
};
// 第二步:模板派生类,持有具体的处理逻辑
template<typename MsgType>
class TypedHandler : public IHandler {
std::function<void(const MsgType&)> func_;
public:
TypedHandler(std::function<void(const MsgType&)> f) : func_(std::move(f)) {}
void handle(const void* data) override {
func_(*static_cast<const MsgType*>(data));
}
};
// 现在可以统一存储了
std::unordered_map<uint16_t, std::unique_ptr<IHandler>> handlers;
// 注册
handlers[1001] = std::make_unique<TypedHandler<LoginReq>>(
[](const LoginReq& req) { /* 处理登录 */ }
);
handlers[2001] = std::make_unique<TypedHandler<MoveReq>>(
[](const MoveReq& req) { /* 处理移动 */ }
);
// 分发(msgId 和 data 来自网络层)
handlers[msgId]->handle(data);
// 例子:CRTP + Type Erasure(但是加了这一层,最顶层还是走的是继承多态的路,与CRTP相反)
class all
{
public:
virtual double area() const = 0;
virtual void printArea() const = 0;
};
template <typename Derived>
class Shape : public all
{
public:
double area() const override
{
// static_cast 在编译期完成,零运行时开销
return static_cast<const Derived *>(this)->areaImpl();
}
void printArea() const override
{
std::cout << "Area: " << area() << std::endl; // 无虚函数调用
}
};
class Circle : public Shape<Circle>
{
double radius_;
public:
Circle(double r) : radius_(r) {}
double areaImpl() const { return 3.14159 * radius_ * radius_; }
};
class Rectangle : public Shape<Rectangle>
{
double w_, h_;
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
double areaImpl() const { return w_ * h_; }
};
int main()
{
std::vector<std::unique_ptr<all>> vec;
vec.push_back(std::make_unique<Circle>(5));
vec.push_back(std::make_unique<Rectangle>(2, 3));
for (const auto& p : vec)
{
p->printArea();
}
std::cout << "-" << std::endl;
return 0;
}
13.4 手写简化版 std::function(模板 + 类型擦除)
理解 std::function 的内部实现,对理解类型擦除至关重要:
cpp
// 下面这里例子建议先自己尝试着实现一个function,在实现的过程中会遇到一些问题,然后再尝试解决这些问题,最终就会得到下面类似的设计方法。
template<typename Signature>
class SimpleFunction;
template<typename Ret, typename... Args>
class SimpleFunction<Ret(Args...)> {
// 非模板基类:定义接口
struct Concept {
virtual Ret invoke(Args... args) = 0;
virtual ~Concept() = default;
};
// 模板派生类:适配任意可调用对象
template<typename Callable>
struct Model : Concept {
Callable callable_;
Model(Callable c) : callable_(std::move(c)) {}
Ret invoke(Args... args) override {
return callable_(std::forward<Args>(args)...);
}
};
std::unique_ptr<Concept> impl_;
public:
// 构造函数是模板:接受任意可调用对象
template<typename Callable>
SimpleFunction(Callable c) : impl_(std::make_unique<Model<Callable>>(std::move(c))) {}
// 调用运算符不是模板:统一接口
Ret operator()(Args... args) const {
return impl_->invoke(std::forward<Args>(args)...);
}
};
// 使用
SimpleFunction<int(int, int)> add = [](int a, int b) { return a + b; };
SimpleFunction<int(int, int)> mul = [](int a, int b) { return a * b; };
std::cout << add(3, 4); // 7
std::cout << mul(3, 4); // 12
精髓:构造函数是模板(知道具体类型),但存储和使用是非模板的(类型被"擦除"了)。
13.5 std::function 的性能代价
std::function 有两个性能代价:
- 堆分配 :要
new一个Model对象(小对象优化 SBO 可以避免:在std::function对象内部(栈上) 预留一段固定大小、对齐良好的缓冲区(常见实现是 16~32 字节),如果要存储的可调用对象足够小 (比如无捕获 lambda、单个函数指针),直接用placement new把对象存到这个内部缓冲区,完全避免堆分配;如果对象太大 / 对齐不兼容,才回退到传统的堆分配。)。 - 虚函数调用:每次调用都通过虚函数间接调用。
在性能敏感 的场景(比如游戏主循环中每帧调用的消息分发),可以考虑用模板函数指针替代 std::function:
cpp
// 轻量级替代方案:函数指针 + void* 上下文
using RawHandler = void(*)(void* ctx, const void* data);
struct HandlerEntry {
RawHandler func;
void* context;
};
// 适配模板:编译期生成类型安全的包装函数
template<typename MsgType, void(*Handler)(const MsgType&)>
void HandlerWrapper(void* ctx, const void* data) {
Handler(*static_cast<const MsgType*>(data));
}
///////////////////////////////////详细例子///////////////////////////////////
#include <iostream>
#include <vector>
// 1. 定义通用函数指针类型
using RawHandler = void(*)(void* ctx, const void* data);
// 2. 处理器条目结构
struct HandlerEntry {
RawHandler func;
void* context;
};
// 3. 模板包装器
template<typename MsgType, void(*Handler)(const MsgType&)>
void HandlerWrapper(void* ctx, const void* data) {
Handler(*static_cast<const MsgType*>(data));
}
// --------------------------
// 业务代码:消息类型 + 处理函数
// --------------------------
// 消息类型1:聊天消息
struct ChatMsg {
std::string content;
int from_uid;
};
// 消息类型2:登录消息
struct LoginMsg {
std::string token;
std::string device_id;
};
// 聊天消息处理函数
void OnChatMsg(const ChatMsg& msg) {
std::cout << "收到聊天消息: " << msg.content << " (来自: " << msg.from_uid << ")" << std::endl;
}
// 登录消息处理函数
void OnLoginMsg(const LoginMsg& msg) {
std::cout << "收到登录请求: token=" << msg.token << ", 设备=" << msg.device_id << std::endl;
}
// --------------------------
// 消息分发器
// --------------------------
class MessageDispatcher {
private:
std::vector<HandlerEntry> handlers_;
public:
// 注册处理器
template<typename MsgType, void(*Handler)(const MsgType&)>
void RegisterHandler() {
// 用模板生成包装函数,注册到列表
handlers_.push_back({
&HandlerWrapper<MsgType, Handler>, // 函数指针
nullptr // 上下文(这里是全局函数,传nullptr;成员函数传this)
});
}
// 分发消息
void Dispatch(int msg_type, const void* data) {
// 简化示例:直接按索引分发(实际会用msg_type映射到对应handler)
if (msg_type >= 0 && msg_type < handlers_.size()) {
auto& entry = handlers_[msg_type];
entry.func(entry.context, data); // 直接调用,无虚函数!
}
}
};
// --------------------------
// 测试
// --------------------------
int main() {
MessageDispatcher dispatcher;
// 注册处理器
dispatcher.RegisterHandler<ChatMsg, &OnChatMsg>();
dispatcher.RegisterHandler<LoginMsg, &OnLoginMsg>();
// 模拟收到消息
ChatMsg chat_msg{"你好", 1001};
LoginMsg login_msg{"abc123", "iphone15"};
// 分发消息(msg_type=0对应ChatMsg,1对应LoginMsg)
dispatcher.Dispatch(0, &chat_msg);
dispatcher.Dispatch(1, &login_msg);
return 0;
}
14. 模板模板参数与策略模式
14.1 什么是模板模板参数
前面介绍的模板参数有两种:类型参数 (typename T)和非类型参数 (int N)。第三种是模板模板参数(Template Template Parameter)------模板参数本身是一个模板:
cpp
// 普通类型参数:接受一个具体类型
template<typename Container>
class Wrapper1 {
Container data_; // Container 必须是完整类型,如 std::vector<int>
};
// 模板模板参数:接受一个模板(而不是类型)
template<template<typename> class Container>
class Wrapper2 {
Container<int> intData_; // 在这里才指定元素类型
Container<double> doubleData_; // 同一个容器模板,不同元素类型
};
关键区别:typename Container 接受的是 std::vector<int> 这样的具体类型 ,而 template<typename> class Container 接受的是 std::vector 这个模板本身(未实例化的模板)。
14.2 基本语法
cpp
// 语法:template<模板参数列表> class 参数名
// C++17 起也可以用 typename 替代 class
template<template<typename, typename> class Container, typename T>
class DataStore {
Container<T, std::allocator<T>> storage_;
public:
void add(const T& val) { storage_.push_back(val); }
size_t size() const { return storage_.size(); }
};
// 使用:传入模板本身,不带尖括号
DataStore<std::vector, int> vecStore; // 底层用 vector
DataStore<std::deque, std::string> dequeStore; // 底层用 deque
vecStore.add(42);
dequeStore.add("hello");
注意:模板模板参数的参数列表必须和实际传入的模板参数个数兼容 。std::vector 实际上有两个模板参数(元素类型和分配器),所以上面用了 template<typename, typename> class Container。如果只写 template<typename> class Container,在一些编译器上会报错。C++17 起,如果模板有默认参数,可以省略。
14.3 策略模式的模板实现
模板模板参数最经典的应用是策略模式------让用户通过模板参数"注入"不同的策略,编译期绑定,零运行时开销:
cpp
// ──── 策略定义 ────
// 分配策略
template<typename T>
struct HeapAllocator {
T* allocate(size_t n) { return new T[n]; }
void deallocate(T* p) { delete[] p; }
};
template<typename T>
struct PoolAllocator {
T* allocate(size_t n) { return static_cast<T*>(pool_.alloc(n * sizeof(T))); }
void deallocate(T* p) { pool_.free(p); }
private:
static MemoryPool pool_;
};
// 日志策略
template<typename T>
struct ConsoleLogger {
void log(const std::string& msg) {
std::cout << "[Console] " << msg << std::endl;
}
};
template<typename T>
struct FileLogger {
void log(const std::string& msg) {
// 写入文件...
}
};
// ──── 使用策略的类 ────
template<typename T,
template<typename> class AllocPolicy = HeapAllocator,
template<typename> class LogPolicy = ConsoleLogger>
class ObjectPool : private AllocPolicy<T>, private LogPolicy<T> {
T* buffer_ = nullptr;
size_t capacity_ = 0;
public:
ObjectPool(size_t cap) : capacity_(cap) {
buffer_ = AllocPolicy<T>::allocate(cap);
LogPolicy<T>::log("ObjectPool created with capacity " + std::to_string(cap));
}
~ObjectPool() {
AllocPolicy<T>::deallocate(buffer_);
LogPolicy<T>::log("ObjectPool destroyed");
}
// ...
};
// 使用:像搭积木一样组合策略
ObjectPool<Bullet, HeapAllocator, ConsoleLogger> devPool(1000); // 开发环境
ObjectPool<Bullet, PoolAllocator, FileLogger> prodPool(10000); // 生产环境
与 Mixin 模式的区别:Mixin 模式中策略类继承自一个 Base 参数形成链式继承,每层可以拦截和增强基类行为;策略模式中策略类是独立的,被宿主类组合使用,各策略之间互不依赖。两者可以根据场景灵活选择。
14.4 不用模板模板参数的替代写法
有时可以用普通类型参数 + 嵌套类型来达到类似效果:
cpp
// 方式 1:模板模板参数
template<template<typename> class Container, typename T>
class Store1 {
Container<T> data_;
};
// 方式 2:普通类型参数 + 重新绑定(Rebind)
// 类似于标准库 allocator 的做法
template<typename Container>
class Store2 {
Container data_; // 直接使用传入的完整类型
};
// 使用
Store1<std::vector, int> s1; // 模板模板参数
Store2<std::vector<int>> s2; // 普通类型参数
// 模板模板参数的优势:可以在类内部用不同元素类型实例化同一个容器模板
// 普通类型参数的优势:语法更简单,兼容性更好
14.5 游戏服务器应用
cpp
// 可配置的消息队列:序列化策略 + 容器策略
template<typename T>
struct JsonSerializer {
static std::string serialize(const T& msg) { return toJson(msg); }
static T deserialize(const std::string& data) { return fromJson<T>(data); }
};
template<typename T>
struct ProtobufSerializer {
static std::vector<uint8_t> serialize(const T& msg) { return msg.SerializeAsString(); }
static T deserialize(const std::vector<uint8_t>& data) {
T msg; msg.ParseFromString(data); return msg;
}
};
template<typename MsgType,
template<typename> class Serializer = ProtobufSerializer>
class MessageQueue {
std::queue<std::string> queue_;
public:
void push(const MsgType& msg) {
queue_.push(Serializer<MsgType>::serialize(msg));
}
MsgType pop() {
auto data = queue_.front();
queue_.pop();
return Serializer<MsgType>::deserialize(data);
}
};
// 开发时用 JSON(方便调试),上线用 Protobuf(高性能)
MessageQueue<ChatReq, JsonSerializer> devQueue;
MessageQueue<ChatReq, ProtobufSerializer> prodQueue;
15. std::tuple 与 std::index_sequence
15.1 std::tuple
std::tuple 是一个可以持有任意数量、任意类型 元素的编译期容器:
std::tuple 是 C++11 引入的固定大小异构容器 ,可存储多个不同类型的元素。它是对 std::pair 的泛化(pair 只能存 2 个元素,tuple 可存任意数量),常用于函数返回多值、临时组合数据等场景。
定义与初始化:
cpp
#include <tuple>
#include <string>
// 方式1:直接构造
std::tuple<int, double, std::string> t1(1, 3.14, "hello");
// 方式2:使用 std::make_tuple(自动推导类型)
auto t2 = std::make_tuple(42, 2.718, "world");
// 方式3:C++17 类模板参数推导(CTAD)
std::tuple t3(10, 3.14f, "test"); // 自动推导为 tuple<int, float, const char*>
访问元素std::get,通过**编译期索引(也就是说std::get中的N必须在编译时确定,不能用变量)**或类型访问元素(类型访问要求类型唯一):
cpp
std::tuple<int, double, std::string> t(1, 3.14, "hello");
// 1. 通过索引访问(从 0 开始)
int i = std::get<0>(t); // 获取第 0 个元素:1
double d = std::get<1>(t); // 获取第 1 个元素:3.14
// 2. 通过类型访问(C++14 起,类型必须唯一)
std::string s = std::get<std::string>(t); // 获取 string 类型元素:"hello"
// 使用这个方法类型必须唯一,比如下面这个例子就是错误的,因为不唯一:
// std::tuple<int, std::string, double, std::string> t1(1, "hello", 1.2, "world");
// std::cout << std::get<std::string>(t); // 编译错误,因为不唯一
// 3. 修改元素(get 返回引用)
std::get<0>(t) = 100; // 修改第 0 个元素为 100
解包:std::tie 与结构化绑定: 将tuple元素解包到独立变量中:
cpp
std::tuple<int, double, std::string> t(1, 3.14, "hello");
// 方式1:std::tie(C++11 起)
int a;
double b;
std::string c;
std::tie(a, b, c) = t; // 解包到 a, b, c
std::tie(a, std::ignore, c) = t; // 用 std::ignore 忽略某个元素
// 方式2:结构化绑定(C++17 起,更简洁)
auto [x, y, z] = t; // 直接绑定 x=1, y=3.14, z="hello"
获取tuple大小与元素类型
cpp
using TupleType = std::tuple<int, double, std::string>;
// 1. 获取元素数量(编译期常量)
constexpr size_t size = std::tuple_size_v<TupleType>; // 3
// 2. 获取第 N 个元素的类型
using FirstType = std::tuple_element_t<0, TupleType>; // in
合并tuple:std::tuple_cat :
cpp
std::tuple<int, double> t1(1, 3.14);
std::tuple<std::string, bool> t2("hello", true);
auto t3 = std::tuple_cat(t1, t2);
// t3 类型为 std::tuple<int, double, std::string, bool>
遍历tuple:std::apply(C++17起):由于tuple元素类型不同,需要模板或std::apply进行遍历:
cpp
#include <iostream>
#include <tuple>
std::tuple t(1, 3.14, "hello");
// 用 lambda + 折叠表达式遍历
std::apply([](auto&&... args) {
((std::cout << args << " "),...); // 输出:1 3.14 hello
}, t);
作为函数返回值(最常用场景):替代传统的"输出参数"方式,更优雅地返回多值:
cpp
#include <tuple>
#include <string>
// 返回多个值
std::tuple<int, double, std::string> getUser() {
return {1001, 98.5, "Alice"}; // C++17 可省略 make_tuple
}
// 使用
auto [id, score, name] = getUser(); // 结构化绑定接收
-
注意: 索引必须是编译期常量,
std::get<N>中的N必须在编译时确定,不能用变量 -
std::make_tuple的类型退化:make_tuple 会对参数进行类型退化 (如数组退化为指针、引用退化为值)。若需保留引用,用
std::ref/std::cref -
比较操作(C++20前用std::tie):
tuple默认支持按字典序比较(==,!=,<,<=,>,>=)。自定义类型比较时,常用std::tie简化:cppstruct Person { std::string name; int age; bool operator<(const Person& other) const { // 按 name 比较,name 相同则按 age 比较 return std::tie(name, age) < std::tie(other.name, other.age); } };
15.2 std::index_sequence
std::index_sequence 是一个编译期整数序列 ,通常配合 std::make_index_sequence 生成:
cpp
// std::make_index_sequence<5> 生成 std::index_sequence<0, 1, 2, 3, 4>
它的核心用途是:在编译期"循环"遍历 tuple 或参数包。
15.3 遍历 tuple
cpp
// 打印 tuple 的所有元素
template<typename Tuple, size_t... Is>
void PrintTupleImpl(const Tuple& t, std::index_sequence<Is...>) {
((std::cout << std::get<Is>(t) << " "), ...); // C++17 折叠表达式
}
template<typename... Args>
void PrintTuple(const std::tuple<Args...>& t) {
PrintTupleImpl(t, std::make_index_sequence<sizeof...(Args)>{});
}
auto t = std::make_tuple(42, 3.14, "hello");
PrintTuple(t); // 输出: 42 3.14 hello
原理:
sizeof...(Args)得到 tuple 元素个数 = 3。std::make_index_sequence<3>生成std::index_sequence<0, 1, 2>。PrintTupleImpl的模板参数Is...推导为0, 1, 2。- 折叠表达式展开为
std::get<0>(t), std::get<1>(t), std::get<2>(t)。
15.4 std::apply
C++17 提供了 std::apply,可以将 tuple 的元素展开为函数参数:
cpp
auto args = std::make_tuple(1, 2.0, std::string("hello"));
auto result = std::apply([](int a, double b, const std::string& c) {
std::cout << a << ", " << b << ", " << c << std::endl;
return a;
}, args);
15.5 服务器应用:编译期组件类型注册
cpp
// ECS 中:根据组件类型列表自动注册
template<typename... Components>
class ComponentRegistry {
using ComponentTuple = std::tuple<std::vector<Components>...>;
ComponentTuple storage_;
public:
// 获取指定组件类型的存储
template<typename C>
std::vector<C>& getStorage() {
return std::get<std::vector<C>>(storage_);
}
// 对所有组件存储执行操作
template<typename Func>
void forEachStorage(Func&& func) {
std::apply([&func](auto&... storages) {
(func(storages), ...);
}, storage_);
}
};
ComponentRegistry<PositionComponent, HealthComponent, VelocityComponent> registry;
registry.getStorage<PositionComponent>().push_back({1.0f, 2.0f, 3.0f});
16. 模板元编程基础 TMP
16.1 是什么
模板元编程(Template Metaprogramming)是利用模板机制在编译期进行计算的技术。C++ 模板系统本质上是一个图灵完备的编译期计算系统。
这里有两个核心关键点:
-
编译期执行 :TMP 的所有逻辑、计算、类型处理,都在代码编译阶段完成,运行期直接使用编译期生成的结果 ,没有任何运行时计算开销。
-
图灵完备:意味着模板系统可以实现任何逻辑(循环、分支、递归、变量 / 类型存储),理论上能在编译期完成任何可计算的任务。
| 普通 C++ 代码 | 模板元编程 TMP |
|---|---|
| 运行期执行 | 编译期执行 |
| 操作数值、变量 | 操作类型、编译期常量 |
| 运行期分支、循环 | 编译期通过模板特化、递归实现分支与循环 |
| 运行期报错 | 编译期报错,提前发现问题 |
16.2 编译期计算
编译期计算是 TMP 最基础的能力:把原本运行期的数值计算,提前到编译期完成,生成编译期常量,运行期直接使用,零开销。
cpp
// 编译期阶乘(经典示例)
template<int N>
struct Factorial {
static constexpr int value = N * Factorial<N - 1>::value;
};
template<>
struct Factorial<0> {
static constexpr int value = 1;
};
constexpr int result = Factorial<5>::value; // 120,编译期计算完成
// C++14 以后,用 constexpr 函数更简洁,编译期计算完成
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
编译期计算和运行时计算最直观的比较:
cpp
// 测试代码
#include <iostream>
#include <chrono>
#include <utility>
// ====================== 编译期模板阶乘 ======================
template <long long N>
struct Fac
{
static constexpr long long value = N * Fac<N - 1>::value;
};
template <>
struct Fac<0>
{
static constexpr long long value = 1;
};
template <size_t... Is>
void allImpl(std::index_sequence<Is...>)
{
// 使用折叠表达式访问所有编译期常量,volatile 防止被优化掉
volatile long long sink;
((sink = Fac<Is>::value), ...);
}
template <long long N>
void all()
{
allImpl(std::make_index_sequence<N>{});
}
// ====================== 普通运行时阶乘函数======================
long long fac_normal(long long n)
{
long long res = 1;
for (long long i = 1; i <= n; ++i)
{
res *= i;
}
return res;
}
void all_normal()
{
volatile long long sink;
for (int i = 0; i < 20; ++i)
{
sink = fac_normal(i);
}
}
// ====================== 测试主函数 ======================
int main()
{
const int test_times = 10000000; // 各调用 10000000 次
// -------------------- 测试 编译期模板版本 --------------------
auto start1 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < test_times; ++i)
{
all<20>();
}
auto end1 = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> duration1 = end1 - start1;
// -------------------- 测试 普通函数版本 --------------------
auto start2 = std::chrono::high_resolution_clock::now();
for (int i = 0; i < test_times; ++i)
{
all_normal();
}
auto end2 = std::chrono::high_resolution_clock::now();
std::chrono::duration<double, std::milli> duration2 = end2 - start2;
// ====================== 输出结果 ======================
std::cout << "\n==================== 耗时对比 ====================\n";
std::cout << "编译期模板版本 调用 " << test_times << " 次耗时:" << duration1.count() << " ms\n";
std::cout << "普通运行时函数 调用 " << test_times << " 次耗时:" << duration2.count() << " ms\n";
std::cout << "====================================================\n";
return 0;
}
// 结果:
ubuntu$ ./test
==================== 耗时对比 ====================
编译期模板版本 调用 10000000 次耗时:8.88274 ms
普通运行时函数 调用 10000000 次耗时:1902.71 ms
====================================================
ubuntu$ ./test
==================== 耗时对比 ====================
编译期模板版本 调用 10000000 次耗时:8.62768 ms
普通运行时函数 调用 10000000 次耗时:1900.22 ms
====================================================
ubuntu$ ./test
==================== 耗时对比 ====================
编译期模板版本 调用 10000000 次耗时:9.43706 ms
普通运行时函数 调用 10000000 次耗时:1922.13 ms
====================================================
对于编译期计算(模板元编程 + constexpr)优化的个人看法总结: 1、利用 C++ 模板编译期实例化 + constexpr 常量求值的特性,可对高频调用、输入编译期可确定、输入与输出为纯映射关系 的计算逻辑,在编译阶段提前完成全量运算,实现运行时零计算开销,相比普通运行时函数可带来数量级的性能提升。 2、不可突破的适用边界 :该技术完全不适用于运行时动态变化的变量场景 。核心原因是动态输入无法在编译期完成模板实例化,不仅无法实现预计算收益,还会导致模板实例化爆炸、编译成本陡增,无任何运行时优化价值。 3、编译期模板方案 :所有阶乘结果在编译阶段就已完成全量计算,直接以常量形式硬编码进二进制文件。运行时仅需一条mov指令完成常量赋值,无任何循环、乘法运算,真正实现了运行时零计算开销。 4、普通运行时函数方案:每次调用都需要 CPU 实打实执行循环 + 乘法指令,100 万次调用就会重复 100 万次完整的阶乘计算,所有计算开销完全集中在运行时。
16.3 编译期类型列表
cpp
// 类型列表定义
template<typename... Types>
struct TypeList {};
// 获取长度
template<typename List>
struct Length;
template<typename... Types>
struct Length<TypeList<Types...>> {
static constexpr size_t value = sizeof...(Types);
};
// 获取第 N 个类型
template<size_t N, typename List>
struct TypeAt;
template<size_t N, typename Head, typename... Tail>
struct TypeAt<N, TypeList<Head, Tail...>> {
using type = typename TypeAt<N - 1, TypeList<Tail...>>::type;
};
template<typename Head, typename... Tail>
struct TypeAt<0, TypeList<Head, Tail...>> {
using type = Head;
};
// 使用
using MyTypes = TypeList<int, double, std::string>;
static_assert(Length<MyTypes>::value == 3);
using Second = TypeAt<1, MyTypes>::type; // double
16.4 编译期条件与分支
cpp
// 编译期 if-else(用模板特化实现)
template<bool Condition, typename Then, typename Else>
struct If {
using type = Then;
};
template<typename Then, typename Else>
struct If<false, Then, Else> {
using type = Else;
};
// 等价于 std::conditional_t
using Result = If<(sizeof(int) > 4), int64_t, int32_t>::type;
16.5 检测是否存在成员(不用 void_t 的方式)
cpp
// 经典 SFINAE 检测模式
template<typename T>
class HasSerialize {
template<typename U>
static auto test(int) -> decltype(std::declval<U>().serialize(), std::true_type{});
template<typename U>
static std::false_type test(...);
public:
static constexpr bool value = decltype(test<T>(0))::value;
};
原理:
- 编译器尝试
test<T>(int)版本,如果T有serialize()方法,decltype(...)合法,返回true_type。 - 如果
T没有serialize()方法,decltype(...)失败,SFINAE 排除这个重载。 - 编译器退而选择
test<T>(...)版本(省略号参数匹配优先级最低),返回false_type。
16.6 服务器应用:编译期消息 ID 映射
cpp
// 编译期关联消息类型和 ID
template<typename MsgType>
struct MsgTraits;
// 为每个消息类型特化
struct LoginReq { std::string username; };
template<>
struct MsgTraits<LoginReq> {
static constexpr uint16_t ID = 1001;
static constexpr const char* Name = "LoginReq";
};
struct MoveReq { float x, y, z; };
template<>
struct MsgTraits<MoveReq> {
static constexpr uint16_t ID = 2001;
static constexpr const char* Name = "MoveReq";
};
// 使用
constexpr uint16_t loginMsgId = MsgTraits<LoginReq>::ID; // 1001,编译期确定
16.7 实践建议
模板元编程非常强大但也非常难读。实际工作中的建议:
- 优先使用
constexpr函数做编译期计算,而不是模板递归。 - **优先使用
if constexpr**做编译期分支,而不是模板特化。 - 优先使用标准库的 type_traits,而不是自己从头写。
- 只在框架层面使用复杂的 TMP(如 ECS 框架、序列化框架、消息系统)。
- 业务逻辑代码中避免使用复杂的 TMP。
17. C++20 Concepts(概念)
17.1 是什么
Concepts 是 C++20 引入的特性,是 SFINAE 的人类友好替代品 。它允许你用清晰的语法来约束模板参数必须满足什么条件。
Concepts 本质上是对模板参数的约束。在 C++20 之前,模板可以接受任何类型,如果传入不合适的类型,编译器会给出冗长且难以理解的错误信息。Concepts 让你可以明确地声明"这个模板参数必须满足什么条件"。
17.2 基本语法
cpp
#include <concepts>
// 简单定义一个 Concept(基于type trait)
template<typename T>
concept Numeric = std::is_arithmetic_v<T>;
// 这里定义了一个叫 Numeric 的 concept,它要求类型 T 必须是算术类型(整数或浮点数)。std::is_arithmetic_v<T> 是一个编译期布尔值,为 true 时约束满足。
// 使用 Concept 约束模板参数
template<Numeric T>
T Add(T a, T b) {
return a + b;
}
Add(3, 4); // OK
Add(3.14, 2.0); // OK
// Add("a", "b"); // 编译错误,清晰的错误信息:string 不满足 Numeric
17.3 requires语法 和 定义 Concept 的方式
在此之前,先讲讲Concepts体系的核心语法requires,requires有两种不同的用法,本质都不一样:
-
requires子句(requires clause) 出现在模板声明或函数签名中,用来施加约束:
cpptemplate<typename T> T add(T a, T b) requires std::integral<T> { // 这是 requires 子句 return a + b; } // 它的作用是说:"只有满足这个条件,这个模板才参与重载决议。" -
requires表达式(requires expression) 它是一个编译期布尔表达式,用来检测一组语法要求是否成立:
cpprequires(T a, T b) { a + b; // 检查这些表达式是否合法 a - b; } // 这个表达式本身的值是 true 或 false。 // 两者可以嵌套出现,就形成了著名的 requires requires: template<typename T> T add(T a, T b) requires requires(T x, T y) { x + y; } { return a + b; } // 第一个 requires 是子句("我要求......"),第二个 requires 开始一个表达式("检测以下操作是否合法")。
requires表达式的完整语法:
cpp
requires (参数列表) {
要求1;
要求2;
...
}
// 参数列表中的参数不会真正创建对象,只是为了在大括号里引用类型的"假想变量"。
// 大括号里可以写四种要求:
// 1. 简单要求(Simple Requirement)
// 检查某个表达式是否能通过编译,不关心返回类型:
template<typename T>
concept Addable = requires(T a, T b) {
a + b; // 只要 a + b 能编译通过就行
};
template<typename T>
concept HasToString = requires(T a) {
a.toString(); // 必须有 toString() 方法
a.size(); // 必须有 size() 方法
std::cout << a; // 必须支持 << 运算符
};
// 注意: 每一行末尾的分号是必须的,每行都是一个独立的要求。
// 2. 类型要求(Type Requirement)
// 检查某个类型是否存在,用 typename 关键字:
template<typename T>
concept HasValueType = requires {
typename T::value_type; // T 必须有 value_type 成员类型
typename T::iterator; // T 必须有 iterator 成员类型
typename std::vector<T>; // vector<T> 必须能实例化
};
// 这在检查容器类型时特别有用:
template<typename C>
concept Container = requires(C c) {
typename C::value_type;
typename C::size_type;
typename C::iterator;
c.begin();
c.end();
c.size();
};
// 3. 复合要求(Compound Requirement)
// 这是最强大的形式。不仅检查表达式能否编译,还能约束返回值类型和 noexcept 属性:
{ 表达式 } noexcept -> 类型约束;
// 三个部分都可以按需选用:
template<typename T>
concept MyType = requires(T a, T b) {
// 只检查能否编译(等价于简单要求)
{ a + b };
// 检查能否编译 + 返回类型约束
{ a + b } -> std::same_as<T>;
// 检查能否编译 + 不抛异常
{ a + b } noexcept;
// 全部检查
{ a.swap(b) } noexcept -> std::same_as<void>;
};
// 箭头 -> 后面跟的是一个 concept,编译器会自动把表达式的返回类型作为第一个参数传入。所以:
{ a + b } -> std::same_as<int>;
// 等价于检查:std::same_as<decltype(a + b), int>
{ a.size() } -> std::convertible_to<std::size_t>;
// 等价于检查:std::convertible_to<decltype(a.size()), std::size_t>
// 4. 嵌套要求(Nested Requirement)
// 在 requires 表达式内部再写一个 requires 子句,用来做编译期布尔判断:
template<typename T>
concept SignedNumeric = requires(T a) {
a + a;
a * a;
requires std::is_signed_v<T>; // 嵌套要求:必须是有符号类型
requires sizeof(T) >= 4; // 嵌套要求:至少 4 字节
};
// 嵌套要求以 requires 开头,后面跟一个编译期常量布尔表达式。注意和"简单要求"的区别:
requires(T a) {
sizeof(T) >= 4; // 简单要求:只检查这个表达式能否编译(永远为 true)
requires sizeof(T) >= 4; // 嵌套要求:检查这个值是否为 true
};
// 前者只问"这句话语法对不对"------当然对,比较运算永远合法。后者问"这个条件是不是真的"------只有 T 确实 ≥ 4 字节才满足。这个区别很容易出错。
// 把四种要求融合在一起:
template<typename T>
concept Serializable = requires(T obj, Buffer buf) {
// 类型要求:必须有这些成员类型
typename T::id_type;
// 简单要求:必须有这些方法
obj.serialize(buf);
obj.deserialize(buf);
// 复合要求:serialize 返回值必须是 void
{ obj.serialize(buf) } -> std::same_as<void>;
// 复合要求:getId 必须不抛异常,且返回可转换为 uint32_t 的类型
{ obj.getId() } noexcept -> std::convertible_to<uint32_t>;
// 嵌套要求:对象大小不能超过 1KB
requires sizeof(T) <= 1024;
// 嵌套要求:必须可以默认构造
requires std::default_initializable<T>;
};
requires表达式可以单独使用:它本身就是一个 bool 值,所以可以用在任何需要编译期布尔的地方:
cpp
// 用在 if constexpr 中
template<typename T>
void process(T val) {
if constexpr (requires { val.toString(); }) {
std::cout << val.toString();
} else {
std::cout << val;
}
}
// 用在 static_assert 中
static_assert(requires(int a) { a + a; }); // 通过
// 用在变量中
constexpr bool can_add = requires(int a, int b) { a + b; }; // true
// 这比传统的 SFINAE 检测手法(写一大堆辅助模板)简洁太多了。
记住核心原则:简单要求只检查"能不能编译",要检查"值是不是 true"必须用嵌套 requires。
接下来是定义 Concept 的方式:
cpp
// 方式 1:基于 type_traits
template<typename T>
concept Integral = std::is_integral_v<T>;
// 方式 2:基于表达式合法性(requires 表达式)
template<typename T>
concept Serializable = requires(T obj, Buffer& buf) {
{ obj.serialize(buf) } -> std::same_as<void>; // 必须有 serialize 方法
{ obj.deserialize(buf) } -> std::same_as<void>; // 必须有 deserialize 方法
{ T::MSG_ID } -> std::convertible_to<uint16_t>; // 必须有 MSG_ID 常量
};
// 这是更强大的写法。requires 表达式里列出了类型 T 必须满足的所有条件:
// { obj.serialize(buf) } -> std::same_as<void>:表示对象必须有 serialize 方法,且返回值为 void
// { T::MSG_ID }:表示类型必须有一个叫 MSG_ID 的静态成员,且能转换为 uint16_t
// 花括号 {} 里写的是一个表达式,箭头 -> 后面是对该表达式返回值类型的约束。
// 方式 3:组合多个 Concept
template<typename T>
concept GameMessage = Serializable<T> && std::is_default_constructible_v<T>;
// 用 &&(逻辑与)把多个约束组合在一起。类型必须既满足 Serializable,又能默认构造。也可以用 ||(逻辑或)。
// 方式 4:复杂的 requires 子句
template<typename T>
concept Hashable = requires(T a) {
{ std::hash<T>{}(a) } -> std::convertible_to<size_t>;
};
// 用 &&(逻辑与)把多个约束组合在一起。类型必须既满足 Serializable,又能默认构造。也可以用 ||(逻辑或)。
template<typename Container>
concept Iterable = requires(Container c) {
c.begin();
c.end();
{ c.size() } -> std::convertible_to<size_t>;
};
17.4 使用 Concept 的四种语法
定义好 Concept 后,有多种方式使用它来约束模板:
cpp
// 定义concept
template<typename T>
concept Numeric = std::is_arithmetic_v<T>;
// 语法 1:放在模板参数中(最推荐)
template<Numeric T>
T Add(T a, T b) { return a + b; }
// 把 typename 换成你的 concept 名字,最简洁。
// 语法 2:requires 子句
template<typename T>
requires Numeric<T>
T Subtract(T a, T b) { return a - b; }
// 语法 3:尾置 requires
template<typename T>
T Multiply(T a, T b) requires Numeric<T> { return a * b; }
// 语法 4:简写(auto + concept)
Numeric auto Divide(Numeric auto a, Numeric auto b) { return a / b; }
// 最简洁的写法,连 template<> 声明都省掉了。Numeric auto 表示"这个参数是满足 Numeric 的任意类型"。
17.5 Concepts vs SFINAE 对比
cpp
// SFINAE 方式(C++11/14/17):晦涩
template<typename T,
std::enable_if_t<std::is_integral_v<T>, int> = 0>
void Process(T value) { /* 整数处理 */ }
template<typename T,
std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
void Process(T value) { /* 浮点处理 */ }
// Concepts 方式(C++20):清晰
template<std::integral T>
void Process(T value) { /* 整数处理 */ }
template<std::floating_point T>
void Process(T value) { /* 浮点处理 */ }
// 对比非常明显------Concepts 写法更短、更直观、更易读。而且当类型不匹配时,Concepts 给出的错误信息比 SFINAE 清晰得多。
当你传入不匹配的类型时,Concepts 给出的错误信息比 SFINAE 清晰得多。
17.6 标准库中的 Concepts
C++20 <concepts> 头文件提供了一组常用 Concepts:
| Concept | 含义 |
|---|---|
std::integral |
整数类型 |
std::floating_point |
浮点类型 |
std::signed_integral |
有符号整数 |
std::unsigned_integral |
无符号整数 |
std::same_as<T, U> |
T 和 U 是相同类型 |
std::derived_from<D, B> |
D 继承自 B |
std::convertible_to<From, To> |
From 可以转换为 To |
std::default_initializable |
可以默认构造 |
std::copyable |
可以拷贝 |
std::movable |
可以移动 |
std::invocable<F, Args...> |
F 可以用 Args 调用 |
17.7 游戏服务器应用
cpp
// 定义游戏消息的 Concept
template<typename T>
concept GameMessage = requires {
{ T::MSG_ID } -> std::convertible_to<uint16_t>;
requires std::is_default_constructible_v<T>;
};
// 消息注册函数:只接受满足 GameMessage 约束的类型
template<GameMessage MsgType>
void RegisterHandler(std::function<void(Connection*, const MsgType&)> handler) {
// 编译期保证 MsgType 有 MSG_ID
handlers_[MsgType::MSG_ID] = /* ... */;
}
// 如果传入不满足条件的类型,错误信息清晰
struct BadType {};
// RegisterHandler<BadType>(...);
// 错误信息:BadType does not satisfy GameMessage
18. 常见陷阱与调试技巧
模板代码的调试难度远高于普通代码。本节总结实际开发中最常踩的坑和应对方法。
18.1 依赖名称与 typename 消歧义
当一个名称依赖于模板参数 时,编译器不知道它是类型还是值。默认情况下编译器会认为它是值,如果实际上是类型,你需要显式加 typename:
cpp
template<typename T>
void foo() {
// T::value_type 依赖于模板参数 T
// 编译器不知道 value_type 是类型还是静态成员变量
// 默认假设是值 → 导致编译错误
// ❌ 编译错误:编译器把 T::value_type 当作值
T::value_type x;
// ✅ 加 typename 告诉编译器:这是一个类型
typename T::value_type x;
// ❌ 常见错误场景:迭代器
T::iterator it = container.begin();
// ✅ 正确写法
typename T::iterator it = container.begin();
}
// C++20 中,在某些上下文(如 using 声明、基类列表)中 typename 可以省略,
// 但在变量声明中仍然需要。建议养成习惯:只要用了依赖于模板参数的嵌套类型,就加 typename。
18.2 依赖名称与 template 消歧义
类似的问题也出现在依赖于模板参数的成员模板调用上:
cpp
template<typename T>
void bar(T& obj) {
// obj.get<int>() 中的 < 会被编译器解析为"小于号"而不是模板参数列表
// ❌ 编译错误
auto val = obj.get<int>();
// ✅ 在成员函数名前加 template 关键字
auto val = obj.template get<int>();
}
// 实际场景:
template<typename Tuple>
void processTuple(Tuple& t) {
// ❌ std::get<0>(t) 在这里没问题(std::get 不依赖于 Tuple)
auto first = std::get<0>(t); // OK
// 但如果是依赖于模板参数的成员模板:
// auto x = t.template get<0>(); // 需要加 template
}
规则总结:当 .、->、:: 后面跟的名称依赖于模板参数 ,并且后面紧跟 < ,就需要加 template 关键字。
18.3 两阶段名称查找(Two-Phase Lookup)
C++ 模板编译分两个阶段:
- 定义阶段:模板被解析时,检查不依赖模板参数的名称(非依赖名称)。
- 实例化阶段:模板被具体类型实例化时,检查依赖模板参数的名称(依赖名称)。
这会导致一个常见的坑------基类成员在派生类模板中"找不到":
cpp
template<typename T>
class Base {
public:
void baseMethod() {}
int baseValue = 42;
};
template<typename T>
class Derived : public Base<T> {
public:
void foo() {
// ❌ 编译错误!编译器在定义阶段找不到 baseMethod
// 因为 Base<T> 依赖于 T,编译器在第一阶段不会去 Base<T> 里查找
baseMethod();
// ✅ 三种修复方式:
// 方式 1:用 this->(推荐,最清晰)
this->baseMethod();
// 方式 2:用基类限定名
Base<T>::baseMethod();
// 方式 3:用 using 声明引入
// 放在类定义开头
}
};
// 如果选择方式 3,在类的开头写:
template<typename T>
class Derived : public Base<T> {
using Base<T>::baseMethod; // 引入基类名称
using Base<T>::baseValue;
public:
void foo() {
baseMethod(); // 现在可以直接用了
}
};
18.4 CTAD------类模板参数推导(C++17)
C++17 引入了类模板参数推导(Class Template Argument Deduction,CTAD),允许从构造函数参数推导模板参数类型,省略尖括号:
cpp
// C++17 之前
std::pair<int, double> p1(1, 3.14);
std::vector<int> v1 = {1, 2, 3};
std::tuple<int, double, std::string> t1(1, 3.14, "hello");
// C++17 CTAD:自动推导
std::pair p2(1, 3.14); // pair<int, double>
std::vector v2 = {1, 2, 3}; // vector<int>
std::tuple t2(1, 3.14, "hello"); // tuple<int, double, const char*>
注意事项 :CTAD 有时候推导的类型不是你想要的。比如上面 "hello" 被推导为 const char* 而不是 std::string。如果需要精确控制类型,还是显式写出模板参数。
自定义类也可以支持 CTAD:
cpp
template<typename T>
class GamePool {
std::vector<T> pool_;
public:
GamePool(std::initializer_list<T> init) : pool_(init) {}
};
// 编译器根据构造函数自动推导
GamePool pool = {1, 2, 3, 4, 5}; // GamePool<int>
// 也可以写自定义推导指引(Deduction Guide)
template<typename Iter>
GamePool(Iter, Iter) -> GamePool<typename std::iterator_traits<Iter>::value_type>;
std::vector<float> src = {1.0f, 2.0f};
GamePool pool2(src.begin(), src.end()); // GamePool<float>
18.5 模板与 ADL(参数依赖查找)
ADL(Argument-Dependent Lookup,又叫 Koenig Lookup)是指:调用函数时,编译器除了在当前作用域查找,还会在参数类型所在的命名空间中查找。这在模板中有时会引发意外行为:
cpp
namespace game {
struct Player { int id; };
// 这个 serialize 函数在 game 命名空间中
void serialize(const Player& p) {
std::cout << "game::serialize(Player)" << std::endl;
}
}
template<typename T>
void process(const T& obj) {
serialize(obj); // ADL:如果 T 是 game::Player,会找到 game::serialize
}
// 调用
game::Player p{1};
process(p); // 通过 ADL 找到 game::serialize ------ 有时候这正是你想要的
// 但如果你在全局也定义了一个 serialize,就可能产生歧义
ADL 在 STL 中被广泛利用(比如 swap、begin、end),但在自己写模板库时需要注意:
cpp
// 最佳实践:自定义 swap 放在类型自己的命名空间中
namespace game {
struct Player { /* ... */ };
void swap(Player& a, Player& b) noexcept {
// 高效交换
}
}
// 模板中调用 swap 的正确方式
template<typename T>
void doSomething(T& a, T& b) {
using std::swap; // 先引入 std::swap 作为后备
swap(a, b); // ADL 会优先找到类型自己命名空间的 swap
}
18.6 模板调试实用技巧汇总
static_assert是你最好的朋友 :在模板入口处用static_assert检查类型约束,给出清晰的错误信息,避免深层的编译错误瀑布。
cpp
template<typename T>
class Buffer {
static_assert(!std::is_pointer_v<T>,
"Buffer<T> 不接受指针类型,请使用 Buffer<PointedType> 并自行管理生命周期");
static_assert(std::is_trivially_copyable_v<T>,
"Buffer<T> 要求 T 可以安全 memcpy,否则请使用 SafeBuffer<T>");
// ...
};
- 打印模板推导结果 :编译期想看
T被推导成什么类型时,故意触发一个编译错误:
cpp
// 辅助工具:不定义这个模板,让编译器报错时显示 T 的类型
template<typename T>
struct TypeDebugger; // 故意不定义
template<typename T>
void myFunc(T&& arg) {
TypeDebugger<T> debug; // 编译器报错信息会显示 T 的实际类型
TypeDebugger<decltype(arg)> d2; // 显示 arg 的实际类型
}
- 从第一个错误开始读 :模板错误往往是瀑布式的,后面的错误大多是连锁反应。重点看第一个错误中的
required from here(触发实例化的位置)和candidate template ignored(编译器尝试了什么但失败了)。 - 分步实例化 :如果模板嵌套很深,先用具体类型手动
typedef中间结果,逐步缩小问题范围:
cpp
// 如果这行报错且难以理解
auto result = Transform<Filter<Map<MyList, Func1>, Pred>, Func2>{};
// 分步展开,逐步定位
using Step1 = Map<MyList, Func1>;
using Step2 = Filter<Step1, Pred>;
using Step3 = Transform<Step2, Func2>; // 哪步报错一目了然
- C++20 Concepts 大幅改善错误信息:如果项目允许使用 C++20,给模板参数加上 Concept 约束后,编译器的错误信息会从"几百行的模板实例化瀑布"变成"类型 X 不满足约束 Y"这种一行就能理解的提示。这是升级到 C++20 的最大动力之一。
19. 综合实战:游戏服务器消息分发系统
这个实战项目把前面学到的所有技术串联起来,实现一个类型安全、编译期注册、零样板代码的消息分发系统。
19.1 设计目标
1. 用 Protobuf-like 的消息结构(带 MSG_ID)
2. 消息 ID → Handler 的映射在编译期自动完成
3. 类型安全:Handler 接收的是具体消息类型,不是 void*
4. 支持批量注册
5. 对外接口简洁
19.2 完整实现
cpp
#include <iostream>
#include <functional>
#include <unordered_map>
#include <memory>
#include <cassert>
#include <cstdint>
#include <string>
// ============================================================
// Part 1: 基础设施
// ============================================================
// 模拟一个网络缓冲区
class Buffer {
std::string data_;
public:
const std::string& data() const { return data_; }
void setData(const std::string& d) { data_ = d; }
};
// 模拟一个网络连接
class Connection {
uint64_t id_;
public:
Connection(uint64_t id) : id_(id) {}
uint64_t getId() const { return id_; }
};
// ============================================================
// Part 2: 消息特征(使用 CRTP + 模板元编程)
// ============================================================
// Concept:定义什么是合法的消息类型
template<typename T>
concept GameMessage = requires {
{ T::MSG_ID } -> std::convertible_to<uint16_t>;
{ T::MSG_NAME } -> std::convertible_to<const char*>;
requires std::is_default_constructible_v<T>;
};
// 消息定义示例
struct LoginReq {
static constexpr uint16_t MSG_ID = 1001;
static constexpr const char* MSG_NAME = "LoginReq";
std::string username;
std::string password;
};
struct LoginRes {
static constexpr uint16_t MSG_ID = 1002;
static constexpr const char* MSG_NAME = "LoginRes";
bool success;
std::string token;
};
struct MoveReq {
static constexpr uint16_t MSG_ID = 2001;
static constexpr const char* MSG_NAME = "MoveReq";
float x, y, z;
};
struct ChatReq {
static constexpr uint16_t MSG_ID = 3001;
static constexpr const char* MSG_NAME = "ChatReq";
std::string channel;
std::string content;
};
// ============================================================
// Part 3: 类型擦除的 Handler 存储
// ============================================================
// 非模板基类(类型擦除的关键)
class IMessageHandler {
public:
virtual void handle(Connection* conn, const void* msgData) = 0;
virtual const char* getMsgName() const = 0;
virtual ~IMessageHandler() = default;
};
// 模板派生类:持有具体类型的处理函数
template<GameMessage MsgType>
class TypedMessageHandler : public IMessageHandler {
using HandlerFunc = std::function<void(Connection*, const MsgType&)>;
HandlerFunc func_;
public:
TypedMessageHandler(HandlerFunc f) : func_(std::move(f)) {}
void handle(Connection* conn, const void* msgData) override {
func_(conn, *static_cast<const MsgType*>(msgData));
}
const char* getMsgName() const override {
return MsgType::MSG_NAME;
}
};
// ============================================================
// Part 4: 消息分发器
// ============================================================
class MessageDispatcher {
std::unordered_map<uint16_t, std::unique_ptr<IMessageHandler>> handlers_;
public:
// 注册单个 Handler(Concept 约束保证类型安全)
template<GameMessage MsgType>
void registerHandler(std::function<void(Connection*, const MsgType&)> handler) {
auto typedHandler = std::make_unique<TypedMessageHandler<MsgType>>(std::move(handler));
std::cout << "[Register] MsgID=" << MsgType::MSG_ID
<< ", Name=" << MsgType::MSG_NAME << std::endl;
handlers_[MsgType::MSG_ID] = std::move(typedHandler);
}
// 分发消息
void dispatch(Connection* conn, uint16_t msgId, const void* msgData) {
auto it = handlers_.find(msgId);
if (it != handlers_.end()) {
it->second->handle(conn, msgData);
} else {
std::cout << "[Warn] No handler for MsgID=" << msgId << std::endl;
}
}
// 批量注册:用折叠表达式展开(变参模板 + fold expression)
template<GameMessage... MsgTypes, typename... Handlers>
void registerAll(std::pair<std::function<void(Connection*, const MsgTypes&)>,
Handlers>... /* 这个方式太复杂,用另一种 */) {}
// 更实用的批量注册方式
template<GameMessage MsgType, typename Handler>
void reg(Handler&& handler) {
registerHandler<MsgType>(
std::function<void(Connection*, const MsgType&)>(
std::forward<Handler>(handler)));
}
bool hasHandler(uint16_t msgId) const {
return handlers_.find(msgId) != handlers_.end();
}
};
// ============================================================
// Part 5: 使用示例
// ============================================================
// 模拟一个游戏服务器类
class GameServer {
MessageDispatcher dispatcher_;
public:
void init() {
// 注册消息处理器
dispatcher_.reg<LoginReq>([this](Connection* conn, const LoginReq& req) {
onLoginReq(conn, req);
});
dispatcher_.reg<MoveReq>([this](Connection* conn, const MoveReq& req) {
onMoveReq(conn, req);
});
dispatcher_.reg<ChatReq>([this](Connection* conn, const ChatReq& req) {
onChatReq(conn, req);
});
}
void onMessage(Connection* conn, uint16_t msgId, const void* data) {
dispatcher_.dispatch(conn, msgId, data);
}
private:
void onLoginReq(Connection* conn, const LoginReq& req) {
std::cout << "[Login] User=" << req.username
<< " from conn " << conn->getId() << std::endl;
}
void onMoveReq(Connection* conn, const MoveReq& req) {
std::cout << "[Move] (" << req.x << ", " << req.y << ", " << req.z
<< ") from conn " << conn->getId() << std::endl;
}
void onChatReq(Connection* conn, const ChatReq& req) {
std::cout << "[Chat] [" << req.channel << "] " << req.content
<< " from conn " << conn->getId() << std::endl;
}
};
// 测试
int main() {
GameServer server;
server.init();
Connection conn(12345);
LoginReq loginReq{"Alice", "password123"};
server.onMessage(&conn, LoginReq::MSG_ID, &loginReq);
MoveReq moveReq{100.0f, 200.0f, 0.0f};
server.onMessage(&conn, MoveReq::MSG_ID, &moveReq);
ChatReq chatReq{"world", "Hello everyone!"};
server.onMessage(&conn, ChatReq::MSG_ID, &chatReq);
// 未注册的消息
server.onMessage(&conn, 9999, nullptr);
return 0;
}
19.3 这个系统用到了哪些技术
| 技术 | 应用位置 |
|---|---|
| 函数模板 | registerHandler<MsgType>()、reg<MsgType>() |
| 类模板 | TypedMessageHandler<MsgType> |
| C++20 Concepts | GameMessage 约束消息类型 |
| 类型擦除 | IMessageHandler 基类 + TypedMessageHandler 派生类 |
| 模板元编程 | MsgType::MSG_ID 编译期消息 ID |
| 完美转发 | std::forward<Handler>(handler) |
| 智能指针 | std::unique_ptr<IMessageHandler> |
20. 总结与学习路径建议
20.1 知识体系全景图
C++ 模板与泛型编程知识体系
│
├── 基础层(必须掌握)
│ ├── 函数模板 / 类模板
│ ├── 模板特化与偏特化
│ ├── 模板编译模型(头文件规则、extern template)
│ ├── 非类型模板参数
│ └── 万能引用与完美转发(std::forward / std::move)
│
├── 类型系统层(必须掌握)
│ ├── type_traits(类型查询与变换)
│ ├── SFINAE / enable_if(编译期条件重载)
│ ├── Tag Dispatch(标签分发)
│ └── std::void_t / declval(表达式检测)
│
├── 现代 C++ 层(强烈推荐)
│ ├── 变参模板(参数包展开)
│ ├── 折叠表达式(C++17)
│ ├── if constexpr(C++17)
│ ├── CTAD 类模板参数推导(C++17)
│ └── Concepts(C++20)
│
├── 设计模式层(重要)
│ ├── CRTP(静态多态)
│ ├── Mixin 模式(功能组合)
│ ├── 类型擦除(统一存储)
│ ├── 模板模板参数与策略模式
│ └── 表达式模板(Expression Templates)
│
├── 高级层(了解即可)
│ ├── 模板元编程 TMP
│ ├── 编译期类型列表
│ └── std::tuple + index_sequence
│
└── 工程实践层(持续积累)
├── 依赖名称消歧义(typename / template)
├── 两阶段名称查找(Two-Phase Lookup)
├── ADL(参数依赖查找)
└── 模板调试技巧(static_assert、TypeDebugger)
20.2 技术选型决策指南
面对具体问题时,应该选哪种模板技术?下表按常见场景给出推荐方案和权衡分析。
编译期分支与条件选择
| 场景 | 推荐技术 | 优势 | 劣势 / 注意事项 |
|---|---|---|---|
| 同一个函数体内,根据类型走不同逻辑 | if constexpr(C++17) |
最简洁直观,一个函数搞定 | 不能改变函数签名(返回类型、参数列表必须统一) |
| 不同类型需要完全不同的函数签名或返回类型 | SFINAE / enable_if(C++17 前);Concepts(C++20) |
可以有不同返回类型、不同参数列表 | SFINAE 语法晦涩;Concepts 需要 C++20 |
| 为特定类型提供完全不同的实现 | 模板全特化 / 偏特化 | 语义明确,每个特化版本独立完整 | 函数模板不支持偏特化;特化版本多了维护成本高 |
根据类型特征(如 is_trivially_copyable)选择算法 |
Tag Dispatch | 可读性好,每个分支是独立函数,易扩展新标签 | 需要定义额外的标签类型;分支数量少时显得过重 |
| 约束模板参数必须满足某些条件 | Concepts(C++20 优先);static_assert(通用);SFINAE(C++17 前) |
Concepts 错误信息最清晰;static_assert 最简单 |
static_assert 不参与重载决议,只能报错不能选择分支 |
类型检测与萃取
| 场景 | 推荐技术 | 优势 | 劣势 / 注意事项 |
|---|---|---|---|
| 查询类型基本属性(是否整数、是否可拷贝等) | 标准库 <type_traits> |
现成可用,经过充分测试 | 只覆盖标准属性,自定义属性需要自己写 |
| 检测类型是否有某个成员函数 / 成员变量 | std::void_t + 偏特化(C++17);requires 表达式(C++20) |
void_t 可检测任意表达式合法性;requires 写法更简洁 |
void_t 模式初学者不易理解 |
| 编译期类型变换(去 const、去引用等) | 标准库 remove_const_t、decay_t 等 |
直接使用,无需自己实现 | 组合变换时嵌套较深,可读性下降 |
| 自定义类型分类(如"是否是游戏消息类型") | 自定义 type_trait(主模板 + 特化),或 Concept | 可复用、可组合 | 手动特化方式需要为每个类型单独写特化 |
多态与代码复用
| 场景 | 推荐技术 | 优势 | 劣势 / 注意事项 |
|---|---|---|---|
| 需要多态且不同类型要放在同一容器中 | 虚函数(运行时多态) | 不同派生类可统一用基类指针管理 | vtable 间接调用开销 + 可能的 cache miss |
| 需要多态但追求极致性能(每帧调用百万次) | CRTP(静态多态) | 编译期绑定,零运行时开销,可内联 | 不同 Base<Derived> 是不同类型,不能放同一容器;不能运行时动态切换 |
| 需要 CRTP 的性能又需要统一存储 | CRTP + 类型擦除(虚基类包一层) | 兼顾编译期优化和运行时灵活性 | 最外层仍有虚函数开销;设计复杂度较高 |
| 给类链式叠加功能(日志 → 加密 → 限频) | Mixin 模式 | 功能可自由组合;每层可拦截和增强基类行为 | 继承链较深时调试困难;编译错误信息可能很长 |
| 编译期注入可替换的策略(分配器、序列化器) | 模板模板参数 + 策略模式 | 零运行时开销;策略间互不依赖,正交组合 | 策略只能在编译期确定,不能运行时切换 |
| 不同模板实例类型需要统一存储和管理 | 类型擦除(非模板基类 + 模板派生类) | 可以把 Handler<A> 和 Handler<B> 放在同一个 map 中 |
需要堆分配(SBO 可缓解);虚函数间接调用开销 |
参数处理与转发
| 场景 | 推荐技术 | 优势 | 劣势 / 注意事项 |
|---|---|---|---|
| 转发参数给下层函数,保持左值 / 右值属性 | 万能引用 T&& + std::forward<T> |
完美保持值类别,零额外拷贝 | 同一对象不能多次 forward;概念较难理解 |
| 处理任意数量、任意类型的参数 | 变参模板 Args... + 折叠表达式(C++17) |
类型安全,编译期展开,支持任意参数组合 | C++11 需用递归展开,写法繁琐;错误信息可能很长 |
| 工厂函数:构造任意类型的对象 | 变参模板 + 完美转发 | 参数直接转发给构造函数,避免中间拷贝 | 需要同时理解万能引用、参数包展开、forward |
编译期计算
| 场景 | 推荐技术 | 优势 | 劣势 / 注意事项 |
|---|---|---|---|
| 编译期计算数值(阶乘、哈希、查找表) | constexpr 函数(优先);模板递归(TMP) |
constexpr 函数写法接近普通代码,易读易维护 |
模板递归可读性极差;只适用于输入在编译期已知的场景 |
| 编译期操作类型列表(遍历、过滤、变换) | std::tuple + std::index_sequence + 折叠表达式 |
标准库支持好,配合 std::apply 很方便 |
嵌套模板多时编译时间显著增加 |
| 编译期关联类型与值(消息类型 → 消息 ID) | 模板特化(MsgTraits<T>)或 Concept + constexpr 静态成员 |
编译期确定映射关系,类型安全 | 每新增一个类型都需要写一个特化或满足 Concept 约束 |
速查:相似技术如何选择
| 对比 | 选 A 的场景 | 选 B 的场景 |
|---|---|---|
if constexpr(A) vs SFINAE(B) |
分支逻辑在一个函数体内、不需要改变签名 | 需要不同的返回类型或参数列表、需要参与重载决议 |
| SFINAE(A) vs Concepts(B) | 项目限制在 C++17 及以下 | 可以用 C++20,追求可读性和清晰的错误信息 |
| CRTP(A) vs 虚函数(B) | 类型在编译期确定、性能敏感、不需要运行时切换 | 类型在运行时才确定、需要统一容器存储、需要动态分发 |
| Mixin(A) vs 策略模式(B) | 需要层层拦截和增强行为(装饰器链) | 策略之间互相独立、只需要组合而不需要链式调用 |
Tag Dispatch(A) vs if constexpr(B) |
分支较多且需要独立函数(可单独测试)、需要可扩展性 | 分支较少、逻辑简单、不想引入额外标签类型 |
| 类型擦除(A) vs 模板参数(B) | 不同类型需要在运行时统一管理(存入同一容器) | 类型在编译期确定、追求零开销 |
std::function(A) vs 函数指针(B) |
需要捕获上下文的 lambda、代码简洁优先 | 性能敏感的热路径(每帧百万次调用)、不需要捕获 |
constexpr 函数(A) vs TMP 模板递归(B) |
任何场景(优先选 A) | 需要操作类型(而非数值)、C++11 且无法用 constexpr |
| 模板特化(A) vs Concept 约束(B) | 需要为特定类型提供完全不同的实现 | 只需要约束类型满足某些条件,不需要改变实现 |
20.3 学习路径建议
模板与泛型编程的知识点繁多且环环相扣,以下是一条推荐的学习路线,按阶段递进:
第一阶段:打好地基(1~2 周)
目标是能写出基本的模板代码、理解编译模型,不再踩"链接失败"的坑。
重点内容:函数模板、类模板、非类型模板参数、模板的默认参数、模板代码为什么必须放在头文件中、显式实例化与 extern template。
练习建议:自己实现一个泛型 Stack<T>;把一个重复了多种类型的工具函数改写成模板;故意把模板定义放在 .cpp 中,观察链接错误并修复。
第二阶段:掌握类型系统工具(2~3 周)
目标是理解编译器如何在编译期做类型判断和条件选择,这是后续所有高级技术的基石。
重点内容:type_traits(is_integral、is_trivially_copyable、remove_const、decay 等)、模板特化与偏特化、SFINAE 的原理、std::enable_if 的三种用法、std::void_t 检测表达式合法性、Tag Dispatch。
练习建议:写一个 WriteToBuffer 函数,对 POD 类型用 memcpy,对非 POD 类型调用 .Serialize() 方法;自己实现 has_begin<T> 检测一个类型有没有 begin() 方法;用 Tag Dispatch 实现一个根据迭代器类别选择不同算法的 MyAdvance 函数。
第三阶段:拥抱现代 C++(2~3 周)
目标是用 C++17/20 的新特性大幅简化模板代码,替代冗长的 SFINAE。
重点内容:万能引用与完美转发、变参模板与参数包展开(递归展开、逗号展开)、折叠表达式(四种形式)、if constexpr 编译期分支、C++20 Concepts(requires 子句与表达式、四种使用语法)。
练习建议:用折叠表达式实现 Sum、Print、AllTrue;把之前用 SFINAE 写的代码改写成 if constexpr 版本和 Concepts 版本,对比可读性;实现一个泛型事件发射器 EventEmitter。
第四阶段:设计模式实战(2~4 周)
目标是学会在真实项目中选择和组合模板设计模式。
重点内容:CRTP(静态多态、实例计数器、静态接口强制)、Mixin 模式(功能链式组合)、类型擦除(IHandler + TypedHandler 模式、手写 SimpleFunction)、完美转发与工厂函数、模板模板参数与策略模式。
练习建议:用 CRTP 实现一个带独立计数的对象池 ObjectPool<T>;用 Mixin 模式给一个网络连接类组合上日志、加密、限频功能;手写一个简化版的 std::function,理解类型擦除的精髓。
第五阶段:高级主题与综合(按需)
这一阶段的内容更偏底层和框架层,日常业务开发中使用频率较低,但在写高性能框架时非常有价值。
重点内容:模板元编程(编译期计算、编译期类型列表)、std::tuple + std::index_sequence、std::apply、Expression Templates(了解即可)。
练习建议:实现本文第 19 节的"游戏服务器消息分发系统",把所有技术串联起来;在自己的项目中找一个合适的场景(如序列化框架、ECS 组件注册),尝试用模板技术重构。
通用学习建议:
- 每学一个技术点,都动手写代码验证,不要只看不写。模板的很多细节(比如 SFINAE 的匹配规则、折叠表达式的展开方向)只有自己写了才能真正理解。
- 善用
static_assert做编译期检查,它是调试模板代码的利器。 - 善用编译器的错误信息。GCC 和 Clang 的模板错误信息虽然长,但其中
required from here和candidate template ignored是关键线索。 - 优先选择更简单的方案:能用
if constexpr就不用 SFINAE,能用 Concepts 就不用enable_if,能用constexpr函数就不用模板递归。 - 模板是工具,不是目的。在业务逻辑中保持简单直接,只在框架和基础设施层面使用复杂的模板技术。过度使用模板会严重降低代码的可读性和可维护性。