Featured image of post C# 鸭子类型汇总

C# 鸭子类型汇总

这篇文章我们来总结一下 C# 中的那些不为人知的鸭子类型。

什么是“鸭子类型”?

鸭子类型的名字来源于一句俚语:

如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子。

这句话的意思是,如果一个对象具有某个方法或属性,那么它就可以被当作拥有这个方法或属性的类型来使用,而不需要严格地遵循一些规定与要求。

在 C# 中,通常我们会认为,如果想要使用一些语法,需要实现一些接口。比如你很可能会觉得:

  • 如果想要使用 foreach 语句,需要实现 IEnumerable 接口
  • 如果想要使用 await 语句,需要与 Task 类或一些底层接口扯上关系
  • 如果想要使用 using 语句释放资源,需要实现 IDisposable 接口

实际上真的如此吗?这篇文章我们就来总结一下 C# 中的那些不为人知的鸭子类型。

C# 中的鸭子类型

foreach 语句

C# 标准库为我们提供了大量的集合类型,比如 ListStackQueueObservableCollection 等等。这些集合类型都实现了 IEnumerable 接口,所以我们可以使用 foreach 语法来遍历它们。

但实际上,foreach 语法并不要求类必须实现 IEnumerable 接口。只要类中有一个名为 GetEnumerator 的方法,返回一个 IEnumerator 类型的对象,就可以使用 foreach 语法。比如下面这个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var c = new MyEnumerableClass();
foreach (var item in c)
{
    Console.WriteLine(item);
}

class MyEnumerableClass
{
    private int[] items = new[] { 1, 2, 3 };

    public IEnumerator GetEnumerator()
    {
        return items.GetEnumerator();
    }
}

我们还可以玩一些“花活”。事实上,这个 GetEnumerator 方法其实都不需要直接出自这个类型,甚至可以是一个扩展方法。所以,我们可以对一些我们无法修改的类添加扩展方法,从而使它们变得可以被 foreach 语法遍历。比如 C# 8.0 引入了 RangeIndex 两个类型,表示数组的范围与索引。

Info

通常我们不会直接声明一个 Range 对象,而是会使用形如 array[1..5] 这样的语法来表示一个范围。在底层,这个 1..5 会被转换为一个 Range 对象。

你甚至可以不在数组的索引器中使用这个语法,而是直接声明一个变量并这样进行赋值:

1
2
var range = 1..5;
Console.WriteLine(range.GetType().Name); // Range

我们可以为 Range 类型添加一个扩展方法,使得它们可以被 foreach 语法遍历:

1
2
3
4
5
6
7
8
static class MyExtensions
{
    public static IEnumerator<int> GetEnumerator(this Range range)
    {
        for (int i = range.Start.Value; i <= range.End.Value; i++)
            yield return i;
    }
}

然后就可以这样玩了:

1
2
3
4
5
6
7
8
9
foreach (var i in 1..3) // 1..3 是一个 Range 类型
{
    Console.WriteLine(i);
}

// 输出:
// 1
// 2
// 3

await 语句

类似地,await 语法也不要求类必须继承 Task 类或实现一些底层接口。只要类中有一个名为 GetAwaiter 的方法,返回一个 IAwaiter 类型的对象,就可以使用 await 语法。并且与上面 foreach 的例子相同,我们也可以把 GetAwaiter 方法定义为一个扩展方法,从而“扩展”一些我们无法修改的类。

1
2
3
4
5
static class Extensions
{
    public static TaskAwaiter GetAwaiter(this TimeSpan ts) => Task.Delay(ts).GetAwaiter();
    public static TaskAwaiter GetAwaiter(this double sec) => Task.Delay(TimeSpan.FromSeconds(sec)).GetAwaiter();
}

上面的静态类中声明了两个扩展方法,分别为 TimeSpandouble 类型添加了 GetAwaiter 方法。然后我们就可以这样使用 await 语法了:

1
2
await TimeSpan.FromSeconds(1);
await 1.0;

是不是感觉越来越离谱了?不过实际开发中,轻易还是不要使用这样的技巧,因为这会严重污染常用的类型,可以说是有百害而无一利。

using 语句

如果你认为 using 语句只能用于实现了 IDisposable 接口的类,那你终于基本上对了一次😂。的确,对于一个 class 类型的对象,如果它没有实现 IDisposable 接口,那么即便它拥有 public void Dispose() 方法,它仍然是无法使用 using 语句的(编译器会提示,这个对象必须可以隐式转换为 IDisposable 对象)。

但是!

C# 中还有一个不太常用的 ref struct 类型。这种类型的对象在离开作用域时会自动被销毁,所以它们不需要实现 IDisposable 接口。即便如此,我们可以为这种类型的对象添加一个 Dispose 方法,这样我们就可以使用 using 语句来释放资源了。

