UnsafeMutablePointer.pointee 和 didSet 属性

UnsafeMutablePointer.pointee and didSet properties

我在我创建的结构(Xcode 10.1、Swift 4.2)中观察到的 属性 上使用 UnsafeMutablePointer 时出现了一些意外行为。请参阅以下游乐场代码:

struct NormalThing {
    var anInt = 0
}

struct IntObservingThing {
    var anInt: Int = 0 {
        didSet {
            print("I was just set to \(anInt)")
        }
    }
}

var normalThing = NormalThing(anInt: 0)
var ptr = UnsafeMutablePointer(&normalThing.anInt)
ptr.pointee = 20
print(normalThing.anInt) // "20\n"

var intObservingThing = IntObservingThing(anInt: 0)
var otherPtr = UnsafeMutablePointer(&intObservingThing.anInt)
// "I was just set to 0."

otherPtr.pointee = 20
print(intObservingThing.anInt) // "0\n"

表面上,将 UnsafeMutablePointer 上的指针修改为观察到的 属性 实际上并没有修改 属性 的值。此外,将指针分配给 属性 的行为会触发 didSet 操作。我在这里错过了什么?

我很确定问题在于您的行为是非法的。您不能只声明一个不安全的指针并声称它指向结构 属性 的地址。 (事实上​​ ,我什至不明白为什么你的代码首先编译;编译器认为这是什么初始化程序?)给出预期结果的正确方法是 ask 对于 指向那个地址的指针,像这样:

struct IntObservingThing {
    var anInt: Int = 0 {
        didSet {
            print("I was just set to \(anInt)")
        }
    }
}
withUnsafeMutablePointer(to: &intObservingThing.anInt) { ptr -> Void in
    ptr.pointee = 20 // I was just set to 20
}
print(intObservingThing.anInt) // 20

任何时候你看到像 UnsafeMutablePointer(&intObservingThing.anInt) 这样的构造,你都应该 非常 警惕它是否会表现出未定义的行为。在绝大多数情况下,它会。

首先,让我们详细分析一下这里发生的事情。 UnsafeMutablePointer 没有任何采用 inout 参数的初始化器,那么这个调用的是什么初始化器?好吧,编译器有一个特殊的转换,允许将 & 前缀参数转换为指向表达式引用的 'storage' 的可变指针。这称为输入到指针的转换。

例如:

func foo(_ ptr: UnsafeMutablePointer<Int>) {
  ptr.pointee += 1
}

var i = 0
foo(&i)
print(i) // 1

编译器插入一个转换,将 &i 转换为指向 i 存储的可变指针。好的,但是当 i 没有任何存储空间时会发生什么?例如,如果计算出来呢?

func foo(_ ptr: UnsafeMutablePointer<Int>) {
  ptr.pointee += 1
}

var i: Int {
  get { return 0 }
  set { print("newValue = \(newValue)") }
}
foo(&i)
// prints: newValue = 1

这仍然有效,那么指针指向的存储空间是什么?为了解决这个问题,编译器:

  1. 调用 i 的 getter,并将结果值放入临时变量。
  2. 获取指向该临时变量的指针,并将其传递给对 foo 的调用。
  3. 用临时的新值调用 i 的 setter。

有效地执行以下操作:

var j = i // calling `i`'s getter
foo(&j)
i = j     // calling `i`'s setter

希望从这个例子中可以清楚地看出,这对传递给 foo 的指针的生命周期施加了一个重要的限制——它只能用于在 i 期间改变 i 的值致电 foo。尝试转义指针并在调用 foo 后使用它会导致修改 临时变量的值,而不是 i.

例如:

func foo(_ ptr: UnsafeMutablePointer<Int>) -> UnsafeMutablePointer<Int> {
  return ptr
}

var i: Int {
  get { return 0 }
  set { print("newValue = \(newValue)") }
}
let ptr = foo(&i)
// prints: newValue = 0
ptr.pointee += 1

ptr.pointee += 1发生在之后 i的setter已经用临时变量的新值调用,因此它没有效果.

