介绍
小票应包含已批准的购买,而不能只接受数字转换函数碰巧从行首识别出的内容。Sunny Snacks 现在需要完整的输入验证,以及不会覆盖已有购买记录的容量检查。
这个独立单元会准备自己的商品目录和小票程序。你需要掌握“保留小票的每条购买记录”中练习过的数组、函数、结构体和整数分小票知识。你将学习有界十进制解析器所需的字符串与地址知识,然后拒绝无效输入,同时不改变购物篮。Workspace 终端和编译器已准备好;这是使用合成数据的文本小票程序,不是支付系统。时长为作者估计。
转换完整十进制文本
本步骤中,你将区分完整有效的数字与数字前缀,并实现解析器成功和失败的边界。
从 Workspace Tab 开始。这里的准备文件独立于上一实验。cd 进入工作目录,ls 核对文件名,cat 显示文本:
cd /home/labex/project/receipts
ls
cat parse-demo.c
cat parse.h
cat read-line.h
parse-demo.c 是调用 parse_number 的小程序;它读取一个数量,打印接受的整数或拒绝消息,然后退出。下一步的 till.c 会使用同一份 parse.h。当前解析器总是返回 0,因此连正确输入也拒绝。请保持 catalog.h、driver.h、read-line.h 和 parse-demo.c 不变;编辑 parse.h,随后再编辑 till.c。
C 中的文本是以零值 NUL 终止符结尾的字符数组,该终止符写作 \0。它与可见数字字符 '0' 不同。例如:
array index: 0 1 2
text: '2' '0' '\0'
提供的 read_line 预留 64 字节:最多 63 字节输入,再加终止符。getchar() 每次读取一个输入字符;其 int 结果还能表示 EOF。循环最多保存 63 个字符,但超长时仍会继续读取,直到该行的换行符,避免残余字符变成下一次回答。完整行返回 1,读完并丢弃的超长行返回 -1,EOF 返回 0。EOF 也会丢弃未完成的一行。样例中的 read_line(text) != 1 会在转换前拒绝后两种未完成情况。
解析器的公开接口如下:
int parse_number(const char text[], int minimum, int maximum, int *result)
请保留函数名称和参数顺序:提供的样例和驱动会调用它。成功返回 1,失败返回 0。成功时,它将转换后的整数存入调用方的结果变量;失败时,保持该变量原值不变。minimum 和 maximum 都包含边界:最小值为 0 时,0 是合法数字值,但购买数量的最小值为 1。
这里需要一个小小的地址概念。普通整数实参传递的是值。&quantity 表示调用方 quantity 存储格的地址。参数 int *result 接收这个地址;*result = value 将值写入调用方的存储格。声明中的星号表示保存地址的变量;表达式中的星号则访问该地址处的存储格。我们只使用这里需要的输出参数,不涉及动态分配或通用指针练习。
在 stdlib.h 中声明的 strtol 把文本转换为 long 整数。这里明确传入基数 10,因此前导零仍按十进制处理。第一个参数指向要读取的首字符。第二个参数也是输出:char *end 将保存第一个没有被当作数字组成部分的字符地址。&end 把这个“保存地址的变量”本身的地址传入,让 strtol 能更新它,而不是再复制一个字符串。
"2bars" -> converted long value 2; end points at 'b'
"20" -> converted long value 20; end points at '\0'
"+" -> no conversion; end equals the starting address
只转换出一个前缀还不够。我们的数字语法允许可选的首尾 ASCII 空格或 Tab、可选符号,以及十进制数字,包括前导零。内部空格、小数点、十六进制前缀、其他空白字符和尾随字母都不合法。只有符号或空白文本也不合法。严格小写命令 done 仅属于收银程序的 Product ID 提示,不是数字文本。
用数组索引 begin 跳过允许的前导空格和 Tab。&text[begin] 是首个剩余字符的地址。在调用 strtol 前,检查该字符是否为数字或符号,防止其更宽泛的空白处理悄悄扩大我们的输入语法。转换后,end == &text[begin] 表示没有读入任何数字。*end 读取停止地址处的字符;end++ 把地址向后移动一个字符。只跳过尾随空格和 Tab,然后要求当前位置为终止符。
还有一道边界:文本可能表示一个连 long 都装不下的值。errno 来自 errno.h,是库的错误指示值。调用前先清零;调用后出现 ERANGE 表示范围溢出。在把 long 转换为 int 前,既要检查这个错误,也要检查业务最小值和最大值。仅凭转换返回数字零,不能认定出错。
用完整解析器替换 parse.h。头文件中的函数为 static,因此属于各自包含它的程序。cat > 替换文件;带引号的 here-document 会原样复制接下来的 C 内容,直到最后的 EOF 行。
cat > parse.h <<'EOF'
#include <errno.h>
#include <stdlib.h>
static int parse_number(const char text[], int minimum, int maximum, int *result) {
int begin = 0;
while (text[begin] == ' ' || text[begin] == '\t') begin++;
char first = text[begin];
if (first != '+' && first != '-' && (first < '0' || first > '9')) return 0;
char *end;
errno = 0;
long value = strtol(&text[begin], &end, 10);
if (end == &text[begin] || errno == ERANGE) return 0;
while (*end == ' ' || *end == '\t') end++;
if (*end != '\0' || value < minimum || value > maximum) return 0;
*result = (int)value;
return 1;
}
EOF
最后的 *result = (int)value 只在全部检查通过后,才向调用方整数写入值。因此,无效输入不会改变已有结果值。强制转换 (int) 执行的是此时已经安全的类型转换。
编译小样例。-std=c11 选择 C11,-Wall -Wextra 启用警告,-o 指定可执行文件名:
gcc -std=c11 -Wall -Wextra parse-demo.c -o parse-demo
./parse-demo
在 Quantity (1..20): 提示处输入 +0002,然后按 Enter。结果为 Accepted quantity: 2,随后返回 shell 提示符。这个输入虽有符号和前导零,表示的仍是普通整数 2。
现在再次运行样例:
./parse-demo
在数量提示处输入 2bars。预期结果为 Rejected quantity.。这是有意触发的失败:转换出前缀 2,并不意味着验证了整行。程序的退出状态是提供给调用方的小整数:这里 0 表示正常成功,2 表示样例拒绝输入。立即运行 echo $? 打印刚才的状态;中间插入任何命令,都会替换它。
echo $?
预期输出为 2。这次拒绝并不表示终端或编译器失败。第三次运行样例:
./parse-demo
输入 9999999999999999999999999999999999999999 并按 Enter。它也会被拒绝:这一行不足 63 字节,但十进制数值超过转换范围。相对地,21 能装入 long,却超过数量最大值 20。它们是导致同一种安全拒绝的不同原因。
如果编译提示缺少文件,通过 cd 和 ls 恢复。语法错误修正后,先保存头文件再重新编译。parse-demo 在编译时包含你的头文件;运行旧可执行文件不能验证新修改。
先批准购买,再修改状态
本步骤中,你将把解析器与购买数组结合,在写入前拒绝已满购物篮,并在多次输入被拒绝后保存一张小票。
从 Workspace 开始,目录为 /home/labex/project/receipts。提供的 till.c 已包含上一实验中的完整记录类型、目录查找和小票打印逻辑;approve_purchase 仍有待补全。
cd /home/labex/project/receipts
cat catalog.h
cat till.c
cat driver.h
从提示向内部阅读驱动。它先读取完整 ID 行,识别严格匹配的 done,解析十进制 ID,并检查商品是否存在。随后读取数量,以 1..20 为边界调用你的解析器。!parse_number(...) 表示解析器返回假。读取或转换失败时,会直接回到 Product ID 提示,不调用批准函数。无效数量只丢弃当前候选项,不丢弃已有购买。
批准逻辑还要保护自己的数组边界。查找商品索引,拒绝不存在的 ID 或不在 1..20 内的数量,并在 line_count >= MAX_LINES 时拒绝。|| 表示任一条件成立就应拒绝。仅在此后,才写入两个字段、增加 line_count 并返回 1。即使只写一次,basket[MAX_LINES] 也已超出六项数组;随后拒绝不能撤销这次无效写入。可用下标是 0 到 5。
所有计算都使用整数分。四条提供的目录记录采用 1..999 的 ID、0..9999 分的价格,其中包括合法免费商品;最多六条、每条数量不超过 20,因此总额上限为 1,199,880 分。每次独立批准的购买都按顺序保留,即使 ID 重复也不合并。与另一份在这些范围内的四行目录一起重新编译时,程序也应复用相同逻辑。结构体和局部变量名可以不同,但提供给驱动调用的函数接口仍须匹配。
用完整程序替换 till.c。这里重复准备这个独立单元需要的记录和打印基础,所以不依赖上一台 VM 的文件。parse.h 仍是你在本 VM 前一步完成的解析器。
cat > till.c <<'EOF'
#include <stdio.h>
#include "catalog.h"
#include "parse.h"
typedef struct {
int id;
char name[32];
int cents;
} Product;
typedef struct {
int product_index;
int quantity;
} Purchase;
static const Product products[] = CATALOG_ROWS;
static const int product_count = sizeof products / sizeof products[0];
static Purchase basket[MAX_LINES];
static int line_count;
static int find_product(int id) {
for (int i = 0; i < product_count; i++)
if (products[i].id == id) return i;
return -1;
}
static int approve_purchase(int id, int quantity) {
int index = find_product(id);
if (index < 0 || quantity < 1 || quantity > MAX_QUANTITY || line_count >= MAX_LINES)
return 0;
basket[line_count].product_index = index;
basket[line_count].quantity = quantity;
line_count++;
return 1;
}
static void print_receipt(void) {
int total = 0;
puts(SHOP_TITLE);
puts("ID | ITEM | QTY | UNIT | SUBTOTAL");
for (int i = 0; i < line_count; i++) {
Product product = products[basket[i].product_index];
int quantity = basket[i].quantity;
int subtotal = product.cents * quantity;
total += subtotal;
printf("%d | %s | %d | %d.%02d | %d.%02d\n",
product.id, product.name, quantity,
product.cents / 100, product.cents % 100,
subtotal / 100, subtotal % 100);
}
printf("TOTAL | %d.%02d\n", total / 100, total % 100);
}
#include "driver.h"
EOF
编译新源码:
gcc -std=c11 -Wall -Wextra till.c -o till
驱动把提示和消息送到标准错误。只有完成的小票进入标准输出。将其重定向到一个新文件,并继续在可见提示处输入:
./till > receipt.txt
按顺序输入下面的交易。每个 ID 和数量输入后都按 Enter。未知 ID 不会出现数量提示。拒绝后会返回 Product ID;不要继续在先前的数量提示处作答。
| Product ID 输入 | Quantity 输入 | 预期观察 |
|---|---|---|
101 |
2 |
Added line 1 |
102 |
2bars |
数量被拒绝;第 1 行仍在 |
999 |
不会询问数量 | 未知商品 ID;第 1 行仍在 |
103 |
20 |
Added line 2,合法数量上边界 |
104 |
1 |
Added line 3,合法零价商品 |
102 |
1 |
Added line 4 |
101 |
1 |
Added line 5,与第 1 行分开保存 |
104 |
2 |
Added line 6 |
103 |
1 |
购物篮已满,拒绝;六行全部保留 |
done |
不会询问数量 | 完成小票;返回 shell |
只有 shell 提示符返回后,才读取保存的结果:
cat receipt.txt
预期内容:
SUNNY SNACKS
ID | ITEM | QTY | UNIT | SUBTOTAL
101 | Oat bar | 2 | 1.25 | 2.50
103 | Water | 20 | 0.75 | 15.00
104 | Water | 1 | 0.00 | 0.00
102 | Fruit cup | 1 | 2.30 | 2.30
101 | Oat bar | 1 | 1.25 | 1.25
104 | Water | 2 | 0.00 | 0.00
TOTAL | 21.05
被拒绝的 2bars、ID 999 和第七次购买都没有生成明细,也没有改变已有行。ID 103 和 104 都显示 Water,但价格仍然不同。零价格不等于零数量。整数求和为 250 + 1500 + 0 + 230 + 125 + 0 = 2105 分。
完整小票的格式为店名、五个指定的竖线分隔列、全部有序购买行,以及唯一的末尾 TOTAL。空行和字段周围的空格可以变化,但缺少表头、出现无法识别的额外行,或者总额后仍有记录,都属于不完整输出。保留 receipt.txt、till.c、parse.h、可执行程序 till 和所有提供的文件。课程检查还会在一次性目录中,对未修改的源码副本进行运行时边界检测。它能发现这些输入实际触及的错误,但不能证明完全的内存安全,也不会修复你的文件。
为了观察取消与结账的区别,使用不同的输出文件名开始另一笔交易:
./till > cancelled.txt
输入 ID 101,按 Enter,然后在空的数量提示处按 Ctrl+D。EOF 会以 Cancelled: no receipt. 取消,并且不发布已完成小票。它也会丢弃尚未结束的部分输入行。立即读取它的原生退出状态:
echo $?
预期输出为 2。先前的 receipt.txt 仍在,是因为这条命令使用了另一个文件名。若是旧的同名文件,> 会在程序运行前就将其截断。相反,在 Product ID 处输入 done 会以状态 0 结账,包括总额为 0.00 的空购物篮;done 从来不是数量命令。
如果合法购买被拒绝,检查解析器的完整文本检查、包含边界的范围检查,以及批准前的防护条件。如果旧行消失,检查错误路径是否在改变购物篮或计数之前返回。保存、重新编译,并从一个空的新执行中重做要求的交易,不要编辑小票文本。正确的保存小票必须与可复用程序相互一致。
总结
你在批准购买前使用了有界字符行、转换结束指针、溢出检查和包含上下边界的业务范围。程序能在无效输入和购物篮已满时保留全部已有明细,同时继续接受零价商品及边界数量。结账与 EOF 取消现在具有清楚且不同的结果。


