返回

基元类型、引用类型和值类型

编程语言的基元类型

什么是基元类型

编译器直接支持的数据类型称为基元类型(primitive type).

基元类型直接映射到Framework类库FCL中存在的类型.例如C#中的int直接映射到的System.Int32类型.

MSDN文档将primitive type翻译成”基元类型”,而不是容易混淆的”基本类型”.

以下四行代码都能正确编译,并能生成完全相同的IL代码.

// 最方便的语法
int a = 0;
// 方便的语法
System.Int32 a = 0;
// 不方便的语法
int a = new int();
// 最不方便的语法
System.Int32 a = new System.Int32();

// 从另外一个角度可以认为C#编译器自动假定所有源代码都添加了一下using指令(取别名作用)
using sbyte = System.SByte;
using byte = System.Byte;
using short = System.Int16;
using ushort = System.UInt16;
using int = System.Int32;
using uint = System.UInt32;
...

基元类型对应的FCL类型

是要是符合CLS公共语言规范的类型,其他语言都提供了类似的基元类型. 不符合的就不一定支持了.

关于关键字和完整的系统类型名称

C#语言规范:”从风格上说,最好使用关键字,而不是使用完整的系统类型名称.” 作者认为: 更好的是使用FCL类型名称,完全不用基元类型名称.

事实上,作者希望编译器根本不提供基元类型名称, 而是强迫开发人员使用FCL类型名称.理由如下:

  1. 有些程序员纠结用string还是String . 由于C#的string(这是关键字)直接映射到System.String类型(这是FCL库中的类型). 所以两者没有区别.

    误区: 有些开发人员说32位系统上int代表32位整数,64位系统上int代表64位整数,这个说法是错误的. 因为c#的int始终映射到System.Int32类型. 所以不管在什么操作系统上运行,代表的都是32位整数. 如果用Int32,这样的误解就没有了.

  2. C#的long类型映射到的是System.Int64,而其他编程语言中可能映射到Int16或Int32. 例如C++/CLI就将long视为Int32. 事实上大多数语言不将long当做关键字.根本不编译使用了它的代码.

  3. FCL的许多方法都将类型名作为方法名的一部分.例如BinaryReader类型的方法包括ReadBoolean,ReadInt32,ReadSingle等, 而System.Convert类型的方法包括ToBoolean,ToInt32,ToSingle等.

    // 虽然语法上没问题, 但float的那一行无法一下子判断该行的正确性.
    BinaryReader br = new BinaryReader(...);
    // 使用float C#关键字
    float val = br.ReadSingle(); // 正确,但感觉别扭
    // 使用Single FCL类型名称
    Single val = br.ReadSingle();// 正确,感觉自然
    
  4. 平时只用C#的许多程序员逐渐忘了还可以用其他语言写面向CLR的代码.C#主义逐渐入侵类库代码.

    例如:FCL几乎完全是用C#写的,FCL团队向库中引入了ArrayGetLongLength这样的方法,该方法返回的是Int64值.这种值在C#中确实是long,但是在其他语言比如C++/CLI中不是. 另一个例子是:System.Linq.EnumerableLongCount方法.

考虑到以上原因,本书坚持使用FCL类型名称.

System.Int32 为什么能转 System.Int64?

在许多编程语言中,一下代码都能正确编译并运行:

Int32 i = 5;//32位值
Int64 i = i;//隐形转型为64位值

但是根据上一章内容,对类型转换的讨论,你或许认为上述代码无法编译. 毕竟System.Int32System.Int64是不同的类型.互相不存在派生关系. 但是能正确编译上述代码,运行起来也没有问题.

原因是: C#编译器非常熟悉基元类型,会在编译代码时应用自己的特殊规则.具体的说,C#编译器支持与类型转换,字面值(直接量或文字常量)以及操作符有关的模式.

转型

编译器能执行基元类型之间的隐式或显示转型.

Int32 i = 5; //从Int32隐式转型为Int32
Int64 l = i; //从Int32隐式转型为Int64
Single s =i; //从Int32隐式转型为Single
Byte b = (Byte)i;// 从Int32显示转型为Byte
Int16 v = (Int16)s;//从Single显示转型为Int16

只有在转换安全的时候,C#才允许隐式转型.

  • 什么是转换安全的时候?
    • 是指不会发生数据丢失的情况. 比如从Int32转换为Int64.
  • 如果可能不安全,C#就要求显示转型.
    • 对于数值类型,不安全意味着转换后丢失精度或数量级.
    • 例如Single(float IEEE32位浮点值),转换为Int16(short 有符号16位)也要求显示转型.因为Single能表示比Int16更大数量级的数字(会丢失精度).

C#编译器总是对转型结果进行截断

比如 6.8的Single(float IEEE32位浮点值)转型为Int32, C#总是对结果进行截断(向下取整), 结果是将6放入Int32类型中.

有些编译器可能会将结果向上取整为7.

字面值

字面值可被看成是类型本身的实例.

// 实例 调用 实例方法
Console.WriteLine(123.ToString() + 456.ToString()); // 123456

// 如果表达式由字面值构成, 编译器在编译时就能完成表达式求值.从而增强应用程序性能.
Boolean found = false;// 生成的代码将found设为0
Int32 x = 100 + 20 + 3;// 生成的代码将x设为123
String s = "a " + "bc";// 生成的代码将s设为"a bc"

checked 和 unchecked 基元类型操作

对基元类型执行的许多算术运算符都可能造成溢出:

// byte 2^8 = 256
Byte b = 100;
// 100 +200 = 300
// 执行此处算术运算符时, 要求所有的操作数扩大到32位(64位,如果有操作数需要超过32位来表示的话)
// b 和 200 都不超过32位,则先转换成32位值,然后加到一起,结果是一个32位的十进制300.
// 然后该值存回b变量前**必须转型为Byte, C#不隐式执行此操作.**
b = (Byte)(b + 200); // b 现在为 44

溢出处理

  1. 溢出大多数时候是不希望出现的,会导致应用程序行为失常.但是极少数时候计算哈希值和校验和,这种溢出可以接受.
  2. 不同语言处理溢出的方式不同, C/C++不将溢出视为错误,允许值回滚.VB则视为错误,并抛出异常.

回滚:一个值超出允许的最大值时,回滚到一个非常小的,负的或者未定义的值.

CLR提供了一些特殊的IL指令. 允许程序员自己决定如何处理溢出. CLR有一个add指令,作用是将两个值相加,但不执行溢出检查,还有一个add.ovf指令,会在溢出时抛System.OverfolwException异常.

  1. 溢出检查默认是关闭的. 编译器生成IL代码时,将自动使用加减乘和转换指令的无溢出检查版本.代码能更快的运行.
  2. /checked+编译器开关在生成代码时使用溢出检查版本.这样CLR会检查这些运算,判断是否发生溢出,并抛出异常.
  3. 除了全局性的打开和关闭溢出检查, 程序员可以在代码的特定区域控制.
    • unchecked 操作符
    • checked 操作符
