现代-Python-秘籍(四)

现代 Python 秘籍(四)

原文:zh.annas-archive.org/md5/185a6e8218e2ea258a432841b73d4359

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:用户输入和输出

在本章中,我们将学习以下示例:

  • 使用print()函数的特性

  • 使用 input()和 getpass()进行用户输入

  • 使用"format".format_map(vars())进行调试

  • 使用 argparse 获取命令行输入

  • 使用 cmd 创建命令行应用程序

  • 使用 OS 环境设置

介绍

软件的核心价值在于产生有用的输出。一种简单的输出类型是一些有用结果的文本显示。Python 通过print()函数支持这一点。

input()函数与print()函数有明显的相似之处。input()函数从控制台读取文本,允许我们向程序提供不同的值。

还有许多其他常见的提供输入的方式。解析命令行对于许多应用程序也是有帮助的。有时我们需要使用配置文件来提供有用的输入。数据文件和网络连接是提供输入的更多方式。每种方式都是独特的,需要单独考虑。在本章中,我们将专注于input()print()的基础知识。

使用print()函数的特性

在许多情况下,print()函数是我们学习的第一个函数。第一个脚本通常是以下变体:

 **print("Hello world.")** 

我们很快就学会了print()函数可以显示多个值,包括有用的空格。

当我们写下这个:

 **>>> count = 9973 
>>> print("Final count", count) 
Final count 9973** 

我们看到在两个值之间包括了一个空格。此外,在函数提供的值后打印了一个换行符,通常用\n字符表示。

我们能控制这种格式吗?我们能改变提供的额外字符吗?

原来我们可以用print()做更多的事情。

准备工作

我们有一个用于记录大帆船燃油消耗的电子表格。它的行看起来像这样:

日期 10/25/13 10/26/13 10/28/13
发动机开启 08:24:00 09:12:00 13:21:00
燃油高度 29 27 22
发动机关闭 13:15:00 18:25:00 06:25:00
燃油高度关闭 27 22 14

有关这些数据的更多信息,请参阅第四章中的从集合中删除项目 - remove()、pop()和 difference对列表进行切片和切块的示例,内置数据结构 - 列表、集合、字典。油箱内没有液位计。燃油的深度必须通过油箱侧面的视镜读取,这就是为什么燃油的容积被陈述为深度。油箱的完整深度约为 31 英寸,容积约为 72 加仑;可以将深度转换为容积。

以下是使用 CSV 数据的示例。此函数读取文件并返回从每行构建的字段列表:

 **>>> from pathlib import Path 
>>> import csv 
>>> from collections import OrderedDict 
>>> def get_fuel_use(source_path): 
...     with source_path.open() as source_file: 
...         rdr= csv.DictReader(source_file) 
...         od = (OrderedDict( 
...             [(column, row[column]) for column in rdr.fieldnames]) 
...             for row in rdr) 
...         data = list(od) 
...     return data 
>>> source_path = Path("code/fuel2.csv") 
>>> fuel_use= get_fuel_use(source_path) 
>>> fuel_use  
[OrderedDict([('date', '10/25/13'), ('engine on', '08:24:00'), 
    ('fuel height on', '29'), ('engine off', '13:15:00'), 
    ('fuel height off', '27')]), 
OrderedDict([('date', '10/26/13'), ('engine on', '09:12:00'), 
    ('fuel height on', '27'), ('engine off', '18:25:00'), 
    ('fuel height off', '22')]), 
OrderedDict([('date', '10/28/13'), ('engine on', '13:21:00'), 
    ('fuel height on', '22'), ('engine off', '06:25:00'), 
    ('fuel height off', '14')])]** 

我们使用了pathlib.Path对象来定义原始数据的位置。我们定义了一个名为get_fuel_use()的函数,它将打开并读取给定路径的文件。该函数从源电子表格创建了一行行的数据列表。每行数据都表示为一个OrderedDict对象。

该函数首先创建一个csv.DictReader对象来解析原始数据。读取器通常返回一个内置的dict对象,它不会对键强加特定的顺序。为了强制特定的键顺序,该函数使用生成器表达式为每行创建一个OrderedDict对象。读取器rdrfieldnames属性用于将列强制为特定顺序。生成器表达式使用了一个嵌套的循环对:一个循环处理一行的每个字段,外部循环处理数据的每一行。

结果是一个包含OrderedDict对象的列表对象。这是我们可以用于打印的一致的数据源。每行都有基于第一行列名的五个字段。

如何做...

我们有两种方法来控制print()的格式:

  • 设置字段间分隔符字符 sep,其默认值为一个空格

  • 设置行尾字符 end,其默认值为 \n 字符

我们将展示几个更改 sepend 的示例。每个都是一种一步到位的配方。

默认情况如下。这个例子没有改变 sepend

 **>>> for leg in fuel_use: 
...    start = float(leg['fuel height on']) 
...    finish = float(leg['fuel height off']) 
...    print("On", leg['date'], 
...    'from', leg['engine on'], 
...    'to', leg['engine off'], 
...    'change', start-finish, 'in.') 
On 10/25/13 from 08:24:00 to 13:15:00 change 2.0 in. 
On 10/26/13 from 09:12:00 to 18:25:00 change 5.0 in. 
On 10/28/13 from 13:21:00 to 06:25:00 change 8.0 in.** 

当我们查看输出时,我们可以看到每个项目之间插入了一个空格。每个数据项集合的末尾的 \n 字符意味着每个 print() 函数产生一个单独的行。

在准备数据时,我们可能希望使用类似于逗号分隔值的格式,可能使用不是简单逗号的列分隔符。这是一个使用 | 的示例:

 **>>> print("date", "start", "end", "depth", sep=" | ") 
date | start | end | depth 
>>> for leg in fuel_use: 
...    start = float(leg['fuel height on']) 
...    finish = float(leg['fuel height off']) 
...    print(leg['date'], leg['engine on'], 
...    leg['engine off'], start-finish, sep=" | ") 
10/25/13 | 08:24:00 | 13:15:00 | 2.0 
10/26/13 | 09:12:00 | 18:25:00 | 5.0 
10/28/13 | 13:21:00 | 06:25:00 | 8.0** 

在这种情况下,我们可以看到每一列都有给定的分隔符字符串。由于 end 设置没有更改,每个 print() 函数产生一个不同的输出行。

最常见的情况似乎是我们想要完全抑制分隔符。这给了我们对输出的精细控制。

这是我们如何改变默认标点以强调字段名称和值。在这种情况下,我们已经更改了 end 设置:

 **>>> for leg in fuel_use: 
...    start = float(leg['fuel height on']) 
...    finish = float(leg['fuel height off']) 
...    print('date', leg['date'], sep='=', end=', ') 
...    print('on', leg['engine on'], sep='=', end=', ') 
...    print('off', leg['engine off'], sep='=', end=', ') 
...    print('change', start-finish, sep="=") 
date=10/25/13, on=08:24:00, off=13:15:00, change=2.0 
date=10/26/13, on=09:12:00, off=18:25:00, change=5.0 
date=10/28/13, on=13:21:00, off=06:25:00, change=8.0** 

由于行尾字符串被更改为,,每次使用 print() 函数都不会产生单独的行。直到最后一个 print() 函数,它具有 end 的默认值,我们才得到正确的行尾。

显然,这种技术对于比这些简单示例更复杂的任何事情都可能变得非常复杂。对于简单的事情,我们可以调整分隔符或结尾。对于更复杂的事情,我们需要使用字符串的 format() 方法。

它是如何工作的...

在一般情况下,print() 函数是围绕 stdout.write() 的一个方便的包装器。这种关系可以被改变,我们将在下面看到。

