C-9-和--NET5-高级教程-三-

41 阅读1小时+

C#9 和 .NET5 高级教程(三)

原文:Pro C# 9 with .NET 5

协议:CC BY-NC-SA 4.0

五、理解封装

在第三章 3 和第四章 4 中,您研究了许多对任何人来说都很常见的核心语法结构。您可能正在开发的. NET 核心应用。在这里,您将开始研究 C# 的面向对象能力。首要任务是检查构建定义良好的类类型的过程,这些类类型支持任意数量的构造函数。在你理解了定义类和分配对象的基础知识之后,本章的剩余部分将研究封装的作用。在这个过程中,您将学习如何定义类属性,并逐渐理解static关键字、对象初始化语法、只读字段、常量数据和分部类的细节。

C# 类类型简介

至于。NET 平台而言,最基本的编程结构之一是类类型。形式上,类是用户定义的类型,由字段数据(通常称为成员变量)和操作这些数据的成员(如构造函数、属性、方法、事件等)组成。).总的来说,这组字段数据代表了一个类实例的“状态”(也称为一个对象)。面向对象语言(如 C#)的强大之处在于,通过在一个统一的类定义中对数据和相关功能进行分组,您能够按照现实世界中的实体对您的软件进行建模。

首先,创建一个名为 SimpleClassExample 的新 C# 控制台应用项目。接下来,在您的项目中插入一个新的类文件(名为Car.cs)。在这个新文件中,添加以下名称空间和using语句:

using System;

namespace SimpleClassExample
{
}

Note

对于这些例子来说,定义名称空间是绝对必要的。然而,养成对所有代码使用名称空间的习惯是一个好习惯。第一章详细讨论了名称空间。

在 C# 中使用class关键字定义了一个类。下面是最简单的声明(确保将类声明添加到SimpleClassExample名称空间内):

class Car
{
}

在定义了一个类类型之后,您将需要考虑一组将用于表示其状态的成员变量。例如,您可能决定汽车维护一个int数据类型来表示当前速度,一个string数据类型来表示汽车的友好昵称。给定这些初始设计注释,如下更新你的Car类:

class Car
{
  // The 'state' of the Car.
  public string petName;
  public int currSpeed;
}

注意,这些成员变量是使用public访问修饰符声明的。一旦创建了这种类型的对象,就可以直接访问类的公共成员。回想一下术语对象用于描述使用new关键字创建的给定类类型的实例。

Note

一个类的字段数据应该很少(如果有的话)被定义为公共的。为了保持状态数据的完整性,更好的设计是将数据定义为私有的(或者可能是受保护的),并允许通过属性控制对数据的访问(如本章后面所示)。然而,为了使第一个例子尽可能简单,公共数据符合要求。

在定义了代表类状态的成员变量集之后,下一步设计就是建立对其行为进行建模的成员。对于这个例子,Car类将定义一个名为SpeedUp()的方法和另一个名为PrintState()的方法。更新您的类,如下所示:

class Car
{
  // The 'state' of the Car.
  public string petName;
  public int currSpeed;

// The functionality of the Car.
// Using the expression-bodied member syntax
// covered in Chapter 4
public void PrintState()
  => Console.WriteLine("{0} is going {1} MPH.", petName, currSpeed);

public void SpeedUp(int delta)
  => currSpeed += delta;
}

PrintState()或多或少是一个诊断函数,它将简单地把给定的Car对象的当前状态转储到命令窗口。SpeedUp()将增加Car物体的速度,增加量由输入的int参数指定。现在,用下面的代码更新Program.cs文件中的顶级语句:

Console.WriteLine("***** Fun with Class Types *****\n");

// Allocate and configure a Car object.
Car myCar = new Car();
myCar.petName = "Henry";
myCar.currSpeed = 10;

// Speed up the car a few times and print out the
// new state.
for (int i = 0; i <= 10; i++)
{
  myCar.SpeedUp(5);
  myCar.PrintState();
}
Console.ReadLine();

运行程序后,您将看到Car变量(myCar)在应用的整个生命周期中保持其当前状态,如以下输出所示:

***** Fun with Class Types *****
Henry is going 15 MPH.
Henry is going 20 MPH.
Henry is going 25 MPH.
Henry is going 30 MPH.
Henry is going 35 MPH.
Henry is going 40 MPH.
Henry is going 45 MPH.
Henry is going 50 MPH.
Henry is going 55 MPH.
Henry is going 60 MPH.
Henry is going 65 MPH.

用 new 关键字分配对象

如前面的代码示例所示,必须使用new关键字将对象分配到内存中。如果您不使用new关键字并试图在后续代码语句中使用您的类变量,您将收到一个编译器错误。例如,下面的顶级语句将不会编译:

Console.WriteLine("***** Fun with Class Types *****\n");
// Compiler error! Forgot to use 'new' to create object!
Car myCar;
myCar.petName = "Fred";

为了使用new关键字正确地创建一个对象,您可以在一行代码中定义和分配一个Car对象。

Console.WriteLine("***** Fun with Class Types *****\n");
Car myCar = new Car();
myCar.petName = "Fred";

或者,如果您想在单独的代码行上定义和分配类实例,可以按如下方式进行:

Console.WriteLine("***** Fun with Class Types *****\n");
Car myCar;
myCar = new Car();
myCar.petName = "Fred";

这里,第一个代码语句简单地声明了对一个待定的Car对象的引用。直到你给一个对象分配了一个引用,这个引用才指向内存中的一个有效对象。

无论如何,在这一点上,你有一个简单的类,它定义了几个数据点和一些基本操作。为了增强当前Car类的功能,您需要理解构造函数的作用。

理解构造函数

假设对象有状态(由对象的成员变量的值表示),程序员通常会希望在使用之前给对象的字段数据分配相关的值。目前,Car类要求在逐个字段的基础上分配petNamecurrSpeed字段。对于当前的示例,这不是太大的问题,因为您只有两个公共数据点。然而,一个类有几十个字段要处理的情况并不少见。显然,编写 20 条初始化语句来设置 20 个数据点是不可取的!

幸运的是,C# 支持使用构造函数,这允许在创建时建立对象的状态。构造函数是一个类的特殊方法,当使用new关键字创建一个对象时,它被间接调用。然而,与“普通”方法不同的是,构造函数从来没有返回值(甚至没有void),并且总是与它们正在构造的类同名。

了解默认构造函数的角色

每个 C# 类都提供了一个“免费的”默认构造函数,如果需要的话,你可以重新定义它。根据定义,默认构造函数从不接受参数。将新对象分配到内存后,默认构造函数确保该类的所有字段数据都设置为适当的默认值(有关 C# 数据类型默认值的信息,参见第三章)。

如果您对这些默认赋值不满意,您可以重新定义默认构造函数来满足您的需要。举例来说,按如下方式更新 C# Car类:

class Car
{
  // The 'state' of the Car.
  public string petName;
  public int currSpeed;

  // A custom default constructor.
  public Car()
  {
    petName = "Chuck";
    currSpeed = 10;
  }
...
}

在这种情况下,你正在强迫所有的Car物体以 10 英里/小时的速度开始名为Chuck的生命。这样,您就可以创建一个设置为这些默认值的Car对象,如下所示:

Console.WriteLine("***** Fun with Class Types *****\n");

// Invoking the default constructor.
Car chuck = new Car();

// Prints "Chuck is going 10 MPH."
chuck.PrintState();
...

定义自定义构造函数

通常,类定义了默认构造函数之外的其他构造函数。这样,您就为对象用户提供了一种简单而一致的方法,可以在创建时直接初始化对象的状态。考虑下面对Car类的更新,它现在总共支持三个构造函数:

class Car
{
  // The 'state' of the Car.
  public string petName;
  public int currSpeed;

  // A custom default constructor.
  public Car()
  {
    petName = "Chuck";
    currSpeed = 10;
  }

  // Here, currSpeed will receive the
  // default value of an int (zero).
  public Car(string pn)
  {
    petName = pn;
  }

  // Let caller set the full state of the Car.
  public Car(string pn, int cs)
  {
    petName = pn;
    currSpeed = cs;
  }
...
}

请记住,使一个构造函数不同于另一个构造函数(在 C# 编译器看来)的是构造函数参数的数量和/或类型。回想一下第四章的,当你定义了一个同名的方法,但是参数的数量和类型不同,那么重载了这个方法。因此,Car类重载了构造函数,提供了多种在声明时创建对象的方法。在任何情况下,您现在都能够使用任何公共构造函数创建Car对象。这里有一个例子:

Console.WriteLine("***** Fun with Class Types *****\n");

// Make a Car called Chuck going 10 MPH.
Car chuck = new Car();
chuck.PrintState();

// Make a Car called Mary going 0 MPH.
Car mary = new Car("Mary");
mary.PrintState();

// Make a Car called Daisy going 75 MPH.
Car daisy = new Car("Daisy", 75);
daisy.PrintState();
...

作为表达式主体成员的构造函数(新 7.0)

C# 7 增加了表达式主体成员样式的额外用途。属性和索引器上的构造函数、终结器和get / set访问器现在接受新语法。考虑到这一点,前面的构造函数可以写成这样:

// Here, currSpeed will receive the
// default value of an int (zero).
public Car(string pn) => petName = pn;

第二个自定义构造函数不能转换为表达式,因为表达式主体成员必须是单行方法。

不带参数的构造函数(新 7.3)

从 C# 7.3 开始,构造函数(以及后面介绍的字段和属性初始化器)可以使用out参数。举个简单的例子,将下面的构造函数添加到Car类中:

public Car(string pn, int cs, out bool inDanger)
{
  petName = pn;
  currSpeed = cs;
  if (cs > 100)
  {
    inDanger = true;
  }
  else
  {
    inDanger = false;
  }
}

必须遵守 out 参数的所有规则。在这个例子中,inDanger参数必须在构造函数结束前赋值。

重新认识默认构造函数

正如您刚刚了解到的,所有的类都提供了一个免费的默认构造函数。在名为Motorcycle.cs的项目中插入一个新文件,并添加以下内容来定义一个Motorcycle类:

using System;
namespace SimpleClassExample
{
  class Motorcycle
  {
    public void PopAWheely()
    {
      Console.WriteLine("Yeeeeeee Haaaaaeewww!");
    }
  }
}

现在,您可以通过现成的默认构造函数创建一个Motorcycle类型的实例。

Console.WriteLine("***** Fun with Class Types *****\n");
Motorcycle mc = new Motorcycle();
mc.PopAWheely();
...

但是,一旦您定义了具有任意数量参数的自定义构造函数,默认构造函数就会从该类中自动移除,并且不再可用。请这样想:如果您没有定义自定义构造函数,C# 编译器会授予您一个默认值,允许对象用户分配您的类型的实例,并将字段数据设置为正确的默认值。然而,当你定义一个独特的构造函数时,编译器会认为你已经掌握了主动权。

因此,如果你想让对象用户用默认构造函数创建你的类型的实例,以及你的自定义构造函数,你必须显式重定义默认。为此,请理解在绝大多数情况下,类的默认构造函数的实现是有意为空的,因为您所需要的只是用默认值创建对象的能力。考虑下面对Motorcycle类的更新:

class Motorcycle
{
  public int driverIntensity;

  public void PopAWheely()
  {
    for (int i = 0; i <= driverIntensity; i++)
    {
      Console.WriteLine("Yeeeeeee Haaaaaeewww!");
    }
  }

  // Put back the default constructor, which will
  // set all data members to default values.
  public Motorcycle() {}

  // Our custom constructor.
  public Motorcycle(int intensity)
  {
    driverIntensity = intensity;
  }
}

Note

既然您已经更好地理解了类构造函数的作用,这里有一个很好的捷径。Visual Studio 和 Visual Studio 代码都提供了ctor代码片段。当您键入ctor并按 Tab 键时,IDE 将自动定义一个自定义的默认构造函数。然后,您可以添加自定义参数和实现逻辑。试试看。

理解 this 关键字的作用

C# 提供了一个this关键字,该关键字提供了对当前类实例的访问。this关键字的一个可能用途是解决范围不明确的问题,当一个传入的参数与该类的一个数据字段同名时就会出现这种情况。但是,您可以简单地采用不会导致这种模糊性的命名约定;为了说明this关键字的用法,用一个新的string字段(名为name)来更新您的Motorcycle类,以表示司机的姓名。接下来,添加一个名为SetDriverName()的方法,实现如下:

class Motorcycle
{
  public int driverIntensity;

  // New members to represent the name of the driver.
  public string name;
  public void SetDriverName(string name) => name = name;
...
}

虽然这段代码可以编译,但 C# 编译器会显示一条警告消息,通知您已经将一个变量重新赋给了它自己!举例来说,更新您的代码来调用SetDriverName(),然后打印出name字段的值。您可能会惊讶地发现name字段的值是一个空字符串!

// Make a Motorcycle with a rider named Tiny?
Motorcycle c = new Motorcycle(5);
c.SetDriverName("Tiny");
c.PopAWheely();
Console.WriteLine("Rider name is {0}", c.name); // Prints an empty name value!

问题是,SetDriverName()的实现将传入的参数赋回给它自己,因为编译器假设name引用的是当前在方法范围内的变量,而不是类范围内的name字段。要通知编译器您想要将当前对象的name数据字段设置为传入的name参数,只需使用this来解决这个歧义。

public void SetDriverName(string name) => this.name = name;

如果没有歧义,当访问数据字段或成员时,就不需要使用this关键字。例如,如果您将string数据成员从name重命名为driverName(这也需要您更新顶级语句),那么这种使用是可选的,因为不再有范围模糊性。

class Motorcycle
{
  public int driverIntensity;
  public string driverName;

  public void SetDriverName(string name)
  {
    // These two statements are functionally the same.
    driverName = name;
    this.driverName = name;
  }
...
}

尽管在明确的情况下使用this没有什么好处,但您可能仍然会发现这个关键字在实现类成员时很有用,因为当指定this时,Visual Studio 和 Visual Studio 代码等 ide 将启用智能感知。当您忘记了某个类成员的名称并希望快速回忆起其定义时,这很有帮助。

Note

常见的命名约定是以下划线开始私有(或内部)类级变量名称(例如,_driverName),这样 IntelliSense 会在列表顶部显示所有变量。在我们的小例子中,所有的字段都是公共的,所以这个命名约定不适用。在本书的其余部分,你会看到私有和内部变量以一个前导下划线命名。

使用此链接构造函数调用

关键字this的另一个用途是使用一种叫做构造函数链接的技术来设计一个类。当一个类定义了多个构造函数时,这种设计模式很有用。考虑到构造函数经常验证传入的参数以执行各种业务规则,在类的构造函数集中发现冗余的验证逻辑是很常见的。考虑以下更新的Motorcycle:

class Motorcycle
{
  public int driverIntensity;
  public string driverName;

  public Motorcycle() { }

  // Redundant constructor logic!
  public Motorcycle(int intensity)
  {
    if (intensity > 10)
    {
      intensity = 10;
    }
    driverIntensity = intensity;
  }

  public Motorcycle(int intensity, string name)
  {
    if (intensity > 10)
    {
      intensity = 10;
    }
    driverIntensity = intensity;
    driverName = name;
  }
...
}

在这里(也许是为了确保骑手的安全),每个建造者都确保强度等级不超过 10。虽然这很好,但是在两个构造函数中确实有多余的代码语句。这并不理想,因为如果您的规则改变(例如,如果强度不应该大于 5 而不是 10),您现在需要在多个位置更新代码。

改善当前情况的一个方法是在Motorcycle类中定义一个方法,该方法将验证传入的参数。如果这样做,每个构造函数都可以在进行字段赋值之前调用此方法。虽然这种方法确实允许您在业务规则发生变化时隔离需要更新的代码,但是您现在要处理以下冗余:

class Motorcycle
{
   public int driverIntensity;
   public string driverName;

   // Constructors.
   public Motorcycle() { }

   public Motorcycle(int intensity)
   {
     SetIntensity(intensity);
   }

   public Motorcycle(int intensity, string name)
   {
     SetIntensity(intensity);
     driverName = name;
   }

   public void SetIntensity(int intensity)
   {
     if (intensity > 10)
     {
       intensity = 10;
     }
     driverIntensity = intensity;
   }
...
}

一种更干净的方法是将采用最大数量参数的构造函数指定为“主构造函数”,并让它的实现执行所需的验证逻辑。其余的构造函数可以利用this关键字将传入的参数转发给主构造函数,并根据需要提供任何额外的参数。这样,你只需要担心维护整个类的单个构造函数,而其余的构造函数基本上都是空的。

下面是Motorcycle类的最后一次迭代(为了便于说明,增加了一个构造函数)。当链接构造函数时,注意this关键字是如何“悬挂”在构造函数声明之外(通过冒号操作符)的。

class Motorcycle
{
   public int driverIntensity;
   public string driverName;

   // Constructor chaining.
   public Motorcycle() {}
   public Motorcycle(int intensity)
     : this(intensity, "") {}
   public Motorcycle(string name)
     : this(0, name) {}

   // This is the 'master' constructor that does all the real work.
   public Motorcycle(int intensity, string name)
   {
     if (intensity > 10)
     {
       intensity = 10;
     }
     driverIntensity = intensity;
     driverName = name;
   }
...
}

理解使用this关键字来链接构造函数调用从来都不是强制性的。然而,当您使用这种技术时,您最终会得到一个更易维护、更简洁的类定义。同样,使用这种技术,您可以简化您的编程任务,因为真正的工作被委托给单个构造函数(通常是具有最多参数的构造函数),而其他构造函数只是“推卸责任”

Note

回想一下第四章的内容,C# 支持可选参数。如果在类构造函数中使用可选参数,可以用更少的代码获得与构造函数链接相同的好处。一会儿您将看到如何做到这一点。

观察构造函数流

最后要注意的是,一旦构造函数将参数传递给指定的主构造函数(并且该构造函数已经处理了数据),最初由调用者调用的构造函数将结束执行任何剩余的代码语句。为了澄清,用对Console.WriteLine()的适当调用来更新Motorcycle类的每个构造函数。

class Motorcycle
{
  public int driverIntensity;
  public string driverName;

  // Constructor chaining.
  public Motorcycle()
  {
    Console.WriteLine("In default ctor");
  }

  public Motorcycle(int intensity)
     : this(intensity, "")
  {
    Console.WriteLine("In ctor taking an int");
  }

  public Motorcycle(string name)
     : this(0, name)
  {
    Console.WriteLine("In ctor taking a string");
  }

  // This is the 'master' constructor that does all the real work.
  public Motorcycle(int intensity, string name)
  {
    Console.WriteLine("In master ctor ");
    if (intensity > 10)
    {
      intensity = 10;
    }
    driverIntensity = intensity;
    driverName = name;
  }
...
}

现在,确保您的顶级语句使用一个Motorcycle对象,如下所示:

Console.WriteLine("***** Fun with class Types *****\n");

// Make a Motorcycle.
Motorcycle c = new Motorcycle(5);
c.SetDriverName("Tiny");
c.PopAWheely();
Console.WriteLine("Rider name is {0}", c.driverName);
Console.ReadLine();

这样,思考一下前面代码的输出:

***** Fun with Motorcycles *****
In master ctor
In ctor taking an int
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Rider name is Tiny

如您所见,构造函数逻辑流程如下:

  • 通过调用只需要一个int的构造函数来创建对象。

  • 此构造函数将提供的数据转发给主构造函数,并提供调用方未指定的任何附加启动参数。

  • 主构造函数将传入数据分配给对象的字段数据。

  • 控制返回到最初调用的构造函数,并执行任何剩余的代码语句。

使用构造函数链的好处是这种编程模式可以在任何版本的 C# 语言和。NET 平台。但是,如果你针对的是。NET 4.0 和更高版本中,通过使用可选参数作为传统构造函数链接的替代方法,可以进一步简化编程任务。

重新审视可选参数

在第四章中,你学习了可选参数和命名参数。回想一下,可选参数允许您为传入的参数定义提供的默认值。如果调用者对这些缺省值满意,他们不需要指定一个唯一的值;但是,他们这样做可能是为了给对象提供自定义数据。考虑下面的Motorcycle版本,它现在提供了许多使用单个构造函数定义来构造对象的方法:

class Motorcycle
{
  // Single constructor using optional args.
  public Motorcycle(int intensity = 0, string name = "")
  {
     if (intensity > 10)
     {
       intensity = 10;
     }
     driverIntensity = intensity;
     driverName = name;
  }
...
}

有了这个构造函数,您现在可以使用零个、一个或两个参数创建一个新的Motorcycle对象。回想一下,命名参数语法允许您跳过可接受的默认设置(参见第三章)。

static void MakeSomeBikes()
{
   // driverName = "", driverIntensity = 0
   Motorcycle m1 = new Motorcycle();
   Console.WriteLine("Name= {0}, Intensity= {1}",
     m1.driverName, m1.driverIntensity);

   // driverName = "Tiny", driverIntensity = 0
   Motorcycle m2 = new Motorcycle(name:"Tiny");
   Console.WriteLine("Name= {0}, Intensity= {1}",
     m2.driverName, m2.driverIntensity);

   // driverName = "", driverIntensity = 7
   Motorcycle m3 = new Motorcycle(7);
   Console.WriteLine("Name= {0}, Intensity= {1}",
     m3.driverName, m3.driverIntensity);
}

在任何情况下,此时您都能够用字段数据(即成员变量)和各种操作(如方法和构造函数)定义一个类。接下来,让我们正式确定关键字static的作用。

理解静态关键字

一个 C# 类可以定义任意数量的静态成员,它们是使用static关键字声明的。这样做时,必须从类级别直接调用相关成员,而不是从对象引用变量调用。为了说明区别,考虑你的好朋友System.Console。如您所见,您没有从对象级别调用WriteLine()方法,如下所示:

// Compiler error! WriteLine() is not an object level method!
Console c = new Console();
c.WriteLine("I can't be printed...");

相反,只需将类名作为静态WriteLine()成员的前缀。

// Correct! WriteLine() is a static method.
Console.WriteLine("Much better! Thanks...");

简而言之,静态成员是(被类设计者)认为非常普通的项,以至于在调用成员之前没有必要创建类的实例。虽然任何类都可以定义静态成员,但它们通常出现在实用程序类中。根据定义,实用程序类是一个不维护任何对象级状态的类,并且不是用new关键字创建的。更确切地说,一个实用程序类将所有功能公开为类级(也称为静态)成员。

例如,如果您要使用 Visual Studio 对象浏览器(通过“查看➤对象浏览器”菜单项)来查看System名称空间,您会看到ConsoleMathEnvironmentGC类的所有成员(以及其他成员)通过静态成员公开了它们的所有功能。这些只是在。NET 核心基本类库。

同样,请注意,静态成员不仅出现在实用程序类中;它们可以是任何类定义的一部分。请记住,静态成员将给定的项提升到类级别,而不是对象级别。正如您将在接下来的几节中看到的,static关键字可以应用于以下内容:

  • 一类数据

  • 类的方法

  • 类的属性

  • 建筑工人

  • 整个类定义

  • 与 C# using关键字一起使用

让我们看看我们的每个选项,从静态数据的概念开始。

Note

在本章的后面,您将在研究属性本身的同时研究静态属性的作用。

定义静态字段数据

大多数时候,在设计一个类时,您将数据定义为实例级数据,或者换句话说,定义为非静态数据。当您定义实例级数据时,您知道每次创建新对象时,该对象都会维护自己的独立数据副本。相反,当你定义一个类的静态数据时,该类的所有对象共享内存。

要了解区别,请创建一个名为 StaticDataAndMembers 的新控制台应用项目。现在,在您的项目中插入一个名为SavingsAccount.cs的文件,并在该文件中创建一个名为SavingsAccount的新类。首先定义一个实例级变量(为当前余额建模)和一个自定义构造函数来设置初始余额。

using System;
namespace StaticDataAndMembers
{
  // A simple savings account class.
  class SavingsAccount
  {
    // Instance-level data.
    public double currBalance;
    public SavingsAccount(double balance)
    {
      currBalance = balance;
    }
  }
}

创建SavingsAccount对象时,为每个对象分配currBalance字段的内存。因此,你可以创建五个不同的SavingsAccount物体,每个都有自己独特的平衡。此外,如果您更改一个帐户的余额,其他对象不会受到影响。

另一方面,静态数据只分配一次,在同一类类别的所有对象之间共享。向SavingsAccount类添加一个名为currInterestRate的静态变量,该变量被设置为默认值 0.04。

// A simple savings account class.
class SavingsAccount
{
   // A static point of data.
   public static double currInterestRate = 0.04;

   // Instance-level data.
   public double currBalance;

   public SavingsAccount(double balance)
   {
     currBalance = balance;
   }
}

在顶级语句中创建三个SavingsAccount实例,如下所示:

using System;
using StaticDataAndMembers;

  Console.WriteLine("***** Fun with Static Data *****\n");
  SavingsAccount s1 = new SavingsAccount(50);
  SavingsAccount s2 = new SavingsAccount(100);
  SavingsAccount s3 = new SavingsAccount(10000.75);
  Console.ReadLine();

内存中的数据分配如图 5-1 所示。

img/340876_10_En_5_Fig1_HTML.png

图 5-1。

静态数据只分配一次,在类的所有实例之间共享

这里的假设是所有的储蓄账户都应该有相同的利率。因为静态数据由同一类别的所有对象共享,所以如果您以任何方式对其进行更改,所有对象将在下次访问静态数据时“看到”新值,因为它们实际上都在查看相同的内存位置。要理解如何更改(或获取)静态数据,您需要考虑静态方法的作用。

定义静态方法

让我们更新SavingsAccount类来定义两个静态方法。第一个静态方法(GetInterestRate())将返回当前利率,而第二个静态方法(SetInterestRate())将允许您更改利率。

// A simple savings account class.
class SavingsAccount
{
  // Instance-level data.
  public double currBalance;

  // A static point of data.
  public static double currInterestRate = 0.04;

  public SavingsAccount(double balance)
  {
    currBalance = balance;
  }

  // Static members to get/set interest rate.
  public static void SetInterestRate(double newRate)
    => currInterestRate = newRate;

  public static double GetInterestRate()
    => currInterestRate;
}

现在,观察以下用法:

using System;
using StaticDataAndMembers;

Console.WriteLine("***** Fun with Static Data *****\n");
SavingsAccount s1 = new SavingsAccount(50);
SavingsAccount s2 = new SavingsAccount(100);

// Print the current interest rate.
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());

// Make new object, this does NOT 'reset' the interest rate.
SavingsAccount s3 = new SavingsAccount(10000.75);
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());

