C/C++项目练习:命令行记账本

目录

[明确目标 - 我们要做什么?](#明确目标 - 我们要做什么?)

回归本质,定义"一笔账"是什么?

从"一笔账"到"一本账",如何存储多条记录?

操作一:创建一个空的账本 (create_ledger)

操作二:向账本中添加一条记录 (add_transaction)

操作三:从账本中删除一条记录 (delete_transaction)

操作四:销毁整个账本 (destroy_ledger)

数据持久化,让记录"不会丢失"

翻译规则的设计:选择文件格式

[操作五:记忆 ------ save_ledger_to_file (将账本存入文件)](#操作五:记忆 —— save_ledger_to_file (将账本存入文件))

[操作六:回忆 ------ load_ledger_from_file (从文件加载账本)](#操作六:回忆 —— load_ledger_from_file (从文件加载账本))

项目的整体设计(模块化编程)

文件一:ledger.h

文件二:ledger.c

文件三:main.c


明确目标 - 我们要做什么?

在动手之前,我们必须用最清晰的语言定义我们的目标。这本身就是第一性原理的一部分------准确定义问题。

📌功能性需求:

  1. 记录账目 (Add):能够添加一笔新的收支记录,包含日期、金额和描述。

  2. 查看账目 (View):能够列出所有已记录的账目。

  3. 删除账目 (Delete):能够根据某种标识(如序号)删除一笔已存在的记录。

📍技术性约束:

  1. 动态管理 (Dynamic):记录的数量没有硬性上限,程序应能根据需要自动扩展存储空间。

  2. 数据持久化 (Persistence):程序关闭后,数据不应丢失,需要通过文件操作来保存和加载。

  3. 字符串类型 (String Type) :在数据结构中,所有字符串类型都必须使用 char*(字符指针),而不是 char[](字符数组)。这是一个非常关键的技术选型,它将深刻影响我们的内存管理方式。

我们完全从"第一性原理"出发,一步一步地拆解和构建这个命令行记账本项目。

忘记所有复杂的代码和现成的方案,我们回到最根本的问题。


回归本质,定义"一笔账"是什么?

一个项目最核心的是它处理的数据。对于记账本来说,最核心、最不可再分的数据单元就是"一笔账目记录"。

那么,一笔账目记录应该包含哪些信息?

  1. 日期 (Date):什么时候发生的?例如 "2025-09-10"。

  2. 金额 (Amount) :花了多少钱或赚了多少钱?例如 50.0-35.5。用正负数可以很自然地表示收入和支出(income / expense)。

  3. 描述 (Description):这笔钱花在了哪里?例如 "午餐"、"工资" 等。

我们找到了最核心的原子。在 C 语言中,如何将这些不同类型的数据(日期是字符串,金额是浮点数,描述是字符串)捆绑在一起,形成一个整体呢?

答案就是 结构体 struct

cpp 复制代码
// 定义一个结构体来表示"一笔账"
struct Transaction {
    char date[20];       // 日期字符串,如 "2025-09-10"
    double amount;       // 金额,正数表示收入,负数表示支出
    char description[100]; // 描述,如 "午餐"
};

这种方式简单。datedescription 的内存是结构体的一部分,它们和 amount 一起被分配。

❌缺点:浪费空间。每个"description"无论长短,都占用100字节。更重要的是,它不符合我们 char* 的技术约束。此方案被否决。

cpp 复制代码
struct Transaction {
    char* date;          // 一个指针
    double amount;
    char* description;   // 另一个指针
};

这是符合我们要求的方案。这个结构体本身非常小,它只存储了两个指针(地址)和一个双精度浮点数。

datedescription 所指向的字符串数据,并不在结构体内部。它们位于内存的其它地方(我们之后将使用 malloc 从"堆"上分配)。

  • 创建时 :当我们创建一笔新的 Transaction 时,我们不仅要为 struct Transaction 本身分配空间,还必须单独为 datedescription 字符串分配内存,然后把字符串内容拷贝进去。

  • 销毁时 :当我们删除一笔 Transaction 时,我们必须先 freedatedescription 指针所指向的内存,然后再处理 struct Transaction 本身。否则,就会发生内存泄漏 (Memory Leak)

我们定义了程序的基本数据单元。现在,整个项目都将围绕着创建、修改、删除和显示 struct Transaction 的实例来展开。


从"一笔账"到"一本账",如何存储多条记录?

现在我们能表示"一笔账"了,但记账本需要记录很多笔账。我们的要求是"无需预先设定最大数量",这是一个关键约束。

最天真的想法:用一个巨大的数组,比如 struct Transaction records[1000];

  • 优点:简单,易于理解。

  • 缺点:浪费空间(如果只记了10笔),有上限(超过1000笔就崩溃),不符合"无需预先设定最大数量"的要求。所以这个方案被否决。

💡符合要求的想法 :使用动态内存分配

程序运行时,根据需要向操作系统"要"内存。当记录增加时,就要更多的内存。

在 C 语言中,实现动态内存分配主要有两种经典的数据结构:

动态数组 (Dynamic Array)

  • 原理:先用 malloc 分配一块能容纳 N 个记录的内存。当记录数量超过 N 时,使用 realloc 重新分配一块更大的内存(比如 2N),将旧数据拷贝过去,然后释放旧的内存。

  • 优点:访问速度快(因为内存是连续的),与普通数组操作类似,对初学者相对友好。

  • 缺点:realloc 可能涉及大量数据拷贝,当数据量极大时效率会降低。

链表 (Linked List)

  • 原理:每个账目记录(节点)除了包含自身数据外,还包含一个指针,指向下一个记录。记录之间像锁链一样串联起来。

  • 优点:插入和删除非常灵活高效,真正实现"用多少、要多少",没有空间浪费。

  • 缺点:指针操作对初学者来说更容易出错,访问特定记录(比如第100条)需要从头开始遍历,速度较慢。

解决方案:使用动态数组。我们需要一个管理者来维护这个动态数组。这个管理者需要知道三件事:

  • 数据存储在哪里?(一个指向 struct Transaction 数组的指针)

  • 现在有多少条数据?(一个计数器)

  • 分配的空间最多能容纳多少条数据?(一个容量记录)

设计管理者 struct Ledger

cpp 复制代码
struct Ledger {
    struct Transaction *transactions; // 指向一个动态分配的、存放 Transaction 结构体的数组
    int count;                        // 当前账目数量
    int capacity;                     // 数组当前的总容量
};

现在,我们来推导围绕这个 Ledger 必须有的核心操作。


接下来,我们将以同样严谨的推导方式,为这个"集合"赋予生命,也就是定义它必须具备的核心操作。这些操作是这个数据结构之所以有用的根本。我们将只关注这四个核心操作:创建 (Create)添加 (Add)删除 (Delete)销毁 (Destroy)

操作一:创建一个空的账本 (create_ledger)

目标:初始化一个 Ledger 结构体,让它处于一个"空"但可用的状态。

第一性问题: 一个"账本"在它存在之初,应该是什么状态?

它必须是一个"空的,但随时准备好记录第一笔账"的状态。我们来推导如何从无到有地构建出这个状态。

推导第一步:存在本身

我们如何凭空创造一个 Ledger 对象?在C语言中,变量要么在栈上(函数结束就消失),要么在堆上(手动管理其生命周期)。我们的账本需要在多个函数之间传递,甚至贯穿整个程序生命周期,因此它必须存在于上。

必须使用 mallocLedger 结构体本身请求一块内存。

cpp 复制代码
#include <stdlib.h> // for malloc

// ... (Transaction 和 Ledger 结构体定义) ...

struct Ledger* create_ledger(void) {
    // 请求内存以容纳一个 Ledger 结构体
    struct Ledger* ledger = (struct Ledger*) malloc(sizeof(struct Ledger));

    // ... 后续步骤 ...

    return ledger;
}

推导第二步:应对"存在"的失败

  • 问题:向操作系统请求内存(malloc)是一个可能会失败的操作(比如内存耗尽)。一个严谨的程序必须处理这种失败。

  • 结论:每次 malloc 后,必须检查其返回值是否为 NULL。如果失败,我们无法创建账本,必须将这个失败的结果通知给调用者。

cpp 复制代码
struct Ledger* create_ledger(void) {
    struct Ledger* ledger = (struct Ledger*) malloc(sizeof(struct Ledger));
    if (ledger == NULL) {
        // 内存分配失败,无法创建。返回 NULL 表示失败。
        return NULL;
    }
    // ... 后续步骤 ...
    return ledger;
}

推导第三步:定义"空"的状态

  • 问题:一个空的账本意味着什么?从 Ledger 结构体的定义来看,意味着它包含的账目数量为零。

  • 结论:必须将 count 成员初始化为 0

cpp 复制代码
// ...
if (ledger == NULL) { /* ... */ }

ledger->count = 0; // 明确表示当前没有任何记录

// ... 后续步骤 ...
// ...

推导第四步:实现"准备好"的状态

  • 问题:账本光是"空"的还不够,它必须"准备好"接收数据。这意味着它的 transactions 指针必须指向一块有效的内存区域,即使这个区域里还没有任何数据。

  • 结论:我们需要为 transactions 数组预先分配一块初始大小的内存。这个大小是任意的,但一个合理的初始值(比如10)可以避免过早地进行扩容。

cpp 复制代码
#define INITIAL_CAPACITY 10 // 定义一个初始容量常量,便于修改

// ...
ledger->count = 0;
ledger->capacity = INITIAL_CAPACITY; // 记录下当前容量

// 为交易数组本身分配初始内存
ledger->transactions = (struct Transaction*) malloc(INITIAL_CAPACITY * sizeof(struct Transaction));

// ... 后续步骤 ...
// ...

推导第五步:再次应对失败并确保一致性

  • 问题:为 transactions 数组分配内存的 malloc 也可能失败。如果它失败了,我们之前为 ledger 本身分配的内存怎么办?

  • 结论:如果第二步 malloc 失败,我们必须释放第一步已成功分配的内存,以避免内存泄漏。程序的状态必须保持一致:要么整个账本创建成功,要么什么都不留下。这叫"原子性"。

cpp 复制代码
struct Ledger* create_ledger(void) {
    struct Ledger* ledger = (struct Ledger*) malloc(sizeof(struct Ledger));
    if (ledger == NULL) {
        return NULL; // 失败
    }

    ledger->count = 0;
    ledger->capacity = INITIAL_CAPACITY;
    ledger->transactions = (struct Transaction*) malloc(INITIAL_CAPACITY * sizeof(struct Transaction));

    if (ledger->transactions == NULL) {
        // 第二步分配失败,必须清理第一步的成果
        free(ledger); // 释放 Ledger 结构体本身
        return NULL; // 报告失败
    }

    return ledger; // 成功创建了一个空的、准备就绪的账本
}

操作二:向账本中添加一条记录 (add_transaction)

要将一笔新账目放入账本,必须满足什么前提条件?之后会发生什么状态变化?

推导第一步:前提条件------必须有空间

  • 问题:我们不能凭空放东西,必须先确认 transactions 数组里有可用的空位。

  • 结论:在添加任何数据之前,必须进行检查:count (当前数量) 是否等于 capacity (总容量)?

代码框架

cpp 复制代码
// 函数需要账本的指针和新账目的数据作为输入
// 使用 const char* 是好的实践,表示这个函数不会修改输入字符串
int add_transaction(struct Ledger* ledger, const char* date, double amount, const char* description) {
    // 检查容量
    if (ledger->count == ledger->capacity) {
        // 容量已满,需要扩容
        // ... 扩容逻辑 ...
    }

    // ... 添加数据的逻辑 ...

    return 1; // 返回1表示成功
}

推导第二步:解决"空间不足"的问题------扩容

  • 问题:如果空间不足,我们必须创造更多空间,同时不能丢失已有的数据。

  • 结论:我们需要一块更大的内存,并将所有旧数据迁移过去。C语言提供了完美的工具:realloc

  • 扩容策略 :每次扩容多大?一个常见的、高效的策略是容量翻倍。这能在添加次数和单次扩容成本之间取得很好的平衡。

代码完善(扩容部分)

cpp 复制代码
if (ledger->count == ledger->capacity) {
    int new_capacity = ledger->capacity * 2; // 策略:容量翻倍
    // 尝试重新分配内存
    struct Transaction* new_transactions = (struct Transaction*) realloc(ledger->transactions, new_capacity * sizeof(struct Transaction));

    if (new_transactions == NULL) {
        // realloc 失败!这是一个严重的问题。
        // 但好消息是,原有的 ledger->transactions 内存块仍然有效。
        // 我们无法添加新数据,但至少没有丢失旧数据。
        return 0; // 返回0表示失败
    }

    // 扩容成功,更新 ledger 的状态
    ledger->transactions = new_transactions;
    ledger->capacity = new_capacity;
}

推导第三步:数据的"所有权"

  • 问题:现在我们确保有空间了。新账目的数据(datedescription 字符串)从哪里来?它们由调用者传入。我们能直接 ledger->transactions[count].date = date; 吗?❌绝对不能!

  • 原因:传入的指针 datedescription 可能指向一个临时缓冲区或者一个马上要被修改的变量。我们的账本必须拥有这些数据的独立副本,其生命周期由账本自己管理。

  • 结论:对于每一个字符串,我们必须:

    1. 在堆上为它分配一块大小正好的新内存 (malloc)。

    2. 将传入的字符串内容拷贝到这块新内存中 (strcpy)。

代码完善(数据拷贝部分)

cpp 复制代码
// 经过扩容检查后,我们保证在 ledger->transactions[ledger->count] 有空间
struct Transaction* new_trans = &ledger->transactions[ledger->count];

// 为字符串副本分配内存
// strlen(date) + 1 的 +1 是为了存储字符串的结束符 '\0'
new_trans->date = (char*) malloc(strlen(date) + 1);
new_trans->description = (char*) malloc(strlen(description) + 1);

// 再次进行错误检查
if (new_trans->date == NULL || new_trans->description == NULL) {
    // 分配失败,这是一个棘手的情况。需要清理已分配的内存。
    free(new_trans->date); // 即使是NULL,free也是安全的
    free(new_trans->description);
    return 0; // 报告失败
}

// 拷贝字符串内容到我们自己的内存中
strcpy(new_trans->date, date);
strcpy(new_trans->description, description);

推导第四步:完成添加并更新状态

  • 问题:字符串副本已经创建好了,还剩下什么?

  • 结论:将非字符串数据(amount)赋给新位置,并且最重要的是,更新账本的 count,以反映它现在多了一笔记录。

最终代码:

cpp 复制代码
// ... (扩容和字符串分配部分) ...

// 拷贝字符串内容
strcpy(new_trans->date, date);
strcpy(new_trans->description, description);

// 赋值剩余成员
new_trans->amount = amount;

// 所有步骤成功,最后更新计数器
ledger->count++;

return 1; // 成功

操作三:从账本中删除一条记录 (delete_transaction)

从一个由指针构成的动态数组中"删除"一个元素,究竟意味着什么?

这不仅仅是"让它消失"。这个动作在物理和逻辑层面包含两个不可分割的后果:

  1. 资源回收:被删除的元素自身可能占有其它资源(在我们的案例中,是它所指向的字符串内存)。这些资源必须被释放,否则将永远丢失在内存中,成为"内存泄漏"。

  2. 结构完整性 :我们的 transactions 数组必须保持其"连续性"。我们不能在数组中间留下一个无效的"空洞"。整个集合的有效元素数量必须减少。

基于此,我们来推导删除操作的每一步。

推导第一步:确定删除的"合法性"

  • 问题:我们能删除一个不存在的账目吗?显然不能。如果用户想删除第 100 号账目,但我们总共只有 10 笔,这是一个非法的操作。

  • 结论:在执行任何删除动作之前,首要任务是验证 要删除的元素索引(index)是否在有效范围内。有效的范围是 [0, count - 1]

代码框架

cpp 复制代码
#include <string.h> // for memmove

// ... (structs and other functions) ...

// 函数接受一个账本指针和要删除的记录索引
// 返回 1 表示成功,0 表示失败
int delete_transaction(struct Ledger* ledger, int index) {
    // 验证索引是否在有效范围内
    if (index < 0 || index >= ledger->count) {
        // 索引无效,这是一个无法执行的操作
        return 0; // 报告失败
    }

    // ... 后续步骤 ...

    return 1;
}

推导第二步:履行"资源回收"的责任

  • 问题:我们即将"忘记" ledger->transactions[index] 这个结构体。它"拥有"什么?根据我们之前的设计,它拥有由 datedescription 指针指向的两块堆内存。

  • 结论:在我们覆盖或移动这个结构体之前,必须 先通过它持有的指针,free 掉它所拥有的那两块字符串内存。这是整个删除操作中最关键、最容易出错的一步。

cpp 复制代码
// ... (索引验证) ...

// 在忘记这个结构体之前,释放它内部管理的内存
free(ledger->transactions[index].date);
free(ledger->transactions[index].description);

// ... 后续步骤 ...

推导第三步:维护"结构完整性"

  • 问题:释放了内部字符串后,ledger->transactions[index] 里的指针成了野指针,这个位置的数据已无意义,形成了一个逻辑上的"空洞"。如何填补这个空洞?

  • 结论:我们需要将该位置之后的所有元素 ,整体向前移动一个位置。index + 1 的元素移动到 indexindex + 2 的移动到 index + 1,以此类推。

如何高效、安全地"移动"一块内存❓

  • 自己写循环:可以,但繁琐且易错。

  • memcpy vs memmove :两者都用于内存拷贝。但当源内存区域和目标内存区域发生重叠时(我们的情况就是如此),memcpy 的行为是未定义的,可能会导致数据损坏。

  • memmove 专门为处理重叠内存而设计,因此是唯一正确、安全的选择。

cpp 复制代码
// ... (释放内部内存) ...

// 计算需要移动的元素数量
// 如果删除的是最后一个元素,那么需要移动的数量就是 0
int num_to_move = ledger->count - 1 - index;
if (num_to_move > 0) {
    memmove(&ledger->transactions[index],      // 目标:空洞的位置
            &ledger->transactions[index + 1],  // 源:空洞之后第一个元素
            num_to_move * sizeof(struct Transaction)); // 移动的总字节数
}

// ... 后续步骤 ...

推导第四步:更新"逻辑状态"

  • 问题:我们已经完成了物理上的资源回收和数据迁移,账本的逻辑状态发生了什么变化?

  • 结论:账本的总记录数减少了一个。必须更新 count 成员以反映这一变化。

最终代码:

cpp 复制代码
int delete_transaction(struct Ledger* ledger, int index) {
    if (index < 0 || index >= ledger->count) {
        return 0;
    }

    free(ledger->transactions[index].date);
    free(ledger->transactions[index].description);

    int num_to_move = ledger->count - 1 - index;
    if (num_to_move > 0) {
        memmove(&ledger->transactions[index],
                &ledger->transactions[index + 1],
                num_to_move * sizeof(struct Transaction));
    }

    // 物理和逻辑都完成后,更新计数器
    ledger->count--;

    return 1;
}

操作四:销毁整个账本 (destroy_ledger)

当一个账本的生命周期结束时,我们应该如何确保它所申请的所有资源都被干净、彻底地归还给操作系统?

这本质上是一个"拆解"的过程,而拆解的顺序至关重要。

推导第一步:确立"拆解顺序"

回顾 create_ledgeradd_transaction,我们申请了三种内存:

  1. 最顶层:Ledger 结构体本身。

  2. 中间层:transactions 数组。

  3. 最底层:每一笔 Transaction 内部的 datedescription 字符串。 我们应该按什么顺序 free 它们?

结论:必须遵循与创建时完全相反的顺序,即"由内而外"、"由下至上"。如果我们先 freetransactions 数组,我们就将永远失去访问内部那些字符串的指针,从而导致大规模内存泄漏。

正确的拆解顺序

  1. 遍历每一笔有效的账目,释放其内部的字符串。

  2. 释放 transactions 数组本身。

  3. 最后,释放 Ledger 结构体。

推导第二步:执行"最底层"的拆解

  • 问题:如何访问并释放每一笔账目内部的字符串?

  • 结论:需要一个循环,遍历从 0count - 1 的所有账目,并对每一个调用 free

cpp 复制代码
void destroy_ledger(struct Ledger* ledger) {
    // 首先检查传入的指针本身是否有效,这是一个好习惯
    if (ledger == NULL) {
        return;
    }

    // 1. 由内而外:先释放每一笔交易内部的动态内存
    for (int i = 0; i < ledger->count; i++) {
        free(ledger->transactions[i].date);
        free(ledger->transactions[i].description);
    }

    // ... 后续步骤 ...
}

推导第三步:执行"中间层"和"顶层"的拆解

  • 问题:内部数据都已被释放,接下来是什么?

  • 结论:按照顺序,释放数组,然后释放 Ledger 结构体本身。

最终代码

cpp 复制代码
void destroy_ledger(struct Ledger* ledger) {
    if (ledger == NULL) {
        return;
    }

    // 1. 释放所有 transaction 内部的字符串
    for (int i = 0; i < ledger->count; i++) {
        free(ledger->transactions[i].date);
        free(ledger->transactions[i].description);
    }

    // 2. 释放 transaction 数组本身
    free(ledger->transactions);

    // 3. 释放 Ledger 结构体本身
    free(ledger);
}

至此,我们已经从第一性原理出发,为 Ledger 定义了它完整的生命周期:create 赋予其初始生命,add 使其成长,delete 使其收缩,destroy 使其在完成使命后,将所有资源归还系统,了无痕迹。这个健壮的内存中数据结构现在已经准备就绪,可以作为更上层应用(如文件操作和用户交互)的坚实基础。


数据**持久化,**让记录"不会丢失"

我们已经为 Ledger 数据结构建立了坚实的"内存法则"------如何创建、成长、收缩和销毁。但目前为止,它仍然是一个活在当下、没有记忆的"浮士德"。程序一旦结束,一切归于虚无。

接下来,我们将从第一性原理出发,为它注入"灵魂",让它能够跨越程序的生死,实现数据的持久化 (Persistence)

什么是"持久化"?

它的本质是将程序在易失性 介质(内存,断电即消失)中的信息状态,完整地"翻译"并"刻印"到非易失性介质(硬盘、SSD,断电后依然存在)上。反之,也要能将这种"刻印"重新"翻译"回内存中的状态。

这个"刻印"的媒介,就是文件。我们的任务,就是设计一套可靠的"翻译"规则。


翻译规则的设计:选择文件格式

我们内存中的 Ledger 结构是一个复杂的、由指针相互关联的立体结构。而文件本质上是一个线性的、一维的字节序列。我们如何将这个立体结构"压平"成一维序列,并且还能无损地还原回来?

推导第一步:二进制 vs. 文本

1️⃣**:二进制存储** 。直接将内存中的 struct Transaction 字节块(write(file, &transaction, sizeof(struct Transaction)))写入文件。

  • 分析:看似简单高效。

  • 致命缺陷:我们的 struct Transaction 含有 char* 指针。一个指针存储的仅仅是一个内存地址(例如 0x7ffee1b7d5f8)。

  • 将这个地址写入文件毫无意义,因为当程序下次运行时,操作系统分配的地址会完全不同。因此,二进制方案在此设计下被彻底否决。

2️⃣**:文本存储**。将结构体中的每个成员(无论是指针指向的字符串,还是数字)都转换成人类可读的字符序列。

  • 分析:我们不存储地址,而是存储地址所指向的内容。下次运行时,我们读取这些内容,为它们分配新的内存和新的地址,然后重建结构。这个方案是可行的。

推导第二步:设计文本的"语法"

当所有数据都变成字符时,我们如何区分一个账目的结束和另一个账目的开始?又如何区分一个账目内部的日期、金额和描述?

我们需要定义分隔符 (Delimiter)

  • 记录分隔符 :在计算机世界中,最通用、最自然地表示"一行结束"的符号是换行符 (\n)。我们规定,文件中的每一行代表一笔独立的账目。

  • 字段分隔符 :在一行之内,我们需要一个特殊字符来隔开日期、金额、描述。逗号 (,) 是一个绝佳的选择,因为它不常出现在这三类数据中,并且是事实上的标准(CSV, Comma-Separated Values)。

👉最终格式:日期字符串,金额,描述字符串\n 例如:2025-09-11,-59.50,团队午餐\n

设计完规则,我们就可以开始推导实现"翻译"过程的两个核心操作了。


操作五:记忆 ------ save_ledger_to_file (将账本存入文件)

"保存"的本质是什么?它是将内存中 Ledger 的当前快照,按照我们设计的"语法",精确无误地转录为文件中的文本序列。

推导第一步:建立与"外部世界"的连接

  • 问题:程序如何与一个文件对话?它需要通过操作系统获得一个"凭证"或"句柄"。

  • 结论:在C语言中,这个凭证就是 FILE* 指针。我们使用 fopen 函数来获取它。fopen 需要两个信息:文件名(我们要写入的路径)和模式(我们想做什么)。

  • 模式选择:"保存"意味着用当前最新的状态覆盖 旧的状态。因此,我们选择写入模式 "w"

cpp 复制代码
#include <stdio.h> // for file operations

// ...

// 函数接受一个账本指针和文件名
int save_ledger_to_file(const struct Ledger* ledger, const char* filename) {
    // "w" - write mode. 如果文件存在则清空,不存在则创建。
    FILE* file = fopen(filename, "w");

    // ... 后续步骤 ...
}

推导第二步:处理连接失败

  • 问题:打开文件是一个可能失败的操作(例如,权限不足、路径无效)。

  • 结论:必须检查 fopen 的返回值。如果为 NULL,表示连接建立失败,我们必须立即停止并报告错误。

cpp 复制代码
int save_ledger_to_file(const struct Ledger* ledger, const char* filename) {
    FILE* file = fopen(filename, "w");
    if (file == NULL) {
        // 无法打开文件,可能是权限问题。
        perror("Error opening file for writing"); // perror会打印具体的系统错误信息
        return 0; // 失败
    }
    // ... 后续步骤 ...
}

推导第三步:逐个"翻译"原子

  • 问题:如何将整个 Ledger 的内容写入文件?LedgerTransaction 的集合。最自然的逻辑是逐个处理 Transaction

  • 结论:我们需要一个循环,遍历 ledger 中所有有效的记录(从 0count - 1)。

cpp 复制代码
// ... (打开文件并检查后) ...

for (int i = 0; i < ledger->count; i++) {
    // 在这里处理第 i 笔交易
    // ...
}

// ... 后续步骤 ...

推导第四步:执行"原子"的翻译

  • 问题:在循环内部,如何将一个 struct Transaction 对象转换成我们设计的 CSV 格式的字符串?

  • 结论:C语言提供了 fprintf 函数,它可以像 printf 一样进行格式化输出,但目标不是屏幕,而是我们打开的文件。

cpp 复制代码
for (int i = 0; i < ledger->count; i++) {
    // 获取当前要处理的交易
    const struct Transaction* trans = &ledger->transactions[i];

    // 按照 "date_string,amount,description_string\n" 的格式写入
    fprintf(file, "%s,%f,%s\n", trans->date, trans->amount, trans->description);
}

推导第五步:断开连接并交还资源

  • 问题:写入完成后,我们还占有着与文件对话的"凭证" (FILE*)。

  • 结论:必须归还这个凭证,告诉操作系统我们已经完成了操作。这不仅能确保所有缓冲的数据被真正写入硬盘,还能释放系统资源。这个操作是 fclose

cpp 复制代码
int save_ledger_to_file(const struct Ledger* ledger, const char* filename) {
    FILE* file = fopen(filename, "w");
    if (file == NULL) {
        perror("Error opening file for writing");
        return 0;
    }

    for (int i = 0; i < ledger->count; i++) {
        const struct Transaction* trans = &ledger->transactions[i];
        fprintf(file, "%s,%f,%s\n", trans->date, trans->amount, trans->description);
    }

    fclose(file); // 关闭文件,释放资源
    return 1; // 成功
}

操作六:回忆 ------ load_ledger_from_file (从文件加载账本)

"加载"的本质是什么?它是"保存"的逆过程。读取文件中的线性文本,按照"语法"进行解析,并在内存中重建出那个立体的 Ledger 结构。

推导第一步:重建的起点

  • 问题:我们不能将数据加载到虚无之中。在开始读取文件之前,我们必须先拥有一个什么?

  • 结论:我们必须先拥有一个空的、但结构完整的 Ledger 。幸运的是,我们已经设计了 create_ledger 函数来做这件事。

cpp 复制代码
// 这个函数的目标是创建一个新的、填满了数据的 Ledger
struct Ledger* load_ledger_from_file(const char* filename) {
    // 创造一个空的容器,准备接收数据
    struct Ledger* ledger = create_ledger();
    if (ledger == NULL) {
        return NULL; // 如果连空账本都创建失败,直接返回
    }

    // ... 后续步骤 ...

    return ledger;
}

推导第二步:再次建立连接

  • 问题:如何从文件读取?

  • 结论:同样使用 fopen,但这次的模式是读取模式 "r"

cpp 复制代码
// ... (创建空账本后) ...

FILE* file = fopen(filename, "r");

// ...

推导第三步:处理"第一次运行"的场景

  • 问题:如果 fopen"r" 模式下返回 NULL 意味着什么?

  • 结论:这通常意味着文件不存在。这不是一个程序错误,而是一个正常的业务场景------用户第一次运行程序,还没有任何数据。在这种情况下,我们应该怎么做?

  • 答案:我们应该返回那个刚刚创建好的空账本。程序将从一个干净的状态开始。

cpp 复制代码
FILE* file = fopen(filename, "r");
if (file == NULL) {
    // 文件不存在,是正常情况,直接返回一个全新的空账本
    return ledger;
}

// ... 文件确实存在,继续处理 ...

推导第四步:逐行"解析"

  • 问题:如何逐行读取文件,并将每一行解析成日期、金额、描述三个部分?

  • 读取 :使用 fgets 是最安全的方式,它可以读取一行(或直到缓冲区满),避免了 scanf 类的溢出风险。我们需要一个循环,只要 fgets 还能读到内容就一直继续。

  • 解析 :对于从 fgets 读到的一行字符串,我们可以使用 sscanf 来从中提取格式化的数据。这是 fprintf 的逆操作。

cpp 复制代码
// ... (文件成功打开后) ...

char line_buffer[256]; // 一个足够大的缓冲区来存放一行数据
while (fgets(line_buffer, sizeof(line_buffer), file) != NULL) {
    // 成功读取了一行到 line_buffer
    // 现在需要解析它

    char date[100], description[100];
    double amount;

    // 使用 sscanf 从字符串中解析数据
    // %[^,] 读取所有非逗号的字符
    // %lf 读取一个 double
    // %[^\n] 读取所有非换行符的字符
    if (sscanf(line_buffer, "%[^,],%lf,%[^\n]", date, &amount, description) == 3) {
        // 如果成功匹配并赋值了 3 个项目...
        // ... 后续步骤 ...
    }
}

推导第五步:重用"成长"的逻辑

  • 问题:当我们从一行文本中成功解析出 date, amount, description 后,如何将它们添加到 ledger 中?

  • 结论:我们是否需要在这里重写一遍 malloc 字符串、检查容量、reallocstrcpy 等等逻辑?完全不需要! 我们已经设计了完美、健壮的 add_transaction 函数。重用是优秀软件设计的核心原则。

cpp 复制代码
// ...
if (sscanf(line_buffer, "%[^,],%lf,%[^\n]", date, &amount, description) == 3) {
    // 我们已经有了数据,现在调用核心的添加函数来处理所有内存细节
    add_transaction(ledger, date, amount, description);
}
// ...

推导第六步:收尾工作

  • 问题:循环结束后,文件读取完毕,还剩下什么?

  • 结论:和保存时一样,必须用 fclose 断开连接。然后返回我们精心重建的、充满了数据的 ledger

cpp 复制代码
struct Ledger* load_ledger_from_file(const char* filename) {
    struct Ledger* ledger = create_ledger();
    if (ledger == NULL) {
        return NULL;
    }

    FILE* file = fopen(filename, "r");
    if (file == NULL) {
        // 文件不存在,返回空账本
        return ledger;
    }

    char line_buffer[256];
    while (fgets(line_buffer, sizeof(line_buffer), file) != NULL) {
        char date[100], description[100];
        double amount;

        if (sscanf(line_buffer, "%[^,],%lf,%[^\n]", date, &amount, description) == 3) {
            add_transaction(ledger, date, amount, description);
        }
    }

    fclose(file); // 完成操作,关闭文件
    return ledger; // 返回重建好的账本
}

至此,我们已经通过第一性原理,完整地推导出了赋予记账本"记忆"和"回忆"能力的核心逻辑。这两个函数是连接程序内存世界和物理存储世界的桥梁。


我们现在进入最后一步:将之前从第一性原理推导出的所有部件------数据结构、核心操作、持久化逻辑------组装成一个完整、健壮、模块化的应用程序。

项目的整体设计(模块化编程)

为了使项目结构清晰、易于维护和扩展,我们不能把所有代码都塞进一个文件里。我们将采用模块化编程思想,将项目拆分为三个核心文件:

ledger.h (头文件 - The "Contract")

  • 角色:这是一个"契约"或"公共接口"。它向程序的其他部分声明 Ledger 模块能做什么,但隐藏了具体是怎么做的。

  • 内容:定义 TransactionLedger 数据结构,并提供所有公共函数的原型声明(function prototypes)。

ledger.c (实现文件 - The "Engine Room")

  • 角色:这是"引擎室",包含了所有"契约"中承诺的功能的具体实现。

  • 内容:create_ledger, add_transaction, delete_transaction, destroy_ledger, save_ledger_to_file, load_ledger_from_file 这些函数的完整代码。

main.c (主程序文件 - The "Cockpit")

  • 角色:这是"驾驶舱",是用户与程序交互的界面。它负责处理用户的输入、显示菜单和信息,并调用"引擎室"中的功能来完成实际工作。

  • 内容:main 函数,程序的主循环,以及所有与用户界面相关的功能。

文件一:ledger.h

这是我们的接口定义。它使用"头文件保护"(#ifndef ... #define ... #endif)来防止在编译时被重复包含。

cpp 复制代码
#ifndef LEDGER_H
#define LEDGER_H

#include <stdio.h>

// -----------------------------------------------------------------------------
// I. 数据结构定义 (The "Nouns")
// -----------------------------------------------------------------------------

/**
 * @brief 表示单笔交易的结构体。
 * 内部字符串成员使用 char*,需要手动管理内存。
 */
typedef struct {
    char* date;          // 日期字符串
    double amount;       // 金额 (正数为收入, 负数为支出)
    char* description;   // 描述
} Transaction;

/**
 * @brief 表示整个账本的结构体。
 * 管理一个动态增长的 Transaction 数组。
 */
typedef struct {
    Transaction *transactions; // 指向动态分配的交易数组
    int count;                 // 当前交易数量
    int capacity;              // 数组当前的总容量
} Ledger;

// -----------------------------------------------------------------------------
// II. 公共函数原型声明 (The "Verbs")
// -----------------------------------------------------------------------------

/**
 * @brief 创建并初始化一个新的、空的账本。
 * @return 指向新创建的 Ledger 的指针;如果内存分配失败则返回 NULL。
 */
Ledger* create_ledger(void);

/**
 * @brief 安全地销毁账本,释放所有相关内存。
 * @param ledger 指向要销毁的账本的指针。
 */
void destroy_ledger(Ledger* ledger);

/**
 * @brief 向账本中添加一笔新的交易。
 * @param ledger 指向要操作的账本。
 * @param date 交易日期字符串。
 * @param amount 交易金额。
 * @param description 交易描述字符串。
 * @return 成功返回 1,失败返回 0。
 */
int add_transaction(Ledger* ledger, const char* date, double amount, const char* description);

/**
 * @brief 从账本中删除指定索引的交易。
 * @param ledger 指向要操作的账本。
 * @param index 要删除的交易的索引 (从 0 开始)。
 * @return 成功返回 1,索引无效则返回 0。
 */
int delete_transaction(Ledger* ledger, int index);

/**
 * @brief 将账本的当前状态保存到文件中。
 * @param ledger 指向要保存的账本。
 * @param filename 要写入的文件名。
 * @return 成功返回 1,失败返回 0。
 */
int save_ledger_to_file(const Ledger* ledger, const char* filename);

/**
 * @brief 从文件中加载数据来创建一个新的账本。
 * @param filename 要读取的文件名。
 * @return 指向新创建并填充了数据的 Ledger 的指针。如果文件不存在,返回一个空的账本。
 */
Ledger* load_ledger_from_file(const char* filename);


#endif // LEDGER_H

文件二:ledger.c

这是所有核心功能的实现。它包含了我们之前一步步推导出的所有逻辑。

cpp 复制代码
#include <stdlib.h>
#include <string.h>
#include "ledger.h"

#define INITIAL_CAPACITY 10

// 实现 create_ledger 函数
Ledger* create_ledger(void) {
    Ledger* ledger = (Ledger*) malloc(sizeof(Ledger));
    if (ledger == NULL) {
        return NULL;
    }

    ledger->count = 0;
    ledger->capacity = INITIAL_CAPACITY;
    ledger->transactions = (Transaction*) malloc(INITIAL_CAPACITY * sizeof(Transaction));

    if (ledger->transactions == NULL) {
        free(ledger);
        return NULL;
    }

    return ledger;
}

// 实现 destroy_ledger 函数
void destroy_ledger(Ledger* ledger) {
    if (ledger == NULL) {
        return;
    }

    for (int i = 0; i < ledger->count; i++) {
        free(ledger->transactions[i].date);
        free(ledger->transactions[i].description);
    }

    free(ledger->transactions);
    free(ledger);
}

// 实现 add_transaction 函数
int add_transaction(Ledger* ledger, const char* date, double amount, const char* description) {
    if (ledger->count == ledger->capacity) {
        int new_capacity = ledger->capacity * 2;
        Transaction* new_transactions = (Transaction*) realloc(ledger->transactions, new_capacity * sizeof(Transaction));

        if (new_transactions == NULL) {
            return 0; // 扩容失败
        }
        ledger->transactions = new_transactions;
        ledger->capacity = new_capacity;
    }

    Transaction* new_trans = &ledger->transactions[ledger->count];

    new_trans->date = (char*) malloc(strlen(date) + 1);
    new_trans->description = (char*) malloc(strlen(description) + 1);

    if (new_trans->date == NULL || new_trans->description == NULL) {
        free(new_trans->date);
        free(new_trans->description);
        return 0; // 字符串内存分配失败
    }

    strcpy(new_trans->date, date);
    strcpy(new_trans->description, description);
    new_trans->amount = amount;

    ledger->count++;
    return 1;
}

// 实现 delete_transaction 函数
int delete_transaction(Ledger* ledger, int index) {
    if (index < 0 || index >= ledger->count) {
        return 0; // 索引无效
    }

    free(ledger->transactions[index].date);
    free(ledger->transactions[index].description);

    int num_to_move = ledger->count - 1 - index;
    if (num_to_move > 0) {
        memmove(&ledger->transactions[index],
                &ledger->transactions[index + 1],
                num_to_move * sizeof(Transaction));
    }

    ledger->count--;
    return 1;
}

// 实现 save_ledger_to_file 函数
int save_ledger_to_file(const Ledger* ledger, const char* filename) {
    FILE* file = fopen(filename, "w");
    if (file == NULL) {
        perror("Error opening file for writing");
        return 0;
    }

    for (int i = 0; i < ledger->count; i++) {
        const Transaction* trans = &ledger->transactions[i];
        fprintf(file, "%s,%.2f,%s\n", trans->date, trans->amount, trans->description);
    }

    fclose(file);
    return 1;
}

// 实现 load_ledger_from_file 函数
Ledger* load_ledger_from_file(const char* filename) {
    Ledger* ledger = create_ledger();
    if (ledger == NULL) {
        return NULL;
    }

    FILE* file = fopen(filename, "r");
    if (file == NULL) {
        // 文件不存在是正常情况,返回一个空账本
        return ledger;
    }

    char line_buffer[256];
    while (fgets(line_buffer, sizeof(line_buffer), file) != NULL) {
        char date[100], description[100];
        double amount;

        if (sscanf(line_buffer, "%[^,],%lf,%[^\n]", date, &amount, description) == 3) {
            add_transaction(ledger, date, amount, description);
        }
    }

    fclose(file);
    return ledger;
}

文件三:main.c

这是用户交互的入口。它负责显示菜单、获取输入并调用 ledger.h 中声明的函数。

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include "ledger.h"

#define DATA_FILENAME "ledger_data.csv"

// 函数原型声明
void print_menu();
void handle_add_transaction(Ledger* ledger);
void handle_view_transactions(const Ledger* ledger);
void handle_delete_transaction(Ledger* ledger);
void clear_input_buffer();

int main() {
    // 1. 程序启动,从文件加载数据
    Ledger* ledger = load_ledger_from_file(DATA_FILENAME);
    if (ledger == NULL) {
        printf("Failed to initialize ledger. Exiting.\n");
        return 1;
    }

    int choice = 0;
    while (1) {
        print_menu();
        // 读取用户选择
        if (scanf("%d", &choice) != 1) {
            printf("Invalid input. Please enter a number.\n");
            clear_input_buffer(); // 清理无效输入
            continue;
        }
        clear_input_buffer(); // 清理掉数字后面的换行符

        switch (choice) {
            case 1:
                handle_add_transaction(ledger);
                break;
            case 2:
                handle_view_transactions(ledger);
                break;
            case 3:
                handle_delete_transaction(ledger);
                break;
            case 4:
                // 退出前先保存
                if (save_ledger_to_file(ledger, DATA_FILENAME)) {
                    printf("Data saved successfully.\n");
                } else {
                    printf("Error saving data.\n");
                }
                destroy_ledger(ledger); // 释放所有内存
                printf("Goodbye!\n");
                return 0; // 退出程序
            default:
                printf("Invalid choice. Please try again.\n");
                break;
        }
        printf("\n");
    }

    return 0; // 理论上不会执行到这里
}

// 打印主菜单
void print_menu() {
    printf("=============================\n");
    printf("=   Command-Line Ledger     =\n");
    printf("=============================\n");
    printf("1. Add a new transaction\n");
    printf("2. View all transactions\n");
    printf("3. Delete a transaction\n");
    printf("4. Save and Exit\n");
    printf("-----------------------------\n");
    printf("Enter your choice: ");
}

// 处理添加交易的逻辑
void handle_add_transaction(Ledger* ledger) {
    char date[100], description[100];
    double amount;

    printf("Enter date (e.g., 2025-09-11): ");
    scanf("%99s", date);
    clear_input_buffer();

    printf("Enter amount (e.g., -59.5 for expense): ");
    while (scanf("%lf", &amount) != 1) {
        printf("Invalid amount. Please enter a number: ");
        clear_input_buffer();
    }
    clear_input_buffer();

    printf("Enter description: ");
    // 使用 fgets 读取可能带空格的描述
    if (fgets(description, sizeof(description), stdin)) {
        // 移除 fgets 读取到的末尾换行符
        description[strcspn(description, "\n")] = 0;
    }


    if (add_transaction(ledger, date, amount, description)) {
        printf("Transaction added successfully!\n");
    } else {
        printf("Failed to add transaction.\n");
    }
}

// 处理查看交易的逻辑
void handle_view_transactions(const Ledger* ledger) {
    printf("\n--- All Transactions ---\n");
    if (ledger->count == 0) {
        printf("No transactions to display.\n");
    } else {
        printf("No. | Date         | Amount     | Description\n");
        printf("----|--------------|------------|--------------------------\n");
        for (int i = 0; i < ledger->count; i++) {
            const Transaction* t = &ledger->transactions[i];
            printf("%-3d | %-12s | %-10.2f | %s\n", i + 1, t->date, t->amount, t->description);
        }
    }
    printf("------------------------\n");
}

// 处理删除交易的逻辑
void handle_delete_transaction(Ledger* ledger) {
    if (ledger->count == 0) {
        printf("No transactions to delete.\n");
        return;
    }

    handle_view_transactions(ledger); // 先显示所有条目
    printf("Enter the transaction number to delete: ");
    
    int choice = 0;
    if (scanf("%d", &choice) != 1) {
        printf("Invalid input.\n");
        clear_input_buffer();
        return;
    }
    clear_input_buffer();

    // 将用户的选择 (从1开始) 转换为数组索引 (从0开始)
    int index = choice - 1;

    if (delete_transaction(ledger, index)) {
        printf("Transaction %d deleted successfully.\n", choice);
    } else {
        printf("Failed to delete. Invalid transaction number.\n");
    }
}

// 清理标准输入缓冲区,防止 scanf 留下意外的字符
void clear_input_buffer() {
    int c;
    while ((c = getchar()) != '\n' && c != EOF);
}

现在,你拥有了一个从第一性原理推导、设计并完整实现的、模块化的命令行记账本程序。

相关推荐
kyle~2 小时前
python---PyInstaller(将Python脚本打包为可执行文件)
开发语言·前端·python·qt
菜就多练,以前是以前,现在是现在2 小时前
Codeforces Round 1048 (Div. 2)
数据结构·c++·算法
User:你的影子2 小时前
WPF ItemsControl 绑定
开发语言·前端·javascript
野生的编程萌新2 小时前
【C++深学日志】从0开始的C++生活
c语言·开发语言·c++·算法
木心爱编程3 小时前
C++程序员速通C#:从Hello World到数据类型
c++·c#
※※冰馨※※3 小时前
【c#】 使用winform如何将一个船的图标(ship.png)添加到资源文件
开发语言·windows·c#
ulias2123 小时前
单元最短路问题
数据库·c++·算法·动态规划
ajassi20004 小时前
开源 C++ QT Widget 开发(十六)程序发布
linux·c++·qt·开源
蜀中廖化4 小时前
bash:trtexec:command not found
开发语言·bash