普通视图

发现新文章,点击刷新页面。
昨天以前NSHipster

Language Server Protocol

作者 Mattt
2018年11月5日 00:00

上个月,苹果公司 在 Swift.org 论坛上宣布,正在着手为 Swift 和 C 语言支持 Language Server Protocol(语言服务器协议,LSP)。

对于苹果公司而言,为所有 Swift 开发者 —— 包括非苹果平台上的 —— 提供高质量的工具支持非常重要。我们希望与开源社区合作,将精力集中在构建 Xcode 和其他编辑器、其他平台可以共享的公共基础设施上。为实现这一目标,[……],我们决定支持 LSP。

Argyrios Kyrtzidis,2018 年 10 月 15 日

这可能是苹果自 2014 年将 Swift 作为开源软件发布以来,为 Swift 做出的最重要的决定。 这对于 APP 开发者来说是一件大事,对于其他平台上的 Swift 开发者来说更是一件大事。

为了理解其中的原因,本周的文章将研究 Language Server Protocol 解决了什么问题,它是如何工作的,以及它的长期影响可能是什么。


想象这样一个矩阵,每一行表示不同的编程语言(Swift、JavaScript、Ruby、Python 等),每一列表示不同的代码编辑器(Xcode、Visual Studio、Vim、Atom 等),这样每个单元格表示特定编辑器对一种语言的支持级别。

然后,你就发现各种组合形成了一种支离破碎的兼容。有些编辑器和部分语言深度集成,但除此之外几乎什么都干不了;其他编辑器则比较通用,对很多语言都提供了基本的支持。(IDE 这个术语通常用来描述前者。)

举个奇葩的例子:你不用 Xcode 来开发 APP,却偏用来干其他事情。

为了更好地支持某一特定的语言,编辑器必须编写一些集成代码(integration code)—— 要么直接写在项目里,要么通过插件。由于不同语言和编辑器的实现机制不一样,因此比方说 Vim 改进了对 Ruby 支持,但这并不能让它更好地支持 Python,也不能让 Ruby 在 Atom 上运行地更好。最终的结果是:大量精力浪费在了不同技术的兼容上。

我们上面描述的情况通常被称为 M × N 问题,即最终的集成方案数量为编译器数量 M 与语言数量 N 的乘积。Language Server Protocol 所做的事情就是将 M × N 问题变成 M + N 问题

编辑器不必实现对每种语言的支持,只需支持 LSP 即可。之后,它就能同等程度地支持所有支持 LSP 的语言。

Language Server Protocol 为支持的语言提供了一套通用的功能集,包括:

  • 语法高亮(Syntax Highlighting)
  • 自动格式化(Automatic Formatting)
  • 自动补全(Autocomplete)
  • 语法(Syntax)
  • 工具提示(Tooltips)
  • 内联诊断(Inline Diagnostics)
  • 跳转到定义(Jump to Definition)
  • 项目内查找引用(Find References in Project)
  • 高级文本和符号搜索(Advanced Text and Symbol Search)

各种工具和编辑器可以将精力用于提升可用性和提供更高级的功能,而不是为每种新技术再造个轮子。

Language Server Protocol 的工作原理

如果你是一个 iOS 程序员,那么一定很熟悉 serverprotocol 这两个术语在 Web 应用程序的 HTTP + JSON 通信场景下的含义。实际上 Language Server Protocol 差不多也是这么工作的。

对于 LSP,client 是指编辑器 —— 或者更宽泛一点,是指工具,server 是指本地独立进程里运行的一个外部程序。

至于名字中包含 protocol,是因为 LSP 类似于一个精简版的 HTTP:

  • 每个消息都由报头部分和内容部分组成。
  • 报头部分包含一个必填的 Content-Length 字段,用于说明内容部分的大小(以字节为单位),以及一个可选的 Content-Type 字段(默认值为 application/vscode-jsonrpc; charset=utf-8)。
  • 内容部分使用 JSON-RPC 描述请求、响应和通知的结构。

每当工具中发生了什么事情,比如用户需要跳转到符号的定义,工具就会向 server 发送一个请求。server 接收到该请求,然后返回适当的响应。

例如,假设用户在支持 Language Server Protocol 的类 Xcode 编辑器中打开以下 Swift 代码:

class Parent {}
        class Child: Parent {}
        

当用户按住 后点击第二行继承位置的 Parent,编辑器跳转到第一行定义 Parent 的位置。

以下是 LSP 如何在幕后实现这种交互:

首先,当用户打开 Swift 代码时,若 Swift language server 并未运行,编辑器将在一个独立进程中启动它,并执行一些额外的配置。

当用户执行 “跳转到定义(jump to definition)” 指令时,编辑器向 Swift language server 发送以下请求:

{
        "jsonrpc": "2.0",
        "id": 1,
        "method": "textDocument/definition",
        "params": {
        "textDocument": {
        "uri": "file:///Users/NSHipster/Example.swift"
        },
        "position": {
        "line": 1,
        "character": 13
        }
        }
        }
        

收到这个请求后,Swift language server 使用 SourceKit 等编译器工具来标识相应的代码实体,并在代码的上一行找到其声明的位置。然后 language server 用以下消息进行响应:

{
        "jsonrpc": "2.0",
        "id": 1,
        "result": {
        "uri": "file:///Users/NSHipster/Example.swift",
        "range": {
        "start": {
        "line": 0,
        "character": 6
        },
        "end": {
        "line": 0,
        "character": 12
        }
        }
        }
        }
        

最后,编辑器导航到文件(在本例中,该文件已经打开),将光标移动到该范围,并高亮显示出来。

这种方法的美妙之处在于,编辑器完成所有这些操作时,除了 .swift 文件与 Swift 代码相关以外,对 Swift 编程语言一无所知。编辑器需要做的就是与 language server 对话并更新 UI。而且编辑器知道如何做到这一点后,就可以遵循相同的过程,与任何带有 language server 的语言所编写的代码进行交互。

Clang / LLVM 里的 Language Server Protocol

如果你觉得之前的 M + N 图有点眼熟,那可能是因为 LLVM 也采用了同样的方法。

LLVM 的核心是中间表示(intermediate representation,IR)。LLVM 所支持的语言使用 编译器前端(compiler frontend) 生成 IR,再使用 编译器后端(compiler backend) 将 IR 生成所支持平台的机器码。

Clang 是 C 语言的 LLVM 编译器前端。Swift 与 Objective-C 的互操作性(inter-operability)就是靠它实现的。在最近的 5.0.0 版本中,Clang 添加了一个名为 Clangd 的新工具,它是 LLVM 对 Language Server Protocol 的实现。

2018 年 4 月,苹果公司向 LLVM 邮件组宣布,将把开发的重心从 libclang 转向 Clangd,以其作为创建交互工具的主要方式。

现在你可能会想,“那又怎样?” 苹果公司是 LLVM 项目最重要的支持者之一,该项目创始人 Chris Lattner 已经在苹果公司工作了十多年。苹果公司决定从不透明的 Clang 工具切换到另一个,似乎是一个实现细节了(可以这么说)。

这个官宣很有趣的一点是,Clangd 似乎完全是在苹果以外开发的,谷歌和其他公司也做出了重大贡献。这个官宣标志着未来工具开发方向的重大转变 —— 六个月后 Swift.org 论坛将证实这一点。

苹果支持 Language Server Protocol 的潜在影响

根据苹果公司 10 月份发布的 LSP 公告,我们预计在未来几周内(撰写本文时,最早 11 月中旬)将看到该项目的首批代码。

要感受这些发展的全部影响还需要一些时间,但请相信我:你的耐心是值得的。我相信以下是 LSP 在未来几个月和几年将会发生的一些事情。

Swift 变成一种更加通用的编程语言

虽然 Swift 主要用于 APP 开发,但它从一开始就被设计成一种功能强大的通用编程语言。在 Swift for TensorFlowSwiftNIO 和其他项目中,我们正开始看到 Swift 承诺的在 App Store 之外的使用。

到目前为止,阻碍 Swift 被主流采用的最大因素之一是它对 Xcode 的依赖。

人们会有很多质疑,当有那么多优秀的、门槛低很多的替代方案可选的情况下,为什么还要让 Web 开发者或机器学习工程师仅仅为了尝试 Swift 而去下载 Xcode?支持 Language Server Protocol 可以让苹果生态圈以外的人更容易地使用他们熟悉工具去感受 Swift。

Xcode 变得更好

支持 LSP 不仅仅是让 Swift 在其他编辑器中运行地更好,Xcode 也将受益匪浅。

看看苹果 Swift 项目负责人 Ted Kremenek 的 这篇论坛帖子:

[Argyrios] 所描述的 LSP 服务将比今天的 SourceKit 更强大。

LSP 对 Xcode 团队来说是一个机遇,让他们使用一种新的方法实现 Swift 集成,并可以将其应用在语言和工具自 1.0 版本发布以来四年中的所有改进上。

Xcode(最终)变得更强大

LSP 的好处并不限于 Swift 和 Objective-C,Argyrios 在那个帖子的另一个留言中指出

Xcode 使用我们新的 LSP 服务,这意味着它也可以使用其他 LSP 服务,我们对此很感兴趣,不过目前暂无具体计划。

目前的工作重点是改进 Swift。但是,一旦实现了这一点,就应该能相对简单地将这些优化转移到支持 LSP 的其他语言中。


软件的架构反映了创建它的组织的结构和价值。在某种程度上,反之亦然。

通过让 Xcode 支持开放的 Language Server Protocol 标准,苹果正在履行其承诺,让 Swift 成功应用于苹果生态体系之外。我认为这是可行的:工具(或缺少工具)通常是技术获得人心的关键决定因素。但或许更重要的是,我认为这一决定表明,公司内部(至少是一小部分)对合作和透明度的意愿有所增强。

numericCast(_:)

作者 Mattt
2018年10月15日 00:00

每个人都曾将编程比喻成其他事物。

类比成木工、编织或者园艺。又或者可能类比成解决问题、讲故事或者制作艺术品。毫无疑问,编程与写作也很像;问题是更像诗歌还是散文。如果编程像音乐的话,不管怎么样它都应该是爵士乐。

