参考:Andrew Koenig《C 陷阱与缺陷(第二版)》3.6节
目录
那段代码的"副效果"
"栏杆错误"
用不对称边界表示一个范围
把不对称边界用到缓冲区上
按列输出整数:先搭骨架,再往里填
3.6 节先问了个基础问题:数组有 10 个元素,下标的合法范围是什么?
答案看语言。
Fortran、PL/I 和 Snobol4 缺省从 1 开始,而且还允许你另外指定起点;
Algol 和 Pascal 没有缺省值,必须显式写出下界和上界;
标准 Basic 里声明一个 10 个元素的数组,编译器实际分配 11 个(下标 0 到 10)。
C 是 0 到 9。
书把这句拆成两半说,第二半才是重点:
一个拥有 n 个元素的数组,却不存在下标为 n 的元素,它的元素的下标范围是从 0 到 n-1。
然后它把导读里那段代码翻了出来——
int i, a[10]; for (i = 1; i <= 10; i++) a[i] = 0;那段代码的"副效果"
书说它「产生了一个出人意料的'副效果'」:i <= 10写错了
于是「实际上并不存在的a[10]被设置为 0」——真正被写的是数组 a 之后的一个字。
接着是一个推演:如果编译器按内存地址递减的方式给变量分配内存,那么 a 之后那个字正好分给了i。
本来i到 10 就该停了,结果a[10] = 0把i置回 0——死循环。
注意那个"如果"。它意味着这个 bug 的后果不是固定的,取决于编译器怎么排内存。
"栏杆错误"
书给这类错误起了名字:最难于察觉的一类是栏杆错误(off-by-one error)。
问题是这样:
100 英尺长的围栏,每隔 10 英尺需要一根支撑用的栏杆,一共几根?
不加思索的答案是100 ÷ 10 = 10。
正确答案是11。
也许,得出正确答案的最容易方式是这样考虑:要支撑 10 英尺长的围栏实际需要 2 根栏杆,两端各一根。这个问题的另一种考虑方式是:除了最右侧的一段围栏,其他每一段 10 英尺长的围栏都只在左侧有一根栏杆;而例外的最右侧一段围栏不仅左侧有一根栏杆,右侧也有一根栏杆。
书给了两种数法,都指向同一件事——边界要单独数:
- 要支撑 10 英尺长的围栏实际需要 2 根栏杆,两端各一根;
- 除最右侧那一段,其他每段都只在左侧有一根栏杆;而例外的这一段左右各有一根。
由此归纳出两个通用原则:
先考虑最简单情况下的特例,然后将结果外推
仔细计算边界,绝不掉以轻心
用不对称边界表示一个范围
书接着去算一个看着很基础的问题:x >= 16 且 x <= 37,这个范围内有多少个整数?
答案显然和37 - 16 = 21很接近,但到底是 20、21 还是 22?
用原则一:
把范围缩到最简单——让上下界重合(x >= 16 且 x <= 16),显然只有 1 个。
那么下界 l、上界 h 的一般情形就是h - l + 1个,所以是37 - 16 + 1 = 22。
造成“栏杆错误”的根源正是“h - l + 1”中的“+1”
然后它问:有没有什么技巧,能让这类错误不容易发生?有,而且一句话就能说完——
用第一个入界点和第一个出界点来表示一个数值范围。
不说x >= 16 且 x <= 37,而说x >= 16 且 x < 38。
下界是"入界点"(包含在范围内),上界是"出界点"(不包含)。
书自己承认这种不对称"从数学上而言并不优美",但换来三条性质:
Ⅰ.取值范围的大小就是上界与下界之差:38 - 16 = 22
Ⅱ.如果范围为空,那么上界等于下界(第 1 条的直接推论)
Ⅲ .即使范围为空,上界也永远不可能小于下界
对 C 尤其方便,因为:数组的上界(第一个"出界点")恰好就是数组元素的个数。于是写
int a[10], i; for (i = 0; i < 10; i++) a[i] = 0;而不是for (i = 0; i <= 9; i++)。
循环条件里的10和声明里的10是同一个数——你不需要在脑子里做那个-1。
书接下来把这个技巧压到了两段真实代码上。
把不对称边界用到缓冲区上
↑3.2
图 3.2 是这件事的另一种说法:
把一块内存分成"可用 / 已占用 / 可用"三段,下界是"入界"点,上界是"出界"点。
上界是"序列中第一个被占用的元素"、下界是"第一个被释放的元素".
要干的活很常见:把长度不固定的输入搬进一块 N 字节的缓冲区,满了就整块写出去。
#define N 1024 static char buffer[N]; static char *bufptr;问题只有一个:bufptr该指向哪儿?
书上摆了两个选择,还说第一个"很有吸引力",但按不对称边界的偏好要选第二个:
- 指向缓冲区里最后一个已占用的字符
- 指向缓冲区里第一个未占用的字符
选第二个之后,这些都不用算:
*bufptr++ = c; /* 存一个字符,指针顺势后移 */ bufptr = buffer; /* 空缓冲区 */ bufptr - buffer /* 已经存了多少个字符 */ N - (bufptr - buffer) /* 还能再存多少个 */第二行是"范围为空时上界等于下界"的兑现:空缓冲区就是指针回到起点。
第三行是"大小 = 上界 - 下界"的兑现:已存字符数就是指针减起点,没有那个 +1。
第一版函数长这样。下面两版都是片段——flushbuffer书里只给了名字和职责,没有定义:
void bufwrite(char *p, int n) { while (--n >= 0) { if (bufptr == &buffer[N]) flushbuffer(); *bufptr++ = *p++; } }buffer[N]这个元素是不存在的。buffer的下标是 0 到 N-1。
书专门停下来解释,因为它看上去太像越界了:为什么写
if (bufptr == &buffer[N])而不是那个"看着更安全"的写法
if (bufptr > &buffer[N-1])因为要比较的是缓冲区后面第一个字符的地址,&buffer[N]正好是这个地址。
区别在"取地址"和"引用元素"。取一个不存在元素的地址是合法的,读它、写它才非法。书里的原话:
ANSI C 标准明确允许这种用法:
数组中实际不存在的"溢界"元素的地址位于数组所占内存之后,这个地址可以用于进行赋值和比较。
当然,如果要引用该元素,那就是非法的了。
第二版换成批量搬运。
逐字符版每次迭代要做两个检查(循环计数、缓冲区满没满),一次只能搬一个字符。
void bufwrite(char *p, int n) { while (n > 0) { int k, rem; if (bufptr == &buffer[N]) flushbuffer(); rem = N - (bufptr - buffer); k = n > rem ? rem : n; memcpy(bufptr, p, k); bufptr += k; p += k; n -= k; } }k取"还能搬多少"和"还剩多少"里小的那个。
后面四行各管一件事:把这 k 个字符搬过去、目标指针前移、源指针前移、剩余数减 k。
rem有两种算法,书特意说明它们等价:
N - (bufptr - buffer) /* 总容量减去已占用 */ (buffer + N) - bufptr /* 可用区间的长度 */第二个写法把空余部分看成一个区间:bufptr是入界点,&buffer[N]是出界点,长度就是两者之差。
还是同一条规则。
按列输出整数:先搭骨架,再往里填
第二个例子难得多。
要求:输出若干页整数,每页 NCOLS 列、每列 NROWS 个。数字按列生成,却要按行打印。
① 按列生成(往表格填数字,先填满第 1 列,再第 2 列,再第 3 列)
plaintext
列1 列2 列3 1 3 5 2 4 6填充顺序:1 → 2(填满第 1 列)→3→4(填满第 2 列)→5→6(填满第 3 列)
② 按行打印(输出的时候,横向一行一行读出来)
第一行输出:1 3 5第二行输出:2 4 6
最终打印成品:
plaintext
1 3 5 2 4 6为什么非缓冲不可?
要打印第 1 行,得先知道它在第 2、3、4 列上的元素,而那些要等后面几列生成完才知道;
反过来,第 1 列的第 2 个元素又必须等第 1 行打完才能打。进和出是拧着的。
缓冲区要多大?第一反应是装下一整页。书说不用:
对于最后一列中的每个元素,也就是相应行的最后一个元素,只要我们得到它的数值,就可以立即打印出来。因此,我们的缓冲区不必包括最后一列:
#define BUFSIZE (NROWS*(NCOLS-1)) static int buffer[BUFSIZE];省掉最后一列,一个 int 都不多。
接下来这步值得单独看:
书先只写框架,把想不清楚的地方留成一句注释。
(printnum/printnl/printpage是外部提供的打印函数,书不展开。)
void print(int n) { if (bufptr == &buffer[BUFSIZE]) { /* 某些暂时不能确定的操作 */ } else *bufptr++ = n; }(骨架最后一行印的是*bufptr++ == n;,两个等号。完整版改回了一个。)
骨架已经定下两件事:
缓冲区按同一列相邻排列(进来就顺序写下去,最省事);
缓冲区满的那一刻,进来的这个数就是当前行的最后一个元素,可以立刻打。
填进去。要打印第row行,先看它的元素在缓冲区里的位置:
buffer[row]就是第row行的第 1 个元素——它一定在,否则根本进不来
- 同一行的相邻元素在缓冲区里相隔 NROWS 个
bufptr指向第一个未占用的位置
所以:
int *p; for (p = buffer+row; p < bufptr; p += NROWS) printnum(*p);循环条件又是p < bufptr——出界点当终点用。
完整的print:
void print(int n) { if (bufptr == &buffer[BUFSIZE]) { static int row = 0; int *p; for (p = buffer+row; p < bufptr; p += NROWS) printnum(*p); printnum(n); /* 打印当前行的最后一个元素 */ printnl(); /* 另起新的一行 */ if (++row == NROWS) { printpage(); row = 0; /* 重置当前行序号 */ bufptr = buffer; /* 重置指针 bufptr */ } } else *bufptr++ = n; }bufptr = buffer;
在if里面——一页 NROWS 行全打完了才清空缓冲区。放到外面会出事:
缓冲区里躺着这一页所有行的前几列,打一行清一次,后面几行就只剩最后一个数了。
最后是flush,处理末尾不满一页的部分。书的第一版很规矩:不管缓冲区里有多少,一律打 NROWS 行。
问题是最后一页可能只有两行有数,另外两行就打成了空行。书说这"虽然也满足了问题定义中的要求,但却不符合程序美学的观点"。改进版先算出缓冲区里剩多少项:
void flush() { int row; int k = bufptr - buffer; /* 计算缓冲区中剩余项的数目 */ if (k > NROWS) k = NROWS; if (k > 0) { for (row = 0; row < k; row++) { int *p; for (p = buffer + row; p < bufptr; p += NROWS) printnum(*p); printnl(); } printpage(); } }k是"缓冲区里有多少项",但最多是 NROWS——因为一个有数的行,至少得有一个元素落在缓冲区里。