《Effective C#》笔记(3) – 泛型

  • A+
所属分类:.NET技术
摘要

泛型约束可以规定一个泛型类必须采用什么样的类型参数才能够正常地运作。设定约束条件的时候,太宽或太严都不合适。
如果根本就不加约束,那么程序必须在运行的时候做很多检查,并执行更多的强制类型转换操作。而且在编译器为这个泛型类型的定义生成IL码的时候,通过约束还可以为提供更多的提示,如果你不给出任何提示,那么编译器就只好假设这些类型参数所表示的都是最为基本的System.Object,也就是假设将来的实际类型只支持由System.Object所公布的那些方法,这使得凡是没有定义在System.Object里面的用法全都会令编译器报错,甚至连最为基本的new T()等操作也不支持。


只定义刚好够用的约束条件

泛型约束可以规定一个泛型类必须采用什么样的类型参数才能够正常地运作。设定约束条件的时候,太宽或太严都不合适。
如果根本就不加约束,那么程序必须在运行的时候做很多检查,并执行更多的强制类型转换操作。而且在编译器为这个泛型类型的定义生成IL码的时候,通过约束还可以为提供更多的提示,如果你不给出任何提示,那么编译器就只好假设这些类型参数所表示的都是最为基本的System.Object,也就是假设将来的实际类型只支持由System.Object所公布的那些方法,这使得凡是没有定义在System.Object里面的用法全都会令编译器报错,甚至连最为基本的new T()等操作也不支持。

但添加约束的时候也不要过分严格,以至于限制了泛型类的使用范围,只添加确实有必要的约束即可。

创建泛型类时,应该给实现了IDisposable的类型参数提供支持

如果在泛型类里面根据类型参数创建了实例,那么就应该判断该实例所属的类型是否实现了IDisposable接口。如果实现了,就必须编写相关的代码,以防程序在离开泛型类之后发生资源泄漏。这还要分不同的情况:
泛型类的方法根据类型参数所表示的类型来创建实例并使用该实例
类似下面的写法,如果T是非托管资源,那么就会造成内存泄露:

public interface IEngine {   void DoWork(); }  public class EngineDriver<T> where T : IEngine, new() {   public void GetThingsDone()   {     var driver =new T();     driver.DoWork();   } } 

正确的写法应该是:

var driver =new T(); using (driver as IDisposable) {   driver.DoWork(); } 

编译器会把driver视为IDisposable,并创建隐藏的局部变量,用以保存指向这个IDisposable的引用。在T没有实现IDisposable的情况下,这个局部变量的值是null,此时编译器不调用Dispose(),因为它在调用之前会先做检查。反之,如果T实现了IDisposable,那么编译器会生成相应的代码,以便在程序退出using块的时候调用Dispose()方法。
这段代码等同于:

var a = driver as IDisposable; driver.DoWork(); a?.Dispose(); 

使用using后,需要注意的是所有调用driver实例的操作都不可以放在using区域之后,因为那时driver已经被释放了。

泛型类将根据类型参数所创建的那个实例当作成员变量
在这种情况下,那么代码会复杂一些。该类拥有的这个引用所指向的对象类型可能实现了IDisposable接口,也可能没有实现,但为了应对可能实现了IDisposable接口的情况,泛型类本身就必须实现IDisposable,并且要判断相关的资源是否实现了这个接口,如果实现了,就要调用该资源的Dispose()方法。

