坏代码的最佳实战
习惯了学习正确的东西,让我们看看错误的东西吧。 本文列出的,都是错误的例子!!!
学习编程
阅读编程手册,忽略练习部分
又不是说为了学习牛顿力学就要自动动手造一个巨型离心机。看看书一样学编程。
有异议
就像学习人类语言一样,学习编程语言需要不断的练习练习再练习
按步就班地学习
如果自己挑选习题来练习,那么你就没法控制自己练习的强度和全面性,所以不要“挑食”。
有异议
挑选自己喜欢的项目来练习,你就会享受这个过程,给你更大的动力去学习。
做一个脚本小子
碰到一个问题,上网找答案(也许这个答案并不好)。复制粘贴代码,好像它们是你自己写的一样。
有异议
学习好的例子是有益的,但是你要自己努力去理解为什么那些解决方案是好的。
独自学习
要成为独立自主的新时代青年
有异议
和他人一起学习,你可以:
- 学习他人的想法和观点来提高自己
- 通过教育别人来巩固自己的知识
- 通过别人的解释来提高自己的理解
- 更好的提问与回答的能力
- 接触到真实项目中团队的交流方式
选择你的工具
作为一个初学者,使用方便的工具可以辅助你的学习,让你能用更少的痛苦来完成挑战,熟练使用 IDE 还能让你给周围的人留下深刻印象。
有异议
应该根据自己的能力来选择工具。 如果你还处于学习编程的早期,比如还不熟悉变量和循环,那么一个可视化的编程环境会更适合你。 如果你熟悉了基本的编程套路,那么就可以选择 IDE 这样更高级的工具。工具可以自动化任务,但是记住。 你必须要了解这个任务,只是需要工具来更快的完成它,如果不是这样,使用工具会让你得不到锻炼。
使用最时髦的工具
最新的 iPhone, 最大的屏幕,新版的包含新特性的 JDK, 最时髦的版本控制工具,这些新的东西带来新的特性,让你学到更多。
有异议
没有银弹!如果过渡追求时髦,就会陷入选择恐惧。何况一个好的程序员,一定是会使用多种工具的。 每过一段时间,都会出现一个新的工具,号称比之前的工具都要更棒,这个新的工具是历史上最好的,直到下一个这样的工具出现。。。
布局和结构
让空格和缩进不一致
对于格式不敏感的编程语言来说,无论有没有缩进,都不影响计算机对代码的执行,所以,不用浪费时间在格式化代码上。
有异议
代码,是用来和其他人交流的。计算机认识的,不是 Java 这样的高级语言,而是二进制代码。 而高级语言存在的意义就是让程序员们可以更方便的编写和理解代码。 缩进让代码更清晰易懂,也更容易发现 bug.
混用空格和tab
只要能起到缩进的作用,空格和 tab 都可以混着用
有异议
不一致是代码混乱的一大元凶。Java 代码规范就规定不要使用 tab, 另外现代编辑器和 IDE 都会强制规定空格和 tab 的规则。 另外一些 code review 的工具也会进行检查。
没用的代码
代码是复杂的,如果代码中有无用的部分,或者永远不会被执行的部分,那会加大程序员的认知成本。 都是,当你发现这样的代码时,你会害怕,如果哪天我又需要它们了呢? 那我就把它们注释掉吧,反正编译器不会在意这些注释。
有异议
如果是没用的代码,那么就删掉它们。所有没用的代码,都是浪费,甚至会成为潜在的 bug. 不要担心万一以后需要它们,你可以使用版本控制工具找回它们。
不要写注释
不要写注释,因为难写的东西,读起来也会很难。如果需要注释,那就说明代码难读,应该让代码变得简单,而不是添加注释。
有异议
注释的意义是让代码变得清晰。对于简单的代码的确不需要注释,但是对于复杂一些的代码,注释是必要的。 另外注释还可以用于生成文档。
重复代码的注释
下面的注释解释了 if 条件里发生了什么
if (numbers[j -1] > numbers[j]) {
// numbers[j -1] is assigned to temp
Integer temp = numbers[j - 1];
// numbers[j] is assigned to numbers[j - 1];
numbers[j - 1] = numbers[j];
// temp is assigned to numbers[j]
numbers[j] = temp;
}
有异议
好的注释会解释代码*背后*的含义。它们*添加×信息,而不是简单的重复代码说明的内容。 程序员明白单独的步骤是怎么回事,一般需要注释的是整体的行为,比如上面的例子可以改成下面这样:
// Compare the values of two consecutive numbers.
// Swap their positions in the numbers array if
// the earlieer is greatre than the latter
if (numbers[j -1] > numbers[j]) {
Integer temp = numbers[j - 1];
numbers[j - 1] = numbers[j];
numbers[j] = temp;
}
过期的注释
当你修改了一段包含注释的代码,而没有修改它的注释后。别人会发现这样一段和注释不同的代码。 那么,可能的问题是:
- 代码有问题
- 注释有问题
你修改了一个地方,却造成了两处可能存在的错误,真是厉害。
有异议
修改代码时必须要同步更改注释,就算有同事给你 code review, 但一般 review 的时候只会注意到修改的部分, 不容易发现没修改的部分的问题。
不划分子程序
In general, Bigger is better.
所以不要把代码分成子程序,大段的代码是更好的。
有异议
代码可以分为两层:低层和高层。 低层更偏重细节,比如简单的语句和变量的赋值。 高层更远离细节,侧重描述概念而不是实际的细节。
把代码分成子程序,能代码更清晰,也可以去除重复代码,封装变化。
Duplicate! Spread stuff around; don't consolidate things.
变量
没有意义的名字
想象一下,amount 这个词由6个字母组成,如果输入20次,那就是120个字母。 不如我们把它改成 a 吧,这样就节省了 100 个字母的输入时间了,何况你的同事一定能理解 a 就是 amount 的意思:)
有异议
现代的 IDE 都能自动补全变量名,你的同事也不必去猜变量名的意思,阅读代码会更流畅。
去掉元音字母
把 count 写成 cnt, price 写成 prc, 这样既减少了输入量,也不太影响代码的阅读。
有异议
看看这些 Style Guide 是怎么说的:
- 如果必须简写,把它限制在一个简单的上下文中 (GNU)
- 缩短单词,但不要减少单词的字母 (Google)
- 使用通用的简写,比如 num 和 url. 不要混用简写和全名(Apple)
懒得命名
既然命名那么麻烦,那就别费力了,随便取个名字,反正不影响功能的实现。
String string;
int number;
boolean flag;
有时声明变量也懒得多打字,一起声明吧
int scoreBob, scoreJohn = 10;
有异议
这个 string 是什么字符串?这个 number 是哪个数字?这个 flag … 当然是个布尔阿,这有什么意义呢? John 的成绩是 10, 但是 Bob 是 0, 可怜的 Bob …
平面作用域
你没法确定一个变量会在什么地方被用到,那为什么不让变量可以被全局访问呢?
有异议
全局变量会造成不当的复用,变量之前的状态可能会影响之后被调用时的行为。 因为全局变量可以在任何地方被修改,所以要跟踪它的状态会非常困难。
把 number 作为神秘代码
假设有一个硬件设备,会返回一个错误码,所以我们可以这样接收:
int status_code = connect_to_devise();
switch (status_code) {
case 0:
display_info(info_messages[1]);
break;
case 1:
reattempt();
break;
case 2:
display_warning(warning_messages[3]);
break;
}
有异议
虽说返回错误码挺常见的,但是我们可以有更好的方法。 比如 Java 可以用枚举来代表数字:
public enum DeviseStatus {
SUCCESS = 0,
WARNING_CONNECTION_SLOW,
ERROR_NO_PINGBACK
}
int status_code = connect_to_devise();
switch (status_code) {
case SUCCESS:
display_info(info_messages[1]);
break;
case ERROR_NO_PINGBACK:
reattempt();
break;
case WARNING_CONNECTION_SLOW:
display_warning(warning_messages[3]);
break;
}
这样可以读性更高。
神奇的 String 可以是任何类型
布尔只能存放 true 和 false, Integer 只能存放数字,而 String 可以存放任意字符。 为什么要学那么多有限制的类型,直接把信息都放在 String 中不是很好吗
有异议
String 没什么限制是因为它能表达的意思很少,就是一串字符而已。 没有限制就意味着你没法享受到类型系统提供的验证功能。 比如:用 String 代表方向,那么如何判断 "north" 和 "North" 是不是一个方向? 如果你用 String 储存数字,那么 "100" 和 "10O" 怎么区分? 所以还是要使用更有意义的类型。
混合起来
使用集合来收集数据,在需要的时候取出来在转换成所需的对象,比如:
ArrayList<Object> patientInfos = getPatient();
String name = (String) patientInfos.get(0);
Date dob = (Date) patientInfos.get(1);
...
有异议
记住,当语言内置的类型不能满足需求的时候,要创造自己的类型。 上面这个例子,应该改为自己创建一个 PatientRecord 类,并且创建类似 getName() getWeight() 这样的方法。
传播 Null
多你在编写自己的子程序时,确保你会返回 null, 特别是下面几种情况:
- 定义一个最终会被返回的变量时,用 null 去初始化它。
- 当子程序需要返回一个空的值时,返回 null
- 不要给你的子程序调用者任何会返回 null 的线索
有异议
对 Null 的战斗一直存在,可以靠肉眼检查代码中有没有对 null 的检查。 如果子程序必须要返回 null, 也应该在注释中强调这一点, Java 中也有 annoatation @NotNull 来帮助 IDE 做检查。 另外现代编程语言也有 Optional 类型来对抗 null.
条件
否则什么?
多做多错,所以省略 else 的情况能减少 bug 存在的可能。
有异议
省略 else 会产生潜在的错误,比如:
void calculateGrade(int score) {
if (score > 60) {
grade = "Pass"
}
}
这段代码,当成绩小于等于 60 时,它不会得到一个 Fail 的 grade.
要解决也很简单
void calculateGrade(int score) {
if (score > 60) {
grade = "Pass"
} else {
grade = "Fail"}
}
这看起来太简单了,但实际上,这是一个非常容易犯的错误,一个好的习惯是,写下 if 后先补完 else.
正常和异常
人类的心理就是这样的:他们更喜欢优先考虑正常的情况,再考虑异常的情况。 所以你的代码也要符合阅读者的预期,先考虑正常的情况,再考虑异常。
有异议
使用 guard clauses, 一开始就返回异常的情况,这样人脑就能专注于一种情况,不必陷入各类条件的判断。
阶梯条件判断
当我们碰到阶梯式的判断条件时,一般会写出下面这样的代码。
if (item.getType().equals("scannable")) {
price = item.scanBarcode();
}
else if (item.getType().equals("produce")) {
price = item.weigh();
}
else if (item.getType().equals("reduced")) {
price = item.keyInPrice();
}
有异议
一般来说,碰到这样的 if 条件,说明你需要一个更好的设计。 比如 switch 语句:
switch (item.getType()) {
case "scannable":
price = item.scanBarcode();
break;
case "product":
price = item.weigh();
break;
case "reduced":
price = item.keyInPrice();
break;
}
滥用表达式
越大越好,复杂的比简单要好。下面看个例子:
String code = getSwiftCode();
String mode = getMode();
if (((code.length() == 8 || code.length() == 11)) && (code.substring(4,6).equals("DE")) && ((mode + code.charAt(7)).equals("L1") || (mode + code.charAt(7)).equals("L2"))) {
// COde checks out
}
验证 SWIFT codes 就是这么复杂,我有什么办法呢?
有异议
你可以让表达式更具可读性,就算它们本身很复杂。 上面的那段代码难读的原因是:
- 一行包含了太多内容
- 一大堆的括号让人费解
- 子表达式需要被计算,增加阅读负担
我们可以先把子表达式提取:
String tag = getMode() + mode.charAt(7);
if (((code.length() == 8 || code.length() == 11)) && (code.substring(4,6).equals("DE")) && ((tag).equals("L1") || (tag).equals("L2"))) {
// ...
}
然后把单独的规则用换行隔开
if (((code.length() == 8 || code.length() == 11)) &&
(code.substring(4,6).equals("DE")) &&
((tag).equals("L1") || (tag).equals("L2"))) {
// ...
}
最后,把单独的规则提取出来
if ((validLength(code)) &&
(validCountry(code)) &&
(validMode(code, tag))) {
//....
}
// ... private methods
不要不用双重否定
人类倾向于和过多的否定作斗争。好消息是:人类语言中的多重否定也能用在编程语言中。
有异议
人类更擅长处理正向的逻辑。比如:
while (squaresUnavailable != 9 && noLinesAchieved)
可以改成这样
while (squaresAvailable > 0 && !linesArchived)
空隙和重叠
处理范围的问题时很容易出错。看看下面这个例子:
if (score < 40) { grade = "F"; }
else if (score > 40) { grade = "E"; }
else if (score > 50) { grade = "D"; }
else if (score > 60) { grade = "C"; }
else if (score > 70) { grade = "B"; }
else if (score > 80) { grade = "A"; }
有异议
这里有两个 bug, 第一个是 socre = 40 被漏掉了,第二个是所有大于 40 的条件都会被忽略。 可以这样修改:
if (score > 80) { grade = "A"; }
else if (score > 70) { grade = "B"; }
else if (score > 60) { grade = "C"; }
else if (score > 50) { grade = "D"; }
else if (score > 40) { grade = "E"; }
else { grade = "F"; }
循环
Collections
当你要遍历一个集合时,使用 for 循环是一个最好的方法
for (int i = 0; i < shoppingList.size(); i++) {
Grocery g = shoppingList.get(i); System.out.println(g.getPrice());
}
有异议
使用一个循环的计数器 i 来跟踪当前在集合中的位置,可能会有问题。
- 如果是 Set 这样的结构,那就根本没有下标的用法
- 维护一个下标可能会带来额外的问题,不如使用 foreach 的循环
for (Pet p : pets) {
p.feed();
}
Ranges
看一下这个 FizzBuzzes 的例子:
for (int i = 5; i <= 100; i += 5) {
if (i % 3 == 0) {
System.out.println(i);
}
}
loop 版本
int i = 5;
while (i <= 100) {
if (i % 3 == 0) {
System.out.println(i);
i += 5;
}
}
找到问题了吧,i+=5 写错地方了,这个循环会变成死循环。所以说不要写 while 循环。
有异议
并不是说 loop 就不好,只是由于 loop 的条件限制并没有在一起定义,所以更容易出现错误。
Arbitrary Iterations
一个读取文件内容的例子:
List<String> lines = Files.readAllLines(Paths.get(filename), StandardCharsets.UTF-8);
for (String line: lines) {
// ...
}
一个接收用户输入的例子:
while(true) {
input = keyboard.next();
System.out.println(input);
}
第一个例子的问题是,会占用太多内存。 第二个例子的问题是,用户无法退出。 可见,使用循环是很不好的。
有异议
第一个例子,可以一行一行地读取:
BufferedReader fileReader = new BufferedReader(new FileReader(file));
String line = fileReader.readLine();
while (line != null) {
//
line = fileReader.readLine();
}
第二个例子,可以是 do while 解决:
Scanner keyboard = new Scanner(System.in);
do {
System.out.print("What now? >");
input = keyboard.next();
String response = processInput(input);
System.out.println(response);
} while (! input.equals("quit"));
无限循环
听说过 "停机问题" 吗?所以你不用担心自己的代码会陷入江局。
有异议
虽然没有办法可以解决停机问题,但是我们可以验证自己的 loop 方法有没有覆盖所有的条件。 讲个最有名的例子:
Int year = 1980;
public void convertDays(int days) {
while (days > 365) {
if (isLeapYear(year)) {
if (days > 366) {
days -= 365;
year += 1;
}
}
else {
days -= 365;
year += 1;
}
}
}
当时间是 2008 年 12 月 30 日的时候,代码会陷入死循环。
采取预防措施
看下面的例子,我们使用了 i != 50 来确保循环会停止。
int i = 0;
int year = 2016;
while (i != 50) {
if (isLeapYear(year + 1)) {
System.out.println(i + " is a leap year");
}
i++;
}
有异议
i != 50 的写法不够健壮,想象一下, 如果程序修改为
int i = 0;
int year = 2016;
while (i != 50) {
if (isLeapYear(year + 1)) {
System.out.println(i + " is a leap year");
}
i += 4;
}
这样一来,i 就会从 48 跳到 52, 程序会进入死循环。 更好的写法是 i < 50
不合适的退出
循环做一件事,如果达到目的了,就停下,这是很正常的。看下面的例子:
while (true) {
// If it's chocolate, I want it!
if (currentSnack.getType().equals("Chocolate")) {
chosenSnack = currentSnack;
break; }
// Otherwise, I'll take a biscuit if it doesn't
// contain gluten.
else if (currentSnack.getType().equals("Biscuit")) {
boolean containsGluten = allergiesInfo.hasGluten(currentSnack);
if (!containsGluten) { chosenSnack = currentSnack; break;
} }
if (snackIterator.hasNext()) {
// This didn't satisfy me, move to next one
currentSnack = snackIterator.next();
}
else {
// Didn't find any snacks at all!
break;
}
}
有异议
多个退出点会迫使读者去花更精力去关注可能退出的情况,也更容易遗漏导致出错。
// 使用 for 循环,这样结束条件更显眼
for (int i = 0; chosenSnack == null && i < snack.size(); i++) {
currentSnack = snacks.get(i);
// 提取出一个 switch 方法,这样 break 只会退出 switch
switch (currentSnack.getType()) {
case "Chocolate":
chosenSnack = currentSnack;
break;
case "Biscuit":
if (allergiesInfo.hasGluten(currentSnack)) {
chosenSnack = currentSnack;
}
break;
}
}
Long Loops
长的循环迫使阅读者一次性记下大量细节,也更容易出现修改终止条件的情况。 因此编写一段很长的 loop 代码可以让人留下深刻印象,读者必须通过滚动和搜索才能看懂。
有异议
可以把长的 loop 抽出子程序:
while (game.isRunning()) { // ...
// Lots of code for checking user input
// ...
// Lots of code for updating position
// of each object in the game world
// ...
// Lots of code for detecting
// collisions between objects
// ...
// Lots of code for possibly creating
// new objects
}
变成:
while (game.isRunning()) { getUserInput();
updatePositions();
detectCollisions();
createNewObjects();
}
Complex Loops
研究表明,loop 这种形式很不适合人类理解。为了增加理解的难度,下面有几个建议:
- 把结束条件的修改散落到各个地方。
- 使用好地 break 和 continue 来使得路径变复杂。
- 增加嵌套层次。
有异议
除了把上面的三点纠正外,还可以考虑使用函数式风格的程序:
Iterator<Integer> numbersIterator = numbers.iterator();
Set<Integer> primeNumbers = new HashSet<>();
while (numbersIterator.hasNext()) {
int n = numbersIterator.next();
boolean isPrime = true;
for (int i = 2; isPrime && i <= n / 2; i++) {
if (n % i == 0) {
isPrime = false;
}
}
if (isPrime) {
primeNumbers.add(n);
}
}
改成:
public boolean isPrime(int n) {
return IntStream.rangeClosed(2, n / 2) .noneMatch(i -> n % i == 0);
}
// ...
Set<Integer> primeNumbers = numbers.stream()
.filter(n -> isPrime(n))
.collect(Collectors.toSet());
子程序
把你的子程序搞的很大
正如之前所说的,越大越好。如果你把大段的代码分割成子程序,那么就会让读者有机会去细读你的每个子程序。
有异议
有经验的程序员会分割大的子程序。比如,子程序有大量细节,很难一次性理解,另外对于不同的任务,也要分解它们,方便复用。 大的子程序也很难维护,对子程序的一部分修改很容易会影响到其他部分。 大的子程序也更容易出 bug, 把子程序维持在小的规模可以让 bug 更少。
坏的名字
子程序也需要名字,尽量取一些诸如 doProcess 或者 runComputation 之类的名字,确保它们没有特殊的意义。 另外,也可以取一个不能完整描述子程序功能的名字,比如名字叫 searchInFile, 但实际搜索内容后还会把文件删除。 虽然这样的子程序会把事情搞的一团糟,但是这和名字又有什么关系呢?
有异议
糟糕的名字会让开发周期更长并且更容易出问题。你的同事会因为你清晰明确的命名而感谢你的。
高复杂度
你可以把子程序当作把不同代码块拼接的东西,最后的结果取决你拼接的技术。 如果你的子程序充满了复杂的 loop 或者条件语句,那么这个子程序必然是很复杂的。 每当你你一个额外的 loop 或者条件语句拼接到子程序中时,你就增加了理解子程序的难度。
有异议
要计算程序的复杂度有很多办法,我们用一种比较简单的方法,一下行为会增加复杂度
- 一个条件语句或者循环结构
- 一个 binary 操作在一个表达式中(比如 && 和 ||)
下面是减少复杂度的一些方法
- 简化决策点的表达式
- 去除一个子程序中的重复代码
- 把代码的复杂部分移到它自己的子程序中。
太多的目的
如果你在野外求生,你希望自己只有一把轻薄的小刀,还是一把瑞士军刀? 代码也一样,一个子程序能做的越多,它就越好。 比如:
void acceptOrder(CustomerOrder order) { // Validate it
if (order.getName().length() == 0 && order.getItemNumber() == 0) {
// Put up error message
}
// Print it
System.out.println("Order: " + order.getId());
System.out.println("Name: " + order.getName());
System.out.println("Items:");
for (OrderItem item : order.getItems()) {
System.out.println(" - " + item);
}
// Save it
DbConnection conn = openDbConnection();
conn.saveOrder(order);
conn.close();
}
可以这样改:
void acceptOrder(CustomerOrder order, boolean printOrder) {
// Validate it
if (order.getName().length() > 0 && order.getItemNumber() > 0) {
// Put up error message
}
// Print it
if (printOrder) {
System.out.println("Order: " + order.getId());
// etc...
}
// Better still would be to extract each individual task into its own subroutine:
boolean isValid(CustomerOrder order) {
return order.getName().length() > 0 && order.getItemNumber() > 0;
}
void printOrder(CustomerOrder order) {
System.out.println("Order: " + order.getId());
System.out.println("Name: " + order.getName());
System.out.println("Items:");
for (OrderItem item : order.getItems()) {
System.out.println(" - " + item);
}
}
void saveOrderToDb(CustomerOrder order) {
DbConnection conn = openDbConnection();
conn.saveOrder(order);
conn.close();
}
}
过渡使用参数
子程序需要信息才能工作,最好的方式就是通过参数。所以参数越多越好。
void processCustomer(String forename, int age, List<Order> orders,
String phoneNumber, String surname,
Date dateOfBirth, String mothersMaidenName, boolean marketingEmails)
有异议
参数过多可能的理由:
- 子程序尝试做太多的事情
- 大多数或全部的参数应该被定义为一个新的类型
// We created this new class...
class Customer {
String forename;
String surname;
Date dateOfBirth;
String mothersMaidenName; boolean sendMarketingEmails; List<Order> orders;
}
// ...
// ... and replaced all the old parameters.
void addNewCustomer(Customer newCustomer)
防御性编程
进攻是最好的防守,因此你可以写出这样的代码:
void shoutMessage(String message) {
System.out.println(message.toUpperCase());
}
当你的同事调用是,可能会出现空指针异常,不过这可不是你的问题。
有异议
子程序之间的边界处会出现大量的错误,不合法的数据会造成错误,因此参数在被使用前总是应该被检查。
void shoutMessage(String message) {
if (message != null) {
System.out.println(message.toUpperCase());
}
}
检查参数的几个例子:
- 确保对象不为空,在调用它们的方法时
- 验证数字,在做数学运算前(除数不能为0等)
- 检查特殊的日期格式
- 在获取文件内容前确认文件被打开并且可读
为返回值把关
一个子程序如果要返回一个空集合,那么就 return null, 如果一个子程序出现了异常,那么 return null 总之:如果有疑问,就返回 null
有异议
有比返回 null 更好的方案,比如:
- 一个子程序要返回一个集合时可以返回空集合而不是 null
- 一个子程序碰到问题时可以抛出异常而不是返回 null
- 如果你返回一个自定义的类型,可以考虑返回一个默认值而不是 null
有趣的输出参数
看看下面这段代码,运行的结果是?
void move(int x, int xDistance, int y, int yDistance) {
x = x + xDistance;
y = y + yDistance;
}
move(x, 10, y, -20)
多么有趣的代码阿。
有异议
这里要考虑参数是传值还是传引用。简单来说,如果子程序要修改参数,最好新建一个变量用来返回,而不是直接返回参数。
处理错误
不要使用 assert
只有一种使用 assert 的方式,那就是误用。如果出现一种条件说明程序异常了,那为什么不抛出异常呢?
有异议
使用断言的标准是,浓清楚假设,和抓住所有不可能出现的情况。 一般来说断言只在开发环境和测试环境中被打开。 另外不要在断言中改变状态。不要这样做:
void haveBirthday() {
// This method increases age by 1.
assert (age++ > 0) : "Invalid age!";
}
应该这样写:
void haveBirthday() { age = age + 1;
// Postcondition: Age must be greater than zero
// after having a birthday.
assert (age > 0) : "Invalid age!";
}
不要 catch
异常最美妙的就是,捕获它们是可选的。俗话说的好,不是强制的就不要做。 所以,不需要去捕获异常
有异议
忽视异常是很危险的。一个异常就是一段代码,可以告诉你,你的程序没有像预期那样执行。 异常一般被分为checked异常和unchecked异常。对于 checked 异常,必须要用 try 语句包裹起来。
让异常消失
虽然有的编程语言迫使你使用 try catch 语句来处理异常,但你可以捕获异常但不做任何事。
// Gets the file location of the application's // configuration information
File configFile = new File(configFileLocation);
try {
parseConfigFile(configFile);
// Code for adjusting app to config settings goes // here...
}
catch (FileNotFoundException e) {
// Leave this empty. Do nothing.
}
有异议
如果读取配置文件出错而不进行任何处理,那么用户就不知道为什么自己的配置有没了。 作为开发者,你也不知道为什么为出现这样的错误情况。
报告问题是非常糟糕的
碰到问题就报告很简单,但是你愿意听到坏消息吗? 确保你的程序出错时,不要把错误消息显示出来,用户也看不懂。
有异议
当你要报告一个问题时,位置和内容取决于听众。 异常错误的技术细节很重要,但是除非你的用户也是程序员,否则就记录到别的地方, 而在界面上显示对用户友好有用的信息。
使用错误码
之前已经提到过错误码了,使用错误代码可以灵活的传输错误情况
有异议
使用错误码主要有两个缺点:
- 使用错误代码的话,就要强制调用方去处理这个错误码
- 当错误代码增加时,Java 这样的编译语言就要重新编译部署
另外,记住:当调用方忽略错误码时,错误就“消失”了,但是,抛出异常的话,异常不会消失。
阻挡和欺骗
就算你必须使用异常,那使用什么样的异常呢?比如网络问题,就算我抛出了一个 IOException, 调用方也没法知道到底网络出了什么问题,与其这样,比如抛出一个 root Exception 算了。
有异议
当一个错误发生时,程序员需要知道关键信息。
void assignGrade(Student student, int score) throws IllegalArgumentException {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("Score (" + score +") not in acceptable range (0 to 100).");
}
}
使用具体类型的异常,也能帮助调用者对于不同异常采取不同的处理方式。
ServerResponse response = getNetworkResource(url);
if (response.getCode().equals("400")) {
// Code 400 means URL was invalid.
// Caller probably needs to stop and
// inform the user.
throw new URIException("Tried to access an" + " invalid URL: " + url);
}
if (response.getCode().equals("403")) {
// Code 403 means access denied.
// Caller might want to ask the user to
// enter name and password and then try
// again to connect.
throw new AuthenticationException("Access to " + url + " denied.");
}
做一个烂摊子
一个连接数据库的例子:
try {
DbConnection connection = database.getConnection(username, password);
results = connection.runQuery("SELECT * FROM User WHERE id = " + id);
connection.close();
}
catch (ConnectionException e) {
// Thrown if a connection fails
}
catch (QueryException e) {
// Thrown if a query fails
}
// etc...
这样,当连接失败时,和 sql 查询出错会抛出不同的异常,干的漂亮。
有异议
当抛出异常时, close 方法就不会被执行,会造成数据库连接不可用。 应该记得在 final 中确保连接被 close .
finally {
connection.close();
}
或者用 Java 1.7 之后的自动释放资源写法:
// DbConnection implements the java.io.AutoCloseable
// interface, so this connection will be automatically
// closed after this try-block exits.
try (DbConnection connection =
database.getConnection(username, password)) {
results = connection.runQuery(
"SELECT * FROM User WHERE id = " + id);
}
catch (QueryException e) {
// Thrown if a query fails
}
模块
导入所有
当你需要使用一个模块时,需要先导入它,比如:
import java.awt.Button;
import java.awt.Canvas;
import java.awt.Paint;
但是,何必这么麻烦呢,直接用通配符不就行了:
import java.awt.*;
有异议
通配符导入几乎是编程时的最坏实践了。导入无用的资源可能会造成性能问题(视语言而定),另外,可能会造成命名冲突。
import java.awt.*;
import java.util.*;
private List meals = new List();
meals.add("Egg and Mushrooms");
meals.add("Steak and Ale Pie");
meals.add("Omelette");
这里 List 可能来自 ast, 也可能来自 util. 所以 import 的时候还是要精准导入。
杂乱和混乱
这样的代码
import java.util.*;
import org.apache.commons.lang3.StringUtils;
import com.google.gson.stream.JsonReader;
import com.google.gson.Gson;
import java.io.*;
import java.awt.Event;
是你修改同事代码后的结果,没有滥用通配符,挺好。
有异议
导入要考虑可读性,一般有如下 guideline:
- 按字母顺序排序
- 按组导入,比如: com.* net.* org.* 这样的顺序
- 组之间用空行隔开
- 不要使用相对路径
- 如果可以,可以给导入的名字一个简称
import java.awt.Event;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import com.google.gson.Gson;
import com.google.gson.stream.JsonReader;
import org.apache.commons.lang3.StringUtils;
防止重用
"写模块的时候要有多个目的,不要让它们关注同一个任务".
public void doVariousUnrelatedStuff() {
System.out.println(supplier.getName());
int price = product.getPrice() - product.getReduction();
updatePrice(product, price);
if (date.getMonth() == "December") {
sendChristmasLeaflet(customer);
}
}
有异议
如果你把这个购物程序分解成独立的可重用的模块,你的同事会很高兴的。 你可以新建一个 Product 类,它有 getPirce()
和 getReduction()
这两个方法。 然后,你可以实现一个 getDiscountedPirce 方法:
return getPrice() - getReduction()
看,可以重用了。
刚才说的,一个模块要专注于一个任务,这叫内聚,有以下几个 level:
- 功能:这个模块执行一个简单的任务。
- 次序:多个任务被放在一起,因为它们的一个输出会变成另一个的输入。
- 交流:多个任务被放在一起,因为它们使用同一个数据。
- 时间:多个任务被放在一起,因为它们要一起运行。
- 程序,多个任务被放在一起,因为它们必须以一个特定的顺序被完成。
- 逻辑,多个任务被放在一起,只是因为它们做了类似的事情。
- 巧合,都是无关的任务。。。
你应该尽可能的编写内聚的模块。
单焦点模块
为了限制重用,我们要降低模块的灵活性。比如,要计算一个数组里的数字的合:
int sum(int[] numbers) {
// ...
}
这样只有 int 的数组可以使用这个方法,其他数字类型都用不了了。
有异议
要允许一个模块可以和很多类型都能工作,它就更具有重用性。
public double sum(double[] nums) { }
public int sum(int[] nums) {}
这样,double 和 int 类型的数组都能使用了。
暴露你的内心
团结就是力量,模块之间要是能紧紧的联系在一起就最好不过了。 模块之间共享本地数据表示它们很团结:
public class Shop {
// Keeps track of next available ID
public static int nextID = 1;
}
public class BakeryProduct {
// Uniquely identifies this type of product public int id;
public BakeryProduct() {
id = Shop.nextID++;
}
}
public class DairyProduct {
public int id;
public DairyProduct() {
id = Shop.nextID++;
}
}
无论是什么类型的产品,它们都能重 Shop 中获取最新的 ID, 这样就不会出现重复了。
有异议
耦合不是非黑即白的。耦合分为弱耦合和强耦合,弱耦合意味着对一个模块的修改,不太会影响其他模块。 强耦合意味着,模块间很难替换,修改一个模块,会对其他模块造成很大影响。
在弱耦合中,模块间用参数交流。 处于中间的控制耦合中,一个模块传递参数给另一个模块是为了控制它。
/**
* Build a list of Student profiles by looking * them up in the database by ID.
*
* @param studentIds
* The list of ids
* @param lookupGraduates
* Whether to lookup graduated students (who are
* stored in a different database) or not.
* @return The list of students found
*/
public List<Students> lookupStudentsByNumber(List<StudentId> studentIds, boolean lookupGraduates) {
// ...
}
在强耦合中,模块间共享数据,就像上面的 Shop 例子一样,一般不建议使用强耦合。
模块的接口
根据之前的经验,我们来编写一个火箭程序:
public class DataStore {
private static DataStore store = null;
// Current weight of the ship private double weight;
private DataStore() { }
public static DataStore getShipData() {
if (store == null) {
store = new DataStore();
}
return store;
}
public double getWeight() {
return weight;
}
public void setWeight(double weight) {
this.weight = weight;
}
}
public class TrajectoryMapper {
public void calculateTrajectory() {
DataStore store = DataStore.getShipData();
double weight;
if (store.getWeight() == 0.0) {
weight = WeighingMachine.getWeight();
store.setWeight(weight);
}
// Code for computing trajectory
// based on weight here...
}
}
public class FuelCalculator {
public void calculateFuelConsumption() {
DataStore store = DataStore.getShipData();
double weight;
if (store.getWeight() == 0.0) {
weight = WeighingMachine.getWeight(); store.setWeight(weight);
}
// Code for calculating rocket
// fuel consumptions here...
}
}
汽油计算和弹道瞄准都需要用到 DataStore 的接口,很符合面向接口编程。
有异议
汽油计算和弹道瞄准公用 DataStore 的数据,会导致一边的修改,间接影响另一边。 下面是接口设计的一些经验
- 小的通常更好
- 每个模块都应该执行一个简单的任务和尽量少的副作用
- 模块内部的改变要对外隐藏
- 模块间的交流要用参数(低耦合)
- 接口要做好文档。
类和对象
数据类
一个数据类就是用来 hold 数据的:
public class Book {
private String author;
private int numPages;
private String isbn;
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public int getNumPages() {
return numPages;
}
public void setNumPages(int numPages) {
this.numPages = numPages;
}
public String getIsbn() {
return isbn;
}
public void setIsbn(String isbn) {
this.isbn = isbn;
}
}
设计一个数据类可以减少你设计时的痛苦。
有异议
当一个数据类出现时,问题就是:负责维护这些值的代码在哪里?
“Data classes are like children. They are okay as a starting point, but to participate as a grownup object, they need to take some responsibility” – Martin Fowler
上帝类
简单的把一大堆逻辑放在一个类里可以减少你设计的痛苦,这样的类一般被叫做上帝类。
有异议
之前提到的,这样的上帝类有着强耦合,并且不容易测试和维护。
工具类
把 OO 的代码写成老旧的过程式代码:
public class BookUtils {
public static boolean validateIsbn(Book b) {
}
public static boolean validateNumPages(Book b) {
}
public static void regsiterBookInLibraryOfCongress( Book b) {
}
}
你的新类变成了一个工具库,不需要担心 OOP 的设计原则了。
有异议
倒不是说工具类就是错的,只不过这样设计的话,你就失去了 OO 的一些特性。 比如,工具类不能被实例化,也就不能被继承。
对象服从命令
一个火车管理的例子:
class StationManager {
// StationManager is composed of lots of other classes
// (like HelpDesk, StationDisplay, SpeakerSystem
// etc.) in addition to the TicketMachine
TicketMachine machine = new TicketMachine();
public void insertCoinToMachine(int coinValue) {
machine.setCredit(coinValue);
}
public void buyTicket() {
Ticket t = chooseTicket();
if (t.getPrice() <= machine.getCredit()) {
machine.deduct(t.getPrice());
printTicket();
} else {
System.out.println("Not enough credit!");
}
}
}
class TicketMachine {
int credit;
public int getCredit() {
return credit;
}
public void setCredit(int value) {
credit = value;
}
public void deduct(int value) {
credit -= value;
}
}
TicketMachine 几乎没做任何事,它听命于 StationManager, 这样设计 TIcketMachine 会很容易。
有异议
这两个类,就是上帝类和数据类,这明显就是有问题的。 当我们考虑代码应该放在哪里时,需要问的问题是:这是哪个对象的责任? 在这个例子里,处理票的售卖是 TicketMachine 的责任。
class TicketMachine {
private int credit;
public void insertCoin(int value) {
credit += value;
}
public void buyTicket() {
Ticket t = chooseTicket();
if (t.getPrice() <= credit) {
credit -= t.getPrice();
printTicket();
}
else {
displayMessage("Not enough credit!");
}
}
}
这样做的好处是:
- 把相关的函数放在一起,方便寻找
- 降低了 StationManager 的的责任(单一职责)
- StationManager 的代码少了 (代码越多越容易出bug)
- TicketMachine 可以更好的做自己的工作,隐藏了实现细节
刚性关系
看这个宠物喂食的例子:
class PetFeeder {
public void giveFood(Dog d) {
d.feed();
}
}
class Dog {
public void feed() {
System.out.println("Wolfing down dog food");
}
}
public static void main(String[] args) {
PetFeeder feeder = new PetFeeder();
Dog lassie = new Dog();
feeder.giveFood(lassie);
}
下面,我们要新增一个 Cat 类:
class Cat {
public void feed() {
System.out.println("Turning nose up at cat food");
}
}
public static void main(String[] args) {
PetFeeder feeder = new PetFeeder();
Dog lassie = new Dog();
feeder.giveFood(lassie);
Cat felix = new Cat();
feeder.giveFood(felix);
}
class PetFeeder {
public void giveFood(Dog d) {
d.feed();
}
public void giveFood(Cat c) {
c.feed();
}
}
好了,完工。
有异议
这里的问题就出在,每次新加一种宠物,都要新加一个 giveFood 方法。 我们可以创建一个 Pet 接口:
interface Pet {
void feed();
}
class Mouse implements Pet {
public void feed() {
System.out.println("Nibbling on cheese.");
}
}
class PetFeeder {
public void giveFood(Pet p) {
p.feed();
}
}
这样,新增宠物种类的时候,就不用修改 giveFood 方法了。
避免多态
使用多态会增大设计的难度,因此我们使用简单的,看看这个计算价格的例子:
ArrayList<Object> shoppingList = getShoppingList();
for (Object item : shoppingList) {
int price = 0;
if (item instanceof ScanItem) {
// Scan the barcode and lookup the price
price = item.lookupPrice();
}
else if (item instanceof ProduceItem) {
// Produce is sold by weight
price = item.getPriceByWeight();
}
else if (item instanceof ReducedItem) {
// Reduced items require the human operator // to key in the price on the tag
price = item.keyInPrice();
}
System.out.println(price)
}
有异议
之前的 Pet 例子就是一种多态的实现。这里我们新建一个接口:
interface Grocery {
int getPrice();
}
这样,各种 item 都实现这个接口:
// A ProduceItem for example is a type of Grocery that // gets a price by weighing the item.
public class ProduceItem implements Grocery
{
// Cents per kilogram private int pricePerKg;
public ProduceItem(int pricePerKg) {
this.pricePerKg = pricePerKg;
}
public int getPrice() {
// Ask the Scales class to weigh this item return Scales.getWeight(this) * pricePerKg;
}
}
List 中的 Object 也要改成 Grocery :
List<Grocery> shoppingList = getShoppingList();
for (Grocery item : shoppingList) {
int price = item.getPrice();
System.out.println(price)
}
过分使用继承
假设我们有一个 Car 类,然后我们有一个 FourWheelDriveCar 继承了 Car 类, 之后又有了 FourWheelDriveDieselCar, MilitaryFourWhellDriveDieselCar 等。 这样继承的深度用来越深,不过谁在乎呢?
有异议
深度的继承会让测试和维护变的困难,很难找到一个方法或字段是继承自哪里。 另外继承是一种强耦合,应该避免。
快速但肮脏的复用
现在有一个 Bird 类:
class Bird {
boolean flying = false;
public void fly() {
flying = true;
System.out.println("I'm flying!");
}
}
你想让 Bat 类继承 fly 的能力,所以:
class Bat extends Bird {
public void squeak() {
System.out.println("'Squeak, squeak!'");
}
}
Bat batsy = new Bat();
batsy.squeak();
batsy.fly();
完美。
有异议
鸟类可以下蛋:
public void layEggs(int n) {
eggs += n;
System.out.println("Laid " + eggs + " eggs");
}
这样一来,蝙蝠也继承了下蛋的能力,这是不对的。 继承用于 is-a 的关系,但是蝙蝠只是会飞,不是鸟类,因此不适合使用继承。 OOP 中也不建议仅仅为了代码复用而使用继承。
我们应该建立另一种抽象:
interface Flyer {
void fly();
}
interface EggLayer {
void layEggs(int n);
}
这样一来:
class Bird implements Flyer, EggLayer {
boolean flying = false;
int eggs;
public void fly() {
flying = true;
System.out.println("I'm flying!");
}
public void layEggs(int n) { eggs += n;
System.out.println("Laid " + eggs + " eggs");
}
}
class Bat implements Flyer {
boolean flying = false;
public void fly() {
flying = true;
System.out.println("I'm flying!");
}
}
不过这样一来,fly 方法就要被实现两次了,这是 Java 语言的限制。 不过好在 Java8 可以有接口的默认实现方式。
测试
你的代码就是你自己的
你自己非常了解自己的代码,没有必要去给它们写测试,别人也不会去管你的代码,更不可能为你的代码写测试了。
有异议
测试代码能起到示例的作用,你的同事可以通过测试代码理解你的代码是如何工作的。
保持最小
不要试图去编写一整套测试代码,只要保证测试可以暴露出问题即可。
有异议
编写测试套件需要深谋远虑。以 fizzBuzz 为例,你需要考虑如下情况:
- 普通的数字
- Fizzes 数字
- Buzzes 数字
- FizzBuzzes 数字
另外还有,不是数字的输入,不接受的数字(比如0)
这么简单的一个程序都有这么多种情况,可见完整测试套件的重要性。
阻挠努力
您可能会发现该策略阻止您测试自己的宝贵代码。
- 很差的代码结构,这让测试很难因为代码会很难理解
- 不要给你的代码写文档。这让单元测试的目的很难被理解
- 使用全局变量,这样测试时很难只关注测试内部
- 编写复杂的单元,这意味着需要更多的测试用例
- 使用复杂的表达式,不但更难懂,也需要更多的测试
- 编写大的子程序,不要把它们分成小的函数,大的子程序更难理解
- 给子程序大量的参数,这样测试起来更难
- 编写很少或很糟糕的错误信息,这样测试者就很难发现错在哪里
- 确保你的模块有很多依赖,这样测试起来就需要更多准备
- 紧密耦合你的模块。这妨碍了测试人员将精力集中在单个单元上的努力,因为其他单元的行为会影响被测单元的行为。
- 创建很深的继承,测试一个类就需要很多准备工作,也更难理解子类的行为
有异议
这些内容之前全都提到过了,它们都会阻碍你测试代码。
机器特定测试
But it works on my machine
只要代码能在自己的机器上运行就行了,世界上有那么多机器,怎么可能都一一测试呢?
有异议
这会让你的同事感到困惑,当涉及到文件,系统变量等问题时,需要使用通用的写法,或者事先准备好脚本。
膨胀的焦点
当测试套件中出现错误时,你的同事会去处理,他一定需要 debug, 那么,何必写那么多测试用例呢? 反正你的同事肯定要 debug 的。
有异议
当编写一个测试时,一定要搞清楚这个测试的关注层级。 这样,当一个 bug 出现时,你可以很容易的找到这个 bug 出现的地方,而不用在多个模块之间去寻找。
混乱
来看一段商品打折的代码:
public class Product {
private int price;
public Product(int price) {
this.price = price;
}
public int getPrice() {
LocalDateTime now = LocalDateTime.now();
// Sales between midnight and 1am are half off
if (now.getHour() >= 0 && now.getHour() < 1) {
return price / 2;
}
return price;
}
}
在凌晨,商品只卖半价。 然后我们的测试代码是:
@Test
public void testGetPrice() {
// Create a product priced at $10.00
Product p = new Product(1000);
int price = p.getPrice();
assertTrue(Integer.toString(price), price == 1000);
}
有异议
只要的测试明显是不对的,可以发现,实际上无法测试时间是凌晨的情况(除非你凌晨跑测试)。 应该通过依赖注入来解决:
public int getPrice(LocalDateTime now) {
// Sales between midnight and 1am are half off
if (now.getHour() >= 0 && now.getHour() < 1) {
return price / 2;
}
return price;
}
测试是就可以这样:
@Test
public void testGetPriceAtMidnight() {
Product p = new Product(1000);
// 10 Jan 2017, 00:00
LocalDateTime midnight = LocalDateTime.of(2017, Month.JANUARY, 10, 00, 00);
int price = p.getPrice(midnight);
assertTrue(Integer.toString(price), price == 500);
}
调试
猜测
程序员要处理成千上万行代码,不妨试试去随机找几个地方改改,运气好的话,也许 bug 就被修复了。
有异议
开始调查的第一步就是寻找线索。你应该先做一些假设,假设感觉和猜很像,但是,一个假设需要符合:
- 可测试可证伪
- 基于观察而不是凭空想象
- 应该适合现有的知识
- 不应该要求做出很多假设,因此往往很简单
程序员就像侦探一样,要还原“犯罪现场", 让 bug 可以重现,然后可以进行下一步了。
偏见
要有自信,首先用你丰富的经验,推断中 bug 产生的原因,然后寻找支持你论断的证据, 如果有和你的理论不一致的证据,忽略就行了。
有异议
当开始 debug 的时候,你只有一小部分信息,因此你一开始的推断不一定是正确的。 如果出现的证据和你的推论不一致时,你有两个选择:
- 调整你的推论,让它可以解释新出现的现象(保持原有的情况也被包含)。
- 彻底推翻之前的推论,重新建立一个能解释所有想象的推论。
混乱
如果我们相信,bug 是会移动的,那么我们可以从一个地方跳到另一个地方,你甚至可以修改多个地方的代码来修复bug.
有异议
你应该有方法去去寻找错误的根源,这里有一些小贴士:
- 复杂的代码更容易出现 bug
- 经常更变的代码更容易出 bug
- 新的代码更容易出 bug
另外,可以采取分而治之的方法,比如,当一个变量的值不正确时,在它被定义之前的代码是一个范围,定义并赋值后是另一个范围。
保持沉没
如果程序出错了,但是你没有让程序显示它的错误,那么你可能成功的让用户以为是他自己的行为造成了错误,这样就没人会责怪你了。
有异议
之前说过了,让用户接触太技术细节的错误没意义,但是还是应该让程序员可以看到:
- stack trace
- 错误日志
- 重要的信息比如程序版本,数据和时间,错误时的相关变量
保持记录
如果你的同事坚持要记录程序的错误信息,那就用 print 语句吧,反正用户也看不到。
有异议
在调试时,你可以在自己本地的代码中使用 print, 但是一定要在提交前删除。 一个 print 的很好的替代品是 log. Log 的优势是可以方便的修改 output, 比如从 console 改为文件。 另外,Log 还有 log levels 这个特性,可以把信息分为 Debug, Info, Warn, Error, Fatal 这几个等级。
Hit and Run
你一定碰到过这样的 bug, 有一天,它就出现了,你尝试去重现这个 bug, 但是无法重现,算了吧,你的同事如果碰到这个 bug, 他会去修的。
有异议
的确,bug 也分轻重缓急,但是你不能就当它不存在,至少应该记录下来。
修复它
现在有一个 bug
// Grade is between A and F inclusively
String grade = calculateGrade(student.getTestScore());
System.out.println(grade);
运行结果如下:
45 —> "E"
67 —> "C"
68 —> "C"
81 —> "A"
40 —> null
73 —> "B"
可见,当输入是 40 时,结果是 null, 而不是 F 让我们来修复它:
// Grade is between A and F inclusively String grade;
if (student.getTestScore() == 40) {
grade = "F";
}else {
grade = calculateGrade(student.getTestScore());
}
System.out.println(grade);
好了,修好了。
有异议
上面的方法根本没有深入过 calculateGrade 方法,说明你没有发现错误的根源。 除非是调用看不到源码的第三方库,否则都应该治标又治本。
public String calculateGrade(int score) {
String grade = null;
if (score > 80) { grade = "A"; }
else if (score > 70) { grade = "B"; }
else if (score > 60) { grade = "C"; }
else if (score > 50) { grade = "D"; }
else if (score > 40) { grade = "E"; }
else if (score <= 40) { grade = "E"; }
return grade;
}