【译】Swift属性包装

124 阅读10分钟

原文地址 Swift Property Wrappers

几年前,我们说过,符号” (@) —的“加上方括号和长得离谱的方法名—是目标C的一个决定性特征。然后是斯威夫特,伴随着它结束了这些奇怪的小 🥨-形状的字形...or所以我们认为。

起初,@的功能仅限于目标-C互操作性:@IB Action、@NS科普ing、@UI Application Main等。但是随着时间的推移,Swift继续包含越来越多的@前缀**属性**。

通过Swift UI公告,我们在**2019年WWDC**首次看到了Swift 5.1。随着每一张“令人震惊的”幻灯片都带来了一个迄今未知的属性:@State,@绑定,@环境对象...

我们看到了斯威夫特的未来,它充满了@s。

我们’会潜入Swift UI一旦它有一个有点长的烤。

但是本周,我们想更仔细地看看Swift UI的一个关键语言功能—可以说是对5.1版及以后的Swift的“je ne sais quoi”有最大影响的东西:属性包装

关于财产代表包装

属性包装早在2019年3月就首次发布到**Swift论坛,比Swift **UI公开发布早—个月。

在他最初的推介中,Swift核心团队成员道格拉斯·格雷格(Douglas Gregor)将该功能(当时被称为*“属性代表”)*描述为用户可访问的功能泛化,目前由语言功能(如懒惰关键字)提供。

懒惰是编程中的一个优点,这种广泛有用的功能是深思熟虑的设计决策的特点,这些决策使Swift成为一种很好的语言。当一个属性被声明为懒惰时,它会将默认值的初始化推迟到第一次访问。例如,您可以自己使用一个私有属性来实现等效的功能,该属性的访问由计算属性包装,但是一个懒惰关键字使得所有这些都不必要。

展开来懒洋洋地评估这个代码表达式。

struct Structure {    // Deferred property initialization with lazy keyword    lazy var deferred = …    // Equivalent behavior without lazy keyword    private var _deferred: Type?    var deferred: Type {        get {            if let value = _deferred { return value }            let initialValue = …            _deferred = initialValue            return initialValue        }        set {            _deferred = newValue        }    }}

**SE-0258:属性包装器**目前正在进行第三次审查(计划于昨天出版时结束),它承诺开放像懒惰这样的功能,以便库作者可以自己实现类似的功能。

这个提议很好地概述了它的设计和实现。因此,与其试图改进这个解释,我们认为’有趣的是看看属性包装使之成为可能的一些新模式—并且在这个过程中,更好地处理我们如何在我们的项目中使用这个特性。

因此,为了您的考虑,这里有四个新的@Property Wrapper属性的潜在用例:

约束值

SE-0258提供了大量的实例,包括@Lazy、@原子、@Thread特定和@Box。但是我们最感兴趣的是@约束属性包装器。

Swift的标准库提供**正确的、高性能的、浮点数类型,你可以拥有你想要的任何大小—只要它是3264(或80*****)位长(套用亨利·福特***的话)。

如果您想实现一个自定义浮点数类型来强制一个有效的值范围,这从**Swift 3**开始就有可能了。然而,这样做需要符合迷宫般的协议要求。

快速按整数浮点数快速按浮点数二进制浮点数浮点数80双签名数字签名整数智能加法算术数字固定宽度整数二进制整数可比等式未签名整数UInt学分: **Swift数字飞行学校**指南

实现这一点不是一个小壮举,而且对于大多数用例来说,工作往往太多,无法证明是合理的。

幸运的是,属性包装器提供了一种参数化标准数字类型的方法,而且花费的精力要少得多。

实现值箝位属性包装器

考虑下面的夹紧结构。作为属性包装器(由@Property Wrapper属性表示),它会自动“夹紧在指定范围内”超出约束的值。

@propertyWrapperstruct Clamping<Value: Comparable> {    var value: Value    let range: ClosedRange<Value>    init(initialValue value: Value, _ range: ClosedRange<Value>) {        precondition(range.contains(value))        self.value = value        self.range = range    }    var wrappedValue: Value {        get { value }        set { value = min(max(range.lowerBound, newValue), range.upperBound) }    }}

您可以使用@Cl**amping来保证化学溶液中的属性建模酸度**在0–14的常规范围内。

struct Solution {    @Clamping(0...14) var pH: Double = 7.0}let carbonicAcid = Solution(pH: 4.68) // at 1 mM under standard conditions

试图将pH值设置在该范围之外会导致使用最接近的边界值(最小值或最大值)。

let superDuperAcid = Solution(pH: -1)superDuperAcid.pH // 0

相关理念

