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

大话C语言 | 用C语言实现一个简单的通讯录(二)

482

大家好,我们在 上一期 中完成了对单向链表节点的增、删、查操作。接下来,我们就再接再厉,完成剩下的部分。

这一部分,主要是涉及与用户交互的操作,包括对标准输入输出流、文件流的使用,为了的程序的易读性,我们使用到了枚举这种类型。

在完成了这些之后,我们的程序就可以完美地运行起来,实现简单的通讯录的管理功能了。


01

用户交互


没有用户交互的程序,只能实现相对单一的功能。就好比请一位厨师给我们菜,如果我们什么都不说,厨师就不知该做些什么菜或只能按照他的习惯做出几道符合大众口味的菜品。但对某些有独特要求的客人,就只能让他们自己点菜,甚至某些喜欢吃甜的客人,他们可能希望在菜里加些糖,而某些喜欢吃辣的客人,他们希望做菜时辣椒能多放一些…,所以在厨师做菜时,我们若能告诉厨师,我们希望他做出哪些菜肴,甚至在口味上,希望哪些菜肴中能稍加些糖或多放几个辣椒,这样就能让厨师做出客人更加满意的佳肴。若将我们的程序看作厨师做菜,那用户交互就好比我们向厨师点菜并提出特殊口味要求的过程。通过程序与用户交互,就能使程序实现出更有特色、更加丰富的功能。

(一)定义枚举。

enum { ADD, DEL, MOD, FIND, SAVE, LOAD, SHOW, EXIT };


我们用enum关键字定义一个枚举类型,由于并不需要定义此枚举类型的变量,因此我们定义的这个,是一个没有名字的枚举类型。花括号中以逗号分隔的各项为枚举值,共有8个,它们的值分别为0,1,2,3,…,7,分别对应后面要显示的8个菜单项,其中:ADD代表添加通讯录,DEL表示删除通讯录,MOD表示修改通讯录,FIND表示查找通讯录,SAVE表示保存至文件,LOAD表示从文件加载,SHOW表示显示所有通讯录,EXIT表示退出系统。

(二)显示菜单。

unsigned show_menu()
{
    unsigned choose;
    system("CLS"); //清屏
    printf("///////////////////////通讯录系统.V1.0/////////////////////////////\n\n");
    printf("0.添加通讯录\t1.删除通讯录\t2.修改通讯录\t3.查找通讯录\n");
    printf("4.保存到文件\t5.从文件加载\t6.显示通讯录\t7.退出系统\n\n");
    printf("//////////////////////////////////////////////////////////////////\n\n");
    printf("请输入菜单编号(0-%u):\n", EXIT);
    while (!scanf("%u", &choose) || EXIT < choose)
    {
        clear_istream();
        printf("输入有误,请重新输入:");
    }
    return choose;
}


该函数能显示菜单项并返回用户所选择的菜单编号,让程序能够按照用户的要求完成某项功能。函数的实现非常简单,函数体里通过多条标准库函数printf,在窗口上打印一些格式化文本信息,提示用户输入所选菜单的编号。并通过标准库函数scanf(在头文件stdio.h中)获取用户的输入保存到无符号整型变量choose 中。需要注意的是,为了界面美观,我们在打印信息之前,通过以字符串字面值“CLS”作为实参调用函数system(在头文件stdlib.h中),以实现清除窗口内容的效果。

为了能够获得正确的用户输入,我们用一个while循环来不断进行测试。正如我们这个函数期望得到的是一个0到7的整数值,如果用户输入了字母、符号、负数,或是输入了一个大于7的整数,我们都认为是用户输入有误。在while循环的判断条件中有两项,首先是检测scanf函数的返回值,在我们这里输入项只有一个,因此如果正确获取了用户的输入并保存到变量choose中,则函数返回值应该为1,如果没有获取到则返回值为0。如果正确获取,我们会再次检测获取的整数是否不大于7。大家需要清楚的是,这里所使用的EXIT是一个枚举值,它对应的值就是整数7。

如果用户输入不正确,则会执行循环体部分。循环体中,我们通过printf函数在窗口打印一条错误消息,并提示用户重新输入。则在打印这条消息之前,我们还使用了一个clear_istream函数,这是一个我们自己编写的函数,功能是清除标准输入流。如果我们不清除输入流,则下次循环scanf函数仍会去读取流中的这些不正确数据,从而形成无限循环。

(三)清除标准输入流。

void clear_istream()
{
    while (getchar() != '\n');
}


