WWDC笔记——在Swift中安全管理指针

526 阅读13分钟

翻译地址

看视频

跟随我们深入研究Swift中不安全的指针类型。发现每种类型的要求以及正确使用方法。我们将讨论类型化的指针,下拉到原始指针,最后通过绑定内存完全规避指针类型的安全性。该会话是WWDC20的“不安全Swift”的后续。为了充分利用它,您应该熟悉Swift和C编程语言。 为了充分利用它,您应该熟悉Swift和C编程语言。

您至少应该具备以下预备知识来理解此会话:C编程语言,C中的指针和数组算术,位级中的数据表示形式和运行时内存。

我建议您阅读笔记并再次观看本节。

该主题不是应用程序开发人员通常需要担心的那种细节。

安全地管理指针意味着了解所有不安全的指针。

安全等级

安全的Swift代码

  • 安全代码不一定是正确的代码,但它的行为可以预测。
  • 如果编程错误可能导致不可预测的行为,则编译器将捕获该错误。
  • 运行时检查确保错误将使程序立即崩溃。
  • 安全代码实际上与错误执行有关。

不安全的Swift代码

  • 测试可以提供有用的诊断,但要取决于安全级别。
  • 不安全的标准库API具有断言和调试版本,它们会捕获某些种类的无效输入。
  • 添加自己的前提条件以验证不安全的假设是一个好习惯。
  • Sanitizer Diagnostics非常适合查明错误,但不能捕获所有未定义的行为。
  • 如果在测试期间未发现错误,则可能导致意外的运行时行为,最糟糕的是损坏或丢失数据。

指针安全

指针的不安全原因1

  • 在创建指针之前,它需要一个稳定的内存位置。
  • 稳定的内存位置的生命周期有限-内存位置可能不在当前堆栈帧中,或者直接将内存释放。
  • 如果指针访问无效的内存地址,则任何行为都是不确定的。

指针的不安全原因2

  • 一个内存位置可以容纳多个对象。例如,一个64位存储器可以容纳两个Int32类型的对象。
  • 通过向指针添加偏移量,允许指针移动到不同的存储器地址。
  • 给指针增加或减少太大,可能会访问其他对象。
  • 未定义访问超出其对象边界的指针。

指针的不安全原因3

  • 指针有自己的类型,它们与内存中的值类型不同。
  • 如果我们拥有的指针是的类型Int16,那么我们将覆盖存储位置以存储Int32对象,则指针类型将不一致。
  • 访问类型的旧指针Int16是未定义的行为。

指针类型错误

  • 不同版本的编译器可能导致不同的程序行为。
  • 可能导致意外的行为。
  • 可能会长时间隐藏。
  • 可能会在令人惊讶的时间曝光:
    • 通过安全的源更改。
    • 通过编译器更新。

Swift类型安全指针

Swift和C的指针类型规则

  • C具有“严格别名”和“类型校正”的规则。
  • 无需了解C规则即可安全地使用Swift指针。
  • Swift指针可以安全地与C互操作,因为它们至少和C指针一样安全。
  • 作为交换,您需要对对象生存期和对象边界负责。
  • 您可以在Unsafe Swift讲座中了解更多信息。

UnsafePointer<T>是一个类型化的指针

  • 在C语言中,常见的是将指针转换为不同的类型,而两个指针继续引用同一内存。
  • UnsafePointer<T> 只从内存中读取该类型的值。
  • UnsafeMutablePointer<T> 仅读取或写入该类型的值。
  • 在Swift中,访问类型参数与其内存位置的绑定类型不匹配的指针是未定义的行为。
  • 指针类型由Swift的类型系统在编译时强制执行。

指向变量的指针

  • 声明一个int类型的变量,然后要求一个指针,将获得一个指向int的指针。

指向数组的指针

  • 数组存储绑定到数组元素类型。
  • 向数组存储请求指针将返回指向数组元素类型的指针。

类型安全的直接内存分配

func directAllocation<T>(t: T, count: Int) {
    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()
}

  • 通过在上调用静态分配方法直接分配内存UnsafeMutablePointer
  • 分配将内存绑定到其类型参数,并返回指向新内存的类型化指针。
  • 使用指针仅将内存初始化为正确的类型。
  • 在初始化状态下,可以重新分配内存。
  • 使用相同类型的指针取消初始化内存。这样就可以安全地进行分配了。

