☰
C语言实现简单DBMS:CREATE/ALTER表结构解析与持久化
2026/11/4 9:58:37 网站建设 项目流程

简介:这份山东科技大学数据库系统概论课程设计实验报告,面向计算机科学与技术专业学生及需要完成数据库课程设计的读者,围绕实现一个简易DBMS展开,重点解决CREATE TABLE建表与ALTER TABLE修改表的结构定义、物理存储和持久化问题。资源包仅含1个doc文档,大小约408KB,正文按任务书、需求分析、设计思想、程序流程等部分组织,记录了表名与字段信息数组存储、table.txt读写、新建表/修改表/显示表信息/保存退出四个模块的实现思路,并涉及命令行与图形化界面两种交互方式。已有934人学习,可作为课程设计报告撰写、建表与改表功能实现及存储结构梳理的参考样本,便于读者理解SQL语句解析、表结构保存与动态修改的完整过程。

1. 从一句 CREATE TABLE 开始:这份课设真正要交付的东西

数据库系统概论课程设计选「实现一个简单的 DBMS」这一题,多数人的第一反应是找个现成嵌入式数据库调两个接口交差。真到验收现场,一句「你建的表结构存在哪、下次启动怎么原样读回来」,往往就答不上来了。这道题真正要交的是一条完整链路:把CREATE TABLE和ALTER TABLE两条语句解析成内部的元数据,让元数据落盘、重启可读,并且命令行和图形界面两个入口操作的是同一份数据。整套东西用纯 C 在 VC6++ 或 CodeBlocks 里就能跑起来,不依赖任何第三方库。

2. 总体架构设计:四个功能模块与一份 table.txt 的元数据布局

程序的功能面很窄,只有四件事:新建表、修改表、显示已建表信息、保存并退出。真正花时间的不是 SQL 语法本身,而是元数据的组织方式——同一份表结构要同时在内存数组里存在一份、在磁盘文本文件里存在一份,并且两边能无损互转。这一步设计歪了,后面每加一个功能都要打补丁。

2.1 一套内核两层外壳:命令行与图形界面怎么共用同一份解析代码

设计要求里写明了「命令以命令行和图形化界面两种形式实现」,很多人把它理解成写两套程序,结果两边的行为不一致:命令行里ALTER TABLE student ADD ...能过,图形界面里同样的语句报错。正确做法是把解析和存储做成一层内核,界面只是输入输出的外壳。内核对外只暴露五个函数,参数是字符串,不关心这个字符串是键盘敲进来的还是编辑框里取出来的。

/* 内核接口:界面层只认这五个函数 */ int SQL_CREATE(const char *sql); /* 解析并登记一张新表,成功 1,语法错 0 */ int SQL_ALTER (const char *sql); /* 解析并改写已有表,成功 1,失败 0 */ int Show_Tables(void); /* 按内存数组打印全部表结构 */ int Load_All (const char *path); /* 启动时把 table.txt 读回内存 */ int Save_All (const char *path); /* 退出时整份重写 table.txt */

命令行版本的main()就是一个while循环,gets读一行交给SQL_CREATE或SQL_ALTER;图形界面里两个按钮的回调函数体分别是SQL_CREATE(GetDlgItemText(...))和SQL_ALTER(...),显示区用一个只读多行编辑框,把Show_Tables的输出转成字符串塞进去就行。这样界面层几乎不写逻辑,答辩时也可以明确说清「命令行和图形界面是同一内核的两个前端」,这是设计上得分的一点。

提示:图形界面不必追求美观,Win32 的对话框资源里放三个按钮、一个输入框、一个显示框就够。VC6++ 里建一个基于对话框的 MFC 工程,十几分钟能搭完。

2.2 表结构在内存里怎么摆:二维与三维字符数组的分工

内存侧用一组字符数组承载全部元数据,这是这道课设最直接、也最容易讲清楚的做法。数组的第一维是表序号,第二维是列序号,第三维是字符串内容。名字、类型、约束都是变长文本,用三维字符数组省去了链表指针的麻烦;列长度是一个整数,单独用整型二维数组存,不要塞进字符串里。

#define LEN 32 /* 最多 32 张表,每张表最多 32 列 */ #define MAX 64 /* 单个名字或约束串的最大长度 */ char tableName[LEN][MAX]; /* [表序号] -> 表名 */ char fieldName[LEN][LEN][MAX]; /* [表序号][列序号] -> 列名 */ char fieldType[LEN][LEN][MAX]; /* 类型名:char/int/float/double/date */ int fieldLen [LEN][LEN]; /* 长度值,如 char(20) 存 20,无长度存 0 */ char fieldCons[LEN][LEN][MAX]; /* primary key / unique / not null / NULL */ int fieldNum [LEN]; /* 每张表当前有几列 */ int tableNum = 0; /* 已登记表数量,也是下一次写入的下标 */

