Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

代码整洁之道 #60

Open
Petelin opened this issue Aug 28, 2020 · 0 comments
Open

代码整洁之道 #60

Petelin opened this issue Aug 28, 2020 · 0 comments

Comments

@Petelin
Copy link
Owner

Petelin commented Aug 28, 2020

代码整洁之道

3. 函数

3.1 短小

函数第一规则是要短小,第二规则还要短小

以前我很讨厌有人写只有一行逻辑的函数, 直到我遇到了这些问题

  • 含义不清楚,strings.Contains(xxx) 这只是一个标准库的用法,跟你想要描述业务有什么关系? 代码不能做到自解释

  • 扩展性,userType=Guest, 当时只是一句就能写清楚的逻辑,因为没有抽出来函数,导致我们对Guest这个身份扩展了, 所有地方都要修改一下

3.2 只做一件事

函数应该只做一件事。做好这件事。只做这一件事。

判断函数是不是只做了一件事情的方式就是看能不能在拆成有意义的子函数。只做一件事情的函数无法合理的被切分为多个区段。切分会显得很不流畅。而只做一件事情的函数读起来是很流畅的。

只做一件事会把函数且的很细,这是不好的,但是会在扩展这个函数代表的意义的时候会变得很容易。

  • 路由函数要抽出来
  • 公用的代码要抽出来
  • 不同「阶段」的是事情要抽象出来

3.3 每个函数一个抽象层次

一个函数中混在不同的抽象层级,往往让人迷惑。 读者无法判断某个表达式是“概念”还是“细节“, 跟恶略的是,像是破损的窗户。一旦细节和基础概念混杂,更多的细节就会在函数中纠结起来。

一个函数写的好不好就要看函数中的所有语句是不是在一个抽象层级上。

一个函数要么是做了一些事情,要么把下一层的粘连在一起。就像是砖和混凝土的关系。

自顶向下读代码, 每个函数天生就是包含了其他函数, 然后组合其他函数. 组合同一级别的函数才是合理的做法. 这样写出来的代码逻辑非常清晰, 你可以看广度优先遍历的方式去读代码.

3.4 对于swtich的态度

如果是小范围的,在底层的函数,还可以接受

如果是顶层的,经常变更的。需要换用工厂模式去实现。

image

3.5 使用描述性的名称

  • 如果每一个函数都让你感到深合己意,那么就是整洁代码。
  • 别害怕长名称,长而具有描述性的名称,要比短而令人费解的名称好。
  • 别害怕花时间取名字, 一切都物有所值
  • 按照某种命名约定去生成名字,保持一致,使用和模块莫一脉相承的短语。

3.6 函数参数

  • 如果参数和函数名处于不同的层级,那意味着你要去了解目前并不特别重要的细节(不符合广度优先便利读代码)

  • 从测试角度来说,参数越多越难构造。( 这里是从整体拆分功能角度说的,如果你无脑把多个参数合成一个参数的struct,那你只是在骗自己, 并不能使得测试变简单)

  • 二元函数,可以思考把第一个参数做成类

  • 参数越少越好,不要传3个以上的参数, 如果超过了, 意味着这里有一个模块可以拆出来. 多个参数,肯定可以抽出来概念,组合在一起

3.7 无副作用

  • 如果有副作用写到函数名中

  • 输出参数别放到参数中。 因为很容易混淆对参数做事,和把事情做到参数中。

3.9 依赖磁铁

错误码通常实现为枚举,定义所有的错误码。

这样几乎所有的类都要导入和使用它。当Error枚举修改的时候,所有地方都要从新编译和部署。

我们应该使用异常类的方式,这样就符合开闭原则,无须重新编译和部署。

(因为java类编译有缓存, 对go没啥意义)

3.10 不要重复你自己

重复可能是软件中一切邪恶的根源

3.12 我并不从一开始就按照规则写函数,我想没人能做到。

我们需要不断地修改, 提升自己, 提升代码质量

4. 注释

代码本身就是注释。

注释不能让垃圾变美好。

不要把git能解决的事情写到注释中, 比如change log

4.3 好注释

  • 法律信息
  • 提供额外信息的注释。比如一个wiki百科链接, 一个业务当时的url
  • 对意图进行解释。 意图意味着人为的决定。代码只有结果, 不能反映你当时的处境和这样写的原因。
  • 对于晦涩的分支做阐释。但是要冒着阐释是错误的风险。本质原因是代码难读。
  • 告警
  • TODO注释
  • 放大细小之处。以免接手的人忽略
  • 公共文档

