总是犹豫r - l要不要+1?边界计算与不对称边界
参考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最终打印成品plaintext1 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 bufferrow; p bufptr; p NROWS) printnum(*p);循环条件又是p bufptr——出界点当终点用。完整的printvoid print(int n) { if (bufptr buffer[BUFSIZE]) { static int row 0; int *p; for (p bufferrow; 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——因为一个有数的行至少得有一个元素落在缓冲区里。