精通-Swift5-第六版-全-
精通 Swift5 第六版(全)
原文:
zh.annas-archive.org/md5/8af3fd3f068b269fb7e2299221cde6fd译者:飞龙
前言
Swift 是一种通用编程语言,由苹果公司开发,采用了非常现代的开发方法。它首次在 2014 年的全球开发者大会(WWDC)上推出,现在,六年后的 Swift 5.3 已经发布。
在过去的六年中,Swift 经历了许多迭代,每次迭代都为语言带来了各种增强和改进。Swift 的最近几个版本也不例外,它们引入了新的增强功能,如合成的成员初始化器和属性包装器。
这本书将帮助任何人掌握 Swift 编程语言,因为它假设你对这种语言没有任何先前的知识。我们将从语言的最基本部分开始,逐渐深入到更高级的主题,例如如何向应用程序添加并发,如何向自定义值类型添加写时复制,以及如何使用 Swift 的各种设计模式。
这本书面向的对象
这本书是为想要深入了解 Swift 最新版本的开发者而写的。如果你是一个通过查看和编写代码来学习的开发者,那么这本书适合你。
这本书涵盖的内容
第一章,用 Swift 迈出第一步,将向您介绍 Swift 编程语言,并讨论是什么启发了苹果公司创造 Swift。我们还将介绍 Swift 的基本语法以及如何使用 playgrounds 进行实验和测试 Swift 代码。
第二章,Swift 文档和安装 Swift,将向您介绍swift.org和swiftdoc.org网站,并讨论 Swift 的开发过程。我们将详细介绍从源代码构建 Swift 并在 Linux 和 Mac 平台上安装它的完整过程。
第三章,了解变量、常量、字符串和运算符,将介绍 Swift 中的变量和常量,并解释何时使用它们。将简要概述最常见的变量类型,并举例说明如何使用它们。我们将以一些 Swift 语言中最常见运算符的示例来结束这一章。
第四章,可选类型,将解释可选类型到底是什么,以及各种解包它们的方法。对于刚开始学习 Swift 的开发者来说,可选类型可能是更难理解的概念之一。
第五章,使用 Swift 集合,将解释 Swift 的数组、集合和字典集合类型,并展示如何使用它们的示例。
第六章,控制流,将向您展示如何使用 Swift 的控制流语句。这包括循环、条件和控制转移语句。
第七章,函数,主要介绍 Swift 中的函数。我们将展示如何定义和正确使用它们。
第八章,类、结构和协议,将专注于 Swift 的类、结构和协议。我们将探讨它们之间的相似之处以及它们的不同之处。
第九章,协议和协议扩展,将详细涵盖协议和协议扩展。协议对于 Swift 语言非常重要,对其有坚实的理解将帮助我们编写灵活且可重用的代码。
第十章,基于协议的设计,将涵盖使用 Swift 进行协议导向设计的最佳实践。
第十一章,泛型,将解释 Swift 如何实现泛型。泛型允许我们编写灵活且可重用的代码,从而避免代码重复。
第十二章,错误处理和可用性,将深入探讨错误处理以及可用性功能。
第十三章,自定义下标,将讨论我们如何在类、结构和枚举中使用自定义下标。
第十四章,使用闭包,将展示如何在我们的代码中定义和使用闭包。本章最后将讨论如何避免闭包中的强引用循环。
第十五章,高级和自定义运算符,将展示如何使用位运算符和溢出运算符。我们还将探讨如何创建自定义运算符。
第十六章,Swift 中的并发和并行处理,将展示如何使用 Grand Central Dispatch 和操作队列来为我们的应用程序添加并发和并行处理。
第十七章,自定义值类型,将涵盖一些您可以在应用程序中使用的高级技术,例如写时复制和实现 Equatable 协议。
第十八章,内存管理,将涵盖诸如自动引用计数(ARC)如何工作、与引用类型相比值类型有多快、强引用循环如何工作以及弱引用和强引用如何比较等主题。
第十九章,Swift 格式化和风格指南,将为 Swift 语言定义一个风格指南,可以作为企业开发者创建自己的风格指南的模板.。
第二十章,在 Swift 中使用设计模式,将向您展示如何在 Swift 中实现一些更常见的设计模式。设计模式识别一个常见的软件开发问题,并提供了一种处理它的策略。
为了充分利用这本书
本书假设您对 Swift 编程语言或任何其他语言没有任何了解。所有代码示例都已使用 Xcode 12.01 在 Mac 上测试过,然而它们也应该可以在 Linux 或 Windows 上使用 Swift 运行。
下载示例代码文件
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Swift-5.3_Sixth-Edition。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781800562158_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如;“一旦检索到成绩,我们将使用它来设置MyValueType实例的 grade 属性。”
代码块按照以下方式设置:
protocol Occupation {
var occupationName: String { get set }
var yearlySalary:Double { get set }
var experienceYears: Double { get set }
}
任何命令行输入或输出都按照以下方式编写:
./swift/utils/build-script --preset=buildbot_swiftpm_linux_platform,tools=RA,stdlib=RA
粗体:表示新术语、重要单词或您在屏幕上看到的单词,例如在菜单或对话框中,也以这种方式出现在文本中。例如:“Swift 是一种由苹果公司在 2014 年的全球开发者大会(WWDC)上推出的编程语言。”
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请通过电子邮件feedback@packtpub.com发送,并在邮件主题中提及书籍的标题。如果您对本书的任何方面有疑问,请通过电子邮件questions@packtpub.com联系我们。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何形式的非法副本,我们将非常感谢您提供位置地址或网站名称。请通过电子邮件copyright@packtpub.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为什么不在这本书购买的网站上留下评论呢?潜在读者可以看到并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问packtpub.com。
第一章:Swift 入门第一步
自从我在 12 岁时用 BASIC 编写了我的第一个程序以来,我就对编程充满热情。即使我成为了一名专业程序员,编程对我来说更多的是一种热情而非工作,但在 Swift 首次发布之前的那几年里,这种热情已经减弱。我不确定为什么我会失去这种热情。我试图通过一些我的副项目来重新找回它,但没有什么真正能让我找回曾经拥有的那种兴奋感。然后,发生了令人惊讶的事情:苹果公司在 2014 年宣布了 Swift。Swift 是一种如此令人兴奋和进步的语言,它让我重新找回了很多那种热情,并让编程再次变得有趣。随着 Swift 的官方版本可用于 Linux 和 Windows 平台,以及 ARM 平台的不官方版本,使用 Swift 进行开发正在变得对苹果生态系统之外的人开放。还有一些非常令人兴奋的项目使用 Swift,例如用于机器学习的 TensorFlow 和用于 IBM Watson 的 CoreML。这是一个学习 Swift 语言的好时机。
在本章中,你将学习以下主题:
-
什么是 Swift?
-
Swift 有哪些特性?
-
什么是 playgrounds?
-
如何使用 playgrounds
-
Swift 语言的基本语法是什么?
什么是 Swift?
Swift 是一种由苹果公司在 2014 年的全球开发者大会(WWDC)上推出的编程语言。Swift 可以说是 2014 年 WWDC 上最重要的公告,包括苹果内部人士在内的很少有人在此之前知道这个项目的存在。
即使按照苹果的标准,他们能够将 Swift 保密这么长时间,而且没有人怀疑他们将要宣布一个新的开发语言,这仍然令人惊讶。在 2015 年 WWDC 上,苹果公司再次引起了轰动,当他们宣布 Swift 2 时。Swift 2 是对 Swift 语言的重大增强。在这次会议上,Chris Lattner 表示,许多增强都是基于苹果从开发社区直接收到的反馈。还宣布 Swift 将成为一个开源项目。在我看来,这是 2015 年 WWDC 上最令人兴奋的公告。
在 2015 年 12 月,苹果公司正式将 Swift 语言以开源的形式发布,网址为swift.org/site,该网站致力于开源 Swift 社区。Swift 的代码库位于苹果公司的 GitHub 页面(github.com/apple)上。Swift 进化代码库(github.com/apple/swift-evolution)通过记录提议的变更来追踪 Swift 的进化。可以在这个进化代码库中找到哪些提议被接受和拒绝的列表。除此之外,苹果公司已经不再使用邮件列表作为与 Swift 社区的主要沟通方式,并建立了 Swift 论坛(forums.swift.org)。
2016 年发布的 Swift 3 是对 Swift 语言的重大增强,它不与 Swift 语言的先前版本保持源代码兼容性。它包含了语言本身和 Swift 标准库的根本性变化。Swift 3 的主要目标之一是在所有平台上保持源代码兼容性,这意味着为某一平台编写的代码应该与其他所有平台兼容。这意味着我们为 macOS 开发的代码应该在 Linux 上运行。
2017 年 9 月,发布了 Swift 4。Swift 4 编译器的主要目标之一是与 Swift 3 保持源代码兼容性。这使得我们能够使用 Swift 4 编译器编译 Swift 3 和 Swift 4 的项目。苹果公司建立了一个社区拥有的源代码兼容性测试套件,该套件将用于回归测试编译器的变更。
被添加到测试套件中的项目将定期与 Swift 的最新开发版本进行构建,以帮助我们了解对 Swift 所做的变更的影响。您可以在以下页面找到 Swift 源代码兼容性页面:swift.org/source-compatibility/。
Swift 4 的原始目标之一是稳定 Swift 的应用程序二进制接口(ABI)。稳定 ABI 的主要好处是允许我们在多个 Swift 版本之间以二进制格式分发框架。如果有一个稳定的 ABI,我们可以使用 Swift 4 编译器构建一个框架,并且它能够与使用未来版本 Swift 编写的应用程序一起工作。这个特性最终被推迟到了 Swift 5。
随着 Swift 5 的发布,ABI 已被宣布对所有苹果平台稳定。您可以在以下链接中阅读 Swift 的 ABI 稳定性宣言:github.com/apple/swift/blob/master/docs/ABIStabilityManifesto.md。随着 Swift 在其他平台,如 Linux 上的开发逐渐成熟,Swift 核心团队表示,他们还将评估稳定这些平台的 ABI。稳定的 ABI 意味着为 Swift 的某个版本编译的库——比如说 Swift 5——理论上可以与未来的 Swift 版本一起工作,而无需重新编译。
自 Swift 5 发布以来,苹果已经发布了三个额外的版本:5.1、5.2 和 5.3。这些版本都对 Swift 进行了补充或改进。在这本书的整个过程中,我们将看到其中的一些变化,并展示您如何使用它们。然而,其中一个最令人兴奋的变化将不会展示,因为我们没有实际展示它的方法。这个变化发生在 Swift 5.1 时,Swift 社区实现了语言服务器协议(LSP)。
LSP 允许代码编辑器和 IDE 标准化对语言的支持。在 LSP 之前,当编辑器或 IDE 想要支持某种特定语言时,这种支持必须集成到工具中。有了 LSP,语言本身提供了这种功能,因此任何支持 LSP 的编辑器或 IDE 现在都可以支持 Swift,具有语法高亮、自动完成和工具提示等功能。这使得任何支持 LSP 的编辑器,如 VSCode,都可以支持 Swift。如果你曾经尝试在 vi 中编写 Swift 应用程序,这将是一个令人兴奋的消息。
随着 Swift 5.3 的发布,最令人兴奋的事情之一是发布了官方支持的 Windows 10 版本的 Swift。这是因为我们现在能够使用我们的 Swift 知识在 Windows 平台上进行开发。Windows 版本由 Swift Windows 平台支持者 Saleem Abdulrasool 提供。
Swift 的开发始于 2010 年,由克里斯·拉特纳(Chris Lattner)发起。当只有少数人知道其存在时,他就实现了大部分基本语言结构。直到 2011 年晚些时候,其他开发者才开始为 Swift 做贡献。2013 年 7 月,它成为苹果开发者工具组的主要关注点。
克里斯于 2005 年夏天开始在苹果公司工作。他在开发者工具组内担任了多个职位,并在 2017 年离开苹果公司时是该组的总监和架构师。在他的主页([www.nondot.org/sabre/](http://www.nondot.org/sabre/))上,他提到 Xcode 的 playground(我们将在本章稍后更详细地讨论 playground)成为他个人的热情所在,因为它使编程更加互动和易于接近。如果您在苹果平台上使用 Swift,您将大量使用 playground 作为测试和实验平台。您还可以在 iPad 上使用 Swift Playgrounds。
Swift 和 Objective-C 之间有很多相似之处。Swift 采用了 Objective-C 的命名参数的可读性和动态对象模型。当我们说 Swift 具有动态对象模型时,我们指的是类型在运行时可以改变的能力。这包括添加新的(自定义)类型和修改/扩展现有类型。
虽然 Swift 和 Objective-C 之间有很多相似之处,但它们之间也存在一些显著的不同。Swift 的语法和格式与 Python 比起 Objective-C 来说更接近,但 Apple 仍然保留了花括号。我知道 Python 的人会不同意我,这很正常,因为我们都持有不同的观点,但我喜欢花括号。实际上,Swift 要求在控制语句(如 if 和 while)中使用花括号,这消除了诸如 Apple SSL 库中的 goto fail 这样的错误。
Swift 功能
当 Apple 首次推出 Swift 时,它说“Swift 是没有 C 的 Objective-C”。这实际上只告诉我们故事的一半。Objective-C 是 C 的超集,为 C 语言提供了面向对象的能力和动态运行时。这意味着使用 Objective-C,Apple 需要维护与 C 的兼容性,这限制了它对 Objective-C 语言的增强。例如,Apple 不能改变 switch 语句的功能,同时仍然保持与 C 语言的兼容性。
由于 Swift 不需要像 Objective-C 那样维护相同的 C 兼容性,因此 Apple 可以自由地向语言添加任何功能/增强。这使得 Apple 能够包含当今许多最受欢迎和现代语言中的最佳功能,例如 Objective-C、Python、Java、Ruby、C# 和 Haskell。
以下表格展示了 Swift 相比 Objective-C 语言提供的最激动人心的增强功能列表:
| Swift 功能 | 描述 |
|---|---|
| 类型推断 | Swift 可以根据初始值自动推断变量或常量的类型。 |
| 泛型 | 泛型允许我们编写一次代码,以对不同类型的对象执行相同任务。 |
| 集合可变性 | Swift 没有为可变或不可变容器定义单独的对象。相反,你通过定义容器为常量或变量来定义可变性。 |
| 闭包语法 | 闭包是包含功能性的自包含块,可以在我们的代码中传递和使用。 |
| 可选 | 可选定义了一个可能没有值的变量。 |
switch 语句 |
switch 语句得到了大幅改进。这是我最喜欢的改进之一。 |
| 元组 | 函数可以通过使用元组来具有多个返回类型。 |
| 运算符重载 | 类可以提供现有运算符的自己的实现。 |
| 带关联值的枚举 | 在 Swift 中,我们可以使用枚举做很多不仅仅是定义一组相关值的事情。 |
| 协议和协议导向设计 | 苹果在 Swift 2 中引入了协议导向编程范式。这是一种不仅编写应用程序,而且改变我们思考编程方式的新方法。这在第十章“协议导向设计”中有详细讨论。 |
表 1.1:Swift 特性
在我们开始探索 Swift 开发的奇妙世界之前,让我们绕道访问一个自从我还是个孩子就喜欢的地点:游戏场。
游戏场
当我还是个孩子的时候,学校一天中最美好的时光就是去游戏场。我们玩什么并不重要,只要我们在游戏场就好。当苹果公司将游戏场作为 Xcode 6 的一部分引入时,我仅仅是因为这个名字就感到兴奋,但我怀疑苹果能否让它的游戏场像我的童年游戏场一样有趣。虽然苹果的游戏场可能没有我在 9 岁时踢垒球的乐趣,但它确实为实验和玩代码带来了很多乐趣。
游戏场也适用于 iPad。虽然我们不会在本节中具体介绍 iPad 版本,但 iPad 版本是实验 Swift 语言的好方法,也是吸引孩子们对编程产生兴趣的好方法。
开始使用游戏场
游戏场是交互式的工作环境,允许我们在代码发生变化时立即看到结果。这意味着游戏场是学习 Swift 和进行实验的绝佳方式。现在我们可以在 iPad 上使用 Swift Playgrounds,我们甚至不需要在面前有电脑就可以实验 Swift。
如果您在 Linux 平台上使用 Swift,您将没有游戏场可用,但您可以使用读取-评估-打印-循环(REPL)shell 在不需要编译代码的情况下实验 Swift。如果您在除 macOS 计算机或 iPad 以外的设备上使用 Swift,您可以安全地跳过本节,转到“Swift 语言语法”部分。在第二章“Swift 文档和安装 Swift”中,我们探讨了 Swift 包管理器和 Swift 编译器等额外工具,作为我们构建和运行本书中示例代码的替代方法。
游戏场还使我们尝试新 API、原型化新算法和展示代码工作方式变得极其容易。您可以使用游戏场来查看示例代码的工作方式。因此,在我们真正开始 Swift 开发之前,让我们花些时间学习和熟悉游戏场。
如果 Swift 代码现在看起来没有太多意义,请不要担心;随着我们继续阅读这本书,我们将在接下来的示例中使用的代码将开始变得有意义。我们只是现在试图对游戏场有一个感觉。
一个游戏场可以有几个部分,但在这本书中我们将广泛使用以下三个部分:
-
编码区域:这是您输入 Swift 代码的地方。
-
结果侧边栏:这是显示你代码结果的地方。每次你输入新的一行代码时,结果都会重新评估,并且 结果侧边栏 部分会更新为新的结果。
-
调试区域:这个区域显示代码的输出,对于调试来说非常有用。
以下截图显示了这些部分在游乐场中的排列方式:

图 1.1:游乐场布局
让我们开始一个新的游乐场。我们首先需要启动 Xcode。一旦 Xcode 启动,我们可以选择 使用游乐场开始 选项,如下面的截图所示:

图 1.2:开始新的游乐场
或者,我们可以通过从顶部菜单栏的 文件 | 新建 转到 游乐场... 来导航,如下面的截图所示:

图 1.3:创建新的游乐场
接下来,我们应该看到一个类似于 图 1.4 的屏幕。这个屏幕允许我们命名我们的游乐场,并选择游乐场是 iOS、tvOS 还是 macOS 游乐场。在本章的大部分示例中,可以安全地假设你可以选择任何操作系统选项,除非另有说明。你还可以选择一个模板来使用。对于本书的示例,我们将使用 空白 模板来编写所有代码:

图 1.4:游乐场模板
最后,我们需要选择保存游乐场的位置。选择位置后,游乐场将打开并看起来类似于 图 1.5:

图 1.5:游乐场屏幕
在前面的截图中,我们可以看到游乐场的编码区域看起来与 Xcode 项目的编码区域相似。这里的不同之处在于右侧的侧边栏。这个侧边栏是显示我们代码结果的地方。前面的截图中的代码导入了 Cocoa 框架,因为它是一个 macOS 游乐场。如果它是一个 iOS 游乐场,它将导入 UIKit 框架。
如果你的新游乐场没有打开调试区域,你可以通过同时按下 shift + command + Y 键来手动打开它。或者,你也可以使用游乐场窗口右上角的侧边栏按钮。再次按下 shift + command + Y 可以关闭调试区域。在本章的后面部分,我们将看到为什么调试区域如此有用。打开或关闭调试区域的另一种方法是点击一个看起来像倒三角形的按钮,该按钮位于调试区域和编码区域之间的边界框中。
iOS、tvOS 和 macOS 游乐场
当你开始一个新的 iOS 或 tvOS 游乐场时,游乐场会导入 UIKit 框架。这使我们能够访问 UIKit 框架,它为 iOS 和 tvOS 应用程序提供核心基础设施。当我们开始一个新的 macOS 游乐场时,游乐场会导入 Cocoa 框架。
上一段话的意思是,如果我们想对 UIKit 或 Cocoa 的特定功能进行实验,我们需要打开正确的游乐场。例如,如果我们有一个 iOS 游乐场打开,并且我们想创建一个表示颜色的对象,我们会使用 UIColor 对象。如果我们有一个 macOS 游乐场打开,我们会使用 NSColor 对象来表示颜色。
在游乐场中创建和显示图形
在原型设计新算法时,创建和显示图形非常有用。这是因为它们允许我们在整个计算过程中看到变量的值。要了解图形化工作原理,请查看以下游乐场:

图 1.6:创建循环
在这个游乐场中,我们将 j 变量设置为 1。接下来,我们创建一个 for 循环,将数字 1 到 5 分配给 i 变量。在 for 循环的每一步中,我们将 j 变量的值设置为当前 j 的值加上 i。图形可以在 for 循环的每一步中改变 j 变量的值,帮助我们看到变量随时间的变化。我们将在本书的后面详细讲解 for 循环。
要显示图形,点击形状像圆圈带点的符号。然后我们可以移动时间线滑块,查看 for 循环每一步中 j 变量的值。以下游乐场显示了图形应该看起来像什么:

图 1.7:绘制图形
当我们想看到变量在代码执行过程中的变化时,图形非常有帮助。
什么不是游乐场
我们可以用游乐场做很多事情,而我们在这里的快速介绍中只是触及了皮毛。在我们离开这个简短的介绍之前,让我们看看什么是游乐场不是,以便我们更好地理解何时不应使用游乐场:
-
游乐场不应用于性能测试:在游乐场中运行的任何代码的性能并不能代表代码在项目中的运行速度
-
游乐场不支持设备上的执行:你不能将游乐场中的代码作为外部应用程序或在外部设备上运行
现在,让我们熟悉一些基本的 Swift 语法。
Swift 语言语法
如果你是一名 Objective-C 开发者,并且你对现代语言如 Python 或 Ruby 不熟悉,前面截图中的代码可能看起来相当奇怪。Swift 语言的语法与基于 Smalltalk 和 C 的 Objective-C 有很大的不同。
Swift 语言使用现代概念和语法来创建非常简洁和可读的代码。同时,也强调消除常见的编程错误。
在我们深入研究 Swift 语言本身之前,让我们看看一些 Swift 语言的基本语法。
注释
在 Swift 代码中编写注释与在 Objective-C 代码中编写注释略有不同。我们仍然可以使用双斜杠 (//) 进行单行注释,以及使用 /** 和 */ 进行多行注释;然而,如果我们想使用注释来记录代码,我们需要使用三斜杠 (///) 或多行注释块。
您可以使用 Xcode 根据方法的签名自动生成注释模板,只需突出显示它,然后同时按下 command + option + /。
为了记录我们的代码,我们通常使用 Xcode 识别的字段。这些字段如下:
-
参数:当我们以
parameter {param name}:开头一行时,Xcode 会将其识别为参数的描述。 -
返回:当我们以
return:开头一行时,Xcode 会将其识别为返回值的描述。 -
抛出:当我们以
throws:开头一行时,Xcode 会将其识别为该方法可能抛出的任何错误描述。
下面的游乐场展示了单行和多行注释的示例以及如何使用注释字段:

图 1.8:在游乐场中添加注释
要写好注释,我建议在函数内部使用单行注释,以快速给出代码的一行解释。然后,我们在函数和类外部使用多行注释来解释函数和类的作用。前面的游乐场展示了如何正确使用注释。通过使用适当的文档,就像我们在前面的截图中所做的那样,我们可以在 Xcode 中使用文档功能。如果我们按住 option 键,然后在代码中的任何地方点击函数名,Xcode 将显示一个包含函数描述的弹出窗口。
下面的截图显示了弹出窗口的外观:

图 1.9:Xcode 中关于函数的文档
我们可以看到,文档包含五个字段。这些字段如下:
-
声明:这是函数的声明。
-
参数:这是函数参数在注释中的描述。参数描述以注释部分的
Parameters:标签为前缀。 -
抛出异常:
throws描述前缀为throws标签,用于描述方法抛出的错误。 -
返回值:
returns描述前缀在注释部分为returns:标签。 -
声明在:这是函数声明的文件,这样我们就可以轻松找到它。
我们可以在注释中添加更多的字段。你可以在 Apple 的网站上找到完整的列表:developer.apple.com/library/content/documentation/Xcode/Reference/xcode_markup_formatting_ref/MarkupFunctionality.html。
如果你正在为 Linux 平台开发,我仍然建议使用 Apple 的文档指南,因为随着其他 Swift IDE 的开发,我相信它们将支持相同的指南。
分号
你可能已经注意到,到目前为止的代码示例中我们没有在行尾使用分号。在 Swift 中,分号是可选的;因此,以下 playground 中的两行在 Swift 中都是有效的:

图 1.10:Swift 中分号的使用
为了风格上的考虑,强烈建议你在 Swift 代码中不要使用分号。如果你真的想使用分号,请保持一致性,并在每一行代码中使用它们;然而,如果你忘记了,也不会有警告。
我再强调一次:建议你在 Swift 中不要使用分号。
括号
在 Swift 中,条件语句周围的括号是可选的;例如,以下 playground 中的两个 if 语句都是有效的:

图 1.11:Swift 中的括号
为了风格上的考虑,建议你除非在同一行上有多个条件语句,否则不要在代码中使用括号。为了可读性,将括号放在同一行上的单个条件语句周围是一个好的做法。
花括号
在 Swift 中,与大多数其他语言不同,在条件或循环语句之后需要使用花括号。这是 Swift 内置的安全特性之一。可以说,如果开发者使用了花括号,那么可能已经避免了大量的安全漏洞。这些漏洞也可以通过其他方式避免,例如单元测试和代码审查,但在我看来,要求开发者使用花括号是一个良好的安全标准。
以下 playground 展示了如果你忘记包含花括号时将得到的错误:

图 1.12:Swift 中的花括号
赋值运算符不返回值
在大多数其他语言中,以下行代码是有效的,但可能并不是开发者想要执行的操作:
if (x = 1) {}
在 Swift 中,这个语句是无效的。在条件语句(if、while 和 guard)中使用赋值运算符(=)会引发错误。这是 Swift 中内置的另一个安全特性。它防止开发者忘记比较语句中的第二个等号(=)。以下 playground 展示了此错误:

图 1.13:Swift 中的赋值运算符
在条件和赋值语句中,空白是可选的。
对于条件(if 和 while)和赋值(=)语句,空白是可选的。因此,在以下 playground 中,i 和 j 代码块都是有效的:

图 1.14:Swift 中的空格
为了风格上的考虑,我建议添加空白,就像 j 块所示(为了可读性),但只要您选择一种风格并保持一致,任何风格都是可接受的。
Hello World
所有旨在教授计算机语言的优秀计算机书籍都有一个部分,展示了用户如何编写 Hello World 应用程序。这本书也不例外。在本节中,我们将向您展示如何编写两个不同的 Hello World 应用程序。
我们的第一个 Hello World 应用程序将是一个传统的 Hello World 应用程序,它只是将 Hello World 打印到控制台。让我们首先创建一个新的 playground,并将其命名为 Chapter_1_Hello_World。
在 Swift 中,要打印消息到控制台,我们使用 print() 函数。在其最基本的形式中,我们会使用 print() 函数打印出一条单独的消息,如下面的代码所示:
print("Hello World")
通常,当我们使用 print() 函数时,我们希望打印的不仅仅是静态文本。我们可以通过使用字符串插值或通过在 print() 函数中使用逗号分隔值来包含变量和/或常量的值。字符串插值使用一个特殊的字符序列,\( ),在字符串中包含变量和/或常量的值。以下代码展示了如何做到这一点:
let name = "Jon"
let language = "Swift"
var message1 = " Welcome to the wonderful world of "
var message2 = "\(name), Welcome to the wonderful world of \(language)!"
print(message2)
print(name, message1, language, "!")
我们还可以在 print() 函数中定义两个参数,这两个参数会改变消息在控制台中的显示方式。这些参数是 separator 和 terminator 参数。separator 参数定义了一个用于在 print() 函数中分隔变量/常量值的字符串。默认情况下,print() 函数使用空格分隔每个变量/常量。terminator 参数定义了在行尾放置的字符。默认情况下,会在行尾添加换行符。
以下代码展示了我们如何创建一个以逗号分隔的列表,列表末尾没有换行符:
let name1 = "Jon"
let name2 = "Kailey"
let name3 = "Kara"
print(name1, name2, name3, separator:", ", terminator:"")
我们还可以向我们的 print() 函数添加一个参数:to: 参数。这个参数将允许我们重定向 print() 函数的输出。在下面的示例中,我们将输出重定向到名为 line 的变量中:
let name1 = "Jon"
let name2 = "Kailey"
let name3 = "Kara"
var line = ""
print(name1, name2, name3, separator:", ", terminator:"", to:&line)
print(line)
之前,print() 函数只是一个用于基本调试的有用工具,但现在,随着新的增强型 print() 函数,我们可以用它做更多的事情。
前两个示例的输出是一个以逗号分隔的 Jon、Kailey、Kara 列表。
摘要
我们在本章的开始讨论了 Swift 语言,并简要介绍了其历史。我们还提到了 Swift 新版本中的一些变化。然后我们向您展示了如何启动和使用游乐场来实验 Swift 编程。我们还涵盖了 Swift 语言的基本语法,并讨论了适当的语言风格。本章以两个 Hello World 示例结束。
在下一章中,我们将查看 Apple 和 Swift 社区提供的文档。然后我们将了解如何从源代码构建 Swift 以及如何使用 Swift 编译器。
第二章:Swift 文档和安装 Swift
我在职业生涯中大部分时间都在担任 Linux 系统管理员和网络安全管理员。这些职位要求我从源代码编译和安装软件包。与下载预构建的二进制文件相比,从源代码构建软件包有很多优点。在我看来,最大的优点是你可以获取最新版本,而无需等待其他人构建它。这使我能够及时对我的系统进行最新的安全更新。使用 Swift,我们也能够下载最新的代码并自行编译,而无需等待其他人构建它。
在本章中,你将学习:
-
关于 swift.org 网站及其提供的内容
-
如何找到 Swift 的最新文档
-
安装 Swift 的方法
-
如何从源代码构建 Swift,包括其完整的工具链和包管理器
在上一章中,我们提到苹果公司已将 Swift 作为开源项目发布,并设立了 swift.org 网站专门服务于 Swift 社区。这意味着我们可以下载 Swift 语言的源代码,检查它,并自行构建 Swift。在我们真正深入 Swift 语言并使用它进行开发之前,让我们看看如何从源代码构建 Swift 以及苹果为我们提供的资源。我们将首先查看 swift.org 网站。
Swift.org
2015 年 12 月 3 日,苹果公司正式发布了 Swift 语言,包括支持库、调试器和包管理器,并将其以 Apache 2.0 许可证的形式开源给社区。当时,创建了 swift.org 网站,作为社区进入项目的门户。这个网站拥有丰富的信息,应该是你了解 Swift 社区和语言本身发生情况的首选网站。博客文章会更新 Swift 的新版本发布、新的 Swift 开源库、标准库的变更以及其他 Swift 新闻。
你还可以下载适用于多种 Linux 版本的预构建二进制文件。在本书编写时,我们可以下载适用于 Ubuntu 16.04、Ubuntu 18.04、Ubuntu 20.04、CentOS 8 和 Amazon Linux 2 的预构建二进制文件。入门页面提供了之前提到的 Linux 版本的依赖项列表以及如何安装二进制文件的说明。
网站还包括官方的 Swift 文档,其中包括语言指南、Swift 简介以及 API 设计指南。理解 API 设计指南对于确保你的代码符合推荐的编码标准至关重要。在 第十八章,Swift 格式化和风格指南 中,我们提供了 Swift 编码标准的建议,这些建议与苹果公司的建议相辅相成。
你还可以找到有关如何为 Swift 社区做出贡献的信息,Swift 源代码的下载位置,甚至还有一个关于推荐 Google Summer of Code 项目的部分。如果你真的想深入研究 Swift 开发,无论是服务器端、Mac 还是 iOS 开发,我建议定期访问 swift.org 网站,以了解 Swift 社区正在发生的事情。
苹果和 Swift 社区也提供了一些你可以用作参考的文档资源。
Swift 文档
苹果和 Swift 社区作为一个整体,已经发布了许多资源来帮助开发者用 Swift 编程。可以在 developer.apple.com/documentation/ 找到的苹果官方文档,包括 Swift 的 API 文档以及苹果的所有框架。苹果的框架中只有一小部分是开源的,并且可以在所有平台上工作;然而,如果你想要开始使用苹果的某个框架,这绝对是一个开始的地方。然而,找到特定 Swift API 的文档可能有些困难。
要快速找到 Swift API 的文档,我最喜欢的网站是 swiftdoc.org。这个网站导航起来非常方便,并为所有类型、协议、运算符和组成 Swift 语言的全局函数自动生成了文档。我注意到这个网站并不总是保持最新;然而,它对于任何 Swift 开发者来说都是一个极好的参考资料。生成这个网站的代码也是开源的,可以在 GitHub 上找到:github.com/SwiftDocOrg/swift-doc。GitHub 页面提供了如何生成你自己的离线文档的说明。
我接下来要提到的最后一个网站是我最近在我最喜欢的 Swift 网站上发现的。它是位于 www.hackingwithswift.com/example-code/ 的 Hacking with Swift 网站上的 Swift 知识库。一旦你学会了 Swift,需要知道如何进行 JSON 解析、提取 PDF 或任何其他特定功能,你在这里很可能找到你需要的东西。
现在我们知道了在哪里查找 Swift 的文档,让我们看看我们可以用哪些不同的方式来安装 Swift。
从 swift.org 安装 Swift
如果你正在为 Apple 平台开发和编程,我强烈建议你使用 Xcode 中的 Swift 版本。苹果不会批准使用与 Xcode 版本不同的 Swift 版本编译的应用程序。这听起来可能有些极端,但它确保了应用程序是用稳定的 Swift 版本编译的,并且这个版本已经经过全面审查,可以与你的 Xcode 版本一起工作。
如果你使用的是在 swift.org 网站上有预构建二进制文件的 Linux 发行版,建议你使用这些二进制文件。它们是让 Swift 运行起来最快、最简单的方法。你还可以在 swift.org 网站的“入门”部分找到完整的安装说明和依赖项列表。
如果你使用的 Linux 发行版没有提供预构建的二进制文件,如果你想尝试 Swift 的最新版本,或者你只是想看看从源代码构建 Swift 是什么样的体验,你也可以这样做。
从源代码构建 Swift 和 Swift 工具链
有许多网站展示了如何从源代码构建 Swift,但遗憾的是,这些网站中的大多数只提供了构建 Swift 语言本身的说明,而没有工具链。我发现这并不太有用,除非你只编写非常简单的应用程序。在我看来,在没有整个工具链和包管理器的情况下为 Linux 构建 Swift 更多的是一种“我能做到吗”的练习,而不是构建可以长期使用的软件。
虽然不建议在生产系统中使用 Swift 的最新构建版本,但它确实使我们能够使用语言的最新功能,并验证引入到我们的应用程序中的更改与 Swift 语言的未来版本兼容。
在本章中,我们将探讨如何从源代码构建 Swift、其整个工具链以及 Swift 包管理器。由于每个 Linux 发行版和 macOS 都有所不同,我需要选择一个平台来编写这些说明;因此,我使用了 Ubuntu 18.04 和本书编写时的当前 Swift 5.3 开发版本。对于其他发行版或版本,你可能需要更改已安装的依赖项或它们的安装方式。构建 Swift 本身的命令将在所有平台上都是相同的。
这些命令中的一些可能相当长,难以手动输入。所有的命令都包含在本书可下载的代码中的文本文件里,你可以从中剪切和粘贴。
现在,让我们开始构建 Swift,通过安装依赖项来启动这个过程。
安装依赖项
我们首先需要确保我们拥有所有必需的依赖项。以下命令包括了我在不同 Linux 发行版上需要安装的依赖项。
你可能会发现你的发行版已经预装了一些这些依赖项,但为了确保你拥有所有内容,请运行以下命令:
sudo apt-get install git cmake ninja-build clang python uuid-dev libicu-dev icu-devtools libedit-dev libxml2-dev libsqlite3-dev swig libpython-dev python-six libncurses5-dev pkg-config libcurl4-openssl-dev systemtap-sdt-dev tzdata rsync
需要注意的一点是依赖项可能会发生变化,如果你尝试从源代码编译并且收到一个错误信息,表明某些东西缺失,你可以使用 apt-get install 命令或系统上的包管理器来添加它。现在我们已经安装了所有依赖项,我们需要下载 Swift 源代码。
Swift 源代码
要下载 Swift 源代码,我们需要创建一个新的目录,将代码下载到该目录,然后运行 git 命令来检索源代码。以下命令将 Swift 源代码下载到名为 swift-source 的目录:
mkdir swift-source
cd swift-source
git clone https://github.com/apple/swift.git
./swift/utils/update-checkout --clone
现在我们已经获取了源代码并克隆了我们需要的部分,接下来让我们构建 Swift。
构建 Swift
在您开始构建 Swift 之前,您需要了解这取决于您的系统或虚拟机设置,可能需要数小时才能构建完成。如果您正在使用虚拟机,例如 VirtualBox,我强烈建议您为虚拟机分配多个核心;这将显著缩短构建时间。以下命令将构建 Swift、其工具链和包管理器:
./swift/utils/build-script --preset=buildbot_swiftpm_linux_platform,tools=RA,stdlib=RA
一旦构建完成所有内容,我们需要将其安装到某个位置,并将二进制文件放入我们的路径中。
安装 Swift
现在我们已经构建了 Swift 和其工具链,我们已准备好安装它。我喜欢在 /opt 目录下安装 Swift,其他人更喜欢在 /usr/local/share 目录下安装。您将其放在哪个目录下完全取决于您。如果您想将其安装在其他位置,只需将路径中的 /opt 替换为您希望安装到的目录即可。
让我们从更改到 /opt 目录并创建一个名为 swift 的新目录开始安装过程。我们需要更改此目录的权限,以便我们可以读取、写入和执行文件。以下命令将执行此操作:
cd /opt
sudo mkdir swift
sudo chmod 777 swift
命令 chmod 777 swift 为此计算机的所有用户添加了读取、写入和执行权限。我喜欢使用这种模式,因为这样任何系统用户都可以使用 Swift;然而,这也可以被认为是一个安全问题,因为它也意味着任何人都可以修改文件。使用此模式时请自行承担风险,对于生产系统,我强烈建议您查看直接需要此权限的用户,并对其进行更严格的限制。
接下来,我们需要将构建的 Swift 二进制文件移动到 swift 目录。为此,我们将更改到 swift 目录,为我们的构建创建一个新的目录,更改到该目录,并复制文件。以下命令将执行此操作:
cd swift
mkdir swift-5.3-dev
cd swift-5.3-dev
cp -R ~/swift-source/build/buildbot_incremental/toolchain-linux-x86_64/* ./
现在我们想要创建一个指向此目录的符号链接,名为 swift-current。这样做的原因是它允许我们向我们的 PATH 环境变量中添加一个条目,这样操作系统就可以在不需要我们输入完整路径的情况下找到 Swift 可执行文件。如果我们使用 swift-current 路径而不是 swift.5.3-dev 路径来设置此条目,那么在安装新的 Swift 版本时,我们可以简单地更改 swift-current 符号链接指向的位置,从而使一切正常工作。我们可以使用以下命令从 /opt/swift 目录中执行此操作:
sudo ln -s /opt/swift/swift-5.3-dev swift-current
现在我们需要在我们的 PATH 变量中创建条目。为此,我们将 /opt/swift/swift-current/usr/bin/ 目录添加到位于你主目录中的 .profile 文件中的 PATH 变量中。然后更新环境。以下命令会这样做:
cd ~
echo 'PATH=$PATH:/opt/swift/swift-current/usr/bin/' >> .profile
source ~/.profile
我们现在应该已经安装了 Swift 并准备好使用。我们最后想要做的是测试我们的安装。
测试安装
我们最后需要做的是验证 Swift 是否已成功安装。为此,我们可以运行以下命令:
swift --version
输出应该看起来像这样,但带有你安装的 Swift 版本:
Apple Swift version 5.3-dev (LLVM a60975d8a4, Swift afe134eb2e)
Target: x86_64-unknown-linux-gnu
如果输出看起来像这样,那么恭喜你,Swift 已经成功安装到你的系统中。如果存在问题,并且你的系统无法找到 swift 命令,那么问题可能出在路径上。你首先想要做的是打印出你的 PATH 变量来验证 /opt/swift/swift-current/usr/bin/ 是否在你的路径中。以下命令会这样做:
echo $PATH
如果 /opt/swift/swift-current/usr/bin/ 不在你的路径中,你可以尝试手动运行 PATH=$PATH:/opt/swift/swift-current/usr/bin/ 命令,而不是将其添加到你的 .profile 文件中。
最后,让我们验证包管理器是否也正确安装。为此,我们想在你的主目录下创建一个 swift-code 目录,并创建一个新的包,如下面的命令所示:
cd ~
mkdir swift-code
cd swift-code/
mkdir test
cd test
swift package init --type=executable
输出应该看起来像这样:
Creating executable package: test
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/test/main.swift
Creating Tests/
Creating Tests/LinuxMain.swift
Creating Tests/testTests/
Creating Tests/testTests/testTests.swift
Creating Tests/testTests/XCTestManifests.swift
我们现在已经准备好使用 Swift 和包管理器。
使用 Swift 包管理器
你可以用包管理器做很多事情,这使得它在 Linux 平台上创建复杂的应用程序成为必需品。它帮助向项目中添加依赖项,并使我们能够将代码拆分成多个文件并创建库项目。你同样可以在 Mac 平台上使用包管理器,但我发现使用 Xcode 更容易。
对于这本书中的示例,我们不需要添加依赖项或使用多个文件。让我们看看我们如何在包管理器中简单地构建和运行一个可执行项目,这样你就可以使用它来运行这本书中的示例。记住,你同样可以在 Apple 平台上使用包管理器。当包管理器在 Sources/test/ 目录中创建 main.swift 文件时,它向其中添加了以下代码:
print("Hello, world!")
这段代码给我们提供了一个基本的 Hello World 应用程序。当你阅读这本书时,你可以用这本书中的示例替换这段代码。为了了解我们如何使用包管理器构建和运行应用程序,让我们现在保持代码不变,并运行以下命令:
swift build
swift run
你应该看到以下输出:
Hello, world!
swift build 命令编译了我们的应用程序,而 swift run 命令运行了由前一个命令构建的可执行文件。本书中的大部分代码不需要包管理器来运行,可能直接使用编译器会更简单。但请记住,对于比简单示例更大的项目,你将希望使用包管理器或 Xcode。
接下来,让我们看看如何使用 Swift 编译器。
使用 Swift 编译器
Swift 编译器是构建 Swift 代码的基本实用工具,它被包管理器、Xcode 和任何其他将 Swift 代码构建为可执行文件的工具使用。我们也可以自己调用它。要了解如何自己调用它,创建一个名为 hello.swift 的文件,并添加如下所示的 print("Hello, world!") 代码:
echo `print("Hello, world!")` >> hello.swift
现在,我们可以使用以下命令编译此代码,该命令调用 Swift 编译器:
swiftc hello.swift
最后,我们可以像运行其他任何可执行文件一样执行新创建的应用程序:
./hello
我们将看到我们的 Hello, world! 消息。
概述
在本章中,我们探讨了苹果公司和 Swift 社区提供的一些不同文档。当你学习 Swift 时,这些文档可能是必不可少的,而且在你掌握了这门语言之后,它们也可以作为参考。我们还探讨了如何构建和安装 Swift 及其完整的工具链。虽然不建议在生产系统中使用 Swift 的最新构建版本,但我通常会在虚拟机或我的桌面设置中保留一个最近的构建版本。这使我能够使用语言的最新功能,并且可以运行我的代码以检查我是否引入了与语言未来版本不兼容的更改。
在下一章中,我们将开始深入了解这门语言本身,我们将看到如何在 Swift 中使用变量和常量。我们还将探讨各种数据类型以及如何在 Swift 中使用运算符。
第三章:学习关于变量、常量、字符串和操作符的知识
我写的第一个程序是用 BASIC 编写的,是一个典型的 Hello World 应用程序。这个应用程序一开始很令人兴奋,但打印静态文本的兴奋感很快就消失了。对于我的第二个应用程序,我使用了 BASIC 的输入命令来询问用户的名字,然后打印出带有他们输入的名字的自定义“hello”消息。12 岁的时候,显示Hello Han Solo还是挺酷的。这个应用程序让我创建了大量的 Mad Libs 风格的应用程序,这些应用程序会提示用户输入各种单词,然后在用户输入所有必需的单词后显示一个故事。这些应用程序让我了解到,并教会了我变量的重要性。从那时起,我创建的每个有用的应用程序都使用了变量。
在本章中,我们将涵盖以下主题:
-
变量和常量是什么?
-
显式类型和推断类型有什么区别?
-
数字、字符串和布尔类型是什么?
-
解释 Swift 中枚举的工作原理
-
解释 Swift 操作符的工作原理
我们认识到 Swift 正在成为苹果生态系统之外的平台上的非常流行的语言。因此,从本章开始,在可下载的代码示例中,我们将包括 Swift playground 和所有示例代码的 .swift 代码文件。这将使您能够轻松地在您选择的任何平台上尝试这些示例。这是从 Mastering Swift 5.3,第六版 书籍开始的一个新特性。让我们通过了解常量和变量来开始我们的 Swift 语言之旅。
常量和变量
常量和变量将一个标识符(如 myName 或 currentTemperature)与特定类型的值(如 String 或 Integer 类型)关联起来,其中标识符可以用来检索值。常量和变量之间的区别在于,变量可以被更新或更改,而一旦为常量分配了值,它就不能被更改。
常量适用于定义那些你知道永远不会改变的值,比如水结冰的温度或光速。常量也适用于定义我们在整个应用程序中多次使用的值,例如标准字体大小或缓冲区中字符的最大数量。本书中会有许多关于常量的例子,建议尽可能使用常量而不是变量。
变量在软件开发中比常量更常见。这主要是因为开发者倾向于更喜欢变量。在 Swift 中,如果声明了一个值永远不会改变的变量,编译器会警告我们。我们可以不使用常量(尽管使用它们是良好的实践)来创建有用的应用程序;然而,没有变量几乎不可能创建一个有用的应用程序。
在 Swift 中鼓励使用常量。如果我们不期望或不想一个值发生变化,我们应该将其声明为常量。这为我们代码添加了一个非常重要的安全约束,确保该值永远不会改变。
您可以在变量或常量的命名/标识符中使用几乎任何字符(甚至 Unicode 字符);然而,您必须遵循一些规则:
-
标识符不得包含任何空白字符。
-
它不能包含任何数学符号或箭头。
-
标识符不得包含私有用途或无效的 Unicode 字符。
-
它不能包含行或框绘制字符。
-
它不能以数字开头,但它可以包含数字。
-
强烈不建议使用 Swift 关键字作为标识符,但如果您确实这样做,请用反引号包围它。
关键字是 Swift 编程语言使用的单词。本章中您将看到的几个关键字示例是 var 和 let。您应该避免使用 Swift 关键字作为标识符,以避免在阅读代码时产生混淆。
定义常量和变量
常量和变量在使用之前必须定义。要定义一个常量,您使用 let 关键字,要定义一个变量,您使用 var 关键字。以下代码显示了如何定义常量和变量:
// Constants
let freezingTemperatureOfWaterCelsius = 0
let speedOfLightKmSec = 300000
// Variables
var currentTemperature = 22
var currentSpeed = 55
我们可以在一行中声明多个常量或变量,通过逗号分隔它们。例如,我们可以将前面的四行代码缩减为两行,如下所示:
// Constants
let freezingTemperatureOfWaterCelsius = 0, speedOfLightKmSec = 300000
// Variables
var currentTemperature = 22, currentSpeed = 55
我们可以将变量的值更改为兼容类型的另一个值;然而,正如我们之前提到的,我们无法更改常量的值。让我们看看以下游乐场。你能从错误信息中看出代码哪里有问题吗?

图 3.1:尝试更改常量时抛出的错误
你弄清楚代码哪里出问题了吗?任何物理学家都可以告诉你,我们无法改变光速,在我们的代码中,speedOfLightKmSec 是一个常量,因此我们在这里也无法改变它。当我们尝试更改 speedOfLightKmSec 常量时,会抛出一个错误。我们可以不产生错误地更改 highTemperature 的值,因为它是一个变量。我们已经多次提到变量和常量的区别,因为它是一个非常重要的概念,尤其是在我们进入定义可变和不可变集合类型时,尤其是在第五章 使用 Swift 集合 中。
当某物是可变的时,这意味着我们能够改变它,当我们说某物是不可变的时,这意味着我们无法改变它。
类型安全
Swift 是一种类型安全的语言,这意味着我们被要求定义将要存储在变量中的值的类型。如果我们尝试将一个错误类型的值分配给变量,我们会得到一个错误。以下游乐场显示了如果我们尝试将字符串值放入期望整数值的变量中会发生什么:
我们将在本章后面讨论最流行的类型。

图 3.2:Swift 游乐场中的类型安全错误
Swift 在编译代码时会执行类型检查,因此它会用错误标记任何不匹配的类型。在这个游乐场中的错误信息清楚地解释了我们试图将字符串值插入到整数变量中。
Swift 如何知道常量 integerVar 是整数类型?Swift 使用类型推断来确定合适的类型。让我们看看什么是类型推断。
类型推断
类型推断允许我们在变量定义时省略变量类型,如果变量有初始值。编译器将根据该初始值推断类型。例如,在 Objective-C 中,我们会这样定义一个整数:
int myInt = 1
这告诉编译器 myInt 变量是 Int 类型,初始值是数字 1。在 Swift 中,我们会这样定义相同的整数:
var myInt = 1
Swift 推断变量类型是整数,因为初始值是整数。让我们看看更多例子:
var x = 3.14 // Double type
var y = "Hello" // String type
var z = true // Boolean type
在前面的例子中,编译器将正确推断变量 x 是 Double 类型,变量 y 是 String 类型,变量 z 是 Boolean 类型,基于它们的初始值。我们能够显式地定义变量类型。然而,建议我们只在未为变量分配初始值时这样做。
显式类型
类型推断是 Swift 中一个非常棒的功能,你可能会很快习惯它。然而,有时我们可能希望显式地定义一个变量的类型。例如,在上一个例子中,变量 x 被推断为 Double 类型,但如果我们想将变量类型定义为 Float 呢?我们可以这样显式地定义变量类型:
var x:Float = 3.14
注意变量标识符后面的 Float 声明(冒号和 Float 这个词)。这告诉编译器将这个变量定义为 Float 类型,并给它一个初始值 3.14。当我们以这种方式定义变量时,我们需要确保初始值与定义的变量类型相同。如果我们尝试给变量一个与定义的变量类型不同的初始值,那么我们会收到一个错误。例如,以下行将引发错误,因为我们显式地将变量定义为 Float 类型,而我们在尝试放入一个 String 值:
var x: Float = "My str"
如果我们没有设置初始值,我们需要显式地定义变量类型。例如,以下代码行是无效的,因为编译器不知道将变量 x 设置为什么类型:
var x
如果我们在应用程序中使用此代码,我们将收到一个 模式中缺少类型注解 错误。如果我们没有为变量设置初始值,我们必须定义变量类型,如下例所示:
var x: Int
现在我们已经看到了如何显式定义变量类型,让我们看看一些最常用的类型。
数值类型
Swift 包含许多适合存储各种整数和浮点值的标准数值类型。让我们首先看看整数类型。
整数类型
整数是一个完整的数字,可以是带符号的(正数、负数或零)或无符号的(正数或零)。Swift 提供了多种不同大小的 整数 类型。表 3.1 展示了 64 位系统上不同整数类型的值范围:
| 类型 | 最小值 | 最大值 |
|---|---|---|
Int8 |
-128 | 127 |
Int16 |
-32,768 | 32,767 |
Int32 |
-2,147,483,648 | 2,147,483,647 |
Int64 |
- 9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 |
Int |
- 9,223,372,036,854,775,808 | 9,223,372,036,854,775,807 |
UInt8 |
0 | 255 |
UInt16 |
0 | 65,535 |
UInt32 |
0 | 4,294,967,295 |
UInt64 |
0 | 18,446,744,073,709,551,615 |
UInt |
0 | 18,446,744,073,709,551,615 |
表 3.1:Swift 中可用的不同整数类型
您可能会注意到从图表中,无符号整数以 U 开头(UInt、UInt8 等),而带符号的整数则没有(Int、Int8)。
除非有特定原因需要定义整数的大小,我建议使用标准的 Int 或 UInt 类型。这将避免您以后需要在不同的整数类型之间进行转换。
在 Swift 中,Integer 类型和其他数值类型实际上是命名类型,并且使用结构体在 Swift 标准库中实现。这为我们提供了一种一致的机制来管理所有数据类型的内存,以及我们可以访问的属性。对于前面的图表,我使用了 Integer 类型的 min 和 max 属性来检索每个 Integer 类型的最小和最大值。查看以下游乐场,看看这些值是如何检索的:

图 3.3:不同数值类型的范围
整数也可以表示为二进制、八进制和十六进制数。我们只需要在数字前加上一个前缀,告诉编译器数字应该使用哪种基数。前缀的形式是一个零,后跟基数指定符。表 3.2 展示了每个数值基数的前缀:
| 基数 | 前缀 |
|---|---|
| 十进制 | 无 |
| 二进制 | 0b |
| 八进制 | 0o |
| 十六进制 | 0x |
表 3.2:每个数值基数的前缀
以下游乐场展示了数字 95 在每种数值基数中的表示方式:

图 3.4:使用不同的数值基数定义值
Swift 还允许我们在我们的数值字面量中插入任意下划线。这可以提高我们代码的可读性,而不会改变其底层值。例如,如果我们正在定义光速,这是一个常数,我们可以这样定义它:
let speedOfLightKmSec = 300_000
Swift 编译器将忽略这些下划线,并像没有下划线一样解释这个值。
Swift 中的 Integer 类型有一个名为 isMultiple(of:) 的方法,这个方法非常有用。此方法允许我们检查一个数字是否是另一个数字的倍数。在此方法之前,我们会使用以下代码:
let number = 4
if number % 2 == 0 {
print("Even")
} else {
print("Odd")
}
现在,我们可以这样使用 isMultiple(of:) 方法:
let number = 4
if number.isMultiple(of: 2) {
print("Even")
} else {
print("Odd")
}
虽然这个新方法实际上并没有消除很多代码,但它确实使我们的代码更容易阅读和理解。现在,让我们看看浮点数和 Double 类型。
浮点数和 Double 值
浮点数是一个带有小数部分的数字。Swift 中有两种标准的浮点数类型:Float 和 Double。Float 类型表示 32 位浮点数,而 Double 类型表示 64 位浮点数。虽然 Float 类型是 32 位浮点数,但 Swift 实际上支持四种浮点数类型。这些是 Float16、Float32、Float64 和 Float80。记住,当使用 Float 类型时,它是一个 32 位浮点数;如果你想要使用其他精度,你需要定义它。
Swift 5.3,随着 Swift Evolution SE-0277 的加入,将 Float16 类型添加到了 Swift 语言中,因为它在图形编程和机器学习中被广泛使用。
建议我们使用 Double 类型而不是 Float 类型,除非有特定的理由使用后者。Double 类型的精度至少为 15 位十进制数字,而 Float 类型的精度可能小到只有六位十进制数字。让我们看看一个例子,看看这如何影响我们的应用程序,而我们却不知道。图 3.5 展示了如果我们使用 Float 类型和使用 Double 类型将两个十进制数相加会发生什么:

图 3.5:Float 和 Double 的计算
如前一个屏幕截图所示,我们正在相加的前两个十进制数包含九位小数点后的数字;然而,Float 类型的结果只包含七位数字,而 Double 类型的结果包含完整的九位数字。这种精度损失可能会在处理货币或其他需要精确计算的数字时引起问题,正如我们在第二组数字的比较中可以看到的那样。
注意,当你为十进制数字使用类型推断时,Swift 将默认使用 Double 类型而不是 Float 类型。
如果我们有两个变量,其中一个为整数,另一个为双精度浮点数?你认为我们能否像以下代码所示那样将它们相加?
var a: Int = 3
var b: Double = 0.14
var c = a + b
如果我们将前面的代码放入游乐场,我们会收到以下错误:
operator '+' cannot be applied to operands of type Int and Double
这个错误告诉我们我们正在尝试添加两种不同类型的数字,这是不允许的。要添加 Int 和 Double 类型,我们需要将整数值转换为双精度浮点数。以下代码展示了如何做到这一点:
var a: Int = 3
var b: Double = 0.14
var c = Double(a) + b
注意我们如何使用 Double() 函数用 Int 值初始化一个 Double 值。Swift 中所有数值类型都有一个用于这些类型转换的初始化器。这些初始化器被称为 便利初始化器,类似于前面代码示例中显示的 Double() 函数。例如,以下代码展示了如何用整数值初始化 Float 或 uint16 值:
var intVar = 32
var floatVar = Float(intVar)
var uint16Var = UInt16(intVar)
通常情况下,当我们把两种不同的类型相加时,我们会希望将精度最低的数字(如整数或浮点数)转换为精度最高的类型,比如双精度浮点数。
布尔类型
布尔值通常被称为逻辑值,因为它们可以是true或false。Swift 有一个内置的 Boolean 类型,它接受两个内置布尔常量之一:true 和 false。
布尔常量和变量可以定义如下:
let swiftIsCool = true
var itIsRaining = false
布尔值在处理条件语句(如 if、while 和 guard 语句)时特别有用。例如,你认为这段代码会做什么?
let isSwiftCool = true
var isItRaining = false
if isSwiftCool {
print("YEA, I cannot wait to learn it")
}
if isItRaining {
print("Get a rain coat")
}
如果你回答说这段代码会打印出 YEA, I cannot wait to learn it,那么你就对了。这一行被打印出来是因为 isSwiftCool 布尔类型被设置为 true,而 isItRaining 变量被设置为 false;因此,Get a rain coat 消息没有被打印。
在大多数语言中,如果我们想要切换布尔变量的值,我们必须做类似这样的事情:
isItRaining = !isItRaining
在 Swift 中,布尔类型有一个名为 toggle() 的方法,允许我们切换变量的值。如果我们不知道变量中存储的值,就会用到这个方法。例如,如果 isItRaining 常量是一个变量,并且我们想要改变它的值,但我们不知道它实际是什么,我们可以使用以下代码行来改变它:
isItRaining.toggle()
就像整数的 isMultiple(of:) 方法一样,这使得我们的代码更容易阅读和理解。现在,让我们看看 String 类型。
字符串类型
字符串是有序字符集合,如 Hello 或 Swift,由 String 类型表示。我们在本书中已经看到了几个字符串的例子,因此以下代码应该看起来很熟悉。这段代码展示了如何定义两个字符串:
var stringOne = "Hello"
var stringTwo = "World"
我们也可以使用多行字符串字面量来创建字符串。以下代码展示了我们如何做到这一点:
var multiLine = """
This is a multiline string literal.
This shows how we can create a string over multiple lines.
"""
注意我们用三个双引号包围了多行字符串。我们可以在多行字符串中使用引号来引用特定的文本。以下代码展示了如何做到这一点:
var multiLine = """
This is a multiline string literal.
This shows how we can create a string over multiple lines.
Jon says, "multiline string literals are cool"
"""
由于字符串是有序字符集合,我们可以遍历字符串中的每个字符。以下代码展示了如何做到这一点:
var stringOne = "Hello"
for char in stringOne {
print(char)
}
以下代码将显示以下截图所示的结果:

图 3.6:遍历字符串的字符
我们还可以使用 map() 函数,如 图 3.6 所示,从 String 类型中检索每个字符,如下面的代码所示:
stringOne.map {
print($0)
}
我们将在本书的后面部分探讨 map() 方法及其工作原理。
我们可以将一个字符串添加到另一个字符串中的两种方式。我们可以连接它们或内联它们。要连接两个字符串,我们可以使用 + 或 += 运算符。以下代码展示了两种连接字符串的方法。第一个示例将 stringB 追加到 stringA 的末尾,并将结果放入新的 stringC 变量中。第二个示例直接将 string 追加到 stringA 的末尾,而不创建新的字符串:
var stringC = stringA + stringB
stringA += string
要将一个字符串内联到另一个字符串中,我们使用一个特殊的字符序列:\()。以下代码展示了如何将字符串插值与另一个字符串结合使用:
var stringA = "Jon"
var stringB = "Hello \(stringA)"
在前面的示例中,stringB 将包含消息 Hello Jon,因为 Swift 将 \(stringA) 字符序列替换为 stringA 变量的值。
从 Swift 5 开始,我们有了创建原始字符串的能力。在 Swift 的早期版本中,如果我们想在字符串中包含引号或反斜杠,我们必须使用反斜杠进行转义,如下面的代码所示:
let str = "The main character said \"hello\""
对于原始字符串,双引号和反斜杠被视为字符串字面部分的一部分,因此我们不需要对它们进行转义。以下示例展示了如何进行这一操作:
let str1 = #"The main character said "hello""#
注意字符串开头和结尾的井号和双引号。这告诉 Swift 这是一个原始字符串。这使得阅读字符串实际包含的内容变得容易得多。如果我们想像之前那样在行内追加另一个字符串,我们会使用 \#() 字符序列。以下代码说明了这一点:
let ans = 42
var str2 = #"The answer is \#(ans)"#
此代码的结果将是一个包含以下字符串的 str2 变量:答案是 42。
在 Swift 中,我们使用 var 和 let 关键字来定义变量和集合的可变性。如果我们使用 var 将字符串定义为变量,则该字符串是可变的,这意味着我们可以更改和编辑其值。如果我们使用 let 将字符串定义为常量,则该字符串是不可变的,这意味着一旦设置,我们无法更改或编辑其值。以下代码显示了可变字符串和不可变字符串之间的区别:
var x = "Hello"
let y = "HI"
var z = " World"
//This is valid because x is mutable
x += z
//This is invalid because y is not mutable.
y += z
Swift 中的字符串有两种方法可以转换字符串的大小写。这些方法是 lowercased() 和 uppercased()。以下示例演示了这些方法:
var stringOne = "hElLo"
print("Lowercase String: \(stringOne.lowercased())")
print("Uppercase String: \(stringOne.uppercased())")
如果我们运行此代码,结果将如下所示:
Lowercase String: hello
Uppercase String: HELLO
Swift 提供了四种比较字符串的方法;这些是字符串相等性、前缀相等性、后缀相等性和 isEmpty。以下示例演示了这些方法:

图 3.7:Swift 中的字符串比较方法
isEmpty() 方法检查字符串是否包含任何字符。字符串相等性(==)检查两个字符串中的字符(区分大小写)是否相同。前缀和后缀相等性检查字符串是否以特定字符串开头或结尾。前缀和后缀相等性也是区分大小写的。
我们可以用另一个字符串替换目标字符串的所有出现,这是通过 replacingOccurrences(of:) 方法完成的。以下代码演示了这一点:
var stringOne = "one,to,three,four"
var stringTwo = stringOne.replacingOccurrences(of: "to", with: "two")
print(stringTwo)
前面的例子将打印 one, two, three, four 到屏幕上,因为我们正在将 stringOne 变量中所有 to 的出现替换为 two。
注意,replacingOccurrences(of:) 方法仅在 Apple 平台上可用,在其他平台上不可用。
我们还可以从我们的字符串中检索子字符串和单个字符;然而,当我们从字符串中检索子字符串时,该子字符串是 Substring 类型的实例,而不是 String 类型。Substring 类型包含与 String 类型大多数相同的方法,因此你可以以类似的方式使用它们。然而,与 String 类型不同,它们旨在仅用于短时间内,仅在我们处理值时使用。如果你需要长时间使用 Substring 类型,你应该将其转换为 String 类型。以下示例显示了如何处理子字符串:
var path = "/one/two/three/four"
//Create start and end indexes
let startIndex = path.index(path.startIndex, offsetBy: 4)
let endIndex = path.index(path.startIndex, offsetBy: 14)
let sPath = path[startIndex ..< endIndex] //returns the "/two/three"
//convert the substring to a string
let newStr = String(sPath)
path[..<startIndex] //returns the "/one"
path[endIndex...] //returns the "/four"
path.last
path.first
在前面的例子中,我们使用了下标路径来检索起始和结束索引之间的子字符串。索引是通过 index(_: offsetBy:) 函数创建的。index(_: offsetBy:) 函数的第一个属性给出了我们希望开始的索引位置,而 offsetBy 属性告诉我们需要增加索引多少。
path[..<startIndex] 行从字符串的开始到索引创建一个子字符串,而 path[endIndex...] 行从索引到字符串的末尾创建一个子字符串。然后我们使用最后一个属性来获取字符串的最后一个字符,使用第一个属性来获取第一个字符。
我们在前面例子中看到的 ..< 操作符被称为 半开区间操作符。我们将在本章末尾查看不同的范围操作符。
我们可以通过使用 count 属性来检索字符串中的字符数。以下示例显示了如何使用此函数:
var path = "/one/two/three/four"
var length = path.count
这就完成了我们对字符串的快速浏览。我们非常快速地浏览了这些属性和函数,但在这本书中我们将广泛使用字符串,所以会有很多代码帮助你熟悉它们。
元组
元组将多个值组合成一个单一的复合类型。这些值不需要是同一类型。
以下示例展示了如何定义一个元组:
var team = ("Boston", "Red Sox", 97, 65, 59.9)
在前面的示例中,创建了一个无名的元组,其中包含两个字符串、两个整数和一个双精度浮点数。元组的值可以分解成一组变量,如下面的示例所示:
var team = ("Boston", "Red Sox", 97, 65, 59.9)
var (city, name, wins, losses, percent) = team
在前面的代码中,city 变量将包含 Boston,name 变量将包含 Red Sox,wins 变量将包含 97,losses 变量将包含 65,最后 percent 变量将包含 59.9。
也可以通过指定值的定位来检索元组的值。以下示例展示了如何通过值的位置来检索值:
var team = ("Boston", "Red Sox", 97, 65, 59.9)
var city = team.0
var name = team.1
var wins = team.2
var losses = team.3
var percent = team.4
命名元组,也称为 named tuples,允许我们避免分解步骤。命名元组将一个名称(键)与元组的每个元素关联起来。以下示例展示了如何创建一个命名元组:
var team = (city:"Boston", name:"Red Sox", wins:97, losses:65, percent:59.9)
可以使用点语法访问命名元组的值。在前面代码中,我们可以这样访问元组的 city 元素:team.city。在前面的代码中,team.city 元素将包含 Boston。
元组极其有用,可以用于各种目的。我发现它们对于替换仅设计用于存储数据且不包含任何方法的类和结构体非常有用。它们也非常适用于从函数中返回多个不同类型的值。现在,让我们看看枚举。
枚举
枚举(也称为 enums)是一种特殊的数据类型,它使我们能够将相关的类型组合在一起,并以类型安全的方式使用它们。与 C 或 Java 等其他语言不同,Swift 中的枚举不与整数值绑定。在 Swift 中,我们可以定义一个具有类型(字符串、字符、整数或浮点值)的枚举,然后定义其实际值(称为 raw value)。枚举还支持传统上只有类才支持的功能,例如计算属性和实例方法。我们将在 第七章,类、结构和协议 中深入讨论这些高级功能。在本节中,我们将探讨枚举的传统特性。
我们将定义一个包含 Planets 列表的枚举,如下所示:
enum Planets {
case mercury
case venus
case earth
case mars
case Jupiter
case Saturn
case Uranus
case neptune
}
注意:当定义枚举类型时,枚举的名称应该大写,就像其他类型一样。枚举值可以是大写或小写;然而,建议使用小写。
在枚举中定义的值被认为是枚举的成员值(或简单地称为成员)。在大多数情况下,你将看到成员值定义得像前面的示例一样,因为这样更容易阅读;然而,还有一个更简短的版本。这个简短版本允许我们在一行中定义多个成员,成员之间用逗号分隔,如下面的示例所示:
enum Planets {
case mercury, venus, earth, mars, jupiter
case saturn, uranus, neptune
}
然后,我们可以这样使用 Planets 枚举:
var planetWeLiveOn = Planets.earth
var furthestPlanet = Planets.neptune
当我们使用 Planets 枚举的一个成员值初始化 planetWeLiveOn 和 furthestPlanet 变量时,变量类型会被推断出来。一旦变量类型被推断出来,我们就可以在不使用 Planets 前缀的情况下分配新值,如下所示:
planetWeLiveOn = .mars
我们可以使用传统的等于运算符 (==) 或使用 switch 语句来比较枚举值。
注意:我们将在本书后面的 第六章,控制流 中学习 Swift 的 switch 语句。现在,我们想通过使用枚举类型来展示其用法。
以下示例展示了如何使用等于运算符和 switch 语句与枚举一起使用:
// Using the traditional == operator
if planetWeLiveOn == .earth {
print("Earth it is")
}
// Using the switch statement
switch planetWeLiveOn {
case .mercury:
print("We live on Mercury, it is very hot!")
case .venus:
print("We live on Venus, it is very hot!")
case .earth:
print("We live on Earth, just right")
case .mars:
print("We live on Mars, a little cold")
default:
print("Where do we live?")
}
枚举可以预先填充原始值,这些原始值必须是同一类型。以下示例展示了如何定义具有字符串值的枚举:
enum Devices: String {
case MusicPlayer = "iPod"
case Phone = "iPhone"
case Tablet = "iPad"
}
print("We are using an \(Devices.Tablet.rawValue)")
前面的示例创建了一个包含三种设备类型的枚举。然后我们使用 rawValue 属性来检索 Devices 枚举中 Tablet 成员的存储值。此示例将打印一条消息,表示“我们正在使用 iPad”。
让我们创建另一个 Planets 枚举,但这次,我们将数字分配给成员,如下所示:
enum Planets: Int {
case Mercury = 1
case Venus
case Warth
case Mars
case Jupiter
case Saturn
case Uranus
case Neptune
}
print("Earth is planet number \(Planets.earth.rawValue)")
这两个最后枚举示例之间的主要区别在于,在第二个示例中,我们只分配了一个值给第一个成员(mercury)。如果枚举的原始值使用整数,那么我们不需要为每个成员分配值。如果没有值存在,原始值将自动递增。
在 Swift 中,枚举也可以有关联值。关联值允许我们存储额外的信息,以及成员值。这些额外信息每次使用成员时都可能不同。它们也可以是任何类型,并且每个成员的类型可以不同。让我们通过定义一个包含两种产品类型的 Product 枚举来了解如何使用关联类型:
enum Product {
case Book(Double, Int, Int)
case Puzzle(Double, Int)
}
var masterSwift = Product.Book(49.99, 2017, 310)
var worldPuzzle = Product.Puzzle(9.99, 200)
switch masterSwift {
case .Book(let price, let year, let pages):
print("Mastering Swift was published in \(year) for the price of \(price) and has \(pages) pages")
case .Puzzle(let price, let pieces):
print("Mastering Swift is a puzzle with \(pieces) and sells for \(price)")
}
switch worldPuzzle {
case .Book(let price, let year, let pages):
print("World Puzzle was published in \(year) for the price of \(price) and has \(pages) pages")
case .Puzzle(let price, let pieces):
print("World Puzzle is a puzzle with \(pieces) and sells for \(price)")
}
在前面的示例中,我们首先定义了一个Product枚举,它有两个成员:Book和Puzzle。Book成员具有Double、Int和Int类型的关联值,而Puzzle成员具有Double和Int类型的关联值。请注意,我们正在使用命名关联类型,为每个关联类型分配一个名称。然后我们创建了两个产品,masterSwift和worldPuzzle。我们将masterSwift变量的值设置为Product.Book,并带有关联值49.99、2017和310。然后我们将worldPuzzle变量的值设置为Product.Puzzle,并带有关联值9.99和200。
然后,我们可以使用switch语句检查Product枚举,就像我们在之前的示例中所做的那样。然后我们在switch语句中提取关联值。在这个例子中,我们使用let关键字将关联值提取为常量,但您也可以使用var关键字将关联值提取为变量。
如果你将之前的代码放入游乐场,将会显示以下结果:
"Master Swift was published in 2017 for the price of 49.99 and has 310 pages"
"World Puzzle is a puzzle with 200 and sells for 9.99"
我们能够选择使我们的枚举符合Comparable协议,这些枚举要么没有关联值,要么它们的关联值本身符合Comparable协议。通过符合Comparable协议,我们能够使用<和>运算符比较相同枚举的案例。让我们看看这是如何工作的:
如果你现在还不理解协议是什么,或者类型如何符合它们,请不要担心。我们将在第九章“协议和协议扩展”中介绍协议。
enum Grades: Comparable {
case f
case d
case c
case b
case a
}
let acceptableGrade = Grades.c
let testOneGrade = Grades.b
if testOneGrade < acceptableGrade {
print("Grade is unacceptable")
}
else {
Print("Grade is acceptable")
}
在之前的代码中,我们定义了一个枚举,它定义了不同的成绩等级。通过在枚举声明后添加: Comparable,我们添加了对Comparable协议的符合。然后我们创建了一个常量,定义了我们的可接受成绩等级。现在我们能够将包含Grades值的任何变量与acceptableGrade常量进行比较,以确保它包含一个可接受的成绩,正如我们在示例中所展示的那样。
枚举的合成Comparable符合性是在 Swift 5.3 版本中通过 Swift Evolution SE-0266 添加的,这是我最兴奋的功能之一。它允许我们使我们的枚举符合Comparable协议,而无需编写代码来自己符合该协议。
在本书的后续章节中,我们将探讨枚举的附加功能,并了解为什么它们可以如此强大。到目前为止,在本书中,我们在许多示例中使用了运算符。让我们更仔细地看看它们。
运算符
运算符是我们可以使用来检查、更改或组合值的符号或符号组合。在本书的许多示例中,我们已经使用了运算符,但并没有特别提到它们。在本节中,我们将向您展示如何使用 Swift 支持的大多数基本运算符。
Swift 支持大多数标准 C 运算符,并对其中一些进行了改进,以消除几个常见的编码错误。例如,赋值运算符不返回值,这防止了它在应该使用等于运算符的地方被使用,等于运算符是两个等号(==)。
让我们看看 Swift 中的运算符。
赋值运算符
赋值运算符初始化或更新一个变量。这里有一个原型:
var A = var B
这里有一个例子:
let x = 1
var y = "Hello"
a = b
比较运算符
比较运算符如果语句为真则返回布尔值true,如果语句不为真则返回布尔值false。
这里有一些原型:
Equality: varA == varB
Not equal: varA != varB
Greater than: varA > varB
Less than: varA < varB
Greater than or equal to: varA >= varB
Less than or equal to: varA <= varB
这里有一些例子:
2 == 1 //false, 2 does not equal 1
2 != 1 //true, 2 does not equal 1
2 > 1 //true, 2 is greater than 1
2 < 1 //false, 2 is not less than 1
2 >= 1 //true, 2 is greater or equal to 1
2 <= 1 //false, 2 is not less or equal to 1
算术运算符
算术运算符执行四种基本的数学运算。这里有一些原型:
Addition: varA + varB
Subtraction: varA - varB
Multiplication: varA * varB
Division: varA / varB
这里有一些例子:
var x = 4 + 2 //x will equal 6
var x = 4 – 2 //x will equal 2
var x = 4 * 2 //x will equal 8
var x = 4 / 2 //x will equal 2
var x = "Hello " + "world" //x will equal "Hello World"
余数运算符
余数操作符计算第一个操作数除以第二个操作数后的余数。在其他语言中,这有时被称为取模或模运算符。
这里有一个原型:
varA % varB
这里有一个例子:
var x = 10 % 3 //x will equal 1
var x = 10 % 6 //x will equal 4
复合赋值运算符
复合赋值运算符将算术运算符与赋值运算符组合起来。
这里有一些原型:
varA += varB
varA -= varB
varA *= varB
varA /= varB
这里有一些例子:
var x = 6
x += 2 //x now is 8
x -= 2 //x now is 4
x *= 2 //x now is 12
x /= 2 //x now is 3
闭区间操作符
闭区间操作符定义了一个从第一个数字到第二个数字的范围。数字之间由三个点分隔。
这里有一个原型:
(a...b)
这里有一个例子。注意,我们将在第六章,控制流中介绍for循环:
for i in 1...3 {
print("Number: \(i)")
}
这个例子将打印出以下内容:
Number: 1
Number: 2
Number: 3
半开区间操作符
半开区间操作符定义了一个从第一个数字到第二个数字减一的数字的范围。数字之间由两个点和小于号分隔。
这里有一个原型:
(a..<b)
这里有一个例子:
for i in 1..<3 {
print("Number: \(i)")
}
这个例子将打印出以下内容:
Number: 1
Number: 2
注意,在闭区间操作符中,打印出了行Number: 3,但在半开区间操作符中却没有。
此外,还有我们与数组一起使用的一侧范围操作符。我们将在第五章,使用 Swift 集合中探讨这些。
三元条件运算符
三元条件操作符根据比较操作符或布尔值的评估将值赋给一个变量。
这里有一个原型:
(boolValue ? valueA : valueB)
这里有一个例子:
var x = 2
var y = 3
var z = (y >x ? "Y is greater" : "X is greater") //z equals "Y is greater"
逻辑非运算符
逻辑非操作符反转一个布尔值。这里有一个原型:
varA = !varB
这里有一个例子:
var x = true
var y = !x //y equals false
逻辑与运算符
逻辑与运算符如果两个操作数都为真则返回true;否则返回false。
这里有一个原型:
varA && varB
这里有一个例子:
var x = true
var y = false
var z = x && y //z equals false
逻辑或运算符
逻辑或运算符如果任一操作数为真则返回true。这里有一个原型:
varA || varB
这里有一个例子:
var x = true
var y = false
var z = x|| y //z is true
对于熟悉 C 或类似语言的人来说,这些运算符看起来应该很熟悉。对于那些不太熟悉 C 运算符的人来说,请放心,一旦你开始频繁使用它们,它们将变得像第二本能一样自然。
摘要
在本章中,我们涵盖了从变量和常量到数据类型和运算符的各种主题。本章中的内容将成为你编写每个应用程序的基础;因此,理解我们在这里讨论的概念非常重要。
在本章中,我们了解到当值不会改变时,我们应该优先使用常量而不是变量。如果你设置了一个变量但从未改变其值,Swift 将在编译时给出警告。我们还看到,我们应该优先使用类型推断而不是显式声明类型。
数字和字符串类型,在其他语言中作为原语实现,在 Swift 中是通过结构体实现的命名类型。在未来的章节中,你将看到为什么这一点很重要。本章需要记住的最重要的事情之一是,如果一个变量包含一个 nil 值,你必须将其声明为可选的。
在下一章中,我们将探讨 Swift 的可选类型。如果你习惯于不使用可选类型的语言,Swift 中的可选类型可能是一个最难掌握的概念。
第四章:可选类型
当我开始使用 Swift 时,我最难以理解的概念是可选类型。由于我的背景是 Objective-C、C、Java 和 Python,我能够将 Swift 的许多功能与我了解的其他语言中的工作方式联系起来,但可选类型是不同的。当 Swift 首次发布时,我所使用的其他语言中几乎没有类似的可选类型,因此我花了很多时间去完全理解它们。
在本章中,我们将涵盖以下主题:
-
可选类型是什么?
-
为什么我们需要在 Swift 中使用可选类型?
-
如何解包可选类型
-
什么是可选绑定?
-
可选链是什么?
介绍可选类型
当我们在 Swift 中声明变量时,它们默认是非可选的,这意味着它们必须包含一个有效、非 nil 的值。如果我们尝试将非可选变量设置为 nil,将会导致错误。
例如,以下代码在尝试将 message 变量设置为 nil 时将抛出错误,因为它是一个非可选类型:
var message: String = "My String"
message = nil
非常重要的是要理解 Swift 中的 nil 与 Objective-C 或其他基于 C 的语言中的 nil 非常不同。在这些语言中,nil 是指向一个不存在对象的指针;然而,在 Swift 中,nil 值表示没有值。掌握这个概念对于完全理解 Swift 中的可选类型非常重要。
被定义为可选的变量可以包含一个有效值,或者它可以表示没有值。我们通过将其分配一个特殊的 nil 值来表示没有值。任何类型的可选都可以设置为 nil,而在 Objective-C 中,只有对象可以被设置为 nil。
要真正理解可选背后的概念,让我们看看定义可选类型的一行代码:
var myString: String?
行尾的问号表示 myString 变量是一个可选类型。我们读取这一行代码时,可以说 myString 变量是一个可选类型,它可能包含 string 类型的值,也可能不包含任何值。如何编写这一行对于理解可选的工作方式非常重要。
可选是 Swift 中的一个特殊类型。当我们定义 myString 变量时,我们实际上定义了它为一个可选类型。为了理解这一点,让我们看看一些更多的代码:
var myString1: String?
var myString2: Optional<String>
这两个声明是等效的。两行都声明了一个可能包含 String 类型或可能没有值的可选类型。在 Swift 中,我们可以将值的缺失视为设置为 nil,但始终记住这与在 Objective-C 中将某物设置为 nil 是不同的。在这本书中,当我们提到 nil 时,我们是指 Swift 如何使用 nil,而不是 Objective-C 如何使用 nil。
可选类型是一个有两个可能值的枚举,None和Some(T),其中T是与可选相关联的泛型值。我们将在第十一章,泛型中讨论泛型。如果我们将可选设置为nil,它将具有None的值,如果我们设置一个值,可选将具有Some的值,并带有相关联的适当类型的值。在第三章,了解变量、常量、字符串和运算符中,我们解释了 Swift 中的枚举可能具有关联值。关联值允许我们在枚举成员值中存储额外的信息。
在内部,可选类型被定义为如下:
enum Optional<T>
{
case None
case Some(T)
}
在这里,T是与可选相关联的类型。T符号用于定义泛型,可以用来表示任何类型。
Swift 中可选类型的需求
现在,一个迫切的问题:为什么 Swift 需要可选类型?为了理解这个问题,我们应该检查可选类型旨在解决哪些问题。
在大多数语言中,可以在不提供初始化值的情况下创建一个变量。例如,在 Objective-C 中,这两行代码都是有效的:
int i;
MyObject *m;
现在,假设用 Objective-C 编写的MyObject类有以下方法:
-(int)myMethodWithValue:(int)i {
return i*2;
}
此方法接受从i参数传递的值,将其乘以二,并返回结果。让我们尝试使用以下代码调用此方法:
MyObject *m;
NSLog(@"Value: %d",[m myMethodWithValue:5]);
我们的第一反应可能认为此代码将显示Value: 10,因为我们传递了5的值给一个将值加倍的方法;然而,这是不正确的。实际上,此代码将显示Value: 0,因为我们没有在使用之前初始化m对象。
当我们忘记初始化一个对象或为变量设置值时,我们可能会在运行时得到意外的结果,就像我们刚才演示的那样。有时,这些意外的结果可能非常难以追踪。
使用可选类型,Swift 能够在编译时检测到此类问题,并在它成为运行时问题之前提醒我们。如果我们期望在使用之前变量或对象始终包含一个值,我们将变量声明为非可选(这是默认声明)。然后,如果我们尝试在使用之前初始化它,我们将收到一个错误。让我们看看这个例子。以下代码将显示一个错误,因为我们试图在使用之前使用一个非可选变量:
var myString: String
print(myString)
如果一个变量被声明为可选,在尝试使用它之前验证它是否包含一个有效值是良好的编程实践。我们只应该将变量声明为可选,如果存在一个合理的理由让变量不包含值。这就是 Swift 默认将变量声明为非可选的原因。
现在我们对可选类型及其旨在解决的问题有了更好的理解,让我们看看如何使用它们。
定义可选类型
需要注意的一件事是,我们在变量声明中定义的类型实际上是可选枚举中的关联值。以下代码显示了我们会如何通常声明一个可选值:
var myOptional: String?
这段代码声明了一个可能包含字符串或可能不包含值的可选变量。当声明此类变量时,默认情况下它被设置为 nil。既然我们已经看到了如何定义可选值,让我们看看我们如何使用它。
使用可选值
使用可选值的关键是始终在访问之前验证它们是否包含有效值。这样做的原因是,如果我们尝试使用未验证是否包含有效值的可选值,我们可能会遇到运行时错误,导致我们的应用程序崩溃。我们使用术语解包来指代从可选值中检索值的过程。我们将介绍两种用于检索可选值的方法;请记住,使用可选绑定是首选的。
可选值的强制解包
要解包或检索可选值的值,我们在变量名后放置一个感叹号(!)。这被称为强制解包。以这种方式进行的强制解包非常危险,只有在确定变量包含非 nil 值时才应使用。否则,如果它包含 nil 值,我们将得到运行时错误,应用程序将崩溃。
当我们使用感叹号来解包可选值时,我们是在告诉编译器我们知道可选值包含一个值,所以请继续给我们。让我们看看如何做到这一点:
var myString1: String?
myString1 = "test"
var test: String = myString1!
这段代码将按预期工作,其中test变量将包含字符串"test";然而,如果设置myString1可选值为test的行被删除,当应用程序运行时,我们将收到运行时错误。请注意,编译器不会提醒我们问题,因为我们正在使用感叹号来解包可选值;因此,编译器假设我们知道我们在做什么,并会愉快地为我们编译代码。在解包之前,我们应该验证myString1可选值是否包含有效值。以下示例是这样做的一种方式:
var myString1: String?
myString1 = "test"
if myString1 != nil {
var test = myString1!
}
现在,如果设置myString1可选值为test的行被删除,我们不会收到运行时错误,因为我们只有在myString1可选值包含有效(非 nil)值时才会解包它。
解包可选值,正如我们刚才所描述的,并不是最佳做法,也不建议以这种方式解包可选值。我们可以将验证和解包合并为一步,称为可选绑定。
可选绑定
可选绑定是推荐的方式来解包可选值。使用可选绑定,我们执行一个检查以查看可选值是否包含有效值,如果是这样,就将其解包到一个临时变量或常量中。这一切都在一步中完成。
可选绑定使用if或while条件语句执行。如果我们想将可选类型的值放入一个常量中,它将采取以下格式:
if let constantName = optional {
statements
}
如果我们需要将值放入变量中,而不是常量,我们可以使用var关键字,如下例所示:
if var variableName = optional {
statements
}
以下示例展示了如何执行可选绑定:
var myString3: String?
myString3 = "Space, the final frontier"
if let tempVar = myString3 {
print(tempVar)
} else {
print("No value")
}
在示例中,我们将myString3变量定义为可选类型。如果myString3可选类型包含一个有效值,新变量tempvar将被设置为该值并打印到控制台。如果myString3可选类型不包含值,则控制台将打印No value。
我们能够在同一可选绑定行中解包多个可选类型。例如,如果我们有三个名为optional1、optional2和optional3的可选类型,我们可以使用以下代码一次性尝试解包所有三个:
if let tmp1 = optional1, let tmp2 = optional2, let tmp3 = optional3 {
}
如果其中任何一个可选类型是nil,整个可选绑定语句将失败。使用可选绑定将值赋给同名的变量也是完全可以接受的。以下代码说明了这一点:
if let myOptional = myOptional {
print(myOptional)
} else {
print("myOptional was nil")
}
注意一点是,临时变量仅限于条件块的作用域,不能在块外使用。为了说明临时变量的作用域,让我们看看以下代码:
var myOptional: String?
myOptional = "test"
if var tmp = myOptional {
print("Inside:\(tmp)")
}
// This next line will cause a compile time error
print("Outside: \(tmp)")
此代码将无法编译,因为tmp变量仅在条件块内有效,而我们试图在块外使用它。
使用可选绑定比手动验证可选类型是否有值并使用强制解包来检索可选类型的值要干净和简单得多。我们可以以不同的方式使用元组中的可选类型,让我们来看看这些。
元组中的可选类型
我们可以将整个元组定义为可选类型,或者将元组中的任何元素定义为可选类型。当从函数或方法返回元组时,使用可选类型与元组一起特别有用。这允许我们将元组的部分(或全部)作为nil返回。以下示例展示了如何将元组定义为可选类型,以及如何将元组的单个元素定义为可选类型:
var tuple1: (one: String, two: Int)?
var tuple2: (one: String, two: Int?)
第一行定义整个元组为一个可选类型。第二行定义元组中的第二个值为可选,而第一个值不是可选的。
可选链
可选链允许我们在可能为nil的可选类型上调用属性、方法和下标。如果链中的任何值返回nil,则返回值将为nil。以下代码给出了使用虚构的car对象进行可选链的示例。在这个例子中,如果car或tires可选变量中的任何一个为nil,则tireSize变量将为nil,否则tireSize变量将等于tireSize属性:
var tireSize = car?.tires?.tireSize
我们将在第八章“类、结构和协议”中再次探讨可选链。
空合并运算符
空合并运算符与我们在第三章,了解变量、常量、字符串和运算符中讨论的三元运算符类似。三元运算符根据比较运算符或布尔值的评估给变量赋值。空合并运算符尝试展开一个可选值,如果它包含一个值,它将返回该值,或者在可选值为 nil 时返回默认值,如下面的代码所示。
让我们看看空合并运算符的原型:
optionalA ?? defaultValue
在这个例子中,我们演示了当可选值为 nil 以及它包含值时如何使用空合并运算符:
var defaultName = "Jon"
var optionalA: String?
var optionalB: String?
optionalB = "Buddy"
var nameA = optionalA ?? defaultName
var nameB = optionalB ?? defaultName
在这个例子中,我们首先将defaultName变量初始化为Jon。然后我们定义了两个可选值,分别命名为optionalA和optionalB。optionalA变量被设置为 nil,而optionalB变量被设置为Buddy。
空合并运算符用于最后两行。由于optionalA变量包含 nil,nameA变量将被设置为defaultName变量的值,即Jon。nameB变量将被设置为optionalB变量的值,因为它包含一个值。
空合并运算符是以下使用三元运算符的简写:
var nameC = optionalA != nil ? optionalA! : defaultName
如我们所见,空合并运算符比等效的三元运算符更简洁、更容易阅读。
摘要
在本章中,我们描述了可选值实际上是什么以及它们在 Swift 语言内部是如何定义的。理解这个概念很重要,因为可选值在 Swift 中使用得很多,了解它们是如何在内部工作的将有助于你正确地使用它们。虽然 Swift 语言中使用的可选类型的概念一开始可能有些令人困惑,但随着你使用它们的次数越多,它们就会变得更有意义。可选类型的一个最大优点是额外的编译时检查,它会提醒我们在使用之前忘记初始化非可选值。我们将在本书的后面部分看到更多关于可选值的示例。
在下一章中,我们将探讨如何使用集合。
第五章:使用 Swift 集合
一旦我超越了基本的“Hello, World!”入门级应用程序,我很快就意识到变量的不足,尤其是在我开始编写的 Mad Libs 风格的应用程序中。这些应用程序要求用户输入多个字符串,这导致了为用户输入的每个输入字段创建单独的变量。
拥有所有这些单独的变量很快就变得繁琐。我记得和一个朋友谈论过这件事,他问我为什么不用数组。当时,我对数组不太熟悉,所以我让他给我展示一下它们是什么。尽管他有一个 TI-99/4A,而我有一个 Commodore Vic-20,但数组的概念是相同的。即使今天,现代开发语言中的数组也和我当年在 Commodore Vic-20 上使用的数组有相同的基本概念。当然,不使用集合,如数组,也可以创建有用的应用程序,但正确使用集合可以显著简化应用程序开发。
在本章中,我们将涵盖以下主题:
-
Swift 中数组是什么以及如何使用它
-
Swift 中字典是什么以及如何使用它
-
Swift 中集合是什么以及如何使用它
Swift 集合类型
集合将多个项目组合成一个单元。Swift 提供了三种原生集合类型。这些集合类型是数组、字典和集合。数组按顺序存储数据,字典是无序的键值对集合,而集合是无序的唯一值集合。在数组中,我们通过数组内的位置或索引来访问数据,而在集合中我们通常遍历集合,字典则是通过唯一键来访问。
存储在 Swift 集合中的数据必须是同一类型。这意味着,例如,我们无法在整数数组中存储字符串值。由于 Swift 不允许我们在集合中混合数据类型,因此当我们从集合中检索元素时,我们可以确定数据类型。这是另一个表面上可能看起来像是一个缺点,但实际上有助于消除常见的编程错误。
让我们从查看集合的可变性开始。
可变性
对于熟悉 Objective-C 的人来说,你们会知道存在不同的类来表示可变和不可变集合。例如,要定义一个可变数组,我们使用 NSMutableArray 类,而要定义一个不可变数组,我们使用 NSArray 类。Swift 稍有不同,因为它不包含用于可变和不可变集合的单独类。相反,我们通过使用 let 和 var 关键字来定义集合是常量(不可变)还是变量(可变)。这应该看起来很熟悉,因为我们用 let 关键字定义常量,用 var 关键字定义变量。
除非有特定的需要更改集合中的对象,否则创建不可变集合是一个好的实践。这允许编译器优化性能。
让我们从查看最常见的集合类型:数组类型开始,来开始我们的集合之旅。
数组
数组几乎可以在所有现代编程语言中找到。在 Swift 中,数组是相同类型对象的有序列表。
当创建一个数组时,我们必须通过显式类型声明或通过类型推断来声明可以存储在其中的数据类型。通常,我们只有在创建一个空数组时才会显式声明数组的数据类型。如果我们用数据初始化一个数组,编译器会使用类型推断来推断数组的数据类型。
数组中的每个对象称为元素。这些元素按顺序存储,可以通过在数组中搜索其位置(索引)来访问。
创建和初始化数组
我们可以使用数组字面量来初始化一个数组。数组字面量是一组预填充数组的值。以下示例展示了如何使用let关键字定义一个不可变的整数数组:
let arrayOne = [1,2,3]
如果我们需要创建一个可变数组,我们会使用var关键字来定义数组,就像我们定义标准变量一样。以下示例展示了如何定义一个可变数组:
var arrayTwo = [4,5,6]
在前面的两个示例中,编译器通过查看数组字面量中存储的值的类型来推断数组中存储的值的类型。如果我们想创建一个空数组,我们需要显式声明要存储在数组中的值的类型。Swift 中有两种声明空数组的方法。以下示例展示了如何声明一个可以用来存储整数的空可变数组:
var arrayThree = [Int]()
var arrayThree: [Int] = []
在前面的示例中,我们创建了包含整数值的数组,本章中的大多数数组示例也将使用整数值;然而,我们可以在 Swift 中使用任何类型的数组。唯一的规则是,一旦定义了一个数组包含特定类型,数组中的所有元素都必须是那种类型。以下示例展示了我们如何创建各种数据类型的数组:
var arrayOne = [String]()
var arrayTwo = [Double]()
var arrayThree = [MyObject]()
Swift 为处理非特定类型提供了特殊的类型别名。这些别名是AnyObject和Any。我们可以使用这些别名来定义元素类型不同的数组,如下所示:
var myArray: [Any] = [1,"Two"]
AnyObject别名可以表示任何类类型的实例,而Any别名可以表示任何类型的实例,包括函数类型。我们应该只在有明确需要这种行为时使用Any和AnyObject别名。始终明确我们集合中包含的数据类型是更好的做法。
如果需要在单个集合中混合类型,我们可以考虑使用元组。
数组也可以初始化为特定大小,并将所有元素设置为预定义的值。如果我们想创建一个数组并预先填充默认值,这会非常有用。以下示例定义了一个包含7个元素的数组,每个元素包含数字3:
var arrayFour = Int
从 Swift 5.1 开始,随着 SE-0245 的引入,我们有了创建未初始化数组的能力。使用此功能,我们不需要用默认值填充数组,而是可以根据需要提供一个闭包来填充数组。我们将在第十四章,使用闭包中展示这一功能。
虽然最常见的数组是一维数组,但也可以创建多维数组。实际上,多维数组不过是一个数组的数组。例如,二维数组是一个数组的数组,而三维数组则是一个数组的数组的数组。以下示例展示了在 Swift 中创建二维数组的两种方式:
var multiArrayOne = [[1,2],[3,4],[5,6]]
var multiArrayTwo = [[Int]]()
现在我们已经看到了如何初始化数组,让我们看看我们如何访问数组的元素。
访问数组元素
下标语法用于从数组中检索值。对于数组,下标语法是指一个数字出现在两个方括号之间,该数字指定了我们想要检索的元素在数组中的位置(索引)。以下示例展示了如何使用下标语法从数组中检索元素:
let arrayOne = [1,2,3,4,5,6]
print(arrayOne[0]) //Displays '1'
print(arrayOne[3]) //Displays '4'
在前面的代码中,我们创建了一个包含六个整数的数组。然后我们打印出索引0和3处的值。
需要注意的一个重要事实是,Swift 数组中的索引从数字0开始。这意味着数组中的第一个元素的索引为0。数组中的第二个元素的索引为1。
如果我们想要从多维数组中检索单个值,我们需要为数组的每个维度提供一个下标。如果我们不为每个维度提供下标,我们将检索数组而不是数组中的单个值。以下示例展示了我们如何定义一个二维数组并从两个维度中检索单个值:
let multiArray = [[1,2],[3,4],[5,6]]
let arr = multiArray[0] //arr contains the array [1,2]
let value = multiArray[0][1] //value contains 2
在前面的代码中,我们首先定义了一个二维数组。当我们检索第一维索引0的值(multiArray[0])时,我们检索到数组[1,2]。当我们检索第一维索引0和第二维索引1的值(multiArray[0][1])时,我们检索到整数2。
我们可以使用first和last属性来检索数组的第一个和最后一个元素。由于数组可能为空,因此first和last属性返回一个可选值。以下示例展示了如何使用这些属性来检索一维和二维数组的第一个和最后一个元素:
let arrayOne = [1,2,3,4,5,6]
let first = arrayOne.first //first contains
let last = arrayOne.last //last contains 6
let multiArray = [[1,2],[3,4],[5,6]]
let arrFirst1 = multiArray[0].first //arrFirst1 contains 1
let arrFirst2 = multiArray.first //arrFirst2 contains[1,2]
let arrLast1 = multiArray[0].last //arrLast1 contains 2
let arrLast2 = multiArray.last //arrLast2 contains [5,6]
现在让我们看看我们如何计算数组的元素数量。
计算数组元素的数量
有时,知道数组中元素的数量是至关重要的。Swift 中的数组类型包含一个只读的count属性。以下示例展示了如何使用此属性来检索单维和多维数组中的元素数量:
let arrayOne = [1,2,3]
let multiArrayOne = [[3,4],[5,6],[7,8]]
print(arrayOne.count) //Displays 3
print(multiArrayOne.count) //Displays 3 for the three array
print(multiArrayOne[0].count) //Displays 2 for the two elements
count属性返回的值是数组中的元素数量,而不是数组的最大有效索引。对于非空数组,最大有效索引是数组元素数量减 1。这是因为数组的第一个元素具有索引号0。例如,如果一个数组有两个元素,有效的索引是0和1,而count属性将返回2。以下代码说明了这一点:
let arrayOne = [0,1]
print(arrayOne[0]) //Displays 0
print(arrayOne[1]) //Displays 1
print(arrayOne.count) //Displays 2
如果我们尝试从数组中检索超出数组范围的元素,应用程序将抛出数组索引越界错误。因此,如果我们不确定数组的大小,验证索引是否不在数组范围之外是一个好的做法。以下示例说明了这个概念:
//This example will throw an array index out of range error
let arrayOne = [1,2,3,4]
print(arrayOne[6])
//This example will not throw an array index out of range error
let arrayTwo = [1,2,3,4]
if (arrayTwo.count > 6) {
print(arrayTwo[6])
}
在前面的代码中,第一个块将抛出数组索引越界错误,因为我们试图从arrayOne数组中索引6处访问值;然而,数组中只有四个元素。第二个示例不会抛出错误,因为我们试图在尝试访问第六个索引之前检查arrayTwo数组是否包含超过六个元素。
数组是否为空?
要检查数组是否为空(即不包含任何元素),我们使用isEmpty属性。如果数组为空,则此属性将返回true;如果不为空,则返回false。以下示例展示了如何检查数组是否为空:
var arrayOne = [1,2]
var arrayTwo = [Int]()
arrayOne.isEmpty //Returns false because the array is not empty
arrayTwo.isEmpty //Returns true because the array is empty
现在让我们看看我们如何可以打乱数组。
打乱数组
数组可以非常容易地使用shuffle()和shuffled()方法进行打乱。如果我们正在创建一个游戏,例如一副 52 张牌的牌局游戏,其中数组包含牌组中的所有牌,这将非常有用。要就地打乱数组,可以使用shuffle()方法;要将打乱的结果放入一个新数组中,同时保持原始数组不变,则应使用shuffled()方法。以下示例展示了这一点:
var arrayOne = [1,2,3,4,5,6]
arrayOne.shuffle()
let shuffledArray = arrayOne.shuffled()
现在让我们看看我们如何将数据添加到数组中。
向数组中添加元素
静态数组有一定的用途,但能够动态地添加元素才是数组真正有用的地方。要将一个项目添加到数组的末尾,我们可以使用append方法。以下示例展示了如何将一个项目添加到数组的末尾:
var arrayOne = [1,2]
arrayOne.append(3) //arrayOne will now contain 1, 2 and 3
Swift 还允许我们使用加法赋值运算符(+=)将一个数组添加到另一个数组中。以下示例展示了如何使用加法赋值运算符将一个数组添加到另一个数组的末尾:
var arrayOne = [1,2]
arrayOne += [3,4] //arrayOne will now contain 1, 2, 3 and 4
你将元素追加到数组末尾的方式完全取决于你。我个人更喜欢赋值操作符,因为它对我来说更容易阅读,但在这本书中我们将使用两种方法。
将值插入到数组中
我们可以通过使用 insert 方法将值插入到数组中。insert 方法将所有项目向上移动一个位置,从指定的索引开始,为新元素腾出空间,然后将值插入到指定的索引。以下示例展示了如何使用此方法将新值插入到数组中:
var arrayOne = [1,2,3,4,5]
arrayOne.insert(10, at: 3) //arrayOne now contains 1, 2, 3, 10, 4 and 5
现在我们已经看到了如何插入一个值,让我们看看我们如何可以在数组中替换一个元素。
在数组中替换元素
我们使用下标语法在数组中替换元素。使用下标,我们选择要更新的数组元素,然后使用赋值操作符分配新值。以下示例展示了我们如何在数组中替换一个值:
var arrayOne = [1,2,3]
arrayOne[1] = 10 //arrayOne now contains 1,10,3
你不能更新数组当前范围之外的值。尝试这样做将抛出与我们在尝试将值插入到数组范围之外时抛出的相同的索引越界异常。
现在让我们看看我们如何可以从数组中移除元素。
从数组中移除元素
我们可以使用三种方法来移除数组中的一个或所有元素。这些方法是 removeLast()、remove(at:) 和 removeAll()。以下示例展示了如何使用这三种方法从数组中移除元素:
var arrayOne = [1,2,3,4,5]
arrayOne.removeLast() //arrayOne now contains 1, 2, 3 and 4
arrayOne.remove(at:2) //arrayOne now contains 1, 2 and 4
arrayOne.removeAll() //arrayOne is now empty
removeLast() 和 remove(at:) 方法也会返回被移除元素的值。因此,如果我们想知道被移除项的值,我们可以重写 remove(at:) 和 removeLast() 行来捕获值,如下例所示:
var arrayOne = [1,2,3,4,5]
var removed1 = arrayOne.removeLast() //removed1 contains the value 5
var removed = arrayOne.remove(at: 2) //removed contains the value 3
合并两个数组
要通过将两个数组相加来创建一个新数组,我们使用加法(+)操作符。以下示例展示了如何使用加法(+)操作符创建一个包含两个其他数组所有元素的新数组:
let arrayOne = [1,2] let arrayTwo = [3,4]
var combined = arrayOne + arrayTwo //combine contains 1, 2, 3 and 4
在前面的代码中,arrayOne 和 arrayTwo 保持不变,而 combined 数组包含来自 arrayOne 的元素,随后是来自 arrayTwo 的元素。
从数组中检索子数组
我们可以通过使用带范围操作符的下标语法从现有数组中检索子数组。以下示例展示了如何从现有数组中检索一系列元素:
let arrayOne = [1,2,3,4,5]
var subArray = arrayOne[2...4] //subArray contains 3, 4 and 5
操作符(三个点)被称为双边范围操作符。前面代码中的范围操作符表示我们想要从 2 到 4(包括元素 2 和 4 以及它们之间的所有元素)的所有元素。还有一个双边范围操作符,..<,被称为半开范围操作符。半开范围操作符的功能与前面的范围操作符相同;然而,它排除了最后一个元素。以下示例展示了如何使用 ..< 操作符:
let arrayOne = [1,2,3,4,5]
var subArray = arrayOne[2..<4] //subArray contains 3 and 4
在前面的例子中,子数组包含两个元素:3 和 4。双向范围操作符在操作符两侧都有数字。在 Swift 中,我们不仅限于使用双向范围操作符;我们还可以使用单侧范围操作符。以下示例展示了我们如何使用单侧范围操作符:
let arrayOne = [1,2,3,4,5]
var a = arrayOne[..<3] //subArray contains 1, 2 and 3
var b = arrayOne[...3] //subArray contains 1, 2, 3 and 4
var c = arrayOne[2...] //subArray contains 3, 4 and 5
单侧范围操作符是在 Swift 语言的第 4 版中添加的。之前的范围操作符使我们能够从数组中访问连续的元素范围。
SE-0270 允许我们获取非连续的元素,这意味着元素可能不是相邻的。这个 Swift 标准库的更新引入了一个新的 RangeSet 类型,它是一个非连续索引的子范围。让我们看看以下代码是如何工作的:
var numbers = [1,2,3,4,5,6,7,8,9,10]
let evenNum = numbers.subranges(where: { $0.isMultiple(of: 2) })
print(numbers[evenNum].count)
//numbers[evenNum] contains 2,4,6,8,10
在此代码中,我们定义了一个包含数字 1 到 10 的数组。然后我们使用 subranges(where:) 方法检索偶数元素。此方法接受一个闭包作为参数,这尚未讨论。现在我们只需要知道我们能够检索非连续的子数组,我们将在第十四章 使用闭包 中再次讨论这一点。
批量更改数组
我们可以使用范围操作符的索引语法来更改多个元素的值。以下示例展示了如何进行此操作:
arrayOne[1...2] = [12,13] //arrayOne contains 1,12,13,4 and 5
在前面的代码中,索引 1 和 2 处的元素将被更改为数字 12 和 13;因此,arrayOne 将包含 1、12、13、4 和 5。
在范围操作符中更改的元素数量不需要与传递的值的数量相匹配。Swift 通过首先移除由范围操作符定义的元素,然后插入新值来执行批量更改。以下示例演示了这一概念:
var arrayOne = [1,2,3,4,5]
arrayOne[1...3] = [12,13] //arrayOne now contains 1, 12, 13 and 5
在前面的代码中,arrayOne 数组开始时有五个元素。然后我们替换从 1 到 3(包括 3)的元素范围。这首先会导致从数组中移除 1 到 3(即三个元素)。在这三个元素被移除之后,然后向数组中添加两个新元素(12 和 13),从索引 1 开始。完成此操作后,arrayOne 将包含四个元素:1、12、13 和 5。使用相同的逻辑,我们也可以添加比移除更多的元素。以下示例说明了这一点:
var arrayOne = [1,2,3,4,5]
arrayOne[1...3] = [12,13,14,15]
//arrayOne now contains 1, 12, 13, 14, 15 and 5 (six elements)
在前面的代码中,arrayOne 数组开始时有五个元素。然后我们说我们想要替换从 1 到 3(包括 3)的元素范围。与前面的例子一样,这会导致从数组中移除 1 到 3(即三个元素)。然后我们在索引 1 处向数组中添加四个元素(12、13、14 和 15)。完成此操作后,arrayOne 将包含六个元素:1、12、13、14、15 和 5。
数组算法
Swift 数组有几个方法,它们接受一个闭包作为参数。这些方法会根据闭包中的代码以某种方式转换数组。闭包是自包含的代码块,可以被传递,类似于 Objective-C 中的 blocks 和其他语言中的 lambdas。我们将在第十四章 使用闭包 中深入讨论闭包。现在,目标是熟悉 Swift 中算法的工作方式。
排序
sort 算法在原地排序数组。这意味着当使用 sort() 方法时,原始数组会被排序后的数组替换。闭包接受两个参数(分别由 $0 和 $1 表示),并且它应该返回一个布尔值,表示第一个元素是否应该放在第二个元素之前。以下代码展示了如何使用排序算法:
var arrayOne = [9,3,6,2,8,5]
arrayOne.sort(){ $0 < $1 }
//arrayOne contains 2,3,5,6,8 and 9
之前的代码将按升序排序数组。我们知道这是因为规则会在第一个数字($0)小于第二个数字($1)时返回 true。因此,当排序算法开始时,它比较前两个数字(9 和 3),如果第一个数字(9)小于第二个数字(3),则返回 true。在我们的例子中,规则返回 false,所以数字被反转。算法以这种方式继续排序,直到所有数字都按正确顺序排序。
要按升序排序数组,我们实际上可以使用 sort() 方法本身,而不使用闭包,如下所示:
var arrayOne = [9,3,6,2,8,5]
arrayOne.sort()
之前的示例按数值递增顺序排序了数组;如果我们想反转顺序,我们会在闭包中反转参数。以下代码展示了如何反转排序顺序:
var arrayOne = [9,3,6,2,8,5]
arrayOne.sort(){ $1 < $0 }
//arrayOne contains 9,8,6,5,3 and 2
当我们运行此代码时,arrayOne 将包含元素 9、8、6、5、3 和 2。
之前的代码可以通过使用 sort(by:) 方法并传入大于或小于运算符来简化,如下所示:
var arrayTwo = [9,3,6,2,8,5]
arrayTwo.sort(by: <)
在之前的代码中,通过使用小于运算符,数组被按升序排序。如果我们使用大于运算符,数组将按降序排序。
排序
虽然排序算法在原地排序数组(即,它替换了原始数组),但 sorted 算法不会改变原始数组;它相反地创建了一个包含原始数组中排序元素的新数组。以下示例展示了如何使用排序算法:
var arrayOne = [9,3,6,2,8,5]
let sorted = arrayOne.sorted(){ $0 < $1 }
//sorted contains 2,3,5,6,8 and 9
//arrayOne contains 9,3,6,2,8 and 5
在运行此代码后,arrayOne 将包含原始未排序的数组(9、3、6、2、8 和 5),而 sorted 数组将包含新的排序后的数组(2、3、5、6、8 和 9)。
过滤
filter 算法将通过过滤原始数组来返回一个新的数组。这是最强大的数组算法之一,最终可能会成为你使用最多的算法之一。如果你需要根据一组规则检索数组的子集,我建议使用此算法而不是尝试编写自己的方法来过滤数组。
闭包接受一个参数,并且它应该返回一个布尔值 true,如果元素应该包含在新的数组中,如下面的代码所示:
var arrayOne = [1,2,3,4,5,6,7,8,9]
let filtered = arrayOne.filter{$0 > 3 && $0 < 7}
//filtered contains 4,5 and 6
在前面的代码中,我们传递给算法的规则是,如果数字大于 3 且小于 7,则返回 true;因此,任何大于 3 且小于 7 的数字都被包含在新的 filtered 数组中。
下一个示例展示了我们如何检索包含其名称中字母 o 的城市子集:
var city = ["Boston", "London", "Chicago", "Atlanta"]
let filteredCity = city.filter{$0.range(of:"o") != nil}
//filtered contains "Boston", "London" and "Chicago"
在前面的代码中,我们使用 range(of:) 方法来返回字符串是否包含字母 o。如果方法返回 true,则字符串被包含在 filtered 数组中。
Map
虽然过滤器算法用于选择数组中的某些元素,但 map 用于对数组中的所有元素应用逻辑。以下示例展示了如何使用 map 算法将每个数字除以 10:
var arrayOne = [10, 20, 30, 40]
let applied = arrayOne.map{ $0 / 10}
//applied contains 1,2,3 and 4
在前面的代码中,新数组包含数字 1、2、3 和 4,这是通过将原始数组中的每个元素除以 10 得到的结果。
由 map 算法创建的新数组不需要包含与原始数组相同的元素类型;然而,新数组中的所有元素必须属于同一类型。在以下示例中,原始数组包含整数值,但由 map 算法创建的新数组包含字符串元素:
var arrayTwo = [1, 2, 3, 4]
let applied = arrayTwo.map{ "num:\($0)"}
//applied contains "num:1", "num:2", "num:3" and "num:4"
在前面的代码中,我们创建了一个字符串数组,该数组将原始数组中的数字追加到 num: 字符串。
计数
我们可以将过滤器算法与 count 方法结合起来,以计算匹配规则的数组中项目数量。例如,如果我们有一个包含测试成绩的数组,我们可以使用 count 算法来计算有多少成绩大于或等于 90,如下所示:
let arrayOne = [95, 90, 75, 80,60]
let count = arrayOne.filter{ $0 >= 90 }.count
正如我们使用过滤器算法一样,我们可以使用数组类型的方法,例如字符串类型的 range(of:) 方法。例如,而不是像在过滤器算法中那样返回包含其名称中字母 o 的城市子集,我们可以这样计数城市:
var city = ["Boston", "London", "Chicago", "Atlanta"]
let count1 = city.filter{$0.range(of:"o") != nil}.count
在前面的计数中,count1 常量包含 3。
Diff
在 Swift 5.1 和 SE-0240 中,引入了 Diff 算法。这个 Swift 语言的补充使得支持有序集合(如数组)的 diff 和修补成为可能。要真正看到这个变化的威力,我们需要了解 switch 语句的工作原理,这在 第六章,控制流 中介绍;因此,我们将简要展示如何使用 applying 方法实现 Diff 算法。我们将在 第六章,控制流 中介绍 switch 语句时更详细地研究这个算法。
让我们看看以下代码:
var scores1 = [100, 81, 95, 98, 99, 65, 87]
var scores2 = [100, 98, 95, 91, 83, 88, 72]
let diff2 = scores2.difference(from: scores1)
var newArray = scores1.applying(diff2) ?? []
在前面的代码中,我们首先创建了两个数组。然后我们使用了 difference(from:) 方法,它返回两个数组之间的差异。这个新数组现在将包含以下值:100,98,95,91,83,88 和 72。返回值是一个枚举集合,告诉我们如何从一个集合生成一个包含另一个集合相同元素的集合。现在这可能不太容易理解,但当我们深入研究时,它将变得清晰。
最后一行使用 applying() 方法将更改应用到 scores1 数组上,并返回一个与 scores2 数组具有相同元素的数组;因此,在调用此方法之后,newArray 数组包含与 scores2 数组相同的元素。
forEach
我们可以使用 forEach 算法遍历一个序列。以下示例展示了我们如何进行操作:
var arrayOne = [10, 20, 30, 40]
arrayOne.forEach{ print($0) }
这个示例将在控制台打印以下结果:
10
20
30
40
虽然使用 forEach 算法非常简单,但它确实有一些限制。推荐遍历数组的方式是使用 for-in 循环,我们将在下一节中看到。
遍历数组
我们可以使用 for-in 循环按顺序遍历数组的所有元素。for-in 循环将为数组的每个元素执行一个或多个语句。我们将在 第六章,控制流 中更详细地讨论 for-in 循环。以下示例展示了我们如何遍历数组的元素:
var arrayOne = ["one", "two", "three"]
for item in arrayOne {
print(item)
}
在前面的例子中,for-in 循环遍历数组,并为数组中的每个元素执行 print(item) 行。如果我们运行此代码,它将在控制台显示以下结果:
one
two
three
有时候我们希望遍历数组,就像前面的例子中那样,但我们还希望知道元素的索引以及其值。为此,我们可以使用数组的 enumerated 方法,它为数组中的每个元素返回一个元组,包含元素的索引和值。以下示例展示了如何使用此函数:
var arrayOne = ["one", "two", "three"]
for (index,value) in arrayOne.enumerated() {
print("\(index) \(value)")
}
前面的代码将在控制台显示以下结果:
one
two
three
现在我们已经在 Swift 中介绍了数组,让我们继续介绍字典。
字典
虽然字典不像数组那样常用,但它们具有额外的功能,这使得它们非常强大。字典是一个容器,存储多个键值对,其中所有键都是同一类型,所有值也都是同一类型。键用作值的唯一标识符。由于我们是通过键而不是值的索引来查找值,因此字典不保证键值对存储的顺序。
字典非常适合存储与唯一标识符相对应的项目,其中唯一标识符应用于检索项目。国家和它们的缩写是字典中可以存储的项目的一个很好的例子。在下面的表中,我们展示了国家和它们的缩写作为键值对:
| 键 | 值 |
|---|---|
| US | 美国 |
| IN | 印度 |
| UK | 英国 |
表 5.1:国家和它们的缩写
创建和初始化字典
我们可以使用字典字面量来初始化字典,这与我们使用数组字面量初始化数组的方式类似。以下示例展示了如何使用前面的图表中的键值对创建字典:
let countries = ["US":"UnitedStates","IN":"India","UK":"UnitedKingdom"]
前面的代码创建了一个不可变的字典,其中包含我们在之前图表中看到的每个键值对。就像数组一样,要创建一个可变的字典,我们需要在let的位置使用var关键字。以下示例展示了如何创建一个包含国家的可变字典:
var countries = ["US":"UnitedStates","IN":"India","UK":"UnitedKingdom"]
在前面的两个示例中,我们创建了一个字典,其中键和值都是字符串。编译器推断出键和值都是字符串,因为这是初始化字典时使用的键和值的类型。如果我们想创建一个空字典,我们需要告诉编译器键和值的类型。以下示例创建具有不同键值类型的各种字典:
var dic1 = [String:String]()
var dic2 = [Int:String]()
var dic3 = [String:MyObject]()
var dic4: [String:String] = [:]
var dic5: [Int:String] = [:]
如果我们想在字典中使用自定义对象作为键,我们需要使自定义对象符合 Swift 标准库中的 Hashable 协议。我们将在本书的后面部分广泛讨论协议,但就现在而言,只需了解可以使用自定义对象作为字典中的键。
现在让我们看看我们如何访问字典的值。
访问字典值
我们使用下标语法来检索特定键的值。如果字典不包含我们正在寻找的键,字典将返回nil;因此,从这个查找返回的变量是一个可选变量。以下示例展示了如何使用下标语法从字典中检索值:
let countries = ["US":"United States", "IN":"India","UK":"UnitedKingdom"]
var name = countries["US"]
在前面的代码中,name变量包含美国字符串。
计算字典中的键或值数量
我们使用字典的count属性来获取字典中键值对的数量。以下示例展示了如何使用此属性:
let countries = ["US":"United States", "IN":"India","UK":"United Kingdom"]
var cnt = countries.count //cnt contains 3
在前面的代码中,cnt变量将包含数字3,因为字典中有三个键值对。
字典是否为空?
要测试一个字典是否包含任何键值对,我们可以使用isEmpty属性。如果字典包含一个或多个键值对,则此属性将返回false;如果它是空的,则返回true。以下示例展示了如何使用此属性来确定我们的字典是否包含任何键值对:
let countries = ["US":"United States", "IN":"India","UK":"United Kingdom"]
var empty = countries.isEmpty
在前面的代码中,isEmpty属性返回false,因为字典中有三个键值对。
更新键的值
要在字典中更新键的值,我们可以使用下标语法或updateValue(_:, forKey:)方法。updateValue(_:, forKey:)方法有一个下标语法没有的附加功能:它在更改值之前返回与键关联的原始值。以下示例展示了如何使用下标语法和updateValue(_:, forKey:)方法来更新键的值:
var countries = ["US":"United States", "IN":"India","UK":"United Kingdom"]
countries["UK"] = "Great Britain"
//The value of UK is now set to "Great Britain"
var orig = countries.updateValue("Britain", forKey: "UK")
//The value of UK is now set to "Britain"
//The orig variable equals "Great Britain"
在前面的代码中,我们使用下标语法将UK键关联的值从United Kingdom更改为Great Britain。在替换之前,我们没有保存United Kingdom的原始值。然后我们使用updateValue(_:, forKey:)方法将UK键关联的值从Great Britain更改为Britain。使用updateValue(_:, forKey:)方法,在字典中更改值之前,将Great Britain的原始值赋给orig变量。
添加键值对
要向字典中添加新的键值对,我们可以使用下标语法或与更新键值相同的方法updateValue(_:, forKey:)。如果我们使用updateValue(_:, forKey:)方法,并且键当前不在字典中,则此方法将添加一个新的键值对并返回nil。以下示例展示了如何使用下标语法和updateValue(_:, forKey:)方法将新的键值对添加到字典中:
var countries = ["US":"United States", "IN":"India","UK":"United Kingdom"]
countries["FR"] = "France" //The value of "FR" is set to"France"
var orig = countries.updateValue("Germany", forKey: "DE")
//The value of "DE" is set to "Germany" and orig is nil
在前面的代码中,countries字典最初有三个键值对,然后我们使用下标语法向字典中添加第四个键值对(FR/France)。我们使用updateValue(_:,forKey:)方法向字典中添加第五个键值对(DE/Germany)。orig变量被设置为nil,因为countries字典没有与DE键关联的值。
删除键值对
有时候我们需要从字典中删除值。有三种方法可以实现这一点:下标语法、removeValue(forKey:)方法或removeAll()方法。removeValue(forKey:)方法在删除之前返回键的值。removeAll()方法从字典中删除所有元素。以下示例展示了如何使用所有三种方法从字典中删除键值对:
var countries = ["US":"UnitedStates","IN":"India","UK":"United Kingdom"]
countries["IN"] = nil //The "IN" key/value pair is removed
var orig = countries.removeValue(forKey:"UK")
//The "UK" key value pair is removed and orig contains "United Kingdom"
countries.removeAll()
//Removes all key/value pairs from the countries dictionary
在前面的代码中,countries 字典最初包含三个键值对。然后我们将与 IN 键关联的值设置为 nil,从而从字典中删除键值对。我们使用 removeValue(forKey:) 方法删除与 UK 键关联的键。在删除与 UK 键关联的值之前,removeValue(forKey:) 方法将值保存在 orig 变量中。最后,我们使用 removeAll() 方法从 countries 字典中删除所有剩余的键值对。
现在我们来看一下集合类型。
集合
集合类型是一个类似于数组类型的泛型集合。虽然数组类型是有序集合,可能包含重复项,但集合类型是无序集合,其中每个元素必须是唯一的。
就像字典中的键一样,存储在数组中的类型必须符合 Hashable 协议。这意味着该类型必须提供一种方法来计算自己的哈希值。Swift 的所有基本类型,如 String、Double、Int 和 Bool,都符合此协议,并且默认情况下可以用于集合。
让我们看看如何使用集合类型。
初始化集合
初始化集合有几种方法。就像数组和字典类型一样,Swift 需要知道将要存储的数据类型。这意味着我们必须告诉 Swift 要在集合中存储的数据类型,或者用一些数据初始化它,以便它可以推断数据类型。
就像数组和字典类型一样,我们使用 var 和 let 关键字来声明集合是否可变:
//Initializes an empty set of the String type
var mySet = Set<String>()
//Initializes a mutable set of the String type with initial values
var mySet = Set(["one", "two", "three"])
//Creates an immutable set of the String type.
let mySet = Set(["one", "two", "three"])
将元素插入到集合中
我们使用 insert 方法将元素插入到集合中。如果我们尝试插入一个已经存在于集合中的元素,该元素将被忽略。以下是将元素插入到集合中的示例:
var mySet = Set<String>()
mySet.insert("One")
mySet.insert("Two")
mySet.insert("Three")
insert() 方法返回一个元组,我们可以使用它来验证值是否成功添加到集合中。以下示例显示了如何检查返回值以查看是否成功添加:
var mySet = Set<String>()
mySet.insert("One")
mySet.insert("Two")
var results = mySet.insert("One")
if results.inserted {
print("Success")
} else {
print("Failed")
}
在这个示例中,由于我们尝试将 One 值添加到已经包含该值的集合中,所以会打印出 Failed 到控制台。
确定集合中的元素数量
我们可以使用 count 属性来确定集合中的元素数量。以下是如何使用此方法的示例:
var mySet = Set<String>()
mySet.insert("One")
mySet.insert("Two")
mySet.insert("Three")
print("\(mySet.count) items")
当执行此代码时,它将在控制台打印出消息 3 items,因为集合包含三个元素。
检查集合是否包含一个元素
我们可以使用 contains() 方法来验证集合是否包含一个元素,如下所示:
var mySet = Set<String>()
mySet.insert("One")
mySet.insert("Two")
mySet.insert("Three")
var contain = mySet.contains("Two")
在前面的示例中,contain 变量被设置为 true,因为集合包含一个值为 Two 的字符串。
遍历集合
我们可以使用 for-in 语句遍历集合中的元素,就像我们处理数组一样。以下示例显示了如何遍历集合中的元素:
for item in mySet {
print(item)
}
前面的示例将打印出集合中的每个元素到控制台。
移除集合中的项目
我们可以移除集合中的一个项目或所有项目。要移除单个项目,我们会使用remove()方法,要移除所有项目,我们会使用removeAll()方法。以下示例展示了如何从集合中移除项目:
//The remove method will return and remove an item from a set
var item = mySet.remove("Two")
//The removeAll method will remove all items from a set
mySet.removeAll()
集合操作
苹果提供了四种方法,我们可以使用这些方法从两个其他集合中构建一个集合。这些操作可以在一个集合上就地执行,或者用于创建一个新的集合。这些操作如下:
-
union和formUnion:这些方法创建一个集合,包含两个集合的所有唯一值,可以理解为移除了重复项。 -
subtracting和subtract:这些方法创建一个集合,包含第一个集合中不在第二个集合中的值。 -
intersection和formIntersection:这些方法创建一个集合,包含两个集合共有的值。 -
symmetricDifference和formSymmetricDifference:这些方法创建一个新集合,包含在任一集合中但不在两个集合中的值。
让我们看看一些例子,看看可以从这些操作中获得哪些结果。对于所有集合操作的例子,我们将使用以下两个集合:
var mySet1 = Set(["One", "Two", "Three", "abc"])
var mySet2 = Set(["abc","def","ghi", "One"])
第一个例子使用了union方法。该方法从两个集合中提取唯一值以创建另一个集合:
var newSetUnion = mySet1.union(mySet2)
newSetUnion变量将包含以下值:One、Two、Three、abc、def和ghi。我们可以使用formUnion方法就地执行union函数,而不创建一个新的集合:
mySet1.formUnion(mySet2)
在这个例子中,mySet1set集合将包含mySet1和mySet2集合的所有唯一值。
现在让我们看看subtract和subtracting方法。这些方法将创建一个集合,包含第一个集合中不在第二个集合中的值:
var newSetSubtract = mySet1.subtracting(mySet2)
在这个例子中,newSetSubtract变量将包含Two和Three值,因为这两个值是唯一不在第二个集合中的值。
我们使用subtract方法就地执行减法函数,而不创建一个新的集合:
mySet1.subtract(mySet2)
在这个例子中,mySet1集合将包含Two和Three值,因为这两个值是唯一不在mySet2集合中的值。
现在让我们看看intersection方法,它通过创建一个新集合,从两个集合中提取共同的值:
var newSetIntersect = mySet1.intersection(mySet2)
在这个例子中,newSetIntersect变量将包含One和abc值,因为它们是两个集合共有的值。
我们可以使用formIntersection()方法就地执行交集函数,而不创建一个新的集合:
mySet1.formIntersection(mySet2)
在这个例子中,mySet1集合将包含One和abc值,因为它们是两个集合共有的值。
最后,让我们看看symmetricDifference()方法。这些方法将创建一个新集合,包含在任一集合中但不在两个集合中的值:
var newSetExclusiveOr = mySet1.symmetricDifference(mySet2)
在这个例子中,newSetExclusiveOr变量将包含Two、Three、def和ghi值。
在原地执行此方法,我们使用 fromSymmetricDifference() 方法:
mySet1.formSymmetricDifference(mySet2)
这四种操作(并集、减集、交集和对称差集)增加了数组所不具备的功能。与数组相比,集合类型具有更快的查找速度,当集合的顺序不重要且集合中的实例必须是唯一的时候,集合类型可以是一个非常有用的替代方案。
摘要
在本章中,我们介绍了 Swift 集合。对 Swift 的原生集合类型有良好的理解对于架构和开发 Swift 应用程序至关重要,因为除了最基本的应用程序之外,所有应用程序都使用它们。
Swift 的三种集合类型是数组、集合和字典。数组以有序集合的形式存储数据。集合以无序集合的形式存储唯一值。字典以无序集合的形式存储键值对。
在下一章中,我们将探讨如何使用 Swift 的控制流语句。
第六章:控制流程
当我还是个青少年时,每个月在我用 Commodore Vic-20 学习 BASIC 编程的时候,我都会阅读几本早期的计算机杂志,比如 Byte Magazine。我记得有一篇关于一款名为 Zork 的游戏的评论。虽然 Zork 并不是为我的 Vic-20 可用的游戏,但这个游戏的概念深深吸引了我,因为我真的很喜欢科幻和奇幻。我记得在想,写一个那样的游戏会多么酷,所以我决定找出如何做到这一点。当时我必须掌握的最大概念之一就是如何根据用户的操作来控制应用程序的流程。
在本章中,我们将涵盖以下主题:
-
条件语句是什么以及如何使用它们
-
循环是什么以及如何使用它们
-
控制转移语句是什么以及如何使用它们
我们到目前为止学到了什么?
到目前为止,我们一直在为使用 Swift 编写应用程序打下基础。虽然我们可以用我们目前学到的东西编写一个非常基础的程序,但仅使用我们前五章所涵盖的内容来编写一个有用的应用程序将会很困难。
从本章开始,我们将开始从 Swift 语言的根基中走出来,开始学习使用 Swift 进行应用程序开发的构建块。在本章中,我们将讨论控制流程语句。要成为 Swift 编程语言的专家,理解本章中讨论的概念至关重要。
在我们讨论控制流程和函数之前,让我们看看在 Swift 中括号和括号是如何使用的。
括号
在 Swift 中,与其它 C 类语言不同,条件语句和循环需要使用括号。在其它 C 类语言中,如果条件语句或循环只有一个要执行的语句,那么围绕该行的括号是可选的。这导致了无数的错误和漏洞,比如苹果的 goto fail 漏洞。当苹果设计 Swift 时,他们决定引入括号的使用,即使只有一行代码要执行也是如此。让我们看看一些说明这一要求的代码示例。第一个示例在 Swift 中是无效的,因为它缺少括号;然而,它将在大多数其它语言中是有效的:
if (x > y)
x=0
在 Swift 中,你需要像以下示例中那样使用括号:
if (x > y) {
x=0
}
括号
与其它 C 类语言不同,Swift 中条件表达式的括号是可选的。在上面的示例中,我们围绕条件表达式使用了括号,但它们不是必需的。以下示例在 Swift 中是有效的,但在大多数 C 类语言中则不是:
if x > y {
x=0
}
控制流程
控制流,也称为控制流程,指的是在应用程序中语句、指令和函数执行的顺序。Swift 支持大多数在 C 类语言中使用的熟悉控制流语句。
这些包括循环(如 while)、条件语句(包括 if、switch 和 guard)以及控制转移语句(包括 break 和 continue)。值得注意的是,Swift 不包括传统的 C for 循环,而不是传统的 do-while 循环,Swift 有 repeat-while 循环。
除了标准的 C 控制流语句外,Swift 还包括 for-in 循环等语句,并增强了一些现有语句,例如 switch 语句。
让我们从查看 Swift 中的条件语句开始。
条件语句
条件语句检查一个条件,并且只有当条件为 true 时才执行代码块。Swift 提供了 if 和 if...else 条件语句。让我们看看如何使用这些条件语句在指定的条件为 true 时执行代码块。
if 语句
if 语句会检查一个条件语句,如果它是 true,它将执行代码块。这个语句采用以下格式:
if condition {
block of code
}
现在,让我们看看如何使用 if 语句:
let teamOneScore = 7
let teamTwoScore = 6
if teamOneScore > teamTwoScore {
print("Team One Won")
}
在前面的例子中,我们首先设置了 teamOneScore 和 teamTwoScore 常量。然后我们使用 if 语句来检查 teamOneScore 的值是否大于 teamTwoScore 的值。如果值更大,我们将 Team One Won 打印到控制台。当运行此代码时,我们确实会看到 Team One Won 被打印到控制台,但如果 teamTwoScore 的值大于 teamOneScore 的值,则不会打印任何内容。这不是编写应用程序的最佳方式,因为我们希望用户知道哪个队伍实际上赢得了比赛。if...else 语句可以帮助我们解决这个问题。
使用 if...else 语句进行条件代码执行
if...else 语句检查一个条件语句,如果它是 true,它将执行一个代码块。如果条件语句不是 true,它将执行一个单独的代码块;这个语句采用以下格式:
if condition {
block of code if true
} else {
block of code if not true
}
现在我们修改前面的例子,使用 if...else 语句来告诉用户哪个队伍赢得了比赛:
let teamOneScore = 7
let teamTwoScore = 6
if teamOneScore > teamTwoScore{
print("Team One Won")
} else {
print("Team Two Won")
}
这个新版本将打印出 Team One Won(队伍一获胜),如果 teamOneScore 的值大于 teamTwoScore 的值;否则,它将打印出消息,Team Two Won(队伍二获胜)。
这解决了我们代码中的一个问题,但如果你认为teamOneScore的值等于teamTwoScore的值时,代码会做什么呢?在现实世界中,我们会看到一场平局,但在前面的代码中,我们会打印出Team Two Won,这对第一队来说是不公平的。在这种情况下,我们可以使用多个else if语句和一个最后的else语句来作为没有满足任何条件时的默认路径。
这在以下代码示例中得到了说明:
let teamOneScore = 7
let teamTwoScore = 6
if teamOneScore > teamTwoScore {
print("Team One Won")
} else if teamTwoScore > teamOneScore {
print("Team Two Won")
} else {
print("We have a tie")
}
在前面的代码中,如果teamOneScore的值大于teamTwoScore的值,我们将Team One Won打印到控制台。然后我们有一个else if语句,这意味着只有当第一个if语句返回false时,才会检查条件语句。最后,如果两个if语句都返回false,则调用else块中的代码,并将We have a tie打印到控制台。
现在是指出这一点的好时机,即像前一个例子中那样堆叠多个else if语句并不是一个好的实践。更好的做法是使用switch语句,我们将在本章后面探讨这一点。
守卫语句
在 Swift 以及大多数现代语言中,我们的条件语句往往侧重于测试一个条件是否为true。例如,以下代码测试x变量是否大于10,如果是,则执行某种功能。如果条件为false,我们处理以下错误条件:
var x = 9
if x > 10 {
// Functional code here
} else {
// Do error condition
}
这种类型的代码将我们的功能代码嵌入到我们的检查中,并将错误条件藏在函数的末尾,但如果我们真的不希望这样呢?有时(实际上很多次),在函数的开始处处理错误条件可能更好。在我们的简单例子中,我们可以轻松检查x是否小于或等于10,如果是,则执行错误条件。并不是所有的条件语句都那么容易重写,特别是像可选绑定这样的项目。
在 Swift 中,我们有guard语句。这个语句专注于在条件为false时执行一个功能;这允许我们在函数的早期捕获错误并执行错误条件。我们可以使用guard语句重写先前的例子,如下所示:
var x = 9
guard x > 10 else {
// Do error condition
return
}
//Functional code here
在这个新例子中,我们检查x变量是否大于10,如果不是,我们执行错误条件。如果变量大于10,应用程序将继续执行我们代码的功能部分。你会注意到我们在守卫条件中嵌入了一个return语句。守卫语句中的代码必须包含一个控制转移语句;这就是防止其余代码执行的原因。如果我们忘记了控制转移语句,Swift 会在编译时显示错误。我们将在本章稍后讨论控制转移语句。
让我们看看guard语句的一些更多示例。以下示例展示了我们如何使用guard语句来验证一个可选值是否包含有效值:
func guardFunction(str: String?) {
guard let goodStr = str else {
print("Input was nil")
return
}
print("Input was \(goodStr)")
}
函数尚未介绍,但将在第七章,函数中介绍。
在这个例子中,我们创建了一个名为guardFunction()的函数,它接受一个包含字符串或nil值的可选值。然后我们使用带有可选绑定的guard语句来验证字符串可选值不是nil。如果它包含nil,则执行guard语句内的代码,并使用return语句退出函数。使用带有可选绑定的guard语句的好处是,新变量在函数的其余部分的作用域内,而不仅仅是可选绑定语句的作用域内。
条件语句会检查一次条件,如果条件满足,则执行代码块。然而,如果我们想连续执行代码块直到满足条件呢?为此,我们使用循环语句。
switch 语句
switch语句获取一个值,将其与几个可能的匹配项进行比较,并根据第一个成功的匹配执行相应的代码块。switch语句是当存在多个可能的匹配项时,使用多个else if语句的替代方案。switch语句的格式如下:
switch value {
case match1:
block of code
case match2:
block of code
//as many cases as needed
default:
block of code
}
与大多数其他语言不同,在 Swift 中,switch语句不会自动跳转到下一个case语句;因此,我们不需要使用break语句来防止这种情况。这是 Swift 中内置的另一个安全特性,因为初学者在处理switch语句时最常见的编程错误之一就是忘记在case语句的末尾添加break语句。让我们看看如何使用switch语句:
var speed = 300000000
switch speed {
case 300000000:
print("Speed of light")
case 340:
print("Speed of sound")
default:
print("Unknown speed")
}
在前面的例子中,switch语句获取了speed变量的值,并将其与两个case语句进行比较。如果speed的值与任一case匹配,则代码会打印速度。如果没有找到匹配项,它会打印Unknown speed消息。
每个switch语句都必须匹配所有可能的值。这意味着,除非我们正在匹配一个具有定义数量的值的枚举,否则每个switch语句都必须有一个default情况。让我们看看没有default情况的例子:
var num = 5
switch num {
case 1 :
print("number is one")
case 2 :
print("Number is two")
case 3 :
print("Number is three")
}
如果我们将前面的代码放入 Playground 并尝试编译,我们将收到一个Switch must be exhaustive错误。这是一个编译时错误,因此,我们只有在尝试编译代码时才会收到通知。
在单个case中可以包含多个项目。为了做到这一点,我们需要用逗号分隔这些项目。让我们看看我们是如何使用switch语句来判断一个字符是元音还是辅音的:
var char : Character = "e"
switch char {
case "a", "e", "i", "o", "u":
print("letter is a vowel")
case "b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "q", "r", "s", "t", "v", "w","x", "y", "z":
print("letter is a consonant")
default:
print("unknown letter")
}
在前面的示例中,我们可以看到每个 case 都有多个项。逗号分隔这些项,switch 语句尝试将 char 变量与 case 语句中列出的每个项进行匹配。
还有可能检查 switch 语句的值,看它是否包含在某个范围内。为此,我们在 case 语句中使用范围运算符之一,如下所示:
var grade = 93
switch grade {
case 90...100: print("Grade is an A")
case 80...89: print("Grade is a B")
case 70...79: print("Grade is an C")
case 60...69: print("Grade is a D")
case 0...59: print("Grade is a F")
default:
print("Unknown Grade")
}
在前面的示例中,switch 语句取了 grade 变量,将其与每个 case 语句中的范围进行比较,并打印出适当的等级。
在 Swift 中,任何 case 语句都可以包含一个可选的 where 子句,它提供了一个需要验证的额外条件。假设在我们前面的例子中,我们有在课堂上接受特殊帮助的学生,我们想要为他们定义一个从 55 到 69 的 D 等级。以下示例展示了我们如何做到这一点:
var studentId = 4
var grade = 57
switch grade {
case 90...100:
print("Grade is an A")
case 80...89:
print("Grade is a B")
case 70...79:
print("Grade is an C")
case 55...69 where studentId == 4:
print("Grade is a D for student 4")
case 60...69:
print("Grade is a D")
case 0...59:
print("Grade is a F")
default:
print("Unknown Grade")
}
在使用 where 表达式时,需要注意的一点是 Swift 将尝试匹配值,从第一个 case 语句开始,逐个检查每个 case 语句。这意味着,如果我们把带有 where 表达式的 case 语句放在 F 等级 case 语句之后,那么带有 where 表达式的 case 语句将永远不会被触及。这在下述示例中得到了说明:
var studentId = 4
var grade = 57
switch grade {
case 90...100:
print("Grade is an A")
case 80...89:
print("Grade is a B")
case 70...79:
print("Grade is an C")
case 60...69:
print("Grade is a D")
case 0...59:
print("Grade is a F")
//The following case statement would never be reached because
//the grades would always match one of the previous two
case 55...69 where studentId == 4:
print("Grade is a D for student 4")
default:
print("Unknown Grade")
}
如果你使用 where 子句,一个很好的经验法则是始终将带有 where 子句的 case 语句放在任何不带 where 子句的类似 case 语句之前。
switch 语句在评估枚举时也非常有用。由于枚举具有有限数量的值,如果我们为枚举中的所有值提供 case 语句,我们就不需要提供 default 语句。以下示例演示了我们可以如何使用 switch 语句来评估枚举:
enum Product {
case Book(String, Double, Int)
case Puzzle(String, Double)
}
var order = Product.Book("Mastering Swift 4", 49.99, 2017)
switch order {
case .Book(let name, let price, let year):
print("You ordered the book \(name): \(year) for \(price)")
case .Puzzle(let name, let price):
print("You ordered the Puzzle \(name) for \(price)")
}
在这个例子中,我们首先定义了一个名为 Product 的枚举,包含两个值,每个值都有关联的值。然后我们创建了一个 order 变量,其类型为 Product,并使用 switch 语句对其进行评估。
当使用枚举的 switch 语句时,我们必须为所有可能的值提供一个 case 语句或一个 default 语句。让我们看看一些额外的代码,以说明这一点:
enum Planets {
case Mercury, Venus, Earth, Mars, Jupiter
case Saturn, Uranus, Neptune
}
var planetWeLiveOn = Planets.Earth
// Using the switch statement
switch planetWeLiveOn {
case .Mercury:
print("We live on Mercury, it is very hot!")
case .Venus:
print("We live on Venus, it is very hot!")
case .Earth:
print("We live on Earth, just right")
case .Mars:
print("We live on Mars, a little cold")
case .Jupiter, .Saturn, .Uranus, .Neptune:
print("Where do we live?")
}
在此示例代码中,我们有一个处理 Planets 枚举中每个行星的 case 语句。我们还可以添加一个 default 语句来处理将来可能添加的任何额外行星。然而,建议如果 switch 语句使用带有枚举的 default 语句,则应使用 @unknown 属性,如下所示:
switch planetWeLiveOn {
case .Mercury:
print("We live on Mercury, it is very hot!")
case .Venus:
print("We live on Venus, it is very hot!")
case .Earth:
print("We live on Earth, just right")
case .Mars:
print("We live on Mars, a little cold")
case .Jupiter, .Saturn, .Uranus, .Neptune:
print("Where do we live?")
@unknown default:
print("Unknown planet")
}
这将始终抛出一个警告,提醒我们如果我们在 Planet 枚举中添加一个新的行星,那么我们需要在这个代码部分处理这个新行星。
在 第五章,使用 Swift 集合 中,我们简要介绍了 Diff 算法。当时我们提到,直到我们理解了 switch 语句,我们才看不到这个算法的强大之处。现在我们理解了 switch 语句,让我们看看我们可以用这个算法做什么:
var cities1 = ["London", "Paris", "Seattle", "Boston", "Moscow"]
var cities2 = ["London", "Paris", "Tulsa", "Boston", "Tokyo"]
let diff = cities2.difference(from: cities1)
for change in diff {
switch change {
case .remove(let offset, let element, _ ):
cities2.remove(at: offset)
case .insert(let offset, let element, _):
cities2.insert(element, at: offset)
}
在前面的代码中,我们首先创建了两个包含城市列表的字符串数组。然后我们运行了 difference(from:) 方法,我们在上一章中介绍了这个方法。difference(from:) 方法返回一个包含 Change 枚举实例的集合。Change 枚举的定义如下:
public enum Change {
case insert(offset: Int, element: ChangeElement, associatedWith: Int?)
case remove(offset: Int, element: ChangeElement, associatedWith: Int?)
}
这个枚举包含两个可能的值。insert 值告诉我们是否需要插入一个值,因为数组缺少来自另一个数组的一个元素。remove 值告诉我们需要删除一个元素,因为数组有一个不在另一个数组中的元素。在我们的代码中,我们处理了 insert 和 remove 两个值,这意味着代码执行后,cities2 数组将具有与 cities1 数组相同的元素和相同的顺序。这并不太令人兴奋,但让我们假设我们想要从 cities2 数组中删除任何不在 cities1 数组中出现的元素。我们可以将我们的代码更改为以下内容:
var cities1 = ["London", "Paris", "Seattle", "Boston", "Moscow"]
var cities2 = ["London", "Paris", "Tulsa", "Boston", "Tokyo"]
for change in diff {
switch change {
case .remove(let offset, let element, _ ):
cities2.remove(at: offset)
default:
break
}
}
当此代码执行时,cities2 数组将包含三个元素:London、Paris 和 Boston。如果我们只处理插入条件,那么 cities2 数组将包含任何属于任一数组的元素。切换元组
我们还可以在 case 语句中使用下划线(通配符)和范围运算符与元组结合;让我们看看如何做到这一点:
let myDog = ("Maple", 4)
switch myDog {
case ("Lily", let age):
print("Lily is my dog and is \(age)")
case ("Maple", let age):
print("Maple is my dog and is \(age)")
case ("Dash", let age):
print("Dash is my dog and is \(age)")
default:
print("unknown dog")
}
在此代码中,我们创建了一个名为 myDog 的元组,其中包含了我狗的名字和她的年龄。然后我们使用 switch 语句来匹配名字(元组的第一个元素)和 let 语句来检索年龄。在这个例子中,信息“Maple 是我的狗,她 4 岁”将被打印到屏幕上。
我们还可以在 case 语句中使用下划线(通配符)和范围运算符与元组结合,如下面的示例所示:
switch myDog {
case(_, 0...1):
print("Your dog is a puppy")
case(_, 2...7):
print("Your dog is middle aged")
case(_, 8...):
print("Your dog is getting old")
default:
print("Unknown")
}
在这个例子中,下划线将匹配任何名字,而范围运算符将查找狗的年龄。在这个例子中,由于 Maple 四岁了,屏幕上会打印出信息“你的狗处于中年”。
匹配通配符
在 Swift 中,我们还可以将下划线(通配符)与 where 语句结合使用。以下示例说明了这一点:
let myNumber = 10
switch myNumber {
case _ where myNumber.isMultiple(of: 2):
print("Multiple of 2")
case _ where myNumber.isMultiple(of: 3):
print("Multiple of 3")
default:
print("No Match")
}
在这个例子中,我们创建了一个名为 myNumber 的整数变量,并使用 switch 语句来确定变量的值是否是 2 或 3 的倍数。注意,case 语句前面有一个下划线,后面跟着 where 语句。下划线将匹配变量的所有值,然后调用 where 语句来查看我们正在切换的项是否匹配其中定义的规则。
循环
循环语句使我们能够连续执行一段代码,直到满足某个条件。它们还使我们能够遍历集合中的元素。让我们看看我们如何使用for-in循环来遍历集合的元素。
for-in 循环
虽然 Swift 没有提供基于 C 的标准for循环,但它确实有for-in循环。在 Swift 3 中,基于 C 的标准for循环被从 Swift 语言中移除,因为它很少使用。你可以在 Swift 进化网站上阅读移除此循环的完整提案:github.com/apple/swift-evolution/blob/master/proposals/0007-remove-c-style-for-loops.md。for-in语句用于对范围、集合或序列中的每个项执行代码块。
使用 for-in 循环
for-in循环遍历一个项的集合或数字的范围,并对集合或范围内的每个项执行一段代码。for-in语句的格式如下:
for variable in collection/range {
block of code
}
如前述代码所示,for-in循环有两个:
-
variable:这个变量会在每次循环执行时改变,并将持有集合或范围中的当前项 -
collection/range:这是要遍历的集合或范围
让我们看看如何使用for-in循环遍历一个数字的范围:
for index in 1...5 {
print(index)
}
在前面的例子中,我们遍历了从 1 到 5 的数字范围,并将每个数字打印到控制台。这个循环使用了闭包范围运算符(...)来为循环提供一个遍历的范围。Swift 还提供了半开范围运算符(..>)和我们在上一章中看到的单侧范围运算符。
现在,让我们看看如何使用for-in循环遍历一个数组:
var countries = ["USA","UK", "IN"]
for item in countries {
print(item)
}
在前面的例子中,我们遍历了countries数组,并将数组中的每个元素打印到控制台。正如你所看到的,使用for-in循环遍历数组比使用基于 C 的标准for循环更安全、更简洁,也更简单。使用for-in循环可以防止我们犯一些常见的错误,例如在条件语句中使用小于等于(<=)运算符而不是小于(<)运算符。
让我们看看如何使用for-in循环遍历一个字典:
var dic = ["USA": "United States", "UK": "United Kingdom","IN":"India"]
for (abbr, name) in dic {
print("\(abbr) --\(name)")
}
在前面的例子中,我们使用了for-in循环遍历字典中的每个键值对。在这个例子中,字典中的每个项都作为(key,value)元组返回。我们可以在循环体中将(key,value)元组的成员分解为命名的常量。需要注意的是,由于字典不保证存储项的顺序,遍历的顺序可能与插入的顺序不同。
现在,让我们看看另一种类型的循环,即while循环。
当循环
while循环执行一个代码块,直到满足某个条件。Swift 提供了两种形式的while循环;这些是while循环和repeat-while循环。在 Swift 2.0 中,Apple 用repeat-while循环替换了do-while循环。repeat-while循环的功能与do-while循环相同。Swift 使用do语句进行错误处理。当你想运行零次或多次循环时,使用while循环;当你想运行一次或多次循环时,使用repeat-while循环。
我们使用while循环当要执行的迭代次数未知且通常依赖于某些业务逻辑时。这可能是像遍历一个集合直到找到一个特定的值或满足某个条件。
使用 while 循环
while循环首先评估一个条件语句,然后当条件语句为true时,反复执行一个代码块。while语句的格式如下:
while condition {
block of code
}
让我们看看如何使用while循环。在下面的例子中,while循环将在生成的随机数小于 7 时继续执行代码块。在这个例子中,我们使用Int.random()函数生成一个介于 0 到 9 之间的随机数:
var ran = 0 while ran < 7 {
ran = Int.random(in: 1..<20)
}
在前面的例子中,我们首先将ran变量初始化为0。然后while循环检查这个变量,如果值小于 7,就生成一个新的介于 0 到 19 之间的随机数。while循环将继续循环,直到生成的随机数等于或大于 7。一旦生成的随机数等于或大于 7,循环将退出。
在前面的例子中,while循环在生成新的随机数之前检查了条件语句。但如果我们不想在生成随机数之前检查条件语句呢?我们可以在初始化变量时生成一个随机数,但这意味着我们需要复制生成随机数的代码,而复制代码永远不是一种理想解决方案。使用repeat-while循环更为可取。
使用 repeat-while 循环
while循环和repeat-while循环的区别在于,while循环在第一次执行代码块之前会检查条件语句;因此,所有在条件语句中的变量都需要在执行while循环之前初始化。
repeat-while循环会在第一次检查条件语句之前运行循环块。这意味着我们可以在代码的条件块中初始化变量。当条件语句依赖于循环块中的代码时,repeat-while循环的使用更为合适。repeat-while循环的格式如下:
repeat {
block of code
} while condition
让我们通过创建一个repeat-while循环来查看这个具体的例子,在这个循环中,我们在循环块内初始化我们要检查的变量:
var ran: Int repeat {
ran = Int.random(in: 1..>20)
} while ran < 4
在前面的例子中,我们将ran变量定义为整数;然而,我们直到进入循环块并生成随机数之前都没有初始化它。如果我们尝试使用while循环(未初始化ran变量),我们将收到variable used before being initialized异常。
之前,我们提到switch语句比使用多个else if块更受欢迎。让我们看看我们如何使用switch语句。
在条件语句和循环中使用case和where语句
正如我们在switch语句中看到的那样,switch语句内的case和where语句可以非常强大。在我们的条件语句中使用case和where语句也可以使我们的代码更加简洁且易于阅读。条件语句和循环,如if、for和while,也可以使用where和case关键字。让我们看看一些例子,从使用where语句在for-in循环中过滤结果开始。
使用where语句进行过滤
在这个例子中,我们取一个整数数组,并仅打印出 3 的倍数。然而,在我们查看如何使用where语句过滤结果之前,让我们看看如何在不使用where语句的情况下完成此操作:
for number in 1...30 {
if number % 3 == 0 {
print(number)
}
}
在这个例子中,我们使用for-in循环遍历数字 1 到 30。在for-in循环内,我们使用if条件语句过滤出 3 的倍数。在这个简单的例子中,代码相对容易阅读,但让我们看看如何使用where语句来减少代码行数并使其更容易阅读:
for number in 1...30 where number % 3 == 0 {
print(number)
}
我们仍然有与上一个例子相同的for-in循环。然而,我们现在将where语句放在了末尾;因此,我们只遍历 3 的倍数。使用where语句将我们的例子缩短了两行,并使其更容易阅读,因为where子句与for-in循环在同一行,而不是嵌入在循环本身中。
现在,让我们看看如何使用for-case语句进行过滤。
使用for-case语句进行过滤
在接下来的例子中,我们将使用for-case语句过滤一个元组数组,并仅打印出符合我们标准的结果。for-case示例与使用where语句非常相似,它旨在消除在循环中过滤结果时需要if语句的需求。在这个例子中,我们将使用for-case语句过滤一系列世界系列冠军,并打印出特定球队赢得世界系列的时间:
var worldSeriesWinners = [
("Red Sox", 2004),
("White Sox", 2005),
("Cardinals", 2006),
("Red Sox", 2007),
("Phillies", 2008),
("Yankees", 2009),
("Giants", 2010),
("Cardinals", 2011),
("Giants", 2012),
("Red Sox", 2013),
("Giants", 2014),
("Royals", 2015)
]
for case let ("Red Sox", year) in worldSeriesWinners {
print(year)
}
在这个例子中,我们创建了一个名为worldSeriesWinners的元组数组,数组中的每个元组包含球队名称和赢得世界大赛的年份。然后我们使用for-case子句遍历数组,并仅打印出红袜队赢得世界大赛的年份。过滤是在case子句中完成的,其中("Red Sox", year)表示我们想要所有第一个元素包含Red Sox字符串的结果,以及year常量的第二个元素。然后for-in循环遍历case子句的结果,打印出year常量的值。
for-case-in子句也使得在可选数组中过滤掉nil值变得非常容易;让我们看看这个例子:
let myNumbers: [Int?] = [1, 2, nil, 4, 5, nil, 6]
for case let .some(num) in myNumbers {
print(num)
}
在这个例子中,我们创建了一个名为myNumbers的可选数组,它可以包含整数或nil。正如我们在第四章,可选类型中看到的,可选在内部定义为枚举,如下面的代码所示:
enum Optional < Wrapped > {
case none,
case some(Wrapped)
}
如果可选设置为nil,它将具有none的值,但如果它不是nil,它将具有some的值,并关联实际值的类型。在我们的例子中,当我们过滤.some(num)时,我们正在寻找任何具有非空值的可选。作为.some()的简写,我们可以使用问号(?)符号,如下面的例子所示。此示例还结合了for-case-in子句和where子句以执行额外的过滤:
let myNumbers: [Int?] = [1, 2, nil, 4, 5, nil, 6]
for case let num? in myNumbers where num < 3 {
print(num)
}
这个例子与上一个例子相同,只是我们在where子句中添加了额外的过滤。在上一个例子中,我们遍历了所有非空值;然而,在这个例子中,我们遍历了大于 3 的非空值。让我们看看我们如何在不使用case或where子句的情况下进行相同的过滤:
let myNumbers: [Int?] = [1, 2, nil, 4, 5, nil, 6]
for num in myNumbers {
if let num = num {
if num < 3 {
print(num)
}
}
}
使用for-case-in和where子句可以大大减少所需的行数。它还使我们的代码更容易阅读,因为所有过滤子句都在同一行。
让我们再看看另一个过滤示例。这次,我们将查看if-case子句。
使用if-case子句
使用if-case子句与使用switch子句非常相似。大多数时候,当我们有超过两个要匹配的情况时,我们更喜欢使用switch子句,但有时需要使用if-case子句。其中一种情况是我们只寻找一两个可能的匹配项,并且不想处理所有可能的匹配项;让我们看看这个例子:
enum Identifier {
case Name(String)
case Number(Int)
case NoIdentifier
}
var playerIdentifier = Identifier.Number(2)
if case let .Number(num) = playerIdentifier {
print("Player's number is \(num)")
}
在这个例子中,我们创建了一个名为Identifier的枚举,其中包含三个可能的值:Name、Number和NoIdentifier。然后我们创建了一个名为playerIdentifier的Identifier枚举实例,其值为Number,关联值为2。然后我们使用if-case语句来查看playerIdentifier是否有Number的值,如果有,我们将在控制台打印一条消息。
就像for-case语句一样,我们可以使用where语句进行额外的过滤。以下示例使用了我们在上一个例子中使用的相同的Identifier枚举:
var playerIdentifier = Identifier.Number(2)
if case let .Number(num) = playerIdentifier, num == 2 {
print("Player is either XanderBogarts or Derek Jeter")
}
在这个例子中,我们使用了if-case语句来查看playerIdentifier是否有Number的值,但我们还添加了where语句来查看关联值是否等于2。如果是这样,我们将玩家识别为XanderBogarts或Derek Jeter。
正如我们在示例中看到的那样,使用case和where语句与我们的条件语句可以减少执行某些类型过滤所需的行数。它还可以使我们的代码更容易阅读。现在让我们来看看控制转移语句。
控制转移语句
控制转移语句用于将控制权转移到代码的另一个部分。Swift 提供了六个控制转移语句;这些是continue、break、fallthrough、guard、throws和return。我们将在第七章,函数中查看return语句,并在第十二章,可用性和错误处理中讨论throws语句。剩余的控制转移语句将在本节中讨论。
continue语句
continue语句告诉循环停止执行代码块并转到循环的下一个迭代。以下示例展示了我们如何使用此语句来打印出范围内的奇数:
for i in 1...10 {
if i % 2 == 0 {
continue
}
print("\(i) is odd")
}
在前面的例子中,我们遍历了从 1 到 10 的范围。对于for-in循环的每次迭代,我们使用余数(%)运算符来查看数字是奇数还是偶数。如果数字是偶数,continue语句告诉循环立即转到循环的下一个迭代。如果数字是奇数,我们打印出该数字是奇数,然后继续。前面代码的输出如下:
1 is odd
3 is odd
5 is odd
7 is odd
9 is odd
现在让我们来看看break语句。
break语句
break语句立即结束控制流中的代码块执行。以下示例演示了当我们遇到第一个偶数时如何从for-in循环中退出:
for i in 1...10 {
if i % 2 == 0 {
break
}
print("\(i) is odd")
}
在前面的示例中,我们遍历从 1 到 10 的范围。对于for-in循环的每次迭代,我们使用取余(%)运算符来判断数字是奇数还是偶数。如果数字是偶数,我们使用break语句立即退出循环。如果数字是奇数,我们打印出该数字是奇数,然后进入循环的下一个迭代。前面的代码有以下输出:
1 is odd
跌落语句
在 Swift 中,switch语句不像其他语言那样会跌落;然而,我们可以使用fallthrough语句来强制它们跌落。fallthrough语句可能非常危险,因为一旦找到匹配项,下一个情况将默认为true,并且执行那个代码块。
这在以下示例中得到了说明:
var name = "Jon"
var sport = "Baseball"
switch sport {
case "Baseball":
print("\name) plays Baseball")
fallthrough
case "Basketball":
print("\(name) plays Basketball")
fallthrough
default:
print("Unknown sport")
}
当运行此代码时,以下结果将打印到控制台:
Jon plays Baseball
Jon plays Basketball
Unknown sport
我建议您在使用fallthrough语句时要非常小心。苹果公司故意禁用了case语句的跌落,以避免程序员常见的错误。通过使用fallthrough语句,您可能会将这些错误重新引入到您的代码中。
摘要
在本章中,我们介绍了 Swift 中的控制流和函数。在继续前进之前,理解本章中的概念是至关重要的。我们编写的每个应用程序,除了简单的 Hello World 应用程序之外,都将非常依赖控制流语句和函数。
控制流语句用于在我们应用程序中做出决策,并且函数,我们将在下一章讨论,用于将我们的代码分组到可重用和有组织的部分。
第七章:函数
当我最初用 BASIC 学习编程时,我的前几个程序都是用一大块代码编写的。我很快意识到我一直在重复相同的代码。我想一定有更好的方法来做这件事,也就是我学习子程序和函数的时候。函数是编写良好代码时需要理解的关键概念之一。
在本章中,我们将介绍以下主题:
-
什么是函数?
-
如何从函数返回值
-
如何在函数中使用参数
-
什么是可变参数?
-
什么是
inout参数?
在 Swift 中,函数是一个执行特定任务的独立代码块。函数通常用于将我们的代码逻辑地分解成可重用的命名块。函数的名称用于调用函数。
当我们定义一个函数时,我们还可以选择性地定义一个或多个参数。参数是通过调用它的代码传递给函数的命名值。这些参数通常在函数内部用于执行函数的任务。我们还可以为参数定义默认值,以简化函数的调用方式。
每个 Swift 函数都有一个与其关联的类型。这个类型被称为返回类型,它定义了从函数返回到调用它的代码的数据类型。如果一个函数没有返回值,则返回类型为Void。
让我们看看如何在 Swift 中定义函数。
使用单参数函数
在 Swift 中定义函数的语法非常灵活。这种灵活性使得我们能够轻松地定义简单的 C 风格函数,或者更复杂的具有局部和外部参数名称的函数,我们将在本章后面看到。让我们看看一些定义函数的例子。以下示例接受一个参数,但不向调用它的代码返回任何值:
func sayHello(name: String) -> Void {
let retString = "Hello " + name
print(retString)
}
在前面的例子中,我们定义了一个名为sayHello()的函数,它接受一个名为name的参数。在函数内部,我们打印了一条问候信息给这个人。一旦函数内部的代码执行完毕,函数就会退出,控制权返回到调用它的代码。我们不仅可以将问候信息打印出来,还可以通过添加返回类型将其返回给调用它的代码,如下所示:
func sayHello2(name: String) ->String {
let retString = "Hello " + name
return retString
}
->字符串定义了与函数关联的返回类型为字符串。这意味着该函数必须返回一个String类型的实例给调用它的代码。在函数内部,我们构建了一个名为retString的字符串常量,其中包含问候信息,然后使用return语句返回它。
调用 Swift 函数的过程与其他语言(如 C 或 Java)中调用函数或方法的过程类似。以下示例展示了如何调用sayHello(name:)函数,该函数将问候信息打印到屏幕上:
sayHello(name:"Jon")
现在,让我们看看如何调用sayHello2(name:)函数,该函数将值返回给调用它的代码:
var message = sayHello2(name:"Jon")
print(message)
在前面的例子中,我们调用了sayHello2(name:)函数,并将返回值赋给了message变量。如果一个函数定义了返回类型,就像sayHello2(name:)函数那样,它必须向调用它的代码返回该类型的一个值。因此,函数中每个可能的条件路径都必须以返回指定类型的值结束。这并不意味着调用函数的代码必须检索返回的值。以下是一个示例,以下两个语句都是有效的:
sayHello2(name:"Jon")
var message = sayHello2(name:"Jon")
如果你没有指定变量来存储返回值,该值将被丢弃。当代码编译时,如果你没有将函数返回的值放入变量或常量中,你会收到一个警告。你可以通过使用下划线来避免这个警告,如下面的示例所示:
_ = sayHello2(name:"Jon")
下划线告诉编译器你已经知道返回值,但不想使用它。在声明函数时使用@discardableResult属性也会消除警告。该属性的使用方法如下:
@discardableResult func sayHello2(name: String) ->String {
let retString = "Hello " + name
return retString
}
在 Swift 5.1 的 SE-0255 中,我们可以省略单表达式函数中的return语句。以下代码将展示这种情况:
func sayHello4(name: String) -> String {
"Hello " + name
}
这个函数与之前的 hello 函数定义类似,具有String返回类型;然而,你会注意到函数中没有返回语句。根据 SE-0255,如果我们有一个像sayHello4(name:)函数这样的单表达式函数,表达式的值可以在不需要return语句的情况下返回。如果我们像这样调用函数:
Let message = sayHello4(name:"Kara")
message常量将包含字符串"Hello Kara"。
让我们看看如何为我们的函数定义多个参数。
使用多参数函数
我们的函数不仅限于只有一个参数;我们还可以定义多个参数。要创建一个多参数函数,我们在括号中列出参数,并用逗号分隔参数定义。让我们看看如何在函数中定义多个参数:
func sayHello(name: String, greeting: String) {
print("\(greeting) \(name)")
}
在前面的例子中,该函数接受两个参数:name和greeting。然后我们使用这两个参数在控制台打印问候语。
调用一个多参数函数与调用一个单参数函数略有不同。在调用多参数函数时,我们用逗号分隔参数。我们还需要为所有参数包含参数名称。以下示例显示了如何调用多参数函数:
sayHello(name:"Jon", greeting:"Bonjour")
如果我们为函数的每个参数定义了默认值,我们就不需要为每个参数提供一个参数。让我们看看如何为我们的参数配置默认值。
定义参数的默认值
我们可以通过在函数定义中声明参数时使用等于运算符(=)来为任何参数定义默认值。以下示例展示了如何声明一个具有参数默认值的函数:
func sayHello(name: String, greeting: String = "Bonjour") {
print("\(greeting) \(name)")
}
在函数声明中,我们定义了一个没有默认值的参数 (name:String) 和一个有默认值的参数 (greeting: String = "Bonjour")。当一个参数有默认值声明时,我们可以带或不带设置该参数的值来调用函数。以下示例展示了如何不带设置 greeting 参数来调用 sayHello() 函数,以及如何设置 greeting 参数来调用它:
sayHello(name:"Jon")
sayHello(name:"Jon", greeting: "Hello")
在 sayHello(name:"Jon") 这一行,函数将打印出消息 Bonjour Jon,因为它使用了 greeting 参数的默认值。在 sayHello(name:"Jon", greeting: "Hello") 这一行,函数将打印出消息 Hello Jon,因为我们已经覆盖了 greeting 参数的默认值。
我们可以通过使用参数名称来声明多个具有默认值的参数,并且只覆盖我们想要的那些。以下示例展示了我们如何通过在调用时覆盖其中一个默认值来实现这一点:
func sayHello(name: String = "Test", name2: String = "Kailey", greeting: String = "Bonjour") {
print("\(greeting) \(name) and \(name2)")
}
sayHello(name:"Jon",greeting: "Hello")
在前面的例子中,我们声明了一个有三个参数的函数,每个参数都有一个默认值。然后我们调用该函数,将 name2 参数保留为其默认值,同时覆盖了剩余两个参数的默认值。
前面的例子将打印出消息 Hello Jon and Kailey。
现在,让我们看看我们如何从函数中返回多个值。
从函数中返回多个值
从 Swift 函数中返回多个值有几种方法。其中最常见的一种是将值放入一个集合类型(数组或字典)中,然后返回该集合。
以下示例展示了如何从 Swift 函数中返回一个集合类型:
func getNames() -> [String] {
var retArray = ["Jon", "Kailey", "Kara"]
return retArray
}
var names = getNames()
在前面的例子中,我们声明了 getNames() 函数,它没有参数,返回类型为 [String]。[String] 的返回类型指定了返回类型为字符串类型的数组。
在前面的例子中,我们的数组只能返回字符串类型。如果我们需要返回包含数字的字符串,我们可以返回一个 Any 类型的数组,然后使用类型转换来指定 object 类型。然而,这并不是我们应用程序的好设计,因为它容易出错。返回不同类型值的一个更好的方法是用 tuple 类型。
当我们从函数中返回一个元组时,建议我们使用命名元组,这样我们可以使用点语法来访问返回的值。以下示例展示了如何从函数中返回一个命名元组并访问返回的命名元组的值:
func getTeam() -> (team:String, wins:Int, percent:Double) {
let retTuple = ("Red Sox", 99, 0.611)
return retTuple
}
var t = getTeam()
print("\(t.team) had \(t.wins) wins")
在前面的例子中,我们定义了getTeam()函数,该函数返回一个包含三个值的命名元组:String、Int和Double。在函数内部,我们创建了将要返回的元组。请注意,我们不需要将将要返回的元组定义为命名元组,因为元组内的值类型与函数定义中的值类型相匹配。现在我们可以像调用其他函数一样调用这个函数,并使用点语法来访问返回的元组的值。在先前的例子中,代码将打印出以下行:
Red Sox had 99 wins
在前面的章节中,我们从函数中返回了非nil值;然而,这并不总是我们需要我们的代码做的事情。如果我们需要从函数中返回一个nil值,会发生什么?以下代码将无效并引发Nil is incompatible with return type String异常:
func getName() ->String {
return nil
}
此代码抛出异常,因为我们已将返回类型定义为字符串值,但我们尝试返回一个nil值。如果有理由返回nil,我们需要将返回类型定义为可选类型,以便调用它的代码知道该值可能是nil。要定义返回类型为可选类型,我们使用与定义变量为可选类型时相同的方式使用问号(?)。以下示例显示了如何定义可选返回类型:
func getName() ->String? {
return nil
}
上述代码不会引发异常。
我们还可以将元组设置为可选类型,或者将元组内的任何值设置为可选类型。以下示例显示了如何将元组作为可选类型返回:
func getTeam2(id: Int) -> (team:String, wins:Int, percent:Double)? {
if id == 1 {
return ("Red Sox", 99, 0.611)
}
return nil
}
在以下示例中,我们可以返回一个元组,就像它在函数定义中定义的那样,或者nil;两种选择都是有效的。如果我们需要一个元组内的单个值是nil,我们需要在元组内添加一个可选类型。以下示例显示了如何在元组内返回nil值:
func getTeam() -> (team:String, wins:Int, percent:Double?) {
let retTuple: (String, Int, Double?) = ("Red Sox", 99, nil)
return retTuple
}
在先前的例子中,我们将percent值设置为Double或nil。
现在,让我们看看我们如何为我们的函数添加外部参数名称。
添加外部参数名称
在本节的前几个例子中,我们以与在 C 代码中定义参数相同的方式定义了参数的名称和值类型。在 Swift 中,我们不受此语法的限制,因为我们还可以使用外部参数名称。
外部参数名称用于在调用函数时指示每个参数的目的。每个参数的外部参数名称需要与本地参数名称一起定义。外部参数名称添加在函数定义中的本地参数名称之前。外部和本地参数名称之间用空格分隔。
让我们看看如何使用外部参数名称。但在我们这样做之前,让我们回顾一下我们之前是如何定义函数的。在以下两个示例中,我们将定义一个没有外部参数名称的函数,然后使用外部参数名称重新定义它:
func winPercentage(team: String, wins: Int, loses: Int) -> Double{
return Double(wins) / Double(wins + loses)
}
在前面的例子中,winPercentage() 函数有三个参数。这些参数是 team、wins 和 loses。team 参数应该是 String 类型,而 wins 和 loses 参数应该是 Int 类型。以下行代码展示了如何调用 winPercentage() 函数:
var per = winPercentage(team: "Red Sox", wins: 99, loses: 63)
现在,让我们使用外部参数名称定义相同的函数:
func winPercentage(baseballTeam team: String, withWins wins: Int, andLoses losses: Int) -> Double {
return Double(wins) / Double(wins + losses)
}
在前面的例子中,我们使用外部参数名称重新定义了 winPercentage() 函数。在这个重新定义中,我们有相同的三个参数:team、wins 和 losses。区别在于我们如何定义参数。当使用外部参数时,我们使用外部参数名称和局部参数名称(用空格分隔)来定义每个参数。
在前面的例子中,第一个参数的外部参数名称为 baseballTeam,内部参数名称为 team。
当我们使用外部参数名称调用函数时,需要在函数调用中包含外部参数名称。以下代码展示了如何调用此函数:
var per = winPercentage(baseballTeam:"Red Sox", withWins:99, andLoses:63)
虽然使用外部参数名称需要更多的输入,但它确实使你的代码更容易阅读。在前面的例子中,很容易看出该函数正在寻找一个棒球队的名称,第二个参数是胜利次数,最后一个参数是失败次数。
使用可变参数
可变参数是指可以接受零个或多个指定类型的值的参数。在函数定义中,我们通过在参数类型名称后附加三个点(...)来定义可变参数。可变参数的值以指定类型的数组形式提供给函数。以下示例展示了我们如何使用函数的可变参数:
func sayHello(greeting: String, names: String...) {
for name in names {
print("\(greeting) \(name)")
}
}
在前面的例子中,sayHello() 函数接受两个参数。第一个参数是 String 类型,用于指定要使用的问候语。第二个参数是 String 类型的可变参数,用于指定要发送问候语的人名。在函数内部,可变参数是一个包含指定类型的数组;因此,在我们的例子中,names 参数是一个 String 值的数组。在这个例子中,我们使用了一个 for-in 循环来访问 names 参数中的值。
以下行代码展示了如何使用可变参数调用 sayHello() 函数:
sayHello(greeting:"Hello", names: "Jon", "Kara")
上一行代码将问候语打印到每个名字上,如下所示:
Hello Jon
Hello Kara
现在,让我们看看 inout 参数是什么。
输入输出参数
如果我们想更改参数的值,并且希望这些更改在函数结束时仍然保持,我们需要将参数定义为inout参数。对inout参数所做的任何更改都会传递回函数调用中使用的变量。
使用inout参数时有两个需要注意的事项:这些参数不能有默认值,并且它们不能是可变参数。
让我们看看如何使用inout参数交换两个变量的值:
func reverse(first: inout String, second: inout String) {
let tmp = first
first = second
second = tmp
}
此函数将接受两个参数,并交换函数调用中使用的变量的值。当我们调用函数时,我们在变量名前放置一个反引号(&),表示函数可以修改其值。以下示例显示了如何调用reverse函数:
var one = "One"
var two = "Two"
reverse(first: &one, second: &two)
print("one: \(one) two: \(two)")
在前面的例子中,我们将变量one设置为One的值,将变量two设置为Two的值。然后我们使用one和two变量调用reverse()函数。一旦reverse()函数返回,名为one的变量将包含Two的值,而名为two的变量将包含One的值。
关于inout参数的两个注意事项:可变参数不能是inout参数,并且inout参数不能有默认值。
省略参数标签
本章中所有函数在将参数传递给函数时都使用了标签。如果我们不想使用标签,我们可以通过使用下划线来省略它们。以下示例说明了这一点:
func sayHello(_ name: String, greeting: String) {
print("\(greeting) \(name)")
}
注意参数列表中name标签前的下划线。这表示在调用此函数时不应使用name标签。现在,我们能够不使用name标签来调用此函数:
sayHello("Jon", greeting: "Hi")
此调用将输出Hi Jon。
现在,让我们将我们所学的内容综合起来,看看一个更复杂的例子。
将所有这些放在一起
为了巩固我们在本章学到的内容,让我们再看一个例子。对于这个例子,我们将创建一个函数来测试一个字符串值是否包含有效的 IPv4 地址。IPv4 地址是分配给使用互联网协议(IP)进行通信的计算机的地址。IP 地址由四个范围从0-255的数值组成,由点(句号)分隔。以下是一个有效 IP 地址的代码示例;即10.0.1.250:
func isValidIP(ipAddr: String?) ->Bool {
guard let ipAddr = ipAddr else {
return false
}
let octets = ipAddr.split { $0 == "."}.map{String($0)}
guard octets.count == 4 else {
return false
}
for octet in octets {
guard validOctet(octet: octet) else {
return false
}
}
return true
}
在isValidIp()函数中,唯一的参数是可选类型,我们首先做的事情是验证ipAddR参数不是nil。为此,我们使用带有可选绑定的guard语句。如果可选绑定失败,我们返回一个布尔值false,因为nil不是一个有效的 IP 地址。
如果ipAddr参数包含非空值,我们将使用点作为分隔符将字符串拆分成一个字符串数组。由于 IP 地址应该包含由点分隔的四个数字,我们再次使用guard语句来检查数组是否包含四个元素。如果不包含,我们返回false,因为我们知道ipAddr参数没有包含有效的 IP 地址。
我们随后使用String类型的split()函数将字符串拆分成四个子字符串,其中每个子字符串包含地址的一个octet。这些子字符串存储在octets数组中。
然后,我们遍历通过在点处拆分原始的ipAddr参数并传递给validOctet()函数创建的数组中的值。如果所有四个值都通过validOctet()函数的验证,我们就有一个有效的 IP 地址,并返回一个布尔值true;然而,如果任何一个值未能通过validOctet()函数的验证,我们返回一个布尔值false。现在,让我们看看validOctet()函数的代码:
func validOctet(octet: String) ->Bool {
guard let num = Int(octet),num >= 0 && num <256 else {
return false
}
return true
}
validOctet()函数有一个名为octet的String参数。这个函数将验证octet参数是否包含介于0和255之间的数值;如果是,函数将返回一个布尔值true。否则,它将返回一个布尔值false。
摘要
在本章中,我们介绍了函数是什么以及如何使用它们。你将在你编写的每一个严肃的应用程序中使用函数。在下一章中,我们将探讨类和结构。类和结构可以包含函数,但这些函数被称为方法。
第八章:类、结构体和协议
我最初学习的编程语言是 BASIC。这是一个开始编程的好语言,但当我用 Commodore Vic-20 交换了 PCjr(是的,我有一台 PCjr,我真的很喜欢它)后,我意识到还有其他更高级的语言,我花了很多时间学习 Pascal 和 C。直到我开始上大学,我才听到面向对象编程语言这个术语。当时,面向对象编程语言还非常新,没有真正的课程,但我能够用 C++ 进行一些实验。毕业后,我放弃了面向对象编程,直到几年后,当我再次开始尝试 C++ 时,我才真正发现了面向对象编程的强大和灵活性。在本章中,我们将涵盖以下主题:
-
什么是类和结构体?
-
如何向类和结构体添加属性和属性观察器
-
如何向类和结构体添加方法
-
如何向类和结构体添加初始化器
-
如何以及何时使用访问控制
-
如何创建类层次结构
-
如何扩展类
什么是类和结构体?
在 Swift 中,类和结构体非常相似。如果我们真的想精通 Swift,不仅理解使类和结构体如此相似的原因非常重要,而且理解使它们区别开来的原因也非常重要,因为它们是我们应用程序的构建块。苹果公司是这样描述它们的:
类和结构体是通用、灵活的构造,它们成为你程序代码的构建块。你可以通过使用已经熟悉的常量、变量和函数的语法来定义属性和方法,为你的类和结构体添加功能。
让我们先快速看一下类和结构体之间的一些相似之处。
类和结构体之间的相似之处
在 Swift 中,类和结构体比在其他语言(如 Objective-C)中更相似。以下是类和结构体共享的一些功能列表:
-
属性: 这些用于在类和结构体中存储信息
-
方法: 这些为我们的类和结构体提供功能
-
初始化器: 这些用于初始化我们的类和结构体实例
-
下标: 这些通过下标语法提供对值的访问
-
扩展: 这些有助于扩展类和结构体
现在,让我们快速看一下类和结构体之间的一些区别。
类和结构体之间的区别
虽然类和结构体非常相似,但也有几个非常重要的区别。以下是 Swift 中类和结构体之间的一些区别列表:
-
类型: 结构体是一个值类型,而类是一个引用类型
-
继承: 结构体不能从其他类型继承,而类可以
-
析构器:结构体不能有自定义析构器,而类可以
在本章中,我们将强调类和结构体之间的区别,以帮助我们了解何时使用每个。在我们真正深入类和结构体之前,让我们看看值类型(结构体)和引用类型(类)之间的区别。为了完全理解何时使用类和结构体以及如何正确使用它们,理解值类型和引用类型之间的区别非常重要。
值类型与引用类型
结构体是值类型。当我们在我们应用程序中传递结构体的实例时,我们传递的是结构体的一个副本,而不是原始的结构体。类是引用类型;因此,当我们在我们应用程序中传递类的实例时,传递的是原始实例的引用。理解这种区别非常重要。在这里,我们将提供一个非常高级的概述,并在第十八章,内存管理中提供更多细节。当我们在我们应用程序中传递结构体时,我们传递的是结构体的副本,而不是原始的结构体。由于函数会得到结构体自己的副本,因此它可以按需更改它,而不会影响结构体的原始实例。当我们在我们应用程序中传递类的实例时,我们传递的是类的原始实例的引用。由于我们将类的实例传递给函数,函数得到的是原始实例的引用;因此,在函数退出后,函数内所做的任何更改都将保留。为了说明值类型和引用类型之间的区别,让我们看看一个现实世界的对象:一本书。如果我们有一个朋友想阅读精通 Swift 5.3,我们要么可以为他们买一本自己的书,要么分享我们的书。如果我们为我们朋友买了一本自己的书,他们在书中做的任何笔记都会保留在他们自己的书里,而不会反映在我们的副本中。这就是结构体和变量按值传递的方式。在函数中对结构体或变量所做的任何更改都不会反映在结构体或变量的原始实例中。如果我们分享我们的书,他们在还书时在书中做的任何笔记都会保留在书中。这就是按引用传递的方式。对类的实例所做的任何更改在函数退出后都将保留。
创建类或结构体
我们使用相同的语法来定义类和结构体。唯一的区别是我们使用class关键字定义类,使用struct关键字定义结构体。
让我们看看用于创建类和结构体的语法:
class MyClass {
// MyClass definition
}
struct MyStruct {
// MyStruct definition
}
在前面的代码中,我们定义了一个名为MyClass的新类和一个名为MyStruct的新结构体。这实际上创建了两个新的 Swift 类型,分别命名为MyClass和MyStruct。当我们命名一个新类型时,我们希望使用 Swift 设定的标准命名约定,即名称采用驼峰式,首字母大写。这也被称为PascalCase。在类或结构体内部定义的任何方法或属性也应使用驼峰式命名,首字母大写。空类和结构体并不那么有用,因此让我们看看我们如何向我们的类和结构体添加属性。
属性
属性将值与类或结构体关联。属性有两种类型:
-
存储属性: 这些属性将变量或常量值作为类或结构体实例的一部分进行存储。存储属性还可以有属性观察器,可以监视属性的变化,并在属性值发生变化时执行自定义操作。
-
计算属性: 这些属性本身不存储值,而是检索并可能设置其他属性。计算属性的返回值也可以在请求时计算。
存储属性
存储属性是作为类或结构体实例的一部分存储的变量或常量。这些属性使用var和let关键字定义,就像普通变量和常量一样。在以下代码中,我们将创建一个名为MyStruct的结构体和一个名为MyClass的类。该结构体和类都包含两个存储属性c和v。存储属性c是一个常量,因为它使用let关键字定义,而v是一个变量,因为它使用var关键字定义。让我们看看以下代码:
struct MyStruct
{
let c = 5
var v = ""
}
class MyClass
{
let c = 5
var v = ""
}
如前例所示,定义存储属性的语法对类和结构体都是相同的。让我们看看我们将如何创建结构体和类的实例。以下代码创建了一个名为myStruct的MyStruct结构体实例和一个名为myClass的MyClass类实例:
结构体和类之间的一种区别是,默认情况下,结构体会创建一个初始化器,允许我们在创建结构体实例时填充存储属性。因此,我们也可以像这样创建MyStruct的实例:
var myStruct = MyStruct(v: "Hello")
在前面的示例中,初始化器用于设置v变量,而c常量仍然包含在结构体中定义的数字5。如果我们没有为常量提供一个初始值,如以下示例所示,则默认初始化器也会用于设置常量:
struct MyStruct {
let c: Int
var v = ""
}
以下示例显示了此新结构体的初始化器将如何工作:
var myStruct = MyStruct(c: 10, v: "Hello")
这允许我们在运行时初始化类或结构体时设置值,而不是在类型内硬编码常量的值。参数在初始化器中出现的顺序是我们定义它们的顺序。在先前的例子中,我们首先定义了 c 常量,因此它是初始化器中的第一个参数。接下来我们定义了 v 参数,因此它是初始化器中的第二个参数。
从 Swift 5.1 开始,随着 SE-0242 的引入,结构体的初始化器得到了增强,可以给任何参数添加默认值,使得参数在初始化器中是可选的。让我们创建一个新的结构体来说明这一点:
struct MyStruct {
var a: Int
var b = "Hello"
var c = "Jon"
}
在此代码中,我们定义了三个参数,a、b 和 c,其中 b 和 c 参数都有默认值。现在我们可以以下任何一种方式初始化 MyStruct 结构体:
let myStruct1 = MyStruct(a: 2)
let myString2 = MyStruct(a: 3, b: "Bonjour")
let myString3 = MyStruct(a: 4, b: "Bonjour", c: "Kara")
我们能够在初始化器中省略 c 参数或 b 和 c 参数,因为我们定义参数时设置了默认值。需要注意的是以下代码将抛出错误:
let myString3 = MyStruct(b: "Hello", c: "Kara")
当我们在结构体中定义参数时,我们首先定义参数 a,然后是 b,最后是 c,这意味着初始化器中的顺序也是 a,然后是 b,最后是 c。当我们使多个参数成为可选时,我们不能省略其中一个参数但仍包括其后的其他参数,因此我们不能省略参数 a 而仍然包括参数 b 和 c。
要设置或读取存储属性,我们使用标准的点语法。让我们看看在 Swift 中如何设置和读取存储属性:
var x = myClass.c
myClass.v = "Howdy"
在代码的第一行中,我们读取 c 属性并将其存储在一个名为 x 的变量中。在第二行代码中,我们将 v 属性设置为 Howdy 字符串。在我们继续到计算属性之前,让我们创建一个表示员工的结构体和类。我们将在此章中使用并扩展这些内容,以展示类和结构体的相似之处以及它们的不同之处:
struct EmployeeStruct {
var firstName = ""
var lastName = ""
var salaryYear = 0.0
}
class EmployeeClass {
var firstName = ""
var lastName = ""
var salaryYear = 0.0
}
员工结构体的名称为 EmployeeStruct,员工类的名称为 EmployeeClass。类和结构体都有三个存储属性:firstName、lastName 和 salaryYear。在结构体和类中,我们可以通过使用属性名和 self 关键字来访问这些属性。每个结构体或类的实例都有一个名为 self 的属性。这个属性指向实例本身;因此,我们可以用它来访问实例内的属性。以下示例展示了如何在结构体或类的实例中使用 self 关键字来访问属性:
self.firstName = "Jon" self.lastName = "Hoffman"
计算属性
计算属性是没有后端变量的属性,这些变量用于存储与属性相关的值,但对外部代码是隐藏的。计算属性的值通常在代码请求时计算。你可以将计算属性视为一个伪装成属性的函数。让我们看看如何定义一个只读计算属性:
var salaryWeek: Double {
get{
self.salaryYear/52
}
}
要创建一个只读计算属性,我们首先使用var关键字定义它,就像定义一个普通变量一样,然后是变量名,一个冒号,以及变量类型。接下来的是不同的;我们在声明末尾添加一个花括号,然后定义一个 getter 方法,当请求计算属性的值时会被调用。在这个例子中,getter 方法将salaryYear属性的当前值除以 52,以获取员工的周薪。
我们可以通过删除get关键字来简化只读计算属性的定义,如下例所示:
var salaryWeek: Double {
self.salaryYear/52
}
计算属性不仅限于只读;我们也可以向它们写入。为了使salaryWeek属性可写,我们将添加一个 setter 方法。以下示例展示了我们如何添加一个 setter 方法,该方法将根据传递给salaryWeek属性的值设置salaryYear属性:
var salaryWeek: Double {
get {
self.salaryYear/52
}
set(newSalaryWeek){
self.salaryYear = newSalaryWeek*52
}
}
我们可以通过不定义新值的名称来简化 setter 定义。在这种情况下,值将被分配给默认变量newValue,如下例所示:
var salaryWeek: Double {
get {
self.salaryYear/52
}
set{
self.salaryYear = newValue*52
}
如前述示例中所示,salaryWeek计算属性可以添加到EmployeeClass类或EmployeeStruct结构体中,无需任何修改。让我们看看如何通过将salaryWeek属性添加到我们的EmployeeClass类中来实现这一点:
class EmployeeClass {
var firstName = ""
var lastName = ""
var salaryYear = 0.0
var salaryWeek: Double {
get {
self.salaryYear/52
}
set(newSalaryWeek) {
self.salaryYear = newSalaryWeek*52
}
}
}
现在,让我们看看如何将salaryWeek计算属性添加到EmployeeStruct结构体中:
struct EmployeeStruct {
var firstName = ""
var lastName = ""
var salaryYear = 0.0
var salaryWeek: Double {
get {
self.salaryYear/52
}
set(newSalaryWeek) {
self.salaryYear = newSalaryWeek*52
}
}
}
如我们所见,类和结构定义到目前为止是相同的,只是用于定义它们的初始class或struct关键字不同。我们读写计算属性的方式与读写存储属性的方式完全相同。类或结构体外部的代码不应知道该属性是计算属性。让我们通过创建EmployeeStruct结构体的实例来观察这一点:
var f = EmployeeStruct(firstName: "Jon", lastName: "Hoffman", salaryYear: 39_000)
print(f.salaryWeek) //prints 750.00 to the console f.salaryWeek = 1000
print(f.salaryWeek) //prints 1000.00 to the console
print(f.salaryYear) //prints 52000.00 to the console
上一示例首先创建了一个EmployStruct结构的实例,并将salaryYear的值设置为 39,000。接下来,我们将salaryWeek属性的值打印到控制台。这个值目前是 750.00。然后我们将salaryWeek属性设置为 1,000.00,并将salaryWeek和salaryYear属性的值都打印到控制台。现在salaryWeek和salaryYear属性的值分别是 1,000.00 和 52,000。正如我们所看到的,在这个例子中,设置salaryWeek或salaryYear属性中的任何一个都会改变两个属性返回的值。计算属性可以非常有用,可以提供相同数据的不同视图。例如,如果我们有一个表示某物长度的值,我们可以将长度存储为厘米,然后使用计算属性来计算米、毫米和千米的值。现在,让我们看看属性观察者。
属性观察者
每当属性的值被设置时,都会调用属性观察者。我们可以将属性观察者添加到任何非延迟存储属性。我们还可以通过在子类中重写属性来将属性观察者添加到任何继承的存储或计算属性,我们将在重写属性部分讨论这一点。Swift 中有两种可以设置的属性观察者:willSet和didSet。willSet观察者在属性被设置之前被调用,而didSet观察者在属性被设置之后被调用。关于属性观察者需要注意的一点是,它们在初始化过程中设置值时不会被调用。让我们看看如何将属性观察者添加到我们的EmployeeClass类和EmployeeStruct结构中的工资属性:
var salaryYear: Double = 0.0 {
willSet(newSalary) {
print("About to set salaryYear to \(newSalary)")
}
didSet {
if salaryWeek > oldValue {
print("\(firstName) got a raise.")
} else {
print("\(firstName) did not get a raise.")
}
}
}
当我们向一个存储属性添加属性观察者时,我们需要在属性的定义中包含要存储的值的类型。在上一示例中,我们不需要将我们的salaryYear属性定义为Double类型;然而,当我们添加属性观察者时,定义是必需的。在属性定义之后,我们定义了willSet观察者,它简单地打印出salaryYear属性将被设置为的新值。我们还定义了一个didSet观察者,它将检查新值是否大于旧值,如果是,它将打印出员工得到了加薪;否则,它将打印出员工没有得到加薪。与计算属性的 getter 方法一样,我们不需要为新值定义名称。如果我们不定义名称,新值将被放入一个名为newValue的常量中。以下示例显示了如何在不为新值定义名称的情况下重写之前的willSet观察者:
willSet {
print("About to set salaryYear to \(newValue)")
}
正如我们所见,属性主要用于存储与类或结构体相关联的信息。方法主要用于向类或结构体添加业务逻辑。让我们看看我们如何向类或结构体添加方法。
方法
方法是与类或结构体实例相关联的函数。与函数一样,方法将封装与类或结构体相关联的特定任务或功能的相关代码。让我们看看我们如何为类和结构体定义方法。以下代码将使用firstName和lastName属性返回员工的完整姓名:
func fullName() -> String {
firstName + " " + lastName
}
我们定义这个方法就像定义任何函数一样。方法只是一个与特定类或结构体相关联的函数,我们在前几章中学到的关于函数的所有内容都适用于方法。fullName()函数可以直接添加到EmployeeClass类或EmployeeStruct结构体中,无需任何修改。要访问方法,我们使用与访问属性相同的点语法。
以下代码显示了如何访问类和结构体的fullName()方法:
var e = EmployeeClass()
var f = EmployeeStruct(firstName: "Jon", lastName: "Hoffman", salaryYear: 50000)
e.firstName = "Jon"
e.lastName = "Hoffman"
e.salaryYear = 50000.00
print(e.fullName()) //Jon Hoffman is printed to the console
print(f.fullName()) //Jon Hoffman is printed to the console
在前面的示例中,我们初始化了EmployeeClass类和EmployeeStruct结构体的实例。我们用相同的信息填充结构和类,然后使用fullName()方法将员工的完整姓名打印到控制台。在两种情况下,都会打印出Jon Hoffman。在定义需要更新属性值的类和结构体的方法时,存在差异。让我们看看我们如何在EmployeeClass类中定义一个给员工加薪的方法:
func giveRaise(amount: Double) {
salaryYear += amount
}
如果我们将前面的代码添加到我们的EmployeeClass中,它将按预期工作,当我们用金额调用该方法时,员工会得到加薪。然而,如果我们尝试将此方法以当前形式添加到EmployeeStruct结构体中,我们会收到一个mark方法和一个mutating to make self mutable错误。默认情况下,我们不允许在结构体的方法中更新属性值。如果我们想修改一个属性,我们可以通过在方法声明的func关键字之前添加mutating关键字来修改该方法的mutating行为。因此,以下代码将是为EmployeeStruct结构体定义giveRaise(amount:)方法的正确方式:
mutating func giveRase(amount: Double) {
self.salaryYear += amount
}
在前面的示例中,我们使用self属性来引用实例本身内部的当前类型实例,因此当我们写self.salaryYear时,我们要求当前类型实例的salaryYear属性的值。
self属性仅在必要时使用。我们在这些示例中使用它来展示它是什么以及如何使用它。
self 属性主要用于区分具有相同名称的局部变量和实例变量。让我们通过一个示例来解释这一点。我们可以将此函数添加到 EmployeeClass 或 EmployeeStruct 类型中:
func isEqualFirstName(firstName: String) -> Bool {
self.firstName == firstName
}
在前面的示例中,该方法接受一个名为 firstName 的参数。在该类型中还有一个具有相同名称的属性。我们使用 self 属性来指定我们想要具有 firstName 名称的实例属性,而不是具有此名称的局部变量。除了对于需要更改结构属性值的 mutating 关键字是必需的外,方法可以像函数定义和使用一样定义和使用。因此,我们在 第六章,函数 中学到的所有关于函数的知识都可以应用到方法上。有时我们希望在类或结构首次初始化时初始化属性或执行一些业务逻辑。为此,我们将使用初始化器。
自定义初始化器
当我们初始化一个类型(类或结构)的新实例时,会调用初始化器。初始化是准备实例以供使用的进程。初始化过程可能包括设置存储属性的初始值、验证外部资源是否可用,或正确设置用户界面。初始化器通常用于确保类或结构的实例在使用前得到适当的初始化。初始化器是用于创建类型新实例的特殊方法。我们定义初始化器的方式与定义其他方法类似,但我们必须使用 init 关键字作为初始化器的名称,以告知编译器此方法是一个初始化器。在其最简单形式中,初始化器不接受任何参数。让我们看看用于编写简单初始化器的语法:
init() {
//Perform initialization here
}
此格式适用于类和结构。默认情况下,所有类和结构都有一个空的默认初始化器,可以重写。我们在上一节中初始化 EmployeeClass 类和 EmployeeStruct 结构时使用了这些默认初始化器。结构还有另一个默认初始化器,我们在 EmployeeStruct 结构中看到了它,它接受每个存储属性的值并将它们初始化为这些值。让我们看看如何向 EmployeeClass 类和 EmployeeStruct 结构添加自定义初始化器。在以下代码中,我们创建了三个自定义初始化器,它们将适用于 EmployeeClass 类和 EmployeeStruct 结构:
init() {
firstName =""
lastName = ""
salaryYear = 0.0
}
init(firstName: String, lastName: String) {
self.firstName = firstName
self.lastName = lastName
salaryYear = 0.0
}
init(firstName: String, lastName: String, salaryYear: Double) {
self.firstName = firstName
self.lastName = lastName
self.salaryYear = salaryYear
}
第一个初始化器 init() 将将所有存储属性设置为它们的默认值。第二个初始化器 init(firstName: String, lastName: String) 将使用参数的值填充 firstName 和 lastName 属性。第三个初始化器 init(firstName: String, lastName: String, salaryYear: Double) 将使用参数的值填充所有属性。在之前的示例中,我们可以看到在 Swift 中,初始化器没有显式的返回值,但它确实返回该类型的实例。这意味着我们不需要为初始化器定义返回类型或在初始化器中包含返回语句。让我们看看我们如何使用这些初始化器:
var g = EmployeeClass()
var h = EmployeeStruct(firstName: "Me", lastName: "Moe")
var i = EmployeeClass(firstName: "Me", lastName: "Moe", salaryYear: 45_000)
EmployeeClass 类的 g 实例使用 init() 初始化器创建 EmployeeClass 类的一个实例;因此,这个实例的所有属性都包含它们的默认值。EmployeeStruct 结构的 h 实例使用 init(firstName: String, lastName: String) 初始化器创建 EmployeeStruct 结构的一个实例;因此,firstName 属性被设置为 Me,lastName 属性被设置为 Moe,这两个参数被传递到初始化器中。salaryYear 属性仍然设置为默认值 0.0。EmployeeClass 类的 i 实例使用 init(firstName: String, lastName: String, salaryYear: Double) 初始化器创建 EmployeeClass 类的一个实例;因此,firstName 属性被设置为 Me,lastName 属性被设置为 Moe,salaryYear 属性被设置为 45_000。由于所有初始化器都与 init 关键字相关联,参数和参数类型被用来识别使用哪个初始化器。与结构不同,类可以有析构器。析构器在类的实例被销毁并从内存中移除之前被调用。在 第十八章,内存管理 中,我们将展示析构器的示例并看到它在何时被调用。让我们看看带有初始化器的内部和外部参数名称。
内部和外部参数名称
就像函数一样,与初始化器关联的参数可以有独立的内部和外部名称。如果我们没有为我们的参数提供外部参数名称,Swift 会自动为我们生成它们。在之前的示例中,我们没有在初始化器的定义中包含外部参数名称,因此 Swift 使用内部参数名称作为外部参数名称为我们创建了它们。如果我们想提供自己的参数名称,我们可以在内部参数名称之前放置外部参数名称,这与我们对任何正常函数的做法完全一样。让我们看看我们如何通过重新定义 EmployeeClass 类中的一个初始化器来定义我们自己的外部参数名称:
init(employeeWithFirstName firstName: String, lastName lastName: String, andSalary salaryYear: Double) {
self.firstName = firstName
self.lastName = lastName
self.salaryYear = salaryYear
}
在前面的例子中,我们创建了init(employeeWithFirstName firstName: String, lastName lastName: String, andSalary salaryYear: Double)初始化器。这个初始化器将创建EmployeeClass类的一个实例,并用参数的值填充实例属性。在这个例子中,每个参数都有外部和内部属性名。让我们看看我们如何使用这个初始化器,使用外部属性名:
var i = EmployeeClass(withFirstName: "Me", lastName: "Moe", andSalary: 45000)
注意我们现在正在使用初始化器中定义的外部参数名。使用外部参数名可以帮助使我们的代码更易读,并有助于区分不同的初始化器。那么,如果我们的初始化器失败会怎样呢?例如,如果我们的类依赖于特定的资源,比如当前不可用的网络服务,会发生什么?这就是 failable 初始化器发挥作用的地方。
Failable 初始化器
Failable 初始化器是一种可能无法初始化类或结构所需资源的初始化器,从而使实例无法使用。当使用 failable 初始化器时,初始化器的结果是可选类型,包含该类型的有效实例或 nil。可以通过在init关键字后添加一个问号(?)来使初始化器成为 failable。让我们看看我们如何创建一个不允许用年薪低于每年 20,000 美元初始化新员工的 failable 初始化器:
init?(firstName: String, lastName: String, salaryYear: Double) {
self.firstName = firstName
self.lastName = lastName
self.salaryYear = salaryYear
if self.salaryYear < 20_000 {
return nil
}
}
在前面的例子中,我们没有在初始化器中包含return语句,因为 Swift 不需要返回初始化的实例;然而,在 failable 初始化器中,如果初始化失败,它必须返回 nil。如果初始化器成功初始化了实例,我们不需要返回任何内容。因此,在我们的例子中,如果传入的年薪低于每年 20,000 美元,我们返回nil,表示初始化失败,否则不返回任何内容。让我们看看我们如何使用 failable 初始化器来创建类或结构的实例:
if let f = EmployeeClass(firstName: "Jon", lastName: "Hoffman", salaryYear: 29_000) {
print(f.fullName())
} else {
print("Failed to initialize")
}
在前面的例子中,我们用超过 20,000 美元的年薪初始化了EmployeeClass类的实例;因此,实例被正确初始化,并且Jon Hoffman的全名被打印到控制台。现在,让我们尝试用低于 20,000 美元的年薪初始化EmployeeClass类的实例,看看它会如何失败:
if let f = EmployeeClass(firstName: "Jon", lastName: "Hoffman", salaryYear: 19_000) {
print(f.fullName())
} else {
print("Failed to initialize")
}
在前面的例子中,我们尝试为我们的员工初始化的年薪低于 20,000 美元,因此初始化失败,并在控制台打印出Failed to initialize消息。
有时候我们想要限制对代码某些部分的访问。为此,我们使用访问控制。
访问控制
访问控制使我们能够隐藏实现细节,仅暴露我们想要暴露的接口。这个功能是通过访问控制来处理的。我们可以为类和结构体分配特定的访问级别。我们还可以为属于我们的类和结构体的属性、方法和初始化器分配特定的访问级别。在 Swift 中,有五个访问级别:
-
公开(Open):这是最可见的访问控制级别。它允许我们在任何想要导入模块的地方使用属性、方法、类等。基本上,任何具有公开访问级别项都可以被任何模块使用。任何标记为公开的项可以由定义它们的模块内的任何项以及导入该模块的任何模块进行子类化或重写。这个级别主要用于框架,以暴露框架的公共 API。公开访问控制仅适用于类及其成员。
-
公共(Public):这个访问级别允许我们在任何想要导入模块的地方使用属性、方法、类等。基本上,任何具有公共访问级别项都可以被任何模块使用。任何标记为公共的项只能由定义它们的模块内的任何项进行子类化或重写。这个级别主要用于框架,以暴露框架的公共 API。
-
内部(Internal):这是默认的访问级别。这个访问级别允许我们在定义项的模块中使用属性、方法、类等。如果在框架中使用此级别,它允许框架的其他部分使用该项,但框架外部的代码将无法访问它。
-
文件私有(Fileprivate):这种访问控制允许从定义项的同一源文件中的任何代码访问属性和方法。
-
私有(Private):这是最不可见的访问控制级别。它只允许我们在定义它的源文件中声明的扩展内使用属性、方法、类等。
当我们开发框架时,访问控制变得非常有用。我们需要将面向公众的接口标记为公共或公开,以便其他模块,如导入框架的应用程序,可以使用它们。然后,我们将使用内部和私有访问控制级别来标记我们希望在框架和源文件内部使用的接口。要定义访问级别,我们在实体的定义之前放置级别的名称。以下代码显示了我们可以如何向多个实体添加访问级别的示例:
private struct EmployeeStruct {}
public class EmployeeClass {}
internal class EmployeeClass2 {}
public var firstName = "Jon"
internal var lastName = "Hoffman"
private var salaryYear = 0.0
public func fullName() -> String {}
private func giveRaise(amount: Double) {}
在访问控制方面存在一些限制,但这些限制是为了确保 Swift 中的访问级别遵循一个简单的指导原则:不能以具有较低(更限制性)访问级别的另一个实体来定义实体。这意味着当实体依赖于具有较低(更限制性)访问级别的另一个实体时,我们不能将其访问级别设置为较高(较少限制性)。以下示例演示了这一原则:
-
当其中一个参数或返回类型的访问级别为 private 时,我们不能将方法标记为 public,因为外部代码无法访问私有类型。
-
当类或结构的访问级别为 private 时,我们不能将方法或属性的访问级别设置为 public,因为外部代码在类为 private 时无法访问构造函数。
现在让我们看看 Swift 5.2 中的一个新特性,即作为函数的关键路径表达式。
作为函数的关键路径表达式
Swift 5.2 中的 SE-0249 引入了一个非常方便的快捷方式,使我们能够轻松访问特定集合中对象的属性。这意味着如果我们使用 map 算法遍历一个集合,我们能够使用关键路径表达式 (\Root.value) 来访问集合中项的属性。让我们看看一个使用我们之前创建的员工结构的示例。我们将从创建三个员工并将它们添加到数组中开始:
let employee1 = EmployeeStruct(firstName: "Jon", lastName: "Hoffman", salaryYear: 90000)
let employee2 = EmployeeStruct(firstName: "Kailey", lastName: "Hoffman", salaryYear: 32000)
let employee3 = EmployeeStruct(firstName: "Kara", lastName: "Hoffman", salaryYear: 28000)
let employeeCollection = [employee1, employee2, employee3]
现在我们有一个员工数组,让我们检索我们员工的全部姓名。我们可以通过遍历数组并逐个提取名称,但如果我们结合我们在第五章“使用 Swift 集合”中看到的 map 算法以及这个新特性,我们可以这样检索所有姓名:
let firstNames = employeeCollection.map(\.firstName)
使用此代码,firstName 数组将包含 employeeCollection 数组中每个员工的姓名。
让我们看看 Swift 5.2 中的另一个新特性,即作为函数的类型调用。
将类型作为函数调用
在 Swift 5.2 的 SE-0253 中,我们能够将类型作为函数调用。为了更好地解释,具有名为 callAsFunction 的方法的类型的实例可以像函数一样被调用。让我们看看这个示例。我们将从创建一个 Dice 类型开始,它可以用来创建任何大小的骰子实例:
struct Dice {
var highValue: Int
var lowValue: Int
func callAsFunction() -> Int {
Int.random(in: lowValue...highValue)
}
}
注意在函数中调用的方法 callAsFunction()。这个函数使用 lowValue 和 highValue 属性生成随机数。由于我们把这个方法命名为 callAsFunction,所以我们能够使用实例的名称来调用它,就像它是一个函数一样。让我们通过创建一个六面骰子并生成一个随机值来查看这是如何工作的:
let d6 = Dice(highValue: 6, lowValue: 1)
let roll = d6()
roll 变量将包含由 callAsFunction() 方法生成的随机值。这使得我们可以简化调用某些函数的方式。在先前的例子中,我们能够通过简单地调用 d6() 来生成骰子的点数,而不是通过像 d6.generateRoll() 这样的函数名来调用实例。
现在让我们看看什么是继承。
继承
继承的概念是面向对象开发的基本概念。继承允许一个类被定义为具有某些特性,然后其他类可以从该类派生。派生类继承了它所继承的类的所有特性(除非派生类覆盖了这些特性),然后通常还会添加它自己的额外特性。
继承是区分类和结构体的基本差异之一。类可以从父类或超类派生,但结构体不能。
使用继承,我们可以创建所谓的类层次结构。在类层次结构中,位于层次结构顶部的类被称为基类,而派生类被称为子类。我们不仅限于仅从基类创建子类,我们还可以从其他子类创建子类。一个子类派生自的类被称为父类或超类。在 Swift 中,一个类只能有一个父类。这被称为单继承。
子类可以调用和访问它们从超类继承的属性、方法和下标。它们还可以覆盖它们从超类继承的属性、方法和下标。
子类可以给它们从超类继承的属性添加属性观察者,以便在属性值发生变化时得到通知。让我们看看一个示例,说明 Swift 中继承是如何工作的。我们将从定义一个名为 Plant 的基类开始。Plant 类将有两个属性:height 和 age。它还将有一个方法:growHeight()。height 属性将表示植物的高度,age 属性将表示植物的年龄,growHeight() 方法将用于增加植物的高度。以下是定义 Plant 类的方法:
class Plant {
var height = 0.0
var age = 0
func growHeight(inches: Double) {
height += inches;
}
}
现在我们有了我们的 Plant 基类,让我们看看我们如何定义它的子类。我们将把这个子类命名为 Tree。Tree 类将继承 Plant 类的 age 和 height 属性,并添加一个额外的属性,命名为 limbs。它还将继承 Plant 类的 growHeight() 方法,并添加两个额外的方法:limbGrow(),用于生长新的枝条,和 limbFall(),用于枝条从树上掉落。让我们看看以下代码:
class Tree: Plant {
var limbs = 0
func limbGrow() {
self.limbs += 1
}
func limbFall() {
self.limbs -= 1
}
}
我们通过在类定义的末尾添加一个冒号和超类的名称来表示一个类有一个超类。在这个例子中,我们指明Tree类有一个名为Plant的超类。现在,让我们看看我们如何使用从Plant类继承来的age和height属性的Tree类:
var tree = Tree()
tree.age = 5
tree.height = 4
tree.limbGrow()
tree.limbGrow()
之前的示例首先创建了一个Tree类的实例。然后我们将Age和height属性分别设置为5和4,并通过调用limbGrow()方法两次为树添加了两条枝条。现在我们有一个名为Plant的基类,它有一个名为Tree的子类。这意味着Tree的超类(或父类)是Plant类。这也意味着Plant的一个子类(或子类)被命名为Tree。然而,世界上有各种各样的树木。让我们从Tree类创建两个子类。这些子类将是PineTree类和OakTree类:
class PineTree: Tree {
var needles = 0
}
class OakTree: Tree{
var leaves = 0
}
当前类的层次结构看起来是这样的:

图 8.1:继承类层次结构
在 Swift 中,一个类可以有多个子类;然而,一个类只能有一个超类。有时,子类需要提供它从超类继承来的方法或属性的自己的实现。这被称为重写。
方法重写和属性重写
要重写一个方法、属性或下标,我们需要在定义前加上override关键字。这告诉编译器我们打算重写超类中的某个东西,并且我们没有错误地创建了重复的定义。override关键字会提示 Swift 编译器验证超类(或其父类之一)是否有可以重写的匹配声明。如果它在一个超类中找不到匹配的声明,将会抛出一个错误。
方法重写
让我们看看我们如何重写一个方法。我们将首先向Plant类添加一个getDetails()方法,然后在其子类中重写它。以下代码显示了新Plant类的代码:
class Plant {
var height = 0.0
var age = 0
func growHeight(inches: Double) {
self.height += inches;
}
func getDetails() -> String {
return "Plant Details"
}
}
现在,让我们看看我们如何在Tree类中重写getDetails()方法:
class Tree: Plant {
private var limbs = 0
func limbGrow() {
self.limbs += 1
}
func limbFall() {
self.limbs -= 1
}
override func getDetails() -> String {
return "Tree Details"
}
}
这里需要注意的是,我们不在Plant类中使用override关键字,因为它是最先实现这个方法的类;然而,我们在Tree类中包含它,因为我们正在重写从Plant类继承来的getDetails()方法。现在,让我们看看如果我们从Plant和Tree类的实例中调用getDetails()方法会发生什么:
var plant = Plant()
var tree = Tree()
print("Plant: \(plant.getDetails())")
print("Tree: \(tree.getDetails())")
之前的示例将在控制台打印以下两行:
Plant: Plant Details
Tree: Tree Details
如我们所见,Tree子类中的getDetails()方法覆盖了其父类Plant的getDetails()方法。在Tree类内部,我们仍然可以通过使用super前缀来调用其超类(或任何覆盖的方法、属性或下标)的getDetails()方法(或任何覆盖的方法、属性或下标)。我们将首先用以下方法替换Plant类中的getDetails()方法,该方法将生成包含height和age属性值的字符串:
func getDetails() -> String {
return "Height:\(height) age:\(age)"
}
现在,我们将用以下方法替换Tree类的getDetails()方法,该方法将调用超类的getDetails()方法:
override func getDetails() -> String {
let details = super.getDetails()
return "\(details) limbs:\(limbs)"
}
在前面的例子中,我们首先调用超类(在本例中为Plant类)的getDetails()方法来获取一个包含树木高度和年龄的字符串。然后我们构建一个新的字符串对象,该对象结合了getDetails()方法的输出和一个包含来自Tree类肢体数量的新字符串。然后返回这个新字符串。让我们看看如果我们调用这个新方法会发生什么:
var tree = Tree()
tree.age = 5
tree.height = 4
tree.limbGrow()
tree.limbGrow()
print(tree.getDetails())
如果我们运行前面的代码,以下行将被打印到控制台:
Height: 4.0
age: 5
limbs: 2
如我们所见,返回的字符串包含了来自Plant类的height和age信息以及来自Tree类的limbs信息。
覆盖属性
我们可以提供自定义的 getter 和 setter 来覆盖任何继承的属性。当我们覆盖一个属性时,我们必须提供我们正在覆盖的属性名称和类型,以便编译器可以验证类层次结构中的某个类是否有一个匹配的属性可以覆盖。让我们看看我们如何通过向我们的Plant类添加以下属性来覆盖一个属性:
var description: String {
return "Base class is Plant."
}
description属性是一个基本的只读属性。该属性返回Base class is Plant字符串。现在,让我们通过向Tree类添加以下属性来覆盖这个属性:
override var description: String {
return "\(super.description) I am a Tree class."
}
当覆盖属性和方法时,也使用相同的override关键字。此关键字告诉编译器我们想要覆盖一个属性,以便编译器可以验证类层次结构中的另一个类是否包含一个可以覆盖的匹配属性。然后我们像其他任何属性一样实现该属性。调用Tree类实例的description属性将返回Base class is Plant. I am a Tree class字符串。有时我们希望防止子类覆盖属性和方法。也有时候我们希望防止整个类被子类化。让我们看看我们如何做到这一点。
防止覆盖
为了防止覆盖或子类化,我们可以使用final关键字。要使用final关键字,我们在项目定义之前添加它。例如,final func、final var和final class。任何尝试覆盖带有此关键字的项都将导致编译时错误。
协议
有时候,我们可能希望在实际上不提供任何实现的情况下描述类型的实现(方法、属性和其他要求)。为此,我们可以使用协议。协议定义了类或结构体的方法、属性和其他要求的蓝图。然后,类或结构体可以提供一个符合这些要求的实现。提供实现的类或结构体被称为符合协议。协议对 Swift 语言非常重要。整个 Swift 标准库都是基于它们构建的,我们将在第九章“协议和协议扩展”和第十章“面向协议设计”中探讨协议及其使用。
协议语法
定义协议的语法与我们定义类或结构体的语法非常相似。以下示例显示了用于定义协议的语法:
protocol MyProtocol {
//protocol definition here
}
我们通过在类型名称之后放置协议名称,并用冒号分隔来声明一个类或结构体符合一个协议。以下是一个示例,说明我们如何声明一个结构体符合 MyProtocol 协议:
struct MyStruct: MyProtocol {
// Structure implementation here
}
一个类型可以符合多个协议。我们通过逗号分隔来列出类型符合的协议。以下示例显示了如何说明我们的结构体符合多个协议:
struct MyStruct: MyProtocol, AnotherProtocol, ThirdProtocol {
// Structure implementation here
}
如果我们需要一个类同时继承自一个超类并实现一个协议,我们应该首先列出超类,然后是协议。以下示例说明了这一点:
class MyClass: MySuperClass, MyProtocol, MyProtocol2 {
// Class implementation here
}
属性要求
协议可以要求符合协议的类型提供具有指定名称和类型的某些属性。协议不指定属性应该是存储属性还是计算属性,因为实现细节留给符合协议的类型。在协议中定义属性时,我们必须使用 get 和 set 关键字来指定属性是只读的还是可读写的。让我们通过创建一个名为 FullName 的协议来看看如何在协议中定义属性:
protocol FullName {
var firstName: String { get set }
var lastName: String { get set }
}
FullName 协议定义了两个属性,任何符合该协议的类型都必须实现。这两个属性是 firstName 和 lastName 属性,它们都是可读写属性。如果我们想指定属性为只读,我们只需使用 get 关键字来定义它,如下所示:
var readOnly: String { get }
让我们看看我们如何创建一个符合此协议的 Scientist 类:
class Scientist: FullName {
var firstName = ""
var lastName = ""
}
如果我们忘记包含任何一个必需的属性,我们会收到一个错误消息,告知我们忘记的属性。我们还需要确保属性的类型相同。例如,如果我们将 Scientist 类中 lastName 属性的定义更改为 var lastName = 42,我们也会收到一个错误消息,因为协议指定我们必须有一个字符串类型的 lastName 属性。
方法要求
协议可以要求符合的类或结构体提供某些方法。我们可以在协议中定义一个方法,就像在类或结构体中定义一样,只是不需要方法体。让我们向我们的 FullName 协议和 Scientist 类添加一个 fullName() 方法:
protocol FullName {
var firstName: String { get set }
var lastName: String { get set }
func fullName() -> String
}
现在,我们需要向我们的 Scientist 类添加一个 fullName() 方法,以便它符合协议:
class Scientist: FullName {
var firstName = ""
var lastName = ""
var field = ""
func fullName() -> String {
return "\(firstName) \(lastName) studies \(field)"
}
}
结构体可以像类一样完全符合 Swift 协议。实际上,Swift 标准库的大多数内容都是实现了组成标准库的各种协议的结构体。以下示例展示了我们如何创建一个同时符合 FullName 协议的 FootballPlayer 结构体:
struct FootballPlayer: FullName {
var firstName = ""
var lastName = ""
var number = 0
func fullName() -> String {
return "\(firstName) \(lastName) has the number \(number)"
}
}
当一个类或结构体符合 Swift 协议时,我们可以确信它已经实现了所需属性和方法。当我们需要确保在多个类中实现某些属性或方法时,这非常有用,就像我们前面的示例所展示的那样。当我们想要解耦代码,使其不依赖于特定类型时,协议也非常有用。以下代码展示了我们如何使用 FullName 协议、Scientist 类和我们已经构建的 FootballPlayer 结构体来解耦代码:
var scientist = Scientist()
scientist.firstName = "Kara"
scientist.lastName = "Hoffman"
scientist.field = "Physics"
var player = FootballPlayer()
player.firstName = "Dan"
player.lastName = "Marino"
player.number = 13
var person: FullName
person = scientist
print(person.fullName())
person = player
print(person.fullName())
在前面的代码中,我们首先创建了一个 Scientist 类的实例和一个 FootballPlayer 结构体的实例。然后我们创建了一个 person 变量,其类型为 FullName (protocol),并将其设置为刚才创建的 scientist 实例。然后我们调用 fullName() 方法来获取我们的描述。这将打印出 Kara Hoffman 研究物理学 消息到控制台。然后我们将 person 变量设置为 player 实例,并再次调用 fullName() 方法。这将打印出 Dan Marino 穿着 13 号球衣 消息到控制台。正如我们所看到的,person 变量并不关心实际的实现类型。由于我们定义了 person 变量为 FullName 类型,我们可以将变量设置为任何符合 FullName 协议的类型的实例。这被称为多态。我们将在第九章 协议和协议扩展 和第十章 面向协议设计 中更详细地介绍多态和协议。
扩展
使用扩展,我们可以添加新的属性、方法、构造函数和下标,或者使现有的类型符合协议,而无需修改该类型的源代码。需要注意的是,扩展不能覆盖现有的功能。要定义一个扩展,我们使用 extension 关键字,后跟我们要扩展的类型。
以下示例展示了我们如何创建一个扩展来扩展 string 类:
extension String {
//add new functionality here
}
让我们通过向 Swift 的标准 string 类添加一个 reverse() 方法和 firstLetter 属性来查看扩展是如何工作的:
extension String {
var firstLetter: Character? {
get {
return self.first
}
}
func reverse() -> String {
var reverse = ""
for letter in self {
reverse = "\(letter)" + reverse
}
return reverse
}
}
当我们扩展现有类型时,我们定义属性、方法、初始化器、下标和协议的方式与我们在标准类或结构体中通常定义它们的方式完全相同。在字符串扩展的例子中,我们可以看到我们定义 reverse() 方法和 firstLetter 属性的方式,就像我们在正常类型中定义它们一样。然后我们可以像使用任何其他方法一样使用这些方法,如下面的例子所示:
var myString = "Learning Swift is fun"
print(myString.reverse())
print(myString.firstLetter!)
Swift 4 为字符串类型添加了 reversed() 方法,这应该比我们在这里创建的方法更受欢迎。这个例子只是说明了如何使用扩展。扩展对于从外部框架添加额外功能到现有类型非常有用,就像在这个例子中演示的那样。我们更倾向于使用扩展来向外部框架的类型添加额外功能,而不是子类化,因为这允许我们在整个代码中继续使用该类型,而不是将其更改为子类。在我们完成本章之前,现在我们已经理解了类和结构体,让我们再次看看可选链。
属性包装器
属性包装器是在 Swift 5.1 中通过 SE-0258 引入的,它们允许使用自定义类型来包装属性值。为了执行这种包装,我们必须创建一个自定义属性和一个将处理该属性的类型。为了看看这个例子,假设我们想要从字符串值的开始和结束处删除所有空白字符。我们可以通过使用属性的获取器和设置器方法来删除空白字符;然而,我们必须为每个想要删除空白的属性放入这种逻辑。使用属性包装器,我们会更容易做到这一点。我们将从创建用作包装器的自定义类型开始;我们将它命名为 Trimmed:
@propertyWrapper
struct Trimmed {
private var str: String = ""
var wrappedValue: String {
get { str }
set { str = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }
}
init(wrappedValue: String) {
self.wrappedValue = wrappedValue
}
}
之前的代码首先使用 @propertyWrapper 属性来定义这个类型可以用作属性包装器。任何被定义为属性包装器的类型都必须有一个非静态属性名为 wrappedValue,我们在 Trimmed 类型内将其定义为 String 类型。最后,我们创建一个初始化器,用于设置 wrappedValue 属性。
如果我们回顾一下本章早期创建的 EmployeeStruct 结构体,我们定义了两个类似这样的 String 属性:
var firstName = ""
var lastName = ""
如果我们想要从这些属性的起始和结束处删除所有空白字符,我们现在只需要给它们添加一个 @Trimmed 属性,如下所示:
@Trimmed var firstName = ""
@Trimmed var lastName = ""
现在如果我们像这样创建了 EmployeeStruct 结构体的新实例(注意初始化器中的空格):
let employee1 = EmployeeStruct(firstName: " Jon ", lastName: " Hoffman ")
firstName 和 lastName 属性将包含自动删除空白字符的值。
可选链
可选绑定允许我们一次解包一个可选值,但如果我们在其他可选类型中嵌入了可选类型会怎样呢?这将迫使我们必须在其他可选绑定语句中嵌入可选绑定语句。有一种更好的处理方法:使用可选链。在我们查看可选链之前,让我们看看这是如何与可选绑定一起工作的。我们将从定义三个我们将在本节示例中使用的类型开始:
class Collar {
var color: String
init(color: String) {
self.color = color
}
}
class Pet {
var name: String
var collar: Collar?
init(name: String) {
self.name = name
}
}
class Person {
var name: String
var pet: Pet?
init(name: String) {
self.name = name
}
}
在这个例子中,我们首先定义了一个名为 Collar 的类,它有一个属性被定义。这个属性名为 color,其类型为字符串。我们可以看到 color 属性不是可选的;因此,我们可以安全地假设它总是会有一个有效的值。接下来,我们定义了一个名为 Pet 的类,它有两个属性被定义。这些属性分别命名为 name 和 collar。name 属性的类型为字符串,而 collar 属性是可选的,可能包含 Collar 类型的实例,或者可能不包含任何值。最后,我们定义了一个名为 Person 的类,它也有两个属性。这些属性分别命名为 name 和 pet。name 属性的类型为字符串,而 pet 属性是可选的,可能包含 Pet 类型的实例,或者可能不包含任何值。对于接下来的示例,让我们使用以下代码来初始化这些类:
var jon = Person(name: "Jon")
var buddy = Pet(name: "Buddy")
jon.pet = buddy
var collar = Collar(color: "red")
buddy.collar = collar
现在,假设我们想要获取一个人的宠物的颜色;然而,这个人可能没有宠物(pet 属性可能为 nil)或者宠物可能没有项圈(collar 属性可能为 nil)。我们可以使用可选绑定来逐层深入,如下面的示例所示:
if let tmpPet = jon.pet, let tmpCollar = tmpPet.collar {
print("The color of the collar is \(tmpCollar.color)")
} else {
print("Cannot retrieve color")
}
虽然这个例子完全有效,并且会打印出 The color of the collar is red 的消息,但代码相当混乱且难以理解,因为我们有多个可选绑定语句在同一行上,其中第二个可选绑定语句依赖于第一个。可选链允许我们在一行代码中深入多个属性、方法和子脚本的多个可选类型层。这些层可以链接在一起,如果任何层返回 nil,整个链优雅地失败并返回 nil。如果链中的所有值都没有返回 nil,则返回链的最后一个值。由于可选链的结果可能是一个 nil 值,结果始终以可选类型返回,即使我们检索的最终值不是可选类型。为了指定可选链,我们在链中的每个可选值后面放置一个问号 (?)。以下示例展示了如何使用可选链使前面的示例更加简洁和易于阅读:
if let color = jon.pet?.collar?.color {
print("The color of the collar is \(color)")
} else {
print("Cannot retrieve color")
}
在这个例子中,我们在pet和collar属性后面加上一个问号,以表示它们是可选类型,并且如果任一值是nil,整个链将返回nil。此代码还会打印出The color of the collar is red消息;然而,它比前面的例子更容易阅读,因为它清楚地显示了我们所依赖的可选类型。
摘要
在本章中,我们深入探讨了类和结构体。我们看到了它们如此相似的原因,也看到了它们如此不同的原因。在接下来的章节中,记住类是引用类型而结构体是值类型是很重要的。我们还探讨了协议和扩展。随着本章的结束,我们结束了 Swift 编程语言的介绍。到目前为止,我们对 Swift 语言的知识已经足够开始编写自己的应用程序;然而,还有很多东西要学习。在接下来的章节中,我们将更深入地探讨我们已经讨论的一些概念,例如协议和下标。我们还将看到如何使用面向协议的编程技术来编写易于管理的代码。最后,我们将有一些章节帮助我们编写更好的代码,例如一个 Swift 风格指南的示例,以及一个关于设计模式的章节。
第九章:协议和协议扩展
当我观看 2015 年 WWDC 关于协议扩展和面向协议编程(POP)的演示时,我必须承认我非常怀疑。我长期从事面向对象编程(OOP),因此我不确定这种新的编程范式是否能够解决苹果所声称的所有问题。既然我不是一个让怀疑阻碍尝试新事物的人,我就设置了一个新的项目,该项目与我现在正在工作的项目相似,但使用苹果对 POP 的建议编写代码。我还广泛使用了协议扩展。我可以真诚地说,与原始项目相比,新项目要干净得多。我相信,协议扩展将是那些定义性特征之一,将一种编程语言与其他编程语言区分开来。
在本章中,你将学习以下主题:
-
协议如何用作类型?
-
我们如何使用协议在 Swift 中实现多态?
-
我们如何使用协议扩展?
-
为什么我们想要使用协议扩展?
虽然协议扩展基本上是语法糖,但在我看来,它们是 Swift 编程语言中最重要的一些新增功能之一。通过协议扩展,我们能够为符合协议的任何类型提供方法和属性实现。为了真正理解协议和协议扩展的有用性,让我们更好地了解协议。
虽然 Swift 中的类、结构和枚举都可以遵守协议,但在这个章节中,我们将专注于类和结构。枚举用于表示有限数量的案例,虽然在我的经验中,枚举遵守协议的有效用例是存在的,但它们很少见。只需记住,在任何我们提到类或结构的地方,我们也可以使用枚举。
让我们从了解它们在 Swift 中是如何作为完整类型开始的,来探索协议。
协议作为类型
尽管在协议中没有实现任何功能,但它们在 Swift 编程语言中仍然被视为完整类型,可以像任何其他类型一样使用。这意味着我们可以将协议用作函数的参数类型或返回类型。
我们还可以将它们用作变量、常量和集合的类型。让我们看看一些例子。对于这些例子,我们将使用以下PersonProtocol协议:
protocol PersonProtocol {
var firstName: String { get set }
var lastName: String { get set }
var birthDate: Date { get set }
var profession: String { get }
init(firstName: String,lastName: String, birthDate: Date)
}
在这个第一个例子中,协议被用作函数的参数类型和返回类型:
func updatePerson(person: PersonProtocol) -> PersonProtocol {
// Code to update person goes here
return person
}
在这个例子中,updatePerson()函数接受一个PersonProtocol协议类型的参数,并返回一个PersonProtocol协议类型的值。接下来的例子展示了如何将协议用作常量、变量或属性的类型:
var myPerson: PersonProtocol
在这个例子中,我们创建了一个名为 myPerson 的 PersonProtocol 协议类型的变量。协议也可以用作存储集合的项目类型,例如数组、字典或集合:
var people: [PersonProtocol] = []
在这个最后的例子中,我们创建了一个 PersonProtocol 协议类型的数组。尽管 PersonProtocol 协议没有实现任何功能,但我们仍然可以在需要指定类型时使用协议。然而,协议不能像类或结构体那样被实例化。这是因为协议中没有实现任何功能。例如,当尝试创建 PersonProtocol 协议的实例时,如下面的例子所示,我们会收到编译时错误:
var test = PersonProtocol(firstName: "Jon", lastName: "Hoffman", birthDate:bDateProgrammer)
我们可以在需要协议类型的地方使用任何符合我们协议的类型实例。例如,如果我们定义了一个变量为 PersonProtocol 协议类型,那么我们可以用任何符合此协议的类或结构体来填充这个变量。为了这个例子,让我们假设有两个类型,分别命名为 SwiftProgrammer 和 FootballPlayer,它们符合 PersonProtocol 协议:
var myPerson: PersonProtocol
myPerson = SwiftProgrammer(firstName: "Jon", lastName: "Hoffman", birthDate: bDateProgrammer)
print("\(myPerson.firstName) \(myPerson.lastName)")
myPerson = FootballPlayer(firstName: "Dan", lastName: "Marino", birthDate:bDatePlayer)
print("\(myPerson.firstName) \(myPerson.lastName)")
在这个例子中,我们首先创建了一个 PersonProtocol 协议类型的 myPerson 变量。然后,我们将变量设置为 SwiftProgrammer 类型的实例,并打印出姓名和姓氏。接下来,我们将 myPerson 变量设置为 FootballPlayer 类型的实例,并再次打印出姓名和姓氏。需要注意的是,Swift 不关心实例是类还是结构体。唯一重要的是类型必须符合 PersonProtocol 协议类型。
我们可以将 PersonProtocol 协议用作数组的类型,这意味着我们可以用符合协议的任何类型的实例来填充数组。再次强调,类型是类还是结构体并不重要,只要它符合 PersonProtocol 协议。
协议的多态性
在之前的例子中,我们看到的是一种多态形式。多态这个词来自希腊语词根 poly,意为许多,和 morphe,意为形式。在编程语言中,多态是单一接口对多种类型(许多形式)。在之前的例子中,单一接口是 PersonProtocol 协议,而多种类型是任何符合该协议的类型。
多态性赋予我们以统一的方式与多种类型交互的能力。为了说明这一点,我们可以扩展之前的例子,其中我们创建了一个 PersonProtocol 类型的数组,并遍历了这个数组。然后,我们可以使用在 PersonProtocol 协议中定义的属性和方法来访问数组中的每个项目,无论实际类型如何。让我们看看这个例子:
for person in people {
print("\(person.firstName)\(person.lastName):\(person.profession)")
}
当我们定义变量、常量、集合类型等类型为协议类型时,我们可以使用符合该协议的任何类型的实例。这是一个非常重要的概念,也是使协议和协议扩展如此强大的许多事情之一。
当我们使用协议来访问实例时,如前一个示例所示,我们仅限于使用协议本身定义的属性和方法。如果我们想使用特定于单个类型的属性或方法,我们需要将实例转换为该类型。
使用协议进行类型转换
类型转换是一种检查实例类型和/或将实例视为指定类型的方法。在 Swift 中,我们使用is关键字来检查实例是否是特定类型,并使用as关键字将实例视为特定类型。
首先,让我们看看我们如何使用is关键字检查实例类型。以下示例显示了如何进行此操作:
for person in people {
if let p = person as? SwiftProgrammer {
print("\(person.firstName) is a Swift Programmer")
}
}
在这个示例中,我们使用if条件语句来检查people数组中的每个元素是否是SwiftProgrammer类型的实例,如果是,我们将打印出该人是 Swift 程序员到控制台。虽然这是一个检查我们是否有特定类或结构体实例的好方法,但如果我们要检查多个类型,则效率不高。使用switch语句会更高效,如下一个示例所示:
for person in people {
switch person {
case is SwiftProgrammer:
print("\(person.firstName) is a Swift Programmer")
case is FootballPlayer:
print("\(person.firstName) is a Football Player")
default:
print("\(person.firstName) is an unknown type")
}
}
在前一个示例中,我们展示了如何使用switch语句检查数组中每个元素的实例类型。为此检查,我们在每个case语句中使用is关键字尝试匹配实例类型。
在第六章,控制流中,我们看到了如何使用where语句来过滤条件语句。我们还可以使用where语句与is关键字一起过滤数组,如下面的示例所示:
for person in people where person is SwiftProgrammer {
print("\(person.firstName) is a Swift Programmer")
}
现在,让我们看看我们如何将类或结构的实例转换为特定类型。为此,我们将使用as关键字。由于类型转换可能会失败,如果实例不是指定类型,则as关键字有两种形式:as?和as!。使用as?形式,如果转换失败,它返回nil,而使用as!形式,如果转换失败,我们得到运行时错误。因此,除非我们绝对确定实例类型或在我们进行转换之前检查实例类型,否则建议使用as?形式。
虽然我们在本书中展示了使用as!进行类型转换的示例,以便您了解它的存在,但我们强烈建议您不要在项目中使用它,因为它可能导致运行时错误。
让我们看看我们如何使用as?关键字将类或结构的实例转换为指定类型:
for person in people {
if let p = person as? SwiftProgrammer {
print("\(person.firstName) is a Swift Programmer")
}
}
由于as?关键字返回一个可选值,我们使用可选绑定来执行类型转换,如下面的示例所示。
现在我们已经涵盖了协议的基础知识,让我们深入探讨 Swift 中最激动人心的特性之一:协议扩展。
协议扩展
协议扩展使我们能够将协议扩展以提供符合类型的方法和属性实现。它们还允许我们为所有符合类型的提供通用实现,从而消除了在每个单独的类型中提供实现或创建类层次结构的需要。虽然协议扩展可能看起来并不太令人兴奋,但一旦你看到它们真正的强大之处,它们将改变你对代码思考和编写的看法。
让我们从如何在一个非常简单的例子中使用协议扩展开始。我们将首先定义一个名为Dog的协议,如下所示:
protocol Dog {
var name: String { get set }
var color: String { get set }
}
使用此协议,我们声明任何符合Dog协议的类型都必须具有名为name和color的String类型的两个属性。接下来,让我们定义符合此Dog协议的三个类型。我们将这些类型命名为JackRussel、WhiteLab和Mutt。以下代码展示了我们如何定义这些类型:
struct JackRussel: Dog{
var name: String
var color: String
}
class WhiteLab: Dog{
var name: String
var color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
}
struct Mutt: Dog{
var name: String
var color: String
}
我们故意将JackRussel和Mutt类型创建为结构体,将WhiteLab类型创建为类,以展示两种类型设置的不同之处,并说明在协议和协议扩展方面它们是如何被同等对待的。
在这个例子中,我们可以看到最大的区别是结构体类型提供了一个默认的初始化器,但在类中我们必须提供初始化器来填充属性。
现在假设我们想要为符合协议的每个类型提供一个名为speak的方法。在协议扩展之前,我们会在协议中添加方法定义,如下面的代码所示:
protocol Dog{
var name: String { get set }
var color: String { get set }
func speak() -> String
}
一旦在协议中定义了方法,我们就需要在每个符合协议的类型中提供方法的实现。根据符合此协议的类型数量,这可能需要一些时间来实现,并且可能会影响大量的代码。以下代码示例展示了我们可能如何实现此方法:
struct JackRussel: Dog{
var name: String
var color: String
func speak() -> String {
return "Woof"
}
}
class WhiteLab: Dog{
var name: String
var color: String
init(name: String, color: String) {
self.name = nameself.color = color}
func speak() -> String {
return "Woof"
}
}
struct Mutt: Dog{
var name: String
var color: String
func speak() -> String {
return "Woof Woof"
}
}
虽然这个方法有效,但效率并不高,因为每次我们更新协议时,都需要更新所有符合该协议的类型,因此会重复大量的代码,如本例所示。如果我们需要更改 speak()方法的默认行为,我们就必须进入每个实现并更改该方法。这就是协议扩展发挥作用的地方。
使用协议扩展,我们可以将speak()方法定义从协议本身中提取出来,并在协议扩展中定义其默认行为。
如果我们在协议扩展中实现一个方法,我们不需要在协议中定义它。
以下代码展示了我们如何定义协议和协议扩展:
protocol Dog{
var name: String { get set }
var color: String { get set }
}
extension Dog{
func speak() -> String {
return "Woof Woof"
}
}
我们首先定义了具有原始两个属性的Dog协议。然后我们创建了一个扩展该协议的协议扩展,其中包含speak()方法的默认实现。使用这段代码,我们不需要在所有符合Dog协议的类型中提供speak()方法的实现,因为它们会自动作为协议的一部分接收该实现。
让我们通过将符合Dog协议的三个类型恢复到它们的原始实现来查看这是如何工作的;然后它们应该会从协议扩展中接收speak()方法:
struct JackRussel: Dog{
var name: String
var color: String
}
class WhiteLab: Dog{
var name: String
var color: String
init(name: String, color: String) {
self.name = name
self.color = color
}
}
struct Mutt: Dog{
var name: String
var color: String
}
我们现在可以使用每种类型,如下面的代码所示:
let dash = JackRussel(name: "Dash", color: "Brown and White")
let lily = WhiteLab(name: "Lily", color: "White")
let maple = Mutt(name: "Buddy", color: "Brown")
let dSpeak = dash.speak() // returns "woof woof"
let lSpeak = lily.speak() // returns "woof woof"
let bSpeak = maple.speak() // returns "woof woof"
如此例所示,通过将speak()方法添加到Dog协议扩展中,我们自动将该方法添加到所有符合该协议的类型中。协议扩展中的speak()方法可以被视为方法的默认实现,因为我们能够在类型实现中覆盖它。例如,我们可以在Mutt结构中覆盖speak()方法,如下面的代码所示:
struct Mutt: Dog{
var name: String
var color: String
func speak() -> String {
return "I am hungry"
}
}
当我们为Mutt类型的实例调用speak()方法时,它将返回I am hungry字符串。
在本章中,我们使用协议后缀命名我们的协议。这样做是为了使这一点非常明确,这是一个协议。这通常不是我们命名类型的方式。以下示例给出了我们如何正确命名协议的更好示例。您可以在 Swift API 设计指南中阅读有关 Swift 命名约定的更多信息:swift.org/documentation/api-design-guidelines/#general-conventions。
现在我们已经看到了如何使用协议和协议扩展,让我们看看一个更贴近现实世界的例子。
现实世界的例子
在多个平台(iOS、Android 和 Windows)的众多应用程序中,我需要在用户输入时验证用户输入。这种验证可以使用正则表达式非常容易地完成;然而,我们不希望在代码中散布各种正则表达式。通过创建包含验证代码的不同类或结构,可以很容易地解决这个问题。然而,我们必须组织这些类型,以便它们易于使用和维护。在 Swift 的协议扩展之前,我会使用一个协议来定义验证要求,然后为每个需要的验证创建符合该协议的结构。让我们看看这种方法在协议扩展之前是如何工作的。
正则表达式是一系列字符,它定义了一个特定的模式。然后可以使用该模式来搜索字符串,以查看字符串是否与模式匹配或包含模式匹配。大多数主要编程语言都包含正则表达式解析器,如果您不熟悉正则表达式,了解它们可能是有益的。
以下代码展示了 TextValidating 协议,它定义了我们想要用于文本验证的任何类型的必要要求:
protocol TextValidating {
var regExMatchingString: String { get }
var regExFindMatchString: String { get }
var validationMessage: String { get }
func validateString(str: String) -> Bool
func getMatchingString(str: String) -> String?
}
Swift API 设计指南(swift.org/documentation/api-design-guidelines/)指出,描述“是什么”的协议应命名为名词,而描述“能做什么”的协议应使用后缀 -able、-ible 或 -ing。考虑到这一点,我们命名文本验证协议为 TextValidating。
在此协议中,我们定义了三个属性和两个方法,任何符合协议的类型都必须实现。这三个属性如下:
-
regExMatchingString:这是一个正则表达式字符串,用于验证输入字符串是否只包含有效字符。 -
regExFindMatchString:这是一个正则表达式字符串,用于从输入字符串中检索只包含有效字符的新字符串。此正则表达式通常在我们需要实时验证用户输入信息时使用,因为它将找到输入字符串的最长匹配前缀。 -
validationMessage:这是如果输入字符串包含非有效字符时显示的错误信息。
此协议的两个方法如下:
-
validateString:如果输入字符串只包含有效字符,则此方法将返回true。在此方法中,将使用regExMatchingString属性来执行匹配。 -
getMatchingString:此方法将返回一个只包含有效字符的新字符串。此方法通常在我们需要实时验证用户输入信息时使用,因为它将找到输入字符串的最长匹配前缀。我们将在此方法中使用regExFindMatchString属性来检索新字符串。
现在我们来看看如何创建一个符合此协议的结构。以下结构将用于验证输入字符串是否只包含字母字符:
struct AlphaValidation1: TextValidating {
static let sharedInstance = AlphaValidation1()
private init(){}
let regExFindMatchString = "^[a-zA-Z]{0,10}"
let validationMessage = "Can only contain Alpha characters"
var regExMatchingString: String {
get {
return regExFindMatchString + "$"
}
}
func validateString(str: String) -> Bool {
if let _ = str.range(of: regExMatchingString, options: .regularExpression) {
return true
} else {
return false
}
}
func getMatchingString(str: String) -> String? {
if let newMatch = str.range(of: regExFindMatchString,
options:.regularExpression) {
return String(str[newMatch])
} else {
return nil
}
}
}
在此实现中,regExFindMatchString 和 validationMessage 属性是存储属性,而 regExMatchingString 属性是一个计算属性。
我们还在结构内部实现了 validateString() 和 getMatchingString() 方法。
通常,我们会有几个不同的类型符合协议,其中每个类型都会验证不同类型的输入。正如我们可以从 AlphaValidation1 结构中看到的那样,每个验证类型都涉及一些代码。大量的代码在每个类型中也会重复。方法和 regExMatchingString 属性的代码可能会在每个验证类中重复。这并不理想,但如果我们想避免创建一个包含重复代码的超类类层次结构(建议我们优先使用值类型而不是引用类型),在协议扩展之前,我们别无选择。现在让我们看看我们如何使用协议扩展来实现这一点。
使用协议扩展时,我们需要稍微改变一下对代码的思考方式。最大的不同是,我们既不需要也不希望定义协议中的所有内容。在标准协议中,所有你希望通过协议接口访问的方法和属性都必须在协议内部定义。
使用协议扩展时,如果我们打算在协议扩展中定义它,我们更倾向于不在协议中定义属性或方法。因此,当我们用协议扩展重写我们的文本验证类型时,TextValidating 将大大简化,如下所示:
protocol TextValidating {
var regExFindMatchString: String { get }
var validationMessage: String { get }
}
在原始的 TextValidating 协议中,我们定义了三个属性和两个方法。正如我们可以从新的协议中看到的那样,我们现在只定义了两个属性。现在我们已经定义了 TextValidating 协议,让我们为它创建协议扩展:
extension TextValidating {
var regExMatchingString: String {
get {
return regExFindMatchString + "$"
}
}
func validateString(str: String) -> Bool {
if let _ = str.range(of:regExMatchingString, options:.regularExpression){
return true
} else {
return false
}
}
func getMatchingString(str: String) -> String? {
if let newMatch = str.range(of:regExFindMatchString, options:.regularExpression) {
return str.substring(with: newMatch)
} else {
return nil
}
}
}
在 TextValidating 协议扩展中,我们定义了两个方法和一个属性,这些在原始的 TextValidating 协议中已经定义,但在新的协议中没有定义。现在我们已经创建了协议和协议扩展,我们能够定义我们新的文本验证类型。在下面的代码中,我们定义了三个结构,我们将使用这些结构来验证用户输入的文本:
struct AlphaValidation: TextValidating {
static let sharedInstance = AlphaValidation()
private init(){}
let regExFindMatchString = "^[a-zA-Z]{0,10}"
let validationMessage = "Can only contain Alpha characters"
}
struct AlphaNumericValidation: TextValidating {
static let sharedInstance = AlphaNumericValidation()
private init(){}
let regExFindMatchString = "^[a-zA-Z0-9]{0,15}"
let validationMessage = "Can only contain Alpha Numeric characters"
}
struct DisplayNameValidation: TextValidating {
static let sharedInstance = DisplayNameValidation()
private init(){}
let regExFindMatchString = "^[\\s?[a-zA-Z0-9\\-_\\s]]{0,15}"
let validationMessage = "Can only contain Alphanumeric Characters"
}
在每个文本验证结构中,我们创建一个静态常量和私有初始化器,这样我们就可以将结构作为单例使用。有关单例模式的更多信息,请参阅 《Swift 中的设计模式采用》 第二十章的 “单例设计模式” 部分。
在定义了单例模式之后,在每个类型中我们只需设置 regExFindMatchString 和 validationMessage 属性的值。现在我们几乎没有任何重复的代码。唯一重复的代码是单例模式的代码,我们不会想在协议扩展中放入这些代码,因为我们不希望将单例模式强加给所有符合的类型。
现在我们可以使用文本验证类型,如下面的代码所示:
var testString = "abc123"
var alpha = AlphaValidation.sharedInstance alpha.getMatchingString(str:testString)
alpha.validateString(str: testString)
AlphaValidation type. Then getMatchingString() is used to retrieve the longest matching prefix of the test string, which will be abc. Then, the validateString() method is used to validate the test string, but since the test string contains numbers, the method will return false.
现在的问题是,我们真的需要使用协议吗?
我需要使用协议吗?
当你已经知道面向对象编程(OOP)时,还需要使用协议和协议扩展吗?简短的答案是:不需要;然而,强烈推荐。在第十章,协议导向设计中,我们探讨了协议导向设计为何如此强大,以展示为什么你应该在协议导向编程(POP)中优先选择协议而不是面向对象编程(OOP)。通过理解协议和协议导向设计,你会更好地理解 Swift 标准库。
使用合成实现采用协议
Swift 可以在特定情况下自动为Equatable、Hashable和Comparable协议提供协议遵循。这意味着我们不需要编写实现这些协议的样板代码,而是可以使用合成实现。这仅适用于结构体或枚举(不是类)只包含遵循Equatable、Hashable和Comparable协议的存储属性(对于结构体)或关联值(对于枚举)。
Swift 标准库提供了Equatable、Hashable和Comparable协议。任何遵循Equatable协议的类型都可以使用等号运算符(==)来比较该类型的两个实例。任何使用Comparable协议的类型都可以使用比较运算符来比较该类型的两个实例。最后,任何遵循Hashable协议的类型都可以被哈希到Hasher实例中,以生成一个整数哈希值。
让我们来看一个例子。我们将从一个简单的结构体开始,该结构体将存储名称:
struct Name {
var firstName = ""
var lastName = ""
}
现在,我们可以创建三个Name结构体的实例,如下面的代码所示:
let name1 = Name(firstName: "Jon", lastName: "Hoffman")
let name2 = Name(firstName: "John", lastName: "Hoffman")
let name3 = Name(firstName: "Jon", lastName: "Hoffman")
如果我们尝试比较Name结构体的实例,如下面的代码所示,我们可能会收到编译时错误,因为Name结构体没有遵循Equatable协议:
name1 == name2
name1 == name3
为了能够比较Name结构体的实例,所需的所有操作就是声明该结构体遵循Equatable协议,编译时将自动添加执行比较的样板代码。下面的代码展示了如何实现这一点:
struct Name: Equatable {
var firstName = ""
var lastName = ""
}
注意,我们唯一改变的是将Equatable协议添加到结构体定义中。现在,我们能够成功比较Name结构体的实例。
Swift 标准库
Swift 标准库为编写 Swift 应用程序定义了一个基础功能层。在这本书中我们迄今为止使用的一切都来自 Swift 标准库。该库定义了基本数据类型,如String、Int和Double类型。它还定义了集合、可选类型、全局函数以及这些类型遵循的所有协议。
要查看构成标准库的所有内容的最佳网站之一是swiftdoc.org。该网站列出了构成标准库的所有类型、协议、运算符和全局变量,并包含了所有内容的文档。
让我们通过查看文档来了解标准库中协议的使用情况。当你第一次访问主页时,你会看到一个可搜索的列表,列出了构成标准库的所有内容。还有一个你可以从中选择的 Swift 类型完整列表。让我们通过点击 Array 链接来查看 Swift 的 Array 类型。这将带你到 Array 类型的文档页面。
这些文档页面非常有用,包含大量关于构成标准库的各种类型的信息,包括如何使用它们的示例。对于我们讨论的目的,我们感兴趣的章节是标记为 继承 的部分:

图 9.1:继承文档
从继承部分,我们可以看到 Array 符合 7 个协议。如果你点击一些协议,例如 MutableCollection 协议,你会注意到它们符合其他协议。这一点现在可能不太容易理解,但在下一章,即第十章“面向协议设计”中,我们将探讨如何使用面向协议的方法来设计我们的应用程序和框架,然后我们将更好地理解 Swift 标准库是如何编写的。
摘要
在本章中,我们了解到 Swift 将协议视为完整的类型。我们还看到了如何在 Swift 中使用协议实现多态。我们通过深入探讨协议扩展以及如何在 Swift 中使用它们来结束本章。协议和协议扩展是苹果新 POP(面向协议)范式的骨架。这种新的编程模型有可能改变我们编写和思考代码的方式。虽然我们本章没有专门介绍 POP,但掌握本章的主题为我们提供了学习这种新编程模型所需的关于协议和协议扩展的坚实基础。在下一章中,我们将探讨在设计我们的应用程序时如何使用协议和协议扩展。
第十章:协议导向设计
当苹果公司在 2016 年的全球开发者大会(WWDC)上宣布 Swift 2 时,他们也宣布 Swift 是世界上第一个协议导向编程(POP)语言。从其名称来看,我们可能会认为 POP 完全是关于协议;然而,这将是错误的假设。POP 不仅仅是关于协议;它实际上是一种不仅编写应用程序,而且思考编程的新方式。
在本章中,我们将涵盖以下主题:
-
面向对象(OOP)和协议导向(POP)设计之间的区别是什么?
-
协议导向设计是什么?
-
协议组合是什么?
-
协议继承是什么?
在 Dave Abrahams 在 2016 年 WWDC 上关于 POP 的演讲之后,互联网上出现了许多关于 POP 的教程,这些教程采用了非常面向对象的方法。通过这个声明,我的意思是这些教程采取的方法集中在用协议和协议扩展替换超类。虽然协议和协议扩展可能是 POP 中两个更为重要的概念,但这些教程似乎遗漏了一些非常重要的概念。
在本章中,我们将比较协议导向设计和面向对象设计,以突出两者之间的概念差异。我们将探讨如何使用协议和协议扩展来替换超类,以及协议导向设计将如何为我们提供一个更干净、更容易维护的代码库。为此,我们将探讨如何以面向对象和协议导向的方式为视频游戏定义动物类型。让我们首先定义我们动物的需求。
需求
当我们开发应用程序时,我们通常有一套我们需要针对其进行开发的规范。考虑到这一点,让我们定义我们将在本章中创建的动物类型的规范:
-
我们将有三类动物:陆地、海洋和空中。
-
动物可能是多个类别的成员。例如,鳄鱼可以是陆地和海洋类别的成员。
-
当动物位于与它们所在类别匹配的方格上时,它们可能会攻击和/或移动。
-
动物将开始时拥有一定数量的生命值,如果这些生命值达到 0 或以下,它们将被视为死亡。
在此示例中,我们将定义两种动物,Lion和Alligator,但我们知道随着游戏的开发,动物类型的数量将会增长。
我们将首先探讨如何使用面向对象的方法来设计动物类型。
面向对象设计
在我们开始编写代码之前,让我们创建一个非常基本的图表,展示我们将如何设计动物类层次结构。在这个图表中,我们将简单地展示类,而不涉及太多细节。这个图表将帮助我们在大脑中形成类层次结构的图像。图 10.1显示了面向对象设计的类层次结构:

图 10.1:动物类层次结构
图 10.1 显示我们有一个名为Animal的超类,以及两个名为Alligator和Lion的子类。我们可能会认为,根据三个类别(陆地、空中和海洋),我们想要创建一个更大的类层次结构,其中中间层将包含陆地、空中和海洋动物的类。这将使我们能够将每个动物类别的代码分开;然而,根据我们的要求,这是不可能的。这不可能的原因是任何动物类型都可以是多个类别的成员,而在类层次结构中,每个类只能有一个且仅有一个超类。这意味着Animal超类将需要包含为三个类别中每一个所需的代码。
让我们从查看Animal超类的代码开始。
我们将从定义Animal超类的 10 个属性开始。这些属性将定义动物的类型以及它可以执行的类型攻击/移动。我们还定义了一个属性,它将跟踪动物的剩余生命值。
我们将这些属性定义为internal变量。我们将在子类中设置这些属性;然而,我们不想外部实体(除了定义动物的模块之外)更改它们。首选的是将它们作为常量,但在面向对象的方法中;子类不能设置/更改在超类中定义的常量的值。为了使这可行,子类需要与Animal超类定义在同一个模块中:
class Animal {
internal var landAnimal = false
internal var landAttack = false
internal var landMovement = false
internal var seaAnimal = false
internal var seaAttack = false
internal var seaMovement = false
internal var airAnimal = false
internal var airAttack = false
internal var airMovement = false
internal var hitPoints = 0
}
接下来,我们定义一个初始化器,它将设置属性。我们将默认将所有属性设置为false,并将生命值设置为zero。适当的属性将由子类来设置:
init() {
landAnimal = false
landAttack = false
landMovement = false
airAnimal = false
airAttack = false
airMovement = false
seaAnimal = false
seaAttack = false
seaMovement = false
hitPoints = 0
}
由于我们的属性是内部的,我们需要创建一些获取器方法,以便我们可以检索它们的值。我们还将创建一些额外的方法来检查动物是否存活。当动物受到攻击时,我们还需要另一个方法来扣除生命值:
func isLandAnimal() -> Bool {
return landAnimal
}
func canLandAttack() -> Bool {
return landAttack
}
func canLandMove() -> Bool {
return landMovement
}
func isSeaAnimal() -> Bool {
return seaAnimal
}
func canSeaAttack() -> Bool {
return seaAttack
}
func canSeaMove() -> Bool {
return seaMovement
}
func isAirAnimal() -> Bool {
return airAnimal
}
func canAirAttack() -> Bool {
return airAttack
}
func canAirMove() -> Bool {
return airMovement
}
func doLandAttack() {}
func doLandMovement() {}
func doSeaAttack() {}
func doSeaMovement() {}
func doAirAttack() {}
func doAirMovement() {}
func takeHit(amount: Int) {
hitPoints -= amount
}
func hitPointsRemaining() -> Int {
return hitPoints
}
func isAlive() -> Bool {
return hitPoints > 0 ? true : false
}
现在我们有了Animal超类,我们可以创建Alligator和Lion类,它们将是Animal类的子类:
class Lion: Animal {
override init() {
super.init() landAnimal = true
landAttack = true
landMovement = true
hitPoints = 20
}
override func doLandAttack() {
print("Lion Attack")
}
override func doLandMovement() {
print("Lion Move")
}
}
class Alligator: Animal {
override init() {
super.init()
landAnimal = true
landAttack = true
landMovement = true
seaAnimal = true
seaAttack = true
seaMovement = true
hitPoints = 35
}
override func doLandAttack() {
print("Alligator Land Attack")
}
override func doLandMovement() {
print("Alligator Land Move")
}
override func doSeaAttack() {
print("Alligator Sea Attack")
}
override func doSeaMovement() {
print("Alligator Sea Move")
}
}
正如我们所见,这些类为每个动物设置了所需的功能。Lion类包含陆地动物的功能,而Alligator类包含陆地和海洋动物的功能。
这种面向对象设计的另一个缺点是我们没有单个点来定义这种动物的类型(空中、陆地或海洋)。当我们复制粘贴或输入代码时,很容易设置错误的标志或添加错误的功能。这可能导致我们拥有像这样的动物:
class landAnimal: Animal {
override init() {
super.init()
landAnimal = true
airAttack = true
landMovement = true
hitPoints = 20
}
override func doLandAttack() {
print("Lion Attack")
}
override func doLandMovement() {
print("Lion Move")
}
}
在之前的代码中,我们将landAnimal属性设置为 true;然而,我们不小心也将airAttack设置为 true。这将给我们一个可以在陆地上移动但不能攻击的动物,因为landAttack属性没有被设置。希望我们能在测试中捕捉到这类错误;然而,正如我们将在本章后面看到的那样,面向协议的方法将有助于防止这类编码错误。
由于这两个类都有相同的Animal超类,我们可以通过Animal超类提供的接口使用多态来访问它们:
var animals = [Animal]()
animals.append(Alligator())
animals.append(Alligator())
animals.append(Lion())
for (index, animal) in animals.enumerated() {
if animal.isAirAnimal() {
print("Animal at \(index) is Air")
}
if animal.isLandAnimal() {
print("Animal at \(index) is Land")
}
if animal.isSeaAnimal() {
print("Animal at \(index) is Sea")
}
}
我们在这里设计的动物类型是可行的;然而,这种设计有几个缺点。第一个缺点是庞大的单体Animal超类。那些熟悉为视频游戏设计角色的人可能意识到这个超类及其子类缺少了多少功能。这是故意的,这样我们就可以专注于设计而不是功能。对于那些不熟悉为视频游戏设计角色的人来说,当我说这个类可能会变得非常大时,请相信我。
另一个缺点是无法在子类可以设置的父类中定义常量。我们可以为父类定义各种初始化器,这些初始化器将正确设置不同动物类别的常量;然而,随着我们添加更多动物,这些初始化器将变得相当复杂且难以维护。建造者模式可以帮助我们进行初始化,但正如我们即将看到的,面向协议的设计会更好。
我要指出的最后一个缺点是使用标志(landAnimal、seaAnimal和airAnimal属性)来定义动物类型,以及动物可以执行攻击和移动的类型。如果我们没有正确设置这些标志,那么动物的行为将不会正确。例如,如果我们把Lion类中的seaAnimal标志而不是landAnimal标志设置,那么狮子将无法在陆地上移动或攻击。相信我,即使是经验最丰富的开发者,也很容易设置错误的标志。
现在我们来看看我们如何以面向协议的方式定义相同的功能。
面向协议的设计
就像我们的面向对象设计一样,我们将从一个显示所需类型及其之间关系的图表开始。图 10.2展示了我们的面向协议设计:

图 10.2:面向协议的设计
如我们所见,POP 设计与 OOP 设计相当不同。在这个设计中,我们使用了三种使 POP 与 OOP 显著不同的技术。这些技术是协议继承、协议组合和协议扩展。我们在上一章中讨论了协议扩展,但尚未涉及协议继承或组合。理解这些概念很重要,因此在我们进入设计之前,让我们看看协议继承和协议组合是什么。
协议继承
协议继承是指一个协议可以继承一个或多个附加协议的要求。这与 OOP 中的类继承类似,但不同的是,我们继承的是要求,而不是功能。我们还可以从多个协议中继承要求,而 Swift 中的类只能有一个超类。让我们首先定义四个协议,分别命名为Name、Age、Fur和Hair:
protocol Name {
var firstName: String { get set }
var lastName: String { get set }
}
protocol Age {
var age: Double { get set }
}
protocol Fur {
var furColor: String { get set }
}
protocol Hair {
var hairColor: String { get set }
}
四个协议各有不同的要求。
有一点我想指出。如果你发现自己正在创建具有单一要求的协议(如本例所示),你可能需要重新考虑你的整体设计。协议不应该这样细粒度,因为我们最终会拥有太多的协议,而且它们变得难以管理。我们在这里使用较小的协议作为示例。
现在,让我们看看我们如何使用这些协议和协议继承来创建额外的协议。我们将定义另外两个协议,分别命名为Person和Dog:
protocol Person: Name, Age, Hair {
var height: Double { get set }
}
protocol Dog: Name, Age, Fur {
var breed: String { get set }
}
在这个例子中,任何符合Person协议的类型都需要满足Name、Age和Hair协议的要求,以及Person协议本身定义的要求。任何符合Dog协议的类型都需要满足Name、Age和Fur协议的要求,以及Dog协议本身定义的要求。这是协议继承的基础,其中我们可以有一个协议继承一个或多个协议的要求。
协议继承非常强大,因为我们可以定义几个较小的协议,并将它们混合/匹配以创建较大的协议。你需要注意不要创建过于细粒度的协议,因为它们将变得难以维护和管理。
协议组合
协议组合允许类型符合多个协议。这是面向协议设计相对于面向对象设计所具有的许多优势之一。在面向对象设计中,一个类只能有一个超类。这可能导致非常庞大且单一的超级类,正如我们在本章的“面向对象设计”部分所看到的。在面向协议设计中,我们鼓励创建多个具有非常具体要求的较小协议。让我们看看协议组合是如何工作的。
让我们在“协议继承”部分的示例中添加另一个协议,命名为Occupation:
protocol Occupation {
var occupationName: String { get set }
var yearlySalary:Double { get set }
var experienceYears: Double { get set }
}
接下来,让我们创建一个新的类型Programmer,它将符合Person和Occupation协议:
struct Programmer: Person, Occupation {
var firstName: String
var lastName: String
var age: Double
var hairColor: String
var height: Double
var occupationName: String
var yearlySalary: Double
var experienceYears: Double
}
在这个示例中,Programmer结构符合Person和Occupation协议的所有要求。请记住,Person协议是由Name、Age、Hair和Person协议的要求组合而成的;因此,Programmer类型需要符合所有这些协议以及Occupation协议。
再次提醒大家,不要让你们的协议过于细化。协议继承和组合是非常强大的功能,但如果使用不当也可能导致问题。
协议组合和继承本身可能看起来并不那么强大;然而,当我们结合协议扩展时,就形成了一种非常强大的编程范式。让我们看看这种范式有多强大。
协议式设计——整合一切
我们将首先将Animal超类重写为一个协议:
protocol Animal {
var hitPoints: Int { get set }
}
在Animal协议中,我们定义的唯一项目是hitPoints属性。如果我们在一个视频游戏中为动物添加所有要求,这个协议将包含所有动物共有的要求。为了与我们的面向对象设计保持一致,我们只需要将hitPoints属性添加到这个协议中。
接下来,我们需要添加一个Animal协议扩展,它将包含所有符合该协议的类型共有的功能。我们的Animal协议扩展将包含以下代码:
extension Animal {
mutating func takeHit(amount: Int) {
hitPoints -= amount
}
func hitPointsRemaining() -> Int {
return hitPoints
}
func isAlive() -> Bool {
return hitPoints > 0 ? true : false
}
}
Animal协议扩展包含了我们在面向对象示例中看到的Animal超类中的相同takeHit()、hitPointsRemaining()和isAlive()方法。任何符合Animal协议的类型将自动继承这三个方法。
现在让我们定义我们的LandAnimal、SeaAnimal和AirAnimal协议。这些协议将分别定义land、sea和air动物的要求:
protocol LandAnimal: Animal {
var landAttack: Bool { get }
var landMovement: Bool { get }
func doLandAttack()
func doLandMovement()
}
protocol SeaAnimal: Animal {
var seaAttack: Bool { get }
var seaMovement: Bool { get }
func doSeaAttack()
func doSeaMovement()
}
protocol AirAnimal: Animal {
var airAttack: Bool { get }
var airMovement: Bool { get }
func doAirAttack()
func doAirMovement()
}
与面向对象设计中的Animal超类不同,这三个协议只包含特定类型动物所需的功能。每个协议只包含四行代码,而面向对象示例中的Animal超类包含的内容则显著更多。这使得我们的协议设计更容易阅读和管理。协议设计也更为安全,因为各种动物类型的功能被隔离在其各自的协议中,而不是嵌入在一个巨大的超类中。我们还能避免使用标志来定义动物类别,而是通过动物符合的协议来定义类别。
在完整的设计中,我们可能需要为每种动物类型添加一些协议扩展,但再次强调,为了与我们的面向对象设计保持一致,我们在这里的示例中不需要它们。
现在,让我们看看我们如何使用面向协议的设计来创建我们的Lion和Alligator类型:
struct Lion: LandAnimal {
var hitPoints = 20
let landAttack = true
let landMovement = true
func doLandAttack() {
print("Lion Attack")
}
func doLandMovement() {
print("Lion Move")
}
}
struct Alligator: LandAnimal, SeaAnimal {
var hitPoints = 35
let landAttack = true
let landMovement = true
let seaAttack = true
let seaMovement = true
func doLandAttack() {
print("Alligator Land Attack")
}
func doLandMovement() {
print("Alligator Land Move")
}
func doSeaAttack() {
print("Alligator Sea Attack")
}
func doSeaMovement() {
print("Alligator Sea Move")
}
}
注意,我们指定Lion类型符合LandAnimal协议,而Alligator类型符合LandAnimal和SeaAnimal协议。正如我们之前所看到的,一个类型符合多个协议被称为协议组合,这正是我们能够使用较小的协议而不是像面向对象示例中那样使用一个巨大的单体超类的原因。
Lion和Alligator类型都源自Animal协议;因此,它们将继承Animal协议扩展中添加的功能。如果我们的动物类型协议也有扩展,那么它们也会继承那些扩展添加的功能。通过协议继承、组合和扩展,我们的具体类型只包含它们所符合的特定动物类型所需的功能,这与面向对象设计不同,在面向对象设计中,每个动物都会包含从庞大的单一超类中继承的所有功能。
由于Lion和Alligator类型源自Animal协议,我们仍然可以使用多态性,就像我们在面向对象示例中所做的那样。让我们看看这是如何工作的:
var animals = [Animal]()
animals.append(Alligator())
animals.append(Alligator())
animals.append(Lion())
for (index, animal) in animals.enumerated() {
if let _ = animal as? AirAnimal {
print("Animal at \(index) is Air")
}
if let _ = animal as? LandAnimal {
print("Animal at \(index) is Land")
}
if let _ = animal as? SeaAnimal {
print("Animal at \(index) is Sea")
}
}
在这个例子中,我们创建了一个将包含名为animals的Animal类型的数组。然后我们创建了两个Alligator类型的实例和一个Lion类型的实例,并将它们添加到animals数组中。最后,我们使用for-in循环遍历数组,根据实例符合的协议打印出动物类型。
使用协议的 where 语句
使用协议,我们能够使用where语句来过滤我们类型的实例。例如,如果我们只想获取符合SeaAnimal协议的实例,我们可以创建一个如下所示的for循环:
for (index, animal) in animals.enumerated() where animal is SeaAnimal {
print("Only Sea Animal: \(index)")
}
这将只检索符合SeaAnimal协议的动物。这比我们在面向对象设计示例中使用标志更安全。
结构体与类
你可能已经注意到,在面向对象设计中我们使用了类,而在面向协议的设计示例中我们使用了结构体。类是引用类型,是面向对象编程的支柱之一,每个主要的面向对象编程语言都使用它们。对于 Swift,苹果公司表示我们应该优先选择值类型(结构体)而不是引用类型(类)。虽然对于有丰富面向对象编程经验的人来说这看起来可能有些奇怪,但这个建议有几个很好的理由。
在我看来,使用结构体(值类型)而不是类的主要原因是我们获得性能提升。值类型不会产生引用类型所承担的额外引用计数开销。值类型也存储在栈上,与存储在堆上的引用类型相比,提供了更好的性能。还值得一提的是,在 Swift 中复制值相对便宜。
请记住,随着我们的值类型变得越来越大,复制的性能成本可能会抵消值类型带来的其他性能优势。在 Swift 标准库中,苹果公司已经实现了写时复制行为,以减少复制大型值类型所产生的高额开销。
使用写时复制行为,当我们将值类型赋值给新变量时,并不会立即创建其新副本。复制操作会推迟到其中一个实例改变值时才进行。这意味着,如果我们有一个包含一百万个数字的数组,当我们把该数组传递给另一个数组时,我们不会复制这 一百万个数字,直到其中一个数组发生变化。这可以大大减少从复制值类型实例所产生的高额开销。
值类型也比引用类型更安全,因为我们没有多个引用指向同一个实例,正如我们在第十八章“内存管理”中将要讨论的强引用循环那样。值类型也更安全,因为我们不会因为常见的编程错误(如内存泄漏)而产生内存泄漏,例如我们将要讨论的强引用循环。
如果你对本节中讨论的一些项目不理解,请不要担心。需要理解的是,值类型,如结构体,更安全,并且在大多数情况下,与引用类型(如类)相比,在 Swift 中提供更好的性能。
摘要
随着我们阅读本章并观察面向协议设计相对于面向对象设计的优势,我们可能会认为面向协议设计明显优于面向对象设计。然而,这个假设并不完全正确。
面向对象设计自 20 世纪 70 年代以来一直存在,是一种经过验证的编程范式。面向协议的设计是新兴的,旨在纠正面向对象设计的一些问题。
面向对象和面向协议的设计有相似的哲学,例如创建自定义类型来模拟现实世界对象,以及多态性以使用单个接口与多个类型交互。不同之处在于这些哲学是如何实现的。
对我来说,使用面向协议设计的项目代码库比使用面向对象设计的项目更安全、更容易阅读、更容易维护。这并不意味着我将完全停止使用面向对象设计。我仍然可以在某些情况下看到类层次结构的需要。
记住,当我们设计我们的应用程序时,我们应该始终使用适合的工具来完成工作。我们不想用链锯来切割一块 2 x 4 英寸的木材,但同样,我们也不想用电锯来砍伐树木。因此,赢家是那些可以选择使用不同编程范式而不是仅限于一种的程序员。在下一章中,我们将探讨泛型。
第十一章:泛型
我第一次接触泛型是在 2004 年,当时它们首次在 Java 编程语言中引入。我仍然记得我拿起我的 《Java 编程语言,第四版》 的副本,它涵盖了 Java 5,并阅读了关于 Java 泛型实现的内容。从那时起,我在几个项目中使用了泛型,不仅是在 Java 中,还在其他语言中。如果你熟悉其他语言中的泛型,如 Java,Swift 使用的语法将会非常熟悉。泛型允许我们编写非常灵活和可重用的代码;然而,就像与子脚本一样,我们需要确保我们正确地使用它们,并且不要过度使用它们。
在本章中,我们将涵盖以下主题:
-
什么是泛型?
-
如何创建和使用泛型函数
-
如何创建和使用泛型类型
-
如何使用协议关联类型
泛型的介绍
泛型的概念已经存在了一段时间,所以对于来自像 Java 或 C# 这样的语言的开发者来说,这应该不是一个新的概念。Swift 的泛型实现与这些语言非常相似。对于那些来自没有泛型的语言,如 Objective-C 的开发者来说,它们可能一开始看起来有些陌生,但一旦开始使用,你就会意识到它们是多么强大。
泛型允许我们编写非常灵活和可重用的代码,从而避免重复。在像 Swift 这样的类型安全语言中,我们经常需要编写适用于多种类型的函数、类和结构体。没有泛型,我们需要为每个我们希望支持的类型编写单独的函数;然而,有了泛型,我们可以编写一个通用的函数来为多种类型提供功能。泛型允许我们告诉一个函数或类型,“我知道 Swift 是一个类型安全语言,但我还不知道需要哪种类型。我现在给你一个占位符,稍后我会告诉你应该使用哪种类型。”
在 Swift 中,我们有能力定义泛型函数和泛型类型。让我们首先看看泛型函数。
通用函数
让我们先来分析泛型试图解决的问题,然后我们将看到泛型是如何解决这个问题的。假设我们想要创建交换两个变量值的函数,正如本章第一部分所描述的;然而,对于我们的应用程序,我们需要交换两个 integer 类型、两个 Double 类型以及两个 String 类型的值。以下代码展示了这些函数可能的样子:
func swapInts(a: inout Int,b: inout Int) {
let tmp = a
a = b
b = tmp
}
func swapDoubles(a: inout Double,b: inout Double) {
let tmp = a
a = b
b = tmp
}
func swapStrings(a: inout String, b: inout String) {
let tmp = a
a = b
b = tmp
}
使用这三个函数,我们可以交换两个 Integer 类型、两个 Double 类型以及两个 String 类型的原始值。现在,假设我们在进一步开发应用程序时发现我们还需要交换两个无符号 Integer 类型、两个 Float 类型以及一些自定义类型的值。我们可能会轻易地得到八个或更多的交换函数。最糟糕的部分是,这些函数之间唯一的区别是参数类型的变化。虽然这个解决方案是可行的,但泛型提供了一个更简单、更优雅的解决方案,消除了所有重复的代码。让我们看看如何将前面三个函数压缩成一个泛型函数:
func swapGeneric<T>(a: inout T, b: inout T) {
let tmp = a
a = b
b = tmp
}
让我们看看我们是如何定义 swapGeneric 函数的。函数本身看起来与普通函数非常相似,只是多了个大写 T。在 swapGeneric 函数中使用的大写 T 是一个占位符类型,告诉 Swift 我们将在稍后定义该类型。当类型被定义时,它将替换所有占位符。
要定义一个泛型函数,我们在函数名称后面包含两个尖括号 (<T>) 中的占位符类型。然后我们可以使用该占位符类型来代替参数定义、返回类型或函数本身中的任何类型定义。需要记住的是,一旦占位符被定义为类型,所有其他占位符都假定该类型。因此,使用该占位符定义的任何变量或常量都必须符合该类型。
大写 T 没有什么特别之处;我们可以用任何有效的标识符代替 T。我们还可以使用描述性的名称,例如键和值,就像 Swift 语言在字典中使用的那样。以下定义是完全有效的:
func swapGeneric<G>(a: inout G, b: inout G) {
//Statements
}
func swapGeneric<xyz>(a: inout xyz, b: inout xyz) {
//Statements
}
在大多数文档中,通用占位符是用 T(表示类型)或 E(表示元素)定义的。在本章的目的上,我们将使用大写 T 来定义通用占位符。在代码中定义通用占位符时使用大写 T 也是一个好的实践,这样在稍后查看代码时可以轻松识别占位符。
如果你不喜欢使用大写 T 或大写 E 来定义泛型,尽量保持一致性。我建议你在整个代码中避免使用不同的标识符来定义泛型。
如果我们需要使用多个泛型类型,我们可以通过逗号分隔来创建多个占位符。以下示例展示了如何为单个函数定义多个占位符:
func testGeneric<T,E>(a: T, b: E) {
//Statements
}
在这个例子中,我们定义了两个通用的占位符,T 和 E。在这种情况下,我们可以将 T 占位符设置为一种类型,而将 E 占位符设置为不同的类型。
让我们看看如何调用一个泛型函数。以下代码将使用 swapGeneric<T>(inout a: T, inout b: T) 函数交换两个整数:
var a = 5
var b = 10
swapGeneric(a: &a, b: &b)
print("a:\(a) b:\(b)")
如果我们运行此代码,输出将是a: 10 b: 5。我们可以看到,我们不需要做任何特别的事情就可以调用泛型函数。函数从第一个参数推断类型,然后设置所有剩余的占位符为此类型。现在,如果我们需要交换两个字符串的值,我们将以如下方式调用相同的函数:
var c = "My String 1"
var d = "My String 2"
swapGeneric(a: &c, b: &d)
print("c:\(c) d:\(d)")
我们可以看到,函数的调用方式与我们想要交换两个整数时调用的方式完全相同。我们无法做到的一件事是将两种不同的类型传递给交换函数,因为我们只定义了一个泛型占位符。如果我们尝试运行以下代码,我们将收到一个错误:
var a = 5
var c = "My String 1"
swapGeneric(a: &a, b: &c)
我们将收到的错误是,无法将类型String的值转换为期望的参数类型Int,这告诉我们我们试图在期望Int值时使用String值。函数寻找Int值的原因是因为我们传递给函数的第一个参数是一个Int值,因此,函数中的所有泛型类型都变为Int类型。
现在,假设我们有一个以下这样的函数,它定义了多个泛型类型:
func testGeneric<T,E>(a: T, b: E) {
print("\(a)\(b)")
}
此函数将接受不同类型的参数;然而,由于它们是不同类型的,我们将无法交换它们的值,因为它们是不同的。泛型还有一些其他限制。例如,我们可能会认为以下泛型函数是有效的;然而,如果我们尝试实现它,我们将收到一个错误:
func genericEqual<T>(a: T, b: T) -> Bool{
return a == b
}
我们收到一个错误,因为二元运算符==不能应用于两个T操作数。由于在编译代码时参数类型是未知的,Swift 不知道它是否可以在这些类型上使用等号运算符,因此抛出错误。我们可能会认为这是一个将使泛型难以使用的限制。然而,我们有一种方法可以告诉 Swift,我们期望由占位符表示的类型具有某种功能。这是通过类型约束实现的。
类型约束指定泛型类型必须继承自特定类或符合特定协议。这允许我们在泛型函数中使用由父类或协议定义的方法或属性。让我们看看如何通过重写genericEqual函数以使用Comparable协议来使用类型约束:
func testGenericComparable<T: Comparable>(a: T, b: T) -> Bool{
a == b
}
要指定类型约束,我们在泛型占位符之后放置类或协议约束,其中泛型占位符和约束由冒号分隔。这个新函数按我们预期的方式工作,它将比较两个参数的值,如果它们相等则返回true,如果不相等则返回false。
我们可以声明多个约束,就像我们声明多个泛型类型一样。以下示例展示了如何声明具有不同约束的两个泛型类型:
func testFunction<T: MyClass, E: MyProtocol>(a: T, b: E) {
//Statements
}
在这个函数中,由 T 占位符定义的类型必须继承自 MyClass 类,由 E 占位符定义的类型必须符合 MyProtocol 协议。现在我们已经了解了泛型函数,让我们看看泛型类型。
泛型类型
当我们查看 Swift 数组和字典时,我们已经对泛型类型的工作原理有一个一般性的介绍。泛型类型是一个类、结构体或枚举,它可以与任何类型一起工作,就像 Swift 数组和字典一样工作。正如我们回忆的那样,Swift 数组和字典被编写成可以包含任何类型。问题是,我们无法在数组或字典中混合和匹配不同的类型。当我们创建泛型类型的实例时,我们定义了实例将与之一起工作的类型。在定义了那个类型之后,我们无法为那个实例更改类型。
为了演示如何创建泛型类型,让我们创建一个简单的 List 类。这个类将使用 Swift 数组作为列表的后端存储,并允许我们向列表中添加项目或从列表中检索值。
让我们先看看如何定义我们的泛型 List 类型:
class List<T> {
}
之前的代码定义了通用的 List 类型。我们可以看到,我们使用 <T> 标签来定义一个通用的占位符,就像我们在定义通用函数时做的那样。这个 T 占位符然后可以在类型的任何地方使用,而不是使用具体的类型定义。
要创建此类型的实例,我们需要定义列表将包含的项目类型。以下示例展示了如何为各种类型创建泛型 List 类型的实例:
var stringList = List<String>()
var intList = List<Int>()
var customList = List<MyObject>()
之前的示例创建了 List 类的三个实例。stringList 实例可以与 String 类型的实例一起使用,intList 实例可以与 integer 类型的实例一起使用,而 customList 实例可以与 MyObject 类型的实例一起使用。
我们不仅限于在类中使用泛型。我们还可以将结构和枚举定义为泛型。以下示例展示了如何定义一个泛型结构和泛型枚举:
struct GenericStruct<T> {
}
enum GenericEnum<T> {
}
现在让我们将后端存储数组添加到我们的 List 类中。存储在这个数组中的项目需要与我们初始化类时定义的类型相同;因此,我们将使用 T 占位符来定义数组的定义。以下代码显示了具有名为 items 的数组的 List 类。items 数组将使用 T 占位符定义,因此它将包含我们为类定义的相同类型:
class List<T> {
var items = [T]()
}
此代码定义了我们的泛型 List 类型,并使用 T 作为类型占位符。然后我们可以在类的任何地方使用这个 T 占位符来定义项目的类型。那个项目将是我们在创建 List 类的实例时定义的相同类型。因此,如果我们创建一个 List 类型的实例,例如 var stringList = List<String>(),则 items 数组将是一个字符串实例的数组。如果我们创建一个 List 类型的实例,例如 var intList = List<Int>(),则 items 数组将是一个整数实例的数组。
现在,我们需要创建 add() 方法,该方法将用于向列表中添加项目。我们将在方法声明中使用 T 占位符来定义 item 参数将与我们在初始化类时声明的相同类型。因此,如果我们创建一个 List 类型的实例来使用 String 类型,我们就必须使用 String 类型的实例作为 add() 方法的参数。然而,如果我们创建一个 List 类型的实例来使用 Int 类型,我们就必须使用 Int 类型的实例作为 add() 方法的参数。
下面是 add() 函数的代码:
func add(item: T) {
items.append(item)
}
要创建一个独立的泛型函数,我们在函数名后添加 <T> 声明来声明它是一个泛型函数;然而,当我们在一个泛型类型中使用泛型方法时,我们不需要 <T> 声明。相反,我们只需要使用我们在类声明中定义的类型。如果我们想引入另一个泛型类型,我们可以在方法声明中定义它。
现在,让我们添加 getItemAtIndex() 方法,该方法将返回后端数组中指定索引处的项目:
func getItemAtIndex(index: Int) -> T? {
if items.count>index {
return items[index]
} else {
return nil
}
}
getItemAtIndex() 方法接受一个参数,即我们要检索的项目索引。然后我们使用 T 占位符来指定我们的返回类型是一个可能为 T 类型或可能为 nil 的可选类型。如果后端存储数组在指定的索引处包含项目,我们将返回该项目;否则,我们返回 nil。
现在,让我们看看我们的整个通用 List 类:
class List<T> {
var items = [T]()
func add(item: T) {
items.append(item)
}
func getItemAtIndex(index: Int) -> T? {
guard items.count < index else {
return items[index]
}
return nil
}
}
如我们所见,我们最初在类声明中定义了泛型 T 占位符类型。然后我们在类中使用这个占位符类型。在我们的 List 类中,我们在三个地方使用了它。我们将其用作 items 数组的类型,用作 add() 方法的参数类型,以及用作 getItemAtIndex() 方法中的可选返回类型。
现在,让我们看看如何使用 List 类。当我们使用泛型类型时,我们将在类内部使用尖括号定义要使用的类型,例如 <type>。以下代码显示了如何使用 List 类来存储 String 类型的实例:
var list = List<String>()
list.add(item: "Hello")
list.add(item: "World")
print(list.getItemAtIndex(index: 1))
在前面的代码中,我们首先创建了一个名为list的List类型实例,并指定它将存储String类型的实例。然后我们使用add()方法两次将两个项目存储在list实例中。最后,我们使用getItemAtIndex()方法检索索引号为1的项目,它将在控制台显示Optional(World)。
我们还可以使用多个占位符类型定义我们的泛型类型,类似于我们在泛型方法中使用多个占位符的方式。要使用多个占位符类型,我们用逗号将它们分开。以下示例展示了如何定义多个占位符类型:
class MyClass<T,E>{
//Code
}
然后,我们创建了一个使用String和Int类型实例的MyClass类型实例,如下所示:
var mc = MyClass<String, Int>()
我们还可以使用泛型类型的类型约束。再次强调,为泛型类型使用类型约束与为泛型函数使用类型约束完全相同。以下代码展示了如何使用类型约束来确保泛型类型符合Comparable协议:
class MyClass<T: Comparable>{}
到目前为止,在本章中,我们已经看到了如何使用占位符类型与函数和类型一起使用。现在让我们看看我们如何可以条件性地向泛型类型添加扩展。
使用泛型条件性地添加扩展
如果类型符合协议,我们可以条件性地向泛型类型添加扩展。例如,如果我们只想在T类型符合数字协议的情况下向我们的泛型List类型添加sum()方法,我们可以这样做:
extension List where T: Numeric {
func sum () -> T {
items.reduce (0, +)
}
}
此扩展将sum()方法添加到任何list实例中,其中T类型符合数字协议。这意味着在先前的例子中创建的用于存储String实例的list实例将不会接收此方法。
在以下代码中,我们创建了一个包含整数的List类型实例,该实例将接收sum()方法,并可以像下面这样使用:
var list2 = List<Int>()
list2.add(item: 2)
list2.add(item: 4)
list2.add(item: 6)
print(list2.sum())
此代码的输出将是12。我们也能够在泛型类型或扩展内条件性地添加函数。
条件性地添加函数
条件性地添加扩展,正如我们在上一节中看到的,效果很好;然而,如果我们希望为不同的条件添加不同的功能,我们就必须为每个条件创建单独的扩展。从 Swift 5.3 开始,随着 SE-0267 的引入,我们能够条件性地向泛型类型或扩展添加函数。让我们通过重写上一节中的扩展来查看这一点:
extension List {
func sum () -> T where T: Numeric {
items.reduce (0, +)
}
}
通过此代码,我们将where T: Numeric子句从扩展声明中移出,放入函数声明中。这将条件性地添加函数,如果类型符合Numeric协议。现在我们可以根据不同的条件向扩展添加额外的函数,如下面的代码所示:
extension List {
func sum () -> T where T: Numeric {
items.reduce (0, +)
}
func sorted() -> [T] where T: Comparable {
items.sorted()
}
}
在之前的代码中,我们添加了一个名为 sorted() 的额外函数,该函数仅适用于符合 Comparable 协议的类型实例。这使得我们能够在同一扩展或泛型类型中放置具有不同条件的函数,而不是创建多个扩展。我肯定会推荐像本节中展示的那样条件性地添加函数,而不是像前节中展示的那样条件性地添加扩展。
现在让我们看看条件遵从性。
条件遵从性
条件遵从性允许泛型类型仅在类型满足某些条件时才遵从协议。例如,如果我们想让我们的 List 类型仅在存储在列表中的类型也符合 Equatable 协议时才符合 Equatable 协议,我们可以使用以下代码:
extension List: Equatable where T: Equatable {
static func ==(l1:List, l2:List) -> Bool {
if l1.items.count != l2.items.count {
return false
}
for (e1, e2) in zip(l1.items, l2.items) {
if e1 != e2 {
return false
}
}
return true
}
}
此代码将为任何存储在列表中的类型也符合 Equatable 协议的 List 类型实例添加对 Equatable 协议的遵从性。
这里展示了一个我们之前没有讨论过的新函数:zip() 函数。这个函数将同时遍历两个序列,在我们的例子中是数组,并创建可以比较的配对(e1 和 e2)。
比较函数将首先检查每个数组是否包含相同数量的元素,如果不是,则返回 false。然后它将遍历每个数组,同时比较数组的元素;如果任何一对不匹配,则返回 false。如果之前的测试通过,则返回 true,这表示 list 实例是相等的,因为列表中的元素相同。
现在让我们看看如何向非泛型类型添加泛型下标。
泛型下标
在 Swift 4 之前,如果我们想在下标中使用泛型,我们必须在类或结构体级别定义下标。这迫使我们当感觉应该使用下标时定义泛型方法。从 Swift 4 开始,我们可以创建泛型下标,其中下标的返回类型或其参数可以是泛型的。让我们看看如何创建一个泛型下标。在这个第一个例子中,我们将创建一个接受一个泛型参数的下标:
subscript<T: Hashable>(item: T) -> Int {
return item.hashValue
}
当我们创建一个泛型下标时,我们在 subscript 关键字之后定义占位符类型。在之前的例子中,我们定义了 T 占位符类型,并使用类型约束来确保类型符合 Hashable 协议。这将允许我们传递任何符合 Hashable 协议的类型的实例。
正如我们在本节开头提到的,我们还可以使用泛型作为下标的返回类型。我们定义泛型占位符的方式与定义泛型参数的方式完全相同。以下示例说明了这一点:
subscript<T>(key: String) -> T? {
return dictionary[key] as? T
}
在这个例子中,我们在subscript关键字之后定义了T占位符类型,就像在之前的例子中做的那样。然后我们将此类型用作subscript的返回类型。
关联类型
关联类型声明了一个可以在协议中使用代替类型的占位符名称。实际要使用的类型直到协议被采用时才指定。在创建泛型函数和类型时,我们使用了非常相似的语法,正如我们在本章中看到的。然而,为协议定义关联类型是非常不同的。我们使用associatedtype关键字来指定关联类型。
让我们看看在定义协议时如何使用关联类型。在这个例子中,我们将定义QueueProtocol协议,该协议定义了实现它的队列需要实现的能力:
protocol QueueProtocol {
associatedtype QueueType
mutating func add(item: QueueType)
mutating func getItem() -> QueueType?
func count() -> Int
}
在这个协议中,我们定义了一个关联类型,命名为QueueType。然后我们在协议中两次使用此关联类型:一次作为add()方法的参数类型,一次在我们定义getItem()方法的返回类型时,将其作为可能返回QueueType关联类型或nil的可选类型。
任何实现了QueueProtocol协议的类型都必须能够指定用于QueueType占位符的类型,并且必须确保在协议中使用QueueType占位符的地方只使用该类型的项。
让我们看看如何在非泛型类IntQueue中实现QueueProtocol协议。这个类将使用Integer类型来实现QueueProtocol协议:
class IntQueue: QueueProtocol {
var items = [Int]()
func add(item: Int) {
items.append(item)
}
func getItem() -> Int? {
return items.count > 0 ? items.remove(at: 0) : nil
}
func count() -> Int {
return items.count
}
}
在IntQueue类中,我们首先定义我们的后端存储机制为一个整数类型的数组。然后我们实现QueueProtocol协议中定义的每个方法,将协议中定义的QueueType占位符替换为Int类型。在add()方法中,parameter类型被定义为Int类型的实例,而在getItem()方法中,return类型被定义为可能返回Int类型实例或nil的可选类型。
我们使用IntQueue类的方式就像使用任何其他类一样。以下代码展示了这一点:
var intQ = IntQueue()
intQ.add(item: 2)
intQ.add(item: 4)
print(intQ.getItem()!)
intQ.add(item: 6)
我们首先创建了一个名为intQ的IntQueue类实例。然后我们调用add()方法两次,将两个整数值添加到intQ实例中。然后我们通过调用getItem()方法检索intQ实例中的第一个项目。这一行将数字2打印到控制台。代码的最后一行将另一个整数类型的实例添加到intQ实例中。
在前面的例子中,我们以非泛型的方式实现了QueueProtocol协议。这意味着我们将占位符类型替换为实际类型。QueueType被替换为Int类型。我们还可以使用泛型类型实现QueueProtocol。让我们看看我们将如何做:
class GenericQueue<T>: QueueProtocol {
var items = [T]()
func add(item: T) {
items.append(item)
}
func getItem() -> T? {
return items.count > 0 ? items.remove(at:0) : nil
}
func count() -> Int {
return items.count
}
}
如我们所见,GenericQueue 的实现与 IntQueue 的实现非常相似,除了我们定义了要使用的类型为泛型占位符 T。然后我们可以像使用任何泛型类一样使用 GenericQueue 类。让我们看看如何使用 GenericQueue 类:
var intQ2 = GenericQueue<Int>()
intQ2.add(item: 2)
intQ2.add(item: 4)
print(intQ2.getItem()!)
intQ2.add(item: 6)
我们首先创建一个 GenericQueue 类的实例,该实例将使用 Int 类型,并将其命名为 intQ2。然后,我们调用 add() 方法两次,将两个整型实例添加到 intQ2 实例中。接着,我们使用 getItem() 方法检索队列中添加的第一个项目,并将值打印到控制台。这一行将在控制台打印数字 2。
我们还可以使用关联类型与类型约束。当采用协议时,为关联类型定义的类型必须从类继承或遵守类型约束定义的协议。以下行定义了一个具有类型约束的关联类型:
associatedtype QueueType: Hashable
在这个例子中,我们指定当实现协议时,为关联类型定义的类型必须遵守 Hashable 协议。
摘要
泛型类型非常有用,它们也是 Swift 标准集合类型(数组和字典)的基础;然而,正如本章引言中提到的,我们必须小心正确地使用它们。
在本章中,我们看到了几个示例,展示了泛型如何使我们的生活变得更简单。本章开头展示的 swapGeneric() 函数是一个很好的泛型函数的例子,因为它允许我们交换任何类型的选择的两个值,而只需实现一次交换代码。
通用 List 类型也是一个很好的例子,说明了如何创建自定义集合类型,这些类型可以用来存储任何类型的对象。在本章中,我们实现通用 List 类型的方法与 Swift 使用泛型实现数组和字典的方法类似。
在下一章中,我们将探讨 Swift 中的错误处理以及如何使某些功能仅在用户使用的设备运行特定版本的操作系统时才可用。
第十二章:错误处理和可用性
当我开始用 Objective-C 编写应用程序时,最明显的不足之一是缺乏异常处理。大多数现代编程语言,如 Java 和 C#,使用 try...catch 块或类似的结构进行异常处理。虽然 Objective-C 确实有 try...catch 块,但它并没有在 Cocoa 框架内部使用,而且它从未真正感觉像是语言的一部分。我有丰富的 C 语言经验,因此我能够理解苹果的框架如何接收和响应错误。说实话,我有时甚至更喜欢这种方法,尽管我已经习惯了 Java 和 C# 中的异常处理。当 Swift 首次推出时,我希望能看到苹果将真正的错误处理加入语言中,这样我们就有选择使用它的选项;然而,它并没有在 Swift 的初始版本中。直到 Swift 2 发布,苹果才将错误处理添加到 Swift 中。虽然这种错误处理看起来可能类似于 Java 和 C# 中的异常处理,但它们之间有一些非常显著的不同之处。
我们在本章中将涵盖以下主题:
-
如何表示错误
-
如何在 Swift 中使用
do-catch块 -
如何使用
defer语句 -
如何使用可用性属性
让我们开始吧!
原生错误处理
类似于 Java 和 C# 这样的语言通常将错误处理过程称为异常处理。在 Swift 文档中,苹果将这个过程称为错误处理。虽然从外表上看,Java 和 C# 的异常处理可能看起来与 Swift 的错误处理有些相似,但那些熟悉其他语言中异常处理的人会在本章中注意到一些显著的差异。
表示错误
在我们真正理解 Swift 中的错误处理工作原理之前,我们必须看看我们如何表示一个错误。在 Swift 中,错误由符合 Error 协议的类型值表示。Swift 的枚举非常适合建模错误条件,因为我们通常有有限数量的错误条件需要表示。
让我们看看我们如何使用枚举来表示错误。为此,我们将定义一个虚构的错误 MyError,它有三个错误条件:Minor、Bad 和 Terrible:
enum MyError: Error {
case Minor
case Bad
case Terrible
}
在这个例子中,我们定义了 MyError 枚举符合 Error 协议,并且定义了三个错误条件:Minor、Bad 和 Terrible。这就是定义基本错误条件所需要做的全部。
我们还可以使用与我们的错误条件关联的值来添加有关错误条件的更多详细信息。比如说,我们想要向 Terrible 错误条件添加一个描述。我们会这样做:
enum MyError: Error {
case Minor
case Bad
case Terrible(description:String)
}
熟悉 Java 和 C#中异常处理的人会发现,在 Swift 中表示错误要干净得多,也容易得多,因为我们不需要创建大量的样板代码或一个完整的类。在 Swift 中,它可能只需要定义一个包含我们的错误条件的枚举。另一个优点是,定义多个错误条件并将它们分组在一起非常容易,这样所有相关的错误条件都是同一类型的。
现在,让我们学习如何在 Swift 中建模错误。为了这个例子,我们将看看我们如何为一个棒球队分配球员号码。对于一个棒球队,每个被召回的新球员都会被分配一个唯一的号码。这个号码也必须在一定的范围内,因为只有两个号码可以放在棒球球衣上。
因此,我们将有三个错误条件:号码太大,号码太小,以及号码不唯一。以下示例显示了我们可以如何表示这些错误条件:
enum PlayerNumberError: Error {
case NumberTooHigh(description: String)
case NumberTooLow(description: String)
case NumberAlreadyAssigned
}
使用PlayerNumberError类型,我们定义了三种非常具体的错误条件,这些条件确切地告诉我们出了什么问题。由于这些错误条件都与分配球员号码相关,因此它们也被组合在一个类型中。
这种定义错误的方法允许我们定义非常具体的错误,这样当发生错误条件时,我们的代码可以确切地知道出了什么问题。它还允许我们将错误分组,这样所有相关的错误都可以定义在同一个类型中。
现在我们知道了如何表示错误,让我们看看如何抛出错误。
抛出错误
当函数中发生错误时,调用该函数的代码必须知道这一点;这被称为抛出错误。当函数抛出错误时,它假设调用该函数的代码,或者链中的某些代码,将捕获并适当地从错误中恢复。
要从函数中抛出错误,我们使用throws关键字。这个关键字让调用它的代码知道函数可能会抛出错误。与其他语言的异常处理不同,我们不需要列出可能抛出的具体错误类型。
由于我们不在函数定义中列出可能从函数中抛出的具体错误类型,因此在函数的文档和注释中列出它们是一种良好的实践。这允许使用该函数的其他开发者知道应该捕获哪些错误类型。
很快,我们将看看如何抛出错误。但首先,让我们向之前定义的PlayerNumberError类型添加一个第四个错误。这展示了如何轻松地向我们的错误类型添加错误条件。这个错误条件是在我们尝试通过号码检索球员,但没有球员被分配该号码时抛出的。
新的PlayerNumberError类型现在看起来将类似于以下内容:
enum PlayerNumberError: Error {
case NumberTooHigh(description: String)
case NumberTooLow(description: String)
case NumberAlreadyAssigned
case NumberDoesNotExist
}
为了演示如何抛出错误,让我们创建一个包含给定球队球员列表的 BaseballTeam 结构。这些球员将被存储在一个名为 players 的字典对象中。我们将使用球员的号码作为键,因为我们知道每个球员都必须有一个唯一的号码。用于表示单个球员的 BaseballPlayer 类型将是一个元组类型的 typealias,定义如下:
typealias BaseballPlayer = (firstName: String, lastName: String, number: Int)
在这个 BaseballTeam 结构中,我们将有两个方法。第一个方法将被命名为 addPlayer()。这个方法将接受一个 BaseballPlayer 类型的参数,并尝试将球员添加到球队中。这个方法也可以抛出三种错误条件之一:NumberTooHigh、NumberTooLow 或 NumberAlreadyExists。以下是这个方法的写法:
mutating func addPlayer(player: BaseballPlayer) throws {
guard player.number < maxNumber else {
throw PlayerNumberError.NumberTooHigh(description: "Max number is \(maxNumber)")
}
guard player.number > minNumber else {
throw PlayerNumberError.NumberTooLow(description: "Min number is \(minNumber)")
}
guard players[player.number] == nil else {
throw PlayerNumberError.NumberAlreadyAssigned
}
players[player.number] = player
}
我们可以看到,throws 关键字被添加到了方法的定义中。throws 关键字让任何调用此方法的代码知道它可能会抛出错误,并且必须处理这个错误。然后我们使用三个 guard 语句来验证数字不是太大,不是太小,并且在 players 字典中是唯一的。如果这些条件中的任何一个不满足,我们将使用 throw 关键字抛出适当的错误。如果我们通过了所有三个检查,那么球员就会被添加到 players 字典中。
我们将要添加到 BaseballTeam 结构中的第二个方法是 getPlayerByNumber() 方法。这个方法将尝试检索被分配了给定数字的棒球运动员。如果没有球员被分配了该数字,这个方法将抛出 NumberDoesNotExist 错误。getPlayerByNumber() 方法将看起来像这样:
func getPlayerByNumber(number: Int) throws -> BaseballPlayer {
if let player = players[number] {
return player
} else {
throw PlayerNumberError.NumberDoesNotExist
}
}
我们也把这个 throws 关键字添加到了这个方法定义中;然而,这个方法还有一个返回类型。当我们使用 throws 关键字与返回类型一起时,它必须放在方法定义中的返回类型之前。
在方法内部,我们尝试检索传递给方法的具有该数字的棒球运动员。如果我们能检索到球员,我们就返回它;否则,我们抛出 NumberDoesNotExist 错误。注意,如果我们从具有返回类型的方法中抛出错误,则不需要返回值。
现在,让我们学习如何使用 Swift 捕获错误。
错误处理
当一个函数抛出错误时,我们需要在调用它的代码中捕获它;这是通过使用 do-catch 块来完成的。我们在 do-catch 块中使用 try 关键字来标识代码中可能抛出错误的地方。带有 try 语句的 do-catch 块具有以下语法:
do {
try [Some function that throws]
[Code if no error was thrown]
} catch [pattern] {
[Code if function threw error]
}
如果抛出了错误,它将传播出去,直到被 catch 子句处理。catch 子句由 catch 关键字组成,后面跟着一个用于匹配错误的模式。如果错误与模式匹配,则执行 catch 块内的代码。
让我们通过调用 BaseballTeam 结构的 getPlayerByNumber() 和 addPlayer() 方法来查看如何使用 do-catch 块。首先让我们看看 getPlayerByNumber() 方法,因为它只抛出一个错误条件:
do {
let player = try myTeam.getPlayerByNumber(number: 34)
print("Player is \(player.firstName) \(player.lastName)")
} catch PlayerNumberError.NumberDoesNotExist {
print("No player has that number")
}
在这个示例中,do-catch 块调用了 BaseballTeam 结构的 getPlayerByNumber() 方法。如果没有球员被分配这个号码,该方法将抛出 NumberDoesNotExist 错误条件;因此,我们尝试在 catch 语句中匹配这个错误。
任何在 do-catch 块内抛出错误时,块内的剩余代码将被跳过,并且将执行匹配该错误的 catch 块内的代码。因此,在我们的示例中,如果 getPlayerByNumber() 方法抛出 NumberDoesNotExist 错误条件,第一个 print 语句永远不会被执行。
我们不需要在 catch 语句后面包含一个模式。如果 catch 语句后面没有包含模式,或者我们放入一个下划线,catch 语句将匹配所有错误条件。例如,以下两个 catch 语句中的任何一个都将捕获所有错误:
do {
// our statements
} catch {
// our error conditions
}
do {
// our statements
} catch _ {
// our error conditions
}
如果我们想要捕获错误,我们可以使用 let 关键字,如下面的示例所示:
do {
// our statements
} catch let error {
print("Error:\(error)")
}
现在,让我们看看如何使用 catch 语句,类似于 switch 语句,来捕获不同的错误条件。为此,我们将调用 BaseballTeam 结构的 addPlayer() 方法:
do {
try myTeam.addPlayer(player:("David", "Ortiz", 34))
} catch PlayerNumberError.NumberTooHigh(let description) {
print("Error: \(description)")
} catch PlayerNumberError.NumberTooLow(let description) {
print("Error: \(description)")
} catch PlayerNumberError.NumberAlreadyAssigned {
print("Error: Number already assigned")
}
在这个示例中,我们有三个 catch 语句。每个 catch 语句都有一个不同的模式来匹配;因此,它们将分别匹配不同的错误条件。如您所回忆的,NumberTooHigh 和 NumberToLow 错误条件有相关值。要检索相关值,我们使用括号内的 let 语句,如前例所示。
总是好的做法是将你的最后一个 catch 语句留空,这样它就会捕获之前 catch 语句中所有模式未匹配的错误。因此,前面的示例应该重写如下:
do {
try myTeam.addPlayer(player:("David", "Ortiz", 34))
} catch PlayerNumberError.NumberTooHigh(let description) {
print("Error: \(description)")
} catch PlayerNumberError.NumberTooLow(let description) {
print("Error: \(description)")
} catch PlayerNumberError.NumberAlreadyAssigned {
print("Error: Number already assigned")
} catch {
print("Error: Unknown Error")
}
我们也可以让错误传播出去而不是立即捕获它们。为此,我们只需在函数定义中添加 throws 关键字。例如,在以下示例中,我们不是捕获错误,而是让它传播到调用函数的代码中,如下所示:
func myFunc() throws {
try myTeam.addPlayer(player:("David", "Ortiz", 34))
}
如果我们确定不会抛出错误,我们可以使用强制尝试表达式来调用函数,该表达式写作 try!。强制尝试表达式禁用了错误传播,并将函数调用包装在一个运行时断言中,因此不会从这个调用中抛出错误。如果抛出错误,您将得到运行时错误,所以使用这个表达式时要非常小心。
强烈建议你在生产代码中避免使用强制尝试表达式,因为它可能导致运行时错误并导致你的应用程序崩溃。
当我在 Java 和 C# 等语言中处理异常时,我看到很多空的 catch 块。这就是我们需要捕获异常的地方,因为可能会抛出异常;然而,我们不想对它做任何事情。在 Swift 中,代码看起来可能像这样:
do {
let player = try myTeam.getPlayerByNumber(number: 34)
print("Player is \(player.firstName) \(player.lastName)")
} catch {}
这样的代码是我不喜欢异常处理的地方之一。嗯,Swift 开发者对此有一个答案:try?。这尝试执行可能会抛出错误的操作,并将其转换为可选值;因此,操作的结果将是如果抛出错误则为 nil,如果没有抛出错误则为操作的结果。
由于 try? 的结果以可选形式返回,我们通常会与可选绑定一起使用它。我们可以将前面的示例重写如下:
if let player = try? myTeam.getPlayerByNumber(number: 34) {
print("Player is \(player.firstName) \(player.lastName)")
}
如我们所见,这使得我们的代码更加整洁且易于阅读。
如果我们需要执行清理操作,无论是否发生错误,我们都可以使用 defer 语句。我们使用 defer 语句在代码执行离开当前作用域之前执行一段代码。以下示例显示了我们可以如何使用 defer 语句:
func deferFunction(){
print("Function started")
var str: String?
defer {
print("In defer block")
if let s = str {
print("str is \(s)")
}
}
str = "Jon"
print("Function finished")
}
如果我们调用这个函数,控制台首先打印的将是 Function started。代码的执行将跳过 defer 块,然后控制台将打印 Function finished。最后,在离开函数的作用域之前,将执行 defer 块的代码,我们会看到 In defer block 的消息。以下是这个函数的输出:
Function started
Function finished
In defer block
str is Jon
defer 块将在执行离开当前作用域之前始终被调用,即使抛出了错误。当我们需要确保执行所有必要的清理操作,即使抛出了错误时,defer 语句非常有用。例如,如果我们成功打开一个文件进行写入,我们总是想确保关闭该文件,即使我们在写入操作中遇到错误。
在这种情况下,我们可以将文件关闭功能放在 defer 块中,以确保在离开当前作用域之前文件总是被关闭。
多模式捕获子句
在前面的章节中,我们有一些看起来像这样的代码:
do {
try myTeam.addPlayer(player:("David", "Ortiz", 34))
} catch PlayerNumberError.NumberTooHigh(let description) {
print("Error: \(description)")
} catch PlayerNumberError.NumberTooLow(let description) {
print("Error: \(description)")
} catch PlayerNumberError.NumberAlreadyAssigned {
print("Error: Number already assigned")
} catch {
print("Error: Unknown Error")
}
你会注意到,对于 PlayerNmberError.NumberTooHigh 和 PlayerNumberError.NumberTooLow 错误的 catch 子句包含重复的代码。在开发过程中,总是好的找到一种方法来消除这样的重复代码。然而,在 Swift 5.3 之前,我们没有选择。Swift 通过 Swift 5.3 中的 SE-0276 引入了多模式 catch 子句,以帮助减少这种重复代码。让我们通过重写前面的代码来使用多模式 catch 子句来查看这一点:
do {
try myTeam.addPlayer(player:("David", "Ortiz", 34))
} catch PlayerNumberError.NumberTooHigh(let description), PlayerNumberError.NumberTooLow(let description) {
print("Error: \(description)")
} catch PlayerNumberError.NumberAlreadyAssigned {
print("Error: Number already assigned")
} catch {
print("Error: Unknown Error")
}
注意,在第一个 catch 子句中,我们现在正在捕获 PlayerNmberError.NumberTooHigh 和 PlayerNumberError.NumberTooLow 两个错误,并且错误之间用逗号分隔。
接下来,我们将探讨如何使用新的可用性属性与 Swift 一起使用。
可用性属性
为最新的 操作系统(OS)版本开发我们的应用程序使我们能够访问我们正在开发平台的所有最新功能。然而,有时我们还想针对旧平台。Swift 允许我们使用可用性属性安全地包装代码,以便仅在操作系统正确版本可用时运行。这最初是在 Swift 2 中引入的。
可用性属性仅在我们在 Apple 平台上使用 Swift 时才可用。
可用性代码块本质上允许我们,如果我们正在运行指定的操作系统版本或更高版本,运行此代码或运行其他代码。我们可以使用可用性属性有两种方式。第一种方式允许我们执行一个特定的代码块,该代码块可以与 if 或 guard 语句一起使用。第二种方式允许我们将方法或类型标记为仅在特定平台上可用。
可用性属性接受最多六个以逗号分隔的参数,这允许我们定义执行我们的代码所需的操作系统的最低版本或应用扩展。这些参数如下:
-
iOS:这是我们代码兼容的最低 iOS 版本。 -
OSX:这是我们代码兼容的最低 OS X 版本。 -
watchOS:这是我们代码兼容的最低 watchOS 版本。 -
tvOS:这是我们代码兼容的最低 tvOS 版本。 -
iOSApplicationExtension:这是我们代码兼容的最低 iOS 应用扩展。 -
OSXApplicationExtension:这是我们代码兼容的最低 OS X 应用扩展。
在参数之后,我们指定所需的最低版本。我们只需要包含与我们的代码兼容的参数。例如,如果我们正在编写 iOS 应用程序,我们只需要在可用性属性中包含 iOS 参数。我们以一个 *(星号)结束参数列表,因为它代表未来的版本。让我们看看如何仅在我们满足最低要求时执行特定的代码块:
if #available(iOS 9.0, OSX 10.10, watchOS 2, *) {
//Available for iOS 9, OSX 10.10, watchOS 2 or above
print("Minimum requirements met")
} else {
//Block on anything below the above minimum requirements
print("Minimum requirements not met")
}
在这个例子中,if #available(iOS 9.0, OSX 10.10, watchOS 2, *) 这行代码防止在运行在不符合指定最低操作系统版本的系统上时执行代码块。在这个例子中,我们还使用了 else 语句,如果操作系统不符合最低要求,将执行另一段代码。
我们还可以限制对函数或类型的访问。在前面的代码中,available 属性前面加了 #(井号,也称为 octothorpe 和 hash)字符。为了限制对函数或类型的访问,我们需要在 available 属性前加上一个 @(在)字符。以下示例展示了我们如何限制对类型和函数的访问:
@available(iOS 9.0, *)
func testAvailability() {
// Function only available for iOS 9 or above
}
@available(iOS 9.0, *)
struct TestStruct {
// Type only available for iOS 9 or above
}
在前面的示例中,我们指定了只有在代码在具有 iOS 9 或更高版本的设备上运行时,testAvailability() 函数和 testStruct() 类型才能被访问。为了使用 @available 属性来阻止对函数或类型的访问,我们必须将调用该函数或类型的代码用 #available 属性包裹起来。
以下示例展示了我们如何调用 testAvailability() 函数:
if #available(iOS 9.0, *) {
testAvailability()
} else {
// Fallback on earlier versions
}
在这个示例中,testAvailability() 函数只有在应用程序运行在具有 iOS 9 或更高版本的设备上时才会被调用。
摘要
在本章中,我们探讨了 Swift 的错误处理功能。虽然我们不需要在我们的自定义类型中使用这些功能,但它们确实为我们提供了一种统一的方式来处理和响应错误。苹果公司也开始在其框架中使用这种错误处理形式。建议我们在代码中使用错误处理。
我们还探讨了 availability 属性,它允许我们开发能够利用目标操作系统的最新功能的应用程序,同时仍然允许我们的应用程序在旧版本上运行。在下一章中,我们将探讨如何编写自定义下标。
第十三章:自定义下标
在 2012 年,Objective-C 中增加了自定义下标。当时,Chris Lattner 已经开发了 Swift 两年了,并且像其他优秀特性一样,下标也被添加到了 Swift 语言中。我在许多其他语言中并没有使用过自定义下标;然而,当我用 Swift 进行开发时,我发现自己在广泛地使用下标。使用下标的语法看起来像是语言的自然组成部分,可能是因为它们在语言发布时就是语言的一部分,而不是后来添加的。一旦开始使用它们,可能会发现它们变得不可或缺。
在本章中,我们将涵盖以下主题:
-
什么是自定义下标?
-
将自定义下标添加到类、结构体或枚举中
-
创建读写和只读下标
-
使用自定义下标访问外部名称
-
使用多维下标
介绍下标
在 Swift 语言中,下标被用作访问集合、列表或序列元素的快捷方式。我们可以在自定义类型中使用它们,通过索引来设置或检索值,而不是使用 getter 和 setter 方法。如果正确使用,下标可以显著提高我们自定义类型的可用性和可读性。
我们可以为单个类型定义多个下标。当类型有多个下标时,将根据下标传递的索引类型选择合适的下标。我们还可以为我们的下标设置外部参数名称,这有助于区分具有相同类型的下标。
我们使用自定义下标的方式就像我们使用数组和大字典的下标一样。例如,要访问数组中的一个元素,我们使用Array[index]语法。当我们为自定义类型定义自定义下标时,我们也使用相同的ourType[key]语法。
在创建自定义下标时,我们应该努力使它们感觉像是类、结构体或枚举的自然部分。如前所述,下标可以显著提高我们代码的可用性和可读性,但如果我们过度使用它们,它们将不会感觉自然,并且难以使用和理解。
在本章中,我们将探讨几个示例,说明我们如何创建和使用自定义下标。然而,在我们了解如何使用自定义下标之前,让我们回顾一下如何在 Swift 数组中使用下标,以了解下标在 Swift 语言本身中的使用方式。我们应该以类似 Apple 在语言中使用下标的方式使用下标,使我们的自定义下标易于理解和使用。
Swift 数组中的下标
以下示例展示了如何使用下标来访问和更改数组的值:
var arrayOne = [1, 2, 3, 4, 5, 6]
print(arrayOne[3]) //Displays '4'
arrayOne[3] = 10
print(arrayOne[3]) //Displays '10'
在先前的例子中,我们创建了一个整数数组,然后使用下标语法来显示和更改索引为三的元素。下标主要用于设置或从集合中检索信息。我们通常不使用下标来应用特定逻辑以确定要选择哪个项目。例如,我们不想使用下标向数组的末尾添加项目或检索数组中的项目数量。要向数组的末尾添加项目或获取数组中的项目数量,我们使用函数或属性,如下所示:
arrayOne.append(7) //append 7 to the end of the array
arrayOne.count //returns the number of items in an array
我们自定义类型中的下标应遵循 Swift 语言本身设定的相同标准,这样其他使用我们类型的开发者就不会被实现方式所困惑。了解何时使用下标,何时不使用的关键在于理解它们将被如何使用。
创建和使用自定义下标
让我们看看如何定义一个用于读取和写入后端数组的下标。读取和写入后端存储类是自定义下标最常见的使用之一。然而,正如我们将在本章中看到的,我们不需要有一个后端存储类。以下代码展示了如何使用下标来读取和写入数组:
class MyNames {
private var names = ["Jon", "Kailey", "Kara"]
subscript(index: Int) -> String {
get {
return names[index]
}
set {
names[index] = newValue
}
}
}
如我们所见,下标的语法与我们使用get和set关键字在类中定义属性的方式相似。区别在于我们使用subscript关键字声明subscript。然后我们指定一个或多个输入和返回类型。
我们现在可以使用自定义下标,就像我们使用数组或字典中的下标一样。以下代码展示了如何在先前的示例中使用下标:
var nam = MyNames()
print(nam[0]) //Displays 'Jon'
nam[0] = "Buddy"
print(nam[0]) //Displays 'Buddy'
在先前的代码中,我们创建了一个MyNames类的实例,并显示了索引0处的原始名称。然后我们更改索引0处的名称并重新显示它。在这个例子中,我们使用MyNames类中定义的下标来检索和设置类内names数组中的元素。
虽然我们可以使names数组对外部代码直接访问,但这会将我们的代码锁定在只能使用数组来存储数据。在未来,如果我们想将后端存储机制更改为字典对象,甚至是一个 SQLite 数据库,我们将很难做到,因为所有外部代码也必须进行更改。下标非常擅长隐藏我们在自定义类型中存储信息的方式;因此,使用这些自定义类型的外部代码不依赖于特定的存储实现。
如果我们直接访问 names 数组,我们也将无法验证外部代码是否正在将有效信息插入数组中。使用下标,我们可以在将数据添加到数组之前对其进行验证,以确保传递的数据是正确的。例如,在之前的例子中,我们可以在验证中添加一个验证,以确保名称只包含字母字符和某些在名称中有效的特殊字符。这在我们创建框架或库时非常有用。
只读自定义下标
我们也可以通过不在下标中声明设置器方法或显式声明获取器和设置器方法来使下标只读。以下代码展示了如何通过不声明获取器或设置器方法来声明只读属性:
//No getter/setters implicitly declared
subscript(index: Int) -> String {
return names[index]
}
以下示例展示了如何仅通过声明获取器方法来声明只读属性:
//Declaring only a getter
subscript(index: Int) -> String {
get {
return names[index]
}
}
在第一个例子中,我们没有定义获取器或设置器方法;因此,Swift 将下标设置为只读,代码的行为就像是在获取器定义中一样。在第二个例子中,我们特别将代码设置在获取器定义中。这两个例子都是有效的只读下标。需要注意的是,Swift 中不允许存在只写下标。
计算下标
虽然前面的例子与在类或结构体中使用存储属性非常相似,但我们也可以以类似的方式使用下标来使用计算属性。让我们看看如何做到这一点:
struct MathTable {
var num: Int
subscript(index: Int) -> Int {
return num * index
}
}
在前面的例子中,我们使用数组作为下标的后端存储机制。在这个例子中,我们使用下标的值来计算返回值。我们会这样使用这个下标:
var table = MathTable(num: 5)
print(table[4])
此示例显示了计算值 5(在初始化中定义的数字)乘以 4(下标值),等于 20。
下标值
在前面的下标示例中,所有下标都接受整数作为下标的值;然而,我们并不局限于整数。在以下示例中,我们将使用 String 类型作为下标的值。subscript 关键字也将返回 String 类型:
struct Hello {
subscript (name: String) -> String {
return "Hello \(name)"
}
}
在这个例子中,下标将字符串作为下标内的值,并返回一条消息说Hello。让我们看看如何使用这个下标:
let greeting = Hello["Jon"]
在之前的代码中,greeting 常量将包含字符串 Hello Jon。
静态下标
静态下标是在 Swift 5.1 中通过 SE-0254 引入的。此功能使我们能够在不创建类型实例的情况下使用下标。让我们看看它是如何工作的:
struct Hello {
static subscript (name: String) -> String {
return "Hello \(name)"
}
}
在之前的代码中,我们创建了一个名为 Hello 的结构体,并在该结构体内部定义了一个 subscript。需要注意的是,在 subscript 声明之前有一个 static 关键字。我们现在能够像下一行代码所示那样使用这个下标:
let greeting = Hello["Jon"]
在之前的代码中,greeting 常量将包含字符串 Hello Jon。请注意,我们不需要创建 Hello 结构的实例来使用下标。
下标的外部名称
如本章前面所述,我们可以为我们的自定义类型有多个下标签名。合适的下标将根据传递给下标的索引类型来选择。然而,有时我们可能希望定义多个具有相同类型的下标。为此,我们可以使用与定义函数参数外部名称类似的方式使用外部名称。
让我们重写原始的 MathTable 结构,以包含两个下标,每个下标都接受整数作为下标类型。然而,一个将执行乘法操作,另一个将执行加法操作:
struct MathTable {
var num: Int
subscript(multiply index: Int) -> Int {
return num * index
}
subscript(add index: Int) -> Int {
return num + index
}
}
如我们所见,在这个例子中,我们定义了两个下标,并且每个下标都接受整数类型。这两个下标之间的区别在于定义中的外部名称。在第一个下标中,我们定义了一个外部名称 multiply,因为我们在这个下标中将下标的值乘以下标内的 num 属性。在第二个 subscript 中,我们定义了一个外部名称 add,因为我们在这个下标中将下标的值加以下标内的 num 属性。
让我们看看如何使用这两个下标:
var table = MathTable(num: 5)
print(table[multiply: 4]) //Displays 20 because 5*4=20
print(table[add: 4]) //Displays 9 because 5+4=9
如果我们运行这个示例,我们将看到根据下标内的外部名称使用了正确的下标。
在我们的下标中使用外部名称非常有用,如果我们需要多个相同类型的下标。除非需要区分多个下标,否则我不建议使用外部名称。
多维下标
虽然最常见的下标是只接受单个参数的,但下标并不局限于单个参数。它们可以接受任意数量的输入参数,并且这些参数可以是任何类型。
让我们看看如何使用多维下标来实现井字棋板。井字棋板看起来类似于以下图示:

图 13.1:空井字棋板
棋盘可以用一个二维数组来表示,其中每个维度有三个元素。棋盘的左上角方框将表示坐标 0,0,而棋盘的右下角方框将表示坐标 2,2。中间的方框将具有坐标 1,1。每个玩家将轮流将他们的棋子(通常是 x 或 o)放在棋盘上,直到一个玩家在一条线上有三个棋子或棋盘满了。
让我们看看如何使用多维数组和多维下标来实现井字棋板:
struct TicTacToe {
var board = [["","",""],["","",""],["","",""]]
subscript(x: Int, y: Int) -> String {
get {
return board[x][y]
}
set {
board[x][y] = newValue
}
}
}
我们从定义一个3×3数组(也称为矩阵)开始,该数组将表示游戏棋盘。然后我们定义一个可以用来设置和检索棋盘上玩家棋子的下标。下标将接受两个整数值。我们通过在括号中放置参数来定义我们的下标参数。在这个例子中,我们定义了下标,参数为(x: Int, y: Int)。我们可以在下标中使用x和y变量名来访问传递的值。
让我们看看如何使用这个下标在棋盘上设置用户的棋子:
var board = TicTacToe()
board[1,1] = "x"
board[0,0] = "o"
如果我们运行此代码,我们将看到我们在中心方格添加了x棋子,在左上方方格添加了o棋子,因此我们的游戏棋盘将类似于以下:

图 13.2:带有两个玩家棋子的井字棋盘
我们不仅限于使用单一类型的多维下标。例如,我们可能有一个(x: Int, y: Double, z: String)的下标。
我们也可以为我们的多维下标类型添加外部名称,以帮助识别使用哪些值,并区分具有相同类型的下标。让我们通过创建一个基于下标值返回字符串实例数组的下标来查看如何使用多个类型和外部名称:
struct SayHello {
subscript(messageText message: String, messageName name: String, number: Int) -> [String]{
var retArray: [String] = []
for _ in 0..<number {
retArray.append("\(message) \(name)")
}
return retArray
}
}
在SayHello结构中,我们定义下标如下:
subscript(messageText message: String, messageName name: String, number: Int) -> [String]
这定义了一个包含三个元素的下标。每个元素都有一个外部名称(messageText、messageName和number)和一个内部名称(message、name和number)。前两个元素是String类型,最后一个元素是Integer类型。我们使用前两个元素为用户创建一个消息,该消息将根据最后一个(number)元素定义的次数重复。我们将如下使用这个下标:
var message = SayHello()
var ret = message[messageText:"Bonjour", messageName:"Jon", number:5]
如果我们运行此代码,我们将看到ret变量包含一个包含五个字符串的数组,其中每个字符串等于Bonjour Jon。现在让我们看看 Swift 语言中最具争议的新增功能之一——动态成员查找。
动态成员查找
动态成员查找允许调用在运行时动态解析的属性。如果不看示例,这可能不太容易理解,所以让我们看看一个例子。假设我们有一个表示棒球队的结构的结构。这个结构有一个属性表示球队来自的城市,另一个属性表示球队的昵称。以下代码显示了此结构:
struct BaseballTeam {
let city: String
let nickName: String
}
在这个结构中,如果我们想检索棒球队的完整名称,包括city和nickname,我们可以轻松地创建一个如以下示例所示的方法:
func fullname() -> String {
return "\(city) \(nickName)"
}
这是在大多数面向对象编程语言中通常会这样做的方式。然而,在我们的代码中,使用 BaseballTeam 结构体时,我们会用点符号检索城市和昵称作为属性,而全名作为一个方法。以下代码显示了我们会如何使用 city 属性和 fullname 方法:
var redsox = BaseballTeam(city: "Boston", nickName: "Red Sox")
let city = redsox.city
let fullname = redsox.fullname()
我们可以使用动态成员查找创建一个更干净的接口。要使用动态成员查找,我们首先需要在定义 BaseballTeam 结构体时添加 @dynamicMemberLookup 属性,如下面的代码所示:
@dynamicMemberLookup
struct BaseballTeam {
let city: String
let nickName: String
let wins: Double
let losses: Double
let year: Int
}
现在,我们需要将查找添加到 BaseballTeam 结构体中。这是通过实现 subscript(dynamicMember:) 下标来完成的。以下代码显示了我们将如何创建一个查找来检索 BaseballTeam 结构体的全名和胜率:
subscript(dynamicMember key: String) -> String {
switch key {
case "fullname":
return "\(city) \(nickName)"
case "percent":
let per = wins/(wins+losses) return String(per)
default:
return "Unknown request"
}
}
此代码将检索传入的键,并使用 switch 语句,根据该键确定从下标返回什么信息。将此代码添加到 BaseballTeam 结构体后,我们可以使用以下示例中的查找方式:
var redsox = BaseballTeam(city: "Boston", nickName: "Red Sox", wins: 108, losses: 54, year: 2018)
print("The \(redsox.fullname) won \(redsox.percent) of their games in \(redsox.year)")
注意我们如何能够像访问正常属性一样访问 BaseballTeam 结构体实例的 fullname 和 percent。这使得我们的代码更加简洁且易于阅读。然而,在使用此类查找时,有一点需要注意:无法控制传递给查找的键。
在上一个示例中,我们调用了 fullname 和 percent;然而,我们同样可以轻松地调用 flower 或 dog,而无需编译器发出任何警告。这就是为什么动态成员查找存在很多争议,因为在编译时如果出现错误,不会有警告信息。
如果你使用动态成员查找,请确保验证键,并处理任何意外发送的情况,就像我们之前使用 switch 语句的默认情况所做的那样。
现在我们已经看到了如何使用下标,让我们快速看一下何时不应使用自定义下标。
现在我们已经看到了如何使用下标,让我们快速看一下何时不应使用自定义下标。
正如我们在本章中看到的,创建自定义下标可以真正增强我们的代码。然而,我们应该避免过度使用它们,或者以不符合标准下标使用方式的方式使用它们。避免过度使用下标的办法是检查 Swift 标准库中下标的使用情况。
让我们来看下面的示例:
class MyNames {
private var names:[String] = ["Jon", "Kailey", "Kara"]
var number: Int {
get {
return names.count
}
}
subscript(add name: String) -> String {
names.append(name)
return name
}
subscript(index: Int) -> String {
get {
return names[index]
}
set {
names[index] = newValue
}
}
}
在前面的示例中,在 MyNames 类中,我们定义了一个用于我们应用程序中的名称数组。作为一个例子,假设在我们的应用程序中我们显示这个名称列表,并允许用户向其中添加名称。然后在 MyNames 类中,我们定义以下下标,允许我们向数组中添加新的名称:
subscript(add name: String) -> String {
names.append(name)
return name
}
这样使用下标会是一个不好的选择,因为它的使用并不符合 Swift 语言本身对下标的用法——我们在这里使用它是为了接受一个参数并添加该值。当类被使用时,这可能会引起混淆。将这个下标重写为一个函数可能更合适,例如以下所示:
func append(name: String) {
names.append(name)
}
记住,当你使用自定义下标时,确保你正在适当地使用它们。
摘要
正如我们在本章中看到的,为我们的自定义类型添加对下标的支持可以极大地增强它们的可读性和可用性。我们看到了下标可以用来在后台存储类和外部代码之间添加一个抽象层。下标也可以以类似的方式用于计算属性,其中下标用于计算一个值。正如我们指出的,使用下标的要点是适当地使用它们,并且与 Swift 语言中的下标用法保持一致。
在下一章中,我们将探讨闭包是什么以及如何使用它们。
第十四章:与闭包一起工作
现在,大多数主要的编程语言都有类似于 Swift 中闭包的功能。其中一些实现非常难以使用(Objective-C 的 blocks),而其他则相对容易(Java 的 lambdas 和 C# 的 delegates)。我发现闭包提供的功能在开发框架时特别有用。我也在通过网络连接与远程服务通信时广泛使用了它们。虽然 Objective-C 中的 blocks 非常有用,但声明 block 所使用的语法绝对糟糕。幸运的是,当 Apple 开发 Swift 语言时,他们使闭包的语法更容易使用和理解。
在本章中,我们将涵盖以下主题:
-
什么是闭包?
-
如何创建闭包
-
如何使用闭包
-
有哪些有用的闭包示例?
-
如何避免闭包内的强引用循环
闭包简介
闭包是包含代码块的自包含代码块,可以在我们的应用程序中传递和使用。我们可以将 Int 类型视为包含整数的类型,将 String 类型视为包含字符串的类型。在这种情况下,闭包可以被视为包含代码块的类型。这意味着我们可以将闭包赋值给变量,将它们作为参数传递给函数,并从函数中返回它们。
闭包可以捕获并存储它们定义上下文中的任何变量或常量的引用。这被称为覆盖变量或常量,并且大部分情况下,Swift 会为我们处理内存管理。唯一的例外是在创建强引用循环时,我们将在第十八章“内存管理”的“使用闭包创建强引用循环”部分中探讨如何解决这个问题。
Swift 中的闭包与 Objective-C 中的 blocks 类似;然而,Swift 中的闭包使用起来更容易,也更易于理解。让我们看看在 Swift 中定义闭包所使用的语法:
{
(<#parameters#>) -> <#return-type#> in <#statements#>
}
创建闭包所使用的语法看起来与我们用来创建函数的语法非常相似,在 Swift 中,全局和嵌套函数都是闭包。闭包和函数之间格式上的最大区别是 in 关键字。in 关键字用于代替花括号,将闭包的参数和返回类型的定义与闭包体分开。
闭包有很多用途,我们将在本章后面讨论其中的一些,但首先,我们需要了解闭包的基本知识。让我们先看看一些非常基础的闭包,以便我们更好地理解它们是什么,如何定义它们,以及如何使用它们。
简单闭包
我们将从创建一个非常简单的闭包开始,这个闭包不接受任何参数,也不返回任何值。它所做的只是将 Hello World 打印到控制台。让我们看看以下代码:
let clos1 = { () -> Void in
print("Hello World")
}
在这个例子中,我们创建了一个闭包并将其赋值给clos1常量。由于括号之间没有定义任何参数,这个闭包将不接受任何参数。此外,返回类型被定义为Void;因此,这个闭包不会返回任何值。闭包的主体包含一行,将Hello World打印到控制台。
使用闭包的方法有很多;在这个例子中,我们只想执行它。我们可以按照以下方式执行闭包:
clos1()
在执行闭包之后,我们会看到Hello World被打印到了控制台。在这个阶段,闭包可能看起来并不那么有用,但随着我们继续学习本章内容,我们将看到它们是多么有用和强大。
让我们看看另一个简单的例子。这个闭包将接受一个名为name的String参数,但不会返回任何值。在闭包的主体内部,我们将打印出通过name参数传递给闭包的问候语。这是第二个闭包的代码:
let clos2 = {
(name: String) -> Void in
print("Hello \(name)")
}
clos2闭包和clos1闭包之间最大的区别是我们定义了一个单独的String参数在括号之间。正如我们所看到的,我们为闭包定义参数的方式就像我们为函数定义参数一样。我们可以以执行clos1闭包相同的方式执行这个闭包。下面的代码展示了如何做到这一点:
clos2("Jon")
当这个例子执行时,将会打印出Hello Jon的消息到控制台。让我们看看另一种我们可以使用clos2闭包的方法。在这个例子中需要注意的一点是,闭包中的命名参数不需要使用参数名。
我们对闭包的原始定义是:闭包是自包含的代码块,可以在我们的应用程序中传递和使用。这告诉我们我们可以将我们的闭包从它们被创建的上下文中传递到代码的其他部分。让我们看看如何将我们的clos2闭包传递给一个函数。我们将定义一个函数,它接受我们的clos2闭包,如下所示:
func testClosure(handler: (String) -> Void) {
handler("Dasher")
}
我们定义函数的方式就像定义任何其他函数一样;然而,在参数列表中,我们定义了一个名为handler的参数,并为handler参数定义了类型(String) -> Void。如果我们仔细观察,我们可以看到handler参数的(String) -> Void定义与为clos2闭包定义的参数和返回类型相匹配。这意味着我们可以将clos2闭包传递给函数。让我们看看如何做到这一点:
testClosure(handler: clos2)
我们像调用任何其他函数一样调用 testClosure() 函数,传入的闭包看起来就像任何其他变量一样。由于 clos2 闭包在 testClosure() 函数中被执行,因此当这段代码执行时,我们将在控制台看到打印出的消息 Hello Dasher。正如我们将在本章稍后看到的那样,将闭包传递给函数的能力使得闭包如此令人兴奋和强大。作为闭包拼图的最后一部分,让我们看看如何从闭包中返回一个值。以下示例展示了这一点:
let clos3 = {
(name: String) -> String in
return "Hello \(name)"
}
clos3 闭包的定义看起来与我们之前定义的 clos2 闭包非常相似。不同之处在于我们将返回类型从 Void 改为了 String 类型。然后,在闭包体中,我们不是将消息打印到控制台,而是使用返回语句返回消息。现在我们可以像之前两个闭包一样执行 clos3 闭包,或者像处理 clos2 闭包那样将闭包传递给函数。以下示例展示了如何执行 clos3 闭包:
var message = clos3("Buddy")
执行此行代码后,消息变量将包含 Hello Buddy 字符串。前面三个闭包示例展示了闭包的格式和定义典型闭包的方法。熟悉 Objective-C 的人可以注意到,Swift 中闭包的格式要干净得多,也更容易使用。我们在本章中迄今为止展示的闭包创建语法相当简短;然而,我们还可以进一步缩短它。在下一节中,我们将探讨如何做到这一点。
闭包的简写语法
在本节中,我们将探讨几种缩短语法的途径。
使用闭包的简写语法完全是个人喜好问题。许多开发者喜欢将他们的代码尽可能小和紧凑,并且为此感到非常自豪。然而,有时这会使代码对其他开发者来说难以阅读和理解。
我们将要查看的第一个闭包简写语法是最受欢迎的之一,这是我们使用 第五章 中数组算法时看到的语法。这种格式主要用于当我们想要向函数发送一个非常小的闭包(通常是单行)时,就像我们处理数组算法那样。在我们查看这种简写语法之前,我们需要编写一个接受闭包作为参数的函数:
func testFunction(num: Int, handler:() -> Void) {
for _ in 0..<num {
handler()
}
}
此函数接受两个参数;第一个参数是一个名为 num 的整数,第二个参数是一个名为 handler 的闭包,该闭包没有参数,也不返回任何值。在函数内部,我们创建一个 for 循环,该循环将使用 num 整数来定义循环的次数。在 for 循环中,我们调用传入函数的 handler 闭包。现在让我们创建一个闭包并将其传递给 testFunction(),如下所示:
let clos = { () -> Void in
print("Hello from standard syntax")
}
testFunction(num: 5, handler: clos)
这段代码非常易于阅读和理解;然而,它需要五行代码。现在让我们看看如何通过在函数调用内编写闭包来缩短它:
testFunction(num: 5,handler: {print("Hello from Shorthand closure")})
在这个例子中,我们使用与数组算法相同的语法在函数调用内行内创建了闭包。闭包放在两个花括号({})之间,这意味着创建闭包的代码是 {print("Hello from Shorthand closure")}。当这段代码执行时,它会在屏幕上打印出五次Hello from Shorthand closure消息。调用 testFunction() 时,为了简洁性和可读性,理想的方式如下:
testFunction(num: 5) {
print("Hello from Shorthand closure")
}
将闭包作为最后一个参数允许我们在调用函数时省略标签。这个例子给出了既紧凑又易读的代码。让我们看看如何使用这种简写语法来使用参数。我们将首先创建一个新的函数,该函数将接受一个具有单个参数的闭包。我们将把这个函数命名为 testFunction2。以下示例显示了新的 testFunction2 函数的作用:
func testFunction2(num: Int, handler: (_ : String)->Void) {
for _ in 0..<num {
handler("Me")
}
}
在 testFunction2 中,我们这样定义闭包:(_ : String)->Void。这个定义意味着闭包接受一个参数并且不返回任何值。现在让我们看看如何使用相同的简写语法来调用这个函数:
testFunction2(num: 5){
print("Hello from \($0)")
}
这个闭包定义与之前的定义之间的区别是 $0。$0 参数是传递给函数的第一个参数的简写。如果我们执行这段代码,它会打印出五次Hello from Me消息。使用美元符号($)后跟一个数字的行内闭包允许我们在定义中不需要创建参数列表就能定义闭包。美元符号后面的数字定义了参数在参数列表中的位置。让我们更详细地考察一下这个格式,因为我们不仅限于只使用美元符号($)和数字简写格式来定义行内闭包。这种简写语法也可以用来缩短闭包定义,允许我们省略参数名称。以下示例演示了这一点:
let clos5: (String, String) -> Void = {
print("\($0) \($1)")
}
在这个例子中,闭包定义了两个字符串参数;然而,我们没有给它们命名。参数是这样定义的:(String,String)。我们可以在闭包体中使用 $0 和 $1 访问这些参数。此外,请注意,闭包定义在冒号(:)之后,使用的是我们用来定义变量类型的相同语法,而不是在花括号内。当我们使用匿名参数时,这就是我们定义闭包的方式。以下定义闭包的方式是不正确的:
let clos5b = { (String, String) in
print("\($0) \($1)")
}
在这个例子中,我们会收到一个错误,告诉我们这种格式是无效的。接下来,让我们看看如何使用 clos5 闭包:
clos5("Hello", "Kara")
由于 Hello 是参数列表中的第一个字符串,它通过 $0 访问,而 Kara 作为参数列表中的第二个字符串,通过 $1 访问。当我们执行这段代码时,我们将看到 Hello Kara 消息打印到控制台。下一个示例用于当闭包不返回任何值时。我们不必将返回类型定义为 Void,我们可以使用括号,如下例所示:
let clos6: () -> () = {
print("Howdy")
}
在这个例子中,我们将闭包定义为 () -> ()。这告诉 Swift 该闭包不接受任何参数,也不返回任何值。我们将如下执行这个闭包:
clos6()
作为个人偏好,我并不特别喜欢这种简写语法。我认为当使用 Void 关键字而不是括号时,代码更容易阅读。
在我们开始展示一些非常有用的闭包示例之前,我们还有一个简写闭包示例要展示。在这个最后的例子中,我们将演示我们如何从闭包中返回一个值,而无需包含 return 关键字。如果整个闭包体只包含一个语句,我们可以省略 return 关键字,该语句的结果将被返回。让我们看看一个这样的例子:
let clos7 = {(first: Int, second: Int) -> Int in first + second }
在这个例子中,闭包接受两个 Int 类型的参数,并将返回一个 Int 类型的实例。闭包体内的唯一语句是将第一个参数添加到第二个参数。然而,如果你注意到,我们在附加语句之前没有包含 return 关键字。Swift 会看到这是一个单语句闭包,并将自动返回结果,就像我们在附加语句之前放置了 return 关键字一样。我们确实需要确保我们语句的结果类型与闭包的返回类型相匹配。
前两个部分中展示的所有示例都是为了展示如何定义和使用闭包。单独来看,这些示例并没有真正展示闭包的力量,也没有展示闭包是多么有用。本章的剩余部分将展示 Swift 中闭包的力量和实用性。
使用 Swift 数组与闭包
在 第五章,使用 Swift 集合 中,我们查看了一些可以与 Swift 数组一起使用的内置算法。在那一章中,我们简要地看了如何使用非常基本的闭包为这些算法中的每一个添加简单的规则。现在我们更好地理解了闭包,让我们看看我们如何可以使用更高级的闭包来扩展这些算法。
使用 Swift 的数组算法与闭包
在本节中,我们将主要使用 map 算法以保持一致性;然而,我们可以使用任何算法中展示的基本思想。我们将首先定义一个数组来使用:
let guests = ["Jon", "Kailey", "Kara"]
这个数组包含一个名字列表,这个数组被命名为guests。这个数组将用于本节的大部分示例。现在我们有了guests数组,让我们添加一个闭包,该闭包将打印数组中每个名字的问候语:
guests.map { name in
print("Hello \(name)")
}
由于map算法将闭包应用于数组的每个项目,此示例将为数组中的每个名字打印一个问候语。在本章的第一部分之后,我们应该对闭包的工作原理有一个相当好的理解。使用我们在上一节中看到的简写语法,我们可以将前面的示例简化为以下单行代码:
guests.map {
print("Hello \($0)")}
在我看来,这可能是少数几次,简写语法可能比标准语法更容易阅读的情况之一。现在,假设我们不是要将问候语打印到控制台,而是想要返回一个包含问候语的新数组。为此,我们应从闭包中返回一个String类型,如下面的示例所示:
var messages = guests.map {
(name:String) -> String in
return "Welcome \(name)"
}
当这段代码执行时,messages数组将包含对guests数组中每个名字的问候,而数组本身将保持不变。我们可以按如下方式访问问候语:
for message in messages {
print("\(message)")
}
本节前面的示例展示了如何在map算法中内联添加闭包。如果我们只想使用一个闭包与map算法,这是很好的,但如果我们想使用多个闭包,或者想多次使用闭包或在不同数组中重用它们呢?为此,我们可以将闭包分配给一个常量或变量,然后根据需要使用闭包的常量或变量名。让我们看看如何做到这一点。我们将首先定义两个闭包。
其中一个闭包将打印数组中每个元素的问候语,另一个闭包将打印数组中每个元素的告别信息:
let greetGuest = { (name:String) -> Void in
print("Hello guest named \(name)")
}
let sayGoodbye = { (name:String) -> Void in
print("Goodbye \(name)")
}
现在我们有了两个闭包,我们可以根据需要使用它们与map算法。下面的代码展示了如何将这些闭包与guests数组交互使用:
guests.map(greetGuest)
guests.map(sayGoodbye)
当我们使用greetGuest闭包与guests数组一起使用时,问候信息将被打印到控制台;当我们使用sayGoodbye闭包与guests数组一起使用时,告别信息将被打印到控制台。如果我们还有一个名为guests2的数组,我们可以使用相同的闭包来处理该数组,如下面的示例所示:
guests.map(greetGuest)
guests2.map(greetGuest)
guests.map(sayGoodbye)
guests2.map(sayGoodbye)
到目前为止,本节中的所有示例要么是将消息打印到控制台,要么是从闭包中返回一个新数组。我们的闭包并不局限于这样的基本功能。例如,我们可以在闭包内过滤数组,如下面的示例所示:
let greetGuest2 = {
(name:String) -> Void in
if (name.hasPrefix("K")) {
print("\(name) is on the guest list")
} else {
print("\(name) was not invited")
}
}
在这个例子中,我们根据名字是否以字母K开头打印不同的消息。
如本章前面所述,闭包具有捕获并存储它们定义上下文中任何变量或常量的引用的能力。让我们来看一个这样的例子。假设我们有一个函数,它包含了一个特定位置过去七天的最高温度,并且这个函数接受一个闭包作为参数。这个函数将在温度数组的数组上执行闭包。这个函数可以写成如下形式:
func temperatures(calculate:(Int)->Void) {
var tempArray = [72,74,76,68,70,72,66]
tempArray.map(calculate)
}
这个函数接受一个闭包,定义为(Int)-> Void。然后我们使用map算法来执行tempArray数组中的每个元素的闭包。在这种情况下正确使用闭包的关键是理解temperatures函数不知道,也不关心calculate闭包内部的任何操作。此外,请注意,闭包也无法更新或更改函数上下文中的项目,这意味着闭包不能更改温度函数中的任何其他变量;然而,它可以更新它在创建上下文中定义的变量。
让我们看看我们将创建闭包的函数。我们将把这个函数命名为testFunction:
func testFunction() {
var total = 0
var count = 0
let addTemps = {
(num: Int) -> Void in
total += num
count += 1
}
temperatures(calculate: addTemps)
print("Total: \(total)")
print("Count: \(count)")
print("Average: \(total/count)")
}
在这个函数中,我们首先定义了两个变量,分别命名为total和count,这两个变量都是Int类型。然后我们创建了一个名为addTemps的闭包,它将被用来将temperatures函数中的所有温度加在一起。addTemps闭包还将计算数组中的温度数量。为此,addTemps闭包计算数组中每个项目的总和,并将总和保存在函数开头定义的total变量中。addTemps闭包还通过为每个项目递增count变量来跟踪数组中的项目数量。请注意,total和count变量都没有在闭包内部定义;然而,我们能够在闭包中使用它们,因为它们是在与闭包相同的上下文中定义的。
然后我们调用temperatures函数,并传递addTemps闭包。最后,我们将Total、Count和Average温度打印到控制台。当testFunction执行时,我们将在控制台看到以下输出:
Total: 498
Count: 7
Average: 71
如输出所示,addTemps闭包能够更新和使用在它创建的上下文中定义的项目,即使闭包在另一个上下文中使用。
现在我们已经看了如何使用数组map算法与闭包一起使用,让我们看看如何单独使用闭包。我们还将看看我们可以如何清理代码,使其更容易阅读和使用。
数组中的非连续元素
在第五章,使用 Swift 集合中,我们展示了如何使用带有闭包的subrange方法从数组中检索非连续的子元素。现在我们知道了更多关于闭包的知识,让我们再次看看这个例子。
我们在第五章,使用 Swift 集合中使用的代码,从整数数组中检索了偶数。让我们再次看看这段代码:
var numbers = [1,2,3,4,5,6,7,8,9,10]
let evenNum = numbers.subranges(where: { $0.isMultiple(of: 2) })
//numbers[evenNum] contains 2,4,6,8,10
在这个例子中,我们使用了Int类型的isMultiple(of:)来检索所有偶数元素。由于subranges(where:)方法接受一个闭包,我们也可以使用其他逻辑。例如,如果我们想检索所有等于或小于 6 的元素,我们可以使用以下代码行:
let newNumbers = numbers.subrange(where: { $0 <= 6 })
现在我们熟悉了闭包,我们可以看到subrange(where:)方法的一些可能性。
未初始化的数组
Swift 5.2 与 SE-0245 一起引入了一个新的数组初始化器,它不会预先使用默认值填充值。这个初始化器使我们能够提供一个闭包来填充我们想要的任何值。让我们通过创建一个将包含 20 次掷骰子值的数组来看看如何做:
let capacity = 20
let diceRolls = Array<Int>(unsafeUninitializedCapacity: capacity) { buffer, initializedCount in
for x in 0..<capacity {
buffer[x] = Int.random(in: 1...6)
}
initializedCount = capacity
}
我们首先设置一个包含数组容量的常量。我们这样做是因为我们需要在初始化器中的几个地方提供这个值,通过使用常量,如果我们以后需要更改这个容量,那么只需要在一个地方更改。
代码的其余部分初始化了数组。闭包提供了一个不安全的可变缓冲区指针,我们在前面的代码中将其命名为buffer,也可以用它来为数组写入值。我们使用for循环用 1 到 6 的随机整数填充数组。
当你使用这个初始化器时,有一些通用规则:
-
你不需要使用你请求的全部容量;然而,你不能使用更多。在我们的例子中,我们请求 20 个元素的容量,这意味着我们可以使用少于 20 个,但不能使用超过 20 个。
-
如果你没有初始化一个元素,那么它可能被填充了随机数据(一个非常糟糕的想法)。在我们的例子中,我们请求 20 个元素的容量,如果我们只填充了前 10 个元素,那么后面的 10 个元素将包含随机垃圾。
-
如果
initializedCount没有设置,则默认为0,所有数据都将丢失。 -
这是一个非常方便的初始化器,但也很容易出错。我们也可以使用
map数组算法重写diceRolls初始化器,如下面的代码所示:let diceRolls = (0…20).map { _ in Int.random(in: 1…6) }然而,这会效率更低,因为像第一个例子中那样初始化数组在内部优化了更好的性能。如果你不担心最佳性能,那么映射算法更容易阅读和理解。
现在让我们看看我们如何可以使用闭包在运行时改变功能。
改变功能
闭包还赋予我们动态改变类型功能的能力。在 第十一章,泛型 中,我们看到了泛型赋予我们编写适用于多种类型的函数的能力。通过闭包,我们能够编写功能和类型可以根据传入的闭包而变化的函数。在本节中,我们将向您展示如何编写一个可以通过闭包改变功能的函数。
让我们从定义一个类型开始,这个类型将用来演示如何替换功能。我们将把这个类型命名为 TestType:
struct TestType {
typealias GetNumClosure = ((Int, Int) -> Int)
var numOne = 5
var numTwo = 8
var results = 0;
mutating func getNum(handler: GetNumClosure) -> Int {
results = handler(numOne,numTwo)
print("Results: \(results)")
return results
}
}
我们从这个类型开始,定义了一个名为 GetNumClosure 的闭包 typealias。任何定义为 GetNumClosure 闭包的闭包都将接受两个整数并返回一个整数。在这个闭包内部,我们假设它会对我们传入的整数做些处理以得到返回的值,但实际上它并不需要与整数有任何关系。说实话,这个类并不关心闭包做了什么,只要它符合 GetNumClosure 类型即可。接下来,我们定义了三个整数,分别命名为 numOne、numTwo 和 results。
我们还定义了一个名为 getNum() 的方法。这个方法接受一个符合 GetNumClosure 类型的闭包作为其唯一参数。在 getNum() 方法内部,我们通过传入 numOne 和 numTwo 变量以及返回的整数来执行闭包,并将返回的整数放入 results 类变量中。现在让我们看看几个符合 GetNumClosure 类型的闭包,我们可以使用这些闭包与 getNum() 方法一起使用:
var max: TestType.GetNumClosure = {
if $0 > $1 {
return $0
} else {
return $1
}
}
var min: TestType.GetNumClosure = {
if $0 < $1 {
return $0
} else {
return $1
}
}
var multiply: TestType.GetNumClosure = {
return $0 * $1
}
var second: TestType.GetNumClosure = {
return $1
}
var answer: TestType.GetNumClosure = {
var _ = $0 + $1
return 42
}
在此代码中,我们定义了五个符合 GetNumClosure 类型的闭包:
-
max:返回传入的两个整数中的最大值 -
in min:返回传入的两个整数中的最小值 -
in multiply:将传入的两个值相乘并返回乘积 -
second:返回传入的第二个参数 -
answer:返回生命、宇宙和万物的答案
在 answer 闭包中,有一行看起来似乎没有作用:
var _ = $0 + $1
我们故意这样做,因为以下代码是不合法的:
var answer: TestType.GetNumClosure = {
return 42
}
这种类型会给我们一个错误:闭包参数列表的上下文类型期望两个参数,这些参数不能被隐式忽略。正如错误所示,Swift 不允许我们在闭包体中忽略期望的参数。在第二个闭包中,Swift 假设有两个参数,因为 $1 指定了第二个参数。现在我们可以将这些闭包中的每一个传递给 getNum() 方法,以改变函数的功能以适应我们的需求。以下代码说明了这一点:
var myType = TestType()
myType.getNum(handler: max)
myType.getNum(handler: min)
myType.getNum(handler: multiply)
myType.getNum(handler: second)
myType.getNum(handler: answer)
当运行此代码时,我们将为每个闭包收到以下结果:
For Max:
Results: 8
For Min:
Results: 5
For Multiply:
Results: 40
For Second:
Results: 8
For Answer:
Results: 42
我们将要展示的最后一个例子是在框架中经常使用的一个,尤其是在那些设计为异步运行的功能性框架中。
根据结果选择闭包
在最后的例子中,我们将向一个方法传递两个闭包,然后根据某些逻辑,一个或两个闭包将被执行。通常,如果方法成功执行,则调用一个闭包;如果方法失败,则调用另一个闭包。
让我们首先创建一个类型,该类型将包含一个方法,该方法将接受两个闭包,然后根据定义的逻辑执行其中一个闭包。我们将这个名字命名为TestType。以下是TestType类型的代码:
class TestType {
typealias ResultsClosure = ((String) -> Void)
func isGreater(numOne: Int, numTwo: Int, successHandler: ResultsClosure,failureHandler: ResultsClosure) {
if numOne > numTwo {
successHandler("\(numOne) is greater than \(numTwo)")
}
else {
failureHandler("\(numOne) is not greater than \(numTwo)")
}
}
}
我们首先通过创建一个typealias来定义我们将要用于成功和失败闭包的闭包。我们将这个名字命名为typealiasResultsClosure。这个例子也说明了为什么你应该使用typealias而不是重新输入闭包定义。这可以节省我们大量的输入,并防止我们出错。在这个例子中,如果我们不使用typealias,我们就需要重新输入闭包定义四次,如果我们需要更改闭包定义,我们就需要在四个地方进行更改。使用类型别名,我们只需要输入一次闭包定义,然后在整个剩余的代码中使用别名。
然后,我们创建一个名为isGreater的方法,它接受两个整数作为前两个参数,以及两个闭包作为接下来的两个参数。第一个闭包命名为successHandler,第二个闭包命名为failureHandler。在这个方法内部,我们检查第一个整数参数是否大于第二个。如果第一个整数大于第二个,则执行successHandler闭包;否则,执行failureHandler闭包。现在,让我们在TestType结构外部创建两个闭包。这两个闭包的代码如下:
var success: TestType.ResultsClosure = {
print("Success: \($0)")
}
var failure: TestType.ResultsClosure = {
print("Failure: \($0)")
}
注意,两个闭包都被定义为TestClass.ResultsClosure类型。在每个闭包中,我们只是简单地打印一条消息到控制台,让我们知道哪个闭包被执行。通常,我们会在闭包中放置一些功能。然后我们将调用方法,如下所示:
var test = TestType()
test.isGreater(numOne: 8, numTwo: 6, successHandler: success, failureHandler: failure)
注意,在方法调用中,我们发送了成功闭包和失败闭包。在这个例子中,我们将看到Success: 8 is greater than 6的消息。如果我们反转数字,我们会看到Failure: 6 is not greater than 8的消息。这种用例在调用异步方法时非常好,例如从网络服务加载数据。如果网络服务调用成功,则调用成功闭包;否则,调用失败闭包。
使用这种闭包的一个大优点是,在我们等待异步调用完成时,UI 不会冻结。这也涉及到并发部分,我们将在第十六章中介绍,即 Swift 中的并发与并行。作为一个例子,想象我们尝试以下方式从网络服务中检索数据:
var data = myWebClass.myWebServiceCall(someParameter)
当我们等待响应时,我们的 UI 会冻结,或者我们必须在单独的线程中进行调用,这样 UI 就不会挂起。有了闭包,我们将闭包传递给网络框架,并依赖框架在完成时执行适当的闭包。这依赖于框架正确实现并发,以异步方式进行调用,但一个不错的框架应该为我们处理这一点。
摘要
在本章中,我们了解到我们可以定义闭包,就像定义整数或字符串类型一样。我们可以将闭包赋值给变量,将它们作为函数的参数传递,并从函数中返回它们。闭包会捕获定义闭包时上下文中的任何常量或变量的强引用。我们必须小心使用这个功能,以确保我们不创建强引用循环,这会导致我们的应用程序中出现内存泄漏。
Swift 中的闭包与 Objective-C 中的 blocks 非常相似,但它们的语法更加简洁和优雅。这使得它们更容易使用和理解。对闭包有良好的理解对于掌握 Swift 编程语言至关重要,这将使开发易于维护的优秀应用程序变得更加容易。它们对于创建既易于使用又易于维护的一等框架也是必不可少的。
在本章中我们探讨的使用案例绝非闭包的唯一有用用例。我可以向你保证,你在 Swift 中使用闭包越多,你会发现它们的应用越广泛。闭包无疑是 Swift 语言中最强大和最有用的特性之一,而苹果通过实现它们做得非常出色。
在下一章中,我们将探讨如何使用 Swift 提供的高级位运算符,以及如何创建我们自己的自定义运算符。
第十五章:高级和自定义运算符
当我开始学习如何编程计算机时,我首先学到的就是如何使用运算符。这些包括基本的运算符,如赋值和算术运算符,这些在第三章,了解变量、常量、字符串和运算符中有所介绍。直到我后来学习 C 语言编程时,我才了解到高级运算符,如位运算符。虽然高级运算符不如基本运算符流行,但使用得当的话,它们可以非常强大。如果你打算编写使用基于 C 的低级库的应用程序,高级运算符特别有用。
在本章中,你将学习:
-
如何使用位运算符
-
溢出运算符的作用
-
如何编写运算符方法
-
如何创建自己的自定义运算符
在第三章,了解变量、常量、字符串和运算符中,我们探讨了最常见的运算符,如赋值、比较和算术运算符。虽然这些运算符在几乎每个有用的应用程序中都会使用,但还有一些不太常用但当你知道如何使用时可以非常强大的运算符。在本章中,我们将探讨一些这些更高级的运算符,从位运算符开始,但首先,我们需要了解比特和字节是什么。
比特和字节
计算机以二进制位的形式思考。这些数字被称为比特,并且只能有两个值:0或1,在电气术语中代表开或关。比特非常小,单独使用时用途有限,除了用作真/假标志。它们被组合成 4、8、16、32 或 64 位的组合,以形成计算机可以使用的数据。
在计算机术语中,字节是一组 8 位。如果我们从字节的角度思考,数字 42 表示如下,其中最低有效位在右边,最高有效位在左边:

图 15.1:以比特表示的数字 42
图 15.1的顶部行显示了 8 位字节中每个比特的值,即开或关,等于数字42。第二行显示了字节中每个比特所表示的值。我们可以看到,对于数字42,值为32、8和2的比特被设置。然后我们可以将这些值相加,看到它们等于 42:32+8+2 = 42。这意味着 8 位字节的值为 42。
默认情况下,Swift 使用 64 位数字;例如,标准的Int类型是 64 位。在本章中,我们将主要使用UInt8类型,它是一个无符号整数,只有 8 位或 1 字节。请注意,64 位类型以与字节相同的方式存储比特;它们只是包含更多的比特。
在上一个例子中,最低有效位在右边,而最高有效位在左边。这是位在图中通常表示的方式。然而,在现实世界的计算机架构中,位可能存储在内存中,其中最高有效位或最低有效位存储在最低的内存地址中。让我们看看这意味着什么。
端序
在计算机术语中,一个架构的 端序 是位在内存中存储的顺序。端序表示为大端序或小端序。在一个被认为是小端序的架构中,最低有效位存储在最低的内存地址中,而在被认为是大端序的架构中,最高有效位存储在最低的内存地址中。
当使用 Swift 标准库,以及在大多数情况下仅使用 Swift 语言本身时,你不需要担心位是如何存储的。如果你需要与低级别的 C 库一起工作,跨多个架构,那么你可能需要了解信息在系统中的存储方式,因为你可能正在处理指向内存位置的指针。
当你需要担心架构的端序时,比如当我们需要与低级别的 C 库交互时,Swift 确实为整数提供了内置的实例属性,名为 littleEndian 和 bigEndian。以下示例展示了如何使用这些属性:
let en = 42
en.littleEndian
en.bigEndian
en.littleEndian 行将返回数字 42 的小端序表示,而 en.bigEndian 行将返回数字 42 的大端序表示。
英特尔处理器和苹果自己的 A 处理器的端序都是小端序;因此,在本章中,我们将假设一切都是小端序。
让我们看看位运算符是什么以及我们如何使用它们。
位运算符
位运算符使我们能够操作值的单个位。位运算符的一个优点是它们直接由处理器支持,因此可以比基本的算术运算(如乘法和除法)快得多。我们将在本章后面看到如何使用位移运算符进行基本的乘法和除法。
在我们查看位运算符能做什么之前,我们需要有能力显示我们变量的二进制表示,以便看到运算符在做什么。让我们看看我们可以这样做的一些方法。
打印二进制数
苹果为我们提供了String类型的通用初始化器,它将提供给定值的字符串表示。这个初始化器是init(_:radix:uppercase:)。默认情况下,uppercase设置为false,radix设置为10。radix定义了将要显示的数字基数,其中10代表十进制。为了看到二进制表示,我们需要将其设置为2。我们可以使用这个初始化器来显示类似以下这样的值的二进制表示:
let en = 42
print(String(en, radix:2))
print(String(53, radix:2))
之前的代码将显示以下结果:
101010
110101
这里,101010是数字42的二进制表示,110101是数字53的二进制表示。这工作得非常好;然而,它没有显示前导零。例如,如果我们比较53和123456的二进制表示,如下面的代码所示:
print(String(53, radix:2))
print(String(123456, radix:2))
我们得到的结果看起来像这样:
110101
11110001001000000
这可能比较难以比较。当我需要轻松地看到数字的二进制表示时,我通常将以下扩展放入我的代码库中:
extension BinaryInteger {
func binaryFormat(_ nibbles: Int) -> String {
var number = self
var binaryString = ""
var counter = 0
let totalBits = nibbles*4
for _ in (1...totalBits).reversed() {
binaryString.insert(contentsOf: "\(number & 1)", at:
binaryString.startIndex)
number >>= 1
counter += 1
if counter % 4 == 0 && counter < totalBits {
binaryString.insert(contentsOf: " ", at: binaryString.startIndex)
}
}
return binaryString
}
}
如果你现在还不理解这段代码是如何工作的,这是完全可以的,因为位移运算符还没有被解释。一旦在本章后面解释了它们,你将能够理解它是如何工作的。
这个扩展将接受一个整数,并返回该数字的二进制表示,带有适当数量的半字节。在本章前面提到,一个字节有 8 位;半字节是字节的一半或 4 位。在返回的字符串中,这段代码将在每个半字节之间放置一个空格,以便更容易阅读。我们可以像以下代码所示使用这个扩展:
print(53.binaryFormat(2))
print(230.binaryFormat(2))
使用这段代码,我们显示了数字53和230在两个半字节中的二进制表示。以下结果显示了将打印到控制台的内容:
0011 0101
1110 0110
现在我们已经对位、字节、半字节和字节序有了非常基本的了解,并且能够以二进制格式显示数字,让我们来看看位运算符,从位与运算符开始。
位与运算符
位与运算符(&)接受两个值,并返回一个新值,其中新值中的位仅当两个输入值的对应位都设置为 1 时才设置为 1。与运算符可以读作:如果第一个值的位与第二个值的位都是 1,则将结果值的对应位设置为 1。让我们通过看看如何对数字 42 和 11 进行位与运算来了解它是如何工作的:

图 15.2:AND 运算符
如此图所示,从右数第二位和第四位都是 1,因此 AND 运算的结果将那些位设置为 1,得到输出值10。现在让我们看看代码中是如何实现的:
let numberOne: Int8 = 42
let numberTwo: Int8 = 11
print("\(numberOne) = \(numberOne.binaryFormat(2))")
print("\(numberTwo) = \(numberTwo.binaryFormat(2))")
let andResults = numberOne & numberTwo
print("\(andResults) = \(andResults.binaryFormat(2))")
之前的代码将两个整数设置为42和11。然后它使用binaryFormat扩展在控制台打印数字的二进制表示,以两个十六进制位为单位。然后它对整数执行按位 AND 运算并将结果的二进制表示打印到控制台。以下结果将被打印到控制台:
42 = 0010 1010
11 = 0000 1011
10 = 0000 1010
如我们所见,代码的结果与图表中显示的相同,结果为10。现在让我们看看按位 OR 运算符。
按位 OR 运算符
按位 OR 运算符(|)接受两个值并返回一个新值,其中结果的位仅当任一或两个值的对应位设置为 1 时才设置为 1。OR 运算读作:如果第一个值的位或第二个值的位为 1,则将结果中的位设置为 1。让我们看看我们如何对数字 42 和 11 进行按位 OR 运算:

图 15.3:OR 运算符
如此图表所示,从右数起的第一、第二、第四和第六位在其中一个或两个值中设置为 1,因此 OR 运算的结果将所有这些位都设置为 1。现在让我们看看这在代码中是如何工作的:
let numberOne: Int8 = 42
let numberTwo: Int8 = 11
print("\(numberOne) = \(numberOne.binaryFormat(2))")
print("\(numberTwo) = \(numberTwo.binaryFormat(2))")
let orResults = numberOne | numberTwo
print("\(orResults) = \(orResults.binaryFormat(2))")
之前的代码将两个整数设置为42和11。然后它使用binaryFormat扩展在控制台打印数字的二进制表示,以两个十六进制位为单位。然后它对整数执行按位 OR 运算并打印结果的二进制表示。以下结果将被打印到控制台:
42 = 0010 1010
11 = 0000 1011
43 = 0010 1011
如我们所见,代码的结果与图 15.3中显示的相同,结果为43。现在让我们看看按位 XOR 运算符。
按位 XOR 运算符
按位 XOR 运算符(^)接受两个值并返回一个新值,其中新值的位仅当任一但不是两个输入值的对应位设置为 1 时才设置为 1。XOR 运算符读作:如果第一个值的位或第二个值的位为 1,但不是两个都为 1,则将结果中的位设置为 1。让我们看看我们如何对数字 42 和 11 进行按位 XOR 运算:

图 15.4:XOR 运算符
如此图表所示,对于两个数,从右数起的第二和第四位都设置为1,因此在结果中这些位没有被设置。然而,数字42中的第六位被设置为1,而数字11中的第一位被设置为1,因此在这些结果中这些位被设置。现在让我们看看这在代码中是如何工作的:
let numberOne: Int8 = 42
let numberTwo: Int8 = 11
print("\(numberOne) = \(numberOne.binaryFormat(2))")
print("\(numberTwo) = \(numberTwo.binaryFormat(2))")
let xorResults = numberOne ^ numberTwo
print("\(xorResults) = \(xorResults.binaryFormat(2))")
之前的代码将两个整数设置为42和11。然后使用binaryFormat扩展将数字的二进制表示打印到两个四分位上。接着对整数执行按位异或操作,并打印结果的二进制表示。以下结果将被打印到控制台:
42 = 0010 1010
11 = 0000 1011
33 = 0010 0001
如我们所见,代码的结果与图表中显示的相同,结果为33。现在让我们看看按位非运算符。
按位非运算符
按位非运算符(~)与其他逻辑运算符不同,因为它只接受一个值。按位非运算符将返回一个所有位都被反转的值。这意味着输入值中设置为 1 的任何位在结果值中将被设置为 0,而输入值中设置为 0 的任何位在结果值中将被设置为 1。让我们看看给定 42 这个值会如何:

图 15.5:非运算符
图表说明了当我们执行按位非操作时,结果值中的所有位都将与原始值中的位相反。让我们看看代码中的样子:
let numberOne: Int8 = 42
let notResults = ~numberOne
print("\(notResults) = \(notResults.binaryFormat(2))")
之前的代码对numberOne变量的值执行了非操作。以下结果将被打印到控制台:
-43 = 1101 0101
注意结果是一个负数。这是因为整数是有符号数。在有符号数中,最高有效位表示该数是正数还是负数。通过所有位反转,非操作后,负数将始终变成正数,而正数将始终变成负数。
现在我们已经了解了逻辑按位运算符,让我们来看看按位移位运算符。
按位移位运算符
Swift 提供了两个按位移位运算符,即按位左移运算符(<<)和按位右移运算符(>>)。这些运算符将所有位向左或向右移动指定的位数。移位运算符的效果是乘以(左移运算符)或除以(右移运算符)2 的因子。通过将位向左移动一位,你将值翻倍,而将位向右移动一位将值减半。让我们看看这些运算符是如何工作的,从左移运算符开始:

图 15.6:左移运算符
使用左移运算符,原始值中的所有位都向左移动一位,最高有效位掉落且不计入最终结果。结果中的最低有效位始终被设置为 0。现在让我们看看右移操作:

图 15.7:右移运算符
使用右移运算符,原始值中的所有位都向右移动一个位置,最低有效位掉落。结果中的最高有效位始终被设置为 0。
现在让我们看看这在代码中是什么样子:
let numberOne: UInt8 = 24
let resultsLeft = numberOne << 1
let resultsRight = numberOne >> 1
let resultsLeft3 = numberOne << 3
let resultsRight4 = numberOne >> 4
print("24 \(numberOne.binaryFormat(2))")
print("<<1 \(resultsLeft.binaryFormat(2))")
print(">>1 \(resultsRight.binaryFormat(2))")
print("<<3 \(resultsLeft3.binaryFormat(2))")
print(">>4 \(resultsRight4.binaryFormat(2))")
在这段代码中,我们首先将一个变量设置为数字24。然后我们使用左移运算符将位向左移动一个位置。移位运算符后面的数字定义了移动的位置数。下一行将位向右移动一个位置,下一行将位向左移动三个位置,下一行将位向右移动四个位置。最后的五行将结果打印到控制台。如果你运行这段代码,你应该看到以下结果:
24 0001 1000
<<1 0011 0000
>>1 0000 1100
<<3 1100 0000
>>4 0000 0001
观察结果,我们可以看到位根据所使用的移位运算符向左或向右移动。在最后一行,我们可以看到当我们向右移动四个位置时,只有一个位被设置为1而不是两个。这是因为原始数字从右数第四位的位实际上已经掉落了。如果我们向右移动五个位置,原始数字中设置为1的两个位都会掉落,我们就会剩下全部为零。
现在让我们看看溢出运算符。
溢出运算符
Swift 的核心设计是为了安全。这些安全机制之一是当变量的类型太小无法容纳时,无法将一个数插入到变量中。例如,以下代码将抛出以下错误:算术运算 '255 + 1'(在类型 'UInt8' 上)导致溢出:
let b: UInt8 = UInt8.max +1
抛出错误的原因是我们试图将一个数加到UInt8能持有的最大数上。这种错误检查可以帮助防止我们应用程序中意外且难以追踪的问题。让我们花点时间看看如果 Swift 在溢出发生时不抛出错误会发生什么。在UInt8变量中,它是一个 8 位的无符号整数,数字 255 是这样存储的,其中所有的位都设置为 1:

图 15.8:255 的二进制表示
现在如果我们给这个数加 1,新的数字将这样存储:

图 15.9:尝试表示 256 时的溢出
注意,表示UInt8数字的 8 位都是零,而最高位的1掉落或溢出,因为我们只能存储 8 位。在这种情况下,当我们给数字 255 加 1 时,如果没有溢出错误检查,结果中存储的数字将是 0。这可能导致我们代码中非常意外的行为,难以追踪。
如果这是我们想要的行为,Swift 确实提供了三个溢出操作符,允许我们选择这种行为。这些是溢出加法操作符(&+)、溢出减法操作符(&-)和溢出乘法操作符(&*)。以下代码显示了这些操作符的工作方式:
let add: UInt8 = UInt8.max &+ 1
let sub: UInt8 = UInt8.min &- 1
let mul: UInt8 = 42 &* 10
print("add: \(add): \(add.binaryFormat(2))")
print("sub: \(sub): \(sub.binaryFormat(2))")
print("mul: \(mul): \(mul.binaryFormat(2))")
在此代码中,我们将 1 加到UInt8类型的最大值 255 上,从UInt8类型的最低值 0 减去 1,然后将42乘以10,其结果大于UInt8类型的 255 最大值。打印到控制台的结果是:
add: 0: 0000 0000
sub: 255: 1111 1111
mul: 164: 1010 0100
从结果中我们可以看出,当我们将 1 加到UInt8类型的最大值上时,结果是0。当我们从UInt8类型的最低值减去 1 时,结果是255(UInt8类型的最大值)。最后,当我们用42乘以10,这通常是我们的数学老师会告诉我们的结果是 420,但实际上我们得到了164,因为发生了溢出。
现在我们来看看如何使用操作符方法将操作符添加到我们的自定义类型中。
操作符方法
操作符方法使我们能够向类和结构体添加标准 Swift 操作符的实现。这也被称为操作符重载。这是一个非常有用的特性,因为它使我们能够使用已知的操作符为我们自定义类型提供常用功能。我们将看看如何做到这一点,但首先,让我们创建一个名为MyPoint的自定义类型:
struct MyPoint {
var x = 0
var y = 0
}
MyPoint结构体定义了一个图表上的二维点。现在让我们向这个类型添加三个操作符方法。我们将添加的操作符是加法操作符(+)、加法赋值操作符(+=)和逆操作符(-)。加法操作符和加法赋值操作符是中缀操作符,因为操作中有左操作数(值)和右操作数(值),而逆操作符是前缀操作符,因为它用于单个值之前。我们还有后缀操作符,它们用于单个值的末尾:
extension MyPoint {
static func + (left: MyPoint, right: MyPoint) -> MyPoint {
return MyPoint(x: left.x + right.x, y: left.y + right.y)
}
static func += (left: inout MyPoint, right: MyPoint) {
left.x += right.x
left.y += right.y
}
static prefix func -(point: MyPoint) -> MyPoint {
return MyPoint(x: -point.x, y: -point.y)
}
}
当我们将操作符方法添加到我们的类型中时,我们使用操作符符号作为方法名称,将它们添加为静态函数。当我们添加前缀或后缀操作符时,我们还在函数声明之前包含prefix或postfix关键字。
加法操作符是一个中缀操作符;因此,它接受两个输入参数,都是MyPoint类型的。一个参数是为加法操作符左侧的MyPoint实例,另一个参数是为加法操作符右侧的MyPoint实例。
加法赋值运算符也是一个中缀运算符;因此,它也接受两个MyPoint类型的输入参数。与加法运算符的主要区别在于,加法运算的结果被赋值给加法赋值运算符左侧的MyPoint实例。因此,这个参数被指定为inout参数,以便可以在该实例内返回结果。
我们添加的最后一个运算符方法是逆运算符。这是一个前缀运算符,用于MyPoint类型的实例之前;因此,它只接受一个MyPoint类型的参数。让我们看看这些运算符是如何工作的:
let firstPoint = MyPoint(x: 1, y: 4)
let secondPoint = MyPoint(x: 5, y: 10)
var combined = firstPoint + secondPoint
print("\(combined.x), \(combined.y)")
combined += firstPoint
print("\(combined.x), \(combined.y)")
let inverse = -combined
print("\(inverse.x), \(inverse.y)")
在这段代码中,我们首先定义了两个点,然后使用我们创建的加法运算符将它们相加。这个运算符的结果被放入新的combined实例中,该实例是MyPoint类型。combined实例将包含x值为 6 和y值为 14。
我们接着使用我们创建的加法赋值运算符将firstPoint实例中的值添加到combined实例中的值。这个操作的结果被放入MyPoint类型的combined实例中。现在combined实例包含x值为 7 和y值为 14。
最后,我们使用combined实例的逆运算符来反转值,并将新值保存到MyPoint类型的inverse实例中。inverse实例包含x值为-7 和y值为-18。
我们不仅限于使用当前运算符,还可以创建自己的自定义运算符。让我们看看我们如何做到这一点。
自定义运算符
自定义运算符使我们能够在 Swift 语言提供的标准运算符之外声明和实现自己的运算符。新的运算符必须使用operator关键字全局声明。它们还必须使用infix、prefix或postfix关键字进行定义。一旦一个运算符被全局定义,我们就可以使用前一个章节中展示的运算符方法将其添加到我们的类型中。让我们通过添加两个新的运算符来查看这一点:•,我们将用它来相乘两个点,以及••,它将用于平方一个值。我们将把这些运算符添加到我们在上一节中创建的MyPoint类型中。
•符号可以在运行 macOS 的计算机上通过按住option键并按下数字8来输入。
我们需要做的第一件事是全局声明运算符。这可以通过以下代码完成:
infix operator •
prefix operator ••
注意,我们定义了它是什么类型的运算符(infix、prefix或postfix),然后是operator关键字,然后是用于运算符的符号。现在我们可以像使用正常的运算符一样使用它们,针对我们的MyPoint类型:
extension MyPoint {
static func • (left: MyPoint, right: MyPoint) -> MyPoint {
return MyPoint(x: left.x * right.x, y: left.y * right.y)
}
static prefix func •• (point: MyPoint) -> MyPoint {
return MyPoint(x: point.x * point.x, y: point.y * point.y)
}
}
这些新的自定义操作符就像我们添加标准操作符一样,被添加到MyPoint类型中,使用静态函数。我们现在能够像使用标准操作符一样使用这些操作符:
let firstPoint = MyPoint(x: 1, y: 4)
let secondPoint = MyPoint(x: 5, y: 10)
let multiplied = firstPoint • secondPoint
print("\(multiplied.x), \(multiplied.y)")
let squared = ••secondPoint
print("\(squared.x), \(squared.y)")
在第一行,我们使用•操作符将两个MyPoint类型的实例相乘。结果被放入MyPoint类型的乘积实例中。现在,乘积实例将包含x的值为 5 和y的值为 40。
然后,我们使用••操作符平方secondPoint实例的值,并将新值放入平方实例中。现在,squared实例将包含x的值为 25 和y的值为 100。
摘要
在本章中,我们探讨了如何使用高级位与、或、异或和非操作符来操作存储在变量中的值的位。我们还探讨了如何使用左移和右移操作符将位向左和向右移动。然后我们看到如何使用溢出操作符来改变加法、减法和乘法的默认行为,以便在操作返回超出类型的最大值或小于最小值时不会抛出错误。
在本章的后半部分,我们看到了如何向类型添加操作符方法,这使得我们能够使用 Swift 提供的标准操作符与我们的自定义类型一起使用。我们还看到了如何创建我们自己的自定义操作符。
在下一章中,我们将探讨如何使用大中央调度和操作队列向我们的应用程序代码中添加并发性和并行性。
第十六章:Swift 中的并发和并行
当我刚开始学习 Objective-C 时,我已经对其他语言(如 C 和 Java)中的并发和多任务处理有了很好的理解。这个背景使我能够很容易地使用线程创建多线程应用程序。然后,当苹果在 OS X 10.6 和 iOS 4 中发布 Grand Central Dispatch (GCD)时,一切发生了改变。起初,我陷入了否认;GCD 根本不可能比我更好地管理我的应用程序的线程。然后,我进入了愤怒阶段;GCD 难以使用和理解。接下来是讨价还价阶段;也许我可以使用 GCD 和我的线程代码,这样我仍然可以控制线程的工作方式。然后,是抑郁阶段;也许 GCD 确实比我更好地处理线程。最后,我进入了哇哦阶段;这个 GCD 真的很容易使用,并且工作得非常出色。
在使用 GCD 和操作队列进行 Objective-C 开发后,我看不到使用 Swift 中的手动线程的理由。
在本章中,我们将学习以下主题:
-
并发和并行的基础知识
-
如何使用 GCD 创建和管理并发调度队列
-
如何使用 GCD 创建和管理串行调度队列
-
如何使用各种 GCD 函数将任务添加到调度队列中
-
如何使用
Operation和OperationQueues为我们的应用程序添加并发
在 Swift 5.x 的整个过程中,我们在 Swift 语言中的并发方面并没有看到太多的改进。但似乎这将在未来改变,因为并发改进是 Swift 6 的主要目标之一。让我们首先看看并发和并行之间的区别,这是理解的重要之处。
并发和并行
并发 是指在相同的时间段内,多个任务开始、运行和完成的概念。这并不一定意味着任务正在同时执行。实际上,为了使任务能够同时执行,我们的应用程序需要在多核或多处理器系统上运行。并发使我们能够为多个任务共享处理器或核心;然而,单个核心在任何给定时间只能执行一个任务。
并行 是指两个或更多任务同时运行的概念。由于我们的处理器的每个核心一次只能执行一个任务,因此同时执行的任务数量受限于处理器中的核心数量和我们拥有的处理器数量。例如,如果我们有一个四核处理器,那么我们同时运行的任务数量将受到限制为四个。今天的处理器可以如此快速地执行任务,以至于看起来更大的任务似乎是在同时执行。然而,在系统中,较大的任务实际上是在核心上轮流执行子任务。
为了理解并发和并行之间的区别,让我们看看杂技演员是如何抛接球的。如果你观察一个杂技演员,他们似乎在任何时候都能同时接住和抛出多个球;然而,仔细观察会发现,他们实际上每次只接住和抛出一个球。其他球在空中等待被接住和抛出。如果我们想能够同时接住和抛出多个球,我们需要有多个杂技演员。
这个例子非常好,因为我们可以把杂技演员看作是处理器的核心。单核处理器的系统(一个杂技演员),无论看起来如何,一次只能执行一个任务(接住或抛出一个球)。如果我们想一次执行多个任务,我们需要使用多核处理器(多个杂技演员)。
在所有处理器都是单核的时代,要有一个能够同时执行任务的系统,唯一的方法是在系统中拥有多个处理器。这也需要专门的软件来利用多个处理器。在当今世界,几乎每个设备都有一个拥有多个核心的处理器,iOS 和 macOS 都被设计用来利用这些多个核心来同时运行任务。
传统上,应用程序通过创建多个线程来添加并发性;然而,这种模型在任意数量的核心上扩展性不好。使用线程的最大问题是我们的应用程序运行在各种各样的系统(和处理器的)上,为了优化我们的代码,我们需要知道在给定时间可以高效使用多少核心/处理器,这通常在开发时是未知的。
为了解决这个问题,许多操作系统,包括 iOS 和 macOS,开始依赖异步函数。这些函数通常用于启动可能需要很长时间才能完成的任务,例如发起 HTTP 请求或写入磁盘数据。异步函数通常在任务完成之前启动一个长时间运行的任务,并返回。通常,这个任务在后台运行,并在任务完成时使用回调函数(例如 Swift 中的闭包)。
这些异步函数对于操作系统提供的任务来说效果很好,但如果我们需要创建自己的异步函数而不想自己管理线程怎么办?为此,Apple 提供了一些技术。在本章中,我们将介绍其中两种:GCD 和操作队列。
GCD 是一个基于 C 的低级 API,它允许将特定任务排队以供执行,并在任何可用的处理器核心上调度执行。操作队列与 GCD 类似;然而,它们是 Foundation 对象,并且内部使用 GCD 实现。
让我们从查看最大公约数(GCD)开始。
大中枢调度(Grand Central Dispatch, GCD)
在 Swift 3 之前,使用 GCD 感觉像是编写低级 C 代码。API 有点笨拙,有时难以理解,因为它没有使用 Swift 语言设计中的任何特性。这一切都在 Swift 3 中发生了变化,因为苹果承担了重写 API 的任务,以便它符合 Swift 3 API 指南。
GCD 提供了所谓的调度队列来管理提交的任务。队列管理这些提交的任务,并以先进先出(FIFO)的顺序执行它们。这确保了任务是以提交的顺序启动的。
任务只是我们应用程序需要执行的一些工作。例如,我们可以创建执行简单计算、读写磁盘数据、发起 HTTP 请求或我们应用程序需要做的任何其他任务的作业。我们通过将代码放在函数或闭包中并将它们添加到调度队列中来定义这些任务。
GCD 提供了三种类型的调度队列:
-
串行队列:串行队列中的任务(也称为私有队列)按提交的顺序一次执行一个。只有在前一个任务完成后,才会启动每个任务。串行队列通常用于同步访问特定资源,因为我们保证串行队列中的两个任务永远不会同时运行。因此,如果访问特定资源的唯一方式是通过串行队列中的任务,那么两个任务将不会同时或顺序错误地尝试访问资源。
-
并发队列:并发队列中的任务(也称为全局调度队列)是并发执行的;然而,任务的启动顺序仍然是它们被添加到队列中的顺序。在任何给定时刻可以执行的任务的确切数量是可变的,并且取决于系统的当前条件和资源。何时启动任务的决定权在 GCD,而不是我们可以在应用程序内部控制的事情。
-
主调度队列:主调度队列是一个全局可用的串行队列,它在应用程序的主线程上执行任务。由于放入主调度队列的任务在主线程上运行,因此它通常在后台处理完成并且用户界面需要更新时从后台队列中调用。
调度队列相对于传统线程提供了几个优势。首要的优势是,使用调度队列时,系统处理线程的创建和管理,而不是应用程序本身。系统可以根据系统的总体可用资源和当前系统条件动态地调整线程的数量。这意味着调度队列可以比我们更有效地管理线程。
调度队列的另一个优点是我们能够控制任务启动的顺序。对于串行队列,我们不仅控制任务启动的顺序,而且还确保在先前的任务完成之前不会启动另一个任务。使用传统的线程,这可能会非常繁琐且难以实现,但正如我们将在本章后面看到的那样,使用调度队列则相当容易。
计算类型
在我们查看如何使用调度队列之前,让我们创建一个类来帮助我们演示各种队列类型的工作原理。这个类将包含两个基本函数,我们将这个类命名为 DoCalculations。第一个函数将简单地执行一些基本计算,然后返回一个值。以下是这个函数的代码,该函数名为 doCalc():
func doCalc() {
let x = 100
let y = x*x
_ = y/x
}
另一个函数,我们将命名为 performCalculation(),接受两个参数。一个是名为 iterations 的整数,另一个是名为 tag 的字符串。performCalculation() 函数重复调用 doCalc() 函数,直到它调用的次数与 iterations 参数定义的次数相同。我们还使用 CFAbsoluteTimeGetCurrent() 函数来计算执行所有迭代所需的时间,然后我们使用 tag 字符串将经过的时间打印到控制台。这将让我们知道函数何时完成以及完成它所需的时间。以下是这个函数的代码:
func performCalculation(_ iterations: Int, tag: String) {
let start = CFAbsoluteTimeGetCurrent()
for _ in 0 ..< iterations {
self.doCalc()
}
let end = CFAbsoluteTimeGetCurrent()
print("time for \(tag):\(end-start)")
}
这些函数将一起使用,以使我们的队列保持忙碌,这样我们就可以看到它们是如何工作的。让我们首先看看我们如何创建一个调度队列。
创建队列
我们使用 DispatchQueue 初始化器来创建一个新的调度队列。以下代码展示了我们如何创建一个新的调度队列:
let concurrentQueue = DispatchQueue(label: "cqueue.hoffman.jon", attributes: .concurrent)
let serialQueue = DispatchQueue(label: "squeue.hoffman.jon")
第一行将创建一个带有标签 cqueue.hoffman.jon 的并发队列,而第二行将创建一个带有标签 squeue.hoffman.jon 的串行队列。DispatchQueue 初始化器接受以下参数:
-
label: 这是一个字符串标签,它附加到队列上,以在调试工具(如仪器和崩溃报告)中唯一标识它。建议我们使用反向 DNS 命名约定。此参数是可选的,可以是nil。 -
attributes: 这指定了要创建的队列类型。这可以是DispatchQueue.Attributes.serial、DispatchQueue.Attributes.concurrent或nil。如果这个参数是nil,则创建一个串行队列。您可以使用.serial或.concurrent,就像我们在示例代码中所展示的那样。
一些编程语言使用反向 DNS 命名约定来命名某些组件。这个约定基于一个注册的域名,并将其反转。例如,如果我们为一家名为 mycompany.com 的公司工作,该公司有一个名为 widget 的产品,那么反向 DNS 名称将是 com.mycompany.widget。
现在让我们看看我们如何创建和使用并发队列。
创建和使用并发队列
并发队列将按照 FIFO 顺序执行任务;然而,任务将并发执行并按任意顺序完成。让我们看看我们如何创建和使用一个并发队列。以下行将创建我们将用于本节的并发队列,并将创建一个DoCalculations类型的实例,该实例将用于测试队列:
let cqueue = DispatchQueue(label: "cqueue.hoffman.jon", attributes:.concurrent)
let calculation = DoCalculations()
第一行将创建一个新的调度队列,我们将命名为cqueue,第二行创建一个DoCalculations类型的实例。现在,让我们看看我们如何使用并发队列,通过使用DoCalculations类型的performCalculation()方法来执行一些计算:
let c = {calculation.performCalculation(1000, tag: "async1")}
cqueue.async(execute: c)
在前面的代码中,我们创建了一个闭包,它代表我们的任务,并简单地调用DoCalculation实例的performCalculation()函数,请求它运行doCalc()函数的 1,000 次迭代。最后,我们使用队列的async(execute:)方法来执行它。此代码将在并发调度队列中执行任务,该队列与主线程分开。
虽然前面的例子工作得很好,但实际上我们可以稍微缩短代码。下一个例子显示,我们实际上不需要像前面例子中那样创建一个单独的闭包。我们也可以像下面这样提交任务来执行:
cqueue.async {
calculation.performCalculation(1000, tag: "async1")
}
这种简写版本是我们通常向队列提交小代码块的方式。如果我们有更大的任务或需要多次提交的任务,我们通常希望创建一个闭包,并将闭包提交到队列中,就像我们在第一个例子中展示的那样。
让我们通过向队列中添加几个项目并查看它们的返回顺序和时间来了解并发队列的工作方式。以下代码将向队列中添加三个任务。每个任务将使用不同的迭代计数调用performCalculation()函数。
记住,performCalculation()函数将连续执行计算例程,直到执行了传入的迭代次数。因此,我们传入函数的迭代计数越大,它应该执行的时间就越长。让我们看看以下代码:
cqueue.async {
calculation.performCalculation(10_000_000, tag: "async1")
}
cqueue.async {
calculation.performCalculation(1000, tag: "async2")
}
cqueue.async {
calculation.performCalculation(100_000, tag: "async3")
}
注意,每个函数在tag参数中调用时都使用不同的值。由于performCalculation()函数会打印出带有经过时间的tag变量,我们可以看到任务完成的顺序以及它们执行所需的时间。如果我们执行前面的代码,我们应该看到类似以下的结果:
time for async2: 0.000200986862182617
time for async3: 0.00800204277038574
time for async1: 0.461670994758606
经过的时间会因运行而异,也会因系统而异。
由于队列按照 FIFO(先进先出)的顺序工作,具有async1标签的任务首先被执行。然而,从结果中我们可以看到,它是最后一个完成的任务。由于这是一个并发队列,如果可能的话(如果系统有可用资源),代码块将并发执行。这就是为什么带有async2和async3标签的任务在带有async1标签的任务之前完成,尽管async1任务的执行在其他两个任务之前开始。
现在,让我们看看一个串行队列是如何执行任务的。
创建和使用串行队列
串行队列与并发队列的工作方式略有不同。串行队列一次只执行一个任务,并在开始下一个任务之前等待当前任务完成。这个队列,就像并发调度队列一样,遵循 FIFO 顺序。以下代码行将创建我们将用于本节的串行队列,并将创建一个DoCalculations类型的实例:
let squeue = DispatchQueue(label: "squeue.hoffman.jon")
let calculation = DoCalculations()
第一行将创建一个新的名为squeue的串行调度队列,第二行创建一个DoCalculations类型的实例。现在,让我们看看我们如何使用我们的串行队列,通过使用DoCalculations类型的performCalculation()方法来执行一些计算:
let s = {calculation.performCalculation(1000, tag: "async1")}
squeue.async (execute: s)
在前面的代码中,我们创建了一个闭包,它代表我们的任务,简单地调用DoCalculation实例的performCalculation()函数,请求它运行doCalc()函数的 1,000 次迭代。最后,我们使用队列的async(execute:)方法来执行它。此代码将在一个串行调度队列中执行任务,该队列与主线程分开。从这段代码中我们可以看到,我们使用串行队列的方式与使用并发队列的方式完全相同。
我们可以稍微缩短这段代码,就像我们处理并发队列时做的那样。以下示例展示了我们如何使用串行队列来完成这个操作:
squeue.async {
calculation.performCalculation(1000, tag: "async2")
}
让我们通过向队列中添加几个项目并查看它们完成的顺序来了解串行队列的工作方式。以下代码将添加三个任务到队列中,这些任务将使用不同的迭代次数调用performCalculation()函数:
squeue.async {
calculation.performCalculation(100000, tag: "async1")
}
squeue.async {
calculation.performCalculation(1000, tag: "async2")
}
squeue.async {
calculation.performCalculation(100000, tag: "async3")
}
正如我们在并发队列示例中所做的那样,我们使用不同的迭代次数和不同的tag参数值调用performCalculation()函数。由于performCalculation()函数会打印出带有经过时间的tag字符串,我们可以看到任务的完成顺序和执行所需的时间。如果我们执行此代码,我们应该看到以下结果:
time for async1: 0.00648999214172363
time for async2: 0.00009602308273315
time for async3: 0.00515800714492798
运行时间会因运行次数和系统而异。
与并发队列不同,我们可以看到任务是以它们提交的顺序完成的,尽管sync2和sync3任务的完成时间明显更短。这表明串行队列一次只执行一个任务,并且队列在开始下一个任务之前会等待每个任务完成。
在之前的示例中,我们使用了async方法来执行代码块。我们也可以使用sync方法。
异步与同步
在之前的示例中,我们使用了async方法来执行代码块。当我们使用async方法时,调用将不会阻塞当前线程。这意味着该方法返回,并且代码块是异步执行的。
而不是使用async方法,我们可以使用sync方法来执行代码块。sync方法将阻塞当前线程,这意味着它不会返回直到代码执行完成。通常,我们使用async方法,但有一些情况下sync方法是有用的。这些用例通常是我们有一个单独的线程,并且我们希望该线程等待某些工作完成。
在主队列上执行代码
DispatchQueue.main.async(execute:)函数将在应用程序的主队列上执行代码。我们通常在想要从另一个线程或队列更新我们的代码时使用此函数。
当应用程序启动时,会自动为主线程创建主队列。这个主队列是一个串行队列;因此,队列中的项目将按它们提交的顺序一次执行一个。我们通常希望避免使用此队列,除非我们有必要从后台线程更新用户界面。
以下代码示例显示了我们将如何使用此函数:
let squeue = DispatchQueue(label: "squeue.hoffman.jon")
squeue.async {
let resizedImage = image.resize(to: rect)
DispatchQueue.main.async {
picView.image = resizedImage
}
}
在之前的代码中,我们假设我们已经向UIImage类型添加了一个方法,该方法将调整图像大小。在这段代码中,我们创建了一个新的串行队列,并在该队列中调整图像大小。这是一个很好的使用调度队列的例子,因为我们不希望在主队列上调整图像大小,因为这会在调整图像大小时冻结 UI。一旦图像调整大小,我们就需要使用新的图像更新UIImageView;然而,所有对 UI 的更新都需要在主线程上发生。因此,我们将使用DispatchQueue.main.async函数在主队列上执行更新。
有时会需要延迟执行任务。如果我们使用线程模型,我们需要创建一个新的线程,执行某种delay或sleep函数,然后执行我们的任务。使用 GCD,我们可以使用asyncAfter函数。
使用 asyncAfter
asyncAfter函数将在给定延迟后异步执行代码块。当我们需要暂停代码执行时,这非常有用。以下代码示例显示了我们将如何使用asyncAfter函数:
let queue2 = DispatchQueue(label: "squeue.hoffman.jon")
let delayInSeconds = 2.0
let pTime = DispatchTime.now() + Double(delayInSeconds * Double(NSEC_PER_SEC)) / Double(NSEC_PER_SEC)
queue2.asyncAfter(deadline: pTime) {
print("Time's Up")
}
在此代码中,我们首先创建一个串行调度队列。然后创建一个DispatchTime类型的实例,并基于当前时间计算执行代码块所需的时间。接着,我们使用asyncAfter函数在延迟后执行代码块。
现在,我们已经了解了 GCD,让我们来看看操作队列。
使用Operation和OperationQueue类型
Operation和OperationQueue类型协同工作,为我们提供了在应用程序中添加并发的 GCD 替代方案。操作队列是 Foundation 框架的一部分,它们作为 GCD 的高级抽象,其功能类似于调度队列。
我们定义我们希望执行的作业(操作),然后将任务添加到操作队列中。操作队列将随后处理任务的调度和执行。操作队列是OperationQueue类的实例,而操作是Operation类的实例。
一个操作代表一个单独的工作单元或任务。Operation类型是一个抽象类,它提供了一个线程安全的结构来模拟状态、优先级和依赖关系。这个类必须被子类化以执行任何有用的操作;我们将在本章的“子类化Operation类”部分中查看如何子类化这个类。
苹果为我们提供了一个Operation类型的具体实现,我们可以直接使用它,在不需要构建自定义子类的情况下。这个子类是BlockOperation。
同时可以存在多个操作队列,实际上,总是至少有一个操作队列在运行。这个操作队列被称为主队列。主队列在应用程序启动时自动为主线程创建,并且所有 UI 操作都在这里执行。
在操作队列中需要注意的一点是,由于它们是 Foundation 对象,因此会添加额外的开销。对于大多数应用程序来说,这微小的额外开销不应成为问题,甚至可能不会被注意到;然而,对于一些项目,例如需要尽可能多资源的游戏,这个额外的开销可能确实是一个问题。
我们可以使用多种方式将Operation和OperationQueue类用于向我们的应用程序添加并发。在本章中,我们将探讨这三种方式中的一种。我们将首先查看Operation抽象类的BlockOperation实现的使用。
使用BlockOperation
在本节中,我们将使用与Grand Central Dispatch (GCD)部分中相同的DoCalculation类,以保持我们的队列忙碌于工作,这样我们就可以看到OperationQueue类是如何工作的。
BlockOperation类是Operation类型的具体实现,它可以管理一个或多个代码块的执行。这个类可以用来同时执行多个任务,而无需为每个任务创建单独的操作。
让我们看看如何使用BlockOperation类来为我们的应用程序添加并发性。以下代码展示了如何使用单个BlockOperation实例将三个任务添加到操作队列中:
let calculation = DoCalculations()
let blockOperation1: BlockOperation = BlockOperation.init(
block: {
calculation.performCalculation(10000000, tag: "Operation 1")
}
)
blockOperation1.addExecutionBlock({
calculation.performCalculation(10000, tag: "Operation 2")
}
)
blockOperation1.addExecutionBlock({
calculation.performCalculation(1000000, tag: "Operation 3")
}
)
let operationQueue = OperationQueue()
operationQueue.addOperation(blockOperation1)
在此代码中,我们首先创建DoCalculation类的一个实例和OperationQueue类的一个实例。接下来,我们使用init构造函数创建BlockOperation类的一个实例。此构造函数接受一个参数,它是一个代码块,代表我们想在队列中执行的任务之一。然后,我们使用addExecutionBlock()方法添加两个额外的任务。
分发队列和操作之间的一个区别是,在分发队列中,如果资源可用,任务将随着它们被添加到队列而执行。在操作中,各个任务不会执行,直到操作本身被提交到操作队列。这允许我们在执行之前将所有操作初始化到一个单独的操作中。
一旦我们将所有任务添加到BlockOperation实例中,我们接着将操作添加到我们在代码开头创建的OperationQueue实例中。此时,操作内的各个任务开始执行。
这个例子展示了如何使用BlockOperation来排队多个任务,然后将任务传递到操作队列。任务按照 FIFO(先进先出)的顺序执行;因此,首先添加的任务将是第一个执行的任务。然而,如果我们有可用的资源,任务可以并发执行。
此代码的输出应类似于以下内容:
time for Operation 2: 0.00546294450759888
time for Operation 3: 0.0800899863243103
time for Operation 1: 0.484337985515594
如果我们不想让任务并发运行呢?如果我们想像串行分发队列一样按顺序运行它们怎么办?我们可以在操作队列中设置一个属性,该属性定义了队列中可以并发运行的任务数量。该属性名为maxConcurrentOperationCount,使用方式如下:
operationQueue.maxConcurrentOperationCount = 1
然而,如果我们将此行添加到我们之前的示例中,它将不会按预期工作。为了了解为什么,我们需要理解这个属性实际上定义了什么。如果我们查看苹果的OperationQueue类参考,这个属性的定义是可以同时执行的最大排队操作数量。
这告诉我们,这个属性定义了可以同时执行的操作数量(这是关键词)。我们添加所有任务的BlockOperation实例代表一个单一的操作;因此,直到第一个操作完成,队列中不会执行其他添加的BlockOperation,但操作内的各个任务可以并发执行。如果我们想按顺序运行任务,我们需要为每个任务创建一个单独的BlockOperation实例。
如果我们有几个任务想要并发执行,使用BlockOperation类的实例是好的,但它们将不会开始执行,直到我们将操作添加到操作队列中。让我们看看如何使用addOperationWithBlock()方法以更简单的方式将任务添加到操作队列。
使用操作队列的addOperation()方法
OperationQueue类有一个名为addOperation()的方法,这使得将代码块添加到队列变得容易。此方法自动将代码块包装在操作对象中,然后将该操作传递到队列。让我们看看如何使用此方法将任务添加到队列:
let operationQueue = OperationQueue()
let calculation = DoCalculations()
operationQueue.addOperation() {
calculation.performCalculation(10000000, tag: "Operation1")
}
operationQueue.addOperation() {
calculation.performCalculation(10000, tag: "Operation2")
}
operationQueue.addOperation() {
calculation.performCalculation(1000000, tag: "Operation3")
}
在本章前面提到的BlockOperation例子中,我们将想要执行的任务添加到了BlockOperation实例中。在这个例子中,我们将任务直接添加到操作队列中,每个任务代表一个完整的操作。一旦我们创建了操作队列的实例,我们就使用addOperation()方法将任务添加到队列。
此外,在BlockOperation例子中,各个任务在所有任务都添加完毕并且该操作被添加到队列之前不会执行。这个例子与 GCD 例子相似,其中任务在添加到操作队列后立即开始执行。
如果我们运行前面的代码,输出应该类似于以下内容:
time for Operation2: 0.0115870237350464
time for Operation3: 0.0790849924087524
time for Operation1: 0.520610988140106
你会注意到操作是并发执行的。通过这个例子,我们可以通过使用前面提到的maxConcurrentOperationCount属性来按顺序执行任务。让我们通过以下方式初始化OperationQueue实例来尝试这一点:
var operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 1
现在,如果我们运行这个例子,输出应该类似于以下内容:
time for Operation1: 0.418763995170593
time for Operation2: 0.000427007675170898
time for Operation3: 0.0441589951515198
在这个例子中,我们可以看到每个任务都在开始之前等待前一个任务完成。
使用addOperation()方法将任务添加到操作队列通常比使用BlockOperation方法更容易;然而,任务将在它们被添加到队列后立即开始执行。这通常是期望的行为,尽管在某些情况下我们可能不希望任务在所有操作都添加到队列后才开始执行,就像我们在BlockOperation例子中看到的那样。
现在,让我们看看我们如何可以继承Operation类来创建一个可以直接添加到操作队列的操作。
继承Operation类
前两个例子展示了如何将小块代码添加到我们的操作队列中。在这些例子中,我们调用DoCalculation类中的performCalculations方法来执行我们的任务。这些例子说明了两种非常好的方法来为已经编写的功能添加并发性,但如果我们希望在设计时为DoCalculation类本身设计并发性怎么办?为此,我们可以继承Operation类。
Operation抽象类提供了大量的基础设施。这使我们能够非常容易地创建一个子类而不需要做很多工作。我们需要提供一个初始化方法和一个main方法。当队列开始执行操作时,将调用main方法。
让我们看看如何将DoCalculation类作为Operation类的子类来实现;我们将这个新类称为MyOperation:
class MyOperation: Operation {
let iterations: Int
let tag: String
init(iterations: Int, tag: String) {
self.iterations = iterations
self.tag = tag
}
override func main() {
performCalculation()
}
func performCalculation() {
let start = CFAbsoluteTimeGetCurrent()
for _ in 0 ..< iterations {
self.doCalc()
}
let end = CFAbsoluteTimeGetCurrent()
print("time for \(tag):\(end-start)")
}
func doCalc() {
let x=100
let y = x*x
_ = y/x
}
}
我们首先定义MyOperation类是Operation类的子类。在类的实现中,我们定义了两个类常量,它们代表了performCalculations()方法使用的迭代次数和标签。记住,当操作队列开始执行操作时,它将不带参数调用main()方法;因此,我们需要传递给它的任何参数都必须通过初始化器传递。
在这个例子中,我们的初始化器接受两个参数,用于设置iterations和tag类常量。然后,操作队列将要调用的main()方法,简单地调用performCalculation()方法。
现在我们可以非常容易地将我们的MyOperation类实例添加到操作队列中,如下所示:
let operationQueue = NSOperationQueue()
operationQueue.addOperation( MyOperation(iterations: 10000000, tag:"Operation 1")
)
operationQueue.addOperation(
MyOperation(iterations: 10000, tag:"Operation 2")
)
operationQueue.addOperation(
MyOperation(iterations: 1000000, tag:"Operation 3")
)
如果我们运行此代码,我们将看到以下结果:
time for Operation 2: 0.00187397003173828
time for Operation 3: 0.104826986789703
time for Operation 1: 0.866684019565582
如我们之前所见,我们也可以通过将操作队列的maxConcurrentOperationCount属性设置为1来串行执行任务。
如果我们知道在编写代码之前需要并发执行某些功能,我建议像这个示例中那样对Operation类进行子类化,而不是使用之前的示例。这给我们提供了最干净的实现;然而,使用本节前面描述的BlockOperation类或addOperation()方法并没有什么不妥。
在考虑将并发添加到我们的应用程序之前,我们应该确保我们理解为什么要添加它,并问自己这是否必要。虽然并发可以通过将工作从主应用程序线程卸载到后台线程来使我们的应用程序更响应,但它也增加了代码的额外开销和复杂性。我甚至看到过许多在各种语言中运行得更好的应用程序,在移除一些并发代码之后。这是因为并发没有经过深思熟虑或计划。
摘要
在本章的开头,我们讨论了并发运行任务与并行运行任务的区别。我们还讨论了限制在特定设备上可以并行运行多少任务的硬件限制。对这些概念有良好的理解对于理解何时以及如何将并发添加到我们的项目中非常重要。
我们学习了 GCD 和操作队列,这两种实现并发的方式。虽然 GCD 并不局限于系统级应用,但在我们将其应用于我们的应用程序之前,我们应该考虑操作队列是否更容易且更适合我们的需求。一般来说,我们应该使用满足我们需求的最高级别的抽象。这通常会将我们引向使用操作队列;然而,实际上并没有什么阻止我们使用 GCD,而且它可能更适合我们的需求。
我们应该始终考虑是否需要在我们的应用程序中添加并发。在讨论应用程序的预期行为时思考并讨论并发是一个好主意。
在下一章中,我们将探讨一些高级主题以及在我们创建自己的自定义类型时需要考虑的事项。
第十七章:自定义值类型
在大多数传统的面向对象编程语言中,我们创建类(引用类型)作为我们对象的蓝图。在 Swift 中,与其它面向对象语言不同,结构体具有与类相似的大部分功能,然而,它们是值类型。苹果公司表示,我们应该优先选择值类型,如结构体,而不是引用类型,但引用类型和值类型之间有什么区别呢?
在本章中,你将探索以下主题:
-
值类型和引用类型之间的区别
-
为什么递归数据类型不能作为值类型创建
-
如何在你的自定义类型中实现写时复制
-
如何遵守
Equatable协议
正如我们在第八章 类、结构体和协议 中所看到的,我们有能力将我们的自定义类型创建为引用类型(或类)或值类型(或结构体)。让我们回顾一下这两种类型之间的区别,因为在确定我们自定义类型使用哪种类型时,理解这些区别非常重要。
值类型和引用类型
结构体是值类型;当我们在我们应用程序中传递结构体的实例时,我们传递的是结构体的副本,而不是原始结构体。类是引用类型;因此,当我们在我们应用程序中传递类的实例时,传递的是原始实例的引用。理解这种区别非常重要。我们在这里将提供一个非常高级的概述,但在第十八章 内存管理 中将提供更多详细信息。
当我们在应用程序中传递结构体时,我们传递的是结构体的副本,而不是原始结构体。这意味着函数会得到结构体自己的副本,它可以按需更改,而不会影响结构体的原始实例。
当我们在应用程序中传递类的实例时,我们传递的是类的原始实例的引用,因此,对类的实例所做的任何更改都会持续存在。
为了说明值类型和引用类型之间的区别,让我们考察一个现实世界的对象:一本书。如果我们有一个朋友想阅读 Mastering Swift 5.3, Sixth Edition,我们可以为他们购买自己的副本,或者分享我们的。
如果我们为我们朋友购买了他们自己的书,那么他们在书中做的任何笔记都会保留在他们自己的书中,而不会反映在我们的书中。这就是结构体和变量按值传递的工作方式。在函数内部对结构体或变量所做的任何更改都不会反映到结构体或变量的原始实例上。
如果我们分享我们的书,那么当书归还给我们时,书中所做的任何笔记都会保留在书中。这就是按引用传递的工作方式。对类的实例所做的任何更改在函数退出时都会保留。
当我们传递一个值类型的实例时,我们实际上是在传递该实例的一个副本。你可能想知道当大值类型从一个代码部分传递到另一个代码部分时,其性能如何。对于可能变得非常大的结构,我们可以使用写时复制(copy-on-write)。
前一段落的解释相当直接;然而,这是一个我们必须理解的重要概念。在本节中,我们将检查值类型和引用类型之间的差异,以便我们知道何时使用每种类型。
让我们从创建两个类型开始;一个是结构(或值类型),另一个是类(或引用类型)。我们将在这部分使用这些类型来演示值类型和引用类型之间的差异。我们将要检查的第一个类型名为MyValueType。我们将使用结构来实现MyValueType,这意味着它是一个值类型,正如其名称所暗示的:
struct MyValueType {
var name: String
var assignment: String
var grade: Int
}
在MyValueType中,我们定义了三个属性。其中两个属性是String类型(name和assignment),另一个是Integer类型(grade)。现在,让我们看看我们如何将这个实现为一个类:
class MyReferenceType {
var name: String
var assignment: String
var grade: Int
init(name: String, assignment: String, grade: Int) {
self.name = name
self.assignment = assignment
self.grade = grade
}
}
MyReferenceType类型定义了与MyValueType类型相同的三个属性,然而,我们需要在MyReferenceType类型中定义一个初始化器,而在MyValueType类型中我们不需要定义。这是因为结构为我们提供了一个默认初始化器,如果未提供默认初始化器,它将初始化所有需要初始化的属性。
让我们看看我们如何使用这些类型。以下代码显示了如何创建这些类型的实例:
var ref = MyReferenceType(name: "Jon", assignment: "Math Test 1", grade: 90)
var val = MyValueType(name: "Jon", assignment: "Math Test 1", grade: 90)
如此代码所示,结构的实例与类的实例的创建方式完全相同。能够使用相同的格式来创建结构和类的实例是有好处的,因为它使我们的生活更简单;然而,我们确实需要记住,值类型的操作方式与引用类型不同。让我们来探讨这个问题;我们需要做的第一件事是创建两个函数,这两个函数将改变两种类型实例的成绩:
func extraCreditReferenceType(ref: MyReferenceType, extraCredit: Int) {
ref.grade += extraCredit
}
func extraCreditValueType(val: MyValueType, extraCredit: Int) {
val.grade += extraCredit
}
这些函数中的每一个都接受我们类型的一个实例和一个额外的学分。在函数内部,我们将额外的学分添加到成绩中。如果我们尝试使用此代码,我们将在extraCreditValueType()函数中收到一个错误,告诉我们可变操作的左侧不可变。这是因为值类型参数默认是不可变的,因为函数接收的是参数的不可变副本。
使用这样的值类型可以保护我们免受意外更改实例的影响;这是因为实例的范围限定在创建它们的函数或类型中。值类型还可以保护我们免受对同一实例的多个引用。因此,它们默认是线程(并发)安全的,因为每个线程都将有自己的值类型版本。如果我们绝对需要在范围之外更改值类型的实例,我们可以使用一个inout参数。
我们通过在参数定义的开始处放置inout关键字来定义一个inout参数。一个inout参数有一个值被传递到函数中。然后这个值被函数修改,并从函数中传回以替换原始值。
让我们探索如何使用inout参数。我们将首先创建一个函数,该函数旨在从一个数据存储中检索一个作业的成绩。然而,为了简化示例,我们将简单地生成一个随机分数。以下代码演示了我们可以如何编写这个函数。
让我们看看如何使用带有inout关键字的值类型来创建一个正确工作的前一个示例版本。我们需要做的第一件事是修改getGradesForAssignment()函数,使其能够使用它可以修改的MyValueType实例:
func getGradeForAssignment(assignment: inout MyValueType) {
// Code to get grade from DB
// Random code here to illustrate issue
let num = Int.random(in: 80..<100)
assignment.grade = num
print("Grade for \(assignment.name) is \(num)")
}
此函数旨在检索在MyValueType实例中定义的作业成绩,并将其传递到函数中。一旦检索到成绩,我们将使用它来设置MyValueType实例的 grade 属性。我们还将打印成绩到控制台,以便我们可以看到它是多少。现在让我们探索如何使用此函数:
var mathGrades = [MyValueType]()
var students = ["Jon", "Kailey", "Kara"]
var mathAssignment = MyValueType(name: "", assignment: "Math Assignment", grade: 0)
for student in students {
mathAssignment.name = student
getGradeForAssignment(assignment: &mathAssignment)
mathGrades.append(mathAssignment)
}
for assignment in mathGrades {
print("\(assignment.name): grade \(assignment.grade)")
}
在前面的代码中,我们创建了一个mathGrades数组,用于存储我们的作业成绩,以及一个students数组,包含我们希望检索成绩的学生姓名。然后我们创建了一个包含作业名称的MyValueType结构实例。我们将使用此实例从getGradeForAssignment()函数请求成绩。注意,当我们传递mathAssignment实例时,我们在实例名称前加上了&符号。这让我们知道我们传递的是原始实例的引用,而不是副本。现在一切都已经定义好了,我们将遍历学生列表以检索成绩。此代码的输出将类似于以下片段:
Grade for Jon is 87
Grade for Kailey is 90
Grade for Kara is 83
Jon: grade 87
Kailey: grade 90
Kara: grade 83
此代码的输出是我们预期的结果,其中mathGrades数组中的每个实例都代表正确的成绩。此代码正确工作的原因是我们在从mathAssignment实例传递到getGradeForAssignment()函数时传递了一个引用,而不是一个副本。
有些事情我们不能用值类型做,但可以用引用(或类)类型做。我们将首先查看递归数据类型。
用于引用类型的递归数据类型
递归数据类型是一种包含与类型相同的值的类型的类型的类型。当我们想要定义动态数据结构,如列表和树时,会使用递归数据类型。这些动态数据结构的大小可以根据我们的运行时需求增长或缩小。
链表是我们可以使用递归数据类型实现的动态数据结构的完美示例。链表是一组相互链接的节点,在其最简单的形式中,每个节点都维护对列表中下一个节点的链接。图 17.1展示了一个非常基本的链表是如何工作的:

图 17.1:基本链表
列表中的每个节点都包含一个值或数据,同时也包含指向列表中下一个节点的链接。如果列表中的某个节点失去了对下一个节点的引用,那么列表的其余部分将会丢失,因为每个节点只知道下一个节点。一些链表维护对前一个节点和后一个节点的链接,这样我们就可以在列表中前后移动。
以下代码展示了我们如何使用引用类型创建链表:
class LinkedListReferenceType {
var value: String
var next: LinkedListReferenceType?
init(value: String) {
self.value = value
}
}
在LinkedListReferenceType类中,我们有两个属性。第一个属性名为value,它包含此实例的数据。第二个属性名为next,它指向链表中的下一个项目。如果next属性为nil,则此实例将是列表中的最后一个节点。如果我们尝试将此链表实现为值类型,代码将类似于以下内容:
struct LinkedListValueType {
var value: String
var next: LinkedListValueType?
}
当我们将此代码添加到游乐场时,我们收到以下错误:Recursive value type LinkedListValueType is not allowed。这告诉我们 Swift 不允许递归值类型。然而,我们可以将它们实现为引用类型,这是我们之前讨论过的。
如果你仔细想想,递归值类型因为值类型的功能而是一个非常糟糕的想法。让我们花点时间来探讨这一点,因为这会真正强调值类型和引用类型之间的差异。这也有助于你理解为什么我们需要引用类型。
假设我们能够无错误地创建LinkedListValueType结构。现在让我们为我们的列表创建三个节点,如下面的代码所示:
var one = LinkedListValueType(value: "One",next: nil)
var two = LinkedListValueType(value: "Two",next: nil)
var three = LinkedListValueType(value: "Three",next: nil)
现在我们将使用以下代码将这些节点链接在一起:
one.next = two
two.next = three
你看到这个问题了吗?如果没有,想想值类型是如何传递的。在第一行one.next = two中,我们实际上并没有将next属性设置为原始的two实例;实际上,我们将其设置为two实例的一个副本,因为我们通过将LinkedListValueType实现为值类型,我们传递的是值而不是实际的实例。这意味着在下一行two.next = three中,我们将原始的two实例的下一个属性设置为three实例。
然而,这种变化并没有反映到为one实例的下一个属性所制作的副本中。听起来有点令人困惑?让我们通过查看一个图表来稍微澄清一下,如果能够运行这段代码,图表将显示我们三个LinkedListValueType实例的状态:

图 17.2:链表结构示例
如从图中所示,one实例的下一个属性指向一个two实例的副本,其下一个属性仍然是nil。然而,原始的two实例的下一个属性指向three实例。这意味着,如果我们从one实例开始遍历列表,我们将无法到达three实例,因为two实例的副本仍然有一个next属性是nil。
我们只能使用引用(或类)类型做的另一件事是类继承。
引用类型的继承
在面向对象编程中,继承指的是一个类(称为子或子类)从另一个类(称为超或父类)派生而来。子类将继承方法、属性和其他特征。通过继承,我们还可以创建一个类层次结构,其中可以有多个继承层。
让我们看看如何使用 Swift 中的类来创建一个类层次结构。我们将从创建一个名为Animal的基本类开始:
class Animal {
var numberOfLegs = 0
func sleeps() {
print("zzzzz")
}
func walking() {
print("Walking on \(numberOfLegs) legs")
}
func speaking() {
print("No sound")
}
}
在Animal类中,我们定义了一个属性(numberOfLegs)和三个方法(sleeps()、walking()和speaking())。现在,任何是Animal类子类的类也将拥有这些属性和方法。让我们通过创建两个是Animal类子类的类来检查这是如何工作的。这两个类将被命名为Biped(两足动物)和Quadruped(四足动物):
class Biped: Animal {
override init() {
super.init() numberOfLegs =2
}
}
class Quadruped: Animal {
override init() {
super.init() numberOfLegs = 4
}
}
由于这两个类继承自Animal类中的所有属性和方法,我们只需要创建一个初始化器,将numberOfLegs属性设置为正确的腿数。现在,让我们通过创建一个Dog类来添加另一层继承,这个类将是Quadruped类的子类:
class Dog: Quadruped {
override func speaking() {
print("Barking")
}
}
在Dog类中,我们继承自Quadruped类,而Quadruped类又继承自Animal类。因此,Dog类将具有Animal和Quadruped类中所有属性、方法和特征。如果Quadruped类覆盖了Animal类中的任何内容,那么Dog类将继承来自Quadruped类的版本。
我们可以用这种方式创建非常复杂的类层次结构;例如,图 17.3扩展了我们刚刚创建的类层次结构,添加了几个其他动物类:

图 17.3:动物类层次结构
类层次结构可能会变得非常复杂。然而,正如你所看到的,它们可以消除大量的重复代码,因为我们的子类从它们的超类中继承了方法、属性和其他特征。因此,我们不需要在所有子类中重新创建它们。
类层次结构的一个主要缺点是复杂性。当我们有一个复杂的层次结构(如前图所示)时,很容易做出改变而不知道它将如何影响所有子类。例如,如果我们考虑Dog和Cat类,我们可能想在Quadruped类中添加一个furColor属性,以便我们可以设置动物的毛色。然而,马没有毛,它们有鬃毛。因此,在我们对层次结构中的任何类做出任何更改之前,我们需要了解它将如何影响层次结构中的所有子类。
在 Swift 中,最好避免使用复杂的类层次结构(如本例所示),而应使用面向协议的设计,除非当然有特定的理由使用它们。现在我们已经很好地理解了引用类型和值类型,让我们来探索动态分派。
动态分派
在上一节中,我们学习了如何使用类继承来继承和覆盖超类中定义的功能。你可能想知道何时以及如何选择合适的实现。选择调用哪个实现的过程是在运行时进行的,这被称为动态分派。
从上一段中理解的一个关键点是,实现的选择是在运行时进行的。这意味着使用类继承会带来一定量的运行时开销,正如在引用类型继承部分所示。对于大多数应用程序来说,这个开销不是问题;然而,对于性能敏感的应用程序,如游戏,这个开销可能是昂贵的。
我们可以减少与动态分派相关的开销的一种方法是在类、方法或函数上使用final关键字。final关键字对类、方法或函数施加限制,表明它不能被覆盖,在方法或函数的情况下,或者在类的情况下,子类。
要使用final关键字,您将其放在类、方法或函数声明之前,如下面的代码所示:
final func myFunc() {}
final var myProperty = 0
final class MyClass {}
在引用类型的继承部分,我们定义了一个以Animal超类开始的class层次结构。如果我们想限制子类覆盖walking()方法和numberOfLegs属性,我们可以修改Animal的实现,如下一个示例所示:
class Animal {
final var numberOfLegs = 0
func sleeps() {
print("zzzzz")
}
final func walking() {
print("Walking on \(numberOfLegs) legs")
}
func speaking() {
print("No sound")
}
}
这个更改允许应用程序在运行时直接调用walking()方法,而不是间接调用,这会给应用程序带来轻微的性能提升。如果您必须使用类层次结构,尽可能使用final关键字是一种好的做法;然而,使用以协议为导向的设计,结合值类型,以避免这种情况会更好。
现在,让我们看看可以帮助我们自定义值类型性能的东西:写时复制。
写时复制
通常情况下,当我们传递一个值类型的实例,例如一个结构体时,会创建该实例的新副本。这意味着如果我们有一个包含 100,000 个元素的大的数据结构,那么每次我们传递该实例时,我们都必须复制所有 100,000 个元素。这可能会对我们的应用程序的性能产生不利影响,尤其是如果我们将实例传递给许多函数时。
为了解决这个问题,Apple 在 Swift 标准库中为所有数据结构(如Array、Dictionary和Set)实现了写时复制功能。使用写时复制,Swift 不会在修改数据结构之前再次复制该数据结构。因此,如果我们将包含 50,000 个元素的数组传递到代码的另一个部分,并且该代码不对数组进行任何修改,我们将避免复制所有元素的运行时开销。
这是一个非常有用的功能,可以大大提高我们应用程序的性能。然而,我们的自定义值类型默认情况下并不会自动获得这个功能。在本节中,我们将探讨如何使用引用类型和值类型一起实现我们自定义值类型的写时复制功能。为此,我们将创建一个非常基本的队列类型,以展示您如何将写时复制功能添加到您的自定义值类型中。
我们将首先创建一个名为BackendQueue的后端存储类型,并将其实现为一个引用类型。以下代码为我们的BackendQueue类型提供了队列类型的基本功能:
fileprivate class BackendQueue<T> {
private var items = [T]()
public func addItem(item: T) {
items.append(item)
}
public func getItem() -> T? {
if items.count > 0 {
return items.remove(at: 0)
} else {
return nil
}
}
public func count() -> Int {
return items.count
}
}
BackendQueue类型是一个泛型类型,它使用数组来存储数据。此类型包含三个方法,使我们能够向队列中添加项目、从队列中检索项目以及返回队列中的项目数量。我们使用fileprivate访问级别来防止在定义源文件之外使用此类型,因为它应该只用于实现我们主要队列类型的写时复制功能。
我们现在需要向 BackendQueue 类型添加一些额外的项目,以便我们可以使用它来实现主队列类型的写时复制特性。我们将添加的第一件事是公共默认初始化器和私有初始化器,后者可以用来创建 BackendQueue 类型的新的实例;以下代码展示了这两个初始化器:
public init() {}
private init(_ items: [T]) {
self.items = items
}
公共初始化器将用于创建一个不包含任何项目的 BackendQueue 类型实例。私有初始化器将用于内部创建一个包含当前队列中任何项目的自身副本。现在我们需要创建一个方法,当需要时将使用私有初始化器来创建自身的副本:
public func copy() -> BackendQueue<T> {
return BackendQueue<T>(items)
}
很容易将私有初始化器公开,然后让主队列类型调用该初始化器来创建副本;然而,将创建新副本所需的逻辑保持在类型本身中是一种良好的实践。这样做的原因是,如果您需要更改类型,可能会影响类型的复制方式。相反,您需要更改类型的逻辑被嵌入在类型本身中,并且易于查找。此外,如果您将 BackendQueue 类型用作多个类型的后端存储,那么如果需要更改,您只需在一个地方更改复制逻辑即可。
这里是 BackendQueue 类型的最终代码:
fileprivate class BackendQueue<T> {
private var items = [T]()
public init() {}
private init(_ items: [T]) {
self.items = items
}
public func addItem(item: T) {
items.append(item)
}
public func getItem() -> T? {
if items.count > 0 {
return items.remove(at: 0)
} else {
return nil
}
}
public func count() -> Int {
return items.count
}
public func copy() -> BackendQueue<T> {
return BackendQueue<T>(items)
}
}
现在让我们创建我们的 Queue 类型,它将使用 BackendQueue 类型来实现写时复制的特性。以下代码为我们的 Queue 类型添加了基本队列功能:
struct Queue {
private var internalQueue = BackendQueue<Int>()
public mutating func addItem(item: Int) {
internalQueue.addItem(item: item)
}
public mutating func getItem() -> Int? {
return internalQueue.getItem()
}
public func count() -> Int {
return internalQueue.count()
}
}
Queue 类型被实现为一个值类型。该类型有一个 BackendQueue 类型的私有属性,用于存储数据。该类型包含三个方法,用于向队列中添加项目、从队列中检索项目以及返回队列中的项目数量。现在让我们探讨如何将写时复制特性添加到 Queue 类型中。
Swift 有一个名为 isKnownUniquelyReferenced() 的全局函数。如果只有一个引用指向引用类型的实例,该函数将返回 true;如果有多个引用,则返回 false。
我们将首先添加一个函数来检查是否对 internalQueue 实例有唯一的引用。这将是一个名为 checkUniquelyReferencedInternalQueue 的私有函数。以下代码展示了我们如何实现这个方法:
mutating private func checkUniquelyReferencedInternalQueue() {
if !isKnownUniquelyReferenced(&internalQueue) {
internalQueue = internalQueue.copy()
print("Making a copy of internalQueue")
} else {
print("Not making a copy of internalQueue")
}
}
在这个方法中,我们检查是否存在对 internalQueue 实例的多个引用。如果有多个引用,那么我们知道我们有多份 Queue 实例的副本,因此我们可以创建一个新的副本。
Queue类型本身是一个值类型;因此,当我们将Queue类型的实例传递到我们的代码中时,接收该实例的代码将接收该实例的新副本。《BackendQueue》类型,Queue类型正在使用,是一个引用类型。因此,当创建Queue实例的副本时,新副本将接收对原始Queue的BackendQueue实例的引用,而不是一个新副本。这意味着Queue类型的每个实例都引用相同的internalQueue实例。以下代码作为示例;queue1和queue2都引用相同的internalQueue实例:
var queue1 = Queue()
var queue2 = queue1
在Queue类型中,我们知道addItem()和getItem()方法都会改变internalQueue实例。因此,在我们进行这些更改之前,我们将想要调用checkUniquelyReferencedInternalQueue()方法来创建internalQueue实例的新副本。这两个方法现在将具有以下代码:
public mutating func addItem(item: Int) {
checkUniquelyReferencedInternalQueue()
internalQueue.addItem(item: item)
}
public mutating func getItem() -> Int? {
checkUniquelyReferencedInternalQueue()
return internalQueue.getItem()
}
使用此代码,当调用addItem()或getItem()方法(这将更改internalQueue实例中的数据)时,我们使用checkUniquelyReferencedInternalQueue()方法来创建数据结构的新实例。
让我们在Queue类型中添加一个额外的方法,这样我们就可以看到是否有对internalQueue实例的唯一引用。以下是该方法的代码:
mutating public func uniquelyReferenced() -> Bool {
return isKnownUniquelyReferenced(&internalQueue)
}
这是Queue类型的完整代码列表:
struct Queue {
private var internalQueue = BackendQueue<Int>()
mutating private func checkUniquelyReferencedInternalQueue() {
if !isKnownUniquelyReferenced(&internalQueue) {
print("Making a copy of internalQueue")
internalQueue = internalQueue.copy()
} else {
print("Not making a copy of internalQueue")
}
}
public mutating func addItem(item: Int) {
checkUniquelyReferencedInternalQueue()
internalQueue.addItem(item: item)
}
public mutating func getItem() -> Int? {
checkUniquelyReferencedInternalQueue();
return internalQueue.getItem()
}
public func count() -> Int {
return internalQueue.count()
}
mutating public func uniquelyReferenced() -> Bool {
return isKnownUniquelyReferenced(&internalQueue)
}
}
现在我们来检查Queue类型的复制写入功能是如何工作的。我们将从创建Queue类型的新实例开始,向队列中添加一个项目,然后检查我们是否对internalQueue实例有唯一的引用。以下代码演示了如何做到这一点:
var queue3 = Queue()
queue3.addItem(item: 1)
print(queue3.uniquelyReferenced())
当我们将项目添加到队列中时,以下消息将被打印到控制台。这告诉我们,在checkUniquelyReferencedInternalQueue()方法中,确定只有一个引用指向internalQueue实例:
Not making a copy of internalQueue
我们可以通过将uniquelyReference()方法的结果打印到控制台来验证这一点。现在让我们通过将其传递给一个新变量来创建queue3实例的一个副本,如下所示:
var queue4 = queue3
现在我们来检查我们是否对queue3或queue4实例的internalQueue实例有唯一的引用。以下代码将执行此操作:
print(queue3.uniquelyReferenced())
print(queue4.uniquelyReferenced())
这段代码将在控制台打印出两条false消息,告诉我们这两个实例都没有对它们的internalQueue实例具有唯一的引用。现在让我们向这两个队列中的任何一个添加一个项目。以下代码将向queue3实例添加另一个项目:
queue3.addItem(item: 2)
当我们将项目添加到队列中时,我们将看到以下消息打印到控制台:
Making a copy of internalQueue
这条消息告诉我们,当我们向队列中添加新项目时,会创建internalQueue实例的新副本。为了验证这一点,我们可以再次将uniquelyReferenced()方法的结果打印到控制台。如果你进行检查,这次你将看到控制台打印出两个true消息,而不是两个false方法。现在我们可以向队列中添加额外的项目,我们会看到我们没有创建internalQueue实例的新副本,因为Queue类型的每个实例现在都有自己的副本。
如果你计划创建可能包含大量项目的自定义数据结构,建议你使用此处描述的写时复制(copy-on-write)功能来实现它。
如果你正在比较自定义类型,也建议你在这些自定义类型中实现Equatable协议。这将使你能够使用等于(==)和不等(!=)运算符来比较类型的两个实例。
实现 Equatable 协议
在本节中,我们将演示如何使用扩展来符合Equatable协议。当一个类型符合Equatable协议时,我们可以使用等于(==)运算符来比较相等性,以及不等(!=)运算符来比较不等性。
如果你将比较自定义类型的实例,那么让该类型符合Equatable协议是一个好主意,因为它使得比较实例变得非常容易。
让我们首先创建我们将要比较的类型。我们将把这个类型命名为Place:
struct Place {
let id: String
let latitude: Double
let longitude: Double
}
在Place类型中,我们有三个属性,它们代表地点的 ID 以及其位置的纬度和经度坐标。如果有两个Place类型的实例具有相同的 ID 和坐标,那么它们将被视为同一个地点。
要实现Equatable协议,我们可以创建一个全局函数;然而,这并不是面向协议编程的推荐解决方案。我们也可以将静态函数添加到Place类型本身,但有时将符合协议所需的功能从实现中提取出来会更好。以下代码将使Place类型符合Equatable协议:
extension Place: Equatable {
static func ==(lhs: Place, rhs: Place) -> Bool {
return lhs.id == rhs.id &&
lhs.latitude == rhs.latitude &&
lhs.longitude == rhs.longitude
}
}
我们现在可以如下比较Place类型的实例:
var placeOne = Place(id: "Fenway Park", latitude: 42.3467, longitude: -71.0972)
var placeTwo = Place(id: "Wrigley Field", latitude: 41.9484, longitude: -87.6553)
print(placeOne == placeTwo)
这将打印false,因为芬威公园和惠利球场是两个不同的棒球场。
你可能想知道为什么我们说将符合协议所需的功能从实现本身中提取出来可能更好。好吧,想想你过去创建的一些大型类型。就我个人而言,我见过有几百行代码的类型,并且符合了许多协议。通过将符合协议所需的代码从类型的实现中提取出来,并将其放入它自己的扩展中,我们使代码在未来的可读性和可维护性方面变得更加容易,因为实现代码被隔离在其自己的扩展中。
从 Swift 5.2 开始,如果所有属性都符合 Equatable 协议,并且你想比较所有属性,就像之前的例子中那样,实际上我们不需要实现 == 函数。我们真正需要做的只是按照以下示例实现代码:
struct Place: Equatable {
let id: String
let latitude: Double
let longitude: Double
}
在之前的代码中,Swift 会自动添加所有必要的代码,使 Place 结构体符合 Equatable 协议;然而,了解如何自己实现这一点是有好处的,尤其是当所有属性都不符合 Equatable 协议,或者我们不想比较所有属性时。
摘要
在本章中,我们探讨了值类型和引用类型之间的区别。我们还探讨了如何使用自定义类型实现 copy-on-write 和 Equatable 协议。我们可以为变得非常大的值类型实现 copy-on-write 功能。当我们需要比较两个实例时,我们可以为任何自定义类型实现 Equatable 协议,包括引用类型。
虽然 Swift 会帮我们管理内存,但了解这种内存管理的工作原理仍然是一个好主意,这样我们可以避免可能导致其失败的陷阱。在下一章中,我们将探讨 Swift 中内存管理的工作方式,并演示它可能失败的情况。
第十八章:内存管理
多年来,我主要使用的编程语言是 C 和基于 C 的面向对象语言。这些语言要求对内存管理有很好的掌握,并知道何时释放内存。幸运的是,现代语言如 Swift 为我们处理内存管理。然而,了解这种内存管理的工作原理是个好主意,这样我们可以避免导致内存管理失败的陷阱。
在本章中,我们将学习:
-
ARC 的工作原理
-
强引用循环是什么
-
如何使用弱引用和无主引用来防止强引用循环
如我们在第十七章中看到的,自定义值类型,结构体是值类型,类是引用类型。这意味着当我们在我们应用程序中传递一个结构体的实例时,例如一个方法的参数,我们在内存中创建了一个新的结构体实例。这个结构体的新实例仅在结构体被创建的作用域内有效。一旦结构体超出作用域,结构体的新实例将被自动销毁,内存将被释放。这使得结构体的内存管理非常简单且无痛苦。
相反,类是引用类型。这意味着我们只为类的实例分配一次内存,即它最初被创建时。当我们在我们应用程序中传递类的实例时,无论是作为函数参数还是通过将其分配给变量,我们实际上是在传递一个指向内存中实例存储位置的引用。由于类的实例可能在多个作用域中被引用(与结构体不同),它不能被自动销毁,当它超出作用域时不会释放内存,因为它可能在另一个作用域中被引用。因此,Swift 需要某种形式的内存管理来跟踪和释放当类不再需要时由类的实例使用的内存。Swift 使用自动引用计数(ARC)来跟踪和管理内存使用。
在 ARC 中,对于 Swift 来说,大部分的内存管理工作很简单。ARC 会自动跟踪类的实例引用,当一个实例不再需要时(当没有引用指向它时),ARC 会自动销毁该实例并释放内存。有一些情况下,ARC 需要关于关系的额外信息来正确管理内存。在我们查看 ARC 需要帮助的情况之前,让我们看看 ARC 本身是如何工作的。
ARC 的工作原理
每当我们创建一个新类的实例时,ARC会分配存储该实例所需的内存。这确保了有足够的内存来存储与该类实例相关的信息,并且锁定内存,防止任何内容覆盖它。
当类的实例不再需要时,ARC 将释放为实例分配的内存,以便它可以用于其他目的。这确保了我们不会占用不再需要的内存。当内存被保留给不再需要的实例时,这被称为内存泄漏。
如果 ARC 释放了一个仍然需要的类的实例的内存,那么将无法从内存中检索到类信息。如果我们尝试在内存释放后访问类的实例,应用程序可能会崩溃或数据可能会损坏。为了确保不会释放仍然需要的类的实例的内存,ARC 会计算实例被引用的次数;也就是说,有多少活跃的属性、变量或常量指向类的实例。一旦类的实例的引用计数等于零(即没有任何东西引用该实例),内存就会被标记为释放。
所有的前一个示例都在 playground 中运行正常;然而,以下示例将不会运行。当我们在一个 playground 中运行示例代码时,ARC 不会释放我们创建的对象;这是设计上的,这样我们就可以看到应用程序的运行情况以及每个步骤中对象的状态。因此,我们需要将这些样本作为一个 iOS 或 macOS 项目来运行。让我们看看 ARC 是如何工作的一个例子。我们首先使用以下代码创建一个MyClass类:
class MyClass {
var name = ""
init(name: String) {
self.name = name
print("Initializing class with name \(self.name)")
}
deinit {
print("Releasing class with name \(self.name)")
}
}
在这个类中,我们有一个name属性,它将通过接受一个字符串值的初始化器来设置。这个类还有一个在类的实例被销毁并从内存中移除之前被调用的析构器。这个析构器会在控制台打印一条消息,让我们知道类的实例即将被移除。
现在,让我们看看展示 ARC 如何创建和销毁类实例的代码:
var class1ref1: MyClass? = MyClass(name: "One")
var class2ref1: MyClass? = MyClass(name: "Two")
var class2ref2: MyClass? = class2ref1
print("Setting class1ref1 to nil")
class1ref1 = nil
print("Setting class2ref1 to nil")
class2ref1 = nil
print("Setting class2ref2 to nil")
class2ref2 = nil
在这个例子中,我们首先创建了两个名为class1ref1(代表类 1 引用 1)和class2ref1(代表类 2 引用 1)的MyClass类的实例。然后我们为class2ref1创建了一个第二个引用,名为class2ref2。
现在,为了看到 ARC 是如何工作的,我们需要开始将引用设置为nil。我们首先将class1ref1设置为nil。由于只有一个引用指向class1ref1,析构器将被调用。一旦析构器完成其任务,在我们的例子中,它会在控制台打印一条消息,让我们知道类的实例已经被销毁,内存已经被释放。
然后,我们将class2ref1设置为nil,但是这个类有一个第二个引用(class2ref2),这阻止了 ARC 销毁实例,因此不会调用析构器。
最后,我们将class2ref2设置为nil,这允许 ARC 销毁这个MyClass类的实例。
如果我们运行这段代码,我们将看到以下输出,它说明了 ARC 是如何工作的:
Initializing class with name One
Initializing class with name Two
Setting class1ref1 to nil
Releasing class with name One
Setting class2ref1 to nil
Setting class2ref2 to nil
Releasing class with name Two
从例子中看起来,ARC 处理内存管理得很好。然而,有可能编写代码来阻止 ARC 正常工作。
强引用循环
强引用循环,或称为强保留循环,是指两个类的实例相互持有强引用,阻止 ARC 释放任何一个实例。再次强调,我们无法在这个例子中使用 playground,因此我们需要创建一个 Xcode 项目。在这个项目中,我们首先创建两个名为MyClass1_Strong和MyClass2_Strong的类,代码如下:
class MyClass1_Strong {
var name = ""
var class2: MyClass2_Strong?
init(name: String) {
self.name = name
print("Initializing class1_Strong with name \(self.name)")
}
deinit {
print("Releasing class1_Strong with name \(self.name)")
}
}
class MyClass2_Strong {
var name = ""
var class1: MyClass1_Strong?
init(name: String) {
self.name = name
print("Initializing class1_Strong with name \(self.name)")
}
deinit {
print("Releasing class1_Strong with name \(self.name)")
}
}
从代码中我们可以看出,MyClass1_Strong包含了一个MyClass2_Strong的实例,因此MyClass2_Strong的实例不能被释放,直到MyClass1_Strong被销毁。我们还可以从代码中看到,MyClass2_Strong包含了一个MyClass1_Strong的实例,因此,MyClass1_Strong的实例不能被释放,直到MyClass2_Strong被销毁。这创建了一个依赖循环,其中两个实例都不能被销毁,直到另一个被销毁。
让我们通过运行以下代码来看看它是如何工作的:
var class1: MyClass1_Strong? = MyClass1_Strong(name: "Class1_Strong")
var class2: MyClass2_Strong? = MyClass2_Strong(name: "Class2_Strong")
class1?.class2 = class2
class2?.class1 = class1
print("Setting classes to nil")
class2 = nil
class1 = nil
在这个例子中,我们创建了MyClass1_Strong和MyClass2_Strong两个类的实例。然后我们将class1实例的class2属性设置为MyClass2_Strong实例。我们还设置了class2实例的class1属性为MyClass1_Strong实例。这意味着MyClass1_Strong实例不能被销毁,直到MyClass2_Strong实例被销毁。这也意味着每个实例的引用计数器永远不会达到零,因此 ARC 不能销毁这些实例,在这种情况下会产生以下输出:
Initializing class1_Strong with name Class1_Strong
Initializing class1_Strong with name Class2_Strong
Setting classes to nil
这种无法销毁实例的能力会导致内存泄漏,应用程序继续使用内存而不正确地释放它。这可能导致应用程序最终崩溃。
要解决强引用循环,我们需要防止其中一个类保持对另一个类实例的强引用,从而允许 ARC 销毁它们。Swift 提供了两种方法,允许我们定义属性为弱引用或无所有者引用。
弱引用和无所有者引用之间的区别是,弱引用所引用的实例可以是nil,而无所有者引用所引用的实例不能是nil。这意味着当我们使用弱引用时,属性必须是可选属性。让我们看看我们如何使用无所有者和弱引用来解决强引用循环。让我们首先看看无所有者引用。
无所有者引用
我们首先创建另外两个类,MyClass1_Unowned和MyClass2_Unowned:
class MyClass1_Unowned {
var name = ""
unowned let class2: MyClass2_Unowned
init(name: String, class2: MyClass2_Unowned) {
self.name = name
self.class2 = class2
print("Initializing class1_Unowned with name \(self.name)")
}
deinit {
print("Releasing class1_Unowned with name \(self.name)")
}
}
class MyClass2_Unowned {
var name = ""
var class1: MyClass1_Unowned?
init(name: String) {
self.name = name
print("Initializing class2_Unowned with name \(self.name)")
}
deinit {
print("Releasing class2_Unowned with name \(self.name)")
}
}
MyClass1_Unowned 类在先前的示例中看起来与 MyClass1_Strong 和 MyClass2_Strong 类非常相似。这里的区别在于 MyClass1_Unowned 类——我们将 class2 属性设置为 unowned,这意味着它不能为 nil,并且它不会保持对它所引用的实例的强引用。由于 class2 属性不能为 nil,因此我们还需要在类初始化时将其设置。
让我们看看如何使用以下代码初始化和销毁这些类的实例:
let class2 = MyClass2_Unowned(name: "Class2_Unowned")
let class1: MyClass1_Unowned? = MyClass1_Unowned(name: "class1_Unowned",class2: class2)
class2.class1 = class1
print("Classes going out of scope")
在前面的代码中,我们创建了一个 MyClass_Unowned 类的实例,然后使用该实例创建了一个 MyClass1_Unowned 类的实例。然后我们将 MyClass2 实例的 class1 属性设置为刚刚创建的 MyClass1_Unowned 实例。
这再次在两个类之间创建了依赖关系的引用循环,但这次 MyClass1_Unowned 实例并没有对 MyClass2_Unowned 实例保持强引用,允许 ARC 在不再需要时释放这两个实例。
如果我们运行此代码,我们将看到以下输出,显示 class1 和 class2 实例都已释放,并且内存已释放:
Initializing class2_Unowned with name Class2_Unowned
Initializing class1_Unowned with name class1_Unowned
Classes going out of scope
Releasing class2_Unowned with name Class2_Unowned
Releasing class1_Unowned with name class1_Unowned
如我们所见,两个实例都得到了适当的释放。现在让我们看看如何使用弱引用来防止强引用循环。
弱引用
我们再次从创建两个新类开始:
class MyClass1_Weak {
var name = ""
var class2: MyClass2_Weak?
init(name: String) {
self.name = name
print("Initializing class1_Weak with name \(self.name)")
}
deinit {
print("Releasing class1_Weak with name \(self.name)")
}
}
class MyClass2_Weak {
var name = ""
weak var class1: MyClass1_Weak?
init(name: String) {
self.name = name
print("Initializing class2_Weak with name \(self.name)")
}
deinit {
print("Releasing class2_Weak with name \(self.name)")
}
}
MyClass1_Weak 和 MyClass2_Weak 类与我们在先前的示例中创建的、展示了强引用循环如何工作的类非常相似。区别在于我们在 MyClass2_Weak 类中定义了 class1 属性为一个弱引用。
现在,让我们看看如何使用以下代码初始化和销毁这些类的实例:
let class1: MyClass1_Weak? = MyClass1_Weak(name: "Class1_Weak")
let class2: MyClass2_Weak? = MyClass2_Weak(name: "Class2_Weak")
class1?.class2 = class2
class2?.class1 = class1
print("Classes going out of scope")
在前面的代码中,我们创建了 MyClass1_Weak 和 MyClass2_Weak 类的实例,并将这些类的属性设置为指向另一个类的实例。再次,这创建了一个依赖关系的循环,但由于我们将 MyClass2_Weak 类的 class1 属性设置为弱引用,它不会创建强引用,允许释放这两个实例。
如果我们运行代码,我们将看到以下输出,显示 class1_Weak 和 class2_Weak 实例都已释放,并且内存已释放:
Initializing class1_Weak with name Class1_Weak
Initializing class2_Weak with name Class2_Weak
Classes going out of scope
Releasing class1_Weak with name Class1_Weak
Releasing class2_Weak with name Class2_Weak
另外值得一提的是,闭包的保留循环与强引用循环完全相同;闭包默认情况下实际上是一个强引用。我们会使用弱引用和非拥有引用来防止这种情况,正如本章所解释的那样,只需将持有类实例的变量更改为持有闭包实例即可。
建议您避免创建循环依赖,正如本节所示,但有时您可能需要它们。在这些情况下,请记住,ARC 需要一些帮助来释放它们。
摘要
在本章中,我们解释了自动引用计数(ARC)的工作原理,以便您了解应用程序中内存是如何管理的。我们展示了强引用循环是什么,并解释了它如何导致 ARC 失败。我们通过展示如何使用弱引用和非拥有引用来防止强引用循环来结束本章。
在下一章中,我们将探讨如何正确格式化我们的 Swift 代码以保持一致性和可读性。
第十九章:Swift 格式和风格指南
在我的开发经验中,每次我学习一门新的编程语言时,通常都会提到如何编写和格式化该语言的代码。在我的开发生涯早期(那是很久以前),这些推荐非常基础,主要是关于如何缩进代码,或者每行只有一个语句等。实际上,直到最近 10-12 年,我才开始看到不同编程语言的复杂和详细的格式和风格指南。如今,你很难找到一个拥有超过两三个开发者的开发机构,他们没有为每种使用的语言制定风格/格式指南。即使不创建自己的风格指南的公司,通常也会参考其他公司发布的某些标准指南,如 Google、Oracle 或 Microsoft。这些风格指南帮助团队编写一致且易于维护的代码。
在本章中,你将学习以下内容:
-
什么是风格指南
-
什么使一个好的风格指南
-
为什么使用风格指南很重要
-
如何创建一个示例风格指南
什么是编程风格指南?
编程风格非常个人化,每个开发者都有自己的首选风格。这些风格可以从一种语言到另一种语言,从一个人到另一个人,随着时间的推移而变化。编程风格的个人性质可能会使得当众多个人共同贡献代码时,难以保持代码库的一致性和可读性。
虽然大多数开发者可能都有自己的首选风格,但不同语言之间推荐或首选的风格可能不同。例如,在 C#中,当我们命名一个方法或函数时,建议我们使用 Pascal 大小写,这与驼峰式大小写类似,只是首字母大写。在大多数其他语言中,如 C、Objective-C 和 Java,也建议我们使用驼峰式大小写,首字母小写。
最佳的应用程序编写得易于维护,代码易于阅读。如果每个开发者都使用自己的编程风格,那么对于大型项目和拥有许多开发者的公司来说,要拥有易于维护和阅读的代码是很困难的。这就是为什么拥有多个开发者的公司和项目通常为每种使用的语言采用编程风格指南。
编程风格指南定义了一套规则和指南,开发者在编写项目或公司中特定语言的程序时应该遵循。这些风格指南在公司或项目之间可能差异很大,反映了公司或项目期望代码的编写方式。这些指南也可能随时间而变化。遵循这些风格指南以保持代码库的一致性是很重要的。
许多开发者不喜欢被告知他们应该如何编写代码的想法,并声称只要他们的代码能正确运行,他们的代码格式如何并不重要。这种哲学在编码团队中不起作用,原因和它在运动队中不起作用的原因相同。您认为如果一支篮球队的所有球员都认为他们可以按照自己的方式打球,并且当他们都按照自己的方式打球时球队会表现得更好,会发生什么?那支球队可能会输掉很多比赛。一支篮球队(或任何运动队)要想持续获胜,除非所有队员都一起合作。确保每个人都在一起合作并执行相同的比赛计划的责任在于教练,就像在开发项目的团队领导确保所有开发者都按照采用的风格指南编写代码一样。
API 设计指南
苹果已经发布了 Swift 的 API 设计指南。这定义了 API 应该如何设计,并且与语言风格指南不同。语言风格指南定义了特定语言的代码应该如何编写;API 设计指南定义了 API 应该如何设计。如果您正在创建将被其他 Swift 开发者使用的 API,您应该熟悉苹果的 API 设计指南,该指南可以在以下位置找到:swift.org/documentation/api-design-guidelines/。
您的风格指南
在本书中定义的风格指南只是一个指南。它反映了作者对如何编写 Swift 代码的看法,并旨在成为创建您自己的风格指南的一个良好起点。如果您真的喜欢这个指南并直接采用它,那太好了。如果您对其中某些部分不同意,并在您的指南中进行了修改,那也很好。
对您和您的团队来说,合适的风格是您和您的团队感到舒适的风格,它可能和本书中的指南不同。不要害怕根据需要调整您的风格指南。在本章的风格指南中,以及在大多数优秀的风格指南中,一个明显的特点是关于为什么每个项目被推荐或不推荐的解释非常少。风格指南应该提供足够的细节,以便读者了解每个项目的推荐和非推荐方法,但同时也应该小巧紧凑,以便易于阅读和快速阅读。如果一个开发者对为什么某个特定方法被推荐有疑问,他们应该将这个疑问提出给开发团队。考虑到这一点,让我们开始指南的学习。
不要在语句的末尾使用分号
与许多语言不同,Swift 不需要在语句的末尾使用分号。因此,我们不应该使用它们。让我们看看以下代码:
//Preferred Method
var name = "Jon"
print(name)
//Non-preferred Method
var name = "Jon";
print(name);
不要在条件语句中使用括号
与许多语言不同,条件语句周围不需要括号;因此,除非需要澄清,否则我们应该避免使用它们。让我们看看以下代码:
//Preferred Method
if speed == 300_000_000 {
print("Speed of light")
}
//Non-Preferred Method
if (speed == 300_000_000) {
print("Speed of light")
}
命名
我们应该始终使用描述性的驼峰命名法为自定义类型、方法、变量、常量等命名。让我们看看一些通用的命名规则。
自定义类型
自定义类型应该有一个描述性的名称,描述该类型的作用。名称应使用帕斯卡大小写。以下是根据我们的风格指南的适当名称和非适当名称的示例:
//Proper Naming Convention
BaseballTeam
LaptopComputer
//Non-Proper Naming Convention
baseballTeam //Starts with a lowercase letter
Laptop_Computer //Uses an underscore
函数和方法
函数名应该是描述性的,描述函数或方法。它们应该使用驼峰命名法。以下是一些适当名称和非适当名称的示例:
//Proper Naming Convention
getCityName
playSound
//Non-Proper Naming Convention
get_city_name //All lowercase and has an underscore
PlaySound //Begins with an uppercase letter
常量和变量
常量和变量应该有一个描述性的名称。它们应该以小写字母开头,并使用驼峰命名法。唯一的例外是当常量是全局的;在这种情况下,常量的名称应包含所有大写字母,单词之间用下划线分隔。我见过许多不赞成使用全大写名称的指南,但我就个人而言,我喜欢在全局作用域中的常量使用全大写名称,因为这样它们是全球作用域,而不是局部作用域。
以下是一些适当名称和非适当名称的示例:
//Proper Names
playerName
driveSize
//Non-Proper Names
PlayerName //Starts with uppercase letter
drive_size //Has underscore in name
缩进
在 Xcode 中,默认的缩进宽度定义为四个空格,制表符宽度也定义为四个空格。我们应该将其保留为默认设置。以下截图显示了 Xcode 中的缩进设置:

图 19.1:缩进
我们应在函数/方法之间添加额外的空白行。我们还应使用空白行来分隔函数或方法内的功能。也就是说,在函数或方法内使用过多的空白行可能表明我们应该将函数分解成多个函数。
注释
我们应该根据需要使用注释来解释我们的代码是如何和为什么被编写的。我们应在自定义类型和函数之前使用块注释。我们应使用双斜杠来注释单行代码。以下是如何编写注释的示例:
/**
This is a block comment that should be used to explain a class or function
**/
public class EmployeeClass {
// This is an inline comment with double slashes
var firstName = ""
var lastName = ""
/**
Use Block comments for functions
parameter paramName: use this tag for parameters
returns: explain what is returned
throws: Error thrown
**/
func getFullName() -> String {
return firstName + " " + lastName
}
}
当我们注释方法时,我们还应该使用文档标签,这些标签将在 Xcode 中生成文档,如前例所示。至少,如果适用,我们应该使用以下标签:
-
参数(Parameter): 这用于参数
-
返回(Returns): 这用于描述返回的内容
-
抛出(Throws): 这用于记录可能抛出的错误
使用self关键字
由于 Swift 在访问对象的属性或调用对象的方法时不需要我们使用self关键字,因此除非我们需要区分实例属性和局部变量,否则我们应该避免使用它。以下是一个你应该使用self关键字的示例:
public class EmployeeClass {
var firstName = ""
var lastName = ""
func setName(firstName: String, lastName: String) {
self.firstName = firstName
self.lastName = lastName
}
}
这里是一个不使用self关键字示例:
public class EmployeeClass {
var firstName = ""
var lastName = ""
func getFullName() -> String {
return self.firstName + " " + self.lastName
}
}
常量和变量
常量与变量的区别在于常量的值永远不会改变,而变量的值可能会改变。 wherever possible,我们应该定义常量而不是变量。
做这件事最简单的方法是将所有内容默认定义为常量,然后在代码中需要更改定义的地方将其更改为变量。在 Swift 中,如果你定义了一个变量但从未在代码中更改其值,你会收到一个警告。
可选类型
只有在绝对必要时才使用可选类型。如果没有绝对必要将 nil 值分配给变量,我们就不应该将其定义为可选。
使用可选绑定
我们应该避免强制解包可选,因为这很少是必要的。我们最好使用可选绑定或可选链而不是强制解包。
以下示例展示了定义myOptional变量为可选时的首选和非首选方法:
//Preferred Method Optional Binding
if let value = myOptional {
// code if myOptional is not nil
} else {
// code if myOptional is nil
}
//Non-Preferred Method
if myOptional != nil {
// code if myOptional is not nil
} else {
// code if myOptional is nil
}
如果我们需要解包多个可选,我们应该将它们包含在同一个if-let或guard语句中,而不是在单独的行上解包。然而,有时我们的业务逻辑可能需要我们以不同的方式处理 nil 值,这可能需要我们在单独的行上解包可选。
//Preferred Method Optional Binding
if let value1 = myOptional1, let value2 = myOptional2 {
// code if myOptional1 and myOptional2 is not nil
} else {
// code if myOptional1 and myOptional2 is nil
}
//Non-Preferred Method Optional Binding
if let value1 = myOptional1 {
if let value2 = myOptional2 {
// code if myOptional is not nil
} else {
// code if myOptional2 is nil
} else {
// code if myOptional1 is nil
}
使用可选链
当我们需要解包多层时,我们应该使用可选链而不是多个可选绑定语句。以下示例展示了首选和非首选方法:
//Preferred Method
if let color = jon.pet?.collar?.color {
print("The color of the collar is \(color)")
} else {
print("Cannot retrieve color")
}
//Non-Preferred Method
if let tmpPet = jon.pet, let tmpCollar = tmpPet.collar{
print("The color of the collar is \(tmpCollar.color)")
} else {
print("Cannot retrieve color")
}
使用类型推断
而不是定义变量类型,我们应该让 Swift 推断类型。我们定义变量或常量类型的唯一情况是我们没有在定义时给它赋值。让我们看看以下代码:
//Preferred method
var myVar = "String Type" //Infers a String type
var myNum = 2.25 //Infers a Double type
//Non-Preferred method
var myVar: String = "String Type"
var myNum: Double = 2.25
使用集合的简写声明
当声明原生 Swift 集合类型时,我们应该使用简写语法,并且除非绝对必要,否则我们应该初始化集合。以下示例展示了首选和非首选方法:
//Preferred Method
var myDictionary: [String: String] = [:]
var strArray: [String] = []
var strOptional: String?
//Non-Preferred Method
var myDictionary: Dictionary<String,String>
var strArray: Array<String>
var strOptional: Optional<String>
使用 switch 语句而不是多个 if 语句
wherever possible,我们应该更倾向于使用单个switch语句而不是多个if语句。以下示例展示了首选和非首选方法:
//Preferred Method
let speed = 300_000_000
switch speed {
case 300_000_000:
print("Speed of light")
case 340:
print("Speed of sound")
default:
print("Unknown speed")
}
//Non-preferred Method
let speed = 300_000_000 if speed == 300_000_000 {
print("Speed of light")
} else if speed == 340 {
print("Speed of sound")
} else {
print("Unknown speed")
}
不要在应用程序中留下注释掉的代码
如果我们在尝试替换代码块时注释掉该代码块,一旦我们对更改感到满意,我们应该移除我们注释掉的代码。大量注释掉的代码块会使代码库看起来杂乱无章,并使其更难跟踪。
摘要
当我们在团队环境中开发应用程序时,拥有一个由团队中每个人遵守的良好定义的编码风格非常重要。这使我们能够拥有一个易于阅读和维护的代码库。
如果一个风格指南长时间保持不变,这可能意味着它可能没有跟上语言中的最新变化。对于每种语言,“太长时间”的定义是不同的。例如,对于 C 语言,太长时间将以年为单位定义,因为该语言非常稳定;然而,对于 Swift,该语言相对较新,变化发生得相对频繁,因此“太长时间”可能被定义为几个月。
建议我们将风格指南保存在版本控制系统之中,这样在需要时我们可以参考旧版本。这使我们能够拉取风格指南的旧版本,并在查看旧代码时回溯参考。
不仅在 Swift 中,在其他语言中也是如此,建议您使用代码检查工具来检查和强制执行良好的编码实践。对于 Swift,有一个名为 SwiftLint (github.com/realm/SwiftLint) 的优秀工具,它有一个命令行工具。
在为您的组织编写风格指南时,您可能需要关注 Swift 进化提案 SE-0250 (github.com/apple/swift-evolution/blob/master/proposals/0250-swift-style-guide-and-formatter.md)。该提案旨在创建一个官方的 Swift 风格指南和格式化工具。如果这个提案被接受并且发布了官方的风格指南,那么您应该采用这些指南。
第二十章:在 Swift 中采用设计模式
尽管四巨头《设计模式:可复用面向对象软件元素》的第一版于 1994 年 10 月发布,但我对设计模式的研究只有 14 年的时间。像大多数经验丰富的开发者一样,当我最初开始阅读有关设计模式的内容时,我认识到了很多模式,因为我已经在不知不觉中使用了它们。我必须说,自从我开始阅读有关设计模式的内容以来,我没有编写过一个不使用至少一种设计模式的严肃应用程序。我会告诉你,我绝对不是一个设计模式狂热者,而且如果我陷入关于设计模式的对话中,通常只有一两个我能不查资料就能叫出名字的。但有一件事我记得很清楚,那就是主要设计模式背后的概念以及它们旨在解决的问题。这样,当我遇到这些问题之一时,我就可以查找适当的模式并应用它。所以,记住,当你阅读这一章时,花时间去理解设计模式背后的主要概念,而不是试图记住模式本身。
在本章中,你将学习以下主题:
-
什么是设计模式?
-
哪些类型的设计模式构成了设计模式的创建、结构和行为类别?
-
如何在 Swift 中实现单例和建造者创建模式
-
如何在 Swift 中实现桥接、外观和代理结构模式
-
如何在 Swift 中实现命令和策略行为模式
什么是设计模式?
每个经验丰富的开发者都有一套非正式的策略,这些策略塑造了他们设计和编写应用程序的方式。这些策略是由他们的过去经验和他们在以前的项目中必须克服的障碍所塑造的。虽然这些开发者可能对自己的策略深信不疑,但这并不意味着他们的策略已经得到了充分的检验。使用这些策略也可能在不同项目和开发者之间引入不一致的实现。
虽然设计模式的概念可以追溯到 20 世纪 80 年代中期,但直到四巨头在 1994 年发布了《设计模式:可复用面向对象软件》一书,它们才变得流行。这本书的作者 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(也被称为四巨头)讨论了面向对象编程的陷阱,并描述了 23 个经典的软件设计模式。这些模式被分为三个类别:创建型、结构型和行为型。
设计模式识别常见的软件开发问题,并提供解决这些问题的策略。这些策略在多年来的实践中已被证明是解决它们旨在解决的问题的有效方案。使用这些模式可以大大加快开发过程,因为它们为我们提供了已被证明可以解决常见软件开发问题的解决方案。
当我们使用设计模式时,我们获得的另一个优点是易于维护的代码,因为几个月或几年后当我们查看代码时,我们将能够识别出模式并理解代码的功能。如果我们适当地记录代码并记录我们正在实施的设计模式,这也有助于其他开发者理解代码的功能。
设计模式背后的两种主要哲学是代码重用和灵活性。作为一名软件架构师,将可重用性和灵活性构建到代码中是至关重要的。这使我们能够轻松地维护代码,并在未来使应用程序更容易扩展以满足未来的需求,因为我们都知道需求变化有多快。
虽然设计模式有很多优点,并且对开发者和架构师来说极为有益,但它们并不是解决世界饥饿问题的方案,就像一些开发者所宣称的那样。在你的开发生涯中,你可能会遇到一些认为设计模式是不可变法则的开发者或架构师。这些开发者通常试图在不需要的情况下强制使用设计模式。一个好的经验法则是,在尝试解决问题之前,确保你有一个需要解决的问题。
设计模式是避免和解决常见编程问题的起点。我们可以将每个设计模式视为一个食谱。就像一个好的食谱一样,我们可以对其进行调整以满足特定的口味。但我们通常不想偏离原始食谱太远,因为我们可能会把它搞砸。
也有时候我们没有一个想要制作的菜肴的食谱,就像有时候我们面对的问题没有现成的设计模式来解决。在这种情况下,我们可以利用我们对设计模式及其潜在哲学的知识来为问题提出一个有效的解决方案。
设计模式通常分为三个类别。具体如下:
-
创建模式:创建模式支持对象的创建
-
结构模式:结构模式关注类型和对象组合
-
行为模式:行为模式在类型之间进行通信
虽然“四人帮”定义了超过 20 种设计模式,但我们将在本章中只查看一些更受欢迎的模式的示例。让我们从查看创建模式开始。
设计模式最初是为面向对象编程定义的。在本章中,尽可能的,我们将关注以更协议导向的方式实现模式。因此,本章中的示例可能与其他设计模式书籍中的示例略有不同,但解决方案的底层哲学将是相同的。
创建型设计模式
创建型模式是处理对象创建的设计模式。这些模式以适合特定情况的方式创建对象。
创建型模式背后有两个基本思想。第一个是封装关于“哪些”具体类型应该被创建的知识,第二个是隐藏这些类型实例的创建方式。
有五种著名的模式属于创建型模式类别。它们如下:
-
抽象工厂模式:这种模式提供了一个创建相关对象的接口,而不指定具体的类型
-
建造者模式:这种模式将复杂对象的构建与其表示分离,因此可以使用相同的流程创建类似类型的对象
-
工厂方法模式:这种模式创建对象,而不暴露对象(或对象类型)创建的底层逻辑
-
原型模式:这种模式通过克隆现有对象来创建对象
-
单例模式:这种模式允许一个(且仅一个)类的实例在整个应用程序的生命周期中存在
在本章中,我们将展示如何在 Swift 中实现单例模式和建造者模式。让我们从一个最具争议性且可能被过度使用的模式——单例模式——开始。
单例模式
单例模式的使用在开发社区的一些角落中是一个相当有争议的话题。主要原因之一是单例模式可能是被过度使用和误用的模式之一。这个模式引起争议的另一个原因是它将全局状态引入应用程序中,这提供了在任何应用程序点更改对象的能力。单例模式还可能引入隐藏的依赖和紧耦合。我个人认为,如果单例模式被正确使用,使用它是没有问题的。然而,我们确实需要小心不要误用它。
单例模式限制了一个类在应用程序生命周期内的实例化只能有一个实例。当我们需要恰好一个对象来协调应用程序内的操作时,这种模式非常有效。单例的一个良好用途示例是,如果应用程序通过蓝牙与远程设备通信,并且我们还想在整个应用程序中保持该连接。有些人可能会说,我们可以从一页传递到另一页连接类的实例,这本质上就是单例。在我看来,在这个例子中,单例模式是一个更干净的解决方案,因为使用单例模式,任何需要连接的页面都可以获取它,而不必强迫每个页面都维护实例。这也允许我们在转到另一页时无需重新连接来维护连接。
理解问题
单例模式旨在解决的问题是,在应用程序的生命周期中我们需要一个且仅有一个类型的实例。单例模式通常用于我们需要集中管理内部或外部资源,并且需要一个单一的全球访问点。单例模式的另一个流行用途是,当我们想要在整个应用程序中整合一组相关的活动,而这些活动在一个地方不维护状态时。
在第九章,协议与协议扩展中,我们在文本验证示例中使用了单例模式。在那个例子中,我们使用单例模式是因为我们想要创建一个单一实例的类型,然后可以被应用程序的所有组件使用,而无需我们为这些类型创建新的实例。这些文本验证类型没有可以改变的状态。它们只有执行文本验证的方法和定义如何验证文本的常量。虽然有些人可能不同意我的观点,但我认为这类类型是单例模式的绝佳候选者,因为没有理由创建这些类型的多个实例。
在那个例子中,我们使用结构体来实现它,但这并不是真正的单例,因为结构体是一个值类型。真正的单例是通过引用(类)类型实现的。
当使用单例模式时,最大的担忧之一是多线程应用程序中的竞态条件。问题发生在当一个线程更改单例的状态时,另一个线程正在访问它,从而产生意外的结果。例如,如果TextValidation类存储要验证的文本,然后我们调用一个方法进行验证,一个线程可能在原始线程进行验证之前更改存储的文本。在实现此模式之前,了解单例将在您的应用程序中如何使用是明智的。
理解解决方案
在 Swift 中实现单例模式有几种方法。在我们使用的方法中,当第一次访问类常量时创建了一个类的单个实例。然后我们将使用类常量来在整个应用程序的生命周期中访问这个实例。我们还将创建一个私有初始化器,这将防止外部代码创建类的额外实例。
注意,我们在描述中使用的是“类”这个词,而不是“类型”。这样做的原因是单例模式只能真正通过引用类型来实现。
实现单例模式
让我们看看我们是如何使用 Swift 实现单例模式的。下面的代码示例展示了如何创建一个单例类:
class MySingleton {
static let sharedInstance = MySingleton()
var number = 0
private init() {}
}
我们可以看到,在 MySingleton 类中,我们创建了一个名为 sharedInstance 的静态常量,它包含了一个 MySingleton 类的实例。静态常量可以在不实例化类的情况下被调用。由于我们声明了 sharedInstance 常量是静态的,因此在整个应用程序的生命周期中只存在一个实例,从而创建了单例模式。
我们还创建了一个私有初始化器,它不能在类外部访问,这将限制其他代码创建 MySingleton 类的额外实例。
现在,让我们看看这个模式是如何工作的。MySingleton 模式还有一个名为 number 的属性,它是一个整数。我们将监控这个属性在我们使用 sharedInstance 属性创建多个 MySingleton 类型的变量时的变化,如下面的代码所示:
var singleA = MySingleton.sharedInstance
var singleB = MySingleton.sharedInstance
var singleC = MySingleton.sharedInstance
singleB.number = 2
print(singleA.number)
print(singleB.number)
print(singleC.number)
singleC.number = 3
print(singleA.number)
print(singleB.number)
print(singleC.number)
在这个例子中,我们使用 sharedInstance 属性创建了三个 MySingleton 类型的变量。我们最初将第二个 MySingleton 变量(singleB)的 number 属性设置为数字 2。当我们打印出 singleA、singleB 和 singleC 实例的 number 属性值时,我们看到所有三个的 number 属性值都等于 2。
然后,我们将第三个 MySingleton 实例(singleC)的 number 属性的值更改为数字 3。当我们再次打印出 number 属性的值时,我们看到现在所有三个的值都是 3。因此,当我们更改任何实例中 number 属性的值时,所有三个的值都会改变,因为每个变量都指向同一个实例。
在这个例子中,我们使用引用(类)类型实现了单例模式,因为我们想确保在整个应用程序中只存在该类型的一个实例。如果我们用结构体或枚举这样的值类型实现这个模式,我们就有可能出现该类型的多个实例。
如果你还记得,每次我们传递一个值类型的实例时,我们实际上是在传递该实例的一个副本,这意味着如果我们用值类型实现单例模式,每次调用sharedInstance属性时,我们都会收到一个新的副本,这实际上会破坏单例模式。
当我们需要在整个应用程序中维护对象的状态时,单例模式非常有用;然而,请注意不要过度使用它。除非有特定的要求(要求是这里的关键词)在整个应用程序的生命周期中只有一个类的实例,否则不应使用单例模式。如果我们仅仅为了方便而使用单例模式,那么我们可能是在误用它。
请记住,虽然苹果公司通常建议我们优先使用值类型而不是引用类型,但仍然有许多例子,例如单例模式,我们需要使用引用类型。当我们不断告诉自己优先使用值类型而不是引用类型时,很容易忘记有时需要引用类型。不要忘记在使用此模式时使用引用类型。
现在,让我们看看构建者设计模式。
构建者模式
构建者模式帮助我们创建复杂对象,并强制执行这些对象的创建过程。使用此模式,我们通常将创建逻辑从复杂类型中分离出来,并将创建逻辑放入另一个类型中。这允许我们使用相同的构建过程来创建类型的不同表示形式。
理解问题
构建者模式旨在解决当类型实例需要大量可配置值时的问题。我们可以在创建类的实例时设置配置选项,但如果选项设置不正确或我们不知道所有选项的正确值,这可能会引起问题。另一个问题是,每次创建类型实例时可能需要的代码量来设置所有可配置选项。
理解解决方案
构建者模式通过引入一个称为构建者类型的中间代理来解决此问题。这个构建者类型包含创建原始复杂类型实例所需的大部分,如果不是全部,信息。
我们可以使用两种方法来实现构建者模式。第一种方法是有多个构建者类型,其中每个类型都包含以特定方式配置原始复杂对象所需的信息。在第二种方法中,我们使用单个构建者类型来设置所有可配置选项的默认值,然后根据需要更改这些值。
在本节中,我们将探讨使用构建者模式的两种方法,因为了解每种方法的工作方式非常重要。
实现构建者模式
在我们展示如何使用构建器模式之前,让我们看看如何在不使用构建器模式的情况下创建复杂结构以及我们遇到的问题。
以下代码创建了一个名为 BurgerOld 的结构,并且没有使用构建器模式:
struct BurgerOld {
var name: String
var patties: Int
var bacon: Bool
var cheese: Bool
var pickles: Bool
var ketchup: Bool
var mustard: Bool
var lettuce: Bool
var tomato: Bool
init(name: String, patties: Int, bacon: Bool, cheese: Bool, pickles:Bool, ketchup: Bool, mustard: Bool,lettuce: Bool, tomato: Bool) {
self.name = name
self.patties = patties
self.bacon = bacon
self.cheese = cheese
self.pickles = pickles
self.ketchup = ketchup
self.mustard = mustard
self.lettuce = lettuce
self.tomato = tomato
}
}
在 BurgerOld 结构中,我们有一些属性定义了汉堡上有哪些配料以及汉堡的名称。由于我们需要知道哪些项目在汉堡上,哪些不在,当我们创建 BurgerOld 结构的实例时,初始化器要求我们定义每个项目。这可能导致应用程序中一些复杂的初始化,更不用说如果我们有多个标准汉堡(培根芝士汉堡、芝士汉堡、汉堡等),我们需要确保每个都定义正确。让我们看看如何创建 BurgerOld 类的实例:
// Create Hamburger
var hamburger = BurgerOld(name: "Hamburger", patties: 1, bacon: false, cheese: false, pickles: true, ketchup: true, mustard: true, lettuce: false, tomato: false)
// Create Cheeseburger
var cheeseburger = BurgerOld(name: "Cheeseburger", patties: 1 , bacon: false, cheese: true, pickles: true, ketchup: true, mustard: true, lettuce: false, tomato: false)
如我们所见,创建 BurgerOld 类型的实例需要大量的代码。现在,让我们看看一种更好的方法。在这个例子中,我们将展示如何使用多个构建器类型,其中每种类型将定义每种汉堡上有什么。我们将首先创建一个 BurgerBuilder 协议,其中将包含以下代码:
protocol BurgerBuilder {
var name: String { get }
var patties: Int { get }
var bacon: Bool { get }
var cheese: Bool { get }
var pickles: Bool { get }
var ketchup: Bool { get }
var mustard: Bool { get }
var lettuce: Bool { get }
var tomato: Bool { get }
}
此协议简单地定义了任何实现此协议的类型所需的九个属性。现在,让我们创建两个实现此协议的结构:HamburgerBuilder 和 CheeseBurgerBuilder 结构:
struct HamburgerBuilder: BurgerBuilder {
let name = "Burger"
let patties = 1
let bacon = false
let cheese = false
let pickles = true
let ketchup = true
let mustard = true
let lettuce = false
let tomato = false
}
struct CheeseBurgerBuilder: BurgerBuilder {
let name = "CheeseBurger"
let patties = 1
let bacon = false
let cheese = true
let pickles = true
let ketchup = true
let mustard = true
let lettuce = false
let tomato = false
}
在 HamburgerBuilder 和 CheeseBurgerBuilder 结构中,我们所做的只是为每个所需的属性定义值。在更复杂类型中,我们可能需要初始化额外的资源。
现在,让我们看看 Burger 结构,它将使用 BurgerBuilder 协议的实例来创建自身的实例。以下代码展示了这种新的 Burger 类型:
struct Burger {
var name: String
var patties: Int
var bacon: Bool
var cheese: Bool
var pickles: Bool
var ketchup: Bool
var mustard: Bool
var lettuce: Bool
var tomato: Bool
init(builder: BurgerBuilder) {
self.name = builder.name
self.patties = builder.patties
self.bacon = builder.bacon
self.cheese = builder.cheese
self.pickles = builder.pickles
self.ketchup = builder.ketchup
self.mustard = builder.mustard
self.lettuce = builder.lettuce
self.tomato = builder.tomato
}
func showBurger() {
print("Name:\(name)")
print("Patties: \(patties)")
print("Bacon: \(bacon)")
print("Cheese: \(cheese)")
print("Pickles: \(pickles)")
print("Ketchup: \(ketchup)")
print("Mustard: \(mustard)")
print("Lettuce: \(lettuce)")
print("Tomato: \(tomato)")
}
}
与前面展示的 BurgerOld 结构相比,这个 Burger 结构的不同之处在于初始化器。在先前的 BurgerOld 结构中,初始化器接受九个参数——结构中定义的每个常量一个。在新结构中,初始化器接受一个参数,即符合 BurgerBuilder 协议的类型的一个实例。这个新的初始化器允许我们按照以下方式创建 Burger 类的实例:
// Create Hamburger
var myBurger = Burger(builder: HamburgerBuilder())
myBurger.showBurger()
// Create Cheeseburger
var myCheeseBurger = Burger(builder: CheeseBurgerBuilder())
// Let's hold the ketchup
myCheeseBurger.ketchup = false
myCheeseBurger.showBurger()
如果我们将创建新 Burger 结构实例的方法与先前的 BurgerOld 结构进行比较,我们可以看到创建 Burger 结构的实例要容易得多。我们还知道我们正确地设置了每种汉堡的类型属性值,因为这些值是在构建器类中直接设置的。
如我们之前提到的,我们可以使用第二种方法来实现建造者模式。而不是有多个建造者类型,我们可以有一个单一的建造者类型,它将所有可配置选项设置为默认值;然后根据需要更改这些值。我在更新旧代码时经常使用这种方法,因为它很容易与现有代码集成。
对于这种实现,我们将创建一个单一的BurgerBuilder结构。这个结构将用于创建BurgerOld结构的实例,并且默认情况下将所有成分设置为它们的默认值。
BurgerBuilder结构还使我们能够在创建BurgerOld结构的实例之前更改将放在汉堡上的成分。我们创建BurgerBuilder结构的方式如下:
struct BurgerBuilder {
var name = "Burger"
var patties = 1
var bacon = false
var cheese = false
var pickles = true
var ketchup = true
var mustard = true
var lettuce = false
var tomato = false
mutating func setPatties(choice: Int) {
self.patties = choice
}
mutating func setBacon(choice: Bool) {
self.bacon = choice
}
mutating func setCheese(choice: Bool) {
self.cheese = choice
}
mutating func setPickles(choice: Bool) {
self.pickles = choice
}
mutating func setKetchup(choice: Bool) {
self.ketchup = choice
}
mutating func setMustard(choice: Bool) {
self.mustard = choice
}
mutating func setLettuce(choice: Bool) {
self.lettuce = choice
}
mutating func setTomato(choice: Bool) {
self.tomato = choice
}
func buildBurgerOld(name: String) -> BurgerOld {
return BurgerOld(name: name, patties: self.patties,bacon: self.bacon, cheese: self.cheese,pickles: self.pickles, ketchup: self.ketchup,mustard: self.mustard, lettuce: self.lettuce,tomato: self.tomato)
}
}
在BurgerBuilder结构中,我们定义了汉堡的九个属性(成分),然后为除了name属性之外的所有属性创建了一个 setter 方法。
我们还创建了一个名为buildBurgerOld()的方法,它将根据BurgerBuilder实例的属性值创建BurgerOld结构的实例。我们使用BurgerBuilder结构的方式如下:
var burgerBuilder = BurgerBuilder()
burgerBuilder.setCheese(choice: true)
burgerBuilder.setBacon(choice: true)
var jonBurger = burgerBuilder.buildBurgerOld(name: "Jon's Burger")
在这个例子中,我们创建了一个BurgerBuilder结构的实例。然后我们使用setCheese()和setBacon()方法向汉堡中添加奶酪和培根。最后,我们调用buildBurgerOld()方法来创建burgerOld结构的实例。
如我们所见,两种实现建造者模式的方法都极大地简化了复杂类型的创建。这两种方法还确保了实例被正确地配置为默认值。如果你发现自己正在创建具有非常长和复杂初始化命令的类型实例,我建议你查看建造者模式,看看你是否可以使用它来简化初始化。
现在,让我们看看结构型设计模式。
结构型设计模式
结构型设计模式描述了类型如何组合成更大的结构。这些更大的结构通常更容易处理,并且可以隐藏许多单个类型的复杂性。结构型模式类别中的大多数模式都涉及对象之间的连接。
有七个广为人知的模式是结构型设计模式类型的一部分。具体如下:
-
适配器(Adapter): 这允许具有不兼容接口的类型一起工作
-
桥接(Bridge): 这用于将类型的抽象元素与实现分离,以便两者可以独立变化
-
复合(Composite): 这允许我们将一组对象视为单个对象处理
-
装饰器(Decorator): 这让我们能够在现有对象的方法中添加或覆盖行为
-
外观(Facade): 这为更大的、更复杂的代码库提供了一个简化的接口
-
享元(Flyweight): 这允许我们减少创建和使用大量相似对象所需的资源
-
代理:这是一个充当其他类或类的接口的类型
在本章中,我们将给出如何在 Swift 中使用桥接、外观和代理模式的示例。让我们首先看看桥接模式。
桥接模式
桥接模式解耦了抽象和实现,以便它们可以独立地变化。桥接模式也可以被视为两层抽象。
理解问题
桥接模式旨在解决几个问题,但我们在这里将要关注的问题通常随着时间的推移,随着新功能和新需求的到来而出现。在某个时刻,当这些需求到来时,我们需要改变功能之间的交互方式。最终,这将需要我们重构代码。
在面向对象编程中,这被称为爆炸性的类层次结构,但在协议导向编程中也可能发生。
理解解决方案
桥接模式通过将交互功能分离,将每个功能特有的功能与它们之间共享的功能分离来解决此问题。然后可以创建一个桥接类型,它将封装共享功能,将它们组合在一起。
实现桥接模式
为了演示我们如何使用桥接模式,我们将创建两个功能。第一个功能是一个消息功能,它将存储和准备我们希望发送的消息。第二个功能是发送者功能,它将通过特定的通道发送消息,例如电子邮件或短信。
让我们首先创建两个名为 Message 和 Sender 的协议。Message 协议将定义用于创建消息的类型的要求。Sender 协议将用于定义用于通过特定通道发送消息的类型的要求。
以下代码显示了如何定义这两个协议:
protocol Message {
var messageString: String { get set }
init(messageString: String)
func prepareMessage()
}
protocol Sender {
func sendMessage(message: Message)
}
Message 协议定义了一个名为 messageString 的单一属性,该属性的类型为 String。这个属性将包含消息的文本,并且不能为 nil。我们还定义了一个初始化器和一个名为 prepareMessage() 的方法。初始化器将用于设置 messageString 属性以及消息类型所需的其他任何内容。prepareMessage() 方法将用于在发送消息之前准备消息。此方法可以用于加密消息或添加格式。
Sender 协议定义了一个名为 sendMessage() 的方法。此方法将通过符合类型定义的通道发送消息。在此函数中,我们需要确保在发送消息之前调用消息类型的 prepareMessage() 方法。
现在让我们看看我们如何定义两个符合 Message 协议的类型:
class PlainTextMessage: Message {
var messageString: String
required init(messageString: String) {
self.messageString = messageString
}
func prepareMessage() {
//Nothing to do
}
}
class DESEncryptedMessage: Message {
var messageString: String
required init(messageString: String) {
self.messageString = messageString
}
func prepareMessage() {
// Encrypt message here
self.messageString = "DES: " + self.messageString
}
}
这些类型都包含符合 Message 协议所需的功能。这些类型之间唯一的真正区别在于 prepareMessage() 方法。在 PlainTextMessage 类中,prepareMessage() 方法是空的,因为我们不需要在发送消息之前对消息进行任何操作。DESEncryptionMessage 类的 prepareMessage() 方法通常会包含加密消息的逻辑,但在这个示例中,我们将在消息的开头添加一个 DES 标签,这样我们就可以知道这个方法被调用了。
现在,让我们创建两个将符合 Sender 协议的类型。这些类型通常会处理通过特定通道发送消息;然而,在这个示例中,我们只是将消息打印到控制台:
class EmailSender: Sender {
func sendMessage(message: Message) {
print("Sending through E-Mail:")
print("\(message.messageString)")
}
}
class SMSSender: Sender {
func sendMessage(message: Message) {
print("Sending through SMS:")
print("\(message.messageString)")
}
}
EmailSender 和 SMSSender 类型都通过实现 sendMessage() 函数符合 Sender 协议。
现在我们可以使用这两个功能,如下面的代码所示:
var myMessage = PlainTextMessage(messageString: "Plain Text Message")
myMessage.prepareMessage()
var sender = SMSSender()
sender.sendMessage(message: myMessage)
这将很好地工作,我们可以在需要创建和发送消息的任何地方添加类似的代码。现在,假设在不久的将来,我们收到一个需求,需要在发送消息之前验证消息,以确保它符合我们通过该通道发送消息的要求。
要做到这一点,我们首先需要将 Sender 协议修改为添加 verify 功能。
新的 Sender 协议如下所示:
protocol Sender {
var message: Message? { get set }
func sendMessage()
func verifyMessage()
}
我们向 Sender 协议添加了一个名为 verifyMessage() 的方法,并添加了一个名为 Message 的属性。我们还更改了 sendMessage() 方法的定义。原始的 Sender 协议被设计为简单地发送消息,但现在我们需要在调用 sendMessage() 函数之前验证消息;因此,我们不能像上一个定义中那样简单地传递消息。
现在,我们需要更改符合 Sender 协议的类型,使它们符合这个新协议。下面的代码展示了我们将如何进行这些更改:
class EmailSender: Sender {
var message: Message?
func sendMessage() {
print("Sending through E-Mail:")
print("\(message!.messageString)")
}
func verifyMessage() {
print("Verifying E-Mail message")
}
}
class SMSSender: Sender {
var message: Message?
func sendMessage() {
print("Sending through SMS:")
print("\(message!.messageString)")
}
func verifyMessage() {
print("Verifying SMS message")
}
}
在对符合 Sender 协议的类型所做的更改之后,我们需要更改代码使用这些类型的方式。以下示例展示了我们现在如何使用它们:
var myMessage = PlainTextMessage(messageString: "Plain Text Message")
myMessage.prepareMessage()
var sender = SMSSender()
sender.message = myMessage
sender.verifyMessage()
sender.sendMessage()
这些更改并不困难;然而,如果没有桥接模式,我们就需要重构整个代码库,并在发送消息的每个地方进行更改。桥接模式告诉我们,当我们有两个紧密交互的层次结构时,我们应该将这些交互逻辑放入一个桥接类型中,这样就可以将逻辑封装在一个地方。这样,当我们收到新的需求或增强时,我们可以在一个地方进行更改,从而限制必须进行的重构。我们可以为消息和发送者层次结构创建一个桥接类型,如下面的示例所示:
struct MessagingBridge {
static func sendMessage(message: Message, sender: Sender) {
var sender = sender
message.prepareMessage()
sender.message = message
sender.verifyMessage()
sender.sendMessage()
}
}
消息和发送者层次结构如何交互的逻辑现在被封装到MessagingBridge结构中。现在,当逻辑需要更改时,我们只需要更改这个结构,而无需重构整个代码库。
桥接模式是一个非常值得记住和使用的模式。曾经(现在仍然)有几次我后悔没有在我的代码中使用桥接模式,因为众所周知,需求经常变化,能够在代码库的一个地方而不是整个代码库中做出更改可以节省我们未来大量的时间。
现在,让我们看看结构类别中的下一个模式:外观模式。
外观模式
外观模式提供了一个简化的接口来访问更大、更复杂的代码库。这使我们能够通过隐藏一些复杂性来使库更容易使用和理解。它还允许我们将多个 API 组合成一个单一、易于使用的 API,这正是我们将在示例中看到的。
理解问题
外观模式通常用于我们有一个复杂系统,该系统具有大量独立且旨在协同工作的 API。有时在初始应用程序设计中很难确定我们应该在哪里使用外观模式。原因是我们通常试图简化初始 API 设计;然而,随着时间的推移,随着需求的变化和新功能的添加,API 变得越来越复杂,然后就会明显地知道应该在何处使用外观模式。
理解解决方案
外观模式的主要思想是在一个简单的接口后面隐藏 API 的复杂性。这为我们提供了几个优点,最明显的是它简化了我们与 API 的交互方式。它还促进了松散耦合,这使得 API 可以根据需求的变化而变化,而无需重构使用它们的所有代码。
实现外观模式
为了演示外观模式,我们将创建三个 API:HotelBooking、FlightBooking和RentalCarBooking。这些 API 将用于搜索和预订酒店、航班和租赁汽车。虽然我们可以在代码中非常容易地单独调用每个 API,但我们将创建一个TravelFacade结构,这将允许我们通过单次调用访问 API 的功能。
我们将首先定义三个 API。每个 API 都需要一个数据存储类来存储有关酒店、航班或租赁汽车的信息。我们将从实现酒店 API 开始:
struct Hotel {
//Information about hotel room
}
struct HotelBooking {
static func getHotelNameForDates(to: Date, from: Date) -> [Hotel]? {
let hotels = [Hotel]()
//logic to get hotels
return hotels
}
static func bookHotel(hotel: Hotel) {
// logic to reserve hotel room
}
}
酒店 API 由Hotel和HotelBooking结构组成。Hotel结构将用于存储酒店房间的信息,而HotelBooking结构将用于搜索酒店房间并为旅行预订房间。航班和租赁汽车 API 与酒店 API 非常相似。以下代码显示了这两个 API:
struct Flight {
//Information about flights
}
struct FlightBooking {
static func getFlightNameForDates(to: Date, from: Date) ->[Flight]? {
let flights = [Flight]()
//logic to get flights return flights
}
static func bookFlight(flight: Flight) {
// logic to reserve flight
}
}
struct RentalCar {
//Information about rental cars
}
struct RentalCarBooking {
static func getRentalCarNameForDates(to: Date, from: Date)-> [RentalCar]?
{
let cars = [RentalCar]()
//logic to get flights return cars
}
static func bookRentalCar(rentalCar: RentalCar) {
// logic to reserve rental car
}
}
在这些 API 中,我们有一个用于存储信息的结构和一个用于提供搜索/预订功能的结构。在初始设计中,在应用程序中调用这些单个 API 会非常容易;然而,正如我们所知,需求往往会变化,这会导致 API 随着时间的推移而变化。
通过在这里使用外观模式,我们能够隐藏 API 的实现方式;因此,如果我们需要在未来更改 API 的工作方式,我们只需要更新外观类型,而不是重构所有代码。这使得代码在未来更容易维护和更新。现在让我们看看我们将如何通过创建一个TravelFacade结构来实现外观模式:
struct TravelFacade {
var hotels: [Hotel]?
var flights: [Flight]?
var cars: [RentalCar]?
init(to: Date, from: Date) {
hotels = HotelBooking.getHotelNameForDates(to: to, from:from)
flights = FlightBooking.getFlightNameForDates(to: to, from:from)
cars = RentalCarBooking.getRentalCarNameForDates(to: to, from:from)
}
func bookTrip(hotel: Hotel, flight: Flight, rentalCar: RentalCar) {
HotelBooking.bookHotel(hotel: hotel)
FlightBooking.bookFlight(flight: flight)
RentalCarBooking.bookRentalCar(rentalCar: rentalCar)
}
}
TravelFacade类包含搜索三个 API 并预订酒店、航班和租赁车的功能。我们现在可以使用TravelFacade类来搜索酒店、航班和租赁车,而无需直接访问单个 API。
正如我们在本章开头提到的,在初始设计中何时使用外观模式并不总是显而易见的。
一个好的规则是:如果我们有几个 API 协同工作以执行任务,我们应该考虑使用外观模式。
现在,让我们看看最后一个结构化模式,即代理设计模式。
代理模式
在代理设计模式中,有一个类型充当另一个类型或 API 的接口。这个包装类,即代理,可以添加功能到对象中,使对象可以通过网络访问,或者限制对对象的访问。
理解问题
我们可以使用代理模式解决几个问题,但我发现我主要使用这个模式来解决两个问题之一。
我使用这个模式要解决的问题的第一个问题是,当我想在单个 API 和我的代码之间创建一层抽象时。API 可以是本地或远程 API,但我通常使用这个模式在代码和远程服务之间添加一个抽象层。这将允许在不需要重构大量应用程序代码的情况下更改远程 API。
我使用代理模式要解决的第二个问题是我需要更改 API,但没有代码或应用程序的其他地方已经对 API 有依赖。
理解解决方案
为了解决这些问题,代理模式告诉我们应该创建一个类型,该类型将充当与其他类型或 API 交互的接口。在示例中,我们将展示如何使用代理模式向现有类型添加功能。
实现代理模式
在本节中,我们将通过创建一个可以添加多个楼层平面图的房屋类来演示代理模式,其中每个楼层平面图代表房屋的不同楼层。让我们首先创建一个FloorPlan协议:
protocol FloorPlan {
var bedRooms: Int { get set }
var utilityRooms: Int { get set }
var bathRooms: Int { get set }
var kitchen: Int { get set }
var livingRooms: Int { get set }
}
在FloorPlan协议中,我们定义了五个属性,将代表每个楼层平面包含的房间数量。现在,让我们创建一个名为HouseFloorPlan的FloorPlan协议的实现,如下所示:
struct HouseFloorPlan: FloorPlan {
var bedRooms = 0
var utilityRooms = 0
var bathRooms = 0
var kitchen = 0
var livingRooms = 0
}
HouseFloorPlan结构实现了FloorPlan协议所需的全部五个属性,并为它们分配了默认值。接下来,我们将创建House类型,它将代表一个房屋:
struct House {
var stories = [FloorPlan]()
mutating func addStory(floorPlan: FloorPlan) {
stories.append(floorPlan)
}
}
在House结构中,我们有一个符合FloorPlan协议的实例数组,其中每个楼层平面将代表房屋的一层。我们还有一个名为addStory()的函数,它接受一个符合FloorPlan协议的类型实例。此函数将楼层平面添加到FloorPlan协议的数组中。
如果我们考虑这个类的逻辑,可能会遇到一个问题:我们可以添加任意多的楼层平面,这可能会导致房屋高达 60 或 70 层。如果我们正在建造摩天大楼,那将很棒,但我们只想建造基本的单户住宅。如果我们想限制楼层平面的数量,而不改变House类(我们可能无法改变它,或者我们只是不想改变它),我们可以实现代理模式。以下示例展示了如何实现HouseProxy类,其中我们限制了可以向房屋添加的楼层平面数量:
struct HouseProxy {
var house = House()
mutating func addStory(floorPlan: FloorPlan) -> Bool {
if house.stories.count < 3 {
house.addStory(floorPlan: floorPlan)
return true
} else {
return false
}
}
}
我们从创建House类的一个实例开始HouseProxy类。然后我们创建一个名为addStory()的方法,它允许我们向房屋添加新的楼层平面。在addStory()方法中,我们检查房屋的故事数量是否少于三个;如果是这样,我们将楼层平面添加到房屋中并返回true。如果故事数量等于或大于三个,则我们不向房屋添加楼层平面并返回false。让我们看看我们如何使用这个代理:
var ourHouse = HouseProxy()
var basement = HouseFloorPlan(bedRooms: 0, utilityRooms: 1, bathRooms:1,kitchen: 0, livingRooms: 1)
var firstStory = HouseFloorPlan (bedRooms: 1, utilityRooms: 0,bathRooms: 2,kitchen: 1, livingRooms: 1)
var secondStory = HouseFloorPlan (bedRooms: 2, utilityRooms: 0,bathRooms: 1,kitchen: 0, livingRooms: 1)
var additionalStory = HouseFloorPlan (bedRooms: 1, utilityRooms: 0,bathRooms:1, kitchen: 1, livingRooms: 1)
ourHouse.addStory(floorPlan: basement)
ourHouse.addStory(floorPlan: firstStory)
ourHouse.addStory(floorPlan: secondStory)
ourHouse.addStory(floorPlan: additionalStory)
在示例代码中,我们首先创建了一个名为ourHouse的HouseProxy类实例。然后我们创建了四个HouseFloorPlan类型的实例,每个实例的房间数量都不同。最后,我们尝试将每个楼层平面添加到ourHouse实例中。如果我们运行此代码,我们将看到前三个floorplans类的实例成功添加到房屋中,但最后一个没有添加,因为我们只能添加三层。
代理模式在我们想要为一个类型添加一些额外的功能或错误检查时非常有用,但我们又不想改变该类型的实际实现。我们还可以用它来在远程或本地 API 之间添加一层抽象。
现在,让我们看看行为设计模式。
行为设计模式
行为设计模式解释了类型之间如何交互。这些模式描述了不同类型的实例如何相互发送消息以使事情发生。
有九种著名的模式属于行为设计模式类型。它们如下所示:
-
责任链:用于处理各种请求,每个请求可能被委派给不同的处理者。
-
命令:创建可以封装操作或参数的对象,以便稍后或由不同的组件调用。
-
迭代器:允许我们按顺序访问对象中的元素,而不暴露底层结构。
-
中介者:用于减少相互通信的类型之间的耦合。
-
备忘录:用于捕获对象的当前状态,并以可以稍后恢复的方式存储它。
-
观察者:允许一个对象发布对其状态的更改。其他对象可以订阅,以便在发生任何更改时得到通知。
-
状态:当对象的内部状态发生变化时,用于改变对象的行为。
-
策略:这允许在运行时从算法家族中选择一个。
-
访问者:这是一种将算法与对象结构分离的方法。
在本节中,我们将给出如何在 Swift 中使用策略和命令模式的示例。让我们先看看命令模式。
命令模式
命令设计模式让我们能够定义可以在以后执行的操作。这个模式通常封装了在以后时间调用或触发操作所需的所有信息。
理解问题
在应用程序中,有时我们需要将命令的执行与其调用者分离。通常,这是当我们有一个需要执行多个操作之一的类型时,但需要运行时选择使用哪个操作。
理解解决方案
命令模式告诉我们应该将操作逻辑封装到一个符合命令协议的类型中。然后我们可以为调用者提供命令类型的实例。调用者将使用协议提供的接口来调用必要的操作。
实现命令模式
在本节中,我们将通过创建一个Light类型来演示如何使用命令模式。在这个类型中,我们将定义lightOnCommand和lightOffCommand命令,并使用turnOnLight()和turnOffLight()方法来调用这些命令。我们首先创建一个名为Command的协议,所有命令类型都将遵守。以下是Command协议:
protocol Command {
func execute()
}
此协议包含一个名为execute()的方法,它将被用于执行命令。现在,让我们看看Light类型将使用哪些命令类型来打开和关闭灯光。它们如下所示:
struct RockerSwitchLightOnCommand: Command {
func execute() {
print("Rocker Switch:Turning Light On")
}
}
struct RockerSwitchLightOffCommand: Command {
func execute() {
print("Rocker Switch:Turning Light Off")
}
}
struct PullSwitchLightOnCommand: Command {
func execute() {
print("Pull Switch:Turning Light On")
}
}
struct PullSwitchLightOffCommand: Command {
func execute() {
print("Pull Switch:Turning Light Off")
}
}
RockerSwitchLightOffCommand、RockerSwitchLightOnCommand、PullSwitchLightOnCommand 和 PullSwitchLightOffCommand 命令都通过实现 execute() 方法符合 Command 协议;因此,我们将能够在 Light 类型中使用它们。现在,让我们看看如何实现 Light 类型:
struct Light {
var lightOnCommand: Command
var lightOffCommand: Command
func turnOnLight() {
self.lightOnCommand.execute()
}
func turnOffLight() {
self.lightOffCommand.execute()
}
}
在 Light 类型中,我们首先创建两个变量,分别命名为 lightOnCommand 和 lightOffCommand,它们将包含符合 Command 协议的类型实例。然后,我们创建 turnOnLight() 和 turnOffLight() 方法,我们将使用这些方法来打开和关闭灯光。在这些方法中,我们调用适当的命令来打开或关闭灯光。
我们将按照以下方式使用 Light 类型:
var on = PullSwitchLightOnCommand()
var off = PullSwitchLightOffCommand()
var light = Light(lightOnCommand: on, lightOffCommand: off)
light.turnOnLight()
light.turnOffLight()
light.lightOnCommand = RockerSwitchLightOnCommand()
light.turnOnLight()
在这个例子中,我们首先创建了一个名为 on 的 PullSwitchLightOnCommand 类型的实例和一个名为 off 的 PullSwitchLightOffCommand 类型的实例。然后,我们使用这两个刚刚创建的命令创建了一个 Light 类型的实例,并调用 Light 实例的 turnOnLight() 和 turnOffLight() 方法来打开和关闭灯光。在最后两行中,我们将 lightOnCommand 方法,原本设置为 PullSwitchLightOnCommand 类型的实例,更改为 RockerSwitchLightOnCommand 类型的实例。现在,每次我们打开灯光时,Light 实例将使用 RockerSwitchLightOnCommand 类型的实例。这允许我们在运行时更改 Light 类型的功能。
使用命令模式有几个优点。其中一个主要优点是我们能够在运行时设置要调用的命令,这也让我们能够在整个应用程序的生命周期中根据需要替换符合 Command 协议的不同实现。命令模式的另一个优点是将命令实现的细节封装在命令类型本身中,而不是在容器类型中。
现在,让我们来看最后一个设计模式,即策略模式。
策略模式
策略模式与命令模式非常相似,因为它们都允许我们将实现细节与调用类型解耦,并且允许我们在运行时切换实现。主要区别在于策略模式旨在封装算法。通过替换算法,我们期望对象执行相同的功能,但以不同的方式。在命令模式中,当我们替换命令时,我们期望对象改变功能。
理解问题
在应用程序中,有时我们需要更改执行操作所使用的后端算法。通常,这是当我们有一个具有几种不同算法的类型,这些算法可以用来执行相同任务时,但需要根据运行时选择使用哪种算法。
理解解决方案
策略模式告诉我们应该将算法封装在一个符合策略协议的类型中。然后我们可以提供策略类型的实例供调用者使用。调用者将使用协议提供的接口来调用算法。
实现策略模式
在本节中,我们将通过展示如何在运行时替换压缩算法来演示策略模式。让我们从这个例子开始,创建一个 CompressionStrategy 协议,每个压缩类型都将符合此协议。让我们看看以下代码:
protocol CompressionStrategy {
func compressFiles(filePaths: [String])
}
此协议定义了一个名为 compressFiles() 的方法,该方法接受一个参数,即包含我们想要压缩的文件路径的字符串数组。我们现在将创建两个符合此协议的结构。这些是 ZipCompressionStrategy 和 RarCompressionStrategy 结构,如下所示:
struct ZipCompressionStrategy: CompressionStrategy {
func compressFiles(filePaths: [String]) {
print("Using Zip Compression")
}
}
struct RarCompressionStrategy: CompressionStrategy {
func compressFiles(filePaths: [String]) {
print("Using RAR Compression")
}
}
这两种结构都通过使用名为 compressFiles() 的方法实现了 CompressionStrategy 协议,该方法接受一个字符串数组。在这些方法中,我们只是简单地打印出我们正在使用的压缩名称。通常,我们会将这些方法中的压缩逻辑实现出来。
现在,让我们看看 CompressContent 类,它将被用来压缩文件:
struct CompressContent {
var strategy: CompressionStrategy
func compressFiles(filePaths: [String]) {
self.strategy.compressFiles(filePaths: filePaths)
}
}
在这个类中,我们首先定义了一个名为 strategy 的变量,它将包含一个符合 CompressionStrategy 协议的类型实例。然后我们创建了一个名为 compressFiles() 的方法,该方法接受一个字符串数组作为参数,该数组包含我们希望压缩的文件路径列表。在这个方法中,我们使用 strategy 变量中设置的压缩策略来压缩文件。
我们将按照以下方式使用 CompressContent 类:
var filePaths = ["file1.txt", "file2.txt"]
var zip = ZipCompressionStrategy()
var rar = RarCompressionStrategy()
var compress = CompressContent(strategy: zip)
compress.compressFiles(filePaths: filePaths)
compress.strategy = rar
compress.compressFiles(filePaths: filePaths)
我们首先创建一个包含我们希望压缩的文件的字符串数组。我们还创建了 ZipCompressionStrategy 和 RarCompressionStrategy 类型的实例。然后我们创建了一个 CompressContent 类的实例,将压缩策略设置为 ZipCompressionStrategy 实例,并调用 compressFiles() 方法,该方法将在控制台打印出 Using zip compression 消息。然后我们将压缩策略设置为 RarCompressionStrategy 实例,并再次调用 compressFiles() 方法,这将打印出 Using rar compression 消息到控制台。
策略模式非常适合在运行时设置要使用的算法,这也允许我们根据应用程序的需要用不同的实现来替换算法。策略模式的另一个优点是,我们将算法的细节封装在策略类型本身中,而不是在主实现类型中。
这篇关于 Swift 中设计模式的游览就此结束。
摘要
设计模式是针对我们在现实世界的应用程序设计中反复遇到的设计问题的解决方案。这些模式旨在帮助我们创建可重用和灵活的代码。设计模式还可以使代码对其他开发者以及我们在几个月或几年后回顾代码时更容易阅读和理解。
如果我们仔细观察本章的示例,我们会注意到设计模式的一个关键组成部分是协议。几乎所有的设计模式(单例设计模式除外)都使用协议来帮助我们创建非常灵活和可重用的代码。
如果你这是第一次真正地研究设计模式,你可能注意到了一些你在自己的代码中过去使用过的策略。当经验丰富的开发者第一次接触设计模式时,这是预料之中的。我也鼓励你阅读更多关于设计模式的内容,因为它们肯定会帮助你创建更灵活和可重用的代码。
Swift 是一种快速发展的语言,因此保持对其最新版本的跟进非常重要。由于 Swift 是一个开源项目,因此有大量的资源可以帮助你。我强烈建议你在你最喜欢的浏览器中收藏 swiftdoc.org。它为 Swift 语言自动生成了文档,是一个极好的资源。
另一个需要收藏的网站是 swift.org。这是主要的开源 Swift 网站。在这个网站上,你可以找到 Swift 源代码、博客文章、入门页面以及有关如何安装 Swift 的信息。
我还建议你在 swift.org 网站上注册一些邮件列表。这些列表位于社区部分。Swift-users 邮件列表是一个询问问题的绝佳地方,也是苹果公司监控的列表。如果你想跟上 Swift 的变化,那么我建议订阅 swift-evolution-announce 列表。
我希望你喜欢阅读这本书,就像我喜欢写这本书一样。


浙公网安备 33010602011771号