4.4 坏注释

  • 只对作者有意义的注释

  • 多余

    • 简单的函数应该用函数名解释自己
    • 日志型注释,每次改动都加一个注释,这应该放到源代码管理工具中
    • 署名
    • 注释掉的代码
  • 误导性注释

  • 循规蹈矩生成的注释

  • 位置标记

  • 括号、锁注释

6 对象和数据结构

6.1 数据抽象

私有变量的含义并不是不让别人访问那么简单,而是把抽象和具体数据隔离开。隐藏实现,只暴露抽象接口。所有的get, set方法都要是业务上的set, get,而不是简单的赋值器。

6.2 过程和面向对象的对立

  • 对象把数据隐藏在抽象之后,只暴露操作数据的方法
  • 数据结构直接暴露其数据,没有提供有意义的函数

过程式代码

过程式

面向对象多态类:
多态类形式

上面两种风格的代码:

  1. 如果要增加一种新的计算方式, 对于过程式, 完全不用改数据结构, 只需要新增计算过程. 但是对于下面的, 就需要所有地方都新增一个方法

  2. 如果要增加一种新的类型, 对于过程式, 需要改所有的计算过程, 但是对于面向对象, 就只影响新增的对象.

这个问题很早就被人发现了 The problem was first observed by John Reynolds in 1975.[2]
https://en.wikipedia.org/wiki/Expression_problem
就是增加一个类,和垂直增加一个方法的的优雅解决方式。

由此, 我引申出来两个扩展的方向:

  • 横向扩展: 增加一个新的种类, 比如新增一个形状, 新增一个用户类型, 新增一种验证方式(加数据结构)

  • 纵向扩展: 对现在已有的流程,进行加固,比如登录流程增加一个验证码校验的过程. (加函数)

对于业务来说, 我们应该以支持横向扩展为目标, 尽可能简单的用纵向扩展的方式去写代码. 纯写成容易横向扩展的代码, 平时加一个简单功能都要改半天, 同时会不得不面对, 接口设置不合理, 需要时时重构, 写个小需求工作量巨大. 但是如果不支持横向扩展, 等需要改的时候, 就只能累死了.

另外, 对于学艺不精的人来说, 强行去写面向对象的代码(支持横向扩容) 容易做出来一个类需要1万个方法......等你真的横向扩展的时候, 发现自己要实现1万个方法, 而且还冗杂一些dispatch的过程式代码. 也是改不动的

另外大多数没有训练过写面向对象代码的人, 很容易写出来第一种过程式代码, 因为人的思维就是过程使得, 面向对象反而是一种抽象, 升华

我们常常会面对这样的场景:

  • 新新手/刚起步的代码喜欢用过程式代码,因为修改起来非常方便,一个switch case打天下。这时逻辑还不够复杂。横向扩展就找到所有用到的地方去修改一下~ 之后有一天需要横向扩展,来一个需求之后发现,无法遍历所有需要修改的地方了。。。本质上是系统耦合度太高,所有东西都搞在一起乱糟糟的。
  • 等到后期,面对shi一样的代码,老油条会竭尽全力避免修改之前的代码,做横向扩展。什么需求都想要单独来一套,然后会发现和之前业务重叠的地方,之后又小需求变更(纵向延伸),两个地方都要改。也痛苦的要死。本质上是因为内聚性不够了,横向扩展的没有通用性~

总结一下

image

6.3.1 在谈对象和数据结构

得墨忒耳定律。 一个模块不应该了解其所操作对象的内部情况。

也就是说一个模块只能调用高级方法,不能访问内部数据结构,不允许存在get,set。只能有业务上的save,check....

要注意的是,这个定律中模块的概念应该是偏上层的。 也就是搞等级的类。 对于低级的数据结构来说,肯定要去操作内部成员的嘛。 所以这里还是两个截然不同的概念:

  • 数据结构类: 也不要使用get, set, 直接使用a.b.c

  • 模块类: 禁止访问成员变量字段, 只能使用方法, 即隐藏数据,暴露操作。

这里有一篇文章很好的解释了这个定律到底在讲什么:

https://www2.ccs.neu.edu/research/demeter/demeter-method/LawOfDemeter/paper-boy/demeter.pdf

重点在于OOP的抽象合理,人不能提供一个钱包对象给收银员,让收银员从里面拿钱。 但是人可以把钱包里的驾驶证直接给police。这都是对现实世界的建模,越贴近现实世界,需求才会越合理。

