抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

今日、海を見た。もう怖くない

本次实验的要求如下:

实验名称: 重构实验

实验目的

  1. 理解重构在软件开发中的作用

  2. 熟悉常见的代码环味道和重构方法

实验内容和要求

  1. 阅读:Martin Fowler 《重构-改善既有代码的设计》

  2. 掌握你认为最常见的8种代码坏味道及其重构方法

  3. 从你过去写过的代码或 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()
{
// ...pre-process
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;
}
// ...post-process
}

虽然分支不长,但是当不同进程需要做的事情明显不同,且包括很多行代码的时候,这样写就会非常的不优雅;根据上面提到的分解表达式方法,我们可以对上述的代码做出如下重构:

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()
{
// ...pre-process
pid_t son = fork();
son ? fatherProcess() : sonProcess();
// ...post-process
}

这样,当父子进程的工作更多,更复杂的时候,也能保证一定程度的可读性。

过大的类

若类中的实例变量过多,一般可以通过提取新类(组件)或创建子类解决;若代码较多,也可以提取共用“接口”,将类对于这些方法的使用具体到接口中;

特别地,如果这是一个 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:

// ...constructor
// ...getter & setter

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:

// ...constructor
// ...getter & setter

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 获得排序的结果(当然,这里是直接在绑定的数组上进行操作)。

体会

很多代码都可以采用更好的设计模式、重构策略进行重构;但是策略也不是万金油:很多时候采用较长/较短的类,使用参数列表还是参数对象,更多是取决于项目属性,数据意义,而不是所谓的策略;

评论