Console.ReadLine();

上面代码的输出如下所示:

***** Fun with Static Data *****
Interest Rate is: 0.04
Interest Rate is: 0.04

如您所见,当您创建新的SavingsAccount类实例时,静态数据的值不会被重置,因为 CoreCLR 将静态数据一次性分配到内存中。此后,所有类型为SavingsAccount的对象对静态currInterestRate字段的相同值进行操作。

在设计任何 C# 类时,设计挑战之一是确定哪些数据应该定义为静态成员,哪些不应该。虽然没有严格的规则,但请记住,静态数据字段由该类型的所有对象共享。因此,如果你正在定义一个数据点,所有的对象应该在它们之间共享这个数据点,那么静态就是最好的方法。

考虑一下,如果利率变量是使用关键字static定义的而不是,会发生什么。这意味着每个SavingsAccount对象都有自己的currInterestRate字段副本。现在,假设您创建了 100 个SavingsAccount对象,并需要更改利率。这将需要您调用SetInterestRate()方法 100 次!显然,这不是一种建模“共享数据”的有用方法同样,当您有一个对该类别的所有对象都通用的值时,静态数据是完美的。

Note

静态成员在其实现中引用非静态成员是一个编译器错误。与此相关,在静态成员上使用关键字this是错误的,因为this暗示了一个对象!