或许对我们每天所做工作最近似的类比来自中东民间故事:打开任何版本的《一千零一夜 (أَلْف لَيْلَة وَلَيْلَة) 》,你会找到对一种被称作镇尼杰尼精灵或者 🧞 的神奇生物的描述。不管你怎么称呼它们,你一定熟悉它们实现愿望的习惯,和必然会引起的不幸。

从许多方面来看,电脑是抽象的愿望满足机的物理体现。像精灵一样,电脑会开心的执行任何你告诉它要做的事,而不会考虑你真正的意图是什么。之后当你意识到自己的错误时,就已经太晚了。

作为一个 Swift 开发者,很有可能你遇到过整数类型转换错误并想着「我希望这些警告赶紧消失,代码能编译通过」。

如果这听起来很熟悉,那你会对学习 numbericCast(_:) 感到高兴,它是 Swift Standard Libray 中一个小小的实用函数,有可能正是你所希望的。但是请小心提出你的愿望,它有可能马上会成真。


让我们从消除觉得 numericCast(_:) 有什么魔法开始,通过查看它的实现

public func numericCast<T : BinaryInteger, U : BinaryInteger>(_ x: T) -> U {
        return U(x)
        }
        

(像从我们有关 Never 的文章里学到的一样,极小量的 Swift 代码也能有巨大的作用。)

Swift 4 推出的 BinaryInteger 协议,作为语言中整个数字实现的一部分。它提供了与整数工作的统一接口,包括有符号和无符号,还有所有的结构和大小。

当你将一个整数值转换为另一个类型时,另一个类型有可能无法表示这个值。这会发生在你尝试将一个有符号整数转换成一个无符号整数时(比如将 -42 转换为 UInt)或者数值超过了目标类型所能表示的范围时(比如 UInt8 只能表示 0255 之间的数字)。

BinaryInteger 为整数类型转换定义了四种策略,每一种在处理超出范围的值时都有不同行为:

  • 范围检查转换init(_:)): 遇到超出范围的值时触发运行时错误
  • 准确转换init?(exactly:)): 遇到超出范围的值时返回 nil
  • 钳制转换init(clamping:)): 遇到超出范围的值时使用最近可表示的值
  • 位模式转换init(truncatingIfNeeded:)): 截断至目标整数类型宽度

正确的转换策略取决于使用时的情况。有些时候,希望能钳制数值到可表示的范围;有些时候,最好不要获取到任何值。对于 numbericCast(_:) 来说,它为了方便使用了范围检查转换。缺点就是使用超过范围的数值调用这个函数会导致运行时错误(具体来说,在 -O-Onone 时陷入溢出错误)。

字面地思考,批判地思考

在更进一步之前,让我们先来谈论一下整数字面量。

我们在之前的文章讨论过,Swift 提供了一个方便且可扩展的方式来在源代码中表示值。当和语言中的类型推断一起使用时,它们通常「可以工作」……这样一切都很好,但是当它们「无法工作」时就非常令人困惑了。

考虑下面的例子,有符号整型数组和无符号整型数组使用同样的字面量初始化:

let arrayOfInt: [Int] = [1, 2, 3]
        let arrayOfUInt: [UInt] = [1, 2, 3]
        

尽管它们好像是相等的,但我们不能做下面例子中的事情:

arrayOfInt as [UInt] // Error: Cannot convert value of type '[Int]' to type '[UInt]' in coercion
        

解决这个问题的一种方式是,将 numericCast 函数作为参数传入 map(_:)

arrayOfInt.map(numericCast) as [UInt]
        

这样等同于直接传入 UInt 范围检查构造器:

arrayOfInt.map(UInt.init)
        

让我们再看一次这个例子,这次使用稍微不同的数值:

let arrayOfNegativeInt: [Int] = [-1, -2, -3]
        arrayOfNegativeInt.map(numericCast) as [UInt] // 🧞‍ Fatal error: Negative value is not representable
        

作为一个编译时类型功能的运行时近似物,numericCast(_:) 更像是 as! 而不是 asas?

将这个和传入精确转换构造器 init?(exactly:) 的结果相比:

let arrayOfNegativeInt: [Int] = [-1, -2, -3]
        arrayOfNegativeInt.map(UInt.init(exactly:)) // [nil, nil, nil]
        

numericCast(_:),像它内在的范围检查转换一样,是一个钝器,当你决定使用它时,明白你在权衡什么是非常重要的。

正确的代价

在 Swift 中,通常指导是为整数值使用 Int(且为浮点值使用 Double),除非有非常好的理由来使用更具体的类型。尽管 Collectioncount 在定义上是非负的,但我们使用 Int 而不是 UInt。因为在与其他 API 交互时转换来转换去类型的代价要比更精确类型带来的好处要大。同样的原因,用 Int 来表示小数字几乎总是会更好,比如工作日数字,尽管它所有的可能值用一个 8 位整型存储都绰绰有余。

理解这个实践最好的方式就是在 Swift 里和 C API 对话几分钟。

古老且低级的 C API 里充斥着体系结构相关的类型定义和细微调整过的值存储空间。独立的来看,它们是可管理的。但从像头文件到指针这些互操作性麻烦上看,它们对某些问题可能会是一个断点(我不是在说调试中那种)。

当你看红色看到烦,只想要编译通过时,numericCast(_:) 就在那等着你。

编译的随机性

很多人应该会熟悉官方文档中的例子

SE-0202 之前,(在苹果的平台上)Swift 中生成随机数的标准实践需要引入 Darwin 框架然后调用 arc4random_uniform(3) 函数:

uint32_t arc4random_uniform(uint32_t __upper_bound)
        

在 Swift 中使用 arc4random 需要进行不止一次而是两次类型转换:一是上限参数(IntUInt32),二是返回值(UInt32Int):

import Darwin
        func random(in range: Range<Int>) -> Int {
        return Int(arc4random_uniform(UInt32(range.count))) + range.lowerBound
        }
        

真恶心。

通过使用 numericCast(_:),我们可以让代码更可读一些,尽管也会变长一点:

import Darwin
        func random(in range: Range<Int>) -> Int {
        return numericCast(arc4random_uniform(numericCast(range.count))) + range.lowerBound
        }
        

在这里 numericCast(_:) 没有做任何类型合适的构造器做不到的事情。它的作用是指明这个转换是敷衍的——为了让代码编译需要做的最少的事情。

不过从前言有关精灵的事情中学到,我们应该谨慎的对待我们的愿望。

经过仔细检查,上面对例子中对 numericCast(_:) 的使用有一个明显的缺陷:当值超过 UInt32.max 时会造成崩溃!

random(in: 0..<0x1_0000_0000) // 🧞‍ Fatal error: Not enough bits to represent the passed value
        

如果我们查看现在 Int.random(in: 0...10) 在 Swift Standard Library 中的实现,可以看到其使用了钳制转换而不是类型检查转换。并且从一个随机字节缓冲区中取值而不是委托给像 arc4random_uniform 这样的简便函数。


编译通过的代码和正确的代码是不一样的。但有时候需要通过前者来最终获得后者。审慎的使用,numericCast(_:) 会是一个方便且能快速解决问题的工具。和类型转换构造器相比它还有表明潜在异常行为的好处。

根本上来说,编程就是准确描述我们想要怎么样——通常伴随艰苦的细节。并没有一个和精灵似的「做正确的事情」 CPU 指令(就算有的话,我们能信赖它吗?)。幸好,Swift 可以让我们比其他很多语言更安全和简洁的做这些事情。老实说,谁还能要求更多呢?

iOS 12

作者 Mattt
2018年9月17日 00:00

如果你去看今年的 WWDC Keynote 演讲,你就知道 iOS 12 的这些比较重大的的新特性: Siri Shortcuts, ARKit 2, and Core ML 2 — 更不用提风传很久的炸弹:代号为 “Marzipan” 的 iOS/Mac 桥接件终于提前透露了出来。

另外如果你看了今年的这一期 Platforms State of the Union session, 你会发现一些虽然没那么光芒四射但依旧很令人兴奋技术: 像自定义用户通知 UI, 和更新了的 Network自然语言(Natural Language) 框架。

但在 NSHipster , 我们感兴趣的是那些最详细最细微的变化 (我们可以说, 晦涩的?) 也是最终加起来却又能给我们每天的工作带来很大影响的变化。

今年的 iOS 12 版本说明Foundation 版本说明涵盖了很多这样的变化,但是它们还没有展开故事的全貌。 正因如此,你还需要深入挖掘。

为了庆祝 iOS 12 在这周的发布, 我们把通过iOS 11.4 到 12 的 API 的一些差异 分享给大家(尽管 API 已经列出来了,但是他们还没有正式文档的说明,所以记得小心使用)。


为更重要的请求在网络流量里排出优先级

你听过 iOS 里的 Fast Lane(快车道)么? 并不是 Google 的 fastlane。 也不是 Cisco 的 IOS

Fast Lane (或者写成 Fastlane?) 是一种能根据网络请求的类型(例如音频、视频或后台数据)来优化无线网络流量的一个机制。

它原本是只有 Cisco 路由器(Cisco 路由器承载了互联网一半的流量)专有的一个技术,里面包含了一些 Wi-Fi 的标准, 例如快速漫游的 802.11r、 辅助漫游的 802.11k 和无线配置的 802.11v

多亏了 2015 年 Apple 和 Cisco 的合作, iOS 开发者可以通过给网络连接提供一个服务类型(QoS 标记) 来用到这个技术 (很多高层的 API 可以自动地帮你实现这个工作)。

在 iOS 12 的新 API 里,你可以把 URLRequest 对象networkServiceType 设置为 NSURLNetworkServiceTypeResponsiveData,从而让一些时间敏感的操作优先请求。

import Foundation
        let url = URL(string: "https://example.com/checkout")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.networkServiceType = .responsiveData // Prioritize
        URLSession.shared.dataTask(with: request) {
        (data, response, error) in
        // ...
        }
        

这个选项还没有文档说明, 但是在 WWDC 2018 Session 714: “Optimizing Your App for Today’s Internet” 的向导里,苹果工程师已经提到了。 不过只有最需要极力减少请求时间的时候才能谨慎地使用这个特性, 例如他们提到了一个场景就是一些购物应用的收银台页面可以使用, 当然你也可以推断出一些其他的使用案例。

