数据类型
2.3.1 CTS类型(.NET中内置的基本类型)
CTS类型(.NET中内置的基本类型)指的是内置于.NET Framework中的数据类型,比如System.Int32、System.Double等。
C#认可的基本预定义类型并没有内置于C#语言中,而是内置于.NET Framework中。例如,在C#中声明一个int类型的数据,声明的实际上是.NET中结构System.Int32的一个实例。
通用类型系统的功能:
- 建立一个支持跨语言集成、类型安全和高性能代码执行的框架。
- 提供一个支持完整实现多种编程语言的面向对象的模型。
- 定义各语言必须遵守的规则,有助于确保用不同语言编写的对象能够交互作用。
例如,要把int i转化成string,可以编写下面的代码:
1 string s = i.ToString();
这样做其意义深远:这表示在语法上,可以把所有的基本数据类型看成支持某些方法的类。
- 确保IL上的强制类型安全;
- 实现了不同.NET语言的互操作性;
- 所有的数据类型都是对象。它们可以有方法,属性,等。
应强调的是,在这种便利的语法的背后,类型实际上仍存储为基本类型即CTS类型。基本类型在概念上用.NET结构表示,所以肯定没有性能损失。
2.3.2 C#中预定义的类型
-
整型
表格 2‑2 整型
类型 |
名称 |
CTS类型 |
说明 |
范围 |
整型 |
sbyte |
System.SByte |
8位有符号的整数 |
-128~127(-27~27-1) |
byte |
System.Byte |
8位无符号的整数 |
0~255(0~28-1) |
|
short |
System.Int16 |
16位有符号的整数 |
-32 768~32 767(-214~214-1) |
|
ushort |
System.Uint16 |
16位无符号的整数 |
0~65 535(216-1) |
|
int |
System.Int32 |
32位有符号的整数 |
-2 147 483 648~2 147 483 647(-231~231-1) |
|
uint |
System.Uint32 |
32位无符号的整数 |
0~4 294 967 295(0~232-1) |
|
long |
System.Int64 |
64位有符号的整数 |
-9 223 372 036 854 775 808~ 9 223 372 036 854 775 807(-263~263-1) |
|
ulong |
System.Uint64 |
64位无符号的整数 |
0~18 446 744 073 709 551 615(0~264-1) |
如果对一个整数的类型没有进行显式的声明,则该变量默认是int类型。
如果想要把整数声明为其他类型,可以在数字后面加上指定的字符:
1 uint ui = 20U; 2 long l = 30L; 3 ulong ul = 40UL;
也可以使用小写字母u和l,但是小写字母‘l’容易与数字‘1’混淆。
- 浮点类型
表格 2‑3 浮点类型
类型 |
名称 |
CTS类型 |
说明 |
位数 |
范围(大致) |
浮点类型 |
float |
System.Single |
32位单精度浮点数 |
7 |
±1.5×10245~±3.4×1038 |
double |
System.Double |
64位双精度浮点数 |
15/16 |
±5.0×10-324~±1.7×10308 |
float数据类型适用于较小的浮点数,因为它要求的精度低。double数据类型比float数据类型大,提供的精度也大一倍(15位)。
如果对一个非整数(如2.43)硬编码,则编译器会默认该变量是double类型。如果想指定该值为float类型,可以在其后方加上字符F(或f):
1 float variable1 = 2.43F; 2 float variable2 = 2.0F; 3 float variable3 = 2.50F;
- decimal类型
decimal类型表示精度更高的浮点数。
表格 2‑4 decimal类型
名称 |
CTS类型 |
说明 |
位数 |
范围(大致) |
decimal |
System.Decimal |
128位高精度十进制数表示法 |
28 |
±1.0×10-28~±7.9×1028 |
CTS和C#一个重要的优点是提供了一种专用类型进行财务计算,这就是decimal类型。但应注意,decimal类型不是基本类型,所以计算时使用该类型会有性能损失。
要把数字定义为decimal类型,而不是double、float或整型,可以在数字后边加上字符M(或m):
1 decimal variable1 = 23.20M; 2 decimal variable2 = 2.00M;
- bool类型
表格 2‑5 bool类型
名称 |
CTS类型 |
说明 |
位数 |
值 |
bool |
System.Boolen |
表示true或false |
NA |
true或false |
bool值和整数值不能相互转换。如果变量声明为bool类型,那么变量的值只能为true或false,不可以用0表示false,非0的值表示true。
- 字符类型
为了保存单个字符,C#支持char类型数据。
表格 2‑6 字符类型
名称 |
CTS类型 |
值 |
char |
System.Char |
表示一个16位的(Unicode)字符 |
char类型的字面量是用单引号括起来的,如‘A’。如果把字符放在双引号中 ,编译器或把它看成字符串,从而产生错误,如下图:
图 2‑7 错误的表示方法
除了字面量可以表示char类型的值以外,4位16进制的Unicode值(如‘\u0041’)、带有数据类型转换的整数值(如(char)65)或16进制数(‘\0041’)表示它们,同时还可以用转义序列表示,转义字符如下表所示:
表格 2‑7 转义字符
转义序列 |
字符 |
\’ |
单引号 |
\” |
双引号 |
\\ |
反斜杠 |
\0 |
空 |
\a |
警告 |
\b |
退格 |
\f |
换页 |
\n |
换行 |
\r |
回车 |
\t |
水平制表符 |
\v |
垂直制表符 |
那么我们就可以通过以下几种方式表示一个字符变量:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 //1.使用字面量表示字符变量 6 char variable1 = 'C'; 7 8 //2.使用4位16进制的Unicode值表示字符变量 9 char variable2 = '\u0041'; 10 11 //3.使用类型转换的整数值表示字符变量 12 char variable3 = (char)65; 13 14 //4.使用16进制数表示字符变量 15 char variable4 = '\x0041'; 16 17 //5.使用转义字符表示字符变量 18 char variable5 = '\\'; 19 20 Console.WriteLine("variable1的值:" + variable1); 21 Console.WriteLine("variable2的值:" + variable2); 22 Console.WriteLine("variable3的值:" + variable3); 23 Console.WriteLine("variable4的值:" + variable4); 24 Console.WriteLine("variable5的值:" + variable5); 25 26 Console.ReadKey(); 27 } 28 }
图 2‑8 运行结果
- 字符串类型
表格 2‑8 字符串类型
名称 |
CTS类型 |
说明 |
string |
System.String |
Unicode字符串 |
字符串字面量需要放在双引号中,如果试图将字符串放到单引号中,编译器会把它当作char类型,从而引发错误。
1 //字符串字面量应放在双引号中 2 string str1 = "This is a string !"; 3 string str2 = 'This is a string !';
图 2‑9 错误的表示方法
- object类型
许多编程语言和类结构都提供了根类型,层次结构中的其他对象都从它派生而来。在C#中,object类型就是最终的父类型,所有内置类型和用户定义的类型都是从它派生而来的。也就是说所有的类型都直接或间接的继承了object类。这样,object类型就可以用于两个目的:
-
- 可以使用object引用绑定任何子类型的对象。object引用也可以用于反射,此时必须有代码来处理类型未知的对象。
1 //可以使用object引用绑定任何子类型对象 2 //1.为object对象绑定Person类型的引用 3 object obj1 = new Person(); 4 //2.为object对象绑定FileInfo类型的引用 5 object obj2 = new System.IO.FileInfo(@"D:\file.txt"); 6 7 //装箱,将一个值类型转换成引用类型 8 object obj3 = 2;
-
- object类型实现了许多一般用途的基本方法,包括Equals()、GetHashCode()、GetType()和ToString()。用户自定义的类如果没有提供这些方法的实现,默认使用object类中对应方法中的实现,用户也可以重写这些方法,如重写ToString()方法。
我们首先定义一个Person类,代码如下:
1 public class Person 2 { 3 public string name = ""; 4 5 public void ShowPersonalInfo() 6 { 7 Console.WriteLine("My name is " + name); 8 } 9 }
Person类中只有一个字段name,和一个方法ShowPersonalInfo()。
接下来我们创建一个Person类的对象,看看对象里边有哪些东西:
图 2‑10 Person对象的字段及方法
从上图我们可以看出,Person对象中多出了Equals()、GetHashCode()、GetType()、ToString()4个方法,这个4个方法我们在Person类中并没有定义,而是从ojbect类中继承过来的,接下来我们调用Person类中的ToString()方法,看一下结果:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 Person p = new Person(); 6 string str = p.ToString(); 7 Console.WriteLine(str); 8 9 Console.ReadKey(); 10 } 11 }
图 2‑11 运行结果
从运行结果可以看出,因为我们在Person类中没有提供ToString()方法的实现代码,所以我们此处使用的object类中ToString()方法的实现代码,默认实现返回 Person类型的完全限定名。
现在我们在Person类中对ToString()方法进行重写,在Person类中为ToString()方法提供我们自己想要的实现代码,Person类的代码如下:
1 public class Person 2 { 3 public string name = ""; 4 5 public void ShowPersonalInfo() 6 { 7 Console.WriteLine("My name is " + name); 8 } 9 10 //重写ToString()方法,重写通过关键字override实现 11 public override string ToString() 12 { 13 return "This is my ToString() !"; 14 } 15 }
最后我们再次调用Person类中的ToString()方法,运行结果如下:
图 2‑12 运行结果
从运行结果可以看出,当前的ToString()方法使用的是Person类中的我们自己定义的实现代码,而不是父类object中默认返回Object对象完全限定名的实现代码。
2.3.3 值类型和引用类型
C# 中的类型一共分为两类,一类是值类型(Value Type),一类是引用类型(Reference Type)。值类型和引用类型是以它们在计算机内存中如何被分配来划分的。
- 值类型
值类型实例通常分配在线程的堆栈(stack)上,并且不包含任何指向实例数据的指针,因为变量本身就包含了其实例数据。
值类型包括:
-
- 结构:struct(直接派生于System.ValueType);
- 数值类型:sbyte(System.Sbyte)、byte(System.Byte)、short(System.Int16)、ushort(System.Int16)、int(System.Int32)、uint(System.Int32)、long(System.Int64)、ulong(System.Int64)、float(System.Singele)、double(System.Double)、decimal(System.Decimal)、bool(System.Boolean);
- 枚举:enum(派生于System.Enum);
- 可空类型(派生于System.Nullable<T>泛型结构体,T实际上是System.Nullable<T>的别名)。
所有的值类型都隐式地继承自 System.ValueType类型(注意System.ValueType本身是一个类类型),System.ValueType和所有的引用类型都继承自 System.Object基类。你不能显示地让结构继承一个类,因为C#不支持多重继承,而结构已经隐式继承自ValueType。
下面我们通过代码,深入理解一下值类型在内存中是如何存放的:
首先我们定义一个数据结构,代码如下:
1 /// <summary> 2 /// 数据结构,表示一个点的X,Y坐标 3 /// </summary> 4 struct StructPoint 5 { 6 public int X, Y; 7 public StructPoint(int X, int Y) 8 { 9 this.X = X; 10 this.Y = Y; 11 } 12 }
接下来我们声明变量,代码如下:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 int variable1; 6 variable1 = 20; 7 bool variable2 = false; 8 9 StructPoint point = new StructPoint(20, 30); 10 Console.WriteLine(variable1); 11 Console.WriteLine(variable2); 12 Console.WriteLine("X:" + point.X + "\tY:" + point.Y); 13 14 Console.ReadKey(); 15 } 16 }
现在,我们分析每一行代码执行时,内存中发生的变化:
1 int variable1;
这行代码只是声明一个变量,在堆栈中并没有为变量variable1分配内存空间,如果观察MSIL代码,会发现此时变量还没有被压到栈上,因为.maxstack(最高栈数) 为0。并且没有看到入栈的指令,这说明只有对变量进行赋值初始化操作,才会进行入栈。
1 variable1 = 20;
这行代码执行时,变量variable1在堆栈中分配空间并被压入栈中对应的内存空间,现在变量已经包含了值类型的所有字段,所以,此时你已经可以对它进行操作了。我们可以用下图表示当前栈中的情况:
1 bool variable2 = false;
这一行代码时,在内存中为变量variable2分配了一块儿空间,然后将变量压入栈中,此时内存中情况如下图:
1 StructPoint point = new StructPoint(20, 30);
这一行代码执行时,应该注意new关键字与类等其他引用类型的工作方式不同。new关键字并不在托管堆中分配内存,而是只调用相应的构造函数,对所有字段进行初始化,然后将其压入堆栈中,此时内存中情况如下图:
对变量进行操作,实际上是一系列的入栈、出栈操作。
- 引用类型
引用类型分配在托管堆(heap)上,会在托管堆中创建一个实例对象,并把实例对象的地址传给堆栈上的变量(也可以反过来理解,堆栈上的变量指向(引用)托管堆中的对象)。
引用类型包括:类、接口、委托、数组以及预定义的引用类型string、object等
当我们声明一个引用类型的变量时,该变量会在堆栈上分配内存空间,用来保存对象实例在托管堆中的内存地址,当需要访问这个实例时,首先从堆栈中找到该变量,然后通过堆栈中变量保存的内存地址从托管堆中找到对象实例的值。
我们通过下面的代码对上面的话进行解释,首先我们来看正常情况下,堆栈和托管堆发生了什么:
我们先定义一个Person类,该类只有一个字段name,代码如下:
1 public class Person 2 { 3 public string name; 4 }
然后我们声明一个Person类型的变量,并对其实例化,代码如下:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 //声明一个Person类型的变量 6 Person p; 7 //对变量进行实例化 8 p = new Person(); 9 //为实例对象的字段赋值 10 p.name = "CS"; 11 //引用实例对象的name字段 12 Console.WriteLine(p.name); 13 14 Console.ReadKey(); 15 } 16 }
接下来我们分析一下每一行代码执行时,堆栈和托管堆中都发生了什么:
1 Person p;
这句代码只是定义了一个Person类型的变量,没有对其进行赋值或初始化等操作,所以不会在堆栈中为变量p分配空间;如果此时代码中使用了变量p,那么就会发生错误,因为当前内存中并不存在变量p。
1 p = new Person();
当这行代码执行时,在堆栈中为变量p分配了内存空间,并通过new关键字,在托管堆中为引用类型的实例分配了内存空间,而且由于我们调用的是Person类默认的构造函数,没有对实例对象的字段name赋值,所以字段name在托管堆中值将被自动设置为默认值Null,最后将分配的内存地址保存在了堆栈中对应的变量中,代码执行完后,堆栈和托管堆中的情况去下图所示(0x04A831C2表示的是托管堆中为实例对象分配的内存空间的地址):
从图中我们可以看出,当前堆栈的变量中的值已经变成了托管堆中为实例对象分配的内存空间的地址,也就是说,变量p指向了托管堆中的实例对象。
1 p.name = "CS";
这句话是为实例对象的name字段设置值,那么它是怎么在内存中找到这个字段,并修改它的值的呢,其实也很简单,首先堆栈中保存着实例对象在托管堆中对应的内存地址,也就是上张图片中的0x04A831C2,有个这个内存地址,就可以在到托管堆中找到实例对象,最终找到实例对象中的name字段,修改他的值,最后结果如下图所示:
1 Console.WriteLine(p.name);
这句代码和上句代码很相似,只不过一个是设置字段name的值,一个是要获取到字段name的值,他们首先都需要在内存中找到字段name。
深入理解
知道了上边的知识,我们再分析一下下面这段代码:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 //声明两个int类型的变量 6 int variable1 = 20; 7 int variable2 = variable1; 8 //查看variable1和variable2是不是同一个实例 9 Console.WriteLine(int.ReferenceEquals(variable1,variable2));//False 10 //修改variable1的值 11 variable1 = 40; 12 13 Console.WriteLine(variable1);//40 14 Console.WriteLine(variable2);//20 15 16 Console.ReadKey(); 17 } 18 }
首先声明int类型的变量variable1,并对其初始化,当前堆栈中的情况如下图:
然后将变量variable1赋值给新声明的变量variable2,此时堆栈中的情况如下图:
可以看出这一步的赋值操作,其实是将变量variable1中的值复制了一份儿,然后保存到variable2对应的内存空间中,这样我们可以想象到Console.WriteLine(int.ReferenceEquals(variable1,variable2))的输出结果肯定是False,因为variable1和variable2在内存中不是同一个实例,而是相互独立的两块空间。
接下来我们将变量variable1中的值从20是修改为40,堆栈中的情况就变成了下图所示的样子:
我们可以看出对变量variable1的修改并没有影响到变量variable2,所有最后输出时,变量variable1和variable2的值分别为40,20。
接下类我们说一下string类型,我们知道string类型是引用类型,但是它又有一些特别的地方,我们来看下边的代码:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 //定义两个string类型的变量 6 string variable1 = "Hello World!"; 7 string variable2 = variable1; 8 //查看两个变量是否是同一个实例 9 Console.WriteLine(string.ReferenceEquals(variable1, variable2));//True 10 //修改变量variable1的值 11 variable1 = "I'm Changed!"; 12 13 Console.WriteLine(string.ReferenceEquals(variable1, variable2));//False 14 Console.WriteLine(variable1);//I’m Changed! 15 Console.WriteLine(variable2);//Hello World! 16 17 Console.ReadKey(); 18 } 19 }
我们来看这两句代码:
1 string variable1 = "Hello World!"; 2 string variable2 = variable1;
第一行代码在托管堆中开辟内存空间并将值保存在内存中,之后将托管堆中实例对象variable1的内存地址保存在堆栈中,接着定义变量variable2引用variable1,也就是将variable1中保存的内存地址放到variable2中,这样做的结果就是变量variable1和variable2指向了托管堆中的同一个对象,这两行代码执行完毕后,堆栈和托管堆中的情况如下图所示:
所以Console.WriteLine(string.ReferenceEquals(variable1, variable2))的结果就是True,因为两个变量指向了同一个内存地址。
1 variable1 = "I'm Changed!"; 2 3 Console.WriteLine(string.ReferenceEquals(variable1, variable2));//False 4 Console.WriteLine(variable1);//I’m Changed! 5 Console.WriteLine(variable2);//Hello World!
我们改变变量variable1的值,如果我们按照引用类型的的特征来分析的话,下边3行输入语句的输出的结果应该分别是True,”I’m Changed!”,”I’m Changed”,堆栈和托管堆中的情况也应该是这样(不正确的理解):
但是很显然这和正确的输出结果是不一样的,说明上面这种理解是不正确的,那么为什么改变variable1的值之后,variabale1和variable2就不是同一个实例了呢?为什么variable1和variable2中的值不一样了呢?
这就要说到string类型的一个特点,那就是:string类型的变量一旦定义它的值就不能被改变。
当我们改变variable1的值时,不是直接把托管堆中保存的”Hello World!”改成”I’m Changed!”,因为string类型的变量的值是不允许改变的,所以是在内存中新分配一块儿空间,将新的值保存下来,然后将新的内存地址保存到堆栈中,修改variable1的值之后,堆栈和托管堆中情况如下(正确理解):
从图中可以看出,变量variable2仍然指向原来的内存地址0x04C233C2,而variable1指向的是新分配的内存地址,现在两个变量指向的是两个不同的内存地址。
string类型还有另外一个特点:字符串驻留技术。
我们看下边的代码:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 //定义两个string类型的变量 6 string variable1 = "Hello World!"; 7 string variable2 = "Hello World!"; 8 //查看两个变量是否是同一个实例 9 Console.WriteLine(string.ReferenceEquals(variable1, variable2));//True 10 11 Console.ReadKey(); 12 } 13 }
这段代码的运行结果显示变量variable1和variable2指向了同一个实例,也就是说它们在堆栈和托管堆中是这样的关系:
为什么会出现这样的情况呢,我明明定义的是两个不同的变量,为什么它们指向的却是内存中的同一个实例对象呢?
原因是string使用了字符串驻留技术,也就是说当你在定义variable1时,在托管堆中分配了一块空间来保存"Hello World!",当你为variable2赋值时,由于内存中已经存在了”Hello World!”这个字符串,所以就不会再重新分配空间,而是直接把已有的字符串对应的内存地址保存下来,这样就可以节省一些内存空间。