【C++ 第七阶段:模板、泛型编程与工程综合详解】

C++ 第七阶段:模板、泛型编程与工程综合详解

本阶段目标:理解 C++ 模板与泛型编程的核心思想,掌握函数模板、类模板、模板参数推导、非类型模板参数、模板特化、可变参数模板、类型萃取、if constexpr、模板分离编译等常用知识,并能够把前面六个阶段学习的 RAII、STL、多态、多线程和现代 C++ 特性组合成可复用的工程组件。


一、第七阶段在整个学习路线中的位置

完整的 C++ 系统补强路线分为七个阶段:

  1. 指针、引用、const、栈与堆、对象生命周期、构造与析构;
  2. RAII、异常安全、智能指针、移动语义、Rule of Zero;
  3. STL 容器、迭代器、Lambda 与算法;
  4. 继承、多态、虚函数、虚析构、抽象接口;
  5. 多线程、锁、条件变量、原子操作与线程同步;
  6. 现代 C++ 常用特性、异常体系、IO 流与工程工具;
  7. 模板、泛型编程、模板进阶与工程综合。

前六个阶段主要解决:

  • 一个对象如何正确创建和销毁;
  • 一个资源如何自动释放;
  • 数据如何保存在容器中;
  • 如何使用算法处理数据;
  • 如何通过抽象接口解耦模块;
  • 多线程如何安全协作;
  • 如何使用现代 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);

编译器会根据实际类型生成对应函数。


二、第七阶段的学习目标

完成本阶段后,应当能够掌握:

模板基础

  • 泛型编程是什么;
  • 函数模板;
  • 类模板;
  • 模板参数;
  • 模板实例化;
  • 模板参数推导;
  • 显式指定模板参数;
  • 普通函数与函数模板的关系;
  • 模板重载。

模板进阶

  • 类型模板参数;
  • 非类型模板参数;
  • 模板模板参数;
  • 默认模板参数;
  • 全特化;
  • 偏特化;
  • 函数模板为什么通常不使用偏特化;
  • 模板分离编译;
  • 显式实例化;
  • 依赖名称;
  • typenametemplate 关键字。

现代泛型工具

  • decltype
  • auto
  • std::move
  • std::forward
  • 完美转发
  • std::enable_if
  • SFINAE
  • 类型萃取
  • std::is_same
  • std::is_integral
  • std::is_pointer
  • std::decay
  • std::remove_reference
  • if constexpr
  • 可变参数模板
  • 参数包展开
  • 折叠表达式

STL 泛型设计

  • 容器、迭代器和算法为什么能够解耦;
  • 比较器和函数对象;
  • 策略参数;
  • 分配器的基本作用;
  • 内存分配与对象构造为什么要分开。

综合工程

  • 如何判断一段代码是否值得模板化;
  • 如何避免过度模板化;
  • 如何控制模板接口复杂度;
  • 如何编写可读的模板错误信息;
  • 如何设计一个泛型线程安全队列;
  • 如何把日志、配置和异步处理模块组合起来。

三、什么是泛型编程

泛型编程的核心不是"少写几个函数",而是:

把算法中真正变化的部分抽象为参数,把不变的逻辑保留下来。

例如,一个查找算法通常只关心:

text 复制代码
从哪里开始?
在哪里结束?
如何判断目标?

它不一定关心底层容器是:

  • vector
  • list
  • deque
  • 数组
  • 自定义容器

因此 STL 的算法一般接收迭代器区间:

cpp 复制代码
std::find(
    container.begin(),
    container.end(),
    target
);

而不是直接接收某个特定容器:

cpp 复制代码
FindInVector(vector, target);
FindInList(list, target);
FindInDeque(deque, target);

泛型编程常见的三类参数包括:

  1. 类型参数
  2. 值参数
  3. 行为参数

例如:

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);

宏展开后,ab 可能被递增多次。

模板:

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;
}

尾置返回类型的价值在于:

在返回类型出现的位置,函数参数名称还不能直接使用;把返回类型放到参数列表之后,就可以使用 leftright


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);

1020 是临时对象。

函数返回其中一个临时对象的引用,完整表达式结束后引用悬空。

