类型的各种成员

  • 常量
    • 是指出数据值恒定不变的符号, 总与类型关联,不与类型实例关联. 逻辑上总是静态成员.
  • 字段
    • 表示只读或可读/可写的数据值.
    • 字段可以是静态的,这种被认为是类型状态的一部分.
    • 字段也可以是实例非静态,这种被认为是对象状态的一部分.
  • 实例构造器
    • 新对象的实例字段初始化的特殊方法.
  • 类型构造器
    • 类型的静态字段初始化的特殊方法.
  • 方法
    • 是更改或查询类型或对象状态的函数.
    • 作用于类型称为静态方法.
    • 作用于对象称为实例方法.
  • 操作符重载
    • 实际上是方法.定义了操作符用于对象时应该如何操作该对象. 不是所有编程语言都支持,所以不属于CLS.
  • 转换操作符
    • 定义如何隐式或显示将对象从一种类型转换为另一种类型的方法.不是所有编程语言都支持,所以不属于CLS.
  • 属性
    • 允许用简单的字段风格的语法设置或查询类型或对象的逻辑状态,同时保证状态不被破坏.
    • 作用于类型的称为静态属性.
    • 作用于对象的称为实例属性.
    • 属性可以无参(非常普遍)
    • 多个参数(集合类用得多)
  • 事件
    • 静态事件允许类型向一个或多个静态或实例方法发送通知.
    • 非静态(实例)事件允许对象向一个或多个静态或实例方法发送通知.
    • 事件包含两个方法:允许静态或实例方法登记注销对该事件的关注.
    • 通常还用一个委托字段来维护已登记的方法集
  • 类型

    • 可以定义其他嵌套类型 (内/外部类).

    元数据是整个Microsoft .Net Framework开发的关键 .元数据实现了编程语言,类型和对象的无缝集成.

