第 1 章 变量与注释

编程,是一个通过代码来表达思想的过程。听上去挺神秘,但这事其实我们早就做过。当年我们在小学课堂上,写出第一篇 500 字的作文时,同样也是在表达思想。二者只是方式不同,作文用的是词语和句子,而编程用的则是代码。

但代码与作文之间也有相通之处,因为在代码里,也有着许多“词语”和“句子”。大部分的变量名都是词语,而大多数注释本身就是一条条句子。当我们看到一段代码时,最先注意到的,不是代码有几层循环,用了什么模式,而是变量与注释 ,因为它们是代码里最接近自然语言的东西,最容易被大脑消化、理解。

正因如此,如果作者在编写变量和注释时含糊不清、语焉不详,其他人将很难搞清楚代码的真实意图。就拿下面这行代码来说:

# 去掉 s 两边的空格,再处理
value = process(s.strip())

你能告诉我这段代码在做什么吗?当我看到它时,大脑大概是这么运作的:

  • s 上调用 strip(),所以 s 可能是一个字符串?不过为什么要去掉两边的空格呢?

  • process(…​) 顾名思义“处理”了一下这个 s,但具体是什么处理呢?

  • 处理结果赋值给了 valuevalue 代表“值”,但“值”又是什么?

  • 那行注释就更别提了,它说的就是代码本身,对理解代码没有一丁点儿帮助。

最后的结论是:“把一个可能是字符串的东西去掉两端的空格,然后处理一下,最后赋值给某个不明物体。”我能做到的最好理解就是这样了。

但同样是这段代码,如果我稍微调整一下变量的名字,加上一点点注释,就会变得截然不同:

# 用户输入可能会有空格,使用 strip 去掉空格
username = extract_username(input_string.strip())

新代码读上去是什么感觉?是不是代码意图变得容易理解多了?这就是变量与注释的魔力。

从计算机的角度来看,变量(variable)是用来从内存找到某个东西的标记。它叫“阿猫”“阿狗”“张三”“李四”,都无所谓。注释同样如此,计算机一点都不关心你的注释写得是否通顺,用词是否准确。因为它在执行代码时,会忽略所有的注释。

但正是这些对计算机来说无关痛痒的东西,直接决定了人们对代码的“第一印象”。好的变量和注释并非为计算机而写,而是为每个阅读代码的人而写(当然也包括你自己)。变量与注释是作者表达思想的基础,是读者理解代码的第一道门,它们对代码质量的贡献毋庸置疑。

本章将对 Python 里的变量和注释做一些简单介绍,我会分享一些常用的变量命名原则,介绍编写代码注释的几种方式。在编程建议里,我会列举一些与变量和注释有关的好习惯。

让我们从变量和注释开始,学习如何写出有着优秀“第一印象”的好代码吧!

1.1 基础知识

在本节,我将介绍一些与变量和注释相关的基础知识。

1.1.1 变量常见用法

在 Python 里,定义一个变量特别简单:

>>> author = 'piglei'
>>> print('Hello, {}!'.format(author))
Hello, piglei!

因为 Python 是一门动态类型语言,所以你不用预先声明变量类型,只要直接对变量赋值即可。

你也可以在一行语句里同时操作多个变量,比如调换两个变量所指向的值:

>>> author, reader = 'piglei', 'raymond'
>>> author, reader = reader, author (1)
>>> author
'raymond'
1 交换两个变量
1. 变量解包(unpacking)

“变量解包(unpacking)”是 Python 里的一种特殊赋值操作。它允许你把一个可迭代对象(比如列表)的所有成员,一次性赋值给多个变量:

>>> usernames = ['piglei', 'raymond']
# 注意:左侧变量个数必须和待展开的列表长度相等,否则会报错
>>> author, reader = usernames
>>> author
'piglei'

假如在赋值语句左侧添加小括号 (…​),你甚至可以一次展开多层嵌套数据:

>>> attrs = [1, ['piglei', 100]]
>>> user_id, (username, score) = attrs
>>> user_id
1
>>> username
'piglei'

除了上面的普通解包外,Python 还支持更灵活的动态解包语法。只要用星号表达式(*variables)作为变量名,它便会贪婪[1]地捕获多个值对象,并将捕获到的内容作为列表赋值给 variables

比如,下面 data 列表里的数据就分为三段:头为用户,尾为分数,中间的都是水果名称。通过把 *fruits 设置为中间的解包变量,我们就能一次性解包所有变量——fruits 会捕获 data 去头去尾后的所有成员:

>>> data = ['piglei', 'apple', 'orange', 'banana', 100]
>>> username, *fruits, score = data
>>> username
'piglei'
>>> fruits
['apple', 'orange', 'banana']
>>> score
100

和常规的切片赋值语句比起来,动态解包语法要直观许多:

# 1. 动态解包
>>> username, *fruits, score = data
# 2. 切片赋值
>>> username, fruits, score = data[0], data[1:-1], data[-1]
# 两种变量赋值方式完全等价

上面的变量解包操作,也可以在任何循环语句里使用:

>>> for username, score in (['piglei', 100], ['raymond', 60]):
...     print(username)
...
piglei
raymond
2. 单下划线变量名 _

在人们常用的诸多变量名中,单下划线 _ 是比较特殊的一个。它常作为一个无意义的占位符出现在赋值语句中。_ 这个名字本身没啥特别之处,这么用算是大家的一种约定俗成。

举个例子,假如你想在解包赋值时忽略某些变量,你就可以使用 _ 作为变量名:

# 忽略展开时的第二个变量
>>> author, _ = usernames

# 忽略第一个和最后一个变量之间的所有变量
>>> username, *_, score = data

而在 Python 交互式命令行(直接执行 python 命令进入的交互环境)里,_ 变量还有着一层特殊含义——它默认保存着你输入的上个表达式的返回值:

>>> 'foo'.upper()
'FOO'
>>> print(_) (1)
FOO
1 此时的 _ 变量保存着上一个 .upper() 表达式的结果

1.1.2 给变量注明类型

前面说过,Python 是动态类型语言,使用变量时不需要做任何类型声明。在我看来,这是 Python 相比其他语言的一个重要优势:它降低了我们的心智成本,让写代码变得更容易。尤其对于许多编程新手来说,“不用声明类型”无疑能让学 Python 这事变得简单很多。

