实战项目2:命令行任务管理器
说实话,命令行工具开发里最经典的练手项目,就是任务管理器。你别看它功能简单——添加、删除、列表、持久化——但这里头几乎涵盖了C语言命令行开发的全部核心技能:参数解析、数据结构、文件I/O、内存管理。我当年带团队时,新人的入职考核就是写这个,能写利索的,基本都能上手干活。
项目需求拆解
我们先明确一下,这个任务管理器要做什么:
- 添加任务:用户输入任务描述,程序存下来
- 删除任务:按编号删除指定任务
- 列表展示:显示所有任务及其状态
- 持久化存储:退出程序后数据不丢失
嗯,听起来简单吧?但你要真上手写,坑不少。我见过有人用全局数组存任务,一重启全没了——那叫任务管理器吗?那叫一次性备忘录。
数据结构设计
任务管理器的核心是数据结构。我个人习惯用链表,因为任务数量不确定,数组扩容太麻烦。每个任务节点包含:
typedef struct Task {
int id; // 任务编号
char description[256]; // 任务描述
int completed; // 0未完成 1已完成
struct Task *next; // 指向下一个任务
} Task;
这里有个细节:id 怎么生成?我建议用自增ID,每次添加任务时取当前最大ID加1。别用随机数,调试时你会疯的。
核心功能实现
添加任务
添加任务时,用户输入描述,程序创建新节点,插入链表尾部。代码大概长这样:
void add_task(Task **head, const char *desc) {
Task *new_task = malloc(sizeof(Task));
if (!new_task) {
fprintf(stderr, "内存分配失败\n");
return;
}
// 生成ID
int new_id = 1;
Task *current = *head;
while (current) {
if (current->id >= new_id) new_id = current->id + 1;
current = current->next;
}
new_task->id = new_id;
strncpy(new_task->description, desc, 255);
new_task->description[255] = '\0';
new_task->completed = 0;
new_task->next = NULL;
// 尾插法
if (*head == NULL) {
*head = new_task;
} else {
Task *tail = *head;
while (tail->next) tail = tail->next;
tail->next = new_task;
}
}
注意看,我用了 strncpy 而不是 strcpy。为什么?防止用户输入超长描述导致缓冲区溢出。我曾经在项目里吃过这个亏,用户输入了一篇小作文,程序直接崩了——从那以后我再也不敢用 strcpy 了。
删除任务
删除任务按ID来。遍历链表,找到对应节点,摘除并释放内存:
int delete_task(Task **head, int id) {
if (*head == NULL) return 0;
Task *current = *head;
Task *prev = NULL;
while (current) {
if (current->id == id) {
if (prev) {
prev->next = current->next;
} else {
*head = current->next;
}
free(current);
return 1;
}
prev = current;
current = current->next;
}
return 0; // 没找到
}
这里有个容易忽略的点:删除头节点时,要更新 *head。我第一次写的时候忘了,结果头节点删不掉,还造成了内存泄漏——嗯,调试了一下午才找到原因。
列表展示
列表展示要清晰。我习惯用表格形式输出:
void list_tasks(Task *head) {
if (head == NULL) {
printf("没有任务。\n");
return;
}
printf("%-4s %-10s %s\n", "ID", "状态", "描述");
printf("----------------------------\n");
Task *current = head;
while (current) {
char *status = current->completed ? "[完成]" : "[待办]";
printf("%-4d %-10s %s\n", current->id, status, current->description);
current = current->next;
}
}
你想想看,如果用户有100个任务,你一行一行列出来,屏幕刷得飞快,根本看不清。所以实际项目中我还会加分页功能,但这里先不展开。
持久化存储
持久化存储说白了就是把数据写到文件里,下次启动时再读回来。我选最简单的文本格式,每行一个任务,字段用逗号分隔:
void save_tasks(Task *head, const char *filename) {
FILE *fp = fopen(filename, "w");
if (!fp) {
perror("无法打开文件");
return;
}
Task *current = head;
while (current) {
fprintf(fp, "%d,%d,%s\n", current->id, current->completed, current->description);
current = current->next;
}
fclose(fp);
}
Task *load_tasks(const char *filename) {
FILE *fp = fopen(filename, "r");
if (!fp) return NULL;
Task *head = NULL;
char line[512];
while (fgets(line, sizeof(line), fp)) {
// 去掉换行符
line[strcspn(line, "\n")] = '\0';
int id, completed;
char desc[256];
if (sscanf(line, "%d,%d,%255[^\n]", &id, &completed, desc) == 3) {
Task *task = malloc(sizeof(Task));
task->id = id;
task->completed = completed;
strcpy(task->description, desc);
task->next = head;
head = task;
}
}
fclose(fp);
return head;
}
这里我用了头插法加载数据,所以文件里的顺序和链表顺序是反的。如果你在意顺序,可以用尾插法,或者加载完再反转链表。我个人觉得任务列表顺序没那么重要,所以偷了个懒。
/home/zhangsan/tasks.txt,换台机器就跑不了。
命令行参数解析
命令行参数解析是命令行工具的入口。我习惯用 getopt 函数,但为了简单,这里直接手动解析:
int main(int argc, char *argv[]) {
Task *tasks = load_tasks("tasks.txt");
if (argc < 2) {
printf("用法: task <命令> [参数]\n");
printf("命令:\n");
printf(" add <描述> 添加任务\n");
printf(" delete <ID> 删除任务\n");
printf(" list 列出所有任务\n");
printf(" done <ID> 标记任务完成\n");
return 1;
}
if (strcmp(argv[1], "add") == 0 && argc >= 3) {
add_task(&tasks, argv[2]);
} else if (strcmp(argv[1], "delete") == 0 && argc >= 3) {
int id = atoi(argv[2]);
if (!delete_task(&tasks, id)) {
printf("未找到ID为%d的任务\n", id);
}
} else if (strcmp(argv[1], "list") == 0) {
list_tasks(tasks);
} else if (strcmp(argv[1], "done") == 0 && argc >= 3) {
int id = atoi(argv[2]);
mark_done(tasks, id);
} else {
printf("未知命令\n");
}
save_tasks(tasks, "tasks.txt");
// 释放链表内存
// free_all(tasks);
return 0;
}
注意看,每次操作完我都调用了 save_tasks。为什么?防止程序异常退出导致数据丢失。你想想看,用户辛辛苦苦加了10个任务,结果程序崩了,全没了——这体验得多糟糕。
知识体系总览
下面这张图概括了本章的核心知识点:
避坑指南
最后,我总结几个实战中容易踩的坑:
- 内存泄漏:每次
malloc都要对应一个free。我习惯在退出前遍历链表释放所有节点。 - 文件锁:如果程序可能多开,文件写入要考虑加锁。不过单用户场景下问题不大。
- 输入校验:用户输入的任务描述可能包含逗号,这会影响CSV解析。我一般用转义或者限制字符集。
- ID重用:删除任务后,ID不会复用。如果你希望ID连续,可以在删除后重新编号——但我觉得没必要,ID只是标识,不连续不影响使用。
valgrind 检查内存泄漏。命令很简单:valgrind --leak-check=full ./task add "测试"。看到 "All heap blocks were freed" 就说明内存管理没问题。
好了,这个项目就讲到这里。代码量不大,但五脏俱全。你写的时候可以自己扩展功能,比如加个优先级、截止日期什么的。动手试试吧,写出来你就掌握了C语言命令行开发的精髓。