该函数能够将标准输入流中残余的数据清除,实现的方式其实异常简单,就是利用循环不断通过标准库函数getchar从标准输入流中读取字符,直到读取的字符为换行字符为止。当用户在输入时会按下回车键,这个回车键对应的就是换行字符“\n”,我们不断通过调用getchar函数从标准输入流中读取字符,我们只判断有无读取到换行字符,而这些字符对我们程序来说没有什么作用,因此这些字符都被直接抛弃了。另外需要大家注意的是,这个while循环没有循环体,或者说循环体就是一个空语句(单独的分号)而已。

后面的函数基本都需要从标准输入流读取数据,以获取用户的输入。因此,为了便于操作,我们就再定义几个和标准输入流相关的功能函数吧!

(四)清除标准输入流前导空白字符。

void clear_space()
{
    int c;
    while (isspace(c = getchar()));
    ungetc(c, stdin);
}


用户在输入的时候,或许不小心,或许是故意(测试程序的健壮性),会输入一些无用的空白字符,例如空格、换行、制表符等等。我们在从标准输入流读取数据时,应该忽略这些空白字符,该函数就是用于这方面功能的。

函数体中,我们首先定义一个变量c,用于保存从标准输入流读取的字符,然后使用while循环不断通过调用getchar函数从标准输入流获取字符保存到变量c中,并通过调用标准库函数isspace(在头文件ctype.h中)来检测该字符是否为空白字符。若为空白字符isspace函数返回值为真,则进行下轮循环;若不是空白字符则函数返回值为假,循环终止。此while循环仍然是一个空循环体。

在循环之后,我们用到了一个比较神奇的标准库函数ungetc(在头文件stdio.h中),它的功能是将一个指定字符回流到指定的输入流中。第一个参数为欲回流的字符,第二个参数表示回流到哪个输入流中。在我们这里,因为while循环终止时,变量c中保存的并非一个空白字符,因此要将变量c中保存的字符回流到标准输入流“stdin”中去,以让后续的函数能正确读取到该字符(否则的话,该字符就被抛弃了)。

(五)获取用户输入的字符串。

size_t get_input_string(char* buf, size_t buf_sz, const char* prompt)
{
    printf("%s", prompt);
    size_t sz = 0;
    int c;
    clear_space(); //忽略前导空白字符
    while (sz < buf_sz - 1)
    {
        if ((c = getchar()) == '\n')
        {
            ungetc(c, stdin);
            break;
        }
        else
            buf[sz++] = c;

    }
    buf[sz] = '\0';
    clear_istream();
    return sz;
}


该函数能够在标准输入流获取用户所输入的字符串,参数有3个,第一个参数为保存用户输入字符串的字符数组首地址,第二个参数为字符数组的大小,第三个参数为一个表示提示字符串的字符指针。函数返回值为用户所输入字符串的长度,size_t其实也就是一个无符号整数类型。

函数体中,首先通过printf函数打印提示字符串,然后调用clear_space函数忽略标准输入流中的前导空白字符,接着,利用while循环来不断获取用户输入的字符,由于C语言中的字符串末尾都有一个隐含的“\0”字符,因此,循环终止的条件是输入字符串的长度应小于字符数组的大小减1。循环体中,我们通过gechar函数获取字符并保存到变量c中,并判断字符是否为换行字符,若不是换行字符则将其存储到字符数组中,或是换行字符的话,则将这个换行字符通过ucgetc函数回流并终止循环。最后,别忘了给字符数组添加“\0”字符。另外,为了后续函数读取的正常,我们通过调用clear_istream函数,清空了标准输入流。

(六)获取用户输入的无符号整数。

void get_input_digit(unsigned* num, const char* prompt)
{
    printf("%s", prompt);
    unsigned c;
    *num = 0;
    clear_space();
    while (c = getchar())
    {
        if (isdigit(c))
        {
            *num *= 10;
            *num += c - '0';
        }
        else
        {
            ungetc(c, stdin);
            break;
        }
    }
    clear_istream();
}


该函数能够在标准输入流中获取用户所输入的无符号整型数据。第一个参数为无符号整型的指针,用于保存用户所输入的数值,第二个参数为一个提示字符串的指针。

和get_input_string函数类似,在这个函数体中,也是先打印提示字符串,然后调用clear_space函数忽略前导空白字符,接着使用while循环,不断获取输入流中的字符,通过标准库函数isdigit(在头文件ctype.h中)检测该字符是否为数字字符,如果是的话就将其保存起来,如果不是数字字符就通过ungetc回流到输入流并终止循环。

