代码改变世界

CLR via C# 边读边想 05 - 原生类型,值类型,引用类型

2012-06-27 15:00  richardzhaoxb  阅读(436)  评论(1编辑  收藏  举报

Programming Language Primitive Types

有一些类型经常被用到,以至于编译器可以允许你更加方便的使用一些简便语法操作它们。比如Int类型,按照一般的要求,应该这样来创建:

System.Int32 a = new System.Int32();

但是许多编译器(包括 CSC.exe)允许我们简化为:

int a = 0;

这样写不但使得代码可读性更好,而且产生的 IL 和上一种写法产生的 IL 是一致的。所以下面的几种写法,编译后产生的 IL 是一致的:

int a = 0; // Most convenient syntax
System.Int32 a = 0; // Convenient syntax
int a = new int(); // Inconvenient syntax
System.Int32 a = new System.Int32(); // Most inconvenient syntax

这类编译器直接支持的类型,我们称作原生类型(Primitive Type),原生类型都可以直接映射到 Framework Class Library(FCL)中的类。下表列出了原生类型和对应的 FCL 中的类。

你也可以把编译器对原生类型的支持,看做是使用了 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;
...

在 C# 的 language specification 中提到:“为了避免不同风格带来的麻烦,尽量使用关键字,而不要使用 FCL 类型名” 。但是作者不赞成这样,他宁愿编译器不支持这些原生类型的关键字,而迫使程序员都使用 FCL 的类型,这是他的理由:

  • 我看到很多开发人员总是混淆,搞不清楚应该使用 string 还是 String, 其实 关键字 string 就是映射到 FCL 中的 System.String,所以是没有区别的。我还听到这样一些说法,int 类型的变量在 32-bit OS上是32位的长度,在64-bit OS 上是64位的长度,这个说法是完全错误的。 关键字 int 映射的就是 FCL 中的 System.Int32, 和OS 没有任何关系。
  • 在 C# 中,关键字 long 映射到 System.Int64 ,然而在其他的语言中,long 会作为 32 位的整数。 那么具有其他语言背景的人看到 long 类型,可能就会造成误会。 其实在其他的语言中,从不会把 long 作为关键字。

按照前面一章中介绍的类型转换的要求,下面的代码应该会出错,因为 System.Int32 和 System.Int64 是不同的类型,也没有基类的关系:

Int32 i = 5; // A 32-bit value
Int64 l = i; // Implicit cast to a 64-bit value

但是 C# 编译器可以成功编译和运行,因为编译器非常清楚这些原生类型之间的关系,对于他们之间的转换会有特别的规则。 

首先,编译器支持如下的隐式和显示的装换:

Int32 i = 5; // Implicit cast from Int32 to Int32
Int64 l = i; // Implicit cast from Int32 to Int64
Single s = i; // Implicit cast from Int32 to Single
Byte b = (Byte) i; // Explicit cast from Int32 to Byte
Int16 v = (Int16) s; // Explicit cast from Single to Int16

隐式转换的原则是数据安全,也就是不能丢失数据。对于不安全的转换就要用显示的转换。

要特别注意的是,不同的编译器在不安全转换时得到的值可能是不同的,例如从 Single 转到 Int32, C#编译器是截掉小数部分,而有些编译器却是做四舍五入运算。

除了数据转换,原生类型还支持 literals 方式的书写,例如:

Console.WriteLine(123.ToString() + 456.ToString()); // "123456"

而且,如果是 带有 literals 方式写的表达式,编译器会在编译时就把计算出它的值,这样可以提高运行的效率。例如:

Boolean found = false; // Generated code sets found to 0
Int32 x = 100 + 20 + 3; // Generated code sets x to 123
String s = "a " + "bc"; // Generated code sets s to "a bc"

 

Checked and Unchecked Primitive Type Operations

大家都知道算术运算经常会导致溢出(overflow) 例如:

Byte b = 100;
b = (Byte) (b + 200); // b now contains 44 (or 2C in Hex).

其实上面的 b+200 的运算时,系统是把 200 当做一个 Int32 来做计算的,所以 b+200 本身没有溢出,但是由于做了一个显示的强制转换到 byte,就会导致溢出,数据丢失。

不同的语言对于 overflow 的处理各不相同,C 和 C++ 不会报错,只是会保留溢出有的值,而VB则会报错。 CLR 提供了 IL 指令让编译器来选择针对溢出的行为。 add 指令不会做溢出检查,而 add.ovf 则会抛出 System.OverflowException 异常。IL 也还有其他相似的指令针对减法(sub/sub.ovf)和乘法(mul/mul.ovf)等。 

C# 允许程序员来控制如何处理溢出,默认情况下溢出的检查是被关闭的。这样程序运行更快,但是就需要程序员自己来确保不会出现溢出。打开溢出检查的一个方法就是在编译时使用 /checked 选项,这个选项是的编出来的 IL 使用了 add.ovf 的指令。

除了可以控制全局的 check 开关,程序员还可以控制更小范围内的溢出检查开关。C# 提供了 checked 和 unchecked 操作符,看例子:

Byte b = 100;
b = checked((Byte) (b + 200)); // OverflowException is thrown
b = (Byte) checked(b + 200); // b contains 44; no OverflowException

checked 和 unchecked 操作符不但可以用于表达式,还可以用来标示语句块。例如:

checked { // Start of checked block
    Byte b = 100;
    b = (Byte) (b + 200); // This expression is checked for overflow.
} // End of checked block

要注意的是,System.Decimal 类型是一个特别的类型,虽然很多语言(C# 和 VB.NET)把 Decimal 当做一种原生数据类型,但是 CLR 并不这样认为。 而且针对 Decimal 算术运算要比其他的原生类型要慢很多,checked 和 unchecked 对 decimal 类型也不起作用。 对于不安全的运算,它总会抛出 OverflowException。

还有,System.Numerics.BigInteger 类型也是很特殊的类型,它内部使用了一个 Int32 的数组来代表一个无限大的整数,所以 它从来不会抛出 OverflowException,但是在内存不够时会抛出 OutOfMemoryException。

 

Reference Types and Value Types

CLR 支持两种类型:引用类型和值类型。在 FCL 中定义的大多数都是引用类型,而我们用的最多的是值类型。 

引用类型通常是被存放在托管堆(managed heap)中,引用类型要用 new 操作符来分配空间,new 操作符会返回这个新创建的对象的内存地址。当你使用引用类型时要清楚下面这些对程序性能的影响:

  • 内存空间必须从托管堆分配。
  • 每个被创建的对象都有一些成员需要被初始化。
  • 其他的字段(Field)都会被置0。
  • 生成一个引用类型的对象,就一定会导致后面GC做回收。

如果每个类型都是引用类型,那么我们的程序将会有很大的性能问题。想象一下如果每一次你使用一个 Int32 的值,都要引发上述的内存分配和回收过程,程序的性能会有多差。为了提升常用类型的性能,CLR 提供了更加轻量化的值类型。

值类型的变量是存放在线程堆栈(thread stack)中的,这个变量直接存储这个实例的值,而不是指针。所以也不需要GC来做回收。

.Net SDK 文档很清楚的标识了引用类型和值类型,任何叫做 class 的类型都是 引用类型,例如:System.Exception class、System.IO.FileStream class。而 structure 和 an enumeration 则都是值类型,例如:System.Int32 structure、System.Boolean structure、System.DayOfWeek enumeration。

如果你看的更加仔细一些,你会发现所有的 structure 都是继承自 System.ValueType。所有的 enumeration 都是继承自 System.Enum,而 System.Enum 又是继承自 System.ValueType。 CLR 对 enumeration 有特殊的处理,后面会有专门的章节介绍。

所有的值类型都是 sealed 的,避免被其他的引用类型来继承。

下面的代码演示了引用类型和值类型的不同:

 1 // Reference type (because of 'class')
 2 class SomeRef { public Int32 x; }
 3 // Value type (because of 'struct')
 4 struct SomeVal { public Int32 x; }
 5 
 6 static void ValueTypeDemo() {
 7     SomeRef r1 = new SomeRef(); // Allocated in heap
 8     SomeVal v1 = new SomeVal(); // Allocated on stack
 9     r1.x = 5; // Pointer dereference
10     v1.x = 5; // Changed on stack
11     Console.WriteLine(r1.x); // Displays "5"
12     Console.WriteLine(v1.x); // Also displays "5"
13     // The left side of Figure 5-2 reflects the situation
14     // after the lines above have executed.
15     SomeRef r2 = r1; // Copies reference (pointer) only
16     SomeVal v2 = v1; // Allocate on stack & copies members
17     r1.x = 8; // Changes r1.x and r2.x
18     v1.x = 9; // Changes v1.x, not v2.x
19     Console.WriteLine(r1.x); // Displays "8"
20     Console.WriteLine(r2.x); // Displays "8"
21     Console.WriteLine(v1.x); // Displays "9"
22     Console.WriteLine(v2.x); // Displays "5"
23     // The right side of Figure 5-2 reflects the situation
24     // after ALL of the lines above have executed.
25 }

值得注意的是,SomeVal v1 = new SomeVal(); 这个语句虽然用 new 操作符,看似好像是在 managed head 中分配内存,但是编译器知道 SomeVal 是值类型的,会把对象分配在 thread stack 中。上面这个语句也可以写成:SomeVal v1;  唯一不同的是,使用了 new 操作符时,值类型的内容被初始化了。下面的两个例子演示了使用 new 和不使用 new 的区别:

// These two lines compile because C# thinks that
// v1's fields have been initialized to 0.
SomeVal v1 = new SomeVal();
Int32 a = v1.x;
// These two lines don't compile because C# doesn't think that
// v1's fields have been initialized to 0.
SomeVal v1;
Int32 a = v1.x; // error CS0170: Use of possibly unassigned field 'x'

当你在设计类型时,要特别注意是选择引用类型和值类型,在有些情况下,值类型可以得到更好的性能。如果符合下面的情况,应该考虑使用值类型:

  • 像原始类型一样,没有提供其他的成员改变内部的字段的,符合这种设计的类我们称它为 immutable。
  • 这个类型不需要从其他任何类型继承。
  • 这个类型也不会有派生类。

除了考虑上面几点,还要考虑类型实例的大小,因为通过参数传递值时,值类型是值拷贝,相对于引用类型来说性能会差些。 所以还要参考下面的因素:

  • 实例大小比较小,<=16 byte。
  • 实例大小虽然大,> 16 byte,但是不会作为方法的参数和返回值。

引用类型和值类型还有以下的不同:

  • 值类型的对象有两种形式:boxed 和 unboxed, 后面会介绍, 而引用类型只有boxed的形式。
  • 值类型继承自 System.ValueType ,它重写了 System.Object 的 Equals 方法,使得比较两个实例的值。也重写了 GetHashCode 方法,使用实例的值来做哈希运算。如果你要自己定义值类型,也一定要重写这两个方法。
  • 因为不能用一个值类型作为另外一个值类型或引用类型的基类,所以你不应该给值类型定义如何的虚方法、抽象方法。值类型其实隐含的被注定了是 sealed 的。
  • 引用类型的变量中存放的是 managed heap 中实例对象的地址,当一个引用类型的变量被创建时,它被初始化为 null,表示这个变量还没有指向任何一个有效的实例对象。如果你访问一个 null 的引用变量,就会导致 NullReferenceException。相反,值类型的值被初始化为0,所有永远不会导致 NullReferenceException 的错误。 不过 CLR 也提供了一中标记让值类型可以为空,后面有专门的章节会介绍。
  • 值类型不是分配在 managed heap 的,所以当值类型的变量在堆栈跳出作用范围后它占用的空间马上就会被释放。而引用类型在被GC处理之前会被通知到(CLR 会调用他的 Finalize 方法)。虽然 CLR 允许值类型定义 Finalize 方法,但是对于 boxed 值类型的实例被 GC 回收时 CLR 不会调用它的 Finalize 方法。

 

Boxing and Unboxing Value Types

虽然之前介绍的值类型比引用类型有更好的性能,但是在很多情况下,你必须要一个引用变量指向一个值类型。 例如下面的代码:

// Declare a value type.
struct Point {
    public Int32 x, y;
}

public sealed class Program {
    public static void Main() {
        ArrayList a = new ArrayList();
        Point p; // Allocate a Point (not in the heap).
        for (Int32 i = 0; i < 10; i++) {
            p.x = p.y = i; // Initialize the members in the value type.
            a.Add(p); // Box the value type and add the
            // reference to the Arraylist.
        }
        ...
    }
}

上面ArrayList 的 Add 方法是需要一个引用类型的参数,它的定义是public virtual Int32 Add(Object value); 这样 Point 值类型必须被转换一个放在 managed head 中的对象,同时被一个引用变量所引用。

这种把值类型转换这引用类型的机制叫做 boxing。 在 CLR 内部,boxing 发生了如下事情:

  • 在 managed heap 上分配空间,大小就是值类型大小再加上两个额外的成员:类型对象指针 和 同步块索引(the type object pointer and the sync block index)。
  • 值类型的值被赋到 managed heap 中。
  • 对象实例的地址被返回。

编译器会自动产生 IL 代码,执行 boxing 的过程。

注意: FCL 现在提供了一套泛型的集合类,相对于非泛型的性能有了显著的提升。同时也支持值类型的集合。例如:用 System.Collections.Generic.List<T> 替换前面用到的 System.Collections.ArrayList 。这样就可以避免频繁的 boxing 和 unboxing,同时减少了 GC 的工作。

知道了 boxing,现在再来看 unboxing,下面的代码将取回第一个对象:

Point p = (Point) a[0];

这样就将一个在 managed heap 中值转换为一个在 thread stack 中的 p。

因为 unboxed 值类型没有 sync block index,所以你不可以用 System.Threading.Monitor 来进行多线程同步访问这个值。

就算 unboxed 值类型没有 type object pointer,你也可以调用object 方法 例如,Equals, GetHashCode, or ToString。

 

Object Equality and Identity

我们经常要比较两个对象是否相等,或比较大小关系。 Object 提供了一个虚方法名叫 Equals,他的默认实现是:

public class Object {
    public virtual Boolean Equals(Object obj) {
        // If both references point to the same object,
        // they must have the same value.
        if (this == obj) return true;
        // Assume that the objects do not have the same value.
        return false;
    }
}

虽然默认实现可以比较引用的地址是否一样,但是对于地址不一样,但是值一样的比较就不一定适合了。也就是默认的 Equals 实现,实现了对 identity 的识别,而不是 value equality 的实现。

Object也提供了一个专门的 identity 的识别方法:ReferenceEquals, 这个方法是静态的不允许重写。他的实现是:

public class Object {
    public static Boolean ReferenceEquals(Object objA, Object objB) {
        return (objA == objB);
    }
}

如果你想检查两个引用是否是否指向了同一个对象,就应该使用 ReferenceEquals 方法,而不是用 == 操作符(除非你先把它们都转换为Object),因为其中的一个类可能会重写 == 操作符的方法内容。

你可能也发现了,.Net 在 quality 和 identity 的check 方面给我们很大混淆。

  • 对于值类型 System.ValueType 重写了 Equals 方法,它的实现是:
  • 如果参数 obj 是 null,返回 false。
  • 如果参数 obj 和 this 是不同的 type,返回 false。
  • 比较每一个field的值,任何一个字段不相等,返回 false。
  • 返回 true。 System.ValueType 的 Equals 方法没有调用 Object 的 Equals 方法。

其中第3步,需要用到反射,因为反射的性能问题,所以如果你自己定义值类型,应该尽可能的重写 Equals 方法,想办法提高性能。在实现 Equals 方法时要,要注意一下几点:

  • Equals 方法是自反的(reflexive),也就是说 x.Equals(x) == true
  • Equals 方法是对称的(symmetric),也就是说如果 x.Equals(y) == y.Equals(x)
  • Equals 方法是可传递的(transitive),也就是说如果 x.Equals(y) == true,而且 y.Equals(z) == true, 者肯定 x.Equals(z) == true
  • Equals 方法是结果是持续的(consistent),也就是说如果两个变量没有被改变的情况下,在任何时候的比较结果都是不变的。

在实现自己的 Equals 方法时,下面两点也值得注意:

  • 实现 System.IEquatable<T> 这个 Interace 的 Equals 方法,它允许你定义一个类型安全的 Equals 方法,然后你自己定义的值类型的 Equals 方法会调用这个类型安全的 Equals 方法。
  • 重写 == 和 != 操作符,利用上面提到的类型安全的 Equals 方法来实现。 

而且,如果你的类还会被用来排序的话,你还要实现 System.IComparable 接口的 CompareTo 方法, 以及 System.IComparable<T> 的 CompareTo 方法。如果你实现了这两个方法,你可能还想重写几个比较的操作符:<, <=, >, >=。

 

Object Hash Codes

FCL的设计者觉得如果每个对象都可以放入哈希表集合(hash table)中 ,是非常有用的,于是乎 System.Object 类就有了一个虚方法 GetHashCode,这个方法返回一个 Int32 的哈希值。

如果你定义了一个类型,并重写了 Equals 方法,那你也应该重写 GetHashCode 方法。其实 C# 的编译器已经帮我做了检查,如果你重写了 Equals 方法,而没有重写 GetHashCode 方法,编译器会有一个警告:

“warning CS0659: 'Program' overrides Object.Equals(object o) but does not override Object.GetHashCode()”.

微软这样设计的原因是 System.Collections.Hashtable 和 System.Collections.Generic.Dictionary 等一些集合类要求任何两个对象如果 Equals 返回 true,那它们必须拥有相同的哈希值。

定义 GetHashCode 方法,可以是很简单、直接的。但是根据你的数据类型和结构的不同,也需要考虑不同的hash算法是的hash code能够均匀的分布。例如下面的例子:

internal sealed class Point {
    private readonly Int32 m_x, m_y;
    public override Int32 GetHashCode() {
        return m_x ^ m_y; // m_x XOR'd with m_y
    }
    ...
}

当选择哈希算法时,有以下几点需要考虑:

  • 越是随机分布,性能越好。
  • 你可以调用基类的 GetHashCode 方法,作为你的计算的一部分。但是最好不要总是调用 Object 或 ValueType 的 GethashCode 方法。
  • 你的算法要使用至少一个实例的字段。
  • 理想情况下,你使用的实例字段最好是不会变的(immutable),也就是说它是在初始化时被赋值,之后的生命周期内都不会被改变的。
  • 你的算法应该是越快越好。
  • 有相同值的两个对象,应该返回相同的 hash code。例如两个相同的 String,他们的 hash code 应该是一样的。

由于 System.Object 实现的 GetHashCode 方法不知道具体的派生类情况,所以它的实现会返回一个保证唯一的值,但是这个值的唯一性只能保证在一个 AppDomain 中是唯一的,这个数值在对象的生命周期内是不会被改变的。一旦这个对象被 GC 回收后,它的 hash code 又会被其他对象 reuse。

注意,hash code 值只能在内存中被用来唯一标识对象,不可以把它持久化,以后再拿到内存中来参考。因为不同版本的类实现可能会有不同的 hash code。

 

The dynamic Primitive Type

C# 是一门类型安全的语言,它的好处是很多类型安全的问题再编译时就会被发现出来。但是有一些情况下,我们的很多信息在代码开始运行之前是无法知道的,这就引入了动态类型,dynamic。 看一个例子:

 1 Private static class DynamicDemo {
 2     public static void Main() {
 3         for (Int32 demo = 0; demo < 2; demo++) {
 4             dynamic arg = (demo == 0) ? (dynamic) 5 : (dynamic) "A";
 5             dynamic result = Plus(arg);
 6             M(result);
 7         }
 8     }
 9     private static dynamic Plus(dynamic arg) { return arg + arg; }
10     private static void M(Int32 n) { Console.WriteLine("M(Int32): " + n); }
11     private static void M(String s) { Console.WriteLine("M(String): " + s); }
12 }

得到的结果是:

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

其中的 Plus 方法定义,返回值是 dynamic,参数也是 dynamic 的。C# 编译器遇到这个定义,会产生 payload 代码,payload 代码的特点是在运行时才来验证 arg 的类型、决定 + 是做什么操作。

当某个字段,方法参数,方法返回值,或者本地变量被设置为 dynamic 时,编译器会把它转换为 Object,并给它加上一个 System.Runtime.CompilerServices.DynamicAttribute 属性(除了本地变量)。

 

后面还有一些 dynamic 和 COM 互操作的介绍,以及和其他动态语言(Ruby,Python)的比较。这里就先不关注了。