  • @正/@non Negative属性包装器,它为有符号整数类型提供无符号保证。

  • 一个@non Zero属性包装器,确保数字值大于或小于0。

  • @已验证或@白名单/@黑名单属性包装器,限制可以分配哪些值。

物业转让的价值转换

接受用户的文本输入对应用程序开发人员来说是一个长期的难题。有太多的事情需要跟踪,从简单的字符串编码到恶意尝试通过文本字段注入代码。但是开发人员在接受用户生成的内容时面临的最微妙和最令人沮丧的问题之一是处理前导和后导空格。

单个前导空间可以通过off-by-one错误使URL失效、混淆日期解析器和播种混乱:

import FoundationURL(string: " https://nshipster.com") // nil (!)ISO8601DateFormatter().date(from: " 2019-06-24") // nil (!)let words = " Hello, world!".components(separatedBy: .whitespaces)words.count // 3 (!)

当涉及到用户输入时,客户端通常会以无知为由,将所有内容按原样发送到服务器。\(ツ)/。

虽然我’并不主张客户端应用承担更多的责任,但这种情况为Swift属性包装提供了另一个引人注目的用例。

Foundation将trimming Characters(in:)方法连接到Swift字符串,这提供了一个方便的方法,可以从字符串值的前面或后面去掉空白。然而,每次你想确保数据正常时调用这个方法就不那么方便了。如果你’不得不自己做这件事,你肯定’想知道是否有更好的方法。

在寻找一种不那么特别的方法时,您可能已经通过will Set属性回调寻求赎回... 只是失望的是你’不能用它来改变已经在运行的事件。

struct Post {    var title: String {        willSet {            title = newValue.trimmingCharacters(in: .whitespacesAndNewlines)            /* ⚠️ Attempting to store to property 'title' within its own willSet,                   which is about to be overwritten by the new value              */        }    }}

从那里,你可能已经意识到didSet作为伟大途径的潜力... 后来才意识到didSet在初始属性分配期间’调用。

struct Post {    var title: String {        // 😓 Not called during initialization        didSet {            self.title = title.trimmingCharacters(in: .whitespacesAndNewlines)        }    }}

你可能已经尝试了许多其他方法... 最终发现没有一个能产生人体工程学和性能特征的可接受的组合。

如果这些听起来符合你的个人经历,你可以欣喜地知道你的搜索已经结束:属性包装是你’期待已久的解决方案。

实现一个属性包装器,从字符串值中裁剪空白

考虑以下Trimmed结构,它从传入的字符串值中修剪空格和换行符。

import Foundation@propertyWrapperstruct Trimmed {    private(set) var value: String = ""    var wrappedValue: String {        get { value }        set { value = newValue.trimmingCharacters(in: .whitespacesAndNewlines) }    }    init(initialValue: String) {        self.wrappedValue = initialValue    }}

通过用@Trimmed注释标记以下Post结构中的每个String属性,分配给title或body—的任何字符串值(无论是在初始化期间还是之后通过属性访问)都会自动删除其前导或尾随空格—。

struct Post {    @Trimmed var title: String    @Trimmed var body: String}let quine = Post(title: "  Swift Property Wrappers  ", body: "…")quine.title // "Swift Property Wrappers" (no leading or trailing spaces!)quine.title = "      @propertyWrapper     "quine.title // "@propertyWrapper" (still no leading or trailing spaces!)

相关理念

  • 应用**重症监护**室转换到传入字符串值的@转换属性包装器。

  • 允许String属性自定义其**规范化**窗体的@Normalize属性包装器。