public class EngineDriver2<T> : IDisposable where T : IEngine, new() {   // it's expensive to create, so create to null   private Lazy<T> driver = new Lazy<T>(() => new T());   public void GetThingsDone() => driver.Value.DoWork();    public void Dispose()   {     if (driver.IsValueCreated)     {       var resource = driver.Value as IDisposable;       resource?.Dispose();     }   } } 

或者可以将driver的所有权转移到该类之外,于是也就不用关心资源的释放了。|

public sealed class EngineDriver3<T> where T : IEngine {   private T driver;    public EngineDriver3(T driver)   {     this.driver = driver;   } } 

如果有泛型方法,就不要再创建针对基类或接口的重载版本

如果有多个相互重载的方法,那么编译器就需要判断哪一个方法应该得到调用。而在引入泛型方法之后,这套判断规则会变得更加复杂,因为只要能够替换其中的类型参数,就可以与这个泛型方法相匹配。
比如有下面三个类型,它们之间的关系如代码所示:

public class MyBase { }  public interface IMsgWriter {   void WriteMsg(); }  public class MyDerived : MyBase, IMsgWriter {   void IMsgWriter.WriteMsg() => Console.WriteLine("Inside MyDerived.WriteMsg"); } 

接下来定义三个重载方法,其中包括了泛型方法:

static void WriteMsg(MyBase b) {   Console.WriteLine("Inside WriteMsg(MyBase b)"); }  static void WriteMsg<T>(T obj) {   Console.WriteLine("Inside WriteMsg<T>(T obj)"); }  static void WriteMsg(IMsgWriter obj) {   Console.Write("Inside WriteMsg(IMsgWriter obj)"); } 

那么如下三种调用写法,结果是怎样的呢?

MyDerived derived = new MyDerived(); WriteMsg(derived);  var msgWriter = derived as IMsgWriter; WriteMsg(msgWriter);  var mbase = derived as MyBase; WriteMsg(mbase); 

下面为运行结果,与你预想是否一致呢?

Inside WriteMsg<T>(T obj) Inside WriteMsg(IMsgWriter obj) Inside WriteMsg(MyBase b) 

第一条结果表明了一个极为重要的现象:如果对象所属的类继承自基类MyBase,那么以该对象为参数来调用WriteMsg时,WriteMsg总是会先于WriteMsg(MyBase b)而得到匹配,这是因为如果要与泛型版的方法相匹配,那么编译器可以直接把子类MyDerived视为其中的类型参数T,但若要与基类版的方法相匹配,则必须将MyDerived型的对象隐式地转换成MyBase型的对象,所以,它认为泛型版的WriteMsg更好。
如果要调用到WriteMsg(MyBase b), 需要将MyDerived型的对象显式地转换成MyBase型对象,就像第三条测试那样。

如果不需要把类型参数所表示的对象设为实例字段,那么应该优先考虑创建泛型方法,而不是泛型类

一般来说,我们通常的习惯是定义泛型类,但有时更推荐用泛型方法。因为使用泛型方法时所提供的泛型参数只需与该方法的要求相符即可,而使用泛型类时所提供的泛型参数则必须满足该类所定义的每一条约束。如果将来还要给类里面添加代码,那么可能会对类级别的泛型参数施加更多的约束,从而令该类的适用场景变得越来越窄。

此外,泛型方法相比泛型类会更加灵活,比如下面的泛型工具类获取提供了获取较大值的方法:

public class Utils<T> {   public static T Max(T left, T right)   {     return Comparer<T>.Default.Compare(left, right) > 0 ? left : right;   } } 

因为是泛型,那么每次调用都要提供类型:

Utils<string>.Max("c", "d"); Utils<int>.Max(4, 3); 

这样虽然类本身的实现比较方便,但调用端使用起来却比较麻烦,更重要的是,值类型可以直接使用Math.Max,而不需要每次都让程序在运行的时候先去判断相关类型是否实现了IComparer,然后才能调用合适的方法,Math.Max可以提供更好的性能,所以可以改进为对于值类型提供不同版本的Max方法:

public class Utils1 {   public static T Max<T>(T left, T right)   {     return Comparer<T>.Default.Compare(left, right) > 0 ? left : right;   }    public static int Max(int left, int right)   {     return Math.Max(left, right) > 0 ? left : right;   }      public static double Max(double left, double right)   {     return Math.Max(left, right) > 0 ? left : right;   } } 

经过这样的修改,将泛型类改成了部分使用泛型方法,对于int、double,编译器会直接调用非泛型的版本,其它的类型会匹配到泛型版本。

Utils1.Max("c", "d"); Utils1.Max(4, 3); 

这样写还有个好处是,将来如果又添加了一些针对其他类型的具体版本,那么编译器在处理那些类型的参数时就不会去调用泛型版本,而是会直接调用与之相应的具体版本。

但也要注意的是,并非每一种泛型算法都能够绕开泛型类而单纯以泛型方法的形式得以实现。
有两种情况,必须把类写成泛型类:

  1. 该类需要将某个值用作其内部状态,而该值的类型必须以泛型来表达(例如集合类)
  2. 该类需要实现泛型版的接口。

除此之外的其他情况通常都可以考虑用包含泛型方法的非泛型来实现。

只把必备的契约定义在接口中,把其他功能留给扩展方法去实现

如果程序中有很多个类都必须实现所要设计的某个接口,那么定义接口的时候就应该定义尽量少的方法,后续可以采用扩展方法的形式编写一些针对该接口的便捷方法。这样做不仅可以使实现接口的人少写一些代码,而且可以令使用接口的人能够充分利用那些扩展方法。

但使用扩展方法时需要注意一点:如果已经针对某个接口定义了扩展方法,而其他一些类又想要以它们自己的方式来实现这个同名方法,那么扩展方法就会被覆盖,类似下面这样,针对IFoo定义了扩展方法NextMarker,同时也在MyType中实现了NextMarker。

public interface IFoo {   int Marker { get; set; } }  public static class FooExtension {   public static void NextMarker(this IFoo foo)   {     foo.Marker++;   } }  public class MyType: IFoo {   public int Marker { get; set; }    public void NextMarker()   {     this.Marker += 5;   } } 

那么下面代码的结果就是5,而不是1

var myType =new MyType(); myType.NextMarker(); Console.WriteLine(myType.Marker);  // 5 

而如果需要调用扩展方法,需要显示地将myType转换为IFoo。

var myType =new MyType(); var a = myType as IFoo; a.NextMarker(); 

参考书籍

《Effective C#:改善C#代码的50个有效方法(原书第3版)》 比尔·瓦格纳