但任何事物都有其两面性。动态类型所带来的缺点,就是代码的可读性也会因此大打折扣。

试着读读看下面这段代码:

def remove_invalid(items):
    """剔除 items 里面无效的元素"""
    ... ...

你能告诉我,函数接收的 items 参数是什么类型吗?是一个装满数字的列表,还是一个装满字符串的集合?只看上面这点代码,我们根本就无从得知。

为了解决动态类型带来的可读性问题,最常见的办法就是在函数文档(docstring)里做文章。我们可以把每个函数参数的类型与说明,全都写在函数文档里。

下面是增加了 Python 官方推荐的 Sphinx 格式文档后的效果:

def remove_invalid(items):
    """剔除 items 里面无效的元素

    :param items: 待剔除对象
    :type items: 包含整数的列表,[int, ...]
    """

在上面的函数文档里,我用 :type items: 注明了 items 是个整型列表。任何人只要读到这份文档,马上就能知道参数类型,不用再猜来猜去了。

当然,标注类型的办法肯定不止上面这一种。在 Python 3.5 版本[2]以后,你可以用类型注解功能来直接注明变量类型。相比编写 Sphinx 文档,我其实更推荐使用类型注解,因为它是 Python 的内置功能,而且正在变得越来越流行。

要使用类型注解,你只要在变量后添加类型,并用冒号隔开即可。比如 func(value: str) 表示函数的 value 参数为字符串类型。

下面是给 remove_invalid 函数添加类型注解后的样子:

from typing import List

def remove_invalid(items: List[int]):
    """剔除 items 里面无效的元素"""
    ... ...
“类型注解”只是一种有关类型的注释,不提供任何校验功能。校验类型正确性,需要使用其他静态类型检查工具(如 mypy 等)。

平心而论,不管是编写 Sphinx 格式文档,还是添加类型注解,它们都会增加写代码时的工作量。同样一段代码,标注变量类型比不标注一定要花费更多时间。

但从我的经验看来,这些额外的时间投入,会带来非常丰厚的回报:

  • 代码更好读,读代码时可以直接看到变量类型;

  • 大部分的现代化 IDE[3] 会读取类型注解信息,提供更智能的输入提示;

  • 类型注解配合 mypy 等静态类型检查工具,能提升代码正确性(13.1.5 节)

因此,我强烈建议在多人参与的中大型 Python 项目里,至少使用一种类型注解方案——Sphinx 文档或官方类型注解都行。能直接看到变量类型的代码,总是会让人更安心。

在本书第 10 章“面向对象设计原则(上)” 的 10.1.1 小节,你会看到更详细的“类型注解”功能说明,以及更多启用了类型注解的代码。

1.1.3 变量命名原则

如果要从变量着手来破坏代码质量,办法多到数也数不清,比如定义了变量但是不用,或者定义 100 个全局变量,等等。但如果要在这些办法中选出破坏力最大的那个,非“给变量起个坏名字”莫属。

下面这段代码就是一个充斥着坏名字的“集大成”者。试着读读看,看看你会有什么感受。

data1 = process(data)
if data1 > data2:
    data2 = process_new(data1)
    data3 = data2
return process_v2(data3)

怎么样,是不是挠破头都看不懂它在做什么?坏名字对代码质量的破坏力可见一斑。

那问题来了,既然大家都知道上面这样的代码不好,为何在这个世界上,每天都还有更多类似代码被写出来呢?我猜这是因为:给变量起个好名字真的很难。在计算机科学领域,有一句广为流传的格言(俏皮话):

在计算机科学领域只有两件难事:缓存失效和命名。(There are only two hard things in Computer Science: cache invalidation and naming things. )

— Phil Karlton

这句话里虽然一半严肃、一半玩笑,但“命名”有时真的会难到让人抓狂。我常常呆坐在显示器前,抓耳挠腮好几分钟,就是没法给变量想到一个合适的名字。

要给变量起个好名字,主要靠的是经验,有时还需再加上一丁点儿灵感,但更重要的是得遵守一些基本原则。下面就是我总结的几条变量命名基本原则。

1. 遵循 PEP8 原则

给变量起名有两种最主要的流派,一是通过大小写界定单词的驼峰命名派:CamelCase,二是通过下划线连接的蛇形命名派:snake_case。这两种流派没有明显的优劣之分,似乎只与个人喜好有关。

但是,为了让不同开发者写出的代码风格尽量保持统一,Python 制定了官方的编码风格指南: PEP 8。这份风格指南里有许多详细的风格建议,比如应该用 4 个空格缩进,每行不超过 79 个字符,等等。

里面当然也包含变量的命名规范:

  • 普通变量:使用蛇形命名法:max_value

  • 常量:全大写字母,使用下划线连接:MAX_VALUE

  • 标记为“仅内部使用”,增加下划线前缀:_local_var

  • 当名字与 Python 关键字冲突时,末尾追加下划线:class_

除变量名以外,PEP 8 中还有许多其他命名规范。比如类名应该使用驼峰风格(FooClass)、函数应该使用蛇形风格(bar_function),等等。给变量起名的第一条原则,就是一定要在格式上遵循以上规范。

PEP 8 是 Python 编码风格的事实标准。“代码符合 PEP8 规范”应该作为对编码者的基本要求之一。假如一份代码不符合 PEP8,就基本不必再继续讨论它优雅与否了。
2. 描述性要强

写作时的一个重要工作,就是为句子挑选合适的词语。不同词语的描述性有强有弱,比如“冬天的梅花”就比“花”的描述性更强。而变量名和普通词语一样,同样有描述性强弱之分。假如代码大幅使用描述性弱的词,读者就很难理解代码的含义。

本章开头的那两段代码,可以很好地解释这个问题:

# 描述性弱的名字:看不懂在做什么
value = process(s.strip())

# 描述性强的名字:尝试从用户输入里解析出一个用户名
username = extract_username(input_string.strip())

所以,在可接受的长度范围内,变量名把它所指向的内容描述得越精确越好。下面的表中是一些具体的例子。

