从函数返回对象和值是在大多数语言中都能找到的核心编程概念之一。无论我们谈论的是像Haskell这样的纯函数式编程语言,还是像Swift这样的多范式编程语言,函数及其生成输出的能力都是任何应用程序或系统的关键构建模块。
然而,就像大多数编程概念一样,函数有很多不同的使用方法。函数式编程语言通常围绕着纯函数的思想——这些函数不会产生任何副作用,并且在给定相同输入的情况下总是产生相同的输出-当用Swift构建应用程序时,更常见的是使用更多面向对象的方法,使用可变的属性和函数来影响它们。
就像在“Swift中的第一类函数”这篇文章中一样,本周,让我们看看如何从函数编程世界中得到启发,来改进Swift代码的结构和健壮性 -这次重点是使用返回对象和值的函数。
Return early, return often
让我们先看一个我们希望改进其结构的代码示例。 在本例中,我们在notes应用程序中使用一个基于NotificationCenter的API,我们正在响应一个通知,告诉我们给定的注释已经更新。要加载更新后的note,我们首先必须从传递的通知中提取它的ID,目前我们使用嵌套的if let语句来做这个操作,如下所示:
class NoteListViewController: UIViewController {
@objc func handleChangeNotification(_ notification: Notification) {
let noteInfo = notification.userInfo?["note"] as? [String : Any]
if let id = noteInfo?["id"] as? Int {
if let note = database.loadNote(withID: id) {
notes[id] = note
tableView.reloadData()
}
}
}
}
上面的代码可以工作,但是阅读和理解有点困难,因为它包含多个级别的缩进和类型转换。让我们看看我们能不能改进它!
我们要做的第一件事是应用早期返回的概念,通过使我们的函数总是尽可能快地返回。不要使用嵌套的if let语句来展开可选项并执行类型转换,我们将使用guard语句,在没有找到所需数据的情况下简单地返回:
class NoteListViewController: UIViewController {
@objc func handleChangeNotification(_ notification: Notification) {
let noteInfo = notification.userInfo?["note"] as? [String : Any]
guard let id = noteInfo?["id"] as? Int else {
return
}
guard let note = database.loadNote(withID: id) else {
return
}
notes[id] = note
tableView.reloadData()
}
}
像我们现在所做的那样,提前返回的好处是,它使函数的失败条件更加清楚。 这不仅提高了可读性——特别是因为我们的代码现在更少缩进了——而且在决定编写单元测试的代码路径时也非常有帮助。由于现在每个失败条件都由一个守卫表示,我们可以简单地添加与每个守卫语句的条件(以及成功路径的条件)匹配的测试——我们的代码应该被完全覆盖。
我们还可以进一步改进,将从传递的通知中提取note ID的代码移到通知类型本身的私有扩展中。这样,我们的通知处理代码就可以包含使用note的ID执行更新的实际逻辑,从而产生一个更清晰的实现-像这样:
private extension Notification {
var noteID: Int? {
let info = userInfo?["note"] as? [String : Any]
return info?["id"] as? Int
}
}
class NoteListViewController: UIViewController {
@objc func handleChangeNotification(_ notification: Notification) {
guard let id = notification.noteID else {
return
}
guard let note = database.loadNote(withID: id) else {
return
}
notes[id] = note
tableView.reloadData()
}
}
使用早期的return和guard语句构造代码也可以大大简化调试失败。现在,我们不必总是遍历所有逻辑,只需在每个guard语句的返回值上设置断点,并在导致失败的条件下立即停止。
Conditional construction
在构造对象的新实例时,我们需要哪种类型的对象取决于一系列的条件,这是很常见的。例如,当应用程序启动时,我们将向用户显示哪个视图控制器取决于两个条件:
Is the user logged in?
Has the user gone through our onboarding flow?
我们对这些条件建模的最初实现可能是使用一系列if和else语句,如下所示:
func showInitialViewController() {
if loginManager.isUserLoggedIn {
if tutorialManager.isOnboardingCompleted {
navigationController.viewControllers = [HomeViewController()]
} else {
navigationController.viewControllers = [OnboardingViewController()]
}
} else {
navigationController.viewControllers = [LoginViewController()]
}
}
同样,在这种情况下,早期返回和guard语句可以让我们编写更容易阅读和调试的代码,但在这种情况下,我们讨论的不是失败条件-而只是状态的不同,所以我们的早期返回不会简单地退出我们的功能。
相反,让我们使用工厂模式的一种“轻量级版本”,并将初始视图控制器的构造移动到一个专用的函数中。这样,我们就可以返回一个视图控制器的新实例,它与我们的条件的当前状态相匹配,就像这样:
func makeInitialViewController() -> UIViewController {
guard loginManager.isUserLoggedIn else {
return LoginViewController()
}
guard tutorialManager.isOnboardingCompleted else {
return OnboardingViewController()
}
return HomeViewController()
}
上述方法的美妙之处在于它允许我们大量清理调用站点。我们不再需要在多个if和else块中复制相同的赋值代码,而可以简单地使用调用上述工厂函数的结果:
func showInitialViewController() {
let viewController = makeInitialViewController()
navigationController.viewControllers = [viewController]
}
因为我们新的makeInitialViewController函数是纯函数(它不会改变任何状态或产生任何副作用),我们实际上已经转变了一个分支逻辑——其中每个分支都会改变应用程序的状态 -变成一个纯粹的功能用来进行单一的变异, 更少的突变通常会导致更可预测的代码👍。
Codified conditions
最后,让我们看看函数如何帮助使复杂的条件更容易理解。 这里我们正在构建一个视图控制器,它让用户在社交网络应用程序中显示评论, 如果满足三个单独的条件,那么用户也可以编辑该评论。 我们的逻辑当前发生在viewDidLoad当我们决定是否添加一个编辑按钮到UI,像这样:
class CommentViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
if comment.authorID == user.id {
if comment.replies.isEmpty {
if !comment.edited {
let editButton = UIButton()
...
view.addSubview(editButton)
}
}
}
...
}
}
就像我们在“编写自文档的Swift代码”中,通过将大函数(如viewDidLoad倾向于随着时间的推移而变得更大)分割成更小的函数,使我们的代码更具有自文档性,并具有更明确的命名和作用域-我们可以做同样的事情,更清楚地编纂我们的上述条件。
由于我们的三个嵌套条件目前发生在一个方法,用于设置我们的视图控制器的子视图,它可能是一个有点难理解为什么他们在那里。除非我们有一些清晰的文档(和测试)来解释为什么这些条件与用户是否应该能够编辑评论有关,人们(包括我们自己)很容易在将来无意中改变或删除这些条件。
相反,让我们将这三个条件移动到一个专门的函数中——命名清楚——这次使用注释上的扩展实现:
extension Comment {
func canBeEdited(by user: User) -> Bool {
guard authorID == user.id else {
return false
}
guard comment.replies.isEmpty else {
return false
}
return !edited
}
}
有了上述改变,我们的视图控制器现在可以简单地专注于在viewDidLoad中设置它的UI,它变得非常清楚是什么导致编辑按钮被添加:
class CommentViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
if comment.canBeEdited(by: user) {
let editButton = UIButton()
...
view.addSubview(editButton)
}
...
}
}
将上面的方法与一些简单的单元测试结合起来,覆盖我们的新功能,我们已经大大降低了我们的代码在将来被误解(和破坏)的风险🎉。
Conclusion
使用函数、保护语句和早期返回并不是最初的概念——但当应用于分支逻辑、复杂条件或处理多个可选的代码时,很容易忘记它们有多么强大。如果我们也可以将代码结构成具有明确条件的纯函数,我们通常会得到更容易测试的代码。
如果您想看到很多早期回报和纯函数的例子,可以看看我最近开发的开源Splash框架,它也是本文中所有代码示例的动力。