之所以把长度拆成int单独存,是这类课设里最典型的翻车点:早期版本用char f_t_len[MAX]存(20)这样的括号串,写入时靠f_length + 48把数字转字符,结果只支持一位数长度,写char(100)落盘后读回来变成(0)或(1)。改成整型后,长度只参与一次%d格式化,怎么都不会坏。这套数组的静态内存开销大约是 32×32×64 字节乘以三个字符串数组,合计两百多 KB,完全在可接受范围内,不需要动态分配。

2.3 落盘格式设计:为什么先清空再重写比原地改更靠谱

磁盘侧的格式必须和内存侧一一对应,读和写用同一套顺序。文件第一行是表的数量,之后每张表先写「表名 列数」,再逐列写「列名 类型 长度 约束」。用空格分隔、一条记录一行,人工打开也能看懂,调试时对着文件检查比打断点快得多。

2 <- 表数量 student 3 <- 表名 列数 sno char 8 primary <- 列名 类型 长度 约束 sname char 20 not null sage int 0 NULL course 2 cno char 6 primary cname char 30 unique

修改表结构时,最省事的策略是「整份重写」而不是「原地定位改」。ALTER 改完之后,内存数组里已经是最终形态,这时直接以写模式打开文件清空,从头按顺序写一遍即可;反过来做随机定位替换,得先算出每一行变长后的偏移量,工作量翻几倍还容易半途写坏。写模式打开文件本身就会截断旧内容,一行fopen(path, "w")就完成了「清空」这个动作。

int Save_All(const char *path) { FILE *fp = fopen(path, "w"); /* "w" 打开即截断,天然完成清空 */ if (!fp) return 0; fprintf(fp, "%d\n", tableNum); /* 先写表数量,读的时候才知道循环几轮 */ for (int i = 0; i < tableNum; i++) { fprintf(fp, "%s %d\n", tableName[i], fieldNum[i]); for (int j = 0; j < fieldNum[i]; j++) { fprintf(fp, "%s %s %d %s\n", fieldName[i][j], fieldType[i][j], fieldLen[i][j], fieldCons[i][j]); } } fclose(fp); return 1; }

读取函数按完全相同的顺序回填:先读表数量,外层循环每轮读表名和列数,内层循环读每一列的四个字段。因为列数写在每一张表的头部,内层for的边界是现成的,不需要任何分隔符判断,也不会出现「读到下一张表去了」的情况。这个「数量先行」的约定,是整个持久化方案里最值得写进报告的一句话。

3. CREATE TABLE 的解析实现:分词、类型映射与约束判定

语句解析是这道题的核心工作量。课设要求的语法是CREATE TABLE <表名> (<列名> <数据类型> [<列完整性约束>][,...][<表完整性约束>]),没有词法分析器可用,只能手写一个逐字符前进的扫描器。思路是维护一个游标i在原始字符串上右移,每遇到一段就跳过空白、截取 token、转小写后与关键字比对。

3.1 逐段扫描的解析流程与关键字校验

扫描的固定套路是「跳空格 → 取到空格或分号为止 → 比较」。第一次取到的是create,第二次是table,第三次是表名,之后进入列定义循环,每轮先取列名,再取类型,再判断后面还有没有长度括号,最后取约束。关键字比较前统一tolower,让CREATE、Create、create都能通过。

int SQL_CREATE(const char *sql) { char buf[MAX]; int i = 0, j; /* 第一步:校验 create */ while (sql[i] == ' ') i++; /* 跳过前导空格 */ for (j = 0; sql[i] && sql[i] != ' ' && sql[i] != ';'; i++, j++) buf[j] = (char)tolower(sql[i]); buf[j] = '\0'; if (strcmp(buf, "create") != 0) return 0; /* 拼错直接返回失败 */ /* 第二步:校验 table,逻辑同上去掉空格后比对 */ while (sql[i] == ' ') i++; for (j = 0; sql[i] && sql[i] != ' ' && sql[i] != ';'; i++, j++) buf[j] = (char)tolower(sql[i]); buf[j] = '\0'; if (strcmp(buf, "table") != 0) return 0; /* 第三步:取表名,之后 sql[i] 停在左括号或空格处 */ while (sql[i] == ' ') i++; return parse_columns(sql, &i); /* 列定义交给下一节处理 */ }

