第4章 C#4:互操作性提升

第4章 C#4:互操作性提升

4.1 动态类型

4.1.1 动态类型介绍

我们在第 3 章有提到:在特定上下文中查找符号含义的过程称为 绑定

  • 动态类型:绑定过程从编译时转移到了 执行 期;

    此时编译器生成的 IL 代码的功能是执行绑定并执行绑定的结果。这一切都是由 dynamic ​ 关键字触发的。

  • 静态类型:编译器生成的 IL 代码中包含的是精准的方法签名的调用。

3.2.1.1 静态类型和动态类型

  • 静态类型语言:典型的面向 编译 的语言:所有表达式的类型都由 编译器 来决定,并由 编译器 来检查类型的使用是否合法。

    这个“决定”过程称之为“绑定”

  • 动态类型语言:把绝大部分甚至所有绑定操作放到了 执行 期。

C# 总体上属于 态类型语言(不过 C#4 引入了动态绑定,第4章 C#4:互操作性提升将详述)。尽管具体调用虚函数的哪个实现取决于执行期调用对象的类型,但是执行方法签名绑定的整个过程都发生在编译时。

后续我们以如下这段代码为例进行讲解:

dynamic text = "hello world";
string world = text.Substring(6);
Console.WriteLine(world);

string broken = text.SUBSTR(6);
Console.WriteLine(broken);

4.1.1.1 什么是动态类型

dynamic​ 类型是只存在于 C# 中的类型(System.Type、CLR 都对该类型一无所知)。在 C# 中使用 dynamic​,IL 都会转换成带有 [Dynamic] ​ 属性的 object ​ 来声明。

使用 dynamic​ 有以下基本规则:

  1. 非指针类型到 dynamic​ 类型存在 式类型转换。
  2. dynamic​ 类型的表达式到任意非指针类型存在 式类型转换。
  3. 如果表达式中有 dynamic​ 类型的值,通常都是在 执行 期才完成绑定的。
  4. 大部分含有 dynamic​ 类型值的表达式,表达式编译时的类型 dynamic​。

我们重新审视前面的这两行代码,则有:

dynamic text = "hello world";
string world = text.Substring(6);
  • text.Substring(6)​ ​在执行期完成绑定(第 3 条规则);
  • text.Substring(6)​ ​在编译时的类型是 dynamic​(第 4 条规则);
  • text.Substring(6)​ ​到 string​ 有一个隐式类型转换(第 2 条规则)。

dynamic​ 的隐式转换十分智能(它也是动态绑定的):

  • 无法隐式转换的类型:执行期会抛出 RuntimeBinderException ​ 异常

  • 可以隐式转换的类型:能够自动进行转换,例如上文的 text​ 字符串,因 string​ 可以隐式转换为 XNamespace​,因此如下转换可以正常进行:

    XNamespace myPamespace = text;
    

4.1.1.2 在多个上下文中应用动态绑定

dynamic​ 的动态不仅限于绑定,几乎所有行为都可以是动态的。

如下代码演示了在执行期根据 dynamic​ 变量的实际类型,执行不同的加法操作。如下代码将输出“ texttext ”、“ 20 ”、“ 01:30:00 ”:

static void Add(dynamic d)
{
    Console.WriteLine(d + d);
}

Add("text");
Add(10);
Add(TimeSpan.FromMinutes(45));

如下代码演示了涉及动态参数的方法重载过程:

void Main()
{
    CallMethod(10);         //
    CallMethod(10.5m);      // 使用不同类型间接调用
    CallMethod(10L);        // SampleMethod 方法
    CallMethod("text");     //
}

static void CallMethod(dynamic d)
{
    SampleMethod(d);        // 动态调用方法
}

static void SampleMethod(int value)
{
    Console.WriteLine("int 参数方法");
}

static void SampleMethod(decimal value)
{
    Console.WriteLine("decimal 参数方法");
}

