本文是网页版《C# 12.0 本质论》第二章解读。欲完整跟踪本系列文章,请关注并订阅我的Essential C# 12.0解读专栏。
前言
数据类型(Data Type)是一个很恼人的话题。
似乎根本没必要对数据类型进行展开讲解,因为人人都懂。
但是,数据类型本身又确实比较复杂,不仅涉及到值类型和引用类型,也涉及到内置类型和自定义类型,更涉及到C#类型、.NET类型、通用类型系统CTS、通用语言规范CLS、.NET standard等很多概念。
本文就力争通过示例,把这些概念理清楚。
引例
请看下面程序示例:
namespace DataTypes
{
internal class Program
{
static void Main()
{
DataTypes.Program pro = new DataTypes.Program();
string name = "Julian Zhang";
int age = 58;
Console.WriteLine($"My name is {name}, and I'm {age} years old.");
}
}
}
Main中出现了string、int和DataTypes.Program三种数据类型。
我们都知道,C#语法是区分大小写的,比如你将Program的字符P改成小写(DataTypes.program),编译就无法通过,会出现如下报警:
Error CS0234 The type or namespace name ‘program’ does not exist in the namespace ‘DataTypes’ (are you missing an assembly reference?)
类似地,如果将int改成Int也会报错:
Error CS0246 The type or namespace name ‘Int’ could not be found (are you missing a using directive or an assembly reference?)
但如果将string的首字母改成大写,也就是改成String,系统却不会报错,运行也正常。
为什么会这样?难道C#为字符串类型提供了两个关键字,分别为string和String?但我们到微软官网查一下C#关键字,发现只有string,并没有首字符大写的String,说明这个猜想是错误的。
在说明这个问题之前,我们需要先将话题扯远一些。下面请听我慢慢道来。
CTS与CLS
中间语言的价值
上一篇开始部分,我们引用了Serge Lidin的名著《Expert .NET 2.0 IL Assembler》中的一张图,展示了托管应用程序的生成与执行模型。在那种图中,C#源代码(Source Code)经C#编译器(Managed Compiler)编译链接以后,生成了程序集(Managed Module)文件。程序集文件中其实并不包含CPU可以直接执行的本机代码(Native Code),Managed Module中只有文件头、Metadata和IL代码。
我们可以把IL看作是另外一种高级语言,IL代码还必须再经过IL编译器编译,变成成本机代码以后,才能被CPU执行。
那么,微软为什么要搞一个IL作为二传手?为什么不像C/C++一样直接编译成本机代码?
IL是Intermediate Language的简称,也就是中间语言。使用中间语言过渡有很多优点,而这也恰恰是.NET平台的本质所在。下面我们通过一个故事来说明其中的道理。
我们知道,C语言非常流行,无论在Windows平台、Linux平台或MacOS系统,都有不错的C语言编译器支持。
假设张三想开发一种新的编程语言,假设就叫CA语言吧。如果CA语言编译器不是将CA源程序编译成本机代码,而是编译成C语言源程序,那么我们就可以使用CA语言来写C程序了。此后,只要我们再用不同平台的C编译器编译一遍,该程序就可以运行在对应平台上。
至于为什么不直接使用C而要发明CA,可能是因为C的语法太复杂,或者C本身不够安全,或者是其他任何原因,总之原理上讲,是可以这样做的。
另外,正因为CA语言会被编译成C,所以CA语言根本没必要再搞一套数据类型,直接使用C语言的数据类型即可,比如CA完全可以将如下C类型直接拿来使用:
- char:用于表示字符或小整数,占用1字节。
- short:用于表示短整数,占用2字节。
- int:用于表示整数,通常为4字节。
- long:用于表示长整数,通常为4或8字节。
- long long:用于表示更长的整数,占用8字节。
- float:用于表示单精度浮点数,占用4字节。
- double:用于表示双精度浮点数,占用8字节。
- long double:用于表示更高精度的浮点数,通常为8或16字节。
- bool:用于表示真(true)或假(false)的值。
- 指针和引用类型
- 等等…
至此,虽然通过CA来写C程序的理由还基本说得过去,但总感觉依旧有些牵强,似乎必要性也不是很高。
接下来,李四受到张三启发,也考虑发明另外一种编程语言CB,并决定CB的语法主要参考Visual Basic,但CB也是被编译成C源代码,然后再用C编译成本机代码,且CB也直接使用C语言类型。
后来王五、赵六又分别发明了CC和CD语言,也是以C语言为底层,且直接使用C的数据类型。
此时,CA、CB、CC、CD这四种编程语言之间就出现了一个很好特性:他们都直接使用C语言作为底层语言,所以他们的函数调用约定完全相同,程序之间的互相调用无需考虑从左向右压栈还是从右向左压栈,也无需考虑由调用者清理栈参数还是由被调用函数清理栈参数,更重要的是,大家最后生成的都是C语言代码,都使用C语言的类型,于是互相调用时根本无需考虑类型转换,这为不同语言间共享代码提供了极大便利。
于是,这种模型的好处渐渐凸显:不仅可以方便地编译成不同平台程序,而且还可以互相调用对方代码。也就是说,无论程序员喜欢CA语法还是CB语法,最终的程序并不存在差异。
所以,这种使用C作为中间语言的平台结构,为跨平台应用开发、代码相互代码调用及数据类型统一奠定了坚实基础。
回到我们现实的C#和IL语言,C#就类似于上述的CA - CD,而IL就类似于上述的C语言。
如今,在.NET大厦,不仅入驻了C#,还入驻了F#, VB.NET, IronPython, C/CLI等众多语言,他们都可以类比成上述故事中的CA - CD语言,只不过底层不是C,而是IL语言。
那么,微软为什么没有直接使用C作为中间语言,而是重新发明轮子,又搞了一个IL语言?
中间语言的设计原则
作为中间语言,最好具备如下特点:
- 原子性:只包含最基本的指令,比如加减乘除、读写存储器、子程序调用和跳转分支,而不要有太复杂的指令,确保指令条数有限;
- 指令代码短:指令代码尽可能短,这样编译出来的中间代码就短,再次编译时就快,且少占内存和硬盘空间;
- 最好和CPU硬件无关,这样才便于在不同平台使用,所以最好不要直接使用特定与CPU的寄存器;
正因为基于以上考虑,微软发现现有的编程语言均难堪此任,所以才搞了IL语言(微软最初称其为MSIL,后来ECMA称其为CIL,C#程序员则一般直接称其IL)。
MSIL实现了以上原则,指令数量极少,非常容易学习。为了减少指令数量,并确保指令字节短且CPU硬件无关,IL不再使用寄存器,所有操作均通过栈进行,所以绝大多数指令基本只有一个字节操作码。
可以说,IL语言的发明,为.NET平台建设奠定了第一块基石。
IL类型
既然IL语言作为其他.NET语言的底层,那么IL语言就应该定义自己的类型系统,比如int, string,class, struct等等。
IL语言设计之初就将其规划成全面支持面向对象的结构,所以虽然其原子性与CPU指令类似,但本身已完全支持面向对象编程理念。其类型系统设计也就直接提供了面向对象支持。下面举例几个IL直接定义好的类型:
string
int16
int32
int64
float32
float64
object
但是,此时微软意识到,要想吸引全球程序员都愿意基于IL开发新的编程语言,就有必要对IL进一步标准化,比如:
IL语言语法
IL数据类型系统
IL被编译后的文件格式
IL被编译后的文件如何运行?
IL需提供哪些标准库?
IL标准库需提供哪些基本函数?
于是,微软开始着手制定一系列标准,并成功将这些标准推广成国际标准(ECMA/ISO)。微软将这一些列标准统称为.NET平台,可以说.NET平台是微软对IL的更高层次抽象,而IL则成为.NET平台的一部分,或者说,.NET平台包含IL语言。而ECMA/ISO则将.NET平台又改名为CLI(Common Language Infrastructure)。
这种抽象过程中,标准制定者发现,有必要进一步将IL类型系统区分成CTS和CLS,于是IL类型系统就成了仅供IL语言使用的类型,而以前直接使用IL类型的高级语言,则改成直接使用CTS类型。有关CLS我们稍后再说,首先接介绍一下CTS。
CTS
ECMA将CLI中定义的所有类型的集合定义为CTS,全称是Common Type System。其中的Common已经很容易理解,也就是可供所有基于CLI平台的高级语言共同使用的类型系统。
事实上,IL语言实现了CTS的所有类型,只不过为防止以后万一需要将IL和CTS分开,所以单独定义了CTS,并且如此区分以后,ECMA规定,任何基于CLI开发的高级语言,都可以直接使用CTS类型名。
下面截图是ECMA-335中列举CLI内置类型:
最左侧列是IL类型名,红框中是对应的CTS类型名,虽然大小写不同,有些名称也不同,还有CTS使用了名称空间并以半角点将名称空间和类型名进行了分隔,但同行中两个名称的含义是完全等同的。
基于以上解释,我们知道了如下基本道理:
- 因为任何基于CLI的高级语言都可以直接使用其支持的CTS名称,而VB.NET及C#都是基于CLI的高级语言,所以C#中可以直接使用System.String,也可以直接使用System.Int32,但不可以直接使用System.Int,因为它不存在。
- 因为我们的C#程序使用了.NET 8.0框架,默认项目文件中ImplicitUsing选项是enable的,也就是相当于已经有了using System,所以System.String可以被简写为String,同理System.Int32也可以被简写成Int32;但如果将这个enable改为disable,那么就必须加上System名称空间。
至此,我们已经知道,C#中可以使用String并不是C#语法的原因,而是CLI标准的原因,在VB.NET中也同样可以使用CLI类型。
那么,全小写的string或int又是怎么回事儿呢?这当然是C#编译器设计者的决定了。这些语言发明者觉得,System.String或System.Int32写起来太长,不方便,于是就规定了使用string作为System.String的别名,使用int作为System.Int32的别名。于是,string和int就成了C#语言的关键字。
CLS
既然CLI被设计为底层框架,那么CLI的CTS系统定义的类型就必须足够丰富。可以想象一下,如果CTS中只有两种类型,比如:
- 十六位的整型:System.Int16
- 32位浮点数:System.Single
我相信任何人都不会愿意基于CLI开发上级高级语言,因为数据类型太少了,根本不够用!
所以,CLI为了兼顾各种应用场景需求,在CTS设计上包含了非常丰富的数据类型。
不过,数据类型如果太多,高级语言的设计就会比较复杂,而且最重要的是众口难调。比如CA语言设计者认为,整型只要有8位~64位有符号型就够了,但CB设计者认为无符号整数其实很有用,有助于减少空间占用。这种冲突会对相互调用造成问题,比如CB调用CA返回了一个十六位的数据EF05h,因为CA只使用有符号整数,所以CA返回的EF05h其实想代表负数-4347;但CB支持16位无符号数,且将这个返回值赋值给了一个无符号变量x,那么x将被解释成正数61189。
类似的问题要如何协调?
于是,CLI在CTS的基础上,又抽象出一个新概念叫CLS(Common Luanguage Specification)。
CLS规定了所有高级语言都必须支持的一组数据类型,CLS是CTS的子集,只要大家都确保符合CLS,那么互相调用就不会发生问题。
上图举例了三种基于CTS的编程语言,分别是N#(某种假想的语言)、C#和VB,其中C#和VB是符合CLS的语言,但N#是不符合CLS的语言,这张图包含了以下三层含义:
- VB和C#语言都提供了对CLS的支持,意思是说VB和C#都对CLS规定了类型提供了全部实现;
- N#是不符合CLS的语言,所以至少有一种CLS类型未被N#实现,所以即便声称符合CLS的程序,被N#调用也可能出现问题;
- 并非VB或C#编写的程序自动符合CLS,而是说如果某程序声称符合CLS,那么一定可以被VB或C#安全调用;
至此,我们已经弄清了CLS。之前我们提到的CLI类型表的第二列(CLS Type)的含义也就不言自明了:所有CLS支持的类型就标记为Yes,否则就标记为No。
那么,C#语言支持的数据类型中,哪些是符合CLS的呢?借用一张网上找到的图来说明:
也就是说,如果一个C#应用程序对外提供的接口成员(公共方法的参数、返回值以及自定义类型中所有可被外部访问的成员)如果均使用了上表中CLS标记为“是”的类型,那么这个程序就是满足CLS规范的程序,其他基于CLI的高级语言可以放心调用。否则就是CLS不兼容,需要格外小心使用。
以上,我们完成了CLI数据类型、C#数据类型、IL数据类型、CTS和CLS的体系介绍。
接下来我们先简单说一下值类型和引用类型。
值类型和引用类型
CLI规定,从存储模式角度,数据类型可以分为值类型和引用类型两类,如下图所示:
一般教科书会说,值类型保存在栈空间,引用类型保存在堆空间。不过这种说法比较模棱两可,并不严密。
严格一点说,无论值类型变量还是引用类型变量都保存在栈空间中。变量其实就是一个栈空间的地址。
如果该变量是值类型,那么该变量对应的栈空间中就会直接保存这个数值。比如int x = 10定义了一个值类型变量x,那么x其实就是内存栈空间一个内存地址,它是编译迁建由编译器和链接器确定的,假设是0x00101010h,那么 x = 10 这条指令就会将 0x00101010h写上数据10。如果后续又有指令 x = 20,那么会立即将0x00101010h改为20。
如果一个变量是引用类型,比如Person person = new Person(),那么,同值类型变量一样,也是在编译期间编译器和链接器就会为person变量生成一个栈空间地址,比如0x00101018h。程序运行到new指令时,CLR会首先到堆空间申请一块可以容纳Person类型的堆空间,然后将堆空间的首地址存入0x00101018h地址的内存中,接下来再调用Person构造函数堆堆空间这个对象进行初始化,也就是在初始化修改person实例数据时,person变量中一直是最初申请的地址,并不会发生变化。无论之后该对象的字段发生什么变化,person变量中的内容都会保持不变。
以后机会合适时,我会通过具体实例对此做进一步说明。暂时先了解到这个程度即可。
程序的执行方式
自高级编程语言产生之时开始,就存在解释方式和编译方式两种程序执行模式。
典型的解释型程序语言有BASIC和JavaScript,他们的源程序无需经过编译,而是在运行期间依赖于一个被称为解释器的程序来实现运行。解释器的作用就是一句源代码一句源代码地翻译,每翻译一句,CPU就执行一句。
编译型语言,如C/C++/C#则与解释型语言不同,他们运行前需先经过编译步骤,将源代码编译成可执行代码。当然,如前所述,C#编译后的代码与C/C++又有所不同,C#源文件最终被编译成了程序集,里面除了文件头,就只有metadata和IL代码,仍不能直接被CPU运行,还需要再次经过IL编译器编译。
之前我们假设C语言作为中间语言的CA - CD语言,首先会被编译成C语言源代码,然后再用C编译器将其编译成本机代码。但IL代码文件的执行方式与我们假想的C作为中间语言不同,IL代码虽然不能直接被CPU执行,但并不需要再次被编译成本机代码,而是可以直接运行。只不过运行的时候需要一个类似于BASIC解释器的软件帮助,这个软件由.NET平台提供,称为CLR。每次IL代码文件被加载到内存时,CLR总是会被操作系统同时加载,然后CLR中的JIT编译器会实时将IL编译成本机代码,然后再交给CPU执行。
正因为有了CLR的即时编译能力,所以才使.NET托管程序真正具备了跨平台特性:同一套代码,可以在任何安装有CLR的环境下直接运行,也就是可以将一次性编译后的dll文件同时拷贝到Windows、Linux和MacOS,都可以使用dotnet命令直接运行。
至此,我们才算基本说清楚了.NET与C#的关系:
- .NET是微软对基于IL语言的底层一组服务的概括性抽象,.NET符合ECMA-335规定的CLI标准,但又对CLI有所扩展;
- CLR可以看成是微软针对CLI中VES(Virtual Executing System)即虚拟执行系统的实现,有些微软文档也称其为EE(Executing Engine);
- IL是.NET平台或者说是CLI的组成部分,但C#等高级语言与CLI或.NET之间不存在包含关系,建立在.NET平台基础上的高级语言,是遵从CLI标准的高级语言具体实现。
- 只有IL语言完整实现了CTS,所有其他高级语言都未实现CTS的全部功能,也就是说,只有IL语言才是.NET平台的功能霸主。
内置类型
如果我们自己定义了一个类型,假设是MyNameSpace.Employee,单独编译成了一个类库MyType.dll。如果我们需要在自己的应用程序中调用,首先必须在项目中加入对MyType.dll的引用,然后才能在我们的代码中使用这个Employee类。
但是,你无需引用任何dll,直接就可以在C#程序中使用int, string等类型。
无需提供任何显式引用,编译器天生就支持的类型,称为内置类型。对于C#,所有C#关键字类型都是内置类型。
内置类型并非空穴来风,内置类型也是预定义类型,只不过是.NET平台帮我们预定义好的类型而已。
比如我们在int 类型上F12,会发现其定义文件前三行内容如下:
#region Assembly System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
// C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\8.0.3\ref\net8.0\System.Runtime.dll
#endregion
也就是说,int是定义在System.Runtime.dll文件中的,只不过C#编译器自动认识这个类型,能自动找到合适的定义入口而已。
与此类似,如果查看string类型,会发现,它其实定义在:
System.Private.CoreLib.dll中。
类型的内涵与外延
当我们使用自定义类型时,比如使用如下语句:
MyNameSpace.Employee emp = new MyNameSpace.Employee();
首先,类型决定了emp对象的内存布局:共占多少字节?每个字节的二进制位含义?各字节的前后关系,等等;
其次,类型决定了可以在该类型上施加哪些操作。比如只有该类型定义了SayHello()方法,你才能在emp变量上使用SayHello,不能使用未在该类型中定义的方法。
对于可施加的操作,除了方法,还有操作符重载、类型转换等。比如查一下Int32类型的源代码,你会发现,看似最简单的int类型,其源文件竟有1460多行。int型之所以可以进行加减乘除运算,是因为 源代码中定义了这些操作,比如:
加法操作(+):
/// <inheritdoc cref="IAdditionOperators{TSelf, TOther, TResult}.op_Addition(TSelf, TOther)" />
static int IAdditionOperators<int, int, int>.operator +(int left, int right) => left + right;
自增1操作(++):
/// <inheritdoc cref="IIncrementOperators{TSelf}.op_Increment(TSelf)" />
static int IIncrementOperators<int>.operator ++(int value) => ++value;
二进制左移位操作(<<):
/// <inheritdoc cref="IShiftOperators{TSelf, TOther, TResult}.op_LeftShift(TSelf, TOther)" />
static int IShiftOperators<int, int, int>.operator <<(int value, int shiftAmount) => value << shiftAmount;
转换成字符串操作(string.TryParse()):
/// <inheritdoc cref="IParsable{TSelf}.TryParse(string?, IFormatProvider?, out TSelf)" />
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out int result) => TryParse(s, NumberStyles.Integer, provider, out result);
因此我们说,类型定义的目的,不仅为了告诉编译器该如何为类型分配内存,如何初始化类型,如何在编译期检查是否存在非法操作,而且还能告诉CLR,在运行时如何检查操作是否合法。
数字类型
这部分内容基本直接引用了原书。我们每天都在和数字类型打交道,所以即便简单,也应引起足够重视。
整型
整型总计有十种,没什么好说的,仅列出表格:
浮点型
浮点型有两种,一种是32位的float,另一种是64位的double。
首先需要注意,浮点型是非精确数据,存在精度损失,所以不要用浮点型进行全等比较。比如无论是float还是double,都不能精确表达0.1,因为浮点数使用的是二进制指数格式存储数据,十进制的0.1转换成二进制是无限位。
浮点数表达方式比较复杂,权威标准是IEEE754或ISO IEC 60559-2020。曾经,我研究得很明白,但过一段时间以后,还是会再次模糊。不过,依旧建议大家有时间时彻底搞懂一次,然后,即便忘记了细节,也不会再对浮点数感觉恐惧。
下面两张图,代表了浮点数最核心的要点:
decimal
注意: decimal外观很像浮点数,比如123.4567M就是一个decimal类型,但decimal的二进制表示与float截然不同,它不是使用二进制转换得到的,而是直接用十进制数据位和指数位做存储。
所以,decimal类型可以精确表示十进制数据,比如0.1M就是精确的十进制0.1,无丝毫含糊。
前文提到,不要对浮点数比较全等;但decimal可以比较全等。
待续
类型系统本身就是一个非常复杂的话题,比如浮点数在内存中是如何存储的?Unicode到底是怎么回事?类和实例在内存中的具体存储方式?CLR类型数据结构与CTS之间的关系等等。因为此话题涉及的领域众多,所以本文只能分阶段续写,请留意日后更新。