保存整数与保存字符串的方式不一样,假如用户输入的是“123”,在读取的时候首先读取的是‘1’这个字符,我们让其与字符‘0’相减,得到的就是与其对应的整数数值1;在读取字符‘2’的时候,我们首先将之前的1乘上10,然后再将其与字符‘2’与字符‘0’之差,即整数值2相加得到整数值12;在读取字符‘3’时,我们再将之前的12与10相乘,再与字符‘3’与字符‘0’之差相加,得到整数值123。在保存数值的时候,我们没有定义单独的变量,而是直接使用参数num这个指针,并且在获取字符之前,先将其值赋为0了,这一步很关键,千万别忘记。

同样的,为了后续函数读取的正常,在函数体的最后,我们通过调用clear_istream函数,清空了标准输入流。

有了这些和标准输入流相关的功能函数,下面我们就可以实现修改节点的操作了。

(七)修改指定姓名的节点。

void modify_by_name(const char* name)
{
    PADDRBOOK p = find_by_name(name);
    if (p == NULL)
        printf("没有发现姓名为 '%s'的通讯录\n", name);
    else
    {
        char buf[BUFSIZE];
        unsigned age;
        get_input_string(buf, 20, "请输入新的姓名:");
        strcpy(p->_name, buf);
        get_input_digit(&age, "请输入年龄:");
        p->_age = age;
        get_input_string(buf, 128, "请输入地址:");
        strcpy(p->_address, buf);
        get_input_string(buf, 12, "请输入电话号码:");
        strcpy(p->_phone_num, buf);
    }
}


该函数实现修改指定姓名节点的功能,参数为欲修改节点的姓名。

函数体中,我们首先通过调用find_by_name函数在链表中查找指定姓名的节点,然后通过if语句判断是否查找到相关节点,若没有找到则打印相关信息,若找到了则执行else部分。

在else部分,我们定义了一个字符数组,数组大小为BUFSIZE,这是一个我们自己定义的宏,在程序中使用宏,是为了便于今后代码的维护性。在本例中,我们是这样定义的:

#define BUFSIZE 128


使用#define指令定义一个宏,宏名为BUFSIZE,宏值为128,要谨记的是,定义宏时不需要在最后加上分号,不然很容易就会引出莫名的错误。

我们通过之前编写的get_input_string函数和get_input_digit函数来获取用户输入的字符串和数值,然后更新到节点所对应的各个成员中。由于字符数组是不能直接赋值的,所以我们使用了标准库函数strcpy(在头文件string.h中)来进行字符数组的字符串拷贝。另外,值得大家关注的是,由于节点成员的字符数组是有大小的,因此,在调用get_input_string函数获取用户输入字符串的时候,我们要根据成员字符数组大小来设置该函数的第二个参数大小,避免用户输入字符串过长,而导致字符数组存储不下而引起程序异常。

(八)将链表保存至文件。

void save_to_file()
{
    FILE* pfile = fopen(file_name, "wb");
    if (pfile == NULL)
    {
        printf("打开文件失败\n");
        return;
    }
    PADDRBOOK p = head;
    while (p)
    {
        fwrite(p, sizeof(ADDRBOOK), 1, pfile);
        p = p->next;
    }
    fclose(pfile);
}


该函数能将我们的链表节点保存至文件中。

在函数体中,我们首先通过标准库函数fopen(在头文件stdio.h)打开一个文件,此文件名为字符指针file_name所指向的字符串,打开方式为“wb”,这里的w表示写(Write),而b表示二进制(Binary),即以二进制和写的方式打开文件。函数返回值为FILE类型的指针,若打开文件失败则返回NULL。我们通过if语句判断,若打开文件失败则给出一个提示信息,然后通过return语句终止函数的执行。由于我们的函数是无返回值类型的,因此,return语句后面直接跟一个分号即可。

若打开文件成功,则使用临时指针变量p通过while循环来遍历链表,在访问每个节点时,我们使用标准库函数fwrite(在头文件stdio.h)将整个节点以二进制方式写入文件。

将所有节点写入文件之后,我们使用标准库函数fclose(在头文件stdio.h)来关闭之前打开的文件,以确保写入操作的真正完成。

(九)从文件中加载数据。

