奇异递归模板模式(CRTP)是什么?
从 Unity / C# 视角理解它能解决什么问题
在看一些框架、底层库或者“写得有点绕”的泛型代码时,你可能会遇到这种写法:
public abstract class Base<TSelf> where TSelf : Base<TSelf>
{
}
第一次看到,脑子通常会响起一声小警报:
“这不是递归了吗?为什么子类要把自己传给父类?”
这就是 CRTP,全称:
Curiously Recurring Template Pattern
中文一般翻译为 奇异递归模板模式
不过先提前说清楚一件事:
在 C# 里,我们更常见到的是“CRTP 风格写法”或“自引用泛型约束”。
因为这个概念最初来自 C++ 模板,而 C# 没有 C++ 那种模板机制。
所以这篇文章你可以理解成:
“C# 里如何理解和使用 CRTP 这类写法”
一、先看最经典的结构
C++ 里的经典形式
template<typename Derived>
class Base
{
};
class Derived : public Base<Derived>
{
};
C# 里的对应写法
public abstract class Base<TSelf> where TSelf : Base<TSelf>
{
}
public class Derived : Base<Derived>
{
}
这里最“奇异”的地方在于:
Base需要一个泛型参数TSelf- 这个
TSelf又要求必须继承自Base<TSelf> - 子类
Derived在继承时,把自己作为参数传给父类
所以它看起来像:
子类把“我自己”告诉父类
这就是它名字里“奇异”的来源。
二、它到底在解决什么问题?
CRTP 不是为了炫技,它主要解决的是一个很实际的问题:
父类想知道“当前真正的子类类型”
普通继承里,父类只知道自己是 Base,不知道你到底是:
FireballBuilderIceBuilderAudioServiceEnemyController
但有时候,父类又确实需要知道“最终子类是谁”。
最常见的场景就是:
1. 链式调用想返回子类类型
2. 基类想复用逻辑,但保留子类具体类型
3. 想写“自引用”的通用基类
三、一个最容易理解的例子:链式调用
假设我们想写一个技能构建器基类。
不用 CRTP 的写法
public abstract class SkillBuilder
{
protected float _cooldown;
protected float _range;
public SkillBuilder SetCooldown(float cooldown)
{
_cooldown = cooldown;
return this;
}
public SkillBuilder SetRange(float range)
{
_range = range;
return this;
}
}
public class FireballBuilder : SkillBuilder
{
private float _burnSeconds;
public FireballBuilder SetBurn(float burnSeconds)
{
_burnSeconds = burnSeconds;
return this;
}
}
然后你想这么写:
var builder = new FireballBuilder()
.SetCooldown(1.5f)
.SetRange(8f)
.SetBurn(3f);
问题来了。
因为 SetCooldown() 返回的是 SkillBuilder,
所以 .SetRange() 之后,类型已经“变窄”成了父类,后面就接不上 SetBurn() 了。
用 CRTP 改写
public abstract class SkillBuilder<TSelf> where TSelf : SkillBuilder<TSelf>
{
protected float _cooldown;
protected float _range;
protected TSelf Self => (TSelf)this;
public TSelf SetCooldown(float cooldown)
{
_cooldown = cooldown;
return Self;
}
public TSelf SetRange(float range)
{
_range = range;
return Self;
}
}
public class FireballBuilder : SkillBuilder<FireballBuilder>
{
private float _burnSeconds;
public FireballBuilder SetBurn(float burnSeconds)
{
_burnSeconds = burnSeconds;
return this;
}
}
这时候就可以正常链式调用:
var builder = new FireballBuilder()
.SetCooldown(1.5f)
.SetRange(8f)
.SetBurn(3f);
因为:
SetCooldown()返回FireballBuilderSetRange()也返回FireballBuilder
这就是 CRTP 最常见、最直观的用途:
让父类通用方法返回“真正的子类类型”
四、Unity / C# 里最常见的 CRTP 风格:泛型单例基类
你在 Unity 项目里很可能见过这种写法:
using UnityEngine;
public abstract class MonoSingleton<T> : MonoBehaviour where T : MonoSingleton<T>
{
public static T Instance { get; private set; }
protected virtual void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = (T)this;
}
}
public class AudioService : MonoSingleton<AudioService>
{
}
这里也是典型的 CRTP 风格:
- 基类
MonoSingleton<T> - 子类
AudioService : MonoSingleton<AudioService>
它的好处是:
Instance的类型不是MonoSingleton<T>- 而是具体的
AudioService
所以你可以直接这样写:
AudioService.Instance.PlayBgm();
而不需要手动强转。
五、为什么说它在 C# 里是“CRTP 风格”,不是原汁原味 CRTP
在 C++ 里
CRTP 常常带有 编译期多态 的意味。
因为模板展开后,很多东西在编译期就确定了。
在 C# 里
我们更多是在利用:
- 泛型
- 自引用约束
- 子类类型回传
所以在 C# 里,它更像是在解决:
Self Type(自类型)问题
也就是:
“父类的方法如何返回真正的子类类型,而不是父类类型?”
所以你在 Unity / C# 里,不要把它想成一个“性能黑魔法模式”,
更应该把它理解成:
一种泛型约束技巧,用来让基类更懂子类类型。
六、它和普通继承的区别是什么?
普通继承
父类不知道你到底是谁。
public abstract class Base
{
public Base DoSomething()
{
return this;
}
}
返回的永远只是 Base。
CRTP
父类知道你是哪个子类。
public abstract class Base<TSelf> where TSelf : Base<TSelf>
{
protected TSelf Self => (TSelf)this;
public TSelf DoSomething()
{
return Self;
}
}
返回的是具体子类。
七、它和接口有什么区别?
很多人会问:
“这不就是接口能干的事吗?”
不完全一样。
接口更适合描述“能力”
比如:
IDamageableIInteractableISaveable
它回答的是:
这个对象会什么
CRTP 更适合解决“自类型”
它回答的是:
父类怎样拿到最终子类类型
所以:
- 接口是“能力约束”
- CRTP 是“类型回传/自引用约束”
它们不冲突,甚至经常一起出现。
八、它适合用在哪些场景?
1. 链式 API / Builder
这个是最推荐的。
比如:
- 配置构建器
- 规则构建器
- 编辑器工具链式配置
- UI Fluent API
2. 泛型基类想返回具体子类
比如:
- 通用设置器
- 通用状态节点
- 某些工具基类
3. Unity 里的泛型单例 / 自引用基类
比如:
MonoSingleton<T>- 某些
ManagerBase<T> - 服务基类
不过要注意,这类写法虽然常见,但也容易被滥用。
九、它不适合用在哪些场景?
1. 只是为了“显得高级”
如果没有“返回子类类型”的需求,就不要硬上。
2. 只有一个实现类
如果整个项目里只有一个类会用这个基类,
那很多时候普通继承就够了。
3. 会让团队读代码成本暴涨
CRTP 对初学者很不友好。
如果只是为了解决一个很小的问题,却把代码搞得像镜子迷宫,那不值。
十、在 C# 里用 CRTP,有什么坑?
这部分很重要。
坑 1:(TSelf)this 本质上是强转
比如这个:
protected TSelf Self => (TSelf)this;
如果你写错了继承关系,就可能在运行时报错。
虽然正常写法下没问题,但这说明:
C# 里的 CRTP 不是绝对“神圣安全”的魔法
坑 2:不是所有问题都该靠它解决
很多时候你真正要的是:
- 接口
- 组合
- 普通继承
- 事件
- 委托
而不是 CRTP。
坑 3:容易过度工程化
比如为了一个链式调用,搞了三层泛型基类,
最后项目像被套娃套进了衣柜。
十一、一个更贴近工程实践的判断标准
以后你看到这个模式,先问自己三件事:
1. 我是不是需要“父类方法返回子类类型”?
如果不需要,先别用。
2. 这个问题用普通继承或接口能不能更简单解决?
能的话,优先简单方案。
3. 这个模式会不会让团队更难读懂代码?
如果会,慎用。
十二、在 Unity 项目里,我建议你怎么理解它
按你现在的阶段,我建议你这么记:
CRTP = 一种让泛型基类“知道自己子类类型”的写法
你不用急着把它用到项目里每个角落。
先把它识别出来就够了。
当你以后看到这些代码时,就不会再发懵:
public abstract class Base<TSelf> where TSelf : Base<TSelf>
它通常在表达的是:
- 这是个自引用泛型基类
- 它想在基类里复用逻辑
- 同时保留子类类型信息
十三、你可以先记住的最小版模板
public abstract class Base<TSelf> where TSelf : Base<TSelf>
{
protected TSelf Self => (TSelf)this;
}
子类:
public class Derived : Base<Derived>
{
}
如果你看到这种结构,先别慌,先想到一句:
“这是子类把自己当参数传给父类,好让父类返回或处理真正的子类类型。”
十四、总结
CRTP 是什么
一种“子类把自己作为泛型参数传给父类”的模式。
它解决什么问题
主要解决:
- 基类想返回子类类型
- 基类想复用逻辑,但保留具体子类信息
在 C# / Unity 里常见吗
有,但通常是“CRTP 风格”写法,最常见于:
- Builder
- 泛型单例基类
- 自引用泛型基类
Comments
评论区
欢迎在这里留言交流。