在后台读取 NFC 标签

在 WWDC 2018 里被排着队问到的最多的一个问题是 NSUserActivity 加入的 ndefMessagePayload 属性, 而彼时苹果工程师在 Lab Session 回答的最多的也是“无可奉告”。

但是重重迷雾终于在上周的 iPhone XS, iPhone XS Max 和 iPhone XR 的发布会上被揭开。 这些设备支持在后台读取 NFC 标签, 而且如果你在最新的设备上跑着 iOS 12, 你将可以 — 干这些 — 可以不需要别的设置, 就通过扫到的标签来启动 App、打电话或打开链接。 为了防止误激活, 这个只在 iPhone 是解锁状态下才工作, 处于飞行模式、使用 Apple Pay 中和使用相机的时候不能工作。

有了 NFC 集成,通过提供一个更顺畅与真实世界连接的途径,而不是堕落到去扫二维码(在中国非常流行,但是被世界其他地方被忽视), Apple 终于可以完全实现 2013 对于 BLE 的承诺了。

可能 NFC 和 iBeacon 最适用场景还是你在博物馆参观, 站在展品面前想了解更多信息的时候, 只需要把 iPhone 放到信息牌上就能实现。

实现类似功能需要 App 有个特别的 entitlements, 设置相关的域和其他一些相关配置 — 还有一些你需要实际实现的 API。 好在,Apple 还提供了一些过程中的扩展文档, 包括一个示例项目这篇文章.

根据电话号码和邮件地址匹配联系人

Contacts framework 是在 iOS 9 和 macOS El Capitan 作为 AddressBook framework 的更现代的替代版本出现的。

截止目前, 你只能通过姓名或者唯一编码(identifier)搜索联系人, 但是在 iOS 12 里, 你能用CNContact 类的 predicateForContacts(matching:)predicateForContacts(matchingEmailAddress:) 来构造谓词(predicate)用以匹配手机号码与邮件地址。

例如, 如果我们想要依照一组手机号和邮件地址取出所有匹配到的姓与名, 你可以创建一个 CNContactFetchRequest, 用 “AND” 来把连个子谓词(subpredicate)组合一个复合谓词,而后传给当前 CNContactStore 对象的 enumerateContacts(with:) 方法。

import Contacts
        let phoneNumber = CNPhoneNumber(stringValue: "+1 555 555 1234")
        let phoneNumberPredicate = CNContact.predicateForContacts(matching: phoneNumber)
        let emailPredicate = CNContact.predicateForContacts(matchingEmailAddress: "johnny@example.com")
        var fetchRequest = CNContactFetchRequest(keysToFetch: [
        CNContactGivenNameKey as CNKeyDescriptor,
        CNContactFamilyNameKey as CNKeyDescriptor
        ])
        fetchRequest.predicate =
        NSCompoundPredicate(andPredicateWithSubpredicates: [
        phoneNumberPredicate,
        emailPredicate
        ])
        let store = CNContactStore()
        try store.enumerateContacts(with: fetchRequest) { (contact, _) in
        // ...
        }
        

在空中更新位置信息

很多飞行员很喜欢用 iPad 来导航和制定飞行计划。 如果你的 App 是用来装备给在天上驾驶员座舱里的老兄们的话, 你就会对 iOS 12 里 CLLocationManager 的新特性感到兴奋。

activityType 属性 已经出来有一段时间了,但是针对于 CLLocationManager 的配置却鲜有人知。 如果你用这个 CLLocationManager 在一段时间内追踪位置的变化, 通过一些唾手可得的优化, 你就能知道用户在以什么行为模式运动。 但目前,运动变化的种类被严格限制在正在 驾驶类走路/跑步/骑行类其他类这三个领域。 但是在 iOS 12 里,你可以指定飞行活动类来让你的运动追踪算法飞起来!

import CoreLocation
        let manager = CLLocationManager()
        manager.activityType = .airborne // ✈️
        

检测平放设备朝向

你是否想检测设备是否平放在一个表面上, 但是只能勉强地使用 两个条件 来检测? 好消息是,在 iOS 12 里, 新增了一个 isFlat 的方便属性。

import UIKit
        // iOS 12+
        UIDevice.current.orientation.isFlat
        // iOS <= 11.4
        UIDevice.current.orientation == .faceUp ||
        UIDevice.current.orientation == .faceDown
        

新密码的自动输入与一次性验证码在文本框里的输入

尽管 Apple 为了在让用户在设备上愉悦地输入做了长期的英雄般卓越的工作, 但是有一些事实还是不能改变: 在一个毫无触感的光滑玻璃上输入总是比在一个合适的硬件键盘上输入要苍白 (尽管依然要吐槽新 MacBook 的键盘)。

为了减少文本输入之类的苦差事, iOS 10 为一些服从 UITextInputTraits 协议的控件(分别是 UITextFieldUITextView) 提供了 textContentType 属性。 只要提供一个具体的枚举类型,你就指定了这个控件的语义类型, 就可以依据当前用户的信息,自动填入名字或者地址类的信息。

iOS 12 和 tvOS 12 拓展了这个枚举,增加了 UITextContentTypeNewPasswordUITextContentTypeOneTimeCode 类型。

当你同时指定 .newPasswordpasswordRules 属性的时候, 密码自动填写功能(Password AutoFill)就能根据系统登录密码的要求自动生成一个新的密码。

textField.textContentType = .newPassword
        textField.passwordRules = .init(descriptor:
        "allowed: ascii-printable; minlength: 8;"
        )
        

当你指定了 .oneTimeCode 内容类型的时候, 文本框能够自动地转发短信的二次验证码。

textField.textContentType = .oneTimeCode
        

我们这一期的 iOS 12 代码 diff 探险就在这里结束了。 当然,这是一次庞大的更新, 我们期待在接下来的几周再讲解更多更深的新 API。

对之后的内容有任何建议? 来 Twitter 联系我们!

CMMotionActivity

作者 Mattt
2018年9月10日 00:00

人类通过组合视觉、本体感觉和前庭系统得到的感觉信息来察觉自身的运动。其中前庭系统起到主要作用,前庭系统由感觉旋转的半规管和对水平与垂直受力敏感的耳石构成。

如今的 iPhone 都有着一整套传感器,包括相机、气压计、陀螺仪、磁强计和加速规。和人类一样,它们使用不同感觉信息的组合来确定其位置和朝向,通常和我们自身的生物力学过程非常相似。

让感觉输入有意义——不管它们是从哪里来的——是非常有挑战性的。需要考虑的信息实在太多了。(见鬼,我们这个种族花了小几百万年才搞定这件事,然而我们还是会被像电梯、飞机和过山车这些新奇的发明弄晕。)

经过几个版本的系统与硬件更新,苹果的设备变得擅长于区分不同运动方式。在你跑去尝试自己实现前,停下来考虑一下使用这周文章中讨论的内置 API。


在 iOS 和 watchOS 上,CMMotionActivityManager 处理设备中传感器的原始数据并告诉你(有多确定)用户是否正在移动,和用户是在行走、跑步、骑行或者开车。

要使用这个 API,首先创建一个活动管理器,然后使用 startActivityUpdates 方法来开始监听活动更新。每当设备更新了运动相关活动,它就会执行指定的闭包,并传入一个 CMMotionActivity 对象。

let manager = CMMotionActivityManager()
        manager.startActivityUpdates(to: .main) { (activity) in
        guard let activity = activity else {
        return
        }
        var modes: Set<String> = []
        if activity.walking {
        modes.insert("🚶‍")
        }
        if activity.running {
        modes.insert("🏃‍")
        }
        if activity.cycling {
        modes.insert("🚴‍")
        }
        if activity.automotive {
        modes.insert("🚗")
        }
        print(modes.joined(separator: ", "))
        }
        

CMMotionActivityManager 由 Core Motion 框架提供。支持 Core Motion 的设备都装备了一个运动协处理器。通过使用专用硬件,系统可以将所有传感器处理工作从 CPU 上卸载掉,并减少电量的使用。

第一款 M 系列协处理器是 M7,在 2013 年九月与 iPhone 5S 一同面世。iOS 7 和 Core Motion API 也与此同时发布。

司机功能

可能运动活动最广为人知的使用是 iOS 11 中新增的「驾驶勿扰」功能。这项功能推出后,对于开车的检测变好了很多。

在速度比较低的情况下,只使用加速规的数据是很难区分驾车行驶和其他活动的。我们只能猜测,有可能 iPhone 是使用磁强计的数据来实现这项功能的。因为汽车和其他机动车辆通常被金属围绕着,电磁通量会减少。

除了安全相关的考虑,一些应用可能会根据当前的交通模式改变行为。比如,一个快递应用可能会发送运动活动改变到服务器来重新计算预计到达时间,或者更新 UI 来表明快递员已经停下了车子,正在走路接近。

不动地移动

CMMotionActivity 对每种运动类型都有一个布尔值属性,并且还有还有一个设备是否处于静止状态的属性。这似乎违反直觉,逻辑上来说你在同一时间只能步行或者开车,但不能一起进行。

关于这一点在 CMMotionActivity 的文档中有澄清:

本类中关于运动的属性并不是互相排斥的。换句话说,有可能有多个运动相关属性的值为 true。举个例子,如果用户在驾驶一辆车,然后在红灯前停了下来,对于这个事情中运动相关的更新事件会将 cyclingstationary 属性都设为真。

文档原文:

The motion-related properties of this class are not mutually exclusive. In other words, it is possible for more than one of the motion-related properties to contain the value true. For example, if the user was driving in a car and the car stopped at a red light, the update event associated with that change in motion would have both the cycling and stationary properties set to true.

等等,我刚才说了澄清?我的意思是……反正不是和这个文档这样。(我很确定这个例子应该是 automotive)幸好,头文件的文档有比我们想知道的更多信息。下面有一些这个 API 在不同情况下具体的例子:

场景 1:你在一辆停在红灯前的车里

🚶‍ walking 🏃‍ running 🚴‍cycling 🚗 automotive 🛑 stationary
false false false true true

场景 2:你在一辆移动中的机动车里

🚶‍ walking 🏃‍ running 🚴‍cycling 🚗 automotive 🛑 stationary
false false false true false

