木鸟杂记

分布式系统,程序语言,算法设计

好好写代码之命名篇——推敲

(贾)岛初赴举京师,一日驴上得句云:“鸟宿池边树,僧敲月下门”。始欲着“推”字,又欲着“敲”字,练之未定,遂于驴上吟哦,时时引手作推敲之势。

—— 宋·胡仔《苕溪渔隐丛话》卷十九引《刘公嘉话》

命名,是编码中最为紧要的事情,其之于程序,便如脸面之于少女。好的命名,能清晰的传达代码的意图,甚而,有一种韵律的美感。而懒散随意的起名,则令人如堕云雾,不忍卒读,会一遍遍地消耗维护者的精气神儿。此外,混乱的命名体系,能轻巧的掩藏 BUG,贻祸千里。

因此,我们在写代码时,有必要花一点时间,对关键命名进行推敲,与人方便,与己方便。对于生命周期越长的项目,其益处便越明显。那么该如何推敲呢?结合自己写代码、看代码、Review 代码的一些经验,聊聊我的一些体会。

最近写 golang 多一点,因此例子用的都是 golang ,但都是伪代码,有些例子并不不严格遵从语法。此外,例子大多出于现造,因此可能并不是特别贴合。

作者:木鸟杂记 https://www.qtmuniao.com/2021/12/12/how-to-write-code-scrutinize-names 转载请注明出处

诚信

说到命名,其实有很多原则,我思来想去,觉得最需要强调的一个思想便是——诚信(笑)。我们在写代码、改代码时决计不能挂羊头卖狗肉,做有意无意的骗子。换言之,要让命名要真实、完整的体现意图。否则,维护者很容易受命名误导,先入为主,忽略一些细节,甚而,忽略一些 bug。

最常见的有几种情形。

其一,函数在做 A 的逻辑,却随意起了一个 B 的名字。这种情况要么是起名太随意了,觉得无关紧要,要么是函数逻辑太复杂,找不到合适的名字,便随意安了一个。前者态度有问题,我们按下不表。后者一般是需要将逻辑进行拆分,拆到名字能清晰体现其逻辑为止。

其二,函数有副作用,但是名字中没有体现

1
2
3
4
5
6
7
func (c *Company) check(p *Person) {
if p.age < 0 {
panic("age can't be negative")
}

c.p = p
}

上面代码中,既然名字说只是 check,便不能偷偷的 set。如果也想 set,可以改名为 checkAndSet()

另外一个典型的例子:看起来像纯函数,却偷偷的改变了全局状态;

其三,对代码进行了改动,名称却没有随之变动。比如,我们改造一个函数时,塞了一些新逻辑,却维持其名字不变;甚而,我们完全改变了代码逻辑,却没有改动名字;当然最常见的是,代码变了,注释却忘改了。

1
2
3
4
5
6
7
8
9
10
// pseudocode
func (c *Content) dumpToFile() error {
// origin code
f, _ := os.Open(fiename)
f.Write()

// added code
db, _ = getDb()
db.Add()
}

其四,用一些约定俗成的名字去指代不同含义。比如在 golang 中,说到 context,我们一般会想到指官方库中用于控制生命周期的 Context。那么在命名时,就不要随意占用这个名字,如某种情况下,我们需要一个保存任务运行上下文的类,务必加上前缀,比如 JobContext ,这时如果直接用 Context 作为类名,哪怕细读之后能理解其含义,也会让人感觉很别扭。

直白

当我们实现某个功能时,会反复思考很多细节,这些细节便是我们当时思想的上下文。处在这些上下文之中,我们很容易写出一些当时觉得无比自然,后面看来无比迷惑的代码。因为随着时间流逝,你脑中的上下文会消失。

比如,当判断某个条件时,用了一个很隐晦、间接或者反直觉的判断语句。这种情况,可以换成更直接的信号,或者通过增加一个变量,以变量名字来进行释义。

1
2
3
4
5
6
7
8
9
func (p *Project) Get(req *GetRequest) (*GetResponse, error){
if req.names == nil {
resp := &GetResponse{}
resp.Teacher = &Teacher{}
return resp, nil
}

// xxxxx
}

上面例子本来想表达, 当 req.names 是一个非空数组时,则只对该数组指定学生信息进行返回;如果 req.names == nil 时,则表明为项目组全部学生,需要额外返回老师的信息。但是后面这个信息就完全是隐式的、反直觉的。

可以通过一些手段将其变为显式,比如 bool isAll = req.names == nil 。同理,对于长一些匿名函数,最好也将其赋给一个变量,通过变量名来对函数进行释义。

因此,最好能不断带入他人视角思考,持续消除隐式上下文依赖,才能写出符合直觉、无须过多注释的代码。另外,多找几个不具有这种实现细节(但最好明白设计方案)上下文的人来 Review 也是一种很好的消除隐式依赖手段。

简洁

