前言

为了提升一下自己的姿势水平,买了 Effective C#More Effective C# 两本书,想一边阅读一边强迫自己做点笔记(主要是自己的理解),不然怕是看了就忘。另外手头还有一本《深入解析C# ( C# in Depth )》其实我已经阅读了一遍,但感觉没咋读懂,下次有机会也用这种方式重读一遍。

之前为了找实习(今年真难啊),花了很多时间在了刷LeetCode和背八股文上。现在离去实习也没多久了,啥也不会过去不太好,所以后续应该也要花相当时间在学Java上。今天这篇是 Effective C# 的第三章。

第三章 合理地运用泛型

泛型还是一个比较方便和重要的特性。Java的泛型基于类型擦除,也就是编译器会保证类型安全,但实际运行时类型都被擦除了,所有不同List<T>都是同一个类型List(个人的理解,有错误请务必指出)。而C#的泛型则是真正的泛型类型,比如通过typeof(List<int>)可以看到其运行时类型System.Collections.Generic.List`1[System.Int32]。而这些泛型类只有在运行时碰到了,CLR才会将其实例化,可以避免不必要的开销。

另外值类型和引用类型分别作为类型参数的泛型类也不相同。泛型参数包含值类型时,CLR在JIT时都会生成单独特化的代码,不同泛型类之间不共享代码。这样能够防止值类型的装箱和拆箱问题。而对于引用类型,CLR生成的不同泛型类的代码是共享的,类型安全通过编译器在编译时保证(但我觉得即使生成的代码相同,也应该还另外保存了类型信息,因为typeof()是可以看到运行时泛型类中的泛型参数究竟是什么引用类型的,这一块书上没说),这样可以缩减程序尺寸和提升性能。

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

