Swift指针的应用

Swift与指针

由于Swift本身是一门较为现代的语言,支持很多高级特性,所以对于程序员来说,大部分时候不需要用到指针这种更“底层”的特性。而Swift语言的设计者也在尽可能希望开发者能尽量少的使用指针。

但是,“慎用”不代表“不能用”,更不代表“没用”。相反,指针非常有用,在某些场景下还是必不可少的特性。尤其是开发工作和系统底层特性、内存处理、高性能需求息息相关时。

所以,Swift通过在施加某种限制的前提下为开发者暴露了指针的使用接口,本篇文章重点介绍Swift使用指针的相关类型、函数,以及在实践应用中灵活使用指针解决问题的技巧。

类型限定的指针 UnsafePointer

Swift通过UnsafePointer<T>来指向一个类型为T的指针,该指针的内容是 只读 的,对于一个UnsafePointer<T>变量来说,通过pointee成员即可获得T的值。

func call(_ p: UnsafePointer<Int>) {
    print("\(p.pointee)")
}
var a = 1234
call(&a) // 打印:1234
复制代码

以上例子中函数call接收一个UnsafePointer<Int>类型作为参数,变量a通过在变量名前面加上&将其地址传给call。函数call直接打印指针的pointee成员,该成员就是a的值,所以最终打印结果为1234

注1:&aswift提供的语法特性,用于传递指针,但它有严格的适用场景限制。

注2:注意示例中对于变量a使用了var声明,而事实上UnsafePointer是“常量指针”,并不会修改a的内容,即使是这样a还是必须用var声明,如果用let会报错Cannot pass immutable value as inout argument: 'a' is a 'let' constant。这是因为swift规定UnsafePointer作为参数只能接收inout修饰的类型,而inout修饰的类型必然是可写的,所以使用var在所难免。

内容可写的类型限定指针 UnsafeMutablePointer

既然有 内容只读 指针,必须也得有 内容可读写 指针搭配才行,在Swift中,内容可读写的类型限定指针为UnsafeMutablePointer<T>类型,就和名字描述的那样,它和UnsafePointer最大的区别就是它指向的内容是可更改的,并且更改后指向的“数据源”也会被改动。

func modify(_ p: UnsafeMutablePointer<Int>) {
    p.pointee = 5678
}
var a = 1234
modify(&a)
print("\(a)") // 打印:5678
复制代码

在以上的例子中,指针p指向的值被重新赋值为5678,这也使得指针的“源”,即变量a的值发生变化,最终打印a的结果可以看出a被修改为5678

指针的辅助函数 withUnsafePointer

通过函数withUnsafePointer,获得指定类型的对应指针。该函数原型如下:

func withUnsafePointer<T, Result>(to value: inout T, _ body: (UnsafePointer<T>) throws -> Result) rethrows -> Result
复制代码

这个函数原型看似很复杂很长,其实只要理解它所需要的信息只有两个:

  1. 指针指向类型是什么。
  2. 想要返回的指针地址是什么。
var a = 1234
let p = withUnsafePointer(to: &a) { $0 }
print("\(p.pointee)") // 打印:1234
复制代码

以上例子是withUnsafePointer最精简的调用例子,我们定义了一个整形a,而p就是指向a的整形指针,事实上它的类型会被自动转换为UnsafePointer<Int>,第二个参数被简化为了{ $0 },它传入了一个代码块,代码块接收一个UnsafePointer<Int>参数,该参数即是a的地址,直接通过$0将它返回,即得到了a的指针,最终它被传给了p

对于第二个参数,或许有人会产生疑问,它似乎是没有意义的参数,大部分时候我们不是直接返回a的地址吗,为什么要多此一举通过代码块返回一次?这个疑问是合理的,绝大多数时候确实第二个参数显得有些多余,当然了,有时候可以通过第二个参数提供指针偏移的灵活性,如下例子可以提供一个案例。

var a = [1234, 5678]
let p = withUnsafePointer(to: &a[0]) { $0 + 1 }
print("\(p.pointee)") // 打印:5678
复制代码

以上例子中,通过在第二个参数中对地址施加偏移,可以原来指向数组首个元素的地址偏移到第二个地址中。

另外,由于withUnsafePointer带着两个泛型参数,这意味着第二个参数可以是不同的类型。

var a = 1234
let p = withUnsafePointer(to: &a) { $0.debugDescription }
print("\(p)")
复制代码

以上例子中,withUnsafePointer返回的并不是UnsafePointer<Int>类型,甚至不是指针,而是一个字符串,字符串保存着a对应指针的debug信息。

注1:同样的,和withUnsafePointer相对应的,还有withUnsafeMutablePointer,一样是只读和可读写的区别。读者可以自行测试用法。 注2:基本上Swift指针操作的with系列函数都提供了第二个参数用来灵活的提供函数的返回类型。

获取指针并进行字节级操作 withUnsafeBytes

有时候,我们需要对某块内存进行字节级编程。比如我们用一个32位整形来表示一个32位的内存块,对内存中的每个字节进行读写操作。

