1d9af2ef276769e2343522bd496ca402
WWDC20 10167 - 在 Swift 里安全管理指针

本文基于 WWDC20 - Safely manage pointers in Swift

在 Swift 中,我们通过 Unsafe 前缀来标识那些输入后可能发生未定义行为的操作,详情可以回顾 WWDC20 - Unsafe Swift。而本文则会更深入地探讨在非安全范围内编写 Swift 的一些细节,日常开发中比较少接触到的部分。

想要更安全地管理指针,意味着需要了解各种导致不安全的方式。指针的安全性可以分为不同级别来讨论,越往底层,程序员越需要为代码的正确性负责。所以日常开发中建议尽量使用顶层的 API 编写代码。

安全级别

安全性可以被分为四个级别。

  1. 最顶层的是安全级别,Swift 的主要目标之一就是无需任何不安全的数据结构就能编写代码。Swift 拥有健壮的类型系统,提供了强大的灵活性和性能。完全不使用指针对于代码安全来说是一个很好的选择。

  2. 但是 Swift 另一个重要目标是和不安全语言的高性能互操性。所以在第二层 Swift 提供了前缀为 Unsafe 的类型或函数。UnsafePointer<T> 可以在不用担心类型安全的情况下使用指针。

  3. 如果需要使用原始内存作为字节序处理,那么就要使用第三层的 UnsafeRawPointer,使用它来加载和存储值到原始内存中,需要熟悉类型的内存布局。

  4. 在最底层中,Swift 提供了绑定内存类型的内存绑定 API,只有在这一层才需要完全保证指针的类型安全。

注意,安全代码并不一定意味着正确代码,但它的行为是可预测的。大多数情况下,编译器会捕获代码导致的不可预测行为。对于编译时无法捕获的错误,运行时会检查并让程序立刻崩溃并给出诊断。安全代码实际上增强了错误的表现。在线程安全且不使用 Unsafe 前缀 API 的情况下,代码会强制产生可预测行为。

反之,在不安全的 Swift 代码中,可预测行为并不是完全强制的,所以需要程序员承担额外的风险。Xcode 的测试提供了一些有用的诊断,但诊断的级别取决于选择的安全级别。标准库中的不安全 API 可以通过断言和调试编译来捕获一些无效输入。添加先决条件来验证不安全的假设也是一个不错的实践。还可以通过 Xcode 的 Address Sanitizer 在运行时检查,但它也无法捕获所有的未定义行为。如果测试期间没有发现错误,则可能在运行时发生难以调试的崩溃,或者更可怕的是执行错误行为导致用户数据被破坏。

指针安全

Swift 设计上是无须指针的编程语言,了解指针的安全性能更清楚为何应该避免使用它们。同时,如果确实需要使用底层 API 来访问内存,这块知识也是值得掌握的。

生命周期

在需要指向变量的存储空间、数组元素或者直接分配的内存时,需要稳定的内存位置。但这块稳定的存储空间生命周期是有限的。可能因为它超出了作用域,也可能你需要直接分配内存,就会导致超出了生命周期。然而,你使用的指针有着自己的生命周期,当你的指针的生命周期超过对应存储空间的生命周期时,指针访问就会变成未定义行为。这是指针不安全的主要原因,但不是唯一原因。

对象边界

对象可以由一组元素组成。指针可以通过偏移来访问不同的内存地址,这是一种处理不同元素的地址的有效方式。但是偏移的过大或过小都会导致访问的不是对应的对象。指针访问超过对象边界的行为也是未定义的。

指针类型

还有一个方面的安全问题容易被忽视,指针本身的类型和内存里的值类型不一致。比如本来有一个指向 Int16 的指针,当内存区域被覆盖为 Int32 时,访问 Int16 旧指针就会产生未定义行为。

下面有一个非常不安全的例子,你可能会被从 C 移植而来的 Swift 代码调用部分旧 C 代码的样子吓到。

struct Image {
    // ...
}

// 未定义行为可能导致数据丢失
struct Collage {
    var imageData: UnsafeMutablePointer<Image>?
    var imageCount: Int = 0
}

// C 风格的 API 需要 Int 的指针传入 
func addImages(_ countPtr: UnsafeMutablePointer<UInt32>) -> UnsafeMutablePointer<Image> {
    // ...
    let imageData = UnsafeMutablePointer<Image>.allocate(capacity: 1)
    imageData[0] = Image()
    countPtr.pointee += 1
    return imageData
}

func saveImages(_ imageData: UnsafeMutablePointer<Image>, _ count: Int) {
    // 随便执行些什么
    print(count)
}

var collage = Collage()
collage.imageData = withUnsafeMutablePointer(to: &collage.imageCount) {
      // 注意这行,创建了指针,但类型不匹配
    addImages(UnsafeMutableRawPointer($0).assumingMemoryBound(to: UInt32.self))
}
saveImages(collage.imageData!, collage.imageCount) // 可能发生 imageCount == 0

addImages(:) 调用时将图像数据写入并更新图像数量,Collage 结构中的 imageCount 是 Int,但 addImages(:) 实参需要的是 UInt32。安全的做法是创建匹配的新变量并使用 Swift 的整数转换。然而这里直接创建了指向结构体的指针,那么在之后运行时读取这个数量时,就可能为 0。这里的不同类型告诉了编译器这两个值会属于不同的内存对象中,所以编译器不会更新 Int 的值。也就是说编译器会根据类型信息进行假设,一旦假设有误,会蔓延到编译管道中,最后可能产生意料之外的结果。不同版本的编译器也可能导致不同的结果。