不使用这个定律的缺点:

  • 暴露了model内部的数据,使得多个模块紧紧的耦合在一起。Paperboy必须知道wallet信息,那万一用户用的是credit card呢?
  • 暴露数据,意味着这个数据被窃取了,如果外部使用不当,比如扣-10块,或者人民币钱包,他传一个美金10doller怎么办? 当然你可以说我做检查等等等...但是这都使得paperboy的逻辑变得冗余。还是那个想法,paperboy不应该去思考用户钱包的问题!

这个定律的优势:

  • it better models the real world scenario
  • the Wallet class can now change, and the paperboy is completely isolated from that change
  • most 'object-oriented' answer is that we are now free to change the implementation of 'getPayment()'.

其实就是这样写更反映真实场景,意味着paperboy和customer.Pay和wallet都可以任意演化了。松耦合的最佳例子。同时反映真实场景,也意味着逻辑收敛到了一个地方。

这个定律的缺点:

  • 有人会说customer的逻辑变复杂了,但是这个逻辑本身就是存在的,改不改都有,改了反而逻辑/结构更通顺了。
  • trying to apply it in every case that seems appropriate。 手里有锤子看什么都是钉子。 如果是警官拦下你的车,找你要wallet里面的驾驶证,那你不把驾驶证整个信息给警察肯定是不对的。这个时候就是需要你直接返回驾驶证信息。所以也不是任何时候信息都不跨级传递。但是这里最好传递的是一个DTO,entity。 让警察自己去封装entity。 这样两边还是能独立演进。

最小知识原则是一个底层原则,很多设计模式体现的就是这

At its simplest, this is simply 'delegation'.

Consider the 'wrapper' patterns, Adapter, Proxy, and Decorator. Each of them hold onto an object, 'wrapping' it for some purpose.

This is the Law of Demeter in the sense that it is 'hiding' the wrapped object, making the user use it through a different interface. The 'Facade' pattern is another great example of the Law of Deme.

6.3.2 对象和数据结构混杂

image

7 错误处理

错误处理很重要,但是如果他搞乱了代码逻辑,那就是错误的做法。

作者始终对错误处理的态度是。将错误处理隔离看待,将其独立于主要逻辑之外。

7.1 是用异常而不是返回错误码

作者站在java的立场上,认为try catch把正常逻辑和错误逻辑分开了,这样逻辑紧凑,不会被分割。

实际上每一个最小函数单元,内部总有各种错误。可以通过各种手段兜底然后继续进行。通常这时候错误处理就是和业务一起写的,这里我跟赞同early return的做法。

不过错误码确实是很糟糕的做法。你永远不应该使用错误码。因为错误码包含的信息太少太少。需要定义的错误码太多太多,你需要的是一个struct, 一个interface, 一个类,反正不是一个数字。

7.2 先写try catch finally 语句

作者认为这样语义清晰,这种结果定义了一个范围,在各自的范围里面,你肯定知道不同的东西是在干什么。

我认为这件事并不重要,因为这个并是像if else一样通用,没必要做成语言特性的。只有在少数地方,比如写测试代码需要init, cleanup才有这种语义。

不过结构清晰我是赞同的。需要给不同的模式编写不同的scope函数。

7.3 使用不可控异常

这章是在讨论在return里面强制声明异常种类是不是好的行为。 结论是不好。原因是

你就得在catch语句和跑出异常处之间的每个方法签名中声明该异常。这意味着对软件中低层次的修改,波及到就高层级的修改。

这章我非常赞同,特别的,这对于入参和出参都是一样的道理。如果入参/出参有很多种,每次家业务都需要改函数签名。那么就是垃圾代码。不仅是中间所有的函数签名都需要修改。还意味着你没有抽象出来调用结构体。

当然,如果你写的是非常核心的代码。强行的定义各种参数是很合理的做法。

7.3 给出异常发生的环境说明

你抛出的每一个异常,都应该提供足够的环境说明,以判断错误的来源的所处。

stack trace并不能告诉你操作失败的初衷。

在go中,你应该用 fmt.Errrof("....%w....") 把环境信息裹进去。

7.5 定义异常类型

可以做聚合判断,不多讲。在go里就是 errors.As

7.6 定义常规流程

当需要检查错误然后走另外一个分支的时候, 想想能不能通过抽象。把不同的分支放到不同的实现中。

