从重复到重用

从重复到重用前言本文是我之前写的文章——《你试过这样写C程序吗》——的第二版,并把文章名改成更贴切的“从重复到重用”。

大家好,欢迎来到IT知识分享网。

前言

本文是我之前写的文章——《你试过这样写C程序吗》——的第二版,并把文章名改成更贴切的“从重复到重用”。

开发技术的发展,从第一次提出“函数/子程序”,实现代码级重用;到面向对象的“类”,重用数据结构与算法;再到“动态链接库”、“控件”等重用模块;到如今流行的云计算、微服务可重用整个系统。技术发展虽然日新月异,但本质都是重用,只是粒度不同。所以写代码的动机都应是把重复的工作变成可重用的方案,其中重复的工作包括业务上重复的场景、技术上重复的代码等。合格的系统可以简化当下重复的工作;优秀的系统还能预见未来重复的工作。

本文不谈框架、不谈架构,就谈写代码的那些事儿!后文始终围绕一个问题的解决方案,不断发现其中“重复”的代码,并提炼出“可重用”的抽象,持续“重构”。希望通过这个过程和大家分享一些发现重复代码和提炼可重用抽象的方法。

问题

作为贯穿全文的主线,这有一个任务需要开发一个程序来完成:有一份存有职员信息(姓名、年龄、工资)的文件“work.txt”,内容如下:

William 35 25000 Kishore 41 35000 Wallace 37 30000 Bruce 39 29999 
  1. 要求从文件(work.txt)中读取员工薪酬,并输出到屏幕上。
  2. 为所有工资小于三万的员工涨 3000 元。
  3. 在屏幕上输出薪资调整后的结果。
  4. 把调整后的结果保存到原始文件。

即运行的结果是屏幕上要有八行输出,“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”。

第一部分:可维护代码

第一版:It works

每位熟练的程序员都能快速地给出自己的实现。本文示例代码使用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语言课堂作业的答案,看起来还不错——至少缩进一致,也没混用空格和制表符;但从软件工程的角度来讲,它简直糟糕透了,因为没有清晰的表达意图:

  1. 魔法常量4重复出现,后续负责维护的程序员无法判断它们是碰巧相等还是有其他原因必需相等。
  2. 文件名work.txt重复出现。
  3. 重复且不清晰的文件指针类型定义,容易忽略ostream前面的*。
  4. e和i变量命名不顾名思义。
  5. 变量的定义与使用离得太远。
  6. 无异常处理,文件可能不可读。

借乔老爷子的话说:“看不见的地方也要用心做好”——这些代码的问题用户虽然看不见也不在乎,但也要用心做好——已有几处显眼的地方出现重复。不过,在代码变得清晰之前,不应急着动手去重构,因为清晰的代码更容易找出重复!针对上述意图不明的问题,准备对代码做以下调整:

  1. 确认数字4在三处的意义都是员工记录数,因此定义共享常量#define RECORD_COUNT 4。
  2. 常量”work.txt”和4不同,内容虽然相同但意义不同:一个作输入,一个作输出。如果也只简单的定义一个常量FILE_NAME共用,后续两者独立变化时,工作量并没减少。所以去除重复代码时,切忌只看表面相同,背后意义相同的才是真正的相同,否则就像给所有常量1定义ONE别名一样没有意义。所以需要定义三个常量FILE_NAME、INPUT_FILE_NAME和OUTPUT_FILE_NAME。
  3. 用自定义的文件类型typedef FILE* File;替代FILE*,可避免遗漏指针。
  4. 变量e是所有职员信息,把变量名改成employees。
  5. 变量i是迭代过程的下标,把变量名改成index。
  6. 将index变量定义放到for语句中。
  7. 将File变量定义从顶部挪到各自使用之前的位置。
  8. 对文件指针做异常检查,当文件无法打开时输出错误信息并提前终止程序。
  9. 程序退出时用<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,这其中还耦合了两个功能:

  1. Employee序列化成字符串。
  2. 序列化结果输出到指定文件流。

因为暂无独立使用某项功能的场景,目前无需进一步拆分:

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, { ... }); 

和普通函数调用相比,唯一不同的是允许花括号包围的代码片段作为参数。因此,若编程语言允许代码作为函数的参数,那就能自定义新的控制语句!这句话隐含了两个语言特性:

  1. 代码是一种数据类型。
  2. 代码类型的数据可作为函数的参数。

所有编程语言都包含一套类型系统,它决定数据的类型,而数据的类型又决定数据的功能。例如,数值类型可以做四则运算;字符串类型的数据可以拼接、查找、替换等;代码如果也是一种数据类型,就可以随时“执行”它。C语言中具备“执行”能力的元素就是“函数”,函数之于代码类型,犹如int、double之于数值类型,都只是C这个特定编程语言对特定类型的特定实现,换成Visual Basic改叫“过程”,换成Java又称作“成员方法”。

至于特性#2,它正是函数式编程的本质!提到函数式风格,脑海中通常会闪过一些耳熟能详的词汇:无副作用、无状态、易于并行编程,甚至是Lisp那扭曲的前缀表达式。追根溯源,函数式编程源自λ演算——函数能作为值传递给其他函数或由其他函数返回——其本质是函数作为类型系统中的“第一等公民”(First-Class),符合以下四项要求:

  1. 可以用变量命名。
  2. 可以提供给过程作为参数。
  3. 可以由过程作为结果返回。
  4. 可以包含在数据结构中。

对照之下会惊讶地发现,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检查修改点是否有超出预期。

总结

本文对代码做了多次迭代,介绍如何使用面向对象、函数式编程、动态作用域等方法不断抽象其中重复的代码。通过这个过程,可以看到面向对象编程和函数式编程两者并非对立,都是为了提高代码的抽象,可以相辅相成:

  1. 函数式编程重点是增强类型系统:常见的数据类型有数值型、字符串型等,函数式编程要求函数也是一种数据类型,即代码也是一种数据。
  2. 面向对象风格侧重于代码的组织形式:把数据和操作数据的函数组织在类中,提高内聚;对象之间通过调用开放的接口通讯,降低耦合。

本文只是抛砖引玉,并不是标准答案,所以并不是要求后续所有的代码都要抽象多少次才能提交。因此,首次交付出去的代码,到底要到达第几版本,这个问题留给大家自己思考。

在说再见之前,再分享两个关于识别重复、抽象重用的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一一对应的函数?
  • ……

试试有什么办法继续抽象。第二个问题是让代码生成代码,给个提示,可以用“宏”。

附录I:Common Lisp的解决方案

从函数式风格重构的过程中能体会到,如果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结构数据。

作者:redraiment

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://yundeesoft.com/84126.html

(0)

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信