我们可以想象 print() 有一个类似于这样的定义:

    def print(*args, *, sep=None, end=None, file=sys.stdout): 
        if sep is None: sep = ' ' 
        if end is None: end = '\n' 
        arg_iter= iter(args) 
        first = next(arg_iter) 
        sys.stdout.write(repr(first)) 
        for value in arg_iter: 
            sys.stdout.write(sep) 
            sys.stdout.write(repr(value()) 
        sys.stdout.write(end) 

这为我们提供了关于分隔符字符串和行尾字符串如何包含在 print() 函数输出中的提示。如果没有提供值,则默认值为空格和换行符。该函数通过参数值进行迭代,将第一个值视为特殊值,因为它没有分隔符。这种方法确保分隔符字符串 sep 出现在值之间。

行尾字符串 end 出现在所有值之后。它总是被写入。我们可以通过将其设置为空字符串来有效地关闭它。

还有更多...

sys 模块定义了两个始终可用的标准输出文件:sys.stdoutsys.stderr

我们可以使用 file= 关键字参数来写入标准错误文件,除了标准输出文件:

    import sys 
    print("Red Alert!", file=sys.stderr) 

我们已经导入了 sys 模块,以便我们可以访问标准错误文件。我们使用它来写入一个不会成为标准输出流的消息。

通常情况下,我们需要谨慎地在一个程序中打开太多的输出文件。操作系统的限制通常足够打开许多文件。然而,当一个程序创建大量文件时,可能会变得混乱。

通常情况下,使用操作系统文件重定向技术会很好用。程序的主要输出可以写入 sys.stdout;这在操作系统级别很容易重定向。用户可能输入类似这样的命令行:

 **python3 myapp.py <input.dat >output.dat** 

这将提供 input.dat 文件作为 sys.stdin 上的输入。当 Python 程序写入 sys.stdout 时,操作系统将输出重定向到 output.dat 对象。

在某些情况下,我们需要打开额外的文件。在这种情况下,我们可能会看到这样的编程:

    from pathlib import Path 
    target_path = Path("somefile.dat") 
    with target_path.open('w', encoding='utf-8') as target_file: 
        print("Some output", file=target_file) 
        print("Ordinary log") 

在这个例子中,我们打开了一个特定的输出路径,并使用with语句将打开的文件分配给target_file。 然后,我们可以将其用作print()函数中的file=值,以将其写入此文件。 因为文件是上下文管理器,离开with语句意味着文件将被正确关闭,并且所有 OS 资源将从应用程序中释放。 所有文件操作都应该包装在with语句上下文中,以确保资源得到正确释放。

另请参阅

  • 请参阅使用"format".format_map(vars())进行调试配方

  • 有关此示例中的输入数据的更多信息,请参阅第四章中的从集合中删除项目-remove()、pop()和 difference切片和切块列表配方,内置数据结构-列表、集合、字典

  • 有关一般文件操作的更多信息,请参阅第九章,输入/输出、物理格式、逻辑布局

使用 input()和 getpass()进行用户输入

一些 Python 脚本依赖于从用户那里收集输入。 有几种方法可以做到这一点。 一种常用的技术是使用控制台提示用户输入。

有两种相对常见的情况:

  • 普通输入:我们使用input()函数。 这将提供正在输入的字符的有用回显。

  • 无回显输入:这通常用于密码。 输入的字符不会显示,提供了一定程度的隐私。 我们使用getpass()模块中的getpass()函数。

input()getpass()函数只是从控制台读取的两种实现选择。 结果表明,获取字符的字符串只是处理的第一步。 实际上,我们有单独的考虑层次:

  1. 与控制台的初始交互。 这是编写提示和读取输入的基础。 这必须正确处理数据以及键盘事件,例如用于编辑的退格键。 这也可能意味着适当处理文件结束。

  2. 验证输入以查看它是否属于预期值域。 我们可能正在寻找数字,是/否值或一周中的某一天。 在大多数情况下,验证层有两个部分:

  • 我们检查输入是否适合某些一般域,例如数字。

  • 我们检查输入是否适合某些更具体的子域。 例如,这可能包括检查数字是否大于或等于零。

  1. 在更大的上下文中验证输入,以确保它与其他输入一致。 例如,我们可以检查用户的出生日期是否在今天之前。

除了这些技术之外,我们将在使用 argparse 获取命令行输入配方中看到一些其他方法。

准备工作

我们将看一种从人那里读取复杂结构的技术。 在这种情况下,我们将使用年,月和日作为单独的项目来创建完整的日期。

这是一个快速的例子,省略了所有验证问题:

    from datetime import date 

    def get_date(): 
        year = int(input("year: ")) 
        month = int(input("month [1-12]: ")) 
        day = int(input("day [1-31]: ")) 
        result = date(year, month, day) 
        return result 

这说明了使用input()函数有多么容易。 我们经常需要将其包装在额外的处理中,以使其更有用。 日历很复杂,我们不愿意接受 2 月 32 日而不警告用户这不是一个正确的日期。

如何做...

  1. 检查输入是否为密码或同样受到保密的内容。 如果是,则使用getpass.getpass()函数。 这意味着我们需要导入以下函数:
        from getpass import getpass 

否则,如果不需要输入,则使用input()函数。

  1. 确定将使用哪个提示。 这可能只是>>>或更复杂的东西。 在某些情况下,我们可能会提供大量的上下文信息。

在我们的示例中,我们提供了一个字段名称和关于预期数据类型的提示作为提示字符串。提示字符串是input()getpass()函数的参数:

        year = int(input("year: ")) 

  1. 确定如何验证每个单独的项目。最简单的情况是一个单一值和一个涵盖所有内容的规则。在更复杂的情况下——比如这个——每个单独的元素都是一个带有范围约束的数字。在后续步骤中,我们将看看如何验证复合项目。

  2. 我们可能希望重新构造我们的输入,使其看起来像这样:

        month = None 
        while month is None: 
            month_text = input("month [1-12]: ") 
            try: 
                month = int(month_text) 
                if 1 <= month <= 12: 
                    pass 
                else: 
                    raise ValueError("Month of range 1-12") 
            except ValueError as ex: 
                print(ex) 
                month = None 

它将两个验证规则应用于输入:

  • 它使用int()函数检查月份是否是有效的整数

  • 它使用if语句检查整数是否在[1, 12]范围内,如果不在范围内则引发ValueError异常

对于错误的输入引发异常通常是最简单的方法。它允许我们最大的灵活性。我们可能会使用其他异常类,包括定义自定义数据验证异常。

由于我们将为复杂对象的每个字段使用几乎相同的循环,因此我们需要重新构造此输入并将验证序列转换为一个单独的函数。我们将其称为get_integer()。我们将在这里看到详细信息:

  1. 验证复合对象。在这种情况下,这也意味着我们的整体输入需要重新构造,以便在出现错误输入时进行重试:
        input_date = None 
        while input_date is None: 
            year = get_integer("year: ", 1900, 2100) 
            month = get_integer("month [1-12]: ", 1, 12) 
            day = get_integer("day [1-31]: ", 1, 31) 
            try: 
                result = date(year, month, day) 
            except ValueError as ex: 
                print(ex) 
                input_date = None 
        # assert input_date is the valid date entered by the user 

这个整体循环实现了复合日期对象的高级验证。

给定年份和月份,我们实际上可以确定一个稍微更窄的天数范围。复杂之处在于月份不仅有不同数量的天数,从 28 到 31 不等,而且二月的天数还取决于年份的类型。

  1. 与其模仿规则,不如使用datetime模块来计算两个相邻月份的第一天,如下所示:
        day_1_date = date(year, month, 1) 
        if month == 12: 
            next_year, next_month = year+1, 1 
        else: 
            next_year, next_month = year, month+1 
        day_end_date = date(next_year, next_month, 1) 

这将正确计算给定月份的最后一天。该算法通过计算给定年份和月份的第一天,然后计算下个月的第一天。它正确地更改年份,以便year的一月跟随year的十二月。

这些日期之间的天数是给定月份的天数。我们可以使用表达式(day_end_date - day_1_date).daystimedelta对象中提取天数。

工作原理...

我们需要将输入问题分解成几个单独但密切相关的问题。在底层是与用户的初始交互。我们确定了两种常见的处理方式:

  • input():这只是提示和读取

  • getpass.getpass():这会提示并读取密码,而不会回显

我们希望能够使用退格字符编辑当前输入行。在某些环境中,有一个更复杂的编辑器可用。它体现在 Python 的readline模块中。如果存在该模块,它可以在准备输入行时添加大量编辑。该模块的主要特性是操作系统级的输入历史记录——我们可以使用上箭头键来恢复任何先前的输入。

我们已将输入验证分解成几个层次,以反映确认输入是否有效所需的编程类型:

  • 通用域验证应该使用简单的转换函数,如int()float()。这些函数 tend to raise exceptions for invalid data.使用这些转换函数并处理异常要简单得多,而不是尝试编写匹配有效数值的正则表达式。

  • 我们的子域验证必须使用if语句来确定值是否符合施加的任何其他约束,例如范围。为了保持一致性,如果数据无效,这也应该引发异常。

可能会对值施加许多潜在的约束类型。例如,我们可能只想要有效的操作系统进程 ID,称为 PID。这需要在 Nanny Linux 系统上检查/proc/<pid>路径。

对于基于 BSD 的系统,如 Mac OS X,/proc文件系统不存在。相反,需要执行类似以下的操作来确定 PID 是否有效:

    import subprocess 
    status = subprocess.check_output( 
        ['ps',PID]) 

对于 Windows,命令如下:

    status = subprocess.check_output( 
        ['tasklist', '/fi', '"PID eq {PID}"'.format(PID=PID)]) 

这两个函数中的任何一个都需要成为输入验证的一部分,以确保用户输入正确的 PID 值。只有在整数的主要域得到保证时才能应用这一点。

最后,我们的整体输入函数还应该对无效输入引发异常。这可能在复杂性上有很大的变化。在示例中,我们创建了一个简单的日期对象。在其他情况下,我们可能需要进行更多的处理来确定复杂输入是否有效。

还有更多...

我们有几种用户输入的替代方法,涉及略有不同的方法。我们将详细讨论这两个主题:

  • 输入字符串解析:这将涉及对input()的简单使用和巧妙的解析

  • 通过cmd模块进行交互:这涉及更复杂的类,以及更简单的解析

输入字符串解析

简单的日期值需要三个单独的字段。包括与 UTC 的时区偏移的更复杂的日期时间将涉及七个单独的字段。通过读取和解析字符串而不是单独的字段,用户体验可能会得到改善。

对于简单的日期输入,我们可以使用以下方法:

raw_date_str = input("date [yyyy-mm-dd]: ") 
input_date = datetime.strptime(raw_date_str, '%Y-%m-%d').date() 

我们使用strptime()函数来解析给定格式的时间字符串。我们在input()函数中提供的提示中强调了预期的日期格式。

这种输入方式要求用户输入更复杂的字符串。由于它是一个包含日期所有细节的单个字符串,许多人发现它更容易和友好。

请注意,收集单独字段和处理复杂字符串这两种技术都依赖于底层的input()函数。

通过 cmd 模块进行交互

cmd模块包括Cmd类,可用于构建交互式界面。这对用户交互的概念采取了截然不同的方法。它不依赖于显式使用input()

我们将在使用 cmd 创建命令行应用中仔细研究这一点。

另请参阅

在 SunOS 操作系统的参考资料中,现在由 Oracle 拥有,有一系列命令提示不同类型的用户输入:

docs.oracle.com/cd/E19683-01/816-0210/6m6nb7m5d/index.html

具体来说,所有以ck开头的这些命令都是用于收集和验证用户输入的。这可以用来定义输入验证规则的模块:

  • ckdate:提示并验证日期

  • ckgid:提示并验证组 ID

  • ckint:显示提示,验证并返回整数值

  • ckitem:构建菜单,提示并返回菜单项

  • ckkeywd:提示并验证关键字

  • ckpath:显示提示,验证并返回路径名

  • ckrange:提示并验证整数

  • ckstr:显示提示,验证并返回字符串答案

  • cktime:显示提示,验证并返回一天中的时间

  • ckuid:提示并验证用户 ID

  • ckyorn:提示并验证是/否

使用"format".format_map(vars())进行调试

在 Python 中,最重要的调试和设计工具之一是print()函数。有一些格式选项可用;我们在使用 print()函数的特性中看到了这些。

如果我们想要更灵活的输出怎么办?使用"string".format_map()方法可以提供更多的灵活性。这还不是全部。我们可以将其与vars()函数结合使用,创建出令人惊叹的东西!

准备工作

让我们看一个涉及一些中等复杂计算的多步过程。我们将计算一些样本数据的平均值和标准差。给定这些值,我们将定位所有比平均值高一个标准差以上的项目:

 **>>> import statistics 
>>> size = [2353, 2889, 2195, 3094, 
... 725, 1099, 690, 1207, 926, 
... 758, 615, 521, 1320] 
>>> mean_size = statistics.mean(size) 
>>> std_size = statistics.stdev(size) 
>>> sig1 = round(mean_size + std_size, 1) 
>>> [x for x in size if x > sig1] 
[2353, 2889, 3094]** 

这个计算有几个工作变量。mean_sizestd_sizesig1变量都显示了过滤size列表的最终列表推导的元素。如果结果令人困惑甚至不正确,了解计算中间步骤是有帮助的。在这种情况下,因为它们是浮点值,我们经常希望四舍五入结果,使其更有意义。

如何做...

  1. vars()函数从各种来源构建一个字典结构。

  2. 如果没有给出参数,默认情况下,vars()函数将展开所有局部变量。这将创建一个可以与模板字符串的format_map()方法一起使用的映射。

  3. 使用映射允许我们将变量的名称插入格式模板中。它看起来像这样:

 **>>> print( 
      ...     "mean={mean_size:.2f}, std={std_size:.2f}" 
      ...     .format_map(vars()) 
      ... ) 
      mean=1414.77, std=901.10** 

我们可以将任何局部变量放入格式字符串中。使用format_map(vars()),我们不需要更复杂的方式来选择要显示的变量。

它是如何工作的...

vars()函数从各种来源构建一个字典结构:

  • vars()表达式将展开所有局部变量,以创建一个可以与format_map()方法一起使用的映射。

  • vars(object)表达式将展开对象内部__dict__属性中的所有项目。这使我们能够公开类定义和对象的属性。当我们在第六章中查看对象时,我们将看到如何利用这一点。

format_map()方法期望一个参数,即映射。格式字符串使用{name}来引用映射中的键。我们可以使用{name:format}来提供格式规范。我们还可以使用{name!conversion}来使用repr()str()ascii()函数提供转换函数。

有关格式选项的更多背景信息,请参考第一章中的使用"template".format()构建复杂字符串配方,数字、字符串和元组

还有更多...

format_map(vars())技术是显示变量值的一种简单方法。另一种方法是使用format(**vars())。这种替代方法可以给我们一些额外的灵活性。

例如,我们可以使用这种更灵活的格式来包括不仅仅是局部变量的额外计算:

 **>>> print( 
...     "mean={mean_size:.2f}, std={std_size:.2f}," 
...     " limit2={sig2:.2f}" 
...     .format(sig2=mean_size+2*std_size, **vars()) 
... ) 
mean=1414.77, std=901.10, limit2=3216.97** 

我们计算了一个新值sig2,它只出现在格式化的输出中。

另请参阅

  • 参考第一章中的使用"template".format()构建复杂字符串配方,数字、字符串和元组,了解 format()方法可以做的更多事情

  • 有关其他格式选项,请参考使用 print()函数的特性配方

使用 argparse 获取命令行输入

在某些情况下,我们希望从操作系统命令行获取用户输入,而不需要太多交互。我们更希望解析命令行参数值,然后执行处理或报告错误。

例如,在操作系统级别,我们可能想要运行这样的程序:

 **slott$ python3 ch05_r04.py -r KM 36.12,-86.67 33.94,-118.40** 

 **From (36.12, -86.67) to (33.94, -118.4) in KM = 2887.35** 

操作系统提示符是slott$。我们输入了一个命令python3 ch05_r04.py。这个命令有一个可选参数-r KM,和两个位置参数36.12,-86.6733.94,-118.40

该程序解析命令行参数并将结果写回控制台。这允许一种非常简单的用户交互方式。它使程序非常简单。它允许用户编写一个 shell 脚本来调用程序或将程序与其他 Python 程序合并以创建一个更高级的程序。

如果用户输入了不正确的内容,交互可能会像这样:

 **slott$ python3 ch05_r04.py -r KM 36.12,-86.67 33.94,-118asd** 

 **usage: ch05_r04.py [-h] [-r {NM,MI,KM}] p1 p2** 

 **ch05_r04.py: error: argument p2: could not convert string to float: '-118asd'** 

一个无效的参数值-118asd导致了一个错误消息。程序以错误状态码停止。在大多数情况下,用户可以按上箭头键获取上一个命令行,进行更改,然后再次运行程序。交互被委托给操作系统命令行。

程序的名称ch05_r04并不是太具有信息性。也许我们可以做得更好。位置参数是两个(纬度,经度)对。输出显示了给定单位下两者之间的距离。

我们如何从命令行解析参数值?

准备就绪

我们需要做的第一件事是重构我们的代码,创建两个单独的函数:

  • 从命令行获取参数的函数。由于argparse模块的工作方式,这个函数几乎总是会返回一个argparse.Namespace对象。

  • 一个执行真正工作的函数。这个函数应该被设计成在任何情况下都不引用命令行选项。这意味着它可以在各种情境中被重复使用。

这是我们的真正工作函数,display()

    from ch03_r05 import haversine, MI, NM, KM 
    def display(lat1, lon1, lat2, lon2, r): 
        r_float = {'NM': NM, 'KM': KM, 'MI': MI}[r] 
        d = haversine( lat1, lon1, lat2, lon2, r_float ) 
        print( "From {lat1},{lon1} to {lat2},{lon2}" 
              "in {r} = {d:.2f}".format_map(vars())) 

我们从另一个模块导入了核心计算haversine()。我们为这个函数提供了参数值,并使用format()来显示最终的结果消息。

我们基于第三章中的根据部分函数选择参数顺序食谱中的示例中显示的计算,函数定义

准备就绪

基本计算产生了两点之间的中心角c,给定为(lat[1]lon[1])和(lat[2]lon[2])。角度以弧度表示。我们通过将其乘以地球的平均半径来将其转换为距离。如果我们将角度c乘以 3959 英里的半径,我们将得到以英里表示的角度距离。

请注意,我们期望距离转换因子r以字符串形式提供。然后,这个函数将字符串映射到实际的浮点值。

有关format()方法的详细信息,请注意我们正在使用“Debugging with "format".format_map(vars())”食谱的变体。

当它在 Python 中使用时,函数的样子如下:

 **>>> from ch05_r04 import display 
>>> display(36.12, -86.67, 33.94, -118.4, 'NM') 
From 36.12,-86.67 to 33.94,-118.4 in NM = 1558.53** 

这个函数有两个重要的设计特点。第一个特点是它避免了对由参数解析创建的argparse.Namespace对象的特性的引用。我们的目标是拥有一个可以在许多替代上下文中重复使用的函数。我们需要保持用户界面的输入和输出元素分开。

第二个设计特点是,这个函数显示了另一个函数计算出的值。这是一个有用的特性,因为它让我们分解问题。我们已经将用户体验与基本计算分开。

如何做…

  1. 定义整体参数解析函数:
        def get_options(): 

  1. 创建“解析器”对象:
        parser = argparse.ArgumentParser() 

  1. 向“解析器”对象添加各种类型的参数。有时这很困难,因为我们仍在完善用户体验。很难想象人们会如何使用程序以及他们可能会有的所有问题。

对于我们的示例,我们有两个强制的位置参数和一个可选参数:

  • 点 1 的纬度和经度

  • 点 2 的纬度和经度

  • 可选距离

我们可以使用海里作为一个方便的默认值,这样水手们就可以得到他们需要的答案:

        parser.add_argument('-r', action='store', 
                choices=('NM', 'MI', 'KM'), default='NM') 
        parser.add_argument('p1', action='store', type=point_type) 
        parser.add_argument('p2', action='store', type=point_type) 

我们添加了两种类型的参数。第一个是-r参数,以-开头标记为可选。有时,一个长名称会用--表示。在某些情况下,我们将提供这两种选择,如下所示:

        add_argument('--radius', '-r'....)

动作是存储在命令行上跟在-r后面的值。我们列出了三种可能的选择并提供了默认值。解析器将验证输入,如果输入不是这三个值之一,将写入适当的错误。

强制参数不带-前缀。我们使用了store的操作;这是默认操作,实际上不需要声明。作为type参数提供的函数用于将源字符串转换为适当的 Python 对象。这也是验证复杂输入值的理想方式。我们将在本节中查看point_type()验证函数。

  1. 评估步骤 2 中创建的解析器对象的parse_args()方法:
        options = parser.parse_args() 

默认情况下,这使用来自sys.argv的值,这些值是用户输入的命令行参数值。如果需要以某种方式修改用户提供的命令行,我们可以提供一个显式参数。

这是最终的函数:

    def get_options(): 
        parser = argparse.ArgumentParser() 
        parser.add_argument('-r', action='store', 
                choices=('NM', 'MI', 'KM'), default='NM') 
        parser.add_argument('p1', action='store', type=point_type) 
        parser.add_argument('p2', action='store', type=point_type) 
        options = parser.parse_args() 
        return options 

这依赖于point_type()验证函数。这是因为默认输入类型由str()函数定义。这确保参数的值将是字符串对象。我们提供了type参数,以便我们可以注入类型转换。我们可以使用type = inttype = float进行转换为数字。

在我们的示例中,我们使用point_type()将字符串转换为(纬度经度)二元组:

    def point_type(string): 
        try: 
            lat_str, lon_str = string.split(',') 
            lat = float(lat_str) 
            lon = float(lon_str) 
            return lat, lon 
        except Exception as ex: 
            raise argparse.ArgumentTypeError from ex 

此函数解析输入值。首先,它在,字符处分隔两个值。它尝试对每个部分进行浮点转换。如果float()函数都有效,则我们有一个有效的纬度和经度,可以将其作为一对浮点值返回。

如果出现任何问题,将引发异常。从这个异常中,我们将引发一个ArgumentTypeError异常。这是由argparse模块用于向用户报告错误。

这是结合选项解析器和输出显示函数的主要脚本:

    if __name__ == "__main__": 
        options = get_options() 
        lat_1, lon_1 = options.p1 
        lat_2, lon_2 = options.p2 
        r = {'NM': NM, 'KM': KM, "MI": MI}[options.r] 
        display(lat_1, lon_1, lat_2, lon_2, r) 

这个主要脚本做了一些事情,将用户输入连接到显示的输出:

  1. 解析命令行选项。这些都存在于选项对象中。

  2. p1p2纬度经度)二元组扩展为四个单独的变量。

  3. 评估display()函数。

