我的代码风格

背景

写点杂文

正文

代码规范

不同的人有不同的编码风格,也就逐渐形成自己的编码规范。但无论是怎样的风格迥异,很多基本的共识还是有的。

个人推荐 PEP 8 (虽然它是用于描述 Python 的,但是大多数语言都类似的)。

变量命名

我是 C 程序员出身,早期的命名风格受 C 风格的影响,很喜欢用缩写。cpy 指 copy,cmp 指 compare,mv 就是 move。直到开始写 Objective-C,才开始接触到另一类的命名哲学,并逐渐喜欢上这种风格。

在 Objective-C 里,很多的变量都特别的长,总是试图将这个变量代表的含义尽可能地表达全面和准确。

1
NSString *status; // status of user's dangerous action
对于上面的变量,Objective-C 的建议是(先忽略驼峰和蛇形风格的讨论):
1
NSString *status_of_user_dangerous_action;
可以看到,Objective-C 的做法是直接将注释里的空格替换成了下划线作为变量名,然后顺手干掉了注释。

在参工的最开始两年,我以写完整、细致的注释为荣,但后来我不这么觉得了。

程序员是艺术家,代码是艺术品。如何让代码这块艺术品展现它的最高价值,那就是让更多的人去阅读它、去使用它。当你的代码被其他人使用的时候,那就是你工作价值的体现,这不仅仅是养家糊口的问题,更是一种无上的荣誉。

当一幅精美的画作或绝世的书法被创作出来后却一直深埋地底,暗无天日直到腐烂分解,那不就等同于这世上从没有过这样的珍品么?

我们写代码最终目标不仅仅是为了能正常跑起来,更重要的是做到让别人更容易地理解我们的代码。 从这个角度来分析,写注释能方便我们阅读代码吗?确实能,至少比在这基础上直接干掉注释好。但我觉得 Objective-C 给出了一个更优的解法。

那为什么 C 里面使用了这么多缩写的变量?是因为当时的程序员审美有问题?要解释这个问题得从当时的环境分析,那个时候编码的 IDE 环境还不成熟,都没有像样的变量补全工具,取这么长的变量名字,每次用到的时候敲起来非常的麻烦。所以跟生产力比起来,审美这种奢侈品得靠后站。但现在不一样了,时代变了,温饱问题已经解决了,很多的基建工具都非常的方便,于是生为现代的程序员就得有更高的追求

函数命名

我很讨代码里有词不达意的函数命名,因为这会误导别人对代码的阅读,增加他人阅读成本。

在一个只带 get 单词的函数里就不应该出现 set 的操作,在一个只带 open 字眼的函数里就不应该有 save 的逻辑,在一个处理体重的函数里就不应该再去处理身高。

在做到 所见即所得 后,还需要表达清楚整个函数的完整逻辑,我总结的是一般遵循下面的公式:

[谓语] + [宾语] + [状语]

即:

[动词] + [名词] + [介词短语]

比如:get_user_info_by_userid 、set_order_status_with_orderid 、upgrade_level_via_vip

这也是我在使用了其他软件后总结出来的,比如 vim 就有类似的思想:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 动词:
d delete 删除
y yank 复制 (为啥不用 copy ,个人猜测是从 emacs 借鉴来的)
r replace 替换
v visual 选择
c change 改变/编辑
# 介词
i inside 在……之内
a around 环绕
t till 直到……(不包括该字符)
f find 直到……(包括该字符)
# 名词:
w word 单词
s sentence 句子
p paragraph 段落
vim 的操作公式基本遵循:

[动词] + [介词] + [名词]

比如有下列的一些组合:

1
2
3
4
diw # 删除当前光标所在单词。 delete inside word,你可以试试不要介词,直接 dw 会发生什么
dtw # 删除字符直到遇到 w 为止(不会删除 'w')
cis # 修改当前句子。会先删除当前光标所在句子,然后立即进入编辑模式
viw # 选择一个单词,准备执行下一个操作
然后开始举一反三:

有了 diw ,那么 dis 就代表着删除一个句子,dip 就代表着删除一个段落了。d2w 代表着删除光标后的两个单词,d3w 删除三个单词……更进一步地,w 代表 word,那么 l 是不是就代表着 letter 呢?d3l 就是删除光标后三个字符吧?

vawU 将光标所在单词转换成大写,v2wU 将光标所在单词的两个单词转换成大写,再进一步地,vawu 就是将光标所在单词转换成小写……

所以 vim 其实也没那么难,很多命令都是我自己摸索推导出来的,我后期几乎很少查看 vim 的帮助文档。因为你只需要掌握一个 公式 即可。

这是也我为什么觉得代码也是艺术品的原因。它也反映了作者的哲学思考,反映了作者对于这个世界思考问题的方式。以 vim 为例,它是自洽的、自由的。这种设计理念在我回想起群论思想和编译器的状态机的时候突然变得豁然开朗,果然 great minds think alike

