通过提取函数的类型依赖部分来减少C++模板膨胀
摘要
本文介绍了通过将类型依赖代码提取到辅助对象中或使用基于span的方法来减少C++模板膨胀的技术,以提高代码效率和可维护性。
<p>C++模板允许你重用代码,但代价是:每次模板展开都会生成不同的函数。对于小函数来说这不是什么问题,但函数越复杂,重复展开的成本就越大。</p>
<p>对于接受lambda表达式的函数,成本尤其高,因为每个lambda都是一个唯一的类型,所以每次使用lambda调用模板函数时,都会得到不同的模板展开。</p>
<p>有时我看到一些大型的模板函数,它们的类型依赖非常少。</p>
<pre>template<typename Table>
void something(Database const& db)
{
// 大量的准备工作
auto statusIndicator = ⟦ 计算状态指示器 ⟧
auto primaryTugboat = ⟦ 计算主要拖船 ⟧
std::vector<Staircase> staircases;
for (auto&& column : Table::Columns()) {
⟦ 使用我们准备的东西操作每个列 ⟧
⟦ 可能添加内容到楼梯并更新拖船 ⟧
}
⟦ 更多代码 ⟧
}
</pre>
<p>在这种极端情况下,唯一的类型依赖是 <code>Table::Columns()</code>。(更常见的类型依赖来源可能是对模板化入参的方法调用。)</p>
<p>这是一个大型函数,它将为每个 <code>Table</code> 重新展开。由于每个表有不同的列集,可能列数也不同,因此没有 COMDAT 折叠的机会,所以不同的展开都将是独立的。</p>
<p>一种缓解膨胀的方法是将所有公共部分包装到一个辅助对象中。</p>
<pre>struct SomethingState {
Database const& db;
Indicator statusIndicator;
Tugboat primaryTugboat;
std::vector<Staircase> staircases;
__declspec(noinline)
SomethingState(Database const& db) : db(db)
{
statusIndicator = ⟦ 计算状态指示器 ⟧
primaryTugboat = ⟦ 计算主要拖船 ⟧
}
__declspec(noinline)
void ProcessColumn(Column const& column)
{
⟦ 使用我们准备的东西操作每个列 ⟧
⟦ 可能添加内容到楼梯并更新拖船 ⟧
}
__declspec(noinline)
void Finish()
{
⟦ 更多代码 ⟧
}
};
template<typename Table>
void something(Database const& db)
{
<span style="border: solid 1px currentcolor;">SomethingState state(db);</span>
for (auto&& column : Table::Columns()) {
<span style="border: solid 1px currentcolor;">state.ProcessColumn(column);</span>
}
<span style="border: solid 1px currentcolor;">state.Finish();</span>
}
</pre>
<p>现在,<code>something</code> 函数的不同展开可以共享 <code>SomethingState</code> 的构造函数和方法,因此唯一的函数相当小。</p>
<p>我们将 <code>SomethingState</code> 构造函数和方法标记为 “noinline”,以阻止编译器内联它们,因为内联会破坏我们的提取。<b>相关</b>:<a title="一个noinline的inline函数?这是什么魔法?" href="https://devblogs.microsoft.com/oldnewthing/20200521-00/?p=103777"> 一个noinline的inline函数?这是什么魔法</a>?</p>
<p>另一种减少代码膨胀问题的方法是以相反方式进行提取:我们提取类型依赖部分并保留公共逻辑,而不是提取公共逻辑并保留类型依赖部分。</p>
<p>这种方法的关键是找到一个所有展开共享的公共类型。我假设 <code>Table::Colums()</code> 是一个 <code>Column</code> 对象的 C 风格数组,或者一个 <code>std::vector</code> 的 <code>Column</code> 对象,或者一个 <code>std::array</code> 的 <code>Column</code> 对象,或者任何可以生成 <code>std::span</code> 的 <code>Column</code> 对象。</p>
<pre>void somethingWorker(Database const& db, <span style="border: solid 1px currentcolor;">std::span<Column> columns</span>)
{
// 大量的准备工作
auto statusIndicator = ⟦ 计算状态指示器 ⟧
auto primaryTugboat = ⟦ 计算主要拖船 ⟧
std::vector<Staircase> staircases;
for (auto&& column : <span style="border: solid 1px currentcolor;">columns</span>) {
⟦ 使用我们准备的东西操作每个列 ⟧
⟦ 可能添加内容到楼梯并更新拖船 ⟧
}
⟦ 更多代码 ⟧
}
template<typename Table>
void something(Database const& db)
{
somethingWorker(db, <span style="border: solid 1px currentcolor;">Table::Columns()</span>);
}
</pre>
<p>我们提前捕获列,然后使用捕获的值在非模板化的辅助函数中执行枚举。由于辅助函数是非模板化的,当被每个 <code>something<Table></code> 调用时,不会发生模板膨胀。</p>
<p>需要注意的一点是我们改变了求值顺序,旧代码在准备工作完成之前不会调用 <code>Table::Columns()</code>。你可以查看代码来确认,但我怀疑 <code>Table::Columns()</code> 只是返回对某个预存列信息源的引用,所以调用时间无关紧要。即使它按值返回列(例如通过克隆内部向量),提前检索列确实改变了该向量的生成点,但即使在预备步骤失败时生成它可能也不是问题,因为(1)生成它没有重要的副作用,(2)求值顺序不重要,(3)失败情况可能很少见,因此生成未使用的向量的额外成本可以忽略不计。</p>
<p>我们将把这些原理应用到 <a title="关于将可调用对象包装在只使用相同参数调用它的lambda中" href="https://devblogs.microsoft.com/oldnewthing/20260819-00/?p=112624"> 我们之前的示例</a> 中,并做出一个令人惊讶的发现,这将震撼和惊叹你。</p>
<p>文章 <a href="https://devblogs.microsoft.com/oldnewthing/20260820-00/?p=112629">通过提取函数的类型依赖部分来减少C++模板膨胀</a> 最初发布在 <a href="https://devblogs.microsoft.com/oldnewthing">The Old New Thing</a> 上。</p>
查看缓存全文
缓存时间: 2026/08/21 15:35
# 通过抽取函数中的类型相关部分来减少 C++ 模板膨胀 - The Old New Thing
来源:https://devblogs.microsoft.com/oldnewthing/20260820-00?p=112629
C++ 模板让你可以复用代码,但这也是有代价的:每次模板展开都会生成一个不同的函数。对于小函数来说这没什么大不了的,但函数越复杂,重复展开的成本就越高。对于接受 lambda 表达式的函数来说,这个成本尤其高昂,因为每个 lambda 都是一个独特的类型,因此每次用 lambda 调用模板函数时,都会得到一个不同的模板展开实例。
有时我看到一些大型的模板函数,它们的类型依赖性非常少。
```cpp
template<typename Table>
void something(Database const& db)
{
// 大量准备工作
auto statusIndicator = /* 计算状态指示器 */;
auto primaryTugboat = /* 计算主拖船 */;
std::vector<Staircase> staircases;
for (auto&& column : Table::Columns())
{
/* 使用准备好的材料处理每一列 */
/* 可能向 staircases 添加内容并更新 tugboat */
}
/* 更多代码 */
}
```
在这个极端例子里,唯一的类型依赖是 `Table::Columns()`。(更常见的类型依赖来源是对模板形参的方法调用。)这是一个很大的函数,并且会针对每个 `Table` 类型重新展开。由于每个表有不同的列集,可能列数也不同,因此没有机会进行 COMDAT 折叠,所以不同的展开都会是完全独立的。
一种缓解代码膨胀的方法是将所有通用部分包装到一个辅助对象中。
```cpp
struct SomethingState
{
Database const& db;
Indicator statusIndicator;
Tugboat primaryTugboat;
std::vector<Staircase> staircases;
__declspec(noinline) SomethingState(Database const& db) : db(db)
{
statusIndicator = /* 计算状态指示器 */;
primaryTugboat = /* 计算主拖船 */;
}
__declspec(noinline) void ProcessColumn(Column const& column)
{
/* 使用准备好的材料处理每一列 */
/* 可能向 staircases 添加内容并更新 tugboat */
}
__declspec(noinline) void Finish()
{
/* 更多代码 */
}
};
template<typename Table>
void something(Database const& db)
{
SomethingState state(db);
for (auto&& column : Table::Columns())
{
state.ProcessColumn(column);
}
state.Finish();
}
```
现在,`something` 函数的不同展开可以共享 `SomethingState` 的构造函数和方法,因此那些独特的函数部分就相当小了。我们将 `SomethingState` 的构造函数和方法标记为 `__declspec(noinline)`,以阻止编译器将它们内联,因为内联会破坏我们的重构效果。
**相关内容**:一个不内联的内联函数?这是什么魔法? (https://devblogs.microsoft.com/oldnewthing/20200521-00/?p=103777)
另一种减少代码膨胀问题的方法是进行反向重构:与其抽取通用逻辑而保留类型相关的部分,不如抽取类型相关的部分而保留通用逻辑。这种方法的诀窍在于找到一个所有展开都共享的公共类型。我假设 `Table::Columns()` 返回一个 C 风格的 `Column` 对象数组、一个 `std::vector<Column>`、一个 `std::array<Column>`,或者其它能产生 `std::span<Column>` 的东西。
```cpp
void somethingWorker(Database const& db, std::span<Column> columns)
{
// 大量准备工作
auto statusIndicator = /* 计算状态指示器 */;
auto primaryTugboat = /* 计算主拖船 */;
std::vector<Staircase> staircases;
for (auto&& column : columns)
{
/* 使用准备好的材料处理每一列 */
/* 可能向 staircases 添加内容并更新 tugboat */
}
/* 更多代码 */
}
template<typename Table>
void something(Database const& db)
{
somethingWorker(db, Table::Columns());
}
```
我们提前捕获列信息,然后在非模板化的 worker 函数中使用捕获到的值进行枚举。由于 worker 函数不是模板化的,当每个 `something` 实例调用它时,就不会发生模板膨胀。
需要注意的是,我们改变了求值顺序。旧代码是在准备工作完成后才调用 `Table::Columns()`。你可以检查代码来确认,但我怀疑 `Table::Columns()` 只是返回对某个预先存在的列信息源的引用,所以何时调用它并不重要。即使它是按值返回列(例如,通过克隆内部向量),提前获取列确实改变了该向量生成的时机,但即使预备步骤失败,生成它可能也不是问题,因为 (1) 生成它没有明显的副作用,(2) 求值顺序并不重要,(3) 失败的情况可能很少见,所以为生成一个不会被使用的向量而付出额外成本是无关紧要的。
我们将应用这些原则到我们之前的例子 (https://devblogs.microsoft.com/oldnewthing/20260819-00/?p=112624) 中,并会有一个令你震惊和惊叹的惊人发现。
### 分类
### 主题
## 作者
Raymond Chen
Raymond 参与了 Windows 的演进超过 30 年。2003 年,他开始了一个名为 The Old New Thing 的网站,其受欢迎程度远远超出了他最疯狂的想象,这一发展至今仍让他感到不安。这个网站催生了一本书,巧合的是也叫《The Old New Thing》(Addison Wesley 2007)。他偶尔会出现在 Windows Dev Docs 的 Twitter 账号上,讲述一些没什么实际信息的故事。
相似文章
通过提取函数的类型相关部分来减少C++模板膨胀,实践示例
本文解释了如何通过提取函数的类型相关部分来减少C++模板膨胀,并使用Windows DispatcherQueueHandler的实践示例来去除代码模板化。
现代化一个25年的最小化C++单元测试框架(第二部分)
本文继续系列内容,讨论如何现代化一个25年的最小化C++单元测试框架,重点介绍使用内联变量和模块来解决文件特定计数器和头文件依赖的问题。
(滥用)重载集在D语言中创建临时模板API
一篇详细的博客文章,解释了如何利用D语言的重载集创建临时模板API,涵盖了针对整数值的特化以及用于维度通用向量接口的真实世界模式。
优化Markdown解析器的内存使用
这篇博客文章详细介绍了优化C++ Markdown解析器内存使用的技术,通过arena分配和指针压缩,将AST节点大小从232字节显著减少到16字节,同时保持解析速度。
C++26:减少未定义行为
C++26 引入了减少未定义行为的更改,特别是使删除指向不完整类型的指针成为格式错误,从而提高了程序安全性。