static void SampleMethod(object value)
{
    Console.WriteLine("object 参数方法");
}

最为神奇的是 CallMethod(10L)​ 这句代码实际调用的是 SampleMethod(decimal) ​ 方法!

4.1.1.3 编译器仍然可以进行检查的动态绑定

dynamic​ 虽然是动态绑定的,编译时必要的检查编译器还是能执行的:

  • 如果在编译时就能获得一个 方法调用 的上下文,那么编译器可以检查能否找到特定名称的方法。如果找不到能够在执行器调用的方法,依然会引发 编译错误

该规则适用于以下场景:

  • 目标不是动态值的实例方法和索引器
  • 静态方法
  • 构造器

如下代码展示了几个动态值的调用,这些调用在编译时会发生报错:

dynamic d = new object();
int invalid1 = "text".Substring(0, 1, 2, d);    // 不存在 4 个参数的 Substring() 方法
bool invalid2 = string.Equals<int>("foo", d);   // 不存在泛型 String.Equals<T>() 方法
string invalid3 = new string(d, "broken");      // 不存在第二个参数类型为字符串的 String 构造器
char invalid4 = "text"[d, d];                   // 不存在接受两个参数的 String 索引器

4.1.1.4 并非动态绑定的动态操作

绝大部分处理动态值的操作涉及动态绑定(如类型绑定、查找合适方法、属性、转换或者运算符等)。还有一些情况编译器不需要为其生成绑定代码:

  • 给类型为 object ​、 dynamic ​ 的变量赋值

    此时只需要复制现有引用,无需类型转换

  • 传入方法的参数类型是 object ​ 或者 dynamic

    原因同上

  • 使用 is ​ 运算符判断某个值的类型

  • 使用 as ​ 运算符转换某个值的类型

    这两个运算符均不能使用自定义转换(即不会发生方法的绑定),因此不需要绑定操作

4.1.1.5 有动态值参与但依然是静态类型

编译器会尽力帮忙执行类型检查。如果某个表达式的类型** 只能是某个唯一类型 **,那么编译器会为其赋予编译时类型。

假设有变量 d​,为 dynamic​ 类型,以下三种情况都是正确的:

  • new SomeType(d)​ 表达式具备编译时类型 SomeType
  • d is SomeType​ 表达式具备编译时类型 bool
  • d as SomeType​ 表达式具备编译时类型 SomeType

4.1.2 超越反射的动态行为

4.1.2.2 ExpandoObject​:一个装有数据和方法的动态袋子

.NET 提供了一个名为 ExpandoObject​ 的类型(位于 System.Dynamic 命名空间)。它有两种工作模式:

  • 作为 态类型 dynamic

  • 作为 态类型

    此时它等同于 name/value 对构成的 字典 (它本身也实现了 IDictonary<string, object> ​ 接口)

下面演示了二者的用法:

// 作为动态类型
dynamic expando = new ExpandoObject();
expando.SomeData = "Some data";         // 将数据复制给属性
Action<string> action =                                         // 将委托赋值
    input => Console.WriteLine("The input was '{0}'", input);   // 给属性
expando.FakeMethod = action;

Console.WriteLine(expando.SomeData);    // 动态访问数据
expando.FakeMethod("hello");            // 和委托

// 作为静态类型
IDictionary<string, object> dictionary = expando;   // 将 ExpandoObject 视作
Console.WriteLine("Keys: {0}",                      // 字典并打印键值
    string.Join(", ", dictionary.Keys));

dictionary["OtherData"] = "other";      // 使用静态上下文填充数据,
Console.WriteLine(expando.OtherData);   // 然后从动态值获取数据

4.1.2.3 Json.NET 的动态视图

4.1.2.4 用代码实现动态行为