同理,我们自己写代码也应该有这样一个公式,可以极大地减少他人的阅读/学习成本(同时它也注入了你的心血,承载了你对这个世界的思考)。

逻辑抽象

抽象本身就是一个很抽象的概念,我这里不打算用我浅薄的能力尝试去给它一个精准的定义。我只会尽量多举一些例子来表达我对 抽象 的理解

为什么要抽象

上面讲了如何让我们的代码更容易的被别人阅读,但是我们还应该更容易地让别人修改代码,要做到可读可写。毕竟你不可能一辈子都维护着自己的代码,总有交给别人的时候。

一个好的抽象可以大大降低维护的成本,但是它不一定能改善他人的阅读成本,甚至有的场景下还会增加阅读成本。但是阅读只是一时难而已,但维护则是一项长期工程,这点牺牲根本不算什么。

如何抽象

互联网里的任何问题都可以通过增加一个层来解决

这句话 99% 的程序员都听过,但大部分的程序员对它的理解只停留在表面,因为他们从未想起应该在实战中用起来,或者说不知道该怎么用。一个观念如果你只听说而没有使用过就相当于你从未听说。

在面向对象的编程模式中,基类 是一个很重要的概念,其实它就是一个典型的层。通过把各个子类里公共的属性和方法抽出来单独组建成一个新的类,来达到结构优化的目的。后续在新增子类时,不用再把重复的属性和方法再写一遍,直接继承基类就好。

抽象的过程其实就是提取公因子的过程。

现在就打开你最近才完成的一个项目,然后再通读一遍所有的代码。如果发现了有两处或多处有相同或相似逻辑的代码,那就说明这些地方需要提取公因子了,他们应该被包装成一个新的函数。将这个新函数放到一个叫 utils 的文件里,然后把有差异的地方做成入参,最后这些被提取的地方再调用一下这个新包装出来的函数。等把项目所有代码都这么优化一遍后,接着再聚焦 utils这个文件,再用同样的方法将里面已经抽出来的公共方法再(尽可能地)提取一遍公因子,继续精简可能的重复的代码,直至无法精简为止。

这样的操作显然会花费很多时间,在正常的工作环境中,老板是不可能给你这么多时间去重构、去优化你的代码的。我也遇到过这个矛盾,但我有自己的解法,很简单,也很通用,就是勤奋,因为我有的是时间,一天足足有 24 小时。别人一生最累的可能是高三,而我则是刚参工的那两年,每天都是 2、3 点才睡。当这样坚持两年后,以后写代码就会有习惯和预感了,不会再这么幸苦地事后再提取公因子了,在第一次写的时候就可以设计得差不多了。

这样的提取公因子有以下几点好处

  • 精简之后项目代码总量一定会减少(如果没有减少,那说明这属于过度抽象了,需要回滚)。而通常情况下 bug 数与代码量成正比,所以你的 bug 数量也应该会有所减少
  • 精简后的代码不会有一行代码是多余的,每一行代码都有它存在的意义,维护成本会减少
  • 项目结构更加清晰,对于后续可能的拆分和移植会更加方便
  • 在这基础上新增/修改需求非常的简单,简单到我很难用语言去描述清楚,这样的快乐只有本人才能感受到

函数签名

前面说到,当自己的代码被别人使用的时才是体现自己价值的时候。那怎样才能构造出一个使用友好的函数签名来吸引更多的用户呢? 1. 函数入参数量要少 2. 函数入参类型要简单 3. 函数返回数量要少 4. 函数返回类型要简单

零入参的当然比有入参的函数好使,有些时候由于业务的复杂性,不得不搞出 4、5 个入参的那也实在没办法(我曾经写过一个有 7 个入参的函数,恶心死我了)。这个时候不建议为了强行减少入参数量,将这些参数统统打包成一个 struct/class 再作为入参传入了(如果这个 struct/class 不会再在别的地方使用的话)。因为这样反而会增加阅读和维护成本,还需要申明一种新的 struct/class,本质上,这无非就是把一堆横着写的参数变成了一堆竖着写的属性罢了,并没有做到真正的结构优化,只是换了一张皮而已(匿名 struct 除外)。而且这个也与第二点冲突。

入参类型要足够的简单和常见,比如:string、int、list、map 等,尽量少用一些自定义的类、函数指针等参数(除非他们会在很多地方都被使用到)。尤其是参数是函数指针的,对于一个陌生的客户来讲,看到一个以函数指针作为参数的接口,本能地就会产生恐惧情绪,为了了解你这个函数的用户还得先去了解另一个函数的用法?相当于是套娃了,搁这搁这呢

