实战项目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个任务,结果程序崩了,全没了——这体验得多糟糕。

知识体系总览

下面这张图概括了本章的核心知识点:

命令行任务管理器知识体系 任务管理器 添加任务 删除任务 列表展示 持久化存储 自增ID生成 尾插法 链表摘除 内存释放 表格输出 状态标记 文件I/O CSV格式

避坑指南

最后,我总结几个实战中容易踩的坑:

  • 内存泄漏:每次 malloc 都要对应一个 free。我习惯在退出前遍历链表释放所有节点。
  • 文件锁:如果程序可能多开,文件写入要考虑加锁。不过单用户场景下问题不大。
  • 输入校验:用户输入的任务描述可能包含逗号,这会影响CSV解析。我一般用转义或者限制字符集。
  • ID重用:删除任务后,ID不会复用。如果你希望ID连续,可以在删除后重新编号——但我觉得没必要,ID只是标识,不连续不影响使用。
小技巧:调试时可以用 valgrind 检查内存泄漏。命令很简单:valgrind --leak-check=full ./task add "测试"。看到 "All heap blocks were freed" 就说明内存管理没问题。

好了,这个项目就讲到这里。代码量不大,但五脏俱全。你写的时候可以自己扩展功能,比如加个优先级、截止日期什么的。动手试试吧,写出来你就掌握了C语言命令行开发的精髓。


公众号:蓝海资料掘金营,微信deep3321