代码整洁之道
写在前面的话
写代码的时间也不算太多,但是我觉得对于一个程序而言,除了时刻保持好的学习能力之外,还要对于代码的编写由好的习惯,养成好的代码编写习惯,可以提高代码的开发效率
下面讲一下好的代码的基本素养
对于命名:
1、有意义的命名
包名:按照域名全部使用小写
类名:首字母大写
方法名:采用驼峰命名法则
变量名:采用驼峰命名法则
常量:字符大写加下划线方式命名
对于名称要求:
1、名称代表实际意义
2、名称不能随便命名,名称意义与实际操作比匹配
对于方法名应当是动词或动词的短语
3、在系统的开发中,对同一概念用同一个词:
例如:在一堆代码中,有controller,manager,还有driver,就会令人困惑,DeviceManager和Protocol_Controller之间有何根本的区别?为什么不全用controller或manager?他们都是Drivers吗?这种名称,让人觉得这两个对象是不同的类型,也分属不同的类。
4、别用双关语
避免将同一单词用于不同的目的。同一术语用于不同的概念,基本上就是双关语了。如果遵守“一词一义”规则,可能在好多个类里面都会有add方法。只要这些add方法的参数列表和返回值在语义上等价,就一切顺利。
5、添加有意义的语境
设想你的名称firstName,lastName,street,houseNumber,city,state和ZipCode的变量。可以通过添加前缀的方式提供语境,例如addrFirstName,addrLastName,addrState等等
但不要添加没有用的语境:
设若有一个名为“加油站豪华版”(Gas Station Deluxe)的应用,在其中给每个类添加GSD前缀就不是什么好的点子。
只要短名称足够清楚,就要比长长名称好
函数
1、短小
常说函数不该长于一屏。
函数的代码块和缩进
if语句,else语句,while语句等,其中的代码块应该只有一行。该行大抵应该是一个函数的调用语句。这样不但能保持函数短小,而且,因为块内调用的函数拥有较具说明的名称,从而增加了文档上的价值。
同时在一个方法内部,不应该大到足以容纳嵌套结构。所以,函数的缩进层级不该多于一层或两层。
在函数中,不应该编写足够多的嵌套语句,这样的代码会增加的代码的阅读和修改成本。
2、方法只做一件事
函数应该做一件事,并且做好这件事,只做这一件事。
3、每个函数一个抽象层级
这了抽象层级一般是指if语句,else 语句,或者while、for语句中的嵌套迭代的层级,按照前文的讲述,一般对于这种程序控制流语句,一般不宜有太多的嵌套,保持的最多两层为佳,如果多于三层,这需要考虑重构出一个新的方法。
我们想要让代码拥有自顶详细的阅读顺序。我们想要让每个函数后面都跟着位于下一个抽象层级的函数,这样一来,在查看函数列表时,就能循抽象层级向下阅读了。
4、switch语句
写出短小的switch语句很难,即便只有两种条件的switch语句也要比我想要的单个代码或函数大的多。
public Money calculatePay(Employee e) throws InvalidEmployeedType{ switch (e.type){ case COMMISSIONED: return calculateCommissionedPay(e); case HOURLY: return calculateHourlyPay(e); case SALARIED:return calculateSalariedPay(e); default: throw new InvalidEmployeeType(e.type); } }
该函数有好几个问题,首先,它太长,当出现新的雇员类型时,还会更长。其次,它明显做了不止一件事情。第三,它违反了单一权责原则,因为有好几个修改它的原因。第四,它违反了开放闭合的原则。
不过,该函数最麻烦的可能是到处皆有类似的结构函数。例如,可能会有
isPayday(Employee e,Date date)
或
deliverDay(Employee e,Money pay)
该问题的解决方案如下:
通过构建工厂方式来解决问题
1 public abstract class Employee{ 2 public abstract boolean isPayday(); 3 public abstract Money calculatePay(); 4 public abstract void deliverPay(Money pay); 5 } 6 _______________________________________________________ 7 public interface EmployeeFactory{ 8 public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType; 9 } 10 _______________________________________________________ 11 public class EmployeeFactory implements EmployeeFactory{ 12 public Employee makeEmployee(EmployeeRecord r)throws InvalidEmployeeType{ 13 switch(r.type){ 14 case COMMISSIONED:return new CommissionedEmployee();
case HOURLY: return new HourlyEmployee();
case SALARIED: return new SalariedEmployee();
default:
throw new InvalidEmployeeType(e.type);
15 }
16 }
17 }
5、对于函数名称,需要使用描述性的名称
别害怕吃的长名字。长而具有描述性的名称,要比短而令人费解的名称好。长而具有描述性的名称,要比描述性的注释好。
6、函数的参数
最理想的参数数量是零(零参数函数),其次是一(单参数函数),再次是二(双参数函数),应尽量避免三(三参数函数)
像函数传递单个参数有两种极普遍的理由
1、操作该参数,将其转换为其他什么东西,在输出之
2、事件。在这种形式中,有输入参数而无输出参数。程序将函数看作是一个事件,使用该参数修改系统状态。
在实际的编程中,尽量避免编写不遵循这些形式的一元函数。如果函数要对输入参数参数进行转换操作,转换的结果就该体现为返回值。对于转换,使用输出参数而非返回值令人迷惑。如果函数要对输入参数进行转换操作,转换的结果就该体现为返回值。这样体现了函数的完整性。
对于参数,应该尽量避免传递boolean值。这样做,方法签名立刻变得复杂起来,大声宣布函数不止做一件事。如果标识为true将这样做,标识为false,则那样做。
实际应该将函数且分为两个函数。
对于标识参数还有一种做法及时将标识参数传递给类的字段,然后派生类继承这个字段,并实现这个抽象类的方法,这样方式达到一根方法只做一件事
对于函数的参数的格式尽量不要超过三个,超过三个,一般考虑将参数封装成对象。
对于函数的可变函数可能是一元,二元甚至是三元的。超过这个数量就可能要犯错误了。
void monad(Integer...args);
void dyad(String name,Integer...args);
void triad(String name,int count,Integer...args);
3、对于方法名称的取名,需要取一个动词加名词的方式
7、无副作用
副作用是一种谎言。函数承诺只做一件事,但还是会做其他的被藏起来的事。
例如:
1 public class UserValidator{ 2 private Cryptographer cryptographer; 3 public boolean checkPassword(String userName,String password){ 4 if(user!=User.NULL){ 5 String coderPhrase=user.getPhraseEncoderByPassword(); 6 String phrase=cryptographer.decrypt(codePhrase,password); 7 if("Valid Password".equals(phrase)){ 8 ---------------------------------------- 9 Session.initialze(); 10 ---------------------------------------- 11 return true; 12 } 13 } 14 return false; 15 } 16 }
输出参数:
当有函数调用时: 你一定会被数用作输出而非输入的参数迷惑过。对于函数的调用,例如:
appendFooter(s);
要明白这个函数的意义,你一定会花时间去看函数的签名。
public void appendFooter(StringBuffer report)
这时候你才 清楚函数是做什么的,但是付出了检查函数声明的代价。你被迫检查函数签名,就得花上一点时间,应该避免这种中断思路的事。
在面向对象语言的输出参数的大部分需求已经消失了,因为this也有输出函数的意味在内。换言之,最好的调用如下:
report.appendFooter();
普遍而言,应避免使用输出参数,如果函数必须要修改某种状态,就修改所属对象的状态。
8、分隔指令与询问
其实函数的主要功能要么做什么事,要么回答什么事,但二者不能兼得
看例子:
public boolean set(String attributes,String values);
该函数设置某个指定属性,如果成功就返回true,如果不存在那个属性则返回false,这样就导致以下的语句:
if(set("username”,"unclebob")....
其实站在读者的角度,这是什么意思呢?它是在为username属性值是否之前已设置为unclebob吗?或者它是在为username属性值是否成功设置为unclebob呢?从这行调用很难判断其含义,因为set是动词还是形容词并不清楚。
解决办法是把指令和询问分隔开来,防止发生混淆:
if(attributeExists("username"){
setAttribute("username","unclebob");
...
}
9、使用异常替代返回错误码
从指令式函数返回错误码轻微违反了指令与询问分隔的规则。它鼓励了在if语句判断中把指令当做表达是使用
if(deletePage(page)==E_OK)
这不会引起动词/形容词混淆,但却导致更深层次的嵌套结构
if(deletePage(page)==E_OK) {
if(registry.deleteReference(page.name)==E_OK){
if(configKeys.deleteKey(page.name.makeKey())==E_OK){
logger.log("page deleted")
另一方面,如果使用异常替代返回错误码,错误处理代码就能从主路径代码中分离出来,得到简化:
try{
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}catch(Exception e){
logger.log(e.getMessage());
}
}
抽离try/catch代码块
Try/Catch代码块丑陋不堪,它们搞乱了代码结构,把错误处理与正常流程混为一谈,最好把try和catch代码块的主体部分抽离出来,另外形成函数
例子:
public void delete(Page page){
try{
deletePageAndAllReferences(page):
}catch(Exception e){
logError(e):
}
}
private void deletePageAndAllReference(Page page) throws Exception{
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}
private void logError(Exception e){
logger.log(e.getMessage());
}
错误处理就是一件事
函数应该只做一件事,错误处理就是一件事。因此,处理错误的函数不该做其他的事,这意味着,如果关键字try在某个函数中存在,它就该是这个函数的第一个单词,而且在catch/finally代码块后面也不该有其他的内容。
Error.java依赖磁铁
在实际的编写代码中,返回错误码通常暗示某处有个类或是枚举,如下:
public enum Error{
OK,
INVALID,
NO_SUCH,
LOCKED,
OUT_OF_RESOURCES,
WAITING_FOR_EVENT;
}
随着错误的增加,还有错误类型的修改,你会不停的维护这样一份错误编码,并且Error枚举修改时,所有这些其他的类都需要重新编译和部署。
使用异常替代错误码,新异常就可以从异常类派生出来,无需重新编译或重新部署。
10、别重复自己
在编程实际中,很多原则与实践规则都是为控制与消除重复而创建。
11、结构化编程
结构化编程认为,每个函数,函数中的每个代码都应该只有一个入口,一个出口。遵循这些规则,意味着每个函数中只该有一个return语句,循环中不能有break或contiue语句,
但是在实际编程中,只要函数保持短小,偶尔出现return,break或continue语句没有什么坏处,甚至比单入单出原则更具有表达力
在实际的编写中,也是一开始都代码冗长而复杂,有太多的缩进和嵌套循环,有过长的参数列表,名称随意取。然后打磨这些代码,分解函数,修改名称,消除重复。缩短和重新安置方法。
注释
其实在实际的编写程序中,注释必不可少,但是好的注释也很有必要
注释一般包括:
1、法律信息
下面试我们在FitNesse项目每个源文件开头放置的标准注释。我们可以很开心的说,IDE自动卷起这些注释,这样就不会显得凌乱了
//Copyright (C) 2003,2004,2005 by Object Mentor,Inc.All right reserved. //Released under the terms of the GNU General Public License versoin 2 or Later
2、提供信息注释
有时,用注释来提供基本信息也有有用处。例如,以下注释解释了某个抽象方法的返回值:
//returns an instance of the responder being tested protected abstract Responder responderInstance();
这类注释有时管用,但更好的方式是尽量利用函数名称传达信息。比如,在本例中,只要把函数重新命名为responderBeingTested,注释就是多余的了。
// format matched kk:mm:ss EEE MM dd .yyy pattern timeMathcher=patern.complie("\\d*:\\d*:\\d \\w*,\\w* \\d*,\\d*");
3、对意图的解释
有时,注释不仅提供了有关实现的有用信息,而且还提供了某个决定后面的意图 通俗的来讲就是通过注释,告诉程序员某段代码的意图
4、阐释
有时,注释把某些晦涩难懂的参数或返回值的意义翻译为某种可读的形式,也是有用的
public void testCompareTo() throws Exception { WikiPagePath a=PathParser.parse("PageA"); WikiPagePath ab=PathParser.parse("PageA.PageB"); WikiPagePath b=PathParser.parse("PageB"); WikiPagePath aa=PathParser.parse("PageA.PageA"); WikiPagePath bb=PathParser.parse("PageB.PageB"); WikiPagePath ba=PathParser.parse("PageB.PageA"); assertTrue(a.compareTo(a)==0) //a==a assertTrue(a.compareTo(b)!=0) //a!=a assertTrue(ab.compareTo(ab)==0) //ab==ab assertTrue(aa.compareTo(ab)==0) //aa==ab }
5、警示
有时,用于警告其他程序员会出现某种后果的注释也是有用的。
//Don't run unless you have some time to kill public void _testWithReallyBigFile(){ writeLinessToFile(1000000); response.setBody(testFile); response.readyToSend(this); String responseString=output.toString(); }
6、 TODO注释
有时,有理由用//TODO形式在源代码中放置要做的工作列表。
//TODO-MdM these are not needed //We except this to go away when we do the checkout model protected VersionInfo makeVersion() throws Exception{ return null; }
7、放大
注释可以用来放大某种看来不合理之物的重要性
String listItemContent =match.group(3).trim(); //the trim is real important,It removes the starting //spaces that could cause the item to be recongnized //as anoter list new ListItemWidget(this,listItemContent,this.level+1); return buildList(text.substring(match.end()));
8、公共API中的doc
没有什么比被良好的描述的公共API更有用和令人满意的了,标准的Java库中的Javadoc就是一例。
如果你在编写公共的API,就该为它编写良好的javadoc。
格式
概念间垂直方向上的区隔
在封包声明,导入声明和每个函数之间,都有空白行隔开。
垂直方向上的靠近
紧密相关的代码应该互相靠近
垂直距离
关系密切的概念应该互相靠近。
变量声明尽可能靠近其使用的位置。
偶尔,在较长的函数中,变量也可能在某个代码块的顶部,或在循环之前声明。
实体变量应该在类的声明的顶部。
相关函数。若某个函数地调用了另外一个函数,就应该把它们放到一起,而且调用者应该尽可能放在被调用者上面。
概念相关。概念相关的代码应该放到一起。相关性越强,彼此之间的距离就该越短。
横向格式
一般一行代码保持在80-120个字符之间,且无需拖动滚动条。
如果一条语句太长可以考虑换行,原则是看代码是无需拖动滚动条。
至于代码的格式,采用IDEA或者eclipse默认的格式即可
对象和数据结构
数据、对象的反对称性
过程式的形状的代码
public class Square{ public Point topLeft; public double side; } public class Rectangle{ public Point toLeft; public double height; public double width; } public class Circle{ public Point center; public double radius; } public class Geometry{ public final double PI=3.141592653589793; public double area(Object shape) thros NosuchShapeException{ if(shape instanceof Square){ Square s=(Square) shape; return s.side*s.side; } else if(shape instanceof Rectangle){ Rectangle r=(Rectangle) shape; return r.height*r.width; } else if(shape instanceof Circle){ Circle c=(Circle) shape; return PI*c.radius*c.radius; } throw new NoSuchShapeExceptino(); } }
多态式的形状
public interface Shape { double area(); } public class Square implements Shape { private Point topLeft; private double side; public double area() { return side * side; } } public class Rectangle implements Shape { private Point topLeft; private double height; private double width; public double area() { return height * width; } } public class Circle implements Shape { private Point center; private double radius; public final double PI = 3.141592653589793; public double area() { return PI * radius * radius; } }
上述代码说明:
过程式的代码(使用数据结构代码)便于在不改动现有的数据结构的前提下,添加新的数据结构。面向对象的代码便于在不改动现有函数的前提下添加新的类。
反过来讲是:
过程式代码难以添加新数据结构,因为必须修改所有函数。面向对象代码难以添加新的函数,因为必须修改所有类。
得墨忒耳律
模块不应了解它所操作对象的内部情况。如上节所见,对象隐藏数据,暴露操作。这意味着对象不应通过存取器暴露其内部结构,因为这样更像是暴露而非隐藏其内部结构。
更准确的说,得墨忒耳认为,类C的方法f只应该地调用以下的对象的方法:
- C(包括静态方法和非静态方法)
- 由f创建对象
- 作为参数传递给f的对象
- 由C的实体变量持有的对象
方法不应调用由任何函数返回的对象的方法。换言之,只跟朋友谈话,不与陌生人谈话。
以下方法的调用违反了得墨忒耳定律,因为它调用了getOption()返回值的getScratchDir()函数,又调用了getScratchDir()返回值的getAbsolutePath()方法。
数据传输对象
最为精练的数据结构,是一个只有公共变量,没有函数的类。这种数据有时被称为数据传送对象,或DTO(data Transfer Objects)。在应用程序代码里一系列将原始数据转换为数据库的,它们往往是排头兵。
public class Address{ private String street; private String streetExtra; private String city; private String state; private String zip; public Address(String street,String streetExtra,String city,String state,String zip){ this.street=street; this.streetExtra=streetExtra; this.city=city; this.state=state; this.zip=zip; } public String getStreet() { return street; } public void setStreet(String street) { this.street = street; } public String getStreetExtra() { return streetExtra; } public void setStreetExtra(String streetExtra) { this.streetExtra = streetExtra; } public String getCity() { return city; } public void setCity(String city) { this.city = city; } public String getState() { return state; } public void setState(String state) { this.state = state; } public String getZip() { return zip; } public void setZip(String zip) { this.zip = zip; } }
其本质上而言,是MVC架构,将DAO层操作Java Bean对象
错误的处理
在实际代码实现中try...catch的创建要和实际的业务代码分开。将try...catch和实际的业务代码绑定在一起会搞乱了代码逻辑。
1、在实际的开发中,使用异常而不是标记非异常值标记码。
2、先写入try...catch代码块
在你调用函数中,最小范围内的,使用try...catch代码块,而不是在增大try...catch代码块,将一大堆业务代码框起来,这样不便于排错
3、使用不可控异常
可控异常代价就是违反开放/闭合原则。如果你在方法中抛出可控异常,而catch语句在三个层级之上,你就得在Catch语句和抛出异常处之间的每个方法签名中声明该异常。这意味着对软件中较低层的修改,都将波及较高层的签名。修改好的模块必须重新构建、发布,即便它们自身所关注的任何东西都不每改动过。
4、给出异常发生的环境说明
在抛出异常地方应该说明,异常产生的位置包名,方法名,产生异常的变量及变量的值等。这样方便后续错误的排查
5、依调用者需要定义异常类
ACMEPort port=new ACMEPort(12); try{ port.open(); }catch(DeviceResponseException e){ logger.warn("Device response exception",e); reportPortError(e); } catch(ATM1212UnlockedException e){ reportPortError(e); logger.log("Unlock exception",e) }catch(GMXError e){ reportPortError(e); logger.log("Device response exception"); }finally{ ... }
通过重构为:
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); } } }
通过封装一个自定义的异常来使上级目录在方法声明throws不在添加更多的异常的抛出。这样达到一定的程度的解耦。
6、定义常规流程
例子
try{ MealExpenses expenses=expenseReportDAO.getMeals(employee.getTD()); m_total+=expense.getTotal(); }catch(MealExpenseNotFound e){ m_total+=getMealPerDiem(); }
业务逻辑是,如果消耗了餐食,则计入总额中,如果没有消耗,则员工得到当日餐食补贴。异常打断了业务逻辑。如果不去处理特殊情况会不会好一些?那样的话代码会看起来会更简洁。就像这样:
MealExpense expense=expenseReportDAO.getMeals(employee.getID());
m_total+=expense.getTotal();
能把代码写的那样简洁吗?能,可以修改一下ExpenseReportDAO,使其总是返回MealExpense对象。如果没有餐食消耗,就返回一个餐食补贴的MealExpense对象。
public class PerDiemMealExpenses implements MealExpenses{ public int getTotal(){ } }
这种手法叫做特例模式,创建一个类或者配置一个对象,用来处理特例。
7.别返回null值
如果打算在方法中返回null值,不如抛出异常,或是返回特例对象(例如一个长度为空的列表)
8.别传递null值
在大多数编程语言中,没有良好的方法能对付由调用者意外传入的null值。事已至此,恰当的做法就是禁止传入null值。这样,你在编码的时候,就会时时记住参数列表中的null值意味着出问题了,从而大量避免这种无心之灾。
边界
边界主要是自己系统中对于别人库的调用关系,在边界中要尽可能的消除边界的兼容性,比如不使用泛型,还有可以使用adapter模式将我们的接口转换为第三方提供的接口。
另外,在实际的开发当中,要学会测试第三方的接口,熟悉第三方的API。
单元测试
类
类的组织
类应该从一组变量列表开始,如果有公共静态变量,应该先出现,然后是私有静态变量,以及私有实体变量,很好会有公共变量。
公共函数应跟在变量列表之后,我们喜欢把由某个公共函数调用的私有工具函数紧随在该公共函数后面。
有时我们也需要用到受保护的(protected)变量或工具函数,好让测试可以访问到。
类应该短小
关于类的第一个规则是类应该短小,第二条规则是还要更短小。
单一权责原则
单一权责原则认为,类或模块应有且只有一条加以修改的理由“添加新的功能“,很多既定的操作在类编写测试之后就基本确定。
从职责单一的角度而言,一个类只负责一个单一的职责,与该职责相关的修改成为修改这个类的理由,与该职责无关的修改,不成为类修改的理由。
再强调一下,系统应该由许多短小的类而不是少量巨大的类组成。每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为。
内聚
如果一个类中的每个变量都被每个每个方法所使用,则该类具有最大的内聚性。
内聚性高,意味着类中的方法和变量互相依赖,互相结合成一个逻辑整体。
保持内聚性就会得到许多短小的类
为了修改而组织
我们希望将系统打造成在添加或修改特性时尽可能少惹麻烦的架子。在理想的系统中,我们通过拓展系统而非修改现有代码来添加新特性。
隔离修改
依赖于具体细节的客户类,当细节改变时,就会有风险。我们可以借助接口和抽象类来隔离这些细节带来的影响。(意思就是将不变的代码组成一类,将变化到的部分以接口和抽象的类的方式,提供出去,如果发生变化,则可以在变化的类中继承或者实现这个接口,来重新实现具体细节,达到减少修改的目的,这是基于一个现实,修改原有的代码的成本,比新开发一个具体实现的成本高,为了降低新的成本,所有选择上面的方式)
通过降低连接度,这样的类就遵循了另一条设计原则。依赖倒置原则。本质而言,DIP认为类应当依赖于抽象而不是依赖于具体细节。