Python-整洁编程-二-

81 阅读1小时+

Python 整洁编程(二)

原文:Clean Python

协议:CC BY-NC-SA 4.0

三、编写更好的函数和类

函数和类是 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 更干净,可读性更强。在下一章,你将探索更多的工具,比如生成器和迭代器,来提高你的应用的质量。