因此"返回 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> 会进行类似按值传参的类型退化:

  • 去掉引用;
  • 去掉顶层 constvolatile
  • 数组退化为指针;
  • 函数退化为函数指针。
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::movestd::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 完成两件事:

  1. 申请内存;
  2. 在内存中构造对象。

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 为什么要分开

容器扩容时,通常会:

  1. 一次申请一大块原始内存;
  2. 根据需要逐个构造元素;
  3. 删除元素时只销毁对象;
  4. 容器整体释放时再释放整块内存。

如果每个元素都单独 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 分配器,而是理解其设计动机。


二十四、内存池与空间配置器

频繁分配大量小对象时,直接调用系统堆可能带来:

  • 分配和释放开销;
  • 内存碎片;
  • 锁竞争;
  • 缓存局部性差。

内存池通常会:

  1. 一次申请较大的内存块;
  2. 切分成多个固定大小或分级大小的小块;
  3. 使用自由链表记录空闲块;
  4. 小对象优先从池中获取;
  5. 释放时归还到池中,而不是立刻归还操作系统。

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();

行为要求:

  1. 多个线程可以安全调用;
  2. 队列为空时,消费者可以等待;
  3. Close() 后不再接受新元素;
  4. 关闭后仍然允许取完剩余元素;
  5. 队列关闭且为空时,WaitPop 返回 false
  6. 析构前使用者应停止生产和消费线程;
  7. 不允许拷贝;
  8. 根据成员能力自动支持移动。

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>

队列逻辑不关心元素是:

  • LogItem
  • Task
  • std::string
  • std::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 看起来通用,但实际工程中要判断是否值得使用。

值得模板化:

  • 多个模块确实有相同异步处理流程;
  • ItemProcessor 有多个真实类型;
  • 性能敏感,需要静态分派;
  • 模板接口不会暴露到稳定 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,但将来看到 requiresconcept 时应知道它们属于模板约束机制。


三十九、常见错误


错误一:把模板理解为运行时选择类型

模板主要在编译期实例化。

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;

要求:

  • 使用固定数组;
  • 支持 PushPopTop
  • 容量编译期确定;
  • 空栈和满栈有明确处理策略。

练习三:类型萃取

实现:

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>

要求:

  • Push
  • Emplace
  • WaitPop
  • TryPop
  • Close
  • 虚假唤醒处理
  • 多生产者和多消费者测试

练习八:异常安全

ThreadSafeQueue::Emplace 在元素构造抛异常时:

  • 队列状态保持正确;
  • 锁能自动释放;
  • 不发送错误通知;
  • 不产生半构造元素。

练习九:静态多态与动态多态对比

分别实现:

cpp 复制代码
template<typename Sink>
class StaticLogger;

以及:

cpp 复制代码
class DynamicLogger
{
    std::unique_ptr<ILogSink> sink;
};

比较:

  • 调用方式;
  • 扩展方式;
  • 编译依赖;
  • 运行时替换;
  • 可测试性;
  • 性能。

四十一、第七阶段验收标准

完成本阶段后,应当能够独立回答以下问题。

模板基础

  1. 泛型编程解决什么问题?
  2. 模板和宏有什么本质区别?
  3. 什么是模板实例化?
  4. 隐式实例化和显式实例化有什么区别?
  5. 函数模板参数是如何推导的?
  6. 为什么 Add(10, 2.5) 可能推导失败?
  7. 普通函数和函数模板同时匹配时如何选择?
  8. 类模板名称和具体类类型有什么区别?
  9. 为什么 Box<int>Box<double> 是不同类型?
  10. 默认模板参数有什么价值?

模板进阶

  1. 什么是非类型模板参数?
  2. 为什么 std::array<int, 10> 的容量属于类型的一部分?
  3. 什么是全特化?
  4. 什么是偏特化?
  5. 函数模板为什么通常使用重载而不是偏特化?
  6. 依赖类型前为什么需要 typename
  7. 依赖成员模板前为什么有时需要 template
  8. 模板定义为什么通常放头文件?
  9. 什么是显式实例化?
  10. .tpp 文件的本质是什么?

类型萃取与约束

  1. std::is_same 有什么作用?
  2. std::remove_reference_t<T> 做了什么?
  3. std::decay_t<T> 会进行哪些转换?
  4. 什么是 SFINAE?
  5. std::enable_if 用于解决什么问题?
  6. 为什么 if constexpr 比普通 if 更适合模板分支?
  7. static_assert 如何改善模板错误信息?
  8. C++20 Concepts 主要改善了什么?