它是如何工作的...

参数解析器分为三个阶段:

  1. 通过创建ArgumentParser的实例作为解析器对象来定义整体上下文。我们可以提供诸如整体程序描述之类的信息。我们还可以在这里提供格式化程序和其他选项。

  2. 使用add_argument()方法添加单个参数。这些可以包括可选参数以及必需参数。每个参数都可以具有多种功能,以提供不同种类的语法。我们将在还有更多...部分中查看一些替代方案。

  3. 解析实际的命令行输入。解析器的parse()方法将自动使用sys.argv。我们可以提供一个显式值,而不是sys.argv的值。提供覆盖值的最常见原因是允许进行更完整的单元测试。

一些简单的程序将具有一些可选参数。更复杂的程序可能有许多可选参数。

通常在位置参数中有一个文件名。当程序读取一个或多个文件时,文件名将在命令行上提供,如下所示:

 **python3 some_program.py *.rst** 

我们使用了 Linux shell 的globbing功能——*.rst字符串扩展为符合命名规则的所有文件的列表。可以使用以下参数定义的文件列表进行处理:

    parser.add_argument('file', nargs='*') 

命令行上所有不以-字符开头的名称都将被收集到解析器构建的对象的file值中。

然后我们可以使用以下内容:

    for filename in options.file: 
        process(filename) 

这将处理命令行中给定的每个文件。

对于 Windows 程序,shell 不进行 glob 操作,应用程序必须处理其中包含通配符模式的文件名。Python 的glob模块可以帮助解决这个问题。此外,pathlib模块可以创建包括 globbing 功能的Path对象。

我们可能需要进行更复杂的参数解析选项。非常复杂的应用程序可能有数十个单独的命令。例如,看看git版本控制程序;该应用程序使用数十个单独的命令,如git clonegit commitgit push。每个命令都有独特的参数解析要求。我们可以使用argparse来创建这些命令及其不同参数集的复杂层次结构。

还有更多...

我们可以处理什么样的参数?常见的使用中有很多参数样式。所有这些变化都是使用解析器的add_argument()方法来定义的:

  • 简单选项-o--option参数通常用于启用或禁用程序的功能。这些通常使用add_argument()参数action='store_true',default=False来实现。有时,如果应用程序使用action='store_false',default=True,实现会更简单。默认值和存储值的选择可能简化编程,但不会改变用户的体验。

  • 带非平凡对象的简单选项:用户将其视为简单的-o--option参数。我们可能希望使用更复杂的对象来实现这一点,而不是简单的布尔常量。我们可以使用action='store_const',const=some_object,default=another_object。由于模块、类和函数也是对象,因此这里可以使用大量的复杂性。

  • 带值的选项:我们展示了-r unit作为接受单位名称的字符串的参数。我们使用action='store'来存储提供的字符串值。我们还可以使用type=function选项来提供验证或将输入转换为有用形式的函数。

  • 增加计数器的选项:一种常见的技术是具有多个详细级别的调试日志。我们可以使用action='count',default=0来计算给定参数出现的次数。用户可以提供-v以获得详细输出,-vv以获得非常详细的输出。参数解析器将-vv视为-v参数的两个实例,这意味着值将从初始值0增加到2

  • 累积列表的选项:我们可能有一个选项,用户可能希望提供多个值。例如,我们可以使用一个距离值列表。我们可以使用action='append',default=[]的参数定义。这将允许用户说-r NM -r KM以便同时显示海里和公里。当然,这将需要对display()函数进行重大更改,以处理集合中的多个单位。

  • 显示帮助文本:如果我们什么都不做,那么-h--help将显示帮助消息并退出。这将为用户提供有用的信息。如果需要,我们可以禁用此功能或更改参数字符串。这是一个广泛使用的惯例,所以最好什么都不做,这样它就成为我们程序的一个特性。

  • 显示版本号:通常会有--Version作为一个参数来显示版本号并退出。我们使用add_argument("--Version",action="version",version="v 3.14")来实现这一点。我们提供一个version动作和一个额外的关键字参数来设置要显示的版本。

这涵盖了大多数命令行参数处理的常见情况。通常,我们在编写自己的应用程序时会尝试利用这些常见的参数样式。如果我们努力使用简单、广泛使用的参数样式,我们的用户可能更容易理解我们的应用程序的工作方式。

还有一些 Linux 命令,其命令行语法甚至更复杂。一些 Linux 程序,如findexpr,具有argparse无法轻松处理的参数。对于这些边缘情况,我们需要直接使用sys.argv的值编写自己的解析器。

另请参阅

  • 我们看了如何在使用 input()和 getpass()进行用户输入配方中获得交互式用户输入

  • 我们将在使用 OS 环境设置配方中看到如何为此添加更多的灵活性

使用 cmd 创建命令行应用程序

有几种创建交互式应用程序的方法。使用 input()和 getpass()进行用户输入配方查看了诸如input()getpass.getpass()之类的函数。使用 argparse 获取命令行输入配方展示了如何使用argparse创建可以从操作系统命令行与用户交互的应用程序。

我们有第三种方法来创建交互式应用程序,使用cmd模块。该模块将提示用户输入,然后调用我们提供的类的特定方法。

这与第七章中的内容相关,更高级的类设计。我们将添加功能到类定义中,以创建一个独特的子类。

交互将如下所示,我们已标记用户输入如下:“help”:

 **Starting with 100** 

 **Roulette> **`help`**** 

 **Documented commands (type help <topic>):** 

 **========================================** 

bet  help 

Undocumented commands: 
 **======================** 

 **done  spin  stake** 

 **Roulette>** 

help bet

 **Bet <name> <amount>** 

 **Name is one of even, odd, red, black, high, or low** 

 **Roulette> **`bet black 1`**** 

 **Roulette> **`bet even 1`**** 

 **Roulette> **`spin`**** 

 **Spin ('21', {'red', 'high', 'odd'})** 

 **Lose even** 

 **Lose black** 

 **... more interaction ...** 

 **Roulette> **`done`**** 

 **Ending with 93** 

应用程序有一个介绍性消息。它显示玩家的起始赌注,也就是他们有多少赌注。应用程序显示一个提示,Roulette>。用户可以输入五个可用命令中的任何一个。

当我们输入help作为命令时,我们会看到可用命令的显示。只有两个有任何文档。其他三个没有更多的详细信息可用。

当我们输入help bet时,我们会看到bet命令的详细文档。描述告诉我们要从可用的六个选择中提供一个赌注名称和一个赌注金额。

我们创建了两个赌注——一个在黑色上,一个在偶数上。然后我们输入spin命令来旋转轮盘。这显示了结果——数字21——是红色的,高的,奇数的。我们的两个赌注都输了。

我们省略了一些没有赢得太多的其他交互。当我们输入done命令时,最终的赌注会显示出来。如果模拟更详细,它可能还会显示一些有关旋转、赢和输的汇总统计数据。

准备工作

cmd.Cmd应用程序的核心特性是读取-求值-打印循环REPL)。当有大量单独的状态更改和大量命令来进行这些状态更改时,这种应用程序运行良好。

我们将使用轮盘中一部分赌注的简单模拟作为示例。想法是允许用户创建一个或多个赌注,然后旋转模拟的轮盘。虽然正规的赌场轮盘有许多可能的赌注,但我们将只关注其中的六个:

  • 红,黑

  • 偶数,奇数

  • 高,低

美式轮盘有 38 个箱子。1 到 36 号是红色和黑色的。还有两个箱子,0 和 00,是绿色的。这两个额外的箱子被定义为既不是偶数也不是奇数,也不是高也不是低。在零上下注的方式很少,但在数字上下注的方式很多。

我们将使用一些辅助函数来表示轮盘轮,这些函数构建了一个箱子集合。每个箱子都有一个显示数字的字符串和一组赢家的赌注名称。

我们可以定义一个通用的箱子,使用一些简单的规则来确定哪些赌注属于获胜集合:

    red_bins = (1, 3, 5, 7, 9, 12, 14, 16, 18, 
        21, 23, 25, 27, 28, 30, 32, 34, 36) 

    def roulette_bin(i): 
        return str(i), { 
            'even' if i%2 == 0 else 'odd', 
            'low'  if 1 <= i < 19 else 'high', 
            'red'  if i in red_bins else 'black' 
        } 

roulette_bin()函数返回一个包含箱子编号的字符串表示和一组三个获胜提议的双元组。

对于000,我们需要一些不同的东西:

    def zero_bin(): 
        return '0', set() 

    def zerozero_bin(): 
        return '00', set() 

zero_bin()函数返回一个字符串箱子编号和一个空集。zerozero_bin()函数返回一个特殊字符串来显示它是00,加上一个空集来显示没有定义的赌注是赢家。

我们可以结合这三个函数的结果来创建一个完整的轮盘轮。整个轮盘将被建模为一个箱子元组的列表:

    def wheel(): 
        b0 = [zero_bin()] 
        b00 = [zerozero_bin()] 
        b1_36 = [ 
            roulette_bin(i) for i in range(1,37) 
        ] 
        return b0+b00+b1_36 

我们建立了一个简单的列表,其中包含完整的一组轮盘号码:一个零,一个双零,以及 1 到 36 的数字。现在我们可以使用random.choice()函数随机选择一个轮盘号码。这将告诉我们哪些赌注赢了,哪些输了。

如何做...

  1. 导入 cmd 模块:
        import cmd 

  1. 定义对cmd.Cmd的扩展:
        class Roulette(cmd.Cmd): 

  1. preloop()方法中定义所需的任何初始化:
            def preloop(self): 
                self.bets = {} 
                self.stake = 100 
                self.wheel = wheel() 

当处理开始时,preloop()方法只被评估一次。我们用它来初始化赌注和玩家的赌注的字典。我们还创建了一个轮盘集合的实例。self 参数是类内方法的要求。现在,它只是一个简单的必需语法。在第六章中,类和对象的基础,我们将更仔细地研究这个问题。

请注意,这是在class语句内缩进的。

初始化也可以在__init__()方法中完成。不过,这有点复杂,因为我们必须使用super()来确保首先完成Cmd类的初始化。

  1. 对于每个命令,创建一个do_command()方法。方法的名称将是命令,前缀为do_。命令后用户输入的文本将作为参数值提供给方法。以下是bet命令和spin命令的两个示例:
            def do_bet(self, arg_string): 
                pass 
            def do_spin(self, arg_string): 
                pass 

  1. 解析和验证每个命令的参数。命令后用户输入的内容将作为方法的第一个位置参数的值提供。

如果参数无效,方法应该打印一条消息并返回。如果参数有效,方法可以继续通过验证步骤。

对于我们的例子,spin命令不需要任何输入。我们可以忽略参数字符串。为了更完整,我们可能希望在字符串非空时显示错误。

然而,bet命令确实有一个赌注,它必须是六个有效的赌注名称之一。我们可能想要检查重复的赌注。我们可能还想要检查缩写的赌注名称。六个赌注中的每一个都有一个独特的首字母。

作为扩展,赌注也可以有一个金额。我们在第一章中的使用正则表达式解析字符串一节中研究了解析字符串的方法。在这个例子中,我们将简单处理赌注的名称:

            def do_spin(self, arg_string): 
                if len(self.bets) == 0: 
                    print("No bets have been placed") 
                    return 
                # Happy path: more goes here. 

            BET_NAMES = set(['even', 'odd', 'high', 'low', 'red', 'black']) 

            def do_bet(self, arg_string): 
                if arg_string not in BET_NAMES: 
                    print("{0} is not a valid bet".format(arg_string)) 
                    return 
                # Happy path: more goes here. 

  1. 为每个命令编写顺利路径处理。对于我们的例子,spin命令将解决赌注。bet命令将累积另一个赌注。这是do_bet()的顺利路径:
        self.bets[arg_string] = 1 

我们已经将用户的赌注添加到self.bets映射中,并标明了金额。在这个例子中,我们将所有的赌注都视为具有相同的最小金额。

  1. 这是do_spin()的顺利路径,解决了所有的赌注:
        self.spin = random.choice(self.wheel) 
        print("Spin", self.spin) 
        label, winners = self.spin 
        for b in self.bets: 
            if b in winners: 
                self.stake += self.bets[b] 
                print("Win", b) 
            else: 
                self.stake -= self.bets[b] 
                print("Lose", b) 
        self.bets= {} 

首先,我们旋转轮盘以获得一个获胜的赌注。然后,我们检查玩家的每个赌注,看看哪些与获胜的赌注匹配。如果玩家的赌注b在获胜的赌注集合中,我们将增加他们的赌注。否则,我们将减少他们的赌注。

在这个例子中,所有的赌注都是 1:1。如果我们想要扩展到其他类型的赌注,我们必须为各种赌注提供适当的赔率。

  1. 编写主脚本。这将创建该类的一个实例并执行cmdloop()方法:
        if __name__ == "__main__": 
            r = Roulette() 
            r.cmdloop() 

我们创建了Cmd子类Roulette的一个实例。当我们执行cmdloop()方法时,该类将写入任何提供的介绍性消息,写入提示符,并读取命令。

它是如何工作的...

Cmd模块包含大量内置功能,用于显示提示符,从用户那里读取输入,然后根据用户的输入找到正确的方法。

例如,当我们输入bet black时,Cmd超类的内置方法将从输入中剥离第一个单词bet,将其前缀为do_,然后评估实现该命令的方法。

如果没有 do_bet() 方法,命令处理器将写入错误消息。这是自动完成的,我们不需要编写任何代码。

由于我们编写了一个 do_bet() 方法,这将被调用。在这种情况下,命令后的文本 black 将作为位置参数值提供。

一些方法,如 do_help() ,已经是应用程序的一部分。这些方法将总结其他 do_* 方法。当我们的方法有文档字符串时,这可以通过内置的帮助功能显示出来。