  • @量化/@舍入/@截断属性,它将值量化到特定程度(例如“舍入到最近的½”),但在内部跟踪精确的中间值,以防止级联舍入错误。

变化的综合平等和比较语义

在Swift中,如果两个字符串值在规范上是**等价**的,它们就被认为是相等的。通过采用这些等价语义学,Swift字符串在大多数情况下的行为或多或少’像你所期望的那样:如果两个字符串包含相同的字符,那么任何单个字符是组合还是预组合都’重要—也就是说,“é”(U+00E9 LATIN SMALL LETTER E With ACUTE)等于“e”(U+0065 LATIN SMALL LETTER E)+“”(U+0301 COMBINING ACUTE ACCENT)。

但是如果你的特定用例需要不同的平等语义学呢?假设你想要一个不区分大小写的字符串平等概念?

现在有很多方法可以使用现有的语言功能来实现这一点:

  • 您可以在进行==比较的任何时候使用low ercas ed()结果,但是与任何手动过程一样,这种方法容易出错。

  • 您可以创建一个自定义的Case In敏感类型,包装一个字符串值,但您’必须做很多额外的工作,使其与标准字符串类型一样符合人体工程学和功能。

  • 您可以定义一个自定义比较器函数来包装这个比较—见鬼,您甚至可以为它定义自己的****定义运算符—但是没有什么能接近两个操作数之间的不合格==。

这些选项都不是特别引人注目的,但是多亏了Swift 5.1中的属性包装,我们’最终会有一个解决方案来满足我们’的需求。

实现不区分大小写的属性包装

下面的Cas eI n敏类型实现了一个字符串/SubString值的属性包装。该类型通过桥接NS String API的Cas eI n敏Compare(_:)符合可比(以及扩展为等式):

import Foundation@propertyWrapperstruct CaseInsensitive<Value: StringProtocol> {    var wrappedValue: Value}extension CaseInsensitive: Comparable {    private func compare(_ other: CaseInsensitive) -> ComparisonResult {        wrappedValue.caseInsensitiveCompare(other.wrappedValue)    }    static func == (lhs: CaseInsensitive, rhs: CaseInsensitive) -> Bool {        lhs.compare(rhs) == .orderedSame    }    static func < (lhs: CaseInsensitive, rhs: CaseInsensitive) -> Bool {        lhs.compare(rhs) == .orderedAscending    }    static func > (lhs: CaseInsensitive, rhs: CaseInsensitive) -> Bool {        lhs.compare(rhs) == .orderedDescending    }}

构造两个仅因大小写而异的字符串值,对于标准的相等性检查,它们’返回false,但是当包装在Case In敏感对象中时返回true。

let hello: String = "hello"let HELLO: String = "HELLO"hello == HELLO // falseCaseInsensitive(wrappedValue: hello) == CaseInsensitive(wrappedValue: HELLO) // true

到目前为止,这种方法与上面描述的自定义“包装器类型”方法没有区别。这通常是我们’开始执行与Express By String Literal和所有其他协议的一致性的漫长过程的地方,以使Case In敏感开始感觉像String一样,对我们的方法感觉良好。

属性包装允许我们完全放弃所有这些忙碌的工作:

struct Account: Equatable {    @CaseInsensitive var name: String    init(name: String) {        $name = CaseInsensitive(wrappedValue: name)    }}var johnny = Account(name: "johnny")let JOHNNY = Account(name: "JOHNNY")let Jane = Account(name: "Jane")johnny == JOHNNY // truejohnny == Jane // falsejohnny.name == JOHNNY.name // falsejohnny.name = "Johnny"johnny.name // "Johnny"

在这里,通过对名称属性值进行不区分大小写的比较来检查帐户对象是否相等。然而,当我们去获取或设置名称属性时,它是一个真正字符串值。

很不错,但这到底是怎么回事?

从Swift 4开始,编译器会自动合成Equable符合类型,这些类型在声明中采用了它,并且其存储属性本身都是Equable的。由于编译器合成的实现方式(至少目前是这样),包装属性是通过它们的包装器而不是它们的底层值来计算的:

// Synthesized by Swift Compilerextension Account: Equatable {    static func == (lhs: Account, rhs: Account) -> Bool {        lhs.$name == rhs.$name    }}

相关理念