定义静态构造函数

典型的构造函数用于在创建时设置对象的实例级数据的值。然而,如果您试图在一个典型的构造函数中为一个静态数据点赋值,会发生什么呢?您可能会惊讶地发现,每次创建新对象时,该值都会被重置。

举例来说,假设您已经如下更新了SavingsAccount类构造函数(还要注意,您不再以内联方式分配currInterestRate字段):

class SavingsAccount
{
  public double currBalance;
  public static double currInterestRate;

  // Notice that our constructor is setting
  // the static currInterestRate value.
  public SavingsAccount(double balance)
  {
    currInterestRate = 0.04; // This is static data!
    currBalance = balance;
  }
...
}

现在,假设您已经在顶级语句中编写了以下代码:

// Make an account.
SavingsAccount s1 = new SavingsAccount(50);

// Print the current interest rate.
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());

// Try to change the interest rate via property.
SavingsAccount.SetInterestRate(0.08);

// Make a second account.
SavingsAccount s2 = new SavingsAccount(100);

// Should print 0.08...right??
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());
Console.ReadLine();

如果您执行了前面的代码,您会看到每次您创建一个新的SavingsAccount对象时,currInterestRate变量都会被重置,并且它总是被设置为0.04。显然,在普通的实例级构造函数中设置静态数据的值有点违背了整个目的。每次创建新对象时,类级别的数据都会被重置。设置静态字段的一种方法是使用成员初始化语法,就像您最初做的那样。

class SavingsAccount
{
  public double currBalance;

  // A static point of data.
  public static double currInterestRate = 0.04;
...
}

这种方法将确保静态字段只被分配一次,不管您创建了多少个对象。但是,如果需要在运行时获取静态数据的值,该怎么办呢?例如,在典型的银行应用中,利率变量的值将从数据库或外部文件中读取。执行这样的任务通常需要一个方法范围,比如构造函数来执行代码语句。

出于这个原因,C# 允许您定义一个静态构造函数,这允许您安全地设置静态数据的值。考虑对您的类进行以下更新:

class SavingsAccount
{
  public double currBalance;
  public static double currInterestRate;

  public SavingsAccount(double balance)
  {
    currBalance = balance;
  }

   // A static constructor!
   static SavingsAccount()
   {
     Console.WriteLine("In static ctor!");
     currInterestRate = 0.04;
   }
...
}

简单地说,静态构造函数是一种特殊的构造函数,当静态数据的值在编译时未知时(例如,您需要从外部文件读入值、从数据库读入值、生成随机数等等),它是初始化静态数据的值的理想位置。如果您要重新运行前面的代码,您会发现您期望的输出。请注意消息“在静态 ctor 中!”只打印一次,因为 CoreCLR 在第一次使用之前调用所有静态构造函数(并且不会为应用的那个实例再次调用它们)。

***** Fun with Static Data *****
In static ctor!
Interest Rate is: 0.04
Interest Rate is: 0.08

以下是一些关于静态构造函数的有趣之处:

  • 给定的类只能定义一个静态构造函数。换句话说,静态构造函数不能重载。

  • 静态构造函数不带访问修饰符,也不能带任何参数。

  • 静态构造函数只执行一次,不管创建了多少个该类型的对象。

  • 运行库在创建类的实例时或在访问调用方调用的第一个静态成员之前调用静态构造函数。

  • 静态构造函数在任何实例级构造函数之前执行。

考虑到这种修改,当您创建新的SavingsAccount对象时,静态数据的值被保留,因为静态成员在静态构造函数中只设置一次,而不管创建的对象数量。

定义静态类

也可以在类级别上直接应用static关键字。当一个类被定义为静态时,它不能用new关键字创建,它只能包含用static关键字标记的成员或数据字段。如果不是这样,您会收到编译器错误。

Note

回想一下,只公开静态功能的类(或结构)通常被称为实用程序类。当设计一个实用程序类时,将static关键字应用于类定义是一个好的实践。

乍一看,这似乎是一个相当奇怪的特性,因为一个不能被创建的类看起来并不那么有用。然而,如果你创建了一个只包含静态成员和/或常量数据的类,那么这个类就不需要被分配了!举例来说,创建一个名为TimeUtilClass的新类,并将其定义如下:

using System;
namespace StaticDataAndMembers
{
  // Static classes can only
  // contain static members!
  static class TimeUtilClass
  {
    public static void PrintTime()
      => Console.WriteLine(DateTime.Now.ToShortTimeString());

    public static void PrintDate()
      => Console.WriteLine(DateTime.Today.ToShortDateString());
  }
}

假设这个类是用static关键字定义的,那么您不能使用new关键字创建TimeUtilClass的实例。相反,所有功能都是从类级别公开的。若要测试该类,请将以下内容添加到顶级语句中:

// This is just fine.
TimeUtilClass.PrintDate();
TimeUtilClass.PrintTime();

// Compiler error! Can't create instance of static classes!
TimeUtilClass u = new TimeUtilClass ();

Console.ReadLine();

通过 C# using 关键字导入静态成员

C# 6 增加了对用关键字using导入静态成员的支持。举例来说,考虑当前定义实用程序类的 C# 文件。因为您正在调用Console类的WriteLine()方法,以及DateTime类的NowToday属性,所以您必须有一个用于System名称空间的using语句。由于这些类的成员都是静态的,您可以用下面的静态using指令来修改您的代码文件:

// Import the static members of Console and DateTime.
using static System.Console;
using static System.DateTime;

有了这些“静态导入”,代码文件的其余部分就能够直接使用ConsoleDateTime类的静态成员,而不需要给定义类加上前缀。例如,您可以像这样更新您的实用程序类:

static class TimeUtilClass
{
  public static void PrintTime()
    => WriteLine(Now.ToShortTimeString());

  public static void PrintDate()
    => WriteLine(Today.ToShortDateString());
}

通过导入静态成员来简化代码的一个更现实的例子可能涉及到一个 C# 类,它大量使用了System.Math类(或其他一些实用程序类)。因为这个类除了静态成员什么都没有,所以对于这个类型有一个静态的using语句,然后在你的代码文件中直接调用Math类的成员可能会更容易一些。