UInt32 invalid = unchecked( (UInt32) (-1)); // OK

Byte b = 100;
// 会抛出OverflowException异常
// 这个过程中,b和200会转成32位值,
// 300 转成 Byte就会抛出异常
b = checked((Byte)(b + 200));

// b包含44, 不会抛出异常.
b = (Byte)checked(b + 200);

C#还支持checked和unchecked语句. 可以是语句块中的代码都进行或不进行溢出检查.

checked和unchecked语句 唯一作用就是决定生成哪个版本的加减乘和数据转换的IL指令. 所以在checked操作符或语句中 调用方法,不会对该方法造成任何影响.

checked
{
  Byte b = 100;
  //简化 b = (Byte)(b + 200)
  b += 200;
}

checked
{
  // 假定SomeMethod试图把400加载到一个Byte中
  SomeMethod(400);
  // SomeMethod 可能会,也可能不会抛出OverflowException异常
  // 如果SomeMethod使用checked指令编译,就可能会抛出异常.
  // 但这和当前的checked语句无关.
}

应对无效输入的建议

  1. 尽量使用有符号数值类型比如:Int32和Int64,而不是无符号数值类型UInt32和UInt64.
    • 这允许编译器检测更多的上溢/下溢的错误.
    • 类库的多个部分,比如Array和String的Length属性 被硬编码为返回有符号的值.
    • 减少强制类型转换,无符号数值类型不符合CLS.
  2. 写代码时,如果代码可能发生你想不到的溢出(可能是无效的输入,使用用户或客户机提供的数据), 就把这些代码放到checked块中. 同时捕捉OverflowException异常.
  3. 将允许发生溢出的代码显示放到unchecked块中,比如在计算校验和时.
  4. 对于没有使用uncheckedchecked的语句,都假定发生溢出时抛出异常.

勾选 检查运算上溢/下溢 相当于打开了编译器的/checked+开关进行调试性生成.应用程序运行起来会慢一点.可以进行比较完整的溢出检查.

System.Decimal是非常特殊的类型

  1. C#和VB视为基元类型. 但是CLR不这样. CLR没有知道如何处理Decimal值的IL指令.
  2. Decimal类型自己定义一系列方法,包括Add,Subtract,Multiply,Divide. 还为+-*/等提供了操作符重载方法.
  3. 编译使用了Decimal值的代码时,编译器会生成代码来调用Decimal的成员方法,并通过这些成员方法来进行实际的运算. 这也意味着Decimal值的处理速度慢于CLR基元类型的值.
  4. 由于没有相应的IL指令来处理Decimal值, 所以checked和unchecked操作符,编译器开关都失去作用.

System.Numerics.BigInteger类型

  1. 类似的,BigInteger类型也在内部使用了UInt32数组来表示任意大的整数,它的值没有上限和下限.
  2. 因此对于BigInteger类型执行运算永远不会造成OverflowException异常
  3. 如果值太大,没有足够的内存来改变数组大小,BigInteger的运算可能会抛出OutOfMemoryException异常.

引用类型和值类型

CLR支持两种类型: 引用类型和值类型.

引用类型总是从托管堆分配. C#的new操作符返回对象内存地址—-指向对象数据的内存地址.

使用引用类型必须留意性能问题.

  1. 内存必须从托管堆分配.
  2. 堆上分配的每个对象都有一些额外成员,这些额外成员必须初始化.
  3. 对象中的其他字节(为字段设置的字节)总是设为0.
  4. 从对管堆分配对象时,可能强制执行一次垃圾回收.

为了提高性能,CLR提供了名为”值类型”的轻量级类型.

值类型 的实例一般在线程栈上分配.

  1. 可以作为字段嵌入引用类型的对象中
  2. 在代表值类型实例的变量中 不包含指向实例的指针.
  3. 实例中包含了实例本身的字段(值). 所以操作实例中的字段(值)不需要提领指针.
  4. 值类型不受垃圾回收器的控制.
  5. 所有值类型都是隐式密封,防止用作其他应用类型或值类型的基类.

值类型缓解了使用托管堆的压力,并减少了应用程序生存期内的垃圾回收次数.

在非托管环境(C/C++)中声明类型后, 使用该类型的代码会决定是在线程栈上还是应用程序的堆中分配该类型的实例. 托管代码中,要由定义类型的开发人员决定在什么地方分配类型实例,使用此类型的人对此没有控制权.

查看文档区分引用类型和值类型

  1. 在文档中查看类型时, 任何称为 的类型都是引用类型. 例如System.Exception类…
  2. 所有值类型都称为 结构枚举. 例如:System.Int32结构…

结构&枚举

所有结构都是抽象类型System.ValueType的直接派生类.System.ValueType本身又直接从System.Object派生.

  1. 根据定义, 所有值类型都必须从System.ValueType派生.
  2. 所有枚举都从System.Enum抽象类型派生,System.EnumSystem.ValueType派生.

CLR和所有编程语言都会给予枚举特殊待遇(直接支持各种强大的操作,非托管环境中就不这样了.)

引用类型和值类型的区别

// 引用类型,因为class
class SomeRef { public Int32 x; }
// 值类型
struct SomeVal{ public Int32 x; }

static void ValueTypeDemo()
{
    // 图示左边部分
    SomeRef r1 = new SomeRef();    // 在堆上分配
    // 使用new看似是要在托管堆上分配实例,实际是C#编译器知道SomeVal是值类型
    // 所以会正确的生成IL代码,在线程栈上分配,
    // C#还会确保值类型中的所有字段都初始化为零.
    // SomeVal v1; 还可以这么写. 但是C#认为此处v1没有初始化.
    // 如果直接使用Int32 a = v1.x;将不能通过编译
    SomeVal v1 = new SomeVal();    // 在栈上分配. 使用new操作符,C#会认为已经初始化.
    r1.x = 5;                      // 提领指针
    v1.x = 5;                      // 在栈上修改

    // 图示右边部分
    SomeRef r2 = r1;               // 只复制引用(指针)
    SomeVal v2 = v1;               // 在栈上分配并复制成员
    r1.x = 8;                      // r1.x和r2.x都会更改
    v1.x = 9;                      // v1.x会更改,v2.x不变,为5
}

在代码中使用类型时, 必须注意是引用类型还是值类型,这会极大的影响在代码中表达自己意图的方式.

设计类型时,满足什么条件才声明为值类型?

除非满足以下全部条件,否则不应该将类型声明为值类型。

  1. 类型具有基元类型的行为. 是十分简单的类型,没有成员会修改类型的任何实例字段.
    • 如果类型没有提供会更改字段的成员,就说该类型不可变(immutable).
  2. 类型不需要从其他任何类继承.
  3. 类型也不派生出其他任何类型

