在Swift里安全管理指针

694 阅读12分钟

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战

《不安全的Swift》一文中,我们介绍了,不安全的定义Swift不安全缓冲区指针的使用,并介绍了Swift中,unsafe相关的API。在本文中,我们来探讨如何在Swift中安全的管理指针。C语言是未定义行为的源,Swift中也提供了相同底层级功能的API。安全管理指针意味着我们需要知道它们可能变得不安全的所有方式,我们可以将指针安全看作是一系列安全层级

Swift安全层级

一共分为4种安全层级,每往下一层,我们就需要确保编写的代码的正确性。

截屏2022-02-04 下午9.12.19.png

  • 1,第一级是安全代码,Swift提供了强大的类型系统,提供了很多可能要通过指针获取的功能,完全不使用指针是最安全的。
  • 2,第二级是不安全API,提供了类型指针供我们使用,来实现高效互用性,通常以Unsafe作为函数的前缀
  • 3,第三级是原始指针,如果你需要将 某些原始内存用作一连串的字节, Swift 提供了 UnsafeRawPointer 选项 利用原始内存加载和存储值,此时,你有责任知道此时内存中的数据类型。
  • 4,第四级,Swift提供了内存绑定的API,可以将 内存绑定到类型上,只有当你使用这些最底层级的API是,就需要承担管理指针类型安全的所有责任。

类型指针

UnsafePointer<T>是一个类型指针,它表示存储在内存里的值类型,该内存位置只能保存该类型的值。作为一个类型指针,只从内存里读取该内存的值。UnsafeMutablePointer<T>能够读取或存入该类型的值。 截屏2022-02-04 下午10.53.18.png

在 Swift里,访问一个类型参数和其内存绑定类型不一致的指针,会产生未定义的行为

截屏2022-02-05 上午11.07.42.png 为避免这个问题,在编译期,Swift类型系统会执行额外的运行检查。

指向变量的类型指针

声明一个Int类型的变量,并请求一个指向变量存储的指针withUnsafePointer,这样就会得到和声明一致的pointer

截屏2022-02-05 上午11.39.40.png

指向数组的类型指针

声明一个Array,使用withUnsafeBufferPointer,就会得到指向数组存储的指针。

截屏2022-02-05 上午11.45.14.png

类型安全的静态内存分配

struct Collage {
    var name: String
}

var c = Collage(name: "A")
let tPtr = UnsafeMutablePointer<Collage>.allocate(capacity: 5) // 1
print(tPtr)
tPtr.initialize(repeating: c, count: 5) // 2
tPtr.assign(repeating: c, count: 5) // 3
tPtr.deinitialize(count: 5) // 4
tPtr.deallocate() //4
  • 1, 使用UnsafeMutablePointer静态分配内存,并将内存绑定至它的类型参数。
  • 2,使用分配给予的类型指针,针对正确的类型,进行初始化内存
  • 3,在初始化状态下,内存可以被重新分配,重新分配会暗地里取消初始先前的内存内值 并将内存重新初始成相同类型的新值。
  • 4,使用同一类型指针取消初始内存,此时内存依然会绑定至相同类型,可以安全的解除分配。

截屏2022-02-05 下午12.13.38.png 在这里,你只需要负责管理内存的初始化状态,swift来确保类型安全

复合类型

截屏2022-02-05 下午12.24.02.png 此处,我们拥有一个包含MyStruct值的内存块,我们可以获取一个指向结构体外层的指针或者指向其属性的指针,而这两种指针都是有效的,可以访问其中任意一个,无需更改内存绑定的类型。这依然遵照了相同的指针安全基本规则 当内存绑定至一个复合类型时 它也会有效地和该类型的成员绑定 因为它们都存在于内存的布局里。

对于类型指针,我们不需要管理内存绑定的数据类型,但我们需要管理其声明周期和内存边界

类型指针可以让我们直接访问内存,但仅限于类型安全的范围内,不能拥有两个指向同一内存却拥有不同类型指针。因此 如果你的目标是 将内存字节重新诠释成不同类型 你就需要使用一个低层级的 API。

原始指针

UnsafeRawPointer可以从一连串字节中读取数据,不需要指定它们可能代表的值类型

存储和读取内存

let ptr = UnsafeMutablePointer<Int64>.allocate(capacity: 1) // 1
ptr.initialize(repeating: 4294967297, count: 1)
ptr.assign(repeating: 4294967297, count: 1)
print(ptr.pointee)

var rPtr = UnsafeRawPointer(ptr) // 2
let value = rPtr.load(as: UInt32.self) // 3
print(value)

