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

嗨翻 C# 第四版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:简介:如何使用本书

图片

这本书适合谁?

如果你能对所有以下问题回答“是”:

  1. 你想学习C#(并在学习过程中掌握一些游戏开发和 Unity 的知识)吗?

  2. 你喜欢摆弄吗?你是通过实践学习,而不仅仅是阅读学习吗?

  3. 你更喜欢有趣和刺激的对话,而不是干燥枯燥学术化的演讲吗?

这本书适合你。

谁应该避免阅读这本书?

如果你对以下任何一个问题回答“是”:

  1. 你更对理论感兴趣还是实践?

  2. 做项目和编写代码的想法让你感到无聊并有点焦躁吗?

  3. 害怕尝试不同的东西吗?你认为像开发这样严肃的主题需要一直很严肃吗?

你可能会考虑先尝试另一本书。

图片

我们知道你在想什么。

“这怎么可能是一本严肃的 C#编程书?”

“这些图形是怎么回事?”

“我真的可以通过这种方式学习吗?”

我们知道你的大脑在想什么。

你的大脑渴望新奇。它总是在搜索、扫描,等待一些不寻常的东西。它就是这样建立的,并且帮助你保持生机。

那么你的大脑会怎么处理所有例行、普通、正常的事情?它尽可能地阻止它们干扰大脑的真正工作——记录重要的事情。它不会费力保存无聊的事情;它们永远无法通过“这显然不重要”的过滤器。

你的大脑如何知道什么是重要的?假设你出去远足一天,老虎突然跳到你面前。你的头脑和身体会发生什么?

神经元发射。情绪高涨。化学物质激增

这就是你的大脑如何知道……

图片

这一定很重要!不要忘记它!

但想象一下,你在家里,或者在图书馆。这是一个安全、温暖、没有老虎的地方。你正在学习。为考试做准备。或者尝试学习一些你的老板认为会花费一周,最多十天的技术主题。

只有一个问题。你的大脑试图帮你一把。它试图确保这显然不重要的内容不会占用稀缺的资源。这些资源最好用来存储真正重要的东西。比如老虎。比如火灾的危险。比如你绝不应该在 Facebook 页面上发布那些“派对”照片。

而且没有简单的方法告诉你的大脑,“嘿,大脑,非常感谢你,但无论这本书有多无聊,我现在情绪上的震动有多少,我真的让你保留这些内容。”

图片图片

元认知:思考思维

如果你真的想学习,并且希望更快更深入地学习,请注意你的注意力如何分配。思考你的思维方式。学习你的学习方式。

大多数人在成长过程中并没有接受关于元认知或学习理论的课程。我们期望学习,但很少我们如何学习。

但我们假设如果你拿着这本书,你真的想学习如何在 C#中构建程序。而且你可能不想花很多时间在上面。如果你想要运用你在这本书中读到的内容,你需要记住你读到的内容。而为此,你必须理解它。为了从这本书,或任何书籍或学习经历中获得最大的收益,要对你的大脑负责。你的大脑对内容的反应。

窍门在于让你的大脑把你正在学习的新材料视为非常重要。对你的健康至关重要。和老虎一样重要。否则,你将面临一个持续战斗的局面,你的大脑会尽其所能防止新内容留下来。

图片

那么,你究竟如何让大脑把 C#看待得像饥饿的老虎呢?

有慢慢来的,单调乏味的方式,还有更快、更有效的方式。慢的方式是关于纯粹的重复。你显然知道,即使是最枯燥的话题,只要你不停地把同样的东西敲打到你的大脑里,你也学习和记住。通过足够的重复,你的大脑会说:“这并不感觉重要,但他们一遍又一遍地看着同样的东西,所以我想这一定很重要。”

更快的方法是进行任何增加大脑活动的活动,特别是不同类型的大脑活动。上一页的内容是解决方案的重要组成部分,它们都被证明能帮助你的大脑更好地运作。例如,研究表明,将文字置于它们所描述的图片中(而不是放在页面的其他位置,如标题或正文中)会让你的大脑试图理解文字和图片之间的关系,这会导致更多的神经元兴奋。更多的神经元兴奋 = 更多大脑注意到这是值得关注的事物,并有可能记录下来。

会话风格有助于人们更多关注,因为人们认为自己在进行对话,因为他们期望跟随并保持他们的一端。令人惊讶的是,你的大脑并不一定在乎这个“对话”是你和一本书之间的!另一方面,如果写作风格是正式和枯燥的,你的大脑会感知到与你在一个被动参与者的房间里上课时的感觉是一样的。没必要保持清醒。

但图片和会话风格只是开始。

这是我们所做的事情

我们使用了图片,因为你的大脑更适合视觉而不是文本。对于你的大脑来说,一张图片确实价值千言万语。当文本和图片共同工作时,我们将文本嵌入到图片中,因为你的大脑在文本在其所指的事物内部时更有效地工作,而不是在标题或文本中埋藏起来。

图片

我们使用了冗余性,以不同的方式和不同的媒体类型重复相同的内容,并涉及多种感官,以增加内容编码到大脑的多个区域的机会。

我们以意想不到的方式使用概念和图片,因为你的大脑更适合新奇感,并且我们使用带有一些情感内容的图片和想法,因为你的大脑更容易注意到情绪的生物化学。引发你感受到某种情感的事物更容易被记住,即使这种感觉只是一点幽默惊讶兴趣

我们采用了个性化的、对话式的风格,因为你的大脑在认为你在进行对话而不是 passively listening to a presentation 时更容易集中注意力。即使在阅读时,你的大脑也会如此。

图片

我们包含了数十种活动,因为你的大脑更容易通过事情而不是阅读事情来学习和记忆更多内容。我们制作了难度适中但可行的纸上谜题和编程练习,因为大多数人更喜欢这样。

我们使用了 多种学习风格,因为可能更喜欢逐步的步骤,而其他人则希望先了解整体大局,还有人只是想看一个例子。但不论你的学习偏好如何,每个人都会因为以多种方式呈现相同内容而受益。

图片

我们包含了适合你大脑两侧的内容,因为你能够激活更多的大脑部位,学习和记忆的可能性更高,也能够保持更长时间的专注。因为经常激活一侧大脑意味着另一侧有机会休息,你在学习中可以更有效率更长时间。

并且我们包含了故事和练习,呈现多种观点,因为你的大脑在被迫做出评估和判断时更容易进行深入学习。

图片

我们包括了挑战,带有练习题,并提出了问题,这些问题并不总是有直接的答案,因为你的大脑被调整为在努力做某事时学习和记住。想想看——你不能仅仅通过在健身房看别人来使你的身体保持良好状态。但我们尽力确保当你努力工作时,你在做正确的事情。确保你不会因为难以理解的例子、难以解析的术语或过于简洁的文本而消耗额外的神经元。

我们使用了人物。在故事中、例子中、图片中等,因为,嗯,因为你是一个人。你的大脑对人物的关注比对事物的关注更多。

图片* *# 这里是你可以做的事情来驯服你的大脑

所以,我们做好了自己的部分。剩下的就看你了。这些技巧是一个起点;倾听你的大脑,找出对你有效和无效的东西。尝试新的事物。

图片

注意

把这个剪下来贴在你的冰箱上。

  1. 放慢速度。你理解得越多,需要记忆的就越少。

    不要只是阅读。停下来思考。当书问你一个问题时,不要直接跳到答案。想象有人真的在*问这个问题。你迫使大脑深入思考的程度越深,学习和记忆的机会就越大。

做练习题。写下你自己的笔记。

我们把它们放进去了,但如果我们替你做了,那就像是让别人帮你做锻炼一样。不要只是*看*练习题。**用铅笔写**。有足够的证据表明,在学习过程中进行体育活动可以增加学习效果。

**阅读“没有愚蠢问题”的部分。**

这意味着所有这些。它们不是可选的侧边栏——***它们是核心内容的一部分!***不要跳过它们。

+   **把这个作为你上床前最后阅读的东西。或者至少是最后的挑战性任务。**

学习的一部分(特别是长期记忆的转移)发生在你放下书后。你的大脑需要自己的时间进行更多的加工。如果在这段加工时间内添加了新内容,你刚学到的一些东西可能会丢失。

+   **喝水。大量喝水。**

你的大脑在液体中的环境中效果最佳。脱水(在你感到口渴之前可能会发生)会降低认知功能。

+   **大声谈论它。**

说话会激活大脑的不同部分。如果你试图理解某事,或增加以后记忆的机会,大声说出来。更好的是,试着向别人大声解释。你会更快地学习,并可能会发现你在阅读时未曾意识到的想法。

+   **倾听你的大脑。**

注意你的大脑是否开始超负荷。如果发现自己开始浅尝辄止或者忘记刚读的内容,那么是休息时间了。一旦超过某个点,通过试图塞更多内容来学得更快,并可能会损害学习过程。

+   **感受一下。**

你的大脑需要知道这*很重要*。参与故事情节。为照片编写你自己的标题。对一句糟糕的笑话嘟囔,*总比一无所获好*。

** **多写代码!**

只有一种方法可以*真正*学会 C#,那就是**大量编写代码**。这正是本书的贯穿始终。编码是一种技能,要想掌握好它,就必须多加练习。每章都有练习题让你解决问题,千万不要只是跳过它们——很多学习过程都发生在解决问题时。如果卡住了,不要害怕**偷看解决方案**!我们每个练习都附上了解答,是有原因的:有时候小问题会让你卡住。但在查看解答之前,请尝试自己解决问题。确保在继续本书的下一部分之前,问题已经解决并且运行正常。

**# 自述

这是一次学习经历,而不是一本参考书。我们有意删除了所有可能妨碍你学习的内容。第一次阅读时,你需要从头开始,因为本书假设你已经看过并学习了某些内容。

这些活动是 必不可少 的。

这些谜题和活动不是附加内容;它们是本书核心内容的一部分。其中一些是为了帮助记忆,一些是为了理解,还有一些是为了帮助你应用所学的知识。不要跳过书面问题。 游泳谜题是唯一不必做的事情,但它们有助于让你的大脑思考复杂的逻辑谜题——而且绝对是加快学习过程的好方法。

图片

冗余是有意而重要的。

Head First 系列书籍的一个显著区别在于,我们希望你真正理解它。我们希望你完成本书时记住所学的东西。大多数参考书没有保持和回忆作为目标,但本书关注学习,因此你会看到一些相同的概念多次出现。

图片

做所有的练习!

我们写作本书时做出的一个重要假设是你想学习如何使用 C#进行编程。因此,我们知道你想马上动手,并且直接进入代码。我们通过在每章节中加入练习机会,为你提供了很多锻炼技能的机会。我们标记了一些为“动手来吧!”—当你看到这样的标记时,意味着我们将带领你完成解决特定问题的所有步骤。但当你看到带有跑鞋标志的练习时,这意味着我们留下了很大一部分问题让你自己解决,同时我们也提供了我们想到的解决方案。不要害怕查看解决方案——这不是作弊!但如果你首先尝试解决问题,你会学到更多。

我们还包括了本书中所有练习解决方案的源代码。你可以在我们的 GitHub 页面找到全部内容:github.com/head-first-csharp/fourth-edition

图片

“脑力挑战”问题没有答案。

对于其中一些问题,没有正确答案;对于其他问题,你决定你的答案是否正确是学习过程的一部分。在一些“脑力挑战”问题中,你会找到一些提示,指导你朝正确的方向前进。

图片

我们的目标是 C# 8.0、Visual Studio 2019 和 Visual Studio 2019 for Mac。

本书旨在帮助你学习 C#。微软的开发和维护 C#语言的团队发布了语言的更新版本。C# 8.0 是本书生产期间的当前版本。我们也非常依赖 Visual Studio,微软的集成开发环境(IDE),作为学习、教学和探索 C#的工具。本书中的截图是使用生产时可用的Visual Studio 2019 及其 Mac 版最新版本进行的。我们在#开始使用 C#构建项目中提供了安装 Visual Studio 的说明,以及在Visual Studio for Mac 学习指南附录中提供了安装 Visual Studio for Mac 的说明。

我们正处于即将发布的 C# 9.0 的前夕。它带来了一些很棒的新功能!本书核心学习的 C#特性将保持不变,因此你可以在未来的 C#版本中使用本书。微软维护 Visual Studio 和 Visual Studio for Mac 的团队定期发布更新,极少情况下这些更改会影响本书中的截图。

本书中的 Unity 实验室部分针对Unity 2020.1,这是本书生产期间可用的最新版本。我们在第一个 Unity 实验室中提供了安装 Unity 的说明。

技术审查团队

图片

丽莎·凯尔纳

图片

林赛·比达

图片

Tatiana Mac

图片

Ashley Godbold

虽然没有图片(但同样了不起的是)来自第三版和第二版的审稿人:Rebeca Dunn-Krahn、Chris Burrows、Johnny Halife 和 David Sterling。

第一版的作者还包括:Jay Hilyard、Daniel Kinnaer、Aayam Singh、Theodore Casser、Andy Parker、Peter Ritchie、Krishna Pala、Bill Meitelski、Wayne Bradney、Dave Murdoch,特别是 Bridgette Julie Landers。

特别感谢我们的优秀读者们——特别是 Alan Ouellette、Jeff Counts、Terry Graham、Sergei Kulagin、Willian Piva 和 Greg Combow——他们在阅读我们的书时发现了问题,并且感谢 Mohawk College 的 Joe Varrasso 教授早早采纳了我们的书作为他的课程教材。

非常感谢大家!!

“如果我看得更远,那是因为我站在巨人的肩膀上。” – 艾萨克·牛顿

您正在阅读的这本书几乎没有任何错误,这要归功于我们杰出的技术审稿团队——这些善良的巨人们借给了我们他们的肩膀。对审阅团队:我们对你们所做的工作感激不尽。非常感谢!

Lindsey Bieda 是一名居住在宾夕法尼亚州匹兹堡的软件工程师。她拥有的键盘可能比任何人都多。在她不编码时,她喜欢与她的猫 Dash 一起玩耍和喝茶。她的项目和碎碎念可以在 rarlindseysmash.com 找到。

Tatiana Mac 是一位独立的美国工程师,直接与组织合作,建立清晰而一致的产品和设计系统。她相信无障碍、性能和包容的三位一体可以在数字和物理社交景观中共同改善。当她关注伦理时,她认为技术人员可以解构排斥性系统,支持面向社区的包容系统。

注意

在这件事上我们完全同意 Tatiana 的观点!

Ashley Godbold 是一位程序员、游戏设计师、作家、艺术家、数学家、教师和妈妈。她全职担任大型零售商的软件工程教练,并经营一家小型独立游戏工作室 Mouse Potato Games。她是 Unity 认证讲师,教授计算机科学、数学和游戏开发课程。她著有《掌握 Unity 2D 游戏开发(第二版)》和《掌握 Unity UI 开发》,并创作了名为《Unity 2D 游戏编程》和《入门 Unity 2D 游戏开发》的视频课程。

我们还特别要感谢 Lisa Kellner ——这已经是她为我们审阅的第 12 本书了。非常感谢你!

我们还要特别感谢Joe AlbahariJon Skeet,因为他们在第一版的技术指导和仔细周到的审查方面给予了不可思议的支持,这确实为我们多年来与这本书取得的成功打下了基础。我们从您的建议中受益匪浅——事实上,比当时我们意识到的要多得多。

致谢

我们的编辑:

首先和最重要的是,我们要感谢我们了不起的编辑Nicole Taché,感谢你为这本书所做的一切。你为我们完成这本书出版作出了巨大贡献,并给出了大量宝贵的反馈。非常感谢你!

Nicole Taché

图像

O’Reilly 团队:

Katherine Tozer

图像

我们想要感谢 O’Reilly 的许多人,希望我们没有遗漏任何人。首先、最后,也永远如此,我们要感谢Mary Treseler,她从最初就与我们一同在 O’Reilly 的旅程中。特别感谢制作编辑Katherine Tozer,索引员Joanne Sprott,以及Rachel Head的锐利校对——他们所有人都帮助我们在创纪录的时间内将这本书从生产到印刷。衷心感谢Amanda QuinnOlivia MacDonaldMelissa Duffield,因为他们在推动整个项目进展中起到了关键作用。还要向我们在 O’Reilly 的其他朋友大声致敬:Andy OramJeff BleielMike Hendrickson,当然还有Tim O’Reilly。如果你现在正在阅读这本书,那么你可以感谢业内最佳的宣传团队:Marsee HenonKathryn Barrett,以及 Sebastopol 的其他出色人士。

我们还要向一些我们喜爱的 O’Reilly 作者们表示敬意:

  • Dr. Paris Buttfield-AddisonJon ManningTim Nugent,他们的书Unity Game Development Cookbook简直令人惊叹。我们急切期待来自 Paris 和 Jon 的Head First Swift

  • Joseph AlbahariEric Johannsen,他们撰写了不可或缺的C# 8.0 in a Nutshell

最后...

非常感谢Cathy Vice(Indie Gamer Chick 的知名人物),因为她在我们的书中使用了关于癫痫的惊人文章,在癫痫倡导方面做出了很大贡献。同时也要感谢 Patricia Aas,因为她在我们的 Code Kata 附录中使用了她关于学习 C# 作为第二语言的出色视频,并且对如何帮助高级学习者使用这本书给出了宝贵的反馈。

Jon Galloway

图像

谢谢我们的朋友们在微软的帮助非常大,在这本书中,你们的支持简直令人惊叹。我们非常感激来自 Visual Studio for Mac 团队的多米尼克·纳霍斯(恭喜有宝宝!),乔丹·马蒂森,和约翰·米勒,以及对于启动我们整个合作关系起到关键作用的科迪·拜尔。感谢大卫·斯特林对第三版的精彩审阅,以及伊莫·兰德沃斯帮助我们确定本版需要覆盖的主题。特别感谢马兹·托尔格森,C#语言的项目经理,在过去几年里给予我们的所有精彩指导和建议。你们都太棒了。

我们特别感谢为整本书提供了大量精彩 Blazor 项目代码的乔恩·加洛维。乔恩是.NET 社区团队的高级项目经理。他共同撰写了几本关于.NET 的书籍,帮助组织了.NET 社区直播,还共同主持了Herding Code播客节目。非常感谢你!

O’Reilly 在线学习

Images

40 多年来,O’Reilly Media 一直致力于为公司提供技术和商业培训、知识和洞察力,帮助它们取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台提供即时访问的实时培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和 200 多家其他出版商的大量文本和视频。更多信息,请访问oreilly.com

第二章:用 C#开始构建:快速构建一些伟大的东西……

图片

想立即构建出色的应用程序吗?

使用 C#,你拥有一个现代编程语言和一个有价值的工具在你的指尖。而且有了Visual Studio,你拥有一个令人惊叹的开发环境,具有高度直观的功能,使编码变得尽可能简单。Visual Studio 不仅是一个编写代码的好工具,它还是一个探索 C#的非常有价值的学习工具。听起来吸引人吗?翻页吧,让我们开始编码吧。

为什么你应该学习 C#

C#是一种简单、现代的语言,让你可以做很多令人惊讶的事情。当你学习 C#时,你不仅仅是在学习一门语言。C#为你打开了整个.NET 世界,这是一个非常强大的开源平台,可以构建各种应用程序。

Visual Studio 是你进入 C#的门户

如果你还没有安装 Visual Studio 2019,现在就是时候了。访问visualstudio.microsoft.com下载 Visual Studio Community 版。(如果已经安装,请运行 Visual Studio Installer 更新已安装的选项。)

注意

确保你正在安装 Visual Studio,而不是安装 Visual Studio Code。

Visual Studio Code 是一个了不起的开源跨平台代码编辑器,但它没有像 Visual Studio 那样专门针对.NET 开发。这就是为什么我们可以在本书中始终使用 Visual Studio 作为学习和探索的工具。

如果你在 Windows 上……

确保选中安装支持.NET Core 跨平台开发和.NET 桌面开发的选项。但不要勾选使用 Unity 进行游戏开发选项——稍后在本书中你将会进行 Unity 的 3D 游戏开发,但你需要单独安装 Unity。

图片

如果你在 Mac 上……

下载并运行 Visual Studio for Mac 安装程序。确保勾选.NET Core 目标。

图片

注意

你也可以在 Windows 上进行 ASP.NET 项目!只需在安装 Visual Studio 时确保勾选“ASP.NET 和 Web 开发”选项。

本书中大多数项目都是.NET Core 控制台应用程序,在 Windows 和 Mac 上都可以运行。一些章节有特定项目——比如本章后面的动物匹配游戏——这些是 Windows 桌面项目。对于这些项目,请使用 ASP.NET Core Blazor 项目附录。它提供了一个完整替代的#start_building_with_chash_build_somethin,以及其他 WPF 项目的 ASP.NET Core Blazor 版本。

Visual Studio 是一个编写代码和探索 C#的工具

你可以使用记事本或其他文本编辑器编写你的 C#代码,但有更好的选择。一个集成开发环境(IDE)——简称集成开发环境——是一个文本编辑器、视觉设计工具、文件管理器、调试器……它就像是一个多功能工具,可以满足你编写代码的所有需求。

这些只是 Visual Studio 帮助您完成的一些事情:

  1. 快速构建应用程序。 C# 语言灵活且易学,Visual Studio IDE 通过自动完成大量手动工作,使其更容易。以下是 Visual Studio 为您做的一些事情:

    • 管理您所有的项目文件

    • 使得编辑项目代码变得更加容易

    • 管理项目的图形、音频、图标和其他资源

    • 通过逐行调试来帮助你调试代码

      图片

  2. 设计一个外观出色的用户界面。 Visual Studio IDE 中的可视化设计器是其中一个最易于使用的设计工具。它为您做了很多工作,以至于您会发现为您的程序创建用户界面是开发 C# 应用程序中最令人满意的部分之一。您可以构建完整功能的专业程序,而无需花费数小时调整用户界面(除非您愿意)。

  3. 构建视觉上令人惊叹的程序。 当您结合 C# 和 XAML,用于设计 WPF 桌面应用程序用户界面的视觉标记语言时,您正在使用最有效的工具之一来创建视觉程序…… 您将使用它来构建看起来既好看又能正常运行的软件。

    注意

    如果您使用的是 Visual Studio for Mac,您将构建相同外观的应用程序,但是不是使用 XAML,而是通过将 C# 与 HTML 结合使用。

    注意

    Visual Studio 是一个令人惊叹的开发环境,但我们也将把它作为一个学习工具来探索 C#。

    注意

    任何 WPF 的用户界面(或UI)都是使用 XAML 构建的(XAML 代表可扩展应用标记语言)。Visual Studio 让使用 XAML 变得非常容易。

  4. 学习和探索 C# 和 .NET。 Visual Studio 是一个世界级的开发工具,但幸运的是,它也是一个很棒的学习工具。我们将使用 IDE 探索 C#,这让我们快速掌握重要的编程概念*。

    注意

    在本书中我们经常将 Visual Studio 简称为“IDE”。

在 Visual Studio 中创建您的第一个项目

学习 C# 最好的方法是开始编写代码,所以我们将使用 Visual Studio 创建一个新项目……并立即开始编写代码!

  1. 创建一个新的控制台应用程序 (.NET Core) 项目。

    启动 Visual Studio 2019。当它首次启动时,会显示一个“创建新项目”的窗口,其中有几个不同的选项。选择创建一个新项目。如果您关闭了该窗口也不必担心——您可以通过选择“文件”>>“新建”>>“项目”来随时再次打开它。

    图片

    点击控制台应用程序 (.NET Core)项目类型,然后点击下一步按钮。

    图片

    将项目命名为MyFirstConsoleApp,然后点击创建按钮。

    图片

    注意

    如果您使用的是Visual Studio for Mac,本项目的代码——以及本书中的所有.NET Core 控制台应用程序项目——都将是相同的,但一些 IDE 功能将会有所不同。请参阅 Visual Studio for Mac 学习指南 附录,以获取本章的 Mac 版本。

  2. 查看您的新应用程序的代码。

    当 Visual Studio 创建一个新项目时,它会为您提供一个可以构建的起点。一旦完成为应用程序创建新文件,它应该会打开一个名为Program.cs的文件,并显示以下代码:

    图片

  3. 运行你的新应用。

    Visual Studio 为您创建的应用程序已经准备就绪。在 Visual Studio IDE 的顶部找到带有绿色三角形和您的应用程序名称的按钮,然后单击它:

    图片

  4. 查看您程序的输出。

    当您运行程序时,Microsoft Visual Studio 调试控制台窗口将弹出并显示程序的输出:

    图片

学习一门语言的最佳方式是大量编写代码,所以你将在本书中构建许多程序。其中许多将是.NET Core 控制台应用项目,所以让我们仔细看看你刚刚做了什么。

窗口顶部是程序的输出

Hello World!

然后有一个换行,接着是一些额外的文字:

C:\*path-to-your-project-folder*\MyFirstConsoleApp\MyFirstConsoleApp\bin\Debug\
netcoreapp3.1\MyFirstConsoleApp.exe (process *####*) exited with code 0.
To automatically close the console when debugging stops, enable Tools->
Options->Debugging->Automatically close the console when debugging stops.
Press any key to close this window . . .

您将在每个调试控制台窗口的底部看到相同的消息。您的程序打印了一行文本(**Hello World!**),然后退出。Visual Studio 会保持输出窗口打开,直到您按键关闭它,以便在窗口消失之前查看输出。

按下一个键来关闭窗口。然后再次运行您的程序。这是您在整本书中构建的所有.NET Core 控制台应用程序的运行方式。

让我们来构建一个游戏!

您已经构建了您的第一个 C#应用程序,这太棒了!现在您已经做到了,让我们构建一些稍微复杂的东西。我们将构建一个动物匹配游戏,玩家将看到一个包含 16 只动物的网格,并需要点击成对的动物使它们消失。

图片

注意

WPF 项目的 Mac 版本使用 ASP.NET Core。您也可以在 Windows 上构建 ASP.NET Core 项目。

您的动物匹配游戏是一个 WPF 应用程序。

如果您只需要输入和输出文本,控制台应用非常适合。如果您想要一个显示在窗口中的可视应用程序,则需要使用不同的技术。这就是为什么您的动物匹配游戏将是一个WPF 应用。WPF(或 Windows Presentation Foundation)允许您创建可以在任何 Windows 版本上运行的桌面应用程序。本书的大多数章节将包含一个 WPF 应用程序。这个项目的目标是介绍 WPF,并为您提供构建视觉上令人惊叹的桌面应用程序以及控制台应用程序的工具。

完成这个项目时,你将更加熟悉在本书中学习和探索 C#所依赖的工具。

这是您将构建游戏的方式

本章的其余部分将引导您逐步构建动物匹配游戏,您将分阶段完成:

  1. 首先,您将在 Visual Studio 中创建一个新的桌面应用程序项目。

  2. 然后您将使用 XAML 来构建窗口。

  3. 你将编写 C# 代码,向窗口添加随机动物表情符号。

  4. 游戏需要让用户点击一对表情符号以进行匹配。

  5. 最后,通过添加计时器使游戏变得更加刺激。

注意

这个项目可能需要 15 分钟到一小时不等,具体取决于您的输入速度。我们在不感到匆忙的情况下学得更好,所以请给自己充足的时间。

图片

注意

请注意在整本书中散布的“游戏设计……及以上”元素。我们将使用游戏设计原则作为学习和探索重要编程概念和想法的方法,这些概念和想法适用于任何类型的项目,而不仅仅是视频游戏。

在 Visual Studio 中创建一个 WPF 项目

继续启动 Visual Studio 2019 的新实例并创建一个新项目:

注意

我们已经完成了您在本章开头创建的控制台应用程序项目,因此可以随时关闭该 Visual Studio 实例。

图片

我们将构建我们的游戏作为一个使用 WPF 的桌面应用程序,因此选择 WPF 应用程序(.NET Core)并点击“下一步”:

图片

Visual Studio 将要求你配置你的项目。将项目名称设为 MatchGame(如果愿意,也可以更改位置以创建项目):

图片图片

点击“创建”按钮。Visual Studio 将创建一个名为 MatchGame 的新项目。

Visual Studio 为您创建了一个项目文件夹,其中包含许多文件

一旦您创建了新项目,Visual Studio 就会添加一个名为 MatchGame 的新文件夹,并填充所有项目所需的文件和文件夹。您将对其中的两个文件 MainWindow.xamlMainWindow.xaml.cs 进行更改。

图片

如果您在此项目中遇到任何问题,请访问我们的 GitHub 页面并查找指向视频演示的链接:github.com/head-first-csharp/fourth-edition

调整您的 IDE 以匹配下面的屏幕截图。首先,通过在“解决方案资源管理器”窗口中双击打开 MainWindow.xaml,然后从“视图”菜单中选择 工具箱错误列表 窗口。您实际上可以根据它们的名称和常识来了解许多这些窗口和文件的用途!花点时间并填写每个空白处——试着填写有关 Visual Studio IDE 的每个部分的说明。我们已经开始了一个示例来帮助您入手。看看您是否能对其他部分进行合理猜测。

图片

图片 磨尖你的铅笔解决方案

我们已经填写了关于 Visual Studio C# IDE 不同部分的注解。你可能写下了一些不同的东西,但希望你能够理解每个窗口和 IDE 部分的基本用途。如果你的答案与我们略有不同,不要担心!你将有很多机会练习使用 IDE。

还有一个快速提醒:我们将在本书中——包括本页——交替使用“Visual Studio”和“IDE”这两个术语。

图片图片

使用 XAML 设计你的窗口

现在 Visual Studio 已经为你创建了一个 WPF 项目,是时候开始使用XAML了。

XAML 代表可扩展应用程序标记语言,这是一种非常灵活的标记语言,C#开发人员用来设计用户界面。你将使用两种不同类型的代码构建应用程序。首先,你将使用 XAML 设计用户界面(UI)。然后,你将添加 C#代码来使游戏运行。

如果你曾经使用 HTML 设计网页,那么你会发现 XAML 有很多相似之处。这里是一个在 XAML 中布局小窗口的快速示例:

图片

当 WPF 渲染(或在屏幕上绘制)这个窗口时,它看起来像这样。它绘制了一个窗口,有两个可见的控件,一个显示文本的 TextBlock 控件和一个用户可以点击的 Button 控件。它们使用不可见的 StackPanel 控件布局,导致它们在彼此之上渲染。看一下窗口截图中的控件,然后回到 XAML,找到TextBlockButton标签。

图片

设计你的游戏窗口

你需要一个具有图形用户界面的应用程序,用来使游戏运行,还需要一个可执行文件来运行。听起来工作量很大,但是在本章的其余部分,你将完成所有这些,并且最终,你将能够熟练使用 Visual Studio 设计出漂亮的 WPF 应用程序。

这是我们将要创建的应用程序窗口的布局:

图片

使用 XAML 属性设置窗口大小和标题

让我们开始为你的动物配对游戏构建用户界面。你要做的第一件事是让窗口变窄并更改其标题。你还将熟悉 Visual Studio 的 XAML 设计器,这是一个用于为应用程序设计出漂亮用户界面的强大工具。

  1. 选择主窗口。

    双击解决方案资源管理器中的MainWindow.xaml

    图片

    一旦这样做,Visual Studio 将在 XAML 设计器中打开它。

    图片

  2. 改变窗口的大小。

    将鼠标移到 XAML 编辑器,并点击 XAML 代码的前八行中的任意位置。一旦你这样做,你应该看到窗口的属性显示在属性窗口中。

    展开布局部分,将宽度更改为 400。设计窗格中的窗口会立即变窄。仔细看 XAML 代码 —— 现在宽度属性是 400。

    图片

  3. 更改窗口标题。

    Window 标签的末尾找到这行 XAML 代码:

    Title="MainWindow" Height="450" Width="400">
    

    并将标题更改为 查找所有匹配的动物,使其看起来像这样:

    Title="Find all of the matching animals" Height="450" Width="400">
    

    您将在属性窗口的常见部分看到更改 —— 更重要的是,窗口的标题栏现在显示新文本。

当您修改 XAML 标记中的属性时,更改会立即显示在属性窗口中。当您使用属性窗口修改 UI 时,IDE 会更新 XAML。

图片

向 XAML 网格添加行和列

或许看起来你的主窗口是空的,但是仔细看看 XAML 底部。注意到有一个带有 <Grid> 的行,后面是一个带有 </Grid> 的行?你的窗口实际上有一个 grid —— 只是因为它没有任何行或列而看不见。让我们继续添加一行。

将鼠标移动到设计器窗口的左侧。当光标上出现加号时,点击鼠标以添加一行。

注意

你的 WPF 应用程序的 UI 是由按钮、标签和复选框等控件构建的。网格是一种特殊的控件 —— 被称为容器 —— 它可以包含其他控件。它使用行和列来定义布局。

图片

您将看到一个数字后跟一个星号,并且窗口上出现了一条水平线。您刚刚在网格中添加了一行!现在添加行和列:

  • 重复四次以添加总共五行。

  • 将鼠标悬停在窗口顶部并点击以添加四列。您的窗口应该看起来像下面的屏幕截图(但您的数字可能不同 —— 没关系)。

图片

注意

这些“注意事项”元素提醒您关于重要但通常令人困惑的事项,可能会使您出错或减慢速度。

使行和列大小相等

当我们的游戏显示玩家要匹配的动物时,我们希望它们均匀间隔。每个动物都将包含在网格中的单元格中,而网格将根据窗口的大小自动调整大小,因此我们需要所有行和列的大小相同。幸运的是,XAML 让我们非常容易调整行和列的大小。单击 XAML 编辑器中的第一个 RowDefinition 标签,以在属性窗口中显示其属性:

图片

转到属性窗口,并点击右侧的方形到 Height 属性,然后从弹出菜单中选择重置。等等,等一下!当您这样做时,设计师中的行消失了。好吧,实际上,它并没有完全消失——它只是变得非常窄。继续为所有行重置 Height 属性。然后为所有列重置 Width 属性。您的网格现在应该有四个大小相等的列和五个大小相等的行。

图片图片

将一个 TextBlock 控件添加到您的网格中

WPF 应用程序使用TextBlock 控件来显示文本,我们将使用它们来显示要查找和匹配的动物。让我们在窗口中添加一个。

在工具箱中展开 Common WPF Controls 部分,将一个 TextBlock 拖放到第二列第二行的单元格中。IDE 将在Grid开始和结束标签之间添加一个TextBlock标记:

<TextBlock Text="*TextBlock*"
   HorizontalAlignment="*Left*" VerticalAlignment="*Center*"
   Margin="*560,0,0,0*" TextWrapping="*Wrap*" />

这个 TextBlock 的 XAML 具有五个属性:

  • Text 告诉 TextBlock 在窗口中显示什么文本。

  • HorizontalAlignment 将文本左对齐、右对齐或居中。

  • VerticalAlignment 将其对齐到其框的顶部、中部或底部。

  • Margin 设置其与其容器顶部、侧面或底部的偏移量。

  • TextWrapping 告诉它是否添加换行符以包装文本。

图片

你的属性可能以不同的顺序排列,并且 Margin 属性将具有不同的数字,因为它们取决于您在单元格中拖动的位置。所有这些属性都可以使用 IDE 的属性窗口进行修改或重置。

我们希望每个动物都居中。单击设计师中的标签,然后转到属性窗口并单击图片来展开布局部分。点击水平和垂直对齐属性的中心,然后使用窗口右侧的方形重置 Margin 属性

图片

我们还希望动物更大,因此在属性窗口中展开 Text 部分并将字体大小更改为36 px。然后转到 Common 部分,并将 Text 属性更改为?以显示问号。

图片

点击属性窗口顶部的搜索框,然后键入单词wrap以查找匹配的属性。使用窗口右侧的方形来重置 TextWrapping 属性。

图片图片

注意

您将在整本书中看到许多这样的练习。它们为您提供了锻炼编码技能的机会。并且查看解决方案总是可以的!

图片

现在,您可以开始为您的游戏编写代码了。

您已经完成了设计主窗口的工作——至少足够使您的游戏的下一部分开始运行。现在是时候添加 C#代码使您的游戏运行了。

图片

生成一个方法来设置游戏

现在用户界面已经设置好了,是时候开始为游戏编写代码了。您将通过生成一个方法(就像您之前看到的 Main 方法一样),然后向其中添加代码。

  1. 在编辑器中打开 MainWindow.xaml.cs。

    在解决方案资源管理器中单击 MainWindow.xaml 旁边的三角形 图片 ,然后双击 MainWindow.xaml.cs 以在 IDE 的代码编辑器中打开它。您会注意到该文件中已经有了一些代码。Visual Studio 将帮助您向其中添加一个方法。

    图片

  2. 生成一个名为 SetUpGame 的方法。

    找到您打开的代码的这一部分:

    注意

    如果您还不完全明白方法是什么,没关系。

    注意

    图片图片

    每当您看到灯泡图标时,它都在告诉您选择的代码有一个快速操作可用,这意味着 Visual Studio 可以为您自动化任务。您可以单击灯泡图标,或按 Alt+Enter 或 Ctrl+.(句号)来查看可用的快速操作。

  3. 尝试运行您的代码。

    点击 IDE 顶部的按钮启动程序,就像您之前在控制台应用程序中做的那样。

    图片

    哎呀,出了些问题。它没有显示窗口,而是抛出了一个异常

    图片

    看起来似乎有些东西出了问题,但这实际上正是我们预期会发生的!IDE 暂停了您的程序,并突出显示了最近运行的代码行。仔细看看它:

    throw new NotImplementedException();
    

    IDE 生成的方法确实告诉 C#抛出了一个异常。仔细看看异常消息:

    System.NotImplementedException: 'The method or operation is not implemented.'
    

    实际上这是有道理的,因为你需要实现这个方法,这是 IDE 生成的。如果你忘记实现它,异常会提醒你还有工作要做。如果你生成了很多方法,这种提醒就非常有用!

    在工具栏中点击方形的停止调试按钮 图片 (或者从调试菜单中选择停止调试(F5))以停止程序,这样您就可以完成实现 SetUpGame 方法的工作。

注意

当您使用 IDE 运行应用程序时,停止调试按钮会立即退出它。

完成您的 SetUpGame 方法

您将 SetUpGame 方法放在 public MainWindow() 方法中,因为该方法中的所有内容将在应用程序启动时立即调用。

注意

这是一个特殊的方法,称为构造函数,您将在 #encapsulation_keep_your_privateshellippr 中学习更多关于它的工作原理。

  1. 开始向 SetUpGame 方法添加代码。

    您的 SetUpGame 方法将获取八对动物表情字符,并将它们随机分配给 TextBlock 控件,以便玩家进行匹配。因此,您的方法首先需要这些表情符号的列表,IDE 将帮助您编写相关代码。选择 IDE 添加的 throw 语句,并将其删除。然后将光标放在该语句的位置,输入 List。IDE 将弹出一个以List开头的一堆关键字的 IntelliSense 窗口:

    图片

    从 IntelliSense 弹出窗口中选择 List。然后输入 <str —— 将会弹出另一个匹配关键字的 IntelliSense 窗口:

    图片

    选择 string。完成代码行的输入,但暂时不要按 Enter 键

    图片

  2. 向您的列表添加值。

    您的 C# 语句还没有完成。确保光标放在行末的 ) 后面,然后键入开括号 { —— IDE 将为您添加闭括号,并将光标放在两个括号之间。按 Enter 键 —— IDE 将自动为您添加换行:

    图片

  3. 完成您的方法。

    现在添加方法的其余部分 —— 要小心处理句点、括号和大括号:

    图片

    红色波浪线下的 mainGrid 表示 IDE 指出存在错误:因为在代码中找不到这个名称的任何东西,您的程序将无法构建。返回 XAML 编辑器,点击 <Grid> 标签,然后转到属性窗口,在名称框中输入 mainGrid

    检查 XAML —— 您将在网格顶部看到 <Grid x:Name="mainGrid">。现在您的代码中不应该有任何错误。如果有错误,请仔细检查每一行 —— 很容易漏掉一些细节。

    注意

    如果在运行游戏时出现异常,请确保 animalEmoji 列表中确实有 8 对表情符号,并且在您的 XAML 中有 16 个 <TextBlock ... /> 标签。

运行您的程序

点击 IDE 工具栏中的 图片 按钮以启动程序。一个窗口将弹出,显示您的八对动物在随机位置上的状态:

图片

当您的程序正在运行时,IDE 进入调试模式:开始按钮被灰色的“继续”按钮取代,工具栏中出现调试控制 图片,其中包括暂停、停止和重新启动程序的按钮。

通过点击窗口右上角的 X 按钮或调试控件中的方形停止按钮来停止程序运行。多次运行程序 —— 动物将每次都被重新排列。

图片图片

注意

您已为下一步骤做好了准备。

当你构建一个新的游戏时,你不仅仅是在写代码。你也在运行一个项目。一个非常有效的运行项目的方式是逐步构建它,沿途检查确保事情朝着正确的方向发展。这样你就有很多机会改变方向。

注意

这里有另一个纸笔练习。做完它们绝对是值得的,因为它们会帮助你更快地掌握重要的 C# 概念。

图片

注意

提高你的代码理解能力将使你成为一名更好的开发者。

纸笔练习是不可选的。它们让你的大脑以不同的方式吸收信息。但它们做的更重要的事情是给了你犯错误的机会。犯错误是学习的一部分,我们都犯了很多错误(甚至你可能在这本书中找到一两个错别字!)。没有人一开始就写出完美的代码——真正优秀的程序员总是假设他们今天写的代码明天可能需要改变。事实上,在本书的后面你会学到关于重构的内容,即在编写代码之后改进它的编程技术。

注意

我们将添加类似这样的项目总结,快速概述你迄今为止见过的许多想法和工具。

将你的新项目添加到源代码控制

在这本书中,你将会构建很多不同的项目。如果有一种简单的方式来备份它们并在任何地方访问它们,那将会多么棒!如果你犯了一个错误——如果你能够轻松地回滚到代码的早期版本,那不是非常方便吗?那么,你真是幸运!这正是 源代码控制 所做的事情:它为你提供了一种简单的方式来备份你所有的代码,并跟踪你所做的每一个更改。Visual Studio 让你可以轻松地将你的项目添加到源代码控制中。

Git 是一种流行的版本控制系统,Visual Studio 将会把你的源代码发布到任何一个 Git 仓库(或 repo)。我们认为 GitHub 是使用最简单的 Git 提供商之一。你需要一个 GitHub 账号来向其推送代码,所以如果你还没有账号,请立即访问 github.com 创建一个。

在 IDE 底部的状态栏中找到 添加到源代码控制

图片

点击它——Visual Studio 将提示你将代码添加到 Git 中:

图片

点击 Git。 Visual Studio 可能会提示你输入你的姓名和电子邮件地址。然后它应该在状态栏中显示如下内容:

图片

现在你的代码已经在源代码控制下。现在将鼠标悬停在 图片 上:

图片

IDE 告诉你有两个 提交 —— 或者说是你的代码的保存版本 —— 没有被推送到电脑外的位置。

当你将项目添加到源代码控制时,IDE 会在与 Solution Explorer 相同的面板中打开 Team Explorer 窗口。(如果看不到,请从“视图”菜单中选择。)Team Explorer 帮助你管理源代码控制。你将使用它来发布你的项目到 远程存储库

当你有本地更改时,你将使用 Team Explorer 将它们推送到远程存储库。为此,请在 Team Explorer 窗口中点击 发布到 GitHub 按钮。

图片

注意

Git 是一个开源的版本控制系统。像 GitHub 这样的多个第三方服务提供了 Git 服务(例如为代码提供存储空间和访问权限)。你可以访问 git-scm.com 了解更多关于 Git 的信息。

图片

注意

前往 github.com/head-first-csharp/fourth-edition 查看和下载本项目以及本书中所有其他项目的完整代码。

图片

下一个构建游戏的步骤是处理鼠标点击

现在游戏显示了供玩家点击的动物,我们需要添加使游戏运行的代码。玩家将按对点击动物。玩家首先点击的动物消失。如果玩家点击的第二个动物与第一个匹配,则第二个动物也消失。如果不匹配,则第一个动物重新出现。我们将通过添加一个 事件处理程序 来实现所有这些功能,这只是当应用程序中发生某些操作(如鼠标点击、双击、窗口调整大小等)时调用的方法的名称。

图片

使你的 TextBlocks 响应鼠标点击

你的 SetUpGame 方法会更改 TextBlocks 以显示动物表情符号,这样你就看到了你的代码如何修改应用程序中的控件。现在你需要编写反向操作的代码,即你的控件需要调用你的代码,而 IDE 可以帮助你。

返回到 XAML 编辑器窗口,点击第一个 **TextBlock** 标签 —— 这将导致 IDE 在设计器中选择它,以便你可以编辑其属性。然后转到属性窗口并点击事件处理程序按钮 (图片)。事件处理程序 是当应用程序发生特定事件时调用的方法。这些事件包括按键、拖放、窗口调整大小,当然还有鼠标移动和点击。滚动属性窗口并查看 TextBlock 可为其添加事件处理程序的不同事件名称。在名为 MouseDown 的事件右侧的框内双击。

图片

IDE 在 MouseDown 框中填写了一个方法名 TextBlock_MouseDown,并且 TextBlock 的 XAML 现在有了 MouseDown 属性:

<TextBlock Text="?" FontSize="36" HorizontalAlignment="Center" 
      VerticalAlignment="Center" MouseDown="TextBlock_MouseDown"/>

你可能没有注意到,因为 IDE 还向代码后台添加了一个新方法——即与 XAML 代码结合的代码——并立即切换到 C# 编辑器显示它。你可以通过在 XAML 编辑器中右键点击 TextBlock_MouseDown 并选择“查看代码”来随时跳转回去。这是它添加的方法:

图片

每当玩家点击 TextBlock 时,应用程序将自动调用 TextBlock_MouseDown 方法。现在我们只需要为其添加代码。然后我们需要连接所有其他 TextBlock,以便它们也调用它。

注意

事件处理程序是应用程序响应鼠标点击、键盘按键或窗口调整等事件时调用的方法。

添加 TextBlock_MouseDown 代码

现在你已经阅读了 TextBlock_MouseDown 的代码,是时候将其添加到你的程序中了。接下来要做的是:

  1. 在 IDE 为你添加的 TextBlock_MouseDown 方法的第一行之前,添加前两行代码,分别是 lastTextBlockClickedfindingMatch,确保将它们放在 SetUpGame 结束的右花括号和 IDE 刚刚添加的新代码之间。

  2. 填写 TextBlock_MouseDown 的代码。在等号(=)和双等号(==)之间要特别小心——这是你将在下一章中学到的一个重要区别。

在 IDE 中看起来是这样的:

图片

让其余的 TextBlock 调用同一个 MouseDown 事件处理程序

目前只有第一个 TextBlock 的 MouseDown 事件与事件处理程序关联。让我们也将其他 15 个 TextBlock 与它关联起来。你可以通过在设计器中选择每个文本块并在 MouseDown 旁边输入 TextBlock_MouseDown 来完成。我们已经知道这只是向 XAML 代码添加一个属性,所以让我们采取一种捷径。

  1. 选择 XAML 编辑器中的最后 15 个 TextBlock。

    转到 XAML 编辑器,在第二个 TextBlock 标签的左侧点击,然后向下拖动到 TextBlocks 的末尾,位于闭合 </``**Grid**``> 标签的上方。现在应该已选择了最后 15 个 TextBlock(但不包括第一个)。

  2. 使用快速替换添加 MouseDown 事件处理程序。

    从编辑菜单选择“查找和替换 >> 快速替换”。搜索 /> 并替换为 MouseDown="``*TextBlock_MouseDown*``"/>——确保在 MouseDown 前有一个空格,并且搜索范围是 选择,这样只会向选定的 TextBlock 添加属性。

    图片

  3. 对所有选定的 15 个 TextBlock 运行替换。

    点击“全部替换”按钮(图片)以向 TextBlocks 添加 MouseDown 属性——它应告诉你已替换了 15 处。仔细检查 XAML 代码,确保它们每个都有一个与第一个 TextBlock 中完全匹配的 MouseDown 属性。

    确保方法在 C# 编辑器中现在显示 16 个引用(选择“从构建菜单中构建解决方案”以更新它)。如果看到 17 个引用,则意外地将事件处理程序附加到了 Grid 上。绝对不要这样做——如果这样做,当您单击动物时会出现异常。

运行您的程序。现在,您可以点击动物对使它们消失。您点击的第一个动物将消失。如果您点击其匹配项,该项也将消失。如果点击一个不匹配的动物,则第一个动物将再次出现。当所有动物都消失时,请重新启动或关闭程序。

注意

当您看到脑力元素时,请花一分钟真正思考它所问的问题。

图片

通过添加一个计时器来完成游戏

如果玩家可以尝试打败他们的最佳时间,我们的动物匹配游戏将更加令人兴奋。我们将添加一个 计时器,它通过反复调用方法在固定时间间隔后“tick”。

图片

图片

计时器“tick”每次间隔调用方法。您将使用一个计时器,该计时器在玩家开始游戏时开始,并在匹配到最后一个动物时结束。

向您的游戏代码中添加一个计时器

  1. MainWindow.xaml.cs 文件的顶部附近找到 namespace 关键字,并在其下直接添加以下代码行:using System.Windows.Threading;

     namespace MatchGame
     {
        using System.Windows.Threading;
    
    注意

    添加这个!

  2. 找到 *public partial class MainWindow* 并在左花括号 **{** 后立即添加以下代码:

    public partial class MainWindow : Window
    {
        DispatcherTimer timer = new DispatcherTimer();
        int tenthsOfSecondsElapsed;
        int matchesFound;
    
    注意

    您将添加这三行代码来创建一个新的计时器,并添加两个字段来跟踪经过的时间和玩家找到的匹配数。

  3. 我们需要告诉我们的计时器多频繁“tick”,以及调用什么方法。在调用 SetUpGame 方法的行的开头点击,将编辑器的光标移动到那里。按 Enter 键,然后在下面屏幕截图中以 **timer.** 开头的两行代码:

    图片

  4. 按 Tab 键。IDE 将完成代码行并添加 Timer_Tick 方法:

    图片

  5. Timer_Tick 方法将更新一个跨越整个网格底部行的 TextBlock。以下是设置方法:

    • 将一个 TextBlock 拖放到左下方的正方形中。

    • 使用 Properties 窗口顶部的 Name box 为其命名为 **timeTextBlock.**

    • 重设其 margins,将其 居中 在单元格中,并将 FontSize 属性设置为 36px,Text 属性设置为“已经过时间”(就像您对其他控件所做的一样)。

    • 找到 ColumnSpan 属性并将其设置为 4。

    • 添加名为 TimeTextBlock_MouseDown 的 MouseDown 事件处理程序

      图片

    下面是 XAML 的外观,请仔细比较它与 IDE 中的代码:

    <TextBlock x:Name="*timeTextBlock* "Text="*Elapsed time*" FontSize="*36*"
        HorizontalAlignment="*Center*" VerticalAlignment="*Center*"
        Grid.Row="*4*" Grid.ColumnSpan="*4*" MouseDown="*TimeTextBlock_MouseDown*"/>
    
  6. 当你添加了 MouseDown 事件处理程序时,Visual Studio 在代码后台创建了一个名为 TimeTextBlock_MouseDown 的方法,就像其他的 TextBlock 一样。将以下代码添加到其中:

    private void TimeTextBlock_MouseDown(object sender, MouseButtonEventArgs e)
    {
        if (matchesFound == 8)
        {
            SetUpGame();
        }
    }
    
    注意

    如果已经找到了所有 8 对匹配项,这将重置游戏(否则不执行任何操作,因为游戏仍在运行)。

  7. 现在你已经拥有完成 Timer_Tick 方法所需的一切内容,该方法会更新新的 TextBlock,显示经过的时间,并在玩家找到所有匹配项后停止计时器:

    private void Timer_Tick(object sender, EventArgs e)
    {
        tenthsOfSecondsElapsed++;
        timeTextBlock.Text = (tenthsOfSecondsElapsed / 10F).ToString("0.0s");
        if (matchesFound == 8)
        {
            timer.Stop();
            timeTextBlock.Text = timeTextBlock.Text + " - Play again?";
        }
    }
    

    但这里有些不对劲。 运行你的代码……哎呀!你得到了一个异常

    我们即将修复这个问题,但首先要仔细查看 IDE 中的错误消息和突出显示的行。

    你能猜到是什么导致了这个错误吗?

图片

使用调试器来解决异常。

你可能之前听过“bug”这个词。你可能曾经在过去对你的朋友说过这样的话:“那个游戏真的很有 bug,有很多故障。”每个 bug 都有一个解释——你程序中的每件事都有原因——但并不是每个 bug 都容易追踪。

理解 bug 是修复 bug 的第一步。 幸运的是,Visual Studio 调试器是一个很好的工具。(这就是为什么它被称为调试器:它是帮助你摆脱 bug 的工具!)

  1. 重新启动你的游戏几次。

    首先要注意的是,你的程序总是抛出相同类型和相同消息的异常:

    图片

    如果将异常窗口移开,你会发现它总是停在同一行上:

    图片

    这个异常是可复现的:你可以可靠地使程序抛出完全相同的异常,并且你已经非常清楚问题所在。

  2. 在抛出异常的那一行添加断点。

    再次运行你的程序,使其在异常处中断。在停止它之前,从调试菜单中选择切换断点(F9)。一旦你这样做,该行将被标记为红色,并且左边的边距旁边将出现一个红点。现在再次停止你的应用程序 —— 突出显示和点仍将存在:

    图片

    你刚刚在该行上设置了断点。 现在每次执行该行代码时,你的程序都会中断。现在试一下。再次运行你的应用程序。程序将在该行处中断,但这次不会抛出异常。按继续。它再次在该行上中断。再次按继续。它又中断了。继续进行,直到看到异常。现在停止你的应用程序。

  3. 收集证据,以便找出问题的原因。

    当你运行应用程序时,是否在 Locals 窗口中注意到任何有趣的内容?重新启动它,并且非常注意 animalEmoji 变量。第一次应用程序中断时,你应该在 Locals 窗口中看到这个:

    图片

    按下继续。看起来计数从 16 减少到 15:

    图片

    应用程序将随机从animalEmoji列表中添加表情符号到 TextBlocks 中,然后从列表中移除它们,因此其计数每次应减少 1。一切顺利,直到animalEmoji列表为空(所以 Count 为 0),然后你会收到异常。所以这是一个证据!另一个证据是这发生在**foreach 循环**中。最后一个证据是这一切都是在我们向窗口添加一个新的 TextBlock 之后开始的

    是时候戴上你的福尔摩斯帽子了。你能找出是什么导致了异常吗?

  4. 找出实际导致错误的原因。

    你的程序崩溃的原因是它试图从animalEmoji列表中获取下一个表情符号,但列表为空,这导致它抛出 ArgumentOutOfRange 异常。是什么导致它耗尽了要添加的表情符号?

    在你最近做出最新更改之前,你的程序运行正常。然后你添加了一个 TextBlock…然后它停止工作了。就在遍历所有 TextBlock 的循环中。一个线索…非常非常有趣。

    图片

    因此,当你运行你的应用程序时,对于窗口中的每个 TextBlock,它都会在这一行中断。所以在前 16 个 TextBlock 中,一切都很顺利,因为集合中有足够的表情符号:

    图片

    但现在窗口底部有一个新的 TextBlock,它第 17 次崩溃了——由于**animalEmoji**集合中只有 16 个表情符号,现在它是空的:

    图片

    所以在你做出更改之前,你有 16 个 TextBlock 和一个包含 16 个表情符号的列表,所以刚好有足够的表情符号添加到每个 TextBlock 中。现在你有 17 个 TextBlock,但仍然只有 16 个表情符号,所以你的程序耗尽了要添加的表情符号…然后它抛出异常。

  5. 修复这个 bug。

    由于异常是因为在遍历 TextBlocks 的循环中我们正在耗尽 emoji,我们可以通过跳过我们刚刚添加的 TextBlock 来修复它。我们可以通过检查 TextBlock 的名称来做到这一点,并跳过显示时间的那个。再次切换以移除断点,或者从调试菜单中选择删除所有断点(Ctrl+Shift+F9)

    图片

添加剩余的代码并完成游戏

你还需要做一件事。你的 TimeTextBlock_MouseDown 方法检查 matchesFound 字段,但这个字段从未在任何地方设置过。因此,在 foreach 循环的右大括号后立即在 SetUpGame 方法中添加这三行代码:

             animalEmoji.RemoveAt(index);
          }
       }
       timer.Start();
       tenthsOfSecondsElapsed = 0;
       matchesFound = 0;
   }
注意

在 SetUpGame 方法的最后添加这三行代码,以启动计时器并重置字段。

然后在 TextBlock_MouseDown 的 if/else 的中间块中添加这个语句:

else if (textBlock.Text == lastTextBlockClicked.Text)
{
    matchesFound++;
    textBlock.Visibility = Visibility.Hidden;
    findingMatch = false;
}
注意

添加这行代码,每当玩家成功找到一对匹配项时,将 matchesFound 增加一。

现在你的游戏有一个计时器,当玩家完成匹配动物时停止计时,游戏结束时可以点击它再玩一次。你已经在 C#中构建了你的第一个游戏。恭喜!

图片

注意

github.com/head-first-csharp/fourth-edition 查看和下载本项目及本书中所有其他项目的完整代码。

在源代码控制中更新你的代码

现在你的游戏已经启动运行,现在是把你的更改推送到 Git 的绝佳时机,而 Visual Studio 使这一切变得非常简单。你只需要暂存你的提交,输入提交消息,然后同步到远程仓库。

图片

  1. 从提交下拉菜单中选择 提交已暂存项并同步(就在提交消息框的下面)。同步可能需要几秒钟时间,然后你会在 Team Explorer 窗口看到成功消息。

    图片

图片

每当你有一个大项目时,将其分解成更小的部分总是一个好主意。

你可以培养的最有用的编程技能之一是能够看待一个庞大而困难的问题,并将其分解为更小、更容易的问题。

在一个大项目开始时很容易感到不知所措,并想:“哇,这个项目真的好大啊!”但如果你能找到一个小片段可以开始工作,那么你就能够着手了。一旦完成了这个片段,你可以继续下一个小片段,然后是另一个,再然后是另一个。随着你构建每个片段,你会在项目进展中学到更多。

更好的条件语句……

你的游戏非常不错!但每个游戏——事实上,几乎每个程序——都可以改进。这里有一些我们认为可以使游戏变得更好的想法:

  • 添加不同种类的动物,这样每次显示的都不一样。

  • 记录玩家的最佳时间,以便他们可以尝试超越它。

  • 让计时器倒计时而不是计时上升,这样玩家就有限定的时间。

注意

我们是认真的——花几分钟时间去做这件事。退后一步,思考一下你刚刚完成的项目,这是将学到的经验教训牢固地印在你的大脑中的好方法。

注意

只是一个快速提醒:在本书中,我们会经常将 Visual Studio 称为“IDE”。

图片

第三章:深入 C#:语句、类和代码

图片

你不只是一个 IDE 用户。你是一个开发者

使用 IDE 可以完成大量工作,但它的能力有限。Visual Studio 是有史以来最先进的软件开发工具之一,但强大的集成开发环境仅仅是一个开始。现在是时候深入研究 C# 代码了:它的结构是怎样的,它如何工作,以及如何掌控它……因为你可以让你的应用程序做任何事情,没有限制。

(顺便说一句,无论你喜欢哪种键盘,你都可以成为真正的开发者。唯一需要做的就是编写代码!)

让我们仔细查看控制台应用程序的文件。

在上一章中,你创建了一个名为 MyFirstConsoleApp 的新 .NET Core 控制台应用程序项目。这样做时,Visual Studio 创建了两个文件夹和三个文件。

图片

让我们仔细看看它创建的 Program.cs 文件。在 Visual Studio 中打开它:

图片

  • 文件顶部是一个 **using 指令**。在所有的 C# 代码文件中,你都会看到类似这样的 using 行。

  • using 指令之后就是 namespace 关键字。你的代码位于 MyFirstConsoleApp 命名空间中。紧接着是一个左花括号 **{**,文件的末尾是右花括号 **}**。在这些花括号之间的所有内容都属于这个命名空间。

  • 在命名空间内部是一个。你的程序有一个名为 Program 的类。在类声明之后是一个左花括号,与文件倒数第二行的右花括号成对出现。

  • 在你的类内部是一个名为 Main 的方法——同样是一对括号及其内容。

  • 你的方法只有一个语句Console.WriteLine("Hello World!");

一个语句执行一个单一动作。

每个方法都由像你的 Console.WriteLine 语句那样的语句组成。当程序调用一个方法时,它会依次执行每个语句,直到语句用完或者遇到一个 **return** 语句时结束,程序的执行会恢复到最初调用该方法的语句之后。

两个类可以在同一个命名空间(和文件)中。

看看 PetFiler2 程序的这两个 C# 代码文件。它们包含三个类:Dog 类、Cat 类和 Fish 类。由于它们都在同一个 PetFiler2 命名空间中,所以在 Dog.Bark 方法中可以调用 Cat.Meow 和 Fish.Swim 而无需添加 using 指令

图片

一个类也可以跨多个文件,但在声明时需要使用 partial 关键字。不管不同的命名空间和类如何分布在文件中,它们在运行时的行为都是相同的。

图片图片

IDE 帮助你正确构建代码。

很久以前,程序员们不得不使用简单的文本编辑器,如 Windows 记事本或 macOS TextEdit 来编辑他们的代码。事实上,一些他们的功能当时可能是尖端的(比如搜索和替换,或者记事本的 Ctrl+G 用于“转到行号”)。我们不得不使用许多复杂的命令行应用程序来构建、运行、调试和部署我们的代码。

多年来,微软(以及许多其他公司和许多个人开发者)找到了如何添加许多有用功能,如错误高亮显示、智能感知、所见即所得的点击拖放窗口 UI 编辑、自动生成代码等等。

经过多年的发展,Visual Studio 现在是有史以来最先进的代码编辑工具之一。幸运的是,它也是一个学习和探索 C#和应用开发的绝佳工具

语句是你的应用程序的构建块

你的应用由类组成,这些类包含方法,方法包含语句。所以,如果我们想要构建能做很多事情的应用程序,我们将需要一些不同类型的语句来使它们运行。你已经看到了一种类型的语句:

   Console.WriteLine("Hello World!");

这是一个调用方法的语句,具体来说是 Console.WriteLine 方法,它将一行文本打印到控制台。我们还将在本章和整本书中使用几种其他类型的语句。例如:

图片 我们使用变量和变量声明让我们的应用存储和处理数据。
图片 许多程序使用数学,所以我们使用数学运算符来加法、减法、乘法、除法等等。
图片 条件语句让我们的代码在选择选项时执行一个代码块或另一个代码块。
图片 循环让我们的代码重复执行相同的块,直到满足条件为止。

你的程序使用变量来处理数据

每个程序,无论大小,都与数据一起工作。有时数据是文档的形式,或者是视频游戏中的图像,或者是社交媒体更新,但它们都只是数据。这就是变量的用武之地。变量是程序用来存储数据的工具。

图片

声明你的变量

每当你声明一个变量时,你告诉你的程序它的类型和它的名称。一旦 C#知道你的变量类型,如果你尝试做一些毫无意义的事情,比如从48353中减去"Fido",它将生成错误并阻止你的程序构建。以下是声明变量的方式:

图片

每当你的程序需要处理数字、文本、真/假值或任何其他类型的数据时,你将使用变量来跟踪它们。

变量是可变的

一个变量在程序运行时会在不同的时间点等于不同的值。换句话说,变量的值变化。(这就是为什么“变量”是个好名字的原因。)这非常重要,因为这个想法是你将写的每个程序的核心。假设你的程序将变量myHeight设置为 63:

 int myHeight = 63;

在代码中出现myHeight时,C#将把它替换为它的值 63。然后,稍后,如果你将其值更改为 12:

 myHeight = 12;

C#将从那时起用 12 替换myHeight(直到再次设置)——但变量仍然称为myHeight

在使用变量之前,你需要为它们赋值。

尝试在你的新控制台应用程序的“Hello World”语句下方输入这些语句:

 string z;
 string message = "The answer is " + z;

快点!

现在就试试看。你会收到一个错误,IDE 将拒绝构建你的代码。这是因为它检查每个变量,确保在使用之前你已经为它赋了值。确保你不会忘记为变量赋值的最简单方法是将声明变量和赋值的语句结合起来:

图片

几个有用的类型

每个变量都有一个类型,告诉 C#它可以保存什么类型的数据。我们将详细讨论 C#中许多不同的类型,暂时我们将集中讨论三种最流行的类型。int保存整数(或整数),string保存文本,bool保存布尔值true/false。

注意

变量,名词。

一个很可能改变的元素或特征。如果气象学家不必考虑那么多变量,预测天气会容易得多。

如果你写了一个使用未赋值的变量的代码,你的代码将无法构建。通过将变量声明和赋值合并为一个语句,可以轻松避免此错误。

注意

一旦为变量赋了一个值,该值可以更改。因此,在声明变量时赋予一个初始值没有任何不利之处。

生成一个新的方法来处理变量

在上一章中,你学到了 Visual Studio 将为你生成代码。当你编写代码时,这是非常有用的,它也是一个非常宝贵的学习工具。让我们在你学到的基础上进一步学习并仔细看看生成方法。

快点!

  1. 在你的新的 MyFirstConsoleApp 项目中添加一个方法。

    打开上一章创建的控制台应用程序项目。IDE 创建了一个只有一个语句的 Main 方法:

    Console.WriteLine("Hello World!");
    

    替换这个语句来调用一个方法:

    OperatorExamples();
    
  2. 让 Visual Studio 告诉你哪里出错了。

    一旦你完成替换语句,Visual Studio 会在你的方法调用下绘制一条红色波浪线。将鼠标悬停在其上。IDE 将显示一个弹出窗口:

    图片

    Visual Studio 告诉您两件事情:有一个问题——您正在尝试调用一个不存在的方法(这将阻止您的代码构建)——以及它有一个潜在的修复方法。

  3. 生成 OperatorExamples 方法

    Windows 上,弹出窗口告诉您按下 Alt+Enter 或 Ctrl+. 来查看潜在的修复方法。在 macOS 上,它有一个 “显示潜在修复” 的链接——按 Option+Return 来查看潜在修复方法。所以,请继续按下其中任何一个键组合(或点击弹出窗口左侧的下拉菜单)。

    图像

    这个 IDE 提供了一个解决方案:它会在您的程序类中生成一个名为 OperatorExamples 的方法。点击“预览更改”显示一个窗口,其中包含 IDE 的潜在修复方案——添加一个新方法。然后点击“应用”将该方法添加到您的代码中。

添加使用运算符的代码到您的方法中

一旦您在变量中存储了一些数据,您可以做什么?如果它是一个数字,您可能想要添加或乘以它。如果它是一个字符串,您可能想要将它与其他字符串一起连接。这就是运算符发挥作用的地方。这里是您的新 OperatorExamples 方法的方法体。将此代码添加到您的程序,并阅读 **注释** 以了解它使用的运算符。

图像

private static void OperatorExamples()
{
  // This statement declares a variable and sets it to 3
  int width = 3;

  // The ++ operator increments a variable (adds 1 to it)
  width++;

  // Declare two more int variables to hold numbers and
  // use the + and * operators to add and multiply values
  int height = 2 + 4;
  int area = width * height;
  Console.WriteLine(area);

  // The next two statements declare string variables
  // and use + to concatenate them (join them together)
  string result = "The area";
  result = result + " is " + area;
  Console.WriteLine(result);

  // A Boolean variable is either true or false
  bool truthValue = true;
  Console.WriteLine(truthValue);
}
注意

字符串变量用于保存文本。当您使用 + 运算符连接字符串时,它们会被合并在一起,所以添加 “abc” + “def” 的结果是一个字符串 “abcdef” 。当您像这样连接字符串时,它被称为串联。

使用调试器观察您的变量变化

当您之前运行程序时,它是在 调试器 中执行的——这是一个非常有用的工具,用于理解您的程序如何工作。您可以使用 断点 在程序执行到达某些语句时暂停,并添加 监视 来查看变量的值。让我们使用调试器来看看您的代码运行情况。我们将使用调试器的这三个特性,您将在工具栏中找到:

图像

如果您进入一个您不期望的状态,只需使用重新启动按钮 (图像) 重新启动调试器。

就这样!

  1. 添加断点并运行您的程序。

    将鼠标光标放在您添加到程序 Main 方法中的方法调用上,并从调试菜单中选择 切换断点 (F9)。现在该行应该看起来像这样:

    图像

    注意

    Mac 上的调试快捷键是 Step Over (图像), Step In (图像), 和 Step Out (图像)。屏幕看起来可能有点不同,但调试器的操作方式完全相同,就像您在 Mac 学习指南 的 #start_building_with_chash_build_somethin 中看到的那样。

    然后按下 图像 按钮在调试器中运行程序,就像您之前所做的那样。

  2. 进入该方法。

    调试器在调用 OperatorExamples 方法的语句处停止。

    图片

    **按下 Step Into (F11) ——调试器会跳进方法,然后在执行第一条语句之前停下来。

  3. 检查 width 变量的值。

    当你 逐步执行代码 时,调试器在执行每条语句后会暂停。这使你有机会检查变量的值。将鼠标悬停在 width 变量上。

    图片

    IDE 显示一个弹出窗口,显示变量的当前值 ——目前为 0。现在 按下 Step Over (F10) ——执行跳过注释到第一个语句,该语句现在已突出显示。我们希望执行它,所以 再次按下 Step Over (F10)。再次悬停在 width 上。现在它的值为 3。

  4. Locals 窗口显示变量的值。

    你声明的变量是 局部 的,即它们只存在于该方法内部,并且只能被方法中的语句使用。当 Visual Studio 调试时,在底部的 IDE 中的 Locals 窗口显示它们的值。

    图片

  5. 为 height 变量添加 Watch。

    调试器的一个非常有用的功能是 Watch 窗口,通常与底部的 Locals 窗口在同一面板中。当你将鼠标悬停在变量上时,可以通过右键单击弹出窗口中的变量名称并选择添加 Watch 来添加 Watch。将鼠标悬停在 height 变量上,然后右键单击并选择 Add Watch 菜单。

    图片

    现在你可以在 Watch 窗口中看到 height 变量。

    图片

    调试器是 Visual Studio 中最重要的功能之一,它是理解程序运行方式的强大工具。

  6. 逐步执行方法的其余部分。

    逐步执行 OperatorExamples 中的每个语句。当你逐步执行方法时,注意 Locals 或 Watch 窗口,并观察值随着变化而变化。在 Windows 上,在 Console.WriteLine 语句之前和之后按下 Alt+Tab 切换到调试控制台查看输出。在 macOS 上,你会在终端窗口中看到输出,所以不需要切换窗口。

使用运算符处理变量

一旦你有了变量中的数据,你要怎么处理它?嗯,大多数时候你会希望你的代码根据这些值执行某些操作。这就是 等式运算符关系运算符逻辑运算符 变得重要的地方:

等式运算符

== 运算符比较两个值,如果它们相等则返回 true。

!= 运算符与 == 非常类似,不同之处在于比较的两个值不相等时返回 true。

关系运算符

使用 > 和 < 比较数字,查看一个变量中的数字是大于还是小于另一个变量中的数字。

你还可以使用>=来检查一个值是否大于或等于另一个,使用<=来检查它是否小于或等于另一个。

逻辑运算符

你可以使用&&运算符进行和||运算符进行将个别条件测试组合成一个长测试。

这里是如何检查i是否等于 3或者j是否小于 5 的方式:(i == 3) || (j < 5)

注意

使用运算符比较两个 int 变量

你可以通过使用比较运算符来检查变量的值进行简单测试。这是如何比较两个 int 变量 x 和 y 的方式:

 x < y (less than)
 x > y (greater than)
 x == y (equals - and yes, with two equals signs)

这些是你最常使用的。

“if”语句做出决策

使用**if** 语句告诉你的程序只有当你设置的条件(或不是)为真时才执行某些操作。if语句测试条件并在测试通过时执行代码。许多if语句检查两个事物是否相等。这时你使用==运算符。这与单等号(=)运算符不同,后者用于设置值。

图片图片

如果/否则语句在条件不为真时也会执行某些操作。

**if/else** 语句就像它听起来的那样:如果条件为真,则执行一件事情否则执行另一件事情。一个if/else语句是一个if语句,后面跟着**else** 关键字,然后是第二组要执行的语句。如果测试为真,则程序执行第一组大括号之间的语句。否则,它执行第二组大括号之间的语句。

图片

循环一遍又一遍地执行一个操作

这里有大多数程序(尤其是 游戏!)的一个奇特之处:它们几乎总是涉及重复执行某些操作。这就是循环的用途——它告诉你的程序在某个条件为真或假时继续执行某一组语句。

图片

while 循环在条件为真时不断地执行循环语句

while 循环中,只要括号内的条件为真,大括号内的所有语句都会执行。

while (x > 5)
{
  // Statements between these brackets will
  // only run if x is greater than 5, then
  // will keep looping as long as x > 5
}

do/while 循环运行语句,然后检查条件

do/while 循环与 while 循环几乎一样,只有一个区别。while 循环首先进行测试,然后仅在测试为真时运行其语句。do/while 循环先运行语句,然后进行测试。因此,如果需要确保循环至少运行一次,do/while 循环是一个不错的选择。

do
{
  // Statements between these brackets will run
  // once, then keep looping as long as x > 5
} while (x > 5);

for 循环在每次循环后运行一个语句。

for 循环在每次执行循环后运行一个语句。

图片

注意

for 语句的各个部分称为 初始化器 **(int i = 0),条件测试 (i < 8),和 迭代器 (i = i + 2)。每次通过一个 for 循环(或任何循环)都称为 迭代。

条件测试始终在每次迭代开始时运行,并且迭代器始终在迭代结束时运行。

注意

当你使用 for 片段时,按 Tab 键可以在 i 和 length 之间切换。如果你改变变量 i 的名称,片段会自动更改另外两次出现的名称。

注意

当我们给你提供纸和笔的练习时,通常会在下一页给出答案。

使用代码片段来帮助编写循环

这样做!

你将在本书中编写大量的循环,Visual Studio 可以通过片段或简单的代码模板来帮助提高效率。让我们使用片段向你的 OperatorExamples 方法中添加一些循环。

如果你的代码仍在运行,请从调试菜单中选择停止调试(Shift+F5)(或者点击工具栏上的停止按钮 Images)。然后找到Console.WriteLine(area);这一行,在分号后面点击,然后按几次 Enter 添加额外的空格。现在开始你的片段。输入 **while** 并按两次 Tab 键。IDE 会向你的代码添加一个 while 循环的模板,条件测试被突出显示:

Images

输入**area < 50** ——IDE 将用文本替换true。按 Enter 完成片段。然后在括号之间添加两个语句:

    while (area < 50)
    {
           height++;
           area = width * height;

    }
注意

IDE 提示:括号

如果你的括号不匹配,你的程序将无法构建,这会导致令人沮丧的错误。幸运的是,IDE 可以帮助解决这个问题!将光标放在括号上,IDE 会突出显示其匹配项。

接下来,使用**do/while** 循环片段在你刚刚添加的 while 循环后立即添加另一个循环。输入**do** 并按两次 Tab 键。IDE 会添加此片段:

Images

输入area > 25并按 Enter 完成片段。然后在括号之间添加两个语句:

    do
    {
        width--;
        area = width * height;

    } while (area > 25);

现在,使用调试器真正了解这些循环的工作方式:

  1. 点击循环体上方的一行,并从调试菜单中选择切换断点(F9)以添加断点。然后运行你的代码,按下F5跳转到新的断点。

  2. 使用逐步执行(F10)来逐步执行这两个循环。观察局部窗口中heightwidtharea值的变化。

  3. 停止程序,然后将 while 循环测试更改为**area < 20**,以使两个循环的条件都为假。再次调试程序。while 先检查条件并跳过循环,但 do/while 会执行一次后再检查条件。

一些关于 C#代码有用的事项

  • 不要忘记,你所有的语句都需要以分号结束。

    name = "Joe";
    
  • 通过在代码行前加两个斜杠来为你的代码添加注释。

    // this text is ignored
    
  • 使用 // 来开始和结束包含换行符的注释。

    /* this comment
     * spans multiple lines */
    
  • 变量是由类型后跟名称声明的。

    int weight;
    // the variable's type is int and its name is weight
    
  • 大多数时候,额外的空白是可以接受的。

    So this:      int      j      =            1234       ;
    Is exactly the same as this: int j = 1234;
    
  • If/else, while, do, and for are all about testing conditions.

    到目前为止,我们看到的每个循环只要条件为真就会继续运行。

    图片

然后你的循环就会永远运行下去。

每次程序运行条件测试时,结果要么是**true**,要么是**false**。如果是**true**,那么程序将再次通过循环。每个循环都应该有一些代码,如果运行足够多次,应该会导致条件测试最终返回**false**。如果没有,那么循环将一直运行,直到您关闭程序或关闭计算机!

注意

有时这被称为无限循环,在编写代码时肯定会有使用它的时候。

图片

当然!每个程序都有其自身的机制。

在软件设计的每个层次都存在着机制。在视频游戏的背景下讨论和理解它们会更容易。我们将利用这一点来帮助您更深入地理解机制,这对于设计和构建任何类型的项目都是非常有价值的。

这里有一个例子。游戏的机制决定了游戏的难易程度。使 Pac Man 更快或鬼魂更慢,游戏会变得更容易。这并不一定使游戏变得更好或更糟——只是不同而已。猜猜?同样的想法也适用于您如何设计您的类!您可以将您如何设计方法和字段看作是类的机制。关于如何将代码分解为方法或何时使用字段的选择会使它们更易于使用或更难以使用。

控件驱动您的用户界面的机制

在上一章中,您使用了 TextBlock 和 Grid 控件来构建一个游戏。但是有很多不同的控件可以使用,您选择使用哪些控件会真正改变您的应用程序。听起来奇怪吗?实际上与我们在游戏设计中做选择的方式非常相似。如果您设计一个需要随机数生成器的桌面游戏,您可以选择使用骰子、旋转器或卡片。如果您设计一个平台游戏,您可以选择让您的玩家跳跃、双重跳跃、墙壁跳跃或飞行(或在不同时间做不同的事情)。对于应用程序也是如此:如果您设计一个需要用户输入数字的应用程序,您可以从不同的控件中选择让他们这样做——而这种选择会影响用户体验应用程序的方式

图片

  • 文本框允许用户输入任何想要的文本。但我们需要一种方法来确保他们只输入数字,而不是任意文本。

    图片

  • 单选按钮允许您限制用户的选择。如果需要,您可以将其用于数字,并且可以选择如何布局它们。

    图片

  • 此页面上的其他控件可以用于其他类型的数据,但滑块专门用于选择数字。电话号码也只是数字而已,所以从技术上讲,你可以使用滑块来选择电话号码。你觉得这是个好选择吗?

    图片

  • 列表框为用户提供了从项目列表中选择的方式。如果列表很长,它会显示滚动条,以便用户更容易找到项目。

    图片

  • 组合框结合了列表框和文本框的行为。它看起来像一个普通的文本框,但当用户点击它时,会在其下弹出一个列表框。

    图片

注意

控件是常见的用户界面(UI)组件,是您的 UI 的构建块。您所做的有关使用哪些控件的选择会改变应用程序的机制。

注意

我们可以借鉴视频游戏中的机制概念来了解我们的选择,这样我们可以为任何我们自己的应用程序做出出色的选择——不仅仅是游戏。

图片

本章的其余部分包含了一个为 Windows 构建 WPF 桌面应用程序的项目。前往 Visual Studio for Mac 学习指南获取相应的 macOS 项目。

创建一个 WPF 应用程序以实验控件

如果你填写了网页上的表单,你就会看到我们刚才展示的这些控件(即使你不知道它们的官方名称)。现在让我们创建一个 WPF 应用程序来练习使用这些控件。这个应用程序非常简单——它唯一的功能就是让用户选择一个数字,并显示所选择的数字。

一定要这样做!

图片图片

“早存,多存。”

这是一个旧说法,来自视频游戏没有自动保存功能的时代,当时您必须将这些东西插入计算机以备份项目……但这仍然是个好建议!Visual Studio 使得将项目添加到源代码控制并保持更新变得轻松——所以您始终可以回顾您所取得的所有进展。

图片

向您的应用程序添加一个 TextBox 控件

TextBox 控件为用户提供了一个输入文本的框,所以让我们将其添加到您的应用程序中。但我们不只是想要一个没有标签的 TextBox,所以我们首先会添加一个Label 控件(它与 TextBlock 很相似,但专门用于为其他控件添加标签)。

  1. 从工具箱拖动一个 Label 到网格的左上角单元格。

    这与你在 #start_building_with_chash_build_somethin 中向你的动物匹配游戏添加 TextBlock 控件的方法完全相同,只是这次你要用 Label 控件来做。无论你把它拖到单元格的哪个位置,只要它在左上角的单元格里就行了。

  2. 设置 Label 的文本大小和内容。

    当选中标签控件时,转到属性窗口,展开文本部分,并将字体大小设置为**18px**。然后展开常规部分,并将内容设置为文本输入一个数字

    图片图片

  3. 将标签拖到单元格的左上角。

    在设计师中单击标签并将其拖动到左上角。当距左侧或顶部单元格墙壁 10 像素时,您将看到灰色条出现,并且它将捕捉到 10 像素的边距。

    现在,您窗口的 XAML 应包含一个标签控件:

    <Label Content="*Enter a number*" FontSize="*18*" Margin="*10,10,0,0*" HorizontalAlignment="*Left*" VerticalAlignment="*Top*"/>
    

    图片

  4. 将一个文本框拖到网格的左上角单元格中。

    您的应用程序将拥有一个文本框,位于标签的正下方,以便用户可以输入数字。将其拖动到左侧并放置在标签的下方 - 相同的灰色条将出现以使其在标签的下方具有 10 像素的左边距。将其名称设置为**numberTextBox**,字体大小设置为**18px**,文本设置为**0**

    图片

    现在运行您的应用程序。糟糕!出了点问题 — 它抛出了一个异常。

    图片

    查看 IDE 底部。它有一个自动窗口,显示您定义的任何变量。

    所以到底发生了什么 — 更重要的是,我们如何修复它?

注意

将 XAML 中的 TextBlock 标签移动到文本框上方,使 TextBlock 首先初始化。

添加 C#代码来更新 TextBlock

在#start_building_with_chash_build_somethin 中,您添加了事件处理程序 — 当某个事件触发时调用的方法(有时我们称事件为触发激发)— 来处理动物匹配游戏中的鼠标点击。现在我们将向代码后台添加一个事件处理程序,每当用户在文本框中输入文本并将该文本复制到您添加到小练习中的右上角单元格中的 TextBlock 时调用。

注意

当您双击文本框控件时,IDE 会添加一个处理 TextChanged 事件的事件处理程序,每当用户更改其文本时调用此事件处理程序。双击其他类型的控件可能会添加其他事件处理程序 — 而在某些情况下(例如 TextBlock),则根本不会添加任何事件处理程序。

  1. 双击文本框控件以添加方法。

    双击文本框后,IDE 会自动添加一个 C#事件处理方法,连接到其 TextChanged 事件。它会生成一个空方法,并赋予一个名称,该名称由控件名称(numberTextBox)后跟下划线和所处理事件的名称组成 — numberTextBox_TextChanged

    private void numberTextBox_TextChanged(object sender, TextChangedEventArgs e)
    {
    
    }
    
  2. 添加代码到新的 TextChanged 事件处理程序中。

    任何时候用户在文本框中输入文本,我们希望应用程序将其复制到你添加到网格右上角单元格中的文本块中。因为你给文本块命名为 number,并且你还给文本框命名为 numberTextBox,你只需要一行代码来复制其内容:

    private void numberTextBox_TextChanged(object sender, TextChangedEventArgs e)
    {
        number.Text = numberTextBox.Text;
    }
    
    注意

    这行代码将文本块中的文本设置为与文本框中的文本相同,任何时候用户改变文本框中的文本,它都会被调用。

  3. 运行你的应用程序并尝试文本框。

    使用“开始调试”按钮(或从调试菜单中选择“开始调试(F5)”)启动你的应用程序,就像你在 #start_building_with_chash_build_somethin 中做动物匹配游戏一样。(如果运行时工具出现,你可以像在 #start_building_with_chash_build_somethin 中那样禁用它们。)在文本框中输入任何数字,它都会被复制。

    图片

    但是有些问题 —— 你可以输入任何文本到文本框中,而不仅仅是数字!

    图片

添加一个只允许数字输入的事件处理程序

当你在 #start_building_with_chash_build_somethin 中给你的文本块添加了鼠标按下事件时,你使用了属性窗口右上角的按钮在属性和事件之间切换。现在你会做同样的事情,但这次你会使用预览文本输入事件只接受由数字组成的输入,并拒绝任何不是数字的输入。

如果你的应用程序当前正在运行,请停止它。然后进入设计器,点击文本框选择它,切换到属性窗口以显示其事件。向下滚动并双击预览文本输入旁边的框,让 IDE 生成一个事件处理程序方法。

图片图片

你的新事件处理程序方法将有一个语句:

private void numberTextBox_PreviewTextInput(object sender, TextCompositionEventArgs e)
{
    e.Handled = !int.TryParse(e.Text, out int result);
}
注意

你会在书后面详细了解 int.TryParse,现在只需按照这里显示的方式输入代码。

这是这个事件处理程序的工作原理:

  1. 当用户输入文本到文本框中时,事件处理程序会被调用,但在文本框更新之前。

  2. 它使用了一个特殊的方法叫做 int.TryParse 来检查用户输入的文本是否为数字。

  3. 如果用户输入了一个数字,它会将 e.Handled 设置为 true,这告诉 WPF 忽略该输入。

在运行代码之前,请回到 XAML 标签查看文本框:

 <TextBox x:Name="*numberTextBox*" FontSize="*18*" Margin="*10,49,0,0*" Text="*0*" Width="*120*"
          HorizontalAlignment="*Left*" TextWrapping="*Wrap*" VerticalAlignment="*Top*"
          TextChanged="*numberTextBox_TextChanged*"
          PreviewTextInput="***numberTextBox_PreviewTextInput***"/>

现在它连接了两个事件处理程序:文本更改事件连接到一个名为 numberTextBox_TextChanged 的事件处理程序方法,紧接着下面的预览文本输入事件连接到一个名为 numberTextBox_PreviewTextInput 的方法。

在网格的底部行添加滑块

让我们在底部行添加两个滑块,然后连接它们的事件处理程序以更新右上角的文本块。

  1. 向你的应用程序添加一个滑块。

    从工具箱中拖动一个滑块到右下角的单元格中。将其拖动到单元格的左上角,并使用灰色条形调整其左侧和顶部边距为 10。

    图片图片

    使用属性窗口的常用部分将 AutoToolTipPlacement 设置为**TopLeft**,将 Maximum 设置为**5**,将 Minimum 设置为**1**。给它命名为**smallSlider**。然后双击滑块,添加这个事件处理程序:

    private void smallSlider_ValueChanged(
            object sender, RoutedPropertyChangedEventArgs<double> e)
    {
        number.Text = smallSlider.Value.ToString("0");
    }
    
    注意

    Slider 控件的值是带有小数点的分数。这个“0”将其转换为整数。

  2. 添加一个荒谬的滑块以选择电话号码。

    有句老话说:“只是因为一个想法很糟糕,也许还很愚蠢,并不意味着你不应该去做它。” 所以让我们做一些有点愚蠢的事情:添加一个滑块以选择电话号码。

    图片图片

    将另一个滑块拖入底行。使用属性窗口的布局部分重置其宽度,将其 ColumnSpan 设置为**2**,将所有边距设置为**10**,将其垂直对齐设置为**Center**,水平对齐设置为**Stretch**。然后使用常用部分将 AutoToolTipPlacement 设置为**TopLeft**,Minimum 设置为**1111111111**,Maximum 设置为**9999999999**,Value 设置为**7183876962**。给它命名为**bigSlider**。然后双击它,添加这个 ValueChanged 事件处理程序:

    图片

添加 C#代码以使其余控件工作

你希望应用程序中的每个控件都执行相同的操作:更新右上角单元格中的 TextBlock,以显示所选数字。因此,当你选中单选按钮或从 ListBox 或 ComboBox 中选择项目时,TextBlock 会更新为你选择的值。

  1. 为第一个 RadioButton 控件添加一个 Checked 事件处理程序。

    双击第一个 RadioButton。IDE 将添加一个名为 RadioButton_Checked 的新事件处理程序方法(因为你从未给控件命名,它只是使用控件类型生成方法)。添加以下代码行:

    private void RadioButton_Checked(
            object sender, RoutedEventArgs e)
    {
        if (sender is RadioButton radioButton) {
        number.Text = radioButton.Content.ToString();
        }
    }
    
  2. 使其他 RadioButton 使用相同的事件处理程序。

    仔细查看你刚修改的 RadioButton 的 XAML。IDE 添加了属性Checked="RadioButton_Checked" ——这与其他事件处理程序的连接方式完全相同。将此属性复制到其他 RadioButton 标签,使它们都具有相同的 Checked 属性——现在它们都连接到同一个 Checked 事件处理程序。你可以使用属性窗口中的事件视图来检查每个 RadioButton 是否正确连接。

    图片

  3. 使 ListBox 更新右上角单元格中的 TextBlock。

    在你进行练习时,你将 ListBox 命名为myListBox。现在,你将添加一个事件处理程序,每当用户选择项目时都会触发该处理程序,并使用该名称获取用户选择的数字。

    双击 ListBox 下方的空白处,使 IDE 添加一个 SelectionChanged 事件的事件处理程序方法。在其中添加以下语句:

    注意

    确保您点击列表项下方的空白处。如果您点击一个项目,它将为该项目添加一个事件处理程序,而不是为整个 ListBox 添加。

    private void myListBox_SelectionChanged(
            object sender, SelectionChangedEventArgs e)
    {
      if (myListBox.SelectedItem is ListBoxItem listBoxItem) {
          number.Text = listBoxItem.Content.ToString();
      }
    }
    
  4. 使只读组合框更新 TextBlock。

    双击只读 ComboBox 以使 Visual Studio 添加一个 SelectionChanged 事件的事件处理程序,每当在 ComboBox 中选择一个新项目时,该事件都会触发。这里的代码——与 ListBox 的代码非常相似:

    private void readOnlyComboBox_SelectionChanged(
            object sender, SelectionChangedEventArgs e)
    {
        if (readOnlyComboBox.SelectedItem is ListBoxItem listBoxItem)
            number.Text = listBoxItem.Content.ToString();
    }
    
    注意

    您还可以使用属性窗口添加 SelectionChanged 事件。如果您意外地这样做了,您可以点击“撤销”(但确保您在两个文件中都这样做)。

  5. 使可编辑的组合框更新 TextBlock。

    可编辑的组合框类似于 ComboBox 和 TextBox 的混合体。您可以从列表中选择项目,但也可以输入自己的文本。因为它像一个 TextBox 一样工作,我们可以添加一个 PreviewTextInput 事件处理程序,以确保用户只能输入数字,就像我们对 TextBox 做的那样。事实上,您可以重用您已经为 TextBox 添加的相同事件处理程序

    转到可编辑 ComboBox 的 XAML,在闭合角标 **>** 前面放置光标,开始输入 PreviewTextInput。 IntelliSense 窗口将弹出以帮助您完成事件名称。然后 添加一个等号—一旦您这样做,IDE 将提示您选择一个新的事件处理程序或选择您已添加的一个。选择现有的事件处理程序。

    图片

    之前的事件处理程序使用列表项来更新 TextBlock。但用户可以在可编辑的 ComboBox 中输入任何文本,所以这次您将添加一种不同类型的事件处理程序

    再次编辑 XAML,在 ComboBox 下方添加一个新的标签。这次,输入 **TextBoxBase**。—一旦你输入句点,自动完成将会给出建议。选择 TextBoxBase.TextChanged 并输入一个等号。现在从下拉菜单中选择 <新事件处理程序>。

    图片

    IDE 将在代码后台添加一个新的事件处理程序。这是它的代码:

    private void editableComboBox_TextChanged(object sender, TextChangedEventArgs e)
    {
        if (sender is ComboBox comboBox)
            number.Text = comboBox.Text;
    }
    

    现在运行你的程序。所有控件都应该工作。干得好!

图片

控件为您的用户提供了灵活性,使事情变得更加简单。

在构建应用程序的用户界面(UI)时,你需要做出很多选择:使用哪些控件,每个控件放置在哪里,如何处理它们的输入。选择一个控件而不是另一个会向用户传达关于如何使用你的应用程序的隐含信息。例如,当你看到一组单选按钮时,你知道你需要从一个有限的选择集中选择一个,而可编辑的组合框则告诉你几乎没有限制的选择。因此,不要把 UI 设计看作是做出“对”或“错”的选择。相反,把它看作是让用户尽可能轻松使用你的途径。

第四章:Unity 实验室#1:探索 C#与 Unity

欢迎来到您的第一个Head First C# Unity Lab。编写代码是一种技能,就像任何其他技能一样,通过练习和实验来提高。Unity 将是一个非常有价值的工具。

Unity 是一个跨平台的游戏开发工具,您可以用它制作专业质量的游戏、模拟等。同时,这也是一个学习 C#工具和理念的有趣而令人满足的方式,这本书中您将会学到这些。我们设计了这些简短而有针对性的实验室,以巩固您刚刚学到的概念和技术,帮助您磨练 C#技能。

这些实验室是可选的,但是它们是宝贵的练习——即使您不打算使用 C#来制作游戏

在这个第一个实验室中,您将开始使用 Unity,并对 Unity 编辑器有所了解,并开始创建和操作 3D 形状。

Unity 是一个强大的游戏设计工具

欢迎来到 Unity 的世界,这是一个完整的系统,用于设计专业质量的游戏——包括二维(2D)和三维(3D)游戏,以及模拟、工具和项目。Unity 包括许多强大的功能,包括...

跨平台游戏引擎

游戏引擎负责显示图形,追踪 2D 或 3D 角色,检测它们的碰撞,使它们表现得像真实世界的物体,并且还有许多其他功能。Unity 将为您在本书中构建的 3D 游戏完成所有这些工作。

Images

强大的 2D 和 3D 场景编辑器

您将在 Unity 编辑器中花费大量时间。它让您可以编辑充满 2D 或 3D 对象的关卡,并提供工具,用于为您的游戏设计完整的世界。Unity 游戏使用 C#来定义其行为,并且 Unity 编辑器与 Visual Studio 集成,为您提供无缝的游戏开发环境。

注意

虽然这些 Unity 实验室将集中在 Unity 中的 C#开发上,但如果您是视觉艺术家或设计师,Unity 编辑器还具有许多专为您设计的艺术友好型工具。在这里查看它们:unity3d.com/unity/features/editor/art-and-design

Images

游戏创作生态系统

除了作为一个强大的游戏创建工具之外,Unity 还具有一个生态系统,帮助您构建和学习。Unity 的学习页面(unity.com/learn)提供宝贵的自学资源,而 Unity 论坛(forum.unity.com)则帮助您与其他游戏设计师联系并提问。Unity 资源商店(assetstore.unity.com)提供免费和付费的资源,如角色、形状和特效,可以用于您的 Unity 项目中。

我们的 Unity 实验室将集中于使用 Unity 作为探索 C#的工具,并通过您在本书中学到的 C#工具和理念进行实践。

Head First C# Unity 实验室专注于以开发者为中心的学习路径。这些实验室的目标是帮助您快速掌握 Unity,采用与Head First C#全书一脉相承的大脑友好、及时学习方法,让您能够有针对性地、有效地练习 C# 的思想和技术

下载 Unity Hub

Unity Hub 是一个帮助您管理 Unity 项目和 Unity 安装的应用程序,也是创建新 Unity 项目的起点。首先从 store.unity.com/download 下载 Unity Hub,然后安装并运行它。

图片

Unity Hub 允许您在同一台计算机上安装多个版本的 Unity,因此您应该安装与我们用来构建这些实验室的相同版本。点击官方发布版本,安装以Unity 2020.1开头的最新版本——这是我们在这些实验室中使用的版本。安装完成后,请确保它已设置为首选版本。

Unity 安装程序可能会提示您安装不同版本的 Visual Studio。您也可以在同一台计算机上安装多个版本的 Visual Studio,但如果您已经安装了一个版本的 Visual Studio,Unity 安装程序就不需要再添加另一个版本了。

您可以在此处详细了解如何在 Windows、macOS 和 Linux 上安装 Unity Hub:docs.unity3d.com/2020.1/Documentation/Manual/GettingStartedInstallingHub.html

注意

Unity Hub 可让您在同一台计算机上安装多个 Unity 版本。因此,即使有更新版本的 Unity 可用,您也可以使用 Unity Hub 安装我们在 Unity 实验室中使用的版本。

使用 Unity Hub 创建新项目

在 Unity Hub 中的项目页面上单击 图片 按钮以创建新的 Unity 项目。命名为Unity Lab 1,确保选择了 3D 模板,并检查是否将其创建在一个合理的位置(通常是您的主目录下的 Unity Projects 文件夹)。

图片

单击“创建项目”以创建包含 Unity 项目的新文件夹。创建新项目时,Unity 会生成许多文件(就像 Visual Studio 为您创建新项目时所做的那样)。Unity 可能需要一两分钟来创建您的新项目的所有文件。

将 Visual Studio 设为 Unity 脚本编辑器

Unity 编辑器与 Visual Studio IDE 配合得非常紧密,使得编辑和调试游戏代码变得非常简单。因此,我们首先要确保 Unity 已经与 Visual Studio 连接好。从“编辑”菜单中选择“首选项”(或者在 Mac 上从 Unity 菜单中选择),打开 Unity 首选项窗口。在左侧点击“外部工具”,在“外部脚本编辑器”窗口中选择 Visual Studio

在某些旧版本的 Unity 中,您可能会看到一个 Editor Attaching 复选框 —— 如果有,请确保它被选中(这样可以让您在 IDE 中调试 Unity 代码)。

图片

好了!您已经准备好开始构建您的第一个 Unity 项目了。

您可以使用 Visual Studio 来调试 Unity 游戏中的代码。只需在 Unity 的偏好设置中选择 Visual Studio 作为外部脚本编辑器。

掌控 Unity 布局

Unity 编辑器就像是您 Unity 项目中除了 C# 以外的所有部分的 IDE。您将使用它来处理场景、编辑 3D 形状、创建材质等等。就像在 Visual Studio 中一样,Unity 编辑器中的窗口和面板可以以许多不同的布局重新排列。

找到窗口顶部附近的 Scene 选项卡。单击选项卡并拖动它以分离窗口:

图片

尝试将其停靠在其他面板内或旁边,然后将其拖动到编辑器中间使其成为浮动窗口。

选择 Wide 布局以与我们的截图匹配

我们选择了 Wide 布局,因为它在这些实验室的截图中表现良好。找到布局下拉菜单,选择 Wide,这样您的 Unity 编辑器看起来就像我们的一样。

图片

注意

Scene 视图是您创建的世界的主要交互视图。您将用它来定位 3D 形状、摄像机、灯光以及游戏中的所有其他对象。

这是 Wide 布局下您的 Unity 编辑器应该呈现的样子:

图片

您的场景是一个 3D 环境

一旦您启动编辑器,您就在编辑一个 场景。您可以将场景视为 Unity 游戏中的关卡。在 Unity 中的每个游戏由一个或多个场景组成。每个场景都包含一个独立的 3D 环境,具有自己的灯光、形状和其他 3D 对象。创建项目时,Unity 添加了一个名为 SampleScene 的场景,并将其存储在名为 **SampleScene.unity** 的文件中。

通过从菜单中选择 GameObject >> 3D Object >> Sphere,向您的场景添加一个球体:

图片

在您的场景窗口中将会出现一个球体。您在场景窗口看到的一切都是从 Scene 视图相机 的视角显示的,它“看”着场景并捕捉其所见。

图片

Unity 游戏由 GameObject 构建

当您向场景添加一个球体时,您创建了一个新的 GameObject。GameObject 是 Unity 中的基本概念。在您的 Unity 游戏中,每个物品、形状、角色、光线、摄像机和特效都是 GameObject。游戏中使用的任何场景、角色和道具都由 GameObject 表示。

在这些 Unity 实验室中,您将使用不同类型的 GameObject 构建游戏,包括:

图片

GameObject 是 Unity 中的基本对象,组件是它们行为的基本构建块。检视器窗口显示了您场景中每个 GameObject 及其组件的详细信息。

每个 GameObject 包含一些组件,提供其形状、设置其位置,并赋予其所有行为。例如:

  • Transform components确定 GameObject 的位置和旋转。

  • Material components改变 GameObject 的渲染方式——或者说改变 Unity 绘制 GameObject 的方式——通过改变颜色、反射、光滑度等。

  • Script components使用 C#脚本确定 GameObject 的行为。

注意

渲染,动词。

以艺术方式代表或描绘。

*米开朗基罗渲染他最喜欢的模型时比他的其他任何图纸都更详细。

使用 Move Gizmo 移动你的游戏对象

Unity 编辑器顶部的工具栏允许你选择 Transform 工具。如果未选择 Move 工具,请按下其按钮进行选择。

Images

Move 工具允许你使用Move Gizmo在 3D 空间中移动 GameObject。你应该看到红色、绿色和蓝色的箭头以及一个立方体出现在窗口中间。这就是 Move Gizmo,你可以用它来移动选定的对象在场景中。

Images

将鼠标光标移到 Move Gizmo 中心的立方体上——注意,当你将鼠标光标移动到立方体上时,立方体的每个面都会亮起?点击左上角的面并拖动球体。你在移动球体在 X-Y 平面上。

注意

当你点击 Move Gizmo 中间的立方体的左上角面时,它的 X 和 Y 箭头会亮起,你可以在场景中的 X-Y 平面上拖动你的球体。

Images

在场景中移动你的球体,感受 Move Gizmo 的工作原理。点击并拖动每个箭头,分别沿每个平面拖动。尝试点击 Scene Gizmo 中的立方体的每个面,以在所有三个平面上移动它。注意随着球体远离你(或者说场景相机),它变小,靠近时变大。

Move Gizmo 允许你沿着场景中的任意轴或平面移动游戏对象。

Inspector 显示了你的 GameObject 的组件

当你在 3D 空间中移动你的球体时,请注意Inspector 窗口,如果你使用宽布局,则在 Unity 编辑器的右侧。浏览 Inspector 窗口——你会看到你的球体有四个标记为 Transform、Sphere(Mesh Filter)、Mesh Renderer 和 Sphere Collider 的组件。

每个 GameObject 都有一组组件,提供其行为的基本构建块,并且每个 GameObject 都有一个Transform 组件,驱动其位置、旋转和缩放。

当你使用 Move Gizmo 在 X-Y 平面上拖动球体时,你可以看到 Transform 组件中位置行的 X 和 Y 数字发生变化。

注意

如果你不小心取消选择了一个游戏对象,只需再次点击它。如果它在场景中不可见,你可以在层次结构窗口中选择它,该窗口显示场景中的所有游戏对象。当你将布局重置为宽时,层次结构窗口位于 Unity 编辑器的左下角。

Images

尝试点击移动 Gizmo 立方体的其他两个面,然后拖动来在 X-Z 和 Y-Z 平面上移动球体。接着点击红色、绿色和蓝色箭头,沿着 X、Y 或 Z 轴拖动球体。当你移动球体时,你会看到 Transform 组件中的 X、Y 和 Z 值发生变化。

现在按住Shift 键将 Gizmo 中间的立方体转换为正方体。点击并拖动该正方体,在与场景视图相平行的平面上移动球体。

一旦你完成了对 Move Gizmo 的实验,使用球体的 Transform 组件上下文菜单将组件重置为其默认值。点击 Transform 面板顶部的上下文菜单按钮 (Images),然后从菜单中选择重置。

Images

位置将重置为[0, 0, 0]。

你可以在 Unity 手册中了解更多关于工具及其如何用于定位游戏对象的信息。点击帮助 >> Unity 手册并搜索“定位游戏对象”页面。

经常保存你的场景!使用文件 >> 保存或 Ctrl+S / ImagesS 来立即保存当前场景。

将材质添加到你的球体游戏对象中。

Unity 使用材质来提供颜色、图案、纹理和其他视觉效果。你的球体现在看起来相当无聊,因为它只有默认材质,这导致 3D 对象呈现为普通的米白色。让我们让它看起来像一个台球。

  1. 选择球体。

    当球体被选中时,你可以在检视器窗口中看到它的材质作为一个组件:

    Images

    我们将通过添加一个纹理来使你的球体更有趣——这只是一个简单的图像文件,包裹在一个 3D 形状周围,就像你在橡胶片上打印了这张图片并将其拉伸到你的物体上一样。

  2. 前往我们在 GitHub 上的台球纹理页面。

    github.com/head-first-csharp/fourth-edition并点击Billiard Ball Textures链接浏览一个完整的台球纹理文件夹。

  3. 下载 8 球的纹理。

    点击文件8 Ball Texture.png查看 8 球的纹理。这是一个普通的 1200 × 600 PNG 图像文件,你可以在你喜爱的图像查看器中打开。

    Images

    将文件下载到计算机上的一个文件夹中。

    (你可能需要右键点击下载按钮来保存文件,或者点击下载以打开并保存它,这取决于你的浏览器。)

  4. 将 8 球纹理图像导入到你的 Unity 项目中。

    在项目窗口中的 Assets 文件夹上右键,选择“导入新资源...”并导入纹理文件。当你点击项目窗口中的 Assets 文件夹时,现在应该能看到它。

    图片

  5. 将纹理添加到你的球体。

    现在你只需拿这个纹理并“包裹”在你的球体上。点击项目窗口中的 8 号球纹理以选中它。一旦选中,将其拖动到你的球体上。

    图片

现在你的球体看起来像一个 8 号球。检视器显示 8 号球游戏对象。现在它有一个新的材质组件:

图片图片

Unity 是真正“掌握”C#的好方法。

编程是一种技能,你写 C#代码的练习越多,编码能力就会越好。这就是为什么我们在整本书中专门设计了 Unity 实验室,来帮助你练习 C#技能,并巩固每章节所学的 C#工具和概念。随着你写更多的 C#代码,你会变得越来越好,这是成为优秀 C#开发者的一种非常有效的方式。神经科学告诉我们,当我们进行实验时,我们学习得更有效,所以我们设计这些 Unity 实验室时提供了许多实验选项,并建议你如何发挥创造力并继续进行每个实验。

但是,Unity 给了我们更重要的机会,帮助理解重要的 C#概念和技术。当你学习一门新的编程语言时,看到它如何在许多不同的平台和技术上运行是非常有帮助的。这就是为什么我们在主章节材料中同时包括了控制台应用和 WPF 应用,有些情况下甚至让你使用两种技术构建相同的项目。加入 Unity,让你获得第三种视角,这真的能加速你对 C#的理解。

旋转你的球体

在工具栏中点击“旋转工具”。你可以使用 Q、W、E、R、T 和 Y 键快速切换变换工具——按 E 和 W 切换旋转工具和移动工具。

图片

  1. 点击球体。Unity 将显示一个带有红、蓝和绿圆圈的线框球体旋转操作器。点击红圈并拖动它以绕 X 轴旋转球体。

    图片

  2. 点击并拖动绿色和蓝色圆圈以绕 Y 轴和 Z 轴旋转。外部的白色圆圈使球体沿着从场景视图摄像机出来的轴旋转。观察检视器窗口中的旋转数值变化。

    图片

  3. 打开检视器窗口中变换面板的上下文菜单。点击“重置”,就像之前一样操作。这将把变换组件中的所有内容重置为默认值——在这种情况下,将把你的球体旋转恢复为[0, 0, 0]。

    图片

    现在使用 文件 >> 保存 或 Ctrl+S / 图片S 来保存场景。早保存,经常保存!

使用 Hand 工具和 Scene Gizmo 移动 Scene 视图相机。

使用鼠标滚轮或轨迹板上的滚动功能进行缩放,并在 Move 和 Rotate Gizmos 之间切换。注意到球体的大小变化,但 Gizmos 不变。编辑器中的 Scene 窗口显示虚拟相机视图,并且滚动功能缩放该相机。

按 Q 键选择Hand 工具,或从工具栏中选择它。你的光标将变成手型。

图片

注意

按住 Alt 键(Mac 上为 Option 键)并拖动,Hand 工具将变成眼睛并围绕窗口中心旋转视图。

Hand 工具通过改变场景相机的位置和旋转来在场景周围移动。选择 Hand 工具后,你可以在场景中的任何地方点击以进行平移。

图片

当选择 Hand 工具时,你可以通过点击和拖动平移场景相机,并且你可以通过按住Alt(或 Option)并拖动旋转它。使用鼠标滚轮进行缩放。按住右键允许你使用 W-A-S-D 键在场景中飞行

当你旋转场景相机时,注意 Scene 窗口右上角的Scene Gizmo。Scene Gizmo 总是显示相机的方向 —— 在使用 Hand 工具移动 Scene 视图相机时,检查它。点击 X、Y 和 Z 锥体可将相机捕捉到一个轴上。

图片

Unity 手册提供了关于导航场景的绝佳提示: docs.unity3d.com/Manual/SceneViewNavigation.html

图片

发挥创造力!

我们设计这些 Unity 实验室,为你提供一个自主实验 C# 的平台,因为这是成为优秀 C# 开发者的最有效途径。在每个 Unity 实验室结束时,我们将为你提供一些可以尝试的建议。在进入下一章之前,花些时间尝试一下你刚刚学到的一切。

  • 在你的场景中再添加几个球体。尝试使用其他台球球形图。你可以从与 8 Ball Texture.png 相同的位置下载它们。

  • 尝试通过选择 GameObject >> 3D Object 菜单中的 Cube、Cylinder 或 Capsule 添加其他形状。

  • 尝试使用不同的图像作为纹理。看看当你将它们用于创建纹理并添加到不同形状时,人物或风景照片会发生什么。

  • 你能否通过形状、纹理和灯光创建一个有趣的 3D 场景?

图片

编写的 C# 代码越多,你就越能掌握它。这是成为优秀 C# 开发者的最有效方式。我们设计这些 Unity 实验室,为你提供实践和实验的平台。

第五章:对象...定位:让代码变得有意义

图像

你编写的每个程序都解决一个问题。

当你构建一个程序时,首先考虑你的程序应该解决的问题总是个好主意。这就是为什么对象非常有用。它们允许你根据解决问题的方式来组织你的代码,这样你可以花时间思考你需要解决的问题,而不是陷入编写代码的细节中。当你正确使用对象,并真正考虑如何设计它们时,你最终会得到易于编写、阅读和修改的代码。

如果代码有用,它会被重用

开发人员自编程早期以来就一直在重用代码,而且很容易理解为什么会这样。如果你为一个程序编写了一个类,并且有另一个需要执行完全相同操作的程序,那么在新程序中重用相同的类是有意义的。

图像

一些方法接受参数并返回一个值

你已经看到了一些执行任务的方法,比如在#start_building_with_chash_build_somethin 中的 SetUpGame 方法,它设置了你的游戏。方法不仅仅可以这样做:它们可以使用参数获取输入,对输入进行处理,然后生成具有返回值的输出,该返回值可以被调用方法使用的语句所使用。

图像

参数是方法用作输入的值。它们被声明为包含在方法声明中的变量(在括号之间)。返回值是在方法内部计算或生成的值,并发送回调用该方法的语句。返回值的类型(如stringint)称为返回类型。如果一个方法有返回类型,则必须使用**return** 语句

这是一个具有两个 int 参数和一个 int 返回类型的方法的示例:

图像

该方法接受名为**factor1****factor2**的两个参数。它使用乘法运算符*****来计算结果,并使用**return**关键字返回结果。

此代码调用 Multiply 方法,并将结果存储在名为**area**的变量中:

图像

让我们构建一个挑选卡片的程序

在本章的第一个项目中,你将构建一个名为 PickRandomCards 的.NET Core 控制台应用程序,该程序允许你随机选择扑克牌。下面是它的结构:

图像

你的 PickSomeCards 方法将使用字符串值来表示扑克牌。如果你想挑选五张牌,你可以像这样调用它:

      string[] cards = PickSomeCards(5);

**cards** 变量有一种您还没见过的类型。方括号 **[]** 表示它是一个字符串数组。数组允许您使用单个变量存储多个值——在本例中,是带有纸牌的字符串。这里是 PickSomeCards 方法可能返回的字符串数组示例:

{ "10 of Diamonds", 
  "6 of Clubs", 
  "7 of Spades",
  "Ace of Diamonds", 
  "Ace of Hearts" }
注意

这是一个包含五个字符串的数组。您的卡片选择器应用程序将创建类似这样的数组来表示随机选择的卡片。

Images

生成数组后,您将使用一个 **foreach** 循环将其写入控制台。

创建您的 PickRandomCards 控制台应用程序

做这个!

让我们利用您迄今学到的知识来创建一个选择随机卡片的程序。打开 Visual Studio 并 创建一个名为 PickRandomCards 的新控制台应用程序项目。您的程序将包括一个名为 CardPicker 的类。以下是显示其名称和方法的类图:

Images

在解决方案资源管理器中右键单击 PickRandomCards 项目,选择 Windows 中的 添加 >> Class…(或 macOS 中的 添加 >> New Class…)来弹出菜单。Visual Studio 将提示您输入一个类名——选择 CardPicker.cs

Images

Visual Studio 将在您的项目中创建一个名为 CardPicker 的全新类:

Images

您的新类是空的——它以类 **CardPicker** 和一对花括号开始,但里面什么都没有。添加一个名为 PickSomeCards 的新方法。您的类应该如下所示:

Images

完成您的 PickSomeCards 方法

现在做这个!

  1. 您的 PickSomeCards 方法需要一个返回语句,让我们添加一个。 继续填写方法的其余部分——现在它使用了一个 **return** 语句来返回字符串数组值,错误消失了:

    class CardPicker
    {
         public static string[] PickSomeCards(int numberOfCards)
         {
             string[] pickedCards = new string[numberOfCards];
             for (int i = 0; i < numberOfCards; i++)
             {
                  pickedCards[i] = RandomValue() + " of " + RandomSuit();
             }
             return pickedCards;
         }
    }
    
    注意

    通过返回与方法的返回类型匹配的值,您使红色波浪线错误消失了。

  2. 生成缺失的方法。 现在您的代码由于缺少 RandomValue 或 RandomSuit 方法而出现不同的错误。像在 #start_building_with_chash_build_somethin 中一样生成这些方法。在代码编辑器左侧边缘使用快速操作图标——单击它时,您将看到生成这两个方法的选项:

    Images

    继续生成它们。您的类现在应该有 RandomValue 和 RandomSuit 方法:

    class CardPicker
    {
        public static string[] PickSomeCards(int numberOfCards)
        {
            string[] pickedCards = new string[numberOfCards];
            for (int i = 0; i < numberOfCards; i++)
            {
                pickedCards[i] = RandomValue() + " of " + RandomSuit();
            }
            return pickedCards;
        }
        private static string RandomValue()
        {
            throw new NotImplementedException();
        }
        private static string RandomSuit()
        {
            throw new NotImplementedException();
        }
    }
    
    注意

    您使用 IDE 生成了这些方法。如果它们不按顺序排列也没关系——类中方法的顺序无关紧要。

  3. 使用 return 语句来构建您的 RandomSuit 和 RandomValue 方法。

    一个方法可以有多个 **return** 语句,当执行其中一个语句时,它立即返回,并且不会执行 方法中的任何其他语句

    这是一个利用返回语句的程序示例。想象一下,你正在制作一个卡牌游戏,需要方法来生成随机的卡牌花色或值。我们将从创建一个随机数生成器开始,就像我们在第一章的动物匹配游戏中使用的那样。将其添加到类声明的下面:

     class CardPicker
     {
         static Random random = new Random();
    

    现在,在你的 RandomSuit 方法中添加代码,利用 **return** 语句尽快停止执行方法。随机数生成器的 Next 方法可以接受两个参数:**random.Next(1, 5)** 返回的数字至少为 1,但小于 5(换句话说,从 1 到 4 的随机数)。你的 RandomSuit 方法将使用这个来选择一个随机的纸牌花色:

    图片

    这是一个生成随机值的 RandomValue 方法。看看你能否弄清楚它是如何工作的:

         private static string RandomValue()
         {
             int value = random.Next(1, 14);
             if (value == 1) return "Ace";
             if (value == 11) return "Jack";
             if (value == 12) return "Queen";
             if (value == 13) return "King";
             return value.ToString();
         }
    

    注意我们为什么返回 value.ToString() 而不只是 value?因为 value 是一个整数变量,但 RandomValue 方法声明为字符串返回类型,所以我们需要将 value 转换为字符串。你可以为任何变量或值添加 **.ToString()** 来将其转换为字符串。

    返回语句会导致你的方法立即停止并返回到调用它的语句。

你完成的 CardPicker 类

这是你完成的 CardPicker 类的代码。它应该位于与项目名称匹配的命名空间中:

图片

当你在编写程序的 Main 方法时,请看一下它的返回类型。你觉得那里在发生什么?

安娜正在开发她的下一个游戏

图片

认识一下安娜。她是一名独立游戏开发者。她的上一款游戏销售了成千上万份,现在她正在着手她的下一款游戏。

图片

安娜已经开始着手一些原型。她一直在编写 alien enemies 的代码,这是游戏中一个激动人心的部分,玩家需要在外星人搜索他们的藏身处时逃脱。安娜编写了几种定义敌人行为的方法:搜索玩家被发现的最后位置,一段时间后放弃搜索(如果找不到玩家),并在敌人靠近时捕获玩家。

图片

安娜的游戏正在演变中…

人类对抗外星人的想法挺不错的,但安娜并不完全确定她想要走这个方向。她还在考虑一个海洋游戏,玩家必须躲避海盗。或者可能是一个设定在诡异农场上的僵尸生存游戏。在这三个想法中,她认为敌人会有不同的图形,但它们的行为可以由相同的方法驱动。

图片图片图片

…那么安娜如何让自己更轻松一些呢?

安娜不确定游戏应该朝哪个方向发展,所以她想制作几个不同的原型,并希望它们所有的敌人代码都一样,包括 SearchForPlayer、StopSearching、SpottedPlayer、CommunicatePlayerLocation 和 CapturePlayer 方法。她的工作任务很多。

图片

为一个经典游戏建立一个纸板原型

纸板原型在你开始构建游戏之前,帮助你理清游戏运作方式的非常有用,这能够节省很多时间。有一个快速的开始方法——你只需要一些纸和一支笔或铅笔。首先选择你最喜欢的经典游戏。平台游戏特别适合,所以我们选择了有史以来最受欢迎、最能被认出来的经典视频游戏之一……但你可以选择任何你喜欢的游戏!接下来该做什么,看这里。

画这个!

  1. 在一张纸上画背景。 通过创建背景开始你的原型。在我们的原型中,地面、砖块和管道不会移动,所以我们在纸上画了它们。我们还在顶部添加了得分、时间和其他文本。

  2. 撕下小纸片并画出移动的部分。 在我们的原型中,我们分别在不同的纸片上画了角色、食人花、蘑菇、火焰花和硬币。如果你不是艺术家,没关系!只需画出简单的人物和粗略的形状。别人永远不会看到这些!

  3. “玩”这个游戏。 这是有趣的部分!尝试模拟玩家移动。在页面上拖动玩家。让非玩家角色也移动。花几分钟玩游戏,然后回到你的原型,看看是否能尽可能精确地重现动作。(一开始可能会感觉有点奇怪,但没关系!)

    图片

图片

是的!纸板原型是任何项目的一个很好的第一步。

如果你正在开发桌面应用程序、移动应用程序或任何其他具有用户界面的项目,制作一个纸板原型是一个很好的开始方式。有时候你需要创建几个纸板原型才能掌握窍门。这就是为什么我们从一个经典游戏的纸板原型开始……因为这是学习如何制作纸板原型的一个很好的方式。原型设计是任何类型开发者的一项非常有价值的技能,不仅仅是游戏开发者。

“游戏设计与更多”部分中的所有工具和想法都是重要的编程技能,不仅限于游戏开发,但我们发现当你首次尝试用游戏时,它们更容易学习。

接下来:构建你的卡片选择应用程序的 WPF 版本

在下一个项目中,你将构建一个名为 PickACardUI 的 WPF 应用程序。以下是它的外观:

图片

你的 PickACardUI 应用程序将允许你使用一个滑块控件选择要随机选取的卡片数量。当你选择了卡片数量后,点击按钮即可将它们添加到一个列表框中。

窗口的布局如下:

图片

注意

我们不会一直提醒您将项目添加到源代码控制,但我们仍然认为创建一个 GitHub 账户并将所有项目发布到其中是一个非常好的主意!

图片

注意

本书中所有 WPF 项目都有 ASP.NET Core 版本,其中包含来自 Visual Studio for Mac 的屏幕截图。

注意

转到 Visual Studio for Mac 学习指南,了解此项目的 Mac 版本。

StackPanel 是一个容器,可以堆叠其他控件

您的 WPF 应用程序将使用一个 Grid 来布局其控件,就像您在匹配游戏中使用的一样。在开始编写代码之前,让我们仔细看看网格的左上角单元格中的两个控件:

图片

那么我们怎样才能把它们像那样叠放在一起?我们可以尝试把它们放在网格的同一个单元格中:

   <Grid>
       <Label HorizontalAlignment="*Center*" VerticalAlignment="*Center*" Margin="*20*"
           Content="How many cards should I pick?" FontSize="20"/>
       <Slider VerticalAlignment="*Center*" Margin="*20*"
           Minimum="1" Maximum="15" Foreground="Black"
           IsSnapToTickEnabled="True" TickPlacement="BottomRight" />
   </Grid>
注意

这是 Slider 控件的 XAML。当您组合表单时,我们将更仔细地看一下它。

但这只会导致它们重叠在一起:

图片

这就是StackPanel 控件的作用。StackPanel 是一个容器控件,就像 Grid 一样,它的工作是容纳其他控件并确保它们在窗口中放置在正确的位置。虽然 Grid 允许您将控件排列在行和列中,但 StackPanel 允许您将控件在水平或垂直堆栈中排列

让我们使用相同的 Label 和 Slider 控件,但这次使用 StackPanel 来布局它们,以便 Label 堆叠在 Slider 上。请注意,我们将对齐和边距属性移到了 StackPanel 上——我们希望面板本身居中,并且周围有一些边距:

 <StackPanel HorizontalAlignment="*Center*" VerticalAlignment="*Center*" Margin="*20*" >
     <Label Content="*How many cards should I pick?*" FontSize="*20*" />
     <Slider Minimum="*1*" Maximum="*15*" Foreground="*Black*" IsSnapToTickEnabled="*True*" TickPlacement="*BottomRight*" />
 </StackPanel>

StackPanel 将使单元格中的控件看起来符合我们的要求:

图片

这就是项目的工作方式。现在让我们开始构建它!

在新的 WPF 应用程序中重用您的 CardPicker 类

如果您为一个程序编写了一个类,通常会希望在另一个程序中使用相同的行为。这就是使用类的一个重要优势之一,它们使得重用您的代码更加容易。让我们为您的卡片选择器应用程序提供一个全新的用户界面,但通过重用您的 CardPicker 类来保持相同的行为。

重用这个!

  1. 创建一个名为 PickACardUI 的新 WPF 应用程序。

    您将完全按照创建动物匹配游戏时使用的步骤来完成您的应用程序#start_building_with_chash_build_somethin:

    • 打开 Visual Studio 并创建一个新项目。

    • 选择WPF 应用程序(.NET Core)

    • 将您的新应用程序命名为PickACardUI。Visual Studio 将创建该项目,并添加带有命名空间 PickACardUI 的MainWindow.xamlMainWindow.xaml.cs文件。

  2. 添加您为控制台应用程序项目创建的 CardPicker 类。

    右键单击项目名称,从菜单中选择添加 >> 现有项…

    图片

    导航到包含你的控制台应用程序的文件夹,选择 CardPicker.cs 将它添加到你的项目中。你的 WPF 项目现在应该有一个来自你的控制台应用程序的 CardPicker.cs 文件的副本。

  3. 更改 CardPicker 类的命名空间。

    双击解决方案资源管理器中的 CardPicker.cs。它仍然带有控制台应用程序的命名空间。将命名空间更改以匹配你的项目名称。IntelliSense 弹出窗口将建议 PickACardUI 命名空间 — 按 Tab 键接受建议

    图像

    现在你的 CardPicker 类应该在 PickACardUI 命名空间中:

    namespace PickACardUI
    {
        class CardPicker
        {
    

    恭喜,你已经重用了你的 CardPicker 类! 你应该在解决方案资源管理器中看到这个类,并且你可以在你的 WPF 应用程序的代码中使用它。

使用 Grid 和 StackPanel 布局主窗口

回到#start_building_with_chash_build_somethin,你使用了 Grid 布局你的动物匹配游戏。花几分钟翻回到你布局网格的章节,因为你将使用相同的方法来布局你的窗口。

  1. 设置行和列。 按照#start_building_with_chash_build_somethin 的相同步骤来 添加两行和两列 到你的网格中。如果步骤正确,你应该看到这些行和列的定义就在 XAML 中 <Grid> 标签下面:

     <Grid.RowDefinitions>
         <RowDefinition/>
         <RowDefinition/>
     </Grid.RowDefinitions>
     <Grid.ColumnDefinitions>
         <ColumnDefinition/>
         <ColumnDefinition/>
     </Grid.ColumnDefinitions>
    
    注意

    你可以使用 Visual Studio 的设计器添加两个相等的行和两个相等的列。如果遇到问题,可以直接在编辑器中输入 XAML 代码。

  2. 添加 StackPanel。 在可视化 XAML 设计器中使用空的 StackPanel 有点困难,因为很难点击,所以我们将在 XAML 代码编辑器中完成这个任务。双击工具箱中的 StackPanel,将一个空的 StackPanel 添加到网格中。你应该看到:

           </Grid.ColumnDefinitions>
    
           <StackPanel/>
    
        </Grid>
    
    </Window>
    

    图像

  3. 设置 StackPanel 的属性。 当你在工具箱中双击 StackPanel 时,它添加了一个没有属性的 StackPanel。默认情况下它位于网格的左上角单元格中,所以现在我们只需要设置它的对齐和边距。在 XAML 编辑器中点击 StackPanel 标签来选择它。一旦在代码编辑器中选中它,你会在属性窗口中看到它的属性。将垂直和水平对齐设置为 **Center**,并将所有边距设置为 **20**

    图像

    现在你的 XAML 代码中应该有这样一个 StackPanel:

    <StackPanel HorizontalAlignment="*Center*" VerticalAlignment="*Center*" Margin="*20*" />
    
    注意

    这意味着所有的边距都设置为 20。你可能还会看到 Margin 属性设置为 “20, 20, 20, 20” — 它的意思是一样的。

布局你的 Card Picker 桌面应用程序窗口

布局新卡片选择器应用程序的窗口,使其左侧有用户控件,并在右侧显示选择的卡片。您将在左上角单元格中使用 StackPanel。它是一个 容器,意味着它包含其他控件,就像 Grid 一样。但是它不是将控件放在单元格中,而是垂直或水平地堆叠它们。一旦您的 StackPanel 布局完成,其中包含一个 Label 和一个 Slider,您将添加 ListBox 控件,就像您在#dive_into_chash_statementscomma_classesc 中使用的那个一样。

  1. 向 StackPanel 添加标签和滑块。

    设计它!

    StackPanel 是一个容器。当 StackPanel 不包含任何其他控件时,您无法在设计器中看到它,这使得很难将控件拖放到其上。幸运的是,向其中添加控件与设置其属性一样快速。点击 StackPanel 以选中它。

    图像

    选中 StackPanel,在工具箱中双击 Label,将一个新的标签控件 放置在 StackPanel 内部。标签将出现在设计器中,并在 XAML 代码编辑器中会出现一个**Label**标记。

    接下来,在工具箱中展开所有 WPF 控件部分,并双击滑块。现在您的左上角单元格应该有一个 StackPanel,其中包含一个在顶部堆叠的标签和一个滑块。

  2. 设置标签和滑块控件的属性。

    现在,您的 StackPanel 已经有了一个标签和一个滑块,您只需要设置它们的属性:

    • 在设计器中点击标签。展开“通用”部分,在属性窗口中将其内容设置为**我应该选择多少张卡片?**—然后展开“文本”部分,将其字体大小设置为**20px**

    • 按 Esc 键取消选择 Label,然后在设计器中点击滑块以选中它。使用属性窗口顶部的名称框将其名称更改为**numberOfCards**

    • 展开“布局”部分,使用方形(图像)重置宽度。

    • 展开“通用”部分,并将其最大属性设置为**15**,最小属性设置为**1**,自动工具提示放置设置为**TopLeft**,刻度放置设置为**BottomRight**。然后点击插入点(图像)展开“布局”部分,并显示其他属性,包括 IsSnapToTickEnabled 属性。将其设置为**True**

    • 让刻度线更易于看见。在属性窗口中展开“笔刷”部分,点击前景右侧的大矩形—这将让您使用颜色选择器选择滑块的前景颜色。点击 R 框,并将其设置为**0**,然后将 G 和 B 也设置为**0**。前景框现在应该是黑色,滑块下的刻度线应该是黑色的。

    XAML 应该如下所示——如果您在设计器中遇到问题,可以直接编辑 XAML:

    <StackPanel HorizontalAlignment="*Center*" VerticalAlignment="*Center*" Margin="*20*">
        <Label Content="*How many cards should I pick?*" FontSize="*20*"/>
        <Slider x:Name="*numberOfCards*" Minimum="*1*" Maximum="*15*" TickPlacement="*BottomRight*" IsSnapToTickEnabled="*True*" AutoToolTipPlacement="*TopLeft*" Foreground="*Black*"/>
    </StackPanel>
    
  3. 向左下角单元格添加一个按钮。

    从工具箱拖动一个按钮到网格的左下角,并设置其属性:

    • 展开通用部分并将其内容属性设置为**Pick some cards**

    • 展开文本部分并将其字体大小设置为**20px**

    • 展开布局部分。重置其边距、宽度和高度。然后将其垂直和水平对齐设置为**Center**图像图像)。

      你的按钮控件的 XAML 应该如下所示:

      <Button Grid.Row="*1*" Content="*Pick some cards*" FontSize="*20*"
              HorizontalAlignment="*Center*" VerticalAlignment="*Center*" />
      
  4. 添加一个 ListBox,占据窗口右半部分并跨两行。

    将一个 ListBox 控件拖到右上角的单元格中,并设置其属性:

    • 使用属性窗口顶部的名称框,将 ListBox 的名称设置为**listOfCards**

    • 展开文本部分并将其字体大小设置为**20px**

    • 展开布局部分。将其边距设置为**20**,就像你在 StackPanel 控件中所做的一样。确保其宽度、高度、水平对齐和垂直对齐被重置。

    • 确保行设置为 0,并设置列为 1。然后将 RowSpan 设置为 2,使 ListBox 占据整个列并跨越两行:

      图像

      你的 ListBox 控件的 XAML 应该如下所示:

      <ListBox x:Name="*listOfCards*" Grid.Column="*1*" Grid.RowSpan="*2*"
               FontSize="*20*" Margin="20,20,20,20"/>
      

      如果这个值只是“20”而不是“20, 20, 20, 20”,那意思是一样的。

  5. 设置窗口标题和大小。

    当你创建一个新的 WPF 应用程序时,Visual Studio 会创建一个主窗口,宽度为 450 像素,高度为 800 像素,标题为“主窗口”。让我们调整它的大小,就像你在动物配对游戏中所做的一样:

    • 在设计器中点击窗口的标题栏以选择窗口。

    • 使用布局部分将宽度设置为**300**

    • 使用通用部分将标题设置为**Card Picker**

    滚动到 XAML 编辑器的顶部,查看**Window**标记的最后一行。你应该看到这些属性:

                    Title="*Card Picker*" Height="*300*" Width="*800*"
    
  6. 为你的按钮控件添加一个 Click 事件处理程序。

    代码后端 ——连接到你的 XAML 的MainWindow.xaml.cs中的 C#代码——由一个方法组成。在设计器中双击按钮——IDE 将添加一个名为 Button_Click 的方法,并将其设置为 Click 事件处理程序,就像在#start_building_with_chash_build_somethin 中所做的那样。这是你的新方法的代码:

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        string[] pickedCards = CardPicker.PickSomeCards((int)numberOfCards.Value);
        listOfCards.Items.Clear();
        foreach (string card in pickedCards)
        {
            listOfCards.Items.Add(card);
        }
    }
    

    与你的 XAML 窗口连接的 C#代码,包含事件处理程序,称为代码后端。

    现在运行你的应用程序。 使用滑块选择随机选取的卡片数量,然后按按钮将它们添加到 ListBox 中。干得好!

    图像

Ana 的原型看起来很棒…

Ana 发现,无论她的玩家是被外星人、海盗、僵尸还是邪恶的杀人小丑追逐,她都可以使用与她的 Enemy 类相同的方法来使它们工作。她的游戏开始形成。

图像

…但如果她想要多于一个敌人呢?

而这很棒...直到 Ana 想要更多的敌人,这是她早期原型中的全部内容。她应该怎么做才能在游戏中添加第二或第三个敌人呢?

Ana 可能 将 Enemy 类的代码复制并粘贴到另外两个类文件中。然后她的程序可以使用方法同时控制三个不同的敌人。从技术上讲,我们正在重复使用代码...对吗?

嘿,Ana,你觉得这个主意怎么样?

图片图片

维护三份相同代码真的很混乱。

许多你需要解决的问题需要一种方式来多次表示一个 事物。在这种情况下,它是游戏中的一个敌人,但它可能是音乐播放器应用中的歌曲,或者社交媒体应用中的联系人。它们都有一个共同点:无论处理多少这样的事物,它们总是需要以相同的方式对待相同类型的事物。让我们看看能否找到一个更好的解决方案。

Ana 可以使用对象来解决她的问题

对象 是 C# 中用来处理一堆相似事物的工具。Ana 可以仅编写一次 Enemy 类,并在程序中任意次数使用它。

图片

使用类来构建对象

类就像对象的蓝图。如果你想在郊区住宅开发中建造五座相同的房子,你不会让建筑师绘制五套相同的蓝图。你只需使用一个蓝图来建造五座房子。

图片

类定义其成员,就像蓝图定义房屋的布局一样。你可以使用一个蓝图制作任意数量的房屋,也可以使用一个类制作任意数量的对象。

对象从其类中获取其方法

一旦你建立了一个类,你可以使用 **new** 语句从中创建任意数量的对象。这样做时,类中的每个方法都成为对象的一部分。

图片

当你从一个类创建一个新对象时,它被称为该类的一个实例

使用 new 关键字 创建对象。你只需一个变量来使用它。使用类作为变量类型来声明该变量,所以不是用 int 或 bool,而是像 House 或 Enemy 这样的类。

实例,名词。

一个例子或某事物的一个发生。IDE 的搜索替换功能找到每个 实例 单词并将其更改为另一个。

图片图片

是的!你已经在自己的代码中创建了实例。

回到你的动物匹配程序,找到这行代码:

 Random random = new Random();

你创建了 Random 类的一个实例,然后调用了它的 Next 方法。现在看看你的 CardPicker 类,找到 **new** 语句。你一直在使用对象!

为 Ana 提供的更好解决方案...由对象提供

Ana 使用对象来重用 Enemy 类中的代码,避免了在项目中到处复制代码的混乱。她是这样做的。

  1. Ana 创建了一个名为**enemies**Enemy 数组存储的 Level 类,就像你用字符串数组存储卡片和动物表情符号一样。

    图片

  2. 她使用了一个循环调用**new**语句为关卡创建 Enemy 类的新实例,并将它们添加到一个敌人数组中。

    图片

  3. 她在每帧更新期间调用每个 Enemy 实例的方法来实现敌人的行为。

    图片

当你创建一个类的新实例时,称为实例化该类。

图片

没错,我们确实没有。

一些游戏原型非常简单,而另一些则更加复杂,但复杂的程序 遵循与简单程序相同的模式。Ana 的游戏程序就是一个使用对象的现实生活示例。这不仅适用于游戏开发!无论你构建什么类型的程序,你都会像 Ana 在她的游戏中那样使用对象。Ana 的例子只是把这个概念引入你的大脑的起点。在本章的其余部分,我们将给你 更多的例子 —— 而这个概念如此重要,我们在未来的章节中也会再次讨论它。

理论与实践

谈到模式,这是本书中会反复出现的模式。我们会在几页内介绍一个概念或思想(比如对象),使用图片和简短的代码片段来演示这个想法。这是你有机会退后一步,试着理解发生的事情,而不必担心让程序工作。

图片

一个实例使用字段来跟踪事物

你已经看到类可以包含字段以及方法。我们刚刚看到你如何在 CardPicker 类中使用**static**关键字声明一个字段:

 static Random random = new Random();

如果你去掉**static**关键字会发生什么?然后这个字段就成为了 实例字段,每次实例化类时,新创建的实例 得到它自己的副本

注意

有时候人们认为“实例化”这个词听起来有点奇怪,但是当你想想它的意思时就会明白。

当我们想要在类图中包含字段时,我们会在框中画一条水平线。字段在线上方,而方法在线下方。

图片

方法是对象做什么的。字段是对象知道什么的。

当 Ana 的原型创建了三个 Enemy 类的实例时,每个对象都用于跟踪游戏中的不同敌人。每个实例都保留相同数据的单独副本:在 enemy2 实例上设置字段不会对 enemy1 或 enemy3 实例产生任何影响。

图片

对象的行为由其方法定义,并使用字段来跟踪其状态。

Images

是的!这就是为什么你在声明中使用了 **static** 关键字。

再看一下你的 CardPicker 类的前几行:

class CardPicker
{
    static Random random = new Random();
    public static string PickSomeCards(int numberOfCards)

当你使用 **static** 关键字在类中声明字段或方法时,你不需要该类的实例来访问它。你只需像这样调用你的方法:

    CardPicker.PickSomeCards(numberOfCards)

这是如何调用静态方法的。如果从 PickSomeCards 方法声明中去掉 **static** 关键字,那么你需要创建一个 CardPicker 的实例才能调用该方法。除此之外,静态方法和对象方法一样:它们可以接受参数、返回值,并且存在于类中。

当字段是静态的时,只有一个副本,它被所有实例共享。因此,如果你创建了多个 CardPicker 实例,它们将共享同一个random字段。你甚至可以将整个类标记为静态,那么所有成员必须也是静态的。如果你尝试向静态类添加非静态方法,你的程序将无法构建。

当字段是静态的时候,只有一个副本被所有实例共享。

注意

*= 操作符告诉 C# 使用操作符左侧的内容与右侧的内容相乘,因此这将更新 Height 字段。

感谢记忆

当你的程序创建一个对象时,它会存放在计算机内存的一部分,称为。当你的代码使用 **new** 语句创建一个对象时,C# 立即在堆中预留空间来存储该对象的数据。

Images

当程序创建一个新对象时,它被添加到堆中。

你的程序在想什么

让我们仔细看看“磨砺你的铅笔”练习中的程序,从 Main 方法的第一行开始。实际上这是两个语句合并成一个:

Images

接下来,让我们仔细看看每组语句执行后堆的样子:

Images

有时代码会很难阅读

你可能没有意识到,但你在不断地做出关于如何组织你的代码的选择。你是使用一个方法来完成某件事吗?你把它分成多个方法了吗?你甚至需要一个新的方法吗?你对方法的选择可以使你的代码更加直观——或者如果不小心的话,会变得更加混乱。

这是一个运行制造巧克力棒机器的控制程序中的一小段精简代码:

    int t = m.chkTemp();
    if (t > 160) {
       T tb = new T();
       tb.clsTrpV(2);
       ics.Fill();
       ics.Vent();
       m.airsyschk();
    }

极其精简的代码可能会特别棘手

再看一眼那段代码。你能弄清楚它是做什么的吗?如果你无法弄清楚,别难过——它确实非常难读!以下是几个原因:

  • 我们可以看到几个变量名:**tb**, **ics**, **m**。这些名字太糟糕了!我们不知道它们具体做什么。那个 T 类是用来干嘛的?

  • chkTemp 方法返回一个整数……但它是干什么的?也许我们可以猜测它可能与检查某物的温度有关?

  • clsTrpV 方法有一个参数。我们知道这个参数应该是什么吗?为什么是 2?那个 160 的数字是干什么用的?

图片

C# 和 .NET 无处不在……真的是无处不在

你玩过树莓派吗?它是一款单板低成本电脑,类似它的计算机可以在各种机械中找到。借助 Windows IoT(物联网),您的 C#代码可以在这些设备上运行。有一个免费的原型版本,所以您随时可以开始玩硬件。

您可以在这里了解更多关于.NET IoT 应用的信息:dotnet.microsoft.com/apps/iot

大多数代码并不附带手册

这些声明并不为您提供任何有关代码为何这样做的提示。在这种情况下,程序员因为能够将所有内容放入一个方法中而感到满意。但尽可能使代码尽可能紧凑并不是真正有用的!让我们将其拆分为方法以使其更易读,并确保类名给出有意义的名称。

我们将从搞清楚代码的预期功能开始。幸运的是,我们知道这段代码是嵌入式系统的一部分,或者是较大电气或机械系统的控制器。而且我们碰巧有这段代码的文档——特别是当程序员最初构建系统时使用的手册。

图片

我们可以将代码与告诉我们代码预期功能的手册进行比较。添加注释绝对可以帮助我们理解它预期做什么:

/* This code runs every 3 minutes to check the temperature.
 * If it exceeds 160C we need to vent the cooling system.
 */
int t = m.chkTemp();
if (t > 160) {
   // Get the controller system for the turbines
   T tb = new T();

   // Close throttle valve on turbine #2
   tb.clsTrpV(2);

   // Fill and vent the isolation cooling system
   ics.Fill();
   ics.Vent();

   // Initiate the air system check
   m.airsyschk();
}

在某些地方为代码添加额外的换行可以使其更易读。

使用直观的类和方法名称

那来自手册的页面使我们更容易理解了代码。它还为我们提供了一些使代码更易于理解的好建议。让我们先看一下前两行代码:

/* This code runs every 3 minutes to check the temperature.
 * If it exceeds 160C we need to vent the cooling system.
 */
int t = m.chkTemp();
if (t > 160) {

我们添加的注释解释了很多。现在我们知道为什么条件测试将变量**t**与 160 比较——手册说明,任何高于 160°C 的温度意味着牛轧糖太热了。原来**m**是一个控制糖果制造机的类,具有用于检查牛轧糖温度和检查空气系统的静态方法。

将温度检查放入一个方法中,并选择使类和方法名称显而易见其目的。我们将这前两行移到一个方法中,该方法返回布尔值,如果牛轧糖太热则返回 true,否则返回 false:

/// <summary>
/// If the nougat temperature exceeds 160C it's too hot.
/// </summary>
public bool IsNougatTooHot() {
   int temp = CandyBarMaker.CheckNougatTemperature();
   if (temp > 160) {
     return true;
   } else {
     return false;
   }
}

当我们将类重命名为“CandyBarMaker”,方法重命名为“CheckNougatTemperature”时,代码开始变得更易理解。

注意看,CandyBarMaker 中的 C 是大写的吗?如果我们总是以大写字母开头来命名类名,以小写字母开头来命名变量,那么当您调用静态方法时与使用实例时,就更容易区分了。

您注意到方法上面的特殊///注释了吗?这被称为XML 文档注释。IDE 使用这些注释向您显示方法的文档,就像您使用 IntelliSense 窗口查找 Random 类中的方法时看到的文档一样。

如果 nougat 太热,手册上说要做什么?它告诉我们执行糖果隔离冷却系统(或 CICS)vent 过程。因此,让我们再做一个方法,并为 T 类(控制涡轮)和 ics 类(控制隔离冷却系统,并有两个静态方法填充和 vent 系统)选择一个明显的名称,并在一些简短的 XML 文档中结束:

/// <summary>
/// Perform the Candy Isolation Cooling System (CICS) vent procedure.
/// </summary>
public void DoCICSVentProcedure() {
   TurbineController turbines = new TurbineController();
   turbines.CloseTripValve(2);
   IsolationCoolingSystem.Fill();
   IsolationCoolingSystem.Vent();
   Maker.CheckAirSystem();
}
注意

当您的方法声明为 void 返回类型时,这意味着它不返回任何值,也不需要 return 语句。在上一章中编写的所有方法都使用了 void 关键字!

现在我们有了 IsNougatTooHot 和 DoCICSVentProcedure 方法,我们可以将原来令人困惑的代码重写为单一方法—并且我们可以为它取一个能清楚地表明其功能的名称:

/// <summary>
/// This code runs every 3 minutes to check the temperature.
/// If it exceeds 160C we need to vent the cooling system.
/// </summary>
public void ThreeMinuteCheck() {
   if (IsNougatTooHot() == true) {
   DoCICSVentProcedure();
   }
}

现在代码更加直观了!即使您不知道如果 nougat 太热,CICS vent 过程需要运行,代码正在做什么也更加明显

图像

注意

使用类图来规划您的类

类图是在开始编写代码之前设计您的代码的有价值工具。在图表的顶部写类的名称。然后在底部的框中写每个方法。现在您可以一眼看到类的所有部分—这是您首次发现可能使您的代码难以使用或理解的问题的机会。

图像

没错。当您改变代码的结构而不改变其行为时,这被称为重构。

优秀的开发人员编写的代码尽可能易于理解,即使在很长一段时间后再次查看也是如此。注释可以帮助,但没有什么比为您的方法、类、变量和字段选择直观名称更好的了。

通过考虑您的代码构建的问题,您可以使代码更易于阅读和编写。如果您为您的方法选择了对理解该问题的人有意义的名称,那么您的代码将更容易解释和开发。无论我们如何精心计划我们的代码,我们几乎永远不会一开始就完全正确。

这就是为什么高级开发人员不断重构他们的代码。他们会将代码移到方法中,并赋予它们有意义的名称。他们会重命名变量。每当他们看到不完全明显的代码时,他们会花几分钟来重构它。他们知道现在花时间做这些事情是值得的,因为这会使他们在一小时(或一天、一个月或一年!)后更容易添加更多的代码。

构建一个类来与一些家伙一起工作

乔和鲍勃经常互相借钱。让我们创建一个类来跟踪他们各自有多少现金。我们将从概述我们将构建的内容开始。

  1. 我们将创建两个“Guy”类的实例。

    我们将使用两个 Guy 变量称为**joe****bob**来跟踪我们的每个实例。在它们创建后,堆看起来是这样的:

    图片图片

  2. 我们将设置每个 Guy 对象的 Cash 和 Name 字段。

    这两个对象代表不同的家伙,每个人都有自己的名字和口袋里的现金数量。每个家伙都有一个 Name 字段来跟踪他的名字,以及一个 Cash 字段来记录他口袋里的钱数。

    图片

  3. 我们将添加方法来给和接收现金。

    我们将通过调用他的 GiveCash 方法使一个家伙从口袋里给现金(并减少他的 Cash 字段),该方法将返回他给出的现金量。我们将通过调用他的 ReceiveCash 方法使他接收现金并将其添加到他的口袋中(增加他的 Cash 字段),该方法将返回他接收的现金量。

    图片

class Guy
{
    public string Name;
    public int Cash;

    /// <summary>
    /// Writes my name and the amount of cash I have to the console.
    /// </summary>
    public void WriteMyInfo()
    {
        Console.WriteLine(Name + " has " + Cash + " bucks.");
    }
    /// <summary>
    /// Gives some of my cash, removing it from my wallet (or printing
    /// a message to the console if I don't have enough cash).
    /// </summary>
    /// <param name="amount">Amount of cash to give.</param>
    /// <returns>
    /// The amount of cash removed from my wallet, or 0 if I don't
    /// have enough cash (or if the amount is invalid).
    /// </returns>
    public int GiveCash(int amount)
    {
        if (amount <= 0)
        {
            Console.WriteLine(Name + " says: " + amount + " isn't a valid amount");
            return 0;
        }
        if (amount > Cash)
        {
            Console.WriteLine(Name + " says: " +
                "I don't have enough cash to give you " + amount);
            return 0;
        }
        Cash -= amount;
        return amount;
    }
    /// <summary>
    /// Receive some cash, adding it to my wallet (or printing
    /// a message to the console if the amount is invalid).
    /// </summary>
    /// <param name="amount">Amount of cash to give.</param>
    public void ReceiveCash(int amount)
    {
        if (amount <= 0)
        {
            Console.WriteLine(Name + " says: " + amount + " isn't an amount I'll take");
        }
        else
        {
            Cash += amount;
        }
    }
}
注意

Name 和 Cash 字段跟踪这个家伙的名字和他口袋里有多少现金。

注意

有时候,您想要要求一个对象执行一个任务,比如将其描述打印到控制台上。

注意

GiveCash 和 ReceiveCash 方法验证它们被要求给出或接收的金额是否有效。这样一来,您就不能要求一个家伙接收一个负数,这会导致他损失现金。

将此代码中的注释与 Guy 对象的类图和插图进行比较。如果一开始有些地方不太明白,请花时间真正理解它。

有一种更简单的方法来使用 C#初始化对象

几乎每个您创建的对象都需要以某种方式初始化。Guy 对象也不例外——在设置其 Name 和 Cash 字段之前它是无用的。初始化字段是如此常见,以至于 C#为您提供了一个快捷方式,称为对象初始化器。IDE 的智能感知将帮助您完成它。

您即将进行一个练习,创建两个 Guy 对象。您可以使用一个**new**语句和两个更多的语句来设置它的字段:

joe = new Guy();
joe.Name = "Joe";
joe.Cash = 50;

而是,输入这个:**Guy joe = new Guy() {**

对象初始化器可以节省您的时间,使您的代码更加紧凑和易于阅读……而且 IDE 可以帮助您编写它们。

一旦您添加左花括号,IDE 将弹出 IntelliSense 窗口,显示您可以初始化的所有字段:

图片

选择 Name 字段,将其设置为 50,并添加逗号:

Guy joe = new Guy() { Cash = 50,

现在键入一个空格,另一个 IntelliSense 窗口将弹出,显示剩余的字段设置:

图片

设置 Name 字段并添加分号。现在您有一个初始化对象的单个语句:

Guy joe = new Guy() { Cash = 50, Name = "Joe" };
注意

这个新声明与页面顶部的三行代码执行相同的操作,但更简洁易读。

图片

注意

首先,它将调用每个 Guy 对象的 WriteMyInfo 方法。然后,它将从输入读取一个金额,并询问要将现金给谁。它将调用一个 Guy 对象的 GiveCash 方法,然后调用另一个 Guy 对象的 ReceiveCash 方法。直到用户输入空行为止,它将继续执行。

注意

在开始下一部分练习之前,请确保第一部分运行良好,并理解发生了什么。花几分钟使用调试器逐步执行程序,确保您真正理解。

使用 C# 交互窗口运行 C# 代码

如果您只想运行一些 C# 代码,不必总是在 Visual Studio 中创建新项目。任何输入到C# 交互窗口的 C# 代码都会立即运行。您可以通过选择“视图 >> 其他窗口 >> C# 交互”来打开它。现在尝试一下,并粘贴来自练习解决方案的代码。您可以输入此代码并按 Enter 键运行它:**Program.Main(new string[]**

图片

您也可以从命令行运行交互式的 C# 会话。在 Windows 上,搜索“开始”菜单以获取开发人员命令提示符,启动它,然后输入**csi**。在 macOS 或 Linux 上,运行**csharp**以启动 Mono C# Shell。在这两种情况下,您都可以直接将前面练习中的 Pizzazz、Abracadabra 和 Program 类粘贴到提示符中,然后运行**Program.Main(new string[] {})**来运行您的控制台应用程序的入口点。

第六章:类型和引用:获取引用

图片

没有数据,你的应用程序还能是什么? 想想看。没有数据,你的程序……嗯,实际上很难想象没有数据写代码。你需要用户提供的信息,然后用它来查找或生成新信息返回给他们。事实上,编程中几乎所有的工作都涉及以某种方式处理数据。在本章中,你将深入了解 C#的数据类型引用,看看如何在程序中处理数据,甚至学习更多关于对象的知识(猜猜看……对象也是数据!

欧文需要我们的帮助!

欧文是一名游戏主持人——一个非常出色的游戏主持人。他每周在自己家里组织一群人玩各种不同的角色扮演游戏(或者 RPG),像所有优秀的游戏主持人一样,他非常努力地为玩家们保持趣味性。

图片图片

叙事、幻想和机制

欧文是一个特别优秀的叙述者。在过去几个月里,他为他的队伍创造了一个复杂的幻想世界,但他对他们正在玩的游戏机制并不满意。

我们能找到一种方法帮助欧文改进他的角色扮演游戏吗?

图片

角色卡在纸上存储不同类型的数据

如果你玩过角色扮演游戏,你一定见过角色卡:一张详细记录角色细节、统计数据、背景信息以及其他任何关于角色的笔记的页面。如果你想制作一个类来保存角色卡,你会使用什么类型作为字段?

图片图片

变量的类型决定了它可以存储什么类型的数据。

C#内置了许多类型,你会用它们来存储许多不同种类的数据。你已经见过一些最常见的,比如 int、string、bool 和 float。还有一些你还没见过的,它们也会非常有用。

这里有一些你会经常使用的类型。

图片

  • int 可以存储从–2,147,483,648 到 2,147,483,647 的任何整数。整数没有小数点。

图片

  • float 可以存储从±1.5 × 10^(–45)到±3.4 × 10³⁸的实数,精度高达 8 位有效数字。

图片

  • string 可以存储任意长度的文本(包括空字符串"")。

图片

  • double 可以存储从±5.0 × 10^(–324)到±1.7 × 10³⁰⁸的实数,精度高达 16 位有效数字。当你在处理 XAML 属性时,这是一个非常常见的类型。

图片

  • bool 是布尔值——它只能是 true 或 false。你会用它来表示只有两种选项的任何东西:它要么是一种东西,要么是另一种东西,但没有其他的选项。

C#有几种用于存储整数的类型

C# 有几种不同的整数类型,以及 int。这可能看起来有点奇怪(双关)。为什么有这么多不带小数点的数字类型?对于本书中的大多数程序来说,使用 int 还是 long 并不重要。如果你要编写一个必须跟踪成千上万个整数值的程序,那么选择像 byte 这样的较小整数类型而不是像 long 这样的更大类型可以节省大量内存。

  • byte 可以存储介于 0 到 255 之间的任何整数

  • sbyte 可以存储从 –128 到 127 的任何整数

  • short 可以存储从 –32,768 到 32,767 的任何整数

  • long 可以存储从 –9,223,372,036,854,775,808 到 9,223,372,036,854,775,807 的任何整数

注意

注意我们说的是“整数”,而不是“整数”?我们尽量小心——我们的高中数学老师总是告诉我们,整数是可以不带分数写出来的任何数字,而整数从 0 开始,不包括负数。

图像图像

你注意到 byte 只存储正数,而 sbyte 存储负数了吗?它们都有 256 个可能的值。区别在于,像 short 和 long 一样,sbyte 可以有一个负号——这就是为什么它们被称为有符号类型(sbyte 中的“s”表示有符号)。就像 byte 是 sbyte 的无符号版本一样,还有以“u”开头的 short、int 和 long 的无符号版本:

  • ushort 可以存储从 0 到 65,535 的任何整数

  • uint 可以存储从 0 到 4,294,967,295 的任何整数

  • ulong 可以存储从 0 到 18,446,744,073,709,551,615 的任何整数

图像

用于存储非常巨大和非常微小数字的类型

有时浮点数并不够精确。信不信由你,有时候 10³⁸ 不够大,而 10^(-45) 不够小。很多为金融或科学研究编写的程序经常遇到这些问题,所以 C# 提供了不同的浮点类型来处理巨大和微小的值:

  • float 可以存储从 ±1.5 × 10^(-45) 到 ±3.4 × 10³⁸ 的任何数字,具有 6–9 个有效数字。

  • double 可以存储从 ±5.0 × 10^(-324) 到 ±1.7 × 10³⁰⁸ 的任何数字,具有 15–17 个有效数字。

  • decimal 可以存储从 ±1.0 × 10^(-28) 到 ±7.9 × 10²⁸ 的任何数字,具有 28–29 个有效数字。当你的程序需要处理货币或货币时,始终应该使用 decimal 来存储数字。

注意

decimal 类型具有更高的精度(更多有效数字),这就是为什么它适用于金融计算。

让我们来谈谈字符串

你已经写了处理字符串的代码。那么,字符串到底是什么?

在任何.NET 应用程序中,字符串都是一个对象。它的完整类名是 System.String——换句话说,类名是 String,它位于 System 命名空间中(就像你之前使用的 Random 类一样)。当你使用 C#的string关键字时,你正在使用 System.String 对象。实际上,你可以在到目前为止你写的任何代码中用System.String替换string关键字,它仍然能够工作!(string关键字称为别名——在你的 C#代码中,stringSystem.String意味着相同的东西。)

对于字符串还有两个特殊值:一个空字符串,“”(或者一个没有字符的字符串),和一个 null 字符串,或者一个根本没有设置为任何值的字符串。我们将在本章后面更多地讨论 null。

字符串由字符组成——具体来说,是 Unicode 字符(你将在本书后面学到更多)。有时候你需要存储单个字符,比如**Q****j****$**,这时你会使用char类型。char 的字面值总是在单引号内('**x**``','**3**``')。你也可以在引号内包含转义序列(例如'\n'表示换行,'\t'表示制表符)。你可以在 C#代码中使用两个字符来编写转义序列,但是你的程序会将每个转义序列存储为单个字符在内存中。

最后,还有一个重要的类型:object。如果一个变量的类型是 object,你可以给它赋任何值object关键字也是一个别名——它和System.Object是一样的。

字面值是直接写入你的代码中的值

一个字面值是你在代码中直接写入的数值、字符串或其他固定值。你已经使用了大量的字面值——这里是你使用的一些数字、字符串和其他字面值的例子:

    int number = 15;
    string result = "the answer";
    public bool GameOver = false;
    Console.Write("Enter the number of cards to pick: ");
    if (value == 1) return "Ace";
注意

你能在你之前章节中编写的代码中找到所有的字面值吗?最后一条语句有两个字面值。

所以当你键入int i = 5;时,5是一个字面值。

使用后缀为你的字面值指定类型

当你在 Unity 中添加了像这样的语句时,你可能会对F感到困惑:

    InvokeRepeating("AddABall", 1.5F, 1);

你是否注意到,如果在字面值 1.5F 和 0.75F 中不加上 F,你的程序将无法构建?这是因为字面值有类型。每个字面值都会自动分配一个类型,而 C#有关于如何组合不同类型的规则。你可以亲自看看它是如何工作的。将以下行添加到任何 C#程序中:

    int wholeNumber = 14.7;

当你尝试构建你的程序时,IDE 会在错误列表中显示这个错误:

图片

IDE 告诉你的是字面值 14.7 有一个类型——它是 double 类型。你可以使用后缀来改变它的类型——尝试在末尾加上 F 将其改为 float(14.7F),或者加上 M 将其改为 decimal(14.7M——M 实际上代表“货币”)。错误信息现在说无法转换为 float 或 decimal。添加 D(或者完全不加后缀)错误就消失了。

C#假设没有后缀的整数字面值(例如 371)是 int 类型,带有小数点的(例如 27.4)是 double 类型。

注意

如果您真的想使用保留的关键字作为变量名,请在其前面加上@,但这是编译器允许您接近保留字的最近方式。如果您愿意,也可以用非保留名称做这个。

变量就像一个带走的数据杯

您的所有数据都占用内存空间。(还记得上一章的堆吗?)因此,每当在程序中使用字符串或数字时,您都需要考虑需要多少空间。这就是您使用变量的原因之一。它们让您在内存中预留足够的空间来存储数据。

注意

并非所有数据最终都会在堆上结束。值类型通常将其数据保存在称为堆栈的内存的另一个部分中。您将在本书后面详细了解这一点。

想象一个变量就像是一个杯子,你把数据放进去。C#使用各种不同类型的杯子来存储不同类型的数据。就像咖啡店里不同大小的杯子一样,变量也有不同大小。

图片

使用 Convert 类来探索位和字节

转换这个!

您一直听说编程是关于 1 和 0。.NET 有一个静态 Convert 类,用于在不同的数值数据类型之间进行转换。让我们使用它来看看位和字节如何工作的示例。

位是单个的 1 或 0。字节是 8 位,所以字节变量存储一个 8 位数,这意味着它是一个可以用最多 8 位表示的数字。这是什么样子?让我们使用 Convert 类将一些二进制数转换为字节:

 Convert.ToByte("10111", 2) // returns 23
 Convert.ToByte("11111111", 2); // returns 255
注意

Convert.ToByte 的第一个参数是要转换的数字,第二个是其进制。二进制数是基数 2。

字节可以存储 0 到 255 之间的数字,因为它们使用 8 位内存——8 位数字是二进制数 0 到 11111111(或十进制 0 到 255)。

short 是一个 16 位值。让我们使用 Convert.ToInt16 将二进制值 111111111111111(15 个 1)转换为 short。int 是一个 32 位值,所以我们将使用 Convert.ToInt32 将 31 个 1 转换为 int:

 Convert.ToInt16("111111111111111", 2); // returns 32767
 Convert.ToInt32("1111111111111111111111111111111", 2); // returns 2147483647

其他类型也有不同的大小

具有小数位的数字与整数存储方式不同,不同的浮点类型占用不同数量的内存。您可以使用float处理大多数具有小数位的数字,它是存储小数的最小数据类型。如果需要更精确,请使用double。如果您编写的是一个存储货币值的财务应用程序,您应该始终使用decimal类型。

哦,还有一件事:不要用 double 来处理货币或货币,只用 decimal

图片

我们已经谈到字符串,所以你知道 C# 编译器还可以处理字符和非数字类型。char 类型用于存储一个字符,而 string 用于“串”在一起的多个字符。字符串对象没有固定大小——它会扩展以容纳所需存储的数据量。布尔数据类型用于存储 true 或 false 值,就像你为你的 **if** 语句使用的那样。

图像

不同的浮点类型占用不同的内存:float 最小,decimal 最大。

5 磅的袋子装下了 10 磅的数据

图像

当你将变量声明为一种类型时,C# 编译器分配(或保留)所有存储最大值所需的内存。即使值远远不接近你声明的类型的上界,编译器也会看到它的杯子,而不是内部的数字。因此,这不会起作用:

 int leaguesUnderTheSea = 20000;
 short smallerLeagues = leaguesUnderTheSea;

20,000 可以毫无问题地放入一个 short 类型中。但是因为 **leaguesUnderTheSea** 被声明为一个 int,C# 会看到它是一个 int 大小,并认为它太大,无法放入一个 short 容器。编译器不会在你飞行时进行这些转换。你需要确保你使用正确的类型来处理数据。

图像

强制转换允许你复制那些 C# 不能自动转换为另一种类型的值

让我们看看当你尝试将一个小数值分配给一个整型变量时会发生什么。

这样做!

  1. 创建一个新的控制台应用程序项目,并将此代码添加到 Main 方法中:

          float myFloatValue = 10;
          int myIntValue = myFloatValue;
          Console.WriteLine("myIntValue is " + myIntValue);
    
    注意

    隐式转换意味着 C# 有一种方式可以自动将一个值转换为另一种类型而不丢失信息。

  2. 试着编译你的程序。你应该会遇到与之前看到的相同的 CS0266 错误:

    图像

    仔细观察错误消息的最后几句话:“你缺少一个强制转换?”这是 C# 编译器为你提供的非常有用的提示,告诉你如何解决这个问题。

  3. 通过强制转换将小数转换为整型来消除错误。你这样做的方法是添加你想要转换的类型的括号:**(int)**。一旦你将第二行更改为看起来像这样,你的程序就会编译并运行:

    图像

    注意

    当你将一个浮点值强制转换为整型时,它会将值向下取整到最接近的整数。

发 那么,发生了什么?

C# 编译器不会允许你将一个值分配给一个变量,如果它的类型错误——即使这个变量可以很好地容纳这个值!事实证明,大量的错误都是由于类型问题引起的,而编译器正在帮助通过引导你朝正确方向迈进。当你使用强制转换时,你基本上是在告诉编译器,你知道类型不同,并承诺在这个特定实例中,将数据塞入新的变量是可以的。

当你强制转换一个值太大的时候,C# 会调整它以适应它的新容器

你已经看到了十进制可以转换为整数。其实任何数字都可以转换为任何其他数字。但这并不意味着数值在转换过程中保持不变。假设你有一个整数变量设置为 365。如果你将它转换为字节变量(最大值为 255),不会报错,而是将数值环绕。256 转换为字节将具有值为 0,25 将转换为 1,258 为 2,以此类推,直到 365,最终变为109。一旦再次达到 255,转换值将“环绕”回零。

如果您使用+(或*******/****-**)来连接两种不同的数值类型,运算符会自动将较小的类型转换为较大的类型。这里有一个例子:

 int myInt = 36;
 float myFloat = 16.4F;
 myFloat = myInt + myFloat;

由于整数可以容纳在浮点数中,但浮点数不能容纳在整数中,因此+运算符在将**myInt**添加到**myFloat**之前将**myInt**转换为浮点数。

注意

您可以在此处阅读有关不同 C#值类型的更多信息——这值得一看: docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/value-types

图片

是的!当您连接字符串时,C#会转换值。

当您使用+运算符将字符串与另一个值组合时,称为连接。当您将字符串与 int、bool、float 或其他值类型连接时,它会自动转换该值。这种转换与强制转换不同,因为在底层它实际上调用了值的 ToString 方法……并且.NET 保证的一件事情是每个对象都有一个 ToString 方法,用于将其转换为字符串(但这取决于各个类是否能够产生有意义的字符串)。

注意

请自行测试

如何“环绕”转换数字并没有什么神秘的——您可以自己做。只需打开任何带有 Mod 按钮的计算器应用程序(执行模数计算——有时在科学模式下),并计算 365 Mod 256。

C#会自动进行一些转换

有两个重要的转换不需要您进行强制转换。第一个是在使用算术运算符时自动进行的转换,就像这个例子中所示:

图片

C#另一种自动为您转换类型的方式是当您使用+运算符连接字符串(这意味着将一个字符串粘贴到另一个字符串的末尾,就像您一直在使用的消息框一样)。当您使用+将字符串与另一种类型连接时,它会自动为您转换数字为字符串。这里有一个例子——尝试将这些行添加到任何 C#程序中。前两行没问题,但第三行无法编译:

   long number = 139401930;
   string text = "Player score: " + number;
   text = number;

在第三行上,C#编译器会给您报错:

图片

ScoreText.text 是一个字符串字段,因此当你使用 + 运算符连接一个字符串时,它会正常分配值。但是当你尝试直接将**x**赋给它时,它无法自动将长整型值转换为字符串。你可以通过调用它的 ToString 方法将其转换为字符串。

调用方法时,参数需要与参数类型兼容

在上一章中,你使用 Random 类来从 1 到(但不包括)5 中选择一个随机数,用来选择一张扑克牌的花色:

 int value = random.Next(1, 5);

尝试将第一个参数从**1**更改为**1.0**

Images

你正在向一个期望接收整数值的方法传递一个双精度浮点数文字。所以你不应该对编译器无法构建你的程序感到惊讶——相反,它会显示一个错误:

Images

有时候 C# 可以自动进行转换。它不知道如何将双精度浮点数转换为整数(例如将 1.0 转换为 1),但它确实知道如何将整数转换为双精度浮点数(例如将 1 转换为 1.0)。更具体地说:

  • C# 编译器知道如何将整数转换为浮点类型。

  • 它知道如何将一个整数类型转换为另一个整数类型,或将一个浮点类型转换为另一个浮点类型。

  • 但是它只能在要转换的类型大小与或小于要转换为的类型时进行这些转换。因此,它可以将整数转换为长整数或浮点数转换为双精度浮点数,但它无法将长整数转换为整数或双精度浮点数转换为浮点数。

但 Random.Next 并不是唯一一个如果你试图向其传递类型与参数不匹配的变量将会得到编译器错误的方法。所有方法都会这样做,甚至是你自己写的方法。将此方法添加到控制台应用程序:

public int MyMethod(bool add3) {
   int value = 12;

   if (add3) 
      value += 3;
   else 
      value -= 2;

   return value;

}

当编译器给出“无效参数”错误时,意味着你试图调用一个方法,并且变量类型与方法参数不匹配。

尝试传递一个字符串或长整数给它——你将会得到一个 CS1503 错误,告诉你它无法将参数转换为布尔值。有些人很难记住参数和参数的区别。因此,为了明确起见:

参数是你在方法中定义的内容。参数是你传递给它的内容。你可以将一个字节参数传递给具有整数参数的方法。

欧文不断改进他的游戏…

优秀的游戏主持人致力于为玩家创造最佳体验。欧文的玩家们即将开始一场全新的战役,使用全新的角色,他认为对他们的能力值分配公式进行一些调整可能会让事情更有趣。

Images

…但是试错方法可能会耗费时间。

欧文一直在尝试调整能力值计算的方法。他相当确定自己的公式大部分是正确的——但他真的很想调整这些数字。

Images

Owen 喜欢总体公式:4d6 掷骰、除法、减法、向下取整、使用最小值……但他不确定实际数字是否正确。

图片

让我们帮助 Owen 实验能力分数

在下一个项目中,你将构建一个 .NET Core 控制台应用程序,Owen 可以使用它测试不同值的能力分数公式,以查看它们如何影响结果分数。这个公式有四个输入起始的 4d6 掷骰结果,被这个结果除的除数值,要加到这个除法结果的添加量值,以及如果结果太小要使用的最小值

Owen 将每个四个输入都输入到应用程序中,并且应用程序将使用这些输入计算能力分数。他可能希望测试一系列不同的值,因此我们将通过重复要求新值来使用应用程序,直到他退出应用程序为止,同时跟踪每次迭代中使用的值,并使用这些先前的输入作为下一次迭代的默认值

当 Owen 运行应用程序时,它看起来是这样的:

图片

这个项目比你之前构建的控制台应用程序要大一点,因此我们将分几步来解决它。首先,你将磨一磨你的铅笔,理解计算能力分数的代码,然后进行一个练习,为应用程序编写其余的代码,最后你将揭开代码中的一个错误。让我们开始吧!

使用 C# 编译器找到有问题的代码行

创建一个名为 AbilityScoreTester 的新 .NET Core 控制台应用程序项目。然后添加 AbilityScoreCalculator 类并使用“磨笔刀”练习中的代码。如果你正确输入了代码,你应该会看到一个 C# 编译器错误:

图片

每当 C# 编译器给出错误时,请仔细阅读。它通常会有一个提示,可以帮助你找到问题的根源。在这种情况下,它确切地告诉我们出了什么问题:它无法在没有强制转换的情况下将 double 转换为 int。**divided**变量被声明为 double,但是 C# 不允许你将其添加到像 AddAmount 这样的 int 字段中,因为它不知道如何进行转换。

当 C# 编译器询问“你是否缺少一个强制类型转换?”时,它实际上是在提示你,在将双精度变量**divided**加到整型字段 AddAmount 之前,你需要显式地进行强制类型转换。

添加一个强制类型转换以使 AbilityScoreCalculator 类编译通过...

现在你知道问题出在哪里了,你可以添加一个强制类型转换来修复 AbilityScoreCalculator 中有问题的代码行。这是生成“无法隐式转换类型”错误的代码行:

   int added = AddAmount += divided;

它导致该错误,因为**AddAmount += divided**返回一个 double 值,无法赋给 int 变量**added**

你可以通过将**divided**强制转换为一个 int来修复它,这样将它添加到 AddAmount 就会返回另一个 int。修改代码行以将**divided**更改为**(int)**``**divided**

图片

添加该转换还解决了欧文能力评分公式的缺失部分:

*向最近的整数舍入

当你将双精度数强制转换为整数时,C# 会将其向下舍入——例如 **(int)19.7431D** 给了我们 **19**。通过添加该转换,你正在添加能力评分公式中缺失的步骤。

…但是仍然存在一个 bug!

我们还没有完成!你解决了编译器错误,所以现在项目可以构建了。但是尽管 C# 编译器会接受它,仍然存在一个问题。你能发现那行代码中的 bug 吗?

注意

看起来我们还不能填写“磨你的铅笔”答案!

图片图片

欧文,你是对的。代码中有一个 bug。

欧文想尝试不同的值来使用他的能力评分公式,所以我们使用循环使应用程序一遍又一遍地请求这些值。

为了让欧文更容易一次只更改一个值,我们在应用程序中包含了一个功能,记住他输入的最后值,并将它们作为默认选项呈现出来。我们通过在内存中保持 AbilityScoreCalculator 类的实例,并在每次**while**循环的迭代中更新其字段来实现该功能。

但是应用程序出了些问题。它记住了大部分的值,但对于“添加数量”的默认值记住了错误的数字。在第一次迭代中,欧文输入了 5,但它给了他 10 作为默认选项。然后他输入了 7,但它给了 12 作为默认值。出了什么问题?

现在我们终于可以修复欧文的 bug

现在你知道发生了什么,你可以修复 bug——结果证明只需要做一个非常小的改变。你只需将语句更改为使用 + 而不是 +=:

int added = AddAmount + (int)divided;
注意

将 += 更改为 + 以防止这行代码更新“添加”的变量并修复 bug。就像福尔摩斯会说的,“这是基本的。”

注意

现在我们找到了问题,我们终于可以提供“磨你的铅笔”解决方案了。

图片

注意

试试这个!

尝试将这个 **if/else** 语句添加到控制台应用程序中:

图片

接下来,将浮点文字更改为双精度数(记住,诸如 0.1 的文字默认为双精度数):

图片

这真的很奇怪。警告移到了**if**语句的第一行。尝试运行程序。等等,那不可能对!它向控制台打印了 **They aren't equal**。0.1 + 0.2 怎么会不等于 0.3?

现在再做一件事。将 0.3 更改为 0.30000000000000004(在 3 和 4 之间有 15 个零)。现在它再次打印 **They’re equal**。所以显然 0.1D 加上 0.2D 等于 0.30000000000000004D。

注意

等等,什么?!

图片

注意

没错。十进制比双精度数或浮点数具有更高的精度,因此它避免了 0.30000000000000004 问题。

一些浮点数类型——不仅在 C#中,在大多数编程语言中!——可能会给你带来罕见的奇怪错误。这太奇怪了!0.1 + 0.2 怎么会是 0.30000000000000004 呢?

事实证明,有一些数字无法被双精度浮点数准确表示——这与它们以二进制数据(在内存中的 0 和 1)存储方式有关。例如,.1D并不是确切.1。尝试将**.1D * .1D**相乘——你得到的是 0.010000000000000002,而不是 0.01。但是**.1M * .1M**会得到正确的答案。这就是为什么浮点数和双精度浮点数在许多场景中非常有用(比如在 Unity 中定位游戏对象)。如果你需要更严格的精度——比如财务应用程序处理金钱——那么应该使用十进制类型。

使用引用变量来访问你的对象

当你创建一个新对象时,你使用**new**语句来实例化它,比如在上一章的程序末尾中使用的**new Guy()**——**new**语句在堆上创建了一个新的 Guy 对象。但是你仍然需要一种方式来访问该对象,这就是**joe**这样的变量发挥作用的地方:**Guy joe = new Guy()**。让我们深入了解一下这其中的具体情况。

**new**语句创建实例,但仅仅创建实例是不够的。你需要一个对象的引用。因此,你创建了一个引用变量:一个 Guy 类型的变量,例如**joe**。因此,**joe**是你创建的新 Guy 对象的一个引用。每当你想使用特定的 Guy 时,你可以通过名为**joe**的引用变量来引用它。

当你有一个对象类型的变量时,它是一个引用变量:指向特定对象的引用。我们来确保术语使用正确,因为我们会经常用到。我们将使用上一章节“Joe 和 Bob”程序的前两行:

图片

引用就像对象的便利贴

在你的厨房里,你可能有盐和糖的容器。如果你交换它们的标签,可能会让人觉得很恶心——尽管你改变了标签,但容器里的内容还是一样的。引用就像标签。 你可以移动标签并将其指向不同的东西,但真正决定方法和数据可用性的是对象本身,而不是引用本身——你可以像复制值一样复制引用

图片

引用就像是你的代码用来谈论特定对象的标签。你可以用它来访问字段和调用对象的方法。

我们在那个对象上贴了很多便利贴!在这种情况下,有很多不同的引用指向同一个 Guy 对象——因为很多不同的方法用它来做不同的事情。每个引用在其上下文中都有一个不同的名称,这是有意义的。

这就是为什么有多个引用指向同一个实例非常有用。所以你可以说**Guy dad = joe**,然后调用**dad.GiveCash()**(这是 Joe 的孩子每天都在做的事)。如果你想编写与对象一起工作的代码,你需要一个指向该对象的引用。如果没有这个引用,你就无法访问对象。

如果没有更多的引用,你的对象将被垃圾回收

如果一个对象的所有标签都被移除,程序将无法再访问该对象。这意味着 C#可以标记该对象进行垃圾回收。这时,C#会清除任何未被引用的对象,并回收这些对象占用的内存,以便程序使用。

  1. 这里有一些创建对象的代码。

    简要回顾一下我们讨论的内容:当使用**new**语句时,你在告诉 C#创建一个对象。当你把像**joe**这样的引用变量赋给该对象时,就像是在它上面贴了一张新的便签。

       Guy joe = new Guy() { Cash = 50, Name = "Joe" };
    

    图片

  2. 现在让我们创建我们的第二个对象。

    一旦我们这样做了,我们将有两个 Guy 对象实例和两个引用变量:一个变量(**joe**)指向第一个 Guy 对象,另一个变量(**bob**)指向第二个。

       Guy bob = new Guy() { Cash = 100, Name = "Bob" };
    

    图片

  3. 让我们把对第一个 Guy 对象的引用改为指向第二个 Guy 对象。

    仔细看看当你创建一个新的 Guy 对象时你在做什么。你正在取一个变量,并使用=赋值运算符来设置它——在这种情况下,设置为**new**语句返回的引用。这种赋值之所以有效,是因为你可以像复制值一样复制引用

    所以让我们继续复制那个值:

     joe = bob;
    

    这告诉 C#让**joe**指向与**bob**指向的同一个对象。现在**joe****bob**变量都指向同一个对象

    图片

  4. 再也没有指向第一个 Guy 对象的引用了……所以它被垃圾回收了。

    现在joe指向与bob相同的对象,不再有指向原来的Guy对象的引用了。那么会发生什么?C#标记该对象进行垃圾回收,并最终将其销毁。噗——它消失了!

    注意

    CLR 跟踪每个对象的所有引用,当最后一个引用消失时,它标记为可删除。但它可能有其他事情要做,所以对象可能会保留几毫秒甚至更长时间!

    图片

要使对象保留在堆中,必须引用它。当对对象的最后一个引用消失后一段时间,该对象也会消失。

图片

多个引用及其副作用

当你开始移动引用变量时,你必须小心。很多时候,看起来你只是将一个变量指向一个不同的对象。在这个过程中,你可能会移除对另一个对象的所有引用。这不是件坏事,但可能不是你打算的。看一看:

  1. Dog rover = new Dog();
    rover.Breed = "Greyhound";
    

    图片

  2. Dog fido = new Dog();
    fido.Breed = "Beagle";
    Dog spot = rover;
    

    图片

  3. Dog lucky = new Dog();
    lucky.Breed = "Dachshund";
    fido = rover;
    

    图片

两个引用意味着可以改变同一个对象的数据的两个变量

除了失去对象的所有引用外,当你有多个引用指向一个对象时,你可能会无意中改变该对象。换句话说,一个引用可能会改变该对象,而另一个引用则不知道发生了什么变化。让我们看看这是如何工作的。

做这个!

图片

现在,继续运行你的程序。这是你将看到的:

You pressed 4
My name is Lucinda
My ears are 4321 inches tall.

You pressed 1
Calling lloyd.WhoAmI()
My name is Lucinda
My ears are 4321 inches tall.

You pressed 2
Calling lucinda.WhoAmI()
My name is Lucinda
My ears are 4321 inches tall.
注意

程序正常运行... 直到按下 4。一旦这样做,按 1 或 2 打印相同的输出 - 按 3 交换引用不再起作用。

在按下 4 并运行你添加的新代码之后,**lloyd**和 lucinda 变量都包含对第二只大象对象的相同引用。按 1 调用 lloyd.WhoAmI 将打印与按 2 调用**lucinda**.WhoAmI 完全相同的消息。交换它们不会有任何差别,因为你在交换两个相同的引用。

对象使用引用来彼此交谈

到目前为止,你已经看到表单通过使用引用变量调用它们的方法和检查它们的字段来与对象交谈。对象也可以使用引用调用彼此的方法。事实上,没有什么是表单可以做而你的对象不能做的,因为你的表单只是另一个对象。当对象彼此交谈时,它们拥有的一个有用关键字是**this**。任何时候一个对象使用**this**关键字,它都在引用自己 - 它是一个指向调用它的对象的引用。让我们通过修改大象类以便实例可以调用彼此的方法来看看它是什么样子。

图片

  1. 添加一个让大象听到消息的方法。

    让我们向大象类添加一个方法。它的第一个参数是另一个大象对象发送的消息。第二个参数是发送消息的大象对象:

    做这个!

     public void HearMessage(string message, Elephant whoSaidIt) {
          Console.WriteLine(Name + " heard a message");
          Console.WriteLine(whoSaidIt.Name + " said this: " + message);
     }
    

    当它被调用时,它看起来是这样的:

     lloyd.HearMessage("Hi", lucinda);
    

    我们调用了**lloyd's** HearMessage 方法,并传递了两个参数:字符串**"Hi"**和 Lucinda 对象的引用。该方法使用其**whoSaidIt**参数来访问传入的任何大象的 Name 字段。

  2. 添加一个让大象发送消息的方法。

    现在让我们向大象类添加一个 SpeakTo 方法。它使用一个特殊的关键字:**this**。这是一个让对象获取对自己的引用的关键字。

    public void SpeakTo(Elephant whoToTalkTo, string message) {
        whoToTalkTo.HearMessage(message, this);
    }
    
    注意

    大象的 SpeakTo 方法使用“this”关键字将自己的引用发送给另一个大象。

    让我们更仔细地看看发生了什么。

    当我们调用卢辛达对象的 SpeakTo 方法时:

     lucinda.SpeakTo(lloyd, "Hi, Lloyd!");
    

    它这样调用了 Lloyd 对象的 HearMessage 方法:

    whoToTalkTo.HearMessage("Hi, Lloyd!", this);
    
    注意

    卢辛达使用 whoToTalkTo(它引用了 Lloyd)来调用 HearMessage。

    注意

    this 被替换为对卢辛达对象的引用。

    注意
    *[a reference to Lloyd]*.HearMessage("Hi, Lloyd!", [*a reference to Lucinda*]);
    
  3. 调用新方法。

    在 Main 方法中再添加一个 **else if** 块,让卢辛达对象向 Lloyd 对象发送一条消息:

    else if (input == '4')
    {
        lloyd = lucinda;
        lloyd.EarSize = 4321;
        lloyd.WhoAmI();
    }
    else if (input == '5')
    {
        lucinda.SpeakTo(lloyd, "Hi, Lloyd!");
    }
    else
    {
        return;
    }
    

    现在运行你的程序并按 5. 你应该看到以下输出:

    You pressed 5
    Lloyd heard a message
    Lucinda said this: Hi, Lloyd!
    
  4. 使用调试器理解正在发生的事情。

    在你刚刚添加到 Main 方法的语句上设置一个断点:

    图像

    1. 运行你的程序并按 5。

    2. 当它触发断点时,使用调试 >> 逐步进入(F11)来进入 SpeakTo 方法。

    3. 添加一个监视器来显示你所处的“大象”对象的名称。你当前位于“卢辛达”对象内部,这是有道理的,因为主方法调用了 lucinda.SpeakTo。

    4. 悬停在行尾的 **this** 关键字上并展开它。这是对卢辛达对象的引用。

      图像

      悬停在 **whoToTalkTo** 上并展开它 —— 它是对 Lloyd 对象的引用。

    5. SpeakTo 方法只有一条语句 —— 它调用 whoToTalkTo.HearMessage。进入它。

    6. 现在你应该位于 HearMessage 方法内部。再次检查你的监视器 —— 现在 Name 字段的值是“Lloyd” —— 卢辛达对象调用了 Lloyd 对象的 HearMessage 方法。

    7. 悬停在 **whoSaidIt** 上并展开它。这是对卢辛达对象的引用。

完成代码的逐步执行。花几分钟真正理解发生了什么。

“this” 关键字让对象获得对自身的引用。

数组保存多个值

注意

字符串和数组不同于本章中看到的其他数据类型,因为它们是唯一没有固定大小的数据类型(想一想这个)。

如果你必须跟踪大量相同类型的数据,比如价格列表或一组狗,你可以用一个 数组。数组的特殊之处在于它是作为一个对象对待的一组变量。数组为你提供了一种存储和修改多个数据而无需单独跟踪每个变量的方法。当你创建一个数组时,你像声明任何其他变量一样,使用一个名称和一个类型 —— 但是 类型后面跟着方括号

bool[] myArray;

使用 **new** 关键字创建数组。让我们创建一个有 15 个布尔元素的数组:

myArray = new bool[15];

使用方括号设置数组中的一个值。该语句通过使用方括号并指定 索引 4,将 **myArray** 的第五个元素的值设置为 **true**。它是第五个,因为第一个是 **myArray[0]**,第二个是 **myArray[1]**,依此类推:

myArray[4] = false;

像普通变量一样使用数组中的每个元素

注意

使用new关键字来创建数组,因为它是一个对象——所以数组变量是一种引用变量。在 C#中,数组是从零开始的,这意味着第一个元素的索引是 0。

当你使用数组时,首先需要声明一个引用变量指向数组。然后你需要使用**new**语句创建数组对象,指定你想要的数组大小。然后你可以设置数组中的元素。下面是一个声明并填充数组的示例代码——以及在执行时堆上的情况。数组中的第一个元素的索引是 0。

**// declare a new 7-element decimal array**
decimal[] prices = new decimal[7];
prices[0] = 12.37M;
prices[1] = 6_193.70M;
**// we didn't set the element**
**// at index 2, it remains**
**// the default value of 0**
prices[3] = 1193.60M;
prices[4] = 58_000_000_000M;
prices[5] = 72.19M;
prices[6] = 74.8M;

图片

数组可以包含引用变量

就像你创建数字或字符串数组一样,你也可以创建一个对象引用的数组。数组并不关心它们存储的变量类型;这由你决定。所以你可以有一个 int 数组,或者一个 Duck 对象数组,都没有问题。

下面是创建七个 Dog 变量数组的代码。初始化数组的行仅创建引用变量。由于只有两行**new Dog()**,实际只创建了两个 Dog 类的实例。

当你设置或检索数组中的元素时,方括号中的数字称为索引。数组中的第一个元素的索引是 0。

图片

注意

数组的长度

你可以使用数组的 Length 属性来查找数组中的元素数量。所以如果你有一个名为“prices”的数组,那么你可以使用 prices.Length 来查找它的长度。如果数组中有七个元素,那么它将给出 7——这意味着数组元素从 0 到 6 编号。

null 意味着引用指向空值

还有一个重要的关键字,你会在处理对象时使用它。当你创建一个新的引用并且没有设置它时,它有一个值。它最初被设置为**null**,这意味着它没有指向任何对象。让我们仔细看看这个:

图片图片

注意

是的。null 关键字非常有用。

在典型程序中,你会看到**null**被使用的几种方式。最常见的方式是确保一个引用指向一个对象:

 **if (lloyd == null) {**

如果**lloyd**引用被设置为**null**,那个测试将返回**true**

另一种你会看到**null**关键字的用法是当你希望你的对象被垃圾收集时。如果你有一个对象的引用并且你完成了对对象的使用,将引用设置为**null**将立即将其标记为可回收(除非它在其他地方有其他引用)。

注意

如果你从未玩过 Go Fish,请花几分钟阅读规则。我们稍后会用到它们!

即使我们不为视频游戏编写代码,我们仍然可以从桌面游戏中学到很多。

很多我们的程序依赖于随机数。例如,你已经使用了 Random 类为几个应用程序生成随机数。我们大多数人在真实世界中并没有太多真正的随机数经验……除非我们玩游戏。掷骰子、洗牌、旋转转盘、抛硬币……这些都是随机数生成器的绝佳示例。Random 类是.NET 的随机数生成器——你将在许多程序中使用它,而你在玩桌面游戏时积累的随机数使用经验将使你更容易理解它的工作原理。

图片

  1. 创建一个新的控制台应用—所有这些代码都将放在 Main 方法中。首先创建一个 Random 的新实例,生成一个随机整数,并将其写入控制台:

     Random random = new Random();
     int randomInt = random.Next();
     Console.WriteLine(randomInt);
    

    指定一个最大值来获取从 0 到最大值(但不包括最大值)的随机数。最大值为 10 将生成从 0 到 9 的随机数:

     int zeroToNine = random.Next(10);
     Console.WriteLine(zeroToNine);
    
  2. 现在模拟掷骰子。你可以指定一个最小值和一个最大值。最小值为 1,最大值为 7 将生成从 1 到 6 的随机数:

     int dieRoll = random.Next(1, 7);
     Console.WriteLine(dieRoll);
    

    图片

  3. NextDouble 方法生成随机的双精度值。将鼠标悬停在方法名上可以看到工具提示——它生成从 0.0 到 1.0 的浮点数:

     double randomDouble = random.NextDouble();
    

    图片

    你可以使用乘以一个随机双精度数来生成更大的随机数。所以,如果你想要一个从 1 到 100 的随机双精度值,就将随机双精度数乘以 100:

     Console.WriteLine(randomDouble * 100);
    

    使用强制类型转换将随机双精度数转换为其他类型。试着运行这段代码多次——你会看到浮点数和十进制值之间的微小精度差异。

     Console.WriteLine((float)randomDouble * 100F);
     Console.WriteLine((decimal)randomDouble * 100M);
    
  4. 使用最大值为 2 来模拟抛硬币。这将生成一个随机值,要么是 0,要么是 1。使用特殊的Convert 类,它有一个静态的 ToBoolean 方法,可以将其转换为布尔值:

     int zeroOrOne = random.Next(2);
     bool coinFlip = Convert.ToBoolean(zeroOrOne);
     Console.WriteLine(coinFlip);
    

欢迎来到 Sloppy Joe's Budget House o' Discount Sandwiches!

Sloppy Joe 有一堆肉、大量面包和比你能摇动的棍子更多的调味品。但他没有菜单!你能建立一个每天为他制作新的随机菜单的程序吗?当然可以……借助新的 WPF 应用、一些数组和几种有用的新技术。

注意

去做这件事!

  1. 向项目添加一个新的 MenuItem 类,并添加其字段。

    看一下类图。它有四个字段:一个 Random 的实例和三个数组,用来保存各种三明治的部分。这些数组字段使用了集合初始化器,通过将项放在大括号内来定义数组中的项目。

    图片

    class MenuItem
    {
        public Random Randomizer = new Random();
    
        public string[] Proteins = { "Roast beef", "Salami", "Turkey",
                    "Ham", "Pastrami", "Tofu" };
        public string[] Condiments = { "yellow mustard", "brown mustard",
                    "honey mustard", "mayo", "relish", "french dressing" };
        public string[] Breads = { "rye", "white", "wheat", "pumpernickel", "a roll" };
    
               public string Description = "";
               public string Price;
    }
    
  2. 将 GenerateMenuItem 方法添加到 MenuItem 类中。

    此方法使用了你已经多次见过的 Random.Next 方法,从 Proteins、Condiments 和 Breads 字段的数组中随机选择项目,并将它们连接成一个字符串。

    public void Generate()
    {
        string randomProtein = Proteins[Randomizer.Next(Proteins.Length)];
        string randomCondiment = Condiments[Randomizer.Next(Condiments.Length)];
        string randomBread = Breads[Randomizer.Next(Breads.Length)];
        Description = randomProtein + " with " + randomCondiment + " on " + randomBread;
    
        decimal bucks = Randomizer.Next(2, 5);
        decimal cents = Randomizer.Next(1, 98);
        decimal price = bucks + (cents * .01M);
       Price = price.ToString("c");
    }
    
    注意

    该方法通过将两个随机整数转换为小数,生成介于 2.01 和 5.97 之间的随机价格。仔细看看最后一行——它返回 **price.ToString("c")**。ToString 方法的参数是一个格式。在这种情况下,**"c"** 格式告诉 ToString 使用本地货币格式化值:如果你在美国,你会看到$;在英国,你会得到£;在欧盟,你会看到€等。

    注意

    前往 Visual Studio for Mac 学习者指南,了解该项目的 macOS 版本。

  3. 创建 XAML 以布局窗口。

    你的应用程序将在窗口中显示随机菜单项,其中包含两列:一列宽的用于菜单项,一列窄的用于价格。网格中的每个单元格都有一个 TextBlock 控件,其 FontSize 设置为**18px**,除了底部行,它只有一个右对齐的 TextBlock,跨越了两列。窗口的标题是“欢迎来到 Sloppy Joe's Budget House o’ Discount Sandwiches!”它的高度为**350**,宽度为**550**。网格有一个边距为**20**

    我们在前两个 WPF 项目中学到的 XAML 基础上构建。您可以在设计器中布局它,手动输入它,或者两者兼而有之。

    图片

  4. 为您的 XAML 窗口添加代码后台。

    菜单是由一个名为 MakeTheMenu 的方法生成的,你的窗口在调用 InitializeComponent 后立即调用它。它使用一个 MenuItem 类的数组来生成菜单中的每一项。我们希望前三个项目是普通菜单项。接下来的两个只在贝果上提供。最后一个是特殊项目,具有自己的一套成分。

    图片

  5. 图片 运行您的程序,看看新生成的随机菜单。

哦...出了点问题。菜单上的价格都一样,并且菜单项很奇怪——前三个都一样,接下来两个也是,而且它们似乎都有相同的蛋白质。出了什么问题?

注意

如果您的计算机速度足够快,则您的程序可能不会遇到此问题。如果在较慢的计算机上运行它,您将看到它。

.NET 的 Random 类实际上是一个伪随机数生成器,这意味着它使用数学公式生成一系列数字,可以通过特定的统计测试来验证其随机性。这使得它们足够好用于我们构建的任何应用(但不要将其作为依赖真正随机数的安全系统的一部分!)。这就是为什么这个方法叫做 Next —— 你得到的是序列中的下一个数字。公式从一个“种子值”开始——它使用该值找到序列中的下一个值。当你创建 Random 的一个新实例时,它使用系统时钟来“种子”公式,但你也可以提供自己的种子。试试使用 C# 交互窗口多次调用 **new** **Random(12345).Next();**。你告诉它使用相同的种子值(12345)创建一个新的 Random 实例,因此 Next 方法每次都会给你相同的“随机”数。

当你看到一堆不同的 Random 实例给出相同的值时,那是因为它们的种子值接近,系统时钟没有改变时间,所以它们都有相同的种子值。那么我们怎么解决这个问题呢?通过将 Randomizer 字段设置为静态,这样所有的 MenuItem 就共享一个 Random 实例:

public **static** Random Randomizer = new Random();

再次运行你的程序——现在菜单将被随机化。

图片

第七章:Unity 实验室 #2:为 Unity 编写 C# 代码

Unity 不仅是一个强大的跨平台引擎和编辑器,用于构建 2D 和 3D 游戏和模拟。它还是一个很好的练习编写 C# 代码的方式

在上一个 Unity 实验室中,你学会了如何在 Unity 和你的 3D 空间中导航,并开始创建和探索游戏对象。现在是时候写一些代码来控制你的游戏对象了。那个实验室的整个目标是让你熟悉 Unity 编辑器(并提供一个简单的方法,以便在需要时提醒自己如何在其中导航)。

在这个 Unity 实验室中,你将开始编写代码来控制你的游戏对象。你将编写 C# 代码来探索你在其余 Unity 实验室中将使用的概念,首先是添加一个方法来旋转你在上一个 Unity 实验室中创建的 8 球游戏对象。你还将开始在 Unity 中使用 Visual Studio 调试器来解决游戏中的问题。

C# 脚本为你的游戏对象添加行为

现在你可以向场景中添加一个游戏对象了,你需要一种方法来让它做些事情。这就是你的 C# 技能发挥作用的地方。Unity 使用C# 脚本来定义游戏中所有内容的行为。

这个 Unity 实验室将介绍一些工具,这些工具将帮助你处理 C# 和 Unity。你将构建一个简单的“游戏”,实际上只是一些视觉效果:你将让你的 8 球在场景中飞来飞去。首先去 Unity Hub 并打开你在第一个 Unity 实验室中创建的相同项目

图像

在这个 Unity 实验室中你将做什么:

  1. 将 C# 脚本附加到你的游戏对象上。 你将向你的球体游戏对象添加一个脚本组件。当你添加它时,Unity 将为你创建一个类。你将修改这个类以驱动 8 球球体的行为。

  2. 使用 Visual Studio 编辑脚本。 记得如何设置 Unity 编辑器的偏好设置,使 Visual Studio 成为脚本编辑器吗?这意味着你只需在 Unity 编辑器中双击脚本,它就会在 Visual Studio 中打开。

  3. 在 Unity 中玩你的游戏。 屏幕顶部有一个播放按钮。当你按下它时,它会开始执行场景中附加到游戏对象的所有脚本。你将使用该按钮来运行你添加到球体的脚本。

    图像

  4. 使用 Unity 和 Visual Studio 一起调试你的脚本。 当你试图追踪你的 C# 代码中的问题时,你已经看到 Visual Studio 调试器是多么有价值。Unity 和 Visual Studio 无缝协作,因此你可以添加断点,使用本地变量窗口,并在游戏运行时使用 Visual Studio 调试器中的其他熟悉工具。

向你的游戏对象添加一个 C# 脚本

Unity 不仅仅是一个用于构建 2D 和 3D 游戏的惊人平台。许多人还将它用于艺术作品、数据可视化、增强现实等等。作为一个学习 C# 的人,对你来说尤为宝贵,因为你可以编写代码来控制 Unity 游戏中的一切。这使得 Unity 成为学习和探索 C# 的绝佳工具

现在就开始使用 C# 和 Unity 吧。确保选择了 Sphere GameObject,然后在检查器窗口底部点击“添加组件”按钮。

图片

当你点击它时,Unity 弹出一个窗口,显示可以添加的所有不同类型的组件——而且有很多种。选择“新建脚本”来为你的 Sphere GameObject 添加一个新的 C# 脚本。系统会提示你输入一个名称。把你的脚本命名为 **BallBehaviour**

图片

点击“创建并添加”按钮来添加这个脚本。你会在检查器窗口中看到一个名为 Ball Behaviour (Script) 的组件出现。

图片

你也会在项目窗口中看到这个 C# 脚本。

图片

注意

项目窗口以文件夹视图显示你的项目。你的 Unity 项目由文件组成:媒体文件、数据文件、C# 脚本、纹理等等。Unity 将这些文件称为资产。当你在资产内右键单击导入纹理时,项目窗口显示了一个名为 Assets 的文件夹,所以 Unity 将其添加到了那个文件夹中。

注意

你有没有注意到,在你把 8 球纹理拖到球体上时,项目窗口里会出现一个叫做 Materials 的文件夹?

编写 C# 代码来旋转你的球体

在第一个实验中,你告诉 Unity 使用 Visual Studio 作为其外部脚本编辑器。所以现在双击你的新的 C# 脚本。这样做时,Unity 将会在 Visual Studio 中打开你的脚本。你的 C# 脚本包含一个名为 BallBehaviour 的类,其中有两个空方法分别称为 Start 和 Update:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BallBehaviour : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {

    }
    // Update is called once per frame
    void Update()
    {

    }
}
注意

通过在 Hierarchy 窗口中点击它来在 Visual Studio 中打开你的 C# 脚本,该窗口显示了当前场景中每个 GameObject 的列表。当 Unity 创建你的项目时,它添加了一个名为 SampleScene 的场景,并包含一个摄像机和一个灯光。你向其中添加了一个球体,所以你的 Hierarchy 窗口将显示所有这些内容。

注意

如果 Unity 没有启动 Visual Studio 并在其中打开你的 C# 脚本,回到 Unity 实验 1 的开始,确保你遵循了设置外部工具偏好的步骤。

这是一行代码,可以使你的球体旋转。将其添加到你的 Update 方法中

 transform.Rotate(Vector3.up, 180 * Time.deltaTime);

现在回到 Unity 编辑器,点击工具栏中的播放按钮来启动你的游戏: 图片

图片

添加一个断点并调试你的游戏

让我们来调试你的 Unity 游戏。首先,如果游戏仍在运行,请停止游戏(通过再次点击播放按钮)。然后切换到 Visual Studio,并在你添加到 Update 方法的行上添加一个断点

图片

现在找到 Visual Studio 顶部的启动调试器按钮:

  • 在 Windows 上看起来是这样的——图片——或者从菜单选择 Debug >> Start Debugging (F5)

  • 在 macOS 上看起来是这样的——图片——或者选择 Run >> Start Debugging (图片)

点击那个按钮启动调试器。现在切换回 Unity 编辑器。如果这是你第一次调试这个项目,Unity 编辑器将弹出一个带有以下按钮的对话框窗口:

图片

按下“启用本次会话的调试”按钮(或者如果你想要避免弹出窗口再次出现,按“为所有项目启用调试”)。Visual Studio 现在已经连接到 Unity,这意味着它可以调试你的游戏。

现在在 Unity 中按下播放按钮启动你的游戏。由于 Visual Studio 已连接到 Unity,它会立即在你添加的断点上中断,就像你设置的任何其他断点一样。

注意

恭喜,你现在正在调试一个游戏!

使用命中计数跳过帧

有时,在你的断点停止之前让游戏运行一段时间是很有用的。例如,你可能希望在你的断点命中之前让游戏生成并移动其敌人。让我们告诉你的断点每 500 帧暂停一次。你可以通过向你的断点添加一个命中计数条件来实现这一点:

  • 在 Windows 上,右键点击行左侧的断点点 (图片),从弹出菜单中选择条件,从下拉菜单中选择Hit CountIs a multiple of,并在框中输入 500:

    图片

  • 在 macOS 上,右键点击断点点 (图片),从菜单中选择编辑断点…,然后从下拉菜单中选择When hit count is a multiple of,并在框中输入 500:

    图片

现在,断点只会在 Update 方法运行 500 次——或者每 500 帧——时暂停游戏。所以如果你的游戏以 60 FPS 运行,那么当你按下继续按钮后,游戏将在再次中断前运行约 8 秒多一点。按下 Continue,然后切换回 Unity,观察球旋转直到断点再次触发。

使用调试器理解 Time.deltaTime

在 Unity Labs 项目中,你将会经常使用 Time.deltaTime。让我们利用你的断点并使用调试器来真正理解这个值的含义。

当你的游戏在 Visual Studio 的断点上暂停时,悬停在 Time.deltaTime 上可以看到自上一帧以来经过的秒数的分数(你需要把鼠标光标放在**deltaTime**上)。然后通过右键菜单选择 Time.deltaTime 并选择添加监视来添加一个监视

图片

继续调试(在 Windows 上是 F5,图片在 macOS 上),就像你调试其他应用程序一样),以恢复你的游戏。球体将再次开始旋转,再过 500 帧后,断点将再次触发。你可以每次运行 500 帧游戏。每次断点中断时,都要注意观察监视窗口。

图片

停止调试(在 Windows 上是 Shift+F5,图片在 macOS 上)来停止你的程序。然后再次开始调试。由于你的游戏仍在运行,当你重新连接 Visual Studio 到 Unity 时,断点将继续工作。完成调试后,再次切换你的断点,这样 IDE 将继续跟踪它,但不会在命中时中断。再次停止调试以从 Unity 中分离出来。

返回 Unity 并停止你的游戏—并保存它,因为播放按钮不会自动保存游戏。

注意

Unity 中的播放按钮用于启动和停止游戏。即使游戏停止,Visual Studio 也会保持与 Unity 的连接。

添加一个圆柱来显示 Y 轴的位置

你的球体正在场景的中心围绕 Y 轴旋转。让我们添加一个非常高和非常细的圆柱以使其可见。通过从 GameObject 菜单选择3D 对象 >> 圆柱创建一个新的圆柱。确保在 Hierarchy 窗口中它被选中,然后查看 Inspector 窗口并检查 Unity 是否在位置(0, 0, 0)处创建它—如果没有,请使用上下文菜单(图片)来重置它。

让我们使圆柱高而细。从工具栏选择 Scale 工具:要么点击它(图片),要么按下 R 键。你应该会看到 Scale Gizmo 出现在你的圆柱上:

图片

将绿色立方体点击并拖拽向上,沿 Y 轴拉长你的圆柱。然后点击红色立方体并向圆柱拉伸,使其沿 X 轴变得非常狭窄,接着再用蓝色立方体也使其沿 Z 轴变得非常狭窄。在改变圆柱尺寸时,请观察检视器中的 Transform 面板—Y 轴将变大,而 X 和 Z 的值将变得更小。

图片

点击 Transform 面板中的 Scale 行中的 X 标签并上下拖动。确保你点击实际的 X 标签,在输入框左侧。当你点击标签时,它会变蓝色,并且 X 值周围会出现蓝色框。随着你拖动鼠标,框中的数字会上下移动,场景视图会根据你的更改更新比例。在拖动时仔细观察—比例可以是正数和负数。

现在选择 X 框中的数字并键入.1—圆柱变得非常细。按 Tab 键,然后键入 20,再次按 Tab 键并键入.1,最后按 Enter 键。

图片

现在你的球体中有一个穿过的非常长的圆柱,显示 Y 轴,其中 Y = 0。

图片

为你的类添加字段以存储旋转角度和速度。

在#objectshellipget_orientedexclamation_mar 中,你学习了 C# 类如何拥有字段来存储方法可以使用的值。让我们修改你的代码来使用字段。在类声明后的第一个左花括号**{:**后立即添加这四行:

public class BallBehaviour : MonoBehaviour
{
    public float XRotation = 0;
    public float YRotation = 1;
    public float ZRotation = 0;
    public float DegreesPerSecond = 180;
注意

这些字段就像你在#objectshellipget_orientedexclamation_mar 和#types_and_references_getting_the_referen 项目中添加的字段一样。它们是变量,用于跟踪它们的值——每次调用 Update 时,它们会重复使用相同的字段。

XRotation、YRotation 和 ZRotation 字段分别包含一个介于 0 和 1 之间的值,你将它们结合起来创建一个向量,确定球体旋转的方向:

 new Vector3(XRotation, YRotation, ZRotation)

DegreesPerSecond 字段包含每秒旋转的角度数,你将像以前一样将其乘以 Time.deltaTime。修改你的 Update 方法以使用这些字段。 这段新代码创建了一个名为**axis**的 Vector3 变量,并将其传递给 transform.Rotate 方法:

 void Update()
 {
     Vector3 axis = new Vector3(XRotation, YRotation, ZRotation);
     transform.Rotate(axis, DegreesPerSecond * Time.deltaTime);
 }

在层次视图窗口中选择球体。你的字段现在显示在脚本组件中。当脚本组件渲染字段时,它在大写字母之间添加空格,以便更容易阅读。

图像

再次运行你的游戏。当游戏运行时,在层次视图窗口中选择球体,并将每秒度数更改为 360 或 90——球体开始以两倍或一半的速度旋转。停止你的游戏——字段将重置为 180。

当游戏停止时,使用 Unity 编辑器将 X Rotation 字段更改为 1,将 Y Rotation 字段更改为 0。开始你的游戏——球体将远离你旋转。在游戏运行时点击 X Rotation 标签并上下拖动以更改值。一旦数字变为负数,球体开始朝你旋转。再次使它为正数,它将远离你旋转。

图像

使用 Debug.DrawRay 探索三维向量的工作原理

向量是一个具有长度(或大小)和方向的值。如果你在数学课上学过向量,你可能见过很多像这样的二维向量图示:

图像

这在理智层面上并不难理解。但是,即使我们上过涵盖向量的数学课程,我们也并不总是对向量如何工作有一个直观的理解,尤其是在三维空间中。这里是我们可以使用 C# 和 Unity 作为学习和探索工具的另一个领域。

使用 Unity 可视化三维向量

你将要为你的游戏添加代码,帮助你真正理解三维向量的工作原理。首先,仔细查看你的 Update 方法的第一行:

 Vector3 axis = new Vector3(XRotation, YRotation, ZRotation);

这行代码告诉我们关于向量的什么?

  • 它有一个类型:Vector3。每个变量声明都以一个类型开头。不再使用 string、int 或 bool,而是声明为 Vector3 类型。这是 Unity 用于 3D 向量的类型。

  • 它有一个变量名: **axis**

  • 它使用 new 关键字创建 Vector3。它使用 XRotation、YRotation 和 ZRotation 字段创建一个具有这些值的向量。

那么这个 3D 向量是什么样子呢?我们无需猜测 —— 我们可以使用 Unity 提供的有用的调试工具之一来为我们绘制这个向量。在您的 Update 方法末尾添加这行代码:

   void Update()
   {
       Vector3 axis = new Vector3(XRotation, YRotation, ZRotation);
       transform.Rotate(axis, DegreesPerSecond * Time.deltaTime);
       Debug.DrawRay(Vector3.zero, axis, Color.yellow);
   }

Debug.DrawRay 方法是 Unity 提供的特殊方法,帮助您调试游戏。它绘制一条射线 —— 一种从一个点到另一个点的向量,并接受其起始点、终点和颜色参数。有一个注意点:射线只出现在场景视图中。Unity 的 Debug 类中的方法设计成不会干扰您的游戏。它们通常只影响游戏与 Unity 编辑器的交互方式。

运行游戏以在场景视图中看到射线

现在再次运行游戏。在 Game 视图中看不到任何不同,因为 Debug.DrawRay 是一个用于调试的工具,不会对游戏玩法产生任何影响。使用 Scene 选项卡切换到场景视图。您可能还需要通过选择布局下拉菜单中的 Wide 来重置 Wide 布局

图片

现在您又回到了熟悉的场景视图。按以下步骤来真正了解 3D 向量的工作原理:

  • 使用 Inspector 来修改 BallBehaviour 脚本的字段。将 X Rotation 设置为 0,Y Rotation 设置为 0,Z Rotation 设置为 3。现在,您应该看到一个黄色射线直接从 Z 轴出来,球体围绕其旋转(请记住,射线只会显示在场景视图中)。

    图片

  • 将 Z 旋转设置回 3. 拖动 X 旋转和 Y 旋转的值,看看它们对射线的影响。确保每次更改后重置 Transform 组件。

  • 使用 Hand 工具和 Scene Gizmo 来获得更好的视图。单击 Scene Gizmo 上的 X 锥体,将其设置为从右侧查看。继续单击 Scene Gizmo 上的锥体,直到您从正面看到视图。很容易迷失方向 —— 您可以重置 Wide 布局以回到熟悉的视图。

为射线添加持续时间以留下轨迹

您可以向 Debug.DrawRay 方法调用添加第四个参数,指定射线在屏幕上停留的秒数。添加 .5f 使每条射线在屏幕上停留半秒钟:

 Debug.DrawRay(Vector3.zero, axis, Color.yellow, .5f);

现在再次运行游戏并切换到场景视图。现在,当您上下拖动数字时,将会看到留下一串射线。这看起来非常有趣,但更重要的是,它是一个很好的可视化 3D 向量的工具。

图片

围绕场景中的一个点旋转您的球体

你的代码调用 transform.Rotate 方法来使你的球围绕其中心旋转,从而改变其 X、Y 和 Z 旋转值。在层次结构窗口中选择 Sphere 并将其 X 位置更改为 5在 Transform 组件中。然后使用 BallBehaviour Script 组件中的上下文菜单(图片来重置其字段。再次运行游戏——现在球将位于位置(5, 0, 0)并围绕自己的 Y 轴旋转。

图片

让我们修改 Update 方法,使用不同类型的旋转。现在我们将使球围绕场景中心点(0, 0, 0)旋转,使用transform.RotateAround 方法,它会使一个 GameObject 围绕场景中的一个点旋转。(这与你之前使用的 transform.Rotate 方法不同,后者使一个 GameObject 围绕其中心旋转。)它的第一个参数是要围绕的点。我们将使用Vector3.zero作为该参数,这是写**new Vector3(0, 0, 0)**的快捷方式。

注意

这个新的 Update 方法使球围绕场景中点(0, 0, 0)旋转。

这是新的 Update 方法:

void Update()
{
    Vector3 axis = new Vector3(XRotation, YRotation, ZRotation);
    transform.RotateAround(Vector3.zero, axis, DegreesPerSecond * Time.deltaTime);
    Debug.DrawRay(Vector3.zero, axis, Color.yellow, .5f);
}

现在运行你的代码。这次它会使球围绕中心点画一个大圆:

图片

使用 Unity 更仔细地查看旋转和向量

在本书的其余部分中,你将会使用 Unity Labs 处理 3D 对象和场景。即使我们中的许多人花费大量时间玩 3D 视频游戏,也不完全了解向量和 3D 对象的工作原理,以及如何在 3D 空间中移动和旋转。幸运的是,Unity 是一个很好的工具,可以探索 3D 对象的工作原理。让我们立即开始实验。

当你的代码运行时,尝试更改参数以实验旋转:

  • 切换回场景视图,这样你就可以看到 Debug.DrawRay 在 BallBehaviour.Update 方法中呈现的黄色射线。

  • 使用层次结构窗口选择 Sphere。你应该在检视器窗口中看到它的组件。

  • 在 Script 组件中将X Rotation、Y Rotation 和 Z Rotation 值更改为10,这样你就会看到向量呈现为一条长射线。使用手动工具(Q)旋转场景视图,直到你能清楚地看到射线。

  • 使用 Transform 组件的上下文菜单(图片)来重置 Transform 组件。由于球体的中心现在位于场景中的零点(0, 0, 0),它将围绕自己的中心旋转。

  • 然后在 Transform 组件中将 X 位置更改为2。现在球应该围绕该向量旋转。当它飞过时,你会看到球在 Y 轴圆柱上投下阴影。

    图片

尝试重复最后三个步骤,使用不同的 X、Y 和 Z 旋转值,每次重置 Transform 组件,以便从一个固定点开始。然后尝试点击旋转字段标签并上下拖动它们——看看你是否能感受到旋转的工作原理。

通过修改 GameObject 的属性,Unity 是探索 3D 对象工作原理的绝佳工具,可以实时观察效果。

多发挥创意!

这是你通过 C#和 Unity 自行实验的机会。你已经了解了如何将 C#与 Unity 游戏对象结合起来的基础知识。花点时间玩转一下你在前两个 Unity 实验中学到的不同 Unity 工具和方法。以下是一些想法:

  • 向场景中添加立方体、圆柱体或胶囊体。将新的脚本附加到它们上——确保每个脚本都有一个唯一的名称!——并使它们以不同的方式旋转。

  • 尝试将旋转的游戏对象放置在场景中不同的位置。看看你能否从多个旋转的游戏对象中制造出有趣的视觉图案。

  • 尝试向场景中添加一个光源。当你使用transform.rotateAround来围绕各个轴旋转新的光源时会发生什么?

  • 这是一个快速的编码挑战:尝试使用+=来向你的BallBehaviour脚本中的一个字段添加值。确保将该值乘以Time.deltaTime。尝试添加一个if语句,如果字段变得太大,将其重置为 0。

注意

在运行代码之前,尝试弄清楚它的作用。它表现得符合你的预期吗?试图预测你添加的代码将如何作用是提高 C#技能的一个好方法。

抽出时间来尝试你刚学到的工具和技术。这是利用 Unity 和 Visual Studio 进行探索和学习的绝佳方式。

第八章:封装:保护您的私人信息……私密

图片

是否曾经希望拥有更多的隐私?

有时你的对象也有同样的感受。就像你不希望任何你不信任的人读你的日记或翻阅你的银行对账单一样,好的对象不让其他对象查看它们的字段。在本章中,您将学习到封装的力量,这是一种编程方式,可以帮助您编写灵活、易于使用且难以被误用的代码。您将使您对象的数据私有,并添加属性以保护数据的访问方式。

让我们帮助 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 控件的名称命名为magicflaming,将 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 方法进行以下更改

Images

现在测试你的代码。运行程序并点击按钮几次。到目前为止一切顺利——数字看起来是正确的。现在选中“魔法”复选框并再次点击按钮几次。好的,看起来我们的修复起作用了!还有一件事需要测试。选中“燃烧”复选框并点击按钮,然后……糟糕!还是不起作用。点击按钮时,它执行了 1.75 的魔法倍增器,但没有增加额外的 3 点 HP 用于燃烧。你仍然需要勾选和取消勾选“燃烧”复选框才能得到正确的数字。所以程序仍然有问题。

Images

在尝试修复 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 字段。将参与计算的字段更改为私有,可以防止应用程序干扰计算。当你将一些数据私有化,然后编写代码来使用这些数据时,这被称为封装。当一个类保护其数据并提供安全使用且难以滥用的成员时,我们称其为良好封装。**

使用封装来控制对类方法和字段的访问

当你将所有字段和方法都设置为公共时,任何其他类都可以访问它们。你的类所做的一切以及所知道的一切都成为程序中每个其他类的开放书籍……你刚刚看到这可能导致你的程序以你从未预料过的方式运行。

这就是为什么publicprivate关键字被称为访问修饰符:它们修改对类成员的访问。封装让你控制在类内部分享什么和保留什么私有。让我们看看这是如何工作的。

  1. 超级间谍赫伯特·琼斯是一位1960 年代间谍游戏中的秘密特工对象,在苏联作为卧底特工捍卫生命、自由和追求幸福。他的对象是 SecretAgent 类的一个实例。

    图片图片

  2. 约翰斯特工有一个计划,可以帮助他躲避敌方特工。他添加了一个 AgentGreeting 方法,该方法以密码作为参数。如果他没有得到正确的密码,他只会透露他的化名,Dash Martin。

    图片

  3. 看起来这是保护特工身份的绝佳方法,对吧?只要调用它的特工对象没有正确的密码,特工的姓名就是安全的。

    图片

但是 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 的自动属性。

  1. 删除 BallsLoaded 属性和背景字段。 删除您添加的 BallsLoaded 属性,因为我们将用自动实现的属性替换它。然后删除 ballsLoaded 背景字段(private int ballsLoaded = 0;),因为每次创建自动属性时,C#编译器都会为您生成一个隐藏的背景字段。

  2. 告诉 IDE 启动 prop 代码片段。 将光标放在字段原来的位置,然后键入 **prop** 按两次 Tab 键告诉 IDE 启动代码片段。它会将以下行添加到您的代码中:

    图片

    该片段是一个模板,允许您编辑其部分——prop 片段允许您编辑类型和属性名称。按一次 Tab 键切换到属性名称,然后将名称更改为 **BallsLoaded** 并按 Enter 键以完成片段

    public int BallsLoaded { get; set; }
    
    注意

    你不必为自动属性声明后备字段,因为 C# 编译器会自动创建它。

  3. 修复类的其余部分。 由于你移除了 ballsLoaded 字段,你的 PaintballGun 类不再编译。这有一个快速的修复方案——代码中 ballsLoaded 字段出现了五次(一次在 IsEmpty 方法中,两次在 Reload 和 Shoot 方法中)。将它们改为 BallsLoaded——现在你的程序又可以工作了。

使用私有 setter 来创建一个只读属性

让我们再来看一下你刚刚创建的自动属性:

 public int BallsLoaded { get; set; }

这绝对是一个很好的替代方案,用一个带有 get 和 set 访问器的属性来更新后备字段。它比 ballsLoaded 字段和 GetBallsLoaded 方法更易读,并且代码更少。所以这是一种进步,对吧?

但是有一个问题:我们破坏了封装性。私有字段和公共方法的整个目的是使装载的球数只读。Main 方法很容易设置 BallsLoaded 属性。我们将字段设为私有,并创建了一个公共方法来获取值,以便只能从 PaintballGun 类内部进行修改。

将 BallsLoaded 的 setter 设为 private

幸运的是,我们有一个快速的方法来重新使 PaintballGun 类良好封装。当你使用属性时,你可以在 getset 关键字前放置一个访问修饰符。

你可以创建一个只读属性,通过将其 set 访问器设为 **private**,使其不能被其他类设置。事实上,对于普通属性,你可以完全省略 set 访问器,但对于自动属性来说不行,否则你的代码将无法编译。

因此,让我们 将 set 访问器设为 private

 public int BallsLoaded { get; private  set; }
注意

你可以通过将其 setter 设为 private 来将自动属性设为只读。

现在 BallsLoaded 字段是一个只读属性。它可以在任何地方读取,但只能从 PaintballGun 类内部更新。PaintballGun 类再次良好封装。

如果我们想要改变弹夹大小怎么办?

现在,PaintballGun 类使用一个 const 来表示弹夹大小:

   public const int MAGAZINE_SIZE = 16;

替换这个!

如果我们希望游戏在实例化枪支时设置弹夹大小怎么办?让我们 用属性来替换它

  1. 移除 MAGAZINE_SIZE 常量并用一个只读属性来替换它。

        public int MagazineSize { get; private set; }
    
  2. 修改 Reload 方法以使用新的属性。

     if (balls > MagazineSize
    )        BallsLoaded = MagazineSize;
    
  3. 修复在 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 构造函数传递参数

修改这个!

  1. 添加你在 #types_and_references_getting_the_referen 为 Owen 的能力得分计算器编写的 ReadInt 方法。

    你需要从某处获取构造函数的参数。你已经有一个非常好的方法提示用户输入 int 值,所以在这里重用它是有道理的。

  2. 添加代码以从控制台输入读取值。

    现在你已经从 #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。

  3. 更新新语句以添加参数。

    现在你已经有了与构造函数参数类型匹配的变量值,可以更新**new**语句将它们作为参数传递给构造函数:

    PaintballGun gun = new PaintballGun(**numberOfBalls, magazineSize, isLoaded**);

  4. 运行你的程序。

    现在运行你的程序。它将提示你输入球的数量、弹夹大小以及枪是否装载。然后它将创建一个新的 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 类中的代码几乎相同。这是我们启动新应用程序所需做的事情。

  1. 创建一个新的.NET 控制台应用程序项目。 我们希望它能够同时进行剑和箭的计算,因此向项目中添加 SwordDamage 类

  2. 创建一个 ArrowDamage 类,完全复制 SwordDamage 的代码。 创建一个名为 ArrowDamage 的新类,然后复制所有代码从 SwordDamage 并粘贴到新的 ArrowDamage 类。然后更改构造函数名称为 ArrowDamage 以便程序构建。

  3. 重构常量。 箭头伤害公式的基础和火焰伤害有不同的值,因此让我们将 BASE_DAMAGE 常量重命名为 BASE_MULTIPLIER,并更新常量值。我们认为这些常量使代码更易于阅读,因此也添加一个 MAGIC_MULTIPLIER 常量:

    图像

    注意

    ArrowDamage

    魔法

    火焰

    伤害

  4. 修改 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 的子类。

  1. 寻找动物共有的特征。

    看看这六种动物。狮子、河马、老虎、猞猁、狼和狗有什么共同之处?它们如何相关联?您需要弄清它们的关系,以便能够提出包含它们所有的类模型。

    图片

  2. 构建一个基类,为动物提供它们共同拥有的一切。

    基类中的字段、属性和方法将为所有继承它的动物提供一个共同的状态和行为。它们都是动物,因此将基类称为 Animal 是有道理的。

    您已经知道我们应该避免重复代码:这很难维护,并且总是会带来后续的头疼。因此,让我们为 Animal 基类选择您只需编写一次的字段和方法,并且每个动物子类都可以继承它们。让我们从公共属性开始:

    • 图片:指向图像文件的路径。

    • 食物:这种动物吃的食物类型。目前只能有两个值:肉和草。

    • 饥饿:表示动物饥饿水平的整数。它会随着动物吃饭的时间(和数量)而改变。

    • 边界:指向一个存储了围栏高度、宽度和位置的类的引用。

    • 位置:动物站立的 X 和 Y 坐标。

    另外,Animal 类还有四种动物可以继承的方法:

    • 发出声音:让动物发出声音的方法。

    • 进食:当动物遇到它们喜欢的食物时的行为。

    • 睡觉:让动物躺下来小睡的方法。

    • 游荡:使动物在它们的圈舍里四处游荡的方法。

      图片

不同的动物有不同的行为

狮子吼,狗叫,至于我们所知,河马根本不会发出任何声音。所有从 Animal 继承的类都将具有 MakeNoise 方法,但每个方法的工作方式都不同,并且具有不同的代码。当子类更改继承的方法的行为时,我们称其覆盖该方法。

注意

仅因为一个属性或方法在 Animal 基类中,这并不意味着每个子类都必须以相同的方式使用它……或根本不使用!

  1. 弄清楚每种动物在 Animal 类做得不同或根本不做的事情。

    每种动物都需要进食,但狗可能会小口吃肉,而河马则会大口吃草。那种行为的代码会是什么样子呢?狗和河马都会覆盖 Eat 方法。河马的方法会让它每次调用时消耗大约 20 磅的干草。另一方面,狗的 Eat 方法会减少动物园的食物供应一罐 12 盎司的狗粮。

    注意

    所以,当你有一个子类继承自一个基类时,它必须继承所有基类的行为……但是你可以修改它们在子类中的执行方式,所以它们不是完全相同的。这就是覆盖的意义。

    图片

  2. 寻找有很多共同点的类。

    狗和狼看起来不是很相似吗?它们都是食肉动物,可以肯定的是,如果你观察它们的行为,它们有很多共同点。它们可能吃同样的食物,以相同的方式睡觉。那么山猫、老虎和狮子呢?事实证明,它们三者在它们的栖息地中的移动方式完全相同。可以肯定的是,你可以创建一个通用的 Feline 类,位于 Animal 和这三个猫科动物类之间,有助于防止它们之间的重复代码。

    图片

  3. 完成你的类层次结构。

    现在你知道如何组织动物了,你可以添加 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糟糕的管理决策。

  1. 在基类方法中添加 virtual 关键字。

    子类只能在一个标有virtual关键字的方法上重写它。在 Fly 方法声明中添加virtual告诉 C#,Bird 类的子类可以重写 Fly 方法。

    图片

  2. 在子类的同名方法上添加 override 关键字。

    子类的方法将需要与基类完全相同的签名——相同的返回类型和参数——并且你将需要在声明中使用**override**关键字。现在,当 Penguin 对象调用其 Fly 方法时,会打印警告。

    图片

图片

一些成员只在子类中实现

到目前为止,我们所见过的所有代码都是从对象外部访问子类的成员——就像你刚刚编写的代码中 Main 方法调用 LayEggs 一样。继承真正发挥作用的地方是基类使用子类中实现的方法或属性。这里有一个例子。我们的动物园模拟器有自动售货机,让游客购买苏打水、糖果和饲料,以供宠物区的动物食用。

图片

VendingMachine 是所有售货机的基类。它有分发物品的代码,但这些物品未定义。检查游客是否放入正确金额的方法始终返回 false。为什么?因为它们将在子类中实现。以下是在宠物区分发动物饲料的子类:

图片

使用调试器来理解重写的工作原理

让我们使用调试器来看看当我们创建一个 AnimalFeedVendingMachine 的实例并要求它分发一些饲料时究竟会发生什么。创建一个新的 Console App 项目,然后按照以下步骤操作。

调试一下这个!

  1. 添加 Main 方法。以下是该方法的代码:

    class Program
    {
        static void Main(string[] args)
        {
            VendingMachine vendingMachine = new AnimalFeedVendingMachine();
            Console.WriteLine(vendingMachine.Dispense(2.00M));
        }
    }
    
  2. 添加 VendingMachine 和 AnimalFeedVendingMachine 类。 一旦它们被添加,尝试将这行代码添加到 Main 方法中:

        vendingMachine.CheckAmount(1F);
    

    由于 protected 关键字,您将收到编译器错误,因为只有 VendingMachine 类或其子类可以访问其受保护的方法。

    图片

    删除该行以使您的代码构建。

  3. 在 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!

virtualoverride 关键字不仅仅是装饰。它们在程序运行中真正起到作用。virtual 关键字告诉 C# 成员(如方法、属性或字段)可以被扩展——没有它,你根本无法覆盖它。override 关键字告诉 C# 你正在扩展该成员。如果在子类中省略 override 关键字,你实际上是创建了一个完全无关的方法,只是恰巧有相同的名称。

听起来有点奇怪,对吧?但实际上是有道理的——真正理解 virtualoverride 如何工作的最佳方法就是编写代码。因此,让我们构建一个真实的示例来进行实验。

当子类覆盖其基类中的方法时,总是调用在子类中定义的更具体版本,即使它是由基类中的方法调用的。

构建一个应用程序来探索 virtual 和 override

在 C#中,继承的一个非常重要的部分是扩展类成员。这就是子类可以从基类继承某些行为,但需要在需要的地方重写某些成员的地方——这就是virtualoverride关键字的用途。virtual关键字确定哪些类成员可以被扩展。当你想扩展一个成员时,必须使用override关键字。让我们创建一些类来实验virtualoverride。你将创建一个代表包含贵重珠宝的保险柜的类——为了一些狡猾的小偷来偷取珠宝。

图像

  1. 创建一个新的控制台应用程序并添加 Safe 类。

    这是 Safe 类的代码:

    图像

  2. 为拥有保险柜的人添加一个类。

    保险柜的主人有些健忘,偶尔会忘记他们极为安全的保险柜密码。添加一个 SafeOwner 类来代表他们:

    class SafeOwner
    {
        private string valuables = "";
        public void ReceiveContents(string safeContents)
        {
            valuables = safeContents;
            Console.WriteLine($"Thank you for returning my {valuables}!");
        }
    }
    
  3. 添加一个能够挑锁的 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);
        }
    }
    

    图像

  4. 添加一个想要窃取贵重物品的 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}");
        }
    }
    
  5. 添加一个主方法,让珠宝窃贼偷走宝石。

    现在是大抢劫的时候了!在这个主方法中,珠宝窃贼潜入房屋,并使用其继承的 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, abstractoverride。好的,这是一个我们可以通过快速更改来修复的错误。用 virtual 关键字标记 Locksmith 的 ReturnContents 方法:

图片

现在重新运行你的程序。这是我们一直在寻找的输出结果:

图片图片

没错。大多数情况下你会想要重写方法,但隐藏它们也是一种选择。

当你在一个扩展基类的子类中工作时,你更有可能使用重写而不是隐藏。所以当你看到关于隐藏方法的编译器警告时,要注意!确保你真的想隐藏该方法,而不是只是忘记使用 virtualoverride 关键字。如果你总是正确使用 virtualoverridenew 关键字,你就不会再遇到这样的问题了!

如果你想在基类中重写一个方法,总是使用 virtual 关键字进行标记,并且每次你想在子类中重写方法时都使用 override 关键字。如果不这样做,你可能会意外隐藏方法。

子类可以使用 base 关键字访问其基类

即使在基类中重写了方法或属性,有时你仍然希望访问它。幸运的是,我们可以使用 base 关键字来访问基类的任何成员。

  1. 所有动物都吃东西,所以 Vertebrate 类有一个以 Food 对象为参数的 Eat 方法。

    class Vertebrate {
       public virtual void Eat(Food morsel) {
          Swallow(morsel);
          Digest();
       }
    }
    

    图片图片

  2. 变色龙通过用舌头捕食来进食。因此,Chameleon 类继承自 Vertebrate 但重写了 Eat 方法。

    图片

  3. 我们可以使用 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 版本。

构建一个蜂箱管理系统。

蜂王需要你的帮助! 她的蜂箱失控了,她需要一个程序来帮助管理她的蜜生产业务。她有一个满是工人的蜂箱,以及一大堆需要在蜂箱周围完成的工作,但不知何故,她失去了对哪只蜜蜂正在做什么以及她是否有足够的蜜能力来完成这些工作的控制。你需要建立一个蜂箱管理系统来帮助她跟踪她的工人。以下是它的工作原理。

图片

  1. 皇后分配工作给她的工人们。

    工人们可以做三种不同的工作。采蜜蜂飞出去将花蜜带回蜂箱。制蜜蜂把花蜜转化为蜜,蜜蜂吃以维持工作。最后,蜂王不断产卵,蛋护理蜜蜂确保它们成为工人。

  2. 当所有工作都分配完毕,就是工作的时候了。

    皇后分配工作完成后,她会通过在她的蜂箱管理系统应用程序中点击“开始下一个班次”按钮,告诉蜜蜂们去工作,这将生成一个班次报告,告诉她分配到每个工作的蜜蜂数量以及蜜罐中花蜜和蜜的状态。

    图片

  3. 就像所有的业务领袖一样,皇后专注于增长。蜂箱业务是一项艰苦的工作,她用工蜂的总人数来衡量她的蜂箱。你能帮助皇后继续增加工蜂吗?她能在蜜用尽之前让蜂箱增长到多大?

蜂箱管理系统类模型

这里是你将为蜂箱管理系统构建的类。有一个带有基类和四个子类的继承模型,一个静态类来管理驱动蜂箱业务的蜜和花蜜,以及具有主窗口代码后台的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 中使用的那个)将其转换为实时游戏,而且只需几行代码即可实现。

  1. 在您的 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的工作原理。

  2. 添加一个私有字段,引用一个DispatcherTimer

    现在您需要创建一个新的DispatcherTimer。将其放在 MainWindow 类的顶部作为一个私有字段:

    private DispatcherTimer timer = new DispatcherTimer();
    
  3. 使计时器调用“工作轮次”按钮的 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关键字的方式非常相似,或者在本章早些时候使用virtualoverride关键字一样。使一些成员私有并不会改变行为。它只是防止你的代码在违反封装性时编译。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 项目。这一次我们将文件稍微整理一下,所以你会为你的材料和脚本创建单独的文件夹,并且为预制件再创建一个文件夹(稍后在实验中你会了解):

  1. 在开始之前,请关闭任何已打开的 Unity 项目。同时关闭 Visual Studio——Unity 会为你打开它。

  2. 创建一个新的 Unity 项目,使用 3D 模板,就像你之前在 Unity 实验室中做的那样。给它一个名称,以帮助你记住它属于哪些实验(“Unity Labs 3 和 4”)。

  3. 选择宽屏布局以便你的屏幕与截图匹配。

  4. Assets 文件夹下创建一个名为 Materials 的文件夹。在项目窗口中右键点击 Assets 文件夹,选择 Create >> Folder。将其命名为 Materials

  5. Assets 文件夹下创建另一个名为 Scripts 的文件夹。

  6. 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 在脚本组件中显示它。

Images

OneBallPrefab 字段仍然显示 None,所以我们需要设置它。将 OneBall 从 Prefabs 文件夹中拖到 One Ball Prefab 标签旁边的框中

Images

现在 GameController 的 OneBallPrefab 字段包含了一个对 OneBall 预制体的引用

Images

回到代码,仔细查看 AddABall 方法。它调用 Instantiate 方法,并将 OneBallPrefab 字段作为参数传递给它。你刚刚设置了该字段,使其包含你的预制体。因此,每当 GameController 调用其 AddABall 方法时,它将创建 OneBall 预制体的一个新实例

按下播放按钮来运行你的代码

你的游戏已经准备好运行了。附加到 Main Camera 的 GameController 脚本将等待 1.5 秒,然后每秒实例化一个 OneBall 预制体。每个实例化的 OneBall 的 Start 方法将其移动到场景中的随机位置,并且其 Update 方法将每 2 秒围绕 Y 轴旋转,使用 OneBallBehaviour 的字段(就像上次实验中一样)。观察当游戏区域慢慢填满旋转的球时:

Images

注意

Unity 在每帧之前调用每个 GameObject 的 Update 方法。这被称为更新循环。

注意

当你在代码中实例化 GameObjects 时,它们会在你运行游戏时显示在层次视图中。

在层次视图中观察实例的动态

飞行在场景中的每个球都是 OneBall 预制体的一个实例。每个实例都有其自己的 OneBallBehaviour 类的实例。你可以使用层次视图来跟踪所有的 OneBall 实例 —— 每创建一个,层次视图就会添加一个 “OneBall(Clone)” 条目。

Images

点击任何一个 OneBall(Clone) 项来在检视器中查看它。当它旋转时,你会看到它的 Transform 值发生变化,就像上次实验中一样。

注意

我们在 Unity 实验室中包含了一些编码练习。它们和书中其他地方的练习一样 —— 记住,偷看解决方案并不算作弊。

使用检视器来操作 GameObject 实例

运行你的游戏。一旦实例化了几个球,点击暂停按钮 —— Unity 编辑器将跳回到场景视图。点击层次视图中的 OneBall 实例中的任何一个来选择它。Unity 编辑器会在场景视图中用轮廓线标出它,以显示你选择的对象。进入检视器窗口的 Transform 组件,并将其 Z 缩放值设置为 4,使球拉伸。

Images

再次启动你的模拟 —— 现在你可以追踪修改的是哪个球了。尝试像上次实验中那样更改它的 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 实验室开始时,我们已经为您介绍了游戏的描述。试着创建一个纸上原型游戏。您能想出让它更有趣的方法吗?

图片

第十一章:接口、转换和“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#代码的次数越多,就会越容易。发挥创造力来制作你的游戏是一个很好的练习机会!

第十四章:LINQ 和 lambda:掌控你的数据

图片

这是一个数据驱动的世界……我们都需要知道如何在其中生存。

已经过去了你可以无需处理大量数据就能编程数天、甚至数周的时代。今天,一切都与数据有关,而这正是LINQ发挥作用的地方。LINQ 是 C#和.NET 的特性,不仅让你以直观的方式查询.NET 集合中的数据,还能让你分组数据合并来自不同数据源的数据。你将添加单元测试以确保你的代码按照你想要的方式工作。一旦你掌握了将数据整理成可管理的块的窍门,你可以使用lambda 表达式重构你的 C#代码,使其更具表现力。

Jimmy 是“超级队长”卡通迷中的铁杆粉丝…

认识一下 Jimmy,他是“超级队长”漫画、图像小说和周边的热情收藏家之一。他知道所有关于队长的冷知识,他拥有所有电影的道具,他还有一个只能用“惊人”来形容的漫画收藏。

图片

…但他的收藏物到处都是

Jimmy 可能充满热情,但他并不是一个特别有条理的人。他试图追踪他收藏的最珍贵的“皇冠明珠”漫画,但他需要帮助。你能帮 Jimmy 建立一个管理他漫画的应用程序吗?

图片

使用 LINQ 查询你的集合

在这一章中,你将学习LINQ(或称语言集成查询)。LINQ 将非常有用的类和方法与直接内置在 C#中的强大功能结合起来,所有这些都是为了帮助你处理数据序列,就像 Jimmy 的漫画书收藏一样。

让我们使用 Visual Studio 开始探索 LINQ。创建一个新的控制台应用程序(.NET Core)项目,并将其命名为LinqTest。添加这段代码,当你到达最后一行时,请加上一个句号并查看智能感知窗口:

图片

让我们使用一些新方法来完成你的控制台应用程序:

            IEnumerable<int> firstAndLastFive = numbers.Take(5).Concat(numbers.TakeLast(5));
            foreach (int i in firstAndLastFive)
            {
                Console.Write($"{i} ");
            }
        }
    }
}

现在运行你的应用程序。它会在控制台打印出这一行文本:

1 2 3 4 5 95 96 97 98 99

那么你刚刚做了什么?

LINQ(或语言集成查询)是 C#特性和.NET 类的结合,帮助你处理数据序列。

LINQ 可以与任何 IEnumerable一起工作

当你在代码中添加了using System.Linq;指令后,你的 List突然“超级强大”起来——一堆 LINQ 方法立即出现在它上面。你可以对任何 实现 IEnumerable的类做同样的事情。

当一个类实现 IEnumerable时,该类的任何实例都是一个序列

  • 从 1 到 99 的数字列表就是一个序列。

  • 当你调用其 Take 方法时,它返回一个包含前五个元素的序列的引用。

  • 当你调用其 TakeLast 方法时,它返回另一个包含五个元素的序列。

  • 当你使用 Concat 将两个包含五个元素的序列合并时,它创建了一个新的包含 10 个元素的序列,并返回对该新序列的引用。

任何实现 IEnumerable 接口的对象都是一个你可以在 LINQ 中使用的序列。在该序列上执行操作按顺序称为枚举该序列。

LINQ 方法枚举你的序列

你已经知道foreach循环可以处理 IEnumerable 对象。想想foreach循环的工作原理:

图片

当方法按顺序遍历序列中的每个项时,这被称为枚举序列。这也是 LINQ 方法的工作方式。

注意

实现 IEnumerable 接口的对象可以被枚举。这就是实现 IEnumerable 接口的对象所做的工作。

注意

如果你想找到 Jimmy 收藏中从第 118 期开始的前 30 期问题,你会怎么做?LINQ 提供了一个非常有用的方法来帮助你。静态的 Enumerable.Range 方法生成一个整数序列。调用 Enumerable.Range(8, 5)会返回一个以 8 开头的 5 个数字序列:8, 9, 10, 11, 12。

图片

注意

在本练习中的 LINQ 方法具有能够清楚表明其功能的名称。一些 LINQ 方法,如 Sum、Min、Max、Count、First 和 Last,返回单个值。Sum 方法将序列中的值相加。Average 方法返回它们的平均值。Min 和 Max 方法返回序列中的最小值和最大值。First 和 Last 方法则按其字面意义执行操作。

其他 LINQ 方法,如 Take、TakeLast、Concat、Reverse(反转序列的顺序)和 Skip(跳过序列中的前几个元素),返回另一个序列。

LINQ 的查询语法

到目前为止你看到的 LINQ 方法可能不足以单独回答关于数据的问题,或者说 Jimmy 关于他的漫画收藏的问题。

这就是LINQ 声明式查询语法的用武之地。它使用特殊关键字,包括whereselectgroupbyjoin,直接在你的代码中构建查询

LINQ 查询由各种子句构建而成

让我们构建一个查询,找出 int 数组中小于 37 的数字,并按升序排列这些数字。它使用四个子句告诉它要查询哪个对象,如何确定选择哪些成员,如何对结果进行排序,以及如何返回结果。

注意

LINQ 查询适用于序列或实现 IEnumerable的对象。LINQ 查询以 from 子句开始:

from (variable) in (sequence)

它告诉查询执行对哪个序列,并为正在查询的每个元素分配一个名称。这就像foreach循环的第一行:它声明了一个变量,在遍历分配给该序列中每个元素时使用。因此:

from v in values

按顺序遍历values数组中的每个元素,将数组中的第一个值赋给 v,然后第二个,第三个,依此类推。

图片图片

LINQ 不仅适用于数字,它也适用于对象。

当吉米看着堆积如山的杂乱漫画时,他可能看到的是纸张、墨水和一团糟。而我们开发者看到的是另一番景象:大量的数据只等待整理。我们如何在 C# 中组织漫画数据?和我们组织纸牌、蜜蜂或者 Sloppy Joe 菜单上的物品一样:我们创建一个类,然后使用集合来管理这个类。因此,帮助吉米的所有我们需要的就是一个 Comic 类,以及一些代码来帮助我们给他的收藏带来一些理性。LINQ 将帮助我们!

LINQ 适用于对象

吉米想知道他一些珍藏漫画的价值,所以他雇了一位专业的漫画评估师为他每一本漫画评估价格。结果表明,他的一些漫画价值不菲!让我们使用集合来为他管理这些数据。

就这样!

  1. 创建一个新的控制台应用程序并添加一个 Comic 类。

    使用两个自动属性来表示名称和期号:

    图像

  2. 添加一个包含吉米目录的 List。

    为 Comic 类添加这个静态 Catalog 字段。它返回一个序列,其中包含吉米珍藏的漫画:

    图像

  3. 使用一个 Dictionary 来管理价格。

    添加一个静态的 Comic.Prices 字段——它是一个 Dictionary<int, decimal>,让你可以通过漫画的期号查找每本漫画的价格(使用你在 #enums_and_collections_organizing_your_da 学到的字典的集合初始化语法)。请注意,我们使用了 IReadOnlyDictionary 接口 进行封装——它是一个仅包含读取值方法的接口(这样我们就不会意外更改价格):

    图像

    注意

    我们使用 Dictionary 来存储漫画的价格。我们本可以在 Comic 类中包括一个名为 Price 的属性。但我们决定将漫画和价格信息分开。这是因为收藏品的价格随时会变动,但名称和期号将始终不变。你认为我们做出了正确的选择吗?

使用 LINQ 查询来完成吉米的应用程序

我们之前使用了 LINQ 声明查询语法来创建一个查询,其中包括四个子句:一个 from 子句创建一个范围变量,一个 where 子句只包括小于 37 的数字,一个 orderby 子句按降序对它们进行排序,以及一个 select 子句确定包含在结果序列中的元素。

让我们在 Main 方法中添加一个 LINQ 查询,它的工作方式完全相同——只是使用 Comic 对象而不是 int 值,因此它将按价格> 500 的顺序逆序打印漫画列表到控制台上。我们将从两个 using 声明开始,这样我们就可以使用 IEnumerable 和 LINQ 方法。查询将返回一个 IEnumerable,然后使用 foreach 循环来迭代它并输出结果。

  1. 修改 Main 方法以使用 LINQ 查询。

    这是整个 Program 类的代码,包括你需要添加到顶部的 using 指令:

    图片

    输出:

    Hippie Madness (misprinted) is worth $13,525.00
    Johnny America vs. the Pinko is worth $3,600.00
    Woman's Work is worth $650.00
    
  2. 使用 descending 关键字可以使你的 orderby 子句更易读。

    你的 orderby 子句使用减号来对漫画价格进行排序前的取反,导致查询按降序排序。但是在阅读代码并尝试理解其工作原理时,很容易无意中忽略这个减号。幸运的是,有另一种方法可以获得相同的结果。删除减号并在子句末尾添加 descending 关键字

    图片

使用 var 关键字可以让 C# 自动推断变量类型。

我们刚刚看到,当我们对 select 子句进行了小改动时,查询返回的序列类型也发生了变化。当它是 select comic; 时,返回类型是 IEnumerable。当我们将其改为 select $"{comic} is worth {Comic.Prices[comic.Issue]:c}"; 时,返回类型变为了 IEnumerable。在使用 LINQ 时,这种情况经常发生——你会不断地微调你的查询。很多时候并不明确它们确切的返回类型。有时候回过头去更新所有的声明会变得很烦人。

幸运的是,C# 给了我们一个非常有用的工具,帮助保持变量声明简单和易读。你可以用 var 关键字 替换任何变量声明。所以你可以用 var 替换下列任何声明:

IEnumerable<int> numbers = Enumerable.Range(1, 10);
string s = $"The count is {numbers.Count()}";
IEnumerable<Comic> comics = new List<Comic>();
IReadOnlyDictionary<int, decimal> prices = Comic.Prices;
注意

当你使用 var 关键字时,你告诉 C# 使用隐式类型的变量。我们在 #enums_and_collections_organizing_your_da 中也看到过同样的词——implicit——当我们讨论协变时。它意味着 C# 能够自行推断类型。

使用这些声明,它们确实做了完全相同的事情:

var numbers = Enumerable.Range(1, 10);
var s = $"The count is {numbers.Count()}";
var comics = new List<Comic>();
var prices = Comic.Prices;

而且你无需改动任何代码。只需用 var 替换类型,一切都能正常工作。

使用 var 时,C# 会自动推断变量的类型。

继续吧,现在就试一试。注释掉你刚写的 LINQ 查询的第一行,然后用 var 替换 IEnumerable

图片

IDE 推断出了 mostExpensive 变量的类型——它是一种我们以前没见过的类型。还记得在 #interfacescomma_castingcomma_and_quotati 里我们讨论过接口如何扩展其他接口吗?IOrderedEnumerable 接口是 LINQ 的一部分——它用于表示排序序列——并且它扩展了 IEnumerable 接口。试着将 orderby 子句注释掉并将鼠标悬停在 mostExpensive 变量上——你会发现它变成了 IEnumerable。这是因为 C# 查看代码以推断用 var 声明的任何变量的类型

图片

你真的可以在变量声明中使用 var。

而且,确实如此简单。很多 C# 开发者几乎总是使用var声明局部变量,并且仅在使代码更易读时包含类型。只要你在同一语句中声明并初始化变量,就可以使用var

但是在使用var时有一些重要的限制。例如:

  • 你只能使用var一次声明一个变量。

  • 你不能在声明中使用正在声明的变量。

  • 你不能将其声明为null

如果你创建一个名为var的变量,你将无法再将其用作关键字:

  • 你绝对不能使用var来声明字段或属性——你只能在方法内部使用它作为局部变量。

  • 如果你坚持这些基本规则,你几乎可以在任何地方使用var

所以当你在#types_and_references_getting_the_referen 中这样做时:

int hours = 24;
short RPM = 33;
long radius = 3;
char initial = 'S';
int balance = 345667 - 567;

或者在#inheritance_your_objectapostrophes_famil 中这样做:

SwordDamage swordDamage = new SwordDamage(RollDice(3));
ArrowDamage arrowDamage = new ArrowDamage(RollDice(1));

或者在#enums_and_collections_organizing_your_da 中的这种情况:

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

你本可以这样做:

var hours = 24;
var RPM = 33;
var radius = 3;
var initial = 'S';
var balance = 345667 - 567;

或者这样做:

var swordDamage = new SwordDamage(RollDice(3));
var arrowDamage = new ArrowDamage(RollDice(1));

或者这样做:

var cards = new List<Card>();

......而且你的代码将完全相同。

但是你不能使用var声明字段或属性:

class Program
{
   static var random = new Random(); // this will cause a compiler error

   static void Main(string[] args)
   {

LINQ 是多才多艺的。

使用 LINQ,你不仅可以从集合中提取一些项目,还可以在返回它们之前修改这些项目。一旦生成了一组结果序列,LINQ 提供了一堆方法来处理它们。从顶到底,LINQ 提供了你管理数据所需的工具。让我们快速回顾一下我们已经看到的一些 LINQ 特性。

  • 修改从查询返回的每个项目。

    此代码将一个字符串添加到字符串数组的每个元素末尾。它不会改变数组本身,而是创建一个新的修改后字符串序列。

    图片

  • 对序列执行计算。

    你可以单独使用 LINQ 方法来获取关于数字序列的统计信息。

    注意

    静态的 String.Join 方法将一个序列中所有项目连接为一个字符串,指定用于它们之间的分隔符。

    var random = new Random();
    var numbers = new List<int>();
    int length = random.Next(50, 150);
    for (int i = 0; i < length; i++)
        numbers.Add(random.Next(100));
    
    Console.WriteLine($@"Stats for these {numbers.Count()} numbers:
    The first 5 numbers: {String.Join(", ", numbers.Take(5))}
    The last 5 numbers: {String.Join(", ", numbers.TakeLast(5))}
    The first is {numbers.First()} and the last is {numbers.Last()}
    The smallest is {numbers.Min()}, and the biggest is {numbers.Max()}
    The sum is {numbers.Sum()}
    The average is {numbers.Average():F2}");
    

    图片

LINQ 查询在访问其结果之前不会运行。

当你在代码中包含 LINQ 查询时,它使用延迟评估(有时称为惰性评估)。这意味着直到你的代码执行一个使用查询结果的语句之前,LINQ 查询实际上不会进行枚举或循环。这听起来有点奇怪,但当你看到它实际运行时就会更有意义。创建一个新的控制台应用程序,并添加以下代码:

就这样!

图片

注意

你是否收到了奇怪的编译器错误?确保将两个 using 指令添加到你的代码中!

    class PrintWhenGetting
    {
        private int instanceNumber;
        public int InstanceNumber
        {
            set { instanceNumber = value; }
            get
            {
                Console.WriteLine($"Getting #{instanceNumber}");
                return instanceNumber;
            }
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            var listOfObjects = new List<PrintWhenGetting>();
            for (int i = 1; i < 5; i++)
                listOfObjects.Add(new PrintWhenGetting() { InstanceNumber = i });

            Console.WriteLine("Set up the query");
            var result =
                from o in listOfObjects
                select o.InstanceNumber;

            Console.WriteLine("Run the foreach");
            foreach (var number in result)
                Console.WriteLine($"Writing #{number}");
        }
    }

现在运行你的应用程序。注意打印 "Set up the query" 的 Console.WriteLine 在 get 访问器执行之前之前运行。这是因为直到 foreach 循环才会执行 LINQ 查询。

图片

如果你需要立即执行查询,可以通过调用需要枚举整个列表的 LINQ 方法来立即执行,例如 ToList 方法,将其转换为 List。添加此行,并更改 foreach 以使用新的列表:

        var immediate = result.ToList();

 Console.WriteLine("Run the foreach");
 foreach (var number in **immediate)**
 Console.WriteLine($"Writing #{number}");

再次运行应用程序。这次你会看到在开始执行 foreach 循环之前调用了 get 访问器——这是有道理的,因为 ToList 需要访问序列中的每个元素才能将其转换为 List。像 Sum、Min 和 Max 这样的方法也需要访问序列中的每个元素,因此当你使用它们时,也会强制 LINQ 立即执行。

使用 group 查询将你的序列分成组

有时你确实希望对数据进行切分和切块。例如,Jimmy 可能希望按照漫画出版的年代对其进行分组。或者他可能想按价格(便宜的放在一个集合中,昂贵的放在另一个集合中)分开它们。有很多理由你可能想要将数据分组在一起。这就是 LINQ group 查询 很有用的地方。

分组数据!

图片

  1. 创建一个新的控制台应用程序并添加卡片类和枚举。

    创建一个名为 CardLinq 的新 .NET Core 控制台应用程序。然后转到解决方案资源管理器面板,在项目名称上右键单击,选择添加 >> 现有项(或在 Mac 上选择添加 >> 现有文件)。导航到你保存的来自#enums_and_collections_organizing_your_da 的“Two Decks”项目所在的文件夹。添加带有Suit 和 Value 枚举的文件,然后添加Deck、Card 和 CardComparerByValue 类

    注意

    你在可下载的“Two Decks”项目的末尾创建了 Deck 类,在#enums_and_collections_organizing_your_da 中。

    确保修改每个文件中的命名空间以匹配 Program.cs 中的命名空间,以便你的 Main 方法可以访问你添加的类。

  2. 使用 IComparable 使你的 Card 类可排序。

    我们将使用 LINQ 的 orderby 子句对组进行排序,因此我们需要使 Card 类可排序。幸运的是,这与 List.Sort 方法完全相同,你在#interfacescomma_castingcomma_and_quotati 中学到过。修改你的 Card 类以扩展 IComparable 接口

    图片

  3. 修改 Deck.Shuffle 方法以支持方法链。

    Shuffle 类用于洗牌。要使其支持方法链,只需修改以返回刚刚被洗牌的 Deck 实例的引用:

    图片

  4. 使用带有 group...by 子句的 LINQ 查询,按花色分组卡片。

    主方法将通过洗牌整副牌随机取出 16 张牌,然后使用 LINQ 的 Take 方法取出前 16 张牌。然后它将使用带有group...by 子句的 LINQ 查询将整副牌分成较小的序列,每个序列对应 16 张牌中的一种花色:

    图片

注意

group...by 子句创建实现 IGrouping 接口的一系列组。IGrouping 扩展 IEnumerable 并且仅添加一个成员:一个名为 Key 的属性。因此,每个组都是其他序列的序列——在这种情况下,它是卡片序列的一个组,其中键是卡片的花色(来自Suits枚举)。每个组的完整类型是 IGrouping<Suits, Card>,这意味着它是卡片序列的序列,每个序列都以其花色作为键。

使用 join 查询合并来自两个序列的数据

每位好的收藏家都知道,关键评价对价值有很大影响。Jimmy 一直在跟踪两个大型漫评聚合网站,MuddyCritic 和 Rotten Tornadoes 的评审分数。现在他需要将它们与他的收藏匹配起来。他将如何做呢?

LINQ 来拯救!它的join关键字允许你使用单个查询结合来自两个序列的数据。它通过比较一个序列中的项与第二个序列中它们匹配的项来实现。 (LINQ 足够聪明,能够高效地执行此操作——除非必须比较每对项。)最终结果将组合每个匹配的一对项。

  1. 查询将从通常的from子句开始。但是在接下来不是添加用于确定结果的条件,而是添加:

    join name in collection
    

    join子句告诉 LINQ 枚举两个序列以匹配每对成员。它在每次迭代中将name分配给从已连接集合中拉出的成员。你将在where子句中使用该名称。

    图片

  2. 接下来你会加上**on**子句,告诉 LINQ 如何将这两个集合匹配在一起。接着是第一个集合中你要匹配的成员的名称,后面是**equals**和第二个集合中要匹配的成员的名称。

  3. 你将像往常一样继续 LINQ 查询,使用**where****orderby**子句。你可以用正常的**select**子句完成它,但通常希望返回的结果从一个集合中提取一些数据,从另一个集合中提取其他数据。

结果是一系列对象,这些对象具有 Comic 的 Name 和 Issue 属性,但具有 Review 的 Critic 和 Score 属性。结果不能是 Comic 对象的序列,但也不能是 Review 对象的序列,因为两个类都没有所有这些属性。结果是一种不同类型:匿名类型

使用 new 关键字创建匿名类型

从#objectshellipget_orientedexclamation_mar 开始,你一直在使用new关键字创建对象的实例。每次使用时,你都包含一个类型(因此语句new Guy();创建类型为 Guy 的实例)。你还可以使用new关键字而不带类型来创建一个匿名类型。这是一个完全有效的类型,具有只读属性,但没有名称。我们从将 Jimmy 的漫画与评论连接的查询中返回的类型就是匿名类型。你可以通过使用对象初始化程序为匿名类型添加属性。下面是其示例:

public class Program
{
    public static void Main()
    {
        var whatAmI = new { Color = "Blue", Flavor = "Tasty", Height = 37 };
        Console.WriteLine(whatAmI);
    }
}

尝试将其粘贴到新的控制台应用程序中并运行它。你将看到以下输出:

{ Color = Blue, Flavor = Tasty, Height = 37 }

现在将鼠标悬停在 IDE 中的whatAmI上,并查看 IntelliSense 窗口:

图片

注意

我们刚刚看到的将 Jimmy 的漫画与评论连接起来的 LINQ 查询返回了一个匿名类型。稍后的章节中,你将把该查询添加到应用程序中。

whatAmI变量是引用类型,就像任何其他引用一样。它指向堆上的一个对象,你可以使用它来访问该对象的成员——在本例中是其两个属性:

Console.WriteLine($"My color is {whatAmI.Color} and I’m {whatAmI.Flavor}");

除了它们没有名称外,匿名类型和任何其他类型都一样。

图片

没错!你使用var来声明匿名类型。

实际上,这是var关键字的最重要用途之一。

图片

单元测试帮助确保你的代码正常工作

我们在给你的代码中故意留了一个 bug……但这是应用程序中唯一的 bug 吗?编写代码时很容易出现不符合预期的情况。幸运的是,我们有一种方法可以找到 bug 并加以修复。单元测试自动化测试,可以帮助确保代码按预期工作。每个单元测试是一个方法,用于确保代码的特定部分(即被测试的“单元”)是否正常工作。如果方法运行时未抛出异常,则测试通过;如果抛出异常,则测试失败。大多数大型程序都有一套涵盖大部分或全部代码的测试套件

Visual Studio 内置了单元测试工具,帮助你编写测试并跟踪测试通过或失败的情况。本书中的单元测试将使用MSTest,这是由 Microsoft 开发的单元测试框架(意味着它是一组类,为你提供编写单元测试的工具)。

注意

Visual Studio 还支持在 NUnit 和 xUnit 两种流行的开源单元测试框架中编写单元测试,这两种框架均适用于 C#和.NET 代码。

Visual Studio for Windows 有一个测试资源管理器窗口

通过选择View >> Test Explorer从主菜单栏打开测试资源管理器窗口。左侧显示单元测试,右侧显示最近运行的结果。工具栏有按钮用于运行所有测试、运行单个测试和重复上次运行。

图片

注意

当您向解决方案添加单元测试时,您可以通过点击“运行所有测试”按钮运行您的测试。您可以通过选择“测试 >> 调试所有测试”在 Windows 上调试您的单元测试,并在 Mac 上通过在单元测试工具窗口中点击“调试所有测试”来调试。

图片

Visual Studio for Mac 拥有单元测试工具窗口。

通过选择 视图 >> 工具窗口 >> 单元测试 打开单元测试面板。它有运行或调试您的测试的按钮。运行单元测试时,IDE 会在测试结果工具窗口中显示结果(通常在 IDE 窗口底部)。

图片

注意

回到 #objectshellipget_orientedexclamation_mar 您了解到原型,或者可以玩、测试、学习和改进的游戏的早期版本,以及这个想法如何适用于任何类型的项目,而不仅仅是游戏。测试软件的概念有时可能显得有点抽象。考虑游戏开发人员如何测试他们的游戏可以帮助我们习惯这个想法,并使测试的概念更加直观。

注意

大多数开发团队尝试在每次提交之前自动化尽可能多的测试。这样,他们就知道在修复错误或添加新功能时是否无意中引入了 bug。

在 Jimmy 的漫画收藏应用程序中添加一个单元测试项目。

  1. 添加一个新的 MSTest (.NET Core) 项目。

    在解决方案资源管理器中右键单击解决方案名称,然后选择 添加 >> 新项目... 从菜单栏。确保选择 MSTest 测试项目 (.NET Core):在 Windows 上使用“搜索模板”框搜索 MSTest;在 macOS 下选择“Web 和控制台”下的“测试”以查看项目模板。将项目命名为 JimmyLinqUnitTests

  2. 在现有项目上添加依赖项。

    您将为 ComicAnalyzer 类编写单元测试。当您在同一解决方案中有两个不同的项目时,它们是 独立的——默认情况下,一个项目中的类不能使用另一个项目中的类——因此,您需要设置依赖关系以让您的单元测试使用 ComicAnalyzer。

    展开解决方案资源管理器中的 JimmyLinqUnitTests 项目,然后右键单击依赖项并选择 添加引用... 从菜单中选择检查您为练习创建的 JimmyLinq 项目。

    图片

  3. 使您的 ComicAnalyzer 类公开。

    当 Visual Studio 添加单元测试项目时,它会创建一个名为 UnitTest1 的类。编辑 UnitTest1.cs 文件,并尝试在命名空间内添加 using JimmyLinq; 指令:

    图片

    哦,有些问题——IDE 不允许你添加该指令。原因是 JimmyLinq 项目没有公共类、枚举或其他成员。尝试修改 Critics 枚举以使其公开 (**public enum Critics**),然后返回并尝试添加 using 指令。现在你可以添加它了!IDE 看到 JimmyLinq 命名空间有公共成员,并将其添加到弹出窗口中。

    现在将 ComicAnalyzer 声明更改为公共:**public static class ComicAnalyzer**

    哎呀,出错了。 你收到了一堆“不一致的可访问性”编译器错误吗?

    图片

    问题在于 ComicAnalyzer 是公共的,但它公开了没有访问修饰符的成员,这使得它们 **internal** ——因此,解决方案中的其他项目无法看到它们。 在 JimmyLinq 项目中的每个类和枚举上添加 public 访问修饰符。现在你的解决方案将再次构建成功。

编写你的第一个单元测试

IDE 向你的新 MSTest 项目中添加了一个名为 UnitTest1 的类。将该类(以及文件)重命名为 ComicAnalyzerTests。该类包含一个名为 TestMethod1 的测试方法。接下来,给它起一个非常描述性的名称:将方法重命名为 ComicAnalyzer_Should_Group_Comics。这是你的单元测试类的代码:

图片

注意

当你在 IDE 中运行单元测试时,它会查找任何带有 [TestClass] 的类。这称为属性。测试类包含测试方法,必须使用 [TestMethod] 属性进行标记。

MSTest 单元测试使用 Assert 类,该类具有你可以用来检查代码行为的静态方法。此单元测试使用 Assert.AreEqual 方法。它接受两个参数,预期结果(你认为代码应该做什么)和实际结果(它实际上做了什么),如果它们不相等则抛出异常。

此测试设置了一些非常有限的测试数据:三部漫画的序列和一个包含三个价格的字典。然后调用 GroupComicsByPrice,并使用 Assert.AreEqual 来验证结果是否与我们预期的一致。

通过选择 测试 >> 运行所有测试(Windows)或 运行 >> 运行单元测试(Mac)来运行你的测试。IDE 将弹出一个测试资源管理器窗口(Windows)或测试结果面板(Mac),显示测试结果:

Test method JimmyLinqUnitTests.ComicAnalyzerTests.ComicAnalyzer_Should_Group_Comics threw exception:
System.Collections.Generic.KeyNotFoundException: The given key ‘2’ was not present in the dictionary.

这是一个 失败的单元测试 结果。在 Windows 中,查找 图片 图标或在 Visual Studio for Mac 窗口底部查看 图片 消息,这是你查看失败单元测试计数的方法。

你期待该单元测试失败吗?你能弄清楚测试出了什么问题吗?

为 GetReviews 方法编写一个单元测试

GroupComicsByPrice 方法的单元测试使用了 MSTest 的静态 Assert.AreEqual 方法来检查预期值与实际值。GetReviews 方法返回一个字符串序列,而不是单个值。我们可以使用 Assert.AreEqual 来比较该序列中的单个元素,就像我们在最后两个断言中所做的那样,使用 LINQ 方法如 First 来获取特定元素……但那会需要大量的代码。

幸运的是,MSTest 有一种更好的比较集合的方法:CollectionAssert 类有用于比较预期结果与实际集合结果的静态方法。因此,如果你有一个包含预期结果的集合和一个包含实际结果的集合,你可以像这样进行比较:

   CollectionAssert.AreEqual(expectedResults, actualResults);

如果预期结果和实际结果不匹配,测试将失败。继续添加这个测试以验证 ComicAnalyzer.GetReviews 方法:

[TestMethod]
public void ComicAnalyzer_Should_Generate_A_List_Of_Reviews()
{
    var testReviews = new[]
    {
        new Review() { Issue = 1, Critic = Critics.MuddyCritic, Score = 14.5},
        new Review() { Issue = 1, Critic = Critics.RottenTornadoes, Score = 59.93},
        new Review() { Issue = 2, Critic = Critics.MuddyCritic, Score = 40.3},
        new Review() { Issue = 2, Critic = Critics.RottenTornadoes, Score = 95.11},
    };

    var expectedResults = new[]
    {
        "MuddyCritic rated #1 ‘Issue 1’ 14.50",
        "RottenTornadoes rated #1 ‘Issue 1’ 59.93",
        "MuddyCritic rated #2 ‘Issue 2’ 40.30",
        "RottenTornadoes rated #2 ‘Issue 2’ 95.11",
    };

    var actualResults = ComicAnalyzer.GetReviews(testComics, testReviews).ToList();
    CollectionAssert.AreEqual(expectedResults, actualResults);
}

现在重新运行你的测试。你应该会看到两个单元测试通过。

编写单元测试以处理边界情况和奇怪的数据

在现实世界中,数据通常是混乱的。例如,我们从未准确告诉你评论数据应该是什么样子的。你见过评论分数在 0 到 100 之间。你以为这些是唯一允许的值吗?在现实世界中,确实有一些评论网站是这样操作的。如果我们得到一些奇怪的评论分数——比如负数、非常大的数或者零怎么办?如果一个评审人为同一个问题给出多个评分怎么办?即使这些事情本不应该发生,它们可能会发生。

我们希望我们的代码健壮,这意味着它能很好地处理问题、失败,特别是坏的输入数据。因此,让我们构建一个单元测试,向 GetReviews 传递一些奇怪的数据,并确保它不会出问题:

图片

注意

总是抽出时间为边界情况和奇怪的数据编写单元测试——把它们看作“必需”而不是“可有”的测试。单元测试的目的是尽可能广泛地捕捉错误,这些类型的测试对此非常有效。

添加处理边界情况和奇怪数据的单元测试非常重要。它们可以帮助你发现你代码中否则无法找到的问题。

图片

当你编写单元测试时,实际上你的项目会更快。

我们是认真的!也许你觉得写更多代码却花费更少时间有些反直觉,但是如果你习惯于编写单元测试,你的项目将会更加顺利,因为你能早期发现并修复 Bug。在本书的前八个半章节中,你已经写了大量代码,这意味着你几乎肯定不得不追踪并修复过代码中的 Bug。当你修复这些 Bug 时,是否需要同时修复项目中的其他代码?当我们发现意外 Bug 时,通常必须停下手头的工作来追踪和修复它,这样来回切换—丢失思路,不得不打断工作流—可能会减慢工作速度。单元测试帮助你早期发现这些 Bug,在它们干扰你工作之前。

还在疑惑什么时候应该编写单元测试吗?我们在章节末尾包含了一个可下载的项目来帮助解答这个问题。

使用 => 运算符创建 Lambda 表达式

我们在章节开头留下了你的疑问。 还记得我们让你在 Comic 类中添加的神秘行吗?在这里又是一次:

    public override string ToString() => $"{Name} (Issue #{Issue})";

你在整章中一直在使用 ToString 方法—你知道它能工作。如果我们要求你按照迄今为止编写方法的方式来重写该方法,你可能会写出这样的代码:

    public override string ToString() {
        return $"{Name} (Issue #{Issue})";
    }

基本上你是对的。那到底发生了什么?那个 => 运算符到底是什么?

你在 ToString 方法中使用的 => 运算符是Lambda 运算符。你可以使用 => 定义Lambda 表达式,或者在单个语句中定义的匿名函数。Lambda 表达式看起来像这样:

    (input-parameters) => expression;

Lambda 表达式分为两部分:

  • input-parameters 部分是一个参数列表,就像你在声明方法时使用的那样。如果只有一个参数,可以省略括号。

  • expression 是任何 C# 表达式:可以是插值字符串,使用运算符的语句,方法调用—基本上你放在语句中的任何东西。

Lambda 表达式起初可能看起来有些奇怪,但它们只是使用同样熟悉的 C# 表达式的另一种方式,这些表达式你在整本书中都在使用—就像 Comic.ToString 方法一样,无论你是否使用 Lambda 表达式,它都能正常工作。

图片

是的!你可以使用 Lambda 表达式重构许多方法和属性。

在本书中,你已经编写了很多仅包含一个语句的方法。你可以将其中大部分重构为使用 Lambda 表达式。在许多情况下,这可以使你的代码更易于阅读和理解。Lambda 给你提供了选择—你可以决定何时使用它们来改进你的代码。

Lambda 测试驱动

图片

让我们试用一下 Lambda 表达式,这为我们提供了一种全新的编写方法的方式,包括返回值或者带参数的方法。

  1. 创建一个新的控制台应用。添加这个带有 Main 方法的 Program 类:

    class Program
    {
        static Random random = new Random();
        static double GetRandomDouble(int max)
        {
            return max * random.NextDouble();
        }
        static void PrintValue(double d)
        {
            Console.WriteLine($"The value is {d:0.0000}");
        }
        static void Main(string[] args)
        {
            var value = Program.GetRandomDouble(100);
            Program.PrintValue(value);
        }
    }
    

    图片

    运行几次。每次它都会打印一个不同的随机数,例如:The value is 37.8709

  2. 使用 => 运算符重构 GetRandomDouble 和 PrintValue 方法

    图片

    再次运行你的程序——它应该像以前一样打印不同的随机数。

    在我们进行最后一次重构之前,悬停在随机字段上并查看 IntelliSense 弹出窗口:

    图片

  3. 修改随机字段以使用 lambda 表达式:

     static Random random **=>** new Random();
    

    程序仍然以相同的方式运行。现在再次悬停在随机字段上:

    图片

    等一下——random 不再是一个字段了。将它改成 lambda 表达式将它变成了一个属性!这是因为lambda 表达式总是像方法一样工作。所以当 random 是一个字段时,它在类构造时被实例化一次。当你把=改成=>并将其转换为 lambda 时,它变成了一个方法——这意味着每次访问属性时都会创建一个新的 Random 实例。

使用 lambda 重构小丑

回到 #interfacescomma_castingcomma_and_quotati,你创建了一个名为 IClown 的接口,有两个成员:

去做吧!

图片

你修改了这个类来实现该接口:

class TallGuy {
    public string Name;
    public int Height;

    public void TalkAboutYourself() {
        Console.WriteLine($"My name is {Name} and I’m {Height} inches tall.");
    }
}

让我们再做同样的事情——但这次我们将使用 lambda 表达式。创建一个新的控制台应用程序项目,并添加 IClown 接口和 TallGuy 类。然后修改 TallGuy 以实现 IClown:

class TallGuy : IClown {

现在打开快速操作菜单,选择“实现接口。”IDE 将填写所有接口成员,使它们像使用生成方法时一样抛出 NotImplementedExceptions。

图片

让我们重构这些方法,使它们能够像以前一样完成相同的工作,但现在使用 lambda 表达式:

    public string FunnyThingIHave => "big red shoes";
    public void Honk() => Console.WriteLine("Honk honk!");

图片

现在添加与你在 #interfacescomma_castingcomma_and_quotati 中使用的相同的 Main 方法:

 TallGuy tallGuy = new TallGuy() { Height = 76, Name = "Jimmy" };
 tallGuy.TalkAboutYourself();
 Console.WriteLine($"The tall guy has {tallGuy.FunnyThingIHave}");
 tallGuy.Honk();

运行你的应用程序。TallGuy 类的工作方式与 #interfacescomma_castingcomma_and_quotati 中一样,但现在我们已经重构了它的成员,使用 lambda 表达式使得它更加紧凑。

我们认为经过改进的 TallGuy 类更容易阅读。你觉得呢?

图片

你可以使用 => 运算符创建一个具有 get 访问器执行 lambda 表达式的属性。

使用 ?: 运算符让你的 lambda 表达式做出选择

如果你希望你的 lambda 做更多的事情怎么办?如果它们能做出决策就好了……这就是条件运算符(有些人称之为三元运算符)派上用场的地方。它的工作方式如下:

    condition ? consequent : alternative;

刚开始可能看起来有点奇怪,所以让我们看一个示例。首先,?: 运算符并不局限于 lambda 表达式——你可以在任何地方使用它。从 AbilityScoreCalculator 类中的 if 语句中取这个 if 语句:

图片

注意如何将 Score 设置为 ?: 表达式的结果。?: 表达式返回一个值:它检查条件(added < Minimum),然后返回结果(Minimum)或替代项(added)。

当你有一个看起来像 if/else 语句的方法时,你可以使用 ?: 来将其重构为 lambda。例如,从 PaintballGun 类中的方法获取:

图片

让我们将其重写为更简洁的 lambda 表达式:

注意细微的变化——在 if/else 版本中,BallsLoaded 属性是在 then 和 else 语句中设置的。我们改为使用条件运算符来检查 balls 是否符合 MAGAZINE_SIZE 并返回正确的值,并使用该返回值设置 BallsLoaded 属性。

Lambda 表达式和 LINQ

将此小型 LINQ 查询添加到任何 C# 应用程序中,然后将鼠标悬停在代码中的 select 关键字上:

图片

IDE 弹出一个工具提示窗口,就像你悬停在方法上时一样***。让我们更仔细地看一看第一行,显示了方法声明:

图片

我们可以从该方法声明中学到一些东西:

  • IEnumerable.Select 方法返回一个 IEnumerable

  • 它接受一个类型为 Func<int, int> 的单一参数。

使用带有 Func 参数的方法的 lambda 表达式

当一个方法接受一个 Func<int, int> 参数时,你可以使用一个接受 int 参数并返回 int 值的 lambda 表达式来调用它。因此,你可以像这样重构 select 查询:

 var array = new[] { 1, 2, 3, 4 };
 var result = array.Select(i => i * 2);

前往——尝试在控制台应用程序中自己尝试。添加一个 foreach 语句来打印输出:

foreach (var i in result) Console.WriteLine(i);

当你打印重构后的查询结果时,你会得到序列 { 2, 4, 6, 8 },与你在重构之前使用 LINQ 查询语法获得的结果完全相同。

LINQ 查询可以编写为链式 LINQ 方法

从之前的 LINQ 查询中获取这个 LINQ 查询,将其添加到一个应用程序中,以便我们可以探索更多的 LINQ 方法:

       int[] values = new int[] { 0, 12, 44, 36, 92, 54, 13, 8 };
       IEnumerable<int> result =
                  from v in values
                  where v < 37
                  orderby -v
                  select v;

OrderBy LINQ 方法对序列进行排序

悬停在 **orderby** 关键字上,并查看其参数:

图片

当你在 LINQ 查询中使用 orderby 子句时,它调用一个 LINQ OrderBy 方法对序列进行排序。在这种情况下,我们可以传递一个带有 int 参数的 lambda 表达式返回排序键,或者实现了 IComparer 的任何值(必须实现 IComparer),以便它可以用来对结果进行排序。

Where LINQ 方法从序列中提取一个子集

现在将鼠标悬停在 LINQ 查询中的 **where** 关键字上:

图片

where 子句在 LINQ 查询中调用一个 LINQ Where 方法,该方法可以使用返回布尔值的 lambda 表达式。Where 方法针对序列中的每个元素调用该 lambda 表达式。如果 lambda 表达式返回 true,则将该元素包含在结果中。如果 lambda 表达式返回 false,则将该元素移除。

使用 => 运算符创建 switch 表达式

自从#inheritance_your_objectapostrophes_famil 以来,您一直在使用 switch 语句来检查变量是否符合多个选项。这是一个非常有用的工具……但您是否注意到了它的局限性?例如,尝试添加一个针对变量的 case:

   case myVariable:

您将收到一个 C# 编译器错误:需要常量值。这是因为您只能在您一直在使用的 switch 语句中使用常量值,例如文字常量和使用 const 关键字定义的变量。

但是,=> 运算符改变了一切,它让您可以创建 switch 表达式。它们与您一直在使用的 switch 语句类似,但它们是表达式,会返回一个值。switch 表达式以要检查的值开头,后跟关键字 switch,然后是一系列用逗号分隔的switch arm,在花括号内。每个 switch arm 使用 => 运算符根据表达式检查值。如果第一个 arm 不匹配,则移动到下一个 arm,返回匹配 arm 的值。

图片

假设您正在开发一个需要根据花色分配特定分数的纸牌游戏,其中黑桃值为 6,红心值为 4,其他牌值为 2。您可以编写如下的 switch 语句:

图片

switch 语句的整个目的是使用 case 来设置 score 变量——我们的许多 switch 语句都是这样工作的。我们可以使用 => 运算符创建一个执行相同操作的 switch 表达式:

图片

探索 Enumerable 类

我们已经使用序列一段时间了。我们知道它们可以与 foreach 循环和 LINQ 一起使用。但究竟是什么使序列运行起来?让我们深入探讨一下。我们将从 Enumerable 类 开始——具体来说,是它的三个静态方法,Range、Empty 和 Repeat。您已经在本章的早些时候看到了 Enumerable.Range 方法。让我们使用 IDE 发现其他两种方法的工作原理。键入 **Enumerable**。,然后将鼠标悬停在 IntelliSense 弹出窗口中的 Range、Empty 和 Repeat 上,以查看它们的声明和注释。

图片

Enumerable.Empty 创建任何类型的空序列

有时候,您需要向接受 IEnumerable 的方法传递一个空序列(例如,在单元测试中)。在这些情况下,Enumerable.Empty 方法非常方便:

var emptyInts = Enumerable.Empty<int>(); // an empty sequence of ints
var emptyComics = Enumerable.Empty<Comic>(); // an empty sequence of Comic references

Enumerable.Repeat 重复一个值多次

假设你需要一个包含 100 个 3s、12 个 “yes” 字符串或者 83 个相同匿名对象的序列。你会惊讶地发现这种情况经常发生!你可以使用 Enumerable.Repeat 方法来做到这一点——它返回一个重复值的序列:

var oneHundredThrees = Enumerable.Repeat(3, 100);
var twelveYesStrings = Enumerable.Repeat("yes", 12);
var eightyThreeObjects = Enumerable.Repeat(
    new { cost = 12.94M, sign = "ONE WAY", isTall = false }, 83);

那么,究竟什么是 IEnumerable

我们已经使用 IEnumerable 一段时间了。我们还没有真正回答可枚举序列到底是什么的问题。理解某个东西的一个真正有效的方法是从头开始构建它,所以让我们通过从头开始构建一些序列来完成本章。

手动创建一个可枚举序列

假设我们有一些体育运动:

   enum Sport { Football, Baseball, Basketball, Hockey, Boxing, Rugby, Fencing }

显然,我们可以创建一个新的 List 并使用集合初始化器来填充它。但是为了探索序列如何工作,我们将手动构建一个。让我们创建一个名为 ManualSportSequence 的新类,并使其实现 IEnumerable 接口。它只有两个成员,返回一个 IEnumerator:

图片

好的,那么 IEnumerator 是什么?它是一个接口,允许你枚举一个序列,逐个遍历序列中的每个项目。它有一个属性,Current,返回当前正在枚举的项目。它的 MoveNext 方法移动到序列中的下一个元素,如果序列已经耗尽,则返回 false。在调用 MoveNext 后,Current 返回下一个元素。最后,Reset 方法将序列重置回起始状态。一旦你有了这些方法,你就有了一个可枚举序列。

图片

所以让我们实现一个 IEnumerator

图片

这就是我们创建自己的 IEnumerable 所需的全部内容。继续——试试吧。创建一个新的控制台应用程序,添加 ManualSportSequence 和 ManualSportEnumerator,然后在 foreach 循环中枚举这个序列:

    var sports = new ManualSportSequence();
    foreach (var sport in sports)
        Console.WriteLine(sport);

使用 yield return 来创建你自己的序列

C# 提供了一种更容易创建可枚举序列的方法:**yield return** 语句。yield return 语句是一种全自动枚举器创建器。理解它的一个好方法是看一个例子。让我们使用 多项目解决方案,这样你就能更多地练习一下。

将新的控制台应用程序项目添加到你的解决方案中 —— 这与你之前在章节中添加 MSTest 项目时所做的操作一样,只不过这次不是选择 MSTest 项目类型,而是选择你在书中大多数项目中使用的相同控制台应用程序项目类型。然后,在解决方案下右键单击项目,选择 “Set as startup project.” 当你在 IDE 中启动调试器时,它将运行新项目。你也可以右键单击解决方案中的任何项目并运行或调试它。

这是新控制台应用程序的代码:

图片

运行应用程序 —— 它会打印出四行:applesorangesbananasunicorns。那么这是如何工作的呢?

使用调试器探索 yield return

Main 方法的第一行设置一个断点,然后启动调试器。然后使用逐步执行(F11 / 图片)逐行调试代码,直到进入迭代器:

  • 进入代码,并一直步进直到到达 SimpleEnumerable 方法的第一行。

  • 再次进入该行。它的作用就像是一个 return 语句,将控制权返回给调用它的语句——在本例中,回到了 foreach 语句,后者调用 Console.WriteLine 输出 apples

  • 再向前迈出两步。你的应用程序将回到 SimpleEnumerable 方法,但跳过方法的第一条语句,直接进入第二行:

    图片

  • 继续步进。应用程序返回到 foreach 循环,然后回到方法的第三行,然后返回到 foreach 循环,再回到方法的第四行

因此,yield return 使一个方法返回一个可枚举序列,每次调用时返回序列中的下一个元素,并跟踪它返回的位置,以便可以从上次离开的地方继续执行。

使用 yield return 重构 ManualSportSequence

你可以通过使用 yield return 实现 GetEnumerator 方法来创建自己的 IEnumerable<T>。例如,这里有一个 BetterSportSequence 类,它与 ManualSportSequence 做的事情完全一样。这个版本更加紧凑,因为它在其 GetEnumerator 实现中使用了 yield return

图片

继续添加新的控制台应用程序项目到你的解决方案。添加这个新的 BetterSportSequence 类,并修改 Main 方法来创建它的实例并枚举序列。

BetterSportSequence 添加一个索引器

你已经看到可以在方法中使用 yield return 创建 IEnumerator<T>。你还可以用它创建实现 IEnumerable<T> 的类。为序列创建单独的类的一个优点是,你可以添加一个索引器。你已经使用过索引器——任何时候使用方括号 [] 从列表、数组或字典中检索对象(如 myList[3]myDictionary["Steve"]),你都在使用索引器。索引器只是一个方法。它看起来很像属性,只是它有一个命名参数。

IDE 有一个特别有用的代码片段,帮助你添加索引器。输入 **indexer**,然后按两次 Tab 键,IDE 将自动为你添加索引器的框架。

这是 SportCollection 类的索引器:

    public Sport this[int index] {
        get => (Sport)index;
    }

使用 [3] 调用索引器返回值 Hockey

    var sequence = new BetterSportSequence();
    Console.WriteLine(sequence[3]);

当你使用片段创建索引器时,请仔细查看——它允许你设置类型。你可以定义一个接受不同类型(包括字符串和对象)的索引器。虽然我们的索引器只有一个 getter,但你也可以包含一个 setter(就像你用来在列表中设置项目的 setter 一样)。

集合交叉

图片

EclipseCrossword.com

Across

1. 使用 var 关键字声明一个 _____ 类型的变量

7. 一个集合 _____ 结合声明和要添加的项

9. 当你有大量关于奇怪数据和边界情况的测试时,你试图让你的代码成为什么样子

11. 返回序列中最后元素的 LINQ 方法

12. 后进先出(LIFO)集合

18. 返回序列中第一个元素的 LINQ 方法

19. 具有多个不同参数的构造函数的方法

20. 告诉你可以使用 lambda 的参数类型

21. 当对整个列表进行上转型时,你利用了什么

22. 当你调用 myArray[3] 时所使用的东西

25. 当你在类或接口定义中看到 时,T 被替换成什么

32. 创建匿名对象所使用的关键字

33. 只允许特定值的数据类型

34. 可以存储任何类型的集合类型

35. 所有序列都实现的接口

36. 条件运算符 ?: 的另一个名称

Down

1. 如果你想对 List 进行排序,其成员需要实现这个

2. 按顺序存储项目的集合类

3. 存储键和值的集合

4. 告诉 List.Sort 如何对其项进行排序的内容

Down

5. 括号中应填写的内容:( _____ ) => expression;

6. 你不能使用 var 关键字声明其中一个的类型

8. 在多项目解决方案中不能被其他项目访问的类的访问修饰符

10. => 操作符创建的表达式类型

13. 将一个序列的元素追加到另一个序列末尾的 LINQ 方法

14. 每个集合都有的方法,用于向其中添加新元素

15. 在一个类中返回该类类型的方法可以做什么

16. 当 IDE 告诉你这个时,你正在查看哪种类型:’a’ is a new string Color, int Height

17. 对象的命名空间后跟一个点,再跟类名,就是完全 _____ 类名

23. 意味着直到访问其结果才运行 LINQ 查询的评估类型

24. 在 LINQ 查询中对结果进行排序的子句

26. 在 LINQ 查询的 from 子句中创建的变量类型

27. 返回包含多个相同元素序列的 Enumerable 方法

28. 在 LINQ 查询中确定要使用的输入元素的子句

29. 合并两个序列数据的 LINQ 查询

30. 先进先出(FIFO)集合

31. switch 表达式没有但 switch 语句有的关键字

集合交叉解决方案

Images

第十五章:读写文件:为我保存最后一个字节!

图片

有时坚持一下是值得的。

到目前为止,您所有的程序都相当短暂。它们启动,运行一段时间,然后关闭。但这通常不够,特别是当您处理重要信息时。您需要能够保存您的工作。在本章中,我们将学习如何将数据写入文件,然后如何从文件中读取这些信息。您将了解的概念,以及如何使用序列化将对象存储到文件中,深入探讨十六进制Unicode二进制数据的实际比特和字节。

.NET 使用流来读取和写入数据。

是 .NET Framework 用来将数据输入和输出到程序的方式。每当您的程序读取或写入文件、通过网络连接到另一台计算机,或者通常做任何需要发送或接收字节的操作时,您都在使用流。有时您会直接使用流,有时间接使用。即使在使用不直接暴露流的类时,在幕后它们几乎总是在使用流。

每当您需要从文件读取数据或向文件写入数据时,都会使用一个 Stream 对象。

注意

假设您有一个简单的应用程序需要从文件中读取数据。最基本的方法之一是使用 Stream 对象。

图片

注意

如果您的应用程序需要将数据写入文件,则可以使用另一个 Stream 对象。

图片

不同的流读取和写入不同的内容。

每个流都是抽象 Stream 类的子类,有许多不同的流子类执行不同的功能。我们将集中讨论读写常规文件,但是在本章中学到的有关流的知识也可以应用于压缩或加密文件,或者根本不使用文件的网络流。

图片

流可执行的操作:

  1. 向流中写入数据。

    您可以通过流的Write 方法将数据写入流中。

  2. 从流中读取数据。

    您可以使用Read 方法从文件、网络、内存或几乎任何其他位置使用流获取数据。即使是非常大的文件,即使它们太大而无法放入内存中,您也可以使用流读取数据。

  3. 在流中更改位置。

    大多数流支持**Seek 方法**,允许您在流中找到位置,以便在特定位置读取或插入数据。然而,并非每个 Stream 类都支持 Seek,这是有道理的,因为在某些流数据源中无法回溯。

流允许您读取和写入数据。根据您处理的数据类型选择适当的流类型。

FileStream 在文件中读取和写入字节。

当程序需要向文件写入几行文本时,需要执行很多操作:

  1. 创建一个新的 FileStream 对象,并告诉它写入文件。

    图片

  2. FileStream 会连接到一个文件。

    图片

  3. 流将字节写入文件,所以你需要将要写入的字符串转换为字节数组。

    图片

  4. 调用流的 Write 方法并传递字节数组。

    图片

  5. 关闭流以便其他程序能够访问文件。

    图片

在三个简单步骤中向文件写入文本

C# 提供了一个方便的类叫做StreamWriter,它为你简化了这些操作。你只需要创建一个新的 StreamWriter 对象并给它一个文件名。它会自动创建一个 FileStream 并打开文件。然后你可以使用 StreamWriter 的 Write 和 WriteLine 方法将所有内容写入你想要的文件中。

StreamWriter 会为你自动创建和管理一个 FileStream 对象。

  1. 使用 StreamWriter 的构造函数来打开或创建文件。

    你可以将文件名传递给 StreamWriter 的构造函数。这样做时,写入器会自动打开文件。StreamWriter 还有一个重载的构造函数,可以让你指定它的追加模式:传递true告诉它将数据添加到现有文件的末尾(或追加),传递false告诉流删除现有文件并创建一个同名的新文件。

    var writer = new StreamWriter("toaster oven.txt", true);
    

    图片

  2. 使用 Write 和 WriteLine 方法来写入文件。

    这些方法的工作方式与控制台类似:Write 写入文本,WriteLine 写入文本并在末尾添加换行。

    writer.WriteLine($"The {appliance} is set to {temp} degrees.");
    

    图片

  3. 调用 Close 方法释放文件。

    如果你保持流处于打开状态并连接到一个文件中,那么它会锁定该文件,其他程序将无法使用它。所以一定要确保始终关闭你的文件!

    writer.Close();
    

骗子启动了另一个毒计划

Objectville 的居民长久以来一直生活在对奇才队长的宿敌骗子的恐惧中。现在他正在使用 StreamWriter 实施另一个邪恶计划。让我们看看发生了什么。创建一个新的控制台应用项目并添加此 Main 代码,从一个 using 声明开始,因为 StreamWriter 在System.IO 命名空间中:

注意

StreamWriter 的 Write 和 WriteLine 方法的工作方式与控制台的类似:Write 写入文本,WriteLine 写入文本并换行。这两个类都支持像这样的 {花括号}。

sw.WriteLine("Clone #{0} attacks {1}",
          number, location);

当你在文本中包含 {0} 时,它会被字符串后的第一个参数替换;{1} 被第二个参数替换,{2} 被第三个参数替换,依此类推。

图片图片

注意

骗子是奇才队长的宿敌,一个阴影般的超级恶棍,企图统治 Objectville。

图片

这是它写入到 secret_plan.txt 中的输出:

输出

How I'll defeat Captain Amazing
Another genius secret plan by The Swindler
I'll unleash my army of clones upon the citizens of
Objectville.
Clone #1 attacks the mall
Clone #2 attacks downtown
Clone #3 attacks the mall
Clone #4 attacks downtown
Clone #5 attacks the mall

StreamWriter 磁铁

图片

糟糕!这些磁铁原本很好地排列在冰箱上,用于 Flobbo 类的代码,但有人猛地关上了门,它们全部掉了下来。你能重新排列它们,使得 Main 方法产生下面的输出吗?

static void Main(string[] args) {
    Flobbo f = new Flobbo("blue yellow");
    StreamWriter sw = f.Snobbo();
    f.Blobbo(f.Blobbo(f.Blobbo(sw), sw), sw);
}

我们增加了一个额外的挑战。

Blobbo 方法似乎有些奇怪。看看它在前两个磁铁中有两个不同的声明?我们定义 Blobbo 作为重载方法——就像你在前几章中使用的重载方法一样,每个都有自己的参数。

图像

StreamWriter 磁铁解决方案

图像

你的任务是从磁铁中构建 Flobbo 类,以创建所需的输出。

static void Main(string[] args) {
    Flobbo f = new Flobbo("blue yellow");
    StreamWriter sw = f.Snobbo();
    f.Blobbo(f.Blobbo(f.Blobbo(sw), sw), sw);
}

图像

注意

只是提醒:我们在这些谜题中故意选择了奇怪的变量名和方法,因为如果我们使用真正好的名字,那么这些谜题就太容易了!在你的代码中不要使用这样的名字,好吗?

使用 StreamReader 来读取文件

让我们用StreamReader来阅读骗子的秘密计划,这个类很像 StreamWriter——不过不是写文件,而是在构造函数中创建一个 StreamReader,并传递文件名。它的 ReadLine 方法返回一个包含文件中下一行的字符串。你可以编写一个循环,从中读取行,直到其 EndOfStream 字段为 true——那时就读完了所有行。添加这个控制台应用程序,使用 StreamReader 来读取一个文件,同时使用 StreamWriter 来写入另一个文件:

注意

StreamReader 是一个从流中读取字符的类,但它本身不是流。当你将文件名传递给它的构造函数时,它会为你创建一个流,并在调用其 Close 方法时关闭它。它还有一个重载的构造函数,它接受一个流的引用。

图像

注意

输出

To: CaptainAmazing@objectville.net
From: Commissioner@objectville.net
Subject: Can you save the day... again?

We've discovered the Swindler's terrible plan:
The plan -> How I'll defeat Captain Amazing
The plan -> Another genius secret plan by The Swindler
The plan -> I'll unleash my army of clones upon the citizens of Objectville.
The plan -> Clone #1 attacks the mall
The plan -> Clone #2 attacks downtown
The plan -> Clone #3 attacks the mall
The plan -> Clone #4 attacks downtown
The plan -> Clone #5 attacks the mall

Can you help us?

数据可以通过多个流传输

在.NET 中使用流的一个巨大优势是,你可以让数据通过多个流传输到最终目的地。在.NET Core 中的众多流类型之一是 CryptoStream 类。这允许你在做任何其他操作之前对数据进行加密。所以,不是将纯文本写入普通的文本文件:

图像图像

骗子可以链接多个流,并在将文本通过 CryptoStream 对象写入其输出之前通过它加密:

图像

你可以链接流。一个流可以写入另一个流,后者可以写入另一个流...最终通常以网络或文件流结束。

水池拼图

图像

你的任务是从池中的代码片段中选取并将它们放入 Pineapple、Pizza 和 Party 类的空白行中。你可以多次使用同一段代码片段,而且不需要使用所有的片段。你的目标是使程序将五行内容写入名为 order.txt 的文件中,如输出框中所示。

Images

池子谜题解决方案

Images

使用静态的 File 和 Directory 类来处理文件和目录。

类似于 StreamWriter,File 类创建的流使你能够在后台处理文件。你可以使用它的方法来执行大多数常见操作,而不必首先创建 FileStreams。Directory 类允许你处理整个目录中的文件。

使用静态的 File 类可以做的事情:

  1. 查找文件是否存在。

    你可以使用 File.Exists 方法来检查文件是否存在。如果存在,则返回 true;否则返回 false。

  2. 从文件中读取和写入。

    你可以使用 File.OpenRead 方法从文件获取数据,或者使用 File.Create 或 File.OpenWrite 方法向文件写入数据。

  3. 向文件追加文本。

    File.AppendAllText 方法允许你向已创建的文件追加文本。即使在方法运行时文件不存在,它也会创建该文件。

  4. 获取文件的信息。

    File.GetLastAccessTime 和 File.GetLastWriteTime 方法返回文件上次访问和修改的日期和时间。

Images

使用静态的 Directory 类可以做的事情:

  1. 创建一个新目录。

    使用 Directory.CreateDirectory 方法创建一个目录。你只需提供路径即可,此方法会完成其余工作。

  2. 获取目录中文件的列表。

    你可以使用 Directory.GetFiles 方法创建一个目录中文件的数组;只需告诉方法你想知道哪个目录,它会完成其余工作。

  3. 删除一个目录。

    需要删除一个目录吗?调用 Directory.Delete 方法。

IDisposable 确保对象被正确关闭

很多 .NET 类实现了一个非常有用的接口叫做 IDisposable。它只有一个成员:一个叫做 Dispose 的方法。每当一个类实现 IDisposable,它告诉你有一些重要的事情需要它自己关闭,通常是因为它分配了资源,直到你告诉它放弃这些资源为止。Dispose 方法就是告诉对象释放这些资源的方式。

使用 IDE 探索 IDisposable

你可以在 IDE 中使用“Go To Definition”功能(Mac 上称为“Go to Declaration”)来查看 IDisposable 的定义。转到项目中的类并在其中的任何位置键入 IDisposable。然后右键单击它,并从菜单中选择“Go To Definition”。它会在新的标签页中打开代码。展开所有代码,你将看到以下内容:

Images

使用语句避免文件系统错误

在整章中,我们一直强调需要关闭你的流。这是因为当程序员处理文件时,一些最常见的 bug 是由于流未正确关闭而引起的。幸运的是,C#为你提供了一个很好的工具来确保这种情况永远不会发生:IDisposable 和 Dispose 方法。当你using语句中包装你的流代码时,它会自动为你关闭流。你只需用using语句声明你的流引用,后面跟着一个使用该引用的代码块(在花括号内)。这样做时,C#会在代码块运行完毕后自动调用 Dispose 方法

注意

这些“using”语句与你代码顶部的不同。

图片

注意

这个using语句声明了一个变量sw,它引用一个新的 StreamWriter,并后面跟着一个代码块。在代码块中的所有语句执行完毕后,using 块会自动调用 sw.Dispose。

对多个对象使用多个 using 语句

你可以将using语句堆叠在一起,不需要额外的花括号或缩进:

using (var reader = new StreamReader("secret_plan.txt"))
using (var writer = new StreamWriter("email.txt"))
{
     // statements that use reader and writer
}
注意

每个流都有一个 Dispose 方法来关闭流。当你在 using 语句中声明你的流时,它总是会被关闭!这很重要,因为一些流直到关闭时才会写入它们的所有数据。

当你在 using 块中声明一个对象时,该对象的 Dispose 方法会被自动调用。

使用 MemoryStream 将数据流向内存

我们一直在使用流来读取和写入文件。如果你想从文件中读取数据,然后做些什么呢?你可以使用MemoryStream,它通过在内存中存储数据来跟踪所有流向它的数据。例如,你可以创建一个新的 MemoryStream,并将其作为参数传递给 StreamWriter 的构造函数,然后使用 StreamWriter 写入的任何数据都会发送到该 MemoryStream。你可以使用MemoryStream.ToArray 方法检索这些数据,它会以字节数组的形式返回所有已经流向它的数据。

使用 Encoding.UTF8.GetString 将字节数组转换为字符串

对于字节数组,你最常见的操作之一就是将它们转换为字符串。例如,如果你有一个名为 bytes 的字节数组,这是一种转换为字符串的方法:

图片

下面是一个小型控制台应用程序,它使用复合格式化将数字写入 MemoryStream,然后将其转换为字节数组,最后转换为字符串。只有一个问题…… 它不起作用!

创建一个新的控制台应用程序,并将此代码添加到其中。你能找出问题并修复它吗?

一定要这样做!

图片

注意

这个应用程序无法工作!当你运行它时,它应该向控制台写入一行文本,但实际上什么都没写。我们会解释出了什么问题,但在我们解释之前,先看看你能不能自己找到答案。

这里有一个提示:你能找出流何时关闭吗?

Q: 为什么在“磨砺你的铅笔”练习中的文件名字符串前面要加上@符号?

A: 因为如果我们不这样做,字符串“C:\SYP”中的\S 将被解释为无效的转义序列并抛出异常。当你向程序添加一个字符串文字时,编译器会将像\n 和\r 这样的转义序列转换为特殊字符。Windows 文件名中包含反斜杠字符,但 C#字符串通常使用反斜杠来开始转义序列。如果在字符串前面加上@,它告诉 C#不要解释转义序列。它还告诉 C#包含换行符在你的字符串中,所以你可以在字符串中间按 Enter 键,它会把那部分作为换行符包含在输出中。

Q: 那么转义序列到底是什么?

A: 转义序列是一种在字符串中包含特殊字符的方法。例如,\n 是换行符,\t 是制表符,\r 是回车符,或者说是 Windows 回车的一半(在 Windows 文本文件中,行必须以\r\n 结束;对于 macOS 和 Linux,行只需以\n 结束)。如果需要在字符串中包含引号,可以使用",它不会结束字符串。如果要在字符串中使用实际的反斜杠而不让 C#解释它作为转义序列的开头,只需使用两个反斜杠:\。

注意

我们给了你一个机会自己解决这个问题。这是我们是如何修复它的。

图片

存储对象到文件中有一种更简单的方法。它叫做序列化。

序列化意味着将对象的整个状态写入到文件或字符串中。反序列化意味着从该文件或字符串中读取对象的状态。因此,不必一行一行地费力写出每个字段和值到文件中,你可以通过将对象序列化到流中来轻松地保存对象。序列化对象就像将其展平,以便你可以将其放入文件中。另一方面,反序列化对象就像将其从文件中取出并膨胀一样。

注意

好吧,坦白说:还有一个名为 Enum.Parse 的方法,它将字符串“Spades”转换为枚举值 Suits.Spades。它甚至有一个伴侣方法 Enum.TryParse,它的工作方式就像你在本书中一直使用的 int.TryParse 方法一样。但是在这里序列化更加合理。你很快就会了解更多...

当对象被序列化时会发生什么?

看起来似乎要对一个对象进行某种神秘的操作,才能将其从堆中复制到文件中,但实际上这并不复杂。

  1. 堆上的对象

    图片

    当你创建一个对象的实例时,它有一个状态。一个对象“知道”的一切是使一个类的一个实例与同一类的另一个实例不同的东西。

  2. 对象已序列化

    图片

    当 C#序列化一个对象时,它保存对象的完整状态,以便稍后可以在堆上重新创建一个完全相同的实例(对象)。

    图片

  3. 以及后来...

    以后——也许是几天后,在另一个程序中——你可以回到文件并反序列化它。这会将原始类从文件中取回,并完全恢复它,所有字段和值都完好无损。

    图片

但一个对象的状态到底是什么?需要保存什么?

我们已经知道一个对象将其状态存储在其字段和属性中。因此,当一个对象被序列化时,每个值都需要保存到文件中。

当你有更复杂的对象时,序列化就变得有趣起来。字符、整数、双精度和其他值类型有可以直接写入文件的字节。如果一个对象有一个实例变量是对象引用呢?如果一个对象有五个实例变量是对象引用呢?如果这些对象实例变量本身有实例变量呢?

花一分钟时间考虑一下。一个对象的哪一部分可能是唯一的?考虑一下需要恢复什么才能获得与保存的对象完全相同的对象。堆上的所有内容都必须写入文件中。

注意

Brain Barbell 就像是“增强版”的 Brain Power。花几分钟时间认真思考一下这个。

当一个对象被序列化时,它所引用的所有对象也会被序列化...

...以及所有它们所引用的对象,以及所有那些其他对象所引用的对象,依此类推。别担心——听起来可能很复杂,但这一切都会自动发生。C#从你想要序列化的对象开始,并查看其字段中的其他对象。然后对每个对象执行相同的操作。每个对象都会被写入文件,连同 C#重新构建对象时所需的所有信息。

注意

一组通过引用相互连接的对象有时被称为图。

图片

使用 JsonSerialization 来序列化你的对象

你不仅仅局限于读写文本行到你的文件中。你可以使用JSON 序列化让你的程序复制整个对象到字符串(然后你可以将其写入文件!)并读取它们回来...所有这些只需几行代码!让我们看看这是如何工作的。首先创建一个新的控制台应用程序。

做这个!

  1. 为你的对象图设计一些类。

    将这个HairColor枚举和这些 Guy、Outfit 和 HairStyle 类添加到你的新控制台应用程序中:

    class Guy {
        public string Name { get; set; }
        public HairStyle Hair { get; set; }
        public Outfit Clothes { get; set; }
        public override string ToString() => $"{Name} with {Hair} wearing {Clothes}";
    }
    
    class Outfit {
        public string Top { get; set; }
        public string Bottom { get; set; }
        public override string ToString() => $"{Top} and {Bottom}";
    }
    
    enum HairColor { 
       Auburn, Black, Blonde, Blue, Brown, Gray, Platinum, Purple, Red, White 
    }
    
    class HairStyle {
        public HairColor Color { get; set; }
        public float Length { get; set; }
        public override string ToString() => $"{Length:0.0} inch {Color} hair";
    }
    
  2. 创建一个要序列化的对象图。

    现在创建一个小的对象图来序列化:一个新的 List 指向几个 Guy 对象。将以下代码添加到你的 Main 方法中。它使用集合初始化器和对象初始化器来构建对象图:

    static void Main(string[] args) {
        var guys = new List<Guy>() {
           new Guy() { Name = "Bob", Clothes = new Outfit() { Top = "t-shirt", Bottom = "jeans" },
              Hair = new HairStyle() { Color = HairColor.Red, Length = 3.5f }
           },
           new Guy() { Name = "Joe", Clothes = new Outfit() { Top = "polo", Bottom = "slacks" },
              Hair = new HairStyle() { Color = HairColor.Gray, Length = 2.7f }
           },
        };
    }
    

    图像

  3. 使用 JsonSerializer 将对象序列化为字符串。

    首先,在你的代码文件顶部添加一个 using 指令:

    using System.Text.Json;
    

    现在你可以用一行代码序列化整个图形

        var jsonString = JsonSerializer.Serialize(guys);
        Console.WriteLine(jsonString);
    

    再次运行你的应用程序,并仔细观察它打印到控制台的内容:

    [{"Name":"Bob","Hair":{"Color":8,"Length":3.5},"Clothes":{"Top":"t-shirt","Bot
    tom":"jeans"}},{"Name":"Joe","Hair":{"Color":5,"Length":2.7},"Clothes":{"Top":
    "polo","Bottom":"slacks"}}]
    

    这就是你的对象图 序列化为 JSON(有些人发音为“Jason”,其他人发音为“JAY-sahn”)。它是一种人类可读的数据交换格式,这意味着它是一种使用字符串存储复杂对象的方式,人们可以理解。因为它是人类可读的,你可以看到它包含图的所有部分:名称和衣服被编码为字符串(“Bob”,“t-shirt”),枚举被编码为它们的整数值。

  4. 使用 JsonSerializer 将 JSON 反序列化为一个新的对象图。

    现在我们有了一个包含对象图的 JSON 序列化的字符串,我们可以进行反序列化。这意味着使用它来创建新的对象。JsonSerializer 也让我们可以用一行代码完成这个操作。在 Main 方法中添加以下代码:

    var copyOfGuys = JsonSerializer.Deserialize<List<Guy>>(jsonString);
    foreach (var guy in copyOfGuys)
        Console.WriteLine("I deserialized this guy: {0}", guy);
    

    再次运行你的应用程序。它会将从 JSON 字符串中反序列化的人物输出到控制台:

    I deserialized this guy: Bob with 3.5 inch Red hair wearing t-shirt and jeans
    I deserialized this guy: Joe with 2.7 inch Gray hair wearing polo and slacks
    
注意

当你使用 JsonSerializer 将对象图序列化为 JSON 时,它生成了(在某种程度上)可读的文本表示形式的数据。

JSON 只包含数据,不包括特定的 C# 类型。

当你浏览 JSON 数据时,你会看到对象中可读的版本的数据:像“Bob”和“slacks”这样的字符串,像 8 和 3.5 这样的数字,甚至是列表和嵌套对象。当你查看 JSON 数据时,你看不到什么?JSON 不包括类型的名称。查看 JSON 文件,你不会看到 Guy、Outfit、HairColor 或 HairStyle 等类的名称,甚至连 int、string 或 double 等基本类型的名称也不会看到。这是因为 JSON 只包含数据,而 JsonSerializer 将尽力将数据反序列化为它发现的任何属性。

让我们来测试一下。向你的项目添加一个新类:

图像

然后再次运行你的代码。由于 JSON 只包含对象列表,JsonSerializer.Deserialize 将乐意将它们放入栈(或队列、数组或其他集合类型)。由于 Dude 公开了与数据匹配的 Name 和 Hair 属性,它将填充任何可以找到的数据。这是它打印到输出的内容:

Next dude: Joe with 2.7 inch Gray hair hair
Next dude: Bob with 3.5 inch Red hair hair
注意

还有一件事!我们向你展示了如何使用 JsonSerializer 进行基本的序列化。你只需要了解它的另外几点内容。

接下来:我们将深入研究我们的数据。

你已经写了很多代码,使用了像 int、bool 和 double 这样的值类型,并创建了存储数据的字段对象。现在是时候从更低的角度来看待事物了。本章的其余部分都是关于通过理解 C# 和 .NET 用于表示数据的实际字节来更好地了解你的数据。

这是我们将要做的事情。

图片

我们将探讨 C# 字符串如何使用 Unicode 编码——.NET 使用 Unicode 存储字符和文本。

图片

我们将把值写入二进制数据,然后读取它们,看看写入了哪些字节。

图片

我们将构建一个十六进制转储程序,让我们更仔细地查看文件中的位和字节。

注意

辅助功能通常是事后加入的,但如果从一开始就考虑这些事情,你的游戏——以及任何其他类型的程序!——会更加出色。

C# 字符串使用 Unicode 编码

你自从在 IDE 开始时键入了"Hello, world!"以来,一直在使用字符串。因为字符串如此直观,我们并没有真正需要解剖它们并弄清楚它们如何运作。但是请问自己……究竟什么是字符串?

C# 字符串是 只读的字符集合。因此,如果你实际查看存储在内存中的字符串,“Elephant”将被存储为字符‘E’、‘l’、‘e’、‘p’、‘h’、‘a’、‘n’和‘t’。现在问问自己……究竟什么是字符?

char 是用 Unicode 表示的字符。Unicode 是用于 编码 字符的行业标准,或将它们转换为字节,以便存储在内存中、通过网络传输、包含在文档中或几乎任何其他你想做的事情——并且你保证总是获得正确的字符。

特别是当你考虑到有多少字符时,这点尤为重要。Unicode 标准支持超过 150 个 脚本(特定语言的字符集),包括不仅限于拉丁文(包括 26 个英文字母和变体如 é 和 ç,还有全球许多语言使用的脚本。支持的脚本列表不断增长,Unicode 联盟每年都在添加新的(这是当前的列表:www.unicode.org/standard/supported.html)。

图片

Unicode 还支持另一组非常重要的字符:emoji。从眨眼笑脸 (图片) 到广受欢迎的一堆大便 (图片) ,所有的 emoji 都是 Unicode 字符。

图片

每个 Unicode 字符(包括 emoji)都有一个称为代码点的唯一编号

Unicode 字符的编号称为代码点。你可以在这里下载所有 Unicode 字符的列表:www.unicode.org/Public/UNIDATA/UnicodeData.txt

那是一个包含每个 Unicode 字符一行的大文本文件。下载它并搜索“ELEPHANT”,你会找到以这种方式开始的行:1F418;ELEPHANT。数字 1F418 代表一个十六进制(或十六进制)值。十六进制值用数字 0 到 9 和字母 A 到 F 编写,通常在处理 Unicode 值(以及一般的二进制值)时,使用十六进制比使用十进制更方便。在 C#中,你可以通过在开头加上 0x 来创建十六进制文字,就像这样:0x1F418。

1F418 是大象表情符号的UTF-8 代码点。UTF-8 是将字符编码为 Unicode(或表示为数字)的最常见方式。它是一种可变长度编码,使用 1 到 4 字节。在这种情况下,它使用 3 字节:0x01(或 1)、0xF4(或 244)和 0x18(或 24)。

但这不是 JSON 序列化器打印的内容。它打印了一个更长的十六进制数:D83DDC18。这是因为C# char 类型使用UTF-16,它使用由一个或两个 2 字节数字组成的代码点。大象表情符号的 UTF-16 代码点是 0xD83D 0xDC18。UTF-8 比 UTF-16 更受欢迎,特别是在 Web 上,因此当你查找代码点时,更有可能找到 UTF-8 而不是 UTF-16。

UTF-8 是大多数网页和许多系统使用的可变长度编码。它可以使用 1、2、3 或更多字节存储字符。UTF-16 是一个固定长度编码,总是使用一个或两个 2 字节数字。.NET 将 char 值存储在内存中作为 UTF-16 值。

Visual Studio 非常适合处理 Unicode

让我们使用 Visual Studio 来查看 IDE 如何处理 Unicode 字符。你在#start_building_with_chash_build_somethin 中看到了可以在代码中使用表情符号。让我们看看 IDE 还能处理什么。进入代码编辑器并输入以下代码:

    Console.WriteLine("Hello ");

如果你使用的是 Windows,打开字符映射应用程序。如果你使用的是 Mac,按下 Ctrl-图片-空格来弹出字符查看器。然后搜索希伯来字母 (图片)并复制到剪贴板。

图片

把光标放在空格和引号之间的字符串末尾,然后粘贴你复制到剪贴板上的字符。嗯,看起来有些奇怪:

图片

你注意到光标位于粘贴字母的左侧吗?好的,让我们继续。不要在 IDE 中点击任何地方——保持光标所在的位置,然后切换到字符映射或字符查看器中搜索希伯来字母拉梅德 (图片)。切换回 IDE——确保光标仍然位于辉的左侧——并粘贴拉梅德进去:

图片

当你粘贴了 lamed 时,IDE 将其添加到 shin 的左侧。现在搜索希伯来字母 vav(I)和 final mem(Images)。将它们粘贴到 IDE 中——它会将它们插入到光标的左侧:

Images

IDE 知道希伯来语从右向左读,因此它会相应地行为。点击选择语句开头附近的文本,并慢慢将光标向右拖动以选择Hello,然后是Images。当选择到希伯来字母时,请仔细观察会发生什么。它跳到 shin(Images)然后从右向左选择——这正是希伯来读者期望它做的。

.NET 使用 Unicode 来存储字符和文本

用于存储文本和字符的两个 C#类型——string 和 char——将它们的数据作为 Unicode 保存在内存中。当这些数据作为字节写入文件时,每个 Unicode 数都被写入文件。让我们来感受一下 Unicode 数据如何写入文件。创建一个新的控制台应用。我们将使用 File.WriteAllBytes 和 File.ReadAllBytes 方法开始探索 Unicode。

做这个!

  1. 将普通字符串写入文件并读取它。

    将以下代码添加到 Main 方法中——它使用 File.WriteAllText 将字符串“Eureka!”写入名为eureka.txt的文件(所以你需要using System.IO;)。然后创建一个新的字节数组称为eurekaBytes,将文件读入其中,并打印出它读取的所有字节:

    Images

    你会看到这些字节被写入输出:69 117 114 101 107 97 33。最后一行调用了 Encoding.UTF8.GetString 方法,它将 UTF-8 编码的字节数组转换为字符串。现在在 Notepad(Windows)或 TextEdit(Mac)中打开文件。它说“Eureka!”

  2. 然后添加代码以将字节写成十六进制数。

    当你编码数据时,你经常会使用十六进制,所以现在让我们这样做。在 Main 方法的末尾添加此代码,使用{0:x2}格式化每个字节为十六进制数

    foreach (byte b in eurekaBytes)
        Console.Write("{0:x2} ", b);
    Console.WriteLine();
    
    注意

    十六进制使用数字 0 到 9 和字母 A 到 F 来表示基数为 16 的数字,所以 6B 等于 107。

    这告诉 Write 将参数 0(打印字符串后的第一个参数)打印为两个字符的十六进制代码。因此,它以十六进制而不是十进制写出相同的七个字节:45 75 72 65 6b-61 21

  3. 修改第一行,写希伯来字母“Images”而不是“Eureka!”

    你刚刚使用了 Character Map(Windows)或 Character Viewer(Mac)将希伯来文本 图片 添加到另一个程序。注释掉 Main 方法的第一行,并用以下代码替换它,该代码将 图片 而不是“Eureka!”写入文件。我们添加了一个额外的 Encoding.Unicode 参数,以便写入 UTF-16(Encoding 类位于 System.Text 命名空间中,因此还要在顶部添加using System.Text;):

    图片

    现在再次运行代码,并仔细观察输出:ff fe e9 05 dc 05 d5 05 dd 05。前两个字符是“FF FE”,这是 Unicode 表示我们将有一个 2 字节字符的方式。其余的字节是希伯来字母 —— 但它们是反向的,所以 U+05E9 显示为**e9 05**。现在在记事本或 TextEdit 中打开文件,确保它看起来正确。

  4. 使用 JsonSerializer 探索 UTF-8 和 UTF-16 代码点。

    当你序列化大象表情符号时,JsonSerializer 生成了\uD83D\uDC18 —— 我们现在知道这是 4 字节 UTF-16 代码点的十六进制表示。现在让我们试试希伯来字母 shin。在你的应用程序顶部添加using System.Text.Json;,然后添加这一行:

    图片

    再次运行你的应用程序。这一次它打印了一个值,有两个十六进制字节,“\u05E9” —— 这是希伯来字母 shin 的 UTF-16 代码点。它也是相同字母的 UTF-8 代码点。

    等等——我们了解到大象表情符号的 UTF-8 代码点是0x1F418,与 UTF-16 代码点(0xD83D 0xDC18)不同。发生了什么事?

    原来大多数具有 2 字节 UTF-8 代码点的字符在 UTF-16 中具有相同的代码点。一旦达到需要三个或更多字节的 UTF-8 值——包括本书中使用的熟悉表情符号——它们就不同了。所以尽管希伯来字母 shin 在 UTF-8 和 UTF-16 中都是 0x05E9,大象表情符号在 UTF-8 中是 0x1F418,在 UTF-16 中是 0xD8ED 0xDC18。

    图片

  5. 使用 Unicode 转义序列进行编码 图片

    在你的 Main 方法中添加这些行,使用 UTF-16 和 UTF-32 转义序列将大象表情符号写入两个文件:

    File.WriteAllText("elephant1.txt", "\uD83D\uDC18");
    File.WriteAllText("elephant2.txt", "\U0001F418");
    

    再次运行你的应用程序,然后在记事本或 TextEdit 中打开这两个文件。你应该看到正确的字符被写入文件。

    注意

    你使用 UTF-16 和 UTF-32 转义序列来创建你的表情符号,但 WriteAllText 方法却写入了一个 UTF-8 文件。你在步骤 1 中使用的 Encoding.UTF8.GetString 方法将 UTF-8 编码的数据转换回字符串。

C#可以使用字节数组来移动数据

因为所有的数据最终都以字节编码,所以把文件想象成一个大字节数组是有意义的……而且你已经知道如何读取和写入字节数组。

图片

使用 BinaryWriter 写入二进制数据

注意

StreamWriter 也对数据进行编码。它专门处理文本和文本编码——默认为 UTF-8。

可以将所有字符串、字符、整数和浮点数编码为字节数组,然后再将它们写入文件,但这将变得非常繁琐。这就是为什么 .NET 给您提供了一个非常有用的类叫做 BinaryWriter,它自动编码您的数据并将其写入文件。您只需创建一个 FileStream 并将其传递给 BinaryWriter 的构造函数(它们都在 System.IO 命名空间中,所以您需要 using System.IO;)。然后,您可以调用其方法将数据写出。让我们练习使用 BinaryWriter 将二进制数据写入文件。

就这样!

  1. 首先创建一个控制台应用程序并设置一些要写入文件的数据:

    图像

  2. 要使用 BinaryWriter,首先需要使用 File.Create 打开一个新流:

      using (var output = File.Create("binarydata.dat"))
      using (var writer = new BinaryWriter(output))
      {
    
  3. 现在只需调用其 Write 方法。每次执行此操作时,它都会将包含传递给它作为参数的数据的编码版本的新字节添加到文件的末尾:

    图像

  4. 现在使用之前用于读取您刚刚写入的文件的相同代码:

      byte[] dataWritten = File.ReadAllBytes("binarydata.dat");
      foreach (byte b in dataWritten)
          Console.Write("{0:x2} ", b);
      Console.WriteLine(" - {0} bytes", dataWritten.Length);
    

    在下面的空白处写下输出。您能找出每个 writer.Write(...) 语句对应的字节吗?我们在与每个语句对应的字节组下面放置了括号,以帮助您确定文件中哪些字节对应于应用程序写入的数据。

使用 BinaryReader 读取数据

BinaryReader 类的工作方式与 BinaryWriter 类似。您创建一个流,将 BinaryReader 对象附加到该流,然后调用其方法……但读取器不知道文件中的数据是什么!它没有办法知道。您的浮点值 491.695F 被编码为 d8 f5 43 45。这些字节也可以作为一个完全有效的int——1,140,185,334——所以您需要告诉 BinaryReader 究竟从文件中读取哪些类型。将以下代码添加到您的程序中,并让它读取您刚刚写入的数据。

注意

不要轻信我们的话。用调用 ReadInt32 替换读取浮点数的那一行。(您需要将 floatRead 的类型更改为 int。)然后,您可以亲自看看它从文件中读取了什么。

  1. 首先设置 FileStream 和 BinaryReader 对象:

    using (var input = File.OpenRead("binarydata.dat"))
    using (var reader = new BinaryReader(input))
    {
    
  2. 您通过调用 BinaryReader 的不同方法告诉它要读取什么类型的数据:

    图像

  3. 现在将从文件中读取的数据写入控制台:

         Console.Write("int: {0}  string: {1}  bytes: ", intRead, stringRead);
         foreach (byte b in byteArrayRead)
             Console.Write("{0} ", b);
         Console.Write(" float: {0}  char: {1} ", floatRead, charRead);
      }
    

    这是打印到控制台的输出:

      int: 48769414  string: Hello!  bytes: 47 129 0 116  float: 491.695  char: E
    
注意

如果您要写入仅包含低编号 Unicode 字符的字符串(如拉丁字母),则每个字符写入一个字节。如果有高编号字符(如表情符号),则每个字符将使用两个或更多字节进行编写。

十六进制转储可以让您查看文件中的字节

十六进制转储是文件内容的十六进制视图,是程序员深入查看文件内部结构的常见方式。

Hex(十六进制)原来是在文件中显示字节的便捷方式。一个字节在十六进制中占用两个字符:字节的范围是从 0 到 255,或者在十六进制中是 00 到 ff。这使您可以在非常小的空间中查看大量数据,并且以一种易于识别模式的格式呈现。将二进制数据显示为每行 8、16 或 32 字节很有用,因为大多数二进制数据倾向于以 4、8、16 或 32 个字节的块来处理,就像 C#中的所有类型一样(例如,int 占用 4 个字节)。十六进制转储让您可以准确查看这些值由什么组成。

如何制作一些普通文本的十六进制转储

从一些使用拉丁字符的熟悉文本开始:

When you have eliminated the impossible, whatever remains, however
improbable, must be the truth. - Sherlock Holmes

首先,将文本分成 16 字符段,从第一个 16 个字符开始:When you have el

接下来,将文本中的每个字符转换为其 UTF-8 代码点。由于所有拉丁字符都具有1 字节的 UTF-8 代码点,每个字符将以从 00 到 7F 的两位十六进制数字表示。我们的每行转储看起来将是这样的:

Images

重复,直到您在文件中转储了每个 16 字符段:

     0000: 57 68 65 6e 20 79 6f 75 -- 20 68 61 76 65 20 65 6c    When you have el
     0010: 69 6d 69 6e 61 74 65 64 -- 20 74 68 65 20 69 6d 70    iminated the imp
     0020: 6f 73 73 69 62 6c 65 2c -- 20 77 68 61 74 65 76 65    ossible, whateve
     0030: 72 20 72 65 6d 61 69 6e -- 73 2c 20 68 6f 77 65 76    r remains, howev
     0040: 65 72 20 69 6d 70 72 6f -- 62 61 62 6c 65 2c 20 6d    er improbable, m
     0050: 75 73 74 20 62 65 20 74 -- 68 65 20 74 72 75 74 68    ust be the truth
     0060: 2e 20 2d 20 53 68 65 72 -- 6c 6f 63 6b 20 48 6f 6c    . - Sherlock Hol
     0070: 6d 65 73 0a             --                            mes.

这就是我们的转储结果。各种操作系统都有许多不同的十六进制转储程序,每个程序的输出略有不同。我们特定的十六进制转储格式中,每行表示生成它的输入中的 16 个字符,每行开头有偏移量,每个字符的文本在行尾。其他十六进制转储应用程序可能会以不同方式显示内容(例如,呈现转义序列或以十进制显示值)。

十六进制转储是文件或内存中数据的十六进制视图,是帮助您调试二进制数据的非常有用的工具。

使用 StreamReader 构建一个十六进制转储程序

让我们构建一个十六进制转储应用程序,它从文件中使用 StreamReader 读取数据,并将其转储到控制台上。我们将利用 StreamReader 中的ReadBlock 方法,它会读取一个字符块到 char 数组中:您可以指定要读取的字符数,它将读取指定数量的字符,或者如果文件中剩余的字符少于要求的数量,则读取剩余的所有字符。由于我们每行显示 16 个字符,因此我们将读取 16 个字符的块。

创建一个名为 HexDump 的新控制台应用程序。在添加代码之前,运行该应用程序以创建带有二进制文件的文件夹。使用记事本或 TextEdit 创建一个名为textdata.txt的文本文件,向其中添加一些文本,并将其放在与二进制文件相同的文件夹中。

下面是 Main 方法内部的代码——它读取textdata.txt文件并将十六进制转储写入控制台。确保在顶部添加using System.IO;

注意

ReadBlock 方法从其输入中读取下一个字符,并将其读入字节数组(有时称为缓冲区)。它会阻塞,这意味着它会继续执行,并且只有在读取完您请求的所有字符或者没有更多数据可读时才会返回。

Images

使用 Stream.Read 从流中读取字节

对于文本文件,十六进制转储程序工作得很好——但存在问题。复制你用 BinaryWriter 写入的binarydata.dat文件到与你的应用程序相同的文件夹中,然后更改应用程序以读取它:

    using (var reader = new StreamReader("binarydata.dat"))

现在再次运行你的应用程序。这次它打印出了其他内容——但不完全正确:

图像

文本字符(“Hello!”)看起来没问题。但是与“磨练你的铅笔”解决方案的输出进行比较——字节并不完全正确。看起来它用不同的字节 fd 替换了一些字节(86、e8、81、f6、d8 和 f5)。这是因为StreamReader 用于读取文本文件,所以它只读取7 位值,或者说最多 127 的字节值(十六进制 7F,或二进制中的 1111111)。

所以让我们做得对——通过直接从流中读取字节。修改using块以使用File.OpenRead,它打开文件并返回一个 FileStream。你将使用流的 Length 属性继续读取,直到读取文件中的所有字节,并使用其 Read 方法将下一个 16 个字节读入字节数组缓冲区:

图像

其余的代码与设置bufferContents:行除外。

    // Write the actual characters in the byte array
    var bufferContents = Encoding.UTF8.GetString(buffer);

你在本章前面使用过 Encoding 类将字节数组转换为字符串。这个字节数组每个字符包含一个字节,这意味着它是一个有效的 UTF-8 字符串。这意味着你可以使用 Encoding.UTF8.GetString 来转换它。由于 Encoding 类在 System.Text 命名空间中,你需要在文件顶部添加using System.Text;

现在再次运行你的应用程序。这次它打印出正确的字节而不是将它们改变为 fd:

图像

还有一件事我们可以做来清理输出。许多十六进制转储程序会在输出中将非文本字符替换为点。for循环的末尾添加这一行:

   if (buffer[i] < 0x20 || buffer[i] > 0x7F) buffer[i] = (byte)’.’;

现在再次运行你的应用程序——这次问号被点替换为点:

    0000: 86 29 e8 02 06 48 65 6c -- 6c 6f 21 2f 81 00 74 f6    .)...Hello!/..t.
    0010: d8 f5 43 45             --                            ..CE

修改你的十六进制转储程序以使用命令行参数

大多数十六进制转储程序是从命令行运行的实用程序。你可以通过将文件名作为命令行参数传递给十六进制转储器来转储文件,例如:C:\> HexDump myfile.txt

让我们修改十六进制转储程序以使用命令行参数。当你创建控制台应用程序时,C#将命令行参数作为args字符串数组传递给 Main 方法:

   static void Main(string[] args)

我们将修改 Main 方法以打开文件并从流中读取其内容。File.OpenRead 方法接受文件名作为参数,打开它以便读取,并返回一个带有文件内容的流。

更改 Main 方法中的这些行:

图像

现在让我们通过更改调试属性来在 IDE 中使用命令行参数来传递给程序。在解决方案中右键单击项目,然后:

  • 在 Windows 上, 选择属性,然后点击调试,在应用程序参数框中输入要转储的文件名(可以是完整路径或者二进制文件夹中文件的名称)。

  • 在 macOS 上, 选择选项,展开运行 >> 配置,点击默认,然后在参数框中输入文件名。

注意

确保您右键单击项目,而不是解决方案。

现在,当您调试应用程序时,其 args 数组将包含您在项目设置中设置的参数。确保在设置命令行参数时指定有效的文件名。

从命令行运行您的应用程序

您还可以从命令行运行应用程序,用文件名(可以是完整路径或当前目录中的文件名)替换 [filename]

  • 在 Windows 上, Visual Studio 会在 bin\Debug 文件夹下生成一个可执行文件(与您放置文件以读取的位置相同),因此您可以直接从该文件夹运行可执行文件。打开命令窗口,cd 到 bin\Debug 文件夹,并运行 HexDump [filename].

  • 在 Mac 上, 您需要构建一个独立的应用程序。打开一个终端窗口,进入项目文件夹,并运行以下命令:dotnet publish -r osx-x64.

    输出将包含类似于这样的一行:HexDump -> /path-to-binary/osx-x64/publish/

    打开一个终端窗口,cd 到打印的完整路径,并运行 ./HexDump [filename].

第十六章:Unity 实验室#5:射线投射

当您在 Unity 中设置场景时,您正在为游戏中的角色创建一个虚拟的 3D 世界,使它们可以在其中移动。但在大多数游戏中,游戏中的大部分事物并不直接由玩家控制。那么这些对象如何在场景中找到它们的路?

实验五和六的目标是让您熟悉 Unity 的寻路和导航系统,这是一个复杂的 AI 系统,让您可以创建能够在您创建的世界中找到路的角色。在这个实验中,您将使用 GameObject 构建场景,并使用导航将角色移动到周围。

您将使用射线投射编写响应场景几何的代码,捕捉输入并用它将一个 GameObject 移动到玩家点击的点。同样重要的是,您将练习编写包括类、字段、引用等在内的 C#代码,这些都是我们讨论过的主题。

创建一个新的 Unity 项目并开始设置场景

开始之前,请关闭任何打开的 Unity 项目。同时关闭 Visual Studio——我们将让 Unity 来打开它。使用 3D 模板创建一个新的 Unity 项目,将布局设置为 Wide,使其与我们的截图匹配,并起一个名字,比如Unity Labs 5 and 6,以便您以后可以回来查看。

首先创建一个玩家将要在其中导航的游戏区域。在层级窗口中右键单击,并创建一个 Plane(GameObject >> 3D Object >> Plane)。将您的新 Plane GameObject 命名为Floor

右键单击项目窗口中的 Assets 文件夹,创建一个名为 Materials 的文件夹。然后在您创建的新 Materials 文件夹上右键单击,并选择创建 >> Material。将新材质命名为FloorMaterial。现在,让我们保持这个材质简单——我们只需使它成为一种颜色。在项目窗口中选择 Floor,然后单击检视器中 Albedo 词右侧的白色框。

图片

在颜色窗口中,使用外环选择地面的颜色。我们在截图中使用了一个颜色,编号为 4E51CB,您可以将其输入到十六进制框中。

项目窗口中的材料拖到层级窗口中的 Plane GameObject上。您的地面平面现在应该是您选择的颜色。

图片图片

注意

仔细思考并猜一下。然后使用检视器窗口尝试各种 Y 比例值,看看平面是否按照您的预期行动。(别忘了把它们设回来!)

注意

平面是一个平方形的平面对象,长宽为 10 个单位(在 X-Z 平面),高度为 0 个单位(在 Y 平面)。Unity 创建它,使得平面的中心点位于 (0,0,0)。这个平面的中心点决定了它在场景中的位置。和其他对象一样,你可以通过检视器或工具来移动它的位置和旋转。你也可以改变它的比例,但因为它没有高度,你只能改变 X 和 Z 的比例—任何放入 Y 比例的正数都会被忽略。

使用 3D 对象菜单创建的对象(平面、球体、立方体、圆柱体以及其他几个基本形状)被称为基本对象。你可以通过从帮助菜单打开 Unity 手册并搜索“基本和占位对象”帮助页面来了解更多信息。现在花一分钟打开这个帮助页面。阅读它对于平面、球体、立方体和圆柱体的介绍。

设置摄像机

在最近的两个 Unity 实验中,你学到了 GameObject 本质上是组件的“容器”,而主摄像机只有三个组件:一个 Transform,一个 Camera,和一个 Audio Listener。这很合理,因为摄像机所需做的就是位于某个位置并记录它所看到和听到的内容。查看检视器窗口中摄像机的 Transform 组件。

图片

注意位置是 (0, 1, –10)。点击位置行中的 Z 标签并向上或向下拖动。你会看到摄像机在场景窗口中前后移动。仔细观察摄像头前方的方框和四条线。它们代表摄像机的视口,或者玩家屏幕上可见的区域。

使用移动工具(W)和旋转工具(E)移动摄像机并在场景中旋转它,就像你在场景中操作其他 GameObject 一样。摄像机预览窗口会实时更新,显示摄像机所见的内容。移动摄像机时保持关注摄像机预览。地面将会随着摄像机视角的改变而移动。

使用检视器窗口中的上下文菜单重置主摄像机的 Transform 组件。注意这不会将摄像机重置到原始位置—它会将摄像机的位置和旋转都重置为 (0, 0, 0)。你会看到摄像机与场景窗口中的平面相交。

图片

现在让我们把摄像机直接对准地面。首先点击旋转旁边的 X 标签并上下拖动。你会看到摄像机预览中的视口移动。现在在检视器窗口中将摄像机的 X 旋转设置为 90 度,以使其直接朝下。

你会注意到在摄像机预览中再也看不到任何内容,这很合理,因为摄像机直接看向无限薄的平面下方。点击 Transform 组件中的 Y 位置标签并向上拖动,直到在摄像机预览中看到整个平面。

现在在层次视图中选择 Floor。注意到摄像机预览消失了—只有在选择摄像机时才会出现。你也可以在场景和游戏窗口之间切换,看看摄像机的视角。

使用平面的 Transform 组件在检视器窗口中,将 Floor GameObject 的缩放设置为 (4, 1, 2),使其长度为宽度的两倍。由于平面宽度和长度均为 10 单位,这个缩放将使其长度为 40 单位,宽度为 20 单位。平面将完全填满视口,因此将摄像机沿 Y 轴向上移动,直到整个平面都能看到。

图片

注意

你可以在场景和游戏窗口之间切换,看看摄像机的视角。

创建一个玩家的 GameObject

你的游戏需要一个玩家来控制。我们将创建一个简单的类人形玩家,它有一个圆柱体作为身体和一个球体作为头部。确保你没有选择任何对象,通过点击层次视图中的场景(或空白处)。

创建一个 Cylinder GameObject(3D Object >> Cylinder)— 你将在场景中央看到一个圆柱体。将其名称改为 Player,然后从上下文菜单中选择 Reset,以确保其具有所有默认值。接下来,创建一个 Sphere GameObject(3D Object >> Sphere)。将其名称改为 Head,并重置其 Transform 组件。它们将分别在层次视图中各占一行。

但我们不想要分开的 GameObject,我们希望有一个由单个 C# 脚本控制的单一 GameObject。这就是为什么 Unity 引入了父子关系的概念。在层次视图中点击 Head,然后将其拖动到 Player 上。这样 Player 就成为了 Head 的父对象。现在 Head GameObject 被嵌套在 Player 下面。

图片

在层次视图中选择 Head。它像你创建的所有其他球体一样被创建在 (0, 0, 0)。你可以看到球体的轮廓,但由于被平面和圆柱体遮挡住了,看不到球体本身。使用检视器窗口中的 Transform 组件,将球体的 Y 位置更改为 1.5。现在球体出现在圆柱体上方,正好是玩家头部的位置。

现在在层次视图中选择 Player。由于其 Y 位置为 0,柱体的一半被平面遮挡住了。将其 Y 位置设置为 1。柱体突出平面上方。注意头部球体也随之移动了。移动 Player 会导致头部也跟随移动,因为移动父 GameObject 会同时移动其子对象—事实上,任何对其 Transform 组件的更改都会自动应用到子对象上。如果你缩放它,其子对象也会缩放。

切换到游戏窗口—你的玩家位于游戏区域中央。

图片

注意

当你修改一个有嵌套子对象的 GameObject 的 Transform 组件时,子对象也会随之移动、旋转和缩放。

介绍 Unity 的导航系统

视频游戏中的最基本事情之一是移动东西。玩家、敌人、角色、物品、障碍物……所有这些东西都可以移动。这就是为什么 Unity 配备了一个复杂的基于人工智能的导航和路径查找系统,以帮助 GameObjects 移动到您的场景中。我们将利用导航系统使玩家朝一个目标移动。

Unity 的导航和路径查找系统允许您的角色智能地在游戏世界中找到自己的路。要使用它,您需要设置基本组件,以告诉 Unity 玩家可以去哪里:

  • 首先,你需要告诉 Unity 你的角色可以去哪里。你可以通过设置 NavMesh 来完成这一点,其中包含场景中可行走区域的所有信息:坡度、楼梯、障碍物,甚至称为离网链接的点,它们允许你设置特定的玩家操作,如打开门。

  • 其次,您为需要导航的任何 GameObject 添加导航网格代理组件。此组件自动移动 GameObject,使用其 AI 找到到目标的最有效路径并避免障碍物,选项 ally 和其他导航网格代理。

  • 对于 Unity 来说,导航复杂的 NavMeshes 需要大量计算。正因为如此,Unity 有一个烘焙功能,让你提前设置 NavMesh,并预先计算(或烘焙)几何细节,以使代理工作更高效。

图片

注意

Unity 提供了一个复杂的 AI 导航和路径查找系统,可以实时移动 GameObjects 周围的场景,通过找到避免障碍物的有效路径。

设置 NavMesh

让我们设置一个仅包含地板平面的导航网格。我们将在“导航”窗口中执行此操作。选择 AI >> 导航从窗口菜单中添加导航窗口到你的 Unity 工作区。它应该显示为与“检查器”窗口同一面板中的标签。然后使用导航窗口标记地面 GameObject“导航静态”和“可行走:”

  • 在导航窗口顶部按“对象”按钮

  • 在层 选择“地板平面” 在“层次结构”窗口中。

  • 检查“导航静态”复选框。这告诉 Unity 在烘焙 NavMesh 时包含地板。

  • 从“导航区域”下拉菜单中选择“可行走”。这告诉 Unity 地板平面是任何具有导航网格代理的 GameObject 可以导航的表面。

图片

由于游戏中唯一可以行走的区域就是地板,所以在对象部分,我们已经完成了。如果场景中有很多可行走的表面或不可行走的障碍物,每个单独的 GameObject 需要被适当地标记。

在导航窗口顶部按“烘焙”按钮查看烘焙选项。

现在在导航窗口的底部点击其他 Bake 按钮。它会短暂地变成取消,然后切换回 Bake。你注意到场景窗口有什么变化了吗?在检查器和导航窗口之间来回切换。当导航窗口处于活动状态时,场景窗口显示 NavMesh 显示,并在标记为导航静态和可行走的游戏对象上显示蓝色的叠加层。在这种情况下,它突出显示了您标记为导航静态和可行走的平面。

现在你的 NavMesh 已经设置好了。

图片

使你的玩家自动导航播放区域

让我们给 Player GameObject 添加一个 NavMesh Agent。在 Hierarchy 窗口中选择 Player,然后返回 Inspector 窗口,点击添加组件按钮,并选择Navigation >> NavMesh Agent来添加 NavMesh Agent 组件。圆柱体的身体高 2 个单位,球形头部高 1 个单位,所以你希望你的代理高度为 3 个单位——所以将高度设置为 3。现在 NavMesh Agent 已经准备好在 NavMesh 中移动 Player GameObject 了。

创建一个脚本文件夹,并添加名为MoveToClick.cs的脚本。这个脚本将允许您点击播放区域,并告诉 NavMesh Agent 将游戏对象移动到该位置。您在#encapsulation_keep_your_privateshellippr 中了解了私有字段。这个脚本将使用一个字段来存储对 NavMeshAgent 的引用,以便 GameObject 的代码可以告诉代理去哪里,因此您将调用 GetComponent 方法来获取该引用,并将其保存在名为私有 NavMeshAgent 字段agent中:

agent = GetComponent<NavMeshAgent>();

图片

导航系统使用 UnityEngine.AI 命名空间中的类,因此您需要将此using行添加到MoveToClick.cs文件的顶部:

图片图片

是的!我们正在使用一个非常有用的工具,称为射线投射。

在第二个 Unity 实验室中,您使用 Debug.DrawRay 探索了如何通过绘制从(0, 0, 0)开始的射线来工作的 3D 向量。您的 MoveToClick 脚本的 Update 方法实际上做了类似的事情。它使用Physics.Raycast 方法“投射”一条射线——就像您用来探索向量的那条射线一样——它从相机开始,经过用户单击的点,并检查射线是否击中地板。如果是,则 Physics.Raycast 方法会提供击中地板的位置。然后脚本设置 NavMesh Agent 的destination 字段,这会导致 NavMesh Agent自动将玩家移动到该位置。

第十七章:卓越队长:对象的死亡

图片图片图片图片

一个对象的生命周期

这里是我们对对象生命周期的快速回顾:

  • 当你创建一个对象时,CLR(运行您的.NET 应用程序并管理内存)在堆上为其分配足够的内存,堆是您计算机内存的一部分,专门用于对象及其数据。

  • 它被一个引用“保持活动”,可以存储在变量中,集合中,或者另一个对象的属性或字段中。

  • 可以有很多引用指向同一个对象,就像你在#types_and_references_getting_the_referen 中看到的那样,当你把lloydlucinda引用变量指向同一个 Elephant 实例时。

  • 当您取消对 Elephant 对象的最后一个引用时,CLR 会标记它进行垃圾收集。

  • 最终 CLR 移除了 Elephant 对象并回收了内存,以便用于稍后您的程序将要创建的新对象实例。

现在我们将更详细地探讨所有这些点,编写一些小程序来展示垃圾收集的工作原理。

但在我们开始实验垃圾收集之前,我们需要退一步。您之前学到,对象被“标记”为垃圾收集,但实际上对象的移除可以随时发生(或永远不会!)。我们需要一种方法来知道何时对象已经被垃圾收集,并且一种强制垃圾收集发生的方法。因此,这就是我们的起点。

图片图片

使用 GC 类(慎用)来强制垃圾收集

.NET 提供了一个控制垃圾收集器的GC 类。我们将使用它的静态方法,比如 GetTotalMemory,它返回一个大致的堆上当前被认为分配的字节数:

图片

您可能会想:“为什么是大致的?被认为分配的意思是什么?垃圾收集器怎么可能不知道到底分配了多少内存?”这反映了垃圾收集的基本规则之一:您绝对可以 100%依赖垃圾收集,但有很多未知和近似之处

在本章中,我们将使用几个 GC 函数:

  • GC.GetTotalMemory 返回当前被认为在堆上分配的字节数。

  • GC.GetTotalAllocatedBytes 返回自程序启动以来大约分配的字节数。

  • GC.Collect 强制垃圾收集器立即回收所有未引用的对象。

关于这些方法只有一件事:我们正在用它们来学习和探索,但除非你真的知道你在做什么,不要在真实项目的代码中调用 GC.Collect。.NET 垃圾收集器是一个精心调试的工程组件。一般来说,当确定何时收集对象时,它比我们聪明,并且我们应该信任它来完成它的工作。

你最后的机会去执行一些操作……你对象的终结器。

有时你需要确保在对象被垃圾收集之前发生一些事情,比如释放非托管资源

你对象中的一个特殊方法称为终结器,它允许你编写当你的对象被销毁时始终执行的代码。它无论如何都会最后执行。

让我们通过终结器做一些实验。创建一个新的控制台应用程序,并添加带有终结器的这个类:

注意

一般来说,你不会为仅拥有托管资源的对象编写终结器。到目前为止,在本书中遇到的所有内容都是由 CLR 管理的。但是有时程序员需要访问不在 .NET 命名空间中的 Windows 资源。例如,如果你在互联网上找到带有 [DllImport] 的声明,你可能正在使用非托管资源。而其中一些非 .NET 资源可能会在系统中保持不稳定,如果它们没有被“清理掉”。这就是终结器的作用。

图片

什么时候确切地运行终结器?

你对象的终结器在所有引用消失之后,但在对象被垃圾收集之前运行。垃圾收集仅在所有对对象的引用消失后发生,但并不总是在最后一个引用消失后立即发生。

假设你有一个有引用的对象。CLR 发送垃圾收集器开始工作,它检查你的对象。但由于有对你对象的引用,垃圾收集器忽略它并继续。你的对象继续在内存中存在。

然后,发生了一些事情。持有对你的对象的最后一个引用的对象移除了该引用。现在你的对象在内存中,没有引用。它无法被访问。它基本上是一个无用的对象

但有一件事:垃圾收集是 CLR 控制的,而不是你的对象。因此,如果垃圾收集器在几秒钟,甚至几分钟内没有再次启动,你的对象仍然存在于内存中。它无法使用,但它还没有被垃圾收集。并且对象的终结器(尚未)无法运行。

最后,CLR 再次发送垃圾收集器。它检查你的对象,发现没有引用,然后运行终结器……可能是在最后一个对对象的引用被移除或更改后的几分钟。现在它已经被终结,你的对象已经死了,收集器将其丢弃。

你可以建议 .NET 执行垃圾回收。

.NET 确实让你建议进行垃圾收集是个好主意。大多数情况下,你不会使用这个方法,因为垃圾收集已调整为响应 CLR 中的许多条件,直接调用并不是一个好主意。但只是为了看看终结器是如何工作的,你可以自己调用垃圾收集,使用 GC.Collect。

但要小心。该方法并不强制CLR 立即垃圾回收事物。它只是说,“尽快进行垃圾收集。”

一个对象的生命和死亡...一个时间表

  1. 你的对象正在堆上过着它最好的生活。另一个对象引用它,使其保持存活状态。

    图片

  2. 另一个对象更改它的引用,所以现在没有其他对象引用你的对象。

    图片

  3. CLR 标记你的对象进行垃圾收集。

    图片

  4. 最终,垃圾收集器运行对象的终结器并从堆中移除对象。

注意

我们正在使用 GC.Collect 作为学习工具来帮助你理解垃圾收集的工作原理。你绝对不应该在非玩具程序中使用它(除非你真正理解.NET 中的垃圾收集工作原理比本书深入讨论的更多)。

图片

终结器不能依赖其他对象。

当你编写一个终结器时,你不能依赖它在任何特定时间运行。即使你调用了 GC.Collect,你只是建议垃圾收集器运行。这并不保证会立即发生。而且一旦发生,你无法知道对象收集的顺序。

那在实际应用中意味着什么呢?想想如果你有两个对象彼此引用。如果首先收集对象#1,那么对象#2 对它的引用指向一个不再存在的对象。但如果首先收集对象#2,那么对象#1 的引用是无效的。这意味着你不能依赖于对象终结器中的引用。这意味着试图在终结器中执行依赖于引用有效性的操作是一个非常糟糕的主意。

不要为序列化使用终结器。

序列化真的是一个很好的例子,你不应该在终结器内部执行它。如果你的对象引用了一堆其他对象,序列化依赖于所有这些对象仍然存在于内存中...以及所有这些对象引用的对象,以此类推。因此,如果在进行垃圾收集时尝试序列化,你可能会因为一些对象在终结器运行之前被收集了而丢失程序的关键部分。

幸运的是,C#为我们提供了一个非常好的解决方案:IDisposable。任何可能修改你的核心数据或依赖于其他对象存在于内存中的事情都应该作为 Dispose 方法的一部分而不是终结器的一部分。

有些人喜欢把终结器看作是 Dispose 方法的一种故障安全机制。这是有道理的——你在 Clone 对象中看到,仅仅因为你实现了 IDisposable,并不意味着对象的 Dispose 方法会被调用。但你需要小心——如果你的 Dispose 方法依赖于堆上的其他对象,那么在终结器中调用 Dispose 可能会导致问题。解决这个问题的最佳方法是,确保始终使用using语句来创建 IDisposable 对象。

从相互引用的两个对象开始。

图片

如果堆上的所有其他对象删除对对象#1 和#2 的引用,它们都将被标记为收集。

图片

如果对象#1 先被收集,那么当 CLR 运行对象#2 的终结器时,它的数据将不可用。

图片

另一方面,对象#2 可能在对象#1 之前消失。你无法知道顺序。

图片

这就是为什么一个对象的终结器不能依赖于堆上任何其他对象仍然存在。

图片

今晚的辩论:Dispose 方法和终结器争夺谁对你,作为 C#开发者更有价值

Dispose: Finalizer:
老实说,我被邀请来这里有点惊讶。我以为编程界已经达成共识。我的意思是,作为 C#工具,我显然比你更有价值。真的,你相当脆弱。你甚至不能依赖于其他对象在你被调用时仍然存在。相当不稳定,不是吗? 对不起?真是滑稽。我“脆弱”?好吧。嗯,我本来不想降到这种水平,但既然我们已经这么做了……至少我不需要一个接口才能开始工作。没有 IDisposable 接口,嗯,面对现实吧……你只是另一个无用的方法而已。
之所以有一个特定的接口因为我如此重要。事实上,它里面只有一个方法! 对,对……继续骗自己吧。如果有人在实例化对象时忘记使用using语句会发生什么?那时你就会不见踪影了。
好吧,你说得对,程序员需要知道他们将需要我,要么直接调用我,要么使用using语句调用我。但他们总是知道我何时运行,并且可以利用我来做任何需要清理对象后的工作。我功能强大、可靠且易于使用。我是三重威胁。而你呢?没有人确切知道你何时运行,或者当你最终决定出现时应用程序的状态如何。 但如果你需要在对象被垃圾收集之前的最后一刻做些事情,没有我是不可能的。我可以释放网络资源和 Windows 句柄以及其他可能会导致应用程序出问题的任何东西。我可以确保你的对象更优雅地处理被丢弃的情况,这一点不容小觑。句柄是你的程序在绕过.NET 和 CLR 直接与 Windows 交互时使用的。由于.NET 不知道它们,因此不能为你清理它们。
你以为你很厉害,因为你总是与 GC 一起运行,但至少我可以依赖其他对象。 是的,朋友,但我总是运行。你需要别人来帮你运行。我不需要任何人或任何东西!

图片图片

结构体看起来像一个对象...

我们一直在谈论堆,因为那是你的对象所在的地方。但这不是对象居住的唯一内存部分。在.NET 中我们还没有多谈到的一种类型是结构体,我们将用它来探索 C#中生命和死亡的另一个方面。结构体简称为结构,结构体看起来很像对象。它们有字段和属性,就像对象一样。你甚至可以将它们传递给以对象类型参数为参数的方法:

图片

...但并不是一个对象。

但结构体不是对象。它们可以有方法和字段,但是它们不能有终结器。它们也不能从类或其他结构体继承,或者有类或结构体从它们继承—你可以在结构体的声明中使用冒号:运算符,但只能跟着一个或多个接口。

图片

所有结构体都扩展自 System.ValueType,而 System.ValueType 又扩展自 System.Object。这就是为什么每个结构体都有一个 ToString 方法—它从 Object 那里继承而来。但这是结构体被允许做的唯一继承。

对象的力量在于它们通过继承和多态来模仿现实世界的行为。

结构体最适合用于存储数据,但继承和引用的缺失可能是一个严重的限制。

值被复制;引用被赋值

我们已经看到引用对于垃圾收集是多么重要——重新分配最后一个引用给一个对象,它就会被标记为待收集。但我们也知道,这些规则对于值来说并不完全合理。如果我们想更好地了解对象和值在 CLR 内存中是如何存活和死亡的,我们需要更仔细地看一看值和引用:它们如何相似,更重要的是,它们如何不同。

你已经知道一些类型如何与其他类型不同。一方面,你有像 int、bool 和 decimal 这样的值类型。另一方面,你有像 List、Stream 和 Exception 这样的对象。它们的工作方式并不完全相同,是吧?

当你使用等号将一个值类型变量设置为另一个时,它复制了值,之后这两个变量不再连接到彼此。另一方面,当你使用等号与引用时,你所做的是指向同一个对象的两个引用

  • 变量声明和赋值在值类型和对象类型中的工作方式相同:

    图片

  • 但是一旦你开始赋值,你就能看到它们之间的不同。所有值类型都通过复制来处理。这是一个例子——这应该是熟悉的内容:

    图片

    这里的输出显示fifteenMorehowMany实际上没有连接:

  • 但正如我们所知,当涉及到对象时,你是在赋予引用而不是值:

    图片

因此,改变列表意味着两个引用都看到更新,因为它们都指向同一个列表对象。通过写一行输出来验证这一点:

图片

这里的输出表明copytemps实际上指向同一个对象:

temps has 3, copy has 3

结构体是值类型;对象是引用类型

让我们更仔细地看看结构体的工作原理,这样你就可以开始理解何时可能需要使用结构体而不是对象。当你创建一个结构体时,你正在创建一个值类型。这意味着当你使用等号将一个结构体变量设置为另一个时,你在新变量中创建了一个全新的副本。因此,即使结构体看起来像一个对象,它并不像一个对象那样行事。

就这样!

  1. 创建一个名为 Dog 的结构体。

    这是一个简单的结构体,用来追踪一只狗。它看起来就像一个对象,但实际上不是。将其添加到一个新的控制台应用程序中:

      public struct Dog {
    
        public string Name { get; set; }
        public string Breed { get; set; }
    
        public Dog(string name, string breed) {
          this.Name = name;
          this.Breed = breed;
        }
    
        public void Speak() {
            Console.WriteLine("My name is {0} and I’m a {1}.", Name, Breed);
        }
      }
    
  2. 创建一个名为 Canine 的类。

    制作一份完全相同的 Dog 结构体的副本,除了用 class 替换 struct,然后用 Canine 替换 Dog。不要忘记重命名 Dog 的构造函数。现在你将拥有一个几乎完全等同于 Dog 结构体的 Canine ,你可以玩弄一下。

  3. 添加一个 Main 方法来创建一些 Dog 和 Canine 数据的副本。

    这是 Main 方法的代码:

      Canine spot = new Canine("Spot", "pug");
      Canine bob = spot;
      bob.Name = "Spike";
      bob.Breed = "beagle";
      spot.Speak();
      Dog jake = new Dog("Jake", "poodle");
      Dog betty = jake;
      betty.Name = "Betty";
      betty.Breed = "pit bull";
      jake.Speak();
    
  4. 在运行程序之前……

    写下你认为在运行这段代码时将会被输出到控制台的内容:

    ...................................................................................

    ...................................................................................

图像图像

这就是发生的事情......

bobspot 引用都指向同一个对象,因此它们都更改了相同的字段并访问了相同的 Speak 方法。但是结构体不是这样工作的。当您创建betty时,您复制了jake中的数据。这两个结构体是完全独立的。

图像图像

注意

当您将一个结构体设置为另一个结构体时,您正在创建数据内部的一个新的复制。这是因为结构体是一个值类型(而不是对象或引用类型)。

栈 vs. 堆:更多关于内存的信息

让我们快速回顾一下结构体与对象的区别。您已经看到,只需使用等号就可以制作结构体的新副本,而这是您无法用对象做到的。但背后的真正情况又是怎样的呢?

CLR 将数据分为内存的两个地方:堆和栈。您已经知道对象存储在上。CLR 还保留了另一个内存部分称为,用于存储您在方法中声明的局部变量和传递给这些方法的参数。您可以将栈视为一堆可以放置值的槽。当调用方法时,CLR 会向栈顶添加更多槽。当方法返回时,它的槽会被移除。

图像图像

了解通过值复制的结构体与通过引用复制的对象之间的不同是非常重要的。

有时您需要编写一个方法,可以接受值类型或者引用类型 —— 也许是可以处理 Dog 结构体或 Canine 对象的方法。如果您发现自己处于这种情况下,可以使用object关键字:

   public void WalkDogOrCanine(object getsWalked) { ... }

如果您向此方法发送一个结构体,该结构体会被包装成一个特殊的“包装器”对象,使其可以存储在堆上。当包装器在堆上时,您无法对结构体做太多操作。您需要“拆包”结构体才能处理它。幸运的是,当您将一个对象设置为值类型或将值类型传递给一个期望对象的方法时,所有这些操作都会自动发生。

注意

您还可以使用“is”关键字来查看一个对象是否是被装箱并放置在堆上的结构体或任何其他值类型。

  1. 这是在您创建一个对象变量并将其设置为 Dog 结构体后,栈和堆看起来的样子。

    Dog sid = new Dog("Sid", "husky");
    WalkDogOrCanine(sid);
    

    图像

  2. 如果您想要解包对象,您只需要将其强制转换为正确的类型,它会自动解包。is关键字对结构体也可以正常工作,但要小心,因为as关键字不适用于值类型。

    if (getsWalked is Dog doggo) doggo.Speak();
    

    图像

图像

注意

打开 Unity 项目并悬停在 Vector3 上——它是一个 struct。垃圾收集(或 GC)可能会严重降低应用程序的性能,而游戏中的许多对象实例可能会触发额外的 GC 并降低帧速率。游戏通常会使用大量向量。将它们设为 struct 意味着它们的数据保存在堆栈上,因此即使创建数百万个向量也不会导致额外的 GC,从而降低游戏的运行速度。

使用out参数使方法返回多个值

谈到参数和参数,还有几种将值传递到程序中的方法。它们都涉及向方法声明中添加修饰符。其中一种常见的方法是使用out修饰符指定输出参数。你已经多次见过out修饰符——每次调用 int.TryParse 方法时都会用到它。你也可以在自己的方法中使用out修饰符。创建一个新的控制台应用程序,并将这个空方法声明添加到表单中。注意这两个参数上的out修饰符:

做这个!

图片

注意

通过使用 out 参数,一个方法可以返回多个值。

仔细看看这两个错误:

  • ‘half’ 输出参数必须在控制离开当前方法之前赋值

  • ‘twice’ 输出参数必须在控制离开当前方法之前赋值

每当使用out参数时,你总是需要在方法返回之前设置它,就像你总是需要在方法声明中使用return语句一样。

这是应用程序的所有代码:

图片

当你运行应用程序时,看看它是什么样子:

Enter a number: 17
Outputs: plus one = 18, half = 8.50, twice = 34

使用ref修饰符进行引用传递

你一直见过的一件事是,每次你把 int、double、struct 或任何其他值类型传递给一个方法时,你都在把该值的副本传递给该方法。这有一个名称:按值传递,这意味着参数的整个值都会被复制。

但是还有另一种将参数传递给方法的方式,称为按引用传递。你可以使用**ref**关键字允许方法直接使用传递给它的参数。与out修饰符一样,当声明方法和调用方法时都需要使用**ref**。无论是值类型还是引用类型,通过ref参数传递给方法的任何变量都将直接被该方法改变。

要查看它是如何工作的,请创建一个新的控制台应用程序,其中包含这个 Guy 类和这些方法:

注意

在底层,一个“out”参数就像一个“ref”参数,唯一的区别是它在进入方法之前不需要被赋值,但在方法返回之前必须被赋值。

图片

使用可选参数来设置默认值

很多时候,你的方法将会以相同的参数被多次调用,但是偶尔会改变。如果你能设置一个默认值,那么当调用方法时只需要在参数不同的时候指定它就足够了。

这正是可选参数的作用。你可以通过在方法声明中使用等号后跟该参数的默认值来指定一个可选参数。你可以拥有任意数量的可选参数,但所有的可选参数都必须在必需参数之后。

这是一个使用可选参数检查某人是否发烧的方法示例:

图片

此方法有两个可选参数:tooHigh 的默认值为 99.5,tooLow 的默认值为 96.5。调用 CheckTemperature 时只传递一个参数将使用这两个参数的默认值。如果你传递两个参数,它将使用第二个参数作为 tooHigh 的值,但仍然使用 tooLow 的默认值。你可以指定所有三个参数来为所有三个参数传递值。

如果你想使用一些(但不是所有)默认值,你可以使用命名参数来仅传递你想传递的那些参数的值。你只需要给出每个参数的名称,后跟一个冒号和它的值。如果你使用多个命名参数,请确保用逗号分隔它们,就像任何其他参数一样。

将 CheckTemperature 方法添加到控制台应用程序,然后添加这个 Main 方法:

static void Main(string[] args)
{
    // Those values are fine for your average person
    CheckTemperature(101.3);

    // A dog’s temperature should be between 100.5 and 102.5 Fahrenheit
    CheckTemperature(101.3, 102.5, 100.5);

    // Bob’s temperature is always a little low, so set tooLow to 95.5
    CheckTemperature(96.2, tooLow: 95.5);
}

它打印出这个输出,根据可选参数的不同值而有不同的工作方式:

Uh-oh 101.3 degrees F -- better see a doctor!
101.3 degrees F - feeling good!
96.2 degrees F - feeling good!

在希望方法具有默认值时,请使用可选参数和命名参数。

空引用不指向任何对象。

当你创建一个新的引用并且没有设置它的值时,它有一个值。它最初设置为**null**,这意味着它不指向任何东西。让我们来实验一下空引用。

就这样吧!

  1. 创建一个新的控制台应用程序,并添加你用来实验 ref 关键字的 Guy 类。

  2. 然后添加以下代码,创建一个新的 Guy 对象,但是不设置其 Name 属性

    static void Main(string[] args)
    {
        Guy guy;
        guy = new Guy() { Age = 25 };
        Console.WriteLine("guy.Name is {0} letters long", guy.Name.Length);
    }
    
  3. 在 Main 方法的最后一行上设置断点,然后调试你的应用程序。

    当它遇到断点时,悬停在guy以检查其属性值:

    图片

    注意

    String 是一个引用类型。由于你没有在 Guy 对象中设置它的值,所以它仍然保持默认值:null。

  4. 继续运行代码。 Console.WriteLine 尝试访问 guy.Name 属性引用的 String 对象的 Length 属性,并抛出异常:

    图片

    注意

    当 CLR 抛出 NullReferenceException(开发人员通常称之为 NRE)时,它告诉你它试图访问对象的成员,但用于访问该成员的引用为 null。开发人员尽量避免空引用异常。

非可空引用类型帮助你避免 NRE

避免空引用异常(或者 NRE)的最简单方法是设计代码以使引用不能为 null。幸运的是,C#编译器为此提供了一个非常有用的工具。在 Guy 类的顶部添加以下代码——可以放在命名空间声明的内部或外部:

#nullable enable

以#开头的行是一个指令,或者说是告诉编译器设置特定选项的一种方式。在这种情况下,它告诉编译器将任何引用视为非可空引用类型。一旦添加了该指令,Visual Studio 会在 Name 属性下方绘制一个警告波浪线。将鼠标悬停在属性上以查看警告:

图片

C#编译器做了一件非常有趣的事情:它使用了流分析(或者说一种分析代码中各种路径的方法)来确定Name 属性有可能被赋予空值。这意味着你的代码可能会抛出 NullReferenceException。

你可以通过在类型后添加?字符来强制 Name 属性成为可空引用类型,从而消除警告。

图片

但是虽然这样可以消除错误消息,却并不能真正防止任何异常。

使用封装来防止属性为空

回到#encapsulation_keep_your_privateshellippr,你学习了如何使用封装来保持类成员不受无效值的影响。因此,将 Name 属性设为私有,然后添加一个构造函数来设置其值:

图片

一旦封装了 Name 属性,就可以防止其被设置为null,这样警告就消失了。

空合并运算符 ?? 对空值有帮助

有时候无法避免与空值一起工作。例如,你已经学习了如何使用 StringReader 从字符串中读取数据,详见#reading_and_writing_files_save_the_last。创建一个新的控制台应用程序,并添加以下代码:

图片

运行代码——你会得到一个 NRE。我们能做些什么来解决这个问题?

?? 检查 null 并返回替代值

防止访问(或者解引用)空引用的一种方法是使用空合并运算符 ?? 来评估可能为空的表达式——在本例中是调用 stringReader.ReadLine,并在其为空时返回替代值。修改using块的第一行,在行末添加?? String.Empty

图片

一旦添加了这个,警告就会消失。这是因为 null 合并运算符告诉 C#编译器执行 stringReader.ReadLine;如果返回的值不为 null,则使用它,但如果为 null,则使用您提供的值(在本例中为空字符串)。

??=仅在变量为 null 时赋值

当您处理 null 值时,编写代码检查值是否为 null 并将其赋予非 null 值以避免 NRE 是非常常见的。例如,如果您想要修改程序以打印第一行代码,您可能会编写如下代码:

     if (nextLine == null)
         nextLine = "(the first line is null)";

     // Code that works with nextLine and needs it to be non-null

你可以使用null 赋值 ??=运算符重写该条件语句:

        nextLine ??= "(the first line was empty)";

??=运算符检查表达式左侧的变量、属性或字段(在本例中是 nextLine),看看它是否为 null。如果是,该运算符将右侧表达式的值赋给它。如果不是,则保留原值。

可空值类型可以是 null...并且可以安全处理

当你声明一个 int、bool 或其他值类型时,如果没有指定值,CLR 会为它分配一个默认值,如 0 或 true。但假设你正在编写代码来存储调查数据,其中有一个可选的是/否问题。如果需要表示可能为 true 或 false,或者根本没有值的布尔值,该怎么办?

这就是可空值类型非常有用的地方。可空值类型可以具有值或设置为 null。它利用了一个泛型结构 Nullable,可以用来包装一个值(或包含该值并提供成员以访问和处理它)。如果将可空值类型设置为 null,则它没有值——Nullable为您提供了方便的成员,让您即使在这种情况下也可以安全地使用它。

您可以像这样声明一个可空布尔值:

    Nullable<bool> optionalYesNoAnswer = null;

C#还有一个快捷方式——对于值类型 T,您可以像这样声明 NullableT?

    bool? anotherYesNoAnswer = false;

可空类型Nullable<T>结构有一个名为 Value 的属性,用于获取或设置值。bool?将具有 bool 类型的值,int?将具有 int 类型的值,等等。它们还有一个名为 HasValue 的属性,如果值不为 null 则返回 true。

您始终可以将值类型转换为可空类型:

    int? myNullableInt = 9321;

并且您可以使用其方便的 Value 属性获取值:

    int = myNullableInt.Value;

图片

但是 Value 调用最终只是使用(int)myNullableInt将值强制转换,如果值为 null,它将引发 InvalidOperationException。这就是为什么 Nullable还有一个 HasValue 属性,如果值不为 null 则返回 true,否则返回 false。您还可以使用方便的 GetValueOrDefault 方法,如果 Nullable 没有值,则安全地返回默认值。您可以选择传递一个默认值来使用,或者使用类型的正常默认值。

“Captain” Amazing...not so much

到目前为止,您应该对不那么强大、更疲惫的“Captain Amazing”发生了什么有了相当好的了解。事实上,那根本不是 Captain Amazing,而是一个装箱结构:

Images

结构体对封装可能是有价值的,因为返回结构体的只读属性总是生成它的新副本。

池子拼图

你的 任务 是从池子中取出片段,并将它们放入代码的空白行中。你可以多次使用相同的片段,而且你不需要使用所有的片段。你的目标是在执行此应用程序时,使代码将下面显示的输出写入控制台。

Images

池子拼图解

Images

扩展方法向现有的类添加新行为

有时你需要扩展一个无法继承的类,比如封闭类(许多 .NET 类都是封闭的,所以你无法从它们继承)。而 C# 给了你一个灵活的工具:扩展方法。当你将一个具有扩展方法的类添加到你的项目中时,它会添加新的方法,这些方法出现在已经存在的类上。你所需做的就是创建一个静态类,并添加一个静态方法,该方法以该类的实例作为其第一个参数,使用 this 关键字。

注意

记住来自 #interfacescomma_castingcomma_and_quotati 的封闭修饰符吗?它是你设置一个无法被扩展的类的方法。

所以假设你有一个封闭的 OrdinaryHuman 类:

Images

一旦 AmazeballsSerum 类被添加到项目中,OrdinaryHuman 就会获得一个 BreakWalls 方法。因此,现在你的 Main 方法可以使用它:

static void Main(string[] args){
    OrdinaryHuman steve = new OrdinaryHuman(185);
    Console.WriteLine(steve.BreakWalls(89.2));
}

就是这样!你所需做的就是将 AmazeballsSerum 类添加到你的项目中,突然间每个 OrdinaryHuman 类都会得到一个全新的 BreakWalls 方法。

注意

当程序创建 OrdinaryHuman 类的实例时,只要 AmazeballsSerum 类存在于项目中,就可以直接访问 BreakWalls 方法。继续,试试看吧!创建一个新的控制台应用程序,并将这两个类和 Main 方法添加进去。调试进入 BreakWalls 方法,看看发生了什么。

嗯... 书中稍早我们通过向我们的代码顶部添加 using 指令来“神奇地”向类添加了方法。你还记得那是在哪里吗?

注意

关于扩展方法还有一点需要记住:通过创建扩展方法,你不会访问类的任何内部内容,因此它仍然像一个局外人一样工作。

Images

是的!LINQ 是基于扩展方法的。

除了扩展类之外,你还可以扩展接口。你所需做的就是在扩展方法的第一个参数的 this 关键字之后使用一个接口名称。扩展方法将被添加到实现该接口的每个类中。这正是 .NET 团队在创建 LINQ 时所做的——所有 LINQ 方法都是 IEnumerable 接口的静态扩展方法。

它的工作原理如下。当你在代码顶部添加using System.Linq;时,它会让你的代码“看到”一个名为 System.Linq.Enumerable 的静态类。你已经使用了它的一些方法,比如 Enumerable.Range,但它还有扩展方法。去 IDE 中输入Enumerable.First,然后查看声明。它以(extension)开头,告诉你它是一个扩展方法,它的第一个参数使用了this关键字,就像你写的扩展方法一样。对于每个 LINQ 方法,你会看到相同的模式。

图像

扩展基本类型:字符串

让我们通过扩展 String 类来探索扩展方法的工作原理。创建一个新的控制台应用程序项目,并添加一个名为HumanExtensions.cs的文件。

做这个!

  1. 将所有扩展方法放在单独的命名空间中。

    将你所有的扩展方法放在一个不同的命名空间中是一个好主意。这样,你就不会在其他程序中使用它们时遇到麻烦。为你的方法设置一个静态类来存放:

    图像

  2. 创建静态扩展方法,并将其第一个参数定义为 this,然后是你要扩展的类型。

    当你声明一个扩展方法时,你需要知道的两件主要事情是,方法必须是静态的,并且它将扩展的类作为它的第一个参数:

    图像

  3. 完成扩展方法。

    这个方法检查字符串是否包含单词“Help!”——如果包含,那么这个字符串就是一个求助呼叫,每个超级英雄都会答应:

    图像

  4. 使用你的新 IsDistressCall 扩展方法。

    在你的 Program 类文件顶部添加using AmazingExtensions;。然后在类中添加代码,创建一个字符串并调用它的 IsDistressCall 方法。你会在 IntelliSense 窗口中看到你的扩展方法:

    图像

扩展磁铁

图像

排列磁铁以产生此输出:

一个铜板生更多的铜板

图像图像

扩展磁铁解决方案

图像

你的工作是排列磁铁以产生此输出:

一个铜板生更多的铜板

图像

第十八章:异常处理:处理异常开始变得老套

图片

程序员不应该成为消防员。

你努力工作,浏览技术手册和一些引人入胜的Head First图书,最终成为你职业生涯的顶峰。但你仍然在半夜因为程序崩溃行为不符合预期而接到恐慌的电话。没有什么能像修复一个奇怪的错误那样让你从编程状态中脱颖而出...但是通过异常处理,你可以编写代码来处理出现的问题。更重要的是,你甚至可以为这些问题做好准备,并在问题发生时保持系统运行

你的十六进制转储程序从命令行读取文件名

在 #reading_and_writing_files_save_the_last 结尾,你构建了一个十六进制转储程序,该程序使用命令行参数转储任何文件。你使用 IDE 中的项目属性设置调试器的参数,并学习了如何从 Windows 命令提示符或 macOS Terminal 窗口调用它。

图片

但是如果你给 HexDump 一个无效的文件名会发生什么?

当你修改你的 HexDump 应用程序以使用命令行参数时,我们要求你务必指定一个有效的文件名。当你提供一个无效的文件名时会发生什么?尝试再次从命令行运行你的应用程序,但这次给它传递参数 invalid-filename。现在它抛出一个异常

图片

使用项目设置将程序的参数设置为一个无效的文件名,并在 IDE 的调试器中运行应用程序。现在你会看到它抛出一个异常,类名相同(System.IO.FileNotFoundException),并显示类似的“找不到文件”的消息。

图片

注意

实际上,你不会连续遇到所有这些异常 —— 程序会抛出第一个异常然后停止。只有在修复第一个异常后才会遇到第二个异常。

当你的程序抛出一个异常时,CLR 会生成一个异常对象

你一直在研究 CLR 在程序中告诉你出了问题的方式:一个异常。当你的代码中发生异常时,会创建一个对象来表示这个问题。这就是——毫不奇怪——异常。

例如,假设你有一个包含四个项目的数组,然后你尝试访问第 16 个项目(由于我们是以零为基础的,所以索引是 15):

图片

注意

ex-cep-tion,名词。

一个被排除在一般声明之外或不遵循规则的人或物。尽管杰米通常讨厌花生酱,他们对帕克的花生酱夹心薄片做了个例外

当 IDE 因为代码抛出异常而停止时,你可以通过在 Locals 窗口中展开$exception来查看异常的详细信息。Locals 窗口显示当前范围内的所有变量(这意味着当前语句可以访问它们)。

CLR 会创建一个对象,因为它想要提供有关引发异常的所有信息。你可能需要修复代码,或者只需对程序中的特定情况进行一些更改。

这个特定的异常是IndexOutOfRangeException,它告诉你问题所在:你正在尝试访问数组中超出范围的索引。你还可以获取有关问题发生位置的详细信息,这使得跟踪和解决问题变得更容易(即使你的代码有数千行)。

所有的异常对象都继承自 System.Exception

.NET 有许多不同的异常可能需要报告。由于许多异常具有许多相似的特征,因此继承起了作用。.NET 定义了一个基类叫做 Exception,所有特定的异常类型都从这个基类继承。

Exception 类有几个有用的成员。Message 属性存储了关于出错原因的易读信息。StackTrace 告诉你在异常发生时正在执行的代码以及导致异常的过程。(还有其他的,但我们首先使用这些。)

图像图像

没错。异常是一个非常有用的工具,可以帮助你找到代码行为不符合预期的地方。

很多程序员第一次看到异常时会感到沮丧。但是异常是非常有用的,你可以利用它们来优化你的程序。当你看到异常时,它提供了很多线索,帮助你找出代码为什么会以你意料之外的方式反应。这对你有好处:它让你知道程序必须处理的新情况,并为你提供了解决问题的机会

异常主要是帮助你找到并修复代码表现出意料之外行为的情况。

有一些文件你是无法转储的

在#linq_and_lambdas_get_control_of_your_dat 中,我们讨论了如何使你的代码更加健壮,以便处理不良数据、格式错误的输入、用户错误和其他意外情况。如果没有通过命令行传递文件或文件不存在,则倒置 stdin 是使十六进制转储器更加健壮的一个很好的起点。

但是还有一些情况需要我们处理吗?例如,如果文件存在但不可读怎么办?让我们看看如果我们移除文件的读取权限,然后尝试读取会发生什么:

  • 在 Windows 上: 在 Windows 资源管理器中右键点击文件,转到安全选项卡,然后点击编辑以修改权限。勾选所有的拒绝框。

    图片

  • 在 Mac 上: 在终端窗口中,切换到包含要转储文件的文件夹,并运行以下命令,将binarydata.dat替换为你的文件名:chmod 000 binarydata.dat.

现在你已经从文件中删除了读取权限,尝试再次运行你的应用程序,可以在 IDE 中或从命令行中执行。

你会看到一个异常—堆栈跟踪显示using语句调用了 GetInputStream 方法,最终导致 FileStream 抛出了 System.UnauthorizedAccessException 异常:

C:\HexDump\bin\Debug\netcoreapp3.1>hexdump binarydata.dat
Unhandled exception. System.UnauthorizedAccessException: Access to the path ’C:\HexDump\bin\Debug\
netcoreapp3.1\binarydata.dat’ is denied.
   at System.IO.FileStream.ValidateFileHandle(SafeFileHandle fileHandle)
   at System.IO.FileStream.CreateFileOpenHandle(FileMode mode, ..., FileOptions options)
   at System.IO.FileStream..ctor(String path, ..., Int32 bufferSize, FileOptions options)
   at System.IO.FileStream..ctor(String path, FileMode mode, FileAccess access, FileShare share)
   at System.IO.File.OpenRead(String path)
   at HexDump.Program.GetInputStream(String[] args) in C:\HexDump\Program.cs:line 14
   at HexDump.Program.Main(String[] args) in C:\HexDump\Program.cs:line 20

图片

实际上,对此是办法的。

是的,用户确实经常出错。他们会向你的程序提供糟糕的数据,奇怪的输入,点击你甚至不知道存在的东西。这是生活的一部分,但这并不意味着你无法应对。C#为你提供了非常有用的异常处理工具,帮助你使程序更加健壮。因为虽然你不能控制用户如何使用你的应用程序,但你可以确保他们这样做时你的应用程序不会崩溃。

当你想调用的方法存在风险时会发生什么?

用户是不可预测的。他们会将各种奇怪的数据输入到你的程序中,并以你意想不到的方式点击东西。这没问题,因为你可以通过添加异常处理来处理代码抛出的异常,从而执行特殊的代码。

  1. 假设你的程序中调用的方法接受用户输入。

    图片

  2. 那个方法可能会在运行时出现风险。

    图片图片

  3. 你需要知道你调用的方法是有风险的。

    图片

    注意

    如果你能想出一种避免抛出异常的风险较小的方法,那就是最好的结果!但有些风险是无法避免的,这时候你就需要这样做。

  4. 然后,如果异常发生,你可以编写代码来处理异常。务必做好准备,以防万一。

    图片

使用 try 和 catch 处理异常

当你在代码中添加异常处理时,你会使用trycatch关键字创建一个代码块,该代码块在抛出异常时执行。

你的try/catch代码基本上告诉 C#编译器:“试试这段代码,如果出现异常,这段其他代码捕获它。”你试图的代码部分是try,处理异常的部分称为catch。在catch块中,你可以做一些事情,比如打印友好的错误消息,而不是让程序停止运行。

让我们再来看看 HexDump 场景中堆栈跟踪的最后三行,帮助我们确定在哪里放置我们的异常处理代码:

   at System.IO.File.OpenRead(String path)
   at HexDump.Program.GetInputStream(String[] args) in Program.cs:line 14
   at HexDump.Program.Main(String[] args) in Program.cs:line 20

UnauthorizedAccessException是由调用File.OpenReadGetInputStream中的那一行引起的。由于我们无法阻止该异常,让我们修改GetInputStream以使用try/catch块:

图片

在我们的异常处理程序中保持简单。首先,我们使用 Console.Error 写入了一行到错误输出(stderr),告知用户发生了错误,然后我们回退到从标准输入读取数据,以便程序仍然执行某些操作。注意catch块中有一个return语句。该方法返回一个流,因此如果处理异常,则仍需要返回一个流;否则,您将会得到“not all code paths return a value”编译器错误。

使用调试器跟踪 try/catch 流程

异常处理的重要部分是,当try块中的语句抛出异常时,块中的其余代码会被短路。程序立即跳转到catch块中的第一行。让我们使用 IDE 的调试器来探索这是如何工作的。

调试这个!

  1. 将您的 HexDump 应用程序中的 GetInputStream 方法替换为我们刚刚展示的方法,以处理 UnauthorizedAccessException。

  2. 修改项目选项,将参数设置为包含不可读文件的路径。

  3. 在 GetInputStream 的第一条语句上设置断点,然后开始调试您的项目。

  4. 当程序运行到断点时,跳过接下来的几个语句,直到到达File.OpenRead。继续执行——应用程序跳转到catch块的第一行。

    图片

  5. 继续逐步执行catch块的其余部分。它将向控制台写入一行,然后返回 Console.OpenStandardInput 并恢复 Main 方法。

如果您有代码始终需要运行,请使用 finally 块

当程序抛出异常时,可能会发生几件事情。如果异常被处理,程序将停止处理并崩溃。如果异常处理,代码将跳转到catch块。那么try块中的其余代码呢?如果您正在关闭流或清理重要资源怎么办?该代码需要运行,即使发生异常,否则程序状态将混乱。这就是您将使用**finally 块**的地方。它位于trycatch之后。**finally**块始终运行,无论是否抛出异常。让我们使用调试器来探索finally块的工作原理。

调试这个!

  1. 创建一个新的控制台应用程序项目。

    在文件顶部添加using System.IO;,然后添加以下Main方法:

    图片

    注意

    您将在 Locals 窗口中看到异常,就像您之前看到的那样。

  2. 在 Main 方法的第一行添加断点。

    调试你的应用程序并逐步执行它。try块中的第一行尝试访问args[0],但由于您没有指定任何命令行参数,args数组为空,它会抛出一个异常——具体来说,是System.IndexOutOfRangeException,并显示消息“Index was outside the bounds of the array.” 在打印消息后,它执行 **finally** ,然后程序退出。

  3. 设置一个带有有效文件路径的命令行参数。

    使用项目属性向应用程序传递命令行参数。给它一个有效文件的完整路径。确保文件名中没有空格,否则应用程序会将其解释为两个参数。再次调试你的应用程序——在完成try块后,它执行 **finally**

  4. 设置一个带有无效文件路径的命令行参数。

    返回到项目属性,更改命令行参数,将应用程序命名为不存在的文件。再次运行你的应用程序。这次它捕获了不同的异常:System.IO.FileNotFoundException。然后它执行 **finally**

通用异常捕获处理 System.Exception

你刚刚让你的控制台应用程序抛出了两种不同类型的异常——一个是IndexOutOfRangeException,另一个是FileNotFoundException,它们都被处理了。仔细看一下catch块:

    catch (Exception ex)

这是一个通用异常捕获catch块后的类型指示要处理的异常类型,由于所有异常都扩展自System.Exception类,指定Exception作为类型告诉try/catch块捕获任何异常。

避免使用多个catch块来捕获所有异常

尽量预料代码可能抛出的具体异常并处理它们是更好的做法。例如,我们知道如果没有指定文件名,此代码可能抛出IndexOutOfRangeException异常,如果找到无效文件,则可能抛出FileNotFoundException异常。我们还在本章的前面看到,尝试读取一个不可读文件会导致 CLR 抛出UnauthorizedAccessException。您可以通过在代码中添加多个catch块来处理这些不同类型的异常:

图片

现在你的应用程序会根据处理的异常不同写入不同的错误消息。注意,前两个catch未指定变量名(如ex)。只有在需要使用异常对象时才需要指定变量名。

池谜题

你的任务是从池中取出代码片段,并将它们放入程序中的空白行中。你可以多次使用相同的片段,而且不需要使用所有的片段。你的目标是使程序产生下面的输出。

图片

池谜题解决方案

图片图片

未处理的异常会上升。

信不信由你,留下未处理的异常确实非常有用。现实生活中的程序具有复杂的逻辑,当程序出现问题时,特别是在程序深处发生问题时,正确恢复通常很困难。通过仅处理特定异常并避免使用捕获所有异常的处理程序,你可以让意外的异常“冒泡上浮”:而不是在当前方法中处理它们,它们会被调用堆栈中下一个语句捕获。预期和处理你期望的异常,并让未处理的异常冒泡上浮,是构建更健壮应用程序的一个很好方法。

有时候重新抛出异常是有用的,这意味着你在方法中处理异常但仍然将其上抛给调用它的语句。重新抛出异常只需在catch块中调用throw;,它捕获的异常将立即上抛:

图片

注意

职业提示:许多 C#编程工作面试都会问到你如何在构造函数中处理异常。

使用合适的异常处理情况

当你使用集成开发环境(IDE)生成一个方法时,它会添加以下代码:

   private void MyGeneratedMethod()
   {
       throw new NotImplementedException();
   }

NotImplementedException 用于任何未实现的操作或方法。它是一种很好的方法来添加占位符 —— 一旦你看到它,你就知道有需要编写的代码。这只是.NET 提供的众多异常之一。

选择正确的异常可以使你的代码更易读,并使异常处理更清晰和更健壮。例如,一个验证其参数的方法中的代码可以抛出 ArgumentException,它有一个重载的构造函数,用于指定造成问题的参数。考虑一下 Guy 类,它在#objectshellipget_orientedexclamation_mar 中返回,具有一个 ReceiveCash 方法,检查amount参数以确保接收到正数金额。这是一个很好的机会来抛出 ArgumentException:

图片

花点时间查看一下.NET API 中的异常列表 —— 你可以在代码中抛出其中任何一个:docs.microsoft.com/en-us/dotnet/api/system.systemexception

捕获扩展自 System.Exception 的自定义异常

有时候你希望程序因为运行时可能发生的特殊情况而抛出异常。让我们回到从#objectshellipget_orientedexclamation_mar 开始的 Guy 类。假设你在一个应用程序中使用它,这个应用程序绝对依赖于 Guy 始终具有正数金额。你可以添加一个扩展自 System.Exception 的自定义异常:

图片

现在你可以抛出这个新异常,并像处理任何其他异常一样捕获它:

图片

异常磁铁

图片

安排磁铁,使应用程序将以下输出写入控制台:

当它解冻时它抛出。

class Program {
   public static void Main(string[] args) {
       Console.Write("when it ");
       ExTestDrive.Zero("yes");
       Console.Write(" it ");
       ExTestDrive.Zero("no");
       Console.WriteLine(".");
   }
}

class MyException : Exception { } 

图片

异常磁铁解决方案

图片

安排磁铁,使应用程序将以下输出写入控制台:

当它解冻时它抛出。

class Program {
   public static void Main(string[] args) {
       Console.Write("when it ");
       ExTestDrive.Zero("yes");
       Console.Write(" it ");
       ExTestDrive.Zero("no");
       Console.WriteLine(".");
   }
}

图片图片

异常过滤器帮助你创建精确的处理程序

假设我们正在建立一个设定在 20 世纪 30 年代经典黑手党犯罪地带的游戏,我们有一个 LoanShark 类需要使用 Guy.GiveCash 方法从 Guy 的实例中收集现金,并且使用老式黑手党风格的方式处理任何 OutOfCashException。

问题是,每个放高利贷的人都知道一个黄金法则:不要试图向大黑手党老板收钱。这就是异常过滤器可以派上用场的地方。异常过滤器使用when关键字告诉你的异常处理程序仅在特定条件下捕获异常。

这是一个异常过滤器如何工作的示例:

图片图片图片

构建尽可能精确的异常处理程序总是更好的。

异常处理远不止打印通用错误消息那么简单。有时你希望对不同的异常做不同的处理——就像十六进制转储器从 FileNotFoundException 和 UnauthorizedAccessException 中不同处理一样。总是要为意外情况做计划。有时可以预防这些情况,有时希望处理它们,有时希望异常上升至上层。这里的一个重要教训是,处理意外情况没有一种“一刀切”的方法,这也是为什么 IDE 不只是在try/catch块中包裹所有内容。

这就是为什么有那么多继承自 Exception 的类,也是为什么你甚至可能想要编写自己的类来继承 Exception 的原因。

史上最糟糕的 catch 块:万能加注释

如果你愿意,catch块会让你的程序继续运行。异常被抛出,你捕捉异常,而不是关闭并给出错误消息,你继续进行。但有时候,这并不是件好事。

看看这个Calculator类,它似乎总是表现得很奇怪。发生了什么?

图片

应该处理你的异常,而不是掩埋它们

仅仅因为你可以让程序继续运行,并不意味着你已经处理了你的异常。在上面的代码中,计算器不会崩溃……至少在 Divide 方法中不会。如果其他代码调用了该方法,并尝试打印结果呢?如果除数为零,那么该方法可能返回一个不正确(且意外的)值。

不要仅仅添加评论并隐藏异常,你需要处理异常。如果你无法处理问题,不要留下空的或注释掉的catch块!那只会让其他人更难追踪问题所在。最好让程序继续抛出异常,因为这样更容易找出问题所在。

注意

请记住,当你的代码无法处理异常时,异常会沿调用堆栈向上冒泡。让异常冒泡是一种完全有效的处理异常的方式,在某些情况下,这比使用空的 catch 块来隐藏异常更合理。

临时解决方案是可以接受的(暂时的)

有时你会发现问题,并且知道这是一个问题,但不确定该怎么办。在这些情况下,你可能希望记录问题并注明正在发生的情况。虽然这不如处理异常好,但比什么都不做要好。

这里是计算器问题的临时解决方案:

注意

...但在现实生活中,“临时”解决方案往往会变成永久性解决方案的不良习惯。

注意

花点时间思考一下这个catch块。如果StreamWriter无法写入到 C:\Logs\文件夹会发生什么?你可以嵌套另一个try/catch块来减少风险。你能想到更好的解决方案吗?

ImagesImages

处理异常并不总是意味着修复异常。

让程序崩溃永远不是好事。更糟糕的是,如果不知道程序为何崩溃或它对用户数据造成了什么影响。这就是为什么你需要确保始终处理你能预测到的错误,并记录你无法预测到的错误。虽然日志对于追踪问题很有用,但在首次出现问题之前预防问题是更好、更永久的解决方案。

第十九章:Unity 实验室#6 场景导航

在上一个 Unity 实验室中,你创建了一个带有地板(一个平面)和玩家(一个球体嵌套在圆柱体下)的场景,并使用了 NavMesh、NavMesh Agent 和射线投射让你的玩家根据鼠标点击在场景中移动。

现在我们将继续上一个 Unity 实验室的工作。这些实验室的目标是让你熟悉 Unity 的寻路和导航系统,这是一个复杂的 AI 系统,可以让你创建能够在你创建的世界中找到路的角色。在这个实验室中,你将使用 Unity 的导航系统使你的游戏对象在场景中自动移动。

在此过程中,你将学习到一些有用的工具:你将创建一个更复杂的场景,并烘焙一个 NavMesh 以让一个代理人在其中导航,你将创建静态和移动障碍物,而且最重要的是,你将得到更多编写 C#代码的实践

让我们继续上一个 Unity 实验室的工作

在上一个 Unity 实验室中,你通过一个球形头部嵌套在圆柱体身体下创建了一个玩家。然后,你添加了 NavMesh Agent 组件,用于使玩家在场景中移动,使用射线投射来找到玩家点击的地板上的点。在这个实验室中,你将继续上一个实验室的工作。你将向场景中添加游戏对象,包括楼梯和障碍物,以便观察 Unity 的导航 AI 如何处理它们。然后,你将添加一个移动障碍物,真正测试 NavMesh Agent 的性能。

现在,打开你在上一个 Unity 实验室结尾保存的 Unity 项目。如果你一直在保存 Unity 实验室以便一口气做完,那么你现在可能已经准备好立即开始了!但如果不是,请花几分钟再翻阅一下上一个 Unity 实验室,并查看你为其编写的代码。

图片

注意

如果你正在使用我们的书籍,因为你正在准备成为一名专业开发者,那么能够回顾和重构你旧项目中的代码是一个非常重要的技能——不仅仅是为了游戏开发!

向你的场景添加一个平台

让我们通过一些对 Unity 导航系统的实验来进行一些试验。为了帮助我们做到这一点,我们将添加更多的游戏对象来建立一个带有楼梯、斜坡和障碍物的平台。这是它将会看起来的样子:

图片

如果我们切换到等距视图或者不显示透视的视图,更容易看清楚正在发生的事情。在透视视图中,远处的物体看起来较小,而近处的物体看起来较大。在等距视图中,无论物体距离摄像机有多远,它们始终保持相同大小。

图片

注意

有时,如果切换到等距视图,你能更容易地看到场景中正在发生的事情。如果你迷失了视角,你可以随时重置布局。

将 10 个游戏对象 添加到你的场景中。在你的材质文件夹中创建一个名为 Platform 的新材质,使用 Albedo 颜色 CC472F,并将其添加到除了障碍物之外的所有游戏对象上,障碍物则使用来自第一个 Unity 实验室的 名为 8 Ball 的新材质,并且带有 8 Ball 纹理映射。这张表展示了它们的名称、类型和位置:

Images

使用烘焙选项使平台可行走

使用 Shift+点击选择你在场景中添加的所有新游戏对象,然后使用 Control+点击(或者在 Mac 上使用 Command+点击)取消选择障碍物。转到导航窗口并点击对象按钮,然后 通过 勾选导航静态并设置导航区域为可行走来 使它们全部可以行走。通过选择障碍物,点击导航静态,并将导航区域设置为不可行走来 使障碍物游戏对象不可行走

Images

现在按照之前使用的相同步骤来 烘焙 NavMesh:点击导航窗口顶部的烘焙按钮切换到烘焙视图,然后点击底部的烘焙按钮。

Images

看起来好像奏效了!NavMesh 现在显示在平台的顶部,并且障碍物周围有空间。试着运行游戏。点击平台的顶部看看会发生什么。

嗯,等等。事情并没有按我们预期的方式运行。当你点击平台顶部时,玩家却在其下方。如果你仔细观察在导航窗口查看时显示的 NavMesh,你会发现它周围有楼梯和坡道的空间,但实际上并没有将它们包含在 NavMesh 中。玩家无法到达你点击的点,所以 AI 尽其所能靠近该点。

Images

在你的 NavMesh 中包括楼梯和坡道

一个不能将你的玩家上下坡或楼梯的 AI 不会很智能。幸运的是,Unity 的路径 finding 系统可以处理这两种情况。我们只需要在烘焙 NavMesh 时对选项进行一些小的调整。让我们从楼梯开始。返回到烘焙窗口并注意步高的默认值是 0.4。仔细查看你的台阶测量值 —— 它们都是 0.5 单位高。因此,为了告诉导航系统包括高度为 0.5 单位的台阶,将步高改为 0.5。你会看到图表中的台阶图片变高,上面的数字从默认的 0.4 改变为 0.5。

我们仍然需要将坡道包含在 NavMesh 中。当你为平台创建游戏对象时,将坡道的 X 旋转设置为 -46,这意味着它是一个 46 度的斜坡。最大坡度设置默认为 45,这意味着它只会包括最多 45 度的坡道、山坡或其他斜坡。所以 将最大坡度更改为 46,然后 再次烘焙 NavMesh。现在它将包括坡道和楼梯。

Images

启动你的游戏,测试一下你的新 NavMesh 更改。

修复 NavMesh 中的高度问题

现在我们控制了摄像机,可以清楚地看到平台下面发生了什么问题。启动游戏,然后旋转摄像机并放大视角,以清晰查看障碍物在平台下方的情况。点击障碍物一侧的地面,然后点击另一侧。看起来玩家直接穿过了障碍物!而且还穿过了坡道的尽头。

图片

但如果你把玩家移回平台顶部,它会很好地避开障碍物。出了什么问题?

仔细观察障碍物上下的 NavMesh 部分。注意它们之间有什么区别吗?

图片

回到上一个实验的部分,那里你设置了 NavMesh Agent 组件,具体来说是设置了高度为 3。现在你只需要对 NavMesh 做同样的设置。返回导航窗口的烘焙选项,将代理高度设置为 3,然后重新烘焙你的网格

图片

这在障碍物下面的 NavMesh 中创建了一个缺口,并扩展了坡道下面的空隙。现在玩家在平台下移动时既不会撞到障碍物也不会撞到坡道。

添加一个 NavMesh 障碍物

你已经在平台中间添加了一个静态障碍物:你创建了一个拉长的胶囊并标记为不可行走,当你烘焙 NavMesh 时,围绕障碍物有一个空洞,所以玩家必须绕过它。如果你想要一个移动的障碍物呢?试试移动障碍物——NavMesh 不会改变!它仍然在障碍物原来的位置创建了一个空洞,而不是它当前所在的位置。如果重新烘焙,它只会在障碍物新位置周围创建一个空洞。要添加一个移动的障碍物,给游戏对象添加一个NavMesh 障碍组件

现在就来做吧。向场景中添加一个立方体,位置为(-5.75, 1, -1),缩放为(2, 2, 0.25)。为它创建一个新的材质,颜色设为深灰色(333333),并命名你的新游戏对象为移动障碍物。这将充当坡道底部的一种门,可以向上移动以让玩家通过,或向下以阻挡玩家。

图片

我们只需要再做一件事。在检视器窗口底部点击“添加组件”按钮,选择导航 >> Nav Mesh Obstacle,为你的立方体游戏对象添加 NavMesh 障碍组件

图片

如果你保留所有选项的默认设置,你将得到一个 NavMesh 代理无法穿过的障碍物。相反,代理会撞上它并停下来。勾选雕刻框——这会导致障碍物在 NavMesh 中创建一个随着 GameObject 移动的移动洞口。现在你的移动障碍物 GameObject 可以阻止玩家在斜坡上下移动。由于 NavMesh 的高度设置为 3,如果障碍物低于地面 3 单位,它将在其下创建一个 NavMesh 中的洞口。如果它的高度超过这个高度,洞口就会消失。

注意

Unity 手册详细且易读地解释了各种组件。点击检视器中 Nav Mesh 障碍物面板顶部的打开参考按钮(Images)以打开手册页面。花点时间阅读它——它很好地解释了这些选项。

添加一个脚本来上下移动障碍物

此脚本使用OnMouseDrag方法。它的工作方式类似于你在上一个实验中使用的 OnMouseDown 方法,只是当 GameObject 被拖动时调用它。

Images

注意

第一个 if 语句阻止块移动到地板下方,第二个阻止它移动太高。你能搞清楚它们是如何工作的吗?

将你的脚本拖放到移动障碍物 GameObject 上并运行游戏——哎呀,出了些问题。你可以点击并拖动障碍物上下移动,但这也会移动玩家。通过给 GameObject 添加标签来修复这个问题。

Images

然后修改你的 MoveToClick 脚本以检查标签:

Images

再次运行你的游戏。如果你点击障碍物,你可以拖动它上下移动,并且当它碰到地面或者高度过高时会停下来。在其他任何地方点击,玩家会像以前一样移动。现在你可以尝试使用 NavMesh 障碍物选项进行实验(如果你减少玩家的 NavMesh 代理速度会更容易):

  • 开始你的游戏。在层级窗口中点击移动障碍物,然后取消勾选雕刻选项。将你的玩家移到斜坡顶部,然后点击斜坡底部—玩家将会撞到障碍物并停下。拖动障碍物向上移动,玩家将继续移动。

  • 现在勾选雕刻框并尝试同样的操作。当你上下移动障碍物时,玩家将重新计算其路线,绕开障碍物的长路,实时改变航线。

发挥创意!

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

  • 扩展场景——添加更多的斜坡、楼梯、平台和障碍物。寻找使用材料的创意方式。搜索网络以找到新的纹理地图。让它看起来有趣!

  • 当玩家按住 Shift 键时使 NavMesh 代理移动更快。在脚本参考中搜索“KeyCode”以找到左/右 Shift 键的代码。

  • 你在上次实验中使用了 OnMouseDown、Rotate、RotateAround 和 Destroy。看看你能否使用它们创建旋转或在点击时消失的障碍物。

  • 我们实际上还没有一个游戏,只是一个在场景中导航的玩家。你能找到方法把你的程序变成一个计时障碍课程吗?

你已经掌握了足够的 Unity 知识来开始构建有趣的游戏——这是一个很好的练习方式,让你可以不断提高作为开发者的水平。

这是你实验的机会。发挥你的创造力是快速提升编码技能的有效方式。

可下载的练习:动物匹配老板战

图片

如果你玩过很多视频游戏(我们非常确定你玩过!),那么你一定经历过很多老板战——那些在关卡或章节结束时,你要面对比你之前见过的更大更强的对手的战斗。在本书结束前,我们为你准备了最后一个挑战——把它看作是Head First C#的老板战。

在#start_building_with_chash_build_somethin 中,你构建了一个动物匹配游戏。这是一个很好的开始,但缺少了一些东西。你能想出如何将你的动物匹配游戏变成记忆游戏吗?去我们的 GitHub 页面下载这个项目的 PDF 文件——或者如果你想在困难模式下进行这场老板战斗,就直接开始尝试看看你是否能独自完成。

图片

这里有更多的可下载材料!书籍已经结束,但我们可以继续学习。我们为重要的 C#主题准备了更多可下载材料。我们还通过额外的 Unity 实验室甚至是一个 Unity 老板战继续 Unity 学习路径。

我们希望你学到了很多——更重要的是,我们希望你的 C#学习之旅才刚刚开始。优秀的开发者永远不会停止学习。

请访问我们的 GitHub 页面获取更多信息:github.com/head-first-csharp/fourth-edition

感谢阅读我们的书!

为自己喝彩吧——这是一个真正的成就!我们希望这段旅程对你和我们一样有意义,并且希望你享受沿途编写的所有项目和代码。

但是等等,还有更多!你的旅程才刚刚开始……

在一些章节中,我们提供了一些额外的项目,你可以从我们的 GitHub 页面下载:github.com/head-first-csharp/fourth-edition

图片

检查这些优秀的 C#和.NET 资源!

连接到.NET 开发者社区:dotnet.microsoft.com/platform/community

观看直播和与构建.NET 和 C#的团队交流:dotnet.microsoft.com/platform/community/standup.

在文档中了解更多信息:docs.microsoft.com/en-us/dotnet.

GitHub 页面包含大量额外资料。仍有更多知识可以学习,更多项目可以实施!

继续你的 C#学习之旅,下载 PDF 继续Head First C#的故事,并涵盖C#的基本主题,包括:

  • 事件处理程序

  • 委托

  • MVVM 模式(包括复古街机游戏项目)

  • ......还有更多!

当你在这里时,还有更多关于 Unity 的学习。你可以下载:

  • 此书中所有 Unity 实验室的 PDF 版本

  • 还有更多的 Unity 实验室,涵盖物理学、碰撞等内容!

  • 一个Unity 实验室的老板战,以测试你的 Unity 开发技能

  • 一个完整的Unity 实验室项目,从头开始创建游戏

还可以查看这些由我们的朋友和同事撰写的基础(和令人惊叹!)书籍,这些书籍也由 O’REILLY 出版。

图片

附录 A. ASP.NET Core Blazor 项目:Visual Studio for Mac 学习指南

图像

你的 Mac 是 C# 和 .NET 世界的一流公民。

我们在编写 Head First C# 时考虑了我们的 Mac 读者,这就是为什么我们为你们专门创建了这个特别的 学习指南。本书中的大多数项目都是 .NET Core 控制台应用程序,可以在 Windows 和 Mac 上运行。一些章节有一个使用桌面 Windows 应用程序技术构建的项目。这个学习指南为所有这些项目提供了 替代方案,包括一个 完整替代 #start_building_with_chash_build_somethin,使用 C# 创建 Blazor WebAssembly 应用程序,这些应用程序在浏览器中运行,与 Windows 应用程序等效。你将使用 Visual Studio for Mac 来完成所有这些工作,这是一个编写代码的好工具,也是探索 C# 的 宝贵学习工具。让我们立即开始编码吧!

为什么你应该学习 C#

C# 是一种简单、现代的语言,让你可以做很多令人惊讶的事情。当你学习 C# 时,你不仅仅是在学习一种语言。C# 开启了 .NET 的整个世界,这是一个非常强大的开源平台,用于构建各种应用程序。

Visual Studio 是你进入 C# 的大门

如果你还没有安装 Visual Studio 2019,现在就是时候了。

前往 visualstudio.microsoft.com下载 Visual Studio for Mac。(如果已安装,请运行 Visual Studio for Mac 安装程序以更新已安装的选项。)

安装 .NET Core

一旦下载了 Visual Studio for Mac 安装程序,请运行它以安装 Visual Studio。确保已选中 .NET Core 目标。

图像

注意

确保你安装的是 Visual Studio for Mac,而不是 Visual Studio Code。

注意

Visual Studio Code 是一个令人惊叹的开源、跨平台代码编辑器,但它并不像 Visual Studio 那样专为 .NET 开发量身定制。这就是为什么在本书中我们可以使用 Visual Studio 作为学习和探索工具。

你也可以使用 Visual Studio for Windows 来构建 Blazor Web 应用程序

Head First C# 中的大多数项目都是 .NET Core 控制台应用程序,你可以使用 macOS 或 Windows 创建这些应用程序。有些章节还包括一个使用 Windows Presentation Foundation (WPF) 构建的 Windows 桌面应用项目。由于 WPF 是一种仅适用于 Windows 的技术,我们编写了这个 Visual Studio for Mac 学习指南,以便你可以使用 Web 技术—具体来说是 ASP.NET Core Blazor WebAssembly 项目—在 Mac 上创建等效的项目。

如果你是 Windows 用户,并想学习使用 Blazor 构建丰富的 Web 应用程序,那么你很幸运!你可以使用 Windows 的 Visual Studio 来完成本指南中的项目。前往 Visual Studio 安装程序,并确保选择了“ASP.NET 和 Web 开发”选项。虽然你的 IDE 截图可能与本指南中的不完全相同,但所有的代码都是一样的。

Visual Studio 是一个编写代码和探索 C#的工具

你可以使用 TextEdit 或其他文本编辑器来编写你的 C#代码,但有一个更好的选择。一个IDE——这是集成开发环境的缩写——是一个文本编辑器、视觉设计器、文件管理器、调试器……它就像一个你需要编写代码所需的多功能工具。

这些只是 Visual Studio 帮助你完成的一些事情:

  1. 快速构建应用程序。 C#语言灵活且易于学习,而 Visual Studio IDE 通过自动完成大量手动工作,使得学习变得更加容易。以下只是 Visual Studio 为你做的一些事情:

    • 管理所有你的项目文件

    • 简化编辑项目代码的过程

    • 跟踪你项目的图形、音频、图标和其他资源

    • 通过逐行调试来帮助你调试代码

      Images

  2. 编写和运行你的 C#代码。 Visual Studio IDE 是目前为止使用最简单的编写代码工具之一。微软开发团队在使你编写代码的工作尽可能简单方面投入了大量的工作。

  3. 构建视觉效果出色的 Web 应用程序。 在这本 Visual Studio for Mac 学习指南中,你将构建能在浏览器中运行的 Web 应用程序。你将使用Blazor,这是一种使用 C#构建交互式 Web 应用程序的技术。当你结合 C#与 HTML 和 CSS时,你将拥有一个强大的 Web 开发工具包。

  4. 学习和探索 C#与.NET。 Visual Studio 不仅是一个世界级的开发工具,还是一个出色的学习工具。我们将使用 IDE 来探索 C#,这将使我们更快速地掌握重要的编程概念。

    注意

    在本书中,我们经常将 Visual Studio 简称为“IDE”。

    Visual Studio 是一个令人惊叹的开发环境,但我们还将把它作为学习工具来探索 C#。

在 Visual Studio for Mac 中创建你的第一个项目

学习 C#的最佳方式是开始编写代码,因此我们将使用 Visual Studio 来创建一个新项目……并立即开始编写代码!

注意

执行此操作!

注意

当你看到“Do this!”(或“Now do this!”或“Debug this!”等),前往 Visual Studio 并跟着操作。我们会告诉你确切的操作步骤,并指出需要注意的示例中的内容,以便让你得到最大的收益。

  1. 创建一个新的控制台项目。

    启动 Visual Studio 2019 for Mac。当它启动时,会显示一个窗口,让你创建一个新项目或打开一个现有项目。点击“新建”来创建一个新项目。如果你不小心关闭了窗口,别担心——你可以通过选择文件 >> 新建解决方案...图片)菜单项来重新打开它。

    图片

    从左侧面板选择.NET,然后选择控制台项目

    图片

  2. 将项目命名为 MyFirstConsoleApp。

    在“项目名称”框中输入MyFirstConsoleApp,然后点击创建按钮来创建项目。

    图片

  3. 查看你的新应用的代码。

    当 Visual Studio 创建一个新项目时,它会为你提供一个可以构建的起点。一旦它完成创建应用程序的新文件,它会打开并显示一个名为Program.cs的文件,其中包含以下代码:

    图片

使用 Visual Studio IDE 探索你的应用程序

  1. 探索 Visual Studio IDE——以及它为你创建的文件。

    当你创建新项目时,Visual Studio 会自动为你创建几个文件,并将它们捆绑成一个解决方案。IDE 左侧的解决方案窗口显示这些文件,解决方案(MyFirstConsoleApp)位于顶部。解决方案包含一个与解决方案同名的项目

    图片

  2. 运行你的新应用。

    Visual Studio for Mac 为你创建的应用程序已经准备就绪。在 Visual Studio IDE 的顶部找到“运行”按钮(带有“播放”三角形)。点击该按钮来运行你的应用程序:

    图片

  3. 查看你程序的输出。

    当你运行程序时,终端窗口会出现在 IDE 底部,并显示程序的输出:

    图片

    学习一门语言的最佳方法是大量编写代码,所以你将在这本书中构建许多程序。其中许多将是控制台应用程序项目,所以让我们更仔细地看看你刚刚做了什么。

    终端窗口顶部显示着程序的输出:

    Hello World!
    

    点击代码中的任何位置来隐藏终端窗口。然后再次按下底部的图片按钮来重新打开它——你会看到程序的相同输出。IDE 在你的应用程序退出后会自动隐藏终端窗口。

    按下运行按钮再次运行你的程序。然后从运行菜单中选择“开始调试”,或者使用其快捷键(图片)。这是你在整本书中运行所有控制台应用程序项目的方式。

让我们来构建一个游戏!

你已经构建了你的第一个 C# 应用程序,这太棒了!既然你已经做到了,那么让我们构建一个稍微复杂一点的东西。我们将创建一个动物配对游戏,玩家会看到一个包含 16 只动物的网格,需要点击成对的动物使它们消失。

图片

你的动物配对游戏是一个 Blazor WebAssembly 应用程序

如果你只需要输入和输出文本,控制台应用程序非常适合。如果你想要一个显示在浏览器页面上的视觉应用程序,你需要使用不同的技术。这就是为什么你的动物匹配游戏将是一个Blazor WebAssembly 应用程序。Blazor 让你可以创建可以在任何现代浏览器中运行的丰富 Web 应用程序。本书的大部分章节将涉及到一个 Blazor 应用程序。这个项目的目标是向你介绍 Blazor,并为你提供构建丰富 Web 应用程序以及控制台应用程序的工具。

注意

在你的 C#学习工具箱中,构建不同类型的项目是一个重要的工具。我们在本书的 Mac 项目中选择了 Blazor,因为它为您提供了设计可以在任何现代浏览器上运行的丰富 Web 应用程序的工具。

但是 C#不仅仅适用于 Web 开发和控制台应用程序!在这本 Mac 学习指南中的每个项目都有一个相应的 Windows 项目。

你是 Windows 用户,但仍然想学习 Blazor 并使用 C#构建 Web 应用程序吗?那么你很幸运!本 Mac 学习指南中的所有项目也可以在 Visual Studio for Windows 中完成。

完成这个项目后,您将更加熟悉本书中学习和探索 C#所依赖的工具。

下面是如何构建你的游戏的方法

本章的其余部分将指导你逐步构建你的动物匹配游戏,你将在一系列单独的部分完成:

  1. 首先,您将在 Visual Studio 中创建一个新的 Blazor WebAssembly App 项目。

  2. 然后,您将布置页面,并编写 C#代码来洗牌动物。

  3. 游戏需要让用户点击一对表情符号来匹配它们。

  4. 你将编写更多的 C#代码来检测玩家何时赢得游戏。

  5. 最后,通过添加计时器使游戏更加令人兴奋。

注意

这个项目可能需要 15 分钟到一个小时不等的时间,具体取决于您打字的速度。我们在不感到赶时间的情况下学得更好,所以请给自己足够的时间。

图像

注意

请注意,书中散布的这些“游戏设计...以及更多”元素。我们将使用游戏设计原则作为学习和探索重要编程概念和想法的途径,这些概念和想法适用于任何类型的项目,而不仅仅是视频游戏。

在 Visual Studio 中创建一个 Blazor WebAssembly App

构建游戏的第一步是在 Visual Studio 中创建一个新项目。

  1. 从菜单中选择文件 >> 新建解决方案... (图像)以打开新项目窗口。这与您开始 Console App 项目的方式相同。

    图像

    在左侧的“Web 和 Console”下点击App,然后选择Blazor WebAssembly App并点击下一步

  2. IDE 将会给你一个带有选项的页面。

    图像

    将所有选项保持默认值并点击下一步

    图像

    如果您在这个项目中遇到任何问题,请访问我们的 GitHub 页面,并寻找视频教程链接:

    github.com/head-first-csharp/fourth-edition

  3. 输入 BlazorMatchGame 作为项目名称,就像您的控制台应用程序一样。

    图片

    然后单击创建以创建项目解决方案。

    图片

  4. IDE 将创建一个新的 BlazorMatchGame 项目,并显示其内容,就像您的第一个控制台应用程序一样。展开解决方案窗口中的 Pages 文件夹以查看其内容,然后双击 Index.razor 以在编辑器中打开它。

    图片

在浏览器中运行您的 Blazor Web 应用程序

运行 Blazor Web 应用程序时,有两部分:一个服务器和一个Web 应用程序。Visual Studio 通过一个按钮同时启动它们。

注意

就这样!

  1. 选择要运行您的 Web 应用程序的浏览器。

    在 Visual Studio IDE 顶部找到三角形形状的运行按钮:

    图片

    应调试>旁边列出您的默认浏览器。单击浏览器名称以查看已安装浏览器的下拉列表,并选择 Microsoft Edge 或 Google Chrome 中的任一浏览器。

  2. 运行您的 Web 应用程序。

    单击运行按钮启动您的应用程序。您也可以从运行菜单中选择开始调试 图片。IDE 首先会打开一个生成输出窗口(底部,就像打开终端窗口一样),然后是一个应用程序输出窗口。之后,它将弹出一个运行您的应用程序的浏览器。

    图片

  3. Index.razor 中的代码与浏览器中看到的内容进行比较。

    您的浏览器中的 Web 应用程序有两部分:左侧有导航菜单,其中包含指向不同页面(主页、计数器和获取数据)的链接,右侧显示一个页面。将 Index.razor 文件中的 HTML 标记与浏览器中显示的应用程序进行比较。

    图片

  4. 将“Hello, world!”更改为其他内容。

    更改 Index.razor 文件的第三行,使其显示其他内容:

    <h1>Elementary, my dear Watson.</h1>
    

    现在返回浏览器并重新加载页面。等一下,什么都没变化 - 它仍然显示“Hello, world!”这是因为您更改了代码,但您从未更新服务器。

    单击停止按钮 图片或从运行菜单中选择停止 图片。现在返回并重新加载浏览器 - 由于您已停止应用程序,它会显示“网站无法访问”的页面。

    重新启动您的应用程序,然后在浏览器中重新加载页面。现在您将看到更新后的文本。

    图片

    是否有额外的浏览器实例打开?每次运行 Blazor Web 应用程序时,Visual Studio 都会打开一个新的浏览器。养成在停止应用程序(图片)之前关闭浏览器(图片)的习惯。

图片

现在,您已准备好开始为游戏编写代码了。

你创建了一个新的应用程序,Visual Studio 为你生成了一堆文件。现在是时候添加 C#代码来让你的游戏开始运行(以及 HTML 标记来使它看起来正确)。

图片图片

你的动物配对游戏页面布局是如何工作的

你的动物配对游戏按网格布局排列——或者说看起来是这样。实际上,它由 16 个正方形按钮组成。如果你把浏览器变得非常窄,它们将重新排列成一个长列。

图片

你将通过创建一个宽度为 400 像素的容器来布置页面(当浏览器处于默认缩放时,CSS“像素”为 1/96 英寸),其中包含 100 像素宽的按钮。我们将提供所有输入到 IDE 的 C#和 HTML 代码。请注意这段代码,很快将其添加到您的项目—通过将 C#代码与 HTML 混合,实现了“魔法”:

图片

Visual Studio 帮助你编写 C#代码

Blazor 让你创建丰富、交互式的应用程序,结合了 HTML 标记和 C#代码。幸运的是,Visual Studio IDE 有很多有用的功能帮助你编写这些 C#代码。

  1. Index.razor文件添加 C#代码。

    首先Index.razor文件末尾添加一个@code 块。(暂时保留文件的现有内容—稍后将删除它们。)转到文件的最后一行,键入@code {。IDE 会为您填写右大括号}。按 Enter 在两个括号之间添加一行:

    图片

  2. 使用 IDE 的 IntelliSense 窗口帮助你编写 C#代码。

    将光标定位在{大括号之间的行上,并键入字母**L**。IDE 将弹出一个IntelliSense 窗口,显示自动完成建议。从弹出窗口中选择List<>

    图片

    IDE 将填写List。添加一个尖括号(大于号)—IDE 将自动填写闭合尖括号,并将光标定位在它们之间。

  3. 开始创建一个列表来存储你的动物表情符号。

    输入 s来显示另一个 IntelliSense 窗口:

    图片

    选择string—IDE 会在括号之间添加它。按下右箭头然后空格键,然后输入 animalEmoji = new。再次按下空格键以弹出另一个 IntelliSense 窗口。按 Enter选择选项中的默认值,List<string>

    图片

    现在你的代码应该看起来像这样:List<string> animalEmoji = new List<string>

  4. 完成创建动物表情符号的列表。

    首先Index.razor文件末尾添加一个@code。转到最后一行,键入@code {. IDE 会为您填写右大括号}。按 Enter 在两个大括号之间添加一行,然后:

    • 键入一个开括号(左圆括号)—IDE 将填写右圆括号。

    • 按右箭头移动到括号后面。

    • 输入一个 左大括号 {—IDE 将自动添加右大括号。

    • 按 Enter 在括号之间添加一行,然后在右括号后添加一个分号 ;

    您的 Index.razor 文件底部的最后六行现在应该如下所示:

    图片

  5. 使用字符查看器输入表情符号。

    接下来,选择 Edit >> Emoji & Symbols (图片 空格) 从菜单中打开 macOS 字符查看器。将光标放在引号之间,然后在字符查看器中搜索“dog”:

    图片

    您的 Index.razor 文件底部的最后六行现在应该如下所示:

    图片

完成您的表情符号列表并在应用程序中显示它

您刚刚将一只狗表情符号添加到您的 animalEmoji 列表中。现在通过在第二个引号后添加逗号、空格、另一个引号、另一只狗表情符号和逗号,再添加一个第二只狗表情符号

图片

现在在其后添加第二行,与之前完全相同,只是用一对狼表情符号替换狗表情符号。然后再添加六行,每行分别包含一对牛、狐狸、猫、狮子、老虎和仓鼠表情符号。您现在的 animalEmoji 列表中应该有八对表情符号:

图片

替换页面的内容

删除页面顶部的这些行

图片

然后将光标放在页面的第三行上,并输入 <st—IDE 将弹出 IntelliSense 窗口:

图片

从列表中选择 style,然后输入 >。IDE 将添加一个闭合的 HTML 标签<style></style>

将光标放在 <style></style> 之间,按 Enter,然后仔细输入以下所有代码。确保您的应用程序中的代码与其完全匹配。

图片

转到下一行,使用 IntelliSense 输入一个开放和闭合的

标签,就像之前使用 <style> 一样。然后仔细输入下面的代码,确保完全匹配:

图片

确保在运行应用程序时它看起来像这张屏幕截图一样。一旦看到这个界面,您就知道已经输入了所有代码而没有任何拼写错误。

将动物随机排序以创建一个新的顺序

如果动物对都排在一起,我们的匹配游戏将会太简单。让我们添加 C# 代码来打乱动物的顺序,以便每次玩家重新加载页面时它们都会以不同的顺序出现。

  1. 将光标放在底部 Index.razor 附近的右大括号 } 上方的分号 ; 后面,按 Enter 两次。然后像之前一样使用 IntelliSense 弹出窗口输入以下行代码:

        List<string> shuffledAnimals = new List<string>();
    
  2. 下一步 输入 protected override (IntelliSense 可以自动完成这些关键字)。一旦输入并键入空格,您将看到 IntelliSense 弹出窗口—从列表中选择 OnInitialized()

    图片

    IDE 将填充一个名为 OnInitialized 的方法的代码(我们将在#dive_into_chash_statementscomma_classesc 中更多地讨论方法):

    图片

  3. SetUpGame()替换base.OnInitialized(),这样你的方法看起来像这样:

        protected override void OnInitialized()
        {
            SetUpGame();
        }
    

    然后在你的 OnInitialized 方法下面添加这个 SetUpGame 方法——再次,智能感知窗口将帮助你正确地完成它:

    图片

    当你在 SetUpGame 方法中输入代码时,你会注意到 IDE 弹出许多智能感知窗口,帮助你更快地输入代码。你使用 Visual Studio 编写 C#代码的次数越多,这些窗口就会变得越有帮助——最终你会发现它们显著加快速度。现在,利用它们来避免输入拼写错误——你的代码需要与我们的代码完全匹配,否则你的应用程序将无法运行。

  4. 滚动回到 HTML 并找到这段代码:@foreach (var animal in animalEmoji)

    双击animalEmoji来选择它,然后输入 s。IDE 将弹出一个智能感知窗口。从列表中选择shuffledAnimals

    图片

    现在再次运行你的应用程序。你的动物应该被洗牌,所以它们是随机顺序的。在浏览器中重新加载页面——它们将以不同的顺序重新洗牌。每次重新加载,它都会重新洗牌动物。

    图片

    注意

    再次确保当你运行时你的应用程序看起来像这个截图。一旦它这样做了,你就会知道你输入了所有的代码而没有任何拼写错误。在你的游戏在重新加载浏览器页面时每次都在重新洗牌之前不要继续。

你正在调试你的游戏

当你点击运行按钮 图片 或选择从运行菜单中选择开始调试 图片 来启动程序运行时,你就把 Visual Studio 置于调试模式

当你看到工具栏中出现调试控件时,你可以知道你正在调试一个应用程序。开始按钮已被方形的停止按钮 图片 替换,选择要启动的浏览器的下拉菜单变灰,还出现了一组额外的控件。

将鼠标悬停在暂停执行按钮上以查看其工具提示:

图片

你可以通过点击停止按钮或从运行菜单中选择停止 图片 来停止你的应用程序。

图片

你已经为接下来要添加的部分做好了准备。

当你构建一个新游戏时,你不仅仅是在编写代码。你也在运行一个项目。一个非常有效的运行项目的方式是逐步构建它,沿途检查确保事情朝着良好的方向发展。这样你就有很多机会改变方向。

注意

这是一个纸笔练习。把所有这些练习都做完绝对值得,因为它们将帮助你更快地掌握重要的 C#概念。

Images

提高你的代码理解能力将使你成为一个更好的开发者。

铅笔和纸上的练习不是可选的。它们让你的大脑以不同的方式吸收信息。但更重要的是:它们给了你犯错的机会。犯错是学习的一部分,我们都犯过很多错误(你甚至可能在这本书中找到一两个拼写错误!)。没有人第一次写出完美的代码 —— 真正优秀的程序员总是假设他们今天写的代码可能明天就需要修改。事实上,书中的后面部分会介绍重构,即改进已编写代码的编程技术。

注意

我们将添加类似这样的项目要点,以快速总结你到目前为止看到的许多想法和工具。

将你的新项目添加到源代码控制中

在这本书中,你将建立许多不同的项目。如果有一种简单的方法可以备份它们并随时访问它们,那不是很好吗?如果你犯了错误,如果你能方便地回滚到以前的代码版本,那不是非常方便吗?好吧,你很幸运!这正是源代码控制做的事情:它为你提供了一种简单的方法来备份你的所有代码,并跟踪你所做的每一个更改。Visual Studio 让你很容易地将你的项目添加到源代码控制中。

Git是一个流行的版本控制系统,而 Visual Studio 会将你的源代码发布到任何 Git 存储库(或repo)。我们认为GitHub是使用最简单的 Git 提供者之一。你需要一个 GitHub 账号来向其推送代码,所以如果你还没有,请访问github.com并创建。

设置好你的 GitHub 账号后,你可以使用 IDE 的内置版本控制功能。从菜单中选择版本控制 >> 发布到版本控制... 来打开克隆存储库窗口:

Images

注意

Visual Studio for Mac 文档完整介绍了在 GitHub 上创建项目并从 Visual Studio 发布的步骤。它包括了为在 GitHub 上创建远程仓库和直接从 Visual Studio 向 Git 发布项目提供逐步说明。我们认为将所有Head First C#项目发布到 GitHub 是一个好主意,这样你以后可以轻松地返回到它们。docs.microsoft.com/en-us/visualstudio/mac/set-up-git-repository

Images

添加 C#代码来处理鼠标点击

你有带有随机动物表情符号的按钮。现在你需要点击它们时让它们做一些事情。操作方式如下:

Images

给你的按钮添加点击事件处理程序

当你点击一个按钮时,它需要做一些事情。在网页中,点击是一个事件。网页还有其他事件,比如当页面完成加载时,或者当输入框发生变化时。一个事件处理程序是一段在特定事件发生时执行的 C# 代码。我们将添加一个事件处理程序来实现按钮的功能。

这是事件处理程序的代码

将这段代码添加到你的 Razor 页面底部,就在底部的 **}** 上方:

图片

将你的事件处理程序连接到按钮上

现在你只需要修改按钮,使其在点击时调用 ButtonClick 方法:

图片

注意

当我们要求你在代码块中更新一个内容时,我们可能会使其余的代码变得浅一些,并使你修改的部分变为粗体。

注意

哎呀——这段代码中有一个 bug!你能找出来吗?我们将在下一节追踪并修复它。

测试你的事件处理程序

再次运行你的应用程序。当它启动时,通过单击按钮来测试你的事件处理程序,然后再单击带有匹配表情符号的按钮。它们应该都会消失。

图片

依次单击另一个,然后再依次单击另一个。你应该能够继续单击成对,直到所有按钮都变空。恭喜,你找到了所有的配对!

图片

但是如果你连续点击同一个按钮会发生什么?

在浏览器中重新加载页面以重置游戏。但这一次,而不是找到一对,连续点击两次同一个按钮。等等——游戏中有一个 bug! 它本应忽略点击,但实际上像你找到了一对一样。

图片

使用调试器来解决问题

你可能以前听过“bug”这个词。你甚至可能曾经对你的朋友说过类似的话:“那个游戏有很多 bug,有这么多故障。”每个 bug 都有一个解释——你程序中的每件事都有其原因——但不是每个 bug 都容易追踪。

理解 bug 是修复它的第一步。 幸运的是,Visual Studio 调试器是一个很好的工具。(这就是为什么它被称为调试器:它是帮助你消除 bug 的工具!)

  1. 考虑一下出了什么问题。

    首先要注意的是,你的 bug 是可复现的:每次你连续单击相同的按钮两次时,它总是像你点击了一个匹配的对。

    第二件需要注意的事情是,你对 bug 的位置有一个相当好的想法。问题只发生在你添加了处理 Click 事件的代码之后,所以那是一个很好的起点。

  2. 为你刚刚编写的 Click 事件处理程序的代码添加断点。

    点击 ButtonClick 方法的第一行,然后从菜单中选择 Run >> Toggle Breakpoint (图片)。该行将改变颜色,并在左边缘看到一个点:

    图片

继续调试你的事件处理程序

现在你的断点已经设置好了,使用它来了解代码的运行情况。

  1. 点击一个动物以触发断点。

    如果你的应用程序已经在运行,请停止它并关闭所有浏览器窗口。然后再次运行你的应用程序点击任何一个动物按钮。Visual Studio 应该弹出到前景。你切换了断点的行应该现在以不同的颜色高亮显示:

    图片

    将鼠标移动到方法的第一行,该行以private void开头,并将光标悬停在 animal 上。将弹出一个小窗口,显示你点击的动物:

    图片

    按下Step Over按钮或选择 Run >> Step Over (图片)菜单。高亮将移动到**{**行。再次跨过以将高亮移至下一个语句:

    图片

    再次跨过一次以执行该语句,然后悬停在lastAnimalFound上:

    图片

    你刚刚跨过的声明将lastAnimalFound的值设为与animal相匹配。

    这就是代码如何跟踪玩家点击的第一个动物。

  2. 继续执行。

    按下Continue Execution按钮或选择 Run >> Continue Debugging (图片)菜单。切换回浏览器 - 你的游戏将继续进行直到再次触发断点。

  3. 点击配对中的相匹配动物。

    找到具有匹配表情符号的按钮并点击它。IDE 将触发断点并再次暂停应用程序。按下Step Over - 它会跳过第一个块并跳转到第二个:

    图片

    悬停在lastAnimalFoundanimal — 它们应该都有相同的表情符号。这就是事件处理程序知道你找到匹配项的方式。再跨过三次

    图片

    现在悬停在shuffledAnimals。你会看到弹出窗口中有几个项目。点击shuffledAnimals旁边的三角形以展开它,然后展开 _items 以查看所有动物:

    图片

    再次按下Step Over以执行从列表中移除匹配项的语句。然后再次悬停在 **shuffledAnimals** 并查看它的项目。现在匹配表情符号的位置有两个(null)值:

    图片

    我们已经筛选了大量证据并收集了一些重要线索。你认为问题的根源是什么?

追踪引起问题的错误...

那么如果你两次点击相同的动物按钮会发生什么呢?让我们找出来!重复刚才做过的步骤,但这次两次点击相同的动物。观察当你到达步骤图片时发生了什么。

悬停在animallastAnimalFound上,就像之前一样。它们是相同的!这是因为事件处理程序没有办法区分不同按钮上相同的动物

...并修复错误!

现在我们知道是什么导致了这个错误,我们知道如何修复它:给事件处理程序一种区分两个具有相同表情符号按钮的方法。

首先,对 ButtonClick 事件处理程序进行这些更改(确保不会漏掉任何更改):

图片

然后用另一种循环替换foreach 循环,即for循环——这个 for 循环计算动物的数量:

图片

现在再次通过应用程序进行调试,就像之前一样。这次当你两次点击相同的动物时,它会跳到事件处理程序的末尾。错误已修复!

图片

当玩家赢得比赛时,添加重置游戏的代码

游戏进行得很顺利——你的玩家从一个充满动物的网格开始匹配,他们可以点击成对的动物,当它们匹配时它们会消失。但是当所有匹配项都被找到时会发生什么?我们需要一种重置游戏的方式,让玩家有第二次机会。

图片

注意

当你看到脑力元素时,花一分钟时间真正思考它问的问题。

图片

通过添加计时器完成游戏

如果玩家可以尝试击败他们的最佳时间,你的动物匹配游戏将会更加令人兴奋。我们将添加一个计时器,通过重复调用方法在固定间隔后“滴答”。

图片图片

注意

计时器通过反复调用方法以固定间隔“滴答”。你将使用一个计时器,当玩家开始游戏时启动,最后一个动物匹配时结束。

在你游戏的代码中添加一个计时器

添加这个!

  1. 首先找到Index.razor文件的顶部这一行:@page "/"

    在下面添加这行代码 —— 你需要它来在你的 C#代码中使用计时器:

    @using System.Timers
    
  2. 你需要更新 HTML 标记以显示时间。将其添加到练习中第一个添加的代码块的下面:

     </div>
     <div class="row">
     <h2>Matches found: @matchesFound</h2>
     </div>
        <div class="row">
            <h2>Time: @timeDisplay</h2>
        </div>
    </div>
    
  3. 你的页面需要一个计时器。它还需要跟踪经过的时间:

    List<string> shuffledAnimals = new List<string>();
    int matchesFound = 0;
    Timer timer;
    int tenthsOfSecondsElapsed = 0;
    string timeDisplay;
    
  4. 你需要告诉计时器每隔多久“滴答”一次以及调用什么方法。你将在 OnInitialized 方法中做这些,这个方法在页面加载后调用一次:

    protected override void OnInitialized()
    {
        timer = new Timer(100);
        timer.Elapsed += Timer_Tick;
    
     SetUpGame();
    }
    
  5. 当你设置游戏时重置计时器:

    private void SetUpGame()
    {
     Random random = new Random();
     shuffledAnimals = animalEmoji
     .OrderBy(item => random.Next())
     .ToList();
     matchesFound = 0;
        tenthsOfSecondsElapsed = 0;
    }
    
  6. 你需要停止并重新启动计时器。在 ButtonClick 方法的顶部附近添加这行代码来在玩家点击第一个按钮时启动计时器:

    if (lastAnimalFound == string.Empty)
    {
     // First selection of the pair. Remember it.
     lastAnimalFound = animal;
     lastDescription = animalDescription;
    
        timer.Start();
    }
    

    最后,在 ButtonClick 方法的更深处添加这两行代码来停止计时器,并在玩家找到最后一对匹配后显示“再玩一次?”消息:

    matchesFound++;
    if (matchesFound == 8)
    {
        timer.Stop();
        timeDisplay += " - Play Again?";
    
     SetUpGame();
    }
    
  7. 最后,你的计时器需要知道每次滴答时该做什么。就像按钮有 Click 事件处理程序一样,计时器有 Tick 事件处理程序:每次计时器滴答时执行的方法。

    将此代码添加到页面的最底部,就在闭合大括号 } 的上方:

    private void Timer_Tick(Object source, ElapsedEventArgs e)
    {
        InvokeAsync(() =>
        {
            tenthsOfSecondsElapsed++;
            timeDisplay = (tenthsOfSecondsElapsed / 10F)
                .ToString("0.0s");
            StateHasChanged();
        });
    }
    
注意

当玩家点击第一个动物时计时器开始计时,并且当找到最后一对匹配时停止。这并不会从根本上改变游戏的运行方式,但会让游戏更加令人兴奋。

清理导航菜单

你的游戏正在运行!但是你是否注意到你的应用中还有其他页面?尝试在左侧导航菜单中点击“Counter”或“Fetch data”。在创建 Blazor WebAssembly 应用程序项目时,Visual Studio 添加了这些额外的示例页面。你可以安全地将它们移除。

首先,展开wwwroot 文件夹并编辑index.html。找到以<title>开头的行并修改它,使其看起来像这样:<title> 动物配对游戏 </title>

接下来,展开解决方案中的Shared 文件夹,并双击 NavMenu.razor。找到这一行:

 <a class="navbar-brand" href="">BlazorMatchGame</a> 

用这个替换它:

 <a class="navbar-brand" href="">Animal Matching Game</a>

然后删除这些行:

Images

最后,按住 Images(Command 键)并点击以多选这些文件在解决方案窗口中:在 Pages 文件夹中的Counter.razorFetchData.razor,在 Shared 文件夹中的SurveyPrompt.razor,以及在 wwwroot 文件夹中的整个 sample-data文件夹。一旦它们全部选择完毕,右键单击其中一个并从菜单中选择删除Images图标)来删除它们。

现在你的游戏完成了!

Images

每当你有一个大项目时,将其拆分成小部分总是一个好主意。

你可以培养的最有用的编程技能之一是能够看待一个庞大而困难的问题,并将其分解成更小、更容易解决的问题。

在开始一个大项目时很容易感到不知所措,然后想,“哇,这太大了!”但如果你能找到一个小部分可以着手,然后你就可以开始了。完成了那一部分后,你可以继续下一个小部分,然后下一个,再下一个。随着每个部分的建设,你会在途中了解更多关于你的大项目的信息。

更好的建议...

你的游戏做得相当不错!但每个游戏——事实上,几乎每个程序——都可以改进。以下是我们考虑到的一些可以让游戏变得更好的建议:

  • 添加不同种类的动物,这样每次不会出现相同的动物。

  • 记录玩家的最佳时间,这样他们可以尝试超越它。

  • 让计时器倒计时而不是计时上升,这样玩家就有限定的时间了。

注意

我们是认真的——花几分钟时间去做这件事。退后一步,思考一下你刚刚完成的项目,这是将学到的经验融入你的大脑的好方法。

Images

来自#dive_into_chash_statementscomma_classesc 深入学习 C#

注意

这是#dive_into_chash_statementscomma_classesc 中 Windows 桌面项目的 Blazor 版本。

注意

#dive_into_chash_statementscomma_classesc 的最后一部分是一个 Windows 项目,用于尝试不同类型的控件。我们将使用 Blazor 来构建一个类似的项目,以尝试 Web 控件。

控件驱动用户界面的机制

在上一章中,你使用了按钮控件来制作了一个游戏。但是有很多不同的方式可以使用控件,而你选择使用哪些控件会真正改变你的应用。听起来很奇怪吗?其实这与我们在游戏设计中做选择的方式非常相似。如果你设计一个需要随机数生成器的桌面游戏,你可以选择使用骰子、旋转器或者卡片。如果你设计一个平台游戏,你可以选择让你的玩家跳跃、双重跳跃、墙壁跳跃或者飞行(或者在不同时间做不同的事情)。对应用程序也是一样的:如果你设计一个用户需要输入数字的应用程序,你可以选择不同的控件让他们这样做 —— 而这个选择会影响用户体验应用程序的方式

图片

  • 文本框 允许用户输入任何文本。但我们需要一种方法来确保他们只输入数字,而不是任意文本。

    图片

  • 滑块 专门用于选择数字。电话号码也只是数字,所以从技术上讲,你可以使用滑块来选择电话号码。你认为这是一个好的选择吗?

    图片

  • 选择器 是专门设计用来从列表中选择特定类型值的控件。例如,日期选择器 允许你通过选择年、月和日来指定日期,而 颜色选择器 则允许你使用色谱滑块或其数值来选择颜色。

    图片

  • 单选按钮 允许你限制用户的选择。它们通常看起来像有点的圆圈,但你也可以将它们样式化成普通按钮的样子。

    图片图片

创建一个新的 Blazor WebAssembly 应用程序项目

在此Visual Studio for Mac 学习指南 的前面,你为你的动物匹配游戏创建了一个 Blazor WebAssembly 应用程序项目。你也将为这个项目做同样的事情。

这里是创建 Blazor WebAssembly 应用程序项目、更改主页标题文本和删除 Visual Studio 创建的额外文件的简洁步骤集。我们不会在本指南的每个附加项目中重复这些步骤 —— 你应该能够对所有未来的 Blazor WebAssembly 应用程序项目都使用相同的指令。

  1. 创建一个新的 Blazor WebAssembly 应用程序项目。

    要么启动 Visual Studio 2019 for Mac,或者从菜单中选择 File >> New Solution...图片)来打开新项目窗口。点击 New 创建一个新项目。将其命名为 ExperimentWithControlsBlazor

  2. 更改标题和导航菜单。

    在动物匹配游戏项目的结尾,您修改了标题和导航栏文本。对于此项目,也要执行相同的操作。展开 wwwroot 文件夹 并编辑 Index.html。找到以 <title> 开头的行,并修改它,使其看起来像这样:<title> **Experiment with Controls** </title>

    在解决方案中扩展 Shared 文件夹双击 NavMenu.razor 找到这一行:

       <a class="navbar-brand" href="">ExperimentWithControlsBlazor</a>
    

    用此代码替换它

       <a class="navbar-brand" href="">Experiment With Controls</a>
    
  3. 删除额外的导航菜单选项及其对应的文件。

    这就像您在动物匹配游戏项目的结尾所做的那样。双击 NavMenu.razor 并删除这些行:

    图片

    然后按住 图片(Command 键),并单击以多选这些文件在解决方案窗口中:在 Pages 文件夹中的 Counter.razorFetchData.razor,在 Shared 文件夹中的 SurveyPrompt.razor,以及 wwwroot 文件夹中的 entire sample-data 文件夹。一旦它们都被选中,右键单击其中一个文件,然后从菜单中选择 Delete图片 )来删除它们。

创建一个带有滑块控件的页面

您的许多程序都需要用户输入数字,而输入数字的最基本控件之一是滑块,也称为范围输入。让我们创建一个新的 Razor 页面,使用滑块来更新一个值。

  1. 替换 Index.razor 页面。

    打开 Index.razor用此 HTML 标记替换其所有内容:

    图片

  2. 运行您的应用程序。

    运行您的应用程序,就像您在 #start_building_with_chash_build_somethin 中所做的那样。将 HTML 标记与浏览器中显示的页面进行比较 - 将各个 <div> 块与页面上显示的内容匹配起来。

    图片

  3. 将 C# 代码添加到您的页面中。

    返回到 Index.razor 并在文件底部添加此 C# 代码

    图片

  4. 将您的范围控件连接到刚刚添加的 Change 事件处理程序。

    为您的范围控件添加一个 @onchange 属性:

    图片

向您的应用程序添加一个文本输入

该项目的目标是尝试不同类型的控件,因此让我们添加一个文本输入控件,使用户可以在应用程序中输入文本并在页面底部显示。

  1. 向您页面的 HTML 标记中添加一个文本输入控件。

    添加一个 **<input ... />** 标签,几乎与您为滑块添加的标签相同。唯一的区别在于,您将 type 属性设置为 "text" 而不是 "range"。以下是 HTML 标记:

    图片

    再次运行你的应用程序 —— 现在它有一个文本输入控件。无论你输入什么文本,它都会显示在页面底部。尝试修改文本,然后移动滑块,然后再次修改文本。每次修改控件时,页面底部的值都会更改。

    图像

  2. 添加一个仅接受数值的事件处理方法。

    如果你只想从用户那里接受数值输入,那该怎么办?在 Razor 页面底部的大括号之间添加此方法:

    图像

  3. 更改文本输入框以使用新的事件处理程序方法。

    修改你的文本控件的 @onchange 属性,以调用新的事件处理程序:

     <input type="text" placeholder="Enter text"
     @onchange="UpdateNumericValue" />
    

    现在尝试将文本输入到文本输入框中 —— 除非你输入的文本是整数值,否则它不会更新页面底部的值。

图像

为你的应用添加颜色选择器和日期选择器

选择器只是不同类型的输入。日期选择器的输入类型是 "date"颜色选择器的输入类型是 "color" — 除此之外,这些输入类型的 HTML 标记是相同的。

修改你的应用程序,添加一个日期选择器和一个颜色选择器。这是 HTML 标记 — 将它添加到包含显示值的 <div> 标记的上方:

图像图像

注意

这就是项目的结尾 — 做得很棒!你可以在结尾处继续学习 #dive_into_chash_statementscomma_classesc,在那里有一个坐在椅子上思考的人,他在想:用户有很多不同的选择数字的方式!

来自 #objectshellipget_orientedexclamation_mar 的对象…… 以获取方向!

注意

这是 Windows 桌面项目在 #objectshellipget_orientedexclamation_mar 的 Blazor 版本。

注意

在 #objectshellipget_orientedexclamation_mar 的中途,有一个项目,你将构建一个 Windows 版本的卡片选择器应用程序。我们将使用 Blazor 来构建同样功能的基于 Web 的版本。

接下来:构建你的卡片选择应用的 Blazor 版本

在接下来的项目中,你将构建一个名为 PickACardBlazor 的 Blazor 应用程序。它将使用滑块来让你选择随机抽取的卡片数量,并在列表中显示这些卡片。以下是它的外观:

图像

在一个新的 Blazor 应用中重用你的 CardPicker 类

重用这个!

如果你已经为一个程序编写了一个类,你通常会希望在另一个程序中使用相同的行为。这就是使用类的一个重要优势 — 它们使得代码重用更加容易。让我们为你的卡片选择器应用程序设计一个闪亮的新用户界面,但通过重用你的 CardPicker 类来保持相同的行为。

  1. 创建一个名为 PickACardBlazor 的新 Blazor WebAssembly 应用项目。

    你将按照创建动物匹配游戏中使用的完全相同的步骤来创建你的应用程序#start_building_with_chash_build_somethin:

    • 打开 Visual Studio 并创建一个新项目。

    • 选择Blazor WebAssembly App,就像你之前在其他 Blazor 应用程序中做的那样。

    • 给你的新应用程序取名为PickACardBlazor。Visual Studio 将创建该项目。

  2. 添加 CardPicker 类,该类是你为控制台应用程序项目创建的。

    右键单击项目名称,然后从菜单中选择添加 >> 现有文件...

    图片

    转到包含你的控制台应用程序的文件夹,然后单击CardPicker.cs将其添加到你的项目中。Visual Studio 会询问你是否要复制、移动或链接文件。告诉 Visual Studio复制文件。你的项目现在应该有来自控制台应用程序的CardPicker.cs文件的副本。

  3. 更改 CardPicker 类的命名空间。

    双击CardPicker.cs在解决方案窗口中。它仍然具有来自控制台应用程序的命名空间。更改命名空间以匹配你的项目名称:

    图片

    恭喜,你已经重用了你的 CardPicker 类! 你应该在解决方案窗口中看到这个类,并且可以在你的 Blazor 应用程序的代码中使用它。

页面使用行和列布局。

在#start_building_with_chash_build_somethin 和#dive_into_chash_statementscomma_classesc 中使用 HTML 标记来创建行和列,而这个新应用程序也是如此。下面是显示应用程序布局的图片:

图片

滑块使用数据绑定来更新一个变量。

页面底部的代码将从一个名为numberOfCards的变量开始:

@code {
    int numberOfCards = 5;

可以使用事件处理程序来更新numberOfCards,但是 Blazor 有更好的方法:数据绑定,它允许你设置输入控件以自动更新你的 C#代码,并且可以自动将你的 C#代码的值插入页面中。

下面是页眉、范围输入和显示其值的文本的 HTML 标记:

图片

仔细查看<input>标签的属性。minmax属性限制输入值为 1 到 15 之间。**@bind**属性设置数据绑定,因此每当滑块更改时,Blazor 会自动更新numberOfCards

<input>标签后面是<div class="col-2">**@numberOfCards</div>**—这段标记添加了文本(ml-2在左边距添加了空间)。这也使用数据绑定,但是反向操作:每当numberOfCards字段更新时,Blazor 会自动更新该<div>标签内的文本。

注意

这就是项目的结束——干得好!你可以回到#objectshellipget_orientedexclamation_mar 并在标题为“Ana's prototypes look great...”的部分继续。

来自#types_and_references_getting_the_referen 类型和引用

注意

这是 Windows 桌面项目的 Blazor 版本,位于#types_and_references_getting_the_referen。

在#types_and_references_getting_the_referen 的结尾有一个 Windows 项目。我们将构建其 Blazor 版本。

欢迎来到 Sloppy Joe's Budget House o' Discount Sandwiches!

Sloppy Joe 有一堆肉,一大堆面包,比你能想象的调味品还多。但他没有菜单!你能建立一个每天为他制作新的随机菜单的程序吗?你绝对可以……通过一个新的 Blazor WebAssembly 应用程序项目,一些数组和一些有用的新技术。

做这个!

图片

  1. 向你的项目添加一个新的 MenuItem 类,并添加其字段。

    看一看类图。它有六个字段:一个 Random 实例,三个数组来保存各种三明治部件,以及用于保存描述和价格的字符串字段。数组字段使用集合初始化器,让你可以通过将项目放在大括号内来定义数组中的项目。

    class MenuItem
    {
        public Random Randomizer = new Random();
        public string[] Proteins = { "Roast beef", "Salami", "Turkey",
                "Ham", "Pastrami", "Tofu" };
        public string[] Condiments = { "yellow mustard", "brown mustard",
               "honey mustard", "mayo", "relish", "french dressing" };
        public string[] Breads = { "rye", "white", "wheat", "pumpernickel", "a roll" };
    
        public string Description = "";
        public string Price;
    }
    
  2. 将 Generate 方法添加到 MenuItem 类中。

    此方法使用了你多次看到的 Random.Next 方法,从 Proteins、Condiments 和 Breads 字段的数组中随机选择项目,并将它们连接成一个字符串。

        public void Generate()
        {
            string randomProtein = Proteins[Randomizer.Next(Proteins.Length)];
            string randomCondiment = Condiments[Randomizer.Next(Condiments.Length)];
            string randomBread = Breads[Randomizer.Next(Breads.Length)];
            Description = randomProtein + " with " + randomCondiment + " on " + randomBread;
    
            decimal bucks = Randomizer.Next(2, 5);
            decimal cents = Randomizer.Next(1, 98); 
            decimal price = bucks + (cents * .01M);
            Price = price.ToString("c");
        }
    
    注意

    Generate 方法通过将两个随机整数转换为小数来生成介于 2.01 到 5.97 之间的随机价格。仔细看最后一行——它返回price.ToString("c")。ToString 方法的参数是一个格式。在这种情况下,"c"格式告诉 ToString 使用本地货币格式化值:如果你在美国,你会看到$;在英国,你会看到£;在欧盟,你会看到€等等。

  3. 将页面布局添加到你的 Index.razor 文件中。

    菜单页面由一系列 Bootstrap 行组成,每个菜单项一个。每行有两列,col-9显示菜单项描述,col-3显示价格。页面底部有最后一行,col-6居中显示鳄梨酱。

    图片

注意

项目结束了!在#types_and_references_getting_the_referen 的最后的项目符号处继续。

附录 B. 高级和/或急躁者的代码卡塔指南

祝贺您通过《Head First C#》的快速路径。如果您已经按照代码卡塔指南完成了#inheritance_your_objectapostrophes_famil,那么您应该已经完全准备好并且已经加速到了#interfacescomma_castingcomma_and_quotati。

posted @ 2025-11-20 09:31  绝不原创的飞龙  阅读(16)  评论(0)    收藏  举报