这篇将会讨论下 Swift 中不安全的 API。
标准库中提供了许多不同的结构,类型,协议,功能,属性等,其中少量被明确标记为不安全。
我们没办法从接口名字上直接知道安全类型和不安全类型具体的区别是什么。实际上他们的区别在于对待无效输入时的处理实现。标准库中的大多数操作在执行之前都会完全验证其输入,因此我们可以放心地假定,我们可能犯的任何严重编码错误都将可靠地捕获并报告。
这里有一个强制展开 Optional 类型的例子:
我们知道 value 一定不能是 nil,如果我们将 value 赋值为 nil,然后使用强解操作符,我们的程序会马上 crash。虽然尝试强制展开 nil 值仍然是严重的编程错误,但是因为其后果已得到很好的定义。所以我们可以说强解这个操作是“安全的”,因为我们可以很清楚的知道对于各种输入会有什么表现形式。(包括强解 nil 会 crash)。
从广义上讲,不安全的操作指的是存在于某些输入,会有着不确定的行为发生。
Optional 还通过其 "unsafelyUnwrapped" 属性提供了 "unsafe" 强制展开操作。就像常规的强制展开运算符一样,这也要求基础值必须为非零。
但是,在启用编译优化的情况下,此属性不会去校验,他信任开发者只会在非 nil 的情况下调用这个接口。如果我们不小心对 nil 调用这个属性,它可能会引发立即崩溃,或者可能返回一些垃圾值。会无法预知发生什么事情,这对调试问题来说是非常困难的。这就是一个典型的不安全类型。标准库中的不安全类型都有着这样的属性:它们是不会对输入做完全的校验的。
unsafe- 前缀就像是一个危险符号的命名约定。它警告您和任何阅读您的代码的人使用中存在着潜在危险。但是,标记为 "unsafe" 的接口仍然可以用来构建可靠运行的代码。实际上,某些任务只能使用它们来完成。只是在使用的时候需要格外小心,并且必须完全了解其使用条件。
unsafe 接口的作用
提供 swift 与 C 或者 Objective-C 相互操作的能力。
提供了对运行时性能或程序执行的其他方面上更加细致的细粒度控制。
Optional 的 unsafelyUnwrapped 属性恰好属于第二类。它省去了对值是否 nil 的多余判断(因为完全相信开发者)。这最好用应用在代码库中最重要的部分,性能测量表明,这些不必要的检查尽管成本微小,但是仍然会对性能产生不利影响。
这个地方就像以前我们在写 oc 的 setter 方法有一种观点是:
- (void)setValue:(Int)value { // if (_value != value) { 这个判断是不需要的,因为既然调用 setter 方法就是要赋值,在大多数情况下,这个判断都只是多余的执行 _value = value // } }
但是 xcode 为了让我们能及时发现这些不安全代码的潜在错误,unsafelyUnwrapped 消除了优化构建中的 nil 检查。在平时未优化的 debug 版本中,它会完全验证其输入值。确保我们对 nil 执行 unsafelyUnwrapped 时会马上终止程序。
需要注意的是,安全的 API 不是为了防止崩溃。相反的。而是当给定的输入超出其约束范围时,安全的 API 会通过引发致命的运行时错误来确保停止执行。这些情况表明存在严重的编程错误:我们的代码在关键逻辑上出现错误,我们需要马上去修复它。规避崩溃而继续执行将是不负责任的。(译者:当然在我个人实际工程开发里面,我更习惯于在核心关键代码出加上比较详细的 log 日志。)
Swift 是一种安全的编程语言时,意思是,默认情况下,它的语言和库级功能完全验证了它们的输入。对于不能做到这一点的类型,都会被标记上 "unsafe"。Swift 标准库提供了功能强大的不安全指针类型,这些类型与 C 编程语言中的指针大致处于同一抽象级别。
为了理解指针是如何工作的,我们必须谈论一些有关内存的知识。
Swift 的内存模型是平面内存模型
(相关文章:https://juejin.im/entry/59156846a22b9d0058007283)
在运行时,地址空间中是稀疏的填充着应用的数据和状态 它包括:
我们应用程序的可执行二进制文件
我们已经导入的所有库和框架;
栈区为本地和临时变量以及一些函数参数提供存储;
动态内存区域,包括类实例存储和我们手动分配的内存;
有些区域甚至可能映射到只读资源,例如图像文件。
每个单独的项目分配了一个连续的存储区域,该区域特定的位置会存储特定的某种数据。当您的应用执行时,其内存状态会不断发生改变。栈空间内不断变化,新对象被创建分配内存,旧对象被销毁。幸运的是,Swift 语言和运行时会跟踪我们的情况。我们通常不需要在 Swift 中手动管理内存。
但是,当有必要的时候。不安全的指针会为我们提供所有有效管理内存所需的低层操作。不过同时,我们需要手动的完全接管这些不安全指针的声明周期和使用中的每个细节。这些指针仅表示内存中某个位置的地址。它们提供了强大的操作,但是你必须确保正确地使用它们,这从根本上来说是不安全的。
如果不小心,指针操作可能会在整个地址空间上“乱涂乱画”,从而破坏应用程序精心维护的状态。
例如:
为整数值动态分配存储空间会为您创建一个存储位置,并为您提供直接指向它的指针。指针使您可以完全控制基础内存,但不能为您管理。以后也无法跟踪该存储位置发生了什么。它仅执行您要求执行的操作。
随着底层内存的初始化和释放,指针将失效。
但是,无效指针看起来就像是常规有效指针。指针本身不知道它已经变得无效。取消引用这种悬空指针的任何尝试都是严重的编程错误。
如果幸运的话,释放位置将使内存位置完全无法访问,而尝试访问它将导致立即崩溃。但是,这不能保证。随后的分配可能已经重用了相同的地址来存储其他值。在这种情况下,取消引用悬空指针可能会导致更严重的问题。甚至导致用户数据丢失。
当我们访问的值包含对象引用,或者内存现在包含不兼容类型的Swift值时,此类错误特别危险。
Xcode 提供了称为 Address Sanitizer 的运行时调试工具,以帮助您捕获此类内存问题。
有关此和类似 Xcode 工具的更多信息,请参见上一届会议的使用Xcode运行时工具查找错误会话。
有关如何避免指针类型安全性问题的更详细讨论,请查看Safely manage pointers in Swift
因此,如果指针是如此危险,那么为什么还要使用它们呢?嗯,一个很大的原因是与不安全的语言(例如 C 或 Objective-C )的互操作性。
C 指针类型与其对应的 Swift 不安全指针对应物之间存在直接映射。
这里有 C 语言接口映射的例子
const int 指针参数被转换为隐式解包的可选不安全指针类型 UnsafePointer!。
以下是获取此类指针的一种方法:
在 UnsafeMutablePointer 上使用静态分配方法来创建适合于保存整数值的动态缓冲区。
用指针算术和专用的初始化方法将缓冲区的元素设置为特定值。
一切都安排好之后,可以调用 C 函数,并将指针传递给初始化缓冲区。
当函数返回时,我们可以取消初始化并重新分配缓冲区,从而允许 Swift 稍后将其内存位置重新用于其他用途。
但是从根本上讲,以上每个步骤都是不安全的:
分配缓冲区的生存期不受返回指针的管理,我们必须记住在适当的时间手动将其分配,否则它将永久存在,从而导致内存泄漏。
初始化无法自动验证寻址位置是否在我们分配的缓冲区内。如果我们弄错了,我们无法预知会发生什么错误
要正确地调用该函数,我们必须知道它是否要取得基础缓冲区的所有权,也就是说,我们需要假设函数生命周期内我们仅访问这个指针,不会去保留指针,也不会去销毁它。这不是由语言强制执行的,编译器也没有做对应的检查。我们需要再函数的注释或者文档中去知道是否有这种情况。
仅当预先使用正确类型的值初始化基础内存时,取消初始化才有意义,我们只能取消先前被分配的并且已经处于析构状态的内存。
以上的每一步,如果出现错误都会导致无法预知的行为发生。
Swift 提供了四种可以让代码更清晰,并且可以轻松地检查缓冲区越界访问的类型。
每当我们需要使用内存区域,而不是指向单个值的指针时,这些便会派上用场。
例如我们如果要使用到数组的情况。
通过将区域的大小及其位置包括在一个不错的作用域中,它们让您更仔细地管理内存。在未优化的调试版本中,这些缓冲区指针通过其下标操作检查越界访问,从而提高了安全性。将长度和地址作为一个单元来考虑能防止一些简单的错误。
Swift 的标准连续集合使用这些方便的接口去临时直接访问他的基础存储缓冲区。
这些生存的临时指针只会在闭包中有效。
通过这些接口,我们能优化代码将不安全的操作隔离到最小的代码段中。
摆脱手动内存管理的需求,我们可以将输入数据存储在Array值中。
然后,我们可以使用 withUnsafeBufferPointer 方法来临时直接访问数组的基础存储。在闭包中,我们传递给此函数,我们可以提取起始地址和计数值,并将它们直接传递给我们要调用的C函数。
因为传递指向 C 函数的指针的需求是非常频繁的,所以 Swift 为它提供了特殊的语法。
我们可以简单地将数组值传递给需要不安全指针的函数,编译器将自动为我们生成等效的 withUnsafeBufferPointer。但是请时刻记住:指针仅在函数调用期间有效。
如果函数转义了指针并尝试稍后访问底层内存,则无论我们使用哪种语法获取指针,都将导致未定义的行为。
这是 Swift 支持的此类隐式值到指针转换的列表。
Swift 支持的此类隐式值到指针转换的列表
如我们所见,要将 swift 数组的内容传递给 C 函数,我们只需传递数组值本身即可。
如果函数想要修改元素,我们可以用 inout 的引用数组来获得可变的指针。
可以通过直接传递 Swift String 值来调用采用 C 字符串的函数-该字符串将产生一个临时 C 字符串,包括所有重要的终止 NUL 字符。
如果 C 函数只希望有一个指向单个值的指针,则可以使用对相应 Swift 值的输入-输出引用来获取合适的指向它的临时指针。
例如,这是 Darwin 模块提供的 C 函数,可用于查询或更新有关当前系统的底层信息。它带有六个参数。
这接口看起来有点吓人。但是,从 Swift 调用此函数不一定像在 C 语言中那么复杂。在这里,隐式指针转换能让我们的代码在看起来和当前其他 Swift 代码没什么区别。
例如,在这里我们要创建一个函数,查询正在运行的 CPU 的 CPU Cache 中的最小缓存单位的大小。
A
sysctl 的文档描述了,该信息在硬件部分的标识符 “CACHELINE” 下可以查询到。
要将这个 ID 传递给 sysctl,我们可以使用隐式 Array 到指针的转换,并使用显式的整数转换来计数。
创建一个结果值,最后会被 sysctil 函数设置为最终结果。
我们要检索的信息是一个 C 整数值,因此我们使用 MemoryLayout 创建了一个局部整数变量,并通过另一个 inout-to-pointer 转换为第三个参数生成了指向它的临时指针。
该函数将从该指针开始将缓存行的大小复制到缓冲区中,并用另一个整数覆盖我们的原始零值。
第四个参数是指向此缓冲区大小的指针,我们可以从相应整数类型的 MemoryLayout 中获得该指针。返回时,函数将将此值设置为其复制到“结果”中的字节数。
因为我们只想检索当前值,而不要设置它,所以我们为“新值”缓冲区提供了零值,并将其大小设置为零。
如果 sysctl 被正常成功调用,那么函数的返回值会是 0。
同样,我们希望该调用设置的字节数与 C 整数值中的字节数相同。
最后,我们可以将 C 整数转换为 Swift Int,然后返回结果。
10.在大多数平台上,缓存行的宽度为 64 字节。
可以注意到,以上就是这些不同的不安全操作是各自分开的放到单个函数里面的。
当然,因为我们也可以选择将此代码扩展为基于显式闭包的调用。
B
该代码在功能上等同于我们的一开始的版本;两种样式之间的选择主要取决于个人代码品味。老实说,在这种情况下,我更喜欢前者(A),既整体代码较为简短的。但是,无论选择哪个版本,我们都需要始终意识到,这些值是临时生成的,并且在函数返回时它们将失效。在纯 Swift 代码中,我们不需要频繁地传递指针,因此通过偏爱使用基于闭包的 API 来突出显示此类情况时很有意义。虽然代码可能会变得冗长,但这能更明确的使你知道当前正在发生什么事情。特别是,它们基于闭包的设计使生成的指针的实际生存期更加明确,从而帮助您避免出现生存期问题,例如这种无效的指针转换。
将临时指针传递给可变指针构造方法,会将其值转出初始值设定项调用。访问结果悬垂指针值是未定义的行为:底层内存位置可能不再存在,或者可能已被其他值重用。为了帮助捕获此类错误,Swift 5.3 编译器现在可以在检测到此类情况时发出有用的警告。
另一项改进是,Swift 标准库现在提供了新的构造方法,该构造方法允许我们通过将数据直接复制到其底层未初始化的存储区中来创建 Array 或 String 值。
https://developer.apple.com/documentation/swift/array/3200717-init
https://developer.apple.com/documentation/swift/string/3565957-init
这消除了仅为准备此类数据而分配临时缓冲区的需要。
例如,可以使用 String 的构造方法调用相同的 sysctl 函数以检索字符串值。
在这里,我们要查找正在运行的操作系统的内核版本,该内核版本由“内核”部分的 “VERSION” 条目标识。
与 CACHE 行示例不同,我们事先不知道版本字符串的大小。因此,为了弄清楚这一点,我们需要调用两次 sysctl 函数。
首先,我们使用 nil 输出缓冲区调用该函数。sysctl 函数返回时,会将 'length' 变量设置为存储字符串所需的字节数。
像以前一样,我们需要记住检查所有报告的错误。
有了结果的大小,我们现在可以要求 String 为我们准备未初始化的存储,以便我们可以获取实际数据。初始化程序为我们提供了一个可以通过 sysctl 函数传递的缓冲点。
该函数将 Version 字符串直接复制到此缓冲区中。
返回时,我们验证函数调用是否成功。
我们再次检查该函数是否确实将某些字节复制到缓冲区。
并且最后一个字节为零,对应于终止 C 字符串的 NUL 字符。
这个 NUL 字符不是 Version 字符串的一部分,因此我们通过返回比复制的字节数少一个的字符来丢弃这个 NUL 字符。
这会向 String 确切指示我们已复制到其存储中的 UTF 8 数据字节数。
通过使用此新的 String 初始化程序,我们摆脱了手动内存管理。
我们可以直接访问一个缓冲区,该缓冲区最终将成为常规 Swift 字符串实例的存储,同时我们不需要手动分配或取消分配内存。
当我们调用此函数时,我们就可以获得我们想要的版本字符串。
因此,正如我们所看到的,我们可以使用标准库的不安全API优雅地解决这些比较棘手的和C语言相互操作的难题。
使用时满足每一个不安全接口的要求。
总而言之,要有效地使用不安全的 API,您需要了解它们所期望的参数和使用条件,并且务必满足他的条件,否则您的代码将会出现无法预知的行为表现。
隔离对不安全接口的使用
将不安全的 API 使用量降至最低是一个非常好的方法。
尽可能选择更安全的替代方案总是一个好主意。
使用 UnsafeBufferPointer 作为内存缓冲区。
当使用一个包含多个元素的内存区域时,最好使用不安全的缓冲区指针而不只是指针值来跟踪其边界。
Xcode 提供了一套出色的工具,可帮助调试有关我们如何使用安全 API 的问题,包括 Address Sanitizer。在将代码投入生产之前,可以使用它们来识别代码中的错误,并调试可能已经发现的问题。
参考文档:https://developer.apple.com/documentation/swift/unsafebufferpointer