通过withUnsafeBytes,可以得到某个类型的数据的字节指针,从而可以对它们进行字节级编程。

var a: UInt32 = 0x12345678
let p = withUnsafeBytes(of: &a) { $0 }
var log = ""
for item in p {
    let hex = NSString(format: "%x", item)
    log += "\(hex)"
}
print("\(p.count)") // 打印:4
print("\(log)") // 对于小端机器会打印:78563412
复制代码

在以上例子中,withUnsafeBytes返回了一个类型UnsafeRawBufferPointer,该类型代表着一个字节级的内存块,并提供了等价于数组操作,所以你可以通过下标索引、for循环的方式来处理返回的对象。

例子中的a是一个32位整形,所以p指针的count返回的是4,单位为字节。 在本例中,对内存块p从低到高逐字节的打印每个字节的16进制值。 具体打印出来的结果因运行的机器而异,在大端机器上,打印的结果是12345678,而在小端机器上打印结果则是78563412

注:大端和小端决定了一个基础数据单元在内存中是如何按序存放的,例如小端机器会将基本数据单元的低位放在内存的低位,由低到高排列,而大端机器则相反。具体相关知识可查阅维基百科。大部分情况下,同一台机器采用的字节序列是一致的,某些CPU可以配置大小端的切换。

指向连续内存的指针 UnsafeBufferPointer

Swift的数组提供了函数withUnsafeBufferPointer,通过它我们可以方便的用指针来处理数组。如下例子:

let a: [Int32] = [1, 2, -1, -2, 5, 6]
let p = a.withUnsafeBufferPointer { $0 }
print("\(p.count)") // 打印:6
print("\(p[3])") // 打印:-2
复制代码

在该例子中,通过withUnsafeBufferPointer,可以获得变量pp的类型为UnsafeBufferPointer<Int32>,它代表着一整块的连续内存,我们可以像看待数组一样看待它,并且它也支持大部分数组操作。

指针的类型转换

介绍了那么多Swift中的指针类型,每一种都有各自的用途,但是在实际开发中,很可能我们需要将一个指针类型转换为特定的指针类型。

以下例子提供了几个类型指针之间的转换

let a: [Int32] = [1, 2, -1, -2, 5, 6]
// 类型 p: UnsafeBufferPointer<Int32>
let p = a.withUnsafeBufferPointer { $0 }
// 类型 p2: UnsafePointer<UInt32>
let p2 = p.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: p.count) { $0 }
// 类型 p3: UnsafeBufferPointer<UInt32>
let p3 = UnsafeBufferPointer(start: p2, count: p.count)
print("\(p3.count)") // 打印:6
print("\(p3[3])") // 打印:4294967294
复制代码

以上例子中,我们获得了以下三个指针类型

  1. UnsafeBufferPointer<Int32>类型的指针p
  2. UnsafePointer<UInt32>类型的指针p2
  3. UnsafeBufferPointer<UInt32>类型的指针p3

该例子有部分细节必须讲明,首先是baseAddress,通过该成员得到UnsafeBufferPointer基地址,获得的数据类型是UnsafePointer<>

由于a指向的元数据类型是Int32,所以其baseAddress类型即是UnsafePointer<Int32>

在本例中,我们将元数据类型由Int32改为UInt32,这里用到了UnsafePointer的成员函数withMemoryRebound,通过它将UnsafePointer<Int32>转换为UnsafePointer<UInt32>

最后一部分,我们创建了一个新的指针UnsafeBufferPointer,通过其构造函数,我们让该指针的起始位置设定为p2,元素个数设定为p的元素个数,这样就成功得到了一个UnsafeBufferPointer<UInt32>类型。

接下来的打印语句,我们可以看到p3类型的count成员依然是6,而p3[3]打印的结果却是4294967294,而不是数组a对应元素的-2,这是因为从p3的角度来看,它是用UInt32类型来“看待”原先的Int32数据元素。

回调函数的实用性

前面讨论withUnsafePointer时我曾经提过第二个回调参数似乎略显鸡肋,事实上它非常有用,通过回调函数,我们可以对上一段代码进行“优化”。

let a: [Int32] = [1, 2, -1, -2, 5, 6]
// 类型 p: UnsafeBufferPointer<Int32>
let p = a.withUnsafeBufferPointer { $0 }
// 类型 p3: UnsafeBufferPointer<UInt32>
let p3 = p.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: p.count) {
    UnsafeBufferPointer(start: $0, count: p.count)
}
print("\(p3.count)") // 打印:6
print("\(p3[3])") // 打印:4294967294
复制代码

可以看到利用回调函数,我们把原先的p2p3代码合并了,这样可以让withMemoryRebound立刻返回UnsafeBufferPointer<UInt32>类型。

注:事实上该回调还可以不断“套娃”,也就是说可以直接把p3部分的代码和p也进行合并,但是出于可读性考虑,开发者应自己根据需要选择性进行嵌套。

Swift中的空指针:UnsafeRawPointer

