WWDC20 第六弹 - “不安全”的Swift

2,314 阅读13分钟
Apple用了两个Session来讲Swift代码中的安全性,这可能是很多初中级的开发者并不会关注的知识点(可能用的也比较少),但了解确实很有必要,尤其是想成为一个高级开发者,那么这一弹我们继续跟随Swift deep dive来学习~

到底是什么让代码“不安全”?了解编程语言的安全预防措施——以及何时需要接触不安全操作。我们将研究非正确使用会导致API出现什么意外状态,以及如何编写更具体的代码来避免未定义的行为。了解如何使用使用指针的C APIs,以及使用Swift中不安全指针的API要采取的步骤。

不安全的API

Swift提供了许多不同的classstructprotocolpropertyfunction等,其中少量被明确标记为不安全,其中一个很明显的前缀即Unsafe

从字面上,我们能很明确的知道它“不安全”,但是一眼看过去并不知道它和不安全类型具体的区别是什么——实际上他们的区别在于对待无效输入时的处理实现。标准库中的大多数操作在执行之前都会完全验证它们的输入,因此我们可以安全地假设,我们可能犯下的任何严重的编码错误都会被可靠地捕获和报告。

什么是“安全”和“不安全”?

例如下面这个强解optional值的代码:

let value: Int? = nil

print(value!) // Fatal error: Unexpectedly found nil while unwrapping an Optional value

强解nil会造成crash,这是一个严重的编程错误(由于人为的不可靠性,团队中我们是明令禁止使用强解),但是它造成的后果是明确且已定义好的,因此我们说强解是“安全的”,因为我们可以很清楚的知道对于各种输入会有什么的结果。

由此我们可以知道,所谓的“安全”并不是指无论如何不会crash,而是指所有的输入是否有定义好的结果:

而那些“不安全”的操作,就是指会存在一些输入导致不确定的结果(并不仅仅是crash):

所以我们要很明确苹果爸爸对于两者的定义:

Unsafe操作

unsafelyUnwrapped为例,它提供了一个“不安全”的强解操作:

let value: String? = "Hello"

print(value.unsafelyUnwrapped) // Hello

它与!类似,因此value也必须是非nil值。但在启用编译优化的情况下,它不会去校验是否为nil,会认为开发者已经做出了保证。如果我们不小心对nil调用这个属性,它可能会引发立即崩溃,或者可能返回一些垃圾值。由于无法确定会发生什么事情,因此调试是很困难的。这就是一个典型的“不安全”类型。

标准库中的“不安全”类型都有着这样的特性:它们都有不愿意或没办法充分验证的假设。

unsafe 前缀就像是一个危险符号,警告开发者使用存在潜在危险。实际上,某些任务只能使用它们来完成,因此它并非禁止使用,只是在使用的时候需要格外小心,并且必须完全了解其使用条件。

存在的意义

刚才我们提到,某些任务只能使用unsafe完成:

  1. 提供与 C 或者 Objective-C 相互操作
  2. 提供了对运行时性能或程序执行的其他方面的细粒度控制

unsafelyUnwrapped属性就属于第二类。它省去了对值是否nil的多余判断,因为从性能测量表明,这些不必要的检查尽管成本微小,但是仍然会对性能产生不利影响。

需要注意的是,安全api的目标不是防止崩溃。恰恰相反:当在给定约束之外为它们提供输入时,安全api通过引发致命的运行时错误来保证停止执行。用于提示开发者马上修复。

我们说Swift是一种安全的编程语言,是因为它的语言和功能库都完全验证了它们的输入,做不到这一点的都会被标记上unsafe。Swift标准库提供了功能强大的“不安全”指针类型,这些类型与C语言中的指针大致处于同一抽象级别。

指针是如何工作的?

为了方便理解指针如何工作,我们必须讨论下内存。

Swift有一个扁平内存模型:它将内存视为可单独寻址的8位字节的线性地址空间。每个字节都有自己的唯一地址,通常以十六进制整数值打印。

在运行时,地址空间中填充着少量的反映应用程序在任何给定时刻的执行状态的数据。其中包括:

  1. 可执行二进制文件;
  2. 导入的所有库和框架;
  3. 堆栈为本地和临时变量以及一些函数参数提供存储;
  4. 动态内存区域,包括类实例存储和开发者手动分配的内存;
  5. 有些区域甚至可能映射到只读资源,如图像文件。