内存中的复合类型

  • 通常不会有两个不同类型的指向相同内存位置的活动指针。
  • 它既可以获取指向外部结构的指针,也可以获取指向其属性的指针,它们都同时有效。

斯威夫特原始指针

  • UnsafeRawPointer 使您可以引用字节序列而不指定类型。
  • 您可以控制内存布局。

加载字节UnsafeRawPointer

  • 它能够将字节解释为类型化的值。
  • 从类型化的指针向下转换为原始指针始终是可能的。
  • 对原始指针的操作只能看到内存中字节的顺序。
  • 可以要求原始指针加载任何类型。

  • 拨打.load(as: UInt32.self)一个Int64指针。
  • 它从内存位置加载低4个字节。
  • 然后,它将4个字节解释为一个UInt32值。
  • 从两个的补码到一个无符号数。

用以下方式存储字节 UnsafeMutableRawPointer

  • 存储字节与加载不对称,因为它会修改内存中的值。
  • 存储原始字节不会在内存中取消初始化先前的值。
  • 确保内存中不包含任何对象引用。

  • 调用.storeBytes(of: u, as: UInt32.self)从一个UInt32值中提取4个字节u,然后将其写入内存中Int64值的高4个字节。
  • iBytes已经指向内存中值的类型化指针仍然可以用于访问它,但是具有不同的值。
  • 无法将原始指针转换回带类型的指针,因为它与内存绑定类型冲突。
  • 在这种情况下,Int64指针与指针重叠UInt32

原始指向变量的指针

  • UnsafeRawBufferPointer是字节的集合,就像UnsafeBufferPointer键入的值的集合一样。
  • 缓冲区计数是变量类型的大小和字节。
  • 集合索引是一个字节偏移量。(与C中的数组算术相同。)

指向可变存储的原始指针

  • withUnsafeMutableBytes给出了可变字节的集合,因此您可以存储UInt值和特定的字节偏移量。

指向数组的原始指针

  • withUnsafeBytes 方法公开数组元素的原始存储。
  • 缓冲区大小是数组的计数乘以元素跨度。
  • 这些字节中的某些字节可能被填充以用于元素对齐。(数据对齐)

原始指向 Data

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))
  • Foundation的data类型是字节的集合。
  • withUnsafeBytes 方法在闭包期间公开基础的原始指针。
  • 在这里,我们读取从偏移量4开始的字节,将其解释为UInt32。

分配原始存储

func rawAllocate<T>(t: T, numValues: Int) -> UnsafeMutablePointer<T> {
    let rawPtr = UnsafeMutableRawPointer.allocate(
            byteCount: MemoryLayout<T>.stride * numValues,
            alignment: MemoryLayout<T>.alignment)
    let tPtr = rawPtr.initializeMemory(as: T.self, repeating: t, count: numValues)
    // Must use the typed pointer ‘tPtr’ to deinitialize.
    return tPtr
}

  • 使用UsafeMutableRawPointer.allocate直接分配原始内存。
  • 应该以字节为单位计算内存大小和对齐方式。
  • 原始分配后,内存状态既不会初始化也不会绑定到类型。
  • 指定值的类型以初始化内存。
  • tPtr 是类型化的指针。
  • 使用类型化的指针进行取消初始化。
  • 没有办法用原始指针取消初始化。
  • 分配并不关心内存是否绑定到类型。

示例:不同类型的连续存储

func contiguousAllocate<Header>(header: Header, numValues: Int) -> (UnsafeMutablePointer<Header>, UnsafeMutablePointer<Int32>) {
    let offset = MemoryLayout<Header>.stride
    let byteCount = offset + MemoryLayout<Int32>.stride * numValues
    assert(MemoryLayout<Header>.alignment >= MemoryLayout<Int32>.alignment)
    let bufferPtr = UnsafeMutableRawPointer.allocate(
            byteCount: byteCount, alignment: MemoryLayout<Header>.alignment)
    let headerPtr = bufferPtr.initializeMemory(as: Header.self, repeating: header, count: 1)
    let elementPtr = (bufferPtr + offset).initializeMemory(as: Int32.self, repeating: 0, count: numValues)
    return (headerPtr, elementPtr)
}

  • 我们希望将不相关的类型存储在同一连续的内存块中。(如图所示,我们将类型命名为Header和Int32。)
  • bufferPtr是指向连续字节块的原始指针。(它指向内存空间的第一个字节。)

  • 将内存的前几个字节初始化为的类型Header。

  • 将剩余的字节初始化为Int32。 这种存储分配技术非常适合实现标准库类型(例如set和)dictionary

