数据结构 - 线性表第三篇:基于顺序表实现 C 语言通讯录(基础功能篇)

前言

接上篇,让我们正式进入实战项目 :基于完整版动态顺序表,手写实现简易通讯录,支持联系人增删改查、查找、修改、保存等功能,把顺序表知识真正落地应用。这期间,我踩了不少坑:编译报错连环炸、运行直接崩溃、输入汉字无限死循环、数据显示乱码...

这篇博客会100% 还原我的真实开发过程,包括每一步的错误想法、踩的坑、排查过程和最终解决方案,所有代码都是我当时一行行写的,保留了新手特有的代码冗余和逻辑漏洞。

一、先回顾:我之前写的通用顺序表代码

通讯录的底层完全复用了我之前写的动态顺序表,这也是后来代码冗余的根源 ------ 顺序表是通用设计,但通讯录只用到了其中几个函数。

1.1 顺序表头文件(SeqList.h)

cpp 复制代码
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include<stdlib.h>
#include<stdio.h>
#include<assert.h>
#include"Contact.h"

// 当时不知道怎么解耦,先留空,后面让通讯录定义
typedef int SLType;
// 错误的宏定义,当时以为所有数据都能用%d打印
#define SLTYPE_PRINT_FMT "%d"

typedef struct SeqList
{
	SLType* a;       // 动态数组
	int size;        // 有效数据个数
	int capacity;    // 总容量
}SL;

// 当时写了所有通用函数,其实通讯录只用到了5个
void SLInit(SL* ps);
void Destroy(SL* ps);
void Print(SL* ps); // 完全没用
void SLExp(SL* ps);
void PushFront(SL* ps, SLType x); // 没用
void SLPopFront(SL* ps); // 没用
void PushBack(SL* ps, SLType x); // 有用
void SLPopBack(SL* ps); // 没用
int FindSpecify(SL* ps, SLType x); // 没用,结构体不能用==比较
void PushSpecify(SL* ps,int pos, SLType x); // 没用
void SLDeleteSpecify(SL* ps,int pos); // 有用
void Modifyspecified(SL* ps, int pos, SLType x); // 没用

1.2 顺序表实现(SeqList.c)

cpp 复制代码
#include"SeqList.h"

void SLInit(SL* ps)
{
	assert(ps);
	ps->a = NULL;
	ps->capacity = 0;
	ps->size = 0;
}

void Destroy(SL* ps)
{
	assert(ps);
	if (ps->a)
	{
		free(ps->a);
		ps->a = NULL;
		ps->capacity = 0;
		ps->size = 0;
	}
}

void SLExp(SL* ps)
{
	assert(ps);
	if (ps->capacity == ps->size)
	{
		int Newspace = ps->capacity == 0 ? 4 : 2 * ps->capacity;
		SLType* Newps = (SLType*)realloc(ps->a, Newspace * sizeof(SLType));
		if (Newps == NULL)
		{
			perror("扩容失败");
			exit(1);
		}
		ps->a = Newps;
		ps->capacity = Newspace;
	}
}

void PushBack(SL* ps, SLType x)
{
	assert(ps);
	SLExp(ps);
	ps->a[ps->size++] = x;
}

void SLDeleteSpecify(SL* ps,int pos)
{
	assert(ps);
	assert(pos >= 0 && pos < ps->size);
	assert(ps->size > 0);
	for (int i = pos; i < ps->size - 1; i++)
	{
		ps->a[i] = ps->a[i + 1];
	}
	ps->size--;
}

// 后面还有一堆没用的函数,全部注释掉了,代码冗余严重

二、第一步:把顺序表改成通讯录的底层

当时的想法很简单:把顺序表的SLTypeint改成联系人结构体就行。结果第一步就踩了头文件循环包含的坑。

2.1 错误的尝试(循环包含)

我直接在SeqList.h里包含Contact.h,然后把SLType改成struct UserData

cpp 复制代码
// SeqList.h 错误写法
#include"Contact.h"
typedef struct UserData SLType;

然后Contact.h里又包含SeqList.h

