原文:RealPython
Python 中的 OrderedDict 与 Dict:适合工作的工具
有时你需要一个 Python 字典来记住条目的顺序。在过去,你只有一个工具来解决这个特定的问题:Python 的 OrderedDict
。它是一个字典子类,专门用来记住条目的顺序,这是由键的插入顺序定义的。
这在 Python 3.6 中有所改变。内置的dict
类现在也保持其项目有序。因此,Python 社区中的许多人现在想知道OrderedDict
是否仍然有用。仔细观察OrderedDict
会发现这个职业仍然提供有价值的特性。
在本教程中,您将学习如何:
- 在你的代码中创建并使用
OrderedDict
对象 - 确定
OrderedDict
和dict
之间的差异 - 了解使用
OrderedDict
vsdict
的优点和缺点
有了这些知识,当您想要保持项目的顺序时,您将能够选择最适合您需要的字典类。
在本教程结束时,您将看到一个使用OrderedDict
实现基于字典的队列的示例,如果您使用常规的dict
对象,这将更具挑战性。
免费奖励: 并学习 Python 3 的基础知识,如使用数据类型、字典、列表和 Python 函数。
在OrderedDict
和dict
之间选择
多年来,Python 字典是无序的数据结构。Python 开发者已经习惯了这个事实,当他们需要保持数据有序时,他们依赖于列表或其他序列。随着时间的推移,开发人员发现需要一种新型的字典,一种可以保持条目有序的字典。
早在 2008 年, PEP 372 就引入了给 collections
增加一个新字典类的想法。它的主要目标是记住由插入键的顺序定义的项目顺序。那就是OrderedDict
的由来。
核心 Python 开发人员想要填补这个空白,提供一个能够保持插入键顺序的字典。这反过来又使得依赖于这一特性的特定算法的实现更加简单。
OrderedDict
被添加到 Python 3.1 的标准库中。它的 API 本质上和dict
一样。然而,OrderedDict
按照插入键的顺序遍历键和值。如果新条目覆盖了现有条目,则项目的顺序保持不变。如果一个条目被删除并重新插入,那么它将被移动到字典的末尾。
Python 3.6 引入了一个对dict
和的新实现。这个新的实现在内存使用和迭代效率方面取得了巨大的成功。此外,新的实现提供了一个新的、有点出乎意料的特性:dict
对象现在以它们被引入时的顺序保存它们的项目。最初,这个特性被认为是一个实现细节,文档建议不要依赖它。
**注意:**在本教程中,您将重点关注 CPython 提供的dict
和OrderedDict
的实现。
用核心 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
:
- **意图信号:**如果你使用
OrderedDict
而不是dict
,那么你的代码清楚地表明了条目在字典中的顺序是重要的。你清楚地表达了你的代码需要或者依赖于底层字典中的条目顺序。 - **控制条目的顺序:**如果您需要重新排列或重新排序字典中的条目,那么您可以使用
.move_to_end()
以及.popitem()
的增强变体。 - **相等测试行为:**如果您的代码比较字典的相等性,并且条目的顺序在比较中很重要,那么
OrderedDict
是正确的选择。
至少还有一个在代码中继续使用OrderedDict
的理由:向后兼容性。在运行 than 3.6 之前版本的环境中,依靠常规的dict
对象来保持项目的顺序会破坏您的代码。
很难说dict
会不会很快全面取代OrderedDict
。如今,OrderedDict
仍然提供有趣和有价值的特性,当你为一个给定的工作选择一个工具时,你可能想要考虑这些特性。
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()
的第二个参数为字典中的所有条目提供一个值。
管理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,反向迭代才被添加到常规字典中。
探索 Python 的OrderedDict
的独特功能
从 Python 3.6 开始,常规字典按照插入底层字典的顺序保存条目。正如你到目前为止所看到的,这限制了OrderedDict
的有用性。然而,OrderedDict
提供了一些你在常规的dict
对象中找不到的独特特性。
使用有序字典,您可以访问以下额外的和增强的方法:
-
.move_to_end()
是 Python 3.2 中添加的一个新方法,它允许你将一个已有的条目移动到字典的末尾或开头。 -
.popitem()
是其对应的dict.popitem()
的增强变体,允许您从底层有序字典的末尾或开头移除和返回一个项目。
OrderedDict
和dict
在进行相等性测试时也表现不同。具体来说,当您比较有序字典时,条目的顺序很重要。正规词典就不是这样了。
最后,OrderedDict
实例提供了一个名为 .__dict__
的属性,这是你在常规字典实例中找不到的。此属性允许您向现有有序字典添加自定义可写属性。
用.move_to_end()
和重新排序项目
dict
和OrderedDict
最显著的区别之一是后者有一个额外的方法叫做.move_to_end()
。这种方法允许您将现有的条目移动到底层字典的末尾或开头,因此这是一个重新排序字典的好工具。
当您使用.move_to_end()
时,您可以提供两个参数:
-
key
持有标识您要移动的项目的键。如果key
不存在,那么你得到一个KeyError
。 -
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)])
在本例中,首先创建一个有序字典letters
。for
循环遍历其排序后的键,并将每一项移动到字典的末尾。当循环结束时,有序字典的条目按键排序。
按值对字典排序将是一个有趣的练习,所以扩展下面的块并尝试一下吧!
按值对以下字典进行排序:
>>> 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()
对有序的字典进行重新排序。你已经准备好进入下一部分了。
移除带有.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_1
与letters_0
和letters_2
相比,其条目的顺序略有不同,所以第一个测试返回False
。在第二个测试中,letters_0
和letters_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__
属性来保存新的实例属性。
用运算符合并和更新字典
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
占用更少的内存。
为工作选择正确的词典
到目前为止,你已经了解了OrderedDict
和dict
之间的细微差别。您已经了解到,尽管从 Python 3.6 开始,常规字典已经是有序的数据结构,但是使用OrderedDict
仍然有一些价值,因为有一组有用的特性是dict
中没有的。
下面总结了这两个类更相关的差异和特性,在您决定使用哪一个时应该加以考虑:
特征 | OrderedDict | dict |
---|---|---|
保持钥匙插入顺序 | 是(从 Python 3.1 开始) | 是(从 Python 3.6 开始) |
关于项目顺序的可读性和意图信号 | 高的 | 低的 |
对项目顺序的控制 | 高(.move_to_end() ,增强型.popitem() ) | 低(需要移除和重新插入项目) |
运营绩效 | 低的 | 高的 |
内存消耗 | 高的 | 低的 |
相等测试考虑项目的顺序 | 是 | 不 |
支持反向迭代 | 是(从 Python 3.5 开始) | 是(从 Python 3.8 开始) |
能够附加新的实例属性 | 是(.__dict__ 属性) | 不 |
支持合并(| )和更新(|= )字典操作符 | 是(从 Python 3.9 开始) | 是(从 Python 3.9 开始) |
这个表格总结了OrderedDict
和dict
之间的一些主要区别,当您需要选择一个字典类来解决一个问题或者实现一个特定的算法时,您应该考虑这些区别。一般来说,如果字典中条目的顺序对于代码的正确运行至关重要,那么你首先应该看一看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()
,对新键使用普通赋值。注意,为了让这个方法工作,您需要提供一个两项的tuple
或list
以及一个有效的键-值对。
.dequeue()
实现使用.popitem()
和设置为False
的last
从底层有序字典.data
的开始移除和返回条目。在这种情况下,您使用一个 try
… except
块来处理在空字典上调用.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
对象 OrderedDict
和dict
之间的主要差异是什么- 使用
OrderedDict
vsdict
的好处和坏处是什么
现在,如果您的代码需要一个有序的字典,您可以更好地决定是使用dict
还是OrderedDict
。
在本教程中,您编写了一个如何实现基于字典的队列的示例,这是一个用例,表明OrderedDict
在您的日常 Python 编码冒险中仍然有价值。******
Python 包:五个真正的 Python 最爱
Python 有一个由包、模块和库组成的庞大生态系统,您可以用它来创建您的应用程序。其中一些包和模块包含在您的 Python 安装中,统称为标准库。
标准库由为常见编程问题提供标准化解决方案的模块组成。它们是跨许多学科的应用程序的重要组成部分。然而,许多开发人员更喜欢使用替代包,或扩展,这可能会提高标准库中内容的可用性和有用性。
在本教程中,您将在 Real Python 见到一些作者,并了解他们喜欢用哪些包来代替标准库中更常见的包。
您将在本教程中了解的软件包有:
pudb
:一个基于文本的高级可视化调试器requests
:一个漂亮的 HTTP 请求 APIparse
:直观、易读的文本匹配器dateutil
:热门datetime
库的扩展typer
:直观的命令行界面解析器
首先,你将看到一个视觉上强大的pdb
的替代品。
免费下载: 从 Python 技巧中获取一个示例章节:这本书用简单的例子向您展示了 Python 的最佳实践,您可以立即应用它来编写更漂亮的+Python 代码。
pudb
进行可视化调试
Christopher Trudeau是 Real Python 的作者和课程创建者。在工作中,他是一名顾问,帮助组织改善他们的技术团队。在家里,他花时间玩棋类游戏和摄影。
我花了很多时间将隐藏到远程机器中,所以我不能利用大多数ide。我选择的调试器是 pudb
,它有一个基于文本的用户界面。我觉得它的界面直观易用。
Python 搭载 pdb
,其灵感来源于 gdb
,其本身的灵感来源于 dbx
。虽然pdb
完成了这项工作,但它最大的优势在于它搭载了 Python。因为它是基于命令行的,所以你必须记住很多快捷键,而且一次只能看到少量的源代码。
用于调试的另一个 Python 包是pudb
。它显示了整个源代码屏幕以及有用的调试信息。它还有一个额外的好处,那就是让我怀念过去我编写涡轮帕斯卡(T2)代码的日子:
该界面分为两个主要部分。左侧面板用于源代码,右侧面板用于上下文信息。右侧分为三个部分:
- 变量
- 堆
- 断点
您在调试器中需要的一切都可以在一个屏幕上找到。
与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
中断执行并显示其接口:
您可以使用键盘导航和执行源代码:
钥匙 | 行动 |
---|---|
Up 或 K | 将代码上移一行 |
Down 或 J | 将代码下移一行 |
Page Up 或 Ctrl + B | 向上滚动代码页 |
Page Down 或 Ctrl + F | 向下滚动代码页 |
T2N | 执行当前行 |
T2S | 如果是函数,则进入当前行 |
T2C | 继续执行到下一个断点 |
如果你重启你的代码,那么pudb
会记住前一个会话的断点。 Right
和 Left
允许你在源代码和右边的控制区之间移动。
在变量框中,您可以看到当前范围内的所有变量:
默认情况下,变量的视图会被缩短,但您可以通过按 \
来查看完整的内容。展开视图将显示元组或列表中的项目,或者显示二进制变量的完整内容。T``R
在repr
和type
显示模式之间来回切换。
使用观察表达式和访问 REPL
当右侧的变量区域被聚焦时,您还可以添加一个观察表达式。手表可以是任何 Python 表达式。这对于在对象仍处于缩短形式时检查深藏在对象中的数据或评估变量之间的复杂关系非常有用。
**注意:**通过按 N
添加一个手表表情。由于 N
也用于执行当前代码行,所以在按键之前,必须确保屏幕的右侧区域处于焦点上。
按下 !
可以跳出当前运行程序的 REPL。此模式还显示调试器触发之前发送到屏幕的任何输出。通过导航界面或使用快捷键,您还可以修改断点、更改您在堆栈框架中的位置以及加载其他源代码文件。
为什么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
是如何保持可读性的。当你用你可信赖的浏览器处理这个任务时,你应该遵循以下步骤:
- 打开浏览器。
- 输入网址。
- 看网站的文字。
你如何用代码达到同样的结果?首先,您在伪代码中规划必要的步骤:
- 导入您需要的工具。
- 获取网站的数据。
- 打印网站的文本。
阐明逻辑后,您使用requests
库将伪代码翻译成 Python :
>>> import requests
>>> response = requests.get("http://www.example.com")
>>> response.text
代码读起来几乎像英语,简洁明了。虽然使用标准库的urllib
包构建这个基本示例并不难,但是requests
即使在更复杂的场景中也能保持其简单明了、以人为中心的语法。
在下一个例子中,您将看到只用几行 Python 代码就可以完成很多事情。
requests
是强大的
让我们加快游戏速度,挑战requests
更复杂的任务:
- 登录您的 GitHub 帐户。
- 持久化登录信息以处理多个请求。
- 创建新的存储库。
- 创建一个包含一些内容的新文件。
- 仅当第一个请求成功时,才运行第二个请求。
挑战已接受并完成!下面的代码片段完成了上述所有任务。您需要做的就是分别用您的 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 498 。 pepdocs
是一个可以下载 Python 增强提案(PEP)文档文本的小工具。
从 PyPI 安装parse
和pepdocs
:
$ 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'
给你字符串中匹配你的模式的索引。
使用格式说明符
你可以用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
互动。
设置时区
有几个有利因素。首先,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
的四个特性,当你处理时间时,它们让你的生活变得更轻松:
- 设置与
datetime
对象兼容的时区的便捷方式 - 一种将字符串解析成日期的有用方法
- 进行时间运算的强大接口
- 一种计算重复或未来日期的绝妙方法。
下一次当你试图用时间编程而变得灰白时,试试吧!
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 解析上的精力是浪费!
为什么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 熊猫:你可能不知道的技巧和特性
*立即观看**本教程有真实 Python 团队创建的相关视频课程。配合文字教程一起看,加深理解: 惯用熊猫:招数&你可能不知道的特点
Pandas 是分析、数据处理和数据科学的基础库。这是一个巨大的项目,有大量的选择和深度。
本教程将介绍一些很少使用但很习惯的 Pandas 功能,这些功能可以让您的代码具有更好的可读性、多功能性和速度。
如果您对 Python 的 Pandas 库的核心概念感到满意,希望您能在本文中找到一两个以前没有遇到过的技巧。(如果你刚从图书馆开始,10 分钟到熊猫是一个好的开始。)
注意:本文中的例子是用 Pandas 版本 0.23.2 和 Python 3.6.6 测试的。但是,它们在旧版本中也应该有效。
1.在解释器启动时配置选项和设置
你可能以前也碰到过熊猫丰富的选项和设置系统。
在解释器启动时设置定制的 Pandas 选项是一个巨大的生产力节省,特别是如果你在一个脚本环境中工作。你可以使用pd.set_option()
来随心所欲地配置一个 Python 或 IPython 启动文件。
这些选项使用一个点符号,比如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
稍后,您还会在其他示例中看到这个数据集。
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 类:
.str
映射到StringMethods
。.dt
映射到CombinedDatetimelikeProperties
。.cat
航线到CategoricalAccessor
。
然后使用 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
,仅用于分类数据,您将很快在它自己的部分中看到。
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
类型可能不太灵活。
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()
以将其值映射或“翻译”到相应的组索引。
8.了解熊猫如何使用布尔运算符
你可能熟悉 Python 的运算符优先级,其中and
、not
、or
的优先级低于<
、<=
、>
、>=
、!=
、==
等算术运算符。考虑下面两条语句,其中<
和>
的优先级高于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 就是在其上构建的)不使用and
、or
或not
。相反,它分别使用了&
、|
和~
,这是正常的、真正的 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
。
好了,现在停下来,让我们回到熊猫的话题。你有两个熊猫系列,我们称之为left
和right
:
>>> left = (s % 2) == (0 & s)
>>> right = (0 & s) > 3
>>> left and right # This will raise the same ValueError
您知道形式为left and right
的语句对left
和right
都进行真值测试,如下所示:
>>> 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
弹出布尔索引,你可能要做的第一件事就是添加一些需要的括号。
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 压缩,而不是将未压缩的文件存储在内存中并进行转换。这里有一个使用来自技巧#1 的abalone
数据的例子:
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 中的引用传递:背景和最佳实践
*立即观看**本教程有真实 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 中的等效引用传递。**
对照按引用传递和按值传递
当您通过引用传递函数参数时,这些参数只是对现有值的引用。相反,当您通过值传递参数时,这些参数将成为原始值的独立副本。
让我们重温一下 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()
,您可以验证以下断言:
- 函数参数最初引用与其原始变量相同的地址。
- 在函数中重新分配参数会给它一个新的地址,而原始变量保持不变。
在下面的例子中,注意到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()
时,n
和x
的初始地址是相同的,这一事实证明了x
参数不是通过值传递的。否则,n
和x
将会有不同的内存地址。
在学习 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 中通过引用传递相同结果的最佳方式。在关于最佳实践的章节中,您将了解到原因以及一些额外的方法。
创建条件多重返回函数
这是返回多个值的一个特定用例,其中该函数可以在一个条件语句中使用,并具有额外的副作用,如修改作为参数传入的外部变量。
考虑一下标准的 Int32。C#中的 TryParse 函数,返回一个布尔值,同时对一个整数参数的引用进行操作:
public static bool TryParse (string s, out int result);
该函数试图使用 out
关键字将string
转换为 32 位有符号整数。有两种可能的结果:
- 如果解析成功,那么输出参数将设置为结果整数,函数将返回
true
。 - 如果解析失败,那么输出参数将被设置为
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 如何处理函数参数了!
用 Python 传递参数
Python 通过赋值传递参数。也就是说,当您调用 Python 函数时,每个函数参数都变成一个变量,传递的值被分配给该变量。
因此,通过理解赋值机制本身是如何工作的,甚至是在函数之外,您可以了解 Python 如何处理函数参数的重要细节。
理解 Python 中的赋值
赋值语句的 Python 语言参考提供了以下细节:
- 如果赋值目标是一个标识符或变量名,那么这个名字就被绑定到对象上。比如在
x = 2
中,x
是名字,2
是对象。 - 如果名称已经绑定到一个单独的对象,那么它将重新绑定到新对象。比如说,如果
x
已经是2
了,你发出x = 3
,那么变量名x
被重新绑定到3
。
所有的 Python 对象都在一个特定的结构中实现。这个结构的属性之一是一个计数器,它记录有多少个名字被绑定到这个对象。
注意:这个计数器被称为引用计数器,因为它跟踪有多少引用或名称指向同一个对象。不要混淆引用计数器和按引用传递的概念,因为这两者是不相关的。
Python 文档提供了关于引用计数的更多细节。
让我们继续看x = 2
的例子,看看当你给一个新变量赋值时会发生什么:
- 如果表示值
2
的对象已经存在,那么就检索它。否则,它被创建。 - 该对象的引用计数器递增。
- 在当前的名称空间中添加一个条目,将标识符
x
绑定到表示2
的对象。这个条目实际上是存储在字典中的一个键-值对!由locals()
或globals()
返回该字典的表示。
现在,如果您将x
重新赋值为不同的值,会发生以下情况:
- 代表
2
的对象的引用计数器递减。 - 表示新值的对象的引用计数器递增。
- 当前名称空间的字典被更新,以将
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 是如何通过赋值传递参数的了!
用 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
因为类属性在通过类实例修改时保持不变,所以您需要记住引用实例属性。
最佳实践:使用字典和列表
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 中的:最佳实践******