Clean Code 读书笔记

命名

方法名

方法名应当是动词或动词短语,如save, deletePage。
属性访问器、修改器、断言根据其值依JavaBean标准命名为get, set, is。

    string name = employee.getName();
    customer.setName("Tom");
    if (paycheck.isPosted())...

重载构造函数,使用描述参数的静态工厂方法更优。

    Complex fulcrumPoint = Complex.FromRealNumber(23.0);

通常好于

    Complex fulcrumPoint = new Complex(23.0);

每个概念确定一个词

避免DeviceManager, ProtocolController类似词语混用,应统一为xxManager, xxController或xxDriver

使用尽量精确的名称

MAC地址、端口地址、Web地址相区别,使用MAC, PostalAddress, URI这样的精确的名字

函数

短小

每个函数不应过长,数行为佳,例如:

    public static String renderPageWithSetupAndTeardowns(
            PageData pageData, boolean isSuite) throws Exception {
        if (isTestPage(pageData)){
            includeSetipAndTeardownPages(pageData, isSuite);
        }
        return pageData.getHtml();
    }

if, else, while语句中的代码块尽量只有一行,调用一个拥有较具说明性的函数名称的函数,增加文档上的价值,易于阅读与理解。

只做一件事

函数应该只做一件事,只做同一个抽象层级上的步骤。要判断函数是否只做了一件事,看它是否还能在拆分出函数。
一个函数中的语句应在一个抽象层级上,基础与细节不能混杂在一起。
要让代码具有自顶向下的阅读顺序,每个函数后跟着下一抽象层级的函数。逻辑上,每个函数形如要……就要……如果……就……。例如:

  • 要容纳设置与分拆步骤,就先容纳设置步骤,然后纳入测试内容,再纳入分拆步骤
  • 要容纳设置步骤,如果是套件,就纳入套件设置步骤,然后再纳入普通设置步骤
  • 要容纳套件设置步骤,先搜索"SuiteSetUp"页面的继承关系,再添加包含页面路径的语句
  • 要搜索"SuiteSetUp"页面,就要……

函数名称

函数命名要保持一致,一个模块内的名称采用一脉相承的描述性短语

函数参数

越少越好。如果函数看起来需要很多(3个或3个以上)参数,可能某些参数需要封装成类了。如:

    Circle makeCircle(double x, double y, double radius);
    Circle makeCircle(Point center, double radius);

函数的输出最好通过返回值体现,而不是在参数中输出。习惯上,信息通过函数输入参数,通过返回值从函数中输出。
不要向函数传入boolean值标识参数,这等于大声宣布本函数不仅做一件事。
对于一元函数,函数名与参数形成良好的动宾形式,如:write(name), writeField(name)
也可以在函数名称中体现关键字。assertExpectedEqualsActual(expected, actual)优于assertEqual(expected, actual)

无副作用

反例:

    public class UserValidator {
        private Cryptographer cryptographer;

        public boolean checkPassword(String userName, String password) {
            User user = userGateway.findByName(useeName);
            if (user != user.NULL) {
                String codePhrase = user.getPhraseEncodedByPassword();
                String phrase = cryptographer.decrypt(codedPhrase, password);
                if ("Valid password".equals(phrase)) {
                    Session.initialize();
                    return true;
                }
            }
            return false;
        }
    }

副作用在于调用了Session.initialize()。函数名称并未体现初始化会话的功能,可能导致调用者顾名思义而误操作。这违反了函数“只做一件事”的规则。

将指令与查询分开

避免设计使用if (set("username", "unclebob"))...这种将判断与指令杂糅的函数,应将它们分开:

    if (attributeExists("username")) {
        setAttribute("username", "unclebob");
    }

这样的代码可读性更高。

使用异常

与TIJ中所讲的类似,使用异常可以将函数中的错误处理单独拎出来,减小使用if语句进行错误判断带来的层层嵌套。
在CleanCode中,作者鼓励将try/catch代码块抽离出去另外形成函数使得代码结构规整美观。

    public void delete(Page page) {
        try {
            deletePageAndAllReferences(page);
        } catch (Exception e) {
            logError(e);
        }
    }

    private void deletePageAndAllReferences(Page page) throws Exception {
        deletePage(page);
        registry.deleteReference(page.name);
        configKeys.deleteKey(page.name.makeKey());
    }

    private void logError(Exception e) {
        logger.log(e.getMessage());
    }

错误处理本来就是一件事,函数应该专注于一件事,所以delete函数只与错误处理相关。
deletePageAndAllReferences函数只与删除页面有关。
logError只与记录异常有关。

如何写出这样的函数

