有效的只读属性

2021-12-17 17:02:04 浏览数 (2)

  • 提议:SE-0310
  • 作者:Kavon Farvardin
  • Review 主管:Doug Gregor
  • 状态:在 Swift 5.5 已实现
  • 决策说明:提出点,接受点
  • 实现过程:apple/swift#36430, apple/swift#36670, apple/swift#37225

介绍

Swift 中类,结构体和枚举等类型支持计算属性和下标,这些计算属性和下标作为类型的成员,当获取或者设置这些成员时,他们触发程序员指定的计算。最近被接受的提案 SE-0296 介绍了通过和asyncawait来实现异步函数,但是没有指明计算属性和下标可以支持类似async这种异步效果。此外,为了充分利用async属性,用来指定一个属性throw同样重要。本文旨在通过为有效的只读属性和下标提供语法和语义来填补这部分空白。

专业术语

只读计算属性是指只有get方法的计算属性。同样的,只读下标是只定义get方法的下标。在本提案的剩余部分。任何对 属性下标 的提及均是指该成员的只读版本。而且除了特殊的指定,本文中同步,异步,asyncsync定义均来自SE-0296.

效果是函数的可观察行为。Swift 的类型系统跟踪几种效果:throws表明函数可能会沿着异常故障路径返回并出现错误,rethrows表示可以调用传递到函数中的抛出闭包,async表明函数可能到了一个挂起点。

本提案中的例子使用了来自其他提案的特性,比如structured concurrencyactors. 描述这些特性超出了本提案的范围,但是要充分把握本篇提案的动机,就需要对这些特性的重要性有基本的了解。

动机

异步函数被设计用来计算,这个计算过程在返回之前有可能或者一直挂起去执行。本提案的初衷是考虑由于缺乏只读的计算属性和下标,Swift 并发特性受限。所以我们优先考虑这些情况。然后,我们再考虑现有 Swift 代码中的编程模式,其中有效属性有助于简化代码。

Swift 并发

异步调用不能出现在同步调用中。这个基本限制意味着计算属性和下标将会被极度限制去使用 Swift 新的并发功能。对于他们唯一可用的并发能力是创建多个独立任务,但是无法同步等待这些任务的完成来直接拿到结果:

代码语言:txt复制
// ...
class Socket {
	// ...
	public var alive: Bool {
		get {
			let handle = Task.detached { await self.checkSocketStatus() }
			return await handle.value
			// `async` property access in a function that does not support concurrency.
		}
	}
	
	private func checkSocketStatus() async -> Bool { /* ... */ }
}

如果属性可以通过标记自身为async, 来告诉其他方法我需要先挂起来获取一个结果,而不是同步返回一个结果。这种方法会更好。这样的话,alive属性可以直接等待异步方法checkSocketStatus()的返回结果.

可以想象,如果某个类型是通过属性暴露来访问内部资源, 那么这个类型是不可能利用参与者actor来隔离并发访问资源。因为必须使用await从参与者隔离的上下文之外与 actor 参与者进行交互:

代码语言:txt复制
struct Transaction {}
enum BackError: Error {
    case NoManager
}

actor AccountManager {
    // NOTE: 当从外部调用该 actor 时,`getLastTransaction` 被默认标记为 `async`
    func getLastTransaction() -> Transaction { /* ... */ }
    func getTransactions(onDay: Date) async -> [Transaction] { /* ... */ }
}

class BankAccount {
    private let manager: AccountManager?
    
    var lastTransaction: Transaction {
        get {
            guard let manager = manager else {
                throw BackError.NoManager
                // error: cannot 'throw' in a non-throwing context
            }
            return await manager.getLastTransaction()
            // error: cannot 'await' in a sync context
        }
    }
   
    subscript(_ d: Date) -> [Transaction] {
        return await manager?.getTransactions(onDay: d) ?? []
        // error: cannot 'await' in a sync context
    }
}

lastTransactionthrow的使用强调这种设计模式对于属性和下标是不可用的。现在,lastTransaction必须要返回一个可选的TransactionOptional<Transaction>, 或者其他的类型类似 enum,tuple, 主要是考虑到不可用时返回失败情况。使用throw,属性能抛出具体错误,而不是简单的返回nil.

