Python-整洁编程-全-

Python 整洁编程(全)

原文:Clean Python

协议:CC BY-NC-SA 4.0

一、Pythonic 式思维

Python 与其他语言的不同之处在于,它是一种简单而有深度的语言。因为简单,所以谨慎编写代码要重要得多,尤其是在大项目中,因为代码很容易变得复杂臃肿。Python 有一种哲学,叫做 Python 的禅,强调简单胜于复杂。 1

在这一章中,你将学习一些常见的实践,这些实践可以帮助你使你的 Python 代码更易读和更简单。我将介绍一些众所周知的实践,以及一些不太为人所知的实践。在编写下一个项目或处理当前项目时,请确保您非常了解这些 Python 实践,以便您可以改进代码。

注意

在 Python 世界中,遵循 Python 哲学的禅使您的代码“Python 化”Python 官方文档中推荐了许多好的实践,以使您的代码更整洁、可读性更好。阅读 PEP8 指南肯定会帮助你理解为什么推荐一些做法。

编写 Pythonic 代码

Python 有一些名为 PEP8 的官方文档,定义了编写 python 代码的最佳实践。这种风格指南随着时间的推移不断发展。您可以在 https://www.python.org/dev/peps/pep-0008/ 查看。

在这一章中,你将关注 PEP8 中定义的一些常见实践,并了解作为一名开发人员,遵循这些规则将如何使你受益。

命名

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

变量和函数

您应该用小写字母命名函数和变量,用下划线分隔单词,这样可以提高可读性。参见清单 1-1 。

names = "Python"                               # variable name
job_title = "Software Engineer"                # variable name with underscore
populated_countries_list = []                  # variable name with underscore

Listing 1-1Variable Names

您还应该考虑在代码中使用非欺诈方法名称,并使用一个下划线(_)或两个下划线(__)。参见清单 1-2 。

_books = {}                    # variable name to define private
__dict = []                    # prevent name mangling with python in-build lib

Listing 1-2Nonmangling Names

您应该使用一个下划线(_)作为类的内部变量的前缀,而不希望外部类访问该变量。这只是一个惯例;Python 不会将只有一个下划线前缀的变量设为私有变量。

Python 对函数也有约定,如清单 1-3 所示。

# function name with single underscore
def get_data():
    ---
    ---

def calculate_tax_data():
    ----

Listing 1-3Normal Function Names

同样的规则也适用于私有方法和希望防止名称与内置 Python 函数混淆的方法。参见清单 1-4 。

# Private method with single underscore
def _get_data():
    ---
    ---

# double underscore to prevent name mangling with other in-build functions
def __path():
    ----
    ----

Listing 1-4Function Names to Represent Private Methods and Nonmangling

除了遵循这些命名规则之外,为函数或变量使用特定的名称而不是晦涩的名称也很重要。

让我们考虑一个函数,当提供一个用户 ID 时,它返回一个用户对象。参见清单 1-5 。

# Wrong Way
def get_user_info(id):
    db = get_db_connection()
    user = execute_query_for_user(id)
    return user

# Right way
def get_user_by(user_id):
    db = get_db_connection()
    user = execute_user_query(user_id)
    return user

Listing 1-5Function Names

在这里,第二个函数get_user_by确保使用相同的词汇传递变量,这为函数提供了正确的上下文。第一个函数get_user_info是不明确的,因为参数id可能意味着任何东西。是用户表索引 ID 还是用户付费 ID 还是其他什么 ID?这种代码可能会给使用您的 API 的其他开发人员造成混乱。为了解决这个问题,我在第二个函数中改变了两件事;我更改了函数名并传递了一个参数名,这使得代码可读性更好。当你读第二个函数时,你马上就知道这个函数的目的和期望值。

作为开发人员,您有责任在命名变量和函数时仔细考虑,以使代码对其他开发人员可读。

班级

像在大多数其他语言中一样,类的名字应该是驼色的。清单 1-6 显示了一个简单的例子。

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

Listing 1-6Class Names

常数

你应该用大写字母定义常量名。清单 1-7 给出了一个例子。

TOTAL = 56
TIMOUT = 6
MAX_OVERFLOW = 7

Listing 1-7Constant Names

函数和方法参数

函数和方法参数应该遵循与变量和方法名称相同的规则。与不将self作为关键字参数传递的函数相比,类方法将self作为第一个关键字参数。参见清单 1-8 。

def calculate_tax(amount, yearly_tax):
    ----

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

Listing 1-8Function and Method Arguments

代码中的表达式和语句

在某些时候,你可能试图用一种聪明的方式来编写代码,以节省一些代码行或者给你的同事留下深刻印象。然而,编写聪明的代码是有代价的:可读性和简单性。让我们来看看清单 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())

Listing 1-9Sort a Nested Dictionary

这个代码有什么问题?

嗯,你在一行中使用 lambda 通过first_name对这个嵌套的字典进行排序,这看起来像是一个对字典进行排序的聪明方法,而不是使用循环。

然而,乍一看理解这段代码并不容易,尤其是对于新开发人员来说,因为 lambdas 由于其古怪的语法而不是一个容易掌握的概念。当然,这里使用 lambda 可以节省行数,因为它允许你以巧妙的方式对字典进行排序;然而,这并不能使代码正确或可读。这段代码无法解决诸如缺少键或者字典是否正确之类的问题。

让我们用一个函数重写这段代码,尽量让代码更易读、更正确;该函数将检查所有意外值,并且编写起来简单得多。参见清单 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

Listing 1-10Sorted Dictionary by Function

如您所见,这段代码检查所有可能的意外值,比前面的一行代码可读性更好。一行代码节省了代码行,但是增加了代码的复杂性。那不一定意味着单行代码不好;我在这里想说的是,如果你的一行代码使代码更难阅读,请避免使用它。

在编写代码时,你必须有意识地做出这些决定。有时候写一行代码可以让你的代码可读,有时候不行。

让我们再考虑一个例子,您试图读取一个 CSV 文件,并计算 CSV 文件处理的行数。清单 1-11 中的代码向您展示了为什么让您的代码可读是重要的,以及命名在让您的代码可读中是如何发挥重要作用的。

当您在生产代码中遇到特定的错误时,将代码分解成 helper 函数有助于使复杂的代码可读并易于调试。

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.')

Listing 1-11Reading a CSV File

这里,代码在with语句中做了多件事。为了提高可读性,您可以将带有 process salary的代码从 CSV 文件中提取出来,放到不同的函数中,以减少出错的可能性。当许多事情在几行代码中进行时,很难调试这种代码,所以在定义函数时,您需要确保有明确的目标和界限。因此,让我们在清单 1-12 中进一步分解它。

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.')

Listing 1-12Reading a CSV File, with More Readable Code

这里,您创建了一个助手函数,而不是在with语句中编写所有内容。这让读者清楚了process_salary函数实际上是做什么的。如果您想要处理一个特定的异常或者想要从一个 CSV 文件中读取更多的数据,您可以进一步分解这个函数以遵循单一责任原则。

拥抱 Pythonic 式的代码编写方式

当你写代码时,PEP8 有一些建议可以遵循,这将使你的 Python 代码更整洁,可读性更好。让我们来看看其中的一些做法。

首选联接,而不是就地字符串连接

只要你关心代码的性能,就使用"".join()方法,而不是像在a += ba = a + b中那样就地串联字符串。"".join()方法保证了各种 Python 实现之间更精简的时间连接。

原因是当你使用join时,Python 只为连接的字符串分配一次内存,但是当你连接字符串时,Python 必须为每次连接分配新的内存,因为 Python 字符串是不可变的。参见清单 1-13 。

first_name = "Json"
last_name = "smart"

# Not a recommended way to concatenate string
full_name = first_name + "  " +  last_name

# More performant and improve readability
" ".join([first_name, last_name])

Listing 1-13Using the join Method

每当需要与 None 进行比较时,可以考虑使用 is 和 is not

始终使用isis notNone进行比较。在编写如下代码时,请记住这一点:

if val:                    # Will work when val is not None

请务必记住,您考虑的是valNone,而不是其他容器类型,如dictset。让我们进一步了解这种代码能让您感到惊讶的地方。

在前一行代码中,val是一个空字典;然而,val被认为是 false,这可能不希望出现在您的代码中,所以在编写这类代码时要小心。

不要这样:

val = {}
if val:                  # This will be false in python context

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

这样做:

if val is not None:       # Make sure only None value will be false

更喜欢用 is not 而不是 not … is

is not和用not ... is没有区别。然而,is not语法比not ... is更具可读性。

不要这样:

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

对于字符串表示和回溯来说,def square(val)函数对象比一般的<lambda>更有用。这种使用消除了 lambdas 的有用性。考虑在更大的表达式中使用 lambdas,这样就不会影响代码的可读性。

与退货声明一致

如果期望函数返回值,请确保该函数的所有执行路径都返回值。确保在函数存在的所有地方都有一个返回表达式是一个好习惯。但是如果期望一个函数只是执行一个动作而不返回值,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()代替切片。slice是一个非常有用的分割字符串的方法,但是当你分割一个大的字符串或者执行字符串操作的时候可能会得到更好的性能。然而,如果你正在做一些简单的事情,比如检查前缀或后缀,使用startswithendswith,因为这对读者来说是显而易见的,你正在检查一个字符串中的前缀或后缀。换句话说,它让你的代码更易读、更干净。

不要这样:

Data = "Hello, how are you doing?"
if data.startswith("Hello")

这样做:

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

使用 isinstance()方法代替 type()进行比较

当你比较两个对象的类型时,考虑使用isinstance()而不是type,因为isinstance()对于子类来说是正确的。考虑这样一个场景,您正在传递一个数据结构,它是像orderdict这样的dict的子类。type()对于特定类型的数据结构将失败;然而,isinstance()会识别出它是dict的子类。

不要这样:

user_ages = {"Larry": 35, "Jon": 89, "Imli": 12}
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:

为上下文管理器编写显式代码

当你在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 代码

代码链接器是使代码格式一致的重要工具。在整个项目中保持一致的代码格式是很有价值的。

林挺工具基本上为您解决了这些问题:

  • 句法误差

  • 结构,如未使用的变量或将正确的参数传递给函数

  • 指出违反 PEP8 准则的行为

林挺工具使你作为一个开发人员更有效率,因为它们通过在运行时找出问题来节省你的大量时间。有多种适用于 Python 的林挺工具。一些工具处理林挺的特定部分,如代码质量的 docstring 风格,流行的 python liniting 工具如 flak8/pylint 检查所有 PEP8 规则,mypy 检查工具专门检查 python 类型。

您可以将它们全部集成到您的代码中,也可以使用一个包含标准检查的代码来确保您遵循 PEP8 风格指南。其中最著名的是 Flake8 和 Pylint。无论你使用什么工具,都要确保它遵守 PEP8 的规则。

林挺工具中有几个功能可供选择:

  • PEP8 规则遵守情况

  • 进口订单

  • 命名(变量、函数、类、模块、文件等的 Python 命名约定。)

  • 循环进口

  • 代码复杂性(通过查看行数、循环数和其他参数来检查函数的复杂性)

  • 拼写检查器

  • 文档字符串样式的检查

有不同的方式可以运行棉绒。

  • 在使用 IDE 编程时

  • 在提交时使用预提交工具

  • 在 CI 时间,通过与 Jenkins、CircleCI 等集成

注意

这些是一定会改进您的代码的一些常见实践。如果您想最大限度地利用 Python 的良好实践,请查看 PEP8 官方文档。此外,阅读 GitHub 中的优秀代码将有助于您理解如何编写更好的 Python 代码。

使用文档字符串

文档字符串是用 Python 记录代码的一种强大方式。文档字符串通常写在方法、类和模块的开始。docstring 成为该对象的__doc__特殊属性。

Python 官方语言推荐使用"""Triple double quotes"""编写 docstrings。你可以在 PEP8 官方文档中找到这些做法。让我们简单谈谈用 Python 代码编写文档字符串的一些最佳实践。参见清单 1-14 。

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

Listing 1-14Function with a Docstring

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

  • 即使字符串在一行中,也要使用三重引号。当您想要扩展时,这种做法很有用。

  • 三重引号中的字符串前后不应有任何空行。

  • 使用句点(。)来结束 docstring 中的语句。

类似地,Python 多行 docstring 规则可以应用于编写多行 docstring。在多行中编写 docstrings 是以更具描述性的方式记录代码的一种方式。通过利用 Python 多行文档字符串,您可以在 Python 代码中编写描述性的文档字符串,而不是在每一行都编写注释。这也有助于其他开发人员在代码本身中找到文档,而不是查阅冗长且令人生厌的文档。参见清单 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

"""

Listing 1-15Multiline Docstring

这里有几点需要注意。

  • 第一行是函数或类的简短描述。

  • 这一行的结尾有一个句号。

  • 文档字符串中的简要描述和摘要之间有一行的间隔。

如果您使用 Python 3 和typing模块,您可以编写相同的函数,如清单 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.
"""

Listing 1-16Multiline Docstring with typing

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

正如我提到的不同的 docstring 类型,这些年来不同的来源已经引入了新样式的 docstring。没有更好的或推荐的方法来编写 docstring。但是,请确保在整个项目中对文档字符串使用相同的样式,以便它们具有一致的格式。

编写 docstring 有四种不同的方法。

  • 这里有一个 Google docstrings 的例子:

    """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.
    """
    
    

模块级文档字符串

模块级的 docstring 应该放在文件的顶部,以简要描述模块的使用。这些评论也应该放在import之前。一个模块 docstring 应该专注于模块的目标,包括模块中所有的方法/类,而不是谈论一个特定的方法或类。如果您认为某个方法或类在使用模块之前需要让客户端在较高的层次上知道一些东西,那么您仍然可以简单地指定一个特定的方法或类。参见清单 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

....

Listing 1-17Module Docstring

为模块编写 docstring 时,您应该考虑执行以下操作:

  • 简要描述模块的用途。

  • 如果您想指定任何对读者了解模块有用内容,比如清单 1-15 ,您可以添加异常信息,但是注意不要太详细。

  • 将模块 docstring 视为提供关于该模块的描述性信息的一种方式,而无需深入每个函数或类操作的细节。

使类 Docstring 具有描述性

docstring 类主要用于简要描述该类的用途及其总体目标。让我们看一些例子,看看如何编写类 docstrings。参见清单 1-18 。

class Student:
"""This class handle actions performed by a student."""

    def __init__(self):
        pass

Listing 1-18Single-Line Docstring

这个类有一行 docstring,简单说一下Student类。如前所述,确保您遵循一行的所有规则。

让我们考虑清单 1-19 中所示的类的多行 docstring。

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

Listing 1-19Multiline Class Docstring

此类 docstring 是多行的;关于Student类的用法以及如何使用,我们写得不多。

函数文档字符串

