C-12-口袋参考-二-

107 阅读39分钟

C#12 口袋参考(二)

原文:zh.annas-archive.org/md5/97bc15629f1b51a0671040c56db61b92

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:空值操作符

C# 提供了三个操作符,用于更方便地处理空值:null 合并运算符null 条件运算符null 合并赋值运算符

空值合并运算符

?? 运算符是 null*-*合并运算符。它表示,“如果左边的操作数非空,则将其给我;否则,给我另一个值。”例如:

string s1 = null;
string s2 = s1 ?? "nothing"; // s2 evaluates to "nothing"

如果左侧表达式非空,则不会评估右侧表达式。空值合并运算符也适用于可空值类型(参见“可空值类型”)。

空值合并赋值运算符

??= 运算符(C# 8 中引入)是 null 合并赋值运算符。它表示,“如果左边的操作数为空,则将右操作数赋给左操作数。”考虑以下示例:

myVariable ??= someDefault;

这等效于:

if (myVariable == null) myVariable = someDefault;

空值条件运算符

?.运算符是空值条件或“Elvis”运算符。它允许您调用方法和访问成员,就像标准点运算符一样,但如果左边的操作数为空,则表达式会评估为 null,而不是抛出NullReferenceException

System.Text.StringBuilder sb = null;
string s = sb?.ToString();   // No error; s is null

最后一行等同于此内容:

string s = (sb == null ? null : sb.ToString());

空值条件表达式也适用于索引器:

string[] words = null;
string word = words?[1];   // word is null

遇到空值时,Elvis 运算符会短路表达式的其余部分。在以下示例中,即使在ToString()ToUpper()之间使用了标准点运算符,s仍然评估为 null:

System.Text.StringBuilder sb = null;
string s = sb?.ToString().ToUpper();   // No error

只有在左边的操作数可能为空时,才需要重复使用 Elvis 运算符。以下表达式可以确保x为空或者x.y为空时也能正常运行:

x?.y?.z

这等同于以下内容(不同之处在于x.y只会被评估一次):

x == null ? null 
          : (x.y == null ? null : x.y.z)

最后的表达式必须能够接受空值。以下示例是非法的,因为int不能接受空值:

System.Text.StringBuilder sb = null;
int length = sb?.ToString().Length;   // Illegal

我们可以通过使用可空值类型来修复此问题(参见“可空值类型”):

int? length = sb?.ToString().Length;
// OK : int? can be null

您还可以使用空值条件运算符来调用一个void方法:

someObject?.SomeVoidMethod();

如果someObject为空,这将成为“无操作”,而不是抛出NullReferenceException

空值条件运算符可以与我们在“类”中描述的常用类型成员一起使用,包括方法字段属性索引器。它还可以与空值合并运算符很好地结合使用:

System.Text.StringBuilder sb = null;
string s = sb?.ToString() ?? "nothing";
// s evaluates to "nothing"

第十一章:语句

函数由按照它们出现的文本顺序依次执行的语句组成。语句块是出现在大括号之间的一系列语句({}标记)。

声明语句

变量声明引入一个新变量,并可选地使用表达式进行初始化。您可以在逗号分隔的列表中声明多个相同类型的变量。例如:

bool rich = true, famous = false;

常量声明类似于变量声明,但在声明后不能更改,并且初始化必须与声明同时进行(更多内容请参阅“常量”):

const double c = 2.99792458E08;

局部变量作用域

局部变量或局部常量的作用域延伸到当前块。您不能在当前块或任何嵌套块中声明另一个同名的局部变量。

表达式语句

表达式语句是有效的语句也是表达式。实际上,这意味着“做”某事的表达式;换句话说:

  • 分配或修改变量

  • 实例化对象

  • 调用方法

不执行这些操作的表达式不是有效的语句:

string s = "foo";
s.Length;          // Illegal statement: does nothing!

当您调用返回值的构造函数或方法时,您不必使用该结果。但是,除非构造函数或方法更改状态,否则该语句是无用的:

new StringBuilder();     // Legal, but useless
x.Equals (y);            // Legal, but useless

选择语句

选择语句有条件地控制程序执行流程。

if语句

if语句在bool表达式为true时执行一个语句。例如:

if (5 < 2 * 3)
  Console.WriteLine ("true");       // true

语句可以是一个代码块:

if (5 < 2 * 3)
{
  Console.WriteLine ("true");       // true
  Console.WriteLine ("...")
}

else 子句

if 语句可以选择地包含 else 子句:

if (2 + 2 == 5)
  Console.WriteLine ("Does not compute");
else
  Console.WriteLine ("False");        // False

else 子句中,你可以嵌套另一个 if 语句:

if (2 + 2 == 5)
  Console.WriteLine ("Does not compute");
else
 if (2 + 2 == 4)
 Console.WriteLine ("Computes");    // Computes

通过移动大括号来改变执行流程

else 子句总是应用于语句块中紧接着的 if 语句。例如:

if (true)
  if (false)
    Console.WriteLine();
  else
    Console.WriteLine ("executes");

这在语义上与以下内容相同:

if (true)
{
  if (false)
    Console.WriteLine();
  else
    Console.WriteLine ("executes");
}

通过移动大括号,你可以改变执行流程:

if (true)
{
  if (false)
    Console.WriteLine();
}
else
  Console.WriteLine ("does not execute");

C# 没有“elseif”关键字;然而,以下模式可以达到相同的效果:

if (age >= 35)
  Console.WriteLine ("You can be president!");
else if (age >= 21)
  Console.WriteLine ("You can drink!");
else if (age >= 18)
  Console.WriteLine ("You can vote!");
else
  Console.WriteLine ("You can wait!");

switch 语句

switch 语句允许你根据变量可能具有的一组可能值来分支程序执行。与多个 if 语句相比,switch 语句可以生成更干净的代码,因为 switch 语句只需要评估一次表达式。例如:

static void ShowCard (int cardNumber)
{
  switch (cardNumber)
  {
    case 13:
      Console.WriteLine ("King");
      break;
    case 12:
      Console.WriteLine ("Queen");
      break;
    case 11:
      Console.WriteLine ("Jack");
      break;
    default:    // Any other cardNumber
      Console.WriteLine (cardNumber);
      break;
  }
}

每个 case 表达式中的值必须是常量,这限制了它们允许的类型为内置的数值类型以及 boolcharstringenum 类型。在每个 case 子句的末尾,你必须明确指定执行流向下一个跳转语句。以下是选项:

  • break(跳转到 switch 语句的末尾)

  • goto case *x*(跳转到另一个 case 子句)

  • goto default(跳转到 default 子句)

  • 任何其他跳转语句,包括 returnthrowcontinuegoto *label*

当多个值应执行相同代码时,可以顺序列出公共的 case

switch (cardNumber)
{
 case 13:
 case 12:
 case 11:
    Console.WriteLine ("Face card");
    break;
  default:
    Console.WriteLine ("Plain card");
    break;
}

switch 语句的这一特性在产生比多个 if-else 语句更清晰的代码方面可以起到关键作用。

在类型上进行 switch

从 C# 7 开始,你可以根据 类型 进行 switch

static void TellMeTheType (object x)
{
  switch (x)
  {
    case int i:
      Console.WriteLine ("It's an int!");
      break;
    case string s:
      Console.WriteLine (s.Length);      // We can use s
      break;
    case bool b when b == true:   // Fires when b is true
      Console.WriteLine ("True");
      break;
    case null:    // You can also switch on null
      Console.WriteLine ("null");
      break;
  }
}

object 类型允许变量具有任何类型 — 见 “继承” 和 “object 类型”。)