写代码如同写文章。先写出粗陋的底稿,在此之上不断打磨成型。将写代码当作讲故事。

注释

用代码来阐述

尽量用代码来解释意图,比如说:

// Check to see if the employee is eligible for full benefits
if ((employee.flags & HOURLY_FLAG) && employee.age > 65) { }

远不如

if(employee.isEligibleForFullBenefits()) { }

好注释

  • 提供信息的注释:
// format matched kk:mm:ss EEE, MMM dd, yyyy
Pattern timeMatcher = Pattern.compile(
    "\\d*:\\d*:\\d* \\w*, \\w* \\d*, \\d*"
);
  • 解释代码目的
  • 解释某些参数或返回值:
assertTrue(a.compareTo(b) == -1); // a < b
  • 警示会产生某种后果的代码段
  • TODO
  • Javadoc(不可滥用)

格式

使用空行将概念隔开,联系紧密的代码行相靠近;行内应有缩进、空格:

public class ReporterConfig {
    private String className;
    private List<Property> properties = new ArrayList<Property>();

    public void addProperty(Property property) {
        properties.add(property);
    }
}

将实体变量放在类的顶部,按照自上而下的顺序展示函数调用以来顺序,从而建立自顶向下贯穿源代码模块的信息流。让最重要的概念以包括最少细节的方式展现,让底层的细节最后出来。

一个团队内部应当采用相同的代码规范。

对象和数据结构

数据抽象

变量设置为private表明我们不希望其他人依赖这些变量,但是为什么许多程序员要为其自动添加getter/setter,使其如同public一般?

// 具象的点
public class Point {
    double x;
    double y;
}
// 抽象的点
public interface Point {
    double getX();
    double getY();
    void setCartesian(double x, double y);

    double getR();
    double getTheta();
    void setPolar(double r, double theta);
}

抽象的点不但呈现了其数据结构,还表明了一种使用策略:可以单个读取坐标,但必须原子性地修改所有坐标。不可乱加getter/setter。

抽象数据意味着隐藏数据细节,而不是简单地在形式上使用了接口、抽象类。

// 具象机动车
public interface Vehicle {
    double getFuelTankCapacityInGallons();
    double getGallonsOfGasoline();
}
// 抽象机动车
public interface Vehicle {
    double getPercentFuelRemaining();
}

显然,并不是使用了接口就是抽象。前者暴露了燃油存放在油箱中,油箱的单位是加仑。但我们无从得知后者的数据结构。

数据与对象、过程式与面向对象编程

// 过程式形状
public class Square {
    public Point topLeft;
    public double side;
}

public class Circle {
    public Point center;
    public double radius;
}

public class Geometry {
    public final double PI = 3.1416;

    public double area(Object shape) throws NoSuchShapeException {
        if (shape instanceof Square) {
            Square s = (Square)shape;
            return s.side*s.side;
        } else if (shape instanceof Circle) {
            Circle c = (Circle)shape;
            return PI*c.radius*c.radius;
        }
        throw new NoSuchShapeException();
    }
}
// 多态式形状
abstract class Shape {
    double area();
}

public class Square extends Shape {
    private Point topLeft;
    private double side;

    @Override
    public double area() {
        return side*side;
    }
}

public class Circle extends Shape {
    private Point center;
    private double radius;
    private final double PI = 3.1416;

    @Override
    public double area(){
        return PI*radius*radius;
    }
}

如果要添加三角形类,在过程式代码中不单要添加新类,还需要更改Geometry中的函数来处理它,而在面向对象式代码中只需要专心于这个新的类即可。
但是,如果需要添加求周长的方法primeter(),过程式代码中只要专注于添加方法,在面向对象式代码中,由于Shape都能求周长,需要修改所有的类。

也就是说,过程式代码便于在不改动已有数据结构的前提下添加新函数;面向对象式代码便于在不改动已有方法的情况下添加新类。
反之,过程式代码难以修改数据结构;而面向对象式代码难以添加新的方法。

迪米特法则(Law of Demeter)/最少知识原则(Least Knowledge Principle)

模块不应该知晓它所操作对象的内部情况。对象隐藏了数据,暴露了操作,它的存取方法不应暴露它的内部结构。

一个类C的方法f只能调用以下对象的方法:

  • C
  • 由f创建的对象
  • 作为参数传递给f的对象
  • 由C的实体变量所持有的对象

方法不应调用由任何函数返回的对象的方法。即“不与陌生人说话”。

避免火车式代码:

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

最好将其划分为:

Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputdir = scratchDir.getAbsolutePath();

数据传输对象(Data Transfer Objects, DTO)

