介绍
Sunny Snacks 需要一张能保留每次购买的小票,包括顾客再次回来购买同一种小吃的情况。你将先运行一个单件商品样例,再完成一个 C 程序,将每次购买存为独立记录,并打印每条明细、单价、小计和完整总额。
你需要掌握 C 的变量、条件、循环、数组、函数,以及基本的终端输入和路径操作。本实验会补充所需的结构体和文本输出知识。浏览器环境已准备好编译器和 Workspace 终端。金额采用合成的整数分,不构成支付或会计系统。阅读与练习时长是作者估计,尚未测量初学者的完成时间。
读懂单件商品小票
本步骤中,你将运行一个完整的小样例,把购买一件商品改成购买两件商品。
打开 Workspace Tab。终端初始目录为 /home/labex/project/receipts。cd 切换目录;pwd 显示当前目录,ls 列出文件。这些命令也能帮助你在路径或文件名输错后恢复。
cd /home/labex/project/receipts
pwd
ls
sample.c 是完整的单件商品样例。till.c 是下一步要完成的多商品程序。商品目录、解析器和输入驱动都是已准备的文件,请不要修改。cat 可以显示文本文件而不编辑它:
cat sample.c
样例中的 quantity 初始值为 1。unit_cents 为 125,表示 1 个完整货币单位又 25 分。乘法得到以分计的小计。整个计算都保持这个单位。整数除以 100 得到整数部分;% 100 得到余数。在 printf 中,%d 打印整数,%s 打印以终止符结束的 C 字符串,%02d 将小数部分补齐到两位,\n 则换行。因此,250 分无需浮点运算就能显示为 2.50。
编译程序。gcc 是 C 编译器,-std=c11 选择 C11,-Wall -Wextra 启用警告,-o sample 指定可执行文件名。./sample 运行当前目录中的这个可执行文件。
gcc -std=c11 -Wall -Wextra sample.c -o sample
./sample
完整的正常结果如下:
SUNNY SNACKS
ID | ITEM | QTY | UNIT | SUBTOTAL
101 | Oat bar | 1 | 1.25 | 1.25
TOTAL | 1.25
ID 表示商品标识符:101 指向目录中的这一项。UNIT 是一件商品的价格;SUBTOTAL 是这次购买的数量乘以单价。竖线分隔便于阅读的各列。最后的 TOTAL 累加所有购买小计;这里仅有一条购买。
将数量改为 2。下面的 sed 命令把指定的旧文本替换为新文本;-i 保存修改。单引号让整段替换表达式作为一个 shell 参数传入。
sed -i 's/int quantity = 1;/int quantity = 2;/' sample.c
编辑后需要重新编译:源文件改变不会自动更新旧的可执行文件。shell 运算符 > 把标准输出重定向到文件中。它会在 C 程序启动之前打开或截断该文件,因此请使用下面的新输出文件名。
gcc -std=c11 -Wall -Wextra sample.c -o sample
./sample > sample-receipt.txt
cat sample-receipt.txt
数量现在是 2,小计和总额都为 2.50;单价仍为 1.25。手算确认:125 × 2 = 250 分。保留 sample.c、可执行文件 sample 和 sample-receipt.txt。
如果找不到文件,用前面学过的 cd 返回目录,再通过 ls 核对拼写。编译报错时,修正它指出的那一行,然后重新编译再运行;即使编译失败,旧的可执行文件仍可能存在。
保留并打印每次购买
本步骤中,你将保存独立的购买记录,并生成包含三条完整明细的小票。
从 Workspace 开始,工作目录为 /home/labex/project/receipts。读取商品目录和未完成的程序:
cd /home/labex/project/receipts
cat catalog.h
cat till.c
头文件 catalog.h 提供店名、最多六条购买的限制、最大数量 20,以及四条商品目录记录。编译时,#include "catalog.h" 将这些定义包含进来。#define 为常量或初始化内容命名;CATALOG_ROWS 提供用于初始化商品数组的记录。目录定义行末的反斜杠(\)表示同一个 #define 继续到下一个物理行。本实验中请保持 catalog.h、parse.h、driver.h 和 read-line.h 不变。
结构体把相关字段组合成一条记录。Product 包含整数 id、一个长度为 32 的字符名称数组,以及整数价格 cents。typedef 让程序用 Product 作为这种记录类型的简短名称。C 字符串以零值字符 \0 结束,所以名称数组除了容纳可见字符,还必须为终止符留出位置。已提供的名称都能放下;本任务不解析任意名称。
Purchase 是第二种记录类型。它保留商品在数组中的位置,以及本次购买的数量。在 basket[line_count].quantity 中,方括号选择某次购买,点号选择其数量字段。数组位置从零开始。全局 static 数组和 line_count 都以零初始化。sizeof products / sizeof products[0] 计算数组中完整商品记录的个数。
提供的 find_product 循环按商品 ID匹配,返回其数组位置;找不到时返回 -1。两种名为 Water 的商品具有不同 ID 和价格:103 的价格为 75 分,104 则是免费商品。仅凭名称不能识别商品。目录包含四种商品,ID 范围为 1 到 999,价格范围为 0 到 9999 分。将程序与另一份在这些范围内的四行目录一起重新编译时,也应能够正常使用。
完成 approve_purchase:在写入任何数组项之前,先检查 ID、数量和剩余容量。通过后,才将商品位置和数量存入 basket[line_count],并增加计数。数量范围是 1 到 20;零价格是合法的。不要合并重复的商品 ID:后一次购买应是另一条记录。六条购买都取最大允许价格和数量时,总额最多为 6 × 20 × 9999 = 1,199,880 分,仍在 32 位有符号整数范围内。
puts 打印以终止符结束的字符串并追加换行。printf 使用格式占位符,例如 %s 表示以 NUL 结尾的字符串,%d 表示整数。print_receipt 遍历每条已批准购买。Product product = ... 把对应的目录记录复制到一个局部变量中。计算每条整数小计,累加到总额,并在店名、表头与唯一的末尾 TOTAL 之间打印全部有序明细。使用给出的五个竖线分隔字段;字段周围的空格和空行可以变化,但额外记录或缺少字段都不构成完整小票。
用下面的完整源码替换 till.c。cat > till.c 替换该文件;<<'EOF' 开始一个 here-document。带引号的分隔符让 C 文本原样复制,直到仅包含 EOF 的那一行。请复制整个代码块,包括最后一行。
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
parse.h 和 driver.h 提供已经能工作的输入循环。驱动先询问商品 ID,再询问数量,随后调用你编写的批准与打印函数。它们已有的名称和签名是集成接口,请保持示例所示形式。内部记录类型和局部变量的名字不是评分目标。下一个实验会讲解解析边界。
编译完成的程序:
gcc -std=c11 -Wall -Wextra till.c -o till
驱动把提示和消息写入标准错误,终端仍会显示这些内容。只有完成的小票进入标准输出。使用新的输出文件运行:
./till > receipt.txt
程序现在等待输入。请在对应提示处键入下表各值,每次输入后按 Enter。不要把这些值当作 shell 命令输入。done 是严格小写的结账命令,仅在 Product ID 提示处有效。
| Product ID 输入 | Quantity 输入 | 结果 |
|---|---|---|
101 |
2 |
Added line 1 |
102 |
1 |
Added line 2 |
101 |
1 |
Added line 3,保留先前那次 101 购买 |
done |
不会询问数量 | 完成小票并返回 shell |
你仍能看到提示和 Added line 消息,因为提供的驱动把它们写入另一条输出流——标准错误。只有完成的小票写入标准输出,由 > 送到 receipt.txt。shell 提示符返回后,再读取真实保存的小票:
cat receipt.txt
预期内容:
SUNNY SNACKS
ID | ITEM | QTY | UNIT | SUBTOTAL
101 | Oat bar | 2 | 1.25 | 2.50
102 | Fruit cup | 1 | 2.30 | 2.30
101 | Oat bar | 1 | 1.25 | 1.25
TOTAL | 6.05
两次 Oat bar 购买仍各占一行。把它们与 Fruit cup 的小计相加:250 + 230 + 125 = 605 分。只有一个看似合理的总额,却没有这些完整明细,会丢失顾客的交易详情。
数字输入使用十进制数字,可带可选符号,首尾也可有 ASCII 空格或 Tab;允许前导零。ID 必须存在于目录中,数量范围为 1..20,每行在按 Enter 前最多输入 63 字节。拒绝 ID 或数量时,会丢弃当前待处理候选项并返回 Product ID,而不改变已批准购买。提供的读取器会读完并丢弃超长行,而不会把它拆成后续输入。最多批准六次购买。空购物篮输入 done 也会生成合法空小票,总额为 0.00。
若要取消而非结账,在空的输入提示处按 Ctrl+D。这个文件结束信号,或者在尚未完成的输入行中遇到 EOF,都会以状态 2 取消,不生成已完成小票。它不会导出或保留未完成交易。重定向已经打开了输出文件;如果有意测试取消,请使用另一个文件名。在数量提示处输入 done 是无效数量文本,不是结账。
如果小票只剩最后一次购买,检查是否存入 basket[line_count],以及每次批准后是否增加计数。保存、重新编译,再用相同的要求购买记录运行一次新交易。每次执行都从空购物篮开始;程序不会重新载入旧小票文本。保留 till.c、可执行程序 till、提供的文件,以及完整的 receipt.txt。课程检查还会在一次性目录中,对未修改的源码副本进行运行时边界检测。它能发现这些输入实际触及的错误,但不能证明完全的内存安全,也不会修复你的文件。
总结
你把单件商品样例扩展成了完整的多行小票。你使用结构体、保存独立购买的数组、稳定的商品 ID 和整数分运算,随后保存并读取了真实文本结果。下一个实验将关注购买进入数组之前的输入边界。


