04 长函数:为什么你总是不可避免地写出长函数?
你好,我是郑晔。
这一讲,我们来讲一个你一定深恶痛绝的坏味道:长函数。
有一个关于程序员的段子,说程序员一定要用大屏显示器,而且一定要竖起来用,这样才能看到一个函数的全貌。这显然是在调侃函数很长,小屏甚至横屏都不足以看到整个函数,只有竖起来才行。
只要一提到长函数,无论是去被迫理解一个长函数的含义,还是要在一个长函数中,小心翼翼地找出需要的逻辑,按照需求微调一下,几乎所有程序员都会有不愉悦的回忆。可以这么说,没有人喜欢长函数,但在实际工作中,却不得不去与各种长函数打交道。
不知道你在实际工作中遇到最长的函数有多长,几百上千行的函数肯定是不足以称霸的。在我的职业生涯中,经常是我以为自己够见多识广了,但只要新接触到一个有悠久历史的代码库,就总会有突破认知的长函数出现。
长函数是一个“我一说,你就知道怎么回事”的坏味道,我就不准备用一个典型的长函数来开启这一讲了,否则,这一讲的篇幅都不够了。但是,为了统一认识,我准备先讨论一下多长的函数算是长函数,我们来看一个案例。
多长的函数才算“长”?
有一次,我在一个团队做分享,讲怎么把一个长函数重构成小函数。现场演示之后,我问了大家一个问题:在你心目中,多长的函数才算长呢?
一个现场听众很认真地思考了一下,给出了一个答案:100 行。我很尴尬地看了一下自己刚刚重构掉的两个函数,最长的一个都不到 100 行。换言之,以他的标准来看,这个函数根本就不是长函数,根本就没有必要重构。
对于函数长度容忍度高,这是导致长函数产生的关键点。
如果一个人认为 100 行代码不算长,那在他眼中,很多代码根本就是没有问题的,也就更谈不上看到更多问题了,这其实是一个观察尺度的问题。这就好比,没有电子显微镜之前,人们很难理解疾病的原理,因为看不到病毒,就不可能理解病毒可以致病这个道理。
一个好的程序员面对代码库时要有不同尺度的观察能力,看设计时,要能够高屋建瓴,看代码时,要能细致入微。
这里的要点就是,看具体代码时,一定要能够看到细微之处。我在《10x 程序员工作法》专栏中讲到过“任务分解”,关键点就是将任务拆解得越小越好,这个观点对代码同样适用。随着对代码长度容忍度的降低,对代码细节的感知力就会逐渐提升,你才能看到那些原本所谓细枝末节的地方隐藏的各种问题。
回到具体的工作中,“越小越好”是一个追求的目标,不过,没有一个具体的数字,就没办法约束所有人的行为。所以,通常情况下,我们还是要定义出一个代码行数的上限,以保证所有人都可以按照这个标准执行。
我自己写代码的习惯是这样的。像 Python、Ruby 这样表达能力比较强的动态语言,大多数情况下,一行代码(one-liner program)可以解决很多问题,所以,我对自己的要求大约是 5 行左右,并且能够用一行代码解决的问题,就尽量会用一行代码解决;而像 Java 这样表达能力稍弱的静态类型语言,我也争取在 10 行代码之内解决问题。
当然,这是我对自己的要求,在实际的项目中,可能不是每个人都能做到这一点,所以,我给了一个更为宽松的限制,在自己的标准上翻了番,也就是 20 行。
这不是一个说说就算的标准,我们应该把它变成一个可执行的标准。比如,在 Java 中,我们就可以把代码行的约束加到 CheckStyle 的配置文件中,就像下面这样:
<module name="MethodLength">
<property name="tokens" value="METHOD_DEF"/>
<property name="max" value="20"/>
<property name="countEmpty" value="false"/>
</module>
这样,在我们提交代码之前,执行本地的构建脚本,就可以把长函数检测出来(关于 CheckStyle,我在《10x 程序员工作法》中讲项目自动化时专门做过介绍,你有兴趣不妨了解一下)。如果你用的是其它的程序设计语言,不妨也找一下相应的静态检查工具,看看是否提供类似的配置。
我知道,即便是以 20 行为上限,这也已经超过很多人的认知,具体的函数行数可以结合团队的实际情况来制定,但是,我非常不建议把这个数字放得很大,就像我前面说的那样,如果你放到 100 行,这个数字基本上是没有太多意义的,对团队也起不到什么约束作用。
我之所以要先讨论多长的函数算是长函数,是因为如果你不能认识到代码行的标准应该很低,那么在接下来的讨论中,有些代码示例可能在你看来,就根本不需要调整了。
长函数的产生
不过,限制函数长度,是一种简单粗暴的解决方案。最重要的是你要知道,长函数本身是一个结果,如果不理解长函数产生的原因,还是很难写出整洁的代码。接下来,我们就来看看长函数是怎么产生的。
- 以性能为由
人们写长函数的历史由来已久。在《软件设计之美》专栏里,我讲过程序设计语言的发展历史。像 C 语言这种在今天已经是高性能的程序设计语言,在问世之初,也曾被人质疑性能不彰,尤其是函数调用。
在一些写汇编语言的人看来,调用函数涉及到入栈出栈的过程,显然不如直接执行来得性能高。这种想法经过各种演变流传到今天,任何一门新语言出现,还是会以同样的理由被质疑。
所以,在很多人看来,把函数写长是为了所谓性能。不过,这个观点在今天是站不住的。性能优化不应该是写代码的第一考量。
一方面,一门有活力的程序设计语言本身是不断优化的,无论是编译器,还是运行时,性能都会越来越好;另一方面,可维护性比性能优化要优先考虑,当性能不足以满足需要时,我们再来做相应的测量,找到焦点,进行特定的优化。这比在写代码时就考虑所谓性能要更能锁定焦点,优化才是有意义的。
- 平铺直叙
除了以性能为由把代码写长,还有一种最常见的原因也会把代码写长,那就是写代码平铺直叙,把自己想到的一点点罗列出来。比如下面这段代码(如果你不想仔细阅读,可以直接跳到后面):
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,其中的变量命名明显比之前要短,理解的成本也相应地会降低。因为变量都是在这个短小的上下文里,也就不会产生那么多的命名冲突,变量名当然就可以写短一些。
平铺直叙的代码,一个关键点就是没有把不同的东西分解出来。如果我们用设计的眼光衡量这段代码,这就是“分离关注点”没有做好,把不同层面的东西混在了一起,既有不同业务混在一起,也有不同层次的处理混在了一起。我在《软件设计之美》专栏中,也曾说过,关注点越多越好,粒度越小越好。
- 一次加一点
有时,一段代码一开始的时候并不长,就像下面这段代码,它根据返回的错误进行相应地错误处理:
然后,新的需求来了,增加了新的错误码,它就变成了这个样子:
你知道,一个有生命力的项目经常会延续很长时间,于是,这段代码有很多次被修改的机会,日积月累,它就成了让人不忍直视的代码,比如:
if (code == 400 || code == 401 || code == 402 || ...
|| code == 500 || ...
|| ...
|| code == 10000 || ...) {
// 做一些错误处理
}
后来人看到这段代码就想骂人了。当他从版本控制的历史中找到这些代码的作者,去询问这些处理的来龙去脉时,每个人其实都很委屈,他们当时也没做太多,只是加了一个判断条件而已。
任何代码都经不起这种无意识的累积,每个人都没做错,但最终的结果很糟糕。对抗这种逐渐糟糕腐坏的代码,我们需要知道“童子军军规”:
让营地比你来时更干净。
—— 童子军军规
Robert Martin 把它借鉴到了编程领域,简言之,我们应该看看自己对于代码的改动是不是让原有的代码变得更糟糕了,如果是,那就改进它。但这一切的前提是,你要能看出自己的代码是不是让原有的代码变得糟糕了,所以,学习代码的坏味道还是很有必要的。
至此,我们看到了代码变长的几种常见原因:
- 以性能为由;
- 平铺直叙;
- 一次加一点。
你会发现,代码变长根本是一个无意识的问题,写代码的人没有觉得自己把代码破坏了。但只要你认识到长函数是一个坏味道,后面的许多问题就自然而然地会被发掘出来,至于解决方案,你已经看到了,大部分情况下,就是拆分成各种小函数。
总结时刻
今天我们讲了程序员最深恶痛绝的坏味道:长函数。没有人愿意去阅读长函数,但许多人又会不经意间写出长函数。
毫无疑问,长函数是一个坏味道。对于团队而言,一个关键点是要定义出长函数的标准。不过,过于宽泛的标准是没有意义的,想要有效地控制函数规模,几十行的函数已经是标准的上限了,这个标准越低越好。
我们还分析了长函数产生的原因:
- 有人以性能为借口;
- 有人把代码平铺直叙地摊在那里;
- 有人只是每次增加了一点点。
其中,平铺直叙是把函数写长最常见的原因。之所以会把代码平摊在那里,一方面是把多个业务写到了一起,另一方面是把不同层次的代码写到了一起。究其根因,那是“分离关注点”没有做好。
每次增加一点点,是另外一个让代码变长的原因,应对它的主要办法就是要坚守“童子军军规”,但其背后更深层次的支撑就是要对坏味道有着深刻的认识。
如果今天的内容你只能记住一件事,那请记住:把函数写短,越短越好。
思考题
你在实际的工作中遇到过长函数吗?讲讲你和长函数斗争的故事,欢迎在留言区写下你的经历。如果你身边有人正在为“长函数”苦恼,也欢迎你把这节课分享给他。
感谢阅读,我们下一讲再见!
参考资料:
一个好的项目自动化应该是什么样子的?
- 明 👍(14) 💬(2)
我写过最长的类,写了4000行,还是我抽出去了几百行if代码。没错,就是我亲手写出来的
2021-01-10 - Jxin 👍(13) 💬(2)
举个场景: 创建商品聚合实体的场景: 1.查找商品,校验商品是否存在。 2.查找店铺, 校验店铺是否存在,获取店铺code。 3.用商品和店铺查找合作关系。 4.用合作关协查找合同,验证合同合法。 以上只是举例,实际场景可能还会有5,6,7,8,9。而这个场景的特点: 每个环节的执行依赖上个环节的回参作为输入,以至于只能平铺(哪怕每个环节都提炼成1行函数调用)而没办法拆分开去封装。而一旦平铺,行数超过10-20就很容易。 像这种场景,郑老师怎么看。可以进一步拆分的话该如何拆?
2021-01-07 - Geek_45023b 👍(11) 💬(3)
我见过10万行的类,上万行的长函数。这些都是在工业软件中,由非计算机专业的人开发的代码
2021-01-10 - 桃子-夏勇杰 👍(10) 💬(1)
郑老师,把一个开发人员从习惯写长函数培养到会写短函数大概需要多少时间和成本?
2021-01-08 - dev.bin 👍(9) 💬(1)
前人留下的一个类6000多行代码,函数没有几个,想改却改不动,改完后,别人提交之后就产生冲突,解决冲突后,别人又提交,又冲突。只改一点点,CI过不了,一周下来,没啥产出。
2021-01-08 - adang 👍(7) 💬(3)
在代码库里看到很多这样平铺直叙的代码,类似这样。
这个方法的逻辑是,转移管理员账号给一个新的管理员,这个方法大约80行的样子。 分析了一下,大家这样写的原因, 1.成员都是自己野蛮生长起来的,没有见过好的范例; 2.团队里没有成型的规范,即使有也没有人坚持,代码随便写,只要功能实现了就行。 像上面这样只要把各种情况顺序写进去就行了,比较简单,不用考虑太多。可这样带来的问题就是,后面维护起来就要花很大精力把逻辑搞清楚才能下手去修改。另外,像上面那样,如果中间某一步执行失败,整个方法就失败,但是前面几步的操作却不能回滚。 对这段代码的优化,可以抽取出几个小方法,如:修改新旧管理员身份,删除旧账号权限,为新管理员赋值权限,修改企业账号,修改缓存等,使代码更容易阅读。另外,可以借鉴Rails中ActiveRecord::Callbacks的封装方式使代码看上去更简洁。 上面方法是Java的一段代码,对Java不是很熟,不知道Java相关的框架中是否有类似于Rails的Callbacks封装。2021-01-10 - huaweichen 👍(7) 💬(2)
"因为变量都是在这个短小的上下文里,也就不会产生那么多的命名冲突,变量名当然就可以写短一些。" 这句话真的很有启发。 之前还和同事在争论,变量名太长的问题。他们说“we love long variable names, because it is very self-explanatory” 。 我现在可以跟他们说问题在与函数写得太长。
2021-01-07 - 斯盖丸 👍(6) 💬(2)
请问一下老师,遇到JDBC读取resultset或者set一个prepared statement而列又很多时应该怎么简化?往往轻轻松松就几时行了…有什么精简优雅的缩短方法吗?
2021-01-19 - 首长 👍(6) 💬(3)
为啥是“关注点越多越好”?
2021-01-10 - 青鸟飞鱼 👍(5) 💬(2)
函数不超过多少行时,别人会用一些有名的开源库来回你,这怎么办?
2021-01-14 - 术子米德 👍(5) 💬(1)
🤔☕️🤔☕️🤔 一个函数长短与否,如果是偏好问题,其实无解 每个人的对于长短的偏好并不一样 有人喜欢短函数,可能是因为,看到大于一个屏幕的代码,就焦虑得不行,就担心自己看不懂 有人喜欢长函数,一个完整的业务在一个过程里,从头到尾读完,要改动就在这里改,有问题也就是这里的问题 说起性能,只要拿不出性能测定的手段,拿不出性能测定的数据,都是臆想成分居多,大概率只是为自己的偏好找借口 只要有性能测定的方法,只要有配套测试用例和自动化体系,可维护就是一个结果,代码怎么写、怎么改,都是由性能测定和测试系统来保障的 我遇到过不少人,喜欢短函数,这些人有个特点,都是急性子的人 我遇到过不少人,啥函数都能津津有味读下来,都是慢性子的人
2021-01-07 - Luke 👍(4) 💬(1)
很多坏味道只有先识别出来,才能有效设立改进的目标,就像郑大之前说到的,要有对于代码的品味。知道什么样子的代码是好的,否则有可能南辕北辙,适得其反就不好了。 郑大的文章特别好读,看的时候一颗心突突直跳,感觉自己被点燃了,我想这背后也离不开您对于专栏文章的一次次打磨和重构。 高手都善于将功夫用在当下,记得梁肇新在「编程高手箴言」封底上写下的一句口号:「我就是程序、程序就是我」。之前只是觉得这句话很酷,被这种「不疯魔、不成活」的态度所感染。直到后来才渐渐觉得,这里面也蕴含着铁杵磨针、水滴石穿的道理。
2021-08-03 - 熊斌 👍(4) 💬(1)
之前做项目时,确实经历过老师文中讲到的平铺直叙和一次加一点这种情况。回想了一下,出现这种情况的原因有以下几点: 1、历史遗留问题,没有可参照的范例 2、无高手进行代码review,项目组各种规范及工具使用不到位 3、自己的水平不够 前两点无法改变,第三点我可以改变,就是学了软件设计之美、设计模式之美等专栏后 培养了自己这方面的意识。另外就是关注了您的开源项目,能时不时去看看源码,赏心悦目,严格要求自己,哈哈哈哈
2021-01-09 - sfqqyq 👍(4) 💬(1)
对于长函数抽取出小函数也是一个技术活。
2021-01-08 - Moonus 👍(4) 💬(1)
从编写单元测试角度来看,1.一个长函数的测试需要Mock一堆依赖;2.长函数一定会有很多的条件分支,会加大我们的认知复杂度,代码会很难读,从而你的单元测试也会很难写,即使覆盖每一个条件分支,当业务逻辑的变更时,你可能代码只改了一行,你的所有的测试都会飘红,这是测试可能会让你难以维护。 在10x中郑大说过,把测试写简单,简单到一目了然,不需要证明它的正确性,那其实我们的函数也是一样,我的方法也应该尽可能的小,保证每个方法的单一职责,他只做一件事。
2021-01-07