通常,原始指针是一种强大的工具,可以很好地实现高性能的数据结构,但是我们不想过多地公开它们。

用例:解码字节缓冲区

当您有一个外部生成的字节缓冲区,并且想要将这些字节解码为Swift类型时,更可能使用原始指针。

  • 读取描述符以确定后续数据的大小和类型。
  • 加载以下数据,然后解码为所需的任何类型。

可变类型

  • API名称是指内存的“绑定类型”:
    • assumingMemoryBound(to:)
    • bindMemory(to:capacity:)
    • withMemoryRebound(to:capacity:)
  • 可以引入未定义的行为或类型指针的现有用法。
  • 规则:每个类型的指针访问都必须与内存的绑定类型一致。 assumingMemoryBound(to:)

恢复类型化的指针

func takesIntPointer(_: UnsafePointer<Int>) { /* elided */ }

struct RawContainer {
    var rawPtr: UnsafeRawPointer
    var pointsToInt: Bool
}

func testContainer(numValues: Int) {
    let intPtr = UnsafeMutablePointer<Int>.allocate(capacity: numValues)
    let rc = RawContainer(rawPtr: intPtr, pointsToInt: true)
    // ...
    if rc.pointsToInt {
        takesIntPointer(rc.rawPtr.assumingMemoryBound(to: Int.self))
    }
}
  • RawContainer.rawPtr 保存原始内存。
  • memory️assumingMemoryBound(to: T.self)当先前的操作已将内存绑定到“ T”时使用。

指向元组元素

func takesIntPointer(_: UnsafePointer<Int>) { /* elided */ }

func testPointingToTuple() {
    let tuple = (0, 1, 2)
    withUnsafePointer(to: tuple) { (tuplePtr: UnsafePointer<(Int, Int, Int)>) in
        takesIntPointer(UnsafeRawPointer(tuplePtr).assumingMemoryBound(to: Int.self))
    }
}
  • withUnsafePointer给出一个指向tuplePtr与UnsafePointer<Int>type 不兼容的元组类型的指针。
  • 绑定到元组的内存也绑定到其元素类型。
  • 构造一个原始指针,以故意删除元组指针的类型。
  • 使用assumingMemoryBound创建的指针Int。
  • 均匀的元组具有保证的布局。(一个值接一个)
  • with️不同类型的元组没有布局保证。

指向结构属性

func takesIntPointer(_: UnsafePointer<Int>) { /* elided */ }

struct MyStruct {
    var status: Bool
    var value: Int
}

func testPointingToStructProperty() {
    let myStruct = MyStruct(status: true, value: 0)
    withUnsafePointer(to: myStruct) { (ptr: UnsafePointer<MyStruct>) in
        let rawValuePtr =
                (UnsafeRawPointer(ptr) + MemoryLayout<MyStruct>.offset(of: \MyStruct.value)!)
        takesIntPointer(rawValuePtr.assumingMemoryBound(to: Int.self))
    }
}
  • MyStruct 具有整数属性。
  • withUnsafePointer提供指向的类型化指针myStruct。
  • 通过将struct指针向下转换为原始指针并添加该字节偏移量,我们获得了指向value属性的原始指针。
  • 属性的内存始终绑定到声明的属性的type上,因此可以安全地调用assumingMemoryBound以获取指向的指针Int。
  • Str️不保证结构布局-rawValuePtr仅指向单个指针。

简单的替代方法:

func takesIntPointer(_: UnsafePointer<Int>) { /* elided */ }

struct MyStruct {
    var status: Bool
    var value: Int
}

let myStruct = MyStruct(status: true, value: 0)
    takesIntPointer(&myStruct.value)
}

bindMemory(to:capacity:)

-bindMemory API使您可以更改内存绑定类型。

  • 如果该内存位置尚未绑定到类型,则它将第一次绑定该类型。
  • 如果内存已经绑定到类型,则它将重新绑定该类型。