  • 定义@兼容性等价,这样包装字符串属性与值 "①" 和1被认为是相等的。

  • @近似属性包装器,用于改进浮点类型的等式语义学(另请参见**SE-0259**)

  • 一个@Ranked属性包装器,它接受一个定义严格排序的函数,例如,枚举值;这可以允许在不同的上下文中对扑克牌排名. ace进行低或高的处理。

审计物业访问

业务需求可能会规定某些控制,规定谁可以在什么时候访问哪些记录,或者规定某种形式的会计方法来应对随着时间的推移而发生的变化。

一次,这’不是一个通常由ios应用程序执行的任务;大多数业务逻辑都是在服务器上定义的,大多数客户端开发人员都希望保持这种状态。但是,当我们开始通过属性包装的眼镜来看待世界时,这是另一个引人注目的用例,不容忽视。

实现属性值版本控制

以下版本结构用作属性包装器,在设置每个值时拦截传入值并创建带时间戳的记录。

import Foundation@propertyWrapperstruct Versioned<Value> {    private var value: Value    private(set) var timestampedValues: [(Date, Value)] = []    var wrappedValue: Value {        get { value }        set {            defer { timestampedValues.append((Date(), value)) }            value = newValue        }    }    init(initialValue value: Value) {        self.wrappedValue = value    }}

假设的Expense Report类可以将其state属性包装为@版本注释,以便在处理过程中为每个操作保留书面记录。

class ExpenseReport {    enum State { case submitted, received, approved, denied }    @Versioned var state: State = .submitted}

相关理念

  • 一个@已审核的属性包装器,每次读取或写入属性时都会记录。

  • 一个@衰减属性包装器,每次读取该值时将该值划分为一组数字值。

然而,这个特殊的例子突出了当前属性包装实现中的一个主要限制,该限制源于Swift的一个长期缺陷:属性’不能标记为抛出。

如果没有参与错误处理的能力,属性包装器’提供一个合理的方法来强制执行和传达策略。例如,如果我们想从以前扩展@版本属性包装器,以防止在以前被拒绝后状态被设置为。批准,我们最好的选择是致命错误(),这’不适合真正的应用程序:

class ExpenseReport {    @Versioned var state: State = .submitted {        willSet {            if newValue == .approved,                $state.timestampedValues.map { $0.1 }.contains(.denied)            {                fatalError("J'Accuse!")            }        }    }}var tripExpenses = ExpenseReport()tripExpenses.state = .deniedtripExpenses.state = .approved // Fatal error: "J'Accuse!"

这只是我们’到目前为止在属性包装上遇到的几个限制之一。为了对这个新功能创建一个平衡的视角,我们’将使用本文的剩余部分来枚举它们。

局限性

属性’不能参与错误处理

属性与函数不同,’不能标记为抛出。

事实上,这是这两种类型成员之间为数不多的区别之一。因为属性同时有getter和setter,所以如果我们要添加错误处理—特别是当您考虑如何更好地处理语法时,比如访问控制、自定义getter/setter和回调。

如上一节所述,属性包装器只有两种方法来处理无效值:

  1. 无视他们(默默地)

