C# 类型系统

C# 是一种强类型语言。 每个变量和常量都有一个类型,每个求值的表达式也是如此。 每个方法声明都为每个输入参数和返回值指定名称、类型和种类(值、引用或输出)。 .NET 类库定义了内置数值类型和表示各种构造的复杂类型。 其中包括文件系统、网络连接、对象的集合和数组以及日期。 典型的 C# 程序使用类库中的类型,以及对程序问题域的专属概念进行建模的用户定义类型。

类型中可存储的信息包括以下项:

  • 类型变量所需的存储空间。
  • 可以表示的最大值和最小值。
  • 包含的成员(方法、字段、事件等)。
  • 继承自的基类型。
  • 它实现的接口。
  • 允许执行的运算种类。

编译器使用类型信息来确保在代码中执行的所有操作都是类型安全的。 例如,如果声明 int 类型的变量,那么编译器允许在加法和减法运算中使用此变量。 如果尝试对 bool 类型的变量执行这些相同操作,则编译器将生成错误,如以下示例所示:

int a = 5;
int b = a + 2; //OK

bool test = true;

// Error. Operator '+' cannot be applied to operands of type 'int' and 'bool'.
int c = a + test;

注意

C 和 C++ 开发人员请注意,在 C# 中,bool 不能转换为 int

编译器将类型信息作为元数据嵌入可执行文件中。 公共语言运行时 (CLR) 在运行时使用元数据,以在分配和回收内存时进一步保证类型安全性。

在变量声明中指定类型

当在程序中声明变量或常量时,必须指定其类型或使用 var 关键字让编译器推断类型。 以下示例显示了一些使用内置数值类型和复杂用户定义类型的变量声明:

// Declaration only:
float temperature;
string name;
MyClass myClass;

// Declaration with initializers (four examples):
char firstLetter = 'C';
var limit = 3;
int[] source = { 0, 1, 2, 3, 4, 5 };
var query = from item in source
            where item <= limit
            select item;

方法声明指定方法参数的类型和返回值。 以下签名显示了需要 int 作为输入参数并返回字符串的方法:

public string GetName(int ID)
{
    if (ID < names.Length)
        return names[ID];
    else
        return String.Empty;
}
private string[] names = { "Spencer", "Sally", "Doug" };

声明变量后,不能使用新类型重新声明该变量,并且不能分配与其声明的类型不兼容的值。 例如,不能声明 int 后再向它分配 true 的布尔值。 不过,可以将值转换成其他类型。例如,在将值分配给新变量或作为方法自变量传递时。 编译器会自动执行不会导致数据丢失的类型转换。 如果类型转换可能会导致数据丢失,必须在源代码中进行显式转换

有关详细信息,请参阅显式转换和类型转换

内置类型

C# 提供了一组标准的内置类型。 这些类型表示整数、浮点值、布尔表达式、文本字符、十进制值和其他数据类型。 还有内置的 stringobject 类型。 这些类型可供在任何 C# 程序中使用。 有关内置类型的完整列表,请参阅内置类型

自定义类型

可以使用 structclassinterfaceenumrecord 构造来创建自己的自定义类型。 .NET 类库本身是一组自定义类型,以供你在自己的应用程序中使用。 默认情况下,类库中最常用的类型在任何 C# 程序中均可用。 其他类型只有在显式添加对定义这些类型的程序集的项目引用时才可用。 编译器引用程序集之后,你可以声明在源代码的此程序集中声明的类型的变量(和常量)。 有关详细信息,请参阅 .NET 类库

通用类型系统

