嗨翻-C--第四版-四-

96 阅读57分钟

嗨翻 C# 第四版(四)

原文:zh.annas-archive.org/md5/aa741d4f28e1a4c90ce956b8c4755b7e

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:接口、转换和“is”:使类们信守它们的承诺

图像

需要一个对象来做特定的工作吗?使用接口。 有时,您需要根据它们能够做什么而不是它们从哪些类继承而来来对对象进行分组,这就是接口的用武之地。您可以使用接口来定义特定的工作。任何实现接口的类的实例都保证能够执行该工作,无论它们与其他类的关系如何。为了使一切正常运行,任何实现接口的类都必须承诺履行其所有义务...否则编译器将削减其膝盖,明白吗?

蜂巢正在遭受攻击!

敌方蜂巢正在试图占领女王的领土,并不断派遣敌蜜蜂来攻击她的工作人员。因此,她添加了一个名为 HiveDefender 的新的精英蜜蜂子类来保卫蜂巢。

图像

因此,我们需要一个 DefendHive 方法,因为敌人随时可能发动攻击。

我们可以通过扩展 Bee 类将 HiveDefender 子类添加到 Bee 类层次结构中,重写其 CostPerShift 以表示每个防御者每个班次消耗的蜂蜜量,并重写 DoJob 方法以飞到敌方蜂巢并攻击敌蜜蜂。

但敌蜜蜂随时可能攻击。我们希望防御者能够在无论他们当前是否在执行正常工作的情况下保护蜂巢。

因此,除了 DoJob 之外,我们还将向任何能够保卫蜂巢的蜜蜂添加 DefendHive 方法——不仅仅是精英的 HiveDefender 工作人员,而是她们的任何能够拿起武器保护她们女王的姐妹们。女王将在她发现自己的蜂巢受到攻击时调用她工作人员的 DefendHive 方法。

图像

我们可以使用 casting 来调用 DefendHive 方法...

当您编写 Queen.DoJob 方法时,使用 foreach 循环来获取workers数组中的每个蜜蜂引用,然后使用该引用调用 worker.DoJob。如果蜂巢遭受攻击,女王将希望调用她的防御者们的 DefendHive 方法。因此,我们将为她提供一个 HiveUnderAttack 方法,每当蜂巢受到敌蜜蜂攻击时调用,她将使用 foreach 循环命令她的工作人员保卫蜂巢,直到所有攻击者都离开为止。

但出现了问题。女王可以使用蜜蜂引用来调用 DoJob,因为每个子类都重写了 Bee.DoJob,但她不能使用 Bee 引用来调用 DefendHive 方法,因为该方法不是 Bee 类的一部分。那么她如何调用 DefendHive 呢?

由于 DefendHive 仅在每个子类中定义,我们需要使用casting将 Bee 引用转换为正确的子类,以便调用其 DefendHive 方法。

public void HiveUnderAttack() {
    foreach (Bee worker in workers) {
        if (EnemyHive.AttackingBees > 0) {
            if (worker.Job == "Hive Defender") {
                HiveDefender defender = (HiveDefender) worker;
                defender.DefendHive();
            } else if (worker.Job == "Nectar Defender") {
                NectarDefender defender = (NectarDefender) defender;
                defender.DefendHive();
            }
        }
    }
}

...但如果我们添加更多可以防御的蜜蜂子类呢?

一些蜜制品制造商和鸡蛋护理蜜蜂也想站出来保护蜂巢。这意味着我们将需要向她的 HiveUnderAttack 方法添加更多的else块。

这变得很复杂。 Queen.DoJob 方法非常简单——一个非常短的 foreach 循环,利用 Bee 类模型调用了子类中实现的特定版本的 DoJob 方法。我们不能对 DefendHive 这样做,因为它不是 Bee 类的一部分——而且我们也不想添加它,因为并非所有蜜蜂都能保卫蜂巢。有没有更好的方法让无关的类执行相同的工作?

图片

一个接口定义了类必须实现的方法和属性...

接口的工作方式就像抽象类:你使用抽象方法,然后使用冒号(:)使类实现该接口。

因此,如果我们想要将防御者添加到蜂巢中,我们可以有一个名为 IDefend 的接口。它看起来像这样。它使用**interface** 关键字定义接口,并包含一个名为 Defend 的抽象方法。接口中的所有成员默认都是公共和抽象的,因此 C#简化了事务,让您省略publicabstract关键字:

图片

任何实现了 IDefend 接口的类必须包含一个 Defend 方法,其声明与接口中的声明相匹配。如果不匹配,编译器将报错。

...但一个类可以实现的接口数量没有限制

我们刚刚说过,你使用冒号(:)使一个类实现一个接口。如果该类已经使用冒号扩展了一个基类怎么办?没问题!一个类可以实现许多不同的接口,即使它已经扩展了一个基类:

图片

现在我们有了一个既可以像 NectarCollector 一样工作,又可以保卫蜂巢的类。NectarCollector 扩展了 Bee,所以如果你使用一个 Bee 引用,它就像一个 Bee:

    Bee worker = new NectarCollector();
    Console.WriteLine(worker.Job);
    worker.WorkTheNextShift();

但如果你使用一个 IDefend 引用,它就像一个蜂巢防御者:

    IDefend defender = new NectarCollector();
    defender.Defend();
注意

当一个类实现一个接口时,它必须包含接口内列出的所有方法和属性,否则代码将无法编译。

接口让无关的类执行相同的工作

接口可以是帮助你设计易于理解和构建的 C#代码的强大工具。首先要考虑的是类需要执行的具体任务,因为这正是接口的目的所在。

图片

那么这对女王有什么帮助呢?IDefender 接口完全存在于 Bee 类层次结构之外。因此,我们可以添加一个知道如何保卫蜂巢的 NectarDefender 类,它仍然可以扩展 NectarCollector。女王可以保留她所有防御者的数组:

IDefender[] defenders = new IDefender[2];
defenders[0] = new HiveDefender();
defenders[1] = new NectarDefender();

这让她很容易召集她的防御者:

private void DefendTheHive() {
  foreach (IDefender defender in defenders)
  {
     defender.Defend();
  }
}

而且由于它存在于 Bee 类模型之外,我们可以不修改任何现有代码来实现这一点。

图片

注意

图片

我们将为您提供许多接口的示例。

如果你对接口的工作方式及其使用方式仍有些困惑?别担心——这很正常!语法非常简单直接,但其中还有很多微妙之处。所以我们会花更多时间来讲解接口...并且我们会给你很多例子和大量的练习。

练习使用接口

理解接口的最佳方法是开始使用它们。继续 创建一个新的控制台应用 项目。

这样做!

  1. 添加 Main 方法。 这是一个名为 TallGuy 的类的代码,以及调用其 TalkAboutYourself 方法的 Main 方法的代码。这里没有新东西——我们马上会用到它:

    class TallGuy {
        public string Name;
        public int Height;
    
        public void TalkAboutYourself() {
            Console.WriteLine($"My name is {Name} and I’m {Height} inches tall.");
        }
    }
    class Program
    {
        static void Main(string[] args)
        {
            TallGuy tallGuy = new TallGuy() { Height = 76, Name = "Jimmy" };
            tallGuy.TalkAboutYourself();
        }
    }
    
  2. 添加一个接口。 我们将使 TallGuy 实现一个接口。向你的项目中添加一个新的 IClown 接口:在“解决方案资源管理器”中右键单击项目,选择“添加 >> 新建项目...(Windows)或添加 >> 新建文件...(Mac)”,选择“接口”。确保它的名字是 IClown.cs。IDE 将创建一个包含接口声明的接口。添加一个 Honk 方法:

    interface IClown
    {
        void Honk();
    }
    
    注意

    你不需要在接口内部添加 “public” 或 “abstract”,因为它自动将每个属性和方法设为公共和抽象。

  3. 尝试编写其余的 IClown 接口部分。 在进行下一步之前,看看是否能创建剩余的 IClown 接口部分,并修改 TallGuy 类以实现此接口。除了不带任何参数的 void 方法 Honk 外,你的 IClown 接口还应该有一个只读的字符串属性 FunnyThingIHave,该属性有一个 get 访问器但没有 set 访问器。

  4. 这是 IClown 接口的样子。 你弄对了吗?如果你把 Honk 方法放在第一位也没关系——接口成员的顺序不重要,就像类中的顺序一样。

    interface IClown
    {
        string FunnyThingIHave { get; }
        void Honk();
    }
    
    注意

    IClown 接口要求任何实现它的类具有一个 void 方法 Honk 和一个名为 FunnyThingIHave 的字符串属性,该属性具有 get 访问器。

  5. 修改 TallGuy 类,使其实现 IClown 接口。 记住,冒号操作符后面总是跟着要继承的基类(如果有的话),然后是一系列以逗号分隔的要实现的接口。由于没有基类,只需实现一个接口,声明看起来像这样:

    class TallGuy: IClown
    

    然后确保类的其余部分保持不变,包括两个字段和方法。从 IDE 的“生成”菜单中选择“生成解决方案”来编译和构建程序。你会看到两个错误:

    Images

  6. 通过添加缺失的接口成员来修复错误。 一旦添加了接口中定义的所有方法和属性,错误就会消失。所以继续实现接口。添加一个只读的字符串属性 FunnyThingIHave,其 get 访问器总是返回字符串 “big shoes”。然后添加一个 Honk 方法,将 “Honk honk!” 写入控制台。

    下面是实现的样子:

    Images

  7. 现在你的代码将会编译。 更新你的 Main 方法,以便打印 TallGuy 对象的 FunnyThingIHave 属性,然后调用它的 Honk 方法:

    static void Main(string[] args) {
        TallGuy tallGuy = new TallGuy() { Height = 76, Name = "Jimmy" };
        tallGuy.TalkAboutYourself();
        Console.WriteLine($"The tall guy has {tallGuy.FunnyThingIHave}");
        tallGuy.Honk();
    }
    

图片

今晚的讨论:抽象类和接口就“谁更重要”这个紧迫问题发生争执。

抽象类:接口:
我觉得很明显,在我们两个中间谁更重要。程序员需要我来完成他们的工作。面对现实吧,你远远不及我。
不错,这肯定会很有意思。
你真的以为你比我更重要吗?你甚至不使用真正的继承——你只被实现。
太好了,又来了。“接口不使用真正的继承。”“接口只实现。”这简直是无知。实现和继承一样好。事实上,它更好!
更好了吗?你疯了。我比你灵活得多。当然,我不能被实例化——但你也不行。不像你,我有继承的强大力量。那些扩展你的可怜家伙根本不能利用virtualoverride
是吗?如果你想要一个类从你你的朋友继承,你不能从两个类继承。你必须选择要从哪个类继承。这简直是无礼!一个类可以实现的接口数没有限制。说到灵活性!通过我,程序员可以让一个类做任何事情。
抽象类:接口:
你可能夸大了自己的力量。真的吗?那好,让我们考虑一下我对使用我的开发人员有多有力。我全靠工作——当他们得到一个接口的引用时,根本不需要知道对象内部正在发生什么。
你认为那是好事吗?哈!当你使用我和我的子类时,你完全知道我们所有人内部都发生了什么。我可以处理所有子类需要的任何行为,他们只需继承它。透明性是一种强大的东西,小子!十有八九,程序员想要确保一个对象具有特定的属性和方法,但并不关心它们是如何实现的。
真的吗?我怀疑,程序员总是关心他们的属性和方法。好的,当然。最终。但是想想有多少次你看到程序员编写一个方法,只需一个具有某个方法的对象,此时方法的具体实现并不重要——只需它存在即可。所以轰!程序员只需使用一个接口。问题解决!
是的,当然,告诉程序员他们不能编码。哎呀,你真是让人气愤至极!

你不能实例化一个接口,但可以引用一个接口。

假设您需要一个具有 Defend 方法的对象,以便您可以在循环中使用它来保护蜂巢。任何实现 IDefender 接口的对象都可以。它可以是一个 HiveDefender 对象,一个 NectarDefender 对象,甚至是一个 HelpfulLadyBug 对象。只要它实现了 IDefender 接口,就保证它有一个 Defend 方法。您只需调用它。

这就是接口引用的用处。您可以使用一个引用来引用实现您需要的接口的对象,您始终可以确保它具有适合您目的的正确方法——即使您对它了解不多。

