yuxino / blog

🎬 Life's a Movie
17 stars 2 forks source link

代码命名上的思考 #57

Closed yuxino closed 3 years ago

yuxino commented 6 years ago

无论是命名变量、函数还是类,都可以使用很多相同的原则。我们喜欢把名字当做一条小小的注释。尽管空间不算很大,但选择一个好名字可以让它承载很多信息。

我们在程序中见到的很多名字都很模糊,例如tmp。就算是看上去合理的词,如size或者get,也都没有装入很多信息。

我们应该选择专业的词。

选择专业的词

“把信息装入名字中”包括要选择非常专业的词,并且避免使用“空洞”的词。 例如,“get”这个词就非常不专业,例如在下面的例子中:

def GetPage(url): ...

“get”这个词没有表达出很多信息。这个方法是从本地的缓存中得到一个页面,还是从数据库中,或者从互联网中?如果是从互联网中,更专业的名字可以是FetchPage()或者DownloadPage()。

下面是一个BinaryTree类的例子:

class BinaryTree
{ 
    int Size();
    ...
}

你期望Size()方法返回什么呢?树的高度,节点数,还是树在内存中所占的空间?

问题是Size()没有承载很多信息。更专业的词可以是Height()、NumNodes()或者MemoryBytes()。

另外一个例子,假设你有某种Thread类:

class Thread
{ 
    void Stop();
    ...
}

Stop()这个名字还可以,但根据它到底做什么,可能会有更专业的名字。例如,你可以叫它Kill(),如果这是一个重量级操作,不能恢复。如果只是停止的话你可以叫它Pause(),如果有方法恢复的话可以创建一个Resume()方法。

找到更有表现力的词

要勇于使用同义词典或者问朋友更好的名字建议。英语是一门丰富的语言,有很多词可以选择。

下面是一些例子,这些单词更有表现力,可能适合你的语境:

单词 其他的选择
send deliver、 dispatch、announce、distribute、route
find search、extract、locate、recover
start launch、create、begin、open
make create、set up、build、generate、compose、add、new

但别得意忘形。在PHP中,有一个函数可以explode()一个字符串。这是个很有表现力的名字,描绘了一幅把东西拆成碎片的景象。但这与split()有什么不同?(这是两个不一样的函数,但很难通过它们的名字来猜出不同点在哪里。)

避免像tmp和retval这样泛泛的名字

使用像tmp、retval和foo这样的名字往往是“我想不出名字”的托辞。与其使用这样空洞的名字,不如挑一个能描述这个实体的值或者目的的名字。

例如,下面的JavaScript函数使用了retval:

var euclidean_norm = function (v) {
var retval = 0.0;
for (var i = 0; i < v.length; i += 1)
retval += v[i] * v[i];
return Math.sqrt(retval);
};

当你想不出更好的名字来命名返回值时,很容易想到使用retval。但retval除了“我是一个返回值”外并没有包含更多信息(这里的意义往往也是很明显的)。

好的名字应当描述变量的目的或者它所承载的值。在本例中,这个变量正在累加v的平方。因此更贴切的名字可以是sum_squares。这样就提前声明了这个变量的目的,并且可能会帮忙找到缺陷。例如,想象如果循环的内部被意外写成

retval += v[i];

如果名字换成sum_squares这个缺陷就会更明显:

sum_squares += v[i]; //我们要累加的"square"在哪里?缺陷!

然而,有些情况下泛泛的名字也承载着意义。让我们来看看什么时候使用它们有意义

tmp

请想象一下交换两个变量的经典情形:

if (right < left) {
    tmp = right;
    right = left;
    left = tmp;
}

在这种情况下,tmp这个名字很好。这个变量唯一的目的就是临时存储,它的整个生命周期只在几行代码之间。tmp这个名字向读者传递特定信息,也就是这个变量没有其他职责,它不会被传到其他函数中或者被重置以反复使用。但在下面的例子中对tmp的使用仅仅是因为懒惰:

String tmp = user.name();
tmp += " " + user.phone_number();
tmp += " " + user.email();
...
template.set("user_info", tmp);

尽管这里的变量只有很短的生命周期,但对它来讲最重要的并不是临时存储。用像user_info这样的名字来代替可能会更具描述性。

在下面的情况中,tmp应当出现在名字中,但只是名字的一部分:

