C++ 第七阶段:模板、泛型编程与工程综合详解
本阶段目标:理解 C++ 模板与泛型编程的核心思想,掌握函数模板、类模板、模板参数推导、非类型模板参数、模板特化、可变参数模板、类型萃取、
if constexpr、模板分离编译等常用知识,并能够把前面六个阶段学习的 RAII、STL、多态、多线程和现代 C++ 特性组合成可复用的工程组件。
一、第七阶段在整个学习路线中的位置
完整的 C++ 系统补强路线分为七个阶段:
- 指针、引用、
const、栈与堆、对象生命周期、构造与析构; - RAII、异常安全、智能指针、移动语义、Rule of Zero;
- STL 容器、迭代器、Lambda 与算法;
- 继承、多态、虚函数、虚析构、抽象接口;
- 多线程、锁、条件变量、原子操作与线程同步;
- 现代 C++ 常用特性、异常体系、IO 流与工程工具;
- 模板、泛型编程、模板进阶与工程综合。
前六个阶段主要解决:
- 一个对象如何正确创建和销毁;
- 一个资源如何自动释放;
- 数据如何保存在容器中;
- 如何使用算法处理数据;
- 如何通过抽象接口解耦模块;
- 多线程如何安全协作;
- 如何使用现代 C++ 类型表达程序语义。
第七阶段进一步解决:
如何编写一份与具体类型无关、能够被多种类型复用,同时仍然保持类型安全和运行效率的代码。
例如,下面三个函数的逻辑完全相同:
cpp
int Max(int left, int right)
{
return left > right
? left
: right;
}
double Max(double left, double right)
{
return left > right
? left
: right;
}
std::string Max(
const std::string& left,
const std::string& right
)
{
return left > right
? left
: right;
}
如果为每种类型都重新写一遍:
- 代码重复;
- 修改时容易遗漏;
- 类型越多,重载越多;
- 通用逻辑无法复用。
模板允许把"类型"变成参数:
cpp
template<typename T>
const T& Max(
const T& left,
const T& right
)
{
return left > right
? left
: right;
}
调用:
cpp
int intResult = Max(10, 20);
double doubleResult = Max(1.5, 2.5);
std::string first = "Alice";
std::string second = "Bob";
const std::string& stringResult =
Max(first, second);
编译器会根据实际类型生成对应函数。
二、第七阶段的学习目标
完成本阶段后,应当能够掌握:
模板基础
- 泛型编程是什么;
- 函数模板;
- 类模板;
- 模板参数;
- 模板实例化;
- 模板参数推导;
- 显式指定模板参数;
- 普通函数与函数模板的关系;
- 模板重载。
模板进阶
- 类型模板参数;
- 非类型模板参数;
- 模板模板参数;
- 默认模板参数;
- 全特化;
- 偏特化;
- 函数模板为什么通常不使用偏特化;
- 模板分离编译;
- 显式实例化;
- 依赖名称;
typename与template关键字。
现代泛型工具
decltypeautostd::movestd::forward- 完美转发
std::enable_if- SFINAE
- 类型萃取
std::is_samestd::is_integralstd::is_pointerstd::decaystd::remove_referenceif constexpr- 可变参数模板
- 参数包展开
- 折叠表达式
STL 泛型设计
- 容器、迭代器和算法为什么能够解耦;
- 比较器和函数对象;
- 策略参数;
- 分配器的基本作用;
- 内存分配与对象构造为什么要分开。
综合工程
- 如何判断一段代码是否值得模板化;
- 如何避免过度模板化;
- 如何控制模板接口复杂度;
- 如何编写可读的模板错误信息;
- 如何设计一个泛型线程安全队列;
- 如何把日志、配置和异步处理模块组合起来。
三、什么是泛型编程
泛型编程的核心不是"少写几个函数",而是:
把算法中真正变化的部分抽象为参数,把不变的逻辑保留下来。
例如,一个查找算法通常只关心:
text
从哪里开始?
在哪里结束?
如何判断目标?
它不一定关心底层容器是:
vectorlistdeque- 数组
- 自定义容器
因此 STL 的算法一般接收迭代器区间:
cpp
std::find(
container.begin(),
container.end(),
target
);
而不是直接接收某个特定容器:
cpp
FindInVector(vector, target);
FindInList(list, target);
FindInDeque(deque, target);
泛型编程常见的三类参数包括:
- 类型参数
- 值参数
- 行为参数
例如:
cpp
template<
typename T,
std::size_t Capacity,
typename Compare
>
class Container
{
};
其中:
T是元素类型;Capacity是编译期容量;Compare是比较策略。
四、模板不是宏
模板和宏都能减少重复,但本质完全不同。
宏:
cpp
#define MAX_VALUE(a, b) \
((a) > (b) ? (a) : (b))
问题包括:
- 没有类型检查;
- 只是文本替换;
- 参数可能被计算多次;
- 错误信息不清晰;
- 容易受运算符优先级影响。
例如:
cpp
int value = MAX_VALUE(++a, ++b);
宏展开后,a 或 b 可能被递增多次。
模板:
cpp
template<typename T>
const T& Max(
const T& left,
const T& right
)
{
return left > right
? left
: right;
}
模板具有:
- 类型检查;
- 正常作用域;
- 正常函数语义;
- 编译器生成类型专用版本;
- 更可靠的错误检查。
因此:
模板是编译期泛型机制,不是文本替换。
五、函数模板
5.1 基本语法
cpp
template<typename T>
T Add(T left, T right)
{
return left + right;
}
也可以写:
cpp
template<class T>
T Add(T left, T right)
{
return left + right;
}
在模板参数列表中:
cpp
typename
与:
cpp
class
通常等价。
现代代码中常用 typename,因为它更准确地表达:
text
这里需要的是一个类型。
5.2 模板参数是什么
cpp
template<typename T>
表示声明了一个类型参数 T。
调用:
cpp
Add(10, 20);
编译器推导:
cpp
T = int
相当于生成:
cpp
int Add<int>(int left, int right)
{
return left + right;
}
调用:
cpp
Add(1.5, 2.5);
推导:
cpp
T = double
相当于生成:
cpp
double Add<double>(
double left,
double right
);
5.3 模板实例化
模板本身不是普通函数。
只有在具体类型被使用时,编译器才会根据模板生成对应实体,这个过程称为:
text
模板实例化
例如:
cpp
template<typename T>
T Square(T value)
{
return value * value;
}
如果程序只调用:
cpp
Square(10);
Square(1.5);
通常只需要生成:
cpp
Square<int>
Square<double>
不会自动生成所有可能类型的版本。
5.4 隐式实例化
cpp
int result = Add(10, 20);
编译器自动推导类型并生成模板实例,称为隐式实例化。
5.5 显式指定模板参数
cpp
double result =
Add<double>(10, 20.5);
这里明确指定:
cpp
T = double
参数 10 会转换为 double。
5.6 参数推导失败
cpp
template<typename T>
T Add(T left, T right);
调用:
cpp
Add(10, 2.5);
第一个参数推导:
cpp
T = int
第二个参数推导:
cpp
T = double
同一个 T 得到了两个不同结果,因此模板参数推导失败。
可以显式指定:
cpp
Add<double>(10, 2.5);
也可以设计两个类型参数:
cpp
template<
typename Left,
typename Right
>
auto Add(
const Left& left,
const Right& right
)
{
return left + right;
}
调用:
cpp
auto result = Add(10, 2.5);
返回类型由表达式:
cpp
left + right
推导。
5.7 返回类型推导
C++14 允许:
cpp
template<
typename Left,
typename Right
>
auto Add(
const Left& left,
const Right& right
)
{
return left + right;
}
C++11 中可以使用尾置返回类型:
cpp
template<
typename Left,
typename Right
>
auto Add(
const Left& left,
const Right& right
)
-> decltype(left + right)
{
return left + right;
}
尾置返回类型的价值在于:
在返回类型出现的位置,函数参数名称还不能直接使用;把返回类型放到参数列表之后,就可以使用
left和right。
5.8 按值还是按引用
简单写法:
cpp
template<typename T>
T Max(T left, T right);
会复制参数。
对于大型对象,可能产生额外成本。
只读参数通常可以写:
cpp
template<typename T>
const T& Max(
const T& left,
const T& right
)
{
return left > right
? left
: right;
}
但必须注意返回引用的生命周期。
错误:
cpp
const int& value = Max(10, 20);
10 和 20 是临时对象。
函数返回其中一个临时对象的引用,完整表达式结束后引用悬空。
因此"返回 const T&"并不是永远正确。
更稳妥的通用写法可能是按值返回:
cpp
template<typename T>
T Max(
const T& left,
const T& right
)
{
return left > right
? left
: right;
}
现代编译器通常能够通过移动或返回值优化降低复制成本。
设计原则
模板接口不能只看性能,还要看:
- 参数是否可能是临时对象;
- 返回值是否需要独立生命周期;
- 类型是否支持廉价复制;
- 是否能够安全返回引用。
六、函数模板与普通函数
6.1 普通函数优先
cpp
template<typename T>
void Print(T value)
{
std::cout
<< "template: "
<< value
<< '\n';
}
void Print(int value)
{
std::cout
<< "normal: "
<< value
<< '\n';
}
调用:
cpp
Print(10);
通常会优先选择普通函数:
cpp
Print(int)
因为它是完全匹配的非模板函数。
6.2 显式调用模板版本
cpp
Print<>(10);
或者:
cpp
Print<int>(10);
可以明确要求使用模板。
6.3 模板也可以重载
cpp
template<typename T>
void Print(const T& value)
{
}
template<typename T>
void Print(T* value)
{
}
调用指针参数时,编译器会根据重载规则选择更匹配的模板。
模板重载规则可能变得复杂,因此不应设计大量难以区分的模板重载。
七、类模板
7.1 基本语法
cpp
template<typename T>
class Box
{
public:
explicit Box(T value)
: m_value(
std::move(value)
)
{
}
const T& Value() const noexcept
{
return m_value;
}
private:
T m_value;
};
使用:
cpp
Box<int> intBox(10);
Box<std::string> stringBox(
"hello"
);
类模板在 C++17 之前通常必须明确写出模板参数:
cpp
Box<int>
7.2 类模板不是一个具体类
cpp
Box
只是一个模板名称。
cpp
Box<int>
Box<double>
Box<std::string>
是三个不同的具体类型。
它们之间默认没有继承关系,也不能相互转换。
7.3 类模板成员函数写在类外
cpp
template<typename T>
class Box
{
public:
explicit Box(T value);
const T& Value() const noexcept;
private:
T m_value;
};
类外定义:
cpp
template<typename T>
Box<T>::Box(T value)
: m_value(
std::move(value)
)
{
}
template<typename T>
const T& Box<T>::Value() const noexcept
{
return m_value;
}
需要同时写:
cpp
template<typename T>
和:
cpp
Box<T>::
因为成员函数本身属于类模板的某个实例。
7.4 多个模板参数
cpp
template<
typename Key,
typename Value
>
class Pair
{
public:
Pair(
Key key,
Value value
)
: m_key(std::move(key)),
m_value(std::move(value))
{
}
private:
Key m_key;
Value m_value;
};
使用:
cpp
Pair<std::string, int> user(
"Alice",
18
);
7.5 默认模板参数
cpp
template<
typename T,
typename Container =
std::vector<T>
>
class Stack
{
private:
Container m_items;
};
默认:
cpp
Stack<int>
等价于:
cpp
Stack<
int,
std::vector<int>
>
也可以换成:
cpp
Stack<
int,
std::deque<int>
>
这是一种策略注入:
栈的行为不变,但底层容器可以替换。
八、类模板参数推导
C++17 支持类模板参数推导,简称 CTAD。
cpp
std::pair pair(10, 2.5);
编译器推导为:
cpp
std::pair<int, double>
自定义类也可以推导:
cpp
template<typename T>
class Box
{
public:
explicit Box(T value)
: m_value(std::move(value))
{
}
private:
T m_value;
};
Box box(10);
推导:
cpp
Box<int>
8.1 推导指引
某些类无法仅靠构造函数正确推导,可以写推导指引:
cpp
template<typename T>
class Box
{
public:
Box(T begin, T end);
};
template<typename Iterator>
Box(Iterator, Iterator)
-> Box<
typename std::iterator_traits<
Iterator
>::value_type
>;
推导指引属于进阶知识,理解其用途即可:
告诉编译器,看到某种构造参数时,应当推导出什么类模板参数。
普通业务代码不需要频繁手写推导指引。
九、非类型模板参数
模板参数不一定是类型,也可以是编译期常量。
cpp
template<
typename T,
std::size_t N
>
class StaticArray
{
public:
constexpr std::size_t Size() const noexcept
{
return N;
}
T& operator[](
std::size_t index
)
{
return m_data[index];
}
private:
T m_data[N]{};
};
使用:
cpp
StaticArray<int, 10> values;
其中:
T是类型参数;N是非类型模板参数。
9.1 不同容量是不同类型
cpp
StaticArray<int, 10>
StaticArray<int, 20>
是两个不同类型。
即使元素类型都为 int,也不能直接赋值。
9.2 为什么使用非类型模板参数
非类型模板参数适合表达:
- 固定容量;
- 固定维度;
- 对齐大小;
- 编译期标志;
- 策略常量;
- 数组长度。
例如:
cpp
std::array<int, 10>
第二个参数就是非类型模板参数。
9.3 编译期容量与运行时容量
cpp
std::array<int, 10>
容量属于类型的一部分,在编译期确定。
cpp
std::vector<int>
容量由运行时动态决定。
选择依据:
| 需求 | 选择 |
|---|---|
| 容量固定且较小 | std::array |
| 容量运行时变化 | std::vector |
| 栈上固定缓冲区 | std::array |
| 数据量可能很大 | 通常 std::vector |
十、模板模板参数
模板参数本身也可以是一个模板。
cpp
template<
typename T,
template<
typename,
typename
>
class Container
>
class Manager
{
private:
Container<
T,
std::allocator<T>
> m_items;
};
使用:
cpp
Manager<int, std::vector>
模板模板参数语法复杂,实际工程中使用频率不如:
cpp
template<
typename T,
typename Container =
std::vector<T>
>
直接传入完整容器类型往往更简单。
推荐优先设计为:
cpp
template<
typename T,
typename Container
>
只有确实需要"接收一个模板"时,才使用模板模板参数。
十一、模板特化
有时通用模板对大多数类型有效,但某些类型需要特殊处理。
11.1 主模板
cpp
template<typename T>
struct TypeName
{
static const char* Get()
{
return "unknown";
}
};
11.2 全特化
cpp
template<>
struct TypeName<int>
{
static const char* Get()
{
return "int";
}
};
调用:
cpp
TypeName<double>::Get();
使用主模板。
cpp
TypeName<int>::Get();
使用 int 的全特化版本。
11.3 类模板偏特化
cpp
template<typename T>
struct TypeInfo
{
static constexpr bool isPointer =
false;
};
针对所有指针类型偏特化:
cpp
template<typename T>
struct TypeInfo<T*>
{
static constexpr bool isPointer =
true;
};
使用:
cpp
TypeInfo<int>::isPointer;
TypeInfo<int*>::isPointer;
TypeInfo<std::string*>::isPointer;
偏特化不是只针对某一个完整类型,而是针对一类类型模式。
11.4 多参数偏特化
cpp
template<
typename First,
typename Second
>
struct IsSame
{
static constexpr bool value = false;
};
两个类型相同时:
cpp
template<typename T>
struct IsSame<T, T>
{
static constexpr bool value = true;
};
这就是标准库:
cpp
std::is_same
背后的基本思想。
11.5 函数模板没有偏特化
函数模板可以:
- 全特化;
- 重载。
但不支持类模板那样的偏特化。
实际工程中,函数模板通常通过:
- 重载;
enable_if;if constexpr;- 辅助类模板特化
实现不同类型的差异化处理。
11.6 不要滥用特化
如果只是简单分支:
cpp
if constexpr (...)
可能比设计多个特化更清楚。
特化适合:
- 类型模式差异明显;
- 需要为一整类类型改变实现;
- 类型萃取;
- 序列化策略;
- 平台适配。
十二、typename 的两个作用
12.1 声明模板类型参数
cpp
template<typename T>
12.2 告诉编译器依赖名称是类型
cpp
template<typename Container>
void PrintFirst(
const Container& container
)
{
typename Container::const_iterator it =
container.begin();
}
为什么需要 typename?
因为:
cpp
Container::const_iterator
依赖模板参数 Container。
在模板真正实例化之前,编译器无法确定它是:
- 类型;
- 静态成员;
- 其他名称。
因此需要明确告诉编译器:
text
这是一个类型。
现代代码中可以使用 auto 简化:
cpp
auto it = container.begin();
但仍然必须理解依赖类型和 typename,因为模板库代码中经常出现。
十三、依赖模板名称与 template 关键字
考虑:
cpp
template<typename T>
void Call(T& object)
{
object.Execute<int>();
}
如果 Execute 是否为模板依赖于 T,某些情况下需要写:
cpp
object.template Execute<int>();
这里的 template 告诉编译器:
text
Execute 是一个成员模板,
后面的 <int> 是模板参数,
不是小于号表达式。
这属于模板语法中的解析问题。
日常代码中不一定经常手写,但阅读泛型库时必须能识别。
十四、模板分离编译
14.1 普通类为什么可以声明和定义分离
头文件:
cpp
class Session
{
public:
void Run();
};
源文件:
cpp
void Session::Run()
{
}
编译 Session.cpp 时,编译器可以直接生成 Session::Run 的机器代码。
14.2 模板为什么通常放在头文件
头文件:
cpp
template<typename T>
T Add(T left, T right);
源文件:
cpp
template<typename T>
T Add(T left, T right)
{
return left + right;
}
其他源文件调用:
cpp
int result = Add(10, 20);
编译调用文件时,编译器知道需要:
cpp
Add<int>
但如果看不到模板定义,就无法生成 Add<int>。
因此模板通常需要:
在实例化位置能够看到完整定义。
常见做法:
text
Add.h
同时包含声明和定义。
也可以使用:
text
Add.hpp
表示这是带实现的模板头文件。
14.3 .tpp 文件
可以把实现放到单独的 .tpp:
cpp
// Box.h
template<typename T>
class Box
{
public:
void Print() const;
};
#include "Box.tpp"
cpp
// Box.tpp
template<typename T>
void Box<T>::Print() const
{
}
本质仍然是:
text
通过 include 让模板定义出现在头文件中。
.tpp 不是特殊语言机制,只是工程组织习惯。
14.4 显式实例化
如果只支持少数固定类型,可以在源文件中显式实例化。
头文件:
cpp
template<typename T>
class Box
{
public:
void Print() const;
};
源文件:
cpp
template<typename T>
void Box<T>::Print() const
{
}
template class Box<int>;
template class Box<double>;
这样源文件明确生成:
cpp
Box<int>
Box<double>
其他类型没有显式实例化,也看不到定义时不能使用。
14.5 extern template
头文件:
cpp
extern template class Box<int>;
表示:
text
不要在当前编译单元重复实例化,
实例会由其他地方提供。
源文件:
cpp
template class Box<int>;
这可以减少重复实例化和编译开销。
普通业务项目不一定需要,但大型模板库可能使用。
十五、类型萃取
类型萃取用于在编译期获取类型信息。
标准库头文件:
cpp
#include <type_traits>
15.1 std::is_same
cpp
static_assert(
std::is_same_v<int, int>
);
static_assert(
!std::is_same_v<int, double>
);
C++11 写法:
cpp
std::is_same<int, int>::value
C++17 提供 _v 简写:
cpp
std::is_same_v<int, int>
15.2 std::is_integral
cpp
static_assert(
std::is_integral_v<int>
);
static_assert(
!std::is_integral_v<double>
);
判断是否为整数类型。
15.3 std::is_floating_point
cpp
std::is_floating_point_v<double>
15.4 std::is_pointer
cpp
std::is_pointer_v<int*>
15.5 std::is_enum
cpp
std::is_enum_v<LogLevel>
15.6 std::is_base_of
cpp
std::is_base_of_v<Base, Derived>
判断:
text
Derived 是否派生自 Base。
15.7 std::is_copy_constructible
cpp
std::is_copy_constructible_v<T>
可以检查类型是否支持拷贝构造。
15.8 std::is_move_constructible
cpp
std::is_move_constructible_v<T>
15.9 std::remove_reference
cpp
using Type =
std::remove_reference_t<int&>;
结果:
cpp
int
15.10 std::remove_const
cpp
using Type =
std::remove_const_t<const int>;
结果:
cpp
int
15.11 std::decay
std::decay_t<T> 会进行类似按值传参的类型退化:
- 去掉引用;
- 去掉顶层
const和volatile; - 数组退化为指针;
- 函数退化为函数指针。
cpp
using Type =
std::decay_t<const int&>;
结果:
cpp
int
十六、自定义类型萃取
可以自己实现一个简化版 is_pointer。
主模板:
cpp
template<typename T>
struct IsPointer
{
static constexpr bool value =
false;
};
指针偏特化:
cpp
template<typename T>
struct IsPointer<T*>
{
static constexpr bool value =
true;
};
使用:
cpp
static_assert(
!IsPointer<int>::value
);
static_assert(
IsPointer<int*>::value
);
C++17 可以提供变量模板:
cpp
template<typename T>
inline constexpr bool IsPointerV =
IsPointer<T>::value;
使用:
cpp
static_assert(
IsPointerV<int*>
);
十七、enable_if 与 SFINAE
17.1 什么是 SFINAE
SFINAE 的全称是:
text
Substitution Failure Is Not An Error
意思是:
模板参数替换失败时,不一定立即报错,而是把当前候选模板从重载集合中移除。
17.2 只允许整数类型调用
cpp
template<
typename T,
typename =
std::enable_if_t<
std::is_integral_v<T>
>
>
T Double(T value)
{
return value * 2;
}
调用:
cpp
Double(10);
可以。
cpp
Double(1.5);
不满足 is_integral,模板会被排除。
17.3 返回类型中使用 enable_if
cpp
template<typename T>
std::enable_if_t<
std::is_integral_v<T>,
T
>
Double(T value)
{
return value * 2;
}
17.4 SFINAE 的问题
虽然功能强大,但常见问题是:
- 语法复杂;
- 错误信息长;
- 多个条件容易难以维护;
- 重载解析不直观。
C++17 的 if constexpr 可以简化很多"实现内部差异"。
C++20 的 Concepts 能进一步改善接口约束,但本路线以 C++17 为主,因此 Concepts 只需要了解。
十八、if constexpr
cpp
template<typename T>
void PrintValue(const T& value)
{
if constexpr (
std::is_pointer_v<T>
)
{
if (value != nullptr)
{
std::cout << *value;
}
else
{
std::cout << "null";
}
}
else
{
std::cout << value;
}
}
调用:
cpp
int number = 10;
int* pointer = &number;
PrintValue(number);
PrintValue(pointer);
18.1 与普通 if 的区别
普通 if:
cpp
if (std::is_pointer_v<T>)
{
std::cout << *value;
}
else
{
std::cout << value;
}
即使条件在编译期固定,两个分支通常都需要是合法代码。
当 T = int 时:
cpp
*value
不合法,可能导致编译错误。
if constexpr 会在编译期丢弃未选中的分支,因此可以编写不同类型专用逻辑。
18.2 适合使用 if constexpr 的场景
- 指针与非指针;
- 整数与浮点数;
- 容器与普通类型;
- 是否支持某个成员函数;
- 序列化不同类型;
- 模板递归终止;
- 可变参数处理。
十九、可变参数模板
可变参数模板可以接收任意数量的模板参数。
cpp
template<typename... Args>
void PrintAll(Args... args)
{
}
其中:
cpp
Args...
是模板参数包。
cpp
args...
是函数参数包。
19.1 参数包大小
cpp
template<typename... Args>
constexpr std::size_t Count(
Args&&...
)
{
return sizeof...(Args);
}
调用:
cpp
Count();
Count(1);
Count(1, 2.5, "hello");
结果分别是:
text
0
1
3
19.2 递归展开
C++11 常见写法:
cpp
void Print()
{
std::cout << '\n';
}
template<
typename First,
typename... Rest
>
void Print(
First&& first,
Rest&&... rest
)
{
std::cout
<< std::forward<First>(
first
);
if constexpr (
sizeof...(Rest) > 0
)
{
std::cout << ", ";
}
Print(
std::forward<Rest>(
rest
)...
);
}
调用:
cpp
Print(
10,
2.5,
"hello"
);
19.3 折叠表达式
C++17 提供折叠表达式。
求和:
cpp
template<typename... Args>
auto Sum(Args... args)
{
return (args + ...);
}
调用:
cpp
auto result = Sum(1, 2, 3, 4);
相当于:
cpp
1 + (2 + (3 + 4))
左折叠
cpp
(... + args)
相当于:
cpp
((arg1 + arg2) + arg3) + arg4
右折叠
cpp
(args + ...)
相当于:
cpp
arg1 + (arg2 + (arg3 + arg4))
对于加法通常结果相同,但对于减法、除法、字符串拼接等操作,方向可能影响结果。
19.4 使用折叠表达式打印
cpp
template<typename... Args>
void PrintLine(Args&&... args)
{
(
std::cout
<< ...
<< std::forward<Args>(args)
);
std::cout << '\n';
}
调用:
cpp
PrintLine(
"id=",
10,
", name=",
"Alice"
);
二十、右值引用与完美转发
第二阶段已经学习过移动语义,第七阶段需要从模板角度进一步理解。
20.1 普通右值引用
cpp
void Process(std::string&& text);
它只接受右值字符串。
20.2 转发引用
cpp
template<typename T>
void Forward(T&& value);
当 T 由模板参数推导,并且参数形式是:
cpp
T&&
时,它可能是转发引用。
调用左值:
cpp
std::string text = "hello";
Forward(text);
推导后:
cpp
T = std::string&
根据引用折叠:
cpp
T&&
= std::string& &&
= std::string&
调用右值:
cpp
Forward(std::string{"hello"});
推导:
cpp
T = std::string
参数类型:
cpp
std::string&&
20.3 引用折叠规则
text
T& & -> T&
T& && -> T&
T&& & -> T&
T&& && -> T&&
只要其中有一个左值引用,最终通常就是左值引用。
20.4 为什么需要 std::forward
cpp
template<typename T>
void Wrapper(T&& value)
{
Process(value);
}
即使调用者传入右值,函数内部变量:
cpp
value
只要有名字,它本身就是左值表达式。
因此:
cpp
Process(value);
会把它当作左值传递。
正确:
cpp
template<typename T>
void Wrapper(T&& value)
{
Process(
std::forward<T>(value)
);
}
std::forward<T> 会根据原始推导类型:
- 原来是左值,继续作为左值;
- 原来是右值,继续作为右值。
这就是完美转发。
20.5 std::move 与 std::forward
std::move:
text
无条件把表达式转换为右值。
std::forward<T>:
text
根据 T 保留调用者原来的值类别。
普通移动操作使用:
cpp
std::move
模板转发参数使用:
cpp
std::forward
二十一、emplace 的模板原理
cpp
vector.emplace_back(
arg1,
arg2
);
容器内部大致需要:
cpp
template<typename... Args>
T& EmplaceBack(Args&&... args)
{
// 在容器管理的内存中直接构造 T
new (address) T(
std::forward<Args>(args)...
);
}
这里组合了:
- 可变参数模板;
- 转发引用;
- 完美转发;
- placement new;
- 容器内存管理。
这说明第七阶段不是孤立的新知识,而是在解释 STL 和智能指针背后的机制。
二十二、placement new
普通 new 完成两件事:
- 申请内存;
- 在内存中构造对象。
placement new 只负责:
在已经存在的内存地址上构造对象。
cpp
void* memory =
::operator new(sizeof(LogItem));
LogItem* item =
new (memory) LogItem(
1,
"hello"
);
销毁时需要显式调用析构:
cpp
item->~LogItem();
然后释放原始内存:
cpp
::operator delete(memory);
22.1 为什么要分开
容器扩容时,通常会:
- 一次申请一大块原始内存;
- 根据需要逐个构造元素;
- 删除元素时只销毁对象;
- 容器整体释放时再释放整块内存。
如果每个元素都单独 new:
- 分配次数多;
- 内存碎片多;
- 连续内存无法保证;
- 性能较差。
因此 STL 会把:
text
内存分配
和:
text
对象构造
分离。
二十三、分配器基本概念
标准容器通常有分配器模板参数。
cpp
template<
typename T,
typename Allocator =
std::allocator<T>
>
class vector;
默认使用:
cpp
std::allocator<T>
分配器负责为容器提供原始内存相关能力。
现代标准中的分配器接口较复杂,普通业务开发不需要自己实现完整分配器。
需要理解的核心是:
text
容器管理元素逻辑;
分配器管理原始内存来源。
23.1 简化理解
容器可能执行:
text
申请足够存放 N 个 T 的原始内存
↓
在指定位置构造 T
↓
调用 T 的析构函数
↓
释放原始内存
这些步骤分别对应:
- 内存获取;
- 对象构造;
- 对象销毁;
- 内存释放。
23.2 为什么很少自定义分配器
完整分配器需要考虑:
- 对齐;
- 异常安全;
- 容器复制和移动;
- 分配器传播规则;
- 不同类型重新绑定;
- 标准库兼容要求;
- 内存资源生命周期。
因此本阶段目标不是复刻 STL 分配器,而是理解其设计动机。
二十四、内存池与空间配置器
频繁分配大量小对象时,直接调用系统堆可能带来:
- 分配和释放开销;
- 内存碎片;
- 锁竞争;
- 缓存局部性差。
内存池通常会:
- 一次申请较大的内存块;
- 切分成多个固定大小或分级大小的小块;
- 使用自由链表记录空闲块;
- 小对象优先从池中获取;
- 释放时归还到池中,而不是立刻归还操作系统。
24.1 一级与二级配置器思想
一些经典 STL 实现会把分配策略分为:
一级配置器
处理大块内存,直接调用系统分配函数。
二级配置器
处理小块内存,通过内存池和自由链表减少系统调用。
需要理解的只是设计思想,不要求背具体阈值和实现细节。
24.2 自由链表
假设按大小维护多个空闲链表:
text
8 字节空闲块链表
16 字节空闲块链表
24 字节空闲块链表
...
申请 16 字节时:
text
从 16 字节链表取出一个节点。
释放 16 字节时:
text
把节点放回 16 字节链表。
这样能减少反复调用系统堆。
24.3 内存池的代价
内存池并非总是更好。
它可能带来:
- 内存长期占用;
- 实现复杂;
- 多线程同步成本;
- 调试困难;
- 对象生命周期管理复杂;
- 对齐问题;
- 不同大小对象管理复杂。
因此只有在:
- 性能分析证明分配是热点;
- 对象大小和生命周期模式稳定;
- 高频小对象分配明显
时才值得使用。
不要因为"内存池高级"就提前实现。
二十五、STL 的泛型设计思想
STL 的核心组成:
text
容器
迭代器
算法
函数对象
适配器
分配器
25.1 容器只负责保存数据
例如:
cpp
std::vector<int>
std::list<int>
std::map<std::string, int>
25.2 迭代器连接容器和算法
算法不需要知道容器内部结构,只处理迭代器区间。
cpp
std::sort(
values.begin(),
values.end()
);
25.3 算法接收行为参数
cpp
std::sort(
users.begin(),
users.end(),
[](const User& left,
const User& right)
{
return left.age < right.age;
}
);
比较规则通过模板参数传入。
25.4 静态多态
继承和虚函数属于运行时多态:
cpp
ILogSink* sink;
sink->Write(item);
模板通常属于编译期多态,也称静态多态:
cpp
template<typename Sink>
void WriteLog(
Sink& sink,
const LogItem& item
)
{
sink.Write(item);
}
编译器根据 Sink 的具体类型生成代码。
25.5 静态多态与动态多态
| 对比 | 模板静态多态 | 虚函数动态多态 |
|---|---|---|
| 决定时机 | 编译期 | 运行时 |
| 扩展类型 | 编译时已知 | 可运行时替换 |
| 调用开销 | 通常可内联 | 通常间接调用 |
| 二进制稳定性 | 模板实现暴露 | 接口可隐藏实现 |
| 编译速度 | 可能较慢 | 通常较稳定 |
| 错误信息 | 可能复杂 | 通常较直接 |
| 代码体积 | 可能膨胀 | 相对集中 |
两者不是互相替代。
工程中经常组合使用:
- 外部模块边界使用虚函数接口;
- 模块内部性能敏感组件使用模板;
- 固定类型集合使用
variant; - 简单策略使用函数对象。
二十六、策略模板
模板可以把某种行为作为类型参数。
cpp
struct Ascending
{
bool operator()(
int left,
int right
) const noexcept
{
return left < right;
}
};
struct Descending
{
bool operator()(
int left,
int right
) const noexcept
{
return left > right;
}
};
容器:
cpp
template<
typename T,
typename Compare
>
class SortedBuffer
{
public:
void Add(T value)
{
m_items.push_back(
std::move(value)
);
std::sort(
m_items.begin(),
m_items.end(),
m_compare
);
}
private:
std::vector<T> m_items;
Compare m_compare;
};
使用:
cpp
SortedBuffer<int, Ascending>
ascending;
SortedBuffer<int, Descending>
descending;
这种设计称为策略模式的编译期形式。
二十七、检测类型能力
在泛型代码中,有时需要判断类型是否支持某个操作。
C++17 可以结合 std::void_t 实现检测。
27.1 检测 Size() 成员函数
主模板:
cpp
template<
typename T,
typename = void
>
struct HasSize
: std::false_type
{
};
特化:
cpp
template<typename T>
struct HasSize<
T,
std::void_t<
decltype(
std::declval<
const T&
>().Size()
)
>
>
: std::true_type
{
};
使用:
cpp
template<typename T>
inline constexpr bool HasSizeV =
HasSize<T>::value;
27.2 根据能力选择逻辑
cpp
template<typename T>
void PrintSize(const T& object)
{
if constexpr (HasSizeV<T>)
{
std::cout
<< object.Size();
}
else
{
std::cout
<< "size unavailable";
}
}
这种检测技巧在 C++17 库代码中常见。
但普通业务代码不要过早设计复杂检测框架。
如果可以通过明确接口解决:
cpp
class ISized
{
public:
virtual std::size_t Size() const = 0;
};
有时反而更容易维护。
二十八、模板代码膨胀
同一个模板被多个类型实例化时,编译器可能生成多个版本:
cpp
Process<int>
Process<double>
Process<float>
Process<long>
这可能导致:
- 编译时间增加;
- 目标文件变大;
- 链接时间增加;
- 指令缓存压力。
28.1 减少膨胀的方法
- 把与类型无关的逻辑提取到普通函数;
- 只模板化真正依赖类型的部分;
- 使用显式实例化;
- 避免为大量相似类型生成复杂实现;
- 模块边界使用非模板接口;
- 对大型实现使用类型擦除或虚函数。
例如:
cpp
template<typename T>
void Save(const T& value)
{
std::string text =
Serialize(value);
WriteFileCommon(text);
}
其中:
cpp
Serialize(value)
依赖类型。
cpp
WriteFileCommon(text)
与类型无关,应放到普通函数中,避免每个实例重复生成文件写入逻辑。
二十九、模板错误信息
模板错误经常很长,因为错误可能发生在多层实例化中。
改善方式:
29.1 使用 static_assert
cpp
template<typename T>
void Save(const T& value)
{
static_assert(
std::is_copy_constructible_v<T>,
"Save requires a copy-constructible type"
);
}
29.2 缩小模板接口
不要让一个模板同时要求类型支持十几种操作。
29.3 使用有意义的类型参数名
不推荐:
cpp
template<
typename T,
typename U,
typename V
>
当含义明确时:
cpp
template<
typename Item,
typename Container,
typename Serializer
>
更容易阅读错误信息。
29.4 在接口处明确要求
即使 C++17 没有 Concepts,也可以通过文档和 static_assert 说明:
text
Item 必须可移动;
Container 必须支持 push_back、begin 和 end;
Sink 必须提供 Write(const Item&)。
三十、什么时候不应该使用模板
模板不是所有复用问题的答案。
不适合模板化的情况:
- 类型只有一种;
- 未来也不太可能变化;
- 模板参数并没有真正改变行为;
- 只为了减少两三行重复;
- 导致接口难以理解;
- 需要稳定 ABI;
- 需要隐藏实现;
- 需要运行时动态扩展;
- 编译时间已经很长;
- 错误信息会严重影响维护。
例如:
cpp
template<typename T>
class UserManager
{
};
如果系统永远只管理 User,模板参数没有实际价值。
更直接:
cpp
class UserManager
{
};
三十一、模板设计原则
31.1 先写具体版本
先为真实类型完成正确实现:
cpp
class LogQueue
{
};
确认需求稳定后,再观察哪些部分真正可复用。
不要一开始就设计:
cpp
template<
typename Item,
typename Queue,
typename Lock,
typename Condition,
typename Allocator,
typename Policy,
typename Sink
>
class UniversalManager;
这种设计通常过早复杂化。
31.2 模板参数越少越好
每增加一个模板参数,都会增加:
- 使用复杂度;
- 组合数量;
- 测试范围;
- 错误信息长度;
- 文档成本。
31.3 给默认参数
cpp
template<
typename T,
typename Container =
std::deque<T>
>
class ThreadSafeQueue;
大多数调用者可以直接:
cpp
ThreadSafeQueue<LogItem>
只有特殊需求才替换容器。
31.4 不暴露不必要的模板实现细节
外部接口可以封装为普通类:
cpp
class AsyncLogger
{
public:
void Log(LogItem item);
private:
ThreadSafeQueue<LogItem> m_queue;
};
调用者不需要知道内部使用了什么模板。
三十二、综合项目:泛型线程安全队列
本阶段综合前面知识,实现:
cpp
ThreadSafeQueue<T>
它可以被:
- 异步日志;
- 后台任务;
- 消息处理;
- 生产者消费者;
- 网络数据队列
复用。
32.1 设计目标
队列需要支持:
cpp
Push(T value);
WaitPop(T& value);
TryPop(T& value);
Close();
IsClosed();
Size();
行为要求:
- 多个线程可以安全调用;
- 队列为空时,消费者可以等待;
Close()后不再接受新元素;- 关闭后仍然允许取完剩余元素;
- 队列关闭且为空时,
WaitPop返回false; - 析构前使用者应停止生产和消费线程;
- 不允许拷贝;
- 根据成员能力自动支持移动。
32.2 头文件实现
ThreadSafeQueue.h
cpp
#pragma once
#include <condition_variable>
#include <cstddef>
#include <deque>
#include <mutex>
#include <utility>
template<
typename T,
typename Container =
std::deque<T>
>
class ThreadSafeQueue
{
public:
ThreadSafeQueue() = default;
~ThreadSafeQueue() = default;
ThreadSafeQueue(
const ThreadSafeQueue&
) = delete;
ThreadSafeQueue& operator=(
const ThreadSafeQueue&
) = delete;
ThreadSafeQueue(
ThreadSafeQueue&&
) = delete;
ThreadSafeQueue& operator=(
ThreadSafeQueue&&
) = delete;
bool Push(T value)
{
{
std::lock_guard<std::mutex>
lock(m_mutex);
if (m_closed)
{
return false;
}
m_items.push_back(
std::move(value)
);
}
m_condition.notify_one();
return true;
}
template<typename... Args>
bool Emplace(Args&&... args)
{
{
std::lock_guard<std::mutex>
lock(m_mutex);
if (m_closed)
{
return false;
}
m_items.emplace_back(
std::forward<Args>(
args
)...
);
}
m_condition.notify_one();
return true;
}
bool WaitPop(T& value)
{
std::unique_lock<std::mutex>
lock(m_mutex);
m_condition.wait(
lock,
[this]
{
return
m_closed ||
!m_items.empty();
}
);
if (m_items.empty())
{
return false;
}
value = std::move(
m_items.front()
);
m_items.pop_front();
return true;
}
bool TryPop(T& value)
{
std::lock_guard<std::mutex>
lock(m_mutex);
if (m_items.empty())
{
return false;
}
value = std::move(
m_items.front()
);
m_items.pop_front();
return true;
}
void Close()
{
{
std::lock_guard<std::mutex>
lock(m_mutex);
m_closed = true;
}
m_condition.notify_all();
}
bool IsClosed() const
{
std::lock_guard<std::mutex>
lock(m_mutex);
return m_closed;
}
bool Empty() const
{
std::lock_guard<std::mutex>
lock(m_mutex);
return m_items.empty();
}
std::size_t Size() const
{
std::lock_guard<std::mutex>
lock(m_mutex);
return m_items.size();
}
private:
mutable std::mutex m_mutex;
std::condition_variable m_condition;
Container m_items;
bool m_closed{false};
};
三十三、线程安全队列代码分析
33.1 为什么模板参数是 T
cpp
template<typename T>
队列逻辑不关心元素是:
LogItemTaskstd::stringstd::unique_ptr<Device>
只要元素满足所需操作即可。
33.2 为什么容器也做成模板参数
cpp
typename Container =
std::deque<T>
默认使用 deque,因为队列需要:
cpp
push_back
front
pop_front
也可以传入满足接口要求的其他容器。
33.3 为什么默认容器不是 vector
vector 没有高效的:
cpp
pop_front()
删除首元素需要移动后续所有元素。
deque 更适合队列头部删除。
33.4 为什么 Push 按值接收
cpp
bool Push(T value)
调用左值:
cpp
queue.Push(item);
先复制到参数 value,再移动进容器。
调用右值:
cpp
queue.Push(std::move(item));
参数直接移动构造,再移动进容器。
这种"按值接收,再移动保存"的接口简单,适合需要拥有参数副本的场景。
也可以设计两个重载:
cpp
bool Push(const T& value);
bool Push(T&& value);
但代码更重复。
33.5 为什么提供 Emplace
cpp
template<typename... Args>
bool Emplace(Args&&... args)
允许直接在容器内部构造 T:
cpp
queue.Emplace(
1,
LogLevel::Info,
"started"
);
内部:
cpp
m_items.emplace_back(
std::forward<Args>(args)...
);
保留每个参数的左值或右值属性。
33.6 为什么通知放在锁外
cpp
{
std::lock_guard lock(m_mutex);
m_items.push_back(...);
}
m_condition.notify_one();
如果在持锁时通知,等待线程被唤醒后仍然需要等待互斥锁。
放到锁外通常可以减少无意义竞争。
但正确性关键是:
修改共享状态必须在锁内完成,通知可以在状态修改后进行。
33.7 为什么 WaitPop 使用谓词
cpp
m_condition.wait(
lock,
[this]
{
return
m_closed ||
!m_items.empty();
}
);
条件变量可能发生虚假唤醒。
谓词确保线程真正满足以下任一条件才继续:
- 队列关闭;
- 队列有元素。
33.8 为什么关闭后仍然可以取剩余元素
cpp
if (m_items.empty())
{
return false;
}
只有在被唤醒后队列仍为空时返回 false。
如果:
text
m_closed == true
但队列中还有数据,仍然会继续取出。
这样可以实现:
text
停止接收新任务,
但处理完已经提交的任务。
33.9 为什么互斥锁是 mutable
cpp
mutable std::mutex m_mutex;
成员函数:
cpp
Size() const
Empty() const
IsClosed() const
逻辑上不改变队列业务状态,但需要加锁。
const 成员函数不能修改普通成员。
把互斥锁标记为 mutable 表示:
锁属于同步机制,不属于对象对外可观察的逻辑状态。
33.10 为什么禁止移动
互斥锁和条件变量本身不可复制、不可移动。
即使强行设计队列移动,也需要处理:
- 是否有线程正在等待;
- 移动时谁持有锁;
- 等待线程应当等待旧对象还是新对象;
- 条件变量如何迁移。
因此最安全的设计是:
cpp
移动 = delete
通常线程安全同步对象应有稳定地址和稳定生命周期。
三十四、把队列用于异步日志
cpp
struct LogItem
{
int id{};
LogLevel level{LogLevel::Info};
std::string message;
};
异步日志器:
cpp
class AsyncLogger
{
public:
explicit AsyncLogger(
std::unique_ptr<ILogSink> sink
)
: m_sink(std::move(sink)),
m_worker(
&AsyncLogger::WorkerLoop,
this
)
{
if (!m_sink)
{
throw std::invalid_argument(
"sink must not be null"
);
}
}
~AsyncLogger() noexcept
{
Stop();
}
AsyncLogger(
const AsyncLogger&
) = delete;
AsyncLogger& operator=(
const AsyncLogger&
) = delete;
bool Log(LogItem item)
{
return m_queue.Push(
std::move(item)
);
}
void Stop() noexcept
{
std::call_once(
m_stopFlag,
[this]
{
m_queue.Close();
if (m_worker.joinable())
{
m_worker.join();
}
}
);
}
private:
void WorkerLoop()
{
LogItem item;
while (m_queue.WaitPop(item))
{
try
{
m_sink->Write(item);
}
catch (const std::exception& error)
{
std::cerr
<< "log sink error: "
<< error.what()
<< '\n';
}
}
}
private:
ThreadSafeQueue<LogItem> m_queue;
std::unique_ptr<ILogSink> m_sink;
std::thread m_worker;
std::once_flag m_stopFlag;
};
这里组合了前七阶段知识:
LogItem对象生命周期;unique_ptr和 RAII;- STL 容器;
- 抽象接口
ILogSink; - 多线程和条件变量;
- 异常处理;
- 模板线程安全队列;
- 移动语义;
std::call_once。
三十五、泛型管理器示例
如果多个业务模块都遵循:
text
接收 Item
↓
后台线程处理
↓
调用 Processor
可以进一步抽象:
cpp
template<
typename Item,
typename Processor,
typename Queue =
ThreadSafeQueue<Item>
>
class AsyncProcessor
{
public:
explicit AsyncProcessor(
Processor processor
)
: m_processor(
std::move(processor)
),
m_worker(
&AsyncProcessor::WorkerLoop,
this
)
{
}
~AsyncProcessor() noexcept
{
Stop();
}
AsyncProcessor(
const AsyncProcessor&
) = delete;
AsyncProcessor& operator=(
const AsyncProcessor&
) = delete;
bool Submit(Item item)
{
return m_queue.Push(
std::move(item)
);
}
template<typename... Args>
bool Emplace(Args&&... args)
{
return m_queue.Emplace(
std::forward<Args>(
args
)...
);
}
void Stop() noexcept
{
std::call_once(
m_stopFlag,
[this]
{
m_queue.Close();
if (m_worker.joinable())
{
m_worker.join();
}
}
);
}
private:
void WorkerLoop()
{
Item item;
while (m_queue.WaitPop(item))
{
m_processor(item);
}
}
private:
Queue m_queue;
Processor m_processor;
std::thread m_worker;
std::once_flag m_stopFlag;
};
35.1 使用 Lambda 作为处理器
cpp
auto printer =
[](const LogItem& item)
{
std::cout
<< item.message
<< '\n';
};
AsyncProcessor<
LogItem,
decltype(printer)
> processor(
std::move(printer)
);
35.2 使用函数对象
cpp
class FileProcessor
{
public:
explicit FileProcessor(
std::filesystem::path path
)
: m_output(
std::move(path),
std::ios::app
)
{
if (!m_output)
{
throw std::runtime_error(
"failed to open output"
);
}
}
void operator()(
const LogItem& item
)
{
m_output
<< item.id
<< ' '
<< item.message
<< '\n';
}
private:
std::ofstream m_output;
};
使用:
cpp
AsyncProcessor<
LogItem,
FileProcessor
> processor(
FileProcessor{"app.log"}
);
三十六、综合管理器是否应该模板化
虽然 AsyncProcessor 看起来通用,但实际工程中要判断是否值得使用。
值得模板化:
- 多个模块确实有相同异步处理流程;
Item和Processor有多个真实类型;- 性能敏感,需要静态分派;
- 模板接口不会暴露到稳定 ABI 边界。
不值得模板化:
- 只有异步日志一个用途;
- 处理器必须运行时动态替换;
- 需要插件式扩展;
- 模板错误让团队维护困难;
- 模块边界需要隐藏实现;
- 编译时间明显恶化。
可能更好的设计是:
cpp
class AsyncLogger
内部复用:
cpp
ThreadSafeQueue<LogItem>
但不公开通用 AsyncProcessor。
这体现了一个重要原则:
能抽象不等于必须抽象。
三十七、模板与抽象接口如何组合
可以让外层使用运行时多态:
cpp
class ILogSink
{
public:
virtual ~ILogSink() = default;
virtual void Write(
const LogItem& item
) = 0;
};
内部使用模板队列:
cpp
ThreadSafeQueue<LogItem>
组合优势:
ILogSink支持运行时替换;ThreadSafeQueue支持类型安全复用;- 外部接口不暴露复杂模板;
- 内部队列没有虚函数开销;
- 模块职责清晰。
这通常比"整个系统全部模板化"更合理。
三十八、C++20 Concepts 简介
本学习路线主要以 C++17 为主,但需要知道 C++20 Concepts 解决了什么问题。
C++17:
cpp
template<typename T>
std::enable_if_t<
std::is_integral_v<T>,
T
>
Double(T value);
C++20:
cpp
template<std::integral T>
T Double(T value)
{
return value * 2;
}
或者:
cpp
template<typename T>
requires std::integral<T>
T Double(T value)
{
return value * 2;
}
Concepts 的价值:
- 约束更接近接口;
- 错误信息更清晰;
- 重载规则更自然;
- 可读性更好。
当前阶段不要求深入 C++20,但将来看到 requires 和 concept 时应知道它们属于模板约束机制。
三十九、常见错误
错误一:把模板理解为运行时选择类型
模板主要在编译期实例化。
cpp
if (userInput == 1)
{
// 不能在运行时创建一个新的模板类型参数
}
运行时类型切换通常使用:
- 虚函数;
variant;- 类型擦除;
- 工厂模式。
错误二:模板声明和定义分离后链接失败
原因通常是实例化位置看不到模板定义。
解决:
- 定义放头文件;
- 使用
.tpp并在头文件末尾包含; - 显式实例化固定类型。
错误三:模板参数过多
cpp
template<
typename A,
typename B,
typename C,
typename D,
typename E,
typename F
>
如果调用者必须理解六种策略,接口可能已经过度设计。
错误四:返回局部对象引用
cpp
template<typename T>
const T& Build()
{
T value{};
return value;
}
函数结束后引用悬空。
错误五:误用 std::move
cpp
template<typename T>
void Forward(T&& value)
{
Process(std::move(value));
}
这会无条件移动,即使调用者传入左值。
模板转发应使用:
cpp
std::forward<T>(value)
错误六:认为命名的右值引用变量仍是右值
cpp
void Process(std::string&& value)
{
Use(value);
}
value 有名字,因此表达式是左值。
需要继续移动时:
cpp
Use(std::move(value));
错误七:忽略引用折叠
转发引用能同时接收左值和右值,是因为模板推导和引用折叠共同作用。
错误八:对所有类型强行使用同一逻辑
模板通用逻辑依赖类型支持某些操作。
cpp
left + right
并非所有类型都支持。
需要:
- 明确要求;
static_assert;- SFINAE;
if constexpr;- 特化。
错误九:使用模板替代所有虚函数
运行时插件、稳定接口和动态扩展仍然适合虚函数。
错误十:自定义分配器但没有性能证据
内存池和分配器增加大量复杂度。
应先通过性能分析确认:
text
内存分配确实是瓶颈。
错误十一:过度使用元编程
如果普通函数、重载或接口类已经足够,不要使用复杂的递归模板和检测技巧。
错误十二:模板中隐藏业务含义
cpp
template<typename T>
class Manager;
T 无法说明是什么。
更好的命名:
cpp
template<typename Item>
class Repository;
四十、第七阶段建议练习
练习一:函数模板
实现:
cpp
template<typename T>
T Clamp(
T value,
T minimum,
T maximum
);
要求:
- 小于最小值时返回最小值;
- 大于最大值时返回最大值;
- 中间值原样返回;
- 思考不同类型参数是否允许混用。
练习二:类模板
实现:
cpp
template<
typename T,
std::size_t Capacity
>
class StaticStack;
要求:
- 使用固定数组;
- 支持
Push、Pop、Top; - 容量编译期确定;
- 空栈和满栈有明确处理策略。
练习三:类型萃取
实现:
cpp
IsPointer<T>
RemoveReference<T>
IsSame<A, B>
并使用 static_assert 验证。
练习四:if constexpr
实现:
cpp
template<typename T>
void DebugPrint(const T& value);
要求:
- 指针为空时输出
null; - 指针非空时输出指向对象;
- 非指针直接输出;
- 枚举类型输出底层整数。
练习五:可变参数模板
实现:
cpp
template<typename... Args>
std::string BuildMessage(
Args&&... args
);
要求:
- 使用
ostringstream; - 使用折叠表达式;
- 支持字符串、整数、路径等可输出类型。
练习六:完美转发
实现简化工厂:
cpp
template<
typename T,
typename... Args
>
std::unique_ptr<T>
MakeObject(Args&&... args);
内部使用:
cpp
std::forward<Args>(args)...
理解它与:
cpp
std::make_unique
的关系。
练习七:线程安全队列
独立实现:
cpp
ThreadSafeQueue<T>
要求:
PushEmplaceWaitPopTryPopClose- 虚假唤醒处理
- 多生产者和多消费者测试
练习八:异常安全
让 ThreadSafeQueue::Emplace 在元素构造抛异常时:
- 队列状态保持正确;
- 锁能自动释放;
- 不发送错误通知;
- 不产生半构造元素。
练习九:静态多态与动态多态对比
分别实现:
cpp
template<typename Sink>
class StaticLogger;
以及:
cpp
class DynamicLogger
{
std::unique_ptr<ILogSink> sink;
};
比较:
- 调用方式;
- 扩展方式;
- 编译依赖;
- 运行时替换;
- 可测试性;
- 性能。
四十一、第七阶段验收标准
完成本阶段后,应当能够独立回答以下问题。
模板基础
- 泛型编程解决什么问题?
- 模板和宏有什么本质区别?
- 什么是模板实例化?
- 隐式实例化和显式实例化有什么区别?
- 函数模板参数是如何推导的?
- 为什么
Add(10, 2.5)可能推导失败? - 普通函数和函数模板同时匹配时如何选择?
- 类模板名称和具体类类型有什么区别?
- 为什么
Box<int>和Box<double>是不同类型? - 默认模板参数有什么价值?
模板进阶
- 什么是非类型模板参数?
- 为什么
std::array<int, 10>的容量属于类型的一部分? - 什么是全特化?
- 什么是偏特化?
- 函数模板为什么通常使用重载而不是偏特化?
- 依赖类型前为什么需要
typename? - 依赖成员模板前为什么有时需要
template? - 模板定义为什么通常放头文件?
- 什么是显式实例化?
.tpp文件的本质是什么?
类型萃取与约束
std::is_same有什么作用?std::remove_reference_t<T>做了什么?std::decay_t<T>会进行哪些转换?- 什么是 SFINAE?
std::enable_if用于解决什么问题?- 为什么
if constexpr比普通if更适合模板分支? static_assert如何改善模板错误信息?- C++20 Concepts 主要改善了什么?
可变参数与转发
- 什么是模板参数包?
sizeof...(Args)表示什么?- 什么是参数包展开?
- 什么是折叠表达式?
- 左折叠和右折叠有什么区别?
- 什么是转发引用?
- 什么是引用折叠?
- 为什么命名的右值引用变量是左值表达式?
std::move和std::forward有什么区别?- 什么是完美转发?
emplace_back为什么需要可变参数模板和完美转发?
STL 与内存
- STL 为什么把容器和算法分开?
- 迭代器在 STL 中起什么作用?
- 什么是静态多态?
- 静态多态和虚函数动态多态如何选择?
- placement new 做了什么?
- 为什么内存分配和对象构造要分开?
- 分配器负责什么?
- 内存池适合解决什么问题?
- 为什么不应在没有性能证据时自定义分配器?
工程设计
- 什么情况下值得把一个类模板化?
- 为什么模板参数不是越多越好?
- 如何减少模板代码膨胀?
- 为什么稳定 ABI 边界通常不适合暴露复杂模板?
- 如何组合模板和抽象接口?
- 为什么线程安全队列通常禁止复制和移动?
ThreadSafeQueue::Close应具有什么语义?- 为什么关闭队列后仍可能继续取剩余元素?
- 为什么模板实现也必须考虑异常安全?
- 如何避免过度模板化?
能够清楚回答这些问题,并独立完成泛型线程安全队列和异步处理模块后,第七阶段才算真正完成。
四十二、完成七个阶段后掌握了什么
完成整个七阶段路线后,应当已经建立以下完整知识结构。
1. 对象与生命周期
能够判断:
- 对象何时构造;
- 对象何时析构;
- 指针和引用是否有效;
- 是否存在悬空指针;
- 是否发生对象切片;
- 是否需要深拷贝。
2. 资源管理
能够使用:
- RAII;
unique_ptr;shared_ptr;weak_ptr;- 自定义删除器;
- Rule of Zero;
- 移动语义。
3. STL 与算法
能够选择:
vectordequelistmapsetunordered_mapunordered_set
并能够正确使用:
- 迭代器;
- Lambda;
- STL 算法;
- 比较器;
- 删除惯用法;
- 迭代器失效规则。
4. 面向对象设计
能够理解:
- 继承;
- 虚函数;
- 运行时多态;
- 虚析构;
- 抽象接口;
- 对象切片;
- RTTI;
- 四种类型转换。
5. 多线程
能够实现:
- 线程启动和回收;
- 互斥锁保护;
- RAII 锁;
- 条件变量;
- 生产者消费者;
- 原子状态;
- 正确停止;
- 死锁预防;
- 异步日志。
6. 现代 C++
能够使用:
autodecltypeconstexproptionalvariantstring_viewfilesystem- 结构化绑定;
if constexpr;- 异常体系;
- 文件流和字符串流。
7. 泛型编程
能够理解:
- 函数模板;
- 类模板;
- 非类型模板参数;
- 特化;
- 类型萃取;
- SFINAE;
- 可变参数模板;
- 完美转发;
- 静态多态;
- 分配器和空间配置器思想。
四十三、七阶段结束后的继续方向
完成第七阶段不代表 C++ 已经全部学完,而是说明:
C++ 语言核心、现代标准库和基本工程能力已经形成完整主线。
后续应根据目标岗位进入专项方向。
Windows 客户端方向
继续学习:
- Win32 消息机制;
- COM;
- ATL/WTL;
- MFC;
- Qt;
- CEF;
- DLL 边界;
- Windows 安全机制;
- 崩溃分析;
- ETW;
- WPR/WPA;
- UI 线程与后台线程协作。
工业软件与上位机方向
继续学习:
- 串口;
- TCP/UDP;
- Modbus;
- CAN;
- OPC UA;
- 设备状态机;
- 数据采集;
- 实时曲线;
- SQLite;
- 配置管理;
- 日志和故障诊断;
- Qt 多线程;
- 插件架构。
C++ 基础架构方向
继续学习:
- CMake;
- 单元测试;
- CI/CD;
- 性能分析;
- 内存分析;
- 网络库;
- RPC;
- 序列化;
- 协程;
- 线程池;
- 对象池;
- 高性能日志;
- 跨平台;
- ABI 与动态库设计。
深入语言与标准库
继续学习:
- C++20 Concepts;
- Ranges;
- 协程;
std::span;std::jthread;std::stop_token;- 三路比较;
- 模块;
- C++23
std::expected; - 标准库实现;
- 编译器优化;
- 对象模型;
- 模板元编程。
总结
第七阶段的核心不是记住复杂模板语法,而是理解:
text
类型也可以成为参数;
编译期也可以进行选择;
算法可以独立于具体容器;
行为可以作为策略传入;
参数数量可以在编译期变化;
左值和右值属性可以被正确转发;
内存分配和对象构造可以分离;
静态多态和动态多态各有适用边界。
本阶段最重要的工程结论是:
模板用于消除真实存在的类型重复,而不是为了展示复杂语法。
一个好的模板组件应当具备:
- 明确的用途;
- 少量且有意义的模板参数;
- 清晰的类型要求;
- 合理的默认参数;
- 可理解的错误信息;
- 正确的异常安全;
- 不暴露不必要的实现细节;
- 有真实复用场景。
完成泛型线程安全队列和异步处理模块后,前面学习的生命周期、RAII、STL、多态、多线程、现代 C++ 与模板将真正连接成一套完整的 C++ 工程能力体系。