欢迎光临散文网 会员登陆 & 注册

第 70 讲:C# 2 之泛型(四):泛型参数约束

2021-12-07 15:07 作者:SunnieShine  | 我要投稿

泛型的基本语法说得差不多了。下面我们来说一个泛型的一个新语法,用来限制泛型的调用条件:泛型约束(Constraint)。

Part 1 引例

1-1 一个排序的例子

有些时候,泛型参数也不是很好写代码。比如我想实现一个泛化排序,那么我们还是使用冒泡排序法,你就会发现,代码写不动。

如果我们尝试对一个 SequenceList<> 类型的实例排序里面的元素的话,我们必须要对每一个 T 类型的元素进行比较和交换操作。可问题在于,T 是泛型参数,那么 T 类型的实例,我压根就不可能好好地比较:因为它是什么类型我都不知道。

有一个方案是,传一个参数,是委托类型,用于比较两个 T 类型的对象,然后返回 int 的结果。C# 提供了一个泛型委托类型 Comparison<T>,它的声明是这样的:

按照这个思路,我们可以传参,然后修改比较操作:

如此比较,就可以达到排序操作了。

不过,这里我们也不必传入委托类型来比较,因为它不一定非得需要委托类型实例,我们有时候也只需要一个很简单的比较两个对象,类似 int 那样,大小比较就完事了。大不了 string 类型的排序用字典序什么的。可是,这么做我们就不得不对每一个实例都给一个委托类型的实例来参与排序。委托类型的实例一次创建就得开辟内存空间,而且是堆内存(因为是引用类型)。怎么说也会影响一定的性能。

那么,怎么样可以解决这样的问题呢?现在我们有一个新的语法:约束。

1-2 解决办法

我们注意到,SequenceList<> 类型具有增删改查以及排序的这些操作。那么既然有查找操作,那必然得比较数据相等性;另外排序也会比较对象。那么这两种行为必然有所关联。

想到什么关联了吗?还记得 C# 之前基本语法里介绍到了一个 IComparable 接口了吗?这个接口自带一个 CompareTo 方法,专门用来比较对象。C# 在泛型建立后,也新建了很多泛型版本的接口,而 IComparable 也不例外:IComparable<> 类型。来看一下这个接口的声明:

可以看到,这个和 IEnumerable<> 还有点不同:IComparable<> 接口并没有从非泛型版本的接口派生,因此这里直接是一个方法,并且完全不需要 new 修饰符。刚好,这个实例方法可以完全代替 if 条件。

我们使用新泛型语法:where T : IComparable<T> 追加到类型声明头部的末尾:

因为语句是可以换行的,写在后面有点长,我特意换行写下来了。这句话的意思是什么呢?这句话的意思是说,我们通过任何形式调用和使用 SequenceList<T> 类型的时候,这个泛型参数 T 的实际类型必须满足 where T : 条件 的冒号之后的条件,才可以正常使用和书写代码,否则将会产生编译器错误,告诉你,这个泛型参数的实际类型并不满足泛型参数约束。而冒号后直接跟上类型名称,就表示泛型参数的实际类型必须从这个类型派生(或者对接口而言,就叫实现此接口)。

接着我们来看一下 if 条件这里。很神奇的是,但凡我为 T 设定了约束条件后,T 就可以书写 .CompareTo 了。这是什么原理?首先 T 有了约束,就意味着 T 的实际类型必须实现这个接口。既然都实现了这个接口了,那么肯定就可以使用 CompareTo 方法了。这是泛型约束的两个意义:

  1. 对用户而言,限制泛型参数 T 的实际范围;

  2. 对编译器而言,泛型参数 T 实现了接口了,因此你就可以使用这个接口里的成员作为 T 类型的实例,作为额外的可使用项。

下面来说一下,C# 有哪些可用的泛型约束。

Part 2 列举一下所有的泛型约束

C# 提供了如下这些泛型约束:

我们挨个说一下。

2-1 类类型约束

我要获取一个人的身份信息,不管它是 StudentTeacher 还是别的类型,我们使用泛型参数 T 的约束 where T : Person 可以要求 T 仅可以用于一个 Person 类型,或是它的派生类型。

这种类型约束是具体的。只要不走这个 Person 类型派生的所有类型全部都会被拒之门外。

接着我们来说一个比较麻烦的问题。

这两个方法有什么区别呢?是的,前者不转型,后者要转型。后者参数的类型在从模糊类型转为具体类型的时候,是需要用户自己强制转换的,而前者自身就是这个类型,因为用的是泛型参数 T,具体到 Person 类型的话,T 就是 Person;具体到 Student 的话,T 就是 Student

2-2 接口类型约束

