泛型类
本教程共 100 篇 · 第 62 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
62. 泛型类
本节目标:学完你能自己写一个带类型参数
T的类,理解它为何比object更香。
你一定用过 List<T>、Dictionary<K,V>。那个尖括号 <T> 就是泛型(generics)的标志。本章讲如何自己写出这样的类。
为什么需要泛型类
在没有泛型之前,想写一个「能装任何类型」的盒子,只能装进 object:
var box = new ArrayList();
box.Add(10);
box.Add("hello");
这么做有两个毛病。一是类型不安全:你想装整数,却混进字符串,编译不报错,运行时才炸。二是值类型(如 int)装箱(boxing)成 object 有性能损耗。
泛型类的思路是:把「具体类型」推迟到使用时再决定。类里用一个占位符 T 代表「之后传进来的类型」。
定义一个泛型类
写法很简单,在类名后加 <T>:
Box<int> intBox = new(42);
Box<string> strBox = new("码上学");
Console.WriteLine(intBox.Value);
Console.WriteLine(strBox.Value);
class Box<T>
{
public T Value { get; }
public Box(T value)
{
Value = value;
}
}
输出:
42
码上学
这里 T 是类型参数(type parameter)。Box<int> 把 T 替换成 int,Box<string> 替换成 string。编译器在编译期就锁定了类型。
Note
T只是约定俗成的名字,代表 Type。你也可以用TItem、TKey等更有含义的名字。多个类型参数用逗号隔开,如Pair<K, V>。
泛型带来的两个好处
第一是类型安全。下面的代码编译都过不了,因为 intBox 只接受 int:
Box<int> intBox = new(42);
// intBox = new Box<string>("错"); // 编译错误:类型不匹配
第二是代码复用。同一份 Box<T> 逻辑,既能服务 int,也能服务 Person、Product,不用为每种类型复制粘贴一个类。
Tip用泛型写一次,胜过写十遍几乎相同的类。这正是
List<T>取代老式ArrayList的根本原因。
实战:写一个泛型栈 Stack
栈(stack)是「后进先出」的结构。我们用泛型实现一个简易版:
Stack<int> numbers = new();
numbers.Push(1);
numbers.Push(2);
numbers.Push(3);
while (numbers.Count > 0)
{
Console.WriteLine(numbers.Pop());
}
class Stack<T>
{
private List<T> _items = [];
public int Count => _items.Count;
public void Push(T item) => _items.Add(item);
public T Pop()
{
if (_items.Count == 0)
{
throw new InvalidOperationException("栈为空");
}
var last = _items[^1];
_items.RemoveAt(_items.Count - 1);
return last;
}
}
输出:3 2 1(最后进的先出)。注意 _items[^1] 用了 C# 的范围索引(index from end),取最后一个元素。
栈里放自定义类型
复用前几章的实体 Person,泛型栈同样好用:
Stack<Person> people = new();
people.Push(new Person("小明", 18));
people.Push(new Person("小红", 20));
Console.WriteLine(people.Pop().Name);
class Person
{
public string Name { get; }
public int Age { get; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
}
输出:小红。泛型让 Stack<T> 既能装 int,也能装 Person,类型全程受编译器保护。
多个类型参数:写个通用缓存
当「数据对」由两个不同类型组成,多个类型参数就派上用场。下面是个极简缓存:
var cache = new Cache<string, int>();
cache.Put("苹果", 5);
Console.WriteLine($"苹果库存:{cache.Get("苹果")}");
class Cache<TKey, TValue>
{
private Dictionary<TKey, TValue> _store = [];
public void Put(TKey key, TValue value) => _store[key] = value;
public TValue Get(TKey key) => _store[key];
}
输出:苹果库存:5。TKey 和 TValue 各自独立,既能是 string/int,也能是别的组合。命名带上含义,比 Cache<T, U> 好读得多。
泛型类能继承吗
能。你可以从一个「已封闭」的泛型类派生,把 T 固定下来:
IntBox b = new(99);
Console.WriteLine(b.Value);
class Box<T>
{
public T Value { get; }
public Box(T value) => Value = value;
}
class IntBox : Box<int>
{
public IntBox(int value) : base(value) { }
}
IntBox 继承自 Box<int>,T 在这里被钉死成 int。反过来,派生类自己也可以继续是泛型:
class StringBox<T> : Box<T> { }
只要 T 能对得上,泛型类之间也能形成继承链。
泛型在底层是怎么实现的
C# 的泛型是「运行时真实存在」的。CLR(公共语言运行时)会为用到的每种类型参数组合,生成真正独立的类型。List<int> 和 List<string> 在运行时是两个不同的类,各有一份原生实现。
这点很重要:值类型(如 int)放进 List<int> 时不发生装箱,直接放原生数组,所以性能比老式 ArrayList 好。相比之下,有些语言的泛型只是「编译期擦除」成 Object,运行时丢失类型信息、值类型照样装箱。C# 这种「具现化(reified)」做法,让泛型既安全又快。
Note每个「封闭泛型类型」拥有自己独立的静态字段。也就是说
Box<int>.Count和Box<string>.Count是两个不同的静态变量,互不影响。写泛型工具类时要注意这一点。
默认值的陷阱
泛型类里常需要「返回一个空值」。但 T 可能是值类型,不能用 null。正确做法是用 default:
class Holder<T>
{
public T GetDefault() => default;
}
Console.WriteLine(new Holder<int>().GetDefault()); // 0
Console.WriteLine(new Holder<string>().GetDefault()); // 空
Note
default会按T的实际类型给默认值:值类型是0/false,引用类型是null。这是写泛型库的必备技巧。
如果 T 可能真的是「没有值」,更现代的做法是返回 T?(可空引用或 Nullable<T>),配合可空引用类型分析。下一章会讲怎么用约束限制 T 的取值范围。
什么时候该写泛型类
泛型类最适合以下情形:
- 容器或集合类(
Stack<T>、Cache<TKey,TValue>),内部逻辑与元素类型无关。 - 仓储(repository)、包装器(wrapper)这类「对某种实体做统一操作」的抽象。
- 工具类,比如比较器、转换器,希望一份代码服务多种类型。
但也不是处处都要泛型。如果类里只用到一个具体类型,或逻辑严重依赖某个类型的特有成员,硬写成泛型反而别扭。那时候直接写具体类,或加约束(见下一章)更合适。
Warning不要为了「显得通用」把一切都泛型化。泛型会增加阅读成本;只有当「代码逻辑真的与类型无关、且需要服务多种类型」时,才值得引入。
新手容易踩的坑
第一,泛型类本身不等于具体类型。Stack<int> 和 Stack<string> 是两个不同的类型,不能互相赋值。
第二,不要在泛型里随意用 null 赋值给 T,除非加了约束(见下一章)。需要「可能没有值」时,优先用 default 或 T?。
第三,类型参数名字要有意义。一个数据对写成 Cache<TKey, TValue>,比 Cache<T, U> 好读得多。
第四,静态字段按封闭类型独立存在。Box<int> 和 Box<string> 的静态状态互不共享,别误以为它们共用一份。
最佳实践速览
- 类型参数起有意义的名字:
TItem、TKey、TValue比单个T/U清晰。 - 逻辑与具体类型无关、又要服务多种类型时,才用泛型类。
- 需要返回「空」时用
default或T?,别硬写null。 - 需要限制
T的能力(比如必须能比较、必须有构造函数)时,交给下一章的约束来解决。
常见包装模式:Result
泛型类很适合表达「带类型的容器」。比如一个「可能成功也可能失败」的结果,用 Result<T> 把值和错误信息一起打包:
var ok = new Result<int>(42);
Console.WriteLine(ok.HasValue ? $"得到:{ok.Value}" : ok.Error);
class Result<T>
{
public T Value { get; }
public string Error { get; }
public bool HasValue { get; }
public Result(T value)
{
Value = value;
Error = "";
HasValue = true;
}
}
输出:得到:42。同一套 Result<T>,无论装 int、Person 还是别的,都不必为每种类型重写一篇。这类「类型化包装」是泛型类最常见的落地形态之一。
Tip看到「一批类都长一个样,只是里面的数据类型不同」,就是泛型类的信号。先别急着复制粘贴,想想能不能用
<T>收口。
小结
泛型类用 <T> 把类型变成参数,换来类型安全和代码复用,还顺带避免装箱损耗。它在底层是运行时真实生成的独立类型,既安全又快。它是 List<T>、Dictionary<K,V> 的底层机制,也是你写工具类时最常用的手段之一。