有的命名简直又臭又长,须知超过三个词的命名并不能使语义更清楚,还会加重理解负担。出现这种情况,一般是没有仔细推敲,利用程序结构来消除信息冗余,举几个例子。

其一,通过层级信息消除冗余。比如包名(或者命名空间,看语言而定)、类名。

1
2
3
4
5
package student

type Student struct {}

func NewStudent() (*Student, error)

上述包名中已经表明是 student ,则函数名中可以省去 Student 字样,即改为 func New()(*Student, error) ,使用时 student.New() 便可以清楚地看出 New 的对象类型。

借助数据结构的视角来看,通过树形组织来概念,可以让单个树节点的命名变得相对简单,同时利用树的路径来表达足够丰富的含义。

其二,利用参数名来消除冗余

1
func handleStudent(student *Student)

参数名即函数的处理对象,因此函数名中不需要再次说明:func handle(student *Student)

其三,作用域越小,名字可以越短。最常见的便是迭代变量、小函数局部变量。因为作用域越小,其冲突的可能性就越小。

1
2
3
4
5
6
7
// case 1: iteration
for i, s := range students {
fmt.Println(i, s)
}

// case 2: small function
sort.Slice(students, func(i, j int) bool { return students[i].Name < students[j].Name })

但简洁是有度的,以不引入二义性为限。仍以上面 Student 为例:

1
2
3
4
5
6
7
package student

type Student struct {}

type StudentManager student{}

func New() (*StudentManager, error)

此间的 New 函数就有点太短,因为在调用时 student.New() 很容易引起误解,以为返回的是 Student 类型,因此最好改成 student.NewManager()

系统

最后,但也是最重要的,命名是需要成体系的。而只有对所解决的问题有了足够的认识,才能做出足够贴切的抽象,写出足够简明扼要的代码,命出简短、对称、一致的名字。仍然借用数据结构来概括下命名系统组织原则:宏观角度看,代码是分层的树形组织;微观角度看,层与层之间是类二分图组织。

下面来从几个侧面举几个例子。

其一,一致性、相容性。在自己设计代码时,表现为多个组件间风格的一致性;在修改别人代码时,表现为延续其风格的相容性。

1
2
3
4
5
6
7
// student.go
func get(name string) (*Student, error)
func process(student *Student) error

// teacher.go
func fetch(name string) (*Teacher, error)
func handle(teacher *Teacher) error

上面例子便是一个反面,同样的意思,用了不同名字。更恶劣的是,相似的地方、相似的名字,却指代不同的含义。这会给维护者带来极大的心智负担。

其二,原子性、正交性。单个函数尽量短小,函数名才能完整体现代码意思;基础函数尽量正交,才能去除冗余,通过组合来表达强大的生命力。

一个常见的例子,是 WebService 中围绕某种实体的 CRUD,如针对 StudentStudentManager 。上层便可以利用这些基本的增删改查完成更细节的业务逻辑。

1
2
3
4
5
6
type StudentManager struct {}

func(m *StudentManager) Create(id, name string, age int)
func(m *StudentManager) Remove(id string)
func(m *StudentManager) Update(s *Student)
func(m *StudentManager) Delete(id string)

其三,体系性。使用某种手段,将系统拆解为几个很自然的模块。这种自然,本质上是通过利用你和读者共享的上下文来做到的。

如需要适配多种存储后端时,关于 Storage 的抽象。

1
2
3
4
5
type Storage interface {
func Create(uri string) (*File, error)
func Remove(uri string) error
func Open(uri string, mode int)(io.ReaderWriter, error)
}

如需要管理任务生命周期时 ,关于 Task 的抽象。

1
2
3
4
5
6
7
8
9
type Task interface {
func Start() error
func Stop() error
func Suspend() error
func Resume() error

func IsRunning() bool
func IsSuspending() bool
}

其惯常的做法是,参考数据结构、操作系统、数据库、网络中一些常见的抽象,比如消息队列、文件系统、进程线程、传输协议等等,做适当变化来为我们服务。大多数程序员都共享这些基本概念,因此可以很快速的理清你代码的脉络。

另一种常用的方式,是借鉴日常生活中大家都熟悉的概念,围绕其性状、时空等特性,对系统进行拆解。比如之前呆过的一个公司,利用鹰(Eagle),鹰眼(监控)、鹰爪(爬取)等概念来拆解一个监控系统。

小结

古人写文章,讲究反复推敲,方能有佳作。代码命名,也需要仔细锤炼,才能不断延长生命周期,免于过快腐烂。如果仅仅追求写代码快,第一反应是什么,就用什么做名字,代码便难逃运行一次就被重构甚至遗弃的命运。

见识所囿,必有诸多遗漏。关于代码命名,大家还有什么心得或吐槽,欢迎留言讨论。


我是青藤木鸟,一个喜欢摄影的分布式系统程序员,更多有意思的文章,欢迎关注我的公众号:“木鸟杂记”。

wx-distributed-system-s.jpg