tmp_file = tempfile.NamedTemporaryFile()
...
SaveData(tmp_file, ...)

请注意我们把变量命名为tmp_file而非只是tmp,因为这是一个文件对象。想象一下如果我们只是把它叫做tmp:

SaveData(tmp, ...)

只要看看这么一行代码,就会发现不清楚tmp到底是文件、文件名还是要写入的数据。

循环迭代器

像i、j、i t e r和i t等名字常用做索引和循环迭代器。尽管这些名字很空泛,但是大家都知道它们的意思是“我是一个迭代器”(实际上,如果你用这些名字来表示其他含义,那会很混乱。所以不要这么做!)

for (int i = 0; i < clubs.size(); i++)
    for (int j = 0; j < clubs[i].members.size(); j++)
        for (int k = 0; k < users.size(); k++)
            if (clubs[i].members[k] == users[j])
                cout << "user[" << j << "] is in club[" << i << "]" << endl;

在if条件语句中,members[]和users[]用了错误的索引。这样的缺陷很难发现,因为这一行代码单独来看似乎没什么问题:

if (clubs[i].members[k] == users[j])  // 此处错误指的是members[k]应该为members[j],而users[j]应该为users[k]

在这种情况下,使用更精确的名字可能会有帮助。如果不把循环索引命名为(i、j、k),另一个选择可以是(club_i、members_i、user_i)或者,更简化一点(ci、mi、ui)。这种方式会帮助把代码中的缺陷变得更明显:

if (clubs[ci].members[ui] == users[mi]) #缺陷!第一个字母不匹配。

如果用得正确,索引的第一个字母应该与数据的第一个字符匹配:

if (clubs[ci].members[mi] == users[ui]) #OK。首字母匹配。

对于空泛名字的裁定

如你所见,在某些情况下空泛的名字也有用处

很多时候,仅仅因为懒惰而滥用它们。这可以理解,如果想不出更好的名字,那么用个没有意义的名字,像foo,然后继续做别的事,这很容易。但如果你养成习惯多花几秒钟想出个好名字,你会发现你的“命名能力”很快提升。

用具体的名字代替抽象的名字

在给变量、函数或者其他元素命名时,要把它描述得更具体而不是更抽象。

例如,假设你有一个内部方法叫做ServerCanStart(),它检测服务是否可以监听某个给定的TCP/IP端口。然而ServerCanStart()有点抽象。CanListenOnPort()就更具体一些。这个名字直接地描述了这个方法要做什么事情。下面的两个例子更深入地描绘了这个概念。

例子 DISALLOW_EVIL_CONSTRUCTORS

这个例子来自Google的代码库。在C++里,如果你不为类定义拷贝构造函数或者赋值操作符,那就会有一个默认的。尽管这很方便,这些方法很容易导致内存泄漏以及其他灾难,因为它们在你可能想不到的“幕后”地方运行。

class ClassName {
    private:
        DISALLOW_EVIL_CONSTRUCTORS(ClassName);
    public: ...
};

这个宏定义成:

#define DISALLOW_EVIL_CONSTRUCTORS(ClassName) \
    ClassName(const ClassName&); \
    void operator=(const ClassName&);

通过把这个宏放在类的私有部分中,这两个方法是私有的,所以不能用它们,即使意料之外的使用也是不可能的。

然而DISALLOW_EVIL_CONSTRUCTORS这个名字并不是很好。对于“邪恶”这个词的使用包含了对于一个有争议话题过于强烈的立场。更重要的是,这个宏到底禁止了什么这一点是不清楚的。它禁止了operator=()方法,但这个方法甚至根本就不是构造函数!

这个名字使用了几年,但最终换成了一个不那么嚣张而且更具体的名字:

#define DISALLOW_COPY_AND_ASSIGN(ClassName) ...

例子 --run_locally (本地运行)

我们的一个程序有个可选的命令行标志叫做--run_locally。这个标志会使得这个程序输出额外的调试信息,但是会运行得更慢。这个标志一般用于在本地机器上测试,例如在笔记本电脑上。但是当这个程序运行在远程服务器上时,性能是很重要的,因此不会使用这个标志。

你能看出来为什么会有--run_locally这个名字,但是它有几个问题:

这里的问题是--run_locally是由它所使用的典型环境而得名。用像--extra_logging这样的名字来代换可能会更直接明了。