对于 .NET 中的类型系统,请务必了解以下两个基本要点:

  • 它支持继承原则。 类型可以派生自其他类型(称为基类型)。 派生类型继承(有一些限制)基类型的方法、属性和其他成员。 基类型可以继而从某种其他类型派生,在这种情况下,派生类型继承其继承层次结构中的两种基类型的成员。 所有类型(包括 System.Int32 (C# keyword: int) 等内置数值类型)最终都派生自单个基类型,即 System.Object (C# keyword: object)。 这样的统一类型层次结构称为通用类型系统 (CTS)。 若要详细了解 C# 中的继承,请参阅继承
  • CTS 中的每种类型被定义为值类型或引用类型。 这些类型包括 .NET 类库中的所有自定义类型以及你自己的用户定义类型。 使用 struct 关键字定义的类型是值类型;所有内置数值类型都是 structs。 使用 classrecord 关键字定义的类型是引用类型。 引用类型和值类型遵循不同的编译时规则和运行时行为。

下图展示了 CTS 中值类型和引用类型之间的关系。

屏幕截图显示了 CTS 值类型和引用类型。

注意

你可能会发现,最常用的类型全都被整理到了 System 命名空间中。 不过,包含类型的命名空间与类型是值类型还是引用类型没有关系。

类和结构是 .NET 通用类型系统的两种基本构造。 C# 9 添加记录,记录是一种类。 每种本质上都是一种数据结构,其中封装了同属一个逻辑单元的一组数据和行为。 数据和行为是类、结构或记录的成员。 这些行为包括方法、属性和事件等,本文稍后将具体列举。

类、结构或记录声明类似于一张蓝图,用于在运行时创建实例或对象。 如果定义名为 Person 的类、结构或记录,则 Person 是类型的名称。 如果声明和初始化 Person 类型的变量 p,那么 p 就是所谓的 Person 对象或实例。 可以创建同一 Person 类型的多个实例,每个实例都可以有不同的属性和字段值。

类是引用类型。 创建类型的对象后,向其分配对象的变量仅保留对相应内存的引用。 将对象引用分配给新变量后,新变量会引用原始对象。 通过一个变量所做的更改将反映在另一个变量中,因为它们引用相同的数据。

结构是值类型。 创建结构时,向其分配结构的变量保留结构的实际数据。 将结构分配给新变量时,会复制结构。 因此,新变量和原始变量包含相同数据的副本(共两个)。 对一个副本所做的更改不会影响另一个副本。

记录类型可以是引用类型 (record class) 或值类型 (record struct)。

一般来说,类用于对更复杂的行为建模。 类通常存储计划在创建类对象后进行修改的数据。 结构最适用于小型数据结构。 结构通常存储不打算在创建结构后修改的数据。 记录类型是具有附加编译器合成成员的数据结构。 记录通常存储不打算在创建对象后修改的数据。

值类型

值类型派生自System.ValueType(派生自 System.Object)。 派生自 System.ValueType 的类型在 CLR 中具有特殊行为。 值类型变量直接包含其值。 结构的内存在声明变量的任何上下文中进行内联分配。 对于值类型变量,没有单独的堆分配或垃圾回收开销。 可以声明属于值类型的 record struct 类型,并包括记录的合成成员。

值类型分为两类:structenum

内置的数值类型是结构,它们具有可访问的字段和方法:

// constant field on type byte.
byte b = byte.MaxValue;

但可将这些类型视为简单的非聚合类型,为其声明并赋值:

byte num = 0xA;
int i = 5;
char c = 'Z';

值类型已密封。 不能从任何值类型(例如 System.Int32)派生类型。 不能将结构定义为从任何用户定义的类或结构继承,因为结构只能从 System.ValueType 继承。 但是,一个结构可以实现一个或多个接口。 可将结构类型强制转换为其实现的任何接口类型。 这将导致“装箱”操作,以将结构包装在托管堆上的引用类型对象内。 当你将值类型传递给使用 System.Object 或任何接口类型作为输入参数的方法时,就会发生装箱操作。 有关详细信息,请参阅装箱和取消装箱

使用 struct 关键字可以创建你自己的自定义值类型。 结构通常用作一小组相关变量的容器,如以下示例所示:

public struct Coords
{
    public int x, y;

    public Coords(int p1, int p2)
    {
        x = p1;
        y = p2;
    }
}

有关结构的详细信息,请参阅结构类型。 有关值类型的详细信息,请参阅值类型

另一种值类型是enum。 枚举定义的是一组已命名的整型常量。 例如,.NET 类库中的 System.IO.FileMode 枚举包含一组已命名的常量整数,用于指定打开文件应采用的方式。 下面的示例展示了具体定义:

public enum FileMode
{
    CreateNew = 1,
    Create = 2,
    Open = 3,
    OpenOrCreate = 4,
    Truncate = 5,
    Append = 6,
}

System.IO.FileMode.Create 常量的值为 2。 不过,名称对于阅读源代码的人来说更有意义,因此,最好使用枚举,而不是常量数字文本。 有关详细信息,请参阅 System.IO.FileMode

所有枚举从 System.Enum(继承自 System.ValueType)继承。 适用于结构的所有规则也适用于枚举。 有关枚举的详细信息,请参阅枚举类型

引用类型

定义为 classrecorddelegate、数组或 interface 的类型是 reference type

在声明变量 reference type 时,它将包含值 null,直到你将其分配给该类型的实例,或者使用 new 运算符创建一个。 下面的示例演示了如何创建和分配类:

MyClass myClass = new MyClass();
MyClass myClass2 = myClass;

无法使用 new 运算符直接实例化 interface。 而是创建并分配实现接口的类实例。 请考虑以下示例:

MyClass myClass = new MyClass();

// Declare and assign using an existing value.
IMyInterface myInterface = myClass;

// Or create and assign a value in a single statement.
IMyInterface myInterface2 = new MyClass();

创建对象时,会在托管堆上分配内存。 变量只保留对对象位置的引用。 对于托管堆上的类型,在分配内存和回收内存时都会产生开销。 “垃圾回收”是 CLR 的自动内存管理功能,用于执行回收。 但是,垃圾回收已是高度优化,并且在大多数情况下,不会产生性能问题。 有关垃圾回收的详细信息,请参阅自动内存管理

所有数组都是引用类型,即使元素是值类型,也不例外。 数组隐式派生自 System.Array 类。 可以使用 C# 提供的简化语法声明和使用数组,如以下示例所示:

// Declare and initialize an array of integers.
int[] nums = { 1, 2, 3, 4, 5 };

// Access an instance property of System.Array.
int len = nums.Length;

引用类型完全支持继承。 创建类时,可以从其他任何未定义为密封的接口或类继承。 其他类可以从你的类继承并替代虚拟方法。 若要详细了解如何创建你自己的类,请参阅类、结构和记录。 有关继承和虚方法的详细信息,请参阅继承

文本值的类型

在 C# 中,文本值从编译器接收类型。 可以通过在数字末尾追加一个字母来指定数字文本应采用的类型。 例如,若要指定应按 float 来处理值 4.56,则在该数字后追加一个“f”或“F”,即 4.56f。 如果没有追加字母,那么编译器就会推断文本值的类型。 若要详细了解可以使用字母后缀指定哪些类型,请参阅整型数值类型浮点数值类型

由于文本已类型化,且所有类型最终都是从 System.Object 派生,因此可以编写和编译如下所示的代码:

string s = "The answer is " + 5.ToString();
// Outputs: "The answer is 5"
Console.WriteLine(s);

Type type = 12345.GetType();
// Outputs: "System.Int32"
Console.WriteLine(type);

泛型类型

可使用一个或多个类型参数声明的类型,用作实际类型(具体类型)的占位符 。 客户端代码在创建类型实例时提供具体类型。 这种类型称为泛型类型。 例如,.NET 类型 System.Collections.Generic.List<T> 具有一个类型参数,它按照惯例被命名为 T。 当创建类型的实例时,指定列表将包含的对象的类型,例如 string

List<string> stringList = new List<string>();
stringList.Add("String example");
// compile time error adding a type other than a string:
stringList.Add(4);

通过使用类型参数,可重新使用相同类以保存任意类型的元素,且无需将每个元素转换为对象。 泛型集合类称为强类型集合,因为编译器知道集合元素的具体类型,并能在编译时引发错误,例如当尝试向上面示例中的 stringList 对象添加整数时。 有关详细信息,请参阅泛型

隐式类型、匿名类型和可以为 null 的值类型

你可以使用 var 关键字隐式键入一个局部变量(但不是类成员)。 变量仍可在编译时获取类型,但类型是由编译器提供。 有关详细信息,请参阅隐式类型局部变量

不方便为不打算存储或传递外部方法边界的简单相关值集合创建命名类型。 因此,可以创建匿名类型。 有关详细信息,请参阅匿名类型

普通值类型不能具有 null 值。 不过,可以在类型后面追加 ?,创建可为空的值类型。 例如,int? 是还可以包含值 nullint 类型。 可以为 null 的值类型是泛型结构类型 System.Nullable<T> 的实例。 在将数据传入和传出数据库(数值可能为 null)时,可为空的值类型特别有用。 有关详细信息,请参阅可以为 null 的值类型

编译时类型和运行时类型

变量可以具有不同的编译时和运行时类型。 编译时类型是源代码中变量的声明或推断类型。 运行时类型是该变量所引用的实例的类型。 这两种类型通常是相同的,如以下示例中所示:

string message = "This is a string of characters";

在其他情况下,编译时类型是不同的,如以下两个示例所示:

object anotherMessage = "This is another string of characters";
IEnumerable<char> someCharacters = "abcdefghijklmnopqrstuvwxyz";

在上述两个示例中,运行时类型为 string。 编译时类型在第一行中为 object,在第二行中为 IEnumerable<char>

如果变量的这两种类型不同,请务必了解编译时类型和运行时类型的应用情况。 编译时类型确定编译器执行的所有操作。 这些编译器操作包括方法调用解析、重载决策以及可用的隐式和显式强制转换。 运行时类型确定在运行时解析的所有操作。 这些运行时操作包括调度虚拟方法调用、计算 isswitch 表达式以及其他类型的测试 API。 为了更好地了解代码如何与类型进行交互,请识别哪个操作应用于哪种类型。

有关详细信息,请参阅以下文章:

C# 语言规范

有关详细信息,请参阅 C# 语言规范。 该语言规范是 C# 语法和用法的权威资料。