接口类型约束就是最开始引例里举的例子,这样的行为。只要我实现了这个接口的类型均可作为泛型参数的实际类型使用。不过请一定要注意,接口是值类型和引用类型均可派生的,而只有类类型约束只应用于引用类型。换句话说,如果写明接口类型约束的话,我们没有任何别的信息可以确定到底这个 T 是一个值类型还是一个引用类型。

2-3 无参构造器约束 new()

无参构造器约束 new() 用于表示一个泛型参数 T 必须包含一个 public 修饰的无参构造器,可提供给外界调用。请注意,无参构造器可以是类型里自定义的,也可以是编译器自己生成的。就结构而言,编译器将无条件自动生成 public 的无参构造器,因而所有结构均满足 new() 约束;但如果是类的话,用户可能会隐藏无参构造器(比如自己创建非 public 修饰的构造器)或直接创建带参构造器,这样可以禁止编译器生成 public 的无参构造器。在这种情况下,可能一个类就不一定能满足 new() 约束了。但始终注意,new() 约束仅能影响到引用类型。

常见情况是为了默认去实例化这个对象。

假设我自己写了一个 List<T> 类型,并需要一个方法,创建一个只有一个元素的 List<T> 类型对象的话,我必须要求 T 具有无参构造器进行实例化才行。为了这里能够实例化 T 类型对象,我设定 where T : new() 使之可以实例化,然后使用 new T() 语句对其实例化。

2-4 引用类型约束 class

引用类型约束暗示泛型参数只能是一个引用类型。比如说类、委托、接口,它们都是引用类型。而枚举、结构都不满足 class 约束。这种约束一般用于广义情况,比如实现一些对象复制内存内容的时候,我们可能会约束一个类型必须包含 Clone 方法。但问题在于值类型是自动复制副本的,因此值类型不需要这个 Clone 方法来复制内,只需要一个赋值运算符就可以了;而相反地,引用类型需要它。因此,我们可以这么写代码:

虽然它没有编译器的实质性影响(除了判断对象满不满足要求要编译器分析一下代码外),但它限制了这个数据类型本身只用于引用类型。

最后一定要注意,这里的 class 语义不是一个类类型限制,而是所有引用类型。

2-5 值类型约束 struct

引用类型配套的另外一种情况自然就是值类型约束 struct 了。不过一般引用类型出现频次较多,所以 struct 约束很少出现了。不过它有一种用法,是表示 Nullable<T> 的这个 T 的时候用。

在这种情况下,我们不得不限制值类型,这样的话我们可以使用语法 T? 来表示这个返回值是包含 null 作为额外数值情况的可空值类型。这在引用类型里是没有这一说的,因为引用类型自己就自带 null 为默认情况,但值类型里没有 null 一说,所以我们需要添加 ? 记号来表示值类型可空。但前提是,它是值类型,才可使用 T? 的记号(或者直接写 Nullable<T>),因此我们要写约束部分 where T : struct

另外,我们查看 Nullable<T> 的官方 API 的声明就可以发现:

在类型的头部末尾是跟着这个 where T : struct 约束的限制的。

2-5 泛型参数约束

虽然这个用得非常少,但还是要说一下。如果在某一个书写代码的地方可以同时看到两个不同的泛型参数的话,要想设定其中一个是另外一个的父类型/子类型的话,就需要用到它。比如假设是 TU,那么写法可以是 where T : U(当然也可能是反过来的)。

2-6 混用约束

要想叠 buff 那样给泛型参数施加多个不同的约束要求的话,我们可以使用逗号分隔每一个约束信息。不过,要注意一下的是,有些约束是包含关系,所以不要混用一些情况。比如类类型约束和 class 约束是包含关系,结构类型约束和 new() 也是包含关系。类似这样的约束形式不要混用。

接着说一下语法的细节。类类型约束和接口类型约束自身就可以包含多个类型在内。比如我同时想让泛型参数实现 IEnumerable<T>ICloneable<T> 接口,那么就挨着写就可以了:

然后是混用其它的约束。比如我想要一个类型是一个引用类型,包含 public 无参构造器和可迭代的,那么就这么写。

注意顺序。C# 强制我们先写 structclass 约束,然后是类类型约束和接口类型约束,最后是 new() 约束。写反了的话,编译器会给出编译器错误,不过它会教我们改变一下顺序,这个顺序记错了不必担心。

混合约束很多时候都用在限定泛型参数一定是一个值类型(或引用类型)来实现某接口的时候。比如我要求泛型参数必须是值类型,且实现接口 I 的话:

这种使用情况居多。

Part 3 泛型约束的灵活使用

下面列举一些奇妙的泛型参数约束的使用,能够让你对泛型约束有一个更深刻和神奇的认知。

3-1 约束多个泛型参数

如果有多个泛型参数的话(虽然之前只是简单提过一嘴),可以使用重复的 where 部分来完成约束:

这是我换行之后的写法。请一定要注意,where 语句之间没有任何符号分隔,特别是不要往 where 语句的末尾加什么分号或者逗号之类的。

3-2 奇异递归模板模式

奇异递归模板模式(Curiously Repeating Template Pattern,简称 CRTP)是 C++ 语言里模板(Template)语言特性里的一个行为,这里 C# 因为类似,因此概念上就直接抄过来用了。

CRTP 在 C# 里是这样的:它表示一个泛型约束。假设类型 A<T>T 也必须是 A<T> 自己或它的派生类型的话,我们就称为这个约束叫做奇异递归模板模式,在 C# 里语法是这样的:

是的,where T : A<T>

可问题是,这样的模式用在什么时候呢?还记得 C# 有一个 IEquatable 接口吗?C# 有了泛型之后,所有这样的接口也全部都有了它们的泛型版本。比如 IEquatable 的泛型版本是 IEquatable<T>。而这里的 T,如果要你自己思考,你认为这个 T 得满足什么条件?

是的,是这个类型自己。考虑到 IEquatable<T> 接口的代码是这样的:

是的,T 就是它自己。比如我有一个 A 类型想要实现接口 IEquatable<T> 了,那么这个 T 就是这里的 A。而仔细分析一下这个泛型约束就可以发现,T 要实现接口自己,那么 A 自己语法上也实现了:

所以它是满足这个泛型约束的写法。

3-3 多泛型参数的交换使用模式

考虑一种情况。假设我想要将一个数据对象以 JSON 形式序列化。

序列化(Serialization)是一种行为,能让一个任何一种数据类型的对象按照字符串或二进制的形式保存它的数据信息,以保存到本地以文件的形式存储。相反地,把二进制或字符串形式的文件打开并解析为一个数据类型的实体对象的过程叫反序列化(Deserialization)。

JSON 序列化是序列化的一种形式,也是 C# 目前推荐的一种序列化模式,它使得一个对象按照 JavaScript 的语法序列化成对应格式的字符串形式。反过来的行为则是反序列化 JSON 代码。

为了一个数据类型能够 JSON 序列化,我必须要求一个对象实现一个接口,比如长这样:IJsonSerializable<T, TConverter>。其中第一个参数 T 是实现 JSON 序列化的那个类型自己,而第二个泛型参数 TConverter 则是你必须实现的一个 JSON 序列化期间需要指定转换行为的类型。

听着很复杂是因为各位没有接触过这个 API。我们来看下这个数据类型的头部:

第一个约束 T 必须实现该接口,这个是前文介绍的 CRTP,表示自己就是当前类型;而第二个约束 TConverter 要求它实现 JsonConverter<> 类型,且自带无参构造器以用于实例化。第一个约束就不必详细解释了,因为前文已经说过了。这里只是要你注意一下,这里有两个泛型参数的时候,也是可以有 CRTP 的使用方式的。而第二个约束,这个 JsonConverter<> 类型是什么呢?在有些时候,我们为了简化 JSON 序列化的过程,有一些数据类型我们不必去获取对象的基本信息,然后挨个序列化,搞成默认的那个输出模式。有些时候,一个普通的字符串可能更方便表达出数据类型的信息,因此我们需要借助 JsonConverter<> 类型来完成。这个 TConverter 泛型参数必须从 JsonConverter<> 类型派生,而这个 JsonConverter<> 类型的泛型参数的实际类型,则应该是这里的 T 自己,这个是第二个参数的含义。

Part 4 约束继承

刚才我们说到一个例子。Nullable<T> 类型有一个自带的约束:where T : struct。而我们要使用这个作为泛型参数的约束的话,我们也必须使用此泛型约束来约束你的泛型参数。

例如这样的例子里,T? 在索引器里作为返回值类型出现。而 T?Nullable<T> 类型的特有记号,因此必须要求这里的 T 也得是一个值类型。而此时如果我们不在第一行加上 where T : struct 的话,可能程序就无法继续编译下去。

这个现象称为约束继承(Constraints Inheritance)。如果你拥有一个实际的代码,要想使用一些已经带有泛型约束的泛型参数,那么你这个泛型参数也必须带有此泛型约束,否则代码将无法通过编译。这也是有道理的,因为你需要让代码能够编译,那么 T? 必须要求 T 至少也得是一个值类型。不管你是否对 T 有别的约束条件,但 T 必须至少应当是值类型才可以使用 T? 语法。

这样的现象也发生在一些你实现派生类型的时候。举个例子,如果你实现了一个基本的数据类型 BaseEntity<T>,它的 T 必须可以比较大小,那么类型的头部应当是这样的:

可如果我们要从这个类型派生下去的话,比如这样的代码:

这样的头部是否正确呢?答案肯定是否定的。因为你的基类型要求 BaseEntity<T>T 至少可以参与比较,但你的派生类型不允许这么做。假设我在一些基类型的代码里追加使用了比如 .CompareTo 的方法调用,由于基类型限制了泛型参数可以参与比较,那么这样的代码是正确的;但派生类型为了执行基类型的方法,那么泛型参数也得带有此约束,否则这个派生类型里的泛型参数就不一定实现了这个接口,也不一定包含了 CompareTo 方法,于是编译器就会在运行时期找不到方法调用。

因此,编译器防止这样的现象发生,必须要求用户在使用之前就必须得遵循约束继承规则。

Part 5 泛型约束的限制

虽然泛型约束对我们实现一些代码有更加方便的方式,可如今的 C# 仍然对很多地方有所限制导致无法我们这么使用。下面列举一些目前 C# 还不让我们这么做的限制。

5-1 泛型约束的条件总是合取关系

目前来说,所有的泛型参数添加的约束,不管你写了多少,它都是析取的:

比如这样的限制要求 T 必须是引用类型、必须有无参构造器,且必须实现了 I 接口。但可以发现,这样的条件是析取的,你必须全部都满足。这么书写的格式并非“满足其中一个即可”,而当前 C# 环境来说,还无法做到“或者”关系的限制。

5-2 泛型约束不能以方法级别限制

到目前来说,泛型约束都无法限制方法级成员。举个例子,我只想让一个数据类型带有 GetEnumerator 方法并返回一个迭代器类型就足够我使用 foreach 循环了。可问题是我们无法使用任何一个 C# 语法来做到这一点,而目前唯一能做到的办法只有实现 IEnumerable 接口来约束泛型参数:where T : IEnumerable<T>

5-3 泛型约束的条件总是针对于实例成员的

到 C# 10 之前,C# 的所有泛型约束的条件都只能设定在实例成员上。比如我实现了一个接口类型,接口里包含了各种各样的成员,但它们都不能是 static 修饰的。

而目前 C# 的泛型约束来说,我们也只能通过接口来限制类型是否实现一些方法。而接口不让存储静态成员,因此这样的条件我们是做不到的。不过到了 C# 10 后,我们可以通过新语法 static abstract 做到这一点,但这是以后的事情了。如果你还在使用早期的 .NET 框架的话,可能你还无法使用这一语法特性。

5-4 泛型约束无法限制对象是枚举类型或委托类型

到 C# 7.3 之前,C# 的泛型约束还无法限制类型是一个枚举或委托类型,因为 C# 团队尚未挖掘出真正这么限制的好处。主要原因是在于,EnumDelegate 类型(所有枚举类型和委托类型的基类型)虽然是 abstract 的,但你仍旧无法自定义类型从它们两个类型派生。换句话说,这种数据类型设计出来只是提供操作执行的,而用户无法创建这两个类型的派生类型。

C# 7.3 以及以后可以使用 where T : Enum 以及 where T : Delegate 来限制泛型参数必须是枚举或委托类型。

5-5 泛型约束无法限制泛型参数可使用指针

到 C# 7.3 之前,C# 的泛型约束还无法限制类型可以使用指针,因为 C# 的所有泛型参数都和指针“绝缘”:指针类型是不能作为泛型参数的。

但在 C# 7.3 以及之后,我们可以使用新的泛型约束 unmanaged 来限制类型可以使用 sizeof(T) 以及 T* 的语法来做一些事情。但目前来说是无法做的。

不过……在 C# 7.3 之前,你可以这么写代码:

C# 有一个没有在官方文档里写出来的关键字 __makeref,可以获取对一个值类型对象的引用,这特别是用在一些数组成员是值类型的时候。

从代码上来看,我们可以使用 __makeref 获取对象的引用,然后返回一个所谓的 TypedReference 类型的实例。这个 TypedReference 你当成一个引用就可以了,具体拿来干嘛的,这个我们不展开讲解,因为这个属于互操作性里的一种黑科技用法,而且不属于 C# 语法。你甚至使用别的支持 C# 的 IDE(点名 JetBrains Rider)都有可能无法编译这段代码。这段代码只对 Visual Studio 有效。

获取引用后,我们可通过转换为 IntPtr 的方式将对象转换为有效地址信息,最后相减就可以得到相邻两块内存的地址差值。而这个差值就等于是一个 T 类型对象占据的内存大小了。

5-6 泛型约束只能限制无参构造器

泛型约束对构造器的限制有点奇怪。目前来说 C# 只能限制一个泛型参数是否自带一个无参构造器,但别的构造器尚不支持。


第 70 讲:C# 2 之泛型(四):泛型参数约束的评论 (共 条)

分享到微博请遵守国家法律