Cmd 类依赖于 Python 的内省功能。类的实例可以检查方法名称,以定位所有以 do_ 开头的方法。它们在类级别的 __dict__ 属性中可用。内省是一个高级主题,将在第七章中涉及,更高级的类设计

还有更多...

Cmd 类有许多其他地方可以添加交互功能:

  • 我们可以定义 help_*() 方法,这些方法将成为杂项帮助主题的一部分。

  • 当任何 do_* 方法返回一个值时,循环将结束。我们可能想要添加一个 do_quit() 方法,其主体为 return True。这将结束命令处理循环。

  • 我们可能会提供一个名为 emptyline() 的方法来响应空行。一种选择是安静地什么也不做。另一个常见选择是当用户不输入命令时采取默认操作。

  • 当用户的输入与任何 do_* 方法都不匹配时,将评估 default() 方法。这可能用于更高级的输入解析。

  • postloop() 方法可用于在循环结束后进行一些处理。这将是写总结的好地方。这还需要一个返回值的 do_* 方法——任何非 False 值——来结束命令循环。

此外,我们还可以设置许多属性。这些是类级别的变量,将成为方法定义的对等体:

  • prompt 属性是要写的提示字符串。对于我们的示例,我们可以这样做:
        class Roulette(cmd.Cmd): 
            prompt="Roulette> " 

  • intro 属性是介绍性消息。

  • 我们可以通过设置 doc_headerundoc_headermisc_headerruler 属性来定制帮助输出。这些都将改变帮助输出的外观。

目标是能够创建一个处理用户交互的整洁类,这种类的方式既简单又灵活。这个类创建了一个应用程序,它与 Python 的 REPL 有许多共同特征。它还具有许多命令行程序的特点,这些程序提示用户输入。

这些交互应用程序的一个例子是 Linux 中的命令行 FTP 客户端。它有一个提示符 ftp> ,并解析数十个单独的 FTP 命令。输入 help 将显示所有属于 FTP 交互的各种内部命令。

另请参阅

  • 我们将在第六章和第七章中查看类定义,类和对象的基础更高级的类设计

使用操作系统环境设置

有几种方法可以查看用户输入的时间跨度:

  • 交互数据:这是由用户在一种现在时间跨度内提供的。

  • 程序启动时提供的命令行参数:这些值通常跨越程序的一个完整执行。

  • 在操作系统级别设置的环境变量:这些可以在命令行中设置,使它们几乎与启动应用程序的命令一样交互。

  • 它们可以在 .bashrc 文件或 .profile 文件中为用户配置。这使它们比命令行更持久,稍微不那么交互。

  • 在 Windows 中,有高级设置选项,允许某人设置长期配置。这些通常是多次执行程序的输入。

  • 配置文件设置:这些因应用程序而异。其思想是编辑一个文件,并使这些选项或参数长时间可用。这些可能适用于多个用户,甚至适用于所有用户。配置文件通常具有最长的时间跨度。

使用 input()和 getpass()进行用户输入使用 cmd 创建命令行应用程序配方中,我们研究了与用户的交互。在使用 argparse 获取命令行输入配方中,我们研究了如何处理命令行参数。我们将在第十三章中研究配置文件,应用集成

环境变量可通过os模块获得。我们如何可以基于这些操作系统级别的设置来配置应用程序?

准备工作

我们可能希望通过操作系统设置向程序提供各种类型的信息。这里存在一个深刻的限制:操作系统设置只能是字符串值。这意味着许多种设置将需要一些代码来解析值,并从字符串创建适当的 Python 对象。

当我们使用argparse解析命令行参数时,这个模块可以为我们做一些数据转换。当我们使用os处理环境变量时,我们将不得不自己实现转换。

使用 argparse 获取命令行输入配方中,我们将haversine()函数包装在一个简单的应用程序中,解析命令行参数。

在操作系统级别上,我们创建了一个像这样工作的程序:

 **slott$ python3 ch05_r04.py -r KM 36.12,-86.67 33.94,-118.40** 

 **From (36.12, -86.67) to (33.94, -118.4) in KM = 2887.35** 

使用了一段时间后,我们发现我们经常使用海里来计算从我们的船锚定的地方到达的距离。我们真的希望为输入点和-r参数之一设置默认值。

由于船只可以停泊在各种地方,我们需要更改默认值,而无需调整实际代码。

我们将设置一个名为UNITS的操作系统环境变量,其中包含距离单位。我们可以设置另一个变量HOME_PORT,其中包含家庭点。我们希望能够执行以下操作:

 **slott$ UNITS=NM** 

 **slott$ HOME_PORT=36.842952,-76.300171** 

 **slott$ python3 ch05_r06.py 36.12,-86.67** 

 **From 36.12,-86.67 to 36.842952,-76.300171 in NM = 502.23** 

单位和家庭点值通过操作系统环境提供给应用程序。这可以在配置文件中设置,以便我们可以轻松进行更改。也可以手动设置,如示例所示。

如何做...

  1. 导入os模块。操作系统环境可通过此模块获得:
        import os 

  1. 导入应用程序所需的任何其他类或对象:
        from ch03_r05 import haversine, MI, NM, KM 

  1. 定义一个函数,该函数将使用环境值作为可选命令行参数的默认值。要解析的默认参数集来自sys.argv,因此还重要的是要导入sys模块:
        def get_options(argv=sys.argv): 

  1. 从操作系统环境设置中收集默认值。这包括任何所需的验证:
        default_units = os.environ.get('UNITS', 'KM') 
        if default_units not in ('KM', 'NM', 'MI'): 
            sys.exit("Invalid value for UNITS, not KM, NM, or MI") 
        default_home_port = os.environ.get('HOME_PORT') 

sys.exit()函数很好地处理了错误处理。它将打印消息并以非零状态代码退出。

  1. 创建parser属性。为相关参数提供任何默认值。这取决于argparse模块,也必须导入:
                  parser = argparse.ArgumentParser() 
        parser.add_argument('-r', action='store', 
            choices=('NM', 'MI', 'KM'), default=default_units) 
        parser.add_argument('p1', action='store', type=point_type) 
        parser.add_argument('p2', nargs='?', action='store', type=point_type, 
            default=default_home_port) 
        options = parser.parse_args(argv[1:]) 

  1. 进行任何额外的验证以确保参数正确设置。在这个例子中,可能没有为HOME_PORT设置值,也没有为第二个命令行参数提供值。这需要一个if语句和对sys.exit()的调用:
                if options.p2 is None: 
                sys.exit("Neither HOME_PORT nor p2 argument provided.") 

  1. 返回具有一组有效参数的options对象:
        return options 

这将允许-r参数和第二个点完全是可选的。如果这些参数从命令行中省略,参数解析器将使用配置信息提供默认值。

使用使用 argparse 获取命令行输入配方来处理get_options()函数创建的选项的方法。

它是如何工作的...

我们使用操作系统环境变量创建可以被命令行参数覆盖的默认值。如果环境变量已设置,那么该字符串将作为参数定义的默认值。如果环境变量未设置,则使用应用程序级别的默认值。

UNITS变量的情况下,如果未设置操作系统环境变量,则应用程序使用公里作为默认值。

这给我们三个层次的交互:

  • 我们可以在.bashrc文件中定义一个设置。或者,我们可以使用 Windows 的高级设置选项来进行持久性更改。这个值将在每次登录或创建新的命令窗口时使用。

  • 我们可以在命令行上交互地设置操作系统环境。这将持续到我们的会话结束。当我们注销或关闭命令窗口时,这个值将丢失。

  • 我们可以通过命令行参数每次运行程序时提供一个唯一的值。

请注意,从环境变量中检索的值没有内置或自动验证。我们需要验证这些字符串,以确保它们是有意义的。

还要注意,我们在几个地方重复列出了有效单位的列表。这违反了不要重复自己DRY)原则。使用这个列表的全局变量是一个很好的改进。

还有更多...

使用 argparse 获取命令行输入示例展示了处理来自sys.argv的默认命令行参数的略有不同的方法。第一个参数是正在执行的 Python 应用程序的名称,通常与参数解析无关。

sys.argv的值将是以下字符串列表:

    ['ch05_r06.py', '-r', 'NM', '36.12,-86.67'] 

我们必须在处理过程中的某个时候跳过sys.argv[0]中的初始值。我们有两种选择:

  • 在这个示例中,我们尽可能晚地在解析过程中丢弃多余的项目。当提供sys.argv[1:]给解析器时,第一个项目将被跳过。

  • 在前面的示例中,我们在处理过程中更早地丢弃了该值。main()函数使用options = get_options(sys.argv[1:])向解析器提供了更短的列表。

一般来说,这两种方法之间唯一相关的区别取决于单元测试的数量和复杂性。这个示例将需要一个包含初始参数字符串的单元测试,在解析过程中将被丢弃。

另请参阅

  • 我们将看到处理配置文件的多种方法在第十三章中,应用集成

第六章:类和对象的基础知识

在本章中,我们将研究以下配方:

  • 使用类封装数据和处理

  • 设计具有大量处理的类

  • 设计具有少量独特处理的类

  • 使用 slots 优化小对象

  • 使用更复杂的集合

  • 扩展集合-执行统计的列表

  • 使用惰性属性

  • 使用可设置属性来更新急切属性

介绍

计算的目的是处理数据。即使构建像交互式游戏这样的东西,游戏状态和玩家的行动也是数据;处理计算下一个游戏状态和显示更新。

一些游戏可能有相对复杂的内部状态。当我们考虑具有多个玩家和复杂图形的控制台游戏时,会有复杂的实时状态变化。

另一方面,当我们想到像Craps这样的赌场游戏时,游戏状态非常简单。可能没有建立点,或者 4、5、6、8、9 或 10 中的一个数字可能是已建立的点。转换相对简单,通常通过在赌场桌上移动标记和筹码来表示。数据包括当前状态、玩家行动和骰子的投掷。处理是游戏规则。

Blackjack这样的游戏在每张牌被接受时有一个稍微复杂的内部状态变化。在手牌可以分开的游戏中,游戏状态可能会变得非常复杂。数据包括当前游戏状态、玩家的命令和从牌堆中抽出的牌。处理由游戏规则定义,这些规则可能会受到任何庄家规则的修改。

craps的情况下,玩家可以下注。有趣的是,玩家的输入对游戏状态没有影响。游戏对象的内部状态完全由骰子的下一次投掷决定。这导致了一个相对容易可视化的类设计。

在本章中,我们将创建实现多个统计公式的类。一开始数学可能有点令人生畏。几乎所有东西都基于一系列值的总和,通常表示为∑ x。在许多情况下,这可以使用 Python 的sum()函数来实现。

使用类封装数据和处理

计算的基本思想是处理数据。当我们编写处理数据的函数时,这一点得到了体现。我们在第三章中已经看到了这一点,函数定义

通常,我们希望有许多与共同数据结构一起工作的相关函数。这个概念是面向对象编程的核心。类定义将包含许多控制对象内部状态的方法。

类定义背后的统一概念通常被概括为分配给类的责任的摘要。我们如何有效地做到这一点?设计类的好方法是什么?

准备工作

让我们来看一个简单的、有状态的对象——一对骰子。这是一个模拟Craps赌场游戏的应用程序的背景。目标是利用结果的模拟来帮助发明更好的游戏策略。这将使我们在试图击败庄家优势时不会失去真钱。

类定义和类的实例之间有一个重要的区别,称为对象。我们将这个想法称为面向对象编程。我们的重点是编写类定义。我们的整体应用程序将创建类的实例。从实例的协作中产生的行为是设计过程的总体目标。

大部分设计工作都在类定义上。因此,面向对象编程这个名字可能会误导人。

新兴行为的概念是面向对象编程中的一个重要组成部分。我们不指定程序的每个行为。相反,我们将程序分解为对象,并通过对象的类定义对象的状态和行为。编程根据其责任和协作分解为类定义。

对象应该被视为一个东西——一个名词。类的行为应该被视为动词。这给了我们一个提示,关于我们如何可以继续设计有效工作的类。

当与有形的现实世界的事物相关联时,面向对象设计通常更容易理解。模拟一张纸牌的游戏往往比创建实现抽象数据类型的软件更容易。

在这个例子中,我们将模拟掷骰子。对于一些游戏,比如赌场游戏Craps,会使用两个骰子。我们将定义一个模拟一对骰子的类。为了确保例子是有形的,我们将在模拟赌场游戏的情境中模拟一对骰子。

如何做到这一点...

  1. 写下简单的句子,描述类的实例做什么。我们可以称这些为问题陈述。专注于简短的句子,并强调名词和动词是至关重要的:
  • Craps游戏有两个标准骰子。

  • 每个骰子有六个面,点数从一到六。

  • 玩家掷骰子。

  • 骰子的总和改变了craps游戏的状态。然而,这些规则与骰子是分开的。

  • 如果两个骰子匹配,这个数字是通过困难方式掷出的。如果两个骰子不匹配,这个数字是容易掷出的。一些赌注取决于这种困难和容易的区别。

  1. 识别句子中的所有名词。名词可能标识不同类的对象。这些是合作者。例如玩家和游戏。名词也可能标识所讨论对象的属性。例如面和点数。

  2. 识别句子中的所有动词。动词通常是所讨论的类的方法。例如,rolled 和 match。有时,它们是其他类的方法。一个例子是改变状态,这适用于Craps

  3. 识别任何形容词。形容词是澄清名词的词或短语。在许多情况下,一些形容词显然是对象的属性。在其他情况下,形容词将描述对象之间的关系。在我们的例子中,诸如骰子的总和这样的短语就是一个介词短语作为形容词的例子。骰子的总和短语修改了名词骰子。总和是一对骰子的属性。

  4. class语句开始编写类:

        class Dice: 

  1. __init__方法中初始化对象的属性:
        def __init__(self): 
            self.faces = None 

我们将用self.faces属性来模拟骰子的内部状态。self变量是必需的,以确保我们引用的是类的给定实例的属性。对象由实例变量self的值来标识。

我们也可以在这里放一些其他属性。另一种选择是将属性实现为单独的方法。这种设计决策的细节将在本章后面的使用属性进行惰性属性中讨论。

  1. 根据不同的动词定义对象的方法。在我们的例子中,我们必须定义几种方法:
  • 以下是我们如何实现玩家掷骰子的方法:
                def roll(self): 
                    self.faces = (random.randint(1,6), random.randint(1,6)) 

通过设置self.faces属性来更新骰子的内部状态。同样,self变量对于标识要更新的对象是至关重要的。

注意,这个方法改变了对象的内部状态。我们选择不返回一个值。这使得我们的方法有点像 Python 内置的集合类的方法。任何改变对象的方法都不返回一个值。

  • 这种方法有助于实现骰子的总和改变了craps游戏的状态。游戏是一个独立的对象,但这个方法提供了一个符合句子的总和。
                def total(self): 
                    return sum(self.faces) 

