Python实践提升-变量与注释

Python实践提升-变量与注释
编程是一个通过代码来表达思想的过程。听上去挺神秘,但其实我们早就做过类似的事情——当年在小学课堂上写出第一篇 500 字的作文,同样也是在表达思想,只是二者方式不同,作文用的是词语和句子,而编程用的是代码。
  但代码与作文之间也有相通之处,代码里也有许多“词语”和“句子”。大部分的变量名是词语,而大多数注释本身就是句子。当我们看到一段代码时,最先注意到的,不是代码有几层循环,用了什么模式,而是变量与注释,因为它们是代码里最接近自然语言的东西,最容易被大脑消化、理解。
  正因如此,如果作者在编写变量和注释时含糊不清、语焉不详,其他人将很难搞清楚代码的真实意图。就拿下面这行代码来说:

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

你能告诉我这段代码在做什么吗?当我看到它时,是这么想的:
在 s 上调用 strip(),所以 s 可能是一个字符串?不过为什么要去掉两边的空格呢?
process(…),顾名思义,“处理”了一下 s,但具体是什么处理呢?
处理结果赋值给了 value,value 代表“值”,但“值”又是什么?
开头的注释就更别提了,它说的就是代码本身,对理解代码没有丝毫帮助。
  最后的结论是:“将一个可能是字符串的东西两端的空格去掉,然后处理一下,最后赋值给某个不明物体。”我只能理解到这种程度了。
  但同样是这段代码,如果我稍微调整一下变量的名字,加上一点点注释,就会变得截然不同:

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

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

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

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

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

我们从变量和注释开始,学习如何写出给人留下美好“第一印象”的好代码吧!
  基础知识
  本节将介绍一些与变量和注释相关的基础知识。

变量常见用法
  在 Python 里,定义一个变量特别简单:

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

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

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

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

❶ 交换两个变量

变量解包

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

>>> usernames = ['andy', 'raymond']

注意:左侧变量的个数必须和待展开的列表长度相等,否则会报错

>>> author, reader = usernames
>>> author
'andy'

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

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

除了上面的普通解包外,Python 还支持更灵活的动态解包语法。只要用星号表达式(*variables)作为变量名,它便会贪婪地捕获多个值对象,并将捕获到的内容作为列表赋值给 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
  1. 切片赋值
>>> username, fruits, score = data[0], data[1:-1], data[-1]

两种变量赋值方式完全等价
上面的变量解包操作也可以在任何循环语句里使用:

>>> for username, score in [('andy', 100), ('raymond', 60)]:
...     print(username)
...
andy
raymond

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

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

忽略展开时的第二个变量

>>> 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 版本以后,你可以用类型注解功能来直接注明变量类型。相比编写 Sphinx 格式文档,我其实更推荐使用类型注解,因为它是 Python 的内置功能,而且正在变得越来越流行。

具体来说,针对变量的类型注解语法是在 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 等静态类型检查工具,能提升代码正确性。
IDE 是 integrated development environment(集成开发环境)的缩写,在满足代码编辑的基本需求外,IDE 通常还集成了许多方便开发者的功能。常见的 Python IDE 有 PyCharm、VS Code 等。

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

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

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

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

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

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

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

——Phil Karlton

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

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

遵循 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 大相径庭,就基本不必继续讨论它优雅与否了。

描述性要强

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

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

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

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

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

描述性弱和描述性强的变量名示例
在这里插入图片描述

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

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

而在其他一些场景下,这里“描述性弱”的名字也可能是好名字,比如把一个数学公式的计算结果叫作 value,就非常恰当。
我认为个中诀窍在于:为变量命名要结合代码情境和上下文。比如在上面的代码里,upgrade_to_level3(user) 函数已经通过自己的名称、文档表明了其目的,那在函数内部,我们完全可以把 how_many_points_needed_for_user_level3 直接删减成 level3_points。

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

要匹配类型

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

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

匹配布尔值类型的变量名

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

要尽量短

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

假如一个特别长的名字重复出现,读者不会认为它足够精确,反而会觉得啰唆难读。既然如此,怎么才能在保证描述性的前提下,让名字尽量简短易读呢?
布尔值变量名示例
在这里插入图片描述
匹配 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 类型的名字。