场景 3:设备正在运动,但你没有在走路也没有在机动车辆上

🚶‍ walking 🏃‍ running 🚴‍cycling 🚗 automotive 🛑 stationary
false false false false false

场景 4:你是一位名侦探,在一辆行驶中的火车上的走廊里追逐嫌疑犯,跑到了最后一节车厢并停下来四处查看并猜测他们藏在哪。(可能在角落里那个可疑、一人大小的箱子里?)

🚶‍ walking 🏃‍ running 🚴‍cycling 🚗 automotive 🛑 stationary 🕵️‍🇧🇪 poirot
false true false true true true

(我们实际上并不确定最后一个场景会发生什么……)

总的来说这个文档的大意是,你应该认为 stationaryCMMotionActivity 中其他属性是正交的,并且你应该准备好处理所有的组合情况。

每个 CMMotionActivity 对象还包括了一个其值可能为 .low.medium.highconfidence 属性。不幸的是,文档既没有提供多少关于这些值的有用信息,也没有说明它们要如何使用。对于这种情况,推荐使用一种经验性的方法:实际测试你的应用,观察不同情况下出现的 confidence 值,使用这些信息来修正应用的行为。

与位置查询组合使用

根据你的实际情况,组合使用 Core Motion 和 Core Location 数据可能是有意义的。

你可以组合一段时间的位置变化和把握比较低的运动活动数据来提高精确度。这里有一些不同移动方式典型速度范围的指导方针:

  • 步行速度通常最高能达到 2.5 米每秒(5.6 mph, 9 km/h)
  • 跑步速度范围从 2.5 到 7.5 米每秒(5.6 – 16.8 mph, 9 – 27 km/h)
  • 骑行速度范围从 3 到 12 米每秒(6.7 – 26.8 mph, 10.8 – 43.2 km/h)
  • 汽车的速度可以超过 100 米每秒(220 mph, 360 km/h)

或者,你可能会使用位置数据来改变 UI,取决于现在的位置是否在一片水域。

if currentLocation.intersects(waterRegion) {
        if activity.walking {
        print("🏊‍")
        } else if activity.automotive {
        print("🚢")
        }
        }
        

然而,位置数据应该只在绝对必要的时候再查询——当你需要查询时,也应该尽量少的查询,比如只检测显著的位置改变。这样的原因是,获取位置数据要求使用 GPS 且/或移动网络,它们都非常耗电。


CMMotionActivityManager 是 Core Motion 里那些好用 API 的其中一个,你可以使用它来构造沉浸般的、快速响应的应用。

如果你还没有考虑过将设备运动信息纳入应用的潜力(或者可能你很久没有关注过 Core Motion),你可能会被它能做到的事情惊讶到。

NSDataAsset

作者 Mattt
2018年8月26日 00:00

在 Web 的世界里,速度不是一种奢求;它事关生死。

近年来的用户研究表明,页面加载中 任何 可以察觉到的延迟 —— 即大于 400 毫秒(字面意义上的“一眨眼的功夫”) —— 都会对转化率和参与率产生负面影响。网页加载时每多花一秒,就会多 10% 的用户返回或者关闭这个页面。

对于谷歌、亚马逊和 Netflix 这样的大型的互联网公司而言,加载时多花一秒钟就意味着损失 数十亿 美元的年收入。所以那些公司投入如此多的工程努力来让网页更快,也没有什么奇怪的了。

有很多加速网络请求的技术:压缩和流技术、缓存和预加载、连接池和多路复用、延迟和后台运行。然而,还有一种比它们优先级更高,效果更好的优化策略:压根就不发请求

在这个方面,App 凭借先下载后使用的特点,拥有传统网页所不具备的独特优势。在这一周的 NSHipster 里,我们将展示如何以一种非传统的方式使用 Asset Catalog 来改善你的 App 的首次启动体验。


Asset Catalog 允许你根据当前设备的特性来组织资源文件。对于一个给定的图片,你可以根据设备(iPhone、iPad、Apple Watch、Apple TV、Mac)、屏幕分辨率(@2x / @3x)或者色域(sRGB / P3),提供不同的文件。对于其他类型的 asset,你可能根据可用内存或者 Metal 版本的不同而提供不同的文件。请求 asset 时仅需提供名字,最合适的那个资源就会自动返回。

除了提供更简便的 API,Asset Catalog 还允许 App 使用 app thinning 为每个用户设备提供一个经过优化的更小的安装包。

图片是最常见的 Asset 类型,但是从 iOS 9 和 macOS El Capitan 开始,JSON、XML 和其他数据文件之类的资源也可以通过 NSDataAsset 这种有趣的方式参与进来。

如何使用 Asset Catalog 存储和获取数据

举个例子,让我们想象一个用于创建数字调色板的 iOS App。

为了区分不同深浅的灰色,我们可能会加载一个颜色和对应名字的列表。通常情况下,我们可能会在第一次启动时从服务器下载这个列表,但是如果恶劣的网络环境限制了 App 的功能,就会导致很差的用户体验。既然它是一个相对静态的数据集,为什么不以一种 Asset Catalog 形式将它添加到 app bundle 中?

步骤 1:向 Asset Catalog 中添加 New Data Set

当你在 Xcode 中新建一个 app 项目时,它会自动生成一个 Asset Catalog。在项目导航(Project navigator)中选中 Assets.xcassets,打开 Asset Catalog 编辑器。点击左下方的 + 图标,然后选择 “New Data Set”。

asset add-new-data-set.png

这样会在 Assets.xcassets 下新建一个后缀名为 .dataset 的子目录。

步骤2:添加数据文件

打开 Finder,找到数据文件,把它拖拽到 Xcode 中 data set asset 的空白处。

asset asset-catalog-any-any-universal.png

当你这么做时,Xcode 会把那个文件复制到 .dataset 子目录,并将它的文件名和 通用类型标识符(Universal Type Identifier) 更新到 contents.json 元数据文件。

{
        "info": {
        "version": 1,
        "author": "xcode"
        },
        "data": [
        {
        "idiom": "universal",
        "filename": "colors.json",
        "universal-type-identifier": "public.json"
        }
        ]
        }
        

步骤3:使用 NSDataAsset 访问数据

现在你可以使用如下代码访问文件的数据:

guard let asset = NSDataAsset(name: "NamedColors") else {
        fatalError("Missing data asset: NamedColors")
        }
        let data = asset.data
        

对于我们颜色 App,我们可能在一个 view controller 的 viewDidLoad() 方法中调用上面的代码,然后解码返回的数据,获取 model 对象的数组,并展示在一个 table view 上。

let decoder = JSONDecoder()
        self.colors = try! decoder.decode([NamedColor].self, from: asset.data)
        

混合一下

Data set 通常无法从 Asset Catalog 的 app thinning 特性中获益(例如,大部分的 JSON 文件都不太关心设备所支持的 Metal 版本)。

但是对于我们的调色板 App,我们可能为支持广色域显示的设备提供不同的颜色列表。

为了做到这一点,在 Asset Catalog 编辑器的侧边栏选中刚才的 asset,然后点击 Attributes Inspector 下名为 Gamut 的下拉控件。

asset select-color-gamut.png

为每个色域提供定制的数据文件后,contents.json 元数据文件应该看起来像这样:

{
        "info": {
        "version": 1,
        "author": "xcode"
        },
        "data": [
        {
        "idiom": "universal",
        "filename": "colors-srgb.json",
        "universal-type-identifier": "public.json",
        "display-gamut": "sRGB"
        },
        {
        "idiom": "universal",
        "filename": "colors-p3.json",
        "universal-type-identifier": "public.json",
        "display-gamut": "display-P3"
        }
        ]
        }
        

保鲜一下

使用 Asset Catalog 存储和获取数据是非常简单的。真正困难 —— 并最终更重要 —— 的是保持数据的更新。

使用 curlrsyncsftp、Dropbox、BitTorrent 或 Filecoin 刷新数据。从一个 shell 脚本开始(如果你喜欢,可以在 Xcode Build Phase 中调用它)。将它添加到你的 MakefileRakefileFastfile,或者你的编译系统所要求的任何地方。将这个任务分配给 Jenkins、Travis 或者某个烦人的实习生。使用定制的 Slack integration 或者 Siri Shortcuts 触发它,这样你就可以用随意的一句 “Hey Siri,在数据变得太旧之前更新一下”,让你的同事大吃一惊。

注意,当你决定同步你的数据时,一定要确保它是自动化的,而且是你发布过程的一部分。

下面是一个 shell 脚本示例,你可以运行它来使用 curl 下载最新的数据文件:

#!/bin/sh
        CURL='/usr/bin/curl'
        URL='https://example.com/path/to/data.json'
        OUTPUT='./Assets.xcassets/Colors.dataset/data.json'
        $CURL -fsSL -o $OUTPUT $URL
        

封装一下

虽然 Assets Catalog 会对 image asset 执行无损压缩,但没有任何文档、Xcode 帮助或 WWDC session 指出 data asset 上也存在这种优化(至少目前没有)。

当 data asset 的文件大小大于,比如说几百 KB 时,你就要考虑使用压缩了。JSON、CSV 和 XML 之类的文本文件尤其如此,它们通常可以被压缩到原始大小的 60% - 80%。

我们可以将 curl 的输出发送给 gzip,然后再写到我们的文件,从而为我们之前的 shell 脚本添加压缩功能。

#!/bin/sh
        CURL='/usr/bin/curl'
        GZIP='/usr/bin/gzip'
        URL='https://example.com/path/to/data.json'
        OUTPUT='./Assets.xcassets/Colors.dataset/data.json.gz'
        $CURL -fsSL $URL | $GZIP -c > $OUTPUT
        

If you do adopt compression, make sure that the "universal-type-identifier" field reflects this:

如果你使用了压缩,请确保 "universal-type-identifier" 字段体现了这一点:

{
        "info": {
        "version": 1,
        "author": "xcode"
        },
        "data": [
        {
        "idiom": "universal",
        "filename": "colors.json.gz",
        "universal-type-identifier": "org.gnu.gnu-zip-archive"
        }
        ]
        }
        

在客户端上,你使用 asset catalog 之前需要先解压数据。如果有 Gzip 模块,你可能会做以下事情:

do {
        let data = try Gzip.decompress(data: asset.data)
        } catch {
        fatalError(error.localizedDescription)
        }
        

或者,如果你会在 App 中反复地这么做,那么可以在 NSDataAsset 的扩展中创建一个便利方法:

extension NSDataAsset {
        func decompressedData() throws -> Data {
        return try Gzip.decompress(data: self.data)
        }
        }
        

尽管人们容易认为所有用户都享受着快速的、无处不在的 WiFi 和 LTE 网络,但这并不适用于所有人,也不适用于所有时段。

花点时间看看你的 App 在启动时发出的网络请求,然后考虑哪些可能从预加载中受益。给人留下好的第一印象可能意味着你的 App 是被长期地积极地使用着,而不是几秒钟之后就被删除。

Swift Property Observers

作者 Mattt
2018年8月20日 00:00

到了 20 世纪 30 年代,Rube Goldberg 已成为家喻户晓的名字,与“自营餐巾” 等漫画中描绘的奇异复杂和异想天开的发明同义。大约在同一时期,阿尔伯特·爱因斯坦对尼尔斯·玻尔量子力学的普遍解释进行了批判,并从中提出了“鬼魅似的远距作用”这一词汇。

近一个世纪之后,现代软件开发已经被视为可能成为 Goldbergian 装置的典范——通过量子计算机相信我们会越来越接近这个鬼魅的领域。

作为软件开发人员,我们提倡尽可能减少代码中的远程操作。这是根据一些众所周知的规范法则得出的,如单一职责原则最少意外原则得墨忒耳定律。尽管它们可能会对代码产生一定的副作用,但更多的时候这些原则能使代码逻辑变得清晰。

这是本周文章的焦点 Swift 属性观察器,它是系统内置的,比模型 - 视图 - 视图模型(MVVM)、函数响应式编程(FRP)这些更正式的解决方案更轻量。


Swift 中有两种属性:存储属性,它们将状态和对象相关联;计算属性,则根据该状态执行计算。例如,

struct S {
        // 存储属性
        var stored: String = "stored"
        // 计算属性
        var computed: String {
        return "computed"
        }
        }
        

当你声明一个存储属性,你可以使用闭包定义一个 属性观察器,该闭包中的代码会在属性被设值的时候执行。willSet 观察器会在属性被赋新值之前被运行,didSet 观察器则会在属性被赋新值之后运行。无论新值是否等于属性的旧值它们都会被执行。

struct S {
        var stored: String {
        willSet {
        print("willSet was called")
        print("stored is now equal to \(self.stored)")
        print("stored will be set to \(newValue)")
        }
        didSet {
        print("didSet was called")
        print("stored is now equal to \(self.stored)")
        print("stored was previously set to \(oldValue)")
        }
        }
        }
        

例如,运行下面的代码在控制台的输出如下:

var s = S(stored: "first")
        s.stored = "second"
        
  • willSet was called
  • stored is now equal to first
  • stored will be set to second
  • didSet was called
  • stored is now equal to second
  • stored was previously set to first

Swift 的属性观察器从一开始就是语言的一部分。为了更好地理解其原理,让我们快速了解一下它在 Objective-C 中的工作原理。

Objective-C 中的属性

从某种意义上说,Objective-C 中的所有属性都是被计算出来的。每次通过点语法访问属性时,都会转换为等效的 getter 或 setter 方法调用。这些调用最终被编译成消息发送,随后再执行读取或写入实例变量的方法。

// 点语法访问
        person.name = @"Johnny";
        // ...等价于
        [person setName:@"Johnny"];
        // ...它被编译成
        objc_msgSend(person, @selector(setName:), @"Johnny");
        // ...最终实现
        person->_name = @"Johnny";
        

编程过程中我们通常想要避免引入副作用,因为它会导致程序的行为难以推断。但很多 Objective-C 开发者已经依赖于这种特性,他们会根据需要在 getter 或 setter 中注入各种额外的行为。

Swift 的属性设计使这些模式更加标准化,并对装饰状态访问(存储属性)的副作用和重定向状态访问(计算属性)的副作用进行了区分。对于存储属性,willSetdidSet 观察器将替换你在 ivar 访问时的代码。对于计算属性,getset 访问器可能会替换在 Objective-C 中实现的一些 @dynamic 属性。

正因为如此,我们才可以获取更一致的语义,并更好地保证键值观察(KVO)和键值编码(KVC)等属性交互机制。


那么你可以使用 Swift 属性观察器做些什么呢?以下是一些供你参考的想法:


标准化或验证值

有时,你希望对类型接受的值增加额外的约束。

例如,你正在开发一个和政府机构对接的应用程序,你需要保证用户填写了所有的必填项并且不包含非法的值才能提交表单。

如果一个表单要求名称字段使用大写字母且不使用重音符号,你可以使用 didSet 属性观察器自动去除重音符号并转化为大写。

var name: String? {
        didSet {
        self.name = self.name?
        .applyingTransform(.stripDiacritics,
        reverse: false)?
        .uppercased()
        }
        }
        

幸运的是在观察器内部设置属性不会触发额外的回调,所以上面的代码中不会产生无限循环。我们之所以不使用 willSet 观察器是因为即使我们在其回调中进行任何赋值,都会在属性被赋予 newValue 时覆盖。

虽然这种方法可以解决一次性问题,但像这样需要重复使用的业务逻辑可以封装到一个类型中。

更好的设计是创建一个 NormalizedText 类型,它封装了要以这种形式输入的文本的规则:

struct NormalizedText {
        enum Error: Swift.Error {
        case empty
        case excessiveLength
        case unsupportedCharacters
        }
        static let maximumLength = 32
        private(set) var value: String
        init(_ string: String) throws {
        if string.isEmpty {
        throw Error.empty
        }
        guard let value = string.applyingTransform(.stripDiacritics,
        reverse: false)?
        .uppercased(),
        value.canBeConverted(to: .ascii)
        else {
        throw Error.unsupportedCharacters
        }
        guard value.count < NormalizedText.maximumLength else {
        throw Error.excessiveLength
        }
        self.value = value
        }
        }
        

一个可抛出异常的初始化方法可以向调用者发送错误信息,这是 didSet 观察器无法做到的。现在面对兰韦尔普尔古因吉尔戈格里惠尔恩德罗布尔兰蒂西利奥戈戈戈赫约翰尼 这样的麻烦制造者,我们能为他做些什么!(换言之,以合理的方式传达错误比提供无效的数据更好)

传播依赖状态

属性观察器的另一个潜在用例是将状态传播到依赖于视图控制器的组件。

考虑下面的 Track 模型示例和一个呈现它的 TrackViewController

struct Track {
        var title: String
        var audioURL: URL
        }
        class TrackViewController: UIViewController {
        var player: AVPlayer?
        var track: Track? {
        willSet {
        self.player?.pause()
        }
        didSet {
        guard let track = self.track else {
        return
        }
        self.title = track.title
        let item = AVPlayerItem(url: track.audioURL)
        self.player = AVPlayer(playerItem: item)
        self.player?.play()
        }
        }
        }
        

当视图控制器的 track 属性被赋值,以下事情会自动发生:

  1. 之前轨道的音频都会暂停
  2. 视图控制器的 title 会被设置为新轨道对象的标题
  3. 新轨道对象的音频信息会被加载并播放

很酷, 对吗?

你甚至可以像捕鼠记 中描绘的场景 一样,将行为与多个观察属性级联起来。


当然,观察器也存在一定的副作用,它使得有些复杂的行为难以被推断,这是我们在编程中需要避免的。今后在使用这一特性的同时也需要注意这一点。

然而,在这摇摇欲坠的抽象塔的顶端,一定限度的系统混乱是诱人的,有时是值得的。一直遵循规则的是波尔理论而非爱因斯坦。

Hashable / Hasher

作者 Mattt
2018年8月13日 00:00

当你在苹果商店预约天才吧服务后,相关工作人员会帮你登记并且安排特定的服务时间,在被带到座位上之后,工作人员会记录你的身份信息并添加到服务队列当中。

根据一份来自零售店某位前员工的报告表示,对于顾客的描述有着严格的指导方针。他们的外貌特征如:年龄、性别、种族、身高都没有被使用 —— 甚至连头发的颜色都没有被使用。而是通过顾客的着装来描述,例如“黑色的高领毛衣,牛仔裤和眼镜”。

这种描述顾客的方式和编程中的哈希函数有很多共同之处。同许多优秀的哈希函数一样,它是连续和易计算的,可用于快速找到你正在寻找的内容(或者人)。我想你肯定也觉得这样比队列要好用多了。

这周我们的主题是 Hashable 和相关的新类型 Hasher。它们共同组成了 Swift 最受喜爱的两个集合类 DictionarySet 的基础功能。


假设你有一个可以比较相等性的对象列表。要在这个列表中找到一个特定的对象,你需要遍历这个列表的元素,直到找到匹配项为止。随着你向列表中添加更多的元素时,需要找到其中任何一个元素所需的平均时间是线性级的(O(n))。

如果将这些对象存储在一个集合中,理论上可以在常量级时间(O(1))内找到它们中的任何一个 - 也就是说,在一个包含 10 个元素的集合中查找或在一个包含 10000* 个元素的集合中查找所需的时间是一样的。这是怎么回事呢?因为集合不是按顺序存储对象的,而是将对象内容计算的哈希值作为索引存储。当在集合中查找对象时,可以使用相同的哈希函数计算新的哈希值然后查找对象存储位置。

* 如果两个不同的对象具有相同的哈希值时,会产生哈希冲突。当发生哈希冲突时,它们将存储在该地址对应的列表中。对象之间发生冲突的概率越高,哈希集合的性能就会更加线性增长。

Hashable

在 Swift 中,Array 为列表提供了标准的接⼝,Set 为集合提供了标准的接⼝。如果要将对象存储到 Set 中,就要遵循 Hashable 协议及其扩展协议 Equatable。Swift 的标准映射接口 Dictionary 对它的关联类型 Key 也需要遵循 Hashable 协议及其扩展协议。

在 Swift 之前的版本中,为了让自定义类型能支持 SetDictionary 存储需要写⼤量的 样板代码

以下面的 Color 类型为例,Color 使⽤了 8 位整型值来表示红,绿,蓝色值:

struct Color {
        let red: UInt8
        let green: UInt8
        let blue: UInt8
        }
        

要符合 Equatable 的要求,你需要提供一个 == 操作符的实现。要符合 Hashable 的要求,你需要提供⼀个名为 hashValue 的计算属性:

// Swift < 4.1
        extension Color: Equatable {
        static func ==(lhs: Color, rhs: Color) -> Bool {
        return lhs.red == rhs.red &&
        lhs.green == rhs.green &&
        lhs.blue == rhs.blue
        }
        }
        extension Color: Hashable {
        var hashValue: Int {
        return self.red.hashValue ^
        self.green.hashValue ^
        self.blue.hashValue
        }
        }
        

对于大多数开发者⽽⾔,实现 Hashable 只是为了能尽快让要做的事情步入正轨,因此他们会对所有的存储属性使⽤逻辑异或操作,并在某一天调用它。

然⽽这种实现的一个缺陷是高哈希冲突率。由于逻辑异或满⾜交换率,像⻘色和⻩色这样不同的颜色也会发⽣哈希冲突:

// Swift < 4.2
        let cyan = Color(red: 0x00, green: 0xFF, blue: 0xFF)
        let yellow = Color(red: 0xFF, green: 0xFF, blue: 0x00)
        cyan.hashValue == yellow.hashValue // true, collision
        

大多数时候这样做不会出问题;现代计算机已经足够强大以至于你很难意识到性能的衰减,除⾮你的实现细节存在⼤量问题。

但这并不是说这些细节⽆关紧要 —— 它们往往极其重要。稍后会详细介绍。

自动合成 Hashable 实现

从 Swift 4.1 开始,如果某个类型在声明时遵循了 EquatableHashable 协议并且它的成员变量同时也满足了这些协议,编译器会为其自动合成 EquatableHashable 的实现。

除了大大的提高了开发人员的开发效率以外,还可以大幅减少代码的数量。比如,我们之前 Color 的例子 —— 现在是最开始代码量的 1/3 :

// Swift >= 4.1
        struct Color: Hashable {
        let red: UInt8
        let green: UInt8
        let blue: UInt8
        }
        

尽管对语言进行了明显的改进,但还是有一些实现细节有着无法忽视的问题。

在 Swift Evolution 提案 SE-0185: 合成 EquatableHashable 的实现 中, Tony Allevato 给哈希函数提供了这个注释:

哈希函数的选择应该作为实现细节,而不是设计中的固定部分;因此,使用者不应该依赖于编译器自动生成的 Hashable 函数的具体特征。最可能的实现是在每个成员的哈希值上调用标准库中的 _mixInt 函数,然后将他们逻辑异或(\^),如同目前 Collection 类型的哈希方式一样。

幸运的是,Swift 不需要多久就能解决这个问题。我们将在下一个版本得到答案:

Hasher

Swift 4.2 通过引入 Hasher 类型并采用新的通用哈希函数进一步优化 Hashable

在 Swift Evolution 提案 SE-0206: Hashable 增强 中:

使用一个好的哈希函数时,简单的查找,插入,删除操作都只需要常量级时间即可完成。然而,如果没有为当前数据选择一个合适的哈希函数,这些操作的预期时间就会和哈希表中存储的数据数量成正比。

正如 Karoy LorenteyVincent Esche 所指出的那样,SetDictionary 等基于哈希的集合主要特点是它们能够在常量级时间内查找值。如果哈希函数不能产生一个均匀的值分布,这些集合实际上就变成了链表。

Swift 4.2 中的哈希函数是基于伪随机函数族 SipHash 实现的,比如 SipHash-1-3 and SipHash-2-4,分别在每个消息块异或哈希之后执行一次 round + 三次 final round,或两次 round + 四次 final round。(译者注:这里的 round 指的是伪随机数变化。具体实现看 _round)

现在,如果你要自定义类型实现 Hashable 的方式,可以重写 hash(into:) 方法而不是 hashValuehash(into:) 通过传递了一个 Hasher 引用对象,然后通过这个对象调用 combine(_:) 来添加类型的必要状态信息。

// Swift >= 4.2
        struct Color: Hashable {
        let red: UInt8
        let green: UInt8
        let blue: UInt8
        // Synthesized by compiler
        func hash(into hasher: inout Hasher) {
        hasher.combine(self.red)
        hasher.combine(self.green)
        hasher.combine(self.blue)
        }
        // Default implementation from protocol extension
        var hashValue: Int {
        var hasher = Hasher()
        self.hash(into: &hasher)
        return hasher.finalize()
        }
        }
        

通过抽象隔离底层的位操作细节,开发人员可以利用 Swift 内置的哈希函数,这样可以避免再现我们原有的基于逻辑异或实现的冲突:

// Swift >= 4.2
        let cyan = Color(red: 0x00, green: 0xFF, blue: 0xFF)
        let yellow = Color(red: 0xFF, green: 0xFF, blue: 0x00)
        cyan.hashValue == yellow.hashValue // false, no collision
        

自定义哈希函数

默认情况下,Swift 使用通用的哈希函数将字节序列缩减为一个整数。

但是,你可以使用你项目中自定义的哈希函数来改进这个缩减的问题。比如,如果你正在编写一个程序来玩国际象棋或者棋盘游戏,你可以使用 Zobrist hashing 来快速的存储游戏的状态。

避免哈希泛滥(Hash-Flooding)

选择像 SipHash 这样的加密算法有助于防止哈希泛滥的 DoS 攻击,这种攻击会尝试生成哈希冲突,并试图强制实施哈希数据结构最坏的情况,最终导致程序慢下来。这在 2010 年初引发了一系列的网络问题

为了使事情变的更加安全,Hasher 会在每次启动应用程序时生成一个随机种子值,使得哈希值更难以预测。


编程类比的挑战在于它们通过边界情况规范反社会行为。

当我们能够考虑到攻击者所有可能利用来达到某种险恶目的的情况时,这时能体现出我们优秀工程师的品质 —— 比如哈希泛滥的 DoS 攻击。在现实生活中,这么做我们需要冒着失败的风险去应用这些 AFK(Away From Keyboard)知识。

也就是说…亲爱的读者,我不希望你和你的朋友下次穿一样的衣服去当地苹果商店的天才吧中制造混乱和不和谐。

请不要这么做。

相反的,希望你有下面的收获:

当你在天才吧等候的时候,和穿同样颜色衣服的人站得远一点。这会让每个人做事都变得容易得多。

Never

作者 Mattt
2018年7月30日 00:00

“Never”是一个约定,表示一件事在过去或未来的任何时段都不会发生。它是时间轴上的一种逻辑上的不可能,在任何方向延展开去都没有可能。这就是为什么在代码中看到 这样的注释 会特别让人不安。

// this will never happen
        

所有编译器的教科书都会告诉你,这样一句注释不能也不会对编译出的代码产生任何影响。墨菲定理 告诉你并非如此,注释以下的代码一定会被触发。

那 Swift 是如何在这种无法预测的、混乱的开发过程中保证安全呢?答案难以置信:“什么都不做”,以及“崩溃”。


使用 Never 替换 @noreturn 修饰符,是由 Joe GroffSE-0102: “Remove @noreturn attribute and introduce an empty Never type” 中提出的。

在 Swift 3 之前,那些要中断执行的函数,比如 fatalError(_:file:line:)abort()exit(_:),需要使用 @noreturn 修饰符来声明,这会告诉编译器,执行完成之后不用返回到调用方。