这两种方法有助于回答 hardways 和 easyways 的问题。

                def hardway(self): 
                    return self.faces[0] == self.faces[1] 
                def easyway(self): 
                    return self.faces[0] != self.faces[1] 

在赌场游戏中很少有一个具有简单逻辑反义的规则。更常见的是有一个罕见的第三种选择,它有一个非常糟糕的回报规则。在这种情况下,我们可以将easyway定义为返回not self.hardway()

以下是使用该类的示例:

  1. 首先,我们将用一个固定值来初始化随机数生成器,这样我们就可以得到一个固定的结果序列。这是为这个类创建一个单元测试的一种方式:
 **>>> import random 
      >>> random.seed(1)** 

  1. 我们将创建一个Dice对象,d1。然后我们可以用roll()方法设置它的状态。然后我们将查看total()方法来看看掷出了什么。我们可以通过查看faces属性来检查状态:
 **>>> from ch06_r01 import Dice 
      >>> d1 = Dice() 
      >>> d1.roll() 
      >>> d1.total() 
      7 
      >>> d1.faces 
      (2, 5)** 

  1. 我们将创建第二个Dice对象,d2。然后我们可以用roll()方法设置它的状态。我们将查看total()方法的结果,以及hardway()方法。我们可以通过查看faces属性来检查状态:
 **>>> d2 = Dice() 
      >>> d2.roll() 
      >>> d2.total() 
      4 
      >>> d2.hardway() 
      False 
      >>> d2.faces 
      (1, 3)** 

  1. 由于这两个对象是Dice类的独立实例,对d2的更改不会影响d1
 **>>> d1.total() 
      7** 

它是如何工作的...

这里的核心思想是利用语法的普通规则——名词、动词和形容词——作为识别类的基本特征的一种方式。名词代表事物。一个好的描述性句子应该更多地关注有形的、现实世界的事物,而不是想法或抽象概念。

在我们的例子中,骰子是真实的事物。我们尽量避免使用抽象术语,比如随机器或事件生成器。更容易描述真实事物的有形特征,然后找到一个提供一些有形特征的抽象实现。

掷骰子的想法是一个我们可以用方法定义来模拟的物理动作的例子。显然,这个动作改变了对象的状态。在罕见的情况下——36 次中有一次——下一个状态恰好与上一个状态匹配。

形容词经常会引起混淆。以下是形容词操作最常见的方式的描述:

  • 一些形容词,比如 first、last、least、most、next、previous 等,会有一个简单的解释。这些可以作为方法的懒惰实现,或作为属性值的急切实现。

  • 一些形容词是更复杂的短语,比如骰子的总和。这是一个由名词(总和)和介词(of)构成的形容词短语。这也可以被视为一个方法或属性。

  • 一些形容词涉及到在我们的软件中出现的其他名词。我们可能会有一个短语,比如Craps 游戏的状态,其中状态修改另一个对象,Craps游戏。这显然只是与骰子本身有关的间接关系。这可能反映了骰子和游戏之间的关系。

  • 我们可以在问题陈述中添加一句话,比如骰子是游戏的一部分。这可以帮助澄清游戏和骰子之间的关系。例如,介词短语是...的一部分总是可以颠倒过来,从另一个对象的角度来创建陈述:例如游戏包含骰子。这可以帮助澄清对象之间的关系。

在 Python 中,对象的属性默认是动态的。我们不指定一个固定的属性列表。我们可以在类定义的__init__()方法中初始化一些(或全部)属性。由于属性不是静态的,我们在设计上有相当大的灵活性。

还有更多...

捕捉内部状态和导致状态改变的方法是良好类设计的第一步。我们可以使用缩写S.O.L.I.D总结一些有用的设计原则。:

  • 单一责任原则:一个类应该有一个明确定义的责任。

  • 开闭原则:一个类应该对扩展开放-通常通过继承,但对修改关闭。我们应该设计我们的类,以便我们不需要调整代码来添加或更改功能。

  • 里氏替换原则:我们需要设计继承,使得子类可以替换父类。

  • 接口隔离原则:在编写问题陈述时,我们希望确保协作类的依赖尽可能少。在许多情况下,这个原则会导致我们将大问题分解为许多小类定义。

  • 依赖反转原则:一个类直接依赖于其他类并不理想。最好是一个类依赖于一个抽象,而具体的实现类替换抽象类。

目标是创建具有适当行为并遵守设计原则的类。

另请参见

  • 参见使用属性进行延迟属性配方,我们将讨论急切属性和延迟属性之间的选择

  • 在第七章 ,更高级的类设计,我们将更深入地研究类设计技术

  • 参见第十一章 ,测试,了解如何为类编写适当的单元测试的方法

设计具有大量处理的类

大多数情况下,一个对象将包含定义其内部状态的所有数据。然而,这并不总是正确的。有些情况下,一个类并不真正需要保存数据,而是可以保存处理过程。

这种设计的一些典型例子是统计处理算法,这些算法通常在被分析的数据之外。数据可能在listCounter对象中。处理可能是一个单独的类。

当然,在 Python 中,这种处理通常是使用函数实现的。有关更多信息,请参见第三章 ,函数定义。在某些语言中,所有代码必须采用类的形式,这会导致一些额外的复杂性。

我们如何设计一个利用 Python 的各种复杂内置集合的类?

准备工作

在第四章 ,内置数据结构-列表、集合、字典,特别是使用集合方法和运算符配方中,我们研究了一种称为优惠券收集器测试的统计过程。其概念是每次执行某个过程时,我们保存一个描述该过程的某个方面或参数的优惠券。问题是,在收集完整的优惠券之前,我需要执行多少次该过程?

如果我们根据客户的购买习惯将客户分配到不同的人口统计群体中,我们可能会问在我们看到每个群体的人之前我们需要进行多少次在线销售。如果这些群体的规模大致相同,那么预测在收集完整的优惠券之前我们遇到的平均客户数量是微不足道的。如果这些群体的规模不同,计算在收集完整的优惠券之前的预期时间就会更加复杂。

假设我们使用Counter对象收集了数据。有关各种集合的更多信息,请参见第四章 ,内置数据结构-列表、集合、字典,特别是使用集合方法和运算符避免函数参数的可变默认值配方。在这种情况下,客户分为八个大致相等的类别。

数据看起来是这样的:

    Counter({15: 7, 17: 5, 20: 4, 16: 3, ... etc., 45: 1}) 

关键是需要多少次访问才能获得完整的优惠券集。值是需要给定次数的访问次数。在前一行代码中,需要 15 次访问七次。需要 17 次访问五次。这有一个很长的尾巴。有一次,收集完整的八张优惠券需要 45 次单独的访问。

我们想对这个Counter进行一些统计。我们有两种整体策略来做到这一点:

  • 扩展:我们可以扩展Counter类定义以添加统计处理。这取决于我们想要引入的处理类型的复杂性。我们将在扩展集合 - 进行统计的列表食谱中详细介绍这一点,以及第七章中的更高级的类设计

  • 封装:我们可以将Counter对象封装在另一个类中,该类仅提供我们需要的功能。不过,当我们这样做时,通常需要公开一些额外的方法,这些方法是 Python 的重要部分,但对于我们的应用程序并不重要。我们将在第七章中讨论这一点,更高级的类设计

封装的变体是我们使用统计计算对象来封装内置集合中的对象。这通常会导致一个优雅的解决方案。

我们有两种设计处理的方式。这两种设计选择都适用于整体架构选择:

  • 急切:这意味着我们将尽快计算统计数据。这些值可以成为类的属性。虽然这可以提高性能,但也意味着对数据收集的任何更改都将使急切计算的值无效。我们必须检查整体上下文,看看是否会发生这种情况。

  • 懒惰:这意味着我们不会计算任何东西,直到通过方法函数或属性需要。我们将在使用属性进行延迟属性食谱中讨论这一点。

这两种设计的基本数学是相同的。唯一的问题是何时进行计算。

我们使用预期值的总和来计算平均值。预期值是值的频率乘以值。平均值μ就是这样的:

准备就绪

在这里,k是来自Counter的键,Cf[k]是来自Counter的给定键的频率值。

标准差σ取决于平均值μ。这还涉及计算一系列值的总和,每个值都由频率加权。以下是公式:

准备就绪

在这里,k是来自Counter的键,Cf[k]是来自Counter的给定键的频率值。Counter中的项目总数是准备就绪。这是频率的总和。

如何做到这一点...

  1. 用一个描述性的名称定义类:
        class CounterStatistics: 

  1. 编写__init__方法以包括将连接到该对象的对象:
        def __init__(self, raw_counter:Counter): 
            self.raw_counter = raw_counter 

我们定义了一个方法函数,它以Counter对象作为参数值。这个Counter对象被保存为Counter_Statistics实例的一部分。

  1. 初始化可能有用的任何其他本地变量。由于我们将急切地计算值,最急切的可能时间是在创建对象时。我们将写一些尚未定义的函数的引用:
        self.mean = self.compute_mean() 
        self.stddev = self.compute_stddev() 

我们已经急切地从Counter对象计算了平均值和标准差,并将它们保存在两个实例变量中。

  1. 为各种值定义所需的方法。这是平均值的计算:
        def compute_mean(self): 
            total, count = 0, 0 
            for value, frequency in self.raw_counter.items(): 
                total += value*frequency 
                count += frequency 
            return total/count 

  1. 这是我们如何计算标准差的方法:
        def compute_stddev(self): 
            total, count = 0, 0 
            for value, frequency in self.raw_counter.items(): 
                total += frequency*(value-self.mean)**2 
                count += frequency 
            return math.sqrt(total/(count-1)) 

请注意,这个计算要求首先计算平均值,并且self.mean实例变量已经被创建。

此外,这使用了math.sqrt()。确保在 Python 文件中添加所需的import math语句。

这是我们如何创建一些样本数据的方法:

 **>>> from ch04_r06 import * 
>>> from collections import Counter 
>>> def raw_data(n=8, limit=1000, arrival_function=arrival1): 
...    expected_time = float(expected(n)) 
...    data = samples(limit, arrival_function(n)) 
...    wait_times = Counter(coupon_collector(n, data)) 
...    return wait_times** 

我们从ch04_r06模块导入了expected()arrival1()coupon_collector()等函数。我们还从标准库的collections模块导入了Counter集合。

我们定义了一个名为raw_data()的函数,它将生成一定数量的顾客访问。默认情况下,将会有 1,000 次访问。领域将包括八种不同类别的顾客;每个类别将有相同数量的成员。我们将使用coupon_collector()函数来遍历数据,输出收集到完整的八张优惠券所需的访问次数。

然后使用这些数据来组装一个Counter对象。这将包括获取完整一套优惠券所需的顾客数量。每个顾客数量还将有一个频率,显示该访问次数发生的频率。

这是我们如何分析Counter对象的方法:

 **>>> import random 
>>> from ch06_r02 import CounterStatistics 
>>> random.seed(1) 
>>> data = raw_data() 
>>> stats = CounterStatistics(data) 
>>> print("Mean: {0:.2f}".format(stats.mean)) 
Mean: 20.81 
>>> print("Standard Deviation: {0:.3f}".format(stats.stddev)) 
Standard Deviation: 7.025** 

首先,我们导入了random模块,以便我们可以选择一个已知的种子值。这样可以更容易地测试和演示应用程序,因为随机数是一致的。我们还从ch06_r02模块导入了CounterStatistics类。

一旦我们定义了所有的项目,我们就可以将seed强制设定为一个已知的值,并生成收集优惠券的测试结果。raw_data()函数将会生成一个我们称之为数据的Counter对象。

我们将使用Counter对象来创建CounterStatistics类的一个实例。我们将把这个实例分配给stats变量。创建这个实例也将计算一些摘要统计数据。这些值可以作为stats.mean属性和stats.stddev属性获得。

对于一组八张优惠券,理论平均值是21.7次访问以收集所有优惠券。看起来raw_data()的结果显示了与随机访问预期相匹配的行为。这有时被称为零假设——数据是随机的。

它是如何工作的...

这个类封装了两个复杂的算法,但不包括任何改变状态的数据。这种类不需要保留大量数据。相反,设计尽快执行所有计算。

我们为处理编写了一个高级规范,并将其放在__init__()方法中。然后我们编写了实现指定处理步骤的方法。我们可以设置所需的属性数量,使其成为一种非常灵活的方法。

这种设计的优点是属性值可以被重复使用。计算成本只需支付一次;每次使用属性值时,无需进一步计算。

这种设计的缺点是,对底层Counter对象的更改会使CounterStatistics对象过时。通常,当Counter不会改变时,我们使用这种设计。该示例创建了一个单一的静态Counter,用于创建CounterStatistics

还有更多...

如果我们需要有状态的对象,我们可以添加更新方法,可以改变Counter对象。例如,我们可以引入一个方法,通过委托工作给相关的Counter来添加另一个值。这将把设计模式从计算和收集之间的简单连接转变为对集合的适当封装。

该方法可能如下所示:

    def add(self, value): 
        self.raw_counter[value] += 1 
        self.mean = self.compute_mean() 
        self.stddev = self.compute_stddev() 

首先,我们更新了Counter的状态。然后,我们重新计算了所有的派生值。这种处理可能会产生巨大的计算开销。需要有一个令人信服的理由,在每次值改变后重新计算均值和标准差。

还有更高效的解决方案。例如,如果我们保存两个中间和一个中间计数,我们可以通过高效地计算平均值和标准差来更新这些和计数。

为此,我们可能有一个看起来像这样的__init__()方法:

    def __init__(self, counter:Counter=None): 
        if counter: 
            self.raw_counter = counter 
            self.count = sum(self.raw_counter[k] for k in self.raw_counter) 
            self.sum = sum(self.raw_counter[k]*k for k in self.raw_counter) 
            self.sum2 = sum(self.raw_counter[k]*k**2 for k in self.raw_counter) 
            self.mean = self.sum/self.count 
            self.stddev = math.sqrt((self.sum2-self.sum**2/self.count)/(self.count-1)) 
        else: 
            self.raw_counter = Counter() 
            self.count = 0 
            self.sum = 0 
            self.sum2 = 0 
            self.mean = None 
            self.stddev = None 

我们编写了这个方法,可以使用Counter或不使用Counter。如果没有提供数据,它将从一个空集合开始,并且各种总和的值为零。当计数为零时,均值和标准差没有有意义的值,因此提供None

如果提供了Counter,那么将计算countsum和平方和。这些可以很容易地进行增量调整,快速重新计算mean和标准差。

