停止编写 DRY 代码
“DRY” 是一个缩写词,在软件工程师早期的教育中似乎普遍被提及。例如,当我搜索“软件工程最佳实践”时,前 5 个结果中有 4 个提到了 DRY。它代表“不要重复自己”,对于刚入行的开发人员来说,这是最糟糕的教育之一。
如果你对这篇文章没有其他任何理解,请记住:DRY 原则永远不应该成为编写代码的目标。它不是代码质量的指标;它充其量只是在某些情况下使用的工具。不重复的代码并不一定比重复的代码更好,所以“DRY”原则永远不应该作为代码审查中的建议出现。
潮湿
代码有点像蛋糕。干蛋糕易碎,一碰就碎。同样,DRY 代码(没有重复的代码)可以抵御扩展和修改。通过将两种实现合并为一种,您可以将它们绑定在一起,这样就无法更改其中一种而不同时更改两种实现。
另一方面,湿蛋糕会散开,无法保持其结构。在软件中,WET 通常意味着“每次都写”——它与 DRY 相反,指的是不断重复。如果你的代码是 WET,那么即使修改其中一部分也不会破坏其他部分。然而,两条本应相同的信息很容易变得不同步。
最好的蛋糕和代码,莫过于 MOIST——“维护一个不容置疑的事实来源”。它并非编写代码的硬性规定,而是给你一个重构代码的理由。MOIST让你兼顾了两者的优势:既能增强代码的刚性以防止错误,又能尽可能保持灵活性。正如所有事物一样,平衡至关重要。
让我们看一个保持代码 MOIST 至关重要的例子。我们正在编写一个程序来处理项目的语义版本控制。它有两个命令:auto
读取你的提交历史记录并生成新版本。该manual
命令从用户那里获取一个规则名称,并根据该规则调整版本。第一次尝试可能如下所示:
fn auto(history: &[Commit]) {
let mut version = Version::get_current();
let mut major = false;
let mut minor = false;
let mut patch = false;
for commit in history {
if commit.message.contains("BREAKING CHANGE") {
major = true;
version.major += 1;
version.minor = 0;
version.patch = 0;
break;
} else if commit.message.contains("feat") && !minor {
minor = true;
version.minor += 1;
version.patch = 0;
} else if commit.message.contains("fix") && !patch && !minor {
patch = true;
version.patch += 1;
}
}
write_version(version);
}
fn manual(rule: String) {
let mut version = Version::get_current();
if rule == "major" {
version.major += 1;
version.minor = 0;
version.patch = 0;
} else if rule == "minor" {
version.minor += 1;
version.patch = 0;
} else if rule == "patch" {
version.patch += 1;
} else {
panic!("Unknown rule: {}", rule);
}
write_version(version);
}
这里,我们维护了两种不同的实现,用于将语义规则应用于语义版本——关于规则的两个截然不同的真相!不幸的是,这种分离很容易导致两个命令之间行为迥异,让用户感到困惑。所以,让我们只维护一个关于如何提升版本号的真相来源!
fn auto(history: &[Commit]) {
let mut rule = "patch";
for commit in history {
if commit.message.contains("BREAKING CHANGE") {
rule = "major";
break;
} else if commit.message.contains("feat") {
rule = "minor";
}
}
bump_version(rule);
}
fn bump_version(rule: String) {
let mut version = Version::get_current();
if rule == "major" {
version.major += 1;
version.minor = 0;
version.patch = 0;
} else if rule == "minor" {
version.minor += 1;
version.patch = 0;
} else if rule == "patch" {
version.patch += 1;
}
write_version(version);
}
fn manual(rule: String) {
if rule != "major" && rule != "minor" && rule != "patch" {
panic!("Unknown rule: {}", rule);
}
bump_rule(rule);
}
好了,一切都好了吧?嗯,其实也不完全是。我们的新bump_version
功能是将规则应用于版本的仲裁器——但现在我们有三个不同的地方定义了这些规则!如果我们想添加“预发布”规则,就必须记住分别更改每个位置,这很容易导致错误!所以,再次强调,我们只维护一个事实来源。
enum Rule {
Major,
Minor,
Patch,
}
fn auto(history: &[Commit]) {
let mut rule = Rule::Patch;
for commit in history {
if commit.message.contains("BREAKING CHANGE") {
rule = Rule::Major;
break;
} else if commit.message.contains("feat") {
rule = Rule::Minor;
}
}
bump_version(rule);
}
fn bump_version(rule: Rule) {
let mut version = Version::get_current();
match rule {
Rule::Major => {
version.major += 1;
version.minor = 0;
version.patch = 0;
},
Rule::Minor => {
version.minor += 1;
version.patch = 0;
},
Rule::Patch => {
version.patch += 1;
},
}
write_version(version);
}
fn manual(rule: String) {
if rule == "major" {
bump_version(Rule::Major);
} else if rule == "minor" {
bump_version(Rule::Minor);
} else if rule == "patch" {
bump_version(Rule::Patch);
} else {
panic!("Unknown rule: {}", rule);
}
}
好了,我们了解了规则的一个真实来源、如何将它们应用于语义版本、如何从提交消息生成它们以及如何解释用户的输入。
但我看到了更多重复!如果我们改成一个commit.message.contains
语句,其中包含从关键字到其所代表的规则的映射,会怎么样?
在考虑任何重构时,思考目标至关重要。以 MOIST 为例,我们必须问自己:“我们试图保护的真相是什么?” “重大变更”和“特性”是否应该始终以相同的方式确定?不!事实上,此实现尚未与语义化版本控制保持一致,并且这两个分支最终会进一步分化!将这两条信息耦合在一起会增加僵化性,并且无法保护单一的真相来源,因此我们不应该将它们合并在一起。
那用户输入怎么办?我们当然应该把字符串到规则的映射分解出来,只保留一个函数调用位置!让我们试试同样的测试——我们想要保护的唯一真理是什么?是“每个规则都应该只由一个字符串决定吗?”这感觉更像是一个实现细节。事实上,如果我们添加一条prerelease
规则,我们需要一些额外的信息来选择前缀。这种改变感觉像是在减少重复,但没有明确的目标——它会把单独的规则绑定在一起,使得只修改一条规则而没有明显的好处变得更加困难。
MOIST 可能会比较主观,也比较模糊——就像任何编码实践一样,但它试图在有害的重复和良性代码之间划清界限。成功应用任何“最佳实践”的关键在于理解其真正的目标,并始终牢记这一点。
雨天
撇开蛋糕不谈,还有一些与 DRY 半相关的、更有价值的目标。我会尝试用一些与 DRY 半反义的缩写来概括它们。“可重用的抽象,最好不要自己动手”是“能买就不要自己做”的另一种说法。一个人解决问题并分享解决方案比几百个人独立解决问题效率高得多。这种做法适用于多种规模,但它也实现了 DRY 试图倡导的另一个基本目标。
最好的例子就是开源社区。与其重复别人已经完成的工作,不如在已有的基础上继续发展。同样,你可以分享你所遇到问题的解决方案,这样其他人就无需再浪费精力,从而让你的工作影响力倍增。RAINY 就像把“不要重复自己”的理念运用到整个社区。更像是“让我们不要重复自己”。
正如所有事情一样,需要找到平衡点。我们不希望出现“维护开源很麻烦,所以其他人经常忽略它”的“季风”现象。🤪 基本上,作为消费者,安装依赖项很麻烦,而保持它们的更新更是麻烦(不过,你应该使用Renovate来缓解这种情况)。另一方面,作为维护者,持续关注问题和拉取请求既繁重又费力,而且往往吃力不讨好。双方的这种程度的努力可能会导致相对简单的错误永远得不到修复。
平衡这一点很困难,我认为关于开源的利弊的讨论远远超出了本文的范围——所以我就此打住。不过,如果你想看一篇题为“你真的需要那个依赖项吗?”或“何时开源它”的文章,请告诉我。
新鲜的
“函数用短小精悍的语言更容易阅读。” 没错——我的缩写越来越乱了,也越来越偏离 DRY 原则了。我一点也不后悔。
审阅者经常使用 DRY 作为“将其变成函数”的代名词。代码的可读性对于可维护性至关重要,而提升可读性的一个简单方法是将大型函数拆分成几个较小的函数。这样,读者只需按顺序阅读函数名称,就能大致了解代码的功能——而且读起来应该有点像散文。
当然,这里需要权衡利弊;函数调用并不总是比组成它的语句更易于阅读,而且调用函数通常会导致性能损失。尽管如此,作为目标,重构以提高可读性远比仅仅为了减少重复而重构更有价值。
测试
下次当你考虑是否应该重复自己的话时,我建议你问自己以下一系列问题:
- 同一个概念是否存在两个不同的事实来源?如果是,请尝试将它们统一起来。
- 这段代码是解决普遍问题,还是针对我/我的工作?如果可以推广,可以考虑发布一个可复用的模块(无论是开源的还是在私人/公司发布的)。
- 我能否在不停下来检查的情况下,通读这段代码并理解它的作用?对于这个问题,理想情况下,请一位没有编写代码的人在代码审查中回答这个问题。如果人类难以理解,可以考虑抽象出一些不必要的细节,比如函数。
希望我这些关于缩写的废话能给你提供足够的 DRY 替代方案,让你不再使用它。记住,不重复自己并非重构的真正价值所在。相反,试着在心中设定一个目标,比如提升正确性、造福社区或让代码更易于阅读。现在去享用蛋糕吧;这是你应得的。
这篇文章对你有帮助吗?请在GitHub、Patreon或Ko-Fi上给我打赏。
对本文有任何疑问或评论?欢迎在GitHub讨论区留言!
想要收到后续文章的通知吗?请在GitHub 仓库中查看发布信息,或在 Twitter 上关注我。
对未来的博客主题有什么想法或要求吗?欢迎在 GitHub 讨论区的想法栏目下提出。
鏂囩珷鏉ユ簮锛�https://dev.to/dbanty/stop-writing-dry-code-51em