暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

图说C语言 | 用C语言实现一个简单的通讯录(一)

630

大家好,我是《大话C语言》这本书的作者蔡苏北,许久没有和大家见面了,甚是想念!哈哈!在《大话C语言》这本书中主要介绍的都是些知识点,列举的也都是些程序片段,没有一个相对完整的C语言程序,可能会让大家感觉不过瘾。

于是乎,今天就带着大家一起学习用C语言编写一个简单的通讯录系统,这个通讯录系统能实现简单的增、删、改、查和文件的导入导出功能。通过程序的编写,能够巩固对C语言中的变量、数组、结构、枚举、指针、函数等结识点的实际运用,通过采用一个简单的单向链表来对数据进行存储和管理,从而加深大家对C语言的灵魂-指针的认识,努力让大家对C语言有进一步的理解。


在编写代码之前,有必要说明一下我所使用的开发环境:操作系统为Windows 10专业版(64位),编辑器为Code::Blocks16.01,编译器为GCC(版本为6.3.0,采用C99标准)。如果你们的开发环境与此不同,可能在编译过程会有所差别或者错误产生。如使用微软公司的IDE(集成开发环境)时,可能会认为C标准库中的某些函数具有安全性的问题,在编译过程会产生警告甚至错误的提示,解决的办法是使用微软建议的安全性更高的替代函数,另一种可行方案是使用宏或#pragma指令来屏蔽安全性检查,以使代码顺利通过编译。

好了,废话不多说,下面就开始代码实战啦!整个程序我们可以简单分为底层链表实现和上层用户交互两块。为了简单化,链表采用的是单向性的,即单向链表,是程序的数据处理核心,而用户交互的主要目的是为程序使用者创造良好的使用体验。


01

实现单向链表


单向链表就像小火车一样,火车头后面拖着一节一节的小车厢,大家可以视这一节节的车厢里装载着的都是我们的程序数据,只不过这辆小火车不是驰骋在广袤的大地上,而是在我们计算机的内存中。小火车的每个车厢都装着一些数据,我们若想查找某些数据,就得从火车头位置开始,一节一节车厢的去寻找。所以,把这辆小火车看成单向链表的话,那一节一节的车厢就是单向链表中的一个一个的节点。同理,我们把整个通讯录用单向链表管理起来,那么通讯录中的具体每个人的部分,我们就把它称之为各个节点。

(一)定义通讯录节点。

typedef struct _addr_book
{

    //数据域部分
    char _name[20]; //姓名
    unsigned _age; //年龄
    char _address[128]; //地址
    char _phone_num[12]; //电话号码
    //指针域部分
    struct _addr_book* next;  //结构体类型的指针,用来指向下一节点
}ADDRBOOK, *PADDRBOOK;


节点其实就是一个C语言中的结构体,所以定义节点就是定义一个结构体类型。现在我们将这个结构体类型的名字定义为struct _addr_book,同时为了简便使用,我们使用关键字typedef为这个结构体类型起了个别名ADDRBOOK,而PADDRBOOK则是这个结构体指针类型的别名。由于节点就是结构体,所以我们也可以说,ADDRBOOK就是我们的通讯录节点类型,而PADDRBOOK就是通讯录节点指针类型。

每个通讯录节点里都有5个数据成员,我们将其分成数据域部分和指针域部分。数据域部分用于存储每个人的姓名、年龄、地址和电话号码,其中表示年龄的成员_age不可能是负数,所以将其用无符号整型unsigned来定义,其它四个成员皆为字符型的数组。指针域部分为一个名为next的成员,为这个结构体指针类型,用于指向下一个节点。指针域是链表的关键部分,负责各节点之间的纽带作用,就像火车上的车钩,能够把车厢与车厢相互连接起来,形成一个整体。如果没有它,各节点就是一个个单独的个体,而有了它,则能把这些个体节点连接起来,形成一个完整的链表。

(二)定义头指针。

PADDRBOOK head = NULL;


我们定义一个结构指针类型的全局变量head,并将其初始化值为NULL。在C语言中,NULL是一个宏,其对应的值为0,即head被初始化为一个空指针。类似火车头一样,我们将用这个指针来指向链表中的第一个数据节点,所以我们通常将它称之为“头指针”,由于初始阶段并没有任何一个数据节点,所以将其初始化为一个空指针。

(三)创建节点。

PADDRBOOK create_addrbook_node(const char* name, unsigned age, const char* addr, const char* phnum)
{
    PADDRBOOK p = (PADDRBOOK)malloc(sizeof(ADDRBOOK));
    if (p == NULL)
        return NULL;
    strcpy(p->_name, name);
    p->_age = age;
    strcpy(p->_address, addr);
    strcpy(p->_phone_num, phnum);
    p->next = NULL;
    return p;
}


该函数能够在堆内存中创建一个通讯录类型的节点,四个形参分别对应节点的数据域部分的姓名、年龄、地址和电话号码,返回值则为指向该节点的指针。