当添加一个新值时,以下方法将逐渐重新计算各种派生值:

    def add(self, value): 
        self.raw_counter[value] += 1 
        self.count += 1 
        self.sum += value 
        self.sum2 += value**2 
        self.mean = self.sum/self.count 
        if self.count > 1: 
            self.stddev = math.sqrt( 
                (self.sum2-self.sum**2/self.count)/(self.count-1)) 

更新Counter对象,countsum和平方和显然是必要的,以确保countsum和平方和值始终与self.raw_counter集合匹配。由于我们知道count至少必须是1,因此均值很容易计算。标准差需要至少两个值,并且是从sum和平方和计算的。

这是标准差变体的公式:

更多内容...

这涉及计算两个总和。一个总和涉及频率乘以值的平方。另一个总和涉及频率和值,总和是平方的。我们用C来表示值的总数;这是频率的总和。

另请参阅

  • 扩展集合 - 进行统计的列表中,我们将看一个不同的设计方法,这些函数用于扩展类定义。

  • 我们将在使用属性进行惰性属性中看到不同的方法。这种替代方法将使用属性,并根据需要计算属性。

  • 设计具有少量独特处理的类中,我们将看一个没有真正处理的类。它作为这个类的完全相反。

设计具有少量独特处理的类

在某些情况下,一个对象是相当复杂的数据的容器,但实际上并不对这些数据进行太多处理。事实上,在许多情况下,可以设计一个仅依赖于内置 Python 功能并且不需要任何独特方法函数的类。

在许多情况下,Python 的内置容器类可以几乎覆盖我们的各种用例。小问题是字典或列表的语法不像对象的属性语法那样优雅。

如何创建一个允许我们使用object.attribute语法而不是object['attribute']的类?

准备工作

对于任何类设计,实际上只有两种情况:

  • 它是无状态的吗?它包含了许多属性,但从不改变吗?

  • 它是有状态的吗?各种属性会发生状态变化吗?

有状态的设计略微更一般。我们总是可以使用有状态的实现,并避免对对象进行任何更改以支持无状态对象。然而,使用真正无状态的对象有一些重要的存储和性能优势。

我们将使用两种类来说明两种设计:

  • 无状态:我们将定义一个类来描述简单的扑克牌,它有一个等级和一个花色。由于一张牌的等级和花色不会改变,我们将为此创建一个小的无状态类。

  • 有状态:我们将定义一个类来描述Blackjack游戏中玩家当前状态,其中有一个庄家的手,玩家的手,以及一个可选的保险赌注。在每一手中,有许多方面的游戏都在增加。

如何做...

我们将先看无状态对象,然后是有状态对象。对于没有方法的有状态对象,我们有两个选择:我们可以使用一个新类,或者我们可以利用一个现有的类。这些选择导致三个小的配方。

无状态对象

  1. 我们将基于collections.namedtuple来构建无状态对象。:
        from collections import namedtuple 

  1. 定义类名,将使用两次:
        Card = namedtuple('Card', 

  1. 定义对象的属性:
        Card = namedtuple('Card', ('rank', 'suit')) 

这是我们如何使用这个类定义来创建Card对象:

 **>>> from collections import namedtuple 
>>> Card = namedtuple('Card', ('rank', 'suit')) 
>>> eight_hearts = Card(rank=8, suit='\N{White Heart Suit}') 
>>> eight_hearts 
Card(rank=8, suit='♡') 
>>> eight_hearts.rank 
8 
>>> eight_hearts.suit 
'♡' 
>>> eight_hearts[0] 
8** 

我们已经创建了一个名为Card的新类,它有两个属性名称:ranksuit。在定义类之后,我们可以创建类的实例。我们创建了一个单个的卡片对象eight_hearts,它的 rank 是八,suit 是♡。

我们可以使用对象的名称或元组内的位置引用该对象的属性。当我们使用eight_hearts.rankeight_hearts[0]时,我们将看到 rank 属性,因为它是在属性名称序列中首先定义的。

这种类定义相对较少见。它具有固定的、定义好的属性集。通常,Python 类定义具有动态属性。此外,对象是不可变的。以下是尝试更改实例属性的示例:

 **>>> eight_hearts.suit = '\N{Black Spade Suit}'  
Traceback (most recent call last): 
  File "/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/doctest.py", line 1318, in __run 
    compileflags, 1), test.globs) 
  File "<doctest default[0]>", line 1, in <module> 
    eight_hearts.suit = '\N{Black Spade Suit}' 
AttributeError: can't set attribute** 

我们尝试更改对象的suit属性。这引发了一个AttributeError异常。

使用新类创建有状态对象

  1. 定义新类:
        class Player: 
            pass 

  1. 我们已经编写了一个空的类定义。可以使用类似以下的方式轻松创建此类的实例:
        p = Player() 

然后我们可以使用以下语句向对象添加属性:

    p.stake = 100 

虽然这可能效果很好,但通常有助于向类定义添加更多功能。通常,我们会添加方法,包括__init__()方法,以初始化对象的实例变量。

使用现有类的有状态对象

与其定义一个空类,我们也可以使用标准库中的模块。我们可以使用argparse模块或types模块来实现这一点:

  1. 导入模块。

argparse模块包括Namespace类,可以用来代替空的类定义:

        from argparse import Namespace

我们还可以使用types模块中的SimpleNamespace。它看起来像这样:

        from types import SimpleNamespace 

  1. 将类创建为对SimpleNamespaceNamespace的引用:
        Player = SimpleNamespace 

工作原理...

这些技术中的任何一种都将定义一个可以具有无限数量属性的类。然而,SimpleNamespace比定义我们自己的类具有更灵活的构造函数:

 **>>> from types import SimpleNamespace 
>>> Player = SimpleNamespace 
>>> player_1 = Player(stake=100, hand=[], insurance=None, bet=None) 
>>> player_1.bet = 10 
>>> player_1.stake -= player_1.bet 
>>> player_1.hand.append( eight_hearts ) 
>>> player_1 
namespace(bet=10, hand=[Card(rank=8, suit='♡')], insurance=None, stake=90)** 

我们已经创建了一个名为Player的新类。我们没有提供属性列表,因为它们是动态的。

当我们构建player_1对象时,我们提供了一个要作为该对象一部分创建的属性列表。创建对象后,我们可以对其进行状态更改;我们设置了player_1.bet值,更新了player_1.stake,还更新了player_1.hand

当我们显示对象时,所有属性都会显示出来。通常,它们以字母顺序提供,这样稍微容易编写单元测试示例。

当我们使用namedtuple()函数时,我们正在创建一个类对象。我们提供一个类名作为字符串,以及与元组的位置值相对应的属性名称。结果对象需要分配给一个变量,并且最好确保作为nametuple()函数参数提供的类名和变量名相同。

namedtuple()创建的类对象与class语句创建的类对象是相同类型的类对象。实际上,如果您想要查看源代码,可以使用print(Card._source)来查看创建类时使用的确切内容。

namedtuple类本质上是一个具有命名属性的元组。与所有其他元组对象一样,它是不可变的——一旦构建,就无法更改。

当我们使用SimpleNamespace时,我们使用的是一个几乎没有方法的非常简单的类定义。因为属性通常是动态的,所以这个类允许我们自由地setgetdelete属性。

不是tuple的子类或使用__slots__(我们将在使用 slots 优化小对象中查看的主题)的类非常灵活。还有一些非常高级的技术可以改变属性行为的方式。这些依赖于对 Python 特殊方法名称如何工作的更深入了解。

还有更多...

在许多情况下,我们将把应用程序处理分解为两类类定义:

  • 数据-集合和项目:我们将使用内置的集合类、标准库中的集合,甚至基于namedtuple()SimpleNamespace或其他似乎专注于通用数据集合的类定义的项目。

  • 处理:我们将以与设计具有大量处理的类配方中所示的示例类似的方式定义类。这些处理类通常依赖于数据对象。

清晰地将数据与处理分离的想法符合几个 S.O.L.I.D.设计原则。特别是,它使我们的类与单一职责原则、开闭原则和接口隔离原则保持一致。我们可以创建具有狭窄焦点的类,这使得通过子类扩展变得相对简单。

另请参阅

  • 设计具有大量处理的类配方中,我们将看到一个完全处理而几乎没有数据的类。它充当了这个类的完全相反的极端。

使用__slots__优化小对象

对象的一般情况允许动态属性集合,每个属性都有动态值。基于tuple类的不可变对象有一个特殊情况。我们在设计具有少量独特处理的类配方中都看到了这两种情况。

有一个中间地带-一个具有固定数量属性的对象,但属性的值可以改变。通过将类从无限属性集合更改为固定属性集合,我们还可以节省内存和处理时间。

我们如何创建具有固定属性集的优化类?

准备工作

让我们来看看在Blackjack赌场游戏中一手扑克牌的概念。一手牌有两个部分:

  • 赌注

两者都具有动态值。但只有这两个东西。通常会获得更多的牌。也可能通过加倍下注来提高赌注。

分牌的想法将创建额外的手牌。每个分牌手是一个独立的对象,具有不同的牌集和独特的赌注。

如何做…

在创建类时,我们将利用__slots__特殊名称:

  1. 定义一个具有描述性名称的类:
        class Hand: 

  1. 定义属性名称列表:
            __slots__ = ('hand', 'bet') 

这标识了允许该类的实例的唯一两个属性。任何尝试添加其他属性的尝试都将引发AttributeError异常。

  1. 添加一个初始化方法:
        def __init__(self, bet, hand=None): 
            self.hand= hand or [] 
            self.bet= bet 

一般来说,每手牌都以赌注开始。然后庄家向手牌发两张初始牌。但在某些情况下,我们可能想要从一系列Card实例重新构建一个Hand对象。我们使用了or运算符的一个特性。如果左侧操作数不是假值(即None),那么它就是or表达式的值。如果左侧操作数是假值,那么将评估右侧操作数。有关为什么这是必要的更多信息,请参阅第三章中的设计具有可选参数的函数配方,函数定义

  1. 添加一个更新集合的方法。我们称之为deal,因为它用于向Hand发牌:
        def deal(self, card): 
            self.hand.append(card) 

  1. 添加一个__repr__()方法,以便可以轻松打印:
        def __repr__(self): 
            return "{class_}({bet}, {hand})".format( 
                class_= self.__class__.__name__, 
                **vars(self) 
            ) 

这是我们如何使用这个类来构建一手牌的方法。我们将需要基于设计具有少量独特处理的类配方中的示例来定义Card类:

 **>>> from ch06_r04 import Card, Hand 
>>> h1 = Hand(2) 
>>> h1.deal(Card(rank=4, suit='♣')) 
>>> h1.deal(Card(rank=8, suit='♡')) 
>>> h1 
Hand(2, [Card(rank=4, suit='♣'), Card(rank=8, suit='♡')])** 

我们已经导入了CardHand类的定义。我们创建了一个Hand的实例h1,赌注是桌面最低赌注的两倍。然后我们通过Hand类的deal()方法向手牌添加了两张牌。这展示了h1.hand值如何被改变。

这个示例还显示了h1的实例,以显示赌注和牌的顺序。__repr__()方法生成了 Python 语法的输出。

当玩家加倍下注时,我们还可以替换h1.bet的值(是的,在显示 12 时这是一个疯狂的事情):

 **>>> h1.bet *= 2 
>>> h1 
Hand(4, [Card(rank=4, suit='♣'), Card(rank=8, suit='♡')])** 

当我们显示Hand对象h1时,它显示bet属性已更改。

当我们尝试创建一个新属性时会发生什么:

 **>>> h1.some_other_attribute = True  
Traceback (most recent call last): 
  File "/Library/Frameworks/Python.framework/Versions/3.4/lib/python3.4/doctest.py", line 1318, in __run 
    compileflags, 1), test.globs) 
  File "<doctest default[0]>", line 1, in <module> 
    h1.some_other_attribute = True  
AttributeError: 'Hand' object has no attribute 'some_other_attribute'** 

我们尝试在Hand对象h1上创建一个名为some_other_attribute的属性。这引发了一个AttributeError异常。使用__slots__意味着不能向对象添加新属性。

工作原理...

当我们创建一个类定义时,行为部分由对象类和type()函数定义。隐式地,一个类被分配了一个特殊的__new__()方法,用于处理创建新对象所需的内部工作。

Python 有三条基本路径:

  • 默认行为会在每个对象中构建一个__dict__属性。因为对象的属性被保存在字典中,我们可以自由地添加、更改和删除属性。这种灵活性需要为字典对象使用相对较大的内存。

  • __slots__行为避免了__dict__属性。因为对象只有__slots__序列中命名的属性,我们不能添加或删除属性。我们只能更改已定义属性的值。这种缺乏灵活性意味着每个对象使用的内存更少。

  • tuple的子类行为。这些是不可变的对象。创建它们的最简单方法是使用namedtuple()。一旦创建,它们就不能被更改。在测量内存使用时,这些是所有对象类中最节俭的。

Python 中很少使用__slots__优化。默认的类行为提供了最大的灵活性,并且使得更改类变得容易。然而,在某些情况下,一个大型应用程序可能会受到内存使用量的限制,将一个类切换到__slots__可能会显著提高性能。

还有更多...

可以调整__new__()方法的工作方式,以替换默认的__dict__属性为不同类型的字典。这是一种相当高级的技术,因为它暴露了一些更多的类和对象的内部工作。

Python 依赖于元类来创建类的实例。默认的元类是type类。这个想法是元类提供了一些功能,用于创建对象。一旦空对象被创建,类的__init__()方法将初始化空对象。

通常,元类将提供__new__()的定义,也许还有__prepare__(),如果有必要自定义命名空间对象。Python 语言参考文档中有一个广泛使用的示例,调整了用于创建类的命名空间。

有关更多详细信息,请参见docs.python.org/3/reference/datamodel.html#metaclass-example

另请参阅

  • 不可变对象或完全灵活对象的更常见的情况在设计具有少量独特处理的类示例中有所涵盖。

使用更复杂的集合

Python 有各种各样的内置集合。在第四章中,我们仔细研究了它们。在选择数据结构的示例中,我们提供了一种决策树,以帮助从可用选择中找到适当的数据结构。

当我们将标准库整合进来时,我们有更多的选择,也有更多的决策要做。我们如何为我们的问题选择正确的数据结构?

准备工作

在将数据放入集合之前,我们需要考虑如何收集数据,以及一旦拥有集合后我们将如何处理它。最重要的问题始终是我们将如何识别集合中的特定项目。我们将看一下一些关键问题,以帮助选择适合我们需求的合适集合。

以下是备选集合的概述。它们在三个模块中。

collections模块包含许多内置集合的变体。其中包括以下内容:

  • deque:双端队列。它是一个可变序列,具有从每一端推送和弹出的优化。请注意,类名以小写字母开头;这在 Python 中是不典型的。

  • defaultdict:可以为缺失的键提供默认值的映射。请注意,类名以小写字母开头;这在 Python 中是不典型的。

  • Counter:旨在计算键的出现次数的映射。有时被称为多重集或袋子。

  • OrderedDict:保留创建键的顺序的映射。

  • ChainMap:将几个字典组合成单个映射的映射。

