目录

目录

《代码整洁之道》阅读笔记

目录

《代码整洁之道》阅读笔记

《clean code》和《clean architecture》两本算是耳熟能详,抽空读一下。架构还好说,出版时间更近,最近AI发展迅猛导致社区也有很大声量嚷嚷着架构的重要性。而代码这本可是十多年前的书了,也不知道现在读的话价值几何。

列了一大堆流派和定义讲什么是整洁代码及其重要性,但看一遍就过了没进脑子。感觉不重要。

首先,如果你打过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*/
}

少用比较通用的词汇,例如list,data等,可以和2.2一样加点限定词,或者选择更准确的命名。此外,避免在同一处使用外形相近的名称,容易导致混淆。如书中示例XYZControllerForEfficientHandlingOfStringsXYZControllerForEfficientStorageOfStrings

一种例子是数字后缀,如:

public static void copyChars(char a1[], char a2[]) {
    for (int i = 0; i<a1.length; i++) {
        a2[i] = a1[i];
    }
}

如果将入参改为sourcedestination就会好很多。

另一种例子是废话,例如某张表的表名为xxx_table,某个名称字段的命名为NameString,这种后缀都是无意义的应该精简。另外,在没有明确约定的情况下,XXX,XXXs,XXXInfo,XXXData等也都没有意义需要做出区分。

比较下面两种定义方式:

// not readable
type Book struct {
    GenYMDHMS time.time
    ModYMDHMS time.time
}

// readable
type Book struct {
    GenerationTimestamp time.time
    ModificationTimestamp time.time
}

可以看到后者对阅读和口头交流更友好。当然原文是java例子,我只是写成go形式而已。go中约定俗成的命名为CreatedAtUpdatedAt

这个在2.2提过,对于值属性,定义一个别名会方便很多。而对于频繁使用和搜索和变量,一个更有标识性的名称也比abcde更方便。

不是很懂,从来没用过。匈牙利语标记法这种东西就该被扫进历史的垃圾堆。

没太看懂在说什么,不过意思还是老生常谈的那一套,别用abc这样的命名,读者需要自行映射为具体概念。应使用有具体意义的命名实现自解释。

类名和对象名应该是名词或名词短语,不应当是动词

方法名应当是动词或动词短语。

对于java来说,属性访问器、属性修改器和断言应该依Javabean标准加上get、set和is前缀。go虽然没有类似的标准,但也建议在项目中统一命名方式,不要一个创建对象搞出NewACreateBInitC等好几种命名。

我觉得更合适的译名是“别整活”,不过10年应该没有这个词。言归正传,不要在命名时使用俗语、俚语或梗等。话说真有人会这样做吗?

没太看懂,但大概明白意思。和2.10的举例一样,不要一个创建对象搞出NewACreateBInitC等好几种命名。

没遇到过,也没有示例。不过意思还是很好懂的。

大概就是使用专有名词的意思。

同上

// 语境不明确的变量
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);
}

错误示例:对于一个名为GSD的应用,在其中每个类添加GSD前缀。我在实际开发中就遇到过类似的,把数据库中所有表名字段名都加一个统一前缀无一例外,实在不知道当初这么设计的用意何在。

总之,只要短名称足够清楚,就比长名称好。别给名称添加不必要的语境。

基本都是些老生常谈的内容。

我的观点和2.16一致,不应该只为了追求函数短小添加过多无意义的抽象。我不希望我的ide在尝试补全时弹出一堆只在一个地方有用到的语义特定的函数,而不是我需要的可复用的通用函数。我理解的抽象是为了复用代码,而阅读障碍应该交给注释解决。至于函数过长的问题,你们的IDE没有折叠功能吗?

如题。

大概能理解意思,但实践中并不容易做到。很多时候刚开始设计时只有几句话位于不同层级就没有进一步抽象而是塞在一起,后续更新迭代不断填充就会越来越臃肿。但认死理必须一个函数一个抽象层级也不可取,会导致项目早期过度抽象,很多几乎不会不会变动的地方也过度设计。所以还是需要具体问题具体分析。我的主力语言是Go语言,通常还是先简单实现,后续规模增大再拆分。

原文是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
}

感觉比起代码,更接近架构方面的内容。

和第二章差不多的内容,只是现在是给函数命名。

作者认为函数参数越少越好,尽量避免三参数及以上。虽然不能说完全反对,但只能说好老派的思想。感觉多参数还挺常见的,按他说的只能全封装起来传一个结构体,感觉更难阅读,还要跳转到定义去查看参数列表。

