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节会详细讲解),它在静态检查时就会报出这种“变量类型不一致”的错误。对于上面的代码,mypy就会输出error: Incompatible types in assignment(变量赋值时类型不兼容)错误。
1.3.2 变量定义尽量靠近使用
包括我自己在内的很多人在初学编程时有一种很不好的习惯——喜欢把所有变量初始化定义写在一起,放在函数最前面,就像下面这样:
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 += ... # 拼接图片:已省略……
通过把变量定义移动到每段“各司其职”的代码头部,大大缩短了变量从初始化到被使用的“距离”。当读者阅读代码时,可以更容易理解代码的逻辑,而不是来回翻阅代码,心想:“这个变量是什么时候定义的?是干什么用的?”
1.3.3 定义临时变量提升可读性
随着业务逻辑变得复杂,我们的代码里也会经常出现一些复杂的表达式,就像下面这样:
# 为所有性别为女或者级别大于 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作为缓冲。不论是代码的可读性还是可维护性,都因为这个变量而增强了。
直接翻译业务逻辑的代码,大多不是好代码。优秀的程序设计需要在理解原需求的基础上,恰到好处地抽象,只有这样才能同时满足可读性和可扩展性方面的需求。抽象有许多种方式,比如定义新函数、定义新类型,“定义一个临时变量”是诸多方式里不太起眼的一个,但用得恰当的话效果也很巧妙。
1.3.4 同一作用域内不要有太多变量
通常来说,函数越长,用到的变量也会越多。但是人脑的记忆力是很有限的。研究表明,人类的短期记忆只能同时记住不超过10个名字。变量过多,代码肯定就会变得难读,以代码清单1-3为例。
代码清单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也可以执行同样的操作。相关操作如代码清单1-4所示。
代码清单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这样,请你试着读读看。
代码清单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所示。
代码清单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位置一片空白,而那里本该备注详尽的接口注释。每当遇到这种情况,我都会不厌其烦地请代码提交者补充和完善接口注释。
为什么大家总会漏掉注释?我的一个猜测是:程序员在编写函数时,总是跳过接口注释直接开始写代码。而当写完代码,实现函数的所有功能后,他就对这个函数失去了兴趣。这时,他最不愿意做的事,就是回过头去补写函数的接口注释,即便写了,也只是草草对付了事。
如果遵守“先写注释,后写代码”的习惯,我们就能完全避免上面的问题。要养成这个习惯其实很简单:在写出一句有说服力的接口注释前,别写任何函数代码。