如何处理 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 属性的信息,请查看官方文档。