类型是值的蓝图。在以下示例中,我们使用了两个 int 类型的字面量 12 和 30 ,并声明了一个 int 类型的变量 x :
本书的大多数代码需要使用 System 命名空间下的类型。因此除了展示与命名空间相关的概念,从该示例开始我们将忽略“ using System ”语句。
变量表示一个存储位置,其中的值可能会不断变化。与之对应,常量总是表示同一个值(后面会详细介绍):
C#中的所有值都是某一种类型的实例。值或者变量所包含的可能取值均由其类型决定。
预定义类型是指那些由编译器特别支持的类型。 int 就是一种预定义类型,它代表一系列能够存储在32位内存中的整数集,其范围从-2 31 到2 31 -1,并且它是该范围内数字字面量的默认类型。我们能够对int类型的实例执行算术运算等功能:
C#中的另一个预定义类型是 string 。 string 类型表示字符序列,例如“.NET”或者“ http://oreilly.com ”。我们可以通过以下方式调用函数来操作字符串:
上述示例调用了 x.ToString() 来获得表示整数 x 的字符串。我们几乎可以在任何类型的变量上调用 ToString() 方法。
预定义类型 bool 只有两种值: true 和 false 。 bool 类型通常与 if 语句一起控制条件分支执行流程,例如:
在C#中,预定义类型(也称为内置类型)拥有相应的C#关键字。在.NET的 System 命名空间下也包含了很多不是预定义类型的重要类型(例如 DateTime )。
我们能够编写自定义的方法,同样也能够编写自定义类型。以下示例定义了一个名为 UnitConverter 的自定义类型,这个类将作为单位转换的蓝图:
在上述范例中,类的定义与顶级语句都位于同一个文件中。这种写法在顶级语句位于类定义之前时是合法的。若你编写的是一个简单的测试程序,那么这种写法尚可接受。但在大型程序中,标准的做法是将类的定义放在一个独立的文件(例如, UnitConverter.cs )中。
类型包含数据成员和函数成员。 UnitConverter 的数据成员是 ratio 字段,函数成员是 Convert 方法和 UnitConverter 的构造器。
C#的优点之一是其中的预定义类型和自定义类型非常相近。预定义 int 类型是整数的蓝图。它保存了32位的数据,提供像 ToString 这种函数成员来使用这些数据。类似地,我们自定义的 UnitConverter 类型也是单位转换的蓝图。它保存比率数据,还提供了函数成员来使用这些数据。
将类型实例化即可创建数据。预定义类型可以简单地通过字面量进行实例化,例如 12 或 "Hello World" 。而自定义类型则需要使用 new 运算符来创建实例。以下的语句创建并声明了一个 UnitConverter 类型的实例:
紧跟 new 运算符之后是对象的实例化逻辑,以上程序调用对象的构造器执行初始化操作。构造器的定义类似于方法,不同的是它的方法名和返回类型是合并在一起的,并且其名称为所属的类型名称:
对类型实例进行操作的数据成员和函数成员称为实例成员。 UnitConverter 的 Convert 方法和 int 的 ToString 方法都是实例成员。在默认情况下,成员就是实例成员。
不对类型实例进行操作的数据成员和函数成员可以标记为static(静态)。如果需要在类型外引用静态成员,则需要指定类型名称而非类型实例。例如, Console 类的 WriteLine 方法。由于该方法是静态方法,因此调用该方法需要写作 Console.WriteLine() 而不是 new Console().WriteLine() 。
事实上, Console 类是一个静态类,即它的所有成员都是静态的,并且该类型无法实例化。
在下面的代码中,实例字段 Name 属于特定的 Panda 实例,而 Population 则属于所有 Panda 实例。我们将创建两个 Panda 实例,先输出它们的名字,再输出总数:
如果试图求 p1.Population 或者 Panda.Name 的值,则会产生编译时错误。
public 关键字将成员公开给其他类。在上述示例中,如果 Panda 类中的 Name 字段没有标记为公有(public)的,那么它就是私有的,我们就无法在类之外访问它。将成员标记为 public 就是类型的通信手段:“这就是我想让其他类型看到的,而其他的都是我私有的实现细节。”在面向对象的术语中,称类的公有成员封装了私有成员。
命名空间是组织类型的有效手段,对于大型程序尤为如此。以下代码将 Panda 类定义在 Animals 命名空间中:
上述代码在顶级语句前导入了 Animals 命名空间,这样代码就可以访问其中的类型而无须书写全称。如果不导入命名空间,则需要将代码写为:
我们将在本章结束时详细介绍命名空间(请参见2.12节)。
到目前为止,本书的范例均使用了顶级语句(顶级语句是C# 9引入的特性)。
若不使用顶级语句,则简单的命令行或Windows应用程序将如以下程序所示:
如不使用顶级语句,C#将查找静态 Main 方法,并将这个方法作为程序入口点。 Main 方法可以定义在任何类中(并且只能够存在一个 Main 方法)。如果 Main 方法需要访问特定类型的私有成员,则可以将 Main 方法定义在相应类中。这种做法要比顶级语句更简单。
Main 方法可以返回一个整数(而非 void )。该整数将返回到执行环境中(一般非零值代表失败)。 Main 方法也可以接受一个字符串数组作为参数(该数组将包含所有传递给可执行程序的参数),例如:
数组(例如 string[] )表示一种固定数目的特定类型元素。它使用元素类型后接方括号来声明。我们将在2.7节介绍数组。
( Main 方法也可以声明为 async 方法,并返回 Task 或者 Task<int> 以支持异步编程。我们将在第14章介绍该内容。)
C# 9引入的顶级语句可以避免静态 Main 方法及包含该方法的类型。具备顶级语句的文件由以下三部分组成:
1.(可选) using 指令
2.一系列语句,其中也可以包含方法的声明
3.(可选)类型与命名空间声明
例如:
由于CLR并不显式支持顶级语句,因此编译器会将上述代码转换为类似以下形式:
请注意,第2部分(Part 2)是包裹在主方法中的。这意味着 SomeMethod1 和 SomeMethod2 都是局部方法。我们将会在3.1.3.2节中进行完整介绍。而目前最重要的是局部方法(非static的声明)可以访问声明在父级方法中的变量:
这种方式的其他后果就是顶级方法无法从其他类或类型中访问。
顶级语句可以将整数返回给调用者(并非必需),并可以“神奇地”访问 string[] 类型的 args 参数,以对应调用者从命令行中传递给程序的参数。
由于每一个应用程序只可能拥有一个入口,因此在C#项目中最多只能在一个文件里使用顶级语句。
C#可以对兼容类型的实例进行转换操作。转换始终会根据一个已经存在的值创建一个新的值。转换可以是隐式的也可以是显式的,隐式转换自动发生而显式转换需要强制转换。以下示例将一个 int 隐式转换为 long 类型(其存储位数是 int 的两倍),并将一个 int 显式转换为一个 short 类型(其存储位数是 int 的一半):
隐式转换只有在以下条件都满足时才能进行:
· 编译器确保转换总能成功。
· 没有信息在转换过程中丢失 [1] 。
相对地,只有在满足下列条件时才需要显式转换:
· 编译器不能保证转换总是成功。
· 信息在转换过程中有可能丢失。
(如果编译器可以确定某个转换必定失败,那么这两种转换都无法执行。包含泛型的转换在特定情况下也会失败,请参见3.9.11节)
以上的数值转换是C#中内置的。C#还支持引用转换、装箱转换(参见第3章)与自定义转换(参见4.17节)。对于自定义转换,编译器并没有强制满足上述规则,因此没有良好设计的类型有可能在转换时产生意想不到的效果。
C#中的类型可以分为以下几类:
· 值类型
· 引用类型
· 泛型参数
· 指针类型
本节将介绍值类型和引用类型。泛型参数将在3.9节介绍,指针类型将在4.18节中介绍。
值类型包含大多数的内置类型(具体包括所有数值类型、 char 类型和 bool 类型)以及自定义的 struct 类型和 enum 类型。
引用类型包含所有的类、数组、委托和接口类型,其中包括了预定义的 string 类型。
值类型和引用类型最根本的不同在于它们在内存中的处理方式。
值类型的变量或常量的内容仅仅是一个值。例如,内置的值类型 int 的内容是32位的数据。
可以通过 struct 关键字定义自定义值类型(参见图2-1):
或采用更简短的形式:
图2-1:内存中的值类型实例
值类型实例的赋值总是会进行实例复制,例如:
图2-2中展示了p1和p2拥有不同的存储空间。
图2-2:赋值操作复制了值类型的实例
引用类型比值类型复杂,它由对象和对象引用两部分组成。引用类型变量或常量中的内容是一个含值对象的引用。以下示例将重新书写前面例子中的 Point 类型,令其成为一个类而非 struct (参见图2-3):
图2-3:内存中的引用类型实例
给引用类型变量赋值只会复制引用,而不是对象实例。这允许不同变量指向同一个对象,而值类型通常不会出现这种情况。如果 Point 是一个类,那么若重复之前的示例,则对 p1 的操作就会影响到 p2 了:
图2-4展示了 p1 和 p2 是指向同一对象的两个不同引用。
引用可以用字面量 null 来赋值,表示它并不指向任何对象:
图2-4:赋值操作复制了引用
在4.8节中,我们将介绍一种避免意外发生 NullReferenceException 错误的C#功能。
相对地,值类型通常不能为null:
C#中也有一种可令值类型为null的结构,称为可空值类型(Nullable Value Type),请参见4.7节。
值类型实例占用的内存大小就是存储其中的字段所需的内存。例如, Point 需要占用8字节的内存:
从技术上说,CLR用整数倍字段的大小(最大到8字节)来分配内存地址。因此,下面定义的对象实际上会占用16字节的内存(第一个字段的7个字节被“浪费了”):
这种行为可以通过指定 StructLayout 特性来重写(请参见24.6节)。
引用类型需要为引用和对象单独分配存储空间。对象除占用了和字段一样的字节数外,还需要额外的管理空间开销。管理开销的精确值本质上属于.NET运行时实现的细节,但最少也需要8字节来存储该对象类型的键、一些诸如线程锁的状态,以及是否可以被垃圾回收器固定等临时信息。根据.NET运行时工作的平台类型(32位或64位平台),每一个对象的引用都需要额外的4字节或8字节的存储空间。
C#中的预定义类型有:
值类型
· 数值
◆ 有符号整数( sbyte 、 short 、 int 、 long )
◆ 无符号整数( byte 、 ushort 、 uint 、 ulong )
◆ 实数( float 、 double 、 decimal )
· 逻辑值( bool )
· 字符( char )
引用类型
· 字符串( string )
· 对象( object )
C#的预定义类型或称为.NET类型,均位于 System 命名空间下。因而以下两个语句仅在拼写上有所不同:
在CLR中,除了 decimal 之外的一系列预定义值类型属于基元类型。之所以将其称为基元类型是因为它们在编译过的代码中有直接的指令支持,而这种指令通常转换为底层处理器直接支持的指令,例如:
System.IntPtr 以及 System.UIntPtr 类型也是基元类型(参见第24章)。