这段代码里没有魔法,三个要点值得在报告里点明:tolower让关键字大小写不敏感,符合 SQL 的通行习惯;每段都以「空格或分号」作为终止条件,所以语句末尾带不带分号都能解析;create和table分开校验,出错时能给出精确提示——「你输入的 create 有误」比笼统的「语句错误」对使用者友好得多,答辩时这也算一个交互设计点。

3.2 数据类型到内部编码的映射表

课设只要求五种类型:char、int、float、double、date。类型名本身要存进fieldType,同时还要判断这一类是否必须带长度参数。用一个并列数组做映射,比写一长串if-else清爽,也方便后面加类型。

类型关键字是否必须带长度长度示例说明
char是char(20)变长字符串,长度上限存在fieldLen
int否int长度位置填 0
float是float(8)精度位数,按课设要求当作长度处理
double是double(16)同上
date否date固定格式,不解析长度

代码上用两个平行数组对着查,命中即取下标,取完才继续往下走:

static const char *TYPE_NAME[5] = {"char", "int", "float", "double", "date"}; static const int NEED_LEN[5] = { 1, 0, 1, 1, 0 }; /* 在 tname 里找类型下标,找不到返回 -1 */ int type_index(const char *tname) { for (int k = 0; k < 5; k++) if (strcmp(tname, TYPE_NAME[k]) == 0) return k; return -1; }

需要长度的那一类,紧跟其后必须是一个(,中间是一串数字,再跟一个)。数字部分要按位累加,写成len = len * 10 + (sql[i] - '0'),这样两位三位都成立——这一步就是前面提到的char(100)问题的根,长度必须当成整数累加,而不是当成一个字符直接塞进括号串。

3.3 列级约束的识别与非法输入回滚

约束有三种写法:primary key(两个 token)、not null(两个 token)、unique(一个 token)。前两种在读到第一个词之后还必须再读一个词确认,只读到primary就落库是不完整的。整个列定义循环里,任何一个环节出错都要能把已经解析了一半的内容丢掉。

/* 约束识别:返回约束串,非法返回 NULL */ const char *parse_cons(const char *sql, int *i) { char w1[MAX], w2[MAX]; int j = 0; while (sql[*i] == ' ') (*i)++; if (sql[*i] == ',' || sql[*i] == ')' || sql[*i] == ';' || sql[*i] == '\0') return "NULL"; /* 没写约束,默认 NULL */ for (j = 0; sql[*i] && sql[*i] != ' ' && sql[*i] != ',' && sql[*i] != ')'; (*i)++, j++) w1[j] = (char)tolower(sql[*i]); w1[j] = '\0'; if (strcmp(w1, "unique") == 0) return "unique"; /* 单 token 约束 */ if (strcmp(w1, "primary") == 0) { /* 必须再吃一个 key */ while (sql[*i] == ' ') (*i)++; for (j = 0; sql[*i] && sql[*i] != ' ' && sql[*i] != ',' && sql[*i] != ')'; (*i)++, j++) w2[j] = (char)tolower(sql[*i]); w2[j] = '\0'; return (strcmp(w2, "key") == 0) ? "primary key" : NULL; } if (strcmp(w1, "not") == 0) { /* 必须再吃一个 null */ while (sql[*i] == ' ') (*i)++; for (j = 0; sql[*i] && sql[*i] != ' ' && sql[*i] != ',' && sql[*i] != ')'; (*i)++, j++) w2[j] = (char)tolower(sql[*i]); w2[j] = '\0'; return (strcmp(w2, "null") == 0) ? "not null" : NULL; } return NULL; /* 不认识的约束 */ }

回滚的做法我一般这么写:为正在解析的这一张表开一组临时数组tmpName/tmpType/tmpLen/tmpCons和计数器tmpNum,全程只写临时数组。等循环读到右括号、确认没有出错,再一次性把这些内容memcpy到正式数组的tableNum那一层,同时tableNum++。中途任何一步返回NULL或类型越界,直接丢掉临时数组返回 0,正式数组一个字节都没动。对比原来的「边解析边写正式数组」,这个改动把「语法错之后留下半张残表」的可能性彻底消掉了——残表一旦进了内存,保存时又会写进文件,下次启动读到一张列数对不上的表,排查起来非常费劲。

4. ALTER TABLE 的三条分支:ADD、DROP、MODIFY 怎么改内存结构