函数文档字符串可以写在函数之后,也可以写在函数顶部。函数文档字符串主要关注于描述函数的操作,如果您没有使用 Python 类型,也可以考虑包含参数,例如参见清单 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
    """
        ...

Listing 1-20Function Docstring

一些有用的 Docstring 工具

Python 有大量的 docstrings 工具。Docstring 工具通过将文档字符串转换为 HTML 格式的文档文件来帮助记录 Python 代码。这些工具还通过运行简单的命令来帮助您更新文档,而不是手动维护文档。让它们成为你开发流程的一部分,从长远来看,会让它们更有用。

有一些有用的文档工具。每个文档工具都有不同的目标,所以选择哪一个取决于您的具体用例。

从长远来看,使用这些工具将使维护代码变得更加容易,并帮助您保持代码文档的一致格式。

注意

文档字符串是 Python 的一个很好的特性,它可以让编写代码变得非常容易。尽早开始在代码中使用 docstrings 将确保当您的项目变得更加成熟并拥有数千行代码时,您不需要投入太多时间。

编写 Pythonic 控制结构

控制结构是任何编程语言的基础部分,对于 Python 也是如此。Python 有多种方法来编写控制结构,但是有一些最佳实践可以让 Python 代码更加整洁。在这一节中,我们将研究这些 Python 控制结构的最佳实践。

使用列表理解

列表理解是一种编写代码来解决现有问题的方式,与 python for loop 的方式类似,但是它允许在列表中使用或不使用 if 条件。Python 中有多种方法可以从另一个列表中导出一个列表。Python 中完成这项工作的主要工具是filtermap方法。但是,推荐使用列表理解方式,因为与其他选项(如映射和过滤)相比,它使您的代码更具可读性。

在本例中,您尝试使用地图版本查找数字的平方:

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 官方文档也建议使用 list comprehension,而不是filtermap

如果你在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]

正如您所看到的,与使用循环相比,使用列表理解的例子可读性更好,但是代码行更少。此外,循环有额外的性能成本,因为每次都需要将项目追加到列表中,而在列表理解中不需要这样做。

类似地,与列表理解相比,filtermap函数调用这些函数有额外的成本。

不要做复杂的列表理解

您还需要确保对列表的理解不会太复杂,否则会影响代码的可读性,并容易出错。

让我们考虑另一个使用列表理解的例子。列表理解对于至多两个具有一个条件的循环是好的。除此之外,它可能会妨碍代码的可读性。

这里有一个例子,你想转置这个矩阵:

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 中的例子。

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

Listing 1-21Using a Lambda Without Assigning

这里的代码使用 lambda 作为一次性函数来寻找最小值。然而,我建议你不要像这样使用 lambda 作为匿名函数:

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

这里,min_val是使用λ表达式计算的。将 lambda 表达式编写为函数复制了def的功能,这违反了 Python 以一种且只有一种方式做事的哲学。

PEP8 文档是这样描述 lambdas 的:

始终使用 def 语句,而不是将 lambda 表达式直接绑定到名称的赋值语句。

是:

def f(x): return 2x*

否:

f =λx:2 * x

第一种形式意味着产生的函数对象的名字是明确的‘f’,而不是一般的‘??’λ>。一般来说,这对于回溯和字符串表示更有用。赋值语句的使用消除了 lambda 表达式相对于显式 def 语句所能提供的唯一好处(即它可以嵌入到更大的表达式中)

何时使用生成器与列表理解

生成器和列表理解之间的主要区别在于,列表理解将数据保存在内存中,而生成器不这样做。

在下列情况下使用列表理解:

  • 当你需要多次遍历列表时。

  • 当您需要列出方法来处理生成器中不可用的数据时

  • 当您没有大量数据需要迭代,并且认为将数据保存在内存中不成问题时

假设您想从一个文本文件中获取一个文件的行,如清单 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

Listing 1-22Read File from a Document

在这里,文件可能非常大,以至于在一个列表中有这么多行可能会影响内存并使您的代码变慢。所以,你可能想考虑在列表上使用迭代器。参见清单 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(">>")

Listing 1-23Read a File from a Document Using Iterators

在清单 1-23 中,您不是使用列表理解将数据推入内存,而是一次读取每一行并采取行动。但是,list comprehension 可以传递给下一步操作,看看它是否找到了所有以> > >开头的行,而生成器每次都需要运行来找到以> > >开头的行。

这两者都是 Python 的优秀特性,按照描述使用它们将使您的代码具有高性能。

为什么不在循环中使用 else

Python 循环有一个else子句。基本上,你可以在代码中的 Python forwhile循环之后有一个else子句。代码中的else子句仅在控制从循环中正常退出时运行。如果控制存在于带有break关键字的循环中,那么控制不会进入代码的else部分。

有一个带有循环的else子句有点令人困惑,这使得许多开发人员避免使用这个特性。考虑到正常流量中if/else条件的性质,这是可以理解的。

让我们看看清单 1-24 中的简单例子;该代码试图在一个列表上循环,并且在循环的外面和后面有一个else子句。

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

Result:
     >>> Then
     >>> Then
     >>> Then
     >>> Else

Listing 1-24else Clause with for Loop

乍一看,您可能认为它应该只打印三个Then子句,而不是Else,因为在一个if/else块的正常场景中,这将被跳过。这是查看代码逻辑的自然方式。然而,这个假设在这里是不正确的。如果您使用while循环,这将变得更加混乱,如清单 1-25 所示。

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

Listing 1-25else Clause with the for Loop

结果如下:

     >>> Then
     >>> Then
     >>> Then
     >>> Else

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

Python 中有这个功能是有原因的。一个主要的用例是在forwhile循环之后有一个else子句,以便在循环结束后执行一个额外的动作。

让我们考虑清单 1-26 中的例子。

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

Listing 1-26else Clause with break

结果如下:

>>> Then

然而,有更好的方法来编写这段代码,而不是在循环之外使用else子句。您可以在循环中使用带有breakelse子句,也可以不使用break条件。然而,有多种方法可以在不使用else条款的情况下达到相同的结果。你应该在循环中使用 condition 而不是else,因为有被其他开发者误解代码的风险,而且看一眼就理解代码也有点困难。见清单 1-27 。

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

Listing 1-27else Clause with break

结果如下:

>>> Then

这种代码更容易阅读和理解,并且在阅读代码时不存在混淆的可能性。else子句是一种有趣的编写代码的方法;但是,它可能会影响代码的可读性,因此避免它可能是解决问题的更好方法。

为什么 Python 3 中范围更好

如果您使用过 Python 2,您可能会使用xrange。在 Python 3 中,xrange被重命名为range,并增加了一些额外的特性。range类似于xrange,生成一个 iterable。

>>> range(4)
range(0, 5)          # Iterable
>>> list(range(4))
[0, 1, 2, 3, 4]      # List

Python 3 的range函数中有一些新特性。与列表相比,使用范围的主要优点是它不会将数据保存在内存中。与列表、元组和其他 Python 数据结构相比,range表示不可变的 iterable 对象,无论范围大小如何,它总是占用少量相同的内存,因为它只存储开始、停止和步长值,并根据需要计算值。

你可以用range做一些在xrange中不可能做的事情。

  • 您可以比较范围数据。

    >>> range(4) == range(4)
    True
    >>> range(4) == range(5)
    False
    
    
  • 你可以切片。

    >>> range(10)[2:]
    range(2, 10)
    >>> range(10)[2:7, -1)
    range(2, 7, -1)
    
    

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

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

当你处理数字的时候,也建议你尽可能的在你的循环中使用 iterables,因为 Python 给了你一个像range这样的方法来轻松的处理数字。

不要这样:

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

这样做:

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

就性能而言,第一个循环的代价要高得多,如果这个列表恰好足够大,那么由于内存情况和从列表中弹出数字,它会使您的代码慢得多。

引发异常

异常有助于报告代码中的错误。在 Python 中,异常由内置模块处理。很好地理解异常是很重要的。了解何时何地使用它们将使您的代码不容易出错。

异常可以毫不费力地暴露代码中的错误,所以永远不要忘记在代码中添加异常。异常有助于 API 或库的消费者理解代码的局限性,以便他们在使用代码时可以使用良好的错误机制。在代码中适当的位置引发一个异常可以极大地帮助其他开发人员理解您的代码,并让第三方客户在使用您的 API 时感到满意。

经常提出的例外

您可能想知道何时何地在 Python 代码中引发异常。

每当发现当前代码块的一个基本假设为假时,我通常会抛出一个异常。Python 更喜欢在代码失败时出现异常。即使您有一个连续的失败,您也想为它引发一个异常。

让我们考虑你需要将清单 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")

Listing 1-28Division of Numbers with Exceptions

正如您在这段代码中看到的,每当您认为代码中可能有错误时,您就引发了一个异常。这有助于调用代码确保每当代码中有ZeroDivisionError时代码都会出错,并以不同的方式处理它。参见清单 1-29 。

result = division(10, 2)

What happens if we return None here as:

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

Listing 1-29Division Without Exceptions

如果调用者不处理调用division(dividend, divisor)方法永远不会失败的情况,即使你的代码中有ZeroDivisionError,如果你在任何异常情况下都没有从division(dividend, divisor)方法返回,这可能会在将来代码规模增长或需求变化时使诊断变得困难。最好避免division(divident, divisor)函数在出现任何失败或异常时返回None,以便调用者更容易理解函数执行过程中失败的原因。当我们提出异常时,我们让调用者预先知道输入值不正确,需要提供正确的值,我们避免任何隐藏的错误。

从调用者的角度来看,获取异常比获取返回值更方便,这是 Python 指示失败的风格。

Python 的信条是“请求原谅比请求许可更容易。”这意味着你没有事先检查以确保你不会得到一个异常;相反,如果你得到异常,你处理它。

基本上,只要您认为代码中有失败的可能性,您就要确保引发异常,这样调用类就可以优雅地处理它们,而不会感到意外。

换句话说,如果你觉得你的代码无法合理运行,还没有想出答案,那就考虑抛出一个异常。

最终利用来处理异常

finally中的代码总是在 Python 中运行。finally关键字在处理异常时很有用,尤其是在处理资源时。您可以使用finally来确保文件或资源被关闭或释放,不管是否出现异常。即使您没有捕捉到异常或者没有特定的异常要捕捉,也是如此。参见清单 1-30 。

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

Listing 1-30finally Keyword Use

在这里,您使用finally处理异常,这有助于清理服务器连接中的资源,以防您在登录期间或在send_email中出现某种异常。

您可以使用finally关键字来编写关闭文件的代码块,如清单 1-31 所示。

def write_file(file_name):
"""Read given file line by line""""
    myfile = open(file_name, "w")
    try:
        myfile.write("Python is awesome")           # Raise TypeError
    finally:
        myfile.close()             # Executed before TypeError propagated

Listing 1-31finally Keyword Use to close the file

这里您正在处理关闭finally块中的文件。无论是否有异常,在finally中的代码将总是运行并关闭文件。

因此,当您想执行某个代码块而不管是否存在异常时,您应该更喜欢使用finally来完成。使用finally将确保你明智地处理你的资源,此外将使你的代码更干净。

创建自己的异常类

当您正在创建 API 或库,或者正在处理一个项目,并且希望定义自己的异常以与项目或 API 保持一致时,建议您创建自己的异常类。这将极大地帮助您诊断或调试代码。这也有助于使您的代码更整洁,因为调用者会知道为什么会抛出错误。

让我们假设当您在数据库中找不到用户时,您必须引发异常。您希望确保异常类名反映了错误的意图。有了名字UserNotFoundError本身就说明了为什么你有一个例外和意图。

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

class UserNotFoundError(Exception):
"""Raise the exception when user not found."""
    def __init__(self, message=None, errors=None):
        # Calling the base class constructor with the parameter it needs
        super().__init__(message)
        # New for your custom code
        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

Listing 1-32Creating a Specific Exception Class

您还需要确保当您创建自己异常类时,这些异常是描述性的,且具有明确定义的边界。您将希望仅在代码找不到用户的地方使用UserNotFoundException,并且您将希望通知调用代码在数据库中没有找到用户信息。为自定义的异常设置一组特定的界限可以更容易地诊断代码。当您查看您的代码时,您确切地知道为什么代码抛出了那个特定的异常。

你也可以通过命名为一个异常类定义一个更大的范围,但是名字应该表明它处理特定种类的情况,如清单 1-33 所示。清单显示了ValidationError,您可以将它用于多个验证案例,但是它的范围是由所有与验证相关的异常定义的。

class ValidationError(Exception):
"""Raise the exception whenever validation failed.."""
    def __init__(self, message=None, errors=None):
        # Calling the base class constructor with the parameter it needs
        super().__init__(message)
        # New for your custom code
        self.errors = errors

Listing 1-33Creating a Custom Exception Class with a Broader Scope

UserNotFoundException相比,该例外的范围更广。ValidationError可以在您认为验证已经失败或特定输入没有有效输入的任何时候提出;但是,边界仍然由验证上下文定义。因此,请确保您知道异常的范围,并在该异常类的范围内发现错误时引发异常。

仅处理特定的异常

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

except: or except Exception will handle each and every exception, which can cause your code to hide critical bugs or exceptions which you don't intend to.

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

不要这样:

def get_even_list(num_list):
"""Get list of odd numbers from given list."""
    # This can raise NoneType or TypeError exceptions
    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

这种代码隐藏了像NoneTypeTypeError这样的异常,这显然是您代码中的一个错误,客户端应用或服务将很难弄清楚为什么它们会收到像“有问题”这样的消息相反,如果您用适当的消息提出特定类型的异常,API 客户端会感谢您的努力。

当您在代码中使用except时,Python 内部认为它是except BaseException。拥有一个特定的异常非常有帮助,尤其是在一个较大的代码库中。

这样做:

def get_even_list(num_list):
"""Get list of odd numbers from given list."""
    # This can raise NoneType or TypeError exceptions
    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 中,因为它将帮助您在调试代码时节省大量时间。此外,它将确保您的代码快速失败,而不是隐藏代码中的错误。

注意第三方例外

在调用第三方 API 时,了解第三方库抛出的所有类型的异常非常重要。了解所有类型的异常有助于您以后调试问题。

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

让我们看看清单 1-34 中的botocore客户端库。

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)
    # Only worry about a specific service error code
    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):
        # Calling the base class constructor with the parameter it needs
        super().__init__(message)
        # New for your custom code
        self.errors = errors

Listing 1-34Creating a Custom Exception Class with a Broader Scope

这里考虑两件事。

  • 每当您在第三方库中发现特定错误时,添加日志将使调试第三方库中的问题变得更加容易。

  • 这里您定义了一个新的错误类来定义您自己的异常。您可能不想对每个异常都这样做;但是,如果您认为创建一个新的异常类会使您的代码更整洁、可读性更好,那么可以考虑创建一个新的类。

有时很难找到正确的方法来处理第三方库/API 抛出的异常。至少了解一些由第三方库抛出的常见异常,会让你在处理产品 bug 时更容易。

更喜欢尝试最少的代码

每当在代码中处理异常时,尽量将代码保持在最少的try块中。这让其他开发人员清楚地知道代码的哪一部分有抛出错误的风险。拥有最少的代码或者有可能在try块中抛出异常的代码也有助于更容易地调试问题。没有用于异常处理的try / catch块可能会稍微快一些;但是,如果不处理该异常,可能会导致应用失败。因此,良好的异常处理使您的代码没有错误,并可以在生产中为您节省数百万美元。

让我们看一个例子。

不要这样:

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 编写代码的最重要的部分之一。很好地理解异常有助于维护您的应用。在大型项目中尤其如此,因为不同的开发人员开发的应用有不同的活动部分,所以您更有可能遇到各种各样的生产问题。在代码中适当的位置使用异常可以节省大量的时间和金钱,尤其是在生产中调试问题的时候。日志记录和异常是任何成熟软件应用的两个最重要的部分,因此提前为它们做好计划,并将它们视为软件应用开发的核心部分,将有助于您编写更易于维护和阅读的代码。

二、数据结构

数据结构是任何编程语言的基本构件。很好地掌握数据结构可以为您节省大量时间,并且使用它们可以使您的代码具有可维护性。Python 有许多使用数据结构存储数据的方法,并且很好地理解何时使用哪种数据结构在内存、易用性和代码性能方面有很大的不同。

在这一章中,我将首先介绍一些常见的数据结构,并解释何时在你的代码中使用它们。我还将介绍在特定情况下使用这些数据结构的优势。然后,您将详细考虑字典作为 Python 中的数据结构的重要性。

公共数据结构

Python 有许多主要的数据结构。在这一节中,您将看到最常见的数据结构。对数据结构概念有一个好的理解对你写有效的代码是重要的。明智地使用它们会使您的代码性能更高,错误更少。

使用速度设置

集合是 Python 中的基本数据结构。他们也是最被忽视的人之一。使用集合的主要好处是速度。那么,让我们来看看集合的其他一些特征:

  • 他们不允许复制。

  • 不能使用索引来访问集合元素。

  • 集合可以在 O(1)时间内访问元素,因为它们使用哈希表。

  • 集合不允许列表所做的一些常见操作,如切片和查找。

  • 集合可以在插入时对元素进行排序。

考虑到这些约束,当你的数据结构中不需要这些通用功能时,最好使用 set,这将使你的代码在访问数据时速度更快。清单 2-1 展示了一个使用集合的例子。

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

Listing 2-1Set Usage for Accessing Data

集合是使用哈希表实现的,所以每当一个新的项被添加到集合中时,该项在内存中的位置就由对象的哈希值决定。这就是哈希在访问数据时性能良好的原因。如果您有数千个项目,并且需要频繁地从这些元素中访问项目,那么使用集合来访问项目比使用列表要快得多。

让我们看看另一个例子(清单 2-2 ),集合是有用的,可以帮助确保您的数据没有被重复。

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

Listing 2-2Set Usage for Removing Duplicates

集合也用作字典的键,您可以将集合用作列表等其他数据结构的键。

让我们考虑清单 2-3 中的例子,其中有一个来自数据库的字典,ID 值作为键,值中有用户的名和姓。

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"])

Listing 2-3Sets as First and Last Names

这将给出您的 id 集和全名列表。如您所见,集合可以从列表中派生出来。

注意

集合是有用的数据结构。当您需要频繁地访问数字列表中的项目,并在 O(1)时间内设置对项目的访问时,可以考虑使用它们。我建议下次需要数据结构时,在考虑使用列表或元组之前先考虑集合。

使用 namedtuple 返回和访问数据

namedtuple基本上是一个带有数据名称的元组。可以做元组可以做的事情,但也有一些元组没有的额外功能。使用namedtuple,很容易创建一个轻量级的对象类型。

使你的代码更加 Pythonic 化。

访问数据

使用namedtuple访问数据使其更具可读性。假设您想创建一个类,它的值在初始化后不会改变。你可以创建一个如清单 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

Listing 2-4Immutable Class

如果你不打算改变class Point的值,而更喜欢使用namedtuple来编写它们,这将使你的代码更具可读性,如清单 2-5 所示。

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

Listing 2-5namedtuple Implementation

正如您在这里看到的,这段代码比使用普通的类可读性强得多,而且行数也少得多。因为一个namedtuple和一个元组使用相同的内存,所以它们和元组一样高效。

你可能想知道为什么不用dict代替namedtuple,因为它们很容易编写。

无论是否命名,元组都是不可变的。通过使用名称而不是索引,使访问更加方便。namedtuple有严格的限制,字段名必须是字符串。另外,namedtuple不执行任何散列,因为它生成一个类型。

返回数据

通常你会在一个元组中返回数据。然而,您应该考虑使用namedtuple来返回数据,因为它使代码在没有太多上下文的情况下更具可读性。我甚至建议,每当你从一个函数向另一个函数传递数据时,你应该看看你是否能使用namedtuple,因为它使你的代码更加 Pythonic 化和可读。让我们考虑清单 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)

Listing 2-6Return a Value from a Function as a Tuple

那么,这个功能有什么问题呢?问题在于返回值。正如您所注意到的,从数据库中获取数据后,您将返回用户的值first_namelast_nameage。现在,假设您需要将这些值传递给其他函数,如get_full_name。你在传递这些值,这给读者阅读你的代码制造了视觉噪音。如果像这样传递更多的值,想象一下用户理解你的代码有多困难。如果您能够将这些值绑定到一个数据结构,以便它提供上下文而无需编写额外的代码,那可能会更好。

让我们用namedtuple重写这段代码,这会更有意义,如清单 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)

Listing 2-7Return a Value from a Function as a Tuple

使用namedtuple编写代码给了它上下文,而不需要你为代码提供额外的信息。在这里,user_info作为namedtuple给你额外的上下文,当从一个叫做get_user_info的函数返回时,不需要显式设置。因此,从长远来看,使用namedtuple会使您的代码更具可读性和可维护性。

如果有十个值要返回,在移动数据时,通常可以考虑使用tupledict。当数据被移动时,这两种数据结构都不太可读。tuple 不为tuple中的数据提供任何上下文或名称,并且dict不具有不可变性,当您不希望数据在第一次赋值后改变时,这会对您产生约束。namedtuple填补了这两个空白。

最后,如果你想把namedtuple转换成dict或者把一个列表转换成namedtuplenamedtuple给你提供了简单的方法。所以,它们也很灵活。下一次你用不可变数据创建一个类或者返回多个值时,为了可读性和可维护性,考虑使用namedtuple

注意

只要您认为对象符号会使您的代码更具 Pythonic 化和可读性,您就应该使用namedtuple而不是 tuple。当我需要在某种上下文中传递多个值时,我通常会考虑它们;在这些情况下,namedtuple可以满足要求,因为它使代码更具可读性。

理解字符串、Unicode 和字节

理解 Python 语言中的一些基本概念将有助于开发人员在处理数据时成为更好的程序员。具体来说,在 Python 中,当您处理数据时,对str、Unicode 和byte有一个基本的了解会对您有所帮助。Python 对于数据处理或任何与数据相关的事情来说都很容易编码,因为它有内置的库和简单性。

您可能已经知道,str是 Python 中字符串的表示类型。参见清单 2-8 。

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

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

Listing 2-8Type str for Different Values

Unicode 为几乎所有语言中的每个字符提供了唯一的标识,例如:

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

Unicode 分配给字符的数字称为码点。那么,拥有 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。

例如,a将被 Python 解释器转换为 01100001,而将被转换为 11000011 01011111 (0xC3 0xA1)。所以,很容易理解 Unicode 为什么有用。

注意

在 Python 3 中,所有字符串都是 Unicode 字符序列。所以,你不应该考虑把字符串编码成 UTF 8 或者把 UTF 8 解码成字符串。您仍然可以使用字符串编码方法将字符串转换为字节,并将字节转换回字符串。

小心使用列表,优先选择生成器

迭代器非常有用,尤其是在处理大量数据的时候。我看到过这样的代码,人们使用一个列表来存储序列数据,但是这样会有内存泄漏的风险,从而影响系统的性能。

让我们考虑清单 2-9 中的例子。

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)
print(get_prime_numbers(30, 30000))

Listing 2-9Using a List of Return Prime Numbers

这样的代码有什么问题?首先,它很难阅读,其次,它在内存泄漏方面可能是危险的,因为你在内存中存储了大量的数据。如何使这段代码在可读性和性能方面更好?

这就是你可以考虑使用生成器的地方,生成器使用 yield 键生成数字,你可以把它们作为迭代器弹出值。让我们使用迭代器重写这个例子,如清单 2-10 所示。

def is_prime(num):
    for item in range(2, int(math.sqrt(num)) + 1):
        if num % item == 0:
            prime = False
    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

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

Listing 2-10Using Generators for Prime Numbers

这段代码可读性更强,性能更好。此外,生成器会无意中迫使您考虑重构代码。这里,在列表中返回值会使代码更加臃肿,生成器很容易解决这个问题。

我观察到的一个常见情况是,当您从数据库中获取数据并且不知道将获取多少行时,迭代器非常有用。这可能是内存密集型工作,因为您可能会尝试将这些值保存在内存中。相反,尝试使用迭代器,它会立即返回一个值,并转到下一行给出下一个值。

假设您必须访问数据库,通过 ID 获取用户的年龄和姓名。您知道数据库中作为索引的 id,并且您知道数据库中的用户总数,即 1,000,000,000。我见过的大多数代码中,开发人员试图使用列表获取数据,这是解决内存问题的好方法。清单 2-11 显示了一个这样的例子。

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

total_users = 1000000000
for user_info in range(total_users):
    info = get_all_users_age()
    for user in info:
        print(user)

Listing 2-11Access a Database and Store the Result in a List as a Chunk

在这里,您试图通过访问数据库来获取用户的年龄和姓名。然而,当系统中没有太多内存时,这种方法可能并不好,因为您会随机选择一个您认为内存安全的数字来存储用户信息,但您不能保证这一点。Python 提供了一个生成器作为解决方案来避免这些问题,并在您的代码中处理这些情况。你可以考虑重写它,如清单 2-12 所示。

def get_all_users_age():
    all_users = 1000000000
    for id in all_users:
        user_obj = 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)

Listing 2-12Using an Iterator Approach

注意

生成器是 Python 的一个有用特性,因为它们使您的代码对于数据密集型工作具有高性能。生成器也迫使你考虑让代码可读。

使用 zip 处理列表

当您有两个列表并且想要并行处理它们时,可以考虑使用zip。这是 Python 的内置函数,非常高效。

假设在数据库的用户表中有一个用户的姓名和薪水,您希望将它们合并到另一个列表中,并作为所有用户的列表返回。你有函数get_users_name_from_dbget_users_salary_from_db,给你一个用户列表和用户对应的工资。你如何将它们结合起来?清单 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

Listing 2-13Combine a List

有没有更好的办法解决这个问题?当然了。Python 有一个名为zip的内置函数,可以为您轻松处理这部分内容,如清单 2-14 所示。

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

Listing 2-14Using zip

如果你有很多数据,考虑在这里使用迭代器,而不是存储到一个列表中。zip使得合并两个列表并并行处理它们变得更加容易,因此使用zip将允许您高效地完成这些工作。

利用 Python 的内置函数

Python 有很多非常棒的内置库。在这一章中,我不能一一介绍,因为有很多这样的库。我将介绍一些基本的数据结构库,它们可以对您的代码产生重大影响,并提高您的代码质量。

收集

这是使用最广泛的库之一,具有有用的数据结构,特别是namedtupledefaultdictorderddict

战斗支援车

使用csv读写 CSV 文件。这会节省你很多时间,而不是在读文件的时候写你自己的方法。

日期和时间

毫无疑问,这是最常用的两个库。其实你很可能已经遇到过他们了。如果没有,熟悉这些库中可用的不同方法在不同的场景中是有益的,尤其是当您处理计时问题时。

数学

lib 有很多有用的方法来执行基础到高级的数学计算。在找第三方库解数学题之前,先试试看这个库是否已经有了。

使用正则表达式解决问题的本库无可替代。事实上,re是 Python 语言中最好的库之一。如果你很了解正则表达式,你可以使用re库创造奇迹。它使您能够使用正则表达式轻松地执行一些更困难的操作。

临时文件

请将此视为创建临时文件的一次性库。是一个很好的内置库。

迭代工具

这个库中一些最有用的工具是排列和组合。但是,如果你进一步探索,你会发现你可以用itertools解决很多计算问题。它有一些有用的功能,如dropwhileproductchainislice

函数工具

如果你是热爱函数式编程的开发人员,这个库就是为你准备的。它有很多功能,可以帮助你以一种更实用的方式思考你的代码。最常用的部分之一是在这个图书馆。

系统和操作系统

当您想要执行任何特定的系统级或操作系统级操作时,请使用这些库。sysos让你能够用你的系统做很多令人惊奇的事情。

子过程

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

记录

没有好的日志记录特性,任何大项目都不可能成功。Python 的logging库可以帮助您轻松地在系统中添加日志。它有不同的方式来输出日志,如控制台、文件和网络。

数据

JSON 是通过网络传递信息和 API 的事实上的标准。Python 的json库在处理不同场景方面做得很好。json库接口易于使用,文档也相当不错。

泡菜

您可能不会在日常编码中使用它,但是每当您需要序列化和反序列化 Python 对象时,没有比pickle更好的库了。

__ 未来 _ _

这是一个伪模块,支持与当前解释器不兼容的新语言功能。因此,如果您想在将来的代码中使用它们,您可能会考虑使用它们。参见清单 2-15 。

import __future__ import division

Listing 2-15Using __future__

注意

Python 有丰富的库,可以为你解决很多问题。了解他们是了解他们能为你做什么的第一步。从长远来看,熟悉内置的 Python 库会对你有所帮助。

既然您已经研究了 Python 中一些最常见的数据结构,那么让我们更深入地研究 Python 中最常用的数据结构之一:字典。如果你写的是专业的 Python 代码,肯定会用到字典,那就让我们多了解一下吧!

利用字典

字典是 Python 中最常用的数据结构之一。字典是访问数据的一种更快的方式。Python 有优雅的内置字典库,这也使它们易于使用。在这一部分,你将仔细观察字典的一些最有用的特性。

什么时候使用字典,什么时候使用其他数据结构

当您考虑可以映射数据的东西时,可能是时候考虑将字典作为代码中的数据结构了。

如果您正在存储需要某种映射的数据,并且您需要快速访问它,那么使用字典将是明智的;但是,您不希望考虑为每个数据存储使用一个字典。

因此,作为一个例子,考虑当您需要一个类的额外机制或需要一个对象时的情况,或者当您需要数据结构中的不变性时考虑使用 tuple 或namedtuple。在构建代码时,考虑一下需要哪种特定的数据结构。

收集

collections是 Python 中有用的模块之一。这是一种高性能的数据类型。collections有许多接口,对于用dictionary执行不同的任务非常有用。所以,我们来看看collections中的一些主要工具。

计数器

给你一个方便的方法来汇总相似的数据。例如,参见清单 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})

Listing 2-16Counter

Counterdict的子类。这是一个顺序集合,其中元素存储为字典键,它们的计数存储为值。这是计算数值的最有效的方法之一。Counter有多种有用的方法。most_common()顾名思义,返回最常见的元素及其计数。参见清单 2-17 中的示例。

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

Listing 2-17most_count() Method in Counter

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

双端队列

如果你想创建一个队列和堆栈,那么考虑使用deque。它允许您从左到右追加值。deque还支持线程安全、内存高效的追加和从任意一端弹出,具有相同的 O(1)性能。

dequeappend(x)追加到右侧、appendleft(x)追加到左侧、clear()移除所有元素、pop()移除右侧、popleft()移除左侧、reverse()反转元素等方法。让我们来看一些案例。参见清单 2-18 。

from collections import deque

# Make a deque
deq = deque("abcdefg")

# Iterate over the deque's element
[item.upper() for item in deq]
>>> deque(["A", "B", "C", "D", "E", "F", "G"])

# Add a new entry to right side
deq.append("h")
>>> deque(["A", "B", "C", "D", "E", "F", "G", "h"])

# Add an new entry to the left side
deq.appendleft("I")
>>> deque(["I", "A", "B", "C", "D", "E", "F", "G", "h"])

# Remove right most element
deq.pop()
>>> "h"

# Remove leftmost element
deq.popleft()
>>> "I"

# empty deque
deq.clear()

Listing 2-18deque

defaultdict(预设字典)

一个defaultdictdict一样工作,因为它是 dict 的子类。一个defaultdictfunction("default factory")初始化,它不带参数,为一个不存在的键提供默认值。defaultdict不像dict那样养出一个KeyError。任何不存在的键都将获得默认工厂返回的值。

让我们看看清单 2-19 中的简单例子。

from collections import defaultdict

# Make a defaultdict
colors = defaultdict(int)

# Try printing value of non-existing key would give us default values
colors["orange"]
>>> 0

print(colors)
>>> defaultdict(int, {"orange": 0})

Listing 2-19defaultdict

命名元组

最流行的工具之一是collection模块中的namedtuple。它是tuple的子类,有一个命名字段和固定长度。namedtuple可以在代码中使用元组的任何地方使用。namedtuple是一个不可变的列表,使得阅读代码和访问数据变得更加容易。

我已经讨论过namedtuple,所以参考那个讨论来了解更多。

有序直接

ordereddict当你想以特定的顺序得到按键时可以使用。dict不给你的顺序是插入顺序,这是ordereddict的主要特点。在 Python 3.6+中,dict也有这个特性,默认情况下 dict 是按照插入顺序排序的。

因此,作为一个例子,请参见清单 2-20 。

from collections import ordereddict

# Make a OrderedDict
colors = OrderedDict()

# Assing values
colors["orange"] = "ORANGE"
colors["blue"] = "BLUE"
colors["green"] = "GREEN"

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

Listing 2-20OrderedDict

有序字典与默认字典和普通字典

我在前面的章节中提到了其中的一些主题。现在让我们仔细看看一些不同类型的字典。

OrderedDictDefaultDict字典类型是dict类(一个普通字典)的子类,增加了一些特性使它们与dict有所区别。然而,它们拥有与普通词典相同的所有功能。Python 中的这些字典类型是有原因的,我将讨论在哪里可以使用这些不同的字典来最好地利用这些库。

从 Python 3.6 开始,dict现在按插入顺序排序,这实际上降低了ordereddict的有用性。

现在我们来谈谈 Python 之前版本的OrderedDictOrderedDict在您将值插入字典时为您提供有序的值。有时在代码中,您可能希望以有序的方式访问数据;这就是你可以使用OrderedDict的地方。OrderedDict与字典相比,没有任何额外的成本,所以在性能方面两者是一样的。

假设您想存储一种编程语言首次引入的时间。您可以使用OrderedDict来获取该语言的信息,就像您插入该语言的创建年份一样,如清单 2-21 所示。

from collections import OrderedDict

# Make a OrderedDict
language_found = OrderedDict()

# Insert values
language_found ["Python"] = 1990
language_found ["Java"] = 1995
language_found ["Ruby"] = 1995

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

Listing 2-21
OrderedDict

当您在字典中访问或插入键时,有时您希望将默认值分配给键。在普通字典中,如果键不存在,你会得到KeyError。但是,defaultdict会为您创建密钥。见清单 2-22 。

from collections import defaultdict

# Make a defaultdict
language_found = defaultdict(int)

# Try printing value of non-existing key
language_found["golang"]
>>> 0

Listing 2-22defaultdict

在这里,当你调用DefaultDict并试图访问不存在的golang键时,defaultdict会在内部调用函数对象(在language_found的例子中是int),这个函数对象是你在构造函数中传递的。它是一个可调用的对象,包括函数和类型对象。所以,你传递的intlist是进入defaultdict的函数。当您试图访问不存在的键时,它会调用已传递的函数,并将其返回值指定为新键的值。

正如您已经知道的,字典是 Python 中的键值集合。许多像defaultdictOrderedDict这样的高级库正在字典的基础上构建,以添加一些在性能方面没有额外成本的新特性。dict肯定会稍微快一点;但是,大多数情况下会有一个疏忽的区别。因此,在为这些问题编写自己的解决方案时,请考虑使用它们。

使用字典的 switch 语句

Python 没有switch关键字。然而,Python 有许多特性可以以更简洁的方式实现这一功能。您可以利用dictionary来创建一个switch语句,并且当您基于特定的标准有多个选项可供选择时,您也应该考虑以这种方式编写代码。

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

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"

calculate_tax("zambia", 8000000)

Listing 2-23switch Statement Using a Dictionary

这里,您只需使用字典来计算税款,与使用典型的switch语句相比,这使得您的代码更加优雅,可读性更好。

合并两本词典的方法

假设您有两本想要合并的词典。与以前的版本相比,在 Python 3.5+中这样做要简单得多。合并任何两个数据结构都很棘手,因为在合并数据结构时,您需要小心内存使用和数据丢失。如果您使用额外的内存来保存合并的数据结构,那么考虑到字典中的数据大小,您应该知道系统的内存限制。

丢失数据也是一个问题。您可能会发现,由于特定数据结构的限制,一些数据已经丢失;例如,在字典中,不能有重复的键。因此,无论何时在字典之间执行合并操作,都要记住这些事情。

在 Python 3.5+中,可以这样做,如清单 2-24 所示。

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}

Listing 2-24Merge Dictionaries in Python 3.5+

然而,在 Python 3.5 之前的版本中,只需做一点额外的工作就可以做到这一点。参见清单 2-25 。

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}

Listing 2-25Merge Dictionaries in Pre-3.5 Python

Python 3.5+有 PEP 448,它提出了对* iterable 解包操作符和**字典解包操作符的扩展使用。

这无疑使代码更具可读性。这不仅适用于字典,也适用于 Python 3.5 以后的列表。

漂亮地印刷一本字典

Python 有一个名为pprint的模块,所以你可以很好地打印。您需要导入pprint来执行操作。

在打印任何数据结构时,给你提供缩进的选项。缩进将应用于您的数据结构。参见清单 2-26 。

import pprint

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

Listing 2-26pprint for a Dictionary

对于嵌套更深、数据量更大的复杂词典来说,这可能无法达到预期效果。您可以考虑使用 JSON 来实现这一点,如清单 2-27 所示。

import json

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

Listing 2-27Using json to Print Dictionaries

摘要

数据结构是每一种编程语言的核心。正如您在阅读本章时了解到的,Python 提供了许多数据结构来存储和操作数据。Python 以数据结构的形式为您提供了各种工具,用于对不同类型的对象或数据集执行各种操作。作为一名 Python 开发人员,了解不同种类的数据结构非常重要,这样您就可以在编写应用时做出正确的决定,尤其是在资源密集型应用中。

我希望这一章能帮助你了解 Python 中一些最有用的数据结构。熟悉不同类型的数据结构及其不同的行为会使你成为更好的开发人员,因为你的工具箱中可以有不同类型的工具。

三、编写更好的函数和类

函数和类是 Python 语言的核心部分。你在专业领域写的所有代码都是由函数和类组成的。在这一章中,你将会学到一些有助于使你的代码更加易读和整洁的最佳实践。

在编写函数和类时,考虑函数/类的边界和结构是很重要的。对你的函数或类试图解决的用例有一个清晰的理解将帮助你编写更好的类和函数。永远记住单一责任原则的哲学。

功能

众所周知,Python 中的一切都是对象,函数也不例外。Python 中的函数非常灵活,因此确保仔细编写它们非常重要。我将讨论用 Python 编写函数时的一些最佳实践。

在 Python 中,通常当您在def子句中编写代码块时,您会将它们定义为函数或方法。我在这里不讨论 lambda 函数,因为我已经在前面的章节中讨论过了。

创建小函数

总是喜欢写一个函数做一个且只有一个任务。你如何确定你的函数只做一个操作,你如何度量你的函数的大小?你认为行数或字符数是函数大小的度量吗?

嗯,更多的是任务。您希望确保您的功能只执行一项任务,但是该任务可以构建在多个子任务之上。作为开发人员,您必须决定何时将一个子任务分解成单独的功能。没有人能替你回答这些问题。您必须严格分析您的功能,并决定何时将它们分解为多个功能。这是一项你必须通过不断分析你的代码并寻找代码中“有味道”的地方,或者换句话说,难以阅读和理解的地方来获得的技能。

考虑清单 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

Listing 3-1Unique E-mail Example

在清单 3-1 中,get_unique_emails正在执行两个不同的任务,首先遍历一个给定的文件来读取每一行,然后执行一个正则表达式来匹配每一行上的电子邮件。你可能已经注意到了两件事:第一,当然是函数执行的任务数量,第二,你可以进一步分解它,创建一个读取文件或行的通用函数。您可以将这个函数分成两个不同的函数,其中一个可以读取文件,另一个可以读取行。所以,作为一个开发者,你要决定这个函数是否需要被分解来写更干净的代码。参见清单 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

Listing 3-2Breaking Functions into Different Functions

在清单 3-2 中,函数read_file现在是一个通用函数,它可以接受任何文件名和yield每行,get_unique_emails在每一行上执行动作来查找唯一的电子邮件。

在这里,我创建了read_file作为生成器函数。但是,如果你想让它返回一个列表,你可以考虑这样做。主要思想是你应该在考虑可读性和单一责任原则后分解一个功能。

注意

我建议您首先编写实现该功能的代码,一旦您实现了该功能并且它正常工作,您就可以开始考虑将该功能分解为多个函数以获得更清晰的代码。此外,请记住遵循良好的命名约定。

返回发电机

正如您可能已经注意到的,在清单 3-2 的代码示例中,我使用了yield,而不是使用任何特定的数据结构,如listtuple。这里不使用任何其他数据结构的主要原因是,您不确定文件会有多大,并且在处理大文件时可能会耗尽内存。

生成器是使用yield关键字的函数(如第一章清单 1-22 所示),而read_file是一个生成器函数。发电机有用有两个主要原因。

  • 当生成器调用函数时,它们会立即返回迭代器,而不是运行整个函数,在这个函数上,你可以执行不同的操作,比如循环或者转换成一个列表(在第 1 的列表 1-22 中,你循环遍历迭代器)。一旦你完成了,它会自动调用内置函数next(),并在yield关键字后的下一行返回调用函数read_file。这也使你的代码更容易阅读和理解。

  • 在列表或其他数据结构中,Python 需要在返回之前将数据保存在内存中,如果数据很大,这可能会导致内存崩溃。发电机没有这个问题。因此,当您有大量数据要处理或者您事先不确定数据大小时,建议使用生成器而不是另一种数据结构。

现在可以考虑对清单 3-2 的get_unique_emails函数代码做一些修改,用yield代替列表,如清单 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)

Listing 3-3Breaking a Function into Different Functions

这里您忽略了从get_unique_emails函数发送列表中所有电子邮件的风险。

我在这里并不是暗示你应该在每个返回函数中使用生成器。如果您事先知道只需要返回特定的数据大小,那么使用 list/tuple/set/ dict可能会更容易。举个例子,在第一章的列表 1-22 中,如果你要返回 100 封电子邮件,最好使用列表或其他数据结构,而不是使用生成器。然而,在您不确定数据大小的情况下,可以考虑使用生成器,这将为您节省大量生产内存。

注意

熟悉 Python 生成器。我还没有看到很多开发人员在专业代码中使用生成器,但是你应该考虑它们的优点。它使你的代码更整洁,并使你免受内存问题的困扰。

引发异常而不是不返回任何异常

我在第一章 1 中详细讨论了异常,所以我不会在这里讨论所有的异常情况。本节只讨论当你有错误时抛出异常,而不是从函数中返回None

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

首先,我注意到当代码中发生意外时,许多程序员要么返回None要么记录一些东西。有时这种策略可能会很危险,因为它可能会隐藏 bug。

此外,我还见过这样的代码,其中函数返回None或一些随机值,而不是引发异常,这使得您的代码对于调用者函数来说很混乱,并且容易出错。参见清单 3-4 。

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

Listing 3-4Return None

在清单 3-4 中,您不能确定read_lines_for_python是否返回了None,因为该文件没有任何 Python 单词或文件问题。这种代码会导致你的代码中出现意想不到的错误,在一个大的代码库中发现错误可能会令人头疼。

所以,每当你写代码时,如果因为一些意想不到的事情发生而返回了None或其他值,考虑引发一个异常。随着代码变得越来越大,这将节省您追踪 bug 的时间。

考虑编写如清单 3-5 所示的代码。

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

Listing 3-5Raising an Exception Instead of None

每当您的代码失败时,您可以通过查看异常来了解失败的原因。引发异常有助于您尽早发现错误,而不是猜测。

注意

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

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

关键字参数对于提高 Python 代码的可读性和整洁度非常有用。关键字参数用于为函数提供默认值,或者可以用作关键字。参见清单 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)

Listing 3-6Default Arguments

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

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

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

def spam_emails(from, to, subject, size, sender_name, receiver_name):
    <rest of the code>

Listing 3-7Without Keyword Arguments

如果你在没有任何关键字参数的情况下调用spam_emails,它看起来像清单 3-8 。

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

Listing 3-8Without Keyword Arguments

如果只研究清单 3-8 中的那一行,很难猜测所有这些参数对一个函数意味着什么。如果你看到很多参数被用来调用一个函数,为了可读性,最好使用关键字参数来调用一个函数,如清单 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")

Listing 3-9With Keyword Arguments

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

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

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

不要显式返回 None

当你没有显式返回时,Python 函数默认返回None。参见清单 3-10 。

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

sum(80, 90)

Listing 3-10Default None Return

这里函数sum默认返回None。然而,很多时候人们会编写在函数中显式返回None的代码,如清单 3-11 所示。

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")           # Return None
result = sum(10, 5)               # Return 15

Listing 3-11Return None Explicitly

在这里,您期望结果是sum函数中的一个值,这具有误导性,因为它可能返回None或两个数的和。所以,你总是需要为None检查结果,这在代码中有太多的噪音,随着时间的推移,使代码更加复杂。

在这些情况下,您可能希望引发一个异常。参见清单 3-12 。

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

Listing 3-12Raise an Exception Instead of Returning None

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

def find_odd_number(numbers):
    odd_numbers = []
    if 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])       # return 7
num = find_odd_numbers((2, 4, 6, 7, 8, 10))        # return None
num = find_odd_number([2, 4, 6, 8, 10])           # return []

Listing 3-13Return None Explicitly

如果没有找到奇数,这个函数默认返回 None。如果数字的类型不是列表,该函数也返回None

你可以考虑重写这段代码,如清单 3-14 所示。

def find_first_odd_number(numbers):
    odd_numbers = []
    if 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])     # return 7
num = find_odd_numbers((2, 4, 6, 7, 8, 10))     # Raise ValueError exception
num = find_odd_number([2, 4, 6, 8, 10])       # return []

Listing 3-14Not Returning None Explicitly

现在,当您检查num值时,您就知道函数调用中有[]的确切原因了。显式添加这一点可以确保读者在没有找到奇数时知道会发生什么。

写函数的时候要有防御性

我们程序员是会犯错误的,所以不能保证你写代码的时候不会出错。考虑到这一事实,您可以在编写一个函数时采取创造性的措施,该函数可以在投入生产之前防止或暴露代码中的错误,或者甚至在生产中帮助您找到它们。

作为一名程序员,在将代码交付生产之前,您可以做两件事来确保交付的代码质量。

  • 记录

  • 单元测试

记录

先说伐木。当您尝试调试代码时,日志记录会有很大的帮助,尤其是在生产中,当您事先不知道哪里可能出错时。在任何成熟的项目中,尤其是大中型项目,如果不进行日志记录,很难保持项目的长期可维护性。当生产问题出现时,在代码中记录日志使代码更容易调试和诊断。

让我们看看日志代码通常是什么样子,如清单 3-15 所示。这是用 Python 编写日志的许多方法之一。

# Import logging module
Import logging

logger = logging.getLogger(__name__)          # Create a custom logger
handler = logging.StreamHandler               # Using stream handler

# Set logging levels
handler.setLevel(logging.WARNING)
handler.setLevel(logging.ERROR)

format_c = logging.Formatter("%(name) - %(levelname) - %(message)")
handler.setFromatter(format_c)                # Add formater to handler
logger.addHandler(handler)

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

num = divison(4, 0)

Listing 3-15Logging in Python

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

Python logging模块是一个成熟的库,它为您提供了许多特性来根据您的需要配置日志记录。

单元测试

单元测试是代码中最重要的部分之一。从专业角度来说,在代码中强制进行单元测试可以防止您引入错误,并且可以在您投入生产之前让您对代码有信心。Python 中有许多优秀的库,使得编写单元测试变得更加容易。一些流行的是py.testunittest库。我们在第八章中详细讨论了它们。这是用 Python 编写单元测试时的样子:

单元测试

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

当你恰当地编写单元测试时,它可以起到一些关键的作用。

  • 您可以使用单元测试作为代码的文档,这在您重新访问代码或新开发人员加入项目时会非常有帮助。

  • 它可以给你一种信心,让你相信你的代码能完成预期的行为。当您对函数进行测试时,您可以确保代码中的任何更改都不会破坏函数。

  • 它可以防止老的 bug 悄悄进入你的代码,因为你是在推向生产之前运行单元测试的。

一些开发人员通过在测试驱动开发(TDD)中编写代码来超越单元测试,但这并不意味着只有 TDD 应该有单元测试。每个需要用户使用的项目都应该有单元测试。

注意

在任何成熟的项目中,日志记录和单元测试都是必须的。它们可以极大地帮助你防止代码中的错误。Python 给了你一个叫logging的库,已经相当成熟了。对于单元测试,Python 有很多选项可供选择。pytestunittest是热门选项。

使用 Lambda 作为单个表达式

Lambdas 是 Python 中有趣的特性,但是我建议你避免使用它们。我见过很多 lambdas 被过度使用或误用的代码。

PEP8 建议而不是编写清单 3-16 所示的代码。

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

Listing 3-16Lambda

相反,编写如清单 3-17 所示的代码。

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

Listing 3-17Using a Normal Function

有几个理由来避免兰姆达斯。

  • 它们使代码更难阅读,这比只有一行的表达式更重要。例如,下面的代码让许多开发人员对 lambdas 感到不安:

  • λ表达式很容易被误用。开发人员经常试图通过编写一行表达式来使代码变得聪明,这使得其他开发人员很难理解。在现实世界中,它会导致代码中出现更多的错误。参见清单 3-18 。

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

import re
data = [abc0, abc9, abc5, cba 2]
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 )

Listing 3-18Misuse of Lambda Functions

在清单 3-18 中,代码误用了 lambda 函数,如果使用了函数,就更难理解了。

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

  • 当团队中的每个人都理解 lambda 表达式时

  • 当它使你的代码比使用函数更容易理解时

  • 当你正在做的操作很简单并且函数不需要名字时

班级

接下来,我将讨论类。

班级大小合适吗?

如果您正在用任何语言进行面向对象编程,您可能会想知道一个类的合适大小是多少。

在编写类的时候,永远记住单一责任原则(SRP)。如果您正在编写一个具有清晰定义的职责和清晰定义的边界的类,您不应该担心一行类代码。有些人认为一个类有一个文件是一个类的很好的衡量标准;然而,我见过文件本身明显很大的代码,如果每个文件只有一个类,可能会令人困惑和产生误解。如果你看到一个类在做不止一件事,那就意味着是时候创建一个新的类了。有时候在责任方面是一条细线;然而,当你在一个类中添加新的代码时,你必须小心。你不想跨越责任的界限。

仔细查看每一个方法和每一行代码,并思考该方法或部分代码是否符合类的总体职责,这是研究类结构的一个好方法。

假设您有一个名为UserInformation的类。你不想将每个用户的支付信息和订单信息添加到这个类中。即使与用户相关的信息不是必要的用户信息,支付信息和订单信息更多的是用户的支付活动。在编写一个类之前,您需要确保定义了这些职责。你可以定义UserInformation类负责保存用户信息的状态,而不是用户活动。

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

我建议在编写代码之前有一个清晰的类范围定义,坚持使用类范围定义将解决大多数类大小问题。

阶级结构

我更喜欢这样的班级结构:

  1. 类别变量

  2. __init__

  3. 内置 Python 特殊方法(__call____repr__等)。)

  4. 类方法

  5. 静态方法

  6. 性能

  7. 实例方法

  8. 私有方法

例如,您可能希望代码看起来像清单 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

Listing 3-19Class Structure

类别变量

通常你想在顶部看到一个类变量,因为这些变量要么是常量,要么是默认的实例变量。这向开发人员展示了这些常量变量已经准备好使用,所以这是一个有价值的信息,在任何其他实例方法或构造函数之前,要保存在类的顶部。

init

这是一个类构造函数,调用方法/类需要知道如何访问类。代表任何类的门,告诉如何调用该类以及该类中有哪些状态。__init__还提供了在开始使用该类之前要提供的关于该类的主要输入的信息。

特殊 Python 方法

特殊的方法改变了类的默认行为或者为类提供了额外的功能,所以将它们放在类的顶部可以让类的读者知道类的一些定制特性。此外,这些被覆盖的元类让您知道,一个类正试图通过改变 Python 类的通常行为来做一些不同的事情。将它们放在顶部允许用户在阅读类代码的其余部分之前记住类的修改行为。

类方法

类方法作为另一个构造函数工作,所以把它放在__init__附近是有意义的。它告诉开发人员不用使用__init__创建构造函数就可以使用该类的其他方法。

静态方法

静态方法绑定到类,而不是像类方法那样绑定到类的对象。它们不能修改类的状态,所以将它们添加在顶部是有意义的,这样可以让读者了解用于特定目的的方法。

实例方法

实例方法在类中添加行为,所以开发人员希望如果一个类有特定的行为,那么实例方法将是该类的一部分。因此,用特殊的方法保存它们会让读者更容易理解代码。

私有方法

由于 Python 没有任何私有关键字概念,在方法名中使用_<name>告诉读者这是一个私有方法,所以不要使用它。你可以把它放在实例方法的底部。

我建议在实例方法周围保留私有方法,以便读者更容易理解代码。实例方法之前可以有私有方法,反之亦然;都是关于调用离被调用方法最近的方法。

注意

Python 是一种面向对象的语言,当您用 Python 编写类时,也应该这样对待它。遵循 OOP 的所有规则不会伤害你。在编写类的时候,要确保读者很容易理解这个类。如果其中一个方法正在使用另一个方法,则实例方法应该相邻。私有方法也是如此。

使用@property 的正确方法

@property装饰器(在第五章中讨论)是 Python 获取和设置值的有用特性之一。在类中有两个地方可以考虑使用@property:隐藏在属性后面的复杂代码中和 set 属性的验证中。见清单 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)

Listing 3-20Class Property Decorator

这个代码有什么问题?您在方法fahrenheit中使用了属性装饰器,但是该方法更新了变量self.temperature的值,而不是返回任何值。当您使用属性装饰器时,请确保您返回了值;当您使用属性装饰器时,这将使调用类/方法更容易期望从方法返回一些东西。所以,确保你返回值并在你的代码中使用一个属性装饰方法作为获取器,如清单 3-21 所示。

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

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

Listing 3-21Class Property Decorator

属性装饰器也用于验证/过滤值。和 Java 等其他编程语言中的 setter 是一样的。在 Python 中,可以使用属性装饰器来验证/过滤特定的信息。我见过很多地方,开发者通常意识不到 Python 中 setter 属性装饰器的强大。以适当的方式使用它会使你的代码可读性更好,并且会把你从那些你有时会忘记的角落错误中拯救出来。

在清单 3-22 中是一个使用 Python 中的属性装饰器实现验证的例子。它通过显示在设置特定值时要验证的内容,使代码对开发人员来说更可读,也更容易理解。

在这个例子中,您有一个名为Temperature的类,它设置华氏温度。使用属性装饰器来获取和设置温度值使得Temperature类更容易验证调用者的输入。

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

Listing 3-22Class Property Decorator

这里,fahrenheit setter 方法在计算华氏温度之前进行验证,这使得调用类预期在错误输入的情况下会引发异常。调用类现在只需要调用fahrenheit方法就可以获得华氏温度的值,而不需要任何输入。

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

何时使用静态方法?

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

当你用 Python 写一个类的时候,你想把相似类型的方法组合在一起,但是也想通过使用不同变量的方法来保持一个特定的状态。此外,您希望使用类的对象执行不同的操作;然而,当您将一个方法设为静态时,该方法不能访问任何类状态,也不需要对象或类变量来访问它们。那么,什么时候应该使用静态方法呢?

当你写一个类时,可能有一个方法可以作为一个函数独立存在,并且不需要类状态来执行一个特定的动作。有时将它作为类的一部分作为静态方法是有意义的。您可以将此静态方法用作类使用的实用工具方法。但是为什么不把它作为一个独立的函数放在类的外面呢?很明显,你可以这样做,但是把它放在类中会让读者更容易把函数和类联系起来。让我们用一个简单的例子来理解这一点,如清单 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

Listing 3-23Without a Static Method

在这里,方法price_to_book_ratio可以在不使用任何状态BookPriceCalculator的情况下工作,但是将它保留在类BookPriceCalculator中可能是有意义的,因为它与类BookPricing相关。因此,您可以编写如清单 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

Listing 3-24With a Static Method

这里你把它作为一个静态方法,你不需要使用任何类方法或变量,但是它与BookPriceCalculator类相关,所以把它作为一个静态方法。

以 Pythonic 的方式使用抽象类继承

抽象是 Python 很酷的特性之一。它有助于确保继承的类以预期的方式实现。那么,在你的接口中有一个抽象类的主要目的是什么呢?

  • 你可以使用抽象来创建一个接口类。

  • 如果不实现抽象方法,就不可能使用接口。

  • 如果你不遵守抽象的类规则,它会给出早期的错误。

如果以错误的方式用 python 实现抽象,这些好处可能会违反 OOPS 抽象规则。清单 3-25 展示了在没有完全使用 Python 抽象特性的情况下创建抽象类的代码。

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

    def originated(self):
        raise NotImplementedError()

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

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

Listing 3-25Abstract Class the Wrong Way

因此,问题如下:

  • 您可以初始化类AppleFruit而不会得到任何错误;一旦你创建了一个类的对象,它就应该抛出一个异常。

  • 代码可能已经投入生产,而您甚至没有意识到它是一个不完整的类,直到您使用了taste方法。

那么,在 Python 中定义一个抽象类的更好的方法是什么,以满足理想抽象类的要求呢?Python 通过给你一个叫做abc的模块解决了这个问题,这个模块做你期望从抽象类中得到的东西。让我们使用abc模块重新实现抽象类,如清单 3-26 所示。

from abc import ABCMeta, abstractmethod

class Fruit(metaclass=ABCMeta):

    @abstractmethod
    def taste(self):
        pass

    @abstractmethod

    def originated(self):
        pass

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

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

Listing 3-26Abstract Class the Right Way

使用abc模块可以确保实现所有预期的方法,给你可维护的代码,并确保在产品中没有半成品代码。

使用@classmethod 访问类状态

除了使用__init__方法之外,类方法还为您提供了创建可选构造函数的灵活性。

那么,在你的代码中哪里可以利用类方法呢?如前所述,一个明显的地方是通过传递一个类对象来创建多个构造函数,所以这是用 Python 创建工厂模式的最简单的方法之一。

让我们考虑一个场景,在这个场景中,您期望从调用方法得到多种格式的输入,并且您需要返回一个标准化的值。序列化类就是一个很好的例子。假设您有一个需要序列化一个User对象并返回用户的名字和姓氏的类。然而,挑战在于确保客户端的接口更易于使用,并且接口可以获得四种不同格式中的一种:字符串、JSON、对象或文件。使用工厂模式可能是解决这个问题的有效方法,这就是类方法有用的地方。清单 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)

Listing 3-27Serialization Class

在这里,您创建了一个User类和多个类方法,它们的行为类似于客户端类的接口,用于根据客户端数据访问特定的类状态。

当您正在构建一个包含多个类的大项目时,类方法是一个有用的特性,拥有干净的接口有助于保持代码的长期可维护性。

使用 public 属性而不是 private 属性

如你所知,Python 对于类没有任何private属性概念。然而,您可能已经使用或见过使用dunder _<var_name>变量名将方法标记为私有的代码。你仍然可以访问这些变量,但是这样做是被禁止的,所以 Python 社区一致认为dunder _<var_name>变量或方法是私有的。

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

假设您有一个将_full_name作为私有实例变量的类Person。为了访问_full_name实例变量,您创建了一个名为get_name的方法,该方法允许调用者类访问变量,而无需直接访问私有方法。见清单 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"

Listing 3-28Using _ in the Wrong Places

然而,这仍然是一种错误的私有变量的方法。

如您所见,Person类试图通过将一个属性命名为_full_name来隐藏它;然而,这使得代码更加麻烦和难以阅读,即使代码的意图是限制用户只能访问_full_name变量。如果您考虑对每个私有变量都这样做,这会使您的代码变得复杂。想象一下,如果你的类中有很多私有变量,你必须定义和私有变量一样多的方法,会发生什么。

当您不想将类变量或方法公开给调用者类或方法时,将类变量或方法设为私有,因为 python 并不强制对变量和方法进行私有访问,所以通过将类变量和方法设为私有是一种传达调用者类或方法的方式,这些方法或变量不应被访问或覆盖。

我建议当你试图继承某个公共类,而你对那个公共类及其变量没有控制权时,在你的代码中使用__<var_name>名。当您想要避免代码中的冲突时,使用__<var_name>来避免名称混乱问题仍然是一个好主意。让我们考虑清单 3-29 中的简单例子。

class Person:

    def __init__(self, first_name, last_name):
        self.age = 50

    def get_name(self):
        return self.full_name

class Child(Person):

    def __init__(self):
        super().__init__()
        self.__age = 20

ch = Child()
print(ch.get())              # 50
print(ch.__age)              # 30

Listing 3-29Using __ in Inheritance of a Public Class

摘要

Python 不像 Java 等其他编程语言那样对变量/方法或类有任何访问控制。然而,Python 社区已经就一些规则达成共识,包括私有和公共概念,尽管 Python 认为一切都是公共的。您还应该知道什么时候使用这些特性,什么时候避免使用它们,这样您的代码对其他开发人员来说是可读的,看起来有说服力的。

四、使用模块和元类

模块和元类是 Python 的重要特性。在处理大型项目时,对模块和元编程有很好的理解将有助于您编写更简洁的代码。Python 中的元类是一种隐藏的特性,除非有特殊需要,否则不需要关心。模块帮助你组织你的代码/项目,帮助你构建你的代码。

模块和元类是很大的概念,所以在这里详细解释它们会很困难。在这一章中,你将探索一些关于模块和元编程的良好实践。

模块和元类

在开始之前,我将简要解释一下 Python 世界中的模块和元类概念。

模块只是扩展名为.py的 Python 文件。模块的名称将是文件的名称。一个模块可以有许多函数或类。Python 中模块的概念是在逻辑上分离项目的功能,如下所示:

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

payment.pyinfo.py是将用户的支付和信息功能逻辑分离的模块。模块有助于使您的代码更容易结构化。

元类是一个大话题,但简而言之,它们是创建一个类的蓝图。换句话说,类创建一个实例,元类在创建时根据需要自动改变类的行为。

让我们假设您需要从awesome开始创建模块中的所有类。您可以在模块级使用__metaclass__来完成这项工作。参见清单 4-1 中的示例。

def awesome_attr(future_class_name, future_class_parents, future_class_attr):
    """
      Return a class object, with the list of its attribute prefix with awesome keyword.
    """

    # pick any attribute that doesn't start with '__' and prefix with 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

    # let `type` do the class creation
    return type(future_class_name, future_class_parents, uppercase_attr)

__metaclass__ = awesome_attr # this will affect all classes in the module

class Example: # global __metaclass__ won't work with "object" though
    # but we can define __metaclass__ here instead to affect only this class
    # and this will work with "object" children
    val = 'yes'

Listing 4-1Metaclass Example

是许多元类概念中的一个特性。Python 提供了多个元类,您可以根据自己的需要加以利用。你可以在 https://docs.python.org/3/reference/datamodel.html 查看

现在让我们看看在编写代码和考虑使用元类或构建模块时,Python 中的一些好的实践。

模块如何帮助组织代码

在这一节中,您将看到模块如何帮助您组织代码。模块通过保存相关的函数、变量和类来帮助分离代码。换句话说,Python 模块为您提供了一个工具,通过将项目的不同层放入不同的模块中来对它们进行抽象。

假设您需要建立一个用户可以购买产品的电子商务网站。要构建这种项目,您可能需要创建具有特定用途的不同层。在高层次上,您可以考虑为用户操作设置层,比如选择产品、将产品添加到购物车以及付款。所有这些层可能只有一个或几个功能,您可以将它们保存在一个文件或不同的文件中。当您想要在另一个模块中使用一个较低级别的层(如支付模块)时,如将产品添加到购物车,您可以简单地使用import语句作为添加到购物车模块中的from ... import来完成。

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

  • 保持模块名称简短。你也可以考虑不使用下划线,或者至少尽量少用下划线。

    Don’t do this:

    import  user_card_payment
    import add_product_cart
    from user import cards_payment
    
    

    Do this:

    import payment
    import cart
    from user.cards import payment
    
    
  • 避免使用带点(.)、大写或其他特殊字符的名称。所以,应该避免使用像credit.card.py这样的文件名。名称中包含这类特殊字符会给其他开发人员带来困惑,并会对代码的可读性产生负面影响。PEP8 也建议不要用这些特殊字符来命名。

    Don’t do this:

    import user.card.payment
    import USERS
    
    

    Do this:

    import user_payment
    import users
    
    
  • 当考虑代码的可读性时,以某种方式导入模块很重要。

    Don’t do this:

    [...]
    from user import *
    [...]
    cart = add_to_cart(4)  # Is add_to_cart part of user? A builtin? Defined above?
    
    

    Do this:

    from user import add_to_cart
    [...]
    x = add_to_cart(4)  # add_to_cart may be part of user, if not redefined in between
    
    

    Even better, do this:

    import user
    [...]
    x = user.add_to_cart(4)  # add_to_cart is visibly part of module's namespace
    
    

能够说出模块来自哪里有助于提高可读性,如前面的例子所示,其中user.add_to_cart有助于识别add_to_cart函数驻留在哪里。

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

  • 作用域:它帮助你避免代码不同部分的标识符之间的冲突。

  • 可维护性:模块帮助你在代码中定义逻辑边界。如果你的代码中有太多的依赖项,开发人员将很难在没有模块的大项目中工作。模块通过将相互依赖的代码隔离在一个模块中,帮助您定义这些界限并最小化依赖性。这在大型项目中很有帮助,因此许多开发人员可以在不影响彼此的情况下做出贡献。

  • 简单性:模块帮助你将大问题分解成小问题,这使得编写代码更加容易,对于其他开发者来说也更加易读。这也有助于调试代码,使其不容易出错。

  • 可重用性:这是拥有模块的主要优势之一。模块可以很容易地在不同的文件中使用,例如项目中的库和 API。

归根结底,模块有助于组织您的代码。尤其是在大型项目中,多个开发人员在代码库的不同部分工作,仔细地、逻辑地定义模块是非常重要的。

利用 init 文件

从 Python 3.3 开始,__init__.py不需要指明目录是 Python 包。在 Python 3.3 之前,需要有一个空的__init__.py文件才能把一个目录做成一个包。然而,__init__.py文件在多种情况下都很有用,可以让你的代码易于使用,并以某种方式打包。

__init__.py的主要用途之一是帮助将模块分割成多个文件。让我们考虑这样一个场景,您有一个名为purchase的模块,它有两个不同的类,分别名为CartPaymentCart将产品添加到购物车中,Payment类执行产品的支付操作。参见清单 4-2 。

# purchase module

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)

Listing 4-2Module Example

假设您想将这两个不同的功能(添加到购物车和支付)分成不同的模块,以更好地构建代码。您可以通过将CartPayment类移动到两个不同的模块中来实现,如下所示:

purchase/
    cart.py
    payment.py

你可以考虑编写清单 4-3 中所示的cart模块。

# cart module

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

Listing 4-3Cart Class Example

考虑一下payment模块,如清单 4-4 所示。

# payment module

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

Listing 4-4Payment Class Example

现在,您可以将这些模块保存在__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!

使用模块的主要原因是为客户创建设计更好的代码。客户不必处理多个小模块并弄清楚什么功能属于哪个模块,而是可以使用单个模块来处理 project 的不同功能。这在大型代码和第三方库中尤其有用。

假设一个客户端使用您的模块,如下所示:

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

这是可行的,但是它给客户带来了更多的负担,让他们去弄清楚什么驻留在你的项目中的什么位置。取而代之的是,统一事物并允许单一导入,以使客户端更容易使用该模块。

from purchase import Cart, Payment

在后一种情况下,最常见的是将大量的源代码看作一个模块。例如,在前一行中,purchase可以被客户端视为一段源代码或一个模块,而不用担心CartPayment类驻留在哪里。

这也展示了如何将不同的子模块缝合到一个模块中。如前面的例子所示,您可以将大型模块分成不同的逻辑子模块,并且用户只能使用一个模块名。

以正确的方式从模块中导入函数和类

在 Python 中,从相同或不同的模块中导入类和函数有不同的方法。您可以在同一个包中导入包,也可以从包的外部导入包。让我们看一下这两种场景,看看从一个模块中导入类和函数的最佳方式是什么。

  • 在包内部,可以使用完全指定的路径或相对路径从同一个包导入。这里有一个例子。

    不要这样:

    from foo import bar               # Don't Do This
    
    

    Do this:

    from . import bar                 # Recommended way
    
    

    第一个import语法使用包的完整路径,比如TestPackage.Foo,顶层包的名称在源代码中是硬编码的。问题是如果你想改变包的名字或者重组你的项目的目录结构。

    例如,如果您想要将名称从TestPackage更改为MyPackage,您必须更改它出现的每个地方的名称。如果您的项目中有很多文件,这可能会很脆弱,很难做到。这也使得任何人都很难移动代码。然而,相对进口没有这个问题。

  • Outside of a package, there are different ways to import a package from outside of a module.

    from mypackage import *             # Bad
    from mypackage.test import bar      # OK
    import mypackage                    # Recommended way
    
    

    第一种选择是导入所有内容,这显然不是导入包的正确方法,因为您不知道从包中导入了什么。第二个选项比较冗长,也是一个很好的实践,因为它比第一个选项更清晰,可读性更好。

第二个选项也有助于读者理解从哪个包中导入了什么。这有助于使代码对其他开发人员更具可读性,并帮助他们理解所有的依赖关系。但是,当您必须从不同的地方导入不同的包时,就会出现一个问题。这在你的代码中变成了一种噪音。想象一下,如果您有 10 到 15 行代码用于从不同的包中导入特定的东西。我注意到的第二个问题是,当你在不同的包中使用相同的名字时,在编写代码时,会产生很多混淆,弄不清哪个类/函数属于哪个包。这里有一个例子:

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

推荐第三个选项的原因是它可读性更好,并且在阅读代码时给你一个概念,哪个类和函数属于哪个包。

import mypackage
import yourpackage
mypackage.foo.get_result()
import 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()       # This throw exception
monthly_payment = MonthlyPayment()           # This will work

您可能已经注意到,使用from payment import *并不会使payment的所有类自动导入。然而,您仍然可以通过如下方式导入CalculatePaymentCreditCardPayment类:

 from payment import CalculatePayment

何时使用元类

如你所知,元类创建类。就像您可以创建类来创建对象一样,Python 元类也以同样的方式创建这些对象。换句话说,元类是类的类。因为这一节不是关于元类如何工作的,所以我将把重点放在什么时候应该考虑使用元类。

大多数情况下,代码中不需要元类。元类的主要用例是创建一个 API 或库,或者添加一些复杂的特性。每当你想隐藏很多细节,让客户更容易使用你的 API/库时,元类真的很有帮助。

以 Django ORM 为例,它大量使用元类使其 ORM API 易于使用和理解。Django 通过使用元类使这成为可能,您可以编写如清单 4-5 所示的 Django ORM。

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

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

Listing 4-5__init__.py

这里user.age不会返回IntegerField;它将返回一个从数据库中获取的int

Django ORM 工作的原因在于Model类利用元类的方式。Model类定义了__metaclass__,它使用一些魔法将User类变成一个复杂的数据库字段挂钩。Django 通过公开一个简单的 API 和使用元类使复杂的事情看起来简单。元类在幕后使这成为可能。

有不同的元类,如__call____new__等。所有这些元类都可以帮助你构建漂亮的 API。如果看好的 Python 库的源代码比如flaskDjangorequests等。,您会发现这些库正在使用元类来使它们的 API 看起来易于使用和理解。

当您发现使用普通的 Python 功能无法让您的 API 具有可读性时,可以考虑使用元类。有时,您必须使用元类编写样板代码,以使您的 API 易于使用。我将在后面的章节中讨论元类如何有助于编写更简洁的 API/库。

使用 new 验证子类

创建实例时将调用神奇的方法__new__。使用这种方法,您可以轻松地定制实例创建。在初始化类的实例时,在调用__init__之前调用该方法。

您还可以通过使用super调用超类的__new__方法来创建一个类的新实例。清单 4-6 给出了一个例子。

class User:
    def __new__(cls, *args, **kwargs):
        print("Creating instances")
        obj = super(User, cls).__new__(cls, *args, **kwargs)
        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

Listing 4-6__new__

这里,当您创建一个类的实例时,在调用__init__ magic 方法之前会调用__new__

想象一个场景,你必须创建一个超类或抽象类。无论哪个类继承了那个超类或抽象类,都应该做特定的检查或工作,这很容易忘记或者可能被子类错误地完成。因此,您可能希望考虑在超类或抽象类中拥有该功能,这也确保了每个类都必须遵守那些验证检查。

在清单 4-7 中,你可以在任何子类继承抽象或超类之前使用__new__元类进行验证。

from abc import abstractmethod, ABCMeta

class UserAbstract(metaclass=ABCMeta):
"""Abstract base class template, implementing factory pattern using __new__() initializer."""

    def __new__(cls, *args, **kwargs):
    """Creates an object instance and sets a base property."""
        obj = object.__new__(cls)
        obj.base_property = "Adding Property for each subclass"
        return obj

class User(UserAbstract):
"""Implement UserAbstract class and add its own variable."""

    def __init__(self):
        self.name = "Larry"

>> user = User()
>> user.name
Larry
>> user.base_property
Adding Property for each subclass

Listing 4-7__new__ for Assigning a Value

这里,每当为子类创建一个实例时,base_property自动被赋予值"Adding Property for each subclass"

现在,让我们修改这段代码来验证提供的值是否是字符串。参见清单 4-8 。

from abc import abstractmethod, ABCMeta

class UserAbstract(metaclass=ABCMeta):
"""Abstract base class template, implementing factory pattern using __new__() initializer."""

    def __new__(cls, *args, **kwargs):
    """Creates an object instance and sets a base property."""
        obj = object.__new__(cls)
        given_data = args[0]
        # Validating the data here
        if not isinstance(given_data, str):
            raise ValueError(f"Please provide string: {given_data}")
        return obj

class User(UserAbstract):
"""Implement UserAbstract class and add its own variable."""

    def __init__(self, name):
        self.name = Name

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

Listing 4-8__new__ for Validating the Provided Value

在这里,每当传递一个值来为User类创建一个实例时,您都要验证所提供的数据是字符串。这样做的真正好处是使用__new__魔法方法,而不用每个子类做重复的工作。

为什么 slots 有用

__slots__帮助你节省对象空间,获得更快的属性访问。让我们用清单 4-9 中的简单例子快速测试一下__slots__的性能。

class WithSlots:
"""Using __slots__ magic here."""
    __slots__ = "foo"

class WithoutSlots:
"""Not using __slots__ here."""
    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

Listing 4-9__slots__ Faster Attribute Access

即使你只是试图访问with_slots.foo,这也比访问WithoutSlots类的属性要快得多。在 Python 3 中,__slots__比没有__slots__时快 30%。

__slots__的第二个用例是为了节省内存。__slots__有助于减少每个对象实例占用的内存空间。__slots__节省的空间是巨大的。

你可以在 https://docs.python.org/3/reference/datamodel.html#slots 找到更多关于__slots__的信息。

使用__slots__的另一个原因显然是为了节省空间。如果您考虑列出 4-8 并找出对象的大小,那么您可以看到__slots__与普通对象相比为对象节省了空间。

>> import sys
>> sys.getsizeof(with_slots)
48
>> sys.getsizeof(without_slots)
56

与不使用__slots__相比,__slots__有助于节省物品空间,并为您提供更好的性能。问题是,什么时候应该考虑在代码中使用__slots__?要回答这个问题,我们先简单说一下实例创建。

当您创建一个类的实例时,额外的空间会自动添加到实例中以容纳__dict____weakrefs____dict__通常不会被初始化,直到你使用它进行属性访问,所以你不应该担心这个。然而,当您创建/访问属性时,如果您需要节省额外的空间或使其具有性能,那么__slots__dict更有意义。

然而,每当你不想让类对象中的__dict__占用额外的空间时,你可以使用__slots__来节省空间,并在你需要访问属性时获得额外的性能。

举个例子,清单 4-10 使用__slots__,子类没有为属性a创建__dict__,这样在访问a属性的同时节省了空间,提高了性能。

class Base:
    __slots__ = ()

class Child(Base):
    __slots__ = ('a',)

c = Child()
c.a = 'a'

Listing 4-10__slots__ Faster Attribute Access

Python 文档建议大多数情况下不要使用__slots__。在极少数情况下,如果您觉得需要额外的空间和性能,请尝试一下。

我还建议不要使用__slots__,除非你真的需要额外的空间和性能,因为它会限制你以特定的方式使用类,尤其是在动态分配变量的时候。例如,参见清单 4-11 。

class User(object):
    __slots__ = ("first_name", )

>> user = User()
>> user.first_name = "Larry"
>> b.last_name = "Page"
AttributeError: "User" object has no attribute "last_name"

Listing 4-11Attribute Error When Using __slots__

有很多方法可以避开这些问题,但是与使用没有__slots__的代码相比,这些解决方案不会给你太多帮助。举个例子,如果你想要动态赋值,你可以使用清单 4-12 中的代码。

class User:
    __slots__ = first_name, "__dict__"

>> user = User()
>> user.first_name = "Larry"
>> user.last_name = "Page"

Listing 4-12Using __dict__ with __slots__ to Overcome the Dynamic Assignment Issue

因此,有了__slots__中的__dict__,您失去了一些规模优势,但好处是您获得了动态分配。

以下是其他一些不应该使用__slots__的地方:

  • 当您对 tuple 或 str 之类的内置函数进行子类化并希望向其添加属性时

  • 当您想通过类属性为实例变量提供默认值时

所以,当你真的需要额外的空间和性能时,考虑使用__slots__。它不会通过限制类特性和增加调试难度来限制你。

使用元类改变类的行为

元类有助于根据您的需要定制类的行为。与其创建一些复杂的逻辑来在类中添加特定的行为,不如看看 Python 元类。它们为您提供了一个很好的工具来处理代码中的复杂逻辑。在本节中,您将了解如何使用一种叫做__call__的神奇方法来实现多个特性。

假设你想阻止一个客户直接创建一个类的对象;您可以使用__call__轻松实现这一点。参见清单 4-13 。

class NoClassInstance:
"""Create the user object."""
    def __call__(self, *args, **kwargs):
        raise TypeError("Can't instantiate directly""")

class User(metaclass=NoClassInstance):
    @staticmethod
    def print_name(name):
    """print name of the provided value."""
        print(f"Name: {name}")

>> user = User()
TypeError: Can't instantiate directly
>>> User.print_name("Larry Page")
Name: Larry Page

Listing 4-13Prevent Creating an Object Directly

这里__call__确保该类不是直接从客户端代码启动的;相反,它使用静态方法。

假设您需要创建一个应用策略设计模式的 API,或者让客户端代码更容易使用您的 API。

让我们考虑清单 4-14 中的例子。

class Calculation:
    """
    A wrapper around the different calculation algorithms that allows to perform different action on two numbers.
    """
    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

Listing 4-14API Design Using __call__

在这里,您可以发送不同的方法或算法来执行特定的操作,而无需复制公共逻辑。这里你可以看到__call__中的代码,这使得你的 API 更容易使用。

让我们看看清单 4-15 中的另一个场景。假设您想以某种方式创建缓存实例。当使用相同的值创建对象时,它会缓存实例,而不是为相同的值创建一个新的实例,这在您不想使用相同的参数复制一个实例时非常有用。

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

Listing 4-15Implement Instance Caching Using __call__

我希望__call__用例能帮助你理解元类如何帮助你轻松完成一些复杂的任务。__call__还有一些其他好的用例,比如创建单例、存储值和使用 decorators。