这种手法叫做特例模式。 创建一个类或者对象。用来处理特例。这样上层代码就不用应付异常行为了。

expenses = people.GetMeals(peopleID)
if expenses != nil{
	mTotal += expenses.getTotal()
}else{
  mTotal += constant_fee
}
获取员工的每天吃饭花了多少钱如果当天没花钱给员工餐补mTotal是公司支出这段代码可以被优化为

expenses = people.GetMeals(peopleID)
mTotal += expenses.getTotal()

奥秘在于expenses 既可以是花费的餐费也可以是餐补他们都去实现get total方法就好了

这个思路可以在很多地方应用, 很好的想法

7.7 别返回null值

返回null,比如抛出异常,或者返回特例对象

我对这件事的看法是,你提供一个函数要尽可能返回所有信息,拿不到东西是什么原因呢? 上层肯定要关心的为什么拿不到的。

否则wrap一下做成 mustGet 也可以接受。

7.8 不要传递null值

在大多数编程语言的。没有良好的方法应对调用者意外传入的null值。事已至此,恰当的做法就是禁止传入null值。这样,在你编码的时候,就会时时记住参数列表中的null值意味着出问题了。

因为go中存在指针,这个问题要比java更严重。

  • 性能考虑传指针避免拷贝
  • 就是需要nil作为参数,表达这个值不存在

除此以外不应该大量使用指针(nil)。 而对于第一点,大部分人没有资格去争论这个。

8 边界

边界是指对第三方包的依赖,这个第三方包可以是utils,mysql,abase,rpc,github project...

这些需要整合到你的代码中,整合需要手段

8.1 使用第三方代码

作者举了一个java.utils.map的例子,最终给的建议是使用一个类封装map[user]的操作。

实际上做的更彻底的方式是这样的。

  1. 对于utils.map 定义自己的interface,这样可以随时更换interfa的map实现。
  2. 定义业务使用interface。

这两层写下来或有一些繁琐,但是可以极大地提高灵活性。

我在业务代码里遇到过两种类型的改动:

  1. 更改json marshal方式,更改redis客户端版本。更改mysql connection库。
  2. 对第三方的返回的值做一些修改。比如一个rpc,返回一个参数是1.我需要调用另外一个rpc(层次1)改掉值返回给上游。层次2 给了你聚合层次1的结果(不要问为什么不在业务上做聚合)

8.2 对第三方代码进行学习性测试

这种毫无成本,但是把测试代码固化到了项目中,之后第三方依赖改动不兼容很容易发现。

并且使得升级过程更大胆,因为测试都跑过了!

8.5 使用尚不存在的代码

有时候你可以先自己定义自己的接口,然后等真正的代码ready之后,适配你之前的定义。这个在mock的时候很好用。而且使得你不被block。多一层这个抽象并不是什么大问题。

8.6 整洁的边界定义

边界上改动是很正常的。作者说有着良好软件设计的,就无需巨大投入和重写即可进行修改。当你依赖控制不了的代码时,比如加倍小心投资,确保未来修改的代价不至于太大。

边界上的代码需要清晰地分割和定义期望的测试。避免我们了解过多第三方代码中的特定信息。

包装接口,或者使用adapter模式都可以是的后面的改动小一点。

9 单元测试

什么是专业的测试? 请看作者写的测试。 注意测试和代码的结构是密不可分的,垃圾代码是没办法测试的。

image

9.2 保持测试整洁

测试不是二等公民,他和代码一样重要。如果测试写多了改不过来,就会成为负担。

测试带来的一切好处:

测试覆盖越多,越能毫无顾虑的改进架构和设计。

测试最重的要的是什么?

可读性 可读性 可读性。 这个要求是对测试最重要的属性。

手段是什么: BUILD OPERATE CHECK

9.3.1 面向特定领域的测试语言

这种测试api并非起初就设计出来,而是对那些充满细节的令人迷惑的测试代码进行重构时逐渐演进。

9.3.2 双重标准

测试代码和正式代码的标准是不一样的。 整洁是第一位,效率是可以放在后面的。

9.4 每个测试一个断言

有个流派认为每个测试中只应该有一个assert。 但是这写代码重复太多了。

这样说是有道理的,但是有多个assert也不是接受。

这个是一个小问题。

更好的原则或许是,每个测试一个概念

9.5 FIRST 原则

快速, 独立,可重复(在各种环境都可以测试),自足验证(不需要人工干预就能得出结果), 及时编写

Fast, Independent,repeatable, self-validating, timely

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant