代码整洁之道-理论

文章目录

代码整洁之道-理论

前言

学习中、工作中遇到很多乱七八糟的糟糕代码,自己入门时也写过不少糟糕代码。在一个夜深人静的晚上,思考人生,觉得要成为一名更好的程序员,那写代码的基本功就要扎实。

于是结合自己曾今看过的关于代码设计的书,进行了总结,写下这篇博客,作为日后编码的参考文档。本文以《代码整洁之道》、《重构:改善既有代码的设计》、《设计模式之禅》、《Head First设计模式》和《阿里巴巴Java开发手册》为原始资料,总结了其中的核心内容,并且在部分内容中加入了自己的见解。

开篇之前,来几句鸡汤文补补身子。

编程不仅仅是写代码,而是一门语言设计的艺术。

大神级程序员是把系统当做故事来讲,动听优雅,而不是当成程序来写。

温馨提醒:以下关于代码整洁的理论和建议对英语水平有一定要求的,尤其是命名和注释的章节。所以这些理论和建议不能全部套用,要根据团队实际情况来斟酌运用,适合自身团队的才是最好的。

一、优雅代码的层次

优雅代码具有这几个特点:可读性、可复用、健壮性(鲁棒性)、高扩展、高性能。

1、第一层次:命名要好

优雅代码最基本的是要有良好的命名。良好的命名才有可读性。

这个可以参考《代码整洁之道》的第2章:有意义的命名《阿里巴巴Java开发手册》

这个是最基本的能力,团队中的程序员必须要学会的基础能力。

2、第二层次:代码结构要清晰

清晰的代码结构才有可读性、可复用。同时代码要健壮(鲁棒性),如需要判空、校验非法值等。

这个可以参考《代码整洁之道》的第3、4、5、7、8章:函数、注释、格式、错误处理、边界《阿里巴巴Java开发手册》《重构:改善既有代码的设计》

这个也是最基本的能力,团队中的程序员必须要学会的基础能力。

3、第三层次:熟悉6大设计原则

前面两个层次只是关注如何编写好的代码行和代码块,如函数的恰当构造,函数之间如何互相关联等。

第三层次将关注代码组织的更高层面,即类。符合6大设计原则的类,可读性、可复用、鲁棒性、扩展性和性能会更好。

这个可以参考《设计模式之禅》的第1、2、3、4、5、6章《代码整洁之道》的第10章

这个是较高的能力要求,个人觉得,在企业级开发中,1-3年的程序员应该要达到这一层次的能力。

4、第四层次:熟悉23种设计模式

第四层要求程序员能站在整个系统的角度去合理运用这23种设计模式,对代码进行分包、分类、接口和类设计等架构工作,使得代码高扩展。

这个可以参考《设计模式之禅》的第7-38章《Head First设计模式》

这个是更高的能力要求,个人觉得,在企业级开发中,3-5年的高级程序员应该要达到这一层次的能力。

5、第五层次:并发编程

代码要高性能。

这个可参考并发编程的相关书籍,如《代码整洁之道》中的附录A:并发编程(p297)《深入理解Java虚拟机》的第五部分:高效并发(p359)《Java并发编程的艺术》《Java并发编程:核心方法与框架》《Java程序性能优化》

这个能力要求就更高了。本人能力暂时不足,不好定义。先暂时定义成最高层次吧。

二、什么是糟糕的代码

本人将代码分为两类:业务代码、框架代码。对于业务代码,以上的特性(可读性、可复用、健壮性(鲁棒性)、高扩展、高性能)都要兼顾,但有时候可以牺牲一部分性能来提高代码的可读性、健壮性(鲁棒性)和高扩展;对于框架代码,可以牺牲一部分可读性来实现更高的性能。

框架代码一般都是大牛写的,基本上不存在糟糕代码。但是业务代码则存在很多糟糕的代码,所以本文中讲的糟糕代码指业务代码。

不符合上面5个特性的代码基本上都是糟糕的代码。具体表现如下:

(一)命名糟糕

1、采用描述性名称。

2、名称没有与抽象层级相符。

3、没有使用标准命名法。如驼峰。

4、使用歧义的名称。

5、没有为较大作用范围选用较长名称。

6、使用编码。

7、名称没有说明副作用。即多做事情了,但名称看不出来。

(二)函数(方法糟糕)

长函数、有大量字符串、怪异不常见的数据类型和API、有太多不同层级的抽象、奇怪的字符串和函数调用、多重嵌套、用标志来控制的if语句…《代码整洁之道》p30页有个例子可以感受一下糟糕代码。其实,在工作中维护旧系统代码时、隔一段时间再看自己以前写的代码时,也会有相同的感受。

1、过多的参数。

2、输出参数。

3、标志参数。

4、死函数。即永不调用的方法。

(三)注释糟糕

1、不恰当的信息。

2、废弃的注释。

3、冗余注释。

4、糟糕瞎扯的注释。

5、注释掉的代码。

(四)测试糟糕

1、测试不足。可以使用覆盖率工具,多数IDE提供了功能。

2、略过小测试。这个不能略过。

3、没有测试边界条件

4、只全面测试相近的缺陷

5、测试很慢

(五)一般性问题

1、一个源文件中存在多种语言,如Java、HTML、XML等

2、明显的行为未被实现。

3、不正确的边界行为。

4、忽视安全。

5、重复。

6、在错误的抽象层级上的代码。如基类和派生类、controller层和service层

7、基类依赖于派生类。

8、信息过多。提供的接口中,需要调用方调用的函数越少越好。

9、死代码。如在if、catch、工具类中、switch/case中。

10、垂直分隔。变量和函数应该在被使用的地方定义,不应该在被使用之处几百行以外声明。这种情况在一些巨大的方法中可以见到。

11、前后不一致。命名要前后形式一致。如controller层、service层、dao层同一个功能的方法名要有一致性。

