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

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

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

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

名字一致性是指在同一个项目(或者模块、函数)中,对一类事物的称呼不要变来变去。如果你把项目里的“用户头像”叫作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(变量赋值时类型不兼容)错误。

包括我自己在内的很多人在初学编程时有一种很不好的习惯——喜欢把所有变量初始化定义写在一起,放在函数最前面,就像下面这样:

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为例。

代码清单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节中,你可以找到更多与函数复杂度有关的内容,看到更多与拆分函数相关的建议。

前面提到过,定义临时变量可以提高代码的可读性。但有时,把不必要的东西赋值为临时变量,反而会让代码显得啰唆:

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() 是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-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

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

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

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

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

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

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

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

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

如果遵守“先写注释,后写代码”的习惯,我们就能完全避免上面的问题。要养成这个习惯其实很简单:在写出一句有说服力的接口注释前,别写任何函数代码