可变参数与转发

  1. 什么是模板参数包?
  2. sizeof...(Args) 表示什么?
  3. 什么是参数包展开?
  4. 什么是折叠表达式?
  5. 左折叠和右折叠有什么区别?
  6. 什么是转发引用?
  7. 什么是引用折叠?
  8. 为什么命名的右值引用变量是左值表达式?
  9. std::movestd::forward 有什么区别?
  10. 什么是完美转发?
  11. emplace_back 为什么需要可变参数模板和完美转发?

STL 与内存

  1. STL 为什么把容器和算法分开?
  2. 迭代器在 STL 中起什么作用?
  3. 什么是静态多态?
  4. 静态多态和虚函数动态多态如何选择?
  5. placement new 做了什么?
  6. 为什么内存分配和对象构造要分开?
  7. 分配器负责什么?
  8. 内存池适合解决什么问题?
  9. 为什么不应在没有性能证据时自定义分配器?

工程设计

  1. 什么情况下值得把一个类模板化?
  2. 为什么模板参数不是越多越好?
  3. 如何减少模板代码膨胀?
  4. 为什么稳定 ABI 边界通常不适合暴露复杂模板?
  5. 如何组合模板和抽象接口?
  6. 为什么线程安全队列通常禁止复制和移动?
  7. ThreadSafeQueue::Close 应具有什么语义?
  8. 为什么关闭队列后仍可能继续取剩余元素?
  9. 为什么模板实现也必须考虑异常安全?
  10. 如何避免过度模板化?

能够清楚回答这些问题,并独立完成泛型线程安全队列和异步处理模块后,第七阶段才算真正完成。


四十二、完成七个阶段后掌握了什么

完成整个七阶段路线后,应当已经建立以下完整知识结构。


1. 对象与生命周期

能够判断:

  • 对象何时构造;
  • 对象何时析构;
  • 指针和引用是否有效;
  • 是否存在悬空指针;
  • 是否发生对象切片;
  • 是否需要深拷贝。

2. 资源管理

能够使用:

  • RAII;
  • unique_ptr
  • shared_ptr
  • weak_ptr
  • 自定义删除器;
  • Rule of Zero;
  • 移动语义。

3. STL 与算法

能够选择:

  • vector
  • deque
  • list
  • map
  • set
  • unordered_map
  • unordered_set

并能够正确使用:

  • 迭代器;
  • Lambda;
  • STL 算法;
  • 比较器;
  • 删除惯用法;
  • 迭代器失效规则。

4. 面向对象设计

能够理解:

  • 继承;
  • 虚函数;
  • 运行时多态;
  • 虚析构;
  • 抽象接口;
  • 对象切片;
  • RTTI;
  • 四种类型转换。

5. 多线程

能够实现:

  • 线程启动和回收;
  • 互斥锁保护;
  • RAII 锁;
  • 条件变量;
  • 生产者消费者;
  • 原子状态;
  • 正确停止;
  • 死锁预防;
  • 异步日志。

6. 现代 C++

能够使用:

  • auto
  • decltype
  • constexpr
  • optional
  • variant
  • string_view
  • filesystem
  • 结构化绑定;
  • 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++ 工程能力体系。

相关推荐
GIS阵地2 小时前
QgsSingleBandPseudoColorRenderer 完整详解(QGIS 3.40.13 C++)
开发语言·前端·c++·qt·qgis
王维同学2 小时前
[原创][Windows C++]LSA 认证、安全与通知包的注册表枚举
c++·windows·安全
yaoxin5211232 小时前
470. Java 反射 - Member 接口与 AccessFlag
java·开发语言·python
groundhappy2 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
_wyt0013 小时前
完全背包问题详解
c++·背包dp
小钻风33663 小时前
Spring Boot 文件上传详解:深入理解 MultipartFile 的使用与原理
java·开发语言
qq_452396234 小时前
第二篇:《Go 开发环境搭建:SDK、IDE、Module 与 Hello World》
开发语言·ide·golang
天青色等烟雨..5 小时前
全流程ArcGISPro空间分析、三维建模、可视化及Python融合应用技术
开发语言·python
丰锋ff5 小时前
1.2 Qt常用基础类型
开发语言·qt