用代码实现动态行为,有几个关键的类型、接口:

  • IDynamicMetaObjectProvider​ 接口:用于指明“ 对象应当实现自身的动态行为 ”。

    public interface IDynamicMetaObjectProvider
    {
        DynamicMetaObject GetMetaObject(Expression parameter);
    }
    
  • DynamicMetaObject​ 类:代表了动态绑定和参与到动态绑定过程中对象的绑定 逻辑 。开发者通常需要派生该类,并覆写一些虚方法。

    例如覆写如下方法可以动态地处理方法调用:

    public virtual DynamicMetaObject BindInvokeMember
        (InvokeMemberBinder binder, DynamicMetaObject[] args);
    
  • DynamicObject​ 类:实现动态行为的基类(类似于 CollecitonBase​),实现了 IDynamicMetaObjectProvider ​ 接口。可以将该类作为基类间接实现 IDynamicMetaObjectProvider​ 接口。

自定义 DynamicObject​ 子类

下面创建了一个名为 SimpleDynamicExample​ 的类。它有如下行为:

  • 对于任意方法的调用,它会打印出被调方法名和参数;
  • 对于任意属性的访问,它会打印出属性名称

下面是具体代码:

class SimpleDynamicExample : DynamicObject
{
    public override bool TryInvokeMember(InvokeMemberBinder binder, object[] args, out object result)
    {
        Console.WriteLine($"Invoked: {binder.Name}({string.Join(", ", args)})");
        // 这里用于设置方法的返回值
        result = null;
        return true;
    }

    public override bool TryGetMember(GetMemberBinder binder, out object result)
    {
        result = $"Fetched: {binder.Name}";
        return true;
    }
}

对上述类做如下调用,它会输出:“ Invoked: CallSomeMether(x, 10) ”、“ Fetched: SomeProperty

dynamic example = new SimpleDynamicExample();
example.CallSomeMether("x", 10);
Console.WriteLine(example.SomeProperty);

在覆写的方法中有两个关键的成员:

  • out object result​ 参数:该参数用于设置方法的 返回值 ,对于不需要返回值的方法,设为 null 即可;
  • bool ​ 类型返回值:用于指示动态对象是否成功处理相关操作,若返回 false ,会抛出 RuntimeBinderException ​ 异常。

4.1.3 动态行为机制速览

4.1.3.1 职责划分

一个 C# 特性的引入,一般涉及 3 部分(或之一):

  • C# 编译器
  • CLR
  • framework 库

有些特性基本属于 C# 编译器范畴(如隐式类型),有些则需要三方都进行支持(如泛型),有些需要编译器和 framework 支持(如 LINQ)。

动态类型更为复杂,它的引入涉及:

  • C# 编译器

  • framework 库。

    • 引入了 动态语言运行时(dynamic language runtime,DLR)

      提供了语言无关的基础架构(如 DynamicMetaObject​),负责所有动态行为。

    • Microsoft.CSharp.dll。

      该库负责在运行时进行 重载决议 ,是 C# 编译器 绑定 部分的一个副本。

image

Tips

如果项目中包含动态类型,则 Microsoft.CSharp.dll 的引用不可或缺;否则可以移除该 dll 的引用。

4.1.3.2 动态类型生成的 IL 代码

我们以这两行代码为例,其对应的反编译代码如下:

dynamic text = "hello world";
string world = text.Substring(6);
using Microsoft.CSharp.RuntimeBinder;
using System;
using System.Runtime.CompilerServices;

class DynamicTypingDecompiled
{
    private static class CallSites  // 缓存调用位置
    {
        public static CallSite<Func<CallSite, object, int, object>> method;
        public static CallSite<Func<CallSite, object, string>> conversion;
    }
  
    static void Main()
    {
        object text = "hello world";
        if (CallSites.method == null)   // 根据需要为方法
        {                               // 创建调用位置
            CSharpArgumentInfo[] argumentInfo = new[]
            {
                CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
                CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.Constant | CSharpArgumentInfoFlags.UseCompileTimeType, null)
            };
            CallSiteBinder binder = Binder.InvokeMember(CSharpBinderFlags.None, "Substring",
                null, typeof(DynamicTypingDecompiled), argumentInfo);
            CallSites.method = CallSite<Func<CallSite, object, int, object>>.Create(binder);
        }

