
作为软件工程师,我们都在努力编写整洁、可维护且易于测试的代码。我们遵循最佳实践,阅读软件设计书籍,并进行严格的代码审查。但如果我们一些根深蒂固的习惯实际上是隐蔽的反模式呢?如果存在更有效、虽然不那么明显的方法来解决常见的编码问题呢?
在最近的 GopherCon Europe[1] 大会上,Dave Cheney 分享了他对于什么是好代码的一些"非常强烈的观点"。这些观点在生产系统的淬炼中形成,常常挑战传统智慧。本文提炼了一些最令人惊讶、最具影响力的要点,它们可能会改变你编写 Go 代码的方式。
Cheney 的第一条颇具挑战性的建议是:对 if 语句保持强烈的厌恶,对 else 更是如此。他认为,每当我们看到 if,就应该想要隐藏它;每当看到 else,就应该重构代码让它消失。
他批评的一个典型模式是"延迟初始化(Lazy Initialization)"。假设你有一个变量 thing,需要在生产环境中初始化为真实实现,在其他情况下使用 mock。许多开发者会这样写:
var thing *Thing
if os.Getenv("ENV") == "production" {
thing = NewRealThing()
} else {
thing = NewMockThing()
}
这里的问题是 thing 一开始是 nil。任何意外绕过这个 if/else 块的重构都可能导致 thing 未被初始化。更好的方法是通过先将 thing 初始化为一个安全的默认值来消除 else:
thing := NewMockThing() // 总是初始化为安全的默认值
if os.Getenv("ENV") == "production" {
thing = NewRealThing() // 仅在生产环境下覆盖
}
这是一个改进,因为 thing 永远不会是 nil。然而,正如 Cheney 所承认的,这引发了一个性能问题:如果创建 mock 的成本很高,我们是否在生产环境中付出了这个代价,却只是为了立即丢弃它?虽然这在应用程序初始化阶段可能是可以接受的,但在每个请求的上下文中确实是一个合理的担忧。这个权衡完美地引出了最佳解决方案:将这个逻辑封装在一个辅助函数中。
func newThing(env string) *Thing {
if env == "production" {
return NewRealThing()
}
return NewMockThing()
}
// 在调用代码中:
thing := newThing(os.Getenv("ENV"))
这种方法提供了几个具体的好处。从调用者的角度来看,thing总是 从单个函数调用初始化。
条件逻辑被赋予了一个描述性的名称(newThing),最重要的是,newThing 现在是一个简单的、独立的函数,可以独立进行单元测试。
Cheney 更进一步指出,因为这是基于环境的 选择,他更喜欢使用 switch 语句,这样意图更清晰,当添加新环境时代码也更容易扩展。
我们花费无数时间争论变量、类型和函数的完美名称。但 Cheney 提出了一个反直觉的观点:有时候,最好的名字就是根本没有名字。事实上,给某些东西命名可能适得其反。
考虑 HTTP 处理器中的一个常见场景:解码 JSON 请求。通常,你不想直接解码到最终的领域类型,因为你需要先执行验证或转换。标准方法是创建一个中间类型来保存原始请求数据。
问题是什么?你现在必须为这个临时的、一次性使用的类型发明一个名字。为一个只存在几行代码的类型绞尽脑汁——RequestPayload?APIRequest?requestClient?——这是认知精力的浪费。
解决方案是使用匿名结构体(Anonymous Struct)。
func MyHandler(w http.ResponseWriter, r *http.Request) {
var payload struct {
Name string `json:"name"`
Value int `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
// 处理错误
return
}
// ...验证 payload 并创建最终的领域类型...
}
这个匿名结构体永远不会逃逸出这个函数,也永远不会被其他人看到。通过不给它命名,你避免了用一个在这个特定上下文之外没有意义的标识符来污染包的命名空间。这个小改变减少了认知负担,并让注意力集中在应用程序中真正重要的标识符上。
下一条建议虽然微妙,但对代码可读性和认知负担有深远影响。Cheney 建议我们应该以一种更容易让大脑处理的方式编写条件语句,即使它看起来不那么直接。
他使用检查 HTTP 状态码的例子。众所周知,400 及以上的状态码表示错误。典型的检查会这样写:
if status >= 400
Cheney 认为这样更好:
if status > 399
为什么?因为 >= 运算符迫使你的大脑执行两项检查:"状态是否大于 400,或者是否等于 400?"相比之下,> 运算符只需要一项检查:"状态是否大于 399?"这看起来可能微不足道,但它直接关系到我们短期记忆的限制——通常被认为能够保持"七加减二"个不同的项目。
> 检查需要在脑中保持的信息少一项。当在整个代码库中一致应用时,这些微小的优化释放了宝贵的心智带宽,让你能够专注于应用程序中更复杂的逻辑。
main.run 模式:隔离你的应用逻辑main 函数是每个 Go 程序的入口点,但它通常是最容易出错的。Cheney 用一个生动的轶事来说明这一点:他曾经遇到过一个生产问题,日志记录器初始化失败,随后尝试使用同一个日志记录器来报告初始化失败,导致程序崩溃。
这突出了核心问题:main 函数非常难以测试,因为它隐式依赖于全局状态,如操作系统环境变量、命令行标志和标准 I/O 流。测试操作这种全局状态的代码,用 Cheney 的话说,是"糟糕且可怕的"。更关键的是,这意味着测试无法并行运行,因为只有一个全局环境,这在现代 CI/CD 流水线中是一个主要瓶颈。
为了解决这个问题,Cheney 提倡"main.run 模式",这是 Matt Ryer 创造的一种技术。机制很简单:将应用程序的所有逻辑从 main 中移出,放入一个新函数中,通常称为 run。这个 run 函数应该将其所有依赖项——如配置、I/O 流和参数——作为参数接收,并返回一个错误。这种模式强制实现了清晰的分离,使你的逻辑更容易理解。正如 Cheney 所说,这是为了让你的逻辑更容易讨论:
从根本上说,如果某样东西很难讨论,它就会很难使用。
你的 main 函数被简化为一个简单的六行包装器:
func main() {
if err := run(os.Stdout, os.Args); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}
// 所有应用逻辑都在这里。
funcrun(stdout io.Writer, args []string)error {
// ... 解析参数、设置依赖项、运行应用 ...
// ... 如果出现问题,返回错误 ...
returnnil
}
好处是立竿见影且非常显著的。run 函数现在只是一个普通的、可测试的 Go 函数。它没有隐藏状态;它的依赖项在函数签名中是显式的,可以像代码库中的其他单元一样快速且并行地进行测试。
这四个习惯——避免 else、使用匿名结构体、编写更简单的条件语句以及将逻辑与 main 隔离——可能看起来像是细微的、带有个人观点的调整。但它们共同形成了一种连贯的哲学,指向一个单一目标:减少认知负担,产生从根本上更清晰、更易维护、更容易理解的代码。
Cheney 在演讲结尾引用了萨丕尔-沃尔夫假说(Sapir-Whorf Hypothesis),该假说认为语言塑造了我们的思维方式。这是一个应用于软件开发的强大理念。他挑战我们"在自己的代码中寻找笨拙的语义,寻找不优雅的散文",并问自己是否可以改进。如果你的程序武器库中有另一个名词或动词,它是否可以改进?
[1]GopherCon Europe: https://www.youtube.com/watch?v=RZe8ojn7goo