RealPython 中文系列教程(九十一)

原文:RealPython

协议:CC BY-NC-SA 4.0

Python 中的 OrderedDict 与 Dict:适合工作的工具

原文:https://realpython.com/python-ordereddict/

有时你需要一个 Python 字典来记住条目的顺序。在过去,你只有一个工具来解决这个特定的问题:Python 的 OrderedDict 。它是一个字典子类,专门用来记住条目的顺序,这是由键的插入顺序定义的。

这在 Python 3.6 中有所改变。内置的dict类现在也保持其项目有序。因此,Python 社区中的许多人现在想知道OrderedDict是否仍然有用。仔细观察OrderedDict会发现这个职业仍然提供有价值的特性。

在本教程中,您将学习如何:

  • 在你的代码中创建并使用 OrderedDict对象
  • 确定OrderedDictdict之间的差异
  • 了解使用OrderedDict vs dict优点缺点

有了这些知识,当您想要保持项目的顺序时,您将能够选择最适合您需要的字典类。

在本教程结束时,您将看到一个使用OrderedDict实现基于字典的队列的示例,如果您使用常规的dict对象,这将更具挑战性。

免费奖励: 并学习 Python 3 的基础知识,如使用数据类型、字典、列表和 Python 函数。

OrderedDictdict之间选择

多年来,Python 字典是无序的数据结构。Python 开发者已经习惯了这个事实,当他们需要保持数据有序时,他们依赖于列表或其他序列。随着时间的推移,开发人员发现需要一种新型的字典,一种可以保持条目有序的字典。

早在 2008 年, PEP 372 就引入了给 collections 增加一个新字典类的想法。它的主要目标是记住由插入键的顺序定义的项目顺序。那就是OrderedDict的由来。

核心 Python 开发人员想要填补这个空白,提供一个能够保持插入键顺序的字典。这反过来又使得依赖于这一特性的特定算法的实现更加简单。

OrderedDict被添加到 Python 3.1 的标准库中。它的 API 本质上和dict一样。然而,OrderedDict按照插入键的顺序遍历键和值。如果新条目覆盖了现有条目,则项目的顺序保持不变。如果一个条目被删除并重新插入,那么它将被移动到字典的末尾。

Python 3.6 引入了一个dict的新实现。这个新的实现在内存使用和迭代效率方面取得了巨大的成功。此外,新的实现提供了一个新的、有点出乎意料的特性:dict对象现在以它们被引入时的顺序保存它们的项目。最初,这个特性被认为是一个实现细节,文档建议不要依赖它。

**注意:**在本教程中,您将重点关注 CPython 提供的dictOrderedDict的实现。

用核心 Python 开发者和OrderedDict的合著者雷蒙德·赫廷格的话说,这个类是专门为保持其项目有序而设计的,而dict的新实现被设计得紧凑并提供快速迭代:

目前的正规词典是基于我几年前提出的设计。该设计的主要目标是紧凑性和快速迭代密集的键和值数组。维持秩序是一个人工制品,而不是一个设计目标。这个设计可以维持秩序,但这不是它的专长。

相比之下,我给了collections.OrderedDict一个不同的设计(后来由埃里克·斯诺用 C 语言编写)。主要目标是有效地维护秩序,即使是在严重的工作负载下,例如由lru_cache施加的负载,它经常改变秩序而不触及底层的dict。有意地,OrderedDict有一个优先排序能力的设计,以额外的内存开销和常数因子更差的插入时间为代价。

我的目标仍然是让collections.OrderedDict有一个不同的设计,有不同于普通字典的性能特征。它有一些常规字典没有的特定于顺序的方法(比如从两端有效弹出的一个move_to_end()和一个popitem())。OrderedDict需要擅长这些操作,因为这是它区别于常规字典的地方。(来源)

Python 3.7 中,dict对象的项目排序特性被宣布为Python 语言规范的正式部分。因此,从那时起,当开发人员需要一个保持条目有序的字典时,他们可以依赖dict

此时,一个问题产生了:在dict的这个新实现之后,还需要OrderedDict吗?答案取决于您的具体用例,也取决于您希望在代码中有多明确。

在撰写本文时,OrderedDict的一些特性仍然使它有价值,并且不同于普通的dict:

  1. **意图信号:**如果你使用OrderedDict而不是dict,那么你的代码清楚地表明了条目在字典中的顺序是重要的。你清楚地表达了你的代码需要或者依赖于底层字典中的条目顺序。
  2. **控制条目的顺序:**如果您需要重新排列或重新排序字典中的条目,那么您可以使用 .move_to_end() 以及 .popitem() 的增强变体。
  3. **相等测试行为:**如果您的代码比较字典的相等性,并且条目的顺序在比较中很重要,那么OrderedDict是正确的选择。

至少还有一个在代码中继续使用OrderedDict的理由:向后兼容性。在运行 than 3.6 之前版本的环境中,依靠常规的dict对象来保持项目的顺序会破坏您的代码。

很难说dict会不会很快全面取代OrderedDict。如今,OrderedDict仍然提供有趣和有价值的特性,当你为一个给定的工作选择一个工具时,你可能想要考虑这些特性。

Remove ads

Python 的OrderedDict 入门

Python 的OrderedDict是一个dict子类,它保留了键-值对,俗称插入字典的顺序。当你迭代一个OrderedDict对象时,条目会按照原来的顺序被遍历。如果更新现有键的值,则顺序保持不变。如果您删除一个条目并重新插入,那么该条目将被添加到词典的末尾。

成为一个dict子类意味着它继承了常规字典提供的所有方法。OrderedDict还有其他特性,您将在本教程中了解到。然而,在本节中,您将学习在代码中创建和使用OrderedDict对象的基础知识。

创建OrderedDict个对象

dict不同,OrderedDict不是内置类型,所以创建OrderedDict对象的第一步是collections导入类。有几种方法可以创建有序字典。它们中的大多数与你如何创建一个常规的dict对象是一样的。例如,您可以通过实例化不带参数的类来创建一个空的OrderedDict对象:

>>> from collections import OrderedDict

>>> numbers = OrderedDict()

>>> numbers["one"] = 1
>>> numbers["two"] = 2
>>> numbers["three"] = 3

>>> numbers
OrderedDict([('one', 1), ('two', 2), ('three', 3)])

在这种情况下,首先从collections导入OrderedDict。然后通过实例化OrderedDict创建一个空的有序字典,而不向构造函数提供参数。

通过在方括号([])中提供一个键并为该键赋值,可以将键-值对添加到字典中。当您引用numbers时,您会得到一个键-值对的 iterable,它按照条目被插入字典的顺序保存条目。

您还可以将 iterable items 作为参数传递给OrderedDict的构造函数:

>>> from collections import OrderedDict

>>> numbers = OrderedDict([("one", 1), ("two", 2), ("three", 3)])
>>> numbers
OrderedDict([('one', 1), ('two', 2), ('three', 3)])

>>> letters = OrderedDict({("a", 1), ("b", 2), ("c", 3)})
>>> letters
OrderedDict([('c', 3), ('a', 1), ('b', 2)])

当您使用一个序列时,比如一个list或一个tuple,结果排序字典中的条目顺序与输入序列中条目的原始顺序相匹配。如果您使用一个set,就像上面的第二个例子,那么直到OrderedDict被创建之前,项目的最终顺序是未知的。

如果您使用一个常规字典作为一个OrderedDict对象的初始化器,并且您使用的是 Python 3.6 或更高版本,那么您会得到以下行为:

Python 3.9.0 (default, Oct  5 2020, 17:52:02)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from collections import OrderedDict

>>> numbers = OrderedDict({"one": 1, "two": 2, "three": 3})
>>> numbers
OrderedDict([('one', 1), ('two', 2), ('three', 3)])

OrderedDict对象中项目的顺序与原始字典中的顺序相匹配。另一方面,如果您使用低于 3.6 的 Python 版本,那么项目的顺序是未知的:

Python 3.5.10 (default, Jan 25 2021, 13:22:52)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from collections import OrderedDict

>>> numbers = OrderedDict({"one": 1, "two": 2, "three": 3})
>>> numbers
OrderedDict([('one', 1), ('three', 3), ('two', 2)])

因为 Python 3.5 中的字典不记得条目的顺序,所以在创建对象之前,您不知道结果有序字典中的顺序。从这一点上来说,秩序得到了维护。

您可以通过将关键字参数传递给类构造函数来创建有序字典:

>>> from collections import OrderedDict

>>> numbers = OrderedDict(one=1, two=2, three=3)
>>> numbers
OrderedDict([('one', 1), ('two', 2), ('three', 3)])

自从 Python 3.6 以来,函数保留了调用中传递的关键字参数的顺序。因此,上面的OrderedDict中的项目顺序与您将关键字参数传递给构造函数的顺序相匹配。在早期的 Python 版本中,这个顺序是未知的。

最后,OrderedDict还提供了.fromkeys(),它从一个可迭代的键创建一个新字典,并将其所有值设置为一个公共值:

>>> from collections import OrderedDict

>>> keys = ["one", "two", "three"]
>>> OrderedDict.fromkeys(keys, 0)
OrderedDict([('one', 0), ('two', 0), ('three', 0)])

在这种情况下,您使用一个键列表作为起点来创建一个有序字典。.fromkeys()的第二个参数为字典中的所有条目提供一个值。

Remove ads

管理OrderedDict中的项目

由于OrderedDict是一个可变的数据结构,你可以对它的实例执行变异操作。您可以插入新项目,更新和删除现有项目,等等。如果您在现有的有序字典中插入一个新项目,则该项目会被添加到字典的末尾:

>>> from collections import OrderedDict

>>> numbers = OrderedDict(one=1, two=2, three=3)
>>> numbers
OrderedDict([('one', 1), ('two', 2), ('three', 3)])

>>> numbers["four"] = 4
>>> numbers
OrderedDict([('one', 1), ('two', 2), ('three', 3), ('four', 4)])

新添加的条目('four', 4)放在底层字典的末尾,因此现有条目的顺序保持不变,字典保持插入顺序。

如果从现有的有序字典中删除一个项目,然后再次插入该项目,则该项目的新实例将被放在字典的末尾:

>>> from collections import OrderedDict
>>> numbers = OrderedDict(one=1, two=2, three=3)

>>> del numbers["one"]
>>> numbers
OrderedDict([('two', 2), ('three', 3)])

>>> numbers["one"] = 1
>>> numbers
OrderedDict([('two', 2), ('three', 3), ('one', 1)])

如果删除('one', 1)项并插入同一项的新实例,那么新项将被添加到底层字典的末尾。

如果您重新分配或更新一个OrderedDict对象中现有键值对的值,那么键会保持其位置,但会获得一个新值:

>>> from collections import OrderedDict
>>> numbers = OrderedDict(one=1, two=2, three=3)

>>> numbers["one"] = 1.0
>>> numbers
OrderedDict([('one', 1.0), ('two', 2), ('three', 3)])

>>> numbers.update(two=2.0)
>>> numbers
OrderedDict([('one', 1.0), ('two', 2.0), ('three', 3)])

如果在有序字典中更新给定键的值,那么该键不会被移动,而是被赋予新的值。同样,如果您使用.update()来修改一个现有的键-值对的值,那么字典会记住键的位置,并将更新后的值赋给它。

迭代一个OrderedDict

就像普通的字典一样,你可以使用几种工具和技术通过一个对象OrderedDict迭代。可以直接迭代键,也可以使用字典方法,比如.items().keys().values():

>>> from collections import OrderedDict
>>> numbers = OrderedDict(one=1, two=2, three=3)

>>> # Iterate over the keys directly
>>> for key in numbers:
...     print(key, "->", numbers[key])
...
one -> 1
two -> 2
three -> 3

>>> # Iterate over the items using .items()
>>> for key, value in numbers.items():
...     print(key, "->", value)
...
one -> 1
two -> 2
three -> 3

>>> # Iterate over the keys using .keys()
>>> for key in numbers.keys():
...     print(key, "->", numbers[key])
...
one -> 1
two -> 2
three -> 3

>>> # Iterate over the values using .values()
>>> for value in numbers.values():
...     print(value)
...
1
2
3

第一个 for循环直接迭代numbers的键。其他三个循环使用字典方法来迭代numbers的条目、键和值。

reversed()和逆序迭代

Python 3.5 开始,OrderedDict提供的另一个重要特性是,它的项、键和值支持使用 reversed() 的反向迭代。这个特性被添加到了 Python 3.8 的常规字典中。因此,如果您的代码使用它,那么您的向后兼容性会受到普通字典的更多限制。

您可以将reversed()OrderedDict对象的项目、键和值一起使用:

>>> from collections import OrderedDict
>>> numbers = OrderedDict(one=1, two=2, three=3)

>>> # Iterate over the keys directly in reverse order
>>> for key in reversed(numbers):
...     print(key, "->", numbers[key])
...
three -> 3
two -> 2
one -> 1

>>> # Iterate over the items in reverse order
>>> for key, value in reversed(numbers.items()):
...     print(key, "->", value)
...
three -> 3
two -> 2
one -> 1

>>> # Iterate over the keys in reverse order
>>> for key in reversed(numbers.keys()):
...     print(key, "->", numbers[key])
...
three -> 3
two -> 2
one -> 1

>>> # Iterate over the values in reverse order
>>> for value in reversed(numbers.values()):
...     print(value)
...
3
2
1

本例中的每个循环都使用reversed()以逆序遍历有序字典中的不同元素。

常规词典也支持反向迭代。然而,如果您试图在低于 3.8 的 Python 版本中对常规的dict对象使用reversed(),那么您会得到一个TypeError:

Python 3.7.9 (default, Jan 14 2021, 11:41:20)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> numbers = dict(one=1, two=2, three=3)

>>> for key in reversed(numbers):
...     print(key)
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'dict' object is not reversible

如果需要逆序遍历字典中的条目,那么OrderedDict是一个很好的盟友。使用常规字典极大地降低了向后兼容性,因为直到 Python 3.8,反向迭代才被添加到常规字典中。

Remove ads

探索 Python 的OrderedDict 的独特功能

从 Python 3.6 开始,常规字典按照插入底层字典的顺序保存条目。正如你到目前为止所看到的,这限制了OrderedDict的有用性。然而,OrderedDict提供了一些你在常规的dict对象中找不到的独特特性。