此外,对我用的go语言来说,多参数可以换行,go fmt格式化后查看也很明了。所以这个观点看看就行。

这个建议很有用又很没用。一方面大家都不想写可能有问题的代码,但这个只能自己踩坑吃一堑长一智。哪些地方怎么写有问题怎么写更合适之类。

明白作者的观点,但举的例子在我看来并不成立。if(set("username","unclebob")),在我看来就是更新属性值并检查是否成功,并不存在歧义。或许是惯用语言不同导致的视角不同。

另外,指令与询问并行也是很常见的操作,不能一棒子打死。比如数据库操作创建一个记录,执行创建指令的同时询问了新记录id。或者一个带缓存的查询,执行查询时顺便进行了缓存读写的操作。这都是很正常的行为。

看不懂,我写Golang的。过。

大意就是通过提取共通逻辑减少重复代码。

总算有了个完全认同作者的观点。return、break、continue之类的该用就用,goto非必要别用。

省流:熟能生巧。(废话)

很多观点并不完全认同,比起读书笔记更像是在辩经,全是个人主观暴论(doge。

作者开篇就极力贬低注释,让我意识到这将又是一场腥风血雨的战斗。

但至少注释能让你知道这段糟糕代码的意图,不用亲自品鉴一番。

如果我是英语母语者或许你是对的,但我不是。即使是相同文本量我也会选择中文,更别说几行注释vs几十上百行代码的差异了。

没啥问题,你是对的。

作者的观点大致可以分为三类:

  1. 多余废话/无用信息:即使是无用文本简单扫视一眼并忽略也不算费劲。
  2. 错误注释:发现注释和预期不符自然会去读代码,这是debug的基本功。注释又不是金科玉律。
  3. 代码自解释大于注释:同4.2。

感觉我仿佛是一个现代文人在读程朱理学,被规训着去写工整规范的八股文。问题是我为什么要跪着让你耳提面命,果断开喷战斗爽😡

go fmt秒了。

// 不好的写法
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;
}

这里作者的观点认为后者优于前者,感觉就有点偷换概念了。在业务只需求一个统计剩余燃油百分比时,确实后者更加。但实际情况是大概率不止这一个需求,所以前者直接获取值更通用也更应该被实现。如果“统计剩余燃油百分比”这个用法出现了多次,再抽象为接口比较合适。也就是说,不用信那套虚头巴脑的“隐藏实现”,一切以实用为主。只用一次,怎么暴露都行。重复了,就抽象到一起。

首先用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 新增操作),来决定使用合适的实现方式。

大意就是不要 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() 这种链式调用不算违规,因为每步返回了新对象。

在Go中也差不多,DTO完全必要封装起来,直接访问字段就行。

虽然有时dto也会直接参与业务逻辑,但那都是很简单的情况,复杂时会定义专门的bo用于业务逻辑。

简单来说,这一章主要就是说要看情况选择用高度封装对象还是简单的数据结构。作者的观点很明显偏向于面向对象优于过程式。而对我来说,一切以实用为主。

跳过。内容主要是 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...
}

主要思想总结就是:不要把第三方类的实例到处传递。在你自己的类/函数里封装它,只暴露你需要的那一小部分。

// 不好的做法:直接把 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思想。有需要可以用,不用认死理。

介绍学习性测试(learning tests)的概念,即“不要再生产代码中试验新东西,而是编写测试来遍览和理解第三方代码”。

一个学习性测试的示例。

介绍学习性测试的另一个好处:在将来第三方库升级时,帮你验证新版本是否兼容。

这三节主要介绍学习性测试,通过编写测试文件来学习第三方库,也是一个可行的方法。虽然pkg.go.dev上文档很全,问AI也能很方便,不一定要用写测试文件来学习,但这个测试新版本兼容性的功能还挺有用的。

在需要的模块还不存在时,可以先定义自己需要的接口,然后用这个接口写你的代码。等拿到真实代码后再写一个适配器讲其修改为自己的定义。作者将其称为接缝(seam)。

这是一种依赖倒置(Dependency Inversion)的处理,让高层模块于底层模块解耦,可以并行推进。

大意就将与第三方包的对接全部放入“边界”中,在边界中编写适配器将第三方api转换为自己定义的需要的接口。一方面清晰地分割了业务代码与第三方包,另一方面也便于测试、管理和迁移。

介绍了如何使用第三方包,例如通过学习性测试去学习和验证等。此外还引入了一个“边界”的概念,集中处理与第三方的对接,同时自定义接口再后续适配实现解耦等。之前见过类似的处理但也没深入去想。总之还算是比较有用的一章。

  • 定律一 在编写不能通过的单月测试前,不可编写生产代码
  • 定律二 只可编写刚好无法通过的单元测试,不能编译也算不通过。
  • 定律三 只可编写刚好足以通过当前失败测试的生产代码。