然而,请注意过度使用静态import语句可能会导致潜在的混乱。首先,如果多个类定义了一个WriteLine()方法怎么办?编译器很困惑,其他阅读你代码的人也一样。其次,除非开发人员熟悉。NET 核心代码库,他们可能不知道WriteLine()Console类的成员。除非人们注意到 C# 代码文件顶部的一组静态导入,否则他们可能不确定这个方法实际上是在哪里定义的。出于这些原因,我将在本文中限制静态using语句的使用。

在任何情况下,在本章的这一点上,您应该对定义包含构造函数、字段和各种静态(和非静态)成员的简单类类型感到舒适。现在您已经理解了类构造的基础,您可以正式研究面向对象编程的三大支柱了。

定义面向对象的支柱

所有面向对象的语言(C#、Java、C++、Visual Basic 等。)必须与这三个核心原则相抗衡,这三个原则通常被称为面向对象编程(OOP)的支柱:

  • 封装:这种语言是如何隐藏一个对象的内部实现细节并保持数据完整性的?

  • 继承:这种语言是如何促进代码重用的?

  • 多态性:这种语言是如何让你以类似的方式对待相关对象的?

在深入了解每个支柱的细节之前,理解它们的基本角色是很重要的。下面是对每个支柱的概述,我们将在本章的剩余部分和下一章中对其进行详细的分析。

理解封装的作用

OOP 的第一个支柱叫做封装。这一特性归结于该语言对对象用户隐藏不必要的实现细节的能力。例如,假设您正在使用一个名为DatabaseReader的类,它有两个主要方法,名为Open()Close()

// Assume this class encapsulates the details of opening and closing a database.
DatabaseReader dbReader = new DatabaseReader();
dbReader.Open(@"C:\AutoLot.mdf");

// Do something with data file and close the file.
dbReader.Close();

虚构的DatabaseReader类封装了定位、加载、操作和关闭数据文件的内部细节。程序员喜欢封装,因为 OOP 的这一支柱使得编码任务更加简单。不需要担心在幕后执行DatabaseReader类工作的众多代码行。您所做的就是创建一个实例并发送适当的消息(例如,“打开位于我的 c 盘上的名为AutoLot.mdf的文件”)。

与封装编程逻辑的概念密切相关的是数据保护的概念。理想情况下,应该使用privateinternalprotected关键字来指定对象的状态数据。这样,外界必须礼貌地询问,才能改变或获得底层价值。这是一件好事,因为公开声明的数据点很容易被破坏(最好是意外而不是故意的!).稍后您将正式检查封装的这一方面。

理解继承的作用

OOP 的下一个支柱,继承,归结为语言允许你基于现有的类定义构建新的类定义的能力。本质上,通过将核心功能继承到派生的子类(也称为子类)中,继承允许您扩展基类(或父类)的行为。图 5-2 显示了一个简单的例子。

img/340876_10_En_5_Fig2_HTML.jpg

图 5-2。

“是”的关系

你可以把图 5-2 中的图表理解为“一个六边形是一个物体的形状。”当你有通过这种继承形式相关的类时,你在类型之间建立了*“是-a”关系*。这种“是-a”关系被称为继承

在这里,您可以假设Shape定义了一些所有后代共有的成员(可能是一个代表绘制形状的颜色的值和其他代表高度和宽度的值)。鉴于Hexagon类扩展了Shape,它继承了ShapeObject定义的核心功能,并定义了自己的附加六边形相关细节(无论是什么)。

Note

在下面。NET/。NET 核心平台中,System.Object总是任何类层次结构中最顶层的父类,它为所有类型定义了一些通用功能(在第六章中有完整描述)。

在 OOP 世界中还有另一种形式的代码重用:包含/委托模型,也称为*“has-a”关系*或聚合。这种形式的重用不用于建立父子关系。相反,“has-a”关系允许一个类定义另一个类的成员变量,并间接地向对象用户公开其功能(如果需要的话)。

例如,假设您再次建模一辆汽车。你可能想表达这样的想法,一辆汽车“有一个”收音机。试图从一个Radio派生出一个Car类是不合逻辑的,反之亦然(一个Car是一个Radio?我觉得不是!).相反,你有两个独立的类一起工作,其中Car类创建并公开Radio的功能。

class Radio
{
  public void Power(bool turnOn)
  {
    Console.WriteLine("Radio on: {0}", turnOn);
  }
}

class Car
{
  // Car 'has-a' Radio.
  private Radio myRadio = new Radio();

  public void TurnOnRadio(bool onOff)
  {
    // Delegate call to inner object.
    myRadio.Power(onOff);
  }
}

注意,对象用户不知道Car类正在使用内部的Radio对象。

// Call is forwarded to Radio internally.
Car viper = new Car();
viper.TurnOnRadio(false);

理解多态性的作用

OOP 的最后一个支柱是多态性。这一特征体现了一种语言以相似的方式对待相关对象的能力。具体来说,这种面向对象语言的租户允许基类定义一组成员(正式术语为多态接口),这些成员对所有后代都可用。一个类的多态接口是使用任意数量的虚拟抽象成员构建的(参见第六章了解全部细节)。

简而言之,虚拟成员是基类中的一个成员,它定义了一个可以被派生类修改(或者更正式地说,覆盖)的默认实现。相反,抽象方法是基类中的成员,它不提供默认实现,但提供签名。当一个类从定义抽象方法的基类派生时,它必须被派生类型覆盖。在这两种情况下,当派生类型重写由基类定义的成员时,它们实际上是在重新定义它们如何响应同一请求。

为了预览多态性,让我们提供一些图 5-3 所示的形状层次背后的细节。假设Shape类已经定义了一个名为Draw()的没有参数的虚方法。考虑到每个形状都需要以一种独特的方式呈现自己,子类如HexagonCircle可以根据自己的喜好随意覆盖这个方法(见图 5-3 )。

img/340876_10_En_5_Fig3_HTML.jpg

图 5-3。

经典多态性

设计了多态接口后,您可以开始在代码中进行各种假设。例如,假设HexagonCircle从一个共同的父类(Shape)派生,那么Shape类型的数组可以包含从这个基类派生的任何东西。此外,假设Shape定义了所有派生类型的多态接口(本例中的Draw()方法),您可以假设数组中的每个成员都有这个功能。

考虑下面的代码,它指示一组从Shape派生的类型使用Draw()方法来呈现它们自己:

Shape[] myShapes = new Shape[3];
myShapes[0] = new Hexagon();
myShapes[1] = new Circle();
myShapes[2] = new Hexagon();

foreach (Shape s in myShapes)
{
  // Use the polymorphic interface!
  s.Draw();
}
Console.ReadLine();

这就结束了我们对 OOP 支柱的简要概述。既然你已经有了这个理论,本章的剩余部分将进一步探讨在 C# 中如何处理封装的细节,从访问修饰符开始。第六章将处理继承和多态的细节。

理解 C# 访问修饰符(更新 7.2)

使用封装时,您必须始终考虑类型的哪些方面对应用的各个部分是可见的。具体来说,类型(类、接口、结构、枚举和委托)及其成员(属性、方法、构造函数和字段)是使用特定的关键字定义的,以控制该项对应用的其他部分的“可见性”。虽然 C# 定义了许多关键字来控制访问,但它们在成功应用的地方(类型或成员)是不同的。表 5-1 记录了每个访问修饰符的作用及其可能应用的地方。

表 5-1。

C# 访问修饰符

|

C# 访问修改

|

可应用于

|

生命的意义

| | --- | --- | --- | | public | 类型或类型成员 | 公共项目没有访问限制。可以从对象以及任何派生类访问公共成员。可以从其他外部程序集访问公共类型。 | | private | 类型成员或嵌套类型 | 私有项只能由定义该项的类(或结构)访问。 | | protected | 类型成员或嵌套类型 | 受保护的项可以由定义它的类和任何子类使用。不能从继承链之外访问它们。 | | internal | 类型或类型成员 | 只能在当前组件中访问内部项目。其他程序集可以被显式授予查看内部项的权限。 | | protected internal | 类型成员或嵌套类型 | 当protectedinternal关键字组合在一个项目上时,该项目可在定义程序集内、定义类内以及定义程序集内外的派生类中访问。 | | private protected``(new 7.2) | 类型成员或嵌套类型 | 当privateprotected关键字组合在一个项目上时,该项目可以在定义类中访问,也可以由同一程序集中的派生类访问。 |

在本章中,您只关心关键字publicprivate。后面的章节将研究internalprotected internal修饰符(当你构建代码库和单元测试时有用)和protected修饰符(当你创建类层次结构时有用)的作用。

使用默认访问修饰符

默认情况下,类型成员是隐式私有、,而类型是隐式内部。因此,下面的类定义被自动设置为internal,而该类型的默认构造函数被自动设置为private(然而,正如您可能会怀疑的那样,您很少需要私有类构造函数):

// An internal class with a private default constructor.
class Radio
{
  Radio(){}
}

如果你想更明确,你可以自己添加这些关键字,而不会产生不良影响(除了一些额外的击键)。

// An internal class with a private default constructor.
internal class Radio
{
  private Radio(){}
}

为了允许程序的其他部分调用对象的成员,你必须用public关键字定义它们(或者可能用protected关键字,你将在下一章中学习)。同样,如果您想要将Radio公开给外部程序集(同样,在构建更大的解决方案或代码库时很有用),您将需要添加public修饰符。

// A public class with a public default constructor.
public class Radio
{
  public Radio(){}
}

使用访问修饰符和嵌套类型

如表 5-1 所述,privateprotectedprotected internalprivate protected访问修饰符可以应用于嵌套类型。第六章将详细研究嵌套。但是,此时您需要知道的是,嵌套类型是直接在类或结构的范围内声明的类型。举例来说,下面是嵌套在公共类(名为SportsCar)中的私有枚举(名为CarColor):

public class SportsCar
{
  // OK! Nested types can be marked private.
  private enum CarColor
  {
    Red, Green, Blue
  }
}

这里,允许在嵌套类型上应用private访问修饰符。然而,非嵌套类型(如SportsCar)只能用publicinternal修饰符来定义。因此,下面的类定义是非法的:

// Error! Nonnested types cannot be marked private!
private class SportsCar
{}

理解第一个支柱:C# 的封装服务

封装的概念围绕着这样一个概念,即不能从对象实例直接访问对象的数据。相反,类数据被定义为私有的。如果对象用户想要改变对象的状态,它可以通过使用公共成员来间接实现。为了说明封装服务的需要,假设您已经创建了以下类定义:

// A class with a single public field.
class Book
{
  public int numberOfPages;
}

公共数据的问题在于,数据本身无法“理解”它被赋予的当前值对于系统的当前业务规则是否有效。如你所知,一个 C# int的上界是相当大的(2,147,483,647)。因此,编译器允许以下赋值:

// Humm. That is one heck of a mini-novel!
Book miniNovel = new Book();
miniNovel.numberOfPages = 30_000_000;

虽然你还没有溢出一个int数据类型的边界,但是应该清楚一个 3000 万页的迷你小说有点不合理。如您所见,公共字段没有提供捕获逻辑上限(或下限)的方法。如果您当前的系统有一个业务规则,规定一本书必须在 1 到 1,000 页之间,那么您会不知如何通过编程来强制执行。因此,公共字段通常在生产级类定义中没有位置。

Note

更具体地说,表示对象状态的类成员不应该被标记为 public。正如你将在本章后面看到的,公共常量和公共只读字段非常有用。

封装提供了一种保持对象状态数据完整性的方法。您应该养成定义私有数据的习惯,而不是定义公共字段(这很容易导致数据损坏),私有数据是使用两种主要技术之一间接操纵的。

  • 您可以定义一对公共访问器(get)和赋值器(set)方法。

  • 您可以定义公共属性。

无论您选择哪种技术,关键是一个封装良好的类应该保护它的数据,并对外界隐藏它如何操作的细节。这通常被称为黑盒编程。这种方法的美妙之处在于,对象可以自由地改变给定方法的实现方式。只要方法的参数和返回值保持不变,它就不会破坏任何使用它的现有代码。

使用传统的访问器和赋值器进行封装

在本章余下的几页中,你将构建一个相当完整的类来模拟一个普通雇员。首先,创建一个名为 EmployeeApp 的新控制台应用项目,并创建一个名为Employee.cs的新类文件。用以下名称空间、字段、方法和构造函数更新Employee类:

using System;
namespace EmployeeApp
{
  class Employee
  {
    // Field data.
    private string _empName;
    private int _empId;
    private float _currPay;

    // Constructors.
    public Employee() {}
    public Employee(string name, int id, float pay)
    {
      _empName = name;
      _empId = id;
      _currPay = pay;
    }

    // Methods.
    public void GiveBonus(float amount) => _currPay += amount;
    public void DisplayStats()
    {
      Console.WriteLine("Name: {0}", _empName);
      Console.WriteLine("ID: {0}", _empId);
      Console.WriteLine("Pay: {0}", _currPay);
    }
  }
}

注意,Employee类的字段目前是使用private关键字定义的。鉴于此,不能从对象变量直接访问 _ empName、_ empId和 _ currPay字段。因此,代码中的以下逻辑会导致编译器错误:

Employee emp = new Employee();
// Error! Cannot directly access private members
// from an object!
emp._empName = "Marv";

如果希望外部世界与工人的全名进行交互,传统的方法是定义一个访问器(get方法)和一个赋值器(set方法)。get方法的作用是向调用者返回底层状态数据的当前值。set方法允许调用者改变底层状态数据的当前值,只要满足定义的业务规则。

为了说明,让我们封装empName字段。为此,将下面的public方法添加到Employee类中。注意,SetName()方法对传入的数据执行测试,以确保string不超过 15 个字符。否则,控制台会显示一条错误信息,并且不会对empName字段进行任何更改。

Note

如果这是一个生产级别的类,您还需要在构造函数逻辑中检查雇员姓名的字符长度。暂时忽略这个细节,因为在检查属性语法时,您将很快清理这段代码。

class Employee
{
  // Field data.
  private string _empName;
  ...

  // Accessor (get method).
  public string GetName() => _empName;

  // Mutator (set method).
  public void SetName(string name)
  {
    // Do a check on incoming value
    // before making assignment.
    if (name.Length > 15)
    {
      Console.WriteLine("Error! Name length exceeds 15 characters!");
    }
    else
    {
      _empName = name;
    }
  }
}

这种技术需要两个唯一命名的方法来操作单个数据点。若要测试新方法,请按如下方式更新代码方法:

Console.WriteLine("***** Fun with Encapsulation *****\n");
Employee emp = new Employee("Marvin", 456, 30_000);
emp.GiveBonus(1000);
emp.DisplayStats();

// Use the get/set methods to interact with the object's name.
emp.SetName("Marv");
Console.WriteLine("Employee is named: {0}", emp.GetName());
Console.ReadLine();

由于您的SetName()方法中的代码,如果您试图指定超过 15 个字符(见下文),您会发现硬编码的错误消息被打印到控制台:

Console.WriteLine("***** Fun with Encapsulation *****\n");
...
// Longer than 15 characters! Error will print to console.
Employee emp2 = new Employee();
emp2.SetName("Xena the warrior princess");

Console.ReadLine();

目前为止,一切顺利。您已经使用两个名为GetName()SetName()的公共方法封装了私有的empName字段。如果要进一步将数据封装在Employee类中,就需要添加各种额外的方法(比如GetID()SetID()GetCurrentPay()SetCurrentPay())。每个 mutator 方法也可以有多行代码来检查额外的业务规则。虽然这肯定可以做到,但 C# 语言有一个有用的替代符号来封装类数据。

使用属性封装

虽然您可以使用传统的getset方法封装一段字段数据。NET 核心语言更喜欢使用属性来强制数据封装状态数据。首先,要理解属性只是“真正的”访问器和赋值器方法的容器,分别命名为getset。因此,作为一个类设计者,您仍然能够在赋值之前执行任何必要的内部逻辑(例如,大写该值,清除该值中的非法字符,检查数值的界限,等等)。).

