基元类型
编译器直接支持的数据类型称为基元类型(primitvie type),基元类型直接映射到Framework类库(FCL)中存在的类型。例如,C#的int直接映射到System.Int32类型。因此,以下4行代码都能正确编译,并生成完全相同的IL:
int a1 = 0; // 最方便的语法
System.Int32 a2 = 0; // 方便的语法
int a3 = new int(); // 不方便的语法
System.Int32 a4 = new System.Int32(); // 最不方便的语法
除此之外,只要是符合公共语言规范(CLS)的类型,其他语言都提供了类型的基元类型(不符合CLS的类型语言就不一定支持了)。下面列一下C#基元类型与对应的FCL类型。
C#基元类型 | FCL类型 | 符合CLS | 说明 |
---|---|---|---|
sbyte | System.SBype | 否 | 有符号8位值 |
byte | System.Byte | 是 | 无符号8位值 |
short | System.Int16 | 是 | 有符号16位值 |
ushort | System.UInt16 | 否 | 无符号16位值 |
int | System.Int32 | 是 | 有符号32位值 |
uint | System.UInt32 | 否 | 无符号32位值 |
long | System.Int64 | 是 | 有符号64位值 |
ulong | System.UInt64 | 否 | 无符号64位值 |
char | System.Char | 是 | 16位Unicode字符 |
float | System.Single | 是 | IEEE 32位浮点值 |
double | System.Double | 是 | IEEE 64位浮点值 |
bool | System.Boolean | 是 | true/false值 |
decimal | System.Decimal | 是 | 128位高精度浮点值,常用于不容许舍入误差的金融计算 |
string | System.String | 是 | 字符数组 |
object | System.Object | 是 | 所有类型的基类型 |
dynamic | System.Object | 是 | 对于CLR,dynamic和object完全一致。但C#编译器允许使用简单的语法让dynamic变量参与动态调度 |
从另一个角度讲,可以认为C#编译器自动为所有源代码文件都添加了以下using指令:
using sbyte = System.SByte;
using byte = System.Byte;
............................................
C#语言规范称:“从风格上说,最好使用关键字,而不是完整的系统类型名称”。作者不同意该观点,我不太赞同作者的观点,就不把作者的想法收录进去了。
基元类型的转型
在许多编程语言中,以下代码都能正确编译并运行:
int i = 5; // 32位值
long l = i; // 隐式转型为64位值
第一次看,可能会感觉比较奇怪,int和long明明是不同类型,且没有任何派生关系,但为什么C#编译器可以编译上述代码,且运行起来也没有问题?
原因是C#编译器非常熟悉基元类型,会在编译代码时应用自己的特殊规则。也就是说,编译器能识别常见的编程模式,并生成必要的IL。具体来说,C#编译器支持与类型转换、字面值(例如123.toString() 123就是字面值,或者"abc".Length,"abc"是字面值)以及操作符有关的模式。
不过,只有在转换“安全”的时候,C#才允许隐式转型。所谓“安全”,就是指不会发生数据丢失的情况,比如从int32(int)转换为int64(long)。但如果可能不安全,C#就要求显式转型(对于数值类型,“不安全”意味着转型后可能丢失精度或数量级)。例如int转换为Byte要求显式转型,因为大的Int32数字可能丢失精度。例如:
int i = 300;
float f = i; // 从int隐式转型为float
byte b = (byte)i; // 从inte显式转型为byte
注意,不同编译器可能生成不同的代码来处理这些转型。C#总是对结果进行截断(或者说是取模),而不进行向上取整。例如上例中,b的值会是44(300%256=44)。
除了转型,基本类型还能写成字母值(literal)。字面值可以被看成类型本身的实例,所以像下面代码可以执行:
Console.WriteLine(123.ToString()); // 输出:123
除此之外,如果表达式由字面值构成,编译器在编译时就能完成表达式求值,从而增强应用程序性能:
int x = 100 + 20 + 3; // 生成的代码将x设为123
checked和unchecked基元类型操作
对基元类型执行的许多算术运算都可能造成溢出:
Byte b = 100;
b = (Byte)(b + 100);
------------
在执行上述运算时,第一步会把所有操作数都扩大为32位值(或者64位值,如果任何操作数需要超过32位来表示的话)。所以b和200首先转为32位值,然后加到一起。结果是一个32位值,但在存回变量b前必须转型为Byte。C#无法隐式做这个转型,所以需要程序员强制转型。
C#允许程序员自己决定如何处理溢出。溢出检查默认关闭。也就是说,编译器生成IL代码时,将自动使用加减乘除以及转换指令的无溢出检查版本(结果是代码能更快运行——但需要我们保证不发生溢出,或者我们能预见到)。
让C#编译器控制溢出的一个办法是使用/checked+编译器开关。该开关指示编译器在生成代码时,使用加减乘除和转换指令的溢出检查版本。这样生成的代码在执行时会稍慢一些,因为CLR会检查这些运算,判断是否发生溢出。如果发生溢出,CLR会抛出OverflowException异常。
除了全局性的打开或关闭溢出检查,程序员还可在代码的特定区域控制溢出检查。C#通过checked和unchecked操作符来提供这种灵活性。例如:
uint i = unchecked((uint)(-1)); // 能够执行,输出:4294967295
下面例子使用了checked操作符:
byte b = 100;
b += checked((byte)(b + 200));
b和200首先转换为32位值,加到一起,结果是300.然后做显式转换,300明显大于byte的最大值255,造成OverflowException异常。会抛出报错:未经处理的异常: System.OverflowException: 算术运算导致溢出。如果将byte转型放到checked操作符外部则不会抛出异常。
byte b = 100;
b += (byte)checked((b + 200));
除了checked和unchecked操作符,C#还支持checked和unchecked语句,它们造成一个块中的所有表达式都进行或不进行溢出检查,例如:
checked
{
byte b = 100;
b += 200;
}
--------------------
会抛出报错:未经处理的异常: System.OverflowException: 算术运算导致溢出
作者对程序员判断是否溢出,做出了几点总结,我摘抄如下:
- 尽量使用有符号数值类型(比如int和long)而不是无符号数值类型(比如uint和ulong)。这允许编译器检测更多的上溢/下溢错误。除此之外,类库多个部分(比如Array和String的Length熟悉)被硬编码返回有符号的值。这样在代码中四处移动这些值时,需要进行的强制类型转换就少了。较少的强类型转换使代码更整洁,更容易维护。除此之外,无符号数值类型不符合CLS。
- 写代码时,如果代码可能发生你不希望的溢出,就把这些代码放到checked块中。同时捕捉OverflowException,得体地从错误中恢复。
- 写代码时,将允许发生溢出的代码显式放到unchecked块中,比如在计算校验和时。
- 对于没有使用checked或unchecked的任何代码,都假定你希望在发生溢出时抛出一个异常,比如在输入已知的前提下计算一些东西(比如质数),此时溢出应被计为bug。
开发应用程序时,打开编译器的/checked+开关进行调试性生成。这样系统会对没有显式标记checked或unchecked的代码进行溢出检测,所以应用程序运行起来会慢一些。此时发生异常,就能轻松检测到,并能及时修复代码的bug。但是,正式发布时,应该使用编译器的/checked-开关,确保代码能更快运行。
要在Microsoft Visual Studio中更改Checked设置,请打开项目的属性页,点击“生成”标签,单击“高级”,再勾选“检查运算上溢/下溢”
引用类型和值类型
CLR支持两种类型:引用类型和值类型。引用类型总是从托管堆分配,c#的new操作符返回对象内存地址——即指向对象数据的内存地址。使用引用类型必须留意性能问题。
- 内存必须从托管堆分配
- 堆上分配的每个对象都有一些额外成员,这些成员必须初始化
- 对象中的其他字节(为字段而设)总是设为零
- 从托管堆分配对象时,可能强制执行一次垃圾回收
如果所有类型都是引用类型,应用程序的性能将显著下降!设想下每次使用int值时都进行一次内存分配,性能会受到多大的影响。CLR提供了名为“值类型”的轻量级类型。
- 值类型的实例一般在线程栈上分配。
- 在代表值类型实例的变量中不包含指向实例的指针。相反,变量中包含了实例本身的字段。
- 由于变量已包含了实例的字段,所以操作实例中的字段不需要提领指针(提领(Dereference)指针意味着通过指针获取它指向的具体数据,如果你有一个指针p指向一个整数变量x,那么通过*p可以访问x的值)。
- 值类型的实例不受垃圾回收器的控制。
- 值类型的使用缓解了托管堆的压力,并减少了应用程序生存期内的垃圾回收次数。
下面提供更细的引用类型与值类型的区别:
- 任何称为“类”的类型都是引用类型,相反,所有值类型都称为结构或枚举。
- 所有结构都是抽象类型System.ValueType的直接派生类,System.ValueType本身又直接从System.Object派生。
- 根据定义,所有值类型都必须从System.ValueType派生。所有枚举都从System.Enum抽象类型派生,后者又从System.ValueType派生。
- 不能在定义值类型时,为它指定基类,但值类型可以实现一个或者多个接口。
- 所有值类型都隐式密封,目的是防止将值类型用作其他引用类型或值类型的基类型。例如,无法将Boolean,Char,Int32等作为基类来定义任何新类型。
设计自己类型时,要仔细考虑类型是否应该定义成值类型而不是引用类型。值类型有时能提供更好的性能。具体来说,除非满足以下全部条件,否则不应将类型声明为值类型。
- 类型具有基元类型的行为。也就是说,是十分简单的类型,没有成员会修改类型的任何实例字段。如果类型没有提供会更改其字段的成员,就说该类型是不可变(immutable)类型。事实上,对于许多值类型,我们都建议将全部字段标记为readonly。
- 类型不需要从任何其他类型继承。
- 类型也不能派生出其他任何类型。
- 类型的实例较小(16字节或更小);实例的实例较大(大于16字节),但不作为方法实参传递,也不从方法返回,因为作为实参传递时,是以传值方式进行传递,会对值类型中字段进行复制。
值类型的主要优势是不作为对象在托管堆上分配。当然,与引用类型相比,值类型也存在自身的一些局限:
- 值类型有2种表示形式:已装箱和未装箱;引用类型总是处于已装箱形式。
- 值类型从System.ValueType派生。该类型提供了与System.Object相同的方法。但System.ValueType重写了Equals方法(反射的方式),能在两个对象的字段值完全匹配的前提下返回true。此外,System.ValueType重写了GetHashCode方法。生成哈希码值时,这个重写方法所用的算法会将对象的实例字段种的值考虑在内。由于这个默认实现存在性能问题,所以定义自己的值类型应重写Equals和GetHashCode方法,并提供它们的显式实现。
- 由于不能将值类型作为基类型来定义新的值类型或者新的引用类型,所以不应在值类型种引入任何新的虚方法。所有方法都不能是抽象的,所有方法都隐式密封(不可重写)。
- 引用类型的变量包含堆中对象的地址。引用类型的变量创建时默认初始化为null,表明当前不指向有效对象。视图使用null引用类型变量会抛出NullReferenceException异常;相反,值类型的变量总是包含其基础类型的一个值,而且值类型的所有成员都初始化为0.值类型变量不是指针,访问值类型不可能抛出NullReferenceException异常。
- 将值类型变量赋给另一个值类型变量,会执行逐字段的复制。将引用类型的变量赋给另一个引用类型的变量只复制内存地址。
- 两个或多个引用类型变量能引用堆种同一个对象,所以对一个变量执行的操作可能影响到另一个变量引用的对象。相反,值类型变量自成一体,对值类型变量执行的操作不可能影响到另一个值类型变量。
- 由于未装箱的值类型不在堆上分配,一旦定义了该类型的一个实例的方法不再活动,为它分配的内存就会被释放,而不是等着垃圾回收。
值类型的装箱和拆箱
值类型比引用类型“轻”,原因是它们不作为对象在托管堆中分配,不被垃圾回收,也不通过指针进行引用。将值类型转换成引用类型要使用装箱机制,C#编译器自动生成对值类型实例进行装箱所需的IL代码,下面列举装箱时内部发生的事情:
- 在托管堆中分配内存。分配的内存量是值类型各字段所需的内存量,还要加上托管堆所有对象都有的两个额外成员(类型对象指针和同步块索引)所需的内存量。
- 值类型的字段复制到新分配的堆内存。
- 返回对象地址。现在该地址是对象引用;值类型成了引用类型。
了解了装箱,再了解一下拆箱:
- 首先获取引用,取得装箱对象中各个字段的地址。这一步就称为拆箱。
- 然后将装箱对象中的所有字段复制到线程栈上的值类型变量中。
拆箱不是直接将装箱的过程倒过来。拆箱的代价比装箱低得多。拆箱其实就是获取指针的过程,该指针指向包含在一个对象中的原始值类型(数据字段)。其实,指针指向的是已装箱实例中的未装箱部分。所以和装箱不同,拆箱不要求在内存中复制任何字节。此外,在拆箱完成后,会立即进行一次字段复制。
已装箱值类型实例在拆箱时,内部发生下面这些事情。
- 如果包含“对已装箱值类型实例的引用”的变量为null,抛出NullReferenceException异常。
- 如果引用的对象不爽所需值类型的已装箱实例,抛出InvalidCastExcetion异常。
第二条则意味着,在以下代码中:
public class Program
{
static void Main(string[] args)
{
int a = 10;
long b1 = a;
object o = a;
long b2 = (long)o;
}
}
b2的转换会报错,抛出System.InvalidCastException异常。
除此之外还有一点要注意的是,如果你想更改已装箱实例的值,你必须将它拆箱后进行修改再重新进行装箱。十分影响程序的性能,写法也很繁琐,例如:
public class Program
{
static void Main(string[] args)
{
int a = 10;
object o = a;
a = (int)o;
a = 15;
o = a;
Console.WriteLine(o);
}
}
有的语言(比如C++/CLI)允许在不复制字段的前提下对已装箱的值类型进行拆箱。拆箱返回已装修对象中的未装箱部分的地址(忽略对象“类型对象指针”和“同步块索引”这2各额外的成员)。接着可利用这个指针来操纵未装箱实例的字段(这些字段恰好在堆上已装箱对象中)。所以,如果装箱/拆箱比较频繁的部分,交由C++来处理也许会更好,因为这起码避免了为了修改字段值导致的拆箱。
仔细研究一下FCL,会发现许多方法都针对不同的值类型参数进行了重载。例如,System.Console.WriteLine有:
public static void WriteLine(Boolean);
public static void WriteLine(Char);
public static void WriteLine(Int32);
....
大多数方法进行重载唯一目的就是减少常用值类型的装箱次数。通过这些例子,我们很容易判断出一个值类型的实例在什么时候需要装箱:
- 要获取对值类型实例的引用,实例必须装箱。
- 将值类型实例传给需要需要获取引用类型的方法,也会发生这种情况。
未装箱值类型比引用类型更“轻”,则要归功于两个原因:
- 不在托管堆上分配
- 没有堆上每个对象都有的额外成员:“类型对象指针”和“同步块索引”
由于未装箱值类型没有同步块索引,所以不能使用System.Threading.Monitor类型的方法(或者C# lock语句)让多个线程同步对实例的访问。
虽然未装箱值类型没有类型对象指针,但仍可调用由类型继承或重写的虚方法(比如Equals,GetHashCode或者ToString)。如果值类型重写了其中任何虚方法,那么CLR可以以非虚地调用该方法,因为值类型隐式密封,不可能由类型从它们派生,而且调用虚方法的值类型没有装箱。例如ToString()方法:
pulic struct MyStruct1
{
public override void ToString(){}
}
public struct MyStruct2{}
....
MyStruct1 struct1 = new MyStruct1();
MyStruct2 struct2 = new MyStruct2();
struct1.ToString(); // 不用装箱
struct2.ToString(); // 要装箱
之所以不需要装箱,是因为重写以后,在编译阶段,JIT编译器能够直接解析出方法的内存地址或偏移量,从而在生成的机器代码中嵌入对该方法的直接调用。
然而,如果在值类型中重写了一个虚方法,并且在重写的方法中需要调用基类版本的该方法,那么当调用基类的方法时,值类型实例会被装箱为对象,以便能够通过this指针将对一个堆对象的引用传给基方法。
但在调用非虚的、继承的方法时(比如GetType或MemberwiseClone),无论如何都要对值类型进行装箱。因为这些方法由System.Object定义,要求this实参是指向堆对象的指针。
此外,当将一个值类型转换为它实现的接口时,值类型会被装箱,因为接口变量需要引用堆上的对象。例如:
using System;
interface IDisplay
{
void Display();
}
struct MyStruct : IDisplay
{
public void Display()
{
Console.WriteLine("Displaying MyStruct");
}
}
class Program
{
static void Main()
{
MyStruct myStruct = new MyStruct();
// 直接调用,不会装箱
myStruct.Display();
// 转换为接口,会发生装箱
IDisplay displayable = myStruct;
displayable.Display();
}
}
有的语言(比如C++/CLI)允许更改已装箱值类型中的字段,但C#不允许。不过我们可以通过接口的方式绕过C#的限定,例如:
public class Program
{
public interface IChangeBox
{
void Change(int x);
}
public struct Box : IChangeBox
{
public int target;
public void Change(int x)
{
target = x;
}
public override string ToString()
{
return target.ToString();
}
}
static void Main(string[] args)
{
Box box;
box.target = 1;
object o = box;
Console.WriteLine(o); // 输出:1
((IChangeBox)o).Change(2);
Console.WriteLine(o); // 输出:2
}
}
对象相等性和同一性
System.Object类型提供了名为Equals的虚方法,作用是在两个对象包含相同值的前提下返回true,它的实现如下:
public class Object
{
public virtual Boolean Equals(Object obj)
{
if(this == obj) return true;
return false;
}
}
乍一看,似乎挺合理,但仔细思考能够发现,对于Object的Equals方法的默认实现,它实现的实际是同一性(identity),而非相等性(equality)。作者给出一个思路,来正确的实现Equals函数:
- 如果obj实参为null,就返回false。
- 如果this和obj实参引用同一个对象,就返回true。在比较包含大量字段的对象时,这一步有助于提升性能。
- 如果this和obj实参引用不同类型的对象,就返回false。
- 针对类型定义的每个实例字段,将this对象中的值与obj对象中的值进行比较。任何字段不相等,就返回false。
- 调用基类的Equals方法来比较它定义的任何字段。如果基类的Equals方法返回false,就返回false;否则返回true。
由于类型能够重写Object的Equals方法,所以不能再用它测试同一性。为了解决这个问题,Object提供了静态方法ReferenceEquals,其原型如下:
public static bool ReferenceEquals(object objA, object objB) => objA == objB;
检查同一性(看两个引用是否指向同一个对象)务必调用ReferenceEquals,不应使用C#的==操作符(除非先把两个操作数都转型为Object),因为某个操作数的类型可能重载了==\操作符,为其赋予不同于“同一性”语义。
可以看出,在涉及对象相等性和同一性的时候,.NET Framework的涉及很容易使人混淆。不过System.ValueType就重写了Object的Equals方法,并进行了正确的实现来执行值的相等性检查(而不是同一性)。ValueType的Equals内部实现如下:
- 如果obj实参为null,就返回false。
- 如果this和obj实参引用不同类型的对象,就返回false。
- 针对类型定义的每个实例字段,都将this对象中的值与obj对象中的值进行比较(通过调用字段的Equals方法)。任何字段不相等,就返回false。
- 返回true。ValueType的Equals方法不调用Object的Equals方法。
在内部,ValueType的Equals方法利用反射来完成步骤3。由于CLR反射机制慢,定义自己的值类型时应重写Equals方法来提供自己的实现,从而提高用自己类型实例进行值相等性比较的性能(注意:自己的实现不调用base.Equals)。
定义自己的类型时,重写的Equals要符合相等性的4个特征。
- Equals必须自反,x.Equals(x)肯定返回true。
- Equals必须对称,x.Equals(y)和y.Equals(x)要返回相同的值。
- Equals必须可传递,x.Equals(y) = true,y.Equals(z) = true,则x.Equals(z)肯定要返回true。
- Equals必须一致,比较的两个值不变,Equals返回值(true或false)也不能变。
除此之外,重写Equals方法时,还需要做如下几件事:
- 让类型实现System.IEquatable<T>接口的Equals方法
这个泛型接口允许定义类型安全的Equals方法。通常,你实现的Equals方法应获取一个Object参数,以便在内部调用类型安全的Equals方法。 - 重载==和!=操作符方法
通常应实现这些操作符方法,在内部调用类型安全的Equals。
对象哈希码
FCL的设计者认为,如果能将任何对象的任何实例放到哈希表集合中,能带来很多好处。为此,System.Object提供了虚方法GetHashCode,它能获取任意对象的Inte32哈希码。
如果你定义的类型重写了Equals方法,还应重写GetHashCode方法,否则编译器会发出警告。类型定义Equals之所以还要定义GetHashCode,是由于在System.Collections.Hashtable类型,System.Collections.Generic.Dictionary类型以及其他一些集合的实现中,要求两个对象必须具有哈希码才被视为相等。
简单来说,向集合添加键/值对,首先要获取对象的哈希码。该哈希码指出键/值对要存储到哪个哈希桶(bucket)中。集合需要查找键时,会获取指定键对象的哈希码。该哈希码标识了现在要以顺序方式搜索的哈希桶,将在其中查找与指定键对象相等的键对象。采用这个算法来存储和查找键,意味着一旦修改了集合中的一个键对象,集合就再也找不到该对象。所以,需要修改哈希表中的键对象时,正确做法是移除原来的键/值对,修改键对象,再将新的键/值对添加回哈希表。
选择算法来计算类型实例的哈希码时,需要遵守以下规则:
- 这个算法要提供良好的随机分布,使哈希表获得最佳性能。
- 可在算法中调用基类的GetHashCode方法,并包含它的返回值。但一般不要调用Object或ValueType的GetHashCode方法,因为两者的实现都与高性能哈希算法“不沾边”。
- 算法至少使用一个实例对象。
- 理想情况下,算法使用的字段应该是不可变(immutable);也就是说,字段应在对象构造时初始化,在对象生存期“永不言变”。
- 算法执行速度尽量快。
- 包含相同值的不同对象应返回相同哈希码。例如,包含相同文本的两个String对象应返回相同哈希码。
- 千万不要对哈希码持久化,因为哈希码很容易改变。例如,一个类型未来版本可能使用不同的算法来计算对象哈希码。