        if (CallSites.conversion == null)   // 根据需要为转换
        {                                   // 创建调用位置
            CallSiteBinder binder = Binder.Convert(CSharpBinderFlags.None, typeof(string),
            typeof(DynamicTypingDecompiled));
            CallSites.conversion =
            CallSite<Func<CallSite, object, string>>.Create(binder);
        }
        object result = CallSites.method.Target(CallSites.method, text, 6);     // 调起方法调用位置
        string str = CallSites.conversion.Target(CallSites.conversion, result); // 调起转换调用位置
    }
}

代码中涉及多个 CallSite​,用于缓存 每步绑定的结果 ,用于提升效率。

4.1.4 动态类型的局限与意外

一门语言,如果在诞生之初就设计为静态语言,很难为其集成动态类型,在某些情况下二者无法很好地融合。

下面列出了常见的动态类型的局限和执行期的意外行为:

4.1.4.1 动态类型与泛型

编译时使用 dynamic​ 有如下规则:

  1. 类型所实现的接口中 不能dynamic​ 类型实参;
  2. 在类型约束中 不能 使用 dynamic​;
  3. 一个类的基类 可以dynamic​ 的类型实参,该类型实参 可以 嵌套在另一接口内;
  4. dynamic可以 用作变量的接口类型实参。

以下声明均非法:

class DynamicSequence : IEnumerable<dynamic>                            // 违反第 1 条
class DynamicListSequence : IEnumerable<List<dynamic>>                  // 违反第 1 条
class DynamicConstraint1<T> : IEnumerable<T> where T : dynamic          // 违反第 2 条
class DynamicConstraint2<T> : IEnumerable<T> where T : List<dynamic>    // 违反第 2 条

以下声明均合法:

class DynamicList : List<dynamic>    // 符合第 3 条
class ListOfDynamicSequences : List<IEnumerable<dynamic>>    // 符合第 3 条
IEnumerable<dynamic> x = new List<dynamic> { 1, 0.5 }.Select(x => x * 2);    // 符合第 4 条

4.1.4.2 扩展方法

执行期的绑定方法 不能 处理扩展方法(原理上讲是可以的,实际未实现)。如下两段代码为例,第 1 段代码的 LINQ 操作的是 List<T>​ 实例,因此可以正常运行,第 2 段则会抛出 RuntimeBinderException​:

List<dynamic> source = new List<dynamic>
{
    5,
    2.75,
    TimeSpan.FromSeconds(45)
};
IEnumerable<dynamic> query = source.Select(x => x * 2);
foreach (dynamic value in query)
{
    Console.WriteLine(value);
}
dynamic source = new List<dynamic>
{
    5,
    2.75,
    TimeSpan.FromSeconds(45)
};
bool result = source.Any();

若想调用扩展方法,则需要像普通方法那样调用:

bool result = Enumerable.Any(source);

4.1.4.3 匿名函数

动态类型在匿名函数的应用上存在 3 项限制:

  1. 匿名方法不能赋值给 dynamic ​ 类型的变量

    因为编译器不能确定应该创建哪个类型的委托。如下代码第 1 段非法,第 2 段合法:

    dynamic function = x => x * 2;
    Console.WriteLine(function(0.75));
    
    dynamic function = (Func<dynamic, dynamic>) (x => x * 2);
    Console.WriteLine(function(0.75));
    
  2. lambda 表达式不能出现在需要动态绑定的操作中

    原因同上。以下代码 无法 通过编译:

    dynamic source = new List<dynamic>
    {
        5,
        2.75,
        TimeSpan.FromSeconds(45)
    };
    dynamic result = source.Select(x => x * 2);
    
  3. 需要转换成 表达式树 的 lambda 表达式,不能包含任何 dynamic 操作

    如下代码无法进行编译:

    List<dynamic> source = new List<dynamic>
    {
        5,
        2.75,
        TimeSpan.FromSeconds(45)
    };
    IEnumerable<dynamic> query = source
        .AsQueryable()
        .Select(x => x * 2);
    

