设计功能强大、灵活且仍然感觉轻量级和易于使用的api的一个主要部分是决定哪些方面可以使用户进行配置。一方面,我们添加的配置选项越多,API就变得越通用——但另一方面,它也可能使它变得更复杂和更难理解。
这就是为什么默认参数在Swift中成为如此伟大的工具——因为它们让我们可以为我们最终提供的许多配置选项添加可靠的、直观的默认值。
Making the easy path the right path
使项目更具可维护性的关键方法之一(特别是当它的规模不断扩大时,不管是代码还是团队)是确保最简单的方法来完成某项任务也是正确的方法。 非常相似的功能被不同的开发人员意外地多次重新实现是很常见的,只是因为没有简单的、共享的抽象可供使用。
例如,我们想要努力统一应用程序中各种动画的持续时间。为了实现这一点,我们的目标需要创建一个比系统开箱即用的API更简单的API,这样,我们团队的所有成员(包括我们自己)将不断选择使用新的API,而不是默认的API。在这种情况下,我们可以这样构建:
extension UIResponder {
// Here we use a default argument to define what we want
// our unified, default animation duration to be:
func animate(withDuration duration: TimeInterval = 0.3,
animations: @escaping () -> Void) {
UIView.animate(withDuration: duration,
animations: animations)
}
}
我们将上述函数的作用域限定在UIResponder (UIView和UIViewController都是UIResponder的子类),以避免在非ui上下文中获得它作为自动完成建议。
有了上述的地方,大多数动画代码将-随着时间的推移-最有可能的结果是这样的:
animate {
button.frame.size = CGSize(width: 100, height: 100)
}
这很好,既有利于可读性,也因为我们现在有一个单一的真相来源,用于所有默认动画的持续时间。 然而,同样重要的是,我们的新默认值很容易被重写——只需在调用站点为该参数指定一个值:
animate(withDuration: 2) {
button.frame.size = CGSize(width: 100, height: 100)
}
除了提供了一种很好的方法来标准化代码库中的各种值之外,默认参数还可以让我们设计出更可伸缩的API——通过使越来越复杂的用例和定制成为可能,而不需要所有API用户承担增加的复杂性。
例如,下面是我们如何扩展我们的简化动画API来支持更大的参数集——同时仍然保持默认用例尽可能简单:
extension UIResponder {
func animate(withDuration duration: TimeInterval = 0.3,
delay: TimeInterval = 0,
options: UIView.AnimationOptions = .curveEaseInOut,
animations: @escaping () -> Void) {
UIView.animate(withDuration: duration,
delay: delay,
options: options,
animations: animations)
}
}
这正是默认参数如此有用的原因——它们让我们不断扩展我们的api,使它们变得越来越强大和灵活,以一种不会影响任何不需要利用这些新功能的代码的方式。
The importance of being obvious
然而,在决定将哪些值转换为默认值时,考虑给定的默认值最终是否会对我们的API用户更直观总是很重要的。毕竟,最好的默认值是那些看起来很明显的默认值,因为它可以帮助我们避免由于API做了一些我们没有预料到的事情而引起的误解和bug。
例如,假设我们编写了一个用于在本地数据库中存储给定值的函数,并且允许API用户决定如何处理冲突—当数据库中已经存在类似的值时。为了使我们的API尽可能的简单,我们再次指定了一个默认参数——像这样:
enum ConflictResolution {
case overwriteExisting
case stopIfExisting
case askUser
}
func store<T: Storable>(
_ value: T,
conflictResolution: ConflictResolution = .stopIfExisting
) throws {
...
}
做上面的事情乍一看可能是个好主意,但是仔细想想,如果没有显式地指定ConflictResolution就调用我们的函数,如果我们的数据库已经包含了一个现有的值,那么就不会存储任何值,这一点并不明显。通过简单地调用try store(value),我们希望实际存储一个值,但同时——我们也不希望覆盖现有的默认值,因为这可能会导致意外的数据丢失。
在这种情况下,如果真的找不到明显的默认值,如果我们想提供某种形式的方便API,那么最好简单地定义一个单独的函数。例如,以下是如何创建一个storeIfNeeded函数,以便在类似的值不存在的情况下轻松存储一个值:
func storeIfNeeded<T: Storable>(_ value: T) throws {
try store(value, conflictResolution: .stopIfExisting)
}
因此,虽然默认参数在很多情况下都很有用,但它们并不是定义便利api的唯一方法,就像其他许多事情一样——这一切都归结为在每个给定的上下文中为工作选择正确的工具。
Retrofitted dependency injection
默认参数还可以提供一种很好的方式,通过依赖项注入来改进给定类型或函数。 就像我们在以前的文章中看到的那样——注入我们的代码级依赖,而不是依赖于单例,通常是编写结构更好和可测试的代码的关键。 然而,完全重构代码库以在每个地方引入依赖注入可能是一项艰巨的任务——但谢天谢地,默认参数使我们能够一步一步地执行这些更改。
假设我们的代码base大量使用了一个FileLoader类——它目前以单例方式访问其底层的FileManager,以及全局共享缓存。这确实有一些好处,因为它让我们可以简单地从任何地方初始化一个文件加载器,而不必担心它的依赖关系。然而,这也使得单元测试类和获得明确的类概述变得更加困难
好消息是,通过简单地将访问这些单例对象的方式转换为默认初始化式参数,我们既可以改善类型的结构,也可以让它更易于测试——像这样:
class FileLoader {
private let fileManager: FileManager
private let cache: Cache
init(fileManager: FileManager = .default,
cache: Cache = .shared) {
self.fileManager = fileManager
self.cache = cache
}
}
因为我们现在已经参数化了所有文件加载器的依赖项,所以它们可以更容易地在我们的测试中被模拟或存根化。例如,下面是我们如何将应用程序的默认缓存替换为一个在每次运行测试时自动清空自身的缓存:
let loader = FileLoader(cache: .autoEmptyingForTests)
有关单元测试的更多信息,请查看这篇基础文章,以及本网站关于该主题的许多其他文章和播客章节。
Associated values within enums
最后,让我们来看看Swift 5.1中新增的一个默认参数的“风味”——枚举用例的默认关联值。
假设我们正在构建一个用于创建XML文档的Swift库。 由于XML是一种树状格式,在这种格式中,所有数据都是使用具有有限数量变化的节点来定义的,我们可以选择使用XMLNode枚举来对每个这样的节点建模,如下所示:
enum XMLNode {
// A standard element, which can contain child elements:
case element(
name: String,
attributes: [Attribute],
children: [XMLNode]
)
// A "void" element that closes itself, and can't have children:
case voidElement(
name: String,
attributes: [Attribute]
)
// An inline piece of text, defined as a child node:
case text(String)
}
在Swift 5.1之前,使用上述方法有一个很大的权衡,因为不能定义默认参数。因此,即使我们只是想创建一个空(但仍然是非空的)元素,我们仍然需要传递在这种情况下所有相关的值:
let emptyItems = XMLNode.element(
name: "items",
attributes: [],
children: []
)
虽然我们可以用方便的api扩展XMLNode,为我们填充这些空的默认值,但我们不再需要——因为我们现在可以为相关的enum值定义默认参数,就像我们为函数参数所做的一样:
enum XMLNode {
case element(
name: String,
attributes: [Attribute] = [],
children: [XMLNode] = []
)
case voidElement(
name: String,
attributes: [Attribute] = []
)
case text(String)
}
有了上面的改变,我们的XMLNode API立刻变得灵活多了——因为我们现在可以通过使用上面的enum类型来定义所有类型的节点:
let emptyItems = XMLNode.element(name: "items")
let link = XMLNode.element(name: "link", children: [.text(url)])
let metadata = XMLNode.voidElement(name: "meta", attributes: metadataAttributes)
很酷!特别是对于用于定义数据的结构和模型,使用默认值可能非常强大,因为任何类型的数据的粒度随着用例的不同而变化很大。
Conclusion
当部署为API提供一组明显且一致的默认值时,默认参数可能会非常强大。 它们可以让我们的api感觉更加轻量级,并使那些api的调用者能够轻松地使用它们-从简单开始,然后根据需要自定义和覆盖默认值。
默认参数还可以提供一种方法,通过依赖项注入轻松地修改现有类型或函数,并使枚举更加灵活。
但在定义默认参数时,我们也必须小心不要做出太多假设。因为到最后,即使defaults和强有力的约定非常有用,一个不明显的defaults可能比没有defaults更糟糕。