Table 1. 表 1-1 一些描述性弱和强的示例
弱名字 强名字 说明

data

file_chunks

data 泛指所有的“数据”,但如果数据是来自文件的碎块,我们可以直接叫 file_chunks

temp

pending_id

temp 泛指所有“临时”的东西,但其实它存的是一个等待处理的数据 ID,因此直接叫它 pending_id 更好

result(s)

active_member(s)

result(s) 经常被用来表示函数执行的“结果”,但如果这个结果就是指“活跃会员”,那还是直接叫它 active_member(s) 吧

看到上面的表格,你会不会认为:“就是说左边的名字都不好,永远别用它们?”

当然不是这样。判断一个名字是否合适,一定要结合它所在的场景,脱离场景谈名字是片面的,是没有意义的。因此,我在表格的“说明”栏里,都写了这个判断所适用的场景。

而在一些其他场景下,“描述性弱”的名字也能是好名字。比如把一个数学公式的计算结果叫作 value,就非常恰当。

3. 要尽量短

我刚说到,变量名的描述性要尽量强,但描述性越强,通常也代表着名字就越长(不信再看看前面那张表,第二列的名字都比第一列长)。假如不加思考地实践“描述性原则”,那你的代码里可能会充斥着 how_many_points_needed_for_user_level3 这种名字,简直像条真蛇一样长:

def upgrade_to_level3(user):
    """如果积分满足要求,将用户升级到级别 3"""
    how_many_points_needed_for_user_level3 = get_level_points(3)
    if user.points >= how_many_points_needed_for_user_level3:
        upgrade(user)
    else:
        raise Error('积分不够,必须要 {} 分'.format(how_many_points_needed_for_user_level3))

假如一个特别长的名字重复出现,读者不会认为它足够精确,反而会觉得它啰嗦难读。既然如此,怎么才能在保证描述性的前提下,让名字尽量简短易读呢?

我认为个中诀窍在于:在起名时结合代码情境和上下文思考。比如在上面的代码里,upgrade_to_level3(user) 这个函数已经通过自己的名称、文档,清楚表明了自己的目的,那在函数内部,我们完全可以把 how_many_points_needed_for_user_level3 直接删减成 level3_points

即使没用特别长的名字,相信读代码的人也肯定能明白,这个 level3_points 指的就是“升到级别 3 所需要的积分”,而不是其他含义。

到底多长的名字算是太长呢?我的经验是尽量不要超过 4 个单词。
4. 要匹配类型

虽然变量无须声明类型,但为了提升可读性,我们可以用类型注解语法给其加上类型。不过现实很残酷,到目前为止,大部分 Python 项目都没有任何类型注解。因此当你看到一个变量时,除了通过上下文猜测,没法轻易知道它是什么类型。

话虽如此,但人们对于变量名和类型的关系,通常会有一些直觉上的约定。如果在起名时遵守这些约定,就可以建立名字和类型间的匹配关系,让代码更容易被理解。

匹配布尔值类型的名字

布尔值(bool)是一种很简单的类型,它只有两个可能的值:“是(True)”或“不是(False)”。因此,给布尔值变量起名有一个原则:一定要让读到变量的人觉得它只会有“肯定”和“否定”两种可能。举例来说,ishas 这些非黑即白的词就很适合用来修饰这类名字。

下面的表内有一些更详细的例子:

Table 2. 表 1-2 一些布尔值变量名示例
变量名 含义 说明

is_superuser

是否是超级用户

是 / 不是

has_errors

有没有错误

有 / 没有

allow_empty

是否允许空值

允许 / 不允许

nullable

是否可以为 null

可以 / 不可以

匹配 int/float 类型的名字

当人们看到和数字有关的名字时,自然就会认定它们是 intfloat 类型。这些名字可被简单分为以下几种常见类型:

  • 释义为数字的所有单词,比如:port(端口号)age(年龄)radius(半径)

  • 使用 _id 结尾的单词,比如:user_idhost_id

  • 使用 length/count 开头或者结尾的单词,比如: length_of_usernamemax_lengthusers_count

最好别拿一个名词的复数形式来作为 int 类型的变量名,比如 applestrips 等。因为这类名字,会和那些装着 AppleTrip 的普通容器对象(List[Apple]、List[Trip])相混淆。为了避免混淆,我建议用 number_of_applestrips_count 这种复合词来作为 int 类型的名字。
匹配其他类型的名字

至于剩下的字符串(str)、列表(list)、字典(dict)等其他值类型,我们很难归纳出一个“由名字猜测类型”的统一公式。拿 headers 这个名字来说,它既可能是一个装满头信息的列表(List[Header]),也可能是一个包含头信息的字典(Dict[str, Header])。

对于这些值类型,我强烈建议使用 1.1.2 节“给变量注明类型”中提到的方案,在代码中明确标注它们的类型详情。

5. 超短命名

在众多变量名里,有一类名字非常特别,那就是只有一两个字母的短名字。这些短名字一般可分为两类,第一类是那些大家约定俗成的短名字,比如:

  1. 数组索引三剑客 ijk

  2. 某个整数 n

  3. 某个字符串 s

  4. 某个异常 e

  5. 文件对象 fp

我并不反对使用这类短名字,自己也经常用,因为它们写起来的确很方便。但如果条件允许,我还是建议尽量用更精确的名字替代它们。比如,在表示用户输入的字符串时,用 input_str 替代 s 总是会更明确一些。

另一类短名字,则是对一些其他常用名的缩写。比如,在使用 Django 框架做国际化内容翻译时,常常会用到 gettext 方法。为了方便,我们常会把 gettext 缩写成 _ 来使用:

from django.utils.translation import gettext as _

print(_('待翻译文字'))

如果你在项目中发现有一些长名字会重复出现,那你也可以效仿上面的方式,为这些长名字设置一些短名字作为别名。这样可以让代码变得更紧凑,更好读。但同一个项目内的超短缩写不宜太多,否则效果就会适得其反。

其他技巧

