Writing Solid Code

Write Solid Code

变量名的力量


最重要的考虑事项是: 该名字要完全、准确地描述出该变量所代表的事物


当变量名的平均长度在 10 到 16 个字符的时候,调试程序所需花费的力气是最小的。


常用对仗词:


为状态变量取一个比 flag 更好的名字:

1
2
3
4
if (dataReady) {}
if (characterType & PRINTABLE_CHAR) {}
if (reportType == REPORTYPE_ANNUAL) {}
if (recalcNeeded == false) {}

Naming Boolean Variables:

  • done, error, found, success, ok

1. 选择专业的词

1
def getPage(url)
  • 从本地缓存中得到一个页面?
  • 从数据库中得到一个页面?
  • 或者从互联网中?

如果是从互联网中,更加专业的名字应该是:

1
2
def fetchPage(url)
def downloadPage(url)

对很多人了来说,get 暗示着 轻量级的访问器


1
2
3
4
5
6
7
8
class BinaryTree {

// 树的高度?
// 树的节点数?
// 内存中所占的空间?
int size();

}
1
2
3
height()
numNodes()
memoryBytes()

2. 不会误解的名字

推荐使用beginend来表示包含的范围

注释

电影制作者在其中给出自己的见解并且通过讲故事来帮助你理解这部电影是如何制作的:

1
2
// 出乎意料的是,对于这些数据使用二叉树比用哈希表快 40%
// 哈希运算的代码比左/右比较大得多

在我们的经验中,很多常量可以通过添加注释得以改进

测试与可读性

测试应当具有可读性,以便其他程序员可以舒服地改变或者增加测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void Test1() {
vector<ScoredDocument> docs;
docs.resize(5);
docs[0].url = "http://example.com";
docs[0].score = -5.0;
docs[1].url = "http://example.com";
docs[1].score = 1;
docs[2].url = "http://example.com";
docs[2].score = 4;
docs[3].url = "http://example.com";
docs[3].score = -99998.7;
docs[4].url = "http://example.com";
docs[4].score = 3.0;

SortAndFilterDocs(&docs);

assert(docs.size() == 3);
assert(docs[0].score == 4);
assert(docs[1].score == 3.0);
assert(docs[2].score == 1);
}
  • 测试很长,充满了不重要的细节,你可以用一句话来描述这个测试所做的事情。
  • 增加新测试不会很容易,你会倾向于拷贝/粘贴/修改
  • 测试输入不是很简单。
  • 没有测试分数为 0 时候的情况。
  • 没有测试其他极端的输入,例如的输入向量、很长的向量、或者有重复分数的情况。
  • 测试的名字 Test1() 没有意义

作为一条普遍的测试原则,你应当 “对使用者隐去不重要的细节,以便更重要的细节会更突出。”


1
2
3
4
5
6
7
8
9
10
11
12
13
14
void Test1() {
vector<ScoredDocument> docs;
docs.resize(5);
MakeScoreDoc(&docs[0], -5.0, "http://example.com");
MakeScoreDoc(&docs[1], 1, "http://example.com");
MakeScoreDoc(&docs[2], 4, "http://example.com");
MakeScoreDoc(&docs[3], -99998.7, "http://example.com");
// ...
}