4.1.4.4 匿名类型

匿名类型的访问权限是 internal ,因此外部程序集无法访问。这种设计本身没什么问题,但和动态类型相结合就有限制了。以如下代码为例,当 PrintName()​ 方法和所定义的匿名类型所属 程序集 不同,如下代码会抛出 RuntimeBinderException​ 异常:

static void PrintName(dynamic obj)
{
    Console.WriteLine(obj.Name);
}

static void Main()
{
    var x = new { Name = "Abc" };
    var y = new { Name = "Def", Score = 10 };
    PrintName(x);
    PrintName(y);
}

我们可以使用友元程序集解决此问题。

Info

关于友元程序集,可见3.5.2 友元程序集、2.5.7 InternalsVisibleTo(友元程序集)、友元程序集

4.1.4.5 显式的接口实现

对于动态类型,使用显式接口实现时,仍需要切换至接口视图,不能直接使用。

以如下代码为例,list3.IsFixedSize​ 属性值的获取是 法的:

List<int> list1 = new List<int>();
Console.WriteLine(list1.IsFixedSize);    // 编译时错误

IList list2 = list1;
Console.WriteLine(list2.IsFixedSize);    // 成功,打印 False

dynamic list3 = list1;
Console.WriteLine(list3.IsFixedSize);    // 执行期错误

若想成功获取 list3.IsFixedSize​ 属性值,需要 手动将其转为接口类型

4.1.5 动态类型的使用建议

4.1.5.1 处理反射更简单

假设需要使用反射来访问某个属性或方法,而且我们在编译时能够知道该属性或方法的名字,但由于某些原因无法获取它的静态类型,这时使用 动态类型 让执行期绑定器来执行获取操作要比通过反射 API 获取容易得多。

Eureka

我觉得倒不如使用接口了。不过,如果是第三方的类库,还是得用动态类型 or 反射。

4.1.5.2 处理共有成员但不共有接口的情况

有时对于一个值,我们会预先知道它所有可能的类型,并且它们的同名成员是独立声明的(并没有实现同一接口/基类)。C#7 提供了模式(见2.11.3.5 带有模式的 switch 语句(C#7)),但仍然比较繁琐。此时应该使用动态类型。

4.1.5.3 使用为动态类型构建的库

.NET 的生态日益壮大,涌现了一批接纳动态类型的库。若这些库可以简化我们的工作,应积极拥抱它们。

4.2 可选形参和命名实参

4.2.1 带默认值的形参和带名字的实参

下面展示了一个带有默认值的方法,各成员的名称如下图:

image

该语法比较简单:

  • 我们可以在形参名称后使用 ===​ 为其指定一个默认值。任何 指定了默认值 的形参都是可选形参;任何 没有指定默认值 的形参都是必要形参。使用 ref​ 或者 out​ 修饰的形参 不能==有默认值。
  • 对于实参,实参可以在实参值前面使用 : ​ 为其指定参数名称。不指定名称的实参被称为定位实参。

形参的默认值必须是以下表达式之一:

  • 编译时的 常量

    比如数值、字符串或者 null

  • default ​ 表达式

    例如 default(CancellationToken)

  • new ​ 表达式

    例如 new Guid()​ 或者 new CancellationToken()​,它只对 类型有效

4.2.2 如何决定方法调用的含义