之前没听说过TDD,但一看就好繁琐让人望而生畏。给每处逻辑都配上单元测试?

我只在一些逻辑较复杂或者对接第三方api/sdk时有单测。

即哪怕是测试代码也要保证质量,脏测试等于没测试。

强调测试代码的可读性。

即**每个测试函数应该只测试一个概念,只做一个断言。**作者对此表示支持。我虽然能理解其追求最小细度的想法,但对此并不认同。

另一个接受度更高的观点是每个测试一个概念,可以有多个相关断言。这种做法更接近工程实践,对开发者也更友好。

介绍优秀测试的标准:

字母单词含义
FFast测试要快
IIndependent测试应该相互独立
RRepeatable测试应该可重复
SSelf-Validating测试应该自验证(结果是布尔值)
TTimely测试要及时编写

并不难理解,讲的也很有道理。虽然我不一定能完全遵守,但朝着这个方向努力是没问题的。

这一章还行。虽然有些观点过于极端难免有吹毛求疵之嫌(TDD,一测试一断言),实践中不会完全遵守,但保持测试代码质量和可读性,以及FIRST等标准总结的很准确。

和go没啥关系。

指的不是代码长度而是权责范围。作者认为一个类应该只有一个职责,即单一职责原则(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) { ... }

虽然工程实践中不一定完全遵守,但也是代码过于复杂时的一种优化思路。感觉实践中的常见场景是另一职责但只有一个方法,就顺手加了进来,但后续迭代变得越来越多,就应该重构代码按职责拆分。或者确实是一个职责,但层层加码方法加的太多,就可以尝试划分出一个子职责进行拆分。

和上一节思路一脉相承。通过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):类应该依赖于抽象而不是依赖于具体细节。

这一章主要是要求我们将类的职责范围功能粒度划分的更细,避免牵一发动全身。这在代码较简单时或许是一种过度设计,但当代码规模上去后是一种很有效的重构思路。

导入本章概念:模块划分和系统架构。

简单来说就是不要让“如何创建对象”这件事污染“如何使用对象”这件事。构造和使用应该分开。业务代码只关心“谁给了我一个能用的对象”,不关心“这个对象是怎么造出来的”。

如下是一个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)
}

如上就是通过依赖注入的方式解决了这个问题。

系统设计并非一蹴而就,而是随着项目迭代不断更新扩容的。一种可行的方式是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)),
}

java内容,过。

java内容,过。

java内容,过。

系统的架构应该像代码一样,通过测试驱动、逐步演化,而不是一开始就做大而全的“终极架构”。

感觉和11.3的主题差不多。只不过11.3主要讲AOP这种扩展方法,而这一节只是一些空话。

简单来说就是延迟决策时间,以便通过足够的信息和经验做出更好的决策。

过早决策延迟决策
第一天就决定用 Redis 做缓存先用最简单的内存 map 实现,等真正需要分布式缓存时再切换
第一天就决定微服务架构先做单体应用,等边界清晰后再拆分
第一天就引入消息队列先做同步调用,等真的需要异步时再加

标准/规范是有价值的,但不要为了“符合标准”而引入不必要的复杂性。

放到现在,最鲜明的例子就是微服务架构。业务量很小单体完全够用但为了迎合潮流拆成微服务导致后续维护成本暴增的例子比比皆是。

DSL 可以让代码更贴近业务、更易读,但它不是银弹。用得好提升效率,用不好增加复杂度。

介绍的很简略,但大概意思很清楚,就是不要过度设计,无论DSL还是其他高级语法特性,复杂设计模式等。

将软件系统设计与建筑设计对比,指出软件设计不像建筑设计那样一但建成就难以修改,所以不用一开始就求全责备,而是边更新迭代边扩展完善。于此同时,设计简单不代表代码简单,也要遵循一定的设计模式(DI,AOP等)让代码整洁有序,确保后续可扩展性。

介绍Kent Benk的简单设计四条规则:

  • 运行所有测试
  • 不可重复
  • 表达了程序员的意图
  • 尽可能减少类和方法的数量

依旧强调测试的重要性。强调测试确保系统完全验证。

同时通过测试导向SRP之类的设计方案,导向小而专的设计思路,导向依赖注入,接口和抽象等实现方式。实现高内聚低耦合。

简单来说就是又念了一遍经。

测试可以帮助验证重构后代码的可行性,消除了清理代码就会破坏代码的恐惧。