void MakeScoreDoc(ScoredDocument* sd, double score, string url) {
sd->score = score;
sd->url = url;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void Test1() {
vector<ScoredDocument> docs;
docs.resize(5);
AddScoredDoc(&docs[0], -5.0);
AddScoredDoc(&docs[1], 1);
AddScoredDoc(&docs[2], 4);
AddScoredDoc(&docs[3], -99998.7);
// ...
}

void AddScoredDoc(vector<ScoredDocument>& docs, double score) {
ScoredDocument sd;
sd.score = score;
sd.url = "http://example.com";
docs.push_back(sd);
}

用自然语言来秒数我们的测试要做什么:

1
我们有一个文档列表,它们的分数为 `[-5,1,4,-99998.7,3]`,在 `SortAndFilterDocs()` 之后,剩下的文档应当有的分数是 `[4,3,1]`,而且顺序也是这样。

如你所见,我们没有在任何地方提及 vector<ScoredDocument>,这里最重要的是分数数组。在理想情况下,测试代码应该写成:

1
CheckScoresBeforeAfter("-5, 1, 4, -99998.7, 3", "4, 3, 1");

大多数的测试的基本内容都能精炼成 “对于这样的输入/情形,期望有这样的行为/输出”


  • 选择好的测试输入: 你应当选择一组最简单的输入,它能完整的使用被测代码。

首先,你可能注意到这个非常嚣张的 -99998.7,这个值的含义只是 “任何负数”, 它应当使用 -1 来代替。

防御式编程

在恰当的抽象层次抛出异常:

1
2
3
4
5
6
7
8
class Employee {

// 此处声明的异常位于不一致的抽象层次
public TaxId getTaxId() throws EOFException {
...
}

}

GetTaxId() 将更底层的异常传递给其调用方,暴露了自身的一些实现细节,这就使得子程序的调用方代码不是与 Employee 类的代码耦合,而是与比 Employee 类层次更低的抛出 EOFException 异常的代码耦合起来了,相反,应该像下面这样:

1
2
3
4
5
6
7
class Employee {

public TaxId GetTaxId() throws EmployeeDataNotAvailable {
...
}

}

关键的构建决策

高级语言的语句与等效的 C 代码语句行数之比:


软件的首要技术使命: 管理复杂度


类的接口应该尽可能少地暴露其内部工作机制,类很像冰山: 八分之七位于水面以下,而你只能看到水面上的八分之一:

API 设计参考

1
Picasso.with(context).load("http://i.imgur.com/DvpvklR.png").into(imageView);

异常封装的架构模式应用

1
2
3
public class CreateCommand extends CliCommand {}
public class CloseCommand extends CliCommand {}
public class DeleteCommand extends CliCommand {}

对于基类 CliCommand:

1
2
3
4
abstract public class CliCommand {
abstract public CliCommand parse(String cmdArgs[]) throws CliParseException;
abstract public boolean exec() throws CliException;
}

上述地方有两个地方值得借鉴:

  • 在基类的方法中抛出异常
  • 定义统一的异常基类:
1
2
3
4
5
6
7
8
9
10
11
public class CliParseException extends CliException {
public CliParseException(ParseException parseException) {
super(parseException);
}
}

public class MalformedPathException extends CliException {
public MalformedPathException(String message) {
super(message);
}
}

子类封装自身抛出的异常,以一种统一的方式重新抛出异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public boolean exec() throws CliException {
try {
String newPath = zk.create(path, data, acl, flags, new Stat(), ttl)
} catch(IllegalArgumentException ex) {
throw new MalformedPathException(ex.getMessage());
} catch(KeeperException.EphemeralOnLocalSessionException e) {
err.println("Unable to create ephemeral node on a local session");
throw new CliWrapperException(e);
} catch (KeeperException.InvalidACLException ex) {
err.println(ex.getMessage());
throw new CliWrapperException(ex);
} catch (KeeperException|InterruptedException ex) {
throw new CliWrapperException(ex);
}
return true;
}

序列化/反序列化架构

1
2
3
4
5
6
public interface Record {
void serialize(OutputArchive archive, String tag)
throws IOException;
void deserialize(InputArchive archive, String tag)
throws IOException;
}

其中 OutputArchive.java 如下:

InputArchive.java 如下所示:

1
2
3
4
public interface InputArchive {
public byte readByte(String tag) throws IOException;
public boolean readBool(String tag) throws IOException;
}

封装为 Context 的作用

将变量和函数封装成各种上下文 (Context) 类, 使得 API 具有更好的易用性和扩展性。 首先, 函数参数列表经封装后变短, 使得函数更容易使用 ; 其次, 当需要修改或添加某些变量或函数时, 只需修改封装后的上下文类即可, 用户代码无须修改, 这样保证了向后兼容性, 具有良好的扩展性。

MapReduce 新版 API 的上下文继承树:

Configurable 可配置的作用

然后提供一个基类:

The Clean Code Talks

  • Ask for Thinks ! Don’t look for Thinks !

1
2
3
4
5
6
7
8
9
10
class Document {

String html;

public Document(String url) {
HtmlClient client = new HtmlClient();
html = client.get(url);
}

}

更好的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Document {

String html;

public Document(String html) {
this.html = html;
}

}

class DocumentFactory {

HtmlClient client;

public DocumentFactory(HtmlClient client) {
this.client = client;
}

Document build(String url) {
return new Document(client.get(url));
}

}

Service Locator:

应该这样做, The House has a clear API shown via the constructor:

Service Locator 错在哪里:

  • Mixing Respon sibilities (Lookup, Factory)

起如下这种名字的类都有可能有这种问题:

  • Registry
  • Locator
  • Context
  • Manager
  • Handler
  • Environment
  • Principle

违反迪米特法则:

1
2
3
4
5
6
7
8
9
10
11
class Goods {

AccountReceivable ar;

void purchase(Customer c) {
// c.getWallet().getMoney() 违反 demeter 法则
Money m = c.getWallet().getMoney();
ar.recordSale(this, m);
}

}

遵循迪米特法则:

1
2
3
4
5
6
7
8
9
class Goods {

AccountReceivable ar;

void purchase(Money m) {
ar.recordSale(this, m);
}

}

Law of Demeter:

  • You only ask for objects which you directly need (operate on)
  • a.getX().getY() is a dead givaway
  • serviceLocator.getService() is breaking the Law of Demeter
  • Dependency Injection of the specific object you need. Hollywood Principle.

两个原则:

  • 业务逻辑不应该关心 object lookup of the construction, 它只需要说 I need all these things.
  • Factory, Builder, DI 负责将这些东西组装在一起

Good API

  • Easy to learn
  • Easy to use, even without documentation
  • Hard to misuse
  • Easy to read and maintain code that uses it
  • Sufficiently powerful to satisfy requirement
  • Easy to evolve

API should do one thing, and do it well.

If it’s hard to name, that’s generally a bad sign.


The fundamental rules of API design:

  • When in doubt, leave it out.
  • Don’t make the client do anything the module could do.

Reduce need for boilerplate code

Context Design Pattern

1
2
3
4
5
6
7
8
9
10
11
12
13
// I would hate the class constructing the
// FileCopier to know it is interested in
// all of these!
FileCopier copier = new FileCopier(logger,
copierSettings, generalTimeouts);

// I end up doing this:
FileCopier copier = new FileCopier(configuration);
// and inside..
FileCopier(Configuration configuration) {
X = configuration.GetSetting(“X”, DefaultValue);
// …
}

设计并改进 “分钟/小时计数器”

1
2
3
4
5
6
7
8
9
10
11
12
interface MinuteHourCounter {

// add a count
void count(int numBytes);

// return the count over this minute
int minuteCount();

// return the count over this hour
int hourCount();

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface MinuteHourCounter {

// add a new data point (count >= 0)
// For the next minute, minuteCount() will larger by +count
// For the next hour, hourCount() will larger by +count
void add(int count);

// return the accumulated count over the past 60 seconds
int minuteCount();

// return the accumulated count over the past 3600 seconds
int hourCount();

}

1
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
39
40
41
42
43
44
45
46
47
48
49
class MinuteHourCounterImpl implements MinuteHourCounter {

static class Event {

private int count;
private int time;

public Event(int count, long time) {
this.count = count;
this.time = time;
}

}

private List<Event> events = new ArrayList<Event>();

public void add(int count) {
events.add(new Event(count, System.currentTimeMillis()));
}

public int minuteCount() {
int count = 0;
final long now = System.currentTimeMillis();
for (int i=0; i<events.size(); i++) {
Event event = events.get(i);

if (event.time > now - 60) {
count += event.count;
}
}

return count;
}

public int hourCount() {
int count = 0;
final long now = System.currentTimeMillis();
for (int i=0; i<events.size(); i++) {
Event event = events.get(i);

if (event.time > now - 60) {
count += event.count;
}
}

return count;
}

}
  • for 循环太大,一口吃不下。大多数读者在读这段代码的时候会显著慢下来,以确定这段代码没有 bug

1
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
39
40
41
42
43
44
45
class MinuteHourCounterImpl implements MinuteHourCounter {

static class Event {

private int count;
private int time;

public Event(int count, long time) {
this.count = count;
this.time = time;
}

}

private List<Event> events = new ArrayList<Event>();

public void add(int count) {
events.add(new Event(count, System.currentTimeMillis()));
}

public int minuteCount() {
return countSince(System.currentTimeMillis() - 60);
}

public int hourCount() {
return countSince(System.currentTimeMillis() - 3600);
}

private int countSince(long cutOff) {
int count = 0;
final long now = System.currentTimeMillis();
for (int i=events.size() - 1; i>=0; i--) {
Event event = events.get(i);

if (event.time <= cutOff) {
break;
}

count += event.count;
}

return count;
}

}

性能问题:

  • 它一直不停地在变大,对内存的使用没有限制
  • minuteCounthourCount 太慢了

两阶段传送带设计方案:

1
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class MinuteHourCounterImpl implements MinuteHourCounter {

static class Event {

private int count;
private int time;

public Event(int count, long time) {
this.count = count;
this.time = time;
}

}

private List<Event> minuteEvents = new ArrayList<Event>();
// only contains elements not in minuteEvents
private List<Event> hourEvents = new ArrayList<Event>();

private int minuteCount;
// counts all events over past hour, including past minute
private int hourCount;

public void add(int count) {
final long now = System.currentTimeMillis();
shiftOldEvents(now);

// Feed into the minuteList (not into the hour list -- that will happen later)
minuteEvents.add(new Event(count, now));

minuteCount += count;
hourCount += count;
}

public int minuteCount() {
shiftOldEvents(System.currentTimeMillis());
return minuteCount;
}

public int hourCount() {
shiftOldEvents(System.currentTimeMillis());
return hourCount;
}

// Find and delete old events, and decrease hourCount and minuteCount accordingly.
private void shiftOldEvents(long time) {
final long minuteAgo = time - 60;
final long hourAgo = time - 3600;

// Move events more that one minute old from 'minuteEvents' into 'hourEvents'
// (Events older that one hour will be removed in the second loop.)
while (!minuteEvents.isEmpty() && minuteEvents.get(0).time <= minuteAgo) {
Event event = minuteEvents.get(0);
hourEvents.add(event);

minuteCount -= event.count;
minuteEvents.remove(event);
}

// Remove events more than one hour old from 'hourEvents'
while (!hourEvents.isEmpty() && hourEvents.get(0).time <= hourAgo) {
Event event = hourEvents.get(0);

hourCount -= event.count;
hourEvents.remove(event);
}
}

}

对很多应用来说,这个解决方案足够好了,但它还是有缺点的:

  • 设计不灵活,假设我们希望保留过去 24 小时的计数,需要修改大量的代码
  • 占用的内存很大,假设你有一个高流量的服务,每分钟调用 add() 函数 100 次,因为我们保留了过去一小时内所有的数据,所以这段代码可能会需要用到大约 5MB 的内存

时间桶设计方案:

把一个小时间窗口内的事件装到桶里,然后用一个总和累加这些事件。例如,过去 1 分钟里的事件可以插入 60 个离散的桶里,每个有 1 秒钟宽。过去 1 小时里的事件也可以插入 60 个离散的桶里,每个 1 分钟宽。

如图一样使用这些桶,方法 minuteCount()hourCount() 的精读会是 1/60,这是合理的。如果要更精确,可以使用更多的桶,以使用更多的内存为交换。

1
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
/**
* A queue with a maximum number of slots, where old data "falls off" the end
* 一个最大长度的队列,可以移位并且维护其总和
*/
class ConveyorQueue {

private Deque<Integer> queue;
private int maxItems;
private int totalSum;

public ConveyorQueue(int maxItems) {
this.maxItems = maxItems;
this.queue = new ArrayDeque<>();
}

// Increment the value at the back of the queue
public void addToBack(int count) {
if (queue.isEmpty()) {
// make sure queue has at least 1 item
shift(1);
}

int lastItemCount = queue.pollLast();
queue.offerLast(lastItemCount + count);

totalSum += count;
}

// Each value in the queue is shifted forward by 'numShifted'.
// New items are initialized to 0.
// Oldest items will be removed so there size <= maxItems
public void shift(int numShifted) {
// In case too many items shifted, just clear the queue.
if (numShifted >= maxItems) {
// clear the queue
queue = new ArrayDeque<>();
totalSum = 0;
return;
}

// Push all the needed zeros
while (numShifted > 0) {
queue.offer(0);
numShifted--;
}

// Let all the excess items fall off
while (queue.size() > maxItems) {
totalSum -= queue.poll();
}
}

// return the total value of all items currently in the queue
public int totalSum() {
return totalSum;
}

}

/**
* A class that keeps counts for the past N buckets of time.
*/
class TrailingBucketCounter {

private int secsPerBucket;
private ConveyorQueue buckets;
private long lastUpdateTime; // the last time update() was called

public TrailingBucketCounter(int numBuckets, int secsPerBucket) {
this.secsPerBucket = secsPerBucket;
this.buckets = new ConveyorQueue(numBuckets);
}

void update(long now) {
long currentBucket = now;
long lastUpdateBucket = lastUpdateTime / secsPerBucket;

buckets.shift((int) (currentBucket - lastUpdateBucket));
lastUpdateTime = now;
}

void add(int count, long now) {
update(now);
buckets.addToBack(count);
}

int trailingCount(long now) {
update(now);
return buckets.totalSum();
}

}

public class MinuteHourCounter {

private TrailingBucketCounter minuteCounter;
private TrailingBucketCounter hourCounter;

public MinuteHourCounter() {
this.minuteCounter = new TrailingBucketCounter(60, 1);
this.hourCounter = new TrailingBucketCounter(60, 60);
}

public void add(int count) {
final long now = System.currentTimeMillis() / 1000;
minuteCounter.add(count, now);
hourCounter.add(count, now);
}

public int minuteCount() {
final long now = System.currentTimeMillis() / 1000;
return minuteCounter.trailingCount(now);
}

public int hourCount() {
final long now = System.currentTimeMillis() / 1000;
return hourCounter.trailingCount(now);
}

}

详细解释:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 假设我们维护的是一个 60 桶的双端队列 …

Deque<Integer> deque = new ArrayDeque<>();
deque.offer(12);

Thread.sleep(2 * 1000);
deque.offer(75);

Thread.sleep(3 * 1000);
deque.offer(96);

Thread.sleep(1 * 1000);
deque.offer(24);

// [ 12 | 0 | 0 | 75 | 0 | 0 | 0 | 96 | 0 | 24 | --> ]

// 假设当前队列长度为 65,我们实际上想要维护一个长度为 60 的队列 (我们需要移除开头加进来的 5 个):

// [ x | x | x | x | x | 0 | 0 | 96 | 0 | 24 | --> ]
// ===>
// [ 0 | 0 | 96 | 0 | 24 | --> ]
  • 使用 poll() 移除先添加进来的超过窗口大小的 (如 60)
  • 使用 0 填补间隔的秒数
  • 使用 offerLast() 从队尾添加元素值,进行计数

Writing Solid Code

1
2
3
4
5
6
7
8
9
/* memcpy 复制一个不重叠的内存块 */
void* memcpy(void* pvTo, void* pvFrom, size_t size)
{
byte* pbTo = (byte*)pvTo;
byte* pbFrom = (byte*)pvFrom;
while(size-->0);
*pbTo++ = *pbFrom++;
return(pvTo);
}

当确定需要用空语句时,你就用。但最好用 NULL 使其明显可见:

1
2
3
4
5
6
7
char* strcpy(char* pchTo, char* pchFrom)
{
char* pchStart = pchTo;
while(*pchTo++ = *pchFrom++)
NULL;
Return(pchStart);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void memcpy(void* pvTo, void* pvFrom, size_t size)
{
void* pbTo = (byte*)pvTo;
void* pbFrom = (byte*)pvFrom;
#ifdef DEBUG
if(pvTo == NULL | | pvFrom == NULL)
{
fprintf(stderr, “Bad args in memcpy\n”);
abort();
}
#endif
while(size-->0)
*pbTo++ == *pbFrom++;
return(pvTo);
}

这种想法是同时维护调试和非调试(即交付)两个版本。在程序的编写过程中,编译其调试版本,利用它提供的测试部分在增加程序功能时自动地查错。在程序编完之后,编译其交付版本,封装之后交给经销商。利用断言进行补救:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifdef DEBUG
void _Assert(char* , unsigned); /* 原型 */
#define ASSERT(f) \
if(f) \
NULL; \
else \
_Assert(__FILE__ , __LINE__)
#else
#define ASSERT(f) NULL
#endif

void memcpy(void* pvTo, void* pvFrom, size_t size)
{
void* pbTo = (byte*)pvTo;
void* pbFrom = (byte*)pvFrom;
assert(pvTo != NULL && pvFrom != NULL);
while(size-->0)
*pbTo++ == *pbFrom++;
return(pvTo);
}

很少比跟踪到了一个程序中用到的断言,但却不知道该断言的作用这件事更令人沮丧的了。你浪费了大量的时间,不是为了排除错误,而只是为了弄清楚这个错误到底是什么。这还不是事情的全部,更有甚者程序员偶尔还会设计出有错的断言。所以如果搞不清楚相应断言检查的是什么,就很难知道错误是出现在程序中,还是出现在断言中。幸运的是,这个问题很好解决,只要给不够清晰的断言加上注解即可。我知道这是显而易见的事情,但令人惊奇的是很少又程序员这样做。为了使用户避免错误的危险,程序员们经历了各种磨难,但却没有说明危险到底是什么。这就好比一个人在穿过森林时,看到树上钉着一块上书“危险”红字的大牌子。但危险到底是什么?树要倒?废矿井?大脚兽?除非告诉人们危险是什么或者危险非常明显,否则这个牌子就起不到帮助人们提高警觉的作用,人们会忽视牌子上的警告。同样,程序员不理解的断言也会被忽视。在这种情况下,程序员会认为相应的断言是错误的,并把它们从程序中去掉。

1
2
/* 内存块重叠吗?如果重叠,就使用 memmove */
ASSERT(pbTo>=pbFrom+size || pbFrom>=pbTo+size);

根本就不应该企图将 memset 写成一个可移植的函数。要接受其不可移植这一事实,不要对其进行改动。至于对程序中所做的其他假定,可以利用断言和条件编译进行相应的验证。

如果不能使错误不断重现,就无法排除它们。找出程序中可能引起随机行为的因素,并将它们从程序的调试版本中清除。把目前尚“无定义”的内存单元置成了某个常量值,就可能产生这种错误。在这种情况下,如果程序在该单元被正确地定义为某个值之前引用了它的内容,那么每次执行这部分错误的代码,都会得到同样的错误结果:

保存一个日志,以唤起你的注意:


假如你受雇为核反应堆编写软件,就必须对堆芯过热这一情况进行处理。

某些程序员解决这个问题的方法可以是自动地向堆芯灌水、插入冷却棒或者是能使反应堆冷却下来的一些其他什么方法。而且,只要程序已经控制了势态就不必向有关人员发出警报。

另一些程序员可能会选择另一种方法,即只要堆芯过热就向反应堆工作人员发出警报。虽然相应的处理仍由计算机自动进行,不同的是操作员总是知道这件事。

如果由你来实现这一程序,你会选择哪一种方法?

我想关于这一点,大家基本上不会有太多的异议,即总是应该向操作人员发出警报,这与计算机能够恢复反应堆的正常操作是两回事。堆芯不会无缘无故地出现过热现象,一定是发生了某种不同寻常的事情,才会引起这一故障。因此在计算机进行相应处理的同时,最好使操作人员搞清楚发生了什么事情以避免事故的再次发生。

防错性程序设计虽然常常被誉为有较好的编码风格,但它却隐瞒了错误。要记住,我们正在谈论的错误决不应该再发生,而对这些错误所进行的安全处理又使编写无错代码变得更加困难。尽管防错性程序设计会隐瞒错误,但它确实有价值。一个程序所能导致的最坏结果是执行崩溃,并使用户可能花几个消失建立的数据全部丢掉。在非理想的世界中,程序确实会瘫痪,因此为了防止用户数据丢失而参去的任何措施都是值得的。防错性性程序设计要实现的就是这个目标。

实际上,无论把这种程序设计风格用在哪里,在编码之前都要同自己 :“在进行防错性程序设计时,程序中隐瞒错误了吗?”如果答案是肯定的,就要在程序中加上相应的断言,以对这些错误进行报警。


子程序是加入断言的绝佳之处,因为它可以使我们用很少的代码就能够进行很彻底的错误检查。这就好象一个足球场,虽然可以有 50000 个球迷来看球,但如果检票人员站在球场的入口,那么只需要几个检票人员就够了。程序中也有这样的入口 ,这就是子系统的调用点。


Robert Cialdini 博土在其 “Influence:How and Why people Agree to Things” 一书中指出:如果你是个售货员,那么当顾客来到你负责的男装部准备购买毛衣和套装时,你应该总是先给顾客看套装然后再给顾客看毛衣。这样做的理由是可以增加销售额,因为在顾客买了一件 $500 元的套装之后,相比之下,一件 $80 元的毛衣就显得不那么贵了。但是如果你先给顾客看毛衣,那么 $80 元一件的价格可能会使其无法接受,最后也许你只能卖出一件 $30 元的毛衣。任何人只要花 30 秒的时间想一想,就会明白这个道理。

以前我也对给程序加上这么多降低效率的调试代码很反感,但不久我就认识到了自己的错误。在程序的交付版本中加上这种调试代码是会断送它的市场前途,付版本中增加任何的测试代码,这些代码只是被用在了它的调试版本中。确实,调试代码会降低调试版本的运行速度。但使你的零售产品瘫在用户那儿,或者为了帮助查错使你的调试版本运行得稍慢,哪一种情况更糟糕呢?我们不应该担心调试版本的效率,因为毕竟顾客不会使用程序的调试版本。

重要的是要在感情上区分程序的调试版本和交付版本。调试版本是用来发现错误的,而交付版本则是用来取悦顾客的。因此在编码时,对这两个版本所作的权衡也会相当不同。

记住,只要相应的交付版本能够满足顾客的大小和速度要求,就可以对调试版本做你想做的任何事情。如果为内存管理程序加上日志程序可以帮助你发现各种难于捕捉的错误,那么就会皆大欢喜。顾客可以得到一个充满活力的程序而你不费很多的时间和精力就可以发现错误。

在由于速度太慢或者占用的内存太多而抛弃一个确认测试程序之前,要三思而后行。切记,这些代码并不是存在于程序的交付版本中。如果发现自己正在想:“这个测试程序太慢、太大了”,那么要马上停下来问自己:“怎样才能保留这个测试程序,并使它既快又小?”

4. 对程序进行逐条跟踪


我希望我知道一种能够说服程序员对其代码进行逐条跟踪的方法,或者至少能够使他们尝试一个月。但是我发现,程序员一般说来都克服不了“那太费时间”这一想法。作为项目负责人的一个好处是对于这种事情你可以霸道一些,直到程序员认识到这样做并不费很多时间,并且觉得很值得这样做,因为出错率显著的下降了。

如果你还没有对你的程序进行逐条的跟踪,你会开始这样做吗?只有你自己才知道这个问题的答案。但我猜想当你拿起这本书并开始阅读的时候,准是因为你正被减少你或你领导的程序员的代码中的错误所困扰。这自然就归结为如下的问题:你是宁愿花少量的时间,通过对代码进行逐条的跟踪来验证它;还是宁愿让错误溜进原版源代码中,希望测试者能够注意到这些错误以便你日后对其进行修改。选择在你。

5. 糖果机界面

我过去常常从要求编写标准的 tolower 函数开始考核候选者。我递给候选者一个 ASCII 表,问候选者“怎样写一个函数把一个大写字母转换成对应的小写字母?” 我有意对如何处理字母以外的其它符号和小写字母说得很含糊,主要是想看看他们会怎样处理这些情况。这些符号在返回时会保持不变吗?会用断言对这些符号进行检查吗?它们会不会被忽视?半数以上的程序员写出的函数会是下面这样:

1
2
3
4
char tolower(char ch)
{
return( ch + ‘a’-‘A’);
}

更常见的是那些未中选的候选者会说:“我没有考虑到这个问题。我可以解决这个问题,当 ch 不是大写字母时,令它返回一个错误代码。”有时他们会使 tolower 返回 NULL,有时会返回空字符。但出于某种原因,无疑 -1 会占上风:

1
2
3
4
5
6
7
char tolower(char ch)
{
if( ch >= ‘A’ && ch <= ‘Z’)
return( ch + ‘a’-‘A’);
else
return(-1);
}

这些解法都违背了我们前面给出的建议,因为他们把出错值同真正的数据混在了一起。但真正的问题并不在于候选者没能注意到他们也许从未听说过的建议,而是他们在大可不必的情况下返回了错误代码。

如果发现无法消除错误的情况,那么可以考虑干脆不允许这些有问题的情况出现,即用断言对函数的输入进行验证。

1
2
3
4
5
char tolower(char ch)
{
ASSERT( ch >= ‘A’ && ch <= ‘Z’);
return( ch + ‘a’-‘A’);
}

想一下 MyBatis 库里面的 RuntimeException 异常直接抛出机制

6. 风险事业

假如将一程序员置于悬崖边,给他绳子和滑翔机,他会怎样从悬崖上下来呢?是沿绳子爬下来呢?还是乘滑翔机呢?还是干脆直接跳下来呢?是沿绳子爬下来还是使用滑翔机我们说不太准,但可以肯定,他不会跳下来,因为那太危险了。可是当程序员有几种可能的实现方案时,他们却经常只考虑空间和速度,而完全忽视了风险性。如果程序员处于这样的悬崖边而又忽视了风险性,只考虑选择到达崖底最有效的途径的话.结果又将如何呢?

程序员忽视风险性,至少有两个原因:

一是因为他们盲目地认为,不管他们怎样实现编码,都不会有错误。没有任何程序员会说 :“我准备编写快速排序程序,并打算在程序中有三个错误。”程序员并没有打算出错,而后来错误出现了,他们也并不特别吃惊。

我认为程序员忽视风险性的第二个原因也是主要原因:在于从来没有人教他们这样去问问题: “该设计有多大的风险性?该实现有多大的风险性?有没有更安全的方法来写这个表达式?能否测试一下该设计?” 要想问出这些问题,首先必须从思想上放弃这样的观点:不管作出哪种选择,最后总能得到无错代码。即使该观点是正确的,可是什么时候能得到无错代码呢?是由于使用安全的编码,在几天或几周之后就可以得到无错代码呢?还是由于忽视了风险性,出现很多错误而需要经过数月的调试和修改之后才能得到无错代码呢?


数据上溢或下溢

有这样一些代码,表面看起来很正确。但是由于实现上存在着微妙的问题,执行却失败了,这是最严重的错误。“简单字符”就是这种性质的错误。下面的代码也具有这样的错误,这段代码用作初始化标准 tolower 宏的查寻表:

1
2
3
4
5
6
7
8
9
10
11
12
13
char chToLower[ UCHAR_MAX+1 ];
void BuildToLowerTable( void ) /* ASCII 版本*/
{
unsigned char ch;
/* 首先将每个字符置为它自己 */
for (ch=0; ch <= UCHAR_MAX;ch++)
chToLower[ch] = ch;
/* 现将小写字母放进大写字母的槽子里 */
for( ch = ‘A’; ch <= ‘Z’; ch++ )
chToLower[ch] = ch +’a’ – ‘A’;
}

#define tolower(ch)(chToLower[(unsigned char)(ch)])

尽管代码看上去很可靠,实际上 BuildToLowerTable 很可能使系统挂起来。看一下第一个循环,什么时候 ch 大于 UCHAR_MAX 呢?如果你认为“从来也不会”,那就对了。如果你不这样认为,请看下面的解释。

假设 ch 等于 UCHAR_MAX,那么循环语句理应执行最后一次了。但是就在最后测试之前,ch 增加为 UCHAR_MAX+1,这将引起 ch 上溢为 0。因此,ch 将总是小于等于 UCHAR_MAX,机器将进行无限的循环。

变量也可能下溢,那将会造成同样的困境。下面是实现 memchr 函数的一段代码。它的功能是通过查寻存储块,来找到第一次出现的某个字符。如果在存储块中找到了该字符,则返问指向该字符的指针,否则,返回空指针。

1
2
3
4
5
6
7
8
9
10
11
void * memchr( void *pv, unsigned char ch, size_t size )
{
unsigned char *pch = (unsigned char *) pv;
while( -- size >=0 )
{
if( *pch == ch )
return (pch );
pch++;
}
return( NULL );
}

循环什么时候终止?只有当 size 小于 0 时,循环才会终止。可是 size 会小于 0 吗?不会,因为 size 是无符号值,当它为 0 时,表达式 --size 将使其下溢而成为类型 size_t 定义的最大无符号位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void IntToStr( int i, char *str )
{
char *strDigits;
if( i < 0 )
{
*str++ = ’-’;
i = -i; /* 把 i 变成正值 */
}
/* 反序导出每一位数值 */
strDigits = str;
do
*str++ = i%10 + ’0’;
while( (i/=10) > 0 );
*str=’/0’;
ReverseStr( strDigits ); /* 将数字的次序转为正序 */
}

若该代码在二进制补码机器上运行,当 i 等于最小的负数(例如,16 位机器的-32768)时就会出现问题。原因在于表达式 i= -i 中的 -i 上;即上溢超出了 int 类型的范围。然而,真正的错误在于程序员实现代码的方式上:程序员没有完全按照他自己的设计来实现代码,而只是近似实现了他的设计。

在设计中要求:“如果 i 是负的,加入一个负号,然后将 i 的无符号数值部分转换成 ASCII。”而上面的代码并没有这么做。它实际执行了:“如果 i 是负的,加入一个负号,然后将 i 的正值也就是带符号的数值部分转换为 ASCII。”就是这个有符号的数字引起了所有的麻烦。如果完全根据算法并使用无符号数,代码会执行得很好。可以将上述代码分为两个函数,这样做十分有用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void IntToStr( int i, char *str )
{
if( i < 0 )
{
*str++ = ‘-‘;
i = -i;
}
UnsToStr(( unsigned )i, str );
}

void UnsToStr( unsigned u, char *str )
{
char * strStart = str;
do
*str++ = (u%10) + ’0’;
while(( u/=10 )>0 );
*str=’\0’;
ReverseStr( strStart );
}

在上面的代码中,i 也要取负,这与前面的例子相同,为什么它就可以正常工作呢?这是因为:如果 i 是最小负数 -32768,二进制补码形式表示为 0x8000,然后通过将所有位倒装(即 01) 再加 1 来取负,从而得到 -i0x8000,若为有符号数,则表示 -32768,若为无符号数,则表示 32768。按定义,由二进制补码表示的任意数,通过将其每一位倒装再加 l,可以得到该数的负值。因此 0x8000 表示的是最小负数 -32768 的负值,即 32768,因此应解释为无符号数。

至此,代码正确了,但并不美观。上面的代码容易让人产生错觉。根据可移植类型。 -32768 并不是有效的可移植整型值,因此通过在 IntToStr 中适当的位置插入断言,就可以排除所有的混乱。

1
2
3
4
5
void IntToStr( int i, char *str )
{
/* i 是否超出范围?使用 LongToStr … */
ASSERT( i>=-32768 && i<- 32767 );
}

消除代码的冗余性:

1
2
3
4
5
6
7
8
void* memchr( void *pv, unsigned char ch, size_t size )
{
unsigned char *pch = (unsigned char * )pv;
unsigned char *pchEnd = pch + size;
while( pch<pchEnd && *pch != ch )
pch ++;
return( ( pch < pchEnd ) ? pch : NULL );
}

第二个版本:

1
2
3
4
5
6
7
8
9
10
11
12
void* memchr( void *pv, unsigned char ch, size_t size )
{
unsigned char *pch = ( unsigned char * )pv;
unsigned char *pchEnd = pch + size;
while( pch < pchEnd )
{
if( *pch == ch )
return ( pch );
pch ++ ;
}
return( NULL );
}

由于第二个版本只在 while 条件中进行块范围检查,所以它更容易理解并且准确地实现了函数的功能。第一个版本的唯一长处是当需要将程序打印出来时,可以节省一些纸。


上面给出的 memchr 两个版本正确吗?你是否看出这两个版本具有同样一个细小错误?提示一下:当 pv 指向存储区的最后 72 个字节,并且 size 也是 72 时, memchr 将要查找存储区的什么范围呢?如果答案是“存储区的全部范围,反复不断地查找。”那么你的回答就是对的。由于在程序中使用了有风险的惯用语, memchr 陷入了无限的循环之中。

无论什么时候,只要有可能就尽量避免使用这些惯用语。在 memchr 中有风险的惯用语是:

1
2
pchEnd = pch + size;
while( pch < pchEnd )

作为改正错误的第一步尝试,将上面的代码改写为如下所示:

1
2
pchEnd = pch + size – 1;
while ( pch <= pchEnd )

但是,这还不能正确工作。现在 pchEnd 可能指向一个合法的存储位置,但是,由于每一次 pch 增加到 pchEnd + l 时都要上溢,因此循环将不会终止。

当你可用指针也可用计数器时,使用计数器作为控制表达式是覆盖一个范围的安全方法:

1
2
3
4
5
6
7
8
9
10
11
void *memchr( void *pv, unsigned char ch, size_t size )
{
unsigned char *pch = ( unsigned char * )pv;
while( size -- > 0 )
{
if( *pch == ch )
return( pch );
pch ++;
}
return( NULL );
}

下面给出另一个惯用语,实际上在前面已经提过了。有些程序员可能会极力主张重写循环表达式,用 --size 代替 size--

1
while( --size >= 0
  • 如果 size 是无符号值(象 memchr 中的一样),根据定义,将总是大于或等于 0,循环将永运执行下去,因此表达式不能正常工作。
  • 如果 size 是有符号数,表达式也不能正常工作。如 size 是 int 类型并且以最小的负值 INT_MIN 进入循环,它先被减 1,那么就会产生下溢,使得循环执行大量的次数。
  • 相反,无论怎样声明 size 都能使 “size-- > 0” 正确工作。这是个小小的、但又很重要的差别。

程序员使用 “-- size > 0” 的唯一原因是想加快速度。让我们仔细看一下,如果真的存在速度问题,那么进行这种改进就好象用指甲刀剪草坪一样,可以这么做,但没有什么效果。如果不存在速度问题,那为什么又要冒这样的风险呢?这就好象没有必要让草坪的所有草叶都一样长,没有必要让每行代码效率都最优一样,要认识到最重要的是总体效果。在某些程序员看来,放弃任何可能获得效率的机会似乎近似于犯罪。但是,当读完本书以后,你会得到这样的思想即使效率可能会稍稍低一点,也要使用安全的设计和实现来系统地减少风险性。用投资方面的一句术语来说就是:赢利并不能证明冒险是正确的


不一致性是编写正确代码的障碍:

1
word = high << 8 + low ;

该代码原意是用两个 8 位字节组合成一个 16 位的字,但是由于 + 操作符比移位操作符的优先级要高,因此,该代码实际实现的是把 high 移动了 8 + low 位。程序员一般不将移位操作符和算术操作符混合使用。如果只用移位类操作符或只用算术类操作符就可以完成,那么为什么还要将移位操作符和算术操作符混合起来呢?

1
2
word = high << 8 | low; /* 移位解法 */
word = high * 256 + low; /* 算术解法 */

这些式子难以理解吗?它们的效率低吗?当然不是。这两种解法差别很大,但这两种解法都是正确的。

若程序员在写表达式时只用一类操作符,那么出现错误代码的概率就要小一些,因为凭直觉,同一类操作符的优先顺序容易掌握。

要插入括号的时候,有些程序员总要先查优先级表,再来确定是否有必要插入括号,如果没有必要就不插。对于这样的程序员,要提醒他:“如果必须通过查表才能确定优先顺序的话,那就太复杂了,简单一些嘛。”这就意味着可以在不需要括号的地方插入括号。这样做不仅正确,而且显然可使任何人不经查表就可判断优先级了。

7. 编码中的假象

应该编写可维护的代码这一观点并不新奇,程序员知道应该编写这样的代码。可是,他们总是没有认识到,他们虽然整天编写可维护的代码,但是如果他们使用只有 C 语言专业人员才能理解的语言,那么这些代码实际上是不可维护的。根据定义,可维护的代码应该是维护人员可以很容易地理解并且在修改时不会引入错误的代码。不管怎样,程序维护人员一般都是该项目的新手而不是专家。

一般来说,有经验的程序员编写出代码,新手维护代码。我并不是说不应该这样安排,这种安排是实用的而且就是这么作的。但是,只有在有经验的程序员认识到,他们有责任使得他们所编写的代码,能够被程序维护人员和程序设计新手维护,这时这种安排才能行得通。

编写直观的代码才是真正的聪明人

8. 剩下来的就是态度问题

本书中讨论的方法可以用来检查错误和防止错误,但是这些技术并不能保证肯定可以写出无错代码,就象一个熟练的球队不可能是常胜军一样。重要的是养成好的习惯和正确的态度。

如果一个球队成天在嘴上讨论如何训练,这个球队可能有取胜的机会吗?如果这个球队的队员不断地因为工资低而牢骚满腹,或时刻耽心被换下场或裁减掉,那又会怎么样呢?虽然这些问题与球赛没有直接关系,但是却影响了球员水平的发挥。

同样读音可以使用本书的所有建议,但是,如果你持疑虑的态度或者使用错误的编码习惯,那么要写出无错的代码将是很困难的。因此,你要有必胜的信心和良好的习惯,同样,你同级的同事如果没有必胜信心和良好习惯也会遇到同样的问题。


错误不出现,我还有一招:

错误消失有三个原因:一是错误报告不对;二是错误已被别的程序员改正了;三是这个错误依然存在但没有表现出来。也就是说,作为一个专业程序员 ,其职责之一就是要确定错误的消失究竟属于以上三种情况中的哪一种,从而采取相应的行动,但是决不能因为错误不出现就简单地忽略了它,就万事大吉了。

通常错误仍然存在,只是环境有了更改从而掩盖了错误。无论什么原因,为了采取适宜的步骤来改正错误,必须弄明白为什么错误消失了。


憨医救人:

在安东尼·罗宾斯的小说《唤醒巨人》(Awaken the Giant Within)中讲了一位医生的故事。一天,有个医生走到一条汹涌的河边,她突然听到落水者的呼救声。她环顾了四周,发现没有人去救,于是,她就跳入水中,朝着落水者游去。她将落水者救上岸,做口对口的人工呼吸,这个人刚一恢复呼吸,又从河里传来了另外两个落水者的求救声。她又一次跳入水中,把这两个人救上岸,正当她安顿好这两个人时,医生又听到另外四个落水者的求救声 ,然后她又听到另外八个落水者的求救声 …… 问题是医生只忙于救人,抽不出时间到上游查明是谁把人们扔到水中。

象这个医生一样,程序员也经常忙于“治愈”错误而没有停下来判断一下是什么原因引起了这些错误

还有几次当我追踪到错误的根源时,经常这样认为:“等一下,修改这个函数可能是不对的。如果是这个函数出错的话,函数在另外的地方也应该出问题呀,可是它没有出问题呀 。”我肯定你能猜出为什么函数在另外的地方能够工作,它之所以工作是因为某个程序员已经局部性地修改了这个较为通常的错误。

参考

推荐文章