除了上面这些规则以外,还有一些给变量命名的小技巧:

  • 在同一段代码内,不要出现多个相似的变量名,比如同时使用 usersusers1users3 这种序列

  • 你可以尝试用换词来简化复合变量名,比如用 is_special 来代替 is_not_normal

  • 如果你苦思冥想都想不出一个合适的名字,请打开 GitHub[4],到其他人的开源项目里找找灵感吧

1.1.4 注释基础知识

注释(comment)是一份代码里非常重要的组成部分。通常来说,注释泛指那些不影响代码实际行为的文字,它们主要起到代码之外的额外说明作用。

Python 里的注释主要分为两种,第一种是最常见的代码内注释,通过在行首输入 # 号来完成:

# 用户输入可能会有空格,使用 strip 去掉空格
username = extract_username(input_string.strip())

当注释包含多行内容时,同样也是使用 # 号:

# 使用 strip() 去掉空格的好处:
# 1. 数据库保存时占用空间更小
# 2. 不用因为用户多打了一个空格而要求用户重新输入
username = extract_username(input_string.strip())

除了使用 # 的注释外,另一种注释则是我们前面看到过的函数(类)文档(docstring),这些文档也可被称为接口注释(interface comment)。

class Person:
    """人

    :param name: 姓名
    :param age: 年龄
    :param favorite_color: 最喜欢的颜色
    """

    def __init__(self, name, age, favorite_color):
        self.name = name
        self.age = age
        self.favorite_color = favorite_color

接口注释有好几种流行的风格,比如 Sphinx 文档风格、Google 风格,等等,其中 Sphinx 风格目前应用最为广泛。上面的 Person 类的接口注释就属于 Sphinx 文档风格。

虽然注释一般不影响代码执行效果,但它却会极大的影响代码可读性。在编写注释时,编程新手们常常会犯下一些同类型的错误,以下是我整理的最常见的 3 种错误。

1. 用注释屏蔽代码

有时,人们会把注释当作临时屏蔽代码的工具。当某些代码暂时不需要执行时,就把它们都注释了,未来需要时再解除注释。

# 源码里有大段大段暂时不需要执行的代码
# trip = get_trip(request)
# trip.refresh()
# ... ...

其实根本没必要这么做。这些被临时注释掉的大段内容,对于读代码的人来说是一种干扰,没有任何意义。对于不再需要的代码,我们应该直接把它们删掉,而不是注释掉。如果未来有人真的需要用到这些旧代码,他直接去 Git 仓库历史里就能找到,毕竟版本控制系统就是专门干这个的。

2. 注释复述代码

在编写注释时,新手常犯的另一类错误是用注释复述代码。就像这样:

# 调用 strip() 去掉空格
input_string = input_string.strip()

上面代码里的注释完全是冗余的,因为读者从代码本身就能读到注释里的信息。好的注释,应该像下面这样:

# 如果直接把带空格的输入传递到后端处理,可能会造成后端服务崩溃
# 因此使用 strip() 去掉首尾空格
input_string = input_string.strip()

注释作为代码之外的说明性文字,应该尽量提供那些读者无法从代码里读出来的信息。描述代码为什么要这么做,而不是简单复述代码本身。

除了描述“为什么”的解释性注释外,还有一种注释也很常见:指引性注释。这种注释并不直接复述代码,而是简明扼要的概括了代码功能,起到“代码导读”的作用。

比如,下面代码里的注释就属于指引性注释:

# 初始化访问服务的 client 对象
token = token_service.get_token()
service_client = ServiceClient(token=token)
service_client.ready()

# 调用服务获取数据,然后进行过滤
data = service_client.fetch_full_data()
for item in data:
    if item.value > SOME_VALUE:
        ...

指引性注释并不提供代码里读不到的东西——假如没注释,耐心读完所有代码,你也能知道代码做了什么事儿。指引性注释的主要作用是降低代码的认知成本,让我们能更容易理解代码的意图。

在编写指引性注释时,有一个点需要注意。那就是你得判断何时该写注释,何时该将代码提炼为独立的函数(或方法)。比如上面的代码,其实可以通过抽象两个新函数被改成这样:

service_client = make_client()
data = fetch_and_filter(service_client)

这么改以后,代码里的指引性注释就可以删掉了,因为有意义的函数名已经达到了概括和指引的作用。

正是因为这样,一部分人认为:只要代码里有指引性注释,就说明代码可读性不高,无法“自说明”[5],一定得抽象新函数把其优化成第二种样子。

但我倒认为这事没那么绝对。无论代码写得多好,多么“自说明”,同读代码相比,读注释总是会让人觉得更轻松。注释会让人们觉得亲切(尤其当注释是中文时),高质量的指引性注释确实会让代码更好读。有时抽象一个新函数,不见得就一定比一行注释加上几行代码更好。

3. 弄错接口注释的受众

在编写接口注释时,人们有时会写出下面这种内容:

def resize_image(image, size):
    """将图片缩放为指定尺寸,并返回新的图片。

    该函数将使用 Pilot 模块读取文件对象,然后调用 .resize() 方法将其缩放为指定尺寸。

    但由于 Pilot 模块自身限制,这个函数不能很好的处理尺寸过大的文件,当文件大小
    超过 5MB 时,resize() 方法的性能就会因为内存分配问题急剧下降,详见 Pilot 模块的
    Issue #007。因此,对于超过 5MB 的图片文件,请使用 resize_big_image() 替代,后者
    基于 Pillow 模块开发,很好的解决了内存分配问题,性能更好。

    :param image: 图片文件对象
    :param size: 包含宽高的元组:(width, height)
    :return: 新图片对象
    """

上面这段注释虽然有些夸张,但像它一样的注释在项目中其实并不少见。这段接口注释最主要的问题,在于过多阐述了函数的实现细节,提供了太多其他人并不关心的内容。

接口文档主要是给函数(或类)的使用者看的,它最主要的存在价值,是让人们不用逐行阅读函数代码,也能很快通过文档知道该如何使用这个函数,以及在使用时有什么注意事项。

在编写接口文档时,我们应该站在函数设计者的角度,着重描述函数的功能、参数说明等。而函数自身的实现细节——比如调用了哪个第三方模块、为啥有性能问题等,都不用放在接口文档里。

对于上面的 resize_image() 函数来说,文档里提供以下内容就足够了:

def resize_image(image, size):
    """将图片缩放为指定尺寸,并返回新的图片。

    注意:当文件超过 5MB 时,请使用 resize_big_image()

    :param image: 图片文件对象
    :param size: 包含宽高的元组:(width, height)
    :return: 新图片对象
    """

至于那些使用了 Pilot 模块、为何有内存问题的细节说明,全都可以丢进函数内部的代码注释里。

1.2 案例故事

在这个部分,我将给你讲述一位 Python 程序员去其他公司面试的故事。

在这个故事以及这本书剩下的所有案例故事里,你会多次看到“小 R”这个名字。

小 R 这个名字来自作者的英文名(Raymond)的首字母缩写。随着故事的不同,小 R 有时是一位刚接触 Python 的初学者,有时又是有着一名有多年经验的 Python 老手。但无论他扮演了什么角色,他总会在每个故事里获得新的成长。

下面,就让我们开始本书的第一个故事。

奇怪的冒泡排序算法

上午 10 点,在 T 公司的会议室里,小 R 正在参加一场他准备了好几天的技术面试。

整体来说,他在这场面试里的表现还不错。无论坐在小 R 对面的面试官提出什么问题,他都能侃侃而谈、对答如流。从单体应用聊到微服务,从虚拟机聊到云计算,每一块小 R 都说得滴水不漏。就在他慢慢认为自己胜券在握,可以通过这家自己憧憬已久的公司面试时,对面的面试官突然说道:“技术问题我问的差不多了。最后有一道编程题,希望你可以用这台笔记本做一下。”

说完,面试官低头从包里拿出了一台笔记本电脑,递给了小 R。小 R 有些紧张地接过电脑,发现屏幕上是一道算法题。

题目 1-1 奇怪的冒泡排序
请用 Python 语言实现冒泡排序算法,把较大的数字放在后面。注意:默认所有的偶数都比奇数更大。

>>> numbers = [23, 32, 1, 3, 4, 19, 20, 2, 4]
>>> magic_bubble_sort(numbers)
[1, 3, 19, 23, 2, 4, 4, 20, 32]

“冒泡排序,这不是所有排序算法里最简单的一种吗?虽然加了一点变化,但这还是没有什么难度啊。”小 R 一边在心里这么想着,一边打开编辑器开始写代码。

五分钟后,他把笔记本递给面试官,说道:“写完了!”

下面就是他写的代码。

代码清单 1-1 小 R 的奇怪的冒泡排序函数
def magic_bubble_sort(numbers):
    j = len(numbers) - 1
    while j > 0:
        for i in range(j):
            if numbers[i] % 2 == 0 and numbers[i + 1] % 2 == 1:
                numbers[i], numbers[i + 1] = numbers[i + 1], numbers[i]
                continue
            elif (numbers[i + 1] % 2 == numbers[i] % 2) and numbers[i] > numbers[i + 1]:
                numbers[i], numbers[i + 1] = numbers[i + 1], numbers[i]
                continue
        j -= 1
    return numbers

这段代码没有任何多余的逻辑,可以通过所有的测试用例。面试官看着小 R 演示完函数功能后,盯着代码似乎想说点什么,但最后只是微微点了点头,说:“挺好,今天的面试就到这儿吧。有后续面试我再通知你。”

小 R 高高兴兴地回到家,一心觉得这次面试稳了。可没想到,他后来却再也没接到任何后续面试通知。

1. 问题出在哪里

究竟是哪里出了问题呢?小 R 思来想去,觉得自己答问题时表现挺好,最有可能出问题的是最后一道编程题,肯定漏掉了什么边界情况没处理。

于是他找到一位有着十年编程经验的前辈小 Q,凭着记忆把题目和自己的答案还原给对方看。

“题目大概就是这样,这是我当时写的代码。Q 哥,你帮忙看看,我是不是有什么情况没考虑到?”小 R 问道。

小 Q 盯着他写的代码,足足两分钟没说一句话。然后突然开口道:“小 R 啊,你这个函数功能实现得没毛病,就是实在太难看懂了。"

“总共就 10 行代码。难看懂?怎么会呢?”小 R 在心里泛起了嘀咕。这时,前辈小 Q 说道:“这样,你把笔记本给我,我来给你稍微改改这段代码,然后你再看看。”

于是,三分钟后,小 Q 把把修改过的代码递了过来:

代码样例 1-2 小 Q 修改后的奇怪的冒泡排序函数
def magic_bubble_sort(numbers: List[int]):
    """有魔力的冒泡排序算法,默认所有的偶数都比奇数大

    :param numbers: 需要排序的列表,函数将会直接修改原始列表
    """
    stop_position = len(numbers) - 1
    while stop_position > 0:
        for i in range(stop_position):
            current, next_ = numbers[i], numbers[i + 1] (1)
            current_is_even, next_is_even = current % 2 == 0, next_ % 2 == 0
            should_swap = False

            # 交换位置的两个条件:
            # - 前面是偶数,后面是奇数
            # - 前面和后面同为奇数或者偶数,但是前面比后面大
            if current_is_even and not next_is_even:
                should_swap = True
            elif current_is_even == next_is_even and current > next_:
                should_swap = True

            if should_swap:
                numbers[i], numbers[i + 1] = numbers[i + 1], numbers[i]
        stop_position -= 1
    return numbers
1 注意:此处变量名是 next_ 而非 next,这是因为已经有一个内置函数用了 next 这个名字。PEP8 规定在这种情况下,应该给变量名增加 _ 后缀来避免冲突

小 R 盯着这段代码,发现它的核心逻辑和之前没有任何不同。但不知怎地,这段代码看上去,就是比自己写的代码让人觉得更舒服。小 R 若有所思,好像一下明白了自己没通过面试的原因。

故事讲完了。看上去,前辈小 Q 只是在小 R 的代码之上,做了些“无关痛痒”的改动,但正是这些“无关痛痒”的改动,改善了代码的观感,提升了整个函数的可读性。

2. “无关痛痒”的改动

