第 1 章 变量与注释
编程,是一个通过代码来表达思想的过程。听上去挺神秘,但这事其实我们早就做过。当年我们在小学课堂上,写出第一篇 500 字的作文时,同样也是在表达思想。二者只是方式不同,作文用的是词语和句子,而编程用的则是代码。
但代码与作文之间也有相通之处,因为在代码里,也有着许多“词语”和“句子”。大部分的变量名都是词语,而大多数注释本身就是一条条句子。当我们看到一段代码时,最先注意到的,不是代码有几层循环,用了什么模式,而是变量与注释 ,因为它们是代码里最接近自然语言的东西,最容易被大脑消化、理解。
正因如此,如果作者在编写变量和注释时含糊不清、语焉不详,其他人将很难搞清楚代码的真实意图。就拿下面这行代码来说:
# 去掉 s 两边的空格,再处理
value = process(s.strip())
你能告诉我这段代码在做什么吗?当我看到它时,大脑大概是这么运作的:
-
在
s
上调用strip()
,所以s
可能是一个字符串?不过为什么要去掉两边的空格呢? -
process(…)
顾名思义“处理”了一下这个s
,但具体是什么处理呢? -
处理结果赋值给了
value
,value
代表“值”,但“值”又是什么? -
那行注释就更别提了,它说的就是代码本身,对理解代码没有一丁点儿帮助。
最后的结论是:“把一个可能是字符串的东西去掉两端的空格,然后处理一下,最后赋值给某个不明物体。”我能做到的最好理解就是这样了。
但同样是这段代码,如果我稍微调整一下变量的名字,加上一点点注释,就会变得截然不同:
# 用户输入可能会有空格,使用 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. )
这句话里虽然一半严肃、一半玩笑,但“命名”有时真的会难到让人抓狂。我常常呆坐在显示器前,抓耳挠腮好几分钟,就是没法给变量想到一个合适的名字。
要给变量起个好名字,主要靠的是经验,有时还需再加上一丁点儿灵感,但更重要的是得遵守一些基本原则。下面就是我总结的几条变量命名基本原则。
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())
所以,在可接受的长度范围内,变量名把它所指向的内容描述得越精确越好。下面的表中是一些具体的例子。
弱名字 | 强名字 | 说明 |
---|---|---|
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)”。因此,给布尔值变量起名有一个原则:一定要让读到变量的人觉得它只会有“肯定”和“否定”两种可能。举例来说,is
、has
这些非黑即白的词就很适合用来修饰这类名字。
下面的表内有一些更详细的例子:
变量名 | 含义 | 说明 |
---|---|---|
|
是否是超级用户 |
是 / 不是 |
|
有没有错误 |
有 / 没有 |
|
是否允许空值 |
允许 / 不允许 |
|
是否可以为 null |
可以 / 不可以 |
匹配 int/float 类型的名字
当人们看到和数字有关的名字时,自然就会认定它们是 int
或 float
类型。这些名字可被简单分为以下几种常见类型:
-
释义为数字的所有单词,比如:
port(端口号)
、age(年龄)
、radius(半径)
等 -
使用
_id
结尾的单词,比如:user_id
、host_id
-
使用
length/count
开头或者结尾的单词,比如:length_of_username
、max_length
、users_count
最好别拿一个名词的复数形式来作为 int 类型的变量名,比如
apples 、trips 等。因为这类名字,会和那些装着 Apple 和 Trip 的普通容器对象(List[Apple]、List[Trip])相混淆。为了避免混淆,我建议用 number_of_apples 或 trips_count 这种复合词来作为 int 类型的名字。
|
5. 超短命名
在众多变量名里,有一类名字非常特别,那就是只有一两个字母的短名字。这些短名字一般可分为两类,第一类是那些大家约定俗成的短名字,比如:
-
数组索引三剑客
i
、j
、k
-
某个整数
n
-
某个字符串
s
-
某个异常
e
-
文件对象
fp
我并不反对使用这类短名字,自己也经常用,因为它们写起来的确很方便。但如果条件允许,我还是建议尽量用更精确的名字替代它们。比如,在表示用户输入的字符串时,用 input_str
替代 s
总是会更明确一些。
另一类短名字,则是对一些其他常用名的缩写。比如,在使用 Django 框架做国际化内容翻译时,常常会用到 gettext
方法。为了方便,我们常会把 gettext
缩写成 _
来使用:
from django.utils.translation import gettext as _
print(_('待翻译文字'))
如果你在项目中发现有一些长名字会重复出现,那你也可以效仿上面的方式,为这些长名字设置一些短名字作为别名。这样可以让代码变得更紧凑,更好读。但同一个项目内的超短缩写不宜太多,否则效果就会适得其反。
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 有些紧张地接过电脑,发现屏幕上是一道算法题。
请用 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 一边在心里这么想着,一边打开编辑器开始写代码。
五分钟后,他把笔记本递给面试官,说道:“写完了!”
下面就是他写的代码。
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 把把修改过的代码递了过来:
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 的新代码主要有这些变化:
-
变量名变成了可读的、有意义的名字,比如在旧代码里,“停止位”是无意义的
j
,新代码里变成了stop_position
。 -
增加了有意义的临时变量,比如
current / next_
代表前一个与后一个元素、{}_is_even
代表元素是否为偶数、should_swap
代表是否应该交换元素。 -
多了一点恰到好处的指引性注释,比如说明应该交换元素顺序的详细条件。
这些变化让整段代码变得更易读,也让整个算法变得更好理解。所以,哪怕是一段不到 10 行代码的简单函数,对变量和注释的不同处理方式,也会让代码发生质的变化。
1.3 编程建议
在这一节,我将与你分享一些与本章主题有关的编程建议。本书的“编程建议”中没啥高谈阔论的大道理,多是些专注细节的小点子。比如:定义临时变量有什么好处;为什么你应该先写注释,再写代码,等等。希望这些“小点子”能助你写出更棒的代码。
下面,来看看那些和变量与注释有关的“小点子”吧。
1.3.1 保持变量的一致性
在使用变量时,你需要保证变量在两个方面的一致性:名字一致性与类型一致性。
名字一致性是指在同一个项目(或者模块、函数)中,对一类事物的称呼不要变来变去。如果你把项目里的“用户头像”叫做 user_avatar_url
,那么在其他地方就别把它改成 user_profile_url
。因为这会让读代码的人犯迷糊:“user_avatar_url
和 user_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
函数里,代码的职责主要分为三块:
-
初始化 waypoints 数据
-
处理 markers 数据
-
计算 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 个名字。如果变量过多,代码肯定就会变得很难读。
拿这段代码为例:
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_count
、failed_count
等。
如果要直接减少函数里的变量数量,最直接的方式是给这些变量归类,建立新的模型。比如,我们可以将代码里的 succeeded_count
、failed_count
建模为 ImportedSummary
类,用 ImportedSummary.succeeded_count
来替代现有变量。对 {duplicated|banned|normal}_users
也可以执行同样的操作。
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
})
1.3.7 空行也是一种“注释”
有时,代码里的注释不只是那些常规的描述性语句。没有一个字符的空行,其实也能被算作是一种特殊的“注释”。
在写代码时,我们可以适当的在代码中插入空行,把代码按不同的逻辑块分隔开来,这样能有效提高代码可读性。
举个例子,拿本章案例故事里的代码来说,假如删掉所有空行,代码会变成下面这样,请你试着读读看:
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
怎么样?是不是感觉代码特别局促,连喘口气的机会都找不到?这就是缺少了空行所导致。只要在代码里加上一丁点空行(不多,就两行),函数的可读性马上会得到可观的提升:
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 的面试故事看来,即使是两段算法一致,功能也完全一样的代码,也会因为变量和注释的区别,给其他人截然不同的感觉。因此,要想让你的代码给人留下漂亮的第一印象,请记得在变量和注释上多下功夫。
要点
-
变量和注释决定“第一印象”:
-
变量和注释是代码里最接近自然语言的东西,它们的可读性非常重要
-
即使是实现同一个算法,变量和注释不一样,代码给人的感觉会截然不同
-
-
基础知识:
-
Python 的变量赋值语法非常灵活,可以使用
*variables
星号表达式灵活赋值 -
编写注释的两个要点:“不要用来屏蔽代码”以及“解释为什么”
-
接口注释是为使用者而写,因此接口注释应该简明扼要的描述函数职责,而不必包含太多内部细节
-
你可以用 Sphinx 格式文档或类型注解给变量标明类型
-
-
变量名字很重要:
-
给变量起名要遵循 PEP 8 指南,代码的其他部分也同样如此
-
尽量给变量起描述性强的名字,但评价描述性也需要结合场景
-
在保证描述性的前提下,变量名要尽量短
-
变量名要匹配它所表达的类型
-
你可以使用一两个字母的超短名字,但注意不要过度
-
-
代码组织技巧:
-
按照代码的职责来组织代码:让变量定义靠近引用
-
适当定义临时变量可以提升代码可读性
-
但不必要的变量反而会让代码显得冗长、啰嗦
-
同一个作用域内不要有太多变量,解决办法:提炼数据类、拆分函数
-
空行也是一种特殊的“注释”,适当的空行可以让代码更好读
-
-
代码可维护性技巧:
-
保持变量在两个方面的一致性:名字一致性与类型一致性
-
显式优于隐式:不要使用 locals() 批量获取变量
-
把接口注释当成一种函数设计工具,先写注释,后写代码
-