张泽鹏
2018-01-22程序员
本文是我以前写的文章——《你试过这样写C程序吗》——的第二版,并把文章名改为更贴切的“从重复到重用”。算法
开发技术的发展,从第一次提出“函数/子程序”,实现代码级重用;到面向对象的“类”,重用数据结构与算法;再到“动态连接库”、“控件”等重用模块;到现在流行的云计算、微服务可重用整个系统。技术发展虽然突飞猛进,但本质都是重用,只是粒度不一样。因此写代码的动机都应是把重复的工做变成可重用的方案,其中重复的工做包括业务上重复的场景、技术上重复的代码等。合格的系统能够简化当下重复的工做;优秀的系统还能预见将来重复的工做。编程
本文不谈框架、不谈架构,就谈写代码的那些事儿!后文始终围绕一个问题的解决方案,不断发现其中“重复”的代码,并提炼出“可重用”的抽象,持续“重构”。但愿经过这个过程和你们分享一些发现重复代码和提炼可重用抽象的方法。数组
做为贯穿全文的主线,这有一个任务须要开发一个程序来完成:有一份存有职员信息(姓名、年龄、工资)的文件“work.txt”,内容以下:数据结构
William 35 25000 Kishore 41 35000 Wallace 37 30000 Bruce 39 29999
即运行的结果是屏幕上要有八行输出,“work.txt”的内容将变成:多线程
William 35 28000 Kishore 41 35000 Wallace 37 30000 Bruce 39 32999
在明确了需求以后,第一步要作的是写测试代码,而不是写功能代码。《重构》一书中对重构的定义是:“在不改变代码外在行为的前提下,对代码作出修改,以改进程序的内部结构。”其中明确指出“代码外在行为”是不改变的!在不断迭代重构时,“保证每次重构的行为不变”也是一项重复的工做,因此测试先行不只能尽早地校验对需求理解的正确性、还能避免重复测试。本文经过一段Shell
脚本完成如下工做:架构
work.txt
文件。work.txt
文件的内容是否与指望一致。#!/bin/sh if [ $# -eq 0 ]; then echo "usage: $0 <c-source-file>" >&2 exit -1 fi input=$(cat <<EOF William 35 25000 Kishore 41 35000 Wallace 37 30000 Bruce 39 29999 EOF ) output=$(cat <<EOF William 35 28000 Kishore 41 35000 Wallace 37 30000 Bruce 39 32999 EOF ) echo "$input" > work.txt echo "$input" > .expect.stdout.txt echo "$output" >> .expect.stdout.txt echo "$output" > .expect.work.txt (gcc "$1" -o main && ./main | diff .expect.stdout.txt - && diff .expect.work.txt work.txt) && echo PASS || echo FAIL rm -f main work.txt .expect.work.txt .expect.stdout.txt
将上述代码保存成check.sh
,待测试的源文件名做为参数。若是程序经过,会显示“PASS”,不然会输出不一样的行以及“FAIL”。app
每位熟练的程序员都能快速地给出本身的实现。本文示例代码使用ANSI C99编写,Mac下用gcc能正常编译运行,其余环境未测试。选择C语言是由于主流编程语言都或多或少借鉴它的语法,同时它的语法特性也足够用于演示。框架
问题很简单,简单到把全部代码都塞到 main 函数里也不以为长:编程语言
#include <stdio.h> int main(void) { struct { char name[8]; int age; int salary; } e[4]; FILE *istream, *ostream; int i; istream = fopen("work.txt", "r"); for (i = 0; i < 4; i++) { fscanf(istream, "%s%d%d", e[i].name, &e[i].age, &e[i].salary); printf("%s %d %d\n", e[i].name, e[i].age, e[i].salary); if (e[i].salary < 30000) { e[i].salary += 3000; } } fclose(istream); ostream = fopen("work.txt", "w"); for (i = 0; i < 4; i++) { printf("%s %d %d\n", e[i].name, e[i].age, e[i].salary); fprintf(ostream, "%s %d %d\n", e[i].name, e[i].age, e[i].salary); } fclose(ostream); return 0; }
其中第一个循环从 work.txt 中读取4行数据,并把信息输出到屏幕(需求#1);同时为薪资小于三万的职员增长三千元(需求#2);第二个循环遍历全部数据,把调整后的结果输出屏幕(需求#3),并保存结果到 work.txt(需求#4)。
试试将上述代码保存成1.c
并执行./check.sh 1.c
,屏幕上会输出“PASS”,即经过测试。
初版代码解决了问题,让原来重复的调薪工做变成简便的、可反复使用的程序。若是它是C语言课堂做业的答案,看起来还不错——至少缩进一致,也没混用空格和制表符;但从软件工程的角度来说,它简直糟糕透了,由于没有清晰的表达意图:
4
重复出现,后续负责维护的程序员没法判断它们是碰巧相等仍是有其余缘由必需相等。work.txt
重复出现。ostream
前面的*
。e
和i
变量命名不顾名思义。借乔老爷子的话说:“看不见的地方也要用心作好”——这些代码的问题用户虽然看不见也不在意,但也要用心作好——已有几处显眼的地方出现重复。不过,在代码变得清晰以前,不该急着动手去重构,由于清晰的代码更容易找出重复!针对上述意图不明的问题,准备对代码作如下调整:
4
在三处的意义都是员工记录数,所以定义共享常量#define RECORD_COUNT 4
。"work.txt"
和4
不一样,内容虽然相同但意义不一样:一个做输入,一个做输出。若是也只简单的定义一个常量FILE_NAME
共用,后续二者独立变化时,工做量并没减小。因此去除重复代码时,切忌只看表面相同,背后意义相同的才是真正的相同,不然就像给全部常量1
定义ONE
别名同样没有意义。因此须要定义三个常量FILE_NAME
、INPUT_FILE_NAME
和OUTPUT_FILE_NAME
。typedef FILE* File;
替代FILE*
,可避免遗漏指针。e
是全部职员信息,把变量名改为employees
。i
是迭代过程的下标,把变量名改为index
。index
变量定义放到for
语句中。File
变量定义从顶部挪到各自使用以前的位置。<stdlib.h>
中更语义化的EXIT_FAILURE
,正常退出时用EXIT_SUCCESS
。你可能会问:“数字30000和3000也是魔法数字,为何不调整?”缘由是此时它们即不重复也无歧义。整理后的完整代码以下:
#include <stdlib.h> #include <stdio.h> #define RECORD_COUNT 4 #define FILE_NAME "work.txt" #define INPUT_FILE_NAME FILE_NAME #define OUTPUT_FILE_NAME FILE_NAME typedef FILE* File; int main(void) { struct { char name[8]; int age; int salary; } employees[RECORD_COUNT]; File istream = fopen(INPUT_FILE_NAME, "r"); if (istream == NULL) { fprintf(stderr, "Cannot open %s with r mode.\n", INPUT_FILE_NAME); exit(EXIT_FAILURE); } for (int index = 0; index < RECORD_COUNT; index++) { fscanf(istream, "%s%d%d", employees[index].name, &employees[index].age, &employees[index].salary); printf("%s %d %d\n", employees[index].name, employees[index].age, employees[index].salary); if (employees[index].salary < 30000) { employees[index].salary += 3000; } } fclose(istream); File ostream = fopen(OUTPUT_FILE_NAME, "w"); if (ostream == NULL) { fprintf(stderr, "Cannot open %s with w mode.\n", OUTPUT_FILE_NAME); exit(EXIT_FAILURE); } for (int index = 0; index < RECORD_COUNT; index++) { printf("%s %d %d\n", employees[index].name, employees[index].age, employees[index].salary); fprintf(ostream, "%s %d %d\n", employees[index].name, employees[index].age, employees[index].salary); } fclose(ostream); return EXIT_SUCCESS; }
将以上代码保存成2.c
并执行./check.sh 2.c
,获得指望的输出PASS
,证实本次重构没有改变程序的行为。
通过第二版的优化,单行代码的意图已比较清晰,但还存在一些过早优化致使代码块的含义不清晰。
例如第一个循环中耦合了“输出到屏幕”和“调整薪资”两个功能,好处是可减小一次循环,性能也许有些提高;但这两个功能在需求中是相互独立的,后续独立变化的可能性更大。假设新需求是第一步输出到屏幕后,要求用户输入命令,再决定是否要进行薪资调整工做。此时,对需求方而言只新增一个步骤,只有一个改动;但到了代码层面,却不是新增一个步骤对应新增一块代码,还会牵涉理论上不相关的代码块;负责维护的程序员在不了解背景时,就不肯定这两段代码放在一块儿有没有历史缘由,也就不敢轻易将它们拆开。当系统规模越大,这种与需求不是一一对应的代码就越让维护人员手足无措!
回想平常开发,需求改动很小而代码却牵一发动全身,根源每每就是过早优化。“优化”和“通用”每每是对立的,优化的越完全就与业务场景结合越紧密,通用性也越差。好比某个系统会在缓冲队列中对收到的消息进行排序,上线运行后发现由于产品设计等外部缘由,消息可能自然接近排好序,因而用插入排序代替快速排序等更通用的排序算法,这就是一次不通用的优化:它让系统的性能更好,但系统的适用面更窄。过早的优化就是过早的给系统能力设置天花板。
理想状况是代码块与需求功能点一一对应,例如当前需求有4个功能点,得有4个独立的代码块与之对应。这样作的好处是:当需求发生变化时,代码的修改也相对集中。所以,基于第二版本代码准备作如下调整:
整理后的完整代码以下:
#include <stdlib.h> #include <stdio.h> #define RECORD_COUNT 4 #define FILE_NAME "work.txt" #define INPUT_FILE_NAME FILE_NAME #define OUTPUT_FILE_NAME FILE_NAME typedef FILE* File; int main(void) { struct { char name[8]; int age; int salary; } employees[RECORD_COUNT]; /* 从文件读入 */ File istream = fopen(INPUT_FILE_NAME, "r"); if (istream == NULL) { fprintf(stderr, "Cannot open %s with r mode.\n", INPUT_FILE_NAME); exit(EXIT_FAILURE); } for (int index = 0; index < RECORD_COUNT; index++) { fscanf(istream, "%s%d%d", employees[index].name, &employees[index].age, &employees[index].salary); } fclose(istream); /* 1. 输出到屏幕 */ for (int index = 0; index < RECORD_COUNT; index++) { printf("%s %d %d\n", employees[index].name, employees[index].age, employees[index].salary); } /* 2. 调整薪资 */ for (int index = 0; index < RECORD_COUNT; index++) { if (employees[index].salary < 30000) { employees[index].salary += 3000; } } /* 3. 输出调整后的结果 */ for (int index = 0; index < RECORD_COUNT; index++) { printf("%s %d %d\n", employees[index].name, employees[index].age, employees[index].salary); } /* 4. 保存到文件 */ File ostream = fopen(OUTPUT_FILE_NAME, "w"); if (ostream == NULL) { fprintf(stderr, "Cannot open %s with w mode.\n", OUTPUT_FILE_NAME); exit(EXIT_FAILURE); } for (int index = 0; index < RECORD_COUNT; index++) { fprintf(ostream, "%s %d %d\n", employees[index].name, employees[index].age, employees[index].salary); } fclose(ostream); return EXIT_SUCCESS; }
将以上代码保存成3.c
并执行./check.sh 3.c
,确保程序的行为没有改变。
通过两轮改造,代码结构已足够清晰;如今能够开始重构,来梳理代码层次。
最显眼的就是格式化输出职员信息:除了输出流不一样,格式、内容彻底相同,四条需求中出现了三次。通常遇到相同/类似代码时,能够抽象出一个函数:相同的部分写在函数体中,不一样的部分做为参数传入。此处,能抽象出一个以结构体数据和文件流为入参的函数,但目前这个结构体仍是匿名的,没法做为函数的参数,因此第一步得先给匿名的职员结构体取一个合适的类型名称:
typedef struct _Employee { char name[8]; int age; int salary; } *Employee;
而后抽象公共函数用于格式化输出Employee
到File
,这其中还耦合了两个功能:
Employee
序列化成字符串。由于暂无独立使用某项功能的场景,目前无需进一步拆分:
void employee_print(Employee employee, File ostream) { fprintf(ostream, "%s %d %d\n", employee->name, employee->age, employee->salary); }
Employee
结构体+employee_print
函数很容易联想到面向对象的“类”。面向对象的本质是由一组功能独立的对象组成系统,对象之间经过发消息协做完成任务,不见得非要有class
关键字,继承、封装、多态等语法糖。
class
关键字,在语言层面强制捆绑,C
语言并无这样的语法,但能够制定编码规范,让数据结构与函数在物理上挨得更近。Java
中foo.baz()
就是向foo
对象发送baz
消息,C++
中等价的语法是foo->baz()
,Smalltalk
中是foo baz
,C
语言则是baz(foo)
。综上所述,虽然C
语言一般被认为不是面向对象的语言,其实它也能支持面向对象风格。沿上述思路,能够抽象出职员对象的四个方法:
employee_read
:构造函数,分配空间、输入并反序列化,相似于Java
的new
。employee_free
:析构函数,释放空间,即纯手工的GC
。employee_print
:序列化并输出。employee_adjust_salary
:调整职员薪资,惟一的业务逻辑。有了职员对象,程序再也不只有一个main
函数。假设把main
函数看做应用层,其余函数看做类库、框架或中间件,这样程序有了层级,层间仅经过开放的接口通信,即对象的封装性。
在Java
中有public
、protected
、default
和private
四种可见性修饰符,C
语言的函数默认是公开的,加上static
关键字后只在当前文件可见。为避免应用层向对象随意发送消息,约定只有在应用层用到的函数才公开,因此额外定义了public
和private
两个修饰符,目前职员对象的四个方法都是公开的。
重构以后的完整代码以下:
#include <stdlib.h> #include <stdio.h> #define private static #define public #define RECORD_COUNT 4 #define FILE_NAME "work.txt" #define INPUT_FILE_NAME FILE_NAME #define OUTPUT_FILE_NAME FILE_NAME typedef FILE *File; /* 职员对象 */ typedef struct _Employee { char name[8]; int age; int salary; } *Employee; public void employee_free(Employee employee) { free(employee); } public Employee employee_read(File istream) { Employee employee = (Employee) calloc(1, sizeof(struct _Employee)); if (employee == NULL) { fprintf(stderr, "employee_read: out of memory\n"); exit(EXIT_FAILURE); } if (fscanf(istream, "%s%d%d", employee->name, &employee->age, &employee->salary) != 3) { employee_free(employee); return NULL; } return employee; } public void employee_print(Employee employee, File ostream) { fprintf(ostream, "%s %d %d\n", employee->name, employee->age, employee->salary); } public void employee_adjust_salary(Employee employee) { if (employee->salary < 30000) { employee->salary += 3000; } } /* 应用层 */ int main(void) { Employee employees[RECORD_COUNT]; /* 从文件读入 */ File istream = fopen(INPUT_FILE_NAME, "r"); if (istream == NULL) { fprintf(stderr, "Cannot open %s with r mode.\n", INPUT_FILE_NAME); exit(EXIT_FAILURE); } for (int index = 0; index < RECORD_COUNT; index++) { employees[index] = employee_read(istream); } fclose(istream); /* 1. 输出到屏幕 */ for (int index = 0; index < RECORD_COUNT; index++) { employee_print(employees[index], stdout); } /* 2. 调整薪资 */ for (int index = 0; index < RECORD_COUNT; index++) { employee_adjust_salary(employees[index]); } /* 3. 输出调整后的结果 */ for (int index = 0; index < RECORD_COUNT; index++) { employee_print(employees[index], stdout); } /* 4. 保存到文件 */ File ostream = fopen(OUTPUT_FILE_NAME, "w"); if (ostream == NULL) { fprintf(stderr, "Cannot open %s with w mode.\n", OUTPUT_FILE_NAME); exit(EXIT_FAILURE); } for (int index = 0; index < RECORD_COUNT; index++) { employee_print(employees[index], ostream); } fclose(ostream); /* 释放资源 */ for (int index = 0; index < RECORD_COUNT; index++) { employee_free(employees[index]); } return EXIT_SUCCESS; }
将代码保存为4.c
,照例执行./check.sh 4.c
检测是否有改变程序行为。
以前的重构,去除了词法和句法上的重复,就像一篇文章里的单词和语句,接着能够看段落有没有重复,即代码块。
与employee_print
相似,三段循环输出职员信息代码也是明显的重复,能够抽象出employees_print
,同时也抽象出另外一个对象——职员列表——Employees
。参考职员对象,能够抽象出四个与之对应的函数:
employees_read
:构造函数,分配列表空间,并依次建立职员对象。employees_free
:析构函数,释放列表空间,以及职员对象的空间。employees_print
:序列化并输出列表中每一位职员信息。employees_adjust_salary
:调整全部符合要求职员的薪资。此时,main
函数只需调用职员列表对象的方法,再也不直接调用职员对象的方法,因此后者可见性从public
降为private
。
重构以后的完整代码以下:
#include <stdlib.h> #include <stdio.h> #define private static #define public #define RECORD_COUNT 4 #define FILE_NAME "work.txt" #define INPUT_FILE_NAME FILE_NAME #define OUTPUT_FILE_NAME FILE_NAME typedef FILE *File; /* 职员对象 */ typedef struct _Employee { char name[8]; int age; int salary; } *Employee; private void employee_free(Employee employee) { free(employee); } private Employee employee_read(File istream) { Employee employee = (Employee) calloc(1, sizeof(struct _Employee)); if (employee == NULL) { fprintf(stderr, "employee_read: out of memory\n"); exit(EXIT_FAILURE); } if (fscanf(istream, "%s%d%d", employee->name, &employee->age, &employee->salary) != 3) { employee_free(employee); return NULL; } return employee; } private void employee_print(Employee employee, File ostream) { fprintf(ostream, "%s %d %d\n", employee->name, employee->age, employee->salary); } private void employee_adjust_salary(Employee employee) { if (employee->salary < 30000) { employee->salary += 3000; } } /* 职员列表对象 */ typedef Employee* Employees; public Employees employees_read(File istream) { Employees employees = (Employees) calloc(RECORD_COUNT, sizeof(Employee)); if (employees == NULL) { fprintf(stderr, "employees_read: out of memory\n"); exit(EXIT_FAILURE); } for (int index = 0; index < RECORD_COUNT; index++) { employees[index] = employee_read(istream); } return employees; } public void employees_print(Employees employees, File ostream) { for (int index = 0; index < RECORD_COUNT; index++) { employee_print(employees[index], ostream); } } public void employees_adjust_salary(Employees employees) { for (int index = 0; index < RECORD_COUNT; index++) { employee_adjust_salary(employees[index]); } } public void employees_free(Employees employees) { for (int index = 0; index < RECORD_COUNT; index++) { employee_free(employees[index]); } free(employees); } /* 应用层 */ int main(void) { /* 从文件读入 */ File istream = fopen(INPUT_FILE_NAME, "r"); if (istream == NULL) { fprintf(stderr, "Cannot open %s with r mode.\n", INPUT_FILE_NAME); exit(EXIT_FAILURE); } Employees employees = employees_read(istream); fclose(istream); /* 1. 输出到屏幕 */ employees_print(employees, stdout); /* 2. 调整薪资 */ employees_adjust_salary(employees); /* 3. 输出调整后的结果 */ employees_print(employees, stdout); /* 4. 保存到文件 */ File ostream = fopen(OUTPUT_FILE_NAME, "w"); if (ostream == NULL) { fprintf(stderr, "Cannot open %s with w mode.\n", OUTPUT_FILE_NAME); exit(EXIT_FAILURE); } employees_print(employees, ostream); fclose(ostream); /* 释放资源 */ employees_free(employees); return EXIT_SUCCESS; }
不要忘记运行./check.sh
做回归测试。
此时的main
函数已经比较清爽,剩下一处明显的重复:打开文件并检查文件是否正常打开。这属于文件相关的操做,能够抽象出一个file_open
代替fopen
:
private File file_open(char* filename, char* mode) { File stream = fopen(filename, mode); if (stream == NULL) { fprintf(stderr, "Cannot open %s with %s mode.\n", filename, mode); exit(EXIT_FAILURE); } return stream; }
接着能够继续抽象职员列表对象的输入和输出方法:
employees_input
:从文件中获取数据并建立职员列表对象。employees_output
:将职员列表对象的内容输出到文件。重构后employees_read
再也不被main
访问,因此改为private
。重构后的完整代码以下:
#include <stdlib.h> #include <stdio.h> #define private static #define public #define RECORD_COUNT 4 #define FILE_NAME "work.txt" #define INPUT_FILE_NAME FILE_NAME #define OUTPUT_FILE_NAME FILE_NAME typedef FILE *File; typedef char* String; /* 职员对象 */ typedef struct _Employee { char name[8]; int age; int salary; } *Employee; private void employee_free(Employee employee) { free(employee); } private Employee employee_read(File istream) { Employee employee = (Employee) calloc(1, sizeof(struct _Employee)); if (employee == NULL) { fprintf(stderr, "employee_read: out of memory\n"); exit(EXIT_FAILURE); } if (fscanf(istream, "%s%d%d", employee->name, &employee->age, &employee->salary) != 3) { employee_free(employee); return NULL; } return employee; } private void employee_print(Employee employee, File ostream) { fprintf(ostream, "%s %d %d\n", employee->name, employee->age, employee->salary); } private void employee_adjust_salary(Employee employee) { if (employee->salary < 30000) { employee->salary += 3000; } } /* 职员列表对象 */ typedef Employee* Employees; private Employees employees_read(File istream) { Employees employees = (Employees) calloc(RECORD_COUNT, sizeof(Employee)); if (employees == NULL) { fprintf(stderr, "employees_read: out of memory\n"); exit(EXIT_FAILURE); } for (int index = 0; index < RECORD_COUNT; index++) { employees[index] = employee_read(istream); } return employees; } public void employees_print(Employees employees, File ostream) { for (int index = 0; index < RECORD_COUNT; index++) { employee_print(employees[index], ostream); } } public void employees_adjust_salary(Employees employees) { for (int index = 0; index < RECORD_COUNT; index++) { employee_adjust_salary(employees[index]); } } public void employees_free(Employees employees) { for (int index = 0; index < RECORD_COUNT; index++) { employee_free(employees[index]); } free(employees); } /* I/O层 */ private File file_open(String filename, String mode) { File stream = fopen(filename, mode); if (stream == NULL) { fprintf(stderr, "Cannot open %s with %s mode.\n", filename, mode); exit(EXIT_FAILURE); } return stream; } public Employees employees_input(String filename) { File istream = file_open(filename, "r"); Employees employees = employees_read(istream); fclose(istream); return employees; } public void employees_output(Employees employees, String filename) { File ostream = file_open(filename, "w"); employees_print(employees, ostream); fclose(ostream); } /* 应用层 */ int main(void) { Employees employees = employees_input(INPUT_FILE_NAME); /* 从文件读入 */ employees_print(employees, stdout); /* 1. 输出到屏幕 */ employees_adjust_salary(employees); /* 2. 调整薪资 */ employees_print(employees, stdout);/* 3. 输出调整后的结果 */ employees_output(employees, OUTPUT_FILE_NAME);/* 4. 保存到文件 */ employees_free(employees); /* 释放资源 */ return EXIT_SUCCESS; }
别忘记执行./check.sh
。
如今,main
里只用到了职员列表相关的函数,且代码和需求几乎一一对应。这些函数能够当作职员管理领域的DSL
,领域特定语言是业务和技术双方的共识,理论上需求不变,基于DSL
开发的业务代码也不变。以前全部的改动仅要求main
行为一致,后续的重构还要尽可能保证main
自身也无任何变化,即API
向后兼容。
回到继续挖掘代码中重复的问题上,其中职员列表方法中几乎都有一个for
循环:for (int index = 0; index < RECORD_COUNT; index++) { ... }
,例如调整薪资和释放空间两段代码:
for (int index = 0; index < RECORD_COUNT; index++) { employee_adjust_salary(employees[index]); } for (int index = 0; index < RECORD_COUNT; index++) { employee_free(employees[index]); }
除了循环体中分别调用了employee_adjust_salary
和employee_free
,其他都一摸同样,即它们的迭代规则相同,而循环体不一样。是否有可能自定义一个for
语句代替这些重复的迭代?
在大多数编程语言中,if
、for
等控制语句是一种特殊的存在,开发者一般没法自定义。这是if
和for
在大多数语言中的样子:
if (condition) { ... } for (init; term; inc) { ... }
若是把它们想象成是函数,语法能够改为更熟悉的函数调用形式:
if (condition, { ... }); for (init, term, inc, { ... });
和普通函数调用相比,惟一不一样的是容许花括号包围的代码片断做为参数。所以,若编程语言容许代码做为函数的参数,那就能自定义新的控制语句!这句话隐含了两个语言特性:
全部编程语言都包含一套类型系统,它决定数据的类型,而数据的类型又决定数据的功能。例如,数值类型能够作四则运算;字符串类型的数据能够拼接、查找、替换等;代码若是也是一种数据类型,就能够随时“执行”它。C
语言中具有“执行”能力的元素就是“函数”,函数之于代码类型,犹如int
、double
之于数值类型,都只是C
这个特定编程语言对特定类型的特定实现,换成Visual Basic
改叫“过程”,换成Java
又称做“成员方法”。
至于特性#2,它正是函数式编程的本质!提到函数式风格,脑海中一般会闪过一些耳熟能详的词汇:无反作用、无状态、易于并行编程,甚至是Lisp
那扭曲的前缀表达式。追根溯源,函数式编程源自λ
演算——函数能做为值传递给其余函数或由其余函数返回——其本质是函数做为类型系统中的“第一等公民”(First-Class),符合如下四项要求:
对照之下会惊讶地发现,C
语言这门看似与函数式编程最远的上古编程语言,利用函数指针,竟然也彻底符合上述条件。观察employee_adjust_salary
和employee_free
两个函数,都只有一个Employee
类型的参数且没有返回值,翻译成C
语言就是typedef void (*EmployeeFn)(Employee)
,把它做为函数的参数,就能抽象出:
private void employees_each(Employees employees, EmployeeFn fn) { for (int index = 0; index < RECORD_COUNT; index++) { fn(employees[index]); } }
在函数式语言中,这类将函数做为参数或返回值的函数称为高阶函数,C
语言里称为控制语句。用这个自定义的控制语句代替原生的for
循环,则代码能够简化成:
employees_each(employees, employee_adjust_salary); employees_each(employees, employee_free);
不过,此时还只解决了一半问题:employees_read
和employees_print
中依然有重复的for
循环,并没有法用employees_each
简化。缘由是这些循环体中函数调用的参数数目与类型和EmployeeFn
不兼容:
employee_read
:包含File
类型的参数,返回Employee
类型。employee_print
:包含Employee
和File
两类参数,无返回值。EmployeeFn
:包含Employee
类型的参数,无返回值。想涵盖全部场景,最简单的方法就是提取一个参数与返回结果的全集——Employee (*EmployeeFn)(Employee, File)
——包含Employee
和File
两个类型的参数,且返回Employee
类型的结果。用新接口重构Employee
的四个方法:
employee_free
返回NULL
,其余都返回Employee
入参。同时,须要改造employees_each
去适应新接口:加入File
参数,以及返回处理结果。在编程的语义中,单纯利用反作用的迭代被称为foreach
,而关注迭代每一个元素的处理结果则称为map
,即映射。所以,用employees_map
取代以前的employees_each
:
private Employees employees_map(Employees employees, File stream, EmployeeFn fn) { for (int index = 0; index < RECORD_COUNT; index++) { employees[index] = fn(employees[index], stream); } return employees; }
重构后的完整代码以下:
#include <stdlib.h> #include <stdio.h> #define private static #define public #define RECORD_COUNT 4 #define FILE_NAME "work.txt" #define INPUT_FILE_NAME FILE_NAME #define OUTPUT_FILE_NAME FILE_NAME typedef FILE *File; typedef char* String; /* 职员对象 */ typedef struct _Employee { char name[8]; int age; int salary; } *Employee; typedef Employee (*EmployeeFn)(Employee, File); private Employee employee_free(Employee employee, File stream) { free(employee); return NULL; } private Employee employee_read(Employee employee, File istream) { employee = (Employee) calloc(1, sizeof(struct _Employee)); if (employee == NULL) { fprintf(stderr, "employee_read: out of memory\n"); exit(EXIT_FAILURE); } if (fscanf(istream, "%s%d%d", employee->name, &employee->age, &employee->salary) != 3) { employee_free(employee, NULL); return NULL; } return employee; } private Employee employee_print(Employee employee, File ostream) { fprintf(ostream, "%s %d %d\n", employee->name, employee->age, employee->salary); return employee; } private Employee employee_adjust_salary(Employee employee, File stream) { if (employee->salary < 30000) { employee->salary += 3000; } return employee; } /* 职员列表对象 */ typedef Employee* Employees; private Employees employees_map(Employees employees, File stream, EmployeeFn fn) { for (int index = 0; index < RECORD_COUNT; index++) { employees[index] = fn(employees[index], stream); } return employees; } private Employees employees_read(File istream) { Employees employees = (Employees) calloc(RECORD_COUNT, sizeof(Employee)); if (employees == NULL) { fprintf(stderr, "employees_read: out of memory\n"); exit(EXIT_FAILURE); } return employees_map(employees, istream, employee_read); } public void employees_print(Employees employees, File ostream) { employees_map(employees, ostream, employee_print); } public void employees_adjust_salary(Employees employees) { employees_map(employees, NULL, employee_adjust_salary); } public void employees_free(Employees employees) { employees_map(employees, NULL, employee_free); free(employees); } /* I/O层 */ private File file_open(String filename, String mode) { File stream = fopen(filename, mode); if (stream == NULL) { fprintf(stderr, "Cannot open %s with %s mode.\n", filename, mode); exit(EXIT_FAILURE); } return stream; } public Employees employees_input(String filename) { File istream = file_open(filename, "r"); Employees employees = employees_read(istream); fclose(istream); return employees; } public void employees_output(Employees employees, String filename) { File ostream = file_open(filename, "w"); employees_print(employees, ostream); fclose(ostream); } /* 应用层 */ int main(void) { Employees employees = employees_input(INPUT_FILE_NAME); /* 从文件读入 */ employees_print(employees, stdout); /* 1. 输出到屏幕 */ employees_adjust_salary(employees); /* 2. 调整薪资 */ employees_print(employees, stdout);/* 3. 输出调整后的结果 */ employees_output(employees, OUTPUT_FILE_NAME);/* 4. 保存到文件 */ employees_free(employees); /* 释放资源 */ return EXIT_SUCCESS; }
这一系列的改造展现了“代码即数据”的一些好处:使用不支持函数式编程的语言开发,将迫使咱们永远在语言刚好提供的基础功能上工做;而“代码即数据”让咱们摆脱这样的束缚,容许自定义控制语句。例如,Java 5
引入foreach
语法糖、Java 7
引入try-with-resource
语法糖,在Java 8
以前想要任何新的语言特性只能等Oracle大发慈悲,Java 8
以后想要任何语言特性就能够自给自足!
通过这么大的改造,切勿忘记测试!
上一版本的代码虽然能够工做,但也暴露出一个常见问题:函数的参数不断膨胀。这个问题在程序的层次不断增长过程会慢慢滋生。例如函数A
会调用B
、B
又调用C
,假设C
须要一个文件对象,假设B
中并不建立文件对象,就得从A
依次传递到B
再传递到C
。函数调用的层次越深,数据逐层传递的问题就越严重,上层函数的入参就会爆炸!
这类函数参数过多且逐层传递的问题,最简单的解决方法就是使用全局变量。例如定义一个全局的文件对象,指向当前输入/输出的目标,这样就能去除全部的文件对象入参。全局变量的弊端是很难判断它的影响范围,不加限制地使用全局变量就和无约束地使用goto
同样,代码会迅速变成意大利面条。因此,建议有节制地使用全局变量:用完以后及时将值恢复。例如如下代码:
int is_debug = 0; void a() { if (is_debug == 1) { printf("debug is enable\n"); } printf("call a()\n"); } void b() { a(); printf("call b()\n"); } void c() { int original = is_debug; is_debug = 1; b(); is_debug = original; }
其中函数c
临时开启了调试选项,并在退出前恢复成原始值。一旦忘记恢复,后续全部调试信息就都会输出,恶梦就会开始。为避免这种尴尬问题,能够利用上一版本中提到的函数式编程的方法,将重复的开启选项、恢复工做抽象成函数:
typedef void (*Callback)(void); void with_debug(Callback fn) { int original = is_debug; is_debug = 1; fn(); is_debug = original; } void c() { with_debug(b); }
像with_debug
这种负责资源分配再自动回收(或资源修改再自动恢复)工做的函数称为上下文包装器(wrapper),开启调试选项是一个常见的应用场景,还能够用于自动关闭打开的文件对象(例如Java 7
的try-with-resources
)。不过,目前的解决方案在多线程环境下依然有问题,为避免不一样的线程之间相互冲突,理想的方案是采用相似Java
中的ThreadLocal
包装全部全局变量,C
语言的多线程方案POSIX thread
有Thread Specific
组件实现相似的线程特有数据功能,此处就不展开讨论。
综上所述,咱们真正须要的功能彷佛是一种代码的包装能力:全局变量某个特定的值只在指定范围内生效(包括范围内代码调用的函数、调用函数的调用等等),相似于会话级别的变量。这种功能被裁剪的全局变量在编程语言中称为动态做用域(Dynamic Scope)变量。
大多数主流编程语言只支持静态做用域——也叫词法做用域——在编译时静态肯定的做用域;但动态做用域是在运行过程当中动态肯定的。简言之,静态做用域由代码的层次结构决定,动态做用域由调用的堆栈层次结构决定。如下代码是Perl
语言动态做用域变量的示例,保存成demo.pl
,执行perl demo.pl
能输出$v = 1
:
sub foo { print "\$v = $v\n"; } sub baz { local $v = 1; foo; } baz;
回到重构问题,利用动态做用域的思路,能够抽象出一个文件对象包装器:用指定文件替换全局的文件流,退出时恢复。C
语言提供了打开指定文件并替代标准输入输出流的函数——freopen
——但却没自带恢复的功能,所以不一样的平台恢复方法不一样,本文以类UNIX环境为例,在unistd.h
包下有dup
和fdopen
两个函数,分别用于克隆和恢复文件句柄。示例代码以下:
void file_with(String filename, String mode) { int handler = dup(mode[0] == 'r'? 0: 1); /* 克隆文件句柄 */ File stream = freopen(filename, mode, mode[0] == 'r'? stdin: stdout); if (stream == NULL) { fprintf(stderr, "Cannot open %s with %s mode.\n", filename, mode); exit(EXIT_FAILURE); } /* TODO */ fclose(stream); fdopen(handler, mode); /* 完成后恢复标准IO */ }
有了这个功能,能够删除掉全部函数和接口的File file
参数!惟一真正和文件相关的只剩下employees_input
和employees_output
,它们分别调用Employees employees_read()
和void employees_print(Employees)
,为了使用file_with
作统一的重定向,利用上一版接口全集的方法,把它们的接口统一改为typedef Employees (*EmployeesFn)(Employees);
。最终,重构后的完整代码以下:
#include <stdlib.h> #include <stdio.h> #include <unistd.h> #define private static #define public #define RECORD_COUNT 4 #define FILE_NAME "work.txt" #define INPUT_FILE_NAME FILE_NAME #define OUTPUT_FILE_NAME FILE_NAME typedef FILE *File; typedef char* String; /* 职员对象 */ typedef struct _Employee { char name[8]; int age; int salary; } *Employee; typedef Employee (*EmployeeFn)(Employee); private Employee employee_free(Employee employee) { free(employee); return NULL; } private Employee employee_read(Employee employee) { employee = (Employee) calloc(1, sizeof(struct _Employee)); if (employee == NULL) { fprintf(stderr, "employee_read: out of memory\n"); exit(EXIT_FAILURE); } if (scanf("%s%d%d", employee->name, &employee->age, &employee->salary) != 3) { employee_free(employee); return NULL; } return employee; } private Employee employee_print(Employee employee) { printf("%s %d %d\n", employee->name, employee->age, employee->salary); return employee; } private Employee employee_adjust_salary(Employee employee) { if (employee->salary < 30000) { employee->salary += 3000; } return employee; } /* 职员列表对象 */ typedef Employee* Employees; typedef Employees (*EmployeesFn)(Employees); private Employees employees_map(Employees employees, EmployeeFn fn) { for (int index = 0; index < RECORD_COUNT; index++) { employees[index] = fn(employees[index]); } return employees; } private Employees employees_read(Employees employees) { employees = (Employees) calloc(RECORD_COUNT, sizeof(Employee)); if (employees == NULL) { fprintf(stderr, "employees_read: out of memory\n"); exit(EXIT_FAILURE); } return employees_map(employees, employee_read); } public Employees employees_print(Employees employees) { return employees_map(employees, employee_print); } public void employees_adjust_salary(Employees employees) { employees_map(employees, employee_adjust_salary); } public void employees_free(Employees employees) { employees_map(employees, employee_free); free(employees); } /* I/O层 */ private File file_open(String filename, String mode) { File stream = freopen(filename, mode, mode[0] == 'r'? stdin: stdout); if (stream == NULL) { fprintf(stderr, "Cannot open %s with %s mode.\n", filename, mode); exit(EXIT_FAILURE); } return stream; } private Employees file_with(String filename, String mode, Employees employees, EmployeesFn fn) { int handler = dup(mode[0] == 'r'? 0: 1); /* 克隆文件句柄 */ File stream = file_open(filename, mode); employees = fn(employees); fclose(stream); fdopen(handler, mode); /* 完成后恢复标准IO */ return employees; } public Employees employees_input(String filename) { return file_with(filename, "r", NULL, employees_read); } public void employees_output(Employees employees, String filename) { file_with(filename, "w", employees, employees_print); } /* 应用层 */ int main(void) { Employees employees = employees_input(INPUT_FILE_NAME); /* 从文件读入 */ employees_print(employees); /* 1. 输出到屏幕 */ employees_adjust_salary(employees); /* 2. 调整薪资 */ employees_print(employees); /* 3. 输出调整后的结果 */ employees_output(employees, OUTPUT_FILE_NAME); /* 4. 保存到文件 */ employees_free(employees); /* 释放资源 */ return EXIT_SUCCESS; }
这一版本改动很是大,连应用层接口都有不向下兼容的改动,因此不要忘记回归测试。
本节介绍了一个重构的黑科技——动态做用域。它颇有用,Web
系统中Session
变量就是动态做用域;但它也会加大判断代码所处上下文的难度,致使行为不易预测。好比JavaScript
中的this
是JS
中惟一一个动态做用域的变量,看看社区对this
的抱怨就知道它的可怕了,它的值由函数的调用方决定,很难预测后续的系统维护者会把这个函数绑定到哪一个对象上。
简言之,动态有风险,入坑需谨慎!
前文都在讨论如何让代码变得更抽象、更加可维护,但到底有没有取得指望的效果,须要一个例子来证实。
以前的版本中,职员列表对象采用的底层存储方案是固定长度为4
的数组结构,若是将来"work.txt"
文件中的记录数不固定,但愿把底层的数据结构从数组改为更合适的单链表结构。这个需求是底层数据结构的改造,理论上与应用层无关,相似从MySQL
迁移到Oracle
,理论上至多只能影响持久层代码,业务逻辑层等不相关的代码是不该该有任何修改的。因此,先评估一下这个需求涉及的变动点:
struct _Employees
必然发生变化。employees_read
也会发生变化。employees_print
也会变化。employees_map
。除了以上四点,其余任何与数据结构自己无关的代码都不该该发生变化。因此,代码重构完并经过测试以后,若是全部的改动范围确实只出如今上述四点中,证实前文全部的改造有效——只改动与需求相关的代码段;不然,证实代码抽象程度依旧不够,一段代码中还耦合着多个业务逻辑,依旧牵一发动全身。
最终重构后的完整代码以下,改造过程此处就再也不详述,你们能够一块儿动手试着重构看看。
#include <stdlib.h> #include <stdio.h> #include <unistd.h> #define private static #define public #define FILE_NAME "work.txt" #define INPUT_FILE_NAME FILE_NAME #define OUTPUT_FILE_NAME FILE_NAME typedef FILE *File; typedef char* String; /* 职员对象 */ typedef struct _Employee { char name[8]; int age; int salary; } *Employee; typedef Employee (*EmployeeFn)(Employee); private Employee employee_free(Employee employee) { free(employee); return NULL; } private Employee employee_read(Employee employee) { employee = (Employee) calloc(1, sizeof(struct _Employee)); if (employee == NULL) { fprintf(stderr, "employee_read: out of memory\n"); exit(EXIT_FAILURE); } if (scanf("%s%d%d", employee->name, &employee->age, &employee->salary) != 3) { employee_free(employee); return NULL; } return employee; } private Employee employee_print(Employee employee) { printf("%s %d %d\n", employee->name, employee->age, employee->salary); return employee; } private Employee employee_adjust_salary(Employee employee) { if (employee->salary < 30000) { employee->salary += 3000; } return employee; } /* 职员列表对象 */ typedef struct _Employees { Employee employee; struct _Employees *next; } *Employees; typedef Employees (*EmployeesFn)(Employees); private Employees employees_map(Employees employees, EmployeeFn fn) { for (Employees p = employees; p; p = p->next) { p->employee = fn(p->employee); } return employees; } private Employees employees_read(Employees head) { Employees tail = NULL; for (;;) { Employee employee = employee_read(NULL); if (employee == NULL) { return head; } Employees employees = (Employees) calloc(1, sizeof(Employees)); if (employees == NULL) { fprintf(stderr, "employees_read: out of memory\n"); exit(EXIT_FAILURE); } if (tail == NULL) { head = tail = employees; } else { tail->next = employees; tail = tail->next; } tail->employee = employee; } } public Employees employees_print(Employees employees) { return employees_map(employees, employee_print); } public void employees_adjust_salary(Employees employees) { employees_map(employees, employee_adjust_salary); } public void employees_free(Employees employees) { employees_map(employees, employee_free); while (employees) { Employees e = employees; employees = employees->next; free(e); } } /* I/O层 */ private File file_open(String filename, String mode) { File stream = freopen(filename, mode, mode[0] == 'r'? stdin: stdout); if (stream == NULL) { fprintf(stderr, "Cannot open %s with %s mode.\n", filename, mode); exit(EXIT_FAILURE); } return stream; } private Employees file_with(String filename, String mode, Employees employees, EmployeesFn fn) { int handler = dup(mode[0] == 'r'? 0: 1); /* 克隆文件句柄 */ File stream = file_open(filename, mode); employees = fn(employees); fclose(stream); fdopen(handler, mode); /* 完成后恢复标准IO */ return employees; } public Employees employees_input(String filename) { return file_with(filename, "r", NULL, employees_read); } public void employees_output(Employees employees, String filename) { file_with(filename, "w", employees, employees_print); } /* 应用层 */ int main(void) { Employees employees = employees_input(INPUT_FILE_NAME); /* 从文件读入 */ employees_print(employees); /* 1. 输出到屏幕 */ employees_adjust_salary(employees); /* 2. 调整薪资 */ employees_print(employees); /* 3. 输出调整后的结果 */ employees_output(employees, OUTPUT_FILE_NAME); /* 4. 保存到文件 */ employees_free(employees); /* 释放资源 */ return EXIT_SUCCESS; }
首先执行check.sh
检查功能是否正确,而后执行diff
检查修改点是否有超出预期。
本文对代码作了屡次迭代,介绍如何使用面向对象、函数式编程、动态做用域等方法不断抽象其中重复的代码。经过这个过程,能够看到面向对象编程和函数式编程二者并不是对立,都是为了提升代码的抽象,能够相辅相成:
本文只是抛砖引玉,并非标准答案,因此并非要求后续全部的代码都要抽象多少次才能提交。所以,首次交付出去的代码,到底要到达第几版本,这个问题留给你们本身思考。
在说再见以前,再分享两个关于识别重复、抽象重用的tips。
编码规范在不少地方被反复强调,也特别容易引起圣战(如花括号的位置);在我看来,编码规范最大的价值是便于发现代码中的重复!
编程语言自己或多或少会有一些约束,例如文件必须先open
再close
,这类问题通常不容易出现不一致;更多的问题并不会在语言层面作约束,例如if else
中异常处理是放在if
代码块中仍是else
,这类问题没有标准答案,公说公有理婆说婆有理。编程规范用于解决第二类问题:TOOWTDI(There is Only One Way To Do It)。
只有统一才能清晰,清晰的代码不必定是短的代码,但啰嗦的代码必定是不清晰的,勿忘清晰是重构的基础。
开始重构时,切记重构的元素必定要从小到大!
就像文章的元素,从单词、句子、段落依次递增,重构时也应遵循从小到大的原则,依次解决重复的常量/变量、语句、代码块、函数、类、库……发现重复不能只浮于表面相同,得理解其背后的意义,只有后续须要一块儿变化的重复才是真正的重复。从小到大的重构顺序能帮助理解每个重复的细节,而反之却容易致使忽略这些背后的细节。
还记得"work.txt"
这个重复的文件名吗?若是采用从大到小的重构顺序,极有可能立刻抽象了一个重用的file_open
,把文件名写死在这个公共函数里。这样作的确解决了重复问题,整段代码只有这一处出现"work.txt"
;可是一旦输入输出的文件名变得不一样,这个公共函数只能弃用。
本文第九版的代码远不是完美的代码,还存在很多重复:
employee_read
和employees_read
中都用到calloc
分配内存空间,并检查是否分配成功。employees_print
之于employee_print
和employees_adjust_salary
之于employee_adjust_salary
,区别只是前者名称多了一个s
,是否有可能根据这个规则自动为Employees
生成与Employee
一一对应的函数?试试有什么办法继续抽象。第二个问题是让代码生成代码,给个提示,能够用“宏”。
从函数式风格重构的过程当中能体会到,若是C
语言能支持动态类型,就没必要在employee_read
中作强制转换;若是C
语言支持匿名函数,亦不用写这么多小函数;若是C
语言除了能读入整型、字符串等基础类型,还能直接读入数组、结构体等复合类型,就无需employee_read
和employee_print
等输入输出函数……
其实许多编程语言(如Python
、Ruby
、Lisp
等)已经让这些“若是”变成现实!让看看Common Lisp
的解决方案:
;; 从文件读入 (defparameter employees (with-open-file (file #P"work.lisp") ; 内置文件环绕包装 (read file))) ; 内置读取列表等复杂结构 ;; 1. 输出到屏幕 (print employees) ; 内置输出列表等复杂结构 ;; 2. 调整薪资 (dolist (employee employees) (if (< (third employee) 30000) (incf (third employee) 3000))) ; 就地修改 ;; 3. 输出调整后的结果 (print employees) ;; 4. 保存到文件 (with-open-file (file #P"work.lisp" :direction :output) (print employees file)) ; print是多态函数,file取代默认标准输出流
其中work.lisp
的内容是:
((William 35 25000) (Kishore 41 35000) (Wallace 37 30000) (Bruce 39 29999))
数据文件的格式是Common Lisp
的列表结构,Lisp
支持直接从流中读取sexp
复杂结构,犹如JavaScript
直接读写JSON
结构数据。