返回

类型基础

所有类型都从System.Object派生

运行时要求每个类型都从System.Object派生。所以每个类型的对象都保证了一组最基本的方法。

//隐式派生自Object
class Employee
  ...

//显式派生自Object
class Employee: System.Object{
  ...
}

System.Object的公共方法Equals

公共方法 说明
Equals 如果两个对象具有相同的值就返回true

对象相等性和同一性

开发人员经常写代码比较对象。例如,有时要将对象放到集合,写代码对集合中的对象排序、搜索或比较。

同一性(引用相同对象)

System.Object类型提供了名为Equals的虚方法,作用实在两个对象包含相同值的前提下返回trueObjectEquals方法像是下面这样实现的:

// 此虚方法实现的是同一性,而非相等性.
// 因为obj引用的对象如果不是引用相同对象,那就无法比较值是否相等.
public virtual Boolean Equals(Object obj)
{
  // 如果两个引用指向同一个对象,它们肯定包含相同的值
  if (this == obj) return true;
  // 假定对象包含不同的值
  return false;
}

由于类型可以重写Equals方法,所以它不能再用来测试同一性

静态方法 说明
ReferenceEquals 检查两个引用是否指向同一对象

检查同一性务必调用ReferenceEquals,不应该使用C#的== 操作符 (除非都转成Object), 因为某个操作数可能重载了==操作符.

// 上述方法是不合理的,因此Microsft提供了检查同一性的方法
public static bool ReferenceEquals (Object objA, Object objB) {
    return objA == objB;
}

相等性(值类型相等)

值类型的基类ValueType重写了Equals方法,并进行了正确的实现来执行值的相等性检测(而非同一性)。

public override bool Equals(object obj)
{
  // 1.判断实参obj是否为null
  if (obj == null)
    return false;
  RuntimeType type = (RuntimeType) this.GetType();
  // 2.this和obj实参引用不同类型的对象,返回false
  if ((RuntimeType) obj.GetType() != type)
    return false;
  object a = (object) this;

  // 3. 如果对象的成員中存在对于堆上的引用,那么返回false,
  // 如果不存在,返回true。例如按照ValPoint的定义,它仅包含一个int类型的字段x,自然不存在对堆上其他对象的引用,所以返回了true
  if (ValueType.CanCompareBits((object) this))
    return ValueType.FastEqualsCheck(a, obj);

  // 4.利用反射获取值的所有字段
  FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
  for (int index = 0; index < fields.Length; ++index)
  {
    object obj1 = ((RtFieldInfo) fields[index]).UnsafeGetValue(a);
    object obj2 = ((RtFieldInfo) fields[index]).UnsafeGetValue(obj);
    // 5. 判断是否为null
    if (obj1 == null)
    {
      if (obj2 != null)
        return false;
    }
    // 6. 通过调用字段的Equals方法进行比较
    else if (!obj1.Equals(obj2))
      return false;
  }
  return true;
}

由于CLR反射机制慢,定义自己的值类型时应重写Equals方法来提供自己的实现.从而提高用自己类型的实例进行值相等性比较的性能. 当然,自己的实现不调用 base.Equals.

重写Equals方法

定义自己的类型时,你重写的Equals要符合相等性的四个特征

  1. Equals 必须自反: x.Equals(x)肯定返回true
  2. Equals 必须对称: x.Equals(y)和y.Equals(x)返回相同
  3. Equals 必须可传递: x.Equals(y)返回true, y.Equals(z)返回true,那么 x.Equals(z)肯定是true
  4. Equals 必须一致:比较的两个值不变,返回值也不能变.

重写Equals方法还需要做的事

  1. 让类型实现System.IEquatable<T>接口的Equals方法

    这个泛型接口允许定义类型安全的Equals方法bool Equals(T other);

  2. 重载==和!=操作符方法( 这些方法内部调用了类型安全的Equals方法. )

  3. 如果需要排序,类型还应该实现System.IComparableCompareTo方法和泛型接口System.IComparable的类型安全的CompareTo方法.

  4. 重载比较操作符方法<,<=,>,>= ( 这些方法内部调用了类型安全的CompareTo方法.)