cpp 复制代码
// Contact.h 错误写法
#include"SeqList.h"
typedef struct UserData
{
    char name[100];
    // ...其他字段
}UseDat;
typedef struct SeqList contact;

编译直接报错:"struct SeqList" 重定义

2.2 解决方案(前向声明)

经过检查,我发现头文件互相包含了,应该用前向声明

cpp 复制代码
// SeqList.h 正确写法
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include<stdlib.h>
#include<stdio.h>
#include<assert.h>

// 前向声明UserData,不用包含Contact.h
typedef struct UserData SLType;

typedef struct SeqList
{
	SLType* a;
	int size;
	int capacity;
}SL;

// 只保留通讯录用到的函数声明
void SLInit(SL* ps);
void Destroy(SL* ps);
void SLExp(SL* ps);
void PushBack(SL* ps, SLType x);
void SLDeleteSpecify(SL* ps,int pos);

然后Contact.h正常包含SeqList.h

cpp 复制代码
// Contact.h 正确写法
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include"SeqList.h"

#define NAME_Max 100
#define Gender_Max 4
#define AGE_Max 4
#define TEL_Max 15
#define ADDRESS_Max 100

typedef struct UserData
{
	char name[NAME_Max];
	char Gender[Gender_Max];
	char age[AGE_Max];  // 当时图省事,直接用char数组存年龄,后面踩了大坑
	char Tel[TEL_Max];  // 电话也用char数组,因为有11位,int存不下
	char Address[ADDRESS_Max];
}UseDat;

typedef struct SeqList contact;

// 通讯录函数声明
void InitAdd(contact* Con);
void AddCon(contact* Con);
void DelCon(contact* Con);
void ShoCon(contact* Con);
void FinCon(contact* Con);
void ModDat(contact* Con);
void DesDat(contact* Con);

终于解决了循环包含的问题,编译通过了第一步。

三、第二步:实现通讯录基础功能

我当时的思路是:先把增删改查的架子搭起来,能跑通就行,不管代码好不好看。

3.1 初始化和文件加载

当时想做数据持久化,程序启动时自动加载之前的联系人:

cpp 复制代码
void LoadCon(contact* Con)
{
	FILE* pf = fopen("contact.txt", "rb");
	if (pf == NULL)
	{
		// 当时写的是exit(1),第一次运行直接崩溃
		perror("打开文件失败");
		exit(1);
	}
	UseDat data;
	while (fread(&data, sizeof(UseDat), 1, pf) == 1)
	{
		PushBack(Con, data);
	}
	printf("历史数据加载完成!\n");
	fclose(pf);
}

void InitAdd(contact* Con)
{
	SLInit(Con);
	LoadCon(Con);
}

3.2 添加联系人

cpp 复制代码
void AddCon(contact* Con)
{
	UseDat data;
	printf("请输入姓名:");
	scanf("%s", data.name);
	printf("请输入性别:");
	scanf("%s", data.Gender);
	printf("请输入年龄:");
	// 当时犯了低级错误:age是char数组,用了%d格式符
	scanf("%d", data.age);
	printf("请输入电话:");
	// 电话也是char数组,同样用了%d
	scanf("%d", data.Tel);
	printf("请输入住址:");
	scanf("%s", data.Address);

	PushBack(Con, data);
	printf("添加成功!\n");
}

3.3 展示通讯录

cpp 复制代码
void ShoCon(contact* Con)
{
	printf("%-10s %-10s %-10s %-15s %-20s\n", "姓名", "性别", "年龄", "电话", "住址");
	for (int i = 0; i < Con->size; i++)
	{ 
		// 同样的错误:age和Tel用了%d打印
		printf("%-10s %-10s %-10d %-15d %-20s\n", 
			Con->a[i].name,
			Con->a[i].Gender,
			Con->a[i].age,
			Con->a[i].Tel,
			Con->a[i].Address);
	}
	printf("展示完成!\n");
}

3.4 删除和修改联系人

cpp 复制代码
// 按姓名查找
int FinNa(contact* Con, char name[])
{
	for (int i = 0; i < Con->size; i++)
	{
		if (strcmp(Con->a[i].name, name) == 0)
		{
			return i;
		}
	}
	return -1;
}