源代码中定义的所有成员都造成编译器生成元数据.有些数据还造成编译器生成额外的成员和额外的元数据.

   public sealed class SomeType                              // 1
   {
       // 嵌套类
       private class SomeNestedType { }                      // 2

       // 常量
       private const Int32 c_SomeConstant = 1;               // 3
       // 只读
       private readonly String m_SomeReadOnlyField = "2";    //4
       // 静态可读/可写字段
       private static Int32 s_SomeReadWriteField = 3;        // 5

       // 类型构造器
       static SomeType(){}                                    // 6

       // 实例构造器
       public SomeType(Int32 x){}                             // 7
       public SomeType(){}                                    // 8

       // 实例方法和静态方法
       private String InstanceMethod(){ return null; }        // 9
       public static void Main(){}                            // 10

       // 实例属性
       public Int32 SomeProp                                  // 11
       {
           get { return 0;}                                   // 12
           set {  }                                           // 13
       }

       // 实例有参属性
       public Int32 this[String s]                            // 14
       {
           get { return 0; }                                  // 15
           set { }                                            // 16
       }

       // 实例事件
       public event EventHandler SomeEvent;                   // 17

例如: 事件成员(17)造成编译器生成

  • 一个字段
  • 两个方法
  • 事件(一些额外的元数据)

类型的可见性

  • public 类型:不仅对定义程序集中的所有代码可见,还对其他程序集中的代码可见.
  • internal 类型: 仅对定义程序集中的所有代码可见. 对其他程序集代码不可见.

定义类型时如果不显示指定可见性,C#编译器会指定为internal.

什么情况下要友元程序集?

假设下述情形:
一个公司有A团队和B团队, A团队定义了一组工具类,并希望团队使用这些类型. 但是他们不能将所有类型都生成到一个程序集中,而都要生成自己的程序集.

为了使B团队使用A的工具类,A团队必须将工具类定义为public. 这样意味着工具类对所有程序集公开,连C团队也能使用. 这不是A团队所希望的.

为了做到用internal定义工具类,同时B团队也能访问,C#可以通过友元程序集功能实现.

示例做法

  1. 生成程序集时,用System.Runtime.CompilerServices命名空间下的InternalsVisibleTo特性,标明它认为是友元的其他程序集.
  2. 传入标识 友元程序集名称公钥的字符串参数(不能包含版本,语言文化和处理器架构)
  3. 之后被标注的程序集能访问该程序集的 所有internal类型,以及 这些类型的internal成员.
using System.Runtime.CompilerServices;

// 这里指定Wintellect和Microsoft是友元程序集
[Assembly:InternalsVisibleTo("Wintellect, Publickey=12314..sbasd")]
[Assembly:InternalsVisibleTo("Microsoft, Publickey=asdas...basd")]

internal sealed class SomeInternalType{...}
internal sealed class AnotherSomeInternalType{...}

C#编译器在编译友元程序集(不含InternalsVisibleTo特性的程序集)要求使用/out:开关, 因为编译器要知道准备编译的程序集的名称,从而判断生成的程序集是不是友元程序集.(意思是编译Wintellect和Microsoft程序集时,在代码编译结束前,C#编译器是不知道输出文件名的,因此使用/out:能极大增强编译性能.)

使用/t:module开关来编译模块,而且该模块还是友元程序集的一部分,那就需要C#编译器使用/moduleassemblyname:开关来编译该模块.

成员的可访问性

每种编程语言都有自己的一组术语以及相应的语法. CLR自己定义了一组可访问性修饰符.

例如:

  • CLR使用Assembly表明成员对同一程序集内的所有代码可见.
  • C#对应的术语是 internal

表中总结了6个应用于成员的可访问性修饰符. 从第一行到最后一行按照限制最大到限制最小的顺序排列.

  • private:只能由定义类型任何嵌套类型中的方法访问
  • protected: 能被定义类型的方法,任何嵌套类型中的方法无论在什么程序集中派生类型中的方法访问.
  • internal: 只能被当前定义的程序集中的方法访问. (比public限制高,限制了程序集)
  • protected internal:能被任何内/外部类(嵌套类), 无论在什么程序集中的派生类型,当前程序集中的任何方法访问.
  • public: 能被任何程序集中的任何方法访问.

在C#中,如果没有显示声明成员的可访问性,编译器通常默认选择private.

CLR 要求接口类型的所有成员都具有public可访问性. C#编译器因此禁止开发人员显示指定接口成员的可访问性.自动设为public.

C#限制派生类重写基类方法需要具有相同的可访问性.

CLR允许放宽限制,但不允许收紧限制(基类protected,派生类可以public,但不可以是private). 否则就无法派生类转为基类时,获取不到基类方法的访问权.

静态类

例如Console,Math,Environment,ThreadPool类,永远不需要实例化的类,只有static成员.

static关键字定义不可以实例化的类. 只能应用于类,不能应用于结构(值类型). CLR总是允许值类型实例化.

C#编译器对静态类做了如下限制

  1. 静态类必须直接从基类System.Object派生. 继承只适用于对象,静态类不能创建实例.因此从其他任何积累下派生都没有意义.
  2. 静态类不能实现任何接口. 只有使用类的实例时,才能调用类的接口方法.
  3. 静态类只能定义静态成员(字段,方法,属性和事件).
  4. 静态类不能作为字段,方法参数或局部变量使用. 因为它们都代表引用实例的变量.

IL会将static定义类标记为absrtact和sealed. 并且不生成实例构造器方法.

分部类,结构和接口

partial 关键字告诉C#编译器: 类,结构或接口的定义源代码可能要分散到一个或多个源代码文件中.

  1. 源代码控制
    • 多个程序员修改一个类,使用partial为多个文件后,每个文件可以单独签出,多个程序员能同时编辑类型.
  2. 在同一个文件中将类或结构分解不同的逻辑单元
    • 有时会创建一个类型提供多个功能,为简化实现,会在源代码中重复声明一个分部类型,分部类型的每个部分都实现一个功能.也可以完整的删除一个功能.
  3. 代码拆分

要将partial关键字应用于所有文件中的类型. 编译到一起时, 编译器会合并代码. 分部类功能完全由C#编译器实现. CLR对此一无所知.

C#关键字及其对组件版本控制的影响

这些关键字直接对应CLR用于支持组件版本控制的功能.

CLR如何调用虚方法,属性和事件

属性和事件实际作为方法实现.

方法是什么?

方法代表类型或类型上的实例上执行某些操作的代码. 在类型上操作是静态方法,在实例上操作是非静态方法.

CLR允许定义多个同名方法,根据返回类型不同或者参数不同

除了IL语言,包括C#大多数语言在判断方法的唯一性时,除了方法名之外,都只以参数为准,方法返回类型会被忽略.

  • 非虚实例方法 public Int32 MethodA()
  • 虚方法public virtual String MethodB()
  • 静态方法public static Int32 MethodC()

    编译这些方法,编译器会在程序集的方法定义表中写入记录项,每个记录项都用一组flag标志指明方法的类型.

    调用这些方法时, 生成调用代码的编译器会检查方法定义的标志flag,来生成对应的IL代码来调用.

callvirt和call指令调用非虚方法,虚方法,静态方法

CLR提供两个方法调用指令:

  1. call
    • 用call指令调用静态方法,必须指定定义此静态方法的类型.
    • 用call指令调用实例方法或虚方法,必须指定引用了对象的变量(就是调用方法的变量的定义类型).
      • 如果调用方法的变量没有定义此方法,就会检查基类来查找匹配方法.
    • 常用于以非虚方式调用虚方法.
  2. callvirt
    • 用callvirt可调用实例方法和虚方法,不能调用静态方法.
    • 必须指定引用了对象的变量.
    • 用callvirt调用非虚方法,变量的类型指明了方法的定义类型.
    • 用callvirt调用虚实例方法,CLR会调查发出的调用对象的实际类型,然后以多态方式调用方法.
    • 为了确定类型,发出调用的变量不能为null. JIT编译器会生成代码来验证变量的值是不是null.(抛出NUllReferenceException异常)
    • 用callvirt调用非虚实例方法也会执行这种null检查. 所以用callvirt指令执行速度比call指令稍慢.
// 调用静态方法
Console.WriteLine();

Object o = new Object();
// 调用虚方法
o.GetHashCode();
// 调用非虚方法
o.GetType();
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // 代码大小       28 (0x1c)
  .maxstack  1
  .locals init (object V_0)
  IL_0000:  nop
  // 静态方法会使用call指令
  IL_0001:  call       void [System.Console]System.Console::WriteLine()
  IL_0006:  nop
  IL_0007:  newobj     instance void [System.Runtime]System.Object::.ctor()
  IL_000c:  stloc.0
  IL_000d:  ldloc.0
  // 这里用callvirt 是因为GetHashCode是虚方法
  IL_000e:  callvirt   instance int32 [System.Runtime]System.Object::GetHashCode()
  IL_0013:  pop
  IL_0014:  ldloc.0
  // 这里用callvirt , Type是非虚方法,在JIT编译好的代码中会以非虚方式调用
  // 为什么不用直接用call指令呢? 因为C#团队认为JIT编译器应生成代码来验证发出调用的对象不为null
  IL_0015:  callvirt   instance class [System.Runtime]System.Type [System.Runtime]System.Object::GetType()
  IL_001a:  pop
  IL_001b:  ret
} // end of method Program::Main

