[TOC]
代码整洁之道-理论
前言
学习中、工作中遇到很多乱七八糟的糟糕代码,自己入门时也写过不少糟糕代码。在一个夜深人静的晚上,思考人生,觉得要成为一名更好的程序员,那写代码的基本功就要扎实。
于是结合自己曾今看过的关于代码设计的书,进行了总结,写下这篇博客,作为日后编码的参考文档。本文以《代码整洁之道》、《重构:改善既有代码的设计》、《设计模式之禅》、《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虚拟机》
八、实战
这里独立成一篇博客来写。计划撰写。