前言
前面一篇文章我们介绍了一些代码坏味道,以及如何解决他们,比如命名、长函数和重复结构,本篇文章我们接着说一些常见的代码坏味道。
正文
本篇文章我们从一个大家很熟悉且讨厌的点开始,就大类。
大类
说起大类,人们就会和想到长函数一样,满满屏幕的代码,这滋味可不好受。类之所以称为了大类,一种表现形式就是前面文章所说的长函数,一个类只要有几个长函数,那么它肯定是一眼望不到边了。
大类还有一种表现形式,类里面有特别多的字段和函数,也许每个函数都不大,但是架不住数量多啊,这也是成为大类的一个主要原因。本节主要说第二种形式的大类,长函数导致的原因我们前面文章已经分析过了。
分模块
假如有人问:为什么不把所有代码都写到一个文件里?
你心里肯定觉得这个问题很傻,正经人谁会把项目代码都写在一个文件里。确实没人会这么做,把文件都写到一个文件里问题是什么呢?
一方面,相同的功能模块没法复用;另一方面,也是最关键的,把代码写在一个文件里,其复杂度会超出一个人能够掌握的认知范围。也就是说,一个人理解的东西是有限的,没有人能同时面对所有细节。
人类面对复杂事物给出的解决方案是分而治之,所以我们看到几乎各种程序设计语言都有自己的模块划分方案,从最初的按照文件划分,到后来,使用面向对象方案按照类进行划分,本质上都是一种模块划分的方式。
当模块划分得足够细,人们面对的就不是细节,而是模块,理解成本就降低了。所以这样我们再来看本节所说的坏味道,如果一个类里面的内容太多,它就会超过一个人的理解范畴,顾此失彼就在所难免了。
大类的产生
要想理解如何拆分一个大类,我们需要知道,这些大类是如何变大的。
职责不单一
最容易产生大类的原因就是职责的不单一,我来看一段代码:
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;
...
}
这个User类拥有一个大类的典型特性,其中包含了一大堆字段;面对这样一个类时,我们要问的第一个问题就是,这个类的字段都是必需的吗?
我们仔细分析一下该类,首先是用户ID(userId)、姓名(name)、昵称(nickname)之类应该是一个用户的基本信息,后面的邮箱(email)和电话(phoneNumber)用作登录方式也和用户关联,这里放入User类也好理解。
再往后看,作者类型(authorType),这里表示作者是签约作者还是普通作者,签约作者可以设置作品的付费信息,而普通作者不能;后面的字段是作者审核状态(authorReviewStatus),也就是说,作者成为签约作者,需要一个审核过程,用该字段表示。
再往后,又有一个编辑类型(editorType)字段,这是因为编辑可以是主编,也可以是小编,他们权限是不一样的。
这还没有过完所有字段,不过相信你已经发现问题了。首先普通用户既不是作者,也不是编辑,作者和编辑这些相关的字段对于普通用户来说,都是没有意义的;其次对于成为作者的用户,编辑的意义也不大,因为不会成为编辑。
在这个类的设计里面,其实有普通用户、作者、编辑这3个不同的角色,都有不同的述求方向和关心的内容,为什么放在一块,仅仅因为他们都是这个系统的用户,所以放在一个类中。这种做法,严重违反了单一职责原则。
单一职责原则非常重要,它可以让我们把模块的变化纳入考量,单一职责原则是衡量软件好坏的一把简单而有效的尺子。所以,破解大类的方法,关键就是能够把不同的职责拆分开来。
回到上面的类中,在业务上有普通用户、作者和编辑3个身份,所以我们需要把系统中默认的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;
...
}
这里根据业务需求拆分出了Author和Editor这俩个类,把作者和编辑相关的字段分别移除,而和User类的关系通过userId字段来关联,这样每个类的职责就单一了。
字段未分组
大类的产生往往还有一个常见的原因,就是字段未分组。有时候,我们会觉得一些字段确实属于某个类,结果是该类还是很大,比如上面拆分后的User类:
public class User {
private long userId;
private String name;
private String nickname;
private String email;
private String phoneNumber;
...
}
前面我们分析这些字段都是必需的,但是再仔细分析,我们可以发现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;
...
}
这里我们引入了Contact类(联系方式),把邮箱和电话号码放入了进去,之后任何联系方式相关的调整都可以放入到这个类里面。
通过调整,我们会发现类变小了,这2种拆分方式总结就是:前面是根据职责,拆分出了不同的实体;后面将字段做了分组,用类把不同的信息分别做了封装。
小结
坏味道:大类。
产生大类的原因:
- 职责不单一;
- 字段未分组。
软件设计的原则:
- 单一职责原则;
- 类写的越小越好。
长参数列表
前面我们说了大类和长函数这俩种非常容易发现的坏味道,还有一种非常容易发现的坏味道,就是长参数列表。
当一个函数参数有十几个甚至几十个时,不仅会让函数变得非常长,而且在调用函数时传递参数也是一件非常痛苦的事。
首先,我们思考一下为什么要有参数呢?我们知道,参数用于函数之间共享信息。但是函数间共享信息的方式不止一种,除了参数,最常见的就是全局变量。
在我们初学编程时,老师就说过不要使用全局变量,全局变量有非常多的不确定性,可修改地方太多,所以在日常编程中,尽量少使用全局变量。
长参数列表的问题和我们之前所说长函数和大类一样,人们能够掌握的东西有限,一旦参数过长,就很难对内容进行把控。
所以一贯思路,就是减少参数的数量,有如下方法。
参数封装成类
我们先来看一段代码:
public void createBook(final String title,
final String introduction,
final URL coverUrl,
final BookType type,
final BookChannel channel,
final String protagonists,
final String tags,
final boolean completed) {
...
Book book = Book.builder
.title(title)
.introduction(introduction)
.coverUrl(coverUrl)
.type(type)
.channel(channel)
.protagonists(protagonists)
.tags(tags)
.completed(completed)
.build();
this.repository.save(book);
}
这是一个创建作品的函数,参数包括了创建一个作品需要的所有信息,包括标题、简介、封面URL、类型、归属频道等等。这里看起来没啥问题,只是参数较多而已,但是假如后续需求变化了,创建一个作品又要多2个参数,是不是很自然的就会再加2个参数,时间久了之后,就会更长了。
和大类"每次只加一点点"类似,我们知道了长函数坏味道形成的原因,那如何解决呢?
这里所有参数都是和创建作品相关,也是创造作品所必须的,所以我们可以把参数封装成一个类:
public class NewBookParamters {
private String title;
private String introduction;
private URL coverUrl;
private BookType type;
private BookChannel channel;
private String protagonists;
private String tags;
private boolean completed;
...
}
这样一来,我们上面函数就变成了下面这样:
public void createBook(final NewBookParamters parameters) {
...
Book book = Book.builder
.title(parameters.getTitle())
.introduction(parameters.getIntroduction())
.coverUrl(parameters.getCoverUrl())
.type(parameters.getType())
.channel(parameters.getChannel())
.protagonists(parameters.getProtagonists())
.tags(parameters.getTags())
.completed(parameters.isCompleted())
.build();
this.repository.save(book);
}
看着参数列表由多个变成了一个,但是在函数内使用还是一个一个地取出来,这不是多此一举吗?
这就涉及到对软件设计的理解,我们并不是简单地把参数封装成类。站在软件设计的角度,我们引入了一个新的模型,而一个模型的封装应该是以行为为基础的。
之前没有这个模型,我们想不到它应该有什么行为,现在模型产生了,它就应该有配套的行为。从代码我们不难看出,这个模型的行为应该就是创建一个作品对象出来,理解了这一点,代码调整如下:
public class NewBookParamters {
private String title;
private String introduction;
private URL coverUrl;
private BookType type;
private BookChannel channel;
private String protagonists;
private String tags;
private boolean completed;
public Book newBook() {
return Book.builder
.title(title)
.introduction(introduction)
.coverUrl(coverUrl)
.type(type)
.channel(channel)
.protagonists(protagonists)
.tags(tags)
.completed(completed)
.build();
}
}
创建作品的函数简化为:
public void createBook(final NewBookParamters parameters) {
...
Book book = parameters.newBook();
this.repository.save(book);
}
这里的关键不仅是把参数封装为类多了一个模型,更重要的是理解模型的行为,即任何模型的封装都是以行为为基础。
动静分离
接下来我们来看一段参数列表不是那么长的代码:
public void getChapters(final long bookId,
final HttpClient httpClient,
final ChapterProcessor processor) {
HttpUriRequest request = createChapterRequest(bookId);
HttpResponse response = httpClient.execute(request);
List<Chapter> chapters = toChapters(response);
processor.process(chapters);
}
这个函数的作用是根据作品ID获取其对应章节的信息,单纯看参数个数,这个函数的参数个数不算多,但是这里的参数列表依旧有问题。
在这几个参数里面,每次传递进来的bookId都是不一样的,是随着请求的不同而改变。但是httpClient和processor这2个参数是一样的,因为他们都有相同的逻辑。
换言之,就是bookId的变化频率和httpClient和processor变化频率不一样。这种变化频率不一样的情况,是分离关注点的典型情况,即动数据(bookId)和静数据(httpClient、processor)应该分离开来。
具体到场景中,静态不变的数据完全可以成为函数所在类的一个字段,所以代码可以改写如下:
public void getChapters(final long bookId) {
HttpUriRequest request = createChapterRequest(bookId);
HttpResponse response = this.httpClient.execute(request);
List<Chapter> chapters = toChapters(response);
this.processor.process(chapters);
}
这个坏味道其实是软件设计问题,代码缺乏应有的结构,将本来属于静态的数据却以动态参数的方式传来传去。
这里静态分离,也说明了前面说的可以把长参数列表用一个类进行封装的前提是:这些参数属于一个类,拥有相同的变化频率。
告别标记
什么是标记,即我们代码中常见的flag,来看一段代码:
public void editChapter(final long chapterId,
final String title,
final String content,
final boolean apporved) {
...
}
这个函数的作用是编辑章节,前3个参数是待编辑章节的信息,后面表示是否审核通过,这是因为假如是作者角色调用该函数,是不能审核通过的(没有权限),如果是编辑角色调用该函数,是直接审核通过的。
这里逻辑看起来没啥问题,使用flag也是程序员常用的一个手段,但是也正是假如代码标记过多,就会造成逻辑混乱。
最简单的解决方式,就是将标记参数代表的不同路径拆分出来。比如上面的函数就可以拆分为2个函数,一个函数负责普通的编辑,另一个负责可直接审核通过的编辑:
// 普通的编辑,需要审核
public void editChapter(final long chapterId,
final String title,
final String content) {
...
}
// 直接审核通过的编辑
public void editChapterWithApproval(final long chapterId,
final String title,
final String content) {
...
}
标记参数在代码中的表现形式不仅仅是布尔值类型,有的是以枚举形式,还有的直接是以字符串或者整数形式。不论哪种形式,我们都可以通过拆分函数的方式将他们拆开,这种手法叫做移除标记参数。
小结
这里所说的解决长函数的3个方法,其实都在强调一个主题:我们应该编写"短小"的代码。
坏味道:长参数
消除长参数:
- 参数数量多导致的长参数
- 变化频率相同,则封装为一个类。
- 变化频率不同的,其中静态不变的,可以成为软件结构的一部分;多个变化频率的,可以封装为几个类。
- 标记参数导致的长函数
- 根据标记参数,将函数拆分为多个函数。
滥用控制语句
本小节开始说一些代码中的坏味道,包括我们常见的嵌套代码和if、else语句等,可能会刷新我们之前的编程习惯认知。
嵌套的代码
嵌套的代码大家肯定都写过,效果就是函数结尾会有一大堆括号,给我们阅读代码带来了极度不方便,下面给出一个简单的代码:
public void distributeEpubs(final long bookId) {
List<Epub> epubs = this.getEpubsByBookId(bookId);
for (Epub epub : epubs) {
if (epub.isValid()) {
boolean registered = this.registerIsbn(epub);
if (registered) {
this.sendEpub(epub);
}
}
}
}
这段代码逻辑用来分发EPUB电子书,首先根据bookId查询到EPUB电子书集和,对于每一本电子书如果有效则进行注册,注册成功后,进行分发。
这个逻辑不复杂,但是函数后面我们发现已经有4个右括号了,代码再稍微复杂一点,嵌套层次就更多了。
这里出现的问题非常简单,在长函数那节我们说过,就是使用"平铺直叙"的方式写代码。这段代码,就是按照需求一步一步实现的,但是问题在于最后没有把代码整理一下。
消除缩进的第一个着手点就是for循环里面的逻辑,我们可以单独拎出来:
public void distributeEpubs(final long bookId) {
List<Epub> epubs = this.getEpubsByBookId(bookId);
for (Epub epub : epubs) {
this.distributeEpub(epub);
}
}
private void distributeEpub(final Epub epub) {
if (epub.isValid()) {
boolean registered = this.registerIsbn(epub);
if (registered) {
this.sendEpub(epub);
}
}
}
我们对每一本电子书的判断逻辑是一样的,所以可以把这部分逻辑抽离为函数给单独出来,这样原来的函数就少了一层缩进了。
if和else
在上面拆分后的distributeEpub方法中,还是有很长的缩进,这里的缩进是由if语句造成的。
通常来说,if语句造成的缩进,很多时候都是在检查某个先决条件,只有条件通过,才继续执行后面的代码。这样的代码,可以使用卫语句来解决,即设置单独的检查条件,不满足条件直接返回。
这是一种典型的重构:以卫语句取代嵌套的条件表达式。修改后代码:
private void distributeEpub(final Epub epub) {
if (!epub.isValid()) {
return;
}
boolean registered = this.registerIsbn(epub);
if (!registered) {
return;
}
this.sendEpub(epub);
}
修改后的代码嵌套就少了很多,也更利于阅读。
这里说了if关键字,与之对应的是else关键字,对于else关键字也是一种坏味道,这挑战了很多程序员的认知。
我们同样可以使用卫语句来消除else关键字,比如下面代码:
public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
double price = 0;
if (highQuality && chapterSequence > START_CHARGING_SEQUENCE) {
price = 4.99;
} else if (sequenceNumber > START_CHARGING_SEQUENCE
&& sequenceNumber <= FURTHER_CHARGING_SEQUENCE) {
price = 1.99;
} else if (sequenceNumber > FURTHER_CHARGING_SEQUENCE) {
price = 2.99;
} else {
price = 0.99;
}
return price;
}
这是一个给电子书定价的函数,逻辑如下:当是高品质电子书,且页数大于起始收费页数则定价4.99,对于非高品质书来说,页数大于起始收费页小于进一步收费页数定价1.99,大于进一步收费页数定价2.99,其他默认定价为0.99。
这一段代码在日常生活中经常写,因为我们一直以为if/else就是天生一对,这里同样可以使用卫语句进行优化:
public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
if (highQuality && chapterSequence > START_CHARGING_SEQUENCE) {
return 4.99;
}
if (sequenceNumber > START_CHARGING_SEQUENCE
&& sequenceNumber <= FURTHER_CHARGING_SEQUENCE) {
return 1.99;
}
if (sequenceNumber > FURTHER_CHARGING_SEQUENCE) {
return 2.99;
}
return 0.99;
优化后的代码,多使用return来提前结束,也更加方便阅读。
不论是嵌套的代码,还是else语句,我们之所以视为坏味道并且改进,本质上都是在追求简单。因为一段代码的分支过多,复杂度就会大大提高。也是我们一直所说的,人脑能够理解的复杂度是有限的,分支过多的代码会超过理解范围。
重复的Switch
前面所说的if else都是坏味道,那我们熟悉的switch语句也可能是坏味道,我们来看段代码:
public double getBookPrice(final User user, final Book book) {
double price = book.getPrice();
switch (user.getLevel()) {
case UserLevel.SILVER:
return price * 0.9;
case UserLevel.GOLD:
return price * 0.8;
case UserLevel.PLATINUM:
return price * 0.75;
default:
return price;
}
}
public double getEpubPrice(final User user, final Epub epub) {
double price = epub.getPrice();
switch (user.getLevel()) {
case UserLevel.SILVER:
return price * 0.95;
case UserLevel.GOLD:
return price * 0.85;
case UserLevel.PLATINUM:
return price * 0.8;
default:
return price;
}
}
这里的逻辑是根据用户的等级来制定书籍和电子书的价格,但是代码部分仔细查看发现会有很多类似的代码,这就是一种典型的坏味道:重复的switch。
之所以会出现重复的switch,通常都是因为缺少一个模型,所以这种坏味道的重构手法是:以多态取代条件表达式。
还记得前面我们说过引入模型的原则吗?要以行为为基础,这里的行为就是根据用户等级获取不同的书籍和电子书价格,所以引入UserLevel模型:
interface UserLevel {
double getBookPrice(Book book);
double getEpubPrice(Epub epub);
}
class RegularUserLevel implements UserLevel {
public double getBookPrice(final Book book) {
return book.getPrice();
}
public double getEpubPrice(final Epub epub) {
return epub.getPrice();
}
class GoldUserLevel implements UserLevel {
public double getBookPrice(final Book book) {
return book.getPrice() * 0.8;
}
public double getEpubPrice(final Epub epub) {
return epub.getPrice() * 0.85;
}
}
class SilverUserLevel implements UserLevel {
public double getBookPrice(final Book book) {
return book.getPrice() * 0.9;
}
public double getEpubPrice(final Epub epub) {
return epub.getPrice() * 0.85;
}
}
class PlatinumUserLevel implements UserLevel {
public double getBookPrice(final Book book) {
return book.getPrice() * 0.75;
}
public double getEpubPrice(final Epub epub) {
return epub.getPrice() * 0.8;
上面代码针对不同的用户等级,在处理获取价格是不一样的,所以前面代码中的switch就可以去掉了:
public double getBookPrice(final User user, final Book book) {
UserLevel level = user.getUserLevel()
return level.getBookPrice(book);
}
public double getEpubPrice(final User user, final Epub epub) {
UserLevel level = user.getUserLevel()
return level.getEpubPrice(epub);
}
小结
本节内容直接刷新我们对编程的认知,因为这些坏味道包含在我们最常见的控制语句中。
坏味道:滥用控制语句。
呈现心态:
- 嵌套的语句;
- else语句;
- 重复的switch。
解决手法:
- 以卫语句取代嵌套的条件表达式;
- 以多态取代重复switch表达式。
总结
本篇文章不仅介绍了容易发现的大类和长参数列表这种坏味道,还介绍了我们很熟悉的嵌套代码、if、else、switch等不容易发现的坏味道,在这之中,我们需要注意单一职责、动静分离等原则。
最后强烈建议阅读源文章和几本文章中提及的书籍,感兴趣的可以阅读源文章:time.geekbang.org/column/intr…