一文搞定泛型知识
敬告:本篇文章是我原创所写,首发于 51CTO 技术网站,未经本人授权任何网站、公众号、App 不允许转载,授权的网站、公众号、App 需明确标识本篇文章首发地址。需转载请联系 [email protected]
泛型是程序设计语言的一种风格,允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。泛型在 .NET 中应用尤其广泛,泛型是在 .NET 2.0 CLR 中的增加的一项新功能,类似于 C++ 的模板但不如 C++ 的模板灵活,不过也有一些自己的特性。泛型为 .NET 引入了类型参数的概念,这样便可以把指定类型的工作推迟到客户端代码声明并实例化类或方法的时候执行。下面我们就来讲解一下泛型的知识。
一、当 C# 没有泛型
在 .NET 2.0 以前没有泛型的时候,开发人员一直在使用 System.Collections.Stack 类,它是一个栈类型的集合对象。 Stack 通过 Push 和 Pop 方法向集合中添加和删除数据。很多开发人员通过前面的描述都会认为使用 Stack 很简单,但是其中存在一个重大的缺陷。 Stack 类所保存的是 object 类型,这样就导致了 CLR 无法验证 push 进集合中的对象是不是想要的类型。
此外当我们使用 Pop 方法是需要将它的返回值转换为我们需要的类型,因此这里就存在一个问题,如果 Pop 方法的返回值不是我们需要的类型那么就有很大可能引发异常。这里的返回值转换使用的是强制类型转换,由于使用了强制类型转换将类型检查放在了运行时进行,因此代码就变得更加脆弱。使用 Stack 类还存在一个性能问题,将值类型的实例传递给 Push 方法,运行时将会对它进行装箱操作,频繁的执行值类型装箱操作系统会频繁的分配内存、复制值已经进行垃圾回收,这样就导致了大量的性能开销。通过前面的描述部分读者应该看出来了 Stack 类不是类型安全的类,因此在不使用泛型的情况下,我们如果修改 Stack 类并保证它是类型安全的,并且要求它存储指定的类型的话,我们必须这么做:
public class StackDemo
{
public virtual User Pop();
public virtual void Push(User user);
//more code
}
上面的代码是不是很简单?如果你真的这么认为那么你就是想多了,由于我们要求只能存储 User 类型的队形,因此我们需要对 Stack 的每个方法进行重写实现,如果我们还需要一个存储 Student 类型的 Stack ,我们就需要再重写一次 Stack 的每个方法。这就凸显了一个问题,代码中产生了大量的类似的代码和重复的代码。
另外在没有泛型的情况下如果声明允许包含 Null 值的变量的时候就比较麻烦了。一般情况下我们常用的有两种方法。
-
方法一
对需要处理 null 值的每个类型都需要声明可空数据类型,我们来看个简单的例子:struct NullInt { public int Value { get; private set;} public bool HasValue {get; private set;} }
上述例子很简单,但是存在两个问题,首先如果我们有很多可空类型的话我们就需要编写大量的类似代码,其次如果可空值类型发生了改变那么我们就必须修改所有的可能类型声明,可想而知工作量是非常巨大的,而且也很容易出现纰漏和错误。
-
方法二
这个方法的出现就是为了解决我们在方法一种所提到的两个问题。我们只需要声明一个可能类型即可,类型中包含 object 类型的 Value 属性,同样我们先来看一下代码:struct NullType { public object Value {get; private set;} public bool HasValue {get; private set;} }
这个方法虽然充分解决了方法一出现的问题,但是它并不完美。因为运行时在设置 Value 属性的时候总是会对值类型进行装箱,另外通过 NullType.Value 获取值得时候需要进行强制类型装换,这个操作在运行时可能会报错。
二、泛型概述
泛型类型是 C# 2.0 引入的,它的引入在一定程度上减轻了开发人员的压力,同时也使得程序变得更加健壮和稳定。泛型类的语法也很简单,用尖括号声明泛型类型参数和提供泛型类型实参即可。我们来看一个定义泛型的例子:
public class DataBaseOperating<T>
{
private T[] ModelArray{get;}
public bool Insert(T t)
{
//more code
}
public T SelectOne()
{
//more code
}
public List<T> SelectAll()
{
//more code
}
//more code
}
前面这段代码我们定义了操作数据库的泛型类,这个类可以被项目中所有需要操作数据库的类使用,我们只需将类型实参传递进来即可。例如我们需要向数据库插入一条 User 数据我们可以这么做:
//more code
DataBaseOperating<User> dbOp=new DataBaseOperating<User>();
dbOp.Insert(user);
//more code
我们看到在定义泛型类的时候我定义类型参数用的是 T ,这么做是大部分 C# 开发人员的一个习惯,也可以说是一个大家都默认的规范,我们在开发时一般都会使用以大写字母 T 作为前缀来表明它是一个类型参数。泛型的定义和使用就这么多,是不是很简单呢?下面我们就来讲解一下泛型各个方面。在学习泛型类之前我们要先来了解一下它的优点,来看看为什么微软在 C# 2.0 中引入了泛型类。
- 泛型促进了类型安全,它确保了参数化类中只有成员明确希望的数据类型才可以使用;
- 类型检查会在编译时发生进而减少了在运行时出现强制类型转换无效的错误;
- 泛型类成员使用的是值类型,因此就不会出现 object 装箱转换操作。并且代码既保持具体类的优势又避免了具体类的开销,这样代码的性能得以提高内存消耗也变得很少。
-
构造函数
我们在开发中经常用到构造函数,在泛型类和泛型结构中同样也适用构造函数。泛型类/结构的构造函数和普通类/结构的构造函数是一模一样的,不需要类型参数只需要按照普通类/结构的构造函数定义方法定义即可。
public class Demo<T> { public T key {get;set;} public Demo(T t) { key=t; } } public struct Demo<T> { public T value {get;set;} public Demo(T t) { value=t; } }
Tip:构造函数包含类型参数也可以
-
结构与接口
在 C# 中不仅仅存在泛型类,还存在泛型接口和泛型结构。泛型接口和泛型结构的语法和泛型类相同。这里主要讲解一下在类中多次实现同一个泛型接口接口。我们先来看一下代码:
public interface IDemo<T> { ICollection<T> items {get;set;} } public class Demo:IDemo<User>,IDemo<Student> { ICollection<User> IDemo<User>.items {get;set;} ICollection<Student> IDemo<Student>.items {get;set;} }
在上述代码中我们在类中显示实现了两个不同类型实参的同一个泛型接口,一般来说在类中多次实现泛型接口并非是一个最优的选择,因为它会造成代码的混淆以及在使用的过程中造成误会。因此除非特殊情况,绝大多数情况下我们不应该在一个类中多次实现同一个接口。
-
默认值
当我们需要在泛型类的构造函数中部分属性进行初始化,而其他属性不进行初始化,但是我们在开发中无法确定传入泛型类中的类型参数是什么,因此我们也无法通过具体的值设置默认值。这种情况在 C# 中可以说是非常好解决,我们可以调用 default 操作符来给传入的任意类型参数提供默认值。例如下面这段代码,我们只初始化 Key ,Value 的初始化则利用 default 操作符。
public class Demo<T> { T tKey {get;set;} T tValue {get;set;} public Demo(T key) { tKey=key; tValue=default(T); } }
Tip:default 中的参数并非是必须传入的,在 C#7 中如果可以推断出数据类型的话是不需要指定参数的。比如
Demo<T> demo = default(T)
就可以写成Demo<T> demo=default
。 -
多类型参数
前面我们所讲的都是单个类型参数的泛型类,但是泛型类型不仅仅只能具有一个参数,它可以具有无限多的参数,例如我们定义一个泛型类,它的构造函数接受两个不同类型的参数,代码可以这么实现。
public class Demo<TKey,TValue>() { public TKey key {get;set;} public TValue value {get;set;} public Demo(TKey tKey,TValue tValue) { key=tKey; value=tValue; } }
我们在使用 Demo 时只需要声明和实例化语句尖括号中指定的多个类型参数即可。在调用时要提供和方法参数匹配的类型。
Demo<string,int> demo=new Demo<string,int>(1,"小明"); Console.Write($"编号 {demo.key} 是 {demo.value}")
Tip:在 C# 中在同一个命名空间中可以存在多个同名但类型参数数量不同的类。在部分文章或图书中会将类型参数数量称为 元数 。
-
嵌套泛型类型
嵌套泛型类型在开发中用的比较少,但是还是有必要在这里说一下,因为有部分开发人员对于这一块不甚了解。嵌套泛型类型的外层也是一个泛型类型,外层的这个泛型类型通常被称为包容泛型类型,嵌套泛型类型会自动获得包容泛型类型的类型参数,这段话有些绕口,我详细讲解一下。例如 A 是包容泛型类型,它有一个类型参数 T,B 是嵌套泛型类型,它位于 A 中,那么 B 也可以使用 A 的类型参数 T ,如果 B 中也包含一个类型参数 T ,那么 B 会隐藏 A 的类型参数 T 。这里需要提醒的是如果嵌套泛型类型的类型参数和包容泛型类型的类型参数相同,那么开发工具将会出现编译警告,这个警告是在告知开发人员使用了相同的类型参数,因此这里就引出一条编码规则:避免在嵌套泛型类型中使用同名参数隐藏外层类型的类型参数。
-
泛型方法
前面我们所说的都是泛型类,在 C# 中除了有泛型类还有泛型方法,泛型方法的语法和泛型类的语法类似,并且泛型方法不仅可以出现在泛型类种也可以出现在普通类中。泛型方法和泛型类相比有一个很特别的地方,就是泛型方法可以自己推断类型。编译器可以根据传给方法的实参来推断泛型参数类型。因此如果想让方法类型推断成功那么实参类型必须与泛型方法的形参相匹配。
三、泛型约束
在开发中大部分情况我们不允许任何不符合我们要求的类型参数出现在我们的代码中并引起错误。要杜绝这个问题就需要用到泛型约束。声明泛型约束需要使用 where 关键字,后面跟一对 参数:要求 。这里面的参数必须是泛型类型中声明的一个参数,要求描述的是类型参数所能转换成的类或接口等条件。泛型约束分为:接口约束、类类型约束、class 和 struct 约束、多约束以及构造函数约束。下面我们就来一一讲解一下。
-
接口约束
为规定某个数据类型必须是向某个接口,需要声明一个 接口类型约束 ,利用这种约束可以避免需要通过转型才能调用一个显示接口成员的实现。下面我们通过一个代码段来讲解一下接口约束。
public class Demo<T> where T:System.IComparable<T> { //more code }
在上面这段代码中我们添加了 System.IComparable 约束,也就是说所提供的类型参数都必须实现 System.IComparable 接口。那么当我们向 Demo 传递 StringBuilder 作为类型参数来创建 Demo 变量时编译器会报告一个错误,这是因为 StringBuilder 没有实现 IComparable 接口。
-
类类型约束
当我们需要将类型实参转换为特定的类类型时就需要用到 类类型约束。类类型约束的语法和接口约束语法相同。这里有一点需要注意如果同时指定了多种约束,那么类类型约束必须位于第一位(第一个出现),并且泛型约束中是不允许使用多个类类型约束的,这是因为我们的代码不可能从多个不想管的类中派生出来,同样类类型约束也不能指定密封类或者不是类的类型。
-
class、struct 约束
class 和 struct 约束是一个很容易出错并且也很容易让新手程序员造成困惑的地方。首先 很多新手程序员看到 class 约束会认为是将类型实参限制为类类型,其实不是这样的。class 约束是类型实参为引用类型,因此这里使用接口、类、委托以技术组类型都符合这个条件。struct 约束和 class 约束正好相反,它是将类型实参限制为值类型,并且值类型还不能是可空值类型。因为可空值类型是作为泛型 NUllable 来实现的,并且 NUllable 中的 T 使用的是 struct 约束。如果可以使用可控制类型那么 NUllable 就有很大的可能被用成 NUllable<NUllable> 。这么做可以说毫无意义,并且也不符合语法和逻辑。
Tip:因为 class 约束要求引用类型而 struct 约束要求值类型,因此这两种约束是不能同时出现的。
-
多约束
我们可以为任意类型的参数指定任意水昂的接口约束,所有的接口约束需要用逗号分割。如果存在多个不同类型的约束,针对每种约束都需要写一个 where 关键字,不同种类约束之间不需要用任何符号分割。我们来看一下多约束的代码段:
public class Demo<TKey,TValue> where TKey:IA,IB where TValue: ClassA { //more code }
-
构造函数约束
有时我们需要在泛型类中创建类型实参的实例,这时我们可以规定传入泛型类的类型实参必须具有构造函数,如果要实现这一点我们可以使用 new() 来作为限制,这个约束叫做 构造函数约束 。这里需要注意的是构造函数约束必须位于其所有其他约束的后面,并且它只能对默认构造函数进行约束,而不能对有参构造函数进行约束。
Tip 1:关于约束继承这个问题,想必好多开发人员都是一头雾水。在这里我通过简单的几句来说一下约束继承。首先无论是泛型类型参数还是它们的约束都不会被 派生类 继承,这是因为泛型类型参数和约束不是类的成员。虽然不能被派生类继承,但是可以被从其派生的泛型类所继承。由于派生的泛型类类型参数时泛型基类的类型实参,所以类型参数必须具有等同于或者强于泛型基类的约束条件。
Tip 2:泛型方法同样也可以使用约束,约束条件和泛型类类似。
六、总结
句来说一下约束继承。首先无论是泛型类型参数还是它们的约束都不会被 派生类 继承,这是因为泛型类型参数和约束不是类的成员。虽然不能被派生类继承,但是可以被从其派生的泛型类所继承。由于派生的泛型类类型参数时泛型基类的类型实参,所以类型参数必须具有等同于或者强于泛型基类的约束条件。
Tip 2:泛型方法同样也可以使用约束,约束条件和泛型类类似。
六、总结
这篇文章我主要讲解了泛型的一些知识,不能说很全面,但已经覆盖了百分之九十的内容。泛型在开发中可以说是经常用到,良好的使用泛型可以提高代码复用率以及程序的运行性能。
上一篇: 数据理解
下一篇: python保存和加载机器学习模型