在阅读《100个go语言经典错误》的时候,看到错误:使用文件名作为函数输入。由此思考,这个虽然是入参的设计,但是实际上涉及到了函数的抽象问题。
从函数输入选择与函数抽象的最佳实践 到 思考关注点分离原则 。
函数输入选择与函数抽象的最佳实践
通过分析46-function-input中的代码,我们可以总结出关于函数输入选择的重要原则以及函数抽象的深入思考。
一、函数名不应包含输入来源
代码展示了两个功能相似但设计差异明显的函数:
1. 问题设计:输入来源耦合在函数名中
func countEmptyLinesInFile(filename string) (int, error) {
file, err := os.Open(filename)
if err != nil {
return 0, err
}
// 处理文件内容...
return 0, nil
}
问题分析:
- 函数名
countEmptyLinesInFile
指明了输入来源(文件) - 函数直接接受文件名作为参数,强制从文件系统读取
- 这限制了函数的用途和测试难度
- 违反了关注点分离原则
2. 改进设计:接受抽象接口
func countEmptyLines(reader io.Reader) (int, error) {
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
// ...
}
return 0, nil
}
改进之处:
- 函数名只描述功能(计数空行),不包含输入来源
- 接受
io.Reader
接口而非具体类型 - 使函数更通用,可以接受任何实现了
io.Reader
的输入源
3. 测试代码的优势对比
func TestCountEmptyLines(t *testing.T) {
emptyLines, err := countEmptyLines(strings.NewReader(
`foo
bar
baz
`))
// 测试逻辑...
}
使用改进后的设计,测试变得更加简单:
- 直接使用
strings.NewReader
创建测试输入 - 无需创建临时文件
- 测试更加快速和可靠
- 避免了文件系统交互的复杂性
二、函数抽象的深入思考
1. 抽象级别的选择
函数抽象应该遵循以下原则:
依赖抽象,不依赖具体实现:
- 尽可能接受接口而非具体类型
- 在Go中,常用的抽象接口包括
io.Reader
、io.Writer
、io.Closer
等 - 这些小型、单一职责的接口符合Go的设计哲学
关注点分离:
- 文件的打开和读取是一个关注点
- 计算空行数是另一个关注点
- 分离这些关注点使代码更具可维护性和可重用性
2. 抽象的粒度与平衡
适当的抽象粒度:
- 过度抽象会增加复杂性,使代码难以理解
- 抽象不足会导致代码重复和难以测试
- 在示例中,
io.Reader
是适当的抽象级别,既通用又易于理解
抽象的成本与收益:
- 抽象增加了间接性,可能使调试更复杂
- 但好的抽象提高了代码的可测试性、可复用性和可维护性
- 权衡这些因素是设计函数时的关键考量
3. 函数组合与管道设计
示例体现了函数组合的思想:
func main() {
file, err := os.Open("main.go")
if err != nil {
panic(err)
}
_, _ = countEmptyLines(file)
}
这体现了Unix哲学中的管道概念:
- 文件打开操作生成
*os.File
(实现了io.Reader
) - 该结果被传递给
countEmptyLines
函数 - 每个函数专注于一项任务,通过组合实现复杂功能
4. 更深层次的抽象考量
面向接口编程:
- 定义函数时考虑"这个函数需要什么能力"而不是"这个函数需要什么具体类型"
- 例如,计数空行只需要"读取内容的能力"(
io.Reader
),不需要知道内容来自哪里
抽象泄漏的防范:
- 防止实现细节泄漏到抽象中
- 如果
countEmptyLinesInFile
内部异常处理针对文件系统特定错误,这就是抽象泄漏
单一职责原则:
- 每个函数应该只有一个变更的理由
countEmptyLines
专注于计数逻辑,不关心I/O来源- 这使得当计数算法需要变更时,不会影响到I/O处理部分
总结与实践建议
-
函数命名原则:
- 函数名应描述"做什么",而不是"用什么做"
- 避免在函数名中包含输入源或实现细节
-
输入参数选择:
- 优先选择接口而非具体类型
- 使用最小化的接口定义,只要求必要的功能
-
分层设计:
- 将底层I/O操作与业务逻辑分离
- 创建多个小函数,每个函数专注于一个方面
-
测试友好设计:
- 函数设计应便于单元测试
- 避免对外部资源(文件、网络、数据库)的直接依赖
-
适应变化:
- 良好的抽象能更好地适应未来的变化
- 考虑函数可能的扩展方式
通过遵循这些原则,我们可以设计出更加健壮、灵活且易于维护的代码,真正体现Go语言简洁而强大的设计理念。
关注点分离原则(Separation of Concerns)
关注点分离原则是软件工程中的一个基本设计原则,它主张将计算机程序分解成不同的部分,每个部分负责处理一个特定的"关注点"或功能方面。
核心概念
关注点分离原则的核心思想是:一段代码应该只关注于解决一个特定的问题或实现一个特定的功能。
原则解释
-
关注点的定义:
- 关注点是程序的一个特定方面或责任
- 例如:数据访问、业务逻辑、用户界面、错误处理、日志记录等
-
分离的好处:
- 提高代码的可维护性
- 增强代码的可读性
- 简化单元测试
- 促进代码复用
- 使系统更易于理解和修改
实例解析
通过之前的代码示例可以很好地理解关注点分离:
未分离关注点:
func countEmptyLinesInFile(filename string) (int, error) {
file, err := os.Open(filename)
if err != nil {
return 0, err
}
// 文件处理和计数逻辑混合在一起
scanner := bufio.NewScanner(file)
// ...计数逻辑...
return 0, nil
}
这个函数混合了两个关注点:
- 文件操作关注点:打开文件、处理文件错误
- 业务逻辑关注点:计算空行数量
分离关注点后:
// 文件操作关注点
func openFile(filename string) (*os.File, error) {
return os.Open(filename)
}
// 业务逻辑关注点
func countEmptyLines(reader io.Reader) (int, error) {
scanner := bufio.NewScanner(reader)
// ...计数逻辑...
return 0, nil
}
// 组合使用
func main() {
file, err := openFile("example.txt")
if err != nil {
// 错误处理
}
defer file.Close()
count, err := countEmptyLines(file)
// ...
}
通过分离,每个函数现在只负责一个关注点:
openFile
只关注文件打开操作countEmptyLines
只关注计数逻辑
测试中的体现
测试文件main_test.go
展示了关注点分离带来的测试优势:
func TestCountEmptyLines(t *testing.T) {
emptyLines, err := countEmptyLines(strings.NewReader(
`foo
bar
baz
`))
// 测试逻辑
}
由于关注点分离:
- 测试不需要依赖文件系统
- 可以直接使用字符串作为输入
- 测试更加简洁、快速和可靠
关注点分离的其他应用
-
MVC架构:
- Model(数据模型):关注数据结构和业务规则
- View(视图):关注用户界面和数据展示
- Controller(控制器):关注用户输入和流程控制
-
分层架构:
- 表示层:关注用户交互
- 业务层:关注业务逻辑
- 数据访问层:关注数据存储和检索
-
Go语言中的体现:
- 接口与实现分离
- 小型、专注的包设计
- 标准库中的
io.Reader
/io.Writer
等接口
实践建议
-
识别关注点:
- 分析函数或模块的职责
- 确定是否混合了多个不同的关注点
-
抽象恰当边界:
- 使用接口定义功能边界
- 在不同关注点之间建立清晰的契约
-
保持适度:
- 过度分离可能导致代码过于分散
- 确保分离后的代码仍然容易理解和组合
-
权衡取舍:
- 关注点分离有时会增加代码量和复杂性
- 在简单场景中,适度的关注点合并可能更实用
关注点分离是软件设计中的重要原则,它帮助我们创建更加模块化、可维护和可测试的代码。在Go编程中,这一原则与语言的简洁哲学和组合设计理念高度契合。