使用有序字典,您可以访问以下额外的和增强的方法:

OrderedDictdict在进行相等性测试时也表现不同。具体来说,当您比较有序字典时,条目的顺序很重要。正规词典就不是这样了。

最后,OrderedDict实例提供了一个名为 .__dict__ 的属性,这是你在常规字典实例中找不到的。此属性允许您向现有有序字典添加自定义可写属性。

.move_to_end()和重新排序项目

dictOrderedDict最显著的区别之一是后者有一个额外的方法叫做.move_to_end()。这种方法允许您将现有的条目移动到底层字典的末尾或开头,因此这是一个重新排序字典的好工具。

当您使用.move_to_end()时,您可以提供两个参数:

  1. key 持有标识您要移动的项目的键。如果key不存在,那么你得到一个 KeyError

  2. last 保存一个布尔值,该值定义了您想要将手头的项目移动到词典的哪一端。它默认为True,这意味着该项目将被移动到词典的末尾或右侧。False表示该条目将被移到有序字典的前面或左侧。

下面是一个如何使用带有key参数的.move_to_end()并依赖于默认值last的例子:

>>> from collections import OrderedDict
>>> numbers = OrderedDict(one=1, two=2, three=3)
>>> numbers
OrderedDict([('one', 1), ('two', 2), ('three', 3)])

>>> numbers.move_to_end("one")
>>> numbers
OrderedDict([('two', 2), ('three', 3), ('one', 1)])

当您用一个key作为参数调用.move_to_end()时,您将手头的键-值对移动到字典的末尾。这就是为什么('one', 1)现在处于最后的位置。请注意,其余项目仍保持原来的顺序。

如果您将False传递到last,那么您将该项目移动到开头:

>>> numbers.move_to_end("one", last=False)
>>> numbers
OrderedDict([('one', 1), ('two', 2), ('three', 3)])

在这种情况下,您将('one', 1)移动到字典的开头。这提供了一个有趣而强大的特性。例如,使用.move_to_end(),您可以按关键字对有序字典进行排序:

>>> from collections import OrderedDict
>>> letters = OrderedDict(b=2, d=4, a=1, c=3)

>>> for key in sorted(letters):
...     letters.move_to_end(key)
...
>>> letters
OrderedDict([('a', 1), ('b', 2), ('c', 3), ('d', 4)])

在本例中,首先创建一个有序字典lettersfor循环遍历其排序后的键,并将每一项移动到字典的末尾。当循环结束时,有序字典的条目按键排序。

按值对字典排序将是一个有趣的练习,所以扩展下面的块并尝试一下吧!

按值对以下字典进行排序:

>>> from collections import OrderedDict
>>> letters = OrderedDict(a=4, b=3, d=1, c=2)

作为实现解决方案的有用提示,考虑使用 lambda函数

您可以展开下面的方框,查看可能的解决方案。

您可以使用一个lambda函数来检索letters中每个键值对的值,并使用该函数作为sorted()key参数:

>>> for key, _ in sorted(letters.items(), key=lambda item: item[1]):
...     letters.move_to_end(key)
...
>>> letters
OrderedDict([('d', 1), ('c', 2), ('b', 3), ('a', 4)])

在这段代码中,您使用了一个lambda函数,该函数返回letters中每个键值对的值。对sorted()的调用使用这个lambda函数从输入 iterable,letters.items()的每个元素中提取一个比较键。然后你用.move_to_end()排序letters

太好了!现在,您知道如何使用.move_to_end()对有序的字典进行重新排序。你已经准备好进入下一部分了。

Remove ads

移除带有.popitem()和的项目

OrderedDict另一个有趣的特点是它的增强版.popitem()。默认情况下,.popitem()按照 LIFO (后进先出)的顺序移除并返回一个项目。换句话说,它从有序字典的右端删除项目:

>>> from collections import OrderedDict
>>> numbers = OrderedDict(one=1, two=2, three=3)

>>> numbers.popitem()
('three', 3)
>>> numbers.popitem()
('two', 2)
>>> numbers.popitem()
('one', 1)
>>> numbers.popitem()
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    numbers.popitem()
KeyError: 'dictionary is empty'

在这里,您使用.popitem()删除numbers中的所有项目。每次调用此方法都会从基础字典的末尾移除一项。如果你在一个空字典上调用.popitem(),那么你得到一个KeyError。到目前为止,.popitem()的行为和普通字典中的一样。

然而在OrderedDict中,.popitem()也接受一个名为last的布尔参数,默认为True。如果您将last设置为False,那么.popitem()将按照 FIFO (先进/先出)的顺序移除条目,这意味着它将从字典的开头移除条目:

>>> from collections import OrderedDict
>>> numbers = OrderedDict(one=1, two=2, three=3)

>>> numbers.popitem(last=False)
('one', 1)
>>> numbers.popitem(last=False)
('two', 2)
>>> numbers.popitem(last=False)
('three', 3)
>>> numbers.popitem(last=False)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    numbers.popitem(last=False)
KeyError: 'dictionary is empty'

last设置为True时,您可以使用.popitem()从有序字典的开头移除和返回条目。在本例中,对.popitem()的最后一次调用引发了一个KeyError,因为底层字典已经为空。

测试字典之间的相等性

当您在布尔上下文中测试两个OrderedDict对象的相等性时,项目的顺序起着重要的作用。例如,如果您的有序字典包含相同的项目集,则测试结果取决于它们的顺序:

>>> from collections import OrderedDict
>>> letters_0 = OrderedDict(a=1, b=2, c=3, d=4)
>>> letters_1 = OrderedDict(b=2, a=1, c=3, d=4)
>>> letters_2 = OrderedDict(a=1, b=2, c=3, d=4)

>>> letters_0 == letters_1
False

>>> letters_0 == letters_2
True

在这个例子中,letters_1letters_0letters_2相比,其条目的顺序略有不同,所以第一个测试返回False。在第二个测试中,letters_0letters_2有相同的一组项目,它们的顺序相同,所以测试返回True

如果你用普通字典尝试同样的例子,你会得到不同的结果:

>>> letters_0 = dict(a=1, b=2, c=3, d=4)
>>> letters_1 = dict(b=2, a=1, c=3, d=4)
>>> letters_2 = dict(a=1, b=2, c=3, d=4)

>>> letters_0 == letters_1
True

>>> letters_0 == letters_2
True

>>> letters_0 == letters_1 == letters_2
True

在这里,当您测试两个常规字典的相等性时,如果两个字典有相同的条目集,您会得到True。在这种情况下,项目的顺序不会改变最终结果。

最后,OrderedDict对象和常规字典之间的相等测试不考虑条目的顺序:

>>> from collections import OrderedDict
>>> letters_0 = OrderedDict(a=1, b=2, c=3, d=4)
>>> letters_1 = dict(b=2, a=1, c=3, d=4)

>>> letters_0 == letters_1
True

当您比较有序词典和常规词典时,条目的顺序并不重要。如果两个字典有相同的条目集,那么无论条目的顺序如何,它们都进行同等的比较。

向字典实例追加新属性

OrderedDict对象有一个.__dict__属性,你在常规字典对象中找不到。看一下下面的代码:

>>> from collections import OrderedDict
>>> letters = OrderedDict(b=2, d=4, a=1, c=3)
>>> letters.__dict__
{}

>>> letters1 = dict(b=2, d=4, a=1, c=3)
>>> letters1.__dict__
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    letters1.__dict__
AttributeError: 'dict' object has no attribute '__dict__'

在第一个例子中,您访问有序字典letters上的.__dict__属性。Python 内部使用这个属性来存储可写的实例属性。第二个例子显示常规字典对象没有.__dict__属性。

您可以使用有序字典的.__dict__属性来存储动态创建的可写实例属性。有几种方法可以做到这一点。例如,您可以使用字典风格的赋值,就像在ordered_dict.__dict__["attr"] = value中一样。你也可以使用点符号,就像在ordered_dict.attr = value中一样。

下面是一个使用.__dict__将新函数附加到现有有序字典的例子:

>>> from collections import OrderedDict
>>> letters = OrderedDict(b=2, d=4, a=1, c=3)

>>> letters.sorted_keys = lambda: sorted(letters.keys())
>>> vars(letters)
{'sorted_keys': <function <lambda> at 0x7fa1e2fe9160>}

>>> letters.sorted_keys()
['a', 'b', 'c', 'd']

>>> letters["e"] = 5
>>> letters.sorted_keys()
['a', 'b', 'c', 'd', 'e']

现在你有了一个.sorted_keys() lambda函数附加到你的letters命令字典上。请注意,您可以通过直接使用点符号或使用 vars() 来检查.__dict__的内容。

**注意:**这种动态属性被添加到给定类的特定实例中。在上面的例子中,那个实例是letters。这既不影响其他实例,也不影响类本身,所以您只能通过letters访问.sorted_keys()

您可以使用这个动态添加的函数按照排序顺序遍历字典键,而不改变letters中的原始顺序:

>>> for key in letters.sorted_keys():
...     print(key, "->", letters[key])
...
a -> 1
b -> 2
c -> 3
d -> 4
e -> 5

>>> letters
OrderedDict([('b', 2), ('d', 4), ('a', 1), ('c', 3), ('e', 5)])

这只是一个例子,说明了OrderedDict的这个特性有多有用。请注意,您不能用普通词典做类似的事情:

>>> letters = dict(b=2, d=4, a=1, c=3)
>>> letters.sorted_keys = lambda: sorted(letters.keys())
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    letters.sorted_keys = lambda: sorted(letters.keys())
AttributeError: 'dict' object has no attribute 'sorted_keys'

如果您尝试向常规字典动态添加定制实例属性,那么您会得到一个AttributeError消息,告诉您底层字典手头没有该属性。这是因为常规字典没有一个.__dict__属性来保存新的实例属性。

Remove ads

用运算符合并和更新字典

Python 3.9 给字典空间增加了两个新的操作符。现在你有了合并 ( |)和更新 ( |=)字典操作符。这些操作符也处理OrderedDict实例:

Python 3.9.0 (default, Oct  5 2020, 17:52:02)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from collections import OrderedDict

>>> physicists = OrderedDict(newton="1642-1726", einstein="1879-1955")
>>> biologists = OrderedDict(darwin="1809-1882", mendel="1822-1884")

>>> scientists = physicists | biologists
>>> scientists
OrderedDict([
 ('newton', '1642-1726'),
 ('einstein', '1879-1955'),
 ('darwin', '1809-1882'),
 ('mendel', '1822-1884')
])

顾名思义,merge 操作符将两个字典合并成一个包含两个初始字典的条目的新字典。如果表达式中的字典有公共键,那么最右边的字典的值将优先。

当您有一个字典并且想要更新它的一些值而不调用.update()时,update 操作符很方便:

>>> physicists = OrderedDict(newton="1642-1726", einstein="1879-1955")

>>> physicists_1 = OrderedDict(newton="1642-1726/1727", hawking="1942-2018")
>>> physicists |= physicists_1
>>> physicists
OrderedDict([
 ('newton', '1642-1726/1727'),
 ('einstein', '1879-1955'),
 ('hawking', '1942-2018')
])

在这个例子中,您使用字典更新操作符来更新牛顿的寿命信息。操作员就地更新字典。如果提供更新数据的字典有新的键,那么这些键将被添加到原始字典的末尾。

考虑性能

性能是编程中的一个重要课题。了解算法运行的速度或它使用的内存是人们普遍关心的问题。OrderedDict最初是用 Python 编写的,然后用 C 编写的,以最大化其方法和操作的效率。这两个实现目前在标准库中都可用。然而,如果 C 实现由于某种原因不可用,Python 实现可以作为一种替代。

OrderedDict的两个实现都涉及到使用一个双向链表来捕获条目的顺序。尽管有些操作有线性时间,但OrderedDict中的链表实现被高度优化,以保持相应字典方法的快速时间。也就是说,有序字典上的操作是 O (1) ,但是与常规字典相比具有更大的常数因子。

总的来说,OrderedDict的性能比一般的字典要低。下面是一个测量两个字典类上几个操作的执行时间的例子:

# time_testing.py

from collections import OrderedDict
from time import perf_counter

def average_time(dictionary):
    time_measurements = []
    for _ in range(1_000_000):
        start = perf_counter()
        dictionary["key"] = "value"
        "key" in dictionary
        "missing_key" in dictionary
        dictionary["key"]
        del dictionary["key"]
        end = perf_counter()
        time_measurements.append(end - start)
    return sum(time_measurements) / len(time_measurements) * int(1e9)

ordereddict_time = average_time(OrderedDict.fromkeys(range(1000)))
dict_time = average_time(dict.fromkeys(range(1000)))
gain = ordereddict_time / dict_time

print(f"OrderedDict: {ordereddict_time:.2f} ns")
print(f"dict: {dict_time:.2f} ns ({gain:.2f}x faster)")

在这个脚本中,您将计算在给定的字典上运行几个常见操作所需的average_time()for循环使用 time.pref_counter() 来衡量一组操作的执行时间。该函数返回运行所选操作集所需的平均时间(以纳秒为单位)。

**注意:**如果你有兴趣知道其他方法来计时你的代码,那么你可以看看 Python 计时器函数:三种方法来监控你的代码

如果您从命令行运行这个脚本,那么您会得到类似如下的输出:

$ python time_testing.py
OrderedDict: 272.93 ns
dict:        197.88 ns (1.38x faster)

正如您在输出中看到的,对dict对象的操作比对OrderedDict对象的操作快。

关于内存消耗,OrderedDict实例必须支付存储成本,因为它们的键列表是有序的。这里有一个脚本可以让您了解这种内存开销:

>>> import sys
>>> from collections import OrderedDict

>>> ordereddict_memory = sys.getsizeof(OrderedDict.fromkeys(range(1000)))
>>> dict_memory = sys.getsizeof(dict.fromkeys(range(1000)))
>>> gain = 100 - dict_memory / ordereddict_memory * 100

>>> print(f"OrderedDict: {ordereddict_memory} bytes")
OrderedDict: 85408 bytes

>>> print(f"dict: {dict_memory} bytes ({gain:.2f}% lower)")
dict:        36960 bytes (56.73% lower)

