嗨翻 C# 第四版(三)
原文:
zh.annas-archive.org/md5/aa741d4f28e1a4c90ce956b8c4755b7e译者:飞龙
第八章:封装:保护您的私人信息……私密
是否曾经希望拥有更多的隐私?
有时你的对象也有同样的感受。就像你不希望任何你不信任的人读你的日记或翻阅你的银行对账单一样,好的对象不让其他对象查看它们的字段。在本章中,您将学习到封装的力量,这是一种编程方式,可以帮助您编写灵活、易于使用且难以被误用的代码。您将使您对象的数据私有,并添加属性以保护数据的访问方式。
让我们帮助 Owen 掷骰子计算伤害
Owen 对他的能力分数计算器感到非常满意,他想要创建更多他可以用于游戏的 C#程序,而你将帮助他。在他目前玩的游戏中,每次有剑攻击时,他会掷骰子并使用一个计算伤害的公式。Owen 在他的游戏主控笔记本中记录了剑伤害公式的工作方式。
这里有一个名为SwordDamage的类,用于进行计算。仔细阅读代码——你即将创建一个应用程序来使用它。
创建一个控制台应用程序来计算伤害
让我们为 Owen 构建一个控制台应用程序,使用 SwordDamage 类。它将在控制台上打印一个提示,询问用户是否指定剑是魔法的和/或燃烧的,然后进行计算。以下是应用程序的输出示例:
是的!我们可以构建一个使用相同类的 WPF 应用程序。
让我们找一个方法来在一个 WPF 应用程序中重用SwordDamage 类。对我们来说,第一个挑战是如何提供一个直观的用户界面。一把剑可以是魔法的、燃烧的、两者兼有,或者都不是,所以我们需要弄清楚如何在 GUI 中处理这个问题——而且有很多选择。我们可以使用四个选项的单选按钮或下拉列表,就像控制台应用程序提供了四个选项一样。但是,我们认为使用复选框会更清晰、更明显。
在 WPF 中,CheckBox 使用 Content 属性在框的右侧显示标签,就像 Button 使用 Content 属性显示文本一样。我们有 SetMagic 和 SetFlaming 方法,因此我们可以使用 CheckBox 控件的Checked 和 Unchecked 事件,让您指定在用户选中或取消选中框时调用的方法。
前往 Visual Studio for Mac 学习指南,查看该项目的 Mac 版本。
设计一个 WPF 版本的伤害计算器的 XAML
创建一个新的 WPF 应用程序,并将主窗口标题设置为**剑伤害**,高度设置为**175**,宽度设置为**300**。向网格添加三行两列。顶部行应包含两个标签为 Flaming 和 Magic 的 CheckBox 控件,中间行应包含一个标签为“掷骰子计算伤害”的 Button 控件,该按钮跨越两列,底部行应包含一个跨越两列的 TextBlock 控件。
注意
做这个!
这是 XAML——你肯定可以使用设计师来构建你的表单,但你也应该学会手动编辑 XAML:
注意
将 CheckBox 控件的名称命名为magic和flaming,将 TextBlock 控件的名称命名为damage。确保这些名称在 XAML 中的x:Name属性中正确显示。
WPF 伤害计算器的代码后台
将这段代码后台添加到你的 WPF 应用中。它创建了 SwordDamage 和 Random 的实例,并使 CheckBox 和按钮计算伤害:
做这个!
public partial class MainWindow : Window
{
Random random = new Random();
SwordDamage swordDamage = new SwordDamage();
public MainWindow()
{
InitializeComponent();
swordDamage.SetMagic(false);
swordDamage.SetFlaming(false);
RollDice();
}
public void RollDice()
{
swordDamage.Roll = random.Next(1, 7) + random.Next(1, 7) + random.Next(1, 7);
DisplayDamage();
}
void DisplayDamage()
{
damage.Text = "Rolled " + swordDamage.Roll + " for " + swordDamage.Damage + " HP";
}
private void Button_Click(object sender, RoutedEventArgs e)
{
RollDice();
}
private void Flaming_Checked(object sender, RoutedEventArgs e)
{
swordDamage.SetFlaming(true);
DisplayDamage();
}
private void Flaming_Unchecked(object sender, RoutedEventArgs e)
{
swordDamage.SetFlaming(false);
DisplayDamage();
}
private void Magic_Checked(object sender, RoutedEventArgs e)
{
swordDamage.SetMagic(true);
DisplayDamage();
}
private void Magic_Unchecked(object sender, RoutedEventArgs e)
{
swordDamage.SetMagic(false);
DisplayDamage();
}
}
注意
仔细阅读这段代码。在运行之前,你能发现任何错误吗?
桌游谈话(或许是…骰子讨论?)
游戏之夜!欧文的整个游戏派对来了,他即将展示他全新的剑伤害计算器。让我们看看效果如何。
杰登: 欧文,你在说什么?
欧文: 我在说这个新应用将会自动计算剑的伤害…
马修: 因为掷骰子实在是太,太难了。
杰登: 别开玩笑了,大家。让我们给它一个机会。
欧文: 谢谢,杰登。这正是个完美的时机,因为布兰妮刚刚用她的火焰魔法剑攻击了狂暴的牛人。来吧,布兰妮。试试看。
布兰妮: 好的。我们刚刚启动了这个应用。我勾选了“魔法”框框。看起来它保存了一个旧掷骰子结果,让我再次点击“掷骰子”来重新计算,然后…
杰登: 等等,这不对。现在你掷了 14 点,但仍显示 3 点 HP。再点击一次。掷了 11 点,造成 3 点 HP。再多点几次。9 点、10 点、5 点,都只给 3 点 HP。欧文,怎么回事?
布兰妮: 嘿,它有点儿用。如果你点击“掷骰子”,然后多次勾选框框,最终会给出正确的答案。看起来我掷了 10 点,造成 22 点 HP 损伤。
杰登: 你说得对。我们只需按照一个非常具体的顺序点击。首先点击掷骰子,然后勾选正确的框框,最好两次检查“Flaming”框框。
欧文: 你说得对。如果我们完全按照这个顺序做,程序就能正常运行。但如果我们按其他顺序做,它就会出错。好吧,我们可以处理这个。
马修: 或者…也许我们可以用真正的骰子来做事情呢?
让我们试着修复这个 bug
当你运行程序时,它做的第一件事是什么?让我们仔细看看 MainWindow 类顶部的这个方法与窗口的代码后台:
当一个类有构造函数时,当创建该类的新实例时,它是第一件被运行的事情。当你的应用程序启动并创建 MainWindow 的一个实例时,首先初始化字段,包括创建一个新的 SwordDamage 对象,然后调用构造函数。所以程序在显示窗口之前就调用了 RollDice,并且每次点击 roll 时我们都会看到问题,所以也许我们可以通过在 RollDice 方法中插入一个解决方案来修复这个问题。对 RollDice 方法进行以下更改:
现在测试你的代码。运行程序并点击按钮几次。到目前为止一切顺利——数字看起来是正确的。现在选中“魔法”复选框并再次点击按钮几次。好的,看起来我们的修复起作用了!还有一件事需要测试。选中“燃烧”复选框并点击按钮,然后……***糟糕!***还是不起作用。点击按钮时,它执行了 1.75 的魔法倍增器,但没有增加额外的 3 点 HP 用于燃烧。你仍然需要勾选和取消勾选“燃烧”复选框才能得到正确的数字。所以程序仍然有问题。
在尝试修复 bug 之前,始终思考是什么导致了 bug。
当代码出现问题时,立即开始编写更多代码来尝试修复确实非常诱人。这样做可能会让你觉得自己在迅速采取行动,但很容易只是添加更多有错误的代码。花点时间弄清楚真正引起 bug 的原因总是更安全的,而不是仅仅尝试插入一个快速修复。
使用 Debug.WriteLine 打印诊断信息
在过去的几章中,你使用调试器来追踪错误,但这并不是开发人员发现代码问题的唯一方法。事实上,当专业开发人员试图追踪其代码中的错误时,他们最常做的事情之一是添加打印输出行的语句,这正是我们将要做的,用来追踪这个 bug。
通过选择“视图”菜单中的“输出”(Ctrl+O W),在 Visual Studio 中打开输出窗口。从 WPF 应用程序中调用 Console.WriteLine 所打印的任何文本将显示在此窗口中。你应该仅在用户应该看到的显示输出时使用 Console.WriteLine。而在仅为调试目的打印输出行时,应使用Debug.WriteLine。Debug 类位于 System.Diagnostics 命名空间中,因此首先在 SwordDamage 类文件的顶部添加一个 using 行:
using System.Diagnostics;
接下来,在 CalculateDamage 方法的末尾添加一个 Debug.WriteLine 语句:
public void CalculateDamage()
{
Damage = (int)(Roll * MagicMultiplier) + BASE_DAMAGE + FlamingDamage;
Debug.WriteLine($"CalculateDamage finished: {Damage} (roll: {Roll})");
}
现在在 SetMagic 方法的末尾再添加一个 Debug.WriteLine 语句,并在 SetFlaming 方法的末尾再添加一个。它们应该与 CalculateDamage 中的语句相同,只是在输出时打印“SetMagic”或“SetFlaming”,而不是“CalculateDamage”:
public void SetMagic(bool isMagic)
{
// the rest of the SetMagic method stays the same
Debug.WriteLine($"SetMagic finished: {Damage} (roll: {Roll})");
}
public void SetFlaming(bool isFlaming)
{
// the rest of the SetFlaming method stays the same
Debug.WriteLine($"SetFlaming finished: {Damage} (roll: {Roll})");
}
注意
现在你的程序将向输出窗口打印有用的诊断信息。
注意
不需要设置任何断点,您可以追踪此错误。这是开发人员经常做的事情……所以你也应该学会这样做!
注意
Debug.WriteLine 是你开发工具箱中最基本且最有用的调试工具之一!有时,在代码中找出错误的最快方法是策略性地添加 Debug.WriteLine 语句,以提供重要线索,帮助你破解问题。
人们不会总是按照你的预期方式使用你的类。
大多数时候使用你的类的“人”是你自己!今天你可能正在编写一个类,明天或下个月你就会使用它。幸运的是,C#为你提供了一种强大的技术,确保你的程序始终正确运行——即使人们做了你从未想过的事情。它被称为封装,对于处理对象非常有帮助。封装的目标是限制对类“内部”数据的访问,以便所有类成员都安全且难以误用。这使你能够设计类,使其更难以被错误使用——这是预防像你在剑伤害计算器中追查出的错误的绝佳方法。
很容易意外地误用你的对象
Owen 的应用程序遇到问题,因为我们假设 CalculateDamage 方法会计算伤害。事实证明直接调用该方法是不安全的,因为它会替换 Damage 值并擦除已经完成的任何计算。相反,我们需要让 SetFlaming 方法为我们调用 CalculateDamage——但甚至这也不够,因为我们还必须确保先始终调用 SetMagic。因此,尽管 SwordDamage 类在技术上可以工作,但是当代码以意外的方式调用它时会引发问题。
SwordDamage 类的 预期使用方式**
SwordDamage 类为应用程序提供了一种良好的方法来计算剑的总伤害。它所需做的就是设置 Roll,然后调用 SetMagic 方法,最后调用 SetFlaming 方法。如果按照这个顺序进行操作,Damage 字段将被计算后更新。但这不是应用程序做的事情。
SwordDamage 类的 实际使用方式**
相反,它设置了 Roll 字段,然后调用了 SetFlaming,将火焰剑的额外伤害添加到 Damage 字段中。然后调用 SetMagic,最后调用 CalculateDamage,这将重置 Damage 字段并丢弃额外的火焰伤害。
封装意味着将类中的一些数据保持私有
有一种方法可以避免滥用对象的问题:确保只有一种方法可以使用你的类。C#通过让你将一些字段声明为**private**来帮助你做到这一点。到目前为止,你只看到了公共字段。如果你有一个具有公共字段的对象,任何其他对象都可以读取或更改该字段。如果将其设置为私有字段,那么该字段只能从该 对象内部访问(或者由同一类的另一个实例访问)。
注意
通过将 CalculateDamage 方法私有化**,我们防止应用程序意外调用它并重置 Damage 字段。将参与计算的字段更改为私有,可以防止应用程序干扰计算。当你将一些数据私有化,然后编写代码来使用这些数据时,这被称为封装。当一个类保护其数据并提供安全使用且难以滥用的成员时,我们称其为良好封装。**
使用封装来控制对类方法和字段的访问
当你将所有字段和方法都设置为公共时,任何其他类都可以访问它们。你的类所做的一切以及所知道的一切都成为程序中每个其他类的开放书籍……你刚刚看到这可能导致你的程序以你从未预料过的方式运行。
这就是为什么public和private关键字被称为访问修饰符:它们修改对类成员的访问。封装让你控制在类内部分享什么和保留什么私有。让我们看看这是如何工作的。
-
超级间谍赫伯特·琼斯是一位1960 年代间谍游戏中的秘密特工对象,在苏联作为卧底特工捍卫生命、自由和追求幸福。他的对象是 SecretAgent 类的一个实例。
-
约翰斯特工有一个计划,可以帮助他躲避敌方特工。他添加了一个 AgentGreeting 方法,该方法以密码作为参数。如果他没有得到正确的密码,他只会透露他的化名,Dash Martin。
-
看起来这是保护特工身份的绝佳方法,对吧?只要调用它的特工对象没有正确的密码,特工的姓名就是安全的。
但是 RealName 字段真的受到保护吗?
只要敌人不知道任何 SecretAgent 对象的密码,特工的真实姓名就是安全的。对吧?但如果这些数据被保存在公共字段中,那就没有任何好处。
私有字段和方法只能从同一类的实例中访问
一个对象可以访问另一个对象的私有字段存储的数据的唯一方法是使用公共字段和方法来返回数据。敌对特工和盟友特工需要使用AgentGreeting方法,但是友好的间谍,也就是SecretAgent的实例,可以看到一切……因为任何类都可以 看到同一类的其他实例中的私有字段。
一个对象可以访问另一个不同类对象中的私有字段存储的数据的唯一方法是使用返回数据的公共方法。
因为有时候你希望你的类把信息隐藏起来不让程序的其他部分看到。
许多人第一次接触封装时可能会觉得有点奇怪,因为隐藏一个类的字段、属性或方法不让另一个类看到这个概念有些违反直觉。有一些非常好的理由让你考虑应该向程序的其他部分公开什么信息。
封装意味着一个类隐藏信息不让另一个类看到。它帮助你预防程序中的错误。** **# 为什么要封装?想象一个对象就像是一个黑盒子……
有时候你会听到程序员把一个对象称为“黑盒子”,这是一个很好的思考方式。当我们说某物是黑盒子时,我们的意思是我们可以看到它的行为,但我们无法知道它是如何运作的。
当你调用一个对象的方法时,你并不真正关心这个方法是如何工作的——至少现在不关心。你关心的是它能够接受你给出的输入并做正确的事情。
你可以包含更多的控件,比如显示盒子内部情况的窗口,以及能够操纵它内部的旋钮和开关。但如果它们对你的系统没有任何用处,那它们对你毫无好处,只会带来问题。
封装使你的类……
-
更易于使用
你已经知道类使用字段来跟踪它们的状态。许多类使用方法来更新这些字段——其他类永远不会调用的方法。有一个类有字段、方法和属性,其他类永远不会调用。如果你把这些成员设为私有,那么当你需要使用这个类时,它们就不会显示在 IntelliSense 窗口中。IDE 中减少杂乱将使你的类更易于使用。
-
更不容易出错
Owen 的程序中出现的 bug 就是因为应用程序直接访问一个方法而不是让类中的其他方法调用它。如果那个方法是私有的,我们本可以避免这个 bug。
-
灵活
许多时候,你会想回头去添加一些你之前写的程序的功能。如果你的类被良好封装,那么以后你就会准确知道如何使用它们并在其上添加功能。
关于封装类的几个想法。
-
你的类中所有东西都是公开的吗? 如果你的类除了公共字段和方法外什么都没有,你可能需要多花点时间考虑封装。
-
思考字段和方法被错误使用的可能性。 如果它们没有被正确设置或调用会出现什么问题?
-
哪些字段在设置时需要进行处理或计算? 这些是封装的首选对象。如果以后有人编写了一个方法来更改其中任何一个值,可能会对程序正在尝试完成的工作造成问题。
-
只有在需要时才将字段和方法设为公开。 如果你没有理由声明某些东西为公开,就别声明 —— 通过将程序中的所有字段设为公开,你可能会使事情变得非常混乱。但也不要仅仅将所有东西设为私有。花点时间前期考虑哪些字段确实需要公开,哪些不需要,可以为你节省后续大量的时间。
确实!区别在于良好封装的类设计方式可以防止 bug 并且更易于使用。
将一个封装良好的类变成封装不良的类很容易:执行搜索并替换,将每个private改为public。
关于private关键字有个有趣的地方:你通常可以对任何程序执行搜索并替换,它仍然能够编译并以完全相同的方式工作。这就是为什么当一些程序员刚开始接触封装时,会感到有点困难的原因之一。
当你回头看那些很久没碰的代码时,很容易忘记最初的使用意图。这就是封装可以极大简化你生活的地方!
到目前为止,这本书一直在讲述如何使程序做事情 —— 执行某些行为。封装有些不同。它不会改变你的程序行为方式。它更多地关注编程的“国际象棋”方面:通过在设计和构建类时隐藏某些信息,你为它们以后的互动设定了一种策略。策略越好,你的程序越灵活和可维护,也能避免更多的 bug。
注意
就像国际象棋一样,封装策略几乎是无穷无尽的!
如果你今天很好地封装了你的类,那么明天重用它们将变得更加容易。
使用封装来改进 SwordDamage 类。
我们刚刚讨论了一些关于封装类的好主意。让我们看看是否可以开始将这些想法应用到 SwordDamage 类中,以防止它在任何包含它的应用程序中被混淆、误用和滥用。
SwordDamage 类的每个成员都是公开的吗?
是的,确实如此。四个字段(Roll、MagicMultiplier、FlamingDamage 和 Damage)是公共的,还有三个方法(CalculateDamage、SetMagic 和 SetFlaming)。我们应该考虑一下封装的问题。
字段或方法是否被误用?
当然。在伤害计算器应用程序的第一个版本中,我们在应该只让 SetFlaming 方法调用它时调用了 CalculateDamage。即使我们试图修复它,也因为调用顺序错误而失败。
在设置字段后是否需要进行计算?
当然。在设置 Roll 字段后,我们真的希望实例立即计算伤害。
那么哪些字段和方法确实需要是公共的?
这是一个很棒的问题。花点时间思考答案。我们会在本章末尾解决这个问题。
将类的成员设置为私有可以防止其他类以意外的方式调用其公共方法或更新其公共字段导致的错误。
封装可以保护你的数据安全
我们已经看到了private关键字保护了类成员不被直接访问,这可以防止其他类以我们未预料的方式调用方法或更新字段—就像你在 Hi-Lo 游戏中的 GetPot 方法只给了私有的 pot 字段只读访问权限,而只有 Guess 或 Hint 方法可以修改它一样。下一个类的工作方式完全相同。
让我们在一个类中使用封装
让我们为一个彩弹枪类创建一个 PaintballGun,用于彩弹竞技场视频游戏。玩家可以随时拿起彩弹弹夹并重新装弹,因此我们希望这个类能够跟踪玩家拥有的总弹球数以及当前加载的弹球数。我们将添加一个方法来检查枪是否已空并需要重新装弹。我们还希望它能够跟踪弹夹的大小。任何时候玩家获得更多弹药,我们希望枪能自动重新装满弹夹,因此我们会提供一个设置弹球数量并调用 Reload 方法的方法来确保始终发生这种情况。
编写一个控制台应用程序来测试 PaintballGun 类
要做这件事!
让我们尝试一下我们的新 PaintballGun 类。创建一个新的控制台应用程序并将 PaintballGun 类添加到其中。这是 Main 方法—它使用一个循环调用类中的各种方法:
static void Main(string[] args)
{
PaintballGun gun = new PaintballGun();
while (true)
{
Console.WriteLine($"{gun.GetBalls()} balls, {gun.GetBallsLoaded()} loaded");
if (gun.IsEmpty()) Console.WriteLine("WARNING: You’re out of ammo");
Console.WriteLine("Space to shoot, r to reload, + to add ammo, q to quit");
char key = Console.ReadKey(true).KeyChar;
if (key == ’ ’) Console.WriteLine($"Shooting returned {gun.Shoot()}");
else if (key == ’r’) gun.Reload();
else if (key == ’+’) gun.SetBalls(gun.GetBalls() + PaintballGun.MAGAZINE_SIZE);
else if (key == ’q’) return;
}
}
注意
现在应该非常熟悉了,一个带有循环的控制台应用程序测试一个类的实例。确保你能阅读代码并理解其工作原理。
我们的类封装得很好,但是……
这个类运行良好,我们封装得也很好。balls 字段是受保护的:它不允许设置负数的球,并且与 ballsLoaded 字段保持同步。Reload 和 Shoot 方法的工作正常,看起来没有明显的显而易见的方法我们可能会意外地误用这个类。
但是请仔细看一下 Main 方法中的这一行:
else if (key == ’+’) gun.SetBalls(gun.GetBalls() + PaintballGun.MAGAZINE_SIZE);
坦率地说,这比一个字段要逊色。如果还有一个字段,我们可以使用+=运算符将其增加到弹匣大小。封装很棒,但我们不希望它让我们的类变得烦人或难以使用。
有没有办法保持 balls 字段受保护,但仍然能方便地使用+=?
属性使封装更容易
到目前为止,你已经学到了两种类成员,方法和字段。还有第三种类成员可以帮助你封装类:它们是属性。属性是一个类成员,在使用时看起来像字段,但在运行时像方法一样工作。
声明属性的方式与字段相同,具有类型和名称,但不是以分号结束,而是后跟花括号。在这些花括号内是属性访问器,或者返回或设置属性值的方法。有两种类型的访问器:
-
一个获取属性访问器,通常简称为get 访问器或getter,用于返回属性的值。它以
**get**关键字开头,后跟着一个花括号内的方法。该方法必须返回与属性声明中类型匹配的值。 -
一个设置属性访问器,通常简称为set 访问器或setter,用于设置属性的值。它以
**set**关键字开头,后跟着一个花括号内的方法。在方法内部,**value**关键字是一个只读变量,包含正在设置的值。
属性通常会获取或设置一个后备字段,这是我们通过属性来限制访问的私有字段。
用属性替换 GetBalls 和 SetBalls 方法
替换这个!
这是来自你的 PaintballGun 类的 GetBalls 和 SetBalls 方法:
public int GetBalls() { return balls; }
public void SetBalls(int numberOfBalls)
{
if (numberOfBalls > 0)
balls = numberOfBalls;
Reload();
}
让我们用一个属性替换它们。删除两个方法。然后添加这个 Balls 属性:
修改你的 Main 方法以使用 Balls 属性
现在你已经用一个名为 Balls 的单一属性替换了 GetBalls 和 SetBalls 方法,你的代码将无法再构建。你需要更新 Main 方法以使用 Balls 属性而不是旧的方法。
在这个 Console.WriteLine 语句中调用了 GetBalls 方法:
更新这个!
Console.WriteLine($"{gun.GetBalls()} balls, {gun.GetBallsLoaded()} loaded");
替换 **GetBalls()** 为 **Balls**——这样做后,这条语句将像以前一样工作。让我们来看看使用了 GetBalls 和 SetBalls 的另一个地方:
else if (key == ’+’) gun.SetBalls(gun.GetBalls() + PaintballGun.MAGAZINE_SIZE);
那是一行混乱且笨拙的代码。属性真的很有用,因为它们像方法一样工作,但你可以像使用字段一样使用它们。所以让我们像使用字段一样使用 Balls 属性——用使用+=运算符的这条语句替换那行代码:
这是更新后的 Main 方法:
static void Main(string[] args)
{
PaintballGun gun = new PaintballGun();
while (true)
{
Console.WriteLine($"{gun.Balls} balls, {gun.GetBallsLoaded()} loaded");
if (gun.IsEmpty()) Console.WriteLine("WARNING: You’re out of ammo");
Console.WriteLine("Space to shoot, r to reload, + to add ammo, q to quit");
char key = Console.ReadKey(true).KeyChar;
if (key == ’ ’) Console.WriteLine($"Shooting returned {gun.Shoot()}");
else if (key == ’r’) gun.Reload();
else if (key == ’+’) gun.Balls += PaintballGun.MAGAZINE_SIZE;
else if (key == ’q’) return;
}
}
调试你的 PaintballGun 类以理解属性的工作原理
使用调试器真正了解您的新 Ball 属性的工作方式:
-
在 get 访问器的大括号内部(
return balls;)放置一个断点。 -
在 set 访问器的第一行(
if (value > 0))上再放一个断点。 -
在 Main 方法的顶部放置一个断点并开始调试。逐个跳过每个语句。
-
当您跳过 Console.WriteLine 时,调试器将在 getter 中断点处停止。
-
继续逐步执行方法。当您执行+=语句时,调试器将在 setter 中断点处停止。为背景字段balls和
**value**关键字添加一个监视器。
自动实现的属性简化了您的代码
添加这个!
使用属性的一个非常常见的方法是创建一个背景字段并为其提供 get 和 set 访问器。让我们创建一个新的 BallsLoaded 属性,使用现有的 ballsLoaded 字段作为背景字段:
private int ballsLoaded = 0;
public int BallsLoaded {
get { return ballsLoaded; }
set { ballsLoaded = value; }
}
注意
此属性使用私有背景字段**。其 getter 返回字段中的值,其 setter 更新字段。**
现在您可以删除 GetBallsLoaded 方法并修改您的 Main 方法以使用属性:
Console.WriteLine($"{gun.Balls} balls, {gun.BallsLoaded} loaded");
再次运行您的程序。它应该仍然以完全相同的方式工作。
使用 prop 代码片段创建自动实现的属性
一个自动实现的属性——有时称为自动属性或自动化属性——是一个具有返回背景字段值的 getter 和更新它的 setter 的属性。换句话说,它的工作原理就像您刚刚创建的 BallsLoaded 属性一样。有一个重要的区别:当您创建自动属性时,不需要定义背景字段。相反,C#编译器会为您创建背景字段,更新它的唯一方法是使用 get 和 set 访问器。
Visual Studio 为创建自动属性提供了一个非常有用的工具:一个代码片段,或者说是一个小型、可重用的代码块,IDE 会自动插入它。让我们用它来创建一个名为 BallsLoaded 的自动属性。
-
删除 BallsLoaded 属性和背景字段。 删除您添加的 BallsLoaded 属性,因为我们将用自动实现的属性替换它。然后删除 ballsLoaded 背景字段(
private int ballsLoaded = 0;),因为每次创建自动属性时,C#编译器都会为您生成一个隐藏的背景字段。 -
告诉 IDE 启动 prop 代码片段。 将光标放在字段原来的位置,然后键入
**prop**并 按两次 Tab 键告诉 IDE 启动代码片段。它会将以下行添加到您的代码中:该片段是一个模板,允许您编辑其部分——prop 片段允许您编辑类型和属性名称。按一次 Tab 键切换到属性名称,然后将名称更改为
**BallsLoaded**并按 Enter 键以完成片段public int BallsLoaded { get; set; }注意
你不必为自动属性声明后备字段,因为 C# 编译器会自动创建它。
-
修复类的其余部分。 由于你移除了 ballsLoaded 字段,你的 PaintballGun 类不再编译。这有一个快速的修复方案——代码中 ballsLoaded 字段出现了五次(一次在 IsEmpty 方法中,两次在 Reload 和 Shoot 方法中)。将它们改为 BallsLoaded——现在你的程序又可以工作了。
使用私有 setter 来创建一个只读属性
让我们再来看一下你刚刚创建的自动属性:
public int BallsLoaded { get; set; }
这绝对是一个很好的替代方案,用一个带有 get 和 set 访问器的属性来更新后备字段。它比 ballsLoaded 字段和 GetBallsLoaded 方法更易读,并且代码更少。所以这是一种进步,对吧?
但是有一个问题:我们破坏了封装性。私有字段和公共方法的整个目的是使装载的球数只读。Main 方法很容易设置 BallsLoaded 属性。我们将字段设为私有,并创建了一个公共方法来获取值,以便只能从 PaintballGun 类内部进行修改。
将 BallsLoaded 的 setter 设为 private
幸运的是,我们有一个快速的方法来重新使 PaintballGun 类良好封装。当你使用属性时,你可以在 get 或 set 关键字前放置一个访问修饰符。
你可以创建一个只读属性,通过将其 set 访问器设为 **private**,使其不能被其他类设置。事实上,对于普通属性,你可以完全省略 set 访问器,但对于自动属性来说不行,否则你的代码将无法编译。
因此,让我们 将 set 访问器设为 private:
public int BallsLoaded { get; private set; }
注意
你可以通过将其 setter 设为 private 来将自动属性设为只读。
现在 BallsLoaded 字段是一个只读属性。它可以在任何地方读取,但只能从 PaintballGun 类内部更新。PaintballGun 类再次良好封装。
如果我们想要改变弹夹大小怎么办?
现在,PaintballGun 类使用一个 const 来表示弹夹大小:
public const int MAGAZINE_SIZE = 16;
替换这个!
如果我们希望游戏在实例化枪支时设置弹夹大小怎么办?让我们 用属性来替换它。
-
移除 MAGAZINE_SIZE 常量并用一个只读属性来替换它。
public int MagazineSize { get; private set; } -
修改 Reload 方法以使用新的属性。
if (balls > MagazineSize ) BallsLoaded = MagazineSize; -
修复在 Main 方法中添加弹药的那一行。
else if (key == ’+’) gun.Balls += gun.MagazineSize;
但是有一个问题……我们如何初始化 MagazineSize?
以前,MAGAZINE_SIZE 常量设置为 16。现在我们用自动属性替换它了,如果需要,我们可以像字段一样在声明的末尾添加赋值来初始化为 16:
public int MagazineSize { get; private set; } = 16;
但是如果我们希望游戏能够指定弹夹中的球数呢?也许大多数枪支都是生成时已经装载好的,但在某些快速袭击级别中,我们希望某些枪支生成时未装载,这样玩家需要在开火前进行装填。我们该怎么做?
使用带参数的构造函数来初始化属性
在本章前面你看到,可以用构造函数或对象首次实例化时调用的特殊方法来初始化对象。构造函数就像任何其他方法一样——这意味着它们可以有参数。我们将使用带参数的构造函数来初始化属性。
你刚才在问答环节中创建的构造函数看起来是这样的:**public ConstructorTest()**。那是一个无参数构造函数,所以就像任何没有参数的方法一样,声明以()结尾。现在让我们向 PaintballGun 类添加一个带参数的构造函数。以下是要添加的构造函数:
出问题了—一旦你添加了构造函数,IDE 就会告诉你 Main 方法出错了:
你认为我们需要做什么来修复这个错误?
当你使用“new”关键字时,需要指定参数。
当你添加了构造函数,IDE 告诉你 Main 方法在**new**语句(**PaintballGun gun = new PaintballGun()**)上有错误。以下是该错误的样子:
阅读错误文本——它告诉你出了什么问题。现在你的构造函数接受参数,因此需要参数。重新输入new语句,IDE 将精确告知你需要添加的内容:
到目前为止,你一直在使用new来创建类的实例。到目前为止,你所有的类都有无参数构造函数,因此你从未需要提供任何参数。
现在你有了一个带参数的构造函数,像任何带参数的方法一样,它要求你指定与这些参数匹配的类型的参数。
让我们修改你的 Main 方法,向 PaintballGun 构造函数传递参数。
修改这个!
-
添加你在 #types_and_references_getting_the_referen 为 Owen 的能力得分计算器编写的 ReadInt 方法。
你需要从某处获取构造函数的参数。你已经有一个非常好的方法提示用户输入 int 值,所以在这里重用它是有道理的。
-
添加代码以从控制台输入读取值。
现在你已经从 #types_and_references_getting_the_referen 添加了 ReadInt 方法,可以使用它来获取两个 int 值。将以下四行代码添加到你的 Main 方法顶部:
int numberOfBalls = ReadInt(20, "Number of balls"); int magazineSize = ReadInt(16, "Magazine size"); Console.Write($"Loaded [false]: "); bool.TryParse(Console.ReadLine(), out bool isLoaded);注意
如果 TryParse 无法解析该行,它将使用默认值留下 isLoaded,对于布尔值,默认值是 false。
-
更新新语句以添加参数。
现在你已经有了与构造函数参数类型匹配的变量值,可以更新
**new**语句将它们作为参数传递给构造函数:PaintballGun gun = new PaintballGun(
**numberOfBalls, magazineSize, isLoaded**); -
运行你的程序。
现在运行你的程序。它将提示你输入球的数量、弹夹大小以及枪是否装载。然后它将创建一个新的 PaintballGun 实例,将你的选择作为参数传递给它的构造函数。
一些关于方法和属性的有用事实
-
你的类中每个方法都有一个独特的签名。
方法的第一行包含访问修饰符、返回值、名称和参数,称为方法的 签名。属性也有签名——由访问修饰符、类型和名称组成。
-
你可以在对象初始化器中初始化属性。
你之前使用过对象初始化器:
Guy joe = new Guy() { Cash = 50, Name = "Joe" };你也可以在对象初始化器中指定属性。如果这样做,构造函数会首先运行,然后设置属性。并且你只能在对象初始化器中初始化公共字段和属性。
-
每个类都有一个构造函数,即使你没有自己添加。
CLR 需要一个构造函数来实例化一个对象——这是 .NET 工作幕后机制的一部分。所以如果你的类中没有添加构造函数,C# 编译器会自动为你添加一个无参构造函数。
-
你可以通过添加私有构造函数阻止其他类实例化这个类。
有时候你需要非常谨慎地控制对象的创建方式。一种方法是将构造函数设为私有——这样它只能从类的内部调用。花点时间试试:
class NoNew {
private NoNew() { Console.WriteLine("I’m alive!"); }
public static NoNew CreateInstance() { return new NoNew(); }
}
将 NoNew 类添加到控制台应用程序中。如果你尝试在 Main 方法中添加 new NoNew();,C# 编译器会给出错误("NoNew.NoNew() 由于其保护级别而不可访问"),但 NoNew.CreateInstance 方法可以正常创建一个新实例。
注意
现在是讨论视频游戏美学的好时机。如果你仔细想想,封装其实并没有给你提供一种你以前不能做到的方法。没有属性、构造函数和私有方法,你仍然可以写出相同的程序——但它们看起来会完全不同。因为编程并不全是让你的代码做些不同的事情。通常,它是让你的代码以更好的方式做同样的事情。当你思考美学时,请考虑这一点。它们不会改变游戏的行为方式,但会改变玩家对游戏的思考和感受。
注意
一些开发者在阅读有关美学的内容时确实持怀疑态度,因为他们认为只有游戏的机制才重要。这里有一个快速的思维实验来展示美学有多么重要。假设你有两款具有完全相同机制的游戏。它们之间只有一个微小的差异。在一个游戏中,你要踢开巨石来拯救一个村庄。在另一个游戏中,你要踢开小狗和小猫,因为你是一个可怕的人。即使这两款游戏在其他方面完全相同,它们也是两款非常不同的游戏。这就是美学的力量。
注意
前往 Visual Studio for Mac 学习指南 查看此练习的 macOS 版本。
第九章:继承:你的对象家族树
有时候,你确实想要像你的父母一样。 是否遇到过一个几乎完全符合你所需类的类?是否发现自己想着如果能稍微改变一些东西,那个类就完美了?通过继承,您可以扩展现有类,以便您的新类获得其所有行为——同时具备灵活性,可以对其行为进行更改,以便根据需要进行定制。继承是 C#语言中最强大的概念和技术之一:借助它,您可以避免重复的代码,更贴近模拟现实世界,并最终获得更易于维护和更少错误的应用程序。
这样做!
-
箭头的基础伤害是 1D6 点数乘以 0.35HP。
-
对于魔法箭,基础伤害乘以 2.5HP。
-
火焰箭增加额外的 1.25HP。
-
结果四舍五入向上取最近的整数 HP。
计算更多武器的伤害
更新后的剑伤害计算器在游戏之夜上大获成功!现在 Owen 想要所有武器的计算器。让我们从箭头的伤害计算开始,它使用 1d6 点数。让我们创建一个新的 ArrowDamage 类来使用 Owen 游戏大师笔记本中箭头公式计算箭头伤害。
ArrowDamage 中的大部分代码与 SwordDamage 类中的代码几乎相同。这是我们启动新应用程序所需做的事情。
-
创建一个新的.NET 控制台应用程序项目。 我们希望它能够同时进行剑和箭的计算,因此向项目中添加 SwordDamage 类。
-
创建一个 ArrowDamage 类,完全复制 SwordDamage 的代码。 创建一个名为 ArrowDamage 的新类,然后复制所有代码从 SwordDamage 并粘贴到新的 ArrowDamage 类。然后更改构造函数名称为 ArrowDamage 以便程序构建。
-
重构常量。 箭头伤害公式的基础和火焰伤害有不同的值,因此让我们将 BASE_DAMAGE 常量重命名为 BASE_MULTIPLIER,并更新常量值。我们认为这些常量使代码更易于阅读,因此也添加一个 MAGIC_MULTIPLIER 常量:
注意
ArrowDamage
掷
魔法
火焰
伤害
-
修改 CalculateDamage 方法。现在,您唯一需要做的就是更新 CalculateDamage 方法,以便它执行正确的计算:
使用 switch 语句匹配多个候选人
让我们更新我们的控制台应用程序,询问用户是要计算箭头还是剑的伤害。我们将请求一个键,并使用静态的Char.ToUpper 方法将其转换为大写:
我们可以使用if/else语句来做这个:
if (weaponKey == ’S’) { /* calculate sword damage */ }
else if (weaponKey == ’A’) { /* calculate arrow damage */ }
else return;
这就是我们到目前为止处理输入的方式。将一个变量与许多不同的值进行比较是一种非常常见的模式,你会一遍又一遍地看到它。这种情况非常普遍,以至于 C#有一种专门为这种情况设计的特殊语句。switch 语句让你以一种简洁易读的方式比较一个变量与许多值。下面是一个**switch** 语句,它与上面的if/else语句完全相同:
还有一件事...我们能计算匕首的伤害吗?还有狼牙棒?还有法杖?以及...
我们为剑和箭伤害制作了两个类。但是如果有三种其他武器呢?还是四种?还是 12 种?如果你必须维护该代码并稍后进行更改呢?如果你必须对五个或六个紧密相关的类进行完全相同的更改会怎样呢?如果你不断进行更改会怎样呢?错误肯定会发生——更新五个类而忘记更改第六个太容易了。
你是对的!在不同的类中重复相同的代码是低效且容易出错的。
幸运的是,C#给了我们一种更好的方式来构建彼此相关并共享行为的类:继承。
当你的类使用继承时,你只需要编写一次你的代码
你的 SwordDamage 和 ArrowDamage 类有很多相同的代码并非巧合。当你编写 C#程序时,通常会创建代表现实世界中事物的类,而这些事物通常彼此相关。你的类具有相似的代码,因为它们在现实世界中代表的事物——来自同一角色扮演游戏的两个相似计算——具有相似的行为。
当你有两个类是更一般的东西的具体情况时,你可以设置它们继承自相同的类。当你这样做时,它们中的每一个都是相同基类的子类。
通过从一般开始并变得更具体来构建你的类模型
当你 当你构建一组代表事物的类(特别是现实世界中的事物)时,你正在构建一个类模型。现实世界的事物通常处于从更一般到更具体的层次结构中,而你的程序也有自己的类层次结构,也是这样的。在你的类模型中,层次结构下面的类继承自上面的类。
你会如何设计一个动物园模拟器?
狮子、老虎和熊...哦,我的上帝!还有,河马、狼,偶尔也会有狗。你的工作是设计一个模拟动物园的应用程序。(不要太兴奋——我们不打算真的编写代码,只是设计代表动物的类。我们打赌你已经在考虑如何在 Unity 中完成这个任务了!)
我们已经得到了将在程序中出现的一些动物的列表,但不是所有动物。我们知道每个动物将由一个对象表示,并且这些对象将在模拟器中移动,执行每个特定动物编程的操作。
更重要的是,我们希望程序易于其他程序员维护,这意味着如果他们想要将新动物添加到模拟器中,他们需要能够稍后添加自己的类。
让我们从为我们所知道的动物建立一个类模型开始。
那么第一步是什么呢?在我们谈论具体的动物之前,我们需要找出它们共同拥有的普遍特征——所有动物都具备的抽象特征。然后我们可以将这些特征构建成一个基类,所有动物类都可以从中继承。
注意
术语“父类”、“超类”和“基类”通常可以互换使用。同样,“扩展”和“继承”这两个术语意思相同。“子类”也可以是一个动词。
注意
有些人使用术语“基类”来特指继承树顶部的类...但不是最顶部,因为每个类都继承自 Object 或 Object 的子类。
-
寻找动物共有的特征。
看看这六种动物。狮子、河马、老虎、猞猁、狼和狗有什么共同之处?它们如何相关联?您需要弄清它们的关系,以便能够提出包含它们所有的类模型。
-
构建一个基类,为动物提供它们共同拥有的一切。
基类中的字段、属性和方法将为所有继承它的动物提供一个共同的状态和行为。它们都是动物,因此将基类称为 Animal 是有道理的。
您已经知道我们应该避免重复代码:这很难维护,并且总是会带来后续的头疼。因此,让我们为 Animal 基类选择您只需编写一次的字段和方法,并且每个动物子类都可以继承它们。让我们从公共属性开始:
-
图片:指向图像文件的路径。
-
食物:这种动物吃的食物类型。目前只能有两个值:肉和草。
-
饥饿:表示动物饥饿水平的整数。它会随着动物吃饭的时间(和数量)而改变。
-
边界:指向一个存储了围栏高度、宽度和位置的类的引用。
-
位置:动物站立的 X 和 Y 坐标。
另外,Animal 类还有四种动物可以继承的方法:
-
发出声音:让动物发出声音的方法。
-
进食:当动物遇到它们喜欢的食物时的行为。
-
睡觉:让动物躺下来小睡的方法。
-
游荡:使动物在它们的圈舍里四处游荡的方法。
-
不同的动物有不同的行为
狮子吼,狗叫,至于我们所知,河马根本不会发出任何声音。所有从 Animal 继承的类都将具有 MakeNoise 方法,但每个方法的工作方式都不同,并且具有不同的代码。当子类更改继承的方法的行为时,我们称其覆盖该方法。
注意
仅因为一个属性或方法在 Animal 基类中,这并不意味着每个子类都必须以相同的方式使用它……或根本不使用!
-
弄清楚每种动物在 Animal 类做得不同或根本不做的事情。
每种动物都需要进食,但狗可能会小口吃肉,而河马则会大口吃草。那种行为的代码会是什么样子呢?狗和河马都会覆盖 Eat 方法。河马的方法会让它每次调用时消耗大约 20 磅的干草。另一方面,狗的 Eat 方法会减少动物园的食物供应一罐 12 盎司的狗粮。
注意
所以,当你有一个子类继承自一个基类时,它必须继承所有基类的行为……但是你可以修改它们在子类中的执行方式,所以它们不是完全相同的。这就是覆盖的意义。
-
寻找有很多共同点的类。
狗和狼看起来不是很相似吗?它们都是食肉动物,可以肯定的是,如果你观察它们的行为,它们有很多共同点。它们可能吃同样的食物,以相同的方式睡觉。那么山猫、老虎和狮子呢?事实证明,它们三者在它们的栖息地中的移动方式完全相同。可以肯定的是,你可以创建一个通用的 Feline 类,位于 Animal 和这三个猫科动物类之间,有助于防止它们之间的重复代码。
-
完成你的类层次结构。
现在你知道如何组织动物了,你可以添加 Feline 和 Canine 类。
当你创建类时,使得顶部有一个基类,下面有子类,而这些子类有它们自己的子类从它们那里继承时,你建立的就是一个类层次结构。这不仅仅是为了避免重复代码,尽管这显然是合理层次结构的一个很大好处。这种层次结构的一个好处是代码更容易理解和维护。当你查看动物园模拟器代码,看到在 Feline 类中定义了一个方法或属性,你就立刻知道这是所有猫共享的东西。你的层次结构成为了一张地图,帮助你在程序中找到方向。
每个子类都扩展了它的基类
您不受限于子类从其基类继承的方法...但您已经知道了这一点!毕竟,您一直在构建自己的类。当您修改一个类以使其继承成员——我们很快就会在 C#代码中看到!——您所做的就是获取您已经构建的类,并通过添加基类中的所有字段、属性和方法来扩展它。所以如果你想为 Dog 添加一个 Fetch 方法,那是很正常的。它不会继承或覆盖任何东西——只有 Dog 类会有该方法,并且它不会出现在 Wolf、Canine、Animal、Hippo 或任何其他类中。
C#总是调用最具体的方法
如果你告诉你的 Dog 对象漫游,只有一个方法可以调用——在 Animal 类中的方法。那么告诉你的 Dog 发出声音呢?调用哪个 MakeNoise?
嗯,弄清楚这个并不太难。Dog 类中的方法告诉你狗怎么发出声音。如果它在 Canine 类中,它告诉你所有犬类动物如何做到这一点。如果它在 Animal 类中,那么它是一个描述行为的行为,非常普遍,适用于每一种动物。所以如果你让你的 Dog 发出声音,首先 C#会查找 Dog 类中特别适用于狗的行为。如果 Dog 没有 MakeNoise 方法,它会检查 Canine,然后检查 Animal。
任何地方可以使用基类,你可以使用它的子类之一代替
继承中最有用的一件事情之一是扩展一个类。所以如果你的方法接受一个 Bird 对象,那么你可以传递一个 Woodpecker 的实例。该方法只知道它有一个鸟。它不知道它有什么种类的鸟,所以它只能要求它做所有鸟都能做的事情:它可以要求鸟走路和下蛋,但它不能要求它用嘴巴敲木头,因为只有啄木鸟有这种行为——而该方法不知道它具体是啄木鸟,只知道它是一个更一般的 Bird。它只能访问它知道的类中的字段、属性和其他方法。
使用冒号扩展基类
当你编写一个类时,你使用冒号 (:) 来让它继承自一个基类。这使得它成为一个子类,并给它所有的字段、属性和方法,来自于它继承的类。这个 Bird 类是 Vertebrate 的一个子类:
我们知道继承将基类的字段、属性和方法添加到子类中...
我们已经看到,当一个子类需要继承基类的所有方法、属性和字段时。
...但有些鸟不会飞!
如果你的基类有一个方法,你的子类需要修改,你会怎么做?
注意
糟糕——我们有了一个问题。企鹅是鸟类,而鸟类有一个名为 Fly 的方法,但我们不希望我们的企鹅飞行。如果企鹅试图飞行,显示一个警告将是很好的。
子类可以重写方法来更改或替换它继承的成员
有时你有一个子类,你希望从基类继承大多数行为,但不是全部。当你想要改变类继承的行为时,你可以重写方法或属性,用同名的新成员替换它们。
当你重写一个方法时,你的新方法需要与它覆盖的基类方法具有完全相同的签名。对于企鹅来说,这意味着它需要被称为 Fly,返回 void,并且没有参数。
注意
覆盖,动词。
to use authority to replace, reject, or cancel. 一旦她成为 Dynamco 的总裁,她可以override糟糕的管理决策。
-
在基类方法中添加 virtual 关键字。
子类只能在一个标有
virtual关键字的方法上重写它。在 Fly 方法声明中添加virtual告诉 C#,Bird 类的子类可以重写 Fly 方法。 -
在子类的同名方法上添加 override 关键字。
子类的方法将需要与基类完全相同的签名——相同的返回类型和参数——并且你将需要在声明中使用
**override**关键字。现在,当 Penguin 对象调用其 Fly 方法时,会打印警告。
一些成员只在子类中实现
到目前为止,我们所见过的所有代码都是从对象外部访问子类的成员——就像你刚刚编写的代码中 Main 方法调用 LayEggs 一样。继承真正发挥作用的地方是基类使用子类中实现的方法或属性。这里有一个例子。我们的动物园模拟器有自动售货机,让游客购买苏打水、糖果和饲料,以供宠物区的动物食用。
VendingMachine 是所有售货机的基类。它有分发物品的代码,但这些物品未定义。检查游客是否放入正确金额的方法始终返回 false。为什么?因为它们将在子类中实现。以下是在宠物区分发动物饲料的子类:
使用调试器来理解重写的工作原理
让我们使用调试器来看看当我们创建一个 AnimalFeedVendingMachine 的实例并要求它分发一些饲料时究竟会发生什么。创建一个新的 Console App 项目,然后按照以下步骤操作。
调试一下这个!
-
添加 Main 方法。以下是该方法的代码:
class Program { static void Main(string[] args) { VendingMachine vendingMachine = new AnimalFeedVendingMachine(); Console.WriteLine(vendingMachine.Dispense(2.00M)); } } -
添加 VendingMachine 和 AnimalFeedVendingMachine 类。 一旦它们被添加,尝试将这行代码添加到 Main 方法中:
vendingMachine.CheckAmount(1F);由于
protected关键字,您将收到编译器错误,因为只有 VendingMachine 类或其子类可以访问其受保护的方法。删除该行以使您的代码构建。
-
在 Main 方法的第一行设置一个断点。 运行程序。当它触发断点时,使用“逐行执行”(F10)逐行执行每行代码。以下是发生的情况:
-
它创建 AnimalFeedVendingMachine 的实例并调用其 Dispense 方法。
-
该方法仅在基类中定义,因此调用 VendingMachine.Dispense。
-
VendingMachine.Dispense 的第一行调用受保护的 CheckAmount 方法。
-
CheckAmount 在 AnimalFeedVendingMachine 子类中被覆盖,这导致 VendingMachine.Dispense 调用 AnimalFeedVendingMachine 中定义的 CheckAmount 方法。
-
这个版本的 CheckAmount 返回 true,因此 Dispense 返回 Item 属性。AnimalFeedVendingMachine 也覆盖了此属性,它返回“一把动物饲料。”
注意
您一直在使用 Visual Studio 调试器来查找代码中的错误。它也是学习和探索 C# 的好工具,就像在这个“Debug this!”中一样,您可以探索覆盖的工作方式。您能想到更多实验覆盖子类的方法吗?
-
有一个重要的原因需要使用 virtual 和 override!
virtual 和 override 关键字不仅仅是装饰。它们在程序运行中真正起到作用。virtual 关键字告诉 C# 成员(如方法、属性或字段)可以被扩展——没有它,你根本无法覆盖它。override 关键字告诉 C# 你正在扩展该成员。如果在子类中省略 override 关键字,你实际上是创建了一个完全无关的方法,只是恰巧有相同的名称。
听起来有点奇怪,对吧?但实际上是有道理的——真正理解 virtual 和 override 如何工作的最佳方法就是编写代码。因此,让我们构建一个真实的示例来进行实验。
当子类覆盖其基类中的方法时,总是调用在子类中定义的更具体版本,即使它是由基类中的方法调用的。
构建一个应用程序来探索 virtual 和 override
在 C#中,继承的一个非常重要的部分是扩展类成员。这就是子类可以从基类继承某些行为,但需要在需要的地方重写某些成员的地方——这就是virtual和override关键字的用途。virtual关键字确定哪些类成员可以被扩展。当你想扩展一个成员时,必须使用override关键字。让我们创建一些类来实验virtual和override。你将创建一个代表包含贵重珠宝的保险柜的类——为了一些狡猾的小偷来偷取珠宝。
-
创建一个新的控制台应用程序并添加 Safe 类。
这是 Safe 类的代码:
-
为拥有保险柜的人添加一个类。
保险柜的主人有些健忘,偶尔会忘记他们极为安全的保险柜密码。添加一个 SafeOwner 类来代表他们:
class SafeOwner { private string valuables = ""; public void ReceiveContents(string safeContents) { valuables = safeContents; Console.WriteLine($"Thank you for returning my {valuables}!"); } } -
添加一个能够挑锁的 Locksmith 类。
如果一个保险柜的主人雇佣专业的锁匠来打开他们的保险柜,他们期望那位锁匠安全无恙地归还里面的内容。这正是 Locksmith.OpenSafe 方法所做的事情:
注意
锁匠的 OpenSafe 方法挑锁、打开保险柜,然后调用 ReturnContents 将贵重物品安全地归还给主人。
class Locksmith { public void OpenSafe(Safe safe, SafeOwner owner) { safe.PickLock(this); string safeContents = safe.Open(Combination); ReturnContents(safeContents, owner); } public string Combination { private get; set; } protected void ReturnContents(string safeContents, SafeOwner owner) { owner.ReceiveContents(safeContents); } } -
添加一个想要窃取贵重物品的 JewelThief 类。
糟糕。看起来有个窃贼——更糟糕的是,他是一个高技能的锁匠,能够打开保险柜。添加这个扩展 Locksmith 的 JewelThief 类:
注意
JewelThief 扩展了 Locksmith 并继承了 OpenSafe 方法和 Combination 属性,但其 ReturnContents 方法窃取了珠宝而不是归还它们。聪明!
class JewelThief : Locksmith { private string stolenJewels; protected void ReturnContents(string safeContents, SafeOwner owner) { stolenJewels = safeContents; Console.WriteLine($"I’m stealing the jewels! I stole: {stolenJewels}"); } } -
添加一个主方法,让珠宝窃贼偷走宝石。
现在是大抢劫的时候了!在这个主方法中,珠宝窃贼潜入房屋,并使用其继承的 Locksmith.OpenSafe 方法来获取保险柜的密码。你认为它运行时会发生什么?
static void Main(string[] args) { SafeOwner owner = new SafeOwner(); Safe safe = new Safe(); JewelThief jewelThief = new JewelThief(); jewelThief.OpenSafe(safe, owner); Console.ReadKey(true); }
子类可以隐藏基类中的方法
现在运行 JewelThief 程序。你应该看到以下内容:
感谢您归还我的珍贵宝石!
你是否预期程序的输出会有所不同?也许是这样的:
I’m stealing the jewels! I stole: precious jewels
看起来 JewelThief 对象表现得就像 Locksmith 对象一样!那么发生了什么?
隐藏方法与重写方法
JewelThief 对象在调用其 ReturnContents 方法时表现得像 Locksmith 对象一样,是因为 JewelThief 类声明了其 ReturnContents 方法的方式。在你编译程序时得到的警告信息中有一个重要的提示:
由于 JewelThief 类继承自 Locksmith 并用自己的方法替换了 ReturnContents 方法,看起来像是 JewelThief 覆盖了 Locksmith 的 ReturnContents 方法—但实际上并非如此。你可能期望 JewelThief 覆盖该方法(我们稍后会讨论),但实际上 JewelThief 是在隐藏它。
注意
JewelThief
Locksmith. ReturnContents
JewelThief. ReturnContents
这有很大的不同。当子类隐藏一个方法时,它替换(技术上来说是重新声明)其基类中同名的方法。所以现在我们的子类实际上有两种不同的方法,它们共享一个名称:一个是从基类继承的,另一个是在该类中定义的全新方法。
当你隐藏方法时,请使用 new 关键字
仔细看看那个警告信息。当然,我们知道我们应该读取我们的警告,但有时我们不会...对吧?这一次,确实读一下它说了什么:**如果打算隐藏,请使用 new 关键字**。
因此,回到你的程序并添加 new 关键字:
new public void ReturnContents(Jewels safeContents, Owner owner)
一旦在 JewelThief 类的 ReturnContents 方法声明中加入了 new 关键字,警告信息就会消失—但是你的代码仍然不会按照你的期望行动!
它仍然调用了 Locksmith 类中定义的 ReturnContents 方法。为什么?因为 ReturnContents 方法正是由 Locksmith 类定义的一个方法—具体来说,是在 Locksmith.OpenSafe 内部调用,即使这是由 JewelThief 对象发起的。如果 JewelThief 只是隐藏了 Locksmith 的 ReturnContents 方法,那么它自己的 ReturnContents 方法将永远不会被调用。
如果子类只是添加了一个与基类中方法同名的方法,它只会隐藏基类方法而不是覆盖它。
使用不同的引用来调用隐藏方法
现在我们知道 JewelThief 只是隐藏了 ReturnContents 方法(与覆盖不同)。这导致它在像 Locksmith 对象一样被调用时表现得像一个 Locksmith 对象。JewelThief 继承了 Locksmith 的一个版本的 ReturnContents,并定义了第二个版本,这意味着有两个同名方法。这意味着你的类需要两种不同的调用方式。
有两种不同的调用 ReturnContents 方法的方式。如果你有一个 JewelThief 的实例,你可以使用 JewelThief 的引用变量来调用新的 ReturnContents 方法。如果你使用 Locksmith 的引用变量来调用它,它将调用隐藏的 Locksmith ReturnContents 方法。
这是如何工作的:
注意
// The JewelThief subclass hides a method in the Locksmith base class,
// so you can get different behavior from the same object based on the
// reference you use to call it!
// Declaring your JewelThief object as a Locksmith reference causes it to
// call the base class ReturnContents() method.
Locksmith calledAsLocksmith = new JewelThief();
calledAsLocksmith.ReturnContents(safeContents, owner);
// Declaring your JewelThief object as a JewelThief reference causes it to
// call JewelThief’s ReturnContents() method instead, because it hides
// the base class’s method of the same name.
JewelThief calledAsJewelThief = new JewelThief();
calledAsJewelThief.ReturnContents(safeContents, owner);
注意
你能想出如何使 JewelThief 覆盖 ReturnContents 方法而不仅仅是隐藏它吗?在阅读下一节之前,看看你能否做到!
使用 override 和 virtual 关键字来继承行为
我们真的希望我们的 JewelThief 类始终使用自己的 ReturnContents 方法,无论如何调用它。这通常是我们期望继承工作的方式:子类可以 重写 基类的方法,使得调用子类中的方法。首先在声明 ReturnContents 方法时使用 override 关键字:
但这还不是你需要做的一切。如果只是在类声明中添加 override 关键字,你会得到一个编译器错误:
再次,仔细观察并阅读错误信息。JewelThief 无法重写继承的成员 ReturnContents 因为它在 Locksmith 中没有标记 virtual, abstract 或 override。好的,这是一个我们可以通过快速更改来修复的错误。用 virtual 关键字标记 Locksmith 的 ReturnContents 方法:
现在重新运行你的程序。这是我们一直在寻找的输出结果:
没错。大多数情况下你会想要重写方法,但隐藏它们也是一种选择。
当你在一个扩展基类的子类中工作时,你更有可能使用重写而不是隐藏。所以当你看到关于隐藏方法的编译器警告时,要注意!确保你真的想隐藏该方法,而不是只是忘记使用 virtual 和 override 关键字。如果你总是正确使用 virtual、override 和 new 关键字,你就不会再遇到这样的问题了!
如果你想在基类中重写一个方法,总是使用
virtual关键字进行标记,并且每次你想在子类中重写方法时都使用override关键字。如果不这样做,你可能会意外隐藏方法。
子类可以使用 base 关键字访问其基类
即使在基类中重写了方法或属性,有时你仍然希望访问它。幸运的是,我们可以使用 base 关键字来访问基类的任何成员。
-
所有动物都吃东西,所以 Vertebrate 类有一个以 Food 对象为参数的 Eat 方法。
class Vertebrate { public virtual void Eat(Food morsel) { Swallow(morsel); Digest(); } } -
变色龙通过用舌头捕食来进食。因此,Chameleon 类继承自 Vertebrate 但重写了 Eat 方法。
-
我们可以使用
base关键字调用被重写的方法,而不是复制代码。现在我们可以访问旧版本和新版本的 Eat 方法。
当一个基类有构造函数时,你的子类需要调用它。
让我们回到你用 Bird、Pigeon、Ostrich 和 Egg 类编写的代码。我们想要添加一个 BrokenEgg 类扩展 Egg,并且让鸽子产下的蛋中有 25% 是破损的。在 Pigeon.LayEgg 中,用这个 if/else 语句替换掉原来的 new 语句,来创建一个新的 Egg 或 BrokenEgg 实例:
添加这个!
if (Bird.Randomizer.Next(4) == 0)
eggs[i] = new BrokenEgg(Bird.Randomizer.NextDouble() * 2 + 1, "white");
else
eggs[i] = new Egg(Bird.Randomizer.NextDouble() * 2 + 1, "white");
现在我们只需要一个扩展 Egg 的 BrokenEgg 类。让我们使它与 Egg 基类相同,只是它有一个构造函数,用来向控制台输出一条消息,告诉我们一个蛋是破碎的:
class BrokenEgg : Egg
{
public BrokenEgg()
{
Console.WriteLine("A bird laid a broken egg");
}
}
继续并进行这两个更改到你的 Egg 程序。
哎呀—看起来那些新代码行引起了编译器错误:
-
第一个错误出现在你创建一个新的 BrokenEgg 的那一行:CS1729 – ’BrokenEgg’没有包含一个接受 2 个参数的构造函数
-
第二个错误在 BrokenEgg 构造函数中:CS7036 – 没有提供与’Egg.Egg(double, string)’的必需形式参数’size’对应的参数
这是另一个很好的机会阅读这些错误并找出哪里出错了。第一个错误非常清楚:创建 BrokenEgg 实例的语句试图传递两个参数给构造函数,但 BrokenEgg 类只有一个无参数的构造函数。因此,请向构造函数添加参数:
public BrokenEgg(double size, string color)
这解决了第一个错误—现在 Main 方法编译得很好。其他错误呢?
让我们分析一下那个错误说了什么:
-
它在*Egg.Egg(double, string)*上抱怨—这是指 Egg 类的构造函数。
-
它说了些关于*参数’大小’*的东西,这是 Egg 类需要的,以便设置其 Size 属性。
-
但是没有提供参数,因为仅仅修改 BrokenEgg 构造函数以接受与参数相匹配是不够的。它还需要调用基类构造函数。
修改 BrokenEgg 类以使用base关键字调用基类构造函数:
public BrokenEgg(double size, string color): base(size, color)
现在你的代码编译了。尝试运行它—现在当鸽子下蛋时,大约四分之一的蛋在实例化时会打印关于破碎的消息(但之后,其余的输出与之前相同)。
注意
轻松回到旧项目。
你可以通过从文件菜单中选择最近的项目和解决方案(Windows)或最近的解决方案(Mac)来让 IDE 加载以前的项目。
子类和基类可以有不同的构造函数
当我们修改 BrokenEgg 以调用基类构造函数时,我们使其构造函数与 Egg 基类中的构造函数匹配。如果我们想让所有破碎的蛋的大小为零,并且颜色以“破碎”开头,修改实例化 BrokenEgg 的语句以只接受颜色参数:
if (Bird.Randomizer.Next(4) == 0)
eggs[i] = new BrokenEgg("white");
else
eggs[i] = new Egg(Bird.Randomizer.NextDouble() * 2 + 1, "white");
修改这个!
当你做出这些更改时,你会再次得到“必需形式参数”编译器错误—这是有道理的,因为 BrokenEgg 构造函数有两个参数,但你只传递了一个参数。
通过修改 BrokenEgg 构造函数以接受一个参数来修复你的代码:
现在再次运行你的程序。在鸽子构造函数的 for 循环中,BrokenEgg 构造函数仍然会将其消息写入控制台,但现在它还会导致 Egg 初始化其 Size 和 Color 字段。当 Main 方法中的 foreach 循环将 egg.Description 写入控制台时,它会为每个破碎的蛋写入这条消息:
Press P for pigeon, O for ostrich:
p
How many eggs should it lay? 7
A bird laid a broken egg
A bird laid a broken egg
A bird laid a broken egg
A 2.4cm white egg
A 0.0cm broken White egg
A 3.0cm white egg
A 1.4cm white egg
A 0.0cm broken White egg
A 0.0cm broken White egg
A 2.7cm white egg
注意
你知道鸽子通常只产下一到两个蛋吗?你会如何修改 Pigeon 类以考虑这一点?
是时候为 Owen 完成工作了
本章中你做的第一件事是修改为 Owen 构建的伤害计算器,以便为剑或箭进行伤害掷骰。它起作用了,你的 SwordDamage 和 ArrowDamage 类封装良好。但除了几行代码外,这两个类是相同的。你已经学会了在不同类中重复代码是低效和容易出错的,特别是如果你想继续扩展程序以添加更多不同种类武器的类。现在你有了一个解决这个问题的新工具:继承。所以现在是时候完成伤害计算器应用程序了。你将分两步完成:首先你会在纸上设计新的类模型,然后你会在代码中实现它。
在编写代码之前在纸上构建你的类模型有助于更好地理解问题,从而更有效地解决它。
当你的类尽可能少地重叠时,这是一个重要的设计原则,称为关注点分离。
如果你今天设计好你的类,以后修改起来会更容易。想象一下,如果你有十几个不同的类来计算不同武器的伤害。如果你想将 Magic 从布尔值更改为整数,这样你就可以拥有具有附魔奖励的武器(如+3 魔法权杖或+1 魔法匕首)会怎样?通过继承,你只需更改超类中的 Magic 属性。当然,你必须修改每个类的 CalculateDamage 方法,但这将是更少的工作量,而且不会有遗漏修改其中一个类的危险。(这在专业软件开发中经常发生!)
这是关注点分离的一个例子,因为每个类只包含解决程序解决的问题的一个特定部分的代码。只涉及剑的代码放在 SwordDamage 中,只涉及箭的代码放在 ArrowDamage 中,而只涉及它们之间共享的代码放在 WeaponDamage 中。
当你设计类时,关注点分离是你应该考虑的首要事项之一。如果一个类似乎在做两件不同的事情,试着看看是否可以将其拆分为两个类。
当你的类封装良好时,它会使你的代码更容易修改。
如果你认识一个专业的开发者,问问他们在过去一年中工作中最让他们烦恼的事情是什么。他们很可能会谈到不得不修改一个类,但为了做到这一点,他们必须更改另外两个类,这就需要三个其他的更改,而且很难跟踪所有的更改。在设计类时考虑封装是避免陷入这种情况的好方法。
使用调试器真正理解这些类如何工作。
本章中最重要的一个概念之一是,当你扩展一个类时,你可以重写它的方法,从而对它的行为做出相当大的改变。使用调试器真正理解它是如何工作的:
-
在调用 CalculateDamage 的 Roll、Magic 和 Flaming setter 行上设置断点。
-
在 WeaponDamage.CalculateDamage 中添加一个 Console.WriteLine 语句。这个语句永远不会被调用。
-
运行你的程序。当它命中任何断点时,使用Step Into进入 CalculateDamage 方法。它将进入子类 —— WeaponDamage.CalculateDamage 方法从未被调用。
做这个!
注意
我们即将讨论游戏设计的一个重要元素:动态性。实际上,它是一个如此重要的概念,以至于它超越了游戏设计。事实上,你几乎可以在任何类型的应用程序中找到动态性。
注意
一开始,动态性可能感觉像一个非常抽象的概念!在本章后面的时间里我们会更多地讨论它 —— 但现在,在你编写下一个项目时,请记住所有与动态性相关的内容。
视频游戏 是 严肃的生意。
视频游戏行业每年在全球范围内都在增长,并雇佣成千上万的人,这是一个有才华的游戏设计师可以进入的行业!有一个完整的独立游戏开发者生态系统,他们个人或小团队构建和销售游戏。
但你是对的 —— C# 是一门严肃的语言,它被用于各种严肃的非游戏应用程序。事实上,虽然 C#是游戏开发人员喜爱的语言之一,但它也是许多不同行业中的企业中最常见的语言之一。
所以,对于下一个项目,让我们通过构建一个严肃的商业应用程序来练习继承。
注意
前往 Visual Studio for Mac 学习指南,查看该项目的 Mac 版本。
构建一个蜂箱管理系统。
蜂王需要你的帮助! 她的蜂箱失控了,她需要一个程序来帮助管理她的蜜生产业务。她有一个满是工人的蜂箱,以及一大堆需要在蜂箱周围完成的工作,但不知何故,她失去了对哪只蜜蜂正在做什么以及她是否有足够的蜜能力来完成这些工作的控制。你需要建立一个蜂箱管理系统来帮助她跟踪她的工人。以下是它的工作原理。
-
皇后分配工作给她的工人们。
工人们可以做三种不同的工作。采蜜蜂飞出去将花蜜带回蜂箱。制蜜蜂把花蜜转化为蜜,蜜蜂吃以维持工作。最后,蜂王不断产卵,蛋护理蜜蜂确保它们成为工人。
-
当所有工作都分配完毕,就是工作的时候了。
皇后分配工作完成后,她会通过在她的蜂箱管理系统应用程序中点击“开始下一个班次”按钮,告诉蜜蜂们去工作,这将生成一个班次报告,告诉她分配到每个工作的蜜蜂数量以及蜜罐中花蜜和蜜的状态。
-
就像所有的业务领袖一样,皇后专注于增长。蜂箱业务是一项艰苦的工作,她用工蜂的总人数来衡量她的蜂箱。你能帮助皇后继续增加工蜂吗?她能在蜜用尽之前让蜂箱增长到多大?
蜂箱管理系统类模型
这里是你将为蜂箱管理系统构建的类。有一个带有基类和四个子类的继承模型,一个静态类来管理驱动蜂箱业务的蜜和花蜜,以及具有主窗口代码后台的MainWindow类。
注意
这个类模型只是一个开始。我们将提供更多细节,以便您编写代码。
仔细检查这个类模型。它包含了即将构建的应用程序的大量信息。接下来,我们将为您提供编写这些类所需的所有细节。
皇后类:她如何管理工蜂
当你按下按钮来开始下一个班次时,按钮的点击事件处理程序调用了皇后对象的WorkTheNextShift方法,该方法继承自蜜蜂基类。接下来会发生以下事情:
-
Bee.WorkTheNextShift调用HoneyVault.ConsumeHoney(HoneyConsumed),使用CostPerShift属性(每个子类使用不同值进行覆盖)来确定她需要多少蜜来工作。 -
Bee.WorkTheNextShift接着调用DoJob,皇后也对此进行了重写。 -
Queen.DoJob会向她的私人蛋字段添加 0.45 个蛋(使用一个名为EGGS_PER_SHIFT的常量)。EggCare蜜蜂将调用她的CareForEggs方法,这会减少蛋的数量并增加未分配工人的数量。 -
然后它使用 foreach 循环调用每个工作人员的 WorkTheNextShift 方法。
-
每个未分配的工作人员每个班次消耗蜂蜜。常量 HONEY_PER_UNASSIGNED_WORKER 跟踪每个工作人员每班次消耗的蜂蜜量。
-
最后,它调用它的 UpdateStatusReport 方法。
当您按下按钮分配工作给一只蜜蜂时,事件处理程序调用女王对象的 AssignBee 方法,该方法接受一个字符串作为工作名称(您将从 jobSelector.text 中获取该名称)。它使用switch语句来创建适当的 Bee 子类的新实例,并将其传递给 AddWorker,所以确保您在 Queen 类中添加 AddWorker 方法。
注意
Array 实例的长度在其生命周期中不能被更改。这就是为什么 C#有这个有用的静态Array.Resize 方法。它实际上不会调整数组的大小。相反,它会创建一个新数组,并将旧数组的内容复制到新数组中。请注意它如何使用 ref 关键字——我们将在本书的后面学到更多关于它的知识。**
/// <summary>
/// Expand the workers array by one slot and add a Bee reference.
/// </summary>
/// <param name="worker">Worker to add to the workers array.</param>
private void AddWorker(Bee worker)
{
if (unassignedWorkers >= 1)
{
unassignedWorkers--;
Array.Resize(ref workers, workers.Length + 1);
workers[workers.Length - 1] = worker;
}
}
注意
要将新工作人员添加到女王的工作人员数组中,您需要使用这个 AddWorker 方法。它调用 Array.Resize 来扩展数组,然后将新工作人员 Bee 添加到数组中。
UI:为主窗口添加 XAML
创建一个名为蜂巢管理系统的新 WPF 应用程序。主窗口采用一个网格布局,Title="蜂巢管理系统" Height="325" Width="625"。它使用了您在前几章中使用过的相同的 Label、StackPanel 和 Button 控件,并引入了两个新控件。作业分配下拉列表是一个ComboBox控件,允许用户从一个选项列表中进行选择。女王报告下的状态报告显示在一个TextBox控件中。
注意
不要被这个练习的长度所吓倒或者感到不知所措!只需将其分解为小步骤。一旦开始工作,您会发现这都是您学到的东西的复习。
好吧,你们猜对了。是的,这是一个游戏。
具体来说,这是一个资源管理游戏,或者一个重点放在收集、监控和使用资源上的游戏。如果您玩过像 SimCity 这样的模拟游戏或者像文明这样的策略游戏,您将会认识到资源管理是游戏的重要部分,您需要资源如金钱、金属、燃料、木材或水来运营一个城市或建立一个帝国。
资源管理游戏是实验机制、动态和美学**关系的绝佳方式:
-
机制很简单:玩家分配工作人员,然后启动下一个班次。然后每只蜜蜂要么添加花蜜,要么减少花蜜/增加蜂蜜,要么减少卵/增加工作人员。卵数增加,并显示报告。
-
美学更加复杂。玩家感受到当蜜或花蜜水平下降时的压力,并显示低水平警告时的兴奋。当他们做出选择并影响游戏时感到满足——然后再次感到压力,因为数字停止增加并开始再次减少。
-
游戏由动态驱动。没有任何代码使蜂蜜或花蜜稀缺——它们只是被蜜蜂和蛋消耗。
注:
真的花一分钟思考这个,因为它触及到动态本质。你看到如何在其他类型的程序中使用其中一些想法,而不仅仅是游戏吗?
反馈驱动你的蜂巢管理游戏。
让我们花几分钟真正了解这款游戏是如何运作的。花蜜转化比对你的游戏有很大影响。如果你改变常数,它可能会对游戏玩法产生很大影响。如果只需少量蜂蜜即可将蛋转化为工蜂,游戏会变得非常容易。如果需要很多,游戏会变得更加困难。但是如果你查看类,你不会找到困难设置。在任何类上都没有困难字段。你的女王不会获得特殊的力量来帮助游戏变得更容易,或者艰难的敌人或 boss 战来增加难度。换句话说,没有明确创建蛋或工蜂数量与游戏难度之间关系的代码。那么究竟发生了什么?
你可能之前玩过反馈。在你的手机和电脑之间启动一个视频通话。将手机靠近电脑扬声器,你会听到嘈杂的回声。将相机对准电脑屏幕,你会看到屏幕的图像在图像的屏幕中,如果你倾斜手机,它将变成一个疯狂的图案。这就是反馈:你正在将实时视频或音频输出反馈到输入中。视频通话应用程序的代码中没有专门生成那些疯狂声音或图像的部分。相反,它们是从反馈中出现的。
工人和蜂蜜处于反馈循环中。
你的蜂巢管理游戏是基于一系列反馈循环:许多小循环,在游戏的各个部分相互作用。例如,蜂蜜生产商向金库中添加蜂蜜,蜂蜜被蜜蜂消耗,蜜蜂再制造更多的蜂蜜。
这只是一个反馈循环。在你的游戏中有许多不同的反馈循环,它们使整个游戏变得更加复杂、更加有趣,希望是更加有趣的。
蜂巢管理系统是一种回合制的……现在让我们将其转换为实时。
回合制游戏是将游戏流程分解为若干部分的游戏——在蜂巢管理系统的情况下,分解为轮次。只有当您点击按钮时,下一个轮次才会开始,因此您可以随意分配工人。我们可以使用一个DispatcherTimer(就像您在#start_building_with_chash_build_somethin 中使用的那个)将其转换为实时游戏,而且只需几行代码即可实现。
-
在您的 MainWindow.xaml.cs 文件顶部添加一个 using 行。
我们将使用一个
DispatcherTimer来强制游戏每隔一秒半进行下一轮操作。DispatcherTimer位于System.Windows.Threading命名空间中,因此您需要将以下using行添加到您的MainWindow.xaml.cs文件顶部:using System.Windows.Threading;注意
您在#start_building_with_chash_build_somethin 中使用了一个
DispatcherTimer为您的动物匹配游戏添加了一个计时器。这段代码与您在#start_building_with_chash_build_somethin 中使用的代码非常相似。花几分钟回顾一下那个项目,以便提醒自己DispatcherTimer的工作原理。 -
添加一个私有字段,引用一个
DispatcherTimer。现在您需要创建一个新的
DispatcherTimer。将其放在 MainWindow 类的顶部作为一个私有字段:private DispatcherTimer timer = new DispatcherTimer(); -
使计时器调用“工作轮次”按钮的 Click 事件处理方法。
我们希望计时器能够推动游戏向前发展,因此如果玩家不足够快地点击按钮,它将自动触发下一轮操作。首先添加以下代码:
现在运行游戏。每隔 1.5 秒钟就会开始一个新的轮次,无论您是否点击按钮。这对机制来说是一个小变化,但它显著改变了游戏的动态,从而在美学上产生了巨大差异。由您决定游戏是作为回合制还是实时模拟更好。
是的!计时器改变了机制,从而改变了动态,进而影响了美学。
让我们花一分钟思考一下这个反馈循环。机制的变化(每隔 1.5 秒自动点击“进行下一轮操作”按钮的计时器)创造了一个全新的动态:玩家必须在一定时间内做出决策,否则游戏会替他们做出决策。这增加了压力,对某些玩家来说提供了令人满意的肾上腺素冲击,但对其他玩家来说只是造成了压力——美学发生了变化,对一些人来说使游戏更有趣,但对其他人来说则没那么有趣。
但是您只向游戏中添加了半打行代码,而且其中没有包括“做出这个决定,否则”的逻辑。这是计时器和按钮如何协同工作所衍生出的行为的一个例子。
注意
这里也有一个反馈环路。随着玩家感受到更大的压力,他们会做出更糟糕的决策,改变游戏……美学反过来影响了机制。
反馈环路和新兴行为是重要的编程概念。
我们设计这个项目是为了让你练习继承,同时让你探索和实验新兴行为。这种行为不仅来自于你的对象单独做什么,还来自于对象之间如何相互作用。游戏中的常数(如花蜜转换比)是这种新兴互动的重要组成部分。当我们创建这个练习时,我们从一些初始值开始设置这些常数,然后通过微小的调整来调整它们,直到我们得到一个不完全处于平衡状态的系统——这是一种一切都完美平衡的状态——所以玩家需要继续做出决策,以尽可能地延长游戏时间。这一切都受到蛋、工人、花蜜、蜂蜜和女王之间的反馈环路的驱动。
注意
尝试用这些反馈环路进行实验。例如,每班增加更多的蛋或者用更多的蜜开始蜂巢,游戏会变得更容易。继续吧,试试看!你可以通过对几个常数进行小的修改来改变整个游戏的感觉。
有些类永远不应该被实例化
还记得我们的动物园模拟器类层次结构吗?你肯定会实例化一堆河马、狗和狮子。那么 Canine 和 Feline 类呢?动物类呢?事实证明,有些类根本不需要被实例化……实际上,如果它们被实例化了,就毫无意义。
听起来奇怪吗?事实上,这种情况经常发生——事实上,你在本章早些时候创建了几个类,它们永远不应该被实例化。
class Bird
{
public static Random Randomizer = new Random();
public virtual Egg[] LayEggs(int numberOfEggs)
{
Console.Error.WriteLine
("Bird.LayEggs should never get called");
return new Egg[0];
}
}
class WeaponDamage
{
/* ... code for the properties ... */ }
protected virtual void CalculateDamage()
{
/* the subclass overrides this */
}
public WeaponDamage(int startingRoll)
{
roll = startingRoll;
CalculateDamage();
}
}
注意
你的 Bird 类很小——它只有一个共享的 Random 实例和一个 LayEggs 方法,只存在于子类可以覆盖它的情况下。你的 WeaponDamage 类要大得多——它有很多属性。它还有一个 CalculateDamage 类,供子类覆盖,它从它的 WeaponDamage 方法调用。
class Bee
{
public virtual float CostPerShift { get; }
public string Job { get; private set; }
public Bee(string job)
{
Job = job;
}
public void WorkTheNextShift()
{
if (HoneyVault.ConsumeHoney(CostPerShift))
{
DoJob();
}
}
protected virtual void DoJob() { /* the subclass overrides this */ }
}
注意
Bee 类有一个 WorkTheNextShift 方法,消耗蜜然后做蜜蜂应该做的工作——因此它期望子类覆盖 DoJob 方法来实际执行工作。
抽象类是一个有意不完整的类
很常见的情况是有一个类具有“占位符”成员,期望子类来实现。它可以位于层次结构的顶部(例如 Bee、WeaponDamage 或 Bird 类)或中间(例如动物园模拟器类模型中的 Feline 或 Canine)。它们利用 C#总是调用最具体方法的特性,例如 WeaponDamage 调用仅在 SwordDamage 或 ArrowDamage 中实现的 CalculateDamage 方法,或者 Bee.WorkTheNextShift 依赖于子类来实现 DoJob 方法。
C#专门为此构建了一个工具:抽象类。这是一个故意不完整的类,其中的空类成员作为子类实现的占位符。要使一个类成为抽象类,需在类声明中添加abstract关键字。以下是关于抽象类的重要内容。
-
抽象类的工作方式与普通类完全相同。
定义抽象类与定义普通类几乎完全相同。它有字段和方法,可以像普通类一样继承其他类。几乎没有什么新的东西需要学习。
-
抽象类可以具有不完整的“占位符”成员。
抽象类可以包含需要由继承类实现的属性和方法声明。具有声明但没有语句或方法体的方法称为抽象方法,而仅声明其访问器而不定义其方法体的属性称为抽象属性。扩展抽象类的子类必须实现所有抽象方法和属性,除非它们本身也是抽象的。
-
只有抽象类可以拥有抽象成员。
如果你在一个类中放置了抽象方法或属性,则必须将该类标记为抽象,否则你的代码将无法编译。稍后你将了解如何将类标记为抽象。
-
抽象类不能被实例化。
抽象的反义词是具体。具体方法是有方法体的方法,到目前为止你所使用的所有类都是具体类。抽象类和具体类最大的不同之处在于你不能使用
new关键字创建抽象类的实例。如果尝试这样做,C#在编译代码时会报错。现在试试吧!创建一个新的控制台应用程序,添加一个空的抽象类,并尝试实例化它:
因为你希望提供部分代码,但仍然要求子类填写其余的代码。
有时候当你创建不应该被实例化的对象时会发生糟糕的事情。类图顶部的类通常有一些字段,它期望其子类设置。例如,Animal 类可能有一个依赖于名为 HasTail 或 Vertebrate 的布尔值的计算,但它本身无法设置这些值。以下是一个创建该类时出现问题的快速示例...
做这个!
class PlanetMission
{
protected float fuelPerKm;
protected long kmPerHour;
protected long kmToPlanet;
public string MissionInfo()
{
long fuel = (long)(kmToPlanet * fuelPerKm);
long time = kmToPlanet / kmPerHour;
return $"We’ll burn {fuel} units of fuel in {time} hours";
}
}
class Mars : PlanetMission
{
public Mars()
{
kmToPlanet = 92000000;
fuelPerKm = 1.73f;
kmPerHour = 37000;
}
}
class Venus : PlanetMission
{
public Venus()
{
kmToPlanet = 41000000;
fuelPerKm = 2.11f;
kmPerHour = 29500;
}
}
class Program
{
public static void Main(string[] args)
{
Console.WriteLine(new Venus().MissionInfo());
Console.WriteLine(new Mars().MissionInfo());
Console.WriteLine(new PlanetMission().MissionInfo());
}
}
在运行此代码之前,你能猜到它会打印什么到控制台吗?
正如我们所说,有些类永远不应该被实例化。
尝试运行 PlanetMission 控制台应用程序。它表现如你所期望的吗?它在控制台打印了两行:
We’ll burn 86509992 units of fuel in 1389 hours
We’ll burn 159160000 units of fuel in 2486 hours
但然后它抛出了一个异常。
所有问题都始于你创建 PlanetMission 类的实例。它的 FuelNeeded 方法期望子类设置字段。当它们没有设置时,它们会得到它们的默认值——零。当 C#试图将一个数字除以零时……
解决方案:使用一个抽象类
当你将一个类标记为abstract时,C#不会让你编写代码来实例化它。那么这如何解决这个问题呢?就像古话说的那样——预防胜于治疗。在 PlanetMission 类声明中添加abstract关键字:
一旦你做出这个改变,编译器就会给你一个错误:
你的代码根本编译不了——没有编译的代码就没有异常。这与你在#封装 _ 保持您的私人信息中使用private关键字的方式非常相似,或者在本章早些时候使用virtual和override关键字一样。使一些成员私有并不会改变行为。它只是防止你的代码在违反封装性时编译。abstract关键字的工作方式也相同:你永远不会因为实例化抽象类而得到异常,因为 C#编译器根本不允许你首先实例化它。
当你在类声明中添加 abstract 关键字时,每当你试图创建该类的实例时,编译器都会给出一个错误。
一个抽象方法没有方法体
你构建的 Bird 类从来就不是用来实例化的。这就是为什么如果程序试图实例化它并调用其 LayEggs 方法,它会使用 Console.Error 输出错误消息:
class Bird
{
public static Random Randomizer = new Random();
public virtual Egg[] LayEggs(int numberOfEggs)
{
Console.Error.WriteLine
("Bird.LayEggs should never get called");
return new Egg[0];
}
}
由于我们根本不希望实例化 Bird 类,让我们在其声明中添加abstract关键字。但这还不够——不仅应该禁止实例化这个类,而且我们希望要求每个扩展 Bird 的子类必须覆盖LayEggs 方法。
这正是当你在类成员中添加abstract关键字时发生的情况。一个抽象方法只有一个类声明,但是没有方法体,必须由任何扩展抽象类的子类实现。方法的方法体是在声明之后的大括号之间的代码,这是抽象方法不能拥有的。
回到你之前的鸟项目,用这个抽象类替换 Bird 类:
abstract class Bird
{
public static Random Randomizer = new Random();
public abstract Egg[] LayEggs(int numberOfEggs);
}
你的程序仍然像之前一样运行!但尝试在 Main 方法中添加这一行:
Bird abstractBird = new Bird();
你会得到一个编译器错误:
尝试给 LayEggs 方法添加一个方法体:
public abstract Egg[] LayEggs(int numberOfEggs)
{
return new Egg[0];
}
你会得到一个不同的编译器错误:
如果一个抽象类有虚拟成员,每个子类必须覆盖所有这些成员。
抽象属性的工作方式就像抽象方法一样
让我们回到我们之前示例中的 Bee 类。我们已经知道我们不希望这个类可以被实例化,所以让我们修改它将其变成一个抽象类。我们可以通过在类声明中添加abstract修饰符,并将 DoJob 方法改为没有方法体的抽象方法来实现:
abstract class Bee
{
/* the rest of the class stays the same */
protected abstract void DoJob();
}
但还有另一个虚拟成员——它不是一个方法。它是 CostPerShift 属性,Bee.WorkTheNextShift 方法调用它来计算蜜蜂本班次需要多少蜂蜜:
public virtual float CostPerShift { get; }
我们在#encapsulation_keep_your_privateshellippr 中学到,属性实际上只是被称为字段的方法。使用abstract 关键字创建抽象属性,就像创建方法一样:
public abstract float CostPerShift { get; }
抽象属性可以有获取器、设置器或两者。抽象属性中的设置器和获取器不能有方法体。它们的声明看起来像自动属性——但它们不是,因为它们根本没有实现。与抽象方法一样,抽象属性是必须由任何扩展它们的子类实现的属性的占位符。
这里是完整的抽象 Bee 类,包括抽象方法和属性:
abstract class Bee
{
public abstract float CostPerShift { get; }
public string Job { get; private set; }
public Bee(string job)
{
Job = job;
}
public void WorkTheNextShift()
{
if (HoneyVault.ConsumeHoney(CostPerShift))
{
DoJob();
}
}
protected abstract void DoJob();
}
替换这里!
在你的蜂箱管理系统应用中用这个新的抽象类替换 Bee 类。它仍然可以工作!但是现在,如果你尝试用new Bee()来实例化 Bee 类,你会得到一个编译器错误。更重要的是,如果你扩展了 Bee 却忘记了实现 CostPerShift,你会得到一个错误。
这是你第一次读你为之前的练习编写的代码吗?
回顾之前编写的代码可能会感觉有点奇怪,但这实际上是许多开发者的做法,你应该逐渐习惯这种习惯。你发现了第二次写代码时会有不同的想法吗?有没有改进或修改的地方?花时间重构你的代码总是一个好主意。这就是你在这个练习中做的:改变代码结构而不修改其行为。这就是重构。
听起来不错!但有个问题。
如果 C#允许你从多个基类继承,那将会引发一系列问题。当一个语言允许一个子类从两个基类继承时,这被称为多重继承。如果 C#支持多重继承,你将会陷入一个被称为“大胖子类难题”的困境中…
致命的死亡菱形
注意
那就是它的真名!有些开发者只是称之为“菱形问题”。
在一个疯狂的世界里,假设 C# 允许多重继承。让我们玩一场“假设”游戏,看看会发生什么。
如果…… 你有一个名为 Appliance 的类,其中有一个名为 TurnOn 的抽象方法呢?
如果…… 它有两个子类:Oven 有一个温度属性,Toaster 有一个面包片数属性呢?
如果…… 你想创建一个继承了温度和面包片数的 ToasterOven 类呢?
如果…… C# 支持多重继承,那你就可以这么做?
那么就只剩下一个问题了……
ToasterOven 继承了哪个 TurnOn?
它会从 Oven 那里得到版本吗?还是从 Toaster 那里得到版本呢?
没有办法知道!
这就是为什么 C# 不允许多重继承的原因。
第十章:Unity 实验室 #3:GameObject 实例
C#是一种面向对象的语言,因为这些 Head First C# Unity 实验室都是关于练习编写 C#代码,所以这些实验室将专注于创建对象。
自从你了解了 **new** 关键字以来,你一直在 C#中创建对象(详见 #objectshellipget_orientedexclamation_mar)。在这个 Unity 实验室中,你将创建 Unity GameObject 的实例并在一个完整的工作游戏中使用它们。这是编写 Unity 游戏的绝佳起点。
接下来两个 Unity 实验室的目标是创建一个简单的游戏,使用你上次实验中熟悉的台球。在这个实验中,你将建立在你对 C#对象和实例的理解上,开始构建游戏。你将使用一个预制件——Unity 用来创建 GameObject 实例的工具,来创建大量的 GameObject 实例,并使用脚本使你的 GameObject 在游戏的 3D 空间中飞来飞去。
让我们在 Unity 中构建一个游戏吧!
Unity 的核心是构建游戏。因此,在接下来的两个 Unity 实验室中,你将运用你在 C#中学到的知识来构建一个简单的游戏。以下是你将要创建的游戏:
注意
当你启动游戏时,场景会慢慢填满台球。玩家需要不断点击它们使它们消失。一旦场景中有 15 个球,游戏就结束了。
让我们开始吧。首先要做的是设置你的 Unity 项目。这一次我们将文件稍微整理一下,所以你会为你的材料和脚本创建单独的文件夹,并且为预制件再创建一个文件夹(稍后在实验中你会了解):
-
在开始之前,请关闭任何已打开的 Unity 项目。同时关闭 Visual Studio——Unity 会为你打开它。
-
创建一个新的 Unity 项目,使用 3D 模板,就像你之前在 Unity 实验室中做的那样。给它一个名称,以帮助你记住它属于哪些实验(“Unity Labs 3 和 4”)。
-
选择宽屏布局以便你的屏幕与截图匹配。
-
在 Assets 文件夹下创建一个名为 Materials 的文件夹。在项目窗口中右键点击 Assets 文件夹,选择 Create >> Folder。将其命名为 Materials。
-
在 Assets 文件夹下创建另一个名为 Scripts 的文件夹。
-
在 Assets 文件夹下创建另一个名为 Prefabs 的文件夹。
在 Materials 文件夹内创建一个新的材质。
双击你的新 Materials 文件夹来打开它。你将在这里创建一个新的材质。
前往github.com/head-first-csharp/fourth-edition,并点击球材质链接(就像你在第一个 Unity 实验中所做的那样),将1 Ball Texture.png下载到计算机上的一个文件夹中,然后将其拖到你的材质文件夹中——就像你在第一个 Unity 实验中下载的文件一样,但这次将其拖到你刚刚创建的材质文件夹中,而不是父级 Assets 文件夹中。
现在你可以创建新的材质了。在项目窗口中右键点击材质文件夹,选择创建 >> 材质。将你的新材质命名为1 Ball。你会在项目窗口的材质文件夹中看到它。
注意
在之前的 Unity 实验中,我们使用了一个纹理,或者说 Unity 可以包裹在游戏对象周围的位图图像文件。当你将纹理拖放到球体上时,Unity 会自动创建一个材质,这就是 Unity 用来跟踪关于如何渲染游戏对象的信息的方式,可以引用到一个纹理。这次你要手动创建材质。和上次一样,你可能需要在 GitHub 页面上点击下载按钮来下载纹理 PNG 文件。
确保在材质窗口中选择了 1 Ball 材质,这样它就会显示在检视器中。点击1 Ball Texture文件,并将其拖放到 Albedo 标签左侧的框中。
现在你应该在检视器中 Albedo 标签左侧的框中看到一个小小的 1 Ball 纹理图像。
现在当它包裹在一个球体周围时,你的材质看起来像一个台球。
在场景中的随机位置生成一个台球。
创建一个名为 OneBallBehaviour 的新球体游戏对象:
-
从游戏对象菜单中选择 3D 对象 >> 球体来创建一个球体。
-
将你的新1 Ball 材质拖到球上,使其看起来像一个台球。
-
接下来,右键点击你在项目窗口中创建的脚本文件夹,然后创建一个新的 C#脚本,命名为 OneBallBehaviour。
-
将脚本拖放到层次视图中的球体上。选择球体,并确保检视器窗口中显示了名为“One Ball Behaviour”的脚本组件。
双击你的新脚本以在 Visual Studio 中进行编辑。添加与 BallBehaviour 中使用的相同代码,然后注释掉 Update 方法中的 Debug.DrawRay 行。
现在你的 OneBallBehaviour 脚本应该是这样的:
现在修改 Start 方法,在创建时将球移动到一个随机位置。你可以通过设置transform.position来实现这一点,它可以改变场景中游戏对象的位置。下面是将球放置在随机点的代码—将其添加到你的 OneBallBehaviour 脚本的 Start 方法中:
使用 Unity 中的播放按钮来运行你的游戏。 现在应该会有一个球围绕 Y 轴在一个随机点上旋转。停止并重新开始游戏几次。每次球应该在场景中的不同点生成。
使用调试器理解 Random.value
你已经多次在 .NET System 命名空间中使用了 Random 类。你用它在动物匹配游戏中散布动物 #start_building_with_chash_build_somethin,以及在随机选择卡牌 #objectshellipget_orientedexclamation_mar。这个 Random 类与以前的不同—试着在 Visual Studio 中将鼠标悬停在 Random 关键字上。
你可以从代码中看出,这个新 Random 类与之前使用的不同。之前你调用 Random.Next 来获取一个随机值,而且那个值是一个整数。这段新代码使用了 Random.value,但那不是一个方法—实际上它是一个属性。
使用 Visual Studio 调试器来查看这个新 Random 类给出的各种值。点击“Attach to Unity”按钮 (在 Windows 上),
(在 macOS 上),将 Visual Studio 与 Unity 连接起来。然后在你添加到 Start 方法中的代码行上添加一个断点。
注意
Unity 可能会提示你启用调试,就像在上一个 Unity 实验室中一样。
现在返回 Unity 并开始你的游戏。一旦按下播放按钮,游戏应该会中断。将鼠标悬停在 Random.value 上—确保它悬停在 value 上。Visual Studio 将在工具提示中显示它的值:
保持 Visual Studio 连接到 Unity,然后回到 Unity 编辑器并停止你的游戏(在 Unity 编辑器中,不是在 Visual Studio 中)。再次启动你的游戏。多试几次。每次都会得到一个不同的随机值。这就是 UnityEngine.Random 的工作原理:每次访问其 value 属性时,它会给你一个新的介于 0 和 1 之间的随机值。
按下继续 () 来恢复你的游戏。它应该会继续运行—断点只在 Start 方法中,每个 GameObject 实例仅调用一次,因此不会再次断开。然后返回 Unity 并停止游戏。
注意
当 Visual Studio 附加到 Unity 时,你无法在其中编辑脚本,所以点击方形停止调试按钮来将 Visual Studio 调试器与 Unity 分离。
将你的 GameObject 转换为一个 prefab
在 Unity 中,一个 prefab 是一个可以在场景中实例化的 GameObject。在过去的几章中,你一直在处理对象实例,并通过实例化类来创建对象。Unity 允许你利用对象和实例,这样你可以构建重复使用相同 GameObject 的游戏。让我们把你的一个球 GameObject 变成一个 prefab。
GameObjects 有名称.. 将你的 GameObject 的名称更改为OneBall。首先选择你的球,通过在层次视图窗口或场景中单击它。然后使用检视器窗口将其名称更改为 OneBall。
现在你可以将你的 GameObject 转换成预制件。从层次视图窗口将 OneBall 拖放到预制文件夹中。
OneBall 现在应该出现在你的预制文件夹中。注意现在在层次视图窗口中,OneBall 变成了蓝色。这表示它现在是一个预制件——Unity 将其变为蓝色以告诉你,在你的层次结构中有一个预制件的实例。对于某些游戏来说这很好,但对于这个游戏来说,我们希望所有的球都是由脚本创建的实例。
在层次视图窗口中右键单击 OneBall并从场景中删除 OneBall GameObject。现在你只能在项目窗口中看到它,而不是在层次窗口或场景中看到。
注意
你一直在保存场景吗?尽早保存,经常保存!
创建一个控制游戏的脚本
游戏需要一种方法来将球添加到场景中(并最终跟踪分数,以及游戏是否结束)。
在项目窗口的 Scripts 文件夹上右键单击创建一个名为 GameController 的新脚本。你的新脚本将使用在任何 GameObject 脚本中都可用的两种方法:
-
Instantiate 方法创建一个 GameObject 的新实例。 当你在 Unity 中实例化 GameObject 时,通常不会像在#dive_into_chash_statementscomma_classesc 中看到的那样使用
new关键字。相反,你将使用 Instantiate 方法,你会在 AddABall 方法中调用它。 -
InvokeRepeating 方法一遍又一遍地调用脚本中的另一个方法。 在这种情况下,它将等待一秒半,然后每秒调用一次 AddABall 方法,直到游戏结束。
这是它的源代码:
将脚本附加到主摄像机上
你的新 GameController 脚本需要附加到一个 GameObject 才能运行。幸运的是,主摄像机只是另一个 GameObject——它恰好是一个带有摄像机组件和音频监听器组件的对象——所以让我们将你的新脚本附加到它上面。从项目窗口的 Scripts 文件夹中拖拽你的 GameController 脚本到层次视图窗口中的主摄像机上。
注意
你已经学习了在#encapsulation_keep_your_privateshellippr 中公共与私有字段的区别。当脚本类有一个公共字段时,Unity 编辑器会在检视器中的脚本组件中显示该字段。它在大写字母之间添加空格,以便更容易阅读其名称。
查看检视器 —— 你会看到一个脚本的组件,与任何其他 GameObject 一样。这个脚本有一个名为 OneBallPrefab 的公共字段,所以 Unity 在脚本组件中显示它。
OneBallPrefab 字段仍然显示 None,所以我们需要设置它。将 OneBall 从 Prefabs 文件夹中拖到 One Ball Prefab 标签旁边的框中。
现在 GameController 的 OneBallPrefab 字段包含了一个对 OneBall 预制体的引用:
回到代码,仔细查看 AddABall 方法。它调用 Instantiate 方法,并将 OneBallPrefab 字段作为参数传递给它。你刚刚设置了该字段,使其包含你的预制体。因此,每当 GameController 调用其 AddABall 方法时,它将创建 OneBall 预制体的一个新实例。
按下播放按钮来运行你的代码
你的游戏已经准备好运行了。附加到 Main Camera 的 GameController 脚本将等待 1.5 秒,然后每秒实例化一个 OneBall 预制体。每个实例化的 OneBall 的 Start 方法将其移动到场景中的随机位置,并且其 Update 方法将每 2 秒围绕 Y 轴旋转,使用 OneBallBehaviour 的字段(就像上次实验中一样)。观察当游戏区域慢慢填满旋转的球时:
注意
Unity 在每帧之前调用每个 GameObject 的 Update 方法。这被称为更新循环。
注意
当你在代码中实例化 GameObjects 时,它们会在你运行游戏时显示在层次视图中。
在层次视图中观察实例的动态
飞行在场景中的每个球都是 OneBall 预制体的一个实例。每个实例都有其自己的 OneBallBehaviour 类的实例。你可以使用层次视图来跟踪所有的 OneBall 实例 —— 每创建一个,层次视图就会添加一个 “OneBall(Clone)” 条目。
点击任何一个 OneBall(Clone) 项来在检视器中查看它。当它旋转时,你会看到它的 Transform 值发生变化,就像上次实验中一样。
注意
我们在 Unity 实验室中包含了一些编码练习。它们和书中其他地方的练习一样 —— 记住,偷看解决方案并不算作弊。
使用检视器来操作 GameObject 实例
运行你的游戏。一旦实例化了几个球,点击暂停按钮 —— Unity 编辑器将跳回到场景视图。点击层次视图中的 OneBall 实例中的任何一个来选择它。Unity 编辑器会在场景视图中用轮廓线标出它,以显示你选择的对象。进入检视器窗口的 Transform 组件,并将其 Z 缩放值设置为 4,使球拉伸。
再次启动你的模拟 —— 现在你可以追踪修改的是哪个球了。尝试像上次实验中那样更改它的 DegreesPerSecond、XRotation、YRotation 和 ZRotation 字段。
当游戏运行时,在游戏视图和场景视图之间切换。即使对于使用 Instantiate 方法创建的 GameObject 实例(而不是添加到层级窗口中的实例),你也可以在场景视图中在游戏运行时使用 Gizmo 工具。
尝试点击工具栏顶部的 Gizmos 按钮以切换它们的显示。你可以在游戏视图中打开 Gizmos,并且可以在场景视图中关闭它们。
使用物理来防止球体重叠
你有没有注意到偶尔一些球会彼此重叠?
Unity 有一个强大的物理引擎,你可以用它让你的 GameObject 表现得像真实的实体——而实体形状不会重叠。要防止重叠,你只需要告诉 Unity 你的 OneBall 预制体是一个实体对象。
停止你的游戏,然后在项目窗口中点击“OneBall”预制体以选择它。然后在检查器中滚动到底部找到“添加组件”按钮:
点击按钮弹出组件窗口。选择 Physics查看物理组件,然后选择 Rigidbody添加组件。
注意
在你运行物理实验时,这里有一个伽利略会欣赏的实验。尝试在游戏运行时勾选“使用重力”框。新创建的球会开始下落,偶尔会碰到另一个球并把它撞开。
再次运行游戏——现在你不会看到球体重叠。偶尔会有一个球体创建在另一个球体之上。当发生这种情况时,新球体会把旧球体撞开。
让我们进行一个小的物理实验,证明这些球现在真的是刚性的。启动游戏,然后一旦创建了两个以上的球就暂停游戏。转到层级窗口。如果看起来像这样:
当你编辑预制体时——点击层级窗口右上角的后退箭头()返回场景(你可能需要再次展开 SampleScene)。
-
按住 Shift 键,点击层级窗口中的第一个 OneBall 实例,然后点击第二个实例,这样前两个 OneBall 实例就被选择了。
-
你会在 Transform 面板的位置框中看到短线(
)。将位置设置为(0,0,0)同时设置两个 OneBall 实例的位置。
-
使用 Shift-click 选择任何其他 OneBall 实例,右键点击,选择删除以将它们从场景中删除,只留下两个重叠的球体。
-
恢复游戏——现在球体不能重叠了,所以它们会旁边旋转。
注意
在 Unity 和 Visual Studio 中停止游戏并保存场景。早保存,频繁保存!
注意
在游戏运行时,您可以使用层级窗口删除场景中的游戏对象。
充满创意!
你已经完成了游戏的一半!你将在下一个 Unity 实验室完成它。与此同时,这是一个练习你纸上原型技能的绝佳机会。在本 Unity 实验室开始时,我们已经为您介绍了游戏的描述。试着创建一个纸上原型游戏。您能想出让它更有趣的方法吗?