为什么需要函数?
1 | int main() { |
这是一个计算数组求和的简单程序。
但是,他只能计算数组 a 的求和,无法复用。
如果我们有另一个数组 b 也需要求和的话,就得把整个求和的 for 循环重新写一遍:
1 | int main() { |
这就出现了程序设计的大忌:代码重复。
在软件设计中,也有一个类似的概念:DRY(Don’t Repeat Yourself)。
例如,你有吹空调的需求,和充手机的需求。你为了满足这两个需求,购买了两台发电机,分别为空调和手机供电。第二天,你又产生了玩电脑需求,于是你又购买一台发电机,专为电脑供电……真是浪费!
重复的代码不仅影响代码的可读性,也增加了维护代码的成本。
- 看起来乱糟糟的,信息密度低,让人一眼看不出代码在干什么的功能
- 很容易写错,看走眼,难调试
- 复制粘贴过程中,容易漏改,比如这里的
s += b[i]
可能写成s += a[i]
而自己不发现 - 改起来不方便,当我们的需求变更时,需要多处修改,比如当我需要改为计算乘积时,需要把两个地方都改成
s *=
- 改了以后可能漏改一部分,留下 Bug 隐患
- 敏捷开发需要反复修改代码,比如你正在调试
+=
和-=
的区别,看结果变化,如果一次切换需要改多处,就影响了调试速度
狂想:没有函数的世界?
如果你还是喜欢“一本道”写法的话,不妨想想看,完全不用任何标准库和第三方库的函数和类,把
fmt::println
和std::vector
这些函数全部拆解成一个个系统调用。那这整个程序会有多难写?
1 | int main() { |
不仅完全没有可读性、可维护性,甚至都没有可移植性。
除非你只写应付导师的“一次性”程序,一旦要实现复杂的业务需求,不可避免的要自己封装函数或类。网上所有鼓吹“不封装”“设计模式是面子工程”的反智言论,都是没有做过大型项目的。
设计模式追求的是“可改”而不是“可读”!
很多设计模式教材片面强调可读性,仿佛设计模式就是为了“优雅”“高大上”“美学”?使得很多人认为,“我这个是自己的项目,不用美化给领导看”而拒绝设计模式。实际上设计模式的主要价值在于方便后续修改!
例如 B 站以前只支持上传普通视频,现在叔叔突然提出:要支持互动视频,充电视频,视频合集,还废除了视频分 p,还要支持上传短视频,竖屏开关等……每一个叔叔的要求,都需要大量程序员修改代码,无论涉及前端还是后端。
与建筑、绘画等领域不同,一次交付完毕就可以几乎永久使用。而软件开发是一个持续的过程,每次需求变更,都导致代码需要修改。开发人员几乎需要一直围绕着软件代码,不断的修改。调查表明,程序员 90% 的时间花在改代码上,写代码只占 10%。
软件就像生物,要不断进化,软件不更新不维护了等于死。如果一个软件逐渐变得臃肿难以修改,无法适应新需求,那他就像已经失去进化能力的生物种群,如《三体》世界观中“安顿”到澳大利亚保留区里“绝育”的人类,被淘汰只是时间问题。
如果我们能在写代码阶段,就把程序准备得易于后续修改,那就可以在后续 90% 的改代码阶段省下无数时间。
如何让代码易于修改?前人总结出一系列常用的写法,这类写法有助于让后续修改更容易,各自适用于不同的场合,这就是设计模式。
提升可维护性最基础的一点,就是避免重复!
当你有很多地方出现重复的代码时,一旦需要涉及修改这部分逻辑时,就需要到每一个出现了这个逻辑的代码中,去逐一修改。
例如你的名字,在出生证,身份证,学生证,毕业证,房产证,驾驶证,各种地方都出现了。那么你要改名的话,所有这些证件都需要重新印刷!如果能把他们合并成一个“统一证”,那么只需要修改“统一证”上的名字就行了。
可以理解为这些证在使用的时候是到一个固定的点位去获取名字,而不是硬编码写死,我们微服务的Nacos这点实现的就非常好。通过这样的操作,我们就不需要全部重新修改一遍了。
不过,现实中并没有频繁改名字的需求,这说明:
- 对于不常修改的东西,可以容忍一定的重复。
- 越是未来有可能修改的,就越需要设计模式降重!
例如数学常数 PI = 3.1415926535897,这辈子都不可能出现修改的需求,那写死也没关系。如果要把 PI 定义成宏,只是出于“记不住”“写起来太长了”“复制粘贴麻烦”。所以对于 PI 这种不会修改的东西,降重只是增加可读性,而不是可修改性。
但是,不要想当然!需求的千变万化总是超出你的想象。
例如你做了一个“愤怒的小鸟”游戏,需要用到重力加速度 g = 9.8,你想当然认为 g 以后不可能修改。老板也信誓旦旦向你保证:“没事,重力加速度不会改变。”你就写死在代码里了。
没想到,“愤怒的小鸟”老板突然要求你加入“月球章”关卡,在这些关卡中,重力加速度是 g = 1.6。
如果你一开始就已经把 g 提取出来,定义为常量:
1 | struct Level { |
那么要支持月球关卡,只需修改一处就可以了。
1 | struct Level { |
小彭老师之前做 zeno 时,询问要不要把渲染管线节点化,方便用户动态编程?张猩猩就是信誓旦旦道:“渲染是一个高度成熟领域,不会有多少修改需求的。”小彭老师遂写死了渲染管线,专为性能极度优化,几个月后,张猩猩羞答答找到小彭老师:“小彭老师,那个,渲染,能不能改成节点啊……”。这个故事告诉我们,甲方的信誓旦旦放的一个屁都不能信。
用函数封装
函数就是来帮你解决代码重复问题的!要领:
把共同的部分提取出来,把不同的部分作为参数传入。
1 | void sum(std::vector<int> const &v) { |
这样 main 函数里就可以只关心要求和的数组,而不用关心求和具体是如何实现的了。事后我们可以随时把 sum 的内容偷偷换掉,换成并行的算法,main 也不用知道。这就是封装,可以把重复的公共部分抽取出来,方便以后修改代码。
sum 函数相当于,当需要吹空调时,插上空调插座。当需要给手机充电时,插上手机充电器。你不需要关心插座里的电哪里来,“国家电网”会替你想办法解决,想办法优化,想办法升级到绿色能源。你只需要吹着空调给你正在开发的手机 App 优化就行了,大大减轻程序员心智负担。
要封装,但不要耦合
但是!这段代码仍然有个问题,我们把 sum 求和的结果,直接在 sum 里打印了出来。sum 里写死了,求完和之后只能直接打印,调用者 main 根本无法控制。
这是一种错误的封装,或者说,封装过头了。
你把手机充电器 (fmt::println) 焊死在了插座 (sum) 上,现在这个插座只能给手机充电 (用于直接打印) 了,不能给笔记本电脑充电 (求和结果不直接用于打印) 了!尽管通过更换充电线 (参数 v),还可以支持支持安卓 (a) 和苹果 (b) 两种手机的充电,但这样焊死的插座已经和笔记本电脑无缘了。
每个函数应该职责单一,别一心多用
很明显,“打印”和“求和”是两个独立的操作,不应该焊死在一块。
sum 函数的本职工作是“数组求和”,不应该附赠打印功能。
sum 计算出求和结果后,直接 return 即可。
如何处理这个结果,是调用者 main 的事,正如“国家电网”不会管你用他提供的电来吹空调还是玩游戏一样,只要不妨碍到其他居民的正常用电。
1 | int sum(std::vector<int> const &v) { |
这就是设计模式所说的职责单一原则。
二次封装
假设我们要计算一个数组的平均值,可以再定义个函数 average,他可以基于 sum 实现:
1 | int sum(std::vector<int> const &v) { |
进一步封装一个打印数组所有统计学信息的函数:
1 | void print_statistics(std::vector<int> const &v) { |
暴露 API 时,要同时提供底层的 API 和高层封装的 API。用户如果想要控制更多细节可以调用底层 API,想要省事的用户可以调用高层封装好的 API。
高层封装 API 应当可以完全通过调用底层 API 实现,提供高层 API 只是方便初级用户使用和理解。
例如
libcurl
就提供了curl_easy
和curl_multi
两套 API。
1 | - `curl_multi` 提供了超详细的参数,把每个操作分拆成多步,方便用户插手细节,满足高级用户的定制化需求,但太过复杂,难以学习。 |
Linus 的最佳实践:每个函数不要超过 3 层嵌套,一行不要超过 80 字符,每个函数体不要超过 24 行
Linux 内核为什么坚持使用 8 缩进为代码风格?
因为高缩进可以避免程序员写出嵌套层数太深的代码,当他写出太深嵌套时,巨大的 8 缩进会让代码变得非常偏右,写不下多少空间。从而让程序员自己红着脸“对不起,我把单个函数写太深了”然后赶紧拆分出多个函数来。
此外,他还规定了单一一个函数必须在终端宽度 80 x 24 中显示得下,否则就需要拆分成多个函数重写,这配合 8 缩进,有效的限制了嵌套的层数,迫使程序员不得不重新思考,更解耦的写法出来。
为什么需要函数式?
你产生了两个需求,分别封装了两个函数:
sum
求所有元素的和product
求所有元素的积
1 | int sum(std::vector<int> const &v) { |
注意到 sum
和 product
的内容几乎如出一辙,唯一的区别在于:
sum
的循环体为+=
;product
的循环体为*=
。
这种函数体内有部分代码重复,但又有特定部分不同,难以抽离。
该怎么复用这重复的部分代码呢?
我们要把 sum
和 product
合并成一个函数 generic_sum
。然后通过函数参数,把差异部分(0、+=
)“注入”到两个函数原本不同地方。
枚举的糟糕用法
如何表示我这个函数是要做求和 +=
还是求积 *=
?
让我们定义枚举:
1 | enum Mode { |
然而,如果用户现在想要求数组的最大值呢?
枚举中还没有实现最大值的操作……要支持,就得手忙脚乱地去修改 generic_sum
函数和 Mode
枚举原本的定义,真麻烦!
1 | enum Mode { |
我用
// ***改***
指示了所有需要改动的地方。
为了增加一个求最大值的操作,就需要三处分散在各地的改动!
不仅如此,还容易抄漏,抄错,比如 MAX
不小心打错成 MUL
了,自己却没发现,留下 BUG 隐患。
这样写代码的方式,心智负担极大,整天就提心吊胆着东一块,西一块的散装代码,担心着有没有哪个地方写错写漏,严重妨碍了开发效率。
并且写出来的代码也不能适应需求的变化:假如我需要支持 MIN
呢?又得改三个地方!这违背了设计模式的开闭原则。
- 开闭原则: 对扩展开放,对修改封闭。指的是软件在适应需求变化时,应尽量通过扩展代码来实现变化,而不是通过修改已有代码来实现变化。
使用枚举和 if-else 实现多态,难以扩展,还要一直去修改原函数的底层实现,就违背了开闭原则。
函数式编程光荣救场
如果我们可以“注入”代码就好了!能否把一段“代码”作为 generic_sum
函数的参数呢?
代码,实际上就是函数,注入代码就是注入函数。我们先定义出三个不同操作对应的函数:
1 | int add(int a, int b) { |
然后,把这三个小函数,作为另一个大函数 generic_sum
的参数就行!
1 | int generic_sum(std::vector<int> const &v, auto op) { |
责任明确了,我们成功把一部分细节从 generic_sum
中进一步抽离。
- 库作者
generic_sum
不必了解main
的操作具体是什么,他只负责利用这个操作求“和”。 - 库用户
main
不必了解generic_sum
如何实现操作累加,他只管注入“如何操作”的代码,以函数的形式。
依赖注入原则
函数对象 op
作为参数传入,让 generic_sum
内部去调用,就像往 generic_sum
体内“注入”了一段自定义代码一样。
这可以让 generic_sum
在不修改本体的情况下,通过修改“注入”部分,轻松扩展,满足开闭原则。
更准确的说,这体现的是设计模式所要求的依赖注入原则。
- 依赖注入原则: 一个封装好的函数或类,应该尽量依赖于抽象接口,而不是依赖于具体实现。这可以提高程序的灵活性和可扩展性。
四大编程范式都各自发展出了依赖注入原则的解决方案:
- 面向过程编程范式中,函数指针就是那个抽象接口。
- 面向对象编程范式中,虚函数就是那个抽象接口。
- 函数式编程范式中,函数对象就是那个抽象接口。
- 模板元编程范式中,模板参数就是那个抽象接口。
同样是把抽象接口作为参数,同样解决可扩展问题。
函数指针贴近底层硬件,虚函数方便整合多个接口,函数对象轻量级、随地取用,模板元有助高性能优化,不同的编程范式殊途同归。
低耦合,高内聚
依赖注入原则可以减少代码之间的耦合度,大大提高代码的灵活性和可扩展性。
- 耦合度: 指的是一个模块、类、函数和其他模块、类、函数之间的关联程度。耦合度越低,越容易进行单元测试、重构、复用和扩展。
高耦合度的典型是“牵一发而动全身”。低耦合的典范是蚯蚓,因为蚯蚓可以在任意断面切开,还能活下来,看来蚯蚓的身体设计非常“模块化”呢。
通常来说,软件应当追求低耦合度,适度解耦的软件能更快适应需求变化。但过度的低耦合也会导致代码过于分散,不易阅读和修改,甚至可能起到反效果。
若你解耦后,每次需求变化要改动的地方变少了,那就是合理的解耦。若你过分解耦,代码东一块西一块,以至于需求变化时需要到处改,比不解耦时浪费的时间还要多,那就是解耦过度。
完全零耦合的程序每个函数互不联系,就像把蚯蚓拆散成一个个独立的细胞一样。连初始需求“活着”都实现不了,谈何适应需求变化?所以解耦也切勿矫枉过正。
为了避免解耦矫枉过正,人们又提出了内聚的概念,并规定解耦的前提是:不耽误内聚。耽误到内聚的解耦,就只会起到降低可维护性的反效果了。
- 内聚: 指的是同一个模块、类、函数内部各个元素之间的关联程度。内聚度越高,功能越独立,越方便集中维护。
例如,人的心脏专门负责泵血,肝脏只负责解毒,这就是高内聚的人体器官。若人的心脏还要兼职解毒,肝脏还兼职泵血,看似好像是增加了“万一心脏坏掉”的冗余性,实际上把“泵血”这一功能拆散到各地,无法“集中力量泵大血”了。
人类的大脑和 CPU 一样,也有“缓存局域性 (cache-locality)”的限制:**不能同时在很多个主题之间快速切换,无论是时间上的还是空间上的割裂 (cache-miss)**,都会干扰程序员思维的连贯性,从而增大心智负担。
好的软件要保持低耦合,同时高内聚。
就像“民主集中制”一样,既要监督防止大权独揽,又要集中力量办一个人办不成的大事。