如果您尝试实例化一个接口,您的代码将无法构建

您可以创建一个 IWorker 引用数组,但是您不能实例化一个接口。您可以将这些引用指向实现 IWorker 的类的新实例。现在您可以拥有一个包含许多不同类型对象的数组!

如果您尝试实例化一个接口,编译器会抱怨。

图片

您不能使用new关键字与接口一起使用,这是有道理的——方法和属性没有任何实现。如果您可以从接口创建对象,它怎么知道如何行为?

使用界面引用您已经拥有的对象

所以你不能实例化一个接口...但是你可以使用接口来做一个引用变量,并用它来引用实现了接口的对象。

还记得如何将老虎引用传递给期望动物的任何方法吗?因为老虎扩展了动物?好吧,这也是一样的——您可以在期望 IDefender 的任何方法或语句中使用实现 IDefender 的类的实例。

图片

这些都是普通的new语句,就像您在本书的大部分部分中一直使用的那样。唯一的区别是您使用 IDefender 类型的变量引用它们。

图片图片

注意

输出

5 法案

7 个小丑

2016 年的 7 月

图片

接口引用是普通的对象引用

您已经了解了对象如何存储在堆中。当您使用接口引用时,这只是引用您已经使用的相同对象的另一种方式。让我们更详细地看看如何使用接口来引用堆中的对象。

  1. 我们将像往常一样创建对象。

    这里是一些创建蜜蜂的代码:它创建了 HiveDefender 的一个实例和 NectarDefender 的一个实例——这两个类都实现了 IDefender 接口。

    HiveDefender bertha = new HiveDefender();
    NectarDefender gertie = new NectarDefender();
    

    图片

  2. 接下来我们将添加 IDefender 引用。

    您可以像使用任何其他引用类型一样使用接口引用。这两个语句使用接口来创建对现有对象的新引用。您只能将接口引用指向实现它的类的实例。

    IDefender def2 = gertie;
    IDefender captain = bertha;
    

    图片

  3. 接口引用会使对象保持活动状态。

    当没有引用指向一个对象时,它就消失了。并没有规定这些引用都必须是同一类型!接口引用在跟踪对象以避免被垃圾回收时和其他对象引用一样好用。

    图片

  4. 像使用任何其他类型一样使用接口。

    你可以用new语句创建一个新对象,并将其直接分配给一个接口引用变量,一行代码搞定。你可以使用接口创建数组,这些数组可以引用任何实现了接口的对象。

    IDefender[] defenders = new IDefender[3];
    defenders[0] = new HiveDefender();
    defenders[1] = bertha;
    defenders[2] = captain;
    

RoboBee 4000 可以完成工蜂的工作,而不使用宝贵的蜂蜜。

上个季度的蜜蜂业务蓬勃发展,女王有足够的预算购买了最新的蜂箱技术:RoboBee 4000。它可以完成三只不同蜜蜂的工作,最重要的是它不消耗任何蜂蜜!虽然这不算是环保,因为它使用的是燃气。那么我们如何使用接口将 RoboBee 整合到蜂箱的日常业务中呢?

图片

class Robot
{
   public void ConsumeGas() {
     // Not environmentally friendly
   }
}
class RoboBee4000 : Robot, IWorker
{
   public string Job {
     get { return "Egg Care"; }
   }
   public void WorkTheNextShift()
   {
     // Do the work of three bees!
   }
}
注意

让我们仔细看看类图,看看我们如何使用接口将 RoboBee 类集成到蜂巢管理系统中。记住,我们用虚线表示对象实现了接口。

图片

现在我们只需要修改蜂巢管理系统,在需要引用工作者时都使用 IWorker 接口,而不是抽象的 Bee 类。

公共接口中的一切都自动是公共的,因为你会用它来定义任何实现它的类的公共方法和属性。

IWorker 的 Job 属性是一个 hack。

蜂巢管理系统使用 Worker.Job 属性如下:if (worker.Job == job)

有点奇怪吧?对我们来说确实如此。我们认为这是一个hack,或者说是一个笨拙、不优雅的解决方案。我们为什么认为 Job 属性是一个 hack 呢?想象一下,如果你出现了这样的拼写错误:

class EggCare : Bee {
   public EggCare(Queen queen) : base("Egg C**ra**e")

   // Oops! Now we’ve got a bug in the EggCare class,
   // even though the rest of the class is the same.
}
注意

我们把“Egg Care”拼错了——这是任何人都可能犯的错误!你能想象这个简单拼写错误会导致多难以追踪的 bug 吗?

现在代码没有办法判断 Worker 引用是否指向 EggCare 的实例。这将是一个非常难以修复的恶心 bug。所以我们知道这段代码容易出错……但它为什么是一个 hack 呢?

我们谈论了关注点分离:解决特定问题的所有代码应该放在一起。Job 属性违反了关注点分离原则。如果我们有一个 Worker 引用,我们不应该需要检查一个字符串来确定它是否指向一个 EggCare 对象或一个 NectarCollector 对象。Job 属性对于 EggCare 对象返回“Egg Care”,对于 NectarCollector 对象返回“Nectar Collector”,仅用于检查对象的类型。但我们已经跟踪了这个信息:它就是对象的类型

图片

没错!C#提供了处理类型的工具。

你永远不需要像“Egg Care”或“Nectar Collector”这样的字符串来跟踪类的类型属性,C#提供了工具,让你可以检查对象的类型。

使用“is”来检查对象的类型

如何消除 Job 属性的 hack?目前女王有她的workers数组,这意味着她只能获取到 IWorker 引用。她使用 Job 属性来区分哪些工人是 EggCare 工人,哪些是 NectarCollector 工人:

foreach (IWorker worker in workers) {
if (worker.Job == "Egg Care") {
    WorkNightShift((EggCare)worker);
}

void WorkNightShift(EggCare worker) {
   // Code to work the night shift
}

我们刚刚看到,如果我们意外地输入“Egg Crae”而不是“Egg Care”,那段代码将会彻底失败。如果你意外地将 HoneyManufacturer 的 Job 设置为“Egg Care”,你将会得到一个 InvalidCastException 错误。如果编译器能在我们编写这些代码时就检测到这些问题,就像我们使用私有或抽象成员一样检测其他类型的问题一样,那将是非常好的。

图片

C#给了我们一个工具来做到这一点:我们可以使用**is** 关键字来检查对象的类型。如果你有一个对象引用,你可以使用is来找出它是否是特定类型的:

  objectReference is ObjectType newVariable

如果 objectReference 指向的对象的类型是 ObjectType,那么它返回 true,并创建一个名为newVariable的新引用,该引用具有该类型。

因此,如果女王想找到所有的 EggCare 工人,并让他们进行夜班工作,她可以使用is关键字:

  foreach (IWorker worker in workers) {
      if (worker is EggCare eggCareWorker) {
          WorkNightShift(eggCareWorker);
      }
  }

此循环中的 if 语句使用is来检查每个 IWorker 引用。仔细观察条件测试:

    worker is EggCare eggCareWorker

如果worker变量引用的对象是一个 EggCare 对象,那么该测试将返回 true,并且is语句将该引用分配给一个新的名为eggCareWorker的 EggCare 变量。这与强制转换类似,但is语句安全地执行强制转换

is 关键字在对象匹配类型时返回 true,并且可以声明一个具有对该对象引用的变量。

使用“is”来访问子类中的方法

让我们把我们到目前为止讨论的一切汇总到一个新项目中,通过创建一个简单的类模型,Animal 在顶部,Hippo 和 Canine 类扩展 Animal,以及一个扩展 Canine 的 Wolf 类。

做这个!

创建一个新的控制台应用程序,并添加这些 Animal、Hippo、Canine 和 Wolf 类到其中:

图片

注:

在#inheritance_your_objectapostrophes_famil 我们学习到,我们可以使用不同的引用调用同一对象的不同方法。当你没有使用 override 和 virtual 关键字时,如果你的引用变量是 Locksmith 类型,它调用 Locksmith.ReturnContents,但如果是 JewelThief 类型,它调用 JewelThief.ReturnContents。我们在这里做了类似的事情。

接下来,填写 Main 方法。它的作用如下:

  • 它创建了一个 Hippo 和 Wolf 对象的数组,然后使用 foreach 循环遍历每个对象。

  • 它使用 Animal 引用调用 MakeNoise 方法。

  • 如果是 Hippo,Main 方法调用其 Hippo.Swim 方法。

  • 如果是 Wolf,Main 方法调用其 Wolf.HuntInPack 方法。

问题在于,如果你有一个 Animal 引用指向一个 Hippo 对象,你不能使用它来调用 Hippo.Swim:

    Animal animal = new Hippo();
    animal.Swim(); // <-- this line will not compile!

你的对象是 Hippo 并不重要。如果你使用 Animal 变量,你只能访问 Animal 的字段、方法和属性。

幸运的是,有一种方法可以解决这个问题。 如果你完全确定你有一个 Hippo 对象,那么你可以将你的 Animal 引用转换为 Hippo——然后你可以访问它的 Hippo.Swim 方法:

    Hippo hippo = (Hippo)animal;
    hippo.Swim(); // It’s the same object, but now you can call the Hippo.Swim method.

这是使用**is** keyword 调用 Hippo.Swim 或 Wolf.HuntInPack 的 Main 方法

Images

注意

花几分钟时间,使用调试器真正理解这里发生了什么。在 foreach 循环的第一行设置断点;添加animal, hippowolf的监视器;并逐步执行。

如果我们希望不同的动物能够游泳或者群体狩猎怎么办?

你知道狮子是群体猎手吗?还是老虎会游泳?那狗呢,它们既群体狩猎又游泳?如果我们想要将 Swim 和 HuntInPack 方法添加到我们动物园模拟器模型中所有需要它们的动物中,那么 foreach 循环将变得越来越长。

在基类中定义抽象方法或属性并在子类中重写它的美妙之处在于你不需要知道任何关于子类的信息就可以使用它。你可以添加所有想要的 Animal 子类,这个循环仍然有效:

foreach (Animal animal in animals) {
    animal.MakeNoise();
}

MakeNoise 方法将始终由对象实现。

实际上,你可以把它看作是一个合同,编译器强制执行。

注意

那么是否有办法将 HuntInPack 和 Swim 方法也像契约一样对待,这样我们就可以用更一般的变量来使用它们——就像我们对 Animal 类所做的那样?

Images

使用接口来处理执行相同工作的类

有游泳的类有一个 Swim 方法,有群体狩猎的类有一个 HuntInPack 方法。好的,这是一个良好的开端。现在我们想写能够处理游泳或群体狩猎对象的代码——这就是接口发挥作用的地方。让我们使用**interface** keyword来定义两个接口,并add an abstract member到每个接口:

Images

Add this!

接下来,使 Hippo 和 Wolf 类实现这些接口,只需在每个类声明的末尾添加一个接口。像扩展类时一样使用冒号(:)来实现一个接口。如果已经扩展了一个类,只需在超类之后添加一个逗号,然后是接口名。然后,你只需确保类实现了所有接口成员,否则会得到编译器错误。

    class Hippo : Animal, ISwimmer {
 /* The code stays exactly the same - it MUST include the Swim method */
    }

    class Wolf : Canine, IPackHunter {
 /* The code stays exactly the same - it MUST include the HuntInPack method */
    }

使用“is”关键字来检查 Animal 是否是游泳者或群体猎手

你可以使用**is** 关键字来检查特定对象是否实现了接口——无论该对象实现了哪些其他类。如果 animal 变量引用了一个实现了 ISwimmer 接口的对象,那么animal is ISwimmer将返回 true,并且你可以安全地将其转换为 ISwimmer 引用以调用其 Swim 方法:

图片

使用“is”安全地导航你的类层次结构

在替换蜜蜂管理系统中的 Bee 为 IWorker 时,你能否使其抛出 InvalidCastException?以下是它抛出异常的原因

图片 你可以安全地将 NectarCollector 引用转换为 IWorker 引用

所有 NectarCollectors 都是 Bees(即它们扩展了 Bee 基类),所以你始终可以使用=运算符将 NectarCollector 的引用赋给 Bee 变量。

  HoneyManufacturer lily = new HoneyManufacturer();
  Bee hiveMember = lily;

并且由于 Bee 实现了 IWorker 接口,所以你也可以安全地将其转换为 IWorker 引用。

  HoneyManufacturer daisy = new HoneyManufacturer();
  IWorker worker = daisy;

这些类型转换是安全的:它们永远不会抛出 IllegalCastException,因为它们只能将更具体的对象分配给具有更一般类型的变量在同一类层次结构中

图片 你不能安全地将 Bee 引用转换为 NectarCollector 引用

你不能安全地反向操作——将 Bee 转换为 NectarCollector——因为并非所有 Bee 对象都是 NectarCollector 的实例。HoneyManufacturer绝对不是 NectarCollector。所以这个:

  IWorker pearl = new HoneyManufacturer();
  NectarCollector irene = (NectarCollector)pearl;

是一种试图将对象转换为不匹配其类型的变量的无效转换

图片 “is”关键字让你可以安全地转换类型

幸运的是,is关键字比用括号进行转换更安全。它允许你检查类型是否匹配,并且只有在类型匹配时才将引用转换为新变量。

  if (pearl is NectarCollector irene) {
     /* Code that uses a NectarCollector object */
  }

这段代码永远不会抛出 InvalidCastException,因为它只有在pearl是 NectarCollector 时才会执行使用 NectarCollector 对象的代码。

C#还有另一种安全类型转换的工具:“as”关键字

C#为安全转换提供了另一种工具:as关键字。它也执行安全类型转换。以下是其工作原理。假设你有一个名为pearl的 IWorker 引用,并且你想将其安全地转换为 NectarCollector 变量irene。你可以这样安全地将其转换为 NectarCollector:

 NectarCollector irene = pearl as NectarCollector;

如果类型兼容,此语句将irene变量设置为引用与pearl变量相同的对象。如果对象的类型与变量的类型不匹配,它不会抛出异常。而是将变量设置为null,您可以使用if语句检查:

 if (irene != null) {
    /* Code that uses a NectarCollector object */
 }

使用向上转型和向下转型在类层次结构中向上和向下移动

类图通常将基类放在顶部,其子类放在其下方,它们的子类依次排列。在图表中,类越高抽象性越强;类越低具体性越强。“抽象高,具体低”并非一成不变的规则,这是一个让我们一眼看清楚我们类模型工作原理的约定

在#inheritance_your_objectapostrophes_famil 中,我们讨论了如何始终可以使用子类替代它继承的基类,但不能总是可以使用基类替代扩展它的子类。您还可以以另一种方式考虑这个问题:从某种意义上讲,您正在向上或向下移动类层次结构。例如,如果您从这开始:

   NectarCollector ida = new NectarCollector();

您可以使用=运算符来执行普通赋值(用于超类)或转型(用于接口)。这就像向上移动类层次结构。这被称为向上转型

  // Upcast the NectarCollector to a Bee
  Bee beeReference = ida;

  // This upcast is safe because all Bees are IWorkers
  IWorker worker = (IWorker)beeReference;

通过使用is运算符可以安全地向下移动类层次结构。这被称为向下转型

 // Downcast the IWorker to NectarCollector
  if (worker is NectarCollector rose) { /* code that uses the rose reference */ }

图片

快速的向上转型示例

如果您正在努力找出如何每个月削减能源账单,您并不真的关心每个家电做什么——您只关心它们消耗的电力。因此,如果您正在编写一个程序来监控您的电力消耗,您可能只会编写一个 Appliance 类。但是,如果您需要区分咖啡机和烤箱,您就必须构建一个类层次结构,并将特定于咖啡机或烤箱的方法和属性添加到您的 CoffeeMaker 和 Oven 类中,它们将继承自具有它们共同方法和属性的 Appliance 类。

然后,您可以编写一个方法来监控功耗:

图片图片

如果您想要使用该方法监控咖啡机的功耗,您可以创建一个 CoffeeMaker 的实例并直接将其引用传递给该方法:

图片

向上转型将您的 CoffeeMaker 转换为一个 Appliance

当你用子类替换基类——比如用咖啡机替换家电,或者用河马替换动物——这被称为向上转型。在构建类层次结构时,这是一个非常强大的工具。向上转型的唯一缺点是,你只能使用基类的属性和方法。换句话说,当你把咖啡机当作家电时,你不能让它制作咖啡或加水。你可以判断它是否插上电源,因为这是你可以对任何家电做的事情(这就是为什么 PluggedIn 属性属于家电类的原因)。

  1. 让我们创建一些对象。

    让我们像往常一样创建咖啡机和烤箱类的实例:

      CoffeeMaker misterCoffee = new CoffeeMaker();
      Oven oldToasty = new Oven();
    
    注意

    你不需要将这段代码添加到应用中——只需阅读代码并开始了解向上转型和向下转型的工作原理。你将在本书的后续章节中获得大量实践。

  2. 如果我们想要创建一个家电数组怎么办?

    你不能把咖啡机放入一个 Oven[]数组中,也不能把烤箱放入一个 CoffeeMaker[]数组中。但是你可以把它们都放入一个 Appliance[]数组中:

      Appliance[] kitchenWare = new Appliance[2];
      kitchenWare[0] = misterCoffee;
      kitchenWare[1] = oldToasty;
    
    注意

    你可以使用向上转型创建一个可以容纳咖啡机和烤箱的家电数组。

  3. 但你不能把任何家电都当作烤箱来对待。

    当你有一个家电的引用时,你只能访问与家电相关的方法和属性。通过家电引用,即使你知道它实际上是一个咖啡机,你也不能使用咖啡机的方法和属性。所以这些语句将正常工作,因为它们把一个咖啡机对象当作家电来对待:

    图像

    你的代码不会编译,并且 IDE 会显示错误:

    图像

    一旦你从子类向基类向上转型,你只能访问与你用来访问对象的引用匹配的方法和属性。

图像

向下转型将你的家电转换回咖啡机。

向上转型是一个很好的工具,因为它让你可以在任何需要家电的地方使用咖啡机或烤箱。但是它有一个很大的缺点——如果你使用一个指向咖啡机对象的家电引用,你只能使用属于家电的方法和属性。这就是向下转型的用武之地:这是如何将你的之前向上转型的引用重新改回的方法。你可以使用**is**关键字来判断你的家电是否真的是一个咖啡机,如果是,你可以将它转换回咖啡机。

  1. 我们将从已经向上转型的咖啡机开始。

    这是我们使用的代码:

      Appliance powerConsumer = new CoffeeMaker();
      powerConsumer.ConsumePower();
    
  2. 如果我们想把家电转换回咖啡机怎么办?

    假设我们正在构建一个应用程序,该应用程序查找一个家电引用数组,以便让我们的咖啡机开始冲泡。我们不能只使用我们的家电引用调用咖啡机方法:

      Appliance someAppliance = appliances[5];
      someAppliance.StartBrewing()
    

    那个语句无法编译 —— 因为 StartBrewing 是 CoffeeMaker 的成员,但您正在使用 Appliance 引用。

    Images

  3. 但既然我们知道它是咖啡机,让我们像使用咖啡机一样使用它。

    is 关键字是第一步。一旦确定你有一个指向 CoffeeMaker 对象的 Appliance 引用,就可以使用 **is** 进行向下转型。这样可以使用 CoffeeMaker 类的方法和属性。由于 CoffeeMaker 继承自 Appliance,它仍然具有其 Appliance 的方法和属性。

      if (someAppliance is CoffeeMaker javaJoe) {
         javaJoe.StartBrewing();
      }
    

    ImagesImages

向上转型和向下转型也适用于接口

接口在向上转型和向下转型中表现非常出色。让我们为任何可以加热食物的类添加一个 ICooksFood 接口。接下来,我们将添加一个 Microwave 类——Microwave 和 Oven 都实现了 ICooksFood 接口。现在,对 Oven 对象的引用可以是 ICooksFood 引用、Microwave 引用或 Oven 引用。这意味着我们有三种不同类型的引用可以指向一个 Oven 对象,每种引用根据其类型可以访问不同的成员。幸运的是,IDE 的 IntelliSense 可以帮助您确切地了解每种引用可以做什么和不能做什么:

Oven misterToasty = new Oven();
misterToasty.

Images

注意

一旦输入点,IntelliSense 窗口将弹出一个列表,列出您可以使用的所有成员。misterToasty 是指向 Oven 对象的 Oven 引用,因此它可以访问所有方法和属性。这是最具体的类型,因此您只能将其指向 Oven 对象。

Images

要访问 ICooksFood 接口成员,请将其转换为 ICooksFood 引用:

if (misterToasty is ICooksFood cooker) {
    cooker.

Images

注意

cooker 是指向同一 Oven 对象的 ICooksFood 引用。它只能访问 ICooksFood 成员,但也可以指向 Microwave 对象。

这是我们之前使用过的同一个 Oven 类,因此它也扩展了 Appliance 基类。如果使用 Appliance 引用访问对象,您只能看到 Appliance 类的成员:

if (misterToasty is Appliance powerConsumer)
    powerConsumer.

Images

注意

powerConsumer 是一个 Appliance 引用。它只允许您访问 Appliance 的公共字段、方法和属性。它比 Oven 引用更一般(所以如果需要,您可以将其指向 CoffeeMaker 对象)。

注意

指向同一对象的三个不同引用可以根据引用的类型访问不同的方法和属性。

接口可以继承其他接口

正如我们提到的,当一个类从另一个类继承时,它会获取基类中的所有方法和属性。接口继承更简单。由于任何接口中都没有实际的方法体,因此您无需担心调用基类的构造函数或方法。继承的接口累积了它们扩展的所有成员

那么代码是什么样子的呢?让我们添加一个从 IWorker 继承的 IDefender 接口:

图片

当一个类实现一个接口时,它必须实现该接口中的每个属性和方法。如果该接口又继承自另一个接口,则还需要实现那些属性和方法。因此,任何实现 IDefender 的类不仅必须实现所有 IDefender 成员,还必须实现所有 IWorker 成员。以下是包含 IWorker 和 IDefender 的类模型,以及两个单独的层次结构来实现它们。

图片图片

绝对!将字段设置为只读有助于防止错误。

返回到 ScaryScary.scaryThingCount 字段——IDE 在字段名的前两个字母下面放置了点。将鼠标悬停在点上即可弹出窗口:

图片

按 Ctrl+. 弹出操作列表,并选择“添加 readonly 修饰符”将 readonly 关键字添加到声明中:

图片

现在该字段只能在声明时或在构造函数中设置。如果您尝试在方法的任何其他位置更改其值,将收到编译器错误:

图片

readonly 关键字……这只是 C# 帮助您保持数据安全的另一种方式。

接口引用只知道在接口中定义的方法和属性。

注释

查看字典中的“implement”一词——其中一个定义是“将决定、计划或协议付诸实施”。

实际上,您可以通过包含静态成员和默认实现来向接口添加代码。

图片

接口不仅仅是确保实现它们的类包含某些成员。当然,这是它们的主要任务。但接口也可以包含代码,就像您用来创建类模型的其他工具一样。

向接口添加代码的最简单方法是添加静态方法、属性和字段。它们的工作方式与类中的静态成员完全相同:可以存储任何类型的数据,包括对对象的引用,并且可以像调用任何其他静态方法一样调用它们:Interface.MethodName();

你还可以通过为方法添加默认实现在你的接口中包含代码。要添加默认实现,只需在接口的方法中添加一个方法体。这个方法不是对象的一部分——这不同于继承——你只能使用接口引用来访问它。它可以调用对象实现的方法,只要它们是接口的一部分。

接口可以拥有静态成员

每个人都喜欢看到过多的小丑挤进一个小小的小丑车里!因此让我们更新 IClown 接口,添加生成小丑车描述的静态方法。这是我们将要添加的内容:

  • 我们将使用随机数,因此我们将添加一个对 Random 实例的静态引用。目前它只需要在 IClown 中使用,但我们很快也会在 IScaryClown 中使用它,所以去标记它为protected

  • 只有挤满小丑的小丑车才有趣,所以我们将添加一个带有私有静态后备字段的静态 int 属性,并且只接受大于 10 的值。

  • 一个名为 ClownCarDescription 的方法返回描述小丑车的字符串。

Images

这是代码——它使用了一个静态字段、属性和方法,就像你在类中看到的那样:

Images

现在你可以更新 Main 方法来访问静态 IClown 成员:

static void Main(string[] args)
{

   IClown.CarCapacity = 18;
   Console.WriteLine(IClown.ClownCarDescription());

   // the rest of the Main method stays the same
}
注意

尝试向你的接口添加一个私有字段。你可以添加一个——但只能是静态的!如果去掉静态关键字,编译器会告诉你接口不能包含实例字段。

这些静态接口成员的行为与你在前几章中使用的静态类成员完全相同。公共成员可以从任何类中使用,私有成员只能从 IClown 内部使用,而受保护的成员可以从 IClown 或任何扩展它的接口中使用。

默认实现为接口方法提供了方法体

到目前为止你在接口中看到的所有方法——除了静态方法——都是抽象的:它们没有方法体,因此任何实现该接口的类必须为该方法提供实现。

但你也可以为你的接口方法提供一个默认实现。这里是一个例子:

Images

你可以调用默认实现——但你必须使用接口引用来进行调用:

    IWorker worker = new NectarCollector();
    worker.Buzz();

但这段代码不会编译——它会给你一个错误*“‘NectarCollector’ does not contain a definition for ‘Buzz’”*:

    NectarCollector pearl = new NectarCollector();
    pearl.Buzz();

原因是当接口方法具有默认实现时,这使其成为一个虚方法,就像您在类中使用的方法一样。任何实现接口的类都可以选择实现该方法。虚方法与接口一样附属。像任何其他接口实现一样,它不会被继承。这是一件好事——如果一个类从它实现的每个接口中继承了默认实现,那么如果其中两个接口具有相同名称的方法,该类将遇到死亡之钻石的问题。

注意

您可以使用逐字字符串文字创建包含换行符的多行字符串。它们与字符串插值非常搭配——只需在开头添加 $。

添加一个带有默认实现的 ScareAdults 方法

当涉及到模拟可怕小丑时,我们的 IScaryClown 接口是最先进的。但是有一个问题:它只有一个用于惊吓小孩的方法。如果我们希望我们的小丑也能吓到成年人,怎么办?

我们可以向 IScaryClown 接口添加一个抽象的 ScareAdults 方法。但是,如果我们已经有数十个实现了 IScaryClown 接口的类呢?如果其中大多数类都对 ScareAdults 方法的同一实现非常满意呢?这就是默认实现真正有用的地方。默认实现允许您向已经在使用中的接口添加一个方法,而无需更新任何实现它的类。向 IScaryClown 添加一个带有默认实现的 ScareAdults 方法:

图片

添加这个!

仔细观察 ScareAdults 方法的工作方式。该方法只有两个语句,但它们包含了很多内容。让我们逐步分解正在发生的事情:

  • Console.WriteLine 语句使用了带有字符串插值的逐字文字。文本以 $@ 开头,告诉 C# 编译器两件事:$ 告诉它使用字符串插值,@ 告诉它使用逐字文字。这意味着字符串将包括三个换行符。

  • 该文字使用字符串插值调用 random.Next(4, 10),它使用 IScaryClown 从 IClown 继承的私有静态 random 字段。

  • 本书贯穿始终的是,当存在静态字段时,意味着该字段只有一个副本。因此,IClown 和 IScaryClown 共享一个 Random 的实例。

  • ScareAdults 方法的最后一行调用 ScareLittleChildren。该方法在 IScaryClown 接口中是抽象的,因此它将调用实现 IScaryClown 接口的类中 ScareLittleChildren 的版本。

  • 这意味着 ScareAdults 将调用在实现 IScaryClown 接口的任何类中定义的 ScareLittleChildren 版本。

通过修改 Main 方法中 if 语句后的代码块调用 ScareAdults 而不是 ScareLittleChildren 来调用您的新默认实现:

 if (fingersTheClown is IScaryClown iScaryClownReference)
 {
        iScaryClownReference.ScareAdults();
 }

图片

C# 开发人员 经常 使用接口,特别是在使用库、框架和 API 时。

开发人员总是站在巨人的肩膀上。你已经读了这本书的一半,在前半部分,你编写了打印文本到控制台的代码,绘制带有按钮的窗口,并渲染了 3D 对象。你不需要编写代码来逐个输出字节到控制台,或者绘制线条和文本以显示窗口中的按钮,也不需要执行显示球体所需的数学计算 —— 你利用了其他人编写的代码:

  • 你已经使用过像 .NET Core 和 WPF 这样的框架

  • 你已经使用过像 Unity 脚本 API 这样的API

  • 这些框架和 API 包含类库,你可以在代码顶部使用 using 指令访问它们。

当你使用库、框架和 API 时,经常会使用接口。自己看看:打开一个 .NET Core 或 WPF 应用程序,在任何方法中点击,然后键入 **I** 弹出 IntelliSense 窗口。任何旁边有 Images 符号的潜在匹配项都是接口。这些都是你可以用来与框架一起工作的接口。

Images

注意

没有 Mac 上等效的功能可以替代下一个讨论的 WPF 特性,因此 Visual Studio for Mac 学习指南跳过了这部分。

数据绑定会自动更新 WPF 控件。

这里有一个真实世界使用接口的绝佳例子:数据绑定。数据绑定是 WPF 中非常有用的功能,它允许你设置控件,使它们的属性根据对象中的属性自动设置,并且当该属性更改时,你的控件属性也会自动保持更新。

Images

下面是修改蜂箱管理系统的步骤概述 —— 我们将在接下来详细介绍它们:

  1. 修改 Queen 类以实现 INotifyPropertyChanged 接口。

    这个接口让 Queen 可以宣布状态报告已更新。

  2. 修改 XAML 以创建 Queen 的实例。

    我们将把 TextBox.Text 属性绑定到 Queen 的 StatusReport 属性。

  3. 修改代码后端,使“queen”字段使用我们刚刚创建的 Queen 实例。

    现在,MainWindow.xaml.cs 中的 queen 字段有一个字段初始化器,其中包含一个 new 语句以创建 Queen 的实例。我们将修改它以使用我们用 XAML 创建的实例。

Images

修改蜂箱管理系统以使用数据绑定

你只需要做一些修改,就可以在你的 WPF 应用程序中添加数据绑定。

做这个!

  1. 修改 Queen 类以实现 INotifyPropertyChanged 接口。

    更新 Queen 类声明以使其实现 INotifyPropertyChanged。该接口位于 System.ComponentModel 命名空间中,因此你需要在类顶部添加一个 using 指令:

    using System.ComponentModel;
    

    现在可以在类声明的末尾添加 INotifyPropertyChanged。IDE 会在其下面绘制一个红色的波浪线——这是你预期的,因为你还没有通过添加其成员来实现接口。

    图片

    按 Alt+Enter 或 Ctrl+. 显示潜在的修复选项,并从上下文菜单中选择“实现接口”。IDE 将向你的类中添加一行代码,其中包含了 **event keyword**,这是你尚未见过的:

    public event PropertyChangedEventHandler PropertyChanged;
    

    但猜猜?你以前使用过事件!你在 #start_building_with_chash_build_somethin 中使用的 DispatchTimer 有一个 Tick 事件,而 WPF Button 控件有一个 Click 事件。现在你的 Queen 类有一个 PropertyChanged 事件。 任何用于数据绑定的类都会触发——或调用——其 PropertyChanged 事件,以通知 WPF 属性已更改。

    你的 Queen 类需要像 DispatchTimer 在间隔上触发其 Tick 事件和 Button 在用户单击时触发其 Click 事件一样触发其事件。因此,添加此 OnPropertyChanged 方法

        protected void OnPropertyChanged(string name)
        {
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
        }
    

    现在只需修改 UpdateStatusReport 方法以调用 OnPropertyChanged:

     private void UpdateStatusReport()
     {
     StatusReport = $"Vault report:\n{HoneyVault.StatusReport}\n" +
     $"\nEgg count: {eggs:0.0}\nUnassigned workers: {unassignedWorkers:0.0}\n" +
     $"{WorkerStatus("Nectar Collector")}\n{WorkerStatus("Honey Manufacturer")}" +
     $"\n{WorkerStatus("Egg Care")}\nTOTAL WORKERS: {workers.Length}";
            OnPropertyChanged("StatusReport");
     }
    
    注意

    你向 Queen 类添加了一个事件,并添加了一个使用 ?. 运算符来调用该事件的方法。目前你需要知道关于事件的就这些——在本书的结尾,我们将为你提供一个可下载的章节,进一步教授你有关事件的知识。

  2. 修改 XAML 以创建 Queen 的一个实例。

    你使用了 new 关键字创建对象,并使用了 Unity 的 Instantiate 方法。XAML 为你提供了另一种创建类的新实例的方式。将这段代码添加到你的 XAML,就在 <Grid> 标签的上面:

    图片

    接下来,修改 <Grid> 标签以添加 DataContext 属性:

    <Grid DataContext="{StaticResource queen}">
    

    最后, <TextBox> 标签添加一个 Text 属性,将其绑定到 Queen 的 StatusReport 属性:

    <TextBox Text="{Binding StatusReport, Mode=OneWay}"
    

    现在每当 Queen 对象调用其 PropertyChanged 事件时,TextBox 将自动更新。

  3. 修改代码后台以使用窗口资源中的 Queen 实例。

    现在 MainWindow.xaml.cs 中的 queen 字段具有一个字段初始化程序,其中包含一个 new 语句以创建 Queen 的一个实例。我们将修改它,以使用我们在 XAML 中创建的实例。

    首先,注释掉(或删除)设置 statusReport.Text 的三个行的出现。一个在 MainWindow 构造函数中,另外两个在 Click 事件处理程序中:

    // statusReport.Text = queen.StatusReport;
    

    接下来,修改 Queen 字段声明,从末尾移除字段初始化程序(new Queen();):

    private readonly Queen queen;
    

    最后,修改构造函数以如下方式设置 queen 字段:

    图片

    这段代码使用了一个名为 Resources 的字典。(这是对字典的一个预览!你将在下一章学习到它们。)现在运行你的游戏。它的工作方式与之前完全相同,但现在 TextBox 在 Queen 更新状态报告时会自动更新。

    注意

    恭喜!你刚刚使用接口为你的 WPF 应用添加了数据绑定。

多态性意味着一个对象可以具有多种形式

每当你用 RoboBee 替代 IWorker,或用 Wolf 替代 Animal,甚至在配方中用老佛蒙特切达干酪代替仅仅要求奶酪的情况下,你正在使用多态性。这就是你进行向上转型或向下转型的情况。它将一个对象用在期望其他东西的方法或语句中。

保持对多态性的关注!

在整个过程中,你一直在使用多态性——我们只是没有用这个词来描述它。在接下来的几章中编写代码时,请留意你使用它的许多不同方式。

这里列出了你将使用多态性的四种典型方式。我们提供了每种方式的示例,尽管你不会在练习中看到这些特定的行。一旦你在本书后面章节的练习中编写了类似的代码,请回到这一页并在下面的列表中打勾确认

图片

当你拿一个类的实例并将其用在期望不同类型的语句或方法中,比如父类或实现类的接口时,你就在使用多态性。

注意

当你将数据和代码合并到类和对象中时,这个想法在最初提出时是革命性的——但这就是你迄今为止一直在构建所有 C#程序的方式,所以你可以将其视为普通的编程。

图片

你是一个面向对象的程序员。

你所做的事有一个名字。它被称为面向对象编程,或者 OOP。在像 C#这样的语言出现之前,人们在编写他们的代码时不使用对象和方法。他们只使用函数(在非面向对象程序中称为方法),这些函数都在一个地方——就像每个程序只是一个有静态方法的大静态类一样。这使得创建能够模拟它们解决的问题的程序变得更加困难。幸运的是,你永远不必在没有 OOP 的情况下编写程序,因为它是 C#的核心部分。

面向对象编程的四个核心原则

当程序员谈论面向对象编程时,他们指的是四个重要原则。现在它们应该对你来说非常熟悉了,因为你已经在使用每一个。我们刚刚告诉过你多态性,你将在#encapsulation_keep_your_privateshellippr 和#inheritance_your_objectapostrophes_famil 中认识到前三个原则:继承,抽象封装

图片

第十二章:枚举和集合:组织您的数据

图片

数据并不总是那么整洁和整齐。

在现实世界中,您不会收到整齐的小数据片段。不,您的数据会以大量、堆和成群的方式出现。您将需要一些非常强大的工具来组织所有这些数据,这就是枚举集合发挥作用的地方。枚举是一种类型,让您定义用于对数据进行分类的有效值。集合是特殊的对象,可以存储许多值,让您存储、排序和管理程序需要处理的所有数据。这样,您可以花时间考虑编写处理数据的程序,而让集合为您跟踪数据。

字符串并不总是适用于存储数据的类别

接下来几章,我们将使用扑克牌进行工作,因此让我们构建一个我们将使用的Card类。首先,创建一个新的Card类,其中包含一个构造函数,允许您传递花色和值,并将它们存储为字符串:

class Card
{
    public string Value { get; set; }
    public string Suit { get; set; }
    public string Name { get { return $"{Value} of {Suit}"; } }

    public Card(string value, string suit)
    {
        Value = value;
        Suit = suit;
    }
}

图片

看起来不错。我们可以创建一个Card对象并像这样使用它:

Card aceOfSpades = new Card("Ace", "Spades");
Console.WriteLine(aceOfSpades);  // prints Ace of Spades

但是有个问题。使用字符串来保存花色和值可能会产生一些意想不到的结果:

图片

我们可以向构造函数添加代码来检查每个字符串,并确保它是有效的花色或值,并通过抛出异常来处理不良输入。这是一个有效的方法——当然,前提是您正确处理异常。

但是如果 C# 编译器能够自动检测到这些无效值,那不是很棒吗?如果编译器能够在您运行代码之前确保所有卡片都是有效的,会怎么样?好吧,猜猜看:它做到!您需要做的就是枚举可以使用的值。

图片

枚举允许您使用一组有效的值

枚举枚举类型是一种数据类型,仅允许该数据片段的某些值。因此,我们可以定义一个名为Suits的枚举,并定义允许的花色:

图片

枚举定义了一个新类型

当您使用enum关键字时,您正在定义一个新类型。以下是有关枚举的一些有用信息:

枚举允许您定义一个新类型,该类型仅允许特定的一组值。不属于枚举的任何值都将使代码崩溃,这可以防止以后的错误。

图片 您可以将枚举用作变量定义中的类型,就像您使用字符串、整数或任何其他类型一样:

Suits mySuit = Suits.Diamonds;

图片 由于枚举是一种类型,您可以使用它来创建数组:

Suits[] myVals= new Suits[3] { Suits.Spades, Suits.Clubs, mySuit };

图片 使用 == 来比较枚举值。这里有一个接受Suit枚举作为参数,并使用==检查它是否等于Suits.Hearts的方法:

图片

图片 但你不能随意为枚举创造一个新值。如果这样做,程序将无法编译——这意味着你可以避免一些讨厌的错误:

IsItAHeart(Suits.Oxen);

如果你使用不属于枚举的值,编译器会报错:

图片

枚举让你用名称表示数字

有时候,如果你有数字的名称,使用数字会更容易。你可以为枚举值分配数字,并使用名称来引用它们。这样,你的代码中就不会有大量未解释的数字漂来漂去了。这是一个枚举,用于跟踪狗比赛中技巧的得分:

图片

注意

你可以将整数转换为枚举,也可以将(基于整数的)枚举转换回整数。

有些枚举使用不同类型,如 byte 或 long(像下面这个)。你可以将它们转换为它们的类型,而不是 int。

这是一个使用TrickScore枚举的方法片段的摘录,通过将其转换为和从整数值中:

图片

你可以将枚举转换为数字并进行计算。你甚至可以将其转换为字符串——枚举的ToString方法会返回带有成员名称的字符串:

图片

如果你没有为名称分配任何数字,列表中的项目将默认分配值。第一个项目将被分配为 0 值,第二个为 1 值,依此类推。但如果你想为枚举器中的一个使用非常大的数字会发生什么?枚举中数字的默认类型是 int,所以你需要使用冒号(:))运算符指定你需要的类型,就像这样:

图片图片

我们可以使用数组来创建一副牌...

如果你想创建一个表示一副牌的类会怎样?它需要一种方法来跟踪牌组中的每张牌,并且需要知道它们的顺序。Cards数组会起作用——牌组中的顶部牌的值为 0,下一张牌为 1,依此类推。这是一个起点——一个初始带有完整 52 张牌的牌组:

图片

...但如果你想做更多呢?

想象一下你可能需要用一副牌做的所有事情。如果你玩一种纸牌游戏,你通常需要改变牌的顺序,并从牌组中添加和删除牌。但你用数组却很难做到这一点。例如,再看一下《蜜蜂管理系统》练习中的AddWorker方法:

图片

你必须使用Array.Resize将数组长度调整长,然后将工作者添加到末尾。这是一项很大的工作。

与数组一起工作可能会很麻烦

对于存储固定值或引用的数组来说,这没问题。一旦你需要移动数组元素或添加超出数组容量的元素,情况就开始变得有点棘手。以下是使用数组时可能会遇到的一些问题。

每个数组都有一个长度。除非你重新调整数组的大小,否则该长度不会改变,因此你需要知道长度才能使用它。假设你想要使用数组来存储 Card 的引用。如果你想要存储的引用数量少于数组的长度,你可以使用空引用来保持某些数组元素为空。

图片

你需要跟踪数组中保存的卡片数量。你可以添加一个 int 字段 —— 也许你会称之为 cardCount,用来保存数组中最后一张卡片的索引。因此,你的三张卡片数组长度为 7,但你会将 cardCount 设置为 3。

图片

现在情况变得复杂了。添加一个 Peek 方法很容易,它只返回卡堆顶的引用,这样你就可以查看卡堆顶部了。如果你想添加一张卡片怎么办?如果 cardCount 小于数组的长度,你可以把卡片放在该索引处,并将 cardCount 加 1。但如果数组已满,你就需要创建一个新的更大的数组,并将现有的卡片复制到新数组中。移除一张卡片很简单 —— 但在从 cardCount 中减去 1 之后,你需要确保将移除的卡片的数组索引设为 null。如果你需要从列表中间移除一张卡片怎么办?如果你移除卡片 4,你需要将卡片 5 移回来替换它,然后移动 6,再移动 7...哇,这多么混乱啊!

注意

#inheritance_your_objectapostrophes_famil 中的 AddWorker 方法使用了 Array.Resize 方法来执行此操作。

列表使得存储任何类型的集合变得容易

C# 和 .NET 拥有处理添加和移除数组元素时遇到的所有棘手问题的 集合 类。最常见的集合类型是 List。一旦创建了 List 对象,就可以轻松地添加项目、从列表中任何位置移除项目、查看项目,甚至将项目从列表中的一个位置移动到另一个位置。以下是列表的工作原理。

注意

在本书中,我们有时会在提到 List 时省略 。当你看到 List 时,请想到 List。

  1. 首先创建一个新的 List 实例。 请记住,每个数组都有一个类型 —— 你不只是有一个数组,你有一个 int 数组、一个 Card 数组等等。列表也是如此。当你使用 new 关键字创建它时,你需要在尖括号(<>)中指定列表将要保存的对象或值的类型:

    List<Card> cards = new List<Card>();
    

    图片

  2. 现在您可以添加到您的 List 中。 一旦您有一个 List 对象,只要这些对象与您创建 List 时指定的类型多态,就可以添加任意数量的项目到其中——这意味着它们可以分配给该类型(包括接口、抽象类和基类)。

    cards.Add(new Card(Values.King, Suits.Diamonds));
    cards.Add(new Card(Values.Three, Suits.Clubs));
    cards.Add(new Card(Values.Ace, Suits.Hearts));
    

    图片

列表比数组更灵活

List 类内置于 .NET Framework 中,它允许您执行许多对象操作,这些操作是使用普通数组无法完成的。查看一些您可以使用 List 完成的操作。

图片

让我们构建一个存储鞋子的应用程序

现在是看看列表(List)如何运作的时候了。让我们构建一个 .NET Core 控制台应用程序,提示用户添加或移除鞋子。以下是运行应用程序的示例,添加两只鞋子,然后将它们移除:

我们将从一个 Shoe 类开始,用于存储鞋子的样式和颜色。然后我们将创建一个名为 ShoeCloset 的类,该类使用 List 存储鞋子,并具有 AddShoe 和 RemoveShoe 方法,这些方法提示用户添加或移除鞋子。

一定要这样做!

  1. 添加一个用于鞋子样式的枚举。 一些鞋子是运动鞋,其他的是凉鞋,所以枚举是有意义的:

    图片

  2. 添加 Shoe 类。 它使用 Style 枚举表示鞋子样式和一个字符串表示鞋子颜色,其工作方式与我们之前在本章创建的 Card 类相似:

    class Shoe
    {
        public Style Style { 
           get; private set; 
        }
        public string Color { 
           get; private set;
        }
        public Shoe(Style style, string color)
        {
            Style = style;
            Color = color;
        }
        public string Description
        {
            get { return $"A {Color} {Style}"; }
        }
    }
    

    图片

    The shoe closet is empty.
    

    图片

    Press ’a’ to add or ’r’ to remove a shoe: a
    Add a shoe
    Press 0 to add a Sneaker
    Press 1 to add a Loafer
    Press 2 to add a Sandal
    Press 3 to add a Flipflop
    Press 4 to add a Wingtip
    Press 5 to add a Clog
    Enter a style: 1
    Enter the color: black
    
    The shoe closet contains:
    Shoe #1: A black Loafer
    
    注意

    按‘a’键添加鞋子,然后选择鞋子类型并输入颜色。

    Press ’a’ to add or ’r’ to remove a shoe: a
    Add a shoe
    Press 0 to add a Sneaker
    Press 1 to add a Loafer
    Press 2 to add a Sandal
    Press 3 to add a Flipflop
    Press 4 to add a Wingtip
    Press 5 to add a Clog
    Enter a style: 0
    Enter the color: blue and white
    
    The shoe closet contains:
    Shoe #1: A black Loafer
    Shoe #2: A blue and white Sneaker
    
    注意

    按‘r’键移除鞋子,然后输入要移除的鞋子编号。

    Press ’a’ to add or ’r’ to remove a shoe: r
    Enter the number of the shoe to remove: 2
    Removing A blue and white Sneaker
    
    The shoe closet contains:
    Shoe #1: A black Loafer
    
    Press ’a’ to add or ’r’ to remove a shoe: r
    Enter the number of the shoe to remove: 1
    Removing A black Loafer
    
    The shoe closet is empty.
    
    Press ’a’ to add or ’r’ to remove a shoe:
    

    图片

  3. ShoeCloset 类使用 List 来管理其鞋子。 ShoeCloset 类有三个方法——PrintShoes 方法将鞋子列表打印到控制台,AddShoe 方法提示用户向衣柜添加鞋子,RemoveShoe 方法提示用户移除鞋子:

    图片

  4. 添加具有入口点的 Program 类。 注意它并没有做很多事情?这是因为所有有趣的行为都封装在 ShoeCloset 类中:

    图片

  5. 运行您的应用程序并重现示例输出。 尝试调试应用程序,并开始熟悉如何使用列表。现在无需记住任何东西——您将有足够的练习!

通用集合可以存储任何类型

您已经看到列表可以存储字符串或鞋子。您还可以创建整数或任何其他对象的列表。这使得列表成为通用集合。创建新的列表对象时,将其绑定到特定类型:可以有整数列表、字符串列表或鞋子对象列表。这样可以轻松使用列表——一旦创建了列表,就始终知道其中的数据类型。

但是“泛型”到底意味着什么?让我们使用 Visual Studio 探索泛型集合。打开 ShoeCloset.cs 并将鼠标悬停在 List 上:

图片

泛型集合可以容纳任何类型的对象,并且提供一致的一组方法来处理集合中的对象,无论它持有什么类型的对象。

有几点需要注意:

  • List 类位于命名空间 System.Collections.Generic——这个命名空间中有几个泛型集合类(这就是为什么你需要那个 using 行)。

  • 描述中说 List 提供了“搜索、排序和操作列表的方法”。你在 ShoeCloset 类中使用了其中一些方法。

  • 最上面一行说 List<T>,最下面一行说 T is Shoe。这就是泛型的定义方式——它表明 List 可以处理任何类型,但对于这个特定的列表,该类型是 Shoe 类。

泛型列表使用 <尖括号> 来声明。

当你声明一个列表时——不管它保存什么类型的对象——你总是以相同的方式声明它,使用 <尖括号> 来指定列表中存储的对象类型。

你经常会看到泛型类(不仅仅是 List)这样写:List。这样你就知道这个类可以接受任何类型。

图片

集合初始化器类似于对象初始化器。

在需要创建列表并立即添加多个项目时,C# 提供了一种简便的快捷方式以减少输入量。当你创建一个新的 List 对象时,可以使用集合初始化器来提供初始的项目列表。它会在列表创建后立即添加这些项目。

图片

集合初始化器通过允许你同时创建列表并添加初始项目,使得你的代码更加紧凑。

让我们创建一个 Duck 列表。

就这样!

这里有一个 Duck 类,用于跟踪你的许多邻里鸭子。(你确实收集鸭子,对吧?) 创建一个新的控制台应用项目 并添加一个新的 Duck 类和 KindOfDuck 枚举。

图片图片

这是你的 Duck 列表的初始化器。

你有六只鸭子,所以你会创建一个具有六个语句的 List,每个语句在初始化器中创建一个新的 Duck,使用对象初始化器来设置每个 Duck 对象的 Size 和 Kind 字段。确保这个 **using directive**Program.cs 的顶部:

using System.Collections.Generic;

然后将这个 PrintDucks 方法添加到你的 Program 类中

public static void PrintDucks(List<Duck> ducks)
{
    foreach (Duck duck in ducks) {
        Console.WriteLine($"{duck.Size} inch {duck.Kind}");
    }
}

最后,在 Program.csMain 方法中添加这段代码来创建一个 Duck 列表,然后打印它们:

List<Duck> ducks = new List<Duck>() {
    new Duck() { Kind = KindOfDuck.Mallard, Size = 17 },
    new Duck() { Kind = KindOfDuck.Muscovy, Size = 18 },
    new Duck() { Kind = KindOfDuck.Loon, Size = 14 },
    new Duck() { Kind = KindOfDuck.Muscovy, Size = 11 },
    new Duck() { Kind = KindOfDuck.Mallard, Size = 14 },
    new Duck() { Kind = KindOfDuck.Loon, Size = 13 },
};

PrintDucks(ducks);
注意

运行你的代码——它将在控制台打印出一堆 Duck。

列表很容易,但是排序可能有些棘手。

想要排序数字或字母并不难。但是如何对两个单独的对象进行排序,特别是它们有多个字段的情况下呢?在某些情况下,您可能希望按照“名称”字段的值对对象进行排序,而在其他情况下,可能根据“身高”或“出生日期”来排序对象才更合理。有很多种排序方法,而列表支持所有这些方法。

图片

列表知道如何对自己进行排序

每个列表都有一个排序方法,可以重新排列列表中的所有项目,使它们按顺序排列。列表已经知道如何对大多数内置类型和类进行排序,并且可以轻松地教会它们如何对您自己的类进行排序。

注意

从技术上讲,不是列表 List知道如何对自己进行排序。这是 IComparer对象的工作,您马上就会了解到它的工作原理。

图片

IComparable帮助列表排序它的鸭子

如果您有一个数字列表并调用其排序方法,它将首先将最小的数字排序,然后是最大的。列表如何知道如何对鸭子对象进行排序呢?我们告诉列表(List.Sort)鸭子类可以进行排序——通常我们用接口*来表示一个类能够完成某个任务。

通过使其实现 IComparable并添加 CompareTo 方法,您可以使任何类与列表的内置排序方法配合使用。

List.Sort 方法知道如何对实现了 IComparable接口的任何类型或类进行排序。该接口只有一个成员——名为 CompareTo 的方法。Sort 使用对象的 CompareTo 方法将其与其他对象进行比较,并使用其返回值(一个整数)来确定哪个对象排在前面。

对象的 CompareTo 方法将其与另一个对象进行比较

为了让我们的列表对象具有对鸭子进行排序的能力,一种方法是**修改鸭子类以实现 IComparable**并添加其唯一的成员,即接受鸭子引用作为参数的 CompareTo 方法。

通过实现 IComparable更新项目的鸭子类,以便根据鸭子的大小进行排序:

图片

在调用 PrintDucks 之前的 Main 方法的最后添加这行代码。这告诉您的鸭子列表对自己进行排序。现在它在将鸭子打印到控制台之前按大小对它们进行排序:

图片

使用 IComparer 告诉列表如何排序

您的鸭子类实现了 IComparable,所以 List.Sort 知道如何对鸭子对象列表进行排序。但是,如果您想以不同于通常方式的方式对它们进行排序怎么办?或者,如果您想对不实现 IComparable 的对象类型进行排序怎么办?那么您可以将一个比较器对象作为参数传递给 List.Sort,以提供不同的排序方式。请注意 List.Sort 的重载方式:

图片

List.Sort 还有一个重载版本,它接受一个 IComparer 的引用,其中 T 将被你列表的泛型类型替换(因此对于 List,它接受一个 IComparer 参数,对于 List,它是一个 IComparer,等等)。你将传递一个实现接口的对象的引用,我们知道这意味着:它 完成特定的工作。在这种情况下,这个工作是比较列表中项目对以告诉 List.Sort 如何排序它们的顺序。

IComparer 接口只有一个成员,一个名为 Compare 的方法。它与 IComparable 中的 CompareTo 方法完全相同:它接受两个对象参数 xy,如果 xy 之前则返回正值,如果 xy 之后则返回负值,如果它们相同则返回零。

向你的项目中添加一个 IComparer

将 DuckComparerBySize 类添加到你的项目中。它是一个比较器对象,你可以将其作为参数传递给 List.Sort,以使其按大小排序你的鸭子。

IComparer 接口位于 System.Collections.Generic 命名空间中,因此如果你将此类添加到新文件中,请确保它有正确的 using 指令:

using System.Collections.Generic;

这是比较器类的代码:

图片

注意

你能想出如何修改 DuckComparerBySize 使其按从大到小排序鸭子吗?

注意

比较器对象是一个类的实例,该类实现了 IComparer,你可以将其作为引用传递给 List.Sort。它的 Compare 方法的工作方式与 IComparable 接口中的 CompareTo 方法完全相同。当 List.Sort 比较其元素以对其进行排序时,它将一对对象传递给你的比较器对象的 Compare 方法,因此你的列表将根据你实现的比较器不同而不同排序。

创建你的比较器对象的一个实例

当你想使用 IComparer 进行排序时,你需要创建一个实现它的类的新实例——在本例中是 Duck。这就是比较器对象,它将帮助 List.Sort 弄清楚如何对其元素进行排序。与任何其他(非静态)类一样,在使用之前你需要实例化它:

图片

多个 IComparer 实现,多种排序对象的方式

你可以创建多个具有不同排序逻辑的 IComparer 类来以不同方式排序鸭子。然后,当你需要以特定方式排序时,你可以使用你想要的比较器。这里是另一个添加到你的项目中的鸭子比较器实现:

图片

返回并修改你的程序,使用这个新的比较器。现在它在打印之前按种类对鸭子进行排序。

IComparer<Duck> kindComparer = new DuckComparerByKind();
ducks.Sort(kindComparer);
PrintDucks(ducks);

比较器可以进行复杂的比较

为你的鸭子创建一个单独的排序类的一个优势是,你可以在该类中构建更复杂的逻辑,并且你可以添加帮助确定列表排序方式的成员。

图片

重写 ToString 方法让对象描述自己

每个对象都有一个叫做 ToString 的方法,将它转换为字符串。你已经用过它了—任何时候你在字符串插值中使用 {花括号},它都会调用花括号内部的 ToString 方法—而且 IDE 也会利用它。当你创建一个类时,它会继承自 Object 的 ToString 方法,Object 是所有其他类扩展的顶级基类。

Object.ToString 方法打印完全限定的类名,或者命名空间后跟一个句点再跟类名。由于在编写本章时我们使用了命名空间 DucksProject,我们的 Duck 类的完全限定类名是 DucksProject.Duck:

图片

IDE 也会调用 ToString 方法—例如,当你查看或检查一个变量时:

图片

嗯,这并不像我们希望的那样有用。你可以看到列表中有六个 Duck 对象。如果你展开一个 Duck,你可以看到它的 Kind 和 Size 值。如果你一次能看到所有这些对象,那不是更方便吗?

覆盖 ToString 方法以在 IDE 中查看你的 Duck 对象

幸运的是,ToString 是 Object 的虚方法,是每个对象的基类。所以你只需要重写 ToString 方法—当你这样做时,你会立即在 IDE 的 Watch 窗口中看到结果!打开你的 Duck 类,并开始通过输入 **override** 来添加一个新方法。一旦你加入一个空格,IDE 将会显示你可以重写的方法:

图片

点击 ToString() 告诉 IDE 添加一个新的 ToString 方法。替换内容使其看起来像这样:

public override string ToString()
{
    return $"A {Size} inch {Kind}";
}

运行你的程序并再次查看列表。现在 IDE 会显示 Duck 对象的内容。

图片

更新你的 foreach 循环,让你的 Duck 和 Card 对象自己写入控制台

你已经看到两个不同的程序示例,它们循环遍历对象列表,并调用 Console.WriteLine 来打印列表中每个对象的内容—就像这个 foreach 循环一样,它会打印 List 中的每个 Card:

    foreach (Card card in cards)
    {
        Console.WriteLine(card.Name);
    }

PrintDucks 方法对 List 中的 Duck 对象做了类似的事情:

    foreach (Duck duck in ducks) {
        Console.WriteLine($"{duck.Size} inch {duck.Kind}");
    }

这是对对象的一个非常常见的操作。现在你的 Duck 有了一个 ToString 方法,你的 PrintDucks 方法应该利用它。使用 IDE 的 IntelliSense 来浏览 Console.WriteLine 方法的重载—特别是这一个:

图片

你可以将任何对象传递给 Console.WriteLine,它会调用它的 ToString 方法。所以你可以用调用这个重载的方法来替换 PrintDucks 方法:

     public static void PrintDucks(List<Duck> ducks) {
         foreach (Duck duck in ducks) {
             Console.WriteLine(duck);
         }
     }

用这个方法替换 PrintDucks 方法,然后再次运行你的代码。它将打印相同的输出。如果你想要为你的 Duck 对象添加比如 Color 或者 Weight 属性,你只需要更新 ToString 方法,一切使用它的地方(包括 PrintDucks 方法)都将反映这些变更。

也为你的 Card 对象添加一个 ToString 方法

你的 Card 对象已经有一个返回卡片名称的 Name 属性:

    public string Name { get { return $"{Value} of {Suit}"; } }

这正是它的 ToString 方法应该做的事情。所以,在 Card 类中添加一个 ToString 方法:

    public override string ToString() 
    {
        return Name;
    }
注:

我们决定让 ToString 方法调用 Name 属性。你认为我们做对了吗?删除 Name 属性并将其代码移到 ToString 方法中会更好吗?当你回头修改代码时,你必须做出这样的选择 —— 并不总是明显哪个选择最好。

现在,使用 Card 对象的程序将更容易调试。

你可以使用 IEnumerable 向上转型整个列表。

记得你可以将任何对象向上转型为其超类吗?嗯,当你有一个对象列表时,你可以一次性将整个列表向上转型。这就叫做协变,你只需要一个 IEnumerable 接口的引用。

让我们看看这是如何运作的。我们将从本章一直使用的 Duck 类开始。然后我们将添加一个它将扩展的 Bird 类。Bird 类将包括一个静态方法,用于迭代 Bird 对象的集合。我们能够让它与 Duck 的 List 一起工作吗?

图片

由于所有的 Duck 都是 Bird,协变允许我们将 Duck 集合转换为 Bird 集合。如果你必须将 List 传递给只接受 List 的方法,这将非常有用。

去做这件事!

  1. 创建一个新的控制台应用程序项目。 添加一个基类 Bird(用于 Duck 扩展)和一个 Penguin 类。我们将使用 ToString 方法来轻松查看每个类的区别。

    图片

  2. 将你的 Duck 类添加到应用程序中。 修改它的声明使其扩展 Bird。你还需要添加此章节前面的 KindOfDuck 枚举

    图片

  3. 创建 List 集合。 请继续在你的 Main 方法中添加这段代码 ——它是本章前面的代码,再加上一行将其向上转型为 List:

    图片

    啊哦 —— 那段代码编译不通过。错误信息告诉你不能将 Duck 集合转换为 Bird 集合。让我们尝试将 ducks 赋值给一个 List:

    图片

    哎呀,这没用。我们得到了一个不同的错误,但它仍然说我们无法转换类型:

    图片

    这是有道理的 —— 这就像安全向上转型与向下转型一样,你在 #inheritance_your_objectapostrophes_famil 中学到的:我们可以使用赋值运算符来进行向下转型,但我们需要使用 is 关键字来安全地向上转型。那么我们如何安全地将 List 向上转型为 List?

  4. 使用协变让你的鸭子飞走。 这就是协变的作用:你可以使用赋值将你的 List向上转型为 IEnumerable。一旦你得到了你的 IEnumerable,你可以调用它的 ToList 方法将其转换为 List。你需要在文件顶部添加 using System.Collections.Generic; 和 using System.Linq;:

    图片

使用字典来存储键和值

列表就像一个长长的页面,上面写满了名字。如果你还想要,对于每个名字,一个地址呢?或者对于garage列表中的每辆车,你想要关于那辆车的详细信息?你需要另一种.NET 集合:一个字典。字典让你取一个特殊的值————并将该键与一堆数据————关联起来。还有一件事:一个特定的键在任何字典中只能出现一次

图片

这是如何在 C#中声明.NET 字典:

图片

让我们看看字典的实际应用。这是一个小型控制台应用程序,使用 Dictionary<string, string>来跟踪几个朋友的最喜爱的食物:

图片

字典功能概述

字典和列表很像。这两种类型都灵活地让你处理许多数据类型,并且都带有许多内置功能。以下是你可以用字典做的基本事情。

  • 添加一个项目。

    你可以使用方括号的索引器向字典中添加一个项目:

    Dictionary<string, string> myDictionary = new Dictionary<string, string>();
    myDictionary["some key"] = "some value";
    

    你也可以使用Add 方法向字典中添加一个项目:

    Dictionary<string, string> myDictionary = new Dictionary<string, string>();
    myDictionary.Add("some key", "some value");
    
  • 使用键查找值。

    你将使用字典中最重要的功能是使用索引器查找值——这是有道理的,因为你将这些值存储在字典中,以便使用它们的唯一键查找它们。这个例子展示了一个 Dictionary<string, string>,所以我们将使用一个字符串键查找值,并且字典返回一个字符串值:

    string lookupValue = myDictionary["some key"];
    
  • 移除一个项目。

    就像列表一样,你可以使用Remove 方法从字典中移除一个项目。你只需要传递给 Remove 方法的是要移除的键值:

    myDictionary.Remove("some key");
    
  • 获取键的列表。

    你可以通过其Keys 属性获取字典中所有键的列表,并使用foreach循环遍历它。下面是这样做的样子:

    foreach (string key in myDictionary.Keys) { ... };
    
    注意

    Keys 是你的字典对象的一个属性。这个特定的字典有字符串键,所以 Keys 是一个字符串集合。

  • 计算字典中的对数。

    Count 属性返回字典中键/值对的数量:

    int howMany = myDictionary.Count;
    
注意

键在字典中是唯一的;任何键只出现一次。值可以出现任意次数——两个键可以有相同的值。这样,当你查找或移除一个键时,字典知道要移除什么。

你的键和值可以是不同类型的

字典非常灵活!它们可以容纳几乎任何东西,不仅仅是值类型,而是任何类型的对象。这里有一个存储整数作为键和 Duck 对象引用作为值的字典的示例:

Dictionary<int, Duck> duckIds = new Dictionary<int, Duck>();
duckIds.Add(376, new Duck() { Kind = KindOfDuck.Mallard, Size = 15 });
注意

当您为对象分配唯一的 ID 号码时,看到一个将整数映射到对象的字典是很常见的。

构建一个使用字典的程序

这是一个快速应用程序,纽约洋基棒球迷会喜欢的。当一名重要球员退役时,球队会退役球员的球衣号码。创建一个新的控制台应用程序,查找一些穿过著名号码的洋基球员以及这些号码何时被退役。这里有一个类来跟踪退役的棒球球员:

做这个!

class RetiredPlayer
{
    public string Name { get; private set; }
    public int YearRetired { get; private set; }

    public RetiredPlayer(string player, int yearRetired)
    {
        Name = player;
        YearRetired = yearRetired;
    }
}

这里有一个带有 Main 方法的 Program 类,将退役球员添加到字典中。我们可以使用球衣号码作为字典的键,因为它是唯一的——一旦球衣号码被退役,球队永远不会再使用它。这在设计使用字典的应用程序时是需要考虑的重要事项:您绝不希望发现您的键并不像您想象的那样唯一!

注意

约基·贝拉曾经是纽约洋基队的#8 号球员,而卡尔·里普肯·朱尓是巴尔的摩金莺的#8 号球员。但在字典中,您可以有重复的值,但每个键必须是唯一的。您能想到一种方法来存储多个球队的退役号码吗?

图像

还有更多的集合类型...

列表和字典是.NET 中最常用的两种集合类型之一。列表和字典非常灵活——你可以以任意顺序访问它们中的任何数据。但有时你使用集合来表示现实世界中需要按特定顺序访问的一堆东西。您可以使用队列或堆栈来限制代码访问集合中的数据。它们是像 List一样的泛型集合,但特别擅长确保按照特定顺序处理数据。

注意

还有其他类型的集合,但这些是你最有可能接触到的。

当您存储的第一个对象将是您要使用的第一个对象时,请使用队列:

  • 汽车沿单向街道行驶

  • 排队等候的人

  • 等待客服支持电话的客户

  • 其他任何按先来先服务处理的事物

注意

队列是先进先出的,这意味着您放入队列的第一个对象是您取出并使用的第一个对象。

当您总是想使用最近存储的对象时,请使用堆栈:

  • 家具装载到移动卡车的后面

  • 一个书堆,你希望先读最近添加的那本书

  • 登机或离开飞机的人

  • 一堆啦啦队员,顶上的人必须先下来...想象一下,如果底下的人先走了会是什么情况!

注意

是后进先出:进入栈的第一个对象是最后一个出栈的对象。

泛型.NET 集合实现 IEnumerable

几乎每个大型项目都会包含某种通用集合,因为程序需要存储数据。当您在现实世界中处理类似的事物组时,它们几乎总是自然地归类到与这些种类的集合相对应的一类中。无论您使用哪种集合类型——List、Dictionary、Stack 或 Queue,您总是可以使用 foreach 循环,因为它们都实现了 IEnumerable接口。

队列就像一个允许您将对象添加到末尾并使用位于开头的对象的列表。栈只允许您访问您放入其中的最后一个对象。

注意

您可以使用 foreach 枚举堆栈或队列,因为它们实现了IEnumerable!

队列是 FIFO—先进先出

队列很像列表,但不能随意在任何索引处添加或删除项目。要将对象添加到队列中,您需要进行入队操作。这将对象添加到队列的末尾。您可以从队列的前端出队第一个对象。这样做时,该对象从队列中移除,并且队列中其余对象向前移动一个位置。

图片图片

是 LIFO—后进先出

与队列非常相似,但有一个很大的区别。您需要每个项目到栈上,当您想从栈中取出一个项目时,您需要一个项目。当您从栈中弹出一个项目时,您得到的是最近推入栈中的项目。这就像一个叠盘子、杂志或任何其他东西的栈一样——您可以把东西放在栈的顶部,但在获取其下面的内容之前,您需要把它拿掉。

图片图片图片图片

当您使用队列或栈时,您并不会失去任何东西。

将队列对象复制到列表对象非常容易。将列表复制到队列,队列复制到堆栈也同样容易……事实上,您可以从任何实现 IEnumerable接口的其他对象创建列表、队列或堆栈。您只需使用允许您将要从中复制的集合作为参数传递的重载构造函数。这意味着您可以灵活方便地使用最适合您需要的集合来表示数据。(但请记住,您正在进行复制,这意味着您正在创建一个全新的对象并将其添加到堆中。)

图片

第十三章:Unity 实验室#4:用户界面

在上一个 Unity 实验室中,你开始构建一个游戏,使用预制体来创建在游戏的 3D 空间中随机点出现并绕圈飞行的 GameObject 实例。这个 Unity 实验室继续了上一个实验室的工作,允许你应用你在 C#中学到的关于界面的知识等等。

到目前为止,你的程序是一个有趣的视觉模拟。这个 Unity 实验室的目标是完成游戏的构建。游戏从零分开始。台球将开始出现并在屏幕上飞动。当玩家点击一个球时,分数会增加 1 分并且球会消失。越来越多的球会出现;一旦屏幕上有 15 个球在飞动,游戏就会结束。为了使你的游戏运行起来,玩家需要一种启动游戏的方式,并且在游戏结束后能够再次玩游戏,并且他们希望在点击球时能看到自己的得分。因此,你将在屏幕角落添加一个显示分数的用户界面,并显示一个按钮来启动新游戏。

添加一个在玩家点击球时增加分数的功能

你有一个非常有趣的模拟器。现在是将其转变成游戏的时候了。在 GameController 类中添加一个新字段来跟踪分数 —— 你可以将其添加在 OneBallPrefab 字段的下方:

    public int Score = 0;

接下来,在 GameController 类中添加一个名为 ClickedOnBall 的方法。每次玩家点击一个球时,该方法将被调用:

    public void ClickedOnBall()
    {
        Score++;
    }

Unity 使得你的 GameObject 能够很容易地响应鼠标点击和其他输入。如果你在一个脚本中添加一个名为 OnMouseDown 的方法,Unity 将在每次点击附加到它的 GameObject 时调用该方法。将此方法添加到 OneBallBehaviour 类中

    void OnMouseDown()
    {
        GameController controller = Camera.main.GetComponent<GameController>();
        controller.ClickedOnBall();
        Destroy(gameObject);
    }

OnMouseDown 方法的第一行获取 GameController 类的实例,第二行调用它的 ClickedOnBall 方法,该方法增加其 Score 字段。

现在运行你的游戏。点击层级中的 Main Camera 并观察检查器中的 Game Controller(脚本)组件。点击一些旋转的球 —— 它们会消失,而得分会增加。

Images

给你的游戏添加两种不同的模式

启动你最喜欢的游戏。你是不是立刻投入到了动作中?可能不是 —— 你可能在看起始菜单。有些游戏允许你暂停动作来查看地图。许多游戏允许你在移动玩家和使用库存之间切换,或者在玩家死亡时显示无法中断的动画。这些都是游戏模式的例子。

你将为你的游戏添加两种模式。你已经有“运行”模式了,现在只需要添加一个“游戏结束”模式。

让我们为你的台球游戏添加两种不同的模式:

  • 模式#1:游戏正在运行。 球正在被添加到场景中,点击它们会使它们消失并增加分数。

  • 模式#2:游戏结束。 球不再添加到场景中,点击它们不会有任何效果,并显示“游戏结束”横幅。

图像

这是你将两种游戏模式添加到游戏中的方法:

  1. 使 GameController.AddABall 注意游戏模式。

    你的新改进的 AddABall 方法将检查游戏是否结束,只有在游戏未结束时才会实例化一个新的 OneBall 预制件。

  2. 使 OneBallBehaviour.OnMouseDown 仅在游戏运行时起作用。

    当游戏结束时,我们希望游戏不再响应鼠标点击。玩家应该只能看到已经添加的球继续在周围旋转,直到游戏重新开始。

  3. 使 GameController.AddABall 在球太多时结束游戏。

    AddABall 还会增加其 NumberOfBalls 计数器,每添加一个球,该计数器将增加 1。如果值达到 MaximumBalls,它会将 GameOver 设置为 true,以结束游戏。

注意

在这个实验中,你将逐步构建这个游戏,并进行途中的更改。你可以从书本的 GitHub 存储库下载每个部分的代码:github.com/head-first-csharp/fourth-edition

向你的游戏中添加游戏模式

修改你的 GameController 和 OneBallBehaviour 类,通过使用布尔字段来追踪游戏是否结束,为你的游戏添加模式

  1. 使 GameController.AddABall 注意游戏模式。

    我们希望 GameController 知道游戏处于什么模式。当我们需要追踪对象知道的信息时,我们使用字段。因为有两种模式——运行和游戏结束——我们可以使用布尔字段来追踪模式。在你的 GameController 类中添加 GameOver 字段

        public bool GameOver = false;
    

    当游戏运行时,游戏应仅向场景中添加新球。修改 AddABall 方法,添加一个if语句,仅在 GameOver 不为真时调用 Instantiate:

         public void AddABall()
          {
             if (!GameOver)
             {
                 Instantiate(OneBallPrefab);
             }
         }
    

    现在可以测试一下。启动游戏,然后在层次视图窗口中点击主摄像机

    图像

    通过取消脚本组件中的复选框来设置 GameOver 字段。直到再次勾选该框,游戏才会停止添加球。

  2. 使 OneBallBehaviour.OnMouseDown 仅在游戏运行时起作用。

    你的 OnMouseDown 方法已经调用了 GameController 的 ClickedOnBall 方法。现在,修改 OneBallBehaviour 中的 OnMouseDown方法,也使用 GameController 的 GameOver 字段:

         void OnMouseDown()
         {
             GameController controller = Camera.main.GetComponent<GameController>();
            if (!controller.GameOver)
            {
                controller.ClickedOnBall();
                Destroy(gameObject);
            }
         }
    

    再次运行你的游戏,并测试只有在游戏未结束时球消失并且分数上升。

  3. 使 GameController.AddABall 在球太多时结束游戏。

    游戏需要跟踪场景中球的数量。我们将通过向 GameController 类添加两个字段来实现这一点,以跟踪当前球的数量和最大球的数量:

        public int NumberOfBalls = 0;
        public int MaximumBalls = 15;
    

    每当玩家点击球时,球的 OneBallBehaviour 脚本会调用 GameController.ClickedOnBall 来增加(加 1 到)分数。让我们也减少(从中减 1)NumberOfBalls:

         public void ClickedOnBall()
         {
             Score++;
             NumberOfBalls--;
         }
    

    现在 修改 AddABall 方法,只有在游戏运行时才添加球,并且如果场景中球太多则结束游戏:

    图片

    现在通过运行游戏并在 Hierarchy 窗口点击 Main Camera 来再次测试游戏。游戏应该正常运行,但一旦 NumberOfBalls 字段等于 MaximumBalls 字段时,AddABall 方法将其 GameOver 字段设置为 true 并结束游戏。

    图片

    一旦这种情况发生,点击球不会有任何作用,因为 OneBallBehaviour.OnMouseDown 检查 GameOver 字段,仅在 GameOver 为 false 时增加分数并销毁球。

    您的游戏需要跟踪其游戏模式。字段是实现这一点的好方法。

为您的游戏添加一个 UI

几乎任何你能想到的游戏 —— 从 Pac Man 到 Super Mario Brothers 到 Grand Theft Auto 5 到 Minecraft —— 都包含了一个 用户界面(或 UI)。一些游戏,如 Pac Man,有一个非常简单的 UI,只显示得分、最高分、剩余生命和当前级别。许多游戏特别在游戏机制中加入了复杂的 UI(比如武器轮让玩家快速切换武器)。让我们为您的游戏添加一个 UI。

从 GameObject 菜单选择 UI >> Text,以向游戏的 UI 添加一个 2D Text GameObject。这将在 Hierarchy 中添加一个 Canvas,并在该 Canvas 下添加一个 Text:

图片

在 Hierarchy 窗口双击 Canvas 以聚焦它。它是一个二维矩形。点击它的 Move Gizmo 并在场景中拖动它。它不会移动!刚添加的 Canvas 将始终显示,按照屏幕大小缩放,并位于游戏中其他所有内容的前面。

图片

注意

在 Hierarchy 中注意到了 EventSystem 吗?Unity 在创建 UI 时会自动添加它。它管理鼠标、键盘和其他输入,并将它们发送回 GameObjects —— 所有这些都是自动完成的,因此您不需要直接与它交互。

然后双击 Text 以聚焦它 —— 编辑器会放大,但默认文本(“New Text”)将会是反向的,因为 Main Camera 正对着 Canvas 的后面。

注意

Canvas 是一个二维 GameObject,可让您布置游戏的用户界面。您游戏的 Canvas 将有两个嵌套的 GameObjects:刚添加的 Text GameObject 将位于右上角显示分数,还有一个 Button GameObject 允许玩家开始新游戏。

使用 2D 视图来操作 Canvas

Scene 窗口顶部的 2D 按钮 切换 2D 视图的开和关:

图片

点击 2D 视图 —— 编辑器会转到正面显示 Canvas。在 Hierarchy 窗口双击 Text 以放大它。

图片

注意

使用鼠标滚轮在 2D 视图中进行缩放

点击 2D 按钮可在 2D 和 3D 视图之间切换。再次点击可返回 3D 视图。

设置在 UI 中显示分数的文本

您的游戏 UI 将包含一个 Text GameObject 和一个 Button。每个 GameObject 都将锚定在 UI 的不同部分。例如,显示分数的 Text GameObject 将显示在屏幕的右上角(无论屏幕大小如何)。

点击 Hierarchy 窗口中的 Text 以选择它,然后查看 Rect Transform 组件。我们希望文本显示在右上角,因此点击 Rect Transform 面板中的锚点框

图片

锚点预设窗口允许您将 UI GameObject 锚定到 Canvas 的各个部分。按住 Alt 和 Shift(或 Mac 上的 Option+Shift),然后选择右上角的锚点预设。再次单击与打开锚点预设窗口相同的按钮。现在文本位于 Canvas 的右上角——再次双击它以放大查看。

图片

让我们在文本的上方和右侧添加一点空间。返回到 Rect Transform 面板,将 Pos X 和 Pos Y 都设置为-10,以便将文本定位在距右上角左 10 个单位和下 10 个单位的位置。然后将 Text 组件上的 Alignment 设置为 right,并使用检查器顶部的框将游戏对象的名称更改为 **Score**

图片

您的新文本现在应该显示在 Hierarchy 窗口中,并带有名称 Score。它现在应该右对齐,文本边缘与 Canvas 边缘之间有一小段距离。

图片

添加一个调用方法以启动游戏的按钮

当游戏处于“游戏结束”模式时,它将显示一个标有“Play Again”的按钮,该按钮调用一个方法以重新启动游戏。向您的 GameController 类添加一个空的 StartGame 方法(稍后我们将添加其代码):

    public void StartGame()
    {
 // We’ll add the code for this method later
    }

点击 Hierarchy 窗口中的 Canvas以将焦点放在它上面。然后从 GameObject 菜单中选择 UI >> Button 添加一个按钮。由于您已经专注于 Canvas,Unity 编辑器将添加新的 Button 并将其锚定到 Canvas 的中心。您注意到 Hierarchy 中的 Button 旁边有一个三角形吗?展开它——它下面嵌套了一个 TextGameObject。点击它并将其文本设置为Play Again

图片

按钮设置好后,我们只需让它在附加到主摄像机上的 GameController 对象上调用 StartGame 方法。UI 按钮只是一个带有 Button 组件的游戏对象,您可以使用检查器中的其 On Click()框来将其连接到事件处理程序方法。点击 On Click()框底部的图片按钮添加事件处理程序,然后将主摄像机拖放到 None(Object)框上

图片

现在按钮知道要使用哪个游戏对象作为事件处理程序。点击图片下拉菜单,选择GameController >> StartGame。现在当玩家按下按钮时,它将调用附加到主摄像机的 GameController 对象上的 StartGame 方法。

图片

使“再玩一次”按钮和得分文本起作用

你游戏的 UI 将会像这样工作:

  • 游戏从游戏结束模式开始。

  • 点击“再玩一次”按钮开始游戏。

  • 屏幕右上角的文本显示当前得分。

你将在代码中使用 Text 和 Button 类。它们位于 UnityEngine.UI 命名空间中,所以在 GameController 类的顶部添加这个 **using 语句**

using UnityEngine.UI;

现在你可以在你的 GameController 中添加 Text 和 Button 字段(就在 OneBallPrefab 字段的上方):

    public Text ScoreText;
    public Button PlayAgainButton;

在层次视图中点击主摄像机将文本游戏对象从层次结构中拖出,并放置到脚本组件的得分文本字段上,然后将按钮游戏对象 放置到“再玩一次”按钮字段上。

图片

回到你的 GameController 代码,并将 GameController 字段的默认值设置为 true

    public bool GameOver = true;
注意

将这个从 false 修改为 true。

现在回到 Unity,并检查检视器中的脚本组件。

等等,出了点问题!

图片

Unity 编辑器仍然显示未选中游戏结束复选框,它没有改变字段值。确保勾选复选框,这样你的游戏将从游戏结束模式开始:

图片

现在游戏将以游戏结束模式开始,玩家可以点击“再玩一次”按钮开始游戏。

完成游戏的代码

主摄像机附加的 GameController 对象在其 Score 字段中跟踪得分。在 GameController 类中添加一个 Update 方法来更新 UI 中的得分文本:

    void Update()
    {
        ScoreText.text = Score.ToString();
    }

接下来,修改你的 GameController.AddABall 方法以在游戏结束时启用“再玩一次”按钮:

    if (NumberOfBalls >= MaximumBalls)
    {
        GameOver = true;
        PlayAgainButton.gameObject.SetActive(true);
    }
注意

每个游戏对象都有一个叫做 gameObject 的属性,让你可以操作它。你将使用它的 SetActive 方法来使“再玩一次”按钮可见或不可见。

还有最后一件事要做:让你的 StartGame 方法起作用,以便启动游戏。它需要做几件事情:销毁当前场景中正在飞行的任何球,禁用“再玩一次”按钮,重置得分和球的数量,并设置模式为“运行”。你已经知道如何做大部分的事情了!你只需要找到球以便销毁它们。点击项目窗口中的 OneBall 预制体并设置它的标签

图片

现在你已经准备好填写你的 StartGame 方法了。它使用 foreach 循环来查找和销毁上一场游戏中剩余的任何球,隐藏按钮,重置得分和球的数量,并改变游戏模式:

     public void StartGame()
     {
        foreach (GameObject ball in GameObject.FindGameObjectsWithTag("GameController"))
        {
            Destroy(ball);
        }
        PlayAgainButton.gameObject.SetActive(false);
        Score = 0;
        NumberOfBalls = 0;
        GameOver = false;
     }

现在运行你的游戏。它从“游戏结束”模式开始。按下按钮开始游戏。每次点击球时,分数都会增加。当第 15 个球被实例化时,游戏结束,再次出现“再玩一次”按钮。

注意

**你注意到了吗,你并没有对 GameController 类做任何更改吗?那是因为你没有改变 GameController 管理 UI 或游戏模式等功能。如果你可以通过修改一个类而不触及其他类来进行修改,那可能是你设计类的良好标志。

发挥创造力!

你能找到改善游戏并练习编写代码的方法吗?以下是一些建议:

  • 游戏太简单了吗?太难了吗?尝试更改你在 GameController.Start 方法中传递给 InvokeRepeating 的参数。尝试将它们作为字段。还可以玩弄 MaximumBalls 值。这些值的小改变可能会对游戏玩法产生很大影响。

  • 我们为所有台球提供了纹理映射。尝试添加具有不同行为的不同球。使用比例尺使一些球变大或变小,并更改它们的参数以使它们移动得更快或更慢,或者移动方式不同。

  • 你能想出如何制作一个“流星”球,它在一个方向上飞行得很快,如果玩家点击它的话,价值很高吗?怎么样制作一个“突然死亡”8 号球,它会立即结束游戏?

  • 修改你的 GameController.ClickedOnBall 方法,使其接受一个分数参数,而不是增加分数字段并添加传递的值。尝试为不同的球赋予不同的值。

如果你修改了 OneBallBehaviour 脚本中的字段,请不要忘记重置 OneBall 预制件的脚本组件!否则,它会记住旧值。

你练习编写 C#代码的次数越多,就会越容易。发挥创造力来制作你的游戏是一个很好的练习机会!