ALTER TABLE比CREATE难在「要先找到目标表」。课设语法写的是ALTER TABLE <表名> [ADD <新列名> <类型> [约束]] [DROP <列名>] [MODIFY <列名> <类型>],三条子句的解析路径不同,但开头两个关键字的校验完全一样,可以复用CREATE的扫描函数。

4.1 表名提取与存在性校验

先按alter→table→ 表名的顺序取三段,拿到表名之后在tableName数组里线性查找,记录命中的表序号t。找不到就报「表 xxx 不存在」并返回,不要往下走。

int find_table(const char *name) { for (int i = 0; i < tableNum; i++) if (strcmp(tableName[i], name) == 0) return i; /* 命中返回表序号 */ return -1; /* -1 表示不存在 */ }

拿到下标之后,后续所有操作都只在这张表的[t][...]一维切片上做,不会波及其它表。这一步的返回值同时承担了「校验结果」和「定位结果」两个角色,比用全局变量记录当前表更安全——尤其是图形界面里连续点两次修改按钮时,全局变量很容易残留上一次的值。

4.2 ADD 分支:追加一列并同步属性计数

ADD是最简单的一条,新列的序号正好是当前列数fieldNum[t],写完这一层之后把计数加一。关键点是新列写在哪里由fieldNum[t]决定,而不是由任何硬编码下标决定,这样一张表加过几列都不会错位。

int alter_add(int t, const char *col, const char *type, int len, const char *cons) { if (fieldNum[t] >= LEN) return 0; /* 列数超上限,拒绝 */ int c = fieldNum[t]; /* 新列落在末尾 */ strcpy(fieldName[t][c], col); strcpy(fieldType[t][c], type); fieldLen [t][c] = len; strcpy(fieldCons[t][c], cons); fieldNum[t] = c + 1; /* 计数与写入同步 */ return 1; }

参数里t是find_table的返回值,col/type/cons是解析出来的三个字符串,len是长度整数。返回值给界面层用来决定提示「修改成功」还是「列数已达上限」。这里容易忽略的是上限判断:三维数组的第一维和第二维都是 32,加到第 33 列就会越界写坏隔壁表的数据,而 C 不会报错,只会让另一张表的列名莫名其妙变成乱码。

4.3 DROP 与 MODIFY 分支:删除后的搬移与类型覆写

DROP不是把那一格清空就完事。假设删掉第 2 列(下标 1),后面还有两列,如果只把fieldNum[t]减一,第 3、4 列并不会自动前移,显示出来的结构就会中间空一格、末尾多出一列幽灵数据。正确做法是找到目标列下标k,把k+1到fieldNum[t]-1的每一列整体向前搬一位,再减计数。

int alter_drop(int t, const char *col) { int k = -1; for (int j = 0; j < fieldNum[t]; j++) if (strcmp(fieldName[t][j], col) == 0) { k = j; break; } if (k < 0) return 0; /* 列不存在 */ for (int j = k; j < fieldNum[t] - 1; j++) { /* 后面的列整体前移 */ strcpy(fieldName[t][j], fieldName[t][j + 1]); strcpy(fieldType[t][j], fieldType[t][j + 1]); strcpy(fieldCons[t][j], fieldCons[t][j + 1]); fieldLen [t][j] = fieldLen[t][j + 1]; } fieldNum[t]--; /* 计数最后减,别提前 */ return 1; }

MODIFY只改类型和长度,不动列名和位置,按列名找到下标后覆写fieldType和fieldLen两个格子即可。这里有一个语义上的取舍要写进报告:课设层面表里并没有真实数据行,所以改类型不需要做数据转换;但要说明「如果表内已有数据,MODIFY需要逐行做类型转换并检查是否溢出」,把边界说清楚,比装作没这回事更显功底。三条分支执行完之后统一调用一次Save_All,把整份元数据重写回文件,保证内存和磁盘始终一致。

5. 常见问题排查:五个最容易翻车的点

这套程序跑通不难,难在稳定。下面五条是我在实际调试里反复遇到的,每一条都能让程序看起来「跑通了」,却在某个输入下突然出问题。

5.1 长度为两位数时读回来变成 (0) 或 (1)

现象是CREATE TABLE t (name char(100))建表成功,SHOW出来却显示char(1)甚至char(0),重启后更乱。原因在于把长度当字符处理:f_t_len[1] = f_length + 48只能把 0 到 9 转成字符,100 加 48 直接溢出成别的字符。解决办法是把长度改成int fieldLen[LEN][LEN],解析时按位累加len = len * 10 + (sql[i] - '0'),落盘时用%d输出。改完之后char(255)这种也能正确往返。

5.2 语句里多一个空格或逗号就解析错位