在这个例子中,您使用 sys.getsizeof() 来测量两个字典对象的内存占用量(以字节为单位)。在输出中,您可以看到常规字典比其对应的OrderedDict占用更少的内存。

Remove ads

为工作选择正确的词典

到目前为止,你已经了解了OrderedDictdict之间的细微差别。您已经了解到,尽管从 Python 3.6 开始,常规字典已经是有序的数据结构,但是使用OrderedDict仍然有一些价值,因为有一组有用的特性是dict中没有的。

下面总结了这两个类更相关的差异和特性,在您决定使用哪一个时应该加以考虑:

特征OrderedDictdict
保持钥匙插入顺序是(从 Python 3.1 开始)是(从 Python 3.6 开始)
关于项目顺序的可读性和意图信号高的低的
对项目顺序的控制高(.move_to_end(),增强型.popitem())低(需要移除和重新插入项目)
运营绩效低的高的
内存消耗高的低的
相等测试考虑项目的顺序
支持反向迭代是(从 Python 3.5 开始)是(从 Python 3.8 开始)
能够附加新的实例属性是(.__dict__属性)
支持合并(&#124;)和更新(&#124;=)字典操作符是(从 Python 3.9 开始)是(从 Python 3.9 开始)

这个表格总结了OrderedDictdict之间的一些主要区别,当您需要选择一个字典类来解决一个问题或者实现一个特定的算法时,您应该考虑这些区别。一般来说,如果字典中条目的顺序对于代码的正确运行至关重要,那么你首先应该看一看OrderedDict

构建基于字典的队列

您应该考虑使用OrderedDict对象而不是dict对象的一个用例是,当您需要实现基于字典的队列时。队列是以 FIFO 方式管理其项目的常见且有用的数据结构。这意味着您在队列的末尾推入新的项目,而旧的项目从队列的开头弹出。

通常,队列实现一个操作来将一个项目添加到它们的末尾,这被称为入队操作。队列还实现了一个从其开始处移除项目的操作,这就是所谓的出列操作。

要创建基于字典的队列,启动您的代码编辑器或 IDE ,创建一个名为queue.py的新 Python 模块,并向其中添加以下代码:

# queue.py

from collections import OrderedDict

class Queue:
    def __init__(self, initial_data=None, /, **kwargs):
        self.data = OrderedDict()
        if initial_data is not None:
            self.data.update(initial_data)
        if kwargs:
            self.data.update(kwargs)

    def enqueue(self, item):
        key, value = item
        if key in self.data:
            self.data.move_to_end(key)
        self.data[key] = value

    def dequeue(self):
        try:
            return self.data.popitem(last=False)
        except KeyError:
            print("Empty queue")

    def __len__(self):
        return len(self.data)

    def __repr__(self):
        return f"Queue({self.data.items()})"

Queue中,首先初始化一个名为.data实例属性。这个属性包含一个空的有序字典,您将使用它来存储数据。类初始化器采用第一个可选参数initial_data,允许您在实例化类时提供初始数据。初始化器还带有可选的关键字参数( kwargs ),允许您在构造函数中使用关键字参数。

然后编写.enqueue(),它允许您将键值对添加到队列中。在这种情况下,如果键已经存在,就使用.move_to_end(),对新键使用普通赋值。注意,为了让这个方法工作,您需要提供一个两项的tuplelist以及一个有效的键-值对。

.dequeue()实现使用.popitem()和设置为Falselast从底层有序字典.data的开始移除和返回条目。在这种情况下,您使用一个 tryexcept来处理在空字典上调用.popitem()时发生的KeyError

特殊方法 .__len__() 提供了检索内部有序字典.data长度所需的功能。最后,当您将数据结构打印到屏幕上时,特殊的方法 .__repr__() 提供了队列的用户友好的字符串表示

以下是一些如何使用Queue的例子:

>>> from queue import Queue

>>> # Create an empty queue
>>> empty_queue = Queue()
>>> empty_queue
Queue(odict_items([]))

>>> # Create a queue with initial data
>>> numbers_queue = Queue([("one", 1), ("two", 2)])
>>> numbers_queue
Queue(odict_items([('one', 1), ('two', 2)]))

>>> # Create a queue with keyword arguments
>>> letters_queue = Queue(a=1, b=2, c=3)
>>> letters_queue
Queue(odict_items([('a', 1), ('b', 2), ('c', 3)]))

>>> # Add items
>>> numbers_queue.enqueue(("three", 3))
>>> numbers_queue
Queue(odict_items([('one', 1), ('two', 2), ('three', 3)]))

>>> # Remove items
>>> numbers_queue.dequeue()
('one', 1)
>>> numbers_queue.dequeue()
('two', 2)
>>> numbers_queue.dequeue()
('three', 3)
>>> numbers_queue.dequeue()
Empty queue

在这个代码示例中,首先使用不同的方法创建三个不同的Queue对象。然后使用.enqueue()numbers_queue的末尾添加一个条目。最后,你多次调用.dequeue()来移除numbers_queue中的所有物品。请注意,对.dequeue()的最后一个调用将一条消息打印到屏幕上,通知您队列已经为空。

结论

多年来,Python 字典都是无序的数据结构。这揭示了对有序字典的需求,在项目的顺序很重要的情况下,有序字典会有所帮助。所以 Python 开发者创造了 OrderedDict ,它是专门为保持其条目有序而设计的。

Python 3.6 在常规词典中引入了一个新特性。现在他们还记得物品的顺序。有了这个补充,大多数 Python 程序员想知道他们是否还需要考虑使用OrderedDict

在本教程中,您学习了:

  • 如何在代码中创建和使用 OrderedDict对象
  • OrderedDictdict之间的主要差异是什么
  • 使用OrderedDict vs dict好处坏处是什么

现在,如果您的代码需要一个有序的字典,您可以更好地决定是使用dict还是OrderedDict

在本教程中,您编写了一个如何实现基于字典的队列的示例,这是一个用例,表明OrderedDict在您的日常 Python 编码冒险中仍然有价值。******

Python 包:五个真正的 Python 最爱

原文:https://realpython.com/python-packages/

Python 有一个由包、模块和库组成的庞大生态系统,您可以用它来创建您的应用程序。其中一些包和模块包含在您的 Python 安装中,统称为标准库

标准库由为常见编程问题提供标准化解决方案的模块组成。它们是跨许多学科的应用程序的重要组成部分。然而,许多开发人员更喜欢使用替代包,或扩展,这可能会提高标准库中内容的可用性和有用性。

在本教程中,您将在 Real Python 见到一些作者,并了解他们喜欢用哪些包来代替标准库中更常见的包。

您将在本教程中了解的软件包有:

  • pudb :一个基于文本的高级可视化调试器
  • requests :一个漂亮的 HTTP 请求 API
  • parse :直观、易读的文本匹配器
  • dateutil :热门datetime库的扩展
  • typer :直观的命令行界面解析器

首先,你将看到一个视觉上强大的pdb的替代品。

免费下载: 从 Python 技巧中获取一个示例章节:这本书用简单的例子向您展示了 Python 的最佳实践,您可以立即应用它来编写更漂亮的+Python 代码。

pudb进行可视化调试

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Christopher Trudeau是 Real Python 的作者和课程创建者。在工作中,他是一名顾问,帮助组织改善他们的技术团队。在家里,他花时间玩棋类游戏和摄影。

我花了很多时间将隐藏到远程机器中,所以我不能利用大多数ide。我选择的调试器是 pudb ,它有一个基于文本的用户界面。我觉得它的界面直观易用。

Python 搭载 pdb ,其灵感来源于 gdb ,其本身的灵感来源于 dbx 。虽然pdb完成了这项工作,但它最大的优势在于它搭载了 Python。因为它是基于命令行的,所以你必须记住很多快捷键,而且一次只能看到少量的源代码。

用于调试的另一个 Python 包是pudb。它显示了整个源代码屏幕以及有用的调试信息。它还有一个额外的好处,那就是让我怀念过去我编写涡轮帕斯卡(T2)代码的日子:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

该界面分为两个主要部分。左侧面板用于源代码,右侧面板用于上下文信息。右侧分为三个部分:

  1. 变量
  2. 断点

您在调试器中需要的一切都可以在一个屏幕上找到。

Remove ads

pudb 互动

可以通过 pip 安装pudb:

$ python -m pip install pudb

如果您使用的是 Python 3.7 或更高版本,那么您可以通过将PYTHONBREAKPOINT环境变量设置为pudb.set_trace来利用 breakpoint() 。如果您使用的是基于 Unix 的操作系统,比如 Linux 或 macOS,那么您可以按如下方式设置变量:

$ export PYTHONBREAKPOINT=pudb.set_trace

如果您基于 Windows,命令会有所不同:

C:\> set PYTHONBREAKPOINT=pudb.set_trace

或者,您可以将import pudb; pudb.set_trace()直接插入到代码中。

当您运行的代码遇到断点时,pudb中断执行并显示其接口:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

您可以使用键盘导航和执行源代码:

钥匙行动
UpK将代码上移一行
DownJ将代码下移一行
Page UpCtrl + B向上滚动代码页
Page DownCtrl + F向下滚动代码页
T2N执行当前行
T2S如果是函数,则进入当前行
T2C继续执行到下一个断点

如果你重启你的代码,那么pudb会记住前一个会话的断点。 RightLeft 允许你在源代码和右边的控制区之间移动。

变量框中,您可以看到当前范围内的所有变量:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

默认情况下,变量的视图会被缩短,但您可以通过按 \ 来查看完整的内容。展开视图将显示元组或列表中的项目,或者显示二进制变量的完整内容。T``Rreprtype显示模式之间来回切换。

使用观察表达式和访问 REPL

当右侧的变量区域被聚焦时,您还可以添加一个观察表达式。手表可以是任何 Python 表达式。这对于在对象仍处于缩短形式时检查深藏在对象中的数据或评估变量之间的复杂关系非常有用。

**注意:**通过按 N 添加一个手表表情。由于 N 也用于执行当前代码行,所以在按键之前,必须确保屏幕的右侧区域处于焦点上。

按下 ! 可以跳出当前运行程序的 REPL。此模式还显示调试器触发之前发送到屏幕的任何输出。通过导航界面或使用快捷键,您还可以修改断点、更改您在堆栈框架中的位置以及加载其他源代码文件。

Remove ads

为什么pudb很牛逼

pudb界面比pdb需要更少的快捷键记忆,并且被设计成显示尽可能多的代码。它拥有在 IDEs 中发现的调试器的大部分功能,但是可以在终端中使用。由于安装这个 Python 包只需要很短的调用pip就可以了,你可以很快地把它带到任何环境中。下一次当你被困在命令行时,看看吧!

requests用于与网络互动

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

马丁·布鲁斯 是 Real Python 的作者和课程创建者。他在 CodingNomads 担任编程教师,在那里教授训练营和在线课程。工作之余,他喜欢航海、散步和录制随机声音。

我从标准库之外挑选的第一个 Python 包是流行的requests包。它在我的电脑上有着特殊的地位,因为它是我在系统范围内安装的唯一的外部包。所有其他软件包都存在于它们专用的虚拟环境中。

我不是唯一一个喜欢将requests作为 Python web 交互的主要工具的人:根据requests 文档,这个包每天有大约 160 万次下载*!

这个数字如此之高是因为与互联网的程序交互提供了许多可能性,无论是通过的网络 API 发布你的作品,还是通过的网络抓取获取数据。但是 Python 的标准库已经包含了urllib包来帮助完成这些任务。那么为什么要用外包呢?是什么让requests成为如此受欢迎的选择?

requests可读

requests库提供了一个开发良好的 API,它紧跟 Python 的目标,即像普通英语一样可读。开发人员在他们的口号“人类的 HTTP”中总结了这个想法

您可以使用pip在电脑上安装requests:

$ python -m pip install requests

让我们通过使用它来访问网站上的文本,来探索一下requests是如何保持可读性的。当你用你可信赖的浏览器处理这个任务时,你应该遵循以下步骤:

  1. 打开浏览器。
  2. 输入网址。
  3. 看网站的文字。

你如何用代码达到同样的结果?首先,您在伪代码中规划必要的步骤:

  1. 导入您需要的工具。
  2. 获取网站的数据。
  3. 打印网站的文本。

阐明逻辑后,您使用requests库将伪代码翻译成 Python :

>>> import requests
>>> response = requests.get("http://www.example.com")
>>> response.text

代码读起来几乎像英语,简洁明了。虽然使用标准库的urllib包构建这个基本示例并不难,但是requests即使在更复杂的场景中也能保持其简单明了、以人为中心的语法。

在下一个例子中,您将看到只用几行 Python 代码就可以完成很多事情。

Remove ads

requests是强大的

让我们加快游戏速度,挑战requests更复杂的任务:

  1. 登录您的 GitHub 帐户。
  2. 持久化登录信息以处理多个请求。
  3. 创建新的存储库。
  4. 创建一个包含一些内容的新文件。
  5. 仅当第一个请求成功时,才运行第二个请求。

挑战已接受并完成!下面的代码片段完成了上述所有任务。您需要做的就是分别用您的 GitHub 用户名和个人访问令牌替换两个字符串 "YOUR_GITHUB_USERNAME""YOUR_GITHUB_TOKEN"

**注意:**到创建个人访问令牌,点击生成新令牌,选择回购范围。复制生成的令牌,并使用它与您的用户名一起进行身份验证。

阅读下面的代码片段,将其复制并保存到您自己的 Python 脚本中,填写您的凭证,并运行它以查看requests的运行情况:

import requests

session = requests.Session()
session.auth = ("YOUR_GITHUB_USERNAME", "YOUR_GITHUB_TOKEN")
payload = {
    "name": "test-requests",
    "description": "Created with the requests library"
}
api_url ="https://api.github.com/user/repos"
response_1 = session.post(api_url, json=payload)
if response_1:
    data = {
            "message": "Add README via API",
            # The 'content' needs to be a base64 encoded string
            # Python's standard library can help with that
            # You can uncover the secret of this garbled string
            # by uploading it to GitHub with this script :)
            "content": "UmVxdWVzdHMgaXMgYXdlc29tZSE="
    }
    repo_url = response_1.json()["url"]
    readme_url = f"{repo_url}/contents/README.md"
    response_2 = session.put(readme_url, json=data)
else:
    print(response_1.status_code, response_1.json())

html_url = response_2.json()["content"]["html_url"]
print(f"See your repo live at: {html_url}")
session.close()

运行完代码后,继续前进并导航到它最后打印出来的链接。您将看到在您的 GitHub 帐户上创建了一个新的存储库。新的存储库包含一个带有一些文本的README.md文件,所有这些都是用这个脚本生成的。

**注意:**您可能已经注意到代码只认证一次,但是仍然能够发送多个请求。这是可能的,因为requests.Session对象允许您在多个请求中保存信息。

如您所见,上面的简短代码片段完成了很多工作,并且仍然易于理解。

为什么requests很牛逼

Python 的 request是 Python 使用最广泛的外部库之一,因为它是一个可读的、可访问的、强大的与 Web 交互的工具。要了解更多关于使用requests的可能性,请查看用 Python 制作 HTTP 请求的

parse用于匹配字符串

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

盖尔阿恩 Hjelle 是 Real Python 的作者和评论家。他在挪威奥斯陆担任数据科学顾问,当他的分析涉及到地图和图像时,他特别高兴。除了键盘,盖尔·阿恩喜欢玩棋盘游戏、吊床和漫无目的地走进森林。

我喜欢正则表达式的力量。使用一个正则表达式,或者正则表达式,你可以在给定的字符串中搜索几乎任何模式。然而,强大的能力带来了巨大的复杂性!构建一个正则表达式可能需要反复试验,理解一个给定正则表达式的微妙之处可能更难。

parse 是一个库,它包含了正则表达式的大部分功能,但使用了更清晰、或许更熟悉的语法。简而言之,parse就是的 f 弦反过来。您可以使用与格式化字符串基本相同的表达式来搜索和解析字符串。让我们看看它在实践中是如何工作的!

查找匹配给定模式的字符串

您需要一些想要解析的文本。在这些例子中,我们将使用最初的 f 弦规范 PEP 498pepdocs 是一个可以下载 Python 增强提案(PEP)文档文本的小工具。

PyPI 安装parsepepdocs:

$ python -m pip install parse pepdocs

要开始使用,请下载 PEP 498:

>>> import pepdocs
>>> pep498 = pepdocs.get(498)

例如,使用parse你可以找到 PEP 498 的作者:

>>> import parse
>>> parse.search("Author: {}\n", pep498)
<Result ('Eric V. Smith <eric@trueblade.com>',) {}>

parse.search()搜索一个模式,在本例中是给定字符串中的任意位置"Author: {}\n"。您也可以使用parse.parse(),它将模式匹配到完整的*字符串。类似于 f 字符串,您使用花括号({})来表示您想要解析的变量。

虽然您可以使用空的花括号,但大多数情况下,您希望在搜索模式中添加名称。你可以将 PEP 498 作者 Eric V. Smith 的姓名和电子邮件地址拆分如下:

>>> parse.search("Author: {name} <{email}>", pep498)
<Result () {'name': 'Eric V. Smith', 'email': 'eric@trueblade.com'}>

这将返回一个带有匹配信息的Result对象。您可以通过.fixed.named.spans访问您搜索的所有结果。您也可以使用[]来获取单个值:

>>> result = parse.search("Author: {name} <{email}>", pep498)
>>> result.named
{'name': 'Eric V. Smith', 'email': 'eric@trueblade.com'}

>>> result["name"]
'Eric V. Smith'

>>> result.spans
{'name': (95, 108), 'email': (110, 128)}

>>> pep498[110:128]
'eric@trueblade.com'

给你字符串中匹配你的模式的索引。

Remove ads

使用格式说明符

你可以用parse.findall()找到一个模式的所有匹配。尝试找出 PEP 498 中提到的其他 PEP:

>>> [result["num"] for result in parse.findall("PEP {num}", pep498)]
['p', 'd', '2', '2', '3', 'i', '3', 'r', ..., 't', '4', 'i', '4', '4']

嗯,看起来没什么用。pep 用数字表示。因此,您可以使用格式语法来指定您要查找的数字:

>>> [result["num"] for result in parse.findall("PEP {num:d}", pep498)]
[215, 215, 3101, 3101, 461, 414, 461]

添加:d告诉parse你正在寻找一个整数。作为奖励,结果甚至从字符串转换成数字。除了:d,你可以使用 f 字符串使用的大部分格式说明符

您还可以使用特殊的双字符规范来解析日期:

>>> parse.search("Created: {created:tg}\n", pep498)
<Result () {'created': datetime.datetime(2015, 8, 1, 0, 0)}>

:tg查找写为日/月/年的日期。如果顺序或格式不同,您可以使用:ti:ta,以及几个其他选项

访问底层正则表达式

parse是建立在 Python 之上的正则表达式库, re 。每次你做一个搜索,parse会在引擎盖下构建相应的正则表达式。如果您需要多次执行相同的搜索,那么您可以使用parse.compile预先构建一次正则表达式。

以下示例打印出 PEP 498 中对其他文档引用的所有描述:

>>> references_pattern = parse.compile(".. [#] {reference}") >>> for line in pep498.splitlines():
...     if result := references_pattern.parse(line):
...         print(result["reference"])
...
%-formatting
str.format
[ ... ]
PEP 461 rejects bytes.format()

该循环使用 Python 3.8 和更高版本中可用的 walrus 操作符,根据提供的模板测试每一行。您可以查看编译后的模式,了解隐藏在您新发现的解析功能背后的正则表达式:

>>> references_pattern._expression
'\\.\\. \\[#\\] (?P<reference>.+?)'

最初的parse模式".. [#] {reference}",对于读和写都更简单。

为什么parse很牛逼

正则表达式显然是有用的。然而,厚书已经被用来解释正则表达式的微妙之处。是一个小型的库,提供了正则表达式的大部分功能,但是语法更加友好。

如果你比较一下".. [#] {reference}""\\.\\. \\[#\\] (?P<reference>.+?)",你就会明白为什么我更喜欢parse而不是正则表达式的力量。

dateutil用于处理日期和时间

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

布莱恩·韦伯 是 Real Python 的作者和评论者,也是机械工程的教授。当他不写 Python 或者不教书的时候,他很可能会做饭,和家人一起玩,或者去远足,如果运气好的话,三者兼而有之。

如果你曾经不得不用时间进行编程,那么你就会知道它会给你带来的错综复杂的麻烦。首先,你必须处理好时区,在任何给定的时刻,地球上两个不同的点将会有不同的时间。然后是夏令时,一年两次的事件,一个小时要么发生两次,要么根本不发生,但只在某些国家发生。

你还必须考虑闰年和闰秒,以保持人类时钟与地球绕太阳公转同步。你必须围绕千年虫千年虫进行编程。这个清单还在继续。

**注:**如果你想继续深入这个兔子洞,那么我强烈推荐时间的问题&时区,这是一个由精彩搞笑的汤姆·斯科特制作的视频,解释了时间难以处理的一些方式。

幸运的是,Python 在标准库中包含了一个真正有用的模块,叫做 datetime 。Python 的datetime是存储和访问日期和时间信息的好方法。然而,datetime有一些地方的界面不是很好。

作为回应,Python 的 awesome 社区已经开发了几个不同的库和 API,以一种明智的方式处理日期和时间。这些有的是对内置datetime的扩展,有的是完全的替代。我最喜欢的图书馆是 dateutil

按照下面的例子,像这样安装dateutil:

$ python -m pip install python-dateutil

现在您已经安装了dateutil,接下来几节中的例子将向您展示它有多强大。您还将看到dateutil如何与datetime互动。

Remove ads

设置时区

有几个有利因素。首先,Python 文档中的推荐的是对datetime的补充,用于处理时区和夏令时:

>>> from dateutil import tz
>>> from datetime import datetime
>>> london_now = datetime.now(tz=tz.gettz("Europe/London"))
>>> london_now.tzname()  # 'BST' in summer and 'GMT' in winter
'BST'

但是dateutil能做的远不止提供一个具体的tzinfo实例。这确实是幸运的,因为在 Python 3.9 之后,Python 标准库将拥有自己访问 IANA 数据库的能力。

解析日期和时间字符串

dateutil使得使用 parser 模块将字符串解析成datetime实例变得更加简单:

>>> from dateutil import parser
>>> parser.parse("Monday, May 4th at 8am")  # May the 4th be with you!
datetime.datetime(2020, 5, 4, 8, 0)

注意dateutil会自动推断出这个日期的年份,即使您没有指定它!您还可以控制如何使用parser解释或添加时区,或者使用 ISO-8601 格式的日期。这给了你比在datetime更多的灵活性。

计算时差

dateutil的另一个优秀特性是它能够用 relativedelta 模块处理时间运算。您可以从一个datetime实例中增加或减去任意时间单位,或者找出两个datetime实例之间的差异:

>>> from dateutil.relativedelta import relativedelta
>>> from dateutil import parser
>>> may_4th = parser.parse("Monday, May 4th at 8:00 AM")
>>> may_4th + relativedelta(days=+1, years=+5, months=-2)
datetime.datetime(2025, 3, 5, 8, 0)
>>> release_day = parser.parse("May 25, 1977 at 8:00 AM")
>>> relativedelta(may_4th, release_day)
relativedelta(years=+42, months=+11, days=+9)

这比 datetime.timedelta 更加灵活和强大,因为您可以指定大于一天的时间间隔,例如一个月或一年。

计算重复事件

最后但并非最不重要的是,dateutil有一个强大的模块叫做 rrule ,用于根据 iCalendar RFC 计算未来的日期。假设您想要生成六月份的常规站立时间表,在星期一和星期五的上午 10:00 进行:

>>> from dateutil import rrule
>>> from dateutil import parser
>>> list(
...     rrule.rrule(
...         rrule.WEEKLY,
...         byweekday=(rrule.MO, rrule.FR),
...         dtstart=parser.parse("June 1, 2020 at 10 AM"),
...         until=parser.parse("June 30, 2020"),
...     )
... )
[datetime.datetime(2020, 6, 1, 10, 0), ..., datetime.datetime(2020, 6, 29, 10, 0)]

请注意,您不必知道开始或结束日期是星期一还是星期五— dateutil会为您计算出来。使用rrule的另一种方法是查找特定日期的下一次发生时间。让我们寻找下一次闰日,2 月 29 日,将发生在像 2020 年那样的星期六:

>>> list(
...     rrule.rrule(
...         rrule.YEARLY,
...         count=1,
...         byweekday=rrule.SA,
...         bymonthday=29,
...         bymonth=2,
...     )
... )
[datetime.datetime(2048, 2, 29, 22, 5, 5)]

下一个星期六闰日将发生在 2048 年。在dateutil文档中还有一大堆例子以及一组练习可以尝试。

为什么dateutil很牛逼

你刚刚看到了dateutil的四个特性,当你处理时间时,它们让你的生活变得更轻松:

  1. 设置与datetime对象兼容的时区的便捷方式
  2. 一种将字符串解析成日期的有用方法
  3. 进行时间运算的强大接口
  4. 一种计算重复未来日期的绝妙方法。

下一次当你试图用时间编程而变得灰白时,试试吧!

Remove ads

typer用于命令行界面解析*

*外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

戴恩·希拉德 是 Python 书籍和博客作者,也是支持高等教育的非营利组织 ITHAKA 的首席 web 应用程序开发人员。在空闲时间,他什么都做,但特别喜欢烹饪、音乐、棋类游戏和交际舞。

Python 开发人员通常使用 sys模块开始命令行界面(CLI)解析。您可以阅读sys.argv来获得用户提供给脚本的参数列表:

# command.py

import sys

if __name__ == "__main__":
    print(sys.argv)

脚本的名称和用户提供的任何参数都以字符串值的形式出现在sys.argv中:

$ python command.py one two three
["command.py", "one", "two", "three"]
$ python command.py 1 2 3
["command.py", "1", "2", "3"]

但是,当您向脚本中添加特性时,您可能希望以更明智的方式解析脚本的参数。您可能需要管理几种不同数据类型的参数,或者让用户更清楚地知道哪些选项是可用的。

argparse是笨重的

Python 内置的 argparse 模块帮助您创建命名参数,将用户提供的值转换为适当的数据类型,并自动为您的脚本创建帮助菜单。如果你以前没有用过argparse,那么看看如何用 argparse 在 Python 中构建命令行接口。

argparse的一大优势是,您可以用更具声明性的方式指定 CLI 的参数,减少了大量的过程性和条件性代码。

考虑下面的例子,它使用sys.argv以用户指定的次数打印用户提供的字符串,并对边缘情况进行最少的处理:

# string_echo_sys.py

import sys

USAGE = """
USAGE:

python string_echo_sys.py <string> [--times <num>]
"""

if __name__ == "__main__":
    if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--help"):
        sys.exit(USAGE)
    elif len(sys.argv) == 2:
        string = sys.argv[1]  # First argument after script name
        print(string)
    elif len(sys.argv) == 4 and sys.argv[2] == "--times":
        string = sys.argv[1]  # First argument after script name

        try:
            times = int(sys.argv[3])  # Argument after --times
        except ValueError:
            sys.exit(f"Invalid value for --times! {USAGE}")

        print("\n".join([string] * times))
    else:
        sys.exit(USAGE)

此代码为用户提供了一种查看一些关于使用脚本的有用文档的方式:

$ python string_echo_sys.py --help

USAGE:

python string_echo_sys.py <string> [--times <num>]

用户可以提供一个字符串和可选的打印该字符串的次数:

$ python string_echo_sys.py HELLO! --times 5
HELLO!
HELLO!
HELLO!
HELLO!
HELLO!

要用argparse实现类似的界面,您可以编写如下代码:

# string_echo_argparse.py

import argparse

parser = argparse.ArgumentParser(
    description="Echo a string for as long as you like"
)
parser.add_argument("string", help="The string to echo")
parser.add_argument(
    "--times",
    help="The number of times to echo the string",
    type=int,
    default=1,
)

if __name__ == "__main__":
    args = parser.parse_args()
    print("\n".join([args.string] * args.times))

argparse代码更具描述性,argparse还提供了完整的参数解析和一个解释脚本用法的--help选项,这些都是免费的。

尽管与直接处理sys.argv相比,argparse是一个很大的改进,但它仍然迫使你考虑很多关于 CLI 解析的问题。您通常试图编写一个脚本来做一些有用的事情,所以花在 CLI 解析上的精力是浪费!

Remove ads

为什么typer很牛逼

typer 提供了几个与argparse相同的特性,但是使用了非常不同的开发模式。与其编写任何声明性的、程序性的或条件性的逻辑来解析用户输入,typer利用类型提示来自省你的代码并生成一个 CLI,这样你就不必花太多精力去考虑如何处理用户输入。

从 PyPI 安装typer开始:

$ python -m pip install typer

既然已经有了typer供您使用,下面是如何编写一个脚本来实现类似于argparse示例的结果:

# string_echo_typer.py

import typer

def echo(
    string: str,
    times: int = typer.Option(1, help="The number of times to echo the string"),
):
    """Echo a string for as long as you like"""

    typer.echo("\n".join([string] * times))

if __name__ == "__main__":
    typer.run(echo)

这种方法使用更少的函数行,这些行主要关注脚本的特性。脚本多次回显一个字符串的事实更加明显。

typer甚至为用户提供了为他们的 shells 生成 Tab 完成的能力,这样他们就可以更快地使用您的脚本的 CLI。

您可以查看比较 Python 命令行解析库——arg parse、Docopt,并单击看看是否有适合您的,但是我喜欢typer的简洁和强大。

结论:五个有用的 Python 包

Python 社区已经构建了如此多令人惊叹的包。在本教程中,您了解了几个有用的包,它们是 Python 标准库中常见包的替代或扩展。

在本教程中,您学习了:

  • 为什么 pudb 可以帮助你调试代码
  • requests 如何改善你与网络服务器的沟通方式
  • 你如何使用 parse 来简化你的字符串匹配
  • dateutil 为处理日期和时间提供了什么功能
  • 为什么应该使用 typer 来解析命令行参数

我们已经为这些包中的一些写了专门的教程和教程部分,以供进一步阅读。我们鼓励你深入研究,并在评论中与我们分享一些你最喜欢的标准库选择!

延伸阅读

这里有一些教程和视频课程,您可以查看以了解更多关于本教程中涵盖的包的信息:

Python 熊猫:你可能不知道的技巧和特性

原文:https://realpython.com/python-pandas-tricks/

*立即观看**本教程有真实 Python 团队创建的相关视频课程。配合文字教程一起看,加深理解: 惯用熊猫:招数&你可能不知道的特点

Pandas 是分析、数据处理和数据科学的基础库。这是一个巨大的项目,有大量的选择和深度。

本教程将介绍一些很少使用但很习惯的 Pandas 功能,这些功能可以让您的代码具有更好的可读性、多功能性和速度。

如果您对 Python 的 Pandas 库的核心概念感到满意,希望您能在本文中找到一两个以前没有遇到过的技巧。(如果你刚从图书馆开始,10 分钟到熊猫是一个好的开始。)

注意:本文中的例子是用 Pandas 版本 0.23.2 和 Python 3.6.6 测试的。但是,它们在旧版本中也应该有效。

1.在解释器启动时配置选项和设置

你可能以前也碰到过熊猫丰富的选项和设置系统。

在解释器启动时设置定制的 Pandas 选项是一个巨大的生产力节省,特别是如果你在一个脚本环境中工作。你可以使用pd.set_option()来随心所欲地配置一个 PythonIPython 启动文件。

这些选项使用一个点符号,比如pd.set_option('display.max_colwidth', 25),这非常适合选项的嵌套字典:

import pandas as pd

def start():
    options = {
        'display': {
            'max_columns': None,
            'max_colwidth': 25,
            'expand_frame_repr': False,  # Don't wrap to multiple pages
            'max_rows': 14,
            'max_seq_items': 50,         # Max length of printed sequence
            'precision': 4,
            'show_dimensions': False
        },
        'mode': {
            'chained_assignment': None   # Controls SettingWithCopyWarning
        }
    }

    for category, option in options.items():
        for op, value in option.items():
            pd.set_option(f'{category}.{op}', value)  # Python 3.6+

if __name__ == '__main__':
    start()
    del start  # Clean up namespace in the interpreter

如果您启动一个解释器会话,您将看到启动脚本中的所有内容都已执行,并且 Pandas 会自动为您导入,并带有您的选项套件:

>>> pd.__name__
'pandas'
>>> pd.get_option('display.max_rows')
14

让我们使用 UCI 机器学习库托管的关于鲍鱼的一些数据来演示启动文件中设置的格式。数据将在 14 行截断,浮点精度为 4 位数:

>>> url = ('https://archive.ics.uci.edu/ml/'
...        'machine-learning-databases/abalone/abalone.data')
>>> cols = ['sex', 'length', 'diam', 'height', 'weight', 'rings']
>>> abalone = pd.read_csv(url, usecols=[0, 1, 2, 3, 4, 8], names=cols)

>>> abalone
 sex  length   diam  height  weight  rings
0      M   0.455  0.365   0.095  0.5140     15
1      M   0.350  0.265   0.090  0.2255      7
2      F   0.530  0.420   0.135  0.6770      9
3      M   0.440  0.365   0.125  0.5160     10
4      I   0.330  0.255   0.080  0.2050      7
5      I   0.425  0.300   0.095  0.3515      8
6      F   0.530  0.415   0.150  0.7775     20
# ...
4170   M   0.550  0.430   0.130  0.8395     10
4171   M   0.560  0.430   0.155  0.8675      8
4172   F   0.565  0.450   0.165  0.8870     11
4173   M   0.590  0.440   0.135  0.9660     10
4174   M   0.600  0.475   0.205  1.1760      9
4175   F   0.625  0.485   0.150  1.0945     10
4176   M   0.710  0.555   0.195  1.9485     12

稍后,您还会在其他示例中看到这个数据集。

Remove ads

2.用熊猫的测试模块制作玩具数据结构

注意:熊猫 1.0 中pandas.util.testing模块已被弃用。来自pandas.testing的“公共测试 API”现在仅限于assert_extension_array_equal()assert_frame_equal()assert_series_equal()assert_index_equal()。作者承认,他因依赖熊猫图书馆的未记录部分而自食其果。

隐藏在熊猫的 testing 模块中的是许多方便的函数,用于快速构建准现实系列和数据帧:

>>> import pandas.util.testing as tm
>>> tm.N, tm.K = 15, 3  # Module-level default rows/columns

>>> import numpy as np
>>> np.random.seed(444)

>>> tm.makeTimeDataFrame(freq='M').head()
 A       B       C
2000-01-31  0.3574 -0.8804  0.2669
2000-02-29  0.3775  0.1526 -0.4803
2000-03-31  1.3823  0.2503  0.3008
2000-04-30  1.1755  0.0785 -0.1791
2000-05-31 -0.9393 -0.9039  1.1837

>>> tm.makeDataFrame().head()
 A       B       C
nTLGGTiRHF -0.6228  0.6459  0.1251
WPBRn9jtsR -0.3187 -0.8091  1.1501
7B3wWfvuDA -1.9872 -1.0795  0.2987
yJ0BTjehH1  0.8802  0.7403 -1.2154
0luaYUYvy1 -0.9320  1.2912 -0.2907

其中大约有 30 个,您可以通过调用模块对象上的dir()来查看完整的列表。以下是一些例子:

>>> [i for i in dir(tm) if i.startswith('make')]
['makeBoolIndex',
 'makeCategoricalIndex',
 'makeCustomDataframe',
 'makeCustomIndex',
 # ...,
 'makeTimeSeries',
 'makeTimedeltaIndex',
 'makeUIntIndex',
 'makeUnicodeIndex']

这些对于基准测试、测试断言以及用您不太熟悉的 Pandas 方法进行实验是很有用的。

3.利用访问器方法

也许你听说过术语访问器,它有点像 getter(尽管 getter 和 setter 在 Python 中很少使用)。对于我们这里的目的,您可以将 Pandas 访问器看作是一个属性,它充当附加方法的接口。

熊猫系列有三个:

>>> pd.Series._accessors
{'cat', 'str', 'dt'}

是的,上面的定义有点拗口,所以在讨论内部原理之前,让我们先看几个例子。

.cat是分类数据,.str是字符串(对象)数据,.dt是类似日期时间的数据。让我们从.str开始:假设您有一些原始的城市/州/邮政编码数据,作为 Pandas 系列中的一个字段。

Pandas 字符串方法是向量化的,这意味着它们在没有显式 for 循环的情况下对整个数组进行操作:

>>> addr = pd.Series([
...     'Washington, D.C. 20003',
...     'Brooklyn, NY 11211-1755',
...     'Omaha, NE 68154',
...     'Pittsburgh, PA 15211'
... ])

>>> addr.str.upper()
0     WASHINGTON, D.C. 20003
1    BROOKLYN, NY 11211-1755
2            OMAHA, NE 68154
3       PITTSBURGH, PA 15211
dtype: object

>>> addr.str.count(r'\d')  # 5 or 9-digit zip?
0    5
1    9
2    5
3    5
dtype: int64

对于一个更复杂的例子,假设您希望将三个城市/州/邮政编码组件整齐地分离到 DataFrame 字段中。

您可以通过一个正则表达式.str.extract()来“提取”序列中每个单元格的部分。在.str.extract()中,.str是访问器,.str.extract()是访问器方法:

>>> regex = (r'(?P<city>[A-Za-z ]+), '      # One or more letters
...          r'(?P<state>[A-Z]{2}) '        # 2 capital letters
...          r'(?P<zip>\d{5}(?:-\d{4})?)')  # Optional 4-digit extension
...
>>> addr.str.replace('.', '').str.extract(regex)
 city state         zip
0  Washington    DC       20003
1    Brooklyn    NY  11211-1755
2       Omaha    NE       68154
3  Pittsburgh    PA       15211

这也说明了所谓的方法链接,其中对addr.str.replace('.', '')的结果调用.str.extract(regex),这消除了句点的使用,得到了一个漂亮的 2 字符状态缩写。

稍微了解一下这些访问器方法是如何工作的会有所帮助,这也是为什么你应该首先使用它们,而不是像addr.apply(re.findall, ...)这样的东西。

每个访问器本身都是真正的 Python 类:

然后使用 CachedAccessor 将这些独立类“附加”到系列类。当这些类被包装在CachedAccessor中时,神奇的事情发生了。

受“缓存属性”设计的启发:每个实例只计算一次属性,然后用普通属性替换。它通过重载 .__get__()方法来做到这一点,方法是 Python 的描述符协议的一部分。

注意:如果你想了解更多关于如何工作的内部信息,请看 Python 描述符 HOWTO这篇关于缓存属性设计的文章。Python 3 还引入了 functools.lru_cache() ,提供了类似的功能。这种模式的例子比比皆是,比如在 aiohttp 包中。

第二个访问器.dt用于类似日期时间的数据。它技术上属于熊猫的DatetimeIndex,如果叫上一个系列,就先转换成DatetimeIndex:

>>> daterng = pd.Series(pd.date_range('2017', periods=9, freq='Q'))
>>> daterng
0   2017-03-31
1   2017-06-30
2   2017-09-30
3   2017-12-31
4   2018-03-31
5   2018-06-30
6   2018-09-30
7   2018-12-31
8   2019-03-31
dtype: datetime64[ns]

>>>  daterng.dt.day_name()
0      Friday
1      Friday
2    Saturday
3      Sunday
4    Saturday
5    Saturday
6      Sunday
7      Monday
8      Sunday
dtype: object

>>> # Second-half of year only
>>> daterng[daterng.dt.quarter > 2]
2   2017-09-30
3   2017-12-31
6   2018-09-30
7   2018-12-31
dtype: datetime64[ns]

>>> daterng[daterng.dt.is_year_end]
3   2017-12-31
7   2018-12-31
dtype: datetime64[ns]

第三个访问器.cat,仅用于分类数据,您将很快在它自己的部分中看到。

Remove ads

4.从组件列创建 DatetimeIndex

说到类似日期时间的数据,如上面的daterng所示,可以从多个组成列中创建一个 Pandas DatetimeIndex,它们共同构成一个日期或日期时间:

>>> from itertools import product
>>> datecols = ['year', 'month', 'day']

>>> df = pd.DataFrame(list(product([2017, 2016], [1, 2], [1, 2, 3])),
...                   columns=datecols)
>>> df['data'] = np.random.randn(len(df))
>>> df
 year  month  day    data
0   2017      1    1 -0.0767
1   2017      1    2 -1.2798
2   2017      1    3  0.4032
3   2017      2    1  1.2377
4   2017      2    2 -0.2060
5   2017      2    3  0.6187
6   2016      1    1  2.3786
7   2016      1    2 -0.4730
8   2016      1    3 -2.1505
9   2016      2    1 -0.6340
10  2016      2    2  0.7964
11  2016      2    3  0.0005

>>> df.index = pd.to_datetime(df[datecols])
>>> df.head()
 year  month  day    data
2017-01-01  2017      1    1 -0.0767
2017-01-02  2017      1    2 -1.2798
2017-01-03  2017      1    3  0.4032
2017-02-01  2017      2    1  1.2377
2017-02-02  2017      2    2 -0.2060

最后,您可以删除旧的单个列,并转换为一个系列:

>>> df = df.drop(datecols, axis=1).squeeze()
>>> df.head()
2017-01-01   -0.0767
2017-01-02   -1.2798
2017-01-03    0.4032
2017-02-01    1.2377
2017-02-02   -0.2060
Name: data, dtype: float64

>>> df.index.dtype_str
'datetime64[ns]

传递 DataFrame 背后的直觉是 DataFrame 类似于 Python 字典,其中列名是键,单个列(系列)是字典值。这就是为什么pd.to_datetime(df[datecols].to_dict(orient='list'))在这种情况下也能工作。这反映了 Python 的datetime.datetime的构造,其中传递关键字参数,如datetime.datetime(year=2000, month=1, day=15, hour=10)

5.使用分类数据节省时间和空间

熊猫的一个强大特征是它的类型。

即使您并不总是在 RAM 中处理千兆字节的数据,您也可能遇到过这样的情况:对大型数据帧的简单操作似乎会挂起几秒钟以上。

Pandas object dtype 通常是转换成类别数据的绝佳选择。(object是 Python str、异构数据类型或“其他”类型的容器。)字符串会占用大量内存空间:

>>> colors = pd.Series([
...     'periwinkle',
...     'mint green',
...     'burnt orange',
...     'periwinkle',
...     'burnt orange',
...     'rose',
...     'rose',
...     'mint green',
...     'rose',
...     'navy'
... ])
...
>>> import sys
>>> colors.apply(sys.getsizeof)
0    59
1    59
2    61
3    59
4    61
5    53
6    53
7    59
8    53
9    53
dtype: int64

**注:**我用sys.getsizeof()来显示序列中每个单独的值所占用的内存。请记住,这些 Python 对象首先会有一些开销。(sys.getsizeof('')将返回 49 个字节。)

还有colors.memory_usage(),它汇总了内存使用量,依赖于底层 NumPy 数组的.nbytes属性。不要在这些细节上陷得太深:重要的是由类型转换导致的相对内存使用,您将在下面看到。

现在,如果我们能把上面独特的颜色映射到一个不那么占空间的整数,会怎么样呢?这是一个简单的实现:

>>> mapper = {v: k for k, v in enumerate(colors.unique())}
>>> mapper
{'periwinkle': 0, 'mint green': 1, 'burnt orange': 2, 'rose': 3, 'navy': 4}

>>> as_int = colors.map(mapper)
>>> as_int
0    0
1    1
2    2
3    0
4    2
5    3
6    3
7    1
8    3
9    4
dtype: int64

>>> as_int.apply(sys.getsizeof)
0    24
1    28
2    28
3    24
4    28
5    28
6    28
7    28
8    28
9    28
dtype: int64

注意:做同样事情的另一种方法是熊猫的pd.factorize(colors):

>>> pd.factorize(colors)[0]
array([0, 1, 2, 0, 2, 3, 3, 1, 3, 4])

无论哪种方式,都是将对象编码为枚举类型(分类变量)。

您会立即注意到,与使用完整的字符串和object dtype 相比,内存使用量减少了一半。

在前面关于访问器的部分,我提到了.cat(分类)访问器。上面带有mapper的图片粗略地展示了熊猫Categorical的内部情况:

“一个Categorical的内存使用量与类别的数量加上数据的长度成正比。相比之下,object dtype 是一个常数乘以数据的长度。(来源)

在上面的colors中,每个唯一值(类别)有 2 个值的比率:

>>> len(colors) / colors.nunique()
2.0

因此,从转换到Categorical节省的内存是好的,但不是很大:

>>> # Not a huge space-saver to encode as Categorical
>>> colors.memory_usage(index=False, deep=True)
650
>>> colors.astype('category').memory_usage(index=False, deep=True)
495

然而,如果你打破上面的比例,有很多数据和很少的唯一值(想想人口统计学或字母测试分数的数据),所需的内存减少超过 10 倍:

>>> manycolors = colors.repeat(10)
>>> len(manycolors) / manycolors.nunique()  # Much greater than 2.0x
20.0

>>> manycolors.memory_usage(index=False, deep=True)
6500
>>> manycolors.astype('category').memory_usage(index=False, deep=True)
585

额外的好处是计算效率也得到了提高:对于分类Series,字符串操作是在.cat.categories属性上执行的,而不是在Series的每个原始元素上。

换句话说,每个唯一的类别只执行一次操作,结果映射回值。分类数据有一个.cat访问器,它是进入属性和方法的窗口,用于操作类别:

>>> ccolors = colors.astype('category')
>>> ccolors.cat.categories
Index(['burnt orange', 'mint green', 'navy', 'periwinkle', 'rose'], dtype='object')

事实上,您可以手动重现类似于上面示例的内容:

>>> ccolors.cat.codes
0    3
1    1
2    0
3    3
4    0
5    4
6    4
7    1
8    4
9    2
dtype: int8

要完全模拟前面的手动输出,您需要做的就是对代码进行重新排序:

>>> ccolors.cat.reorder_categories(mapper).cat.codes
0    0
1    1
2    2
3    0
4    2
5    3
6    3
7    1
8    3
9    4
dtype: int8

注意,dtype 是 NumPy 的int8,一个 8 位有符号整数,可以取-127 到 128 之间的值。(只需要一个字节来表示内存中的一个值。就内存使用而言,64 位有符号ints是多余的。)默认情况下,我们粗略的例子产生了int64数据,而 Pandas 足够聪明,可以将分类数据向下转换为尽可能最小的数字数据类型。

.cat的大多数属性都与查看和操作底层类别本身有关:

>>> [i for i in dir(ccolors.cat) if not i.startswith('_')]
['add_categories',
 'as_ordered',
 'as_unordered',
 'categories',
 'codes',
 'ordered',
 'remove_categories',
 'remove_unused_categories',
 'rename_categories',
 'reorder_categories',
 'set_categories']

不过,有一些注意事项。分类数据通常不太灵活。例如,如果插入以前看不见的值,您需要首先将这个值添加到一个.categories容器中:

>>> ccolors.iloc[5] = 'a new color'
# ...
ValueError: Cannot setitem on a Categorical with a new category,
set the categories first

>>> ccolors = ccolors.cat.add_categories(['a new color'])
>>> ccolors.iloc[5] = 'a new color'  # No more ValueError

如果您计划设置值或重塑数据,而不是派生新的计算,Categorical类型可能不太灵活。

Remove ads

6.通过迭代内省 Groupby 对象

当你调用df.groupby('x')时,产生的熊猫groupby对象可能有点不透明。该对象被延迟实例化,本身没有任何有意义的表示。

您可以使用来自示例 1 的鲍鱼数据集进行演示:

>>> abalone['ring_quartile'] = pd.qcut(abalone.rings, q=4, labels=range(1, 5))
>>> grouped = abalone.groupby('ring_quartile')

>>> grouped
<pandas.core.groupby.groupby.DataFrameGroupBy object at 0x11c1169b0>

好了,现在你有了一个groupby对象,但是这个东西是什么,我怎么看它?

在调用类似于grouped.apply(func)的东西之前,您可以利用groupby对象是可迭代的这一事实:

>>> help(grouped.__iter__)

 Groupby iterator

 Returns
 -------
 Generator yielding sequence of (name, subsetted object)
 for each group

grouped.__iter__()产生的每个“东西”都是一个由(name, subsetted object)组成的元组,其中name是分组所依据的列的值,而subsetted object是一个数据帧,它是基于您指定的任何分组条件的原始数据帧的子集。也就是说,数据按组分块:

>>> for idx, frame in grouped:
...     print(f'Ring quartile: {idx}')
...     print('-' * 16)
...     print(frame.nlargest(3, 'weight'), end='\n\n')
...
Ring quartile: 1
----------------
 sex  length   diam  height  weight  rings ring_quartile
2619   M   0.690  0.540   0.185  1.7100      8             1
1044   M   0.690  0.525   0.175  1.7005      8             1
1026   M   0.645  0.520   0.175  1.5610      8             1

Ring quartile: 2
----------------
 sex  length  diam  height  weight  rings ring_quartile
2811   M   0.725  0.57   0.190  2.3305      9             2
1426   F   0.745  0.57   0.215  2.2500      9             2
1821   F   0.720  0.55   0.195  2.0730      9             2

Ring quartile: 3
----------------
 sex  length  diam  height  weight  rings ring_quartile
1209   F   0.780  0.63   0.215   2.657     11             3
1051   F   0.735  0.60   0.220   2.555     11             3
3715   M   0.780  0.60   0.210   2.548     11             3

Ring quartile: 4
----------------
 sex  length   diam  height  weight  rings ring_quartile
891    M   0.730  0.595    0.23  2.8255     17             4
1763   M   0.775  0.630    0.25  2.7795     12             4
165    M   0.725  0.570    0.19  2.5500     14             4

与之相关的是,groupby对象也有.groups和组获取器.get_group():

>>> grouped.groups.keys()
dict_keys([1, 2, 3, 4])

>>> grouped.get_group(2).head()
 sex  length   diam  height  weight  rings ring_quartile
2    F   0.530  0.420   0.135  0.6770      9             2
8    M   0.475  0.370   0.125  0.5095      9             2
19   M   0.450  0.320   0.100  0.3810      9             2
23   F   0.550  0.415   0.135  0.7635      9             2
39   M   0.355  0.290   0.090  0.3275      9             2

这有助于您更加确信您正在执行的操作是您想要的:

>>> grouped['height', 'weight'].agg(['mean', 'median'])
 height         weight
 mean median    mean  median
ring_quartile
1              0.1066  0.105  0.4324  0.3685
2              0.1427  0.145  0.8520  0.8440
3              0.1572  0.155  1.0669  1.0645
4              0.1648  0.165  1.1149  1.0655

无论您在grouped上执行什么计算,无论是单个 Pandas 方法还是定制的函数,这些“子帧”中的每一个都作为参数一个接一个地传递给那个可调用函数。这就是术语“拆分-应用-组合”的来源:按组分解数据,按组进行计算,然后以某种聚合方式重新组合。

如果你很难想象这些组实际上会是什么样子,简单地迭代它们并打印几个会非常有用。

7.使用这个映射技巧为会员宁滨

假设您有一个系列和一个对应的“映射表”,其中每个值属于一个多成员组,或者根本不属于任何组:

>>> countries = pd.Series([
...     'United States',
...     'Canada',
...     'Mexico',
...     'Belgium',
...     'United Kingdom',
...     'Thailand'
... ])
...
>>> groups = {
...     'North America': ('United States', 'Canada', 'Mexico', 'Greenland'),
...     'Europe': ('France', 'Germany', 'United Kingdom', 'Belgium')
... }

换句话说,您需要将countries映射到以下结果:

0    North America
1    North America
2    North America
3           Europe
4           Europe
5            other
dtype: object

这里你需要的是一个类似于熊猫的pd.cut()的函数,但是对于宁滨是基于分类成员的。你可以使用pd.Series.map(),你已经在的例子#5 中看到了,来模仿这个:

from typing import Any

def membership_map(s: pd.Series, groups: dict,
                   fillvalue: Any=-1) -> pd.Series:
    # Reverse & expand the dictionary key-value pairs
    groups = {x: k for k, v in groups.items() for x in v}
    return s.map(groups).fillna(fillvalue)

对于countries中的每个国家,这应该比通过groups的嵌套 Python 循环快得多。

下面是一次试驾:

>>> membership_map(countries, groups, fillvalue='other')
0    North America
1    North America
2    North America
3           Europe
4           Europe
5            other
dtype: object

我们来分析一下这是怎么回事。(旁注:这是一个用 Python 的调试器 pdb 进入函数范围的好地方,用来检查函数的局部变量。)

目标是将groups中的每个组映射到一个整数。然而,Series.map()不会识别'ab'—它需要分解的版本,每个组中的每个字符都映射到一个整数。这就是字典理解正在做的事情:

>>> groups = dict(enumerate(('ab', 'cd', 'xyz')))
>>> {x: k for k, v in groups.items() for x in v}
{'a': 0, 'b': 0, 'c': 1, 'd': 1, 'x': 2, 'y': 2, 'z': 2}

这个字典可以传递给s.map()以将其值映射或“翻译”到相应的组索引。

Remove ads

8.了解熊猫如何使用布尔运算符

你可能熟悉 Python 的运算符优先级,其中andnotor的优先级低于<<=>>=!===等算术运算符。考虑下面两条语句,其中<>的优先级高于and运算符:

>>> # Evaluates to "False and True"
>>> 4 < 3 and 5 > 4
False

>>> # Evaluates to 4 < 5 > 4
>>> 4 < (3 and 5) > 4
True

:不是专门和熊猫有关,但是3 and 5因为短路评估而评估为5:

“短路运算符的返回值是最后计算的参数。”(来源)

Pandas(和 NumPy,Pandas 就是在其上构建的)不使用andornot。相反,它分别使用了&|~,这是正常的、真正的 Python 位操作符

这些运营商不是熊猫“发明”的。相反,&|~是有效的 Python 内置操作符,其优先级高于(而不是低于)算术操作符。(Pandas 覆盖了像.__ror__()这样映射到|操作符的 dunder 方法。)为了牺牲一些细节,您可以将“按位”视为“元素方式”,因为它与 Pandas 和 NumPy 相关:

>>> pd.Series([True, True, False]) & pd.Series([True, False, False])
0     True
1    False
2    False
dtype: bool

充分理解这个概念是有好处的。假设您有一个类似系列的产品:

>>> s = pd.Series(range(10))

我猜您可能已经在某个时候看到了这个异常:

>>> s % 2 == 0 & s > 3
ValueError: The truth value of a Series is ambiguous.
Use a.empty, a.bool(), a.item(), a.any() or a.all().

这里发生了什么事?用括号递增地绑定表达式很有帮助,说明 Python 如何一步一步地扩展这个表达式:

s % 2 == 0 & s > 3                      # Same as above, original expression
(s % 2) == 0 & s > 3                    # Modulo is most tightly binding here
(s % 2) == (0 & s) > 3                  # Bitwise-and is second-most-binding
(s % 2) == (0 & s) and (0 & s) > 3      # Expand the statement
((s % 2) == (0 & s)) and ((0 & s) > 3)  # The `and` operator is least-binding

表达式s % 2 == 0 & s > 3等同于((s % 2) == (0 & s)) and ((0 & s) > 3)(或被视为)((s % 2) == (0 & s)) and ((0 & s) > 3)。这叫做扩张 : x < y <= z相当于x < y and y <= z

好了,现在停下来,让我们回到熊猫的话题。你有两个熊猫系列,我们称之为leftright:

>>> left = (s % 2) == (0 & s)
>>> right = (0 & s) > 3
>>> left and right  # This will raise the same ValueError

您知道形式为left and right的语句对leftright都进行真值测试,如下所示:

>>> bool(left) and bool(right)

问题是熊猫开发者故意不为整个系列建立一个真值。一个系列是真是假?谁知道呢?结果是不明确的:

>>> bool(s)
ValueError: The truth value of a Series is ambiguous.
Use a.empty, a.bool(), a.item(), a.any() or a.all().

唯一有意义的比较是元素方面的比较。这就是为什么,如果涉及到算术运算符,就需要括号:

>>> (s % 2 == 0) & (s > 3)
0    False
1    False
2    False
3    False
4     True
5    False
6     True
7    False
8     True
9    False
dtype: bool

简而言之,如果你看到上面的ValueError弹出布尔索引,你可能要做的第一件事就是添加一些需要的括号。

Remove ads

9.从剪贴板加载数据

需要将数据从像 Excel 或 Sublime Text 这样的地方转移到 Pandas 数据结构是一种常见的情况。理想情况下,你想这样做而不经过中间步骤,即把数据保存到一个文件,然后把文件读入熊猫

您可以使用 pd.read_clipboard() 从计算机的剪贴板数据缓冲区载入数据帧。其关键字参数被传递到 pd.read_table()

这允许您将结构化文本直接复制到数据帧或系列中。在 Excel 中,数据看起来像这样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

它的纯文本表示(例如,在文本编辑器中)如下所示:

a   b           c       d
0   1           inf     1/1/00
2   7.389056099 N/A     5-Jan-13
4   54.59815003 nan     7/24/18
6   403.4287935 None    NaT

只需突出显示并复制上面的纯文本,然后调用pd.read_clipboard():

>>> df = pd.read_clipboard(na_values=[None], parse_dates=['d'])
>>> df
 a         b    c          d
0  0    1.0000  inf 2000-01-01
1  2    7.3891  NaN 2013-01-05
2  4   54.5982  NaN 2018-07-24
3  6  403.4288  NaN        NaT

>>> df.dtypes
a             int64
b           float64
c           float64
d    datetime64[ns]
dtype: object

10.把熊猫对象直接写成压缩格式

这是一个简短而甜蜜的结尾。从 Pandas 版本 0.21.0 开始,您可以将 Pandas 对象直接写入 gzip、bz2、zip 或 xz 压缩,而不是将未压缩的文件存储在内存中并进行转换。这里有一个使用来自技巧#1abalone数据的例子:

abalone.to_json('df.json.gz', orient='records',
                lines=True, compression='gzip')

在这种情况下,大小差为 11.6 倍:

>>> import os.path
>>> abalone.to_json('df.json', orient='records', lines=True)
>>> os.path.getsize('df.json') / os.path.getsize('df.json.gz')
11.603035760226396

想要添加到此列表吗?让我们知道

希望您能够从这个列表中挑选一些有用的技巧,让您的熊猫代码具有更好的可读性、通用性和性能。

如果你有这里没有提到的东西,请在评论中留下你的建议或者作为 GitHub 的要点。我们将很高兴地添加到这个列表中,并给予应有的信任。

立即观看**本教程有真实 Python 团队创建的相关视频课程。配合文字教程一起看,加深理解: 惯用熊猫:招数&你可能不知道的特点******

Python 中的引用传递:背景和最佳实践

原文:https://realpython.com/python-pass-by-reference/

*立即观看**本教程有真实 Python 团队创建的相关视频课程。和书面教程一起看,加深理解: 顺便引用 Python 中的:最佳实践

熟悉 Python 之后,您可能会注意到函数没有像您预期的那样修改参数的情况,尤其是在您熟悉其他编程语言的情况下。一些语言将函数参数作为对现有变量引用来处理,这被称为通过引用传递。其他语言将它们作为独立值来处理,这种方法被称为按值传递

如果你是一名中级 Python 程序员,希望理解 Python 处理函数参数的特殊方式,那么本教程适合你。您将在 Python 中实现引用传递构造的真实用例,并学习一些最佳实践来避免函数参数中的陷阱。

在本教程中,您将学习:

  • 通过引用传递意味着什么以及为什么你想这样做
  • 通过引用传递与通过值传递Python 独特的方法有何不同
  • Python 中函数参数的行为方式
  • 如何在 Python 中使用某些可变类型进行引用传递
  • Python 中通过引用复制传递的最佳实践是什么

免费奖励: 掌握 Python 的 5 个想法,这是一个面向 Python 开发者的免费课程,向您展示将 Python 技能提升到下一个水平所需的路线图和心态。

参照定义路径

在深入研究按引用传递的技术细节之前,通过将术语分解为几个部分来更仔细地了解它本身是有帮助的:

  • 传递的意思是给函数提供一个自变量。
  • 通过引用意味着你传递给函数的参数是对内存中已经存在的变量的引用,而不是该变量的独立副本。

因为您给了函数一个对现有变量的引用,所以对这个引用执行的所有操作都会直接影响它所引用的变量。让我们看一些例子来说明这在实践中是如何工作的。

下面,您将看到如何在 C#中通过引用传递变量。注意在突出显示的行中使用了关键字ref:

using  System; // Source:
// https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/passing-parameters
class  Program { static  void  Main(string[]  args) { int  arg; // Passing by reference.
  // The value of arg in Main is changed.
  arg  =  4; squareRef(ref  arg);   Console.WriteLine(arg); // Output: 16
  } static  void  squareRef(ref  int  refParameter)   { refParameter  *=  refParameter; } }

如你所见,squareRef()refParameter必须用ref关键字声明,在调用函数时也必须使用关键字。然后参数将通过引用传入,并可以就地修改。

Python 没有ref关键字或任何与之等同的东西。如果您尝试在 Python 中尽可能接近地复制上面的例子,那么您会看到不同的结果:

>>> def main():
...     arg = 4
...     square(arg)
...     print(arg)
...
>>> def square(n):
...     n *= n
...
>>> main()
4

在这种情况下,arg变量是而不是被改变了位置。Python 似乎将您提供的参数视为独立的值,而不是对现有变量的引用。这是否意味着 Python 通过值而不是通过引用传递参数?

不完全是。Python 既不通过引用也不通过值来传递参数,而是通过赋值来传递**。下面,您将快速探索按值传递和按引用传递的细节,然后更仔细地研究 Python 的方法。之后,您将浏览一些最佳实践,以实现 Python 中的等效引用传递。**

Remove ads

对照按引用传递和按值传递

当您通过引用传递函数参数时,这些参数只是对现有值的引用。相反,当您通过值传递参数时,这些参数将成为原始值的独立副本。

让我们重温一下 C#的例子,这次没有使用ref关键字。这将导致程序使用默认的按值传递行为:

using  System; // Source:
// https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/passing-parameters
class  Program { static  void  Main(string[]  args) { int  arg; // Passing by value.
  // The value of arg in Main is not changed.
  arg  =  4; squareVal(arg);   Console.WriteLine(arg); // Output: 4
  } static  void  squareVal(int  valParameter)   { valParameter  *=  valParameter; } }

在这里,你可以看到squareVal()没有修改原始变量。更确切地说,valParameter是原始变量arg的独立副本。虽然这符合您在 Python 中看到的行为,但请记住 Python 并不完全通过值传递。我们来证明一下。

Python 的内置id()返回一个整数,代表所需对象的内存地址。使用id(),您可以验证以下断言:

  1. 函数参数最初引用与其原始变量相同的地址。
  2. 在函数中重新分配参数会给它一个新的地址,而原始变量保持不变。

在下面的例子中,注意到x的地址最初与n的地址匹配,但是在重新分配后发生了变化,而n的地址从未改变:

>>> def main():
...     n = 9001
...     print(f"Initial address of n: {id(n)}")
...     increment(n)
...     print(f"  Final address of n: {id(n)}")
...
>>> def increment(x):
...     print(f"Initial address of x: {id(x)}")
...     x += 1
...     print(f"  Final address of x: {id(x)}")
...
>>> main()
Initial address of n: 140562586057840
Initial address of x: 140562586057840
 Final address of x: 140562586057968 Final address of n: 140562586057840

当您调用increment()时,nx的初始地址是相同的,这一事实证明了x参数不是通过值传递的。否则,nx将会有不同的内存地址。

在学习 Python 如何处理参数的细节之前,我们先来看一些引用传递的实际用例。

使用引用传递构造

通过引用传递变量是实现特定编程模式的几种策略之一。虽然很少需要,但通过引用传递可能是一个有用的工具。

在这一节中,您将看到三种最常见的模式,对于这些模式,通过引用传递是一种实用的方法。然后您将看到如何用 Python 实现这些模式。

避免重复对象

如您所见,通过值传递变量将导致创建该值的副本并存储在内存中。在默认通过值传递的语言中,您可能会发现通过引用传递变量会提高性能,特别是当变量包含大量数据时。当您的代码在资源受限的机器上运行时,这一点会更加明显。

然而,在 Python 中,这从来都不是问题。你会在下一节的中看到原因。

返回多个值

通过引用传递的最常见应用之一是创建一个函数,该函数在返回不同值的同时改变引用参数的值。您可以修改按引用传递的 C#示例来说明这种技术:

using  System; class  Program { static  void  Main(string[]  args) { int  counter  =  0; // Passing by reference.
  // The value of counter in Main is changed.
  Console.WriteLine(greet("Alice",  ref  counter)); Console.WriteLine("Counter is {0}",  counter); Console.WriteLine(greet("Bob",  ref  counter)); Console.WriteLine("Counter is {0}",  counter); // Output:
  // Hi, Alice!
  // Counter is 1
  // Hi, Bob!
  // Counter is 2
  } static  string  greet(string  name,  ref  int  counter) { string  greeting  =  "Hi, "  +  name  +  "!"; counter++; return  greeting; } }

在上面的例子中,greet()返回一个问候字符串,并修改counter的值。现在尝试用 Python 尽可能地再现这一点:

>>> def main():
...     counter = 0
...     print(greet("Alice", counter))
...     print(f"Counter is {counter}")
...     print(greet("Bob", counter))
...     print(f"Counter is {counter}")
...
>>> def greet(name, counter):
...     counter += 1
...     return f"Hi, {name}!"
...
>>> main()
Hi, Alice!
Counter is 0
Hi, Bob!
Counter is 0

在上面的例子中,counter没有递增,因为正如您之前了解到的,Python 无法通过引用传递值。那么,如何才能获得与 C#相同的结果呢?

本质上,C#中的引用参数不仅允许函数返回值,还允许对附加参数进行操作。这相当于返回多个值!

幸运的是,Python 已经支持返回多个值。严格来说,返回多个值的 Python 函数实际上返回一个包含每个值的元组:

>>> def multiple_return():
...     return 1, 2
...
>>> t = multiple_return()
>>> t  # A tuple
(1, 2)

>>> # You can unpack the tuple into two variables:
>>> x, y = multiple_return()
>>> x
1
>>> y
2

正如您所看到的,要返回多个值,您可以简单地使用 return关键字,后跟逗号分隔的值或变量。

有了这种技术,您可以将greet()中的 return语句从之前的 Python 代码中修改为既返回问候又返回计数器:

>>> def main():
...     counter = 0
...     print(greet("Alice", counter))
...     print(f"Counter is {counter}")
...     print(greet("Bob", counter))
...     print(f"Counter is {counter}")
...
>>> def greet(name, counter):
...     return f"Hi, {name}!", counter + 1 ...
>>> main()
('Hi, Alice!', 1)
Counter is 0
('Hi, Bob!', 1)
Counter is 0

这看起来还是不对。虽然greet()现在返回多个值,但是它们被打印为tuple。此外,原来的counter变量保持在0

为了清理你的输出并得到想要的结果,你必须在每次调用greet()重新分配你的counter变量:

>>> def main():
...     counter = 0
...     greeting, counter = greet("Alice", counter) ...     print(f"{greeting}\nCounter is {counter}")
...     greeting, counter = greet("Bob", counter) ...     print(f"{greeting}\nCounter is {counter}")
...
>>> def greet(name, counter):
...     return f"Hi, {name}!", counter + 1
...
>>> main()
Hi, Alice!
Counter is 1
Hi, Bob!
Counter is 2

现在,通过调用greet()重新分配每个变量后,您可以看到想要的结果!

将返回值赋给变量是获得与 Python 中通过引用传递相同结果的最佳方式。在关于最佳实践的章节中,您将了解到原因以及一些额外的方法。

Remove ads

创建条件多重返回函数

这是返回多个值的一个特定用例,其中该函数可以在一个条件语句中使用,并具有额外的副作用,如修改作为参数传入的外部变量。

考虑一下标准的 Int32。C#中的 TryParse 函数,返回一个布尔值,同时对一个整数参数的引用进行操作:

public  static  bool  TryParse  (string  s,  out  int  result);

该函数试图使用 out关键字string转换为 32 位有符号整数。有两种可能的结果:

  1. 如果解析成功,那么输出参数将设置为结果整数,函数将返回true
  2. 如果解析失败,那么输出参数将被设置为0,函数将返回false

您可以在下面的示例中看到这一点,该示例尝试转换许多不同的字符串:

using  System; // Source:
// https://docs.microsoft.com/en-us/dotnet/api/system.int32.tryparse?view=netcore-3.1#System_Int32_TryParse_System_String_System_Int32__
public  class  Example  { public  static  void  Main()  { String[]  values  =  {  null,  "160519",  "9432.0",  "16,667", "   -322   ",  "+4302",  "(100);",  "01FA"  }; foreach  (var  value  in  values)  { int  number; if  (Int32.TryParse(value,  out  number))  { Console.WriteLine("Converted '{0}' to {1}.",  value,  number); } else  { Console.WriteLine("Attempted conversion of '{0}' failed.", value  ??  "<null>"); } } } }

上面的代码试图通过TryParse()将不同格式的字符串转换成整数,输出如下:

Attempted conversion of '<null>' failed.
Converted '160519' to 160519.
Attempted conversion of '9432.0' failed.
Attempted conversion of '16,667' failed.
Converted '   -322   ' to -322.
Converted '+4302' to 4302.
Attempted conversion of '(100);' failed.
Attempted conversion of '01FA' failed.

要在 Python 中实现类似的函数,您可以使用多个返回值,如前所述:

def tryparse(string, base=10):
    try:
        return True, int(string, base=base)
    except ValueError:
        return False, None

这个tryparse()返回两个值。第一个值指示转换是否成功,第二个值保存结果(如果失败,则保存 None )。

然而,使用这个函数有点笨拙,因为您需要在每次调用时解包返回值。这意味着您不能在 if语句中使用该函数:

>>> success, result = tryparse("123")
>>> success
True
>>> result
123

>>> # We can make the check work
>>> # by accessing the first element of the returned tuple,
>>> # but there's no way to reassign the second element to `result`:
>>> if tryparse("456")[0]:
...     print(result)
...
123

尽管它通常通过返回多个值来工作,但是tryparse()不能用于条件检查。这意味着你有更多的工作要做。

您可以利用 Python 的灵活性并简化函数,根据转换是否成功返回不同类型的单个值:

def tryparse(string, base=10):
    try:
        return int(string, base=base)
    except ValueError:
        return None

由于 Python 函数能够返回不同的数据类型,现在可以在条件语句中使用该函数。但是怎么做呢?难道您不需要先调用函数,指定它的返回值,然后检查值本身吗?

通过利用 Python 在对象类型方面的灵活性,以及 Python 3.8 中新的赋值表达式,您可以在条件if语句*中调用这个简化的函数,如果检查通过,*将获得返回值:

>>> if (n := tryparse("123")) is not None:
...     print(n)
...
123
>>> if (n := tryparse("abc")) is None:
...     print(n)
...
None

>>> # You can even do arithmetic!
>>> 10 * tryparse("10")
100

>>> # All the functionality of int() is available:
>>> 10 * tryparse("0a", base=16)
100

>>> # You can also embed the check within the arithmetic expression!
>>> 10 * (n if (n := tryparse("123")) is not None else 1)
1230
>>> 10 * (n if (n := tryparse("abc")) is not None else 1)
10

哇!这个 Python 版本的tryparse()甚至比 C#版本更强大,允许您在条件语句和算术表达式中使用它。

通过一点小聪明,您复制了一个特定且有用的按引用传递模式,而实际上没有按引用传递参数。事实上,当使用赋值表达式操作符(:=)并在 Python 表达式中直接使用返回值时,您再次为返回值赋值。

到目前为止,您已经了解了通过引用传递意味着什么,它与通过值传递有何不同,以及 Python 的方法与这两者有何不同。现在您已经准备好仔细研究 Python 如何处理函数参数了!

Remove ads

用 Python 传递参数

Python 通过赋值传递参数。也就是说,当您调用 Python 函数时,每个函数参数都变成一个变量,传递的值被分配给该变量。

因此,通过理解赋值机制本身是如何工作的,甚至是在函数之外,您可以了解 Python 如何处理函数参数的重要细节。

理解 Python 中的赋值

赋值语句的 Python 语言参考提供了以下细节:

  • 如果赋值目标是一个标识符或变量名,那么这个名字就被绑定到对象上。比如在x = 2中,x是名字,2是对象。
  • 如果名称已经绑定到一个单独的对象,那么它将重新绑定到新对象。比如说,如果x已经是2了,你发出x = 3,那么变量名x被重新绑定到3

所有的 Python 对象都在一个特定的结构中实现。这个结构的属性之一是一个计数器,它记录有多少个名字被绑定到这个对象。

注意:这个计数器被称为引用计数器,因为它跟踪有多少引用或名称指向同一个对象。不要混淆引用计数器和按引用传递的概念,因为这两者是不相关的。

Python 文档提供了关于引用计数的更多细节。

让我们继续看x = 2的例子,看看当你给一个新变量赋值时会发生什么:

  1. 如果表示值2的对象已经存在,那么就检索它。否则,它被创建。
  2. 该对象的引用计数器递增。
  3. 在当前的名称空间中添加一个条目,将标识符x绑定到表示2的对象。这个条目实际上是存储在字典中的一个键-值对!由locals()globals()返回该字典的表示。

现在,如果您将x重新赋值为不同的值,会发生以下情况:

  1. 代表2的对象的引用计数器递减。
  2. 表示新值的对象的引用计数器递增。
  3. 当前名称空间的字典被更新,以将x与表示新值的对象相关联。

Python 允许您使用函数sys.getrefcount()获得任意值的引用计数。您可以用它来说明赋值如何增加和减少这些引用计数器。请注意,交互式解释器采用的行为会产生不同的结果,因此您应该从文件中运行以下代码:

from sys import getrefcount

print("--- Before  assignment ---")
print(f"References to value_1: {getrefcount('value_1')}")
print(f"References to value_2: {getrefcount('value_2')}")
x = "value_1"
print("--- After   assignment ---")
print(f"References to value_1: {getrefcount('value_1')}")
print(f"References to value_2: {getrefcount('value_2')}")
x = "value_2"
print("--- After reassignment ---")
print(f"References to value_1: {getrefcount('value_1')}")
print(f"References to value_2: {getrefcount('value_2')}")

此脚本将显示赋值前、赋值后和重新赋值后每个值的引用计数:

--- Before  assignment ---
References to value_1: 3
References to value_2: 3
--- After   assignment ---
References to value_1: 4
References to value_2: 3
--- After reassignment ---
References to value_1: 3
References to value_2: 4

这些结果说明了标识符(变量名)和代表不同值的 Python 对象之间的关系。当您将多个变量赋给同一个值时,Python 会增加现有对象的引用计数器,并更新当前名称空间,而不是在内存中创建重复的对象。

在下一节中,您将通过探索 Python 如何处理函数参数来建立对赋值操作的理解。

探索函数参数

Python 中的函数参数是局部变量。那是什么意思?局部是 Python 的作用域之一。这些范围由上一节提到的名称空间字典表示。您可以使用locals()globals()分别检索本地和全局名称空间字典。

执行时,每个函数都有自己的本地名称空间:

>>> def show_locals():
...     my_local = True
...     print(locals())
...
>>> show_locals()
{'my_local': True}

使用locals(),您可以演示函数参数在函数的本地名称空间中成为常规变量。让我们给函数添加一个参数my_arg:

>>> def show_locals(my_arg):
...     my_local = True
...     print(locals())
...
>>> show_locals("arg_value")
{'my_arg': 'arg_value', 'my_local': True}

您还可以使用sys.getrefcount()来展示函数参数如何增加对象的引用计数器:

>>> from sys import getrefcount

>>> def show_refcount(my_arg):
...     return getrefcount(my_arg)
...
>>> getrefcount("my_value")
3
>>> show_refcount("my_value")
5

上面的脚本首先输出"my_value"外部的引用计数,然后输出show_refcount()内部的引用计数,显示引用计数增加了两个,而不是一个!

那是因为,除了show_refcount()本身之外,show_refcount()内部对sys.getrefcount()的调用也接收my_arg作为参数。这将my_arg放在sys.getrefcount()的本地名称空间中,增加了对"my_value"的额外引用。

通过检查函数内部的名称空间和引用计数,您可以看到函数参数的工作方式与赋值完全一样:Python 在函数的本地名称空间中创建标识符和表示参数值的 Python 对象之间的绑定。这些绑定中的每一个都会增加对象的引用计数器。

现在您可以看到 Python 是如何通过赋值传递参数的了!

Remove ads

用 Python 复制按引用传递

在上一节中检查了名称空间之后,您可能会问为什么没有提到 global 作为一种修改变量的方法,就好像它们是通过引用传递的一样:

>>> def square():
...     # Not recommended!
...     global n
...     n *= n
...
>>> n = 4
>>> square()
>>> n
16

使用global语句通常会降低代码的清晰度。这可能会产生许多问题,包括以下问题:

  • 自由变量,看似与任何事物无关
  • 对于所述变量没有显式参数的函数
  • 不能与其他变量或参数一起使用的函数,因为它们依赖于单个全局变量
  • 使用全局变量时缺少线程安全

将前面的示例与下面的示例进行对比,下面的示例显式返回值:

>>> def square(n):
...     return n * n
...
>>> square(4)
16

好多了!你避免了全局变量的所有潜在问题,通过要求一个参数,你使你的函数更加清晰。

尽管既不是按引用传递的语言,也不是按值传递的语言,Python 在这方面没有缺点。它的灵活性足以应对挑战。

最佳实践:返回并重新分配

您已经谈到了从函数返回值并将它们重新赋值给一个变量。对于操作单个值的函数,返回值比使用引用要清楚得多。此外,由于 Python 已经在幕后使用指针,即使它能够通过引用传递参数,也不会有额外的性能优势。

旨在编写返回一个值的专用函数,然后将该值(重新)赋给变量,如下例所示:

def square(n):
    # Accept an argument, return a value.
    return n * n

x = 4
...
# Later, reassign the return value:
x = square(x)

返回和赋值也使您的意图更加明确,代码更容易理解和测试。

对于操作多个值的函数,您已经看到 Python 能够返回一组值。你甚至超越了 Int32 的的优雅。C#中的 try parse()感谢 Python 的灵活性!

如果您需要对多个值进行操作,那么您可以编写返回多个值的单用途函数,然后将这些值赋给变量。这里有一个例子:

def greet(name, counter):
    # Return multiple values
    return f"Hi, {name}!", counter + 1

counter = 0
...
# Later, reassign each return value by unpacking.
greeting, counter = greet("Alice", counter)

当调用返回多个值的函数时,可以同时分配多个变量。

最佳实践:使用对象属性

对象属性在 Python 的赋值策略中有自己的位置。Python 对赋值语句的语言参考声明,如果目标是支持赋值的对象属性,那么对象将被要求对该属性执行赋值。如果您将对象作为参数传递给函数,那么它的属性可以就地修改。

编写接受具有属性的对象的函数,然后直接对这些属性进行操作,如下例所示:

>>> # For the purpose of this example, let's use SimpleNamespace.
>>> from types import SimpleNamespace

>>> # SimpleNamespace allows us to set arbitrary attributes.
>>> # It is an explicit, handy replacement for "class X: pass".
>>> ns = SimpleNamespace()

>>> # Define a function to operate on an object's attribute. >>> def square(instance):
...     instance.n *= instance.n
...
>>> ns.n = 4
>>> square(ns)
>>> ns.n
16

请注意,square()需要被编写为直接操作一个属性,该属性将被修改,而不需要重新分配返回值。

值得重复的是,您应该确保属性支持赋值!下面是与namedtuple相同的例子,它的属性是只读的:

>>> from collections import namedtuple
>>> NS = namedtuple("NS", "n")
>>> def square(instance):
...     instance.n *= instance.n
...
>>> ns = NS(4)
>>> ns.n
4
>>> square(ns)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in square
AttributeError: can't set attribute

试图修改不允许修改的属性会导致AttributeError

此外,您应该注意类属性。它们将保持不变,并将创建和修改一个实例属性:

>>> class NS:
...     n = 4
...
>>> ns = NS()
>>> def square(instance):
...     instance.n *= instance.n
...
>>> ns.n
4
>>> square(ns)
>>> # Instance attribute is modified.
>>> ns.n
16
>>> # Class attribute remains unchanged.
>>> NS.n
4

因为类属性在通过类实例修改时保持不变,所以您需要记住引用实例属性。

Remove ads

最佳实践:使用字典和列表

Python 中的字典是一种不同于所有其他内置类型的对象类型。它们被称为映射类型。Python 关于映射类型的文档提供了对该术语的一些见解:

一个映射对象将的散列值映射到任意对象。映射是可变的对象。目前只有一种标准的映射类型,即字典。(来源)

本教程没有介绍如何实现自定义映射类型,但是您可以使用简单的字典通过引用来复制传递。下面是一个使用直接作用于字典元素的函数的示例:

>>> # Dictionaries are mapping types.
>>> mt = {"n": 4}
>>> # Define a function to operate on a key:
>>> def square(num_dict):
...     num_dict["n"] *= num_dict["n"]
...
>>> square(mt)
>>> mt
{'n': 16}

因为您是在给字典键重新赋值,所以对字典元素的操作仍然是一种赋值形式。使用字典,您可以通过同一个字典对象访问修改后的值。

虽然列表不是映射类型,但是您可以像使用字典一样使用它们,因为有两个重要的特性:可订阅性可变性。这些特征值得再解释一下,但是让我们先来看看使用 Python 列表模仿按引用传递的最佳实践。

要使用列表复制引用传递,请编写一个直接作用于列表元素的函数:

>>> # Lists are both subscriptable and mutable.
>>> sm = [4]
>>> # Define a function to operate on an index:
>>> def square(num_list):
...     num_list[0] *= num_list[0]
...
>>> square(sm)
>>> sm
[16]

因为您是在给列表中的元素重新赋值,所以对列表元素的操作仍然是一种赋值形式。与字典类似,列表允许您通过同一个列表对象访问修改后的值。

现在让我们来探索可订阅性。当一个对象的结构子集可以通过索引位置访问时,该对象是可订阅的:

>>> subscriptable = [0, 1, 2]  # A list
>>> subscriptable[0]
0
>>> subscriptable = (0, 1, 2)  # A tuple
>>> subscriptable[0]
0
>>> subscriptable = "012"  # A string
>>> subscriptable[0]
'0'
>>> not_subscriptable = {0, 1, 2}  # A set
>>> not_subscriptable[0]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'set' object is not subscriptable

列表、元组和字符串是可下标的,但集合不是。试图访问一个不可订阅的对象元素会引发一个TypeError

可变性是一个更广泛的主题,需要额外的探索文档参考。简而言之,如果一个对象的结构可以就地改变而不需要重新分配,那么它就是可变的:

>>> mutable = [0, 1, 2]  # A list
>>> mutable[0] = "x"
>>> mutable
['x', 1, 2]

>>> not_mutable = (0, 1, 2)  # A tuple
>>> not_mutable[0] = "x"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

>>> not_mutable = "012"  # A string
>>> not_mutable[0] = "x"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment

>>> mutable = {0, 1, 2}  # A set
>>> mutable.remove(0)
>>> mutable.add("x")
>>> mutable
{1, 2, 'x'}

列表和集合是可变的,就像字典和其他映射类型一样。字符串和元组是不可变的。试图修改一个不可变对象的元素会引发一个TypeError

结论

Python 的工作方式不同于支持通过引用或值传递参数的语言。函数参数成为分配给传递给函数的每个值的局部变量。但是这并不妨碍您在其他语言中通过引用传递参数时获得预期的相同结果。

在本教程中,您学习了:

  • Python 如何处理给变量赋值
  • Python 中函数参数如何通过赋值函数传递
  • 为什么返回值是通过引用复制传递的最佳实践
  • 如何使用属性字典列表作为备选最佳实践

您还学习了一些在 Python 中复制按引用传递构造的其他最佳实践。您可以使用这些知识来实现传统上需要支持按引用传递的模式。

为了继续您的 Python 之旅,我鼓励您更深入地研究您在这里遇到的一些相关主题,例如可变性赋值表达式Python 名称空间和范围

保持好奇,下次见!

立即观看**本教程有真实 Python 团队创建的相关视频课程。和书面教程一起看,加深理解: 顺便引用 Python 中的:最佳实践******

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值