System.Object的公共方法GetHashCode

公共方法 说明
GetHashCode 返回对象值的哈希码.如果某个类型的对象要在哈希表集合(比如Dictionary)中作为键使用,类型应重写该方法.

对象哈希码

FCL的设计者认为,如果能将任何对象的任何实例放到哈希集合中,能带来很多好处。为此System.Object提供了虚方法GetHashCode,它能获取任意对象的Int32哈希码.

如果你定义的类型重写了Equals方法,还应该重写GetHashCode方法. 这是因为System.Collections.Hashtable类型,System.Collections.Generic.Dictionary类型以及一些其他的集合中,要求两个对象必须具有相同哈希码才能被视为相等. 确保相等性算法和对象哈希码算法一致.

  • 添加
    • 向集合添加键/值对,首先要获取对象的哈希码, 该哈希码指出这个键/值对要存储到哪个哈希桶bucket中.
  • 查找
    • 集合需要查找时候, 会获取对象的哈希码, 此哈希码标识了现在要以顺序的方式搜索的哈希桶. 在这个哈希桶中查找哈希码相同的对象,以及对应的对象.
  • 修改
    • 错误的方式: 直接修改集合中的对象,修改后的键对象的哈希码与原来不同,就会去搜索错误的哈希桶,找不到对应的值对象.
    • 正确做法: 从集合中移除原来的键/值对, 修改键对象, 重新将新的键值对添加回哈希表.

自己实现哈希算法

自定义GetHashCode方法,取决于数据类型和数据分布情况, 需要设计出能返回良好分布值的哈希算法.

要遵循的规则:

  1. 算法要提供良好的随机分布,使哈希表获得最佳性能.
  2. 一般不要调用Object或ValueType的GetHashCode方法, 因为两者的实现与高性能哈希算法不沾边.
  3. 算法至少使用一个实例字段.
  4. 理想情况下, 算法使用的字段值应该不可变,也就是说,字段在对象构造时初始化,在对象的生存期永不言变.
  5. 算法执行速度尽量快.
  6. 包含相同值的不同对象应返回相同的哈希码.

System.Object实现的GetHashCode方法对派生类型和其中的字段一无所知,所以返回一个在对象生存期保证不变的编号.

最好不要将哈希码持久化, 因为生成哈希码的算法可能会发生改变. 例如:CLR版本升级后,String的GetHashCode方法发生了改变. 之前如果存储的是string的哈希码,则导致全部不对应了.

System.Object的公共方法ToString

公共方法 说明
ToString 默认返回类型的完整名称this.GetType().FullName 。但经常用于调试的目的重写该方法,返回一些值的字符串表示。

System.Object的公共方法GetType

公共方法 说明
GetType 返回从Type派生的一个类型的实例,指出调用GetType的对象是什么类型.

GetType是非虚方法.

目的是不允许重写. 防止类重写该方法,隐瞒其类型,进而破坏类型安全性.(用new关键字告诉编译器定义一个新方法new public void F())

返回的Type对象可以和反射类配合,获取与对象的类型有关的元数据信息.

System.Object的受保护方法MemberwiseClone

受保护方法 说明
MemberwiseClone 就是创建一个浅表副本的新对象,然后将当前对象的非静态字段复制到该新对象.

MemberwiseClone 方法创建一个浅表副本。如果字段是值类型的,则对该字段执行逐位复制。如果字段是引用类型,则复制引用但不复制引用的对象;因此,原始对象及其复本引用同一对象。

public DemoClass Clone1() //浅CLONE
{
    return this.MemberwiseClone() as DemoClass;
}

public DemoClass Clone2() //深clone
{
    MemoryStream stream = new MemoryStream();
    BinaryFormatter formatter = new BinaryFormatter();
    formatter.Serialize(stream, this);
    stream.Position = 0;
    return formatter.Deserialize(stream) as DemoClass;
}

