该文章是我看完张建飞著作的《代码精进之路》这本书而做出的有关日常写代码的一些总结,这只是这本书的冰山一角,书里还有很多其他的一些思想,模型,设计让我们受益良多,推荐阅读
大家说我们日常开发写代码,是写注释好还是不写注释好…
其实真正好的代码会让你看一遍下来会觉得完全没有写注释的必要。
好的代码就应该有一定的自明性,也就是不借助注释和文档,代码本身就能显性化地表达开发者的意图。那么如何达到这种程度呢,接下来可以从命名,规范,函数,设计原则这四个方面循序渐进的来阐述这些观点。
命名
首先就是命名,一个名字虽然不影响程序的执行,但是对代码的表达力和可读性起着最关键的作用,在我们程序员日常的工作中,大部分的时间都在阅读跟理解代码,好的命名能够让代码的概念清晰,增加代码的表达力;词不达意的命名就会破坏我们思考的连贯性,分散有限的注意力。
变量名
我们直接看一个简单的例子:
// 表示过去的天数
int d;
上面这里我们只能通过注释才能知道变量d指的是什么,更糟的是如果没有注释,我们就不得不去寻找它的实例联系代码的上下文才能知道这个变量d代表的是啥意义。但是我们直接能够稍微多思考多花点心思取个好名字像下面一样,之后阅读代码的人就能够很容易地知道这个变量的含义
int elapesdTimeInDays;
同时也避免使用包含意义太广泛的词表示变量比如:data, list等等这种。要尽可能的起到词达意的命名。 如果确实是临时产生的变量可以加上tempo前缀来表示。
一个概念一个词
在一个项目的启动前,我们应该贯彻置底的遵守 一个概念 就用一个词来表达的原则。比如:fetch、retrieve、get、find、和query都可以表达查询的意思,如果我们不约定好使用哪种,那么就调用查询的时候很难去根据名字就直接去调用。可能要跟进到方法里确认。所以我们命名的时候应该保持命名的一致性,广泛的使用建议有如下表示:
CRUD操作 | 方法名约定 |
---|---|
新增 | create |
添加 | add |
删除 | remove |
修改 | update |
查询(单个结果) | get |
查询(多个结果) | list |
查询(分页) | page |
统计 | count |
使用对仗词
yyds,awsl,nbcs
这些词相信大家不是听人解释的话很难理解其中的意思吧,他们的意思分别是:永远滴神!,啊我死了,nobody cares。最离谱的就是最后这个。当然这也充分佐证了我们在写代码的时候最好就不用那些类名里每个单词的首字母做缩写给变量取名,不要怕名字长,能一眼看得出这代表的是什么意思的就是好代码!
遵守对仗词的命名规则有助于保持一致性,进而提升我们代码的可读性,比如first/last这样的词我们就很容易理解,就知道这里就是拿第一个或者最后一个,然后先有fileOpen()之后再冒出一个fClose()这样的组合,就会让我们迷惑,单单一个fClose(),根本不知道“f”表达的是什么。下列就有一些常见的对仗词组:
- add/remove
- insert/delete
- create/destroy
- increment/decrement
- open/close
- begin/end
- show/hide
- lock/unlock
- source/target
- first/last
- min/max
- start/stop
- get/set
- next/previous
- up/down
- old/new
后置限定语
日常开发中会有很多表示计算结果的变量,例如总额,平均值,最大值等。这样我们对变量的命名无外乎就是会使用类似Total,Sum,Average,Max,Min这样的限定词来取名,这样的话就要把限定词加到名字的最后,并且自始至终在项目中贯彻执行,保持命名风格的一致性。
这样做的优点有:
- 变量名中最重要的部分就是表达其含义的部分,放在最前面,就可以被首先阅读到
- 可以避免同时在程序中使用totalRevenue和revenueTotal而产生的歧义
贯彻执行之后就能收货一组非常优雅,具有对称性的变量命名:
revenueTotal(总收入)
expenseTotal(总支出)
revenueAverage(平均收入)
expenseAverage(平均支出)
中间变量
我们可以通过添加中间变量的方式来让代码变得更加自明,就是将我们的计算过程打散成多个步骤,然后用有意义的变量名来命名,就会提高我们整体的可读性
例如下面这个例子,业务为在一个订单作废明细方法中判断订单中的商品,如果是非手动添加的普通商品就直接抛出异常
if (!(Assert.isTrue(bo.getDetail().isGift()) || Assert.isTrue(bo.getDetail().isManualAdd()))) {
throw new OmsException("非手动添加的普通商品不允许作废");
}
我们忽略这里他的写法以及用双重否定的表达方式,把注意力集中到中间变量的使用——如果用中间变量,可以写成如下形式:
boolean isNormalProduct = !Assert.isTrue(bo.getDetail().isGift());
boolean isManualAdd = Assert.isTrue(bo.getDetail().isManualAdd());
if (isNormalProduct && !isManualAdd) {
throw new OmsException("非手动添加的普通商品不允许作废");
}
我们上面就可以看到,只要把计算过程打撒成一系列使用良好命名的中间值,语义可读性自然就会提高了起来。
鲜活的反例就是上面第一种写法间接造成了后来开发的时候导致出问题:下面是后期开发人员根据上面的单个操作增加的批量处理方法,不仅逻辑关系复杂,甚至最后的计算结果都是不准确的
bo.getDetails().forEach(x -> {
if (!(Assert.isTrue(x.isGift()) && !Assert.isTrue(x.isManualAdd()))) {
throw new OmsException("非手动添加的普通商品不允许作废");
}
}
函数名
函数命名的时候我们需要思考的是要体现做什么,而不是怎么做。
这句话怎么理解呢?其实这句话还可以这么说就是函数命名要体现的就是我们想做的业务,而不是说明我们这个函数里面是怎么做的;
举个例子就是,假如我们将客户的信息存储在一个栈中,现在要从栈中获取最近存储的一个客户信息,那么我们就能命名这个方法为getLatestCustomer(),而不是popRecord()。因为栈数据结构是我们函数里面的实现细节,这种方法放在别的业务方法里调用,不写注释的话就稍显麻烦的要去理解这个方法是在干什么,所以我们函数的命名就要体现业务语义,阅读代码的时候看到getLatestCustomer() 就知道这里是"获取到最近的一个客户信息"。
此外,类名,包名,模块名的命名其实都如出一辙,尽可能的表达出业务的含义。同时函数名,类名,包名,模块名这些后者一定是前一级别的上层抽象。
规范
代码格式
代码的格式直接会关系到代码的可读性,因为我们每个人的习惯可能不同,但是一个团队肯定不能让我们各自自由无拘
束的使用自己喜欢的格式,所以我们需要遵从一定的规范,包括缩进,水平对齐,注释格式等。
当然代码格式的规范不是绝对的,也没有哪一种比另一种更好的说法。其实就是一种约定,固化成属于这个团队里的每个成员使用的格式模板,大家一起遵守。
空行规范
空行的规范看似不值一提,其实在日常开发中把握好空行的使用能够极大的提升代码的美观。一个简单的编写代码时添加空行的原则就是:将相关性的代码放在一起,相关性越强,彼此之间的距离应该更短。
但是千万不要在每一行的代码后面都加上空行,这样空行就是去意义了。结果跟没空行差不多。
注释
-
如果注释是为了阐述代码背后的意图,那么这个注释就是有用的。
-
如果只是为了复述代码的功能,那么这里可能就是代码的“坏味道”了。
-
写注释的驱动原因就是弥补代码表达能力的意图有所欠缺。
-
不要复述功能
大家可以思考在自己的日常开发中,什么情况下会去写注释:就是想向后来阅读的人表达我这里在干什么。但是其实大部分驱动原因可能都是弥补我们直接用代码表达意图的失败。
例子:在JDK的源码java.util.logging.Handler中,有如下代码:
public synchronized void setFormatter(Formatter newFormatter) {
checkPermission();
// Check for a null pointer:
newFormatter.getClass();
formatter = newFormatter;
}
这里如果没有注释的话,可能就没人知道这里“newFormatter.getClass()”这行的代码是为了判空了,所以这个注释就是为了弥补代码表达能力的失败而存在的,如果我们换一种写法,使用java.util.Objects.requireNonNull进行判空,那么这里注释的这段话完全就是多余的,因为我们代码本身就可以表达我们的意图
- 要解释背后的意图
如果我们因为种种因素,需要添加注释解释的话,那么我们的注释也应该尽可能解释代码背后的意图,而不仅仅对功能的简单重复描述。
例子如下:
try {
//在这里等待两秒
Thread.sleep(2000);
} catch (InterruptedException e) {
LOGGER.error(e);
}
可以看到其实这里的注释跟没写是一样的,因为这个仅仅就是对sleep这个函数方法的功能简单复述,正确的注释应该要阐述sleep背后的原因,比如:
try {
//休息2秒,为了等待关联系统处理结果
Thread.sleep(2000);
} catch (InterruptedException e) {
LOGGER.error(e);
}
或者直接抽取出来封装成一个private方法,用良好的函数命名的方法来表达意图,这样就不用注释了。
private void waitProcessResultFromA() {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
LOGGER.error(e);
}
}
所以,我们关于日常开发中对注释的操作可以总结为:
- 不要写重复描述代码功能的注释
- 写注释尽量阐述背后的原因
- 最好通过代码本身来阐述意图
函数
如果把数据比作一道菜,那么函数就是菜谱,程序员就是厨师。相同的菜,有不同的做法,由不同的厨师做出来,味道会截然不同。
- 封装判断
好的函数应该是清晰易懂的,我们在很多的if和while语句中的布尔逻辑难以理解的时候,如果把解释条件意图作为函数抽离出来,用函数名把判断条件的语义显性化地表达出来,就能大幅地提升代码的可读性和可理解性。就拿我们上面介绍的中间变量举的例子为例,此处我们可以进一步的优化可读性就是把此处的判断封装成一个函数:
if (isNormalProductAndNotManualAdd(bo)) {
throw new OmsException("非手动添加的普通商品不允许作废");
}
.....
private boolean isNormalProductAndNotManualAdd(SalesOrderDetailBatchBO bo) {
boolean isNormalProduct = !Assert.isTrue(bo.getDetail().isGift());
boolean isManualAdd = Assert.isTrue(bo.getDetail().isManualAdd());
return isNormalProduct && !isManualAdd;
}
- 函数参数
最理想的函数参数就是没有参数,其次就是1个,再次就是两个,应该尽量避免三个及三个以上的函数。当然根据业务场景,有足够特殊的理由也是可以的,而且这里有关函数的定义还是主要根据场景,有些情况下:两个参数就会比一个参数好。比如代表的是坐标系中的点:
Point p = new Point(0,0);
但回归总体上来说,函数的参数越少,我们也越容易理解,也更容易使用和测试,因为我们对一个方法进行测试传递的各种不同组合的测试用例就是一个笛卡尔积。在日常开发中,如果我们碰到函数需要3个以上的参数,就说明其中的一些参数可以抽象出来封装成类了。比如绘制一条直线:
Line makeLine(double startX, double startY, double endX, double endY);
我们知道在二维坐标轴里x, y就是最基础的用来确定一个点的概念,这里我们就可以将x, y抽象出来作为一个新的抽象对象叫做点:Point。然后将参数对象化之后,参数的个数减少了,表达上也更加清晰了
Line makeLine(Point start, Point end);
class Point{
double x;
double y;
...
}
- 短小的函数与职责单一
编程大师Robert C. Martin有一个心跳就是:函数的第一规则是要短小,第二规则是要更短小。
其实在我们日常开中,做需求改bug需要了解上下文的时候,应该都会被那种超长函数折磨困扰吧,相比于那些几百行甚至几千行代码的“庞然大物”,肯定是更短小的函数更易于理解和维护的。关于函数的代码行数多长才是合适的,这个就没有一个量化标准了。有的说80行,甚至就是说不超过20行的。
但是其实关于短小的函数的定义,另一种衡量方法就是是函数级别的职责单一原则。就是一个方法就只做一件事情。我们遵循了这个原则之后就可以提升代码的可读性,还可以提高代码的复用性。
通常,我们碰到的长方法是肯定需要拆分的,但是并不意味着短小的函数就不需要拆分,如果该方法不满足单一职责原则,就值得进一步的进行分离。就算分解后的子函数就是一行代码,但只要是有助于我们理解业务,就是值得的。
举例说明一个给员工发工资的简单方法:
public void pay(List<Employee> employees) {
for (Employee e: employees) {
if(e.isPayDay()) {
Money pay = e.calculatePay();
e.deliverPay(pay);
}
}
}
我们是如果遵循单一职责原则就会改成下面的方式:
public void pay(List<Employee> employees) {
for (Employee e: employees) {
payIfNecessary(e);
}
}
private void payIfNecessary(Employee e) {
if(e.isPayDay()) {
calculateAndDeliverPay(e);
}
}
private void calculateAndDeliverPay(Employee e) {
Money pay = e.calculatePay();
e.deliverPay(pay);
}
提升抽象思维
关于如何提升抽象思维,就是这句话:
要自下而上地思考,总结概括;自上而下地表达,结论先行。
举个例子你老婆叫你去超市里买点东西,分别要买葡萄,橘子,咸鸭蛋,土豆,鸡蛋,牛奶,胡萝卜然后出门的时候又交待再买点苹果,酸奶。
我们如果一下子硬记这9中物品,不是记忆力超群的话到了超市后兜兜转转还是很容易就漏了忘了,但是如果我们分析这些商品,把他们抽象出来进行分类的话,就会让我们此次行程不会挨老婆的骂了~~~~
咸鸭蛋,鸡蛋,酸奶,牛奶 可以抽象成蛋奶产品类;
土豆,胡萝卜 可以抽象成蔬菜类;
葡萄,橘子 可以抽象成水果类;
这样记得话一下子就清晰了很多,我们到超市里,只要到了一个区域就可以直接买齐属于这个区域类下的所有东西。
设计原则
-
单一职责原则:一个类的功能不应该是多种多样的。是什么类型,就只负责有关的功能跟方法。
-
里氏替换原则:子类可以代替父类出现在任何父类可以出现的地方。实现父类代码真正的复用,解决继承的侵入性详细解释
-
依赖倒置原则:更高的模块不应该依赖于低层次的模块。核心为面向抽象编程。实体类与实体类之间避免依赖关系 详细解释。
另提一嘴(觉得说的很好):
抽象是对实现的约束,是对依赖者的一种契约,不仅仅约束自己,还同时约束自己与外部的关系,其目的就是保证所有的细节不脱离契约的范畴,确保约束双方按照规定好的契约(抽象)共同发展,只要抽象这条线还在,细节就脱离不了这个圈圈。就好比一场篮球比赛,已经定好了规则,大家如果按照规则来打球,那么会很愉快。但是假如大家脱离了规则,那么也许比赛就无法顺利进行。
-
接口分离原则:接口的负责的功能也要单一。
-
开放封闭原则:开放对功能的扩展,避免直接修改基类中的代码(在系统设计之初,应该考虑到后期在面对新需求时应该尽量的增加代码去实现,而不是直接去修改已有代码) 详细解释。
给公司做出的最大的技术贡献就是留下一个清晰易懂,可读性,可维护性强的代码库。