C# 泛型

1. 泛型概述

有了泛型,就可以创建独立于被包含类型的类和方法。我们不必给不同的类型编写功能相同的许多方法或类,只创建一个方法或类即可。

另一个减少代码的选项是使用 Object 类,但使用派生自 Object 类的类型进行传递不是类型安全的。泛型类使用泛型类型,并可以根据需要用特定的类型替代泛型类型。这就保证了类型安全性:如果某个类型不支持泛型类,编译器就会出现错误。

泛型不仅限于类,还有用于接口和方法的泛型。

下面几节介绍泛型的优点和缺点,尤其是:

  • 性能

  • 类型安全性

  • 二进制代码重用

  • 代码的拓展

  • 命名约定

1.1 性能

泛型的一个主要优点是性能。

对值类型使用非泛型集合类,在把值类型转换为引用类型,和把引用类型转换为值类型时,需要进行装箱和拆箱操作。

值类型存储在栈上,引用类型存储在堆上。C# 类是引用类型,结构是值类型。.NET 很容易把值类型转换为引用类型,所以可以在需要对象(对象是引用类型)的任意地方使用值类型。例如,int 可以赋予一个对象。从值类型转换为引用类型称为装箱。如果方法需要把一个对象作为参数,同时传递一个值类型,装箱操作就会自动进行。另一方面,装箱的值类型可以使用拆箱操作转换为值类型。在拆箱时,需要使用类型强制转换运算符。

var list = new ArrayList();
list.Add(44); // 装箱
int i1 = (int)list[0]; // 拆箱
foreach (var i2 in list)
{
    Console.WriteLine(i2);
}

装箱和拆箱操作很容易使用,但性能损失比较大,遍历许多项时尤其如此。

System.Collections.Generic 名称空间中的 List 类不使用对象,而是在使用时定义类型。在下面的例子中,List 类的泛型类型定义为 int,所以 int 类型在 JIT(Just-In-Time)编译器动态生成的类中使用,不再进行装箱和拆箱操作:

var list = new List<int>();
list.Add(44);
int i1 = (int)list[0];
foreach (int i2 in list)
{
    Console.WriteLine(i2);
}

1.2 类型安全

泛型的另一个特性是类型安全。与 ArrayList 类一样,如果使用对象,就可以在这个集合中添加任意类型。

var list = new ArrayList();
list.Add(44);
list.Add("myString");
list.Add(new CurrentAccount());

如果这个类型使用下面的 foreach 语句迭代,而该 foreach 语句使用整数元素来迭代,编译器就会接受这段代码。但并不是集合中的所有元素都可以强制转换为 int,所以会出现一个运行时异常:

foreach (var i in list)
{
    Console.WriteLine(i);
}

错误应尽早发现。在泛型类 List 中,泛型类型 T 定义了允许使用的类型。有了 List 的定义,就只能把整数类型添加到集合中。编译器不会编译这段代码,因为 Add() 方法的参数无效:

var list = new List<int>();
list.Add(44);
list.Add("myString"); // Argument type 'string' is not assignable to parameter type 'int'
list.Add(new CurrentAccount()); //Argument type 'CurrentAccount' is not assignable to parameter type 'int'

1.3 二进制代码的重用

在C#中使用泛型的优势之一是二进制代码的重用。这是因为泛型允许我们在编写代码时指定类型参数,使得代码可以在多个不同类型上重复使用,而不需要为每个类型编写单独的代码。这样可以减少代码的冗余,提高代码的可维护性和可重用性。

下面是一个示例,展示了使用泛型的优势,通过减少冗余代码实现二进制代码的重用。

using System;

public class MyGenericClass<T>
{
    public void Display(T item)
    {
        Console.WriteLine($"Type: {typeof(T)}, Value: {item}");
    }
}

public class Program
{
    public static void Main()
    {
        MyGenericClass<int> intInstance = new MyGenericClass<int>();
        MyGenericClass<string> stringInstance = new MyGenericClass<string>();

        intInstance.Display(42);
        stringInstance.Display("Hello");
    }
}

在这个示例中,我们定义了一个泛型类MyGenericClass<T>,其中包含一个Display方法,用于显示类型和传递的值。我们通过创建MyGenericClass<int>MyGenericClass<string>的实例,分别指定了intstring作为类型参数。

通过使用泛型类,我们可以在不同的实例上重复使用相同的代码逻辑。例如,intInstance.Display(42)stringInstance.Display("Hello")两个调用都使用了相同的Display方法实现,只是类型参数不同。这样,我们避免了为每种类型编写单独的方法或类,实现了代码的重用。

