Swift Digest
SE-0310 | Swift Evolution

Effectful Read-only Properties

Proposal
SE-0310
Authors
Kavon Farvardin
Review Manager
Doug Gregor
Status
Implemented (Swift 5.5)

01 何が問題だったのか

Swift の computed property や subscript の get は、これまで 同期 かつ throw しない ことが前提でした。そのため、Swift Concurrency(SE-0296 で導入された async/await や、SE-0306 のアクター)を computed property から自然に使うことができませんでした。

async が使えない

同期コンテキストの中では await を書けないため、非同期処理の結果をプロパティから返す手段がありません。

class Socket {
  public var alive: Bool {
    get {
      let handle = detach { await self.checkSocketStatus() }
      return await handle.get()
      //     ^~~~~ error: cannot 'await' in a sync context
    }
  }

  private func checkSocketStatus() async -> Bool { /* ... */ }
}

アクターが持つ状態をプロパティ経由で外部に公開しようとした場合も、アクター外からのアクセスは await を必要とするため、同期な get の中からは呼べません。

actor AccountManager {
  func getLastTransaction() -> Transaction { /* ... */ }
}

class BankAccount {
  private let manager: AccountManager?
  var lastTransaction: Transaction {
    get {
      return await manager!.getLastTransaction()
      //     ^~~~~ error: cannot 'await' in a sync context
    }
  }
}

throws が使えない

get の中でエラーを投げることもできません。そのため、失敗を表現したいプロパティは Optional を返すか、Result や独自の enum を返すかといった回避策を取らざるをえず、Swift の通常のエラーハンドリング機構に乗せられませんでした。

var lastTransaction: Transaction {
  get {
    guard let manager = manager else {
       throw BankError.NoManager
    // ^~~~~ error: cannot 'throw' in a non-throwing context
    }
    // ...
  }
}

既存コードでの実例

ブロックしうる/失敗しうるプロパティは実際のコードにも存在します。AVFoundation の AVAsynchronousKeyValueLoading プロトコルは、AVAsset のプロパティが同期的にアクセスするとブロックしたり失敗したりする問題を、別メソッドで非同期に値を読み込ませることで回避するために存在しています。

自作コードでも同じ問題は起きます。たとえば次のように、同名の非同期版メソッドを別途用意するパターンです。

class NetworkResource {
  var isAvailable: Bool {
    get { /* ブロックしうる処理 */ }
  }
  func isAvailableAsync(completionHandler: ((Bool) -> Void)?) {
    // 非同期版
  }
}

この書き方には、「プロパティアクセスはすぐ返るもの」という読み手の思い込みに反して isAvailable がブロックしうる、というドキュメント任せのリスクがあります。get 自体に async を付けられれば、型システムが await を強制するので、利用者に「このアクセスは中断しうる」と明示できます。

02 どのように解決されるのか

read-only な computed property と subscript(get だけを持つもの)の get に、asyncthrows・その両方の effect specifier を書けるようにします。

class BankAccount {
  var lastTransaction: Transaction {
    get async throws {
      guard manager != nil else {
        throw BankError.notInYourFavor
      }
      return await manager!.getLastTransaction()
    }
  }

  subscript(_ day: Date) -> [Transaction] {
    get async {
      return await manager?.getTransactions(onDay: day) ?? []
    }
  }
}

アクセス側は、その get が宣言している effect に応じて awaittry を書く必要があります。

extension BankAccount {
  func meetsTransactionLimit(_ limit: Amount) async -> Bool {
    return try! await self.lastTransaction.amount < limit
    //                    ^~~~~~~~~~~~~~~~
    //                    このアクセスは async かつ throws
  }
}

func hadWithdrawlOn(_ day: Date, from acct: BankAccount) async -> Bool {
  return await !acct[day].allSatisfy { $0.amount >= Amount.zero }
  //            ^~~~~~~~~
  //            このアクセスは async
}

effect specifier は get{ の間に、関数と同じ順序(async throws の順)で書きます。get throwsget asyncget async throws の3通りの組み合わせが許されます。effect を持てるのは read-only な場合だけで、set を併設するプロパティや subscript は対象外です。

書ける位置は get の直後だけ

プロパティ宣言には effect specifier を書ける位置がいくつか考えられますが、本提案では get の直後 に固定しています。こう書くことで、関数の effect specifier の位置と揃い、またプロパティの型が関数型のときにも、アクセス時の effect と戻り値の関数型の effect を混同せずに書けます。

var predicate: (Int) async throws -> Bool {
  get throws { /* ... */ }
}
// predicate へのアクセスは throws。得られた関数値の呼び出しは async throws。

なお、implicit getter のショートハンド(var x: Int { 0 } のように get を省略する書き方)では effect specifier を書く場所がないため、effectful property を書きたいときは明示的な get を伴うフル構文を使います。

プロトコルでの宣言

プロトコル側でも同じ書き方で effectful な要件を宣言できます。

protocol Account {
  associatedtype Transaction

  var lastTransaction: Transaction { get async throws }

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

適合する型の witness は、要件と同じかそれより少ない effect しか持てません。関数の effect に関する適合ルールと同じで、要件で async throws が許されているところに、効果のない getter や get async / get throws だけの getter を当てるのは OK です。逆に、要件に無い effect を witness 側で追加することはできません。アクセス側では常に要件が許す effect がかかるので、try!await を付けて呼び出します。

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! JustThrows().someProp
  let _ = try! await Everything().someProp
}

クラス継承でのオーバーライド

クラスの effectful property / subscript をサブクラスでオーバーライドする際も、同じ「同じかそれより少ない effect」のルールが適用されます。サブクラスがさらに強い effect を持つとベースクラス側でその effect を取り扱えないため、サブタイプ関係として自然な制約です。

Objective-C メソッドの effectful property としてのインポート

SE-0297 により、completion handler を取る Objective-C メソッドは Swift の async メソッドとしてインポートされます。これに加えて、オプトインのアノテーションを付けると、Swift 側で effectful な read-only property としてインポートされるようになります。

条件は次のとおりです。

  1. 引数が completion handler 一つだけであること
  2. 戻り値が void であること
  3. __attribute__((swift_async_name("getter:プロパティ名()"))) を付けること

元の Swift メソッドとしてのインポートも残るので、メソッド形・プロパティ形の両方から呼べます。

@interface SFSafariTab: NSObject
- (void)getPagesWithCompletionHandler:(void (^)(NSArray<SFSafariPage *> *pages))completionHandler
__attribute__((swift_async_name("getter:pages()")));
@end
class SFSafariTab: NSObject {
  var pages: [SFSafariPage] {
    get async { /* ... */ }
  }
}

key path では使えない

effectful property は key path 経由のアクセスはサポートされません。KeyPath そのものは型の中に effect を持たないため、subscript(keyPath:) を通じて asyncthrows を伝搬させる仕組みがまだ整っていないためです。型システムや KeyPath 周りの拡張が行われるまでは、effectful property を key path で扱うことはできません。

rethrows は対象外

rethrows はクロージャ引数を取ることが前提の仕組みですが、プロパティ get は引数を取れないため、この提案では rethrows は導入されません。

Future Directions

本提案は read-only の computed property と subscript に限定しており、書き込み可能なプロパティ・subscript への effect 付けは対象外です。inout_modifydidSet/willSet・プロパティラッパーといった既存機能との相互作用の設計が大きな課題になるため、将来の拡張として切り離されています(あくまで今後の方向性であり、実現が約束されるものではありません)。