1.1 基础知识
本节将介绍一些与变量和注释相关的基础知识。
1.1.1 变量常见用法
在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个,这种行为就称得上是“贪婪”。
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的内置功能,而且正在变得越来越流行。
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节中,你会看到更详细的“类型注解”功能说明,以及更多启用了类型注解的代码。
1.1.3 变量命名原则
如果要从变量着手来破坏代码质量,办法多到数也数不清,比如定义了变量但是不用,或者定义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世界上规模最大的开源项目源码托管网站。
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种。
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“自说明”是指代码在命名、结构等方面都非常规范,可读性强。读者无须借助任何其他资料,只通过阅读代码本身就能理解代码意图。