在编译时,针对不同的类型参数,编译器会生成相应的代码副本,但这些副本在二进制代码层面是重用的。这样可以节省存储空间,并且在执行时可以获得更好的性能,因为不需要重复地加载和执行相同的代码逻辑。

1.4 代码的拓展

在用不同的特定类型实例化泛型时,会创建多少代码?

对于值类型,当使用特定的值类型实例化泛型类时,在 JIT(Just-In-Time) 编译器将泛型类编译为本地代码时,会为每个值类型创建一个新的类。这是因为值类型是直接包含在实例化的泛型类的内存中的,而不是通过引用进行访问。每个值类型可能对内存的要求都不同,因此需要为每个值类型实例化一个新的类,以适应不同的内存需求。

而对于引用类型,当使用特定的引用类型实例化泛型类时,在 JIT 编译器将泛型类编译为本地代码时,多个实例化使用相同的引用类型的泛型类会共享同一个本地类的实现代码。这是因为引用类型只需要占用4个字节的内存地址来引用实际对象,无论具体引用的是哪个对象,所以可以共享相同的实现代码。

由于泛型类的定义在程序集中,使用特定类型实例化泛型类时,并不会在 IL(Intermediate Language) 代码中复制这些类。相反,根据值类型和引用类型的不同特性,JIT 编译器在编译为本地代码时做出了上述的处理方式选择。

以下使用代码进行简单的说明:

using System;

public class MyGenericClass<T>
{
    public void Display()
    {
        Console.WriteLine($"Type: {typeof(T)}, Value: {default(T)}");
    }
}

public class Program
{
    public static void Main()
    {
        MyGenericClass<int> intInstance1 = new MyGenericClass<int>();
        MyGenericClass<int> intInstance2 = new MyGenericClass<int>();
        MyGenericClass<string> stringInstance1 = new MyGenericClass<string>();
        MyGenericClass<string> stringInstance2 = new MyGenericClass<string>();

        intInstance1.Display();
        intInstance2.Display();
        stringInstance1.Display();
        stringInstance2.Display();
    }
}

在这个示例中,我们定义了一个泛型类MyGenericClass<T>,其中包含一个Display方法,用于显示类型和默认值。我们使用intstring作为特定类型实例化泛型类,并创建了多个实例。

在运行示例时,我们会发现intInstance1intInstance2实例共享相同的本地类的实现代码,因为它们都是使用相同的值类型int进行实例化的。而stringInstance1stringInstance2实例也共享相同的本地类的实现代码,因为它们都是使用相同的引用类型string进行实例化的。

这样的处理方式可以节省内存,并提高性能,因为共享相同的实现代码可以避免为每个值类型实例化一个新类。而对于引用类型,共享相同的实现代码是安全的,因为引用类型只需要4个字节的内存地址来引用实际对象。

1.5 命名约定

如果在程序中使用泛型,在区别泛型类型和非泛型类型时就会有一定的帮助。下面是泛型类型的命名规则:

  • 泛型类型的名称用字母 T 作为前缀。

  • 如果没有特殊的要求,泛型类型允许用任意类替代,且只使用了一个泛型类型,就可以用字符 T 作为泛型类型的名称。

    • public class List<T> { }​​

    • public class LinkedList<T> { }​​

  • 如果泛型类型有特定的要求(例如,它必须实现一个接口或派生自基类),或者使用了两个或多个泛型类型,就应给泛型类型使用描述性的名称:

    • public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);​​

    • ​public delegate TOutput Converter<TInput, TOutput>(TInput from);​​

    • public class SortedList<TKey, TValue> { }​​

2. 创建泛型类

首先介绍一个一般的、非泛型的简化链表类,它可以包含任意类型的对象,以后再把这个类转化为泛型类。

在链表中,一个元素引用下一个元素。所以必须创建一个类,它将对象封装在链表中,并引用下一个对象。类 LinkedListNode 包含一个类型 Value,该属性用构造函数初始化。另外,LinkedListNode 类包含对链表中下一个元素和上一个元素的引用,这些元素都可以从属性中访问。

public class LinkedListNode
{
    public LinkedListNode(object value) => Value = value;
    public object Value { get; }
    public LinkedListNode Next { get; internal set; }
    public LinkedListNode Prev { get; internal set; }
}

LinkedList 类包含 LinkedListNode 类型的 First 和 Last 类型,它们分别标记了链表的头尾。AddLast() 方法在链表尾添加一个新元素。首先创建一个 LinkedListNode 类型的对象。如果链表是空的,First 和 Last 属性就设置为该新属性;否则,就把新元素添加为链表中的最后一个元素。通过实现 GetEnumerator() 方法,可以用 foreach 语句遍历链表。GetEnumerator() 方法使用 yield 语句创建一个枚举器类型。