12、不使用的变量、函数、没有信息量的注释等。这些都可以直接删除。一般在IDE自动生成的代码中会看到。

13、耦合。如为了方便,随意将变量、常量和函数放在一个不合适的临时地方。

14、特性依恋。类的方法只应操作其所属的变量和函数,不应该操作其它类的变量和函数。但是对于Controller层直接调用Service层方法,这种就不算坏味道。像下面这种,从其他类中获取其他类的变量来进行计算的,这种就是特性依恋了。

// 特性依恋
public class HourlyPayCalculator{
    
    public Money calculateWeeklyPay(HourlyEmployee e){
        int tenthRate = e.getTenthRate().getPennies();
        int tenthsWorked = e.getTenthsWorked();
        ......
        ......
        return new Money();
    }
    
}

// 非特性依恋
public class UserController {
    
    @Autowired
    priavate UserService userService;
    
    public boolean login(String username, String password){
        userService.login(username, password);
    }
}

15、选择参数。传入参数使用:boolean、枚举元素、整数或者任何一种用于选择函数行为的参数。

16、晦涩不明的意图。如使用联排表达式、匈牙利语标记法和魔法数等。

17、位置错误的权责。代码放错位置,如放到了不同模块、无关的类中。

18、不恰当的静态方法。有些类是会用到多态的,就不要用静态方法。

19、使用解释性变量。在计算过程中,如果直接计算 会很难读懂计算过程。此时,加上一些解释性变量,把计算过程打散成一些了良好命名的中间值,这样计算过程会易读很多。

Matcher match = headerPattern.matcher(line);
if(match.find()){
    String key = match.group(1);
    String value = match.group(2);
    headers.put(key.toLowerCase(), value);
}

20、函数名称应该表达其行为。

21、理解算法。很多糟糕代码是因为人们没有花时间去理解算法,而是不停地加if语句和标志。

22、把逻辑依赖改为物理依赖。

23、用多态替代if/else和switch/case

24、遵循标准约定。团队成员要遵循团队共同制定的规范。

25、用命名常量替代魔法数。

26、准确。如不能用浮点数表示货币,数据库的查询不一定返回唯一一条记录、有并发更新时要适当加锁、是否判空、异常是否处理等等。

27、结构比约定好。如使用IDE的强制性结构提示、使用基类使得具体类必须实现所有方法。

28、封装条件。

// 糟糕代码
if (timer.hasExpired() && !timer.isRecurrent()){}

// 优雅代码
if (shouldBeDeleted(timer)){}

29、避免否定性条件。

// 糟糕代码
if (!buffer.shouldNotCompact()){}

// 优雅代码
if (buffer.shouldCompact()){}

30、函数只做一件事。

31、掩盖时序耦合。这个见仁见智了。见《代码整洁之道》p284-285。

32、别随意。

33、封装边界条件。

34、函数中的语句应该只在一个抽象层级上。

35、在较高层级放置可配置数据。

public class Arguments {
    public static final String DEFAULT_PATH = "" ;
    public static final String DEFAULT_ROOT = "FitNesseRoot";
    public static final String DEFAULT_PORT = 80 ;
    public static final String DEFAULT_VERSION_DAYS = 14 ;
    
    public void parseCommandLine(String[] args){
        // user 80 by default  这里就不需要写了。因为已经在上面写好了配置数据。
        if(arguments.port == 0) {}
    }
    
}

public class Main {
    public static void main(String[] args){
        Arguments arguments = parseCommandLine(args);
        ......
    }
}

36、避免传递浏览。不要出现a.getB().getC()。

(六)Java

1、过长的导入清单。可使用通配符来避免。如import package.*;

2、继承常量。不能为了使用常量而继承有常量的类,正确的做法是导入该常量类。

3、常量 VS 枚举,建议使用枚举enum。

(七)环境

1、构建项目要很多步骤。

如项目检出,导入IDE后,还需要四处找额外的jar、xml文件和其它系统需要的杂物。

正常情况,只需要三步:源代码控制系统检出项目、导入项目到IDE、直接构建项目。

2、进行测试要很多步骤。

最好的是一个指令就可以执行所有测试。

使用Maven工具,可以快速构建、管理项目。

三、编码时

(一)命名

1、范围

变量、函数、参数、类、包、目录、jar文件、war文件、war文件等。

2、要有含义

(1)对于所有的命名,都要有具体含义,也就是说看到命名就知道是干什么的。这个对英语能力有一定的要求。英语不行的可以借助翻译工具。

(2)不能使用a、b、c、i、j、x、y等进行命名。

(3)不要使用魔法数,如直接使用1、2、3、4。

3、不能有误导

(1)不能使用一些专有名称。

(2)别用userList,即使真的是List类型,建议也别再名称中写出容器类型名称。如直接用users更好。

(3)注意不要使用不同之处较小的名称。起码要有两个单词不同。

(4)不能使用小写字母“l”和大写字母“O”,因为这个两个看起来很像数字”1“和”0“。

4、要有区分

(1)不能以数字系列命名来区分,如a1、a2、…aN,

// 糟糕命名
public static void copyChars(char[] a1, char[] a2){}
// 优雅命名
public static void copyChars(char[] source, char[] destination){}

(2)不要用废话命名。

废话一:使用意思没区别的命名。如下面这3个类的名称虽然不同,但是意思却没有区别。

Product.java
ProductInfo.java
ProductData.java

废话二:命名带上类型。

String name
String nameString ;

Customer.java
CustomerObject.java

数据表名:
user
user_table

5、不要用缩写,要能读

// 糟糕命名
class DtaRcrd102{
    private Date genymdhms;
    private Date modymdhms;
    private final String pszqint = "102" ;     
}

// 优雅命名
class Customer{
    private Date generationTimestamp;
    private Date modificationTimestamp;
    private final String recordId = "102" ;     
}

6、要可搜索