此外,计算属性 getter 方法不能接受显式参数,例如 completion handler 的闭包,访问属性的语法在底层设计时就不支持接受参数。像这种对输入参数的限制是计算属性和方法的关键区别之一。但是随着async函数的到来,completion-handler 参数出现异步函数中一去不复返。因此,拥有async的计算属性并不会违背现有的计算属性访问语法:这主要是类型系统中一个差别。

现有的代码

根据苹果 API 设计准则, 计算属性如果包含异步操作,不能快速返回,通常不是程序员期望的。

记录时间复杂度不为1的计算属性的复杂度。属性访问通常被认为没有太多重要的计算,因为大家通常把存储属性作为思维模型。当这个假设被违反时,一定要提醒他们。

但是,实际场景中计算属性有可能会阻塞或者计算失败。

举个需要有效属性的真实案例。SDK 定义了一个协议AVAsynchronousKeyValueLoading, 这个协议专门用于查询类属性的状态,同时提供异步机制来加载属性。遵循该协议的有AVAsset. AVAsset依赖此协议,因为它的只读属性是同步阻塞和可失败的。

上述AVAsynchronousKeyValueLoading解决的问题可以放到这个简单的例子中。在现有的代码中,无法让属性的get访问方法去接受一个 completion handler 参数,例如,使用某个结果值来调用属性的闭包。因此,这里需要有一个变通方法。其中一个方法是:定义另外属性的异步版本作为一个方法,该方法接受一个 completion handler 参数:

代码语言:txt复制
class NetworkResource {
	var isAvailable: Bool {
		get { /* a possibly blocking operation */ }
	}
	
	func isAvailableAsync(completionHandler: ((Bool) -> Void)?) {
		// method that returns without blocking.
		// completionHandler is invoked once operation completes.
	}
}

上面这段代码的问题是,即使在isAvailable注释说明它的get方法可能会阻塞,开发者也可能会错误的使用这个属性而不是使用方法isAvailableAsync. 因为这太容易忽视了。但是,如果isAvailable属性的get方法使用async声明,Swift 的类型系统就是强制开发者去使用await,这就可以告诉开发者对属性访问在返回结果之前可能会异步挂起。因此async效果说明符通过利用类型检查来提醒用户属性访问可能会涉及大量计算,从而增强了 API 设计准则 中提出的建议。

提议的解决方案 (Proposed solution)

针对上述描述的问题,提议的解决方案是允许async, throws, 或者组合来声明一个只读属性或者下标:

代码语言:txt复制
// ...
class BankAccount {
    var lastTransaction: Transaction {
        get async throws { 	// <-- NEW:effects specifiers!
            guard let manager = manager else {
                throw BackError.NoManager
            }
            return await manager.getLastTransaction()
        }
    }
   
    subscript(_ d: Date) -> [Transaction] {
    	get async { 	// <-- NEW: effects specifiers! 
    		return await manager?.getTransactions(onDay: d) ?? []
    	}
    }
}

在访问属性的地方,表达式将被视为具有 getter 上列出的效果,需要使用awaittry来修饰表达式。

代码语言:txt复制
extension BankAccount {
  func meetsTransactionLimit(_ limit: Amount) async -> Bool {
    return try! await self.lastTransaction.amount < limit
    //                    ^~~~~~~~~~~~~~~~
    //                    this access is async & throws
  }                
}

func hadWithdrawlOn(_ day: Date, from acct: BankAccount) async -> Bool {
  return await !acct[day].allSatisfy { $0.amount >= Amount.zero }
  //            ^~~~~~~~~
  //            this access is async
}

如果属性定义的唯一访问器是get, 计算属性或者下标才支持效果说明符。强制这个只读限制的主要目的是为了把本篇提案的范围限制为简单,有用且更易于理解。更多关于为什么实现有效的 setter 方法更加棘手的讨论,我们可以在本提案Extensions condidered章节查看。

详细设计

