抽象属性的行为 —— Property Delegates

之前 Joe 跟 Doug 提了一个草案,提议加入一个叫做 Property Delegate 的提案,昨天作为一份预备提案发了出来,我看了之后觉得特别兴奋,所以写了这篇文章跟大家分享一下。

简介

我们在 Swift 定义属性的时候,有时候会需要它表现出更复杂的行为,例如线程安全(例子来源于 Objc.io 的这篇文章):

private let queue: DispatchQueue = ...
private var _count: Int = 3
    
var count: Int {
  get { return queue.sync { _count } }
  set { queue.sync { self._count = newValue } }
}

但每次都需要写这么长的代码显得很多余,所以这次提案提议增加一个名为 Property Delegates 的功能来简化这些属性的声明,这个名字翻译过来就是属性代理

有了这个功能之后,想要一个线程安全的属性时只要加上一个注解 @Atomic 即可:

@Atomic var count: Int = 3

编译器会自动将属性的声明展开成下面这样:

var $count = Atomic<Int>(initialValue: 3)
var count: Int {
  get { return $count.value }
  set { $count.value = newValue }
}

$count 其实就是 count 的属性代理,一切 get / set 行为都会通过 $count 去实现,$ 是属性代理特有的前缀,我们只要在属性名前面加上就可以访问到实际的属性代理:

counter.count  // 类型是 Int
counter.$count // 类型是 Atomic<Int>

Atomic 其实是一个具体的类型,声明属性代理的方式很简单,只要满足下面的要求即可:

  1. 声明类型时必须用 @propertyDelegate 标注
  2. 必须有一个可读写 value 属性

这里我简单实现了一个 Atomic 类型:

@propertyDelegate
class Atomic<Value> {
  private let queue = DispatchQueue(label: "Atomic serial queue")
  private var _value: Value
    
  init(_ value: Value) {
    self._value = value
  }

  var value: Value {
    get { return queue.sync { self._value } }
    set { queue.sync { self._value = newValue } }
  }
}

BTW,这个功能是借鉴 Kotlin 的,语法有微妙的区别。

拓展

除了 Atomic 之外,还有很多玩法,例如通过 UserDefaults 存取的值:

@propertyDelegate
struct UserDefaultValue<T> {
  let key: String
  
  init(key: String {
    self.key = key
  }
  
  var value: T {
    get { return UserDefaults.standard.object(forKey: key) as! T }
    set { UserDefaults.standard.set(newValue, forKey: key) }
  }
}

@UserDefaultValue(key: "count") var count: Int

甚至是 Swift 值类型使用的优化技巧 —— 写时复制:

protocol Copyable: AnyObject {
  func copy() -> Self
}

@propertyDelegate
struct CopyOnWrite<Value: Copyable> {
  init(initialValue: Value) {
    value = initialValue
  }
  
  private(set) var value: Value
  
  var storageValue: Value {
    mutating get {
      if !isKnownUniquelyReferenced(&value) {
        value = value.copy()
      }
      return value
    }
    set {
      value = newValue
    }
  }
}

@CopyOnWrite var array = NSMutableArray<Int>()

目前 Swift 对于这些属性的高级行为支持是硬编码在语言里的,例如说 lazy@NSCopying,更好的方式是将通过某种统一的功能形式来完成它们的功能,Property Delegate 就是这么一个角色。

问题

将属性的行为使用属性代理来处理,并且让它们的声明糅合到一起能够简化代码,但也会带来很多问题,在这里我列举其中一部分出来。

原有的 lazy 没办法被完全替代

Property Delegate 只是单纯的语法糖,我们可以把它看成是一种特殊的宏,让编译器把属性的声明进行了简单的展开,但它本质上并没有改变语言运作的方式,现有代码能做到的它也能做到,现有代码做不到的它也做不到

最典型的就是前面提到的 Lazy,Property Delegate 目前的设计让它没办法完全替代现有的 lazy 声明,原因很简单 —— 它访问不到 self

lazy var count = self.previousCount
@Lazy var count = self.previousCount // 编译错误

访问级别

语法如何融入到当前的设计里也是一个难点首先是访问级别,当我们声明一个延迟加载的属性时 @Lazy var count: Int = 3,我们希望 countopen 的,并且让属性代理 $count 隐藏起来,语法该怎么设计会更好?

目前提案给出的解决方案是让它们的访问级别保持一致,之后的版本可能会仿照 set 的访问级别的声明这样去处理:

@Lazy
public private(storage) var count: Int = 330

但现阶段如果有需要的话,就还是用回之前的写法(不使用 Property Delegate)。

组合属性代理

我们可能会需要将属性代理组合起来使用:

@Lazy
@Atomic
var object: NSObject = ...

直觉上我们会期望 object 既是线程安全的,又是写时复制的,当问题在于当前设计里的属性代理,代理处理的不只是 setter 和 getter,还有属性的存储,这也就导致了属性代理无法组合到一起,但前面提到 Property Delegate 只是单纯的语法糖,如果把这段代码展开,那它可能会是这样的:

var object: NSObject {
  get { return $object.value }
  set { $object.value = newValue }
}

var $object: Atomic<NSObject> {
  get { return $$object.value }
  set { $$object.value = value }
}

var $$object: Lazy<Atomic<NSObject>>= ... 

官方的解释是,这样的写法虽然在大部分情况下能够表现出我们期望的行为,但实际的语句含义与我们表达的并不相符,并且部分情况下可能会产生预期之外的行为,所以目前只支持单个属性代理。

BTW,这一部分官方其实写的比较含糊,我自己也没太理解什么情况下会出问题。

其它

其它一些细枝末节的东西也挺多的,例如是否应该使用 $ 作为标示等,有人提议使用下划线,这样会更加符合现有代码库的做法,把 $ 这个标示留下来给以后的功能使用。

还有什么命名一致性的问题,目前的注解有大写驼峰,也有小写驼峰,并且也没有一个统一的规则,虽然这不在这个提案的讨论范围内,但还是需要给之后的规则保留足够的制定空间。

结语

增强属性的声明其实早在 SE-0030 提案里就有了,但因为底层设计没有稳定,而且优先级不高,所以这个提案虽然通过了,但一直没有实现,现在里面的设计已经不太符合现在的 Swift 了。

目前 Property Delegate 的提案还没有正式提交,正在讨论阶段,但其实功能本身的思路和优势是很清晰的,我个人认为这种功能肯定会被引入,讨论的重点主要在于语法的形式,如何与现有的语法结合的更好,如何适应之后自定义注解功能的加入。