1
2
3
4
5
6
7
8
9
ref struct MyDisposableStructType
{
    public void Dispose()
    {
        Console.WriteLine("MyDisposableStructType Disposed.");
    }
}

using var s = new MyDisposableStructType();

这样的话,我们就可以对一个没有实现 IDisposable 接口的对象使用 using 语句了。

集合初始化器

C# 中的很多集合类型都支持集合初始化器语法 { } 来初始化集合对象。比如 List 类型可以这样初始化:

1
var list = new List<int> { 1, 2, 3 };

实际上,只要类实现了 IEnumerable 接口,并且包含一个名为 Add 的方法,那么这个类就可以使用集合初始化器语法。比如下面这个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class PlanetCollection<T> : IEnumerable
{
    public T[] Planets { get; init; }

    private int _index = 0;

    public PlanetCollection(int count)
    {
        Planets = new T[count];
    }

    public void Add(T item)
    {
        if (_index >= Planets.Length)
            throw new IndexOutOfRangeException();
        Planets[_index++] = item;
    }

    public IEnumerator GetEnumerator() => Planets.GetEnumerator();
}

然后我们就可以这样初始化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var collection = new PlanetCollection<string>(8)
{
    "mercury", "venus", "earth", "mars", "jupiter", "saturn", "uranus", "neptune"
};

// 会被编译为:
PlanetCollection<string> source = new PlanetCollection<string>(8);
source.Add("mercury");
source.Add("venus");
source.Add("earth");
source.Add("mars");
source.Add("jupiter");
source.Add("saturn");
source.Add("uranus");
source.Add("neptune");

元组拆分

C# 在引入了元组后,也引入了元组拆分语法。比如我们可以这样写:

1
2
var (a, b) = (1, 2);
(int c, int d) = (3, 4);

很多原生的类型也支持元组拆分。比如:

1
2
3
4
5
var pair = new KeyValuePair<string, int>("key", 42);
var (key, value) = pair;

var dt = DateTime.Now;
var (year, month, day) = dt;

此外,如果我们声明一个 record 类型,那么底层也会为我们提供元组拆分的功能。

实际上,元组拆分的语法是通过 Deconstruct 方法实现的。只要类中有一个名为 Deconstruct 的方法,并且用 out 的方式进行传参,那么这个类就可以使用元组拆分语法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Point2d
{
    public int X { get; set; }
    public int Y { get; set; }
    public void Deconstruct(out int x, out int y)
    {
        x = X;
        y = Y;
    }
}

var point = new Point2d { X = 1, Y = 2 };
var (x, y) = point;

LINQ 的 SelectMany 方法

最后再说一个比较冷门且不常用的,就是 LINQ 中的 SelectMany 方法,以及多层 from 语句。比如我们现在有一个“数组的数组”一样的结构,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Person
{
    public string Name { get; set; }
    public List<Pet> Pets { get; set; }
}

class Pet
{
    public string Name { get; set; }
}

我们可以使用 SelectMany 方法来展开这个结构:

1
2
3
4
5
6
7
var people = GetPeople();
// 使用查询表达式
var pets = (from person in people
            from pet in person.Pets
            select pet).ToList();
// 或者链式表达式
var pets = people.SelectMany(p => p.Pets).ToList();

实际上,只要我们为类提供正确的 SelectMany 方法,那么我们就可以使用多层 from 语句来展开这个结构。比如我们可以为上面集合初始化器中的 PlanetCollection 类型提供一个 SelectMany 方法,从而展开这个结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static class Extensions
{
    public static IEnumerable<TResult> SelectMany<TSource, TCollection, TResult>(
        this PlanetCollection<TSource> source,
        Func<TSource, IEnumerable<TCollection>> collectionSelector,
        Func<TSource, TCollection, TResult> resultSelector
    )
    {
        foreach (var item in source.Planets)
        {
            foreach (var subItem in collectionSelector(item))
            {
                yield return resultSelector(item, subItem);
            }
        }
    }
}

var query = 
    from planet in collection 
    from letter in planet 
    select letter;

Console.WriteLine(new string(query.ToArray()).ToUpper());

这个 SelectMany 方法的要求相对比较复杂,这里我们就不展开讨论了。

总结

看了这篇文章之后,相信大家会对 C# 这门编程语言有一个更新的认识了吧?

事实上,上面提到的大多数鸭子类型,都是与底层 C# 代码密不可分的。这里我给大家提供一个探索的方向,比如可以借助 SharpLab 这样的工具,查看诸如 foreach 语句、await 语句、以及集合初始化器等语法对应的底层 C# 代码。相信大家一定会有所收获。

使用 Hugo 构建
主题 StackJimmy 设计