如果调用非虚方法时, C#编译器生成的是callvirt指令,那么发出调用的对象如果为null就会抛出异常.

C#用callvirt指令调用所有 实例方法. 用call指令调用 静态方法.

不建议把非虚方法改为虚方法. 这是因为某些编译器会用call而不是callvirt指令调用非虚方法. 并且引用此方法的代码没有重新编译,就会造成应用程序行为无法预料. 用C#写的引用代码不会出问题,因为C#用callvirt指令调用所有实例方法.

编译器有时用call而不是callvirt调用虚方法.

特殊的语句:base.ToString();

public override String ToString()
{
  // 编译器使用IL指令call
  // 以非虚方式调用Object的ToString方法

  // 如果编译器用callvirt而不是call
  // 那么该方法将递归调用自身,直至栈溢出
  return base.ToString();
}
// 如果编译器生成的是callvirt,那么是以虚方式(多态方式)调用, base.ToString还是会调用到重写的当前方法中
// 再次执行base.ToString导致循环
// call则是以非虚的方式调用,不会调用到重写的方法中,而是基类自己的ToString方法.

因为callvirt是以虚方式调用, 会导致base.ToString递归执行,直至线程栈溢出.

编译器调用值类型定义的方法时倾向于使用call指令,因为值类型时密封的,即使有虚方法也不用考虑多态性. 值类型的实例的本质保证它永不为null. 所以永远不抛出NullReferenceException异常.

