C语言数据库管理系统示例:文件操作、内存管理、错误处理与动态数据库设计 栈和堆的内存分配
C语言的管理数据库完整的小型系统示例:
#include <stdio.h> // 引入标准输入输出库,提供printf等功能
#include <assert.h> // 引入断言库,用于调试时检查条件
#include <stdlib.h> // 引入标准库,提供malloc、free、exit等功能
#include <errno.h> // 引入错误号库,用于获取系统调用的错误号
#include <string.h> // 引入字符串处理库,提供strncpy等字符串操作函数
#define MAX_DATA 512 // 定义常量MAX_DATA为512,用于指定名字和邮件的最大长度
#define MAX_ROWS 100 // 定义常量MAX_ROWS为100,表示数据库最多可以有100条记录
// 地址结构体,表示每条记录
struct Address {
int id; // 记录的唯一标识符
int set; // 标记记录是否已被设置,0表示未设置,1表示已设置
char name[MAX_DATA]; // 存储联系人姓名的数组,最大长度为MAX_DATA
char email[MAX_DATA];// 存储联系人电子邮件的数组,最大长度为MAX_DATA
};
// 数据库结构体,表示整个数据库
struct Database {
struct Address rows[MAX_ROWS]; // 数组存储每一条记录,最多MAX_ROWS条记录
};
// 连接结构体,用于表示数据库连接,包括文件和数据库内存数据
struct Connection {
FILE *file; // 文件指针,指向数据库文件
struct Database *db; // 指向内存中数据库数据的指针
};
// 错误处理函数,打印错误信息并终止程序
void die(const char *message)
{
if (errno) { // 如果有错误号
perror(message); // 打印系统错误信息
} else {
printf("ERROR: %s\n", message); // 否则打印自定义的错误消息
}
exit(1); // 退出程序
}
// 打印地址(联系人的)信息
void Address_print(struct Address *addr)
{
printf("%d %s %s\n", addr->id, addr->name, addr->email); // 打印id、name和email
}
// 从文件中加载数据库
void Database_load(struct Connection *conn)
{
int rc = fread(conn->db, sizeof(struct Database), 1, conn->file); // 从文件读取数据库
if (rc != 1) die("Failed to load database."); // 如果读取失败,调用die函数输出错误信息并退出
}
// 打开数据库文件,返回连接对象
struct Connection *Database_open(const char *filename, char mode)
{
struct Connection *conn = malloc(sizeof(struct Connection)); // 为Connection结构体分配内存
if (!conn) die("Memory error"); // 如果内存分配失败,调用die函数
conn->db = malloc(sizeof(struct Database)); // 为Database结构体分配内存
if (!conn->db) die("Memory error"); // 如果内存分配失败,调用die函数
if (mode == 'c') { // 如果是创建模式,打开文件进行写操作
conn->file = fopen(filename, "w");
} else { // 如果是读取模式,打开文件进行读写操作
conn->file = fopen(filename, "r+");
if (conn->file) { // 如果文件打开成功
Database_load(conn); // 从文件加载数据库
}
}
if (!conn->file) die("Failed to open the file"); // 如果文件无法打开,调用die函数退出
return conn; // 返回连接对象
}
// 关闭数据库连接并释放相关资源
void Database_close(struct Connection *conn)
{
if (conn) { // 如果连接对象不为空
if (conn->file) fclose(conn->file); // 关闭文件
if (conn->db) free(conn->db); // 释放数据库内存
free(conn); // 释放连接对象内存
}
}
// 将数据库内容写入文件
void Database_write(struct Connection *conn)
{
rewind(conn->file); // 将文件指针回到文件开头
int rc = fwrite(conn->db, sizeof(struct Database), 1, conn->file); // 将数据库内容写入文件
if (rc != 1) die("Failed to write database."); // 如果写入失败,调用die函数
rc = fflush(conn->file); // 刷新文件流,确保所有数据都写入文件
if (rc == -1) die("Cannot flush database."); // 如果刷新失败,调用die函数
}
// 创建数据库,初始化每条记录
void Database_create(struct Connection *conn)
{
int i = 0;
for (i = 0; i < MAX_ROWS; i++) {
struct Address addr = {.id = i, .set = 0}; // 初始化每条记录,id为i,set为0
conn->db->rows[i] = addr; // 将初始化的记录赋值给数据库的相应位置
}
}
// 设置数据库某条记录的信息
void Database_set(struct Connection *conn, int id, const char *name, const char *email)
{
struct Address *addr = &conn->db->rows[id]; // 获取指定id的记录
if (addr->set) die("Already set, delete it first"); // 如果记录已设置,则报错
addr->set = 1; // 设置记录标记为已设置
// 复制名字到记录
char *res = strncpy(addr->name, name, MAX_DATA);
if (!res) die("Name copy failed"); // 如果复制失败,调用die函数
// 复制电子邮件到记录
res = strncpy(addr->email, email, MAX_DATA);
if (!res) die("Email copy failed"); // 如果复制失败,调用die函数
}
// 获取并打印指定id的记录
void Database_get(struct Connection *conn, int id)
{
struct Address *addr = &conn->db->rows[id]; // 获取指定id的记录
if (addr->set) { // 如果记录已设置
Address_print(addr); // 打印记录
} else {
die("ID is not set"); // 如果记录未设置,则报错
}
}
// 删除指定id的记录
void Database_delete(struct Connection *conn, int id)
{
struct Address addr = {.id = id, .set = 0}; // 初始化一个删除的记录,id为id,set为0
conn->db->rows[id] = addr; // 将该记录写入数据库
}
// 列出所有已设置的记录
void Database_list(struct Connection *conn)
{
int i = 0;
struct Database *db = conn->db;
for (i = 0; i < MAX_ROWS; i++) {
struct Address *cur = &db->rows[i]; // 获取当前记录
if (cur->set) { // 如果记录已设置
Address_print(cur); // 打印记录
}
}
}
// 主函数:根据命令行参数执行相应的数据库操作
int main(int argc, char *argv[])
{
if (argc < 3) die("USAGE: ex17 <dbfile> <action> [action params]"); // 如果参数不足,报错并退出
char *filename = argv[1]; // 获取数据库文件名
char action = argv[2][0]; // 获取操作类型(c=create, g=get, s=set, d=del, l=list)
struct Connection *conn = Database_open(filename, action); // 打开数据库文件并返回连接对象
int id = 0;
if (argc > 3) id = atoi(argv[3]); // 如果有id参数,转换为整数
if (id >= MAX_ROWS) die("There's not that many records."); // 如果id超过最大记录数,报错并退出
switch (action) {
case 'c': // 如果操作类型是创建数据库
Database_create(conn); // 创建数据库
Database_write(conn); // 写入文件
break;
case 'g': // 如果操作类型是获取记录
if (argc != 4) die("Need an id to get"); // 如果缺少id参数,报错
Database_get(conn, id); // 获取并打印指定id的记录
break;
case 's': // 如果操作类型是设置记录
if (argc != 6) die("Need id, name, email to set"); // 如果缺少参数,报错
Database_set(conn, id, argv[4], argv[5]); // 设置指定id的记录
Database_write(conn); // 写入文件
break;
case 'd': // 如果操作类型是删除记录
if (argc != 4) die("Need id to delete"); // 如果缺少id参数,报错
Database_delete(conn, id); // 删除指定id的记录
Database_write(conn); // 写入文件
break;
case 'l': // 如果操作类型是列出记录
Database_list(conn); // 列出所有已设置的记录
break;
default: // 如果操作类型无效,报错
die("Invalid action, only: c=create, g=get, s=set, d=del, l=list");
}
Database_close(conn); // 关闭数据库连接并释放资源
return 0; // 返回0表示程序正常结束
}
对以上的文件操作库函数进行解析:
1.fopen
函数
FILE *fopen(const char *filename, const char *mode);
功能:fopen
是 C 标准库中用于打开文件的函数,用于指定文件的打开模式,并返回一个指向文件的指针,允许程序读写文件
参数:
filename:要打开的文件名(包括路径,如果文件不在当前目录)。
mode:打开文件的模式,指定如何访问文件。
返回值: 如果文件成功打开,返回指向文件的 FILE 指针,后续的文件操作都通过这个指针进行。 如果文件无法打开(例如文件不存在,权限不足等),返回 NULL,此时可以通过 errno 或 perror 获取错误信息。
2. fread
和 fwrite
函数
int fread(void *ptr, size_t size, size_t count, FILE *stream);
功能:从指定的文件流 stream 中读取 count 个对象,每个对象大小为 size 字节,并将它们存储在 ptr 指向的内存区域中。
参数:
ptr:指向存储读取数据的缓冲区的指针。
size:每个对象的大小(以字节为单位)。
count:要读取的对象数量。
stream:指向 FILE 类型的文件指针,指定要读取的文件。
返回值:返回实际读取的对象数量。如果返回值小于 count,则表示发生了错误或文件结束。
int fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
功能:向指定的文件流 stream 写入数据。写入 count 个对象,每个对象大小为 size 字节,数据来自 ptr 指向的内存区域。
参数:
ptr:指向要写入数据的缓冲区的指针。
size:每个对象的大小(以字节为单位)。
count:要写入的对象数量。
stream:指向 FILE 类型的文件指针,指定要写入的文件。
返回值:返回实际写入的对象数量。如果返回值小于 count,则表示发生了错误。
3. rewind
函数
void rewind(FILE *stream);
功能:将文件指针 stream 移动到文件的开头。
参数:
stream:指向要操作的文件流。
返回值:没有返回值。成功时,文件指针指向文件的起始位置。如果发生错误,ferror 会返回一个非零值。
4. fflush
函数
int fflush(FILE *stream);
功能:刷新输出缓冲区,将缓冲区中的数据强制写入到文件中。
参数:
stream:指向要刷新的文件流。如果 stream 为 NULL,则刷新所有输出流的缓冲区。
返回值: 如果成功,返回 0。 如果发生错误,返回 EOF,并设置 errno 来指示错误原因。
C 标准库中用于处理错误的相关机制
1、errno
errno 是一个全局变量,定义在 <errno.h> 头文件中,它用于指示上一个系统调用或库函数发生的错误类型。
作用:每当某个函数(特别是系统调用或库函数)失败时,它会将一个错误代码存储在 errno 中。该错误代码是一个整数,代表具体的错误类型。
errno 的值:它的值会随着错误发生而改变。因此,在调用可能设置 errno 的函数后,需要检查其值并根据错误代码提供相应的处理。
常见的 errno 错误代码:
EINVAL:无效的参数。
ENOENT:没有该文件或目录。
ENOMEM:内存不足。
EACCES:权限拒绝。
EIO:输入输出错误。
EPERM:操作不允许。
EIO:硬件错误。
示例:
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main() {
FILE *file = fopen("nonexistent_file.txt", "r");
if (file == NULL) {
// 打印错误代码
printf("Error code: %d\n", errno);
// 打印错误描述
printf("Error description: %s\n", strerror(errno));
}
return 0;
}
2、perror
perror
是 C 标准库中用于打印 errno
的错误信息的函数,它会根据 errno
中的错误代码输出相应的错误描述。它输出的信息通常包括用户自定义的前缀字符串(可选)和错误消息。
函数原型:
void perror(const char *s);
参数 s:是用户提供的字符串,如果提供了,perror 会先输出该字符串,再输出对应的错误描述;如果不提供,直接输出错误描述。
输出:perror 会输出一个标准的错误信息,格式为:<prefix>: <error_description>。
堆和栈的内存分配
堆就是你电脑中的剩余内存,你可以通过malloc
访问它来获取更多内存,OS会使用内部函数为你注册一块内存区域,并且返回指向它的指针。当你使用完这片区域时,你应该使用free
把它交还给OS,使之能被其它程序复用。如果你不这样做就会导致程序“泄露”内存,但是Valgrind
会帮你监测这些内存泄露。
栈是一个特殊的内存区域,它储存了每个函数的创建的临时变量,它们对于该函数为局部变量。它的工作机制是,函数的每个函数都会“压入”栈中,并且可在函数内部使用。它是一个真正的栈数据结构,所以是后进先出的。这对于main
中所有类似char section
和int id
的局部变量也是相同的。使用栈的优点是,当函数退出时C编译器会从栈中“弹出”所有变量来清理。这非常简单,也防止了栈上变量的内存泄露。
理清内存的最简单的方式是遵守这条原则:如果你的变量并不是从malloc
中获取的,也不是从一个从malloc
获取的函数中获取的,那么它在栈上。
而除了堆和栈之外,还有数据段(Data Segment),用于存储程序中的全局变量、静态变量和常量数据,分为:已初始化数据段(存储已初始化的全局变量和静态变量)未初始化数据段(BSS段)存储未初始化的全局变量和静态变量,程序运行时会将它们初始化为 0。特点:数据段在程序加载时会被加载到内存中,并且生命周期与程序相同
附加题
die
函数需要接收conn
变量作为参数,以便执行清理并关闭它。void die(struct Connection *conn,const char *message) { if(errno) { perror(message); } else { printf("ERROR: %s\n", message); } Database_close(conn); exit(1); }
- 修改代码,使其接收参数作为
MAX_DATA
和MAX_ROWS
,将它们储存在Database
结构体中,并且将它们写到文件。这样就可以创建任意大小的数据库。
// 修改struct Database struct Database { int max_data; // 动态存储最大数据大小 int max_rows; // 动态存储最大行数 struct Address *rows; // 动态分配存储的地址 }; // 修改struct Connection *Database_open函数 struct Connection *Database_open(const char *filename, char mode, int max_data, int max_rows) { struct Connection *conn = malloc(sizeof(struct Connection)); if (!conn) die(conn, "Memory error"); conn->db = malloc(sizeof(struct Database)); if (!conn->db) die(conn, "Memory error"); // 存储MAX_DATA和MAX_ROWS conn->db->max_data = max_data; conn->db->max_rows = max_rows; // 动态分配数据库条目 conn->db->rows = malloc(max_rows * sizeof(struct Address)); if (!conn->db->rows) die(conn, "Memory error for rows"); if (mode == 'c') { conn->file = fopen(filename, "w"); } else { conn->file = fopen(filename, "r+"); if (conn->file) { Database_load(conn); } } if (!conn->file) die(conn, "Failed to open the file"); return conn; } // 修改主函数中输入参数格式 if (argc < 5) die(NULL, "USAGE: ex17 <dbfile> <action> <max_data> <max_rows> [action params]"); char *filename = argv[1]; char action = argv[2][0]; int max_data = atoi(argv[3]); int max_rows = atoi(argv[4]); struct Connection *conn = Database_open(filename, action, max_data, max_rows); int id = 0; if (argc > 5) id = atoi(argv[5]); if (id >= conn->db->max_rows) die(conn, "There's not that many records."); switch (action) { case 'c': Database_create(conn); Database_write(conn); break; case 'g': if (argc != 6) die(conn, "Need an id to get"); Database_get(conn, id); break; case 's': if (argc != 7) die(conn, "Need id, name, email to set"); Database_set(conn, id, argv[6], argv[7]); Database_write(conn); break; case 'd': if (argc != 6) die(conn, "Need id to delete"); Database_delete(conn, id); Database_write(conn); break; case 'l': Database_list(conn); break; default: die(conn, "Invalid action, only: c=create, g=get, s=set, d=del, l=list"); }
- 向数据库添加更多操作,比如
find
。
// 添加相应内容 void Database_find(struct Connection *conn,const char *name) { struct Database *db = conn->db; for (int i = 0; i < db->max_rows; i++) { struct Address *cur = &db->rows[i]; if (cur->set && strcmp(cur->name,name) == 0) { Address_print(db,i); } } } case 'f': if (argc != 7) die(conn, "Need an id to get"); Database_find(conn,argv[6]); break;
- 查询C如何打包结构体,并且试着弄清楚为什么你的文件是相应的大小。看看你是否可以计算出结构体添加一些字段之后的新大小。
在 C 语言中,打包(packing)结构体是指调整结构体的内存布局,减少或消除由于字节对齐造成的内存填充(padding)。字节对齐的目的是提高处理器访问内存时的效率,尤其是在处理较大数据时。
1. 结构体的内存布局(Padding)
通常情况下,编译器会根据结构体中成员的类型对内存进行对齐。每个数据类型都有一个对齐要求(alignment requirement),例如:
char 通常对齐到 1 字节边界(sizeof(char) = 1)
int 通常对齐到 4 字节边界(sizeof(int) = 4)
如果结构体成员没有按照其对齐要求对齐,编译器会自动插入填充字节(padding)来确保每个成员按照适当的对齐要求存储。
例如:
布局如下:#include <stdio.h> struct MyStruct { char a; int b; short c; }; int main() { printf("Size of MyStruct: %lu\n", sizeof(struct MyStruct)); return 0; } // 输出结果为: Size of MyStruct: 12
2. 结构体打包(packing)| a(char) | padding(3 bytes) | b(int) | c(short) | padding(2 bytes) |
为了打包结构体(消除内存中的填充字节),我们可以使用编译器特性,例如#pragma pack
指令(具体依赖于编译器)或__attribute__((packed))
。
例如:#include <stdio.h> #pragma pack(1) // 设置结构体按 1 字节对齐 struct MyStruct { char a; int b; short c; }; int main() { printf("Size of MyStruct: %lu\n", sizeof(struct MyStruct)); return 0; } // 输出结果:Size of MyStruct: 7
- 向
Address
添加一些字段,使它们可被搜索。
类似find功能
- 编写一个shell脚本来通过以正确顺序运行命令执行自动化测试。提示:在
bash
顶端使用使用set -e
,使之在任何命令发生错误时退出。
#!/bin/bash # 启用错误退出模式 #set -e # 定义文件和路径 C_SOURCE_FILE="ex17.c" C_EXEC_FILE="ex17" TEST_DB_FILE="testdb.dat" # 编译 C 代码 echo "Compiling the C code..." gcc -Wall -g $C_SOURCE_FILE -o $C_EXEC_FILE # 测试 1: 创建数据库 echo "Running test 1: Creating a new database..." ./$C_EXEC_FILE $TEST_DB_FILE c # 测试 2: 设置数据 echo "Running test 2: Setting data for ID 0..." ./$C_EXEC_FILE $TEST_DB_FILE s 0 "Joe Alex" "joe@example.com" # 测试 3: 获取数据 echo "Running test 3: Getting data for ID 0..." ./$C_EXEC_FILE $TEST_DB_FILE g 0 # 测试 4: 列出所有数据 echo "Running test 4: Listing all entries..." ./$C_EXEC_FILE $TEST_DB_FILE l # 测试 5: 删除数据 echo "Running test 5: Deleting data for ID 0..." ./$C_EXEC_FILE $TEST_DB_FILE d 0 # 测试 6: 确认删除 echo "Running test 6: Verifying data for ID 0..." ./$C_EXEC_FILE $TEST_DB_FILE g 0 # 清理测试文件 echo "Cleaning up after tests..." rm $TEST_DB_FILE echo "All tests passed successfully!"
- 尝试重构程序,使用单一的全局变量来储存数据库连接。这个新版本和旧版本比起来如何?
使用全局变量,在重构后,struct Connection *conn 被定义为一个全局变量。所有与数据库相关的操作都直接访问这个全局变量,无需再通过函数参数传递连接。函数 Database_open、Database_close、Database_write、Database_create 等不再需要接受 conn 作为参数,因为全局变量 conn 可以直接访问。
新版本与旧版本的对比:
优点:
简洁性: 代码中函数签名变得更简洁,不再需要通过参数传递 conn,而是直接使用全局变量。
易于访问: 在多个函数中直接访问全局变量,避免了多次传递参数的麻烦。
缺点:
可维护性差: 使用全局变量会让程序的状态管理变得更复杂,特别是在多线程或多任务环境中。函数内部无法独立操作数据库连接,而是依赖于全局状态。
难以测试: 单一的全局变量增加了测试的复杂性。测试时,可能需要手动初始化和清理全局变量,尤其是在多个测试用例之间共享状态时。
灵活性降低: 使用全局变量意味着函数之间没有明确的数据流动关系,导致代码的解耦性降低,后期的扩展可能变得更加困难。
如果程序的规模较小,且没有复杂的模块化需求,使用全局变量可以简化代码,减少函数参数的传递。 但在大型项目或需要高可维护性和高可测试性的项目中,尽量避免使用全局变量,或者将其限制在必要的范围内。
- 搜索“栈数据结构”,并且在你最喜欢的语言中实现它,然后尝试在C中实现。
使用python实现栈:class Stack: def __init__(self): # 使用列表来存储栈中的元素 self.items = [] def is_empty(self): # 判断栈是否为空 return len(self.items) == 0 def push(self, item): # 将元素推入栈中 self.items.append(item) def pop(self): # 从栈中弹出一个元素,并返回该元素 if not self.is_empty(): return self.items.pop() else: raise IndexError("pop from empty stack") def peek(self): # 查看栈顶的元素,不移除它 if not self.is_empty(): return self.items[-1] else: raise IndexError("peek from empty stack") def size(self): # 返回栈中元素的个数 return len(self.items) # 示例用法 if __name__ == "__main__": stack = Stack() stack.push(1) stack.push(2) stack.push(3) print(f"栈顶元素: {stack.peek()}") # 输出 3 print(f"栈的大小: {stack.size()}") # 输出 3 print(f"弹出的元素: {stack.pop()}") # 输出 3 print(f"栈的大小: {stack.size()}") # 输出 2
使用C语言实现栈:
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> #define MAX_SIZE 100 // 栈的最大容量 // 定义栈结构体 typedef struct { int items[MAX_SIZE]; // 存储栈元素的数组 int top; // 栈顶指针 } Stack; // 初始化栈 void init_stack(Stack *s) { s->top = -1; // 初始化时栈为空 } // 判断栈是否为空 bool is_empty(Stack *s) { return s->top == -1; } // 判断栈是否已满 bool is_full(Stack *s) { return s->top == MAX_SIZE - 1; } // 将元素推入栈中 void push(Stack *s, int item) { if (is_full(s)) { printf("栈满,无法推入元素\n"); return; } s->items[++(s->top)] = item; } // 从栈中弹出一个元素 int pop(Stack *s) { if (is_empty(s)) { printf("栈空,无法弹出元素\n"); exit(1); // 异常退出 } return s->items[(s->top)--]; } // 查看栈顶元素 int peek(Stack *s) { if (is_empty(s)) { printf("栈空,无法查看栈顶元素\n"); exit(1); // 异常退出 } return s->items[s->top]; } // 返回栈的大小 int size(Stack *s) { return s->top + 1; } int main() { Stack stack; init_stack(&stack); push(&stack, 1); push(&stack, 2); push(&stack, 3); printf("栈顶元素: %d\n", peek(&stack)); // 输出 3 printf("栈的大小: %d\n", size(&stack)); // 输出 3 printf("弹出的元素: %d\n", pop(&stack)); // 输出 3 printf("栈的大小: %d\n", size(&stack)); // 输出 2 return 0; }
Python和C实现对比
在Python中,栈实现依赖于列表(list),Python自动处理内存管理。栈的大小可以动态变化。 在C中,我们必须手动管理内存(栈大小),并且栈的最大容量是预定义的(MAX_SIZE)。如果需要动态调整大小,需要重新分配内存。 Python的代码更加简洁和灵活,因为它具有自动内存管理和内建的动态数据结构(如list)。 C需要更多的手动内存管理和对边界条件的检查,代码相对繁琐。