在处理方法调用时,需要注意:

  • 实参会按照在 源码 中出现的顺序从 依次被计算;

    是比较如下两段代码,很明显第二段代码的可读性更强:

    int tmp1 = 0;
    Method(x: tmp1++, y: tmp1++, z: tmp1++);
    
    int tmp3 = 0;
    int argX = tmp3++;
    int argY = tmp3++;
    int argZ = tmp3++;
    Method(x: argX, y: argY, z: argZ);
    
  • 如果由编译器提供默认值,这些值是嵌在它所生成 IL 代码中的。

    这一行为在编译时完成,因此默认值必须是 编译时 常量,为此,修改默认值会影响软件版本号。

4.2.3 对版本号的影响

因命名实参使用方式的出现,形参名称的修改都有可能导致调用方的不兼容。

因形参变化导致的不兼容主要有 3 种:

  1. 参数名称的改变具有破坏性

    下面演示了签名相同的方法,因修改形参名称,导致通过 命名实参 方式的调用产生了不兼容:

    public static Method(int x, int y = 5, int z = 10)
    // 如果在新版本中做如下改动:
    public static Method(int a, int b = 5, int c = 10)
    
  2. 默认值的改动

    如前文所述,默认值是编译器在 IL 代码对方法的调用中就已经确定的值。在 同一 程序集下默认值的改动不会引发问题;在 不同 程序集只有 重新编译 才能使改动后的默认值生效。

    此处建议使用专用的默认值,该默认值总是让方法在执行器自行选取值。例如对于某个 int​ 类型的参数,可以改用 Nullable<int>​,则默认值为 null,然后方法内部再根据 null 值进行特化处理。

  3. 添加重载方法

    添加重载方法稍不注意就可能造成重载决议与之前不一致。

    强烈建议尽量避免添加那些有可选参数的重载方法。如果需要可选参数,建议使用 因子 模式(使用一个类表示这些选项)。

4.3 COM 互操作性提升

4.3.1 链接主互操作程序集

针对 COM 类型编程,离不开主互操作程序集(primary interop assembly, PIA)。.NET 使用 COM 有两种方式:

  • 在 C#4 和 VS2010 之前:PIA 通过 引用 的方式关联 COM 组件,其 VARIANT​ 类型通过 object ​ 表示
  • 自 C#4 和 VS2010 开始,PIA 的使用可以从引用变为 链接 方式,其 VARIANT​ 类型通过 dynamic ​ 表示

Info

VARIANT​ 是一种通用的数据结构,用于在跨语言、跨进程的组件间传递不同类型的数据。它是 COM 中处理动态类型数据的核心机制。

——deepseek

Tips

链接方式通过在“VS →相关引用→属性页”中设置其“嵌入互操作类型”为 True 启用。将该选项设为 Ture 后,相关的 PIA 部分会被直接嵌入当前程序集,并且只有应用程序中用到的部分才会被纳入。到了代码运行阶段,程序用到的那部分代码已经在编译时包含进来了,因此前期无需部署。

不过我实际测试发现它不管用。还是得部署(将相关 dll 拷贝至程序根目录)。

下图演示了运行视角下,引用方式(旧方式)和链接方式(新方式)的区别:

image

4.3.2 COM 组件中的可选形参

部分 COM 方法包含大量参数,而且这些参数经常是 ref​ 参数。在 C#4 之前,像保存 Word 文档这样的操作都要大费周章:

object missing = Type.Missing;      // ref 参数的占位变量

Application app = new Application { Visible = true };   // 打开 Word
Document doc = app.Documents.Add(                           //
    ref missing, ref missing, ref missing, ref missing);    // 创建并填充
Paragraph para = doc.Paragraphs.Add(ref missing);           // 文档
para.Range.Text = "Awkward old code";                       //

object fileName = "demo1.docx";
doc.SaveAs2(ref fileName, ref missing, ref missing, ref missing,    //
    ref missing, ref missing, ref missing, ref missing,             // 保存
    ref missing, ref missing, ref missing, ref missing,             // 文档
    ref missing, ref missing, ref missing, ref missing);            //

