前言
先纠正标题里的一个拼写问题:「TpeScript」应为「TypeScript」。这个标题想讨论的是 C++ 模板与 TypeScript 泛型之间的关系。两者都允许把类型作为参数,写出「对多种类型都适用」的代码,但它们的实现模型几乎相反:C++ 模板在编译期为每一组实参生成一份独立代码,TypeScript 泛型只存在于类型检查阶段,编译成 JavaScript 后类型参数被完全擦除。
由此产生的误解很典型。有人以为 TypeScript 的泛型和 C++ 模板一样,可以在运行时new T()、判断x instanceof T,实际上泛型信息在运行期不存在。也有人以为 C++ 的模板只是「带类型检查的宏」,忽略了它能做编译期计算、模板特化和重载决议。还有人把 TypeScript 的extends当成 C++ 的继承,其实它只是对类型参数施加结构约束。
本文分别说明两种泛型的机制,再从实例化方式、约束表达、特化能力、运行期行为四个维度做对照。C++ 部分以 C++17 为基准,涉及 C++20 的约束时单独标注;TypeScript 部分以 4.x 及以上的常见语义为准。
一、C++ 模板:编译期实例化
C++ 的类模板与函数模板是「代码生成器」。编译器看到larger(3, 7)时,用T = int实例化一份函数;看到larger<std::string>(...)时,再生成一份T = std::string的版本。这个过程叫单态化(monomorphization),每份实例都是真正的机器码,调用时没有运行期类型分派,通常也能被内联。
// 适用于 C++17
#include <iostream>
#include <string>
#include <utility>
template <class T>
T larger(const T& a, const T& b) {
return a < b ? b : a;
}
template <class T>
struct Box {
T value;
explicit Box(T v) : value(std::move(v)) {}
};
int main() {
std::cout << larger(3, 7) << '\n';
std::cout << larger<std::string>("abc", "abd") << '\n';
Box<int> b{42};
std::cout << b.value << '\n';
return 0;
}模板的参数推导基于实参类型。larger(3, 7)推导出T = int;如果写larger(3, 7.0),两个实参类型不同,推导会失败,需要显式指定larger<double>(3, 7.0),或者把模板写成两个类型参数。这一点与 TypeScript 的类型推断不同:TS 会在多个候选类型之间做联合或最佳公共类型,C++ 的推导规则更严格,冲突时直接报错。
模板定义通常必须放在头文件里。因为编译器在实例化时需要有完整的函数体;如果把定义放进.cpp,另一个翻译单元只看到声明,链接阶段就找不到对应实例,报未定义引用。这不是工具链的缺陷,而是「按需实例化」模型的必然结果。C++11 的extern template可以抑制某些实例的重复生成,但不会改变「定义必须可见」这一前提。
C++20 引入了概念(concepts),把对类型参数的约束从「实例化失败时的长篇报错」变成显式声明:
// 需要 C++20 及以上
#include <concepts>
template <std::totally_ordered T>
T larger(const T& a, const T& b) {
return a < b ? b : a;
}std::totally_ordered要求类型支持全序比较。不满足约束的类型在重载决议阶段就被排除,错误信息指向约束本身,而不是模板展开后的某一行。概念是 C++20 特性,GCC、Clang、MSVC 的支持版本不同,使用前要确认工具链。
模板还支持全特化与偏特化。例如可以为Box<char const*>写一个专门版本,改变其存储方式;函数模板支持重载与全特化,但不支持偏特化(常用重载或类模板偏特化来替代)。这种「针对具体类型改写实现」的能力是 TypeScript 泛型没有的。
二、TypeScript 泛型:类型擦除与结构约束
TypeScript 的泛型写在类型层面,作用是让调用方与实现方共享同一个类型变量,从而获得类型检查与推断。它不会为每个类型参数生成不同代码;编译产物里泛型参数被擦除,运行时只有一个函数实现。
// 适用于 TypeScript 4.x 及以上
function larger<T extends number>(a: T, b: T): T {
return a > b ? a : b;
}
interface Box<T> {
value: T;
}
const b: Box<number> = { value: 42 };
const n = larger(3, 7); // T 推断为 numberextends在这里不是继承,而是约束:T必须可赋值给number。TypeScript 采用结构类型系统,判断「是不是」看成员形状,而不是看声明关系。因此约束可以写成T extends { id: number },任何含有id: number的类型都满足,不需要显式实现某个接口。
更复杂的约束常与keyof配合:
// 适用于 TypeScript 4.x 及以上
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "Ada", age: 36 };
const name = getProperty(person, "name"); // 类型为 stringK extends keyof T把键限制为T的成员名,返回类型T[K]则精确到该键对应的值类型。这套表达在 C++ 里没有直接对应物:C++ 的模板参数是类型或非类型值,不能直接对「对象的成员名」做约束,需要借助 SFINAE、decltype或概念手工搭建。
TypeScript 泛型还有默认类型参数(<T = string>)、泛型约束的组合、条件类型与映射类型等能力。这些都属于类型层面的运算,编译后不保留。运行时无法判断一个值是不是Box<number>,也无法根据T做不同的分支——因为T已经不存在了。
三、四个维度的对照
| 维度 | C++ 模板 | TypeScript 泛型 |
|---|
| 实例化时机 | 编译期,按实参生成多份代码 | 仅类型检查,运行期擦除 |
| 运行期类型 | 保留(每份实例是独立函数) | 不保留,只有一个实现 |
| 约束方式 | SFINAE、static_assert、C++20 概念 | extends结构约束 |
| 特化 | 全特化、类模板偏特化 | 无特化,用条件类型模拟 |
| 代码体积 | 可能膨胀(每种类型一份) | 不随类型参数增加 |
| 类型检查 | 实例化时检查模板体 | 调用点与实现分别检查 |
| 编译错误 | 展开后可能很长,概念可改善 | 通常指向具体的类型不匹配 |
| 典型用途 | 零开销抽象、容器、编译期计算 | 复用类型关系、API 类型安全 |
这张表里最容易被忽视的是「代码体积」与「运行期行为」的取舍。C++ 为每种类型生成专门代码,换来的是内联、常量折叠和针对该类型的优化;代价是二进制变大、编译变慢。TypeScript 只有一份实现,体积不随类型增长,但无法针对具体类型做运行时特化,也不能在泛型函数里创建T的实例。
另一个差异是「约束到底约束了什么」。C++ 概念约束的是「这个类型能否完成模板体中用到的操作」,检查发生在实例化时(或概念的约束满足检查时);TypeScript 的extends约束的是「这个类型能否赋值给目标类型」,遵循结构兼容规则。两者都能提前发现错误,但判断依据不同:前者看操作是否合法,后者看形状是否兼容。
常见坑点
坑点一:以为 TypeScript 泛型在运行时可用。
❌function make<T>() { return new T(); }或if (x instanceof T),泛型参数在运行期不存在。
✅ 把构造函数或类型守卫作为参数传入,例如接收ctor: new () => T或判定函数。
坑点二:把 C++ 模板定义放进.cpp。
❌ 头文件只留声明,定义在源文件里,其他翻译单元实例化时报未定义引用。
✅ 模板定义放头文件;确实要分离时,在源文件中显式实例化需要的类型。
坑点三:以为 TypeScript 的extends是继承。
❌ 认为T extends Base要求T真的继承Base类。
✅ 它表示结构可赋值;只要形状匹配即可,必要时用implements或判别字段表达意图。
坑点四:C++ 模板参数推导冲突时硬写转换。
❌larger(3, 7.0)推导失败后,把函数改成接收两个不同类型却仍按T返回,导致截断。
✅ 显式指定larger<double>(3, 7.0),或把模板设计成两个类型参数并明确返回类型。
坑点五:用any绕过 TypeScript 泛型约束。
❌function f<T>(x: any): T { return x; }泛型形同虚设,调用方拿到错误的类型承诺。
✅ 用unknown接收外部输入,缩小后再返回;保持泛型参数与实现的一致性。
坑点六:认为 C++ 模板一定会被内联、一定零开销。
❌ 在头文件里堆大量模板,假设编译器必然展开优化。
✅ 模板是否内联、是否消除抽象,取决于优化设置与具体调用;以生成的代码和测量为准。
坑点七:在 TypeScript 泛型里做运行时校验的假设。
❌ 接口声明Box<number>后就认为运行时一定是数字,外部 JSON 直接断言。
✅ 在边界处做运行时校验,类型断言不能替代数据验证。
坑点八:C++ 模板报错时只看最后一行。
❌ 看到「no matching function」就改调用点,忽略前面「模板体里某个操作不合法」的根因。
✅ 从第一条与模板实例相关的错误读起,或用概念把约束前置。
总结
| 问题 | C++ | TypeScript |
|---|
| 泛型是什么 | 编译期代码生成机制 | 类型层面的参数化 |
| 何时消失 | 不消失,生成具体实例 | 编译后擦除 |
| 如何约束 | 概念、SFINAE、static_assert | extends结构约束 |
| 能否特化 | 可以 | 不能,用条件类型模拟 |
| 运行期能否建 T | 可以(T{}、new T) | 不可以 |
| 代码体积 | 随实例增加 | 基本不变 |
| 错误发现 | 实例化时 | 类型检查时 |
C++ 模板与 TypeScript 泛型共享「类型参数」这个表面形式,底层却是两套哲学:前者用代码复制换取运行期效率与特化能力,后者用类型擦除换取实现唯一与编译产物体积。理解这一点,就不会把 TS 的泛型当成「不用写类型的模板」,也不会期待 C++ 模板具备运行期反射。选择哪种,取决于你要的是零开销的具体代码,还是灵活的类型关系描述。