04 | 长函数:为什么你总是不可避免地写出长函数?
1.多长的函数才算“长”?
对于函数长度容忍度高,这是导致长函数产生的关键点。
如果一个人认为 100 行代码不算长,那在他眼中,很多代码根本就是没有问题的,也就更谈不上看到更多问题了,这其实是一个观察尺度的问题。这就好比,没有电子显微镜之前,人们很难理解疾病的原理,因为看不到病毒,就不可能理解病毒可以致病这个道理。
回到具体的工作中,“越小越好”是一个追求的目标,不过,没有一个具体的数字,就没办法约束所有人的行为。所以,通常情况下,我们还是要定义出一个代码行数的上限,以保证所有人都可以按照这个标准执行。
在实际的项目中,可能不是每个人都能做到这一点,所以,我给了一个更为宽松的限制,在自己的标准上翻了番,也就是 20
行(ps:我感觉我目前做不到这点 o(╥﹏╥)o ,因为对于某些代码逻辑复杂的地方,还是无法缩这么短)。
2.长函数的产生
限制函数长度,是一种简单粗暴的解决方案。最重要的是你要知道,长函数本身是一个结果,如果不理解长函数产生的原因,还是很难写出整洁的代码。接下来,我们就来看看长函数是怎么产生的。
2.1 以性能为由
在很多人看来,把函数写长是为了所谓性能。不过,这个观点在今天是站不住的。性能优化不应该是写代码的第一考量。
一方面,一门有活力的程序设计语言本身是不断优化的,无论是编译器,还是运行时,性能都会越来越好;另一方面,可维护性比性能优化要优先考虑,当性能不足以满足需要时,我们再来做相应的测量,找到焦点,进行特定的优化。这比在写代码时就考虑所谓性能要更能锁定焦点,优化才是有意义的。
2.2 平铺直叙
除了以性能为由把代码写长,还有一种最常见的原因也会把代码写长,那就是写代码平铺直叙,把自己想到的一点点罗列出来。比如下面这段代码(如果你不想仔细阅读,可以直接跳到后面):
public void executeTask() {
ObjectMapper mapper = new ObjectMapper();
CloseableHttpClient client = HttpClients.createDefault();
List<Chapter> chapters = this.chapterService.getUntranslatedChapters();
for (Chapter chapter : chapters) {
// Send Chapter
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) {
// ignore
}
}
// Translate Chapter
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) {
// ignore
}
}
}
}
这段代码的逻辑是,把没有翻译过的章节发到翻译引擎,然后,启动翻译过程。在这里翻译引擎是另外一个服务,需要通过 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);
}
}
拆出来的部分,实际上就是把对象打包发送的过程,我们以发送章节为例,先来看拆出来的发送章节部分:
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) {
// ignore
}
}
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
}
我们只用了最简单的提取函数这个重构手法,就把一个大函数拆分成了若干的小函数。
长函数往往还隐含着一个命名问题。如果你看修改后的 sendChapter,其中的变量命名明显比之前要短,理解的成本也相应地会降低。因为变量都是在这个短小的上下文里,也就不会产生那么多的命名冲突,变量名当然就可以写短一些。
平铺直叙的代码,一个关键点就是没有把不同的东西分解出来。如果我们用设计的眼光衡量这段代码,这就是“分离关注点”没有做好,把不同层面的东西混在了一起,既有不同业务混在一起,也有不同层次的处理混在了一起。关注点越多越好,粒度越小越好。
2.3 一次加一点
有时,一段代码一开始的时候并不长,就像下面这段代码,它根据返回的错误进行相应地错误处理:
if (code == 400 || code == 401) {
// 做一些错误处理
}
然后,新的需求来了,增加了新的错误码,它就变成了这个样子:
if (code == 400 || code == 401 || code == 402) {
// 做一些错误处理
}
你知道,一个有生命力的项目经常会延续很长时间,于是,这段代码有很多次被修改的机会,日积月累,它就成了让人不忍直视的代码,比如:
if (code == 400 || code == 401 || code == 402 || ...
|| code == 500 || ...
|| ...
|| code == 10000 || ...) {
// 做一些错误处理
}
任何代码都经不起这种无意识的累积,每个人都没做错,但最终的结果很糟糕。 对抗这种逐渐糟糕腐坏的代码,我们需要知道“童子军军规”:
让营地比你来时更干净。
—— 童子军军规
Robert Martin 把它借鉴到了编程领域,简言之,我们应该看看自己对于代码的改动是不是让原有的代码变得更糟糕了,如果是,那就改进它。 但这一切的前提是,你要能看出自己的代码是不是让原有的代码变得糟糕了,所以,学习代码的坏味道还是很有必要的。