doc.Close(ref missing, ref missing, ref missing);               // 关闭
app.Application.Quit(ref missing, ref missing, ref missing);    // Word

C#4 推出的若干特性大大简化了该过程:

  • 命名实参可以让实参与形参的对应关系一目了然;

  • 对于 ref 参数,值可以直接指定为实参;

    仅适用于 COM 库。编译器会在后台为其创建一个局部变量,然后将该变量按照引用进行传递。

  • ref 参数可以是可选参数,在调用代码中可以省略;

    仅适用于 COM 库。Type.Missing​ 用于表示默认值。

基于以上特性,前面的代码可以简化为:

Application app = new Application { Visible = true };
Document doc = app.Documents.Add();     // 省略了所有
Paragraph para = doc.Paragraphs.Add();  // 可选形参
para.Range.Text = "Simple new code";

doc.SaveAs2(FileName: "demo2.docx");    // 使用命名实参表意

doc.Close();
app.Application.Quit();

4.3.3 命名索引器

C# 的索引器在源码中始终是不具名的,类型只能拥有默认索引器。虽然可以通过 attribute 来指定一个名字(见5.2.1 索引属性的设计),但是只能在其他语言中使用。自 C#4 开始,C# 单独为 COM 类型开了一道后门,可以调用 COM 中具有其他名称的索引器。

如下代码演示了调用 Word 中的 Application​ 类型对外提供的名为 SynonymInfo​ 的命名索引器,其声明如下:

SynonymInfo SynonymInfo[string Word, ref object LanguageId = Type.Missing]

在 C#4 之前,需要像调用一个方法那样调用 get_SynonymInfo​,自 C#4 开始,可以通过名称进行访问,并可以省去多余的参数:

Application app = new Application { Visible = false };

object missing = Type.Missing;
SynonymInfo info = app.get_SynonymInfo("method", ref missing);      // 在 C#4 之前访问
Console.WriteLine("'method' has {0} meanings", info.MeaningCount);  // synonyms

info = app.SynonymInfo["index"];                                    // 使用命名索引器
Console.WriteLine("'index' has {0} meanings", info.MeaningCount);   // 简化代码

4.4 泛型型变

泛型型变讨论的是关于如何根据泛型的类型实参对泛型进行安全的类型 转换 ,其中数据 流转的方向 是重点。

4.4.1 泛型型变示例

泛型参数可以型变有一个前提:转换成其他类型后,它的其他操作必须是安全的。

试比较如下两段代码,其中第 段代码无法通过编译:

IEnumerable<string> strings = new List<string> { "a", "b", "c" };
IEnumerable<object> objects = strings;
IList<string> strings = new List<string> { "a", "b", "c" };
IList<object> objects = strings;

这是因为在 IList<T>​ 的方法中,类型 T​ 既用作 输入 (索引器),也用作 输出Add()​ 方法),这导致类型转换可能存在风险,以如下代码为例,第 3、4 行代码会造成类型不匹配:

IList<string> strings = new List<string> { "a", "b", "c" };
IList<object> objects = strings;
objects.Add(new object());
string element = strings[3];

前面的代码中,IEnumerable<T>​ 的泛型类型 T​ 总是用于输 ,类似的,Action<T>​ 委托的泛型类型 T 总是用于输 。如下代码演示了 Action<T>​ 的型变:

Action<object> objectAction = obj => Console.WriteLine(obj);
Action<string> stringAction = objectAction;
stringAction("Print me");

基于以上示例代码,定义如下术语:

  • 协变:当泛型值只用作输 时。
  • 逆变:当泛型值只用作输 时。
  • 不变:当泛型值既用作输入也用作输出时。

4.4.2 接口和委托声明中的变体语法

泛型变体有如下几个规则:

  1. 变体只能用于 接口委托

    类和结构体的协变是不存在的

  2. 变体的定义与每一个具体的类型形参绑定。

TIps