就像C语言有void*(即空指针)一样,Swift也有自己的空指针,它通过类型UnsafeRawPointer来获得,我们知道,空指针没有指向特定的类型,又“可以”指向任何类型,灵活性极高,也需要程序员自己能够理解和处理好对应的细节。

同样是将UnsafeBufferPointer<Int32>转换为UnsafeBufferPointer<UInt32>,以下代码通过UnsafeRawPointer来实现。

let a: [Int32] = [1, 2, -1, -2, 5, 6]
let p = a.withUnsafeBufferPointer { $0 }
let p2 = UnsafeRawPointer(p.baseAddress!).assumingMemoryBound(to: UInt32.self)
let p3 = UnsafeBufferPointer(start: p2, count: p.count)
print("\(p3.count)") // 打印:6
print("\(p3[3])") // 打印:4294967294
复制代码

在该例子中我们通过空指针完成了如下操作:

  1. UnsafeRawPointer通过构造函数接收了p的“基地址”构造了一个空指针类型。
  2. 由于构造的是空指针类型,我们需要对它进行类型转换,通过assumingMemoryBound把它转换成新的数据类型UnsafePointer<UInt32>
  3. 通过UnsafeBufferPointer构造函数重新构造了一个新的指针UnsafeBufferPointer<UInt32>

通过指针动态创建、销毁内存

有时候我们需要动态开辟和管理一块内存,最后释放它,Swift提供了UnsafeMutablePointer的成员函数allocate来处理该工作。

let p = UnsafeMutablePointer<Int32>.allocate(capacity: 1)
p.initialize(to: 0) // 初始化
p.pointee = 32
print("\(p.pointee)") // 打印:32
p.deinitialize(count: 1) // 反初始化
p.deallocate()
复制代码

以上例子中我们提供了一个存放32位整形的内存块,容量为1(即其容量为1个32位整形,实际就是 4 个字节)。 接下来的代码示例较为简单,即开辟内存,初始化、赋值、反初始化,释放内存的流程。

Swift指针类型和C指针类型的对应关系

Swift的指针类型看似繁多,事实上只是对C指针类型进行了封装和类别整理,并增加了一定程度上的安全性。

下表提供了SwiftC部分指针类型和函数的大致等价关系。

Swift C 描述
UnsafeMutableRawPointer void* 空指针
UnsafeMutablePointer T* 类型指针
UnsafeRawPointer const void* 常量空指针
UnsafePointer const T* 常量类型指针
UnsafeMutablePointer.allocate (int32_t*)malloc 分配内存

可以看出Swift的指针并不神秘,它只是映射了C语言指针的对应操作(只是乍看一下更复杂)。

进阶实践:C标准库函数的映射调用

Swift提供了大量的C标准库的桥接调用,也就是说,我们可以像调用C语言库函数一样调用Swift函数。这其中包括很多有用的函数,如memcpystrcpy等。

下面通过一段示例程序来展现这类函数的调用。

var n = 10086
// malloc
let p = malloc(MemoryLayout<Int32>.size)!
// memcpy
memcpy(p, &n, MemoryLayout<Int32>.size)
let p2 = p.assumingMemoryBound(to: Int32.self)
print("\(p2.pointee)") // 打印:10086
// strcpy
let str = "abc".cString(using: .ascii)!
if str.count != MemoryLayout<Int32>.size {
    return
}
let pstr = p.assumingMemoryBound(to: CChar.self)
strcpy(pstr, str)
print("\(String(cString: pstr))") // 打印:abc
// strlen
print("\(strlen(pstr))") // 打印: 3
// memset
memset(p, 0, MemoryLayout<Int32>.size)
print("\(p2.pointee)") // 打印:0
// strcat
strcat(pstr, "h".cString(using: .ascii)!)
strcat(pstr, "i".cString(using: .ascii)!)
print("\(String(cString: pstr))") // 打印:hi
// strstr
let s = strstr(pstr, "i")!
print("\(String(cString: s))") // 打印:i
// strcmp
print("\(strcmp(pstr, "hi".cString(using: .ascii)!))") // 打印:0
// free
free(p)
复制代码

以上demo提供了如memsetstrcpyC库函数原型的调用方式。通过该例子可以看出指针操作的灵活性,对于开辟的一块4个字节的内存,我们既可以把它看做一个32位整形,又可以把它看做4个ascii字符,当把它看做4个字符时,我们可以用它存放abc三个字符,并在最后一个字节用\0作为终止符。

总结

指针可以让我们用更底层的视角来看待程序和数据,在某些场景下,通过指针我们有机会开发出更高性能的代码。但同时指针的使用有时也是极复杂易出错的。如何使用好这把双刃剑,全看开发者自身的能力和态度。本文仅仅是抛砖引玉的提供了Swift指针的基本框架和使用技巧,大量细节因为篇幅原因并未提及,还需要读者自行不断研究和学习。

本文的样例代码已上传至我的github,请参见地址:github.com/FengHaiTong…

猜你喜欢

转载自juejin.im/post/7030789069915291661