什么是浅表副本?

  • 浅度拷贝(浅表副本)

    • 一个集合的浅度拷贝意味着只拷贝集合中的元素,不管他们是引用类型或者是值类型,但是它不拷贝引用所指的对象。这就是说新集合中的引用和原始集合中的引用所指的对象是同一个对象
  • 深度拷贝(深表副本)

    • 深度拷贝不仅拷贝集合中的元素,而且还拷贝了这些元素直接或者间接引用的所有内容。这也就意味着,新集合中的引用和原始集合中的引用所指的对象是不同的.

深度复制原理

为了实现深度复制,我们就必须遍历有相互引用的对象构成的图,并需要处理其中的循环引用结构。这无疑是十分复杂的。幸好借助.Net的序列化和反序列化机制,可以十分简单的深度Clone一个对象。原理很简单,首先将对象序列化到内存流中,此时对象和对象引用的所用对象的状态都被保存到内存中。.Net的序列化机制会自动处理循环引用的情况。然后将内存流中的状态信息反序列化到一个新的对象中。这样一个对象的深度复制就完成了。在原型设计模式中CLONE技术非常关键。

深拷贝的帮助类

public static class ObjectCopier
{
    /// <summary>
    /// Perform a deep Copy of the object.
    /// </summary>
    /// <typeparam name="T">The type of object being copied.</typeparam>
    /// <param name="source">The object instance to copy.</param>
    /// <returns>The copied object.</returns>
    public static T Clone<T>(T source)
    {
        // 判断泛型T是否能序列化
        if (!typeof(T).IsSerializable)
        {
            throw new ArgumentException("The type must be serializable.", "source");
        }

        // Don't serialize a null object, simply return the default for that object
        // 不能序列化一个null对象
        if (Object.ReferenceEquals(source, null))
        {
            return default(T);
        }

        // 流操作
        IFormatter formatter = new BinaryFormatter();
        Stream stream = new MemoryStream();
        using (stream)
        {
            formatter.Serialize(stream, source);
            stream.Seek(0, SeekOrigin.Begin);
            return (T)formatter.Deserialize(stream);
        }
    }
}

System.Object的受保护方法Finalize

受保护方法 说明
Finalize 在垃圾回收器判断此对象作为垃圾被回收之后,在对象的内存被实际回收之前,调用此虚方法.

需要在回收之前执行清理工作的类型应重写此方法.

所有对象都要用new操作符

实例字段(实例成员)是非静态字段——属于类的对象 静态成员——属于类

new 操作符所做的事情

  1. 计算类型及其所有基类(一直到System.Object)中定义的所有实例字段需要的字节数. (计算需要的字节数 )

    • 上的每个对象都需要有开销成员overhead,包括 类型对象指针(type object pointer)同步块索引sync block index.
    • CLR利用这些开销成员管理对象. 开销成员的字节数要计入对象的大小.
  2. 托管堆中分配类型要求的字节数, 从而分配对象的内存, 分配的所有字节都设置为零(0).

  3. 初始化对象的开销成员:类型对象指针(type object pointer)同步块索引sync block index.

  4. 调用类型的实例化构造器,传递在new调用中指定的实参

    • 每个类型的构造器都负责初始化该类型定义的实例字段. 最终调用System.Object的构造器,该构造器什么都不做,简单地返回.
  5. 返回新建对象的一个引用(或指针).

没有new对应的delete操作符用来显示释放为对象分配的内存, CLR采用垃圾回收机制.

类型转换及类型安全性

CLR最重要的特性就是类型安全. 在运行时,CLR总是知道对象的类型是什么. 调用GetType方法即可知道对象的确切类型. 由于它是非虚方法,所以不可能伪装成别的类型.

CLR运行将对象转换为它的(实际)类型或者它的任何基类型.

  • 对象类型 –转换成–>该对象的基类型

    • C#中, 向基类型的转换是一种安全的隐式转换 .
  • 而将对象类型–转换成–>该对象的某个派生类型

    • C#要求开发人员只能进行 显示转换(强制类型转换) .