不要使用单字母名称和数字常量,因为很难搜索。如数字1、2、3和字母i、j、e等,很难找出来。

所以,长名称比短名称好。

// 糟糕命名
int[] a = ... ;
int s = 0 ;
for (int j = 0; j<10; j++){
    s += (a[j] * 4)/5 ;
}

// 优雅命名
int[] taskEstimate = ... ;
int realDayPerIdealDay = 4 ;
int WORK_DAYS_PER_WEEK = 5 ;
int sum = 0 ;
for (int j = 0;j<NUMBER_OF_TASKS; j++){
    int realTaskDays = taskEstimate[j] * realDayPerIdealDay ;
    int realTaskWeeks = realTaskDays / WORK_DAYS_PER_WEEK;
    sum += realTaskWeeks ;
}

在该例子中,搜索“sum”比搜索“s”容易多,搜索“WORK_DAYS_PER_WEEK”比搜索“5”容易多。

当然,名称长短要与其作用域大小相对应。对于局部变量,名称短一点可以,但也要可读;对于可能被多处代码使用的、经常需要搜索的变量或常量,需要使用适当长的、便于搜索的名称。单字母名称只能用于短方法中局部变量。

7、不能使用编码

(1)不使用匈牙利语标记法。Java是强类型的语言,IDE工具在编译开始前就能侦测到数据类型错误,所以这种方式在Java开发中基本没人使用。这里就不赘述。

(2)不要再使用前缀(如m_)或者后缀。而是应当把类和函数写得足够小。因为现在很多IDE能够用颜色区分成员变量。

// 糟糕命名
public class Part{
    private String m_dsc ;
    public void setName(String name){
        m_desc  = name ;
    }
}

// 优雅命名
public class Part{
    private String description ;
    public void setName(String description){
        this.description  = description ;
    }
}

(3)接口和实现类。接口不要再使用前导字母“I”。

// 糟糕命名
IUserService.java
UserService.java

// 优雅命名
UserService.java
UserServiceImpl.java

8、类名和对象名:名词或名词短语

// 糟糕命名
Manage.java
Process.java
Data.java
Info.java

// 优雅命名
Customer.java
User.java
Account.java
WikiPage.java
AdressParser.java

类名不能是动词

9、方法名:动词或动词短语

(1)方法名要使用动词或动词短语

// 使用动词或动词短语
postPayment();
deletePage();
savaPage();

(2)属性访问器、修改器和断言(isXXX)

// 属性访问器、修改器和断言应根据其值命名,并按照JavaBean标准加上set、get、is前缀。如Employee.java中有一个属性name,则属性访问器和修改器为setName(String name)和getName()。
String name  = employee.getName();
customer.setName("dave");
if(paycheck.isPosted()){......}

(3)重载构造方法

// 重载构造方法时,使用描述了参数的静态工厂方法名。如
Complex fulcrumPoint = Complex.FromRealNumber(23.0);
// 通常好于
Complex fulcrumPoint = new Complex(23.0);
// 同时可以考虑将构造方法设置为private,强制使用这种静态工厂方法名。

10、命名不能用笑话、俗语等

// 糟糕命名
// 劈砍
whack();
// 去死吧
eatMyShorts();

// 优雅命名
kill();
abort();

11、不能将同一单词用于不同目的

在多个类中都有add方法,作用都是通过增加或连接两个现存值来获得新值,相当于“+”。

如果要写一个新类,该类中有个方法,作用是将一个参数插入到一个集合中。这个时候,是不能再把方法定义为add,因为语义是不同的,应该使用insert或者append等词命名。

12、使用解决方案领域的名称

尽量使用计算机科学的术语、算法名、模式名和数学术语等,取一个技术性的名称。

// 设计模式:访问者模式
AccountVisitor.java

// 框架:任务队列
JobQueue.java

13、使用所涉及问题领域的名称

如果无法做到12中的用程序员熟悉的术语来命名,可以考虑采用从所涉及问题领域而来的名称。

这里不是很理解,本人的想法是,从技术解决方案领域无法找到合适命名,则从业务问题领域入手。

不过,一般不会出先这种情况。

14、添加有意义的语境

对于一些变量,如果单独写在一大段代码中,没有一个简单明了的语境,是很难读懂这些变量的。这个时候,就需要用类、函数或者名称空间来处理这些变量。

(1)使用名称空间(即前缀)。但不是最优方案,不提倡。更好的方案是创建一个类。

// 糟糕命名
void excute(){
    ......
    ......
    String firstName ;
    String lastName ;
    String street ;
    String houseNumber ;
    String city ;
    String state ;
    String zipcode ;
    ......
    ......
}

// 稍微好一点点的命名
void excute(){
    ......
    ......
    String addrFirstName ;
    String addrLastName ;
    String addrStreet ;
    String addrHouseNumber ;
    String addrCity ;
    String addrState ;
    String addrZipcode ;
    ......
    ......
}


// 优雅命名
void excute(){
    ......
    ......
    Address address = new Address();
    ......
    ......
}

class Address {
    private String firstName ;
    private String lastName ;
    private String street ;
    private String houseNumber ;
    private String city ;
    private String state ;
    private String zipcode ;
    // setter和getter方法
} 

(2)案例

// 糟糕命名
public class Main {
    public static void main(String[] args){
        Main name = new Main();
        name.printGuessStatistics('d',2);
    }
    private void printGuessStatistics(char candidate,int count){
        String number ;
        String verb ;
        String pluralModifier ;

        if (count == 0){
            number = "no" ;
            verb = "are" ;
            pluralModifier = "s";
        }else if (count == 1){
            number = "1" ;
            verb = "is" ;
            pluralModifier = "";
        }else {
            number = Integer.toString(count) ;
            verb = "are" ;
            pluralModifier = "s";
        }
        
        String guessMessage = String.format(
                "There %s %s %s%s",verb,number,candidate,pluralModifier
        );
        System.out.println(guessMessage);
    }
}


