Clean Code 读书笔记

2019-12-08  本文已影响0人  Reramip

命名

方法名

方法名应当是动词或动词短语,如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语句中的代码块尽量只有一行,调用一个拥有较具说明性的函数名称的函数,增加文档上的价值,易于阅读与理解。

只做一件事

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

函数名称

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

函数参数

越少越好。如果函数看起来需要很多(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

格式

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

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只能调用以下对象的方法:

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

避免火车式代码:

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:得到一系列可观测结果,即待检测的断言。

在对测试代码进行重构的过程中,逐步构建出简洁的测试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原则:

单一职责原则(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可以给临界区数据加锁。但是,如果需要共享数据的地方过多,可能:

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

使用数据副本

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

线程尽可能独立

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

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

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

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

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

上一篇 下一篇

猜你喜欢

热点阅读