《代码简洁之道》笔记
# 代码简洁之道》笔记
# 2. 有意义的命名
# 2.4 做有意义的区分
假设你有一个Product类,如果还有一个ProductInfo类或ProductData类,名称虽然不同,但意思却无区别。Info和Data与a、the、an一样是意义含混的废话。
getActiveAccount();
getActiveAccounts();
getActiveAccountInfo();
2
3
如果缺少约定,变量moneyAccount和money没区别,customerInfo和customer,accountData和account,theMessage和message没区别。
# 2.6 使用可搜索的名称
使用常量MAX_CLASSES_PER_STUDENT表示数字7更容易明白和搜索; 名称长短应该与其作用域大小相对应,如果变量或常量在代码中多处使用,应该使用便于搜索的名称。
# 2.9 类名
类名和对象应该是名称或者名称短语,如Customer、WikiPage、Account、AddressParser。避免使用Manager、Processor、Data或Info这样的类名。类名不应当是动词
# 2.10 方法名
方法名应当是动词或是动词短语,如postPayment、deletePage、save。属性访问器、修改器、断言应根据其值命名。
string name = employee.getName();
Complex fulcrumPoint = Complex.FromRealNumber(23.0);
// 好于下面
Complex fulcrumPoint = new Complex(23.0);
2
3
4
重载构造器时,使用描述了参数的静态工厂方法。可以考虑将相应的构造器方法设置为private,强制使用这种静态工厂方法。
# 2.12 每个概念对应一个词
使用fetch、retrieve、get来给多个类中的同种方法命名;函数名称应该独一无二,而且要保持一致,一以贯之的命名兼职天将福音!!
# 2.13 别用双关语
避免将同一个单词用于不同目的。假设要写一个新类,该类中有一个方法,把单个参数放到集合(collection)中,把这个方法叫做add吗?似乎和其他add方法保持一致了,但实际上语义却不同,应该使用insert、append之类的动词来命名更加正确。把该方法命名为add就是双关语了。
# 2.14 使用解决方案领域名称
尽管用计算机科学(CS)的算法名、术语、模式名、数学术语吧。不该让协作者老是跑去问客户每个名称的含义。给业务相关的变量起个技术性的名称(技术人员都懂的),通常是最靠谱的做法。
# 2.16 添加有意义的语境
你需要用有良好命名的类、函数或名称空间来放置名称,给堵住提语境。可以添加前缀addrFirstName等,以此提供语境。更好的方案是创建名为Address的类,这样即使是编译器也知道这些变量隶属于某个更大的概念。
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
);
print(guessMessage);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
以上代码中的变量的含义完全不清楚;上列函数过长,始终贯彻始终。要分解这个函数,需要创建一个GuessStatisticsMessage的类,把三个变量做成该类的成员字段。语境的增强也让算法能够通过分解更小的函数而变得更为干净利落。
public class GuessStatisticsMessage {
private String number;
private String verb;
private String pluralModifier;
public String make(char candidate, int count) {
createPluralDependentMessageParts(count);
return String.format("There %s %s %s%s", verb, number, candidate, pluralModifier);
}
private void createPluralDependentMessageParts(int count) {
if (count === 0) {
thereAreMoLetters();
} else if (count === 1) {
thereIsOneLetter();
} else {
thereAreManyLetters();
}
}
private void thereAreManyLetters(int count) {
number = Integer.toString(count);
verb = "are";
pluralModifier = "s";
}
private void thereIsOneLetter() {
number = "1";
verb = "is";
pluralModifier = "";
}
private void thereAreNoLetter() {
number = "no";
verb = "are";
pluralModiffer = "s";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
只要短名称足够好,就要比长名称好。别给名称添加不必要的语境。对于Address类的实体来说,accountAddress和customerAddress都是不错的名称,不过用在雷鸣上就不太好。Address是个好类名。如果需要与MAC地址、端口地址和Web地址相区别,我会考虑使用PostalAddress、MAC和URI。这样的名称更加精确,而精确正式命名的要点。
# 3. 函数
# 3.1 短小
一个函数最好在20行之内(不是绝对)。if语句、else语句、while语句等,其中的代码应该只有一行。该行大抵应该是一个函数调用语句。这样不但能保持函数短小,而且,因为块内调用的函数拥有较具说明性的名称,从而增加了文档上的价值。
# 3.2 只做一件事
函数应该做一件事。做好这件事。只做这件事。如果函数只是做了还函数名下同一抽象层上的步骤,则函数还是只做了一件事。
public static String renderPageWithSetupsAndTeardowns {
PageData pageData, boolean isSuite) throws Exception {
if (isTestPage(pageData)
includeSetupAndTeardownPages(pageData, isSuite);
return pageData.getHtml();
}
}
2
3
4
5
6
7
8
# 3.3 每个函数一个抽象层级
要确保函数只做一件事,函数中的语句都要在同一抽象层上。
自顶向下读代码:向下规则。我们想让代码拥有自顶向下的阅读顺序。我们想要然每格函数后面都跟着位于下一抽象层级的函数,这样一来,在查看函数列表是,就能循抽象层级向下阅读了。我们把这叫做向下规则。学习这个技巧是保持函数短小、确保只做一件事的诀窍。
# 3.4 switch语句
不好的代码:
public Money calculatePay(Employee e)
throw InvalidEmployeeType {
switch (e.type) {
case COMMISSIONED:
return calculateCommissionedPay(e);
case HOURLY:
return calculateHourlyPay(e);
case SALARIED:
return calculateSalariedPay(e);
default:
throw new InvalidEmployeeType(e.type);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
该函数有几个问题:首先是太长,当出现雇员类型是,还会变得更长;其次不止做一件事;违反单一权责原则(Single Reponsibility Principle, SRP);违反开放闭合原则(Open Closed Principle, OCP),因为每当添加新类型是,就必须修改之。最麻烦的是该函数中存在类似结构的函数。例如:isPayday(Employee e, Date date) 或 deliverPay(Employee e, Money pay)
该问题的解决方案是将switch语句埋到抽象工厂底部,不让任何人看到。该工厂使用switch语句为Employee的派生物创建适当的实体,而不同的函数如calculatePay、isPayday和deliverPay等,则藉由Employee接口多态地接收派遣。实际中应就事论事:
public abstract class Employee {
pubic abstract boolean isPayday();
public abstract Money calculatePay();
public abstract viod deliverPay(Money pay);
}
public interface EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
}
public class EmployeeFactoryImpl implements EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
switch (r.type) {
case COMMISSIONED:
return new CommissionedEmployee(r);
case HOURLY:
return new HourlyEmployee(r);
case SALARIED:
return new SalariedEmploye(r);
defaul:
throw new InvalidEmployeeType(r.type);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 3.5 使用描述性的名称
从testablelHtml改为SetupTeardownIncluder.render。如isTestable或includeSetupAndTeardonwPages。沃德原则:“如果每个例程都让你感到深合己意,那就是整洁代码“。别害怕长名称。命名方式要保持一致。使用于模块名一脉相承的短语、名词和动词给函数命名。例如includeSetupAndTeardonwPages、includeSetupPages、includeSuiteSetupPage和includeSetupPage等。
# 3.6 函数参数
最理想的是零参数函数,其次是单参数函数,再次是双参数函数。有足够特殊的理由才能用三个以上参数。includeSetupPage()要比includeSetupPageInfo(newPageContent)易于理解。多参数不易于编写测试用例。
一元函数的普遍形式(p38没看懂)
标识参数。**向函数传入布尔值简直就是骇人听闻的做法。**方法签名立刻变得复杂起来,大声宣布函数不止做一件事:如果标识为true将会这样做,如果标识为false将会那样做。例如:调用render(true)对读者来说摸不着头脑,看到render(Boolean isSuite)时稍微有点帮助,正确做法应该把该函数一分为二:renderForSuite()和renderForSingleTest().
二元函数。有两个参数的函数要比一个难懂。例如writeField(name)比writeField(outputStream, name)好懂。可以把writeField方法写成outputStream的成员方法,使用outputString.writeField(name)调用;或者把outputString写成当前类的成员变量,从而无需传递它;还可以分离出类似FieldWriter的新类,在其构造器中采用outputStream,并且包含一个write方法。
三元函数。有三个参数的函数要比二元函数难懂很多。
参数对象。如果函数看来需要两个、三个或者三个以上参数,就说明其中一些参数应该封装为类了。当一组参数被共同传递,就像上例中的x和y那样,旺旺就是该有自己名称的某个概念的一部分。例如:
Circle makeCircle(double x, double y, double radius); Circle makeCircle(Point center, double radius);
1
2参数列表。有时要想函数传入数量可变的额参数。如
String.format("%s worked $.2f hours.", name, hours);
如果可变参数像上栗中那样被等同对待,就和类型为List的单个参数没什么两样。这样一来String.formate实则是二元函数:public String format(String format, Object... args)
动词与关键字。给函数取个好名字,能较好地解释函数的意图,以及参数的顺序和意图。对于一元函数,函数和参数硬蛋形成一种非常良好的动词/名词对形式。例如,write(name)就相当令人认同。不管这个“name”是什么,都要被“write”。更好的名称大概是writeField(name),它告诉我们“name”是一个“field”。最后那个例子展示了函数名称的关键字(keyword)形式。使用这种形式,我们把参数的名称编码成了函数名。例如,assertEqual改为assertExpectedEqualsActual(expected, actual)可能会好些,大大减轻了记忆参数顺序的负担。
# 3.7 无副作用
副作用是一种谎言。函数承诺只做一件事,但还是会做其他被隐藏起来的事,导致古怪的时序性耦合和顺序依赖。例如:
public class UserValidator {
private Crytographer cryptographer;
public boolean checkPassword(String userName, String password) {
User user = UserGateway.findByName(userName);
if (user != User.NULL) {
String codedPhrase = user.getPhraseEncodeByPassword();
if ("Valid Password".equals(phrase) {
Session.initialize();
return true;
}
}
return false;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
其中副作用就在对Session.initialize()的调用。checkPassword函数,就是用来检查密码的,该名称并未暗示它会初始化该次会话。所以,当摸个误信了函数名的调用者想要检查用户有效性是,就有可能抹除现有的会话数据。这一副作用造成了一次时序性耦合。也就是所checkPassword只能在特定时刻调用(在初始化会话是安全的时候调用)。如果一定要时序性耦合,可以重命名函数为checkPasswordAndInitiallizeSession.
输出参数:在调用appendFooter(s);
时会有这样的疑惑:这个函数是把s添加到什么东西后面吗(s作为输入参数);还是它把什么东西添加到s后面(s作为输出参数)。当看到函数声明:public void appendFooter(StringBuffer report)
时才知道s作为输出参数。在面向对象中一般这样写:report.appendFooter();
# 3.8 分隔指令与询问
函数要么做什么事,要么回答什么事,但二者不可兼得。函数应该修改某对象的状态或是返回该对象的有关信息。
public boolean set(String attribute, String value);
该函数设置某个指定属性,如果成功就返回true,如果不存在那个属性则返回false,这样就导致一下语句:
if (set("username", "unclebob"))...
这个if语句可能在问username属性值是否之前已经设置为unclebob吗?还是在问username属性值是否成功设置为unclebob呢? 真确的解决方案是把之灵与询问分隔开来,防止混淆的发生:
if (attributeExists("username")) {
setAttribute("username", "unclebob");
...
}
2
3
4
# 3.9 使用异常代替返回错误码
函数返回错误码导致更深层次的嵌套结构,方返回错误码时,要求调用者立刻处理错误:
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
如果使用异常替代返回错误码,错误处理代码就能从主路径代码中分离出来:
try {
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
} catch (Exception e) {
logger.log(e.getMessage());
}
2
3
4
5
6
7
- 抽离Try/Catch代码块 Try/Catch代码块丑陋不堪,它们搞乱了代码结构,吧错误处理与正常流程混为一谈。最好把tray和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();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在上例中,delete函数只与错误处理有关。很容易理解然后就忽略掉。deletePageAndAllReference 函数只与完全删除一个page有关。错误粗粒可以忽略掉。有了这样美妙的去个,代码就更易于理解和修改了。
- 错误处理就是一件事 函数应该只做一件事情,错误处理就是一件事。因此处理错误的函数不该做其他事。**这意味着如果关键字try在某个函数汇总存在,它就该是这个函数的第一个单词,而且在catch/finally代码块后面也不该有其他内容。
# 3.10 别重复自己
# 3.11 结构化编程
# 3.12 如何写出这个的函数
好的函数并不是在一开始就按照这些规则就能够写出的。需要经过分解函数、修改名称、消除重复、拆散类等,同时保持测试通过来达到的。
# 3.13 小结
本章所讲述的有个编写良好函数的机制。如果遵循这些规则,函数就会短小,有个好名字,而且被很好的规则。不过永远别忘记,真正的目标在于讲述系统的故事,编写的函数必须干净利落地拼装到一起,形成一种精确而清晰的语言,帮助你讲故事。
# 4. 注释
尽量少注释,使用代码来表达。因为代码在变动,而注释往往没有随之变动。
# 4.1 注释不能美化糟糕的代码
与其花时间编写解释你搞出的糟糕代码的注释,不如花时间清洁那堆糟糕的代码。
# 4.2 用代码来注释
// Check to see if the employee is eligible for full benefits
if ((employee.flags & HOURLY_FLAG) && (employee.age > 65))
2
改为
if (employee.isEligibleForFullBenefits())
很多时候,简单到只需要创建一个描述与注释所言同一事物的函数即可。
# 4.3 好注释
有些注释是必须的,也是有利的。
法律信息。有时,公司代码规范要求编写与法律有关的注释,例如版权与著作权声明。
提供信息的注释。用注释来提供基本信息也有其用处。
// Returns an instance of the Responder being tested. protected abstract Responder responderInstance();
1
2但是,只要把函数重新命名为responderBeingTested,注释就是多余的。
// format matched kk:mm:ss EEE, MMM dd, yyyy Pattern timeMatcher = Pattern.compile("\\d*:\\d*:\\d* \\w* \\d*, \\d*");
1
2如果把这段代码移到某个转换日期格式的类中,就不需要注释了。
对意图的解释。注释不仅提供了有关实现的有用信息,而且还提供了某个确定后面的意图。
阐释。注释把某些晦涩难明的参数或返回值的意义翻译为某种可读的形式,也会是有用的。
警示。用于警告其他程序员会出现某种后果。
// Don't run unless you have some time to kill public void _testWithReallyBigFile() { writeLinesToFile(1000000000); response.setBody(testFile); resonse.readyToSend(this); String responseString = output.toString(); assertSubString("content-Length: 100000000", responseString); assertTrue(bytesSent > 100000000); }
1
2
3
4
5
6
7
8
9
10
11TODO注释。TODO注释是一种程序员认为应该做,但由于某些原因目前还没做的工作。它可能要提醒删除某个不必要的特性,或者要求他人注意某个问题。它可能是恳请别人去个好名字,或者提示对依赖于某个计划事件的修改。
# 4.4 坏注释
不要喃喃自语。如果只是因为你觉得应该或者因为过程需要就添加注释,那就是无谓之举。如果你决定写注释,就要花必要的时间确保写出最好的注释。
多余的注释
// Utility method that returns when this.closed is true. Throws an exception // if the timeout is reached. public synchronized void waitForClose(final long timeoutMillis) throws Exception { if (!closed) { wait(timeoutMillis); if (!closed) throw new Exception("MockResonseSender could not be closed"); } }
1
2
3
4
5
6
7
8
9这段注释并不能比代码本身提供更多的信息。没哟证明代码的意义,也没有给出代码的意图或逻辑。
误导性注释
循规式注释。所谓每个函数都要有Javadoc或每个变量都要有注释的规矩全然是愚蠢可笑的。这类注释徒然让代码变得散乱,满口胡言,令人疑惑不解。
日志式注释。有人会在每次编辑代码是,在模块开始出添加一条注释。如今有代码控制系统,也就不需要这类注释了。
废话注释。
能用函数或变量时就别用注释
// does the module from the global list <mod> depend on the subsystem we are part of? if (smodule.getDependSubsystems().contains(subSysMod.getSubSystem())
1
2可以改为
ArrayList moduleDependees = smodule.getDependSubsystems(); String ourSubSystem = subSysMod.getSubSystem(); if (moduleDependees.contains(ourSubSystem))
1
2
3归属于署名。
/* Added by Rick */
, 有Git就无需这样做。注释掉的代码。直接把代码注释掉是讨厌的做法,别这么干。因为其他人不敢删除注释掉的代码。
函数头。段函数不需要太多描述。为只做一件事的短函数选个好名字,通常比写函数头注释要好。
非公共代码的JavaDoc。虽然Javadoc对于公共API非常有用,但对于不打算公共用途的代码就令人厌烦。
范例(书P66-P69)
# 5. 格式
# 5. 1 垂直格式
垂直方向上的靠近。靠近的代码行则暗示了它们之间的紧密关系。所以,紧密相关的代码应该相互靠近。
垂直距离。变量声明应尽可能靠近其使用位置。相关函数,若某个函数调用了另外一个,就应该把它们放到一起,而且调用者应该尽可能放在被调用者上面。
概念相关。概念相关的代码应该放在一起。下面函数有着极强的概念相关性,因为它们拥有共同的格式,执行同一基础任务的不同变种。
public class Assert {
static public void assertTrue(String message, boolean condition) {
if (!condition)
fail(message);
}
static public void assertTrue(boolean condition) {
assertTrue(null, condition);
}
static public void assertFalse(String message, boolean condition) {
assertTrue(message, !condition);
}
static public void assertFalse(boolean condition) {
assertFalse(null, condition);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- 垂直顺序。被调用的函数应该放在执行调用的函数下面。这样就建立了一种自顶向下贯穿原代码模块的良好信息流。
# 5.2 横向格式
建议一行在120个字符为上限。
- 水平方向上的区隔与靠近。乘法因子之间没有加空格,因为它们具有较高的优先级。加减法运算之间用空格隔开,因为优先级低。
- 可以不是用水平对齐
- 缩进。
- 团队规则。一组开发者应当认同一种格式风格,每个成员都应该采用哪种风格。可以使用风格检查器进行检查。
# 6. 对象与数据结构
# 6.1 数据抽象
public class Point {
public double x
public double y;
}
2
3
4
public interface Point {
double getX();
double getY();
void setCartesian(double x, double y);
double getR();
double getTheta();
void setPolar(double r, double theta);
}
2
3
4
5
6
7
8
第一段代码暴露了其实现,而另一个则完全隐藏了其实现。后者漂亮之处在与你不知道该实现会是在矩形坐标系中还是在极坐标系中。可能两个都不是,但是该接口还是明白无误地呈现了一种数据结构。它呈现的还不止一个数据结构。哪些方法固定了一套存取策略。你可以单独读取某个坐标,但必须通过一次原子操作设定所有坐标。
前者非常清楚地是在矩形坐标系中实现,并要求我们单个操作哪些坐标。这就暴露了实现。实际上,即便变量都是私有,而且我们也通过变量去之气和复制器使用变量,其实现仍然暴露了。
隐藏实现并非只是在变量之间放上一个函数层那么简单。隐藏实现关乎抽象。类并不简单地用去之气和赋值器将其变量推向外间,而是暴露抽象接口,以便用户无需了解数据的实现就能操作数据本体。
下面两段代码,前者使用具象手段与机动车的燃料层通信,而后者则采用百分比抽象。你能确定前这里面都是些变量存取器,而无法得知后者中的数据形态。
public interface Vehicle {
double getFuelTankCapacityInGallons();
double getGallonsOfGasoline();
}
2
3
4
public interface Vehicle {
double getPercentFuelRemaining();
}
2
3
以上两段代码以后者为佳。我们不愿暴露数据细节。更愿意以抽象形态表述数据。这并不只是用接口/或赋值器、取值器就万事大吉。要以最好的方式呈现某个对象包含的数据。
# 6.2 数据、对象的反对称性
下面的代码为过程式代码形状的范例。Geometry类操作三个形状类。形状都是简单的数据结构,没有任何行为。所有行为都在Geometry类中。
pubilc class Squrae {
public Point topLeft;
public double side;
}
public class Rectangle {
public Point topLeft;
public double height;
public double width;
}
public class Circle {
public Point center;
public double radius;
}
public class Geometry {
public final double PI = 3.141592;
public double area(Object shape) throws 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 NoSuchShapeException();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
面向对象程序员可能会对此嗤之以鼻,抱怨说这是过程式代码--不过这种嘲笑并不完全正确。想想看,如果给Geometry类添加一个primeter()函数会怎样。那些形状类根本不会因此而受影响!另一方面,如果添加一个新形状,就要修改Geometry中的所有函数来处理它。
现在看看下面的面向对象方案。area()方法是多态的。不需要有Geometry类。所以,添加一个新形状,现有的函数一个也不会受到影响,而当添加新函数是所有的形状都得做修改!
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 doubel PI = 3.1415926;
public double area() {
return PI * radius * radius;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
我们再次看到这两种定义的本质:它们是截然队里的。这说明了对象与数据结构之间的二分原理: * 过程式代码(使用数据结构的代码)便于在不改动既有数据结构的前提下添加新函数。面向对象代码便于在不改动既有函数的前提下添加新类。 * 过程式代码难以添加新数据结构,因为必须修改所有函数,面向对象代码难以添加新函数,因为必须修改所有类。
在任何一个复杂的系统中,都会有需要添加新数据类型而不是新函数的时候。这时,对象和面向对象就比较适合。另方面,也会有想要添加新函数而不是数据类型的时候。在这种情况下,过程式代码合数据结构更适合。
# 6.3 德墨忒耳律
著名的德墨忒尔律(The Law of Demeter)认为,模块不应了解他所操作对象的内部情形。如上节所见,对象隐藏数据,暴露操作。这意味着对象不应该通过存取暴露其内部结构,因为这样更像是暴露而非隐藏起内部结构。
方法不应调用有任何函数返回的对象的方法。下面代码违反了德墨忒尔律,因为他调用了getOptions()返回值的getScratchDir()函数,又调用了getScratchDir()返回的getAbsolutePath()方法。
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
这类代码常常被称作火车失事,因为他看起来就像是一列火车。这类连串的调用通常被认为是肮脏的风格,应该避免。做好做类似下面的划分:
Options opts = ctxt.getOptions(); File scratchDir = opts.getScratchDir(); final String outputDir = scratchDir.getAbsolutePath();
1
2
3上列代码是否违反了德墨忒尔律?当然,模块知道ctxt对象包含有多个选项,每个选项中有一个临时目录,而每个临时目录都有一个绝对路径。对于一个函数,这些知识真够丰富的。调用函数懂得如何在一大堆不同对象间浏览。
这些代码是否违反德墨忒尔律,取决于ctxt、Options和ScratchDir是对象还是数据结构。如果是对象,则它们的内部结构应当隐藏而不暴露,而有关其内部细节的知识就明显违反了德墨忒尔律。如果ctxt、Options和ScratchDir只是数据结构,没有任何行为,则它们自然会暴露其内部结构,德墨忒尔律也就不适用了。
隐藏结构。即使ctxt、Options和ScratchDir是红油这是行为的对象又怎样呢?对于对象应隐藏其内部结构,我们就不该能够看到内部结构。这样一来,如何才能取得临时目录绝对路径呢?
ctxt.getAbsolutePathOfScratchDirectoryOption();
1或者
ctx.getScratchDirectoryOption().getAbsolutePath();
1- 第一种方案可能导致ctxt对象中方法的暴露。第二种方案是在假设getScratchDirectoryOption()返回一个数据机构而非对象。两种方案感觉都不好。
- 如果ctxt是一个对象,就应该要求他做点什么,不该要求他给出内部情形。那我们为何还要得到临时绝对路径呢?
String outFile = outputDir + "/" + className.replace('.', '/') + ".class"; FileOutputStream fout = new FileOutputStream(outFile); BufferedOutputStream box = new BufferedOutputStram(fout);
1
2
3- 这种不同层级细节的混杂有点麻烦。据点、斜杆、文件扩展名和File对象不该如此随便地混杂到一起。不过,撇开这些毛病,我们发现,取得临时目录绝对路径的初衷是为了创建指定名称的临时文件。所以直接让ctxt对象类做这件事如何?
BufferedOutputStream bos = ctxt.createScratchFileSteam(classFileName);
ctxt隐藏了其内部结构,防止当前函数因浏览它不该知道的对象而违法德墨忒尔律。
# 6.4 数据传送对象
最为精炼的数据结构,是一个只有公共变量、没有函数的类。这种数据结构有时被称为数据传送对象,或DTO(Data Transfer Objects)。更常见的豆(bean)结构。豆结构拥有由赋值器和取值器操作的私有变量。对豆结构的半封装会让某些OO纯化论者感觉舒服一些,通常没有其他好处。如下:
public class Address {
private String street;
private String streetExtra;
private String city;
public Address(String street, String streetExtra, String city) {
this.street = street;
this.streetExtra = streetExtra;
this.city = city;
}
public String getSteet() {
return street;
}
public String getStreetExtra() {
return streetExtra;
}
public String getCity() {
return city;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Active Record是一种特殊的DTO形式:拥有公共(或可豆式访问)变量的数据结构,但通常也会拥有类似save和find这样的可浏览方法。Active Record一般是对数据库表或其他数据源的直接翻译。
我们不幸经常发现开发者往这类数据结构中塞进业务规则方法,把这类数据结构当成对象来用。这是不明智的行为,因为它导致了数据结构和对象的混杂体。
解决方案就是把Active Record当做数据结构,并创建包含业务规则、隐藏内部数据(可能就是Active Record的实体)的独立对象。
# 6.5 小结
对象暴露行为,隐藏数据。便于添加新对象类型而无需修改既有行为,同时也难以在既有对象中添加新行为。数据结构暴露数据,没有明显的行为。便于向既有数据结构添加新行为,同时也难以向既有函数添加新数据结构。
在任何系统中,我们有时会希望能够灵活地添加新数据类型,所以更喜欢使用在这部分使用对象。另外一些时候,我们希望能灵活地添加新行为,这时我们更喜欢使用数据类型和过程。优秀的软件开发者不带成见地了解这种情形,并依据手边工作的性质选择其中一种手段。
# 7. 错误处理
错误处理很重要,但如果它搞乱了代码逻辑,就是错误的做法。
# 7.1 使用异常而非返回码
以前许多语言不支持异常,通常设置一个错误标识或者返回给调用者检查的错误码。
public class DeviceController {
public void sendShutDown() {
if (handle != DeviceHandle.INVALID) {
retriveDeviceRecord(handle);
if (record.getStatus() != DEVICE_SUSPENDED) {
pauseDeviceWorkQueue(handle);
closeDeviceWorkQueue(handle);
closeDevice(handle);
} else {
logger.log("Device suspended. Unable to shut down");
}
} else {
logger.log("Invlid handle for: " + DEV1.toString());
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这类手段问题在于,它们搞乱了调用者的代码。调用者必须在调用之后即刻检查错误。不幸的是,这个步骤很容易被遗忘。所以遇到错误是最好抛出一个异常。调用代码很整洁,其逻辑不会被错误处理搞乱。
public class DeviceController {
...
public void sendShutDown() {
try {
tryToShutDown();
} catch (DeviceShutDownError e) {
logger.log(e);
}
}
private void tryToShutDonw() throws DeviceShutDownError {
DeviceHandle handle = getHandle(DEV1);
DeviceRecord record = retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}
private DeviceHandle getHandle(DeviceId id) {
...
throw new DeviceShutDownError("Invalid handle for: " + id.toString()):
...
}
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 7.2 先写Try-Catch-Finally语句
我们要编写访问某个文件并读出一些序列化对象的代码,先写一个单元测试,其中显示当文件不存在是将得到一个异常:
@Test(excepted = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
sectionStore.retrieveSection("invalid - file");
}
// 该测试驱动创建以下占位符:
public List<RecordeGrip> retrieveSection(String sectionName) {
// dummy return until we have a real implementation
return new ArrayList<RecordeGrip>();
}
2
3
4
5
6
7
8
9
10
11
测试失败了,因为以上代码并未抛出异常,做以下修改后测试通过,因为捕获了异常。可以继续使用测试驱动TDD方法构建剩余的代码逻辑。
public List<RecordeGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStram(sectionName);
stream.close();
} catch (Exception e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordeGrip>();
}
2
3
4
5
6
7
8
9
10
尝试编写强行抛出异常的测试,在往处理器中添加行为,使之满足测试要求。结构就是要先构造try代码块的事务范围,而且也会帮助你维护好该范围的事务特征。
# 7.3 使用不可控异常
# 7.4 给出异常发生的环境说明
抛出的每一个异常都应该提供足够的环境说明,以便判断错误的来源和处所。应创建信息充分的错误消息,并和异常一起传递出去:包括失败的操作和失败类型。乳沟程序有日至系统,传递足够的信息给catch块,并记录下来。
# 7.5 依调用者需要定义异常类
最终的是要考虑异常如何捕获。看如下代码:
ACMPort port = new ACMEPort(12);
try {
port.open();
} catch (DeviceResponseException e) {
reportPortError(e);
logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e) {
reportPortError(e);
logger.log("unlock exception", e);
} catch (GMXError e) {
reportPortError(e);
logger.log("device response exception");
} finally {
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
上面代码包含一堆重复代码。可以通过打包调用API、确保它返回通用异常类型,从而简化代码:
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
...
}
2
3
4
5
6
7
8
9
LocalPort类就是一个简单的打包类,捕获并翻译有ACMPort类抛出的异常:
public class LocalPort {
private ACMPOrt innerPort;
public LocalPort(int portNumber) {
innerPort = new ACMEPort(portNumber);
}
public void open() {
try {
innerPort.open();
} catch (DeviceResonseException e) {
throw new PortDeviceFailure(e);
} catch (ATM1212UnlockedException e) {
throw new PortDeviceFailure(e);
} catch (GMXError e) {
throw new PortDeviceFailure(e);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
类似我们为ACMEPort定义的这种打包类非常有用。实际上,将第三方API打包是个良好的实践手段。当你打包一个第三方API,你就降低了对它的依赖:未来你可以不太痛苦的改用其他代码库。
# 7.6 定义常规流程
有时不希望打包外部API以抛出自己的异常。下面的笨代码来自某个记账应用的开发总计模块:
try {
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
} catch (MealExpensesNotFound e) {
m_total += getMealPerDiem();
}
2
3
4
5
6
业务逻辑是如果消耗了餐食则计入总额中。如果没有消耗,则员工得到当日餐食补助。异常打断了业务逻辑。如果不出处理特殊情况会不会好一些?那样的话代码看起来会更简洁:
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
2
继续优化:可以修改ExpenseReportDAO,使其总是返回MealExpense对象。如果没有餐食消耗,就返回一个返回餐食补贴的MealExpense对象。
public class PerDiemMealExpense implements MealExpenses {
public int getTotal() {
// return the per diem default
}
}
2
3
4
5
这种做法叫做特例模式(SPECIAL CASE PATIERN)。创建一个类或配置一个对象,来处理特例。你来处理特例,客户代码就不用应付异常行为了。异常行为被封装到特例对象中
# 7.7 别返回null值
别返回null值:
public void registerItem(Item item) {
if (item != null) {
ItemRegistry registry = peristentStore.getItemRegistry();
if (registry != null) {
Item existing = registry.getItem(item.getID());
if (existing.getBillingPeriod().hasRetailOwner()) {
existing.register(item);
}
}
}
}
2
3
4
5
6
7
8
9
10
11
这种代码十分糟糕。增加自己的工作量,并给调用者添乱。只要一处没有处理null,程序就会失控。
如果在调用第三方API中可能返回null值的方法,可以考虑用心方法打包这个方法,在新方法中抛出异常或返回特例对象。
List<Employee> employees = getEmployees();
if (employees != null) {
for (Employee e: employees) {
totalPay += e.getPay();
}
}
2
3
4
5
6
现在getExployees可能返回null,如果修改getExployees,返回空列表,就更简洁了:
List<Employee> employees = getEmployees();
for (Employee e: employees) {
totalPay += e.getPay();
}
2
3
4
public List<Employee> getEmployees() {
if (.. there are no employees ..) {
return Collections.emptyList();
}
}
2
3
4
5
Java的Collections.emptyList()方法,返回一个预定义不可变的列表,可用于这种目的。这样就尽量避免NullPointerException的出现,代码就更简洁了。
# 7.8 别传递null值
在方法中返回null值是糟糕的做法,但将null值传递给其他方法就更糟糕了。除非API要求你向它传递null值,否则就要尽可能避免传递null值。例如:
public class MetricsCalculator
{
public double xProjection(Point p1, Point p2) {
return (p2.x = p1.x) * 1.5;
}
...
}
2
3
4
5
6
7
如果传入空值:calculator.xProjection(null, new Point(12, 13));
则程序会报错,可能会使用以下两种方式处理:
public class MetricsCalculator
{
public double xProjection(Point p1, Point p2) {
if (p1 == null || p2 == null) {
throw InvalidArgumentException("invalid argument for MetricsCalculator.xProjection"););
}
return (p2.x = p1.x) * 1.5;
}
...
}
2
3
4
5
6
7
8
9
10
public class MetricsCalculator
{
public double xProjection(Point p1, Point p2) {
assert p1 != null : "p1 should not be null";
assert p2 != null : "p2 should not be null";
return (p2.x = p1.x) * 1.5;
}
...
}
2
3
4
5
6
7
8
9
以上两种方式并未解决问题。如果传入null,还是会得到运行时错误。恰当的做法是禁止传入null值。
# 7.9 小结
如果将错误处理隔离看待,独立于主要逻辑之外,就能写出强固而贞洁的代码。
# 9. 单元测试
# 9.1 TDD 三定律
- 在编写不能通过的单元测试前,不可编写生成代码
- 只可编写刚好无法通过的单元测试,不能编译也算不通过
- 只可编写刚好足以通过当前失败测试的生成代码
# 9.2 保持整洁的代码
测试diamante和生产代码一样重要。它需要被思考被设计和被照料。它该像生产代码一般保持整洁。 正是单元测试让你的代码可扩展、可维护、可复用。原因很简单,有了测试,就不担心对代码的修改后引入bug。覆盖了生产代码的自动化单元测试程序组尽可能的保持设计和交媾的整洁。测试带来了一切好处,因为测试使变动变得可能。
# 9.3 整洁的测试
整洁的测试的重要要素:可读性。
- 面向特定领域的测试语言。打造一套包装API的函数和工具代码,这样就能更方便的编写测试,写出来的测试页更便于阅读。测试代码也需要重构。
- 双重标准。测试API代码和生产代码有一套不同的工程标准。测试代码应当简单、精悍、足具表达力,但它该和生产代码一般游戏哦啊。毕竟它是在测试环境而非生产环境中运行,这两种环境有着截然不同的需求。
# 9.4 每个测试一个断言
最好的说法是单个测试中的断言数量应该最小化(可以不止一个断言)。
每个测试函数中只测试一个概念。不要写超长的测试函数,测试完这个又测试那个。
# 9.5 F.I.R.S.T
整洁的测试遵循以下5条规则:
- 快速(Fast)。测试应该能够快速运行。测试运行缓慢,你就不会想要频繁的运行它。如果不频繁运行,就不能尽早发现问题,也无法青叶修正,从而也不能轻而易举的清理代码。
- 独立(Independent)。某个测试不应为下一个测试设定条件。应该可以单独运行每个测试,以及任何顺序运行测试。当测试忽下那个依赖时,头一个没通过就会导致一连串的测试失败,是问题诊断变得困难,隐藏了下级错误。
- 可重复(Repeatable)。测试应当能够在生成环境、质检环境中运行测试,也能够在无网络的列车上用笔记本电脑运行测试。如果测试不能再任意环境中重复,你就总会有个解释其失败的接口。
- 自足验证(Self-Validating)测试应该有布尔值输出。无论是通过或失败,都不应该查看日志文件来确定该测试是否通过。不应该手工对比两个不同文本文件来确定测试是否通过。如果测试不能自足验证,对失败的判断就会变得历来主观,而运行测试也需要更长的手工操作时间。
- 及时(Timely)测试应测试赛编写。单元测试应该恰好在十七通过的生产代码之前编写。如果在编写生成代码之后编写测试,你会发现生成代码难以测试。你可能认为某些生产代码本身难以测试。你可能不会去设计可测试的代码。