可以概括地说“IEnumerable<T>​ 是协变的”,而更准确的说法是“IEnumerable<T>​ 对于类型 T​ 是协变的”。

在声明接口和委托时,可以为每个类型形参添加独立修饰符,用于标明类型形参的变体属性:

  • out:表示形参是 变的;
  • in:表示形参是 变的;
  • 无修饰:表示形参是 变的。
public interface IEnumerable<out T>
public delegate void Action<in T>
public interface IList<T>

任何类型形参都只能有一个修饰符修饰,而多个类型形参可以各自拥有自己的修饰符:

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

4.4.3 变体的使用限制

我们前面提到:

类和结构体的协变是不存在的

这里再次重申:变体声明于接口和委托中,并且不能被实现接口的类或结构体所继承。假设有如下类定义:

public class SimpleEnumerable<T> : IEnumerable<T>    // 这里不可以使用 out 修饰符
{
    // 实现
}

则有:

  • SimpleEnumerable<string>不可 转换为 SimpleEnumerable<object>

  • SimpleEnumerable<string>可以 转换为 IEnumerable<object>

    此处利用了 IEnumerable<T>​ 的协变特性

假设有类型实参 A​ 和 B​,我们希望将 IEnumerable<A>​ 转换成 IEnumerable<B>​。只有存在从 A​ 到 B​ 的 一致性 转换或 隐式 引用转换时,才能完成目标转换。

假设有某个具有 n 个类型形参的泛型声明: T<X1 , ..., Xn >,完成 T<A1 , ..., An> 到 T<B1 , ..., Bn > 的转换,要通过遍历从 1 到 n 之间的每个 i 值来检查每对实参。

  • 如果 Xi 是协变, A i B i 必须存在一致性转换或者隐式引用转换
  • 如果 Xi 是逆变, B i A i 必须存在一致性转换或者隐式引用转换。
  • 如果 Xi 是不可变的,Ai 到 Bi 必须存在一致性转换。

Tips

几个术语:

  • 包含变体的转换称为 变体 转换
  • 变体转换属于 引用 转换。 引用 转换不改变变量的值,它只改变变量在编译时的类型。
  • 一致性转换指的是从一个类型转换为一个相同的(从 CLR 的角度看)类型。它可以是在 C# 中同类型之间的转换(例如 string​ 类型到 string​ 类型的转换),也可以是 C# 中不同类型间的转换,例如从 object​ 到 dynamic​ 的转换。

4.4.4 泛型型变实例

泛型型变总是与我们的自然预期一致,很多情况下我们并没有察觉应用了泛型型变的特性。试比较如下两段代码,第一段代码是在泛型型变出来之前的编码方式:

IEnumerable<string> strings = new[] { "a", "b", "cdefg", "hij" };
List<object> list = strings
    .Where(x => x.Length > 1)
    .Cast<object>()
    .ToList();
IEnumerable<string> strings = new[] { "a", "b", "cdefg", "hij" };
List<object> list = strings
    .Where(x => x.Length > 1)
    .ToList<object>();

此外,IComparer<T>​ 与逆变联用会有奇效。假设有如下情形:

  1. 有一个基类 Shape​,它有一个 Area​ 的属性,并且有 Circle​ 和 Rectangle​ 两个子类继承自 Shape
  2. 想要通过 List<T>.Sort()​ 方法对 List<Shape>​、List<Circle>​ 或 List<Rectangle>​ 进行排序

我们可以借助泛型逆变,编写一个实现 IComparer<Shape> ​ 接口的 AreaComparer​ 类,对其进行排序:

List<Circle> circles = new List<Circle>
{
    new Circle(5.3),
    new Circle(2),
    new Circle(10.5)
};
circles.Sort(new AreaComparer());
foreach (Circle circle in circles)
{
    Console.WriteLine(circle.Radius);
}

posted @ 2025-03-30 15:04  hihaojie  阅读(64)  评论(0)    收藏  举报