Python工匠:案例、技巧与工程实践
上QQ阅读APP看书,第一时间看更新

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

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

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

因为Python是一门动态类型的语言,所以我们无须预先声明变量类型,直接对变量赋值即可。

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

>>> author, reader = 'piglei', 'raymond'
>>> author, reader = reader, author ➊
>>> author
'raymond'

❶ 交换两个变量

01.变量解包

变量解包(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

02.单下划线变量名_

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

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

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

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

>>> 'foo'.upper()
'FOO'
>>> print(_) ➊
FOO

❶ 此时的_变量保存着上一个.upper() 表达式的结果

1“贪婪”一词在计算机领域具有特殊含义。比方说,某个行为要捕获一批对象,它既可以选择捕获1个,也可以选择捕获10个,两种做法都合法,但它总是选择结果更多的那种:捕获10个,这种行为就称得上是“贪婪”。

前面说过,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的内置功能,而且正在变得越来越流行。

2具体来说,针对变量的类型注解语法是在Python 3.6版本引入的,而3.5版本只支持注解函数参数。

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

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

from typing import List
def remove_invalid(items: List[int]): ➊
    """剔除 items 里面无效的元素"""
    ... ...

❶ List表示参数为列表类型,[int] 表示里面的成员是整型

“类型注解”只是一种有关类型的注释,不提供任何校验功能。要校验类型正确性,需要使用其他静态类型检查工具(如mypy等)。

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

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

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

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

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

3IDE是integrated development environment(集成开发环境)的缩写,在满足代码编辑的基本需求外,IDE通常还集成了许多方便开发者的功能。常见的Python IDE有PyCharm、VS Code等。

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

在10.1.1节中,你会看到更详细的“类型注解”功能说明,以及更多启用了类型注解的代码。

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

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

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

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

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

计算机科学领域只有两件难事:缓存失效和命名。

——Phil Karlton

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

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

01.遵循PEP 8原则

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

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

·对于普通变量,使用蛇形命名法,比如max_value;

·对于常量,采用全大写字母,使用下划线连接,比如MAX_VALUE;

·如果变量标记为“仅内部使用”,为其增加下划线前缀,比如_local_var;

·当名字与Python关键字冲突时,在变量末尾追加下划线,比如class_。

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

PEP 8是Python编码风格的事实标准。“代码符合PEP 8规范”应该作为对Python程序员的基本要求之一。假如一份代码的风格与PEP 8大相径庭,就基本不必继续讨论它优雅与否了。

02.描述性要强

写作过程中的一项重要工作,就是为句子斟酌恰当的词语。不同词语的描述性强弱不同,比如“冬天的梅花”就比“花”的描述性更强。而变量名和普通词语一样,同样有描述性强弱之分,假如代码大量使用描述性弱的变量名,读者就很难理解代码的含义。

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

# 描述性弱的名字:看不懂在做什么
value = process(s.strip())
# 描述性强的名字:尝试从用户输入里解析出一个用户名
username = extract_username(input_string.strip())

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

表1-1 描述性弱和描述性强的变量名示例

看到表1-1中的示例,你可能会想:“也就是说左边的名字都不好,永远别用它们?”

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

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

03.要尽量短

刚刚说到,变量名的描述性要尽量强,但描述性越强,通常名字也就越长(不信再看看表1-1,第二列的名字就比第一列长)。假如不加思考地实践“描述性原则”,那你的代码里可能会充斥着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所需要的积分”,而不是其他含义。

要匹配类型

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

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

04.匹配布尔值类型的变量名

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

表1-2中给出了一些更详细的例子。

表1-2 布尔值变量名示例

·匹配int/float类型的变量名

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

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

o使用以_id结尾的单词,比如user_id、host_id;

o使用以length/count开头或者结尾的单词,比如length_of_username、max_length、users_count。

最好别拿一个名词的复数形式来作为int类型的变量名,比如apples、trips等,因为这类名字容易与那些装着Apple和Trip的普通容器对象(List[Apple]、List[Trip])混淆,建议用number_of_apples或trips_count这类复合词来作为int类型的名字。

·匹配其他类型的变量名

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

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

05.超短命名

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

·数组索引三剑客i、j、k

·某个整数n

·某个字符串s

·某个异常e

·文件对象fp

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

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

from django.utils.translation import gettext as _
print(_('待翻译文字'))

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

4相比之下,类型注解在开源领域的接受度更高一些,许多流行的Python开源项目(比如Web开发框架Flask和Tornado等),早早地给代码加上了类型注解。

其他技巧

除了上面这些规则外,下面再分享几个给变量命名的小技巧:

·在同一段代码内,不要出现多个相似的变量名,比如同时使用users、users1、users3这种序列;

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

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

5世界上规模最大的开源项目源码托管网站。

注释(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种。

01.用注释屏蔽代码

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

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

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

02.用注释复述代码

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

# 调用 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)

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

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

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

03.弄错接口注释的受众

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

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模块、为何有内存问题的细节说明,全都可以丢进函数内部的代码注释里。

6“自说明”是指代码在命名、结构等方面都非常规范,可读性强。读者无须借助任何其他资料,只通过阅读代码本身就能理解代码意图。