// Employee的基类是Object
// 不需要转换, 派生类对象转基类对象是类型安全的隐式转换
Object o = new Employee();

// Employee是Object的派生类,需要进行转型
// 必须用强制类型转换
Employee e = (Employee)o;

为了方便记忆,打个比方

  • 公交汽车(派生类) 可以认为是 汽车(基类) , 因为包含汽车(基类)都存在的东西.
  • 但是, 汽车(基类) 并不只有公交汽车(派生类),还有其他类型的汽车.

这样才能让编译器顺利编译这些代码.

运行时做的事:

  1. CLR检查转型操作, 确保总是转换为对象的实际类型或者它的任何基类.
// 在运行时, CLR会检查转换,判断o的实际类型是否是Employee类型或者它的派生类.
Employee e = (Employee) o;

( A ) >= A/A的派生类.

因此,类型安全是CLR及其重要的一个特点.

class A{..}

class B:A{..}

Main()
{
  B b = new B();
  test(b);

  DateTime t = new DateTime(...);
  // 编译期 t的基类也是object,能通过编译
  test(t);
}

static test(Object o)
{
  // 运行期会CLR会进行类型检查,
  // 传进来的参数t类型基类不是A,也不是A的派生类,
  // 会报System.InvalidCastException异常
  A a = (A)o;
}

给方法合适的参数能在编译期就能发现错误,而非运行期报错.例如改为test(A a){}而不是Object参数类型.

使用C#的is和as操作符来转型

相较于is操作符,使用as操作符来简化写法和提升性能.

相较于之前的()强转语法,C#还有另外一方式进行类型转换. 是使用is操作符,区别如下

  1. 返回Boolean值true或false;
  2. 并且永远不会抛出异常.
  3. 如果对象引用null,is操作符总是返回false.

通常使用方法:

// 这种用法有个缺点,
// CLR实际检查两次对象类型.
// 1. is操作符首先核实o是否兼容于A类型,
// 2. 如果是,在if内部转型时,CLR再次核实o是否引用一个A类型.
// 这样对性能造成了一定的影响
if(o is A)
{
  A a = (A)o;
  // 在if剩余语句使用a
}

这种写法对性能造成影响,是因为CLR必须遍历继承层次结构,用每个基类型去核对指定的类型(上述例子中的A类型).

C#专门提供了as操作符,目的就是简化这种写法,同时提升性能.

  1. as操作符返回对同一个对象的非null引用.
  2. 工作方式与强制转换一样,并且不会抛出异常.
  3. 如果对象不能转型,则返回null.
  4. as操作符造成CLR值校验一次对象类型
// as操作符造成CLR值校验一次对象类型
// 从而提高性能
A a = o as A;
if (a != null)
{
  // 在if语句中使用a
}

编译时错误 Complier Time Error 运行时错误Run Time Error

class Program
{
    static void Main(string[] args)
    {
        // 向基类型的转换是一种安全的隐式转换
        Base b2 = new Dervied();

        // CTE 编译时错误

        // Base派生自object,不能由new基类创建子类. new子类可以创建基类
        // Base b3 = new Object(); // CTE
        Object o2 = new Base();

        // Dervied d3 = new Object(); // CTE
        Object o3 = new Dervied();

        // new Dervied()隐式转为Base类. b2要转成原本的Dervied类型需要显示转换
        // Dervied d3 = b2; // Base b2 = new Dervied();  // CTE
        Dervied d3 = (Dervied)b2;

        // RTE 运行时错误

        // 不能由new基类转换子类,CLR会在运行期检查类型,判断 (Dervied) >= new Base()
        Dervied d6 = (Dervied) new Base(); // RTE
        Base b5 = (Base) new Object(); //RTE
    }
}

class Base{}
class Dervied : Base{}

注意: C#允许类型定义转换操作符方法, 只有在使用转型表达式时才调用这些方法,使用C# as/is操作符永远不会调用它们.