但是如果--run_locally需要做比额外日志更多的事情怎么办?例如,假设它需要建立和使用一个特殊的本地数据库。现在--run_locally看上去更吸引人了,因为它可以同时控制这两种情况。

但这样用的话就变成了因为一个名字含糊婉转而需要选择它,这可能不是一个好主意。更好的办法是再创建一个标志叫--use_local_database。尽管你现在要用两个标志,但这两个标志非常明确,不会混淆两个正交的含义,并且你可明确地选择一个。

为名字附带更多信息

我们前面提到,一个变量名就像是一个小小的注释。尽管空间不是很大,但不管你在名中挤进任何额外的信息,每次有人看到这个变量名时都会同时看到这些信息。

因此,如果关于一个变量有什么重要事情的读者必须知道,那么是值得把额外的“词”添加到名字中的。例如,假设你有一个变量包含一个十六进制字符串:

string id; // Example: "af84ef845cd8"

如果让读者记住这个ID的格式很重要的话,你可以把它改名为hex_id。

带单位的值

如果你的变量是一个度量的话(如时间长度或者字节数),那么最好把名字带上它的单位。

例如,这里有些JavaScript代码用来度量一个网页的加载时间:

var start = (new Date()).getTime(); // top of the page
...
var elapsed = (new Date()).getTime() - start; // bottom of the page
document.writeln("Load time was: " + elapsed + " seconds");

这段代码里没有明显的错误,但它不能正常运行,因为getTime()会返回毫秒而非秒。

通过给变量结尾追加_ms,我们可以让所有的地方更明确:

var start_ms = (new Date()).getTime(); // top of the page
...
var elapsed_ms = (new Date()).getTime() - start_ms; // bottom of the page
document.writeln("Load time was: " + elapsed_ms / 1000 + " seconds");

除了时间,还有很多在编程时会遇到的单位。下表列出一些没有单位的函数参数以及带单位的版本:

函数参数 带单位的参数
Start(int delay) delay → delay_secs
CreateCache(int size) size → size_mb
ThrottleDownload(float limit) limit → max_kbps
Rotate(float angle) angle → degrees_cw

附带其他重要属性

例如,很多安全漏洞来源于没有意识到你的程序接收到的某些数据还没有处于安全状态。在这种情况下,你可能想要使用像untrustedUrl或者unsafeMessageBody这样的名字。在调用了清查不安全输入的函数后,得到的变量可以命名为trustedUrl或者safeMessageBody。

下表给出更多需要给名字附加上额外信息的例子:

情形 变量名 更好的名字
一个“纯文本”格式的密码,需要加密后才能进一步使用 password plaintext_password
一条用户提供的注释,需要转义之后才能用于显示 comment unescaped_comment
已转化为UTF-8格式的html字节 html html_utf8
以“url方式编码”的输入数据 data data_urlenc

但你不应该给程序中每个变量都加上像unescaped_或者_utf8这样的属性。如果有人误解了这个变量就很容易产生缺陷,尤其是会产生像安全缺陷这样可怕的结果,在这些地方这种技巧最有用武之地。基本上,如果这是一个需要理解的关键信息,那就把它放在名字里。

匈牙利表示法

匈牙利表示法是一个在微软广泛应用的命名系统,它把每个变量的“类型”信息都编写进名字的前缀里。下面有几个例子:

名字 含义
pLast 指向某数据结构最后一个元素的指针(p)
pszBuffer 指向一个以零结尾(z)的字符串(s)的指针(p)
cch 一个字符(ch)计数(c)
mpcopx 在指向颜色的指针(pco)和指向x轴长度的指针(px)之间的一个映射(m)

名字应该有多长

当选择好名字时,有一个隐含的约束是名字不能太长。没人喜欢在工作中遇到这样的标识符:newNavigationControllerWrappingViewControllerForDataSourceOfClass

名字越长越难记,在屏幕上占的地方也越大,可能会产生更多的换行。

另一方面,程序员也可能走另一个极端,只用单个单词(或者单一字母)的名字。那么如何来处理这种平衡呢?如何来决定是把一变量命名为d、days还是days_since_last_update呢?

这是要你自己要拿主意的,最好的答案和这个变量如何使用有关系,但下面还是提出了一些指导原则。

在小的作用域里可以使用短的名字

