基元类型
编辑器直接支持的数据类型成为基元类型(primitive type),实际上也是一个类型,但是有语法支持,写起来贼方便。最常见的基元类型就包括了我们的int32。
基元类型也可以用new
关键字创建,但是如果不是作为对象的一个成员,会被直接分配在线程栈上。
每个基元类型都有对应的FCL类型。常见的int
对应System.Int32
。特别的,float对应System.Single
,dynamic对应System.Object
。
基元类型包括了:
- bool
- sbyte 有符号8位值
- byte
- short 16bits
- ushort 16bits
- char 16bits unicode
- float 32bits
- int 32bits
- uint
- long 64bits
- double 64bits
- decimal 128bits
- string
- object
- dynamic
定义一个int可以有四种形式
1 | int a = 0; |
C#语言规范推荐在使用基元类型的时候,尽量用关键字而不是类名。比如用string关键字,不要用System.String
。
在C#中,int始终是32bits,long始终是64bits,与系统无关。
由于基元类型于对应的FCL类型名字不一定一致,而且出于FCL类型能够正确表现类型所占用大小的理由,所以这本书在后面都会使用FCL类型。
基元类型之间的转换
众所周知,Int32
和Int64
是不同的两种类型。系统允许隐式的把数值从低精度往高精度转换。而如果想要把数值从高精度类型转换成低精度,就一定要显式转换。
1 | Int32 i = 5; |
从高精度转成低精度,C#总是对结果进行向下取整。
checked和unchecked基元类型操作
当所代表的值超出自己所能表达的范围的时候,就会导致溢出。如下
1 | Byte b = 100; |
Byte八位,上限为127。当把b与200(默认为Int32类型)相加时,b会被转成32位值,相加后得到一个32位值。我们需要把它显式转换为Byte类型。
CLR的加指令有两个,一是add指令,把两个值相加而不执行溢出检查;另一个add.ovf指令,将两个值相加,若发生了溢出则抛出System.OverflowException
异常。当然其他四则运算也少不了,还有sub/sub.ovf, mul/mul.ovf, conv/conv.ovf。
C#的溢出检查默认是关闭的,这样能让代码的效率更高。如果程序员需要溢出检查,可以使用/checked+
编译器开关来打开全局溢出检查,也可以只在局部使用checked
和unchecked
关键字来控制:
1 | UInt32 invalid = unchecked((UInt32) -1); // OK |
也可以使用checked
关键字包裹住一段区域:
1 | checked |
注意,checked只能影响本层级方法的四则运算操作,无法影响所调用方法里的检查规则。
1 | checked { |
作者对程序员有几个建议:
- 由于类库的多个方法(Array和String的Length属性)经常会返回有符号的值而非无符号的,所以推荐平常使用的时候多用有符号值,这样可以避免强制转换和溢出。
- 在有可能产生溢出的地方使用
checked
,并捕捉OverflowException
,优雅地进行处理
Decimal类型与BigInteger类型
Decimal类型虽是C#基元类型,却不是CLR中的基元类型,这意味着CLR没有处理Demical的IL指令。Demical类型提供了一系列的public static方法,包括了加减乘除,并重载了+ - * /
等操作符方法。在使用Decimal的时候,CLR实际上是在调用上面提到的方法,所以处理速度理所当然的要比CLR基元类型的要慢。
由于没有对应的IL指令来处理Decimal,所以checked和unchecked操作符也就对Decimal是没有作用的。
同样的,System.Numerics.BigInteger
类型也是通过在内部使用UInt32数组来表示任意大的整数,它没有上限和下限,当然也就永远不会有OverflowException。但是如果值太大,没有足够内存来改变数组大小,对BigInteger的运算可能抛出OOM异常。
引用类型和值类型
引用类型总是从托管堆中分配,new
操作符返回对象的内存地址。使用引用类型会有一定的性能问题,要注意:
- 内存必须从托管堆分配
- 堆上分配的每个对象都有一些额外成员(同步索引块和类型对象指针),这些成员必须初始化
- 从托管堆分配对象时,可能造成一次强制GC
如果所有类型都是引用类型,那么每次使用的时候都需要开辟内存,会造成效率极其低。所以便有了值类型。
值类型一般被分配在线程栈上(有时也作为字段嵌入成为引用类型对象的一部分,被分配于托管堆上)。可以缓解托管堆压力,减少GC次数。
简单来说,任何class都是引用类型,任何struct和enum都是值类型。这里的结构包括了System.Int32
, ‘System.Boolean’, ‘System.Dicimal’, ‘System.TimeSpan’, ‘System.TimeSpan’(??常用的这货竟然是struct??), 另外,枚举就包括’System.DayOfWeek’。
我们可以发现,所有的值类型都是抽象类型System.ValueType
的直接派生类。其中,所有的枚举从System.Enum
抽象类中派生,而这个抽象类也是派生自System.ValueType
的。
所有的值类型都是封闭的(sealed),这是为了避免它成为其他引用类型的基类。
不管是值类型和引用类型都可以new
,这说明new
并不是引用类型的专权。只是值类型被new
之后,仍会被分配在线程栈上。
值类型可以选择单纯声明,或者使用new
初始化。没有经过new
或者值类型的内容没有被修改过的,编译器会认为该值类型没有被初始化过,如果后面的代码要访问该值类型,编译器会报未初始化的错。如下面代码:
1 | struct SomeVal |
用哪一种类型
设计自己的类型时,我们要慎重考虑使用值类型还是引用类型。值类型有时能提供更好的性能(如果是作为引用类型的字段来使用,则是对性能毫无改进)。如果你想把类型设计为值类型,要符合下面全部条件:
- 类型要非常简单,甚至是不可变的。建议将所有字段都设置为readonly的。
- 类型不需要从其他任何类型继承。
- 类型不需要派生出其他任何类型。
- 类型大小不应过大(应该小于等于16bytes)。因为实参默认以传值方式传递,对值类型的实参复制一份,影响性能。当一个方法返回一个值类型时,同样会复制出一份来,若值类型过大,同样会影响性能。
值类型和引用类型的区别还有:
- 值类型有两种表现形式:未装箱和已装箱。引用类型总是在已装箱形势下。
- 值类型从
System.ValueType
派生,从而有了System.Object
的方法。System.ValueType
重写了Equals
方法,当两个对象所有的字段值相同时才返回true,同时又重写了GetHashCode
方法,使得哈希算法会考虑所有字段的值。这两个默认的方法重写显而易见地在字段过多的情况下会有极大的性能消耗,这种情况下程序员应该自己重写这两个方法。 - 值类型没法作为基类,所以值类型中不应该存在新的虚方法,所有的方法都是密封的。
- 把值类型变量赋值给另一个值类型变量会逐字段复制,将一个引用类型变量复制给另一个引用类型变量只复制内存地址。
CLR如何控制类型中的字段布局
我们可以为自己定义的类或者结构应用System.Runtime.InteropServices.StructLayoutAttrbute
特性。这个特性包括三种:
LayoutKind.Auto
这是C#编译器的默认选择LayoutKind.Sequence
强制让CLR保持你的字段布局LayoutKind.Explicit
显式设置字段偏移量
:star:显式设置字段偏移量以模拟C/C++中的union结构:star:
C/C++中的union结构中,每个字段共享同一段内存,设置其中一个字段的值会影响另一个字段。
1 | using System; |
在上面的例子中,m_int
和m_uint
在内存中的位置将会重叠。设置m_int
为-1之后观察m_uint
有惊喜。
值类型的「装箱」和「拆箱」
装箱
值类型的装箱操作常发生在它被作为Object
类型使用的时候。比如在方法
1 | string.format(string content, object[] params); |
中,参数是object类型的,当你调用了这样一句话,就会自动进行装箱操作
1 | string.foramt("{0}", 1234); |
当发生装箱的时候,包括了以下三个步骤:
- 在托管堆中分配内存。内存大小 = 值类型各字段所需大小 + 类型对象指针大小 + 同步索引块大小
- 把值类型的新字段复制到新分配的堆内存
- 返回对象的地址
由于需要在托管堆中分配一块新的内存,所以装箱操作的性能并不怎么好。为了避免装箱,应当尽量避免需要把值类型变为object类型的操作。在使用系统API的时候,尽量使用泛型类型,不要使用object类型的。比如应当多使用System.Collections.Generic.List<T>
,不使用System.Collections.ArrayList
。
拆箱
拆箱可以理解为把已装箱的值类型复制出来成为未装箱值类型,没有堆内存分配,效率要比装箱高上不少。
需要注意的是拆箱只能恢复成值对象未装箱时候的类型。比如下面代码是木大木大的:
1 | Int32 x = 5; |
如果实在是想做一次强转,就得这么写:
1 | Int16 y = (Int16)(Int32) o; |
如何在不经意间分配了两次内存
1 | public static void Main() |
以下代码中装箱发生了多少次
1 | public static void Main() |
值类型的方法
前面提到,值类型继承于System.ValueType
,后者又继承于System.Object
。System.Object
有ToString()
, GetHashCode()
等虚方法。
我们可以为自定义的值类型重写继承于System.Object
的虚方法,也可以自行为值类型添加方法。
有两种情况不需要装箱:
- 调用继承自Object的虚方法,比如ToString(但是如果在内部调用了
base.ToString()
这一属于Object的方法,那就要装箱) - 自己新增的方法(同样要求不能调用base的方法)
哪些情况下要装箱:
- 调用属于Object的方法(直接调用或者内部方法间接调用)
- 使用一个接口来持有值类型对象
- 作为Object类型的参数的时候
比如:
1 | public struct MyVal : IComparable |
注意上面最后提到的这个例子,myVal1被Object持有之后会有一次装箱。当使用myVal转回来之后发生一次拆箱,拆箱产生一个在线程栈上的临时变量,随后CLR对这个临时变量执行Change()
,导致临时变量上的intVal
被改变。然而,这并不会影响myVal1
的值,因为他们压根儿不是同一个变量了。
对象比较
Object
有一个Equals(Object o)
方法,常用来比较两个对象是否相同。这个方法的默认实现很简单,就是判断是否使用了相同的引用:
1 | public class Object |
实际使用的时候我们可能想要根据两个对象的每个字段是否相同来判断他们是否是“相同的”。更好的方法应该是:
1 | public class MyClass : Object |
注意,上面判断this和obj是否为同一个引用的时候使用了静态方法Object.ReferenceEquals(this, obj)
而不是this == obj
。这是因为==
符号的操作有可能被重载,导致它的作用变为不是在判断引用是否相同了。
对于值类型来说,每个值类型都重写了Equals
方法。值得注意的是,在它们重写的Equals
方法中,一定不会调用到base.Equals
,这是由于前文提到的一个点:调用Object的方法的时候,会自动发生装箱。所以避免调用到Object的方法,是为了避免装箱。
hash code
如果你重写了Equals
方法,就应该重写GetHashCode
方法,否则编译器会生成一条警告。这是由于在一些用到哈希码的结构中(比如Hashtable或者Dictionary),只有两个对象哈希码相同的时候,才被视为相等。所以如果不想出现一些什么奇怪的bug,就应该在重写Equals
方法的同时,重写GetHashCode
方法。
在重写Equals
方法之前,我们可以先重写GetHashCode
方法。在Equals
方法中,如果两个对象的类型相同,那么直接判断他们的哈希码是否相同即可。
dynamic
待填坑。。。