每个单独的项目工程都分配了一个连续的存储区域,该区域特定的位置会存储特定的某种数据。当应用执行时,其内存状态会不断发生改变。栈空间内不断变化,新对象被创建分配内存,旧对象被销毁。但幸运的是,我们通常不需要在Swift中手动管理内存。

但是当我们确实需要这样做时,“不安全”的指针为我们提供了有效管理内存所需的所有低级操作。当然,使用它们伴随而来的就是风险,这些指针只是表示内存中某个位置的地址。它们提供了强大的操作,但必须依赖开发者正确地使用,从根本上说是“不安全”的。

如果不小心,指针操作可能会在整个地址空间上乱涂乱画,从而破坏应用程序精心维护的状态。

例如下面这个例子:

我们为整数值动态分配存储空间,创建了一个存储位,并提供指向该位置的直接指针。当底层内存被释放时,指针将失效,但它本身并不知道自己已经失效,此时尝试访问它将导致崩溃。

此外,随后的分配可能会使用该存储位来存储其他值,在这种情况下取消对悬挂指针的引用可能会导致更严重的问题。

Xcode提供了称为Address Sanitizer的运行时调试工具,用来帮助捕获此类内存问题,详情可参看Finding Bugs Using Xcode Runtime Tools

另外有关如何避免指针类型安全性问题的更详细讨论,可参看Safely manage pointers in Swift

与 C 或者 Objective-C 相互操作

我们之所以要使用这么危险的指针,其中一个重要的原因就是与 C 或者 Objective-C 相互操作

直接映射

在 C 或者 Objective-C中,函数通常采用指针参数,为了能够从Swift调用它们,开发者需要知道如何生成指向Swift值的指针。事实上,C指针类型和它们对应的Swift不安全指针类型之间存在直接映射。

底层实现

例如下面这个例子:

// C:
void process_integers(const int *start, size_t count);

// Swift:
func process_integers(_ start: UnsafePointer<CInt>!, _ count: Int)

当这个C函数被引入到Swift时,const int pointer参数被转换为隐式强解的可选不安全指针类型。

其Swift的底层实现为:

// 1.对UnsafeMutablePointer使用静态allocate方法来创建一个适合保存整数值的动态缓冲区
let start = UnsafeMutablePointer<CInt>.allocate(capacity: 4)

// 2.使用指针算法和专用的初始化方法将缓冲区的元素设置为特定的值
start.initialize(to: 0)
(start + 1).initialize(to: 2)
(start + 2).initialize(to: 4)
(start + 3).initialize(to: 6)

// 3.调用C函数,把指向初始化缓冲区的指针传递给它
process_integers(start, 4)

// 4.当函数返回时,我们可以取消初始化并释放缓冲区,允许Swift在以后将其内存位置重新用于其他用途
start.deinitialize(count: 4)
start.deallocate()

但实际上面的每一步都是不安全的:

  1. 分配的缓冲区的生存期不是由返回指针管理的。我们必须记住在适当的时候手动释放它,否则它将永远存在,导致内存泄漏。
  2. 初始化无法自动验证地址位置是否在我们分配的缓冲区内。如果我们弄错了,我们会得到未定义的行为。
  3. 为了正确地调用函数,我们必须知道它是否将获得底层缓冲区的所有权。在本例中,我们假设只是在函数调用期间访问它,既不持有指针,也不尝试释放指针。这不是语言强制执行的;只能在函数的说明文档中查询是否存在这种情况。
  4. 只有在基础内存之前已使用正确类型的值初始化时,取消初始化才有意义。同时,必须只释放先前分配的、处于非初始化状态的内存。

在每一步,都有未经检查的假设,任何一个错误都会导致未知的行为。

Swift的优化支持

当我们需要处理内存区域而不是指向单个值的指针时,标准库提供了四种“不安全”的缓冲区指针类型,从而方便的获取缓冲区的边界。

例如,Swift的标准连续集合使用这些缓冲区指针,通过这些方便但“不安全”的方法提供对其底层存储缓冲区的临时直接访问:

也可以获得一个指向单个Swift值的临时指针,然后我们可以将它传递给期望这样的C函数:

这些临时指针的生命周期仅仅存在于当前的闭包里。

例如我们通过下面这些方法来简化代码,将不安全操作隔离到尽可能小的代码部分:

// C:
void process_integers(const int *start, size_t count);

