简介:这份山东科技大学《数据库系统概论》课程设计实验报告,面向计算机科学与技术等专业学生及需要完成数据库课程设计的读者,围绕实现一个简易DBMS的建表与改表功能展开。内容涵盖课程设计任务书、需求分析、设计思想、程序流程图与物理存储结构说明,重点讲解CREATE TABLE与ALTER TABLE语句的解析与实现,以及“新建表、修改表、显示已建表信息、保存并退出”四个模块,并讨论用数组保存表名、字段名、类型和列级约束,借助table.txt持久化表结构、启动时加载的设计思路。压缩包共1个doc文件,约408KB,适合直接查阅报告结构、功能划分与实现细节。已有934人学习,可为课程设计选题、报告撰写、命令行与图形界面交互设计及表结构存储方案提供较完整的参考。
1. 从一条 CREATE TABLE 说起:这个课程设计到底做了什么
数据库系统概论这门课,考试时能默写 B+ 树和三级模式,可真让你把CREATE TABLE student(sno char(10) primary key, ...)这句 SQL 落到磁盘上,多半就卡住了。这个课程设计做的就是这件事:用 C 写一个迷你 DBMS 的结构定义模块,能建表、改表、显示已建表的结构、把表结构存成文件下次再读回来,命令行和菜单式交互两条路都留着。它不适合当生产力工具,但对刚啃完数据库原理、想找个几百行小工程把手感练起来的人很合适——一句 SQL 从字符串变成磁盘上一行行文本的全过程,自己写一遍才算真看懂。下面按我拆项目的顺序讲:先看骨架,再逐段啃 CREATE 和 ALTER 的解析,最后说怎么在这个骨架上继续加东西。
2. 整体架构与存储结构:几组字符数组撑起一套表管理流程
2.1 从交互层到落盘:四个功能模块怎么串起来
这个程序的骨架非常朴素,没有分层框架、没有设计模式,就是一个main里的while循环,加上四个职责清晰的函数。它对应任务书里的四项要求:新建表、修改表、显示已建表的信息、保存并退出。我把它整理成下面这张职责表,读源码时对着看会快很多。
| 函数 | 职责 | 输入 | 输出 |
|---|---|---|---|
SQL_CREATE | 解析建表语句,把表名、列名、类型、约束写进内存数组 | 一条以;结尾的 CREATE 语句 | 成功返回 1,失败打印原因返回 0 |
SQL_ALTER | 解析改表语句,定位目标表后增删改列 | 一条以;结尾的 ALTER 语句 | 同上 |
Read/Show | 启动时从 table.txt 回填内存,运行时打印所有表结构 | 无(或文件路径) | 屏幕输出 |
Save | 把内存里的全部表结构整体重写回 table.txt | 文件路径 | 覆盖写 |
主循环大致是这样的结构:
int main(void) { char sql[512]; loadAll("table.txt"); // 启动先把上次的表结构读回来 while (1) { showMenu(); // 1 新建 2 修改 3 显示 0 保存退出 int choice = readChoice(); if (choice == 0) break; switch (choice) { case 1: printf("请输入 CREATE 语句(以;结尾): "); readLine(sql, sizeof(sql)); SQL_CREATE(sql); break; case 2: printf("请输入 ALTER 语句(以;结尾): "); readLine(sql, sizeof(sql)); SQL_ALTER(sql); break; case 3: Show(); break; default: printf("无效选项\n"); } } saveAll("table.txt"); // 退出前统一落盘 return 0; }这里有个值得说一句的设计取舍:程序不在每次建表或改表后立刻写文件,而是退出时一次性重写。原作者在说明书里也写了这个理由——对已存在的表做修改后,想直接在文件里定位到那一行再改,位置计算很麻烦;干脆整体清空重写,逻辑简单一百倍。对一个几百行的教学项目来说,这个选择是对的,代价是程序被强杀时这次改的动作会丢。真要做成能二次开发的东西,我一般会在每次结构变更后调一次saveAll,代价只是多几次几百字节的写盘。
另一个容易被忽略的点是“演示顺序”。任务书里要求“演示建立了一个表,并修改了表结构”,所以Show()必须能在任意时刻打印出当前内存里的完整结构,包括类型后面那个(20)长度修饰和约束条件。这个函数写得好不好,直接决定你答辩时能不能一眼看出改对了没有。
2.2 表结构在内存里怎么摆
整个程序的核心是几组全局的定长字符数组。原始代码里写成了char tableName[LEN][MAX],其中LEN和MAX都是同一个量级的常量,这个维度顺序其实是反的:tableName[number]被当作“第 number 张表的表名”来用,那第一个维度就必须是表的数量上限,第二个才是名字长度。搞反了不会编译报错,但你在strcpy(tableName[number], t_name)的时候会踩到越界的玄学问题——有时候跑得好好的,加第三张表就崩。
我按正确的维度顺序重新整理了一版,加上了注释:
#define MAX_TABLE 16 /* 最多支持几 张表 */ #define MAX_FIELD 32 /* 每张表最多几 列 */ #define LEN 64 /* 名字/类型串的最大长度 */ char tableName[MAX_TABLE][LEN]; /* 表名 */ int proNum[MAX_TABLE]; /* 每张表当前有几列 */ char fieldName[MAX_TABLE][MAX_FIELD][LEN]; /* 列名 */ char fieldType[MAX_TABLE][MAX_FIELD][LEN]; /* 类型,带长度修饰,如 char(20) */ char fieldCond[MAX_TABLE][MAX_FIELD][LEN]; /* 约束编码:PK / UQ / NN / NULL */ int tableCount = 0; /* 当前已建表数量 */这五组数组就是整个 DBMS 的“数据字典”。tableCount是全局游标,建表时tableCount++,改表时通过findTable拿到下标。注意proNum是每张表独立的列数,不能用一个全局变量代替——这是最容易犯的错,我见过有同学写了个全局int num,建第二张表时列数直接串到第一张表上去。
类型我选择把长度修饰直接并进fieldType,存成char(20)这样一个完整字符串,而不是像原始代码那样单独开一个f_t_len数组存(、2、0、)四个字符。原始写法的问题在于:它只取了一位数字。
/* 原始代码片段,只看不改 */ f_t_len[number][num][0] = '('; f_t_len[number][num][1] = f_length + 48; /* 长度大于 9 就废了 */ f_t_len[number][num][2] = ')'; f_t_len[number][num][3] = '\0';f_length是解析出来的整数长度,这里用+48把它转成字符,只转了个位数。写char(20)的时候,长度是两位,f_length算出 20,20+48得到 68,转成字符是'D',最后屏幕上显示的是char(D)。这个 bug 在演示char(10)的时候完全看不出来,一旦老师让你建个char(50),当场翻车。合并成字符串加sprintf就没这个问题:
/* 长度解析与拼接,digits 至少为 1 才合法 */ int len = 0, digits = 0; while (*p >= '0' && *p <= '9') { len = len * 10 + (*p - '0'); p++; digits++; } if (digits == 0 || *p != ')') { printf("长度写法有误\n"); return 0; } p++; sprintf(fieldType[t][n] + strlen(fieldType[t][n]), "(%d)", len);sprintf的目标地址用fieldType[t][n] + strlen(...),也就是追加到已有类型名后面,char加(20)拼成char(20)。这样长度多大都对,后面写文件、打印、比较都是一整个字符串,省掉一堆下标运算。
2.3 存储文件格式:一行一列,别让空格咬你
落盘用的是纯文本,这也是最省事的选择——出问题了直接打开记事本就能看,不用写十六进制查看器。格式我设计成“一行一列”:
2 student 3 sno char(10) PK sname char(20) NN age int NULL course 2 cno char(8) PK cname char(30) UQ第一行是表的数量,之后每张表两段:先是“表名 + 列数”一行,然后按列数跟若干行列定义。每行列定义三个字段:列名、类型(含长度)、约束编码。约束用编码而不是原文,是这份格式最关键的一个决定,原因在避坑那一章细说。
约束编码对照表:
| SQL 里的写法 | 存盘编码 | 显示时还原成 |
|---|---|---|
PRIMARY KEY | PK | primary key |
UNIQUE | UQ | unique |
NOT NULL | NN | not null |
| 不写约束 | NULL | 无 |
这样设计之后,loadAll和saveAll就是一对严格对称的函数,读的时候按同样的行序推回去即可。对称性是这个模块唯一的正确性保证——只要saveAll写出来的东西loadAll一定读得回来,中间就不会出现“存了但读不出”的黑匣子状态。
3. CREATE TABLE 解析:一个字符指针走到底的手写词法器
3.1 先把扫描器抽出来:skipSpace 与 readToken
正规做法是先做词法分析,把 SQL 切成 token 数组,再做语法分析。对这个规模的项目,我一般不走那套,直接用一个游标指针char *p在原始字符串上向前推进,配两个小函数就够了。
/* 跳过空白,返回第一个非空白字符的位置 */ static char *skipSpace(char *p) { while (*p == ' ' || *p == '\t' || *p == '\n') p++; return p; } /* 从 *pp 开始读一个 token,转小写后写入 out,返回长度 token 的分隔符:空格、逗号、左右括号、分号、字符串结束 */ static int readToken(char **pp, char *out, int cap) { char *p = skipSpace(*pp); int n = 0; while (*p && *p != ' ' && *p != ',' && *p != '(' && *p != ')' && *p != ';') { if (n < cap - 1) out[n++] = (char)tolower((unsigned char)*p); p++; } out[n] = '\0'; *pp = p; return n; }readToken有两个设计点值得说明。一是它统一转小写,这样用户在终端敲CREATE TABLE还是create table都能认,SQL 关键字本来就不区分大小写。二是它把(、)、,、;全部当分隔符,这几个符号在 CREATE 语句里只起结构作用,不参与内容,让它们在读 token 时天然被切开,主流程就不用自己做if (*p == '(')这种逐字符判断了。
cap参数是防溢出的,调用方传LEN进来,函数内部做n < cap - 1判断。定长数组项目里最怕的就是用户敲一长串字符把栈冲了,加这么一个上限判断,成本几乎为零。
3.2 主流程:create → table → 表名 → 括号 → 列定义循环
有了readToken,主流程就变成一串“读一个、比一个”的动作。
int SQL_CREATE(char *sql) { char kw[LEN], tname[LEN]; char *p = sql; int t; if (!readToken(&p, kw, LEN) || strcmp(kw, "create") != 0) { printf("\nCREATE 拼写有误,请重新输入\n"); return 0; } if (!readToken(&p, kw, LEN) || strcmp(kw, "table") != 0) { printf("\nTABLE 拼写有误,请重新输入\n"); return 0; } if (!readToken(&p, tname, LEN)) { printf("\n缺少表名\n"); return 0; } if (findTable(tname) >= 0) { printf("\n表 %s 已存在\n", tname); return 0; } if (tableCount >= MAX_TABLE) { printf("\n表数量已达上限\n"); return 0; } t = tableCount; strcpy(tableName[t], tname); proNum[t] = 0; p = skipSpace(p); if (*p != '(') { printf("\n表名后必须跟左括号\n"); return 0; } p++; while (1) { /* 一列一轮 */ char fname[LEN], ftype[LEN]; if (!readToken(&p, fname, LEN)) { printf("\n列定义不完整\n"); return 0; } if (!readToken(&p, ftype, LEN)) { printf("\n缺少列类型\n"); return 0; } if (!addColumn(t, fname, ftype, &p)) return 0; p = skipSpace(p); if (*p == ',') { p++; continue; } /* 还有下一列 */ if (*p == ')') { p++; break; } /* 列定义结束 */ printf("\n列定义之间缺少逗号或右括号\n"); return 0; } tableCount++; /* 只有整条语句都成功才提交 */ return 1; }这段代码最值得学的是“提交时机”。t = tableCount先占位,但tableCount++放在最后,中间任何一步失败都直接return 0,表数量不增加,等于这次建表被丢弃。原始代码里是在函数开头就Pro_Num[number] = num; number++;,解析到一半发现类型写错,退出时表和列数已经算进去了,后面的表就全错位。这是典型的事务思路在几十行代码里的最小应用——不要求你写日志和回滚,至少做到“要么全成,要么不动”。
addColumn负责一列内部的细节,签名为addColumn(t, fname, ftype, &p),最后一个参数是游标指针的地址,函数内部要继续往后读长度和约束,读完了把游标位置回写给调用方。
3.3 类型、长度与约束:三种约束怎么落到编码上
类型只认五个关键字,任务书里已经给死了:char、int、float、double、date。做法是一张静态表加一个循环匹配。
static const char *kLegalType[] = {"char", "int", "float", "double", "date"}; static int addColumn(int t, char *fname, char *ftype, char **pp) { int i, n = proNum[t]; char *p; if (findField(t, fname) >= 0) { printf("\n列名 %s 重复\n", fname); return 0; } for (i = 0; i < 5; i++) if (strcmp(ftype, kLegalType[i]) == 0) break; if (i == 5) { printf("\n不支持的类型 %s\n", ftype); return 0; } strcpy(fieldName[t][n], fname); strcpy(fieldType[t][n], ftype); strcpy(fieldCond[t][n], "NULL"); p = skipSpace(*pp); if (*p == '(') { /* char(20) 这类长度修饰 */ int len = 0, digits = 0; p++; while (*p >= '0' && *p <= '9') { len = len * 10 + (*p - '0'); p++; digits++; } if (digits == 0 || *p != ')') { printf("\n长度写法有误\n"); return 0; } p++; sprintf(fieldType[t][n] + strlen(fieldType[t][n]), "(%d)", len); } if (!parseConstraint(t, n, &p)) return 0; *pp = p; proNum[t]++; /* 这一列确认完整,列数才加一 */ return 1; }长度解析用累加:len = len * 10 + (*p - '0'),这样char(100)能算出 100,char(5)算 5,两位三位都行。如果长度写成字母,digits会是 0,直接报错;如果数字后面不是),比如用户漏了右括号,也报错。这两种错误如果不拦,后面游标位置就全乱了。
约束解析单独抽成parseConstraint,因为它要处理带空格的组合词:
static int parseConstraint(int t, int n, char **pp) { char tok[LEN]; char *p = skipSpace(*pp); if (*p == ',' || *p == ')' || *p == ';') return 1; /* 没写约束 */ if (!readToken(&p, tok, LEN)) return 1; if (strcmp(tok, "primary") == 0) { if (!readToken(&p, tok, LEN) || strcmp(tok, "key") != 0) { printf("\nPRIMARY 后面必须跟 KEY\n"); return 0; } strcpy(fieldCond[t][n], "PK"); } else if (strcmp(tok, "unique") == 0) { strcpy(fieldCond[t][n], "UQ"); } else if (strcmp(tok, "not") == 0) { if (!readToken(&p, tok, LEN) || strcmp(tok, "null") != 0) { printf("\nNOT 后面必须跟 NULL\n"); return 0; } strcpy(fieldCond[t][n], "NN"); } else { printf("\n不认识的约束条件 %s\n", tok); return 0; } *pp = p; return 1; }这里我把primary key和not null都拆成两个 token 读,而不是去字符串里找子串。为什么不用strstr?因为strstr会被列名骗到——你建一个叫primary_count的列,strstr一样能匹配上。按 token 顺序读是唯一稳妥的方式,代价只是多写两行。
写完之后回头看一眼函数链:SQL_CREATE管语句级结构,addColumn管列级结构,parseConstraint管约束级结构,三层各管各的,加新约束只要动最后那个else if。这种拆法比原始代码里那个一百多行、所有逻辑堆在一个函数里的版本舒服太多,出问题时也知道该去哪个函数打printf。
4. ALTER TABLE 与读表回填:改结构比建结构难在哪
4.1 先找表:findTable 与大小写
改表的第一步是确认表存在。写个findTable返回下标,找不到返回 -1,这个返回值约定要在所有调用点保持一致。
/* 返回表在数组中的下标,不存在返回 -1 */ int findTable(const char *name) { int i; for (i = 0; i < tableCount; i++) if (strcmp(tableName[i], name) == 0) return i; return -1; } /* 返回某张表里列的下标,不存在返回 -1 */ int findField(int t, const char *name) { int i; for (i = 0; i < proNum[t]; i++) if (strcmp(fieldName[t][i], name) == 0) return i; return -1; }大小写问题在这里会咬人。readToken把 token 转小写了,所以建表时存下来的表名和列名全是小写;如果用户在 ALTER 里敲ALTER TABLE Student ADD ...,也走readToken转小写,能对上。但如果哪天为了“显示得好看”,把表名按用户输入的原样存下来,那 ALTER 就永远找不到表。我的做法是全流程统一小写,显示时也不还原,宁可难看也不出错。
4.2 ADD / DROP / MODIFY 三条分支的实现差异
ALTER 的入口和 CREATE 结构相似,读完alter、table、表名之后,靠下一个 token 分发到三条分支。
int SQL_ALTER(char *sql) { char kw[LEN], tname[LEN]; char *p = sql; int t; if (!readToken(&p, kw, LEN) || strcmp(kw, "alter") != 0) { printf("\nALTER 拼写有误\n"); return 0; } if (!readToken(&p, kw, LEN) || strcmp(kw, "table") != 0) { printf("\nTABLE 拼写有误\n"); return 0; } if (!readToken(&p, tname, LEN)) { printf("\n缺少表名\n"); return 0; } t = findTable(tname); if (t < 0) { printf("\n表 %s 不存在\n", tname); return 0; } if (!readToken(&p, kw, LEN)) { printf("\n缺少 ALTER 子句\n"); return 0; } if (strcmp(kw, "add") == 0) return alterAdd(t, &p); if (strcmp(kw, "drop") == 0) return alterDrop(t, &p); if (strcmp(kw, "modify") == 0) return alterModify(t, &p); printf("\n不支持的 ALTER 子句 %s\n", kw); return 0; }ADD 分支实际上是复用addColumn,因为“往表里加一列”和“建表时定义一列”在结构上完全一样。
static int alterAdd(int t, char **pp) { char fname[LEN], ftype[LEN]; if (proNum[t] >= MAX_FIELD) { printf("\n列数已达上限\n"); return 0; } if (!readToken(pp, fname, LEN)) { printf("\nADD 缺少列名\n"); return 0; } if (!readToken(pp, ftype, LEN)) { printf("\nADD 缺少类型\n"); return 0; } return addColumn(t, fname, ftype, pp); /* 类型校验、长度、约束全走同一条路 */ }DROP 要麻烦一点,因为它涉及数组元素的“删除”——把目标列之后的所有列整体前移一格,然后把列数减一。
static int alterDrop(int t, char **pp) { char name[LEN]; int idx, k; if (!readToken(pp, name, LEN)) { printf("\nDROP 缺少列名\n"); return 0; } if (strcmp(name, "column") == 0) /* 兼容 DROP COLUMN x 的写法 */ if (!readToken(pp, name, LEN)) { printf("\nDROP COLUMN 缺少列名\n"); return 0; } idx = findField(t, name); if (idx < 0) { printf("\n列 %s 不存在\n", name); return 0; } if (proNum[t] <= 1) { printf("\n至少要保留一列\n"); return 0; } for (k = idx; k < proNum[t] - 1; k++) { /* 后面整体前移 */ strcpy(fieldName[t][k], fieldName[t][k + 1]); strcpy(fieldType[t][k], fieldType[t][k + 1]); strcpy(fieldCond[t][k], fieldCond[t][k + 1]); } proNum[t]--; printf("列 %s 已删除\n", name); return 1; }前移循环的边界是k < proNum[t] - 1,最后一列特殊——它不需要被复制,直接靠proNum[t]--把它“切掉”。这个写法比先memset清理最后一格再减一更简洁,因为被切掉的那格数据反正不会再被访问。
MODIFY 只改类型,不动列的位置:
static int alterModify(int t, char **pp) { char name[LEN], type[LEN]; int idx, i; if (!readToken(pp, name, LEN)) { printf("\nMODIFY 缺少列名\n"); return 0; } if (!readToken(pp, type, LEN)) { printf("\nMODIFY 缺少新类型\n"); return 0; } idx = findField(t, name); if (idx < 0) { printf("\n列 %s 不存在\n", name); return 0; } for (i = 0; i < 5; i++) if (strcmp(type, kLegalType[i]) == 0) break; if (i == 5) { printf("\n不支持的类型 %s\n", type); return 0; } strcpy(fieldType[t][idx], type); { /* 新类型如果带长度修饰,一起拼上去 */ char *p = skipSpace(*pp); if (*p == '(') { int len = 0, digits = 0; p++; while (*p >= '0' && *p <= '9') { len = len * 10 + (*p - '0'); p++; digits++; } if (digits > 0 && *p == ')') { sprintf(fieldType[t][idx] + strlen(fieldType[t][idx]), "(%d)", len); } } } printf("列 %s 的类型已改为 %s\n", name, fieldType[t][idx]); return 1; }三条分支的差别其实就三句话:ADD 是末尾追加,DROP 是中间挖洞后前移,MODIFY 是原地改字。理解了这个差异,再去看那些把 ALTER 写了一百多行、三种情况全揉在一个if-else瀑布里的代码,就知道该往哪切了。
4.3 载入与保存:loadAll / saveAll 的对称性
存取这一对函数是全文最需要小心的地方,因为它决定了程序能不能“记住”上次建的表。
int saveAll(const char *path) { FILE *fp = fopen(path, "w"); int t, i; if (!fp) { printf("\n无法写入 %s\n", path); return 0; } fprintf(fp, "%d\n", tableCount); /* 第一行:表数量 */ for (t = 0; t < tableCount; t++) { fprintf(fp, "%s %d\n", tableName[t], proNum[t]); /* 表名 + 列数 */ for (i = 0; i < proNum[t]; i++) { fprintf(fp, "%s %s %s\n", fieldName[t][i], fieldType[t][i], fieldCond[t][i]); } } fclose(fp); return 1; }saveAll用的是“全量覆盖”,先fopen(path, "w")把文件清空,再从头写一遍。原始代码也是这个思路,注释里写得很明白:改一张表之后想定位到原位置再局部覆盖太麻烦,不如全部重写。代价是如果要存十万张表会很慢,但这个规模最多十几张表,无所谓。
loadAll必须严格按saveAll的行序读回来,而且要用“整行读 + sscanf”的方式,不能直接fscanf(fp, "%s")一个个 token 读。
int loadAll(const char *path) { FILE *fp = fopen(path, "r"); char line[256]; int t, i; if (!fp) return 0; /* 首次运行没有文件,不算错误 */ if (!fgets(line, sizeof(line), fp)) { fclose(fp); return 0; } tableCount = atoi(line); if (tableCount < 0 || tableCount > MAX_TABLE) { tableCount = 0; fclose(fp); return 0; } for (t = 0; t < tableCount; t++) { if (!fgets(line, sizeof(line), fp)) break; /* 表头行 */ if (sscanf(line, "%63s %d", tableName[t], &proNum[t]) != 2) break; if (proNum[t] < 0 || proNum[t] > MAX_FIELD) { proNum[t] = 0; break; } for (i = 0; i < proNum[t]; i++) { if (!fgets(line, sizeof(line), fp)) break; /* 一列一行 */ if (sscanf(line, "%63s %63s %63s", fieldName[t][i], fieldType[t][i], fieldCond[t][i]) != 3) break; } } fclose(fp); return 1; }fgets拿整行、sscanf再从行里切三个%63s,这个组合的好处是每一列占据固定的一行,读错一行不会像 token 流那样把后面全带歪。%63s的数字 63 对应LEN - 1,一定要写,不写就是标准的缓冲区溢出。
5. 避坑与排查:五个调了整晚的问题
5.1 现象:重启后第二张表读不出来,第三张表的列全串了
原因:存储格式里约束条件带空格,比如not null、primary key。用fscanf(fp, "%s")或ioFile >> str这类按空白切分的方式读,会把not读进约束字段,把null当成下一行的开头,从此整个文件的行列对齐全部错位。原始代码里正是用ioFile >> _condition1[i]再判断是不是not的方式补救,但补救本身又引入了“读到什么算对”的歧义。
解决:约束存盘时编码成无空格的单 token(PK/UQ/NN/NULL),显示的时候再翻译回去。或者退一步,用fgets整行读、sscanf按位置切,这样即使约束里带空格,只要它固定在第三个字段,也能用%63[^\n]把整行剩余部分吃掉。两种都行,我倾向前者,因为编码后的文件肉眼可读性更好,也方便 diff。
5.2 现象:编译报 “cannot convert 'const char*' to 'char'”
原因:代码里出现了这种写法fieldCondition[number][i] = 'primary key';——把一个字符串字面量的地址赋给一个char元素(或者在某些老编译器里被隐式截断成指针),编译期直接报错;就算强行用(char)转过去编译通过,运行时也是一次野指针访问。
解决:字符串赋值一律用strcpy/strncpy/sprintf,并且保证目标有足够空间。
/* 错 */ fieldCond[t][n] = "PK"; /* 对 */ strcpy(fieldCond[t][n], "PK");顺带说一个同类问题:char name[20]; name = "abc";也一样不合法,数组名是常量地址,不能重新赋值。这类错误在老版本的 C 教材里经常被一笔带过,真上手写才会撞上。
5.3 现象:程序读回文件后,最后一张表重复出现一次
原因:循环条件写成while (!ioFile.eof())或while (!feof(fp))。eof标志不是“读到末尾就为真”,而是“上一次读取尝试失败之后才为真”。所以文件最后一个字符读完后,循环体还会再执行一轮,此时所有变量都是上一轮的残留值,于是数据被重复写了一遍。
解决:不要用eof做循环条件,改用读取函数的返回值做前置判断。
/* 错 */ while (!feof(fp)) { fscanf(fp, "%s", buf); /* 最后一次 buf 是上一轮的残留 */ } /* 对 */ while (fgets(line, sizeof(line), fp) != NULL) { /* 只有真读到内容才进循环 */ }同理,C++ 里的while (!ioFile.eof())换成while (ioFile >> str),前者是坑,后者是标准写法。
5.4 现象:建char(50)的列,显示出来是char(D)
原因:长度转换只处理了个位。f_t_len[...][1] = f_length + 48;,当f_length是 50 时,50 + 48 = 98,对应字符'b';当f_length是 20 时是 68,对应'D'。位数越多错得越离谱。而演示时大家都习惯随手写char(10),10+48=58是':',看起来也怪,但很容易被当成下标偏移忽略过去。
解决:不要用+48手工转字符,直接sprintf或者itoa。如果真要手工转,得先算出位数,从高位到低位逐位取余,代码量反而更多。
/* 我一般这么写,省事且没有位数限制 */ sprintf(fieldType[t][n] + strlen(fieldType[t][n]), "(%d)", len);5.5 现象:换一台机器跑,之前建的表全没了,还静悄悄地建了个空文件
原因:路径写死成绝对路径,比如C:\\Users\\Administrator\\Desktop\\DBMS.txt。换台机器这个目录不存在,fopen或fstream::open返回失败,但代码里没判断返回值,程序认为“文件是空的”,于是从tableCount = 0开始;退出时saveAll用"w"模式打开,创建一个新文件,把原来的数据目录彻底覆盖成空的。
解决:路径改成相对路径"table.txt",和工作目录绑定;同时在fopen之后必须判断返回值,读失败就把tableCount清零并提示,写失败就打印错误并放弃保存,而不是继续往下跑。更进一步,保存时可以写成table.txt.tmp,写成功后再rename覆盖正式文件,这样中途崩了也不会毁掉上一份数据。
6. 二次开发:在这个骨架上加数据行与图形界面
这个骨架最值得继续做的一件事,是把“只存结构”推进到“能存数据”。任务书里只要求建表和改表,但从建表到插数据只差一步:CREATE TABLE已经把列数、类型、长度都记下来了,完全可以按定长记录往另一个文件里写行。
具体做法是给每张表加一个数据文件<表名>.dat,每行对应一条记录,各字段按fieldType里的长度定义补齐。int固定 4 字节,char(n)固定 n 字节,写的时候用fwrite按字节写,读的时候按同样的偏移量fread回来。这样ALTER TABLE ADD一列之后旧文件就会读不了,得在表头里记一个版本号或者列数,读到不匹配就提示“结构已变更,数据文件需要重建”。这是个很真实的工程问题,比单纯解析 SQL 有意思得多。
改造点我整理成这张表,按难度从低到高:
| 改造点 | 主要改动位置 | 验证方式 |
|---|---|---|
| 结构变更后立即落盘 | SQL_CREATE/SQL_ALTER末尾加saveAll | 建表后强杀进程,重启看表还在不在 |
| 支持多列约束 | 新增tableCond数组,解析完所有列后再扫一遍表级约束 | 建primary key(sno, cno)的复合主键表 |
| 加数据行存储 | 新增insertRow/selectRow,每表一个.dat文件 | 建表、插三条、重启、再查,数据不丢 |
| 图形界面 | 把四个函数原样搬到按钮回调里,输入框取文本丢给SQL_CREATE | 按钮点一遍,结果和命令行一致 |
图形界面这块我要单独说一句。任务书写了“命令行和图形化界面两种形式”,很多人卡在这里是因为把解析逻辑和界面绑在一起了。只要SQL_CREATE(char *sql)的入参是一个字符串、返回是一个 0/1,界面就只是往这个函数里传字符串而已。VC6++ 里的 Win32 对话框程序或者 Code::Blocks 里的 wxWidgets 都行,核心一行都不用改。当年用 VC6 写的时候还踩过一个纯编译器层面的坑:VC6 默认按 C89 走,变量必须在块的开头声明,代码中间写int _i = 0;会报 C2143。改法很简单,把声明提到块首,或者直接把工程编译选项切到 C++。
验证方法我习惯用一个笨办法:写一个test.sql,把十来条语句按顺序列好——建两张表、给其中一张加一列、改一列类型、删一列、再故意写两条错的——然后写个脚本逐行喂给程序,跑完把table.txt拷一份,手工核对一遍,再重启程序Show()一次,两边的输出必须一模一样。这个流程走一遍,存取不对称、游标错位、覆盖丢数据这几类问题基本都能炸出来。
从那以后我每次接这种“字符串进、结构出”的小工具,都强制先把存和取写成一对、跑通一次往返再往下写功能,绝不先写
本文还有配套的精品资源,点击获取