heapq模块包括优先队列实现。这是一种专门的序列,它以排序顺序维护项目。

bisect模块包括用于搜索排序列表的方法。这在字典功能和列表功能之间创建了一些重叠。

如何做...

有一些问题需要回答,以决定我们是否需要库数据集合而不是内置集合:

  1. 结构是否是生产者和消费者之间的缓冲?算法的某些部分是否产生数据项,另一部分是否消耗数据项?

生产者通常会以列表中累积项目,然后消费者从列表中处理项目的一种天真的方法。这种方法往往会构建一个大型的中间数据结构。改变焦点可以交错生产和消费,减少内存使用量。

  • 队列用于先进先出FIFO)处理。项目从一端插入,从另一端消耗。我们可以使用list.append()list.pop(0)来模拟这一过程,尽管collections.deque会更有效;我们可以使用deque.append()deque.popleft()

  • 栈用于后进先出LIFO)处理。项目从同一端插入和消耗。我们可以使用list.append()list.pop()来模拟这一过程,尽管collections.deque会更有效;我们可以使用deque.append()deque.pop()

  • 优先队列(或堆队列)保持队列按某种顺序排序,与到达顺序不同。这通常用于优化工作,包括图搜索算法。我们可以通过使用list.append()list.sort(key=lambda x:x.priority)list.pop(-1)来模拟这一过程。由于这涉及每次插入后的排序,效率非常低。使用heapq模块要高效得多。

  1. 我们希望如何处理字典中缺失的键?
  • 引发异常。这是内置dict类的工作方式。

  • 创建默认项目。这就是defaultdict的工作原理。我们必须提供一个返回默认值的函数。常见的例子包括defaultdict(int)defaultdict(float)以使用默认值为零。我们还可以使用defauldict(list)defauldict(set)来创建字典列表或字典集结构。

  • 在某些情况下,我们需要提供不同的文字值作为默认值:

                lookup = defaultdict(lambda:"N/A") 

这使用 lambda 对象来定义一个没有名称并始终返回字符串N/A的非常小的函数。这将为缺失的键创建一个默认项目N/A

defaultdict(int)用于计算项目是如此常见,Counter类正好做到了这一点。

  1. 我们希望如何处理字典中键的顺序?
  • 顺序不重要;我们总是通过键设置和获取项目。这是内置dict类的行为。键的排序取决于哈希随机化,因此是不可预测的。

  • 我们希望保留插入顺序,同时快速使用它们的键找到项目。OrderedDict类提供了这些独特的特性组合。它具有与内置dict类相同的接口,但保留了键的插入顺序。

  • 我们希望按照正确的顺序对键进行排序。虽然排序列表可以做到这一点,但对于给定的键来说,查找时间相当慢。我们可以使用 bisect 模块来提供对排序列表中项目的快速访问。这需要一个三步算法:

  1. 构建列表,可能通过append()extend()

  2. 对列表进行排序。list.sort()就足够了。

  3. 从排序列表中检索,使用bisect模块。

  4. 我们将如何构建字典?

  • 我们有一个简单的算法来创建项目。在这种情况下,内置的 dict 可能就足够了。

  • 我们有多个需要合并的字典。这可能发生在读取配置文件时。我们可能有一个单独的配置,一个系统范围的配置,以及一个需要合并的默认应用程序配置。

                import json 
                user = json.load('~/app.json') 
                system = json.load('/etc/app.json') 
                application = json.load('/opt/app/default.json') 

  1. 我们如何结合这些?
            from collections import ChainMap 
            config = ChainMap(user, system, application) 

生成的config对象将通过各种字典进行顺序搜索。它将在用户、系统和应用程序字典中查找给定的键。

它是如何工作的...

数据处理有两个主要的资源约束:

  • 存储

  • 时间

我们所有的编程都必须遵守这些约束。在大多数情况下,这两者是相互对立的:我们为了减少存储使用而做的任何事情往往会增加处理时间,而我们为了减少处理时间而做的任何事情会增加存储使用。

时间方面通过复杂度度量来形式化。对算法的复杂性进行了相当多的分析:

  • 描述为O(1)的操作以恒定时间发生。在这种情况下,复杂性不随数据量的增加而改变。对于一些集合,实际的长期平均值几乎是O(1),只有少量例外。列表append操作就是一个例子:它们的复杂性都差不多。不过,偶尔在幕后的内存管理操作会增加一些时间。

  • 描述为O(n)的操作以线性时间发生。随着数据量的增加,成本也会增加。在列表中查找项目具有这种复杂性。在字典中查找项目更接近O(1),因为它的复杂性很低,无论字典有多大,都是(几乎)相同的。

  • 描述为O(n log n)的操作增长速度比数据量快。bisect模块包括具有这种复杂性的搜索算法。

  • 甚至有更糟的情况:一些算法的复杂性是O(n²)甚至O(n!)。我们希望通过巧妙的设计和更智能的数据结构来避免这些情况。

各种数据结构反映了独特的时间和存储权衡。

还有更多...

作为一个具体而极端的例子,让我们来看看在 Web 日志文件中搜索特定事件序列。我们有两种总体设计策略:

  • 将所有事件读入类似file.read().splitlines()的列表结构中。然后我们可以使用for语句来遍历列表,寻找事件的组合。虽然初始读取可能需要一些时间,但搜索会非常快,因为日志都在内存中。

  • 从日志文件中读取每个事件。如果事件是模式的一部分,只保存这个事件。我们可以使用defaultdict,以 IP 地址作为键,以事件列表作为值。这将需要更长的时间来读取日志,但内存中的结果结构将会小得多。

第一个算法,将所有内容读入内存,通常是非常不切实际的。在大型 Web 服务器上,日志可能涉及数百 GB,甚至是 TB 级别的数据。这是无法容纳在任何计算机内存中的。

第二种方法有许多替代实现:

  • 单个进程:这里大多数 Python 配方的一般方法假设我们正在创建一个作为单个进程运行的应用程序。

  • 多个进程:我们可以将逐行搜索扩展为使用multiprocessingconcurrent包的多进程应用程序。我们将创建一组工作进程,每个进程可以处理可用数据的子集,并将结果返回给组合结果的消费者。在现代多处理器、多核计算机上,这可以非常有效地利用资源。

  • 多个主机:极端情况需要多个服务器,每个服务器处理数据的一个子集。这需要更复杂的协调来共享结果集。通常,这种处理需要像 Hadoop 这样的框架。

我们经常将大型搜索分解为映射和减少处理。映射阶段对集合中的每个项目应用一些处理或过滤。减少阶段将映射结果组合成摘要或聚合对象。在许多情况下,有一种复杂的MapReduce操作层次结构应用于先前 MapReduce 操作的结果。

参见

  • 在第四章的选择数据结构配方中,内置数据结构 - 列表、集合、字典,有一组基本的决策,用于选择数据结构

扩展集合 - 进行统计的列表

设计具有大量处理的类配方中,我们看了一种区分复杂算法和集合的方法。我们展示了如何将算法和数据封装到单独的类中。

另一种设计策略是将集合扩展到包含有用的算法。

我们如何扩展 Python 的内置集合?

准备工作

我们将创建一个复杂的列表,可以计算列表中项目的总和和平均值。这将要求我们的应用程序只将数字放入列表;否则,将会有ValueError异常。

如何做...

  1. 选择一个名称,也可以进行简单的统计。将类定义为内置list类的扩展:
        class StatsList(list): 

这显示了定义内置类的扩展的语法。如果我们提供的主体只包含pass语句,那么新的StatsList类可以在任何使用list类的地方使用。

当我们写这个时,list类被称为StatsList的超类。

  1. 将附加处理定义为新方法。self变量将是一个从超类继承了所有属性和方法的对象。这是一个sum()方法:
        def sum(self): 
            return sum(v for v in self) 

我们使用了生成器表达式,以清楚地表明sum()函数应用于列表中的每个项目。使用生成器表达式可以让我们非常容易地进行计算或引入过滤器。

  1. 这是我们经常应用于列表的另一种方法。这计算项目数:
        def count(self): 
            return sum(1 for v in self) 

这将计算列表中的项目数。我们选择使用生成器表达式,而不是使用len()函数,以防将来想要添加过滤功能。

  1. 这是mean函数:
            def mean(self): 
                return self.sum() / self.count() 

  1. 以下是一些附加方法:
        def sum2(self): 
            return sum(v**2 for v in self) 
        def variance(self): 
            return (self.sum2() - self.sum()**2/self.count())/(self.count()-1) 
        def stddev(self): 
            return math.sqrt(self.variance()) 

sum2()方法计算列表中值的平方和。这用于计算方差。然后使用方差来计算列表中值的标准差。

StatsList对象继承了list对象的所有特性。它通过我们添加的方法进行了扩展。以下是使用此集合的示例:

 **>>> from ch06_r06 import StatsList 
>>> subset1 = StatsList([10, 8, 13, 9, 11]) 
>>> data = StatsList([14, 6, 4, 12, 7, 5]) 
>>> data.extend(subset1)** 

我们从对象的文字列表中创建了两个StatsList对象。我们使用extend()方法来合并这两个对象。以下是结果对象:

 **>>> data 
[14, 6, 4, 12, 7, 5, 10, 8, 13, 9, 11]** 

以下是我们如何使用我们在此对象上定义的附加方法:

 **>>> data.mean() 
9.0 
>>> data.variance() 
11.0** 

我们展示了mean()variance()方法的结果。当然,内置list类的所有特性都存在于我们的扩展中:

 **>>> data.sort() 