更糟糕的是,它表现出 未定义的行为,因为编译器不保证临时变量在对 foo 的调用结束后仍然有效。例如,优化器可以在调用后立即取消初始化。

好的,但只要我们只获得指向未计算变量的指针,我们就应该能够在传递给它的调用之外使用指针,对吗?不幸的是,事实证明,在逃避 inout-to-pointer 转换时,还有很多其他方法会搬起石头砸自己的脚!

仅举几例(还有更多!):

  • 一个局部变量是有问题的,原因与我们之前的临时变量类似——编译器不保证它会保持初始化状态,直到它声明的范围结束。优化器是可以提前解除初始化。

    例如:

    func bar() {
      var i = 0
      let ptr = foo(&i)
      // Optimiser could de-initialise `i` here.
    
      // ... making this undefined behaviour!
      ptr.pointee += 1
    }
    
  • 带有观察者的存储变量是有问题的,因为在幕后它实际上是作为一个计算变量实现的,它在 setter.

    中调用它的观察者

    例如:

    var i: Int = 0 {
      willSet(newValue) {
        print("willSet to \(newValue), oldValue was \(i)")
      }
      didSet(oldValue) {
        print("didSet to \(i), oldValue was \(oldValue)")
      }
    }
    

    本质上是语法糖:

    var _i: Int = 0
    
    func willSetI(newValue: Int) {
      print("willSet to \(newValue), oldValue was \(i)")
    }
    
    func didSetI(oldValue: Int) {
      print("didSet to \(i), oldValue was \(oldValue)")
    }
    
    var i: Int {
      get {
        return _i
      }
      set {
        willSetI(newValue: newValue)
        let oldValue = _i
        _i = newValue
        didSetI(oldValue: oldValue)
      }
    }
    
  • 在 类 上存储的非最终 属性 是有问题的,因为它可以被计算的 属性.

    [=128 覆盖=]

这甚至没有考虑依赖编译器内部实现细节的情况。

因此,编译器仅保证 来自 inout 到指针转换的稳定且唯一的指针值 on stored global and static stored variables without observers。在任何其他情况下,在传递给它的调用之后尝试从 inout 到指针转换中转义并使用指针将导致 未定义的行为.


好的,但是我的函数 foo 示例与您调用 UnsafeMutablePointer 初始化程序的示例有何关系?好吧,UnsafeMutablePointer has an initialiser that takes an UnsafeMutablePointer argument(由于符合大多数标准库指针类型所符合的带下划线的 _Pointer 协议)。

这个初始化器实际上与 foo 函数相同——它接受一个 UnsafeMutablePointer 参数和 returns 它。因此,当您执行 UnsafeMutablePointer(&intObservingThing.anInt) 时,您正在转义从 inout 到指针转换产生的指针——正如我们所讨论的,只有当它在没有观察者的情况下用于存储的全局或静态变量时才有效。

所以,总结一下:

var intObservingThing = IntObservingThing(anInt: 0)
var otherPtr = UnsafeMutablePointer(&intObservingThing.anInt)
// "I was just set to 0."

otherPtr.pointee = 20

是未定义的行为。从 inout 到指针转换产生的指针仅在调用 UnsafeMutablePointer 的初始化程序期间有效。之后尝试使用它会导致 未定义的行为 。作为 ,如果你想要范围指针访问 intObservingThing.anInt,你想使用 withUnsafeMutablePointer(to:).

I'm actually currently working on implementing a warning(希望转换为错误)将在此类不正确的 inout-to-pointer 转换中发出。不幸的是,我最近没有太多时间来处理它,但一切进展顺利,我的目标是在新的一年开始推动它,并希望它能成为 Swift 5.x发布。

此外,值得注意的是,虽然编译器目前不保证以下行为的明确定义:

var normalThing = NormalThing(anInt: 0)
var ptr = UnsafeMutablePointer(&normalThing.anInt)
ptr.pointee = 20

根据关于 #20467 的讨论,看起来这 可能 是编译器在未来版本中保证明确定义的行为的东西,由于事实上,基础 (normalThing) 是一个没有观察者的 struct 的脆弱存储全局变量,而 anInt 是一个没有观察者的脆弱存储 属性。