匹配其他类型的变量名

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

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

超短命名

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

数组索引三剑客 i、j、k
某个整数 n
某个字符串 s
某个异常 e
文件对象 fp
我并不反对使用这类短名字,我自己也经常用,因为它们写起来的确很方便。但如果条件允许,建议尽量用更精确的名字替代。比如,在表示用户输入的字符串时,用 input_str 替代 s 会更明确一些。

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

from django.utils.translation import gettext as _

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

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

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

其他技巧

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

在同一段代码内,不要出现多个相似的变量名,比如同时使用 users、users1、users3 这种序列;
可以尝试换词来简化复合变量名,比如用 is_special 来代替 is_not_normal;
如果你苦思冥想都想不出一个合适的名字,请打开 GitHub,到其他人的开源项目里找找灵感吧!

注释基础知识
  注释(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 种。

用注释屏蔽代码

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

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

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

用注释复述代码

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

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

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

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

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

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

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

案例故事
  下面是 Python 程序员小 R 去其他公司面试的故事。

在本书剩下的案例故事里,你还会多次看到“小 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 一边在心里这么想着,一边打开编辑器开始写代码。

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

代码清单 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

# Example usage:
if __name__ == "__main__":
    num_list = [3, 8, 1, 6, 2, 5, 7, 4]
    sorted_list = magic_bubble_sort(num_list)
    print("Sorted list:", sorted_list)

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

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

问题出在哪里

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

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

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

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

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

三分钟后,小 Q 把修改过的代码递了过来,如代码清单 1-2 所示。

代码清单 小 Q 修改后的冒泡排序函数

from typing import List

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]  # ➋
            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

# Example usage:
if __name__ == "__main__":
    num_list = [3, 8, 1, 6, 2, 5, 7, 4]
    sorted_list = magic_bubble_sort(num_list)
    print("Sorted list:", sorted_list)

❶ 注意:此处变量名是 next_ 而非 next,这是因为已经有一个内置函数使用了 next 这个名字。PEP 8 规定在这种情况下,应该给变量名增加 _ 后缀来避免冲突

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

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

“无关痛痒”的改动

和小 R 写的代码相比,前辈小 Q 的新代码主要进行了以下改进。

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

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

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

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

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

下面,我们一起来看看那些跟变量与注释有关的“小点子”吧。

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

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

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

def foo():
    # users 本身是一个 Dict
    users = {'data': ['andy', 'raymond']}
    
    # 将 users 转换为 List 类型
    users = users['data']
    
    # 现在 users 是一个列表,可以继续处理或返回它
    return users

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

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

def foo():
    users = {'data': ['andy', 'raymond']}
    ...
    # 使用一个新名字
    user_list = []
    ...