类型实例大小也应该在考虑范围内,因为实参默认以值方式传递,造成对值类型实例中的字段进行复制,影响性能.

  1. 类型的实例较小(16字节或更小)
  2. 类型的实例较大(大于16字节),但不作为方法实参传递,也不从方法返回

值类型的主要优势以及自身的局限

  1. 值类型主要的优势是不作为对象在托管堆上分配.
  2. 值类型对象有两种表示形式: 未装箱已装箱. 引用类型总是处于已装箱形式.
  3. 值类型System.ValueType派生. 该类型提供了与System.Object相同的方法,但重写了Equals方法,能在两个对象的字段值完全匹配的前提下返回true.还重写了GetHashCode方法.会将字段中的值都考虑在内.但是这个实现存在性能问题.所以定义自己的值类型时应该重写Equals和GetHashCode方法,并提供显式实现.
  4. 由于不能将值类型作为基类或新的引用类型,所以不应当在值类型中引入任何新的虚方法,所有方法不能是抽象的,并且隐式密封不可重写.
  5. 引用类型的变量包含堆中对象的地址.引用类型的变量创建时默认初始化为null(表示当前不指向任何有效对象). 试图使用null引用时会报NullReferenceException异常.值类型的变量总是包含其基础类型的一个值.而值类型的初始化都为0. 值类型变量不是指针,不会报NullReferenceException异常.CLR允许为值类型添加可空标识.
  6. 值类型变量赋给另一个值类型变量,会执行逐字段复制. 引用类型的变量赋给另一个引用类型的变量值只复制内存地址.
  7. 两个或多个引用类型变量能引用堆中的同一对象,所以对一个变量操作可能会影响到另一个变量引用的对象.值类型则不会影响另一个值类型变量.
  8. 由于未装箱的值类型不在堆上分配, 定义了该类型的实例的方法不再活动(变量的生命周期结束),为它们分配的栈存储就会被释放.

CLR如何控制类型中的字段布局?

为了提高性能,CLR能按照它所选择的任何方式排列类型的字段. 例如:CLR可以在内存中重新安排字段的顺序,将对象引用分为一组,同时正确排列和填充数据字段.

定义类型时,针对类型的各个字段,你可以告诉CLR按照指定的顺序排列,还是按照CLR自己认为合适的方式重新排列.

如何告诉CLR怎么排列?

  1. 要为自己定义的类或结构应用System.Runtime.InteropServices.StructLayoutAttribute特性。

    • 向该特性的构造器传递LayoutKind.Auto : 让CLR自动排列字段.
    • 顺序布局:传递LayoutKind.Sequential : 让CLR保持你的字段布局.
    • 精确布局:传递LayoutKind.Explicit : 指定每个字段的偏移量,利用偏移量在内存中显示排列字段.
  2. 如果不指定StructLayoutAttribute,则CLR按照自己的方式.

  3. 注意:C#编译器有默认设定:

    • 默认引用类型选择LayoutKind.Auto
    • 默认值类型选择LayoutKind.Sequential
    • 这是因为C#编译器团队认为和非托管代码互操作时会经常用到结构.为此,字段必须保持程序员定义的顺序.
    • 假如创建的值类型不与非托管代码互操作,就应该覆盖C#编译器的默认设定.
    using System;
    using System.Runtime.InteropServices;
    
    // 让CLR自动排列字段以增强这个值类型的性能
    // 覆盖掉C#编译器默认的`LayoutKind.Sequential`设置
    [StructLayout(LayoutKind.Auto)]
    internal struct SomeValType
    {
        private readonly Byte m_b;
        ....
    }
    

LayoutKind.Explicit说明

构造器传递了LayoutKind.Explicit之后, 要向值类型中的每个字段都应用System.Runtime.InteropServices.FieldOffsetAttribute特性的实例.并且向该特性传递Int32值来指出字段第一个字节距离实例起始处的偏移量(以字节为单位).

显示布局常用来模拟非托管C/C++中的union,因为多个字段可起始于内存的相同偏移位置.

注意在类型中:一个引用类型和一个值类型互相重叠是不合法的.多个值类型相互重叠则是合法的.为了是这样的类型能够验证,所有重叠字节都必须能通过公共字段访问.

union是特殊类,union中的数据成员在内存中的存储是相互重叠.每个数据成员都从相同的内存地址开始. 分配给union的存储区数量是包含它最大数据成员所需的内存数, 同一时刻只有一个成员可以被赋值. (1)同一个内存段可以用来存放几种不同类型的成员,但在每一个时刻只能存在其中一种,而不能同时存放几种,即每一瞬间只有一个成员起作用,其它的成员不起作用,不能同时都存在和起作用; (2)共用体变量中起作用的成员是最后一个存放的成员,在存入一个新的成员后,原有的成员就会失去作用,即所有的数据成员具有相同的起始地址。 (3)union和struct都是由多个不同的数据成员组成,但是union所有成员共享一段内存地址,只存放最后一次赋值的成员值,而struct可以存放所以有成员的值。 (4)union的大小是所有成员中所占内存最大的成员的大小,struct是所有成员的大小的“和”。

using System;
using System.Runtime.InteropServices;
// 让开发人员显示排列这个值类型的字段
[StructLayout(LayoutKind.Explicit)]
internal struct SomeValType
{
    [FieldOffset(0)]
    private readonly Byte m_b;// m_b和m_x字段在该类型的实例中相互重叠

    [FieldOffset(0)]
    private readonly Int16 m_x; // m_b和m_x字段在该类型的实例中相互重叠
}

值类型的装箱和拆箱

值类型不作为对象在托管堆中分配,不被垃圾回收,也不通过指针进行引用.

using System;
using System.Collections;

namespace ConsoleApp1
{
    struct Point{public Int32 x, y;}
    class Program
    {
        static void Main(string[] args)
        {
            ArrayList a = new ArrayList();
            Point p;                        // 分配一个Point,不在堆中分配
            // 每次迭代都初始化一个`Point值类型字段`, 并将该Point存储到ArrayList中.
            for (int i = 0; i < 10; i++)
            {
                p.x = p.y = i;     // 初始化值类型中的成员
                a.Add(p);          // 对值类型进行装箱,将引用添加到ArrayList中
            }
        }
    }
}

ArrayList中究竟存储了什么? 是Point结构还是Point结构的地址?

想要知道答案需要研究ArrayList的Add方法.

Add方法原型:public virtual Int32 Add(object value);

  1. 参数是object,也就是说Add获取对托管堆上的一个对象的引用(指针)来作为参数.
  2. a.Add(p); 之前的代码传递的是Point,是值类型.
  3. 为了使代码正确工作,Point值类型必须转换成真正的,在堆中托管的对象,而且必须获取对该对象的引用
  4. 值类型转成引用类型要使用装箱机制.

