一文详解|如何写出优雅的代码
简介: 和大家一起探讨一下优雅代码
谈到好代码,我的第一想法就是优雅,那我们如何该写出好的代码,让阅读的人感受到优雅呢?首先简单探讨一下优雅代码的定义
关于好代码的定义,各路大神都给出了自己的定义和见解
- 整洁的代码如同优美的散文。—— Grady Booch
- 任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。—— Martin Fowler
首先要达成一致,我们写的代码,除了用于机器执行产生我们预期的效果之外,更多的时候是给人读的,可能是后续的维护人员,更多时候是一段时间后的作者本人,因此优雅面向不同的用户有两层含义的解读
- 对人而言,代码的整洁,清晰的逻辑
- 对机器而言,准确性、执行性能、异常处理机制等
这次,我们就来聊一聊,什么代码是优雅的代码,怎样写出优雅的代码
简单说就是类、方法、变量的命名要名副其实,要能描述清晰自己的职责。一个好的命名能输出更多的信息,它会告诉你,它为什么存在,它是做什么事的,应该怎么使用。一个简单的衡量标准是,如果命名完仍需要注释来补充语义,那就不是名副其实;
选个好名字要花时间,但省下的时间的时间比花掉的多,一旦发现有更好的名称,就换掉旧的。
举个栗子
整体逻辑没啥问题,读完之后,就有很多问题在脑海中产生
- 1. theList中是存储什么东西的数组?
- 2. theList第一个值是做什么的?
- 3. 值4的意义又是什么?
- 4. 返回的列表该怎么使用?
代码应该体现所处的情景,比方说上述的代码所处情景是我们正在开发一种扫雷游戏,盘面是名为theList的单元格列表,那就将其名称改为gameBoard。
盘面上每个单元格都用一个简单数组表示。零下标条目是一种状态值,而这种状态值为4代表“已标记”。只要改为有意义的名称,代码就得到了改进。
更进一步,不用int数组来表示单元格,而是另写一个类。该类包括一个名副其实的函数(称为isFlagged),从而掩盖住哪个魔术数4,得到新的函数版本。
实际上,只要我们的代码有足够的表达力,能清晰的通过命名来做到名副其实,就不太需要注释,或者根本不需要;注释的存在往往是弥补我们无法用代码清晰表达意图的情况。可以想象一下,每次自己发现需要写注释的时候,是什么心态,担心此处代码明天自己看不懂或者别人看不懂,那有没有考虑用更好的语义的代码来替代。
但尽管有注释,也有好有坏,有时候注释也会撒谎,通常注释存在的越久,就离其描述的代码越远,变得越来越离谱;因为代码在变动在迭代,在注释和代码间可能会插入新的代码,旧代码我们通常copy来copy去,分离又重组,但注释一般不会修改,就会造成注释和描述的代码分离,对阅读者造成更大的迷惑。
我们在需要写注释的时候就要告诉自己,能不能用代码来进行描述。以下是一些坏代码的注释bad case
方法应该有多短小?没有明确约束,idea也不会限制你,但通常我们的方法不该长于一屏,至少多于一屏或者横向外溢到屏幕以外最直观的就会造成可读性体验差,读了下面忘记上面,左右拖拽等。对大多数笔记本来说一屏大概就30行左右。短小精简的方法要比30行短很多,比如
if语句、else语句、while语句等,其中的代码应该只有一行,
改行通常是一个调用语句,这样不但能保持短小,还可以给调用方法命名一个有说明性的名字,进一步增加代码的可读性
一事精,便可动人。这个普世法则甚至适用于各种场合。像设计原则的单一职责模式,让类只有一个职责。如果一个类有一个以上的职责,这些职责就耦合在了一起。这会导致逻辑混乱,设计耦合。当一个职责发生变化时,可能会影响其它的职责。
另外,多个职责耦合在一起,会影响复用性。针对方法而言更是如此。方法作为程序的原子单元,保持单一会有效提升复用性。 那怎么判断一个方法是否只做了一件事。最简单的规则就是看看该方法是否能在拆出一个方法,且拆出去的方法是不同于该方法的诠释和实现。但是要注意同一方法的逻辑层级务必要一致。
抽象层级一致也是对方法只做一件事的更高要求,抽象层级不一致的代码一定是做了多件事。
我们读代码通常是自顶向下阅读,我们想让每个方法后面都跟着位于下一层级的方法,这样我们可以依着抽象层级向下阅读了。我们也需要这样阅读代码,先有整体在展示细节,这种叫向下规则。这也是保持方法短小,确保只做一件事的诀窍。一旦方法中混杂不同的抽象层级,会让人很迷惑,因为没办法这个方法中判断某个表达式是基础概念还是细节,更恶劣的是,一旦细节与基础概念混杂,更多的细节就会纠缠不清,举例子我们想写一个冰冻大象的需求
这个例子的1.2两步就不是一个层级的逻辑,是属于更高层级的抽象。3.4.5都是将大象放入冰箱的步骤,属于低层级的抽象。可以将代码拆分为如下实现,将高抽象层级的代码聚合提取出来,细节在分别单独实现,如下
针对错误码的判断会导致更深层次的嵌套结构,返回错误码就意味着要求调用者跟着处理错误,如下
一般我们还需要将try/Catch代码块给抽离出去,另外形成方法。防止代码块过多搞乱代码结构,分不清错误处理还是正常流程。同时因为方法只做一件事,错误处理就是一件事,因此错误处理的方法不应该在做其他事,也就是如果一个方法中有try关键字,那try就是方法的开头。catch/finally代码块后面也不应该再有内容,如下
比如Lombok组件通过注解的方式,在编译时自动为属性生成构造器、getter/setter、equals、hashcode、toString方法 举例如下:
比如Apache Commons系列组件给我们提供了关于字符串、集合、IO操作等工具方法。这些组件是个大宝库,提供了不少轮子
重构是对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
在重构之前一定要知道,一旦开始对类和方法进行重构,就需要事前有完备的单元测试用例来保障重构的准确性,每次重构之后都要去执行对应的单元测试用例,验证重构的正确性!
如果在一个以上的地点看到相同的代码结构,可以肯定的是,想办法抽线出来合而为一,代码会变得更好。一般包含几个点的重复
- 最单纯的重复代码就是“同一个类的两个函数含有相同的表达式”。这时候需要做的就是采用提炼函数提炼出重复的代码,然后让这两个地点都调用被提炼出来的那一段代码
- 如果重复代码只是相似而不是完全相同,需要先尝试用移动语句重组代码顺序,把相似的部分放在一起以便提炼。
- 如果重复的代码段位于同一个超类的不同子类中,可以使用函数上移来避免在两个子类之间互相调用。
遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名,可以对一组甚至短短一行代码做这件事。哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用途,就要毫不犹豫地那样做,关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。
- 百分之九十九的场合里,要把函数变短,只需使用提炼函数。找到函数中适合集中在一起的部分,将它们提炼出来形成一个新函数。
- 如果函数内有大量的参数和临时变量,最终就会把许多参数传递给被提炼出来的新函数,导致可读性几乎没有任何提升。此时可以经常运用以查询取代临时变量来消除这些临时元素。引入参数对象和保持对象完整则可以将过长的参数列表变得更简洁一些。
- 如果有多个switch语句基于同一个条件 进行分支选择,就应该使用以多态取代条件表达式。
对数据的修改经常导致出乎意料的结果和难以发现的bug。在一处更新数据,却没有意识到软件中的另一处期望着完全不同的数据,于是出现难以预料的bug,往往比较难排查(需要排查数据流转的整体链路),这就需要一些方法用于约束对数据的更新,降低数据可变性的风险。
- 可以用封装变量来确保所有数据更新操作都通过很少几个函数来进行,使其更容易统一监控和演进
- 如果一个变量在不同时候被用于存储不同的东西, 可以使用拆分变量将其拆分为各自不同用途的变量,从而避免危险的更新操作。
- 使用移动和提炼函数尽量把逻辑从处理更新操作的代码中搬移出来,将业务处理逻辑代码与执行数据更新操作的代码分开。
所谓模块化,就是力求将代码分出区域,最大化区域内部的交互、最小化跨区域的交互。但是经常出现一个函数跟另一个模块中的函数或者数据交流格外频繁,远胜于与所处模块内部的交流,这就是模块功能不单一的典型情况。
- 总看到某个函数为了计算某个值,从另一个对象那儿调用半打的取值函数。如果这个函数需要跟这些数据待在一起,那就使用移动功能把它移过去。
- 一个函数往往会用到几个模块的功能,那么它究竟该被置于何处呢?原则是:判断哪个模块拥有的此函数使用的数据最多,然后就把这个函数和那些数据摆在一起。 如果先以提炼函数将这个函数分解为数个较小的函数并分别置放于不同类中,上面的步骤就会比较容易完成。
- Strategy模式和Visitor模式是为了对抗发散式变化,但也能解决单一职责问题,最根本的原则是:将总是一起变化的东西放在一块儿。 数据和引用这些数据的行为总是一起变化的,如果有特殊情况,我们就搬移那些行为,保持变化始终只在一地发生。
点击查看原文,获取更多福利!
https://developer.aliyun.com/article/1117703?utm_content=g_1000366324
版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。
正确复制、重写别人的代码,不算抄袭
我最近在一篇文章提到,工程师应该怎样避免使用大量的库、包以及其他依赖关系。我建议的另一种方案是,如果你没有达到重用第三方代码的阈值时,那么你就可以自己编写代码。
在本文中,我将讨论一个在重用和从头开始编写之间的抉择。这些技术能为你提供良好的综合优势。你将会听到我提到的一些在不同编程语言/环境下的例子。但是,这篇文章的层次足够高,而且说实话,你在什么环境下工作,并不重要。
我们一再要求她把字写得小一点。她浪费了那么多纸张。
设想你所处的情况是这样的,你希望你的软件能够完成当前无法完成的任务。比如,你也许想要一份 PDF 格式的报告,方便打印。但是你的软件并不会输出 PDF。作为一名工程师,你要做的工作就是解决这个问题。
下图展示了四种不同的技术来添加这个功能。我并没有提到“这四种技术”。当然,我肯定还有更多的技术,包括镖靶和猴子。
为什么这么多决定都要权衡利弊呢?这真的很让人恼火。
- 重用:根据你的环境,你可能会在 lib 中建立链接,或者在 pom.xml 或 package.json 中添加一个条目。不管你怎么做,“重用”就是抓取别人的代码,通过它的接口来使用,而不去关心它的内部结构。这段代码还可能会“偷渡”到一群横向的依赖关系中去,这会给你带来麻烦。最少的努力,带来的是最少的控制。
- 复制:从 Github repo、代码片段集或者其他地方,选择你所需要的代码。然后把它直接粘贴到你的项目中。这段代码已经存在于你的代码库中,而不再是一个依赖关系。
- 重写:采用一些适合你的第三方代码,通过重写,使其成为你自己的代码。
- 编写:你自己编写所有的代码,用自己的脑子,不借用任何人的东西。Ayn Rand 和 Casey Muratori 将尊重你的决定。最大的努力,将得到最大限度的控制。
为了确保我上面的图表给人留下正确的印象,我给了它两把斧子——一把是努力,一把是控制。
另一方面,这家伙的两把斧子也让人印象深刻。
所有这四种技术都是在努力和控制之间的权衡。
你可以通过重用快速地获取大量的功能,然后以软件膨胀、黑盒调试、安全修补和耦合升级的形式遭受缺乏控制的痛苦。在重用的情况下,你不一定会面临这些问题,但是这样做的风险会增大。在你的项目中添加的每一行代码就好像是一张,会“赢得”一个非常糟糕的问题。
或者在这张图的另一端,你从头开始编写所有的代码,你将会对进入项目的新代码进行完全的控制。不过,你可以花些无聊的时间去把所有的东西都按你的想法去编写,而你却成了编程的天后。
在我以前的一篇文章《多编写,少重用》(Write More, Reuse Less)中,我已经详细地阐述了重用和编写之间的权衡。
我将会深入探讨两种中间技术(复制和重写)。
有许多可以复制代码的好地方。如果你要完成的任务可以用一句话来完成,而且代码不超过 100 行即可实现,那么,你只需把问题输入到搜索引擎,就能轻松地找到。
与 DuckDuckGo 相比,谷歌最大的优势在于:“DuckDuckGo”作为动词,在会话中会让人感到难堪。
用这种方式来搜索一些简单的代码任务的问题,你将很快就能找到代码的天堂。
我从 StackOverflow、W3Schools、MDN、Unity Answers 和各种我懒得记住的地方复制了很多代码。我总是先从搜索引擎开始,在那里提出问题。通常情况下,我会先从我工作的语言/平台开始,然后再提问。在我敲代码的时候,看一下自动完成的内容往往很有帮助。
从复制人类答案的机器人那里复制你的答案。
如果你更愿意相信由复杂的人工智能为你编写的代码片段,可以试试 GitHub Copilot。这是一个集成到 IDE 中的插件。基本上,你并不需要在搜索引擎中输入你想要的东西,而是将该文本输入一个略微详细的源码注释即可。然后,实现的源代码就会自动填充在下面。
是真的!我不是在开玩笑。请看一些关于它的视频。
视频:https://youtu.be/FHwnrYm0mNc
如果你想要为更大的功能部分复制代码,那么只要:
- 寻找包含这些功能的开源项目;
- 将所需的文件复制过来。
我刚才提出的一种做法,不是很傻就是很疯狂。我真心希望你能想到下面那个问题。别让我跟你扯淡。
如果你仅仅想将一个依赖关系的所有未改变的文件复制到你的项目中,那么我将会发现这种复制方法的缺陷。你复制的源码不会轻易地被更新到依赖关系的新版本。这就意味着你会错过 Bug 修复、新功能和安全补丁。
尽管在一些情况下,对某个特定版本的代码进行快照非常有价值,但是你可以通过构建清单(例如 Java 中的 pom.xml,Node 中的 package.json)使用固定版本来完成同样的事情。而且如果你决定要升级的话,那么你可以轻易地更新一个固定版本。
另外一个潜在的复制理由是,你可能需要对项目的源码进行修改。如果你要进行增量更改,你可以最好这样做:1. 从原项目中创建一个复刻项目;或者 2. 把你的更改贡献给原项目。这样可以让你在以后更容易从原项目中收到修改。
有三种不同的方式可以让你在你的项目中修改他人的代码。最上面的一种做法是不好的。
这也许是你大规模、全面地修改你的代码库。你准备在以后的合并项目中,把一切后路都堵死。那真是太好了。这往往是有正当理由的。但是我在本文中所提供的定义是,对源码的大规模结构性的修改属于“重写”技术,下面我会详细讨论。
我唯一能想到的一个很好的理由就是,将第三方项目的代码复制(而非重写)到你的项目中:你只需在这个项目中得到一些源码即可。
而这种理由出现的频率超出了你的想象。大型 Node 包,如 Lodash 和 TurfJS,都是非常聪明的,它们会提供子集包,这些子集包只是为了你需要的特定功能在函数级颗粒度来导入。不过,在野外中也有很多臃肿的怪物。
Nodelerfish 需要你的爱。但不要让它进入你的项目中。
你也许只需要使用 50 行代码就可以完成的库 API。但是单体导入的库,可能会拉来数十万行代码,特别是那些具有横向依赖关系。在这种情况下,将一个子集复制下来,而非将其导入到整个该死的烂摊子中,这完全是很合理的。
我不是一个律师。既然我不是律师,我就会把我在开源许可方面的实践告诉你。你可以咨询顶级律师团队,以达成你自己的决策。
笔者作为非律师,是如何做的:
如果我把源码复制到我的项目中,我把它和导入代码一样对待,并遵循所有的许可条款。在开源软件许可的条款中,一般都是指分发源码或者从许可源码中构建的行为。如果复制的代码是我发布版本的一部分,那么它也算作分发。对于我使用的确切的开源许可证,我也非常谨慎,例如,GPL 可能会要求我使用 GPL 许可证来许可我自己的代码库,而 MIT 许可证几乎不要求我做任何事情。
我还喜欢将复制的代码归入“第三方”目录(例如 Github 上的例子),然后在文档的顶部添加注释,这样就可以保证所有的内容都是一致的。
他们不认识我,但 Matt Daly 和 Chris Anderson 是我的代码兄弟。
你知道你正在复制的代码里有什么吗?它是适合你的代码吗?
你能够而且应当对其他人的代码进行判断。或许不要对这段代码做出无情的评论。但是,请务必为自己的利益,私下评估第三方代码。
如果是 200 行以内的代码片段,或者是复制的代码,我一定会逐行重写代码,这样可以了解到这些代码是如何工作的,并且做一些改动,使之更适合我的项目。这是为数不多的几次在其他方面进行毫无意义的、风格化的编辑,是有价值的。
我要举一个具体的例子来说明。现在,我要从互联网上某个地方随机找出一个代码片段,然后进行重写。
左边的代码是原来的。右边的代码是我重写的。
我重写的代码片段是由 StackOverflow 上一位名叫“Mark”的人发布的。他的代码通过对每一个点与它的左右邻居进行平均,使折线图中参差不齐的高峰和低谷变得平滑。
概括地说,在重写代码中,我做了以下的改动:
- 为变量重新命名,这样更容易显示它们的用途;
- 使用更加具体的方式来声明变量;
- 把某些代码重构为一个单独的函数,来描述其用途;
- 删除另外一个第三方库(HighCharts)中的一些数据结构。
- 把空白的地方改了改。
我编写代码的风格并不比 Mark 的好。它只不过是碰巧以正确的方式触动了我的大脑。这并不足以让 Mark 的代码发生改变。下面是重写他的代码片段的真实益处:
- 我学会了这个算法。我现在完全明白这个折线图平滑的工作原理了。
- 我创建的代码对我而言更易于维护,即使对 Mark 来说并不是这样。
- 我可以把那些和我需求无关的部分代码删除掉。我确认了代码没有任何错误,比如,无限循环。
- 我已经确认了没有引入安全漏洞,比如在 DOM 中注入一个 querystring 变量。
- 我已经确认了,这些代码没有添加任何额外的依赖关系,比如某些统计库。
- 我了解到另外一位工程师很享受编写代码的乐趣,并且有机会把他的实践为我自己所用。
所以,这种浅重写是一种很好的方式,可以把别人的代码导入到你的项目中。有些问题是可以避免的。你可以根据你的用例和其他需求对代码进行调整。另外,你还可以在学习新的算法和实践中,成长为一名工程师。
笔者作为非律师,是如何做的:
我想,完全的逐行重写应该可以免除几乎所有开源软件许可的法律义务。但是我始终坚持着开源的理念,例如,在源码的注释中引用原作,或者为原项目提供帮助。
有时候,你希望导入多个文档或数千行的源码,并做大量的改动,以让新代码适合你的项目。尤其是,修剪掉你实际上不需要的东西,是很好的做法。
下面是一个简单的复制和修剪的方法:
- 将所有的依赖源文件复制到你的项目中。
- 确保你所需的功能能够在实践中起作用。单元测试一般都是非常有用的。
- 把你不需要的部分删除。
- 重复第 2 和第 3 步,直至剩余的代码能够符合你的需求。
- 至少对剩余的代码进行一次浅重写,这样才能保证你能够了解并从中得到其他的益处。
你的 IDE 的选择和配置应该能够很好地支持你完成这项任务,包括提示、语法高亮和通知功能,这些功能可以向你显示:
- 哪些代码在调用函数。(修剪它们?)
- 哪些函数从未被调用。(修剪它们!)
- 哪些变化会破坏你的构建。(取消修剪!)
如果你的 IDE 没有为你对这些进行适配,可以花点时间去做更好的设置。
git commit 和 git checkout 可以让你设定一个很好的状态,在你因过度修剪而破坏一些东西的时候,能够恢复到之前的状态。这是一张很好的安全网,可以使你的工作速度更快。
我都能听见你想说什么了……
我不是说你一定要重写。只有在一些情况下,如果你这么做,就能得到很好的回报。我来告诉你一个真实的案例,我重写了一个第三方项目,并且从中获益良多。
我先从依赖树开始,如下所示:
在花了半天时间完成重写之后,我去掉了 5 个依赖关系的需求,最后得到了如下结果:
有一个名为“microphone-stream”的 NPM 包,我在 Web 应用中使用它来发送从麦克风捕获的样本缓冲区到语音识别包(Cieran O\’Reilly 的 vosk-browser,如果你有兴趣的话)中的一个接口。
我最初是在“让它工作”的开发阶段使用 microphone-stream。它包含在一个示例 Web 应用项目中,我已经将其复制到我的项目中。microphone-stream 运行得很好,直到我升级了一个构建工具(Webpack),这破坏了 readable-stream 的构建,这是一个更高级别的依赖包。我研究了对这两个第三方库中任何一个可能的 PR 修复。不过,向仓库提交一个好的修改需要花费好几天时间。我由于种种理由而拒绝了其他的变通方案。
通过查看 microphone-stream 的代码,我意识到我并不需要该库的核心功能:一个 Node.js 风格的流接口。因此,我认真地重写了那些我真正需要的那部分代码,把那些我不想要的东西删除掉。
一路走来,我在源码中发现了这样的宝藏:
还有这个:
我很感激地将这些想法合并到我重写的代码中。
作者 Nathan Friedly 在这样的源码注释中阐述了他的思考过程。也许他拯救了我,可能让我以后不用再找漏洞来修复了。从这个角度来看,重写比从头开始编写要好。你可以“捕捉”到别人来之不易的知识。
因此,对我来说,这是一个明显的案例,重写,而非重用,可以节约我的时间,并且让我得到更好的结果。重写也比从头开始编写要好,因为这样可以让我了解其他工程师的真实经验,否则我可能会错过这些经验。
复制和重写——试试吧!
这是好东西。这算不上作弊。
只要遵循开源许可的条款,和你的工程师伙伴成为好邻居吧。
你不希望复制或重写所有的东西。但是要学习辨别哪些情况值得你这么做。
并享受与其他人工作中的联系。那些数以百万计的项目,都是由伟大的头脑构建的。
Erik Hermansen,博主,撰写关于工程、技术,以及人机共同构成的系统的文章。
https://levelup.gitconnected.com/copying-other-peoples-code-is-very-cool-717e8a72aa3b
如何写好代码
作者:陈晓(逸晓)
抛开性能、并发、一致性等技术因素,好的业务代码,应当如一篇显浅易懂的业务叙实文章,满足以下几个基本条件:
- 词要达意:最基础的变量、函数、类的命名,是否名达其意。
- 结构清晰:类的关系结构,函数的调用结构,是否如文章的章节、段落划分层次分明和逻辑清晰。
- 紧扣主题:包、类、函数是否内聚,是否破坏了单一和开闭原则。
因此,好代码如同好文章,它应该是饱含业务语义(词要达意)、具有自明性和可读性(结构清晰),能够显性化表达业务意图(紧扣主题),让人赏心悦目。
好的代码,从好的命名开始,做到名副其实。
✪ 2.1.1 变量命名
变量名是名词,要正确和清晰地描述业务语义,如果一个变量需要通过注释补充说明,那可能就是没取好变量名。
变量命名的关键点:
1)词要达意:避免无业务语义的命名,如:list、val、a…;
2)语境范围:避免小范围词套大范围数据,反之亦然,不使用过于宽泛的名词;
3)名词复数:统一风格,加s或List尾缀,变量名建议使用s尾缀,函数名建议使用List尾缀;
4)后置限定词:限定词是对前面变量名的修饰,可以描述名词的作用范围属性,例如:
- 请求入参:xxxQuery/xxxRequest
- 返回结果:xxxResponse/xxxResult
- 传参数据:xxxDTO/xxxVO/xxxInfo
- 运算结果:xxxTotal(总和)/xxxMax(最大值)/xxxAverage(平均值)
Bad case:
Good case:
函数命名要体现做什么,而不是怎么做,要清楚表达出操作意图和业务语义。
函数命名的关键点:
1)动名词搭配,动词表达操作意图,名词表达业务语义。
2)正反操作使用对仗词,例如:
- add/remove
- open/close
- begin/end
- insert/delete
- first/last
- min/max
Bad Case:
Good Case:
类是面向对象中最重要的概念,是一组关联数据的相关操作的封装,通常可以把类分为两种:
1)实体类:承载业务的核心数据和业务逻辑,命名要充分体现业务语义,比如Order/Buyer/Item。
2)辅助类:协调实体类完成业务逻辑,命名通常加后缀体现出其功能性,比如OrderQueryService/OrderRepository。
函数命名的关键点:
1)辅助类尽量避免用 Helper/Util 之类的后缀,因为其含义过于笼统,容易破坏单一职责原则。
2)针对某个实体的辅助操作过多,或单个操作很复杂,可通过 “实体 + 操作类型 + 功能后缀”来命名,同时符合职责单一和接口隔离的原则,比如OrderService:
- OrderCreateService:订单创建服务;
- OrderUpdateService:订单更新服务;
- OrderQueryService:订单查询服务。
包(package)是一组强关联(内聚)的类的集合,起分类收纳和命名空间的作用。
- 包名应该要反映一组类在更高抽象层次上的联系,比如类Apple、Orange都是水果,可以收纳进fruit包内。
- 包的命名要大小适中,不能太具体,也不能太抽象。比如包名叫Apple,太具体导致类Orange放不进去,又比如包名叫food,太抽象导致其他非水果也被放进来了。
实际工程中,常见的分类维度主要是两种,按功能性或业务域分类。
- 功能性分类:metaq、mapper、service、dao等;
- 业务域分类:user、item、order、promotion等。
同一层级的包,要严格保持分类维度的一致性,要么先按业务域分类,再按功能性分类;要么就先按功能性分类,再按业务域分类。
有时候,优雅的实现仅仅是一个函数,不是一个类,不是一个框架,只是一个函数。—— John Carmack
- 短小:一个函数不超过50行代码,大量的setXXX()除外。
- 专一:一个函数只做一件事情,符合单一职责原则。
遵循金字塔原则,把函数层层递进的调用,理解成结论先行,自上而下的表达过程。
同层函数是对上一层的支撑,同层间要符合MECE法则,应描述和处理同一逻辑范畴的事情,高层抽象和底层细节不能杂糅在一起,否则会变得凌乱和难以理解。
(MECE是(Mutually Exclusive Collectively Exhaustive)的缩写,指的是“相互独立,完全穷尽”的分类原则。通过MECE方法对问题进行分类,能做到清晰准确,从而容易找到答案。)
- client:外部可见层(暴露服务声明);
- service:业务逻辑层,对client层的实现,协调domain和infrastructure一起完成业务逻辑;
- domain:领域层,对应DDD中的领域知识;
- infrastructure:基础设施层,数据库访问、消息、外部调用等;
- start:应用启动层,主要是项目启动时的静态配置。
分包的建议:
- 如果有多个一级域,建议:一级按业务分包,二级按功能分包,三级可按子领域分包。
- 如果仅一个一级域,建议:一级按功能分包,二级按子领域分包。
例如:
软件设计的目标是高内聚、低耦合。
如果代码是高耦合和低内聚的,就会出现修改一个逻辑,会导致多处代码要修改,可能影响到多个业务链路,这增加了出bug的业务风险,同时增加了测试回归的范围,导致研发成本增加。
耦合和内聚,是我们常挂在嘴边的话,但是大家却说不太清楚,讲不太明白,很难衡量:
- 什么样的叫高内聚,什么样的叫低耦合?
- 高内聚要高到什么程度,低耦合要低到什么程度?
耦合是描述模块(系统/模块/类/函数)之间相互联系(控制/调用/数据传递)紧密程度的一种度量。
- 紧耦合:模块之间联系越紧密,耦合性就越强,模块的独立性则越差;
- 松耦合:模块之间联系越松散,单个模块解决问题的目的越明确,模块的独立性越强。
如果两个模块之间没有直接关系,它们之间的联系完全是通过主模块控制调用来实现的,这就是非直接耦合,这种耦合的模块独立性最强。
如果一个模块访问另一个模块时,彼此之间是通过数据参数(不是控制参数、公共数据结构或外部变量)来交换输入、输出信息的,则称这种耦合为数据耦合,它是较好的耦合形式。
✪ 3.1.3 印记(引用)耦合(Stamp Coupling)
当模块之间使用复合数据结构进行通信时,就会发生印记耦合。
复合数据结构可以是数组、类、结构体、联合体等的引用,通过复合数据结构在模块之间传递的参数,可能会或不会被接收模块完全使用。
印记耦合优点:
- 把模块A的引用一把传递给模块B,模块B只需要接受少量参数,接口说明简单。
印记耦合缺点:
- 不必要的参数:模块B可能只使用了模块A中部分的数据;
- 模块B捆绑了模块A:任何需要用到模块B的地方,都需要先获取到模块A,无法脱离模块A单独使用;
- 修改可能互相影响:修改模块A或模块B,可能导致对方也需要跟着修改,不符合开闭原则。
印记耦合优化:
增加入参数类型,进传入模块需要的必要数据,如下:
✪ 3.1.4 控制耦合(Control Coupling)
如果一个模块通过传送开关、标志等控制信息,明显地控制选择另一模块的功能,就是控制耦合。
- 数据耦合和控制耦合的主要区别:在数据耦合中,模块之间的依赖关系非常小,而在控制耦合中,模块之间的依赖关系很高。在数据耦合中,模块之间通过传递数据进行通信,而在控制耦合中,模块之间通过传递模块的控制信息进行通信;
- 控制耦合优化:把控制的逻辑放在模块A之中,或增加模块C封装控制逻辑,不然模块B只做某一件独立的事情。
外部耦合,是指多个模块同时依赖同一个外部因素(IO设备/文件/协议/DB等),如上图所示:
外部耦合与与外部设备的通信有关,而不是与公共数据或数据流有关。
一个模块对外部数据或通信协议所做的任何更改都会影响其他模块,可以通过增加中间模块隔离外部变化来降低耦合度,如下:
✪ 3.1.6 共用耦合(Common Coupling)
共用耦合是指不同的模块共享全局数据的信息(全局数据结构、共享的通信区、内存的公共覆盖区)。
共用耦合的问题:
- 较难控制各个模块对公共数据的存取,容易影响模块的可靠性和适应性;
- 使软件的可维护性变差,若一个模块修改了共用数据,则会影响相关模块;
- 降低了软件的可理解性,不容易清楚知道哪些数据被哪些模块所共享,排错困难。
内容耦合在低级语言(汇编)中出现,高级语言从设计上已避免出现内容耦合。
如果发生下列情形,两个模块之间就发生了内容耦合:
- 一个模块直接访问另一个模块的内部数据;
- 一个模块不通过正常入口而直接转入到另一个模块的内部;
- 两个模块有一部分代码重叠(该部分代码具有一定的独立功能);
- 一个模块有多个入口。
3.2 内聚的类型
内聚,是描述一个模块内各元素彼此结合的紧密程度,是从功能角度来度量模块内的联系。
- 低内聚:模块内的元素的职责相关性低,通常也意味着模块与外部是紧耦合的。
- 高内聚:模块内的元素的职责相关性强,通常也意味着模块与外部是松耦合的。
通常,解决了耦合的问题,就解决了内聚的问题,反之亦然。
偶然内聚,一个模块内的各元素之间没有任何联系,仅是恰好放在同一个模块内,业务的“Util/Helper”类有大量例子。
问题的原因:通常是模块名起的过于抽象,导致不同职责的元素都可以放进去,从而引起了低内聚。
问题的解法:将抽象的模块拆解成多个更小的具体模块,例如RetailTradeHelper可以拆为OrderAmountHelper/OrderPaymentParamHelper。
逻辑内聚,把几种相关的功能组合在一起,由调用方传入的参数来确定具体执行哪一种功能。
逻辑内聚是一种“低内聚”,某程度上对应了“控制耦合”,它把内部的逻辑处理暴露给了接口之外,当内部逻辑发生变更时,原本无辜的调用方也会受牵连改动。
时间内聚,指一个模块内的组件除了在同一时间都会被执行外,相互之间没有任何关联。
过程内聚,指一个模块内的组件以特定次序被执行,但相互之间没有数据传递。
顺序内聚,指一个模块内的元素以特定次序被执行,且上一步的输出被下一元素所依赖。
功能内聚,指一个模块内所有组件属于一个整体,完成同一个不可切分的功能,彼此缺一不可。
设计原则,是指导我们如何设计出低耦合、高内聚的代码,让代码能够更好的应对变化,从而降本提效。
设计原则的关键,是从使用方的角度看提供方的设计,一句话概括就是:请不要要我知道太多,你可以改,但请不要影响我。
定义:一个函数/类只能因为一个理由被修改。
单一职责原则,是所有原则中看起来最容易理解的,但是真正做到并不简单。因为遵循这一原则最关键是职责的划分。
职责的划分至少要回答两个基本问题:
- 什么是你,什么是我?
- 什么事情归你管,什么事情归我管?
且不说写代码,工作中我们也会出现人人不管或相争的重叠地带,划分清楚职责看起容易,实际很难。
定义:对扩展开放,对修改关闭(不修改代码就可以增加新功能)。
要理解开闭原则,关键是要理解定义中隐含着的两个主语,“使用方”和“提供方”,即:
提供方可以修改,增加新的功能特性,但是使用方不需要被修改,即可享用新的功能特征。
开闭原则广泛的理解,可以指导类、模块、系统的设计,满足该原则的核心设计方法是:通过协议(接口)交互。
定义:所有引用父类的地方,必须能透明的使用它的子类对象,指导类继承的设计。
面向对象的继承特性,一方面,子类可以拥有父类的属性和方法,提高了代码的复用性;另一方面,继承是有入侵性的,父类对子类有约束,子类必须拥有父类全部的属性和方法,修改父类会影响子类,增加了耦合性。
里氏替换原则是对继承进行了约束,体现在以下方面:
- 子类可以实现父类的抽象方法,但不能重写(覆盖)父类的非抽象方法;
- 子类可以增加父类所没有的属性和方法;
- 子类重写父类方法时,输入参数类型要和父类的一致,或更宽松(参数类型的父类);
- 子类重写父类方法时,返回值类型要和父类的一致,或更严谨(返回类型的子类)。
定义:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象,目的是降低层与层之间的耦合。
从倒置来看,该原则可以有更泛化的理解:
- 依赖实体的倒置:高层不依赖底层模块,抽象不依赖细节,例如模块分层规范中的domain不依赖infrastructure的实现;
- 依赖控制的倒置:依赖具体对象的创建控制,从程序内部交给外部,例如Spring的Ioc容器。
举个购物车的例子:
- 商业能力基座:主要包含购物车的业务流程实现、外域服务定义(非实现)、商业定制能力(扩展点),打包后需满足一套代码多处部署的要求。
- 域服务能力实例:针对不同运行环境,提供适配环境的域服务实现,商业基座反向依赖域服务实例,使得基座与环境无关。
定义:客户端不应该被强迫去依赖它并不需要的接口。
理解接口隔离原则,需要拿单一职责的原则做对比。细品一下,如果一个接口满足了
- 单一职责,是否就也就满足接口隔离原则?
- 单一职责原则,解决了接口内聚的问题。
接口隔离原则,认为某些场景下需要存在非内聚接口(多职责),但是又不希望客户端知道整个类,客户端只要知道具有内聚接口的抽象父类即可。
简单来讲,接口隔离原则解决的问题是,当某些类不满足职责单一原则时,客户端不应该直接使用它们,而是通过增加接口类,通过它隐藏客户端不需要感知到的部分。
编程范式,本质是一种思维方式,而和具体语言没关系。
用C语言可以写出面向对象的程序,用Java语言可以写出面向过程的程序。而不争的现实是,我们大部分人是在用java写面向过程的代码。
例如下面代码,它是如何用面向过程语言实现封装、继承、多态的?
以上代码来自开源libevent库
最早使用机器和汇编语言编程,是编排好一堆命令让机器逐条执行,为了控制一些跳跃的流程(如if/for/continue/break),就会用到类似goto的语句,让程序直接跳转到希望执行的指令位置,这样程序员就拥有了直接转移程序控制权的能力。goto的无条件转移,使得程序的控制流难于追踪,程序难以修改和维护。
后来大家总结出了一套流程结构化的定律:任何程序都可以用顺序、选择、循环三种基本控制结构来表示。
- 顺序:代码是至上往下顺序执行的;
- 选择:if-else/switch选择执行;
- 循环:for/while控制循环执行。
因此,结构化编程的本质,是对程序控制权的直接转移进行了规范和限制。
结构化编程思维,比较靠近机器运行的思维,当程序越来越复杂的时候,大家发现简单靠结构化思维编程,很难构建起一个庞大的应用。而在编码过程中,大家不知不觉的把一些数据和逻辑封装了起来,形成一个个可复用的组件。
慢慢大家总结出了一套符合人类理解客观世界的编程范式:利用事物的信息建模概念,如实体、关系、属性等,同时运用封装、继承、多态等机制来构造模拟现实系统的方法。
- 封装:核心是对实体建模,把客观世界的属性和行为,封装成类的数据和方法,同时通过控制访问权限(private/protect/public),对外隐藏细节;
- 继承:在封装的基础上,可以定义子类,从而使子类获得父类的属性和行为,这也符合人类从抽象到具象的认知思维;
- 多态:继承使得子类获得父类的行为,总有儿子不听爸爸话的时候,当子类重写了父类行为时,多态使得父类引用执行方法时,实际执行的是子类的行为。
封装、继承、多态是面向对象的三大特征,三者的关系是层层递进的,而多态实际是规范了程序控制权的间接转移,在面向对象编程之前,大家是通过函数指针来解耦不同组件的函数实现,这种方式需要工程师严格遵守约定初始化函数指针,是非常脆弱的。
因此,面向对象编程的本质,是规范了数据和行为的封装,同时限制了程序控制权的间接转移。
函数式思维,是一种数学思维,把一个问题分解为一系列函数。函数式编程有多种定义,但是从根本上来看,它的核心是“纯函数”和“引用透明”:
- 纯函数:无副作用,同样的输入永远得到同样的输出;
- 引用透明:任意函数直接用它的计算结果替代,而不影响任何调用它的程序。
若要做到以上两点,就需要对赋值进行限制,即变量一旦初始化就不可以再修改。
因此,函数式编程的本质,是规范了函数(一等公民/高阶函数/声明式/闭包等),同时限制了赋值行为。
编程范式的本质,更多是告诉我们不能做什么,并且通过规范来约束我们的行为。
- 结构化编程:限制对程序的控制权做直接转移,请按照控制结构规范来;
- 面向对象编程:限制对程序控制权的间接转移,请按照封装、继承、多态的规范来;
- 函数式编程:限制了赋值行为,请按照函数的规范来。
灵魂拷问一下:
- 为什么面向对象编程大受推崇?
- 为什么多数人在用面向对象语言写面向过程代码?
- 为什么函数式编程束之高阁,很难产业化大规模使用?
我当前表浅的理解是:
- 面向过程符合人类的直线直觉思维,不需要太多深度思考,可以快语直言;
- 面向对象需要充分了解客观主体信息,才能从中思考和提炼出要素(实体)、关系(方法)和目标(职责),要求有系统性的抽象思维;
- 函数式编程基于数学,缺少合适的抽象机制,纯函数式编程很难满足企业级应用要求的严格和复杂的业务需求。
三种编程范式没有好坏之分,核心是思维方式的区别,针对不同的问题和场景,如何选择适当的方式来思考和解决问题,才是我们理解它们的关键。
表模式关注的数据库的表,它先考虑数据库表需要管理,然后添加对数据增删改查的操作。封装是面向对象的关键特征之一,把数据和操作数据的行为绑定在一起,拥有一个标识符(类)来表示它两的集合,而表模式允许你把数据和行为放在一起,但是它没有一个标识符来标出它所代表的主体。
这种模式在PC时代很盛行,例如VB和.net等桌面应用开发框架上,但是活久见,在JAVA服务应用中也被我发现了,如下:
脚本,是指表演戏剧、拍摄电影等所依据的底本又或者书稿的底本。脚本可以说是故事的发展大纲,用以确定故事的发展方向。
事务脚本模式,关注点是事务的流程和步骤,是对事务流程和步骤的编排,是一种面向过程的组织和表达形式。
按照事务脚本模式编程,可以不需要任何面向对象的设计,其中任何逻辑都可以通过if/else/while等流程控制元素来表达。
事务脚本模式的优点是,门槛低容易上手,也符合人的直线直觉思维;它的缺点是,当业务逻辑复杂是,事务方法会快速膨胀,因为业务属性不明确和缺乏抽象,不好复用和扩展。该模式在服务端应用中很常见,从MVC时代开始,一般通过controller组织事务流程,常见的分层结构如下:
领域设计模式,是通过分析和发掘业务领域的概念,从中提炼和设计出具有数据和行为的对象模型(类),并建立模型之间的关系。
领域设计模式,需要建立一个完整的由对象模型组成的层,来对目标业务领域建模。业务是经常变化的,通常有会通过分层的模式,让领域模型和系统其他部分保持最小的依赖。
至此,你会发现领域设计是DDD的底层思想,是面向对象的实践,更多请查阅“对象建模”和“领域驱动设计(DDD)”相关的材料和数据,这里不做展开。
不同的应用范式,是随着软件复杂度逐步提升演进出来的,不同模式面对和解决不同复杂度的问题,相互之间没有好坏之分。当问题比较简单时,使用事务脚本模式足够应付,反倒使用领域设计就过度设计,增加了不必要的复杂度,适得其反。
任何一个学科的学习,都要从基本概念、基本原理、基本方法入手,才能把握住问题的实质。
所谓,招式套路可以千变万化,扎实深厚的内功却始终如一。内功是基础和本源的东西,例如耦合和内聚,我们都知道低耦合高内聚好,但如何衡量代码的耦合和内聚?
再如编程范式,我们都在使用面向对象语言,为什么看到的大多数是面向过程的代码?究其根本,是我们容易忽视基础和本源的东西,比如更关注设计模式,更关注架构设计,但上层的设计理念大多数是来自基础和本源的思想指引。
套用道家的一句话:道以明向,法以立本,术以立策,器以成事。
从代码的角度来看:
- 道:是好代码,无以名状,无以表述,只能假想形式表达,仁者见仁智者见智;
- 法:是编程范式,是规章制度,是规范约束,使其在道的合理范围之内,保住基线;
- 术:是设计原则,执行层面的方法论;
- 器:是spring/mos-boot,有形的工具,保障的是执行和效率。
从代码的角度来看它们的关系:
- 以法固道:理念需要通过法制来规范和约束,才能得到贯彻、落实和巩固;
- 以器载道:善于创造和使用工具,用工具承载理念,可以事半功倍;
- 以道驭术:基本理念和具体操作要统一,即知行合一。
关于如何写好代码,描述如有不当之处,请大家帮忙指正。
最后,一句话与大家共勉:万丈高楼平地起,勿在浮沙筑高台。
参考阅读
[01]《重构》
[02]《代码整洁之道》
[03]《架构整洁之道》
[04]《易读代码的艺术》
[05]《代码精进之路》
[06]《企业应用架构模式》
[07]《麦肯锡教我的协作武器》
[08]《深入理解Java虚拟机》
本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com
文章为作者独立观点不代本网立场,未经允许不得转载。