注意

还有很多时候,元类可以用来轻松完成复杂的任务。我建议深入研究元类,并尝试理解一些元类的用例。

了解 Python 描述符

Python 描述符有助于从对象的字典中获取、设置和删除属性。当您访问class属性时,这将启动查找链。如果描述符方法是在代码中定义的,那么描述符方法将被调用来查找属性。这些描述符方法在 Python 中是__get____set____delete__

实际上,当您从一个类实例中分配或获取一个特定的属性值时,您可能希望在设置属性值之前或在获取属性值时做一些额外的处理。Python 描述符帮助您完成这些验证或额外的操作,而无需调用任何特定的方法。

因此,让我们来看一个例子,它将帮助你理解一个真实的用例,如清单 4-16 所示。

import random

class Dice:
"""Dice class to perform dice operations."""
    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

Listing 4-16Python Descriptor __get__ Example

这里,您使用__get__描述符为class属性提供额外的功能,而不调用任何特定的方法,并且您使用__set__来确保您只将int值分配给Dice类属性。

让我们简单了解一下这些描述符。

  • __get__(self, instance, owner):当你访问这个属性时,这个方法会在定义时被自动调用,如清单 4-16 所示

  • __set__(self, instance, owner):当你设置实例的属性时,这个方法被称为obj.attr = "value"

  • __delete__(set, instance):当你想删除一个特定的属性时,这个描述符被调用。

描述符为您提供了对代码的更多控制,可用于不同的场景,如在赋值前验证属性、使属性为只读等。这也有助于使您的代码更加整洁,因为您不需要创建一个特定的方法来完成所有这些复杂的验证或检查操作。

注意

当你想以一种更简洁的方式设置或获取类属性时,描述符非常有用。如果您了解它们是如何工作的,那么在您想要执行特定属性验证或检查的其他地方,它可能会对您更有用。理想情况下,本节帮助您对描述符有一个基本的了解。

摘要

Python 中的元类被认为是晦涩的,因为它们的语法和有点神奇的功能。然而,如果你掌握了本章中讨论的一些最常用的元类,它会使你的代码更好地为最终用户所用,你会觉得你能更好地控制为用户设计 API 或库的方式。

但是,请谨慎使用它们,因为有时使用它们来解决代码中的每个问题会影响代码的可读性。类似地,很好地理解 Python 中的模块会让您更好地理解为什么以及如何让您的模块遵循 SRP。我希望这一章能让你对 Python 中这两个非常重要的概念有足够的了解。

五、装饰器和上下文管理器

装饰器和上下文管理器是 Python 中的一个高级主题,但它们在许多现实场景中很有用。许多流行的库广泛使用装饰器和上下文管理器来使它们的 API 和代码更干净。最初,理解装饰器和上下文管理器可能有点棘手,但是一旦你掌握了它们,它们可以让你的代码更整洁。

在这一章中,你将学习装饰器和上下文管理器。在编写下一个 Python 项目时,您还将探索这些特性何时有用。

注意

装饰器和上下文管理器是 Python 中的高级概念。在幕后,他们大量使用元类。您不需要学习元类来学习如何使用装饰器和上下文管理器,因为 Python 为您提供了足够的工具和库来创建装饰器和上下文管理器,而无需使用任何元类。如果您对元类了解不多,不要担心。您应该能够充分了解装饰器和上下文管理器是如何工作的。您还将学习一些技术,使编写装饰器和上下文管理器变得更容易。我建议很好地掌握装饰器和上下文管理器的概念,这样你就可以知道在代码中什么地方可以使用它们。

装修工

先说装修工。在这一节中,您将学习装饰器是如何工作的,以及在现实世界的项目中可以在哪里使用它们。装饰器是 Python 的一个有趣而有用的特性。如果你很好的理解了 decorators,你可以不费吹灰之力的构建很多神奇的特性。

Python decorators 帮助您动态地向函数或对象添加行为,而不改变函数或对象的行为。

什么是装修工,他们为什么有用?

假设您的代码中有几个函数,您需要在所有这些函数中添加日志记录,以便当它们被执行时,函数名被记录在日志文件中或在控制台上打印出来。一种方法是使用日志库,并在每个函数中添加一行日志。然而,这样做要花相当多的时间,而且也容易出错,因为您只是为了添加一个日志而对代码做了大量的修改。另一种方法是在每个函数/类的顶部添加装饰器。这要有效得多,而且没有给现有代码添加新错误的风险。

在 Python 世界中,decorators 可以应用于函数,它们有能力在它们包装的函数之前和之后运行。装饰器帮助在函数中运行额外的代码。这允许您访问和修改输入参数和返回值,这在很多地方都很有用。以下是一些例子:

  • 限速

  • 缓存值

  • 计时函数的运行时间

  • 记录目的

  • 缓存异常或引发异常

  • 证明

这些是装饰器的一些主要用例;然而,使用它们是没有限制的。事实上,你会发现像flask这样的 API 框架严重依赖 decorators 将功能转化为 API。清单 5-1 显示了一个flask的例子。

from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello World!"

Listing 5-1flask Example

这段代码使用route装饰器将hello函数转换成一个 API。这就是装饰器的魅力所在,作为开发人员,很好地理解他们会让你受益,因为他们可以让你的代码更干净,更不容易出错。

理解装饰器

在这一节中,您将看到如何使用 decorators。假设您有一个简单的函数,将传入的字符串转换为大写并返回结果。参见清单 5-2 。

def to_uppercase(text):
"""Convert text to uppercase and return to uppercase."""
    if not isinstance(text, str):
        raise TypeError("Not a string type")
    return text.upper()

>>> text = "Hello World"
>>> to_uppercase(text)
HELLO WORLD

Listing 5-2Convert to Uppercase by Passing a String

这是一个简单的函数,它接受一个字符串并将其转换成大写。让我们对to_uppercase做一个小的改动,如清单 5-3 所示。

def to_uppercase(func):
"""Convert to uppercase and return to uppercase."""

    # Adding this line, will call passed function to get text
    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

Listing 5-3Convert to Uppercase by Passing func

做了两处改动。

  • 我修改了函数to_uppercase来接受func而不是字符串,并调用该函数来获取字符串。

  • 我创建了一个返回“welcome”的新函数调用,并将该函数传递给了to_upper_case方法。

to_uppercase函数调用say函数并获取要转换成大写的文本。因此,to_uppercase通过调用函数say获取文本,而不是从传递的参数中获取。

现在,对于相同的代码,您可以编写类似清单 5-4 的代码。

@to_uppercase
def say():
    return "welcome"

>>> say
WELCOME

Listing 5-4Using Decorators

to_uppercase放在函数前面作为@to_uppercase使得函数to_uppercase成为装饰函数。这类似于在执行say功能之前执行to_uppercase

这是一个简单的例子,但适合展示装饰器如何在 Python 中工作。现在,使用to_uppercase作为装饰函数的好处是,您可以将它应用于任何函数,使字符串大写。例如,参见清单 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

Listing 5-5Applying Decorators in Other Places

这使得代码更清晰,更容易理解。请确保您的装饰器名称是显式的,这样就很容易理解装饰器想要做什么。

使用装饰器修改行为

现在你已经知道了装饰器的基本原理,让我们更深入一点去理解装饰器的主要用例。在清单 5-6 中,你将编写一个复杂的小函数来包装另一个函数。因此,您将修改函数to_uppercase以接受任何函数,然后在to_uppercase下定义另一个函数来执行upper()操作。

def to_uppercase(func):
    def wrapper():
        text = func()
        if not isinstance(text, str):
            raise TypeError("Not a string type")
        return text.upper()
    return wrapper

Listing 5-6Decorator for Uppercase

那么,这是怎么回事?您有一个名为to_uppercase的函数调用,像以前一样将func作为参数传递,但是这里您将代码的剩余部分转移到另一个名为wrapper的函数中。wrapper函数由to_uppercase返回。

wrapper函数允许你在这里执行代码来改变函数的行为,而不仅仅是运行它。现在,您可以在函数执行之前和函数完成执行之后做多件事情。包装器闭包可以访问输入函数,并可以在函数前后添加新代码,这显示了装饰器函数改变函数行为的实际能力。

拥有另一个函数的主要用途是在显式调用之前不执行该函数。直到它被调用,它将包装函数并写入函数的对象。因此,您可以编写清单 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

Listing 5-7Full Code for Decorator for Uppercase

在上面的例子中,to_uppercase()是一个 define a decorator,它基本上以任何函数作为参数,并将字符串转换成大写。在上面的代码中say()函数使用to_uppercase作为装饰器,当 python 执行函数say()时,python 在执行时将say()作为函数对象传递给to_uppercase()装饰器,并返回一个名为 wrapper 的函数对象,该函数对象在被调用为say()hello()时被执行。

几乎所有在运行特定功能之前必须添加功能场景都可以使用 decorator。考虑这样一种情况,当你想让你网站用户登录后才能看到你网站上的任何页面时,你可以考虑在允许用户访问你网站页面的任何功能上使用登录装饰器,这将迫使用户登录后才能看到你网站上的任何页面。相似性,考虑一个简单的场景,您想要在文本后添加单词“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

使用多个装饰器

你也可以对一个函数应用多个装饰器。假设你必须在“拉里·佩奇”前加一个前缀在这种情况下,您可以使用不同的装饰器来添加前缀,如清单 5-8 所示。

def add_prefix(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_prefix
def say():
    return "welcome"

>> say()
WELCOME LARRY PAGE!

Listing 5-8Multiple Decorators

您可能已经注意到,decorator 是自下而上应用的,所以首先调用add_prefix,然后调用to_uppercase decorator。为了证明这一点,如果你改变装饰器的顺序,你会得到不同的结果,如下所示:

@add_prefix
@to_uppercase
def say():
    return "welcome"

>> say()
WELCOME Larry Page!

正如你所注意到的,“Larry Page”没有被转换成大写,因为它是最后被调用的。

装饰器接受争论

让我们扩展一下前面的例子,将参数传递给 decorator 函数,这样您就可以动态地将传递的参数改为大写,并称呼不同的人。参见清单 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'

Listing 5-9Pass Arguments to Decorator Functions

正如您所看到的,您可以将参数传递给装饰器函数,它执行代码并在装饰器中使用那些传入的参数。

考虑为装饰器使用一个库

当你创建一个装饰器时,它主要是用另一个函数替换一个函数。让我们考虑清单 5-10 中的简单例子。

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

Listing 5-10Decorator for Logging Function

您可能希望将foo作为函数名打印出来。相反,它打印logs作为函数名,这是装饰函数logging内部的一个包装函数。事实上,当你在使用一个装饰器时,你总是会丢失诸如__name____doc__等信息。

为了克服这个问题,您可以考虑使用functool.wrap,它接受装饰器中使用的函数,并添加复制函数名、文档字符串、参数列表等功能。因此,您可以编写相同的代码,如清单 5-11 所示。

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__)  # prints 'f'
print(foo.__doc__)   # prints 'does some math'

Listing 5-11functools to Create Decorators

Python 标准库有一个名为functools的库,它有funtools.wrap来创建有助于保留所有信息的 decorator,否则当您创建自己的 decorator 时,这些信息可能会丢失。

除了functools,还有decorator之类的库,也是真的好用。清单 5-12 显示了一个例子。

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 (), {}

Listing 5-12Use a Decorator to Create a Decorator Function

类似地,您可以在类中为类方法使用 decorators,如清单 5-13 所示。

def retry_requests(tries=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

    @try_request(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

Listing 5-13Class Using a Function Decorator

用于维护状态和验证参数的类装饰器

到目前为止,您已经看到了如何使用函数作为装饰器,但是 Python 对于只创建方法作为装饰器没有任何限制。类也可以用作装饰器。这完全取决于你想用哪种特定的方式来定义你的装饰器。

使用类装饰器的一个主要用例是维护状态。然而,让我们首先了解一下__call_方法如何帮助您的类使其可调用。

为了使任何类都可以调用,Python 提供了特殊的方法,比如__call__()方法。这意味着__call_允许类实例作为函数被调用。像__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}")

Listing 5-14Use of the __call__ Method

现在,每当您使用类的实例调用Count类时,就会调用__call__方法。

>>> count = Count()
>>> count()
Number to times called: 2

>>> count()
Number of times called: 3

如你所见,调用count()会自动调用__call__方法,该方法维护变量num的状态。

您可以使用这个概念来实现装饰器类。参见清单 5-15 。

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

Listing 5-15Maintain the State Using Decorators

__init__方法需要存储函数的引用。每当调用修饰类的函数时,就会调用__call__方法。这里使用了functools库来创建装饰器类。如您所见,您正在使用类装饰器存储变量的状态。

让我们看一个更有趣的例子,如清单 5-16 所示,这可以使用类装饰器来实现,也就是类型检查。这是一个展示用例的简单例子;但是,您可以在需要检查参数类型的各种情况下使用它。

class ValidateParameters:

    def __init__(self, func):
        functools.update(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)

#  returns anb
print(concate("a", "n", "b"))

# raises Error.
print(concate("a", 1, "c"))

Listing 5-16Validate Parameters Using Class Decorators

您会注意到,您正在使用类装饰器来进行类型检查。

正如您所看到的,有很多地方您可以使用 decorators 来使您的代码更加整洁。无论何时考虑使用装饰器模式,都可以使用 Python 装饰器轻松实现。理解 decorator 有点棘手,因为它需要对函数如何工作有一定程度的理解,但是一旦你对 decorator 有了基本的理解,就可以考虑在现实世界的应用中使用它们。你会发现它们使你的代码更加整洁。

上下文管理器

上下文管理器和装饰器一样,是 Python 的一个有用特性。您甚至可能在日常代码中使用它们而没有意识到这一点,尤其是当您使用 Python 内置库时。常见的例子是文件操作或套接字操作。

此外,在编写 API 或第三方库时,上下文管理器非常有用,因为它使您的代码更具可读性,并防止客户端代码编写不必要的代码来清理资源。

上下文管理器及其用途

正如我提到的,您可能在执行不同的文件或套接字操作时不知不觉地使用了上下文管理器。参见清单 5-17 。

with open("temp.txt") as fread:
    for line in fread:
        print(f"Line:  {line}")

Listing 5-17File Operations Using a Context Manager

这里的代码使用上下文管理器来处理操作。with关键字是使用上下文管理器的一种方式。为了理解上下文管理器的用处,让我们在没有上下文管理器的情况下编写这段代码,如清单 5-18 所示。

fread = open("temp.txt")
try:
    for line in fread:
        print(f"Line:  {line}")
finally:
    fread.close()

Listing 5-18File Operations Without a Context Manager

try - finally块代替了with语句,这样客户端就不用担心处理异常了。

除了更简洁的 API 之外,上下文管理器的主要用途是资源管理。考虑一个场景,您有一个可以读取用户输入文件的函数,如清单 5-19 所示。

def read_file(file_name):
"""Read given file and print lines."""
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

Listing 5-19Reading Files

首先,很容易忘记在前面的代码中添加file.close()语句。读取文件后,文件还没有被read_file函数关闭。现在考虑函数read_file被连续调用数千次;这将在内存中打开数千个文件处理程序,并可能有内存泄漏的风险。为了防止这些情况,您可以使用上下文管理器,如清单 5-20 所示。

类似地,这里会有内存泄漏,因为系统对在特定时间可以使用的资源数量有限制。在清单 5-16 的情况下,当你打开一个文件时,操作系统会分配一个叫做文件描述符的资源,这个资源是被操作系统限制的。因此,当超过这个限制时,程序崩溃并显示消息OSError

fread = []
for x in range(900000):
    fread.append(open('testing.txt', 'w'))

>>> OSError: [Errno 24] Too many open files: testing.txt

Listing 5-20
Leak File Descriptor

显然,上下文管理器可以帮助您更好地处理资源。在这种情况下,这包括在文件操作完成后关闭文件并放弃文件描述符。

了解上下文管理器

如您所见,上下文管理器对于资源管理非常有用。让我们看看您如何构建它们。

要创建一个with语句,你需要做的就是给一个对象添加__enter____exit__方法。Python 在需要管理资源的时候会调用这两个方法,所以你不用担心。

因此,让我们看看打开一个文件并构建一个上下文管理器的同一个例子。参见清单 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")

Listing 5-21
Managing Files

现在,当您运行这段代码时,您将尽可能不会遇到文件描述符泄漏的问题,因为ReadFile正在为您管理这个问题。

这是因为当with语句执行时,Python 调用__enter__函数并执行。当执行离开上下文块(with)时,它执行__exit__来释放资源。

让我们看看上下文管理器的一些规则。

  • __enter__返回分配给上下文管理器块中as之后的变量的对象。这个对象通常是self

  • __exit__调用原始的上下文管理器,而不是由__enter__返回的那个。

  • 如果__init____enter__方法出现异常或错误,则不会调用__exit__

  • 一旦代码块进入上下文管理器块,无论抛出什么异常或错误,都将调用__enter__

  • 如果__exit__返回 true,那么任何异常都将被抑制,并且执行将从上下文管理器块中退出,而没有任何错误。

让我们通过查看清单 5-22 中的例子来理解这些规则。

class ContextManager():
    def __init__(self):
        print("Crating 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__

Listing 5-22Context Manager Class

使用 contextlib 构建上下文管理器

Python 提供了一个名为contextlib.contextmanager decorator 的库,而不是编写类来创建上下文管理器。编写上下文管理器比编写类更方便。

Python 内置的库使得编写上下文管理器更加容易。您不需要用所有这些__enter____exit__方法编写整个类来创建上下文管理器。

contextlib.contextmanager装饰器是一个基于生成器的工厂函数,用于自动支持with语句的资源,如清单 5-23 所示。

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

Listing 5-23Creating a Context Manager Using contextlib

首先,write_file获取资源,然后调用者将使用的yield关键字生效。当调用者从with块退出时,生成器继续执行,这样任何剩余的清理步骤都可以进行,比如清理资源。

当使用@contextmanager装饰器创建上下文管理器时,生成器产生的值就是上下文资源。

基于类的实现和contextlib装饰器是相似的实现;这是你想要实现的个人选择。

使用上下文管理器的一些实例

让我们看看上下文管理器在日常编程和您的项目中有什么用处。

在许多情况下,您可以使用上下文管理器来使您的代码更好,也就是说没有错误和更干净。

您将探索几个不同的场景,在这些场景中,您可以从第一天开始使用上下文管理器。除了这些用例,您还可以在许多不同的特性实现中使用上下文管理器。为此,您需要在代码中找到您认为使用上下文管理器编写时会更好的机会。

访问数据库

您可以在访问数据库资源时使用上下文管理器。当特定进程正在处理数据库中的某些特定数据并修改值时,您可以在该进程处理该数据时锁定数据库,一旦操作完成,您就可以放弃锁定。

作为一个例子,清单 5-24 展示了来自 https://docs.python.org/2/library/sqlite3.html#using-the-connection-as-a-context-manager 的一些 SQLite 3 代码

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"

Listing 5-24
sqlite3 Lock

在这里,您使用了一个上下文管理器,它可以在失败时自动提交和回滚。

写作测试

在编写测试的时候,很多时候你想要用代码抛出的不同种类的异常来模拟测试的特定服务。在这些情况下,上下文管理器非常有用。像pytest这样的测试库具有允许你使用上下文管理器来编写测试那些异常或模拟服务的代码的特性。参见清单 5-25 。

def divide_numbers(self, first, second):
    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)

Listing 5-25
Testing Exception

你也可以用它来嘲讽:

with mock.patch("new_class.method_name"):
    call_function()

mock.patch是一个可以用作装饰器的上下文管理器的例子。

共享资源

使用with语句,您可以一次只允许访问一个进程。假设您必须锁定一个文件才能用 Python 写。可以同时从多个 Python 进程访问它,但是您希望一次只使用一个进程。您可以使用上下文管理器来实现,如清单 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.")

Listing 5-26Lock File While Reading with Shared Resource

这段代码使用filelock库来锁定文件,这样它只能被一个进程读取。

上下文管理器块防止您在操作进行时进入另一个进程来使用该文件。

远程连接

在网络编程中,您主要与套接字进行交互,并使用网络协议通过网络访问不同的东西。当您想要使用远程连接来访问资源或在远程连接上工作时,可以考虑使用上下文管理器来管理资源。远程连接是使用上下文管理器的最佳场所之一。参见清单 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): <code for sending data>
     def receive(self): <code for receiving data>

with Protocol(host, port) as protocol:
     protocol.send(['get', signal])
     result = protocol.receive()

Listing 5-27Lock File While Reading with Remote Connection

这段代码使用上下文管理器通过套接字访问远程连接。它会帮你处理很多事情。

注意

上下文管理器可以在多种情况下使用。当您在编写测试时发现管理资源或处理异常的机会时,就开始使用上下文管理器。上下文管理器也让你的 API 更加干净,隐藏了很多瓶颈代码,给你一个更干净的界面。

摘要

装饰器和上下文管理器是 Python 中的一等公民,应该是您在应用设计中的首选。装饰器是一种设计模式,允许您在不修改代码的情况下向现有对象添加新功能。类似地,上下文管理器允许您有效地管理资源。您可以使用它们在函数之前和之后运行一段特定的代码。它们还能帮助你使你的 API 更干净,可读性更强。在下一章,你将探索更多的工具,比如生成器和迭代器,来提高你的应用的质量。

六、生成器和迭代器

迭代器和生成器是 Python 中有用的工具。它们可以使处理不同的数据问题变得更容易,并且帮助您编写更干净、性能更好的代码。

Python 有一个库来利用这两个特性。你将在这一章中学习它们,并且你将探索不同的问题,这些问题可以被生成器和迭代器轻松地处理,而不需要太多的努力。

利用迭代器和生成器

在这一节中,您将探索迭代器和生成器的不同特性,并了解这两个特性可以在代码中的什么地方得到更好的应用。这两个特性主要用于解决不同的数据问题。

理解迭代器

一个迭代器是一个作用于数据流的对象。迭代器对象有一个名为__next__的方法,当你使用for循环、列表理解或任何遍历所有数据点的方法从对象或其他数据结构中获取数据时,在后台会调用__next__方法。

清单 6-1 展示了如何创建一个类并使它成为一个迭代器。

class MutiplyByTwo:
    def __init__(self, number):
        self.number = number
        self.count = 0

    def __next__(self):
        self.counter += 1
        return self.number * self.counter

mul = Mutiple(500)
print(next(mul))
print(next(mul))
print(next(mul))
>>> 500
>>> 1000
>>> 1500

Listing 6-1Iterator Class

让我们看看 Python 中迭代器的实际工作方式。在前面的代码中,有一个名为MultiplyByTwo的类,该类有一个名为__next__的方法,每当调用该方法时都会返回一个新的迭代器。迭代器需要通过使用__next__中的计数器变量来记录 in 在序列中的位置。然而,如果您试图在一个for循环中使用这个类,您会发现它会抛出一个错误,如下所示:

for num in MultiplyByTwo(500):
    print(num)
>>> MultiplyByTwo object is not iterable.

有趣的是,MultiplyByTwo是迭代器,不是可迭代的。所以,for循环在这里不起作用。那么,什么是可迭代的?让我们看看 iterables 和迭代器有什么不同。

iterable 对象有一个名为__iter__的方法,它返回一个迭代器。当在任何对象上调用__iter__时,它返回迭代器,迭代器可以用来迭代对象以获取数据。在 Python 中,字符串、列表、文件和字典都是可迭代的例子。

当您尝试对它们执行一个for循环时,效果很好,因为循环返回一个迭代器。

既然你已经理解了可迭代和迭代器,让我们修改类MultiplyByTwo成为可迭代的。见清单 6-2 。

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)

Listing 6-2Iterator Class with the for Loop

这个迭代器永远运行,这在某些情况下可能是有用的,但是如果您想要有限数量的迭代器呢?清单 6-3 展示了如何实现这一点。

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)

Listing 6-3Iterator Class with StopIteration

当你举起StopIteration,你的MultiplyByTwo对象得到它已经用完限制的信号,引发一个由 Python 自动处理的异常,并退出循环。

什么是发电机?

生成器对于读取大量数据或大量文件非常有用。发生器可以暂停和恢复。生成器返回可以像列表一样迭代的对象。然而,与列表不同,它们很懒,一次生成一个项目。与其他数据结构相比,在处理大型数据集时,生成器的内存效率要高得多。

让我们试着创建一个类似于上一个例子中迭代器的乘法函数。参见清单 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)

Listing 6-4Generator Example

你会注意到这比迭代器例子要短得多,因为你不需要定义__next____iter__。您也不需要跟踪内部状态或引发异常。

你可能已经注意到的新东西是yield关键字。yield类似于return,但是它不是终止函数,而是简单地暂停执行,直到请求另一个值。与迭代器相比,生成器更具可读性和性能。

何时使用迭代器

当您处理大量文件或数据流形式的数字时,迭代器非常有用。迭代器为您提供了一次处理一部分数据的灵活性,而不是将所有数据加载到内存中。

假设您有一个包含一系列数字的 CSV 文件,您需要从这个 CSV 文件中计算数字的总和。您可以将 CSV 文件中的数据序列存储在一个列表中,然后计算总和,或者使用迭代器方法,逐行读取 CSV 文件,然后计算每行的总和。

让我们来看看这两种方式,这样您就可以理解其中的区别,如清单 6-5 所示。

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)

Listing 6-5Read a CSV File Using a List

请注意,您在这里将数据保存在一个列表中,然后计算列表中数字的总和。这可能会增加内存开销,并可能导致内存泄漏,因为您是以 CSV 文件和列表的形式复制内存中的数据,如果您正在读取一个大文件,这可能会很危险。在这里,迭代器可以通过只从 CSV 文件中获取一行来节省您的时间,因此您不必一次将所有数据都转储到内存中。参见清单 6-6 。

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)

Listing 6-6Read a CSV File Using an Iterator

这段代码计算一行的总和,并通过让迭代器从 CSV 文件中给你一组新的数据,把它加到下一行。

迭代器的另一个用例是从数据库中读取数据。让我们考虑这样一个场景,电子商务公司通过在线商店销售产品,用户通过在线支付购买这些产品。用户的付款存储在一个名为Payment的表中,24 小时后,一个自动化系统查询Payment表,并计算过去 24 小时的总利润。

有两种方法可以解决这个问题。第一个选项是查询Payment表,获得一个金额列表,然后计算这些金额的总和。在平常的日子里,这可能行得通,但请考虑某个特定的日子,比如黑色星期五或某个公司有数百万笔交易的假日。一次在内存中加载数百万条记录可能会使系统崩溃。第二个选项是查询表,但按行或按行数(如 100 或 1,000)获取数据,然后计算总事务。在 Django 中,你可以做一些如清单 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

Listing 6-7Read Payment Information from a Database Using an Iterator

这段代码通过一次一行地从数据库中获取数据来计算总量,而不是一次加载所有数据。

使用 itertools

Python 有一个名为itertools的模块,它收集了有用的方法。我不能在这里涵盖所有的方法,但我会谈谈其中的一些。

组合()

itertools.combinations(iterable, r)

这个工具给出了长度为riterable的组合元组,在前一行中是 2。

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')
] 

置换()

itertools.permutations(iterable, r)

这将返回所有长度为r的排列;如果rNone,那么r的默认长度就是一个 iterable 的长度。

from itertools import permutations

print(permutations(['1','2','3']))

print(list(permutations(['1','2','3'])))
[('1', '2', '3'), ('1', '3', '2'),
 ('2', '1', '3'), ('2', '3', '1'),
 ('3', '1', '2'), ('3', '2', '1')
]

产品()

itertools.product(iterable, r)

此工具计算输入 iterable 的笛卡尔积。这类似于嵌套循环。

例如,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)
] 

计数()
itertools.count(start=0, step=1)

count()是一个迭代器,返回从数字start开始的等距数字。

举个例子,你告诉count()用步骤 4 返回一个数字迭代器。

import itertools

for num in itertools.count(1, 4):
    print(item)
    if item > 24:
        break

>>> 1, 5, 9, 13, 17, 21

groupby(群件)
itertools.groupby(iterable, key=None)

itertools.groupby tool helps you to group items.

举个简单的例子,假设您需要按如下方式对角色进行分组:

numbers = 555441222
result = []
for num, length in groupby(numbers):
    result.append((len(list(length)), int(num)))

print(*result)

>>> (3, 5)(2,4)(1,1)(3,2)

itertools里还有其他有用的方法真的很有用。建议你去 https://docs.python.org/3.7/library/itertools.html 了解更多信息。

为什么发电机很有用

像迭代器一样,生成器也节省内存。因为迭代器能够进行惰性进化,所以可以通过只获取操作所需的数据来节省内存。因此,在从数据库中读取大文件时,可以使用生成器来节省内存和 CPU 周期。

所以,假设你想以懒惰的方式读取文件;您可以使用yield关键字,它会为您创建一个生成器函数。参见清单 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)

Listing 6-8Read in a Chunk Using a Generator

在这里,您正在读取一个大文件,而不是将while文件加载到内存中。

列表理解与迭代器

列表理解和迭代器是生成数字的两种不同方式,它们在生成数字时如何在内存中保存数据或执行操作方面有很大的不同。

# This is iterators expression to generate numbers up to 200.
 (x*2 for x in xrange(200))
# List comprehension expression to generate numbers up to 200

[x*2 for x in xrange(200)]

这里的主要区别是列表理解在完成后会将所有 200 个数字保存在内存中。然而,迭代器创建一个动态生成数字的 iterable 对象,所以在迭代器的情况下速度很快。此外,迭代器让您可以灵活地传递对象,动态生成一个数字。

利用 yield 关键字

在深入研究yield之前,我先说说如何在 Python 中使用yield关键字。

当你在你的一个函数内部定义yield时,调用该函数会给你一个生成器对象;然而,那并不运行你的功能。一旦你得到一个生成器对象,每次你从生成器中提取一个对象(通过使用for循环或者通过使用next(),Python 将执行这个函数,直到到达yield关键字。一旦 Python 到达了yield关键字,它就交付对象并暂停,直到你提取它。一旦提取了对象,Python 就会在yield之后继续运行代码,直到到达另一个yield(可能是同一个yield关键字,也可能是不同的yield)。一旦发生器耗尽,它将退出并出现一个StopIteration异常,这个异常由for循环自动处理。

换句话说,yield是一个像return一样使用的关键字,除了函数返回一个生成器。参见清单 6-9 。

def generate_numbers(limit):
    for item in xrange(limit):
        yield item*item
        print(f"Inside the yield: {item}")

numbers = generate_numbers() # create a generator

print(numbers) # numbers is an object!
<generator object generate_numbers at 0xb7555c34>

for item in numbers:
     print(item)
0
1
4

Listing 6-9Generate a Number Using a Generator

这里您使用关键字yield创建了一个生成器函数。注意,当您调用函数generate_numbers()时,您会得到numbers对象,这是一个生成器对象。然后,您可以使用它来动态生成数字。

当你第一次在一个for循环中调用生成器对象时,它从generator_numbers的开始运行函数,直到遇到yield关键字,然后它停止并返回循环的第一个值。一旦它第二次调用,就从下一行开始,也就是print(f"Inside the yield: {item}")。它继续这样做,直到达到一个极限。

从...屈服

从 Python 3 开始就使用了yield from关键字。yield from的主要用例是从其他生成器获取一个值,如清单 6-10 所示。

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]

Listing 6-10Generate a Number Using the yield from Keyword

您没有迭代flat_list,而是使用了yield from,这不仅缩短了代码行,还使您的代码更加整洁。

与数据结构相比,产量更快

如果您正在处理大量数据并且需要速度,那么显然您应该使用生成器来生成数据,而不是依赖于像列表或元组这样的数据结构。

这里有一个简单的例子:

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肯定比使用列表快。

摘要

生成器和迭代器真的很有用,尤其是在处理大量数据或大文件的时候。您需要格外小心内存和 CPU 消耗,因为过度消耗会导致内存泄漏等问题。Python 为您提供了像itertoolsyield这样的工具来帮助您避免所有这些问题。当你处理大文件、使用数据库或调用多个 API 时,要格外勤奋;您也许可以使用这些工具来使您的代码更加整洁和高效。

七、利用新的 Python 特性

最新 Python 3 版本中引入的新特性使得用 Python 编写程序变得有趣多了。Python 已经有了很多很棒的特性,Python 3 使它成为一种特性更加丰富的语言。Python 3 附带了一些特性,比如对异步编程、类型、更好的性能、迭代器改进等的原生支持。

在这一章中,你将会学到一些新的特性,这些特性可以使你的代码比以前的 Python 版本更好,性能更高。您将了解使用任何或所有这些特性是如何有用的,以及您应该考虑在代码中的什么地方使用它们。

注意

你可以在 https://docs.python.org/3/whatsnew/3.7.html 的官方文档中探索 Python 的新特性。在撰写本书时,Python 3 仍处于开发阶段,因此可能会有一些这里没有提到的改进。换句话说,请留意 Python 官方文档,了解最新的特性。

异步编程

如果你曾经用另一种语言如 JavaScript 做过异步编程(或简称为 async programming ),你可能知道这不是一个简单的话题。在 Python 3.4 之前,有一种方法可以使用第三方库进行异步编程,但与 NodeJS 这样对异步编程非常友好的语言相比,总感觉有点笨拙。

Python 在这个问题上很灵活,因为您可以编写同步和异步代码。与同步编程相比,使用异步编程可以使您的代码更加高效,因为它可以更有效地利用资源。然而,知道什么时候应该使用异步编程,什么时候不应该使用非常重要。

在进一步讨论之前,让我们讨论一下异步和同步编程。在同步世界中,事情一次发生一件。你调用一个函数或操作,你的程序控制在它继续做下一件事之前等待它完成。当一个函数完成其操作时,该函数返回结果。当操作被函数执行时,你的系统除了等待它完成之外不做任何其他事情。

在异步世界中,多件事情可以同时发生。当您开始一个动作或调用一个函数时,您的程序会继续运行,您可以执行其他动作或调用其他函数,而不只是等待异步函数完成。一旦异步函数完成了工作,程序控件就可以访问结果。

作为一个例子,我们假设您必须通过调用不同公司的股票 API 来获取不同公司的股票数据。在同步代码中,您将调用第一个 stock API 并等待得到回复,然后您将进行另一个调用并等待它完成。这是运行程序的简单方法;然而,程序花费太多时间等待响应。在异步代码中,调用第一个 stock API,然后是第二个,第三个,一直到从其中一个 API 得到结果。您收集结果并继续调用其他库存 API,而不是等待结果。

在本节中,您将探索 Python 中的异步编程,以便理解如何使用它。这是 Python 异步编程的三个主要构件:

  • 事件循环的主要任务是管理不同的任务,并将它们分配执行。事件循环注册每个任务,并负责这些任务之间的流控制。

  • 协程是调度事件循环运行的函数。一个await将控制流释放回事件循环。

  • Futures 代表一个任务的结果,这个任务可能已经执行,也可能没有执行。这个结果可能是个例外。

Python 中的异步引入

为了在 Python 编程中实现异步范式,Python 引入了两个主要组件。

  • asyncio :这是允许 API 运行和管理协程的 Python 包。

  • async/await : Python 引入了两个新的关键字来处理异步代码。它们帮助您定义协程。

基本上,Python 现在有能力以两种不同的方式运行,异步或同步。根据您选择的方式,您在设计代码时应该有不同的想法,因为代码的功能和行为是不同的。这些样式也有彼此不同的库。换句话说,异步和同步编码的风格和语法互不相同。

为了说明这一点,如果你正在进行 HTTP 调用,你不能使用阻塞的requests库;因此,您可能想考虑使用aiohttp来进行 HTTP 调用。类似地,如果你使用 Mongo 驱动,你不能依赖像mongo-python这样的同步驱动。你必须使用类似motor的异步驱动程序来访问 MongoDB。

在同步世界中,在 Python 中没有实现并发性或并行性的简单方法。但是,可以选择使用 Python 的线程模型并行运行代码;然而,在异步世界中(不要把这和并行混淆),事情已经变得更好了。现在一切都在一个事件循环中运行,这让您可以同时运行几个协程。这些协同程序同步运行,直到它们到达await然后暂停,将控制权交给事件循环。另一个协程将有机会执行一个动作,或者会发生一些其他事情。

同样需要注意的是,不能在同一个函数中混合使用异步和同步代码。例如,您不能将await与同步功能一起使用。

在深入异步编程之前,有几件事情您应该知道,尤其是在 Python 世界中。

  • 在同步编程中,当您想要暂停执行或让程序不做任何事情时,通常使用 Python time.sleep(10)函数。然而,在异步世界中,这不会像你期望的那样工作。你应该用await asyncio.sleep(10);这不会将控制返回给事件循环,并且会阻碍整个过程。其他什么也不会发生,这可能是一件好事,因为当代码从一个await调用转移到另一个调用时,这使得竞争情况更难发生。

  • 如果你在异步函数中使用阻塞代码,Python 不会抱怨你使用它;然而,事情会痛苦地慢下来。此外,Python 有调试模式,这将警告您那些由于常见错误而阻塞太久的东西。

  • 当您在同一代码库中编写异步和同步代码时,您可能需要考虑使用重复的代码。在大多数情况下,为异步和同步代码使用同一个库或助手是不可能的。

  • 编写异步代码时,您应该假设与同步代码的完全控制相比,执行时的控制流可能会丢失。尤其是当您的代码中运行多个协同程序时,会发生多种情况。

  • 可以想象,在异步世界中,调试变得越来越困难。目前还没有好的调试工具或技术。

  • 在 Python 中测试异步代码不是很方便。缺少测试异步代码的好库。你可能会看到一些库正在努力实现这一点,但是它们还没有像 JavaScript 等其他编程语言那样成熟。

  • 在同步代码中使用 Python 的async关键字,比如在同步函数中使用await,会导致语法错误。

改变异步设计代码的思维模式也很重要。如果你的代码库中既有异步代码又有同步代码,那么你必须以不同的方式来看待它们。async def里面的任何东西都是异步代码,其他的都是同步代码。

在两种情况下,您应该考虑使用异步代码。

  • 从异步代码中调用异步代码,您可以使用所有的 Python 关键字,如awaitasync,以充分利用 Python 异步编码。

  • 在 Python 3.7 中,只需调用asyncio中的run()函数,就可以从同步代码中调用异步代码。

总的来说,写异步代码不像用 Python 写同步代码那么容易。Python 异步模型基于事件、回调、传输、协议和未来等概念。好消息是asyncio库正在发展,每个版本都在改进。Python asyncio将会一直存在!

注意

在编写任何异步代码之前,确保您对以异步方式编写代码有正确的想法,尤其是当您有同步编程背景时。很多时候你会觉得自己搞不懂异步编程。使用少量的异步代码,并以最小的影响将其引入到您的代码库中,是开始使用它的好方法。对异步代码进行良好的测试将确保代码库中的更改不会破坏现有的功能。Python 的异步世界正朝着更好的方向快速发展。因此,请关注 Python 的新版本,了解异步编程中的所有新特性。

它是如何工作的

我已经谈了一些asyncio特性的背景,所以现在让我们看看asyncio在现实世界中是如何工作的。Python 引入了asyncio包来编写异步代码。该包提供了两把钥匙,asyncawait。让我们深入一个简单的异步示例,看看 Python 异步实际上是如何工作的。见清单 7-1 。

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

Listing 7-1Async, Simple Hello Example

清单 7-1 显示了一些简单的asyncio代码;它先打印Welcome,一秒钟后打印Good - bye。让我们看看这是如何工作的。首先asyncio.run()调用异步函数hello,并传入两个参数:WelcomeGood - bye。调用hello函数时,首先打印first_print,然后等待一秒钟打印second_print。这种行为可能看起来像同步代码;然而,深入细节可能会让您感到惊讶,并且有助于您理解异步代码实际上是如何工作的。让我们先了解一下这里使用的一些术语。

协程函数

在 Python 中,任何被定义为async def的函数都可以被称为协程。这里,async def hello(first_print, second_print)可以称为协程函数。

协同程序对象

调用协程函数返回的对象称为协程对象。在后面的例子中,您会更清楚地看到协程函数和现实世界中的协程对象之间的区别。

asyncio.run()

该功能是asyncio模块的一部分。这是任何异步代码的主入口点,应该只调用一次。它做了几件事。

  • 它负责运行传递的协程,在前面的例子中,协程运行的是async def hello协程函数。

  • 它还管理asyncio事件循环。这基本上创建了一个新的事件循环,并在结束时关闭它。

等待

await是将功能控制传递回事件循环并暂停协程执行的关键字。在前面的例子中,当 Python 遇到await关键字时,它暂停hello协程执行一秒钟,并将控制传递回事件循环,一秒钟后恢复。

在进入细节之前,让我们看一个更简单的例子,看看会发生什么。通常会暂停协程函数的执行,直到它在等待什么。当协程的结果返回时,执行重新开始。await有一些规则。

  • 它只能在async def函数内部使用。

  • 如果在普通函数中定义它,它会引发一个异常。

  • 要调用协程函数,必须等待结果返回。

  • 当你使用类似于await func(的东西时,要求func()是一个可调整的对象,这意味着它应该是另一个协程函数或者是定义了返回迭代器的__await__()方法的对象。

现在让我们来看一个更有用的例子,如清单 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())

Listing 7-2asyncio Running Two Tasks

结果如下:

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 所示。与前面的清单相比,您可能会注意到代码中的一些重大变化。

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

Listing 7-3asyncio Running Code Concurrently

结果如下:

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开始运行时,它会遇到类似于async def say_something协程中的task1await关键字。

  • 然后,它让task2暂停两秒钟,并将控制返回给事件循环。

  • 现在事件循环恢复第一个任务(task1),因为asyncio.sleep已经完成(休眠一秒)。

  • 当任务task1完成工作时,第二个任务task2恢复该任务并完成它。

您可能已经注意到的第一件事是asyncio.create_task(),它使函数作为一个asyncio任务并发地运行协程。

任务

每当使用类似于asyncio.create_task()的方法调用任何协程函数时,该协程都会被自动调度为很快运行。

任务帮助你同时运行协程函数,Python 在 Python asyncio世界中把这些运行的协程称为任务。让我们看一个使用asyncio库创建任务的简单例子;见清单 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())

Listing 7-4Simple Task Creation Example

另一种创建任务并等待所有任务完成的方法是使用asyncio.gather函数。asyncio.gather能够将所有协程函数作为任务运行,并在返回事件循环之前等待它们的结果。

让我们看一个简单的例子;参见清单 7-5 。

import asyncio
import time

async def greetings():
    print("Welcome")
    await asyncio.sleep(1)
    print("Good By")

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

Listing 7-5Using asyncio.gather to Run Tasks Concurrently

当您运行这段代码时,您会看到类似这样的内容:

Welcome
Welcome
Good By
Good By
Total time elapsed: 1.006283138

让我们尝试使用asyncio.gather来理解前面的代码是如何运行的。当您运行这段代码时,您会注意到Welcome在控制台上出现了两次,然后Good By运行了两次。在打印两个Welcome和两个Good By消息之间有轻微的延迟。

当你从say_greet()调用异步main()函数时,那么与greetings()函数对话就是事件循环的工作,执行greetings()可以被称为任务

在前面的代码中,您有两个正在运行的任务可以执行greetings()函数。

有一个话题我没讲过,就是await关键词。这是 Python 中asyncio编程的重要关键词之一。任何可以和await一起使用的对象都可以被称为可应用的对象。理解可适应对象也很重要,因为这将让你更好地了解asyncio库是如何操作的,以及如何在 Python 中的不同任务之间切换。

合适的对象

如前所述,任何与await一起使用的对象都被称为可应用对象。大多数的asyncioAPI 接受一个合适的对象。

异步代码中的可适应对象有以下类型。

协同程序

在前一节中,我已经提到了协程的概念。在这里,您将进一步探索这一点,并了解它是如何成为一种可盈利的类型。

所有的协程函数都是可调度的,所以它们可以被其他协程等待。您也可以将协程定义为子例程,但是它可以在不破坏异步环境中的状态的情况下退出。参见清单 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))

Listing 7-6Coroutine Awaiting from Another Coroutine

结果如下:

Calculating sum of 7 and 8
Sum is 15
Calculating multiply of 7 and 8
Multiply of 56

正如您将在示例中注意到的,您多次调用协程,并使用带有await关键字的协程。

任务

当使用asyncioasyncio.create_task()方法将协程包装在一个任务中时,协程被调度运行。大多数情况下,如果你使用异步代码,你就要处理create_task方法来并发运行你的协程。参见清单 7-7 。

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

Listing 7-7create_task Helping to Schedule a Coroutine to Run

结果如下:

Calculating sum of 7 and 8
Calculating sum of 7 and 8
Sum is 15
Sum is 15

正如您在这个例子中看到的,通过利用asyncio方法asyncio.create_task创建任务,您正在同时运行两个不同的协程。

一旦创建了一个任务,就可以使用await关键字同时运行新创建的任务。一旦这两项任务都完成了,就将结果发送到一个事件循环。

期货

Futures 是一个可应用的对象,表示异步操作的未来结果。协程需要等待,直到Future对象返回响应或完成操作。大多数情况下,您不会在代码中显式使用Future对象。然而,Future对象已经被asyncio暗中照顾了。

当一个未来的实例被创建时,这意味着它还没有完成,但将在未来的某个时候完成。

Future有类似于done()cancel()的方法。不过,您通常不需要编写这样的代码,但是理解Future对象是必不可少的。

Future对象实现__await__()方法,Future对象的工作是保存某个状态和结果。

Future具有以下状态:

  • PENDING:这表示一个Future正在等待完成。

  • CANCELLED:如上所述,可以使用 cancel 方法取消一个Future对象。

  • FINISHED:有两种方法可以完成一个Future对象:作为Future.set_result()或者作为一个带有Future.set_exception()的异常。

清单 7-8 展示了一个Future对象的例子。

from asyncio import Future

future = Future()
future.done()

Listing 7-8Future Object

结果如下:

False

这可能是一个学习更多关于asyncio.gather的好时机,因为你现在可能更好地理解了在asyncio世界中一个可行的方法是如何工作的。

注意

这里我只介绍了gather方法;然而,我建议您也看看其他的asyncio方法,看看它们的语法是什么样子的。通常,您会了解这些函数需要哪种类型的输入以及原因。

其语法如下所示:

asyncio.gather(*aws, loop=None, return_exceptions=False)

可以是一个协程,也可以是调度给一个任务的一系列协程。当所有任务完成后,asyncio.gather方法将它们聚集起来并返回结果。它按照这些可应用对象的顺序运行任务。

默认情况下,return_exceptions的值是False,这意味着如果任何一个任务返回异常,当前正在运行的其他任务将不会暂停,并将继续运行。

如果return_exception的值为True,则认为是成功的结果,并在结果列表中汇总。

超时设定

除了引发异常之外,在等待任务完成时,您还可以进行某种超时。

asyncio有一个名为asyncio.wait_for(aws, timeout, *)的方法,可以用来设置任务运行的超时。如果超时发生,它会取消任务并引发异常asyncio.TimeoutError。超时值可以是Nonefloatint;如果超时是None,它将阻塞,直到Future对象完成。

清单 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())

Listing 7-9Async Timeout

>> Timeout occurred

在清单 7-9 中,方法long_time_taking_method需要大约 4000 秒;但是,您已经为Future对象设置了两秒钟的超时,所以如果结果不可用,它会在两秒钟后转到asyncio.TimeoutError

注意

本节讨论的方法是asyncio代码中最常见的方法;然而,还有一些其他的库和方法出现在asyncio库中,它们不太常见或者用于更高级的场景。如果你有兴趣了解更多关于asyncio的知识,可以看看 Python 官方文档。

异步发电机

异步发生器使得在async函数中使用yield成为可能。因此,任何包含yieldasync函数都可以被称为异步生成器。拥有异步发电机的想法是复制同步yield的功能。唯一的区别是你可以把那个函数叫做async

与同步发电机相比,异步发电机无疑提高了发电机的性能。根据 Python 文档,异步生成器比同步生成器快 2.3 倍。参见清单 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())

Listing 7-10
Async Generators

这将在一秒钟的差异内打印项目 1 至 9。这个例子展示了如何在异步协程中的代码中使用异步生成器。

异步理解

Python 异步功能提供了实现异步理解的工具,类似于同步代码理解listdicttupleset的方式。换句话说,异步理解类似于在异步代码中使用理解。

让我们看看清单 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)))

Listing 7-11Async Comprehension

这将打印一个从 2 到 16 的数字列表;但是,您必须等待五秒钟才能看到结果,因为它将完成所有任务,然后返回结果。

异步迭代器

你已经看到了一些迭代器的例子,比如asyncio.gather,它是迭代器的一种形式。

在清单 7-12 中,您可以使用asyncio.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]))

Listing 7-12async Iterator Using as_completed

结果如下:

completed with data: <coroutine object as_completed.._wait_for_one at 0x10373dcc8>
=> [(3, 'Odd'), (5, 'Odd'), (10, 'Even'), (23, 'Odd'), (90, 'Even')]
completed with data: <coroutine object as_completed.._wait_for_one at 0x10373dd48>
=> [(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 所示。

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

Listing 7-13Using asyncio.gather for Iterating on a Task

结果如下:

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,还有几个第三方库可以实现同样的目标。大多数第三方库试图克服你在asyncio中看到的一些问题。

然而,考虑到 Python asyncio库中的持续改进,我建议在你的项目中使用asyncio,除非你需要asyncio完全没有的东西。

让我们看看一些可用于异步代码的第三方库。

酷吏欧

Curio 是一个第三方库,允许您使用 Python 协程执行并发 I/O。它基于一个任务模型,提供线程和进程之间交互的高级处理。清单 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)

Listing 7-14Curio Example

这将以异步方式生成 1 到 10 个数字。Curio 通过调用run()启动内核,并使用async def等方法定义任务。

任务应该在 Curio 内核中运行,它有责任运行到没有任务可运行为止。

使用 Curio 时要记住的是,它运行一个异步函数作为任务,每个任务都需要在 Curio 内核中运行。

让我们再看一个 Curio 库的例子,它实际上运行多个任务。参见清单 7-15 。

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)

Listing 7-15Curio Multiple Tasks

您可能已经猜到了,这显示了创建和加入任务的过程。这里有两个主要概念需要把握。

spawn方法将协程作为参数,并启动新的hello_task任务。

join方法在返回内核之前等待任务完成。

我希望这有助于您了解 Curio 如何在 Python 中实现并发性。你可以查看古董官方文档了解更多细节。

三人组

Trio 是一个像 Curio 一样的现代开源库。它有望让用 Python 编写异步代码变得更容易。Trio 中值得注意的一些功能如下:

  • 它具有良好的可扩展性机制。

  • 它可以同时运行 10,000 个任务。

  • Trio 是用 Python 编写的,对于那些想深入了解事物如何工作的开发人员来说,这可能很有用。

  • 快速入门更容易,因为 Trio 文档非常棒。如果你想寻找一个特定的功能,它都有很好的记录。

让我们快速地看一下 Trio 的一个简单例子,感受一下 Trio 异步代码。参见清单 7-16 。

import trio

async def greeting():
    await trio.sleep(1)
    return "Welcome to Trio!"

trio.run(greeting)

>> Welcome to Trio!

Listing 7-16Trio, Simple Async Code

正如您所看到的,理解代码是怎么回事真的很容易。Trio 使用run()方法运行async函数,该方法启动greeting异步函数执行,然后暂停执行一秒钟,最后返回结果。

让我们看一个更有用的例子,你可以用 Trio 运行多个任务。

让我们将清单 7-13 asyncio版本的is_oddis_prime异步函数转换成 Trio,这样你就能更好地理解 Trio 的用法。见清单 7-17 。

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

Listing 7-17Trio Running Multiple Tasks

正如您可能已经注意到的,您在is_primeis_odd异步函数中没有做太多改变,因为它们在这里的工作方式与asyncio相似。

这里的主要区别是 in main()函数。您没有调用asyncio.as_completed,而是使用了trio.open_nursery方法,该方法获得了nursery对象。nursery使用函数nursery.start_soon开始运行异步协同程序。

一旦nursery.start_soon包装了异步函数is_primeis_odd,这两个任务就开始在后台运行。

async with语句的最后一个块强制main()函数停止并等待所有协程完成;然后从nursery退出。

一旦运行了清单 7-17 中的示例,您可能会注意到它的运行方式类似于asyncio示例,其中is_primeis_odd函数同时运行。

注意

在撰写本书时,Curio 和 Trio 是两个著名的异步代码库。对asyncio有很好的理解,有助于你快速跳转到任何第三方库。我建议在选择任何第三方库之前对asyncio有一个很好的了解,因为大多数库的底层都使用了一些 Python 异步特性。

用 Python 输入

Python 是一种动态语言,所以在用 Python 编写代码时,通常不需要担心定义类型。如果您使用的是 Java 或。NET 中,即使在编译代码之前,您也必须了解类型;否则,这些语言将抛出错误。

数据类型有助于调试和读取大型代码库。然而,像 Python 和 Ruby 这样的语言给了你灵活性和自由,让你不用担心数据类型,而是专注于业务逻辑。

类型化是动态语言世界中的一个话题,有些开发人员喜欢类型,有些不喜欢使用类型。

Python 有以typing模块形式提供的类型,所以我建议在您的项目中尝试一下,看看它们对您是否有意义。

我发现它们在编写代码时非常有用,尤其是在调试和记录代码时。

Python 中的类型

从 Python 3 开始,可以在代码中使用类型。然而,类型在 Python 中是可选的。当您运行代码时,它不会检查类型。

即使你定义了错误的类型,Python 也不会抱怨。如果你想确保你写的是正确的类型,你可以考虑使用像mypy这样的工具,如果你没有正确的类型,它会报错。

现在 Python 允许您通过简单地添加: <data_types>来在代码中添加类型。参见清单 7-18 。

def is_key_present(data: dict, key: str) -> bool:
    if key in data:
        return True
    else:
        return False

Listing 7-18Adding Types in Python

这里你通过传递一个字典和一个键来寻找字典中的一个键。该函数还将参数的类型定义为data: dictkey: str,并将类型返回为-> bool。这是你在 Python 中编写类型所需要做的。

Python 理解这种语法,并假设您已经编写了正确的类型,而无需验证它们。然而,作为一名开发人员,它会让您了解传递给函数的类型。

您可以使用 Python 中本地可用的所有数据类型,而无需使用任何其他模块或库。Python 支持listdictintstrsettuple等类型。,不需要任何其他模块。但是,在某些情况下,您可能需要更高级的类型,这将在下一节中看到。

打字模块

对于高级用途,Python 引入了一个名为typing的模块,它为您提供了更多类型来添加到您的代码库中。开始可能要花一些努力来习惯语法和类型,但是一旦你理解了这个模块,你可能会觉得它使你的代码更整洁,可读性更好。

有很多内容要谈,所以让我们直接开始吧。typing模块提供了基本类型,例如AnyUnionTupleCallableTypeVarGeneric等等。让我们简单地谈谈其中的一些类型,以便对它们有所了解。

联盟

如果您事先不知道什么类型将被传递给一个函数,但是该函数期望从有限的类型集中获得一个类型,那么您可以使用Union。这里有一个例子:

from typing import Union

def find_user(user_id: Union[str, int]) -> None:
    isinstance(user_id, int):
        user_id = str(user_id)
    find_user_by_id(user_id)
    ...

这里,user_id可以是strint,所以你可以使用Union来确保你的函数期望user_id作为strint

任何的

这是一种特殊的类型;其他类型都和Any一致。它有所有的值和所有的方法。如果不知道这个函数在运行时接受哪种类型,可以考虑使用这种类型。

from typing import Any

def stream_data(sanitize: bool, data: Any) -> None:
    if sanitize:
        ...
    send_to_pipeline_for_processing(data)    

元组

正如您可能从它的名字中猜到的那样,这是一种元组类型。唯一的区别是您可以定义元组包含的类型。

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:
            ...

类型变量和泛型

如果你想定义你自己的类型或者重命名特定的类型,你可以利用typing中的TypeVar来实现。这有助于提高代码的可读性,并为自定义类定义类型。

这是一个更高级的typing概念。大多数时候,你可能不需要它,因为你会发现typing模块给了你足够多的类型。

from typing import TypeVar, Generics

Employee = TypeVar("Employee")
Salary = TypeVar

def get_employee_payment(emp: Generics[Employee]) -> :

    ...

可选择的

当您怀疑类型None也将作为值而不是定义的类型传递时,可以使用Optional。所以,不要写成Union[str, None],你可以简单地写成Optional[str]

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

数据类型会降低代码速度吗?

一般来说,使用typing模块或类型不会影响代码的性能。然而,typing模块提供了一个名为typing.get_type_hints的方法来返回对象的类型提示,第三方工具可以使用它来检查对象的类型。Python 在运行时不进行类型检查,所以这根本不会影响你的代码。

根据 Python PEP 484 1 :

虽然提议的类型模块将包含一些用于运行时类型检查的构建块,特别是 get_type_hints()函数,但必须开发第三方包来实现特定的运行时类型检查功能,例如使用 decorators 或元类。使用类型提示进行性能优化是留给读者的练习。

打字如何帮助编写更好的代码

类型化可以帮助您进行静态代码分析,以便在将代码投入生产之前捕捉类型错误,并防止出现一些明显的错误。

有像mypy这样的工具,你可以将它们作为软件生命周期的一部分添加到你的工具箱中。mypy可以通过部分或全部运行您的代码库来检查类型是否正确。mypy还可以帮助你检测错误,比如当函数返回值时检查None类型。

键入有助于使您的代码更整洁。与使用注释记录代码不同,在 docstring 中指定类型,您可以使用类型而不会有任何性能损失。

如果您使用的是 PyCharm 或 VSCode 之类的 IDE,typing模块也可以帮助您完成代码。众所周知,早期的错误捕捉和干净的代码对于任何大型项目的长期持续都是非常重要的。

打字陷阱

在使用 Python 的typing模块时,您应该注意一些陷阱。

  • 没有很好的记录。类型注释没有被很好地记录。在编写定制类或高级数据结构时,可能很难弄清楚如何编写正确的类型。当您开始使用typing模块时,这可能会很困难。

  • 类型不严格。因为类型提示不严格,你不能保证一个变量是它的注释所声称的类型。在这种情况下,您并没有提高代码的质量。因此,编写正确的类型是每个开发人员的责任。mypy可能是检查类型的一个解决方案。

  • 不支持第三方库。当你在使用第三方库时,你可能会发现自己很焦虑,因为在很多情况下你不知道特定第三方工具的正确类型,比如数据结构或类。在这些情况下,你可能最终会使用任何一个。mypy也不支持所有那些第三方库给你查。

注意

typing模块无疑是朝着正确方向迈出的一大步,但是typing模块可能还需要很多改进。不过,正确使用typing一定会帮你发现一些细微的 bug 和类型错误。将类型与类似mypy的工具一起使用肯定会有助于使您的代码更加整洁。

super()方法

super()方法语法现在更容易使用,可读性更好。您可以使用super()方法进行继承,方法如下:

class PaidStudent(Student):
    def __int__(self):
        super().__init__(self)

类型提示

正如我提到的,Python 有一个名为typing的新模块,它在代码中给你类型提示。

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()现在是一个函数。在以前的版本中,它是一个语句。

  • : print "Sum of two numbers is", 2 + 2

  • : print("Sum of two number is", (2+2))

拳头

Python 引入了一种新的改进的写字符串的方法,叫做 f-string 。这使得代码比以前的版本(如% formatformat方法)更具可读性。

user_id = "skpl"
amount = 50
f"{user_id} has paid amount: ${amount}"
>>> skpl has paid amount: $50

使用 f-string 的另一个原因是它比以前的版本更快。

根据人教版 498 2 :

F 字符串提供了一种在字符串中嵌入表达式的方法,使用了最少的语法。应该注意,f 字符串实际上是一个在运行时计算的表达式,而不是一个常数值。在 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 文档,了解 Python 中更多的新特性。

摘要

本章重点介绍了新的主要特性,如asynciotyping,以及次要特性,如 pathlib 和 order dictionary。然而,Python 版本 3 中还有许多其他令人兴奋的新特性。

查看所有改进的 Python 文档总是一个好的做法。Python 有很棒的文档,非常容易浏览,可以帮助你理解任何库、关键字或模块。我希望这一章给了你足够的动力,让你在现有的代码库或新项目中尝试这些特性。

八、调试和测试 Python 代码

如果您正在编写代码,尤其是生产代码,那么代码具有良好的日志记录特性和测试用例非常重要。两者都确保您可以跟踪错误并修复出现的任何问题。Python 有一套丰富的内置库,用于调试和测试 Python 代码,我将在本章中介绍。

注意

与任何编程语言一样,Python 有很多工具可以在代码中添加日志和测试。在专业环境中,对这些工具有一个很好的理解是很重要的,因为在生产环境中运行软件可以为你赚钱。由于生产代码中的错误或缺陷而造成的损失对公司或产品来说是灾难性的。因此,在将代码投入生产之前,您需要进行日志记录和测试。拥有某种度量和性能跟踪工具也是有帮助的,这样你就可以了解当你的软件在现实世界中被数百万用户使用时会是什么样子。

排除故障

作为开发人员,调试是最重要的技能之一。大多数开发人员没有投入足够的精力去学习调试;他们通常只是在需要的时候尝试不同的事情。调试不应该是事后想起的过程;这是一种在对代码中的实际问题得出任何结论之前排除不同假设的技术。在本节中,您将探索调试 Python 代码的技术和工具。

调试工具

在本节中,我将介绍一下pdbipdbpudb

物理数据库

pdb是调试 Python 代码最有用的命令行工具之一。pdb提供堆栈信息和参数信息,并在pdb调试器中跳转代码命令。要在 Python 代码中设置调试器,您可以编写如下代码:

import pdb
pdb.set_trace()

一旦控制权到达启用了pdb调试器的那一行,您就可以使用pdb命令行选项来调试您的代码。pdb给你以下命令:

  • h:帮助命令

  • w:打印堆栈跟踪

  • d:向下移动当前帧数

  • u:向上移动当前帧数

  • s:执行当前行

  • n:继续执行直到下一行

  • unt [line number]:继续执行,直到一个行号

  • 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> ?

Documented commands (type help <topic>):
========================================
EOF    bt        cont      enable  jump  pdef    psource  run        unt
a      c         continue  exit    l     pdoc    q        s        until
alias  cl        d         h       list  pfile   quit     step        up
args   clear     debug     help    n     pinfo   r        tbreak       w
b      commands  disable   ignore  next  pinfo2  restart  u       whatis
break  condition down     j       p     pp     return   unalias where

Miscellaneous help topics:
==========================
exec  pdb

Undocumented commands:
======================
retval  rv

你可以在 https://pypi.org/project/ipdb/ 找到更多关于ipdb的信息。

ipdb具有与pdb相同的命令行选项,如下所示:

  • h:帮助命令

  • w:打印堆栈跟踪

  • d:向下移动当前帧数

  • u:向上移动当前帧数

  • s:执行当前行

  • n:继续执行直到下一行

  • unt [line number]:继续执行,直到一个行号

  • r:继续执行,直到当前功能返回

pudn

pudb是一个功能丰富的调试工具,比pdbipdb功能更多。它是一个基于控制台的可视化调试器。您可以在编写代码时调试代码,而不是像使用pdbipdb那样跳到命令行。它看起来更像一个 GUI 调试器,但运行在控制台上,这使得它比 GUI 调试器更轻量级。

您可以通过添加以下代码行在代码中添加调试器:

import pudb
pudb.set_trace()

它有很好的文档。你可以在 https://documen.tician.de/pudb/starting.html 找到更多关于pudb的信息和它的所有特性。

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

断点

breakpoint是 Python 3.7 中引入的新关键字。它给你调试代码的能力。breakpoint类似于讨论的其他命令行工具。您可以编写如下代码:

x = 10
breakpoint()
y = 20

breakpoint也可以使用PYTHONBREAKPOINT环境变量进行配置,为调试器提供一个由breakpoint()函数调用的方法。这很有帮助,因为您可以轻松地更改调试器模块,而无需更改任何代码。举个例子,如果想禁用调试,可以使用PYTHONBREAKPOINT=0

在生产代码中使用日志模块而不是打印

如前所述,日志是任何软件产品的重要组成部分,Python 有一个名为logging的库。日志记录还有助于您理解代码的流程。如果您有日志记录,它会通过提供堆栈跟踪让您知道问题出在哪里。您可以简单地通过如下方式导入库来使用logging库:

import logging
logging.getLogger(__name__).addHandler(logging.NullHandler())

logging库有五个标准级别,表示事件的严重程度。见表 8-1 。

表 8-1

记录标准级别

|

水平

|

数值

|
| --- | --- |
| CRITICAL | Fifty |
| ERROR | Forty |
| WARNING | Thirty |
| INFO | Twenty |
| DEBUG | Ten |
| NOTSET | Zero |

因此,您可以编写类似于清单 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}
    },
    root={
        'handlers': ['h'],
        'level': logging.DEBUG,
    },
)

dictConfig(logging_config)

logger = logging.getLogger()
logger.debug("This is debug logging")

Listing 8-1Logging Configuration

假设您想要捕获日志的整个堆栈跟踪;你可以做一些类似于清单 8-2 的事情。

import logging

a = 90
b = 0

try:
  c = a / b
except Exception as e:
  logging.error("Exception ", exc_info=True)

Listing 8-2Stack Trace Logging

日志记录中的类和函数

logging模块有许多类和函数,可用于定义您自己的日志记录类,并为您的特定需求和项目配置日志记录。

日志模块中定义的最常用的类如下:

  • Logger:这是logging模块的一部分,由应用直接调用以获取logger对象。它有许多方法,如下所示:

    • setLevel:设置日志记录的级别。当记录器被创建时,它被设置为NOSET

    • isEnableFor:该方法检查由logging.disable(level)设置的日志级别。

    • debug:在这个记录器上记录级别为DEBUG的消息。

    • info:在这个记录器上记录带有INFO的消息。

    • warning:在这个记录器上记录带有WARNING的消息。

    • error:在这个记录器上记录级别为ERROR的消息。

    • critical:在这个记录器上记录一个级别为CRITICAL的消息。

    • log:在该记录器上记录整数级别的消息。

    • exception:在这个记录器上记录一个级别为ERROR的消息。

    • addHandler:将指定的处理程序添加到该记录器中。

  • Handler : Handler是其他有用的处理程序类的基类,比如StreamHandlerFileHandlerSMTPHandlerHTTPHandler等等。这些子类将日志输出发送到相应的目的地,如sys.stdout或磁盘文件。

    • createLock:这将初始化线程锁,该线程锁可用于串行化对底层 I/O 功能的访问。

    • setLevel:将处理器设置为一个级别。

    • flush:这确保记录输出已被刷新。

    • close:Handler的子类确保它从被覆盖的close()方法中被调用。

    • format:对输出日志进行格式化。

    • emit:实际上是记录指定的日志信息。

  • Formatter:这是通过指定一个列出输出应该包含的属性的字符串格式来指定输出格式的地方。

    • format:格式化字符串。

    • formatTime:格式化时间。它与time.strftime()一起用于格式化记录的创建时间。默认值为'%Y-%m-%d %H:%M:%S, uuu',其中uuu以毫秒为单位。

    • formatException:格式化具体的异常信息。

    • formatStack:格式化字符串上的堆栈信息。

您还可以为正在运行的应用配置日志记录,如清单 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

Listing 8-3Logging Configuration File

现在您可以使用这个配置文件,如清单 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')

Listing 8-4Use Logging Configuration

这与清单 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]

Listing 8-5Logging Configuration in YAML

您可以阅读这个文件,如清单 8-6 所示。

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

Listing 8-6Use Logging Configuration YAML File

你可以在 https://docs.python.org/3/library/logging.html 找到更多关于伐木的信息。

使用度量库来识别瓶颈

我见过很多开发人员不理解度量在产品代码中的价值。度量从代码中收集不同的数据点,例如代码特定部分的错误数量或第三方 API 的响应时间。还可以定义指标来捕获特定的数据点,比如当前登录到 web 应用的用户数量。通常按请求、每秒、每分钟或定期收集指标,以随时间监控系统。

有很多第三方应用用于收集生产代码的度量,比如 New Relic、Datadog 等等。您可以收集不同种类的指标。您可以将它们归类为性能指标或资源指标。性能指标可能如下:

  • 吞吐量:这是单位时间内系统所做的工作量。

  • 误差:这是单位时间内误差结果的数量或误差率。

  • 性能:表示完成一个单位工作所需的时间。

除了这些点之外,还有几个数据点可以用来捕捉应用的性能。除了性能指标之外,还有资源指标等指标,您可以使用这些指标来获取资源指标,如下所示:

  • 利用率:这是资源忙碌的时间百分比。

  • 可用性:这是资源响应请求的时间。

在使用指标之前,请考虑要使用哪种数据点来跟踪您的应用。使用度量标准肯定会让你对你的应用更有信心,你可以测量你的应用性能。

IPython 是如何有用的

IPython 是 Python 的 REPL 工具。IPython 帮助您在命令行运行代码,并且无需太多配置就可以测试它。IPython 是一个真正聪明成熟的 REPL;它有很多功能,比如制表符补全和神奇的功能,比如%timeit%run等等。您还可以获得历史记录,并在 IPython 中调试您的代码。有一些调试工具明确地工作在 IPython 上,比如ipdb

IPython 的主要特性如下:

  • 全面的对象内省

  • 输入历史,它在会话中是持久的

  • 使用自动生成的引用在会话期间缓存输出结果

  • 可扩展的制表符补全,默认支持 Python 变量和关键字、文件名和函数关键字的补全

  • “神奇”命令的可扩展系统,用于控制环境和执行许多与 IPython 或操作系统相关的任务

  • 一个丰富的配置系统,可以在不同的设置之间轻松切换(比每次改变$PYTHONSTARTUP环境变量更简单)

  • 会话记录和重新加载

  • 用于特殊用途情况的可扩展语法处理

  • 通过用户可扩展的别名系统访问系统外壳

  • 容易嵌入到其他 Python 程序和 GUI 中

  • pdb调试器和 Python 分析器的集成访问

命令行界面继承了前面列出的功能,并增加了以下内容:

  • 真正的多行编辑得益于prompt_toolkit

  • 键入时语法高亮显示

  • 与命令行编辑器集成,实现更好的工作流程

当与兼容的前端一起使用时,内核允许以下功能:

  • 可以创建 HTML、图像、LaTEX、声音和视频的丰富显示的对象

  • 使用ipywidgets包的交互式小部件

您可以按如下方式安装 IPython:

pip install ipython

IPython 的入门真的很容易;您只需键入命令ipython,您将进入ipython命令 shell,如下所示:

`Python 3.7.0

键入“版权”、“信用”或“许可证”了解更多信息

IPython 6 . 4 . 0——一种增强的交互式 Python。键入“?”寻求帮助。

[1]中的:

现在你可以像这样开始使用ipython命令:

In [1]: print("hello ipython")

你可以在 https://ipython.readthedocs.io/en/stable/interactive/index.html 找到更多关于 IPython 的信息。

测试

对于任何软件应用,拥有测试代码和拥有应用代码一样重要。测试确保您没有部署错误的代码。Python 有很多有用的库,使得编写不同类型的测试变得更加容易。

为什么测试很重要

测试和你的实际代码一样重要。测试确保运输代码按预期工作。您应该在开始编写应用代码的第一行时就开始编写测试代码。测试不应该是事后的想法,也不应该只是为了测试而测试。测试应该确保每段代码都产生预期的行为。

有几个原因让您应该考虑在软件开发生命周期中尽早编写测试。

  • 为了确保你正在构建正确的东西,一旦你开始写代码,在你的软件生命周期中进行测试是很重要的。如果没有测试来检查预期的行为,就很难确保您处于正确的道路上。

  • 您希望尽早发现任何重大变化。当您对代码的一部分进行更改时,很有可能会破坏代码的其他部分。您希望在早期而不是在投入生产后检测到代码中断。

  • 测试在记录你的代码中也扮演了一个角色。测试是记录代码的一种非常有用的方法,不需要为代码的每一部分专门编写文档。

  • 进行测试的另一个好处是让新开发人员加入进来。当一个新的开发人员加入团队时,他们可以通过运行和阅读测试来开始熟悉代码,这可以让您了解代码的流程。

如果您想确保您的代码如您所期望的那样工作,并且您的用户在使用软件时有一段美好的时光,那么您应该在您的产品代码中使用测试。

pytestvs . unittest

Python 有很多令人惊奇的测试库。Pytest 和 UnitTest 是两个最著名的库。在这一节中,您将看到这两个库之间的主要区别,这样您就可以决定使用哪个库来测试您的代码。

两者都是大众图书馆;然而,它们之间的多重差异使人们选择一个而不是另一个。在决定选择哪一个之前,让我们看看你想要考虑的一些主要特性。

Pytest 是第三方库,UnitTest 是 Python 中的内置库。要使用 Pytest,必须安装它,但这没什么大不了的。

pip install pytest

UnitTest 需要继承TestCase,需要有一个类来编写和运行测试。Pytest 在这方面更加灵活,因为您可以通过函数或类来编写测试。清单 8-7 显示了 UnitTest,而清单 8-8 显示了 Pytest。

import pytest

def test_simple():
    assert 2 == 2

def test_tuple():
    assert (1, 3, 4) == (1, 3, 4)

Listing 8-8Pytest Example 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')

Listing 8-7UnitTest Example 1

您可能已经注意到,UnitTest 使用了TestCase实例方法;但是,Pytest 有一个内置的断言。Pytest 断言在不了解不同断言方法的情况下更容易阅读。然而,UnitTest 断言更具可配置性,并且有更多的方法可以断言。

https://docs.python.org/3/library/unittest.html#assert-methods 可以看到 UnitTest 的所有断言方法,在 https://docs.pytest.org/en/latest/reference.html 可以看到 Pytest 的所有断言方法。

清单 8-9 显示 UnitTest,清单 8-10 显示 Pytest。

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]

Listing 8-10Pytest Example 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]

Listing 8-9UnitTest Example 2

您可能会注意到,Pytest 比 UnitTest 更容易断言。与 UnitTest 相比,Pytest 的可读性更强。

Pytest 用代码片段突出错误,而 UnitTest 没有那个特性;它显示了一行错误,没有突出显示。这在未来的版本中可能会改变,但是目前 Pytest 有更好的错误报告。清单 8-11 显示了 Pytest 控制台输出,而清单 8-12 显示了 UnitTest 控制台输出。

Traceback (most recent call last):
  File "~<stdin>~", line 11, in simple.py
ZeroDivisionError: integer division or modulo by zero

Listing 8-12UnitTest Console Output

>>> 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 ====

Listing 8-11Pytest Console Output

Pytest 有类似于fixture的设置方法,您可以为模块、会话和函数进行配置。UnitTest 有方法setUptearDown。清单 8-13 显示了 Pytest 夹具,而清单 8-14 显示了 UnitTest 夹具。

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

Listing 8-14UnitTest Tests Using Setup and Teardown

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

Listing 8-13Pytest Fixture

您会注意到,Pytest 和 UnitTest 处理测试设置的方式不同。这是 Pytest 和 UnitTest 之间的一些主要区别。然而,两者都是功能丰富的工具。

我通常更喜欢使用 Pytest,因为它易于使用和阅读。但是,如果您习惯使用 UnitTest,请不要觉得必须使用 Pytest。用你觉得舒服的任何东西。选择测试工具是次要的;主要目标应该是对你的代码进行良好的测试!

性能测试

属性测试是测试函数的一种方法,在这种情况下,你需要提供大量的输入。你可以在 https://hypothesis.works/articles/what-is-property-based-testing/ 了解更多信息。

Python 有一个名为hypothesis的库,非常适合编写属性测试。hypothesis很好用,如果熟悉 Pytest,就更容易了。

您可以按如下方式安装hypothesis:

pip install hypothesis

您可以看到一个使用hypothesis进行属性测试的例子,如清单 8-15 所示。

from hypothesis import given
from hypothesis.strategies import text

@given(text())
def test_decode_inverts_encode(s):
    assert decode(encode(s)) == s

Listing 8-15Property Testing

在这里,hypothesis提供了各种文本来测试函数test_decode_inverts_encode,而不是您提供那组数据来解码文本。

如何创建测试报告

有很多工具可以生成测试报告。事实上,Pytest 和 UnitTest 都会这样做。测试报告有助于理解测试结果,也有助于跟踪测试覆盖率的进展。然而,这里我严格地说的是测试报告的生成。

当您运行一个测试时,报告生成可以为您提供运行一个测试的完整概述,以及通过/失败的结果。您可以使用以下工具之一来完成此操作:

pip install pytest-html
pytest -v tests.py --html=pytest_report.html --self-contained-html

一个叫做nose的工具有内置的报告生成工具。如果您正在使用nose,您可以通过运行如下命令来生成测试:

nosetests -with-coverage --cover-html

有了 UnitTest,你可以使用TextTestRunner,如清单 8-16 所示。

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)

Listing 8-16UnitTest with TextTestRunner Part 1

让我们假设您有前面的测试要运行。UnitTest 为您提供了一个名为TextTestRunner的方法来生成测试报告,如清单 8-17 所示。

import test

test_suite = unittest.TestLoader().loadTestFromModule(test)
test_results = unittest.TextTestRunner(verbosity=2).run(test_suite)

Listing 8-17UnitTest with TextTestRunner Part 2

如果您运行这段代码,它将为TestBasic类生成报告。

除了这里讨论的工具之外,还有大量的 Python 第三方库,它们在生成报告的方式方面提供了很大的灵活性,它们是非常强大的工具。

自动化单元测试

自动化单元测试意味着单元测试无需您启动即可运行。能够在与主代码或主要代码合并的同时运行单元测试意味着您可以确保新的更改不会破坏任何现有的特性或功能。

正如我已经讨论过的,对任何代码库进行单元测试都是非常重要的,并且您会希望使用某种 CI/CD 流来运行它们。这还假设您正在使用某种版本控制(如 Git)或第三方工具(如 GitHub 或 GitLab)来存储代码。

运行测试的理想流程如下:

  1. 使用版本控制提交更改。

  2. 将更改推送到某种版本控制中。

  3. 使用像 Travis 这样的第三方工具从版本控制中触发单元测试,Travis 会自动运行测试并将结果发送到版本控制。

  4. 在测试通过之前,版本控制不应该允许合并到主版本。

让您的代码准备好投入生产

在投入生产之前,有一些事情是很重要的,以确保交付的代码是高质量的,并按预期工作。在将变更或新代码部署到产品中之前,每个团队或公司都有不同的步骤。我不会讨论任何一个部署到生产中的理想过程。然而,您可以在当前的部署管道中引入一些东西,以使您的 Python 代码更好,并且在生产中不容易出错。

在 Python 中运行单元和集成测试

正如已经提到的,进行单元测试是很重要的。除了单元测试之外,进行集成测试也有很大的帮助,尤其是如果你在代码库中有很多可移动的部分。

众所周知,单元测试有助于检查特定的代码单元,并确保该代码单元正常工作。对于集成测试,重要的是测试代码的一部分是否与代码的另一部分一起工作而没有任何错误。集成测试帮助您检查代码是否作为一个整体工作。

使用林挺使代码一致

代码分析器分析你的源代码中潜在的错误。Linters 解决了代码中的以下问题:

  • 句法误差

  • 像使用未定义变量这样的结构性问题

  • 违反代码风格指南

代码林挺给你的信息可以很容易地浏览。这对于代码非常有用,尤其是对于一个大项目,当有大量移动代码,并且所有从事代码工作的开发人员都可以就特定的代码风格达成一致时。

有很多 Python 林挺代码。您应该使用哪种类型取决于您或您的开发团队。

使用林挺有很多好处。

  • 通过对照编码标准检查代码,它可以帮助您写出更好的代码。

  • 它可以防止您犯明显的错误,比如语法错误、打字错误、格式错误、样式不正确等等。

  • 它节省了开发人员的时间。

  • 它帮助所有开发人员就特定的代码标准达成一致。

  • 它非常易于使用和配置。

  • 很容易设置。

让我们看看 Python 中一些流行的林挺工具。如果您正在使用现代的 IDE 工具,如 VSCode、Sublime 或 PyCharm,您会发现这些工具已经提供了某种类型的林挺。

薄片 8

flake8是最流行的林挺工具之一。它是pep8pyflakes和循环复杂性的包装器。它的误报率很低。

您可以使用以下命令轻松设置它:

pip install flake8

派林特

对林挺来说是另一个不错的选择。与flake8相比,它需要更多的设置,并给出更多的误报,但是如果你需要对你的代码进行更严格的林挺检查,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。这些工具对于更大的团队来说可能更方便,因为检查可以在新代码提交审查后立即运行。将代码覆盖作为软件生命周期的一部分,可以确保您不会在产品代码上冒任何风险。

将 virtualenv 用于您的项目

virtualenv是应该成为每个开发人员工具链一部分的工具之一。您可以使用它来创建独立的 Python 环境。当您安装virtualenv并为您的项目创建一个环境时,virtualenv会创建一个包含您的项目需要运行的所有可执行文件的文件夹。

您可以按如下方式安装 virtualenv:

pip install virtualenv

我建议在这里寻找更多关于 virtualenv 的信息:

https://docs.python-guide.org/dev/virtualenvs/

摘要

对于任何生产代码,拥有帮助您调试和更好地监控代码的工具是非常重要的。正如您在本章中所了解到的,Python 有很多工具可以让您在将代码部署到生产环境之前更好地准备代码。这些工具不仅帮助您在数百万用户使用您的应用时保持理智,还帮助您维护您的代码以供长期使用。确保您的应用利用了这些工具,因为从长远来看,投资这些工具肯定会有回报。在生产中部署应用时,拥有正确的过程与构建新功能一样重要,因为这将确保您的应用是高质量的。

posted @ 2024-08-10 15:28  绝不原创的飞龙  阅读(13)  评论(0编辑  收藏  举报