void DelCon(contact* Con)
{
	char name[NAME_Max];
	printf("请输入要删除的联系人姓名:");
	scanf("%s", name);
	int index = FinNa(Con, name);
	if (index < 0)
	{
		printf("联系人不存在!\n");
		return;
	}
	SLDeleteSpecify(Con, index);
	printf("删除成功!\n");
}

void ModDat(contact* Con)
{
	char name[NAME_Max];
	printf("请输入要修改的联系人姓名:");
	scanf("%s", name);
	int index = FinNa(Con, name);
	if (index < 0)
	{
		printf("联系人不存在!\n");
		return;
	}
	// 当时必须全部重输,不能跳过,体验极差
	printf("请输入新的姓名:");
	scanf("%s", Con->a[index].name);
	printf("请输入新的性别:");
	scanf("%s", Con->a[index].Gender);
	printf("请输入新的年龄:");
	scanf("%d", Con->a[index].age);
	printf("请输入新的电话:");
	scanf("%d", Con->a[index].Tel);
	printf("请输入新的住址:");
	scanf("%s", Con->a[index].Address);
	printf("修改成功!\n");
}

3.5 退出时保存数据

cpp 复制代码
void SaveCon(contact* Con)
{
	FILE* pf = fopen("contact.txt", "wb");
	if (pf == NULL)
	{
		perror("保存失败");
		return;
	}
	for (int i = 0; i < Con->size; i++)
	{
		fwrite(&Con->a[i], sizeof(UseDat), 1, pf);
	}
	printf("数据保存完成!\n");
	fclose(pf);
}

void DesDat(contact* Con)
{
	SaveCon(Con);
	Destroy(Con);
}

四、主菜单和第一次运行:连环报错

我写完主菜单,信心满满地点击运行,结果迎来了连环暴击。

4.1 错误 1:无法解析的外部符号 main

报错信息无法解析的外部符号 main,函数 "int __cdecl invoke_main(void)" 中引用了该符号原因 :光顾着写通讯录函数,忘记写main函数了!C 程序必须有main作为入口。解决:加上主菜单和 main 函数:

cpp 复制代码
// test.c
#define _CRT_SECURE_NO_WARNINGS
#include "SeqList.h"
#include"Contact.h"

void menu()
{
	contact Con;
	InitAdd(&Con);
	int input = 0;
	do{
		printf("欢迎使用通讯录系统!\n");
		printf("1.添加 2.删除 3.展示 4.查找 5.修改 6.退出\n");
		printf("请输入操作:");
		scanf("%d", &input);
		switch (input)
		{
		case 1: AddCon(&Con); break;
		case 2: DelCon(&Con); break;
		case 3: ShoCon(&Con); break;
		case 4: FinCon(&Con); break;
		case 5: ModDat(&Con); break;
		case 6: printf("退出系统!\n"); break;
		default: printf("输入错误!\n"); break;
		}
	} while (input != 6);
	DesDat(&Con);
}

int main()
{
	menu();
	return 0;
}

4.2 错误 2:运行直接崩溃

现象 :点击运行,直接弹出 "程序已停止工作",控制台显示打开文件失败: No such file or directory原因 :第一次运行时contact.txt不存在,LoadCon函数里直接调用exit(1)终止了程序。解决 :修改LoadCon,文件不存在时直接返回,不崩溃:

cpp 复制代码
void LoadCon(contact* Con)
{
	FILE* pf = fopen("contact.txt", "rb");
	if (pf == NULL)
	{
		printf("没有找到历史数据,创建新通讯录!\n");
		return; // 不exit,继续运行
	}
	// 后面不变
}

4.3 错误 3:输入汉字直接无限循环

现象 :主菜单输入汉字,控制台无限刷屏 "输入错误!"原因scanf("%d", &input)读取汉字失败,汉字留在输入缓冲区,下一次循环又读到同样的脏数据。临时解决:当时还不会写缓冲区清理函数,只能重启程序,后面优化篇会详细解决。

4.4 错误 4:年龄和电话显示乱码

现象 :添加联系人后,展示时年龄和电话显示一堆乱码。原因ageTelchar数组,但我用了%d格式符输入输出,类型不匹配导致数据错乱。解决 :把所有%d改成%s

