来源:
allendowney.github.io/ThinkPython/
译者:飞龙
17. 继承
与面向对象编程最常关联的语言特性是继承。继承是定义一个新的类,该类是现有类的修改版本的能力。在本章中,我将通过表示扑克牌、扑克牌组和扑克手牌的类来演示继承。如果你不玩扑克,不用担心——我会告诉你需要了解的内容。
17.1. 表示牌
一副标准的扑克牌有 52 张——每一张牌属于四种花色之一和十三种点数之一。花色有黑桃、红桃、方块和梅花。点数有 Ace(王牌)、2、3、4、5、6、7、8、9、10、J(杰克)、Q(女王)和 K(国王)。根据你玩的游戏规则,Ace 可以比 K 高,也可以比 2 低。
如果我们想定义一个新的对象来表示一张扑克牌,属性应该是显而易见的:rank
和 suit
。然而,属性应该是什么类型就不那么明显了。一个可能的选择是使用字符串,比如用 'Spade'
表示花色,'Queen'
表示点数。这个实现的问题在于,比较牌的大小,看看哪张牌的点数或花色更高,将变得不那么容易。
另一种选择是使用整数来编码点数和花色。在这里,“编码”意味着我们将定义一个数字与花色之间,或者数字与点数之间的映射。这种编码并不意味着是保密的(那是“加密”)。
例如,这个表格展示了花色和相应的整数代码:
花色 | 代码 |
---|---|
黑桃 | 3 |
红桃 | 2 |
方块 | 1 |
梅花 | 0 |
使用这种编码,我们可以通过比较它们的代码来比较花色。
为了编码点数,我们将使用整数 2
来表示点数 2
,3
来表示 3
,依此类推,一直到 10
。下面的表格展示了面牌的代码。
点数 | 代码 |
---|---|
杰克 | 11 |
女王 | 12 |
国王 | 13 |
我们可以使用 1
或 14
来表示 Ace(王牌),具体取决于我们希望它被视为比其他点数低还是高。
为了表示这些编码,我们将使用两个字符串列表,一个包含花色的名称,另一个包含点数的名称。
这是一个表示扑克牌的类的定义,使用这些字符串列表作为类变量,类变量是定义在类内部,但不在方法内部的变量。
class Card:
"""Represents a standard playing card."""
suit_names = ['Clubs', 'Diamonds', 'Hearts', 'Spades']
rank_names = [None, 'Ace', '2', '3', '4', '5', '6', '7',
'8', '9', '10', 'Jack', 'Queen', 'King', 'Ace']
rank_names
的第一个元素是 None
,因为没有点数为零的牌。通过包括 None
作为占位符,我们得到了一个很好的属性:索引 2
映射到字符串 '2'
,以此类推。
类变量与类相关联,而不是与类的实例相关联,因此我们可以像这样访问它们。
Card.suit_names
['Clubs', 'Diamonds', 'Hearts', 'Spades']
我们可以使用 suit_names
来查找花色并获取相应的字符串。
Card.suit_names[0]
'Clubs'
并且可以使用 rank_names
来查找点数。
Card.rank_names[11]
'Jack'
17.2. 卡片属性
这是Card
类的__init__
方法——它接受花色
和点数
作为参数,并将它们分配给具有相同名称的属性。
%%add_method_to Card
def __init__(self, suit, rank):
self.suit = suit
self.rank = rank
现在我们可以这样创建一个Card
对象。
queen = Card(1, 12)
我们可以使用新的实例来访问属性。
queen.suit, queen.rank
(1, 12)
使用实例来访问类变量也是合法的。
queen.suit_names
['Clubs', 'Diamonds', 'Hearts', 'Spades']
但如果使用类来访问,能够更清楚地表明它们是类变量,而不是实例属性。
17.3. 打印卡片
这是一个__str__
方法,用于Card
对象。
%%add_method_to Card
def __str__(self):
rank_name = Card.rank_names[self.rank]
suit_name = Card.suit_names[self.suit]
return f'{rank_name} of {suit_name}'
当我们打印一个Card
对象时,Python 会调用__str__
方法来获取该卡片的可读表示。
print(queen)
Queen of Diamonds
以下是Card
类对象和卡片实例的示意图。Card
是一个类对象,所以它的类型是type
。queen
是Card
的实例,所以它的类型是Card
。为了节省空间,我没有画出suit_names
和rank_names
的内容。
[外链图片转存中…(img-59DTwrxS-1748168177835)]
每个Card
实例都有自己的suit
和rank
属性,但只有一个Card
类对象,并且类变量suit_names
和rank_names
只有一份副本。
17.4. 比较卡片
假设我们创建了第二个具有相同花色和点数的Card
对象。
queen2 = Card(1, 12)
print(queen2)
Queen of Diamonds
如果我们使用==
运算符来比较它们,它会检查queen
和queen2
是否指向同一个对象。
queen == queen2
False
它们不相等,所以返回False
。我们可以通过定义特殊方法__eq__
来改变这种行为。
%%add_method_to Card
def __eq__(self, other):
return self.suit == other.suit and self.rank == other.rank
__eq__
接受两个Card
对象作为参数,如果它们具有相同的花色和点数,即使它们不是同一个对象,也会返回True
。换句话说,它会检查它们是否等价,即使它们不是同一个对象。
当我们使用==
运算符比较Card
对象时,Python 会调用__eq__
方法。
queen == queen2
True
作为第二个测试,让我们创建一张具有相同花色但不同点数的卡片。
six = Card(1, 6)
print(six)
6 of Diamonds
我们可以确认queen
和six
不是等价的。
queen == six
False
如果我们使用!=
运算符,Python 会调用一个叫做__ne__
的特殊方法(如果存在)。如果没有,它会调用__eq__
并反转结果——也就是说,如果__eq__
返回True
,那么!=
运算符的结果就是False
。
queen != queen2
False
queen != six
True
现在假设我们想比较两张卡片,看看哪一张更大。如果我们使用关系运算符之一,将会出现TypeError
。
queen < queen2
TypeError: '<' not supported between instances of 'Card' and 'Card'
要改变<
运算符的行为,我们可以定义一个特殊的方法叫做__lt__
,它是“less than”(小于)的缩写。为了简单起见,假设花色比点数更重要——所以所有黑桃的等级高于所有红心,红心又高于所有方块,依此类推。如果两张卡片的花色相同,那么点数较大的卡片获胜。
为了实现这个逻辑,我们将使用以下方法,它返回一个元组,包含卡片的花色和点数,按此顺序。
%%add_method_to Card
def to_tuple(self):
return (self.suit, self.rank)
我们可以使用这个方法来编写__lt__
。
%%add_method_to Card
def __lt__(self, other):
return self.to_tuple() < other.to_tuple()
元组比较会比较每个元组的第一个元素,这些元素表示花色。如果它们相同,则比较第二个元素,这些元素表示点数。
现在,如果我们使用<
运算符,它会调用__lt__
方法。
six < queen
True
如果我们使用>
运算符,它会调用一个名为__gt__
的特殊方法(如果存在)。否则,它会使用__lt__
,并将参数顺序调换。
queen < queen2
False
queen > queen2
False
最后,如果我们使用<=
运算符,它会调用一个名为__le__
的特殊方法。
%%add_method_to Card
def __le__(self, other):
return self.to_tuple() <= other.to_tuple()
所以我们可以检查一张牌是否小于或等于另一张牌。
queen <= queen2
True
queen <= six
False
如果我们使用>=
运算符,它会使用__ge__
(如果存在)。否则,它会使用__le__
,并将参数顺序调换。
queen >= six
True
正如我们所定义的,这些方法是完整的,因为我们可以比较任何两个Card
对象,而且是相容的,因为不同运算符的结果不互相矛盾。拥有这两个特性,我们可以说Card
对象是完全有序的。这意味着,正如我们很快将看到的,它们可以被排序。
17.5. 牌组
现在我们有了表示牌的对象,让我们定义表示牌组的对象。以下是Deck
类的定义,其中__init__
方法接收一个Card
对象列表作为参数,并将其赋值给一个名为cards
的属性。
class Deck:
def __init__(self, cards):
self.cards = cards
要创建一个包含标准牌组 52 张牌的列表,我们将使用以下静态方法。
%%add_method_to Deck
def make_cards():
cards = []
for suit in range(4):
for rank in range(2, 15):
card = Card(suit, rank)
cards.append(card)
return cards
在make_cards
中,外循环枚举从0
到3
的花色,内循环枚举从2
到14
的点数——其中14
表示比国王还大的 Ace。每次迭代都会用当前的花色和点数创建一张新的Card
,并将其添加到cards
列表中。
下面是我们如何制作一组牌并创建一个包含这些牌的Deck
对象。
cards = Deck.make_cards()
deck = Deck(cards)
len(deck.cards)
52
它包含 52 张牌,符合预期。
17.6. 打印牌组
这是Deck
的__str__
方法。
%%add_method_to Deck
def __str__(self):
res = []
for card in self.cards:
res.append(str(card))
return '\n'.join(res)
这个方法展示了一种高效地积累大字符串的方式——先构建一个字符串列表,然后使用字符串方法join
。
我们将用一副只包含两张牌的牌组来测试这个方法。
small_deck = Deck([queen, six])
如果我们调用str
,它会调用__str__
方法。
str(small_deck)
'Queen of Diamonds\n6 of Diamonds'
当 Jupyter 显示字符串时,它会显示字符串的“表示”形式,其中换行符用序列\n
表示。
然而,如果我们打印结果,Jupyter 会显示字符串的“可打印”形式,其中换行符被显示为空格。
print(small_deck)
Queen of Diamonds
6 of Diamonds
所以这些牌会显示在不同的行上。
17.7. 添加、删除、洗牌和排序
要发牌,我们需要一个方法,它从牌组中移除一张牌并返回。列表方法pop
提供了一个方便的方式来实现这一点。
%%add_method_to Deck
def take_card(self):
return self.cards.pop()
下面是我们如何使用它。
card = deck.take_card()
print(card)
Ace of Spades
我们可以确认牌组中还剩下51
张牌。
len(deck.cards)
51
要添加一张牌,我们可以使用列表方法append
。
%%add_method_to Deck
def put_card(self, card):
self.cards.append(card)
作为示例,我们可以把刚刚弹出的牌放回去。
deck.put_card(card)
len(deck.cards)
52
要洗牌,我们可以使用random
模块中的shuffle
函数:
import random
%%add_method_to Deck
def shuffle(self):
random.shuffle(self.cards)
如果我们洗牌并打印前几张卡片,我们会看到它们的顺序看似随机。
deck.shuffle()
for card in deck.cards[:4]:
print(card)
2 of Diamonds
4 of Hearts
5 of Clubs
8 of Diamonds
要对卡片进行排序,我们可以使用列表方法sort
,该方法会“就地”排序元素——也就是说,它修改原列表,而不是创建一个新的列表。
%%add_method_to Deck
def sort(self):
self.cards.sort()
当我们调用sort
时,它会使用__lt__
方法来比较卡片。
deck.sort()
如果我们打印前几张卡片,可以确认它们是按升序排列的。
for card in deck.cards[:4]:
print(card)
2 of Clubs
3 of Clubs
4 of Clubs
5 of Clubs
在这个例子中,Deck.sort
除了调用list.sort
之外并不会做其他事情。将责任传递给其他方法的做法称为委托。
17.8. 父类和子类
继承是定义一个新类的能力,这个新类是现有类的修改版。例如,假设我们想定义一个类来表示“手牌”,也就是一个玩家持有的卡片。
-
Hand
类似于Deck
——两者都是由卡片集合组成,并且都需要执行像添加和移除卡片这样的操作。 -
Hand
和Deck
也有不同之处——我们希望对Hand
进行的操作在Deck
上没有意义。例如,在扑克中,我们可能会比较两副牌,看看哪一副胜出。在桥牌中,我们可能会计算一副牌的分数,以便进行叫牌。
这种类之间的关系——其中一个是另一个的专门化版本——非常适合继承。
要定义一个基于现有类的新类,我们将现有类的名称放在括号中。
class Hand(Deck):
"""Represents a hand of playing cards."""
这个定义表明Hand
继承自Deck
,这意味着Hand
对象可以访问Deck
中定义的方法,如take_card
和put_card
。
Hand
也继承了Deck
中的__init__
方法,但如果我们在Hand
类中定义了__init__
,它将覆盖Deck
类中的版本。
%%add_method_to Hand
def __init__(self, label=''):
self.label = label
self.cards = []
这个版本的__init__
方法接受一个可选的字符串作为参数,并且总是从一个空的卡片列表开始。当我们创建一个Hand
对象时,Python 会调用这个方法,而不是在Deck
中的方法——我们可以通过检查结果是否包含label
属性来确认这一点。
hand = Hand('player 1')
hand.label
'player 1'
要发一张卡片,我们可以使用take_card
从Deck
中移除一张卡片,并使用put_card
将卡片添加到Hand
中。
deck = Deck(cards)
card = deck.take_card()
hand.put_card(card)
print(hand)
Ace of Spades
让我们将这段代码封装到一个名为move_cards
的Deck
方法中。
%%add_method_to Deck
def move_cards(self, other, num):
for i in range(num):
card = self.take_card()
other.put_card(card)
这个方法是多态的——也就是说,它可以与多种类型一起工作:self
和other
可以是Hand
或Deck
。因此,我们可以使用这个方法将一张卡片从Deck
发给Hand
,从一副Hand
发给另一副,或者从Hand
发回Deck
。
当一个新类继承自现有类时,现有类称为父类,新类称为子类。一般来说:
-
子类的实例应该拥有父类的所有属性,但它们可以有额外的属性。
-
子类应该拥有父类的所有方法,但它可以有额外的方法。
-
如果子类重写了父类的方法,则新方法应该采用相同的参数,并返回兼容的结果。
这一套规则被称为“李斯科夫替代原则”,以计算机科学家芭芭拉·李斯科夫的名字命名。
如果你遵循这些规则,任何设计用来处理父类实例的函数或方法,比如Deck
,也可以用来处理子类实例,比如Hand
。如果违反这些规则,你的代码将像纸牌屋一样崩塌(抱歉)。
17.9. 专门化
让我们创建一个名为BridgeHand
的类,用来表示桥牌中的一手牌——这是一种广泛玩的纸牌游戏。我们将从Hand
继承,并添加一个名为high_card_point_count
的新方法,使用“高牌点数”方法来评估一手牌,该方法会为手中的高牌加总分数。
这是一个类定义,其中包含一个类变量,映射了从卡片名称到其点数值的字典。
class BridgeHand(Hand):
"""Represents a bridge hand."""
hcp_dict = {
'Ace': 4,
'King': 3,
'Queen': 2,
'Jack': 1,
}
给定一张卡片的等级,比如12
,我们可以使用Card.rank_names
获取该等级的字符串表示,然后使用hcp_dict
获取它的分数。
rank = 12
rank_name = Card.rank_names[rank]
score = BridgeHand.hcp_dict.get(rank_name, 0)
rank_name, score
('Queen', 2)
以下方法遍历BridgeHand
中的卡片,并加总它们的分数。
%%add_method_to BridgeHand
def high_card_point_count(self):
count = 0
for card in self.cards:
rank_name = Card.rank_names[card.rank]
count += BridgeHand.hcp_dict.get(rank_name, 0)
return count
为了进行测试,我们将发一手五张牌——桥牌通常有十三张,但使用小例子更容易测试代码。
hand = BridgeHand('player 2')
deck.shuffle()
deck.move_cards(hand, 5)
print(hand)
4 of Diamonds
King of Hearts
10 of Hearts
10 of Clubs
Queen of Diamonds
这是国王和皇后的总分。
hand.high_card_point_count()
5
BridgeHand
继承了Hand
的变量和方法,并增加了一个类变量和一个特定于桥牌的方法。使用这种方式进行继承被称为专门化,因为它定义了一个针对特定用途(如打桥牌)而专门化的新类。
17.10. 调试
继承是一个有用的特性。一些如果没有继承就会重复的程序,可以用继承更简洁地编写。此外,继承有助于代码复用,因为你可以在不修改父类的情况下定制其行为。在某些情况下,继承结构反映了问题的自然结构,这使得设计更容易理解。
另一方面,继承可能会让程序变得难以阅读。当调用一个方法时,有时不清楚在哪里找到它的定义——相关代码可能分散在多个模块中。
每当你不确定程序的执行流程时,最简单的解决方法是,在相关方法的开始处添加打印语句。如果Deck.shuffle
打印一条类似于Running Deck.shuffle
的消息,那么程序运行时就能追踪执行流程。
作为替代,你可以使用以下函数,它接受一个对象和一个方法名(作为字符串),并返回提供该方法定义的类。
def find_defining_class(obj, method_name):
"""Find the class where the given method is defined."""
for typ in type(obj).mro():
if method_name in vars(typ):
return typ
return f'Method {method_name} not found.'
find_defining_class
使用mro
方法获取类对象(类型)的列表,该列表将用于搜索方法。“MRO”代表“方法解析顺序”,即 Python 搜索的类的顺序,用于“解析”方法名——也就是说,找到该名称所引用的函数对象。
作为示例,我们实例化一个 BridgeHand
,然后找到 shuffle
的定义类。
hand = BridgeHand('player 3')
find_defining_class(hand, 'shuffle')
__main__.Deck
BridgeHand
对象的 shuffle
方法是 Deck
中的那个。
17.11. 词汇表
继承: 定义一个新类,该类是先前定义的类的修改版本。
编码: 使用另一组值来表示一组值,通过在它们之间构建映射。
类变量: 在类定义内部定义的变量,但不在任何方法内部。
完全有序: 如果我们能比较任何两个元素并且比较结果是一致的,那么该集合就是完全有序的。
委托: 当一个方法将责任传递给另一个方法来完成大部分或所有工作时。
父类: 被继承的类。
子类: 继承自另一个类的类。
专门化: 使用继承来创建一个新类,该类是现有类的专门化版本。
17.12. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
Exception reporting mode: Verbose
17.12.1. 请求虚拟助手
当它运行顺利时,面向对象编程可以使程序更易读、可测试和可重用。但它也可能使程序变得复杂,难以维护。因此,面向对象编程是一个有争议的话题——一些人喜欢它,而另一些人则不喜欢。
要了解更多关于该主题的信息,请请求虚拟助手:
-
面向对象编程有哪些优缺点?
-
当人们说“偏好组合而非继承”时,这是什么意思?
-
里氏替换原则是什么?
-
Python 是面向对象的语言吗?
-
一个集合要是完全有序的,需要满足哪些要求?
和往常一样,考虑使用虚拟助手来帮助完成以下练习。
17.12.2. 练习
在桥牌中,“trick” 是一轮比赛,其中四名玩家各出一张牌。为了表示这些牌,我们将定义一个继承自 Deck
的类。
class Trick(Deck):
"""Represents a trick in contract bridge."""
作为示例,考虑这个技巧,第一位玩家以方块 3 出牌,这意味着方块是“领先花色”。第二位和第三位玩家“跟花色”,也就是出与领先花色相同的牌。第四位玩家出了一张不同花色的牌,这意味着他们不能赢得这一轮。所以这轮的赢家是第三位玩家,因为他们出了领先花色中的最大牌。
cards = [Card(1, 3),
Card(1, 10),
Card(1, 12),
Card(2, 13)]
trick = Trick(cards)
print(trick)
3 of Diamonds
10 of Diamonds
Queen of Diamonds
King of Hearts
编写一个 Trick
方法,名为 find_winner
,它遍历 Trick
中的牌,并返回获胜牌的索引。在前面的示例中,获胜牌的索引是 2
。
17.12.3. 练习
接下来的几个练习要求你编写函数来分类扑克牌型。如果你不熟悉扑克牌,我会解释你需要知道的内容。我们将使用以下类来表示扑克牌型。
class PokerHand(Hand):
"""Represents a poker hand."""
def get_suit_counts(self):
counter = {}
for card in self.cards:
key = card.suit
counter[key] = counter.get(key, 0) + 1
return counter
def get_rank_counts(self):
counter = {}
for card in self.cards:
key = card.rank
counter[key] = counter.get(key, 0) + 1
return counter
PokerHand
提供了两个方法,帮助完成练习。
-
get_suit_counts
循环遍历PokerHand
中的牌,计算每种花色的牌数,并返回一个字典,将每个花色代码映射到它出现的次数。 -
get_rank_counts
与牌的等级执行相同的操作,返回一个字典,将每个等级代码映射到它出现的次数。
所有接下来的练习都可以仅使用我们迄今学到的 Python 特性完成,但其中一些比以前的练习更难。我鼓励你寻求虚拟助手的帮助。
对于这样的问题,通常很好地寻求关于策略和算法的一般建议。然后你可以自己编写代码,或者请求代码。如果你请求代码,你可能需要在提示的一部分中提供相关的类定义。
作为第一练习,我们将编写一个名为has_flush
的方法,检查一手牌是否有“同花” - 即是否包含至少五张同一花色的牌。
在大多数扑克牌的变体中,一手牌通常包含五张或七张牌,但也有一些异国情调的变体,一手牌包含其他数量的牌。但不管一手牌有多少张牌,只有五张牌才算在内,这五张牌可以组成最好的一手牌。
17.12.4. 练习
编写一个名为has_straight
的方法,检查一手牌是否包含顺子,即五张具有连续等级的牌。例如,如果一手牌包含等级5
、6
、7
、8
和9
,那么它就包含顺子。
一张 A 可以出现在 2 之前或 K 之后,所以A
、2
、3
、4
、5
是顺子,10
、J
、Q
、K
、A
也是顺子。但顺子不能“绕过”,所以K
、A
、2
、3
、4
不是顺子。
17.12.5. 练习
一手牌有一个顺子同花顺,如果它包含五张既是顺子又是同一花色的牌 - 也就是说,五张具有连续等级的相同花色的牌。编写一个PokerHand
方法,检查一手牌是否有顺子同花顺。
17.12.6. 练习
一手扑克牌有一对,如果它包含两张或更多张同等级的牌。编写一个PokerHand
方法,检查一手牌是否包含一对。
要测试你的方法,这里有一个有一对的手牌。
pair = deepcopy(bad_hand)
pair.put_card(Card(1, 2))
print(pair)
2 of Clubs
3 of Clubs
4 of Hearts
5 of Spades
7 of Clubs
2 of Diamonds
pair.has_pair() # should return True
True
bad_hand.has_pair() # should return False
False
good_hand.has_pair() # should return False
False
17.12.7. 练习
一手牌有一个葫芦,如果它包含一组三张同一等级的牌和两张另一等级的牌。编写一个PokerHand
方法,检查一手牌是否有葫芦。
17.12.8. 练习
这个练习是一个关于一个常见错误的警示故事,这种错误往往很难调试。考虑以下的类定义。
class Kangaroo:
"""A Kangaroo is a marsupial."""
def __init__(self, name, contents=[]):
"""Initialize the pouch contents.
name: string
contents: initial pouch contents.
"""
self.name = name
self.contents = contents
def __str__(self):
"""Return a string representaion of this Kangaroo.
"""
t = [ self.name + ' has pouch contents:' ]
for obj in self.contents:
s = ' ' + object.__str__(obj)
t.append(s)
return '\n'.join(t)
def put_in_pouch(self, item):
"""Adds a new item to the pouch contents.
item: object to be added
"""
self.contents.append(item)
__init__
接受两个参数:name
是必需的,但contents
是可选的 - 如果没有提供,则默认值为空列表。
__str__
返回对象的字符串表示,包括袋子的名称和内容。
put_in_pouch
接受任何对象并将其附加到contents
中。
现在让我们看看这个类是如何工作的。我们将创建两个名为’Kanga’和’Roo’的Kangaroo
对象。
kanga = Kangaroo('Kanga')
roo = Kangaroo('Roo')
我们将向 Kanga 的袋子中添加两个字符串和 Roo。
kanga.put_in_pouch('wallet')
kanga.put_in_pouch('car keys')
kanga.put_in_pouch(roo)
如果我们打印kanga
,似乎一切正常。
print(kanga)
Kanga has pouch contents:
'wallet'
'car keys'
<__main__.Kangaroo object at 0x7f44f9b4e500>
但是如果我们打印roo
会发生什么呢?
print(roo)
Roo has pouch contents:
'wallet'
'car keys'
<__main__.Kangaroo object at 0x7f44f9b4e500>
Roo 的袋子里包含与 Kanga 的袋子相同的内容,包括对roo
的引用!
看看你能否弄清楚哪里出了问题。然后问虚拟助手:“以下程序有什么问题?”并粘贴Kangaroo
的定义。
版权所有 2024 Allen B. Downey
代码许可:MIT 许可证
文本许可:创作共用 署名-非商业性使用-相同方式共享 4.0 国际版
18. Python 附加功能
本书的目标之一是尽量少教你 Python。当有两种方法可以做某事时,我选择了一种并避免提到另一种。有时,我会将第二种方法放进练习中。
现在我想回过头去补充一些被遗忘的好点子。Python 提供了一些不是真正必要的功能——你可以不使用它们写出好的代码——但使用它们,你可以写出更简洁、可读或高效的代码,有时甚至是三者兼具。
18.1. 集合
Python 提供了一个名为set
的类,用于表示一组唯一的元素。要创建一个空集合,我们可以像使用函数一样使用类对象。
s1 = set()
s1
set()
我们可以使用 add
方法添加元素。
s1.add('a')
s1.add('b')
s1
{'a', 'b'}
或者我们可以将任何类型的序列传递给 set
。
s2 = set('acd')
s2
{'a', 'c', 'd'}
一个元素在 set
中只能出现一次。如果你添加一个已经存在的元素,它将没有任何效果。
s1.add('a')
s1
{'a', 'b'}
或者,如果你用一个包含重复元素的序列创建一个集合,结果将只包含唯一元素。
set('banana')
{'a', 'b', 'n'}
本书中的一些练习可以通过集合高效简洁地完成。例如,以下是第十一章中的一个练习解决方案,使用字典来检查序列中是否存在重复元素。
def has_duplicates(t):
d = {}
for x in t:
d[x] = True
return len(d) < len(t)
这个版本将 t
中的元素作为字典中的键添加,然后检查键是否比元素少。使用集合,我们可以像这样编写相同的函数。
def has_duplicates(t):
s = set(t)
return len(s) < len(t)
一个元素在集合中只能出现一次,因此,如果 t
中的某个元素出现多次,集合将比 t
小。如果没有重复元素,集合的大小将与 t
相同。
set
对象提供了一些方法来执行集合操作。例如,union
计算两个集合的并集,它是一个包含两个集合中所有元素的新集合。
s1.union(s2)
{'a', 'b', 'c', 'd'}
一些算术运算符可以与集合一起使用。例如,-
运算符执行集合差集运算——结果是一个新集合,包含第一个集合中所有不在第二个集合中的元素。
s1 - s2
{'b'}
在第十二章中,我们使用字典查找文档中出现但不在单词列表中的单词。我们使用了以下函数,它接收两个字典,并返回一个仅包含第一个字典中不出现在第二个字典中的键的新字典。
def subtract(d1, d2):
res = {}
for key in d1:
if key not in d2:
res[key] = d1[key]
return res
使用集合,我们不必自己编写这个函数。如果 word_counter
是一个包含文档中唯一单词的字典,word_list
是一个有效单词的列表,我们可以像这样计算集合差异。
set(word_counter) - set(word_list)
结果是一个包含文档中未出现在单词列表中的单词的集合。
关系运算符可以与集合一起使用。例如,<=
用于检查一个集合是否是另一个集合的子集,包括它们相等的情况。
set('ab') <= set('abc')
True
使用这些运算符,我们可以利用集合来完成第七章的一些练习。例如,下面是一个使用循环的 uses_only
版本。
def uses_only(word, available):
for letter in word:
if letter not in available:
return False
return True
uses_only
检查 word
中的所有字母是否都在 available
中。使用集合,我们可以像这样重写它。
def uses_only(word, available):
return set(word) <= set(available)
如果 word
中的字母是 available
中字母的子集,那么意味着 word
只使用了 available
中的字母。
18.2. Counters
Counter
类似于集合,但如果一个元素出现多次,Counter
会记录该元素出现的次数。如果你熟悉数学中的“多重集”概念,那么 Counter
就是表示多重集的自然方式。
Counter
类定义在一个名为 collections
的模块中,因此你需要导入该模块。然后,你可以像使用函数一样使用类对象,并将字符串、列表或其他类型的序列作为参数传递。
from collections import Counter
counter = Counter('banana')
counter
Counter({'a': 3, 'n': 2, 'b': 1})
from collections import Counter
t = (1, 1, 1, 2, 2, 3)
counter = Counter(t)
counter
Counter({1: 3, 2: 2, 3: 1})
Counter
对象类似于字典,它将每个键映射到该键出现的次数。与字典一样,键必须是可哈希的。
与字典不同,Counter
对象在访问不存在的元素时不会引发异常。相反,它会返回 0
。
counter['d']
0
我们可以使用 Counter
对象来解决第十章的一个练习,该练习要求编写一个函数,接受两个单词并检查它们是否是字母异位词——即,一个单词的字母是否可以重新排列成另一个单词。
这是使用 Counter
对象的一个解决方案。
def is_anagram(word1, word2):
return Counter(word1) == Counter(word2)
如果两个单词是字母异位词,它们包含相同的字母和相同的出现次数,因此它们的 Counter
对象是等价的。
Counter
提供了一个名为 most_common
的方法,它返回一个值-频率对的列表,按出现频率从高到低排序。
counter.most_common()
[(1, 3), (2, 2), (3, 1)]
它们还提供了方法和运算符来执行类似集合的操作,包括加法、减法、并集和交集。例如,+
运算符可以将两个 Counter
对象合并,创建一个新的 Counter
,其中包含两个对象的键以及计数的和。
我们可以通过将 'bans'
中的字母制作成 Counter
,并将其添加到 'banana'
中的字母来进行测试。
counter2 = Counter('bans')
counter + counter2
Counter({1: 3, 2: 2, 3: 1, 'b': 1, 'a': 1, 'n': 1, 's': 1})
你将有机会在本章末的练习中探索其他 Counter
操作。
18.3. defaultdict
collections
模块还提供了 defaultdict
,它类似于字典,但如果访问一个不存在的键,它会自动生成一个新值。
创建 defaultdict
时,你提供一个函数,用于创建新值。创建对象的函数有时被称为工厂函数。内置的用于创建列表、集合等类型的函数可以作为工厂函数使用。
例如,下面是一个创建新 list
的 defaultdict
。
from collections import defaultdict
d = defaultdict(list)
d
defaultdict(list, {})
请注意,参数是 list
,它是一个类对象,而不是 list()
,后者是一个函数调用,用来创建一个新列表。工厂函数只有在我们访问一个不存在的键时才会被调用。
t = d['new key']
t
[]
新的列表,我们称之为t
,也被添加到了字典中。因此,如果我们修改t
,变动也会出现在d
中:
t.append('new value')
d['new key']
['new value']
如果你正在创建一个包含列表的字典,通常可以使用defaultdict
编写更简洁的代码。
在第十一章的一个练习中,我创建了一个字典,将已排序的字母字符串映射到可以用这些字母拼写的单词列表。例如,字符串 'opst'
映射到列表 ['opts', 'post', 'pots', 'spot', 'stop', 'tops']
。这是原始代码。
def all_anagrams(filename):
d = {}
for line in open(filename):
word = line.strip().lower()
t = signature(word)
if t not in d:
d[t] = [word]
else:
d[t].append(word)
return d
这是一个使用 defaultdict
的更简洁版本。
def all_anagrams(filename):
d = defaultdict(list)
for line in open(filename):
word = line.strip().lower()
t = signature(word)
d[t].append(word)
return d
在章节末尾的练习中,你将有机会练习使用defaultdict
对象。
from collections import defaultdict
d = defaultdict(list)
key = ('into', 'the')
d[key].append('woods')
d[key]
['woods']
18.4. 条件表达式
条件语句通常用于选择两个值中的一个,例如这样:
if x > 0:
y = math.log(x)
else:
y = float('nan')
该语句检查 x
是否为正数。如果是,它会计算其对数。如果不是,math.log
会引发一个 ValueError。为了避免程序中断,我们生成一个 NaN
,这是一个表示“非数字”的特殊浮点值。
我们可以通过条件表达式更简洁地编写这个语句。
y = math.log(x) if x > 0 else float('nan')
你几乎可以像读英语一样读这行:“y
等于 log-x
,如果 x
大于 0;否则它等于 NaN
”。
递归函数有时可以通过条件表达式简洁地写出来。例如,这是一个带有条件语句的 factorial
版本。
def factorial(n):
if n == 0:
return 1
else:
return n * factorial(n-1)
这是一个带有条件表达式的版本。
def factorial(n):
return 1 if n == 0 else n * factorial(n-1)
条件表达式的另一个用途是处理可选参数。例如,这是一个类定义,包含一个使用条件语句来检查带有默认值的参数的 __init__
方法。
class Kangaroo:
def __init__(self, name, contents=None):
self.name = name
if contents is None:
contents = []
self.contents = contents
这是一个使用条件表达式的版本。
def __init__(self, name, contents=None):
self.name = name
self.contents = [] if contents is None else contents
一般来说,如果两个分支都包含单一的表达式且没有语句,可以用条件表达式替代条件语句。
18.5. 列表推导式
在前几章中,我们已经看到一些例子,我们从一个空列表开始,并通过 append
方法逐个添加元素。例如,假设我们有一个包含电影标题的字符串,我们想要将所有单词的大写字母进行转换。
title = 'monty python and the holy grail'
我们可以将其拆分成一个字符串列表,遍历这些字符串,进行大写转换,并将它们追加到一个列表中。
t = []
for word in title.split():
t.append(word.capitalize())
' '.join(t)
'Monty Python And The Holy Grail'
我们可以通过列表推导式更简洁地做同样的事情:
t = [word.capitalize() for word in title.split()]
' '.join(t)
'Monty Python And The Holy Grail'
方括号操作符表示我们正在构建一个新列表。括号内的表达式指定了列表的元素,for
子句指示我们正在循环遍历的序列。
列表推导式的语法可能看起来很奇怪,因为循环变量—在这个例子中是 word
—出现在表达式中,而我们还没有看到它的定义。但你会习惯的。
另一个例子是,在第九章中,我们使用这个循环从文件中读取单词并将它们追加到列表中。
word_list = []
for line in open('words.txt'):
word = line.strip()
word_list.append(word)
下面是我们如何将其写成列表推导式的方式。
word_list = [line.strip() for line in open('words.txt')]
列表推导式也可以包含一个if
子句,用来决定哪些元素会被包含在列表中。例如,这里是我们在第十章中使用的一个for
循环,用于生成word_list
中所有回文单词的列表。
palindromes = []
for word in word_list:
if is_palindrome(word):
palindromes.append(word)
下面是我们如何用列表推导式做同样的事情。
palindromes = [word for word in word_list if is_palindrome(word)]
当列表推导式作为函数的参数时,我们通常可以省略括号。例如,假设我们想要将(1 / 2^n)的值加总,其中(n)从 0 到 9。我们可以像这样使用列表推导式。
sum([1/2**n for n in range(10)])
1.998046875
或者我们可以像这样省略括号。
sum(1/2**n for n in range(10))
1.998046875
在这个例子中,参数严格来说是一个生成器表达式,而不是列表推导式,它实际上并没有创建一个列表。但除此之外,行为是一样的。
列表推导式和生成器表达式简洁且易于阅读,至少对于简单的表达式是如此。它们通常比等效的for
循环更快,有时甚至快得多。所以,如果你生气我没有早点提到它们,我理解。
但为了我的辩护,列表推导式更难调试,因为你不能在循环内部放置print
语句。我建议你仅在计算足够简单、你很可能第一次就能写对的情况下使用它们。或者考虑先编写并调试一个for
循环,再将其转换为列表推导式。
18.6. any
和all
Python 提供了一个内置函数any
,它接受一个布尔值序列,并在其中任何一个值为True
时返回True
。
any([False, False, True])
True
any
通常与生成器表达式一起使用。
any(letter == 't' for letter in 'monty')
True
这个例子并不是很有用,因为它与in
运算符做的事情相同。但我们可以使用any
来为第七章中的一些练习写出简洁的解法。例如,我们可以像这样编写uses_none
。
def uses_none(word, forbidden):
"""Checks whether a word avoids forbidden letters."""
return not any(letter in forbidden for letter in word)
这个函数循环遍历word
中的字母,检查其中是否有字母在forbidden
中。使用any
和生成器表达式的结合是高效的,因为一旦找到了True
值,它就会立即停止,而不必遍历整个序列。
Python 提供了另一个内置函数all
,它会在序列中的每个元素都为True
时返回True
。我们可以使用它来编写uses_all
的简洁版本。
def uses_all(word, required):
"""Check whether a word uses all required letters."""
return all(letter in word for letter in required)
使用any
和all
表达式可以简洁、高效且易于阅读。
18.7. 命名元组
collections
模块提供了一个名为namedtuple
的函数,可以用来创建简单的类。例如,第十六章中的Point
对象只有两个属性,x
和y
。以下是我们如何定义它的。
class Point:
"""Represents a point in 2-D space."""
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f'({self.x}, {self.y})'
这段代码传达了少量信息却包含了很多代码。namedtuple
提供了一种更简洁的方式来定义像这样的类。
from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
第一个参数是你想创建的类的名称,第二个参数是Point
对象应该拥有的属性列表。结果是一个类对象,这就是为什么它被赋值给一个首字母大写的变量名。
使用namedtuple
创建的类提供了一个__init__
方法,用于将值分配给属性,还有一个__str__
方法,用于以可读的形式显示对象。所以我们可以像这样创建并显示一个Point
对象。
p = Point(1, 2)
p
Point(x=1, y=2)
Point
还提供了一个__eq__
方法,用于检查两个Point
对象是否相等——也就是说,它们的属性是否相同。
p == Point(1, 2)
True
你可以通过名称或索引访问命名元组的元素。
p.x, p.y
(1, 2)
p[0], p[1]
(1, 2)
你也可以将命名元组当作元组来使用,如下所示的赋值。
x, y = p
x, y
(1, 2)
但namedtuple
对象是不可变的。属性初始化后,它们不能被更改。
p[0] = 3
TypeError: 'Point' object does not support item assignment
p.x = 3
AttributeError: can't set attribute
namedtuple
提供了一种快速定义简单类的方法。缺点是简单类有时并不总是保持简单。你可能会决定稍后为命名元组添加方法。在这种情况下,你可以定义一个新类,从命名元组继承。
class Pointier(Point):
"""This class inherits from Point"""
或者到那时你可以切换到常规的类定义。
18.8. 打包关键字参数
在第十一章中,我们写了一个函数,将它的参数打包成一个元组。
def mean(*args):
return sum(args) / len(args)
你可以用任意数量的参数调用这个函数。
mean(1, 2, 3)
2.0
但*
运算符并不会打包关键字参数。因此,带有关键字参数调用此函数会导致错误。
mean(1, 2, start=3)
TypeError: mean() got an unexpected keyword argument 'start'
要打包关键字参数,我们可以使用**
运算符:
def mean(*args, **kwargs):
print(kwargs)
return sum(args) / len(args)
关键字打包参数可以使用任何名称,但kwargs
是常见的选择。结果是一个字典,它将关键字映射到对应的值。
mean(1, 2, start=3)
{'start': 3}
1.5
在这个例子中,kwargs
的值被打印出来,但除此之外没有任何效果。
但**
运算符也可以在参数列表中使用,用来解包字典。例如,这是一个mean
的版本,它打包收到的任何关键字参数,然后将其解包为sum
的关键字参数。
def mean(*args, **kwargs):
return sum(args, **kwargs) / len(args)
现在,如果我们以start
作为关键字参数调用mean
,它会传递给sum
,并作为求和的起始点。在下面的例子中,start=3
在计算平均值之前将3
加到总和中,所以总和是6
,结果是3
。
mean(1, 2, start=3)
3.0
作为另一个例子,如果我们有一个包含x
和y
键的字典,我们可以使用解包运算符来创建一个Point
对象。
d = dict(x=1, y=2)
Point(**d)
Point(x=1, y=2)
如果没有解包运算符,d
将被视为单个位置参数,因此它被赋值给x
,我们会得到一个TypeError
,因为没有第二个参数可以赋值给y
。
d = dict(x=1, y=2)
Point(d)
TypeError: Point.__new__() missing 1 required positional argument: 'y'
当你处理具有大量关键字参数的函数时,通常创建并传递指定常用选项的字典是很有用的。
def pack_and_print(**kwargs):
print(kwargs)
pack_and_print(a=1, b=2)
{'a': 1, 'b': 2}
18.9. 调试
在前面的章节中,我们使用doctest
来测试函数。例如,这里有一个名为add
的函数,它接受两个数字并返回它们的和。它包含一个doctest
,检查2 + 2
是否等于4
。
def add(a, b):
'''Add two numbers.
>>> add(2, 2)
4
'''
return a + b
这个函数接受一个函数对象并运行它的doctests
。
from doctest import run_docstring_examples
def run_doctests(func):
run_docstring_examples(func, globals(), name=func.__name__)
所以我们可以像这样测试add
函数。
run_doctests(add)
没有输出,这意味着所有的测试都通过了。
Python 提供了另一种用于运行自动化测试的工具,称为unittest
。它的使用稍微复杂一些,但这里有一个例子。
from unittest import TestCase
class TestExample(TestCase):
def test_add(self):
result = add(2, 2)
self.assertEqual(result, 4)
首先,我们导入TestCase
,这是unittest
模块中的一个类。为了使用它,我们必须定义一个继承自TestCase
的新类,并提供至少一个测试方法。测试方法的名称必须以test
开头,并应表明它测试的是哪个函数。
在这个例子中,test_add
通过调用add
函数、保存结果,并调用assertEqual
来测试add
函数。assertEqual
继承自TestCase
,它接受两个参数并检查它们是否相等。
为了运行这个测试方法,我们必须运行unittest
中的一个名为main
的函数,并提供几个关键字参数。以下函数展示了详细信息——如果您有兴趣,可以向虚拟助手询问它是如何工作的。
import unittest
def run_unittest():
unittest.main(argv=[''], verbosity=0, exit=False)
run_unittest
不接受TestExample
作为参数,而是查找继承自TestCase
的类。然后,它查找以test
开头的方法并运行它们。这个过程叫做测试发现。
下面是我们调用run_unittest
时发生的情况。
run_unittest()
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
unittest.main
报告它运行的测试数量和结果。在这种情况下,OK
表示测试通过。
为了查看测试失败时发生了什么,我们将向TestExample
添加一个错误的测试方法。
%%add_method_to TestExample
def test_add_broken(self):
result = add(2, 2)
self.assertEqual(result, 100)
下面是我们运行测试时发生的情况。
run_unittest()
======================================================================
FAIL: test_add_broken (__main__.TestExample)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/tmp/ipykernel_1109857/3833266738.py", line 3, in test_add_broken
self.assertEqual(result, 100)
AssertionError: 4 != 100
----------------------------------------------------------------------
Ran 2 tests in 0.000s
FAILED (failures=1)
报告包括失败的测试方法和显示失败位置的错误信息。总结部分表明有两个测试被运行,其中一个失败了。
在下面的练习中,我将建议一些提示,您可以用它们向虚拟助手询问关于unittest
的更多信息。
18.10. 术语表
工厂: 用于创建对象的函数,通常作为参数传递给其他函数。
条件表达式: 使用条件语句来选择两个值中的一个的表达式。
列表推导式: 一种简洁的方式来遍历序列并创建一个列表。
生成器表达式: 类似于列表推导式,但它不创建列表。
测试发现: 一种用于查找和运行测试的过程。
18.11. 练习
# This cell tells Jupyter to provide detailed debugging information
# when a runtime error occurs. Run it before working on the exercises.
%xmode Verbose
18.11.1. 向虚拟助手提问
本章有一些话题可能您会想了解。
-
“Python 的 set 类有哪些方法和操作符?”
-
“Python 的 Counter 类有哪些方法和操作符?”
-
“Python 的列表推导式和生成器表达式有什么区别?”
-
“什么时候应该使用 Python 的
namedtuple
而不是定义一个新类?” -
“打包和解包关键字参数有什么用途?”
-
“
unittest
是如何进行测试发现的?” -
“除了
assertEqual
,unittest.TestCase
中最常用的方法有哪些?” -
“
doctest
和unittest
的优缺点是什么?”
对于以下练习,考虑请求虚拟助手的帮助,但如同往常一样,请记得测试结果。
18.11.2. 练习
第七章中的一个练习要求编写一个名为uses_none
的函数,它接受一个单词和一串禁用字母,如果单词中不使用任何禁用字母,则返回True
。以下是一个解决方案。
def uses_none(word, forbidden):
for letter in word.lower():
if letter in forbidden.lower():
return False
return True
编写这个函数的版本,使用set
操作代替for
循环。提示:询问虚拟助手,“如何计算 Python 集合的交集?”
18.11.3. 练习
拼字游戏是一种棋盘游戏,目标是使用字母瓦片拼写单词。例如,如果我们有字母瓦片T
、A
、B
、L
、E
,我们可以拼出BELT
和LATE
,但是我们无法拼出BEET
,因为我们没有两个E
。
编写一个函数,接受一个字母字符串和一个单词,检查这些字母是否能拼出该单词,考虑每个字母出现的次数。
18.11.4. 练习
在第十七章中的一个练习中,我对has_straightflush
的解决方案使用了以下方法,它将PokerHand
分成一个包含四手牌的列表,每手牌都包含相同花色的卡牌。
def partition(self):
"""Make a list of four hands, each containing only one suit."""
hands = []
for i in range(4):
hands.append(PokerHand())
for card in self.cards:
hands[card.suit].add_card(card)
return hands
编写这个函数的简化版本,使用defaultdict
。
18.11.5. 练习
这是来自第十一章的一个计算斐波那契数的函数。
def fibonacci(n):
if n == 0:
return 0
if n == 1:
return 1
return fibonacci(n-1) + fibonacci(n-2)
编写这个函数的版本,使用单个返回语句,使用两个条件表达式,其中一个嵌套在另一个内部。
18.11.6. 练习
以下是一个递归计算二项式系数的函数。
def binomial_coeff(n, k):
"""Compute the binomial coefficient "n choose k".
n: number of trials
k: number of successes
returns: int
"""
if k == 0:
return 1
if n == 0:
return 0
return binomial_coeff(n-1, k) + binomial_coeff(n-1, k-1)
使用嵌套条件表达式重写函数主体。
这个函数的效率不高,因为它会不断计算相同的值。通过如第十章所述的记忆化方法,使其更高效。
binomial_coeff(10, 4) # should be 210
210
18.11.7. 练习
这是来自第十七章中Deck
类的__str__
方法。
%%add_method_to Deck
def __str__(self):
res = []
for card in self.cards:
res.append(str(card))
return '\n'.join(res)
使用列表推导或生成器表达式编写这个方法的更简洁版本。
版权所有 2024 Allen B. Downey
代码许可证:MIT 许可证
文本许可证:知识共享署名-非商业性使用-相同方式共享 4.0 国际
19. 最后的思考
学习编程并不容易,但如果你已经走到了这一步,你已经打下了一个良好的基础。现在,我有一些建议,可以帮助你继续学习并应用所学的知识。
本书旨在为编程提供一个通用的入门介绍,因此我们没有专注于具体应用。根据你的兴趣,使用你新学到的技能可以应用到任何领域。
如果你对数据科学感兴趣,我有三本书你可能会喜欢:
-
Think Stats: Exploratory Data Analysis,O’Reilly Media,2014 年。
-
Think Bayes: Bayesian Statistics in Python,O’Reilly Media,2021 年。
-
Think DSP: Digital Signal Processing in Python,O’Reilly Media,2016 年。
如果你对物理建模和复杂系统感兴趣,你可能会喜欢:
-
Modeling and Simulation in Python: An Introduction for Scientists and Engineers,No Starch Press,2023 年。
-
Think Complexity: Complexity Science and Computational Modeling,O’Reilly Media,2018 年。
这些书籍使用了 NumPy、SciPy、pandas 以及其他用于数据科学和科学计算的 Python 库。
本书试图在编程的通用原则和 Python 的细节之间找到平衡。因此,它并没有涵盖 Python 语言的所有特性。关于 Python 的更多内容以及使用它的良好建议,我推荐由 Luciano Ramalho 编写的Fluent Python: Clear, Concise, and Effective Programming,第二版,O’Reilly Media,2022 年。
在学习编程之后,一个常见的下一步是学习数据结构和算法。我目前正在进行相关工作,名为Data Structures and Information Retrieval in Python。该书的免费电子版可以在 Green Tea Press 网站上获得:greenteapress.com
。
当你处理更复杂的程序时,你将会遇到新的挑战。你可能会觉得回顾本书中关于调试的章节很有帮助。特别是,记住第十二章中的调试六个 R 法则:阅读、运行、反思、橡皮鸭调试、撤退和休息。
本书建议了一些调试工具,包括print
和repr
函数,第十一章中的structshape
函数,以及第十四章中的内建函数isinstance
、hasattr
和vars
。
它还建议了一些用于测试程序的工具,包括assert
语句、doctest
模块和unittest
模块。在程序中加入测试是预防和发现错误、节省调试时间的最佳方法之一。
但最好的调试方式是你根本不需要调试。如果你按照第六章中描述的增量开发过程进行开发,并且在过程中不断进行测试,你将会犯更少的错误,并且在出错时能更快找到它们。另外,记得第四章中提到的封装和泛化,尤其是在你在 Jupyter notebooks 中开发代码时特别有用。
在本书中,我提到了一些使用虚拟助手帮助你学习、编程和调试的方法。我希望这些工具对你有帮助。
除了像 ChatGPT 这样的虚拟助手,你可能还想使用像 Copilot 这样的工具,在你输入代码时自动完成。我最初没有推荐使用这些工具,因为它们对于初学者来说可能会让人感到不知所措。但现在你可以尝试一下。
有效使用 AI 工具需要一些实验和反思,以找到适合自己的工作方式。如果你觉得从 ChatGPT 复制代码到 Jupyter 很麻烦,你可能会更喜欢像 Copilot 这样的工具。但你用来编写提示和解释回应的认知工作,可能和工具生成的代码一样有价值,就像橡胶鸭调试一样。
随着你编程经验的积累,你可能想要探索其他开发环境。我认为 Jupyter notebook 是一个不错的起点,但它相对较新,并不像传统的集成开发环境(IDE)那样广泛使用。对于 Python,最流行的 IDE 包括 PyCharm 和 Spyder,还有 Thonny,通常推荐给初学者。其他 IDE,如 Visual Studio Code 和 Eclipse,也支持其他编程语言。或者,作为一种更简单的选择,你可以使用任何你喜欢的文本编辑器来编写 Python 程序。
在你继续编程之旅的过程中,你不必孤单前行!如果你住在城市里或附近,很可能有一个 Python 用户组可以加入。这些小组通常对初学者非常友好,所以不要害怕。如果你所在的地方没有这样的群体,你也许可以远程参与活动。另外,留意本地区的 Python 会议。
提高编程技能的最好方法之一就是学习另一种语言。如果你对统计学和数据科学感兴趣,你可能想学习 R。但是我特别推荐学习像 Racket 或 Elixir 这样的函数式编程语言。函数式编程需要一种不同的思维方式,这改变了你对程序的思考方式。
祝你好运!
版权所有 2024 Allen B. Downey
代码许可证:MIT 许可证
文本许可证:知识共享署名-非商业性使用-相同方式共享 4.0 国际
空白笔记本
对于每一章节,我创建了一个“空白”笔记本,保留了原始文本,但大部分代码已删除。这些笔记本非常适合进行跟随练习,学习者可以在其中填写空白部分。
第一章:编程作为思维方式
第二章:变量和语句
第三章:函数
第四章:函数和接口
第五章:条件语句和递归
第六章:返回值
第七章:迭代和搜索
第八章:字符串和正则表达式
第九章:列表
第十章:字典
第十一章:元组
第十二章:文本分析与生成
第十三章:文件和数据库
第十四章:类和函数
第十五章:类和方法
第十六章:类和对象
第十七章:继承
第十八章:Python 扩展
第十九章:最终思考