下面是更新后的Employee类,现在使用属性语法而不是传统的getset方法来强制封装每个字段:

class Employee
{
  // Field data.
  private string _empName;
  private int _empId;
  private float _currPay;
  // Properties!
  public string Name
  {
    get { return _empName; }
    set
    {
      if (value.Length > 15)
      {
        Console.WriteLine("Error! Name length exceeds 15 characters!");
      }
      else
      {
        _empName = value;
      }
    }
  }
  // We could add additional business rules to the sets of these properties;
  // however, there is no need to do so for this example.
  public int Id
  {
    get { return _empId; }
    set { _empId = value; }
  }
  public float Pay
  {
    get { return _currPay; }
    set { _currPay = value; }
  }
...
}

C# 属性是通过直接在属性本身中定义一个get作用域(访问器)和set作用域(赋值器)组成的。请注意,属性通过似乎是返回值的内容来指定它所封装的数据的类型。还要注意,与方法不同,属性在定义时不使用括号(甚至是空括号)。考虑以下对你目前在Id的房产的评论:

// The 'int' represents the type of data this property encapsulates.
public int Id // Note lack of parentheses.
{
  get { return _empId; }
  set { _empID = value; }
}

在属性的set范围内,使用一个名为value的令牌,它用于表示调用者用来分配属性的传入值。这个标记是而不是一个真正的 C# 关键字,而是被称为上下文关键字。当标记值在属性的设置范围内时,它总是表示由调用方分配的值,并且它总是与属性本身具有相同的基础数据类型。因此,请注意Name属性仍然可以测试string的范围,如下所示:

public string Name
{
  get { return _empName; }
  set
  {
    // Here, value is really a string.
    if (value.Length > 15)
    {   Console.WriteLine("Error! Name length exceeds 15 characters!");
    }
    else
    {
      empName = value;
    }
  }
}

在您设置好这些属性之后,调用者会觉得它正在获取和设置一个数据的公共点;然而,正确的getset块在后台被调用以保持封装。

Console.WriteLine("***** Fun with Encapsulation *****\n");
Employee emp = new Employee("Marvin", 456, 30000);
emp.GiveBonus(1000);
emp.DisplayStats();

// Reset and then get the Name property.
emp.Name = "Marv";
Console.WriteLine("Employee is named: {0}", emp.Name);
Console.ReadLine();

属性(相对于访问器和赋值器方法)也使您的类型更容易操作,因为属性能够响应 C# 的内部运算符。举例来说,假设Employee类类型有一个代表雇员年龄的内部私有成员变量。下面是相关的更新(注意构造函数链接的使用):

class Employee
{
...
   // New field and property.
   private int _empAge;
   public int Age
   {
     get { return _empAge; }
     set { _empAge = value; }
   }

   // Updated constructors.
   public Employee() {}
   public Employee(string name, int id, float pay)
   :this(name, 0, id, pay){}

   public Employee(string name, int age, int id, float pay)
   {
     _empName = name;
     _empId = id;
     _empAge = age;
     _currPay = pay;
   }

   // Updated DisplayStats() method now accounts for age.
   public void DisplayStats()
   {
     Console.WriteLine("Name: {0}", _empName);
     Console.WriteLine("ID: {0}", _empId);
     Console.WriteLine("Age: {0}", _empAge);
     Console.WriteLine("Pay: {0}", _currPay);
   }
}

现在假设您已经创建了一个名为joeEmployee对象。在他生日那天,你想把年龄加一。使用传统的访问器和赋值器方法,您需要编写如下代码:

Employee joe = new Employee();
joe.SetAge(joe.GetAge() + 1);

然而,如果您使用一个名为Age的属性封装empAge,您可以简单地这样写:

Employee joe = new Employee();
joe.Age++;

作为表达式主体成员的属性(新 7.0)

如前所述,属性getset访问器也可以写成表达式体成员。规则和语法是相同的:可以使用新的语法编写单行方法。所以,Age属性可以这样写:

public int Age
{
  get => empAge;
  set => empAge = value;
}

两种语法都编译成相同的 IL,所以使用哪种语法完全由您决定。在本文中,您将看到两种风格的混合,以保持它们的可见性,而不是因为我坚持特定的代码风格。

在类定义中使用属性

属性,特别是属性的set部分,是封装类的业务规则的常见地方。目前,Employee类有一个Name属性,确保名称不超过 15 个字符。剩余的属性(IDPayAge)也可以用任何相关的逻辑来更新。

虽然这很好,但也要考虑类构造函数通常在内部做什么。它将接受传入的参数,检查有效数据,然后对内部私有字段进行赋值。目前,您的主构造函数不而不是测试传入的字符串数据的有效范围,因此您可以这样更新这个成员:

public Employee(string name, int age, int id, float pay)
{
  // Humm, this seems like a problem...
  if (name.Length > 15)
  {
    Console.WriteLine("Error! Name length exceeds 15 characters!");
  }
  else
  {
    _empName = name;
  }
  _empId = id;
  _empAge = age;
  _currPay = pay;
}

我确信你能看到这种方法的问题。属性和你的主构造函数正在执行相同的错误检查。如果您还对其他数据点进行检查,您将会有大量重复的代码。为了简化您的代码并将所有的错误检查隔离到一个中心位置,如果您在需要获取或设置值时总是使用类中的属性,您会做得很好。考虑以下更新的构造函数:

public Employee(string name, int age, int id, float pay)
{
   // Better! Use properties when setting class data.
   // This reduces the amount of duplicate error checks.
   Name = name;
   Age = age;
   ID = id;
   Pay = pay;
}

除了更新构造函数以在赋值时使用属性之外,在整个类实现中使用属性来确保您的业务规则始终得到执行也是一个很好的实践。在许多情况下,直接引用底层私有数据的唯一时间是在属性本身中。记住这一点,下面是您更新的Employee类:

class Employee
{
   // Field data.
   private string _empName;
   private int _empId;
   private float _currPay;
   private int _empAge;
   // Constructors.
   public Employee() { }
   public Employee(string name, int id, float pay)
     :this(name, 0, id, pay){}
   public Employee(string name, int age, int id, float pay)
   {
     Name = name;
     Age = age;
     ID = id;
     Pay = pay;
   }
   // Methods.
   public void GiveBonus(float amount) => Pay += amount;

   public void DisplayStats()
   {
     Console.WriteLine("Name: {0}", Name);
     Console.WriteLine("ID: {0}", Id);
     Console.WriteLine("Age: {0}", Age);
     Console.WriteLine("Pay: {0}", Pay);
   }

   // Properties as before...
...
}

属性只读属性

当封装数据时,您可能想要配置一个只读属性。为此,只需省略set块。例如,假设您有一个名为SocialSecurityNumber的新属性,它封装了一个名为empSSN的私有string变量。如果您想使它成为只读属性,可以这样写:

public string SocialSecurityNumber
{
  get { return _empSSN; }
}

只有一个 getter 的属性也可以使用表达式体成员来简化。下面一行相当于前面的代码块:

public string SocialSecurityNumber => _empSSN;

现在假设您的类构造函数有一个新参数,让调用者设置对象的 SSN。因为SocialSecurityNumber属性是只读的,所以不能这样设置值:

public Employee(string name, int age, int id, float pay, string ssn)
{
   Name = name;
   Age = age;
   ID = id;
   Pay = pay;

   // OOPS! This is no longer possible if the property is read only.
   SocialSecurityNumber = ssn;
}

除非您愿意将属性重新设计为可读写的(您很快就会这么做),否则您对只读属性的唯一选择就是在构造函数逻辑中使用底层的empSSN成员变量,如下所示:

public Employee(string name, int age, int id, float pay, string ssn)
{
   ...
   // Check incoming ssn parameter as required and then set the value.
   empSSN = ssn;
}

属性只写属性

如果您想将您的属性配置为一个*只写属性,*省略了get块,如下所示:

public int Id
{
  set { _empId = value; }
}

混合属性的私有和公共 Get/Set 方法

定义属性时,getset方法的访问级别可以不同。重新查看社会安全号,如果目标是防止来自类之外的对该号的修改,那么将get方法声明为 public,而将set方法声明为 private,如下所示:

public string SocialSecurityNumber
{
  get => _empSSN;
  private set => _empSSN = value;
}

请注意,这将属性从只读更改为读写。不同之处在于,write 对定义类之外的任何东西都是隐藏的。

重温 static 关键字:定义静态属性

在本章的前面,您研究了关键字static的作用。现在您已经理解了 C# 属性语法的使用,您可以形式化静态属性了。在本章前面创建的 StaticDataAndMembers 项目中,您的SavingsAccount类有两个公共静态方法来获取和设置利率。然而,将这个数据点包装在一个静态属性中会更标准。下面是一个例子(注意static关键字的使用):

// A simple savings account class.
class SavingsAccount
{
   // Instance-level data.
   public double currBalance;

   // A static point of data.
   private static double _currInterestRate = 0.04;

   // A static property.
   public static double InterestRate
   {
     get { return _currInterestRate; }
     set { _currInterestRate = value; }
   }
...
}

如果您想要使用这个属性来取代先前的静态方法,可以更新您的程式码,如下所示:

// Print the current interest rate via property.
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.InterestRate);

与属性模式匹配的模式(新 8.0)

属性模式使您能够匹配对象的属性。要设置该示例,请添加一个新文件(EmployeePayTypeEnum.cs)来枚举员工工资类型,如下所示:

namespace EmployeeApp
{
    public enum EmployeePayTypeEnum
    {
        Hourly,
        Salaried,
        Commission
    }
}

用 pay 类型的属性更新Employee类,并从构造函数中初始化它。下面列出了相关的代码更改:

private EmployeePayTypeEnum _payType;
public EmployeePayTypeEnum PayType
{
  get => _payType;
  set => _payType = value;
}
public Employee(string name, int id, float pay, string empSsn)
  : this(name,0,id,pay, empSsn, EmployeePayTypeEnum.Salaried)
{
}
public Employee(string name, int age, int id,
  float pay, string empSsn, EmployeePayTypeEnum payType)
{
  Name = name;
  Id = id;
  Age = age;
  Pay = pay;
  SocialSecurityNumber = empSsn;
  PayType = payType;
}

现在所有的部分都已经就绪,可以根据雇员的工资类型更新GiveBonus()方法。受委托的员工获得 10%的奖金,每小时获得相当于 40 小时的按比例分配的奖金,受薪员工获得输入的金额。更新后的GiveBonus()方法如下:

public void GiveBonus(float amount)
{
  Pay = this switch
  {
    {PayType: EmployeePayTypeEnum.Commission }
      => Pay += .10F * amount,
    {PayType: EmployeePayTypeEnum.Hourly }
      => Pay += 40F * amount/2080F,
    {PayType: EmployeePayTypeEnum.Salaried }
      => Pay += amount,
    _ => Pay+=0
  };
}

与其他使用模式匹配的switch语句一样,如果没有一个case语句被满足,要么必须有一个包罗万象的case语句,要么switch语句必须抛出一个异常。

要对此进行测试,请将以下代码添加到顶级语句中:

Employee emp = new Employee("Marvin",45,123,1000,"111-11-1111",EmployeePayTypeEnum.Salaried);
Console.WriteLine(emp.Pay);
emp.GiveBonus(100);
Console.WriteLine(emp.Pay);

了解自动属性

当您构建属性来封装数据时,通常会发现 set 作用域具有强制执行程序业务规则的代码。但是,在某些情况下,除了简单地获取和设置值之外,您可能不需要任何实现逻辑。这意味着您可能会得到如下所示的大量代码:

// An Employee Car type using standard property
// syntax.
class Car
{
   private string carName = "";
   public string PetName
   {
     get { return carName; }
     set { carName = value; }
   }
}

在这些情况下,多次定义私有支持字段和简单的属性定义会变得相当冗长。举例来说,如果您正在对一个需要九个私有字段数据点的类进行建模,那么您最终会创作九个相关的属性,这些属性只不过是封装服务的瘦包装器。

为了简化提供简单字段数据封装的过程,您可以使用自动属性语法。顾名思义,这个特性将使用新的语法把定义私有支持字段和相关 C# 属性成员的工作卸载给编译器。举例来说,创建一个名为 AutoProps 的新控制台应用项目,并添加一个名为Car.cs的新类文件。现在,考虑对Car类的修改,它使用这个语法快速创建三个属性:

using System;

namespace AutoProps
{
  class Car
  {
     // Automatic properties! No need to define backing fields.
     public string PetName { get; set; }
     public int Speed { get; set; }
     public string Color { get; set; }
  }
}

Note

Visual Studio 和 Visual Studio 代码提供了prop代码片段。如果在类定义中键入prop并按 Tab 键两次,ide 将为新的自动属性生成启动代码。然后,您可以使用 Tab 键在定义的每个部分中循环,以填充细节。试试看!

定义自动属性时,只需指定访问修饰符、底层数据类型、属性名和空的get / set范围。在编译时,你的类型将被提供一个自动生成的私有支持字段和一个合适的get / set逻辑实现。

Note

自动生成的私有支持字段的名称在 C# 代码库中不可见。查看它的唯一方法是使用一个工具,比如ildasm.exe

从 C# 版本 6 开始,可以通过省略set范围来定义“只读自动属性”。只读自动属性只能在构造函数中设置。但是,不可能定义只写属性。为了巩固,请考虑以下几点:

// Read-only property? This is OK!
public int MyReadOnlyProp { get; }

// Write only property? Error!
public int MyWriteOnlyProp { set; }

与自动属性交互