void load_from_file()
{
    ADDRBOOK ab;
    FILE* pfile = fopen(file_name, "rb");
    if (pfile == NULL)
    {
        printf("打开文件失败\n");
        return;
    }
    del_all(); //删除当前所有节点
    while (fread(&ab, sizeof(ADDRBOOK), 1, pfile))
        add_addrbook(ab._name, ab._age, ab._address, ab._phone_num);
    fclose(pfile);
}


该函数的功能为从文件中读取各节点并添加到链表中,我们可以将其视为save_to_file函数的逆向操作。

函数体中,我们同样通过fopen函数打开文件,只不过这次我们打开文件的方式是“rb”,其中r表示读取(Read)的意思。

如果打开文件失败就打印一条提示信息,并终止函数执行。

如果打开文件成功则,首先调用del_all函数清空当前链表。这是因为我们未必是在程序刚启动的时候加载文件数据的,或许我们是在链表中添加若干节点后,才选择从文件加载数据的。

我们在函数体中还定义了一个临时的通讯录节点类型的变量ab,在while循环中,我们使用标准库函数fread从文件读取一个节点数据并保存到变量ab中,然后通过add_addrbook函数在链表中添加一个节点。在调用add_addrbook函数的时候,我们是用变量ab中的各成员作为实参的。

到这里,我们已经完成了链表和用户交互的所有操作。最后,就是我们的主函数啦!

(十)主函数。

int main()
{
    char name[20]; //姓名
    unsigned age = 0; //年龄
    char address[128]; //地址
    char phone_num[12]; //电话号码
    int running = 1;
    while(running)
    {
        switch (show_menu())
        {
        case ADD:
            get_input_string(name, 20, "请输入姓名:");
            get_input_digit(&age, "请输入年龄:");
            get_input_string(address, 128, "请输入地址:");
            get_input_string(phone_num, 12, "请输入电话号码:");
            add_addrbook(name, age, address, phone_num);
            break;
        case DEL:
            get_input_string(name, 20, "想要删除通讯录中的姓名为:");
            del_addrbook(find_by_name(name));
            break;
        case MOD:
            get_input_string(name, 20, "想要修改的通讯录中的姓名为:");
            modify_by_name(name);
            break;
        case FIND:
            get_input_string(name, 20, "想要查找的通讯录中的姓名为:");
            print_by_name(name);
            break;
        case SAVE:
            save_to_file();
            break;
        case LOAD:
            load_from_file();
            break;
        case SHOW:
            print_all();
            break;
        case EXIT:
            running = 0;
            break;
        }
        system("PAUSE");
    }
    del_all();
    return 0;
}


主函数中的代码虽然比较多一些,但逻辑却不复杂。在函数体中,我们首先定义了3个字符数组和一个无符号整型变量,相信大家都能想到,它们是用于存储通讯录节点成员的。此外,我们还定义一个整型变量running并初始化其值为1,它是用于控制下面的while循环的。当它的值为非零时,循环就一直执行,当它的值为零时,循环终止。

循环体中使用了一个switch…case语句,switch后面的圆括号中是一个show_menu函数的调用,而各case后面都是跟着一个枚举值。这是用于检测用户所选的菜单编号,并根据不同的菜单编号去执行与之匹配的case部分。每个case部分基本都使用了我们之前所实现的函数去完成相应的功能,没有什么特别的地方,所以这里就不再赘述了。

由于程序执行的速度非常快,为了让大家能看清打印输出内容,我们在每次循环时都调用一次system("PAUSE"),这个函数调用能让我们的程序暂时处于暂停状态,在按下任何键后才能继续执行。

最后,主函数退出前,我们调用del_all删除链表,释放链表各节点所占的内存空间,不失为一种好习惯。

好了,到这儿,我们的通讯录系统已大功告成!剩下的就是代码编译与执行调试了,相信大家都能顺利地完成,如果确实遇到了困难,绞尽脑汁也无法解决,也可以在留言中向老师索要一下我所编写的源代码(强烈建议自己完成,也可以和朋友们交流)。如果你能轻松地完成上述任务,那么就可以思考一下,程序中的代码能否进行改进?我们为什么要自己编写get_input_string函数,如果使用标准库中的scanf、gets、fgets等函数来替代会有何不同?是否可以加入对于重复姓名情况下链表节点的处理?是否可以用双向链表来实现?……

最最最后,我衷心地祝愿大家,理解C语言,热爱C语言!



02

参考书籍

《大话C语言》

ISBN:978-7-302-55131-7

蔡苏北 范志军 编著

定价:69元







扫码优惠购书



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

评论