现象是同一条语句在命令行里能过、手工敲成create table student(sno char(8),sname char(20))就报类型错误。原因是扫描器只在固定位置跳一次空格,遇到连续空格或逗号后空格就取到了空 token。解决方式是所有「跳过」都用while (sql[i] == ' ' || sql[i] == ',') i++;而不是if,并且在读取列名、类型、约束之前各跳一次。这条改动的收益很高,用户手敲 SQL 时几乎不可能严格控制空格。

5.3 保存时用了追加模式,第二次运行表数量翻倍

现象是第一次建两张表,退出再进,SHOW出来变成四张,表名还重复。原因是保存函数以ios::app或追加方式打开文件,新内容接在旧内容后面,而读取函数只按第一行的表数量循环,读到的是上一轮那批旧数据。解决办法是明确用写模式fopen(path, "w")打开,利用截断特性完成清空;如果担心写到一半崩溃导致文件损坏,可以写临时文件table.tmp,写完后remove原文件再rename,这一步只需三行代码。

5.4 DROP 之后列数没跟着变,出现越界或幽灵列

现象是删掉中间一列后SHOW出来的结构少了一列但末尾多出一列重复内容,或者再加列时直接崩溃。原因有两个:删除时只清了目标格子没做前移,或者提前把fieldNum减了导致搬移循环边界算错。解决办法是先把k+1到fieldNum-1的列整体左移一位,最后再减计数,顺序不能颠倒。搬移时四个数组(名字、类型、长度、约束)必须同步搬,漏掉任何一个都会出现「列名是新的、类型是旧的」这种诡异状态。

5.5 文件路径写成了绝对路径,换台机器就读不到表

现象是在自己电脑上一切正常,拷到同学机器或机房上启动后显示「无表可显示」,但table.txt明明就在程序目录里。原因是代码里写死了C:\\Users\\xxx\\Desktop\\DBMS.txt这样的绝对路径,换环境自然找不到。解决办法是统一用相对路径table.txt,路径相对当前工作目录解析;同时在Load_All里判断fopen返回NULL时给出「首次运行,未发现表文件」的提示而不是直接崩溃,这个提示也是答辩演示时的一个加分细节。

注意:以上五条里,第 5.1 和第 5.3 属于「不修也能演示、一深究就露馅」的类型。验收前务必拿一份含两位数长度、含多次增删列的表结构做一次完整的退出重进测试。

6. 验收与答辩:一份可复现的测试脚本和三个必答问题

演示环节最怕的是临场敲错一条语句,然后花五分钟排查。稳妥做法是提前准备一份按顺序执行的测试脚本,现场只负责粘贴和讲解,把注意力放在「为什么这么设计」上,而不是「为什么又报错了」。下面的用例覆盖了建表、加列、改类型、删列、退出重进五条主路径,跑完一遍基本能说明程序是完整的。

序号输入语句期望结果考察点
1create table student(sno char(8) primary key, sname char(20) not null, sage int)建表成功,显示 3 列多列解析与约束识别
2create table course(cno char(6) primary key, cname char(30) unique)建表成功,共 2 张表多表共存
3alter table student add sdept char(100)student 变为 4 列两位数长度 + 追加
4alter table student modify sage char(4)sage 类型变为 char(4)类型覆写
5alter table student drop sname剩 3 列且无空洞数组前移
6退出程序后重新启动,执行显示两张表结构与退出前一致持久化往返

答辩提问基本集中在三个方向。第一问通常是「你的表结构存在哪里、为什么这么存」,回答要点是把元数据和数据分开讲:这一版只做表定义,所以磁盘上存的是列名、类型、长度、约束这四元组,真正的行数据不在本题范围内,落盘格式用「数量先行 + 逐列四字段」正是为了让读取不需要任何分隔判断。第二问是「ALTER 之后你怎么保证文件是新的」,回答整份清空重写、写完再统一落盘,顺带提一句临时文件加改名可以防写坏。第三问多半是「如果表里已经有数据,DROP 一列怎么办」,这时候别硬撑,直接说这一版没有行数据所以不做搬移,若要支持需要逐行重建并处理约束冲突,把边界交代清楚反而显得想过。

演示前我习惯做一件事:把table.txt删掉,从零开始按脚本走一遍,再退出重进走第二遍。第一遍验证建表和改表,第二遍验证读取,两遍都过才算能上桌。从那以后我每次交这类带持久化的课设,都会强制走一遍「删文件、重建、重启、核对」的流程,因为翻车从来不在功能本身,而在第二次启动的那一刻。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询