news 2026/10/5 10:25:32

总是犹豫r - l要不要+1?边界计算与不对称边界

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
总是犹豫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

最终打印成品:

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——因为一个有数的行,至少得有一个元素落在缓冲区里。


版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 10:25:31

(159页PPT)关于6S现场管理培训教材页)很实用资料)(附下载方式)

篇幅所限&#xff0c;本文只提供部分资料内容&#xff0c;完整资料请看下面链接 &#xff08;159页PPT&#xff09;关于6S现场管理培训教材页)很实用资料).ppt_PPT版智慧疗愈小镇规划资源-CSDN下载 资料解读&#xff1a;企业6S管理培训教材 详细资料请看本解读文章的最后内容…

作者头像 李华
网站建设 2026/10/5 10:25:26

在AI时代,计算机的学习就两个字:自学

在AI时代&#xff0c;计算机的学习就两个字&#xff1a;自学我说一个很扎心的现实&#xff1a;计算机要想找到工作&#xff0c;就不要去听老师讲课&#xff0c;所有的课都不要听&#xff0c;老老实实地自学&#xff0c;老老实实地旷课自学&#xff0c;然后就是老老实实地旷课实…

作者头像 李华
网站建设 2026/10/5 10:23:02

Windows系统时间校准指南:time命令、date命令、w32tm /resync与CMOS排查

一、Win10没有睡眠选项的常见原因打开Windows 10的开始菜单&#xff0c;点击电源按钮后发现只有关机和重启&#xff0c;没有睡眠选项。这种情况在台式机和笔记本上都可能发生。以下是导致睡眠选项消失的六大原因&#xff1a;1、电源计划里没开睡眠最常见。电源计划把睡眠关了&a…

作者头像 李华
网站建设 2026/10/5 10:16:40

六盘水装修买瓷砖别纠结,自己采购划算还是让装修公司代买合适

行业痛点分析六盘水本地装修业主选购瓷砖时&#xff0c;普遍面临自采还是装修公司代买的两难选择&#xff1a;自采缺乏专业甄别能力&#xff0c;容易踩中品质、价格坑&#xff1b;代买则担心信息差导致预算虚高、品质不达预期。数据表明&#xff0c;2023年六盘水市家装消费权益…

作者头像 李华