  2. 崩溃与致命错误()

这两个选项都不是特别好,所以我们’会对任何解决这个问题的建议非常感兴趣。

包装属性’不能被混叠

当前建议的另一个限制是,您’不能将属性包装器的实例用作属性包装器。

我们之前的Unit Interval示例将包装值限制在0和1之间(包括),可以简洁地表示为:

typealias UnitInterval = Clamping(0...1) // ❌

但是,这’不可能,也不能使用属性包装器的实例来包装属性。

let UnitInterval = Clamping(0...1)struct Solution { @UnitInterval var pH: Double } // ❌

所有这些实际上意味着在实践中代码复制比理想的要多。但是考虑到这个问题源于语言中类型和值之间的基本区别,如果这意味着避免错误的抽象,我们可以原谅一点点重复。

属性包装很难编写

属性包装器的组成不是交换操作;声明它们的顺序会影响它们’的行为。

考虑**执行字符串变化的属性和其他字符串**转换之间的相互作用。例如,如果在空格修剪之前或之后用破折号替换空格,那么自动规范化博客文章中的URL“”中的URL和段塞的属性包装器组合将产生不同的结果。

struct Post {    …    @Dasherized @Trimmed var slug: String}

但是让它开始工作说起来容易做起来难!试图组成两个作用于String值的属性包装器失败了,因为最外面的包装器作用于最里面的包装器类型的值。

@propertyWrapperstruct Dasherized {    private(set) var value: String = ""    var wrappedValue: String {        get { value }        set { value = newValue.replacingOccurrences(of: " ", with: "-") }    }    init(initialValue: String) {        self.wrappedValue = initialValue    }}struct Post {    …    @Dasherized @Trimmed var slug: String // ⚠️ An internal error occurred.}

有一种方法可以让它工作,但它并不完全明显或令人愉快。这是可以在实现中修复的东西,还是仅仅通过文档来纠正,还有待观察。

属性包装器’不是一流的依赖类型

依赖类型是由其值定义的类型。例如,“一对整数,其中后者大于前者”和“一个元素素数的数组”都是依赖类型,因为它们的类型定义取决于其值。

Swift在其类型系统中缺乏对依赖类型的支持,这意味着必须在运行时强制执行任何此类保证。

好消息是,属性包装器比迄今为止提出的任何其他语言功能都更接近于填补这一空白。然而,它们仍然’不能完全取代真正的依赖于值的类型。

例如,您’不能使用属性包装器来定义一个新的类型,并对哪些值是可能的进行约束。

typealias pH = @Clamping(0...14) Double // ❌func acidity(of: Chemical) -> pH {}

也不能使用属性包装器来注释集合中的键或值类型。

enum HTTP {    struct Request {        var headers: [@CaseInsensitive String: String] // ❌    }}

这些缺点绝不是破坏交易的因素;属性包装非常有用,填补了语言中的一个重要空白。

看看属性包装的添加是否会’Swift带来依赖类型的新兴趣,或者它们是否’会被视为“足够好”,从而避免进一步形式化概念的需要,将会很有趣。

财产包装很难记录

突击测验: Swift UI框架提供了哪些属性包装?

继续访问**官方的Swift UI**文档并尝试回答。

😬

公平地说,这种失败’不是属性包装所独有的。

如果您的任务是确定哪个协议负责标准库中的特定API,或者仅根据developer.apple.com上的记录来确定一对类型支持哪些运算符,您’可能会开始考虑职业生涯中期远离计算机。

斯威夫特越来越复杂,使得这种缺乏可理解性变得更加可怕。

财产包装使Swift进一步复杂化

Swift是一种比目标C复杂得多的语言。自Swift 1.0以来一直如此*,*而且随着时间的推移越来越复杂。

Swift中大量的@-前缀功能—

Swift 4中的@**动态成员查找和@动态可调用,或者Swift中的@****可微和@成员智能用于Tensorflow—使得仅仅基于文档对Swift应用编程接口进行合理的**理解变得越来越困难。在这方面,“属性包装”的引入将是一个力量倍增器。

我们将如何理解这一切?(这是一个真正的问题,不是修辞上的问题。)

好吧,让我们试着结束这件事—

Swift属性包装器允许库作者访问以前为语言特性保留的那种高级行为。它们在提高安全性和降低代码复杂性方面的潜力是巨大的,我们’只是开始触及可能的表面。

然而,尽管有这些承诺,属性包装器及其与Swift UI一起推出的语言功能群给Swift带来了巨大的剧变。

或者,正如娜塔莉亚·帕特索夫斯卡在**推特**上所说:

ios API设计,简史:

  • 目标C-描述名称中的所有语义学,类型’意义不大
  • Swift 1 to 5-name侧重于清晰和基本结构,枚举,类和协议保持语义学
  • Swift 5.1-@包装$path@yolo

@nataliya_bg

也许我们’回过头来才知道Swift 5.1是我们心爱的语言的转折点还是转折点。