// Swift:
// 1.为了摆脱手动内存管理的需要,我们可以将输入数据存储在数组值中
let values: [CInt] = [0, 2, 4, 6]

// 2.然后我们可以使用withUnsafeBufferPointer方法临时直接访问数组的底层存储
values.withUnsafeBufferPointer { buffer in
  // 3.在闭包中,我们可以获取起始地址和长度,并将它们直接传递给我们要调用的C函数
  print_integers(buffer.baseAddress!, buffer.count)
}

事实上,传递指向C函数的指针的需求是非常频繁的,因此Swift为它提供了特殊的语法。

我们可以简单地将数组值传递给需要不安全指针的函数,编译器将自动为我们生成等效的withUnsafeBufferPointer,而不用开发者手动进行上面的操作。但要记住:指针仅在函数调用期间有效。

如果指针尝试从函数中逃逸并尝试访问底层内存,无论我们使用何种语法获取指针使其逃逸,都将导致不可知的后果。

下面是Swift支持的这种隐式值到指针转换的列表:

从图中依次可以了解到:

  1. 要将Swift数组的内容传递给C函数,我们只需传入数组值本身。
  2. 如果函数想要改变元素,我们可以传递一个对数组的inout引用来获得一个可变指针。
  3. 可以通过直接传入Swift字符串值来调用接受C字符串的函数:该字符串将生成一个临时C字符串,包括最重要的终止NULL字符。
  4. 如果C函数只需要一个指向单个值的指针,我们可以使用对相应Swift值的inout引用来获得指向它的合适的临时指针。

运行时性能或程序执行的其他方面的细粒度控制

仔细小心地使用上面的特性,我们甚至可以调用最复杂的C接口。

例如Darwin模块提供的C函数,可用于查询或更新有关当前系统的底层信息:

这个C函数有6个参数,看起来很吓人。然而,从Swift调用这个函数并不一定像在C语言中那样复杂。在这里使用隐式指针转换能达到很好的效果,从而表面上看起来与其他原生语法差别不大。

例如,这里我们要创建一个函数,用来查询我们正在运行的CPU架构中一个缓存块的大小:

关于这个例子的详细解释,可以在Unsafe Swift中16:30开始查看,这里就不赘述了。

当然,因为我们也可以选择将此代码扩展为基于显式闭包的调用:

两段代码在功能上是等同的,选择哪种主要取决于个人喜好。无论选择哪个版本,从前文我们了解到,这些指针是临时生成的,并且在函数返回时它们将失效。在纯Swift代码中,通过后面这种基于闭包的方式更容易让开发者注意到这一点。

其他的一些改进

临时指针警告

从上面我们可以知道,基于闭包的设计能更明确的掌握指针的生命周期,从而避免出现相关问题,例如这个无效的指针转换:

p的生命周期仅仅存在于当前闭包,但开发者可能不了解并在闭包结束后尝试访问它,此时底层内存位置可能已不存在,或者可能已被重新存储其他值。为了帮助捕捉这类错误,swift5.3编译器现在可以检测到此类情况时生成一个有用的警告。

新的构造方法

Swift标准库现在提供了新的构造方法,支持我们直接将数据直接复制到底层未初始化的存储区中来创建ArrayString。这样就不再需要只为初始化此类数据而分配临时缓冲区。

例如下面这个例子,我们需要找出正在运行的操作系统的内核版本,它由内核部分的version标识。

同样的,这个例子的详细解释可以在Unsafe Swift中20:00开始查看。(好吧,我承认是因为懒😑)

总结

从这一章我们可以知道:

  1. 我们可以使用标准库的“不安全”API优雅地解决即使是最棘手的相处操作的难题。
  2. 知道它们的缺陷,才能有效地使用这些“不安全”的API,否则代码将存在未定义的行为。
  3. 尽可能的控制对“不安全”API的使用,尽量选择安全的替代品。
  4. 当处理一个包含多个元素的内存区域时,最好使用“不安全”的缓冲区指针(而不仅仅是指针值)来跟踪其边界。
  5. Xcode提供了一套很好的工具来帮助调试我们如何使用不安全API的问题,包括Address Sanitizer,在上线之前,使用它们来识别代码中的错误。

相关Session

原创不易,文章有任何错误,欢迎批(feng)评(kuang)指(diao)教(wo),顺手点个赞👍,不甚感激!