cpp 复制代码
// 添加联系人
printf("请输入年龄:");
scanf("%s", data.age); // %d→%s
printf("请输入电话:");
scanf("%s", data.Tel); // %d→%s

// 展示通讯录
printf("%-10s %-10s %-10s %-15s %-20s\n", 
	Con->a[i].name,
	Con->a[i].Gender,
	Con->a[i].age, // %d→%s
	Con->a[i].Tel, // %d→%s
	Con->a[i].Address);

五、基础版终于跑通了!但问题一大堆

经过一下午的调试,基础版终于能跑了:1.可以添加联系人;2. 可以展示联系人;3. 可以删除和修改;4.退出时自动保存数据。

但是体验差到极致:1. 输入汉字直接死循环;2.修改时必须全部重输,不能跳过;3. 只能按姓名查找,不能按电话 / 地址;4. 性别必须手动输入 "男""女",容易输错;5.没有任何输入校验,输入超长会崩溃;6.代码冗余严重,顺序表里一堆没用的函数。

六、通讯录系统运行演示

6.1 程序启动

运行程序后,首先检测本地contact.txt文件,首次运行无历史数据,提示创建新通讯录,随后弹出功能菜单:

6.2 功能分步演示

1. 添加联系人(序号 1)

输入1,依次填写联系人信息:姓名、性别、年龄、电话、住址,回车后提示添加成功。示例:添加联系人【张三】

2. 删除联系人(序号 2)

输入2,输入要删除的姓名,即可精准删除对应联系人。示例:删除联系人【张三】

3. 展示所有联系人(序号 3)

输入3,系统格式化打印通讯录内全部联系人信息,无联系人时提示为空。示例:查看联系人【李四】

4. 修改联系人(序号 5)

输入5,输入待修改的原姓名,依次填写新信息,即可更新联系人全部内容。示例:将【李四】修改为【王五】

修改后再次执行展示功能,可看到信息已更新:

5. 退出系统并保存(序号 6)

输入6,程序自动将当前通讯录数据写入本地contact.txt文件,持久化保存,下次启动可自动加载历史数据。

6.3、核心功能总结

  1. 动态扩容:联系人数量无上限,顺序表自动扩容;
  2. 持久化存储:退出自动保存、启动自动加载;
  3. 精准操作:按姓名实现增删改查,操作简单直观;
  4. 数据隔离:5 个文件分层编写,结构清晰、易维护。

七、基础版学习总结

  1. 头文件循环包含是新手高频坑:一定要用前向声明解决,不要互相包含
  2. scanf 格式符必须和类型严格匹配:char 数组用 % s,int 用 % d,否则必乱码
  3. 文件操作要考虑不存在的情况:不要直接 exit,要优雅降级
  4. 输入缓冲区问题是 C 语言的噩梦:新手一定要提前了解
  5. 通用代码复用会带来冗余:顺序表的通用函数很多在通讯录里没用,但先实现功能再优化。

本篇Gitee链接为Luminous/Luminousbegin

下一篇博客,我会记录如何一步步把这个 "能用但难用" 的基础版,优化成一个真正好用的通讯录系统。

相关推荐
_日拱一卒1 小时前
LeetCode:114二叉树展开为链表
java·开发语言·算法
Szime1 小时前
深智微华润微代理端整理:FS32K144国产化替代三年BCM选型验证避坑笔记
笔记
无小道1 小时前
Redis——哈希类型相关指令
redis·算法·哈希算法
凌波粒1 小时前
LeetCode--513.找树左下角的值(二叉树)
java·算法·leetcode
一个不知名程序员www1 小时前
算法学习入门---算法题DAY1
c++·算法
几司1 小时前
OpenISP 模块拆解 · 第1讲:坏点校正 (DPC)
笔记·学习·isp
问心无愧05131 小时前
ctf show web 入门155
笔记
子琦啊1 小时前
构造函数、this指向和原型链机制
javascript·算法·贴图
WHS-_-20221 小时前
Millimeter Wave ISAC-SLAM: Framework and RFSoC Prototype
人工智能·算法·原型模式