然后再念了一遍经,把之间提过的所有整洁代码规则都提一下说可用于重构。同时符合简单设计后三条规则。

消除重复是简单设计的核心驱动力。重复的代码应该被提取成共同的方法或类。

同时介绍了模范方法模式用于消除重复。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里面都差不多了。

依旧念经,命名/短小/术语/测试…

之前一边强调SRP,要把粒度拆分的足够细;一边又说不要过度设计,要让系统架构随项目演进不断迭代。其实两者并不冲突。这一章就说了应该抵制教条主义,一切以实用为主。目标是在保持函数和类短小的同时,保持整个系统短小精悍。

不过,作者的观点是该条在简单设计四规则中优先级最低,应该优先完善测试、消除重复和强化表达力。我倒认为这条会重要些,先保持少的类和方法降低系统复杂度,哪怕在一定程度上违反SRP。等到系统增长到一定程度在进行拆分重构,此时再注意用测试进行验证,顺便消除重复逻辑,并检查表达力薄弱的地方加以修改。

主要介绍简单设计四条规则。至于实现规则的手段基本就是前文提到的那些方法,翻来覆去地再提一遍而已。

以java内容为主。就算要学通用的并发思想,也没必要看这本书,不如找本专门讲Go并发的。直接跳过。

一个java案例研究实践,如何将一个“能跑就行”的混乱代码逐步重构,得到一个整洁的版本。

但是java代码太多读着太累,懒得读。过。

启示总结:

  • 整洁代码是通过不断重构“长出来”的
  • 测试是重构的安全网
  • 不需要一次写完美,但要持续改进

看不懂,我写Go的。过。

看不懂,我写Go的。过。

总结章节,全书精华所在。

坏味道说明
不恰当的注释把该用代码表达的东西写成了注释
过时的注释注释和代码不一致
冗余的注释代码已经自解释,注释是多余的
糟糕的注释注释写得不够清晰、准确
注释掉的代码留着不删的注释代码,应该直接删除
坏味道说明
需要多步才能构建构建系统应该一步到位
需要多步才能测试跑测试应该是一条命令的事
坏味道说明
太多参数参数多(尤其超过3个)难以理解和使用
输出参数通过参数修改对象是反直觉的
标识参数传布尔值区分不同行为,说明函数应该拆分
死函数从不被调用的函数,应该删掉

数量很多,此处列举部分内容

坏味道说明
一个源文件中有多种语言如一个文件里混着 Java、HTML、JS
明显的行为未被实现函数的名字暗示的功能没实现
不正确的边界行为没处理好边界条件(空值、零、负数等)
过度耦合一个模块依赖太多其他模块
特性依恋函数更喜欢别类的数据而不是自己的
选择算子参数传布尔值做选择,说明应该拆成多个函数
晦涩的意图代码写得太绕,看不出意图
错位的职责代码放在错误的地方
不恰当的静态方法不该静态的静态了
解释性变量需要临时变量来解释表达式,说明表达式太复杂
函数名没说清楚做什么名不副实
理解算法代码太复杂,看不出算法
逻辑依赖依赖“巧合”而不是明确约定
死代码永远不会执行的代码
使用硬编码常量魔法数字,应该用命名常量
不检查 null不处理空值,等着 NPE
缓存不一致缓存和真实数据不一致
随意修改改代码时随意,破坏格式和规范

过。

坏味道说明
不准确的命名名字和实际功能对不上
驼峰命名不规范风格不一致
枚举名称含 Enum冗余后缀
太通用的名称datainfomanager
坏味道说明
测试不足没覆盖足够的情况
使用测试覆盖率工具100% 覆盖率不代表好测试
忽略小测试小测试也很重要
边界条件未测只测正常路径
测试写得不认真随便写的测试不如不写
测试过少每个测试只测一点点,导致测试数量爆炸
忽略测试失败有失败的测试不修,继续写代码

虽然是个不错的自查清单,但注意不要教条主义。这些也并非金科玉律,一切以自己的习惯和实际开发经验为主。

作为10年的书,很多内容都显得过时且陈腐。此外,虽然《clean code》的书名看起来语言无关,实际上有很多java独有的语言特性,java专属的实践案例。示例是用java写,很多观点也是用java那套oop思想去规训读者,所以对于其他语言使用者很不友好。不过有用的部分还是有的,部分章节阐释了一些或许见过但不明所以的设计思想,阅读并理解思路后恍然大悟。总之有价值但不高,需要有自己的判断力沙里淘金,但有这功夫不如找本更好的书来读。