命名空间和程序集

  1. 命名空间和程序集不一定相关, 同一个命名空间中的类型可能在不同程序集中实现.同一程序集也可能包含不同命名空间中的类型.

  2. 使用命名空间用using指令

    • 引用类库,标记命名, 少写代码.
    • 为类型和命名空间创建别名, 消除歧义.
    using Microsoft; // 可以少写Microsoft.前缀
    using Wintellect;// 可以少写Wintellect.前缀
    
    using WintellectWidget = Wintellect.Widget; //
    
    public class Program
    {
      // 这样写会有歧义,不明确引用. 两个命名空间中都包含Widget类
      // Widget w = new Widget();
    
      // 消除了歧义, 需要多打一点字
      Wintellect.Widget w = new Wintellect.Widget();
    
      // 使用using别名方式
      WintellectWidget w = new WintellectWidget();
    }
    
    
    
  3. 外部别名extren alias用于更精细的消除歧义

    • 公司Axxxx Bxxxx Cxxxxx和Ayyyy Byyyy Cyyyyy公司都发布一个BuyProduct类型
  • 如果他们都用ABC作为命名空间, 那同时引用这2个公司的dll就会出现一个问题
    • ABC.BuyProduct方法会报不明确引用.

为此,为了降低冲突发生的概率,应该使用全称来作为自己的顶级命名空间名称.

运行时的相互关系

  • C#中编译期间就分配好的内存空间,因此你的代码中必须就栈的大小有明确的定义;
  • 程序运行期间动态分配的内存空间,你可以根据程序的运行情况确定要分配的堆内存的大小.

创建线程栈

已加载CLR的一个Windows进程,进程中可能有多个线程。

线程创建时会分到1MB的栈。

  • 栈空间用于向方法传递实参
  • 方法内部定义的局部变量也在栈上。
  • 栈从高位内存地址向低位地址构建。

开始调用一个方法M1

在开始调用之前,

  • 序幕(prologue)代码对其进行初始化.

在方法做完工作后

  • 尾声(epilogue)代码对其进行清理,以便返回至调用者.

  1. 假定线程执行的代码要调用M1方法
  2. M1方法开始执行时,它的序幕代码在线程栈上分配局部变量name的内存,如4-3图示.

  1. 然后M1调用M2方法, 将局部变量name作为实参传递,将这个实参也压入栈,并且将返回地址压栈.

    • 返回地址:被调用的方法在结束之后应该回至该位置.如4-4图示.

  1. M2方法开始执行时, 它的序幕代码在线程栈上分配局部变量length和tally的内存. 如4-5所示.

  1. M2方法内部开始执行,最终达到return语句,CPU的指令指针被设置成返回地址. M2的栈帧展开(unwind).恢复成4-3所示.

栈帧展开(unwind) : 这个翻译来源自生活,把线缠到线圈上称为wind,从线圈上搜开称为unwind.同样的调用方法时压入栈帧称为wind,方法执行完毕弹出栈帧称为unwind.

  1. 最终M1会返回到它的调用者. 这同样通过将CPU的指令指针设置成返回地址来实现.

围绕CLR来观察,来演示CLR如何工作的