public class LinkedList : IEnumerable
{
    public LinkedListNode First { get; private set; }
    public LinkedListNode Last { get; private set; }
    public LinkedListNode AddLast(object node)
    {
        var newNode = new LinkedListNode(node);
        if (First == null)
        {
            First = newNode;
            Last = First;
        }
        else
        {
            LinkedListNode previous = Last;
            Last.Next = newNode;
            Last = newNode;
            Last.Prev = previous;
        }
        return newNode;
    }

    public IEnumerator GetEnumerator()
    {
        LinkedListNode current = First;
        while (current != null)
        {
            yield return current.Value;
            current = current.Next;
        }
    }
}
var list1 = new LinkedList();
list1.AddLast(2); // 装箱分配:从'int'到'object'的转换需要装箱值类型
list1.AddLast(4); // 装箱分配:从'int'到'object'的转换需要装箱值类型
list1.AddLast("6"); // 发生运行时异常,因为把它强制转换为 int 时会失败
foreach (int i in list1) // 可能的对象分配:在'GetEnumerator()'调用上创建新的'IEnumerator'实例(除非它被实现缓存))
{
    Console.WriteLine(i); // 装箱分配:从'int'到'object'的转换需要装箱值类型
}

下面创建链表的泛型版本。

public class LinkedListNode<T>
{
    public LinkedListNode(T value) => Value = value;
    public T Value { get; }
    public LinkedListNode<T> Next { get; internal set; }
    public LinkedListNode<T> Prev { get; internal set; }
}
 public class LinkedList<T> : IEnumerable<T>
{
    public LinkedListNode<T> First { get; private set; }
    public LinkedListNode<T> Last { get; private set; }
    public LinkedListNode<T> AddLast(T node)
    {
        var newNode = new LinkedListNode<T>(node);
        if (First == null)
        {
            First = newNode;
            Last = First;
        }
        else
        {
            LinkedListNode<T> previous = Last;
            Last.Next = newNode;
            Last = newNode;
            Last.Prev = previous;
        }
        return newNode;
    }

    public IEnumerator<T> GetEnumerator()
    {
        LinkedListNode<T> current = First;
        while (current != null)
        {
            yield return current.Value;
            current = current.Next;
        }
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    public static void Main()
    {
        var list1 = new LinkedList<int>();
        list1.AddLast(2);
        list1.AddLast(4);
        // list1.AddLast("6"); // error: 实参类型'string'不能赋值给形参类型'int'
        foreach (int i in list1)
        {
            Console.WriteLine(i);
        }
    }
}

3. 泛型类的功能

在创建泛型类时,还需要一些其他 C# 关键字。例如,不能把 null 赋予泛型类型。此时,如下一节所述,可以使用 default 关键字。如果泛型类型不需要 Object 类的功能,但需要调用泛型类上的某些特定方法,就可以定于约束。

本节讨论如下主题:

  • 默认值

  • 约束

  • 基础

  • 静态成员

首先介绍一个使用泛型文档管理器的示例。文档管理器用于从队列中读写文档。先创建一个新的控制台项目 DocumenManager,并添加 DocumentManager 类。AddDocument() 方法将一个文档添加到队列中。如果队列不为空,IsDocumentAvailable 只读属性就返回 true:

public class DocumentManager<T>
{
    readonly Queue<T> m_DocumentQueue = new Queue<T>();
    readonly object m_LockQueue = new object();

    public void AddDocument(T doc)
    {
        lock (m_LockQueue)
        {
            m_DocumentQueue.Enqueue(doc);
        }
    }

    public bool IsDocumentAvailable => m_DocumentQueue.Count > 0;
}

3.1 默认值

现在给 DocumentManager 类添加一个 GetDocument() 方法。在这个方法中,应把类型 T 指定为 null。但是,不能把 null 赋予泛型类型。原因是泛型类型也可以实例化为值类型,而 null 只能用于引用类型。为了解决这个问题,可以使用 default 关键字。通过 default 关键字,将 null 赋予引用类型,将 0 赋予值类型。

public T GetDocument()
{
    T doc = default;
    lock (m_LockQueue)
    {
        doc = m_DocumentQueue.Dequeue();
    }
    return doc;
}

3.2 约束

如果泛型类需要调用泛型类型中的方法,就必须添加约束。

对于 DocumentManager,文档的所有标题应在 DisplayAllDocuments() 方法中显示。Document 类实现带有 Title 和 Content 只读属性的 IDocument 接口。

public interface IDocument
{
    string Title { get; }
    string Content { get; }
}

public class Document : IDocument
{
    public Document(string title, string content)
    {
        Title = title;
        Content = content;
    }

