CLR-via-CSharp-03 第5章 基元类型、引用类型和值类型

基元类型

编辑器直接支持的数据类型成为基元类型(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
2
3
4
int a = 0;
System.Int32 a = 0;
int a = new int();
System.Int32 a = new System.Int32();

C#语言规范推荐在使用基元类型的时候,尽量用关键字而不是类名。比如用string关键字,不要用System.String

在C#中,int始终是32bits,long始终是64bits,与系统无关。

由于基元类型于对应的FCL类型名字不一定一致,而且出于FCL类型能够正确表现类型所占用大小的理由,所以这本书在后面都会使用FCL类型。

基元类型之间的转换

众所周知,Int32Int64是不同的两种类型。系统允许隐式的把数值从低精度往高精度转换。而如果想要把数值从高精度类型转换成低精度,就一定要显式转换。

1
2
3
4
5
6
Int32 i = 5;
Int64 l = i;
Single s = i;

Byte b = (Byte) i; // 显式,丢失精度
Int16 v = (Int16) s;

从高精度转成低精度,C#总是对结果进行向下取整。

checked和unchecked基元类型操作

当所代表的值超出自己所能表达的范围的时候,就会导致溢出。如下

1
2
Byte b = 100;
b = (Byte) (b + 200);

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+编译器开关来打开全局溢出检查,也可以只在局部使用checkedunchecked关键字来控制:

1
2
3
4
5
UInt32 invalid = unchecked((UInt32) -1); // OK
UInt32 invalid = checked((UInt32) -1); // 抛出System.OverflowException异常

// 注意这种情况
UInt32 invalid = (UInt32) checked(-1); // 如果写成这种形式,理所当然是不会报错的

也可以使用checked关键字包裹住一段区域:

1
2
3
4
5
checked
{
Byte b = 100;
b += 200; // 抛出System.OverflowException异常
}

注意,checked只能影响本层级方法的四则运算操作,无法影响所调用方法里的检查规则。

1
2
3
checked {
SomeMethod(400); // CheckMethod内部是否检查溢出,不会被这个checked影响到
}

作者对程序员有几个建议:

  1. 由于类库的多个方法(Array和String的Length属性)经常会返回有符号的值而非无符号的,所以推荐平常使用的时候多用有符号值,这样可以避免强制转换和溢出。
  2. 在有可能产生溢出的地方使用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操作符返回对象的内存地址。使用引用类型会有一定的性能问题,要注意:

  1. 内存必须从托管堆分配
  2. 堆上分配的每个对象都有一些额外成员(同步索引块和类型对象指针),这些成员必须初始化
  3. 从托管堆分配对象时,可能造成一次强制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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct SomeVal
{
Int32 x;
}

void Func()
{
SomeVal v1; // OK
Int32 x = v1.x; // error CS0170 使用了可能未赋值的字段"x"
}

void Func2()
{
SomeVal v1 = new SomeVal(); // OK,且还是被分配在线程栈上
Int32 x = v1.x; // OK
}

用哪一种类型

设计自己的类型时,我们要慎重考虑使用值类型还是引用类型。值类型有时能提供更好的性能(如果是作为引用类型的字段来使用,则是对性能毫无改进)。如果你想把类型设计为值类型,要符合下面全部条件:

  1. 类型要非常简单,甚至是不可变的。建议将所有字段都设置为readonly的。
  2. 类型不需要从其他任何类型继承。
  3. 类型不需要派生出其他任何类型。
  4. 类型大小不应过大(应该小于等于16bytes)。因为实参默认以传值方式传递,对值类型的实参复制一份,影响性能。当一个方法返回一个值类型时,同样会复制出一份来,若值类型过大,同样会影响性能。

值类型和引用类型的区别还有:

  1. 值类型有两种表现形式:未装箱和已装箱。引用类型总是在已装箱形势下。
  2. 值类型从System.ValueType派生,从而有了System.Object的方法。System.ValueType重写了Equals方法,当两个对象所有的字段值相同时才返回true,同时又重写了GetHashCode方法,使得哈希算法会考虑所有字段的值。这两个默认的方法重写显而易见地在字段过多的情况下会有极大的性能消耗,这种情况下程序员应该自己重写这两个方法。
  3. 值类型没法作为基类,所以值类型中不应该存在新的虚方法,所有的方法都是密封的。
  4. 把值类型变量赋值给另一个值类型变量会逐字段复制,将一个引用类型变量复制给另一个引用类型变量只复制内存地址。

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

我们可以为自己定义的类或者结构应用System.Runtime.InteropServices.StructLayoutAttrbute特性。这个特性包括三种:

  • LayoutKind.Auto 这是C#编译器的默认选择
  • LayoutKind.Sequence 强制让CLR保持你的字段布局
  • LayoutKind.Explicit 显式设置字段偏移量

:star:显式设置字段偏移量以模拟C/C++中的union结构:star:

C/C++中的union结构中,每个字段共享同一段内存,设置其中一个字段的值会影响另一个字段。

1
2
3
4
5
6
7
8
9
10
11
12
using System;
using System.Runtime.InteropServices;

[StructLayout(LayoutKind.Explicit)]
internal struct SomeValtype
{
[FieldOffset(0)]
public int m_int;

[FieldOffset(0)]
public uint m_uint;
}

在上面的例子中,m_intm_uint在内存中的位置将会重叠。设置m_int为-1之后观察m_uint有惊喜。

值类型的「装箱」和「拆箱」

装箱

值类型的装箱操作常发生在它被作为Object类型使用的时候。比如在方法

1
string.format(string content, object[] params);

中,参数是object类型的,当你调用了这样一句话,就会自动进行装箱操作

1
string.foramt("{0}", 1234);

当发生装箱的时候,包括了以下三个步骤:

  1. 在托管堆中分配内存。内存大小 = 值类型各字段所需大小 + 类型对象指针大小 + 同步索引块大小
  2. 把值类型的新字段复制到新分配的堆内存
  3. 返回对象的地址

由于需要在托管堆中分配一块新的内存,所以装箱操作的性能并不怎么好。为了避免装箱,应当尽量避免需要把值类型变为object类型的操作。在使用系统API的时候,尽量使用泛型类型,不要使用object类型的。比如应当多使用System.Collections.Generic.List<T>,不使用System.Collections.ArrayList

拆箱

拆箱可以理解为把已装箱的值类型复制出来成为未装箱值类型,没有堆内存分配,效率要比装箱高上不少。

需要注意的是拆箱只能恢复成值对象未装箱时候的类型。比如下面代码是木大木大的:

1
2
3
Int32 x = 5;
Object o = x;
Int16 y = (Int16) o; // 抛出InvalidCastException

如果实在是想做一次强转,就得这么写:

1
Int16 y = (Int16)(Int32) o;

如何在不经意间分配了两次内存

1
2
3
4
5
6
7
8
9
10
public static void Main()
{
Point p;
p.x = p.y = 1;
Object o = p; // 装箱第一次

p = (Point) o;
p.x = 2;
o = p; // 装箱第二次,o重新引用另一个装箱对象,重新开辟了一次内存,并不会直接改动o原引用的那块内存的值
}

以下代码中装箱发生了多少次

1
2
3
4
5
6
7
8
public static void Main()
{
Int32 v = 5;
Object o = v;
v = 123;

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

值类型的方法

前面提到,值类型继承于System.ValueType,后者又继承于System.ObjectSystem.ObjectToString(), GetHashCode()等虚方法。

我们可以为自定义的值类型重写继承于System.Object的虚方法,也可以自行为值类型添加方法。

有两种情况不需要装箱:

  1. 调用继承自Object的方法,比如ToString(但是如果在内部调用了base.ToString()这一属于Object的方法,那就要装箱)
  2. 自己新增的方法(同样要求不能调用base的方法)

哪些情况下要装箱:

  1. 调用属于Object的方法(直接调用或者内部方法间接调用)
  2. 使用一个接口来持有值类型对象
  3. 作为Object类型的参数的时候

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public struct MyVal : IComparable
{
Int32 intVal;
public override string ToString()
{
return intVal.ToString();
}

public Int32 CompareTo(MyVal other){
return this.intVal.CompareTo(other.intVal);
}

public Int32 CompareTo(Object other){
if(this.GetType() != other.GetType)
{
throw new ArgumentException("other is not a MyVal object");
}
return this.intVal.CompareTo((other as MyVal).intVal);
}

public void Change(Int32 value)
{
intVal = value;
}

public void NewFunc(){}
}

public static void Main()
{
MyVal myVal1 = new MyVal(){ intVal = 1 };
MyVal myVal2 = new MyVal(){ intVal = 1 };
string s = myVal.ToString(); // 不需要装箱
myVal.NewFunc(); // 新定义的方法,不需要装箱

IComparable c = myVal2; // 使用引用持有,装箱
Object o2 = myVal2; // 使用object持有,当然装箱


// ---- 注意
myVal1.Change(1);
Object o1 = myVal1;
((myVal) o1).Change(2);
console.out.PrintLine(myVal1.intVal); // 打印结果是 1
}

注意上面最后提到的这个例子,myVal1被Object持有之后会有一次装箱。当使用myVal转回来之后发生一次拆箱,拆箱产生一个在线程栈上的临时变量,随后CLR对这个临时变量执行Change(),导致临时变量上的intVal被改变。然而,这并不会影响myVal1的值,因为他们压根儿不是同一个变量了。

对象比较

Object有一个Equals(Object o)方法,常用来比较两个对象是否相同。这个方法的默认实现很简单,就是判断是否使用了相同的引用:

1
2
3
4
5
6
7
8
9
10
public class Object
{
public virtual Boolean Equals(Object obj)
{
if(this == obj)
return true;

return false;
}
}

实际使用的时候我们可能想要根据两个对象的每个字段是否相同来判断他们是否是“相同的”。更好的方法应该是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyClass : Object
{
public int IntVal;

public override Boolean Equals(Object obj)
{
if(Object.ReferenceEquals(this, obj))
return true;

if(this.GetType() != obj.GetType())
return false;

return this.IntVal == obj.IntVal;
}
}

注意,上面判断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

待填坑。。。

Buy Me A Coffee / 捐一杯咖啡的钱
分享这篇文章~
0%
//