指针类型导致的 Bug:

  • 可能导致意料之外的行为
  • 可能长时间难以被发现
  • 可能在意外的时间爆发
    • 在看起来无害的源码改动后
    • 编译器升级后

C 指针有着 严格别名类型双关 规则。幸运的是,不需要了解这些规则也能在 Swift 中安全地使用指针。Swift 指针因为需要传递到 C,所以至少和 C 一样严格以便安全地互操作。

UnsafePointer<T>:类型指针

UnsafePointer<T> 是类型指针,提供了 C 指针大部分底层功能,且不需要担心指针类型安全问题,只需要管理对象的生命周期和对象边界就好。

范型参数 T 表示存储在内存里的期望类型,Swift 对于类型指针是严格但简单的。内存状态包括该内存地址对应的类型,该内存位置只能保存该类型的值。类型指针只读写该类型的值。在 C 中转换指针类型的情况并不少见,且两个指针都继续引用同一内存。在 Swift 中,访问指针类型和内存类型不匹配的指针会产生未定义行为,所以不允许转换指针。这样,编译时强制使用该指针类型,而不需要在内存中储存额外的运行时信息或类型信息,也无须执行额外的运行时检查。

复合类型也是类似的,里面的结构以正确的类型绑定。

获取 UnsafePointer<T> 的方式有两种。

1. 通过已有变量获取

let i: Int
withUnsafePointer(to: i) { (intPtr: UnsafePointer<Int>) in
        // ...
}

let array: [T]
array.withUnsafeBufferPointer { (elmentPtr: UnsafeBufferPointer<T>) in
     //...
}

这样获取的指针类型就和原来变量的类型一致。数组则返回数组元素类型的指针。

2. 直接分配内存

let tPtr = UnsafeMutablePointer<T>.allocate(capacity: count)
tPtr.initialize(repeating: t, count: count)
tPtr.assign(repeating: t, count: count)

tPtr.deinitialize(count: count)
tPtr.deallocate()

直接分配内存将绑定类型,返回一个类型指针,但这时还未构造,可以通过 initalzize 进行构造,assgin 进行重新分配,deinitialize 进行析构。Swift 会保证这一过程的指针类型安全,而内存构造状态由程序员来管理。

UnsafeRawPointer :原始指针

如果需要将内存里的字节转换成其他类型,则需要使用无类型的 UnsafeRawPointer。原始指针会忽略内存绑定类型。

获取 UnsafeRawPointer 的方式有两种。

1. 通过已有 UnsafePointer<T> 获取

可以传入 UnsafePointer<T> 来构造 UnsafeRawPointer

let p: UnsafePointer<T>
let r = UnsafeRawPointer(p)

然后通过 load(as:) 来指定读取类型所对应的字节数。例如,指定 UInt32 时,就会加载当前地址前 4 个字节,生成 UInt32 值。

写入通过 storeBytes(of:as:) 并指定类型。和类型指针不一样,原始指针不会析构先前在内存里的值。所以之前的引用依旧有效。比如给 Int64 内存区域的写入 UInt32 字节,写入后依旧是 Int64 绑定类型。因此原来的类型指针依旧可以访问,而不会被自动转换。

2. 通过已有变量获取

var i: Int
withUnsafeBytes(of: i) { (iBytes: UnsafeRawBufferPointer) in
    //...
}


withUnsafeMutableBytes(of: &i) { (xBytes: UnsafeMutableRawBufferPointer)  in
    //...
}


let array: [T]
array.withUnsafeBytes { (elementBytes: UnsafeRawBufferPointer) in
    //...
}

获取到的 UnsafeRawBufferPointerUnsafeBufferPointer<T> 类似,都是字节的集合。count 是变量类型的内存大小,索引是字节的偏移量,索引的值是对应字节的 UInt 值。

修改可以通过 withUnsafeMutableBytes 的方式获取 UnsafeMutableRawBufferPointer 进行修改。

数组也有类似的方法,count 对应的是数组数量x元素 跨步。其中一些字节会被填充用于元素对齐。

3. 从 Data 中获取

Foundation 中的 DatawithUnsafeBytes 方法,通过闭包返回原始指针。

import Foundation

func readUInt32(data: Data) -> UInt32 {
    data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in
        buffer.load(fromByteOffset: 4, as: UInt32.self)
    }
}

let data = Data(Array<UInt8>([0, 0, 0, 0, 1, 0, 0, 0]))
print(readUInt32(data: data))

4. 直接分配内存

let rawPtr = UnsafeMutableRawPointer.allocate(
            byteCount: MemoryLayout<T>.stride * numValues,
            alignment: MemoryLayout<T>.alignment)
let tPtr = rawPtr.initializeMemory(as: T.self, repeating: t, count: numValues)
// 必须使用类型指针 ‘tPtr’ 进行析构

直接分配内存需要负责计算内存大小和字节对齐方式。分配后和类型指针不一样,不会绑定类型,也没有进行构造。通过指定内存绑定的值和类型进行构造,就会返回类型指针。这个过程是单向的,所以没法使用原始指针进行析构,而要通过类型指针。而使用原始指针释放时需要保证它处于未构造的状态。

top Created with Sketch.