    public string Title { get; }
    public string Content { get; }
}

要使用 DocumentManager 类显示文档,可以将类型 T 强制转换为 IDocument 接口,以显示标题:

public void DisplayAllDocuments()
{
    foreach (T doc in m_DocumentQueue)
    {
        Console.WriteLine(((IDocument)doc).Title);
    }
}

问题是,如果类型 T 没有实现 IDocument 接口,这个类型强制转换就会导致一个运行时异常。最好给 DocumentManager 类定义一个约束:TDocument 类型必须实现 IDocument 接口。为了在泛型类型的名称中指定该要求,将 T 改为 TDocument。where 语句指定了实现 IDocument 接口的要求。

public class DocumentManager<TDocument> where TDocument : IDocument
{
    readonly Queue<TDocument> m_DocumentQueue = new Queue<TDocument>();
    readonly object m_LockQueue = new object();

    public void AddDocument(TDocument doc)
    {
        lock (m_LockQueue)
        {
            m_DocumentQueue.Enqueue(doc);
        }
    }

    public bool IsDocumentAvailable => m_DocumentQueue.Count > 0;

    public TDocument GetDocument()
    {
        TDocument doc = default;
        lock (m_LockQueue)
        {
            doc = m_DocumentQueue.Dequeue();
        }

        return doc;
    }