在函数体中,我们首先使用标准库函数malloc(stdlib.h头文件)在堆中申请一块内存,参数为申请内存的大小,我们用sizeof运算符来获取通讯录节点的大小作为其实参。返回值为指向分配内存的首字节地址的指针,我们用节点指针类型变量p来保存它。

接着,我们用if语句判断堆内存申请是否成功,如果失败的话直接让函数返回NULL。

若堆内存申请成功,我们就可以将形参值赋给内存节点的各数据成员了。这里需要注意的是,对于那些字符数组类型的成员不能采用直接赋值的形式,我们可以借助标准库函数strcpy(string.h头文件)来进行字符数组的拷贝。

因为在创建节点阶段,并不知是否有下一个节点,所以统一将next指针赋值为NULL是个好习惯。

最后,函数返回在堆内存中创建的节点的指针。

(四)向链表添加节点。

void add_addrbook(const char* name, unsigned age, const char* addr, const char* phnum)
{
    PADDRBOOK p = create_addrbook_node(name, age, addr, phnum);
    if (head == NULL) //链表为空
        head = p;
    else            //链表不为空
    {
        PADDRBOOK pPreNode = head;
        while (pPreNode->next) //下一节点不为空
            pPreNode = pPreNode->next; //让pPreNode指向下一节点
        pPreNode->next = p; //将新创建节点的地址赋给链表最后节点的next成员
    }
}


该函数可以向单向链表添加一个节点。四个形参的意义与创建节点函数的四个形参意义相同,函数无返回值。函数的具体功能为通过参数在堆内存中创建一个节点,并将节点放到链表的末端。

在函数体中,首先将四个形参传递给创建节点函数create_addrbook_node,通过调用create_addrbook_node函数在堆中创建一个节点。

接下来判断头指针是否为空:如果为空的话,说明当前链表为空,就让头指针指向这个新创建的节点;如果不为空的话,说明链表不为空,那么就需要从头指针指向的节点一个一个向后找,直到最后一个节点,并让这个节点的next指针指向新创建的节点。代码中,链表为空的情况下,直接将新创建节点的地址赋给head即可;链表不为空的情况下,我们定义一个临时指针pPreNode,将其初始值设为头指针,然后通过while循环不断测试其next成员的值是否为空,在循环体中通过pPreNode = pPreNode->next这条语句来给pPreNode重新赋值,能让pPreNode指向下一个节点,当循环退出时,pPreNode所指节点的next成员为空指针,表示pPreNode现已指向了链表中的最后那个节点,此时,我们将新创建节点的地址赋给最后节点的next成员就可以了。此刻,大家需谨记的是,新创建的节点俨然已经成为了链表中的最后那个节点了。

(五)按姓名查找节点。

PADDRBOOK find_by_name(const char* name)
{
    PADDRBOOK p = head;
    while (p)
    {
        if (!strcmp(p->_name, name)) //若比较结果相同,strcmp返回0
            return p; //比较结果相同,即查找到,返回该节点指针
        p = p->next; //让p指向下一个节点
    }
    return NULL; //没有查找过,返回NULL(返回p亦可)
}


该函数能够按照参数指定的姓名,在链表中查找节点,并返回该节点的指针,若没有找到则返回一个空指针。

函数体中定义了一个临时指针p,为头指针head的一个副本。然后,通过while循环遍历链表所有节点,不断使用标准库strcmp函数(string.h头文件)来测试节点中的姓名与参数是否相同,若相同则返回该节点的指针,若不相同则让指针p指向下一个节点。如此循环,直至链表结尾,也就是当p的值为当前链表最后一个节点的next成员的值时,亦即p的值为NULL,此时,循环结束。若整个循环过程没有查找到指定姓名的节点,则返回NULL(返回p亦可)。

(六)打印指定姓名的节点。

void print_by_name(const char* name)
{
    PADDRBOOK p;
    print_title(); //打印标题行
    if ((p = find_by_name(name)) != NULL) //检查返回指针是否为NULL
        printf(fmt_str, p->_name, p->_age, p->_address, p->_phone_num);
}


有了查找指定姓名节点的函数,现在实现打印指定姓名节点的功能就变得非常容易,我们通过调用find_by_name函数,就可以获得指向该节点的指针。通过标准库函数printf就可以非常方便地格式化打印出节点中的数据。唯一需要注意的是,find_by_name函数有可能返回一个空指针,所以在打印节点数据之前,先通过if检查一下指针显得非常有必要。

为了能够统一打印格式,让显示效果更好一些,我们在程序中使用了专门的打印函数,比如函数体中所使用到的print_title函数,它专门负责打印输出数据的标题行。而且,我们还使用了格式控制字符串,用来控制printf函数的格式化输出效果。

const char* fmt_title = "%-20s%-5s%-25s%12s\n"; //标题行格式化控制字符串
const char* fmt_str = "%-20s%-5u%-25s%12s\n"; //节点数据格式化控制字符串
void print_title()
{
    printf(fmt_title, "姓名", "年龄", "地址", "电话号码");
    printf("-------------------------------------------------------------------\n");
}


