程序员“起名”头痛根治指南
There are only two hard things in Computer Science: cache invalidation and naming things.-- Phil Karlton软件开发中一个著名的反直觉就是“起名儿”,这个看上去很平凡的任务实际上很有难度。身边统计学显示,越是有经验的程序员,越为起名头痛,给小孩起名儿都没这么费劲。
命名的困难可能来自于以下几个方面:
[*]信息压缩:命名的本质是把类/方法的信息提炼成一个或几个词汇,这本身需要对抽象模型的准确理解和概括。
[*]预测未来:类/方法的职责可能会在未来有变化,现在起的名字需要考虑未来可能的变动。
[*]语言能力:缺少正确的语法知识,或是缺少足够的词汇量。本来英文就不是大部分中国人的母语,更甚者,计算机的词汇表不同于日常交流词汇表,有大量黑话。
[*]不良设计:混乱的职责分布、不清晰的抽象分层、错误的实现,都会导致无法起出好的名字。在这个意义上,起名字其实是对设计的测试: 如果起不出名字来,很可能是设计没做好 -- 重新想想设计吧。
命名就像写作,会写字不等于会写作。而且,命名更多像是一门艺术[注](此处艺术的含义取自于 Knuth -- 命名会诉诸品味和个人判断。),不存在一个可复制的命名操作手册。
本文描述一些实用主义的、可操作的、基于经验的命名指南,并提供了一个代码词汇表,和部分近义词辨析。本文没有涉及讨论名字的形而上学,例如如何做更好的设计和抽象以利于命名,也没有涉及如何划分对象等,也无意讨论分析哲学。
命名原则
命名是一门平衡准确性和简洁性的艺术 -- 名字应该包含足够的信息能够表达完整的含义,又应该不包含冗余的信息。
准确 Precision
名字最重要的属性是准确。名字应该告诉用户这个对象/方法的意图 -- “它是什么” 和 “它能做什么”。 事实上,它是体现意图的第一媒介 -- 名字无法表现含义时读者才会阅读文档。
名字应该是有信息量的、无歧义的。以下一些策略可以增加名字的准确度:
可读
最基本的语法原理,是一个类(Class/Record/Struct/... 随你喜欢)应该是一个名词,作为主语。一个方法应该是动词,作为谓语。 换言之,类“是什么”,方法“做什么”, 它们应该是可读的,应该是 式的句子。
可读是字面意思,意味着它应该是通顺的,所以应该:
避免 API 中使用缩写
就像是给老板的汇报中不会把商业计划写成 Busi Plan 一样,也不应该在公开 API 中使用一些奇怪的缩写。现在已经不是 1970 年了,没有一个方法不能超过 8 个字符的限制。把类/方法的名字写全,对读者好一点,可以降低自己被同事打一顿的风险。
creat 是个错误,是个错误,是个错误!
但是,首字母缩略词的术语是可行并且推荐的,如 Http, Id, Url。
以下是可用的、得到普遍认可的缩写:
[*]configuration -> config
[*]identifier -> id
[*]specification -> spec
[*]statistics -> stats
[*]database -> db (only common in Go)
[*]regular expression -> re/regex/regexp
未得到普遍认可的缩写:
[*]request -> req
[*]response -> resp/rsp
[*]service -> svr
[*]object -> obj
[*]metadata -> meta
[*]business -> busi
req/resp/svr 在服务名称中很常见。这非常糟糕。请使用全称。
再次说明:以上的说明是针对 API 名称,具体包括公开对象/函数名字、RPC/Web API 名字。在局部变量使用缩写不受此影响。
避免双关
对类/方法的命名,不要使用 2 表示 To, 4 表示 For。
func foo2Bar(f *Foo) *Bar // BAD
func fooToBar(f *Foo) *Bar // GOOD
func to(f *Foo) *Bar // Good if not ambiguous.2/4 这种一般只有在大小写不敏感的场合才会使用,例如包名 e2e 比 endtoend 更可读。能区分大小写的场合,不要使用 2/4。
合乎语法
虽然不能完全符合语法(例如通常会省略冠词),但是,方法的命名应该尽量符合语法。例如:
class Car {
void tireReplace(Tire tire); // BAD, reads like "Car's tire replaces"
void replaceTire(Tire tire); // GOOD, reads like "replace car's tire"
}关于命名的语法见“语法规则”一章。
使用单一的概念命名
命名本质上是分类(taxonomy)。即,选择一个单一的分类,能够包含类的全部信息,作为名字。
考虑以下的角度:
例如,把大象装进冰箱,需要有三步 -- 打冰箱门打开,把大象放进去,把冰箱门关上。但是,这可以用单一的概念来描述:“放置”。
class Fridge {
public void openDoorAndMoveObjectIntoFridgeAndCloseDoor(Elephant elphant); // BAD
public void put(Elephant elphant); // GOOD
}应该使用所允许的最细粒度的分类
避免使用过于宽泛的类别。例如,这世界上所有的对象都是“对象”,但显然,应该使用能够完整描述对象的、最细颗粒度的类别。
class Fridge {
public put(Elephant elephant); // GOOD.
public dealWith(Elephant elephant); // BAD: deal with? Anything can be dealt with. How?
}简而言之,名字应该是包含所有概念的分类的下确界。
简洁 Simplicity
名字长通常会包含更多信息,可以更准确地表意。但是,过长的名字会影响可读性。例如,“王浩然”是一个比“浩然·达拉崩吧斑得贝迪卜多比鲁翁·米娅莫拉苏娜丹尼谢莉红·迪菲特(defeat)·昆图库塔卡提考特苏瓦西拉松·蒙达鲁克硫斯伯古比奇巴勒·王”可能更好的名字。(来自于达啦崩吧)
在此,我提出一个可能会有争议的观点:所有的编程语言的命名风格应该是趋同的。不同于通常认为 Java 的命名会倾向于详尽,Go 的命名会倾向于精简,所有的语言对具体的“名字到底有多长”的建议应该是几乎一样的 -- 对外可见应该更详细,内部成员应该更精简。具体地:
[*]public,如 public 类的名字、public 方法的名字 - 应该详细、不使用缩写、减少依赖上下文。通常是完整名词短语。
[*]non-public,如类成员、私有方法 - 不使用缩写、可以省略上下文。下界是单词,不应该使用单字符。
[*]local,如函数的局部变量 - 基本上是风格是自由的。不影响可读性的前提下,例如函数方法长度很短,可以使用单字符指代成员。
上述规则像是 Go 的风格指南。但是,并没有规定 Java 不能这样做。事实上,Java 的冗长是 Java 程序员的自我束缚。即使在 Java 的代码里,也可以这样写:
public class BazelRuntime {
public boolean exec(Command cmd) {
String m = cmd.mainCommand(); // YES, you can use single-letter variables in Java.
// ...
}
}同样,在 Go 的代码中也不应该出现大量的无意义的缩写,尤其是导出的结构体和方法。
type struct Runtime {} // package name is bazel, so bazel prefix is unnecessary
type struct Rtm {} // BAD. DO NOT INVENT ABBREVIATION!当然,由于语言特性,在命名风格上可能会有差异。例如,由于 Go 的导入结构体都需要加包前缀,所以结构名中通常不会重复包前缀;但 C++/Java 通常不会依赖包名。但是,上述的原则仍然是成立的 -- 可见度越高,应该越少依赖上下文,并且命名越详尽。
Google Go Style Guide 是唯一详尽讨论命名长度的风格指南,非常值得参考,并且不限于 Go 编程:
https://google.github.io/styleguide/go/decisions#variable-names
一致 Consistency
另一个容易被忽略的命名的黄金原则是一致性。换言之,名字的选取,在项目中应该保持一致。遵守代码规范,避免这方面的主观能动性,方便别人阅读代码。通常情况下,一个差的、但是达成共识的代码规范,也会远好于几个好的、但是被未达成共识的规范。
这个图我能用到下辈子: xkcd 927
但是仅符合代码规范是不够的。如同所有的语言,同一个概念,有多个正确的写法。
考虑以下的例子:
message Record {
int32 start_time_millis = 1; // OK
int32 commited_at = 2; // Wait. Why not commit_time? Anything special?
int32 update_time = 3; // What unit? Also millis?
google.types.Timestamp end_time = 4; // WTF? Why only end_time is typed?
}几种都是合理的(虽然不带单位值得商榷)。但是,如果在一个代码中出现了多种风格,使用起来很难预测。您也不想使用这样的 API 吧?
所以,在修改代码的时候,应该查看上下文,选择已有的处理方案。一致性大于其它要求,即使旧有的方案不是最好的,在做局部修改时,也应该保持一致。
另一个可考虑的建议是项目的技术负责人应该为项目准备项目的专有词汇表。
语法规则
类/类型
类
类应该是名词形式,通常由单个名词或名词短语组成。其中,主要名词会作为名词短语的末尾。例如 Thread, PriorityQueue, MergeRequestRepository。
[*]名词短语通常不使用所有格。如,并非 ServiceOfBook,也不是 BooksService (省略 '),而是 BookService。
接口
接口的命名规则和类相同。除此之外,当接口表示可行动类型时,可使用另一个语法,即 Verb-able。例如:
public interface Serializable {
byte[] serialize();
}
public interface Copyable<T> {
T copy();
}
public interface Closable {
void close();
}(Go 通常不使用这个命令风格。只在 Java/C++ 中使用。)
辅助类
只在 Java(注 1)中使用。一个类或概念所有的辅助方法应该聚合在同一个辅助类。这个类应该以被辅助类的复数形式出现。不推荐使用 Helper/Utils 后缀表示辅助类。尤其不推荐使用 Utils/Helpers 做类名,把所有的辅助方法包进去。如:
class Collections {} // For Collection
class Strings {} // For String
class BaseRuleClasses {} // For BaseRuleClass
class StringUtils {} // WORSE!
class StringHelper {} // WORSE!注 1: 客观来说,这适用于所有强制 OOP 的语言(所有强制把方法放在类里的语言)。但是除了 Java, 没有别的语言这么烦啦。
方法
方法通常是谓语(动词),或是 谓宾(动词+名词) 结构。注意以上语法中,动词都在最前端。例如:
class Expander {
String expand(String attribute); // 主-谓
String expandAndTokenizeList(String attribute, List<String> values); // 主-谓-宾
}除此之外,有以下特例值得注意:
访问器 Getter
直接使用所 Get 的对象的名词形式,即 Foo()。不要使用 GetFoo()。
Java: 所有的 Getter 都需要一个 get 前缀是来自于过时的 Java Beans Specification,以及 Javaer 的思想钢印。
func Counts() int; // GOOD
func GetCounts() int; // BAD: UNNECESSARY.断言 Predicate
断言函数指返回结果是布尔型(即真伪值)的函数。它们通常有以下命名格式:
系动词: 主-系-表
即 isAdjective() 或 areAdjective() 格式,表示是否具有某个二元属性。类似于 Getter,可以省略系语,只使用表语,即: adjective()。
func IsDone() bool {} // OK-ish. But could be better.
func Done() bool {} // GOOD. Why bother with is/are?
func CheckEnabled() bool { // BAD. Nobody cares if it is "checked". Just tell the user if it is enabled.
return enabled;
}
func Enabled() bool {} // GOOD.情态动词: 主-助谓-谓-(宾/表)
情态动词也是常见的断言形式。常见的是以下三个:
[*]should: 查询当前是否应该执行给定的实义动词。
[*]can: 查询当前类所在状态是否可以执行给定的实义动词。某些情况下,也可以使用第三人称单数作为更简洁的代替。
[*]must: 特殊形式。不同于前两者,会执行给定的实义动词。must 表示执行必须成功,否则会抛出不可恢复错误 (throw/panic)。类似于 C++ 中常见的 OrDie 后缀。
func Compile(s string) Regexp, error // Returns error upon failure
func MustCompile(s string) Regexp // Panics upon failure
func (r Regexp) CanExpand(s string) bool // Whether s is legal and can be expanded
func (r Regexp) Expands(s string) bool // Whether r expands s, i.e. r can expand s.
func (r Regexp) ShouldReset() bool // Whether the state requires reset. Does not perform de-facto reset.
func (r Regexp) Reset() // De-facto reset.表尝试: 主-maybe/try-谓-(宾/表)
上文 "must" 的反面,表示尝试性的执行,并且失败不会造成严重后果:
[*]maybe 前缀用以表示指定的行为有前置条件,也在方法中执行。如果前置条件不满足,不会执行指定行为。通常不会出现在公开 API。
[*]try 通常用于 Try-Parse Pattern,用于避免抛出异常。
void maybeExecute() {
if (!predicate()) {
return;
}
// execute
}
std::unique_ptr<DateTime> ParseOrDie(std::string_view dateTime);
bool TryParse(string_view dateTime, DateTime* dateTime);第三人称单数
另一个常见场景是我们希望表示类拥有某些属性,但是使用助动词并不合适。如果前文描述,常见的选择是使用第三人称单数的静态动词(Stative verb)(注 1) 表示类满足给定断言。
func (l *List) Contains(e interface{}) bool
func (r Regexp) Expands(s string) bool注 1: 简单地说,静态动词是表示状态的动词,与动态动词(Dynamic verb)表示动作对应。或言“持续性动词”。
一阶逻辑 First-order logic, Predicate Logic
一阶逻辑量词也是常见的前缀:
[*]all 表示所有对象满足给定要求
[*]any 表示任意对象满足给定要求
[*]none 表示没有任何对象满足给定要求
语法:
class Stream { // Returns whether all elements of this stream match the provided predicate. boolean allMatch(Predicate
页:
[1]