当你去短期度假时,你带的行李通常会比长假少。同样,“作用域”小的标识符(对于多少行其他代码可见)也不用带上太多信息。也就是说,因为所有的信息(变量的类型、它的初值、如何析构等)都很容易看到,所以可以用很短的名字。

if (debug) {
    map<string,int> m;
    LookUpNamesNumbers(&m);
    Print(m);
}

尽管m这个名字并没有包含很多信息,但这不是个问题。因为读者已经有了需要理解这段代码的所有信息。

然而,假设m是一个全局变量中的类成员,如果你看到这个代码片段:

LookUpNamesNumbers(&m);
Print(m);

这段代码就没有那么好读了,因为m的类型和目的都不明确。

因此如果一个标识符有较大的作用域,那么它的名字就要包含足够的信息以便含义更清楚。

首字母缩略词和缩写

程序员有时会采用首字母缩略词和缩写来命令,以便保持较短的名字,例如,把一个类命名为BEManager而不是BackEndManager。这种名字会让人费解,冒这种风险是否值得?

在我们的经验中,使用项目所特有的缩写词非常糟糕。对于项目的新成员来讲它们看上去太令人费解和陌生,当过了相当长的时间以后,即使是对于原作者来讲,它们也会变得令人费解和陌生。

所以经验原则是:团队的新成员是否能理解这个名字的含义?如果能,那可能就没有问题。

例如,对程序员来讲,使用eval来代替evaluation,用doc来代替document,用str来代替string是相当普遍的。因此如果团队的新成员看到FormatStr()可能会理解它是什么意思,然而,理解BEManager可能有点困难。

丢掉没用的词

有时名字中的某些单词可以拿掉而不会损失任何信息。例如,ConvertToString()就不如ToString()这个更短的名字,而且没有丢失任何有用的信息。同样,不用DoServeLoop(),ServeLoop()也一样清楚。

利用名字的格式来传递含义

对于下划线、连字符和大小写的使用方式也可以把更多信息装到名字中。例如,下面是一些遵循Google开源项目格式规范的C++代码:

static const int kMaxOpenFiles = 100;
    class LogReader {
        public:
            void OpenFile(string local_file);
        private:
            int offset_;
            DISALLOW_COPY_AND_ASSIGN(LogReader);
};

对不同的实体使用不同的格式就像语法高亮显示的形式一样,能帮你更容易地阅读代码。

该例子中的大部分格式都很常见,使用CamelCase(驼峰命名)来表示类名,使用lower_separated(分开的小写字母)来表示变量名。但有些规范也可能会出乎你的意料。

例如,常量的格式是kConstantName而不是CONSTANT_NAME。这种形式的好处是容易和#define的宏区分开,宏的规范是MACRO_NAME。

类成员变量和普通变量一样,但必须以一条下划线结尾,如o f f s e t_。刚开始看,可能会觉得这个规范有点怪,但是能立刻区分出是成员变量还是其他变量,这一点还是很方便的。例如,如果你在浏览一个大的方法中的代码,看到这样一行:

stats.clear();

你本来可能要想“stats属于这个类吗?这行代码是否会改变这个类的内部状态?”如果用了member这个规范,你就能迅速得到结论:“不,stats一定是个局部变量。否则它就会命名为stats。”

其他的一些规范

根据项目上下文或语言的不同,还可以采用其他一些格式规范使得名字包含更多信息。

例如,在《JavaScript:The Good Parts》( Douglas Crockford, O’Reilly, 2008)一书中,作者建议“构造函数”(在新建时会调用的函数)应该首字母大写而普通函数首字母小字:

var x = new DatePicker(); // DatePicker() is a "constructor" function
var y = pageHeight(); // pageHeight() is an ordinary function

下面是另一个JavaScript例子:当调用jQuery库函数时(它的名字是单个字符$),一条非常有用的规范是,给jQuery返回的结果也加上$作为前缀:

var $all_images = $("img"); // $all_images is a jQuery object
var height = 250; // height is not

在整段代码中,都会清楚地看到$all_images是个jQuery返回对象。

下面是最后一个例子,这次是HTML/CSS:当给一个HTML标记加id或者class属性时,下划线和连字符都是合法的值。一个可能的规范是用下划线来分开ID中的单词,用连字符来分开class中的单词。

<div id="middle_column" class="main-content"> ...

是否要采用这些规范是由你和你的团队决定的。但不论你用哪个系统,在你的项目中要保持一致。