如何處理 Swift 屬性的包裝器

已發表: 2022-03-11

簡單來說,屬性包裝器是一種通用結構,它封裝了對屬性的讀寫訪問,並為其添加了額外的行為。 如果我們需要限制可用的屬性值,我們會使用它來為讀/寫訪問添加額外的邏輯(比如使用數據庫或用戶默認值),或者添加一些額外的方法。

Swift 5.1 中的屬性包裝器

本文是關於 Swift 5.1 包裝屬性的新方法,它引入了一種新的、更簡潔的語法。

舊方法

假設您正在開發一個應用程序,並且您有一個包含用戶配置文件數據的對象。

 struct Account { var firstName: String var lastName: String var email: String? } let account = Account(firstName: "Test", lastName: "Test", email: "[email protected]") account.email = "[email protected]" print(account.email)

您想要添加電子郵件驗證 - 如果用戶電子郵件地址無效,則email屬性必須為nil 。 這將是使用屬性包裝器封裝此邏輯的好案例。

 struct Email<Value: StringProtocol> { private var _value: Value? init(initialValue value: Value?) { _value = value } var value: Value? { get { return validate(email: _value) ? _value : nil } set { _value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-za-z]{2,64}" let pred = NSPredicate(format: "SELF MATCHES %@", regex) return pred.evaluate(with: email) } }

我們可以在 Account 結構中使用這個包裝器:

 struct Account { var firstName: String var lastName: String var email: Email<String> }

現在,我們確定 email 屬性只能包含有效的電子郵件地址。

一切看起來都不錯,除了語法。

 let account = Account(firstName: "Test", lastName: "Test", email: Email(initialValue: "[email protected]")) account.email.value = "[email protected]" print(account.email.value)

使用屬性包裝器,初始化、讀取和寫入此類屬性的語法變得更加複雜。 那麼,是否有可能避免這種複雜性並在不更改語法的情況下使用屬性包裝器? 使用 Swift 5.1,答案是肯定的。

新方式:@propertyWrapper 註解

Swift 5.1 為創建屬性包裝器提供了更優雅的解決方案,其中允許使用@propertyWrapper註釋標記屬性包裝器。 與傳統包裝器相比,此類包裝器具有更緊湊的語法,從而產生更緊湊和易於理解的代碼。 @propertyWrapper註釋只有一個要求:您的包裝器對象必須包含一個名為wrappedValue的非靜態屬性。

 @propertyWrapper struct Email<Value: StringProtocol> { var value: Value? var wrappedValue: Value? { get { return validate(email: value) ? value : nil } set { value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: email) } }

要在代碼中定義這樣的包裝屬性,我們需要使用新的語法。

 @Email var email: String?

所以,我們用註解@標記了屬性. 屬性類型必須與包裝器的 `wrappedValue` 類型匹配。 現在,您可以像使用普通屬性一樣使用此屬性。

 email = "[email protected]" print(email) // [email protected] email = "invalid" print(email) // nil

太好了,現在看起來比舊方法更好。 但是我們的包裝器實現有一個缺點:它不允許包裝值的初始值。

 @Email var email: String? = "[email protected]" //compilation error.

為了解決這個問題,我們需要將以下初始化程序添加到包裝器中:

 init(wrappedValue value: Value?) { self.value = value }

就是這樣。

 @Email var email: String? = "[email protected]" print(email) // [email protected] @Email var email: String? = "invalid" print(email) // nil

包裝器的最終代碼如下:

 @propertyWrapper struct Email<Value: StringProtocol> { var value: Value? init(wrappedValue value: Value?) { self.value = value } var wrappedValue: Value? { get { return validate(email: value) ? value : nil } set { value = newValue } } private func validate(email: Value?) -> Bool { guard let email = email else { return false } let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let emailPred = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailPred.evaluate(with: email) } }

可配置的包裝器

讓我們再舉一個例子。 您正在編寫一個遊戲,並且您有一個存儲用戶分數的屬性。 要求是該值應大於或等於 0 且小於或等於 100。您可以通過使用屬性包裝器來實現這一點。

 @propertyWrapper struct Scores { private let minValue = 0 private let maxValue = 100 private var value: Int init(wrappedValue value: Int) { self.value = value } var wrappedValue: Int { get { return max(min(value, maxValue), minValue) } set { value = newValue } } } @Scores var scores: Int = 0

此代碼有效,但似乎並不通用。 您不能使用不同的約束(不是 0 和 100)重用它。 此外,它只能約束整數值。 最好有一個可配置的包裝器,它可以約束任何符合 Comparable 協議的類型。 為了使我們的包裝器可配置,我們需要通過初始化程序添加所有配置參數。 如果初始化器包含一個wrappedValue屬性(我們屬性的初始值),它必須是第一個參數。

 @propertyWrapper struct Constrained<Value: Comparable> { private var range: ClosedRange<Value> private var value: Value init(wrappedValue value: Value, _ range: ClosedRange<Value>) { self.value = value self.range = range } var wrappedValue: Value { get { return max(min(value, range.upperBound), range.lowerBound) } set { value = newValue } } }

為了初始化一個包裝的屬性,我們在註解後面的括號中定義了所有的配置屬性。

 @Constrained(0...100) var scores: Int = 0

配置屬性的數量是無限的。 您需要按照與初始化程序中相同的順序在括號中定義它們。

訪問包裝器本身

如果您需要訪問包裝器本身(而不是包裝的值),則需要在屬性名稱前添加下劃線。 例如,讓我們以 Account 結構為例。

 struct Account { var firstName: String var lastName: String @Email var email: String? } let account = Account(firstName: "Test", lastName: "Test", email: "[email protected]") account.email // Wrapped value (String) account._email // Wrapper(Email<String>)

我們需要訪問包裝器本身才能使用我們添加到它的附加功能。 例如,我們希望 Account 結構符合 Equatable 協議。 如果兩個帳戶的電子郵件地址相等,則兩個帳戶相等,並且電子郵件地址必須不區分大小寫。

 extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs.email?.lowercased() == rhs.email?.lowercased() } }