C 语言的返回数量都是一个,通常都是一个 int,代表着错误码,遵循了 C 语言的设计哲学(这个可以以后聊)。但现在很多语言都支持直接返回多个元素,与入参不同,我支持将这些多个元素打包成一个匿名 list/map 返回。因为在作为返回值的场景下,在大多数的语言里不再需要声明和初始化了。

最后尽量不要返回自定义的类型,不然用户就要为了这个自定义的类型而引入一个全新的文件(除非这个自定义的类型的申明与被调用的接口申明放在了一个文件里)

不过根据我的经历,在实际应用场景下,很少有 SDK 的接口(包括我们自己设计的)能完美地做到上面的几点,但毕竟总得给自己定一个目标不是。一个好的接口都需要将心比心,减轻使用者的压力,降低用户的抵触情绪,这样才能吸引更多的用户来用我们的接口。

安全检查

代码的安全又是另一个庞大而杂乱的讨论主题,我这里也不想花太多时间,只说说最常见也是最重要的入参检查。

入参检查

互联网上数据的流转就像一台流水线,数据被流水线上的每一个工人挨个依次处理。我们的代码就是这流水线上的其中一个工人。我们接收上一个工人的输出作为我们的输入,经过一堆业务逻辑的处理后,再输出到下一个工人手上。

这个输入是上游给的,我们无法判断这些数据的正确性。它可能是恶意的,由一个不怀好意的工人生产,也可能是一个善良的工人意外搞出了个 bug 导致的。所以我们无法揣测别人是正是邪,但我们可以要求自己始终坚持 人性本恶 的原则去构建我们的产品。

对于任何带有入参的函数,请务必先对其做合法性校验

所以我自己写函数的时候,如果这个函数带有参数,那么函数的第一行一定是一个 if 判断,这几乎是我养成的一个习惯,快成肌肉记忆了。

凡事都一定有上下限。 * 在一个入参为年龄的函数里,这个年龄下限是 0,上限怎么着也不会超过 1000 万年吧(毕竟人类历史也不超过 1000 万年) 此外还需要判断一些特殊值的: * 如果是 string 或数组,需要判断长度是不是 0 * 如果是指针,需要判断是不是 nil

记得早些年对接一个 SDK,调用其中某个函数时误传了一个 nil 的指针进去,结果那个函数不仅不报错,反而还返回了一个跟正常参数格式一样的结果出来,导致后续发生了一连串诡异的 bug。当我最终定位到这个问题并反馈给对方开发时,对方回复:

这个是你自己使用的姿势问题,三岁小孩都知道我这个函数参数不可能接受一个空指针

有时候我是真的很佩服西方人精准的逻辑,他们使用了 clientserver 这两个词,而不是别的。我也很庆幸中文翻译没有自作聪明,忠于原著地翻译了这两个词:客户服务。我试着翻译翻译什么叫 服务

如果你开了一家餐饮店,你服务的基本目标就是为客户提供正常的饭菜,无论你的客户是工人、老师、医生,又或者是小偷、强盗和杀人犯,甚至傻瓜和疯子都能来你的餐厅顺利地吃上一顿饭。如此傻瓜式的体验才是强大的服务(如果业务逻辑要求拒绝为某些人员提供服务,那也应该写清楚逻辑,而不是让他们在餐厅里乱窜,影响其他客户的进餐。而这本身也就属于服务的内容)。

所以那哥们不可能写出健壮的代码,因为他无法给低于三岁的客户提供服务。没办法,我只能自己在外面判断一下是否为 nil,然后再去调用他的函数,如果有另一个人写了个跟他一模一样的 SDK,但是在入参那函数自己判断了一下是否为 nil 的话,那么我肯定会去换他的 SDK,因为这次我知道在外面提前判断一下,那下一次呢?下下次呢?我或者其他人总有忘记的时候吧?写代码的也分程序员和码农啊。像上文说的那样,一个优秀的 SDK 会尽可能地多考虑使用者的感受,减轻客户的使用门槛,这样才能使客户专注于自己的业务逻辑。

现在每当我去研究其他项目的开源代码,看到带参数的函数第一行不是入参检查时,作为有着黑客经历的我总是不自觉地开始激动得心跳加速,就像猎豹嗅到了空气中的一丝血腥味,因为我知道我要的猎物很可能就会出现在接下来的代码里。

总结

优秀的代码各有各的风格,我上面只是我的建议。我也读过不少艺术品。对我来说,它们都有一些共同点:

看之前觉得自己可能会看不懂,远古大佬的作品,我一个凡人得花多少时间才能窥探一二?看之后觉得也不过如此,真理可能就是如此的朴实无华,没有装模作样的强行抽象,一切行为都是如此地顺理成章、浑然天成。大佬们也会为了节省那么几十个字节的空间而绞尽脑汁,也会为了提高一丁点儿效率做一些现在看来显得有些愚蠢的体力活。一切都没有捷径,不神秘,也不普通