装箱机制发生的事情

  1. 在托管中分配内存. 分配的内存是值类型各字段所需的内存量(还要加上托管堆所有对象都有的两个额外成员:类型对象指针和同步块索引所需的内存量).
  2. 值类型的字段复制到新分配的堆内存.
  3. 返回对象地址, 现在该地址是对象引用:值类型成了引用类型.

在托管堆中分配相应的内存.

C#编译器自动生成对值类型实例进行装箱所需的IL代码. 但是仍需要理解内部的工作机制才能体会到代码的大小和性能问题.

在运行时,当前存在于Point值类型实例p中的字段复制到新分配的Point对象中. 已装箱Point对象(现在是引用类型)的地址传给Add方法. Point对象一直存在于堆中,直至被垃圾回收.

FCL现在包含一组新的泛型集合类, 非泛型集合类已经是过时的东西. 例如:应该使用System.Collections.Generic.List类而不是System.Collections.ArrayList类. 泛型集合类非泛型集合类进行了大量改进.

  1. API得到简化和增强,性能也得到显著提升
  2. 允许开发人员在操作值类型的集合时不需要对集合中的项进行装箱/拆箱
  3. 开发人员还获得编译时的类型安全性, 减少强制类型转换次数.

拆箱机制

要从上面代码中的ArrayList a取第一个元素.

Point p = (Point)a[0];
  1. 它获取ArrayList的元素0包含的引用(指针), 试图放到Point值类型的实例p中.
  2. 为此,已装箱Point对象中的所有字段都必须复制到值类型变量p中. 后者在线程栈上.
  3. CLR分两步完成复制
    • 第一步: 获取已装箱Point对象各个Point字段的地址. ← 此过程称为拆箱.
    • 第二步: 将字段包含的值从复制到的值类型实例中.

拆箱不是直接将装箱过程倒过来, 装箱的代价被拆箱高得多.

  1. 拆箱就是获取指针的过程.
  2. 拆箱不要求在内存中复制任何字节. 指针指向的是已装箱实例中的未装箱部分.
  3. 拆箱操作后紧接着一次字段复制.

已装箱值类型在拆箱时的过程

  1. 如果包含”对已装箱值类型实例的引用”的变量为null,抛出NullReferenceException.
    • Point p = (Point)a[0];// a为null时
  2. 如果引用的对象不是所需值类型的已装箱实例,抛出InvalidCastException.
public static void Main()
{
  Int32 x = 5;
  object o = x;              // 对x装箱,o引用已装箱对象
  // 在对对象进行拆箱时,只能转型为最初未装箱的值类型(本例是Int32)
  // 所以以下写法会抛出异常
  Int16 y = (Int16)o;        // 抛出InvalidCastException.

  // 正确写法
  Int16 y = (Int16)(Int32)o; // 先拆箱为正确类型,再转型
}
拆箱和复制例子
public static void Main()
{
  Point p;
  p.x = p.y = 1;
  Object o = p;   // 对p装箱;  o引用已装箱实例

  // 将Point的x字段变成2
  p = (Point)o;   // 对o拆箱, 将字段从已装箱的实例复制到[栈]变量中 (复制所有字段)
  p.x = 2;        // 更新[栈]变量的状态
  o = p;          // 对p装箱;o引用新的已装箱实例 (复制所有字段)
}

首先进行一次拆箱,再执行一次字段复制(到栈变量中),最后再执行一次装箱(在托管堆上创建全新的已装箱实例).

这个过程对应用程序性能影响较大.

此段代码用C++/CLI来写,效率会高很多,因为它允许在不复制字段的前提下,对已装箱的值类型进行拆箱,拆箱返回的是已装箱对象中的未装箱部分的地址(忽略对象的类型对象指针和同步索引块这两个额外成员),接着可以用这个指针来操作未装箱实例的字段(这些字段恰好在堆上的已装箱对象中). C++/CLI直接在已装箱Point实例中修改Point的x字段的值. 就这避免了在堆上分配新对象和复制字段两次.

从IL代码查看编译器隐式生成的装箱代码

如果关心特定算法的性能,可以用ILDasm.exe这样的工具查看方法的IL代码,观察IL指令box都在哪些地方出现.

例子一

// 以下代码发生了3次装箱
public static void Main()
{
  Int32 v = 5; // 值类型变量
  object o = v; // 一次装箱
  v = 123; // 将未装箱的值修改为123

  Console.WriteLine(v + "," + (Int32) o );// 显示123,5
}

解析:

  1. 首先在栈上创建一个Int32未装箱值类型实例v , 将其初始化为5.

  2. 创建Object类型的变量o, 让它指向v.

    • 因为引用类型的变量始终是指向中的对象,所以C#生成正确的IL代码对v进行装箱,(复制所有字段到堆中)
    • v的已装箱拷贝的地址存储到o中.
  3. 接着,值123被放到未装箱值类型实例v中, 但这个操作不会影响已装箱的Int32,后者值依然是5.

  4. 接着调用WriteLine方法, 由于方法需要string对象作为参数,编辑器选择Concat的重载版本去实现3个参数的合并

  5. Concat(object,object,object)
    
    • 第一个参数传递v(未装箱的值参数),需要进行装箱操作
    • 第二个参数”,”,作为string对象,引用传递.
    • 第三个参数,(Int32) o, 先进行拆箱(但不紧接着执行复制),获取到在已装箱Int32中的未装箱Int32的地址.这个未装箱的Int32实例必须再次装箱. 将实例的内存地址传给arg2参数
  6. Concat方法调用每个对象的ToString方法,将每个对象的字符串连接起来.返给给WriteLine方法以最终显示.

.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // 代码大小       47 (0x2f)
  .maxstack  3
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  nop       // nop:没有什么意义;

  // Int32 v = 5;
  IL_0001:  ldc.i4.5  // Ldc.I4 将所提供的 int32 类型的值作为 int32 推送到计算堆栈上。
  IL_0002:  stloc.0   //从计算堆栈的顶部弹出当前值并将其存储到索引 0 处的局部变量列表中。

  // object o = v;
  // 局部变量object o,位置设定为索引0
  IL_0003:  ldloc.0   //将索引 0 处的局部变量加载到计算堆栈上。
  IL_0004:  box        [System.Runtime]System.Int32  // 将值类转换为对象引用(O 类型)。
  IL_0009:  stloc.1   //从计算堆栈的顶部弹出当前值并将其存储到索引 1 处的局部变量列表中。

  // v = 123;
  IL_000a:  ldc.i4.s   123  // 将提供的 int8 值作为 int32 推送到计算堆栈上(短格式)。
  IL_000c:  stloc.0

  // Console.WriteLine(v + "," + (Int32) o );
  // 在WriteLine方法参数(object引用类型)中, 会对值类型的v产生装箱操作
  IL_000d:  ldloc.0
  IL_000e:  box        [System.Runtime]System.Int32
  IL_0013:  ldstr      ","    // 推送对元数据中存储的字符串的新对象引用。

  // Console.WriteLine(v + "," + (Int32) o );
  // (Int32) o 拆箱操作,拆成原始的Int32值类型.
  IL_0018:  ldloc.1
  IL_0019:  unbox.any  [System.Runtime]System.Int32
  // 由于被作为WriteLine方法参数(object引用类型), 再进行装箱操作
  IL_001e:  box        [System.Runtime]System.Int32
  // 调用string的Concat方法连接字符串
  IL_0023:  call       string [System.Runtime]System.String::Concat(object,object,object)
  // 将Concat返回的string 传给WriteLine方法
  IL_0028:  call       void [System.Console]System.Console::WriteLine(string)
  IL_002d:  nop

  // 从Main返回,终止应用程序
  IL_002e:  ret
} // end of method Program::Main