如果以虚方式调用值类型的虚方法,CLR要获取对值类型的对象的引用,以便引用方法表.需要装箱.

call和callvirt的区别

call的callvirt的区别主要有两点:

  • call可以调用 静态方法实例方法和虚方法
  • call一般是以 非虚的方式 来调用函数的.

  • callvirt不能调用 静态方法,只能调用实例方法和虚方法

  • callvirt是以 已多态(虚方式)的方式 来调用函数的.

设计类型时要注意

  1. 虚方法的调用速度比调用非虚方法慢.
  2. JIT编译器不能内嵌虚方法. 进一步影响性能
  3. 使最复杂(参数最多)的方法成为虚方法,使所有重载的简便方法成为非虚方法.
private Int32 m_lenth = 0;

// 重载的简便方法是非虚的
public Int32 Find(Object value)
{
    return Find(value,0,m_lenth);
}

// 重载的简便方法是非虚的
public Int32 Find(Object value, Int32 startIndex)
{
    return Find(value,0,m_lenth -startIndex);
}

// 功能最丰富的方法是虚方法,可以被重写
public virtual Int32 Find(Object value, Int32 startIndex, Int32 endIndex)
{
    // 可被重写的实现写这里..
    //.....
}

合理使用类型的可见性和成员的可访问性

默认生成的类是非密封里, 允许使用关键字sealed将类型显示标记为密封.

  • 性能更好
    • 如果调用的是密封类的虚方法(C#编译器在生成callvirt指令), JIT会优化并生成代码用非虚方式调用.

调用虚方法在性能上不及调用非虚方法, 因为CLR必须在运行时查找对象的类型,判断要调用的方法由哪个类型定义.

如果JIT编译器看到使用的是密封类型的虚方法调用, 密封类不会有派生类,就可以采用非虚的方式调用虚方法(call指令),从而生成更高效的代码.

class Program
{
    static void Main(string[] args)
    {
        Point p = new Point(3,4);
        // C#编译器在此生成callvirt调用虚方法
        // 但是JIT编译器会优化这个调用,并生成
        // 代码来非虚地调用ToString,是因为Point是密封类
        Console.WriteLine(p.ToString());
    }
}
public sealed class Point
{
    private Int32 m_x, m_y;

    public Point(int mX, int mY)
    {
        m_x = mX;
        m_y = mY;
    }
    public override string ToString()
    {
        return String.Format($"{m_x},{m_y}");
    }
}

开发人员希望从现有类型派生出一个类,在其中添加额外字段或状态信息. 还希望在派生类中定义帮助方法(helper method)简便方法(convenience method)来操作这些额外的字段. 如果是密封类就不合适,可以利用C#的扩展方法来模拟帮助方法, 还可以利用ConditionalWeakTable类模拟跟踪状态.