>>> data[len(data)//2] 
9** 

我们使用内置的sort()方法,并使用索引功能从列表中提取一个项目。因为值的数量是奇数,这是中位数值。请注意,这会改变list对象的顺序。这不是这个算法的最佳实现。

它是如何工作的...

类定义的一个基本特征是继承的概念。当我们创建超类-子类关系时,子类继承了超类的所有特性。这有时被称为泛化-特化关系。超类是一个更一般化的类;子类更专业化,因为它添加或修改了特性。

所有内置类都可以扩展以添加特性。在这个例子中,我们添加了一些统计处理,创建了一个特殊类型的列表子类。

两种设计策略之间存在重要的紧张关系:

  • 扩展:在这种情况下,我们扩展了一个类以添加特性。这些特性与这个单一数据结构紧密结合,我们不能轻易地将它们用于不同类型的序列。

  • 包装:在设计具有大量处理的类时,我们将处理与集合分开。这会导致在操纵两个对象时更复杂一些。

很难建议其中一个在本质上优于另一个。在许多情况下,我们会发现包装可能具有优势,因为它似乎更符合 S.O.L.I.D.设计原则。然而,总会有一些情况,其中明显适合扩展内置集合。

还有更多...

泛化的概念可能导致超类是抽象的。抽象类是不完整的,需要一个子类来扩展它并提供缺失的实现细节。我们不能创建抽象类的实例,因为它会缺少使其有用的特性。

正如我们在第四章的选择数据结构配方中所指出的,内置数据结构-列表、集合、字典,所有内置集合都有抽象超类。我们可以从一个抽象基类开始设计,而不是从一个具体类开始。

例如,我们可以开始一个类定义如下:

    from collections.abc import Mapping 
    class MyFancyMapping(Mapping): 
    etc. 

为了完成这个类,我们需要为许多特殊方法提供实现:

  • __getitem__()

  • __setitem__()

  • __delitem__()

  • __iter__()

  • __len__()

这些方法中的每一个在抽象类中都是缺失的;它们在Mapping类中没有具体的实现。一旦我们为每个方法提供了可行的实现,我们就可以创建新子类的实例。

另请参阅

  • 设计具有大量处理的类配方中,我们采取了不同的方法。在那个配方中,我们将复杂的算法留在了一个单独的类中。

使用属性进行惰性属性

设计具有大量处理的类配方中,我们定义了一个类,它急切地计算了集合中数据的许多属性。那里的想法是尽快计算值,以便属性不会有进一步的计算成本。

我们将这描述为急切处理,因为工作尽快完成。另一种方法是惰性处理,其中工作尽可能晚地完成。

如果我们有很少使用但计算成本很高的值,我们该怎么做来最小化前期计算,只在真正需要时计算值?

准备就绪...

假设我们使用Counter对象收集了数据。有关各种集合的更多信息,请参见第四章,内置数据结构 - 列表、集合、字典,特别是使用集合方法和运算符避免函数参数的可变默认值配方。在这种情况下,客户分为八个大致相等的类别。

数据看起来像这样:

    Counter({15: 7, 17: 5, 20: 4, 16: 3, ... etc., 45: 1}) 

在这个集合中,每个键都是获取完整优惠券所需的访问次数。值是发生访问的次数。在我们看到的先前数据中,有七次需要15次访问才能获得完整的优惠券。我们可以从样本数据中看到,有五次需要17次访问。这有一个长尾。只有一个点,需要45次单独访问才能收集到八张优惠券的完整集。

我们想要计算这个Counter上的一些统计数据。我们有两种总体策略可以做到这一点:

  • 扩展:我们在扩展集合 - 进行统计的列表配方中详细介绍了这一点,我们将在第七章中介绍这一点,更高级的类设计

  • 包装:我们可以将Counter对象包装在另一个类中,该类仅提供我们需要的功能。我们将在第七章中查看这一点,更高级的类设计

包装的常见变体使用具有单独数据收集对象的统计计算对象。这种包装的变体通常会导致优雅的解决方案。

无论我们选择哪种类架构,我们都有两种设计处理的方式:

  • 急切:这意味着我们将尽快计算统计数据。这是在设计具有大量处理的类配方中采用的方法。

  • 懒惰:这意味着在需要通过方法函数或属性时才会计算任何东西。在扩展集合 - 进行统计的列表配方中,我们向集合类添加了方法。这些额外的方法是懒惰计算的例子。只有在需要时才计算统计值。

这两种设计的基本数学是相同的。唯一的问题是计算何时完成。

平均值μ是这样的:

准备好...

在这里,k是来自Counter的键,Cf[k]是给定键的频率值来自Counter

标准偏差σ取决于平均值μ。公式如下:

准备好...

在这里,k是来自Counter的键,Cf[k]是给定键的频率值来自Counter。计数器中的项目总数是准备好...

如何做...

  1. 定义一个具有描述性名称的类:
        class LazyCounterStatistics: 

  1. 编写初始化方法以包括将连接到该对象的对象:
        def __init__(self, raw_counter:Counter): 
            self.raw_counter = raw_counter 

我们已经定义了一个方法函数,它以Counter对象作为参数值。这个counter对象被保存为Counter_Statistics实例的一部分。

  1. 定义一些有用的辅助方法。每个方法都使用@property进行装饰,使其表现得像一个简单的属性:
        @property 
        def sum(self): 
            return sum(f*v for v, f in self.raw_counter.items()) 
        @property 
        def count(self): 
            return sum(f for v, f in self.raw_counter.items()) 

  1. 定义各种值所需的方法。这是平均值的计算。这也是用@property装饰的。其他方法可以被引用,就像它们是属性一样,尽管它们是适当的方法函数:
        @property 
        def mean(self): 
            return self.sum / self.count 

  1. 这是我们如何计算标准偏差的方法:
        @property 
        def sum2(self): 
            return sum(f*v**2 for v, f in self.raw_counter.items()) 
        @property 
        def variance(self): 
            return (self.sum2 - self.sum**2/self.count)/(self.count-1) 
        @property 
        def stddev(self): 
            return math.sqrt(self.variance) 

请注意,我们一直在使用math.sqrt()。确保在 Python 文件中添加所需的import math语句。

  1. 这是我们如何创建一些样本数据的方法:
 **>>> from ch04_r06 import * 
      >>> from collections import Counter 
      >>> def raw_data(n=8, limit=1000, arrival_function=arrival1): 
      ...    expected_time = float(expected(n)) 
      ...    data = samples(limit, arrival_function(n)) 
      ...    wait_times = Counter(coupon_collector(n, data)) 
      ...    return wait_times** 

我们已经从ch04_r06模块导入了expected()arrival1()coupon_collector()等函数。我们从标准库collections模块导入了Counter集合。

我们定义了一个名为raw_data()的函数,它将生成一定数量的客户访问。默认情况下,将有 1,000 次访问。域将包括八种不同类别的客户;每个类别将有相等数量的成员。我们将使用coupon_collector()函数来遍历数据,发出收集完整八张优惠券所需的访问次数。

然后使用这些数据来组装一个Counter对象。这将显示获得完整一套优惠券所需的客户数量。每个客户数量还将显示该访问次数发生的频率。

  1. 这是我们如何分析Counter对象的方法:
 **>>> import random 
      >>> from ch06_r07 import LazyCounterStatistics 
      >>> random.seed(1)** 

 **>>> data = raw_data() 
      >>> stats = LazyCounterStatistics(data) 
      >>> print("Mean: {0:.2f}".format(stats.mean)) 
      Mean: 20.81** 

 **>>> print("Standard Deviation: {0:.3f}".format(stats.stddev)) 
      Standard Deviation: 7.025** 

首先,我们导入了random模块,以便我们可以选择一个已知的seed值。这样做可以更容易地测试和演示应用程序,因为随机数是一致的。我们还从ch06_r07模块中导入了LazyCounterStatistics类。

一旦我们定义了所有的项目,我们可以强制将种子设为已知值,并生成收集器测试结果。raw_data()函数将发出一个Counter对象,我们称之为data

我们将使用Counter对象来创建LazyCounterStatistics类的一个实例。我们将把这个实例分配给stats变量。当我们打印stats.mean属性和stats.stddev属性的值时,方法将被调用来做各种值的适当计算。

对于八张优惠券,理论平均值是 21.7 次访问以收集所有优惠券。看起来raw_data()的结果显示了与随机访问预期相匹配的行为。这有时被称为零假设——数据是随机的。

在这种情况下,数据确实是随机的。我们验证了我们的方法。现在我们可以相当有信心地在真实世界的数据上使用这个软件,因为它的行为是正确的。

它是如何工作的...

懒惰计算的想法在很少使用值的情况下效果很好。在这个例子中,计数在计算方差和标准差时被计算了两次。

这表明对于懒惰设计的天真看法在某些情况下可能不是最佳的。这是一个很容易修复的问题。我们总是可以创建额外的本地变量来保存中间结果。

为了使这个类看起来像执行急切计算的类,我们使用了@property装饰器。这使得一个方法函数看起来像一个属性。这只对没有参数值的方法函数起作用。

在所有情况下,急切计算的属性都可以被懒惰的属性替换。创建急切属性变量的主要原因是为了优化计算成本。在很少使用值的情况下,懒惰的属性可以避免昂贵的计算。

还有更多...

有一些情况下,我们可以进一步优化属性,以限制重新计算的数量。这需要仔细分析使用情况,以了解对底层数据的更新模式。

在加载数据并执行分析的情况下,我们可以缓存结果以节省第二次计算它们的成本。

我们可能会这样做:

    def __init__(self, raw_counter:Counter): 
        self.raw_counter = raw_counter 
        self._count = None 
    @property 
    def count(self): 
        if self._count is None: 
            self._count = sum(f for v, f in self.raw_counter.items()) 
        return self._count 

这种技术使用一个属性来保存计数计算的副本。这个值可以计算一次,并在需要时无需重新计算地返回。

只有在raw_counter对象的状态永远不会改变的情况下,这种优化才有帮助。在更新底层Counter的应用程序中,这个缓存值将变得过时。这种应用程序需要在每次更新Counter时重新创建LazyCounterStatistics

另请参阅...

  • 设计具有大量处理的类配方中,我们定义了一个急切计算多个属性的类。这代表了管理计算成本的不同策略。

使用可设置的属性来更新急切属性

在之前的几个示例中,我们已经看到了急切和懒惰计算之间的重要区别。请参阅设计具有大量处理的类示例,了解急切计算结果并设置对象属性的示例。请参阅使用属性进行懒惰属性示例,了解使用属性懒惰地计算结果的方法。

当对象具有状态时,属性值必须在对象的整个生命周期中进行更改。通常使用方法急切地计算属性更改,但这并不是必需的。

对于有状态的对象,我们有以下选择:

  • 通过方法设置属性值:

  • 急切地计算结果,将结果放在属性中

  • 懒惰地计算结果,使用看起来像简单属性的属性

  • 通过属性设置值:

  • 如果结果是通过属性懒惰地计算的,那么新状态可以反映在这些计算中

如果我们想要使用类似属性的语法来设置值,但又想进行急切计算,我们可以怎么做?

这给了我们另一个变化:我们可以使用属性设置器来使用类似属性的语法。这种方法还可以对结果进行急切的计算。

例如,我们将使用一个外观相当复杂的对象,它有几个属性是从其他属性派生出来的。我们如何急切地计算属性更改的值?

准备好

考虑一个表示航程的腿的类。它有三个主要特征——速率、时间和距离。总的来说,可以从其他两个属性的变化中急切地计算任何一个值。

我们可以添加功能,使其变得更加复杂。例如,如果距离是从纬度和经度计算出来的,一般的方法必须稍作修改。如果我们使用特定的点而不是更灵活的距离,那么距离计算可能涉及速率、时间、起点和方位角之类的东西。这涉及到两个相互关联的计算。在这个例子中,我们不会走得那么远;我们将坚持更简单的速率-时间-距离计算。

由于必须设置两个属性才能计算第三个属性,对象将具有相当复杂的内部状态:

  • 没有属性被设置:一切都是未知的。

  • 已设置一个项目:还不能计算任何东西。

  • 已经设置了两个不同的项目:现在可以计算第三个。

之后,最好支持额外的属性更改。基本规则是根据最近的两个不同的更改计算适当的新值:

  • 如果速率,r,和时间,t,是最后更改的两个属性,计算距离,d。使用d = r * t

  • 如果速率,r,和距离,d,是最后更改的两个属性,计算时间,t。使用t = d/r

  • 如果时间,t,和距离,d,是最后更改的两个属性,计算速率,r。使用r = d/t

我们希望对象的行为如下:

    leg_1 = Leg() 
    leg_1.rate = 6.0 # knots 
    leg_1.distance = 35.6 # nautical miles 
    print("Cover {leg.distance:.1f}nm at {leg.rate:.2f}kt = {leg.time:.2f}hr". 
        format(leg=leg_1)) 

这有一个明显的优势,即为leg对象提供了一个非常简单的接口。应用程序只需设置任何两个属性,计算就会急切地执行,以为剩余的属性提供一个值。

如何做...

我们将把这分为两部分。首先是定义可设置属性的概述,然后是如何跟踪状态变化的细节:

  1. 定义一个有意义的类名。

  2. 提供隐藏属性。这些将被公开为属性:

        class Leg: 
        def __init__(self): 
            self._rate= rate 
            self._time= time 
            self._distance= distance. 

  1. 对于每个可获取的属性,提供一个计算属性值的方法。在许多情况下,这些方法将与隐藏属性并行:
        @property 
        def rate(self): 
            return self._rate 

  1. 对于每个可设置的属性,提供一个设置属性值的方法:
        @rate.setter 
        def rate(self, value): 
            self._rate = value 
            self._calculate('rate') 

设置方法具有基于获取方法名称的特殊属性装饰器。在这个例子中,@property装饰器在rate()方法上还创建了一个rate.setter装饰器,可以用来定义该属性的设置方法。

注意,getter 和 setter 的方法名称是相同的。@property@rate.setter装饰区分了这两个方法。

在这个例子中,我们将值保存到隐藏属性self._rate中。然后,如果可能的话,使用_calculate()方法急切地计算所有隐藏属性。

  1. 这可以重复应用到所有其他属性上。在我们的例子中,时间和距离的代码是相似的:
        @property 
        def time(self): 
            return self._time 
        @time.setter 
        def time(self, value): 
            self._time = value 
            self._calculate('time') 
        @property 
        def distance(self): 
            return self._distance 
        @distance.setter 
        def distance(self, value): 
            self._distance = value 
            self._calculate('distance') 

跟踪状态更改的细节依赖于collections.deque类的一个特性。计算规则可以实现为两个元素的有界队列,其中包含不同的更改。当每个不同的字段被更改时,我们可以将字段名称入队。队列中的两个不同名称是最近更改的最后两个字段;第三个可以通过集合减法从中确定:

  1. 导入deque类:
        from collections import deque 

  1. __init__()方法中初始化队列:
        self._changes= deque(maxlen=2) 

  1. 入队每个不同的更改。确定队列中缺少什么,并计算出来:
            def _calculate(self, change): 
            if change not in self._changes: 
                self._changes.append(change) 
            compute = {'rate', 'time', 'distance'} - set(self._changes) 
            if compute == {'distance'}: 
                self._distance = self._time * self._rate 
            elif compute == {'time'}: 
                self._time = self._distance / self._rate 
            elif compute == {'rate'}: 
                self._rate = self._distance / self._time 

如果最新的更改尚未在队列中,它将被追加。由于队列有一个有界的大小,最老的项目,即最近更改的项目,将被悄悄地弹出以保持队列大小固定。

可用属性集和最近更改的属性集之间的差异是一个属性名称。这是最近设置的名称;这个值可以从更近设置的其他两个值计算出来。

它是如何工作的...

这是因为 Python 实现了一种称为描述符的类的属性。描述符类可以有获取值、设置值和删除值的方法。根据上下文,其中一个方法会被隐式使用:

  • 当在表达式中使用描述符对象时,将使用__get__方法

  • 当一个描述符出现在赋值语句的左侧时,将使用__set__方法

  • 当描述符出现在del语句中时,将使用__delete__方法

@property装饰器做了三件事:

  • 修改后面的方法,将其包装成一个描述符对象。后面的方法被修改为描述符的__get__方法。在表达式中使用时,它将计算值。

  • 添加method.setter装饰器。这个装饰器将修改后面的方法成为描述符的__set__方法。当名称在赋值语句的左侧使用时,给定的方法将被执行。

  • 添加method.deleter装饰器。这个装饰器将修改后面的方法成为描述符的__delete__方法。当名称在del语句中使用时,给定的方法将被执行。

这允许构建一个属性名称,可以用来提供值、设置值,甚至删除值。

还有更多...

对这个类还有一些更多的改进。我们将看看两种更高级的初始化和计算技术。

初始化

我们可以提供一种正确初始化实例的方法。这个改变使得可以做到以下几点:

 **>>> from ch06_r08 import Leg 
>>> leg_2 = Leg(distance=38.2, time=7) 
>>> round(leg_2.rate, 2) 
5.46 
>>> leg_2.time=6.5 
>>> round(leg_2.rate, 2) 
5.88** 

这个例子展示了如何通过帆船规划航行。如果要覆盖的距离是38.2海里,目标是在7小时内完成,船必须达到5.46节的速度。要缩短半个小时的行程需要达到5.88节的速度。

为了使其工作,需要更改__init__()方法。内部的dequeue对象必须立即构建。当设置每个属性时,必须使用内部的_calculate()方法来跟踪设置:

    class Leg: 
        def __init__(self, rate=None, time=None, distance=None): 
            self._changes= deque(maxlen=2) 
            self._rate= rate 
            if rate: self._calculate('rate') 
            self._time= time 
            if time: self._calculate('time') 
            self._distance= distance 
            if distance: self._calculate('distance') 

首先创建dequeue函数。当设置每个单独的字段值时,更改将被记录在更改属性的队列中。如果设置了两个字段,第三个将被计算。

如果设置了所有三个字段,那么最后两个更改——在这种情况下是时间和距离——将计算出rate的值。这将覆盖提供的值。

计算

目前,各种计算都隐藏在一个if语句中。这使得更改变得困难,因为子类将被迫提供整个方法,而不仅仅是提供计算更改。

我们可以使用内省技术来移除if语句。整体设计会更好,使用显式计算方法:

    def calc_distance(self): 
        self._distance = self._time * self._rate 
    def calc_time(self): 
        self._time = self._distance / self._rate 
    def calc_rate(self): 
        self._rate = self._distance / self._time 

以下版本的_calculate()利用了这些方法:

    def _calculate(self, change): 
        if change not in self._changes: 
            self._changes.append(change) 
        compute = {'rate', 'time', 'distance'} - set(self._changes) 
        if len(compute) == 1: 
            name = compute.pop() 
            method = getattr(self, 'calc_'+name) 
            method() 

当计算的值是一个单例集合时,使用pop()方法从集合中提取该值。在这个字符串前加上calc_会得到一个计算所需值的方法的名称。

getattr()函数进行查找以找到对象self的请求方法,然后将其作为绑定函数进行评估。它可以使用所需的结果更新属性。

将计算重构为单独的方法使得类更容易扩展。现在我们可以创建一个包括修订计算但保留类的整体特性的子类。

另请参阅

  • 有关使用集合的更多信息,请参阅第四章中的使用集合方法和运算符配方,内置数据结构 - 列表,集合,字典

  • dequeue实际上是一个针对追加和弹出操作进行了高度优化的列表。请参阅第四章中的从列表中删除 - 删除,移除,弹出和过滤配方,内置数据结构 - 列表,集合,字典

posted @ 2024-04-18 10:54  绝不原创的飞龙  阅读(1)  评论(0编辑  收藏  举报