代码可以粗略分为,通用代码和业务代码。这种划分方式并非唯一,不同角度有不同的划分方式。
通用代码和业务代码,实际没有明确的界限。只是普遍来说,通用代码目的明确,可以复用于很多地方,比较稳定,因外部而引起的变动较少。而业务代码,经常因外部原因而变化,比如产品需求、公司活动、节日庆典等等。
大多数程序员日常工作,其实是在写业务代码。自然也可以从业务代码中,逐渐抽出一些通用代码,但业务逻辑还是避免不了的。对于通用代码,自然是求稳,测试性能,编写单元测试。但对于业务代码,全部编写单元测试是不现实的,因为业务变化太快,写的单元测试很容易就过时,单元测试代码也是代码,也需要维护。
对于业务代码,应该思考如何让其容易修改,而非如何不被修改。
复制粘贴代码之所以糟糕,并非是复制粘贴那个时刻,而是将修改问题推到了日后。假如只考虑目前,要实现一个类似的新功能,复制粘贴,改几个名字或数字,很快就得到成果;往往比你先去重构整理代码,抽取函数再调用,要快得多。只是日后需要修改时,复制出来的代码都要分别修改,复制 10 次,就需要修改 10 个地方。漏了 1 个地方,就会出现 Bug 了。
闲话休提,言归正传。除了封装成函数,另外一个减少重复的有效手段是表格驱动。表格驱动,是将原有代码拆分成,表格和操作表格的代码,这里的表格是指数组或者字典。单看这句话大概不会明白,下面举例子。
比如我有代码创建一个按钮。
let button0 = UIButton()
button0.setTitle("Red Button", for: .normal)
button0.setTitleColor(UIColor.red, for: .normal)
self.view.addSubview(button0)
之后我再想加一个按钮,这时可以不做任何重构,直接复制粘贴,改一下名字,这样重复了 4 行代码,还不算严重(假如重复的代码太多,比如超过 10 行,就不能直接复制)。变成
let button0 = UIButton()
button0.setTitle("Red Button", for: .normal)
button0.setTitleColor(UIColor.red, for: .normal)
self.view.addSubview(button0)
let button1 = UIButton()
button1.setTitle("Blue Button", for: .normal)
button1.setTitleColor(UIColor.blue, for: .normal)
self.view.addSubview(button1)
之后我又想加一个按钮,这时就不能复制粘贴了。代码只出现一次,是孤例;出现两次,可能是巧合;但事不过三,出现三次就需要重构了。观察到重复的代码有 title 和 color 不一样,于是表格只有两项,原有的代码重构成。
let infos = [
("Red Button", UIColor.red),
("Blue Button", UIColor.blue),
];
infos.forEach { info in
let button = UIButton()
button.setTitle(info.0, for: .normal)
button.setTitleColor(info.1, for: .normal)
self.view.addSubview(button)
}
重构时,并不实现任何新功能,也不改变代码行为。重构后,我想加个黄色的按钮,只需要在表格中添加一项。
let infos = [
("Red Button", UIColor.red),
("Blue Button", UIColor.blue),
("Yellow Button", UIColor.yellow),
];
xxxx
同样思路,我们来解决题主的例子,用同一个函数从磁盘载入三个不同的文件。假设这是模型文件,已经有函数 loadModelFromFile,最开始只有一行。
let model0 = loadModelFromFile("example/star.model")
载入另一个模型,复制粘贴一行,变成
let model0 = loadModelFromFile("example/star.model")
let model1 = loadModelFromFile("example/moon.model")
假如需要载入其它模型,就需要重构了。将路径抽取成表格,就演变成:
let paths = [
"example/star.model",
"example/moon.model",
"example/sun.model",
];
let models = paths.map { path in
let model = loadModelFromFile(path)
return model
}
以后再修改,只需要在表格中插入一行。假如担心路径复制后忘记改名字怎么办呢?特别是表格逐渐变长的时候。这时可以稍微加个检查。
extension Array where Element: Hashable {
func toSet() -> Set {
var set = Set()
for v in self {
set.insert(v)
}
return set
}
func hasDuplicate() -> Bool {
return self.toSet().count != self.count
}
}
let paths = [
"example/star.model",
"example/moon.model",
"example/moon.model"
]
assert(!paths.hasDuplicate())
xxxx
我们添加了一个 assert 作为额外检查,这个 assert 在 release 版本是会被优化掉的。而在开发 debug 阶段,假如我忘记改名字了,paths 就有重复元素(如上面代码有两行 "example/moon.model")就会触发了 assert,于是就知道写错了。重构和添加 assert 检查之后,我可以确信我这段代码是不会出错的。
另外注意到,我们已经从业务代码中,为数组抽出 toSet 和 hasDuplicate 两个通用函数。这两个通用函数脱离具体业务,可以复用于其它地方。当这两个通用函数第一次使用时,可以直接写在当前业务文件中。当其它地方也需要用到这些函数时,我们可以将其转移到通用代码库中,顺手为其编写单元测试代码。单元测试代码主要测试你不确定,可能出错的地方,但其实这两个函数很简单,一眼看过去就知道不会错的,甚至连单元测试也可以免了。之后假如觉得 hasDuplicate 这个名字不够好,就取个更好的名字,将用到的地方顺手再改改。慢慢地,就会形成一些通用库,来帮助更好地写业务代码。
业务代码通常没有什么性能问题,怎么清晰怎么写。将其转移到通用代码,就需要多考虑性能了。比如上述的 hasDuplicate,保存接口不变,我们将其优化一下。碰到重复元素就可以立即退出了,没有必要扫描整个数组。
extension Array where Element: Hashable {
func hasDuplicate() -> Bool {
var set = Set(minimumCapacity: self.count)
for v in self {
let (inserted, _) = set.insert(v)
if !inserted {
return true
}
}
return false
}
}
表格驱动在《代码大全》这本书有详细描述。上述例子使用 Swift 语言,而这种思路独立于语言。实际上是将程序分解成,数据和操作数据的代码,表格驱动的表格就是数据。这样操作数据的代码保持不变,修改的只是数据。而修改数据会比修改代码更容易。抽成的表格,假如将来有需要,也很容易从主程序中分离出来,独立做成配置。配置最开始是纯数据,如果配置当中又需要一定的逻辑,就会慢慢演变成了 DSL。
也可以扩展阅读这篇文章《程序的本质复杂性和元语言抽象》,这篇文章中将程序分解成,逻辑和控制,讲述方式和术语可能不一样,而道理是相通的。