给泛型的类型参数指定约束条件,这样只有满足特定条件的类型才能作为该泛型的参数。约束条件具体都包括哪些,请参考文档 类型参数的约束(C# 编程指南)(中文版在表格的倒数第2-5行有显示错误,英文版没问题,分别应该是基类名和接口名)。

书中主要是约束条件限制得太宽,那就很难使用,因为缺少信息,很多操作不能使用,比如

public static bool AreEqual<T>(T left, T right) where T : IEquatable<T>
{
    return left.Equals(right)
}

如果设定了IEquatable<T>的约束条件,那么在内部对于T类型的变量就可以使用IEquatable<T>.Equals(),否则就只能调用Object.Equals()

同样,如果设置得过严,那就失去了泛型的优势,比如:

// 我想的一个例子,实际上不太合理,但用来说明应该足够了
static void Print<T>(T list) where T: IList<int>
{
    foreach (var item in list)
    {
        Console.WriteLine(item + 1);
    }
}

HashSet<int>对象按理说应该也能完成上面循环输出的操作,但实际上却无法使用,因为HashSet<int>并没有实现IList<int>接口。而foreach操作只要实现了IEnumerable<T>就能使用,因此把IList<int>约束换为IEnumerable<int>更加合理。

另外书中提醒还有new()约束也要注意使用,因为有的时候代码中的new()可以用default()替代。其中对于值类型,new()返回的就是值类型的默认值,所以和default()没区别(注意C# 10开始支持自定义struct的无参构造函数,对于这样的struct,new()default()就不同了!);对于引用类型,默认值都是null,显然new()default()和差异很大。

总的来说,泛型就是为了在各种场合能够广泛使用,所以要仔细考虑约束条件。约束条件太宽,有很多特性难以使用;约束条件太严,使用场合就受限。因此要仔细考虑每一条约束条件,只保留必要的条件。

第19条 通过运行期类型检查实现特定的泛型算法

放宽类型参数的限制(比如让输入参数的类型为接口,而非一个具体类型),可以让程序更加通用和泛化,但是这又会导致实现的时候无法使用一些具体类型才拥有的特性。因此可以通过运行期类型检查来检测参数的运行时类型,如果是一些特定类型则使用其的某些特性,以此达到提升程序性能的目的。

书中举了个作者自己写的例子,但我觉得直接看List<T>的源码就好,List<T>有一个参数为IEnumerable<T>类型的构造函数:

public List(IEnumerable<T> collection)
{
    if (collection == null)
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);

    // 运行时类型检查
    if (collection is ICollection<T> c)
    {
        int count = c.Count;
        if (count == 0)
        {
            _items = s_emptyArray;
        }
        else
        {
            _items = new T[count];
            c.CopyTo(_items, 0);
            _size = count;
        }
    }
    else
    {
        _items = s_emptyArray;
        using (IEnumerator<T> en = collection!.GetEnumerator())
        {
            while (en.MoveNext())
            {
                Add(en.Current);
            }
        }
    }
}

因为输入泛型参数为IEnumerable<T>,那么只能使用IEnumerable<T>的特性,从源中逐个访问元素然后添加进新的List<T>中。由于不知道源中的元素总数到底为多少,也就不能提前开辟好空间,逐个添加的时候会触发List<T>的自动扩容机制,相对性能就较差。而不少容器其实在实现了IEnumerator<T>的同时还实现了ICollection<T>(其实ICollection<T>本来就继承于IEnumerator<T>),ICollection<T>接口提供了CountCopyTo()。因此如果输入参数实现了ICollection<T>,那直接先开辟Count大小的空间,然后利用CopyTo()把源元素都复制进新的List<T>中就好了。

因此我们把List<T>构造函数输入参数的类型设为IEnumerator<T>,这样只要实现了IEnumerator<T>,就可以用来创造一个新List<T>对象。同时在构造函数中,还会做额外的运行时类型检查,如果输入参数还实现了ICollection<T>,那么就可以使用更高效的算法,兼顾了通用和性能。

(其实感觉这条有点跑题,确实这条建议很有用,但是跟泛型没啥关系。)

第20条 通过IComparable及IComparer定义顺序关系

如果想把某个类型的对象放入集合以执行搜索和排序,那么需要将这些对象之间的关系定义出来。这里主要用到两个接口,一个是IComparable<T>IComparer<T>。前者规定了该类型的对象之间的自然顺序关系,后者是用来表示一种排序机制(比较器)。

IComparable<T>只有一个方法CompareTo(),如果本对象小于作为参数的对象,那么返回小于0的值(一般是-1),相等返回0,大于返回大于0的值(一般是1)。另外还有不带泛型的老版本IComparable,为了兼容性最好也实现,不过要加以限制(默认应该调用泛型版本),并且进行比较的时候要进行类型转换。举个例子:

public class Student : IComparable<Student>, IComparable
{
    public int Id { get; set; }
    public string Name { get; set; }

    // 实现IComparable<Student>
    public int CompareTo(Student other)
    {
        // 利用Id进行比较(使用默认的int.CompareTo())
        return Id.CompareTo(other.Id);
    }

    // 实现IComparable
    // 只有当Student转型成IComparable时,调用的CompareTo()才会是这个版本
    // 限制的原因是为了防止其他开发者误用,避免拆箱装箱等
    int IComparable.CompareTo(object obj)
    {
        // 要做类型检查
        if (obj is Student other)
        {
            return CompareTo(other);
        }

        throw new ArgumentException();
    }
}

另外还可以重载关系运算符>, >=, <, <=,在运算符内部调用泛型版本的CompareTo()即可(我觉得不重载运算符也行吧,像string也有CompareTo()但是并不支持关系运算符)。

实现了IComparable<T>IComparer<T>,就为类型实现了默认的排序方式。但有时候可能需要新的排序方式,或者给一些本身并没有提供比较功能的类型排序,这时我们需要实现了IComparer<T>的排序器。比如要根据上面StudentName排序,我们需要:

var list = new List<Student>()
{
    new Student()
    {
        Id = 2,
        Name = "1"
    },
    new Student()
    {
        Id = 1,
        Name = "2"
    }
};

// 经典写法,麻烦
public class StudentComparer : IComparer<Student>
{
    public int Compare(Student x, Student y)
    {
        return x.Name.CompareTo(y.Name);
    }
}

list.Sort(new StudentComparer());

// 用Comparer<T>.Create(Comparison<T>)创造比较器
// 参数是委托Comparison<T>,输入两个参数x和y,返回小于0时代表x<y
list.Sort(Comparer<Student>.Create((x, y) => x.Name.CompareTo(y.Name)));

// 而且像List<T>.Sort()支持直接用Comparison<T>做参数,不用创造比较器
list.Sort((x, y) => x.Name.CompareTo(y.Name));

另外设计排序比较时没有必要重载Equals()==,因为确定先后顺序和判断是否相等是不同的操作。即使CompareTo()结果为0,Equals()也可能是false,因为那也只是代表可能作为排序标准的那个属性相等,而不代表两个对象就相等了。

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

一般来说,给泛型添加约束,就能使用该条件所拥有的一些特性,比如如果约束了类型必须实现IEnumerable<T>,那么就能对其进行foreach等操作。而有些时候我们追求泛用性,不使用某些约束,自然也就不能使用相关的特性,也就无需关心输入的类型参数是不是具有这些特性了。

IDisposable不同,如果泛型类的类型参数没有对其做约束,那么可能会有如下情况:

public interface IEngine
{
    void DoWork();
}

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

当T是实现了IDisposable的类型的时候,因为没有释放操作,所以有可能会造成内存泄露的问题。因此如果需要创建T类型的局部变量,就应该对其是否实现了IDisposable接口做检查:

public void GetSomethingDone()
{
    // 先实例化变量
    var driver = new T();

    // 在using语句里面做类型转换
    using (driver as IDisposable)
    {
        // 不管using的是什么,都会正常执行这条语句
        driver.DoWork();
    }
}

这个方法还是挺巧妙的,以前应该是没见过这种操作。原理就是先实例化一个T变量,然后在using语句里将其类型转换至IDisposable。如果T没有实现IDisposable,那么using语句中的就是null,编译器就不会调用Dispose();如果实现了IDisposable,那么using语句中的就是driver向上转型为IDisposable的临时变量,离开using范围的时候,就会调用这个变量的Dispose()来释放资源(本质上这个临时变量指向的对象跟driver指向的相同,调用的也是其方法)。而在using内部,因为调用的是driver,所以不管using语句中的临时变量是什么,都能正常执行。相当于:

public void GetSomethingDone()
{
    var driver = new T();
    var tmp = driver as IDisposable;
    // temp是什么对driver.DoWork()没影响
    driver.DoWork();
    // driver如果未实现IDisposable,temp就为null,不会执行Dispose()
    tmp?.Dispose();
}

但如果创建的是T类型的成员变量,就会麻烦一些,得让泛型类本身实现IDisposable接口,书中的例子如下:

public sealed class EngineDriver2<T> : IDisposable where T : IEngine, new()
{
    // 防止创建driver的代价过高,用了延迟加载
    private Lazy<T> driver = new Lazy<T>(() => new T());

    public void GetThingsDone() => driver.Value.DoWork();

    // 实现IDisposable接口
    public void Dispose()
    {
        if (driver.IsValueCreated)
        {
            var resource = driver.Value as IDisposable;
            resource?.Dispose();
        }
    }
}

而且这个例子做了一定的简化,因为声明的是sealed类,意味着无法被继承,也就不用实现第二章第17条建议的标准dispose模式。另外还要再添加标识符防止多次调用Dispose()等等。

可以看到如果要让泛型类支持IDisposable相关的还是很麻烦的,因此可以把创建T类型变量的职责放到外部,通过依赖注入的方式来使用该变量,这样也就把调用Dispose()的职责移到了外部:

public sealed class EngineDriver<T> where T : IEngine
{
    // 私有T类型的成员变量
    private T _driver;

    // 通过依赖注入获取外部的T类型的变量
    public EngineDriver(T driver) => _driver = driver;

    public void GetThingsDone() => _driver.DoWork();
}

这样泛型类的职责相对就小了很多,实现难度也降低了。在实际泛型类的编写过程中,为了其实用性,有必要根据上面几种方法,针对IDisposable的可能情况做好应对。

第22条 考虑支持泛型协变和逆变

泛型的变体机制,分为协变和逆变,另外还有不变。只有泛型接口和泛型委托支持变体,泛型类和泛型方法都不支持。这个东西理解起来还是很绕的,举个例子来说:

IEnumerable<string> a = new List<string>() { "#66ccff" };
IEnumerable<object> b = a;

string能隐式转换成object是很自然的事情,但是从IEnumerable<string>隐式转换到IEnumerable<object>在原来是做不到的,直到C# 4.0支持了泛型协变和逆变之后才可以。像在string能转换成object的情况下,IEnumerable<string>也能转换到IEnumerable<object>叫做协变(转换是同方向的);而如果是在string能转换成object的情况下,Action<object>能转换到Action<string>就叫做逆变(转换是反方向的)。

变体需要在泛型接口或委托中提前声明。协变发生在类型参数只用作输出时,所以修饰符为out;逆变发生在类型参数只用作输入时,所以修饰符为in;如果类型参数既做输入也做输出,那么就只能声明为不变了(即默认,无修饰符)。比如:

// 协变(T仅作输出)
public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

// 逆变(T仅作输入)
public delegate void Action<in T>(T obj);

// 不变(T既做输入也做输出)
public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable
{
    ...
}

为什么只用于输出时是协变,而只用于输入时是逆变,既做输入也做输出就只能为不变呢?我继续根据例子讲讲我的理解:

// 协变
IEnumerable<string> a = new List<string>() { "#66ccff" };
IEnumerable<object> b = a;
// c是object
var c = b.First();

我们原来就能从IEnumerable<string>中获取string类型对象(通过IEnumerable<string>的输出),而string类型本来就能转换为object类型,那理应IEnumerable<string>的每个输出也能以object类型的方式获取。而IEnumerable<string>的类型参数string恰好也仅用于输出,因此IEnumerable<string>可以协变到IEnumerable<object>

// 逆变
var a = new Action<object>(Console.WriteLine);
Action<string> b = a;
// 输入字符串
b("#66ccff");

Action<object>则相反,类型参数仅用于输入。Action<object>的委托接收的参数为object类型,那string类型的变量自然也可以通过隐式转换成object类型的方式作为该委托的参数。因此Action<object>可以逆变到Action<string>

// 不变
IList<string> a = new List<string>() { "#66ccff" };
// 尝试协变,但是本句无法通过编译
IList<object> b = a;
b.Add(new object());

IList<string>因为类型参数仅用于输入也用于输出,因此为了安全考虑被设计成不变。对于输入(比如Add()方法)来说,假如可以实现上面例子中的协变,IList<string>协变到IList<object>,那么按理说就能往IList<object>中添加object类型元素。但两个IList引用本质指向的是同一个List<string>对象,上面的操作会导致往List<string>对象中添加了object元素,造成了类型的不安全。

// 不变
IList<object> a = new List<object>() { new object() };
// 尝试逆变,但是本句无法通过编译
IList<string> b = a;
var element = b[0];

这个就更好理解了,对于输出(比如通过索引访问)来说,假如可以实现上面例子中的逆变,IList<object>逆变到IList<string>,那么通过IList<string>访问得到的岂不是都是string类型的变量?但实际List对象里存储的都是object类型变量,object类型也不能隐式转换到string

另外注意值类型是无法使用变体的,原因就是文章开头所说的,对于一个泛型类来说,当所有类型参数都为引用类型的时候,CLR通过JIT生成的泛型类的代码是复用的;但如果类型参数中有值类型,对于每一个不同的值类型,CLR都将生成单独的代码(这样可以避免装箱拆箱)。这样也就导致比如List<int>转型成IEnumerable<int>之后,没法协变成IEnumerable<object>,因为CLR生成的List<int>List<object>的代码并不共享。

单个类型参数最多只能有一个修饰符,如果有多个类型参数的话则可以各自有不同的修饰符,比如:

public delegate TResult Func<in T, out TResult>(T arg);

只要类型参数和其修饰符符合上述规则就行。

另外还有当协变和逆变结合的时候,这个就更绕了,注意不要判断错误了,比如有如下关系:

// 支持逆变
interface IA<in T>
{
}

class A<T> : IA<T>
{
}

// 支持协变
interface IB<out T>
{
    // 好像类型参数明明只做了输入而不是输出?
    void Add(IA<T> foo);
}

class ListA<T> : IB<T>
{
    // 一个List用于存IA<T>对象
    private List<IA<T>> _list = new List<IA<T>>();

    // 往List里添加IA<T>元素
    public void Add(IA<T> a)
    {
        _list.Add(a);
    }
}

可以看到接口IB明明是对T的协变,按理说应该是用作输出,但是在其方法里好像是作为输入参数的,为什么会这样呢?

// 协变
IB<object> b = new ListA<string>();
IA<object> a = new A<object>();
// 其实这里a发生了逆变
b.Add(a);

ListA<string>对象,其内部的_list变量是List<IA<string>>类型的,Add()方法的参数也只能是IA<string>类型。但由于其接口IB<string>支持协变,因此可以协变为IB<object>。前面举过Add()方法的例子,原来存储的是IA<string>类型,这样协变完以后,岂不是可以添加IA<object>类型的元素,不就类型不安全了吗?但是IA<object>支持逆变,因此添加IA<object>元素也可以视作添加的是IA<string>元素,这样就保证了类型安全。

第23条 用委托要求类型参数必须提供某种方法

C#里泛型的约束就那么几种,有些时候要求不太好用这几种约束表达出来,比如要求类型参数必须要具有某种方法,或者必须提供某种形式的构造函数。其实先通过接口把要求列出来,然后设置必须实现该接口的约束也可以达到目的,但相对比较复杂,尤其是对于那些使用我们编写的泛型的开发者,这样可能导致他们嫌麻烦而不愿意去用。

比如我们需要让我们的泛型类支持相加,因此要求类型参数也必须能够支持相加,比如具有Add()方法。我们可以先写一个IAdd接口,要求提供Add()方法,然后约束泛型类的类型参数必须实现该接口。这样实现就比较麻烦,而且其他开发者得改写自己的类实现IAdd接口才能使用我们的泛型类。而实际上我们可以用委托来实现要求类型参数必须提供该方法。比如:

public class Adder<T>
{
    public T Val { get; set; }

    public Adder(T val)
    {
        Val = val;
    }

    // 把两个Adder的Val相加,得到一个新Adder
    // 具体两个T的相加是怎么实现的,交由开发者自己把委托传进来
    public Adder<T> Add(Adder<T> other, Func<T, T, T> addFunc)
    {
        return new Adder<T>(addFunc(Val, other.Val));
    }
}

这样我们就不用约束泛型类需要实现某个接口,而是让开发者自己把泛型类需要的方法通过委托传进来。

var adder1 = new Adder<int>(1);
var adder2 = new Adder<int>(2);
// 具体两个参数如何相加靠开发者自由实现
// 我这里通过lambda表达式写了个最简单的实现
adder1.Add(adder2, (x, y) => x + y);

再比如实现一个能够自动转换类型的容器(虽然好像没啥意义):

public class TestList<T1, T2>
{
    private readonly List<T2> _list = new List<T2>();
    private readonly Func<T1, T2> _convertFunc;

    // 交由其他开发者实现转换的细节
    public TestList(Func<T1, T2> convertFunc)
    {
        _convertFunc = convertFunc;
    }

    // 输出T2类型的元素
    public T2 this[int index] => _list[index];

    // 输入T1类型的元素,转换成T2类型存储
    public void Add(T1 item)
    {
        _list.Add(_convertFunc(item));
    }
}
// 传入转换的具体实现
var list = new TestList<string, int>(int.Parse);
list.Add("0");
int element = list[0];

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

当同时有泛型版本和普通重载版本的时候,编译器自动匹配的并调用的版本可能并不是我们想要的。举个例子:

// 父类
class Shape
{
}

// 接口
interface IColor
{
}

// 子类
class Circle : Shape, IColor
{
}

然后有泛型和重载方法:

// 泛型
void Print<T>(T t)
{
    Console.WriteLine("Generic");
}

// 重载(父类)
void Print(Shape shape)
{
    Console.WriteLine("Shape");
}

// 重载(接口)
void Print(IColor color)
{
    Console.WriteLine("IColor");
}

实际调用时,编译器自动匹配的版本是:

// 子类实例
var circle = new Circle();
// 转型到父类
Shape shape = circle;
// 转型到接口
IColor color = circle;

// 调用的是Print<T>(T t)
Print(circle);
// 调用的是Print(Shape shape)
Print(shape);
// 调用的是Print(IColor color)
Print(color);

根据我的理解,简单来说就是,如果有参数类型与输入类型完全匹配的普通重载版本,那么就优先调用普通重载版本,否则调用泛型版本。就像上面例子中,按理说circle也能作为Print(Shape shape)Print(IColor color)的输入参数,但需要经过一次隐式转换;而对于Print<Circle>(Circle t),它则是完全匹配的,因此优先调用泛型方法。但如果转型到父类Shape或者接口IColor,因为本身就有实现这两种类型的重载版本,所以会优先调用普通重载版本的方法。

所以一般来说,在已经有了泛型版本的前提之下,即使想要给某个类及其子类(或者接口)提供特殊的支持,也不应该轻易去创建专门针对该类的重载版本。不过数字类型没有这个问题,因为数字类型(int,double等)之间没有继承关系。

不过如果不想通过重载的方式,也可以依靠运行时类型检查来实现为特定类或接口实现特定功能:

void Print<T>(T t)
{
    if (t is Shape)
    {
        Console.WriteLine("Shape");
    }
    else if (t is IColor)
    {
        Console.WriteLine("IColor");
    }
    else
    {
        Console.WriteLine("Generic");
    }
}

不过这样会有类型检查的性能开销,只有你确定你的特定实现更为合适的时候才该这样做。比如前面第19条List<T>的中输入参数为IEnumerable<T>的构造函数,特别判断了输入是不是也实现了ICollection<T>,如果是的话就能调用更高效的算法。

当然也不是说绝对不应该专门针对某些类型去创建更为具体的方法。如果能实现更好更高效的算法,那也是值得的。

所以如果你想针对某个类型创建与已有的泛型方法相互重载的方法,那么就必须为该类的所有子类型也分别创建对应的方法(否则就会调用泛型版本的)。接口也同理,必须为所有实现了该接口的类型实现对应的方法。

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

简单来说就是如果没必要的话就不要创建泛型类了,而是应该写成有泛型方法的非泛型类。因为写成泛型类会导致使用不同的类型参数时,每个都会在JIT时生成整套泛型类的代码,即使当中很多方法可能从来就没用上,而泛型方法则只针对单个方法生成。另外如果是泛型类,那么内部用到的泛型都得满足泛型类上的约束,而泛型方法每个可以单独定义其约束。而且使用泛型方法,编译器也能更容易匹配到所使用的方法。

举个例子:

// 泛型类
public static class Utils<T>
{
    public static T Max(T left, T right) =>
        Comparer<T>.Default.Compare(left, right) < 0 ? right : left;
    public static T Min(T left, T right) =>
        Comparer<T>.Default.Compare(left, right) < 0 ? left : right;
}
// 能用,但是手动必须指明类型
// 生成了两份Utils<T>,且它们各自都没用到的Min()也还是被生成了
Utils<int>.Max(7, 12);
Utils<string>.Max("lty", "#66ccff");

改写成有泛型方法的非泛型类:

public static class Utils
{
    // 泛型方法
    public static T Max<T>(T left, T right) =>
        Comparer<T>.Default.Compare(left, right) < 0 ? right : left;

    // 而且还可以为一些特定类型设置更高效的重载方法
    public static int Max(int left, int right) => Math.Max(left, right);

    public static T Min<T>(T left, T right) =>
        Comparer<T>.Default.Compare(left, right) < 0 ? left : right;

    public static int Min(int left, int right) => Math.Min(left, right);
}
// 编译器会自动推断类型
Utils.Max(7, 12);
Utils.Max("lty", "#66ccff");

这样就无需开发者调用时手动指出所用类型,而且也能让一些特定类型使用更高效的算法(虽然明明前一节还叫别这么写,不过如果像例子里是数字类型的话也没啥问题)。

另外还有一个例子:

public class CommaSeparatedListBuilder
{
    private readonly StringBuilder _builder = new StringBuilder();

    public void Add<T>(IEnumerable<T> items)
    {
        foreach (var item in items)
        {
            if (_builder.Length > 0)
            {
                _builder.Append(", ");
            }
            _builder.Append(item);
        }
    }

    public override string ToString() => _builder.ToString();
}
var builder = new CommaSeparatedListBuilder();
builder.Add(new List<int>() { 1, 2 });
builder.Add(new List<string>() { "3", "4" });

用泛型方法实现,就可以对该类型的同一对象插入不同类型的元素。而如果使用泛型类的话,开始就要先给CommaSeparatedListBuilder指定一个类型,后续也只能插入该类型元素。

不过也不是所有泛型算法都能绕开泛型类而单纯以泛型方法的形式得以实现,不过一般来说只有两种情况必须把类写成泛型类:

  • 该类需要将某个值用作其内部状态(成员变量),而该值的类型必须以泛型来表达(比如List<T>就是内部用了T[]来存储元素)。
  • 该类需要实现泛型版本的接口。

所以,设计API的时候,尽量还是使用泛型方法而非泛型类。首先调用简单,编译器能自动判断。其次对于开发者,可以针对某些特定类型加入更高效的算法,编译器也能够自动匹配到该版本上。

第26条 实现泛型接口的同时,还应该实现非泛型接口

因为到.NET Framework 2.0才引入泛型,所以有部分非泛型的老代码还在使用。为了兼容性,我们在实现了泛型接口的同时,最好还是把非泛型接口也实现了。不过在实现非泛型接口时需要加以限定,使得默认情况下都会调用泛型接口的方法,只有转型成非泛型接口的时候才会调用对应的非泛型方法。

例子可以看第20条的实现IComparable<T>IComparable接口。

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

在接口里声明方法,那么实现这个接口的类就必须把其实现,如果这样的方法多了的话实现起来就很麻烦。最好是只在接口里定义必要的东西,然后通过扩展方法的形式实现一些扩展(或者说默认实现的)功能。这样既方便了后续类的实现,又提供了一些可以使用的便捷功能。

比如IEnumerable<T>的定义非常简单:

public interface IEnumerable<out T> : IEnumerable
{
    IEnumerator<T> GetEnumerator();
}

但是微软在Enumerable类里为IEnumerable<T>提供了非常多的扩展方法,比如Count():

// 扩展方法
public static int Count<TSource>(this IEnumerable<TSource> source)
{
    if (source == null)
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
    if (source is ICollection<TSource> sources)
        return sources.Count;
    // 我看源码里挺多Provider接口的,不知道是什么,文档上也找不到
    if (source is IIListProvider<TSource> ilistProvider)
        return ilistProvider.GetCount(false);
    if (source is ICollection collection)
        return collection.Count;
    int num = 0;
    using (IEnumerator<TSource> enumerator = source.GetEnumerator())
    {
        while (enumerator.MoveNext())
            checked { ++num; }
    }
    return num;
}

所以如果我们编写实现IEnumerable<T>的类,我们只需要将接口声明的GetEnumerator()和所需的迭代器实现,接着就可以对该类的实例使用各种IEnumerable<T>的扩展方法,而不需要我们手动实现。

不过如果想在类中自己重新实现一个跟扩展方法同名的方法会有点问题。如果是以类的形式调用的话,那么编译器能保证调用的是类中定义的该方法。但如果是以接口的形式调用的话,那么编译器调用的就会是接口的扩展方法了。所以应该要尽量保证类中的同名方法要与原扩展方法行为一致,这样即使可能接口的扩展方法不如类中同名方法高效,但至少能保证结果正确。

另外,本书的环境是C# 6.0,而C# 8.0中提供了默认接口方法(有点类似Java的接口的默认方法,但不太一样),可以在接口上提供默认的方法实现,而实现该接口的类不一定要实现该方法,但如果不实现的话无法以类的形式调用。举个例子:

public interface IShape
{
    public string Shape { get; set; }

    public void Print()
    {
        Console.WriteLine(Shape);
    }
}

public interface IColor
{
    public string Color { get; set; }

    public void Print()
    {
        Console.WriteLine(Color);
    }
}

public class BlueCircle : IShape, IColor
{
    public string Shape { get; set; } = "Circle";
    public string Color { get; set; } = "Blue";
}
var blueCircle = new BlueCircle();
// 无法编译,提示BlueCircle中找不到Print()的定义
blueCircle.Print();
// 输出Circle
((IShape)blueCircle).Print();
// 输出Blue
((IColor)blueCircle).Print();

可以看到如果接口有默认方法,那么类可以不实现。但如果不实现,那么就无法以类的形式去调用该方法。而如果类继承了多个接口,且接口中有同名默认方法,那么就以其实际使用的接口类型的默认方法为准。

如果类中实现了接口中的默认方法:

public class BlueCircle : IShape, IColor
{
    public string Shape { get; set; } = "Circle";
    public string Color { get; set; } = "Blue";

    public void Print()
    {
        Console.WriteLine($"{Color}{Shape}");
    }
}
var blueCircle = new BlueCircle();
// 输出BlueCircle
BlueCircle.Print();
// 输出BlueCircle
((IShape)blueCircle).Print();
// 输出BlueCircle
((IColor)blueCircle).Print();

可以看到无论是以类的形式还是接口的形式调用该方法,最后全都是调用的类中实现的方法。

我的感觉是,C#设计成在类未实现默认接口方法的时候,必须得转换成对应接口来调用默认方法,这样就能防止接口多继承带来的同名方法不知道该调用哪个的问题。然后像现在很多时候都是靠依赖注入和面向接口编程,所以通过接口调用默认方法也很方便(或者说就该用接口调用)。而如果类中实现了接口的默认方法,大概就可以看作覆写,那自然怎么调用都是以类中的实现为准。

接口默认方法这个特性其实挺有争议的,不过多一种特性总是好事。

第28条 考虑通过扩展方法增强已构造类型的功能

就像上一条说的,很多功能我们可以通过扩展方法的形式实现,LINQ也是基于扩展方法实现的,用过的都说好。而尤其是对一些我们并不能修改的类型,比如自带的stringIEnumerable<T>等等,要在基础上加功能显然利用扩展方法会方便很多。

说白了扩展方法就是个语法糖,实际调用的还是普通的静态方法,不过写起来很方便,而且可以实现链式调用,我个人很喜欢用这个特性。比如在OpenCC.NET里,每个转换内部其实都是对分好的词组利用不同字典进行多批次的转换:

private static IEnumerable<string> ConvertBy(this IEnumerable<string> phrases,
    params IDictionary<string, string>[] dictionaries)
{
    return phrases.Select(phrase =>
    {
        ...    // 根据字典进行转换
    }
}

这就是给IEnumerable<string>新增的一个扩展方法,同时这个方法返回类型也是IEnumerable<string>,因此可以链式调用。比如将简中转换成繁中(台)并且转换地方词汇:

public static string HansToTWWithPhrase(string text)
{
    // 分词
    var phrases = ZhSegment.Segment(text);
    // 链式调用扩展方法
    return phrases.ConvertBy(ZhDictionary.STPhrases, ZhDictionary.STCharacters)
            .ConvertBy(ZhDictionary.TWPhrases)
            .ConvertBy(ZhDictionary.TWVariants)
            // Join()也是我写的扩展方法,其实就是调用string.Join()把词组重新合并成句子
            .Join();
}

如果没法链式调用的话就得写成:

return string.Join(string.Empty, ConvertBy(ConvertBy(ConvertBy(phrases, ZhDictionary.STPhrases, ZhDictionary.STCharacters), ZhDictionary.TWPhrases), ZhDictionary.TWVariants));

光看看都觉得崩溃。

第三章完。

如果觉得我的文章对你有用,请随意赞赏