这个章节深入探讨了引入 Swift 的变化和一些实现,也就是本提案的结果。

语法和语义

在 声明的语法规则 中的类型变量属性章节,被推荐的修改点和新增点如下:

代码语言:txt复制
getter-clause  → attributes? mutation-modifier? "get" getter-effects? code-block
getter-effects → "throws"
getter-effects → "async" "throws"?

其中getter-effects是语法中的一个新增点,这个新增点允许get{效果说明符之间有3种可能的组合方式,同时确定asyncthrows之间的顺序,这是为了体现现有函数的顺序。此外, 可以通过在get后增加效果关键字为例如协议来声明效果属性. 如下面语法所指定的:

代码语言:txt复制
getter-setter-keyword-block → "{" getter-keyword-clause setter-keyword-clause? "}"
getter-setter-keyword-block → "{" setter-keyword-clause getter-keyword-clause "}"
getter-keyword-clause → attributes? mutation-modifier? "get" getter-effects?

例如这个协议例子:

代码语言:txt复制
protocol Account {
  associatedtype Transaction

  var lastTransaction: Transaction { get async throws }

  subscript(_ day: Date) -> [Transaction] { get async }
}

实现该协议的,实现 Account 协议中的属性和下标时,可以提供与协议中相同或者更少的效果关键字。

有效属性定义很简单:在get中定义的code-block允许出现效果指,例如,抛出或者挂起tryawait表达式被允许出现在代码块中。而且,计算一个属性和下标的getter方法表达式将会被以属性声明的效果对待。其实可以将这些表达式看作是对对象的方法调用一样,只是没有getter这种语法糖。其实我们始终可以确定某个属性是否具有此类asynctry等效果,因为属性是静态声明的,如果我们在需要的地方疏忽了awaittry等,编译器也会出现静态报错。

协议一致性

上文提到过,为了让某个类型去遵循包含效果属性的协议,该类型必须实现效果属性或者下标,而且类型中实现的属性或者下标中修饰的效果词不超过原协议。此规则反映了如何对具有效果词修饰的函数进行一致性检查:协议具体实现可能会遗漏某个效果词,但是它不能展示协议中没有指定的效果词。下面是个类型正确的例子,没有任何多余的的awaittry, 遵循该规则:

代码语言:txt复制
protocol P {
  var someProp: Int { get async throws }
}

class NoEffects: P { var someProp: Int { get { 1 } } }

class JustAsync: P { var someProp: Int { get async { 2 } } }

struct JustThrows: P { var someProp: Int { get throws { 3 } } }

struct Everything: P { var someProp: Int { get async throws { 4 } } }

func exampleExpressions() async throws {
  let _ = NoEffects().someProp
  let _ = try! await (NoEffects() as P).someProp

  let _ = await JustAsync().someProp
  let _ = try! await (JustAsync() as P).someProp

  let _ = try! JustThrows().someProp
  let _ = try! await (JustThrows() as P).someProp

  let _ = try! await Everything().someProp
  let _ = try! await (Everything() as P).someProp
}

一般来说,我们认为某个 getter 方法G有一系列与它相关的效果说明符,我们称之为effects(G). 本提案给一致性检测增加了一条规则:如果说某个 getter 方法W实现协议的 getter 方法R, 此时我们认为W 的效果说明符集合是R的效果说明符集合的子集,即effects(W)effects(R)的子集。

类继承

有效属性和下标可以从基类继承。关键不同点是,如果要重载继承的有效属性(或者下标),子类属性的效果说明符数量不能超过被重载属性。此规则是类子类化的本质结果:基类必须考虑其子类可能展示的所有效果说明符。本质上,该规则和协议一致性规则相同。

Objective-C 桥接

一些 API 设计人员可能希望通过将 Objective-C 方法导入作为属性来利用 Swift 的有效属性。正常来说 Objective-C 方法导入作为 Swift 方法,所以把他们导入作为 Swift 有效属性将会通过 opt-in 注释进行控制。这避免了导入声明的任何源兼容性问题。

由于 Swift 有效属性只读特性限制,并且大部分 Objective-C 可失败的函数已经导入作为 Swiftthrows函数,在本提案中对Objective-C 桥接支持适用于 Swift 并发特性。本提案不讨论 Objective-C 导入作为有效下标. 而且,将有效属性导出到 Objective-C 作为方法是以后的工作。

为了把 Objective-C 方法导入作为 Swift 有效属性,如 SE-0297 描述一样,该方法必须与asyncSwift 方法的导入规则兼容。注解改变导入行为,产生有效计算属性,而不是asyncSwift 方法。原 Objc 方法仍然与属性并排,作为一个正常 Swift 方法导入。

综上所述,如果 Objective-C 方法满足以下要求:

  1. 方法带一个参数,completion handler,
  2. 方法返回 void.
  3. 方法使用__attribute__((swift_async_name("getter:myProp()")))注解。注意getter:的用法,指定它应该是属性而不是方法。

该 Objective-C 方法将会被导入成为 Swift 有效只读属性,名字为myProp, 而不是 Swiftasync(也可能是throws)方法. 下面是 SDK 中一些已经被注解为导入作为 Swift 中有效属性的方法例子:

代码语言:txt复制
// from Safari Services
@interface SFSafariTab: NSObject
- (void)getPagesWithCompletionHandler:(void (^)(NSArray<SFSafariPage *> *pages))completionHandler
__attribute__((swift_async_name("getter:pages()")));
// ...
@end

// from Exposure Notification
@interface ENManager: NSObject
- (void)getUserTraveledWithCompletionHandler:(void (^)(BOOL traveled, NSError *error))completionHandler
__attribute__((swift_async_name("getter:userTraveled()")));; // TODO: 这里多个标点 `;` 需要给苹果提 MR,删除
// ...
@end

被导入到 Swift 中使用:

代码语言:txt复制
class SFSafariTab: NSObject {
	var pages: [SFSafariPage] {
		get async { /* ... */ }
	}
	// ....
}

class ENManager: NSObject {
	var userTraveled: Bool {
		get async throws { /* ... */ }
	}
}

源兼容性

如果本篇中提议的语法改变出现在之前的语言版本中,会被解析器以错误抛出处理。

对 ABI 稳定性的影响

本篇提案是附加的,且有意限制了范围,避免破坏 ABI 稳定性。

对 API 扩展的影响

作为一个附加特性,不会影响 API 的扩展性。但是,已有使用有效只读属性的 APIs 将会破坏向后兼容性。因为 APIs 的使用者会使用awaittry包装属性的访问。

扩展考虑

在本节中,我们将讨论本提案的延伸和附加部分,以及为什么不将他们纳入到上述提议设计中。

有效的 set 属性

在 async 或者 throwing 标记的可写属性之间定义交互,例如:

  1. inout
  2. _modify
  3. property observers, 例如didSet,willSet
  4. property wrappers
  5. writable subscripts

这是一个需要做大量工作的大型项目。本篇提案初衷是允许在计算属性和下标中使用 Swift 并发特性。为有效的只读属性提议的设计实现起来轻量而且简单,同时为现有的程序提供明显的好处。

Key Paths

key-path expression 是KeyPath类实例的语法糖,and its type-erased siblings. 有效属性的引入需要更改每种类型的subscript(keyPath:)的合成。对于可以访问有效属性的关键路径,还可能需要对类型擦除做严格限制。

例如,因为我们不允许基于仅仅是效果说明符不同的函数重载,subscript(keyPath:)会以某些机制例如 rethrowsasync的等价版本(比如可能叫 "reasync")作为开头。虽然 key-path 字面上 被看成函数, 但是KeyPath值不是函数,所以在它类型里是无法带上效果说明符。当尝试调用时subscript(keyPath:)rethrows版本时,会出现问题。

我们也可以引入其他种类,拥有不同功能的 key-Paths,像现有的WritableKeyPathReferenceWritablePath. 然后,我们可以使用正确的效果说明符来合成subscript。比如 subscript<T: ThrowingKeyPath>(keyPath: T) throws. 除了无效果说明符之外,这将需要所有三种新效果说明符组合的KeyPath类型.

所以,对类型系统非比寻常的重构,或者对KeyPathAPI 重要的扩展,都将会要求 key-paths 可以对有效属性起作用。因此,从现在开始,我们将不允许通过 key-paths 来访问有效属性。 key-paths 对实例类型的不可变属性已经存在这些限制,例如 (WritableKeyPath), 所以不允许有效属性的 key-paths 并不罕见。

备选考虑方案

在本节中,我们将讨论本提案的备选方案。

效果说明符位置

有许多个地方可以放置效果说明符:

代码语言:txt复制
<A> var prop: Type <B> {
	<C> get <D> {  } 
}
  • A: 最初被访问修饰符比如private(set)或者声明修饰符比如override使用。大多的效果说明符只能在 getter 方法声明之前,也就是 C 位置,这更在结构体里的一个方法很相似。这个位置不能被占用,因为像 override async throws var prop或者async throws override var prop这类短语可读性不好。
  • B:这位置看起来意义不大,因为效果说明符仅仅是函数类型的一部分,而不是其他类型的某部分。所以,放在这个位置会让大家很困惑,以为Int async throws是个类型,事实并不是。排除在这里引入新的标点符号,此处存在替代性。
  • C: 看起来还行。这个位置仅被mutatingnonmutating占用,但是这里放置效果说明符与函数的位置不一致,函数位于效果说明符之后了。因为位置 D 被采用,D 比 C 使用更有意义。
  • D: 最终在本提案中采纳的位置。这个位置在语法中没有使用,把效果说明符放置在访问器上而不是变量或者类型上。另外,它与函数声明中作用的位置一致,位于主题之后:get throwsget async throws, 其中 get 是主题。另外一个好处是远离变量,所以它可以避免访问说明符和函数返回的效果说明符之间的混淆:
代码语言:txt复制
 var predicate: (Int) async throws -> Bool {

 	get throws { / _..._ / } 

 }

访问predicate的 get 方法可能会 throw, 但是如果没有,它产生一个 async throws 的函数。

这里可以使用隐式 getter 来简写:

代码语言:txt复制
var predicate: (Int) async throws -> Bool { /* ... */ }

但是因为语法糖会为了简洁而放弃灵活性, 这里没法放效果说明符。所以,不允许有效属性使用简写的语法声明也是可以的。计算属性完整的语法明确定义了存取器(如 get),也就可以声明效果说明符。

下标

下标与计算属性主要不同点在于像方法的头语法,支持隐式 getter 语法糖。这两个不同点让下标更像一个方法:

代码语言:txt复制
class C {
	subscript(_ : InType) <E> -> RetType { /* ...  */ }
}

位置E 对于下标的效果说明符来说是一个诱人的位置,但是下标不是方法,无法使用c.subscript的一级函数值访问它们,也不是使用c.subscript(0)调用。他们使用索引语法c[0]. 方法不可以被赋值,但是下标索引表达式可以。因此,相对属性来说,下标更接近去接受一个入参。

很多像只读属性的简写形式,如果将来可写下标支持效果说明符,那么尝试从只读下标(不论位置是 E 还是其他)的简写组成中去找到效果说明符的位置,将会让此功能受到局限。为什么呢?位置 E 在简写和完成语法中都是逻辑合法的点,在两者之间创造了不一致。然后,使用位置 E 和完整语法会在下面这边情况混淆:

代码语言:txt复制
subscript(_ i : Int) throws -> Bool {
	get async {}
	set {}
}

这里唯一合乎逻辑的解释是set是 throws,get是 async throws. 开发人员需要在多个调用的前面加上效果说明符来确定存储访问支持哪个效果说明符。上面例子看起来不是那么差,但是考虑到要跳过一大段get定义来了解当前下标set允许的效果说明符,但是计算属性事实不需要这么做。

杂记

rethrows说明符排除在本篇提案内容上,因为在属性get操作期间无法传递闭包 (或其他任何显式值)。

async/await特性是专门为异步编程定制的,因此不用考虑异步属性不依赖该特性的备选方案。同样,throws/try也是一样。

0 人点赞