func testBindMemory() {
    let uint16Ptr = UnsafeMutablePointer<UInt16>.allocate(capacity: 2)
    uint16Ptr.initialize(repeating: 0, count: 2)
    let int32Ptr = UnsafeMutableRawPointer(uint16Ptr).bindMemory(to: Int32.self, capacity: 1)
    // Accessing uint16Ptr is now undefined
    int32Ptr.deallocate()
}

更改内存区域的绑定类型:

  • 修改抽象内存状态
  • 重新解释内存区域的原始字节
  • 使现有的类型化指针无效
  • 可以为变量,数组和集合存储未定义
  • 促进Swift的低级实现-注意应用程序代码

withMemoryrebound(to:capacity)

临时更改绑定类型。

func takesUInt8Pointer(_: UnsafePointer<UInt8>) { /* elided */ }

func testWithMemoryRebound(int8Ptr: UnsafePointer<Int8>, count: Int) {
    int8Ptr.withMemoryRebound(to: UInt8.self, capacity: count) {
        (uint8Ptr: UnsafePointer<UInt8>) in
        // int8Ptr cannot be used within this closure
        takesUInt8Pointer(uint8Ptr)
    }
    // uint8Ptr cannot be used outside this closure
}
  • withMemoryrebound(to:capacity) 给出一个保证在其关闭范围内有效的指针。

bindMemory(to:capacity:)安全使用

  • withMemoryrebound(to:capacity)局限性:
    • 需要一个指向原始类型的指针
    • 两种类型都需要相同的步幅
  • bindMemory(to:capacity:)直接致电,请遵循相同的原则:
    • 将指针的使用限制在受控范围内
    • 范围结束时将内存重新绑定回原始类型

内存绑定API

  • assumingMemoryBound(to:)
    • 恢复类型擦除的指针类型
    • ⚠️需要事先了解内存的绑定类型状态
  • bindMemory(to:capacity:)
    • 全局更改为内存的绑定类型状态
    • Low️低级操作,使现有的类型化指针无效
  • withMemoryrebound(to:capacity)
    • 临时更改内存的绑定类型状态
    • ⚠️对于调用不同类型的C API很有用

安全地重新解释字节

let uint32Ptr = rawPtr.bindMemory(to: UInt32.self)
return uint32Ptr.pointee
  • 调用bindMemory以获取要读取的类型的指针。
  • 但是在创建该指针的过程中,我们更改了内存状态,并可能使其他指针无效。
return rawPtr.load(as: UInt32.self)
  • 避免更改内存中的类型并使其他指针无效
  • 类型安全:仅布局兼容性很重要
  • 可以将类型化的指针强制转换为原始指针
  • withUnsafeBytes 为变量,数组或数据对象提供原始缓冲区

在原始内存上分层类型

假设您想将内存区域视为具有特定元素类型的元素序列,但是基础存储被公开为原始指针,并且代码的不同部分可能会将其视为不同的类型。

您可以轻松地围绕该原始指针创建包装器,以保留您的元素类型。

struct UnsafeBufferView<Element>: RandomAccessCollection {
    let rawBytes: UnsafeRawBufferPointer
    let count: Int

    init(reinterpret rawBytes: UnsafeRawBufferPointer, as: Element.Type) {
        self.rawBytes = rawBytes
        self.count = rawBytes.count / MemoryLayout<Element>.stride
        precondition(self.count * MemoryLayout<Element>.stride == rawBytes.count)
        precondition(Int(bitPattern: rawBytes.baseAddress).isMultiple(of: MemoryLayout<Element>.alignment))
    }

    var startIndex: Int { 0 }

    var endIndex: Int { count }

    subscript(index: Int) -> Element {
        rawBytes.load(fromByteOffset: index * MemoryLayout<Element>.stride, as: Element.self)
    }
}

func testBufferView() {
    let array = [0,1,2,3]
    array.withUnsafeBytes {
        let view = UnsafeBufferView(reinterpret: $0, as: UInt.self)
        for val in view {
            print(val)
        }
    }
}

摘要

  • 尽量避免使用指针
  • 避免使用类型化的指针将内存重新解释为不同的类型
  • 使用UnsafeRawBufferPointer于:
    • 重新将原始字节解释为不同的类型
    • 从字节流解码Swift类型
    • 实现一个容器以在连续内存中保存不同类型