首页 / C# 入门教程 / 泛型类

C# 入门教程

泛型类

本教程共 100 篇 · 第 62 篇 · 更新于 2026-07-31 · 约 11 分钟阅读

C#C# 入门教程编程语言泛型Generics类型安全

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 替换成 intBox<string> 替换成 string。编译器在编译期就锁定了类型。

Note

T 只是约定俗成的名字,代表 Type。你也可以用 TItemTKey 等更有含义的名字。多个类型参数用逗号隔开,如 Pair<K, V>

泛型带来的两个好处

第一是类型安全。下面的代码编译都过不了,因为 intBox 只接受 int

Box<int> intBox = new(42);
// intBox = new Box<string>("错");  // 编译错误:类型不匹配

第二是代码复用。同一份 Box<T> 逻辑,既能服务 int,也能服务 PersonProduct,不用为每种类型复制粘贴一个类。

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];
}

输出:苹果库存:5TKeyTValue 各自独立,既能是 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>.CountBox<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,除非加了约束(见下一章)。需要「可能没有值」时,优先用 defaultT?

第三,类型参数名字要有意义。一个数据对写成 Cache<TKey, TValue>,比 Cache<T, U> 好读得多。

第四,静态字段按封闭类型独立存在。Box<int>Box<string> 的静态状态互不共享,别误以为它们共用一份。

最佳实践速览

  • 类型参数起有意义的名字:TItemTKeyTValue 比单个 T/U 清晰。
  • 逻辑与具体类型无关、又要服务多种类型时,才用泛型类。
  • 需要返回「空」时用 defaultT?,别硬写 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>,无论装 intPerson 还是别的,都不必为每种类型重写一篇。这类「类型化包装」是泛型类最常见的落地形态之一。

Tip

看到「一批类都长一个样,只是里面的数据类型不同」,就是泛型类的信号。先别急着复制粘贴,想想能不能用 <T> 收口。

小结

泛型类用 <T> 把类型变成参数,换来类型安全和代码复用,还顺带避免装箱损耗。它在底层是运行时真实生成的独立类型,既安全又快。它是 List<T>Dictionary<K,V> 的底层机制,也是你写工具类时最常用的手段之一。