    public void DisplayAllDocuments()
    {
        foreach (TDocument doc in m_DocumentQueue)
        {
            Debug.Log(doc.Title);
        }
    }
}

public static void Main()
{
    var dm = new DocumentManager<Document>();
    dm.AddDocument(new Document("Title A", "Sample A"));
    dm.AddDocument(new Document("Title B", "Sample B"));
    dm.DisplayAllDocuments();
    if (dm.IsDocumentAvailable)
    {
        Document d = dm.GetDocument();
        Console.WriteLine(d.Content);
    }
}

类型参数的约束(C# 编程指南)

3.3 基础

前面创建的 LinkedList 类实现了 IEnumerable 接口:

public class LinkedList<T> : IEnumerable<T>

泛型类型可以实现泛型接口,也可以派生自一个类。泛型类可以派生自泛型基类:

public class Base<T> {}
public class Derived<T> : Base<T> {}

其要求是必须重复接口的泛型类型,或者必须指定基类的类型:

public class Base<T>{}
public class Derived<T> : Base<string>

于是,派生类可以是泛型类或非泛型类。例如,可以定义一个抽象的泛型基类,它在派生类中用一个具体的类实现。这允许对特定类型执行特殊的操作:

public abstract class Calc<T>
{
    public abstract T Add(T x, T y);
    public abstract T Sub(T x, T y);
}

public class IntCalc : Calc<int>
{
    public override int Add(int x, int y) => x + y;
    public override int Sub(int x, int y) => x - y;
}

还可以创建一个部分的特殊操作,如从 Query 中派生 StringQuery 类,只定义一个泛型参数,如字符串 TResult。要实例化 StringQuery,只需要提供 TRequest 的类型:

public class Query<TRequest, TResult> { }
public class StringQuery<TRequest> : Query<TRequest, string> { }

3.4 静态成员

泛型类的静态成员需要特别关注。泛型类的静态成员只能在类的一个实例中共享

public class StaticDemo<T>
{
    public static int x;
}

由于同时对一个 string 类型和一个 int 类型使用了 StaticDemo类,因此存在两组静态字段:

staticDemo<string>.x = 4;
staticDemo<int>.x = 5;
Console.WriteLine(StaticDemo<string>.x); // 4

以下是另一个示例,展示了泛型类的静态成员的使用:

using System;

public class MyGenericClass<T>
{
    private static int count;  // 静态字段

    public static int Count
    {
        get { return count; }
    }

    public static void IncrementCount()
    {
        count++;
    }
}

public class Program
{
    public static void Main()
    {
        MyGenericClass<int>.IncrementCount();
        MyGenericClass<int>.IncrementCount();
        Console.WriteLine(MyGenericClass<int>.Count);  // 输出:2

        MyGenericClass<string>.IncrementCount();
        Console.WriteLine(MyGenericClass<string>.Count);  // 输出:1
    }
}

在这个示例中,我们定义了一个泛型类MyGenericClass<T>,其中包含一个静态字段count和一个静态属性Count,用于跟踪实例化该泛型类的次数。我们还定义了一个静态方法IncrementCount,用于递增count字段的值。

Main方法中,我们首先通过MyGenericClass<int>.IncrementCount()两次调用IncrementCount方法来增加count字段的值。然后,我们使用MyGenericClass<int>.Count输出count的值,得到结果为2。

接着,我们通过MyGenericClass<string>.IncrementCount()调用IncrementCount方法来增加count字段的值。然后,使用MyGenericClass<string>.Count输出count的值,得到结果为1。

通过泛型类的静态成员,我们可以在不同的泛型实例之间共享相同的静态数据和行为。这使得我们能够在泛型上进行更精确的统计、计数或跟踪操作,并且每个不同的泛型实例都有自己独立的静态成员

4. 泛型接口

使用泛型可以定义接口,在接口中定义的方法可以带泛型参数。在链表的示例中,实现了 IEnumerable 接口,它定义了 GetEnumerator() 方法,以返回 IEnumerable。.NET 为不同的情况提供了许多泛型接口,例如,IComparable、ICollection 和 IExtensibleObject。同一个接口常常存在比较老的非泛型版本,例如,.NET 1.0 有基于对象的 IComparable 接口。IComparable 基于一个泛型类型:

public interface IComparable<in T>
{
    int CompareTo(T other);
}

注意:不要混淆用于泛型参数的 in 和 out 关键字。

比较老的非泛型接口 IComparable 需要一个带 CompareTo() 方法的对象。这需要强制类型转换为特定的类型,例如,Person 类需要使用 LastName 属性,就需要使用 CompareTo()方法:

public class Person : IComparable
{
    string m_LastName;
    public int CompareTo(object obj)
    {
        Person other = obj as Person;
        return m_LastName.CompareTo(other.m_LastName);
    }
}

实现泛型版本时,不再需要将 object 的类型强制转换为 Person:

public class Person : IComparable<Person>
{
    string m_LastName;
    public int CompareTo(Person other) => m_LastName.CompareTo(other.m_LastName);
}

4.1 协变和抗变(逆变)

泛型中的协变和逆变

协变和逆变都是术语,前者(协变)指能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型后者(逆变)指能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型 。 泛型类型参数支持协变和逆变,可在分配和使用泛型类型方面提供更大的灵活性。

在引用类型系统时,协变、逆变和不变性具有如下定义。 这些示例假定一个名为 Base​​ 的基类和一个名为 Derived​​的派生类。

  • ​Covariance​​(协变性)

    • 使你能够使用比原始指定的类型派生程度更大的类型。

    • 你可以将 IEnumerable​​ 的实例分配给 IEnumerable​​ 类型的变量。(父亲用儿子的)

  • ​Contravariance​​(抗变性/逆变性)

    • 使你能够使用比原始指定的类型更泛型(派生程度更小)的类型。

    • 你可以将 Action​​ 的实例分配给 Action​​ 类型的变量。(儿子用父亲的)

  • ​Invariance​​(不变性)

    • 表示只能使用最初指定的类型。 固定泛型类型参数既不是协变,也不是逆变。

    • 你无法将 List​​ 的实例分配给 List​​ 类型的变量,反之亦然。

在 .NET 4 之前,泛型接口是不变的。.NET 4 通过协变和逆变为泛型接口和泛型委托添加了一个重要的拓展。协变和逆变指对参数和返回值的类型进行转换。例如,可以给一个需要 Shape 参数的方法传送 Rectangle 参数吗?下面用示例说明这些拓展的优点。

在 .NET 中,参数类型是逆变的。假定有 Shape 和 Rectangle 类,Rectangle 类派生自 Shape 基类。声明 Display() 方法是为了接受 Shape 类型的对象作为其参数:

public class Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public override string ToString() => $"Width: {Width}, Height: {Height}";
}

public class Rectangle : Shape { }

public class Test1
{
    public void Display(Shape o) { /* 实现 */ }
  
