(贾)岛初赴举京师,一日驴上得句云:“鸟宿池边树,僧敲月下门”。始欲着“推”字,又欲着“敲”字,练之未定,遂于驴上吟哦,时时引手作推敲之势。
—— 宋·胡仔《苕溪渔隐丛话》卷十九引《刘公嘉话》
命名,是编码中最为紧要的事情,其之于程序,便如脸面之于少女。好的命名,能清晰的传达代码的意图,甚而,有一种韵律的美感。而懒散随意的起名,则令人如堕云雾,不忍卒读,会一遍遍地消耗维护者的精气神儿。此外,混乱的命名体系,能轻巧的掩藏 BUG,贻祸千里。
因此,我们在写代码时,有必要花一点时间,对关键命名进行推敲,与人方便,与己方便。对于生命周期越长的项目,其益处便越明显。那么该如何推敲呢?结合自己写代码、看代码、Review 代码的一些经验,聊聊我的一些体会。
最近写 golang 多一点,因此例子用的都是 golang ,但都是伪代码,有些例子并不不严格遵从语法。此外,例子大多出于现造,因此可能并不是特别贴合。
作者:木鸟杂记 https://www.qtmuniao.com/2021/12/12/how-to-write-code-scrutinize-names 转载请注明出处
诚信
说到命名,其实有很多原则,我思来想去,觉得最需要强调的一个思想便是——诚信(笑)。我们在写代码、改代码时决计不能挂羊头卖狗肉,做有意无意的骗子。换言之,要让命名要真实、完整的体现意图。否则,维护者很容易受命名误导,先入为主,忽略一些细节,甚而,忽略一些 bug。
最常见的有几种情形。
其一,函数在做 A 的逻辑,却随意起了一个 B 的名字。这种情况要么是起名太随意了,觉得无关紧要,要么是函数逻辑太复杂,找不到合适的名字,便随意安了一个。前者态度有问题,我们按下不表。后者一般是需要将逻辑进行拆分,拆到名字能清晰体现其逻辑为止。
其二,函数有副作用,但是名字中没有体现。
1 | func (c *Company) check(p *Person) { |
上面代码中,既然名字说只是 check
,便不能偷偷的 set
。如果也想 set
,可以改名为 checkAndSet()
。
另外一个典型的例子:看起来像纯函数,却偷偷的改变了全局状态;
其三,对代码进行了改动,名称却没有随之变动。比如,我们改造一个函数时,塞了一些新逻辑,却维持其名字不变;甚而,我们完全改变了代码逻辑,却没有改动名字;当然最常见的是,代码变了,注释却忘改了。
1 | // pseudocode |
其四,用一些约定俗成的名字去指代不同含义。比如在 golang 中,说到 context
,我们一般会想到指官方库中用于控制生命周期的 Context
。那么在命名时,就不要随意占用这个名字,如某种情况下,我们需要一个保存任务运行上下文的类,务必加上前缀,比如 JobContext
,这时如果直接用 Context
作为类名,哪怕细读之后能理解其含义,也会让人感觉很别扭。
直白
当我们实现某个功能时,会反复思考很多细节,这些细节便是我们当时思想的上下文。处在这些上下文之中,我们很容易写出一些当时觉得无比自然,后面看来无比迷惑的代码。因为随着时间流逝,你脑中的上下文会消失。
比如,当判断某个条件时,用了一个很隐晦、间接或者反直觉的判断语句。这种情况,可以换成更直接的信号,或者通过增加一个变量,以变量名字来进行释义。
1 | func (p *Project) Get(req *GetRequest) (*GetResponse, error){ |
上面例子本来想表达, 当 req.names
是一个非空数组时,则只对该数组指定学生信息进行返回;如果 req.names == nil
时,则表明为项目组全部学生,需要额外返回老师的信息。但是后面这个信息就完全是隐式的、反直觉的。
可以通过一些手段将其变为显式,比如 bool isAll = req.names == nil
。同理,对于长一些匿名函数,最好也将其赋给一个变量,通过变量名来对函数进行释义。
因此,最好能不断带入他人视角思考,持续消除隐式上下文依赖,才能写出符合直觉、无须过多注释的代码。另外,多找几个不具有这种实现细节(但最好明白设计方案)上下文的人来 Review 也是一种很好的消除隐式依赖手段。
简洁
有的命名简直又臭又长,须知超过三个词的命名并不能使语义更清楚,还会加重理解负担。出现这种情况,一般是没有仔细推敲,利用程序结构来消除信息冗余,举几个例子。
其一,通过层级信息消除冗余。比如包名(或者命名空间,看语言而定)、类名。
1 | package student |
上述包名中已经表明是 student
,则函数名中可以省去 Student
字样,即改为 func New()(*Student, error)
,使用时 student.New()
便可以清楚地看出 New 的对象类型。
借助数据结构的视角来看,通过树形组织来概念,可以让单个树节点的命名变得相对简单,同时利用树的路径来表达足够丰富的含义。
其二,利用参数名来消除冗余。
1 | func handleStudent(student *Student) |
参数名即函数的处理对象,因此函数名中不需要再次说明:func handle(student *Student)
。
其三,作用域越小,名字可以越短。最常见的便是迭代变量、小函数局部变量。因为作用域越小,其冲突的可能性就越小。
1 | // case 1: iteration |
但简洁是有度的,以不引入二义性为限。仍以上面 Student 为例:
1 | package student |
此间的 New 函数就有点太短,因为在调用时 student.New()
很容易引起误解,以为返回的是 Student
类型,因此最好改成 student.NewManager()
。
系统
最后,但也是最重要的,命名是需要成体系的。而只有对所解决的问题有了足够的认识,才能做出足够贴切的抽象,写出足够简明扼要的代码,命出简短、对称、一致的名字。仍然借用数据结构来概括下命名系统组织原则:宏观角度看,代码是分层的树形组织;微观角度看,层与层之间是类二分图组织。
下面来从几个侧面举几个例子。
其一,一致性、相容性。在自己设计代码时,表现为多个组件间风格的一致性;在修改别人代码时,表现为延续其风格的相容性。
1 | // student.go |
上面例子便是一个反面,同样的意思,用了不同名字。更恶劣的是,相似的地方、相似的名字,却指代不同的含义。这会给维护者带来极大的心智负担。
其二,原子性、正交性。单个函数尽量短小,函数名才能完整体现代码意思;基础函数尽量正交,才能去除冗余,通过组合来表达强大的生命力。
一个常见的例子,是 WebService 中围绕某种实体的 CRUD,如针对 Student
的 StudentManager
。上层便可以利用这些基本的增删改查完成更细节的业务逻辑。
1 | type StudentManager struct {} |
其三,体系性。使用某种手段,将系统拆解为几个很自然的模块。这种自然,本质上是通过利用你和读者共享的上下文来做到的。
如需要适配多种存储后端时,关于 Storage
的抽象。
1 | type Storage interface { |
如需要管理任务生命周期时 ,关于 Task
的抽象。
1 | type Task interface { |
其惯常的做法是,参考数据结构、操作系统、数据库、网络中一些常见的抽象,比如消息队列、文件系统、进程线程、传输协议等等,做适当变化来为我们服务。大多数程序员都共享这些基本概念,因此可以很快速的理清你代码的脉络。
另一种常用的方式,是借鉴日常生活中大家都熟悉的概念,围绕其性状、时空等特性,对系统进行拆解。比如之前呆过的一个公司,利用鹰(Eagle),鹰眼(监控)、鹰爪(爬取)等概念来拆解一个监控系统。
小结
古人写文章,讲究反复推敲,方能有佳作。代码命名,也需要仔细锤炼,才能不断延长生命周期,免于过快腐烂。如果仅仅追求写代码快,第一反应是什么,就用什么做名字,代码便难逃运行一次就被重构甚至遗弃的命运。
见识所囿,必有诸多遗漏。关于代码命名,大家还有什么心得或吐槽,欢迎留言讨论。