如果使用 mypy 工具,它在静态检查时就会报出这种“变量类型不一致”的错误。对于上面的代码,mypy 就会输出 error: Incompatible types in assignment(变量赋值时类型不兼容)错误。
变量定义尽量靠近使用
  包括我自己在内的很多人在初学编程时有一种很不好的习惯——喜欢把所有变量初始化定义写在一起,放在函数最前面,就像下面这样:

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

    # 开始初始化 waypoints 数据
    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 图片
    """
    # 开始初始化 waypoints 数据
    waypoints = []
    waypoints.append(...)
    ...

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

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

    # 拼接图片:已省略……

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

定义临时变量提升可读性
  随着业务逻辑变得复杂,我们的代码里也会经常出现一些复杂的表达式,就像下面这样:

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

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

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

#为所有性别为女或者级别大于 3 的活跃用户发放 10 000 个金币
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 作为缓冲。不论是代码的可读性还是可维护性,都因为这个变量而增强了。

直接翻译业务逻辑的代码,大多不是好代码。优秀的程序设计需要在理解原需求的基础上,恰到好处地抽象,只有这样才能同时满足可读性和可扩展性方面的需求。抽象有许多种方式,比如定义新函数、定义新类型,“定义一个临时变量”是诸多方式里不太起眼的一个,但用得恰当的话效果也很巧妙。
同一作用域内不要有太多变量
  通常来说,函数越长,用到的变量也会越多。但是人脑的记忆力是很有限的。研究表明,人类的短期记忆只能同时记住不超过 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_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

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

需要说明的一点是,大多数情况下,只是执行上面这样的操作是远远不够的。函数内变量的数量太多,通常意味着函数过于复杂,承担了太多职责。只有把复杂函数拆分为多个小函数,代码的整体复杂度才可能实现根本性的降低。

你可以找到更多与函数复杂度有关的内容,看到更多与拆分函数相关的建议。
能不定义变量就别定义
  前面提到过,定义临时变量可以提高代码的可读性。但有时,把不必要的东西赋值为临时变量,反而会让代码显得啰唆:

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)
    }

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

不要使用 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() 实在是太隐晦了,直接写出变量名显然更好。

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

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

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

代码清单 没有任何空行的冒泡排序(所有文字类注释已删除)

from typing import List

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  # Move the stop position one step left

    return numbers

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

代码清单 增加了空行的冒泡排序

from typing import List

def magic_bubble_sort(numbers: List[int]):
    """
    Sorts a list of integers such that even numbers precede odd numbers, and within each group
    (even or odd), numbers are sorted in ascending order.
    
    Args:
    - numbers (List[int]): The list of integers to be sorted
    
    Returns:
    - List[int]: The sorted list of integers
    """
    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

            # Conditions for swapping:
            # 1. Previous is even and latter is odd
            if previous_is_even and not latter_is_even:
                should_swap = True
            # 2. Both previous and latter are even or odd, but previous is greater than latter
            elif previous_is_even == latter_is_even and previous > latter:
                should_swap = True

            # Perform the swap if necessary
            if should_swap:
                numbers[i], numbers[i + 1] = numbers[i + 1], numbers[i]

        stop_position -= 1  # Move the stop position one step left
    
    return numbers

# Example usage:
if __name__ == "__main__":
    num_list = [3, 8, 1, 6, 2, 5, 7, 4]
    sorted_list = magic_bubble_sort(num_list)
    print("Sorted list:", sorted_list)

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

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

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

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

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

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

我常常在审查代码时发现,一些关键函数的 docstring 位置一片空白,而那里本该备注详尽的接口注释。每当遇到这种情况,我都会不厌其烦地请代码提交者补充和完善接口注释。

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

如果遵守“先写注释,后写代码”的习惯,我们就能完全避免上面的问题。要养成这个习惯其实很简单:在写出一句有说服力的接口注释前,别写任何函数代码。
  总结
  在一段代码里,变量和注释是最接近自然语言的东西。因此,好的变量名、简明扼要的注释,都可以显著提升代码的质量。在给变量起名时,请尽量使用描述性强的名字,但也得注意别过了头。

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

以下是本章要点知识总结。

(1) 变量和注释决定“第一印象”

变量和注释是代码里最接近自然语言的东西,它们的可读性非常重要
即使是实现同一个算法,变量和注释不一样,给人的感觉也会截然不同
  (2) 基础知识

Python 的变量赋值语法非常灵活,可以使用 *variables 星号表达式灵活赋值
编写注释的两个要点:不要用来屏蔽代码,而是用来解释“为什么”
接口注释是为使用者而写,因此应该简明扼要地描述函数职责,而不必包含太多内部细节
可以用 Sphinx 格式文档或类型注解给变量标明类型
  (3) 变量名字很重要

给变量起名要遵循 PEP 8 原则,代码的其他部分也同样如此
尽量给变量起描述性强的名字,但评价描述性也需要结合场景
在保证描述性的前提下,变量名要尽量短
变量名要匹配它所表达的类型
可以使用一两个字母的超短名字,但注意不要过度使用
  (4) 代码组织技巧

按照代码的职责来组织代码:让变量定义靠近使用
适当定义临时变量可以提升代码的可读性
不必要的变量会让代码显得冗长、啰唆
同一个作用域内不要有太多变量,解决办法:提炼数据类、拆分函数
空行也是一种特殊的“注释”,适当的空行可以让代码更易读
  (5) 代码可维护性技巧

保持变量在两个方面的一致性:名字一致性与类型一致性
显式优于隐式:不要使用 locals() 批量获取变量
把接口注释当成一种函数设计工具:先写注释,后写代码

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值