《代码整洁之道》阅读笔记
《代码整洁之道》阅读笔记
《clean code》和《clean architecture》两本算是耳熟能详,抽空读一下。架构还好说,出版时间更近,最近AI发展迅猛导致社区也有很大声量嚷嚷着架构的重要性。而代码这本可是十多年前的书了,也不知道现在读的话价值几何。
第1章 整洁代码
列了一大堆流派和定义讲什么是整洁代码及其重要性,但看一遍就过了没进脑子。感觉不重要。
第2章 有意义的命名
2.2 名副其实
首先,如果你打过acm之类,放弃那一套abcxyzikl的命名方式,尽量保证每个变量名都有意义。其次,最好多写几个词表明详细用途,而不是单一的名词。例如daysSinceCreation就是比单独一个days更合适。现在ide都有AI自动补全,tab一下也不费事。
还有就是,类似状态之类的判断,相比单纯的值比较再查定义,定义一系列别名或者功能函数会更合适,如下:
// 1. 值比较
type Book struct {
Name string
Status int // 0-unavailable 1-available
}
if book.Status == 1 {
/*do something*/
}
// 2. 别名
const (
BOOK_STATUS_UNAVAILABLE = 0
BOOK_STATUS_AVAILABLE = 1
)
type Book struct {
Name string
Status int
}
if book.Status == BOOK_STATUS_AVAILABLE {
/*do something*/
}
// 3. 函数
const (
BOOK_STATUS_UNAVAILABLE = 0
BOOK_STATUS_AVAILABLE = 1
)
type Book struct {
Name string
Status int
}
func (b *Book) IsAvailable() bool {
return b.Status == BOOK_STATUS_AVAILABLE
}
if book.IsAvailable() {
/*do something*/
}2.3 避免误导
少用比较通用的词汇,例如list,data等,可以和2.2一样加点限定词,或者选择更准确的命名。此外,避免在同一处使用外形相近的名称,容易导致混淆。如书中示例XYZControllerForEfficientHandlingOfStrings和XYZControllerForEfficientStorageOfStrings。
2.4 做有意义的区分
一种例子是数字后缀,如:
public static void copyChars(char a1[], char a2[]) {
for (int i = 0; i<a1.length; i++) {
a2[i] = a1[i];
}
}如果将入参改为source和destination就会好很多。
另一种例子是废话,例如某张表的表名为xxx_table,某个名称字段的命名为NameString,这种后缀都是无意义的应该精简。另外,在没有明确约定的情况下,XXX,XXXs,XXXInfo,XXXData等也都没有意义需要做出区分。
2.5 使用读的出来的名称
比较下面两种定义方式:
// not readable
type Book struct {
GenYMDHMS time.time
ModYMDHMS time.time
}
// readable
type Book struct {
GenerationTimestamp time.time
ModificationTimestamp time.time
}可以看到后者对阅读和口头交流更友好。当然原文是java例子,我只是写成go形式而已。go中约定俗成的命名为CreatedAt和UpdatedAt。
2.6 使用可搜索的名称
这个在2.2提过,对于值属性,定义一个别名会方便很多。而对于频繁使用和搜索和变量,一个更有标识性的名称也比abcde更方便。
2.7 避免使用编码
不是很懂,从来没用过。匈牙利语标记法这种东西就该被扫进历史的垃圾堆。
2.8 避免思维映射
没太看懂在说什么,不过意思还是老生常谈的那一套,别用abc这样的命名,读者需要自行映射为具体概念。应使用有具体意义的命名实现自解释。
2.9 类名
类名和对象名应该是名词或名词短语,不应当是动词
2.10 方法名
方法名应当是动词或动词短语。
对于java来说,属性访问器、属性修改器和断言应该依Javabean标准加上get、set和is前缀。go虽然没有类似的标准,但也建议在项目中统一命名方式,不要一个创建对象搞出NewA、CreateB、InitC等好几种命名。
2.11 别扮可爱
我觉得更合适的译名是“别整活”,不过10年应该没有这个词。言归正传,不要在命名时使用俗语、俚语或梗等。话说真有人会这样做吗?
2.12 每个概念对应一个词
没太看懂,但大概明白意思。和2.10的举例一样,不要一个创建对象搞出NewA、CreateB、InitC等好几种命名。
2.13 别用双关语
没遇到过,也没有示例。不过意思还是很好懂的。
2.14 使用解决方案领域名称
大概就是使用专有名词的意思。
2.15 使用源自所涉问题领域的名称
同上
2.16 添加有意义的语境
// 语境不明确的变量
private void printGuessStatistics(char candidate, int count) {
String number;
String pluralModifier;
String verb;
if (count == 0) {
number = "no";
pluralModifier = "s";
verb = "are";
} else if (count == 1) {
number = "1";
pluralModifier = "";
verb = "is";
} else {
number = Integer.toString(count);
verb = "are";
pluralModifier = "s";
}
String guessMessage = String.format(
"There %s %s %s%s", verb, number, candidate,pluralModifier
);
print(guessMessage);
}// 有语境的变量
private void printGuessStatistics(char candidate, int count) {
String number;
String pluralModifier;
String verb;
public String make(char candidate, int count) {
createPluralDependentMessageParts(count);
return String guessMessage = String.format(
"There %s %s %s%s", verb, number, candidate, pluralModifier
);
}
private void createPluralDependentMessageParts(int count) {
if (count == 0) {
thereAreNoLetters();
} else if (count == 1) {
thereIsOneLetter();
} else {
thereAreManyLetters(count);
}
}
private void thereAreManyLetters(int count) {
number = Integer.toString(count);
verb = "are";
pluralModifier = "s";
}
private void thereIsOneLetter() {
number = Integer.toString(count);
verb = "are";
pluralModifier = "s";
}
private void thereAreNoLetters() {
number = "no";
pluralModifier = "s";
verb = "are";
}
print(guessMessage);
}看看如上例子就很容易理解,不过我并不完全认同这个观点,除非分支内部的代码过于复杂,否则我都是在分支点上注释一下,而不是抽象出好几层函数徒增复杂度。
// 个人习惯
// 打印统计信息
private void printGuessStatistics(char candidate, int count) {
String number;
String pluralModifier;
String verb;
// 统计数量,分类处理
if (count == 0) {
// 没有
number = "no";
pluralModifier = "s";
verb = "are";
} else if (count == 1) {
// 只有一个
number = "1";
pluralModifier = "";
verb = "is";
} else {
// 有多个
number = Integer.toString(count);
verb = "are";
pluralModifier = "s";
}
// 构造结果
String guessMessage = String.format(
"There %s %s %s%s", verb, number, candidate, pluralModifier
);
print(guessMessage);
}2.17 不要添加没用的语境
错误示例:对于一个名为GSD的应用,在其中每个类添加GSD前缀。我在实际开发中就遇到过类似的,把数据库中所有表名字段名都加一个统一前缀无一例外,实在不知道当初这么设计的用意何在。
总之,只要短名称足够清楚,就比长名称好。别给名称添加不必要的语境。
第2章总结
基本都是些老生常谈的内容。
第3章 函数
3.1 短小
我的观点和2.16一致,不应该只为了追求函数短小添加过多无意义的抽象。我不希望我的ide在尝试补全时弹出一堆只在一个地方有用到的语义特定的函数,而不是我需要的可复用的通用函数。我理解的抽象是为了复用代码,而阅读障碍应该交给注释解决。至于函数过长的问题,你们的IDE没有折叠功能吗?
3.2 只做一件事
如题。
3.3 每个函数一个抽象层级
大概能理解意思,但实践中并不容易做到。很多时候刚开始设计时只有几句话位于不同层级就没有进一步抽象而是塞在一起,后续更新迭代不断填充就会越来越臃肿。但认死理必须一个函数一个抽象层级也不可取,会导致项目早期过度抽象,很多几乎不会不会变动的地方也过度设计。所以还是需要具体问题具体分析。我的主力语言是Go语言,通常还是先简单实现,后续规模增大再拆分。
3.4 switch语句
原文是Java的抽象工厂模式,这里用go重写方便理解:
// 问题:每次处理员工都要写 switch
type EmployeeType int
const (
COMMISSIONED EmployeeType = iota // 提成员工
HOURLY // 时薪员工
SALARIED // 月薪员工
)
// 计算薪资 - 这里有一个 switch
func CalculatePay(e Employee) int {
switch e.Type {
case COMMISSIONED:
return calculateCommissionedPay(e)
case HOURLY:
return calculateHourlyPay(e)
case SALARIED:
return calculateSalariedPay(e)
}
return 0
}
// 判断是否是领班 - 又有一个 switch
func IsLead(e Employee) bool {
switch e.Type {
case COMMISSIONED:
return isCommissionedLead(e)
case HOURLY:
return isHourlyLead(e)
case SALARIED:
return isSalariedLead(e)
}
return false
}
// 以后每加一个员工类型,要改所有 switch 函数如上,对Employee的所有操作都要先判断类型再处理。
通过go的接口,让switch类型判断只在创建对象时处理一次,后续各自的操作由各类型自己实现。
// 第一步:定义接口,所有员工都实现它
type Employee interface {
CalculatePay() int
IsLead() bool
// 其他员工相关行为...
}
// 第二步:三种具体员工类型,各自实现接口
type CommissionedEmployee struct {
// 提成员工的字段
}
func (e *CommissionedEmployee) CalculatePay() int {
// 提成薪资算法
return 0
}
func (e *CommissionedEmployee) IsLead() bool {
// 提成员工判断领班的逻辑
return false
}
type HourlyEmployee struct {
// 时薪员工的字段
}
func (e *HourlyEmployee) CalculatePay() int {
// 时薪算法
return 0
}
func (e *HourlyEmployee) IsLead() bool {
// 时薪员工判断领班的逻辑
return false
}
type SalariedEmployee struct {
// 月薪员工的字段
}
func (e *SalariedEmployee) CalculatePay() int {
// 月薪算法
return 0
}
func (e *SalariedEmployee) IsLead() bool {
// 月薪员工判断领班的逻辑
return false
}
// 第三步:工厂函数 - switch 只在这里出现一次!
func CreateEmployee(type EmployeeType) Employee {
switch type {
case COMMISSIONED:
return &CommissionedEmployee{}
case HOURLY:
return &HourlyEmployee{}
case SALARIED:
return &SalariedEmployee{}
}
return nil
}感觉比起代码,更接近架构方面的内容。
3.5 使用描述性的名称
和第二章差不多的内容,只是现在是给函数命名。
3.6 函数参数
作者认为函数参数越少越好,尽量避免三参数及以上。虽然不能说完全反对,但只能说好老派的思想。感觉多参数还挺常见的,按他说的只能全封装起来传一个结构体,感觉更难阅读,还要跳转到定义去查看参数列表。
此外,对我用的go语言来说,多参数可以换行,go fmt格式化后查看也很明了。所以这个观点看看就行。
3.7 无副作用
这个建议很有用又很没用。一方面大家都不想写可能有问题的代码,但这个只能自己踩坑吃一堑长一智。哪些地方怎么写有问题怎么写更合适之类。
3.8 分隔指令与询问
明白作者的观点,但举的例子在我看来并不成立。if(set("username","unclebob")),在我看来就是更新属性值并检查是否成功,并不存在歧义。或许是惯用语言不同导致的视角不同。
另外,指令与询问并行也是很常见的操作,不能一棒子打死。比如数据库操作创建一个记录,执行创建指令的同时询问了新记录id。或者一个带缓存的查询,执行查询时顺便进行了缓存读写的操作。这都是很正常的行为。
3.9 使用异常替代返回错误码
看不懂,我写Golang的。过。
3.10 别重复自己
大意就是通过提取共通逻辑减少重复代码。
3.11 结构化编程
总算有了个完全认同作者的观点。return、break、continue之类的该用就用,goto非必要别用。
3.12 如何写出这样的函数
省流:熟能生巧。(废话)
第3章总结
很多观点并不完全认同,比起读书笔记更像是在辩经,全是个人主观暴论(doge。
第4章 注释
作者开篇就极力贬低注释,让我意识到这将又是一场腥风血雨的战斗。
4.1 注释不能美化糟糕的代码
但至少注释能让你知道这段糟糕代码的意图,不用亲自品鉴一番。
4.2 用代码来阐述
如果我是英语母语者或许你是对的,但我不是。即使是相同文本量我也会选择中文,更别说几行注释vs几十上百行代码的差异了。
4.3 好注释
没啥问题,你是对的。
4.4 坏注释
作者的观点大致可以分为三类:
- 多余废话/无用信息:即使是无用文本简单扫视一眼并忽略也不算费劲。
- 错误注释:发现注释和预期不符自然会去读代码,这是debug的基本功。注释又不是金科玉律。
- 代码自解释大于注释:同4.2。
第4章总结
感觉我仿佛是一个现代文人在读程朱理学,被规训着去写工整规范的八股文。问题是我为什么要跪着让你耳提面命,果断开喷战斗爽😡
第5章 格式
go fmt秒了。
第6章 对象和数据结构
6.1 数据抽象
// 不好的写法
type Point struct {
X float64 // 直接暴露
Y float64
}
// 较好的写法(接口隐藏实现)
type Point interface {
GetX() float64
GetY() float64
GetR() float64
GetTheta() float64
}
// 内部实现可以是直角坐标
type cartesianPoint struct {
x, y float64
}
func (p *cartesianPoint) GetX() float64 { return p.x }
func (p *cartesianPoint) GetY() float64 { return p.y }
func (p *cartesianPoint) GetR() float64 { return math.Hypot(p.x, p.y) }
func (p *cartesianPoint) GetTheta() float64 { return math.Atan2(p.y, p.x) }
// 也可以换成极坐标实现,调用方完全无感知
type polarPoint struct {
r, theta float64
}
func (p *polarPoint) GetX() float64 { return p.r * math.Cos(p.theta) }
func (p *polarPoint) GetY() float64 { return p.r * math.Sin(p.theta) }
func (p *polarPoint) GetR() float64 { return p.r }
func (p *polarPoint) GetTheta() float64 { return p.theta }大意就是建议隐藏具体实现只暴露抽象接口。如上例子或许算个不错的例子,前提是业务真的需要两种坐标系( 提前抽象用于解耦不是不行,但没必要所有地方都这样过度设计,需要扩展的核心业务逻辑解耦一下就行。
至于另一个示例:
// 不好的写法
type Vehicle interface {
func getFuelTankCapacityInGallons() double;
func getGallonsOfGasoline() double;
}
// 较好的写法(接口隐藏数据形态)
type Vehicle interface {
func getPercentFuelRemaining() double;
}这里作者的观点认为后者优于前者,感觉就有点偷换概念了。在业务只需求一个统计剩余燃油百分比时,确实后者更加。但实际情况是大概率不止这一个需求,所以前者直接获取值更通用也更应该被实现。如果“统计剩余燃油百分比”这个用法出现了多次,再抽象为接口比较合适。也就是说,不用信那套虚头巴脑的“隐藏实现”,一切以实用为主。只用一次,怎么暴露都行。重复了,就抽象到一起。
6.2 数据、对象的反对称性
首先用go转译一下示例代码:
// 数据:纯结构体
type Square struct {
Side float64
}
type Rectangle struct {
Height float64
Width float64
}
type Circle struct {
Radius float64
}
// 函数:所有操作在外部
func Area(shape any) float64 {
switch s := shape.(type) {
case Square:
return s.Side * s.Side
case Rectangle:
return s.Height * s.Width
case Circle:
return math.Pi * s.Radius * s.Radius
}
return 0
}
// 加新函数(如 Perimeter)→ 容易,加一个函数就行
// 加新类型(如 Triangle)→ 痛苦,要改 Area、Perimeter 等所有函数// 接口:定义行为
type Shape interface {
Area() float64
}
// 每个类型自己实现
type Square struct {
Side float64
}
func (s Square) Area() float64 {
return s.Side * s.Side
}
type Rectangle struct {
Height float64
Width float64
}
func (r Rectangle) Area() float64 {
return r.Height * r.Width
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
// 加新类型(如 Triangle)→ 容易,实现 Area() 即可
// 加新函数(如 Perimeter)→ 痛苦,要给所有已有类型加 Perimeter() 方法如上分别是过程式和面向对象式的实现方法,可以看到前者添加新函数简单,添加新类型麻烦。而后者正相反,添加新类型简单,添加新函数麻烦。这就是所谓的反对称
| 风格 | 擅长 | 不擅长 |
|---|---|---|
| 过程式 | 加新操作 | 加新类型 |
| 面向对象 | 加新类型 | 加新操作 |
在实际开发中,应该自行评估项目后续的增长方向(新增类型 or 新增操作),来决定使用合适的实现方式。
6.3 得墨忒耳律
大意就是不要 a.b.c.doSomething()这样跨层级调用直接调用子层级的内容,例如:
type Address struct {
City string
}
type Department struct {
Address Address
}
type Employee struct {
Department Department
}
// 调用方
func GetCity(e Employee) string {
return e.Department.Address.City // 翻了三层,违规
}
// ---
func (e Employee) GetCity() string {
return e.department.GetCity() // Employee 自己负责
}
// 调用方只跟 Employee 打交道
func GetCity(e Employee) string {
return e.GetCity() // 只点了一次,合规
}这个观点有点道理,但如果严格遵守会导致大量很简单的转发方法(wrapper methods),导致代码变得极其臃肿。所以还是要看情况。
此外,a.doB().doC().doD() 这种链式调用不算违规,因为每步返回了新对象。
6.4 数据传送对象
在Go中也差不多,DTO完全必要封装起来,直接访问字段就行。
虽然有时dto也会直接参与业务逻辑,但那都是很简单的情况,复杂时会定义专门的bo用于业务逻辑。
第6章总结
简单来说,这一章主要就是说要看情况选择用高度封装对象还是简单的数据结构。作者的观点很明显偏向于面向对象优于过程式。而对我来说,一切以实用为主。
第7章 错误处理
跳过。内容主要是 Java 的异常机制,对 Go 参考价值有限。
唯一记住的核心观点:在边界处理错误,不要让错误处理污染业务逻辑层。
// 不推荐:业务逻辑里充斥着错误处理的细节
func ProcessUser(id int) error {
data, err := readFile("/some/path")
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("config file missing: %w", err)
}
return fmt.Errorf("read failed: %w", err)
}
// 业务逻辑...
}
// 推荐:边界处理错误,业务逻辑保持清晰
func ProcessUser(id int) error {
config, err := loadConfig() // 错误已经在 loadConfig 里处理/包装好了
if err != nil {
return err
}
// 业务逻辑直接用 config...
}第8章 边界
8.1 使用第三方代码
主要思想总结就是:不要把第三方类的实例到处传递。在你自己的类/函数里封装它,只暴露你需要的那一小部分。
// 不好的做法:直接把 map 暴露给整个系统
type Sensors struct {
Data map[string]Sensor // 字段公开,到处都能直接修改
}
// 到处都在直接操作 map
sensors.Data["temp"] = NewSensor("temp")
sensor := sensors.Data["temp"]
delete(sensors.Data, "temp")
// 好的做法:封装,只暴露需要的方法
type Sensors struct {
data map[string]Sensor // 私有字段,外部不能直接访问
}
func NewSensors() *Sensors {
return &Sensors{
data: make(map[string]Sensor),
}
}
func (s *Sensors) Add(sensor Sensor) {
// 可以加验证、日志等
s.data[sensor.ID()] = sensor
}
func (s *Sensors) Get(id string) (Sensor, bool) {
val, ok := s.data[id]
return val, ok
}
func (s *Sensors) Remove(id string) {
delete(s.data, id)
}
// 调用方只能使用你暴露的方法,不会直接触碰底层 map总的来说还是java那套oop思想。有需要可以用,不用认死理。
8.2 浏览和学习边界
介绍学习性测试(learning tests)的概念,即“不要再生产代码中试验新东西,而是编写测试来遍览和理解第三方代码”。
8.3 学习log4j
一个学习性测试的示例。
8.4 学习性测试的好处不只是免费
介绍学习性测试的另一个好处:在将来第三方库升级时,帮你验证新版本是否兼容。
这三节主要介绍学习性测试,通过编写测试文件来学习第三方库,也是一个可行的方法。虽然pkg.go.dev上文档很全,问AI也能很方便,不一定要用写测试文件来学习,但这个测试新版本兼容性的功能还挺有用的。
8.5 使用尚不存在的代码
在需要的模块还不存在时,可以先定义自己需要的接口,然后用这个接口写你的代码。等拿到真实代码后再写一个适配器讲其修改为自己的定义。作者将其称为接缝(seam)。
这是一种依赖倒置(Dependency Inversion)的处理,让高层模块于底层模块解耦,可以并行推进。
8.6 整洁的边界
大意就将与第三方包的对接全部放入“边界”中,在边界中编写适配器将第三方api转换为自己定义的需要的接口。一方面清晰地分割了业务代码与第三方包,另一方面也便于测试、管理和迁移。
第8章总结
介绍了如何使用第三方包,例如通过学习性测试去学习和验证等。此外还引入了一个“边界”的概念,集中处理与第三方的对接,同时自定义接口再后续适配实现解耦等。之前见过类似的处理但也没深入去想。总之还算是比较有用的一章。
第9章 单元测试
9.1 TDD三定律
- 定律一 在编写不能通过的单月测试前,不可编写生产代码
- 定律二 只可编写刚好无法通过的单元测试,不能编译也算不通过。
- 定律三 只可编写刚好足以通过当前失败测试的生产代码。
之前没听说过TDD,但一看就好繁琐让人望而生畏。给每处逻辑都配上单元测试?
我只在一些逻辑较复杂或者对接第三方api/sdk时有单测。
9.2 保持测试整洁
即哪怕是测试代码也要保证质量,脏测试等于没测试。
9.3 整洁的测试
强调测试代码的可读性。
9.4 每个测试一个断言
即**每个测试函数应该只测试一个概念,只做一个断言。**作者对此表示支持。我虽然能理解其追求最小细度的想法,但对此并不认同。
另一个接受度更高的观点是每个测试一个概念,可以有多个相关断言。这种做法更接近工程实践,对开发者也更友好。
9.5 F.I.R.S.T
介绍优秀测试的标准:
| 字母 | 单词 | 含义 |
|---|---|---|
| F | Fast | 测试要快 |
| I | Independent | 测试应该相互独立 |
| R | Repeatable | 测试应该可重复 |
| S | Self-Validating | 测试应该自验证(结果是布尔值) |
| T | Timely | 测试要及时编写 |
并不难理解,讲的也很有道理。虽然我不一定能完全遵守,但朝着这个方向努力是没问题的。
第9章总结
这一章还行。虽然有些观点过于极端难免有吹毛求疵之嫌(TDD,一测试一断言),实践中不会完全遵守,但保持测试代码质量和可读性,以及FIRST等标准总结的很准确。
第10章 类
10.1 类的组织
和go没啥关系。
10.2 类应该短小
指的不是代码长度而是权责范围。作者认为一个类应该只有一个职责,即单一职责原则(Single Responsibility Principle, SRP)。
如下是一个go示例:
// 不好的例子:一个类/结构体承担了多个职责
type SuperDashboard struct {
widgets []Widget
userPreferences map[string]string
layoutCache Layout
version string
}
func (d *SuperDashboard) GetLastFocusedWidget() *Widget { ... }
func (d *SuperDashboard) GetComponentVersion() string { ... }
func (d *SuperDashboard) SaveUserPreferences() error { ... }
func (d *SuperDashboard) CalculateLayout() Layout { ... }
func (d *SuperDashboard) SendErrorReport(err error) { ... }// 好的做法:拆分成多个单一职责的类/结构体
type Dashboard struct {
widgets []Widget
}
func (d *Dashboard) GetLastFocusedWidget() *Widget { ... }
type VersionInfo struct {
version string
}
func (v *VersionInfo) GetComponentVersion() string { ... }
type UserPreferences struct {
data map[string]string
}
func (up *UserPreferences) Save() error { ... }
type LayoutEngine struct {
cache Layout
}
func (le *LayoutEngine) Calculate() Layout { ... }
type ErrorReporter struct{}
func (er *ErrorReporter) Send(err error) { ... }虽然工程实践中不一定完全遵守,但也是代码过于复杂时的一种优化思路。感觉实践中的常见场景是另一职责但只有一个方法,就顺手加了进来,但后续迭代变得越来越多,就应该重构代码按职责拆分。或者确实是一个职责,但层层加码方法加的太多,就可以尝试划分出一个子职责进行拆分。
10.3 为了修改而组织
和上一节思路一脉相承。通过SRP划分出粒度更细的代码结构,让修改时影响范围更小,从而使系统更稳定且便于测试。即开放封闭原则(Open-Closed Principle, OCP):一个类应该对扩展开放(可以加新功能),对修改封闭(不需要改原有代码)。
一个go示例如下:
// 定义接口
type SqlGenerator interface {
Generate() string
}
// Oracle 实现
type OracleSqlGenerator struct{}
func (g OracleSqlGenerator) Generate() string {
return "SELECT * FROM table" // Oracle 特定语法
}
// SQL Server 实现
type SqlServerSqlGenerator struct{}
func (g SqlServerSqlGenerator) Generate() string {
return "SELECT * FROM table" // SQL Server 特定语法
}
// 新增 PostgreSQL 支持:完全不改已有代码
type PostgreSqlGenerator struct{}
func (g PostgreSqlGenerator) Generate() string {
return "SELECT * FROM table" // PostgreSQL 特定语法
}
// 依赖通过参数传递
func ExecuteQuery(generator SqlGenerator) error {
sql := generator.Generate()
// 执行 sql...
}如上,我们通过接口+多态实现对不同数据库的对接,让添加新数据库/修改一个数据库时不会影响其他数据库的代码。这同时也遵循了依赖倒置原则(Dependency Inversion Priciple DIP):类应该依赖于抽象而不是依赖于具体细节。
第10章总结
这一章主要是要求我们将类的职责范围功能粒度划分的更细,避免牵一发动全身。这在代码较简单时或许是一种过度设计,但当代码规模上去后是一种很有效的重构思路。
第11章 系统
11.1 如何建造一个城市
导入本章概念:模块划分和系统架构。
11.2 将系统的构造于使用分开
简单来说就是不要让“如何创建对象”这件事污染“如何使用对象”这件事。构造和使用应该分开。业务代码只关心“谁给了我一个能用的对象”,不关心“这个对象是怎么造出来的”。
如下是一个go示例:
// 不好的做法:业务逻辑里自己负责对象的创建
func ProcessOrder(order Order) error {
// 业务逻辑还没开始,先要自己处理连接
db, err := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/db")
if err != nil {
return err
}
defer db.Close()
// 然后才是真正的业务逻辑
result, err := db.Exec("INSERT INTO orders ...", order.ID, order.Amount)
// ...
}// 好的做法:对象已经建好了传进来,业务逻辑只负责用它
type OrderProcessor struct {
db *sql.DB // 依赖由外部注入,不自己创建
}
func NewOrderProcessor(db *sql.DB) *OrderProcessor {
return &OrderProcessor{db: db}
}
func (p *OrderProcessor) ProcessOrder(order Order) error {
// 业务逻辑只关心“使用”db,不关心“创建”db
result, err := p.db.Exec("INSERT INTO orders ...", order.ID, order.Amount)
// ...
}
// 在 main.go 或启动文件中“构造”
func main() {
db, err := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/db")
if err != nil {
panic(err)
}
defer db.Close()
processor := NewOrderProcessor(db) // 注入
processor.ProcessOrder(order)
}如上就是通过依赖注入的方式解决了这个问题。
11.3 扩容
系统设计并非一蹴而就,而是随着项目迭代不断更新扩容的。一种可行的方式是AOP(Aspect-Oriented Programming,面向切面编程)。
| 概念 | 说明 |
|---|---|
| 切面(Aspect) | 横跨多个模块的关注点(如日志、权限、事务、缓存) |
| AOP | 把切面从业务逻辑中抽出来,单独维护,运行时动态织入 |
传统做法 vs AOP:
| 传统做法 | AOP |
|---|---|
| 把日志/权限代码写到每个方法里 | 切面单独写,业务代码干干净净 |
| 增加新切面要改所有相关类 | 增加新切面不需要改业务代码 |
| 业务逻辑和基础设施混在一起 | 业务逻辑只关心业务 |
go中对于AOP思想最常见的实现就是web服务中的http中间件:
// 日志中间件
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("request:", r.URL)
next.ServeHTTP(w, r)
})
}
// 业务 handler(不关心日志)
func UserHandler(w http.ResponseWriter, r *http.Request) {
// 业务逻辑
}
// 组装:用中间件包装业务 handler
server := http.Server{
Handler: LoggingMiddleware(http.HandlerFunc(UserHandler)),
}11.4 Java代理
java内容,过。
11.5 纯Java AOP框架
java内容,过。
11.6 AspectJ的方面
java内容,过。
11.7 测试驱动系统架构
系统的架构应该像代码一样,通过测试驱动、逐步演化,而不是一开始就做大而全的“终极架构”。
感觉和11.3的主题差不多。只不过11.3主要讲AOP这种扩展方法,而这一节只是一些空话。
11.8 优化决策
简单来说就是延迟决策时间,以便通过足够的信息和经验做出更好的决策。
| 过早决策 | 延迟决策 |
|---|---|
| 第一天就决定用 Redis 做缓存 | 先用最简单的内存 map 实现,等真正需要分布式缓存时再切换 |
| 第一天就决定微服务架构 | 先做单体应用,等边界清晰后再拆分 |
| 第一天就引入消息队列 | 先做同步调用,等真的需要异步时再加 |
11.9 明智使用添加了可论证价值的标准
标准/规范是有价值的,但不要为了“符合标准”而引入不必要的复杂性。
放到现在,最鲜明的例子就是微服务架构。业务量很小单体完全够用但为了迎合潮流拆成微服务导致后续维护成本暴增的例子比比皆是。
11.10 系统需要领域特定语言
DSL 可以让代码更贴近业务、更易读,但它不是银弹。用得好提升效率,用不好增加复杂度。
介绍的很简略,但大概意思很清楚,就是不要过度设计,无论DSL还是其他高级语法特性,复杂设计模式等。
第11章总结
将软件系统设计与建筑设计对比,指出软件设计不像建筑设计那样一但建成就难以修改,所以不用一开始就求全责备,而是边更新迭代边扩展完善。于此同时,设计简单不代表代码简单,也要遵循一定的设计模式(DI,AOP等)让代码整洁有序,确保后续可扩展性。
第12章 迭进
12.1 通过迭进设计达到整洁目的
介绍Kent Benk的简单设计四条规则:
- 运行所有测试
- 不可重复
- 表达了程序员的意图
- 尽可能减少类和方法的数量
12.2 简单设计规则1:运行所有测试
依旧强调测试的重要性。强调测试确保系统完全验证。
同时通过测试导向SRP之类的设计方案,导向小而专的设计思路,导向依赖注入,接口和抽象等实现方式。实现高内聚低耦合。
简单来说就是又念了一遍经。
12.3 简单设计规则2~4:重构
测试可以帮助验证重构后代码的可行性,消除了清理代码就会破坏代码的恐惧。
然后再念了一遍经,把之间提过的所有整洁代码规则都提一下说可用于重构。同时符合简单设计后三条规则。
12.4 不可重复
消除重复是简单设计的核心驱动力。重复的代码应该被提取成共同的方法或类。
同时介绍了模范方法模式用于消除重复。go中没有继承,但可以通过接口+组合的方式实现类似效果:
// 定义算法骨架(接受接口)
type VacationPolicy struct {
adjuster Adjuster
applier Applier
}
func (p *VacationPolicy) AccrueVacation() {
calculateBaseVacation()
p.adjuster.Adjust()
p.applier.Apply()
}
// 美国实现
type USAdjuster struct{}
func (USAdjuster) Adjust() { /* 美国算法 */ }
// 欧盟实现
type EUAdjuster struct{}
func (EUAdjuster) Adjust() { /* 欧盟算法 */ }如上就把重复的部分集合到了AccrueVacation中,而差异的部分通过接口各自实现。
由于go的语法比较简单,感觉很多复杂的设计模式放到go里面都差不多了。
12.5 表达力
依旧念经,命名/短小/术语/测试…
12.6 尽可能少的类和方法
之前一边强调SRP,要把粒度拆分的足够细;一边又说不要过度设计,要让系统架构随项目演进不断迭代。其实两者并不冲突。这一章就说了应该抵制教条主义,一切以实用为主。目标是在保持函数和类短小的同时,保持整个系统短小精悍。
不过,作者的观点是该条在简单设计四规则中优先级最低,应该优先完善测试、消除重复和强化表达力。我倒认为这条会重要些,先保持少的类和方法降低系统复杂度,哪怕在一定程度上违反SRP。等到系统增长到一定程度在进行拆分重构,此时再注意用测试进行验证,顺便消除重复逻辑,并检查表达力薄弱的地方加以修改。
第12章总结
主要介绍简单设计四条规则。至于实现规则的手段基本就是前文提到的那些方法,翻来覆去地再提一遍而已。
第13章 并发编程
以java内容为主。就算要学通用的并发思想,也没必要看这本书,不如找本专门讲Go并发的。直接跳过。
第14章 逐步改进
一个java案例研究实践,如何将一个“能跑就行”的混乱代码逐步重构,得到一个整洁的版本。
但是java代码太多读着太累,懒得读。过。
启示总结:
- 整洁代码是通过不断重构“长出来”的
- 测试是重构的安全网
- 不需要一次写完美,但要持续改进
第15章 JUnit内幕
看不懂,我写Go的。过。
第16章 重构SerialDate
看不懂,我写Go的。过。
第17章 味道与启发
总结章节,全书精华所在。
17.1 注释
| 坏味道 | 说明 |
|---|---|
| 不恰当的注释 | 把该用代码表达的东西写成了注释 |
| 过时的注释 | 注释和代码不一致 |
| 冗余的注释 | 代码已经自解释,注释是多余的 |
| 糟糕的注释 | 注释写得不够清晰、准确 |
| 注释掉的代码 | 留着不删的注释代码,应该直接删除 |
17.2 环境
| 坏味道 | 说明 |
|---|---|
| 需要多步才能构建 | 构建系统应该一步到位 |
| 需要多步才能测试 | 跑测试应该是一条命令的事 |
17.3 函数
| 坏味道 | 说明 |
|---|---|
| 太多参数 | 参数多(尤其超过3个)难以理解和使用 |
| 输出参数 | 通过参数修改对象是反直觉的 |
| 标识参数 | 传布尔值区分不同行为,说明函数应该拆分 |
| 死函数 | 从不被调用的函数,应该删掉 |
17.4 一般性问题
数量很多,此处列举部分内容
| 坏味道 | 说明 |
|---|---|
| 一个源文件中有多种语言 | 如一个文件里混着 Java、HTML、JS |
| 明显的行为未被实现 | 函数的名字暗示的功能没实现 |
| 不正确的边界行为 | 没处理好边界条件(空值、零、负数等) |
| 过度耦合 | 一个模块依赖太多其他模块 |
| 特性依恋 | 函数更喜欢别类的数据而不是自己的 |
| 选择算子参数 | 传布尔值做选择,说明应该拆成多个函数 |
| 晦涩的意图 | 代码写得太绕,看不出意图 |
| 错位的职责 | 代码放在错误的地方 |
| 不恰当的静态方法 | 不该静态的静态了 |
| 解释性变量 | 需要临时变量来解释表达式,说明表达式太复杂 |
| 函数名没说清楚做什么 | 名不副实 |
| 理解算法 | 代码太复杂,看不出算法 |
| 逻辑依赖 | 依赖“巧合”而不是明确约定 |
| 死代码 | 永远不会执行的代码 |
| 使用硬编码常量 | 魔法数字,应该用命名常量 |
| 不检查 null | 不处理空值,等着 NPE |
| 缓存不一致 | 缓存和真实数据不一致 |
| 随意修改 | 改代码时随意,破坏格式和规范 |
17.5 Java
过。
17.6 名称
| 坏味道 | 说明 |
|---|---|
| 不准确的命名 | 名字和实际功能对不上 |
| 驼峰命名不规范 | 风格不一致 |
枚举名称含 Enum | 冗余后缀 |
| 太通用的名称 | 如 data、info、manager |
17.7 测试
| 坏味道 | 说明 |
|---|---|
| 测试不足 | 没覆盖足够的情况 |
| 使用测试覆盖率工具 | 100% 覆盖率不代表好测试 |
| 忽略小测试 | 小测试也很重要 |
| 边界条件未测 | 只测正常路径 |
| 测试写得不认真 | 随便写的测试不如不写 |
| 测试过少 | 每个测试只测一点点,导致测试数量爆炸 |
| 忽略测试失败 | 有失败的测试不修,继续写代码 |
第17章总结
虽然是个不错的自查清单,但注意不要教条主义。这些也并非金科玉律,一切以自己的习惯和实际开发经验为主。
全书总结
作为10年的书,很多内容都显得过时且陈腐。此外,虽然《clean code》的书名看起来语言无关,实际上有很多java独有的语言特性,java专属的实践案例。示例是用java写,很多观点也是用java那套oop思想去规训读者,所以对于其他语言使用者很不友好。不过有用的部分还是有的,部分章节阐释了一些或许见过但不明所以的设计思想,阅读并理解思路后恍然大悟。总之有价值但不高,需要有自己的判断力沙里淘金,但有这功夫不如找本更好的书来读。