var mPtr = UnsafeMutableRawPointer(ptr) // 4
mPtr.storeBytes(of: 10, as: UInt32.self)
print(mPtr.load(as: UInt32.self))
  • 1,使用类型指针,将内存初始化为Int64类型的内存块,并写入4294967297 == UInt32.max + 2
  • 2,将类型指针转变为原始指针
  • 3,从内存空间中以UInt32的形式,读取内存值。
  • 4,将类型指针转化为可变的原始指针,对内存进行操作。

我们来看下输出情况

4294967297
1
10

我们可以看到,当我们使用UInt32类型读取Int64类型的内存值时,此时,数据出现了错误。因为只读取了前32位的数据,此时,保证数据的安全性,就变成了你的责任

截屏2022-02-05 下午4.18.45.png 类型指针可以变为原始指针,原始指针不可以变为类型指针

截屏2022-02-05 下午4.21.59.png

指向变量的原始指针

使用withUnsafeBytes可以获得指向变量的原始指针UnsafeRawBufferPointer

var i: Int32 = 10
withUnsafeBytes(of: i) { (ptr: UnsafeRawBufferPointer) in
    print(ptr.count)
    print(MemoryLayout<Int32>.size) // MemoryLayout<Int32>.size == ptr.count
    print("0:\(ptr[0]), 1: \(ptr[1]), 2: \(ptr[2]), 3: \(ptr[3])")
}

在这里,内存缓冲区计数就是变量类型字节的大小,集合索引字节偏移量,读取索引处的元素,会返回UInt8的值,就是依次读取1个字节。ptr[1]就是从第一个字节偏移量中读取1个字节大小的值,

我们来看下输出情况

4
4
0:255, 1: 255, 2: 255, 3: 127

此时连续的32位存储空间的数据如下图所示

截屏2022-02-05 下午4.50.53.png

使用withUnsafeMutableBytes可以得到可变的原始指针UnsafeMutableRawBufferPointer,可以修改特定字节偏移量处的内存值。

var j: Int32 = Int32.max
withUnsafeMutableBytes(of: &j) { (ptr: UnsafeMutableRawBufferPointer) in
    ptr.storeBytes(of: 10, toByteOffset: 1, as: UInt8.self)
    print("0:\(ptr[0]), 1: \(ptr[1]), 2: \(ptr[2]), 3: \(ptr[3])")
}

此时,已经将集合索引为1的地方的内存值,修改为10

输出结果如下

0:255, 1: 10, 2: 255, 3: 127

指向数组的原始指针

withUnsafeBytes这个方法能够得到指向原始内存存储字节的指针。

var array:[Int] = [1, 2, 3]
array.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
    print(bytes.count)
    print(MemoryLayout<Int>.stride * array.count) //bytes.count == MemoryLayout<Int>.stride * array.count
}

此时获得的缓冲区的大小等于数据类型的步幅 * 集合的数量

指向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))

// 输出结果
1

我们可以使用该方法,使用二进制数据,在特定的字节偏移量中,读取特定的元素类型。

指向静态分配的原始指针

func rawAllocate<T>(t: T, numValues: Int) -> UnsafeMutablePointer<T> {
    let rawPtr = UnsafeMutableRawPointer.allocate(
            byteCount: MemoryLayout<T>.stride * numValues,
            alignment: MemoryLayout<T>.alignment) // 1
            
    let tPtr = rawPtr.initializeMemory(as: T.self, repeating: t, count: numValues) // 2
    // Must use the typed pointer ‘tPtr’ to deinitialize.
    return tPtr
}
var ptr = rawAllocate(t: Int.self, numValues: 10)
  • 1, 使用UnsafeMutableRawPointer.allocate在内存上申请一段连续的内存空间,并获得指向该块内存的指针。这时候,需要我们自己计算字节内存的大小和内存对齐的方式,经过原始分配后 内存状态既不初始化 也不绑定至类型
  • 2,指定内存中存储的值类型,将内存绑定至该类型,并返回类型指针,如果内存已经是初始化状态,必须使用返回的类型指针,来deinitialize该处内存。如果没有初始话,那么可以使用原始指针rawPtr,来销毁内存。

截屏2022-02-05 下午5.38.02.png

使用类型指针进行内存分配,更加安全快捷,但有有些情况不得不使用原始指针

连续存储不同类型的数据

当我们在一块连续的内存空间,存储不同类型的数据时,使用原始指针就变成了一个很好的选择。 有以下场景:在内存的起始部分,我们需要存入Header类型的数据,紧接着存储Int32类型的数据

截屏2022-02-05 下午5.49.40.png

这个时候,我们就需要通过原始指针偏移一个Header大小的空间,然后依次读取4字节来读取数据。

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)
}

应用案例

decoding bytes buffers

当你拥有外部生成的字节缓冲区 并且想将这些字节解码成 Swift 类型时 你就会想要使用原始指针

截屏2022-02-05 下午5.58.07.png

import Foundation
func readUInt32(data: Data, byteOffset: Int) -> UInt32 {


    data.withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in
        buffer.load(fromByteOffset: byteOffset, as: UInt32.self)
    }

}

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