它有效,但它不是最好的解決方案,因為我們必須記住在比較電子郵件的任何地方添加一個小寫()方法。 更好的方法是使電子郵件結構相等:

 extension Email: Equatable { static func ==(lhs: Email, rhs: Email) -> Bool { return lhs.wrappedValue?.lowercased() == rhs.wrappedValue?.lowercased() } }

並比較包裝器而不是包裝的值:

 extension Account: Equatable { static func ==(lhs: Account, rhs: Account) -> Bool { return lhs._email == rhs._email } }

預計價值

@propertyWrapper註釋提供了另一種語法糖 - 投影值。 此屬性可以具有您想要的任何類型。 要訪問此屬性,您需要在屬性名稱中添加$前綴。 為了解釋它是如何工作的,我們使用了 Combine 框架中的一個例子。

@Published屬性包裝器為屬性創建發布者並將其作為預計值返回。

 @Published var message: String print(message) // Print the wrapped value $message.sink { print($0) } // Subscribe to the publisher

如您所見,我們使用消息來訪問包裝的屬性,並使用 $message 來訪問發布者。 你應該怎麼做才能為你的包裝器添加一個預計值? 沒什麼特別的,聲明一下。

 @propertyWrapper struct Published<Value> { private let subject = PassthroughSubject<Value, Never>() var wrappedValue: Value { didSet { subject.send(wrappedValue) } } var projectedValue: AnyPublisher<Value, Never> { subject.eraseToAnyPublisher() } }

如前所述, projectedValue屬性可以根據您的需要具有任何類型。

限制

新的屬性包裝器的語法看起來不錯,但它也包含一些限制,主要是:

  1. 他們不能參與錯誤處理。 包裝的值是一個屬性(不是方法),我們不能將 getter 或 setter 標記為throws 。 例如,在我們的Email示例中,如果用戶嘗試設置無效電子郵件,則不可能引發錯誤。 我們可以通過fatalError()調用返回nil或使應用程序崩潰,這在某些情況下可能是不可接受的。
  2. 不允許對屬性應用多個包裝器。 例如,最好有一個單獨的@CaseInsensitive包裝器並將其與@Email包裝器結合,而不是使@Email包裝器不區分大小寫。 但是像這樣的結構是被禁止的,會導致編譯錯誤。
 @CaseInsensitive @Email var email: String?

作為這種特殊情況的解決方法,我們可以從CaseInsensitive包裝器繼承Email包裝器。 但是,繼承也有限制——只有類支持繼承,並且只允許一個基類。

結論

@propertyWrapper註解簡化了屬性包裝器的語法,我們可以像處理普通屬性一樣操作被包裝的屬性。 作為 Swift 開發人員,這使您的代碼更加緊湊和易於理解。 同時,它有幾個我們必須考慮的限制。 我希望它們中的一些將在未來的 Swift 版本中得到糾正。

如果您想了解更多關於 Swift 屬性的信息,請查看官方文檔。