和小 R 写的代码相比,前辈小 Q 的新代码主要有这些变化:

  1. 变量名变成了可读的、有意义的名字,比如在旧代码里,“停止位”是无意义的 j,新代码里变成了 stop_position

  2. 增加了有意义的临时变量,比如 current / next_ 代表前一个与后一个元素、{}_is_even 代表元素是否为偶数、should_swap 代表是否应该交换元素。

  3. 多了一点恰到好处的指引性注释,比如说明应该交换元素顺序的详细条件。

这些变化让整段代码变得更易读,也让整个算法变得更好理解。所以,哪怕是一段不到 10 行代码的简单函数,对变量和注释的不同处理方式,也会让代码发生质的变化。

1.3 编程建议

在这一节,我将与你分享一些与本章主题有关的编程建议。本书的“编程建议”中没啥高谈阔论的大道理,多是些专注细节的小点子。比如:定义临时变量有什么好处;为什么你应该先写注释,再写代码,等等。希望这些“小点子”能助你写出更棒的代码。

下面,来看看那些和变量与注释有关的“小点子”吧。

1.3.1 保持变量的一致性

在使用变量时,你需要保证变量在两个方面的一致性:名字一致性与类型一致性。

名字一致性是指在同一个项目(或者模块、函数)中,对一类事物的称呼不要变来变去。如果你把项目里的“用户头像”叫做 user_avatar_url,那么在其他地方就别把它改成 user_profile_url。因为这会让读代码的人犯迷糊:“user_avatar_urluser_profile_url 到底是不是一个东西?”

类型一致性则是指不要把同一个变量重复指向不同类型的值。举个例子:

def foo():
    # users 本身是一个 Dict
    users = {'data': ['piglei', 'raymond']}
    ...
    # users 这个名字真不错!尝试复用它,把它变成 List 类型
    users = []
    ...

foo() 函数的作用域内,users 变量被使用了两次:第一次指向字典,第二次则变成了列表。虽然 Python 的类型系统允许我们这么做,但这样做其实有很多坏处。比如变量的辨识度会因此降低,并且还很容易引入 bug。

所以,我建议在这种情况下启动一个新变量:

def foo():
    users = {'data': ['piglei', 'raymond']}
    ...
    # 使用一个新名字
    user_list = []
    ...
如果使用 mypy 工具(13.1.5 节),它在静态检查时就会把这种“变量类型不一致”的错误给报出来。像上面的代码会输出 error: Incompatible types in assignment 错误。

1.3.2 变量定义尽量靠近使用

包括我自己在内的很多人在初学编程时,有一种很不好的习惯——喜欢把所有变量初始化定义写在一起,放在函数最前面。

就像下面这样:

def generate_trip_png(trip):
    """
    根据旅途数据,生成 PNG 图片
    """
    # 预先定义好所有的局部变量
    waypoints = []
    photo_markers, text_markers = [], []
    marker_count = 0

    # 开始初始化 waypoint 数据
    waypoints.append(...)
    ...
    # 经过好几行代码后,开始处理 photo_markers、text_markers
    photo_markers.append(...)
    ...
    # 经过更多代码后,开始计算 marker_count
    marker_count += ...

    # 拼接图片:已省略...

我们之所以这么写代码,是因为我们觉得:“初始化变量”语句是类似的,所以应该将其归类到一起,放到最前面,这样代码会整洁很多。

但是,这样的代码只是看上去整洁,它的可读性不会得到任何提升,反而会下降。

在组织代码时,我们应该谨记:总是从代码的职责出发,而不是其他东西。比如,在上面的 generate_trip_png 函数里,代码的职责主要分为三块:

  1. 初始化 waypoints 数据

  2. 处理 markers 数据

  3. 计算 marker_count

那代码可以这么调整:

def generate_trip_png(trip):
    """
    根据旅途数据,生成 PNG 图片
    """
    # 开始初始化 waypoint 数据
    waypoints = []
    waypoints.append(...)
    ...

    # 开始处理 photo_markers、text_markers
    photo_markers, text_markers = [], []
    photo_markers.append(...)
    ...

    # 开始计算 marker_count
    marker_count = 0
    marker_count += ...

    # 拼接图片:已省略...

通过把变量定义移动到每段不同职责的代码头部,大大缩短了变量从初始化到被使用的距离。当读者阅读代码时,可以更容易理解代码的逻辑,而不是来回翻阅代码,心想:“这个变量是啥时候定义的?干什么用的?”

1.3.3 定义临时变量提升可读性

随着业务逻辑变得复杂,我们的代码里也会经常出现一些复杂的表达式,就像下面这样:

# 为所有性别为女性,或者级别大于 3 的活跃用户发放 10000 个金币
if user.is_active and (user.sex == 'female' or user.level > 3):
    user.add_coins(10000)
    return

看见 if 后面那一长串了吗?有点难读对不对?但这也没办法,毕竟产品经理就是明明白白这么跟我说的——业务逻辑如此。

但逻辑虽然如此,不代表我们就得把代码直白地写成这样。如果把后面的复杂表达式赋值为一个临时变量,代码可以变得更好读:

# 为所有性别为女性,或者级别大于 3 的活跃用户发放 10000 个金币
user_is_eligible = user.is_active and (user.sex == 'female' or user.level > 3):

if user_is_eligible:
    user.add_coins(10000)
    return

在新代码里,“计算用户合规的表达式”和“判断合规发送金币的条件分支”这两段代码不再直接被杂糅在了一起,而是有了一个可读性强的变量 user_is_elegible 作为缓冲。不论是代码的可读性还是可维护性,都因为这个变量而变得更好了。

直接翻译业务逻辑的代码,大多都不是好代码。优秀的程序设计需要在理解原需求的基础上,创造恰到好处的抽象,才能同时满足在可读和可扩展方面的需求。创造抽象有许多种方式,比如定义新函数、定义新类型,等等,而“定义一个临时变量”,则是诸多方式里不太起眼的那一个。

1.3.4 同一作用域内不要有太多变量

通常来说,你的函数越长,它所用到的变量也会越多。但是人脑的记忆力是很有限的,研究表明,人类的短期记忆只能同时记住不超过 10 个名字。如果变量过多,代码肯定就会变得很难读。

拿这段代码为例:

代码样例 1-3 局部变量过多的函数
def import_users_from_file(fp):
    """尝试从文件对象读取用户,然后导入到数据库中

    :param fp: 可读文件对象
    :return: 成功与失败数量
    """
    # 初始化变量:重复用户、黑名单用户、正常用户
    duplicated_users, banned_users, normal_users = [], [], []
    for line in fp:
        parsed_user = parse_user(line)
        # ... 进行判断处理,修改上面定义的 {X}_users 变量

    succeeded_count, failed_count = 0, 0
    # ... 读取 {X}_users 变量,写入数据库并修改成功失败数量
    return succeeded_count, failed_count

import_users_from_file() 函数里就有着许多变量,比如用来暂存用户的 {duplicated|banned|normal}_users,用来保存结果的 succeeded_countfailed_count 等。

如果要直接减少函数里的变量数量,最直接的方式是给这些变量归类,建立新的模型。比如,我们可以将代码里的 succeeded_countfailed_count 建模为 ImportedSummary 类,用 ImportedSummary.succeeded_count 来替代现有变量。对 {duplicated|banned|normal}_users 也可以执行同样的操作。

代码样例 1-4 对局部变量分组并建模
class ImportedSummary:
    """保存导入结果摘要的数据类"""

    def __init__(self):
        self.succeeded_count = 0
        self.failed_count = 0

class ImportingUserGroup:
    """用于暂存用户导入处理的数据类"""

    def __init__(self):
        self.duplicated = []
        self.banned = []
        self.normal = []

def import_users_from_file(fp):
    """尝试从文件对象读取用户,然后导入到数据库中

    :param fp: 可读文件对象
    :return: 成功与失败数量
    """
    importing_user_group = ImportingUserGroup()
    for line in fp:
        parsed_user = parse_user(line)
        # ... 进行判断处理,修改上面定义的 importing_user_group 变量

    summary = ImportedSummary()
    # ... 读取 importing_user_group,写入数据库并修改成功失败数量
    return summary.succeeded_count, summary.failed_count

通过增加两个数据类,函数内的变量被更有逻辑地组织了起来,数量变少了许多。

但大多数情况下,上面这样做是远远不够的。函数内变量太多,通常意味着函数已经过于复杂,承担了太多职责。只有把这种函数拆分为多个小函数,代码的整体复杂度才能有根本性的降低。

在 7.3.1 节,你可以找到更多与函数复杂度有关的内容,看到更多与拆分函数的建议。

1.3.5 能不定义变量就别定义

前面提到过,定义临时变量可以提高代码的可读性。但有时,把不必要的东西赋值为临时变量,反而会让代码显得啰嗦:

def get_best_trip_by_user_id(user_id):
    # 心理活动:“嗯,这个值未来说不定会修改/二次使用”,让我们先把它定义成变量吧!
    user = get_user(user_id)
    trip = get_best_trip(user_id)
    result = {
        'user': user,
        'trip': trip
    }
    return result

在编写代码时,我们会下意识地定义很多变量,好为未来调整代码做准备。但其实,你所想的未来也许永远不会来,上面这段代码里的三个临时变量完全可以去掉,变成下面这样:

def get_best_trip_by_user_id(user_id):
    return {
        'user': get_user(user_id),
        'trip': get_best_trip(user_id)
    }

这样的代码就像删掉了赘语后的句子,反而变得更精炼、更好读。所以,不必为了那些可能出现的变动,牺牲代码此时此刻的可读性。如果以后需要定义变量,那就以后再做吧!

1.3.6 不要使用 locals()

locals() 是 Python 的一个内置函数,调用它会返回当前作用域中的所有局部变量。

def foo():
    name = 'piglei'
    bar = 1
    print(locals())

# 调用 foo() 将输出:
{'name': 'piglei', 'bar': 1}

在有些场景下,有时我们需要一次性拿到当前作用域下的所有(或绝大部分)变量。比如在渲染 Django 模板时就是如此:

def render_trip_page(request, user_id, trip_id):
    """渲染旅程页面"""
    user = User.objects.get(id=user_id)
    trip = get_object_or_404(Trip, pk=trip_id)
    is_suggested = check_if_suggested(user, trip)
    return render(request, 'trip.html', {
        'user': user,
        'trip': trip,
        'is_suggested': is_suggested
    })

而这看上去正适合使用 locals() 函数。假如调用 locals(),上面的代码会简化许多:

def render_trip_page(request, user_id, trip_id):
    ...

    # 利用 locals() 把当前所有变量作为模板渲染参数返回
    # 节约了三行代码,我简直是个天才!
    return render(request, 'trip.html', locals())

上面这种代码,第一眼看上去非常“简洁”。但是,这样的代码真的更好吗?

答案并非如此。虽然 locals() 看似简洁,但其他人在阅读代码时,为了搞明白模板渲染到底用了哪些变量,他必须记住当前作用域里的所有变量。假如函数非常复杂,“记住所有局部变量”简直是个不可能的任务。

使用 locals() 还有一个缺点,那就是它会把一些并不真正使用的变量也一并暴露出去。

因此,比起使用 locals(),我还是建议老老实实把代码写成这样:

    return render(request, 'trip.html', {
        'user': user,
        'trip': trip,
        'is_suggested': is_suggested
    })
Python 之禅:显式优于隐式

在 Python 命令行中输入 import this,你可以看到 Tim Peters 写的一段编程原则: The Zen of Python(“Python 之禅”) 。这些原则字字珠玑,里面蕴藏着许多 Python 编程智慧。

“Python 之禅”其中有一句:“Explicit is better than implicit.(显式优于隐式)”,这完全可以套用到 locals() 的例子上,因为 locals() 实在是太隐晦了。

1.3.7 空行也是一种“注释”

有时,代码里的注释不只是那些常规的描述性语句。没有一个字符的空行,其实也能被算作是一种特殊的“注释”。

在写代码时,我们可以适当的在代码中插入空行,把代码按不同的逻辑块分隔开来,这样能有效提高代码可读性。

举个例子,拿本章案例故事里的代码来说,假如删掉所有空行,代码会变成下面这样,请你试着读读看:

代码样例 1-5 没有任何空行的冒泡排序(所有文字类注释已删除)
def magic_bubble_sort(numbers: List[int]):
    stop_position = len(numbers) - 1
    while stop_position > 0:
        for i in range(stop_position):
            current, next_ = numbers[i], numbers[i + 1]
            current_is_even, next_is_even = current % 2 == 0, next_ % 2 == 0
            should_swap = False
            if current_is_even and not next_is_even:
                should_swap = True
            elif current_is_even == next_is_even and current > next_:
                should_swap = True
            if should_swap:
                numbers[i], numbers[i + 1] = numbers[i + 1], numbers[i]
        stop_position -= 1
    return numbers

怎么样?是不是感觉代码特别局促,连喘口气的机会都找不到?这就是缺少了空行所导致。只要在代码里加上一丁点空行(不多,就两行),函数的可读性马上会得到可观的提升:

代码样例 1-6 增加了空行的冒泡排序
def magic_bubble_sort(numbers: List[int]):
    stop_position = len(numbers) - 1
    while stop_position > 0:
        for i in range(stop_position):
            previous, latter = numbers[i], numbers[i + 1]
            previous_is_even, latter_is_even = previous % 2 == 0, latter % 2 == 0
            should_swap = False

            if previous_is_even and not latter_is_even:
                should_swap = True
            elif previous_is_even == latter_is_even and previous > latter:
                should_swap = True

            if should_swap:
                numbers[i], numbers[i + 1] = numbers[i + 1], numbers[i]
        stop_position -= 1
    return numbers

1.3.8 先写注释,后写代码

在编写了许多函数以后,我总结出了一个值得推广的好习惯:先写注释,后写代码。

每个函数的名称与接口注释(也就是 docstring),其实是一种比函数内部代码更为抽象的东西。你需要在函数名字和短短几行注释里,把函数内代码所做的事情,高度浓缩的表达清楚。

正因如此,接口注释其实完全可以被当成一种协助你设计函数的前置工具。这个工具的用法很简单:假如你没法通过几行注释把函数职责描述清楚,那么整个函数的合理性就应该打上一个问号。

举个例子,你在编辑器里写下了 def process_user(…​):,准备实现一个名为 process_user 的新函数。在编写函数注释时,你发现在写了好几行文字后,仍然没法把 process_user() 的职责描述清楚,因为它可以同时完成好多件不同的事情。

这时你就会意识到,process_user() 函数已经承担了太多职责,你应该直接删掉它,设计更多单一职责的子函数来替代。

先写注释的另一个好处是:你不会漏掉任何应该写的注释。

我常常在代码评审时发现,一些关键函数的 docstring 位置总是一片空白,而那里本该有着详尽的接口注释。每到这时,我都得不厌其烦的请求代码提交者补充上接口注释。

为什么大家总会漏掉注释?我的一个猜测是:人们在编写函数时,总是跳过接口注释直接开始写代码。而当他写完代码,实现完函数的所有功能后,他就已经对这个函数失去了兴趣。这时,他最不愿意做的事,就是回过头去补写函数的接口注释,即便写了,也只是草草对付几句了事。

如果遵守“先写注释,再写代码”的习惯,我们就能完全避免上面的问题。这个习惯实施起来其实很简单:在写出一句有说服力的接口注释前,别写任何函数代码。

1.4 总结

在一段代码里,变量和注释是最接近自然语言的东西。因此,好的变量名、简明扼要的注释,都可以显著提升你的代码质量。在给变量起名时,请尽量使用那些描述性强的名字,但有时也得注意别过了头。

从小 R 的面试故事看来,即使是两段算法一致,功能也完全一样的代码,也会因为变量和注释的区别,给其他人截然不同的感觉。因此,要想让你的代码给人留下漂亮的第一印象,请记得在变量和注释上多下功夫。

要点

  1. 变量和注释决定“第一印象”:

    • 变量和注释是代码里最接近自然语言的东西,它们的可读性非常重要

    • 即使是实现同一个算法,变量和注释不一样,代码给人的感觉会截然不同

  2. 基础知识:

    • Python 的变量赋值语法非常灵活,可以使用 *variables 星号表达式灵活赋值

    • 编写注释的两个要点:“不要用来屏蔽代码”以及“解释为什么”

    • 接口注释是为使用者而写,因此接口注释应该简明扼要的描述函数职责,而不必包含太多内部细节

    • 你可以用 Sphinx 格式文档或类型注解给变量标明类型

  3. 变量名字很重要:

    • 给变量起名要遵循 PEP 8 指南,代码的其他部分也同样如此

    • 尽量给变量起描述性强的名字,但评价描述性也需要结合场景

    • 在保证描述性的前提下,变量名要尽量短

    • 变量名要匹配它所表达的类型

    • 你可以使用一两个字母的超短名字,但注意不要过度

  4. 代码组织技巧:

    • 按照代码的职责来组织代码:让变量定义靠近引用

    • 适当定义临时变量可以提升代码可读性

    • 但不必要的变量反而会让代码显得冗长、啰嗦

    • 同一个作用域内不要有太多变量,解决办法:提炼数据类、拆分函数

    • 空行也是一种特殊的“注释”,适当的空行可以让代码更好读

  5. 代码可维护性技巧:

    • 保持变量在两个方面的一致性:名字一致性与类型一致性

    • 显式优于隐式:不要使用 locals() 批量获取变量

    • 把接口注释当成一种函数设计工具,先写注释,后写代码


1. “贪婪”这个词儿在计算机领域有特殊含义。比方说,某个行为要捕获一批对象,它既可以选择捕获 1 个,也可以选择捕获 10 个,两种做法都合法。而它总是选择更多的那个:捕获 10 个,这种行为就可称得上是“贪婪”。
2. 具体来说,针对变量的类型注解语法是在 Python 3.6 版本引入,而 3.5 版本只支持注解函数参数。
3. IDE 是 Integrated Development Environment(集成开发环境)的缩写,在满足代码编辑的基本需求外,IDE 通常还集成了许多方便开发者的功能。常见的 Python IDE 有 PyCharm、VSCode 等。
4. 世界上规模最大的开源项目源码托管网站
5. “自说明”是指代码在命名、结构等方面都非常规范,可读性强。读者无须借助任何其他资料,只通过阅读代码本身就能理解代码意图。