1. Introduction
在上两集中,我们深入探讨了setter函数的世界,并看到了它们如何让我们精确地操作大型数据结构。这只是事情的一半!getters?让我们来探索如何从结构中访问数据,探索getter是如何组成的,并看看关键路径如何在这一过程中进一步帮助我们!
2. Properties
类和结构通常将它们的数据存储在属性中,访问属性的方式与使用点语法的方法非常相似,但是在必须调用方法的地方,属性会立即返回它们的值。
struct User {
let id: Int
let email: String
}
我们可以实例化一个用户并使用点语法访问它的属性。
let user = User(id: 1, email: "blob@pointfree.co")
user.id // 1
user.email // "blob@pointfree.co"
属性就像没有参数的方法,反过来又像只有一个参数的函数,self就是隐式参数。 这有点神奇,因为它是我们最喜欢的函数组合形状!
当然,它们不能很好地进行开箱即用,但是短闭包使这变得非常容易。
比如,如果我们想从用户中提取id并将其转换为字符串:
{ (user: User) in user.id } >>> String.init
// (User) -> String
这个可以,但是有点吵。Swift为我们提供了一个更强大的语言功能。
3. Key paths as getters
Swift有一个强大的编译器生成的语言特性,它允许我们在没有实例的情况下引用属性访问:关键路径(key paths)。语法看起来有点滑稽。
\User.id // KeyPath<User, Int>
这里我们有一个KeyPath,它是根容器类型User和被访问的值类型的泛型,在本例中是Int类型。
那么我们可以用这些关键路径做什么呢? 我们可以用它们来访问一个值的属性。
user[keyPath: \User.id] // 1
当我们使用点语法时,这似乎不是特别有用。
user.id // 1
但是正如我们在过去所看到的,在抽象中引用这种功能的能力可以解锁所有类型的组合。除此之外,Swift还免费提供编译器生成的代码! 我们应该能够利用这一点来简化我们之前的写作尝试:
{ (user: User) in user.id } >>> String.init
左边只是一个函数,它接受User并返回该用户的属性值,这正是键路径getter提供的功能。
让我们定义一个函数,给定一个键路径,它将生成一个getter函数。
func get<Root, Value>(_ kp: KeyPath<Root, Value>) -> (Root) -> Value {
return { root in
root[keyPath: kp]
}
}
How do we use this?
get(\User.id) // (User) -> Int
我们得到一个全新的从用户到Int的免费函数! 这很整洁。这正是我们之前组合所需要的。
get(\User.id) >>> String.init
// (User) -> String
Swift也有“计算”属性的概念,这些属性不直接存储数据,而是存储逻辑。
计算属性实际上只是伪装的函数,但它们让我们能够隐藏私有的实现细节,将getter-setter函数对统一到一个单独的构造中,并在调用点包含更少的视觉噪声。对于什么时候应该使用计算属性而不是方法,并没有编译器强制的规则,但一般感觉是,对于给定的状态,属性访问应该是廉价且可预测的。
让我们向用户添加一个computed属性。
extension User {
var isStaff: Bool {
return self.email.hasSuffix("@pointfree.co")
}
}
它包含了一些检查电子邮件是否有某个后缀的逻辑。
user.isStaff // true
Swift也为这些计算属性生成关键路径!
\User.isStaff // KeyPath<User, Bool>
我们可以像以前一样使用它。
user[keyPath: \User.isStaff] // true
当然,这不是使用关键路径的实际方法。但是,我们的get函数使它立即变得有用。
get(\User.isStaff) // (User) -> Bool
由于getter可以被认为是专注于较大结构的一部分的函数,所以它们可以被认为是非常基本的转换函数。标准库中有一种高阶方法,它采用这些转换函数,可以很好地与关键路径组合在一起:map。 例如,假设我们有一个用户数组。
let users = [
User(id: 1, email: "blob@pointfree.co"),
User(id: 2, email: "protocol.me.maybe@appleco.example"),
User(id: 3, email: "bee@co.domain"),
User(id: 4, email: "a.morphism@category.theory")
]
我们可以用一个闭包来映射它,以生成一个电子邮件地址数组。
users
.map { $0.email }
看看关键路径是如何工作的,我们可能希望这样:
users
.map(\User.email)
不幸的是,该语言还不支持将键路径转换为getter函数,标准库也没有定义接受键路径的映射重载。
我们可以自己定义重载。遍历属性是map的一种非常常见的操作。
extension Sequence {
func map<Value>(_ kp: KeyPath<Element, Value>) -> [Value] {
return self.map { $0[keyPath: kp] }
}
}
现在我们可以传递一个关键路径!
users
.map(\User.email)
如果我们利用类型推断,我们得到的东西甚至比点语法版本更短。
users
.map(\.email)
这个看起来很漂亮!现在我们可能还想这样写:
users
.filter(\.isStaff)
但我们需要另一个超载。这不是很好地扩展。我们真的想为标准库中每个相关的高阶函数定义重载吗? 那么其他第三方代码呢?
幸运的是,我们已经定义了一个桥接! 我们可以在所有这些情况下使用get。
我们可以使用带有Swift的map的get,而不是依赖于我们的临时超载。
users
.map(get(\.email))
我们可以将get传递给标准库方法,而不是依赖于filter的键路径重载。
users
.filter(get(\.isStaff))
我们不需要定义任何重载。我们可以免费使用所有这些api的关键路径。
现在我们把关键路径放到函数世界里,我们甚至可以把它们组合在一起。
我们来回顾一下map的一个性质。我们看到可以对数组进行两次map:
users
.map(get(\.email))
.map(get(\.count))
// [17, 33, 13, 26]
我们看到,这可以用一个单一的map和组合来重写。
users
.map(get(\.email) >>> get(\.count))
// [17, 33, 13, 26]
这也是一个很好的性能提升! 一次遍历而不是两次遍历。
不过,关键路径是自己组成的!这里不需要箭头运算符。
users
.map(get(\.email.count))
// [17, 33, 13, 26]
Getter组合解锁了很多好东西。我们可以将isStaff getter与!操作符结合。
users
.filter(get(\.isStaff) >>> (!))
以这种顺序使用前缀操作符可能读起来有点奇怪,但幸运的是,我们使用了反向组合来解决这个问题!
users
.filter((!) <<< get(\.isStaff))
我们可以很容易地将这些单元插入到map和filter中,并且可以自由地将这些getter转换与其他函数组合在一起,这真是令人惊讶!
4. Sorting
还有哪些高阶函数可以从键路径组合中受益?排序呢?
按照属性排序是很常见的,但是Swift在这里并没有真正帮助我们,没有开箱即用。我们总是被拉到点语法的世界。
user
.sorted(by: { $0.email < $1.email })
这并不糟糕,但我们可以想象,在深度嵌套的属性上进行排序会有点麻烦。
users
.sorted(by: { $0.email.count < $1.email.count })
这变得越来越冗长了。我们必须根据我们排序的嵌套属性在每一边重复我们自己。这个属性只是一个键路径,也许我们可以从键路径世界找到另一个桥梁来执行sorted期望的函数。
排序到底是什么样的?
users.sorted(by: <#(User, User) throws -> Bool#>)
它接受一个非常特定的函数:一个接受一对元素并返回其中一个是否应该出现在另一个之前。
我们应该能够生成这种类型的函数,但要深入到User并对嵌套属性进行排序。我们就这么做吧。
func their<Root, Value>(
_ f: @escaping (Root) -> Value,
_ g: @escaping (Value, Value) -> Bool
)
-> (Root, Root)
-> Bool {
return { g(f($0), f($1)) }
}
这里有很多东西,但需要注意的是我们可以把从Root到Value的转换单独应用到两个根上。这允许我们创建一个全新的功能,关心Values。
如何使用这个函数?
users
.sorted(by: their(get(\.email), <))
不坏!我们可以用一个字符来反转排序。
users
.sorted(by: their(get(\.email), >))
我们还可以更深入地研究,并根据用户电子邮件地址的字符数进行排序。
users
.sorted(by: their(get(\.email.count), >))
不坏!我们设法避免了定义重载。我们的组合器很好地插入了现有的方法。我们已经构建了一些可以使用任何函数的东西,而不仅仅是关键路径!还有什么其他的方法可以受益呢?
max怎么样?它接受一个函数(User, User) -> Bool。
users
.max(by: their(get(\.email), <))
我们也可以用min做同样的事!
users
.min(by: their(get(\.email), <))
诚然,指定<有点奇怪,因为它是获得最大值或最小值的唯一有效方法。也许我们可以定义一个超载来帮助你。
func their<Root, Value: Comparable>(
_ f: @escaping (Root) -> Value
)
-> (Root, Root)
-> Bool {
return their(f, <)
}
通过将Value约束为Comparable,可以为Comparable类型定义一个默认版本。
现在我们的max和min就不用担心额外的参数了。
users
.max(by: their(get(\.email)))
users
.min(by: their(get(\.email)))
这也有助于sorted,因为我们的默认值可能是升序的。
不坏!我们采用了三个标准库方法,并定义了一个连接关键路径的方法。
5. Reduce
让我们再看一个高阶函数:reduce。这是一个有趣的函数,它取一个起始值,然后通过一个转换函数传递每个元素,将数据折叠到其中。
[1, 2, 3]
.reduce(0, +)
// 6
如果我们的数据结构更复杂怎么办?如果我们想根据数据的深度嵌套属性来减少数据,该怎么办?
struct Episode {
let title: String
let viewCount: Int
}
let episodes = [
Episode(title: "Functions", viewCount: 961),
Episode(title: "Side Effects", viewCount: 841),
Episode(title: "UIKit Styling with Functions", viewCount: 1089),
Episode(title: "Algebraic Data Types", viewCount: 729),
]
如果我们想要合计所有这些剧集的观看次数,我们可以编写以下代码:
episodes
.reduce(0) { $0 + $1.viewCount }
这段代码很短,但主要依赖于我们一眼就能记住$0和$1指的是什么。
让我们专注于形状,看看我们是否可以写一个函数来连接这些世界,让它更清晰一点,我们如何在更大的值的子结构的基础上积累。
func combining<Root, Value>(
_ f: @escaping (Root) -> Value,
by g: @escaping (Value, Value) -> Value
)
-> (Value, Root)
-> Value {
return { value, root in
g(value, f(root)) }
}
}
我们如何使用它?
episodes.reduce(0, combining(get(\.viewCount), by: +))
这有点冗长,但它更易于阅读,并且是由小型的可组合单元构建的。
6. Operator overload?
我们在很多地方看到了get的好处,我相信我们还会看到更多! 在这里接受额外的噪音可能是明智的,但是……我们已经有一段时间没有引入操作员了,我认为我们可以尝试一些东西,使这个过程更轻量化。
prefix operator ^
prefix func ^ <Root, Value>(kp: KeyPath<Root, Value>) -> (Root) -> Value {
return get(kp)
}
前缀运算符的一个好处是,我们不必担心优先级或结合性!
我们如何使用这个运算符?
^\User.id
// (User) -> Int
这就是我们的getter ! 我们可以直接把它传递给map。
users.map(^\.id) // [1, 2, 3, 4]
这看起来不错!非常接近我们定义的原始map重载。那其他的例子呢?
我们可以映射用户电子邮件的字符数。
users.map(^\.email.count)
// [17, 33, 13, 26]
我们仍然有能力将这些功能向前组合成新的功能。
users.map(^\.email.count >>> String.init)
// ["17", "33", "13", "26"]
我们不需要什么仪式就能过滤。
users.filter(^\.isStaff)
// or
users.filter((!) <<< ^\.isStaff)
这不是魔术!它只是函数复合。 **!**运算符只是一个接受一个布尔参数并否定它的函数。这样很好。
我们之前的桥接呢? 我们可以根据用户的电子邮件进行分类。
users.sorted(by: their(^\.email))
甚至按降序排列。
users.sorted(by: their(^\.email, >))
Our max and min work just as before.
users.max(by: their(^\.email.count))
users.min(by: their(^\.email.count))
所有这些例子都集中在他们所关心的事情上。get函数非常短,但它似乎足够常见,如果可以的话,可以避免使用操作符。
不过,我们引入了一个新的操作员。它符合我们的要求吗?
- 与前面的操作符不同,这个操作符在Swift中已经存在,但作为中缀操作符,而不是前缀操作符。作为前缀操作符,它似乎不太可能造成太多的混淆,但它肯定与现有的符号空间重叠。
- 它有现有技术和良好的形状吗? 它的形状很好,有点像一个向上的箭头,就好像它把一条关键路径提升到函数世界。这个形状还唤起了Objective-C的块语法,它们本身就是函数。其他语言有类似的功能吗? Ruby使用前缀&来做类似的事情,但我们不能使用相同的符号,因为前缀&是为Swift中的inout调用站点保留的。
- 它是在解决一个普遍的问题吗? Swift到处都有属性访问,所以是的。
它并没有很好地解决问题,但取决于你使用它的频率,它可能是一个有价值的添加,以减少括号和噪音。
7. What’s the point?
我们引入了一个get函数,它只处理KeyPath值,这是我们在日常工作中不经常处理的东西。有时它会让事情变得更短。有时它没有!我们应该关心这种事情吗?
我们是这样认为的! 我们认为它有助于展示关键路径是多么强大,以及为什么编译器生成的代码也是如此! 关键路径对我们的日常代码、语法和所有内容来说有些陌生,但让我们关注它们给我们带来了什么。
这一整集基本上是在庆祝一个简单的功能:get。它所做的只是打开了一条通往函数世界的关键路径。这让我们能够立即使用许多其他库代码,从map和filter开始。只要编译器可以为我们工作,我们要做的工作就会少一些。让我们拥抱这些吧!