欢迎光临
我们一直在努力

通过Python序列的特殊方法理解鸭子类型

什么是鸭子类型?就是只看重能力,不看重血统,不关心它是不是真正的鸭子,即使是一只猫,但只要它会嘎嘎叫,我就当它是鸭子。因为我不需要它来抓老鼠,我只需要它会嘎嘎叫。这符合我们现实生活中的实用主义哲学,有一句经典的话:不管黑猫还是白猫,能抓老鼠就是好猫!

用现实世界的例子可以完美诠释鸭子类型,而在我们的Python世界中,血统也即父子类的继承关系,能力也即类中所定义的方法。本文通过实现一个自定义的序列,向读者诠释Python中鸭子类型的含义。

实现卡牌序列:

请读者思考一下,如果你要使用Python定义一个属于自己的序列,这个序列要拥有序列的基本能力,比如可以被for循环遍历,你会怎么实现?

如果是熟悉其他语言例如Java的读者,他首先肯定想到的就是继承List类或者实现List的接口。这就偏离了Python鸭子类型的哲学,过于看重血统了。在Python中我们只需要实现序列的核心能力(协议)就可以了,让猫会嘎嘎叫就可以,不需要管这猫是不是禽类。

下面实现一个扑克牌序列:

import collections

# 定义一张纸牌,只有花色和点数
Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()

def __init__(self):
# 初始化时生成 52 张牌
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]

# 【能力一】:只要实现了 __len__,Python 就能调用 len(deck)
def __len__(self):
return len(self._cards)

# 【能力二】:只要实现了 __getitem__,Python 就能进行切片、迭代、索引
def __getitem__(self, position):
return self._cards[position]

# 验证一下他有没有序列的能力
deck = FrenchDeck()
print(len(deck)) # 输出: 52
print(deck[0]) # 输出: Card(rank='2', suit='spades')
for card in deck: # 可以被for循环遍历
print(card)
print(deck[:5]) # 可以切片

作为一个Python序列所需要具备的最基本的能力就是__len__和__getitem__,只要你实现了这个序列协议,Python就当这个类是序列,即使我们没有在代码中显示地去继承或者是实现任何List的方法,这就是鸭子类型的核心。

切片的原理:

虽然我们上面的示例已经可以使用切片,但是还是不太完美。假如我们不想在切片的时候返回的是卡牌对象,而是花色加点数的字符串,我们应该怎么实现?

我们上面只实现了__getitem__就获得了切片的能力,我们由此可以知道,其实切片的能力也是通过__getitem__来完成的。那当deck[:5]的时候,__getitem__接收到的索引是什么呢?我们通过一小段代码来展示。

class MySeq:
def __getitem__(self, index):
# 啥也不做,就返回索引
return index

s = MySeq()
print(s[2]) # 会直接返回2,也即传入的索引

print(s[1:2:3]) # 尝试切片返回的索引

print(dir(slice))

运行上述代码,可以看到对于s[1:2:3]的切片,打印出来的是slice(1, 2, 3),slice是内置属性,可以得出结论,Python一旦看到你写了切片的语法,就会把切片的信息打包成slice对象,让__getitem__去根据slice做处理。通过 dir(slice)我们发现它有 start、stop 和 step 这 3 种数据属性,还有 indices 方法。

slice的indices方法是一个非常重要的方法,但是鲜为人知。indices接收一个参数,即序列的长度,返回start, stop, step即为启始索引,结束索引,步长。我们只要把你的序列长度传给它,它就会自动计算出真正的 start, stop, step,并且自动处理好负数索引和越界截断。

le = 6 # 假设长度为6
print(slice(1, 10, 2).indices(le))

回到我们上面的卡牌序列,我们想在切片后不返回卡牌对象,而是返回花色和点数的字符串。

import collections

# 定义一张纸牌,只有花色和点数
Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()

def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]

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

# 一切照旧,除了__getitem__方法
def __getitem__(self, position):
# 1. 判断:如果是切片操作
if isinstance(position, slice):
# 先拿到切片后的原生 Card 对象列表
subset = self._cards[position]
# 然后用列表推导式,把每个 Card 转成字符串
return [f"{card.rank} of {card.suit}" for card in subset]

# 2. 如果是单个索引 (int)
return self._cards[position]

deck = FrenchDeck()
print(deck[:5]) # 新的切片方法
print(deck[2]) # 单个索引

本文通过实现一个自定义的卡牌序列,向读者演示了Python中鸭子类型是怎么只注重能力而不看重血统的。使用鸭子类型的时候虽然相对于继承会承担了以下代价:

  • 运行时错误 (Runtime Errors):

    • 继承:如果你继承了 list,编译器/解释器保证你有 pop() 方法。

    • 鸭子类型:解释器不保证。只有当代码运行到那一行,发现你没写 pop() 时,程序才会崩溃(Crash)。这是一把双刃剑:灵活性 vs 安全性。

  • 文档缺失 (Hidden Protocols):

    • 看继承关系图,你能一眼看出 Dog 是 Animal。

    • 看鸭子类型,你得去读代码:“这个函数到底需要传入一个什么对象?哦,它好像调用了 quack,所以它需要一个会叫的对象。” 这被称为隐式接口。

但是,有舍必有得,鸭子类型显式避免了继承,我们知道继承往往是有害的,而鸭子类型解耦了继承,使得我们不需要被迫去实现继承带来的庞大家族网,而鸭子类型(以及 Python 的协议机制)告诉我们:你不必非要是谁的儿子才能干活。所以,甘瓜苦蒂,没有哪种设计是百分百完美的,需要我们自己根据不同的场景去做不同的取舍。

赞(0)
未经允许不得转载:171主机测评 » 通过Python序列的特殊方法理解鸭子类型
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址