    // 现在可以传递派生自 Shape 基类的任意对象。因为 Rectangle 派生自 Shape,所以 Rectangle 满足 Shape 的所有要求,编译器接受这个方法调用:
    public static void Main()
    {
        var r = new Rectangle { Width = 5, Height = 2 };
        Display(r);
    }
}

方法的返回类型是协变的。

当方法返回一个 Shape 时,不能把它赋予 Rectangle,因为 Shape 不一定总是 Rectangle。反过来是可行的:如果一个方法像 GetRectangle() 方法那样返回一个 Rectangle:

public Rectangle GetRectangle()
{
    return new Rectangle();
}

public void Test()
{
    Shape s = GetRectangle(); // 可用 Shape 接收 Rectangle
}

在 .NET Framework 4 版本之前,这种行为方式不适用于泛型。自 C# 4 以后,拓展后的语言支持泛型接口和泛型委托的协变和逆变。

4.2 泛型接口的协变(out)

如果泛型类型用 out 关键字标注,泛型接口就是协变的。这也意味着返回类型只能是 T。接口 IIndex 与类型 T 是协变的,并从一个只读索引器中返回这个类型:

public interface IIndex<out T>
{
    T this[int index] { get; }
    int Count { get; }
}

IIndex 接口用 RectangleCollection 类来实现。RectangleCollection 类为泛型类型 T 定义了 Rectangle:

注意:如果对接口 IIndex 使用了读写索引器,就把泛型类型 T 传递给方法,并从方法中检索这个类型。这不能通过协变来实现——泛型类型必须定义为不变的。不使用 out 和 in 标注,就可以把类型定义为不变的。

public class RectangleCollection : IIndex<Rectangle>
{
    Rectangle[] data = new Rectangle[3]
    {
        new Rectangle { Height = 2, Width = 5 }
      , new Rectangle { Height = 3, Width = 7 }
      , new Rectangle { Height = 4, Width = 9 }
    };

    static RectangleCollection s_Collection;
    
    public static RectangleCollection GetRectangle() => s_Collection ??= new RectangleCollection();
    
    public Rectangle this[int index]
    {
        get
        {
            if (index < 0 || index > data.Length)
                throw new ArgumentOutOfRangeException(nameof(index));
            return data[index];
        }
    }
    