因为编译器将在编译时定义私有支持字段(并且假定这些字段不能在 C# 代码中直接访问),所以类定义的自动属性将总是需要使用属性语法来获取和设置基础值。这一点很重要,因为许多程序员直接使用类定义中的私有字段*,这在这种情况下是不可能的。例如,如果Car类要提供一个DisplayStats()方法,它需要使用属性名来实现这个方法。*

class Car
{
   // Automatic properties!
   public string PetName { get; set; }
   public int Speed { get; set; }
   public string Color { get; set; }

   public void DisplayStats()
   {
     Console.WriteLine("Car Name: {0}", PetName);
     Console.WriteLine("Speed: {0}", Speed);
     Console.WriteLine("Color: {0}", Color);
   }
}

当您使用用自动属性定义的对象时,您将能够使用预期的属性语法分配和获取值。

using System;
using AutoProps;

Console.WriteLine("***** Fun with Automatic Properties *****\n");

Car c = new Car();
c.PetName = "Frank";
c.Speed = 55;
c.Color = "Red";

Console.WriteLine("Your car is named {0}? That's odd...",
  c.PetName);
c.DisplayStats();

Console.ReadLine();

属性自动属性和默认值

当您使用自动属性来封装数字或布尔数据时,您可以在代码库中直接使用自动生成的类型属性,因为隐藏的后台字段将被分配一个安全的默认值(false用于布尔数据,0用于数字数据)。但是,请注意,如果使用自动属性语法包装另一个类变量,隐藏的私有引用类型也将被设置为默认值null(如果不小心的话,这可能会有问题)。

让我们在您当前的项目中插入一个名为Garage.cs的新类文件,它利用了两个自动属性(当然,一个真正的 garage 类可能会维护一个Car对象的集合;然而,忽略这里细节)。

namespace AutoProps
{
  class Garage
  {
     // The hidden int backing field is set to zero!
     public int NumberOfCars { get; set; }

     // The hidden Car backing field is set to null!
     public Car MyAuto { get; set; }
  }
}

给定 C# 字段数据的默认值,您将能够按原样打印出NumberOfCars的值(因为它被自动赋予零值),但是如果您直接调用MyAuto,您将在运行时收到一个“空引用异常”,因为在后台使用的Car成员变量还没有被赋予一个新的对象。

...
Garage g = new Garage();

// OK, prints default value of zero.
Console.WriteLine("Number of Cars: {0}", g.NumberOfCars);

// Runtime error! Backing field is currently null!
Console.WriteLine(g.MyAuto.PetName);
Console.ReadLine();

要解决这个问题,您可以更新类构造函数,以确保对象以安全的方式出现。这里有一个例子:

class Garage
{
   // The hidden backing field is set to zero!
   public int NumberOfCars { get; set; }
   // The hidden backing field is set to null!
   public Car MyAuto { get; set; }
   // Must use constructors to override default
   // values assigned to hidden backing fields.
   public Garage()
   {
     MyAuto = new Car();
     NumberOfCars = 1;
   }
   public Garage(Car car, int number)
   {
     MyAuto = car;
     NumberOfCars = number;
   }
}

通过这一修改,您现在可以将一个Car对象放入Garage对象中,如下所示:

Console.WriteLine("***** Fun with Automatic Properties *****\n");

// Make a car.
Car c = new Car();
c.PetName = "Frank";
c.Speed = 55;
c.Color = "Red";
c.DisplayStats();

// Put car in the garage.
Garage g = new Garage();
g.MyAuto = c;
Console.WriteLine("Number of Cars in garage: {0}", g.NumberOfCars);
Console.WriteLine("Your car is named: {0}", g.MyAuto.PetName);

Console.ReadLine();

初始化自动属性

虽然前面的方法是可行的,但是自从 C# 6 发布以来,您就可以使用一种语言特性来简化自动属性接收初始值赋值的方式。回想一下本章的开头,一个类的数据字段可以在声明时直接被赋予一个初始值。这里有一个例子:

class Car
{
  private int numberOfDoors = 2;
}

以类似的方式,C# 现在允许您为编译器生成的底层支持字段分配初始值。这减轻了您在类构造函数中添加代码语句以确保属性数据符合预期的麻烦。

这是一个更新版本的Garage类,它将自动属性初始化为合适的值。注意你不再需要添加逻辑到你的默认类构造函数来进行安全赋值。在这个迭代中,你直接分配一个新的Car对象给MyAuto属性。

class Garage
{
    // The hidden backing field is set to 1.
    public int NumberOfCars { get; set; } = 1;

    // The hidden backing field is set to a new Car object.
    public Car MyAuto { get; set; } = new Car();

    public Garage(){}
    public Garage(Car car, int number)
    {
        MyAuto = car;
        NumberOfCars = number;
    }
}

您可能同意,自动属性是 C# 编程语言的一个很好的特性,因为您可以使用简化的语法为一个类定义许多属性。当然,如果您正在构建一个除了获取和设置底层私有字段之外还需要额外代码的属性(如数据验证逻辑、写入事件日志、与数据库通信等),请注意。),你将被要求定义一个“正常”。手动输入网络核心属性类型。C# 自动属性只不过为底层(编译器生成的)私有数据提供简单的封装。

了解对象初始化

如本章所示,构造函数允许您在创建新对象时指定启动值。另一方面,属性允许您以安全的方式获取和设置基础数据。当您使用其他人的类时,包括在。NET 核心基础类库,你会发现没有一个构造函数允许你设置每一个底层状态数据。鉴于这一点,程序员通常被迫选择可能的最佳构造函数,之后程序员使用一些提供的属性进行赋值。

查看对象初始化语法

为了帮助简化启动和运行对象的过程,C# 提供了对象初始化语法。使用这种技术,可以用几行代码创建一个新的对象变量,并分配一系列属性和/或公共字段。从语法上来说,对象初始化器由一个逗号分隔的指定值列表组成,由{}标记括起来。初始化列表中的每个成员都映射到正在初始化的对象的公共字段或公共属性的名称。

要查看此语法的运行情况,请创建一个名为 ObjectInitializers 的新控制台应用项目。现在,考虑一个名为Point的简单类,它是使用自动属性创建的(对于对象初始化语法来说,这不是强制性的,但是可以帮助您编写一些简洁的代码)。

class Point
{
   public int X { get; set; }
   public int Y { get; set; }

   public Point(int xVal, int yVal)
   {
     X = xVal;
     Y = yVal;
   }
   public Point() { }

   public void DisplayStats()
   {
     Console.WriteLine("[{0}, {1}]", X, Y);
   }
}

现在考虑如何使用以下方法制作Point对象:

Console.WriteLine("***** Fun with Object Init Syntax *****\n");

// Make a Point by setting each property manually.
Point firstPoint = new Point();
firstPoint.X = 10;
firstPoint.Y = 10;
firstPoint.DisplayStats();

// Or make a Point via a custom constructor.
Point anotherPoint = new Point(20, 20);
anotherPoint.DisplayStats();

// Or make a Point using object init syntax.
Point finalPoint = new Point { X = 30, Y = 30 };
finalPoint.DisplayStats();
Console.ReadLine();

最后一个Point变量没有使用定制的构造函数(就像传统的做法一样),而是为公共的XY属性设置值。在后台,调用类型的默认构造函数,然后将值设置为指定的属性。为此,对象初始化语法只是用于使用默认构造函数创建类变量和逐个属性设置状态数据的语法的速记符号。

Note

重要的是要记住,对象初始化过程是隐式使用属性 setter 的。如果属性 setter 标记为 private,则不能使用此语法。

使用仅初始化的设置器(新 9.0)

C# 9.0 中增加的一个新特性是init -only setters。这些设置器使属性能够在初始化过程中设置其值,但在对象上的构造完成后,属性将变为只读。这些类型的属性被称为*不可变的。*将名为ReadOnlyPointAfterCreation.cs的新类文件添加到您的项目中,并添加以下代码:

using System;

namespace ObjectInitializers
{
  class PointReadOnlyAfterCreation
  {
    public int X { get; init; }
    public int Y { get; init; }

    public void DisplayStats()
    {
      Console.WriteLine("InitOnlySetter: [{0}, {1}]", X, Y);
    }
    public PointReadOnlyAfterCreation(int xVal, int yVal)
    {
      X = xVal;
      Y = yVal;
    }
    public PointReadOnlyAfterCreation() { }
  }
}

使用下面的代码来测试这个新类:

//Make readonly point after construction
PointReadOnlyAfterCreation firstReadonlyPoint = new PointReadOnlyAfterCreation(20, 20);
firstReadonlyPoint.DisplayStats();

// Or make a Point using object init syntax.
PointReadOnlyAfterCreation secondReadonlyPoint = new PointReadOnlyAfterCreation { X = 30, Y = 30 };
secondReadonlyPoint.DisplayStats();

请注意,您为Point类编写的代码没有任何变化,当然类名除外。区别在于一旦创建了类,就不能修改XY的值。例如,以下代码将不会编译:

//The next two lines will not compile
secondReadonlyPoint.X = 10;
secondReadonlyPoint.Y = 10;

使用初始化语法调用自定义构造函数

前面的例子通过隐式调用类型的默认构造函数来初始化Point类型。

// Here, the default constructor is called implicitly.
Point finalPoint = new Point { X = 30, Y = 30 };

如果您想弄清楚这一点,可以显式调用默认构造函数,如下所示:

// Here, the default constructor is called explicitly.
Point finalPoint = new Point() { X = 30, Y = 30 };

请注意,当您使用初始化语法构造类型时,您可以调用由该类定义的任何构造函数。您的Point类型当前定义了一个双参数构造函数来设置( x,y )位置。因此,下面的Point声明导致100X值和100Y值,而不管构造函数参数指定了值1016的事实:

// Calling a custom constructor.
Point pt = new Point(10, 16) { X = 100, Y = 100 };

给定您的Point类型的当前定义,在使用初始化语法的同时调用自定义构造函数并不是非常有用(而且有点冗长)。但是,如果您的Point类型提供了一个新的构造函数,允许调用者建立一种颜色(通过一个名为PointColor的自定义enum,自定义构造函数和对象初始化语法的组合就变得很清楚了。

将名为PointColorEnum.cs的新类添加到您的项目中,并添加以下代码来创建颜色的枚举:

namespace ObjectInitializers
{
  enum PointColorEnum
  {
    LightBlue,
    BloodRed,
    Gold
  }
}

现在,更新Point类,如下所示:

class Point
{
   public int X { get; set; }
   public int Y { get; set; }
   public PointColorEnum Color{ get; set; }

   public Point(int xVal, int yVal)
   {
     X = xVal;
     Y = yVal;
     Color = PointColorEnum.Gold;
   }

   public Point(PointColorEnum ptColor)
   {
     Color = ptColor;
   }

   public Point() : this(PointColorEnum.BloodRed){ }

   public void DisplayStats()
   {
     Console.WriteLine("[{0}, {1}]", X, Y);
     Console.WriteLine("Point is {0}", Color);
   }
}

使用这个新的构造函数,您现在可以创建一个黄金点(位于 90,20 ),如下所示:

// Calling a more interesting custom constructor with init syntax.
Point goldPoint = new Point(PointColorEnum.Gold){ X = 90, Y = 20 };
goldPoint.DisplayStats();

使用初始化语法初始化数据

正如本章前面简要提到的(在第六章中也有详细讨论),“has-a”关系允许你通过定义现有类的成员变量来组成新的类。例如,假设您现在有一个Rectangle类,它利用Point类型来表示它的左上角/右下角坐标。因为自动属性将类变量的所有字段设置为null,所以您将使用“传统”属性语法来实现这个新类。

using System;

namespace ObjectInitializers
{
  class Rectangle
  {
    private Point topLeft = new Point();
    private Point bottomRight = new Point();

    public Point TopLeft
    {
      get { return topLeft; }
      set { topLeft = value; }
    }
    public Point BottomRight
    {
      get { return bottomRight; }
      set { bottomRight = value; }
    }

    public void DisplayStats()
    {
      Console.WriteLine("[TopLeft: {0}, {1}, {2} BottomRight: {3}, {4}, {5}]",
          topLeft.X, topLeft.Y, topLeft.Color,
          bottomRight.X, bottomRight.Y, bottomRight.Color);
    }
  }
}

使用对象初始化语法,您可以创建一个新的Rectangle变量,并如下设置内部的Point:

// Create and initialize a Rectangle.
Rectangle myRect = new Rectangle
{
   TopLeft = new Point { X = 10, Y = 10 },
   BottomRight = new Point { X = 200, Y = 200}
};

同样,对象初始化语法的好处是它基本上减少了击键次数(假设没有合适的构造函数)。以下是建立类似Rectangle的传统方法:

// Old-school approach.
Rectangle r = new Rectangle();
Point p1 = new Point();
p1.X = 10;
p1.Y = 10;
r.TopLeft = p1;
Point p2 = new Point();
p2.X = 200;
p2.Y = 200;
r.BottomRight = p2;

虽然您可能会觉得对象初始化语法可能需要一点时间来适应,但是一旦您对代码感到满意,您将会对能够以最少的麻烦和麻烦快速建立新对象的状态感到非常满意。

使用常量和只读字段数据

有时,您需要一个您根本不想更改的属性,也称为不可变的,无论是从它被编译的时候还是在构造期间被设置之后。我们已经研究了一个只有init设置器的例子。现在我们将检查常量和只读字段。

了解常量字段数据

C# 提供了const关键字来定义常量数据,这些数据在初始赋值后永远不会改变。正如您可能猜到的那样,当您在应用中定义一组与给定的类或结构有逻辑联系的已知值时,这可能会很有帮助。

假设您正在构建一个名为MyMathClass的实用程序类,它需要为 pi 定义一个值(为简单起见,您将假设该值为 3.14)。首先创建一个名为 ConstData 的新控制台应用项目,并添加一个名为MyMathClass.cs的文件。假设您不想让其他开发人员在代码中更改这个值,那么 pi 可以用下面的常量来建模:

//MyMathClass.cs
using System;
namespace ConstData
{
   class MyMathClass
   {
     public const double PI = 3.14;
   }
}

更新Program.cs类中的代码,使其与下面的代码相匹配:

using System;
using ConstData;

Console.WriteLine("***** Fun with Const *****\n");
Console.WriteLine("The value of PI is: {0}", MyMathClass.PI);
// Error! Can't change a constant!
// MyMathClass.PI = 3.1444;

Console.ReadLine();

请注意,您正在使用类名前缀(例如,MyMathClass.PI)引用由MyMathClass定义的常量数据。这是因为类的常量字段是隐式的静态的。但是,允许在方法或属性的范围内定义和访问局部常量变量。这里有一个例子:

static void LocalConstStringVariable()
{
   // A local constant data point can be directly accessed.
   const string fixedStr = "Fixed string Data";
   Console.WriteLine(fixedStr);

   // Error!
   // fixedStr = "This will not work!";
}

不管在哪里定义一个数据常量,有一点要记住,在定义常量时必须指定赋给常量的初始值。在类别建构函式中指派 pi 的值,如下列程式码所示,会产生编译错误:

class MyMathClass
{
   // Try to set PI in ctor?
   public const double PI;

   public MyMathClass()
   {
     // Not possible- must assign at time of declaration.
     PI = 3.14;
   }
}

这种限制的原因是常量数据的值在编译时必须是已知的。众所周知,构造函数(或任何其他方法)都是在运行时被调用的。

了解只读字段

与常量数据密切相关的是只读字段数据(不要与只读属性混淆)。像常量一样,只读字段在初始赋值后不能更改,否则会收到编译时错误。但是,与常量不同,赋给只读字段的值可以在运行时确定,因此可以合法地在构造函数的范围内赋值,但不能在其他地方赋值。

当您直到运行时才知道字段的值时,这可能会很有帮助,因为您需要读取外部文件来获取该值,但希望确保该值在该点之后不会更改。为了便于说明,假设对MyMathClass进行如下更新:

class MyMathClass
{
   // Read-only fields can be assigned in ctors,
   // but nowhere else.
   public readonly double PI;

   public MyMathClass ()
   {
     PI = 3.14;
   }
}

同样,任何在构造函数范围之外对标记为readonly的字段进行赋值的尝试都会导致编译器错误。

class MyMathClass
{
   public readonly double PI;
   public MyMathClass ()
   {
     PI = 3.14;
   }

   // Error!
   public void ChangePI()
   { PI = 3.14444;}
}

了解静态只读字段

与常量字段不同,只读字段不是隐式静态的。因此,如果您想从类级别公开PI,您必须显式地使用static关键字。如果你在编译时知道一个静态只读字段的值,初始赋值看起来类似于一个常量的值(然而,在这种情况下,首先简单地使用const关键字会更容易,因为你是在声明时给数据字段赋值的)。

class MyMathClass
{
   public static readonly double PI = 3.14;
}

//Program.cs
Console.WriteLine("***** Fun with Const *****");
Console.WriteLine("The value of PI is: {0}", MyMathClass.PI);
Console.ReadLine();

但是,如果静态只读字段的值直到运行时才知道,则必须使用静态构造函数,如本章前面所述。

class MyMathClass
{
   public static readonly double PI;

   static MyMathClass()
   { PI = 3.14; }
}

了解分部类

当处理类时,理解 C# partial关键字的作用是很重要的。partial 关键字允许将单个类划分到多个代码文件中。当您从数据库中构建实体框架核心类时,创建的类都是作为分部类创建的。这样,假设您的代码位于标有partial关键字的单独的类文件中,那么您编写的用于扩充这些文件的任何代码都不会被覆盖。另一个原因是,随着时间的推移,您的类可能已经变得难以管理,作为重构该类的中间步骤,您可以将它拆分成片段。

在 C# 中,您可以将单个类划分到多个代码文件中,以将样板代码与更有用(和复杂)的成员隔离开来。为了说明分部类的用处,请在 Visual Studio 中打开本章前面创建的 EmployeeApp 项目,然后打开Employee.cs文件进行编辑。正如您所记得的,这个文件包含了该类所有方面的代码。

class Employee
{
   // Field Data

   // Constructors

   // Methods

   // Properties
}

使用分部类,您可以选择将(例如)属性、构造函数和字段数据移动到一个名为Employee.Core.cs的新文件中(文件名无关紧要)。第一步是将关键字partial添加到当前的类定义中,并剪切要放入新文件中的代码。

// Employee.cs
partial class Employee
{
   // Methods

   // Properties
}

接下来,假设您已经在项目中插入了一个新的类文件,您可以使用简单的剪切和粘贴操作将数据字段和属性移动到新文件中。此外,您必须partial关键字添加到类定义的这个方面。这里有一个例子:

// Employee.Core.cs
partial class Employee
{
   // Field data

   // Properties
}

Note

记住,每个分部类都必须用关键字partial标记!

编译修改后的项目后,您应该看不到任何区别。分部类的整体思想只有在设计时才能实现。应用编译后,程序集中只有一个统一的类。定义分部类型时,唯一的要求是类型的名称(在本例中为Employee)是相同的,并在相同的。NET 核心命名空间。

使用记录(新 9.0)

在 C# 9.0 中新增,记录 类型是一个特殊类型的类。记录是提供合成方法的引用类型,以提供相等的值语义。默认情况下,记录类型是不可变的。虽然您本质上可以创建一个不可变的类,但是使用只包含init的 setters 和只读属性的组合,记录类型消除了额外的工作。

要开始试验记录,创建一个名为 FunWithRecords 的新控制台应用。考虑下面的Car类,它是根据本章前面的例子修改的:

class Car
{
  public string Make { get; set; }
  public string Model { get; set; }
  public string Color { get; set; }

  public Car() {}

  public Car(string make, string model, string color)
  {
    Make = make;
    Model = model;
    Color = color;
  }
}

正如您现在所知道的,一旦您创建了这个类的实例,您就可以在运行时更改任何属性。如果每个实例都需要是不可变的,您可以将属性定义更改为以下内容:

public string Make { get; init; }
public string Model { get; init; }
public string Color { get; init; }

为了测试这个新类,下面的代码创建了两个Car类的实例,一个通过对象初始化,另一个通过自定义构造函数。将Program.cs文件更新为以下内容:

using System;
using FunWithRecords;

Console.WriteLine("Fun with Records!");

//Use object initialization
Car myCar = new Car
{
    Make = "Honda",
    Model = "Pilot",
    Color = "Blue"
};
Console.WriteLine("My car: ");
DisplayCarStats(myCar);
Console.WriteLine();
//Use the custom constructor
Car anotherMyCar = new Car("Honda", "Pilot", "Blue");
Console.WriteLine("Another variable for my car: ");
DisplayCarStats(anotherMyCar);
Console.WriteLine();

//Compile error if property is changed
//myCar.Color = "Red";

Console.ReadLine();

static void DisplayCarStats(Car c)
{
  Console.WriteLine("Car Make: {0}", c.Make);
  Console.WriteLine("Car Model: {0}", c.Model);
  Console.WriteLine("Car Color: {0}", c.Color);
}

正如预期的那样,这两种对象创建方法都可以工作,属性会显示出来,而在构造后试图更改属性会引发编译错误。

要创建一个Car记录类型,向您的项目添加一个名为(CarRecord.cs)的新文件,并添加以下代码:

record CarRecord
{
  public string Make { get; init; }
  public string Model { get; init; }
  public string Color { get; init; }

  public CarRecord () {}
  public CarRecord (string make, string model, string color)
  {
    Make = make;
    Model = model;
    Color = color;
  }
}

通过在Program.cs中运行以下代码,您可以确认该行为与仅具有init设置的Car类相同:

Console.WriteLine("/*************** RECORDS *********************/");
//Use object initialization
CarRecord myCarRecord = new CarRecord
{
    Make = "Honda",
    Model = "Pilot",
    Color = "Blue"
};
Console.WriteLine("My car: ");
DisplayCarRecordStats(myCarRecord);
Console.WriteLine();

//Use the custom constructor
CarRecord anotherMyCarRecord = new CarRecord("Honda", "Pilot", "Blue");
Console.WriteLine("Another variable for my car: ");
Console.WriteLine(anotherMyCarRecord.ToString());
Console.WriteLine();

//Compile error if property is changed
//myCarRecord.Color = "Red";.

Console.ReadLine();

虽然我们还没有讨论记录的相等性(下一节)或继承性(下一章),但是第一次看记录似乎没什么好处。当前的Car例子包含了我们所期望的所有管道代码。在输出上有一个显著的区别:ToString()方法是为记录类型设计的,如下面的示例输出所示:

/*************** RECORDS *********************/
My car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }
Another variable for my car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }

但是考虑一下这个更新后的Car记录的定义:

record CarRecord(string Make, string Model, string Color);

称为位置记录类型,构造函数定义了记录的属性,所有其他的管道代码都被删除了。使用此语法时有三个注意事项:第一,不能使用紧凑定义语法对记录类型进行对象初始化;第二,必须在正确的位置使用属性构造记录;第三,构造函数中属性的大小写直接转换为记录类型属性的大小写。

了解记录类型的相等性

Car类的例子中,两个Car实例是用完全相同的数据创建的。一个可能认为这两个类是相等的,如下面的代码测试行所示:

Console.WriteLine($"Cars are the same? {myCar.Equals(anotherMyCar)}");

然而,它们并不平等。回想一下,记录类型是类的一种特殊类型,而类是引用类型*。要使两个引用类型相等,它们必须指向内存中的同一个对象。作为进一步的测试,检查两个Car对象是否指向同一个对象:*

Console.WriteLine($"Cars are the same reference? {ReferenceEquals(myCar, anotherMyCar)}");

再次运行该程序会产生以下(简略)结果:

Cars are the same? False
CarRecords are the same? False

记录类型的行为不同。记录类型隐式地覆盖了Equals==!=,它们产生的结果就像实例是值类型一样。考虑下面的代码及其结果:

Console.WriteLine($"CarRecords are the same? {myCarRecord.Equals(anotherMyCarRecord)}");
Console.WriteLine($"CarRecords are the same reference? {ReferenceEquals(myCarRecord,anotherMyCarRecord)}");
Console.WriteLine($"CarRecords are the same? {myCarRecord == anotherMyCarRecord}");
Console.WriteLine($"CarRecords are not the same? {myCarRecord != anotherMyCarRecord}");
/*************** RECORDS *********************/
My car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }
Another variable for my car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }

CarRecords are the same? True
CarRecords are the same reference? false
CarRecords are the same? True
CarRecords are not the same? False

请注意,它们被认为是相等的,即使变量指向内存中的两个不同的变量。

使用 with 表达式复制记录类型

对于记录类型,将记录类型实例分配给新变量会创建一个指向相同引用的指针,这与类的行为相同。下面的代码演示了这一点:

CarRecord carRecordCopy = anotherMyCarRecord;
Console.WriteLine("Car Record copy results");
Console.WriteLine($"CarRecords are the same? {carRecordCopy.Equals(anotherMyCarRecord)}");
Console.WriteLine($"CarRecords are the same? {ReferenceEquals(carRecordCopy, anotherMyCarRecord)}");

执行时,两个测试都返回Yes,证明它们的值和引用是相同的。

为了创建修改了一个或多个属性的记录的真实副本,C# 9.0 引入了带有表达式的*。*在with构造中,任何需要更新的属性都用它们的新值指定,任何没有列出的属性都被精确复制。查看以下示例:

CarRecord ourOtherCar = myCarRecord with {Model = "Odyssey"};
Console.WriteLine("My copied car:");
Console.WriteLine(ourOtherCar.ToString());

Console.WriteLine("Car Record copy using with expression results");
Console.WriteLine($"CarRecords are the same? {ourOtherCar.Equals(myCarRecord)}");
Console.WriteLine($"CarRecords are the same? {ReferenceEquals(ourOtherCar, myCarRecord)}");

代码创建了一个CarRecord类型的新实例,复制了myCarRecord实例的MakeColor值,并将Model设置为字符串Odyssey。此代码的结果如下所示:

/*************** RECORDS *********************/
My copied car:
CarRecord { Make = Honda, Model = Odyssey, Color = Blue }

Car Record copy using with expression results
CarRecords are the same? False
CarRecords are the same? False

使用with表达式,您可以用更新的属性值将记录类型组合成新的记录类型实例。

这就结束了我们对新的 C# 9.0 记录类型的第一次观察。下一章将研究记录类型和继承。

摘要

本章的目的是向你介绍 C# 类类型和新的 C# 9.0 记录类型的作用。如您所见,类可以接受任意数量的构造函数,使对象用户能够在创建时建立对象的状态。本章还举例说明了几种类设计技术(以及相关的关键字)。关键字this可以用来访问当前对象。static关键字允许您定义在类(而不是对象)级别绑定的字段和成员。只有const关键字、readonly修饰符和init设置器允许你定义一个数据点,它在初始赋值或对象构造之后永远不会改变。记录类型是一种特殊类型的类,它是不可变的,当将一个记录类型与同一记录类型的另一个实例进行比较时,其行为类似于值类型。

本章的大部分深入探讨了 OOP 的第一个支柱:封装的细节。您了解了 C# 的访问修饰符、类型属性的作用、对象初始化语法和分部类。有了这些,你现在可以转到下一章,在那里你将学习使用继承和多态来构建一系列相关的类。*