要在 macOS 上将 C++ 模板实现放在单独的源文件中,请在定义可见的位置显式实例化支持的类型,然后将该目标文件链接到可执行文件中。本指南使用 Apple 的 C++ 工具构建一个小型本地示例。
任务的范围很明确:编译一个模板成员函数体位于 .cpp 文件中的 C++ 程序。示例支持两种具体类型。后续部分会说明 Swift 桌面应用使用同一 C++ 接口时的额外要求。
为什么将模板代码移到源文件中会导致链接失败?
模板描述一系列类型或函数。编译其实现文件不会自动生成所有可能的特化。实例化类也不会自动实例化它的每个成员函数体;通常只会在需要时实例化成员。这些区别解释了为什么调用方可能顺利编译,却在链接时留下未解析的引用。C++ 工作草案:隐式实例化
假设有一个头文件,其中定义了类模板的布局,并声明了它的构造函数和方法。调用方可以理解这些声明。但如果方法体只存在于另一个源文件中,调用方就无法利用这些函数体生成所需的函数。实现文件必须提供调用方需要的具体实例化。
显式实例化定义会请求生成这些实例化。extern template 声明则指出某个实例化由其他位置提供。提供显式实例化定义的翻译单元中必须能看到实现定义。C++ 工作草案:显式实例化
根据谁来控制类型实参选择相应的组织方式:
| 读者的需求 | 适用的组织方式 | 维护上的影响 |
|---|---|---|
| 调用方可以提供任意适用类型 | 将定义放在头文件中,或放在由头文件包含的实现头文件中 | 调用方可以实例化新类型 |
| 库支持一组已知类型 | 将函数体放在源文件中,并显式实例化这组类型 | 添加支持类型时也要添加相应的实例化 |
| 桌面应用只需要一个具体操作 | 为选定的特化提供普通 C++ 封装函数 | 将模板选择保留在 C++ 实现中 |
上述决策指南基于前面说明的实例化规则。本指南中的支持类型集有意限制为 int 和 double。
构建完整的三文件示例
新建一个空文件夹,并将以下三个文件保存在其中。这个模拟程序会将偏移量加到一个值上;较小的输入便于检查预期结果。
准备 Apple 命令行工具
Apple 通过 Xcode 或独立的 Command Line Tools 软件包提供编译器和 macOS SDK。如果两者都未安装,请打开终端并运行以下命令,然后完成安装程序对话框。如果已安装 Xcode,就不需要单独安装工具。安装命令行工具
xcode-select --install
定义公共接口
将以下完整头文件保存为 Offset.hpp。类定义(包括其数据成员)应对调用方可见。只有构造函数和方法的函数体移到源文件中。
#ifndef OFFSET_HPP
#define OFFSET_HPP
namespace demo {
template <typename T>
class Offset {
public:
explicit Offset(T amount);
T apply(T value) const;
private:
T amount_;
};
extern template class Offset<int>;
extern template class Offset<double>;
} // namespace demo
#endif
两行 extern template 声明指出这些实例化由其他位置提供;它们本身不提供成员函数体。对于这种类级显式实例化组织方式,类模板定义必须始终可见。C++ 工作草案:显式实例化
定义函数体并提供实例化
将以下完整实现保存为 Offset.cpp。在成员定义之后放置显式实例化定义。
#include "Offset.hpp"
namespace demo {
template <typename T>
Offset<T>::Offset(T amount) : amount_(amount) {}
template <typename T>
T Offset<T>::apply(T value) const {
return value + amount_;
}
template class Offset<int>;
template class Offset<double>;
} // namespace demo
顺序很重要:类级显式实例化定义只涵盖在该位置之前已定义的成员。它也不会自动实例化成员模板。每个显式实例化定义都应由一个源文件负责;对于程序中的每组模板和实参,语言至多允许一个此类定义。显式实例化、模板实例化与特化:一般规则
调用两种受支持的类型
将以下完整调用方程序保存为 main.cpp。
#include "Offset.hpp"
#include <iostream>
int main() {
const demo::Offset<int> whole(3);
const demo::Offset<double> fractional(0.5);
const int first = whole.apply(4);
const double second = fractional.apply(2.0);
std::cout << first << '\n' << second << '\n';
return (first == 7 && second == 2.5) ? 0 : 1;
}
程序既会打印结果,也会通过退出状态检查结果。准备过程中只检查了此示例的一致性,没有进行编译:研究环境中没有 C++ 编译器或 macOS SDK。
编译两个源文件,然后链接两个目标文件
在终端中切换到包含这三个文件的文件夹。按顺序运行以下命令,只有前一条命令成功后才继续:
xcrun clang++ -std=c++17 -stdlib=libc++ -Wall -Wextra -pedantic -c Offset.cpp -o Offset.o
xcrun clang++ -std=c++17 -stdlib=libc++ -Wall -Wextra -pedantic -c main.cpp -o main.o
xcrun clang++ -std=c++17 -stdlib=libc++ main.o Offset.o -o template-demo
./template-demo
这里的 -c 会生成目标文件,但不进行链接。最后一次编译器调用不带 -c,而是将两个目标文件合并为一个可执行文件。这些命令显式选择 C++17 和 libc++,而不是依赖默认设置。Clang 编译器命令指南
根据模拟输入推导出的预期输出为:
7
2.5
最后一条构建命令展示了关键依赖:main.o 请求这些函数,而 Offset.o 提供其实例化。在更大的项目中,请确保包含 Offset.cpp 的目标已构建,并且其目标文件或库进入可执行文件的链接步骤。仅成功编译该文件并不能完成链接。
如果要单独编译调用方,不要在其中添加 #include "Offset.cpp"。对于这种组织方式,应包含公共头文件,并将实现文件作为独立翻译单元编译。如果调用方需要这些函数体,则应有意选择前表所述的包含实现头文件的组织方式。
按有效顺序排查缺失的实例化
首先确定确切的未解析函数,包括其模板实参和限定符,然后按以下顺序检查。这些检查依据了生成目标文件与链接之间的区别,以及显式实例化要求。Clang 编译阶段、C++ 显式实例化规则
| 发现的问题 | 检查或更改的内容 |
|---|---|
| 两种受支持类型的成员函数都未解析 | 确认最终链接命令中包含 Offset.o |
| 新使用的类型失败 | 在所属实现文件中添加相应的实例化,并在头文件中添加声明 |
| 缺少一个方法 | 确认其函数体位于类实例化之前,且声明与定义相匹配 |
| 缺少成员模板 | 在定义可见的位置单独提供所需的成员模板实例化 |
| 出现重复定义诊断 | 检查是否重复定义了实例化,或是否在包含实现文件的同时又单独编译了它 |
| 诊断指出架构不兼容 | 检查每个已链接目标文件和库的架构 |
例如,如果将调用方改为使用 demo::Offset<float>,就会引入该实现尚未提供的类型。要支持它,请在头文件的命名空间内添加 extern template class Offset<float>;,并在 Offset.cpp 的函数体后添加 template class Offset<float>;,然后重新构建两个目标文件。仅添加声明不会生成缺失的函数。
如果已安装 LLVM 单独提供的 llvm-nm 可执行文件,并且它位于命令搜索路径中,可运行以下可选命令检查示例中的目标文件:
llvm-nm --demangle --undefined-only main.o
llvm-nm --demangle --defined-only Offset.o
在调用方的未解析符号和实现文件的已定义符号中查找匹配的 demo::Offset 成员名称。其他条目(包括标准库引用)都是预期内容。LLVM 文档说明了这两个过滤选项和名称解码;这些命令具体需要 llvm-nm,不应假定安装了 Apple 工具就一定有它。llvm-nm 命令指南
保持 macOS 架构和 ABI 兼容性
显式实例化会提供具体的模板代码,但不会让一个目标文件适用于所有架构。Apple 的通用二进制工作流程会分别编译和链接各架构版本,然后将它们合并。如果应用同时支持 arm64 和 x86_64,请确保两个构建中都包含实例化源文件。构建 macOS 通用二进制文件
要为示例构建通用二进制文件,请使用以下命令检查生成的可执行文件。确认报告的架构与预期构建一致;前面的普通构建命令不会请求生成通用二进制文件。
xcrun lipo -archs ./template-demo
将方法体分离也意味着类布局仍在头文件中可见。应将对该布局的更改视为接口更改,并重新构建依赖代码。不要因为存在显式实例化就推断 ABI 稳定。libc++ 文档说明了具体的兼容性保证和配置限制,并明确区分这些保证与对用户定义函数的保证。libc++ 的 ABI 保证
Swift 应用调用 C++ 代码时有哪些变化?
请分别满足两个要求:Swift 必须能够导入受支持的 C++ 声明,最终可执行文件也必须链接其实现。Swift 文档说明,可通过 C++ 类型别名公开具体的类模板特化;未实例化的模板本身不能直接作为 Swift 类型使用。混合使用 Swift 和 C++:使用类模板
对于示例接口,下面是一个可选的头文件片段,可插入 namespace demo 内、类定义之后的位置。它为具体类型提供一个便于导入的名称:
using IntOffset = Offset<int>;
这个别名为该类型命名。Offset.cpp 中的显式实例化仍会提供外置定义的构造函数和方法。Swift 使用 Clang 模块导入 C++ 头文件,目前无法导入 C++20 模块。请遵循基于头文件的互操作设置。混合使用 Swift 和 C++
在 Xcode 中,为 Swift 目标启用 C++ and Objective-C interoperability,并将值设为 C++ / Objective-C++。对于在同一目标中使用 C++ 的应用目标,请在其桥接头文件中包含 C++ 头文件。框架目标通过公共头文件公开受支持的声明。设置混合语言 Swift 和 C++ 项目
混合使用 Swift 和 C++ 的代码必须使用相同的 C++ 标准库;Apple 平台使用 libc++。互操作要求 C++14 或更高版本。导入头文件所用的语言模式应与 C++ 构建相匹配,并在公开更复杂的模板之前查看受支持功能列表。显式实例化不会扩展 Swift 支持的语言映射范围。C++ 互操作的受支持功能和限制
如果类型集固定,请同时维护公共声明、实例化列表和最终链接输入。新的调用方需要另一种类型时,可以有意添加该类型,也可以让调用方能够使用实现进行实例化。随着桌面项目不断扩展,这种选择能让源文件边界保持清晰。
来源
- 隐式实例化 — C++ 工作草案
- 显式实例化 — C++ 工作草案
- 模板实例化与特化:一般规则 — C++ 工作草案
- 安装命令行工具 — Apple
- clang — Clang C、C++ 和 Objective-C 编译器 — LLVM 项目
- llvm-nm — 列出 LLVM 位码和目标文件的符号表 — LLVM 项目
- 构建 macOS 通用二进制文件 — Apple
- libc++ 的 ABI 保证 — LLVM 项目
- 混合使用 Swift 和 C++ — Swift.org
- 设置混合语言 Swift 和 C++ 项目 — Swift.org
- C++ 互操作的受支持功能和限制 — Swift.org