• 蛙蛙推荐:C语言入门之二——编写第一个有意义的小程序


    简介

      上次配置好了linux+vim+gcc以及写了一个HelloWorld级别的示例程序,这次写一个稍微有意义的程序,在写这个小程序的过程中,我们快速的对C语言有一个大致的了解,SICP里指出,要学一门语言,要注意3个方面,一是这个语言提供了哪些Primitive,如数据类型,表达式,语句;二是提供了哪些组合规则,三是提供了哪些抽象机制,我们学C的时候也有意识的留意一下。

    需求分析

      同事们中午一般都一起出去吃午饭,AA制,但每次吃饭都现场算钱的话,比较麻烦,不如一人付一次,轮换着付钱,最终付的钱还是均匀的。但有的时候今天吃的多,明天吃的少,而且有的人今天来了,明天没来,所以要有个记账的软件,要记录下哪天都有谁去吃饭了,花了多少钱,打了多少折扣,当天是谁付的款,然后程序能自动算出来,谁付款付的多,谁付款付的少,付款付的最少的今天就主动付款。(大家可以了解下www.5dfantuan.com)

      我定义了一个文件格式,每个字段用"|"分隔,从左到右每列一次是吃饭日期,总消费金额,折扣,吃饭的人,付款人和付款金额。其中吃饭的人用逗号分隔,付款记录也用逗号分隔,每个付款记录用冒号分隔开付款人和付款金额。

    2010-9-10|83|0.8|a,b,c,d|a:100,b:100
    2010-9-11|102|0.8|a,b,c,d,e|b:100,c:50

    比如以上的输入文件input.txt,9月10日花了83块钱,打了0.8折是66.4元,有4个人吃饭,分别是a,b,c,d,人均消费是66.4/4=16.6元,当天a和b各充了100元,那么今天a和b的余额就是100-16.6=83.4元,而c和d没付钱,余额就是-16.6元,下次就应该让他俩出钱。

    数据结构定义

      我们先进行数据结构的定义,在C里定义数据一般用struct来定义,c的struct不能定义函数(能定义函数指针),只能定义数据成员,而且不是原生支持的数据类型,使用类型的时候要加struct前缀。

      我们定义两个常量,MAX_RECORD_COUNT定义input.txt里最大的记录数(一行一个记录),因为C里要自己管理内存,分配数据等要考虑个最大值,不像c#里有ArrayList这样自动扩大的类,所以我们声明列表类型的数据一般用数组,数组要给定一个最大长度。MAX_ARRAY_COUNT,这个定义普通字符串的最大长度,如输入文件里各个字段的长度都不能超过这个长度。

    data_structure.h
    #define MAX_RECORD_COUNT 10
    #define MAX_ARRAY_COUNT 15

    struct people
    {
    char name[MAX_ARRAY_COUNT];
    };
    struct pay_record
    {
    struct people person;
    double amount;
    };
    struct account_record
    {
    char date[MAX_ARRAY_COUNT];
    double discount;
    struct people person[MAX_ARRAY_COUNT];
    int people_count;
    struct pay_record payrecord[MAX_ARRAY_COUNT];
    int pay_record_count;
    int total_consumption;
    };
    struct account_record_list
    {
    struct account_record records[MAX_RECORD_COUNT];
    int count;
    };
    struct person_consumption
    {
    char name[MAX_ARRAY_COUNT];
    double consumption;
    };
    struct person_consumption_list
    {
    struct person_consumption persons[MAX_ARRAY_COUNT];
    int count;

    };

      如上,我们用struct account_record来表示一天的记账记录,account_record_list表示多条这样的记录,我们的命名规则就是表示多条数据类的结构后缀名加_list,并有一个count的成员表示有效数据的长度。struct account_record里各个成员分别对应输入文件里的各个字段,比如struct people其实就是一个长度为15的字符数组,person_consumption表示每个人的余额。这里尽量不用typeof是因为那样有些乱。

      在这里我们用到了各种数据类型的定义,如单个值int,double,一维数组,结构定义等。

    接口设计

      定义好了数据,就该定义操作这些数据的函数了,我们先从上层来分析都需要哪些模块,模块之间的依赖关系,以及模块里有哪些操作。首先因为我们定义了一个输入文件,就应该有一个模块来读取这个文件,并构建成内存里的消费记录,付款记录等对象,该模块就叫readinput吧。另外内存里有了消费记录,付款记录这些对象,就需要处理它们,计算出每个人的余额,某天的人均消费等,我们把这个模块叫record_handler,最后我们要有个主模块调用这两个模块,组合成最终的业务逻辑,并显示给用户,这个模块就叫main吧。

    readinput.h

    struct account_record_list read_input();

      该模块对外只提供一个方法read_input,返回一个消费记录列表类,其内部实现的私有函数不需要写在头文件里,因为没人用它,这也算起到了封装的作用,因为具体该函数的实现类是readinput.c,该文件最终会编译成一个.o文件,别人要想用该模块的功能的话,只要有readinput.o和readinput.h就行了,一般会把.o放到lib目录下,.h放到include目录下。

    record_handler.h
    void edit_person_consumption(struct person_consumption_list *list,
    const char *name,double money);
    void print_person_consumption_list(const struct person_consumption_list list);
    double calc_avg_consumption(double total, int person_count, double discount);

      该模块定义了对消费记录的处理,edit_person_consumption用来修改消费记录,比如某人吃饭消费了多少钱,某人付了多少钱,都调用它来计算出各个人的余额。print_person_consumption_list用来打印出每个人的余额,谁是正的余额,谁是负的余额,calc_avg_consumption用来根据总金额,折扣数和吃饭的人数计算出人均消费数。

      我们在设计模块时要尽量让模块的职责清晰,做到高内聚,尽量少的使用别的模块的功能,并尽量让很多的模块使用自己,还要考虑清楚模块之间的调用关系。

      Main模块不需要.h头,它是一个驱动模块,用来调用其它两个模块,完成整体的功能,不对外提供接口,但要实现一个main的入口函数。

    主函数的实现

      每个可执行程序都要有一个main的方法,我们在main模块里定义,在使用前,先要用include来声明你都依赖哪些模块,只需要包含该模块的头文件就可以,尖括号括的是系统的头文件,会在/usr/include/下查找,引号括住的是自己的头文件,会在当前目录下查找。

    代码
    #include <stdio.h>
    #include
    "data_structure.h"
    #include
    "readinput.h"
    #include
    "record_handler.h"

    void print_account_record_list(const struct account_record_list list);
    struct person_consumption_list handler_account_record_list(
    const struct account_record_list list);
    int main()
    {
    struct account_record_list list ;
    list
    = read_input();
    print_account_record_list(list);
    struct person_consumption_list consumption_list =
    handler_account_record_list(list);
    print_person_consumption_list(consumption_list);
    return 0;

    }

      接下来我们声明两个main函数要用到的两个私有函数,因为c里要使用函数要先声明,否则你就只能用你这个函数上面定义的函数,我们在这里先声明两个私有函数的原型,print_account_record_list来打印出每条消费记录的细节,handler_account_record_list用来处理整个记录列表。在这里看到list参数有个const的修饰,该关键字可以保证调用的函数不会修改你的传入的变量,因为这两个方法一个用来打印,一个用来当作输入源计算一些值,从语义上来说就不应该会去修改该参数,所以我们加了const。c里使用并深入理解const关键字是老鸟和新手的一个标志,大家可以查查相关资料。

      main主函数一般都返回int,其中函数定义里可以省略掉int,默认就是int,里面的逻辑也很简单,读取消费记录,打印消费记录,处理消费记录得到每个人的余额状况,打印每个人的余额状况,逻辑非常清晰,下面就是每个子函数的具体实现了。

      下面这个私有函数用来处理消费记录,遍历每天的消费和充值记录,并修改每人的余额记录,逻辑也很清晰,很好的调用了record_handler模块提供的功能,使该函数的简单明了,职责明确。

    代码
    struct person_consumption_list handler_account_record_list(
    const struct account_record_list list)
    {
    struct person_consumption_list consumption_list;
    consumption_list.count
    = 0;
    int i = 0, j = 0;
    for(i = 0; i < list.count; i++)
    {
    struct account_record record = list.records[i];
    double average_consumption =
    calc_avg_consumption(
    record.total_consumption,
    record.people_count,
    record.discount);
    for(j = 0; j < record.people_count; j++)
    {
    edit_person_consumption(
    &consumption_list,
    record.person[j].name,
    -average_consumption);
    int k =0;
    }
    for(j = 0; j< record.pay_record_count; j++)
    {
    edit_person_consumption(
    &consumption_list,
    record.payrecord[j].person.name,
    record.payrecord[j].amount);
    }
    }
    return consumption_list;
    }

    读取记账文件

      我们会用到IO,字符串以及一些字符串和数值转换的函数,所以先包含这些头文件。

    #include <stdio.h>
    #include
    <string.h>
    #include
    <stdlib.h>
    #include
    "data_structure.h"

      C的编译器比较傻,有的时候你不包含头文件也能编译,但运行时会给个错误记录,比如atof是在stdlib.h里定义的,你不包含它也能编译,但你printf("%f",atof("0.8"));它会给你显示0.0,你包含了就没事了,这个太无语了,在c#里你不引用dll就使用人家的方法,编译肯定出错,在C里却什么事都可能发生,所以最好把自己以前学的编程知识先扔到一边,当个编程初学者来学习C,感觉c比javascript还诡异。

      struct account_record_list read_input()是一个比较大的函数,我们分开来看,先看变量定义部分,在C的函数里,变量定义要放在最前面,我们这里定义了fp一个文件类型指针,其中文件操作用c的标准库函数fopen,fclose操作,大家看下c手册就知道用法,这里是用只读方式打开,如果不存在则抛错。

    代码
    FILE *fp;
    if((fp=fopen("input.txt","rt")) == NULL)
    {
    printf(
    "cannot open input.txt");
    getchar();
    exit(
    1);
    }

    int i = 0;
    enum read_state {
    state_default,
    state_date,
    state_consumption,
    state_discount,
    state_person,
    state_payrecord
    } state;
    state
    = state_date;
    struct account_record_list result;
    result.count
    = 0;
    struct account_record *p_record = result.records;
    char temp_buffer[512];
    memset(temp_buffer, ’
    \0’, 512);
    char *p_temp_buffer = temp_buffer;
    char ch = fgetc(fp);

      定义了一个read_state的枚举,在定义枚举的时候一般第一个成员定义成default,表示一种无效或者默认的状态,c里的枚举不能用xxx.yyy来访问,只能用yyy来访问,跟常量一样,所以我们定义成员的时候加上一个state_前缀,这样在使用的时候就知道是个枚举了。

      下面还定义了要返回的account_record_list result,因为在栈上声明的变量没人给初始化,所以result.count我们要人工设置为0,p_record是指向result.records的指针,它是一个指向数组的指针,这样可以用p_record++来依次对每个记录赋值,而不需要像用下标访问那样得知道下标值,再一个就是指针可以提高一点性能。

      temp_buffer是定义的一个临时缓冲区,因为我们解析输入文件,肯定要对原文件进行一些分隔等,所以要用临时缓存区保存临时结果。同理,这里生成的字符数组也没人给初始化,我们用memset来把每个字节都初始化成'\0'。最后也用一个p_temp_buffer指针来指向临时缓冲区,指针我们就以p_做前缀,这样能看出来。

      接下来是对输入文件的解析,我们要尽量保证函数的短小,所以这里的逻辑只是按分隔符找出每个字段,具体每个字段的解析又调用了各个set_xxx的函数。

    代码

    while (ch != EOF)
    {
    if(result.count > MAX_RECORD_COUNT)
    {
    printf(
    "max record count");
    break;
    }
    if(ch != '|' && ch != '\n'){
    *(p_temp_buffer++) = ch;
    }
    else{
    *(p_temp_buffer++) = '\0';
    switch(state)
    {
    case state_date:
    set_date(p_record,temp_buffer);
    state
    = state_consumption;
    break;
    case state_consumption:
    set_consumption(p_record,temp_buffer);
    state
    = state_discount;
    break;
    case state_discount:
    set_discount(p_record,temp_buffer);
    state
    = state_person;
    break;
    case state_person:
    set_person(p_record,temp_buffer);
    state
    = state_payrecord;
    break;
    case state_payrecord:
    set_payrecord(p_record,temp_buffer);
    state
    = state_default;
    break;

    default:
    printf(
    "state is error");
    break;
    }
    memset(temp_buffer,
    0, 512);
    p_temp_buffer
    = temp_buffer;

    }
    if(ch == '\n'){
    result.count
    ++;
    p_record
    ++;
    memset(temp_buffer,
    0, 512);
    p_temp_buffer
    = temp_buffer;
    state
    = state_date;
    }
    putchar(ch);
    ch
    = fgetc(fp);
    }
    fclose(fp);
    return result;

      这些逻辑性的东西就没什么说的了,逐个读取每个字符,如果遇到分隔符|或者\n就把这段字符放入缓冲区,并传给set_xxx来处理,注意每次set_xxx后要重置缓冲区的内容,以及让缓冲区指针指向起始位置。这里读取完某个字段后要把读取状态修改成下一个状态,这也是简单的状态机的应用,在字符串解析方面用的很广。

      最后记着要fclose文件,否则会资源泄漏,像那些成对出现的api要时刻记着配平资源,比如foepn,fclose,malloc,free这种,少半拉的话,一般就会引起资源泄漏问题。  

      我们在看一个set_xxx方法,对付款记录的解析是最复杂的,我们就看这个,付款记录字段格式是先用逗号分隔每个人的付款记录,再用冒号分隔付款人和付款金额。在c里有个strtok的函数,类似split,可以把一个字符串分隔成多个子串,这里也用到了临时缓冲区,把传入的只读字符串用strncpy拷贝到临时缓冲区里再做处理,strncpy比strcpy安全,因为后者拷贝时会一直拷贝,直到遇到\0为止,前者可以指定最多拷贝多少个字符。

    代码
    void set_payrecord(struct account_record *record, const char *buff){
    char temp_buffer[512];
    memset(temp_buffer,
    0, 512);
    strncpy(temp_buffer, buff,
    512*sizeof(char));

    char c[MAX_ARRAY_COUNT][2*MAX_ARRAY_COUNT] = {{'\0'}};
    char (*pc)[2*MAX_ARRAY_COUNT] = c;

    char *p = strtok(temp_buffer,",");
    int paycount= 0;
    while(p != NULL)
    {
    strncpy(
    *pc++, p, 2*MAX_ARRAY_COUNT*sizeof(char));
    p
    = strtok(NULL,",");
    paycount
    ++;
    }

    struct pay_record *payrecord = record -> payrecord;
    int i = 0;
    for(i = 0; i < paycount; i++)
    {
    char *p2 = strtok(c[i],":");
    if(p2 == NULL)
    {
    printf(
    "error:parse payrecord error");
    return;
    }
    struct people person;
    strncpy(person.name, p2, MAX_ARRAY_COUNT
    *sizeof(char));
    p2
    = strtok(NULL,":");
    if(p2 == NULL)
    {
    printf(
    "error:parse payrecord error");
    return;
    }
    double amount = atof(p2);

    payrecord
    -> person = person;
    payrecord
    -> amount = amount;
    payrecord
    ++;
    record
    -> pay_record_count++;
    }
    }

      这里需要一个两维数组,声明两维数组就用char [3][4] 就行,c99里只是声明数组时直接初始化,用={{'\0'}}就可以把数组都初始化成'\0',然后虽然这是一个两位的数组,但要用一维的数组指针去指,如char (*pc)[4],然后用*pc就能访问二维数组的每一行了,每一行是个字符数组,可以用strncpy等函数操作。注意strtok不能嵌套使用,所以先用它把逗号分隔的子串放入到二维数组里,然后便利二维数组的每一行,对每一行按冒号分隔取出付款人和付款金额,最后放到内存对象里。

    处理记账记录

      这个模块比较小,edit_person_consumption用来处理每一笔消费和付款记录,先看list里有没有这个人,如果有这个人就直接把金额修改掉,如果没有,就在list里添加一个人机器消费记录。这里有个问题折腾了半天,就是我把strcmp写成strcpy了,编译也没问题,但输出结果让人很诡异,赋值都乱了,看来这种编译不出错,运行时给个错误值的问题是最难排查的,拼写错误真是程序员最常见的错误呀。剩下两个函数比较简单,打印没人余额记录和计算人均消费。

    代码
    #include "data_structure.h"

    void edit_person_consumption(struct person_consumption_list *list,
    const char *name,double money)
    {
    int i = 0;
    int found = -1;
    for(i = 0; i < list -> count; i++)
    {
    if(strcmp(list -> persons[i].name, name) == 0)
    {
    found
    = i;
    list
    -> persons[i].consumption += money;
    }
    }

    if(found == -1)
    {
    int count = list -> count;
    strncpy(list
    -> persons[count].name, name, MAX_ARRAY_COUNT);
    list
    -> persons[count].consumption = money;
    list
    -> count++;
    }
    }
    void print_person_consumption_list(const struct person_consumption_list list)
    {
    int i;
    printf(
    "\n-----consumption details-------\n");
    for(i = 0; i < list.count; i++)
    {
    printf(
    "%s=%0.2f\n",list.persons[i].name,list.persons[i].consumption);
    }
    }
    double calc_avg_consumption(double total,int person_count,double discount)
    {
    return total * discount / person_count;
    }

    编译及测试

      上篇帖子简单介绍过makefile的编写,以下是该程序的makefile文件,注意换行符和跳格键的使用。

    代码
    book:readinput.o record_handler.o \
    data_structure.h readinput.h record_handler.h\
    main.c
    gcc main.c
    -o book readinput.o record_handler.o
    readinput.o: data_structure.h readinput.h readinput.c
    gcc
    -c readinput.c
    record_handler: data_structure.h record_handler.h record_handler.c
    gcc
    -c record_handler.c

      最后输出一个book的可执行文件,执行./book,输出以下结果,符合预期

      可以看到d负的最多,因为它吃了两顿都没付钱,下次吃饭就该他出钱了,而b正的最多,可以连续一周不用付款吃饭了。

    代码
    2010-9-10|83|0.8|a,b,c,d|a:100,b:100
    2010-9-11|102|0.8|a,b,c,d,e|b:100,c:50
    2010-9-10
    discount
    =0.80
    consumption
    =83
    person:
    a,b,c,d,
    pay_record
    a:
    100.00
    b:
    100.00

    2010-9-11
    discount
    =0.80
    consumption
    =102
    person:
    a,b,c,d,e,
    pay_record
    b:
    100.00
    c:
    50.00


    -----consumption details-------
    a
    =67.08
    b
    =167.08
    c
    =17.08
    d
    =-32.92
    e
    =-16.32

    小节

      其实最终的每人余额可以从小到大排个序,可以练习一下冒泡排序和函数指针的使用,不过这也算是一个比较有意义的下程序了,多写代码,C的入门也就快了。下次可能给大家分享下如何配置VIM能更快的编写C程序,工具的熟练程度会大大影响开发效率。

      语言,工具等在编程里都是次要矛盾,编程的主要要解决的问题是业务逻辑本身的复杂性,所以要经常写一些逻辑比较复杂的小程序来提高编程能力,可以迅速提高思维能力,减少出错的能力,在写代码的过程中所犯的错误都积累起来,以后就可以一次编写,直接执行就通过了,编译和运行都没有错误,推荐下我前段时间写的练习作品:大家来找错-自己写个正则引擎

    源码下载:bookkeeper.zip

    环境:cygwin+gcc3.4.4+vim7.3.3+make3.8.1

  • 相关阅读:
    spark 读取mongodb失败,报executor time out 和GC overhead limit exceeded 异常
    在zepplin 使用spark sql 查询mongodb的数据
    Unable to query from Mongodb from Zeppelin using spark
    spark 与zepplin 版本兼容
    kafka 新旧消费者的区别
    kafka 新生产者发送消息流程
    spark ui acl 不生效的问题分析
    python中if __name__ == '__main__': 的解析
    深入C++的new
    NSSplitView
  • 原文地址:https://www.cnblogs.com/onlytiancai/p/1830362.html
Copyright © 2020-2023  润新知