// Swift < 3.0
        @noreturn func fatalError(_ message: () -> String = String(),
        file: StaticString = #file,
        line: UInt = #line)
        

从 Swift 3 开始,fatalError 和它的相关函数都被声明为返回 Never 类型。

// Swift >= 3.0
        func fatalError(_ message: @autoclosure () -> String = String(),
        file: StaticString = #file,
        line: UInt = #line) -> Never
        

作为一个注释的替代品,它肯定是很复杂的,对吗?NO!事实上,恰恰相反,Never 可以说是整个 Swift 标准库中最简单的一个类型:

enum Never {}
        

无实例类型(Uninhabited Types)

Never 是一个 无实例Uninhabited)类型,也就是说它没有任何值。或者换句话说,无实例类型是无法被构建的。

在 Swift 中,没有定义任何 case 的枚举是最常见的一种无实例类型。跟结构体和类不同,枚举没有初始化方法。跟协议也不同,枚举是一个具体的类型,可以包含属性、方法、泛型约束和嵌套类型。正因如此,Swift 标准库广泛使用无实例的枚举类型来做诸如 定义命名空间 以及 标识类型的含义 之类的事情。

Never 并不这样。它没有什么花哨的东西,它的特别之处就在于,它就是它自己(或者说,它什么都不是)。

试想一个返回值为无实例类型的函数:因为无实例类型没有任何值,所以这个函数无法正常的返回。(它要如何生成这个返回值呢?)所以,这个函数要么停止运行,要么无休止的一直运行下去。

消除泛型中的不可能状态

从理论角度上说,Never 确实很有意思,但它在实际应用中又能帮我们做什么呢?

做不了什么,至少在 SE-0215: Conform Never to Equatable and Hashable 推出以前,做不了什么。

Matt Diephouse 在提案中解释了为什么让这个令人费解的类型去遵守 Equatable 和其他协议:

Never 在表示不可能执行的代码方面非常有用。大部分人熟悉它,是因为它是 fatalError 等方法的返回值,但 Never 在泛型方面也非常有用。比如说,一个 Result 类型可以使用 Never 作为它的 Value,表示某种东西一直是错误的,或者使用 Never 作为它的 Error,表示某种东西一直不是错误的。

Swift 没有标准的 Result 类型,大部分情况下它们是这个样子的:

enum Result<Value, Error: Swift.Error> {
        case success(Value)
        case failure(Error)
        }
        

Result 类型被用来封装异步操作生成的返回值和异常(同步操作可以使用 throw 来返回异常)。

比如说,一个发送异步 HTTP 请求的函数可能使用 Result 类型来存储响应或错误:

func fetch(_ request: Request, completion: (Result<Response, Error>) -> Void) {
        // ...
        }
        

调用这个方法后,你可以使用 switch 来分别处理它的 .success.failure

fetch(request) { result in
        switch result {
        case .success(let value):
        print("Success: \(value)")
        case .failure(let error):
        print("Failure: \(error)")
        }
        }
        

现在假设有一个函数会在它的 completion 中永远返回成功结果:

func alwaysSucceeds(_ completion: (Result<String, Never>) -> Void) {
        completion(.success("yes!"))
        }
        

ResultError 类型指定为 Never 后,我们可以使用类型检测体系来表明失败是永远不可能发生的。这样做的好处在于,你不需要处理 .failure,Swift 可以推断出这个 switch 语句已经处理了所有情况。

alwaysSucceeds { (result) in
        switch result {
        case .success(let string):
        print(string)
        }
        }
        

下面这个例子是让 Never 遵循 Comparable 协议,这段代码把 Never 用到了极致:

extension Never: Comparable {
        public static func < (lhs: Never, rhs: Never) -> Bool {
        switch (lhs, rhs) {}
        }
        }
        

因为 Never 是一个无实例类型,所以它没有任何可能的值。所以当我们使用 switch 遍历它的 lhsrhs 时,Swift 可以确定所有的可能性都遍历了。既然所有的可能性 — 实际上这里不存在任何值 — 都返回了 Bool,那么这个方法就可以正常编译。

工整!

使用 Never 作为兜底类型

实际上,关于 Never 的 Swift Evolution 提案中已经暗示了这个类型在未来可能有更多用处:

一个无实例类型可以作为其他任意类型的子类型 — 如果某个表达式根本不可能产生任何结果,那么我们就不需要关心这个表达式的类型到底是什么。如果编译器支持这一特性,就可以实现很多有用的功能……

解包或者死亡

强制解包操作(!)是 Swift 中最具争议的部分之一。(在代码中使用这个操作符)往好了说,是有意为之(在异常时故意让程序崩溃);往坏了说,可能表示使用者没有认真思考。在缺乏其他信息的情况下,很难看出这两者的区别。

比如,下面的代码假定数组一定不为空,

let array: [Int]
        let firstIem = array.first!
        

为了避免强制解包,你可以使用带条件赋值的 guard 语句:

let array: [Int]
        guard let firstItem = array.first else {
        fatalError("array cannot be empty")
        }
        

未来,如果 Never 成为兜底类型,它就可以用在 nil-coalescing operator 表达式的右边。

// 未来的 Swift 写法? 🔮
        let firstItem = array.first ?? fatalError("array cannot be empty")
        

如果你想现在就使用这种模式,可以手动重载 ?? 运算符(但是……):

func ?? <T>(lhs: T?, rhs: @autoclosure () -> Never) -> T {
        switch lhs {
        case let value?:
        return value
        case nil:
        rhs()
        }
        }
        

在拒绝 SE-0217: Introducing the !! “Unwrap or Die” operator to the Swift Standard Library原因说明中, Joe Groff 提到,“我们发现重载 [?? for Never] 会对类型检测的性能产生难以接受的影响”。所以,不建议你在自己的代码中添加上面的代码。

表达式风格的 Throw

类似的,如果 throw 可以从语句变成一个返回 Never的表达式,你就可以在 ?? 右边使用 throw

// 未来的 Swift 写法? 🔮
        let firstItem = array.first ?? throw Error.empty
        

带类型的 Throw

继续研究下去:如果函数声明的 throw 关键字支持类型约束,那么 Never 可以用来表明某个函数绝对不会抛出异常(类似于在上面的 Result 例子):

// 未来的 Swift 写法? 🔮
        func neverThrows() throws<Never> {
        // ...
        }
        neverThrows() // 无需使用 `try` ,因为编译器保证它一定成功(可能)
        

声称某个事情永远不可能发生,就像是向整个宇宙发出邀请,来证明它是错的一样。情态逻辑(modal logic)或者信念逻辑(doxastic logic)允许保面子式的妥协(“它当时是对的,至少我是这么认为的!”),但时态逻辑(temporal logic)似乎将这个约定提到了更高的一个标准。

幸运的是,得益于最不像类型的 Never,Swift 到达了这个高标准。

密码规则 / UITextInputPasswordRules

作者 Mattt
2018年7月23日 00:00

也难怪 hipster 们着迷于工艺品和手工制品。不管是一片厚切鳄梨吐司、一瓶限量(非乳制)姜黄奶或一杯完美的手冲咖啡——其中的人情味是无法替代的。

相反,好密码和工艺品截然不同。密码应该完全没有任何意义,除非它是一个 90 年代骇客电影的标题或者一个密室逃脱游戏的答案。

有了 iOS 12 和 macOS Mojave 中的 Safari,生成可以想象到的最强、最没有意义、最难猜到的密码从未如此简单——这都要感谢一些新功能。


理想的密码策略非常简单:强制要求最少字符数(至少 8 位)并且允许长密码(64 位或者更多)。

其他更复杂的策略,像预置的安全问题、周期性的失效密码或者强制要求一些奇怪的符号,只不过让这些策略想要保护人感到厌烦。

但是不要太相信我说的话——我不是安全专家

相对的,请查看美国国家标准技术研究所最新发布(2017 年 6 月)的 Digital Identity Guidelines

好消息是越来越多的公司和组织开始注意安全性最佳实践了。坏消息则是改变这些事情需要进行一系列影响数百万人的大范围数据改动。事实上前面说到的安全性反面模式并不会很快消失,因为公司和政府做任何事情都需要花很久的时间。

自动式强密码

Safari 的自动填充从 iOS 8 起就可以生成密码了,但是它有一个缺点就是不能保证生成的密码符合某些服务的要求。

Apple 通过 iOS 12 和 macOS Mojave 里 Safari 中的自动式强密码功能来解决这个问题。

WebKit 工程师 Daniel Bates 在 3 月 1 日给 WHATWG 提交了这个提案。6 月 6 日,WebKit 团队发布了 Safari Technology Preview 58,使用新属性 passwordrules 来支持强密码生成。同时,WWDC 发布了 iOS 12 beta SDK,包括新的 UITextInputPasswordRules API,还有验证码自动输入和联合身份验证等其他一些密码管理功能。

密码规则

密码规则就像是密码生成器的配方。根据一些简单的规则,密码生成器就可以随机生成满足服务提供方需求的新密码。

密码规则由一个或多个键值对组成:

required: lower; required: upper; required: digit; allowed: ascii-printable; max-consecutive: 3;

每个规则可以指定下列键:

  • required: 需要的字符类型
  • allowed: 允许使用的字符类型
  • max-consecutive: 允许字符连续出现次数的最大值
  • minlength: 密码最小长度
  • maxlength: 密码最大长度

requiredallowed 键使用下面列出的字符类别作为值。max-consecutiveminlengthmaxlength 使用非负整数作为值。

字符类别

requiredallowed 键可以使用下面的字符类别作为值:

  • upper (A-Z)
  • lower (a-z)
  • digits (0-9)
  • special (-~!@#$%^&\*\_+=`|(){}[:;"'<>,.? ] 和空格)
  • ascii-printable (U+0020 — 007f)
  • unicode (U+0 — 10FFFF)

除了这些预置字符类别,还可以用方括号包住 ASCII 字符来指定自定义字符类别(比如 [abc])。


Apple 的 Password Rules Validation Tool 让你可以对不同的规则进行实验,并得到实时的结果反馈。甚至可以生成并下载上千个密码用来开发和测试!

Password Rules Validation Tool

更多有关于密码规则的语法,请查看 Apple 的文档「Customizing Password AutoFill Rules」


指定密码规则

在 iOS 上,给 UITextFieldpasswordRules 属性设置一个 UITextInputPasswordRules 对象(同时也应该将 textContentType 属性设置为 .newPassword):

let newPasswordTextField = UITextField()
        newPasswordTextField.textContentType = .newPassword
        newPasswordTextField.passwordRules = UITextInputPasswordRules(descriptor: "required: upper; required: lower; required: digit; max-consecutive: 2; minlength: 8;")
        

在网页上,设置 <input> 元素(且 type="password")的 passwordrules 属性:

<input type="password" passwordrules="required: upper; required: lower; required: special; max-consecutive: 3;"/>
        

如果没有指定,默认的密码规则是 allowed: ascii-printable。如果表单中有密码验证区域,它的密码规则会从上一个区域继承下来。

在 Swift 中生成密码规则

不光是只有你会觉得直接使用没有良好抽象的字符串格式令人感到不安。

下面是一种将密码规则封装成 Swift API 的方式(可以作为 Swift package 获取):

enum PasswordRule {
        enum CharacterClass {
        case upper, lower, digits, special, asciiPrintable, unicode
        case custom(Set<Character>)
        }
        case required(CharacterClass)
        case allowed(CharacterClass)
        case maxConsecutive(UInt)
        case minLength(UInt)
        case maxLength(UInt)
        }
        extension PasswordRule: CustomStringConvertible {
        var description: String {
        switch self {
        case .required(let characterClass):
        return "required: \(characterClass)"
        case .allowed(let characterClass):
        return "allowed: \(characterClass)"
        case .maxConsecutive(let length):
        return "max-consecutive: \(length)"
        case .minLength(let length):
        return "minlength: \(length)"
        case .maxLength(let length):
        return "maxlength: \(length)"
        }
        }
        }
        extension PasswordRule.CharacterClass: CustomStringConvertible {
        var description: String {
        switch self {
        case .upper: return "upper"
        case .lower: return "lower"
        case .digits: return "digits"
        case .special: return "special"
        case .asciiPrintable: return "ascii-printable"
        case .unicode: return "unicode"
        case .custom(let characters):
        return "[" + String(characters) + "]"
        }
        }
        }
        

有了这个,我们就可以在代码里指定一些规则,然后用它们生成有效的密码规则语法字符串:

let rules: [PasswordRule] = [ .required(.upper),
        .required(.lower),
        .required(.special),
        .minLength(20) ]
        let descriptor = rules.map{ "\($0.description);" }
        .joined(separator: " ")
        // "required: upper; required: lower; required: special; max-consecutive: 3;"
        

只要你愿意,你甚至可以扩展 UITextInputPasswordRules 给它添加一个接收 PasswordRule 数组的 convenience initializer。

extension UITextInputPasswordRules {
        convenience init(rules: [PasswordRule]) {
        let descriptor = rules.map{ $0.description }
        .joined(separator: "; ")
        self.init(descriptor: descriptor)
        }
        }
        

如果你是一个在个人认证信息上非常有感情的人,喜欢在密码输入区域中的小圆点后面输入你的大学、小狗或者最喜欢的运动团队,请考虑不要再这样做了。

就我个人来说,我无法想象没有密码管理器的日子。当你知道任何时候你都能访问到你需要的信息,并且只有你能访问到时,你的心灵将会获得极大的平静。

从现在开始改变,你就能完全利用上在之后今年发布的 iOS 12 和 macOS Mojave 的 Safari 中的这些改进。

guard & defer

作者 Mattt
2015年10月5日 00:00

「我们应该(聪明的程序员明白自己的局限性)尽力……让文本里的程序(program)和时间轴上的进程(process)的对应尽量简单。」

Edsger W. Dijkstra, 《Go To 有害论》

很遗憾,他的文章通常只因为使《____有害论》这种文章标题在程序员中流行起来,还有网上对这些论文不妥当的抨击出现时,才会被想起。因为 Dijkstra(照常)提出了一个很好的观点:代码结构应该反映其行为。

Swift 2.0 带来了两个新的能够简化程序和提高效率的控制流表达形式:guarddefer。前者可以让代码编写更流畅,后者能够让执行推迟。

我们应该如何使用这两个新的声明方式呢?guarddefer 将如何帮我们厘清程序和进程间的对应关系呢?

我们 defer(推迟)一下 defer 先看 guard


guard

guard 是一个要求表达式的值为 true 从而继续执行的条件语句。如果表达式为 false,则会执行必须提供的 else 分支。

func sayHello(numberOfTimes: Int) {
        guard numberOfTimes > 0 else {
        return
        }
        for _ in 1...numberOfTimes {
        print("Hello!")
        }
        }
        

guard 语句中的 else 分支必须退出当前的区域,通过使用 return 来退出函数,continue 或者 break 来退出循环,或者使用像 fatalError(_:file:line:) 这种返回 Never 的函数。

guard 语句和 optional 绑定组合在一起非常好用。在 guard 语句的条件里进行的 optional 绑定可以在函数或闭包其后的部分使用。

对比一下 guard-let 语句和 if-let 语句中的 optional 绑定:

var name: String?
        if let name = name {
        // name 在这里面不是 optional(类型是 String)
        }
        // name 在外面是 optional(类型是 String?)
        guard let name = name else {
        return
        }
        // name 从这里开始都不是 optional 了(类型是 String)
        

如果说在 Swift 1.2 中介绍的并行 optional 绑定领导了对 厄运金字塔 的革命,那么 guard 声明则与之一并将金字塔摧毁。

for imageName in imageNamesList {
        guard let image = UIImage(named: imageName)
        else { continue }
        // do something with image
        }
        

使用 guard 来避免过多的缩进和错误

我们来对比一下使用 guard 关键字之后能如何改善代码且帮助我们避免错误。

比如,我们要实现一个 readBedtimeStory() 函数:

enum StoryError: Error {
        case missing
        case illegible
        case tooScary
        }
        func readBedtimeStory() throws {
        if let url = Bundle.main.url(forResource: "book",
        withExtension: "txt")
        {
        if let data = try? Data(contentsOf: url),
        let story = String(data: data, encoding: .utf8)
        {
        if story.contains("👹") {
        throw StoryError.tooScary
        } else {
        print("Once upon a time... \(story)")
        }
        } else {
        throw StoryError.illegible
        }
        } else {
        throw StoryError.missing
        }
        }
        

要读一个睡前故事,我们需要能找到一本书,这本故事书必须要是可读的,并且故事不能太吓人(请不要让怪物出现在书的结尾,谢谢你!)。

请注意 throw 语句离检查本身有多远。你需要读完整个方法来找到如果没有 book.txt 会发生什么。

像一本好书一样,代码应该讲述一个故事:有着易懂的情节,清晰的开端、发展和结尾。(请尝试不要写太多「后现代」风格的代码。)

使用 guard 语句组织代码可以让代码读起来更加的线性:

func readBedtimeStory() throws {
        guard let url = Bundle.main.url(forResource: "book",
        withExtension: "txt")
        else {
        throw StoryError.missing
        }
        guard let data = try? Data(contentsOf: url),
        let story = String(data: data, encoding: .utf8)
        else {
        throw StoryError.illegible
        }
        if story.contains("👹") {
        throw StoryError.tooScary
        }
        print("Once upon a time ...\(story)")
        }
        

这样就好多了! 每一个错误都在相应的检查之后立刻被抛出,所以我们可以按照左手边的代码顺序来梳理工作流的顺序。

不要在 guard 中双重否定

不要滥用这个新的流程控制机制——特别是在条件表达式已经表示否定的情况下。

举个例子,如果你想要在一个字符串为空是提早退出,不要这样写:

// 啊?
        guard !string.isEmpty else {
        return
        }
        

保持简单。自然的走下去。避免双重否定。

// 噢!
        if string.isEmtpy {
        return
        }
        

defer

在错误处理方面,guard 和新的 throw 语法之间,Swift 鼓励用尽早返回错误(这也是 NSHipster 最喜欢的方式)来代替嵌套 if 的处理方式。尽早返回让处理更清晰了,但是已经被初始化(可能也正在被使用)的资源必须在返回前被处理干净。

defer 关键字为此提供了安全又简单的处理方式:声明一个 block,当前代码执行的闭包退出时会执行该 block。

看看下面这个包装了系统调用 gethostname(2) 的函数,用来返回当前系统的主机名称

import Darwin
        func currentHostName() -> String {
        let capacity = Int(NI_MAXHOST)
        let buffer = UnsafeMutablePointer<Int8>.allocate(capacity: capacity)
        guard gethostname(buffer, capacity) == 0 else {
        buffer.deallocate()
        return "localhost"
        }
        let hostname = String(cString: buffer)
        buffer.deallocate()
        return hostname
        }
        

这里有一个在最开始就创建的 UnsafeMutablePointer<UInt8> 用于存储目标数据,但是我既要在错误发生后销毁它,又要在正常流程下不再使用它时对其进行销毁。

这种设计很容易导致错误,而且不停地在做重复工作。

通过使用 defer 语句,我们可以排除潜在的错误并且简化代码:

func currentHostName() -> String {
        let capacity = Int(NI_MAXHOST)
        let buffer = UnsafeMutablePointer<Int8>.allocate(capacity: capacity)
        defer { buffer.deallocate() }
        guard gethostname(buffer, capacity) == 0 else {
        return "localhost"
        }
        return String(cString: buffer)
        }
        

尽管 defer 紧接着出现在 allocate(capacity:) 调用之后,但它要等到当前区域结束时才会被执行。多亏了 deferbuffer 才能无论在哪个点退出函数都可以被释放。

考虑在任何需要配对调用的 API 上都使用 defer,比如 allocate(capacity:) / deallocate()wait() / signal()open() / close()。这样的话,你不仅可以消除一种程序员易犯的错误,还能让 Dijkstra 自豪地用它的母语德语说:「Goed gedaan!」。

经常 defer

如果在同一个作用域内使用多个 defer 语句,它们会根据出现顺序反过来执行——像栈一样。这个反序是非常重要的细节,保证了被延迟的代码块创建时作用域内存在的东西,在代码块执行同样存在。

举个例子,执行这段代码会得到下面的输出:

func procrastinate() {
        defer { print("wash the dishes") }
        defer { print("take out the recycling") }
        defer { print("clean the refrigerator") }
        print("play videogames")
        }
        

play videogames
clean the refrigerator
take out the recycling
wash the dishes

如果你像这样嵌套 defer 语句,会怎么样?

defer { defer { print("clean the gutter") } }
        

你的第一想法可能是语句会被压入栈的最底部。但并不是这样的。仔细想一想,然后在 Playground 里验证你的猜想。

正确 defer

如果在 defer 语句中引用了一个变量,执行时会用到变量最终的值。换句话说:defer 代码块不会捕获变量当前的值。

如果你运行这段代码,你会得到下面的输出:

func flipFlop() {
        var position = "It's pronounced /ɡɪf/"
        defer { print(position) }
        position = "It's pronounced /dʒɪf/"
        defer { print(position) }
        }
        

It's pronounced /dʒɪf/
It's pronounced /dʒɪf/

仔细 defer

另一件需要注意的事情,那就是 defer 代码块无法跳出它所在的作用域。因此如你尝试调用一个会 throw 的方法,抛出的错误就无法传递到其周围的上下文。

func burnAfterReading(file url: URL) throws {
        defer { try FileManager.default.removeItem(at: url) }
        // 🛑 Errors not handled
        let string = try String(contentsOf: url)
        }
        

作为替代,你可以使用 try? 来无视掉错误,或者直接将语句移出 defer 代码块,将其放到函数的最后,正常的执行。

(其他情况下)Defer 会带来坏处

虽然 defer 像一个语法糖一样,但也要小心使用避免形成容易误解、难以阅读的代码。在某些情况下你可能会尝试用 defer 来对某些值返回之前做最后一步的处理,例如说在后置运算符 ++ 的实现中:

postfix func ++(inout x: Int) -> Int {
        let current = x
        x += 1
        return current
        }
        

在这种情况下,可以用 defer 来进行一个很另类的操作。如果能在 defer 中处理的话为什么要创建临时变量呢?

postfix func ++(inout x: Int) -> Int {
        defer { x += 1 }
        return x
        }
        

这种写法确实聪明,但这样却颠倒了函数的逻辑顺序,极大降低了代码的可读性。应该严格遵循 defer 在整个程序最后运行以释放已申请资源的原则,其他任何使用方法都可能让代码乱成一团。


「聪明的程序员明白自己的局限性」,我们必须权衡每种语言特性的好处和其成本。

类似于 guard 的新特性能让代码流程上更线性,可读性更高,就应该尽可能使用。

同样 defer 也解决了重要的问题,但是会强迫我们一定要找到它声明的地方才能追踪到其销毁的方法,因为声明方法很容易被滚动出了视野之外,所以应该尽可能遵循它出现的初衷尽可能少地使用,避免造成混淆和晦涩。

❌
❌