重复代码、长函数与大类的优化
# 重复代码、长函数与大类的优化
# 重复代码
时至今日,很多初级程序员写代码依然规避不了复制粘贴,基本的做法就是把一段代码复制过来,改动几个地方,然后,跑一下没有太大问题就万事大吉了。殊不知,这种做法就是在给未来挖坑。
通常情况下,只要这些复制代码其中有一点逻辑要修改,就意味着所有复制粘贴的地方都要修改。所以,我们在实际的项目中,常常看见这样的情况:明明是一个简单的需求,你却需要改很多的地方,需要花费很长的时间,结果无论是项目经理,还是产品经理,对进度都很不满意。
更可怕的是,只要你少改了一处,就意味着留下一处潜在的问题。问题会在不经意间爆发出来,让人陷入难堪的境地。
复制粘贴是最容易产生重复代码的地方,所以,一个最直白的建议就是,不要使用复制粘贴。真正应该做的是,先提取出函数,然后,在需要的地方调用这个函数。
其实,复制粘贴的重复代码是相对容易发现的,但有一些代码是有类似的结构,这也是重复代码,有些人对这类坏味道却视而不见。
# 重复的结构
我们看一下下面的几段代码:
@Task
public void sendBook() {
try {
this.service.sendBook();
} catch (Throwable t) {
this.notification.send(new SendFailure(t)));
throw t;
}
}
@Task
public void sendChapter() {
try {
this.service.sendChapter();
} catch (Throwable t) {
this.notification.send(new SendFailure(t)));
throw t;
}
}
@Task
public void startTranslation() {
try {
this.service.startTranslation();
} catch (Throwable t) {
this.notification.send(new SendFailure(t)));
throw t;
}
}
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
这三段函数业务的背景是:一个系统要把作品的相关信息发送给翻译引擎。所以,结合着代码,我们就不难理解它们的含义,sendBook 是把作品信息发出去,sendChapter 就是把章节发送出去,而 startTranslation 则是启动翻译。
这几个业务都是以后台的方式在执行,所以,它们的函数签名上增加了一个 Task 的 Annotation,表明它们是任务调度的入口。然后,实际的代码执行放到了对应的业务方法上,也就是 service 里面的方法。
这三个函数可能在许多人看来已经写得很简洁了,但是,这段代码的结构上却是有重复的,请把注意力放到 catch 语句里。
之所以要做一次捕获(catch),是为了防止系统出问题无人发觉。捕获到异常后,我们把出错的信息通过即时通讯工具发给相关人等,代码里的 notification.send 就是发通知的入口。相比于原来的业务逻辑,这个逻辑是后来加上的,所以,这段代码的作者不厌其烦地在每一处修改了代码。
我们可以看到,虽然这三个函数调用的业务代码不同,但它们的结构是一致的,其基本流程可以理解为:
当你能够发现结构上的重复,我们就可以把这个结构提取出来。从面向对象的设计来说,就是提出一个接口,就像下面这样:
private void executeTask(final Runnable runnable) {
try {
runnable.run();
} catch (Throwable t) {
this.notification.send(new SendFailure(t)));
throw t;
}
}
2
3
4
5
6
7
8
有了这个结构,前面几个函数就可以用它来改写了。对于支持函数式编程的程序设计语言来说,可以用语言提供的便利写法简化代码的编写,像下面的代码就是用了 Java 里的方法引用(Method Reference):
@Task
public void sendBook() {
executeTask(this.service::sendBook);
}
@Task
public void sendChapter() {
executeTask(this.service::sendChapter);
}
@Task
public void startTranslation() {
executeTask(this.service::startTranslation);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
经过这个例子的改写,如果再有一些通用的结构调整,比如,在任务执行前后要加上一些日志信息,这样的改动就可以放到 executeTask 这个函数里,而不用四处去改写了。
这个例子并不复杂,关键点在于,能不能发现结构上的重复。因为相比于直接复制的代码,结构上的重复看上去会有一些迷惑性。比如,在这个例子里,发送作品信息、发送章节、启动翻译看起来是三件不同的事,很难让人一下反应过来它也是重复代码。
一般来说,参数是名词,而函数调用,是动词。我们传统的程序设计教育中,对于名词是极度重视的,但我们必须认识到一点,动词也扮演着重要的角色,尤其是在函数式编程兴起之后。那你就需要知道,动词不同时,并不代表没有重复代码产生。
理解到这一点,我们就容易发现结构上的相似之处。比如在上面的例子中,发送作品信息、发送章节、启动翻译之所以看上去是三件不同的事,只是因为它们的动词不同,但是除了这几个动词之外的其它部分是相同的,所以,它们在结构上是重复的。
# 做真正的选择
我们再来看一段代码:
if (user.isEditor()) {
service.editChapter(chapterId, title, content, true);
} else {
service.editChapter(chapterId, title, content, false);
}
2
3
4
5
这是一段对章节内容进行编辑的代码。这里有一个业务逻辑,章节只有在审核通过之后,才能去做后续的处理,比如,章节的翻译。所以,这里的 editChapter 方法最后那个参数表示是否审核通过。
在这段代码里面,目前的处理逻辑是,如果这个章节是由作者来编辑的,那么这个章节是需要审核的,如果这个章节是由编辑来编辑的,那么审核就直接通过了,因为编辑本身同时也是审核人。不过,这里的业务逻辑不是重点,只是帮助你理解这段代码。
问题来了,这个 if 选择的到底是什么呢?
相信你和我一样,第一眼看到这段代码的感觉一定是,if 选择的一定是两段不同的业务处理。但只要你稍微看一下,就会发现,if 和 else 两段代码几乎是一模一样的。在经过仔细地“找茬”之后,才能发现,原来是最后一个参数不一样。
只有参数不同,是不是和前面说的重复代码是如出一辙的?没错,这其实也是一种重复代码。
只不过,这种重复代码通常情况下是作者自己写出来的,而不是粘贴出来的。因为作者在写这段代码时,脑子只想到 if 语句判断之后要做什么,而没有想到这个 if 语句判断的到底是什么。但这段代码客观上也造就了重复。
写代码要有表达性。把意图准确地表达出来,是写代码过程中非常重要的一环。显然,这里的 if 判断区分的是参数,而非动作。所以,我们可以把这段代码稍微调整一下,会让代码看上去更容易理解:
boolean approved= user.isEditor();
service.editChapter(chapterId, title, content, approved);
2
请注意,这里我把 user.isEditor() 判断的结果赋值给了一个 approved 的变量,而不是直接作为一个参数传给 editChapter,这么做也是为了提高这段代码的可读性。因为 editChapter 最后一个参数表示的是这个章节是否审核通过。通过引入 approved 变量,我们可以清楚地看到,一个章节审核是否通过的判断条件是“用户是否是一个编辑”,这种写法会让代码更清晰。
如果将来审核通过的条件改变了,变化的点全都在 approved 的这个变量的赋值上面。如果你追求更有表达性的做法,甚至可以提取一个函数出来,这样,就把变化都放到这个函数里了,就像下面这样:
boolean approved = isApproved(user);
service.editChapter(chapterId, title, content, approved);
private boolean isApproved(final User user) {
return user.isEditor();
}
2
3
4
5
6
为了说明问题,我特意选择了一段简单的代码,if 语句的代码块里只有一个语句。在实际的工作中,if 语句没有有效地去选择目标是经常出现的,有的是参数列表比较长,有的是在 if 的代码块里有多个语句。
所以,只要你看到 if 语句出现,而且 if 和 else 的代码块长得又比较像,多半就是出现了这个坏味道。如果你不想所有人都来玩“找茬”游戏,赶紧消灭它。
# 重复是一个泥潭
重复是一个泥潭,对于程序员来说,时刻提醒自己不要重复是至关重要的。在软件开发里,有一个重要的原则叫做 Don’t Repeat Yourself(不要重复自己,简称 DRY),我在《[软件设计之美]》中也讲到过它,而更经典的叙述在《[程序员修炼之道]》中。
在一个系统中,每一处知识都必须有单一、明确、权威地表述。
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
写代码要想做到 DRY,一个关键点是能够发现重复。
# 不可避免的长函数?
只要一提到长函数,无论是去被迫理解一个长函数的含义,还是要在一个长函数中,小心翼翼地找出需要的逻辑,按照需求微调一下,几乎所有程序员都会有不愉悦的回忆。可以这么说,没有人喜欢长函数,但在实际工作中,却不得不去与各种长函数打交道。
不知道你在实际工作中遇到最长的函数有多长,几百上千行的函数肯定是不足以称霸的。在我的职业生涯中,经常是我以为自己够见多识广了,但只要新接触到一个有悠久历史的代码库,就总会有突破认知的长函数出现。
# 长函数的产生
比如下面这段代码(如果你不想仔细阅读,可以直接跳到后面):
public void executeTask() {
ObjectMapper mapper = new ObjectMapper();
CloseableHttpClient client = HttpClients.createDefault();
List<Chapter> chapters = this.chapterService.getUntranslatedChapters();
for (Chapter chapter : chapters) {
SendChapterRequest sendChapterRequest = new SendChapterRequest();
sendChapterRequest.setTitle(chapter.getTitle());
sendChapterRequest.setContent(chapter.getContent());
HttpPost sendChapterPost = new HttpPost(sendChapterUrl);
CloseableHttpResponse sendChapterHttpResponse = null;
String chapterId = null;
try {
String sendChapterRequestText = mapper.writeValueAsString(sendChapterRequest);
sendChapterPost.setEntity(new StringEntity(sendChapterRequestText));
sendChapterHttpResponse = client.execute(sendChapterPost);
HttpEntity sendChapterEntity = sendChapterPost.getEntity();
SendChapterResponse sendChapterResponse = mapper.readValue(sendChapterEntity.getContent(), SendChapterResponse.class);
chapterId = sendChapterResponse.getChapterId();
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
if (sendChapterHttpResponse != null) {
sendChapterHttpResponse.close();
}
} catch (IOException e) {
}
}
HttpPost translateChapterPost = new HttpPost(translateChapterUrl);
CloseableHttpResponse translateChapterHttpResponse = null;
try {
TranslateChapterRequest translateChapterRequest = new TranslateChapterRequest();
translateChapterRequest.setChapterId(chapterId);
String translateChapterRequestText = mapper.writeValueAsString(translateChapterRequest);
translateChapterPost.setEntity(new StringEntity(translateChapterRequestText));
translateChapterHttpResponse = client.execute(translateChapterPost);
HttpEntity translateChapterEntity = translateChapterHttpResponse.getEntity();
TranslateChapterResponse translateChapterResponse = mapper.readValue(translateChapterEntity.getContent(), TranslateChapterResponse.class);
if (!translateChapterResponse.isSuccess()) {
logger.warn("Fail to start translate: {}", chapterId);
}
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
if (translateChapterHttpResponse != null) {
try {
translateChapterHttpResponse.close();
} catch (IOException e) {
}
}
}
}
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
这段代码的逻辑是,把没有翻译过的章节发到翻译引擎,然后,启动翻译过程。在这里翻译引擎是另外一个服务,需要通过 HTTP 的形式向它发送请求。相对而言,这段代码还算直白,当你知道了我上面所说的逻辑,你是很容易看懂这段代码的。
这段代码之所以很长,主要原因就是把前面所说的逻辑全部平铺直叙地摆在那里了,这里既有业务处理的逻辑,比如,把章节发送给翻译引擎,然后,启动翻译过程;又有处理的细节,比如,把对象转成 JSON,然后,通过 HTTP 客户端发送出去。
从这段代码中,我们可以看到平铺直叙的代码存在的两个典型问题:
把多个业务处理流程放在一个函数里实现;
把不同层面的细节放到一个函数里实现。
这里发送章节和启动翻译是两个过程,显然,这是可以放到两个不同的函数中去实现的,所以,我们只要做一下提取函数,就可以把这个看似庞大的函数拆开,而拆出来的几个函数规模都会小很多,像下面这样:
public void executeTask() {
ObjectMapper mapper = new ObjectMapper();
CloseableHttpClient client = HttpClients.createDefault();
List<Chapter> chapters = this.chapterService.getUntranslatedChapters();
for (Chapter chapter : chapters) {
String chapterId = sendChapter(mapper, client, chapter);
translateChapter(mapper, client, chapterId);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
拆出来的部分,实际上就是把对象打包发送的过程,我们以发送章节为例,先来看拆出来的发送章节部分:
private String sendChapter(final ObjectMapper mapper,
final CloseableHttpClient client,
final Chapter chapter) {
SendChapterRequest request = asSendChapterRequest(chapter);
CloseableHttpResponse response = null;
String chapterId = null;
try {
HttpPost post = sendChapterRequest(mapper, request);
response = client.execute(post);
chapterId = asChapterId(mapper, post);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
if (response != null) {
response.close();
}
} catch (IOException e) {
}
}
return chapterId;
}
private HttpPost sendChapterRequest(final ObjectMapper mapper, final SendChapterRequest sendChapterRequest) throws JsonProcessingException, UnsupportedEncodingException {
HttpPost post = new HttpPost(sendChapterUrl);
String requestText = mapper.writeValueAsString(sendChapterRequest);
post.setEntity(new StringEntity(requestText));
return post;
}
private String asChapterId(final ObjectMapper mapper, final HttpPost sendChapterPost) throws IOException {
String chapterId;
HttpEntity entity = sendChapterPost.getEntity();
SendChapterResponse response = mapper.readValue(entity.getContent(), SendChapterResponse.class);
chapterId = response.getChapterId();
return chapterId;
}
private SendChapterRequest asSendChapterRequest(final Chapter chapter) {
SendChapterRequest request = new SendChapterRequest();
request.setTitle(chapter.getTitle());
request.setContent(chapter.getContent());
return request
}
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
37
38
39
40
41
42
43
44
45
46
47
48
49
当然,这个代码还算不上已经处理得很整洁了,但至少同之前相比,已经简洁了一些。我们只用了最简单的提取函数这个重构手法,就把一个大函数拆分成了若干的小函数。
顺便说一下,长函数往往还隐含着一个命名问题。如果你看修改后的 sendChapter,其中的变量命名明显比之前要短,理解的成本也相应地会降低。因为变量都是在这个短小的上下文里,也就不会产生那么多的命名冲突,变量名当然就可以写短一些。
平铺直叙的代码,一个关键点就是没有把不同的东西分解出来。如果我们用设计的眼光衡量这段代码,这就是“分离关注点”没有做好,把不同层面的东西混在了一起,既有不同业务混在一起,也有不同层次的处理混在了一起。我在《软件设计之美》专栏中,也曾说过,关注点越多越好,粒度越小越好。
# 日积月累下的长函数
有时,一段代码一开始的时候并不长,就像下面这段代码,它根据返回的错误进行相应地错误处理:
if (code == 400 || code == 401) {
}
2
3
然后,新的需求来了,增加了新的错误码,它就变成了这个样子:
if (code == 400 || code == 401 || code == 402) {
}
2
3
你知道,一个有生命力的项目经常会延续很长时间,于是,这段代码有很多次被修改的机会,日积月累,它就成了让人不忍直视的代码,比如:
if (code == 400 || code == 401 || code == 402 || ...
|| code == 500 || ...
|| ...
|| code == 10000 || ...) {
}
2
3
4
5
6
7
8
9
后来人看到这段代码就想骂人了。当他从版本控制的历史中找到这些代码的作者,去询问这些处理的来龙去脉时,每个人其实都很委屈,他们当时也没做太多,只是加了一个判断条件而已。
**任何代码都经不起这种无意识的累积,每个人都没做错,但最终的结果很糟糕。**对抗这种逐渐糟糕腐坏的代码,我们需要知道“童子军军规”:
Robert Martin 把它借鉴到了编程领域,简言之,我们应该看看自己对于代码的改动是不是让原有的代码变得更糟糕了,如果是,那就改进它。但这一切的前提是,你要能看出自己的代码是不是让原有的代码变得糟糕了,所以,学习代码的坏味道还是很有必要的。
至此,我们看到了代码变长的几种常见原因:
你会发现,代码变长根本是一个无意识的问题,写代码的人没有觉得自己把代码破坏了。但只要你认识到长函数是一个坏味道,后面的许多问题就自然而然地会被发掘出来,至于解决方案,你已经看到了,大部分情况下,就是拆分成各种小函数。
如果遇到这种每次加一点,日期长了就变成长函数的情况,该怎么做呢?
比如:将多出的变化条件,封装成一个对象,每种条件对应一个属性,每个实现逻辑对应一个函数。这样就避免了在if-else中加判断条件了。可以参考下设计模式中的策略模式。
总之,遇到这种可能变化的情况,一定要多思考下,千万别一上来就加判断条件。
# 大类
一听到大类,估计你的眼前已经浮现出一片无边无际的代码了。类之所以成为了大类,一种表现形式就是我们上节课讲到的长函数,一个类只要有几个长函数,那它就肯定是一眼望不到边了(长函数的话题,我们上一讲已经讨论过了,这里就不再赘述了)。
大类还有一种表现形式,类里面有特别多的字段和函数,也许,每个函数都不大,但架不住数量众多啊,这也足以让这个类在大类中占有一席之地。这一讲,我们就主要来说说这种形式的大类。
# 分模块的程序
我先来问你一个问题,为什么不把所有的代码都写到一个文件里?
你可能会觉得这个问题很傻,心里想:除了像练习之类的特定场景,谁会在一个正经的项目上把代码写到一个文件里啊?
没错,确实没有人这么做,但你思考过原因吗?把代码都写到一个文件里,问题在哪里呢?
事实是,把代码写到一个文件里,一方面,相同的功能模块没有办法复用;另一方面,也是更关键的,把代码都写到一个文件里,其复杂度会超出一个人能够掌握的认知范围。简言之,一个人理解的东西是有限的,没有人能同时面对所有细节。
人类面对复杂事物给出的解决方案是分而治之。所以,我们看到几乎各种程序设计语言都有自己的模块划分方案,从最初的按照文件划分,到后来,使用面向对象方案按照类进行划分,本质上,它们都是一种模块划分的方式。这样,人们面对的就不再是细节,而是模块,模块的数量显然会比细节数量少,人们的理解成本就降低了。
好,你现在已经理解了,对程序进行模块划分,本质上就是在把问题进行分解,而这种做法的背后原因,就是人类的认知能力是有限的。
理解了这一点,我们再回过头来看大类这个坏味道,你就知道问题出在哪了。如果一个类里面的内容太多,它就会超过一个人的理解范畴,顾此失彼就在所难免了。
按照这个思路,解决大类的方法也就随之而来了,就是把大类拆成若干个小类。你可能会想,这我也知道,问题是,怎么拆呢?
# 大类的产生
想要理解怎么拆分一个大类,我们需要知道,这些类是怎么变成这么大的。
最容易产生大类的原因在于职责的不单一。我们先来看一段代码:
public class User {
private long userId;
private String name;
private String nickname;
private String email;
private String phoneNumber;
private AuthorType authorType;
private ReviewStatus authorReviewStatus;
private EditorType editorType;
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这个 User 类拥有着一个大类的典型特征,其中包含着一大堆的字段。面对这样一个类时,我们要问的第一个问题就是,这个类里的字段都是必需的吗?
我们来稍微仔细地看一下这个类,用户 ID(userId)、姓名(name)、昵称(nickname) 之类应该是一个用户的基本信息,后面的邮箱(email)、电话号码(phoneNumber) 也算是和用户相关联的。今天的很多应用都提供使用邮箱或电话号码登录的方式,所以,这个信息放在这里,也算是可以理解。
再往后看,作者类型(authorType),这里表示作者是签约作者还是普通作者,签约作者可以设置作品的付费信息,而普通作者不能。后面的字段是作者审核状态(authorReviewStatus),就是说,作者成为签约作者,需要有一个申请审核的过程,这个状态就是审核的状态。
再往后,又出现了一个编辑类型(editorType),编辑可以是主编,也可以是小编,他们的权限是不一样的。
这还不是这个 User 类的全部。但是,即便只看这些内容,也足以让我们发现一些问题了。
首先,普通的用户既不是作者,也不是编辑。作者和编辑这些相关的字段,对普通用户来说,都是没有意义的。其次,对于那些成为了作者的用户,编辑的信息意义也不大,因为作者是不能成为编辑的,反之亦然,编辑也不会成为作者,作者信息对成为编辑的用户也是没有意义的。
在这个类的设计里面,总有一些信息对一部分人是没有意义,但这些信息对于另一部分人来说又是必需的。之所以会出现这样的状况,关键点就在于,这里只有“一个”用户类。
普通用户、作者、编辑,这是三种不同角色,来自不同诉求的业务方关心的是不同的内容。只是因为它们都是这个系统的用户,就把它们都放到用户类里,造成的结果就是,任何业务方的需求变动,都会让这个类反复修改。这种做法实际上是违反了单一职责原则。
回到我们这个类上,其实,我们前面已经分析了,虽然这是一个类,但其实,它把不同角色关心的东西都放在了一起,所以,它变得如此庞大。我们只要把不同的信息拆分开来,问题也就迎刃而解了。下面就是把不同角色拆分出来的结果:
public class User {
private long userId;
private String name;
private String nickname;
private String email;
private String phoneNumber;
...
}
public class Author {
private long userId;
private AuthorType authorType;
private ReviewStatus authorReviewStatus;
...
}
public class Editor {
private long userId;
private EditorType editorType;
...
}
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
37
这里,我们拆分出了 Author 和 Editor 两个类,把与作者和编辑相关的字段分别移到了这两个类里面。在这两个类里面分别有一个 userId 字段,用以识别这个角色是和哪个用户相关。这个大 User 类就这样被分解了。
大类的产生往往还有一个常见的原因,就是字段未分组。
有时候,我们会觉得有一些字段确实都是属于某个类,结果就是,这个类还是很大。比如,我们看一下上面拆分的结果,那个新的 User 类:
public class User {
private long userId;
private String name;
private String nickname;
private String email;
private String phoneNumber;
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
前面我们分析过,这些字段应该都算用户信息的一部分。但是,即便相比于原来的 User 类小了许多,这个类依然也不算是一个小类,原因就是,这个类里面的字段并不属于同一种类型的信息。比如,userId、name、nickname 几项,算是用户的基本信息,而 email、phoneNumber 这些则属于用户的联系方式。
从需求上看,基本信息是那种一旦确定就不怎么会改变的内容,而联系方式则会根据实际情况调整,比如,绑定各种社交媒体的账号。所以,如果我们把这些信息都放到一个类里面,这个类的稳定程度就要差一些。所以,我们可以根据这个理解,把 User 类的字段分个组,把不同的信息放到不同的类里面。
public class User {
private long userId;
private String name;
private String nickname;
private Contact contact;
...
}
public class Contact {
private String email;
private String phoneNumber;
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这里我们引入了一个 Contact 类(也就是联系方式),把 email 和 phoneNumber 放了进去,后面再有任何关于联系方式的调整就都可以放在这个类里面。经过这次调整,我们把不同的信息重新组合了一下,但每个类都比原来要小。
对比一下,如果说前后两次拆分有什么不同,那就是:前面是根据职责,拆分出了不同的实体,后面是将字段做了分组,用类把不同的信息分别做了封装。
或许你已经发现了,所谓的将大类拆解成小类,本质上在做的工作是一个设计工作。我们分解的依据其实是单一职责这个重要的设计原则。没错,很多人写代码写不好,其实是缺乏软件设计的功底,不能有效地把各种模型识别出来。所以,想要写好代码,还是要好好学学软件设计的。
当然,对于上述这种情况,还可以使用抽离公共属性的方式将大类变小,而之后,每个不同的子类通过继承的方式获得父类属性。