本次实验的要求如下:
实验名称 : 重构实验
实验目的 :
理解重构在软件开发中的作用
熟悉常见的代码环味道和重构方法
实验内容和要求 :
阅读:Martin Fowler 《重构-改善既有代码的设计》
掌握你认为最常见的8种代码坏味道及其重构方法
从你过去写过的代码或 Github 等开源代码库上寻找这8种坏味道,并对代码进行重构
简单的说就是总结出 8 中“坏味道”,并且给出样例;
重复代码 它可能出现在下面的三种情况中;每种情况有对应的改正方法:
下面的样例说明了在同一个类中的重复代码的重构情况
实例 下面,在某类中有两个需要随机数的地方,需要使用 C++ 随机数生成器得到随机数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 void methodA () { uniform_real_distribution<double > dm (1 , 65536 ) ; random_device rd; default_random_engine rm (rd()) ; auto seed = dm(rm); } void methodB () { uniform_real_distribution<double > dm (0 , 255 ) ; random_device rd; default_random_engine rm (rd()) ; auto r = dm(rm); auto g = dm(rm); auto b = dm(rm); }
C++ 随机数生成器初始化是一个非常麻烦的工作,但是却是一个确定的事情;可以将这些代码提取到一个公共的随机数生成器生成器函数中,用函数生成符合要求的随机数生成器,使得代码更加清晰;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 auto createRandomMachine (int lb, int rb) { if (lb > rb) swap(lb, rb); uniform_real_distribution<double > dm (lb, rb) ; random_device rd; default_random_engine rm (rd()) ; return [=]()mutable {return dm(rm);}; } void methodA () { auto random = createRandomMachine(1 , 65536 ); auto seed = random (); } void methodB () { auto random = createRandomMachine(0 , 255 ); auto r = random (); auto g = random (); auto b = random (); }
这样就完成了重构;代码更加的简洁,而且random()
的调用方式也符合 C 语言的使用习惯。
超长函数 每当需要使用注释说明函数每一步在干什么时,就将需要说明的步骤写进独立函数中,并以其用途命名;根据不同情况,可能需要做到下面的不同的程度:
一般来说,只需要将代码按照步骤提取成方法就可以了; 如果有大量参数和临时变量,考虑使用查询替换临时变量;查询时,构造参数对象或保留整个对象可以简化查询函数的参数列表; 若临时变量/参数仍然很多,可以使用方法对象来代替方法——将方法构成一个新类,保有计算需要的信息,提供一个方法接口来完成函数的工作(比如operator()
); 对于存在条件表达式和循环的情况,可以分解条件表达式:将循环体、或者时不同的分支提取成为不同的函数;主函数只控制分支流向,每个分支的具体工作交给独立函数完成。
实例 比如一个需要创建子进程的函数,使用fork
函数的返回值来判断当前所处进程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int forkNewProcess () { pid_t son = fork(); if (son) { auto ppid = getppid(); cout << "父親進程: pid = " << getpid() << " ppid = " << ppid << endl ; cout << "父親的兒子: pid = " << son << endl ; } else { auto ppid = getppid(); cout << "兒子進程: pid = " << getpid() << " ppid = " << ppid << endl ; cout << "兒子的父親: pid = " << ppid << endl ; } }
虽然分支不长,但是当不同进程需要做的事情明显不同,且包括很多行代码的时候,这样写就会非常的不优雅;根据上面提到的分解表达式方法,我们可以对上述的代码做出如下重构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 void fatherProcess () { auto ppid = getppid(); cout << "父親進程: pid = " << getpid() << " ppid = " << ppid << endl ; cout << "父親的兒子: pid = " << son << endl ; } void sonProcess () { auto ppid = getppid(); cout << "兒子進程: pid = " << getpid() << " ppid = " << ppid << endl ; cout << "兒子的父親: pid = " << ppid << endl ; } int forkNewProcess () { pid_t son = fork(); son ? fatherProcess() : sonProcess(); }
这样,当父子进程的工作更多,更复杂的时候,也能保证一定程度的可读性。
过大的类 若类中的实例变量过多,一般可以通过提取新类(组件)或创建子类解决;若代码较多,也可以提取共用“接口”,将类对于这些方法的使用具体到接口中;
特别地,如果这是一个 GUI 类(组件),可能要将业务数据和需要这些数据的方法放到一个处理业务的类中,从视图类中分离;视图类对于业务类实现观察者模式,仅保留视图必需的数据,并且和业务对象保持同步;
实例 在前端框架 React 的实际使用过程中,上述对于 GUI 类的描述则是一种比较常见的设计模式:即聪明组件和傻瓜组件的设计模式;
比如一个 React 组件,它的工作是从后台的 API 请求一个笑话,并且将它显示在用户的主页上;它可以这么写:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 export default class JokeTeller extends React.Component { state = { joke: null } render() { return ( <div> <img src={SmileFace} /> {joke || 'loading...' } </div> ) } componentDidMount() { fetch('https://icanhazdadjoke.com/', {headers: {'Accept': 'application/json'}} ).then(response => { return response.json(); }).then(json => { this.setState({joke: json.joke}); }); } }
因为只是将一个笑话显示在页面上,所以就算这么写也并没有什么;但是当这个组件需要显示的内容非常的复杂(即render
函数很大很长),并且需要从后端获得大量数据的时候,就会得到一个长的离谱的类;但是,我们可以使用上述的重构思想对这个类进行重构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const JokeShower = ({value}) => { return ( <div> <img src={SmileFace} /> {value || 'loading...' } </div> ); } export default class JokerGetter extends React.Component { state = { joke: null } render() { return <JokeShower value={this.state.joke} /> } componentDidMount() { fetch('https://icanhazdadjoke.com/', {headers: {'Accept': 'application/json'}} ).then(response => { return response.json(); }).then(json => { this.setState({joke: json.joke}); }); } }
这样,就将 React 组件类中的一个很长的部分——也就是渲染函数直接单立出去,避免了类既包含太多的渲染结构,也包含了大量的业务逻辑;至于观察者模式,React 框架已经帮我们做好了一切。
引入空对象 当代码中的多项操作需要检查一个对象是不是空对象(如果是空对象,则使用默认配置)的时候,可以为该类创建一个空对象的子类,或者创建一个包含默认设置的静态空对象;
使用一个具体的对象代替空对象,可以使得代码运行的更安全,避免意外情况的出现;
实例 比如下面这个函数,它接受一个可以是空的对象,并对它进行操作:
1 2 3 4 5 6 7 8 9 10 const request = () => { return res; } const handle = (res ) => { res ??= {}; let data = res.data ?? {err : 'system.1001' }; }
我们可以预先定义空对象,保证操作对象的函数永远获得的是一个存在的对象——当然这个对象可能实际上是一个没有意义的空对象;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const nullObject = { data : { err : 'system.1001' } } const request = () => { return res ?? nullObject; } const handle = (res ) => { let data = res.data; }
这样可以使得代码运行更加的安全,并且也避免了大量的判空工作。
狎昵关系 当两个类过分的关注彼此的私有域,可以进行如下重构:
可以移动方法、私有域来划清界限 可以就共同部分提取成一个新的公共类 可以使用隐藏委托来传递这些信息 使用以委托取代继承的方法来回避类的继承带来的问题 下面的实例使用了移动私有域的方法回避了两个类过于亲近的关系。
实例 下面是一个 C++ 图的类型,它使用了一个边的类型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 struct edge { int u, v, w, next; edge() = default ; edge(int u, int v, int w, int next) : u(u), v(v), w(w), next(next) {} }; template <int N, int M> class FWS { int head[N]; int tot; edge ee[M*2 ]; public : FWS(int n = N-1 ) { memset (head, -1 , sizeof (int )*(n+1 )); tot = 0 ; } void addedge (int u, int v, int w) { ee[tot] = edge(u,v,w,head[u]); head[u] = tot ++; ee[tot] = edge(v,u,w,head[v]); head[v] = tot ++; } void foreach (int st, const function<bool (edge&)>& func) { for (int c = head[st]; ~c; c = ee[c].next) if (!func(ee[c])) break ; } }
edge 类的 next 域完全就是为了图 FWS 类进行遍历,不应当放在 edge 类中;所以可以对于上述的代码做出如下的重构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 struct edge { int u, v, w; edge() = default ; edge(int u, int v, int w) : u(u), v(v), w(w) {} }; template <int N, int M> class FWS { int head[N]; int tot; edge ee[M*2 ]; int next[M*2 ]; public : FWS(int n = N-1 ) { memset (head, -1 , sizeof (int )*(n+1 )); tot = 0 ; } void addedge (int u, int v, int w) { ee[tot] = edge(u,v,w,head[u]); head[u] = tot ++; ee[tot] = edge(v,u,w,head[v]); head[v] = tot ++; } void foreach (int st, const function<bool (edge&)>& func) { for (int c = head[st]; ~c; c = next[c]) if (!func(ee[c])) break ; } }
这样就避免了方法FWS::foreach
频繁的访问 edge 类的私有成员变量 next;
基本类型偏执 很多时候,在小任务上使用小对象是一件好事;但是这经常被人们忽视。具体的做法如下:
可以将原本单独存在的数据值替换为对象 如果想要替换的数据值是类型码而它不影响行为,可以使用类来替换类型码 如果有与类型码相关的条件表达式,可以替换为子类或状态 如果多个字段经常共同存在,则可以提取出新公共类 如果参数列表中出现了基本类型数据,尝试替换成对象 如果从数组中挑选数据,可以使用对象来替换数组 下面的实例介绍了一种常见的数据结构的重构过程;
实例 并查集是一个很常见的数据结构;最简单的并查集可以使用一个数组和简单的递归函数实现:
1 2 3 4 5 6 int p[N];int getFather (int x, int * p) { return p[x] == x ? x : p[x] = getFather(p[x]); }
如果需要使用多个并查集,这样就显得非常乱:毕竟在getFather
之外的函数看来,p只不过是一个一般的数组。尽管getFather
只是做了一些微小的工作,但是这并不妨碍我们将它重构成一个类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 template <int N> ufs{ int p[N]; public : ufs() { for (int i = 0 ; i < N; ++ i) p[i] = i; } int getFather (int x, int * p) { return p[x] == x ? x : p[x] = getFather(p[x]); } }
每当使用并查集的时候,只需要构建一个这个的对象,也避免了大量的未知数组创建;
参数列过长 有的函数可能会带着一个长长的参数列表,以至于调用的时候甚至还需要换行;对于这种情况,可以对这个函数进行重构;具体的重构方法包括:
如果向已有的对象发出一条请求就可以取代一个参数,那应该使用方法替代参数 可以将来自于同一个对象的参数用所属的对象进行替换 如果某些数据缺乏合理的对象归属,可以为它们创建一个参数对象 但是特殊情况下,比如明显不希望这些参数之间产生某些联系,也可以将这些数据按照单独的参数处理。
实例 比如下面的代码,它是一个函数的声明;该函数接受很多的参数,来生成一个符合参数要求的注册表文件:
1 2 3 4 5 6 7 8 9 int createClsidRegFileDefault ( const char * file_path, const char * app_name, const char * clsid_main, const char * default_icon, const char * inproc_server, const char * clasid_instance = nullptr , const char * exec_options = nullptr ) ;
我们将它的参数列表提取成一个文件对象,那么这个函数的工作仅仅是将这个对象“文件化”,可以作为对象的成员函数(方法);重构之后如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class clsidFile { char * default_icon; char * app_name; char * clsid_main; char * inproc_server; char * clasid_instance; char * exec_option; public : int createRegFile (const char * file_path) ; }
因为文件路径并不是一个 CLSID 注册表项的固有成员,可以在调用的时候指定;故予以保留。
数据泥团 当在很多地方看到相同的三四项数据,例如两个类中相同的字段或是许多函数签名中相同的参数的时候,可以找出数据以字段形式出现的地方,将它们提取到公共类中;再缩减参数列表。
当删掉众多数据中的一项,如果有数据失去类意义,那么这意味着需要产生新对象(类)。
实例 比如下面的一些排序函数的声明:
1 2 3 4 5 6 7 8 9 10 void basketSort (int * array , unsigned length) ;void binaryInsertSort (int * array , unsigned length) ;void bubbleSort (int * array , unsigned length) ;void countSort (int * array , unsigned length) ;void heapSort (int * array , unsigned length) ;void insertSort (int * array , unsigned length) ;void mergeSort (int * array , unsigned length) ;void quickSort (int * array , unsigned length) ;void radixSort (int * array , unsigned length) ;void randomQuickSort (int * array , unsigned length) ;
因为所有的排序方法都是对于一个数组而言的;如果需要对一个数组使用不同的方法排序,可以将这些相同的参数提取到一个类中,并且将这些方法移动成为方法;具体重构方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class Sorter { int * array ; unsigned length; public : Sorter& bind (int * array , unsigned length) ; void basketSort () ; void binaryInsertSort () ; void bubbleSort () ; void countSort () ; void heapSort () ; void insertSort () ; void mergeSort () ; void quickSort () ; void radixSort () ; void randomQuickSort () ; }
这样就可以将一个数组作为任务对象化,之后对这个任务对象使用不同的排序方法;最后使用 getter 获得排序的结果(当然,这里是直接在绑定的数组上进行操作)。
体会 很多代码都可以采用更好的设计模式、重构策略进行重构;但是策略也不是万金油:很多时候采用较长/较短的类,使用参数列表还是参数对象,更多是取决于项目属性,数据意义,而不是所谓的策略;