目录
[明确目标 - 我们要做什么?](#明确目标 - 我们要做什么?)
操作二:向账本中添加一条记录 (add_transaction)
操作三:从账本中删除一条记录 (delete_transaction)
[操作五:记忆 ------ save_ledger_to_file (将账本存入文件)](#操作五:记忆 —— save_ledger_to_file (将账本存入文件))
[操作六:回忆 ------ load_ledger_from_file (从文件加载账本)](#操作六:回忆 —— load_ledger_from_file (从文件加载账本))
明确目标 - 我们要做什么?
在动手之前,我们必须用最清晰的语言定义我们的目标。这本身就是第一性原理的一部分------准确定义问题。
📌功能性需求:
-
记录账目 (Add):能够添加一笔新的收支记录,包含日期、金额和描述。
-
查看账目 (View):能够列出所有已记录的账目。
-
删除账目 (Delete):能够根据某种标识(如序号)删除一笔已存在的记录。
📍技术性约束:
-
动态管理 (Dynamic):记录的数量没有硬性上限,程序应能根据需要自动扩展存储空间。
-
数据持久化 (Persistence):程序关闭后,数据不应丢失,需要通过文件操作来保存和加载。
-
字符串类型 (String Type) :在数据结构中,所有字符串类型都必须使用
char*
(字符指针),而不是char[]
(字符数组)。这是一个非常关键的技术选型,它将深刻影响我们的内存管理方式。
我们完全从"第一性原理"出发,一步一步地拆解和构建这个命令行记账本项目。
忘记所有复杂的代码和现成的方案,我们回到最根本的问题。
回归本质,定义"一笔账"是什么?
一个项目最核心的是它处理的数据。对于记账本来说,最核心、最不可再分的数据单元就是"一笔账目记录"。
那么,一笔账目记录应该包含哪些信息?
-
日期 (Date):什么时候发生的?例如 "2025-09-10"。
-
金额 (Amount) :花了多少钱或赚了多少钱?例如
50.0
或-35.5
。用正负数可以很自然地表示收入和支出(income
/expense
)。 -
描述 (Description):这笔钱花在了哪里?例如 "午餐"、"工资" 等。
我们找到了最核心的原子。在 C 语言中,如何将这些不同类型的数据(日期是字符串,金额是浮点数,描述是字符串)捆绑在一起,形成一个整体呢?
答案就是 结构体 struct
。
cpp
// 定义一个结构体来表示"一笔账"
struct Transaction {
char date[20]; // 日期字符串,如 "2025-09-10"
double amount; // 金额,正数表示收入,负数表示支出
char description[100]; // 描述,如 "午餐"
};
这种方式简单。date
和 description
的内存是结构体的一部分,它们和 amount
一起被分配。
❌缺点:浪费空间。每个"description"无论长短,都占用100字节。更重要的是,它不符合我们 char*
的技术约束。此方案被否决。
cpp
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
:
cpp
struct Ledger {
struct Transaction *transactions; // 指向一个动态分配的、存放 Transaction 结构体的数组
int count; // 当前账目数量
int capacity; // 数组当前的总容量
};
现在,我们来推导围绕这个 Ledger
必须有的核心操作。
接下来,我们将以同样严谨的推导方式,为这个"集合"赋予生命,也就是定义它必须具备的核心操作。这些操作是这个数据结构之所以有用的根本。我们将只关注这四个核心操作:创建 (Create) 、添加 (Add) 、删除 (Delete) 和 销毁 (Destroy)。
操作一:创建一个空的账本 (create_ledger
)
目标:初始化一个 Ledger
结构体,让它处于一个"空"但可用的状态。
第一性问题: 一个"账本"在它存在之初,应该是什么状态?
它必须是一个"空的,但随时准备好记录第一笔账"的状态。我们来推导如何从无到有地构建出这个状态。
推导第一步:存在本身
我们如何凭空创造一个 Ledger
对象?在C语言中,变量要么在栈上(函数结束就消失),要么在堆上(手动管理其生命周期)。我们的账本需要在多个函数之间传递,甚至贯穿整个程序生命周期,因此它必须存在于堆上。
必须使用 malloc
为 Ledger
结构体本身请求一块内存。
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;
}
推导第三步:数据的"所有权"
-
问题:现在我们确保有空间了。新账目的数据(
date
和description
字符串)从哪里来?它们由调用者传入。我们能直接ledger->transactions[count].date = date;
吗?❌绝对不能! -
原因:传入的指针
date
和description
可能指向一个临时缓冲区或者一个马上要被修改的变量。我们的账本必须拥有这些数据的独立副本,其生命周期由账本自己管理。 -
结论:对于每一个字符串,我们必须:
-
在堆上为它分配一块大小正好的新内存 (
malloc
)。 -
将传入的字符串内容拷贝到这块新内存中 (
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
)
从一个由指针构成的动态数组中"删除"一个元素,究竟意味着什么?
这不仅仅是"让它消失"。这个动作在物理和逻辑层面包含两个不可分割的后果:
-
资源回收:被删除的元素自身可能占有其它资源(在我们的案例中,是它所指向的字符串内存)。这些资源必须被释放,否则将永远丢失在内存中,成为"内存泄漏"。
-
结构完整性 :我们的
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]
这个结构体。它"拥有"什么?根据我们之前的设计,它拥有由date
和description
指针指向的两块堆内存。 -
结论:在我们覆盖或移动这个结构体之前,必须 先通过它持有的指针,
free
掉它所拥有的那两块字符串内存。这是整个删除操作中最关键、最容易出错的一步。
cpp
// ... (索引验证) ...
// 在忘记这个结构体之前,释放它内部管理的内存
free(ledger->transactions[index].date);
free(ledger->transactions[index].description);
// ... 后续步骤 ...
推导第三步:维护"结构完整性"
-
问题:释放了内部字符串后,
ledger->transactions[index]
里的指针成了野指针,这个位置的数据已无意义,形成了一个逻辑上的"空洞"。如何填补这个空洞? -
结论:我们需要将该位置之后的所有元素 ,整体向前移动一个位置。
index + 1
的元素移动到index
,index + 2
的移动到index + 1
,以此类推。
如何高效、安全地"移动"一块内存❓
-
自己写循环:可以,但繁琐且易错。
-
memcpy
vsmemmove
:两者都用于内存拷贝。但当源内存区域和目标内存区域发生重叠时(我们的情况就是如此),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_ledger
和 add_transaction
,我们申请了三种内存:
-
最顶层:
Ledger
结构体本身。 -
中间层:
transactions
数组。 -
最底层:每一笔
Transaction
内部的date
和description
字符串。 我们应该按什么顺序free
它们?
结论:必须遵循与创建时完全相反的顺序,即"由内而外"、"由下至上"。如果我们先
free
了transactions
数组,我们就将永远失去访问内部那些字符串的指针,从而导致大规模内存泄漏。
✅正确的拆解顺序:
-
遍历每一笔有效的账目,释放其内部的字符串。
-
释放
transactions
数组本身。 -
最后,释放
Ledger
结构体。
推导第二步:执行"最底层"的拆解
-
问题:如何访问并释放每一笔账目内部的字符串?
-
结论:需要一个循环,遍历从
0
到count - 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
的内容写入文件?Ledger
是Transaction
的集合。最自然的逻辑是逐个处理Transaction
。 -
结论:我们需要一个循环,遍历
ledger
中所有有效的记录(从0
到count - 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
字符串、检查容量、realloc
、strcpy
等等逻辑?完全不需要! 我们已经设计了完美、健壮的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
模块能做什么,但隐藏了具体是怎么做的。 -
内容:定义
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
)来防止在编译时被重复包含。
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);
}
现在,你拥有了一个从第一性原理推导、设计并完整实现的、模块化的命令行记账本程序。