C/C++项目练习:命令行记账本
目录
明确目标 - 我们要做什么?
回归本质,定义“一笔账”是什么?
从“一笔账”到“一本账”,如何存储多条记录?
操作一:创建一个空的账本 (create_ledger)
操作二:向账本中添加一条记录 (add_transaction)
操作三:从账本中删除一条记录 (delete_transaction)
操作四:销毁整个账本 (destroy_ledger)
数据持久化,让记录“不会丢失”
翻译规则的设计:选择文件格式
操作五:记忆 —— save_ledger_to_file (将账本存入文件)
操作六:回忆 —— load_ledger_from_file (从文件加载账本)
项目的整体设计(模块化编程)
文件一:ledger.h
文件二:ledger.c
文件三:main.c
明确目标 - 我们要做什么?
在动手之前,我们必须用最清晰的语言定义我们的目标。这本身就是第一性原理的一部分——准确定义问题。
📌功能性需求:
-
记录账目 (Add):能够添加一笔新的收支记录,包含日期、金额和描述。
-
查看账目 (View):能够列出所有已记录的账目。
-
删除账目 (Delete):能够根据某种标识(如序号)删除一笔已存在的记录。
📍技术性约束:
-
动态管理 (Dynamic):记录的数量没有硬性上限,程序应能根据需要自动扩展存储空间。
-
数据持久化 (Persistence):程序关闭后,数据不应丢失,需要通过文件操作来保存和加载。
-
字符串类型 (String Type):在数据结构中,所有字符串类型都必须使用
char*
(字符指针),而不是char[]
(字符数组)。这是一个非常关键的技术选型,它将深刻影响我们的内存管理方式。
我们完全从“第一性原理”出发,一步一步地拆解和构建这个命令行记账本项目。
忘记所有复杂的代码和现成的方案,我们回到最根本的问题。
回归本质,定义“一笔账”是什么?
一个项目最核心的是它处理的数据。对于记账本来说,最核心、最不可再分的数据单元就是“一笔账目记录”。
那么,一笔账目记录应该包含哪些信息?
-
日期 (Date):什么时候发生的?例如 "2025-09-10"。
-
金额 (Amount):花了多少钱或赚了多少钱?例如
50.0
或-35.5
。用正负数可以很自然地表示收入和支出(income
/expense
)。 -
描述 (Description):这笔钱花在了哪里?例如 "午餐"、"工资" 等。
我们找到了最核心的原子。在 C 语言中,如何将这些不同类型的数据(日期是字符串,金额是浮点数,描述是字符串)捆绑在一起,形成一个整体呢?
答案就是 结构体 struct
。
// 定义一个结构体来表示“一笔账”
struct Transaction {char date[20]; // 日期字符串,如 "2025-09-10"double amount; // 金额,正数表示收入,负数表示支出char description[100]; // 描述,如 "午餐"
};
这种方式简单。date
和 description
的内存是结构体的一部分,它们和 amount
一起被分配。
❌缺点:浪费空间。每个“description”无论长短,都占用100字节。更重要的是,它不符合我们 char*
的技术约束。此方案被否决。
struct Transaction {char* date; // 一个指针double amount;char* description; // 另一个指针
};
这是符合我们要求的方案。这个结构体本身非常小,它只存储了两个指针(地址)和一个双精度浮点数。
✅ date
和 description
所指向的字符串数据,并不在结构体内部。它们位于内存的其它地方(我们之后将使用 malloc
从“堆”上分配)。
-
创建时:当我们创建一笔新的
Transaction
时,我们不仅要为struct Transaction
本身分配空间,还必须单独为date
和description
字符串分配内存,然后把字符串内容拷贝进去。 -
销毁时:当我们删除一笔
Transaction
时,我们必须先free
掉date
和description
指针所指向的内存,然后再处理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
:
struct Ledger {struct Transaction *transactions; // 指向一个动态分配的、存放 Transaction 结构体的数组int count; // 当前账目数量int capacity; // 数组当前的总容量
};
现在,我们来推导围绕这个 Ledger
必须有的核心操作。
接下来,我们将以同样严谨的推导方式,为这个“集合”赋予生命,也就是定义它必须具备的核心操作。这些操作是这个数据结构之所以有用的根本。我们将只关注这四个核心操作:创建 (Create)、添加 (Add)、删除 (Delete) 和 销毁 (Destroy)。
操作一:创建一个空的账本 (create_ledger
)
目标:初始化一个 Ledger
结构体,让它处于一个“空”但可用的状态。
第一性问题: 一个“账本”在它存在之初,应该是什么状态?
它必须是一个“空的,但随时准备好记录第一笔账”的状态。我们来推导如何从无到有地构建出这个状态。
推导第一步:存在本身
我们如何凭空创造一个 Ledger
对象?在C语言中,变量要么在栈上(函数结束就消失),要么在堆上(手动管理其生命周期)。我们的账本需要在多个函数之间传递,甚至贯穿整个程序生命周期,因此它必须存在于堆上。
必须使用 malloc
为 Ledger
结构体本身请求一块内存。
#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
。如果失败,我们无法创建账本,必须将这个失败的结果通知给调用者。
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
。
// ...
if (ledger == NULL) { /* ... */ }ledger->count = 0; // 明确表示当前没有任何记录// ... 后续步骤 ...
// ...
推导第四步:实现“准备好”的状态
-
问题:账本光是“空”的还不够,它必须“准备好”接收数据。这意味着它的
transactions
指针必须指向一块有效的内存区域,即使这个区域里还没有任何数据。 -
结论:我们需要为
transactions
数组预先分配一块初始大小的内存。这个大小是任意的,但一个合理的初始值(比如10)可以避免过早地进行扩容。
#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
失败,我们必须释放第一步已成功分配的内存,以避免内存泄漏。程序的状态必须保持一致:要么整个账本创建成功,要么什么都不留下。这叫“原子性”。
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
(总容量)?
代码框架:
// 函数需要账本的指针和新账目的数据作为输入
// 使用 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
。 -
扩容策略:每次扩容多大?一个常见的、高效的策略是容量翻倍。这能在添加次数和单次扩容成本之间取得很好的平衡。
代码完善(扩容部分):
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;
}
推导第三步:数据的“所有权”
-
问题:现在我们确保有空间了。新账目的数据(
date
和description
字符串)从哪里来?它们由调用者传入。我们能直接ledger->transactions[count].date = date;
吗?❌绝对不能! -
原因:传入的指针
date
和description
可能指向一个临时缓冲区或者一个马上要被修改的变量。我们的账本必须拥有这些数据的独立副本,其生命周期由账本自己管理。 -
结论:对于每一个字符串,我们必须:
-
在堆上为它分配一块大小正好的新内存 (
malloc
)。 -
将传入的字符串内容拷贝到这块新内存中 (
strcpy
)。
-
代码完善(数据拷贝部分):
// 经过扩容检查后,我们保证在 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
,以反映它现在多了一笔记录。
最终代码:
// ... (扩容和字符串分配部分) ...// 拷贝字符串内容
strcpy(new_trans->date, date);
strcpy(new_trans->description, description);// 赋值剩余成员
new_trans->amount = amount;// 所有步骤成功,最后更新计数器
ledger->count++;return 1; // 成功
操作三:从账本中删除一条记录 (delete_transaction
)
从一个由指针构成的动态数组中“删除”一个元素,究竟意味着什么?
这不仅仅是“让它消失”。这个动作在物理和逻辑层面包含两个不可分割的后果:
-
资源回收:被删除的元素自身可能占有其它资源(在我们的案例中,是它所指向的字符串内存)。这些资源必须被释放,否则将永远丢失在内存中,成为“内存泄漏”。
-
结构完整性:我们的
transactions
数组必须保持其“连续性”。我们不能在数组中间留下一个无效的“空洞”。整个集合的有效元素数量必须减少。
基于此,我们来推导删除操作的每一步。
推导第一步:确定删除的“合法性”
-
问题:我们能删除一个不存在的账目吗?显然不能。如果用户想删除第 100 号账目,但我们总共只有 10 笔,这是一个非法的操作。
-
结论:在执行任何删除动作之前,首要任务是验证要删除的元素索引(
index
)是否在有效范围内。有效的范围是[0, count - 1]
。
代码框架:
#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]
这个结构体。它“拥有”什么?根据我们之前的设计,它拥有由date
和description
指针指向的两块堆内存。 -
结论:在我们覆盖或移动这个结构体之前,必须先通过它持有的指针,
free
掉它所拥有的那两块字符串内存。这是整个删除操作中最关键、最容易出错的一步。
// ... (索引验证) ...// 在忘记这个结构体之前,释放它内部管理的内存
free(ledger->transactions[index].date);
free(ledger->transactions[index].description);// ... 后续步骤 ...
推导第三步:维护“结构完整性”
-
问题:释放了内部字符串后,
ledger->transactions[index]
里的指针成了野指针,这个位置的数据已无意义,形成了一个逻辑上的“空洞”。如何填补这个空洞? -
结论:我们需要将该位置之后的所有元素,整体向前移动一个位置。
index + 1
的元素移动到index
,index + 2
的移动到index + 1
,以此类推。
如何高效、安全地“移动”一块内存❓
-
自己写循环:可以,但繁琐且易错。
-
memcpy
vsmemmove
:两者都用于内存拷贝。但当源内存区域和目标内存区域发生重叠时(我们的情况就是如此),memcpy
的行为是未定义的,可能会导致数据损坏。 -
memmove
专门为处理重叠内存而设计,因此是唯一正确、安全的选择。
// ... (释放内部内存) ...// 计算需要移动的元素数量
// 如果删除的是最后一个元素,那么需要移动的数量就是 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
成员以反映这一变化。
最终代码:
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_ledger
和 add_transaction
,我们申请了三种内存:
-
最顶层:
Ledger
结构体本身。 -
中间层:
transactions
数组。 -
最底层:每一笔
Transaction
内部的date
和description
字符串。 我们应该按什么顺序free
它们?
结论:必须遵循与创建时完全相反的顺序,即“由内而外”、“由下至上”。如果我们先
free
了transactions
数组,我们就将永远失去访问内部那些字符串的指针,从而导致大规模内存泄漏。
✅正确的拆解顺序:
-
遍历每一笔有效的账目,释放其内部的字符串。
-
释放
transactions
数组本身。 -
最后,释放
Ledger
结构体。
推导第二步:执行“最底层”的拆解
-
问题:如何访问并释放每一笔账目内部的字符串?
-
结论:需要一个循环,遍历从
0
到count - 1
的所有账目,并对每一个调用free
。
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
结构体本身。
最终代码:
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"
。
#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
,表示连接建立失败,我们必须立即停止并报告错误。
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
的内容写入文件?Ledger
是Transaction
的集合。最自然的逻辑是逐个处理Transaction
。 -
结论:我们需要一个循环,遍历
ledger
中所有有效的记录(从0
到count - 1
)。
// ... (打开文件并检查后) ...for (int i = 0; i < ledger->count; i++) {// 在这里处理第 i 笔交易// ...
}// ... 后续步骤 ...
推导第四步:执行“原子”的翻译
-
问题:在循环内部,如何将一个
struct Transaction
对象转换成我们设计的 CSV 格式的字符串? -
结论:C语言提供了
fprintf
函数,它可以像printf
一样进行格式化输出,但目标不是屏幕,而是我们打开的文件。
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
。
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
函数来做这件事。
// 这个函数的目标是创建一个新的、填满了数据的 Ledger
struct Ledger* load_ledger_from_file(const char* filename) {// 创造一个空的容器,准备接收数据struct Ledger* ledger = create_ledger();if (ledger == NULL) {return NULL; // 如果连空账本都创建失败,直接返回}// ... 后续步骤 ...return ledger;
}
推导第二步:再次建立连接
-
问题:如何从文件读取?
-
结论:同样使用
fopen
,但这次的模式是读取模式"r"
。
// ... (创建空账本后) ...FILE* file = fopen(filename, "r");// ...
推导第三步:处理“第一次运行”的场景
-
问题:如果
fopen
在"r"
模式下返回NULL
意味着什么? -
结论:这通常意味着文件不存在。这不是一个程序错误,而是一个正常的业务场景——用户第一次运行程序,还没有任何数据。在这种情况下,我们应该怎么做?
-
答案:我们应该返回那个刚刚创建好的空账本。程序将从一个干净的状态开始。
FILE* file = fopen(filename, "r");
if (file == NULL) {// 文件不存在,是正常情况,直接返回一个全新的空账本return ledger;
}// ... 文件确实存在,继续处理 ...
推导第四步:逐行“解析”
-
问题:如何逐行读取文件,并将每一行解析成日期、金额、描述三个部分?
-
读取:使用
fgets
是最安全的方式,它可以读取一行(或直到缓冲区满),避免了scanf
类的溢出风险。我们需要一个循环,只要fgets
还能读到内容就一直继续。 -
解析:对于从
fgets
读到的一行字符串,我们可以使用sscanf
来从中提取格式化的数据。这是fprintf
的逆操作。
// ... (文件成功打开后) ...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
字符串、检查容量、realloc
、strcpy
等等逻辑?完全不需要! 我们已经设计了完美、健壮的add_transaction
函数。重用是优秀软件设计的核心原则。
// ...
if (sscanf(line_buffer, "%[^,],%lf,%[^\n]", date, &amount, description) == 3) {// 我们已经有了数据,现在调用核心的添加函数来处理所有内存细节add_transaction(ledger, date, amount, description);
}
// ...
推导第六步:收尾工作
-
问题:循环结束后,文件读取完毕,还剩下什么?
-
结论:和保存时一样,必须用
fclose
断开连接。然后返回我们精心重建的、充满了数据的ledger
。
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
模块能做什么,但隐藏了具体是怎么做的。 -
内容:定义
Transaction
和Ledger
数据结构,并提供所有公共函数的原型声明(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
)来防止在编译时被重复包含。
#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
这是所有核心功能的实现。它包含了我们之前一步步推导出的所有逻辑。
#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
中声明的函数。
#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);
}
现在,你拥有了一个从第一性原理推导、设计并完整实现的、模块化的命令行记账本程序。