如果改进一下,如下:

// 此处如果修改成如下代码,效率更高,避免了2次操作:一次装箱一次拆箱
Console.WriteLine(v + "," + o );// 显示123,5

对比一下IL代码

  1. 比之前版本小了10个字节. 第一个版本额外的拆箱/装箱显然会产生更多的代码. 额外的装箱操作步骤会从托管堆中分配一个额外的对象, 将来对其进行垃圾回收.
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // 代码大小       37 (0x25)
  .maxstack  3
  .locals init (int32 V_0,
           object V_1)
  IL_0000:  nop
  IL_0001:  ldc.i4.5
  IL_0002:  stloc.0
  IL_0003:  ldloc.0
  IL_0004:  box        [System.Runtime]System.Int32
  IL_0009:  stloc.1
  IL_000a:  ldc.i4.s   123
  IL_000c:  stloc.0
  IL_000d:  ldloc.0
  IL_000e:  box        [System.Runtime]System.Int32
  IL_0013:  ldstr      ","
  IL_0018:  ldloc.1
  IL_0019:  call       string [System.Runtime]System.String::Concat(object,object,object)
  IL_001e:  call       void [System.Console]System.Console::WriteLine(string)
  IL_0023:  nop
  IL_0024:  ret
} // end of method Program::Main

进一步提升代码的性能:

v.ToString(),返回是的String,是引用类型,不需要装箱,减少一个装箱操作.

Console.WriteLine(v.ToString() + "," + (Int32) o );

对应的IL代码则是: IL_000f: call instance string [System.Runtime]System.Int32::ToString()

例子二

// 以下代码只发生了一次装箱.
static void Main(string[] args)
{
    Int32 v = 5;
    // 装箱
    Object o = v;
    // 修改栈, 不影响已装箱的o
    v = 123;
    // public static void WriteLine(int value)
    // 方法WriteLine是传值的方式,不需要装箱
    Console.WriteLine(v); // 123
    // 拆箱
    v = (Int32) o;
    Console.WriteLine(v); // 5
}
FCL重载了很多常用值类型的方法,减少常用值类型的装箱次数
public static void WriteLine(double value);
public static void WriteLine(float value);
public static void WriteLine(int value);
...

大多数方法进行重载唯一的目的就是减少常用值类型的装箱次数.

但是FCL不可能接受你自己定义的值类型. 也可能FCL没有提供对应的重载版本,那调用方法传递值类型参数时,就是调用Object参数的重载版本. 将值类型实例作为Object传递会造成装箱.

定义自己的类时, 可将类中的方法定义为泛型(通过泛型约束将类型参数限制为值类型), 这样方法就可以获取任何值类型而不必装箱.

例子三

-(如果要反复对一个值类型装箱,请改为手动方式)

static void Main(string[] args)
{
    Int32 v = 5;
    // v会被装箱3次,浪费时间和内存
    Console.WriteLine($"{v}+{v}+{v}");

    // 对v手动装箱一次
    Object o = v;
    // 编译下一行不会发生装箱行为
    // 内存利用还是执行速度都比上一段代码更胜一筹
    Console.WriteLine($"{o}+{o}+{o}");
}

附加一个主题: C#值类型与引用类型相互嵌套

  1. 值类型里包含引用类型,如:struct包含class对象。

    该引用类型将作为值类型的成员变量,堆栈上将保存该成员的引用,而成员的实际数据还是保存在托管堆中.

  2. 引用类型包含值类型,如:class包含int

    如果是成员变量做为引用类型的一部分将分配在托管堆上。如果是方法里的局部变量则分配在该段代码的线程栈上

    public class ReceiveTest : MonoBehaviour
    {
        private void Awake()
        {
            //对于引用类型new才会有新的内存存在,不new而赋值不会有新的内存存在。
            //对于值类型在使用“=”赋值时,会自动调用隐式的构造函数,会new一次。
            FruitStruct tf = new FruitStruct(100);
            FruitStruct tf2 = tf;
            tf2.fruit.apple = 200;
            tf2.num = 200;
            print(tf.fruit.apple);//打印200 说明共用托管堆中同一个FruitClass类实例
            print(tf.num);//打印100 说明堆栈上存在各自的num
        }
    }
    
    public class FruitClass
    {
        public int apple;
    }
    public struct FruitStruct
    {
        public int num;
        public FruitClass fruit;
        public FruitStruct(int num)
        {
            this.num = num;
            fruit = new FruitClass();
            fruit.apple = num;
        }
    }
    

