# 《整洁的Python——优雅的编码》

book_cover

本书是一本译著,英文原著的基本信息如下:

  • 原书名(标题 - 子标题): Clean Python - Elegant Coding in Python
  • 原书作者: [美国] Sunil Kapil
  • 原书出版社: Apress (opens new window)
  • ISBN: 978-1-4842-4878-2
  • 出版日期: 2019-05-22
  • 页数: 267

资源:

简体中文翻译:

  • 2020: 龙德志、杨常春、罗力风、兰康译、王薪、沈静远、阳松键、陈时鲲、袁波。
  • 2021: 胡毅、石帆、雷泽霖、卓洪、袁波。
  • 2022:袁波。

若您发现本书的任何错误,或是有任何好的建议,欢迎和我们联系(Email: yuan.bob@outlook.com),谢谢!

# 目录

# 内容简介

# 关于本书

本书介绍了在 Python 中编写正确代码的方法,提供了编写简洁、无错误而且优雅的 Python 代码所需要的知识和技巧。

您通过本书学习编码的旅程,将始于认识编码风格和文档对于易读代码的重要性,利用 Python 自带的数据结构和字典来提高代码的可维护性,以及使用模块(modules)和元类(meta-classes)来有效地组织代码。然后,您将深入了解 Python 语言的一些新特性,学习如何有效地使用它们。接下来,您将学习一些重要概念,如异步编程、Python 数据类型、类型提示(type hinting)和路径处理(path handling)。您将学到在 Python 代码中进行调试、执行单元测试与集成测试的一些建议,以便确保代码能够用于生产环境。学习旅程的最后一段将是版本管理、管理实时代码(managing live code)和智能代码补全(intelligent code completion)等必备开发工具。

阅读和使用本书后,您将能够掌握编写整洁的 Python 代码,并且可以将这些编码原则成功地应用于您的 Python 项目开发中。

您将学到:

  • 在 Python 代码中使用正确的表达式和语句
  • 创建和评估 Python 字典
  • 使用 Python 中的高级数据结构
  • 编写更好的模块、类、函数和元类
  • 编写异步 Python
  • Python 中的新特性

本书的目标读者:

这本书适用于具有基本 Python 编程知识的读者,他们希望通过学习正确的 Python 编码方法来提高 Python 编程技能。

# 关于作者

author_sunil

苏尼尔·卡皮尔(Sunil Kapil)从事软件行业已有十年,他用 Python 和其他几种语言编程。苏尼尔曾担任过软件工程师,主要从事 Web 和移动端后台服务开发。他开发、部署和维护了大大小小的生产项目,它们被数百万用户使用着并深受欢迎。他为世界各地的知名软件公司完成了这些项目,在不同的专业开发环境中管理过从小到大的团队。他是开放源码的热情倡导者,不断地为 Zulip Chat 和 Black 等项目做出贡献。他还与非营利组织合作,以志愿者的身份上为他们的软件项目做出贡献。

Sunil 是各种聚会和会议的常客,经常谈论 Python。您可以访问他的网站,他在网站上介绍软件工程、开发工具和技术。您可以通过电子邮件联系他,或者在社交媒体上关注他。

# 关于技术审查员

Sonal Raj

萨纳尔·拉杰(Sonal Raj,@_sonalraj) 是一位作家、工程师、导师,一位超过 10 年的狂热 Python 支持者。他是高盛的校友,曾任印度科学研究所研究员。他是构建交易算法和低延迟系统方面的专家,是金融科技行业中不可或缺的一部分。他是开源开发者,也是社区成员。 萨纳尔拥有信息技术和工商管理硕士学位。他的研究领域包括分布式系统、图形数据库和教育科技。他是伦敦工程技术研究所(Institution of Engineering and Technology, IET)的积极分子,也是印度技术教育协会(Indian Society for Technical Education)的终身会员。他是《高性能 Neo4j》一书的作者,该书介绍了图形数据库 Neo4j 的功能和使用。他也是《面试要点》系列书籍的作者,该系列丛书侧重于技术面试的方法学。萨纳尔还是人民纪事媒体(People Chronicles Media)的编辑、开放源码软件杂志 (Journal of Open Source Software, JOSS) 的评论员和尤根基金会(Yugen Foundation)的创始人。

# 致谢

首先,我要感谢 Apress 的尼基尔(Nikhil)。尼基尔于 2018 年 10 月联系我,并说服我与 Apress 媒体有限责任公司一起写一本书。然后,我要感谢《新闻》杂志协调编辑迪维亚·莫迪(Divya Modi)在撰写章节时给予的大力支持,并感谢她在百忙之中的耐心。此外,非常感谢 Apress 的开发编辑瑞塔·费尔南多(Rita Fernando),他在审阅过程中提供了有价值的建议,使这本书对 Python 开发人员更有价值。接下来,我要感谢 Sonal Raj 挑剔地审视每一章。你发现了很多我永远不会发现的问题。

当然,我想对 Apress 的整个制作团队对我的支持表示衷心的感谢。

最后,我还要感谢我亲爱的家庭,尤其是他们的理解。一个图书的写作项目需要大量的时间。感谢我的母亲,利拉·卡皮尔(Leela Kapil)和父亲哈里什·钱德拉·卡皮尔(Harish Chandra Kapil),感谢所有的鼓励和支持。

我亲爱的妻子尼图(Neetu):我深深感谢你在写这本书时给予的鼓励和支持;它使一切与众不同。你真是太好了!

# 第1章 Pythonic 思维

Python 与其它编程语言不同,它是一种简单但有深度的语言。因为它简单,所以谨慎地编写代码显得特别重要,尤其是在大型项目中,代码容易变得复杂和臃肿。Python 有一个被称为 Python 之禅的哲学(Zen of Python (opens new window)),它强调简单而不是复杂。

在本章中,您将学习一些常见实践,这些实践可以让 Python 代码更具可读性,还更简单。我将介绍一些众所周知的实践,以及一些可能不那么知名的做法。在开始下一个项目或在当前项目时,请充分了解这些 Python 实践,从而改进您的代码。

注意: 在 Python 世界里,遵循 Python 之禅(Zen of Python)的哲学会让您的代码更“地道”(Pythonic)。Python 官方文档中建议了许多好的做法,可以使代码更简洁、易读。阅读 PEP8 指南将会帮助您了解为什么建议采用某些做法。

# 编写 Pythonic 代码

Python 有一些被称为 PEP8 的官方文档,这些文档定义了编写 Python 代码的最佳实践。此编码风格指南是随着时间推移而不断演变的。您可以在 https://www.python.org/dev/peps/pep-0008/ (opens new window) 中查阅。 在本章中,您将重点关注 PEP8 中定义的一些常见实践,并了解为什么遵循这些规则会对开发者有益。

# 命名

作为一名开发人员,我使用过不同的编程语言,例如 Java、NodeJS、Perl 和 Golang。所有这些语言都有变量、函数、类等的命名约定。Python 也建议使用命名约定。我将在本节中讨论编写 Python 代码时应当遵循的一些命名约定。

# 变量和函数

变量和函数应该用小写字母命名,用下划线分隔单词,从而提高可读性。例如示例1-1。

示例1-1. 变量名

names = "Python"                   # 变量名
job_title = "Software Engineer"    # 有下划线的变量名
populated_countries_list = []      # 有下划线的变量名

为了让代码中的方法名不产生混淆或冲突,还可以考虑使用一个下划线(_)或两个下划线(__)的前缀。例如示例1-2。

示例1-2. 不会混淆的名称

_books = {}                        # 私有的变量名
__dict = []                        # 防止与 Python 自有库里的命名混淆

使用一个下划线 (_) 作为类的内部变量的前缀,即不希望外部类访问该变量。注意,这只是一种约定俗成的惯例而已,Python 并不会使具有单个下划线前缀的变量成为私有变量。

Python 也有关于函数命名的约定,如示例1-3 所示。

示例1-3. 普通的函数名

# 有单个下划线的函数名
def get_data():
    ---
    ---
def calculate_tax_data():
    ---

相同的规则也适用于私有方法,还包括为了避免与 Python 自有的函数发生命名冲突的方法。参见示例1-4。

示例1-4. 私有和不会混淆的函数名

# 有单个下划线前缀的私有方法
def _get_data():
    ---
    ---
# 有双下划线前缀的方法,避免与 Python 自有库里的命名发生混淆
def __path():
    ---
    ---

除了遵循这些命名规则外,使用特定名称也很重要。 让我们来看一个函数,该函数在接受用户 id 后返回一个 user 对象。如示例1-5。

示例1-5. 函数名

# 错误的方式
def get_user_info(id):
    db = get_db_connection()
    user = execute_query_for_user(id)
    return user

# 正确的方式
def get_user_by(user_id):
    db = get_db_connection()
    user = execute_user_query(user_id)
    return user

在这里,第二个函数 get_user_by() 的命名,可以确保您使用相同的词汇(user_id)来传递一个变量,并且为函数提供了正确的上下文。而第一个函数 get_user_info() 是不明确的,因为参数 id 可能表示任何含义。到底是用户表索引 id 还是用户付款 id ,或是任何其他 id 呢?此类代码可能会给使用 API 的开发人员造成困惑。为了解决这个问题,我在第二个函数中改动了2处:函数名称和参数名称,这使得代码更具可读性。读第二个函数时,我们可以立即知道函数的用途和函数的预期参数是什么。 作为开发人员,您有责任在命名变量和函数时仔细斟酌,从而让您的代码对于其他开发人员来说更易读。

#

类的名称应该和其他大多数语言一样是驼峰式的,示例1-6展示了一个简单的例子。 示例1-6. 类的命名

class UserInformation:
    def get_user(id):
        db = get_db_connection()
        user = execute_query_for_user(id)
        return user
# 常量

您应该使用大写字母定义常量,1-7展示了一个例子。

示例1-7. 常量的命名

TOTAL = 56
TIMEOUT = 6
MAX_OVERFLOW = 7
# 函数和方法的参数

函数和方法的参数应当遵循和变量与函数名相同的规则。与不传递 self 关键字参数的函数相比,类方法用 self 作为第一个参数,如示例1-8。

示例1-8. 函数和类方法参数

def calculate_tax(amount, yearly_tax):
    ---

class Player:
    def get_total_score(self, player_name):
        ---

# 代码中的表达式和语句

有时候您可能努力编写了一个“聪明”的方法,节省一些代码行,或是给同事留下了深刻的印象。但是,编写聪明的代码总会在可读性和简洁性上付出一些代价。让我们来看看示例1-9中对嵌套字典排序的代码。

示例1-9. 给嵌套的字典排序

users = [{"first_name":"Helen", "age":39},
         {"first_name":"Buck", "age":10},
         {"first_name":"anni","age":9}
        ]
users = sorted(users, key=lambda user: user["first_name"].lower())

这段代码有什么问题呢?

该代码在一行中使用 lambda 函数通过 first_name 来给这个嵌套字典排序,它看起来像是一个聪明的字典排序方法,没有用循环来实现。

然而,要一眼就理解这行代码却不容易,特别是对于新手而言,因为 lambda 函数并不是一个容易掌握的概念,它的语法有点古怪。当然,您在这里用 lambda 函数“聪明”地给字典排序,节省了多行代码,但它并不能保证代码的正确性和易读性。此代码还有未考虑到的情况,例如键缺失或是字典本身不正确。

让我们用一个函数来重写代码,让它变得正确且易读。该函数将会检查所有意外值,并且编写起来也会更加简单。例如1-10。

示例1-10. 用函数来给字典排序

users = [{"first_name":"Helen", "age":39}{"first_name":"Buck", "age":10},
         {"name":"anni", "age":9}
        ]

def get_user_name(users):
    """Get name of the user in lower case"""
    return users["first_name"].lower()

def get_sorted_dictionary(users):
    """Sort the nested dictionary"""
    if not isinstance(users, dict):
        raise ValueError("Not a correct dictionary")
    if not len(users):
        raise ValueError("Empty dictionary")

    users_by_name = sorted(users, key=get_user_name)
    return users_by_name

如您所见,这段代码检查了所有可能的意外值,并且该代码也变得比以前“聪明”的一行代码更加易读。“聪明”的一行代码确实节省了不少代码行数,但是与此同时也带来了更多的复杂性。这里想表达的重点并不是说一行代码是不好的,而是说如果一行代码导致阅读代码更加困难,请避免使用它。

在编程时,您需要谨慎地做出选择。有时一行代码可以使您的代码变得更加易读,但有时则相反。

让我们再来看一个例子:读取 CSV 文件并计算 CSV 文件处理的行数。示例1-11中的代码说明了为什么代码可读性很重要,以及命名是如何在代码可读性上面发挥重要作用的。

当生产环境的代码出错时,将代码分解为辅助函数有助于提升复杂代码的可读性,更容易调试。

示例1-11. 读取一个 CSV 文件

import csv

with open("employee.csv", mode="r") as csv_file:
    csv_reader = csv.DictReader(csv_file)
    line_count = 0
    for row in csv_reader:
        if line_count == 0:
            print(f'Column names are {", ".join(row)}')
            line_count += 1
            print(f'\t{row["name"]} salary: {row["salary"]}'
                  f'and was born in {row["birthday month"]}.')
        line_count += 1
    print(f'Processed {line_count} lines.')

这里的代码在with语句中执行了多项操作。为了使代码更易读,可以将处理 salary 的代码抽取成为另一个函数,从而减少出错的可能性。当很多步骤都在几行代码中执行时,调试这类代码是很困难的,所以在定义函数时要确保有明确的目标和边界。下面,我们在示例1-12中将它重构。

示例1-12. 读取一个 CSV 文件,更易读的代码

import csv

with open('employee.txt', mode='r') as csv_file:
    csv_reader = csv.DictReader(csv_file)
    line_count = 0
    process_salary(csv_reader)

def process_salary(csv_reader):
    """Process salary of user from csv file."""
    for row in csv_reader:
        if line_count == 0:
            print(f'Column names are {", ".join(row)}')
            line_count += 1
        print(f'\t{row["name"]} salary: {row["salary"]}')
        line_count += 1
    print(f'Completed {line_count} lines.')

这里创建了一个辅助函数,而不是在 with 语句中写下全部内容。这使读者清楚地了解 process_salary 函数的实际作用。如果想处理一个特定的异常或者想从 CSV 文件中读取更多的数据,可以遵循单一职责原则(single responsibility principle),进一步分解这个函数。

# 用 Pythonic 思维来编码

在编写代码时,PEP8 有一些推荐和建议可以遵循,这些建议将使您的 Python 代码更清晰、更易读。让我们来看看其中的一些实例。

# 用 join 替代字符串拼接

若要考虑代码的性能,您可以用 "".join() 方法而不是内置的字符串拼接,如 a += ba = a + b。方法 "".join() 可以保证在各种 Python 实现之间更快速地实现字符串拼接。

使用该方法的原因是 Python 只为拼接的字符串分配一次内存;而在用“+”拼接字符串时,因为 Python 的字符串是不可变的,Python 必须为每次拼接分配内存。参见示例1-13。

示例1-13. 使用 join 方法

first_name = "Json"
last_name = "smart"

# 不推荐的方式,使用字符串拼接
full_name = first_name + " " + last_name

# 更有效的方式,增加了可读性
" ".join([first_name, last_name])
# 和 None 进行比较时,用 is 和 is not

在编写如下代码时,请记住一点:总是使用 is 或 not 来与 None 比较:

if val:                # 可用于当 val 不是 None 的判定

请务必记住,您正在判断 val 是否为 None,而不是其它容器类型,如 dictset。让我们进一步来理解这种代码在什么地方会给您带来麻烦。

在下面的第一行代码中,val 是一个空字典,然而,val 被判定是假,它可能不是您期望的行为,所以在编写这种代码时要小心。

别这样做:

val = {}
if val:                # 这里将会是假

相反的,应尽可能明确地编写代码,以减少出错的可能性。

要这样做:

if val is not None:    # 只有 val 是 None 时才是假
# 推荐使用 is not 而不是 not … is

使用 is not 和使用 not ... is 没有区别。然而,与 not ... is 相比,is not 语法的可读性更强。

别这样做:

if not val is None:

要这样做:

if val is not None:
# 在赋值给标识符时考虑使用函数而非 lambda

当您将 lambda 表达式赋值给某个标识符时,请考虑使用函数。lambda 是 Python 中执行单行操作的关键字;但是,使用 lambda 编写函数可能不如使用def 编写函数那么好。

别这样做:

square = lambda x: x * x

要这样做:

def square(val):
    return val * val

与通常的 lambda 相比,def square(val) 函数对象在字符串表示和跟踪调试时更有用。这种用法就可以去掉 lambdas 。只有在不影响代码的可读性的情况下,才考虑使用较大的 lambda 表达式。

# 返回值保持一致性

如果期望函数返回一个值,请确保该函数的所有执行路径都返回该值。最好确保在函数退出的所有地方都有一个返回表达式。如果函数只执行一个操作而无返回值,那么 Python 将隐式地从函数返回 None 作为默认的返回值。

别这样做:

def calculate_interest(principle, time, rate):
    if principle > 0:
        return (principle * time * rate) / 100

def calculate_interest(principle, time, rate):
    if principle < 0:
        return
    return (principle * time * rate) / 100

要这样做:

def calculate_interest(principle, time, rate):
    if principle > 0:
        return (principle * time * rate) / 100
    else:
        return None

def calculate_interest(principle, time, rate):
    if principle < 0:
        return None
    return (principle * time * rate) / 100
# 推荐使用"".startswith()和"".endswith()

当需要检查字符串的前缀或后缀时,请考虑使用 "". startswith()"".endswith(),而不是用切片(slicing)。切片是一种非常有用的字符串方法,但只在对大字符串进行切片或执行字符串操作时,才可能会获得更好的性能。相反的,如果您只是在做检查前缀或后缀这样简单的事情,可以选择 startswithendswith,因为这会让读者清楚地看到您正在检查字符串的前缀或后缀。换句话说,它使您的代码更具可读性、更简洁。

别这样做:

data = "Hello, how are you doing?"
if data[:5] == "Hello":

要这样做:

data = "Hello, how are you doing?"
if data.startswith("Hello"):
# 比较类型时使用 isinstance() 方法而不是 type()

在比较两个对象的类型时,请考虑使用 isinstance() 而不是 type(),因为 isinstance() 对于子类的判定是真。考虑这样一个场景:您正在传递一个数据结构,它是一个 dict 的子类,比如 orderdicttype() 对于特定类型的数据结构将判定失败;但是,isinstance() 将识别出它是 dict 的子类。

别这样做:

user_ages = {"Larry": 35, "Jon": 89, "Imli": 12}
if type(user_ages) == dict:

要这样做:

user_ages = {"Larry": 35, "Jon": 89, "Imli": 12}
if isinstance(user_ages, dict):
# Pythonic 方式比较布尔值

在 Python 中有多种方法可以比较布尔值。

别这样做:

if is_empty = False:
if is_empty == False:
if is_empty is False:

要这样做:

is_empty = False
if is_empty:
# 为 Context Manager 编写显式代码

当您在 with 语句中编写代码时,请考虑使用一个函数来执行不同于资源获取与释放以外的的操作。

别这样做:

class NewProtocol:
    def __init__(self, host, port, data):
        self.host = host
        self.port = port
        self.data = data

    def __enter__(self):
        self._client = Socket()
        self._client.connect((self.host, self.port))
        self._transfer_data(data)

    def __exit__(self, exception, value, traceback):
        self._receive_data()
        self._client.close()

    def _transfer_data(self):
        ...

    def _receive_data(self):
        ...

con = NewProtocol(host, port, data)
with con:
    transfer_data()

要这样做:

#connection
class NewProtocol:
    def __init__(self, host, port):
        self.host = host
        self.port = port

    def __enter__(self):
        self._client = socket()
        self._client.connect((self.host, self.port))

    def __exit__(self, exception, value, traceback):
        self._client.close()

    def transfer_data(self, payload):
        ...

    def receive_data(self):
        ...

with connection.NewProtocol(host, port):
    transfer_data()

在上面的例子中,Python 的 __enter____exit__ 方法除了打开和关闭连接外,还做了一些别的事情。最好是明确地编写另外的函数来执行连接获取和关闭以外的其它操作。

# 使用整理工具来改进 Python 代码

代码整理(Code Linter)是保持代码格式一致性的重要工具。在整个项目开发过程中保持代码格式的一致性是很有价值的。

代码整理工具基本上可以帮您解决这些问题:

  • 语法错误
  • 结构问题。诸如未使用的变量或传递不正确的参数
  • 指出违反 PEP8 准则的地方

代码整理工具使开发人员的工作效率大大提高,因为它可以在运行时查找问题,从而节省您大量时间。Python有多个可用的整理工具。一些工具针对特定的代码质量,如文档字符串风格,还有流行的 Python 整理工具如 flak8 或 pylint,它检查所有的 PEP8 规则,再有专门针对 Python 类型的 mypy 检查工具等。

您可以将这些工具全部集成到您的代码中,也可以使用一个覆盖标准检查的工具来确保您遵循 PEP8 样式指南。其中最值得称道的是 Flake8 和 Pylint。但无论您使用什么工具,都要确保它符合 PEP8 的规则。

选择代码整理工具,要看它是否具备这些特性:

  • 遵守 PEP8 规则
  • 引用排序(import ordering)
  • 命名(变量、函数、类、模块、文件等的 Python 命名约定)
  • 循环引用(Cirtular imports)
  • 代码复杂度(通过代码行数、循环数和其他指标来检查函数的复杂度)
  • 拼写检查
  • 文档字符串风格(Docstring-style)检查

有很多种不同的方法来运行代码整理工具。

  • 在使用 IDE 编程时
  • 在提交代码时使用预提交工具
  • 在 Jenkins, CircleCI 等持续集成工具时

注意: 这些是肯定能够帮助您改进代码的常见方法。如果想最大限度地充分利用 Python 最佳实践,请查看PEP8 官方文档。另外,在 Github 中阅读优秀的代码范例,也将帮助您学习如何编写出更好的 Python 代码。

# 使用文档字符串

文档字符串(Docstring)是在 Python 中用于注释代码的强大方法。文档字符串通常在方法、类和模块开始时编写。文档字符串会成为该对象的一个特殊属性 __doc__

Python 官方语言建议使用 """三重双引号""" 来编写文档字符串。这些实践都可以在 PEP8 官方文档中找到。让我们简要讨论一下在 Python 代码中编写文档字符串的一些最佳实践。如示例1-14所示。

示例1-14. 用了文档字符串的函数

def get_prime_number():
    """Get list of prime numbers between 1 to 100.""""

Python 推荐了一种特定的编写文档字符串的方法。有多种方法来编写文档字符串,我们将在本章后面讨论,所有的方法都遵循一些共性的规则。Python 定义这些规则如下:

  • 使用三重引号,即便字符串只有一行。当用户希望扩展到多行时,这就非常实用。
  • 在三重引号中的字符串,前后都不应该有任何空行。
  • 使用句点(.)结束文档字符串中的句子。

类似地,Python 多行文档字符串规则适用于编写多行文档字符串。多行文档字符串是一种更具描述性的方式来注释代码的方法。您可以利用 Python 多行文档字符串在 Python 代码中编写描述性注释,而不是在单行上编写注释。这也有助于其他开发人员在代码本身中就找到文档,而不是转到别处去阅读冗长且令人厌烦的文档。参见示例1 - 15。

示例1-15. 多行文档字符串

def call_weather_api(url, location):
    """Get the weather of specific location.

    Calling weather api to check for weather by using weather api and location.
    Make sure you provide city name only, country and county names won't be
    accepted and will throw exception if not found the city name.

    :param url: URL of the api to get weather.
    :type url: str :param location: Location of the city to get the weather.
    :type location: str
    :return: Give the weather information of given location.
    :rtype: str
    """

这里有几点需要注意:

  • 第一行是函数或类的简短描述。
  • 行尾有一个句点。
  • 文档字符串中的摘要和描述之间有一空行。

如果是 Python 3 与类型(typing)模块一起使用,则可以如下编写相同的函数,如示例1-16所示。

示例1-16. 多行文档字符串,使用类型

def call_weather_api(url: str, location: str) -> str:
    """Get the weather of specific location.

    Calling weather api to check for weather by using weather api and location.
    Make sure you provide city name only, country and county names won't be
    accepted and will throw exception if not found the city name.
    """

如果在 Python 代码中使用类型,则不需要编写参数信息。

正如我提到的,有多种不同的文档字符串,多年来从不同的来源已经引入了一些新的文档字符串样式。这里并没有最好的或推荐的方法来编写文档字符串,但是,请确保在整个项目中使用相同的样式来编写文档字符串,让它们具有一致的风格。

有四种不同的方法来编写文档字符串。

  • 这是 Google 的文档字符串样例:

    """Calling given url.
    Parameters:
        url (str): url address to call.
    Returns:
        dict: Response of the url api.
    """
    
  • 这是重新构造的样例(Python 官方文档推荐这样做):

    """ Calling given url.
    :param url: Url to call.
    :type url: str
    :returns: Response of the url api.
    :rtype: dict
    """
    
  • 这是 NumPy/SciPy 文档字符串的样例:

    """ Calling given url.
    Parameters
    ---------
    url : str
        URL to call.
    Returns
    ------
    dict
        Response of url
    """
    
  • 这是一个 Epytext 的样例:

    """Call specific api.
    @type url: str
    @param file_loc: Call given url.
    @rtype: dict
    @returns: Response of the called api.
    """
    

# 模块级别的文档字符串

模块级别(module-level)的文档字符串(Docstring)应该放在文件的顶部,用来简要描述该模块的作用。同时,这些注释也应该在 import 之前。模块文档字符串应该关注模块的目标,包括模块中全部的方法/类,而不是讨论某些特定的方法或类。当然,如果您认为在用户使用该模块之前,需要了解某些特定的方法或类,也可以简要介绍一下它们。参见示例1-17。

示例1-17. 模块的文档字符串

"""
This module contains all of the network related requests. This module will check for all the exceptions while making the network calls and raise exceptions for any unknown exception. Make sure that when you use this module, you handle these exceptions in client code as:
NetworkError exception for network calls.
NetworkNotFound exception if network not found.
"""

import urllib3
import json
....

在为一个模块编写文档字符串时,应当考虑以下几点:

  • 简要描述模块的用途。
  • 如果为了方便读者了解模块而提供一些信息,您可以添加一些额外信息(如1-17所示),但注意不要太过详细。
  • 使用模块文档字符串来提供关于模块的描述性信息,而不是详细介绍每个函数或类的操作。

# 让类的文档字符串具有描述性

类的文档字符串主要用于简要描述类的使用及其总体目标。让我们再在看一些如何编写类文档字符串的例子。请参见示例1-18。

示例1-18. 单行文档字符串

class Student:
    """This class handle actions performed by a student."""
    def __init__(self):
        pass

这个类有一个单行的文档字符串,简要介绍了 Student 类。如前述,请确保遵循单行文档字符串的全部规则。

我们再来看一下示例 1-19 中类的多行文档字符串。

示例1-19. 多行文档字符串

class Student:
    """Student class information.

    This class handle actions performed by a student.
    This class provides information about student full name,
    age, roll-number and other information.

    Usage:
      import student

    student = student.Student()
      student.get_name()

    >>> 678998
    """
    def __init__(self):
        pass

这个类文档字符串是多行,然后我们还写了一些关于 Student 类的用途和用法。

# 函数的文档字符串

函数的文档字符串可以写在函数之后,也可以写在函数的顶部。函数文档字符串主要用于描述该函数如何工作,如果不使用 Python 的 typing 的话,可以包含一些参数的描述,如1-20所示。

示例1-20. 函数的文档字符串

def is_prime_number(number):
    """Check for prime number.

    Check the given number is prime number or not by checking
    against all the numbers less the square root of given number.

    :param number: Given number to check for prime.
    :type number: int
    :return: True if number is prime otherwise False.
    :rtype: boolean
    """
    ...

# 一些有用的文档字符串工具

Python 有很多文档字符串工具。文档字符串工具可以把代码中的文档字符串转换成 HTML 格式的文档。通过这些工具,运行一些简单的命令就可以帮您更新文档,而非手动去维护文档。从长远来看,在开发流程中引入它非常实用。

这里有一些实用的文档工具。每个工具都有着不同的目标,选择哪个取决于您具体的应用场景。

使用这些工具将会有助于更加容易地进行长期代码维护,还能够有助于保持代码文档的格式一致。

注意: 文档字符串是 Python 的一个重要特征,它可以让编写代码文档变得更加容易。应当尽早开始在代码中使用文档字符串,免得在项目成熟的后期将花费更多的时间。

# 编写 Python 的控制结构

控制结构是任何编程语言的基本组成部分,Python 也是如此。Python 有许多方法来编写控制结构,但有一些最佳实践可以帮助您让 Python 代码更简洁。在本节中,我们将探讨 Python 中这些用于控制结构的最佳实践。

# 使用列表解析

列表解析(List Comprehension)是一种编写代码来解决问题的方法,与 Python 的 for 循环所做的类似,但它允许在列表中选择使用 if 条件。Python 中有多种方法可以从一个列表中派生出另一个列表,主要的工具是过滤器(filter)和映射(map)方法。但这里推荐使用列表解析,因为与 map 和 filter 等其他选择相比,列表解析可以让代码更具有可读性。

在下面的例子中,我们用 map 来获得一个序列的平方:

numbers = [10, 45, 34, 89, 34, 23, 6]
square_numbers = map(lambda num: num**2, num)

这里给出一个使用列表解析的版本:

square_numbers = [num**2 for num in numbers]

让我们再来看另一个示例,对所有真值使用过滤器。下面是使用 filter 的版本:

data = [1, "A", 0, False, True]
filtered_data = filter(None, data)

这里是一个使用列表解析的版本:

filtered_data = [item for item in filter if item]

您可能已经看出来了,与过滤器和映射版本相比,列表解析的版本代码可读性更强。官方 Python 文档也建议使用列表解析而不是过滤器和映射。

如果在 for 循环中没有复杂的条件或复杂的计算,则应该考虑使用列表解析。但是如果您在一个循环中做很多事情,为了可读性,最好还是使用一个循环。

为了进一步说明在 for 循环中使用列表解析的意义,让我们来看一个示例,例子中需要从字符列表中识别一个元音。

list_char = ["a", "p", "t", "i", "y", "l"]
vowel = ["a", "e", "i", "o", "u"]
only_vowel = []
for item in list_char:
    if item in vowel:
        only_vowel.append(item)

这里是一个列表解析的版本:

[item for item in list_char if item in vowel]

正如您所看到的,与使用循环相比,使用列表解析的可读性更强,代码行数更少。此外,循环还有额外的性能损耗,因为每次循环都要将条目追加到列表中,而在列表解析中不需要这样。

类似的,与列表解析相比,过滤器和映射的函数调用也有额外的开销。

# 不要使用过于复杂的列表解析

列表解析不能太复杂,否则会影响代码的可读性并且容易出错。

列表解释最多只适用于有一个条件的双重循环。再复杂的话,它就会影响代码的可读性了。让我们来看一个例子。

这个示例是将矩阵转置。将:

matrix =[[1,2,3],
        [4,5,6],
        [7,8,9]]

转置成:

matrix =[[1,4,7],
        [2,5,8],
        [3,6,9]]

用列表解析可以写成:

return [[ matrix[row][col] for row in range(0, height) ] for col in range(0,width) ]

这样的代码是可读的,使用列表解析也是有意义的。我们还可以把代码改写成更好的格式,如下:

return [[ matrix[row][col]
         for row in range(0, height) ]
         for col in range(0,width) ]

当有多个 if 条件时,可以使用循环而不是列表解析,如下:

ages = [1, 34, 5, 7, 3, 57, 356]
old = [age for age in ages if age > 10 and age < 100 and age is not None]

在这里,很多事情都发生在同一行,就很难读懂且容易出错。这种情况下,应该使用 for 循环而是列表解析。

我们可以改写代码如下:

ages = [1, 34, 5, 7, 3, 57, 356]
old = [] for age in ages:
    if age > 10 and age < 100:
        old.append(age)
print(old)

正如您所看到的,它的代码行数虽然更多,但是可读性更好,也更简洁。

因此,一个好的准则是,先考虑使用列表解析,当表达式开始变得复杂或可读性受到影响时,就转而使用循环。

注意: 合理地使用列表解析可以改进您的代码;但是,过度使用列表解析可能会影响代码可读性。因此,在处理复杂语句时(可能不止一个条件或循环),不要使列表解析。

# 该不该用 lambda ?

可以用 lambda 来辅助构造表达式,但别用它来代替函数。让我们看1-21中的示例。

示例1-21. 使用无赋值的 lambda

data = [[7], [3], [0], [8], [1], [4]]
def min_val(data):
    """Find minimum value from the data list."""
    return min(data, key=lambda x:len(x))

此代码使用 lambda 构建了一个一次性函数来查找最小值。然而,我建议不要像这样用 lambda 来构建函数:

min_val = min(data, key=lambda x: len(x)

这里,min_val 函数是用 lambda 表达式构建出来的。用 lambda 表达式来构建一个函数,将和 def 的功能重叠,这违反了 Python 的哲学,即只用一种方式来完成某操作。

PEP8 文档中 lambda 的相关建议是:

务必使用 def 语句而不是将 lambda 表达式直接绑定到某个名称的赋值语句。

要这样:

def f(x): return 2*x

不要这样:

f = lambda x: 2*x

前一种方式表示定义的函数对象的名称是 f 而不是泛型 lambda。这在跟踪回溯和函数名的符串表示时更有用。而后一种方式使用 lambda 赋值语句,相对于前一种显式 def 语句而言,没有利用到 lambda 表达式的唯一价值所在,即,它可以嵌入到较大的表达式中。

# 使用生成器和列表解析时机的比较

生成器(Generator)和列表解析(List Comprehension)之间的主要区别是列表解析会把数据保存在内存中,而生成器不会。

请按下列情况使用列表解析:

  • 当需要对列表进行多次迭代时。
  • 当需要列出方法来处理生成器中不可用的数据时。
  • 当没有大量数据需要迭代时(即您认为将数据保存在内存中不是个问题的时候)。

假设您希望从文本文件中读取某行,如示例1-22所示。

示例 1-22. 从文档中读文件

def read_file(file_name):
    """Read the file line by line."""
    fread = open(file_name, "r")
    data = [line for line in fread if line.startswith(">>")]
    return data

在这里,文件可能太大,列表中有那么多行可能会内存装不下并使代码变慢。因此,需要考虑在列表上使用迭代器。1-23给出了一个示例。

示例 1-23. 使用迭代器从文档中读文件

def read_file(file_name):
    """Read the file line by line."""
    with open(file_name) as fread:
        for line in fread:
            yield line

for line in read_file("logfile.txt"):
    print(line.startswith(">>")

在示例1-23中,不是使用列表解析将数据放入内存,而是每一次读取一行并采取操作。尽管如此,这里我们还是可以传递列表解析来做进一步操作,如处理所有以 >>> 开头的行,而生成器每次都需要去查找以 >>> 开头的行。

这两个都是 Python 的优秀特性,正确使用它们将让您的代码具有更好的性能。

为什么不使用 else 循环

Python 循环中有一个 else 子句,可以在 Python 代码的 for 或 while 循环中使用。代码中的 else 子句仅在控制正常退出循环时运行。如果控制从 break 关键字中退出循环,则不会进入代码的 else 部分。

使用带有 else 子句的循环容易让人困惑,这使得许多开发人员避免使用这个特性。如果是正常流程控制中的 if/else 条件语句,这是可以理解的。

让我们看看1-24中的一个简单示例。代码试图循环遍历一个列表,然后在循环外部紧随其后有一个 else 子句。

Listing 1-24. 带有 else 子句的 for 循环

for item in [1, 2, 3]:
    print("Then")
else:
    print("Else")

输出结果:
    >>> Then
    >>> Then
    >>> Then
    >>> Else

乍一看,您可能认为它应该只打印三个 Then 而不打印 Else,如同在正常的 if/else 语句块中那样,会跳过 else 子句。这是观察代码逻辑的一种自然方式。然而,这种假设在这里是不正确的。如果使用 while 循环,则会更加混乱,如1-25所示。

Listing 1-25. 带有 else 子句的 while 循环

x = [1, 2, 3]
while x:
    print("Then")
    x.pop()
else:
    print("Else")

输出结果:
     >>> Then
     >>> Then
     >>> Then
     >>> Else

这里 while 循环运行直到列表不为空,然后运行 else 子句。

在 Python 中存在这种功能是有原因的。一个主要的用例是在 for 和 while 循环之后使用 else 子句,以便在循环结束后执行一些额外的操作。让我们看一下1-26中的示例。

Listing 1-26. else 子句带有 break

for item in [1, 2, 3]:
    if item % 2 == 0:
        break
    print("Then")
else:
    print("Else")

输出结果:
>>>Then

有更好的方法来编写这段代码,而不是在循环之外使用 else 子句。您可以在循环内使用带有 break 的 else 子句,或使用无条件的 break 语句。有多种方法可以在不使用 else 子句的情况下实现相同的结果。您应该在循环中使用条件判断,而不是 else 子句,因为有被其他开发人员误解代码的风险,再者,让人一眼就能读懂代码也比较困难。请参见示例1-27。

Listing 1-27. 带有 break 的 else 子句

flag = True
for item in [1, 2, 3]:
    if item % 2 == 0:
        flag = False
        break
    print("Then")
if flag:
    print("Else")

运行的结果是:
>>>Then

这段代码更容易阅读理解,并且在阅读时不存在混淆的可能性。else 子句是编写代码的一种有趣的方法,但是,它会影响代码的可读性,因此避免使用它反而是个好办法。

# 为何 Python 3 中的 range 更好

如果用过 Python 2,您或许已经用过 xrange 了。在 Python 3中,xrange 被改名为 range 且增加了一些额外的功能。range 类似于 xrange 并且可以迭代。

>>> range(4)
range(0, 4)          # 可迭代
>>> list(range(4))
[0, 1, 2, 3]         # 列表

Python 3 的 range 函数有一些新特性。与列表(list)相比,它主要的优点是数据不保存在内存中。与列表、元组(tuple)和其他 Python 数据结构相比,range 代表着不可修改的迭代对象,它总是使用同样大小的内存,无论范围有多大。因为它只存储了开始值、停止值和步长值,并根据需要计算当前值。

有些事情可以用 range 来做,而这些在 xrange 中是不行的。

  • 可以比较 range 的数值

    >>> range(4) == range(4)
    True
    >>> range(4) == range(0, 4)
    True
    >>> range(4) == range(5)
    False
    
  • 可以切片

    >>> range(10)[2:]
    range(2, 10)
    

range 有很多新特性,您可以在这里查看更多细节: https://docs.python.org/3.0/library/functions.html#range (opens new window)

此外,当需要处理数字而不是代码中的数字列表时,可以考虑使用 range,因为它比列表要快得多。

Python 为您提供了简单易用的 range ,建议您在处理数字时尽可能多地在循环中使用它。

别这样做:

for item in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]:
    print(item)

要这样做

for item in range(10):
    print(item)

就性能而言,第一个循环的代价要大得多,如果这个列表足够大,那么由于内存占用问题以及从列表中取出数字的原因,代码运行会慢得多。

# 抛出异常

异常(Exception)有助于在代码中报告发生的错误。在 Python 中,异常由内置模块处理。掌握异常很重要,在恰当的时机和场景下使用异常,可以让代码不容易出错。

我们可以很方便通过异常来报告发生的错误,所以不要忘记在代码中去使用它。异常可以帮助 API 或库的使用者了解到代码的限制,以便在调用时设计良好的错误处理机制。在代码适当的地方抛出异常可以极大地帮助其他开发者理解您的代码,并且让用户在使用您的 API 时感到高兴。

# 频繁抛出的异常

您也许想知道在什么时候,或者什么地方需要在 Python 代码中引发异常。

我通常会在发现当前代码块的基本前提假设不成立时,抛出异常。在您的代码失败时,Python 会抛出异常。即使有连续的故障,您也希望为它引发异常。让我们来看示例1-28中计算两数的除法。

示例1-28. 带异常的除法

def division(dividend, divisor):
    """Perform arithmetic division."""
    try:
        return dividend/divisor
    except ZeroDivisionError as zero:
        raise ZeroDivisionError("Please provide greater than 0 value")

正如在这段代码中所看到的,当代码发生错误时,就会引发异常。这有助于调用方意识到可能发生 ZeroDivisionError 异常并以不同的方式来处理它。

如果我们返回 None 会怎样呢?参见示例1-29。

示例 1-29. 不带异常的除法

def division(dividend, divisor):
    """Perform arithmetic division."""
    try:
        return dividend/divisor
    except ZeroDivisionError as zero:
        return None

result = division(10, 2)

如果调用者不处理的情况下,调用该 division(dividend, divisor) 函数失败了,即使代码中没有出现 ZeroDivisionError ,或者该函数没有抛出任何异常,要想在代码增长后或是需求发生变化后诊断该代码,都会比较困难。因此,在出现任何错误时,最好避免通过 division(dividend, divisor) 函数返回 None,以便调用者更容易理解函数执行期间发生了什么错误。当我们抛出异常时,它将让调用者提前知道输入值不正确,需要提供正确的输入参数值,从而避免了潜在的 Bug。

从调用者的角度来看,获取异常比获取返回值要方便得多。异常是 Python 用来指示错误的方式。

Python 的信条是“请求原谅比请求许可更容易”,这意味着调用方不会事先检查代码以确保不会出现任何异常;相反的,如果捕获了异常,就处理它。

通常的情况,当您认为代码可能出现错误时就抛出异常,以便调用方能够优雅地处理它。

换句话说,如果您认为代码不能正确地运行,且还没找到解决办法时,就考虑抛出一个异常。

# 利用 finally 处理异常

在 Python 中 finally 内的代码总是会得到运行。finally 关键字在处理异常时非常有用,特别是在处理资源时。我们可以用 finally 来确保关闭文件或者释放资源,不管是否发生异常。即使您没有捕获异常,或是没有需要捕获的异常时,这样做也是对的。参见示例1-30。

示例 1-30. finally 关键字的使用

def send_email(host, port, user, password, email, message):
    """send email to specific email address."""
    try:
        server = smtlib.SMTP(host=host, port=port)
        server.ehlo()
        server.login(user, password)
        server.send_email(message)
    finally:
        server.quite()

这里用 finally 来处理异常,它有助于清理服务器连接中所占用的资源,防止在登录期间或在 send_email 调用中出现任何错误。

我们可以用 finally 关键字来编写关闭文件的代码,如示例1-31所示。

示例 1-31. 用 finally 关键字关闭文件

def write_file(file_name):
    """Read given file line by line""""
    myfile = open(file_name, "w")
    try:
        myfile.write("Python is awesome")        # 抛出了TypeError
    finally:
        myfile.close()                           # 将在TypeError异常传播之前执行

这里用 finally 来关闭文件。无论是否有异常发生,finally 中的代码都将运行以关闭文件。

因此,当要执行某段代码而不管是否发生异常时,应该用 finally 。用 finally 可以确保正确地处理资源,还能让代码更简洁。

# 创建自己的异常类

在创建 API 或库时,或是想定义自己的异常以便在项目或 API 中保持一致性时,建议创建自己的异常类。这将极大地方便代码诊断或调试。它还能让代码更简洁,因为调用者将知道为什么会发生错误。

例如,在数据库中找不到用户时抛出异常,我们希望异常的类名能够反映出错的原因。那么,命名为UserNotFoundError 的异常本身就解释了异常的原因与意图。

您可以在 Python 3+ 中定义自己的异常类,如示例1-32所示。

示例 1-32. 创建一个特定的异常类

class UserNotFoundError(Exception):
    """Raise the exception when user not found."""
    def __init__(self, message=None, errors=None):
        # 使用参数调用基类构造函数
        super().__init__(message)
        # 新的自定义的代码
        self.errors = errors

def get_user_info(user_obj):
    """Get user information from DB."""
    user = get_user_from_db(user_obj)
    if not user:
        raise UserNotFoundException(f"No user found of this id: {user_obj.id}")

get_user_info(user_obj)
>>> UserNotFoundException: No user found of this id: 897867

您还希望自己创建的异常类有一定的描述性,并且有定义良好的范围与边界。您希望仅在代码无法找到用户时使用 UserNotFoundException,并且通知调用代码,数据库中没有找到此用户的信息。为定制的异常提供明确的边界,可以让代码诊断变得更加容易。当您查看代码时,您就能确切地知道代码引发该特定异常的原因。

通过命名可以让异常类表示更宽的范围,但它的名称应该表示它所处理的那种情况,如1-33所示。示例中的 ValidationError 可用于多种验证场景,但它的范围都是和验证相关。

示例 1-33. 创建一个更宽泛的异常类

class ValidationError(Exception):
    """Raise the exception whenever validation failed."""
    def __init__(self, message=None, errors=None):
        # 使用参数调用基类构造函数
        super().__init__(message)
        # 新的自定义的代码
        self.errors = errors

UserNotFoundException 相比,这个异常类定义的范围更广。每当您认为验证失败,或是没有输入有效的数据时,就会引发 ValidationError;尽管如此,该范围仍然由校验的上下文来确定。因此,请确保您知道异常类的适用范围,并且在异常类适用的特定范围内引发异常。

# 捕获特定的异常

在捕获异常时,建议只捕获某个特定的异常,而不要使用 except: 子句。

except: 或者 except Exception 将捕获所有异常,这可能会导致代码隐藏了您本不打算隐藏的关键错误或异常。

让我们看一个代码片段,它使用 try/catch 块中的 except 子句来调用函数 get_even_list

别这样做:

def get_even_list(num_list):
    """Get list of odd numbers from given list."""
    # 此处可能引发 NoneType 或 TypeError 异常
    return [item for item in num_list if item%2==0]

numbers = None
try:
    get_even_list(numbers)
except:
    print("Something is wrong")

输出结果:
>>>Something is wrong

这种代码隐藏了一个异常,可能是 NoneType 或者 TypeError,这显然是代码中的一个 Bug。客户端应用程序或服务将很难明白为什么它们会收到这种“某处发生了错误”的消息。相反,如果您用恰当的信息抛出特定类型的异常, API 客户端将会感谢您的提醒。

当您在代码中使用 except 时,Python 内部将其视为 except BaseException。创建特定的异常非常有用,特别是在较大的代码库中。

要这样做:

def get_even_list(num_list):
    """Get list of odd numbers from given list."""
    # 此处可能引发 NoneType 或 TypeError 异常
    return [item for item in num_list if item%2==0]

numbers = None
try:
    get_even_list(numbers)
except NoneType:
    print("None Value has been provided.")
except TypeError:
    print("Type error has been raised due to non sequential data type.")

处理特定的异常有助于调试和诊断问题。调用方将立即知道代码失败的原因,并且将强制您添加处理特定异常的代码。这也将让您的代码在调用或者被调用时更不易出错。

根据 PEP8 文档,在处理异常时,您应该在这些情况下使用 except 关键字:

  • 当异常处理代码需要打印或记录下跟踪日志时。至少可以让用户会意识到此处发生了错误。
  • 当代码需要做一些清理工作,然后再让异常通过 raise 向上传播时。try...finally 适合处理这种情况。

注意: 处理特定的异常是编写代码的最佳实践之一,尤其是在 Python 中,它将帮您节省大量调试代码的时间。此外,它还会让有 Bug 的代码快速失败,而不是将 Bug 隐藏在代码中。

# 关注第三方异常类

在调用第三方 API 时,了解第三方库抛出的全部异常很重要。了解所有类型的异常可以帮助您调试代码。

如果您认为第三方库的异常类不太适合您的使用场景,那么可以考虑创建自己的异常类。在使用第三方库时,如果希望根据应用程序的错误来重新命名异常类,或者想在第三方异常类中添加新的信息,可以创建自己的异常类。

让我们来看示例1-34中的 botocore 客户端库。

示例 1-34. 创建范围更广的自定义异常类

from botocore.exceptions import ClientError

ec2 = session.get_client('ec2', 'us-east-2')
try:
    parsed = ec2.describe_instances(InstanceIds=['i-badid'])
except ClientError as e:
    logger.error("Received error: %s", e, exc_info=True)
    # 只需关注某个特定的服务错误代码
    if e.response['Error']['Code'] == 'InvalidInstanceID.NotFound':
        raise WrongInstanceIDError(message=exc_info, errors=e)

class WrongInstanceIDError(Exception):
    """Raise the exception whenever Invalid instance found."""
    def __init__(self, message=None, errors=None):
        # 使用参数调用基类构造函数
        super().__init__(message)
        # 新的自定义代码
        self.errors = errors

这里考虑两件事:

  • 在第三方库中发现特定错误时,记录日志,这将让调试第三方库中的问题变得更容易。
  • 这里您创建了一个新的错误类来定义自己的异常,可能您并不想对每个异常都这样做,但是,如果您认为创建一个新的异常类将可以让您的代码更清晰、更易读,那么就请考虑创建一个新类吧。

有时很难找到处理第三方库或 API 抛出的异常的正确方法。了解至少一些由第三方库抛出的常见异常,将能够让您更轻松地应对生产环境中的 Bug。

# 让 try 内的代码最少

无论何时处理代码中的异常,都要尽量让 try 块中的代码量最少。在 try 块中让有可能抛出异常的代码量最少,可以让其他开发人员清楚地知道哪一部分代码有抛出错误的风险,也方便调试。没有 try/catch 来处理异常的代码可能会运行稍微快一些,但是,如果不处理异常,则有可能导致应用程序失败。因此,恰当的异常处理能够让您的代码没有 Bug,并且在生产中为您节省数百万元。

让我们来看一个例子。

别这样做:

def write_to_file(file_name, message):
    """Write to file this specific message."""
    try:
        write_file = open(file_name, "w")
        write_file.write(message)
        write.close()
    except FileNotFoundError as exc:
        FileNotFoundException("Please provide correct file")

如果您仔细查看前面的代码,就会发现有可能出现不同类型的异常。一个是 FileNotFound ,另一个是 IOError

您可以在一行中捕获不同的异常,或者在不同的 try 块中编写不同的异常处理代码。

要这样做:

def write_to_file(file_name, message):
    """Write to given file this specific message."""
    try:
        write_file = open(file_name, "w")
        write_file.write(message)
        write.close()
    except (FileNotFoundError, IOError) as exc:
        FileNotFoundException(f"Having issue while writing into file {exc}")

即使在其它行上没有发生异常的风险,在 try 块中包含最少的代码也是可取的,如下所示。

别这样做:

try:
    data = get_data_from_db(obj)
    return data
except DBConnectionError:
    raise

要这样做:

try:
    data = get_data_from_db(obj)
except DBConnectionError:
    raise
return data

这样代码更简洁,并且清楚地表明了仅在调用 get_data_from_db 方法中有可能发生错误。

# 小结

在本章中,我们学习了一些可以帮助您的 Python 代码更具可读性和更简单的常见方法。

此外,异常处理是用 Python 编写代码时最重要的部分之一。理解好异常有助于您维护应用程序,在大型项目中更是如此,因为应用程序的不同部分是由不同的开发人员编写的,所以出现各种问题的可能性更大。在代码的恰当位置使用异常可以帮您节省大量时间和金钱,特别是在调试开发环境中的问题时。日志和异常是任何成熟的应用程序软件中最重要的两个部分,因此,提前计划并将它们作为应用程序软件开发的核心部分,将有助于您编写更加易于维护和可读的代码。

# 第2章 数据结构

数据结构(Data Structure)是任何编程语言的基础构件。掌握好数据结构可以节省大量时间,使用好的数据结构可以让代码更易于维护。Python 有许多用数据结构来存储数据的方法,充分理解在什么时候用哪一种数据结构,将会在代码的内存利用率、易用性、以及性能等多方面产生巨大的差异。

在本章中,我先会介绍一些常见的数据结构,并解释何时在代码中使用它们。我还将介绍在特定情况下使用这些数据结构的好处。然后,您将具体认识到字典(dict)作为 Python 数据结构的重要性。

# 通用数据结构

Python 具备许多重要的数据结构。在本节中,您将了解最常见的数据结构,充分理解数据结构对于编写高效代码的重要性。合理地使用数据结构可以让代码更出色,更少 Bug。

# 使用集合提速

集合(set)是 Python 中的基本数据结构,它也是最容易被忽视的数据结构之一。集合的最主要的好处是速度快。让我们来看看集合的一些其它特性:

  • 不允许重复的元素。
  • 无法通过索引访问集合内的元素。
  • 集合的实现用的是哈希表,因此,我们可以用 O(1) 的时间访问集合内的元素。
  • 集合中不允许出现列表(list)的某些常见操作,如切片和查找。
  • 集合可以在插入时对元素进行排序。

考虑到这些约束,每当数据结构中用不到这些通用功能时,使用集合是个不错的选择,这将让代码在访问数据时更快。示例2-1展示了使用集合的一个例子。

示例2-1. 集合访问元素的用法

data = {"first", "second", "third", "fourth", "fifth"}
if "fourth" in data:
    print("Found the Data")

集合是用哈希表来实现的,因此每当有新的元素添加到集合时,该元素在内存中的位置将由对象的哈希值来确定。这就是集合在访问数据时性能优异的原因。如果您有数千个元素,并且需要经常访问它们,使用集合来访问元素会比列表(list)快得多。

让我们看另一个示例2-2,其中集合是用于确保没有重复的数据。

示例2-2. 集合去除重复元素的用法

data = ["first", "second", "third", "fourth", "fourth", "fifth"]
no_duplicate_data = set(data)
>>> {"first", "second", "third", "fourth", "fifth"}

集合可用作字典的键,并且也能作为其他数据结构(如列表)的键。 如示例2-3所示,有一个来自数据库的字典,该字典的 ID 值为键,对应的值包含了用户的名字和姓氏。

示例2-3. 名字和姓氏的集合

users = {'1267': {'first': 'Larry', 'last': 'Page'},
         '2343': {'first': 'John', 'last': 'Freedom'}}
ids = set(users.keys())
full_names = []
for user in users.values():
    full_names.append(user["first"] + " " + user["last"])

最后会得到一个 ID 的集合以及一组姓名的列表。我们还可以看到,集合可以从列表中创建。

注意: 集合是一个实用的数据结构。如果您需要经常访问一系列数据,并且时间复杂度在 O(1) 以内,应当考虑使用它。下次当您需要用到一个数据结构时,我建议在使用列表或元组(tuple)之前考虑集合。

# 命名元组用作返回和访问数据

命名元组(namedtuple)本质上是一个带有数据名称的元组(tuple)。元组可以做的事情,命名元组也同样可以,且它还具有一些元组没有的额外功能。使用命名元组,可以很容易创建轻量级的对象类型。命名元组还会让代码更加符合 Python 风格。

# 访问数据

用命名元组访问数据可使代码更具可读性。假设您想要创建一个在初始化以后就不再改变的类,可以参考示例2-4中的类:

示例2-4. 不可变类

class Point:
    def init(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

point = Point(3, 4, 5)
point.x
point.y
point.z

如果不打算改变 Point 类的值,就可以用命名元组来编写,它将让代码更具可读性,如示例2-5所示。

示例2-5. 用命名元组的实现

Point = namedtuple("Point", ["x", "y", "z"])
point = Point(x=3, y=4, z=5)
point.x
point.y
point.z

很明显,此代码可读性更高了,且代码行数比使用普通类更少。命名元组和元组占用的内存相同,它们具有相同的性能。

您可能想知道为什么不使用字典而要用命名元组呢?答案是,它们易于编写。

元组是不可变的,无论是否命名。命名元组使用名称而非索引可以更加方便地访问。命名元组有一个严格的限制即名称必须为字符串。此外,命名元组不执行任何哈希操作,因为它产生的是一个类型。

# 返回数据

一般情况下,您可能会用元组的形式返回数据。但是,这里建议您考虑使用命名元组来返回数据,因为它在代码没有太多上下文的情况下更具可读性。我甚至建议,当需要把数据从一个函数传递到另一个函数时,应当考虑使用命名元组,因为它可以让代码更加地道和易读。让我们参考示例2-6中的例子。

示例2-6. 从函数返回元组作为返回值

def get_user_info(user_obj):
    user = get_data_from_db(user_obj)
    first_name = user["first_name"]
    last_name = user["last_name"]
    age = user["age"]
    return (first_name, last_name, age)

def get_full_name(first_name, last_name):
    return first_name + last_name

first_name, last_name, age = get_user_info(user_obj)
full_name = get_full_name(first_name, last_name)

这个函数有什么问题呢?答案就是返回值。

如您所见,该代码将从数据库中获取用户信息,返回 first_name 、last_name 和 age。现在考虑一下,您需要将这些返回值作为参数传递给其他函数如 get_full_name。在传递这些值时,容易让读者在阅读代码时受到视觉干扰。如果您需要像这样传递更多的值,可以想象用户要理解您的代码将有多么困难。如果可以将这些值通过一个数据结构绑定在一起,它就可以在不编写额外代码的情况下提供上下文。

让我们使用命名元组来重写这段代码,更合理些,如示例2-7所示。

示例2-7. 从函数返回命名元组作为返回值

def get_user_info(user_obj):
    user = get_data_from_db(user_obj)
    UserInfo = namedtuple("UserInfo", ["first_name", "last_name", "age"])

    user_info = UserInfo(first_name=user["first_name"],
                         last_name=user["last_name"],
                         age=user["age"])
    return user_info

def get_full_name(user_info):
    return user_info.first_name + user_info.last_name

user_info = get_user_info(user_obj)
full_name = get_full_name(user_info)

使用命名元组编写的代码会自己提供上下文,而无需额外的代码。在这段代码中,user_info 作为命名元组从 get_user_info 函数返回时并没有明显设置额外的上下文。因此,从长远来看,使用命名元组能有效地提升代码的可读性和可维护性。

如果有10个值要传递,通常情况下您可以考虑使用元组或字典。但元组不能给其中的数据提供上下文或名称,而字典又不具备不可变性。当用户想在初次赋值后不再希望数据发生变化时,命名元组就特别适用,它弥补了元组和字典的不足。

最后,如果想把命名元组转换成字典,或者将一个列表转换成命名元组时,命名元组中提供了简单的方法可以实现。所以,命名元组是十分灵活的,下次创建具有不可变数据或返回多个值的类时,可以考虑使用命名元组,以提高可读性和可维护性。

注意: 在任何您认为对象表示法会让代码更地道更易读的地方,应该使用命名元组(不是元组)。每当需要传递多个值时,我通常会考虑使用命名元组,在这种情况下,命名元组可以完美地满足要求,它让代码具有更高的可读性。

# 理解字符串、Unicode和字节

作为开发人员,理解 Python 语言中的一些基本概念能够帮助您处理数据。具体来说,就是在 Python 中对字符串(str)、Unicode 和字节(byte)的基本了解将有助于您处理数据。归因于 Python 内置库和语言的简单性,数据处理或与数据相关的代码是非常容易编写的。

如您所知,str 是 Python 中表示字符串的类型。参阅示例2-8。

示例2-8. 不同值的字符串类型

p = "Hello"
type(p)
>>> str

t = "6"
type(t)
>>> str

Unicdoe 为世界上几乎所有语言的字符都赋予了唯一标识,例如下面:

0x59 : Y
0xE1 : á
0x7E : ~

Unicode 给字符分配的数字被称为码点(code point)。那么,使用 Unicode 的目的是什么呢?

Unicode 的目的是为世界上几乎所有语言中的每个字符都提供唯一的 ID。您可以使用 Unicode 的码点来表示任何语言的任意字符。通常情况下,Unicode 使用格式前导字符 U+,紧跟着用十六进制数填充成至少四位的数字。

因此,您需要记住的是,Unicode 的核心在于为每个字符分配一个被称为码点的数字 ID,从而让用户可以毫无歧义地表示这些字符。

将任意字符映射到二进制位的过程被称为编码,这些二进制位将保存至计算机内存或磁盘中。字符编码方式有许多种,最常见的有 ASCII,ISO-8859-1 和 UTF-8。

Python 解释器使用的字符编码方式是 UTF-8。

因此,让我们来简要介绍一下 UTF-8。UTF-8 将所有 Unicode 字符映射到长度为8、16、24或32的二进制位模式,即对应于1、2、3或4个字节。 例如,Python 解释器会将 a 转换为01100001,将 å 转换为11000011 01011111(0xC3 0xA1)。可见,Unicode 是非常实用的。

注意: 在 Python 3 中,所有的字符串都是 Unicode 字符序列。因此,不需要将字符串编码成 UTF-8 或从 UTF-8 解码到字符串。当然,您依旧可以使用字符串编码方法在字符串和字节之间来回转换。

# 谨慎使用列表并优先选择生成器

迭代器(Iterators)非常有用,特别是在处理大量数据时。我曾见过一些用列表来存储序列数据的代码,但是这样会存在内存泄漏从而影响系统性能的风险。例如示例2-9。

示例2-9. 使用列表来返回素数

import math

def get_prime_numbers(lower, higher):
    primes = []
    for num in range(lower, higher + 1):
        for prime in range(2, num + 1):
            is_prime = True
            for item in range(2, int(num ** 0.5) + 1):
                if num % item == 0:
                    is_prime = False
                    break
        if is_prime:
            primes.append(num)

lower = 3
higher = 30000
print(get_prime_numbers(lower, higher))

像这样的代码有什么问题呢?首先,它很难读懂;其次,因为有大量的数据存储在内存中,可能存在内存泄漏的风险。那么,如何在可读性和性能方面改进上述代码呢?

这里可以考虑使用生成器,生成器可用 yield 生成数字,并且可以把它用作迭代器来得到数值。下面让我们使用迭代器重写这段代码,如2-10所示。

示例2-10. 使用生成器来生成素数

import math

def is_prime(num):
    prime = True
    for item in range(2, int(math.sqrt(num)) + 1):
        if num % item == 0:
            prime = False
            break
    return prime

def get_prime_numbers(lower, higher):
    for possible_prime in range(lower, higher):
        if is_prime(possible_prime):
            yield possible_prime
        yield False

lower = 3
higher = 30000
for prime in get_prime_numbers(lower, higher):
    if prime:
        print(prime)

此代码易于理解而且性能更优。另外,生成器无意中会促使您进行代码重构。在这里,将列表作为返回值会让代码变得很臃肿,而生成器可以很轻松解决这个问题。

据我观察,有一种常见的情况,即从数据库中获取数据但不明确具体行数时,迭代器非常有用。您可能会把这些值保存在内存中,这将会占用大量内存。换用迭代器试试吧,它将立即返回一个值,然后运行到下一行再给出另一个值。

假设您想要通过 ID 访问数据库获得用户的年龄和姓名。您知道数据库中的 ID 是索引,也知道数据库中的用户总数是 1,000,000,000。我经常看到开发人员的代码中会使用列表来获取数据块,这是解决内存问题的一种好方法。参考示例2-11。

示例2-11. 访问数据库并将结果以数据块的形式存储在列表中

def get_all_users_age(total_users=1000):
    age = []
    for id in range(1, total_users):
        user = access_db_to_get_users_by_id(id)
        age.append([user.name, user.age])
    return age

total_users = 1000000000

all_users_age = get_all_users_age(total_users)
for user_name, user_age in all_users_age:
    print(user_name, user_age)

在这段代码中,您试图通过访问数据库来获取用户的年龄和姓名。但是,当系统中内存不多时,这种方法可能不好,因为您是随机选择一个您认为内存安全的数字来存储用户信息,但其安全性并不能得到保证。所以,Python提供了生成器这种解决方案来避免这些问题,在代码中应对这些情况。您可以考虑重写这段代码,如示例2-12所示。

示例2-12. 使用迭代器的方法

def get_all_users_age():
    total_users = 1000000000
    for id in range(1, total_users):
        user = access_db_to_get_users_by_id(id)
        yield user.name, user.age

for user_name, user_age in get_all_users_age():
    print(user_name, user_age)

注意: 生成器是 Python 中一个很实用的功能,因为它可以让代码处理数据密集型的工作性能更好。生成器也会迫使您去考虑代码的可读性。

# 使用 zip 函数处理列表

当您有两个需要同时处理的列表时,请考虑使用 zip 函数。这是 Python 的内置函数,它非常高效。

假设在一个数据库中,用户数据表内包含了用户的姓名和工资。您需要将它们合并到一个列表中,将它作为全部用户的列表返回。函数 get_users_name_from_dbget_users_salary_from_db 分别提供了用户的姓名列表和相应的工资列表。如何将它们结合起来?其中一种方法如示例2-13所示。

示例2-13. 合并列表

def get_user_salary_info():
    users = get_users_name_from_db()
    # ["Abe", "Larry", "Adams", "John", "Sumit", "Adward"]

    users_salary = get_users_salary_from_db()
    # ["2M", "1M", "60K", "30K", "80K", "100K"]

    users_salary = []
    for index in len(users):
        users_salary.append([users[index], users_salary[index]])

    return users_salary

有更好的办法来解决这个问题吗?答案是肯定的。Python 的内置函数—— zip,可轻松应对此问题,如示例2-14。

示例2-14. 使用 zip 函数

def get_user_salary_info():
    users = get_users_name_from_db()
    # ["Abe", "Larry", "Adams", "John", "Sumit", "Adward"]

    users_salary = get_users_salary_from_db()
    # ["2M", "1M", "60K", "30K", "80K", "100K"]

    users_salary = []
    for usr, slr in zip(users, users_salary):
        users_salary.append(usr, slr)

    return users_salary

如果需要处理大量数据,请考虑使用迭代器,而不是存储到列表中。zip 函数可以合并两个列表且能并行处理,因此使用 zip 函数将会更高效。

# 利用 Python 的内置函数

Python 有很多优秀的内置库。在这一章中,我不会一一介绍每个库,因为它们的数量过于庞大,我只会介绍一些可以对代码产生重大影响并提高代码质量的基础数据结构库。

# Collections

这是使用最广泛的库之一,它包含了许多实用的数据结构,特别是命名元组(namedtuple)、默认字典(defaultdict)和有序字典(orderddict)。

# Csv

使用 csv 库来读取和写入 CSV 文件。用它能够节省大量的时间,不需要自己编写方法。

# datetime 和 time

毫无疑问,这是最常用的两个库。事实上,您可能已经用过它们了;如果没有,熟悉在不同场景下如何使用该库中的各种方法也能帮到您,特别是在处理计时问题的场景下。

# math

数学库(math)中有很多实用的方法来执行从基础到高等数学的运算。在采用第三方库来解决数学问题之前,可以先在这个库中查找,看看是否已经存在。

# re

在解决正则表达式的问题上,没有任何其他的可以替代这个库。 实际上,re 是 Python 语言中最好的库之一。如果您对正则表达式足够了解,那么 re 库可以帮助您创造奇迹。它使您能够通过正则表达式轻松执行一些原本较为困难的操作。

# tempfile

可以将 tempfile 看作创建临时文件的库,这是一个优秀的内置库。

# itertools

排列与组合是此库中最有用的部分工具,但如果您进一步探索,您会发现它还有一些非常实用的功能,如 dropwhile、product、chain,以及 islice, itertools 能帮助您解决许多计算方面的问题。

# functools

这个库适用于喜欢函数式编程(functional programming)的开发人员。它包含许多函数,可以帮助您以更加函数式的思维来编写代码。最常用的 partial 就在这个库中。

# sys 和 os

当需要执行特定系统或操作系统级别的操作时,sys 和 os 库可以让您在系统上做很多令人惊讶的操作。

# subprocess

该库可帮助您在系统上轻松创建多个进程。它使用简单,可用于创建多个进程并使用多种方法处理它们。

# logging

如果没有优秀的日志记录功能,任何大型项目都不可能成功。Python 的日志库可帮助您轻松地在日志中添加记录。它还能通过不同的方法输出日志,如控制台、文件,或网络。

# Json

JSON 是通过网络传递信息和 API 调用的事实标准。Python 中的 Json 库在不同场景中做得很好。Json 库的接口易于使用,而且它的文档也写得非常好。

# Pickle

pickle 在日常编码中可能比较少见,但每当您需要序列化和反序列化 Python 对象时,没有比 pickle 更好的库了。

# future

这是一个伪模块,它支持与当前解释器不兼容的新语言特性。因此,您可能会考虑将他们投入到需要使用到未来版本的代码中。 例如示例2-15。

示例2-15. 使用 __future__

import __future__
import division

注意: Python 有丰富的库,可以帮您解决许多问题。第一步先要了解它们,弄明白它们可以用在什么地方。从长远来看,熟悉 Python 内置库将会让您受益匪浅。

现在,您已经探索了一些 Python 中常见的数据结构,下面让我们深入探讨一种在 Python 中最常用的数据结构:字典(dict)。如果您正在编写专业的 Python代码,那么您肯定会用字典。让我们进一步来了解它吧!

# 利用字典

字典是 Python 中最常用的数据结构之一。使用字典可以快速地访问数据。对于字典,Python 有精巧且使用简单的内置库。在本节中,您将详细了解字典中最有用的部分功能。

# 何时使用字典或其他数据结构

在考虑如何映射数据时,可以将字典作为代码中的数据结构。

如果您存储的数据需要某种映射,并且需要快速访问,那么使用字典将是明智的选择;但是,并不是每一个数据存储都要用到字典。

假如您需要一个额外的类或一个对象,或者当您不想要数据结构发生变化时,请使用元组或命名元组。在编写代码时,请仔细斟酌在代码中用哪一种数据结构。

# collections

collections (集合)是 Python 中非常实用的模块之一。它是一种高性能数据类型。collections 中含有许多接口,这些接口在使用字典执行不同任务时非常实用。所以,让我们来看看在 collections 中的一些重要工具。

# Counter

Counter (计数器)为您提供了一种便利的方式来统计相似的数据。如2-16中的例子所示。

示例2-16. 计数器

from collections import Counter

contries = ["Belarus", "Albania", "Malta", "Ukrain", "Belarus", "Malta", "Kosove", "Belarus"]
Counter(contries)
>>> Counter({'Belarus': 2, 'Albania': 1, 'Malta': 2, 'Ukrain': 1, 'Kosove': 1})

Counter 是 dict 的一个子类。它是一个有序集合,其元素以字典“键”的形式存储,而它的计数以“值”的形式存储,这是最有效的计算数值的方法之一。Counter 内含有多种实用的方法,例如 most_common() ,顾名思义,返回最常见的元素及其计数。请参阅示例 2-17。

示例2-17. 计数器中的 most_count() 方法

from collections import Counter

contries = ["Belarus", "Albania", "Malta", "Ukrain", "Belarus", "Malta", "Kosove", "Belarus"]
contries_count = Counter(contries)
>>> Counter({'Belarus': 2, 'Albania': 1, 'Malta': 2, 'Ukrain': 1, 'Kosove': 1})
contries_count.most_common(1)
>>> [('Belarus', 3)]

其他方法如 elements(),会返回一个迭代器,其中元素的重复次数与计数的值相同。

# deque

如果要创建队列或栈,请考虑使用 deque。它支持从左向右追加值,也支持从另一端以线程安全的、内存高效的方式追加和弹出,且时间复杂度都为 O(1)。

deque 可以用方法 append(x) 从右侧添加元素,appendleft(x) 从左侧添加元素,clear() 删除所有元素,pop() 从右侧删除元素,popleft() 从左侧删除元素,还有 reverse() 将元素反转。让我们来参考一些使用范例。如示例2-18。

示例2-18. deque 队列

from collections import deque

# 创建一个 deque
deq = deque("abcdefg")

# 遍历 deque 的元素
[item.upper() for item in deq]
>>> deque(["A", "B", "C", "D", "E", "F", "G"])

# 在右边添加元素
deq.append("h")
>>> deque(["A", "B", "C", "D", "E", "F", "G", "h"])

# 在左边添加元素
deq.appendleft("I")
>>> deque(["I", "A", "B", "C", "D", "E", "F", "G", "h"])

# 弹出最右边的元素
deq.pop()
>>> "h"

# 弹出最左边的元素
deq.popleft()
>>> "I"

# 清空 deque
deq.clear()
# defaultdict

defaultdict(默认字典)的工作方式与 dict(字典)类似,因为它是 dict 的子类。defaultdict 使用一个不带参数的“默认值函数”来初始化,它在没找到键的时候提供默认值。默认字典不会像字典一样引发 KeyError,任何不存在的键都将获得从“默认值函数”的返回值。

让我们来看看示例2-19中的简单示例。

示例2-19. defaultdict

from collections import defaultdict

# 定义一个无参数的“默认值函数”。当没找到键时,用它来返回默认值。
def defaultvalue():
    return 0

# 创建一个默认字典。使用上面定义的函数作为参数。
colors = defaultdict(defaultvalue)

# 尝试打印一个不存在的键,它将打印默认值。
colors["orange"]
>>> 0

print(colors)
>>> defaultdict(<function defaultvalue at 0x0000015913EE48B8>, {'orange': 0})
# namedtuple

namedtuple(命名元组)是 collection 模块中最受欢迎的工具之一,它是 tuple (元组)的子类,具有命名的域和固定的长度。命名元组可以在代码中任意位置完美替代元组。命名元组是不可变的列表,它使得阅读代码和访问数据变得更加容易。之前已经介绍过了命名元组,您可以参阅之前的内容了解更多信息。

# ordereddict

当您想要让字典的键保持某种次序时,可以使用 ordereddict(有序字典)。dict(字典)不能按插入顺序来排序,而这正是 ordereddict 的主要功能。在 Python3.6+ 中,dict 也有这个特性,它会默认地按照插入顺序排序。

示例2-20. 有序字典

from collections import OrderedDict

# 创建一个有序字典。
colors = OrderedDict()

# 赋值
colors["orange"] = "ORANGE"
colors["blue"] = "BLUE"
colors["green"] = "GREEN"

# 取值
[k for k, v in colors.items()]
>>> ["orange", "blue", "green"]

# 有序字典、默认字典,普通字典的对比

在前几节中我们已经触及过这些字典,现在让我们对比仔细看看这些不同类型的字典。

有序字典(OrderedDict)和默认字典(defaultdict)都是普通字典类(dict)的子类,它们在普通字典的基础上增加了一些新功能,使它们有别于普通字典。但是它们也拥有普通字典所具备的功能。在 Python 中存在这些不同的字典是有原因的。为了充分利用这些库,下面我将介绍如何使用这些不同的字典。

从 Python 3.6 开始,字典(dict)开始按照插入顺序排序,这实际上降低了有序字典(OrderedDict)的用途。

现在,让我们来讨论一下在 Python 3.6 版本之前的有序字典。有序字典在将值插入字典后还会按顺序排列。有时候如果您想按顺序访问数据,可以使用有序字典。使用有序字典与普通字典相比,并没有额外的消耗,两者性能上是一样的。

假设您希望存储某些编程语言和它首次引入时的年份,可以使用有序字典。然后按编程语言与年份的插入顺序,获取信息,如示例2-21所示。

示例2-21. 有序字典

from collections import OrderedDict

# 创建一个有序字典
language_found = OrderedDict()

# 插入键值对
language_found["Python"] = 1990
language_found["Java"] = 1995
language_found["Ruby"] = 1995

# 取值
[k for k, v in langauge_found.items()]
>>> ["Python", "Java", "Ruby"]

有时用户会希望在访问或在字典中插入键时,将默认值分配给键。在普通字典中,如果该键不存在,则会出现 KeyError。但是,在默认字典中,会创建一个全新的键,参考示例2-22。

示例2-22. 默认字典

from collections import defaultdict

# 创建一个默认字典
language_found = defaultdict(int)

# 尝试打印一个不存在的键的值
language_found["golang"]
>>> 0

在这里,当您调用默认字典并尝试访问 golang 键时,它并不存在。在内部实现中,默认字典将会调用在构造函数中传入的函数对象(在上例中为 int )。 这是一个可调用对象,它包括函数和类型对象。因此,传递给默认字典的 int 和 list 都是函数。当您尝试访问不存在的键时,它将调用此传入的函数,并将其返回值作为新键的值。

如您所知,字典是 Python 中“键-值”对的集合。许多高级库(例如默认字典和有序字典)都是在字典的基础上构建的,增加了一些新特性,且在性能方面并没有额外的代价。字典的运行速度会略快,但大多数项目总会有各种各样的差异。因此,在为这些问题编写解决方案时,可以考虑使用它们。

# 用字典实现 switch 语句

Python 没有 switch 关键字。但是,Python 有很多可以实现此功能更简洁的方式。您可以利用字典生成 switch 语句,并且当您在某些条件下有多个选项可供选择时,应该考虑以这种方式编写代码。

考虑一个根据特定国家的税收规则计算每个国家税收的系统。有多种方法可以做到这一点,然而,拥有多个选项最困难的部分是不要在代码中添加多个 if else 条件,让我们看看如何用更精美的方式使用字典来解决这个问题。参考示例2-23。

示例2-23. 使用字典的 switch 语句

def tanzania(amount):
    calculate_tax = <Tax Code>
    return calculate_tax

def zambia(amount):
    calculate_tax = <Tax Code>
    return calculate_tax

def eritrea(amount):
    calculate_tax = <Tax Code>
    return calculate_tax

contry_tax_calculate = {
    "tanzania": tanzania,
    "zambia": zambia,
    "eritrea": eritrea,
}

def calculate_tax(country_name, amount):
    country_tax_calculate["contry_name"](amount)

calculate_tax("zambia", 8000000)

在这里,您只需使用一个字典来计算税额,这使得您的代码比使用传统的 switch 语句更优雅、更具可读性。

# 合并两个词典的方法

假设您有两个字典要合并。与之前的版本相比,在 Python 3.5+ 版本后会更简单。合并任何两个数据结构都是十分复杂的,因为不仅需要注意内存的使用,还要留意合并数据结构时数据的丢失。如果使用额外的内存来保存合并的数据结构,那么应该考虑到字典中的数据大小,并了解系统的内存限制。

丢失数据也是一个问题。您可能会发现由于特定数据结构的限制,部分数据已经丢失了。例如,在字典中不能有重复的键。所以,在进行合并操作时一定要留意上述问题。

在 Python3.5+ 中,您可以按照示例2-24所示进行操作。

示例2-24. Python 3.5+ 中的字典合并

salary_first = {"Lisa": 238900, "Ganesh": 8765000, "John": 3450000}
salary_second = {"Albert": 3456000, "Arya": 987600}
{**salary_first, **salary_second}
>>> {"Lisa": 238900, "Ganesh": 8765000, "John": 345000, "Albert": 3456000, "Ary": 987600}

而在 3.5 版本前的 Python 中,您需要多加一点工作。参考示例2-25。

示例2-25. Python 3.5 之前的字典合并

salary_first = {"Lisa": 238900, "Ganesh": 8765000, "John": 3450000}
salary_second = {"Albert": 3456000, "Arya": 987600}
salary = salary_first.copy()
salary.update(salary_second)
>>> {"Lisa": 238900, "Ganesh": 8765000, "John": 345000, "Albert": 3456000, "Ary": 987600}

Python 3.5+ 具有PEP 448,它建议扩展使用迭代解包运算符 * 和字典解包运算符 **。这无疑使代码更具可读性,这不仅适用于字典和也适用于 Python 3.5+ 以后的列表。

# 漂亮地打印字典

Python 有一个称为 pprint 的模块,我们可以用它来实现漂亮的打印效果。您需要导入 pprint 以执行该操作。

pprint 实现了为打印任何数据结构时提供缩进的选项,它可以应用到您的数据结构中。参考示例2-26。

示例2-26. 字典的 pprint

import pprint

pp = pprint.PrettyPrinter(indent=4)
pp.pprint(colors)

这可能不一定如预期那样正常工作,因为复杂的字典有很多的嵌套和大量的数据。这种情况下,您可以考虑使用 JSON,如示例2-27所示。

示例2-27. 使用 json 打印字典

import json

data = {'a':12, 'b':{'x':87, 'y':{'t1': 21, 't2':34}}
json.dumps(data, sort_keys=True, indent=4)

# 小结

数据结构是每种编程语言的核心。就像在本章中所提到的一样,Python 提供了许多能存储和操作的数据结构。Python 提供了大量的数据结构作为工具,用于对不同类型的对象或数据集执行各种操作。作为 Python 开发人员,了解不同类型的数据结构十分重要,它能在您编写应用程序时帮您做出正确的决策,尤其是在资源密集型的问题上。

我希望这一章已经让您意识到 Python中最有用的一些数据结构。熟悉不同类型的数据结构及其不同的操作和功能,就像在您的工具箱中配置了不同类型的工具,它能帮助您成为一个更好的开发人员。

# 第3章 编写更好的函数和类

函数和类是 Python 语言的核心部分。您从事软件开发职业生涯中编写的全部代码都是由函数和类构成的。在本章中,您将学习有助于提高代码可读性和简洁性的最佳实践。

在编写函数和类时,思考函数和类的边界与结构很重要。考虑清楚这个函数或类所要解决的问题和用例,将有助于您编写更好的函数和类。始终牢记单一责任原则。

# 函数

正如您所知道的,Python 中任何东西都是一个对象,函数也不例外。Python 中的函数非常灵活,因此一定要仔细编写。这里我将讨论一些用 Python 编写函数的最佳实践。

在 Python 中,通常是在 def 子句下编写代码块来定义函数或方法。在这里我不是在说 lambda 函数,因为前几章中已经介绍过了。

# 创建小函数

编写函数时,总是编写一个只执行唯一一项单个任务的函数。但我们如何确保函数只执行一个操作,以及如何测量函数是大是小呢?是否能用行数或者字符数来衡量函数的大小呢?

当然,它更多是和任务相关。我们想要让函数只执行一项任务,但该任务又是构建在多个子任务上。作为开发人员,您必须想清楚何时将子任务抽取成为单独的函数。没有人能够帮您回答这些问题。您必须批判性地仔细分析函数,并且决定何时将它们分解为多个函数。这是一项必须通过持续分析代码,在代码中寻找“臭味”来习得的技能,换句话说,就是查找很难阅读和理解的代码。请看示例3-1。

示例3-1. 唯一的电子邮件

def get_unique_emails(file_name):
   """Read the file data and get all unique emails."""
    emails = set()
    with open(file_name) as fread:
        for line in fread:
            match = re.findall(r'[\w\.-]+@[\w\.-]+', line)
            for email in match:
                emails.add(email)

    return emails

在示例3-1中,get_unique_emails 在这里执行了两个不同的任务,先循环访问指定文件读取每一行,然后执行正则表达式匹配每行上的电子邮件。我们可以在此观察到两件事:第一个当然是函数所执行的任务数量,第二个是可以进一步分解它,创建一个读取文件或读取行的通用函数。我们可以将此函数分解成为两个不同的函数,一个函数读取文件,另一个函数读取行。作为开发人员,您将决定是否有必要对这个函数进行分解,从而编写更加简洁的代码。参见示例3-2。

示例3-2. 将函数分解成为不同的函数

def get_unique_emails(file_name):
    """ Get all unique emails. """
    emails = set()
    for line in read_file(file_name):
        match = re.findall(r'[\w\.-]+@[\w\.-]+', line)
        for email in match:
            emails.add(email)
    return emails

def read_file(file_name):
    """ Read file and yield each line. """
    with open(file_name) as fread:
        for line in fread:
            yield line

在示例3-2中,函数 read_file 现在是一个通用函数,它可以接受任意一个文件名并读取生成每一行,而 get_unique_emails 函数再在每一行上执行操作,查找唯一的电子邮件。

在这里,我们创建的 read_file 是个生成器函数,但如果您希望它返回一个列表也是可以的。主要思想就是,考虑可读性和单一责任原则,再分解函数。

注意: 建议您先编写实现该功能的代码,一旦实现了该功能并能正常工作后,就可以开始考虑将函数分解为多个子函数,让代码更加清晰易读。再有,记住遵循好命名约定。

# 返回生成器

您可能在示例3-2的代码中已经注意到了,我使用了 yield 而没有用任何其他的数据结构,如列表(list)或元组(tuple)。此处不使用其他数据结构的主要原因是,我们不确定文件有多大,如果处理大型文件,有可能内存会耗尽。

生成器(generator)是使用了 yield 关键字的函数(如第1章的示例1-22所示),read_file 是一个生成器函数。之所以生成器有用,主要是两个原因:

  1. 当生成器调用函数时,它们会立即返回迭代器,而不是运行整个函数,您可以在该函数上执行不同的操作,如循环或转换为列表(在第1章中示例1-22,在迭代器上循环)。完成后,它会自动调用内建的函数next() 并返回到调用函数 read_file 在 yeild 关键字的下一行 。它还能让代码更易于阅读和理解。

  2. 在列表或其他数据结构中,Python 在返回之前要把数据保存在内存中,如果数据量大,则可能导致内存不足而崩溃。生成器不会有此问题。因此,当您有大量数据需要处理或事先不确定数据量大小时,建议使用生成器而不是其他数据结构。

现在,我们可以修改示例3-2的 get_unique_emails 函数代码,使用 yield 而不是列表,如示例 3-3 所示。

示例3-3. 将函数分解成为不同的函数

def get_unique_emails(file_name):
    """ Get all unique emails. """
    for line in read_file(file_name):
        match = re.findall(r'[\w\.-]+@[\w\.-]+', line)
        for email in match:
            yield email

def read_file(file_name):
    """ Read file and yield each line."""
    with open(file_name) as fread:
        for line in fread:
            yield line

def print_email_list():
    """ Print list of emails. """
    for email in get_unique_emails('duplicate_emails'):
        print(email)

在这里,我们避免了从 get_unique_emails 函数返回列表中全部电子邮件的风险。

这里我不是暗示说,您应该在每个返回函数中使用生成器。如果事先知道要返回的数据量大小,则使用列表、元组、集合、字典可能更容易。例如,在第 1 章的示例1-22 中,如果您返回 100 封电子邮件,最好使用列表或其他数据结构而不是使用生成器。但是,如果不确定数据量大小,请考虑使用生成器,这将避免生产环境中大量的内存问题。

注意: 您应当掌握 Python 生成器。我还没有看到很多专业开发人员在代码中使用生成器,但您应该考虑它的优点。它能让代码更简洁,并且避免出现内存问题。

# 引发异常而不是返回 None

我在第 1 章中详细讨论了异常,因此这里不会谈论所有例外情形。本节只讨论在出现错误时引发异常,而不是从函数返回 None。

异常是 Python 的一项核心特性。使用异常时有几件事需要考虑。

首先,我注意到,当代码中发生任何意外情况时,许多程序员要么返回"无",要么用日志记录内容。有时,这种策略可能很危险,因为它可以隐藏 Bug。

此外,我还看到函数返回 None 或一些随机值,而不是引发异常,这使得您的代码让调用方函数感到困惑,并且容易出错。参见示例3-4。

示例3-4. 返回 None

def read_lines_for_python(file_name, file_type):
    if not file_name or file_type not in ("txt", "html"):
        return None
    lines = []
    with open(file_name, "r") as fileread:
        for line in fileread:
            if "python" in line:
                return "Found Python"

if not read_lines_for_python("file_without_python_name", "pdf"):
    print("Not correct file format or file name doesn't exist")

在示例3-4中,您无法确定 read_lines_for_python 是否返回 None,因为该文件没有任何 Python 问题。这种代码可能会导致出现意料之外的 Bug,在大型代码库中查找 Bug 可能会令人头疼。

因此,当您编写代码时,如果是因为意外情形返回 None 或其他值的情况下,请考虑引发异常。在您的代码变大时,它会节省您追踪 Bug 的时间。

请考虑按下面的方式编写代码,见示例3-5。

示例3-5. 引发异常而不是返回 None

def read_lines_for_python(file_name, file_type):
    if file_type not in ("txt", "html"):
        raise ValueError("Not correct file format")
    if not file_name:
        raise IOError("File Not Found")

    with open(file_name, "r") as fileread:
        for line in fileread:
            if "python" in line:
                return "Found Python"

if not read_lines_for_python("file_without_python_name", "pdf"):
    print("Python keyword doesn't exists in file")

Result: >> ValueError("Not correct file format")

每当代码失败时,您都会通过查看异常原因知道为什么失败了,引发异常有助于您及早捕获 Bug,而不是猜测。

注意: Python 是一种动态语言,因此您需要小心编写代码,尤其是在代码中发现意外值时。None 是从函数返回的默认值,但不要每一种意外的情况下过度使用它。在使用 None 之前,考虑是否可以引发异常,让代码更简洁。

# 使用默认和关键字添加行为参数

关键字参数让 Python 代码更加可读和简洁。关键字参数可用于为函数提供默认值,也可以用作关键字。参见示例3-6。

示例3-6. 默认参数

def calculate_sum(first_number=5, second_number=10):
    return first_number + second_number

calculate_sum()
calculate_sum(50)
calculate_sum(90, 10)

在这里,您已经使用关键字参数来设定默认值,但在调用函数时,您可以选择是否需要默认值或用户定义的值。

关键字参数在大型代码库或具有多个参数的函数中非常有用。关键字参数可以让代码更容易理解。

在此,让我们看一个示例,您需要使用电子邮件内容中的关键字查找垃圾邮件,如示例3-7 所示。

示例3-7. 无关键字参数

def spam_emails(from, to, subject, size, sender_name, receiver_ name):
    #函数的代码...

如果您不使用任何关键字参数调用 spam_emails(),它看起来像示例3-8。

示例3-8. 无关键字参数

spam_emails("ab_from@gmail.com",
            "nb_to@yahoo.com",
            "Is email spam",
            10000, "ab", "nb")

如果您只看示例3-8,将很难猜测所有这些参数对此函数意味着什么。如果您看到函数调用有许多参数,为了便于阅读,最好使用关键字参数来调用它,如示例3-9 所示。

示例3-9. 带关键字参数

spam_emails(from="ab_from@gmail.com",
            to="nb_to@yahoo.com",
            subject="Is email spam",
            size=10000,
            sender_name="ab",
            receiver_name="nb")

这不是一个绝对的准则,但请考虑对两个以上参数的函数调用使用关键字参数。将关键字参数用于调用函数可以让新开发人员更容易理解代码。

在 Python 3+ 中,可以通过定义如下函数来强制关键字参数到调用函数中:

def spam_email(from, *, to, subject, size, sender_name, receiver_name)

# 不显式返回None

默认情况下,Python 函数在没有显式地返回时,将返回 None。参见示例3-10。

示例3-10. 默认返回 None

def sum(first_number, second_number):
    sum = first_number + second_number

sum(80, 90)

这里的 sum 函数默认返回 None。然而,也有许多人显式地在函数代码中返回 None,如下面的示例 3-11。

示例3-11. 显式返回 None

def sum(first_number, second_number):
    if isinstance(first_number, int) and isinstance(second_ number, int):
        return first_number + second_number
    else:
        return None

result = sum(10, "str")   # 返回 None
result = sum(10, 5)       # 返回 15

在这里,您期望的结果是 sum 函数返回一个值。然后,代码实现可能会返回 None 或两个数字的总和。因此,您始终需要检查结果是否为“None”,这让代码中出现“噪音”,随着时间的推移,会让代码会变得更加复杂。

在这种情况下,您也许更希望引发异常。参见示例3-12。

示例3-12. 引发异常而不是返回 None

def sum(first_number, second_number):
    if isinstance(first_number, int) and isinstance(second_ number, int):
        return first_number + second_number
    else:
        raise ValueError("Provide only int values")

让我们来看第二个示例。如示例3-13 所示,如果给定的输入不是列表,则显式返回 None。

示例3-13. 显式返回None

def find_odd_numbers(numbers):
    odd_numbers = []
    if not isinstance(numbers, list):
        return None
    for item in numbers:
        if item % 2 != 0:
            odd_numbers.append(item)
    return odd_numbers

num = find_odd_numbers([2, 4, 6, 7, 8, 10])    # 返回 7
num = find_odd_numbers((2, 4, 6, 7, 8, 10))    # 返回 None
num = find_odd_numbers([2, 4, 6, 8, 10])       # 返回 []

默认情况下,如果找不到奇数,则默认为 None。如果数字类型不是列表,则函数还会返回 None。

您可以考虑这样重写此代码,如示例3-14 所示。

示例3-14. 不显式返回 None

def find_odd_numbers(numbers):
    odd_numbers = []
    if not isinstance(numbers, list):
        raise ValueError("Only accept list, wrong data type")
    for item in numbers:
        if item % 2 != 0:
            odd_numbers.append(item)
    return odd_numbers

num = find_odd_numbers([2, 4, 6, 7, 8, 10])    # 返回 7
num = find_odd_numbers((2, 4, 6, 7, 8, 10))    # 抛出 ValueError 异常
num = find_odd_numbers([2, 4, 6, 8, 10])       # 返回 []

现在,当您检查 num 值时,您知道函数调用返回[]的原因。显式地这样做可以让读者知道在未找到奇数时会期待返回的结果。

# 编写函数时要有预见性

程序员都容易犯错,所以不能保证您在编写代码时不会犯错。考虑到这一事实,您可以在编写函数时采取创造性措施,在开始部署到生产时防止或暴露出代码中的 Bug,或者方便您在生产环境中查找 Bug。

作为程序员,在将代码部署到生产环境之前,有两件事以确保代码质量:

  • 记录日志
  • 单元测试
# 记录日志

让我们先讨论一下记录日志。当调试代码时,日志记录可以极大地帮助到您,尤其是在生产环境中事先不知道哪里可能出错。在任何成熟的项目中,尤其是大中型项目中,如果没有记录日志,很难保持项目长时间可维护。在代码中记录日志可使代码在生产环境中出现问题时更易于调试和诊断。

让我们来看看日志记录代码通常的样子吧,如示例3-15所示。

示例3-15. 在 Python 中记录日志

# 引入日志包
import logging

logger = logging.getLogger(__name__)    # 创建一个定制的 logger
handler = logging.StreamHandler         # 使用 stream handler

# 设置日志级别
handler.setLevel(logging.WARNING)
handler.setLevel(logging.ERROR)
format_c = logging.Formatter("%(name) - %(levelname) - %(message)")
handler.setFromatter(format_c)          # 将 formater 添加到 handler

logger.addHandler(handler)

def division(divident, divisor):
    try:
        return divident/divisor
    catch ZeroDivisionError:
        logger.error("Zero Division Error")

num = divison(4, 0)

Python 有一个完善且可自定义的日志记录模块。您可以在代码中定义不同的日志级别,如果项目中有不同类型的错误,则可以根据情况的严重程度记录该错误。例如,用户帐户创建期间的异常其严重性将高于发送营销电子邮件时失败的严重性。

Python 日志模块是一个成熟的库,它提供了大量功能,可根据需要配置使用。

# 单元测试

单元测试是代码中最重要的部分之一。专业地说,在代码中强制进行单元测试可以防止您引入 Bug,并让您在推送到生产环境之前对代码有信心。Python 中有很多很棒的库,可以让您轻松地编写单元测试。其中一些流行的有 py.test 和 unittest 库。我们在第 8 章中将详细讨论它们。

下面是 Python 中单元测试的代码示例:

unittest

import unittest

def sum_numbers(x, y):
    return x + y

class SimpleTest(unittest.TestCase):
    def test(self):
        self.assertEqual(sum_numbers(3, 4), 7)

py.test

def sum_numbers(x, y):
    return x + y

def test_sum_numbers():
    assert func(3, 4) == 7

正确编写单元测试,它可以扮演一些关键的角色:

  • 您可以将单元测试用作代码的文档,这在您重新来看代码或者有新开发人员加入项目时非常有用。
  • 它可以让您对代码有信心,即它能够执行预期的行为。当某函数有单元测试时,可以确保代码中的修改不会破坏函数原有功能。
  • 它可以防止旧错误潜入代码中,因为代码在推送到生产环境之前运行了单元测试。

一些开发者使用测试驱动开发 (Test Driven Development, TDD) 流程,超越了单元测试,但这并不意味着只有 TDD 应该有单元测试。交付给用户使用的每个项目都应该有单元测试。

注意: 在任何成熟的项目中,日志记录和单元测试都必须要有。它们可以帮助您防止代码中的 Bug。Python 为您提供了一个日志记录的 logging 库,该库非常成熟。对于单元测试,Python 有很多选项可供选择,其中 pytest 和 unittest 是比较受欢迎的选项。

# 使用 Lambda 作为单个表达式

Lambdas 是 Python 中有趣的功能,但我建议您避免使用它们。我见过很多过度使用或误用 lambdas 的代码。PEP8 建议不要编写示例3-16 中显示的代码。

示例3-16。Lambda

sorted_numbers = sorted(numbers, key=lambda num: abs(num))

要编写这样的代码,见示例3-17。

示例3-17. 使用普通函数

def sorted_numbers(numbers):
    return sorted(numbers, reverse=True)

这里有几个理由避免使用 lambda。

  • 它使代码更加难以阅读,这在单行表达式中很重要。例如,以下代码让许多开发者对 lambdas 感到不舒服:

    sorted(numbers, key=lambda num: abs(num))
    
  • Lambda 表达式很容易被误用。开发人员经常会编写单行表达式来使代码看起来“很聪明”,这使得其他开发人员很难理解。在现实世界中,它可能会导致代码中出现更多错误。参见示例3-18。

示例3-18. 滥用 Lambda 函数

import re

data = [abc0, abc9, abc5, cba2]
convert = lambda text: float(text) if text.isdigit() else text
alphanum = lambda key: [convert(c) for c in re.split('([-+]?[0-9]*\.?[0-9]*)', key) ]
data.sort( key=alphanum )

在示例3-18中,它误用了 lambda 函数,而且更加难以理解是否使用了函数。

我建议在以下情况下使用 lambda:

  • 当团队中的每个人都理解 lambda 表达式时
  • 当它使代码比使用函数更容易理解时
  • 当您执行的操作是琐碎的,并且函数不需要命名时

#

接下来,我将讨论类。

# 类的正确大小

您在使用任何语言进行面向对象编程时,您可能会想知道类的正确大小是怎样的。

在编写类时,永远记住单一职责原则(Single Responsibility Principle, SRP)。如果您正在编写一个有明确定义的职责和确定的边界的类时,您不必担心类的每一行代码。有些人认为一个文件里面定义一个类是个好办法。但是,我看到文件本身明显太大。每个文件一个类,也可能会令人困惑和误导。如果您看到一个类正在做多个事情,这意味着它是创建新类的合适时机。在责任方面画一条边界线是好事,我们在类中添加新代码时必须小心,不要跨越责任的界限。

仔细审查每个方法和每一行代码,思考该方法或代码的一部分是否适合该类定义的总体责任,是一种好的调查类结构的方法。

假设您有一个 UserInformation 类。你不想将每个用户的付款信息和订单信息添加到此类。这些信息只是和用户相关,但付款信息和订单信息更多的是用户付款的活动相关。在编写类之前要定义清楚这些职责。可以定义 UserInformation 类仅负责保存用户信息的状态,而不是用户的活动。

重复代码是另一个提示,表明类可能正在执行超越它应该做的事情。例如,如果您有一个名为 Payment 的类,并且正在编写十几行代码来访问数据库,其中包括创建与数据库的连接、获取用户信息和获取用户信用卡信息,则可能需要考虑创建另一类来访问数据库。然后,任何其他类都可以使用此类来访问数据库,而无需重复相同的代码或方法。

我建议在编写代码之前对类的范围有一个明确的定义,保持类的范围定义将解决大多数类大小的问题。

# 类结构

我更喜欢按这样的顺序排列类的结构:

  1. 类变量
  2. __init__
  3. Python 内置的特殊方法(__call____repr__等)
  4. 类方法
  5. 静态方法
  6. 属性
  7. 实例方法
  8. 私有方法

例如,您可能希望具有类似于示例3-19 的代码。

示例3-19. 类结构

class Employee(Person):
    POSITIONS = ("Superwiser", "Manager", "CEO", "Founder")

    def __init__(self, name, id, department):
        self.name = name
        self.id = id
        self.department = department
        self.age = None
        self._age_last_calculated = None
        self._recalculated_age()

    def __str__(self):
        return ("Name: " + self.name + "\nDepartment: " + self.department)

    @classmethod
    def no_position_allowed(cls, position):
        return [t for t in cls.POSITIONS if t != position]

    @staticmethod
    def c_positions(position):
        return [t for t in cls.TITLES if t in position]

    @property
    def id_with_name(self):
        return self.id, self.name

    def age(self):
        if(datetime.date.today() > self._age_last_recalculated): self.__recalculated_age()
        return self.age

    def _recalculated_age(self):
        today = datetime.date.today()
        age = today.year - self.birthday.year
        if today < datetime.date(today.year, self.birthday.month, self.birthday.year):
            age -= 1
        self.age = age
        self._age_last_recalculated = today
# 类变量

通常您希望在顶部看到类变量,因为这些变量要么是常量,要么是默认实例变量。这向开发人员表明有这些常量和变量已可供使用,因此,先于任何其他实例方法或构造函数之前,这是保留在类顶部的宝贵信息。

# __init__

这是一个类的构造函数,调用方法/类需要知道如何使用该类。__init__ 就像是一扇门,通过它可以看到如何调用类以及类中都有哪些状态。__init__ 提供了在开始使用类之前,要提供的主要输入参数的信息。

# 特殊 Python 方法

特殊方法更改了类的默认行为或是提供额外功能,因此将它们放在类的顶部,使读者了解到类的某些自定义功能。此外,这些被重写的元类还让您知道类试图通过更改 Python 的默认行为来执行不同的事情。将它们放在顶部,用户可以在读取类代码的其余部分之前,记住类修改了的行为。

# 类方法

类方法是用来作为另一种构造函数,因此将其放在 __init__ 附近是有意义的。它告诉开发人员还可以使用类方法,而无需使用 __init__ 构造函数来创建实例。

# 静态方法

静态方法绑定到类,而不是类的对象(如类方法)。它们无法修改类状态,因此在顶部添加它们,可以使读者了解这些用于特定目的的方法。

# 实例方法

实例方法在类中添加行为,因此开发人员期望,如果类具有特定行为,则实例方法将成为类的一部分。因此,将它们保留在特殊方法之后,将使读者更容易理解代码。

# 私有方法

由于 Python 没有任何私有关键字概念,因此在方法名称中使用 _(方法名) 告诉读者这是一个私有方法,因此不要使用它。您可以用实例方法将其保留在底部。 我建议将私有方法放在实例方法周围,以便读者更容易理解代码。您可以将私有方法放在实例方法之前或者之后,即在离它被调用的方法最近处。

注意: Python 是一种面向对象的语言,在 Python 中编写类时,应将其视为此类语言。以下面向对象编程的所有规则对你不会有坏处。在编写类时,要让读者很容易理解这个类。如果其中一个方法正在使用实例方法,则实例方法应彼此接近。私有方法也是如此。

# 使用@property的正确方法

@property 装饰器(在第5章中讨论)是 Python 获取和设置值的有用功能之一。有两处可以考虑在类中使用@property:隐藏属性后面的复杂代码,以及设置属性值的合法性校验。参见示例3-20。

示例3-20. 类属性装饰器

class Temperature:
    def __init__(self, temperature=0):
        self.temperature = temperature

    @property
    def fahrenheit(self):
        self.temperature = (self.temperature * 1.8) + 32

temp = Temperature(10)
temp.fahrenheit
print(temp.temperature)

此代码有什么问题吗?这里方法 fahrenheit 使用了属性修饰,但该方法却修改了 self.temperature 变量的值,而不是返回某个值。使用属性装饰器时,请确保返回一个值。这将让类/方法在调用此使用属性装饰器的方法时,符合它的期望。因此,请确保返回该值并使用属性装饰器方法作为代码中的 getter,如示例3-21 所示。

示例3-21. 类属性装饰器

class Temperature:
    def __init__(self, temperature=0):
        self.temperature = temperature

    @property
    def fahrenheit(self):
        return (self.temperature * 1.8) + 32

属性装饰器还用于验证/筛选值。它与其他编程语言如 Java 中的 setter 相同。在 Python 中,您可以使用属性装饰器验证/筛选某个信息段。我见过很多地方,开发人员通常没有意识到 Python 中 setter 属性装饰器的强大。当我们恰当地使用它时,可以让代码更易读,还可能将您从容易忘记的某些边角错误情况中挽救出来。

在示例3-22中,使用了 Python 的属性装饰器。通过显示设置属性值时要验证的内容,提高了代码可读性。

在此示例中,有一个名为 Temperature 的类,该类设置温度值(以华氏度为单位)。使用属性装饰器获取和设置温度值,它使得 Temperature 类更容易验证调用方的输入。

示例3-22. 类属性装饰器

class Temperature:
    def __init__(self, temperature=0):
        self._temperature = temperature

    @property
    def fahrenheit(self):
        return self._temperature

    @fahrenheit.setter
    def fahrenheit(self, temp):
        if not isinstance(temp, int):
            raise("Wrong input type")
        self._temperature = (self.temp * 1.8) + 32

在这里,fahrenheit 的 setter 方法会先验证以华氏度为单位的温度输入,如果调用类在输入错误的情况下可能会引发异常。调用类现在只需调用 fahrenheit 方法(不提供任何输入参数)即可读取华氏度值。

请确保在正确的上下文中使用属性关键字,并将它们视作编写代码的 getter 和 setter 更 Pythonic 的方式。

# 何时使用静态方法?

根据定义,静态方法与类相关,但不能访问任何特定于类的数据。在静态方法中不使用 self 或 cls。静态方法可以独立工作,不需要依赖于类的任何状态。区别于独立函数而言,这是使用静态方法时感到困惑的主要原因之一。

在 Python 中编写类时,您会把相似的方法分组,还会让方法以不同的变量来保持状态,同时,还希望用类的对象来执行一些不同的操作。然而,当您将方法变成为静态时,此方法将不能访问类的任何状态,也不需要对象或类变量来访问它。那么,何时该使用静态方法呢?

当您在编写类的时候,可能有一个能单独存在的方法作为函数,不需要类的状态来执行某个操作。有时,将其作为一个静态方法并作为类的一部分是有意义的。可以将此静态方法作为类要调用的工具方法。但是,你为什么不把这个功能作为类外部的独立函数呢?显然,您可以这样做,但将其保留在类中,可以让读者更轻松地将该函数与类相关联。让我们举一个简单的示例来理解这一点,如示例3-23所示。

示例3-23. 无静态方法

def price_to_book_ratio(market_price_per_share, book_value_per_ share):
    return market_price_per_share/book_value_per_share

class BookPriceCalculator:
    PER_PAGE_PRICE = 8

    def __init__(self, pages, author):
        self.pages = pages
        self.author = author

    @property
    def standard_price(self):
        return self.pages * PER_PAGE_PRICE

在这里,price_to_book_ratio 函数可以在不使用任何 BookPriceCalculator 的状态下工作,但将其保留在类 BookPriceCalculator 中可能有意义,因为它与 BookPricing 类相关。所以,你可以这样编写代码,如示例3-24 所示。

示例3-24. 有静态方法

class BookPriceCalculator:
    PER_PAGE_PRICE = 8

    def __init__(self, pages, author):
        self.pages = pages
        self.author = author

    @property
    def standard_price(self):
        return self.pages * PER_PAGE_PRICE

    @staticmethod
    def price_to_book_ratio(market_price_per_share, book_value_ per_share):
        return market_price_per_share/book_value_per_share

在这里,它作为一个静态的方法,不需要使用类的任何方法或变量,但它与 BookPriceCalculator 类相关联,因此它成了一个静态方法。

# 使用抽象类的继承

抽象是 Python 的特性之一。它确保子类以预期的方式实现类的继承。那么,在接口中设置抽象类的主要目的是什么?

  • 您可以使用抽象来编写接口类。
  • 如果不实现抽象方法,就无法使用接口。
  • 如果不遵循抽象类的规则,它将给出早期错误。

如果您以错误的方式实现抽象,将会违背面向对象编程的抽象规则。示例3-25显示了在 Python 中不完全遵守抽象规则的情况下编写抽象类的代码。

示例3-25. 错误的抽象类

class Fruit:
    def taste(self):
        raise NotImplementedError()

    def originated(self):
        raise NotImplementedError()

class Apple(Fruit):
    def originated(self):
        return "Central Asia"

fruit = Fruit("apple")
fruit.originated                       #Central Asia
fruit.taste
NotImplementedError

于是,代码的问题如下:

  • 您可以正常初始化 Apple 或 Fruit 类(不会收到任何错误),一旦创建类的对象,它就会抛出异常。
  • 代码可能已进入生产环境,您甚至还没有意识到它是一个不完整的类,直到您调用 taste 方法。

那么,为了在 Python 中定义抽象类以满足抽象类的理想要求,更好的方法是什么呢?Python 提供一个名为 abc 的模块来解决此问题,该模块执行您期望从抽象类中得到的一些功能。让我们使用 abc 模块来重新实现抽象类,如示例3-26 所示。

示例3-26. 正确的抽象类

from abc import ABCMeta, abstractmethod

class Fruit(metaclass=ABCMeta):
    @abstractmethod
    def taste(self):
        pass

    @abstractmethod
    def originated(self):
        pass

class Apple(Fruit):
    def originated(self):
        return "Central Asia"

fruite = Fruite("apple")
TypeError: "Can't instantiate abstract class concrete with abstract method taste"

使用 abc 模块可确保所有预期的方法都实现,为您提供可维护的代码,确保生产环境中没有“半成品”。

# 使用@classmethod访问类状态

除了 __init__ 构造函数之外,类方法可以作为一个替代品,灵活地创建类实例。

那么,在代码中何处使用类方法呢?如前所述,一个明显的地方是创建多个构造函数并传递类对象作为参数,这是在 Python 中使用工厂模式的最简单方法之一。

让我们考虑一个场景,您希望调用方法提供多种格式输入,返回一个标准值。序列化类就是一个很好的例子。考虑您有一个类,需要序列化 User 对象,返回用户的名字和姓氏。然而,难点是确保客户端的接口容易使用,且接口支持四种不同的格式:字符串,JSON,对象实例或文件。使用工厂模式可能是解决此问题的有效方法,这是类方法可能有用的地方。示例3-27显示了一个例子。

示例3-27. 序列化类

class User:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @classmethod
    def using_string(cls, names_str):
        first, second = map(str, names_str.split(" "))
        student = cls(first, second)
        return Student

    @classmethod
    def using_json(cls, obj_json):
        # parsing json object...
        return Student

    @classmethod
    def using_file_obj(cls, file_obj):
        # parsing file object...
        return Student

data = User.using_string("Larry Page")
data = User.using_json(json_obj)
data = User.using_file_obj(file_obj)

在这里,您可以创建一个 User 类和多个类方法,这些类方法像接口一样,方便客户端根据自己的数据情况来访问类的状态。

在构建有许多类的大型项目中,类方法是一个有用的功能。干净的接口有助于保持代码长期可维护。

# 使用公共属性而不是私有属性

如你所知,Python 没有任何类私有属性的概念。但是,您可能已经使用或看到用单下划线 _(var_name) 将变量标记为私有。您仍然可以访问这些变量,但这样做是被禁止的,因此 Python 社区一致认为 _(var_name) 变量或方法为私有变量或方法。

考虑到这一事实,我仍然建议不要在任何想要约束类变量的地方使用它,因为它会使代码变得繁琐和脆弱。

假设你有一个 Person 类将 _full_name 作为私有实例变量。为了访问 _full_name 私有实例变量,您创建了一个名为 get_name() 的方法,该方法允许调用方访问该私有实例变量,而不是直接访问它。参见示例3-28。

示例3-28. 在错误的地方使用 _

class Person:
    def __init__(self, first_name, last_name):
        self._full_name = f"${first_name} ${last_name}"

    def get_name(self):
        return self._full_name

per = Person("Larry", "Page")
assert per.get_name() == "Larry Page"

但是,这却是使变量私有化的错误方法。

如您所见,Person 类试图通过将其命名为 _full_name 来隐藏此属性,但这样做会使代码更加繁琐和难懂,即便代码的目的是限制用户仅访问 _full_name 变量。如果您正在考虑对所有其他私有变量执行此操作,这将会令代码变得复杂。想象一下,如果类中有很多私有变量,并且必须定义尽可能多的方法来访问私有变量,会发生什么情况。

只要您不想将类变量或方法暴露给调用方,就应该让它们成为私有。Python 不会强制对类变量和方法限制私有访问,让类变量和方法私有,将传达意思给调用方:此类变量和方法不应被访问或重载。

当您尝试继承某些公共类,并且您无法控制该公共类及其变量时,我建议在代码中使用双下划线 __(var_name) 名称。当您希望避免代码中的冲突时,最好使用 __(var_name) 命名。让我们考虑示例3-29中的简单示例。

示例3-29. 在公共类的继承中使用 __

class Person:
    def __init__(self):
        self.age = 50

    def get_age(self):
        return self.age

class Child(Person):
    def __init__(self):
        super().__init__()
        self.__age = 20

ch = Child()
print(ch.get_age())      # 50

# 小结

与 Java 等其他编程语言相比,Python 对变量/方法或类没有任何访问控制。尽管 Python 将一切视为公开,但是 Python 社区已就一些规则达成共识,包括私有和公开的概念。您应该掌握何时使用这些功能,以及何时避免它们,从而让自己的代码可读,且对其他开发人员也易懂。

# 第4章 使用模块和元类

模块(Module)和元类(Meta-class)是 Python 的重要特性。在大型项目中,理解模块和元类编程将有助于您编写出更整洁的代码。元类像是 Python 的一种隐藏功能,除非有特殊需求要用到它,否则您大可不必关注这些功能。模块可帮助您更好地组织代码或项目,使其结构更清晰。 模块和元类都是很大的概念,因此,很难在这里详细解释它们。在本章中,您将探索一些有关模块和元类编程的良好实践。

# 模块和元类

在开始之前,先简要说明一下 Python 中模块和元类的概念。 模块简单来说就是带有.py 扩展名的 Python 文件,该文件的名称就是模块名称,一个模块可以有多个方法或类。模块化思想就是在项目中在逻辑上将功能分离,如下所示:

users/
users/payment.py
users/info.py

这里 payment.pyinfo.py 模块从逻辑上将支付功能和用户信息分离,这样便于组织代码结构。 元类同样是个大概念,但简而言之,元类是类的设计蓝图。换句话说,类创建实例,在创建时会根据元类中定义的行为进行构建。

例如您需要在模块中以 awesome 开头创建所有的类,您可以在模块级别使用 __metaclass__ 来做到这一点。参见示例4-1。

示例4-1. 元类示例

def awesome_attr(future_class_name, future_class_parents, future_class_attr):
    """返回一个类对象,且每个属性以awesome开头"""
    # 选择所有不以'__'开头的属性,并在开头加上awesome
    awesome_prefix = {}
    for name, val in future_class_attr.items():
        if not name.startswith('__'):
            uppercase_attr["_".join("awesome", name)] = val
        else:
            uppercase_attr[name] = val

    # 用type创建此对象
    return type(future_class_name, future_class_parents, uppercase_attr)

__metaclass__ = awesome_attr  # 对此模块中的所有类起作用

class Example:  # 全局的 __metaclass__ 作用域不会超过此类
    # 但我们可以把 __metaclass__ 定义放在此处仅对此类其作用
    # 同时也对其子类起作用
    val = 'yes'

__metaclass__ 是众多元类概念中的一个特性,Python 提供了多个元类,您可以根据需要进行使用。更多元类信息请参见:https://docs.python.org/3/reference/datamodel.html (opens new window)

现在让我们来看一下,在考虑使用元类或构建模块编写代码时,应遵循的一些良好实践。

# 如何使用模块组织代码

在本节中,您将了解如何使用模块来组织代码。模块通过将互相关连的变量、方法和类放在一起来拆分整个项目的代码,换句话说,通过把代码放入不同的模块中,可将项目抽象为不同的层。

假设您需要构建一个用于购物的电子商务网站。要构建此类项目,您可以根据不同功能创建不同的层。在较高的层次上,您可以考虑为用户操作建立一个层。如选择一个产品、添加产品到购物车、以及进行支付。所有这些层可能只有一个或多个方法,可以将其保存在一个文件中或多个文件中。当您想要在另一个模块(如将产品添加到购物车)中使用较低层级的模块(如支付模块)时,您只需使用 import 语句 from ... import ... 来添加到购物车模块中。

让我们来看有助于创建更好模块的一些规则。

  • 保持模块名称简短。尽量不使用或少使用下划线。

    别这样做:

    import user_card_payment
    import add_product_cart
    from user import cards_payment
    

    要这样做:

    import payment
    import cart
    from user.cards import payment
    
  • 避免使用带有点(.)、大写字母或其它特殊字符的名称。所以要避免使用 credit.card.py 这类文件名。因为这类带特殊字符的名称容易让其他开发者感到迷惑,从而影响代码的可读性。PEP8 中也不建议使用这些特殊字符进行命名。

    别这样做:

    import user.card.payment
    import USERS
    

    要这样做:

    import user_payment
    import users
    
  • 当考虑代码的可读性时,使用非常显式的方式导入模块非常重要。

    别这样做:

    [...]
    from user import *
    [...]
    cart = add_to_cart(4)  # add_to_cart是user中定义的? 内建的? 还是当前模块中定义的?
    

    要这样做:

    from user import add_to_cart
    [...]
    x = add_to_cart(4)  # 如果当前模块未定义过add_to_cart,那么就是user中的add_to_cart
    

    再好一些,可以只有做:

    import user
    [...]
    x = user.add_to_cart(4)  # add_to_cart很显然是user中的方法
    

明确指出模块来自何处有助于提高可读性。如上面的示例中,user.add_to_cart 可以确定add_to_cart 方法来自何处。

充分利用模块可以帮助您的项目实现以下目标:

  • 范围界定: 帮助您避免不同代码区域之间的标识符冲突。
  • 可维护性: 帮助您定义逻辑边界。假设您的代码中有很多依赖项,开发人员很难在没有模块的情况下进行大型项目开发。通过将相互依赖的代码放入同一个模块内,定义边界来将依赖最小化。这可以让大项目中的多个开发人员同步开发而不会导致冲突。
  • 简洁性: 模块可帮助您把大问题分解为小问题,使得更容易编写代码,并使其他开发人员更容易理解,它还有助于减少错误和方便代码调试。
  • 可复用性: 这是使用模块的主要优势之一。模块可以很容易被复用到其它地方,如同项目中的库和 API 一样。

最后,模块可以帮助您很好地组织代码。特别是在大型项目中,多个开发人员分别负责代码库的不同部分,使用模块将边界定义清晰非常重要。

# 使用__init__文件

自从 Python 3.3 以后,__init__.py 不再需要单独指定一个目录作为包。而在 Python 3.3 之前,需要有一个空的 __init__.py 文件单独放在一个文件夹下作为一个包。但是 __init__.py 文件在复杂场景中可以使代码使用和打包变得更简单。

__init__.py 的主要用途之一是将模块拆分为多个文件。让我们假设一个场景,您有一个模块名为 purchase,它有两个不同的类分别叫 CartPaymentCart 负责将产品添加到购物车中,Payment 负责支付操作。如示例4-2。

示例4-2. 模块示例

# purchage 模块
class Cart:
    def add_to_cart(self, cart, product):
        self.execute_query_to_add(cart, product)

class Payment:
    def do_payment(self, user, amount):
        self.execute_payment_query(user, amount)

假设您要拆分这两个不同的功能(添加到购物车和支付)到不同的模块,以便更好地组织代码。您可以通过将CartPayment放入两个不同的模块中,如下所示:

purchase/
    cart.py
    payment.py

您可以考虑对购物车模块进行编写,如示例4-3。

示例4-3. 购物车类示例

# cart 模块
class Cart:
    def add_to_cart(self, cart, product):
        self.execute_query_to_add(cart, product)
        print("Successfully added to cart")

考虑 payment 模块,如示例4-4。

示例4-4. 支付类示例

# payment 模块
class Payment:
    def do_payment(self, user, amount):
        self.execute_payment_query(user, amount)
        print(f"Payment of ${amount} successfully done!")

现在,您可以将这些模块放到__init__.py 文件中,将它们放到一块。

from .cart import Cart
from .payment import Payment

如果按照上述步骤,您将为用户提供了一个统一的接口来使用您的包里不同的功能。例如:

import purchase
>>> cart = purchase.Cart()
>>> cart.add_to_cart(cart_name, prodct_name)
Successfully added to cart
>>> payment = purchase.Payment()
>>> payment.do_payment(user_obj, 100)
Payment of $100 successfully done!

这样做的主要目的是为客户端提供精心设计的代码。您可以使用一个模块处理项目中的不同功能,代替让客户端处理多个小模块,或找出什么功能属于哪个模块。这在大型代码和第三方库中特别有用。

假设一个客户端像下面这样使用您的模块:

from purchase.cart import Cart
from purchase.payment import Payment

这样做可行,但用户在查找该方法的来源时,会增加额外的负担。相反的,统一的单次导入可使用户更加轻松地使用这些模块。

from purchase import Cart, Payment

在后一种情况下,最常见的做法是将大量源代码看作单个模块。例如,在前一行中,客户端可以将 purchase 看作是一段源代码或一个模块,而不必关心 CartPayment 类具体在哪儿。

这里同时也展示了如何将不同的子模块拼接到一个模块。正如上一个例子中,您可以将大模块拆分为多个不同的逻辑子模块,但用户还是使用同一个模块名称。

# 使用正确的方法从模块中导入类和方法

在Python中有很多方法导入类和方法。您可以在同一个包内导入,也可以从不同的包导入。让我们来看看这两种情形下导入类和方法最好的做法。

  • 同一个包内,使用绝对路径或相对路径导入。

    下面是一个示例:

    别这样做:

    from foo import bar  # 不要这样做
    

    要这样做:

    from . import bar    # 推荐的做法
    

    第一个 import 语法是使用绝对路径导入,如 TestPackage.Foo。从代码的顶级包开始在源代码中写死。这种方式的问题在于修改包的名称或调整目录结构时,导入的源代码也必须修改。

    例如,您想要把包名称 TestPackage 改为 MyPackage,您就必须在每个使用到该包的地方都进行修改。当您的项目中文件众多时,这几乎不可能做到,同时其他人也难以移植这些代码,但使用相对路径导入就没有这个问题。

  • 不同的包之间,也有多种方式导入。

    from mypackage import *           # 不好的做法
    from mypackage.test import bar    # 一般的做法
    import mypackage                  # 推荐的做法
    

    第一条命令是导入包内所有内容,很显然不是一个好做法,因为您不知道从这个包导入了哪些内容。第二条命令很详细并且是明确的好做法,比第一条命令更具可读性。

    第二条命令帮助读者了解导入的内容来自哪个包,这使其他开发人员更容易理解代码,并了解所有依赖项。但在多个地方导入包时会有一些问题。首先,这可能会成为代码中的一种污染。想象一下,有 10 到 15 行代码仅仅用于导入包。还有一个问题,从不同的地方导入同名的内容时,会产生混淆,无法判断类/方法的来源。例如:

    from mypackage import foo
    from youpackage import foo
    foo.get_result()
    

    推荐第三条命令的原因是它的可读性更高,并为您提供一个方法,从代码中就能明确知道类或方法从属于哪个包。

    import mypackage
    import yourpackage
    mypackage.foo.get_result()
    yourpackage.foo.feed_data()
    

# 使用__all__阻止导入

有一种机制可以阻止用户导入你的模块中所有内容。Python 有一个特殊的元类 __all__,允许您控制导入的行为。使用 __all__ 可以限制用户仅可导入指定的类或方法,而不是模块中所有内容。

例如,假设您有一个叫 user.py 的模块。通过使用 __all__,可以限制其他模块仅可导入指定的内容。

现在有一个payment模块,包含所有支付类,您希望防止一些类被错误导入,可以使用 __all__ 来实现,如下例所示。

payment.py

class MonthlyPayment:
    ....
class CalculatePayment:
    ....
class CreditCardPayment:
    ....
__all__ = ["CalculatePayment", "CreditCardPayment"]

user.py

from payment import *

calculate_payment = CalculatePayment()   # 正常运行
monthly_payment = MonthlyPayment()       # 这里将抛出异常

您可能已经注意到,使用 from payment import * 并没有把所有 payment 模块中的类都导入进来。另外,您仍然可以使用下面的方式强制导入MonthlyPayment

from payment import MonthlyPayment

# 何时使用元类

如您所知,元类创建类。就像您可以创建类来生成对象一样,Python 元类以同样的方式生成类。换句话说,元类是类的类。由于这一节不是讲解元类是如何工作的,因此我将重点讨论应该在何时考虑使用元类。

在代码中,大多数时候都不需要使用到元类。元类的主要用途是创建 API 、库或添加一些复杂的功能,元类可以帮助隐藏大量的细节让用户更轻松地使用这些 API 或库。

以 Django ORM 为例,它使用大量元类来使 ORM API 易于理解和使用。在您编写 Django ORM 时,如示例 4-5。

示例4-5. __init__.py

class User(models.Model):
    name = models.CharField(max_length=30)
    age = models.IntegerField()

user = User(name="Tracy", age=78)
print(user.age)

这里 user.age 不会返回一个IntegerField,而是返回从数据库取出的int

Django ORM 能这样工作,因为在 Model 类中使用了元类。Model 类定义了 __metaclass__,并且它使用一些魔法通过复杂的中间件将 User 类与数据库字段相互关联。Django 通过公开一个简单的 API 和使用元类让一些复杂的工作看起来很简单,元类在幕后让它成为现实。

还有一些特别的元类,如 __call____new__ 等。这些元类可以帮助您构建漂亮的 API。如果您看过那些良好 Python 库的代码,如 flask,Django,requests 等,你会发现这些库都在使用元类来使其 API 易于使用和理解。

当您发现使用普通的 Python 语法不能使您的 API 有很好的可读性时,请考虑使用元类。编写样板代码时您可以用元类使得 API 更易于使用。我将在后面的章节中讨论如何使用元类编写出良好的 API 或库。

# 使用__new__验证子类

创建实例时会调用 __new__ 这个神奇的方法。使用此方法,可以轻松自定义创建实例,此方法将在调用 __init__ 初始化类实例之前被调用。

您还可以在创建类的时候使用 super 调用父类的 __new__ 方法。如示例4-6。

示例4-6. __new__

class User:
    def __new__(cls, *args, **kwargs):
        print("Creating instances")
        obj = super(User, cls).__new__(cls)
        return obj
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

>> user = User("Larry", "Page")
Creating Instance
>> user.full_name()
Larry Page

从上面可以看出,创建实例时,__new__ 先于 __init__ 方法被调用。

假设这样一个场景,您必须创建一个父类或抽象类。无论哪个类继承该父类或抽象类都需要做一项特定的检查或操作,这在实现子类中很容易被忘记或弄错。所以您可以在父类或抽象类中实现这项操作,同时也确保了后面每个类都会执行这些操作。

在示例4-7中,您可以看到使用 __new__ 元类在子类继承抽象类和父类之前进行验证。

示例4-7. 使用__new__指定一个值

from abc import abstractmethod, ABCMeta

class UserAbstract(metaclass=ABCMeta):
    """抽象基类模板,使用__new__()批量初始化。"""
    def __new__(cls, *args, **kwargs):
        """创建一个对象实例并添加一个基础属性。"""
        obj = object.__new__(cls)
        obj.base_property = "Adding Property for each subclass"
        return obj

class User(UserAbstract):
    """实现UserAbstract抽象类并添加一个私有变量。"""
    def __init__(self):
        self.name = "Larry"
>> user = User()
>> user.name
Larry
>> user.base_property
Adding Property for each subclass

您可以看到,每当创建一个子类实例,都会被添加一个值为“Adding Property for each subclass”的 base_property 变量。

现在,让我们修改此代码来验证传入的参数是否为字符串,如示例4-8。

示例4-8. 使用__new__检验参数

from abc import abstractmethod, ABCMeta

class UserAbstract(metaclass=ABCMeta):
    """抽象基类模板,使用__new__()批量初始化。"""
    def __new__(cls, *args, **kwargs):
        """创建一个对象实例并添加一个基础属性。"""
        obj = object.__new__(cls)
        given_data = args[0]
        # 在这里验证数据
        if not isinstance(given_data, str):
            raise ValueError(f"Please provide string:{given_data}")
        return obj

class User(UserAbstract):
    """实现UserAbstract抽象类并添加一个私有变量。"""
    def __init__(self, name):
        self.name = name

>> user = User(10)
ValueError: Please provide string: 10

从上面可以看出,每当传入参数使用 User 类创建实例时,都会验证传入的参数是否为字符串。使用 __new__ 魔法真正的美妙之处在于不需要每个子类来重复此工作。

# 为何__slots__如此有用

__slots__ 可帮助您节省对象的内存空间,并更快地访问属性。我们可以快速的测试一下 __slots__ 的性能,如示例4-9。

示例4-9. __slots__更快的属性访问

class WithSlots:
    """在这里使用__slots__魔法。"""
    __slots__ = "foo"

class WithoutSlots:
    """ 这里不使用__slots__。"""
    pass

with_slots = WithSlots()
without_slots = WithoutSlots()

with_slots.foo = "Foo"
without_slots.foo = "Foo"

>> %timeit with_slots.foo
>> 44.5 ns
>> %timeit without_slots.foo
>> 54.5 ns

即使简单的访问 with_slots.foo 就比访问 WithoutSlots 中的属性更快。在 Python 3 中,使用 __slots__ 比不使用 __slots__ 平均快30%。

使用 __slots__ 的第二个原因是节省内存。__slots__有助于减少每个对象实例占用的内存空间。__slots__ 节省出的空间非常有用。

您可以在 https://docspython.org/3/reference/datamodel.htmlslots (opens new window) 找到有关 __slots__ 的更多信息。

使用 __slots__ 的另一个明显的原因是为了节省空间。如果您把示例4-8中对象的大小与使用 __slots__ 的对象大小相比,使用 __slots__ 的对象占用的空间更少。

>> import sys
>> sys.getsizeof(with_slots)
48
>> sys.getsizeof(without_slots)
56

__slots__ 帮助您在使用对象时节省空间,并为您提供更好的效果性能。问题是何时应该考虑使用 __slots__?要回答这个问题,让我们先简要谈谈实例的创建。

创建实例时,Python 会自动为实例分配空间给 __dict____weakrefs____dict__ 通常不会初始化直到有属性被访问时,所以您无需关注它。但您需要创建/访问属性时节省额外的空间或提升性能,__slots__dict 更好。

所以,每当您不希望对象中 __dict__ 占用额外的空间,可以使用 __slots__ 来节省空间或提升访问属性的性能。

例如,示例4-10中使用 __slots__ 且子类不为属性 a 创建 __dict__,从而节省空间和增加访问属性的性能。

示例4-10. __slots__更快的属性访问

class Base:
    __slots__ = ()

class Child(Base):
    __slots__ = ('a',)

c = Child()
c.a = 'a'

Python 文档建议尽量不要使用 __slots__。但您觉得需要额外的空间和性能时,它不失为一个好的选择。

我同样不建议使用__slots__,除非您真的需要额外的空间和性能,因为它有很大的限制,尤其是在动态分配变量时。如示例 4-11。

示例4-11. 使用__slots__的属性错误

class User(object):
    __slots__ = ("first_name", )

>> user = User()
>> user.first_name = "Larry"
>> user.last_name = "Page"
AttributeError: "User" object has no attribute "last_name"

虽然有很多方法可以规避这个问题,但这些解决方案与不使用 __slots__ 差别不大。如果需要动态赋值,如示例4-12。

示例4-12. 同时使用__dict____slots__避免动态分配属性错误

class User:
    __slots__ = "first_name", "__dict__"

>> user = User()
>> user.first_name = "Larry"
>> user.last_name = "Page"

因为在 __slots__ 中使用 __dict__,所以会丢失一些空间优势,但您可以继续使用动态分配属性。

以下是一些您不应使用 __slots__ 的场景:

  • 当您创建内置的数据结构(如tuplestr)的子类并希望向其添加属性时
  • 当您希望通过类提供默认值初始化实例变量的属性时

所以,当你真正需要额外的空间和性能,并且它不会限制类功能或使调试更加困难时,再考虑使用 __slots__

# 使用元类更改类行为

元类可以根据需要定制类行为。 Python 元类代替用户自己编写复杂的逻辑在类中添加特定行为。它为用户提供一个很棒的工具来处理代码中复杂的逻辑。在本节中,您将了解如何使用被称为 __call__ 的魔术方法来实现多个功能。

假设您要阻止客户端直接创建类对象,可使用 __call__ 轻松实现这一点。如示例4-13。

示例4-13. 阻止直接创建对象

class NoClassInstance:
    """创建用户对象。"""
    def __call__(self, *args, **kwargs):
        raise TypeError("Can't instantiate directly""")

class User(metaclass=NoClassInstance):
    @staticmethod
    def print_name(name):
        """打印传入的姓名信息。"""
        print(f"Name: {name}")

>> user = User()
TypeError: Can't instantiate directly
>>> User.print_name("Larry Page")
Name: Larry Page

此处 __call__ 确保类不会被用户代码直接初始化,但可以直接使用它的静态方法。

假设您需要创建一个 API,并希望在其中使用策略设计模式,或者让用户更轻松的使用 API。

让我们来看示例4-14。

示例4-14. 使用__call__设计API

class Calculation:
    """
    可使用不同的算法对两个数字进行计算。
    """
    def __init__(self, operation):
        self.operation = operation

    def __call__(self, first_number, second_number):
        if isinstance(first_number, int) and isinstance(second_number, int):
            return self.operation()
        raise ValueError("Provide numbers")

def add(self, first, second):
    return first + second

def multiply(self, first, second):
    return first * second

>> add = Calculation(add)
>> print(add(5, 4))
9
>> multiply = Calculation(multiply)
>> print(multiply(5, 4))
20

在上例中,您可以传入不同的方法或算法来执行特定的操作而不覆盖公共逻辑。在这里,你可以看到代码里面 __call__ 使您的 API 更易于使用。

让我们再看一下示例4-15中的一个场景。您想以某种方式创建缓存实例,当传入相同的值时,它缓存这个实例,而不再次创建一个新的实例。在不想复制具有相同参数的实例时,可以参考此示例。

示例4-15. 使用__call__实现缓存示例

class Memo(type):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.__cache = {}

    def __call__(self, _id, *args, **kwargs):
        if _id not in self.__cache:
            self.cache[_id] = super().__call__(_id, *args,**kwargs)
        else:
            print("Existing Instance")
        return self.__cache[id]

class Foo(Memo):
    def __init__(self, _id, *args, **kwargs):
        self.id = _id

def test():
    first = Foo(id="first")
    second = Foo(id="first")
    print(id(first) == id(second))

>>> test()
>>> True

我希望 __call__ 用例可以让您了解元类是如何帮助您完成一些复杂的任务。__call__ 还有一些其他不错的用处,如创建单例、存储值或用于装饰器。

注意: 在许多时候可以使用元类轻松完成复杂的任务,我建议深入了解元类并试着参考一些元类的用例。

# 了解 Python 描述符

Python 描述符(Descriptor)有助于从对象字典中获取、设置和删除属性。当您访问class的属性,就会启动查找链,如果在代码中定义了描述符方法,则使用描述符方法查找属性。这些描述符方法在 Python 中是 __get____set____delete__

实际上,当您从类实例中设置或获取特定属性值时,您可能希望在设置属性的值或获取属性的值之前执行一些额外的操作。Python 描述符可帮助您执行这些操作而不需要再定义其他方法。

让我们看一个能帮助您理解的一个真实用例,如示例4-16。

示例4-16. Python 描述符__get__示例

import random
class Dice:
"""骰子类模拟掷骰子。"""
    def __init__(self, sides=6):
        self.sides = sides

    def __get__(self, instance, owner):
        return int(random.random() * self.slides) + 1

    def __set__(self, instance, value):
        print(f"New assigned value: ${value})
        if not isinstance(instance.sides, int):
            raise ValueError("Provide integer")
                instance.sides = value

class Play:
    d6 = Dice()
    d10 = Dice(10)
    d13 = Dice(13)

>> play = Play()
>> play.d6
3
>> play.d10
4
>> play.d6 = 11
New assigned value: 11

>> play.d6 = "11"
I am here with value: 11
---------------------------------------------------------------
ValueError Traceback (most recent call last)
<ipython-input-66-47d52793a84d> in <module>()
----> 1 play.d6 = "11"

<ipython-input-59-97ab6dcfebae> in __set__(self, instance, value)
    9 print(f" New assigned value: {value}")
    10 if not isinstance(value, int):
---> 11 raise ValueError("Provide integer")
    12 self.sides = value
    13

ValueError: Provide integer

在这里,我们使用的 __get__ 描述符提供额外的功能到 class 的属性,而不需要调用其他方法,并且使用__set__以确保仅将 int 类型的值传入 Dice 类属性中。

让我们简要地了解一下这些描述符:

  • __get__(self, instance, owner):若定义了此方法,访问属性时,自动调用该方法,如示例4-16。
  • __set__(self, instance, value):当您设置实例属性时,该方法调用形式为 obj.attr= "value"
  • __delete__(self, instance): 当您想要删除一个特定的属性,此方法被调用。

描述符使您可以对代码进行更多控制,用于不同的场景,如在设定定属性值之前验证,将属性设置为只读等等。它还有助于使代码变得更加简洁,因为您不需要额外创建一个方法来做这些复杂的操作。

注意: 当您想要以更简洁的方式读取或设置类属性值时,描述符非常有用。如果您了解它们的工作原理,那么在您想执行特定的属性验证或检查时,它将变得更有效。希望这部分内容能让您对描述符有一个基本的了解。

# 小结

Python 中的元类因其语法和一些神奇的功能使得难以理解。但您能掌握本章中讨论的这些常用的元类,可以使您的代码更好地为用户所用,同时也会让您对提供给用户的 API 和库有更好的掌控。

但是,请谨慎使用它们,因为使用它们解决问题时可能会影响代码的可读性。同样,对Python 中的模块有良好的理解可以让您更好地理解为什么以及如何让模块遵循 SRP。希望本章能让您对 Python 中这两个非常重要的概念有一个清晰的认识。

# 第5章 装饰器和上下文管理器

装饰器(Decorator,又称修饰器)和上下文管理器(Context manager)是 Python 中的一个高级主题,它们在许多实际场景中非常有用。许多流行的库广泛使用装饰器和上下文管理器,从而增加 API 和代码的可读性。刚开始时,理解装饰器和上下文管理器可能有点难,但一旦掌握了,它们就可以使代码的可读性大大增加。 在本章中,您将学习装饰器和上下文管理器。并探索这些功能在编写一个 Python 项目时何时能用到它。

注意: 装饰器和上下文管理器是 Python 中的高级概念。它的底层实现中大量使用了元类(meta class)。您无需学习元类即可掌握如何使用装饰器和上下文管理器,因为 Python 为您提供了足够的工具和库,不需要使用任何元类即可创建装饰器和上下文管理器。如果你对元类了解不多,也不必担心,您应该能够充分了解装饰器和上下文管理器的工作原理。您还将学习一些技术,以便更轻松地编写装饰器和上下文管理器。我建议您很好地掌握装饰器和上下文管理器概念,以便识别出在代码中可以用到它的地方。

# 装饰器

我们先来谈谈装饰器吧。在本节中,您将了解装饰器是如何工作的,以及在实际项目中在何处使用它们。装饰器是 Python 的一个有趣且有用的特性。如果您能很好地理解装饰器,那么您就可以不费吹灰之力地构建许多神奇的功能。

在不改变函数或对象现有功能的基础上,Python 的装饰器可以动态地向函数或对象添加新功能。

# 什么是装饰器,它为何用处这么大?

假设您的代码中有几个函数,您需要为所有这些函数添加日志记录,以便在它们执行时,函数名称可以写到日志文件中或者在控制台上打印出来。一种方法是使用日志库并在每个函数中添加日志行。但是,这样做需要相当长的时间,而且也很容易出错,因为您在代码里做的诸多改动只是为了增加一个日志记录。另一种方法是在每个函数或者类的顶部添加装饰器。事实上,第二种方法是最有效的,并且不存在向现有代码引入新 Bug 的风险。

在 Python 世界中,装饰器可以应用于函数,并且它们能够在所包装的函数之前和之后运行。装饰器有助于在函数中运行其他代码。它允许您访问和修改输入参数和返回值,这在多个地方都有用。下面是一些例子:

  • 速率限制
  • 缓存值
  • 计算函数的运行时长
  • 记录日志
  • 捕获或者抛出异常
  • 身份验证

这些是装饰器的一些主要用例。并且,使用它们是没有任何限制的。事实上,您会发现像 Flask 这样的 API 框架在很大程度上依赖装饰器将函数转换成为 API。示例5-1显示了一个 关于Flask的 实例。

示例5-1. Flask 实例

from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello World!"

这段代码使用了 route 装饰器将 hello 函数转换为 API。这就是装饰器的优美之处。了解装饰器将有助于您成为一名合格的开发人员,它可以使您的代码更简单,这样代码就不容易产生错误。

# 理解装饰器

在本节中,您将看到如何使用装饰器。假设有一个简单的函数可以将传入的字符串转换为大写格式并返回结果。如示例5-2。

示例5-2. 将传递的字符串变为大写并返回结果

def to_uppercase(text):
    """Convert text to uppercase and return."""
    if not isinstance(text, str):
        raise TypeError("Not a string type")
    return text.upper()

>>> text = "Hello World"
>>> to_uppercase(text)
HELLO WORLD

这一个函数可以接受一个字符串并将其转换为大写。然后,我们将大写字母稍微改一下,如示例5-3。

示例5-3. 通过传递 func 转换为大写

def to_uppercase(func):
    """Convert text to uppercase and return."""
    # 加了此行代码,将调用传入的函数来得到字符串
    text = func()
    if not isinstance(text, str):
        raise TypeError("Not a string type")
    return text.upper()

def say():
    return "welcome"

def hello():
    return "hello"

>>> to_uppercase(say)
WELCOME
>>> to_uppercase(hello)
HELLO

这个地方我们做了两个改动:

  1. 将函数修改为 to_uppercase 以接受 func 而不是字符串,并调用该函数获取字符串。
  2. 创建了一个返回“welcome”的新函数 say(),并将该函数传递给 to_uppercase 方法。

to_uppercase 函数调用 say() 函数并获取要转换为大写的文本。因此,to_uppercase 通过调用函数 say() 而不是从传递的参数获取文本。

现在,看懂了上述例子,您就可以编写类似示例5-4的代码。

示例5-4. 使用装饰器

@to_uppercase
def say():
  return "welcome"

>>> say
WELCOME

在函数前面加上 @to_uppercase 使函数 to_uppercase 成为装饰器函数。这类似于在 say 函数之前执行 to_uppercase。

这虽然是一个简单的示例,但却完整的展示了装饰器是如何在 Python 中工作的。使用 to_uppercase 装饰器的优势在于您可以将其应用于任何函数中,从而达到将字符串变为大写形式的目的。例如示例5-5。

示例5-5. 装饰器在其他地方的应用

@to_uppercase
def say():
    return "welcome"

@to_uppercase
def hello():
    return "Hello"

@to_uppercase
def hi():
    return 'hi'

>>> say
WELCOME
>>> hello
HELLO
>>> hi
HI

请确保你的装饰器名称是通俗易懂的,这样能让你的代码更容易被其他人理解。

# 使用装饰器修改函数行为

读到这里,想必您已经了解了装饰器的基本原理,那么就让我们进一步了解装饰器的主要用例。在示例5-6中,您将编写一个复杂的小函数来包装另一个函数。因此,您将修改函数 to_uppercase 以接受任何函数,然后在 to_uppercase 里面定义另一个函数以执行 upper() 操作。

示例5-6. 大写字母的装饰器

def to_uppercase(func):
    def wrapper():
        text = func()
        if not isinstance(text, str):
            raise TypeError("Not a string type")
        return text.upper()
    return wrapper

那么,上述代码的结果是什么样的呢?这里有一个函数 to_uppercase,在此函数中,您像以前一样将 func 作为参数传递进去,但在这里,您将代码的其余部分移到另一个名为 wrapper 的函数中。wrapper 函数由 to_uppercase 返回。

wrapper 函数允许您在这里执行代码来更改函数的行为,而不仅仅是运行它。现在可以在函数执行之前和完成执行之后执行多个操作。wrapper 闭包(closure)可以访问输入函数,并可以在函数前后添加新代码,这显示了装饰器函数可以更改函数行为的实际能力。

使用另一个函数的主要用途是在明确调用之前不执行该函数。在调用之前,它将包装函数并编写函数的对象。下面是编写完整的代码,如示例5-7。

示例5-7. 使用装饰器转换大写字母的完整代码

def to_uppercase(func):
    def wrapper():
        text = func()
        if not isinstance(text, str):
            raise TypeError("Not a string type")
        return text.upper()
    return wrapper

@to_uppercase
def say():
    return "welcome"

@to_uppercase
def hello():
    return "hello"

>>> say()
WELCOME
>>> hello()
HELLO

在上面的示例中,to_uppercase() 是一个定义了的装饰器,它可以接受任何函数作为参数,并将字符串转换为大写。其中,say() 函数使用 to_uppercase 作为装饰器,当 Python 执行 say() 函数时,Python 将 say() 作为函数对象在执行时传递给 to_uppercase() 装饰器,并返回一个名为 wrapper 的函数对象,在调用 say()hello() 时执行该函数对象。

不仅如此,您可以将装饰器运用到几乎所有的场景上面,不过在运行特定函数之前必须添加功能。以下面代码为例,当您希望网站用户在看到您网站上的任何页面之前登录时,您可以考虑在任何允许用户访问的网站页面上使用登录装饰器,这将强制用户在看到您网站上的任何页面之前登录。类似的,考虑一个简单的场景,在文本后面添加单词“Larry Page”,可以通过添加以下语句来完成:

def to_uppercase(func):
    def wrapper():
        text = func()
        if not isinstance(text, str):
            raise TypeError("Not a string type")
        result = " ".join([text.upper(), "Larry Page"])
        return result
    return wrapper

# 使用多个装饰器

装饰器的奥妙还不止如此。您还可以对一个函数应用多个装饰器。假设你必须给“Larry Page!”加个后缀,在这种情况下,可以使用不同的装饰器来添加这个后缀,如示例5-8。

示例5-8. 一对多装饰器

def add_suffix(func):
    def wrapper():
        text = func()
        result = " ".join([text, "Larry Page!"])
        return result
    return wrapper

def to_uppercase(func):
    def wrapper():
        text = func()
        if not isinstance(text, str):
            raise TypeError("Not a string type")
        return text.upper()
    return wrapper

@to_uppercase
@add_suffix
def say():
    return "welcome"

>> say()
'WELCOME LARRY PAGE!'

如您所见,装饰器是从下到上应用的,因此首先调用 add_suffix,然后调用 to_uppercase。为了证明这一点,我们更改装饰器的顺序,将得到不同的结果,如下所示:

@add_suffix
@to_uppercase
def say():
    return "welcome"

>> say()
'WELCOME Larry Page!'

正如您所注意到的,“Larry Page”不会被转换成大写,因为 to_uppercase 是先于 add_suffix 被调用的。

# 装饰器接受参数

让我们在前面的示例上进行扩展,比如将参数传递给装饰器,这样您就可以动态地将传递的参数更改为大写样式,并按名称问候不同的人。如示例5-9。

示例5-9. 将参数传递给装饰器

def to_uppercase(func):
    def wrapper(*args, **kwargs):
        text = func(*args, **kwargs)
        if not isinstance(text, str):
            raise TypeError("Not a string type")
        return text.upper()
    return wrapper

@to_uppercase
def say(greet):
    return greet

>> say("hello, how you doing")
'HELLO, HOW YOU DOING'

如您所见,我们可以向装饰器传递参数,它将执行代码并使用装饰器中传入的参数。

# 将库用于装饰器

当您创建装饰器时,它通常会用一个函数替换另一个函数。让我们看看5-10中的简单示例。

示例5-10. logging 函数的装饰器

def logging(func):
    def logs(*args, **kwargs):
        print(func.__name__ + " was called")
        return func(*args, **kwargs)
    return logs

@logging
def foo(x):
    """Calling function for logging"""
    return x * x

>>> fo = foo(10)
>>> print(foo.__name__)
logs

您可能希望将 foo 打印为函数名。但事实是,它将 logs 打印为函数名,它是装饰器函数 logging 中的包装函数。事实上,当你使用一个装饰器时,你总是会丢失诸如 __name____doc__ 等信息。

要解决此问题,可以考虑使用 functool.wrap,它接受装饰器中使用的函数,并添加复制函数名、文档字符串(docstring)、参数示例等功能。因此,您可以编写相同功能的代码,如示例5-11。

示例5-11. 用 functools 创建装饰器

from functools import wraps

def logging(func):
    @wraps(func)
    def logs(*args, **kwargs):
        print(func.__name__ + " was called")
        return func(*args, **kwargs)
    return logs

@logging
def foo(x):
    """does some math"""
    return x + x * x

print(foo.__name__)  # 打印出 'foo'
print(foo.__doc__)   # 打印出 'does some math'

Python 标准库有一个名为 functools 的库,其中包含了一个 funtools.wrap 来创建有助于保留所有信息的装饰器,否则在创建自己的装饰器时可能会丢失这些信息。

除了 functools,还有 decorator 这样的库,它也很容易使用。5-12就展示了一个相关的示例。

示例5-12. 用 decorator 创建装饰器函数

from decorator import decorator

@decorator
def trace(f, *args, **kw):
    kwstr = ', '.join('%r: %r' % (k, kw[k]) for k in sorted(kw))
    print("calling %s with args %s, {%s}" % (f.__name__, args, kwstr))
    return f(*args, **kw)

@trace
def func(): 
    pass

>>> func()
calling func with args (), {}

类似地,您可以在一个类里为类方法使用装饰器,如示例5-13。

示例5-13. 使用函数 Decorator 的类

def retry_requests(retries=3, delay=10):
    def try_request(fun):
        @wraps(fun)
        def retry_decorators(*args, **kwargs):
            for retry in retries:
                fun(*args, **kwargs)
                time.sleep(delay)
        return retry_decorators
    return try_request

class ApiRequest:
    def __init__(self, url, headers):
        self.url = url
        self.headers = headers

    @retry_requests(retries=4, delay=5)
    def make_request(self):
        try:
            response = requests.get(url, headers)
            if reponse.status_code in (500, 502, 503, 429):
                continue
        except Exception as error:
            raise FailedRequest("Not able to connect with server")
        return response

# 用于维护状态和验证参数的类修饰符

到目前为止,您已经了解了如何将函数用作装饰器,但是 Python 对创建仅作为装饰器的方法没有任何限制。类也可以用作装饰器。这完全取决于您想用哪种特定的方式定义您的装饰器。

使用类装饰器的主要用例之一是维护状态。在学习维护状态之前,让我们先了解一下 __call__ 方法如何帮助您的类使其可调用。

为了使任何类都可以调用,Python提供了一些特殊的方法,比如 __call__() 方法。这意味着允许类的实例作为函数调用。像 __call__ 这样的方法可以将类创建为装饰器并返回要用作函数的类对象。

让我们看一下5-14中的例子,进一步了解 __call__ 方法。

示例5-14. call 方法的使用

class Count:
    def __init__(self, first=1):
        self.num = first

    def __call__(self):
        self.num += 1
        print(f"number of times called: {self.num}")

现在,每当您使用该类的实例调用 Count 类时,都将调用 __call__ 方法。

>>> count = Count()
>>> count()
Number to times called: 2

>>> count()
Number of times called: 3

显而易见,调用 count() 会自动调用 __call__ 方法,该方法维护了变量 num 的状态。

您可以使用这个概念来实现装饰器类。见示例5-15。

示例5-15. 用装饰器来维护状态

import functools

class Count:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.num = 1

    def __call__(self, *args, *kwargs):
        self.num += 1
        print(f"Number of times called: {self.num}")
        return self.func(*args, *kwargs)

@Count
def counting_hello():
    print("Hello")

>>> counting_hello()
Number of times called: 2

>>> counting_hello()
Number of times called: 3

__init__ 方法需要存储函数的引用。每当调用修饰类的函数时,就会调用 __call__ 方法。这里使用 functools 库来创建装饰器类。换句话说,您使用类装饰器来存储变量的状态。

让我们看一个更有趣的例子,如示例5-16,使用类装饰器来进行类型检查。这虽然是一个展示用的简单示例,但是您可以在需要检查参数类型的各种情况下使用它。

示例5-16. 用类装饰器验证参数

import functools

class ValidateParameters:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func

    def __call__(self, *parameters):
        if any([isinstance(item, int) for item in parameters]):
            raise TypeError("Parameter shouldn't be int!!")
        else:
            return self.func(*parameters)

@ValidateParameters
def add_numbers(*list_string):
    return "".join(list_string)

# 打印出: anb
print(concate("a", "n", "b"))

# 抛出错误。
print(concate("a", 1, "c"))

正如您看到的,这里使用类装饰器进行类型检查。

如您所见,有很多地方可以使用装饰器来增加代码的可读性。无论何时考虑使用装饰器模式,都可以使用 Python 装饰器来轻松实现。理解装饰器有点难,因为它需要对函数的工作方式有一定程度的理解,但是一旦对装饰器有了基本的了解,就可以考虑在实际应用程序中使用它们。在那之后,您就会发现代码质量有了显著的提高。

# 上下文管理器

上下文管理器(Context manager)和装饰器(decorator)一样,是 Python 的一个有用特性。您甚至可能在日常代码中使用了它们却没有意识到,特别是在使用 Python 内置库时。常见的例子是文件操作和套接字操作。

此外,上下文管理器在编写 API 或第三方库时非常有用,因为它使您的代码更具可读性,并防止用户编写不必要的代码来清理资源。

# 上下文管理及其效用

如之前所述,在操作不同的文件或套接字时,您可能会不知不觉地使用了上下文管理器。见示例5-17。

示例5-17. 使用上下文管理器的文件操作

with open("temp.txt") as fread:
    for line in fread:
        print(f"Line: {line}")

这里的代码使用了上下文管理器来处理文件操作。with 关键字就是使用上下文管理器的一种方法。为了理解上下文管理器的作业,我们先不使用上下文管理器来编写这段代码,如示例5-18。

示例5-18. 不带上下文管理器的文件操作

fread = open("temp.txt")
try:
    for line in fread:
        print(f"Line: {line}")
finally:
    fread.close()

可以看到 with 语句被 try finally 块替代,这样用户就不必担心发生异常。

上下文管理器的主要用途就是资源管理,其中包括了它简洁的 API。假如有这样一种情况,用一个函数读取用户输入文件,如示例5-19。

示例5-19. 读取文件

def read_file(file_name):
    """读取指定文件并打印每一行。"""
    try:
        fread = open("temp.txt")
        for line in fread:
            print(f"Line: {line}")
    catch IOError as error:
        print("Having issue while reading the file")
        raise

假如,我们忘记了在代码中添加 file.close() 语句。在读取完文件之后,read_file 函数并没有关闭该文件。现在考虑函数 read_file 被连续调用了数千次,这将在内存中打开数千个文件句柄,并可能导致内存泄漏。为了防止这些情况,可以如示例5-17那样使用上下文管理器。

类似地,下面的代码也会出现内存泄漏,因为系统对在特定时间可以使用的资源数量是有限制的。在示例5-20中,当您打开一个文件时,操作系统将分配一个称为文件描述符的资源,该资源会受到操作系统的限制。因此,当资源数量超过这个限制时,程序就会崩溃,并抛出 OSError 错误消息。

示例5-20. 泄漏文件描述符

fread = []
for x in range(900000):
    fread.append(open('testing.txt', 'w'))

>>> OSError: [Errno 24] Too many open files: testing.txt

显然,上下文管理器可以帮助您更好地处理资源。在本例中,这包括关闭文件并在完成文件操作后释放文件描述符。

# 理解上下文管理器

如您所见,上下文管理器对于资源管理非常有用。让我们看看如何去构建它们。

要创建一个 with 语句,您需要做的就是将 __enter__ 方法和 __exit__ 方法添加到一个对象里面。当 Python 需要管理资源时,它会调用这两个方法,所以您不必担心它们。

让我们看一个关于打开文件的上下文管理器的例子。见示例5-21。

示例5-21. 管理文件

class ReadFile:
    def __init__ (self, name):
        self.name = name

    def __enter__ (self ):
        self.file = open (self.name, 'w')
        return self

    def __exit__ (self,exc_type,exc_val,exc_tb):
        if self.file:
            self.file.close()

with ReadFile(file_name) as fread:
    f.write("Learning context manager")
    f.write("Writing into file")

当多次运行这段代码时,不会出现文件描述符泄漏问题,这是因为 ReadFile 正在为您管理这个问题。

原理就是 with 语句执行时,Python 调用 __enter__ 函数并执行。当执行离开上下文块(with)时,它就执行 __exit__ 来释放资源。

让我们看看上下文管理器的一些规则。

  • 在上下文管理器块中,__enter__ 函数返回一个被赋给了 as 之后变量的对象。这个对象通常是 self。
  • __exit__ 函数调用原始上下文管理器,而不是由 __enter__ 函数返回的上下文管理器。
  • 如果在 __init__ 函数或 __enter__ 函数方法中存在异常或错误,__exit__ 函数则不调用。
  • 一旦代码块进入上下文管理器,无论抛出了什么异常或错误,都将调用 __enter__ 函数。
  • 如果 __exit__ 函数返回值为 true,那么任何异常都将被抑制,并且执行进程将从上下文管理器块退出,没有任何错误。

让我们通过示例5-22来理解这些规则。

示例5-22. 上下文管理器类

class ContextManager():
    def __init__(self):
        print("Creating Object")
        self.var = 0

    def __enter__(self):
        print("Inside __enter__")
        return self

    def __exit__(self, val_type, val, val_traceback):
        print('Inside __exit__')
        if exc_type:
            print(f"val_type: {val_type}")
            print(f"val: {val }")
            print(f"val_traceback: {val_traceback}")

>> context = ContextManager()
Creating Object
>> context.var
0
>> with ContextManager as cm:
>>     print("Inside the context manager")
Inside __enter__
Inside the context manager
Inside __exit__

# 使用 contextlib 构建上下文管理

Python 使用 contextlib 构建上下文管理器,而不是编写类来创建上下文管理器,它提供了一个名为 contextlib.contextmanager 装饰器的库。因此编写上下文管理器比编写类更方便。

Python 内置库使得编写上下文管理器很容易。您不需要编写实现所有的 __enter____exit__ 方法来编写上下文管理器。

contextlib.contextmanager 装饰器是一个基于生成器的,自动支持 with 语句的工厂函数,如示例5-23。

示例5-23. 使用contextlib创建上下文管理器

from contextlib import contextmanager

@contextmanager
def write_file(file_name):
    try:
        fread = open(file_name, "w")
        yield fread
    finally:
        fread.close()

>> with read_file("accounts.txt") as f:
        f.write("Hello, how you are doing")
        f.write("Writing into file")

首先,write_file 获取资源,然后由调用者使用的 yield 关键字生效。当呼叫者从 with 块退出时,生成器继续执行,使得任何剩余的清理步骤都会发生(例如清理资源)。

@contextmanager 用于创建上下文管理器时,生成器产生的值就是上下文资源。

基于类的实现和基于 contextlib 装饰器的实现都是可行的,您可以根据个人偏好选择。

# 使用上下文管理器的一些实例

让我们看看上下文管理器在日常编程和项目中的用处吧。

在很多情况下可以使用上下文管理器来改进代码,让代码更加简洁且没有缺陷。

接下来将探索几种不同的场景,一开始就能用上下文管理器。除了这些用例之外,在许多不同的特性实现中也可以用上下文管理器。为此,我们需要找到用它会让代码更加优秀的地方。

# 访问数据库

访问数据库资源时可以使用上下文管理器。当特定进程处理数据库中的某些特定数据并修改该值时,可以在操作该数据时锁定数据库,一旦操作完成,就解除锁定。

示例5-24为我们展示了一个 sqlite3 的代码,具体可以前往 https://docs.python.org/2/library/sqlite3.html#using-the-connection-as-a-context-manager (opens new window)

示例5-24. sqlite3 锁

import sqlite3

con = sqlite3.connect(":memory:")
con.execute("create table person (id integer primary key, firstname varchar unique)")

# Successful, con.commit() is called automatically afterwards
with con:
    con.execute("insert into person(firstname) values (?)", ("Joe",))

# con.rollback() is called after the with block finishes with an exception, the
# exception is still raised and must be caught
try:
    with con:
        con.execute("insert into person(firstname) values (?)", ("Joe",))
except sqlite3.IntegrityError:
    print "couldn't add Joe twice"

在这里使用的就是一个上下文管理器,它在成功时会自动提交,在失败时会自动回滚。

# 编写测试

在编写测试时,您经常希望用代码抛出的不同类型的异常来测试某些模拟(mock)服务。在这些情况下,上下文管理器非常有用。像 pytest 这样的测试库有一些特性,允许您使用上下文管理器编写测试代码,测试那些异常或者模拟服务。见示例5-25。

示例5-25. 测试异常

def divide_numbers(self, first, second):
    if isinstance(first, int) and isintance(second, int):
        raise ValueError("Value should be int")
    try:
        return first/second
    except ZeroDevisionException:
        print("Value should not be zero")
        raise

with pytest.raises(ValueError):
    divide_numbers("1", 2)

还可以将其用于模拟测试:

with mock.patch("new_class.method_name"):
    call_function()

mock.patch 是将上下文管理器用作装饰器的一个例子。

# 共享资源

使用 with 语句,可以一次只能允许一个操作访问某资源。假设您要在 Python 中为一个文件的写操作加锁,一次只能一个进程访问它。那么您就可以使用上下文管理器来实现这一点,如示例5-26。

示例5-26. 使用共享资源读取时锁定文件

from filelock import FileLock

def write_file(file_name):
    with FileLock(file_name):
        # work with the file as it is now locked
        print("Lock acquired.")

这段代码使用 filelock 库来锁定文件,从而达到一次只有一个操作可以访问该文件的目的。

当有一个进程正在使用该文件时,上下文管理器会阻止您访问它。

# 远程连接

在网络编程中,您主要与套接字交互,并通过网络使用网络协议访问不同的内容。如果要使用远程连接访问资源或在远程连接上工作,请考虑使用上下文管理器管理资源。远程连接是使用上下文管理器的最佳例子之一。见示例5-27。

示例5-27. 使用远程连接读取时锁定文件

class Protocol:
    def __init__(self, host, port):
        self.host, self.port = host, port

    def __enter__(self):
        self._client = socket()
        self._client.connect((self.host, self.port))
        return self

    def __exit__(self, exception, value, traceback):
        self._client.close()

    def send(self, payload): <发送数据的代码...>
    def receive(self): <接收数据的代码...>

with Protocol(host, port) as protocol:
    protocol.send(['get', signal])
    result = protocol.receive()

此代码使用上下文管理器通过套接字访问远程连接。它帮你处理了很多事情。

注意: 上下文管理器可以用于各种情况。当您在编写测试时需要管理资源或处理异常时,就可以使用上下文管理器。上下文管理器隐藏了大量瓶颈代码,这使得您的 API 更简洁。

# 小结

装饰器和上下文管理器是 Python 中的一级公民,是应用程序设计中的首选项。装饰器是一种设计模式,允许您在不修改代码的情况下向现有对象添加新功能。类似地,上下文管理器允许您有效地管理资源。您可以使用它在函数前后运行特定的代码片段。它们还能让 API 更加简洁易读。在下一章中,您将探索更多工具,如生成器(generator)和迭代器(iterator),以提高应用程序的质量。

# 第6章 生成器和迭代器

迭代器(iterator)和生成器(generator)是 Python 中有用的工具。它们可以让数据处理变得更加容易,帮助您编写更简洁、性能更优的代码。

在 Python 中可以通过一个库来使用这两个功能。在本章中,您将探索使用迭代器和生成器轻松应对多个不同问题,无需付出很多努力。

# 利用迭代器和生成器

在本节中,您将探索迭代器和生成器的不同特性,并将看到如何在代码中使用这两个特性以改善代码。这两个特性主要是用于解决不同的数据问题。

# 理解迭代器

迭代器是作用于数据流的一个对象。迭代器对象具有名为 __next__ 的方法,当您使用 for 循环、列表解析(list comprehension)或任何从对象或其他数据结构的全部数据点获取数据时,背后都是调用了 __next__ 方法。如示例6-1。

示例6-1. 迭代器类

class MutiplyByTwo:
    def __init__(self, number):
        self.number = number
        self.counter = 0
    def __next__(self):
        self.counter += 1
        return self.number * self.counter

>>> mul = MutiplyByTwo(500)
>>> print(next(mul))
500
>>> print(next(mul))
1000
>>> print(next(mul))
1500

让我们看看迭代器在 Python 中是如何工作的。在前面的代码中,您有一个名为 MultiplyByTwo 的类,该类有一个名为 __next__ 的方法,每当调用它时,它都会返回一个新的迭代器。迭代器需要通过在 __next__ 中使用计数器变量来记录它在序列中的位置。但是,如果您尝试在 for 循环中使用这个类,您会发现它会抛出一个错误,如下所示:

for num in MultiplyByTwo(500):
    print(num)

>>> MultiplyByTwo object is not iterable.

非常有趣的是,MutiplyByTwo 虽然是一个迭代器,但不是一个可迭代的。因此,for 循环在这里不起作用。那么,什么是可迭代的呢?

先让我们来看看迭代与迭代器是如何区别的。可迭代对象具有名为 __iter__ 的方法,该方法返回一个迭代器。当对任何对象调用 __iter__ 时,它返回迭代器,该迭代器可用于迭代对象以获取数据。在 Python 中,字符串、列表、文件和字典都是可迭代的对象。

当您尝试对它们进行 for 循环时,它工作得很好,因为 __iter__ 每次循环返回一个迭代器。

现在,您已经明白了可迭代与迭代器,我们将类 MutiplyByTwo 修改为可迭代。参加示例6-2。

示例6-2. 带 for 循环的迭代器类

class MultiplyByTwo:
    def __init__(self, num):
        self.num = num
        self.counter = 0

    def __iter__(self):
        return self

    def __next__(self):
        self.counter += 1
        return self.number * self.counter

for num in MutliplyByTwo(500):
    print(num)

此迭代器会永远运行,在某些情况下可能很有用,但如果您想要有限数量的迭代器,该怎么办?

如示例6-3所示。

示例6-3. 有 StopInteration 的迭代器类

class MultiplyByTwo:
    def __init__(self, num, limit):
        self.num = num
        self.limit = limit
        self.counter = 0

    def __iter__(self):
        return self

    def __next__(self):
        self.counter += 1
        value = self.number * self.counter
        if value > self.limit:
            raise StopIteration
        else:
            return value

for num in MutliplyByTwo(500, 5000):
    print(num)

当代码抛出 StopIteration 异常时,MutliplyByTwo 对象会收到已经到达数量限制的信号,此异常将被 Python 自动处理并退出循环。

# 什么是生成器?

生成器对于需要读取大量数据或大量文件时非常有用。它可以被暂停和继续。生成器返回的对象可以像列表一样可迭代。但是,与列表不同,它们是惰性的,并且一次仅生成一个数据项。与任何其他数据结构相比,生成器在处理大型数据集时内存效率要高得多。

让我们尝试创建与上例中的迭代器类似的乘法函数。参加示例6-4。

示例6-4. 生成器示例

def multiple_generator(num, limit):
    counter = 1
    value = number * counter
    while value <= limit:
        yield value
    counter += 1
    value = number * counter

for num in multiple_generator(500, 5000):
    print(num)

您会注意到,这比迭代器示例短得多,因为您不需要定义 __next____iter__。您也不需要跟踪内部状态或引发异常。

你可能注意到的新事物是 yield 关键字。yield 类似于函数返回,但它不是终止函数,而是暂停执行,直到请求另一个值。与迭代器相比,生成器有更好的可读性和性能。

# 何时使用迭代器

当您处理文件或数据流形式的大量数据时,迭代器非常有用。迭代器使您能够灵活地一次处理一个数据,而不是将所有数据都加载到内存中。

假设您有一个包含数字序列的 CSV 文件,您需要计算此 CSV 文件中的数字总和。可以通过将 CSV 文件中的数据序列存储在列表中,然后计算总和,或者使用迭代器方法,在迭代器方法中逐行读取 CSV 文件并计算每行的总和。

让我们从这两种方式来看一下,以便了解差异。如示例 6-5 所示。

示例6-5. 使用列表读取CSV文件

import csv

data = []
sum_data = 0

with open("numbers.csv", "r") as f:
    data.extend(list(csv.reader(f)))

for row in data[1:]:
    sum_data += sum(map(int, row))

print(sum_data)

注意,这是在列表中保存数据,然后从列表中计算数字的总和。这可能在内存方面代价最大,并可能导致内存泄漏,因为您正在将 CSV 文件以列表的形式复制到内存中。如果您正在读取一个大文件,这可能是危险的。在这里如果用迭代器,它可以从 CSV 文件中一次只获取一行,这样您就不会一次将所有数据都转储到内存中。参见示例 6-6。

示例6-6. 使用迭代器读取 CSV 文件

import csv

sum_data = 0

with open('numbers.csv', 'r') as f:
    reader = csv.reader(f)
    for row in list(reader)[1:]:
        sum_data += sum(map(int, row))

print(sum_data)

这段代码计算一行的和并将其添加到下一行,然后请求迭代器从 CSV 文件中读取一组新的数据。

迭代器的另一个用例是在从数据库中读取数据。让我们考虑这样一个场景:电子商务公司通过在线商店销售产品,用户通过在线支付购买这些产品。用户的付款被存储在一个名为 Payment 的表中,24小时后,自动系统查询付款表并计算最近24小时的总利润。

解决这个问题有两种方法。第一个选项是查询付款表,获得金额列表,然后计算这些金额的总和。在正常的一天,这可能行得通,但是考虑一个特定的日子,如黑色星期五或一个公司有数百万交易的假日。一次在内存中加载数百万条记录可能会导致系统崩溃。第二种选择是查询表,但按行或按行数(如100或1,000)获取数据,然后计算总事务。在 Django 中,可以执行类似示例6-7所示的代码。

示例6-7. 使用迭代器从数据库读取支付信息

def get_total_payment():
    payments = Payment.objects.all()
    sum_amount = 0
    if payments.exists():
        for payment in payments.iterator():
            sum_amount += payment
    return sum_amount

此代码通过一次从数据库获取一行数据而不一次加载所有数据来计算总金额。

# 使用 itertools

Python 有一个称为 itertools 的模块,该模块包含了一些有用的方法。我不能在这里罗列所有的方法,但会谈论其中的一些。

# combinations()
itertools.combinations(iterable, r)

这个工具给出可迭代的组合元组,其长度为 r。即从 iterable 中按 r 个数的任意组合。

from itertools import combinations

print(list(combinations('12345',2)))
[('1', '2'), ('1', '3'), ('1', '4'), ('1', '5'),
 ('2', '3'), ('2', '4'), ('2', '5'),
 ('3', '4'), ('3', '5'),
 ('4', '5')
]
# permuations()
itertools.permutations(iterable, r)

这返回 r 长度的所有排列;如果 r 为 None,则 r 的默认值是 iterable 的长度。

from itertools import permutations

print(list(permutations(['1','2','3'])))
[('1', '2', '3'), ('1', '3', '2'),
 ('2', '1', '3'), ('2', '3', '1'),
 ('3', '1', '2'), ('3', '2' '1')
]
# product()
itertools.product(iterable, r)

此工具计算可迭代输入的笛卡尔积。它类似于嵌套循环。

例如,函数 product(x, y) 就像是 ((x,y) for x in A for y in B)

from itertools import product

print(list(product([1,2,3],repeat = 2)))
[(1, 1), (1, 2), (1, 3),
 (2, 1), (2, 2), (2, 3),
 (3, 1), (3, 2), (3, 3)
]
# count()
itertools.count(start=0, step=1)

count() 是一个迭代器,它返回的数字以数字开头等距排列。

例如,在步骤4中告诉 count() 返回一个数字迭代器。

import itertools

for item in itertools.count(1, 4):
    print(item)
    if item > 24:
        break

>>> 1, 5, 9, 13, 17, 21, 25
# groupby()
itertools.groupby(iterable, key=None)

itertools.groupby 工具可帮助您将元素分组。

作为一个简单的示例,假设您需要按照以下方式对字符进行分组:

import itertools

numbers = '555441222'
result = []
for num, length in itertools.groupby(numbers):
    result.append((len(list(length)), int(num)))

print(*result)
>>> (3, 5)(2,4)(1,1)(3,2)

在迭代工具中还有其他实用的方法,它们都非常有用。我建议您到这里查看更多信息: https://docs.python.org/3.7/library/itertools.html (opens new window)

# 为什么生成器有用

像迭代器一样,生成器可以节省内存。因为迭代器能够进行惰性更新,所以您可以通过只获取操作所需的数据来节省内存。因此,在从数据库中读取大量数据,或读取大型文件时,可以使用生成器来节省内存和 CPU 消耗。

假设您想以惰性的方式读取文件,您可以使用 yield关键字,它将创建一个生成器函数。参见示例6-8。

示例6-8. 使用生成器读取数据块

def read_in_chunks(file_handler, chunk_size=1024):
    """Lazy function (generator) to read a file piece by piece.
    Default chunk size: 1k."""
    while True:
        data = file_handler.read(chunk_size)
        if not data:
            break
        yield data

f = open('large_number_of_data.dat')
for piece in read_in_chunks(f):
    print(piece)

在这里,您将每次读取一个大文件中的一块数据,而不是在 while 循环中把整个文件都加载到内存中。

# 列表解析与迭代器

列表解析(List Comprehension)和迭代器是生成数字的两种不同的方法,它们在如何将数据保存在内存中,或在生成数字时执行操作等方面有着显著的不同。

# 这是迭代器表达式,生成最多 200 个数字。
(x*2 for x in range(200))

# 列表解析表达式,生成最多 200 个数字。
[x*2 for x in range(200)]

译者注: 原文在上面的代码中使用 xrange 函数,而在 Python 3 中已经将 range() 与 xrange() 合并为 range() 函数。因此,译者将用 range 替换掉 xrange 函数。在后面的代码中也是如此。

这里的主要区别是列表解析在完成后会保存所有的 200 个数字。但是,迭代器创建一个可迭代对象,该对象可动态生成数字,因此在迭代器情况下,速度非常快。此外,迭代器还使您能够灵活地传递对象以动态生成数字。

# 利用 yield 关键字

在深入研究 yield 之前,我将讨论如何在 Python 中使用 yield 关键字。

当您在一个函数中定义 yield 时,调用该函数将得到一个生成器对象。然而,这并不能运行该函数。只有获得了生成器对象,并且每次从生成器中提取对象(通过使用 for 循环或使用 next()), Python 才执行该函数,直到遇到 yield 关键字为止。一旦 Python 遇到 yield 关键字,它就会交出对象并且暂停该函数,直到您提取它。一旦提取了对象, Python 就会在 yield 之后继续运行代码,直到它达到另一个 yield (可以是相同的 yield 关键字,也可以是不同的 yield )。一旦一个生成器被耗尽,它将带着一个 StopIteration 异常退出,这个异常将由 for 循环自动处理。

换句话说,除了函数返回生成器外,yield 是一个与 return 类似的关键字。参见示例6-9。

示例6-9. 使用生成器生成一个数字

def generate_numbers(limit):
    for item in range(limit):
        yield item*item
        print(f"Inside the yield: {item}")

numbers = generate_numbers(5)  # 创建一个生成器(generator)

print(numbers)  # numbers 是一个对象!
<generator object generate_numbers at 0x000002BAB52065E0>

for item in numbers:
    print(item)

0
Inside the yield: 0
1
Inside the yield: 1
4
Inside the yield: 2
9
Inside the yield: 3
16
Inside the yield: 4

这里,您使用 yield 关键字创建了一个生成器函数。注意,当您调用函数 generate_numbers() 时,您会得到 numbers 对象,它是一个生成器对象。然后可以使用它动态生成数字。

当您第一次在 for 循环中调用生成器对象时,它便从 generator_numbers 开始运行函数,直到遇到 yield 关键字,然后停止并返回循环的第一个值。一旦它第二次在 for 循环中调用生成器对象,它就执行下一行代码,即 print(f"Inside the yield: {item}")。它会一直重复这些步骤,直到达到 limit。

# yield from

从 Python 3开始就使用了 yield from 关键字。yield from 的主要用例是从其他生成器获取值,如示例 6-10所示。

示例6-10. 使用 yield from 关键字生成一个数字

def flat_list(iter_values):
    """flatten a multi list or something."""
    for item in iter_values:
        if hasattr(item, '__iter__'):
        yield from flat_list(item)
    else:
        yield item

print(list(flat_list([1, [2], [3, [4]]])))
>>> [1, 2, 3, 4]

您使用的是 yeild from ,而不是迭代 flat_list,这不仅缩短了行数,还使得代码更简洁。

# yield 比于数据结构更快

如果您需要高效快速地处理大量数据,那么显然应该使用生成器来生成数据,而不是依赖列表或元组等数据结构。

data = range(1000)

def using_yield():
    def wrapper():
        for d in data:
            yield d
    return list(wrapper())

def using_list():
    result = []
    for d in data:
        result.append(d)
    return result

如果同时运行这两个代码示例,您会注意到使用 yield 绝对比使用 list 更快。

# 小结

Python 中的生成器和迭代器非常有用,尤其是在处理大量数据或大文件时。您需要格外小心内存和 CPU 消耗,因为内存和 CPU 的过度消耗可能会导致内存泄漏等问题。Python 因此提供了 itertools 和 yield 等工具来帮助您避免这些问题。另外在处理大型文件、处理数据库或调用多个 API时,也需要格外小心。您可以使用这些工具让您的代码更简洁、更高效。

# 第7章 使用的 Python 新功能

最新版的 Python 3 中引入的新功能使 Python 编程变得更加有趣。如今,Python 已经具备很多出色的功能,最新的 Python 3 更是让它成为功能丰富的编程语言。Python 3 支持异步编程、数据类型、更优的性能、迭代器改进等等。

在本章中,您将了解众多的新功能,可以让代码更好、性能更优。经过本章的学习,您会了解如何使用这些功能以及它们的适用面,也许会对您之后的编程大有裨益。

注意: 您可以在官方文档 https://docs.python.org/3/whatsnew/index.html (opens new window) 中探索发现 Python 中的新功能。在编写本书时,Python 3 仍处于开发阶段,因此请关注Python的官方文档,了解最新的功能及改进内容。

# 异步编程

如果您曾经使用过诸如 JavaScript 等其它编程语言来进行异步编程,您想必会认识到这并不容易。在 Python 3.4 之前,虽然可以使用第三方库来进行异步编程,但与 NodeJS 这样对于异步编程友好的语言相比总会显得相形见绌。

Python 在这个问题上非常灵活,因为您可以同时编写同步和异步代码。与同步编程相比,采用异步编程可以使您的代码更加高效,因为它能够更加高效地调用资源。尽管如此,知道何时应该使用异步编程,何时不应该使用异步编程才是真正的关键。

在此之前,先让我们讨论一下异步编程与同步编程。在同步世界中,事件每次仅发生一个。每调用一个函数或操作,程序都会等待这个操作完成之后再执行下一个操作。每当函数完成操作,就会返回该操作的结果。当函数正在执行某操作的时候,系统除了等待操作完成之外,什么也做不了。

在异步世界中,多个事件可以同时发生。启动操作或调用函数时,程序将继续运行。您可以执行其它操作或调用其它函数,而不是等待该异步函数完成。当异步函数完成工作后,程序就可以访问其结果。

例如,假设您通过调用不同公司的股票 API 来获取不同公司的股票数据。在同步代码中,您将调用第一个股票 API 并等待其响应,收到答复后才能进行另一个调用。这是程序运行的一种简单方式。但是,程序花费太多的时间来等待响应。在异步代码中,您可以调用第一个股票 API,然后调用第二个和第三个等等,直到从这些 API 之中获得结果。您可以收集这些结果并继续调用其它股票 API,而不必花费太多时间去等待答复。

在本节中,您将探索 Python 中的异步编程,以便了解如何使用它。以下是 Python 异步编程的三个主要构建模块:

  • 事件循环(event loop): 主要任务是管理不同的任务并分发它们去执行,事件循环会注册每个任务,并负责这些任务之间的流转控制。
  • 协程(coroutine): 安排某事件循环去执行的函数,await 将释放控制回到事件循环。

    译者注:coroutine 通常被翻译为协程、协同例程或协同函数,本文统一称呼此术语为“协程”。

  • 未来(future): 表示可能执行或尚未执行的任务的结果,此结果可能会是一个异常(Exception)。

# 介绍 Python 中的异步

为了实现 Python 中的异步编程,Python 引入了两个主要组件。

  • asyncio: 这是允许 API 运行和管理协程的 Python 包。
  • async/await: Python 引入了两个用于异步代码的新关键字。它们可帮助您定义协程。

Python 现在能够以两种不同的方式运行,异步或是同步。因为代码的功能和行为不同,因此在设计代码时,根据选择的运行方式,应该以不同的方式来思考。针对不同的编程方式也有不同的库。换句话说,异步编程和同步编程的风格和语法是不同的。

为了说明这一点,假设您要进行 HTTP 调用,则不能使用阻塞式 request 库。因此,您可能需要考虑使用 aiohttp 库来进行 HTTP 调用。类似的,如果您正在使用 Mongo 驱动程序,则不能依赖于同步的驱动程序,如 mongo-python。您必须使用异步驱动程序(如 motor)来访问 MongoDB。

在同步的世界里,要在 Python 中实现并发或并行并不容易。可以选择使用 Python 的线程模型并行运行代码。然而,在异步世界中(不要将它与并行性混淆),这种情况得到了改善。现在,全部都运行在事件循环中,它允许您同时运行多个协程。这些协程同步运行,直到它们遇到 await 后暂停,把控制交给事件循环。其它协序将有机会执行某个操作,或者发生其它某个事情。

还必须注意,不能在同一函数中混有异步和同步代码。例如,在同步的代码中不能使用 await 关键字。

在深入了解异步编程之前,您应该了解以下几点,尤其是在 Python 中。

  • 在同步编程中,当您想要停止执行或使程序不执行任何操作时,通常使用 Python 的 time.sleep(10) 函数。但是,在异步世界中,这不会像您所期望的那样工作。您应该改用 await asyncio.sleep(10),这将不会把控制交还给事件循环,并且它可以阻止整个操作,什么也不会发生。考虑到这将让代码从一个 await 调用到下一个 await 调用时,争用情况更难以发生,也许是一件好事。
  • 如果您在异步函数中使用阻塞代码,Python 不会抱怨您使用它。然而,执行会被严重放缓。此外,Python 还具有调试模式,它将警告您存在一些因常见错误导致的阻塞时间过长。
  • 在同一代码库中编写异步和同步代码时,可能需要考虑出现重复的代码。在大多数情况下,对于异步代码和同步代码,几乎不大可能使用相同的库。
  • 编写异步代码时,与同步代码的完全控制相比,您应该认识到执行时的控制可能会丢失。特别是当代码中运行了多个协程时,许多情况都可能发生。
  • 正如您所想象的那样,在异步世界中调试变得更加困难。到目前为止,还没有很好的工具或技术可用于调试。
  • 在 Python 中测试异步代码并不方便。缺乏用于测试异步代码的良好库。您可能会看到一些库试图实现此目的,但它们不像 JavaScript 等其它编程语言那样成熟。
  • 在同步代码中使用 Python 的异步关键字,比如在同步函数中使用 await,将会导致语法错误。

设计异步代码时,改变思维方式很重要。如果代码库中同时具有异步和同步代码,必须以不同的方式来看待它们。在 async def 中的任何内容都是异步代码,其他内容都是同步代码。

有两种情况可以考虑使用异步代码。

  • 从异步代码调用异步代码。您可以使用所有 Python 关键字(如 await、async)来充分使用 Python 进行异步编程。
  • 从同步代码调用异步代码。Python 3.7 就可以做到,只需在 asyncio 中调用 run() 函数即可。

总而言之,编写异步代码不像在 Python 中编写同步代码那么容易。Python 异步模型基于事件、回调、传输、协议和未来等概念。好消息是,asyncio 库正在不断发展,并且每个版本都在改进,Python 的 asyncio 已经到来!

注意: 在编写任何异步代码之前,尤其是在具有同步编程背景的情况下,请确保以正确的思维方式来进行异步编程。因为很多时候,您会发觉你不能理解异步编程。以逐步一点一滴的方式使用异步代码并将其以最不影响代码库的方式引入,是开始使用异步编程的好方法。对异步代码进行良好的测试将确保代码库中的更改不会破坏现有功能。Python 的异步编程发展很快。因此,请留意 Python 新版本中异步编程的新特性。

# 工作原理

我们已经讨论了一些关于 asyncio 的特性和背景,接下来让我们看看 asyncio 在现实世界里是如何工作的。Python 中引入了 asyncio 包来编写异步代码。该包提供了两个关键字,async 和 await。接下来让我们通过一个简单的异步编程例子,来看看 Python 中的异步方式究竟是如何工作的。请参阅示例7-1。

示例 7-1. 异步编程简单的例子 Hello

import asyncio

async def hello(first_print, second_print):
    print(first_print)
    await asyncio.sleep(1)
    print(second_print)

asyncio.run(hello("Welcome", "Good-bye"))
Welcome
Good-bye

示例7-1中展示了一些简单的异步代码。执行的结果为首先打印出 Welcome,接着执行函数中的 asyncio.sleep(1) 方法后等待一秒钟,最后打印出 Good-bye。让我们来看看它的执行过程。 首先,asyncio.run() 调用异步函数 hello,其中传递了两个参数:Welcome 和 Good-bye。调用 hello 函数时,它首先打印 first_print 指向的参数内容,然后等待一秒钟才能打印 second_print 指向的内容。这种执行方式表面上看起来像是同步代码的执行过程。不过,了解其中执行细节的奥妙之处兴许会让您大吃一惊,并帮助您理解异步代码的实际工作原理。首先让我们了解一下此处使用到的一些术语。

# 协程函数

在 Python 中任何以 async def 定义的函数都被称作协程函数(coroutine,简称协程)。在上面的例子中,async def hello(first_print,second_print) 就是一个协程函数。

# 协程对象

通过调用协程函数返回的对象,被称为协程对象。之后您将看到一些例子,通过这些例子您会进一步理解在现实世界中协程与协程对象之间的差别。

# asyncio.run()

该函数是 asyncio 模块的一部分。这是任何异步代码的主要入口,只会被调用一次。执行过程中它做了几件事:

  • 它负责运行传递过来的协程函数,如运行上例中的 async def hello() 协程。
  • 它还负责管理 asyncio 事件循环。基本上就是创建了一个新的事件循环,并在结束时关闭它。
# await

await 是一个关键字,该关键字将函数的控制权传递回事件循环并暂停协程的执行。在前面的示例中,当 Python 执行过程中遇到 await 关键字时,它将暂停 hello 协程的执行一秒钟,并将函数的控制权交还回事件循环,该循环将在一秒钟后恢复。

在详细介绍之前,让我们看一个更为简单的例子,看看这个例子中又会发生些什么。await 通常会在它等待的东西前暂停协程的执行。当协程执行的结果返回后,将继续执行。此处是 await 的几条规则:

  • 只能在 async def 定义的函数体内使用。
  • 不能在普通函数定义中使用,否则会引发异常。
  • 要调用协程,就必须等待它将结果返回。
  • 当您编写类似 await func() 之类的代码时,需要该函数 func() 所指向的对象是可等待的对象,这也就意味着它应该是另一个协程或者是一个定义了 __await__() 方法的对象,并且该方法返回一个迭代器(iterator)。

现在,让我们看一个更加有用的例子,如示例7-2所示,您将尝试利用异步方式同时运行多个任务。

示例 7-2. 采用异步方式运行两个任务

import asyncio
import time

async def say_something(delay, words):
    print(f"Before: {words}")
    await asyncio.sleep(delay)
    print(f"After: {words}")

async def main():
    print(f"start: {time.strftime('%X')}")
    await say_something(1, "First task started.")
    await say_something(1, "Second task started.")
    print(f"Finished: {time.strftime('%X')}")

asyncio.run(main())

输出结果:
start: 11:30:11
Before: First task started.
After: First task started.
Before: Second task started.
After: Second task started.
Finished: 11:30:13

在上面这个例子中,我们两次调用协程 say_something 并等待它们完成,这里运行了两次相同的协程。正如您在结果中注意到的,say_something 协程先运行,然后等待一秒钟,接着继续完成该协程。然后,main() 协程再次调用它以执行另一个任务,即在一秒钟后打印第二个任务。这并不是您使用 async 时所想要的,这看起来像是同步代码在运行。总之,异步代码背后的核心思想是您可以同时运行 say_something。

让我们来改造此代码,实现同时运行,如示例7-3所示。您可能会注意到与上一个示例7-2相比,代码中发生了一些显著的变化。

示例 7-3. 异步方式并发执行代码

import asyncio
import time

async def say_something(delay, words):
    print(f"Before: {words}")
    await asyncio.sleep(delay)
    print(f"After: {words}")

async def main():
    print(f"Starting Tasks: {time.strftime('%X')}")
    task1 = asyncio.create_task(say_something(1, "First task started"))
    task2 = asyncio.create_task(say_something(2, "Second task started"))
    await task1
    await task2
    print(f"Finished Tasks: {time.strftime('%X')}")

asyncio.run(main())

输出结果:
Starting Tasks: 11:43:56
Before: First task started
Before: Second task started
After: First task started
After: Second task started
Finished Tasks: 11:43:58

从结果中可以看出,该函数同时运行了具有不同参数的相同协程,这正是您想要的,同时并发执行。

接下来,让我们分析一下该例子中所发生的情况:

  • say_something 协程从参数的第一个任务(称为 task1)开始。
  • 然后,当它遇到 await 关键字时,它会暂停执行一秒钟。
  • 当 task1 遇到 await 后,它将暂停正在运行的协程并将控制权返还给事件循环。
  • 另一个称为 task2 的任务是通过将协程 say_something 以 create_task 用参数来创建的。
  • 当第二个任务 task2 开始运行时,它遇到类似于 task1 在协程 async def say_something 中遇到的 await 关键字。
  • 然后,它使 task2 暂停两秒钟,并将控制权返回到事件循环。
  • 现在事件循环将恢复第一个任务(task1),因为 asyncio.sleep 函数已经执行完成(休眠一秒钟)。
  • 当任务 task1 完成工作时,第二个任务 task2 将恢复运行并完成。

想必您会第一时间注意到 asyncio.create_task() 函数,它使函数作为 asyncio 任务来并发地运行协程。

# 任务

每当使用类似 asyncio.create_task() 的方法来调用任何协程时,该协程都会自动地计划尽快执行。

任务可帮助您同时运行协程,Python 调用 Python asyncio 世界里那些正在运行的协程任务。让我们来看一个简单的使用 asyncio 库创建任务的示例,参见示例7-4。

示例 7-4. 创建简单任务

import asyncio

async def value(val):
    return val

async def main():
    # Creating a task to run concurrently
    # You can create as many task as possible here
    task = asyncio.create_task(value(89))
    # This will simply wait for task to finish
    await task

asyncio.run(main())

创建任务并等待全部任务完成的另一种方法是用 asyncio.gather 函数。asyncio.gather 能够作为任务运行全部协程,并在返回到事件循环之前等待其结果。

让我们来看一个简单的例子。请参阅示例7-5。

示例 7-5. 使用 asyncio.gather 方法并发执行任务

import asyncio
import time

async def greetings():
    print("Welcome")
    await asyncio.sleep(1)
    print("Good Bye")

async def main():
    await asyncio.gather(greetings(), greetings())

def say_greet():
    start = time.perf_counter()
    asyncio.run(main())
    elapsed = time.perf_counter() - start
    print(f"Total time elapsed: {elapsed}")

asyncio.run(say_greet())

执行结果:
Welcome
Welcome
Good Bye
Good Bye
Total time elapsed: 1.006283138

让我们试着去了解一下前面的代码是如何用 asyncio.gather 来运行的。当您运行此代码时,您会注意到 Welcome 出现在控制台上两次,接着 Good Bye 两次。在打印两个 Welcome 和两个 Good Bye 消息之间有轻微的延迟。

当您从 say_greet() 调用 async main() 函数时,事件循环负责与 greetings() 函数对话,执行 greetings() 函数可以被视作一个任务。

在前面的代码中,有两个任务,执行它们就是执行 greetings() 函数。

我还没有谈论过的内容之一是 await 关键字。这是 Python 中异步编程的重要关键字之一。任何可以使用 await 关键字的对象都可以称之为“可等待对象”。理解可等待对象也很重要,因为它将让您更好地理解 asyncio 库的操作方式,以及如何在 Python 中的不同任务之间切换。

# 可等待对象

如前所述,与 await 关键字一起使用的任何对象都称为可等待对象。大多数的 asyncio API 都接受可等待对象。

可等待对象在异步代码中具有以下几种类型。

# 协程型

我们在上一节中已经讨论了协程的概念。在这里,您将进一步了解这一点,并了解它为什么是一种可等待的类型。

所有协程都是可等待的,因此可以从其它协程来等待它们。您还可以将协程定义为子函数,但是它可以在不破坏异步世界中的状态的情况下退出。参见示例7-6。

示例 7-6. 协程中等待其它协程

import asyncio

async def mult(first, second):
    print(f"Calculating multiply of {first} and {second}")
    await asyncio.sleep(1)
    num_mul = first * second
    print(f"Multiply of {num_mul}")
    return num_mul

async def sum(first, second):
    print(f"Calculating sum of {first} and {second}")
    await asyncio.sleep(1)
    num_sum = first + second
    print(f"Sum is {num_sum}")
    return num_sum

async def main(first, second):
    await sum(first, second)
    await mult(first, second)

asyncio.run(main(7, 8))

输出结果:
Calculating sum of 7 and 8
Sum is 15
Calculating multiply of 7 and 8
Multiply of 56

正如上例所示,您可以多次调用协程,并在协程中使用 await 关键字。

# 任务型

当协程使用异步方法 asyncio.create_task() 被包装成为一个任务(task)时,该协程将被调度执行。大多数情况下,如果您使用异步代码,那么您是在用 create_task 方法来并发运行协程。参见示例7-7。

示例 7-7. create_task 方法让协程去调度执行

import asyncio

async def mul(first, second):
    print(f"Calculating multiply of {first} and {second}")
    await asyncio.sleep(1)
    num_mul = first * second
    print(f"Multiply of {num_mul}")
    return num_mul

async def sum(first, second):
    print(f"Calculating sum of {first} and {second}")
    await asyncio.sleep(1)
    num_sum = first + second
    print(f"Sum is {num_sum}")
    return num_sum

async def main(first, second):
    sum_task = asyncio.create_task(sum(first, second))
    mul_task = asyncio.create_task(sum(first, second))
    await sum_task
    await mul_task

asyncio.run(main(7, 8))

输出结果:
Calculating sum of 7 and 8
Calculating sum of 7 and 8
Sum is 15
Sum is 15

如本例中所示,我们通过 asyncio.create_task 创建任务,可以并发运行两个不同的协程。

一旦任务创建后,使用 await 关键字并发运行新创建的任务。当这两个任务完成后,结果将会被发送到事件循环。

# 未来型

未来(Future)是可等待的对象,表示异步操作的未来结果。协程需要等待未来对象返回响应或完成操作。大多数情况下,您不会在代码中显式地使用未来对象,该对象已隐式地由 asyncio 处理。

创建未来实例,这意味着它还未完成,但将在未来的某个时间完成。

Future 有诸如 done()cancel() 的方法。不过,您大多不需要编写这样的代码,但了解未来对象也至关重要。

未来对象实现了 __await__() 方法,未来对象的职责是保存某些状态和结果。

未来具有以下状态:

  • 待定(PENDING): 这表示未来正在等待完成。
  • 已取消(CANCELED): 如前所述,可以使用 cancel 方法取消未来对象。
  • 已完成(FINISHED): 未来对象有两种完成方式,即 Future.set_result() 或为 Future.set_exception() 异常。

示例7-8显示了一个未来对象的示例。

示例 7-8. 未来对象

from asyncio import Future

future = Future()
future.done()

输出结果:
False

现在可能是进一步了解 asyncio.gather 的好时机,因为您可能更好地理解了可等待的方法在异步世界是如何运作的。

注意: 此处我只介绍 gather 方法,但是我建议您看看其它异步方法,包括它们的语法。主要是了解这些函数需要哪种输入参数以及原因。

它的语法如下所示:

asyncio.gather(*aws, loop=None, return_exceptions=False)

aws 可以是一个协程或是被加入到任务中的协程列表。当完成全部任务之后,asyncio.gather 方法就聚合它们并返回结果。它根据这些可等待对象的顺序来执行任务。

默认情况下,return_exceptions 的值为 False,这意味着如果存在任何任务返回异常,则当前正在运行的其他任务不会停止,还将继续运行。如果 return_exception 的值为 True,则结果将被视为成功,并且将聚合在结果列表中。

# 超时

除了引发异常之外,您还可以在等待任务完成时触发超时。

asyncio 具有一个称为 asyncio.wait_for(aws, timeout, *) 的方法,可用于设置即将要被执行的任务的超时。如果发生超时,它将取消任务并引发 asyncio.TimeoutError 异常。超时值可以是 None、float 型、或是 int 型。如果超时值为 None,则会发生阻塞直到未来对象完成。示例7-9是异步超时的一个示例。

示例 7-9. 异步超时

import asyncio

async def long_time_taking_method():
    await asyncio.sleep(4000)
    print("Completed the work")

async def main():
    try:
        await asyncio.wait_for(long_time_taking_method(), timeout=2)
    except asyncio.TimeoutError:
        print("Timeout occurred")

asyncio.run(main())

输出结果:
Timeout occurred

在示例7-9中,方法 long_time_taking_method 大约花费4000秒。但我们已经将 Future 对象的超时设置为两秒钟,因此,若是在两秒钟后得不到结果,则会发生 asyncio.TimeoutError 超时错误。

注意: 本节中讨论的方法在异步代码中是最常见的,但是 asyncio 库中还存在一些其它的库和方法,它们不太常见,或是用于更复杂的场景。如果您有兴趣了解有关 asyncio 的更多内容,可以查阅 Python 的官方文档。

# 异步生成器

异步生成器(Async Generators)使得在异步函数中使用 yield 成为可能。因此,任何包含 yield 的异步函数都可以被称之为异步生成器。使用异步生成器的目的是复制同步 yield 的功能,唯一的区别是可以调用异步函数。与同步生成器相比,异步生成器确实提高了生成器的性能。根据 Python 文档,异步生成器比同步生成器快2.3倍。参见示例7-10。

示例 7-10. 异步生成器

import asyncio

async def generator(limit):
    for item in range(limit):
        yield item
        await asyncio.sleep(1)

async def main():
    async for item in generator(10):
        print(item)

asyncio.run(main())

输出结果:
0
1
2
3
4
5
6
7
8
9

这将每隔一秒打印出1到9。这个示例展示了如何在异步协程中使用异步生成器。

# 异步解析

Python 的异步功能提供了实现异步解析的工具,类似于同步代码的列表、字典、元组和集合的解析。换句话说,异步解析类似于在异步代码中使用解析。让我们看看示例7-11,它展示了如何使用异步解析。

示例 7-11. 异步解析

import asyncio

async def gen_power_two(limit):
    item = 0
    while item < limit:
        yield 2 ** item
        item += 1
        await asyncio.sleep(1)

async def main(limit):
    gen = [item async for item in gen_power_two(limit)]
    return gen

print(asyncio.run(main(5)))

输出结果:
[1, 2, 4, 8, 16]

这将打印从2到16的数字列表。但是,您必须等待5秒钟才能看到结果,因为它将完成全部任务,然后返回结果。

# 异步迭代器

您已经看到迭代器的一些示例,如 asyncio.gather,这是迭代器的一种形式。在示例7-12中,您可以使用 asyncio.as_completed() 来查看迭代器,该迭代器在结束时获得任务。

示例 7-12. 异步迭代器中使用 as_completed

import asyncio

async def is_odd(data):
    odd_even = []
    for item in data:
        odd_even.append((item, "Even") if item % 2 == 0 else (item, "Odd"))
    await asyncio.sleep(1)
    return odd_even

async def is_prime(data):
    primes = []
    for item in data:
        if item <= 1:
            primes.append((item, "Not Prime"))
        if item <= 3:
            primes.append((item, "Prime"))
        if item % 2 == 0 or item % 3 == 0:
            primes.append((item, "Not Prime"))
        factor = 5
        while factor * factor <= item:
            if item % factor == 0 or item % (factor + 2) == 0:
                primes.append((item, "Not Prime"))
            factor += 6
    await asyncio.sleep(1)
    return primes

async def main(data):
    odd_task = asyncio.create_task(is_odd(data))
    prime_task = asyncio.create_task(is_prime(data))
    for res in asyncio.as_completed((odd_task, prime_task)):
        compl = await res
        print(f"completed with data: {res} => {compl}")

asyncio.run(main([3, 5, 10, 23, 90]))

输出结果:
completed with data: <coroutine object as_completed.<locals>._wait_for_one at 0x000001A0EF70A0A0> => [(3, 'Odd'), (5, 'Odd'), (10, 'Even'), (23, 'Odd'), (90, 'Even')]
completed with data: <coroutine object as_completed.<locals>._wait_for_one at 0x000001A0EF70A110> => [(3, 'Prime'), (3, 'Not Prime'), (10, 'Not Prime'), (90, 'Not Prime'), (90, 'Not Prime')]

如示例7-12的结果所示,两个任务同时运行,并且基于质数和奇数/偶数状态在列表中传递给两个协程。

当使用 asyncio.gather 函数时,只需通过 asyncio.gather 而不用 asyncio.as_completed 即可创建类似的任务,正如示例 7-13所示。

示例7-13. 在任务中使用 asyncio.gather 进行迭代

import asyncio

async def is_odd(data):
    odd_even = []
    for item in data:
        odd_even.append((item, "Even") if item % 2 == 0 else (item, "Odd"))
    await asyncio.sleep(1)
    return odd_even

async def is_prime(data):
    primes = []
    for item in data:
        if item <= 1:
            primes.append((item, "Not Prime"))
        if item <= 3:
            primes.append((item, "Prime"))
        if item % 2 == 0 or item % 3 == 0:
            primes.append((item, "Not Prime"))
        factor = 5
        while factor * factor <= item:
            if item % factor == 0 or item % (factor + 2) == 0:
                primes.append((item, "Not Prime"))
            factor += 6
    await asyncio.sleep(1)
    return primes

async def main(data):
    odd_task = asyncio.create_task(is_odd(data))
    prime_task = asyncio.create_task(is_prime(data))
    compl = await asyncio.gather(odd_task, prime_task)
    print(f"completed with data: {compl}")
    #return compl

asyncio.run(main([3, 5, 10, 23, 90]))

输出结果:
completed with data: [[(3, 'Odd'), (5, 'Odd'), (10, 'Even'), (23, 'Odd'), (90, 'Even')], [(3, 'Prime'), (3, 'Not Prime'), (10, 'Not Prime'), (90, 'Not Prime'), (90, 'Not Prime')]]

您可能会注意到,在此不需要编写循环,因为 asyncio.gather 会为您编写该循环。它收集所有结果的数据,并将其发送回调用方。

# 第三方异步库

除了 asyncio 之外,还有一些第三方库可以实现同样的目标。大多数第三方库都试图解决异步中的一些问题,但是考虑到 Python asyncio 库的不断改进,除非您需要 asyncio 完全不具备的东西,否则我建议您在项目中使用 asyncio。让我们看一下一些异步的第三方库。

# Curio

Curio 是一个第三方库,允许您使用 Python 中的协程执行并发 I/O。它基于任务模型,该模型提供线程和进程之间交互的高级处理。示例7-14显示了一个使用 Curio 库编写异步代码的简单示例。

示例 7-14. Curio示例

import curio

async def generate(limit):
    step = 0
    while step <= limit:
        await curio.sleep(1)
        step += 1

if __name__ == "__main__":
    curio.run(generate, 10)

这将以异步方式生成1到10的数字。Curio 通过调用 run() 启动内核,并使用 async def 的方法定义任务。

任务应在 Curio 内核内运行,该内核负责运行任务,直到没有需要运行的任务为止。

使用 Curio 时需要记住的一件事情是:异步函数将作为任务来运行,且每个任务都需要在 Curio 内核中运行。

让我们再看一个 Curio 库的示例,该示例实际上运行了多个任务。参见示例 7-15。

示例 7-15. Curio 多任务

import curio

async def generate(limit):
    step = 0
    while step <= limit:
        await curio.sleep(1)
        step += 1

async def say_hello():
    print("Hello")
    await curio.sleep(1000)

async def main():
    hello_task = await curio.spawn(say_hello)
    await curio.sleep(3)
    
    gen_task = await curio.spawn(generate, 5)
    await gen_task.join()
    
    print("Welcome")
    await hello_task.join()
    print("Good by")

if __name__ == '__main__':
    curio.run(main)

您可能已经猜到,这显示了创建和加入任务的过程。这里有两个主要的概念需要掌握。

首先是 spawn 方法以协程作为参数,并启动新的 hello_task 任务。其次是 join 方法等待任务完成,然后再返回到内核。

我希望这能帮助您理解 Curio 是如何在 Python 中实现并发。您也可以从 Curio 的官方文档上查看更多详细信息。

# Trio

Trio 是一个像 Curio 一样的现代开源库。它承诺让 Python 中编写异步代码变得更容易。在 Trio 中值得注意的一些功能如下:

  • 具有良好的可扩展性机制。
  • 可同时运行 10,000 个任务。
  • Trio 是用 Python 编写的,这可能对希望深入了解工作原理的开发人员有用。
  • 容易快速上手,因为 Trio 文档真的非常优秀。如果你想寻找某个功能,都会有很好的说明文档。

让我们快速看一个简单的 Trio 示例,感受一下 Trio 的异步代码。参见示例 7-16。

示例 7-16. Trio,简单的异步代码

import trio

async def greeting():
    await trio.sleep(1)
    return "Welcome to Trio!"

trio.run(greeting)

输出结果:
Welcome to Trio!

正如您所见,代码很容易理解。Trio 使用 run() 方法运行异步函数,该方法先启动 greeting 异步函数的执行,然后中断执行一秒钟,最后返回结果。让我们看一个更有用的示例,您可以在其中用 Trio 运行多个任务。

我们将示例 7-13 asyncio 版本的 is_odd 和 is_prime 异步函数转换为 Trio,以便您可以更好地了解 Trio 的使用。参见示例 7-17。

示例 7-17. Trio 执行多项任务

import trio

async def is_odd(data):
    odd_even = []
    for item in data:
        odd_even.append((item, "Even") if item % 2 == 0 else (item, "Odd"))
    await trio.sleep(1)
    return odd_even

async def is_prime(data):
    primes = []
    for item in data:
        if item <= 1:
            primes.append((item, "Not Prime"))
        if item <= 3:
            primes.append((item, "Prime"))
        if item % 2 == 0 or item % 3 == 0:
            primes.append((item, "Not Prime"))
        factor = 5
        while factor * factor <= item:
            if item % factor == 0 or item % (factor + 2) == 0:
                primes.append((item, "Not Prime"))
            factor += 6
    await trio.sleep(1)
    return primes

async def main(data):
    print("Calculation has started!")
    async with trio.open_nursery() as nursery:
        nursery.start_soon(is_odd, data)
        nursery.start_soon(is_prime, data)

trio.run(main, [3, 5, 10, 23, 90])

您可能已经注意到了,is_prime 和 is_odd 异步函数并没有太大的变化,因为它们在这里的工作方式与在 asyncio 中相似。

这里主要的区别是 main() 函数。不是调用 asyncio.as_completed 函数,而是调用 trio.open_nursery 方法,该方法获取 nursery 对象。nursery 对象使用函数 nursery.start_soon 来运行异步协程。

一旦 nursery.start_soon 将异步函数 is_prime 和 is_odd 包裹起来,这两个任务将在后台开始运行。

async with 语句块将强制让 main() 函数停下并等待所有协程都完成,然后从 nursery 中退出。

运行上述7-17中的例子后,您可能会注意到它的运行方式与 asyncio 的例子相似,其中 is_prime 和 is_odd 函数都是并发运行。

注意: Curio 和 Trio 是在本书写作时编写异步代码的两个值得关注的库。理解好 asyncio 将帮助您快速上手任何第三方库。我建议在选择任何第三方库之前,先充分学习 asyncio,因为大多数库的底层实现都会或多或少地用到一些 Python 的异步功能。

# Python 中的类型

Python 是一种动态语言,因此用 Python 编写代码通常不必定义类型。如果您使用的是 Java 或者 .NET 之类的语言,那么在编译代码之前就必须了解这些类型,否则,这些语言将出错。

数据类型对于调试和阅读大型代码库有帮助。但有些语言如 Python 和 Ruby 提供了灵活性和自由,它们无需考虑数据类型,从而可以专注于业务逻辑。

类型是动态语言世界里的一个话题,有些开发人员喜欢它,有些不喜欢。Python 以 typing 模块的形式提供类型支持。我建议您在项目中尝试一下,看看它们对您是否有意义。

我发现它们在编写代码时很有用,特别是在调试和写代码注释的时候。

# 数据类型

从 Python 3开始,您可以在代码中使用类型。尽管如此,类型在 Python 中是可选的。当你运行代码时,它不会检查类型。

即使您定义了错误的类型,Python 也不会怎么样。但如果要确保编写的类型是正确的,可以考虑使用诸如 mypy 这样的工具,这个工具会在您定义了错误的类型时提示错误。

现在 Python 允许您在代码中添加类型,只需添加: <数据类型>。参见示例 7-18。

示例 7-18. 在 Python 中增加类型

def is_key_present(data: dict, key: str) -> bool:
    if key in data:
        return True
    else:
        return False

在这里,您通过传入一个字典(data)和键(key)来查找此字典中是否存在此键。函数定义了传入参数的数据类型 data: dictkey: str,以及返回类型 -> bool。这就是在 Python 中使用类型时要做的主要事情。

Python 理解此语法,并假定您设定了正确的类型,而不会去验证它们。但作为开发人员,它让您了解到需要传递给函数的参数类型。

您可以使用 Python 中自带的全部数据类型,不需要使用任何其他模块或库。Python 支持列表(list)、字典(dict)、整型(int)、字符串(str)、集合(set)、元组(tuple)等,无需任何其他模块。但是,在某些情况下,您可能需要更高级的类型,这些类型将在下一节中提讨论。

# ----【第7章 bobyuan 校对进度线,勿删】----

# typing 模块

对于高级应用,Python 引入了一个称为 typing 的模块,它为您提供了更多可添加到代码库的类型。您开始可能需要费一点劲来适应语法和类型,一旦您理解了此模块,您就会觉得它使得代码更简洁、更具可读性。

这里有很多方面要讲,所以让我们直接进入正题。typing 模块为您提供基本类型,如 Any、Union、元组(Tuple)、Callable、TypeVar、泛型(Generic)等。让我们简要地谈谈这些类型,以便于理解它们。

# Union

如果您事先不知道将哪种类型传递给函数,但函数希望从一组有限的类型中获取其中之一,则可以使用 Union。下面是一个示例:

from typing import Union

def find_user(user_id: Union[str, int]) -> None:
    if isinstance(user_id, int):
        user_id = str(user_id)
    find_user_by_id(user_id)
    ...

在这里,user_id 可以是 str 型或 int 型,因此您可以使用 Union 确保您的函数期望的 user_id 类型为 str 类型或 int 类型。

# Any

这是一种特殊的类型,所有其他类型都与 Any 类型一致,它代表任意值和任意方法。如果您不知道某函数在运行时接受的类型,则可以考虑使用此类型。

from typing import Any

def stream_data(sanitize: bool, data: Any) -> None:
    if sanitize:
        ...
    send_to_pipeline_for_processing(data)
# Tuple

正如您根据名称猜测的那样,这是一个用于元组(Tuple)的类型。唯一的区别是,您可以定义元组中包含的元素类型。

from typing import Tuple

def check_fraud_users(users_id: Tuple[int]) -> None:
    for user_id in users_id:
    try:
        check_fraud_by_id(user_id)
    exception FraudException as error:
        ...
# TypeVar 和 Generics

如果要定义自己的类型或重命名某个类型,则可以使用 typing 模块的 TypeVar。这将让代码更具可读性,并可用于自定义类。

这是 typing 中的一个更高级的概念。因为 typing 模块已经为您提供了足够多的类型,所以大多数时候您可能用不着它。

from typing import TypeVar, Generics

Employee = TypeVar("Employee")
Salary = TypeVar

def get_employee_payment(emp: Generics[Employee]) -> :
    ...
# Optional

当您怀疑类型“None”也将作为值而不是定义的类型传递时,可以使用 Optional。因此,您可以简单地写作 Optional[str] ,而不用 Union[str, None]。

from typing import Optional

def get_user_info_by_id(user_id: Optional[int]) -> Optional[dict]:
    if user_id:
        get_data = query_to_db_with_user_id(user_id)
        return get_data
    else:
        return None

以上是 Python 中 typing 模块的介绍。typing 模块中还有许多其他类型可供选择,您可能在现有代码库中用到它们。请参阅 Python 官方文档 https://docs.python.org/3/library/typing.html (opens new window) 了解更多信息。

# 类型会让代码变慢吗?

使用 typing 模块和类型通常不会影响代码的性能。但是,typing 模块提供了一个方法 typing.get_type_hints 用来返回对象的类型提示,第三方工具可以使用该提示来检查对象的类型。Python 不会在运行时进行类型检查,因此这不会影响您的代码性能。

根据 Python PEP 484:https://www.python.org/dev/peps/pep-0484/ (opens new window)

虽然建议的 typing 模块将包含一些用于运行时类型检查的构建基础,特别是 get_type_hints() 函数 ,必须开发第三方包来实现运行时的类型检查,例如使用装饰器或元类(metaclass)。使用类型提示进行性能优化可作为留给读者的一个练习。

# 类型如何帮助编写更好的代码

typing 可以帮助您执行静态代码分析,在将代码发送到生产环境之前捕获类型错误,防止出现一些明显的缺陷。

有一些工具,如 mypy,可以添加到您的工具箱,作为软件生命周期的一部分。mypy 可以通过部分或完全针对您的代码库运行来检查正确的类型。mypy 还可以帮助您检测缺陷,例如当从函数返回值时检查 None 类型。 键入有助于使代码更简洁。您可以使用类型而不产生任何性能成本,而不是使用注释(在文档字符串中指定类型)来记录代码。

如果您使用的是 PyCharm 或 VSCode 等 IDE,则键入模块还可以帮助您完成代码。众所周知,早期错误捕获和干净代码对于任何长期维持的大型项目都很重要。

# 键入陷阱

在使用 Python 的键入模块时,您应该注意一些陷阱:

  • 没有很好的记录。类型注释没有很好的记录。在编写自定义类或高级数据结构时,可能很难找出如何编写正确的类型。当您开始键入模块时,这可能很困难。
  • 类型不严格。由于类型提示不严格,因此无法保证变量的类型是其注释声明的类型。在这种情况下,您没有提高代码的质量。因此,由各个开发人员编写正确的类型。mypy 可能是一个检查类型的解决方案。
  • 不支持第三方库。当您使用第三方库时,您可能会发现自己正在拔毛,因为可能有很多情况下,您不知道特定第三方工具的正确类型,例如数据结构或类。在这些情况下,您可能会随意是使用。mypy 也不支持所有这些第三方库来为您检查。

注意: 键入模块当然是朝着正确方向迈出的好的一步,但在键入模块中可能需要进行大量改进。但是,使用正确的键入方式肯定会帮助您找到一些细微的错误和类型错误。使用 mypy 等工具的类型肯定会有助于使代码更简洁。

# super() 方法

super() 方法语法现在更易于使用和更具可读性。可以使用 super()方法进行继承,方法是按如下方式声明它:

class PaidStudent(Student):
 def __int__(self):
 super().__init__(self)

# 类型提示

正如我提到的,Python有一个称为类型的新模块,它为您提供在代码中的键入提示。

import typing
def subscribed_users(limit_of_users: int) -> Dict[str, int]:
 ...

# 使用pathlib更好地处理路径

pathlib 是 Python 中的新模块,可帮助您读取文件、联接路径、显示目录树和其他功能。 使用 pathlib,文件路径可以由正确的 Path 对象表示,然后您可以对该 Path 对象执行不同的操作。它有功能来查找上次修改的文件、创建唯一的文件名、显示目录树、对文件进行计数、移动和删除文件、获取文件的特定组件以及创建路径。 让我们看一个示例,其中 resolve() 方法查找文件的完整路径,如下所示:

import pathlib
path = pathlib.Path("error.txt")
path.resolve()
>>> PosixPath("/home/python/error.txt")
path.resolve().parent == pathlib.Path.cwd()
>>> False

print() 现在是一个函数。在前一版本中,它是一个语句。

  • 旧版本: print "Sum of two numbers is", 2 + 2
  • 新版本:print("Sum of two number is", (2+2))

# f-string

Python 引入了一种新的改进的字符串编写方法,称为 f-string。与以前的版本(如格式和格式方法)相比,这使得代码更具可读性。

user_id = "skpl"
amount = 50
f"{user_id} has paid amount: ${amount}"
>>> skpl has paid amount: $50

使用 f-string 的一个主要原因是它比以前的版本更快。

根据 PEP 498: https://www.python.org/dev/peps/pep-0498/ F-string 提供了一种使用最小语法在字符串文本中嵌入表达式的方法。需要注意的是,f-string 实际上是在运行时计算的表达式,而不是常量值。在 Python 源代码中,f-string 是一个文本字符串,前缀为 f,其中包含大括号内的表达式。表达式将替换为其值。

# 仅关键字参数

Python现在允许您使用*作为函数参数来定义关键字参数。

def create_report(user, *, file_type, location):
...
create_report("skpl", file_type="txt", location="/user/skpl")

现在,当您调用 create_report 时,您必须在 * 之后提供一个关键字参数。您可以强制其他开发人员使用位置参数来调用函数。

# 保留字典的顺序

现在字典保留插入的顺序。以前,您必须使用 OrderDict 来执行此操作,但现在默认字典可以执行此操作。

population_raking = {}
population_raking["China"] = 1
population_raking["India"] = 2
population_raking["USA"] = 3
print(f"{population_raking}")
{'China': 1, 'India': 2, 'USA': 3}

# 可迭代解包

现在,Python 使您能够灵活地以迭代方式解压缩。这是一个很酷的功能,您可以在其中迭代解压缩变量。

*a, = [1] # a = [1]
(a, b), *c = 'PC', 5, 6 # a = "P", b = "C", c = [5, 6]
*a, = range(10)

# 小结

本章重点介绍了 Python 3 中的新功能,如异步和数据类型,同时还有些新的特点,如路径库和顺序字典。值得一提的是 Python 3 中的趣味还远远不止于此,还有许多令人振奋的新功能。 并且时刻留意 Python 官方文档的所有改进始终是一种好的做法。Python 有很棒的文档,您可以便捷的找到您想发现的内容,这将帮助您了解任何库、关键字或模块。我希望本章已经给了你足够的动力去在您的现有代码库或新项目中尝试这些新的功能。

# 第8章 调试与测试 Python 代码

如果您正在编写代码,特别是用于开发工作,那么该代码是否具有良好的日志功能和测试用例就显得格外重要。同样重要的还有是否可以跟踪错误并修复出现的任何问题。Python有一组丰富的内置库,用于调试和测试Python代码,我将在本文中介绍这些代码。

注意: 与其它任何编程语言一样,有很多工具可以在代码中添加日志和测试。很好的理解这些工具在专业环境中很重要,在生产中运行软件可以为您赚钱。由于生产代码中的错误或错误而损失资金对公司或产品来说可能是灾难性的。因此,您需要在将代码推送到生产之前进行日志记录和测试。它还有助于有某种度量和性能跟踪工具,这样你就可以知道当你的软件被数百万用户在现实世界中使用时,情况会如何。

# 调试

调试是开发人员最重要的技能之一。大多数开发人员没有投入足够的精力来学习调试;他们通常只是在需要的时候尝试不同的事情。调试不应是事后思考的过程;这是一种在对代码中的实际问题得出任何结论之前排除不同假设的技术。在本节中,您将探索调试Python代码的技术和工具。

# 调试工具

在本节中,我将介绍 pdb、ipdb 和 pudb。

# PDB

PDB是调试Python代码最有用的命令行工具之一。 PDB提供堆栈信息和参数信息,并在PDB调试器内部跳转代码命令。 要在Python代码中设置调试器,可以编写如下内容:

import pdb

pdb.set_trace()

一旦控件到达启用pdb调试器的行,就可以使用pdb命令行选项调试代码。pdb给您以下命令:

  • h:帮助命令
  • w:打印堆栈跟踪
  • d:移动当前帧计数
  • u:移动当前帧计数
  • s:执行当前行
  • n:继续执行,直到下一行
  • unt[行号]:继续执行,直到行号
  • r:继续执行,直到当前函数返回

在pdb中还有其他命令行选项。 你可以去看看他们 https://docs.python.org/3/library/pdb.html

# ipdb

与 pdb 类似,ipdb是调试器命令行工具。 它为您提供了与 pdb 相同的功能,并增加了您可以在IPython上使用 ipdb 的优势。 可以添加 ipdb 调试器如下:

import ipdb
ipdb.set_trace()

安装后,您可以检查ipdb中的所有可用命令。大多数情况下,这些都类似于pdb,如下所示:

ipdb> help

Documented commands (type help <topic>):
========================================
EOF    clear      display  l         pfile    return           tbreak     where
a      commands   down     list      pinfo    retval           u
alias  condition  enable   ll        pinfo2   run              unalias
args   cont       exit     longlist  pp       rv               undisplay
b      context    h        n         psource  s                unt
break  continue   help     next      q        skip_hidden      until
bt     d          ignore   p         quit     skip_predicates  up
c      debug      j        pdef      r        source           w
cl     disable    jump     pdoc      restart  step             whatis

Miscellaneous help topics:
==========================
exec  pdb

Undocumented commands:
======================
interact

更多关于ipdb的信息可以在如下网址查找: https://pypi.org/project/ipdb/

ipdb与pdb具有相同的命令行选项,如图所示:

  • h:帮助命令
  • w:打印堆栈跟踪
  • d:移动当前帧计数
  • u:移动当前帧计数
  • s:执行当前行
  • n:继续执行,直到下一行
  • unt[行号]:继续执行,直到行号
  • r:继续执行,直到当前函数返回
# pudb

Pudb 是一个功能丰富的调试工具,它比PDB具有更多的功能和 ipdb。 这是一个基于控制台的可视化调试器。 您可以在编写代码时调试代码,而不是像 pdb 或 ipdb 那样跳转到命令行。 它看起来更像 GUI 调试器,但运行在控制台上,这使得它比 GUI 调试器轻量级。

您可以通过添加以下一行来在代码中添加调试器:

import pudb

pudb.set_trace()

这里也有不错的文档,你可以在里面找到更多关于pudb及其所有特征的信息,网址如下: https://documen.tician.de/pudb/starting.html (opens new window)

在pudb调试界面可以使用以下按键:

  • n:执行下一个命令
  • s:逐步
  • c:继续执行
  • b:在当前行上设置断点
  • e:显示抛出异常的回溯
  • q:打开对话框以退出或重新启动运行程序
  • o:显示原始控制台/标准输出屏幕
  • m:在不同的文件中打开模块
  • L:转到一行
  • !:转到屏幕底部的Python命令行子窗口
  • ?显示帮助对话框,其中包含快捷命令的完整列表
  • <Shift+V>:将上下文切换到屏幕右侧的变量子窗口
  • <Shift+B>:将上下文切换到屏幕右侧的断点子窗口
  • <CTRL+X>:在代码行和Python命令行之间切换上下文

例如,一旦您在pudb显示中,按b将在该行上设置一个断点,在该行中,在继续使用c快捷方式后执行停止。 一个有用的选项是设置一个变量条件,在这个条件下断点应用。 一旦条件满足,控制就会在那一点停止。

您还可以通过创建 ~/config/pudb/pudb.cfg 这样的文件来配置 pudb,如这里所示:

[pudb]
breakpoints_weight = 0.5
current_stack_frame = top
custom_stringifier =
custom_theme =
display = auto
line_numbers = True
prompt_on_quit = True
seen_welcome = e027
shell = internal
sidebar_width = 0.75
stack_weight = 0.5
stringifier = str
theme = classic
variables_weight = 1.5
wrap_variables = True

# 断点

断点是 Python3.7 中引入的一个新关键字。 它使您能够调试代码。 断点类似于讨论的其他命令行工具。 可编写代码如下:

x = 10
breakpoint()
y = 20

断点也可以使用 PYTHONBREAKPOINT环境变量配置,以向调试器提供一种由 breakpoint() 函数调用的方法。 这很有帮助,因为您可以轻松地更改调试器模块,而无需进行任何代码更改。 例如,如果要禁用调试,可以设置 PYTHONBREAKPOINT=0。

# 使用日志模块代替生产代码中打印

如前所述,日志记录是任何软件产品的重要组成部分,Python有一个名为日志记录的库。 日志记录还可以帮助您理解代码的流程。 如果您有可用的日志记录,它可以通过提供堆栈跟踪来让您了解事情正在哪里失败。 您可以简单地通过导入库来使用日志库,如下所示:

import logging
logging.getLogger(__name__).addHandler(logging.NullHandler())

日志库有五个标准级别,表示事件的严重程度。 见表8-1:

表8-1 记录标准级别

Level Nnmeric Value
CRITICAL 50
ERROR 40
WARNING 30
INFO 20
DEBUG 10
NOTSET 0

清单8-1日志配置

import logging
from logging.config import dictConfig

logging_config = dict(
    version=1,
    formatters={
        'f': {'format': '%(asctime)s %(name)-12s %(levelname)-8s%(message)s'}
    },
    handlers={
        'h': {'class': 'logging.StreamHandler',
        'formatter': 'f',
        'level': logging.DEBUG}
    },
 
Table 8-1. Logging Standard Levels
Level Numeric Value
CRITICAL 50
ERROR 40
WARNING 30
INFO 20
DEBUG 10
NOTSET 0
Chapter 8 Debugging and Testing Python Code
229
 root={
 'handlers': ['h'],
 'level': logging.DEBUG,
 },
)
dictConfig(logging_config)
logger = logging.getLogger()
logger.debug("This is debug logging")

如果您想捕获日志的整个堆栈跟踪;您可以执行清单8-2这样的操作

清单8-2堆栈跟踪日志记录

import logging

a = 90
b = 0
try:
    c = a / b
except Exception as e:
    logging.error("Exception ", exc_info=True)
# 日志记录中的类和函数

日志模块中定义的最常用的类如下:

Logger:这是日志模块的一部分,应用程序直接调用它来获取记录器对象。 它有许多方法,在这里列出:

  • setLevel:这将设置日志记录级别。 创建记录器时,它被设置为NOSET。
  • isEnableFor:此方法检查logging.disable(Level)设置的日志级别)。
  • debug:这将在此记录器上使用DEBUG级别记录消息。
  • info:这将在此记录器上使用INFO记录消息。
  • warning:这将在此记录器上使用警告记录消息。
  • error:这将在此记录器上使用级别错误记录消息。
  • critical:这将在这个记录器上使用级别CRITICAL记录消息。
  • log:这将在此记录器上使用整数级别记录消息。
  • exception:这将在此记录器上记录带有级别错误的消息。
  • addHandler:这将指定的处理程序添加到此记录器中。

Handler:Handler是其他有用的处理程序类的基类,如Stream Handler、File Handler,SMTPHandler,HTTPHandler等等。 这些子类将日志输出发送到相应的目的地,如sys.stdout或磁盘文件。

  • createLock:这将初始化线程锁,该线程锁可用于序列化对底层I/O功能的访问。
  • setLevel:这将处理程序设置为级别。
  • flush:这确保日志输出已被刷新。
  • close:Handler的子类确保这是从覆盖的关闭()方法调用的。
  • format:这是输出日志的格式。
  • emit:实际上,这记录了指定的日志消息。

Formatter:在这里,您可以通过指定列出输出应该包含的属性的字符串格式来指定输出的格式。

  • format:这将格式化字符串。
  • formatTime:格式化时间,它与time.strftime()一起用于格式化记录的创建时间。 默认是%Y-%m-%d%H:%M:%S,uu,其中uu是毫秒。
  • formatException:格式化特定的异常信息。
  • formatStack:格式化堆栈字符串上的信息。

还可以为正在运行的应用程序配置日志记录,如清单8-3所示。

清单 8-3. 日志配置文件

[loggers]
keys=root,sampleLogger

[handlers]
keys=consoleHandler

[formatters]
keys=sampleFormatter

[logger_root]
level=DEBUG
handlers=consoleHandler

[logger_sampleLogger]
level=DEBUG
handlers=consoleHandler
qualname=sampleLogger
propagate=0

[handler_consoleHandler]
class=StreamHandler
level=DEBUG
formatter=sampleFormatter
args=(sys.stdout,)

[formatter_sampleFormatter]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s

现在可以使用这个配置文件,如清单8-4所示:

清单 8-4. 使用日志配置

import logging
import logging.config

logging.config.fileConfig(fname='logging.conf', disable_existing_loggers=False)
# Get the logger specified in the file
logger = logging.getLogger(__name__)
logger.debug('Debug logging message')

这与清单8-5所示的 YAML 文件的配置相同。

清单 8-5. 将日志配置在YAML中

version: 1
formatters:
  simple:
  format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
handlers:
  console:
  class: logging.StreamHandler
  level: DEBUG
  formatter: simple
  stream: ext://sys.stdout
loggers:
  sampleLogger:
  level: DEBUG
  handlers: [console]
  propagate: no
root:
  level: DEBUG
  handlers: [console]

您可以读取这个文件,如清单8-6所示。

清单 8-6. 将日志配置在YAML中

import logging
import logging.config
import yaml
with open('logging.yaml', 'r') as f:
 config = yaml.safe_load(f.read())
 logging.config.dictConfig(config)
logger = logging.getLogger(__name__)
logger.debug('Debug logging message')

您可以在 https://docs.python.org/3/library/logging.html (opens new window) 找到更多关于登录的信息。

# 使用度量库来识别瓶颈

我见过许多开发人员不了解生产代码中度量的价值。 度量从代码中收集不同的数据点,例如代码特定部分中的错误数或第三方API响应时间;还可以定义度量来捕获特定的数据点,例如当前登录到Web应用程序的用户数量。度量通常是基于每次请求、每秒、每分钟的这种定期间隔收集的,以此来监视系统随时间的变化。

在生产代码上有很多用于收集度量的第三方应用程序,如New Relic、Datadog等。 有的程序您可以收集的不同类型的度量。 您可以将它们归类为性能度量或资源度量。 性能指标可以如下:

  • 吞吐量:这是系统每单位时间所做的工作量。
  • 错误:这是单位时间的错误结果或错误率的数量。
  • 性能:这表示完成一个工作单元所需的时间。

除了这些点之外,还可以使用几个数据点来捕获应用程序的性能。 除了性能度量之外,还有资源度量等度量,您可以使用这些度量来获得这样的资源度量:

  • 利用率:这是资源繁忙时间的百分比。
  • 可用性:这是资源响应请求的时间。

在使用度量之前,考虑要使用哪种数据点来跟踪应用程序。 使用度量肯定会使您对应用程序更有信心,并且您可以度量应用程序的性能。

# Ipython的优势

IPython是Python的REPL工具。 IPython帮助您在命令行中运行代码并在没有太多配置的情况下进行测试。 IPython是一个非常智能和成熟的REPL;它有很多特性,比如tab完成和魔法功能,如%Timeit,%Run,等等。 你可以还可以获取历史记录并在IPython中调试代码。 有一些调试工具显式地在ipython上工作,比如ipdb。

IPython的主要特点如下:

  • 综合对象反思
  • 输入历史记录,这是跨会话的持久性
  • 在具有自动生成的引用的会话期间缓存输出结果
  • 可扩展选项卡完成,默认支持完成Python变量和关键字,文件名和函数关键字
  • 可扩展的“魔法”命令系统,用于控制环境和执行与IPython或操作系统相关的许多任务
  • 一个丰富的配置系统,易于在不同的设置之间切换(比更改更简单

每次$PY THONSTARTUP环境变量)

  • 会话日志记录和重新加载
  • 特殊用途情况下的可扩展语法处理
  • 使用用户可扩展别名系统访问系统外壳
  • 易于嵌入到其他Python程序和GUI中
  • 集成了对PDB调试器和Python分析器的访问

命令行接口继承前面列出的功能,并添加以下内容:

  • 真正的多行编辑感谢 prompt_toolkit
  • 键入时语法高亮显示
  • 与命令行编辑器集成,以获得更好的工作流

当与兼容前端一起使用时,内核允许以下内容:

  • 可以创建HTML、图像、LaTEX、声音和视频的丰富显示的对象
  • 使用ipy widgets的交互式小部件包

您可以安装IPython如下:

pip install ipython

从IPython开始真的很容易;您可以只键入命令IPython,并且您将处于IPython命令shell中,如下所示:

Python 3.7.0
Type ‘copyright’, ‘credits’ or ‘license’ for more information
IPython 6.4.0 -- An enhanced Interactive Python. Type ‘?’ for help.
In [1]:
Now you can start using the ipython command like this:
In [1]: print("hello ipython")

您可以在:https://ipython.readthedocs.io/en/stable/interactive/index.html (opens new window) 找到更多关于IPython的信息。

# 测试

对于任何软件应用程序,拥有测试代码和拥有应用程序代码一样重要。 测试确保您没有部署bug代码。 Python有许多有用的库,使编写不同类型的测试变得更容易。

# 测试的重要性

测试和实际代码一样重要。 测试确保运输代码按预期工作。 一旦开始编写应用程序代码的第一行,就应该开始编写测试代码。 测试不应该是一个事后的想法,不应该仅仅为了测试而存在。 测试应该确保每一段代码都会导致预期的行为。

在您的软件开发生命周期中,有几个原因您应该考虑尽早编写测试。

  • 为了确保您正在构建正确的东西,在您开始编写代码时,在软件生命周期中进行测试是很重要的。 如果你没有测试来检查预期的行为,很难确保你在正确的道路上。
  • 你想尽早发现任何断裂的变化。 当您对代码的一个部分进行更改时,很有可能会破坏代码的其他部分。 您希望尽早检测到该中断代码,而不是在生产之后。
  • 测试在记录代码方面也起着重要作用。 测试是一种非常有用的方法来记录代码,而无需为代码的每个部分编写特定的文档。
  • 拥有测试的另一个优点是新开发人员的入职。 当新开发人员加入团队时,他们可以通过运行和阅读测试开始熟悉代码,这可以让您了解代码的流程。

如果您想确保您的代码按您的期望工作,并且您的用户使用该软件的时间很好,那么您应该在生产代码中使用测试。

# Py测试和单元测试

Python有很多惊人的测试库。 Pytest和Unit Test是两个最著名的库。 在本节中,您将查看这两个库之间的主要区别,以便您可以决定要使用哪一个来测试代码。

两者都是流行的图书馆;然而,它们之间存在着多种差异,使人们选择彼此。 让我们看看你想考虑的一些主要功能,然后再决定选择哪一个。

Pytest是第三方库,Unit Test是Python中的内置库。 要使用Pytest,您必须安装它,但这不是什么大事。

pip install pytest

单元测试需要继承TestCase,需要有一个类来编写和运行测试。 在这方面,Pytest更灵活,因为您可以按函数或类编写测试。 清单8-7显示Unit Test,清单8-8显示Pytest。

清单8-7.单元测试示例1

from unittest import TestCase

class SimpleTest(TestCase):
    def test_simple(self):
        self.assertTrue(True)
    def test_tuple(self):
        self.assertEqual((1, 3, 4), (1, 3, 4))
    def test_str(self):
        self.assertEqual('This is unit test', 'this is')

清单8-8.测试示例1

import pytest

def test_simple():
    assert 2 == 2
def test_tuple():
    assert (1, 3, 4) == (1, 3, 4)

正如您可能注意到的,Unit Test使用Test Case实例方法;但是,Pytest有一个内置的断言。 在不知道不同的断言方法的情况下,Pytest断言更容易读取。 然而,单元测试断言更可配置,并且有更多的方法可断言。

您可以在看到单元测试的所有断言方法 https://docs.python.org/3/library/unittest.html#assert-methods (opens new window) 还有 Pytest https://docs.pytest.org/en/latest/reference.html (opens new window)

清单8-9显示了单元测试,清单8-10显示了Pytest。

清单8-9.单元测试示例2

from unittest import TestCase

class SimpleTest(TestCase):
    def not_equal(self):
        self.assertNotEqual(2, 3)   # 2 != 3
    def assert_false(self):
        x = 0
        self.assertFalse(x) # bool(x) is false
    def assert_in(self):
        self.assertIn(5, [1, 3, 8, 5])   # 5 in [1, 3, 8, 5]

清单8-10.测试示例2

import pytest

def not_equal():
    assert 2 != 2
def assert_false():
    x = 0
    assert x is 0
def assert_in():
    assert 5 in [1, 3, 8, 5]

您可能会注意到,与单元测试相比,Pytest更容易断言。与单元测试相比,Pytest也更具可读性Pytest用代码片段突出显示错误,而Unit Test没有这个特性;它显示了一行错误,没有突出显示。 这可能会在未来版本中发生变化,但目前Pytest有更好的错误报告。 清单8-11显示Pytest控制台输出,清单8-12显示Unit Test控制台输出

清单8-11.Pytest控制台输出

>>> py.test simple.py
============================= test session starts =============
platform darwin -- Python 3.7.0 -- py-1.4.20 -- pytest-2.5.2
plugins: cache, cov, pep8, xdist
collected 2 items
simple.py .F
=================================== FAILURES =================
___________________________________ test_simple_______________
 def test_simple():
 print("This test should fail")
> assert False
E assert False
simple.py:7: AssertionError
------------------------------- Captured stdout ---------------
This test should fail
====================== 1 failed, 1 passed in 0.04 seconds ====

清单8-12.Unit Test控制台输出

Traceback (most recent call last):
 File "~<stdin>~", line 11, in simple.py
ZeroDivisionError: integer division or modulo by zero

Pytest有一些设置方法,比如可以为模块、会话和函数配置的夹具。 单元测试有设置和拆卸方法。 清单8-13显示了Pytest夹具,清单8-14显示了Unit Test夹具。

清单8-13.Pytest Fixture

import pytest

@pytest.fixture
def get_instance():
    s = CallClassBeforeStartingTest()
    s.call_function()
    return s

@pytest.fixture(scope='session')
def test_data():
    return {"test_data": "This is test data which will be use in different test methods"}

def test_simple(test_data, get_instance):
    assert test_instance.call_another_function(test_data) is not None

清单8-14.Unit Test Fixture

from unittest import TestCase

class SetupBaseTestCase(TestCase):
    def setUp(self):
        self.sess = CallClassBeforeStartingTest()
    def test_simple():
        self.sess.call_function()
    def tearDown(self):
        self.sess.close()

正如您将注意到的,Pytest和Unit Test有不同的方法来处理测试设置。 这些是Pytest和UnitTest的一些主要区别。 不过,这两个都是功能丰富的工具。我通常更喜欢使用Pytest,因为它易于使用和可读性。不过,如果您使用单元测试是舒适的,请不要觉得您必须使用Pytest。 用你觉得舒服的东西。 选择测试工具是次要的;主要目标应该是对代码进行良好的测试!

# 属性测试

属性测试是测试函数的方法,您可以在其中提供输入数。 你可以读更多关于它的东西https://hypothesis.works/ (opens new window) 文章/基于属性的测试/ (opens new window).

Python有一个名为假设的库,非常适合编写属性测试。 假设很容易使用,如果你熟悉Pytest,它就更容易了。

您可以安装hypothes如下:

pip install hypothesis

您可以看到使用假设进行属性测试的示例,如清单8-15所示。

清单8-15.属性测试

from hypothesis import given
from hypothesis.strategies import text

@given(text())
def test_decode_inverts_encode(s):
    assert decode(encode(s)) == s

在这里,假设提供了各种文本来测试函数test_decode_inverts_encode而不是提供一组数据来解码文本。

# 测试报告的创建

有很多工具可以生成测试报告。 实际上,Pytest和Unit Test会这样做。 测试报告有助于理解测试结果,并有助于跟踪测试覆盖的进度。 然而,在这里我严格地说测试报告的生成。

当您运行测试时,报表生成可以为您提供使用PASS/Fail结果运行测试的完整概述。 您可以使用以下工具之一来做到这一点:

pip install pytest-html
pytest -v tests.py --html=pytest_report.html --self-containedhtml

一个叫做nose的工具内置了报表生成工具。 如果您正在使用nose,您可以通过运行以下命令生成测试:

nosetests -with-coverage --cover-html

使用Unit Test,可以使用Text Test Runner,如清单8-16所示:

清单8-16.单元测试与文本测试Runner第1部分

class TestBasic(unittest.TestCase):
    def setUp(self):
    # set up in here

class TestA(TestBasic):
    def first_test(self):
        self.assertEqual(10,10)
    def second_test(self):
        self.assertEqual(10,5)

让我们假设你有以前的测试要运行。 Unit Test为您提供了一个名为Text Test Runner的方法来生成测试报告,如清单8-17所示。

清单8-17.单元测试与文本测试Runner第2部分

import test

test_suite = unittest.TestLoader().loadTestFromModule(test)
test_results = unittest.TextTestRunner(verbosity=2).run(test_suite)

如果运行此代码,它将生成Test Basic类的报告。

除了这里讨论的工具之外,还有许多Python第三方库在生成报告的方式上提供了大量的灵活性,它们都是非常强大的工具。

# 自动单元测试

自动单元测试意味着单元测试运行时无需启动它们。 有能力在与主代码或主代码合并时运行单元测试,这意味着您可以确保新的更改不会破坏任何现有的特性或功能。

正如我已经讨论过的,对任何代码库进行单元测试是非常重要的,您将希望使用某种CI/CD流运行它们。

这也假设您正在使用类似Git或第三方工具(如GitHub或GitLab)这样的版本控制来存储代码。

运行测试的理想流程如下:

  1. 使用版本控制提交更改。
  2. 将更改推送到某种版本控制中。
  3. 使用Travis等第三方工具从版本控制中触发单元测试,该工具自动运行测试并将结果发布到版本控制中。
  4. 在测试通过之前,版本控制不允许合并到主机。
# 代码准备生产

在去生产之前,有一些重要的事情,以确保发运的代码是高质量的,并按预期工作。 每个团队或公司在向生产部署更改或新代码之前都有不同的步骤。 我不会讨论任何一个理想的过程来部署到生产中。 然而,你可以在你的当前介绍一些东西

部署管道,使您的Python代码在生产中更好,更少出错。

# 在Python中运行单元和集成测试

如前所述,进行单元测试是很重要的。 除了单元测试之外,集成测试也有很大帮助,特别是当您在代码库中有很多移动部分时。

如您所知,单元测试有助于检查代码的特定单元,并确保代码单元工作。 在集成测试中,测试代码的一个部分是否与代码的另一个部分一起工作,而没有任何错误是很重要的。 集成测试帮助您检查代码是否整体工作。

# 使用Linting让代码一致

代码linter分析您的源代码是否存在潜在错误。 Linters解决了代码中的以下问题:

  • 语法错误
  • 结构问题,如使用未定义的变量
  • 代码样式指南违规

代码提示给你可以轻松浏览的信息。 它对代码非常有用,特别是对于一个大项目,当有很多移动代码时,所有正在处理代码的开发人员都可以就特定的代码样式达成一致。

有很多Python的linting代码。 您应该使用哪种类型取决于您或您的开发人员团队。

使用楣板有很多优点。

  • 它通过对照编码标准检查代码,帮助您编写更好的代码。
  • 它可以防止您产生明显的错误,如语法错误、错别字、格式错误、样式不正确等等。
  • 它节省了你作为开发人员的时间。
  • 它帮助所有开发人员就特定的代码标准达成一致。
  • 它真的很容易使用和配置。
  • 很容易设置。

让我们看看Python中可用的一些流行的linting工具。 如果您正在使用现代IDE工具,如VSCode、Sublime或Py Charm,您将发现这些工具已经有某种类型的门楣可用。

# flake8

flake8是最受欢迎的linting工具之一。 它是pep8、pyflake和循环复杂性的包装。 它的误报率很低。

您可以通过使用此命令轻松地设置它:

pip install flake8
# pylint

pylint是另一个很好的选择。 它需要更多的设置,与flake8相比提供更多的false positives,但如果你需要更严格的皮线检查你的代码,pylint还是比较好的工具。

# 用代码覆盖检查测试

代码覆盖是一个过程,在这个过程中,您可以检查为代码编写的一些测试(或者精确地说,不同测试所触及的代码)。 代码覆盖确保您有足够的测试来确保代码的质量。 代码覆盖应该是软件开发生命周期的一部分;它不断提高代码的质量标准。

Python有一个名为Coverage.py的工具,它是检查测试覆盖率的第三方工具。 可以安装如下:

pip install coverage

在安装Coverage.py时,一个名为Coverage的Python脚本被放置在Python脚本目录中。 coverage.py有许多命令来决定执行的操作。

  • run:运行Python程序并收集执行数据
  • report:报告覆盖结果
  • html:生成带有覆盖结果的带注释的HTML列表
  • XML:生成覆盖结果的XML报告
  • annotate:用覆盖结果注释源文件
  • erase:删除先前收集的覆盖数据
  • combine:组合多个数据文件
  • debug:获取诊断信息

您可以运行覆盖报告如下:

coverage run -m packagename.modulename arg1 arg2

还有其他工具直接与版本控制系统集成,如GitHub。 这些工具对更大的团队来说可能更方便,因为一旦提交新代码供审查,检查就可以运行。将代码覆盖作为软件生命周期的一部分,可以确保您的生产代码不会冒任何风险。

# 为你的项目配置virtualev

virtualenv是工具之一,应该是每个开发人员工具链的一部分。 您可以使用它创建孤立的Python环境。 安装virtualenv并为项目创建环境时,virtualenv创建一个文件夹,其中包含项目需要运行的所有可执行文件。

pip install virtualenv

我建议在这里了解更多关于virtualenv的信息:

https://docs.python-guide.org/dev/virtualenvs/

# 小结

对于任何生产代码,重要的是要有帮助您调试和更好地监视代码的工具。 正如您在本章中了解到的,Python有大量的工具,使您能够在将代码部署到生产之前更好地准备代码。 这些工具不仅帮助您在数百万用户使用应用程序时保持清醒,而且还帮助您维护代码以供长期使用。 确保您正在为应用程序利用这些工具,因为投资这些工具从长远来看肯定会有回报。 在生产中部署应用程序时,拥有正确的流程与构建新功能一样重要,因为它将确保应用程序是高质量的。

Last Updated: 3/7/2023, 4:25:13 AM