使用原始指针的时候,我们需要额外注意此时的内存的空间布局

内存绑定

swift提供了3个内存绑定的API

  • assumingMemoryBound(to:)
  • bindMemory(to:capacity:)
  • withMemoryRebound(to:capacity) 在使用内存绑定时,我们要遵守一个规则:类型指针的访问需和内存绑定类型一致

assumingMemoryBound

来看下如下示例:

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

struct RawContainer { // 1
    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)) // 2
    }
}
  • 1,保留原始内存的容器。
  • 2,assumingMemoryBound告诉编译器,假设内存绑定的是Int类型的数据。只有当你确定内存已经被绑定为你想要的类型的时候,才可以使用assumingMemoryBound
C语言回调

在处理C语言API的结果回调时,此时,我们也需要使用assumingMemoryBound函数。

// Use assumingMemoryBound to recover a pointer type from a (void *) C callback.
/*
func pthread_create(_ thread: UnsafeMutablePointer<pthread_t?>!,
    _ attr: UnsafePointer<pthread_attr_t>?,
    _ start_routine: (UnsafeMutableRawPointer) -> UnsafeMutableRawPointer?,
    _ arg: UnsafeMutableRawPointer?) -> Int32
*/
import Darwin

struct ThreadContext { /* elided */ }

func testPthreadCreate() {
    let contextPtr = UnsafeMutablePointer<ThreadContext>.allocate(capacity: 1)
    contextPtr.initialize(to: ThreadContext())
    var pthread: pthread_t?
    let result = pthread_create(
            &pthread, nil,
            { (ptr: UnsafeMutableRawPointer) in
                let contextPtr = ptr.assumingMemoryBound(to: ThreadContext.self)
                // ... The rest of the thread start routine
                return nil
            },
            contextPtr)
}
类型指针不一致

有时候,我们有一个类型指针,但类型指针的类型不一致

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))
    }
}

(Int, Int, Int)型的类型指针,转化为原始指针,然后,使用assumingMemoryBound将其变化为类型为Int的 类型指针

结构体属性
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))
    } // 1
}
  • 1,通过withUnsafePointerAPI得到myStruct的内存地址,然后通过MemoryLayout获得value属性在结构体中的偏移量,从而获得value属性的真实内存地址。

上述操作等价于下面的操作,可以使用inout关键字,编译器会将它转换成函数所声明的指针类型。

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

struct MyStruct {
    var status: Bool
    var value: Int
}
func testPointingToStructProperty() {
 let myStruct = MyStruct(status: true, value: 0)
 takesIntPointer(&myStruct.value)
}

assumingMemoryBound会让编译器对内存的绑定类型进行一个不经检查的假设。

bindMemory

bindMemory可以更改内存的绑定类型。如果之前没有绑定任何类型,那么就会进行首次绑定,如果已经绑定至一个类型,那么它会重新绑定该类型。

func testBindMemory() {
    let uint16Ptr = UnsafeMutablePointer<UInt16>.allocate(capacity: 2) // 1
    uint16Ptr.initialize(repeating: 0, count: 2) 
    let int32Ptr = UnsafeMutableRawPointer(uint16Ptr).bindMemory(to: Int32.self, capacity: 1) // 2
    // Accessing uint16Ptr is now undefined
    int32Ptr.deallocate()
}
  • 1,开辟2个UInt16大小的空间,此时,内存绑定的类型是UInt16
  • 2,bindMemory将该段内存空间重新绑定为Int32类型,此时在访问uint16Ptr,这个指针就是未定义的。

更改内存绑定类型,并不会从物理上调整内存,但我们应该把它想像成更改内存状态的属性

withMemoryRebound

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在必要时暂时性的绑定内存,相对来说,比bindMemory安全一些。

bingMemory API总结

截屏2022-02-06 上午11.16.26.png

  • 1,assumingMemoryBound(to:):需要提前知道内存绑定的数据类型。
  • 2,bindMemory(to:capacity:):可以改变内存的绑定的数据类型状态。
  • 3,withMemoryRebound: 暂时性的改变内存绑定的状态。
内存绑定的常见错误

截屏2022-02-06 上午11.21.19.png

从内存中读取一个不同类型,使用bindMemory会更改内存状态,使其他指向该区域的指针失效。使用load(as:)是一个比较好的选择。

原始数据

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)
        }
    }
}

总结

以上就是Swift中,关于指针的所有内容了,在Swift代码中,这些也都是非常有用的。比如,我们可能需从字节流里解码值,或者需要自己实现诸如Set、Dictionary等等的数据容器。

如果觉得有收获请按如下方式给个 爱心三连:👍:点个赞鼓励一下。🌟:收藏文章,方便回看哦!。💬:评论交流,互相进步!