蛙蛙推荐: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

posted @ 2010-09-18 19:39  蛙蛙王子  Views(5413)  Comments(10Edit  收藏  举报