大家仔细比对一下就会发现,标题行与节点数据的格式化控制字符串中唯一区别就是关于年龄那一块的,毕竟标题中使用的是字符串,而数据中年龄对应的其实是一个无符号整型数值。稍微提一下的是,格式化控制字符串中的数字表示的是输出所占的字符宽度,而前面加上-号则表示输出按左对齐的方式进行。

(七)打印所有节点。

void print_all()
{
    PADDRBOOK p = head;
    print_title();
    while (p)
    {
        printf(fmt_str, p->_name, p->_age, p->_address, p->_phone_num);
        p = p->next; //让p指向下一个节点
    }
}


既然能打印出指定姓名的节点,那么想打印链表所有节点也非难事了,我们只需从头指针所指向节点开始,一个个地打印地所有节点数据即可。循环的过程和查找指定姓名的节点类似,在此就不再赘述了。

(八)删除指定姓名的节点。

添加节点比较简单,就像是给火车加车厢,在最后一节车厢后面再接上一个新车厢就是了。而删除节点恐怕是链表操作中最难的部分了,要删除的节点是链表中第一个节点或是最后一个节点还好,若删除的是链表中间部分的节点,那就得千万小心了,也许一个不小心,就会造成链表的断裂,造成部分数据丢失,引发内存泄露问题。

在单向链表中,想要删除一个中间节点更要注意。例如,我们有一个单向链表,它现在有3个节点A、B、C:

我们想要删除节点A很简单,让头指针指向节点B,然后删除节点A。不过需要注意的是,执行的顺序挺重要,因为节点B的地址保存在节点A的next成员中,如果先删除节点A就有可能造成节点B地址的丢失(除非事先保存节点B的地址),从而节点B和之后的节点C就再也找不到了;另一方面,在让头指针指向节点B之前,要保存好节点A的地址,毕竟节点A的地址原先保存在头指针那儿(现在头指针已经保存节点B的地址了),不然就无法正常释放节点A的内存空间,造成内存泄露。

要删除节点B的话,就得先找到在它之前的节点A,通过改变节点A的next成员,让其指向节点B的下一个节点,也就是节点C即可。同时要保存到节点B的地址,不然节点B就无法正常释放内存了。

同理,要删除节点C的话,就得将节点B的next成员设为NULL。同样需要注意的是要事先保存好节点C的地址,以防丢失。

好了,知道了这些,下面就把按指定姓名删除节点的函数实现出来吧!

void del_addrbook(PADDRBOOK pNode) //参数为欲删除节点的指针
{
    PADDRBOOK p = head; //保存第一个节点
    if (head == NULL || pNode == NULL) //判断链表或者欲删除节点是否为空
        return;
    else if (head == pNode) //如果删除的是第一个节点
    {
        head = head->next; //让头指针指向第二个节点
        free(p); //释放第一个节点内存空间
    }
    Else                //如果删除的不是第一个节点
    {
        while (p->next) //判断是否有下一节点
        {
            if (p->next == pNode) //下一节点是否为要删除的节点
            { //如果是的话
                p->next = pNode->next; //让指针p所指节点的下一节点为删除节点的下一节点
                free(pNode); //释放删除节点的内存空间
                break; //终止循环
            }
            p = p->next; //让指针p指向下一节点
        }
    }
}


函数体中,我们首先用临时指针p保存好第一个节点地址,然后使用if语句检测链表以及要删除的节点是否为空,如果都不为空,则执行else if再次判断要删除的是否为链表中第一个节点,若是的话就让头指针指向链表第二个节点,然后通过指针p释放第一个节点的内存空间;若删除的并非是第一个节点的话,则执行else部分,大家需要注意while循环的条件,我们是通过指针p的next成员是否等于要删除的节点来进行判断,也就是检查指针p所指节点的下一节点是否为要删除的节点,如果是的话,则p所指节点就为要删除节点的前一节点,我们通过修改前一节点的next成员,将其赋值为要删除节点的下一节点地址即可。最后,删除节点释放其内存空间并终止循环。若指针p所指节点的下一节点不是要删除的节点,则修改指针p的值,让其指向下一节点,继续下一轮的循环。

(九)删除所有节点。

void del_all()
{
    PADDRBOOK p;
    while(head)
    {
        p = head;
        head = head->next;
        free(p);
    }
}


若懂得了如何在链表中删除指定节点的话,则对解决删除链表所有节点这件事应该是手到擒来啦。我们从头指针所指向的节点开始,不断检查链表中的各个节点并删除释放其内存空间即可。唯一需要注意的还是要妥善保管好相关指针,不能轻易丢掉了欲删除节点的地址或者指向下一个节点的指针。

到这里,我们已经完成了链表操作中的增、删、查,唯一缺失的就是对链表节点的修改操作,因为这涉及到了与用户交互的操作。关于用户交互这一块,会和标准输入流、标准输出流打交道,更会将我们的链表灵活运用到实际的操作中,其中不乏许多有意思的地方以及相关的小技巧。由于篇幅有限,这些我就留待下期再完成。


02

参考书籍

《大话C语言》

ISBN:978-7-302-55131-7

蔡苏北 范志军 编著

定价:69元







扫码优惠购书



文章转载自清华计算机学堂,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论