internal class Employee {
    public               int32         GetYearsEmployed()       { ... }
    public    virtual    String        GenProgressReport()      { ... }
    public    static     Employee      Lookup(String name)      { ... }    
}
internal sealed class Manager : Employee {
    public    override   String         GenProgressReport()    { ... }
}
  1. Window进程已经启动,CLR已经加载到其中,托管堆已经初始化,而且创建了一个线程(连同它的1MB栈空间).

  2. 准备要调用M3方法.

  3. JIT编译器将M3的IL代码转换成本机CPU指令时, CLR需要确认定义了这些类的类型都已加载.

    Employee,Int32,Manager,以及String(因为存在一个”Joe”的字符串).

  4. 然后利用程序集的元数据,CLR提取与这些类型有关的信息. 并创建一些数据结构来表示类型本身.

    图4-7展示了EmployeeManager类型对象使用的数据结构.

    至于Int32,String数据结构可以认为之前已经定义好了.因为它们都是很常用的类型.所以图中没显示它们.

    堆上所有对象都包含两个额外成员:

    • 类型对象指针(type object pointer)
    • 同步索引块(sync block index)
    • 静态数据字段.
    • 方法表: 定义的所有方法都有一个对应的记录项.
  5. 当CLR确认方法需要的所有类型对象都已创建,M3的代码编译之后,就允许线程执行M3的本机代码.

    • M3的序幕代码执行时必须在线程栈中为局部变量分配内存.

    • 在调用类型构造器之前,CLR会先初始化同步块索引,将对象的所有实例字段设为null或者0.

    • CLR自动将所有局部变量初始化为null或者0.

    • Manager只定义了1个方法(GetProgressReport的重写)

  6. 任何时候在堆上新建对象,CLR都自动初始化内部的类型对象指针成员来引用和对象对应的类型对象.

  7. new 操作符返回Manager对象的内存地址. 该地址保存到变量e中,(e在线程栈上).

  8. M3下一行代码调用Employee的静态方法Lookup.

    • CLR定位类型对象
    • JIT编译器查找类型对象的方法表中对应的记录项, 对方法进行JIT(如果需要的话).
    • 再调用JIT编译好的代码.
  9. 假定静态方法Lookup会从数据库找出一名经理Joe.

    • 在方法内会在堆上构造一个新的Manager对象, 用Joe的对象初始化它.返回该对象的地址.
    • 该地址保存到变量e中
    • 这里e不再引用第一个Manager对象, 第一个对象会被垃圾回收.
  10. M3的下一行代码调用Employee的非虚实例方法GetYearsEmployed

    • JIT编译器会找到 发出调用的那个变量(e)的类型 对应的类型对象(Employee).

    • 此时e的类型定义为Employee类型

    • 如果此类型中没有定义被调用的方法

    • JIT编译器会回溯类层次结构(一直到Object),并沿途的每个类型中查找该方法.

      之所以能回溯,是因为每个类型对象都有一个字段引用了它的基类型.

  1. JIT编译器找到了被调用方法的记录项, 进行JIT编译,再调用JIT编译好的代码,将返回的数据放到临时变量中保存.
  2. M3的下一行代码调用了Employee的虚实例方法(虚方法,重写过的)GetProgressReport.
    • 调用虚方法时,JIT编译器要在方法中生成一些额外的代码.
    • 方法每次调用都会执行这些代码, 这些代码首先
    • 检查发出调用的变量, 并跟随地址来到发出调用的对象
    • 变量e当前引用的是代表”Joe”的Manager对象.
    • 代码检查对象内部的类型对象指针成员, 该成员指向对象的实际类型.
    • 代码在类型对象的方法表中查找对应调用方法的记录项,对方法进行JIT编译
    • 再调用编译好的代码.
    • 由于目前e引用的是一个Manager对象,所以会调用Manager的GetProgressReport实现

注意: 如果Employee对象的Lookup方法发现”Joe”是Employee而不是Manager,则Lookup会在内部构造一个Employee对象,它的类型对象指针将引用Employee类型, 最终执行的则是Employee的GetProgressReport实现,而不是Manager的.

CLR内部发生的事情

Employee和Manager类型对象都包含”类型对象指针”成员. 这是由于类型对象本质上也是对象.

CLR创建类型对象时, 必须初始化这些成员.

初始化什么呢?

  1. CLR开始在一个进程中运行时, 会立即为MSCorLib.dll中定义的System.Type类型创建一个特殊的类型对象.

  2. Employee和Manager类型对象都是该类型的实例

  3. System.Object的GetType方法返回存储在指定对象的类型对象指针成员中的地址.

    • 也就是说,GetType方法返回指向对象的类型对象的指针.
    • 这样就可以判断系统中任何对象的真实类型.

Licensed under CC BY-NC-SA 4.0
0