    public int Count => data.Length;
}

RectangleCollection.GetRectangle() 方法返回一个实现 IIndex 接口的 RectangleCollection 类,所以可以把返回值赋予 IIndex 类型的变量 rectangle。因为接口是协变的,所以也可以把返回值赋予 IIndex 类型的变量。Shape 不需要 Rectangle 没有提供的内容。使用 shapes 变量,就可以在 for 循环中使用接口中的索引器和 Count 属性。

public static void Main()
{
    IIndex<Rectangle> rectangles = RectangleCollection.GetRectangle();
    IIndex<Shape> shapes = rectangles;
    for (int i = 0; i < shapes.Count; i++)
    {
        Console.WriteLine(shapes[i]);
    }
}

4.3 泛型接口的逆变(in)

如果泛型类型用 in 关键字标注,泛型接口就是逆变的。这样,接口只能把泛型类型 T 用作其方法的输入

public interface IDisplay<in T>
{
    void Show(T item);
}

ShapeDisplay 类实现 IDisplay,并使用 Shape 对象作为输入参数:

public class ShapeDisplay : IDisplay<Shape>
{
    public void Show(Shape s) => Console.WriteLine($"{s.GetType().Name} Width: {s.Width}, Height: {s.Height}");
}

创建 ShapeDisplay 的一个新实例,会返回 IDisplay,并把它赋予 shapeDisplay 变量。因为 IDisplay 是逆变的,所以可以把结果赋予 IDisplay,其中 Rectangle 派生自 Shape。这次接口的方法只能把泛型类型定义为输入,而 Rectangle 满足 Shape 的所有要求:

public class ContravarianceExample
{
    public static void Main()
    {
        IIndex<Rectangle> rectangles = RectangleCollection.GetRectangle();
        IIndex<Shape> shapes = rectangles;
        for (int i = 0; i < shapes.Count; i++)
        {
            Console.WriteLine(shapes[i]);
        }

        // ---------------------------------------------------------------
      
        IDisplay<Shape> shapeDisplay = new ShapeDisplay();
        IDisplay<Rectangle> rectangleDisplay = shapeDisplay;
        rectangleDisplay.Show(rectangles[0]);
    }
}

5. 泛型结构

与类相似,结构也可以是泛型的。它们非常类似于泛型类吗,只是没有继承特性。本节介绍泛型结构 Nullable,它由 .NET Framework 定义。

.NET Framework 中的一个范型结构是 Nullable。数据库中的数字和编程语言中的数字有显著不同的特征,因为数据库中的数字可以为空,而 C# 中的数字不能为空。

Int32 是一个结构,而结构实现同值类型,所以结构不能为空。这种区别常常令人很头痛,映射数据也要多做许多辅助工作。这个问题不仅存在于数据库中,也存在于把XML 数据映射到 .NET 类型。

一种解决方案是把数据库和 XML 文件中的数字映射为引用类型,因为引用类型可以为空值。但这也会在运行期间带来额外的系统开销。

使用 Nullable 结构很容易解决这个问题。下面的代码定义了一个简化版本的 Nullable。结构 Nullable 定义了一个约束:其中的泛型类型 T 必须是一个结构。把类定义为泛型类型后,就没有低系统开销这个优点了,而且因为类的对象可以为空,所以对类使用 Nullable 类型是没有意义的。除了 Nullable 定义的 T 类型之外,唯一的系统开销是 hasValue 布尔字段,它确定是设置对应的值,还是使之为空。除此之外,泛型结构还定义了只读属性 HasValue 和 Value,以及一些运算符重载。把 Nullable 类型强制转换为 T 类型的运算符重载是显式定义的,因为当 hasValue 为 false时,它会抛出一个异常。强制转换为 Nullable 类型的运算符重载定义为隐式的,因为它总是能成功地转换:

public struct Nullable<T> where T : struct
{
    bool m_HasValue;
    public bool HasValue => m_HasValue;
    T m_Value;
    public T Value
    {
        get
        {
            if (!m_HasValue)
                throw new InvalidOperationException("no value");
            return m_Value;
        }
    }
    public Nullable(T value)
    {
        m_HasValue = true;
        m_Value = value;
    }
    public static explicit operator T(Nullable<T> value) => value.Value;
    public static implicit operator Nullable<T>(T value) => new Nullable<T>(value);
    public override string ToString() => !HasValue ? string.Empty : m_Value.ToString();
}

在这个例子中,Nullable 用 Nullable 实例化。变量 x 现在可以用作一个 int,进行赋值或使用运算符执行一些计算。这是因为强制转换了 Nullable 类型的运算符。但是,x 还可以为空。Nullable 的 HasValue 和 Value 属性可以检查是否有一个值,该值是否可以访问:

Nullable<int> x;
x = 4;
x += 3;
if (x.HasValue)
{
    int y = x.Value;
}
x = null;

因为可空类型使用得非常频繁,所以 C# 有一种特殊的语法,它用于定义可空类型的变量。定义这类变量时,不使用泛型结构的语法,而使用“?”运算符。

Nullable<int> x1;
int? x2;

可空类型可以与 null 和数字比较。

int? x = GetNullableType();
if (x == null)
{
    Console.WriteLine("x is null");
}
else if (x < 0)
{
    Console.WriteLine("x is smaller than 0");
}

知道了 Nullable 是如何定义的之后,下面就使用可空类型。可空类型还可以与算术运算符一起使用。变量 x3 是变量 x1 和 x2 的和。如果这两个可空变量中任何一个的值是 null,它们的和就是 null

int? x1 = GetNullableType();
int? x2 = GetNullableType();
int? x3 = x1 + x2;

注意:这里调用的 GetNullableType() 方法只是一个占位符,它对于任何方法都返回一个可空的 int。为了进行测试,简单起见,可以使实现的 GetNullableType() 返回 null 或返回任意整数。

非可空类型可以转换为可空类型。非可空类型 -> 可空类型,在不需要强制类型转换的地方可以进行隐式转换,这种转换总是成功的。

int y1 = 4;
int? x1 = y1;

反之可能会失败。如果可空类型的值是 null,并且将之赋予非可空类型,就会抛出 InvalidOperationException 类型的异常。这就是需要i类型强制转换运算符进行显式转换的原因:

int? x1 = GetNullableType();
int y1 = (int)x1;

如果不进行显式类型转换,还可以使用合并运算符从可空类型转换为非可空类型。合并运算符的语法是“??”,为转换定义了一个默认值,以防可空类型的值是 null。这里,如果 x1 是 null,y1的值就是0。

int? x1 = GetNullableType();
int y1 = x1 ?? 0;

6. 泛型方法

除了定义泛型类之外,还可以定义泛型方法。在泛型方法中,泛型类型用方法声明来定义。泛型方法可以在非泛型类中定义。

void Swap<T>(ref T x, ref T y)
{
    T temp;
    temp = x;
    x = y
    y = temp;
}

把泛型类型赋予方法调用,就可以调用泛型方法:

int i = 4;
int j = 5;
Swap<int>(ref i, ref j);

但是,因为 C# 编译器会通过调用 Swap() 方法来获取参数的类型,所以不需要把泛型类型赋予方法调用。泛型方法可以像非泛型方法那样调用:

int i = 4;
int j = 5;
Swap(ref i, ref j);

6.1 泛型方法示例

using System.Collections.Generic;

public class Account
{
    public string Name { get; }
    public decimal Balance { get; }
    public Account(string name, decimal balance)
    {
        Name = name;
        Balance = balance;
    }
}

public static class Algorithms
{
    public static decimal AccumulateSimple(IEnumerable<Account> source)
    {
        decimal sum = 0;
        foreach (Account a in source) sum += a.Balance;
        return sum;
    }
}

public class AccountExample
{
    public static void Main()
    {
        var accounts = new List<Account>
        {
            new Account("A", 1000)
          , new Account("B", 1500)
          , new Account("C", 2000)
          , new Account("D", 2500)
        };
        decimal amount = Algorithms.AccumulateSimple(accounts);
    }
}

6.2 带约束的泛型方法

第一个实现代码的问题是,它只能用于 Account 对象。使用泛型方法就可以避免这个问题。

public static decimal Accumulate<TAccount>(IEnumerable<TAccount> source) where TAccount : IAccount // 泛型方法
{
    decimal sum = 0;
    foreach (TAccount a in source) sum += a.Balance;
    return sum;
}
public interface IAccount // 接口定义
{
    string Name { get; }
    decimal Balance { get; }
}
public class Account : IAccount {} // 实现接口
public static void Main()
{
    var accounts = new List<Account>
    {
        new Account("A", 1000)
      , new Account("B", 1500)
      , new Account("C", 2000)
      , new Account("D", 2500)
    };
    decimal amount1 = Algorithms.Accumulate(accounts);
    // 因为编译器会从方法的参数类型中自动推断出泛型类型参数,所以可以使用上面的方式调用
    // decimal amount1 = Algorithms.Accumulate<Account>(accounts); 
}

6.3 带委托的泛型方法

泛型类型实现 IAccount 接口的要求过于严格。下面的示例提示了,如何通过传递一个泛型委托来修改 Accumulate() 方法。

public static TReturn Accumulate<T, TReturn>(IEnumerable<T> source, Func<T, TReturn, TReturn> func)
{
    TReturn sum = default(TReturn);
    foreach (T item in source)
    {
        sum = func(item, sum);
    }
    return sum;
}

在调用这个方法时,需要指定泛型参数类型,因为编译器不能自动推断出该类型。对于方法的第1个参数,所赋予的 accounts 集合是 IEnumerable 类型。对于第2个参数,使用一个 lambda 表达式来定义 Account 和 decimal 类型的两个参数,返回一个小数。对于每一项,通过 Accumulate() 方法调用这个 lambda 表达式:

decimal amount2 = Algorithms.Accumulate<Account, decimal> // Accumulate<T, TReturn>
    (source: accounts, func: (account, sum) => sum += account.Balance); // Func<T, TReturn, TReturn> func

6.4 泛型方法规范

泛型方法可以重载,为特定的类型定义规范。这也适用于带泛型参数的方法。

public class MethodOverloads
{
    public void Foo<T>(T obj) => Console.WriteLine($"Foo<T>(T obj), obj type: {obj.GetType().Name}");
    public void Foo(int x) => Console.WriteLine($"Foo(int x)");
    public void Foo<T1, T2>(T1 obj1, T2 obj2) => Console.WriteLine($"{obj1.GetType().Name} {obj2.GetType().Name}");
    public void Foo<T>(int obj1, T obj2) => Console.WriteLine($"Foo<T>(int obj1, T obj2) {obj2.GetType().Name}");
    public void Bar<T>(T obj) => Foo(obj);