什么时候需要装箱

  • 第一种情况: 将值类型的实例传给需要获取引用类型的方法.

    要获取值类型的引用,实例就必须装箱.

    未装箱值类型比引用类型更”轻”,归结于

    1. 不在托管堆上分配
    2. 没有堆上的每个对象都有的额外成员:类型对象指针同步块索引

    其中,由于未装箱值类型没有同步块索引,所以不能使用System.Threading.Monitor(提供同步访问对象的机制)类型的方法(或C#lock语句),让多个线程同步对实例的访问.

  • 第二种情况: 值类型如果重写的虚方法(例如Equals,ToString…)方法要调用基类的实现时,会装箱,通过this指针将引用传给基方法.

    虽然未装箱的值类型没有类型对象指针,但仍可以调用由类型继承或重写的虚方法.(比如Equals,GetHashCode,ToString).

    值类型可以重写Equals, GetHashCode或者ToString的虚方法,CLR可以非虚地调用该方法,因为值类型是隐式密封的(即不存在多态性),没有任何类型能够从它们派生。如果你重写的虚方法要调用方法在基类中的实现,那么在调用基类的实现时,值类型实例就会装箱,以便通过this指针将对一个堆对象的引用传给基方法。

  • 第三种情况:

    调用非虚的,继承的方法时(比如GetType或者MemberwiseClone),无论如何都要对值类型进行装箱, 因为这些方法由System.Object定义,要求this实参是指向堆对象的指针.

  • 第四种情况:

    将值类型的未装箱实例 转型为某个接口时 要对实例进行装箱. 是因为接口变量必须包含对堆对象的引用.

    using System;
    using System.Collections;
    
    namespace ConsoleApp1
    {
        internal struct Point : IComparable
        {
            private Int32 m_x, m_y;
            public Point(Int32 x, Int32 y)
            {
                m_x = x;
                m_y = y;
            }
            // 重写从System.ValueType继承的ToString方法.
            public override string ToString()
            {
                // 将Point作为字符串返回. 调用ToString避免装箱
                return string.Format($"{m_x.ToString()}, {m_y.ToString()}");
            }
            // 实现类型安全的CompareTo方法
            public Int32 CompareTo(Point other)
            {
                // Math.sign方法用来判断一个数到底是正数、负数、还是零。
                // 利用勾股定理计算哪个Point离(0,0)更远
                return Math.Sign(
                    Math.Sqrt(m_x * m_x + m_y * m_y) - Math.Sqrt(other.m_x * other.m_x + other.m_y * other.m_y));
            }
            // 实现IComparable接口的CompareTo方法
            public int CompareTo(object obj)
            {
                if (GetType() != obj.GetType()) throw new ArgumentException("obj is not a point");
                // 调用类型安全的CompareTo方法
                return CompareTo((Point) obj);
            }
        }
    
        class Program
        {
            static void Main(string[] args)
            {
                Point p1 = new Point(10,10);
                Point p2 = new Point(20,20);
    
                // 调用Point重写的ToString(虚方法)方法,不装箱p1
                Console.WriteLine(p1.ToString());
    
                // 调用GetType(非虚方法)时, 要对p1进行装箱
                // 调用非虚的,继承的方法时, 无论如何都要对值类型进行装箱
                // 因为这些方法由System.Object定义,要求this实参是指向堆对象的指针.
                Console.WriteLine(p1.GetType()); // 显示ConsoleApp1.Point
    
                // p1调用的是CompareTo(Point other),所以p2不需要装箱
                Console.WriteLine(p1.CompareTo(p2)); // 显示 -1
    
                // 装箱p1, 引用放到c中
                // 将值类型的未装箱实例 转型为某个接口时 要对实例进行装箱
                IComparable c = p1;
                Console.WriteLine(c.GetType()); // 显示ConsoleApp1.Point
    
                // 由于向CompareTo传递的不是Point变量,
                // 所以调用的是CompareTo(object obj),c不需要装箱,
                // 因为已经是引用了已装箱的Point
                Console.WriteLine(p1.CompareTo(c)); // 显示0
    
                // c是引用类型,不需要装箱
                // c调用的是IComparable的CompareTo(object obj)方法
                // 所以p2要装箱
                Console.WriteLine(c.CompareTo(p2)); // 显示-1
    
                // 对c拆箱, 字段复制到p2中
                p2 = (Point) c;
                Console.WriteLine(p2.ToString()); // 显示(10,10)  证明已经复制到栈
            }
        }
    }
    

    上述代码演示了涉及装箱和拆箱的几种情形

    1. 调用ToString

      p1.ToString()时, p1不必装箱,因为ToString是从基类System.ValueType继承的虚方法.

      通常,为了调用虚方法,CLR需要判断对象的类型来定位类型的方法表,由于p1是未装箱的值类型,,所以不存在 类型对象指针 这个成员. 但是JIT编译器发现Point重写了ToString方法,所以会 直接生产代码来直接(非虚地)调用重写的这个ToString方法 . 这里不存在多态性,没有类型能从它派生以提供虚方法的另一个实现. 但是如果Point.ToString方法内部调用了base.ToString() ,那么调会调用System.ValueType的ToString方法,值类型实例就会装箱.

    2. 调用GetType

      调用非虚方法GetType时,p1必须装箱. 因为Point的GetType方法从System.Object继承的,CLR必须使用指向类型的指针,这个指针只能通过装箱p1来获得.

    3. 调用CompareTo第一次

      p1.CompareTo(p2)时,p1不用装箱,因为Point实现了CompareTo(Point other)方法,编译器直接调用它,并且传递的是值类型对象,不需要装箱.

    4. 转型为IComparable

      p1转型为接口类型c时必须装箱. 因为接口被定义为引用类型.

    5. 调用CompareTo第二次

      p1.CompareTo(c)时,传递的是接口变量c,所以编译器调用的是重载版本的CompareTo(object obj),要传递引用指针, c是引用了一个已装箱的Point,所以无序额外装箱.

    6. 调用CompareTo第三次

      c.CompareTo(p2)时,c是引用堆上的已装箱Point对象,还是IComparable接口类型,只能调用接口的CompareTo(object obj)方法, 因此p2需要装箱.

    7. 转型为Point

      将c引用的堆上对象拆箱成Point复制到栈上的p2.

使用接口更改已装箱值类型中的字段(不应该这样做)

来看看你的理解程度,答出控制台输出什么.

using System;
using System.Collections;

namespace ConsoleApp1
{
    internal struct Point
    {
        private Int32 m_x, m_y;

        public Point(Int32 x, Int32 y)
        {
            m_x = x;
            m_y = y;
        }
        public void Change(Int32 x, Int32 y)
        {
            m_x = x;
            m_y = y;
        }
        public override string ToString()
        {
            // 将Point作为字符串返回. 调用ToString避免装箱
            return string.Format($"{m_x.ToString()}, {m_y.ToString()}");
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            Point p = new Point(1, 1);
            Console.WriteLine(p);

            p.Change(2, 2);
            Console.WriteLine(p);

            Object o = p;
            Console.WriteLine(o);

            ((Point) o).Change(3, 3);
            Console.WriteLine(o);
        }
    }
}

答案: 1,1 2,2 2,2 2,2

解析: 重点说下((Point) o).Change(3, 3);

Object o对象对于Change方法一无所知, 所以需要拆箱转型到Point.

  • 拆箱转型过程
    • 将已装箱的Point中的字段 复制到线程栈 上的一个 临时Point 中.
    • 这个栈上的Point的m_x和m_y字段会变成3,3
    • 但是在堆上已装箱的Point里的值不受这个Change调用的影响.所以最后输出的Object o是堆上的2,2

有的语言(比如C/C++)允许更改已装箱值中的字段,但是C#不允许. 不过,可以用接口欺骗C#,让它允许这个操作.代码如下:

using System;
using System.Collections;

namespace ConsoleApp1
{
    internal interface IChangeBoxedPoint
    {
        void Change(Int32 x, Int32 y);
    }
    internal struct Point : IChangeBoxedPoint
    {
        private Int32 m_x, m_y;
        public Point(Int32 x, Int32 y)
        {
            m_x = x;
            m_y = y;
        }
        public void Change(Int32 x, Int32 y)
        {
            m_x = x;
            m_y = y;
        }    
        public override string ToString()
        {
            // 将Point作为字符串返回. 调用ToString避免装箱
            return string.Format($"{m_x.ToString()}, {m_y.ToString()}");
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            Point p = new Point(1, 1);
            Console.WriteLine(p); // 1,1

            p.Change(2, 2);
            Console.WriteLine(p); // 2,2

            Object o = p;
            Console.WriteLine(o); // 2,2

            ((Point) o).Change(3, 3);
            Console.WriteLine(o); // 2,2

            // 将P转型接口,装箱
            // 在已装箱的值上调用Change, 堆上已装箱的值就变为4,4
            // 并没有引用指向这个已装箱值,即将被垃圾回收掉
            // 未装箱的Point p仍然是2,2
            ((IChangeBoxedPoint) p).Change(4,4);
            Console.WriteLine(p);// 2,2

            // 将引用类型o转成IChangeBoxedPoint,才能使用Change方法
            // 不需要装箱,因为o本来就是已装箱的Point
            // Change(5,5);正确修改了已装箱的值
            ((IChangeBoxedPoint) o).Change(5,5);
            Console.WriteLine(o); //  5,5
        }
    }
}

演示接口方法如何修改已装箱值类型中的字段,在C#中,不用接口方法便无法做到.

值类型应该是”不可变”(immutable). 也就是说我们不应该定义任何会修改实例字段的成员.建议将值类型字段都标记为readonly. 否则容易写出一个试图更改字段的方法,就会产生非预期的行为. 标记readonly后,编译时就会报错.前面的例子清楚的揭示了为什么这样做.

FCL的核心值类型(Byte,Int32,UInt32,Int64,UInt64,Single,Double,Decimal,BigInteger,Complex以及所有枚举)都是不可变的.

不可变(immutable): 即对象一旦被创建初始化后,它们的值就不能被改变,之后的每次改变都会产生一个新对象。

所以,对于不变对象来说,调用对象自身的任意方法,也不会改变该对象自身的内容。相反,这些方法会创建新的对象并返回,这样,就保证了不可变对象本身永远是不可变的。

dynamic(动态绑定)基元类型

C#是类型安全的编程语言. 意味着所有表达式都解析成类型的实例,编译器生成的代码只执行对该类型有效的操作.

类型安全的优势:

  1. 许多错误能在编译时检测到
  2. 能编译出更小,更快的代码. 是因为能在编译时进行更多预设,并在生成的IL和元数据中落实预设.

为了方便开发人员使用反射或者与其他组件通信.C#编译器允许将表达式/变量的类型标记为dynamic.

对于CLR, dynamic与Object完全一致.

dynamic 变量/表达式

变量/表达式 使用dynamic的话,编译器会生成payload有效载荷代码根据运行时具体的类型来决定执行的操作.

编译器生成特殊IL代码来描述所需操作,这种特殊的代码称为payload有效载荷.

在运行时,payload有效载荷代码根据dynamic表达式/变量引用的对象的实际类型来决定具体执行的操作.

static void Main(string[] args)
{
    // dynamic 局部变量:编译器会将dynamic转成System.Object.
    // 值类型会装箱.
    dynamic val;
    for (Int32 demo = 0;  demo<2 ;demo ++)
    {
        // dynamic 表达式 : 编译器会生成payload代码
        val = (demo == 0) ? (dynamic) 5 : (dynamic) "A";
        // 两个操作数的类型是dynamic
        val = val + val;
        // 传入dynamic类型参数, C#编译器会生成payload代码
        // 在运行时检查val的实际类型.
        // 调用对应的重载版本.
        M(val);
    }
}
private static void M(Int32 n)
{
    Console.WriteLine($"M(Int32):{n}");
}

private static void M(String s)
{
    Console.WriteLine($"M(String):{s}");
}

输出: M(Int32):10 M(String):AA

由于val是dynamic类型,C#编译器生成payload代码在运行时检查value的实际类型,然后决定+操作符实际要做什么.

  1. 第一个循环中: val = 5(Int32值),结果是10 .
  2. M(val);传入dynamic类型参数, C#编译器会生成payload有效载荷代码,在运行时检查val的实际类型,调用对应的重载版本.

所有表达式都能隐式转型为dynamic,因为所有表达式最终都生成从Object派生的类型(值类型需要装箱).

正常情况下:

  1. 编译器 不允许 写代码将表达式Object隐式转成其他类型. 必须显示转型.

  2. 但是,编译器允许使用隐式转型语法将表达式dynamic转型为其他类型.

    • 虽然编译器允许省略显示转型,但CLR会在运行时验证来确保类型的安全性.
    • 运行时,dynamic类型不兼容要转型的类型,则会抛出InvalidCastException.
    Object o1 = 123;    // OK: 从值类型隐式转型为Object,装箱
    //Int32 n1 = o1;    // Error: 不允许从Object隐式转型到Int32
    
    Int32 n2 = (Int32) o1; // OK: 显示转型, 拆箱
    
    dynamic d1 = 123;    // OK: 从值类型隐式转为dynamic,装箱
    Int32 n3 = d1;       // OK: 从dynamic隐式转为值类型,拆箱
    // 编译出的IL代码会对值类型123进行装箱
    dynamic d = 123;
    // d引用了已装箱的Int32
    var result = M(d); // var result 等同于 dynamic result
    

    上述代码之所以能通过编译,是因为编译器不知道调用哪个方法,也不知道返回的类型,所以编译器假定result具有dynamic类型. 如果运行时调用M方法.返回类型是void,将抛出Microsoft.CSharp.RuntimeBinder.RuntimeBinderException异常.

    var声明局部变量是一种简化语法,要求编译器根据表达式推断具体数据类型. 只能用在方法内部声明局部变量,必须显式初始化用var声明的变量.

    表达式不能转型为var,但是能转型dynamic,也无需初始化用dynamic声明的变量.

dynamic 字段/方法参数/方法返回值

字段/方法参数/方法返回值 是dynamic类型, 编译器会将dynamic转成System.Object.

C#编译器会将该类型转换为System.Object,并在元数据中向字段/参数/返回类型应用System.Runtime.CompilerServices.DynamicAttribute的实例.

如果指定局部变量被指定为dynamic, 则变量类型也会成为Object,局部变量不会应用DynamicAttribute,因为限制在方法内部使用.

由于dynamic其实就是Object,所以方法签名不能仅靠dynamic和Object来区分.

dynamic 泛型类(引用类型),结构(值类型),接口,委托,方法的泛型类型实参

泛型类(引用类型),结构(值类型),接口,委托,方法泛型类型实参也可以是dynamic,编译器将dynamic转为Object. 向必要的元数据应用DynamicAttribute.

泛型方法是已经编译好的,会将类型视为Object,编译器不在泛型代码中生成payload代码.所以不会执行动态调度.

重要提示:

  1. 对于CLR, dynamic与Object完全一致.

  2. 变量/表达式用dynamic会生成 payload代码,进行动态调度.

  3. 不能定义对dynamic进行扩展的扩展方法.

  4. 不能将lanmbda表达式匿名方法作为实参传给dynamic方法调用. 因为编译器推断不了类型.

  5. foreach或者using语句中的资源被指定了dynamic表达式, 编译器分别将表达式转型为非泛型System.IEnumerable接口或System.IDispose接口.

    • 转型成功,就是用表达式,代码正常运行.
    • 失败就抛出Microsoft.CSharp.RuntimeBinderException异常.

dynamic 具体用法

什么是动态化(dynamification)?

  • 在为COM对象生成可由”运行时”调用的包装(warpper)程序集是,COM方法中使用任何VARIANT实际都转换成dynamic.

    // 如果没有dynamic类型,就需要转型成Range类型,才能访问Value属性
    ((Range)execel.Cells[1,1]).Value = "Text";
    
    // 由于excel.Cells[1,1]是dynamic类型,所以不需要显示转型.
    execel.Cells[1,1].Value = "Text";
    

利用反射和dynamic的例子

Object target = "ABC";
Object arg    = "ff";

// 在目标上查找和希望的实参类型匹配的方法.
// 从目标target的String类型上查找 方法名MethodA,参数的类型String的方法信息
Type[]     argTypes = new Type[] {arg.GetType()};
MethodInfo method   = target.GetType().GetMethod("MethodA", argTypes);

// 在目标上调用方法,传递实参"ff"
Object[] arguments = new Object[] {arg};
Boolean  result    = Convert.ToBoolean(method.Invoke(target, arguments));


// 利用dynamic简化上述代码写法
dynamic target1 = "ABC";
dynamic arg1    = "ff";
result = target1.MethodA(arg1);

可以看到显著简化的语法.

payload代码

C#编译器会生成payload代码,在运行时根据对象的实际类型判断要持续什么操作.

这些payload代码使用了称为运行时绑定器(RuntimeBinder)的类.

C# 运行时绑定器(RuntimeBinder)的代码在Microsoft.CSharp.dll程序集中. 生成使用dynamic关键字就必须引用该程序集(默认的csc.rsp中已经引用了该程序集).

是这个程序集中的代码知道在运行时生成代码,在+操作符2个Int32执行加法,+操作符两个string时执行连接. 运行时绑定器(RuntimeBinder) 首先检查类型是否实现了IDynamicMetaObjectProvider接口. 如果是就调用接口的GetMetaObject方法, 返回的类型DynamicMetaObject的派生类型能处理对象的多有成员,方法和操作符绑定.

payload代码执行时,会在运行时生成动态代码; 这些代码进入驻留于内存的程序集,即”匿名寄宿的DynamicMethods程序集(Anonymously Housted DynamicMethods Assembly)”,作用是当特定的call site(发生调用处)使用具有相同运行时类型的动态参数发出大量调用时增强动态调度的性能.

C#内建的动态功能所产生的额外开销不容忽视

虽然动态功能能简化语法,但也要加载这些程序集以及额外的内存消耗,会对内存造成额外的影响.

  • Microsoft.CSharp.dll
  • System.dll
  • System.Core.dll
  • System.Dynamic.dll (如果使用dynamic与COM组件互操作)

什么时候使用dynamic

  1. 如果程序只是 一两个地方需要动态行为,传统(反射)做法或许更高效. 托管对象则调用反射方法,COM对象则进行手动类型转换.
  2. 如果在动态表达式中使用的对公对象未实现IDynamicMetaObjectProvider接口,C#编译器会将对象视为C#定义的普通类型的实例,利用反射在对象上执行操作.

dynamic的限制

dynamic的一个限制是只能访问对象的实例成员,因为dynamic变量必须引用对象.但有时需要动态调用运行时才能确定一个类型的静态成员.

实现动态调用类型的静态成员方法

作者实现的StaticMemberDynamicWrapperDynamicObject派生,实现了接口IDynamicMetaObjectProvider.

        /// <summary>
        /// 构造一个 'dynamic' 的实例派生类,来动态调用类型的静态成员
        /// </summary>
        internal sealed class StaticMemberDynamicWrapper : DynamicObject //P(132)
        {
            private readonly TypeInfo m_type;
            public StaticMemberDynamicWrapper(Type type) { m_type = type.GetTypeInfo(); }

            public override IEnumerable<String> GetDynamicMemberNames()
            {
                return m_type.DeclaredMembers.Select(mi => mi.Name);
            }

            public override bool TryGetMember(GetMemberBinder binder, out object result)
            {
                result = null;
                var field = FindField(binder.Name);
                if (field != null) { result = field.GetValue(null); return true; }

                var prop = FindProperty(binder.Name, true);
                if (prop != null) { result = prop.GetValue(null, null); return true; }
                return false;
            }

            public override bool TrySetMember(SetMemberBinder binder, object value)
            {
                var field = FindField(binder.Name);
                if (field != null) { field.SetValue(null, value); return true; }

                var prop = FindProperty(binder.Name, false);
                if (prop != null) { prop.SetValue(null, value, null); return true; }
                return false;
            }

            public override Boolean TryInvokeMember(InvokeMemberBinder binder, Object[] args, out Object result)
            {
                MethodInfo method = FindMethod(binder.Name, args.Select(a => a.GetType()).ToArray());
                if (method == null) { result = null; return false; }
                result = method.Invoke(null, args);
                return true;
            }

            private MethodInfo FindMethod(String name, Type[] paramTypes)
            {
                return m_type.DeclaredMethods.FirstOrDefault(mi => mi.IsPublic && mi.IsStatic && mi.Name == name && ParametersMatch(mi.GetParameters(), paramTypes));
            }

            private Boolean ParametersMatch(ParameterInfo[] parameters, Type[] paramTypes)
            {
                if (parameters.Length != paramTypes.Length) return false;
                for (Int32 i = 0; i < parameters.Length; i++)
                    if (parameters[i].ParameterType != paramTypes[i]) return false;
                return true;
            }

            private FieldInfo FindField(String name)
            {
                return m_type.DeclaredFields.FirstOrDefault(fi => fi.IsPublic && fi.IsStatic && fi.Name == name);
            }

            private PropertyInfo FindProperty(String name, Boolean get)
            {
                if (get)
                    return m_type.DeclaredProperties.FirstOrDefault(
                       pi => pi.Name == name && pi.GetMethod != null &&
                       pi.GetMethod.IsPublic && pi.GetMethod.IsStatic);

                return m_type.DeclaredProperties.FirstOrDefault(
                   pi => pi.Name == name && pi.SetMethod != null &&
                      pi.SetMethod.IsPublic && pi.SetMethod.IsStatic);
            }
        }

为了调用静态成员,传递想要操作的Type来构建上述类的实例, 将引用放到dynamic变量中, 再用实例成员语法调用所需静态成员.

dynamic stringType = new StaticMemberDynamicWrapper(typeof(String));
var r = stringType.Concat("A","B");// 动态调用String 的静态Concat方法
Console.WriteLine(r); // 显示AB
Licensed under CC BY-NC-SA 4.0
0