// 优雅命名
public class Main {
    public static void main(String[] args){
        Main name = new Main();
        name.printGuessStatistics('d',2);
    }

    private void printGuessStatistics(char candidate,int count){
        GuessStatisticsMessage guessStatisticsMessage = new GuessStatisticsMessage();
        String guessMessage =  guessStatisticsMessage.make(candidate,count);

        System.out.println(guessMessage);
    }
}

public class GuessStatisticsMessage {

    private String number ;
    private String verb ;
    private String pluralModifier ;

    public String make(char candidate,int count){
        createPluralDepentMessageParts(count);
        return String.format(
                "There %s %s %s%s",verb,number,candidate,pluralModifier
        );

    }

    private void createPluralDepentMessageParts(int count){
        if (count == 0){
            thereAreNoLetters();
        }else if (count == 1){
            thereIsOneLetters();
        }else {
            thereAreManyLetters(count);
        }
    }

    private void thereAreManyLetters(int count){
        number = Integer.toString(count) ;
        verb = "are" ;
        pluralModifier = "s";
    }

    private void thereIsOneLetters(){
        number = "1" ;
        verb = "is" ;
        pluralModifier = "";
    }

    private void thereAreNoLetters(){
        number = "no" ;
        verb = "are" ;
        pluralModifier = "s";
    }
}

15、不添加没有意义的语境

命名时不需要加一些无关紧要的前缀。如项目的名称叫做(Online School),那么不需要在每个类前加上“OS”前缀,如OSUSer.java 、OSAccount.java等

只要短名称已经最够说清楚,就不需要加长名称。

(二)函数(方法)

1、短小

(1)函数的第一规则是要短小,第二条规则是要更短小。

(2)函数的函数不能超过20行。显示器一屏要能够看完一个函数。

(3)if语句、else if语句、else语句和while语句,其中的代码只能有一行,这一行一般是函数调用语句。

2、只做一件事情

(1)判断一个函数是够做了多件事情:一是看是否存在不同的抽象层级;二是看能否再拆出一个函数。

(2)一个函数只能做一件事情。

3、每个函数一个抽象层级

MVC代表了不同的抽象层级。从页面、控制层、服务层、数据层,每一层的方法只能处理该层抽象层级的事,不能处理其他层级的事。举个例子,数据层不能出现调用服务层的代码,在控制层也不能直接出现调用数据层的代码。

4、switch语句

(1)一般不会用switch语句。

(2)实在无法避免了,使用多态来实现。这里不赘述,详见《代码整洁之道》P35页。

5、使用描述性的名称

(1)函数越短小、功能越集中,就越便于取个好名称。所以在发现很难给函数取名时,看看函数是否做了多件事情。

(2)可以使用长名称。

(3)命名格式要保持一致,使用与模块名一脉相承的相关动词和动词短语给函数命名。

6、参数

参数个数不能超过3个,超过的要进行封装。最理想的是没有参数,第二好是一个参数。

通过参数传入数据,但不能通过参数传出处理结果,而是要通过返回值输出处理结果。

(1)一个参数

传入单个参数有两种极普遍的理由,一是问关于这个参数的问题,如boolean fileExists("MyFile") ,问这个路径的文件是否存在;二是操作这个参数,将其转为其他东西, 如InputStream fileOpen("MyFile"),把String类型的文件名转换为InputStream类型的返回值。

还有一种不是很普遍的理由,那就是事件:有输入参数,无输出参数。如void passwordAttemptFailedNtimes(int attempts)。要小心使用这种形式。

以上这三种形式都是较好的。

尽量避免编写不遵循这些形式的一元函数,如使用输出参数而不是返回值。如

// 糟糕用法
void transform(StringBuffer out);
// 优雅用法
StringBuffer transform(StringBuffer in);

(2)标志参数

不要使用标志参数,这种参数丑陋不堪。如向函数传入布尔值,简直骇人听闻。见《代码整洁之道》p46页的代码清单3-7例子。

// 丑陋代码
render(Boolean isSuite);

// 优雅代码
renderForSuite();
renderForSingleTest();

(3)两个参数

二元函数容易搞错两个参数的顺序。如assertEquals(expected,actual),容易搞错expected与actual位置

尽量利用一些机制将其换成一元函数:

writeField(outputStream,name);

方案一:可以将writeField方法写成outputStream的方法,则直接用outputStream.writeField(name);
方案二:把outputStream写成当前类的成员变量,从而无需再传递;
方案三:分离出FiledWriter的新类,在其构造器中采用outputStream,并且包含write方法。

(4)三个参数

三元函数更加容易搞错三个参数的顺序。如assertEquals(message, expected, actual),很容易将message误以为expected。

因此这里需要注意。

(5)参数对象

如果函数看起来需要两个、三个或三个以上参数,则说明其中一些参数需要封装为类。如:

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

(6)参数列表

这里参数列表指的是可变参数。如String.format方法。

public String format(String format,Object... args);

有可变参数的函数可能是一元、二元和三元的,不要超过这个数量。

void monad(Integer... args);
void dyad(String name, Integer... args);
void triad(String name, int out, Integer... args);

7、函数偷偷多做事

函数名表明只做了一件事,但是又偷偷地做了其它事情,这些事情包括:一,对自己类中的变量做出未能预期的改动,二是把变量搞成向函数传递的参数或者系统全局变量。这两种情况都会导致时序性耦合与顺序依赖。

public class UserValidator {
    
    private Cryptographer cryptographer ;
    
    public boolean checkPassword(String username,String password){
        User user = UserGateway.findByName(username);
        if (user != User.NULL){
            String codedPhrase = user.getPhraseEncodedByPassword();
            String phrase = cryptographer.decrypt(codedPhrase,password);
            if ("Valid Password".equals(phrase)){
                Session.initialize();
                return true ;
            }
        }
        return false ;
    }
    
}

