zoukankan      html  css  js  c++  java
  • 蛙蛙推荐: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

  • 相关阅读:
    MongoDB下配置用户权限
    (CF)Codeforces445A DZY Loves Chessboard(纯实现题)
    C语言概述
    C#中值类型和引用类型的差别浅记
    Qt5官方demo解析集30——Extending QML
    汉澳sinox通过ndis执行windows驱动程序
    linux设备驱动归纳总结(三):4.ioctl的实现【转】
    linux设备驱动归纳总结(三):3.设备驱动面向对象思想和lseek的实现【转】
    linux设备驱动归纳总结(三):2.字符型设备的操作open、close、read、write【转】
    linux设备驱动归纳总结(三):1.字符型设备之设备申请【转】
  • 原文地址:https://www.cnblogs.com/onlytiancai/p/1830362.html
Copyright © 2011-2022 走看看