DTO是指一种只有公共变量,没有函数的类。用于与数据库通信,或解析套接字传递的消息等场景中。它们可以将原始数据转换为数据库数据。

拥有由赋值器、取值器操作的私有变量的"bean"豆式结构举例:

public class Address {
    private String street;
    private String city;
    private String zip;

    public Address(String street, String city, String zip) {
        this.street=street;
        this.city=city;
        this.zip=zip;
    }

    public String getStreet(){
        return street;
    }

    public String getCity(){
        return city;
    }

    public String getZip(){
        return zip;
    }
}

错误处理

封装第三方类

LocalPort port = new LocalPort(12);
try {
    port.open();
} catch (PortDeviceFailure e){
    reportError(e);
    logger.log(e.getMessage(), e);
} finally {
    // ...
    ;
}
public class LocalPort {
    private ACMEPort innerPort;

    public LocalPort(int portNumber) {
        innerPort = new ACMEPort(portNumber);
    }

    public void open() {
        try {
            innerPort.open();
        } catch (DeviceResponseException e) {
            throw new PortDeviceFailure(e);
        } catch (ATM1212UnlockedException e) {
            throw new PortDeviceFailure(e);
        } catch (GMXError e) {
            throw new PortDeviceFailure(e);
        }
    }
    // ...
}

其中,LocalPort是对第三方类ACMEPort进行的封装。它将自己的设备错误处理与第三方API分离开,降低了对第三方API的依赖性,以备不时之需,切换其他代码库。
其他时候,为了隐藏边界,也要进行封装。

不要返回/传递null

不如使用异常或特例对象。
特例对象:在特殊条件下返回的继承自正常对象的对象。

仿照TIJ中给出的例子,我们还可以定义空对象:

public class Employee {
    private String name;
    private String office;

    public static final Employee NULL = new Employee("Null Name", "Null Office");

    Employee(String name, String office) {
        this.name = name;
        this.office = office;
    }
}

测试

测试驱动开发(TDD)

在本书作者Robert C. Martin(Uncle Bob)的博客中,有一个BowlingGame Kata。可以看作学习TDD的一个样板。
http://butunclebob.com/ArticleS.UncleBob.TheBowlingGameKata

kata(かた、形),空手道、柔道用语,一招一式皆称为“形”。也就是招式、套路。
观察保龄球计分器的开发过程,可以看到,在TDD中,用例先行,紧接着编写能使单元测试通过的代码,然后写下一个测试用例,再写项目代码……在编写单元测试、编写项目代码的同时,将其中杂糅的、重复的代码抽出去,进行重构,让测试更加自动化,在不影响输出的情况下改善代码。

IDEA中自带JUnit,在Project Structure中将新建文件夹改为"Test"类型即可在其下创建测试文件。

TDD三定律

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

如果严格地按照TDD进行开发,测试代码量将与工作代码量相当。那么,一旦写出了混乱的测试代码,随着代码版本更新,测试将会变得愈发无序,难以维护。因此,要像对待工作代码一样对待测试代码,保持代码整洁。

构造-操作-检验模式

结合测试三段论Given-When-Then,写出用户故事。

Given:上下文,指定测试预设
When:进行一系列操作
Then:得到一系列可观测结果,即待检测的断言。

  • 第一个环节:构造测试数据 -> Given
  • 第二个环节:操作测试数据 -> When
  • 第三个环节:检验操作是否得到期望的结果 -> Then

在对测试代码进行重构的过程中,逐步构建出简洁的测试API。尽量保证测试代码的整洁,测试环境不需要像生产环境一样考虑内存或CPU效率的问题。

// 重构前
void testGetPageHieratchyAsXml() {
    crawler.addPage(root, PathParser.parse("PageOne"));
    crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));
    crawler.addPage(root, PathParser.parse("PageTwo"));

    request.setResource("root");
    request.addInput("type", "pages");
    Responder responder = new SerializedPageResponder();
    SimpleResponse response = (SimpleResponse)responder.makeResponse(
        new FitNesseContext(root), request
    );
    String xml = response.getContent();

    assertEquals("text/xml", response.getContentType());
    assertSubString("<name>PageOne</name>", xml);
    assertSubString("<name>PageTwo</name>", xml);
    assertSubString("<name>ChildOne</name>", xml);
}
// 重构后(遵循每个测试一个断言原则)
void testGetPageHieratchyAsXml() {
    // 构造
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

    // 操作
    whenRequestIsIssued("root", "type:pages");

    // 检验 
    thenResponseShouldBeXML();
}

