Swift-学习指南第二版-全-

Swift 学习指南第二版(全)

原文:zh.annas-archive.org/md5/0506cce1dddfeceb0ba611a76e68d512

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书将帮助您迅速开始学习 Swift。它不仅从概念上,也从实现的角度帮助您理解 iOS 编程的细微差别。如果您期待探索 iOS 应用程序编程的世界,这本书是无价之宝。

本书涵盖内容

第一章, 介绍 Swift, 将引导读者通过安装 Swift 并运行他们的第一个 Swift 程序的过程,以便立即展示其功能。

第二章, 构建模块 - 变量、集合和流程控制,通过现实世界的例子介绍了 Swift 用于以表达性和易于访问的方式表示复杂信息的各种内置机制。

第三章, 一次一块 – 类型、作用域和项目,介绍了使用代码紧密模拟现实世界所需的工具。它将教会您如何使用结构体、类和枚举定义自己的自定义类型。它还探讨了作用域和访问控制的概念。

第四章, 存在还是不存在 - 可选类型,专注于 Swift 中一个特殊且关键的类型,称为可选类型。它详细解释了可选类型的工作原理以及如何使用它们,将看似复杂的话题转化为非常直观的概念。

第五章, 现代范式 - 闭包和函数式编程,介绍了关于代码的一种新思维方式,称为函数式编程。我们学习 Swift 如何支持这种技术,以及我们如何将其应用于我们的程序,使其更加易于理解和表达。

第六章, 让 Swift 为您服务 - 协议和泛型,描述了泛型和协议是什么,以及它们如何同时提供力量和安全。

第七章, 万物相连 – 内存管理,深入探讨了 Swift 的内部机制。我们讨论了计算机如何存储信息,以及我们如何结合 Swift 中的一些新工具使用这些知识,以确保我们的代码保持响应性并最小化对电池寿命的影响。

第八章, 另辟蹊径 – 错误处理,深入探讨了 Swift 中优雅地处理错误情况的方法,包括错误抛出和捕获。

第九章,以 Swift 的方式编写代码 - 设计模式和技巧,通过引导读者通过一系列有助于减少代码复杂性的特定设计模式,向他们介绍编程的艺术。

第十章,利用过去 - 理解和翻译 Objective-C,通过关注 Objective-C 与 Swift 的比较,帮助读者建立对 Objective-C 的基本理解。这使得读者能够利用 Objective-C 中存在的丰富资源来帮助他们进行 Swift 开发。

第十一章,一个全新的世界 - 开发应用程序,侧重于通过示例解释创建真实世界 iOS 应用程序的过程。

第十二章,接下来是什么? - 资源、建议和下一步行动,讨论了如何前进以成为您可能成为的最佳应用程序开发者。它提供了一份资源和建议清单,读者可以使用这些资源继续他们的 Swift 和应用程序开发学习过程。

您需要这本书什么

要运行本书中的代码,您需要 Xcode 7.2。

这本书适合谁阅读

如果您想使用最现代的技术构建 iOS 或 OS X 应用程序,这本书非常适合您。学习 Swift将使您进入一个需求激增的小型开发者社区,当所有针对 Apple 平台的开发都转向它时。如果您是编程新手或尚未为 iOS 或 OS X 开发,您会发现这本书特别有用。

规范

在这本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

当在文本中提及代码片段时,我们将使用如下样式:“您可以看到"Hello, playground"确实被存储在变量中。”

如果代码较长,它将以如下块的形式呈现:

if invitees.count > 20 {
   println("Too many people invited")
}
else if invitees.count <= 3 {
    println("Not really a party")
}
else {
    println("Just right")
}

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,将以如下方式显示:“现在,点击连接按钮上的远程桌面查看器”。键盘快捷键将使用样式显示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中受益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果您在某个主题上具有专业知识,并且您对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经是 Packt 图书的骄傲拥有者,我们有许多事情可以帮助您从购买中获得最大收益。

下载示例代码

您可以从您的账户中下载本书的示例代码文件www.packtpub.com。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support,并注册以将文件直接发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载和勘误

  4. 搜索框中输入本书的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买本书的来源。

  7. 点击代码下载

文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/LearningSwiftSecondEdition_ColorImages.pdf下载此文件。

勘误

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入本书的名称。所需信息将在勘误部分显示。

侵权

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过链接<版权@packtpub.com>与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,请通过链接<问题@packtpub.com>与我们联系,我们将尽力解决问题。

第一章. 介绍 Swift

你通过阅读这本书想要达到什么目标?学习 Swift 可以很有趣,但对我们大多数人来说,我们试图实现的目标更大。我们想要创造一些东西,想要追求一份职业,或者可能完全是其他事情。无论那个目标是什么,我鼓励你在阅读这本书时牢记它。如果你能始终将其与你的目标联系起来,那么从这本书或其他任何资源中学习都会更容易。

在此之前,在我们深入学习 Swift 之前,我们必须了解它到底是什么,以及它将如何帮助我们实现目标。我们还需要采取有效的学习技巧,并提前尝尝即将到来的滋味。为了做到这一切,我们将在本章中涵盖以下主题:

  • 确定本书的学习目标

  • 设置开发环境

  • 运行我们的第一个 Swift 代码

  • 理解游乐场

  • 使用本书学习

确定本书的学习目标

Swift 是由苹果公司开发的一种编程语言,主要是为了让开发者能够继续推动他们平台的进步。这是他们试图使 iOS、OS X、watchOS 和 tvOS 应用程序开发更加现代化、安全、强大的尝试。

然而,苹果公司也将 Swift 作为开源软件发布,并开始努力为 Linux 添加支持,目的是使 Swift 更加完善,并使其成为无处不在的通用编程语言。一些开发者已经开始使用它来创建命令行脚本,作为现有脚本语言(如 Python 或 Ruby)的替代品或补充,许多人迫不及待地想要能够将他们的一些应用程序代码与 Web 后端代码共享。至少目前,苹果公司的优先事项是使其成为最好的语言,以促进应用程序的开发。然而,最重要的是要记住,现代应用程序开发几乎总是需要将多个平台整合到单一用户体验中。如果一种语言能够弥合这些差距,并且编写起来既愉快又安全、高效,我们将更容易制作出令人惊叹的产品。Swift 正在朝着实现这一目标迈进。

现在,重要的是要注意,学习 Swift 只是开发的第一步。为了为设备开发,你必须学习设备制造商提供的编程语言和框架。掌握一种编程语言是提高使用框架和最终构建应用程序技能的基础。

开发软件就像制作一张桌子。你可以学习木工的基础知识,并用几块木头钉在一起制作一个功能性的桌子,但你所能做的非常有限,因为你缺乏高级木工技能。如果你想制作一张真正优秀的桌子,你需要离开桌子,首先专注于提升你的技能集。你使用工具的能力越强,你能够创造更高级、更高品质家具的可能性就越大。同样,在 Swift 的知识非常有限的情况下,你可以从网上找到的代码开始拼凑一个功能性的应用程序。然而,要真正做出一些伟大的东西,你必须投入时间和精力来完善你的技能集。你学习的每一个语言特性或技术都会为你打开更多应用程序的可能性。

话虽如此,大多数开发者都是被创造事物和解决问题的热情所驱动。当我们能够将我们的热情转化为真正改善自己和周围世界时,我们学得最好。我们不想陷入学习语言细节的困境中,而这些细节没有任何实际用途。

这本书的目标是培养你的技能和信心,让你充满热情地投入到创建引人入胜、可维护和优雅的 Swift 应用程序中。为了做到这一点,我们将以实用的方式介绍 Swift 的语法和特性。你将构建一个丰富的工具集,同时看到这个工具集在实际世界中的应用。所以,无需多言,让我们直接进入设置我们的开发环境。

设置开发环境

为了使用 Swift,你需要运行 OS X,这是所有 Mac 电脑都预装的操作系统。你唯一需要的软件叫做 Xcode(版本 7 及以上)。这是苹果提供的环境,它促进了其平台上的开发。你可以从 Mac App Store 免费下载 Xcode,网址为 www.appstore.com/mac/Xcode

下载并安装后,你可以打开应用程序,它将安装苹果开发者工具组件的其余部分。就这么简单!我们现在准备好运行我们的第一个 Swift 代码了。

运行我们的第一个 Swift 代码

我们将首先创建一个新的 Swift playground。正如其名所示,playground 是一个你可以玩转代码的地方。在 Xcode 打开的情况下,从菜单栏中选择 文件 | 新建 | Playground…,如图所示:

运行我们的第一个 Swift 代码

将其命名为 MyFirstPlayground,保持平台为 iOS,并保存到你希望的位置。

一旦创建,一个包含一些已预填充代码的 playground 窗口将出现:

运行我们的第一个 Swift 代码

你已经运行了你的第一个 Swift 代码。在 Xcode 中,每次你做出更改时,都会运行你的代码,并在屏幕右侧的侧边栏中显示代码结果。

让我们分析一下这段代码做了什么。第一行是一个注释,在运行时会被忽略。它可以在代码中添加额外的信息。在 Swift 中,有两种类型的注释:单行和多行。单行注释,如前面所述,总是以 // 开头。您也可以通过用 /**/ 包围它们来写跨多行的注释。例如:

/*
   This is a multi-line comment
   that takes up more than one line
   of code
*/

如前一个截图所示,第二行 import UIKit 导入了一个名为 UIKit 的框架。UIKit 是苹果为 iOS 开发提供的框架的名称。对于这个例子,我们实际上并没有使用 UIKit 框架,所以可以安全地完全删除这一行代码。

最后,在最后一行,代码定义了一个名为 str 的变量,并将其分配给文本 "Hello, playground"。在结果侧边栏中,紧邻这一行,您可以看到 "Hello, playground" 确实被存储在变量中。随着您的代码变得越来越复杂,这将变得非常有用,可以帮助您跟踪和监视代码的运行状态。每次您对代码进行更改时,结果都会更新,显示更改的结果。

如果您熟悉其他编程语言,许多语言都需要某种类型的行终止符。在 Swift 中,您不需要这样的东西。

Xcode 游乐场的另一个优点是它们会在您输入时显示错误。让我们向游乐场添加第三行:

  var str = "Something Else"

独立来看,这是一段完全有效的 Swift 代码。它将文本 "Something Else" 存储到一个名为 str 的新变量中。然而,当我们将其添加到游乐场中时,我们会看到一行旁边的红色感叹号形式的错误。如果您点击感叹号,将会显示完整的错误信息:

运行我们的第一个 Swift 代码

这一行被高亮显示为红色,并显示 'str' 的无效重新声明 错误。这是因为您不能声明两个具有完全相同名称的不同变量。此外,注意右侧的结果变成了灰色而不是黑色。这表明显示的结果不是来自最新的代码,而是来自上一次成功的代码运行。由于错误,代码无法成功运行以创建新的结果。如果我们将第二个变量更改为 strTwo,错误就会消失:

小贴士

下载示例代码

您可以从 www.packtpub.com 的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  • 使用您的电子邮件地址和密码登录或注册我们的网站。

  • 将鼠标指针悬停在顶部的“支持”选项卡上。

  • 点击“代码下载与勘误”。

  • 在搜索框中输入书籍名称。

  • 选择你想要下载代码文件的书籍。

  • 从下拉菜单中选择你购买这本书的地方。

  • 点击代码下载。

一旦文件下载完成,请确保使用最新版本解压缩或提取文件夹:

  • WinRAR/7-Zip(适用于 Windows)

  • Zipeg/iZip/UnRarX(适用于 Mac)

  • 7-Zip/PeaZip(适用于 Linux)

运行我们的第一个 Swift 代码

现在结果再次以黑色显示,我们可以看到它们已经根据最新的代码进行了更新。特别是如果你有其他编程环境的经验,游乐场的反应性可能会让你感到惊讶。让我们揭开盖子,更好地了解正在发生的事情以及 Swift 是如何工作的。

理解游乐场

游乐场并不是真正的程序。虽然它像程序一样执行代码,但在开发环境之外并不真正有用。在我们理解游乐场为我们做了什么之前,我们首先必须了解 Swift 是如何工作的。

Swift 是一种编译型语言,这意味着 Swift 代码要运行,必须首先转换成计算机可以实际执行的形式。执行这种转换的工具称为编译器。编译器实际上是一个程序,也是定义编程语言的一种方式。

Swift 编译器将 Swift 代码作为输入,如果它可以正确解析和理解代码,就会输出机器代码。苹果开发了 Swift 编译器,以便根据一系列规则理解代码。这些规则定义了 Swift 编程语言,这些规则就是我们所说的学习 Swift 时试图学习的。

一旦生成机器代码,Xcode 可以将机器代码封装在应用程序中,用户可以运行。然而,我们在游乐场中运行 Swift 代码,所以显然构建应用程序并不是运行代码的唯一方式;这里还有其他的事情在进行。

每次你对游乐场进行更改时,它都会自动尝试编译你的代码。如果成功,它不会将机器代码封装在应用程序中以供稍后运行,而是立即运行代码并显示结果。如果你必须自己完成这个过程,你首先必须自觉地决定将代码构建到应用程序中,然后在你想测试某些内容时运行它。这将是一种巨大的时间浪费;特别是,如果你编写了一个错误,直到你决定真正运行它时才被发现。你越快看到代码更改的结果,你开发代码的速度就越快,你犯的错误就越少。

现在,我们将所有代码都在游乐场中开发,因为它是一个出色的学习环境。游乐场甚至比我们迄今为止看到的更强大,随着我们深入探索 Swift 语言,我们将看到这一点。

我们几乎准备好进入学习 Swift 的核心内容了,但在那之前,让我们花一点时间确保你能最大限度地利用这本书。

使用这本书学习

这本书的学习过程非常贴近 playgrounds 背后的哲学。如果你能围绕我们讨论的代码和想法进行探索,你将能从这本书中获得最大收益。不要只是被动地阅读,匆匆浏览代码,而是将代码放入 playground 中,观察它是如何真正工作的。修改代码,尝试破坏它,尝试扩展它,这样你会学到更多。如果你有问题,不要默认去查找答案,而是亲自尝试。

在其核心,编程是一种创造性的练习。是的,它需要你具备逻辑思考问题的能力,但十有八九并没有正确的方式,也没有正确的答案。技术是由那些不愿满足于既定解决方案、不满足于遵循固定指令集、想要突破边界的人推动的。随着我们学习 Swift 的进程,通过不让这本书和 Swift 仅停留在表面价值,让这本书为你所用。

概述

我们已经有一个良好的开端。我们已经了解了 Swift 是一种主要设计用于应用开发的编程语言,这通常包括多个不同的平台。我们已经运行了我们的第一个代码,并了解了一些关于计算机如何间接运行它的知识——首先将其编译成它理解的形式。最重要的是,我们已经了解到,通过设定目标并随着阅读探索概念,你将在这本书中学到最多。那么,让我们开始吧!

接下来,我们将开始分解 Swift 的基础知识,然后将它们组合起来制作我们的第一个程序。

第二章. 构建块 – 变量、集合和流程控制

编程中最酷的事情之一是概念是如何相互构建的。如果你以前从未编写过任何程序,即使是功能最基本的应用程序也可能看起来非常复杂。现实是,如果你将应用程序中发生的一切分析到处理器中流动的零和一,它是非常复杂的。然而,使用计算机的每个方面都是一个抽象。当你使用应用程序时,编程的复杂性正在为你抽象化。学习编程就是深入一层,让计算机为你工作。

随着你学习编程背后的基本概念,它们将变得像第二本能一样自然,这将使你的思想能够把握更复杂的概念。当你第一次学习阅读时,逐个发音每个单词是具有挑战性的。然而,最终你会达到一个水平,你只需瞥一眼一个单词,就能立刻知道它的意思。这让你能够开始从文本中寻找更深层次的意义。

在本章中,我们将构建你在 Swift 中编程构建块的知识。每个构建块本身都很有趣,随着我们开始看到它们开启的可能性,它们将变得更加有趣。无论编程现在对你来说可能多么复杂,我保证有一天你会回过头来,惊叹于所有这些概念是如何变得像第二本能一样的。

在本章中,我们将涵盖:

  • 核心 Swift 类型

  • Swift 的类型系统

  • 打印到控制台

  • 控制程序流程

  • 所有涵盖概念的综合示例

核心 Swift 类型

每种编程语言都需要命名信息的一部分,以便以后可以引用它。这是代码在编写后保持可读性的基本方法。Swift 提供了一系列核心类型,帮助你以非常易于理解的方式表示你的信息。

常数和变量

Swift 提供了两种类型的信息:常数变量

// Constant
let pi = 3.14

// Variable
var name = "Sarah"

所有常数都是使用 let 关键字后跟一个名称来定义的,所有变量都是使用 var 关键字来定义的。Swift 中的常数和变量在使用之前必须包含一个值。这意味着,当你定义一个新的时,你很可能会给它一个初始值。你是通过使用赋值运算符(=)后跟一个值来做到这一点的。

两个之间的唯一区别是常数永远不能改变,而变量可以改变。在先前的例子中,代码定义了一个名为 pi 的常数,它存储了信息 3.14,以及一个名为 name 的变量,它存储了信息 "Sarah"。将 pi 定义为常数是有意义的,因为 pi 将始终是 3.14。然而,我们需要在未来更改 name 的值,所以我们将其定义为变量。

管理程序中最困难的部分之一是所有变量的状态。作为程序员,即使在相对较小的程序中,通常也不可能计算出变量可能具有的所有不同值。由于变量通常可以被遥远的、看似无关的代码更改,更多的状态会导致更多难以追踪的 bug。始终最好默认使用常量,直到您遇到需要修改信息值的实际场景。

容器

给更复杂的信息命名通常很有帮助。我们经常必须处理一系列相关的信息或一系列类似的信息,如列表。Swift 提供了三种主要的集合类型,称为元组数组字典

元组

元组是由两个或更多信息片段组成的固定大小集合。例如,一副扑克牌中的一张牌有三个属性:颜色花色数值。我们可以使用三个单独的变量来完全描述一张牌,但最好在一个表达式中表达它:

var card = (color: "Red", suit: "Hearts", value: 7)

每条信息由一个名称和一个值组成,名称和值之间用冒号(:)分隔,每个值之间用逗号(,)分隔。最后,整个结构被括号(())包围。

您可以使用点(.)通过名称单独访问元组的每个部分,这通常被称为点:

card.color // "Red"
card.suit // "Hearts"
card.value // 7

您还可以为每个部分创建一个没有名称的元组。然后,您可以根据它们在列表中的位置访问它们,从零开始作为第一个元素:

var diceRoll = (4, 6)
diceRoll.0 // 4
diceRoll.1 // 6

访问元组中的特定值的另一种方法是分别捕获每个值:

let (first, second) = diceRoll
first // 4
second // 6

如果您想在元组中更改一个值,您可以一次分配所有值,或者可以使用与前面代码相同的引用更新单个值:

diceRoll = (4, 5)
diceRoll.0 = 2

数组

数组本质上是一个可变长度的信息列表。例如,我们可以创建一个列表,列出我们想要邀请参加派对的人,如下所示:

var invitees = ["Sarah", "Jamison", "Marcos", "Roana"]

数组始终以方括号开始和结束,每个元素之间用逗号分隔。您甚至可以使用开闭括号声明一个空数组:[]

您可以通过向数组中添加另一个数组来向数组中添加值,如下所示:

invitees += ["Kai", "Naya"]

注意,+=是以下内容的缩写:

invitees = invitees + ["Kai", "Naya"]

您可以根据数组中元素的索引位置访问数组中的值,通常称为索引,如下所示:

invitees[2] // Marcos

索引是通过在数组名称后立即使用方括号([])来指定的。索引从0开始,像元组一样向上递增。因此,在前面的例子中,索引2返回了数组中的第三个元素,Marcos。您还可以检索有关数组的其他信息,例如随着我们向前移动可以看到的元素数量。

字典

字典是一组的集合。键用于在容器中存储和查找特定的值。这种容器类型以单词“dictionary”命名,你可以在其中查找单词的定义。在那个现实生活中的例子中,单词将是键,定义将是值。作为一个例子,我们可以定义一个按类型组织的电视节目字典:

var showsByGenre = [
   "Comedy": "Modern Family",
   "Drama": "Breaking Bad",
]

字典看起来与数组相似,但每个键和值都由冒号(:)分隔。请注意,Swift 对空白的使用相当宽容。数组可以定义为每个元素单独一行,字典可以定义为每行包含一个元素。使用空白来使你的代码尽可能可读是你的责任。

如上所示定义的字典,如果你查找键Comedy,你会得到值Modern Family。在代码中访问值的方式类似于在数组中,但你不是在方括号中提供索引,而是提供键:

showsByGenre["Comedy"] // Modern Family

你可以像定义空数组一样定义一个空字典,但使用字典时,你必须在括号之间包含一个冒号:[:]

向字典中添加值的方式类似于检索值,但你使用赋值运算符(=):

showsByGenre["Variety"] = "The Colbert Report"

作为额外的好处,这也可以用来更改现有键的值。

你可能已经注意到,我所有的变量和常量名称都以小写字母开头,并且每个后续单词都以大写字母开头。这被称为驼峰式命名法,并且是编写变量和常量名称的广泛接受的方式。遵循此约定使得其他程序员更容易理解你的代码。

现在我们已经了解了 Swift 的基本容器,让我们更详细地探讨它们是什么。

Swift 的类型系统

Swift 是一种强类型语言,这意味着每个常量和变量都使用特定的类型进行定义。只有匹配类型的值可以分配给它们。到目前为止,我们已经利用了 Swift 的一个名为类型推断的功能。这意味着如果可以在声明时从分配给它的值中推断出类型,则代码不需要显式声明类型。

没有类型推断的情况下,之前的name变量声明将写成如下所示:

var name: String = "Sarah"

这段代码明确地将name声明为类型String,其值为Sarah。可以通过在名称后添加一个冒号(:)和类型来指定常量或变量的类型。

字符串由一系列字符组成。这对于存储文本来说非常完美,就像我们名称的例子一样。我们不需要指定类型的原因是Sarah是一个字符串字面量。被引号包围的文本是字符串字面量,可以推断其类型为String。这意味着如果你将其初始值设置为Sarah,则name必须为String类型。

类似地,如果我们没有为其他变量声明使用类型推断,它们看起来会是这样:

let pi: Double = 3.14

var invitees: [String] = ["Sarah", "Jamison", "Roana"]

let showsByGenre: [String:String] = [
    "Comedy": "Modern Family",
    "Drama": "Breaking Bad",
]

Double是一种可以存储小数的数值类型。数组的类型通过将存储的元素类型放在方括号中来声明。最后,字典的类型以[KeyType:ValueType]的形式定义。所有这些类型都可以推断,因为每个类型都被分配了一个具有可推断类型的值。

如果我们像原始示例那样省略类型,代码会更干净、更容易理解。只需记住,这些类型始终隐含存在,即使它们没有明确写出。如果我们尝试将一个数字赋值给name变量,我们会得到一个错误,如下所示:

Swift 的类型系统

在这里,我们试图将一个数字,特别是Int,赋值给被推断为String类型的变量。Swift 不允许这样做。

当处理推断类型时,询问 Xcode 一个变量被推断为何种类型非常有用。你可以通过按住键盘上的Option键并点击变量名称来实现。这将显示一个如下所示的弹出窗口:

Swift 的类型系统

如预期的那样,变量确实被推断为String类型。

类型是 Swift 的一个基本组成部分。它们是 Swift 作为编程语言如此安全的主要原因之一。它们帮助编译器更多地了解你的代码,因此,编译器可以在不运行你的代码的情况下自动警告你关于错误。

打印到控制台

将输出写入日志非常有用,这样你可以追踪代码的行为。随着代码库的复杂性增加,很难追踪事情发生的顺序以及数据在代码中流动时的确切样子。游乐场在这方面有很大帮助,但并不总是足够。

在 Swift 中,这个过程被称为打印到控制台。要这样做,你使用一个叫做print的东西。它是通过写入print后跟括号内的文本来使用的。例如,要将Hello World!打印到控制台,代码看起来会是这样:

print("Hello World!")

如果你将这段代码放入游乐场,你会在结果面板中看到Hello World!被写入。然而,这并不是真正的控制台。要查看控制台,你可以转到视图 | 调试区域 | 显示调试区域。窗口底部将出现一个新的视图,其中包含代码打印到控制台的所有文本:

打印到控制台

你不仅可以打印静态文本到控制台,还可以打印出任何变量。例如,如果你想打印出name变量,你会这样写:

print(name)

你甚至可以使用 Swift 的一个功能,称为字符串插值,将变量插入到字符串中,如下所示:

print("Hello \(name)!")

在字符串字面量的任何位置,即使不是在打印时,你都可以通过将代码用 \() 包围起来来插入代码的结果。通常这将是变量的名称,但它可以是任何返回值的代码。

当我们开始使用更复杂的代码时,向控制台打印信息就更加有用。

控制流程

如果一个程序只是一系列固定的命令列表,总是做同样的事情,那么它就不会很有用。使用单一的代码路径,计算器应用只能执行一个操作。我们可以做很多事情来使应用更强大,并收集数据以决定下一步做什么。

条件语句

控制程序流程的最基本方法是指定只有在满足特定条件时才应该执行的代码。在 Swift 中,我们使用 if 语句来实现这一点。让我们看看一个例子:

if invitees.count > 20 {
   print("Too many people invited")
}

从语义上看,前面的代码是:如果受邀人数大于 20,则打印 'Too many people invited'。这个例子如果条件为真,则只执行一行代码,但你可以在花括号 {} 内放置尽可能多的代码。

任何可以评估为真或假的任何内容都可以用在 if 语句中。然后你可以使用 else if 和/或 else 将多个条件链接在一起:

if invitees.count > 20 {
    print("Too many people invited")
}
else if invitees.count <= 3 {
    print("Not really a party")
}
else {
    print("Just right")
}

每个条件都会从上到下进行检查,直到满足某个条件。在这一点上,代码块将被执行,剩余的条件将被跳过,包括最后的 else 块。

作为一项练习,我建议在前面代码的基础上添加一个额外的场景,如果恰好没有受邀者,则打印 "One is the loneliest number"。你可以通过调整添加到 invitees 声明中的受邀者数量来测试你的代码。记住,条件的顺序非常重要。

尽管条件语句很有用,但如果有很多条件链接在一起,它们可能会变得非常冗长。为了解决这类问题,还有一种名为 switch 的控制结构。

switch

switch 是编写一系列 if 语句的更表达性的方式。条件语句部分的示例的直接翻译如下:

switch invitees.count {
    case let x where x > 20:
        print("Too many people invited")
    case let x where x <= 3:
        print("Not really a party")
    default:
        print("Just right")
}

switch 由一个值和该值的条件列表组成,如果条件为真,则执行相应的代码。要测试的值紧跟在 switch 命令之后,所有条件都包含在花括号 {} 中。每个条件称为一个 case。使用这个术语,前面代码的语义是:“考虑到受邀人数,如果它大于 20,则打印 "Too many people invited",否则,如果它小于或等于三个,则打印 "Too many people invited",否则,默认打印 "Just right"

这是通过创建一个临时常量x来实现的,该常量被赋予开关测试的值。然后它对x进行测试。如果条件通过,它将执行该情况的代码,然后退出开关。

就像在条件语句中一样,只有当所有前面的情况都不满足时,才会考虑每个情况。与条件语句不同,所有的情况都需要穷尽。这意味着你需要为变量可能传递的每个可能的值设置一个情况。例如,invitees.count是一个整数,所以理论上可以是负无穷大到正无穷大之间的任何值。

处理这种情况最常见的方式是使用由default关键字指定的默认情况。有时,你实际上不想在默认情况下做任何事情,甚至可能在特定情况下也不做。为此,你可以使用break关键字,如下所示:

switch invitees.count {
    case let x where x > 20:
        print("Too many people invited")
    case let x where x <= 3:
        print("Not really a party")
    default:
        break
}

注意,默认情况必须始终是最后一个。

到目前为止,我们已经看到开关很棒,因为它们强制执行穷尽条件的规则。这对于让编译器为你捕获错误非常有用。然而,开关也可以更加简洁。我们可以像这样重写前面的代码:

switch invitees.count {
    case 0...3:
        print("Not really a party")
    case 4...20:
        print("Just right")
    default:
        print("Too many people invited")
}

在这里,我们将每种情况描述为可能值的范围。第一种情况包括介于03之间(包括这两个数)的所有值。这比使用where子句表达得更加丰富。这个例子也展示了逻辑的重新思考。我们不是为超过20的值设置特定的情况,而是为已知的闭区间设置情况,然后在默认情况下捕获超过20的所有情况。请注意,这个版本的代码没有正确处理计数可能为负的情况,而原始版本可以处理。在这个版本中,如果计数是-1,它将一直通过到默认情况并打印出"Too many people invited"。对于这个用例来说,这是可以接受的,因为数组的计数永远不会是负数。

开关不仅与数字一起工作。它们非常适合执行任何类型的测试:

switch name {
    case "Marcos", "Amy":
       print("\(name) is an honored guest")
    case let x where x.hasPrefix("A"):
        print("\(name) will be invited first")
        fallthrough
    default:
        print("\(name) is someone else")
}

这段代码展示了开关的一些其他有趣特性。第一种情况实际上由两个单独的条件组成。每个情况可以有任意数量的条件,条件之间用逗号(,)分隔。当你有多个情况想要使用相同的代码时,这很有用。

第二种情况使用自定义测试对名称进行检查,以查看它是否以字母 A 开头。这对于展示开关的执行方式非常出色。尽管字符串Amy会满足第二个条件,但这段代码只会打印出"Amy is an honored guest",因为一旦第一个条件得到满足,就不会再评估其他情况。现在,如果你不完全理解hasPrefix是如何工作的,请不要担心。

最后,第二种情况使用了 fallthrough 关键字。这告诉程序执行下一个情况的代码。重要的是,这绕过了下一个情况的条件;无论值是否通过条件,代码仍然会被执行。

为了确保你理解 switch 的执行方式,将以下代码放入游乐场中,并尝试预测使用各种名称时将打印出什么内容:

let testName = "Andrew"
switch testName {
    case "Marcos", "Amy":
        print("\(testName) is an honored guest")
    case let x where x.hasPrefix("A"):
        print("\(testName) will be invited first")
        fallthrough
    case "Jamison":
        print("\(testName) will help arrange food")
    default:
        print("\(testName) is someone else")
}

一些值得尝试的好名字有 AndrewAmyJamison

现在我们完全控制了在什么情况下执行哪种代码。然而,一个程序通常需要我们多次执行相同的代码。例如,如果我们想要对数组中的每个元素执行一个操作,复制和粘贴一大堆代码是不可行的。相反,我们可以使用称为 循环 的控制结构。

循环

有许多不同类型的循环,但它们都会在条件不再为真时重复执行相同的代码。最基本类型的循环称为 while 循环:

var index = 0
while index < invitees.count {
    print("\(invitees[index]) is invited")

    index+=1
}

while 循环由一个用于测试的条件和直到该条件失败为止要运行的代码组成。在上面的例子中,我们遍历了 invitees 数组中的每个元素。我们使用变量 index 来跟踪我们当前在哪个邀请者。为了移动到下一个索引,我们使用了一个新的运算符 +=,它将一个加到现有值上。这和写 index = index + 1 是一样的。

关于这个循环有两个重要的事情需要注意。首先,我们的索引从 0 开始,而不是 1,并且它一直持续到小于邀请者的数量,而不是小于或等于它们。这是因为,如果你记得,数组索引从 0 开始。如果我们从 1 开始,我们会错过第一个元素,如果我们包含了 invitees.count,代码会崩溃,因为它会尝试访问数组末尾之外的元素。始终记住:数组的最后一个元素在索引上比计数少一个

另一点需要注意的是,如果我们忘记在循环中包含 index+=1,我们就会有一个无限循环。循环将永远继续运行,因为 index 从不会超过 invitees.count

这种想要遍历列表的模式如此常见,以至于有一个更简洁、更安全的循环称为 for-in 循环:

for invitee in invitees {
    print("\(invitee) is invited")
}

现在事情变得相当酷了。我们不再需要担心索引。没有意外从 1 开始或越过末尾的风险。此外,我们可以在遍历数组时给特定的元素起自己的名字。需要注意的是,我们没有用 letvar 声明 invitee 变量。这特别适用于 for-in 循环,因为那里使用的常量每次通过循环都会被新声明。

for-in 循环非常适合遍历不同类型的容器。它们也可以用来遍历字典,如下所示:

for (genre, show) in showsByGenre {
    print("\(show) is a great \(genre) series")
}

在这种情况下,我们可以访问字典的键和值。这应该看起来很熟悉,因为(genre, show)实际上是一个元组,用于循环的每次迭代。在确定是否从类似数组或元组的for-in循环中有一个单一值时可能会感到困惑。在这个时候,最好记住这两个常见的案例。背后的原因将在我们开始讨论第六章中的序列时变得清晰,让 Swift 为你工作 – 协议和泛型

for-in循环的另一个特性是能够只遍历通过给定测试的元素。你可以使用if语句实现这一点,但 Swift 提供了一个更简洁的方式来写它,使用where关键字:

for invitee in invitees where invitee.hasPrefix("A") {
    print("\(invitee) is invited")
}

现在,循环将只为以字母A开头的每个邀请人运行。

这些循环很棒,但有时我们需要访问当前所在的索引,而在其他时候,我们可能想要遍历一组数字而不使用数组。为此,我们可以使用类似于Switchrange,如下所示:

for index in 0 ..< invitees.count {
    print("\(index): \(invitees[index])")
}

此代码使用变量index从值0运行到但不包括invitees.count。实际上有两种类型的范围。这种类型被称为半开范围,因为它不包括最后一个值。另一种类型,我们在开关中看到的那种类型,被称为闭包范围

print("Counting to 10:")
for number in 1 ... 10 {
    print(number)
}

闭包范围包括最后一个值,因此循环将打印出从1开始到10结束的每个数字。

所有循环都有两个特殊关键字,可以修改它们的行为,这些关键字被称为continuebreakcontinue用于跳过循环的其余部分,并返回到条件以查看是否应该再次运行循环。例如,如果我们不想打印以A开头的邀请人,我们会使用以下代码:

for invitee in invitees {
    if invitee.hasPrefix("A") {
        continue
    }
    print("\(invitee) is invited")
}

如果条件invitee.hasPrefix("A")得到满足,continue命令将被执行,并且它会跳过循环的其余部分,转到下一个邀请人。正因为如此,只有不以A开头的邀请人会被打印出来。

break关键字用于立即退出循环:

for invitee in invitees {
   print("\(invitee) is invited")

   if invitee == "Tim" {
       print("Oh wait, Tim can't come")
       break
   }
}
print("Jumps here")

一旦遇到break,执行就会跳转到循环之后。在这种情况下,它跳转到最后一行。

循环非常适合处理可变数量的数据,比如我们的邀请人列表。在编写代码时,你可能不知道列表中会有多少人。使用循环可以让你灵活地处理任何长度的列表。

作为练习,我建议你尝试编写一个循环来找出 10,000 以下所有 3 的倍数的总和。你应该得到 16,668,333。

循环也是重用代码而不重复代码的绝佳方式,但它们只是高质量代码重用的第一步。接下来,我们将讨论函数,这将开启一个全新的可理解和可重用代码的世界。

函数

我们迄今为止探索的所有代码都非常线性地沿着文件排列。每一行一次被处理,然后程序继续执行下一行。这是编程的伟大之处之一:程序所做的每一件事都可以通过你自己逐行地 mentally stepping through 程序来预测。

然而,随着你的程序变得越来越大,你会注意到有些地方重复使用非常相似或相同的代码,而这些代码不能通过使用循环来重用。此外,你写的代码越多,就越难确切知道它在做什么。代码注释可以帮助解决这个问题,但有一个更好的解决方案可以解决这两个问题,它们被称为函数。函数本质上是一组可以被执行和通过该名称重用的代码集合。

存在着各种不同类型的函数,但每种类型都是建立在之前类型的基础上的。

基本函数

最基本的函数类型仅仅有一个名称和一些稍后要执行的静态代码。让我们看看一个简单的例子。以下代码定义了一个名为 sayHello 的函数:

func sayHello() {
    print("Hello World!")
}

函数是通过使用关键字 func 后跟一个名称和括号(())来定义的。要在函数中运行的代码被大括号({})包围。就像在循环中一样,一个函数可以由任意数量的代码行组成。

从我们对打印的了解中,我们知道这个函数将打印出文本 Hello World!。然而,它将在什么时候做这件事呢?用于告诉函数执行的操作的术语是“调用函数”。你通过使用函数的名称后跟括号(())来调用函数:

sayHello() // Prints "Hello World!"

这是一个非常简单的函数,虽然它并不那么有用,但我们已经可以看到函数的一些非常显著的好处。实际上,当你调用这个函数时,执行会进入函数内部,当它执行完函数中的每一行代码后,它会退出并从函数被调用的地方继续执行。然而,作为程序员,我们通常并不关心函数内部发生的事情,除非出了什么问题。如果函数命名得当,它们会告诉你它们将做什么,这就是你需要知道的所有信息,以便理解代码的其余部分。实际上,命名良好的函数几乎可以替代代码中的注释,这真的减少了代码的杂乱,同时又不损害代码的可读性。

这个函数相较于直接使用 print 的另一个优点是代码的可维护性更高。如果你在代码的多个地方使用 print,然后改变你想要表达Hello的方式,你不得不更改很多代码。然而,如果你使用上述这样的函数,你可以通过更改函数来轻松地改变它表达Hello的方式,并且它将在你使用该函数的每个地方都相应地改变。

你可能已经注意到,我们在命名sayHello函数和使用print的方式上有一些相似之处。这是因为print是 Swift 本身内置的一个函数。print函数中包含复杂的代码,使得向控制台打印变得可能并且对所有程序员都是可访问的。但是,print能够接受一个值并对其进行操作,我们如何编写这样的函数呢?答案是:参数。

参数化函数

一个函数可以接受零个或多个参数,这些是输入值。让我们修改我们的sayHello函数,使其能够使用字符串插值对任意名称说Hello

func sayHelloToName(name: String) {
    print("Hello \(name)!")
}

现在我们这个函数接受一个任意参数,称为name,类型为String,并向其打印hello。这个函数的名字现在是sayHelloToName:。我们没有包含参数名,因为当你调用方法时,默认情况下不会使用第一个参数的名称:

sayHelloToName("World") // Prints "Hello World!"

我们在名称的末尾包含了一个冒号(:),以表示它在那里接受一个参数。这使得它与不接收参数的sayHelloToName函数不同。命名可能看起来不重要且随意,但确保我们都能使用共同和精确的术语来交流我们的代码非常重要,这样我们才能更有效地相互学习和合作。

如前所述,一个函数可以接受多个参数。参数列表看起来很像一个元组。每个参数都有一个名称和一个类型,由冒号(:)分隔,然后由逗号(,)分隔。除此之外,函数不仅可以接受值,还可以向调用代码返回值。

返回值的函数

函数返回值的类型定义在所有参数之后,由箭头->分隔。让我们编写一个函数,它接受一个邀请者列表和另一个要添加到列表中的人。如果有空位,该函数会将这个人添加到列表中,并返回新版本。如果没有空位,它就只返回原始列表,如下所示:

func addInviteeToListIfSpotAvailable
    (
    invitees: [String],
    newInvitee: String
    )
    -> [String]
{
    if invitees.count >= 20 {
        return invitees
    }
    return invitees + [newInvitee]
}

在这个函数中,我们测试了邀请者列表上的名称数量,如果它大于 20,我们就返回传递给invitees参数的相同列表。请注意,return在函数中的使用方式与在循环中break的使用方式相似。一旦程序执行了返回的行,它就会退出函数,并将该值提供给调用代码。因此,最后的return行只有在if语句未通过时才会执行。然后它将newinvitee参数添加到列表中,并将其返回给调用代码。

你可以这样调用这个函数:

var list = ["Sarah", "Jamison", "Marcos"]
var newInvite = "Roana"
list = addInviteeToListIfSpotAvailable(list, newInvite: newInvitee)

需要注意的是,我们必须将函数返回的值赋给list,因为新值可能会被函数修改。如果我们不这样做,列表将不会有任何变化。

如果你尝试在游乐场中输入这段代码,你会注意到一个非常酷的现象。当你开始输入函数名时,你会看到一个小的弹出窗口,建议你可能想要输入的函数名,如下所示:

返回值的函数

你可以使用箭头键在列表中上下移动以选择你想要输入的函数,然后按 Tab 键让 Xcode 帮你完成函数的输入。不仅如此,它还会突出显示第一个参数,这样你就可以立即开始输入你想要传递的内容。当你完成第一个参数的定义后,你可以再次按 Tab 键来移动到下一个参数。这大大提高了你编写代码的速度。

这是一个相当好命名的函数,因为它清楚地说明了它的功能。然而,我们可以通过使其更像一个句子来给它一个更自然、更富有表现力的名称:

func addInvitee
    (
    invitee: String,
    ifPossibleToList invitees: [String]
    )
    -> [String]
{
    if invitees.count >= 20 {
        return invitees
    }
    return invitees + [invitee]
}
list = addInvitee(newInvite, ifPossibleToList: list)

这是 Swift 的一项伟大特性,允许你使用带命名参数的函数。我们可以通过给第二个参数两个名称,用空格分隔来实现这一点。第一个名称是在调用函数时要使用的名称,也称为外部名称。第二个名称是在函数内部引用传入的常量时要使用的名称,也称为内部名称。作为一个练习,尝试更改函数,使其使用相同的内外部名称,并查看 Xcode 的建议。更具挑战性的是,编写一个函数,它接受一个邀请人列表和一个特定邀请人的索引,以便写一条消息请他们只带自己。例如,对于前面列表中的索引 0,它会打印 Sarah, just bring yourself

带有默认参数的函数

有时我们编写的函数中有一个参数通常具有相同的值。如果能提供一个参数值,以便调用者没有覆盖该值时使用,那就太好了。Swift 有一个名为默认参数的特性。要为参数定义一个默认值,你只需在参数后添加一个等号,然后跟上一个值。我们可以在 sayHelloToName: 函数中添加一个默认参数,如下所示:

func sayHelloToName(name: String = "World") {
    print("Hello \(name)!")
}

这意味着我们现在可以带或不带指定名称调用这个函数:

sayHelloToName("World") // Prints "Hello World!"
sayHelloToName() // Also Print "Hello World!"

当使用默认参数时,参数的顺序变得不再重要。我们可以在我们的 addInvitee:ifPossibleToList: 函数中添加默认参数,然后以任何组合或顺序调用它:

func addInvitee
    (
    invitee: String = "Default Invitee",
    ifPossibleToList invitees: [String] = []
    )
    -> [String]
{
    // ...
}
list = addInvitee(ifPossibleToList: list, newInvite)
list = addInvitee(newInvite, ifPossibleToList: list)
list = addInvitee(ifPossibleToList: list)
list = addInvitee(newInvite)
list = addInvitee()

显然,当以相同的顺序编写时,调用仍然读起来更好,但并非所有函数都是这样设计的。这个特性的最重要部分是,你可以指定你想要与默认值不同的参数。

保护语句

我们将要讨论的函数的最后一个特性是另一种称为guard语句的条件类型。我们之前没有讨论它,因为它除非在函数或循环中使用,否则没有太多意义。guard语句的行为与if语句类似,但编译器强制你提供一个else条件,该条件必须从函数、循环或switch案例中退出。让我们重新设计我们的addInvitee:ifPossibleToList:函数,看看它是什么样子:

func addInvitee
    (
    invitee: String,
    ifPossibleToList invitees: [String]
    )
    -> [String]
{
    guard invitees.count < 20 else {
        return invitees
    }
    return invitees + [newInvitee]
}

从语义上讲,guard语句指示我们确保受邀人数少于 20 人,否则返回原始列表。这与我们之前使用的逻辑相反,当时如果有 20 人或更多受邀者,我们会返回原始列表。这种逻辑实际上更有意义,因为我们规定了先决条件并提供了失败路径。使用guard语句的另一个优点是我们不会忘记从else条件中返回。如果我们这样做,编译器会给我们一个错误。

重要的一点是,要注意guard语句没有在通过时执行的代码块。只能指定一个else条件,假设你想要为通过条件运行的任何代码将简单地跟在语句之后。这之所以安全,仅仅是因为编译器强制else条件退出函数,从而确保语句之后的代码不会运行。

总体来说,guard语句是一种很好的定义函数或循环先决条件的方法,而无需为通过情况缩进代码。对我们来说,这还不是什么大问题,但如果你有很多先决条件,通常会使缩进代码变得繁琐。

将所有内容结合起来

到目前为止,我们已经了解了 Swift 的基本工作原理。让我们花点时间将这些概念在单个程序中结合起来。我们还将看到一些我们所学内容的新变体。

程序的目标是接受一个受邀者列表和一个电视节目列表,并随机要求人们从每个类型中带一个节目。它还应要求其他人只带自己。

在我们查看代码之前,我将提到我将要使用的三个小新特性:

  • 生成随机数

  • 使用变量仅存储真或假

  • 重复-直到循环

最重要的特性是生成随机数的能力。为了做到这一点,我们必须导入Foundation框架。这是苹果提供的基本框架中最基础的框架。正如其名所示,它构成了 OS X 和 iOS 框架的基础。

Foundation 包含一个名为 rand 的函数,它返回一个随机数。实际上,计算机无法生成真正的随机数,并且默认情况下,rand 总是在相同的顺序返回相同的值。为了使它在每次程序运行时返回不同的值,我们使用一个名为 srand 的函数,代表种子随机。播种随机意味着我们提供一个值给 rand,作为其第一个值的基础。一种常见的播种随机数的方法是使用当前时间。我们将使用来自 Foundationclock 方法。

最后,rand 函数返回一个从 0 到一个非常大的数字,但正如您将看到的,我们希望将随机数限制在 0 和受邀人数之间。为此,我们使用取余运算符 (%)。这个运算符给出第一个数除以第二个数后的余数。例如,14 % 4 返回 2,因为 4 可以进入 143 次后还剩下 2。这个运算符的伟大之处在于它强制任何大小的数字始终在 0 和除数减 1 之间。这对于改变所有可能的随机值是完美的。

生成随机数的完整代码如下:

// Import Foundation so that "rand" can be used
import Foundation

// Seed the random number generator
srand(UInt32(clock()))

// Random number between 0 and 9
var randomNumber = Int(rand()) % 10

您可能还会注意到代码中的另一个特点。我们正在使用新的语法 UInt32()Int()。这是一种将一种类型转换为另一种类型的方法。例如,clock 函数返回 clock_t 类型的值,但 srand 函数需要一个 UInt32 类型的参数。记住,就像变量一样,您可以按住选项键并单击一个函数来查看它接受和返回的类型。

我们将使用的第二个特性是一个只能存储真或假的变量。这被称为 Bool,是布尔(Boolean)的简称。我们之前已经多次使用过这种类型,因为它用于所有条件和循环中,但这是我们第一次将 Bool 直接存储在变量中。在最基本的情况下,布尔变量是这样定义和使用的:

var someBool = false
if someBool {
    print("Do This")
}

注意,我们可以在条件语句中直接使用布尔值。这是因为布尔值正是条件语句所期望的类型。我们所有的其他测试,如 <=,实际上都导致一个 Bool

最后,我们将使用的第三个特性是 while 循环的一种变体,称为 重复-直到 循环。与 repeat-while 循环的唯一区别是条件是在循环的末尾而不是开始时检查。这很重要,因为与 while 循环不同,repeat-while 循环至少会执行一次,如下所示:

var inviteeIndex: Int
repeat {
    inviteeIndex = Int(rand()) % 5
} while inviteeIndex != 3

使用这个循环,我们将继续生成 04 之间的随机数,直到我们得到一个不等于 3 的数字。

代码中的其他一切都是基于我们已知的概念构建的。我建议你阅读代码并尝试理解它。尝试不仅从它如何工作的角度理解它,还要理解为什么我以这种方式编写它。我包含了注释来帮助解释代码正在做什么以及为什么以这种方式编写:

// Import Foundation so that "rand" can be used
import Foundation

// Seed the random number generator
srand(UInt32(clock()))

// -----------------------------
// Input Data
// -----------------------------

// invitees
//
// Each element is a tuple which contains a name
// that is a String and a Bool for if they have been
// invited yet. It is a variable because we will be
// tracking if each invitee has been invited yet. 
var invitees = [
    (name: "Sarah", alreadyInvited: false),
    (name: "Jamison", alreadyInvited: false),
    (name: "Marcos", alreadyInvited: false),
    (name: "Roana", alreadyInvited: false),
    (name: "Neena", alreadyInvited: false),
]

// showsByGenre
//
// Constant because we will not need to modify
// the show list at all
let showsByGenre = [
    "Comedy": "Modern Family",
    "Drama": "Breaking Bad",
    "Variety": "The Colbert Report",
]

这段代码的第一个部分为我们提供了一个局部位置,我们可以在这里放置所有数据。如果我们想修改数据,可以轻松回到程序中,而不必在程序的其余部分中搜索以更新它:

// -----------------------------
// Helper functions
// -----------------------------

// inviteAtIndex:toBringShow:
//
// Another function to help make future code
// more comprehensible and maintainable
func inviteAtIndex
    (
    index: Int,
    toBringShow show: (genre: String, name: String)
    )
{
    let name = invitees[index].name
    print("\(name), bring a \(show.genre) show")
    print("\(show.name) is a great \(show.genre)")

    invitees[index].alreadyInvited = true
 }

// inviteToBringThemselvesAtIndex:
//
// Similar to the previous function but this time for
// the remaining invitees
func inviteToBringThemselvesAtIndex(index: Int) {
    let invitee = invitees[index]
    print("\(invitee.name), just bring yourself")

    invitees[index].alreadyInvited = true
 }

在这里,我提供了一些函数,它们简化了程序后面更复杂的代码。每个函数都有一个有意义的名称,这样,当它们被使用时,我们就不必去查看它们的代码来理解它们正在做什么:

// -----------------------------
// Now the core logic
// -----------------------------

// First, we want to make sure each genre is assigned
// to an invitee
for show in showsByGenre {
    // We need to pick a random invitee that has not
    // already been invited. With the following loop
    // we will continue to pick an invitee until we
    // find one that has not already been invited
    var inviteeIndex: Int
    repeat {
        inviteeIndex = Int(rand()) % invitees.count
    } while invitees[inviteeIndex].alreadyInvited

    // Now that we have found an invitee that has not
    // been invited, we will invite them
    inviteAtIndex(inviteeIndex, toBringShow: (show))
}

// Now that we have assigned each genre, we
// will ask the remaining people to just bring
// themselves
for index in 0 ..< invitees.count {
    let invitee = invitees[index]
    if !invitee.alreadyInvited {
        inviteToBringThemselvesAtIndex(index)
    }
}

最后一段包含程序的真正逻辑,通常被称为业务逻辑。上一段中的函数只是细节,最后一段是真正定义程序做什么的逻辑。

这绝不是组织程序的唯一方法。随着我们学习更多高级组织技术,这一点将变得更加清晰。然而,这种分解展示了你应该组织代码的一般哲学。你应该努力编写每一行代码,就像它将要被发表在书中一样。随着你对 Swift 的熟练程度提高,这个例子中的许多注释将变得过多,但当你不确定时,使用注释或命名良好的函数来解释你正在做什么。这不仅有助于他人理解你的代码,当你六个月后再次回到代码时,它也会帮助你理解代码。不仅如此,如果你强迫自己在编写代码时使思想形式化,你会发现你创建的 bug 会少得多。

让我们也看看这个实现的一个有趣限制。如果邀请人数少于演出数量,这个程序将遇到一个主要问题。repeat-while循环将永远继续,永远不会找到一个未被邀请的邀请人。你的程序不需要处理所有可能的输入,但你至少应该意识到它的局限性。

摘要

在本章中,我们为 Swift 知识打下了坚实的基础。我们学习了 Swift 如何以表达性和易于访问的方式表示复杂信息的各种内置机制。我们知道,默认情况下,我们应该将信息声明为常量,直到我们发现实际需要改变它的时候,然后我们应该将其变为变量。我们探讨了在 Swift 中,每一条信息都通过编译器与一个类型相关联,无论是通过类型推断还是显式声明。我们对许多内置类型都很熟悉,包括简单的类型如StringIntBool,以及容器类型如元组、数组和字典。我们可以使用控制台输出更好地调查我们的程序,特别是通过使用字符串插值来实现动态输出。我们认识到使用if语句、条件语句、switch语句和循环来控制程序流程的强大功能。我们的技能集中包含编写更易读、可维护和可重用代码的函数。最后,我们看到了如何将这些概念结合起来编写一个完整程序的例子。

作为对你的一次挑战,我建议你修复最终的程序,使其在邀请人不足时停止尝试分配节目。当你能够做到这一点时,你就已经准备好继续下一个主题了,这个主题是类型作用域项目

这些都是我们可以用来编写更加有序代码的工具,随着我们编写越来越大的项目,它们将变得更加关键。

第三章. 一点一滴 – 类型、范围和项目

在第二章中,我们开发了一个非常简单的程序,帮助组织派对。尽管我们以逻辑方式分离了代码的部分,但所有内容都写在单个文件中,我们的函数都聚集在一起。随着项目的复杂性增加,这种组织代码的方式是不可持续的。同样,我们使用函数在代码中分离逻辑组件,我们也需要能够分离函数和数据逻辑组件。为此,我们可以在不同的文件中定义代码,我们还可以创建包含自定义数据和功能的自己的类型。这些类型通常被称为对象,作为编程技术面向对象编程的一部分。在本章中,我们将涵盖以下内容:

  • 结构体

  • 类和继承

  • 枚举

  • 项目

  • 扩展

  • 范围

  • 访问控制

结构体

我们可以将数据和功能组合成一个逻辑单元或对象的最基本方法就是定义一个名为结构的东西。本质上,结构是一组命名的数据和函数。实际上,我们已经看到了几个不同的结构,因为我们之前看到的字符串、数组和字典等所有类型都是结构。现在我们将学习如何创建自己的结构。

类型与实例

让我们直接定义第一个结构来表示联系人:

struct Contact {
    var firstName: String = "First"
    var lastName: String = "Last"
}

在这里,我们通过使用struct关键字、一个名称以及包含其中的代码的大括号({})创建了一个结构。就像函数一样,结构中的所有内容都是在它的大括号内定义的。然而,结构中的代码并不是直接运行的,它全部是定义结构本身的一部分。将结构视为未来行为的规范,而不是要运行的代码,就像蓝图是建造房子的规范一样。

在这里,我们为第一个和最后一个名字定义了两个变量。这段代码并没有创建任何实际的变量,也没有记住任何数据。就像函数一样,这段代码只有在其他代码使用它时才会真正被使用。就像字符串一样,我们必须定义这个类型的新变量或常量。然而,在过去,我们总是使用像Sarah10这样的字面量。有了我们自己的结构,我们将不得不初始化自己的实例,这就像根据规范建造房子一样。

实例是类型的特定实现。这可能是在我们创建一个String变量并将其赋值为Sarah时。我们已经创建了一个具有值SarahString变量的实例。字符串本身不是数据的一部分;它仅仅定义了包含数据的 String 实例的性质。

初始化是创建新实例的正式名称。我们这样初始化一个新的Contact

let someone = Contact()

你可能已经注意到这看起来很像调用一个函数,这是因为它确实非常相似。每个类型都必须至少有一个特殊函数,称为初始化器。正如其名所示,这是一个初始化类型新实例的函数。所有初始化器都是以它们的类型命名的,它们可以有也可以没有参数,就像函数一样。在我们的例子中,我们没有提供任何参数,所以第一个和最后一个名字将保留我们在规范中提供的默认值:FirstLast

你可以在游乐场中通过点击该行右侧的Contact旁边的加号来看到这一点。这将在该行之后插入一个结果面板,显示firstNamelastName的值。我们刚刚初始化了我们的第一个自定义类型!

如果我们定义一个不提供默认值的第二个接触结构,它会改变我们调用初始化器的方式。因为没有默认值,所以在初始化它时必须提供值:

struct Contact2 {
    var firstName: String
    var lastName: String
}

let someone2 = Contact2(firstName: "Sarah", lastName: "Smith")

再次,这看起来就像调用一个函数,而这个函数恰好是以我们定义的类型命名的。现在,someone2Contact2的一个实例,firstName等于SarahlastName等于Smith

属性

这两个变量,firstNamelastName,被称为成员变量,如果我们将它们改为常量,那么它们就被称为成员常量。这是因为它们是与类型的特定实例相关联的信息片段。你可以在任何结构实例上访问成员常量和变量:

print("\(someone.firstName) \(someone.lastName)")

这与静态常量形成对比。我们可以在类型的定义中添加以下行来向我们的类型添加一个静态常量:

struct Contact {
    static let UnitedStatesPhonePrefix = "+1" // "First Last"
}

注意在常量声明之前的static关键字。静态常量可以直接从类型中访问,并且与任何实例无关:

print(Contact.UnitedStatesPhonePrefix) // "+1"

注意,我们有时会像这样向现有代码中添加代码。如果你在游乐场中跟随,你应该已经向现有的Contact结构添加了static let行。

成员和静态常量和变量都属于属性这一类别。属性只是与实例或类型相关联的信息片段。这有助于加强每个类型都是对象的观念。例如,一个球是一个具有许多属性的对象,包括其半径、颜色和弹性。我们可以通过创建一个具有所有这些属性的球结构,以面向对象的方式在代码中表示一个球:

struct Ball {
    var radius: Double
    var color: String
    var elasticity: Double
}

注意,这个Ball类型没有为其属性定义默认值。如果在声明中没有提供默认值,则在初始化该类型的实例时需要提供。这意味着该类型没有空初始化器可用。如果你尝试使用它,你会得到一个错误:

Ball() // Missing argument for parameter 'radius' in call

就像普通变量和常量一样,所有属性一旦初始化就必须有一个值。

成员和静态方法

就像你可以在结构体内部定义常量和变量一样,你也可以定义 成员 和静态函数。这些函数被称为 方法,以区分它们与任何类型都不相关的全局函数。你以与函数类似的方式声明成员方法,但你在类型声明内部这样做,如下所示:

struct Contact {
    var firstName: String = "First"
    var lastName: String = "Last"

    func printFullName() {
        print("\(self.firstName) \(self.lastName)")
    }
}

成员方法始终作用于它们定义的类型的具体实例。要在方法内部访问该实例,你使用 self 关键字。Self 在行为上与任何其他变量类似,你可以访问其上的属性和方法。前面的代码打印了 firstNamelastName 属性。你以与调用任何其他类型上的方法相同的方式调用此方法:

someone.printFullName()

在一个普通的结构体方法中,self 是常量,这意味着你不能修改其任何属性。如果你尝试这样做,你会得到一个像这样的错误:

struct Ball {
    var radius: Double
    var color: String
    var elasticity: Double

    func growByAmount(amount: Double) {
        // Error: Left side of mutating operator
        // isn't mutable: 'self' is immutable
        self.radius += amount
    }
}

为了让一个方法能够修改 self,它必须被声明为 可变方法,使用 mutating 关键字:

mutating func growByAmount(amount: Double) {
    self.radius += amount
}

我们可以定义适用于类型的静态属性,但我们也可以使用 static 关键字定义在类型上操作 静态方法。我们可以在我们的 Contact 结构体中添加一个打印可用电话前缀的静态方法,如下所示:

struct Contact {
    static let UnitedStatesPhonePrefix = "+1"

    static func printAvailablePhonePrefixes() {
        print(self.UnitedStatesPhonePrefix)
    }
}

Contact.printAvailablePhonePrefixes() // "+1"

在静态方法中,self 指的是类型而不是类型的实例。在前面的代码中,我们通过 self 使用了 UnitedStatesPhonePrefix 静态属性,而不是写出类型名称。

在静态和实例方法中,Swift 允许你为了简洁而无需使用 self 就可以访问属性。self 简单地隐含:

func printFullName() {
    print("\(firstName) \(lastName)")
}

static func printAvailablePhonePrefixes() {
    print(UnitedStatesPhonePrefix)
}

然而,如果你在方法中创建了一个同名变量,你必须使用 self 来区分你想要的是哪一个:

func printFirstName() {
    let firstName = "Fake"
    print("\(self.firstName) \(firstName)") // "First Fake"
}

我建议避免 Swift 的这个特性。我想让你知道这一点,这样你在查看别人的代码时不会感到困惑,但我感觉总是使用 self 可以大大提高你代码的可读性。self 使得变量与实例相关联而不是仅在函数中定义变得一目了然。如果你添加了创建变量来隐藏成员变量的代码,你也可能会创建错误。例如,如果你在前面代码中的 printFullName 方法中引入了 firstName 变量,而没有意识到你后来在代码中使用 firstName 来访问成员变量,那么你将创建一个错误。而不是访问成员变量,后面的代码将开始只访问局部变量。

计算属性

到目前为止,似乎属性被用来存储信息,而方法被用来执行计算。虽然这通常是正确的,但 Swift 有一个名为计算属性的功能。这些属性在每次访问时都会被计算。为此,你定义一个属性,然后提供一个名为getter的方法,该方法返回计算值,如下所示:

struct Ball {
    var radius: Double
    var diameter: Double {
        get {
            return self.radius * 2
        }
    }
}

var ball = Ball(radius: 2)
print(ball.diameter) // 4.0

这是一种避免存储可能与其他数据冲突的数据的绝佳方法。如果 diameter 只是另一个属性,那么它可能与 radius 不同。每次你更改半径时,你都必须记得更改直径。使用计算属性消除了这种担忧。

你甚至可以提供一个名为setter的第二个函数,允许你像普通属性一样为这个属性赋值:

var diameter: Double {
    get {
        return self.radius * 2
    }
    set {
        self.radius = diameter / 2
    }
}

var ball = Ball(radius: 2)
ball.diameter = 16
print(ball.radius) // 8.0

如果你提供了一个 setter,那么你也必须显式地提供一个 getter。如果不这样做,Swift 允许你省略 get 语法:

var volume: Double {
    return self.radius * self.radius * self.radius * 4/3 * 4.13
}

这提供了一种简洁定义只读计算属性的好方法。

对属性更改做出反应

在属性更改时执行操作是很常见的。实现这一目标的一种方法是通过定义一个具有执行必要操作的 setter 的计算属性。然而,Swift 提供了一种更好的方法。你可以在任何存储属性上定义一个 willSet 函数或 didSet 函数。WillSet 在属性更改之前被调用,并提供了变量 newValuedidSet 在属性更改之后被调用,并提供了变量 oldValue,如下所示:

var radius: Double {
    willSet {
        print("changing from \(self.radius) to \(newValue)")
    }
    didSet {
        print("changed from \(oldValue) to \(self.radius)")
    }
}

使用 didSetwillSet 与多个属性时,请注意避免创建无限循环。例如,如果你尝试使用这种技术来保持 diameterradius 的同步,而不是使用计算属性,它看起来会是这样:

struct Ball {
    var radius: Double {
        didSet {
            self.diameter = self.radius * 2
        }
    }
    var diameter: Double {
        didSet {
            self.radius = self.diameter /  2
        }
    }
}

在这个场景中,如果你设置了 radius,它将触发 diameter 的更改,这又触发 radius 的另一个更改,然后无限循环继续下去。

下标

你可能也已经意识到,我们过去与结构体交互的另一种方式。我们使用方括号 ([]) 既可以访问数组也可以访问字典中的元素。这些被称为下标,我们也可以在我们的自定义类型上使用它们。它们的语法与我们之前看到的计算属性类似,只是你定义它更像是带有参数和返回类型的方法,如下所示:

struct MovieAssignment {
    var movies: [String:String]

    subscript(invitee: String) -> String? {
        get {
            return self.movies[invitee]
        }

        set {
            self.movies[invitee] = newValue
        }
    }
}

你在方括号中声明你想要用作下标方法参数的参数。下标函数的返回类型是当用于访问值时将返回的类型。它也是任何分配给下标的值的类型:

var assignment = MovieAssignment(movies: [:])
assignment["Sarah"] = "Modern Family"
print(assignment["Sarah"]) // "Modern Family"

你可能已经注意到了返回类型中的问号(?)。这被称为可选类型,我们将在下一章中进一步讨论。现在,你只需要知道,这是在通过键访问字典时返回的类型,因为并非每个可能的键都有一个值。

就像计算属性一样,你可以定义一个只读的下标,而不使用get语法:

struct MovieAssignment {
    var movies: [String:String]

    subscript(invitee: String) -> String? {
        return self.movies[invitee]
    }
}

如果你在subscript声明中添加了额外的参数,subscript可以接受任意数量的参数。使用下标时,你将使用逗号在方括号中分隔每个参数,如下所示:

struct MovieAssignment {
    subscript(param1: String, param2: Int) -> Int {
        return 0
    }
}

print(assignment["Sarah", 2])

下标是一个缩短代码的好方法,但你应该始终小心,避免为了简洁而牺牲清晰度。编写清晰的代码是在过于冗长和不够简洁之间取得平衡。如果你的代码太短,将很难理解,因为含义会变得模糊。有一个名为movieForInvitee:的方法比使用下标要好得多。然而,如果你的所有代码都太长,周围会有太多的噪音,这样你会在某种程度上失去清晰度。谨慎使用下标,并且只有在它们基于你创建的结构类型对其他程序员来说直观时才使用。

自定义初始化

如果你对你提供的默认初始化器不满意,你可以定义自己的。这是通过使用init关键字来完成的,如下所示:

init(contact: Contact) {
    self.firstName = contact.firstName
    self.lastName = contact.lastName
}

就像方法一样,初始化器可以接受任意数量的参数,包括没有任何参数。然而,初始化器还有一些其他限制。一条规则是,每个成员变量和常量都必须在初始化器的末尾有一个值。如果我们从初始化器中省略了lastName的值,我们会得到如下错误:

struct Contact4 {
    var firstName: String
    var lastName: String

    init(contact: Contact4) {
        self.firstName = contact.firstName
    }// Error: Return from initializer without
     // initializing all stored properties
}

注意,这段代码没有为firstNamelastName提供默认值。如果我们将其添加回来,我们就不再得到错误,因为此时提供了一个值:

struct Contact4 {
    var firstName: String
    var lastName: String = "Last"

    init(contact: Contact4) {
        self.firstName = contact.firstName
    }
}

一旦你提供了自己的初始化器,Swift 就不再提供任何默认的初始化器。在先前的例子中,Contact就不能再通过firstNamelastName参数进行初始化了。如果我们想要两者都有,我们必须添加自己的初始化器版本,如下所示:

struct Contact3 {
    var firstName: String
    var lastName: String

    init(contact: Contact3) {
        self.firstName = contact.firstName
        self.lastName = contact.lastName
    }

    init(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}
var sarah = Contact3(firstName: "Sarah", lastName: "Smith")
var sarahCopy = Contact3(contact: sarah)
var other = Contact3(firstName: "First", lastName: "Last")

在初始化器中设置初始值的另一个选项是调用不同的初始化器:

init(contact: Contact4) {
    self.init(
        firstName: sarah.firstName,
        lastName: sarah.lastName
    )
}

这是一种在多个初始化器中减少重复代码的绝佳工具。然而,在使用这个工具时,你必须遵循一个额外的规则。你不能在调用其他初始化器之前访问self

init(contact: Contact4) {
    self.print()
    // Use of 'self' in delegating initializer
    // before self.init is called
    self.init(
        firstName: contact.firstName,
        lastName: contact.lastName
     )
}

这是一个很好的例子,说明了为什么存在这样的要求。如果我们调用print在调用其他初始化器之前,firstNamelastName将没有值。在这种情况下会打印什么?相反,你只能在调用其他初始化器之后访问self,如下所示:

init(contact: Contact4) {
    self.init(
        firstName: contact.firstName,
        lastName: contact.lastName
    )
    self.print()
}

这保证了在调用任何方法之前,所有属性都有一个有效的值。

你可能已经注意到初始化器在参数命名上遵循不同的模式。默认情况下,初始化器要求所有参数都有一个标签。然而,请记住,这只是一个默认行为。你可以通过提供内部和外部名称或使用下划线(_)作为外部名称来更改行为。

结构体是编程中一种非常强大的工具。它是我们作为程序员可以用来抽象更复杂概念的重要方式。正如我们在第二章中讨论的,构成要素 – 变量、集合和流程控制,这是我们提高使用计算机能力的方式。其他人可以为我们提供这些抽象,以理解我们尚未理解的概念,或者在不需要从头开始的情况下节省我们的时间。我们也可以为自己使用这些抽象,以便更好地理解应用中正在进行的整体逻辑。这将大大提高我们代码的可靠性。结构体使我们的代码对他人和我们自己来说都更容易理解。

然而,结构体在一点上受到限制,它们不提供一种很好的方式来表达类型之间的父子关系。例如,狗和猫都是动物,并且有很多共同的属性和行为。如果我们只需要实现一次这些共同属性将会很棒。然后我们可以将这些类型分成不同的物种。为此,Swift 有一个不同的类型系统,称为

类可以做到结构体所能做到的一切,除了类可以使用一种称为继承的东西。类可以从另一个类继承功能,然后扩展或定制其行为。让我们直接进入一些代码。

从另一个类继承

首先,让我们定义一个可以稍后继承的名为Building的类:

class Building {
    let squareFootage: Int

    init(squareFootage: Int) {
        self.squareFootage = squareFootage
    }
}
var aBuilding = Building(squareFootage: 1000)

预计之下,类是通过使用class关键字而不是struct来定义的。否则,类看起来与结构体极为相似。然而,我们也可以看到一点不同。在使用结构体时,我们之前创建的初始化器就不再必要了,因为它已经被自动创建。而在类中,除非所有属性都有默认值,否则初始化器不会自动创建。

现在,让我们看看如何从这个构建类中继承:

class House: Building {
    let numberOfBedrooms: Int
    let numberOfBathrooms: Double

    init(
        squareFootage: Int,
        numberOfBedrooms: Int,
        numberOfBathrooms: Double
        )
    {
        self.numberOfBedrooms = numberOfBedrooms
        self.numberOfBathrooms = numberOfBathrooms

        super.init(squareFootage: squareFootage)
    }
}

在这里,我们创建了一个新的名为House的类,它继承自我们的Building类。这通过类声明中的冒号(:)后跟Building来表示。正式来说,我们会说HouseBuilding子类,而BuildingHouse超类

如果我们初始化一个House类型的变量,我们就可以访问HouseBuilding的属性,如下所示:

var aHouse = House(
    squareFootage: 800,
    numberOfBedrooms: 2,
    numberOfBathrooms: 1
)
print(aHouse.squareFootage)
print(aHouse.numberOfBedrooms)

这是使类强大的起点之一。如果我们需要定义十种不同的建筑类型,我们不必为每一种都添加一个单独的squareFootage属性。这同样适用于属性和方法。

除了简单的超类子类关系之外,我们还可以定义一个完整的类层次结构,包括子类的子类、子类的子类的子类,依此类推。通常,将类层次结构想象成一个倒置的树是有帮助的:

从另一个类继承

树的主干是最顶层的超类,每个子类都是从这个主干上分离出来的一个分支。最顶层的超类通常被称为基类,因为它为所有其他类提供了基础。

初始化

由于类的层次性,它们的初始化器规则更为复杂。以下是一些附加规则:

  • 子类中的所有初始化器都必须调用其超类的初始化器

  • 子类的所有属性必须在调用超类初始化器之前初始化

第二条规则使我们能够在调用初始化器之前使用self。然而,你不能出于任何其他原因使用self,除了初始化其属性。

你可能已经注意到了我们在house初始化器中使用了关键字supersuper用于引用当前实例,就像它是其超类一样。这就是我们调用超类初始化器的方式。当我们进一步探索继承时,我们将看到super的更多用法。

继承也创建了以下四种类型的初始化器,如下所示:

  • 覆盖初始化器

  • 必需初始化器

  • 指定初始化器

  • 便利初始化器

覆盖初始化器

覆盖初始化器用于替换超类中的初始化器:

class House: Building {
    let numberOfBedrooms: Int
    let numberOfBathrooms: Double

    override init(squareFootage: Int) {
        self.numberOfBedrooms = 0
        self.numberOfBathrooms = 0
        super.init(squareFootage: squareFootage)
    }
}

Building中已经存在一个只接受squareFootage作为参数的初始化器。这个初始化器将替换那个初始化器,所以如果你尝试仅使用squareFootage来初始化House,这个初始化器将被调用。然后,它将调用Building版本的初始化器,因为我们通过super.init调用来请求它。

如果你想使用超类初始化器来初始化子类,这种能力尤为重要。默认情况下,如果你在子类中没有指定新的初始化器,它将继承其超类中的所有初始化器。然而,一旦你在子类中声明了一个初始化器,它就会隐藏所有超类的初始化器。通过使用覆盖初始化器,你可以再次暴露超类版本的初始化器。

必需初始化器

必需初始化器是超类的一种初始化器。如果你将一个初始化器标记为必需,它将强制所有子类也定义该初始化器。例如,我们可以使Building初始化器成为必需的,如下所示:

class Building {
    let squareFootage: Int

    required init(squareFootage: Int) {
        self.squareFootage = squareFootage
    }
}

然后,如果我们自己在House中实现了自己的初始化器,我们就会得到如下错误:

class House: Building {
    let numberOfBedrooms: Int
    let numberOfBathrooms: Double

    init(
        squareFootage: Int,
        numberOfBedrooms: Int,
        numberOfBathrooms: Double
        )
    {
        self.numberOfBedrooms = numberOfBedrooms
        self.numberOfBathrooms = numberOfBathrooms

        super.init(squareFootage: squareFootage)
    }

    // 'required' initializer 'init(squareFootage:)' must be
    // provided by subclass of 'Building'
}

这次,在声明这个初始化器时,我们重复使用required关键字而不是使用override

required init(squareFootage: Int) {
    self.numberOfBedrooms = 0
    self.numberOfBathrooms = 0
    super.init(squareFootage: squareFootage)
}

当你的超类有多个执行不同操作的初始化器时,这是一个重要的工具。例如,你可以有一个初始化器从数据文件创建你的类的实例,另一个初始化器从代码设置其属性。本质上,你有两条初始化路径,你可以使用必需的初始化器确保所有子类都考虑了这两条路径。子类仍然应该能够从文件和代码中初始化。将超类的两个初始化器都标记为必需确保了这一点。

指定和便利初始化器

要讨论指定初始化器,我们首先必须谈谈便利初始化器。我们最初开始使用的正常初始化器实际上被称为指定初始化器。这意味着它们是初始化类的核心方式。你还可以创建便利初始化器,正如其名所示,它们是为了便利而存在的,并不是初始化类的核心方式。

所有便利初始化器都必须调用一个指定初始化器,并且它们没有像指定初始化器那样手动初始化属性的能力。例如,我们可以在我们的Building类上定义一个便利初始化器,它接受另一个建筑并创建一个副本:

class Building {
    // ...

    convenience init(otherBuilding: Building) {
        self.init(squareFootage: otherBuilding.squareFootage)
    }
}
var aBuilding = Building(squareFootage: 1000)
var defaultBuilding = Building(otherBuilding: aBuilding)

现在,作为一个便利,你可以使用现有建筑的属性创建一个新的建筑。关于便利初始化器的另一条规则是,它们不能被子类使用。如果你尝试这样做,你会得到一个像这样的错误:

class House: Building {

    // ...

    init() {
        self.numberOfBedrooms = 0
        self.numberOfBathrooms = 0
        super.init() // Missing argument for parameter 'squareFootage' in call
    }
}

这正是便利初始化器存在的主要原因之一。理想情况下,每个类应该只有一个指定初始化器。你拥有的指定初始化器越少,维护你的类层次结构就越容易。这是因为你经常会添加额外的属性和其他需要初始化的东西。每次你添加这样的东西时,你都必须确保每个指定初始化器都正确且一致地设置好。使用便利初始化器而不是指定初始化器可以确保一切的一致性,因为它们必须调用一个指定初始化器,而该初始化器反过来又必须正确设置一切。基本上,你希望尽可能通过最少的指定初始化器进行所有初始化。

通常,你的指定初始化器是具有最多参数的,可能包含所有可能的参数。这样,你就可以从所有其他初始化器中调用它,并将它们标记为便利初始化器。

重写方法和计算属性

就像初始化器一样,子类可以重写方法和计算属性。然而,对这些操作需要更加小心。编译器提供的保护较少。

方法

虽然可能,但覆盖方法调用其超类实现并不是强制性的。例如,让我们给我们的BuildingHouse类添加清理方法:

class Building {
    // ...

    func clean() {
        print(
            "Scrub \(self.squareFootage) square feet of floors"
        )
    }
}

class House: Building {
    // ...

    override func clean() {
        print("Make \(self.numberOfBedrooms) beds")
        print("Clean \(self.numberOfBathrooms) bathrooms")
    }
}

在我们的Building超类中,我们唯一需要清理的是地板。然而,在我们的House子类中,我们还需要整理床铺和清洁浴室。如上所示实现,当我们调用House上的clean时,它不会清理地板,因为我们已经在Houseclean方法中覆盖了这种行为。在这种情况下,我们还需要让Building超类执行任何必要的清理,因此我们必须调用超类版本,如下所示:

override func clean() {
    super.clean()

    print("Make \(self.numberOfBedrooms) beds")
    print("Clean \(self.numberOfBathrooms) bathrooms")
}

现在,在基于房屋定义进行任何清理之前,它将首先基于建筑定义进行清理。你可以通过改变调用超类版本的位置来控制事情发生的顺序。

这是一个需要覆盖方法的绝佳例子。我们可以在超类中提供通用功能,这些功能可以在每个子类中扩展,而不是在多个类中重写相同的功能。

计算属性

再次使用override关键字覆盖计算属性也是有用的:

class Building {
    // ...

    var estimatedEnergyCost: Int {
        return squareFootage / 10
    }
}

class House: Building {
    // ...

    override var estimatedEnergyCost: Int {
        return 100 + super.estimatedEnergyCost
    }
}

在我们的Building超类中,我们已经提供了一个基于每 1000 平方英尺 100 美元的能源成本估算。这个估算仍然适用于房屋,但与有人住在建筑中相关的额外成本。因此,我们必须重写estimatedEnergyCost计算属性,以返回Building的计算结果加上 100 美元。

再次强调,使用覆盖的计算属性的父类版本不是必需的。子类可以完全不同的实现,不考虑其超类中实现的内容,或者它可以利用其超类的实现。

类型转换

我们已经讨论了类如何在类型层次结构之间共享功能。使类强大的另一个原因是它们允许代码以更通用的方式与多个类型交互。任何子类都可以在将其视为其超类的代码中使用。例如,我们可能想编写一个函数来计算建筑数组总面积。对于这个函数,我们不在乎它是哪种具体的建筑类型,我们只需要访问在超类中定义的squareFootage属性。我们可以定义我们的函数接受一个建筑数组,而实际的数组可以包含House实例:

func totalSquareFootageOfBuildings(buildings: [Building]) -> Int {
    var sum = 0
    for building in buildings {
        sum += building.squareFootage
    }
    return sum
}

var buildings = [
    House(squareFootage: 1000),
    Building(squareFootage: 1200),
    House(squareFootage: 900)
]
print(totalSquareFootageOfBuildings(buildings)) // 3100

尽管这个函数认为我们正在处理Building类型的类,但程序将执行HousesquareFootage实现。如果我们还创建了一个Building的办公室子类,那么该子类的实例也将包含在数组中,并具有自己的实现。

我们还可以将子类的实例分配给定义为其超类之一的变量:

var someBuilding: Building = House(squareFootage: 1000)

这为我们提供了一种比使用结构时更强大的抽象工具。例如,让我们考虑一个假设的图像类层次结构。我们可能有一个名为Image的基类,以及用于不同编码类型的子类,如JPGImagePNGImage。有子类是很好的,因为我们能够干净地支持多种类型的图像,但一旦图像被加载,我们就不再需要关心图像保存的编码类型。任何其他想要操作或显示图像的类都可以使用一个定义良好的图像超类来这样做;图像的编码已被从其余代码中抽象出来。这不仅创造了更容易理解的代码,而且也使得维护变得更加容易。如果我们需要添加另一个图像编码,如GIF,我们可以创建另一个子类,所有现有的操作和显示代码都可以通过不修改该代码来获得 GIF 支持。

实际上存在两种不同的转型类型。到目前为止,我们只看到了称为向上转型的转型类型。可以预见,另一种转型类型被称为向下转型

向上转型

我们到目前为止所看到的方法被称为向上转型,因为我们通过将子类视为其超类来向上遍历我们之前可视化的类树。之前,我们通过将子类实例赋值给定义为其超类的变量来进行向上转型。我们也可以使用as运算符来做同样的事情,如下所示:

var someBuilding2 = House(squareFootage: 1000) as Building

关于应该使用哪种方法,这实际上是一种个人偏好。

向下转型

向下转型意味着我们将超类视为其子类之一。

当使用其超类声明的函数或将其赋值给具有其超类类型的变量时,向上转型可以隐式进行,但向下转型必须显式进行。这是因为向上转型不会因为其继承性质而失败,但向下转型可能会失败。你可以始终将子类视为其超类,但你不能保证超类实际上是其特定的子类之一。你只能向下转型那些实际上是该类或其子类实例的实例。

我们可以通过使用as!运算符来强制向下转型,如下所示:

var house = someBuilding as! House
print(house.numberOfBathrooms)

as!运算符中添加了感叹号,因为它是一种可能会失败的操作。感叹号充当警告,确保你意识到它可能会失败。例如,如果someBuilding实际上不是House,则强制向下转型失败时,程序会崩溃,如下所示:

var anotherHouse = aBuilding as! House // Execution was interrupted

使用特殊if语句中的as?运算符进行向下转型是一种更安全的方法,这种if语句被称为可选绑定。我们将在下一章详细讨论这个问题,但到目前为止,你只需记住语法:

if let house = someBuilding as? House {
    // someBuilding is of type House
    print(house.numberOfBathrooms)
}
else {
    print("someBuilding is not a house")
}

这段代码仅在建筑物的类型为House时打印出numberOfBathroomsHouse常量被用作someBuilding的临时视图,其类型明确设置为House。有了这个临时视图,你可以像访问House一样访问someBuilding,而不是仅仅访问Building

枚举

到目前为止,我们已经涵盖了 Swift 中三种分类中的两种:结构和类。第三种分类被称为枚举。枚举用于为实例定义一组相关值。例如,如果我们想要值表示三种原色之一,枚举是一个很好的工具。

基本声明

枚举由案例组成,就像switch一样,它使用enum关键字而不是structclass。一个用于原色的枚举应该看起来像这样:

enum PrimaryColor {
    case Red
    case Green
    case Blue
}

然后,你可以定义一个具有这种类型的变量,并给它分配一个案例:

var color = PrimaryColor.Green

注意,要使用其中一个值,我们必须使用类型的名称后跟一个点(.),然后是具体的案例。如果变量的类型可以被推断出来,甚至可以省略枚举名称,只需从点开始:

var color = PrimaryColor.Green
color = .Red

在将.Red赋值的过程中,编译器已经知道color变量是PrimaryColor类型,因此不需要我们再次指定。这是一种使代码更简洁的好方法,但请确保你不会牺牲可读性。如果你省略了类型名称,代码的上下文应该仍然很明显。

测试枚举值

枚举实例可以像任何其他类型一样测试特定的值,使用相等运算符(==):

if color == PrimaryColor.Red {
}
else if color == .Blue {
}

注意,在第二个if语句中,当检查color是否为蓝色时,代码利用了类型推断,并没有麻烦地指定PrimaryColor

这种比较方法对于一两个可能的值来说是熟悉且有用的。然而,对于不同值的枚举测试,有更好的方法。你不需要使用if语句,而是可以使用switch。考虑到枚举是由案例组成的,而switch测试的是案例,这是一个逻辑上的解决方案:

switch color {
    case .Red:
        print("color is red")
    case .Green:
        print("color is green")
    case .Blue:
        print("color is blue")
}

这对于所有与switch本身相同的原因都是很好的。事实上,switch与枚举配合得更好,因为枚举的可能值总是有限的,而其他基本类型则不是。你可能记得,switch要求你必须为每个可能的值有一个案例。这意味着,如果你没有为枚举的每个案例提供测试案例,编译器将产生错误。这通常是一种很好的保护措施,这就是为什么我建议在大多数情况下使用switch而不是简单的if语句。如果你在枚举中添加了额外的案例,那么在代码中任何没有考虑这个新案例的地方都会产生错误,这样你可以确保你已经处理了它。

原始值

枚举类型非常出色,因为它们能够存储基于 Swift 基本类型(如字符串、整数和双精度浮点数)之外的信息。有许多像我们的颜色示例这样的抽象概念,它们与基本类型根本无关。然而,你通常希望每个枚举情况都有一个原始值,它属于另一种类型。例如,如果我们想表示美国货币中的所有硬币及其货币价值,我们可以使我们的枚举具有一个整型原始值类型,如下所示:

enum USCoins: Int {
    case Quarter = 25
    case Dime = 10
    case Nickel = 5
    case Penny = 1
}

原始值类型是以与类中指定继承相同的方式指定的,然后每个情况都单独分配了该类型的特定值。

你可以通过使用rawValue属性在任何时候访问情况的原始值:

print("A Quarter is worth \(USCoins.Quarter.rawValue) cents.")

请记住,枚举只能具有可以用文字如10String定义的原始值类型。你不能定义一个具有自定义类型的枚举,该类型作为其原始值。

关联值

原始值非常适合当你枚举中的每个情况都与相同类型的值相关联,并且其值永远不会改变时。然而,也存在每种情况都有不同值与之相关联,并且这些值对于枚举的每个实例都不同的情况。你可能甚至想要一个具有多个相关值的案例。为此,我们使用枚举的一个功能,称为关联值

你可以指定零个或多个类型,这些类型可以与每个情况单独关联关联值。然后,在创建枚举实例时,你可以给出任何你想要的值,如下所示:

enum Height {
    case Imperial(feet: Int, inches: Double)
    case Metric(meters: Double)
    case Other(String)
}
var height1 = Height.Imperial(feet: 6, inches: 2)
var height2 = Height.Metric(meters: 1.72)
var height3 = Height.Other("1.9 × 10-16 light years")

在这里,我们定义了一个枚举来存储使用各种测量系统的高度测量值。有一个英制系统的情况,它使用英尺和英寸,还有一个公制系统的情况,它只有米。这两个情况都有其关联值的标签,类似于元组。最后一个情况是为了说明,如果你不想提供标签,你不必提供。它只需一个字符串。

与具有关联值的枚举进行比较和访问值比常规枚举要复杂一些。我们不能再使用等号运算符(==)。相反,我们必须始终使用一个情况。在情况内部,有几种处理关联值的方法。最简单的方法是访问特定的关联值。为此,你可以将其分配给一个临时变量:

switch height1 {
    case .Imperial(let feet, var inches):
        print("\(feet)ft \(inches)in")
    case let .Metric(meters):
        print("\(meters) meters")
    case var .Other(text):
        print(text)
}

在英制情况下,前面的代码将feet赋值给一个临时常量,将inches赋值给一个临时变量。这些名称与关联值使用的标签相匹配,但这不是必需的。公制情况表明,如果你想让所有临时值都是常量,你可以在枚举情况之前声明let。无论有多少关联值,let只需要写一次,而不是每个值都写一次。另一种情况与公制情况相同,只是它创建了一个临时变量而不是一个常量。

如果你想要为关联值的条件创建单独的情况,你可以使用我们在上一章中看到的where语法:

switch height1 {
    case .Imperial(let feet, var inches) where feet > 1:
        print("\(feet)ft \(inches)in")
    case let .Metric(meters) where meters > 0.3:
        print("\(meters) meters")
    case var .Other(text):
        print(text)
    default:
        print("Too Small")
}

注意,我们不得不添加一个默认情况,因为我们对其他情况的限制不再详尽无遗。

最后,如果你实际上不关心关联值,你可以使用下划线(_)来忽略它,如下所示:

switch height1 {
    case .Imperial(_, _):
        print("Imperial")
    case .Metric(_):
        print("Metric")
    case .Other(_):
        print("Other")
}

这表明,与枚举一起,开关甚至比我们之前看到的更有力量。

现在你已经了解了如何使用关联值,你可能已经注意到它们可以改变枚举的概念性质。没有关联值时,枚举代表了一组抽象和恒定的可能值。具有关联值的枚举则不同,因为具有相同情况的两个实例不一定相等;每个情况可能有不同的关联值。这意味着枚举的概念性质实际上是一系列查看特定类型信息的方式。这不是一个具体的规则,但它很常见,并且能更好地帮助你理解枚举可以最好地表示的不同类型的信息。这也有助于使你自己的枚举更易于理解。每个情况理论上都可以通过关联值代表与其它情况完全无关的概念,但那应该是一个信号,表明枚举可能不是完成那个特定工作的最佳工具。

方法与属性

枚举实际上与结构非常相似。与结构一样,枚举可以有方法和属性。为了改进Height枚举,我们可以添加方法来访问我们想要的任何测量系统中的高度。作为一个例子,让我们实现一个meters方法,如下所示:

enum Distance {
    case Imperial(feet: Int, inches: Double)
    case Metric(meters: Double)

    func meters() -> Double {
        switch self {
            case let .Imperial(feet, inches):
                return Double(feet)*0.3048+inches*0.3048/12
            case let .Metric(meters):
                return meters
        }
    }
}
var distance1 = Distance.Imperial(feet: 6, inches: 2)
distance1.meters() // 1.8796

在这个方法中,我们开启了self,它告诉我们这个实例是用哪个单位创建的。如果它在米,我们就可以直接返回,但如果它在英尺和英寸,我们必须进行转换。作为一个练习,我建议你尝试实现一个feetAndInches方法,它返回一个包含两个值的元组。最大的挑战在于使用正确的类型处理数学运算。你不能用不匹配的类型进行数学运算。如果你需要将一个数字类型转换为另一个类型,你可以通过初始化一个副本来实现,就像上面的代码所示:Double(feet)。与之前讨论的类型转换不同,这个过程只是创建了一个新的feet变量副本,现在它是Double而不是Int。这之所以可能,是因为Double类型恰好定义了一个可以接受Int的初始化器。大多数数字类型都可以用其他任何类型进行初始化。

现在,你对我们可以如何在一个文件中组织 Swift 代码以使代码更易于理解和维护的所有不同方式有了很好的了解。现在是时候讨论我们如何将代码分离到多个文件中,以进一步提高代码质量。

项目

如果我们想要摆脱使用单个文件进行开发,我们就需要远离游乐场,创建我们的第一个项目。为了简化项目,我们将创建一个命令行工具。这是一个没有图形界面的程序。作为一个练习,我们将重新开发来自第二章,“基础知识 – 变量、集合和流程控制”,负责管理派对邀请人的示例程序。我们将在第十一章,“一个全新的世界 – 开发应用程序”中开发一个具有图形界面的应用程序。

设置命令行 Xcode 项目

要创建一个新的命令行工具项目,打开 Xcode,从顶部菜单栏中选择文件 | 新建 | 项目…。一个窗口将出现,允许你选择项目的模板。你应该从OS X | 应用程序菜单中选择命令行工具

设置命令行 Xcode 项目

从那里,点击下一步,然后给项目起一个像Learning Swift Command Line这样的名字。任何组织名称标识符都可以。最后,确保从语言下拉菜单中选择Swift,然后再次点击下一步。现在,将项目保存在你可以找到的地方,然后点击创建

Xcode 将然后向你展示项目开发窗口。在左侧选择main.swift文件,你应该能看到 Xcode 为你生成的Hello, World!代码:

设置命令行 Xcode 项目

这应该感觉和游乐场很相似,只是我们不能再看到右侧代码的输出。在一个像这样的常规项目中,代码不会自动为你运行。当你编写代码时,代码仍然会被分析错误,但每次你想测试它时,你必须自己运行它。要运行代码,你可以点击工具栏上的运行按钮,它看起来像一个播放按钮。

程序将构建并运行。一旦运行,Xcode 将在底部显示控制台,你将看到文本Hello, World!,这是运行此程序的结果。这与我们在游乐场中看到的是同一个控制台。

与游乐场不同,我们在左侧有项目导航器。这是我们组织所有用于使应用程序工作的源文件的地方。

创建和使用外部文件

现在我们已经成功创建了我们命令行项目,让我们创建我们的第一个新文件。为每个你创建的类型创建一个单独的文件是很常见的。让我们首先创建一个用于invitee类的文件。我们希望将文件添加到与main.swift文件相同的文件组中,所以点击那个组。然后,你可以点击窗口左下角的加号(+)并选择New File。从那个窗口,选择OS X | Source | Swift File并点击Next

创建和使用外部文件

新文件将被放置在进入对话框之前选择的任何文件夹中。你可以随时拖动文件以按你想要的方式组织它。这个文件的好位置是main.swift旁边。将你的新文件命名为Invitee.swift并点击Create。让我们向这个文件添加一个简单的Invitee结构。我们希望Invitee有一个名字,并且能够邀请他们参加聚会,无论是否有表演:

// Invitee.swift
struct Invitee {
    let name: String

    func askToBringShowFromGenre(genre: ShowGenre) {
        print("\(self.name), bring a \(genre.name) show")
        print("\(genre.example) is a great \(genre.name)")
    }

    func askToBringThemselves() {
        print("\(self.name), just bring yourself")
    }
}

这是一个非常简单的类型,不需要继承,所以没有理由使用类。请注意,继承并不是使用类的唯一原因,正如我们将在后面的章节中看到的那样,但就目前而言,结构对我们来说将工作得很好。此代码提供了简单、命名良好的方法来打印出两种邀请类型。

我们已经使用了我们尚未创建的结构,称为ShowGenre。我们预计它将有一个nameexample属性。现在让我们实现这个结构。创建一个名为ShowGenre.swift的新文件,并将以下代码添加到其中:

// ShowGenre.swift
struct ShowGenre {
    let name: String
    let example: String
}

这是一个更简单的结构。这只是在使用元组的基础上进行的小改进,因为它提供了一个名称而不是仅仅属性,并且它还让我们能够更精细地控制什么是常量或不是。可能看起来为这个而创建整个文件是浪费的,但这对未来的可维护性来说是非常好的。因为它在一个命名良好的文件中,所以更容易找到这个结构,我们可能以后还想向它添加更多代码。

代码设计中的一个重要原则被称为关注点分离。其理念是每个文件和每个类型都应该有一个清晰且定义良好的关注点。你应该避免有两个文件或类型负责同一件事,并且希望它清楚地表明每个文件和类型存在的原因。

与其他文件中的代码交互

现在我们已经有了基本的数据结构,我们可以为我们的受邀者列表使用一个更智能的容器。这个列表包含了为随机受邀者分配流派逻辑。让我们先定义一些属性来构建这个结构:

// InviteList.swift
struct InviteList {
    var invited: [Invitee] = []
    var pendingInvitees: [Invitee]

    init(invitees: [Invitee]) {
        srand(UInt32(clock()))
        self.pendingInvitees = invitees
    }
}

我们可以不用存储一个包含受邀者和待邀请受邀者的单一列表,而是可以将它们存储在两个单独的数组中。这使得选择待邀请受邀者变得容易得多。此代码还提供了一个自定义初始化器,因此我们只需要从其他类提供受邀者列表,无需担心它是否是待邀请受邀者的列表。我们本可以使用默认初始化器,但参数将被命名为pendingInvitees。我们还为随机数生成器设置了种子,以供以后使用。

注意,我们不需要在初始化器中为invited提供一个值,因为我们已经给它赋予了默认值一个空数组。

注意,我们在这个代码中自由地使用了Invitee结构。Swift 会自动从同一项目中的其他文件中查找代码,并允许你使用它。与其他文件中的代码交互就像那样简单。

现在,让我们添加一个辅助函数,将受邀者从pendingInvitee列表移动到invited列表:

// InviteList.swift
struct InviteList {

    // ...

    // Move invitee from pendingInvitees to invited
    //
    // Must be mutating because we are changing the contents of
    // our array properties
    mutating func invitedPendingInviteeAtIndex(index: Int) {
        // Removing an item from an array returns that item
        let invitee = self.pendingInvitees.removeAtIndex(index)
        self.invited.append(invitee)
    }
}

这使得我们的其他方法更简洁、更容易理解。我们首先想要允许的是随机邀请一个受邀者,然后要求他们从一个特定的genre中带来一场表演:

// InviteList.swift
struct InviteList {

    // ...

    // Must be mutating because it calls another mutating method
    mutating func askRandomInviteeToBringGenre(genre: ShowGenre) {
        if self.pendingInvitees.count > 0 {
            let randomIndex = Int(rand()) % self.pendingInvitees.count
            let invitee = self.pendingInvitees[randomIndex]
            invitee.askToBringShowFromGenre(genre)
            self.invitedPendingInviteeAtIndex(randomIndex)
        }
    }
}

随机选择受邀者的过程比我们之前的实现更简洁。我们可以创建一个介于0和待邀请受邀者数量之间的随机数,而不是不得不不断尝试随机受邀者,直到找到一个尚未被邀请的。然而,在我们能够选择那个随机数之前,我们必须确保待邀请受邀者的数量大于零。如果没有任何剩余的受邀者,我们将在Int(rand()) % self.pendingInvitees.count中将随机数除以0,这会导致崩溃。它还有一个额外的好处,即允许我们处理流派数量多于受邀者数量的场景。

最后,我们希望能够邀请其他人只带来他们自己:

// InviteList.swift
struct InviteList {

    // ...

    // Must be mutating because it calls another mutating method
    mutating func inviteeRemainingInvitees() {
        while self.pendingInvitees.count > 0 {
            let invitee = self.pendingInvitees[0]
            invitee.askToBringThemselves()
            self.invitedPendingInviteeAtIndex(0)
        }
    }
}

在这里,我们只是反复邀请并从pendingInvitees数组中移除第一个待邀请受邀者,直到没有剩余的为止。

我们现在有了所有的自定义类型,可以回到main.swift文件来完成程序的逻辑。要切换回来,你只需在项目导航器(左侧的文件列表)中再次点击文件即可。在这里,我们只想创建我们的受邀者名单和包含示例节目的流派列表。然后,我们可以遍历我们的流派,并要求我们的受邀者名单进行邀请:

var inviteeList = InviteList(invitees: [
    Invitee(name: "Sarah"),
    Invitee(name: "Jamison"),
    Invitee(name: "Marcos"),
    Invitee(name: "Roana"),
    Invitee(name: "Neena"),
])

let genres = [
    ShowGenre(name: "Comedy", example: "Modern Family"),
    ShowGenre(name: "Drama", example: "Breaking Bad"),
    ShowGenre(name: "Variety", example: "The Colbert Report"),
]

for genre in genres {
    inviteeList.askRandomInviteeToBringGenre(genre)
}
inviteeList.inviteeRemainingInvitees()

这就是我们的完整程序。你现在可以通过点击运行按钮来运行程序并检查输出。你刚刚完成了你的第一个真正的 Swift 项目!

文件组织和导航

随着你的项目变大,仅仅有一个单一的文件列表可能会变得很繁琐。将你的文件组织到文件夹中可以帮助你区分它们在你的应用中扮演的角色。在项目导航器中,文件夹被称为组。你可以通过选择你想要添加新组的组,然后转到文件 | 新建 | 来创建一个新的组。确切地如何分组你的文件并不十分重要;重要的是你应该能够想出一个相对简单且合理的系统。如果你在做这件事时遇到困难,你应该考虑如何改进你拆分代码的方式。如果你在分类文件时遇到困难,那么你的代码可能并不是以可维护的方式拆分的。

我建议使用大量的文件和组来更好地分离你的代码。然而,这样做的一个缺点是项目导航器可能会很快填满,变得难以导航。在 Xcode 中快速导航到文件的一个很好的技巧是使用键盘快捷键Command + Shift + O。这会显示快速打开搜索。在这里,你可以开始输入你想要打开的文件名,Xcode 会显示所有匹配的文件。使用箭头键上下导航,并按Enter键打开文件。

扩展

到目前为止,我们必须在单个文件中定义我们的整个自定义类型。然而,有时将我们的自定义类型的一部分分离到不同的文件中,或者甚至在同一文件中,有时是有用的。为了实现这一点,Swift 提供了一个名为扩展的功能。扩展允许我们从任何地方向现有类型添加额外的功能。

此功能仅限于额外的函数和额外的计算属性:

extension Building {
    var report: String {
        return "This building is \(self.squareFootage) sq ft"
    }

    func isLargerThanOtherBuilding(building: Building) -> Bool {
        return self.squareFootage > building.squareFootage
    }
}

注意,为了定义一个扩展,我们使用extension关键字,后跟我们要扩展的类型。扩展也可以用于现有的类、结构体或枚举,即使是 Swift 中定义的,如 String。让我们给 String 添加一个扩展,允许我们重复字符串任意次数:

extension String {
    func repeatNTimes(nTimes: Int) -> String {
        var output = ""
        for _ in 0..<nTimes {
            output += self
        }
        return output
    }
}
"-".repeatNTimes(4) // ----

这只是一个简单的想法,但扩展内置类型通常非常有用。

现在我们已经对可用于组织代码的工具有了良好的概述,是时候讨论编程中的一个重要概念,即作用域了。

作用域

作用域是关于哪些代码可以访问哪些其他代码片段。Swift 使这相对容易理解,因为所有作用域都是由大括号({})定义的。本质上,大括号内的代码只能访问同一大括号内的其他代码。

作用域的定义

为了说明作用域,让我们看看一些简单的代码:

var outer = "Hello"
if outer == "Hello" {
    var inner = "World"
    print(outer)
    print(inner)
}
print(outer)
print(inner) // Error: Use of unresolved identifier 'inner'

如你所见,outer可以从if语句的内外访问。然而,由于inner是在if语句的大括号中定义的,因此它不能从大括号外访问。这对于结构体、类、循环、函数以及任何涉及大括号的任何其他结构都是正确的。所有不在大括号中的内容都被认为是全局作用域的一部分,这意味着任何东西都可以访问它。

嵌套类型

有时,自己控制作用域是有用的。为此,你可以在其他类型内定义类型:

class OuterClass {
    struct InnerStruct {
    }
}

在这种情况下,InnerStruct只能直接从OuterClass内部访问。然而,这提供了一个对于其他控制结构(如if语句和循环)不存在的特殊场景。如果全局作用域的代码想要访问InnerStruct,它只能通过它直接可以访问的OuterClass来这样做,如下所示:

var inner = OuterClass.InnerStruct()

这对于更好地分割你的代码很有用,同时也可以很好地隐藏对其他代码无用的代码。随着你在更大的项目中编程,你将越来越多地依赖 Xcode 的自动完成功能。在大型的代码库中,自动完成提供了很多选项,将类型嵌套到其他类型中是减少自动完成列表中不必要的杂乱的好方法。

访问控制

Swift 提供了一套工具,可以帮助控制其他代码可以访问哪些代码,称为访问控制。实际上,所有代码都被赋予了三个级别的访问控制:

  • 私有:只能在同一文件内访问

  • 内部:只能在同一模块或应用内访问

  • 公共:任何导入模块的代码都可以访问

在我们进一步讨论之前,你应该完全理解模块是什么。本书的范围不涉及实现模块,但模块是一组可以在其他模块和应用程序中使用的代码。到目前为止,我们已经使用了 Apple 提供的Foundation模块。模块是当你使用import关键字时使用的任何东西。

默认情况下,所有代码都被定义为内部级别。这意味着你的程序中的任何给定代码片段都可以访问任何其他文件中定义的代码片段,只要它遵循我们之前讨论的作用域规则。

如前所述,被声明为私有的代码只能从同一文件中访问。这是一种更好的方法来保护外部代码不看到你不希望它看到的代码。你可以通过在它之前写上private关键字来声明任何变量或类型为私有,如下所示:

private var mySecretString = "Hello World"
private struct MyPrivateStruct {
    private var privateProperty: String
    private func privateMethod() {
    }
}

注意,访问控制独立于花括号作用域。它是建立在作用域之上的。所有现有的作用域规则都适用,访问控制作为额外的过滤器。

这是一种改进抽象概念的绝佳方式。你的代码外部视图越简单,理解和使用你的抽象就越容易。你应该把每个文件和每个类型都看作是一个小的抽象。在任何抽象中,你希望外部世界尽可能少地了解其内部工作原理。你应该始终牢记你希望你的抽象如何被使用,并隐藏任何不服务于该目的的代码。这是因为随着代码不同部分之间的墙壁倒塌,代码变得越来越难以理解和维护。你最终会得到一团像意大利面一样的代码。就像很难找到一根面条的起点和终点一样,具有许多相互依赖和代码组件之间最小障碍的代码很难理解。提供过多关于其内部工作原理的知识或访问权限的抽象通常被称为泄漏抽象

公共代码的定义方式相同,只是你会使用public关键字而不是private。然而,由于我们不会研究设计自己的模块,这对我们来说并不有用。了解它的存在对未来的学习是有益的,但默认的内部访问级别对我们应用来说已经足够了。

摘要

这是一章非常密集的内容。我们覆盖了大量的内容。我们深入探讨了使用结构、类和枚举定义我们自己的自定义类型。结构非常适合简单类型,而类非常适合需要相关类型层次的结构类型。枚举提供了一种将相关事物分组在一起的方法,并通过关联值表达更抽象的概念。

我们也创建了我们的第一个项目,该项目利用多个源文件提高了我们代码库的可维护性,尤其是在规模较大的情况下。扩展可以在这些文件之间以及文件内部使用,以向现有类型添加额外功能,包括那些不是由我们定义的类型。

最后,我们很好地理解了作用域是什么以及我们如何控制它以获得优势,尤其是在访问控制的有力帮助下,这为我们提供了对代码可以与其他代码交互的更精细的过滤器。

现在你已经走得很远了,你正朝着成为一名优秀的 Swift 程序员迈进。我强烈建议你稍作休息,并尝试到目前为止所学到的所有内容。我们只剩下几个概念需要学习,直到我们拥有创建优秀应用所需的所有工具。

一旦你准备好继续前进,我们就可以讨论可选参数了,我已经暗示过了。可选参数有些复杂,但它们是有效使用 Swift 语言的一个不可或缺的部分。在下一章中,我们将深入探讨它们是什么,以及如何以最有效的方式利用它们。

第四章.存在与否——可选类型

正如我们在第二章中讨论的,构建块——变量、集合和流程控制,所有变量和常量在使用之前都必须有一个值。这是一个很好的安全特性,因为它可以防止你忘记为变量提供一个初始值。对于某些数字变量,例如开始时点三明治的数量为零,这可能是有意义的,但对于所有变量来说并不合理。例如,站立保龄球柱的数量应该从 10 开始,而不是零。在 Swift 中,编译器强制你决定变量应该从哪里开始,而不是提供一个可能不正确的默认值。

然而,还有其他场景,你需要表示值的完全缺失。一个很好的例子是,如果你有一个单词定义的字典,并且尝试查找字典中不存在的单词。通常,这将返回一个字符串,所以你可以返回一个空字符串,但如果你还需要表示一个单词存在但没有定义的情况呢?另外,对于使用你的字典的其他程序员来说,当他们查找不存在的单词时会发生什么可能并不明显。为了满足表示值缺失的需求,Swift 有一个特殊的类型,称为可选类型

在本章中,我们将涵盖以下主题:

  • 定义一个可选类型

  • 解包可选类型

  • 可选链

  • 隐式展开可选类型

  • 可选类型的调试

  • 底层实现

定义一个可选类型

因此,我们知道 Swift 中可选类型的作用是允许表示值的缺失,但它看起来是什么样子,又是如何工作的呢?可选类型是一个特殊的类型,可以“包装”任何其他类型。这意味着你可以创建一个可选的String,可选的Array等等。你可以通过在类型名称后添加一个问号(?)来实现这一点,如下所示:

var possibleString: String?
var possibleArray: [Int]?

注意,此代码没有指定任何初始值。这是因为所有可选类型默认情况下都没有值。如果我们想提供一个初始值,我们可以像其他任何变量一样这样做:

var possibleInt: Int? = 10

此外,请注意,如果我们省略了类型指定(: Int?),possibleInt将被推断为Int类型,而不是可选的Int

现在,说一个变量没有值是很冗长的。相反,如果一个可选变量没有值,我们说它是 nil。所以possibleStringpossibleArray都是 nil,而possibleInt10。然而,possibleInt并不是真正的10。它仍然被包装在可选类型中。

你可以通过在游乐场中放入以下代码来查看变量可以采取的所有形式:

var actualInt = 10
var possibleInt: Int? = 10
var nilInt: Int?
print(actualInt) // 10
print(possibleInt) // Optional(10)
print(nilInt) // nil

如你所见,actualInt打印出的结果正如我们所期望的,但possibleInt打印出的结果是一个包含值10的可选值,而不是仅仅是10。这是一个非常重要的区别,因为可选值不能用作它所包裹的值。nilInt只是报告它为 nil。在任何时候,你都可以更新可选值中的值;这包括使用赋值运算符(=)为其赋值:

nilInt = 2
print(nilInt) // Optional(2)

你甚至可以通过将其赋值为nil来移除可选值中的值:

nilInt = nil
print(nilInt) // nil

因此,我们有了这种可能包含也可能不包含值的变量包装形式。如果我们需要访问可选值中的值,我们该怎么办?答案是,我们必须解包它。

解包可选值

解包可选值有多种方式。它们本质上都断言可选值中确实有值。这是 Swift 的一个非常好的安全特性。编译器强制你考虑一个可能的情况,即可选值可能没有任何值。在其他语言中,这是一个非常常见的被忽视的场景,可能导致难以追踪的 bug。

可选绑定

解包可选值最安全的方式是使用一种称为可选绑定的技术。使用这种技术,你可以将一个临时常量或变量赋值给可选值中包含的值。这个过程包含在一个if语句中,这样你就可以在没有值的情况下使用else语句。可选绑定看起来类似于以下代码:

if let string = possibleString {
    print("possibleString has a value: \(string)")
}
else {
    print("possibleString has no value")
}

可选绑定与if语句的主要区别在于if let语法。从语义上看,这段代码的意思是:“如果你可以让常量string等于possibleString中的值,就打印出它的值;否则,打印出它没有值。”可选绑定的主要目的是创建一个临时常量,它是可选值的正常(非可选)版本。

我们也可以在可选绑定中使用一个临时变量:

possibleInt = 10
if var actualInt = possibleInt {
    actualInt *= 2
    print(actualInt) // 20
}
print(possibleInt) // Optional(10)

注意,在 Swift 中,星号(*)用于乘法。你还应该注意这个代码的一个重要特点。如果你将它放入一个 playground 中,即使我们乘以了actualInt,可选值内部的值也不会改变。当我们稍后打印出possibleInt时,值仍然是Optional(10)。这是因为尽管我们将actualInt变成了一个变量(也称为可变),它只是possibleInt内部值的临时副本。无论我们对actualInt做什么,都不会改变possibleInt内部的值。如果我们必须更新possibleInt中实际存储的值,我们只需在修改完成后将possibleInt赋值给actualInt

possibleInt = 10
if var actualInt = possibleInt {
   actualInt *= 2
   possibleInt = actualInt
}
print(possibleInt) // Optional(20)

现在,possibleInt内部包裹的值实际上已经被更新了。

你可能会遇到的一个常见场景是需要解包多个可选值。一个选择是简单地嵌套可选绑定:

if let actualString = possibleString {
    if let actualArray = possibleArray {
        if let actualInt = possibleInt {
            print(actualString)
            print(actualArray)
            print(actualInt)
        }
    }
}

然而,这可能会很麻烦,因为它每次都会增加缩进级别以保持代码的整洁。相反,你实际上可以将多个可选绑定列在单个语句中,用逗号分隔:

if let actualString = possibleString,
    let actualArray = possibleArray,
    let actualInt = possibleInt
{
    print(actualString)
    print(actualArray)
    print(actualInt)
}

这通常会产生更易读的代码。

在函数内进行简洁的可选绑定还有另一种很好的方法,就是使用 guard 语句。这样,你可以进行一系列解包操作,而无需增加代码的缩进级别:

func someFunc2() {
    guard let actualString = possibleString,
        let actualArray = possibleArray,
        let actualInt = possibleInt
    else {
        return
    }

    print(actualString)
    print(actualArray)
    print(actualInt)
}

这个结构允许我们在 guard 语句之后访问解包后的值,因为 guard 语句保证了如果可选值为 nil,我们会在到达该代码之前退出函数。

这种解包方式很好,但说可选绑定是访问可选值内值最安全的方式,这意味着存在一种不安全地解包可选值的方法。这种方法被称为强制解包

强制解包

解包可选值最短的方式是使用强制解包。当使用时,在变量名后使用感叹号(!):

possibleInt = 10
possibleInt! *= 2
print(possibleInt) // "Optional(20)"

然而,它被认为是不安全的,是因为如果你尝试解包当前为 nil 的可选值,整个程序将会崩溃:

nilInt! *= 2 // fatal error

你得到的完整错误是在解包可选值时意外地发现值为 nil。这是因为强制解包本质上是你个人保证可选值确实包含值的保证。这就是为什么它被称为“强制”。

因此,强制解包应仅限于有限的情况下使用。它绝不应该仅仅为了缩短代码而使用。相反,它只应在你可以从代码的结构保证它不可能是 nil 的情况下使用,即使它被定义为可选的。即使在那种情况下,你也应该看看是否可以使用非可选变量。你可能会使用的另一个地方是如果你的程序真的无法从可选值为 nil 中恢复。在这种情况下,你应该至少考虑向用户显示错误,这总比程序崩溃要好。

一种可能有效使用场景的例子是与延迟计算值一起使用。延迟计算值是在第一次访问时才创建的值。为了说明这一点,让我们考虑一个代表文件系统目录的假设类。它将有一个列出其内容的属性,该属性是延迟计算的。代码将类似于以下代码:

class FileSystemItem {}
class File: FileSystemItem {}
class Directory: FileSystemItem {
    private var realContents: [FileSystemItem]?
    var contents: [FileSystemItem] {
        if self.realContents == nil {
            self.realContents = self.loadContents()
        }
        return self.realContents!
    }

    private func loadContents() -> [FileSystemItem] {
        // Do some loading
        return []
    }
}

在这里,我们定义了一个名为 FileSystemItem 的超类,FileDirectory 都继承自它。目录的内容是一个 FileSystemItem 列表。我们定义 contents 为一个计算变量,并将实际值存储在 realContents 属性中。计算属性检查 realContents 是否有值加载;如果没有,它将加载内容并将它们放入 realContents 属性中。基于这种逻辑,我们知道当我们到达返回语句时,realContents 内部肯定会有一个值,因此使用强制解包是绝对安全的。

nil 合并

除了可选绑定和强制解包之外,Swift 还提供了一个名为 nil 合并运算符 的运算符来解包可选值。它由一个双问号(??)表示。基本上,这个运算符允许我们为变量或操作结果提供一个默认值,以防它是 nil。这是一种将可选值安全转换为非可选值的方法,其代码看起来可能如下所示:

var possibleString: String? = "An actual string"
print(possibleString ?? "Default String") // "An Actual String"

在这里,我们要求程序打印出 possibleString,除非它是 nil;如果是 nil,则只打印 "Default String"。由于我们确实给它赋了一个值,所以它打印出了那个值,并且值得注意的是,它是以常规变量的形式打印出来的,而不是可选值。这是因为无论如何,一个实际值都将被打印出来。

这是一个在默认值有意义时,简洁且安全地解包可选值的好工具。

可选链

在 Swift 中,一个常见的场景是必须从一个可选值中计算出一些内容。如果可选值有值,你将希望将计算结果存储在其上,但如果它是 nil,则结果应直接设置为 nil:

var invitee: String? = "Sarah"
var uppercaseInvitee: String?
if let actualInvitee = invitee {
    uppercaseInvitee = actualInvitee.uppercaseString
}

这相当冗长。为了在不安全的方式中缩短它,我们可以使用强制解包:

uppercaseInvitee = invitee!.uppercaseString

然而,可选链允许我们安全地做到这一点。本质上,它允许对可选值进行可选操作。当调用操作时,如果可选值是 nil,它立即返回 nil;否则,它返回对可选值内部值执行操作的结果:

uppercaseInvitee = invitee?.uppercaseString

因此,在这个调用中,invitee 是一个可选值。我们不是解包它,而是在它后面放置一个问号(?),然后进行可选操作。在这种情况下,我们要求获取它的 uppercaseInvitee 属性。如果 invitee 是 nil,则 uppercaseInvitee 立即设置为 nil,甚至没有尝试访问 uppercaseString。如果它实际上包含一个值,则 uppercaseInvitee 被设置为包含值的 uppercaseString 属性。请注意,所有可选链都返回一个可选结果。

你可以以这种方式将任意多的调用(可选和非可选)一起链起来:

var invitees: [String]? = ["Sarah", "Jamison", "Marcos", "Roana"]
invitees?.first?.uppercaseString.hasPrefix("A")

这段代码检查邀请人列表的第一个元素是否以字母A开头,即使它是小写A。首先,它使用可选链,以防invitees是 nil。然后对first的调用使用额外的可选链,因为该方法返回一个可选String。然后我们调用uppercaseString,它不返回可选值,这使得我们可以在结果上访问hasPrefix而无需使用另一个可选链。如果在任何时刻任何一个可选值是 nil,结果将是 nil。这可以有两个不同的原因:

  • 邀请人是 nil

  • first返回nil,因为数组是空的

如果链一直到达uppercaseString,就没有失败路径了,它肯定会返回一个实际值。你会注意到在这个链中恰好使用了两个问号,并且有两个可能的失败原因。

起初,可能难以理解何时应该和不应该使用问号来创建一系列调用;规则是如果链中的前一个元素返回一个可选值,则始终使用问号。然而,为了做好准备,让我们看看如果你不正确地使用可选链会发生什么:

invitees.first // Error

在这种情况下,我们试图在不使用链的情况下直接在可选值上调用一个方法,所以我们得到一个错误,说可选类型'[String]?'的值未展开;你是指要使用'!'还是'?'吗?。这不仅告诉我们值没有被展开,甚至建议两种处理问题的常见方法:强制展开或可选链。

我们还有试图不恰当地使用可选链的情况:

var otherInvitees = ["Kai", "Naya"]
otherInvitees?.first // Error

这里,我们得到一个错误,说不能在非可选值类型'[String]'上使用可选链。了解你可能会在犯错时看到的错误感非常好;这样你就可以快速纠正它们,因为我们都时不时地会犯愚蠢的错误。

可选链的另一个伟大特性是它可以用于对不实际返回值的可选进行方法调用:

invitees?.removeAll()

在这种情况下,我们只想在可选数组中确实有值时调用removeAll。所以,用这段代码,如果有值,所有元素都会从它中移除;否则,它将保持 nil。

最后,链式调用是一个编写简洁代码的好选择,同时仍然保持表达性和可理解性。

隐式展开的可选

另一种可选类型称为隐式展开的可选。实际上有两种看待隐式展开可选的方式;一种方式是说它是一个也可以是 nil 的正常变量;另一种方式是说它是一个你不必展开即可使用的可选。重要的是要理解的是,与可选类似,它们可以是 nil,但你不必像正常变量那样展开它们。

你可以用感叹号(!)而不是问号(?)在类型名后定义一个隐式未包装的可选类型:

var name: String!

与常规可选类型类似,隐式未包装的可选类型不需要给出一个初始值,因为它们默认为 nil。

起初这听起来像是两者的最佳之处,但现实中它更像是两者的最坏之处。即使隐式未包装的可选类型不需要解包,如果它在使用时为 nil,它也会使你的整个程序崩溃:

name.uppercaseString // Crash

想象它们的一个好方法是,每次使用时,它都会隐式地进行强制解包。感叹号放在其类型声明中,而不是每次使用时。这可能会引起问题,因为它看起来与其他任何变量相同,除了它的声明方式。这意味着它非常不安全,与普通可选类型不同。

所以如果隐式未包装的可选类型是两者的最坏之处,并且如此不安全,那么它们为什么甚至存在呢?现实是,在罕见的情况下,它们是必要的。它们用于变量不是真正可选的情况,但你也不能给它一个初始值。这对于具有非可选成员变量但无法在初始化期间设置的定制类型几乎总是这种情况。

这种情况的一个罕见例子是在 iOS 中的视图。正如我们之前讨论的,UIKit 是苹果为 iOS 开发提供的框架。在这个框架中,苹果提供了一个名为UIView的类,用于在屏幕上显示内容。苹果还在 Xcode 中提供了一个名为 Interface Builder 的工具,它允许你在可视化编辑器中而不是在代码中设计这些视图。许多以这种方式设计的视图将需要引用其他可以在以后通过编程访问的视图。当一个这样的视图被加载时,它是在没有任何连接的情况下初始化的,然后所有连接都会被建立。一旦所有连接都建立好了,视图上就会调用一个名为awakeFromNib的函数。这意味着这些连接在初始化期间不可用,但在awakeFromNib被调用后可用。这种操作顺序也确保了awakeFromNib总是在实际使用视图之前被调用。这是一个需要使用隐式未包装可选类型的情况。成员变量可能无法在视图初始化后定义,当它完全加载时:

Import UIKit
class MyView: UIView {
    @IBOutlet var button: UIButton!
    var buttonOriginalWidth: CGFloat!

    override func awakeFromNib() {
        self.buttonOriginalWidth = self.button.frame.size.width
    }
}

注意,我们实际上声明了两个隐式未包装的可选类型。第一个是一个按钮的连接。我们知道这是一个连接,因为它前面有@IBOutlet。因为这个连接是在初始化之后才设置的,所以它被声明为隐式未包装的可选类型,但它们仍然保证在调用视图上的任何其他方法之前被设置。

这就引出了我们解包第二个变量buttonOriginalWidth的情况,这是隐式进行的,因为我们需要等待连接建立,才能确定按钮的宽度。在调用awakeFromNib之后,我们可以安全地将buttonbuttonOriginalWidth视为非可选类型。

你可能已经注意到,我们必须深入到应用程序开发中,才能找到一个隐式可选类型的有效用例,这可能是由于 UIKit 是用 Objective-C 实现的,正如我们将在第十章第十章。利用过去 – 理解和翻译 Objective-C 中了解到的那样。这是对这样一个事实的又一证明,即它们应该被谨慎使用。

调试可选类型

我们已经看到了由于可选类型导致的几种常见的编译器错误。如果我们尝试在一个本应应用于包装值的可选类型上调用方法,我们会得到一个错误。如果我们尝试解包一个实际上不是可选类型的值,我们也会得到一个错误。我们还需要准备好可选类型可能导致的运行时错误。

正如我们之前讨论的,如果尝试强制解包一个为 nil 的可选类型,它会导致运行时错误,也被称为崩溃。这既可能发生在显式强制解包中,也可能发生在隐式强制解包中。如果你到目前为止已经遵循了我在本章中的建议,这种情况应该很少发生。然而,我们最终都会与第三方代码打交道,也许他们很懒惰,或者他们使用强制解包来强制他们的代码使用期望。

此外,我们有时都会因为懒惰而感到疲惫。当你对编写应用程序的核心功能感到兴奋时,担心所有边缘情况可能会令人疲惫或气馁。我们可能会在担心主要功能的同时临时使用强制解包,并计划稍后回来处理它。毕竟,在开发过程中,让强制解包导致应用程序的开发版本崩溃,比它未处理该边缘情况而默默失败要好。我们甚至可能决定,处理边缘情况不值得开发努力,因为开发应用程序的每一件事都是一种权衡。无论如何,我们需要快速识别强制解包导致的崩溃,以免浪费额外的时间试图找出出了什么问题。

当应用程序尝试解包一个 nil 值时,如果你当前正在调试应用程序,Xcode 会显示尝试解包的行。该行会报告存在一个EXC_BAD_INSTRUCTION错误,你也会在控制台中收到一条消息,说致命错误:在解包可选值时意外发现 nil

调试可选类型

你有时还必须查看当前正在调用失败代码的代码。为此,你可以使用 Xcode 中的调用栈。调用栈是所有到达此位置的功能调用的完整路径。所以,如果你有function1调用function2,然后function2调用function3function3将在顶部,function1将在底部。一旦执行退出function3,它将从栈中移除,所以你将只有function2function1的顶部。

当你的程序崩溃时,Xcode 会自动显示调用栈,但你也可以通过导航到视图 | 导航器 | 显示调试导航器来手动显示它。它看起来会类似于以下截图:

调试可选参数

在这里,你可以点击不同的代码级别来查看事物的状态。如果程序在 Apple 的框架中崩溃,而你无法访问代码,这将变得更加重要。在这种情况下,你可能需要将调用栈向上移动到你的代码调用框架的点。你也许还能够查看函数的名称,以帮助你弄清楚可能出了什么问题。

在调用栈的任何地方,你都可以查看调试器中变量的状态,如下所示:

调试可选参数

如果你看不到这个变量的视图,你可以通过点击屏幕右下角第二个从右边的按钮来显示它,该按钮将被灰色显示。在这里,你可以看到invitee确实是 nil,这就是导致崩溃的原因。

虽然调试器非常强大,但如果发现它没有帮助你找到问题,你可以在代码的重要部分添加print语句。只要你不像前一个例子那样强制展开可选参数,打印可选参数总是安全的。正如我们之前看到的,当可选参数被打印时,如果没有值,它会打印nil,如果有值,它会打印Optional()

调试是成为一名高效开发者极其重要的部分,因为我们都会犯错误并创建 bug。成为一名优秀的开发者意味着你能够快速识别问题,并在之后很快地理解如何修复它们。这主要来自于实践,但也会来自于对你代码的实际运行情况有牢固的掌握,而不是简单地通过试错来适应你在网上找到的代码以适应你的需求。

基本实现

到目前为止,你应该已经对可选参数有了相当强的理解,以及如何使用和调试它,但深入了解一下可选参数的实际工作原理将非常有价值。

实际上,可选类型的问号语法只是一个特殊的缩写。写String?相当于写Optional<String>。写String!相当于写ImplicitlyUnwrappedOptional<String>。Swift 编译器有简写版本,因为它们非常常用。这使得代码更加简洁易读。

如果你使用长形式声明一个可选类型,你可以通过按住Command键并点击单词Optional来查看 Swift 的实现。在这里,你可以看到Optional被实现为一个枚举。稍微简化一下代码,我们有:

enum Optional<T> {
    case None
    case Some(T)
}

因此,我们可以看到可选类型实际上有两种情况:NoneSomeNone代表 nil 的情况,而Some情况有一个关联的值,即可选类型中包裹的值。解包是检索Some情况中关联值的过程。

这一部分你还没有看到的是角度括号语法(<T>)。这被称为泛型,它本质上允许枚举具有任何类型的关联值。我们将在第六章让 Swift 为你工作 – 协议和泛型中深入探讨泛型。

认识到可选类型仅仅是枚举将帮助你理解如何使用它们。这也让你对概念是如何建立在其他概念之上的有了更深的洞察。可选类型在你意识到它们只是两个情况的枚举之前看起来非常复杂。一旦你理解了枚举,你就可以很容易地理解可选类型了。

摘要

在本章中,我们只覆盖了单个概念,即可选类型,但我们已经看到这是一个相当密集的话题。我们看到在表面层面上,可选类型相当直接。它们是一种表示没有值的变量的方式。然而,有多种方法可以访问可选类型中包裹的值,并且它们有非常特定的用例。可选绑定始终是首选,因为它是最安全的方法,但如果我们确信可选类型不是 nil,我们也可以使用强制解包。我们还有一个名为隐式解包可选的类型,用于延迟分配一个不打算是可选的变量;然而,我们应该谨慎使用它,因为几乎总是有更好的替代方案。

现在我们对可选类型有了牢固的理解,我们可以开始看看表面上可能看似微不足道但实际上开启了一个全新世界的东西。Swift 中的所有函数实际上都是变量或常量本身。我们将在下一章探讨这意味着什么。

第五章. 现代范式 – 闭包和函数式编程

到目前为止,我们一直在使用被称为面向对象编程的范式进行编程,其中程序中的所有内容都表示为可以操作并传递给其他对象的实体。这是创建应用程序最流行的方式,因为它是一种非常直观的思考软件的方式,并且与苹果设计的框架非常契合。然而,这种技术有一些缺点。最大的缺点是数据的状态可能非常难以跟踪和推理。如果我们有成千上万的不同的对象在我们的应用程序中漂浮,每个对象都有不同的信息,那么找到错误发生的地方可能很困难,理解整个系统如何结合在一起也可能很困难。另一种可以帮助解决这个问题的编程范式被称为函数式编程

一些编程语言被设计为仅使用函数式编程,但 Swift 主要被设计为一种面向对象的语言,具有使用函数式编程概念的能力。在本章中,我们将探讨如何在 Swift 中实现这些函数式编程概念以及它们的应用。为此,我们将涵盖以下主题:

  • 函数式编程哲学

  • 闭包

  • Swift 中函数式编程的构建块

  • 惰性求值

  • 示例

函数式编程哲学

在我们开始编写代码之前,让我们讨论一下函数式编程背后的思想和动机。

状态和副作用

函数式编程使得单独思考每个组件变得容易得多。这包括类型、函数和方法等。如果我们能够理解输入到这些代码组件中的所有内容以及从它们返回的所有内容,我们就可以轻松地分析代码,以确保没有错误并且性能良好。每个类型都使用一定数量的参数创建,程序中的每个方法和函数都有一定数量的参数和返回值。通常,我们认为这些是唯一的输入和输出,但现实情况是,往往还有更多。我们将这些额外的输入和输出称为状态

在更广泛的意义上,状态是任何可以更改的存储信息,无论其是暂时的。让我们考虑一个简单的double函数:

func double(input: Int) -> Int {
    return input * 2
}

这是一个无状态函数的绝佳例子。无论程序中的整个宇宙发生什么,只要提供相同的输入,这个方法总是会返回相同的值。输入2总是会返回4

现在,让我们看看一个有状态的方法:

struct Ball {
    var radius: Double

    mutating func growByAmount(amount: Double) -> Double {
        self.radius = self.radius + amount
        return self.radius
    }
}

如果你反复调用这个方法,在同一个Ball实例上使用相同的输入,每次都会得到不同的结果。这是因为这个方法中有一个额外的输入,即它被调用的实例。它通常被称为self。实际上,self既是这个方法的输入也是输出,因为原始的半径值会影响输出,而radius在方法结束时会被改变。只要记住self总是另一个输入和输出,这仍然不是很难理解。然而,你可以想象,对于一个更复杂的数据结构,跟踪代码中每一个可能的输入和输出可能会很困难。一旦这种情况开始发生,就更容易产生错误,因为我们几乎肯定会遇到意外的输入导致意外的输出。

副作用是更糟糕的一种额外输入或输出的类型。它们是对状态的意外更改,看似与正在运行的代码无关。如果我们简单地将前面的方法重命名为一个稍微不那么清晰的名字,它对实例的影响就会变得意外:

mutating func currentRadiusPlusAmount(amount: Double) -> Double {
    self.radius = self.radius + amount
    return self.radius
}

根据其名称,你不会期望这个方法会改变radius的实际值。这意味着,如果你没有看到实际的实现,你会期望这个方法在同一个实例上以相同的数量调用时保持返回相同的值。不可预测性对于程序员来说是一件可怕的事情。

在其最严格的形式中,函数式编程消除了所有状态和副作用。我们永远不会在 Swift 中走那么远,但我们会经常使用函数式编程技术来减少状态和副作用,以极大地提高代码的可预测性。

声明式代码与命令式代码

除了可预测性之外,函数式编程对我们代码的另一个影响是它变得更加声明式。这意味着代码向我们展示了我们期望信息如何通过我们的应用程序流动。这与我们使用面向对象编程所做的是相反的,我们称之为命令式代码。这是编写一个循环遍历数组以将某些元素添加到新数组中的代码与在数组上运行过滤器的区别。前者可能看起来像这样:

var originalArray = [1,2,3,4,5]
var greaterThanThree = [Int]()
for num in originalArray {
    if num > 3 {
        greaterThanThree.append(num)
    }
}
print(greaterThanThree) // [4,5]

在数组上运行过滤器看起来可能像这样:

var originalArray = [1,2,3,4,5]
var greaterThanThree = originalArray.filter {$0 > 3}
print(greaterThanThree) // [4,5]

如果你现在还不理解第二个例子,不要担心。这是我们将在本章的其余部分中要涵盖的内容。一般想法是,在命令式代码中,我们将发出一系列命令,其中代码的意图是次要的、微妙的想法。为了理解我们正在创建一个只包含大于3的元素的originalArray的副本,我们必须阅读代码并在心理上逐步通过正在发生的事情。在第二个例子中,我们在代码本身中声明我们正在过滤原始数组。最终,这些想法存在于一个光谱上,很难有 100%的声明式或命令式,但每个原则都很重要。

到目前为止,在我们的命令式代码中,大部分只是定义了我们的数据应该看起来像什么以及如何操作它。即使有高质量的抽象,理解一段代码通常也涉及到在许多方法之间跳转,追踪执行过程。在声明式代码中,逻辑可以更加集中,并且通常更容易阅读,基于命名良好的方法。

你也可以将命令式代码想象成一个工厂,其中每个人都在思考着如何完整地制作一辆车,而将声明式代码想象成一个拥有装配线的工厂。为了理解在一个非装配线工厂中人们正在做什么,你必须一步一步地观察整个过程展开。他们可能会在不同时间拉入各种工具,这会很难跟上。在一个装配线工厂中,你可以通过观察装配线上的每一个步骤来确定正在发生什么。

现在我们已经了解了一些函数式编程的动机,让我们来看看使它成为可能的 Swift 特性。

闭包

在 Swift 中,函数被视为一等公民,这意味着它们可以像任何其他类型一样被对待。它们可以被分配给变量,并可以在其他函数之间传递。当这样处理时,我们称它们为闭包。这是编写更多声明式代码的一个极其关键的部分,因为它允许我们将功能视为对象。我们不再将函数视为要执行的代码集合,而是可以开始更多地将其视为完成某事的配方。就像你可以把几乎任何食谱交给厨师来烹饪一样,你可以创建接受闭包以执行某些可定制行为的类型和方法。

闭包作为变量

让我们来看看 Swift 中闭包是如何工作的。在变量中捕获闭包的最简单方法是在定义函数后,使用其名称将其分配给变量:

func double(input: Int) -> Int {
        return input * 2
}

var doubleClosure = double
print(doubleClosure(2)) // 4

正如你所见,doubleClosure在被分配后可以像普通函数名一样使用。实际上,使用double和使用doubleClosure之间没有区别。请注意,我们现在可以将这个闭包视为一个对象,它将加倍任何传递给它的东西。

如果你按住选项键并单击 doubleClosure 的名称,你会看到类型被定义为 (Int) -> Int。任何闭包的基本类型是 (ParameterType1, ParameterType2, …) -> ReturnType

使用这种语法,我们还可以直接定义我们的闭包,例如:

var doubleClosure2 = { (input: Double) -> Double in
    return input * 2
}

我们以花括号 {} 开始和结束任何闭包。然后,我们在开括号后面跟闭包的类型,这将包括输入参数和返回值。最后,我们使用 in 关键字将类型定义与实际实现分开。

返回类型的不存在被定义为 Void()。尽管你可能看到一些程序员使用括号,但 Void 更适合用于返回声明:

var printDouble = { (input: Double) -> Void in
    print(input * 2)
}

实际上,() 是一个空的元组,意味着它不包含任何值,它更常用于输入参数,以防闭包根本不接受任何参数:

var makeHelloWorld = { () -> String in
    return "Hello World!"
}

到目前为止,尽管我们可以通过将其转换为闭包来改变我们对代码块的想法,但这并不特别有用。要真正使闭包有用,我们需要开始将它们传递给其他函数。

闭包作为参数

我们可以使用之前看到的相同类型语法定义一个函数,以闭包作为参数:

func firstInNumbers(
    numbers: [Int],
    passingTest: (number: Int) -> Bool
    ) -> Int?
{
    for number in numbers {
        if passingTest(number: number) {
            return number
        }
    }
    return nil
}

在这里,我们有一个函数,它可以找到数组中通过某些任意测试的第一个数字。函数声明末尾的语法可能令人困惑,但如果你从内向外工作,它应该很清楚。passingTest 的类型是 (number: Int) -> Bool。这是整个 firstInNumbers 函数的第二个参数,它返回一个 Int?。如果我们想使用这个函数来找到大于三的第一个数字,我们可以创建一个自定义测试并将其传递给函数:

let numbers = [1,2,3,4,5]
func greaterThanThree(number: Int) -> Bool {
    return number > 3
}
var firstNumber = firstInNumbers(numbers, greaterThanThree)
print(firstNumber) // "Optional(4)"

在这里,我们实际上是将一小块功能传递给 firstInNumbers: 函数,这使我们能够极大地增强单个函数通常能做的事情。这是一个极其有用的技术。通过遍历数组来查找元素可能非常冗长。相反,我们可以使用这个函数来查找元素,只显示代码的重要部分:测试。

我们甚至可以直接在函数调用中定义我们的测试:

firstNumber = firstInNumbers(numbers, passingTest: { (number: Int) -> Bool in
    return number > 3
})

尽管这更简洁,但它相当复杂;因此,Swift 允许我们省略一些不必要的语法。

语法糖

首先,我们可以利用类型推断来推断 number 的类型。编译器知道根据 firstInNumbers:passingTest: 的定义,数字需要是 Int。它还知道闭包必须返回 Bool。现在,我们可以重写我们的调用,如下所示:

firstNumber = firstInNumbers(numbers, passingTest: { (number) in
    return number > 3
})

这样看起来更简洁,但括号围绕 number 的部分不是必需的;我们可以省略这些。此外,如果我们把闭包作为函数的最后一个参数,我们可以在函数调用外提供闭包:

firstNumber = firstInNumbers(numbers) { number in
    return number > 3
}

注意,函数参数的闭包括号从闭包之后移动到了闭包之前。这看起来相当不错,但我们还可以更进一步。对于单行闭包,我们甚至不需要写 return 关键字,因为它已经隐含了:

firstNumber = firstInNumbers(numbers) { number in
    number > 3
}

最后,我们并不总是需要给闭包的参数命名。如果你完全省略了名称,每个参数都可以使用语法 $<ParameterIndex> 来引用。就像数组一样,索引从 0 开始。这有助于我们非常简洁地在单行中编写这个调用:

firstNumber = firstInNumbers(numbers) { $0 > 3 }

这与我们的原始语法相去甚远。你可以混合使用所有这些不同的技术,以确保你的代码尽可能易于理解。正如我们之前讨论过的,可理解性是在简洁和清晰之间取得平衡。在每种情况下,都取决于你决定省略多少语法。对我来说,如果没有名称,闭包并不立即清晰。我首选的语法是在调用中使用参数名称:

firstNumber = firstInNumbers(numbers, passingTest: {$0 > 3})

这清楚地表明闭包是一个测试,看看我们想要从列表中提取哪个数字。

现在我们已经知道了闭包是什么以及如何使用它,我们可以讨论一些 Swift 的核心特性,这些特性允许我们编写函数式风格的代码。

Swift 中函数式编程的构建块

首先要意识到的是,Swift 不是一个函数式编程语言。在其核心,它始终是一个面向对象编程语言。然而,由于 Swift 中的函数是一等公民,我们可以使用一些核心技术。Swift 提供了一些内置方法来帮助我们入门。

过滤

我们将要讨论的第一个方法被称为 filter。正如其名称所暗示的,这个方法用于过滤列表中的元素。例如,我们可以过滤 numbers 数组,只包含偶数:

var evenNumbers = numbers.filter({ element in
    element % 2 == 0
}) // [2, 4]

我们提供的用于过滤的闭包将对数组中的每个元素调用一次。它的任务是如果元素需要包含在结果中则返回 true,否则返回 false。前面的闭包利用了隐含的返回值,如果数字除以二有余数则简单地返回 true

注意,过滤不会改变 numbers 变量;它只是返回一个过滤后的副本。改变值将修改状态,这是我们想要避免的。

这种方法为我们提供了一种简洁的方式来以我们想要的方式过滤列表。它也是构建一个可以应用于数据的转换词汇表的开始。有人可能会争论,所有应用只是将数据从一种形式转换为另一种形式,所以这个词汇表帮助我们实现任何应用中我们想要的最高功能。

减少

Swift 还提供了一个名为 reduce 的方法。reduce 的目的是将列表缩减为单个值。reduce 通过遍历每个值并将其与代表所有前元素的单一值组合来工作。这就像为食谱在碗中混合一堆配料一样。我们将一次取一个配料,并将其放入碗中,直到我们只剩下一个装有所有配料的碗。

让我们看看 reduce 函数在代码中的样子。我们可以使用它来计算数字数组中的值总和:

var sum = numbers.reduce(0, combine: { previousSum, element in
    previousSum + element
}) // 15

如你所见,reduce 函数接受两个参数。第一个参数是用于开始组合列表中每个项的值。第二个是一个闭包,它将执行组合。与 filter 类似,这个闭包对数组中的每个元素调用一次。闭包的第一个参数是在将每个前一个元素与初始值组合后的值。第二个参数是下一个元素。

因此,当闭包第一次被调用时,它使用的是 0(初始值)和 1(列表中的第一个元素);然后返回 1。这意味着它随后再次使用 1(上一次调用的值)和 2(列表中的下一个元素)返回 3。这个过程会一直持续,直到它将运行总和 10 与最后一个元素 5 结合,得到最终结果 15。一旦我们将其分解,它就变得非常简单。

Reduce 是另一个可以添加到我们的技能集的优秀词汇。它可以通过分析数据将任何信息列表缩减为单个值,例如从图像列表生成文档等。

此外,我们还可以开始将我们的函数串联起来。如果我们想找到列表中所有偶数的总和,我们可以运行以下代码:

var evenSum = numbers.filter({$0 % 2 == 0}).reduce(0, combine: {$0 + $1}) // 6

现在,我们可以做一件事情来缩短这个过程。每个算术运算,包括加法(+),实际上只是另一个函数或闭包。加法是一个接受相同类型的两个值并返回它们的和的函数。这意味着我们可以简单地将加法函数作为我们的组合闭包传递:

evenSum = numbers.filter({$0 % 2 == 0}).reduce(0, combine: +) // 6

现在我们开始变得复杂了!

此外,请注意,组合的值不需要与原始列表中的类型相同。而不是求和值,我们可以将它们全部组合成一个字符串:

let string = numbers.reduce("", combine: {"\($0)\($1)"}) // "12345"

在这里,我使用字符串插值来创建一个以运行值开始并以下一个元素结束的字符串。

Map

Map 是一个将列表中的每个元素转换成另一个值的方法。例如,我们可以给列表中的每个数字加一:

let plusOne = numbers.map({ element -> Int in
    return element + 1
}) // [2, 3, 4, 5, 6]

如你或许能猜到的,map 函数所采用的闭包对列表中的每个元素只调用一次。作为一个参数,它接受元素并期望返回要添加到结果数组中的新值。

就像 reduce 一样,转换后的类型不需要匹配。我们可以将所有的数字都转换为字符串:

let strings = numbers.map {String($0)}

map 非常灵活。它可以用来将数据列表转换为视图列表以显示数据,将图像路径列表转换为加载的图像,等等。

map 方法是一个对列表中的每个元素执行计算的绝佳选择,但它只应该在将计算结果放回列表中合理时使用。技术上,你可以用它来遍历列表并执行其他操作,但在那种情况下,for-in 循环更为合适。

排序

我们将要讨论的最后一个内置函数式方法是称为 sorted。正如其名所示,sorted 允许你改变列表的顺序。例如,如果我们想将我们的数字列表重新排序,从大到小:

numbers.sort({ element1, element2 in
    element1 > element2
}) // [5, 4, 3, 2, 1]

传递给 sorted 的闭包称为 isOrderedBefore。这意味着它接受列表中的两个元素作为输入,如果第一个元素应该在第二个元素之前排序,则应返回 true。我们无法依赖闭包被调用一定次数,也不能依赖它将被调用的元素,但它将一直被调用,直到排序算法有足够的信息来提出新的顺序。

在我们的情况下,当第一个参数大于第二个参数时,我们返回 true。这导致较大的元素始终出现在较小的元素之前。

这是一个很好的方法,因为排序是一个非常常见的任务,并且数据通常需要根据用户的交互以多种方式排序。使用这种方法,你可以设计多个排序闭包,并根据用户的交互更改正在使用的闭包。

这些如何影响代码的状态和本质

有更多内置的功能方法,我们将在下一章关于泛型的章节中学习如何编写自己的方法,但这些都是核心的几个方法,可以帮助你以函数式的方式思考某些问题。那么,这些方法是如何帮助我们避免状态的?

这些方法以及其他方法可以以无限的方式组合起来,以转换数据和执行操作。无论组合多么复杂,都无法干扰每个单独的步骤。因为没有副作用,唯一的输入是前一步的结果,唯一的输出是传递给下一步的内容。

你还可以看到,复杂的转换都可以在一个简洁且集中的地方声明。代码的读者不需要追踪许多变量的变化值;他们只需查看代码,就可以看到它将经历哪些过程。

延迟评估

Swift 的一个强大功能是能够使这些操作延迟评估。这意味着,就像一个懒惰的人会做的那样,只有在绝对必要且尽可能晚的时候才会计算值。

首先,重要的是要意识到这些方法的执行顺序。例如,如果我们只想将我们的数字列表的第一个元素映射到字符串:

var firstString = numbers.map({String($0)}).first

这工作得很好,但事实上我们实际上将每个数字都转换为字符串,只是为了得到第一个。这是因为链的每一步都必须在执行下一步之前完成。为了防止这种情况,Swift 有一个内置的名为lazy的方法。

懒加载创建了一个新的容器版本,它仅在特定请求时从其中提取特定值。这意味着懒加载本质上允许每个元素一次通过一系列函数,正如它所需的那样。你可以把它想象成一个懒版本的工人。如果你让一个懒人查找喀麦隆的首都,他们不会在得到答案之前编译所有国家的首都列表。他们只会做得到那个特定答案所必需的工作。这项工作可能涉及多个步骤,但他们只需为你要询问的特定国家做这些步骤。

现在,让我们看看代码中懒加载的样子。你使用它将普通列表转换为懒列表:

firstString = numbers.lazy.map({String($0)}).first

现在,我们不再直接在numbers上调用map,而是在numbers的懒加载版本上调用它。这使得每次从结果请求值时,它只处理输入数组中的一个元素。在我们的前一个例子中,map方法只执行了一次。

这甚至适用于遍历结果:

let lazyStrings = numbers.lazy.map({String($0)})
for string in lazyStrings {
    print(string)
}

每个数只有在 for-in 循环的下一个迭代中才会被转换为字符串。如果我们提前退出循环,其余的值将不会被计算。这是一种节省处理时间的好方法,尤其是在大型列表上。

示例

让我们看看在实践中这看起来是什么样子。我们可以使用本章学到的某些技术来编写不同的,可能更好的派对邀请者实现。

我们可以先定义相同的输入数据:

//: List of people to invite
let invitees = [
    "Sarah",
    "Jamison",
    "Marcos",
    "Roana",
    "Neena",
]

//: Dictionary of shows organized by genre
var showsByGenre = [
    "Comedy": "Modern Family",
    "Drama": "Breaking Bad",
    "Variety": "The Colbert Report",
]

在这个实现中,我们正在创建一个邀请者列表,它只是一个常量列表的名字和按类型分类的节目字典变量。这是因为我们将把我们的邀请者列表映射到邀请文本列表。当我们进行映射时,我们必须为当前受邀者选择一个随机类型,为了避免重复分配同一个类型,我们可以从字典中移除该类型。

因此,让我们编写随机的genre函数:

func pickAndRemoveRandomGenre() -> (genre: String, example: String)? {
    let genres = Array(showsByGenre.keys)
    guard genres.count > 0 else {
        return nil
    }

    let genre = genres[Int(rand()) % genres.count]
    let example = showsByGenre[genre]!
    showsByGenre[genre] = nil
    return (genre: genre, example: example)
}

我们首先创建一个只包含按类型分类的节目字典的键的数组。然后,如果没有剩余的类型,我们简单地返回 nil。否则,我们随机选择一个类型,从字典中移除它,并返回它和节目示例。

现在,我们可以使用这个函数将受邀者映射到邀请列表:

let invitations: [String] = invitees
.map({ name in
    guard let (genre, example) = pickAndRemoveRandomGenre() else {
        return "\(name), just bring yourself"
    }
    return "\(name), bring a \(genre) show"
        + "\n\(example) is a great \(genre)"
})

在这里,我们尝试随机选择一个流派。如果我们做不到,我们就返回一个邀请,告诉被邀请者只需带上自己。如果我们能做到,我们就返回一个邀请,告诉他们应该带上什么流派,以示例演出为例。这里需要注意的新事物是我们正在使用字符串中的序列 "\n"。这是一个换行符,它表示文本中应该开始新的一行。

最后一步是打印出邀请函。为了做到这一点,我们可以将邀请函作为由换行符连接的字符串打印出来:

print(invitations.joinWithSeparator("\n"))

这工作得相当好,但有一个问题。我们列出的第一个被邀请者总是会分配一个流派,因为它们被处理的顺序永远不会改变。为了解决这个问题,我们可以在开始映射函数之前编写一个函数来打乱被邀请者:

func shuffle(array: [String]) -> [String] {
    return array
        .map({ ($0, Int(rand())) })
        .sort({ $0.1 < $1.1 })
        .map({$0.0})
}

为了打乱一个数组,我们通过三个步骤进行:首先,我们将数组映射到一个元组中,包含原始元素和一个随机数。其次,我们根据这些随机数对元组进行排序。最后,我们将元组映射回它们的原始元素。

现在,我们只需要在我们的序列中添加对这个函数的调用:

let invitations: [String] = shuffle(invitees)
.map({ name in
    guard let (genre, example) = pickAndRemoveRandomGenre() else {
        return "\(name), just bring yourself"
    }
    return "\(name), bring a \(genre) show"
        + "\n\(example) is a great \(genre)"
})

这种实现方式并不一定比我们之前的实现更好,但它确实有其优势。我们通过将其实现为一系列数据转换来减少状态。其中的大问题是我们在流派字典中仍然维护状态。我们当然可以做更多来消除这一点,但这也给你一个很好的想法,了解我们如何开始以函数式方式思考问题。我们可以以更多方式思考一个问题,我们找到最佳解决方案的机会就越高。

摘要

在这一章中,我们不得不改变我们思考代码的方式。至少,这是一个很好的练习,这样我们就不会陷入编程习惯。我们已经涵盖了函数式编程背后的哲学以及它与面向对象编程的不同之处。我们探讨了闭包的具体内容以及它们如何在 Swift 中启用函数式编程技术。最后,我们探索了一些 Swift 内置的特定函数式方法。

一个真正伟大程序员的标志不是对一种工具了解很多,而是知道何时使用哪种工具。我们通过学习和练习使用大量不同的工具和技术来实现这一点,而从不过分依赖任何一种。

一旦你对闭包和函数式编程的概念感到舒适,你就可以继续学习我们的下一个主题,泛型。泛型是我们第一次有机会让 Swift 的强类型特性真正为我们所用。

第六章. 让 Swift 为你工作 – 协议和泛型

如我们在第二章中学习的,构建块 – 变量、集合和流程控制,Swift 是一种强类型语言,这意味着每份数据都必须有一个类型。我们不仅可以利用这一点来减少代码的混乱,还可以利用它让编译器为我们捕获错误。我们越早捕获错误,越好。除了最初不编写它们之外,我们最早可以捕获错误的地方是当编译器报告错误时。

Swift 提供的两个大工具,用于实现这一功能,被称为协议泛型。它们都利用类型系统来使编译器清楚我们的意图,以便它能为我们捕获更多错误。

在本章中,我们将涵盖以下主题:

  • 协议

  • 泛型

  • 扩展现有的泛型

  • 扩展协议

  • 使用协议和泛型

协议

我们将要查看的第一个工具是协议。协议本质上是一种类型可以签署的合同,指定它将向其他组件提供一定的接口。这种关系比子类与其超类之间的关系要松散得多。协议不会为它们实现的类型提供任何实现。相反,一个类型可以以任何它们喜欢的方式实现它们。

让我们看看我们如何定义一个协议,以便更好地理解它们。

定义一个协议

假设我们有一些需要与字符串集合交互的代码。我们实际上并不关心它们存储的顺序,我们只需要能够在容器内部添加和枚举元素。一个选项是简单地使用数组,但数组的功能远远超出了我们的需求。如果我们后来决定我们更愿意从文件系统中写入和读取元素呢?此外,如果我们想编写一个在变得非常大时智能地开始使用文件系统的容器呢?我们可以通过定义一个字符串容器协议来使我们的代码足够灵活,以便在未来做到这一点,这个协议是一个松散的合同,定义了我们希望它执行的操作。这个协议可能看起来类似于以下代码:

protocol StringContainer {
    var count: Int { get }
    mutating func addString(string: String)
    func enumerateStrings(handler: (string: String) -> Void)
}

预计,协议是通过使用 protocol 关键字定义的,类似于类或结构体。它还允许你指定计算属性和方法。你不能声明存储属性,因为无法直接创建协议的实例。你只能创建实现协议的类型实例。此外,你可能注意到,计算属性或方法都没有提供实现。在协议中,你只提供接口。

由于协议不能自行初始化,因此在我们创建实现它们的类型之前,它们是无用的。让我们看看我们如何创建一个实现我们的 StringContainer 协议的类型。

实现一个协议

类型“签署”协议的合同的方式与类从另一个类继承的方式相同,只是结构和枚举也可以实现协议:

struct StringBag: StringContainer {
    // Error: Type 'StringBag' does not conform to protocol 'StringContainer'
}

如您所见,一旦一个类型声明实现了特定的协议,如果它没有通过实现协议中定义的所有内容来履行合同,编译器将会报错。为了满足编译器的要求,我们现在必须实现count计算属性、修改函数addString:和函数enumerateStrings:,正如它们在协议中定义的那样。我们将通过在内部使用数组来持有我们的值来完成这项工作:

struct StringBag: StringContainer {
    var strings = [String]()
    var count: Int {
        return self.strings.count
    }

    mutating func addString(string: String) {
        self.strings.append(string)
    }

    func enumerateStrings(handler: (string: String) -> Void) {
        for string in self.strings {
            handler(string: string)
        }
    }
}

count属性将始终只返回我们的strings数组中的元素数量。addString:方法可以简单地添加字符串到我们的数组中。最后,我们的enumerateString:方法只需要遍历我们的数组,并对每个元素调用处理程序。

到目前为止,编译器已经满意地认为StringBag正在履行其与StringContainer协议的合同。

现在,我们可以类似地创建一个实现StringContainer协议的类。这一次,我们将使用内部字典而不是数组来实现它:

class SomeSuperclass {}
class StringBag2: SomeSuperclass, StringContainer {
    var strings = [String:Void]()
    var count: Int {
        return self.strings.count
    }

    func addString(string: String) {
        self.strings[string] = ()
    }

    func enumerateStrings(handler: (string: String) -> Void) {
        for string in self.strings.keys {
            handler(string: string)
        }
    }
}

在这里,我们可以看到,一个类可以同时从超类继承并实现一个协议。超类始终必须排在列表的第一位,但你可以实现尽可能多的协议,每个协议之间用逗号分隔。实际上,结构和枚举也可以实现多个协议。

在这个实现中,我们对字典做了一些稍微奇怪的事情。我们定义它没有值;它只是一个键的集合。这允许我们存储字符串,而不考虑它们的顺序。

现在,当我们创建实例时,我们可以将任何实现我们的协议的类型的任何实例分配给定义为我们的协议的变量,就像我们可以用超类一样:

var someStringBag: StringContainer = StringBag()
someStringBag.addString("Sarah")
someStringBag = StringBag2()
someStringBag.addString("Sarah")

当一个变量使用我们的协议作为其类型被定义时,我们只能通过协议定义的接口与之交互。这是一种很好的抽象实现细节并创建更灵活代码的方法。通过对我们想要使用的类型施加较少的限制,我们可以轻松地更改我们的代码,而不会影响我们使用它的方式。协议提供了与超类相同的利益,但以一种更加灵活和全面的方式,因为它们可以被所有类型实现,并且一个类型可以实现无限数量的协议。超类相对于协议的唯一好处是,超类与其子类共享它们的实现。

使用类型别名

可以使用一个名为类型别名的功能来使协议更加灵活。它们作为稍后将在实现协议时定义的类型的一个占位符。例如,我们不需要创建一个专门包含字符串的接口,我们可以创建一个可以容纳任何类型值的容器接口,如下所示:

protocol Container {
    typealias Element

    mutating func addElement(element: Element)
    func enumerateElements(handler: (element: Element) -> Void)
}

如你所见,此协议使用关键字 typealias 创建了一个名为 Element 的类型别名。它实际上并没有指定一个真实类型;它只是为稍后定义的类型提供一个占位符。在我们之前使用字符串的所有地方,我们只需将其称为 Element

现在,我们可以创建另一个使用新的 Container 协议和类型别名而不是 StringContainer 协议的字符串包。为此,我们不仅需要实现每个方法,还需要为类型别名提供一个定义,如下所示:

struct StringBag3: Container {
    typealias Element = String

    var elements = [Element:Void]()

    var count: Int {
        return elements.count
    }

    mutating func addElement(element: Element) {
        self.elements[element] = ()
    }

    func enumerateElements(handler: (element: Element) -> Void) {
        for element in self.elements.keys {
            handler(element: element)
        }
    }
}

使用此代码,我们指定了 Element 类型别名应该为此实现是字符串,使用等号 (=)。此代码继续使用类型别名来表示所有属性和方法,但你也可以使用字符串,因为它们现在实际上是同一件事。

使用类型别名实际上使我们能够轻松地创建另一个可以存储整数而不是字符串的结构:

struct IntBag: Container {
    typealias Element = Int

    var elements = [Element:Void]()

    var count: Int {
        return elements.count
    }

    mutating func addElement(element: Element) {
        self.elements[element] = ()
    }

    func enumerateElements(handler: (element: Element) -> Void) {
        for element in self.elements.keys {
            handler(element: element)
        }
    }
}

这两段代码之间的唯一区别在于,在第二种情况下,类型别名被定义为整数而不是字符串。我们可以使用复制和粘贴来创建几乎任何类型的容器,但像往常一样,大量复制和粘贴是更好的解决方案的迹象。此外,你可能注意到,我们新的 Container 协议本身并不是特别有用,因为使用我们现有的技术,我们无法将一个变量视为一个 Container。如果我们将与实现此协议的实例交互,我们需要知道它将类型别名分配给了什么类型。

Swift 提供了一个名为 泛型 的工具来解决这两个问题。

泛型

泛型非常类似于类型别名。区别在于泛型的确切类型是由其使用的上下文决定的,而不是由实现类型决定的。这也意味着泛型只有一个实现,必须支持所有可能的类型。让我们先定义一个泛型函数。

泛型函数

在 第五章 中,现代范式 – 闭包和函数式编程,我们创建了一个函数,帮助我们找到通过测试的数组中的第一个数字:

func firstInNumbers(
    numbers: [Int],
    passingTest: (number: Int) -> Bool
    ) -> Int?
{
    for number in numbers {
        if passingTest(number: number) {
            return number
        }
    }
    return nil
}

如果我们只处理整数数组,这将非常棒,但显然能够用其他类型做这件事会更有帮助。事实上,我敢说,对所有类型都这样做会更好。我们通过使我们的函数泛型来实现这一点。泛型函数的声明方式与普通函数类似,但在函数名称的末尾包含一个逗号分隔的占位符列表,放在尖括号 (<>) 内,如下所示:

func firstInArray<ValueType>(
    array: [ValueType],
    passingTest: (value: ValueType) -> Bool
    ) -> ValueType?
{
    for value in array {
        if passingTest(value: value) {
            return value
        }
    }
    return nil
}

在这个函数中,我们声明了一个名为ValueType的单个占位符。就像类型别名一样,我们可以在实现中继续使用这个类型。这将代表一个在我们要使用函数时确定的单个类型。你可以想象在这个代码中插入String或任何其他类型而不是ValueType,它仍然可以工作。

我们使用这个函数的方式与任何其他函数类似,如下所示:

var strings = ["This", "is", "a", "sentence"]
var numbers = [1, 1, 2, 3, 5, 8, 13]
firstInArray(strings, passingTest: {$0 == "a"}) // "a"
firstInArray(numbers, passingTest: {$0 > 10}) // 13

在这里,我们使用了firstInArray:passingTest:,同时使用了字符串数组和数字数组。编译器根据我们传递给函数的变量来确定要替换占位符的类型。在第一种情况下,strings是一个String数组。它将这个与[ValueType]进行比较,并假设我们想要用String替换ValueType。在第二种情况下,我们的Int数组发生同样的情况。

那么,如果我们闭包中使用的类型与我们传递的数组类型不匹配会发生什么?

firstInArray(numbers, passingTest: {$0 == "a"}) // Cannot convert
// value of type '[Int]' to expected argument type'[_]'

正如你所见,我们得到了一个错误,类型不匹配。

你可能已经注意到,我们实际上之前已经使用过泛型函数。我们在第五章中查看的所有内置函数,如mapfilter都是泛型的;它们可以与任何类型一起使用。

我们甚至已经体验过泛型类型。数组和字典也是泛型的。Swift 团队不需要为容器内可能使用的每个类型编写新的数组和字典实现;他们创建了泛型类型。

泛型类型

与泛型函数类似,泛型类型就像一个普通类型一样定义,但它在名称的末尾有一个占位符列表。在本章早些时候,我们为字符串和integers创建了我们的容器。让我们创建这些容器的泛型版本,如下所示:

struct Bag<ElementType> {
    var elements = [ElementType]()

    mutating func addElement(element: ElementType) {
        self.elements.append(element)
    }

    func enumerateElements(
        handler: (element: ElementType) -> ()
        )
    {
        for element in self.elements {
            handler(element: element)
        }
    }
}

这个实现看起来与我们的类型别名版本相似,但我们使用的是ElementType占位符。

虽然泛型函数的占位符是在函数调用时确定的,但泛型类型的占位符是在初始化新实例时确定的:

var stringBag = Bag(elements: ["This", "is", "a", "sentence"])
var numberBag = Bag(elements: [1, 1, 2, 3, 5, 8, 13])

与一个通用实例的所有未来交互都必须使用相同的类型来为其占位符。这实际上是泛型之美之一,编译器为我们做了工作。如果我们创建了一个类型的实例,却意外地尝试将其用作不同类型,编译器不会允许我们这样做。这种保护在许多其他编程语言中都不存在,包括苹果之前使用的语言:Objective-C。

考虑一个有趣的案例,如果我们尝试用一个空数组初始化一个包:

var emptyBag = Bag(elements: []) // Cannot invoke initilaizer for
// type 'Bag<_>' with an argument list of type '(elements: [_])'

正如你所见,我们得到了一个错误,编译器无法确定要分配给我们的泛型占位符的类型。我们可以通过给我们要分配的泛型一个显式类型来解决这个问题:

var emptyBag: Bag<String> = Bag(elements: [])

这真是太好了,因为编译器不仅可以根据我们传递给它们的变量来确定泛型占位符类型,还可以根据我们使用结果的方式来确定类型。

我们已经看到了如何以强大的方式使用泛型。我们解决了在类型别名部分讨论的第一个问题,即针对不同类型复制粘贴大量实现。然而,我们还没有弄清楚如何解决第二个问题:我们如何编写一个泛型函数来处理Container协议中的任何类型?答案是我们可以使用类型约束

类型约束

在我们直接跳入解决问题之前,让我们先看看类型约束的简单形式。

协议约束

假设我们想编写一个函数,该函数可以使用相等性检查来确定实例在数组中的索引。我们的第一次尝试可能看起来像以下代码:

func indexOfValue<T>(value: T, inArray array: [T]) -> Int? {
    var index = 0
    for testValue in array {
        if testValue == value { // Error: Cannot invoke '=='
            return index
        }
        index++
    }
    return nil
}

在这次尝试中,我们遇到了无法调用相等运算符(==)的错误。这是因为我们的实现必须适用于可能分配给我们的占位符的任何可能的类型。并不是 Swift 中的每个类型都可以进行相等性测试。为了解决这个问题,我们可以使用类型约束来告诉编译器,我们只想允许我们的函数以支持相等运算的类型调用。我们通过要求占位符实现一个协议来添加类型约束。在这种情况下,Swift 提供了一个名为Equatable的协议,我们可以使用:

func indexOfValue<T: Equatable>(
    value: T,
    inArray array: [T]
    ) -> Int?
{
    var index = 0
    for testValue in array {
        if testValue == value {
            return index
        }
        index++
    }
    return nil
}

类型约束看起来类似于在占位符名称后面使用冒号(:)来使用协议实现类型。现在,编译器已经满意,每个可能的类型都可以使用相等运算符进行比较。如果我们尝试用非可比较的类型调用这个函数,编译器会生成一个错误:

class MyType {}
var typeList = [MyType]()
indexOfValue(MyType(), inArray: typeList)
// Cannot convert value of type '[MyType]' to expected
// argument type '[_]'

这又是编译器能帮助我们避免错误的一个例子。

我们也可以向我们的泛型类型添加类型约束。例如,如果我们尝试在没有约束的情况下使用我们的字典实现创建一个包,我们会得到一个错误:

struct Bag2<ElementType> {
    var elements: [ElementType:Void]
    // Type 'ElementType' does not conform to protocol 'Hashable'
}

这是因为字典的键有一个约束,它必须是Hashable。字典定义为struct Dictionary<Key : Hashable, Value>Hashable基本上意味着该类型可以用整数表示。实际上,如果我们把Hashable写在 Xcode 中,然后按住Command键点击它,就会带我们到Hashable的定义,其中包含注释解释两个相等对象的哈希值必须相同。这对Dictionary的实现方式很重要。因此,如果我们想能够在字典中将我们的元素作为键存储,我们必须也添加Hashable约束:

struct Bag2<ElementType: Hashable> {
    var elements: [ElementType:Void]

    mutating func addElement(element: ElementType) {
        self.elements[element] = ()
    }

    func enumerateElements(
        handler: (element: ElementType) -> ()
        )
    {
        for element in self.elements.keys {
            handler(element: element)
        }
    }
}

现在编译器很高兴,我们可以开始使用我们的Bag2结构体与任何是Hashable类型的类型了。我们接近解决我们的Container问题,但我们需要对Container类型别名的类型进行约束,而不是对Container本身进行约束。为了做到这一点,我们可以使用一个where子句。

where子句用于协议

在定义了每个占位符类型之后,你可以指定任意数量的where子句。它们允许你表示更复杂的关系。如果我们想编写一个函数来检查我们的容器是否包含特定的值,我们可以要求元素类型是可比较的:

func container<C: Container where C.Element: Equatable>(
    container: C,
    hasElement element: C.Element
    ) -> Bool
{
    var hasElement = false
    container.enumerateElements { testElement in
        if element == testElement {
            hasElement = true
        }
    }
    return hasElement
}

在这里,我们指定了一个必须实现Container协议的占位符C;它还必须有一个是Equatable类型的Element类型。

有时我们可能还想要在多个占位符之间强制建立关系。为了做到这一点,我们可以在where子句中使用等式测试。

等式where子句

如果我们想编写一个函数,可以将一个容器合并到另一个容器中,同时仍然允许确切的类型变化,我们可以编写一个函数,该函数要求容器持有相同的值:

func merged<C1: Container, C2: Container where C1.Element == C2.Element>(
    lhs: C1,
    rhs: C2
    ) -> C1
{
    var merged = lhs
    rhs.enumerateElements { element in
        merged.addElement(element)
    }
    return merged
}

在这里,我们指定了两个不同的占位符:C1C2。它们都必须实现Container协议,并且它们还必须包含相同的Element类型。这允许我们将第二个容器的元素添加到我们最终返回的第一个容器的副本中。

现在我们知道了如何创建自己的泛型函数和类型,让我们看看我们如何可以扩展现有的泛型。

扩展泛型

我们可能想要扩展的两个主要泛型是数组和字典。这两个是最突出的容器,由 Swift 提供,几乎在每一个应用中都被使用。一旦你理解扩展本身不需要是泛型的,扩展泛型类型就很简单了。

向所有泛型形式添加方法

知道数组被声明为struct Array<Element>,你扩展数组的第一个直觉可能看起来像这样:

extension Array<Element> { // Use of undeclared type 'Element'
    // ...
}

然而,正如你所看到的,你会得到一个错误。相反,你可以简单地省略占位符指定,仍然可以在你的实现中使用Element占位符。你的另一个直觉可能是将Element声明为你的单个方法的占位符:

extension Array {
    func someMethod<Element>(element: Element) {
        // ...
    }
}

这更危险,因为编译器不会检测到错误。这是错误的,因为你实际上是在方法内部声明一个新的占位符Element。这个新的Element与在Array中定义的Element没有任何关系。例如,如果你尝试将一个参数与方法的元素与数组的元素进行比较,你可能会得到一个令人困惑的错误:

extension Array {
    mutating func addElement<Element>(element: Element) {
        self.append(element)
        // Cannot invoke 'append' with argument list
        // of type '(Element)'
    }
}

这是因为在 Array 中定义的 Element 不能保证与在 addElement: 中定义的新 Element 完全相同。你可以在泛型类型的方法中声明额外的占位符,但最好给它们唯一的名称,以免隐藏类型的占位符版本。

现在我们已经理解了这一点,让我们添加一个扩展到数组中,允许我们测试它是否包含通过测试的元素:

extension Array {
    func hasElementThatPasses(
        test: (element: Element) -> Bool
        ) -> Bool
    {
        for element in self {
            if test(element: element) {
                return true
            }
        }
        return false
    }
}

如您所见,我们在扩展中继续使用占位符 Element。这允许我们为数组中的每个元素调用传入的测试闭包。现在,如果我们想能够添加一个方法来检查元素是否存在,使用等号运算符,会发生什么问题?我们将遇到的问题是数组没有对 Element 应用类型约束,要求它必须是 Equatable。为了做到这一点,我们可以在我们的扩展中添加一个额外的约束。

仅向泛型类型的一定实例添加方法

扩展的约束以 where 子句的形式编写,如下所示:

extension Array where Element: Equatable {
    func containsElement(element: Element) -> Bool {
        for testElement in self {
            if testElement == element {
                return true
            }
        }
        return false
    }
}

这里我们添加了一个约束,保证我们的元素是可比较的。这意味着我们只能在这个方法上调用具有可比较元素的数组:

[1,2,3,4,5].containsElement(4) // true
class MyType {}
var typeList = [MyType]()
typeList.containsElement(MyType()) // Type 'MyType' does not
// conform to protocol 'Equtable'

再次,Swift 正在保护我们免受意外尝试在它不起作用的数组上调用此方法的风险。

这些是我们必须与泛型一起使用的构建块。然而,我们实际上还有一个尚未讨论的协议特性,它与泛型结合得非常好。

扩展协议

我们首先在第三章 一次一件——类型、作用域和项目 中讨论了如何扩展现有类型。在 Swift 2 中,苹果公司增加了扩展协议的能力。这有一些令人着迷的后果,但在我们深入探讨这些之前,让我们先看看如何向 Comparable 协议添加一个方法的示例:

extension Comparable {
    func isBetween(a: Self, b: Self) -> Bool {
        return a < self && self < b
    }
}

这将为所有实现 Comparable 的类型添加一个方法。这意味着它现在将可用于任何可比较的内置类型以及我们自己的任何可比较类型:

6.isBetween(4, b: 7) // true
"A".isBetween("B", b: "Z") // false

这是一个非常强大的工具。实际上,这就是 Swift 团队实现我们在第五章 现代范式——闭包和函数式编程 中看到的许多功能方法的方式。他们不必在数组、字典或其他应可映射的序列上实现 map 方法;相反,他们直接在 SequenceType 上实现了它。

这表明类似地,协议扩展可以用于继承,并且它也可以应用于类和结构体,类型也可以从多个不同的协议中继承这种功能,因为没有限制类型可以实现的协议数量。然而,两者之间有两个主要区别。

首先,类型不能从协议继承存储属性,因为扩展不能定义它们。协议可以定义只读属性,但每个实例都必须重新声明它们作为属性:

protocol Building {
    var squareFootage: Int {get}
}

struct House: Building {
    let squareFootage: Int
}

struct Factory: Building {
    let squareFootage: Int
}

其次,方法重写与协议扩展的工作方式不同。在协议中,Swift 不会根据实例的实际类型智能地确定调用哪个版本的方法。在类继承中,Swift 会调用与实例最直接关联的方法版本。记住,当我们调用第三章中我们的House子类实例的clean方法时,一点一滴 – 类型、作用域和项目,它会调用重写的clean版本,如下所示:

class Building {
    // ...

    func clean() {
        print(
            "Scrub \(self.squareFootage) square feet of floors"
        )
    }
}

class House: Building {
    // ...

    override func clean() {
        print("Make \(self.numberOfBedrooms) beds")
        print("Clean \(self.numberOfBathrooms) bathrooms")
    }
}

let building: Building = House(
    squareFootage: 800,
    numberOfBedrooms: 2,
    numberOfBathrooms: 1
)
building.clean()
// Make 2 beds
// Clean 1 bathroom

在这里,即使建筑变量被定义为Building类型,实际上它是一栋房子;因此 Swift 会调用房子的clean版本。与协议扩展的对比在于,它会调用变量声明为的确切类型的版本的方法:

protocol Building {
    var squareFootage: Int {get}
}

extension Building {
    func clean() {
        print(
            "Scrub \(self.squareFootage) square feet of floors"
        )
    }
}

struct House: Building {
    let squareFootage: Int
    let numberOfBedrooms: Int
    let numberOfBathrooms: Double

    func clean() {
        print("Make \(self.numberOfBedrooms) beds")
        print("Clean \(self.numberOfBathrooms) bathrooms")
    }
}

let house = House(
    squareFootage: 1000,
    numberOfBedrooms: 2,
    numberOfBathrooms: 1.5
)
house.clean()
// Make 2 beds
// Clean 1.5 bathrooms

(house as Building).clean()
// Scrub 1000 square feet of floors

当我们调用类型为Househouse变量的clean方法时,它会调用房子的版本;然而,当我们将该变量转换为Building类型并调用它时,它会调用建筑的版本。

所有这些都表明,在结构体、协议或类继承之间进行选择可能会很困难。我们将在下一章关于内存管理的章节中探讨这个考虑的最后一部分,这样我们就能在前进时做出完全明智的决定。

现在我们已经了解了泛型和协议为我们提供的功能,让我们抓住这个机会来探索在 Swift 中使用协议和泛型的一些更高级的方法。

将协议和泛型投入使用

Swift 的一个酷特性是生成器和序列。它们提供了一种轻松迭代值列表的方法。最终,它们归结为两个不同的协议:GeneratorTypeSequenceType。如果你在你的自定义类型中实现了SequenceType协议,它允许你使用 for-in 循环遍历你的类型的实例。在本节中,我们将探讨我们如何做到这一点。

生成器

这中最关键的部分是GeneratorType协议。本质上,生成器是一个你可以反复询问序列中下一个对象的对象,直到没有对象为止。大多数时候你可以简单地使用数组来做这件事,但这并不总是最好的解决方案。例如,你甚至可以创建一个无限生成器。

有一个著名的斐波那契数列的无穷级数,其中序列中的每个数都是前两个数的和。这尤其著名,因为它在自然界中无处不在,从巢中的蜜蜂数量到矩形最令人愉悦的宽高比。让我们创建一个无限生成器,它将生成这个序列。

我们首先创建一个实现GeneratorType协议的结构体。该协议由两部分组成。首先,它有一个类型别名,用于表示序列中的元素类型,其次,它有一个名为next的 mutating 方法,该方法返回序列中的下一个对象。

实现看起来像这样:

struct FibonacciGenerator: GeneratorType {
    typealias Element = Int

    var values = (0, 1)

    mutating func next() -> Element? {
        self.values = (
            self.values.1,
            self.values.0 + self.values.1
        )
        return self.values.0
    }
}

我们定义了一个名为values的属性,它是一个表示序列中前两个值的元组。每次调用next时,我们都会更新values并返回元组的第一个元素。这意味着序列将没有尽头。

我们可以通过实例化它并在 while 循环中反复调用 next 来单独使用这个生成器:

var generator = FibonacciGenerator()
while let next = generator.next() {
    if next > 10 {
        break
    }
    print(next)
}
// 1, 1, 2, 3, 5, 8

我们需要设置某种条件,以便循环不会无限进行。在这种情况下,一旦数字超过 10,我们就跳出循环。然而,这段代码相当丑陋,所以 Swift 还定义了一个名为SequenceType的协议来清理它。

序列

SequenceType是另一个协议,它被定义为具有一个GeneratorType的类型别名和一个名为generate的方法,该方法返回该类型的新生成器。我们可以为我们的FibonacciGenerator声明一个简单的序列,如下所示:

struct FibonacciSequence: SequenceType {
    typealias Generator = FibonacciGenerator

    func generate() -> Generator {
        return FibonacciGenerator()
    }
}

每个 for-in 循环都操作在SequenceType协议上,因此现在我们可以在FibonacciSequence上使用 for-in 循环:

for next in FibonacciSequence() {
    if next > 10 {
        break
    }
    print(next)
}

这非常酷;我们可以轻松地以非常可读的方式迭代斐波那契数列。理解前面的代码比理解每次都要计算数列下一个值的复杂 while 循环要容易得多。想象一下我们可以设计的其他类型的序列,比如素数、随机姓名生成器等等。

然而,定义两种不同的类型来创建一个单一的序列并不总是理想的。为了解决这个问题,我们可以使用泛型。Swift 提供了一个名为AnyGenerator的泛型类型,以及一个名为anyGenerator:的伴随函数。这个函数接受一个闭包,并返回一个使用闭包作为其 next 方法的生成器。这意味着我们不必显式地创建生成器;相反,我们可以在序列中直接使用anyGenerator:

struct FibonacciSequence2: SequenceType {
    typealias Generator = AnyGenerator<Int>

    func generate() -> Generator {
        var values = (0, 1)
        return anyGenerator({
            values = (values.1, values.0 + values.1)
            return values.0
        })
    }
}

在这个版本的FibonacciSequence中,每次调用 generate 时,我们都会创建一个新的生成器,它接受一个闭包,执行与原始FibonacciGenerator相同的事情。我们在闭包外部声明values变量,以便我们可以使用它来在闭包调用之间存储状态。如果你的生成器很简单,不需要复杂的状态,使用AnyGenerator泛型是一个很好的选择。

现在,让我们使用这个FibonacciSequence来解决计算机擅长的数学问题。

50 以下斐波那契数的乘积

如果我们想知道 50 以下斐波那契数中每个数的乘积是什么,我们可以尝试使用计算器并费力地输入所有数字,但使用 Swift 来做会更高效。

让我们从创建一个通用的 SequenceType 开始,它将接受另一个序列类型并将其限制为达到最大数字时停止序列。我们需要确保最大值的类型与序列中的类型匹配,并且元素类型是可比较的。为此,我们可以在元素类型上使用 where 子句:

struct SequenceLimiter<
    S: SequenceType where S.Generator.Element: Comparable
    >: SequenceType
{
    typealias Generator = AnyGenerator<S.Generator.Element>
    let sequence: S
    let max: S.Generator.Element

    init(_ sequence: S, max: S.Generator.Element) {
        self.sequence = sequence
        self.max = max
    }

    func generate() -> Generator {
        var g = self.sequence.generate()
        return anyGenerator({
            if let next = g.next() {
                if next <= self.max {
                    return next
                }
            }
            return nil
        })
    }
}

注意,当我们提到元素类型时,我们必须通过生成器类型进行。

当我们的 SequenceLimiter 结构被创建时,它会存储原始序列。这是为了让它每次在父序列上调用 generate 方法时都能使用 generate 方法的返回结果。每次调用 generate 都需要重新开始序列。然后它创建一个 AnyGenerator,其中包含一个闭包,该闭包在原始序列的本地初始化生成器上调用 next。如果原始生成器返回的值大于或等于最大值,我们返回 nil,表示序列已结束。

我们甚至可以向 SequenceType 添加一个扩展,其中包含一个为我们创建限制器的函数:

extension SequenceType where Generator.Element: Comparable {
    func limit(max: Generator.Element) -> SequenceLimiter<Self> {
        return SequenceLimiter(self, max: max)
    }
}

我们使用 Self 作为占位符,表示被调用方法的具体实例类型。

现在,我们可以轻松地将斐波那契序列限制在 50 以下:

FibonacciSequence().limit(50)

我们需要解决的最后一个问题是如何找到序列的乘积。我们可以通过另一个扩展来实现这一点。在这种情况下,我们只将支持包含 Int 的序列,这样我们就可以确保元素可以相乘:

extension SequenceType where Generator.Element == Int {
    var product: Generator.Element {
        return self.reduce(1, combine: *)
    }
}

这种方法利用了 reduce 函数,从值一开始,乘以序列中的每个值。现在我们可以轻松地进行最终的计算:

FibonacciSequence().limit(50).product // 2,227,680

几乎瞬间,我们的程序就会返回结果 2,227,680。现在我们真正理解了为什么我们把这些设备称为计算机。

摘要

协议和泛型确实很复杂,但我们已经看到它们可以有效地让编译器保护我们免受自己错误的影响。在本章中,我们介绍了协议如何像类型签订的合同。我们还看到,可以通过类型别名使协议更加灵活。泛型允许我们充分利用协议和类型别名,还可以创建强大且灵活的类型,这些类型可以适应它们被使用的上下文。最后,我们探讨了如何以序列和生成器形式使用协议和泛型,以非常干净和易于理解的方式解决一个复杂的数学问题,这可以作为解决其他类型问题的灵感。

到目前为止,我们已经涵盖了 Swift 语言的所有核心功能。我们现在可以更深入地了解程序运行时数据的实际存储方式以及我们如何最好地管理程序使用的资源。

第七章。一切皆相连——内存管理

当使用一个应用程序时,最糟糕的事情莫过于它运行缓慢且无响应。计算机用户已经习惯了每一款软件都能立即对每一次交互做出响应。即使是最功能丰富的应用程序,如果使用起来不愉快,因为它没有有效地管理设备资源,那么它也会被毁掉。此外,随着移动计算机和设备的日益普及,编写高效使用电池电力的软件比以往任何时候都更加重要。编写软件的方面中,对响应速度和电池寿命影响最大的是内存管理。

在本章中,我们将讨论 Swift 特有的技术,这些技术使我们能够管理内存,以确保我们的代码保持响应性并最小化其对电池寿命和其他应用程序的影响。我们将通过以下主题来实现这一点:

  • 计算机数据存储

  • 值类型与引用类型

  • 自动引用计数

  • 强引用循环

  • 失踪的对象

  • 结构体与类

计算机数据存储

在我们开始查看代码之前,我们需要详细了解数据在计算机中的表示方式。常见的陈词滥调是计算机中的所有数据都是 1 和 0。这是真的,但在讨论内存管理时并不那么重要。相反,我们关心的是数据存储的位置。所有计算机,无论是台式机、笔记本电脑、平板电脑还是手机,都在两个地方存储数据。

我们通常首先想到的地方是文件系统。它存储在专门的硬件上;在许多计算机中,这被称为硬盘驱动器,但最近,一些计算机开始使用固态驱动器。当我们购买计算机时,我们还听说它有多少“内存”。计算机内存以“条”的形式出现,比普通驱动器存储的信息少。所有数据,即使主要存储在互联网上的某个地方,也必须加载到计算机的内存中,以便我们与之交互。

让我们来看看这对我们作为程序员意味着什么。

文件系统

文件系统是为了长期存储数据而设计的。它的访问速度远慢于内存,但存储大量数据时成本效益更高。正如其名称所暗示的,文件系统只是一个文件的组织层次结构,我们可以通过 Mac 上的Finder直接与之交互。这个文件系统在 iPhone 和 iPad 上仍然存在,但它对我们来说是隐藏的。然而,软件仍然可以读取和写入文件系统,因此我们可以永久存储数据,即使在关闭设备之后。

内存

内存比文件系统要复杂一些。它被设计用来存储当前运行的软件所需的必要数据,暂时存储。与文件系统不同,一旦关闭设备,所有内存都会丢失。这个类比类似于我们人类有短期和长期记忆。当我们正在交谈或思考某事时,我们正在积极思考的信息子集在我们的短期记忆中,其余的则在我们的长期记忆中。为了积极思考某事,我们必须将其从长期记忆中召回短期记忆。

内存访问速度快,但成本更高。当计算机开始出现异常缓慢的反应时,通常是因为它几乎用完了所有的内存。这是因为当内存不足时,操作系统会自动开始使用文件系统作为备份。原本打算短期存储的信息会自动写入文件系统,这使得再次访问变得非常缓慢。

这类似于我们人类一次性处理过多信息时遇到的问题。如果我们试图在脑海中加两个 20 位的数字,这将花费我们很长时间,或者根本不可能完成。相反,我们通常会边做边在纸上写下部分解决方案。在这种情况下,纸张就充当了我们的文件系统。如果我们能记住所有东西,而不是花时间写下它并读回它,那会更快,但我们一次无法处理那么多信息。

在编程时考虑这一点很重要,因为我们希望减少在任何给定时间使用的内存量。使用大量内存不仅会负面影响我们的软件,还会影响整个计算机的性能。此外,当操作系统不得不求助于使用文件系统时,额外的处理和访问第二块硬件会导致更多的能耗。

现在我们已经理解了我们的目标,我们可以开始讨论如何在 Swift 中更好地管理内存。

值类型与引用类型

Swift 中的所有变量和常量都存储在内存中。实际上,除非你明确地将数据写入文件系统,否则你创建的所有东西都将存储在内存中。在 Swift 中,有两种不同类型的类别。这两个类别是值类型引用类型。它们之间的唯一区别在于它们在分配给新变量、传递给方法或捕获在闭包中的行为方式。本质上,它们只在尝试将新变量或常量的值分配给现有变量或常量时有所不同。

值类型在被分配到新的位置时总是被复制,而引用类型则不是。在我们详细探讨这究竟意味着什么之前,让我们先了解一下如何确定一个类型是值类型还是引用类型。

确定值类型或引用类型

值类型是指定义为结构体或枚举的任何类型,而所有类都是引用类型。根据你如何声明它们,你可以很容易地确定你自己的自定义类型。除此之外,Swift 的所有内置类型,如字符串、数组和字典,都是值类型。如果你不确定,你可以在游乐场中测试任何两种类型,看看它们的行为是否与值类型或引用类型一致。最简单的行为检查是在赋值时发生的情况。

赋值时的行为

当值类型被重新赋值时,它会复制,这样之后每个变量或常量都持有可以独立更改的独立值。让我们通过一个简单的字符串示例来看看:

var value1 = "Hello"
var value2 = value1
value1 += " World!"
print(value1) // "Hello World!"
print(value2) // "Hello"

如你所见,当value2被设置为value1时,会创建一个副本。这样,当我们向value1追加" World!"时,value2保持不变,仍然是"Hello"。我们可以将它们视为两个完全独立的实体:

行为在赋值时的表现

另一方面,让我们看看引用类型会发生什么:

class Person {
    var name: String

    init(name: String) {
        self.name = name
    }
}
var reference1 = Person(name: "Kai")
var reference2 = reference1
reference1.name = "Naya"
print(reference1.name) // "Naya"
print(reference2.name) // "Naya"

如你所见,当我们更改reference1的名称时,reference2也发生了变化。那么这是为什么?正如其名所示,引用类型仅仅是实例的引用。当你将一个引用赋给另一个变量或常量时,两者实际上都指向同一个实例。我们可以将其视为两个单独的对象引用同一个实例:

行为在赋值时的表现

在现实生活中,这就像两个孩子分享一个玩具。他们都可以玩这个玩具,但如果其中一个孩子弄坏了玩具,两个孩子的玩具都会坏。

然而,重要的是要意识到,如果你将引用类型赋值给新的值,它不会改变它最初引用的值:

reference2 = Person(name: "Kai")
print(reference1.name) // "Naya"
print(reference2.name) // "Kai"

如你所见,我们将reference2赋值给了完全不同的Person实例,因此它们现在可以独立操作。我们可以将它们视为两个不同实例上的两个单独引用,如下面的图像所示:

行为在赋值时的表现

这就像给其中一个孩子买了一个新玩具。

这表明引用类型实际上是值类型的一个特殊版本。区别在于引用类型本身不是任何类型的实例。它只是引用另一个实例的一种方式,有点像占位符。你可以复制引用,以便有两个变量引用同一个实例,或者你可以给一个变量一个指向新实例的完全新的引用。在引用类型中,基于多个变量之间共享实例,存在一个额外的间接层。

现在我们知道了这一点,验证一个类型是值类型还是引用类型最简单的方法是检查它在赋值时的行为。如果你修改第一个值时第二个值发生了变化,这意味着你正在测试的类型是引用类型。

输入时的行为

另一个值类型的行为与引用类型不同的地方是在将它们传递到函数和方法中时。然而,如果你将传递变量或常量到函数视为另一种赋值操作,那么这种行为的记忆就会变得非常简单。这意味着当你将值类型传递到函数中时,它会被复制,而引用类型仍然共享相同的实例:

func setNameOfPerson(person: Person, var to name: String) {
    person.name = name
    name = "Other Name"
}

在这里,我们定义了一个函数,它接受一个引用类型:Person和一个值类型:String。当我们函数内部更新Person类型时,我们传入的人员也会发生变化:

var person = Person(name: "Sarah")
var newName = "Jamison"
setNameOfPerson(person, to: newName)

print(person.name) // "Jamison"
print(newName) // "Jamison"

然而,当我们函数内部更改字符串时,传入的String保持不变。

当事情变得稍微复杂一些时,是关于inout参数。inout参数实际上是对传入实例的引用。这意味着它将值类型视为引用类型:

func updateString(inout string: String) {
    string = "Other String"
}

var someString = "Some String"
updateString(&someString)
print(someString) // "Other String"

如您所见,当我们函数内部更改了inout版本的string时,它也改变了函数外部的someString变量,就像它是一个引用类型一样。

如果我们记住引用类型只是值类型的一个特殊版本,其中值是一个引用,那么我们可以推断出引用类型的inout版本将可能实现什么。当我们定义一个inout引用类型时,我们实际上有一个指向引用的引用;这个引用就是指向引用的那个引用。我们可以将inout值类型和inout引用类型之间的区别可视化如下:

输入行为

如果我们简单地更改这个变量的值,我们会得到与它不是inout参数时相同的行为。然而,我们也可以通过将其声明为inout参数来更改内部引用的指向:

func updatePerson(inout insidePerson: Person) {
    insidePerson.name = "New Name"
    insidePerson = Person(name: "New Person")
}

var person2 = person
updatePerson(&person)
print(person.name) // "New Person"
print(person2.name) // "New Name"

我们首先创建第二个引用:person2,指向与之前具有名字"Jamison"person变量相同的实例。之后,我们将原始的person变量传递到我们的updatePerson:方法中,得到以下结果:

输入行为

在这个方法中,我们首先将现有人员的名字更改为新名字。我们可以从输出中看到person2的名字也发生了变化,因为函数内部的insidePersonperson2仍然引用着相同的实例:

输入行为

然而,我们随后也将insidePerson赋值给Person引用类型的一个全新的实例。这导致函数外部的personperson2指向两个完全不同的Person实例,使得person2的名字变为"New Name",并将person的名字更新为"New Person"

输入行为

在这里,通过将insidePerson定义为inout参数,我们能够改变传入变量的引用位置。这有助于我们将所有不同的类型视为一个类型指向另一个类型。

在任何时刻,这些箭头中的任何一个都可以通过赋值指向新的内容,实例始终可以通过引用来访问。

闭包捕获行为

我们必须关注的最后一个行为是当变量在闭包中被捕获时。这是我们之前在第五章中未涵盖的闭包内容,现代范式 – 闭包和函数式编程。闭包实际上可以使用与闭包本身相同作用域中定义的变量:

var nameToPrint = "Kai"
var printName = {
    print(nameToPrint)
}
printName() // "Kai"

这与我们之前看到的正常参数非常不同。我们实际上并没有将nameToPrint指定为参数,也没有在调用方法时传递它。相反,闭包捕获了定义在其之前的nameToPrint变量。这些类型的捕获在函数中类似于inout参数。

当捕获值类型时,它可以被更改,并且会改变原始值:

var outsideName = "Kai"
var setName = {
    outsideName = "New Name"
}
print(outsideName) // "Kai"
setName()
print(outsideName) // "New Name"

正如你所见,outsideName在调用闭包后被更改了。这正好像一个inout参数。

当捕获引用类型时,任何更改也将应用于变量的外部版本:

var outsidePerson = Person(name: "Kai")
var setPersonName = {
    outsidePerson.name = "New Name"
}
print(outsidePerson.name) // "Kai"
setPersonName()
print(outsidePerson.name) // "New Name"

这也正好像一个inout参数。

我们需要记住的关于闭包捕获的另一个部分是,在闭包定义之后更改捕获的值仍然会影响闭包内的值。我们可以利用这一点来使用我们在上一节中定义的printName闭包来打印任何名称:

nameToPrint = "Kai"
printName() // Kai
nameToPrint = "New Name"
printName() // "New Name"

正如你所见,我们可以通过更改nameToPrint的值来更改printName打印的内容。实际上,这种行为在意外发生时很难追踪,因此通常最好尽可能避免在闭包中捕获变量。在这种情况下,我们正在利用这种行为,但大多数情况下,它会导致错误。在这里,最好将我们想要打印的内容作为参数传递。

避免这种行为的另一种方法是使用一个名为捕获列表的功能。使用它,你可以通过复制来指定你想要捕获的变量:

nameToPrint = "Original Name"
var printNameWithCapture = { [nameToPrint] in
    print(nameToPrint)
}
printNameWithCapture() // "Original Name"
nameToPrint = "New Name"
printNameWithCapture() // "Original Name"

捕获列表是在闭包的开始处定义的,在任何一个参数之前。它是由逗号分隔的变量列表,我们想要在方括号内复制这些变量。在这种情况下,我们请求复制nameToPrint,因此当我们稍后更改它时,它不会影响打印出的值。我们将在本章后面看到捕获列表的更高级用法。

自动引用计数

现在我们已经了解了 Swift 中数据表示的不同方式,我们可以探讨如何更好地管理内存。我们创建的每个实例都会占用内存。自然地,永远保留所有数据是没有意义的。Swift 需要能够释放内存,以便它可以用于其他目的,一旦我们的程序不再需要它。这是我们管理应用程序内存的关键。我们需要确保 Swift 能够尽快释放我们不再需要的所有内存。

Swift 知道何时可以释放内存,是因为代码无法再访问一个实例。如果没有变量或常量引用一个实例,它就可以被重新用于另一个实例。这被称为“释放内存”或“删除对象”。

在 第三章 的 一次一件——类型、作用域和项目 中,我们已经讨论了变量何时可访问或不可访问的问题,这部分内容在作用域部分进行了讨论。这使得值类型的内存管理变得非常简单。由于值类型在重新分配或传递给函数时总是被复制,因此一旦它们超出作用域,就可以立即删除。我们可以通过一个简单的例子来全面了解:

func printSomething() {
    let something = "Hello World!"
    print(something)
}

这里有一个非常简单的函数,它会打印出 "Hello World!"。当调用 printSomething 时,something 被分配给一个新的 String 实例,其值为 "Hello World!"。在调用 print 之后,函数退出,因此 something 就不再在作用域内了。此时,something 所占用的内存可以被释放。

虽然这很简单,但引用类型要复杂得多。从高层次来看,引用类型的实例在作用域内不再有任何引用时被删除。这相对容易理解,但在细节上会更复杂。管理这一功能的 Swift 特性被称为 自动引用计数 或简称 ARC

对象关系

自动引用计数(ARC)的关键是每个对象都与一个或多个变量有关。这可以扩展到所有对象都与其他对象有关的概念。例如,一个汽车对象会包含其四个轮胎、引擎等对象。它还会与制造商、经销商和车主有关。ARC 使用这些关系来确定何时可以删除对象。在 Swift 中,有三种不同类型的关系:强引用弱引用非拥有引用

强大

第一种,也是默认类型的关系是强关系。它表示变量需要它所引用的实例始终存在,只要变量仍在作用域内。这是值类型可用的唯一行为。当一个实例不再有任何强关系时,它将被删除。

这种关系的一个很好的例子是必须有一个方向盘的汽车:

class SteeringWheel {}

class Car {
    var steeringWheel: SteeringWheel

    init(steeringWheel: SteeringWheel) {
        self.steeringWheel = steeringWheel
    }
}

默认情况下,steeringWheel属性与它初始化时关联的SteeringWheel实例有一个强关系。从概念上讲,这意味着汽车本身与方向盘有一个强关系。只要汽车存在,它就必须与一个存在的方向盘有关联。由于steeringWheel被声明为一个变量,我们可以更改汽车的方向盘,这将消除旧的强关系并添加一个新的,但强关系始终存在。

如果我们创建一个新的Car实例并将其存储在一个变量中,那么这个变量将与汽车有一个强关系:

let wheel = SteeringWheel()
let car = Car(steeringWheel: wheel)

让我们分析一下这段代码中的所有关系。首先,我们创建wheel常量并将其分配给一个新的SteeringWheel实例。这从wheel到新实例建立了一个强关系。我们用car常量做同样的事情,但这次我们还向初始化器传递了wheel常量。现在,不仅car与新的Car实例有一个强关系,而且Car初始化器还从steeringWheel属性到与wheel常量相同的实例建立了一个强关系:

强关系

那么,这种关系图对内存管理意味着什么呢?此时,Car实例有一个强关系:car常量,而SteeringWheel实例有两个强关系:wheel常量和Car实例的steeringWheel属性。

这意味着一旦car常量超出作用域,Car实例就会被删除。另一方面,SteeringWheel实例只有在wheel常量超出作用域且Car实例被删除之后才会被删除。

你可以想象在你的程序中的每个实例都有一个强引用计数器。每次设置一个强关系到实例时,计数器就会增加。每次一个强引用对象被删除时,计数器就会减少。如果那个计数器回到零,实例就会被删除。

另一个重要的事情是,所有关系都是单向的。仅仅因为Car实例与SteeringWheel实例有一个强关系,并不意味着SteeringWheel实例有任何反向关系。你可以通过向SteeringWheel类中添加一个汽车属性来添加自己的反向关系,但当你这样做时必须小心,正如我们将在接下来的强引用循环部分中看到的。

Swift 中的下一种关系类型是弱关系。它允许一个对象引用另一个对象,而不强制要求它始终存在。弱关系不会对实例的引用计数器做出贡献,这意味着弱关系的添加不会增加计数器,也不会在移除时减少计数器。

由于弱关系不能保证它始终存在,它必须始终定义为可选的。弱关系是在变量声明前使用weak关键字定义的:

class SteeringWheel {
    weak var car: Car?
}

这允许SteeringWheel分配一个汽车,而不强制要求汽车永远不会被删除。然后,汽车初始化器可以将这个反向引用分配给自己:

class Car {
    var steeringWheel: SteeringWheel

    init(steeringWheel: SteeringWheel) {
        self.steeringWheel = steeringWheel
        self.steeringWheel.car = self
    }
}

如果汽车被删除,SteeringWheel的汽车属性将自动设置为 nil。这允许我们优雅地处理弱关系引用已删除实例的场景。

无所有者

最后一种关系类型是无所有者关系。这种关系几乎与弱关系相同。它也允许一个对象引用另一个对象,而不增加强引用计数。唯一的区别是,无所有者关系不需要声明为可选的,并且使用unowned关键字而不是weak。它类似于隐式展开的可选类型。你可以像对待强关系一样与无所有者关系交互,但如果无所有者实例已被删除而你尝试访问它,你的整个程序将会崩溃。这意味着你应该只在无所有者对象在主要对象仍然存在的情况下永远不会实际被删除的场景中使用无所有者关系。

你可能会问,“为什么我们不会总是使用强关系呢?”答案是,有时需要无所有者或弱引用来打破称为强引用循环的东西。

强引用循环

强引用循环是指两个实例直接或间接持有对彼此的强引用。这意味着两个对象都无法被删除,因为它们都在确保对方将始终存在。

这种场景是我们遇到的第一种真正糟糕的内存管理场景。保留比所需更长时间的内存是一回事;创建永远无法释放以供再次使用的内存则是完全不同的层次。这种内存问题被称为内存泄漏,因为计算机将逐渐泄漏内存,直到没有更多可用的新内存。这就是为什么你有时会在重启设备后看到速度提升的原因。重启时,所有内存都会再次释放。现代操作系统有时会找到强制释放内存的方法,尤其是在完全退出应用程序时,但我们作为程序员不能依赖这一点。

那么,我们如何防止这些强引用循环呢?首先,让我们看看它们是什么样子。这些循环可以存在的两种主要场景是:在对象之间以及与闭包一起。

在对象之间

对象之间的强引用循环是指两种类型直接或间接包含对彼此的强引用。

发现

如果我们不使用从SteeringWheelCar的弱引用重写前面的汽车示例,那么这是一个对象之间强引用循环的绝佳例子:

class SteeringWheel {
    var car: Car?
}

class Car {
    var steeringWheel: SteeringWheel

    init(steeringWheel: SteeringWheel) {
        self.steeringWheel = steeringWheel
        self.steeringWheel.car = self
    }
}

与前面的代码相比,唯一的区别是SteeringWheel上的car属性不再声明为弱引用。这意味着当创建一个汽车时,它将设置与SteeringWheel实例的强关系,然后从SteeringWheel实例创建对汽车的强引用:

Spotting

这种场景意味着两个实例的引用计数永远不会降到零,因此它们永远不会被删除,内存将会泄漏。

两个对象也可以通过一个或多个第三方间接地相互持有强引用:

class Manufacturer {
    var cars: [Car] = []
}

class SteeringWheel {
    var manufacturer: Manufacturer?
}

class Car {
    var steeringWheel: SteeringWheel?
}

在这里,我们有一个场景,一个Car可以有一个对SteeringWheel的强引用,而SteeringWheel可以有一个对Manufacturer的强引用,反过来,Manufacturer又有一个对原始Car的强引用:

Spotting

这又是一个强引用循环,它说明了两个更重要的点。首先,默认情况下,可选类型仍然在非 nil 时创建强关系。此外,内置容器类型,如数组和字典,也会创建强关系。

显然,强引用循环可能很难被发现,尤其是因为它们一开始就很难检测。单个内存泄漏很少会对你程序的用户明显,但如果你一次又一次地持续泄漏内存,它可能会导致他们的设备运行缓慢甚至崩溃。

作为开发者,检测它们最好的方式是使用 Xcode 内置的工具Instruments。Instruments 可以执行许多任务,但其中之一被称为Leaks。要运行此工具,你必须有一个 Xcode 项目;你不能在 Playground 上运行它。它通过从菜单栏中选择Product | Profile来运行。

这将构建你的项目并显示一系列分析工具:

Spotting

如果你选择Leaks工具并按下左上角的记录按钮,它将运行你的程序并警告你内存泄漏,它可以检测到。内存泄漏将看起来像红色的 X 图标,并将列为泄漏的对象:

Spotting

你甚至可以选择泄漏对象的Cycles & Roots视图,Instruments 将显示你的强引用循环的视觉表示。在下面的屏幕截图中,你可以看到SteeringWheelCar之间存在循环:

Spotting

显然,Leaks 是一个强大的工具,你应该定期在你的代码上运行它,但它不会捕获所有的强引用循环。最后的防线将是你保持对代码的警惕,始终思考所有权图。

当然,检测循环只是战斗的一部分。战斗的另一部分是修复它们。

修复

打破强引用循环的最简单方法是简单地完全移除其中一个关系。然而,这通常不是一个选择。很多时候,保持双向关系很重要。

我们修复循环的方法是使一个或多个关系变为弱引用或未拥有。实际上,这正是其他两种关系存在的主要原因。

我们通过将汽车关系改回弱引用来修复原始示例中的强引用循环:

class SteeringWheel {
    weak var car: Car?
}

class Car {
    var steeringWheel: SteeringWheel

    init(steeringWheel: SteeringWheel) {
        self.steeringWheel = steeringWheel
        self.steeringWheel.car = self
    }
}

现在CarSteeringWheel有强引用,但只有弱引用回传:

修复

你如何打破任何给定的循环将取决于你的实现。唯一重要的是,在引用循环中某处有一个弱引用或未拥有关系。

未拥有关系适用于连接永远不会缺失的场景。在我们的例子中,有时方向盘存在但没有汽车引用。如果我们将其改为在Car初始化器中创建SteeringWheel,我们可以使引用未拥有:

class SteeringWheel2 {
    unowned var car: Car

    init(car: Car) {
        self.car = car
    }
}

class Car {
    var steeringWheel: SteeringWheel2!

    init() {
        self.steeringWheel = SteeringWheel2(car: self)
    }
}

此外,请注意我们不得不将steeringWheel属性定义为隐式展开的可选属性。这是因为我们在初始化它时必须使用self,但同时又不能在所有属性都有值之前使用self。将其设置为可选属性允许它在使用self创建方向盘时为 nil。只要SteeringWheel2初始化器不尝试访问传入汽车的steeringWheel属性,这是安全的。

使用闭包

如我们在第五章中发现的,“现代范式 – 闭包和函数式编程”,闭包只是另一种类型的对象,因此它们遵循相同的 ARC 规则。然而,由于它们能够捕获其周围作用域中的变量,它们比类更微妙。这些捕获从闭包到捕获变量的强引用往往被忽视,因为与条件、for 循环和其他类似语法相比,捕获变量看起来非常自然。

正如类可以创建循环引用一样,闭包也可以。某个东西可以对一个闭包有强引用,该闭包直接或间接地对原始对象有强引用。让我们看看我们如何发现这一点。

发现

提供将在某些事情发生时被调用的闭包属性是非常常见的。这些通常被称为回调。让我们看看一个球类,它有一个当球弹跳时的回调:

class Ball {
    var location: (x: Double, y: Double) = (0,0)

    var onBounce: (() -> ())?
}

这种设置很容易无意中创建强引用循环:

let ball = Ball()
ball.onBounce = {
    print("\(ball.location.x), \(ball.location.y)")
}

在这里,我们每次球弹跳时都会打印出球的位置。然而,如果你仔细考虑,你会看到闭包和球实例之间存在一个强引用循环。这是因为我们在闭包中捕获了球。正如我们已经学到的,这从闭包到球创建了一个强引用。球也通过onBounce属性对闭包有一个强引用。这就是我们的循环。

你应该始终意识到你的闭包中捕获了哪些变量,以及该变量是否直接或间接地有强引用到闭包本身。

修复

为了修复这些类型的强引用循环,我们再次需要使循环的一部分弱或无所有者。

Swift 不允许我们使闭包引用弱引用,因此我们必须找到一种方法来弱捕获球变量而不是强引用。

要弱捕获一个变量,我们必须使用捕获列表。使用捕获列表,我们可以捕获原始变量的弱或无所有者副本。我们通过在捕获列表变量名之前指定weakunowned变量来实现这一点:

ball.onBounce = { [weak ball] in
    print("\(ball?.location.x), \(ball?.location.y)")
}

通过将球副本声明为弱引用,它自动使其成为可选的。这意味着我们必须使用可选链来打印其位置。就像其他弱变量一样,如果球被删除,ball将被设置为 nil。然而,根据代码的性质,我们知道如果球被删除,这个闭包永远不会被调用,因为闭包存储在球实例上。在这种情况下,可能最好使用unowned关键字:

ball.onBounce = { [unowned ball] in
    print("\(ball.location.x), \(ball.location.y)")
}

总是清理你的代码,通过删除不必要的可选类型,总是很令人愉快。

失踪的对象

总是记住强引用循环是一个好主意,但如果我们在使用弱和无所有者引用时过于激进,我们可能会遇到相反的问题,即对象在我们打算删除它之前被删除。

在对象之间

当对象的所有引用都是弱引用或无所有者引用时,这种情况会发生。如果我们使用弱引用,这不会是一个致命的错误,但如果这种情况发生在无所有者引用上,程序将会崩溃。

例如,让我们看看前面例子中添加一个额外的弱引用:

class SteeringWheel {
    weak var car: Car?
}
class Car {
    weak var steeringWheel: SteeringWheel!

    init(steeringWheel: SteeringWheel) {
        self.steeringWheel = steeringWheel
        steeringWheel.car = self
    }
}

let wheel = SteeringWheel()
let car = Car(steeringWheel: wheel)

这段代码与前面的代码相同,除了SteeringWheelcar属性和CarsteeringWheel属性都是弱引用。这意味着一旦wheel超出作用域,它将被删除,将汽车的steeringWheel属性重置为 nil。可能存在你想要这种行为的情况,但通常这将是无意中造成的,并产生令人困惑的错误。

重要的是要记住对象的所有关系。只要你还想让对象存在,就应该始终至少有一个强引用,当然,永远不应该有强引用循环。

在闭包中

这实际上不会发生在闭包中,因为我们之前讨论过,你不能弱引用一个闭包。如果你尝试这样做,编译器会给你一个错误:

class Ball2 {
    weak var onBounce: (() -> ())? // Error: 'weak' cannot be
    // applied to non-class type '() -> ()'
}

Swift 让我们免于另一种类型的错误。

结构体与类

现在我们对内存管理有了很好的理解,我们准备讨论当我们选择将类型设计为结构体或类时所做的全部权衡。凭借我们像上一章中看到的那样扩展协议的能力,我们可以实现与第三章中看到的类继承非常相似的功能,即“一次一块——类型、作用域和项目”。这意味着我们通常是在根据内存影响来选择使用结构体还是类,换句话说,我们是否希望我们的类型是值类型还是引用类型。

值类型有优势,因为它们非常简单,易于推理。你不必担心多个变量引用同一个实例。更好的是,你不必担心我们之前讨论过的所有潜在的问题,即强引用循环。然而,引用类型仍然有优势。

当确实有必要在多个变量之间共享一个实例时,引用类型是有优势的。这尤其适用于表示某种物理资源,如计算机上的端口或应用程序的主窗口,这种资源复制是没有意义的。有些人还会争论说,引用类型使用内存更有效率,因为它们不会因为存在大量副本而占用更多内存。然而,Swift 编译器实际上会对我们的代码进行大量的优化,并在可能的情况下减少或消除大多数实际发生的复制。例如,如果我们将一个值类型传递给一个永远不会修改该值的函数,就没有必要实际创建那个副本。最终,我不建议在它变得必要之前对这种类型进行优化。有时你可能会遇到应用程序的内存问题,这时将大量复制的类型转换为类可能是合适的。

最终,我建议将结构体和协议作为默认选项,因为它们大大减少了复杂性,只有在必要时才回退到类。我甚至建议在可能的情况下使用协议而不是超类,因为它们更容易调整,并且使值类型和引用类型之间的过渡更加容易。

摘要

内存管理通常被认为难以理解,但当你将其分解时,你会发现它相对简单直接。在本章中,我们了解到计算机中的所有数据要么存储在文件系统中,这是一个缓慢的永久存储,要么存储在内存中,这是一个快速但临时的位置。文件系统用作内存的备份,极大地减慢了计算机的速度,因此我们作为程序员希望最大限度地减少我们一次使用的内存量。

我们看到在 Swift 中有值类型和引用类型。这些概念对于理解如何减少内存使用和消除内存泄漏至关重要。当对象对自己有强引用时,就会创建内存泄漏,可能通过第三方,这被称为强引用循环。我们还必须小心,确保我们对我们想要保留的每个对象至少保持一个强引用,否则我们可能会过早地丢失它。

通过实践编程,你将更好地预防并修复内存问题。你将编写流畅的应用程序,使你的用户的计算机运行顺畅。

我们现在准备继续讨论 Swift 的最后一个特性,在我们进入更艺术化的计算机编程领域,即错误处理之前。

第八章. 少有人走的路 - 错误处理

Swift 2 中最大的变化之一是苹果增加了一个名为错误处理的功能。处理错误情况通常是编程中最不有趣的部分。处理成功的情况通常更有趣,通常被称为快乐路径,因为那里是功能最吸引人的地方。然而,为了制作真正出色的用户体验,因此制作出真正出色的软件,我们必须仔细关注软件在出现错误时做了什么。Swift 的错误处理功能帮助我们简洁地处理这些情况,并阻止我们最初就忽略错误。

在本章中,我们将讨论 Swift 具体的错误处理功能以及它们如何帮助我们编写更好的软件。我们将通过涵盖以下主题来完成:

  • 抛出错误

  • 处理错误

  • 错误情况下的清理

抛出错误

在我们讨论如何处理错误之前,我们需要讨论如何首先发出错误已发生的信号。这个术语是抛出错误

定义错误类型

抛出错误的第一个部分是定义一个我们可以抛出的错误。任何实现了ErrorType协议的类型都可以被抛出,如下所示:

struct SimpleError: ErrorType {}

此协议没有任何要求,因此类型只需将其列为它实现的协议即可。现在它就可以从函数或方法中抛出了。

定义一个抛出错误的函数

让我们定义一个函数,它将接受一个字符串并将其重复,直到它至少达到一定的长度。这将非常简单实现,但会有一个问题场景。如果传入的字符串为空,无论我们重复多少次,它都不会变长。在这种情况下,我们应该抛出一个错误。

任何函数或方法都可以抛出错误,只要它被标记为带有throws关键字,如下面的代码所示:

func repeatString(
    string: String,
    untilLongerThan: Int
    ) throws -> String
{
    // TODO: Implement
}

throws关键字始终位于参数之后和返回类型之前。

实现抛出错误的函数

现在,我们可以测试传入的字符串是否为空,如果为空则抛出一个错误。为此,我们使用throw关键字和我们的错误实例:

func repeatString(
    string: String,
    untilLongerThan: Int
    ) throws -> String
{
    if string.isEmpty {
        throw SimpleError()
    }

    var output = string
    while output.characters.count <= untilLongerThan {
        output += string
    }
    return output
}

这里需要注意的一个重要事情是,当我们抛出一个错误时,它会立即退出函数。在前面的例子中,如果字符串为空,它会跳到抛出行,然后不会执行函数的其余部分。在这种情况下,通常更合适使用guard语句而不是简单的if语句,如下面的代码所示:

func repeatString(
    string: String,
    untilLongerThan: Int
    ) throws -> String
{
    guard !string.isEmpty else {
        throw SimpleError()
    }

    var output = string
    while output.characters.count < untilLongerThan {
        output += string
    }
    return output
}

最终,这与之前的实现没有太大区别,但它重申了如果函数失败,则不会执行函数的其余部分。我们现在可以尝试使用这个函数。

处理错误

如果我们尝试调用一个函数,比如正常情况下,Swift 会给我们一个错误,如下面的示例所示:

let repeated1 = repeatString("Hello", untilLongerThan: 20)
// Error: Call can throw but is not market with 'try'

为了消除这个错误,我们必须在调用之前添加try关键字。然而,在我们继续之前,我建议如果你在游乐场中跟随,将所有代码包裹在一个函数中。这是因为游乐场根级别的错误抛出将不会被正确处理,甚至可能导致游乐场停止工作。要包裹你的代码在函数中,你可以简单地添加以下代码:

func main() {
// The rest of your playground code
}
main()

这定义了一个名为main的函数,它包含所有在游乐场末尾一次性调用的正常游乐场代码。

现在,让我们回到使用try关键字。实际上,它有三种形式:trytry?try!。让我们先讨论感叹号形式,因为它是最简单的形式。

强制尝试

try!关键字被称为强制尝试。如果你使用它,错误将完全消失,如下所示:

let repeated2 = try! repeatString("Hello", untilLongerThan: 20)
print(repeated2) // "HelloHelloHelloHello"

这种方法的缺点可能基于感叹号及其过去的意义是直观的。就像强制解包和强制转换一样,感叹号是一个标志,表明将会有一个会导致整个程序崩溃的场景。在这种情况下,崩溃将是由函数抛出的错误引起的。有时你可以真正断言从抛出函数或方法的调用中永远不会抛出错误,但一般来说,这不是一个可取的解决方案,考虑到我们正在尝试优雅地处理我们的错误情况。

可选尝试

我们还可以使用try?关键字,这被称为可选尝试。它不会允许崩溃的可能性,而是将函数的结果转换为可选值:

let repeated3 = try? repeatString("Hello", untilLongerThan: 20)
print(repeated3) // Optional("HelloHelloHelloHello")

这里的好处是,如果函数抛出错误,repeated3将简单地设置为nil。然而,与此相关有几个奇怪的情况。首先,如果函数已经返回了一个可选值,结果将转换为可选的可选值:

func aFailableOptional() throws -> String? {
    return "Hello"
}
print(try? aFailableOptional()) // Optional(Optional("Hello"))

这意味着你将不得不解包可选值两次才能到达真正的值。如果抛出错误,外层的可选值将是nil,如果方法返回nil,则内层的可选值也将是nil

另一个奇怪的情况是,如果函数根本不返回任何内容。在这种情况下,使用可选的try将创建一个可选的空值,如下所示:

func aFailableVoid() throws {
    print("Hello")
}
print(try? aFailableVoid()) // Optional(())

你可以通过检查结果是否为nil来确定是否抛出了错误。

这种技术的最大缺点是,无法确定错误抛出的原因。对于我们的repeatString:untilLongerThan:函数来说这不是问题,因为只有一个错误场景,但我们会经常遇到可以以多种方式失败的功能或方法。特别是,如果这些是基于用户输入调用的,我们希望能够向用户报告错误发生的确切原因。

为了让我们能够获取关于错误原因的更精确信息,我们可以使用final关键字,它简单地就是try

捕获错误

为了了解捕获错误的有用性,让我们看看编写一个新函数,该函数将创建一个随机数列表。我们的函数将允许用户配置列表的长度以及可能的随机数的范围。

捕获错误的背后的想法是,你有机会查看抛出的错误。就我们当前的错误类型而言,这不会非常有用,因为没有办法创建不同类型的错误。一个很好的解决方案是使用实现ErrorType协议的枚举:

enum RandomListError: ErrorType {
    case NegativeListLength
    case FirstNumberMustBeLower
}

这个枚举为我们将要抛出的错误提供了两种情况,因此我们现在可以开始实现我们的函数:

func createRandomListContaininingXNumbers(
    xNumbers: Int,
    between low: Int,
    and high: Int
    ) throws -> [Int]
{
    guard xNumbers >= 0 else {
        throw RandomListError.NegativeListLength
    }
    guard low < high else {
        throw RandomListError.FirstNumberMustBeLower
    }

    var output = [Int]()
    for _ in 0 ..< xNumbers {
        let rangeSize = high - low + 1
        let betweenZero = Int(rand()) % rangeSize
        let number = betweenZero + low
        output.append(number)
    }
    return output
}

这个函数首先检查错误场景。它首先检查我们是否试图创建一个负长度的列表。然后,它检查范围的高值是否确实大于低值。之后,我们重复将随机数添加到输出数组中,次数与请求的次数相同。

注意,这个实现使用了rand函数,我们在第二章中使用了它,构建块 – 变量、集合和流程控制。要使用它,你需要import Foundation,并且再次使用srand来初始化随机数。

此外,这种随机数的使用稍微复杂一些。之前,我们只需要确保随机数在零和数组长度之间;现在,我们需要它在两个任意数之间。首先,我们确定可以生成多少个不同的数字,这是高数和低数之差加一,因为我们想包括高数。然后,我们在那个范围内生成随机数,最后,通过将低数加到结果上来将其移到我们想要的实际范围。为了确保这能正常工作,让我们通过一个简单的场景来思考。假设我们想要生成一个介于410之间的数字。这里的范围大小将是10 - 4 + 1 = 7,所以我们将生成介于06之间的随机数。然后,当我们加上4时,它将那个范围移动到410之间。

因此,我们现在有一个会抛出几种类型错误的函数。如果我们想捕获这些错误,我们必须在do块中嵌入调用,并添加try关键字:

do {
    try createRandomListContaininingXNumbers(
        5,
        between: 5,
        and: 10
    )
}

然而,如果我们把它放在 playground 的main函数中,我们仍然会得到一个错误,即这里抛出的错误没有被处理。如果你把它放在 playground 的根级别,这不会产生错误,因为 playground 会默认处理任何抛出的错误。要在函数中处理它们,我们需要添加catch块。catch块的工作方式与switch案例相同,就像switch是在错误上执行一样:

do {
    try createRandomListContaininingXNumbers(
        5,
        between: 5,
        and: 10
    )
}
catch RandomListError.NegativeListLength {
    print("Cannot create with a negative number of elements")
}
catch RandomListError.FirstNumberMustBeLower {
    print("First number must be lower than second number")
}

catch 块是通过关键字 catch 后跟情况描述,然后是包含要运行该情况的代码的大括号来定义的。每个 catch 块都作为一个独立的 switch case。在我们的前一个例子中,我们定义了两个不同的 catch 块:一个用于每个错误,我们打印出用户可理解的错误信息。

然而,如果我们把这段代码添加到我们的游乐场中,我们仍然会得到一个错误,因为所有错误都没有被处理,因为外层的 catch 块不是穷尽的。这是因为 catch 块就像 switches 一样,必须覆盖所有可能的情况。我们无法说明我们的函数只能抛出随机的列表错误,因此我们需要添加一个最后的 catch 块来处理任何其他错误:

do {
    try createRandomListContaininingXNumbers(
        5,
        between: 5,
        and: 10
    )
}
catch RandomListError.NegativeListLength {
    print("Cannot create with a negative number of elements")
}
catch RandomListError.FirstNumberMustBeLower {
    print("First number must be lower than second number")
}
catch let error {
    print("Unknown error: \(error)")
}

最后的 catch 块将错误存储到一个仅类型为 ErrorType 的变量中。我们真正能做的只是将其打印出来。根据我们当前的实现,这个块永远不会被调用,但如果我们在以后向函数添加不同的错误并忘记添加新的 catch 块,它可能会被调用。

注意,目前没有方法可以指定从特定函数可以抛出哪种类型的错误;因此,在这个实现中,编译器无法确保我们覆盖了错误枚举的每个情况。我们可以在 catch 块内执行一个 switch,这样编译器至少会强制我们处理每个情况:

do {
    try createRandomListContaininingXNumbers(
        5,
        between: 5,
        and: 10
    )
}
catch let error as RandomListError {
    switch error {
    case .NegativeListLength:
        print("Cannot create with a negative number of elements")
    case .FirstNumberMustBeLower:
        print("First number must be lower than second number")
    }
}
catch let error {
    print("Unknown error: \(error)")
}

这种技术不会在从我们的函数抛出完全不同类型的错误时让编译器给出错误,但至少如果我们在枚举中添加新的情况,它会给出错误。

我们可以使用的另一种技术是定义一个包含应显示给用户的描述的错误类型:

struct UserError: ErrorType {
    let userReadableDescription: String
    init(_ description: String) {
        self.userReadableDescription = description
    }
}

func createRandomListContaininingXNumbers2(
    xNumbers: Int,
    between low: Int,
    and high: Int
    ) throws -> [Int]
{
    guard xNumbers >= 0 else {
        throw UserError(
            "Cannot create with a negative number of elements"
        )
    }

    guard low < high else {
        throw UserError(
            "First number must be lower than second number"
        )
    }

    // ...
}

我们不是抛出枚举情况,而是创建 UserError 类型的实例,并带有问题的文本描述。现在,当我们调用函数时,我们只需捕获错误作为 UserError 类型,并打印出其 userReadableDescription 属性的值:

do {
    try createRandomListContaininingXNumbers2(
        5,
        between: 5,
        and: 10
    )
}
catch let error as UserError {
    print(error.userReadableDescription)
}
catch let error {
    print("Unknown error: \(error)")
}

这是一个相当吸引人的技术,但它也有自己的缺点。这不允许我们在发生特定错误时轻松运行某些代码。在仅仅向用户报告错误的情况下,这并不重要,但在我们可能更智能地处理错误的情况下,这非常重要。例如,如果我们有一个上传信息到互联网的应用程序,我们经常会遇到互联网连接问题。我们不仅可以告诉用户稍后再试,还可以将信息本地保存,并自动稍后尝试再次上传,而无需打扰用户。然而,互联网连接问题不会是上传失败的唯一原因。在其他错误情况下,我们可能想要做其他的事情。

一个更健壮的解决方案可能是结合这两种技术。我们可以从定义一个可以直接报告给用户的错误协议开始:

protocol UserErrorType: ErrorType {
    var userReadableDescription: String {get}
}

现在我们可以为我们的特定错误创建一个枚举,该枚举实现了该协议:

enum RandomListError: String, UserErrorType {
    case NegativeListLength =
        "Cannot create with a negative number of elements"
    case FirstNumberMustBeLower =
        "First number must be lower than second number"

    var userReadableDescription: String {
        return self.rawValue
    }
}

这个枚举被设置为具有原始类型的字符串。这允许我们编写一个更简单的 userReadableDescription 属性的实现,它只返回原始值。

这样,我们的函数实现看起来和之前一样:

func createRandomListContaininingXNumbers3(
    xNumbers: Int,
    between low: Int,
    and high: Int
    ) throws -> [Int]
{
    guard xNumbers >= 0 else {
        throw RandomListError.NegativeListLength
    }
    guard low < high else {
        throw RandomListError.FirstNumberMustBeLower
    }

    // ...
}

然而,我们的错误处理现在可以更高级。我们总是可以捕获任何 UserErrorType 并将其显示给用户,但如果我们想在这种情况下做些特别的事情,我们也可以捕获特定的枚举情况:

do {
    try createRandomListContaininingXNumbers3(
        5,
        between: 5,
        and: 10
    )
}
catch RandomListError.NegativeListLength {
    // Do something else
}
catch let error as UserErrorType {
    print(error.userReadableDescription)
}
catch let error {
    print("Unknown error: \(error)")
}

请记住,我们的 catch 块的顺序非常重要,就像 switch 情况的顺序一样重要。如果我们把 UserErrorType 块放在 NegativeListLength 块之前,我们就会总是向用户报告它,因为一旦一个 catch 块被满足,程序就会跳过所有剩余的块。

这是一个相当直接的方法;因此,你可能有时想使用一个更简单的解决方案。你甚至可能在将来想出你自己的解决方案,但这也为你提供了一些可以尝试的选项。

传播错误

处理错误的最后一个选项是允许它传播。这只有在包含的函数或方法也被标记为抛出错误时才可能,但如果这是真的,那么实现起来就很简单:

func parentFunction() throws {
    try createRandomListContaininingXNumbers3(
        5,
        between: 5,
        and: 10
    )
}

在这种情况下,try 调用不需要被包裹在 do-catch 中,因为 createRandomListContainingXNumbers:between:and: 抛出的所有错误都会被 parentFunction 重新抛出。实际上,你仍然可以使用 do-catch 块,但是 catch 情况不再需要是详尽的,因为任何未被捕获的错误将简单地被重新抛出。这允许你只捕获与你相关的错误。

然而,虽然这可以是一个有用的技术,但我建议不要过度使用。你处理错误的情况越早,你的代码就越简单。每个可能抛出的错误就像是在高速公路系统中增加了一条新道路;如果有人走错了方向,确定他们走错路的地方就会变得更难。我们处理错误得越早,在父函数中创建额外的代码路径的机会就越少。

错误情况下的清理

到目前为止,我们并不需要过于担心在抛出错误后函数中会发生什么。有时,无论是否抛出错误,我们都需要在退出函数之前执行某些操作。

发生错误时的执行顺序

抛出错误的一个重要注意事项是当前作用域的执行会退出。如果你把它看作是一个简单的返回调用,那么对于函数来说这很容易理解。在抛出之后的所有代码将不会被执行。在 do-catch 块中这稍微有点不那么直观。一个 do-catch 可以有多个可能抛出错误的函数调用,但是一旦一个函数抛出错误,执行就会跳转到第一个匹配该错误的 catch 块:

do {
    try function1()
    try function2()
    try function3()
}
catch {
    print("Error")
}

在这里,如果 function1 抛出错误,function2function3 将不会被调用。如果 function1 没有抛出错误但 function2 抛出了,那么只有 function3 不会被调用。此外,请注意,我们可以使用两个其他的 try 关键字中的任何一个来防止跳过行为:

do {
    try! function1()
    try? function2()
    try function3()
}
catch {
    print("Error")
}

现在,如果 function1 抛出错误,整个程序将会崩溃,如果 function2 抛出错误,它将直接继续执行 function3

延迟执行

现在,正如我之前暗示的,在某些情况下,无论是否抛出错误,我们都需要在退出函数或方法之前执行某些操作。你可以将那种功能放入一个在抛出每个错误之前被调用的函数中,但 Swift 提供了一种更好的方式,称为 defer 块。defer 块简单地允许你在退出函数或方法之前运行一些代码。让我们看看一个个人厨师类型的例子,这种类型在尝试烹饪食物后必须始终进行清理:

struct PersonalChef {
    func clean() {
        print("Wash dishes")
        print("Clean counters")
    }

    func addIngredients() throws {}
    func bringToBoil() throws {}
    func removeFromHeat() throws {}
    func allowItToSit() throws {}

    func makeCrèmeBrûlée(URL: NSURL) throws {
        defer {
            self.clean()
        }

        try self.addIngredients()
        try self.bringToBoil()
        try self.removeFromHeat()
        try self.allowItToSit()
    }
}

在制作焦糖布丁的方法中,我们从一个调用清理方法的 defer 块开始。这不会立即执行;它会在抛出错误或方法退出之前立即执行。这确保了无论焦糖布丁的制作过程如何,个人厨师都会进行清理。

实际上,即使在从函数或方法返回的任何时刻,defer 也会起作用:

struct Ingredient {
    let name: String
}

struct Pantry {
    private let ingredients: [Ingredient]

    func openDoor() {}
    func closeDoor() {}

    func getIngredientNamed(name: String) -> Ingredient? {
        self.openDoor()

        defer {
            self.closeDoor()
        }

        for ingredient in self.ingredients {
            if ingredient.name == name {
                return ingredient
            }
        }
        return nil
    }
}

在这里,我们定义了一个小的成分类型和一个储藏室类型。储藏室有一个成分列表和一个帮助我们从中获取成分的方法。当我们去获取成分时,我们首先必须打开门,因此我们需要确保无论是否找到成分,我们都要在最后关闭门。这是一个 defer 块的另一个完美场景。

关于 defer 块,还有一点需要注意,你可以定义任意多的 defer 块。每个 defer 块将按照它们定义的相反顺序被调用。因此,最近的延迟块将首先被调用,最旧的延迟块将最后被调用。我们可以看看一个简单的例子:

func multipleDefers() {
    defer {
        print("C")
    }
    defer {
        print("B")
    }
    defer {
        print("A")
    }
}
multipleDefers()

在这个例子中,"A" 将首先被打印出来,因为它是最晚被延迟的块,而 "C" 将最后被打印出来。

最终,在执行任何需要清理操作的动作时使用 defer 是一个很好的主意。在最初实现它时,你可能没有额外的返回或抛出,但它将使以后对代码的更新更加安全。

概述

错误处理通常不是编程中最有趣的部分,但正如你所见,围绕它绝对可以有一些有趣的设计策略。在开发高质量软件时,它也是绝对关键的。我们喜欢认为我们的用户永远不会遇到任何问题或未预见的场景,但你可能会惊讶于这种情况发生的频率。我们希望尽我们所能让这些场景运行良好,因为如果用户在不可避免的错误情况下陷入困境,他们会对你的产品留下持久的负面印象。

我们看到 Swift 为我们提供了一个称为错误处理的范式来帮助解决这个问题。函数和方法可以被标记为可能抛出错误,然后我们可以抛出任何实现了ErrorType协议的类型。我们可以以不同的方式处理这些抛出的错误。我们可以使用try!关键字断言错误永远不会被抛出,我们可以使用try?关键字将抛出函数或方法转换为可选,或者我们可以使用 do-catch 块捕获和检查错误。最后,我们讨论了 defer 块,它帮助我们确保无论我们是否抛出错误或提前返回,某些操作都会发生。

现在我们已经解决了错误处理的问题,我们可以跳入计算机编程的更具艺术性的方面,即设计模式。

第九章. 以 Swift 方式编写代码 – 设计模式和技巧

除非你是计算机科学的尖端人物,否则你编写的软件将更多地关注用户体验和可维护性,而不是任何特定的先进编程语言。随着你编写越来越多的这类软件,你将看到许多模式出现,特别是如果你专注于可读性和可维护性,正如我们大多数人应该做的那样。然而,我们不必自己想出所有这些模式;多年来,人们一直在编程,并提出了许多模式,这些模式可以从一种语言很好地转移到另一种语言。

我们把这些模式称为设计模式。设计模式是一个庞大的主题,有无数本书籍、教程和其他资源。我们花费我们整个职业生涯来练习、塑造和完善这些模式在实践中的应用。我们给每个模式起一个名字,这样我们就可以与同行程序员进行更流畅的对话,并在我们自己的脑海中更好地组织它们。

在本章中,我们将探讨一些最常见的设计模式,特别是那些对理解苹果框架很重要的模式。当你开始在使用他人代码时识别模式时,你将更容易理解和利用那段代码。这也有助于你自己编写更好的代码。我们将关注每个模式背后的高层次思想,然后是如何在 Swift 中实现它们。然后我们将超越经典的设计模式,看看 Swift 的一些高级特性,这些特性允许我们编写特别干净的代码。

为了做到这一切,我们将在本章中涵盖以下主题:

  • 什么是设计模式?

  • 行为型模式

  • 结构型模式

  • 创建型模式

  • 有效使用关联值

  • 将系统类型扩展以减少代码

  • 懒加载属性

什么是设计模式?

在我们深入研究具体模式之前,让我们先深入了解一下什么是设计模式。正如你可能已经理解的,编写一个甚至只做简单事情的程序有无数种方式。设计模式是为了解决反复出现和常见问题的一种解决方案。这些问题通常是如此普遍,以至于即使你没有故意使用模式,你也几乎肯定在不经意间使用了其中的一种或多种模式;尤其是如果你在使用第三方代码。

为了更好地评估设计模式的使用,我们将查看三个高级度量:耦合度内聚度复杂性

耦合度是指单个代码组件对其他组件的依赖程度。我们希望减少代码中的耦合度,以便所有代码组件尽可能独立地运行。我们希望能够单独查看它们,并理解每个组件,而无需对整个系统有全面的理解。低耦合还允许我们在不影响其他代码的情况下对单个组件进行更改。

聚合性是指不同代码组件之间如何很好地结合在一起。我们希望代码组件能够独立操作,但它们仍然应以一种连贯且易于理解的方式与其他组件结合。这意味着为了实现低耦合和高聚合,我们希望代码组件被设计成只有一个目的,并且与其他代码的接口很小。这适用于我们代码的每个层面,从我们的应用程序的不同部分如何结合,到函数之间如何相互交互。

这两种度量都对我们的最终度量:复杂度,有很高的影响。复杂度基本上就是理解代码的难度,尤其是在添加新功能或修复错误等实际事情上。通过实现低耦合和高聚合,我们通常会编写更少的复杂代码。然而,如果将这些原则推向极端,有时实际上会导致更大的复杂度。有时最简单的解决方案是最快和最有效的,因为我们不希望在我们能够十倍更快地实现近乎完美的解决方案时,陷入设计完美解决方案的困境。我们大多数人都不可能在不限预算的情况下编码。

与拥有一个单一的巨大列表不同,设计模式通常根据它们的使用方式组织成三个主要类别:行为结构创建

行为模式

行为模式是描述对象之间如何相互通信的模式。换句话说,这是如何一个对象将信息发送给另一个对象,即使信息只是某个事件已经发生。它们通过提供一种更分离的通信机制来帮助降低代码的耦合度,允许一个对象向另一个对象发送信息,同时尽可能少地了解另一个对象。任何类型对代码库中其他类型的了解越少,它对这些类型的依赖就越少。这些行为模式还通过提供简单易懂的方式来发送信息,从而帮助提高聚合性。

这往往是在做某事,比如打电话给你的姐姐让她去问你的妈妈再问你的爷爷他生日想要什么,和能够直接问你爷爷因为他与你有一个良好的沟通渠道之间的区别。一般来说,我们希望保持直接的沟通渠道畅通,但有时与较少的人互动实际上是一种更好的设计,只要我们不过度依赖其他组件。行为模式可以帮助我们做到这一点。

迭代器

我们将要讨论的第一个行为模式被称为迭代器模式。我们之所以从这一模式开始,是因为我们实际上已经在第六章 让 Swift 为你工作 – 协议和泛型中使用了这种模式。迭代器模式的思想是提供一种方法,可以独立于容器内部元素表示方式来遍历容器的内容。

正如我们所见,Swift 通过GeneratorTypeSequenceType协议为我们提供了这种模式的基础。它甚至为它的数组和字典容器实现了这些协议。即使我们不知道数组或字典内部元素是如何存储的,我们仍然能够遍历它们包含的每个值。苹果可以轻松地更改它们内部元素的存储方式,而这根本不会影响我们遍历容器的方式。这显示了我们的代码与容器实现之间的高度解耦。

如果你记得,我们甚至能够为无限斐波那契序列创建一个生成器:

struct FibonacciGenerator: GeneratorType {
    typealias Element = Int

    var values = (0, 1)

    mutating func next() -> Element? {
        self.values = (
            self.values.1,
            self.values.0 + self.values.1
        )
        return self.values.0
    }
}

"容器"甚至不存储任何元素,但我们仍然可以像它存储了元素一样遍历它们。

迭代器模式是了解我们如何在现实世界中使用设计模式的一个很好的介绍。遍历列表是一个如此常见的问题,以至于苹果直接将其构建到 Swift 中。

观察者

我们将要讨论的另一种行为模式被称为观察者模式。这种模式的基本思想是,有一个对象被设计成允许其他对象在发生某些事件时被通知。

回调

在 Swift 中,实现这一点最简单的方法是在你想要被观察的对象上提供一个闭包属性,并在该对象想要通知其观察者时调用该闭包。该属性将是可选的,这样任何其他对象都可以设置它们的闭包到这个属性上:

class ATM {
    var onCashWithdrawn: ((amount: Double) -> ())?

    func withdrawCash(amount: Double) {
        // other work

        // Notify observer if any
        if let callback = self.onCashWithdrawn {
            callback(amount: amount)
        }
    }
}

在这里,我们有一个代表允许取款的 ATM 的类。它提供了一个每次取款时都会被调用的闭包属性onCashWithdrawn。这种类型的闭包属性通常被称为回调。通过其名称清楚地表明其目的是一个好主意。我个人选择以“on”这个词作为所有基于事件回调的命名前缀。

现在,任何对象都可以定义自己的闭包在回调上,并在取款时被通知:

class RecordKeeper {
    var transactions = [Double]()

    func watchATM(atm: ATM) {
        atm.onCashWithdrawn = { [weak self] amount in
            self?.transactions.append(amount)
        }
    }
}

在这种情况下,ATM被视为可观察对象,而RecordKeeper是观察者。ATM类型与可能记录其交易过程的任何过程完全断开连接。记录机制可以改变,而无需对ATM进行任何更改。只要新的ATM实现仍然在取款时调用onCashWithDrawnATM就可以更改,而无需对RecordKeeper进行任何更改。

然而,RecordKeeper 需要传递一个 ATM 实例以建立此连接。同时,一次也只有一个观察者。如果我们需要允许多个观察者,我们可以提供回调函数数组,但这可能会使移除观察者变得更加困难。解决这两个问题的方案是使用通知中心来实现观察者模式。

通知中心

通知中心是一个管理其他类型事件的中心对象。我们可以为 ATM 提款实现一个通知中心:

class ATMWithdrawalNotificationCenter {
    typealias Callback = (amount: Double) -> ()
    private var observers: [String:Callback] = [:]

    func trigger(amount: Double) {
        for (_, callback) in self.observers {
            callback(amount: amount)
        }
    }

    func addObserverForKey(key: String, callback: Callback) {
        self.observers[key] = callback
    }

    func removeObserverForKey(key: String) {
        self.observers[key] = nil
    }
}

在这个实现中,任何对象都可以通过传递一个唯一的键和回调给 addObserverForKey:callback: 方法来开始观察。它不需要对 ATM 实例有任何引用。观察者也可以通过传递相同的唯一键给 removeObserverForKey: 来被移除。在任何时候,任何对象都可以通过调用 trigger: 方法来触发通知,所有注册的观察者都将被通知。

如果你真的想通过高级协议和泛型来挑战自己,你可以尝试实现一个完全通用的通知中心,它可以同时存储和触发多个事件。在 Swift 中,理想的通知中心应该允许任何对象触发任意事件,以及任何对象观察任意事件,只要它们知道这些事件。通知中心不需要了解任何特定事件。它还应允许事件包含任何类型的数据。

结构型模式

结构型模式是描述对象之间如何相互关联以共同实现一个共同目标的模式。它们通过建议一种简单明了的方式来将问题分解为相关部分,帮助我们降低耦合度,并通过为我们提供一种预定义的方式,让这些组件能够相互配合,从而帮助我们提高内聚度。

这就像一个运动队为场上的每个人定义特定的角色,以便他们能够作为一个整体更好地一起比赛。

组合

我们将要研究的第一个结构型模式被称为组合模式。这种模式的概念是,你可以有一个单一的对象,它可以被分解成一组与自身类似的对象。这就像许多大型公司的组织结构。它们将拥有由更小的团队组成的团队,这些小团队再由更小的团队组成。每个子团队负责一小部分工作,然后他们一起负责公司更大的部分。

层次结构

计算机最终用像素数据网格来表示屏幕上的内容。然而,并不是每个程序都需要关心每个单独的像素。相反,大多数程序员使用操作系统提供的框架,在更高的层面上操作屏幕上的内容。一个图形程序通常被赋予一个或多个窗口来绘制,而不是在窗口内绘制像素;程序通常会设置一系列“视图”。一个视图将具有许多不同的属性,但最重要的是它有一个位置、大小和背景颜色。

我们可以用一个大的视图列表构建整个窗口,但程序员已经设计了一种使用组合模式来使整个过程更加直观的方法。一个视图实际上可以包含其他视图,这些视图通常被称为子视图。从这个意义上说,你可以将任何视图视为子视图的树。如果你查看树的根,你会看到一个将在窗口上显示的完整图像。然而,你可以查看树中的任何分支或叶子,并看到该视图的较小部分。这与将一个大团队作为一个整体看待与在更大团队中看待一个小团队是一样的。在这整个过程中,树根的视图和树叶的视图之间没有区别,除了根有更多的子视图。

让我们看看我们自己的View类的实现:

class View {
    var color: (red: Float, green: Float, blue: Float)
        = (1, 1, 1) // white
    var position: (x: Float, y: Float) = (0, 0)
    var size: (width: Float, height: Float)
    var subviews = [View]()

    init(size: (width: Float, height: Float)) {
        self.size = size
    }
}

这是一个相当简单的类,但通过添加subviews属性,它是一个额外的视图数组,我们使用组合模式使这个类变得非常强大。你可以想象一个几乎无限的视图层次结构,所有这些视图都包含在一个单一父视图内。这个单一视图可以被传递给其他类,以绘制整个视图层次结构。

例如,让我们设置一个视图,其中左侧为红色,右上角为绿色,右下角为蓝色:

层次结构

要使用我们的类生成此内容,我们可以编写类似的代码:

let rootView = View(size: (width: 100, height: 100))

let leftView = View(size: (width: rootView.size.width / 2, height: rootView.size.height))
leftView.color = (red: 1, green: 0, blue: 0)
rootView.subviews.append(leftView)

let rightView = View(size: (width: rootView.size.width / 2, height: rootView.size.height))
rightView.color = (red: 0, green: 0, blue: 1)
rightView.position = (x: rootView.size.width / 2, y: 0)
rootView.subviews.append(rightView)

let upperRightView = View(size: (width: rightView.size.width, height: rootView.size.height / 2))
upperRightView.color = (red: 0, green: 1, blue: 0)
rightView.subviews.append(upperRightView)

在这个实现中,我们实际上有一个由leftView定义的红色左侧和一个由rightView定义的蓝色右侧。右上角是绿色而不是蓝色,是因为我们将upperRightView作为子视图添加到rightView中,并且只使其高度为二分之一。这意味着我们的视图层次结构看起来类似于以下图像:

层次结构

重要的是要注意,upperRightView的位置被保留为0, 0。这是因为所有子视图的位置总是相对于它们最近的父视图。这使得我们可以在不影响任何子视图的情况下从层次结构中提取任何视图;在rootView内绘制rightView将看起来与它单独绘制时完全一样。

你也可以设置单独的对象来管理主视图的不同部分的 内容。例如,要创建一个像 Xcode 这样的程序,我们可能有一个对象管理左侧的文件列表内容,另一个对象管理所选文件的显示。显然,Xcode 比这要复杂得多,但它给了我们一个想法,即我们可以如何使用相对简单的概念构建极其强大和复杂的软件。

然而,你可能已经注意到了我们视图类的一个潜在问题。如果我们将其自己的子视图层次结构添加到某个地方,会发生什么?这很可能会在代码的另一个部分试图绘制视图时导致无限循环。作为对你的一项挑战,尝试更新我们的View类以防止这种情况发生。我建议你首先将subviews设为私有,并提供添加和删除子视图的方法。你可能还希望添加一个可选的superview属性,它将引用父视图。

相对于子类化

如你所见,组合模式非常适合任何可以将对象分解成与其完全相同的片段的情况。这对于看似无限的视图层次结构非常理想,但它也是子类化的一个很好的替代方案。子类化实际上是耦合最紧密的形式。子类极其依赖于其超类。对超类的任何更改几乎肯定会影响所有子类。我们经常可以使用组合模式作为子类化的一个松散耦合的替代方案。

例如,让我们探讨表示一个句子的概念。看待这个问题的一种方式是将句子视为一种特殊的字符串。任何这样的专门化通常都会引导我们创建一个子类;毕竟,子类是其超类的一种专门化。因此,我们可以创建一个Sentence子类,它是String的子类。这将非常棒,因为我们可以使用我们的句子类构建字符串,然后将它们传递给期望接收普通字符串的方法。

然而,这个方法存在一个重要的障碍:我们没有控制String代码,甚至更糟糕的是,我们甚至无法查看代码,所以我们甚至不知道字符是如何存储的。这意味着代码可以在我们不知情的情况下被苹果的更新所更改。即使我们有知识,这也可能引起维护上的麻烦。

一个更好的解决方案是使用组合模式并实现一个包含字符串的Sentence类型:

struct Sentence {
    var words: [String]

    enum Type: String {
        case Statement = "."
        case Question = "?"
        case Exclamation = "!"
    }

    var type: Type
}

在这里,我们能够用各种词汇给句子的各个部分赋予更有意义的名称,并设置了一个Type枚举,允许我们使用不同的结束标点符号。作为一个便利性,我们甚至可以添加一个计算属性string,这样我们就可以将句子用作普通字符串:

struct Sentence {
    // ..

    var string: String {
        return self.words.joinWithSeparator(" ")
            + self.type.rawValue
    }
}

let sentence = Sentence(words: [
    "This", "is",
    "a", "sentence"
], type: .Statement)
print(sentence.string) // "This is a sentence."

在这种情况下,这是一个比子类化更好的替代方案。

委托

苹果框架中最常用的设计模式之一被称为委托模式。其背后的理念是设置一个对象,以便让另一个对象处理其部分责任。换句话说,一个对象将部分责任委托给另一个对象。这就像经理雇佣员工来完成经理自己无法或不想亲自完成的工作。

作为更技术性的例子,在 iOS 中,苹果提供了一个名为UITableView的用户界面类。正如其名所示,这个类为我们提供了一个绘制元素列表的简单方法。单独的UITableView不足以创建界面。它需要数据来显示,并且需要能够处理各种用户交互,如点击、重新排序、删除等。

一种本能的想法是创建自己的UITableView子类,比如PeopleTableView。这是一个不错的选择,直到你记得我们讨论过子类化实际上是两个对象之间最强的耦合类型。为了正确地子类化UITableView,你必须非常熟悉超类的工作方式。这在你甚至不允许查看超类代码的情况下尤其困难。

另一个选项是在表格视图中设置数据,并使用观察者模式来处理用户交互。这比子类化选项更好,但大多数你想要显示的数据都不是静态的,因此更新表格视图会变得繁琐。同时,实现一个可以轻松用于其他显示信息列表方式的对象也会很困难。

因此,苹果的做法是,在UITableView上创建了两个不同的属性:delegatedataSource。这些属性的存在是为了让我们能够分配自己的对象来处理表格的各种责任。数据源主要负责提供表格中要显示的信息,而委托的责任是处理用户交互。当然,如果这些对象可以是任何类型,表格视图实际上无法与它们交互。另外,如果这些对象是特定类型,我们仍然会遇到相同的子类化问题。因此,它们被定义为分别实现UITableViewDelegateUITableViewDataSource协议。

这些协议仅定义了允许表格视图正确运行所需的方法。这意味着delegatedataSource属性可以是任何类型,只要它们实现了必要的方法。例如,数据源必须实现的一个关键方法是tableView:numberOfRowsInSection:。这个方法为表格视图提供了一个整数,表示它想要了解的分区。它要求返回一个整数,表示引用分区中的行数。这是数据源必须实现的多达多个方法之一,但它让你了解表格视图不再需要确定它包含的数据。它只是要求数据源来解决这个问题。

这提供了一种非常松散耦合的方式来实现特定的表格视图,并且这种相同的模式在编程世界的各个角落都被重复使用。你会对苹果公司如何使用其表格视图感到惊讶,它对第三方开发者造成的痛苦非常小。如果你真的想要的话,表格视图可以非常优化地处理成千上万行数据。自 iOS 的第一个开发者工具包以来,表格也发生了很大的变化,但这些协议很少改变,除非是为了添加额外的功能。

模型视图控制器

模型视图控制器是最高级别和最抽象的设计模式之一。它的变体在大量软件中普遍存在,尤其是在苹果的框架中。它实际上可以被认为是苹果所有代码设计的基石,因此也是大多数第三方开发者设计自己代码的基础。模型视图控制器的核心概念是将所有类型分成三个类别,通常被称为层:模型视图控制器

模型层是为所有表示和操作数据的类型设计的。这一层是软件能够为用户提供的真正基础,因此它也常被称为业务逻辑。例如,地址簿应用中的模型层将包含代表联系人、群组等的类型。它还将包含创建、删除、修改和存储这些类型的逻辑。

视图层是所有参与软件显示和交互的类型。它包括表格、文本视图和按钮等类型。本质上,这一层负责向用户显示信息,并提供用户如何与您的应用程序交互的便利性。地址簿应用中的视图将包括显示的联系人、群组和联系人信息列表。

最外层,控制器层,主要是模型层和视图层之间的粘合代码。它将根据模型层中的数据指示视图显示什么内容,并根据视图层传来的交互触发正确的业务逻辑。在我们的通讯录示例中,控制器层会将视图中的某个元素,比如添加联系人的按钮,连接到模型中定义的创建新联系人的逻辑。它还会将屏幕上的表格视图连接到模型中的联系人列表。

在模型视图控制器理想的实现中,没有任何模型类型应该知道视图类型的存在,也没有任何视图类型应该知道模型类型的存在。通常,模型视图控制器被形象地比作一块蛋糕:

模型视图控制器

用户看到并与之交互的是蛋糕的顶部,每一层只与其相邻的层进行通信。这意味着视图层和模型层之间的所有通信都应该通过控制器层进行。同时,控制器层应该相对轻量级,因为模型层在应用逻辑方面承担着繁重的任务,而视图层在屏幕绘制和接受用户输入方面承担着繁重的任务。

这种设计模式的主要好处之一是它提供了一种逻辑上和一致的方式来分解许多软件组件。这极大地提高了你与其它开发者共享代码和理解他们代码的能力。当尝试理解一个之前未曾见过的庞大代码库时,它为每个人提供了一个参考框架。类的命名也给了开发者关于类型在整体系统中扮演什么角色的强烈提示。iOS 中几乎每个视图类都包含“view”这个词:UITableViewUIViewUICollectionViewCell等。同样,苹果提供的控制器层类大多数都包含“controller”这个词:UIViewControllerUITableViewControllerMFMailComposeViewController等。模型层主要留给第三方开发者,除了基本的数据类型,因为苹果不太可能在你软件的业务逻辑方面提供太多帮助。然而,即使在第三方开发者中,这些类通常都是以它们所表示或操作的数据命名的名词:Person、AddressBook、Publisher 等等。

模型视图控制器的另一个巨大好处是大多数组件将非常易于重用。你应该能够轻松地重用视图,就像你可以使用表格视图来显示几乎任何类型的数据,而不需要改变表格视图类型一样;你也应该能够以许多不同的方式显示类似通讯录这样的内容,而不需要改变通讯录类型。

尽管这个模式很有用,但它也非常难以坚持。你可能会在整个开发生涯中不断进化你对如何有效地将问题分解为这些层级的感知。通常,为每一层创建显式的文件夹会有所帮助,迫使你将每种类型放入仅一个类别中。你也可能会发现自己创建了一个臃肿的控制器层,尤其是在 iOS 中,因为将业务逻辑放在那里通常很方便。比其他任何设计模式,模型视图控制器可能是最需要努力追求但很少能完美实现的东西。

创建型模式

我们将要讨论的最后一种设计模式称为创建型模式。这些模式与新对象的初始化有关。起初,对象的初始化可能看起来很简单,并不是一个特别重要的地方来应用设计模式。毕竟,我们已经有初始化器了。然而,在特定情况下,创建型模式可以非常有帮助。

单例/共享实例

我们将要讨论的第一种设计模式是单例模式共享实例模式。我们将它们一起讨论,因为它们非常相似。首先,我们将讨论共享实例,因为它是单例模式的较不严格形式。

共享实例模式的想法是提供你类的一个实例供代码的其他部分使用。让我们通过 Swift 中的一个快速示例来看看这一点:

class AddressBook {
    static let sharedInstance = AddressBook()

    func logContacts() {
        // ...
    }
}

在这里,我们有一个简单的地址簿类,但我们提供了一个静态常量sharedInstance,任何其他代码都可以使用它,而无需创建自己的实例。这是一种非常方便的方式,允许原本分离的代码进行协作。你不需要在代码中传递相同实例的引用,任何代码都可以通过类本身直接引用共享实例:

AddressBook.sharedInstance.logContacts()

现在,单例模式的不同之处在于,你会以这种方式编写代码,以至于甚至不可能创建你类的一个第二个实例。尽管我们之前的地址簿类提供了一个共享实例,但没有任何东西可以阻止某人使用正常的初始化器创建自己的实例。我们可以相当容易地将我们的地址簿类改为单例而不是共享实例,如下所示:

class AddressBook {
    static let singleton = AddressBook()

    private init() {}

    func logContacts() {
        // ...
    }
}

AddressBook.singelton.logContacts()

除了更改静态常量的名称外,与这段代码的唯一区别在于我们将初始化器声明为私有。这使得文件外的代码无法使用初始化器,因此,文件外的代码也无法创建新的实例。

单例模式在多个实例的同一类将导致问题时非常出色。这对于代表有限物理资源的类尤为重要,但它也可以是一种简化类的途径,这样就可以以更困难和不必要的方式实现多个实例。例如,实际上并没有太多理由确保应用程序中始终只有一个地址簿。也许用户会想要有两个地址簿:一个用于商业,一个用于个人。只要它们从不同的文件中工作,它们应该能够独立操作。但在你的应用程序中,你可能知道将始终只有一个地址簿,并且它总是由一个文件驱动。与其要求你的代码使用特定的文件路径创建地址簿,并且处理多个实例读取和写入同一文件的风险,你还可以使用上面的单例版本,并将文件路径固定。

事实上,单例模式和共享实例模式非常方便,以至于许多开发者过度使用它们。因此,让我们讨论一下这些模式的缺点。能够从任何地方访问一个实例是很方便的,但当这样做变得容易时,也容易在对象上创建一个非常复杂的依赖网络。这与我们试图实现的低耦合原则相悖。想象一下,当你有 20 段不同的代码都直接使用它时,试图更改单例类是多么困难。

使用这些模式也可能创建隐藏的依赖。通常,根据它必须初始化的内容,可以很清楚地了解实例的依赖关系,但单例或共享实例并没有传递给初始化器,因此它通常会被忽视作为依赖。尽管将对象传递给初始化器有一些初始的额外开销,但它通常会减少耦合,并保持对类型之间交互的更清晰的认识。底线是,就像任何其他模式一样,仔细思考每个单例和共享实例模式的使用,并确保它是完成工作的最佳工具。

抽象工厂

我们在这里将要讨论的最后一个模式被称为抽象工厂。它基于一个更简单的模式,即工厂模式。工厂模式的想法是,你为创建其他对象实现一个对象,就像你为组装汽车创建一个工厂一样。当初始化一个类型非常复杂或你想创建许多相似的对象时,工厂模式非常出色。让我们看看第二种场景。如果我们正在创建一个双打乒乓球游戏,并且在游戏中有一些场景需要添加特定玩家需要保持比赛进行的多余球,球类可能看起来像这样:

struct Ball {
    let color: String
    let owningPlayer: Int
}

每次我们需要一个新的球时,我们都可以为其分配一个新的颜色和拥有者。或者,我们可以为每个玩家创建一个单独的球工厂:

struct BallFactory {
    let color: String
    let owningPlayer: Int

    func createNewBall() -> Ball {
        return Ball(
            color: self.color,
            owningPlayer: self.owningPlayer
        )
    }
}

let player1Factory = BallFactory(
    color: "Red", owningPlayer: 1
)
let player2Factory = BallFactory(
    color: "Green", owningPlayer: 1
)

let ball1 = player1Factory.createNewBall()

现在,我们可以将这个工厂传递给任何负责处理球创建事件的物体,这样该物体就不再负责确定球的颜色或任何其他我们可能想要的属性。这对于减少该物体所承担的责任数量非常有用,同时也使得代码在未来添加额外的球属性时非常灵活,而无需更改球创建事件对象。

抽象工厂是一种特殊的工厂形式,其中工厂创建的实例可能是单个其他类的一个或多个子类的实例。一个很好的例子是图像创建工厂。正如我们在第三章中讨论的,“一次一件——类型、作用域和项目”,计算机有无数种方式来表示图像。在第三章中,我们假设有一个名为“Image”的超类,它将为每种图像类型有一个子类。这将帮助我们很容易地编写处理任何类型图像的类,因为它们总是与图像超类一起工作。同样,我们可以创建一个图像工厂,这将几乎消除外部类型了解不同类型图像的任何需要。我们可以设计一个抽象工厂,它接受任何图像的路径,将其加载到适当的子类中,并简单地将其作为图像超类返回。现在,加载图像的代码和使用图像的代码都不需要知道它们正在处理什么类型的图像。不同图像表示的复杂性都被抽象到工厂和图像类层次结构中。这对于使我们的代码更容易理解且更易于维护是一个巨大的优势。

有效使用关联值

良好的编程不仅仅是关于如何编写有效代码的宏大、普遍概念。最优秀的程序员知道如何发挥手中工具的优势。现在,我们将从观察编程设计的核心原则转向使用 Swift 的强大功能来增强代码的一些具体细节。

我们将首先探讨如何有效地使用枚举的关联值。关联值是 Swift 的一个相当独特的特性,因此它们开辟了一些相当有趣的可能性。

替换类层次结构

我们已经在第三章中看到,我们可以使用带有关联值的枚举来表示像距离这样的测量值在多个测量系统中的表示:

enum Height {
    case Imperial(feet: Int, Inches: Double)
    case Metric(meters: Double)
    case Other(String)
}

我们可以将这个用例概括为使用枚举来简化简单的类层次结构。而不是枚举,我们本可以创建一个高度超类或协议,并为每个测量系统创建子类。然而,这将是一个更复杂的解决方案,我们会失去使用值类型而不是引用类型的好处。枚举解决方案也非常紧凑,使得它一目了然,而不是需要分析多个不同类如何结合在一起。

让我们看看一个更复杂的例子。假设我们想要创建一个健身应用,并希望能够跟踪多种类型的锻炼。有时人们锻炼是为了完成一定数量的各种动作的重复;而有时他们只是想要锻炼一定的时间。我们可以为这个目的创建一个类层次结构,但使用关联值的枚举效果很好:

enum Workout {
    case ForTime(seconds: Int)
    case ForReps(movements: [(name: String, reps: Int)])
}

现在,当我们想要创建一个锻炼时,我们只需要定义与我们感兴趣的锻炼类型相关的值,而无需使用任何类。

简洁地表示状态

枚举与关联值另一个很好的用途是表示某物的状态。这个最简单的例子将是一个结果枚举,它可以在发生错误时包含一个值或错误描述:

enum NumberResult {
    case Success(value: Int)
    case Failure(reason: String)
}

这允许我们编写一个可能会失败并给出失败原因的函数:

func divide(first: Int, by second: Int) -> NumberResult {
    guard second != 0 else {
        return .Failure(reason: "Cannot divide by zero")
    }
    return .Success(value: first / second)
}

这是对正常错误处理的替代方案,并且对于将失败情况视为与成功情况类似而不是罕见异常的情况的函数来说是有意义的。

一个稍微复杂一些的想法是使用枚举来表示一个将在一段时间内经过各种阶段的过程,通常称为状态机。我们可以为下载过程编写一个枚举:

enum DownloadState {
    case Pending
    case InProgress(percentComplete: Float)
    case Complete(data: String)
}

在下载进行过程中,我们可以访问其完成程度,一旦完成,我们可以访问下载的数据。这些信息仅在适用时才可访问。这个枚举还将使确保我们的下载始终处于合理且明确定义的状态变得更加容易。例如,下载可能已完成但数据尚未处理的中立状态是不可能的。如果我们想要表示一个额外的处理步骤,我们可以轻松地添加另一个情况,并且从那时起,将清楚地知道下载将经过那个额外的状态。

扩展系统类型以减少代码

另一个我们在第三章中简要介绍过的强大功能,即一次处理一个部分 – 类型、作用域和项目,是扩展现有类型的能力。我们了解到,我们可以向字符串类型添加一个扩展,使我们能够多次重复字符串。让我们看看这个功能的更实际的应用案例,并讨论它在改进我们的代码方面的好处。

也许我们正在创建一个成绩跟踪程序,我们将打印出大量的百分比。表示百分比的一个很好的方式是使用介于零和一之间的浮点数。浮点数非常适合表示百分比,因为我们可以使用内置的数学函数,并且它们可以表示非常细粒度的数字。使用浮点数表示百分比时需要克服的障碍是打印它。如果我们简单地打印出值,它很可能不是我们想要的方式。人们更喜欢百分比以 100 为基数,并在其后加上百分号。

最坏的情况是,我们每次需要打印百分比时都要写一些东西,比如 print("\(myPercent * 100)%")。这并不灵活;如果我们想调整所有百分比输出以具有前导空格,使其打印为右对齐,我们不得不逐个更改每个打印语句。相反,我们可以编写自己的函数,比如 printPercentage。这将允许我们在很多地方共享相同的代码。

这是个不错的步骤,但我们可以利用 Swift 扩展系统类型的能力做得更好。如果我们有一个名为 printPercentage 的任意函数,我们将很难记住它的存在,其他开发者也可能很难在最初发现它。如果我们可以轻松地从浮点数本身获取可打印版本,那就好多了。我们可以通过向 Float 添加扩展来实现这一点:

extension Float {
    var percentString: String {
        return "\(self * 100)%"
    }
}
let myPercent: Float = 0.32
print(myPercent.percentString) // 32.0%

现在,我们可以使用自动完成功能来帮助我们记住为浮点数定义了哪些格式。随着时间的推移,你可能会积累一些像这样的有用且通用的扩展,它们因为独立于你其他特定程序的代码而极具可重用性。以这种方式编写这些代码使得将它们引入新程序变得非常容易,从而大大加快了每个新项目的启动速度。

然而,你确实需要小心,不要创建太多的扩展。对于更复杂的情况,通常更合适的是使用组合模式。例如,我们可以将其编写为一个可以与 Float 构造的 Percent 类型:

struct Percent: CustomStringConvertible {
    let value: Float

    var description: String {
        return "\(self.value * 100)%"
    }
}
print(Percent(value: 0.3))

在这种情况下,可能不需要创建自己的类,但你至少应该考虑如何在未来扩展百分比的概念。

懒加载属性

我们尚未讨论的一个特性是懒加载属性的概念。将属性标记为懒加载允许 Swift 在首次访问时才初始化它。这至少在几个重要方面可能很有用。

避免不必要的内存使用

使用懒加载属性最明显的方式是避免不必要的内存使用。让我们先来看一个非常简单的例子:

struct MyType {
    lazy var largeString = "Some String"
}
let instance = MyType()

尽管我们在前面的代码中创建了一个新的MyType实例,但直到我们尝试访问它之前,largeString并未被设置。如果我们有一个可能不是每个实例都需要的大变量,这很好。在访问之前,它不会占用任何内存。

避免不必要的处理

我们还可以通过使用闭包来计算值进一步扩展这个懒属性的想法:

class Directory {
    lazy var subFolders: [Directory] = {
        var loaded = [Directory]()
        // Load subfolders into 'loaded'
        return loaded
    }()
}

在这里,我们实际上是在使用一个自我评估的闭包。我们通过在闭包的末尾添加开闭括号来实现这一点。通过这样做,我们将subFolders属性分配给执行闭包的结果;因为它是一个延迟属性,所以闭包只有在第一次访问subFolders属性时才会执行。就像可以帮我们避免占用不必要的内存的普通延迟属性一样,这项技术允许我们在不需要时避免运行耗时的操作。

将逻辑本地化到相关属性

要实现上述目标,除了使用懒属性外,我们还可以使用可选属性,并在需要时简单地分配这些值。这是一个可行的解决方案,特别是如果我们唯一的目的是减少不必要的内存使用或处理。然而,懒属性解决方案还有另一个巨大的好处。它通过将计算属性值的逻辑直接连接到其定义,产生了更易读的代码。如果我们只是有一个可选属性,它必须在初始化器或其他方法中初始化。在查看属性时,不会立即清楚其值是什么,以及何时设置,如果会设置的话。

当你的代码库规模和年龄增长时,这是一个至关重要的优势。即使代码库是你的,也很容易在其中迷失方向。你可以从一条逻辑到另一条逻辑画出越多的直线,当你后来回到代码库时,找到你想要的逻辑就会越容易。

摘要

在短时间内,我们覆盖了许多非常大的设计概念。我们查看了许多特定的设计模式,这些模式通过减少对象之间的依赖性来降低我们代码的复杂性,通常称为低耦合,并增加了这些对象协同工作的简单性,通常称为高内聚。

我们了解到,存在三种关注解决不同类型问题的设计模式。行为模式有助于对象之间更好地进行通信,结构模式促进将复杂结构分解成更小、更简单的部分,而创建模式则帮助初始化新对象。

我们还探讨了 Swift 的一些非常具体的特性以及它们如何帮助我们实现与设计模式相似的目标。我们看到了如何使用带关联值的枚举来简化我们的类型系统并更好地表示状态;我们使用扩展来减少对系统类型的代码量,并使用惰性属性编写更高效、更易于理解的代码。

正如我一开始所说的,设计模式是一个巨大的主题,而且不是你能够迅速掌握的,如果真的能掌握的话。弄清楚如何最好地使用特定语言的功能也是一个巨大的主题。我强烈建议你在开始开发大型软件并希望找到使其更简单的方法时,将本章作为参考。我也强烈鼓励你研究更多模式,并尝试自己实现它们。每个设计模式都是你工具箱中的另一个工具。你拥有的工具越多,你对每个工具的经验越丰富,你就越能选择适合正确工作的正确工具。这就是编程的艺术。

现在我们已经准备好进入下一章,在这一章中,我们将回顾过去,研究 Objective-C,以便我们能够利用针对 Objective-C 的丰富资源,这些资源对我们作为 Swift 开发者来说仍然非常相关。

第十章. 利用过去 – 理解和翻译 Objective-C

尽管苹果的平台已经存在很多年,但 Swift 仍然是一种非常新的语言。甚至在第一代 iPhone 发布之前,苹果首选的主要语言就是 Objective-C。这意味着世界上有大量使用 Objective-C 在苹果平台上开发的应用资源。有许多令人惊叹的教程、代码库、文章等等,这些都是在 Objective-C 中编写的,对于 Swift 开发者来说仍然非常有价值。

为了利用这些资源,您至少需要具备 Objective-C 的基本理解,这样您才能将教程和文章中学到的概念翻译成 Swift,并利用经过时间考验的 Objective-C 库。

在本章中,我们将通过以下主题来了解 Objective-C 的基础知识,并关注其与 Swift 的比较:

  • Swift 与 Objective-C 的关系

  • Objective-C 的背景

  • 常量和变量

  • 容器

  • 控制流

  • 函数

  • 类型

  • 项目

  • 从 Swift 调用 Objective-C 代码

Swift 与 Objective-C 的关系

正如我们之前讨论的,Objective-C 曾是开发苹果平台应用的主要语言。这意味着 Objective-C 对 Swift 产生了很大影响;其中最大的影响是 Swift 被设计成与 Objective-C 互操作。Swift 代码可以调用 Objective-C 代码,同样,Objective-C 代码也可以调用 Swift 代码。

Swift 的设计初衷,并且仍在设计中,是为了成为编程语言的下一步,而不必丢弃我们所有的 Objective-C 代码。苹果对这种语言的目标是使 Swift 更加现代、交互式、安全、快速和强大。如果我们没有与 Swift 进行比较的基准,这些话将毫无意义。由于 Swift 主要设计用于苹果平台,这个基准在很大程度上是 Objective-C。

Objective-C 的背景

在我们能够讨论 Objective-C 的细节之前,我们需要承认其历史。Objective-C 基于一种被称为“C”的语言。C 编程语言是第一种高度可移植的语言之一。可移植意味着相同的 C 代码可以被编译成在任何处理器上运行,只要有人为该平台编写一个编译器。在此之前,大多数代码都是用汇编语言编写的;这总是需要针对每个将要运行的处理器进行编写。

C 通常被称为过程式编程语言。它基于一系列相互调用的函数的概念。它提供了非常基本的创建自定义类型的支持,但没有内置的对象概念。Objective-C 被开发为 C 的面向对象扩展。正如 Swift 与 Objective-C 向后兼容一样,Objective-C 也与 C 向后兼容。实际上,它只是在 C 的基础上添加了一些新的语法和内置库,以实现面向对象的功能。

真正重要的是苹果开发了他们当前的 API:Cocoa 和 Cocoa Touch,用于 Objective-C。这是 Objective-C 仍然对我们作为 Swift 开发者来说非常相关的一个最大原因。尽管我们主要编写 Swift 代码,但我们仍然会定期与用 Objective-C 编写的 Cocoa 和 Cocoa Touch 库进行交互。

常量和变量

现在,我们准备深入探讨 Objective-C 语言的基础知识。Objective-C 中的常量和变量与 Swift 非常相似,但它们的声明和工作方式略有不同。让我们来看看在 Swift 和 Objective-C 中如何声明一个变量:

var number: Int
int number;

第一行应该看起来很熟悉,因为它是 Swift 语法。Objective-C 版本实际上并没有太大的区别。重要的区别在于变量的类型是在名称之前而不是之后声明的。还应注意,Objective-C 没有类型推断的概念。每次声明变量时,都必须给它指定一个特定的类型。你还会看到在名称后面有一个分号。这是因为 Objective-C 中的每一行代码都必须以分号结束。最后,你应该注意到我们没有显式地将 number 声明为变量。这是因为除非明确指定,否则 Objective-C 中假设所有信息都是变量。为了将 number 定义为常量,我们将在其类型之前添加 const 关键字:

let number = 10
const int number = 10;

Objective-C 有值类型和引用类型,就像 Swift 一样。然而,在 Objective-C 中,它们之间的区别更具有概念性。

值类型

我们上面声明的数字在这两种语言中都是值类型。如果将它们传递给另一个函数,它们会被复制,并且不能有多个变量引用相同的实例。

实际上,在 Objective-C 中确定变量是值类型还是引用类型更容易,因为我们将会看到,几乎所有的引用类型都是用星号 (*) 声明的。如果有星号,你可以安全地假设它是一个引用类型。

引用类型

Objective-C 实际上允许你通过添加一个星号来将任何类型转换为引用类型:

int *number;

这声明了一个指向数字变量的引用,更常见的是称为 指针。在指针声明中,星号应该始终位于类型之后和名称之前。

在 Objective-C 中,引用类型实际上与 Swift 中的可选概念松散地混合在一起。所有引用类型都是可选的,因为指针可以始终指向 nil:

int *number = nil;

指针也可以始终检查 nil:

number == nil;

要访问引用的值,你必须取消引用它:

int actualNumber = *number;

你可以通过在指针前添加一个星号来取消引用它。

这就是 Swift 中指针与可选类型相似的地方。区别在于在 Objective-C 中无法声明非可选的引用类型。每个引用类型在技术上都可以是 nil,即使你设计它永远不会是 nil。这通常会增加很多不必要的 nil 检查,意味着你编写的每个接受引用类型的函数都应该处理 nil 的情况。

最后,这两种语言中引用类型的另一个区别是,Objective-C 在指针引用的类型上不是很严格。例如,如果我们在 int 指针指向相同的东西上创建一个新的双引用,Objective-C 不会抱怨:

double *another = (double *)number;

现在,我们有两个变量:numberanother;它们指向相同的值,但假设它们是不同类型的。其中之一显然是错误的,但如果你尝试,Objective-C 会愉快地尝试将相同的值用作 doubleint。这正是 Swift 通过设计使其不可能的一个错误。

到目前为止,我们查看的所有 Objective-C 代码实际上都是严格的 C 语言。我们没有使用 Objective-C 添加到 C 中的任何功能。Objective-C 添加到 C 中的主要事情是其类系统。

让我们看看我们的第一个实际的 Objective-C 类型 NSString 与 Swift 的 String 类型进行比较:

var myString = "Hello World!"
NSString *myString = @"Hello World!";

就像在 Swift 中一样,你可以使用双引号创建一个字符串实例;然而,在 Objective-C 中,你必须在其前面放置一个 @ 符号。

在 Objective-C 类系统中有一个重要的事情要记住,那就是无法创建一个值类型的类的实例。所有实例都必须通过引用类型来引用。我们不能创建一个普通的 NSString。它必须始终是一个 NSString* 指针。

容器

Objective-C 与 Swift 具有完全相同的核心容器,有两个例外:它们的命名略有不同,并且由于 Objective-C 中所有类型都必须是引用类型的基本要求,Objective-C 中的所有容器都是引用类型。

数组

在 Objective-C 中,数组被称为 NSArray。让我们看看 Swift 和 Objective-C 中数组的初始化是如何并排进行的:

var array = [Int]()
NSArray *array = [NSArray alloc];
array = [array init];

我们定义了一个名为 array 的变量,它是对 NSArray 类型的引用。然后我们将其分配给一个新的 NSArray 实例。Objective-C 中的方括号表示法允许我们在类型或实例上调用方法。每个单独的调用总是包含在单个方括号集中。在这种情况下,我们首先在 NSArray 类上调用 alloc 方法。这返回一个新分配的变量,其类型为 NSArray

与 Swift 不同,Objective-C 需要两步过程来初始化一个新的实例。首先,必须分配内存,然后必须初始化。分配意味着我们为该对象预留内存,初始化意味着我们将它设置为默认值。这就是我们在第二行所做的事情。第二行要求实例初始化自己。我们将数组重新赋值为对 init 调用的结果,因为 init 可能返回 nil。请注意,我们没有解引用 array 变量来对其调用方法。我们实际上是在指针上直接调用方法。

现在,使用两行来初始化一个新的实例有点浪费,所以通常会将调用链式连接起来:

NSArray *array = [[NSArray alloc] init];

这会在 NSArray 上调用 alloc,然后立即在 alloc 的结果上调用 init。然后,array 变量被分配给 init 调用的结果。请注意,alloc 可能返回 nil,在这种情况下,我们将对 nil 调用 init。在 Objective-C 中这是可以的;如果你在 nil 上调用方法,它将简单地总是返回 nil。这与 Swift 中的可选链式调用类似。

除了调用 allocinit 之外,还有一个简单的替代方法,那就是使用 new

NSArray *array = [NSArray new];

这个类方法同时分配和初始化实例。当你没有向 init 传递任何参数时,这很棒,但当你向其中传递参数时,你仍然需要单独调用 alloc。我们将在稍后看到这个例子。

你可能已经注意到,我们没有指定这个数组应该包含什么类型。这是因为实际上这是不可能的。在 Objective-C 中,只要不是 C 类型,所有数组都可以包含任何类型的混合。这意味着 NSArray 不能包含 int(而是使用 NSNumber 类),但它可以包含任何混合的 NSStringsNSArrays 或其他 Objective-C 类型。编译器不会为你进行任何形式的类型检查,这意味着我们可以编写期望数组中包含错误类型的代码。这是 Swift 使之不可能的另一种错误分类。

那么,我们如何向我们的数组中添加对象呢?实际上,NSArray 类不允许我们向其中添加或删除对象。换句话说,NSArray 是不可变的。相反,有一个名为 NSMutableArray 的数组版本,允许我们添加和删除对象。然后我们可以使用 addObject: 方法:

NSMutableArray *array = [NSMutableArray new];
[array addObject:@"Hello World!"];

Objective-C 和 Swift 中的方法命名方式相同,冒号用于表示每个参数。在 Objective-C 中,冒号也用于调用方法,以表示以下代码是传递给方法的值。

纯粹的NSArray的存在是为了与 Swift 中的常量数组实现相同的基本目的。实际上,我们将看到所有 Objective-C 容器都被分为可变和不可变版本。可变容器可以被传递到方法中,并像不可变版本一样处理,通过不允许不想要的代码修改数组来增加一些安全性。

现在,要访问NSArray中的值,我们有两种选择。完整的方式是使用objectAtIndex:方法:

NSString *myString = [array objectAtIndex:0];

我们也可以使用方括号,类似于 Swift:

NSString *myString = array[0];

注意,我们只是假设从数组返回的类型是NSString。我们同样可以假设它是另一种类型,比如NSArray

NSArray *myString = array[0];

如我们所知,这将是不正确的,并且几乎肯定会在代码的后期引发错误,但编译器不会抱怨。

最后,要从可变数组中删除对象,我们可以使用removeObjectAtIndex:方法:

[array removeObjectAtIndex:0];

你需要了解的另一个重要特性是,Objective-C 也有数组字面量,因此你不必动态构建它们:

NSArray *array = @[@"one", @"two", @"three"];

数组字面量以@符号开头,就像字符串一样,但随后它由方括号内的对象列表定义,就像 Swift 一样。

数组可以做很多事情,但你应该能够在看到每个方法时理解它所做的是什么,因为大多数方法命名得很好。这些方法在每种语言中通常都有相同的名称,或者你可以在网上查找,苹果公司有广泛的文档。本章的目的是让你对 Objective-C 代码有一个足够高的层次理解。

字典

按照与数组相同的模式,Objective-C 中的字典被称为NSDictionaryNSMutableDictionary。字典的初始化方式与所示完全相同:

NSMutableDictionary *dict = [[NSMutableDictionary alloc] init];
NSDictionary *dict2 = [NSDictionary new];

要设置一个值,我们使用setObject:forKey:方法:

[dict setObject:@"World" forKey:@"Hello"];

就像数组一样,我们无法在不可变字典上设置新对象。此外,这是我们第一个需要多个参数的方法的例子。正如你所看到的,每个参数都包含在方括号内,但由空格和该参数的标签分隔。在这种模式中,Objective-C 方法可以有多个参数。

现在要访问一个值,我们可以使用objectForKey:方法或再次使用方括号:

NSString *myString = [dict objectForKey:@"Hello"];
NSString *myString2 = dict[@"Hello"];

再次强调,我们假设返回的结果是一个字符串,因为我们知道我们刚刚放入字典中的内容。这种假设并不总是安全的,我们还需要始终意识到,如果该键不存在对象,此方法将返回 nil。

最后,要删除对象,我们可以使用removeObjectForKey:方法:

 [dict removeObjectForKey:@"Hello"];

这一切相对简单明了,尤其是在阅读代码时。这种冗长性一直是 Objective-C 编写可理解代码的伟大特性,并且这一特性也被明确地继承到了 Swift 中。

字典也有字面量,但与 NSArrays 和 Swift 数组字面量不同,Objective-C 中的字典字面量使用花括号声明。否则,它看起来与 Swift 非常相似:

NSDictionary *dict3 = @{@1: @"one", @2: @"two", @3: @"three"};

再次强调,我们必须以 @ 符号开始我们的字面量。我们还可以看到,只要我们在每个数字前加上 @ 符号,我们就可以在我们的容器中使用数字作为对象。这不像创建一个 int 类型,而是创建一个 NSNumber 实例。你不需要了解太多关于 NSNumber 类的信息,除了它是一个表示许多不同形式的数字的对象类。

控制流

Objective-C 与 Swift 有许多相同的控制流范式。我们将快速浏览每个,但在我们这样做之前,让我们看看 Objective-C 中 print 的等价物:

var name = "Sarah"
println("Hello \(name)")
NSString *name = @"Sarah";
NSLog(@"Hello %@", name);

我们不是使用 print,而是使用一个名为 NSLog 的函数。Objective-C 没有字符串插值,所以 NSLog 是一个比 print 稍微复杂一些的解决方案。NSLog 的第一个参数是一个描述要打印格式的字符串。这包括每个我们想要记录的信息的占位符,它指示应该期望的类型。每个占位符都以百分号符号开始。在这种情况下,我们使用 at 符号来指示我们将在字符串中替换的内容。初始格式之后的每个参数都将按照传入的顺序替换占位符。这里,这意味着它最终会记录 Hello Sarah,就像 Swift 代码一样。

现在,我们已经准备好查看 Objective-C 中不同的控制流方法了。

条件语句

在 Swift 和 Objective-C 中,条件语句看起来几乎完全相同,只是在 Objective-C 中需要括号:

var invitees = ["Sarah", "Jamison", "Roana"]
if invitees.count > 20 {
    print("Too many people invited")
}
NSArray *invitees = @[@"Sarah", @"Jamison", @"Roana"];
if (invitees.count > 20) {
    NSLog(@"Too many people invited");
}

你也可以在 Swift 中包含那些括号,但它们是可选的。在这里,你还可以看到 Objective-C 仍然有点语法来调用某些方法的概念。在这种情况下,我们使用了 invitees.count 而不是 [invitees count]。这只有在访问实例的属性或调用不带参数且返回某些内容的方法时才是可选的,就像它是一个计算属性一样。

开关

Objective-C 中的 switch 与 Swift 中的 switch 相比,功能要弱得多。实际上,switch 是严格 C 的一个特性,并且 Objective-C 并没有对其进行任何增强。switch 不能像一系列条件语句那样使用;它只能用于进行相等比较:

switch invitees.count {
    case 1:
        print("One person invited")
    case 2:
        print("Two people invited")
    default:
        print("More than two people invited")
}
switch (invitees.count) {
    case 1:
        NSLog(@"One person invited");
        break;

    case 2:
        NSLog(@"Two people invited");
        break;

    default:
        NSLog(@"More than two people invited");
        break;
}

再次强调,在 Objective-C 中需要括号,而在 Swift 中则是可选的。与 Objective-C 中的 switch 相比,最重要的区别是默认情况下,一个 case 会流入下一个,除非你明确使用 break 关键字来退出 switch。这与 Swift 相反,Swift 中只有在使用 fallthrough 关键字时才会流入下一个 case。在实践中,这意味着绝大多数 Objective-C 的 switch case 都需要以 break 结尾。

Objective-C 的 switch 语句功能不足以让我们为一系列值创建情况,当然也不能像 Swift 那样测试一系列任意条件。

循环

就像条件语句一样,Objective-C 中的循环与 Swift 非常相似。while 循环完全相同,只是需要括号:

var index = 0
while index < invitees.count {
    print("\(invitees[index]) is invited");
    index++
}
int index = 0;
while (index < invitees.count) {
    NSLog(@"%@ is invited", invitees[index]);
    index++;
}

for-in 循环略有不同,在这个循环中,你必须指定你正在遍历的变量的类型,如下所示:

var showsByGenre = [
    "Comedy": "Modern Family",
    "Drama": "Breaking Bad"
]
for (genre, show) in showsByGenre {
    print("\(show) is a great \(genre)")
}
NSDictionary *showsByGenre=@{
    @"Comedy":@"Modern Family",
    @"Drama":@"Breaking Bad"
};
for (NSString *genre in showsByGenre) {
    NSLog(@"%@ is a great %@", showsByGenre[genre], genre);
}

你可能也注意到了,当我们遍历 Objective-C 中的 NSDictionary 时,你只能得到键。这是因为 Objective-C 中不存在元组。相反,你必须使用键作为遍历的依据来从原始字典中访问值。

Objective-C 缺失的另一个特性是范围。为了遍历一系列数字,Objective-C 程序员必须使用一种不同的循环,称为 for 循环:

for number in 1 ... 10 {
    print(number)
}
for (int number = 1; number <= 10; number++) {
    NSLog(@"%i", number);
}

这个循环由三部分组成:一个初始值,一个运行条件,以及每次循环后要执行的操作。这个版本与 Swift 版本一样,循环遍历数字 1 到 10。显然,仍然可以将 Swift 代码翻译成 Objective-C;只是它并不那么简洁。

即使有这个限制,你仍然可以看出,Objective-C 和 Swift 的循环几乎相同,只是需要括号。

函数

到目前为止,我们已经调用了一些 Objective-C 函数,但还没有定义它们。让我们看看我们在 第二章,构建块 – 变量、集合和流程控制中定义的函数的 Objective-C 版本。

我们最基本的函数定义没有接受任何参数,也没有返回任何内容。Objective-C 版本看起来类似于以下代码:

func sayHello() {
    print("Hello World!");
}
sayHello()
void sayHello() {
    NSLog(@"Hello World!");
}
sayHello();

Objective-C 函数总是以函数返回的类型开头,而不是 func 关键字。在这种情况下,我们实际上并没有返回任何内容,所以我们使用 void 关键字来表示。

接受参数并返回值的函数在这两种语言之间有更大的差异:

func addInviteeToListIfSpotAvailable
    (
    invitees: [String],
    newInvitee: String
    )
    -> [String]
{
    if invitees.count >= 20 {
        return invitees
    }
    return invitees + [newInvitee]
}
addInviteeToListIfSpotAvailable(invitees, newInvitee: "Roana")
NSArray *addInviteeToListIfSpotAvailable
    (
    NSArray *invitees,
    NSString *newInvitee
    )
{
    if (invitees.count >= 20) {
        return invitees;
    }
    NSMutableArray *copy = [invitees mutableCopy];
    [copy addObject:newInvitee];
    return copy;
}
addInviteeToListIfSpotAvailable(invitees, @"Roana");

再次强调,Objective-C 版本在函数开始处就定义了返回类型。同样,与变量一样,函数的参数必须在名称之前而不是之后定义其类型。然而,其余部分相当相似:参数包含在括号内,并用逗号分隔;函数的代码包含在大括号内,我们使用 return 关键字来指示我们想要返回的内容。

这种特定的实现实际上提出了处理 Objective-C 中数组的一个有趣的要求。就像我们希望在 Swift 中避免可变数组一样,我们通常也希望在 Objective-C 中避免它们。在这种情况下,我们仍然不想修改传入的数组,我们只想将新邀请者添加到复制的版本末尾。在 Swift 中,因为数组是值类型,复制是自动进行的,我们可以使用加法运算符来添加新邀请者。在 Objective-C 中,我们需要显式地复制数组。更重要的是,我们需要这个复制是可变的,这样我们才能向其中添加新邀请者。

所有这些加在一起,Swift 函数和 Objective-C 方法之间最大的区别是返回值的定义位于参数的开始或结束位置。两种语言都以相同的方式处理内存。当在 Objective-C 中传递指针时,指针本身被复制,但两个版本都将引用同一个实例。当值类型在 Swift 中传递给函数时,值被简单地复制,之后两个版本之间没有任何关系。

类型

Objective-C 中的类型系统比 Swift 稍微复杂一些。这是因为 Objective-C 中的结构和枚举来自 C 语言。只有类和类别来自 Objective-C 的扩展。

结构

在 Swift 中,结构与类非常相似,但在 Objective-C 中,它们差别很大。Objective-C 中的结构本质上只是给一组单独的类型命名的一种方式。它们不能包含方法。甚至更为严格的是,结构不能包含 Objective-C 类型。这让我们只剩下基本的可能性:

struct Cylinder {
    var radius: Int
    var height: Int
}
var c = Cylinder(radius: 10, height: 10)
typedef struct {
    int radius;
    int height;
} Cylinder;
Cylinder c;
c.radius = 10;
c.height = 5;

Objective-C 中的结构以关键字typedef开始,它是类型定义的简称。然后是struct关键字,以及包含在花括号内的结构的不同组成部分。最后,在花括号之后是结构的名称。

高级 C 程序员会更多地使用结构。有一些方法可以模拟继承的一些功能,以及进行其他更高级的操作,但这超出了本书的范围,并且在大多数现代编程项目中并不相关。在 Apple 的 API 中,有一些类型是结构,比如 CGRect,所以你应该知道如何与之交互,但在查看 Objective-C 资源时,你很可能不需要处理自定义结构定义。

枚举

枚举在 Objective-C 中也非常受限。它们实际上只是一个简单的机制,用于表示一系列相关的可能值。这允许我们仍然表示可能的基本颜色:

enum PrimaryColor {
    case Red
    case Green
    case Blue
} 
var color = PrimaryColor.Blue

typedef enum {
    PrimaryColorRed,
    PrimaryColorGreen,
    PrimaryColorBlue,
} PrimaryColor;
PrimaryColor color = PrimaryColorBlue;

就像结构一样,Objective-C 枚举以关键字typedef开始,然后是enum,定义的末尾是名称。每个情况都包含在花括号内,并用逗号分隔。

注意,枚举的每个情况都以枚举的名称开头。这是一个非常常见的约定,使得代码补全变得容易,并显示枚举的所有可能值。这是因为 Objective-C 中,你不能通过枚举本身的名称来指定特定的枚举值。相反,每个情况都是其自己的关键字。这就是为什么当我们把color变量赋值为蓝色时,我们只使用情况名称本身。

Objective-C 中的枚举不能有方法、关联值或表示任何其他值,除了整数。实际上,在 Objective-C 枚举中,每个情况都有一个数值。如果你没有指定任何值,它们从0开始,每个情况增加1。如果你想的话,你可以手动指定一个或多个情况的值:

typedef enum {
    PrimaryColorRed,
    PrimaryColorGreen = 10,
    PrimaryColorBlue,
} PrimaryColor;

在手动指定的情况之后,每个情况将继续增加一个。这意味着在前面代码中PrimaryColorRed仍然是0,但PrimaryColorBlue11

与 Objective-C 的结构体和枚举不同,类与它们的 Swift 对应物非常相似。Objective-C 类可以包含方法属性,使用继承,并且可以初始化。然而,它们看起来相当不同。最值得注意的是,Objective-C 中的类分为两部分:其接口和其实现。接口旨在成为类的公共接口,而实现包括该接口的实现以及任何其他私有方法。

基本类

让我们再次看看第三章中的联系人类,一次一个部分 – 类型、作用域和项目,以及它在 Objective-C 中的样子:

class Contact {
    var firstName: String = "First"
    var lastName: String = "Last"
}
@interface Contact : NSObject {
    NSString *firstName;
    NSString *lastName;
}
@end

@implementation Contact
@end

已经有很多行 Objective-C 代码了。首先,我们有接口声明。它从@interface关键字开始,以@end关键字结束。方括号内是一个属性列表。这些属性本质上与结构体的属性相同,只是你可以在属性中包含 Objective-C 对象。这些属性通常不会这样写,因为使用属性会自动创建它们,就像我们稍后将要看到的那样。

你还会注意到,我们的类是从名为NSObject的类继承的,正如: NSObject所示。这是因为 Objective-C 中的每个类都必须继承自NSObject,这使得NSObject成为最基本的类形式。然而,不要被“基本”这个词所迷惑;NSObject提供了很多功能。我们在这里不会深入探讨,但你至少应该知道这一点。

类的另一个部分是实现。它以 @implementation 关键字开始,后面跟着我们要实现的类名,然后以 @end 关键字结束。在这里,我们没有实际上为我们联系人类添加任何额外的功能。然而,你可能注意到我们的类缺少 Swift 版本中存在的东西。

初始化器

Objective-C 不允许为任何属性或属性指定默认值。这意味着我们必须实现一个设置默认值的初始化器:

@implementation Contact
- (id)init {
    self = [super init];
    if (self) {
        firstName = @"First";
        lastName = @"Last";
    }
    return self;
}
@end

在 Objective-C 中,初始化器与一个方法完全相同,只是按照惯例它们以 init 为名开头。这实际上只是一种惯例,但很重要,因为它会在内存管理和与 Swift 代码交互时引起后续问题。

开头带有减号的符号表示这是一个方法。接下来,在括号内指定返回类型,然后是方法名:在这种情况下是 init。方法体包含在类似于函数的大括号内。

所有初始化器的返回类型都将是 id,这是惯例。这使我们能够轻松地覆盖子类的初始化器。

几乎所有初始化器都将遵循这种相同的模式。就像在 Swift 中一样,self 引用被调用的实例。第一行通过调用父类的初始化器 [super init]self 引用赋值给结果。然后我们允许初始化器失败并返回 nil 的可能性,通过在 if (self) 语句中测试它是否为 nil。如果 self 为 nil,则 if 语句将失败。如果不是 nil,我们分配默认值。最后,我们返回 self,以便调用代码可以维持对新初始化对象的引用。然而,这只是一个惯例,Objective-C 没有任何保护措施来确保属性被正确初始化。

属性

Objective-C 版本的联系人类仍然不完全像 Swift 版本,因为 firstNamelastName 属性无法从类外部访问。为了使它们可访问,我们需要将它们定义为公共属性,并可以将它们从显式属性中删除:

@interface Contact : NSObject {
}
@property NSString *firstName;
@property NSString *lastName;
@end

注意,属性是在大括号之外但仍在 @interface 内定义的。实际上,如果你没有要定义的内容,你可以完全省略大括号。属性会自动生成同名的属性,但名称前有一个下划线:

@implementation Contact
- (id)init {
    self = [super init];
    if (self) {
        _firstName = @"First";
        _lastName = @"Last";
    }
    return self;
}
@end

或者,你可以直接使用 self 来设置值:

@implementation Contact
- (id)init {
    self = [super init];
    if (self) {
        self.firstName = @"First";
        self.lastName = @"Last";
    }
    return self;
}
@end

每种方法都有其细微差别,但仅就一般阅读目的而言,使用哪一种并不重要。

同样,正如你可以在 Swift 中定义弱引用一样,你可以在 Objective-C 中这样做:

@interface SteeringWheel : NSObject
@property (weak) Car *car;
@end

如果你愿意,你可以用 strong 替换 weak,但就像 Swift 一样,所有属性默认都是强引用。Objective-C 中的弱引用如果引用的对象被释放,将自动设置为 nil。你还可以使用 unsafe_unretained 关键字,它在 Swift 中相当于 unowned。然而,这很少被单独使用,因为在 Objective-C 中,unsafe_unretained 不会将值重置为 nil;相反,如果对象被释放,它将引用一个无效的对象,如果使用它,可能会导致混淆的崩溃。

除了 weakstrong 之外,你还可以指定属性是 readonlyreadwrite

@interface SteeringWheel : NSObject
@property (weak, readonly) Car *car;
@end

每个属性属性应该写在括号内,用逗号分隔。正如 readonly 名称所暗示的,这使得属性可读但不能写入。每个属性默认都是读写,所以通常不需要包含它。

注意,你可能会在括号中看到关键字 nonatomic。这是一个更高级的话题,超出了本书的范围。

方法

我们已经看到了一个初始化器形式的示例方法,但让我们看看一些带有参数的方法:

@implementation Contact
- (NSArray *)addToInviteeList:(NSArray *)invitees includeLastName:(BOOL)include {
    NSMutableArray *copy = [invitees mutableCopy];
    if (include) {
        NSString *newString = [self.firstName
           stringByAppendingFormat:@" %@", self.lastName
        ];
        [copy addObject:newString];
    }
    else {
        [copy addObject:self.firstName];
    }
    return copy;
}
@end

每个参数都通过一个冒号后的公共标签定义,括号内是其类型,以及一个内部名称。然后,每个参数通过空格或换行符分隔。

你还可以看到一种格式化长方法调用的示例,即创建 newString 实例。类似于 Swift,任何空格都可以转换为换行符。这允许我们将单行转换为多行,只要我们不在部分行后放置分号。

就像 Swift 一样,Objective-C 也具有类方法的概念。类方法用加号而不是减号表示:

@implementation Contact
+ (void)printAvailablePhonePrefixes {
    NSLog(@"+1");
}
@end

因此,现在你可以直接在类上调用该方法:

[Contact printAvailablePhonePrefixes];

继承

就像我们迄今为止的所有类都继承自 NSObject 一样,任何类都可以像 Swift 一样继承自任何其他类,并且所有相同的规则都适用。方法和属性从其超类继承,你可以在子类中覆盖方法。然而,编译器对规则的强制程度较低。编译器不会强迫你指定你的方法意图覆盖另一个方法。编译器不会对初始化器和它们调用的对象施加任何规则。然而,所有这些约定都存在,因为这些约定是 Swift 要求的灵感来源。

类别

Objective-C 中的类别就像 Swift 扩展一样。它们允许你向现有类添加新方法。它们看起来与普通类非常相似:

extension Contact {
    func fullName() -> String {
        return "\(self.firstName) \(self.lastName)"
    }
}
@interface Contact (Helpers)
- (NSString *)fullName;
@end

@implementation Contact (Helpers)
- (NSString *)fullName {
    return [self.firstName stringByAppendingFormat:@" %@", self.lastName];
}
@end

我们知道这是一个类别而不是普通类,因为我们已经在类名后面添加了一个括号内的名称。一个类上的每个类别都必须有一个唯一的名称。在这种情况下,我们将其称为 Helpers,并添加了一个返回联系人的全名的方法。

在这里,我们第一次在接口内部声明了一个方法。这也适用于类。方法定义看起来与实现完全相同,只是它以分号结束,而不是花括号内的代码。这将允许我们从当前文件外部调用该方法,正如我们将在即将到来的项目部分中更详细地看到的那样。

类别也可以添加属性,但你必须定义自己的获取器和设置器方法,因为就像 Swift 扩展不能添加存储属性一样,Objective-C 类别也不能添加属性:

@interface Contact (Helpers)
@property NSString *fullName;
@end

@implementation Contact (Helpers)
- (NSString *)fullName {
    return [self.firstName stringByAppendingFormat: @" %@",
        self.lastName
    ];
}
- (void)setFullName:(NSString *)fullName {
    NSArray *components = [fullName
        componentsSeperatedByString:@" "];
    ];
    if (components.count > 0) {
        self.firstName = components[0];
    }
    if (components.count > 1) {
        self.lastName = components[1];
    }
}
@end

这些类型的属性与计算属性非常相似。如果你需要允许从属性中读取,你必须实现一个具有完全相同名称的方法,该方法不接受任何参数并返回相同的类型。如果你想能够写入属性,你必须实现一个以set开头的方法,后面跟着以大写字母开头的相同属性名称,该方法接受属性类型作为参数并返回无值。这允许外部类以属性属性的方式与属性交互,尽管实际上它只是一组方法。同样,这可以在类或类别中实现。

协议

就像 Swift 一样,Objective-C 也有协议的概念。它们的定义看起来类似于以下这样:

protocol StringContainer {
    var count: Int {get}
    func addString(string: String)
    func enumerateStrings(handler: () -> ())
}
@protocol StringContainer
@property (readonly) NSInteger count;
- (void)addString:(NSString *)string;
- (void)enumerateStrings:(void(^)(NSString *))handler;
@end

在这里,我们使用@protocol关键字而不是@interface,并且它仍然以@end关键字结束。我们可以定义任何我们想要的属性或方法。然后我们可以说一个类实现了这个协议,类似于以下这样:

@interface StringList : NSObject <StringContainer>
@property NSMutableArray *contents;
@end

一个类实现的协议列表应该列在它继承的类之后,用尖括号括起来,并用逗号分隔。在这种情况下,我们只实现了一个协议,所以不需要任何逗号。此代码还声明了一个contents属性,因此我们可以像下面这样实现协议:

@implementation StringList

- (NSInteger)count {
    return [self.contents count];
}

- (void)addString:(NSString *)string {
    if (self.contents == nil) {
        self.contents = [NSMutableArray new];
    }
    [self.contents addObject:string];
}

- (void)enumerateStrings:(void (^)(NSString *))handler {
    for (NSString *string in self.contents) {
        handler(string);
    }
}

@end

注意,我们在实现中并没有做任何特殊的事情来实现协议;我们只需要确保实现了正确的方法和计算属性。

你还应该注意的另一件事是,Objective-C 中的协议并不像类那样使用。你不能仅仅定义一个变量为协议;相反,你必须给它一个类型,并要求它实现该协议。最常见的是使用id类型:

    id<StringContainer> container = [StringList new];

任何变量声明都可能要求它不仅继承自特定类型,还实现某些协议。

最后,块是 Objective-C 中 Swift 闭包的替代品。实际上,它们是 Objective-C 的后期添加,因此它们的语法有些复杂:

int (^doubleClosure)(int) = ^(int input){
    return input * 2;
};
doubleClosure(2);

让我们分解一下。我们像任何其他变量一样开始,变量名和类型在等号之前。名称以第一个括号内的胡萝卜符号(^)开头。在这种情况下,我们将其称为 doubleClosure。闭包的实际类型包围着它。它开始的类型是闭包返回的类型,在这个例子中是 int。第二组括号列出了闭包接受的参数类型。总的来说,这意味着我们正在定义一个名为 doubleClosure 的闭包,它接受 int 并返回 int

然后,我们转向实现闭包的业务。所有闭包实现都以胡萝卜符号开头,后面跟着括号内的任何参数,以及包含实际实现的括号。一旦定义了闭包,就可以像调用任何其他函数一样调用它。然而,你应该始终意识到闭包可能是 nil,调用它会导致程序崩溃。

还可以定义一个接受闭包作为参数的函数或方法。首先是一个函数:

id firstInArrayPassingTest(NSArray *array, BOOL(^test)(id)) {
    for (id element in array) {
        if (test(element)) {
            return element;
        }
    }
    return nil;
}
firstInArrayPassingTest(array, ^BOOL(id test) {
   return false;
});

注意,类型 id 表示任何 Objective-C 对象,尽管它没有星号,但它是一个引用类型。上面的用法看起来与独立的代码块用法完全一样。然而,在方法中的语法看起来有些不同:

- (id)firstInArray:(NSArray *)array
    passingTest:(BOOL(^)(id test))test
{
    for (id element in array) {
        if (test(element)) {
            return element;
        }
    }
    return nil;
}
[self firstInArray:array passingTest:^BOOL(id test) {
    return false;
}];

这是因为一个方法的参数名称由括号分隔。这导致参数的名称从与胡萝卜符号一起移动到括号之后。最终,我们可以说,在阅读 Objective-C 代码并将其翻译为 Swift 时,只要认识到胡萝卜符号表示一个代码块,那么语法的细微差别并不太重要。许多 Objective-C 程序员会定期查阅代码块语法的细节。

在 Objective-C 中,与代码块相关的所有内存问题都存在。默认情况下,所有参数都是强引用捕获,而弱引用捕获的语法要复杂得多。你必须在代码块外部创建弱变量,并使用它们来捕获:

@interface Ball : NSObject
@property int xLocation;
@property (strong) void (^onBounce)();
@end
@implementation Ball
@end

Ball *ball = [Ball new];
__weak Ball *weakBall = ball;
ball.onBounce = ^{
    NSLog(@"%d", weakBall.xLocation);
};

在这里,我们使用关键字 __weak(有两个下划线)来表示 weakBall 变量应该只对 ball 有弱引用。然后我们可以在代码块内安全地引用 weakBall 变量,而不会创建循环引用。

项目

现在我们对 Objective-C 有了一个相当好的理解,让我们讨论一下项目中的 Objective-C 代码看起来是什么样子。与 Swift 代码不同,Objective-C 使用两种不同类型的文件编写。其中一种类型称为头文件,以扩展名 h 结尾。另一种类型称为实现文件,以扩展名 m 结尾。

在我们真正讨论两者之间的区别之前,我们首先必须讨论代码暴露。在 Swift 中,你写的所有代码都可以访问项目中的其他所有代码。在 Objective-C 中并非如此。在 Objective-C 中,你必须明确表示你想要访问另一个文件中的代码。

头文件

头文件是其他文件可以包含的文件类型。这意味着头文件应该只包含类型的接口。事实上,这就是为什么类接口和实现之间存在分离。任何文件都可以导入头文件,这实际上是将一个文件的代码插入到导入它的文件中:

#import <Foundation/Foundation.h>
#import "Car.h"

@interface SteeringWheel : NSObject
@property (weak) Car *car;
@end

这允许我们将每个类单独放入自己的文件中,就像我们在 Swift 中喜欢做的那样。危险在于我们必须只将可以安全导入到头文件中的代码放入。如果你试图在头文件中放置实现,你将得到每次导入头文件时的重复实现。

在前面的例子中,我们实际上是将一个头文件导入到了另一个文件中。这意味着如果现在有另一个文件包含了这个头文件,它实际上会导入这两个头文件。

你还会注意到导入文件有两种不同的方式。我们使用尖括号导入基础文件,而使用引号导入我们的汽车头文件。尖括号用于从框架中导入头文件,而引号用于在同一个框架或应用程序中导入头文件。

很多的时间,一个头文件包含另一个头文件实际上并不是必要的,因为它只需要知道类的存在。如果它不需要知道类的任何实际细节,它可以使用@class关键字简单地指示类存在:

@class Car;

@interface SteeringWheel : NSObject
@property (weak) Car *car;
@end

现在,编译器不会抱怨它不知道Car是什么。然而,你很可能仍然需要在实现文件中导入汽车头文件,因为你很可能会与该类的一些部分进行交互。

实现文件

如你所猜到的,实现文件通常是用于你的类型的实现。这些文件不会被导入到其他文件中;它们只是履行接口文件定义的承诺。这意味着头文件和实现文件通常成对存在。如果你正在定义方向盘类,你很可能会创建一个SteeringWheel.h头文件和一个SteeringWheel.m实现文件。任何需要与方向盘类的细节进行交互的其他代码将导入头文件,在编译时,编译器将所有实现提供给运行程序。

实现文件也是一个很好的地方来隐藏私有代码,因为它们不能被其他代码导入。由于代码在其他任何地方都不可见,因此不太可能被交互。这意味着有时如果类的使用仅限于该文件,人们会向实现文件添加类接口。将所谓的匿名类别添加到实现文件中也是非常常见的:

@interface SteeringWheel ()
@property NSString *somePrivateProperty;
- (void)somePrivateMethod;
@end

这被认为是匿名的,因为该类别实际上并没有被赋予一个名称。这意味着无法直接将实现与该类别配对。相反,实现应该在类的正常实现中定义。这为在实现文件顶部定义任何私有属性和方法提供了一个很好的方法。从技术上讲,你不需要定义任何私有方法,因为只要它们在同一个文件中实现,就可以进行交互。然而,通常在文件顶部有一个简洁的属性和方法列表会很好。

这又提出了另一个观点,即只有打算从外部文件使用的方法才应该在头文件中声明。你应该始终将头文件视为你类的公共接口,并且它应该尽可能简单。它总是从外部文件的角度来写。这是 Objective-C 实现访问控制的方式。它并没有正式地构建到语言中,但编译器会在你尝试与未导入的代码交互时发出警告。实际上,仍然可以与这些私有接口交互,特别是如果你在其他地方复制了接口声明,但被认为是一个最佳实践,不要这样做,并且苹果实际上会在审查期间拒绝你的应用程序,如果你尝试与它们的 API 的私有部分交互。

组织

除了明显的区别之外,Objective-C 项目将有两种不同类型的文件。它们的组织方式与 Swift 文件完全相同。仍然被认为是一个好习惯,创建文件夹来将相关的文件分组在一起。大多数时候,你都会想将头文件和实现文件配对,因为人们会经常在这两种类型的文件之间切换。然而,人们也可以使用键盘快捷键Control/Command向上箭头或Control/Command向下箭头来快速在头文件和它的实现文件之间切换。

从 Swift 调用 Objective-C 代码

对于我们的目的来说,理解 Objective-C 的最后也是可能最重要的一个组成部分是能够从 Swift 调用 Objective-C 代码。在大多数情况下,这实际上是非常直接的。我们不会花时间讨论从 Objective-C 调用 Swift 代码,因为这本书假设你只编写 Swift 代码。

桥接头文件

能够从 Swift 调用 Objective-C 代码的最重要部分是如何让代码对 Swift 可见。正如我们所知,Objective-C 代码需要导入才能被其他代码看到。这一点在 Swift 中仍然适用,但 Swift 没有导入单个文件的机制。相反,当你将第一个 Objective-C 代码添加到 Swift 项目中时,Xcode 会询问你是否想要添加一个称为 桥接头 的文件:

桥接头

你应该选择 ,然后 Xcode 将自动创建一个以项目结尾名为 Bridging-Header.h 的头文件。这就是你需要导入任何想要暴露给 Swift 的 Objective-C 头文件的文件。它将只是一个包含导入列表的文件。你仍然不需要导入任何实现文件。

使用函数

在将头文件暴露给 Swift 之后,调用函数非常简单。你可以直接调用函数,就像它们没有参数名称一样:

NSArray *addInviteeToListIfSpotAvailable
     (
     NSArray *invitees,
     NSString *newInvitee
     );
addInviteeToListIfSpotAvailable(inviteeList, "Sarah")

Xcode 甚至会为你自动完成代码。从你的 Swift 文件的角度来看,你无法知道该函数是在 Objective-C 中实现还是在 Swift 中实现。

使用类型

你可以使用类型的方式使用函数。一旦在桥接头文件中导入了适当的头文件,你就可以像使用 Swift 类型一样使用该类型:

@interface Contact : NSObject
@property NSString *firstName;
@property NSString *lastName;
- (NSArray *)addToInviteeList:(NSArray *)invitees includeLastName:(BOOL)include;
@end
var contact = Contact()
contact.firstName = "First"
contact.lastName = "Last"
contact.addToInviteeList(inviteeList, includeLastName: false)

再次强调,从 Swift 的角度来看,我们编写使用 Objective-C 类的代码的方式与我们如果该类是在 Objective-C 中实现的方式之间绝对没有区别。我们甚至能够使用相同的参数名称调用 addToInviteeList:includeLastName: 方法。这使得更加清楚,Swift 是在设计时考虑了向后兼容性的。

唯一真正的限制是,所有在 Objective-C 中定义的类都将继承自 NSObject,并且 Objective-C 枚举不会完美地转换为 Swift 枚举。相反,它们仍然以单个常量的形式暴露出来:

typedef enum {
    PrimaryColorRed,
    PrimaryColorGreen,
    PrimaryColorBlue,
} PrimaryColor;
var color: PrimaryColor = PrimaryColorRed

容器

你可能也注意到,NSStringNSArray 类型似乎在前面的代码中透明地转换为 StringArray 类。这是 Swift 和 Objective-C 之间桥接的另一个奇妙特性。这些类型,以及字典,几乎完美地转换。唯一的区别是,由于 Objective-C 在定义容器时确实需要一个元素类型,因此它们在 Swift 中被转换为包含 AnyObject 类型的对象。如果你想将它们视为更具体的类型,你必须进行类型转换:

inviteeList = contact.addToInviteeList(
    inviteeList,
    includeLastName: false
    ) as! [String]

当此方法转换为 Swift 时,其实际返回值是 [AnyObject]!。因此,如果你确定该方法永远不会返回 nil 并且总是返回一个 Strings 数组,那么进行我们上面所做的那种强制转换是安全的。否则,你仍然应该检查 nil 并进行可选转换 (as?)。

注释

你会注意到,当 Objective-C 类型被转换为 Swift 时,这就像是一个模式。任何引用类型默认都会被转换为隐式解包的可选类型,这是由于 Objective-C 引用类型的特性。编译器无法自动知道返回的值是否可能为 nil,因此它不知道是否应该将其转换为常规可选类型或非可选类型。然而,Objective-C 开发者可以添加注释来让编译器知道一个值是否可能为 nil。

可空性

Objective-C 开发者首先可以为特定变量是否可以为 null 添加注释:

- (NSArray * __nonnull)addToInviteeList:
        (NSArray * __nullable)invitees;

__nonnull 关键字表示它不能为 nil,因此它将在 Swift 中被转换为非可选类型,而 __nullable 关键字表示它可以被 nil,因此在 Swift 中它将被转换为常规可选类型。

容器元素类型

Objective-C 开发者也可以注释他们的容器类型,以说明它们包含的类型。为此,使用与 Swift 一样的尖括号:

- (NSArray<NSString *> * __nonnull)addStringToInviteeList:
        (NSArray<NSString *> * __nullable)invitees;

现在,这个方法将真正像 Swift 方法一样工作,它将接受一个可选的字符串数组并返回一个非可选的字符串数组;将不需要进行类型转换:

inviteeList = contact.addStringToInviteeList(inviteeList)

如果你控制着你要导入的 Objective-C 代码,那么你可能想要添加它。否则,你可能能够请求代码的开发者添加注释,以便让你的 Swift 编码更加容易和整洁。

摘要

虽然 Swift 目前是苹果开发社区中的热门新语言,但没有迹象表明 Objective-C 将被完全取代。苹果的所有 API 仍然是用 Objective-C 编写的,如果苹果想要重写它们,这将是一项巨大的工作。苹果肯定设计了 Swift 以能够与 Objective-C 并存,所以现在我们必须假设 Objective-C 将会持续存在。这使得理解和能够与 Objective-C 交互变得非常有价值,即使作为 Swift 开发者也是如此。

在本章中,我们从主要使用 Swift 开发者的角度概述了最相关的 Objective-C 特性和语法。我们了解到 Swift 基本上是演变语言长河的一部分。它受到了苹果希望使其与 Objective-C 向后兼容的强烈影响,而 Objective-C 实际上是 C 语言的一个演变,C 语言又是汇编语言的一个演变,等等。Objective-C 仍然是一种强大的语言,能够表达与 Swift 许多相同的概念。Objective-C 有类似常量和变量的概念,但更侧重于变量。它也有相同的基本容器,但两种语言中的控制流略有不同。Swift 有更强大的 switch 和 ranges,但底层概念仍然非常相似。函数在这两种语言中几乎是相同的,但 Objective-C 的类型系统在某种程度上更为有限,因为它只能表达类,而 Swift 有强大的类、结构和枚举的概念。结构和枚举在 Objective-C 中仍然存在,但它们实际上直接来自 C 语言,并且可以做更少的事情。最后,我们看到了在项目中组织 Objective-C 的方式非常相似,并且从 Swift 调用 Objective-C 代码实际上相当直接。

在苹果开发者社区中,关于 Objective-C 在未来将有多大的相关性存在一些争议。有些人已经全职投入到 Swift 开发中,而有些人则在等待 Swift 更加成熟后再投入精力真正学习它。然而,关于 Objective-C 知识在一段时间内仍将保持相关性的事实,争议并不多,尤其是考虑到存在的大量资源和所有现有的苹果 API 都是用 Objective-C 编写的。我们将在下一章中利用这些 API:第十一章, 一个全新的世界 – 开发一个应用,届时我们将最终深入到一些真正的应用开发中。

第十一章. 一个全新的世界 - 开发应用

到目前为止,我们几乎完全专注于学习 Swift 而不是学习它所设计的平台。这是因为学习一个新的平台与学习一门语言完全不同。学习一门编程语言就像学习一门口语的基本语法。不同口语之间的语法通常表达相似的概念,但具体词汇往往更加多样化,即使有时可以辨认出来。学习一门编程语言就是学习如何连接你希望的平台的具体词汇。本章将介绍 iOS 框架的一些词汇。

我们将通过开发一个简单的相机应用的过程来实现这一点。在这个过程中,我们将学习一些开始任何其他类型 iOS 应用所需的最关键词汇,许多概念也适用于 OS X 开发。具体来说,我们将涵盖:

  • 构想应用

  • 设置应用项目

  • 配置用户界面

  • 运行应用

  • 暂时保存照片

  • 填充我们的照片网格

  • 重构以尊重模型-视图-控制器

  • 永久保存照片

构想应用

在我们甚至打开 Xcode 之前,我们应该对我们计划开发的内容有一个很好的认识。我们想知道我们将需要哪些基本数据来表示,以及用户界面将是什么样的。我们目前不需要每个屏幕的像素完美设计,但我们应该有一个很好的应用流程和我们要在第一个版本中包含的功能的想法。

功能

正如我们已经讨论过的,我们将开发一个基本的相机应用。这给我们留下了一个非常明确的特征列表,我们希望在第一个版本中拥有:

  • 拍摄照片

  • 查看已拍摄照片的相册

  • 标记照片

  • 删除照片

这些是相机应用高度关键的功能。显然,我们没有任何区别化的功能可以使这个应用比其他现有应用更有价值,但这足以学习制作 iOS 应用最关键的部分。

界面

现在我们有了功能列表,我们可以构思应用的基本流程,也称为线框图。我们应用的第一屏将是一个相册,展示用户已经拍摄的所有图片。屏幕上会有一个按钮,允许他们拍摄新图片。它还将具有激活编辑模式的能力,在那里他们可以删除照片或更改标签:

界面

此界面将允许我们利用稍后将要详细探讨的内置拍照界面。此界面还将允许我们使其灵活地适应所有不同的手机和平板屏幕。这看起来可能很简单,但有许多组件必须组合在一起才能使这个应用工作。另一方面,一旦你对不同的组件有了良好的理解,它将再次开始看起来很简单。

数据

现在我们大致了解了应用需要如何为用户工作,我们可以至少提出一个关于数据存储的高层次概念。在这种情况下,我们仅仅有一个带有不同标签的图像的扁平列表。我们存储这些文件的最简单方式是在本地文件系统中,每个图像以用户选择的标签命名。在这个系统中需要注意的唯一一点是我们将不得不找到一种方法来允许两个具有相同确切标签的不同图像。当我们着手实现时,我们将更详细地解决这个问题。

设置应用项目

现在我们已经完成了应用的概念化,我们准备开始编码。在 第三章,一次一件——类型、作用域和项目 中,我们创建了一个命令行项目。这次,我们将创建一个 iOS 应用。再次在 Xcode 中导航到 文件 | 新建 | 项目…。当窗口出现时,从 iOS | 应用 菜单中选择 单视图应用

设置应用项目

从那里,点击 下一步,然后给项目命名为 LearningCamera。任何 组织名称标识符 都可以。最后,确保从语言下拉菜单中选择 Swift,从 设备 下拉菜单中选择 通用。现在再次选择 下一步 并创建项目。

Xcode 将向你展示一个项目开发窗口,它看起来与命令行项目略有不同:

设置应用项目

这个默认屏幕允许我们配置应用的各种属性,包括版本号、目标设备等。就我们的目的而言,所有默认设置都很好。当你决定将应用提交到应用商店时,这个屏幕将变得非常重要。

Xcode 也为我们创建了一些不同的文件和文件夹。我们将仅在 LearningCamera 文件夹中工作。LearningCameraTests 文件夹用于自动化测试;这是一个极好的想法,但超出了本书的范围。最后一个文件夹是 Products 文件夹,你不需要对其进行更改。

LearningCamera文件夹中,我们有几个重要的文件。第一个文件是AppDelegate.swift,它是应用的入口点。它有一个为你创建的类,称为AppDelegate,该类在应用生命周期中的不同点调用了一些方法。我们不需要修改这个文件来完成我们的目的,但它在许多应用中是一个重要的文件。

第二个文件是ViewController.swift。这个文件包含一个UIViewController子类,用于管理应用默认视图与业务逻辑之间的交互。我们将在那里做很多工作。

第三个文件是Main.storyboard。这个文件包含我们视图的界面设计。目前,它只有一个由ViewController管理的视图。我们将在稍后使用这个文件来添加和配置我们的视觉组件。

第四个文件是Assets.xcassets。这是一个用于存放我们希望在应用中显示的所有图像的容器。你制作的几乎每个应用都会至少有一个图像,所以这也是一个非常重要的文件。

最后,最后一个文件是LaunchScreen.storyboard。这个文件让我们可以管理应用启动时的显示。这是生产应用的一个极其重要的部分,因为这是用户每次启动应用时看到的第一件事;一个精心设计的启动过程可以产生巨大的影响。然而,我们不需要对这个文件做任何事情来完成我们的学习目的。

配置用户界面

现在我们已经在项目中找到了方向,让我们开始配置我们应用的用户界面。正如我们之前讨论的,这是在Main.storyboard文件中完成的。当我们选择该文件时,我们会看到一个图形编辑工具,通常被称为界面构建器

配置用户界面

在中间,有一个由ViewController实例控制的主要视图。这是一个空白画布,我们可以添加我们想要的任何界面元素。

我们首先想做的事情是添加我们线框中沿顶部的那条栏。这个栏被称为导航栏,我们可以直接添加它,因为它是我们库中的一个元素。然而,如果我们使用导航控制器,框架将为我们处理许多复杂问题。导航控制器是一个包含其他视图控制器的视图控制器。具体来说,它会在顶部添加一个导航栏,并允许我们在未来将其推送到子视图控制器上。这个控制器在许多应用中创建了一个视图从右侧推入的动画。例如,当你选择邮件应用中的电子邮件时,它会动画显示电子邮件的内容;这使用了导航控制器。我们不需要在这个应用中推送任何视图控制器,但为未来做好准备是好的,这也是在顶部获得导航栏的更优方式。

沿着右侧,我们有一个元素库,我们可以将其拖拽到画布上,让我们先找到导航控制器。从库中将它拖拽到左侧的面板中,其中列出了视图控制器场景。这将向列表中添加两个新的视图控制器:

配置用户界面

我们不希望有新的根视图控制器,只保留视图控制器场景,所以让我们将其删除。为此,点击带有黄色图标的根视图控制器并按下删除键。接下来,我们希望将视图控制器场景设置为根视图控制器。根视图控制器是在导航控制器内首先显示的控制器。为此,右键点击带有黄色图标的导航控制器并将其拖拽到下面的带有黄色图标的视图控制器上。视图控制器将被高亮显示为蓝色:

配置用户界面

释放鼠标右键后,会出现一个菜单,你应该点击根视图控制器。最后,我们希望将导航控制器设置为应用中首先出现的视图控制器。选择带有黄色图标的导航控制器,从主菜单中选择视图 | 实用工具 | 显示属性检查器,然后向下滚动并勾选是否为初始视图控制器复选框。请注意,你可以随意拖拽屏幕上的视图控制器,但请确保使文件更容易导航。

现在我们已经准备好自定义我们的主视图。为了聚焦视图,从左侧的面板中选择视图控制器。现在双击标题,将其更改为Gallery

配置用户界面

接下来,我们希望将“拍照”按钮添加到我们的导航栏中。工具栏中的所有按钮都称为栏按钮项。在库中找到它们,然后将它拖拽到工具栏的右侧(当你靠近时,可以放置它的位置会变为蓝色)。默认情况下,按钮会显示为Item,但我们希望它是一个添加按钮。一个选项是将文本更改为加号符号,但有一个更好的选项。添加按钮后,你应该能在左侧的主视图的层次结构中看到它。在那里,你会看到带有新按钮项的导航栏嵌套在Gallery标题下。如果你在层次结构中选择该项,你将在屏幕右侧看到一些关于该项目的配置选项。我们希望将系统项更改为添加

配置用户界面

现在,你可以用编辑标识符对导航栏的左侧做同样的操作。

最后,我们需要添加照片画廊。为此,我们将从库中使用集合视图。将其拖到视图的中心。集合视图由一定数量的单元格组成,这些单元格以网格形式排列。每个单元格都是模板单元格的副本,并且可以在代码中配置以显示特定数据。当您拖动集合视图时,它还为您创建了一个模板单元格。我们很快将配置它。

首先,我们需要定义集合视图大小的规则。这将使界面能够很好地适应每个不同的屏幕尺寸。我们用来做这个的工具称为自动布局。点击集合视图,然后点击屏幕右下角的固定图标:

配置用户界面

将此窗口配置为与前面的截图匹配。点击每个四条支柱,使它们突出显示为红色,取消选中限制于边距,并将每个测量值更改为零。配置完成后,点击添加 4 个约束。这将导致出现一些黄色线条,指示视图的放置与我们刚刚创建的规则不一致。我们可以自己调整视图的大小以使其匹配,或者让 Xcode 为我们做:屏幕左侧的画廊场景旁边将出现一个黄色图标。点击它,您将获得一个列表,其中包含放置不当的视图。在那里,您可以点击黄色三角形,然后点击修复错位。我们还想将背景改为白色而不是黑色。选择集合视图,然后在属性检查器中将背景更改为白色。

在此屏幕上我们需要配置的最后一件事情是集合视图单元格。这是集合视图左上角的一个框。我们需要更改大小并添加一个图像和一个标签;让我们先从更改大小开始。如果尚未选中,请点击集合视图,然后从主菜单导航到视图 | 实用工具 | 显示大小检查器。将单元格大小更改为宽度为110点,高度为150点。

现在,我们可以将我们的图片拖入。在库中,这被称为图像视图。将其拖入单元格中,然后在大小检查器中更改高度和宽度为110xy0。接下来,我们想要将一个标签拖到图像视图下方。一旦放置好,我们想要配置单元格内的放置规则。

首先,选择图像视图。我们必须使其全宽并附加到单元格的顶部,因此再次选择固定图标并按以下方式配置:

配置用户界面

它被固定在左侧、顶部和右侧,没有约束到边距,所有三个测量值都为零。点击添加 3 个约束,我们就可以为标签定义规则了。我们希望标签能够全宽并且垂直居中。标签会自动居中文本,因此我们希望标签足够高,以便在文本上下方有合理的边距。点击标签并按照以下方式配置它:

配置用户界面

它在所有方向上都被固定,没有约束到边距,所有测量值都为零。它还通过勾选高度复选框被约束为 30 点高。点击添加 5 个约束,然后从左侧菜单让 Xcode 再次为您调整大小。同时,确保在属性检查器中选择居中对齐,并将字体大小减少到12

运行应用

现在我们已经配置了大部分界面,而没有写任何代码。我们可以运行应用来查看它的样子。为此,首先从顶部栏的菜单中选择您想要运行它的模拟器。然后您可以点击运行按钮,即带有黑色三角形的按钮。这将打开一个新的模拟器窗口,运行您的应用:

运行应用

您可以通过硬件菜单旋转虚拟设备,以查看旋转时的效果,并且可以在不同的模拟器上尝试运行它。我们迄今为止已经配置了视图,使其能够适应任何屏幕尺寸。

允许拍照

现在我们准备进入编程环节。首先我们需要允许用户进行拍照操作。为了实现这一点,我们需要编写一些代码,每次用户点击添加按钮时都会执行这些代码。我们通过将添加按钮的触发动作连接到视图控制器上的一个方法来实现这一点。通常,我们通过右键拖动从按钮到代码来建立连接;然而,如果我们不能同时看到界面和代码,我们就无法这样做。最简单的方法是显示辅助编辑器。您可以通过导航到视图 | 辅助编辑器 | 显示辅助编辑器来做到这一点。同时,确保它被配置为自动,通过点击编辑器顶部的栏位:

允许拍照

此模式会导致第二个视图自动更改为最合适的文件,根据您在左侧选择的文件。在这种情况下,因为我们正在处理视图控制器的界面,所以它显示的是视图控制器的代码。

我们的视图控制器代码最初生成了两个方法。viewDidLoad在视图控制器视图加载时被调用。大多数时候,这发生在视图控制器即将第一次显示时。didReceiveMemoryWarning在系统开始运行内存不足时被调用。这为你提供了一个机会,通过删除任何不必要的项目来帮助系统找到更多内存。

我们想从按钮到一个新方法创建一个连接。你可以通过在添加按钮上右键单击并拖动到didReceiveMemoryWarning方法下方来完成此操作:

允许拍照

当你释放鼠标右键时,会出现一个小窗口。在那里你应该从连接菜单中选择操作,并输入didTapTakePhotoButton。当你点击连接时,Xcode 会为你创建一个新的方法并将其连接到按钮。你知道它已经连接,因为方法左侧有一个填充的灰色圆圈。现在,每次用户点击按钮时,这个方法都会被执行。请注意,这个方法在其开头有@IBAction。这是连接到界面元素的任何方法都需要的东西。

我们希望这个方法向用户提供一个拍照的界面。苹果为我们提供了一个名为UIImagePickerController的类,这使得这非常简单。我们只需要创建一个UIImagePickerController实例,将其配置为允许拍照,并将其呈现到屏幕上。代码看起来是这样的:

@IBAction func didTapTakePhotoButton(sender: AnyObject) {
    let imagePicker = UIImagePickerController()
    if UIImagePickerController.isSourceTypeAvailable(.Camera) {
        imagePicker.sourceType = .Camera
    }
    self.presentViewController(
        imagePicker,
        animated: true,
        completion: nil
    )
}

让我们分解这段代码。在第一行,我们创建了一个图片选择器。在第二行,我们通过使用UIImagePickerControllerisSourceTypeAvailable:类方法来检查当前设备是否有摄像头。如果摄像头源可用,我们在第三行将其设置为图片选择器的源类型。否则,默认情况下,图片选择器允许用户从他们的照片库中选择图片。由于模拟器不支持拍照,所以在模拟应用时,你会看到一个图片选择器而不是摄像头。最后,最后一行要求我们的视图控制器通过在屏幕上动画显示来呈现我们的图片选择器。presentViewController:animated:completion:UIViewController类实现的一个方法,它是我们的ViewController的父类,这使得我们能够轻松地呈现新的视图控制器。如果你运行应用并点击添加按钮,你会被要求允许访问照片,然后它会显示图片选择器。你可以点击右上角的取消按钮,图片选择器控制器将被关闭。然而,如果你选择了一张照片,什么也不会发生。

我们需要编写一些代码来处理照片的选择。为了使这成为可能,图像选择器可以有一个代理,当选择图像时接收方法调用。我们将使我们的视图控制器成为图像选择器的代理并实现其协议。首先,我们必须在我们的操作方法中添加一行,将我们的视图控制器分配为图像选择器的代理。在调用显示图像选择器之前添加此行:

imagePicker.delegate = self

当我们这样做时,我们会得到一个编译器错误,说我们无法进行这个赋值,因为我们的视图控制器没有实现必要的协议。让我们来改变一下。我喜欢在每个文件中实现每个协议作为一个单独的扩展,以便更好地分离代码。我们需要根据错误实现UIImagePickerControllerDelegateUINavigationControllerDelegate。在这两个协议中,对我们来说唯一重要的方法是当选择图像时被调用的方法。这让我们有了以下代码:

extension ViewController: UINavigationControllerDelegate {}

extension ViewController: UIImagePickerControllerDelegate {
    func imagePickerController(
        picker: UIImagePickerController,
        didFinishPickingImage image: UIImage!,
        editingInfo: [NSObject : AnyObject]!
        )
    {
        self.dismissViewControllerAnimated(true, completion: nil)
    }
}

我们对UINavigationControllerDelegate代理的实现是空的,但我们有一个简单的imagePickerController:picker:didFinishPickingImage:editingInfo:方法的实现。这就是我们将添加我们的处理代码的地方,但现在,我们只是关闭显示的视图控制器,将用户返回到上一个屏幕。这个方法并不强迫我们指定我们要关闭的视图控制器,因为视图控制器已经知道它正在显示哪个视图控制器。现在,如果你运行应用程序并选择一张照片,你将返回到上一个屏幕,但不会发生其他任何事情。为了使照片发生有意义的事情,我们必须放置大量的其他代码。我们必须保存图片并实现我们的视图控制器,以便在集合视图中显示图片。

临时保存照片

首先,我们只关心在内存中临时存储我们的图片。为此,我们可以将一个图像数组添加为视图控制器的一个属性:

class ViewController: UIViewController {

    var photos = [UIImage]()

    // ...
}

正如我们在图像选择器代理方法中看到的,UIKit 提供了一个可以表示图像的类UIImage。我们的photos属性可以存储这些实例的数组。这意味着当我们收到回调时,我们的第一步是向我们的属性添加新的图像:

    func imagePickerController(
        picker: UIImagePickerController,
        didFinishPickingImage image: UIImage!,
        editingInfo: [NSObject : AnyObject]!
        )
    {
        self.photos.append(image)
        self.dismissViewControllerAnimated(true, completion: nil)
    }

现在每次用户拍摄或选择一张新照片,我们都会将其添加到我们的列表中,该列表存储所有图像在内存中。然而,这还不够,我们还想为每张照片要求一个标签。

为了支持这个功能,让我们创建一个新的结构体叫做 Photo,它包含一个图像和标签属性。在这个阶段,我会通过在 LearningCamera 文件夹上右键点击并选择 New Group 来创建三个组:Model、View 和 Controller。我会将 ViewController.swift 移动到 Controller 组中,然后通过在 Model 组上右键点击并选择 New File… 来创建一个新的 Photo.swift 文件。一个简单的 Swift File 就可以了。

你应该在那个文件中定义你的照片结构:

import UIKit

struct Photo {
    let image: UIImage
    let label: String
}

我们必须导入 UIKit,因为它是定义 UIImage 的地方。我们的结构体的其余部分都很直接,因为它只定义了我们想要的两个属性。默认初始化器现在就足够了。

现在,我们可以回到我们的 ViewController.swift 文件,并将我们的 photos 属性更新为 Photo 类型而不是 UIImage

var images = [Photo]()

这现在给我们带来了一个新的问题。我们如何请求用户为图像提供标签?让我们在一个标准的提示框中这样做。为了显示提示框,UIKit 有一个名为 UIAlertController 的类。为了使用它,我们可能需要重新设计我们的函数。UIKit 不允许你从同一个视图控制器在同一时间显示多个视图控制器。这意味着我们必须先关闭照片选择器,等待其完成后再显示我们的提示框:

self.dismissViewControllerAnimated(true) {
    // Ask User for Label

    let alertController = UIAlertController(
        title: "Photo Label",
        message: "How would you like to label your photo?",
        preferredStyle: .Alert
    )

    alertController.addTextFieldWithConfigurationHandler()
    {
        textField in
        let saveAction = UIAlertAction(
            title: "Save",
            style: .Default
            ) { action in
            let label = textField.text ?? ""
            let photo = Photo(image: image, label: label)
            self.photos.append(photo)
        }
        alertController.addAction(saveAction)
    }

    self.presentViewController(
        alertController,
        animated: true,
        completion: nil
     )
}

让我们分解这段代码,因为它有些复杂。首先,我们使用了尾随闭包语法来调用 dismissViewControllerAnimated:completion: 方法。这个闭包在视图控制器完成动画离开屏幕后被调用。

接下来,我们创建了一个带有标题、消息和 Alert 样式的提示框控制器。在我们能够显示提示框之前,我们必须使用一个文本框和一个保存操作来配置它。我们首先添加文本框,并在 addTextFieldWithConfigurationHandler: 上再次使用尾随闭包。这个闭包被调用以给我们机会配置文本框。我们接受默认设置,但我们将想要知道在保存时文本框中的文本,这样我们就可以在这个提示框中直接创建保存操作,从而避免以后获取其引用的麻烦。

提示框的每个操作都必须是 UIAlertAction 类型。在这种情况下,我们创建了一个标题为 Save 并具有默认样式的操作。UIAlertAction 构造函数的最后一个参数是一个闭包,当用户选择该操作时会被调用。同样,我们使用了尾随闭包语法。

在那个回调内部,我们从文本框中获取文本,并使用它以及我们的图像来创建一个新的 Photo 实例,并将其添加到我们的 photos 数组中。

最后,我们必须将我们的保存操作添加到提示框控制器中,然后显示提示框控制器。

现在如果你运行应用程序,它会在选择照片后要求你为每个照片提供一个标签,但它看起来仍然没有显示出来,因为我们还没有显示保存的照片。这是我们下一个任务。

填充我们的照片网格

现在我们正在维护一个照片列表,我们需要在集合视图中显示它。集合视图通过提供实现其 UICollectionViewDataSource 协议的数据源来填充。最常见的事情可能是让视图控制器成为数据源。我们可以通过重新打开 Main.storyboard控制 拖动从集合视图到视图控制器来实现这一点:

填充我们的照片网格

当你松开鼠标时,从菜单中选择 dataSource。之后,我们只需要实现数据源协议。我们需要实现的两个方法是 collectionView:numberOfItemsInSection:collectionView:cellForItemAtIndexPath:。前者允许我们指定应该显示多少个单元格,后者允许我们为列表中的特定索引自定义每个单元格。我们很容易返回我们想要的单元格数量:

extension ViewController: UICollectionViewDataSource {
    func collectionView(
        collectionView: UICollectionView,
        numberOfItemsInSection section: Int
        ) -> Int
    {
        return self.photos.count
    }
}

我们只需要返回我们 photos 属性中的元素数量。

配置单元格需要更多的准备工作。首先,我们需要创建自己的单元格子类,以便可以引用在故事板中创建的图像和标签。所有集合视图单元格都必须是 UICollectionViewCell 的子类。让我们称它为 PhotoCollectionViewCell 并在 View 组中为它创建一个新文件。就像我们需要从故事板到我们的代码中建立点击添加按钮的连接一样,我们还需要为图像和标签建立连接。然而,这是一种不同类型的连接。这种类型的连接不是动作,而被称为输出,它将对象作为属性添加到视图控制器中。我们可以使用与动作相同的点击和拖动技术,但这次我们将自己提前设置代码:

import UIKit

class PhotoCollectionViewCell: UICollectionViewCell {
    @IBOutlet var imageView: UIImageView!
    @IBOutlet var label: UILabel!
}

在这里,我们指定了两个属性,每个属性都有一个前缀 @IBOutlet。这个前缀允许我们在 Interface Builder 中建立连接,就像我们处理数据源时做的那样。这两种类型都被定义为隐式解包的可选类型,因为这些连接在实例初始化时无法设置。相反,它们在加载视图时建立连接。

现在我们已经设置了环境,我们可以回到故事板并建立连接。目前这个单元格仍然只是一个通用单元格的类型,所以首先我们需要将其更改为我们的类。在左侧视图层次结构中找到单元格,并点击它。选择视图 | 实用工具 | 显示识别检查器。在这个检查器中,我们可以在类字段中输入PhotoCollectionViewCell来设置单元格的类。现在,如果你导航到视图 | 实用工具 | 显示连接检查器,你会看到我们的两个输出列在可能连接中列出。点击并从imageView旁边的空心灰色圆圈拖动到单元格中的图像视图:

填充我们的照片网格

一旦你松开鼠标,连接就会建立。用同样的方法对之前创建的标签连接进行操作。我们还需要为我们的单元格设置一个重用标识符,这样我们就可以在代码中引用这个模板。你可以通过返回属性检查器并在标识符文本字段中输入DefaultCell来完成此操作:

填充我们的照片网格

我们还需要在视图控制器内部获取集合视图的引用。这是因为每次保存照片时,我们都需要要求集合视图添加一个单元格。你可以通过编写代码或通过右键单击并从集合视图拖动到代码来实现这一点。无论如何,你应该在视图控制器上得到一个类似这样的属性:

class ViewController: UIViewController {
    @IBOutlet var collectionView: UICollectionView!

    // ... 
}

然后,我们就准备好实现剩余的数据源方法:

extension ViewController: UICollectionViewDataSource {
    // ...

    func collectionView(
        collectionView: UICollectionView,
        cellForItemAtIndexPath indexPath: NSIndexPath
        ) -> UICollectionViewCell
    {
        let cell = collectionView
            .dequeueReusableCellWithReuseIdentifier(
            "DefaultCell",
            forIndexPath: indexPath
            ) as! PhotoCollectionViewCell

        let photo = self.photos[indexPath.item]
        cell.imageView.image = photo.image
        cell.label.text = photo.label

        return cell
    }
}

这个实现的第 一行要求集合视图提供一个带有我们的DefaultCell标识符的单元格。为了完全理解这一点,我们需要稍微了解一些集合视图的工作原理。集合视图被设计成可以处理几乎任何数量的单元格。我们可能想要一次性显示成千上万的单元格,但一次在内存中拥有成千上万的单元格是不可能的。相反,集合视图会自动重用已经滚动出屏幕的单元格以节省内存。我们无法知道从这个调用中获取的单元格是新的还是重用的,所以我们必须始终假设它正在被重用。这意味着在这个方法中对单元格所做的任何配置,都必须在每次调用时重置,否则,一些旧配置可能仍然存在。我们通过将结果转换为我们的PhotoCollectionViewCell类来结束这个调用,这样我们就可以正确地配置我们的子视图。

我们的第二行是从我们的列表中获取正确的照片。indexPath变量上的item属性是我们用来配置单元格的照片的索引。任何时候,都可以用零到我们之前数据源方法返回的数字之间的任何索引调用此方法。这意味着在我们的情况下,它将始终是photos数组中的数字,因此可以安全地假设索引在其范围内。

接下来的两行根据照片设置图像和标签,最后,最后一行返回该单元格,以便集合视图可以显示它。

在这一点上,如果你运行了应用程序并添加了一张照片,你仍然什么也看不到,因为当向照片数组添加元素时,集合视图不会自动重新加载数据。这是因为collectionView:numberOfItemsInSection:方法是一个回调。回调只有在其他代码启动它时才会被调用。此方法在集合视图首次加载时调用一次,但我们必须手动请求它再次调用。最简单的方法是在我们向列表添加照片时在集合视图上调用reloadData。这会导致所有数据和单元格再次加载。然而,这看起来并不好,因为单元格会突然出现。相反,我们希望使用insertItemsAtIndexPaths方法。当正确使用时,这将导致单元格以动画方式出现在屏幕上。使用此方法时需要记住的重要事情是,你必须在collectionView:numberOfItemsInSection:返回插入后的更新数量之后调用它。这意味着我们必须在我们已经将照片添加到我们的属性之后调用它:

let saveAction = UIAlertAction(
    title: "Save",
    style: .Default
    ) { action in
    let label = textField.text ?? ""
    let photo = Photo(image: image, label: label)
    self.photos.append(photo)

    let indexPath = NSIndexPath(
        forItem: self.photos.count - 1,
        inSection: 0
    )
    self.collectionView.insertItemsAtIndexPaths([indexPath])
}

这里的最后两行是新的。首先,我们为要插入新项的位置创建一个索引路径。索引路径由项和部分组成。我们所有的项都存在于单个部分中,因此我们可以将其始终设置为零。我们希望项的总数减一,因为我们刚刚将其添加到列表的末尾。最后一行只是调用接受索引路径数组作为参数的插入项方法。

现在,你可以运行你的应用程序,所有保存的照片都将显示在集合视图中。

重构以尊重模型-视图-控制器

我们已经在我们的应用程序的核心功能上取得了一些进展。然而,在我们继续前进之前,我们应该反思我们所编写的代码。最终,我们实际上并没有编写很多代码行,但它肯定可以改进。我们代码的最大缺点是我们将大量业务逻辑放在了视图控制器中。这不是我们不同模型、视图和控制器层之间的良好分离。让我们利用这个机会将此代码重构为单独的类型。

我们将创建一个名为 PhotoStore 的类,该类将负责存储我们的照片,并将实现数据源协议。这意味着将一些代码从我们的视图控制器中移出。

首先,我们将照片的属性移动到照片存储类中:

import UIKit

class PhotoStore: NSObject {
    var photos = [Photo]()
}

注意,这个新的照片存储类继承自 NSObject。这对于我们能够完全满足 UICollectionViewDataSource 协议是必要的,这是我们下一个任务。

我们可以将代码从我们的视图控制器简单地移动到这个类中,但我们不希望我们的模型直接处理我们的视图层。当前的实现创建并配置我们的集合视图单元格。让我们允许视图控制器仍然处理它,通过提供我们自己的回调,当我们需要给定照片的单元格时。为此,我们首先需要添加一个回调属性:

class PhotoStore: NSObject {
    var photos = [Photo]()
    var cellForPhoto:
        (Photo, NSIndexPath) -> UICollectionViewCell

    init(
        cellForPhoto: (Photo,NSIndexPath) -> UICollectionViewCell
        )
    {
        self.cellForPhoto = cellForPhoto

        super.init()
    }
}

我们现在需要提供一个初始化器,这样我们就可以获取回调函数。接下来,我们必须调整我们的数据源实现,并将它们放入这个新类中:

extension PhotoStore: UICollectionViewDataSource {
    func collectionView(
        collectionView: UICollectionView,
        numberOfItemsInSection section: Int
        ) -> Int
    {
        return self.photos.count
    }

    func collectionView(
        collectionView: UICollectionView,
        cellForItemAtIndexPath indexPath: NSIndexPath
        ) -> UICollectionViewCell
    {
        let photo = self.photos[indexPath.item]
        return self.cellForPhoto(photo, indexPath)
    }
}

collectionView:numberOfItemsInSection: 方法仍然可以只返回我们数组中的照片数量,但 collectionView:cellForItemAtIndexPath: 是通过回调来实现的,而不是创建一个单元格本身。

我们需要添加到这个类的第二件事是保存照片的能力。让我们添加一个方法来接受一个新的图像和标签,并返回应该添加的索引路径:

func saveNewPhotoWithImage(
    image: UIImage,
    labeled label: String
    ) -> NSIndexPath
{
    let photo = Photo(image: image, label: label)
    self.photos.append(photo)
    return NSIndexPath(
       forItem: self.photos.count - 1,
       inSection: 0
    )
}

这看起来与我们在视图控制器中编写的代码相同,但分离得更好。

现在我们的照片存储已经完成,我们只需更新我们的视图控制器以使用它而不是旧的实现。首先,让我们在 ViewController 中添加一个照片存储属性,它是一个隐式解包的可选值,这样我们就可以在视图加载后创建它:

    var photoStore: PhotoStore!

要在 viewDidLoad 中创建我们的照片存储,我们将调用照片存储初始化器,并传递一个可以创建单元格的闭包。为了清晰起见,我们将定义这个闭包为一个单独的方法:

func createCellForPhoto(
    photo: Photo,
    indexPath: NSIndexPath
    ) -> UICollectionViewCell
{
    let cell = self.collectionView
        .dequeueReusableCellWithReuseIdentifier(
        "DefaultCell",
        forIndexPath: indexPath
        ) as! PhotoCollectionViewCell

    cell.imageView.image = photo.image
    cell.label.text = photo.label

    return cell
}

这个方法看起来几乎与我们的旧 collectionView:cellForItemAtIndexPath: 实现相同;唯一的区别是我们已经有一个对正确照片的引用。

此方法允许我们的 viewDidLoad 实现非常简单。我们所需做的只是用对这个方法的引用初始化照片存储,并使其成为集合视图的数据源:

override func viewDidLoad() {
    super.viewDidLoad()
    self.photoStore = PhotoStore(
        cellForPhoto: self.createCellForPhoto
    )
    self.collectionView.dataSource = self.photoStore
}

最后,我们只需更新保存操作以使用照片存储:

let saveAction = UIAlertAction(
    title: "Save",
    style: .Default
    ) { action in
    let label = textField.text ?? ""
    let indexPath = self.photoStore.saveNewPhotoWithImage(
        image,
        labeled: label
    )
    self.collectionView.insertItemsAtIndexPaths([indexPath])
}

你可以再次运行应用程序,它将像以前一样操作,但现在我们的代码是模块化的,这将使未来的任何更改都更容易。

永久保存照片

我们的应用程序在保存图片方面工作得相当不错,但一旦应用程序退出,所有照片都会丢失。我们需要添加一种永久保存照片的方法。我们对代码的重构使我们现在主要在模型层工作。

在编写任何代码之前,我们必须决定我们将如何永久存储照片。我们可以选择多种方式来保存照片,但其中一种最简单的方式是将它们保存到文件系统中,这是我们构思阶段所设想的方式。每个应用程序都提供了一个文档目录,作为正常备份的一部分,操作系统会自动备份。我们可以将照片存储在那里,以用户提供的标签命名的文件命名。为了避免任何与重复标签相关的问题,即我们会有多份同名文件,我们可以将每个文件嵌套在以保存照片的时间命名的子目录中。时间戳总是唯一的,因为我们永远不会在完全相同的时间保存两张照片。

现在我们已经决定了这一点,我们可以开始更新我们的照片存储代码。首先,我们希望有一个简单的方法来使用一致的目录进行保存。我们可以通过添加一个名为 getSaveDirectory 的方法来实现这一点。这个方法可以是私有的,并且按照惯例,我喜欢将私有代码分组在私有扩展中:

private extension PhotoStore {
    func getSaveDirectory() throws -> NSURL {
        let fileManager = NSFileManager.defaultManager()
        return try fileManager.URLForDirectory(
            .DocumentDirectory,
            inDomain: .UserDomainMask,
            appropriateForURL: nil,
            create: true
        )
    }
}

这段代码首先从名为 NSFileManager 的苹果提供类中获取表示文档目录的 URL。你可能注意到 NSFileManager 有一个共享实例,可以通过 defaultManager 类方法访问。然后我们调用 URLForDirectory 方法,给它提供信息表明我们想要当前用户的文档目录,并返回结果。请注意,这个方法可能会抛出错误,所以我们标记了自己的方法为抛出错误,并且不允许任何错误传播。

现在我们可以继续将所有添加的图片保存到磁盘上。我们需要完成的事情有很多。首先,我们需要获取当前的时间戳。我们可以通过创建一个 NSDate 实例,请求时间戳,并使用字符串插值将其转换为字符串来完成:

let timeStamp = "\(NSDate().timeIntervalSince1970)"

NSDate 实例可以代表任何日期上的任何时间。默认情况下,所有 NSDate 实例都是创建来表示当前时间的。

接下来,我们想要将这个路径附加到我们的保存目录上,以获取我们将要保存文件的路径。为此,我们可以使用 NSURLURLByAppendingPathComponent: 方法:

let fullDirectory = directory.URLByAppendingPathComponent(
    timestamp
    )

这将确保如果还没有添加,正确的路径斜杠会被添加。现在我们需要确保在尝试将文件保存到该目录之前,这个目录已经存在。这是通过 NSFileManager 上的一个方法来完成的:

NSFileManager.defaultManager().createDirectoryAtURL(
    fullDirectory,
    withIntermediateDirectories: true,
    attributes: nil
)

如果有错误,这个方法可能会抛出异常,我们稍后会需要处理它。如果目录已经存在,这仍然被视为成功。一旦我们确信目录已经创建,我们就会想要创建一个指向特定文件的路径,使用标签文本:

let fileName = "\(self.label).jpg"
let filePath = fullDirectory
    .URLByAppendingPathComponent(fileName)

在这里,我们使用了字符串插值来给文件名添加 .jpg 扩展名。

最重要的是,我们需要将我们的图像转换为可以保存到文件中的数据。为此,UIKit 提供了一个名为UIImageJPEGRepresentation的函数,它接受UIImage并返回一个NSData实例:

let data = UIImageJPEGRepresentation(self.image, 1)

第二个参数是一个介于零和一之间的值,表示我们想要的压缩质量。在这种情况下,我们希望以全质量保存文件,所以我们使用 1。然后它返回一个可选数据实例,因此我们需要处理它返回 nil 的情况。

最后,我们需要将数据保存到我们创建的文件路径:

data.writeToURL(filePath, atomically: true)

NSData上的这种方法简单来说就是接收一个文件路径和一个布尔值,表示我们是否希望在覆盖任何现有文件之前将其写入临时位置。它还会根据是否成功返回truefalse。与目录创建不同,如果文件已存在,这将失败。然而,由于我们使用的是当前时间戳,这应该永远不会成为问题。

让我们将所有这些逻辑组合到我们的照片结构上的一个方法中,我们可以稍后使用它将其保存到磁盘,如果发生错误则抛出错误:

struct Photo {
    // ...

    enum Error: String, ErrorType {
        case CouldntGetImageData = "Couldn't get data from image"
        case CouldntWriteImageData = "Couldn't write image data"
    }

    func saveToDirectory(directory: NSURL) throws {
        let timeStamp = "\(NSDate().timeIntervalSince1970)"
        let fullDirectory = directory
            .URLByAppendingPathComponent(timeStamp)
        try NSFileManager.defaultManager().createDirectoryAtURL(
            fullDirectory,
            withIntermediateDirectories: true,
            attributes: nil
        )
        let fileName = "\(self.label).jpg"
        let filePath = fullDirectory
            .URLByAppendingPathComponent(fileName)
        if let data = UIImageJPEGRepresentation(self.image, 1) {
            if !data.writeToURL(filePath, atomically: true) {
                throw Error.CouldntWriteImageData
            }
        }
        else {
            throw Error.CouldntGetImageData
        }
    }
}

首先,我们定义一个嵌套枚举来表示我们的可能错误。然后我们定义一个方法来接受应该保存的根级目录。我们允许目录创建的任何错误传播。如果数据返回 nil 或writeToURL:automatically:方法失败,我们还需要抛出我们的错误。

现在,我们需要更新我们的saveNewPhotoWithImage:labeled:方法,以使用saveToDirectory:方法。最终,如果在保存照片时抛出错误,我们希望向用户显示一些内容。这意味着这个方法只需要传播错误,因为模型不应该向用户显示内容。这导致以下代码:

func saveNewPhotoWithImage(
    image: UIImage,
    labeled label: String
    ) throws -> NSIndexPath
{
    let photo = Photo(image: image, label: label)
    try photo.saveToDirectory(self.getSaveDirectory())
    self.photos.append(photo)
    return NSIndexPath(
        forItem: self.photos.count - 1,
        inSection: 0
    )
}

如果保存到目录失败,我们将跳过方法的其余部分,这样我们就不会将其添加到我们的照片列表中。这意味着我们需要更新调用它的视图控制器代码以处理错误。首先,让我们添加一个方法,使其能够轻松地显示一个带有给定标题和消息的错误:

func displayErrorWithTitle(title: String?, message: String) {
    let alert = UIAlertController(
        title: title,
        message: message,
        preferredStyle: .Alert
    )
    alert.addAction(UIAlertAction(
        title: "OK",
        style: .Default,
        handler: nil
    ))
    self.presentViewController(
        alert,
        animated: true,
        completion: nil
    )
}

这个方法很简单。它只是创建一个带有 OK 按钮的警报,然后显示它。接下来,我们可以添加一个函数来显示我们预期会遇到的任何类型的错误。它将接受一个弹出警报的标题,这样我们就可以为产生它的场景定制显示的错误:

func displayError(error: ErrorType, withTitle: String) {
    switch error {
    case let error as NSError:
        self.displayErrorWithTitle(
            title,
            message: error.localizedDescription
        )
    case let error as Photo.Error:
        self.displayErrorWithTitle(
            title,
            message: error.rawValue
        )
    default:
        self.displayErrorWithTitle(
            title,
            message: "Unknown Error"
        )
    }
}

我们期望的是来自 Apple API 的内置错误类型NSError,或者我们在我们的照片类型中定义的错误类型。Apple 错误的本地化描述属性仅创建设备当前配置的区域的描述。我们还通过仅将其报告为未知错误来处理任何其他错误场景。

我还会将我们的保存操作创建提取到一个单独的方法中,这样当我们添加 do-catch 块时,就不会使事情过于复杂。这将会非常类似于我们之前的代码,但我们将在 saveNewPhotoWithImage:labeled: 的调用周围包裹一个 do-catch 块,并在抛出的任何错误上调用我们的错误处理方法:

func createSaveActionWithTextField(
    textField: UITextField,
    andImage image: UIImage
    ) -> UIAlertAction
{
    return UIAlertAction(
        title: "Save",
        style: .Default
        ) { action in
        do {
            let indexPath = try self.photoStore
               .saveNewPhotoWithImage(
                image,
                labeled: textField.text ?? ""
                )
            self.collectionView.insertItemsAtIndexPaths([indexPath]
            )
        }
        catch let error {
            self.displayError(
                error,
                withTitle: "Error Saving Photo"
            )
        }
    }
}

这就剩下我们需要更新 imagePickerController:didFinishPickingImage:editingInfo: 方法,以便使用我们新的保存操作创建方法:

// ..

alertController.addTextFieldWithConfigurationHandler()
{
    textField in
    let saveAction = self.createSaveActionWithTextField(
        textField,
        andImage: image
    )
    alertController.addAction(saveAction)
}

// ..

这完成了永久存储我们照片的第一部分。我们现在正在将图像保存到磁盘,但如果我们从磁盘上根本不加载它们,那就毫无意义。

要从磁盘加载图像,我们可以使用 UIImagecontentsOfFile: 初始化器,它返回一个可选的图像:

let image = UIImage(contentsOfFile: filePath.relativePath!)

要将我们的文件路径 URL 转换为字符串,这是初始化器所要求的,我们可以使用相对路径属性。

我们可以通过删除文件扩展名并获取路径的最后一个组件来获取照片的标签:

let label = filePath.URLByDeletingPathExtension?
    .lastPathComponent ?? ""

现在,我们可以将这个逻辑组合到我们的 Photo 结构体的初始化器中。为此,我们还需要创建一个简单的初始化器,它接受图像和标签,这样我们的其他代码在使用默认初始化器时仍然可以正常工作:

init(image: UIImage, label: String) {
    self.image = image
    self.label = label
}

init?(filePath: NSURL) {
    if let image = UIImage(
        contentsOfFile: filePath.relativePath!
        )
    {
        let label = filePath.URLByDeletingPathExtension?
            .lastPathComponent ?? ""
        self.init(image: image, label: label)
    }
    else {
        return nil
    }
}

最后,我们需要让图片存储库遍历文档目录中的文件,并对每个文件调用此初始化器。要遍历一个目录,NSFileManager 有一个 enumeratorAtFilePath: 方法。它返回一个具有 nextObject 方法的枚举器实例。每次调用它时,它都会返回原始目录内的下一个文件或目录。请注意,这将遍历它找到的每个子目录的所有子项。这是我们在第九章中看到的迭代器模式的一个很好的例子,以 Swift 方式编写代码 – 设计模式和技术。我们可以使用 fileAttributes 属性来确定当前对象是否是文件。所有这些都让我们能够编写一个像这样的 loadPhotos 方法:

func loadPhotos() throws {
    self.photos.removeAll(keepCapacity: true)

    let fileManager = NSFileManager.defaultManager()
    let saveDirectory = try self.getSaveDirectory()
    let enumerator = fileManager.enumeratorAtPath(
        saveDirectory.relativePath!
    )
    while let file = enumerator?.nextObject() as? String {
        let fileType = enumerator!.fileAttributes![NSFileType]
            as! String
        if fileType == NSFileTypeRegular {
            let fullPath = saveDirectory
                .URLByAppendingPathComponent(file)
            if let photo = Photo(filePath: fullPath) {
                self.photos.append(photo)
            }
        }
    }
}

在这个方法中,我们首先删除所有现有的照片。这是为了防止在照片已经存在时调用此方法。接下来,我们从保存目录创建一个枚举器。然后,我们使用一个 while 循环来继续获取每个下一个对象,直到没有剩余的对象。在循环内部,我们检查我们刚刚获取的对象是否实际上是一个文件。如果是,并且我们使用完整路径成功创建了照片,我们就将照片添加到我们的照片数组中。

最后,我们只需确保在适当的时间调用此方法来加载照片。考虑到我们希望能够在显示错误给用户之前完成这一操作,这是一个很好的时机。由于视图控制器有一个在视图加载后立即调用的方法,因此还有一个名为 viewWillAppear: 的方法,每次视图即将出现时都会被调用。在这里,我们可以加载照片,并使用我们的 displayError:withTitle: 方法向用户显示任何错误:

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)

    do {
        try self.photoStore.loadPhotos()
        self.collectionView.reloadData()
    }
    catch let error {
        self.displayError(
            error,
            withTitle: "Error Loading Photos"
        )
    }
}

现在如果你运行应用程序,保存一些照片,然后退出,当你再次运行它时,之前保存的照片将仍然存在。我们已经完成了保存照片的功能!

摘要

这个应用程序远远不是我们可以上架的那种,但它让你对构建 iOS 应用程序的感觉有了很好的初步了解。我们已经介绍了如何构思一个应用程序,然后如何将其变为现实。我们知道如何在故事板中配置界面,如何运行它,我们还深入探讨了将照片临时和永久保存到磁盘以及在我们自己的自定义界面中显示这些照片的实用细节。我们甚至通过确保我们的代码尽可能遵循模型-视图-控制器设计模式来练习编写高质量代码。

尽管我们已经覆盖了很多内容,但这显然不足以立即编写任何其他 iOS 应用程序。关键是要了解应用程序开发过程是什么样的,并开始在一个 iOS 应用程序项目中感到更加自在。所有开发者都会花费大量时间在文档和互联网上搜索如何在任何特定平台上完成特定任务。关键在于能够从互联网或书籍中找到解决方案,确定最适合你用例的最佳方案,并将它们有效地集成到自己的代码中。随着时间的推移,你将能够独立完成更多任务,而无需查阅资料,但随着框架和平台的不断变化,这始终将是你的开发周期的一部分。

考虑到这一点,我现在挑战你完成我们构思的功能列表。找出如何删除图片,并添加你想要的任何其他功能、可用性调整或视觉调整。正如我之前所说,应用程序开发是一个全新的世界去探索。即使在这个简单的应用程序中,你也可以调整很多东西;所有这些都将帮助你学习很多。

在我们的最后一章中,我们将探讨你可以从这里开始,成为你所能成为的最佳 Swift 开发者。

第十二章。接下来是什么? – 资源、建议和下一步

到目前为止,我们在书中已经涵盖了大量的内容。Swift 不是一个小的主题,而应用开发本身比这大得多。我们学习了 Swift 的大部分内容,但涵盖语言的所有小特性并不实用,而且 Swift 仍然是一个新且不断发展的语言。您永远不可能在不参考的情况下记住您所学的一切。您始终可以参考这本书,但苹果的文档也可以作为一个很好的参考。除此之外,如果您真正想成为一名熟练的 Swift 开发者,您可以通过始终学习和进步来确保您的成功。在没有外界帮助的情况下做到这一点是非常困难的。确保您跟上时代的最佳方式是关注并参与您最感兴趣的任何主题的社区。在本章中,我们将介绍如何使用苹果的文档,以及一些关于您可以在哪里找到并参与 Swift、iOS 和 OS X 开发者社区的建议。更具体地说,在本章中,我们将涵盖以下内容:

  • 苹果的文档

  • 论坛和博客

  • 显著人物

  • 播客

苹果的文档

苹果投入了大量的时间和精力来维护其文档。这些文档经常是确定您如何与他们框架交互的非常有价值的工具。

Xcode 实际上与文档集成得相当好。查看文档的主要方法之一是在快速帮助检查器中查看。您可以通过从主菜单导航到视图 | 实用工具 | 显示快速帮助检查器来显示它。此检查器显示您当前光标所在的代码的文档。如果该特定类、方法或函数是苹果框架的一部分,您将获得一些有关它的快速帮助,如下面的截图所示:

苹果的文档

这里光标位于UICollectionView上,因此快速帮助检查器为我们提供了关于它的高级信息。

如果您需要更多信息或想要进行更多探索,也可以在单独的窗口中查看文档。您可以通过导航到帮助 | 文档和 API 参考来打开此窗口,并可以搜索您想要的任何主题。然而,您也可以通过按住选项键并双击它来直接跳转到特定代码的文档。例如,如果您按住选项键并双击isSourceTypeAvailable,您将获得以下完整的文档窗口:

苹果的文档

此窗口的功能与网页非常相似。您可以通过点击任何链接或搜索一个完全不相关的主题来在文档中导航。您还可以使用屏幕左侧的大纲视图跳转到文档页面的特定部分。

当你已经对框架的哪些部分需要用于特定任务有一个大致的了解时,这份文档尤其有用。然后你可以使用这份文档来了解如何正确使用框架的这一部分。随着你对苹果的框架越来越熟悉,这将会变得更加有用,因为记住你用于所有常见任务的框架部分相对容易,但记住它们的确切工作方式则要困难得多,而且往往不切实际。然而,有时文档还不够。下一个你应该寻找答案的地方是网上。

论坛和博客

在编程过程中遇到问题或疑问时,几乎可以肯定的是,其他人已经遇到过类似的问题,而且很可能有人已经某处写下了关于它的内容。在你直接在论坛上提问之前,我强烈建议你先自行搜索。首先,你想要节省社区成员的宝贵时间。如果他们不断地重复回答同样的问题,那么他们投入到真正新问题上的时间就会少得多。其次,你常常会发现,在思考如何搜索答案的过程中,你自己就能找到答案。最后,随着你不断练习,你将变得更加擅长搜索编程相关的问题。与找到自己的答案相比,论坛通常要慢得多,显然时间就是金钱。

大多数时候,当你使用搜索引擎查找问题时,你将找到两种主要的资源类型,它们提供了答案:博客文章和论坛。

博客文章

与书籍类似,博客文章非常适合更大、更高层次的概念。你可能搜索的内容,例如:“永久存储信息的方法”,你可能会找到许多讨论不同方法的博客文章。博客文章在这方面通常更好,因为它们可以讨论不同解决方案的细微差别,而且它们不受限于针对小问题。

博客文章也可以用于解决极其深入和复杂的问题。例如,我们从 32 位处理器迁移到 64 位处理器,有一些重大的和复杂的影响。真正理解潜在问题对于你未来的发展将比找到你当前问题的快速解决方案更有价值;如果你找不到书籍,博客文章是理想的选择,可以给你提供那种理解。

论坛

表单在提供针对特定问题的快速解决方案方面非常出色。最常见的形式可能是 stackoverflow.com/forums.developer.apple.com。在这些网站上,有非常专注的社区成员在回答和提问。苹果开发者论坛甚至有苹果员工回答问题。提出好问题与回答问题一样重要。这些网站不仅作为获取新问题答案的方式,而且作为未来寻找答案的人的活文档。一个结构良好的问题更容易得到回答,也更容易被搜索引擎找到。

Stack Overflow 上有关于如何提出好问题和答案的优秀文档,但一般来说,它们应该具备以下特点:

  • 明确并清晰地说明您所请求的内容。

  • 让其他人容易在自己的系统上重现问题。

  • 通过在提问时尽可能多地投入努力,尊重任何回答者的时间。

最后一点是最重要的。您希望将问题表述得让比您知识更丰富的人能够精确地锁定问题,而不是浪费时间在您自己可以解决的问题上。这通常意味着描述您已经尝试过的所有事情以及您遇到的障碍。您越清楚地表明您已经投入了真正的努力来解决该问题,您从社区那里得到的反响和答案就会越好。我甚至无法计算我在论坛上撰写问题时解决了多少问题。这种类型的解决方案将比别人给出的解决方案更容易记住,并且更持久。

杰出人物

在使用特定语言和/或框架进行编程方面越有经验,就越有可能陷入以相同方式反复解决问题的模式。很可能会发现其他人已经找到了更好的解决同样问题的方法,而且某处有人正在讨论它。即使你自己没有参与其中,你也必须至少观察社区。

跟随社区中的杰出人物是了解社区的最佳方式之一。例如,对于 Swift 来说,关注 Swift 的原始创建者克里斯·拉特纳(Chris Lattner)是一个很好的主意。尽管现在有很多人在开发 Swift,但他曾独自开发了一年多,并且继续在苹果公司的开发者工具部门工作。您可以在 Twitter 上关注他 @clattner_llvm,并且关注他在苹果开发者论坛 devforums.apple.com/people/ChrisLattner 的活动也可能很有用。您可以通过点击 邮件更新 按钮来接收关于他活动相关的邮件。

除了 Chris Lattner 之外,还有很多其他有价值的人值得关注,但只有你自己能决定谁对你有价值。注意你在社区中看到很多名字的人,并找出他们是否有博客、播客或任何其他你可以了解他们观点的地方。

播客

如果你不太熟悉播客,它们是一种非常宝贵的方式,以相对被动的方式跟上几乎所有话题。它们本质上是可以订阅的按需广播节目,你可以随时收听,比如在开车、做家务或锻炼时。这就是为什么它们特别有价值:它们可以把相对无聊的情况变成极好的学习机会。

苹果在 iOS 中内置了播客应用,你可以使用它,或者应用商店中还有许多其他播客应用,我建议你检查一下。这些应用中的大多数都包括发现机制,这使得找到新的播客更容易,许多播客也会谈论他们推荐的播客。

推荐具体的播客比较困难,因为大多数开发播客的持续时间并不长。制作播客需要花费大量的时间和精力,所以很多人只是做了一段时间,然后长时间休息或决定在某段时间后停止。然而,由于播客的按需性质,回过头去听旧的播客集仍然非常有价值。以下是一些很好的播客,可以帮助你开始:

  • 核心直觉:来自知名开发者 Daniel Jalkut 和 Manton Reece 关于一般开发主题的优秀播客。

  • 意外科技播客:由行业大腕包括 Marco Arment(对我而言是一位非常鼓舞人心的开发者)在内的苹果相关科技讨论。

  • 低调播客:一个简洁且内容丰富的播客,总是 30 分钟或更短,但经常包含围绕独立苹果开发的宝贵信息片段。它由 Marco Arment 和 David Smith 主持,另一位鼓舞人心的开发者。

有些播客非常有价值和有趣,你会想听每一集。有些播客非常适合挑选和选择对你来说有趣和相关的集数。无论你做什么,我建议你不要错过这个免费且容易的机会,以跟上开发社区的发展。

摘要

本章的篇幅虽短,但其重要性却十分显著。如果在阅读完这本书后我能给你留下什么,那就是最好的开发者知道如何从我们可利用的众多来源中寻求并找到自己的解决方案。有时这些解决方案就在像这样的书中;有时则存在于文档、博客文章、论坛、播客,甚至是与其他人的对话中。那些不仅能找到这些解决方案,还能将它们整合并真正理解的开发者,将在他们整个职业生涯中变得极其宝贵。然而,如果你在开始时感到不知所措,请不要担心,因为我们都从那里开始。一次专注于一个问题,不要满足于看似可行的解决方案。确保你理解你实施的每一个解决方案,你将很快,甚至可能没有意识到,成为一个极其熟练的开发者。

posted @ 2025-10-24 10:08  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报