    public static void Main()
    {
        var test = new MethodOverloads();
        test.Foo(33);
        test.Foo("abc");
        test.Foo("abc", 22);
        test.Foo(33, "abc");
    }
}

需要注意的是,所调用的方法是在编译期间而不是运行期间定义的。这很容易举例说明:添加一个调用 Foo() 方法的 Bar() 泛型方法,并传递泛型参数值:

public static void Main()
{
    test.Bar(44); // Foo<T>(T obj), obj type: Int32
    // public void Foo<T>(T obj) => Console.WriteLine($"Foo<T>(T obj), obj type: {obj.GetType().Name}");
}

从控制台的输出可以看出,Bar() 方法选择了泛型 Foo() 方法,而不是选择用 int 参数重载的 Foo() 方法。原因是编译器狮子编译期间选择 Bar() 方法调用的 Foo() 方法。由于 Bar() 方法定义了一个泛型参数,而且泛型 Foo() 方法匹配这个类型,因此调用了 Foo() 方法。在运行期间给 Bar() 方法传递一个 int 值不会改变这一点。

#知识点##技术分享##学习笔记##学习##CSharp#
C# 知识库 文章被收录于专栏

C#知识库用于学习和复习C#知识~

全部评论

相关推荐

2 4 评论
分享
牛客网
牛客企业服务