void testGetPageHierarchyHasRightTags() {
    // 构造
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

    // 操作
    whenRequestIsIssued("root", "type:pages");

    // 检验 
    thenResponseShouldContain(
        "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
    );
}

F.I.R.S.T

整洁的测试应遵循FIRST原则:

  • 快速(Fast):测试应该能够快速运行,以便频繁地运行。
  • 独立(Independent):测试之间相互独立,每个测试可以单独运行。
  • 可重复(Repeatable):测试可在任何环境重复通过,无论是生产环境、质检环境还是个人计算机。
  • 自足验证(Self-Validating):测试应有布尔输出,不应由开发者通过手工对比日志判断测试是否通过。
  • 及时(Timely):测试应该及时编写,单元测试应该恰好在使其通过的生产代码之前编写。这有助于编写易于测试的生产代码。

单一职责原则(Single Responsibility Principle, SRP)

类或模块应有且只有一条加以修改的理由。类只应有一个职责——只有一条修改的理由。

public class SuperDashBoard extends JFrame implements MetaDataUser {
    public Component getLastFocusedComponent(){}
    public void setLastFocused(Component lastFocused){}
    public int getMaorVersionNumber(){}
    public int getMinorVersionNumber(){}
    public int getBuildNumber(){}
}

这个类既负责了管理Swing组件,又要跟踪版本信息,可以把负责版本信息的方法置于Version类中:

public class Version {
    public int getMaorVersionNumber(){}
    public int getMinorVersionNumber(){}
    public int getBuildNumber(){}
}

内聚

类应该只有少量的实体变量。类中每个方法都应该操作一个或多个实体变量。类中的方法与变量相互依程度越高,这个类的内聚性就越强。

开闭原则(Open Closed Principle, OCP)

类应当对扩展开放,对修改封闭。添加或修改特性时不应触及其他部分。

// 添加update功能必须修改这个类,有可能触及其他代码
public class Sql {
    Sql(String table, Column[] columns){}
    public String create(){}
    public String insert(Object[] fields){}
}
// 通过扩展系统而非修改来增加新的特性
abstract class Sql {
    Sql(String table, Column[] columns){}
    abstract String generate();
}

public class CreateSql extends Sql {
    public CreateSql(String table, Column[] columns) {}
    @Override
    public String generate(){}
}

public class InsertSql extends Sql {
    public InsertSql(String table, Column[] columns) {}
    @Override
    public String generate(){}
}

简单设计

简单设计的原则:

  • 测试
  • 重构
  • 消除重复
  • 代码描述性、表达力强
  • 尽可能少的类与方法

并发

引言:“对象是过程的抽象。线程是调度的抽象。”

为什么要并发

并发是一种解耦策略。它帮助我们把目的时机分离开。在单线程应用中,目的与时机紧密耦合。

从结构上看,将目的与时机解耦,使得应用程序看起来像是多台协同工作的机器,而非一个大循环。

从响应时间与吞吐量上看,以网络信息聚合为例,多线程使得程序可以同时访问多个站点,减少了阻塞在套接字I/O上的时间。

  • 并发会在性能和编写代码上增加一些开销
  • 正确的并发是复杂的
  • 并发缺陷视作偶发事件
  • 并发常常需要对设计策略的根本性修改

并发遇到的问题与挑战与进程同步模型

同操作系统课程内容。略。

并发防御原则

单一职责原则

  • 并发代码有自己的开发、修改、调优生命周期
  • 并发代码有并发代码独有的挑战
  • 较差的并发代码容易出错

建议:分离并发代码与其他代码

限制临界区

synchronized可以给临界区数据加锁。但是,如果需要共享数据的地方过多,可能:

  • 漏掉保护临界区数据
  • 破坏DRY原则以费力保证有效保护
  • 难以判断错误源

建议:谨记数据封装,严格限制共享数据访问

使用数据副本

复制对象分发给各个线程,最终在单线程中合并各个对象,避免共享数据

线程尽可能独立

使线程按本地变量存储从源头接纳的数据,不与其他线程共享。将数据分解为可以被独立的线程操作的独立子集

共享对象需要多个方法的应对策略

基于客户端的锁定:客户端代码在调用第一个方法前锁定服务端,确保锁的范围覆盖了调用最后一个方法的代码。

基于服务端的锁定:在服务端内创建锁定服务端的方法,调用所有方法后解锁,让客户端代码调用新方法。

适配服务端:创建执行锁定的中间层。是不修改服务端代码的基于服务端的锁定。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,755评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,369评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,799评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,910评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,096评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,159评论 3 411
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,917评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,360评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,673评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,814评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,509评论 4 334
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,156评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,882评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,123评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,641评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,728评论 2 351