每个 case 子句指定了要匹配的类型以及要在匹配成功时分配的变量。与常量不同,你可以使用任何类型。可选的 when 子句指定了必须满足的条件以使 case 匹配。

当你在类型上进行 switch 时,case 子句的顺序是相关的(与在常量上进行 switch 不同)。一个例外是 default 子句,无论它出现在何处,都是最后执行的。

你可以堆叠多个 case 子句。以下代码中的 Console.WriteLine 将对大于 1,000 的任何浮点类型执行:

  switch (x)
  {
 case float f when f > 1000:
 case double d when d > 1000:
 case decimal m when m > 1000:
      Console.WriteLine ("f, d and m are out of scope");
      break;

在这个例子中,编译器允许我们在 when 子句中消耗变量 fdm,仅在调用 Console.WriteLine 时,无法确定将为这三个变量中的哪一个分配值,因此编译器使它们全部超出范围。

switch 表达式

从 C# 8 开始,你也可以在 表达式 的上下文中使用 switch。假设 cardNumber 的类型是 int,下面展示了它的使用方法:

string cardName = cardNumber switch
{
  13 => "King",
  12 => "Queen",
  11 => "Jack",
  _ => "Pip card"   // equivalent to 'default'
};

注意,switch关键字出现在变量名之后,并且 case 子句是表达式(以逗号结尾),而不是语句。你也可以对多个值(tuples)进行 switch:

int cardNumber = 12; string suite = "spades";
string cardName = (cardNumber, suite) switch
{
  (13, "spades") => "King of spades",
  (13, "clubs") => "King of clubs",
  ...
};

迭代语句

C#允许使用whiledo-whileforforeach语句重复执行一系列语句。

whiledo-while循环

while循环在bool表达式为真时重复执行一段代码。表达式在执行循环体之前进行测试。例如,以下输出012

int i = 0;
while (i < 3)
{                         // Braces here are optional
  Console.Write (i++);
}

do-while循环与while循环的功能不同之处仅在于它们在执行语句块后测试表达式(确保语句块至少执行一次)。以下是使用do-while循环重写的前述示例:

int i = 0;
do
{
  Console.WriteLine (i++);
}
while (i < 3);

for循环

for循环类似于while循环,但具有用于循环变量的初始化和迭代的特殊子句。for循环包含以下三个子句:

for (*init-clause*; *condition-clause*; *iteration-clause*)
  *statement-or-statement-block*

init-clause在循环开始之前执行,通常初始化一个或多个iteration变量。

condition-clause是一个bool表达式,在每次循环迭代之前进行测试。当条件为真时,执行循环体。

iteration-clause在每次执行循环体之后执行。通常用于更新迭代变量。

例如,以下打印数字 0 至 2:

for (int i = 0; i < 3; i++)
  Console.WriteLine (i);

以下打印前 10 个斐波那契数(每个数字是前两个数字的和):

for (int i = 0, prevFib = 1, curFib = 1; i < 10; i++)
{
  Console.WriteLine (prevFib);
  int newFib = prevFib + curFib;
  prevFib = curFib; curFib = newFib;
}

for语句的三个部分都可以省略。你可以实现无限循环,例如以下(虽然可以使用while(true)替代):

for (;;) Console.WriteLine ("interrupt me");

foreach循环

foreach语句在可枚举对象中迭代每个元素。大多数表示集合或列表的.NET 类型都是可枚举的。例如,数组和字符串都是可枚举的。以下是枚举字符串中字符的示例,从第一个字符到最后一个字符:

foreach (char c in "beer")
  Console.Write (c + " ");   // b e e r

我们在“Enumeration and Iterators”中定义可枚举对象。

跳转语句

C#跳转语句包括breakcontinuegotoreturnthrow。我们在“try Statements and Exceptions”中讨论throw关键字。

break 语句

break语句结束迭代或switch语句的执行:

int x = 0;
while (true)
{
  if (x++ > 5) break;      // break from the loop
}
// execution continues here after break
...

continue 语句

continue语句放弃循环中剩余的语句,并提前开始下一次迭代。以下循环跳过偶数:

for (int i = 0; i < 10; i++)
{
  if ((i % 2) == 0) continue;
  Console.Write (i + " ");      // 1 3 5 7 9
}

goto 语句

goto语句将执行转移至语句块内的标签(用冒号后缀表示)。以下迭代 1 至 5 的数字,模拟for循环:

int i = 1;
startLoop:
if (i <= 5)
{
  Console.Write (i + " ");   // 1 2 3 4 5
  i++;
 goto startLoop;
}

return 语句

return语句退出方法,并且如果方法是非 void 类型,则必须返回方法返回类型的表达式:

decimal AsPercentage (decimal d)
{
  decimal p = d * 100m;
  return p;     // Return to calling method with value
}

return语句可以出现在方法中的任何位置(除了finally块),并且可以多次使用。

第十二章:命名空间

命名空间是类型名称必须唯一的域。类型通常组织在层次结构命名空间中,这样既可以避免命名冲突,又可以更容易找到类型名称。例如,处理公钥加密的RSA类型定义在以下命名空间中:

System.Security.Cryptography

命名空间是类型名称的一个重要部分。以下代码调用RSACreate方法:

System.Security.Cryptography.RSA rsa =
  System.Security.Cryptography.RSA.Create();
注意

命名空间独立于程序集,这些程序集是部署单位,例如*.exe.dll*。

命名空间对成员的可访问性没有影响 — publicinternalprivate等等。

namespace关键字为该块内的类型定义了一个命名空间。例如:

namespace Outer.Middle.Inner
{
  class Class1 {}
  class Class2 {}
}

命名空间中的点表示嵌套命名空间的层次结构。接下来的代码在语义上与前面的示例相同:

namespace Outer
{
  namespace Middle
  {
    namespace Inner
    {
      class Class1 {}
      class Class2 {}
    }
  }
}

您可以使用完全限定名称引用类型,其中包括从最外层到最内层的所有命名空间。例如,您可以将前面示例中的Class1称为Outer.Middle.Inner.Class1

未定义在任何命名空间中的类型被称为全局命名空间。全局命名空间还包括顶层命名空间,例如我们示例中的Outer

文件范围的命名空间

通常,您希望文件中的所有类型都定义在一个命名空间中:

namespace MyNamespace
{
  class Class1 {}
  class Class2 {}
}

自 C# 10 起,您可以使用文件范围的命名空间实现此目的:

namespace MyNamespace;  // Applies to everything below

class Class1 {}         // inside MyNamespace
class Class2 {}         // inside MyNamespace

文件范围的命名空间减少了混乱,并消除了不必要的缩进级别。

using 指令

using指令导入一个命名空间,是一种方便的方法,可以引用类型而不必使用完全限定名称。例如,您可以像下面这样引用前面示例中的Class1

using Outer.Middle.Inner;

Class1 c;    // Don't need fully qualified name

using指令可以嵌套在命名空间内本身,以限制指令的作用范围。

全局 using 指令

自 C# 10 起,如果您在using指令前加上global关键字,则该指令将应用于项目或编译单元中的所有文件:

global using System;
global using System.Collection.Generic;

这使得您可以集中管理常见的导入,避免在每个文件中重复相同的指令。

global using指令必须在非全局指令之前,并且不能出现在命名空间声明内。全局指令可以与using static一起使用。

使用静态导入

using static指令导入的是一个类型而不是命名空间。该类型的所有静态成员可以在不用限定类型名称的情况下使用。在以下示例中,我们调用Console类的静态WriteLine方法:

using static System.Console;

WriteLine ("Hello");

using static 指令导入类型的所有可访问的静态成员,包括字段、属性和嵌套类型。你还可以将此指令应用于枚举类型(见“枚举”),在这种情况下,它们的成员被导入。如果在多个静态导入之间出现歧义,C# 编译器无法从上下文中推断出正确的类型,并将生成错误。

命名空间内的规则

名称作用域

在内部命名空间中,可以不带限定地使用外部命名空间中声明的名称。在此示例中,Class1Inner 中不需要限定:

namespace Outer
{
  class Class1 {}

  namespace Inner
  {
    class Class2 : Class1 {}
  }
}

如果你想引用命名空间层次结构中不同分支的类型,可以使用部分限定名称。在以下示例中,我们基于 Common.ReportBase 创建 SalesReport

namespace MyTradingCompany
{
  namespace Common
  {
    class ReportBase {}
  }
  namespace ManagementReporting
  {
    class SalesReport : Common.ReportBase {}
  }
}

名称隐藏

如果同一类型名称同时出现在内部和外部命名空间中,则内部名称优先。要引用外部命名空间中的类型,必须限定其名称。

注意

所有类型名称在编译时转换为完全限定名称。中间语言(IL)代码不包含未限定或部分限定的名称。

重复的命名空间

可以重复命名空间声明,只要命名空间内的类型名称不冲突:

namespace Outer.Middle.Inner { class Class1 {} }
namespace Outer.Middle.Inner { class Class2 {} }

类可以跨源文件和程序集。

全局限定符 global::

偶尔,完全限定的类型名称可能与内部名称冲突。你可以通过在其前面加上 global:: 强制 C# 使用完全限定的类型名称,如下所示:

global::System.Text.StringBuilder sb;

类型和命名空间别名

导入命名空间可能会导致类型名称冲突。与其导入整个命名空间,你可以只导入需要的特定类型,并为每个类型起一个别名。例如:

using PropertyInfo2 = System.Reflection.PropertyInfo;
class Program { PropertyInfo2 p; }

可以通过以下方式给整个命名空间起别名:

using R = System.Reflection;
class Program { R.PropertyInfo p; }

类型别名(C# 12)

自 C# 12 起,using 指令可以为任何类型和命名空间起别名,包括例如数组:

using NumberList = double[];
NumberList numbers = { 2.5, 3.5 };

你还可以给元组起别名(见“元组”)。

第十三章:类

是最常见的引用类型。最简单的类声明如下:

class Foo
{
}

更复杂的类可选包含以下内容:

在关键字 class特性类修饰符。非嵌套类修饰符包括 publicinternalabstractsealedstaticunsafepartial
Foo 后面泛型类型参数约束,一个 基类接口
在大括号内类成员(包括 方法属性索引器事件字段构造函数重载运算符嵌套类型终结器)。

字段

字段 是一个类或结构体的成员变量。例如:

class Octopus
{
 string name;
 public int Age = 10;
}

字段可以使用 readonly 修饰符防止其在构造后被修改。只能在声明中或封闭类型的构造函数内为只读字段赋值。

字段初始化是可选的。未初始化的字段具有默认值(0'\0'nullfalse)。字段初始化程序按其出现顺序在构造函数之前运行。

为方便起见,您可以在逗号分隔的列表中声明多个相同类型的字段。这是所有字段共享相同属性和字段修饰符的便捷方式。例如:

static readonly int legs = 8, eyes = 2;

常量

常量在编译时静态评估,编译器在使用时直接替换其值(类似于 C++中的宏)。常量可以是任何内置的数值类型:boolcharstring或枚举类型。

使用const关键字声明常量,并且必须用一个值进行初始化。例如:

public class Test
{
 public const string Message = "Hello World";
}

常量比static readonly字段更加受限制——无论在您可以使用的类型还是在字段初始化语义上。常量与static readonly字段的另一个不同之处在于常量的评估发生在编译时。常量也可以声明为方法的本地变量:

static void Main()
{
  const double twoPI = 2 * System.Math.PI;
  ...
}

方法

方法通过一系列语句执行操作。方法可以通过指定参数接收调用者的输入数据,并通过指定返回类型向调用者发送输出数据。方法可以指定void返回类型,表示它不向其调用者返回任何值。方法还可以通过refout参数将数据返回给调用者。

方法的签名必须在类型内是唯一的。方法的签名包括其名称和参数类型的顺序(但不包括参数名称和返回类型)。

表达式体方法

一个由单个表达式组成的方法,例如以下内容:

int Foo (int x) { return x * 2; }

可以更简洁地编写为表达式体方法。一个胖箭头取代了大括号和return关键字:

int Foo (int x) => x * 2;

表达式体函数也可以有void返回类型:

void Foo (int x) => Console.WriteLine (x);

本地方法

您可以在另一个方法内定义一个方法:

void WriteCubes()
{
  Console.WriteLine (Cube (3));

  int Cube (int value) => value * value * value;
}

本地方法(本例中为Cube)仅对封闭方法(WriteCubes)可见。这简化了包含类型,并立即向查看代码的任何人表明Cube在其他地方未被使用。本地方法可以访问封闭方法的本地变量和参数。这带来了许多后果,我们在“捕获外部变量”中描述。

本地方法可以出现在其他函数种类中,例如属性访问器、构造函数等,并且甚至可以出现在其他本地方法中。本地方法可以是迭代器或异步的。

声明在顶层语句中的方法被隐式地视为本地方法;我们可以如下演示:

int x = 3; Foo();
void Foo() => Console.WriteLine (x);  // We can access x

静态本地方法

static修饰符添加到本地方法(从 C# 8 开始)可防止其访问封闭方法的本地变量和参数。这有助于减少耦合,并防止本地方法意外地引用包含方法中的变量。

方法重载

警告

局部方法不能重载。这意味着在顶级语句中声明的方法(将其视为局部方法)不能重载。

类型可以重载方法(具有相同名称的多个方法),只要参数类型不同即可。例如,以下方法可以共存于同一类型中:

void Foo (int x);
void Foo (double x);
void Foo (int x, float y);
void Foo (float x, int y);

实例构造函数

构造函数在类或结构上运行初始化代码。构造函数定义类似于方法,不同之处在于方法名和返回类型被简化为封闭类型的名称:

Panda p = new Panda ("Petey");   // Call constructor

public class Panda
{
  string name;              // Define field
 public Panda (string n)   // Define constructor
 {
 name = n;               // Initialization code
 }
}

单语句构造函数可以编写为表达式体成员:

public Panda (string n) => name = n;

类或结构可以重载构造函数。一个重载可以调用另一个,使用this关键字:

public class Wine
{
  public Wine (decimal price) {...}

  public Wine (decimal price, int year) 
    : this (price) {...}
}

当一个构造函数调用另一个时,被调用的构造函数会首先执行。

您可以将表达式传递给另一个构造函数,如下所示:

  public Wine (decimal price, DateTime year)
    : this (price, year.Year) {...}

表达式本身不能使用this引用,例如调用实例方法。不过,它可以调用静态方法。

隐式无参数构造函数

对于类,只有当您没有定义任何构造函数时,C# 编译器才会自动生成一个无参数的公共构造函数。但是,一旦您定义了至少一个构造函数,无参数构造函数就不再自动生成。

非公共构造函数

构造函数不需要是公共的。有一个非公共构造函数的常见原因是通过静态方法调用控制实例创建。可以使用静态方法从池中返回对象,而不是创建新对象,或者根据输入参数返回所选的特殊子类。

解构方法

虽然构造函数通常接受一组值(作为参数)并将它们分配给字段,但解构函数(C# 7+)则相反,将字段分配回一组变量。解构方法必须称为Deconstruct并具有一个或多个out参数:

class Rectangle
{
  public readonly float Width, Height;

  public Rectangle (float width, float height)
  {
    Width = width; Height = height;
  }

 public void Deconstruct (out float width,
 out float height)
 {
 width = Width; height = Height;
 }
}

要调用解构函数,您需要使用以下特殊语法:

var rect = new Rectangle (3, 4);
(float width, float height) = rect;
Console.WriteLine (width + " " + height);    // 3 4

第二行是解构调用。它创建两个局部变量然后调用Deconstruct方法。我们的解构调用等同于以下内容:

rect.Deconstruct (out var width, out var height);

解构调用允许隐式类型转换,因此我们可以缩短我们的调用到:

(var width, var height) = rect;

或者简单点:

var (width, height) = rect;

如果您要解构的变量已经定义,完全可以省略类型;这称为解构赋值

(width, height) = rect;

您可以通过重载Deconstruct方法为调用者提供一系列解构选项。

注意

Deconstruct方法可以是扩展方法(参见“扩展方法”)。如果您想解构未经作者授权的类型,这是一个有用的技巧。

从 C# 10 开始,您可以在解构时混合和匹配现有变量和新变量:

double x1 = 0;
(x1, double y2) = rect;

对象初始化器

为了简化对象初始化,可以在构造后直接通过对象初始化器初始化对象的可访问字段或属性。例如,请考虑以下类:

public class Bunny
{
  public string Name;
  public bool LikesCarrots, LikesHumans;

  public Bunny () {}
  public Bunny (string n) => Name = n;
}

使用对象初始化器,你可以像下面这样实例化Bunny对象:

Bunny b1 = new Bunny {
                       Name="Bo",
                       LikesCarrots = true,
                       LikesHumans = false
                     };

Bunny b2 = new Bunny ("Bo") {
                              LikesCarrots = true,
                              LikesHumans = false
                            };

this 引用

this引用指向实例本身。在以下示例中,Marry方法使用this来设置partnermate字段:

public class Panda
{
  public Panda Mate;

  public void Marry (Panda partner)
  {
    Mate = partner;
    partner.Mate = this;
  }
}

this引用还可以将局部变量或参数与字段区分开。例如:

public class Test
{
  string name;
  public Test (string name) => this.name = name;
}

this引用仅在类或结构的非静态成员中有效。

属性

属性看起来像外部的字段,但内部包含逻辑,就像方法一样。例如,你无法通过查看以下代码确定CurrentPrice是字段还是属性:

Stock msft = new Stock();
msft.CurrentPrice = 30;
msft.CurrentPrice -= 3;
Console.WriteLine (msft.CurrentPrice);

属性声明与字段类似,但添加了get/set块。以下是如何将CurrentPrice实现为属性的方法:

public class Stock
{
  decimal currentPrice;  // The private "backing" field

  public decimal CurrentPrice    // The public property
  {
     get { return currentPrice; }
     set { currentPrice = value; }
  }
}

getset表示属性的访问器。当读取属性时,get访问器运行。它必须返回属性类型的值。当分配属性时,set访问器运行。它有一个隐含的名为value的参数,通常赋给一个私有字段(在本例中为currentPrice)。

虽然属性的访问方式与字段相同,但它们的区别在于它们允许实现者完全控制获取和设置其值。这种控制使得实现者可以选择所需的内部表示,而不向属性的用户公开内部细节。在这个例子中,如果value超出有效值范围,set方法可能会抛出异常。

注意

在本书中,我们使用公共字段来保持示例不被干扰。在真实应用中,你通常会倾向于使用公共属性而不是公共字段来促进封装。

如果只指定了get访问器,则属性是只读的;如果只指定了set访问器,则属性是只写的。很少使用只写属性。

属性通常具有专用的后备字段来存储底层数据。但它不需要这样做;它可以返回从其他数据计算出的值:

decimal currentPrice, sharesOwned;

public decimal Worth
{
  get { return currentPrice * sharesOwned; }
}

表达式体属性

你可以将只读属性(如上面的示例)更简洁地声明为表达式体属性。一个箭头替换了所有的大括号、getreturn关键字:

public decimal Worth => currentPrice * sharesOwned;

从 C# 7 开始,set访问器也可以是表达式体的:

public decimal Worth
{
  get => currentPrice * sharesOwned;
 set => sharesOwned = value / currentPrice;
}

自动属性

属性最常见的实现方式是一个简单读取和写入与属性相同类型的私有字段的 getter 和/或 setter。自动属性声明指示编译器提供此实现。我们可以通过将CurrentPrice声明为自动属性来改进本节中的第一个示例:

public class Stock
{
  public decimal CurrentPrice { get; set; }
}

编译器会自动生成一个不能被引用的具有编译器生成名称的私有后备字段。如果要将属性公开为其他类型的只读属性,可以将set访问器标记为privateprotected

属性初始化器

你可以像处理字段一样向自动属性添加属性初始化器

public decimal CurrentPrice { get; set; } = 123;

这使得CurrentPrice有了一个初始值为 123。具有初始化器的属性可以是只读的:

public int Maximum { get; } = 999;

就像只读字段一样,只读自动属性也可以在类型的构造函数中分配。这在创建不可变(只读)类型时非常有用。

获取和设置的可访问性

getset访问器可以具有不同的访问级别。这种情况的典型用法是在public属性上具有internalprivate访问修饰符的set访问器:

private decimal x;
public decimal X
{
  get         { return x;  }
  private set { x = Math.Round (value, 2); }
}

请注意,您声明属性本身具有更宽松的访问级别(在此示例中为public),并在您希望更不可访问的访问器上添加修饰符。

仅初始化器

从 C# 9 开始,你可以声明一个使用init而不是set的属性访问器:

public class Note
{
  public int Pitch    { get; init; } = 20;
  public int Duration { get; init; } = 100;
}

这些仅初始化属性的行为类似于只读属性,但也可以通过对象初始化器进行设置:

var note = new Note { Pitch = 50 };

之后,属性将无法更改:

note.Pitch = 200;  // Error – init-only setter!

仅初始化属性甚至不能从其类内部进行设置,除非通过其属性初始化器、构造函数或另一个仅初始化访问器。

替代仅初始化属性的方法是通过构造函数填充只读属性:

  public Note (int pitch = 20, int duration = 100)
  {
 Pitch = pitch; Duration = duration;
  }

如果类是公共库的一部分,则此方法使得版本控制变得困难,因为在以后的日期向构造函数添加可选参数会破坏与使用者的二进制兼容性(而添加新的仅初始化属性不会破坏任何东西)。

注意

仅初始化属性还有另一个显著的优势,即当与记录一起使用时,它们允许非破坏性变异(见“Records”)。

与普通的set访问器一样,初始化访问器也可以提供一个实现:

public class Point
{
  readonly int _x;
  public int X { get => _x; init => _x = value; }
  ...

请注意,_x字段是只读的:仅初始化器允许修改其自身类中的readonly字段。如果没有此功能,_x需要是可写的,而类在内部将无法完全不可变。

索引器

索引器为访问类或结构中封装的值列表或字典的元素提供了一种自然的语法。索引器类似于属性,但通过索引参数访问而不是属性名。string类具有一个索引器,它允许您通过int索引访问其每个char值:

string s = "hello";
Console.WriteLine (s[0]); // 'h'
Console.WriteLine (s[3]); // 'l'

使用索引器的语法与使用数组的语法类似,不同之处在于索引参数可以是任何类型。您可以在方括号之前插入问号来对索引器进行空值条件调用(参见“Null Operators”):

string s = null;
Console.WriteLine (s?[0]);  // Writes nothing; no error.

实现索引器

要编写索引器,定义一个名为this的属性,并在方括号中指定参数。例如:

class Sentence
{
  string[] words = "The quick brown fox".Split();

 public string this [int wordNum]      // indexer
 { 
 get { return words [wordNum];  }
 set { words [wordNum] = value; }
 }
}

这是我们如何使用这个索引器的方式:

Sentence s = new Sentence();
Console.WriteLine (s[3]);       // fox
s[3] = "kangaroo";
Console.WriteLine (s[3]);       // kangaroo

类型可以声明多个索引器,每个索引器可以带有不同类型的参数。索引器还可以接受多个参数:

public string this [int arg1, string arg2]
{
  get { ... }  set { ... }
}

如果省略set访问器,索引器将变为只读,并且可以使用表达式主体语法来缩短其定义:

public string this [int wordNum] => words [wordNum];

使用索引和范围与索引器

您可以通过定义索引器的参数类型为IndexRange来支持自己的类中的索引和范围(见“索引和范围”)。我们可以通过向Sentence类添加以下索引器来扩展我们之前的示例:

  public string this [Index index] => words [index];
  public string[] this [Range range] => words [range];

这随后使以下内容成为可能:

Sentence s = new Sentence();
Console.WriteLine (s [¹]);         // fox  
string[] firstTwoWords = s [..2];   // (The, quick)

主构造函数(C# 12)

从 C# 12 开始,您可以直接在类(或结构)声明之后包含参数列表:

class Person (string firstName, string lastName)
{
  public void Print() =>
    Console.WriteLine (firstName + " " + lastName);
}

这会指示编译器使用主构造函数参数firstNamelastName)自动构建一个主构造函数,以便我们可以按以下方式实例化我们的类:

Person p = new Person ("Alice", "Jones");
p.Print();    // Alice Jones

主构造函数非常适用于原型设计和其他简单场景。另一种方法是定义字段来存储firstNamelastName,然后编写一个构造函数来填充它们。

C#生成的构造函数之所以称为主构造函数,是因为您选择(显式地)编写的任何额外构造函数都必须调用它:

class Person (string firstName, string lastName)
{
  public Person (string first, string last, int age)
    : this (first, last)  // Must call primary constructor
 { ... }
}

这确保了主构造函数参数始终被填充

注意

C#还提供了记录,我们在“记录”中介绍。记录也支持主构造函数;但是,编译器会额外处理记录,并默认为每个主构造函数参数生成一个公共的 init-only 属性。

主构造函数最适用于简单的场景,因为以下限制:

  • 您不能向主构造函数添加额外的初始化代码。

  • 尽管很容易将主构造函数参数公开为公共属性,但除非属性是只读的,否则不能轻松地整合验证逻辑。

主构造函数取代了 C#否则会生成的默认无参数构造函数。

主构造函数语义

要理解主构造函数的工作原理,请考虑普通构造函数的行为:

class Person
{
  public Person (string firstName, string lastName)
  {
 *   ... do something with firstName, lastName*
  }
}

当此构造函数内部的代码执行完毕后,参数firstNamelastName将退出作用域,不能随后访问。相比之下,主构造函数的参数不会退出作用域,并且在类的任何地方都可以随后访问,对象的生命周期内有效。

注意

主构造函数参数是特殊的 C#构造,不是字段,尽管编译器最终会在幕后生成隐藏字段来存储它们的值(如果需要)。

主构造函数和字段/属性初始化器

主构造函数参数的可访问性延伸到字段和属性的初始化器。在以下示例中,我们使用字段和属性的初始化器将firstName分配给公共字段,将lastName分配给公共属性:

class Person (string firstName, string lastName)
{
  public readonly string FirstName = firstName;
  public string LastName { get; } = lastName;
}

遮蔽主构造函数参数

字段(或属性)可以重用主构造函数参数名称:

class Person (string firstName, string lastName)
{
  readonly string firstName = firstName;
  readonly string lastName = lastName;
}

在这种情况下,字段或属性具有优先权,因此在字段和属性初始化器的右侧(用粗体显示)以外,主构造函数参数会被屏蔽。

注意

与普通参数一样,主构造函数参数是可写的。使用同名的readonly字段屏蔽它们(就像我们的示例中那样),有效地保护它们免受后续修改。

验证主构造函数参数

在“抛出表达式”中,我们将描述在遇到无效数据等场景时如何抛出异常。以下是一个预览,说明了如何在主构造函数中使用这个技术来验证lastName,确保其不为 null:

new Person ("Alice", null);   // throws exception

class Person (string firstName, string lastName)
{
  readonly string lastName = (lastName == null)
 ? throw new ArgumentNullException ("lastName")
 : lastName;
}

(请记住,在字段或属性初始化器中的代码是在对象构造时执行的,而不是在访问字段或属性时执行。)同样的技术也可以将lastName公开为一个只读属性:

public string LastName { get; } = (lastName == null)
  ? throw new ArgumentNullException ("lastName")
  : lastName;

静态构造函数

静态构造函数每个类型只执行一次,而不是每个实例执行一次。类型可以定义一个静态构造函数,它必须是无参数的,并且与类型同名:

class Test
{
  static Test() { Console.Write ("Type Initialized"); }
}

在类型被使用之前,运行时会自动调用静态构造函数。这有两个触发条件:实例化该类型和访问该类型中的静态成员。

警告

如果静态构造函数抛出未处理的异常,该类型在应用程序的生命周期内将变得不可用

注意

从 C# 9 开始,您还可以定义模块初始化器,它在程序集加载时执行一次(当程序集首次加载时)。要定义模块初始化器,编写一个静态 void 方法,然后将[ModuleInitializer]属性应用于该方法:

[System.Runtime.CompilerServices.ModuleInitializer]
internal static void InitAssembly()
{
  ...
}

静态字段初始化器在调用静态构造函数之前执行。如果类型没有静态构造函数,则静态字段初始化器将在类型被使用之前——或者在运行时的任何早期时间——执行。

静态类

标记为static的类无法实例化或继承,并且必须完全由静态成员组成。Sys⁠tem.​ConsoleSystem.Math类是静态类的良好示例。

终结器

终结器是仅在类中执行的方法,在垃圾收集器回收未引用对象的内存之前执行。终结器的语法是类名前缀加上~符号:

class Class1
{
  ~Class1() { ... }
}

C#将终结器翻译成一个方法,该方法重写object类中的Finalize方法。我们在《C# 12 入门》的第十二章中详细讨论了垃圾收集和终结器。

使用表达式体语法可以编写单语句终结器。

部分类型和方法

部分类型允许将类型定义拆分-通常跨多个文件。一个常见的场景是从其他源(例如 Visual Studio 模板)自动生成部分类,并通过额外手动编写的方法来增强该类。例如:

// PaymentFormGen.cs - autogenerated
partial class PaymentForm { ... }

// PaymentForm.cs - hand-authored
partial class PaymentForm { ... }

每个参与者必须有partial声明。

参与者不能有冲突的成员。例如,具有相同参数的构造函数不能重复。部分类型完全由编译器解析,这意味着每个参与者必须在编译时可用,并且必须驻留在同一个程序集中。

可以在单个参与者或多个参与者上指定基类(只要您指定的基类相同)。此外,每个参与者可以独立指定要实现的接口。我们在“继承”和“接口”中介绍基类和接口。

部分方法

部分类型可以包含部分方法。这些方法让自动生成的部分类型为手动编写提供可定制的钩子。例如:

partial class PaymentForm    // In autogenerated file
{
 partial void ValidatePayment (decimal amount);
}

partial class PaymentForm    // In hand-authored file
{
 partial void ValidatePayment (decimal amount)
 {
 if (amount > 100) Console.Write ("Expensive!");
 }
}

部分方法由两部分组成:定义实现。定义通常由代码生成器编写,实现通常手动编写。如果未提供实现,部分方法的定义将被编译消除(调用它的代码也将被消除)。这允许自动生成的代码在提供钩子时更加自由,而无需担心膨胀。部分方法必须是void,并且隐式为private

扩展的部分方法

扩展的部分方法(来自 C# 9)旨在用于反向代码生成场景,程序员在其中定义代码生成器实现的钩子。可能发生这种情况的一个例子是源生成器,这是 Roslyn 的一个功能,允许您向编译器提供一个自动生成代码部分的程序集。

如果部分方法声明以可访问性修饰符开头,则它是扩展的

public partial class Test
{
  public partial void M1();   // Extended partial method
  private partial void M2();  // Extended partial method
}

可访问性修饰符的存在不仅影响可访问性:它告诉编译器以不同方式处理声明。

扩展的部分方法必须有实现;如果未实现,它们不会消失。在这个例子中,M1M2都必须有实现,因为它们各自指定了可访问性修饰符(publicprivate)。

由于它们不会消失,扩展的部分方法可以返回任何类型,并且可以包含out参数。

nameof运算符

nameof运算符将任何符号(类型、成员、变量等)的名称作为字符串返回:

int count = 123;
string name = nameof (count);       // name is "count"

它相对于简单指定字符串的优势在于静态类型检查。诸如 Visual Studio 之类的工具可以理解符号引用,因此如果您重命名所涉及的符号,所有引用也将被重命名。

要指定类型成员的名称,比如字段或属性,也要包括类型。这适用于静态成员和实例成员:

string name = nameof (StringBuilder.Length);

这将评估为"Length"。要返回"StringBuilder.Length",你需要这样做:

nameof(StringBuilder)+"."+nameof(StringBuilder.Length);

第十四章:继承

类可以从另一个类 继承,以扩展或自定义原始类。从类继承可以重用该类中的功能,而不是从头开始构建。一个类只能从一个类继承,但本身可以被多个类继承,从而形成类层次结构。在这个例子中,我们首先定义了一个名为 Asset 的类:

public class Asset { public string Name; }

接下来,我们定义了名为 StockHouse 的类,它们将继承自 AssetStockHouse 获得 Asset 所有的东西,以及它们自己定义的任何额外成员:

public class Stock : Asset   // inherits from Asset
{
  public long SharesOwned;
}

public class House : Asset   // inherits from Asset
{
  public decimal Mortgage;
}

下面是如何使用这些类的示例:

Stock msft = new Stock { Name="MSFT",
                         SharesOwned=1000 };

Console.WriteLine (msft.Name);         // MSFT
Console.WriteLine (msft.SharesOwned);  // 1000

House mansion = new House { Name="Mansion",
                            Mortgage=250000 };

Console.WriteLine (mansion.Name);      // Mansion
Console.WriteLine (mansion.Mortgage);  // 250000

子类 StockHouse基类 Asset 继承了 Name 字段。

子类也称为 派生类

多态性

引用是 多态的。这意味着类型为 x 的变量可以引用一个子类 x 的对象。例如,考虑以下方法:

public static void Display (Asset asset)
{
  System.Console.WriteLine (asset.Name);
}

这个方法可以显示 StockHouse,因为它们都是 Asset。多态性基于子类(StockHouse)具有其基类(Asset)的所有特征。然而,反之则不成立。如果将 Display 重写为接受 House,则不能传递 Asset

转换和引用转换

对象引用可以是:

  • 隐式地 向上转换 到基类引用

  • 显式地 向下转换 到子类引用

在兼容的引用类型之间进行上转换和下转换执行 引用转换:创建一个指向 相同 对象的新引用。上转换始终成功;下转换仅在对象适当类型时才成功。

上转换

上转换操作从子类引用创建一个基类引用。例如:

Stock msft = new Stock();    // From previous example
Asset a = msft;              // Upcast

上转换后,变量 a 仍然引用与变量 msft 相同的 Stock 对象。被引用的对象本身没有被修改或转换:

Console.WriteLine (a == msft);        // True

虽然 amsft 引用同一个对象,但 a 对该对象的视图更为限制:

Console.WriteLine (a.Name);         // OK
Console.WriteLine (a.SharesOwned);  // Compile-time error

最后一行生成了一个编译时错误,因为变量 a 的类型是 Asset,即使它引用的是 Stock 类型的对象。要访问其 SharesOwned 字段,必须将 Asset 下转换Stock

下转换

下转换操作从基类引用创建一个子类引用。例如:

Stock msft = new Stock();
Asset a = msft;                      // Upcast
Stock s = (Stock)a;                  // Downcast
Console.WriteLine (s.SharesOwned);   // <No error>
Console.WriteLine (s == a);          // True
Console.WriteLine (s == msft);       // True

与上转换类似,只影响引用而不影响底层对象。下转换需要显式转换,因为它在运行时可能失败:

House h = new House();
Asset a = h;          // Upcast always succeeds
Stock s = (Stock)a;   // Downcast fails: a is not a Stock

如果一个下转换失败,会抛出 InvalidCastException。这是一个 运行时类型检查 的例子(参见 “静态类型检查与运行时类型检查”)。

as 操作符

as 操作符执行一个下转换,如果下转换失败则返回 null(而不是抛出异常):

Asset a = new Asset();
Stock s = a as Stock;   // s is null; no exception thrown

当你要测试结果是否为 null 时,这是非常有用的:

if (s != null) Console.WriteLine (s.SharesOwned);

as运算符无法执行自定义转换(见“运算符重载”),也不能进行数值转换。

is运算符

is运算符测试引用转换是否成功——换句话说,对象是否派生自指定的类(或实现接口)。通常用于在向下转换之前进行测试:

if (a is Stock) Console.Write (((Stock)a).SharesOwned);

如果拆箱转换会成功(见“对象类型”),is运算符也会返回 true。但它不考虑自定义或数值转换。

自 C# 7 开始,您可以在使用is运算符时引入变量:

if (a is Stock s)
  Console.WriteLine (s.SharesOwned);

引入的变量可以被“立即”使用,并且在is表达式外保持作用域内:

if (a is Stock s && s.SharesOwned > 100000)
  Console.WriteLine ("Wealthy");
else
 s = new Stock();   // s is in scope

Console.WriteLine (s.SharesOwned);  // Still in scope

is运算符与 C#的最新版本中引入的其他模式一起使用。有关详细讨论,请参阅“模式”。

虚函数成员

标记为virtual的函数可以被子类重写,提供特定的实现。方法、属性、索引器和事件都可以声明为virtual

public class Asset
{
  public string Name;
  public virtual decimal Liability => 0;
}

(Liability => 0{ get { return 0; } }的快捷方式。有关此语法的更多详细信息,请参阅“表达式体属性”。)子类通过应用override修饰符来重写虚方法:

public class House : Asset
{
  public decimal Mortgage;

  public override decimal Liability => Mortgage;
}

默认情况下,AssetLiability0Stock不需要专门化此行为。但是,House专门化了Liability属性,以返回Mortgage的值:

House mansion = new House { Name="Mansion",
                            Mortgage=250000 };
Asset a = mansion;
Console.WriteLine (mansion.Liability);  // 250000
Console.WriteLine (a.Liability);        // 250000

虚方法和重写方法的签名、返回类型和可访问性必须相同。重写方法可以通过base关键字调用其基类实现(见“base 关键字”)。

协变返回

自 C# 9 开始,你可以重写一个方法(或属性get访问器),使其返回更具体(子类化)的类型。例如,你可以在Asset类中编写一个返回AssetClone方法,并在House类中重写该方法,使其返回House

这是允许的,因为它不违反Clone必须返回Asset的约定:它返回一个House,而House是一个Asset(甚至更多)。

抽象类和抽象成员

声明为abstract的类永远不能被实例化。相反,只能实例化其具体的子类

抽象类可以定义抽象成员。抽象成员类似于虚成员,但它们不提供默认实现。除非子类也声明为抽象类,否则必须由子类提供实现:

public abstract class Asset
{
  // Note empty implementation
  public abstract decimal NetValue { get; }
}

子类可以像重写虚方法一样重写抽象成员。

隐藏继承成员

基类和子类可以定义相同的成员。例如:

public class A      { public int Counter = 1; }
public class B : A  { public int Counter = 2; }

B中的Counter字段被称为隐藏A中的Counter字段。通常情况下,这种情况是偶然发生的,当在子类型中添加相同成员之后添加基类型时。因此,编译器生成警告,然后按以下方式解决歧义:

  • A的引用(在编译时)绑定到A.Counter

  • B的引用(在编译时)绑定到B.Counter

有时,您想要故意隐藏一个成员,在这种情况下,您可以在子类的成员上应用new修饰符。new修饰符只是抑制了否则会产生的编译器警告:

public class A     { public     int Counter = 1; }
public class B : A { public new int Counter = 2; }

new修饰符向编译器和其他程序员表明,重复的成员不是偶然事件。

封闭函数和类

重写的函数成员可以使用sealed关键字封闭其实现,防止其被后续的子类重写。在我们之前的虚函数成员示例中,我们可以封闭HouseLiability的实现,从而防止从House派生的类重写Liability,如下所示:

public sealed override decimal Liability { get { ... } }

您还可以将sealed修饰符应用于类本身,以防止子类化。

base关键字

base关键字类似于this关键字。它有两个基本目的:从子类访问重写的函数成员和调用基类构造函数(请参见下一节)。

在此示例中,House使用base关键字访问AssetLiability实现:

public class House : Asset
{
  ...
  public override decimal Liability 
    => base.Liability + Mortgage;
}

使用base关键字,我们非虚拟地访问AssetLiability属性。这意味着无论实例的实际运行时类型如何,我们始终访问Asset的此属性版本。

如果Liability隐藏而不是重写,同样的方法也适用。(在调用函数之前,您可以通过将其强制转换为基类来访问隐藏成员。)

构造函数和继承

子类必须声明其自己的构造函数。例如,假设我们定义BaseclassSubclass如下:

public class Baseclass
{
  public int X;
  public Baseclass () { }
  public Baseclass (int x) => X = x;
}
public class Subclass : Baseclass { }

随后的内容是非法的:

Subclass s = new Subclass (123);

Subclass必须“重新定义”它想要公开的任何构造函数。在这样做时,它可以使用base关键字调用基类的任何构造函数:

public class Subclass : Baseclass
{
  public Subclass (int x) : base (x) { ... }
}

base关键字工作方式类似于this关键字,不同之处在于它调用基类中的构造函数。基类构造函数总是首先执行;这确保了基础初始化发生在专门化初始化之前。

如果子类中的构造函数省略了base关键字,则会隐式调用基类型的无参数构造函数(如果基类没有可访问的无参数构造函数,则编译器会生成错误)。

必需成员(C# 11)

如果在大型类层次结构中,子类需要调用基类的构造函数可能会变得繁琐,特别是当有许多参数和多个构造函数时。有时,最好的解决方案是完全避免构造函数,而是完全依赖对象初始化器在构造过程中设置字段或属性。为了帮助解决这个问题,您可以将字段或属性标记为required(来自 C# 11):

public class Asset
{
  public required string Name;
}

必需成员在构造时必须通过对象初始化器填充:

Asset a1 = new Asset { Name="House" };  // OK
Asset a2 = new Asset();                 // Error

如果您希望同时编写一个构造函数,您可以为该构造函数应用[SetsRequiredMembers]属性以绕过该构造函数的必需成员限制:

public class Asset
{
  public required string Name;

  public Asset() { }

  [System.Diagnostics.CodeAnalysis.SetsRequiredMembers]
  public Asset (string n) => Name = n;
}

构造函数和字段初始化顺序

当一个对象被实例化时,初始化按照以下顺序进行:

  1. 从子类到基类:

    1. 字段被初始化。

    2. 调用基类构造函数调用的参数被评估。

  2. 从基类到子类:

    1. 构造函数体执行。

具有主构造函数的继承

具有主构造函数的类可以使用以下语法进行子类化:

class Baseclass (int x) {...}
class Subclass (int x, int y) : Baseclass (x) {...}

重载和解析

继承对方法重载有着有趣的影响。考虑以下两个重载:

static void Foo (Asset a) { }
static void Foo (House h) { }

当调用一个重载时,最具体的类型优先:

House h = new House (...);
Foo(h);                      // Calls Foo(House)

静态地(在编译时)而不是在运行时决定调用特定的重载。下面的代码调用Foo(Asset),尽管a的运行时类型是House

Asset a = new House (...);
Foo(a);                      // Calls Foo(Asset)
注意

如果将Asset转换为dynamic(参见“动态绑定”),则决定调用哪个重载将推迟到运行时,并基于对象的实际类型进行选择。

第十五章:对象类型

objectSystem.Object)是所有类型的最终基类。任何类型都可以隐式向上转型为object

为了说明这对我们有用,考虑一个通用的。栈是一种基于 LIFO 原则的数据结构——“后进先出”。栈有两个操作:push将对象推入栈中和pop从栈中弹出对象。以下是一个简单的实现,可以容纳最多 10 个对象:

public class Stack
{
  int position;
  object[] data = new object[10];
  public void Push (object o) { data[position++] = o; }
  public object Pop() { return data[--position]; }
}

因为Stack使用的是对象类型,我们可以向StackPushPop任何类型的实例:

Stack stack = new Stack();
stack.Push ("sausage");
string s = (string) stack.Pop();   // Downcast
Console.WriteLine (s);             // sausage

object是一个引用类型,因为它是一个类。尽管如此,值类型(如int)也可以被转换为object并从object转换回来。为了实现这一点,CLR 必须执行一些特殊工作来弥合值类型和引用类型之间的基本差异。这个过程称为装箱拆箱

注意

在“泛型”中,我们描述了如何改进我们的Stack类以更好地处理具有相同类型元素的堆栈。

装箱和拆箱

装箱是将值类型实例转换为引用类型实例的过程。引用类型可以是object类或一个接口(见“接口”)。在这个例子中,我们将一个int装箱为一个对象:

int x = 9;
object obj = x;           // Box the int

拆箱通过将对象转换回原始值类型来逆转操作:

int y = (int)obj;         // Unbox the int

拆箱需要显式转换。运行时会检查声明的值类型是否与实际对象类型匹配,如果检查失败则抛出InvalidCastException异常。例如,以下代码因为long不能精确匹配int而抛出异常:

object obj = 9;       // 9 is inferred to be of type int
long x = (long) obj;  // InvalidCastException

然而以下成功:

object obj = 9;
long x = (int) obj;

同样适用于这里:

object obj = 3.5;      // 3.5 inferred to be type double
int x = (int) (double) obj;    // x is now 3

在最后一个例子中,(double)执行了拆箱操作,然后(int)执行了数值转换

装箱将值类型实例复制到新对象中,而拆箱将对象的内容复制回值类型实例:

int i = 3;
object boxed = i;
i = 5;
Console.WriteLine (boxed);    // 3

静态和运行时类型检查

C#在静态(编译时)和运行时都检查类型。

静态类型检查使得编译器能够在运行程序之前验证其正确性。以下代码会因为编译器强制执行静态类型而失败:

int x = "5";

当通过引用转换或拆箱进行向下转换时,CLR 执行运行时类型检查:

object y = "5";
int z = (int) y;       // Runtime error, downcast failed

运行时类型检查是可能的,因为堆上的每个对象内部都存储了一个小的类型标记。通过调用objectGetType方法可以检索此标记。

GetType 方法和 typeof 运算符

在 C#中,所有类型在运行时都由System.Type的实例表示。获取System.Type对象有两种基本方式:对实例调用GetType方法或者在类型名称上使用typeof运算符。GetType在运行时评估;typeof在编译时静态评估。

System.Type具有诸如类型名称、程序集、基类型等属性。例如:

int x = 3;

Console.Write (x.GetType().Name);               // Int32
Console.Write (typeof(int).Name);               // Int32
Console.Write (x.GetType().FullName);    // System.Int32
Console.Write (x.GetType() == typeof(int));     // True

System.Type还有一些方法作为运行时反射模型的入口,我们在C# 12 in a Nutshell中详细描述了这一点。

对象成员列表

这里是object的所有成员:

public extern Type GetType();
public virtual bool Equals (object obj);
public static bool Equals (object objA, object objB);
public static bool ReferenceEquals (object objA,
                                    object objB);
public virtual int GetHashCode();
public virtual string ToString();
protected virtual void Finalize();
protected extern object MemberwiseClone();

Equals、ReferenceEquals 和 GetHashCode

object类中的Equals方法与==操作符类似,但Equals是虚拟的,而==是静态的。以下示例说明了它们的区别:

object x = 3;
object y = 3;
Console.WriteLine (x == y);        // False
Console.WriteLine (x.Equals (y));  // True

因为xy已转换为object类型,编译器静态绑定到object==操作符,它使用引用类型语义来比较两个实例。(并且因为xy被装箱,它们被表示在不同的内存位置,因此是不相等的。)然而,虚拟的Equals方法会委托给Int32类型的Equals方法,在比较两个值时使用值类型语义。

静态的object.Equals方法简单地调用第一个参数的虚拟Equals方法,之前会检查参数不为 null:

object x = null, y = 3;
bool error = x.Equals (y);        // Runtime error!
bool ok = object.Equals (x, y);   // OK (false)

ReferenceEquals强制进行引用类型的相等比较(在一些重载了==操作符的引用类型中,偶尔会有用)。

GetHashCode生成适用于基于哈希表的字典(如System.Collections.Generic.DictionarySystem.Collections.Hashtable)的哈希码。

要自定义类型的相等语义,至少必须重写EqualsGetHashCode方法。通常还会重载==!=运算符。有关如何执行这两者的示例,请参见“运算符重载”。

ToString 方法

ToString方法返回类型实例的默认文本表示。所有内置类型都会重写ToString方法:

string s1 = 1.ToString();      // s1 is "1"
string s2 = true.ToString();   // s2 is "True"

您可以按如下方式重写自定义类型的ToString方法:

public override string ToString() => "Foo";