Swift之构建非常优雅的便利API—Swift中的计算属性
使Swift成为如此强大且通用的语言的主要原因在于,当我们选择在为特定问题形成解决方案时选择使用哪种语言功能时,我们通常可以使用多种选项。然而,这种多样性也可能引起混淆和争论,特别是当我们正在考虑的功能的关键用例之间没有明确的界限时。
本周,我们来看看一个这样的语言特性 - 计算属性 - 以及它们如何让我们构建非常优雅的便利API,如何避免在部署它们时意外隐藏性能问题,以及在计算属性之间进行选择的一些不同策略和方法。
属性用于数据
理想情况下,属性是计算还是存储应该只是一个实现细节 - 特别是因为只要查看它所使用的代码就无法确切地知道属性是如何存储的。因此,就像存储的属性如何构成类型存储的数据一样,计算属性可以被视为在需要时计算类型数据的方法。
假设我们正在制作一个用于收听播客的应用程序,并且使用如下所示的State枚举来模拟给定播客剧集所处的状态(是否已经下载,收听等)。
extension Episode {
enum State {
case awaitingDownload
case downloaded
case listening(progress: Double)
case finished
}
}
然后,我们为我们的Episode模型提供一个存储的state属性,我们可以根据给定的剧集状态来制作决策 - 例如,能够向用户显示是否已经下载了一集。但是,由于该特定用例在我们的代码库中非常常见,我们不希望必须state在许多不同的地方手动打开- 所以我们还提供Episode了一个isDownloaded我们可以在需要的地方重用的计算属性:
extension Episode {
var isDownloaded: Bool {
switch state {
case .awaitingDownload:
return false
case .downloaded, .listening, .finished:
return true
}
}
}
我们在state上面开启而不是使用if或guard声明的原因是,如果我们在我们的State枚举中添加一个新案例,那么“强迫”我们自己更新这个代码- 否则我们可能会以不正确的方式处理这个新案例了解它。
上面的实现可以说是计算属性的一个很好的用例 - 它消除了样板,增加了便利性,并且就像它是一个只读存储属性一样 - 它的全部意义在于让我们访问模型数据的特定部分。
意外瓶颈
现在让我们来看看硬币的另一面 - 如果我们不小心,计算属性虽然非常方便,但有时最终会导致意外的性能瓶颈。继续上面的播客应用程序示例,假设我们为用户的播客订阅库建模的方式是通过Library结构,该结构还包含类似上次服务器同步发生时的元数据:
struct Library {
var lastSyncDate: Date
var downloadNewEpisodes: Bool
var podcasts: [Podcast]
}
虽然Podcast我们需要在我们的应用程序中呈现上述大多数视图的所有模型,但我们确实有一些地方可以将所有用户的播客显示为平面列表。就像我们之前Episode使用isDownloaded属性扩展一样,最初的想法可能是在这里做同样的事情 - 添加一个计算allEpisodes属性,收集用户库中每个播客的所有剧集 - 如下所示:
extension Library {
var allEpisodes: [Episode] {
return podcasts.flatMap { $0.episodes }
}
}
要了解更多信息flatMap,请查看“Map,FlatMap和CompactMap”基础知识文章。
上面的API可能看起来非常简单 - 但它有一个相当大的缺陷 - 它的时间复杂度是线性的(或O(N)),因为为了计算我们的allEpisodes属性,我们需要一次遍历所有播客。一开始这似乎不是什么大不了的事 - 但是在我们每次我们在一个单元格中出列单元格时访问上述属性时,在这种情况下可能会出现问题UITableView:
class AllEpisodesViewController: UITableViewController {
...
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: reuseIdentifier,
for: indexPath
)
// Here we're accessing allEpisodes just as if it was a
// stored property, and there's no way of telling that this
// will actually cause an O(N) evaluation under the hood:
let episode = library.allEpisodes[indexPath.row]
cell.textLabel?.text = episode.title
cell.detailTextLabel?.text = episode.duration
return cell
}
}
由于表格视图单元格可以以非常快的速度出列 - 当用户滚动时 - 上述代码迟早会成为性能瓶颈,因为我们当前的allEpisodes实现将在每次访问时不断迭代所有播客。不是很好。
虽然收集每个播客的所有剧集本身就是O(N)我们当前模型结构的一个操作,但我们可以改进我们通过API 发出复杂信号的方式。我们不是将它allEpisodes看作另一个属性,而是让它成为一种方法。这样一来,它看起来更像是一个行动正在执行(这是),而不是访问一个数据块的只是一个快速的方法:
extension Library {
func allEpisodes() -> [Episode] {
return podcasts.flatMap { $0.episodes }
}
}
如果我们还更新我们AllEpisodesViewController接受一组剧集作为其初始化程序的一部分,而不是Library直接访问我们的模型,那么我们得到以下调用站点 - 它看起来比我们之前的实现更清晰:
let vc = AllEpisodesViewController(episodes: library.allEpisodes())
在我们的视图控制器中,我们仍然可以像以前一样继续访问所有剧集 - 只是现在该阵列只构建一次,而不是每次单元格出列时都是如此,这是一个很大的胜利。
方便懒惰
将任何无法在常规时间内执行的计算属性转换为方法通常会提高API的整体清晰度 - 因为我们现在强烈表示存在与访问它们相关的某种形式的成本。但在这样做的过程中,我们也失去了一些使用属性给我们带来的“优雅”。
然而,在许多这样的情况下,实际上有一种方法可以实现清晰,优雅和性能 - 所有这些都在同一时间。为了能够继续使用属性,而不必通过使用延迟评估来预先完成所有处理工作。
就像我们在“Swift序列:懒惰的艺术”和“Swift中的字符串解析”中看到的那样,推迟迭代序列直到它实际需要可以给我们带来显着的性能提升 - 所以让我们来看看如何我们可以使用该技术将其allEpisodes转变为属性。
我们将首先Library使用两种新类型扩展我们的模型 - 一种用于我们的剧集序列,另一种用于迭代该序列中的元素:
extension Library {
struct AllEpisodesSequence {
fileprivate let library: Library
}
struct AllEpisodesIterator {
private let library: Library
private var podcastIndex = 0
private var episodeIndex = 0
fileprivate init(library: Library) {
self.library = library
}
}
}
要变成AllEpisodesSequence第一类Swift序列,我们所要做的就是Sequence通过实现makeIterator工厂方法使其符合:
extension Library.AllEpisodesSequence: Sequence {
func makeIterator() -> Library.AllEpisodesIterator {
return Library.AllEpisodesIterator(library: library)
}
}
接下来,让我们的迭代器符合要求IteratorProtocol,并实现我们的实际迭代代码。我们将通过阅读播客中的每一集来做到这一点,当没有更多的剧集可以找到时,我们将继续播放下一个播客 - 直到所有剧集都被退回,如下所示:
extension Library.AllEpisodesIterator: IteratorProtocol {
mutating func next() -> Episode? {
guard podcastIndex < library.podcasts.count else {
return nil
}
let podcast = library.podcasts[podcastIndex]
guard episodeIndex < podcast.episodes.count else {
episodeIndex = 0
podcastIndex += 1
return next()
}
let episode = podcast.episodes[episodeIndex]
episodeIndex += 1
return episode
}
}
有了上述内容,我们现在可以自由地allEpisodes转回计算属性 - 因为它不再需要任何前期评估,只需AllEpisodesSequence在常量时间内返回一个新实例:
extension Library {
var allEpisodes: AllEpisodesSequence {
return AllEpisodesSequence(library: self)
}
}
虽然上述方法需要的代码多于我们之前的代码,但它有一些关键的好处。第一个是现在完全不可能简单地下标到allEpisodes返回的序列,因为Sequence这并不意味着随机访问任何底层元素:
// Compiler error: Library.AllEpisodesSequence has no subscripts
let episode = library.allEpisodes[indexPath.row]
这看起来似乎不是一个好处,但它阻止我们意外地造成我们之前遇到的那种性能瓶颈 - 迫使我们将我们的allEpisodes序列复制到一个Array之前我们将能够随机访问其中的剧集它:
let episodes = Array(library.allEpisodes)
let vc = AllEpisodesViewController(episodes: episodes)
虽然每次我们想要阅读一集时都没有什么能阻止我们执行上面的数组转换 - 但是当我们意外地订阅一个看起来像是存储的数组时,这是一个更加慎重的选择。比计算的。
另一个好处是,如果我们所寻找的只是一小部分,我们不再需要从每个播客中不必要地收集所有剧集。例如,如果我们只想向用户展示他们下一个即将到来的剧集 - 我们现在可以简单地这样做:
let nextEpisode = library.allEpisodes.first
使用延迟评估的好处在于,即使allEpisodes返回序列,上述操作也具有恒定的时间复杂度 - 就像您期望访问first任何其他序列一样。太棒了!
这都是关于语义的
既然我们能够将复杂的操作转换为计算属性,而无需任何前期评估,那么最大的问题是 - 无参数方法的剩余用例是什么?
答案很大程度上取决于我们希望给定API具有哪种语义。属性非常意味着某种形式的访问值或对象的当前状态 - 而不更改它。所以修改状态的任何东西,例如通过返回一个新值,很可能更好地用一个方法表示 - 比如这个,它更新了state我们Episode之前的一个模型:
extension Episode {
func finished() -> Episode {
var episode = self
episode.state = .finished
return episode
}
}
将上述API与使用属性的情况进行比较 - 很明显,一种方法为这种情况提供了恰当的语义:
// Looks like we're performing an action to finish the episode:
let finishedEpisode = episode.finished()
// Looks like we're accessing some form of "finished" data:
let finishedEpisode = episode.finished
许多相同的逻辑也可以应用于静态API,但我们可能会选择做出某些例外,特别是如果我们要优化使用点语法调用的API 。有关设计此类静态API的一些示例,请参阅“Swift中的静态工厂方法”和Swift中基于规则的逻辑”。
结论
计算属性非常有用 - 并且可以使我们能够设计更简单,更轻量级的API。但是,重要的是要确保这种简单性不仅被感知,而且还反映在底层实现中。否则,我们冒着隐藏性能瓶颈的风险,在这种情况下,通常更好的选择方法 - 或者在适当时部署延迟评估。
链接:https://www.jianshu.com/p/315e4522c7c8