checkPassword()方法,顾名思义,就是用来检查密码的。这个名称并没有说明会初始化该次会话,但是在代码中却调用了Session.initialize();,也就是说,调用该方法来检查密码时,会删除现有会话。这就造成了时序性耦合。

所以checkPassword()方法需要重新命名为checkPasswordAndInitializeSession()方法。当然这样会违背了只做一件事的原则。

因此,最终方案是要将Session.initialize();提取出来。

8、分割指令和询问

函数要么做什么事,要么回答什么事,二者不可兼得。

函数要么修改某对象的状态,要么返回该对象的有关信息,二者不能同时做。

public boolean set(String attibute, String value){}

public static void main(String[] args){
    if(set("username","unclebob"){
        return true ;
    }
    return false ;
}

在读者看来,会存在疑问:这个方法时在问username属性值是否之前已设置为unclebob,还是在问username属性值是否成功设置为unclebob呢?这里很难判断其含义。

解决方案是将指令和询问分隔开,

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

这样,看起来就很明显知道:如果username存在,则将username属性值设置为unclebob。

9、使用异常替代返回错误码

(1)抽离try/catch代码块

返回错误码,是在要求调用者立刻处理错误。

if(deletePage(page) == E_OK){
    if(registry.deleteReference(page.name) == E_OK){
        if(configKeys.deleteKey(page.name.makeKey())==E_OK){
            logger.log("page deleted");
        }else{
            logger.log("configKey not deleted");
        }
    }else{
        logger.log("deleteReference from registry failed");
    }
}else{
    logger.log("delete failed");
    return E_ERROR;
}

如果使用异常代替返回错误码,可简化为

try{
    deletePage(page);
    registry.deleteReference(page.name);
    configKeys.deleteKey(page.name.makeKey();
}catch(Exception e){
    logger.log(e.getMessage());
}

但是,try/catch语句很丑,搞乱了代码结构,把错误处理与正常流程混在一起。所以,要把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函数只处理业务,与错误处理无关了。

(2)错误处理就是一件事。函数处理错误,就是做一件事。也就是说, 函数如果是处理异常的,关键字try就是这个函数的第一个单词,而且在catch/finally代码块后面不应该有其它代码了。

(3)Error.java依赖磁铁

使用返回错误码,一般是某个类或者枚举。这个类就像一颗依赖磁铁:其它许多类都导入和使用它。当Error枚举修改时,所有其他类都要重新编译和部署。

public enum Error{
    OK,
    INVALID,
    NO_SUCH,
    LOCKED,
    OUT_OF_RESOURCES,
    WAITING_FOR_EVENT;
}

使用异常替代返回错误码,新异常可以从异常类派生出来,无需重新编译和部署。

10、不要重复

重复是软件中邪恶的根源。很过原则和实践规则都是为了控制与消除重复的。如数据库范式是为了消除数据重复,面向对象编程将代码集中到基类,避免代码重复。面向切面、面向组件编程等,也是消除重复的策略。

11、结构化编程

结构化编程规则:每个函数、函数中的每个代码块都应该有一个入口、一个出口,意味着,每个函数只有一个return语句,循环中不能有break和continue,不能有任何的goto语句。

小函数:一般不需要遵守,助益不大。因为都是写小函数,所以这个可以略过。

大函数:结构化编程有明显好处。

12、如何写出这样的函数

(1)一开始时,可能会相对长和复杂。有太多缩进和嵌套循环。有过长的参数列表。名称比较随意,也会有部分重复代码。

(2)写单元测试代码,覆盖每行丑陋的代码。

(3)分解函数、修改名称、消除重复。缩短和重新安置方法,有时还会拆散类。同时要保持测试通过。

(4)遵循以上的规则,组装好函数。

有个小技巧:如果发现某个方法不能进行简单的单元测试,那么这个方法肯定有问题。

(三)注释

1、注释不能美化糟糕的代码

别给糟糕的代码写注释,直接重写。

2、好注释

(1)法律信息

(2)提供信息的注释。如解释某个抽象方法的返回值。

(3)对意图的解释。如程序员解释尽力做了优化后,仍然这样写的原因。

(4)阐释。如把某些晦涩难懂的参数或返回值的意义翻译为某种可读形式。

(5)警示。警告其他程序员出现某种后果的注释。如警示一些单元测试不能跑的(Junit4测试框架可以使用@Ignore注解)、日期工具类中提醒SimpleDateFormat是线程不安全的,所以需要每次都实例化对象等。

(6)TODO注释。不过需要定期清理。

(7)放大。如使用“非常重要”等字眼,提醒其他程序员注意该处代码。

(8)公共API的Javadoc。这个不能缺了,但是一定要准确。

3、坏注释

(1)喃喃自语。程序员的自我表达,只有作者自己看得懂。

(2)多余的注释。代码已经能清晰说明了,但还是加上了无关紧要的注释。一般出现在一些自动生成或者复制粘贴的模板代码中。注意要删除这类注释。

(3)误导性注释。如那些不够精确的注释,会误导读者。

(4)循规式注释。不一定每个函数每个变量都要有注释。但是本人觉得业务代码基本都要写。

(5)日志式注释。每次修改代码都加一条注释,这种注释是不需要的。

(6)废话注释。一般是IDE工具自动生成的注释,没啥用。

(7)可怕的废话。一般是复制粘贴过来的废话注释,废话的废话。

(8)位置标记。这种没有必要。

//////////////////////////////////////////////////////
......
......
/////////////////////////////////////////////////////

(9)括号后的注释。

try{
    while(){
        
    } // while
} //try
catch{
    
} //catch

(10)归属和署名。源代码控制系统(Git和SVN)才是这些信息最好的归属地。

/* Added by Dave */
/* Modified by Dave */

(11)注释掉的代码。删掉吧,有源代码控制系统,怕啥。

(12)HTML注释。不要写。

(13)非本地信息。要写当前位置的注释,不要写远在他方的注释。

(14)信息过多。不要写一大堆注释,要言简意赅。

(15)不明显的关系。注释要和代码有关联。

(16)函数头。这里建议不写注释。但这个本人觉得在业务代码中,还是建议每个函数头要写注释。看项目质量吧。

(17)非公共代码中的Javadoc。非公共代码,就不要写Javadoc注释。

(四)格式

1、格式的目的

格式会影响可读性。可读性会影响可维护性和扩展性。

2、垂直格式

(1)垂直方向上,代码最顶部应该是高层次概念和算法,细节往下逐次展开,直到最底层的函数和细节。也就是说,被调用的函数要放在调用函数的下面。就像看报纸一样,从上到下阅读,顶部是头条,接着第一段是故事大纲,然后细节在后面逐渐展开。

(2)概念间的隔开。适当使用一些空白行隔开,如package、import、每一块成员变量间、每个方法间、方法内每一块代码间(因为都是小函数,所以方法内一般不需要空白行)。

(3)概念间的靠近。不要使用空白行、或者不恰当的注释隔断紧密相关的代码。

// 糟糕代码
public class ReporterConfig{
    /**
     * The class name of the reporter listener
     */
    private String m_className ;
    
    /**
     * The properties of the reporter listener
     */
    private List<Property> m_properties = new ArrayList<Property>(); 
    
    public void addProperty(Property property){
        m_properties.add(property);
    }
}

// 优雅代码
public class ReporterConfig{

    private String classNameOfReporterListener ;
    private List<Property> propertiesOfReporterListener = new ArrayList<Property>(); 
    
    public void addProperty(Property property){
        propertiesOfReporterListener.add(property);
    }
}

(4)概念间的距离

第一,函数短小,局部变量应该在函数的顶部出现。

第二,成员变量应该在类的顶部出现。

第三,相关函数。若一个函数调用了同一个类的另一个,应该把这两个放在一起,而且调用者应该尽可能放在被调用者上面。

第四,概念相关的代码应该放在一起。如相关性可能来自于执行相似操作的一组函数。

public class Assert{
    public static void assertTrue();
    public static void assertFalse();
    public static void assertTrue(String message);
    public static void assertFalse(String message);
}

(5)概念间的顺序

被调用的函数应该放在执行调用函数的下面。这样,阅读代码时,看最前面的几个函数就能大概知道该类主要是做什么的了。

3、横向格式

一行代码应该有多宽?小屏幕一屏能展示,不用拖动滚动条到右边。

(1)隔开。

第一,在赋值操作符两边加上空格。

int lineSize = line.length();
totalChars += lineSizw();

第二,不在函数名和左圆括号之间加空格。

第三,在函数括号中的参数之间加空格。

public static boolean checkUserNameAndPassword(String username, String password);

(2)对齐。

Java开发无需关注横向方向上的对齐,而是要关注垂直的长度。如果发现需要对齐才好看清楚,那就要思考该类是否需要拆分了。

public class FitNesseExpediter implements ResponseSender{
    
    private Socket socket;
    private InputStream input;
    private OutputStream output;
    private Request request;
    private Response response;
    private FitNesseContext context;
    protected long requestParsingTimeLimit;
    private long requestProcess;
    private long requestParsingDeadline;
    private boolean hasError;
    
    ......
}

(3)缩进。一般IDE工具可以自动缩进。

(4)空范围。while或for语句的语句体为空时,容易忽略在同一行的分号";"。

// 后面的分号容易被忽略
while (dis.read(buf, 0, readBufferSize) != -1);

// 好一点
while (dis.read(buf, 0, readBufferSize) != -1)
;

// 优雅代码
while (dis.read(buf, 0, readBufferSize) != -1){
    
};

4、团队规则

在一个团队中工作,则需要定一个团队规则,一旦定好,所有人包括后来接手的人都要接受。

(1)启动项目时,团队要先制定一套编码规范,如什么地方放置括号,缩进几个字符,如何命名类、变量和方法等等。

(2)定好编码规范后,将这些规则编写今IDE的代码格式功能。

(3)后面接手的人要一定要按照这种编码规范来进行编码。

不要用不同风格来编写同一个项目的源代码。

由此可见,编码规范一旦定下,就要一直沿用。所以,在项目一开始时,就要非常注重编码规范的制定。

(五)对象与数据结构

慎用链式调用。这类代码被称作火车失事,是一种肮脏的风格。

最为精炼的数据结构,是数据传送对象,即DTD(Data Transfer Objects),只有公共变量、没有函数。

对象曝露行为,隐藏数据。所以便于添加新对象类型而无需修改既有行为,同时难以在既有对象中添加新行为。

数据结构曝露数据,隐藏行为。便于向既有数据结构添加新行为,同时难以向就有函数添加新数据结构。

(六)错误处理

错误处理很重要,但是如果它搞乱了代码逻辑,那就是错误的做法。

1、使用异常,而不是返回码

遇到错误时,最好是抛出一个异常。见(二)函数(方法)的第9条。

2、先写try-catch-finally语句

3、使用不可控异常

对于一般的应用开发,使用不可控异常。

如果使编写一套关键代码库,则可以考虑使用可控异常。

异常分类 说明
可控异常(checked exception) 继承自java.lang.Exception的异常,这种异常需要显式的try/catch或throw出来,否则编译不通过;
不可控异常(unchecked exception) 继承自java.lang.RunTimeException的异常,这种异常不需要显式的try/catch或throw出来编译就能通过。也叫运行时异常

之所以使用不可控异常,是因为不可控异常可以简化代码。然而,由于不需要额外处理就能编译通过,所以最好在调用前检查一下可能发生的错误,比如空指针、数组越界等。

4、catch时打印异常发生的环境

要将失败的操作和失败类型等打印记录下来,便于追踪排查问题。

使用日志系统,传递足够的信息给catch块,并记录下来。

这里参考《阿里巴巴Java开发手册》的日志打印规范。

5、自定义异常类

为某个功能定义一个异常类型,可以简化代码。

// 糟糕代码。catch里面代码大量重复。
ACMEReport port  = newACMEReport();

try {
    port.open();
} catch (DeviceResponseException e) {
    reportPortError(e);
    logger.log("......",e);
} catch (ATM1212UnlockedException e) {
    reportPortError(e);
    logger.log("......",e);
} catch (GMXError e) {
    reportPortError(e);
    logger.log("......",e);
} finally {
    ......
}

// 优雅代码。
LocalPort port = new LocalPort(12);
try {
    port.open();
} catch(PortDeviceFailure e) {
    reportPortError(e);
    logger.log(e.message(),e);
} finally {
    ......
}

// ACMEReport封装进LocalPort
public class LocalPort {
    private ACMEReport innerPort;
    
    public LocalPort(int portNumber){
        innerPort = new ACMEReport(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);
        }finally {
            ......
        }
    }

}

// 自定义异常类
class PortDeviceFailure extends RunTimeException {}

6、定义常规流程

特例模式。

该情况很少见,不展开说了。具体见《代码整洁之道》p100-p101

7、不要返回null值

新写的代码,不要返回null值。

调用第三方API返回null值的方法,要在新方法中打包这个方法,在新方法中抛出异常或者返回特列对象。

8、不要传递null值

准确讲,是禁止传入null值。

(七)边界

边界代码一般指函数的入参和返回值、第三方API的规范。

1、边界不用Map

不要使用Map作为传入参数类型;不要使用Map作为返回值类型

可以将Map进行包装后再使用。

// 糟糕代码:直接将Map作为参数在系统中传递
Map sensors = new  HashMaps();
......
Sensor sensor = (Sensor)sensors.get(sensorId);

// 使用泛型,好一点
Map<Sensor> sensors = new  HashMaps<Sensor>();
......
Sensor sensor = sensors.get(sensorId);

// 将Map包装起来,在系统中传递的是包装类Sensors
public class Sensors {
    private Map sensors = new HashMap();
    
    public Sensor getById(String id){
        return (Sensor)sensors.get(id);
    }
    
    ......
}

Sensors sensors = getSensors();
......
Sensor sensor = sensors.getById(id);

2、识别应用和第三方API的边界接口

如学习log4j框架时,要能够学会将应用程序的其它部分与log4j的边界接口隔离开。

3、对接的API还没设计出来时

这种方式一般不建议的,除非是真的无法避免了。

简单来说,就是两个系统之间交互的API格式还没定好,有一方b的进度是延后的,超前的团队a也不可能等待,所以a会先单方面定义好一个API进行开发。等b进度赶上,和a共同制定了最终的API,此时a在编写一个适配器类来对接两个接口。这是一种适配器模式,是属于事后的补救措施。可以参考《设计模式之禅》p215的第19章《适配器模式》

4、小结:整洁的边界

(1)整洁的边界要考虑到需要改动时,边界不需要太大代价来修改,甚至重写。

(2)边界上的代码需要清晰的分割和定义期望的测试。学会进行学习型测试来理解第三方代码,找出边界。

(3)避免我们的代码过多了解第三方代码中特定信息

(4)边界传值:两种方法,包装和使用适配器模式。

(八)单元测试

测试驱动开发。

1、TDD三定律

(1)在编写不能通过的单元测试前,不可编写生产代码

(2)只可编写刚好无法通过的单元测试,不能编译也算不通过

(3)只可编写刚好足以通过当前失败测试的生产代码。

2、保持测试的整洁

(1)脏测试等于没有测试,甚至比没有测试更坏。

(2)测试代码和生产代码一样重要。

(3)单元测试可以让代码可扩展、可维护、可复用。

3、整洁的测试

可读性。其实和生产代码的编码规范差不多。测试代码可按照这三个环节来写:

第一是构造测试数据;

第二是操作测试数据。这一部分往往就是生产代码;

第三是检验操作是否得到期望结果。

(1)测试代码要有一定的流程规范,如上面提到的三个环节。

(2)测试和生产可以有双重标准。但是测试代码一定整洁。测试代码和和生产代码的不同应该是在内存和CPU效率,而不是整洁方面,两者都要整洁。

4、每个测试一个断言

单个测试中的断言数量应该最小化。

每个测试函数只测试一个概念。

5、F.I.R.S.T

整洁测试遵循以下5条规则:

Fast:快速。测试运行要快。如果发现测试很慢,就要怀疑是不是性能问题了。

Independent:独立。各个测试之间要互相独立。

Repeatable:可重复。测试要能够在任何环境中重复通过。

Self-Validation:自足验证。测试要有布尔值输出,不能手工对比来确认测试是否通过,应使用assert方法。

Timely:及时。测试应及时编写。单元测试代码要在使其通过的生产代码之前编写。

(九)类设计

前面一直讲的是如何编写好的代码行和代码块。如函数的恰当构造,函数之间如何互相关联等。

现在将注意力放到代码组织的更高层面,即类,来探讨如何得到整洁代码。

更加具体的可以参考《设计模式之禅》

1、类的组织

(1)顺序:

第一,公共静态常量

第二,私有静态变量

第三,私有成员变量

第四,公共函数

第五,私有函数

以下例子仅为了说明类的组织顺序,其命名是不正确的。

public class Main {
    public static final String SALT = "salt" ;
    
    private static final String SALT = "salt" ;

    private String String username ;
    
    Main(){}
    
    public void f(){
        a();
        b();
    }
    
    private void a(){};
    private void b(){};
    
    public void g(){
        c();
    }
    
    private void c(){};
    
    ......
}

2、类要短小

类要短小,更加短小。

衡量函数是通过计算代码行数衡量大小;衡量类,采用计算权责衡量。

类的名称应当可以描述其权责。如果无法给一个类定一个精确的名称,那这个类就太长了。类名越含糊,改类就拥有过多权责。大概25个字母能描述一个类,且不能出现“if”、“and”、“or”、“but”等。

(1)单一权责原则

单一权责原则(SRP)认为,类或模块只有一条修改的理由。

系统应该有许多选小的类组成,而不是由少量巨大的类组成。

(2)内聚

类应只有少量实体变量。

类中的每个方法都应该操作一个或多个实体变量。

通常而言,方法操作的变量越多,就越内聚到类上。

如果一个类中每个变量都被每个方法所使用,那该类具有最大的内聚性。

一般来说,一个类的要有较高的内聚性。

如果发现,一个类中有的方法没有引用改类的任何变量,也没有操作改类的任何变量,那么这个方法可以剥离出来。

如果发现,一个类中有的实体变量没有被任何方法使用,那这个变量就可以直接删除了。

(3)内聚会得到许多短小的类

将大函数拆成小函数,往往也是将类拆分为多个小类的时机。

3、为了修改而组织

(1)开发-闭合原则:类应该对扩展开放,对修改关闭。

// 一个必须打开修改的类
public class Sql{
    public Sql(String table, Column[] columns);
    public String create();
    public String insert(Object[] fields);
    public selectAll();
    public findByKey(String keyColumn, String keyValue);
    public select(Column column, String pattern);
    public select(Criteria criteria);
    public preparedInsert();
    private columnList(Column[] columns);
    private valuesList(Object[] fields, final Column[] columns);
    private selectWithCriteria(String criteria);
    private placeholderList(Column[] columns);
    
    // 增加update语句,需要修改这个类
    public String update(Object[] fields);
}

当需要增加一种新语句时,需要修改Sql类。重构一下:

// 一组封闭的类
public abstract class Sql{
    public Sql(String table, Column[] columns);
    public abstract String generate();
}

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

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

public class InsertSql extends Sql{
    public InsertSql(String table, Column[] columns, Object[] fields){}
    @Override
    public String generate(){}
    
    private String valuesList(Object[] fields, final Column[] columns){}
}

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

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

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

public class PreparedInsertSql extends Sql{
    public PreparedInsertSql(String table, Column[] columns){}
    @Override
    public String generate(){}
    
    private placeholderList(Column[] columns);
}

public class Where{
    public Where(String criteria){}
    public String generate(){}
}

public class ColumnList(){
    public ColumnList(Column[] columns){}
    public String generate(){}
}

// 此时,增加update语句,不需要修改原来的任何类。只需要新建一个子类,继承父类Sql
public class UpdateSql extends Sql{
    public UpdateSql(String table, Column[] columns, Object[] fields){}
    @Override
    public String generate(){}
}

(2)依赖倒置原则:类应当依赖于抽象,而不是依赖于具体细节(如实现类)。

需求会变,所以代码也会变。

接口(抽象类)是概念,具体类是实现细节。

把变化的东西放到具体类,接口保持定义好后保持不变。

所以,需求变了,只需修改具体类,不用修改接口。

// 接口
public interface StockExchange {
    Money currentPrice(String symbol);
}

// 实现类
public class FixedStockExchangeSub implements StockExchange{
    
}

// 客户端调用
public class Portfolio {
    private StockExchange exchange;
    // 依赖StockExchange接口,而不是具体类
    public Portfolio(StockExchange exchange){
        this.exchange = exchange;
    }
    ......
}

// 测试
public class portfolioTest {
    private FixedStockExchangeSub exchange ;
    private Portfolio portfolio;
    
    @Before
    protected void setUp() throws Exception(){
        exchange = new FixedStockExchangeSub();
        exchange.fix("MSFT",100);
        portfolio = new Portfolio(exchange);
    }
    
    @Test
    public void GivenFiveMSFTTotalShouldBe500() throws Exception{
        portfolio.add(5,"MSFT");
        Assert.assertEquals(500,portfolio.value());
    }
}

(十)系统

前面讲的是类如何得到整洁代码。

这里讨论更高的抽象层级,如何在系统层级保持整洁。

1、构造和使用分开:依赖注入

工厂模式

2、AOP

代理模式、Java AOP框架、AspectJ

3、模块化

(十一)小结

4条简单的规则,按优先级从高到低,排列如下:

第一,运行所有测试。紧耦合的代码难以编写测试。

第二,不可重复。可用模板方法模式消除明显重复。

第三,表达了程序员的意图。如好的命名、函数和类短小等

第四,尽可能减少类和方法的数量。

优秀的软件设计:提升内聚性,降低耦合度,关注切面,模块化,缩小函数和类,使用好名称等。

四、重构时

这里独立成一篇博客来写。在撰写中。

代码整洁之道-理论-重构

五、并发编程

这里独立成一篇博客来写。待撰写。

代码整洁之道-理论-并发编程

六、总结

代码质量、架构和项目管理决定软件质量,代码质量是重要因素。

想成为更好的程序员,基础是要能写整洁优雅的代码。

糟糕的代码会毁掉一个项目,甚至毁掉一个公司。

整洁代码要立刻动手写,因为稍后等于永不。

让经过你手的代码能够更干净些。

七、参考

《代码整洁之道》

《重构:改善既有代码的设计》

《设计模式之禅》

《Head First设计模式》

《阿里巴巴Java开发手册》

《Java并发编程的艺术》

《Java并发编程:核心方法与框架》

《Java程序性能优化》

《深入理解Java虚拟机》

如何写出优雅的代码

写代码时应该注意的问题

八、实战

这里独立成一篇博客来写。计划撰写。

代码整洁之道-实验-重构Args

代码整洁之道-实验-重构Junit

代码整洁之道-实验-重构SerialDate

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章