方法之特殊方法(Dunder Method)的定义

Hakuna 2025-02-07 2025-02-07 22284 字 112 minutes OOP Python

大家好,欢迎来到《基于 Python 的面向对象编程》系列的第 篇文章!在这个系列里,我们一起了解面向对象编程的基本概念,看看 Python 是怎么实现类的,还有怎么把这些知识用到实际开发中。希望大家一起轻松学习!

相关文章链接


Character类中,从命名方式看,__init____str__ 方法与其他方法有着显著的区别,该类方法以两个下划线开始,以两个下划线结束,下划线之间的名称也似乎有固定的名称。这种命名方式在 Python 中有着特殊的意义,标识了 Python 的一类特殊方法(Special methods),有时也被称为“魔法方法(Magic Methods)”或“双下方法(Dunder Methods)”。这些特殊方法使得用户定义的对象可以实现和修改一些 Python 语言内置的行为或者实现与 Python 语言的交互。比如说,通过适当地定义这些特殊方法,一个类的实例可以模仿内置类型的行为。这包括但不限于执行算术运算(如使用 __add__ 实现加法)、支持迭代(通过 __iter____next__)、自动进行属性访问(借助 __getattr____setattr__),乃至于作为函数被调用(使用 __call__)。这些特殊方法的实现可以极大地提升对象的表现力,使得自定义对象不仅能够和 Python 的内置类型媲美,还能提供清晰、直观且高效的接口。例如,在 Character 类中,__init__ 方法用于初始化新实例,而 __str__ 方法则提供了对象的字符串表示,这对于打印输出和调试是非常有用的。

特殊方法的主要目的是让咱们的对象适应 Python 的语法规则,而不是自定义单独的方法来实现所有功能。例如,通过实现 __add__ 方法,新定义的对象可以使用加号 + 运算符来进行加法运算。这种方法的使用,增加了代码的可读性和简洁性,使得自定义类型在行为上更像是 Python 的内置类型。

从特征上讲,所有的特殊方法具有一些通用特性,比如说,可以实现自动调用。特殊方法的设计使它们在特定的语言结构和语法中自动调用。例如,当咱们想打印一个对象时,Python 会自动寻找并调用该对象的 __str__ 方法。又如所有的特殊方法都有一个共同的命名模式,两个前导下划线和两个尾随下划线,例如 __getitem__。这种命名约定不仅帮助 Python 可以正确识别它们,而且也使得它们在视觉上与普通方法区分开来。

如何理解这里的自动调用机制?我们来看一下如下这个示例:

class Character:
    def __init__(self, name, health=100):
        self.name = name
        self.health = health

    def __str__(self):
        return f"{self.name}, Level {self.health}"

当我们创建一个 Character 类的实例时,例如 Character("Arthur", 200),Python 解释器执行以下步骤:

  1. 内存分配:首先,解释器为新对象分配内存。这个新对象是类的一个实例,拥有类定义中声明的所有属性和方法。
  2. 调用 __init__:一旦内存分配完成,Python 自动调用类的 __init__ 方法。此时,self 参数指向新创建的内存地址(即新对象),而其他参数则是我们传递给类构造器的值,如 namehealth 值。在这分别是 "Arthur"200
  3. 初始化对象:__init__ 方法内的代码负责设置对象的初始状态。这通常包括给实例变量赋值和执行必要的初始化操作,这里是通过在 __init__ 内部设置 self.nameself.level 实现。在 __init__ 方法执行完毕后,对象被认为是准备好的,可以被用于后续操作。

这样的机制设计,可以确保每个对象在使用前都被正确初始化。同时也确保了所有对象的创建和初始化过程都是一致的,有助于维护类的行为一致性。

所以说,在 Python 中,当我们实现或修改特殊方法时,实际上是在利用面向对象编程中的多态性概念,通过重写这些方法为咱们的类定制一些行为。因为特殊方法,如 __add____str____getitem__ 等,已经在更高级别的基类(如 object 类)中预定义了,通过在新的类中重新定义这些方法,实际上是在重写继承自基类的方法。至于要重写那些特殊方法,需要根据新类将要实现哪些行为来确定。比如说,根据类的预期功能决定实现相应的特殊方法。如果新类代表一个序列类型,我们可能会实现 __len____getitem__ 方法来支持序列协议。或者,如果想让新类的实例能够通过调用方式使用,那么我们应该实现 __call__ 方法。

下面我们将对一些常用的特殊方法进行介绍。

构造和初始化

在 Python 中,__init__ 是一个极其关键的特殊方法,主要负责在对象的创建后立即进行初始化。这个方法的核心功能是设置对象的初始状态,具体包括属性的赋值和执行必要的初始化操作。作为对象生命周期中第一个自动调用的方法,__init__ 扮演了构造器的角色。通过 __init__ 方法,创建对象时所需的数据可以作为参数传递进来。这些参数随后被用于初始化对象的各个属性或进行启动时必须的任何操作。这样的设计确保了对象在使用前已经被正确地配置和准备好,为对象的后续使用提供了一致的基础。注意,由于__init__ 方法的主要目的是初始化对象,因此,不应该返回任何值。其基本的语法结构如下:

class ClassName:
    def __init__(self, param1, param2):
        self.param1 = param1
        self.param2 = param2

我们仍以 Character 类为例。

class Character:
    def __init__(self, name, health, experience):
        self.name = name           # 实例变量,每个角色的名字
        self.health = health       # 实例变量,每个角色的健康点数
        self.experience = experience  # 实例变量,每个角色的经验值

    def __str__(self):
        """特殊方法,定义角色的字符串表示形式"""
        return f"Character({self.name}, Health: {self.health}, Experience: {self.experience})"

在此例子中,Character 类有三个属性,即,name, health, experience。当创建一个新的 Character 实例时,需要提供 name, health, 以及 experience 三个参数。这些参数在 __init__ 方法中被接收并赋值给相应的实例属性。以下示例展示了 __init__ 方法的使用:

arthur = Character("Character", 100, 0)

print(arthur) # 自动调用 Character 类中的 __str__ 特殊方法,输出 Character(Arthur, Health: 100, Experience: 0)

在此情景下,我们创建了一个名为 arthur、健康值为 100, 经验值为 0 的 Character 对象。当 Character 类的实例被创建时,__init__ 方法自动执行,接收传递的参数并将它们设置为对象的属性。print(arthur) 时,会自动调用 Character 类中的 __str__ 特殊方法,输出 Character(Arthur, Health: 100, Experience: 0)。这里展示了 __str__ 方法的使用方式。接下来,我们将继续探讨如何使用 __str__ 方法来提供对象的字符串表示,增强类的可用性和调试友好性。

对象表示

对象表示(Object Representation)是指如何将一个对象的状态以文本形式表达出来。在 Python 中,对象的表示形式主要用于调试和日志记录,帮助开发者理解对象的当前状态或者简单地输出对象信息。正确的对象表示能够提高代码的可读性和维护性,在处理复杂的数据结构时尤为有用。

Python 提供了两种主要的方法来定义对象的字符串表示:一种时我们刚才遇到的 __str__ 方法,另一种是类似的 __repr__ 方法。这两种方法有些许的不同,比如,__str__ 方法主要用于对象的“非正式”或可打印的字符串表示,面向的对象一般是最终的用户;而 __repr__ 方法通常用于开发者开发和调试过程中,为对象提供了“正式”的字符串表示。在使用上,当我们使用 print() 函数或 str() 函数时,Python 将自动调用对象的 __str__ 方法。如上面的 print(Arthur) 。而 __repr__ 方法通常是当我们在 Python 解释器中直接查看对象或使用 repr() 函数时才被调用。

我们适当拓展 Charater 来说明 __str____repr__ 这两个特殊方法的区别以及它们的具体使用场景:

class Character:
    def __init__(self, name, level, items=None):
        self.name = name
        self.level = level
        self.items = items if items is not None else []

    def __str__(self):
        return f"{self.name}, Level {self.level}"

    def __repr__(self):
        return f"Character('{self.name}', {self.level}, {self.items})"

这里,__str__ 方法提供了一个用户友好的字符串表示,适用于最终用户需要的输出,如在游戏界面或日志文件中显示角色信息:

character = Character("Aragorn", 5, ["Sword", "Shield"])
print(str(character))  # 输出: Aragorn, Level 5

__repr__ 方法则提供一个更正式的字符串表示,其目标是明确无误,甚至可以用来重新创建该对象:

character = Character("Aragorn", 5, ["Sword", "Shield"])
print(repr(character))  # 输出: Character('Aragorn', 5, ['Sword', 'Shield'])

在这个例子中,__repr__ 输出了足够的信息,包括角色的名字、等级和具体的装备列表。这样的信息量使得开发者可以使用这个字符串来理解或重构对象的精确状态。

如何理解 __repr__ 方法的输出应该使得开发者能够“理解或重构对象的精确状态”?

在 Python 中,__repr__ 方法设计的核心思想是让它返回一个对象的精确表示,理想情况下,这个表示应该足以通过执行相同的字符串来重建该对象。这种能力使得开发者可以从打印或日志中直接获取对象的详细状态,并用它来复现或调试程序的状态。这也是为什么 __repr__ 的返回值通常被写成是一个有效的 Python 表达式,如 Character('Aragorn', 5, ['Sword', 'Shield']),这个表达式包含了类名 Character 以及相应的所有参数 ('Aragorn', 5, ['Sword', 'Shield'])。所以说,开发者可以借助于这个表达式,重建 character 这个对象的状态。但是,开发者仅凭此示例中的 __str__ 方法返回的信息则很难实现同样的目的。

我们可以再举一个例子:假设咱们有一个复杂的数据结构,如一个表示几何图形的类 Rectangle

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def __repr__(self):
        return f"Rectangle({self.width}, {self.height})"

如果一个 Rectangle 对象的 __repr__ 方法被调用并返回 Rectangle(10, 20),这条信息不仅告诉了我们这是一个宽度为 10、高度为 20 的矩形,而且这条字符串本身就是一个可以用来创建相同矩形的 Python 命令。这对于调试非常有用,尤其是在处理多个对象或复杂系统时,能够快速地通过日志或错误输出确定程序状态。此外,这也符合“代码即文档”的理念,让代码本身就足够表达意图和状态,减少了额外文档的需要。

总结下,关于 __repr__ 方法的设计,应该遵循如下标准:

  1. 精确性:__repr__ 应提供所有关键信息,以充分描述对象的当前状态。这包括所有重要的属性值和可能影响对象行为的内部状态。
  2. 明确性:输出应清晰到足够让其他开发者(或未来的你)理解对象的构造和行为,无需查看其他文档或源代码。
  3. 可执行性:在许多情况下,__repr__ 的输出格式是一个有效的 Python 表达式,即复制粘贴这个表达式到 Python 解释器或代码中,应能重新创建出相同的对象。例如,eval(repr(obj)) 应该能够创建一个与 obj 状态相同的新对象。

在设计类时,通常建议至少实现 __repr__,因为它的目的是无歧义的重现对象;如果可能的话,也实现 __str__ 提供更友好的用户界面。在实际开发中,这样的实践可以大大增强代码的可维护性和调试效率,同时也提升了用户交互的友好性。

属性访问

在面向对象编程中,当咱们定义一个类时,通常会包含实例属性和类属性,如 Character 类中的 nameexperiencelevellevel_up_experience。这些属性实际上是可以通过实例或类访问的变量,它们存储了对象的内部状态。因为它们直接影响到对象的行为和数据的一致性,因此,管理这些属性的访问和修改是面向对象编程中一个重要的环节。

直接访问

在 Python 中,属性访问主要有两种基本方式。一种是直接访问和修改属性。我们可以直接通过对象来获取或设置属性的值。例如,想访问 Character 类的实例对象中的 name 属性,我们可以通过 instance.name 的方式来访问和修改该属性:

arthur = Character("Arthur", 100, 0)
print(arthur.name)

这种方法虽然简单快捷,但它不能控制外部对属性的修改。所以说直接访问属性可能会破坏封装性。

封装性作为面向对象编程中的一个核心概念,它要求一个对象的状态(属性)应该被隐藏或保护起来,不应该被外部直接访问。封装性有助于保护对象内部的实现细节,确保对象数据的完整性,并使对象易于使用和维护。

让我们通过前面提到的 Character 类来探讨为什么直接访问属性可能会破坏封装性。

class Character:
    def __init__(self, name, health, experience):
        self.name = name           # 角色的名字
        self.health = health       # 角色的健康点数
        self.experience = experience  # 角色的经验值

在这个类中,属性 name, health, 和 experience 都是公开的,意味着它们可以在类的外部被直接访问和修改:

# 创建角色实例
arthur = Character("Arthur", 100, 0)

# 直接修改属性
arthur.health = -50   # 将健康点数设置为负数
arthur.experience = -100  # 经验值也设置为一个不合逻辑的负数

这里,我们通过直接访问的方式将健康点数和经验值都设置成一个不合逻辑的负数。在游戏逻辑中,健康点数和经验值通常不应该是负数。直接访问使得这些属性可以被随意设置为无效值,从而可能会导致程序出错或行为异常。此外,直接访问还有可能降低程序的可维护性。当类的内部实现依赖于特定的属性状态时,直接修改这些属性会增加维护难度。假设 Character 类中有一个内部机制,它依赖于健康点数(health)来决定角色是否存活,并在健康点数达到零或以下时触发某些特定的行为,比如角色死亡:

class Character:
    def __init__(self, name, health, experience):
        self.name = name
        self.health = health
        self.experience = experience

    def set_health(self, value):
        if value < 0:
            self.health = 0
            print(f"{self.name} has died.")
        elif value > 100:
            self.health = 100
        else:
            self.health = value

    def add_experience(self, value):
        self.experience += value
        while self.experience >= 100:
            self.experience -= 100
            print(f"{self.name} has leveled up!")

这里,set_health 方法用于安全地设置角色的健康点数。它接受一个值作为参数。该方法的功能是,首先进行负值检查,如果传入的 value 小于 0,这通常意味着角色受到了致命伤害。该方法则将 health 设置为 0,表示角色没有健康点数,同时输出一个消息提示角色已死亡。如果传入的 value 大于 100,这可能是由于游戏逻辑错误或特殊情况(如超级加血)。该方法则将 health 设置为满血状态(100),这是健康点数的假定上限,确保角色的健康点数不会超过这个值。当然,如果传入的 value 在 0 到 100 之间,直接将此值设置为角色的 health,表示角色的健康点数在正常范围内,不需要特殊处理。

add_experience 方法用于安全地增加角色的经验值,并处理升级逻辑。该方法首先将传入的 value 加到 experience 属性上,表示角色通过完成任务或击败敌人获得了经验。如果 experience 是达到或超过 100,则角色会升级,同时,减去 100 经验值(或更多,以处理多余的经验值),并输出一个消息提示角色已升级。这个循环会持续执行,直到 experience 小于 100,确保如果获得的经验足够多,角色可以连续升级多次。

通过这两个方法的解释,我们可以发现,Character 类的内部逻辑(如角色死亡或者升级)依赖于特定的属性状态(healthexperience 的值)。如果允许直接访问 health 属性,玩家或者游戏中的其他逻辑可能会将其设置为不合适的值,比如:

# 创建角色实例
arthur = Character("Arthur", 50, 0)

# 直接修改属性
arthur.health = -20   # 将健康点数设置为负数
arthur.health = 150  # 健康值超出最大限制

这里,我们将健康点数设置为 -20。根据游戏的规则,角色应当被视为死亡。然而,直接设置并不会触发 set_health 中定义的死亡逻辑(打印死亡消息并将健康点数设为 0)。这样,角色可能会在游戏中以一个不合逻辑的状态继续存在。而将 health 直接设置为 150 也违背了游戏的规则,因为这个类设计中的健康点数上限为 100。直接设置可能导致游戏的一些特定逻辑(如伤害计算和显示)出现不符合预期的结果。experience 的情况类似,根据类的设计,当角色的经验值超过 100 时,角色应该升级一次或者连续升级多次(每 100 经验升一级)。但是,直接修改 experience(比如 arthur.experience = 1000), 并不会触发 add_experience 方法中定义的升级逻辑(经验值重置和打印升级消息)。这会导致角色的经验值和等级之间的不一致,以及不触发与升级相关的其他游戏事件(如能力提升)。

可以看出,通过直接方式访问或者修改属性通常会导致数据不一致、逻辑错误和潜在的状态异常。该种方式并不可取。这也凸显了我们接下来要介绍的第二种属性访问方式的重要性。它不仅可以保护数据完整性,同时还可以维护程序的整体规则和逻辑的一致性。

Accessor 和 Mutator

第二种属性访问方式主要是通过定义“实例方法”来实现。这些方法不仅为我们提供了访问属性的接口,还可以在访问和修改属性时加入一些逻辑处理,如验证数据或修改相关的其他属性。由于其功能的特殊性,这类方法一般被称为访问器(accessor)和变更器(mutator),或者称为 “getter” 和 “setter” 方法。accessor,顾名思义,允许我们访问类中的属性,而 mutator 则允许我们设置或者修改类中属性的值。

要实现 accessor 和 mutator 模式,我们需要:

  1. 将属性设置为非公开的。
  2. 为每个属性编写 accessor 和 mutator 方法。

仍以 Character 类为例,下面运用 accessor 和 mutator 方法来管理角色的属性。为此,我们首先需要将 Character 类的属性 namehealthexperience 设置为受保护的属性,即在变量名称前加上单下划线(_),这样表示这些属性不应该被直接从类的外部访问和修改。

class Character:
    def __init__(self, name, health, experience):
        self._name = name
        self._health = health
        self._experience = experience

在此基础上,我们为每个私有属性编写对应的 accessor 和 mutator 方法,以提供访问和修改属性的安全方式。这些方法通常命名为 get_<属性名>set_<属性名>,分别用于返回属性值和设置新的属性值,比如:

class Character:
    def __init__(self, name, health, experience):
        self._name = name           # 初始化时将属性设置为私有
        self._health = health
        self._experience = experience

    # accessor for name
    def get_name(self):
        return self._name

    # mutator for name
    def set_name(self, value):
        if isinstance(value, str):  # 简单的类型检查
            self._name = value

    # accessor for health
    def get_health(self):
        return self._health

    # mutator for health
    def set_health(self, value):
        if value < 0:
            value = 0  # 保护健康值不为负
        elif value > 100:
            value = 100  # 限制健康值的最大值
        self._health = value

    # accessor for experience
    def get_experience(self):
        return self._experience

    # mutator for experience
    def set_experience(self, value):
        if value < 0:
            value = 0  # 防止经验值为负
        self._experience = value

在该 Character 类的实现中,我们可以看到如何使用 accessor 和 mutator 方法来管理类的受保护属性。这些方法一般是成对定义,从而提供了一个明确的接口来控制对属性的访问和修改。在该类中,角色名称的访问器 get_name() 方法提供了对私有属性 _name 的访问途径,从而实现不暴露属性的直接修改方式,其他访问器类似。在 Python 中,accessor 一般不接受除 self 外的任何参数。这确保了该方法的调用仅依赖于对象本身的状态。同时,accessor 总是返回它们所获取的属性值,如 get_name() 方法返回角色的名称,get_health() 返回健康值,而 get_experience() 返回经验值。

Character 类中,健康值的变更器 set_health() 方法不仅设置健康值,还包含逻辑以确保健康值不会低于 0 或高于 100,从而维护游戏规则的一致性和角色的有效状态。通常情况下,mutator 除了隐含的 self 参数外,每个变更器方法通常接受一个参数,即想要设置的新值。为了避免设置操作的副作用,这些方法也通常不返回任何值(或者说返回 None)。在属性值被更新之前,在 mutator 中可以进行必要的检查,如 set_experience() 方法中的验证确保了经验值不为负值。

下面是 accessor 和 mutator 方法的使用示例:

arthur = Character("Arthur", 100, 50)

# 使用 getter 方法
print(arthur.get_name())  # 输出: Arthur

# 使用 setter 方法
arthur.set_health(150)
print(arthur.get_health())  # 输出: 100 (因为健康值被限制在100)

arthur.set_experience(-10)
print(arthur.get_experience())  # 输出: 0 (因为经验值不能为负)

@property 装饰器

虽然传统的 accessor(访问器)和 mutator(变更器)方法提供了一种明确的模式来控制类属性的访问和修改,并允许在这些操作中嵌入额外的处理逻辑,但这种方法通常需要为每个属性编写两个方法:一个用于获取属性值,另一个用于设置新值。这不仅使得代码变得较为冗长,也降低了代码的简洁性,与 Python 语言推崇的“简单明了”的原则也不太一致。

在上述模式中,属性的读取和写入需要通过 instance.get_<属性名>()instance.set_<属性名>(value) 的形式进行,这对于开发者和最终用户而言可能稍显繁琐。如果能有一种方法,既能够保持属性访问和修改的简洁性,又能够融入 accessor 和 mutator 的优势(如逻辑封装和数据验证),那将大大增强代码的可用性和可维护性。

为此,Python 提供了一种解决方案,即使用 @property 装饰器。@property 装饰器允许开发者将类中的方法转变为对应的属性,这些属性仍然执行与 accessor 和 mutator 相同的内部逻辑。这种方法的使用不仅使得属性的访问看起来就像是直接访问变量一样,而且还能在后台自动处理必要的逻辑,如检查数据有效性或执行必要的转换。因此,@property 装饰器是 Python 社区中处理类属性时尤为推崇的一种方法,特别是在需要对属性的读写进行控制或添加额外逻辑时。

以下是使用 @property 装饰器实现的 Character 类:

class Character:
    def __init__(self, name, health, experience):
        self._name = name
        self._health = health
        self._experience = experience

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise ValueError("Name must be a string")
        self._name = value

    @property
    def health(self):
        return self._health

    @health.setter
    def health(self, value):
        if value < 0:
            value = 0
        elif value > 100:
            value = 100
        self._health = value

    @property
    def experience(self):
        return self._experience

    @experience.setter
    def experience(self, value):
        if value < 0:
            value = 0
        self._experience = value

从该版本的 Character 类可以看出,使用 @property 装饰器将 accessor 和 mutator 方法转换为属性非常简单。只需在对应的 accessor 方法前添加 @property 装饰器,并在 mutator 方法前添加 @<property_name>.setter 装饰器。通常,这些方法的命名与它们控制的属性名相同,例如,对于 _name 属性,相应的 accessor 和 mutator 方法命名为 name;对于 _health 属性,方法命名为 health,依此类推。除此之外,内部的处理逻辑和不使用 @property 装饰器时的 accessor 和 mutator 方法内部逻辑一致。

使用 @property 装饰器的一个显著好处是,它使得访问属性值就像访问公开属性一样直接,同时允许我们在背后保留必要的逻辑处理(如该例子中的数据验证和条件检查)。比如说,我们创建一个 Character 类的实例,并初始化其属性::

hero = Character("Arthur", 100, 50)

由于使用了 @property 装饰器,我们可以直接访问这些属性,就像它们是公开的属性一样:

print(hero.name)            # 输出: Arthur
print(hero.health)          # 输出: 100
print(hero.experience)      # 输出: 50

当我们需要修改这些属性时,可以直接为它们赋值,而 @property 装饰器确保调用相应的 mutator 方法来处理赋值操作:

hero.name = "SunWukong"   # 尝试将 name 设置为 "SunWukong"。由于这是一个字符串,赋值将成功。
hero.health = 110         # 尝试将 health 设置为 110。由于 mutator 方法中限制了健康值的最大值为 100,它将自动调整为 100。
hero.experience = -10     # 尝试将 experience 设置为 -10。由于 mutator 方法不允许负值,它将自动调整为 0。

我们可以查看下相应的修改后的属性值:

print(hero.name)          # 输出: SunWukong
print(hero.health)          # 输出: 100
print(hero.experience)      # 输出: 0

除了以上方式使用 @property装饰器,Python 中的属性(properties)具有更为广泛的应用场景。例如,我们可以借助于 @property 装饰器创建只读(read-only)、只写(write-only)和可读写(read-write)的属性。

定义只读属性也很简单,在类中,针对某一个属性只定义 accessor 方法,而不定义对应的 mutator 方法即可。比如说:

class Character:
    def __init__(self, name, health, experience):
        self._name = name
        self._health = health
        self._experience = experience

    @property
    def name(self):
        return self._name

这里,我们只定义了角色名称的 accessor 方法。显然有:

hero = Character("Arthur", 100, 50)

print(hero.name)       # 输出: Arthur

但是,在此种情况下,我们再想修改角色名称时:

hero.name = "SunWukong"

解释器会输出属性错误(AttributeError)的异常:

Traceback (most recent call last):
  File "c:\Users\thinkstation\Desktop\testing\property.py", line 44, in <module>
    hero.name = "SunWukong"
    ^^^^^^^^^
AttributeError: property 'name' of 'Character' object has no setter

如果想使某个属性为只写属性,那么我们可以使用 @property 装饰器结合 mutator 方法,而不提供 accessor 方法。这样,该属性只能被设置,而不能被外部直接读取,符合只写属性的定义。比如说游戏角色中有另外一个密码属性:

class Character:
    def __init__(self, name, health, experience):
        self._name = name
        self._health = health
        self._experience = experience
        self._password = None  # 私有属性,初始时没有设置

我们可以按照如下方式定义对应的方法:

class Character:
    def __init__(self, name, health, experience):
        self._name = name
        self._health = health
        self._experience = experience
        self._password = None  # 私有属性,初始时没有设置

    # 只写属性 password 的 setter
    @property
    def password(self):
        raise AttributeError("This attribute is write-only")

    @password.setter
    def password(self, value):
        # 可以在这里加入密码强度验证等逻辑
        self._password = value

在该例中,password 属性被定义为只写。尝试读取这个属性将会引发异常,而设置这个属性则是允许的:

hero = Character("Arthur", 100, 50)

# 设置密码
hero.password = "secure_password123"

# 尝试读取密码
try:
    print(hero.password)
except AttributeError as e:
    print(e)  # 输出: This attribute is write-only

除此之外,使用 @property 装饰器,还可以定义删除(deletion)操作和提供属性的文档说明等功能。比如说:

class Character:
    def __init__(self, name, health, experience):
        self._name = name
        self._health = health
        self._experience = experience

    @property
    def name(self):
        "The name of the character"
        return self._name

    @name.setter
    def name(self, value):
        self._name = value

    @name.deleter
    def name(self):
        print("Deleting name...")
        del self._name

    @property
    def health(self):
        "The health level of the character. Must be between 0 and 100."
        return self._health

    @health.setter
    def health(self, value):
        if value < 0:
            value = 0
        elif value > 100:
            value = 100
        self._health = value

    @health.deleter
    def health(self):
        print("Deleting health...")
        del self._health

    @property
    def experience(self):
        "The experience points of the character."
        return self._experience

    @experience.setter
    def experience(self, value):
        if value < 0:
            value = 0
        self._experience = value

    @experience.deleter
    def experience(self):
        print("Deleting experience...")
        del self._experience

在这个 Character 类中,每个属性不仅可以被读取和修改,还可以被删除(通过在对应方法前加上 @<property_name>.deleter 实现)。同时,每个属性都有对应的文档字符串,可以使用 Python 的内置函数 help() 来查看:

hero = Character("Arthur", 100, 50)

# 删除属性
del hero.name
del hero.health
del hero.experience

# 尝试再次访问属性,会引发错误因为属性已被删除
try:
    print(hero.name)
except AttributeError as e:
    print(e)        # 输出:'Character' object has no attribute '_name'

使用 help() 来查看 Character 类的文档字符串:

help(Character)

则会显示如下结果:

Help on class Character in module __main__:

class Character(builtins.object)
 |  Character(name, health, experience)
 |
 |  Methods defined here:
 |
 |  __init__(self, name, health, experience)
 |      Initialize self.  See help(type(self)) for accurate signature.
 |
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |
 |  __dict__
 |      dictionary for instance variables
 |
 |  __weakref__
 |      list of weak references to the object
 |
 |  experience
 |      The experience points of the character.
 |
 |  health
 |      The health level of the character. Must be between 0 and 100.
 |
 |  name
 |      The name of the character

此外,我们也可以使用如下的方式,显示对应属性的文档字符串

# 显示属性的文档字符串
print(Character.name.__doc__)       # 输出:The name of the character
print(Character.health.__doc__)     # 输出:The health level of the character. Must be between 0 and 100.
print(Character.experience.__doc__) # 输出:The experience points of the character.

Descriptors

除了前面三种方式的属性访问外,Python 还为我们提供了一种高级的控制属性行为的方法,即描述符(descriptors)。描述符是一种遵守特定协议(protocol)的类,该协议包括方法 __get__(), __set__(), 和 __delete__() 三种特殊方法。通过这些方法,描述符使得我们能够精细控制类属性的访问、修改和删除行为。

从背后的实现上看,Python 中使用 @property 装饰器创建的属性,实际上是在后台使用描述符来实现属性的控制的。这可以从使用 help(Character) 得到的结果中观察到。在这个帮助文档中,我们可以看到 namehealth 以及 experience 被列为数据描述符(Data descriptors)。数据描述符(data descriptors)是同时定义了 __get__()__set__() 方法的描述符。与之相对应的还有非数据描述符(non-data descriptors)。非数据描述符(non-data descriptors)是只定义了 __get__() 方法的描述符。相比而言,数据描述符具有更高的优先级。

现在,我们以 Character 类为基础,创建一个简单的描述符 NonNegative,来说明描述符的定义和使用。

class NonNegative:
    def __init__(self, default=0):
        self.default = default

    def __get__(self, instance, owner):
        # 返回实例中存储的值
        return getattr(instance, self.private_name, self.default)

    def __set__(self, instance, value):
        # 检查设置的值是否为负,如果是,抛出异常
        if value < 0:
            raise ValueError("This value must be non-negative")
        setattr(instance, self.private_name, value)

    def __set_name__(self, owner, name):
        # 存储属性的私有名称
        self.private_name = '_' + name

从定义上看,描述符本质上是类(Class),但是与普通类在功能定位、使用上下文、方法实现以及实例行为等方面还是有一定的区别:

  • 功能定位方面:普通类通常封装数据和行为,用来模拟真实世界的对象。而描述符类专门用于管理属性访问行为,它们实现了描述符协议,可以控制属性的获取、设置和删除行为。
  • 使用上下文方面:普通类可以直接实例化并使用。描述符类通常被用作另一个类的属性,用来控制那个类的某个属性的行为。
  • 方法实现方面:描述符类必须实现描述符协议中的一个或多个方法(__get__, __set__, __delete__),这是其作为描述符工作的基础。普通类则不需要这些方法,除非特定需求。
  • 实例行为方面:在描述符中,__get____set__ 方法的调用是自动的,基于属性访问和属性赋值触发,而普通类的方法调用通常是显式的。

从定义形式上,描述符可以按照如下结构定义:

class Descriptor:
    def __init__(self, initial_value=None, name='my_var'):
        self.value = initial_value # 存储属性的实际数据
        self.name = name

    def __get__(self, obj, objtype=None):
        print(f"Getting: {self.name}")
        return self.value

    def __set__(self, obj, value):
        print(f"Setting: {self.name} to {value}")
        self.value = value

    def __delete__(self, obj):
        print(f"Deleting: {self.name}")
        del self.value

其中的 __init__() 方法是用来初始化描述符实例的,可以接受一个初始值和一个名称(主要目的是用于调试或日志记录),这里的 value 存储了属性的实际数据。__get__ 方法负责返回属性的值,与 accessor 的功能类似。这里的 obj 是访问属性的对象实例,objtype 是拥有该属性的类。这个方法可以用来返回内部存储的值,或基于复杂逻辑计算后的值。__set__ 方法主要负责设置属性的新值,与 mutator 的功能类似。obj 是尝试修改属性的对象实例。value 是尝试设置的新值。这个方法可以用来验证新值是否符合要求,或在设置新值前进行必要的处理。__delete__ 方法主要负责处理属性的删除操作。obj 是尝试删除属性的对象实例。可以在删除属性前执行清理操作,或阻止删除操作。

在描述符中,__init__ 方法并不是必须的。是否在描述符中使用 __init__ 方法主要还是取决于描述符是否需要存储或初始化数据,以及是否需要接受外部配置参数。在很多简单应用或固定行为的描述符中,完全可以省略 __init__ 方法以简化代码,如下例,

class ToStringDescriptor:
    def __get__(self, obj, objtype=None):
        return getattr(obj, '_hidden', '')

    def __set__(self, obj, value):
        setattr(obj, '_hidden', str(value))

该描述符用于将所有赋值转换为字符串类型。在这个示例中,描述符 ToStringDescriptor 自身不需要初始化任何内部状态,因为它仅仅是将值转换为字符串并存储在实例的隐藏属性 _hidden 中。因此,这里没有必要实现 __init__ 方法。

不过,在需要灵活配置或有状态的描述符中,__init__ 方法是初始化内部状态的理想选择。在以下两种情况下,建议构建 __init__ 方法:

  • 初始化数据:如果描述符需要内部存储信息,如计数器、缓存或其他状态信息,__init__ 方法就非常有用。它可以初始化这些内部数据结构。
  • 配置属性:在某些情况下,描述符可能需要从使用它的类接收配置信息或初始值。通过在描述符的构造函数中设置这些值,可以让描述符的行为更加灵活和动态。

比如说,在描述符 NonNegative 中,我们构建了 __init__ 方法。该方法被用于初始化一个默认值 default。这个默认值在 __get__ 方法中用作一个后备值,如果实例中还没有设置属性,则返回这个默认值。假设我们不创建 __init__ 方法。则 __get__ 方法假定实例始终已经有一个有效的值存储。这意味着在使用描述符之前,实例需要以某种方式初始化这些属性,否则在第一次访问属性时可能引发错误。

回到 NonNegative 描述符。我们进一步简要介绍下其中各个方法的作用。

  • __get__(self, instance, owner):此方法在访问属性值时被调用。其中 instance 是属性所属的实例,owner 是拥有该实例的类。在 NonNegative 中,这个方法返回实例的私有属性值,如果未设置,则返回默认值。
  • __set__(self, instance, value):此方法在属性值被修改时调用。其中 value 是尝试设置的新值。在 NonNegative 中,这个方法确保只有非负数才能被设置到属性上,否则抛出 ValueError
  • __set_name__(self, owner, name):这是 Python 3.6 新增的一个描述符方法,它在创建类时自动调用,用于捕获属性名。其中 owner 是拥有描述符的类,name 是属性名。在 NonNegative 中,这个方法用来设置私有属性名称,通常是在原有属性名前加一个下划线(如 _health),以便内部使用且不与其他属性名冲突。

接下来,我们修改 Character 类,使用 NonNegative 描述符来管理 healthexperience 属性:

class Character:
    health = NonNegative()
    experience = NonNegative()

    def __init__(self, name, health, experience):
        self.name = name
        self.health = health
        self.experience = experience

现在,任何尝试将 healthexperience 设置为负数的操作都会触发异常:

hero = Character("Arthur", 100, 50)

# 尝试设置负的健康值
try:
    hero.health = -10
except ValueError as e:
    print(e)  # 输出: This value must be non-negative

# 正常设置经验值
hero.experience = 30
print(hero.experience)  # 输出: 30

# 尝试删除 health 属性
try:
    del hero.health
except AttributeError as e:
    print(e)  # 如果有特定的删除逻辑,可以在描述符中处理

可以看出,使用描述符 NonNegative,确保了 Character 类的 healthexperience 属性不会被设置为负数。这在功能上与使用 @property 装饰器类似,但描述符提供了一些独特的优势,特别是在代码的复用性和一致性方面。

虽然 @property 装饰器在简单性和直观性方面上有优势,且易于理解和实施,但是描述符在处理更复杂的属性行为时优势明显。描述符允许开发者将属性的控制逻辑封装在一个独立的类中,这不仅增强了代码的模块化,还提高了维护效率。最重要的是,描述符可以在多个类之间共享,避免了重复编写相同的属性逻辑,从而保持了代码的干净和一致。例如,如果有多个类都需要确保某些数值属性不能为负,使用 NonNegative 描述符可以一次性解决这一需求,而无需在每个类中分别使用 @property 定义相同的逻辑。这不仅节省了开发时间,也使得未来的逻辑更改更加集中和高效。

__getattr__, __setattr__, __delattr__

关于属性访问,我们最后再介绍一种方式,使用 __getattr__, __setattr__, __delattr__ 特殊方法来实现属性的管理。这些方法允许开发者拦截对未知属性的访问或控制所有属性的赋值和删除行为,从而实现更复杂的属性管理策略。

在 Python 中,__getattr__ 被用于定义对象属性访问失败时的行为。当咱们尝试访问对象的一个属性时,如果该属性在对象的常规属性字典中不存在,Python 将自动调用这个对象的 __getattr__ 方法。

在 Python 中,每个类的实例都有一个名为 __dict__ 的属性,它是一个字典对象,用于存储实例的属性及其值。这个字典被称为 “常规属性字典”,它持有对象在运行时动态设置的所有属性。这些属性可以是任何添加到对象的数据成员,不仅包括在类定义中直接声明的属性,也包括在对象生命周期中动态添加的属性。下例展示了如何使用 __dict__

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

person = Person("John", 30)

# 访问 __dict__
print(person.__dict__)  # 输出:{'name': 'John', 'age': 30}

# 动态添加新属性
person.height = 175
print(person.__dict__)  # 输出:{'name': 'John', 'age': 30, 'height': 175}

# 删除属性
del person.age
print(person.__dict__)  # 输出:{'name': 'John', 'height': 175}

当咱们尝试获取一个属性时,Python 首先查看对象的 __dict__ 来找到这个属性。如果找不到,Python 将检查类的 __dict__,然后是继承链上的类的 __dict__。如果所有这些常规查找都失败了,才会调用特殊方法 __getattr__(如果定义了的话),这就是为什么说“如果该属性在对象的常规属性字典中不存在,Python 才将自动调用这个对象的 __getattr__ 方法”。

假设我们希望在 Character 类中,当某个属性不存在时,提供一种处理方式而不是抛出 AttributeError。这里,我们就可以添加一个 __getattr__ 方法来实现这个功能:

class Character:
    def __init__(self, name, health, experience):
        self.name = name
        self.health = health
        self.experience = experience

    def __getattr__(self, attr):
        # 如果尝试访问的属性不存在,返回一个默认消息
        return f"{attr} not available"

hero = Character("Arthur", 100, 50)

# 正常访问存在的属性
print(hero.name)  # 输出:Arthur

# 尝试访问不存在的属性
print(hero.strength)  # 自动调用__getattr__(), 输出: strength not available

如果,不定义 __getattr__, 则在 print(hero.strength) 会输出如下异常:

Traceback (most recent call last):
  File "c:\Users\thinkstation\Desktop\testing\property.py", line 48, in <module>
    hero.strength
AttributeError: 'Character' object has no attribute 'strength'

此外,如果某个属性不存在,__getattr__ 还可以用来提供一个默认值。比如说:

class Character:
    def __init__(self, name, health, experience):
        self.name = name
        self.health = health
        self.experience = experience

    def __getattr__(self, attr):
        # 提供默认值的字典
        default_values = {
            'strength': 10,
            'intelligence': 10,
            'agility': 10
        }
        # 如果属性在默认值字典中,返回默认值
        if attr in default_values:
            return default_values[attr]
        # 如果不在默认值字典中,返回通用不可用消息
        return f"{attr} not available"

这里,__getattr__ 方法首先检查一个名为 default_values 的字典,该字典定义了一些属性及其对应的默认值。当尝试访问的属性名在这个字典中时,方法返回相应的默认值。如果属性名不在这个字典中,则返回一个表示属性不可用的消息。

hero = Character("Arthur", 100, 50)

# 正常访问存在的属性
print(hero.name)  # 输出: Arthur

# 尝试访问不存在的属性,但提供默认值
print(hero.strength)  # 输出: 10
print(hero.intelligence)  # 输出: 10

# 尝试访问不存在且无默认值的属性
print(hero.charisma)  # 输出: charisma not available

__getattr__ 还常用于实现延迟加载(lazy loading),即属性的值在首次访问时才计算或加载。这对处理开销大的计算或从外部数据源加载数据尤其有用。假设我们有一个 profile 属性。该属性将从一个外部数据源(例如数据库或网络服务)加载角色的详细配置文件。我们可以通过如下方式实现 profile 属性的延迟加载:

class Character:
    def __init__(self, name, health, experience):
        self.name = name
        self.health = health
        self.experience = experience
        self._profile_loaded = False
        self._profile = None

    def load_profile(self):
        # 这里模拟一个耗时的数据加载过程
        print("Loading profile from a slow database...")
        return {
            "biography": "Arthur was a legendary British leader.",
            "achievements": ["Established the Knights of the Round Table", "United Britain"]
        }

    def __getattr__(self, attr):
        if attr == "profile":
            if not self._profile_loaded:
                self._profile = self.load_profile()
                self._profile_loaded = True
            return self._profile
        return f"{attr} not available"

hero = Character("Arthur", 100, 50)

# 访问 profile 属性,将触发延迟加载
print(hero.profile)  # 输出加载过程和数据
print(hero.profile)  # 直接输出数据,无需再次加载

这里,我们初始化时程序并不加载 profile 数据,而是在该属性首次被访问时(如第一次print(hero.profile))调用 load_profile 方法来加载数据。这减少了对象创建时的开销,特别是对于加载数据特别耗时的属性。这个逻辑的具体实现是由 __getattr__() 特殊方法控制。__getattr__() 在属性 profile 被访问且尚未加载时,调用 load_profile 方法并缓存结果。一旦数据被加载,后续访问该属性不会再触发加载过程, 如第二次 print(hero.profile)

__setattr__ 方法允许我们定义当属性被赋值时应执行的自定义行为,如数据验证、自动转换、维护属性间的依赖关系,或其他任何需要在属性值改变时执行的操作。当咱们尝试给属性赋值时,例如 obj.attribute = value,Python 会自动调用定义好的 __setattr__ 方法。

__setattr__ 方法通常定义如下:

def __setattr__(self, name, value):
    # 自定义行为

其中 name 是要设置的属性名称,value 是要设置的值。

我们可以通过实现 __setattr__ 方法来达到确保 healthexperience 属性不会被设置为负值的目的:

class Character:
    def __init__(self, name, health, experience):
        self.name = name
        self.health = health
        self.experience = experience

    def __setattr__(self, key, value):
        # 对 health 和 experience 属性进行非负值检查
        if key in ["health", "experience"] and value < 0:
            raise ValueError(f"{key} cannot be negative.")

        # 调用基类的 __setattr__ 来实际设置属性
        super().__setattr__(key, value)

        # 记录属性设置
        print(f"Set {key} to {value}")

# 创建一个 Character 对象
hero = Character("Arthur", 100, 50)

# 修改 health 属性
hero.health = 80

# 尝试将 experience 设置为负值,将触发 ValueError
try:
    hero.experience = -10
except ValueError as e:
    print(e)    # 输出:experience cannot be negative

需要注意的是,在 __setattr__ 中直接使用 self.name = value 形式赋值会再次触发 __setattr__,导致无限递归。为避免这种情况,应使用 super().__setattr__(name, value)object.__setattr__(self, name, value) 来确保调用基类的实现,从而正确地设置属性。

__delattr__ 特殊方法的定义和使用与 __setattr__ 类似,具体可以参考Customizing attribute access。这里不再详述。

属性访问的四种主要方式各有其特点和适用场景。下面比较了直接访问、@property 装饰器、描述符(descriptors),以及通过使用特殊方法(如 __getattr____setattr__ 等)管理属性的方法。

属性访问方式 特点 优点 缺点 适用场景
直接访问 直接通过属性名访问或修改对象的属性 简单直观;代码易于理解和使用 不提供封装和验证;容易导致外部代码直接修改内部状态 不需要额外逻辑,对性能要求高的场合
@property 装饰器 使用装饰器定义的方法来管理属性的获取和设置 代码整洁;易于添加额外逻辑如验证和转换 每个属性需要额外的方法;可能影响性能 需要封装属性以隐藏实现细节,或当属性的设置和获取需要附加逻辑处理时
描述符 (Descriptors) 通过定义带有 __get____set____delete__ 方法的类来管理属性 高度可复用;可应用于多个属性和类;提供最大的控制力 较复杂,初学者可能难以理解;增加了代码的抽象层次 需要在多个属性或类中统一实现相同的存取逻辑,或需要跨属性同步或其他复杂的属性管理行为
特殊方法 (__getattr__, __setattr__, etc.) 使用魔法方法来拦截对属性的所有访问、设置或删除操作 提供对属性操作的全面控制;可以实现动态属性或代理 处理复杂;容易导致错误,如无限递归;可能影响性能 需要自定义属性访问机制,动态生成属性,或在访问属性时执行非标准行为

类的容器功能

在 Python 中,类可以被视为一种容器,它们用于封装数据和与数据相关的方法。同时,类也是实现复杂数据结构的主要工具。如链表、树、图、堆、哈希表等。通过定义节点、操作如插入、删除等,类能够提供数据结构所需的所有功能。下面借助于类,实现了一个简单的链表:

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

class LinkedList:
    def __init__(self):
        self.head = None

    def append(self, data):
        new_node = Node(data)
        if self.head is None:
            self.head = new_node
        else:
            last = self.head
            while last.next:
                last = last.next
            last.next = new_node

    def display(self):
        current = self.head
        while current:
            print(current.data, end=' ')
            current = current.next
        print()

在这个例子中,Node 类用于创建链表节点,而 LinkedList 类则封装了链表的操作,如添加新节点和显示链表内容。这个设计体现了类作为容器的特性,其中 LinkedList 类封装了链表的所有功能和数据,使得链表的操作逻辑清晰且易于管理。

从这个例子上来说,容器就像一个桶或者盒子一样,是一个可以存放各种东西的地方。在编程里,这些“东西”可以是数字、文字或者其他任何东西。更为正式的说法是容器是一种用来存储和组织多个数据元素的数据结构。Python 内置的列表(list)、元组(tuple)、集合(set)、字典(dictionary)等均是容器。像列表和元组可以用来保存序列化的数据,而集合可以被用于存储无序且不重复的元素,字典则可以提供了基于键值对的存储机制。根据数据特征,使用不同的容器来存储,可以为我们提供便捷的方法来访问存储的数据。比如字典允许通过键来快速检索数据,列表则允许通过索引进行访问。此外,某些容器(如集合和字典)对数据的添加、删除和查找也能提供更为高效的处理方式,这些操作的时间复杂度可以达到 O(1)。

Python 的容器类型通常支持一系列的内置方法,比如列表通常支持 append(), remove(), pop() 等方法,字典通常支持 keys(), values(), items() 等方法。当然,我们也可以在自定义类中实现和扩展这些容器方法,以满足特定的业务需求或性能优化,如 LinkedList 类内容中实现的 append() 方法。与其他特殊方法类似,Python 也为我们提供了一些特殊方法来实现容器类的特殊功能,如 __len__, __getitem__,__setitem__, __delitem__ 等。这些方法的实现,可以提供类似与内置类型的行为。比如说,一旦一个类定义了 __len__ 方法,我们就可以使用内置的 len(instance) 函数来获取该容器的长度,这样的设计极大地提升了语言的一致性和直观性。下表列举了 Python 中用于定义容器类行为的常用特殊方法:

方法名 描述
__len__ 返回容器中元素的数量。
__add__ 定义加法操作,使对象能够使用 + 运算符。
__mul__ 定义乘法操作,使对象支持重复,如 obj * n
__iadd__ 定义就地(In-palce)加法操作,使对象支持 += 运算符。
__imul__ 定义就地乘法操作,使对象支持 *= 运算符。
__eq__ 定义等于测试,使对象支持 == 运算符。
__ne__ 定义不等于测试,使对象支持 != 运算符。
__lt__ 定义小于测试,使对象支持 < 运算符。
__le__ 定义小于等于测试,使对象支持 <= 运算符。
__gt__ 定义大于测试,使对象支持 > 运算符。
__ge__ 定义大于等于测试,使对象支持 >= 运算符。
__getitem__ 使对象支持索引操作,如 obj[key] 来访问元素。
__setitem__ 使对象支持通过索引设置元素,如 obj[key] = value
__delitem__ 使对象支持通过索引删除元素,如 del obj[key]
__iter__ 返回容器的迭代器,用于 for 循环遍历。
__next__ 使对象成为迭代器,返回迭代中的下一个元素。
__contains__ 检查容器是否包含某元素,响应 in 关键字。
__reversed__ 提供容器的反向迭代器。

下面,我们基于 LinkedList 链表类探讨在容器中如何实现这些特殊方法的定义及其使用。

LinkedList 类中,__len__() 方法用于返回链表中元素的数量。由于该链表本身不存储其长度信息,我们不能像数组那样直接访问长度属性。因此,我们需要从链表的头部开始遍历每一个节点,直到达到链表的末尾。每遍历一个节点,计数器就递增一次,这样可以确保准确计算出链表中的元素总数。

这个方法的实现反映了链表的基本特性——节点间的线性连接。计算长度的过程实际上是一个线性时间复杂度的操作(O(n)),这意味着操作的耗时与链表长度成正比。虽然这种方法在性能上不如直接访问数组长度,但它是链表结构所必需的,因为链表的非连续存储特性使得无法像数组那样直接通过一个属性获取长度。

以下是 __len__() 方法在 LinkedList 中的实现:

def __len__(self):
    count = 0
    current = self.head
    while current:
        count += 1
        current = current.next
    return count

这种方式确保了无论链表如何变化,都可以准确地获取其长度,是处理链表类型数据时常见的操作之一。这样,当想知道 LinkedList 对象中的数据个数时,我们就可以调用 len(my_list) 打印出链表的长度。在 Python 在内部,这个过程实际上调用了 my_list.__len__() 方法。如该方法所示,这个方法将遍历整个链表,计数节点的数量,并返回这个值。

# 创建一个 LinkedList 实例
heroes_list = LinkedList()

# 向链表中添加草丛三姐妹
heroes_list.append("妲己")
heroes_list.append("安琪拉")
heroes_list.append("王昭君")

# 使用 __len__ 方法获取链表的长度
length = len(heroes_list)  # 这里实际上调用的是 heroes_list.__len__()

# 打印链表的长度
print("Number of heroes in the linked list:", length) # 输出:Number of heroes in the linked list: 3

为了提升 LinkedList 类的效率,我们可以在类内部直接跟踪链表的长度,从而避免每次调用 __len__ 方法时需要遍历整个链表。下面是修改后的 LinkedList 类,其中添加了一个 length 属性来跟踪链表的长度,并相应地更新了 append__len__ 方法:

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

class LinkedList:
    def __init__(self):
        self.head = None
        self.length = 0  # 初始化链表长度为0

    def append(self, data):
        new_node = Node(data)
        if self.head is None:
            self.head = new_node
        else:
            last = self.head
            while last.next:
                last = last.next
            last.next = new_node
        self.length += 1  # 每添加一个节点,长度增加1

    def __len__(self):
        return self.length  # 直接返回链表的长度

这样,我们就不用每次计算链表长度时,均需要遍历链表中的所有元素,只需要访问访问对象的 length属性。这使得获取长度的时间复杂度降低到了 O(1),即常数时间内完成。

LinkedList 类中,__add__() 方法的实现是为了使 LinkedList 类的对象支持使用加号 (+) 运算符来合并两个链表,并返回一个新链表。

def __add__(self, other):
    if not isinstance(other, LinkedList):
        return NotImplemented
    new_list = LinkedList()
    current = self.head
    while current:
        new_list.append(current.data)
        current = current.next
    current = other.head
    while current:
        new_list.append(current.data)
        current = current.next
    return new_list

该方法首先检查 other 是否也是一个 LinkedList 实例(这是必要的,因为只有相同类型的链表才能合并)。如果 other 不是 LinkedList 类型,方法返回 NotImplemented,这是 Python 的一种标准做法,用于指示特定操作在给定的类型间不支持。

其次,创建一个名为 new_list 的新 LinkedList 实例。这个新链表将包含来自 self(当前对象)和 other(另一个链表)的所有节点。

第三步,设置一个指针 current 指向 self.head(当前对象的头节点)。遍历当前链表 (self),使用 append 方法将每个节点的数据复制到新链表 new_list。这确保 new_list 会包含当前链表的所有元素的副本

第四步,重新设置指针 current 指向 other.head(另一个链表的头节点)。继续遍历 other 链表,同样使用 append 方法将每个节点的数据添加到 new_list。这样,new_list 也将包含另一个链表的所有元素的副本。

最后,方法返回新创建的 new_list,这个链表现在就包含了两个原始链表的所有节点,而原始链表的节点和结构保持不变。

我们可以在前面的示例中添加第二个链表,并运用 + 的方式合并这两个链表:

# 创建第二个 LinkedList 实例,并添加其他英雄
more_heroes_list = LinkedList()
more_heroes_list.append("露娜")
more_heroes_list.append("娜可露露")
more_heroes_list.append("貂蝉")

# 显示第一个链表的英雄名字和长度
print("First LinkedList:")
heroes_list.display()
print("Number of heroes in the first linked list:", len(heroes_list))

# 显示第二个链表的英雄名字和长度
print("\nSecond LinkedList:")
more_heroes_list.display()
print("Number of heroes in the second linked list:", len(more_heroes_list))

# 使用 __add__ 方法合并两个链表
combined_list = heroes_list + more_heroes_list

# 显示合并后的链表的英雄名字和长度
print("\nCombined LinkedList:")
combined_list.display()
print("Number of heroes in the combined linked list:", len(combined_list))

运行后,会输出如下结果:

First LinkedList:
妲己 安琪拉 王昭君 
Number of heroes in the first linked list: 3

Second LinkedList:
露娜 娜可露露 貂蝉
Number of heroes in the second linked list: 3

Combined LinkedList:
妲己 安琪拉 王昭君 露娜 娜可露露 貂蝉
Number of heroes in the combined linked list: 6

类似的,我们可以在 LinkedList 中实现 __mul__() 方法,使链表可以通过乘法操作符(*)与一个整数相乘,从而创建一个新的链表,其中原链表的内容重复指定的次数。

def __mul__(self, n):
    if not isinstance(n, int):
        return NotImplemented
    if n <= 0:
        return LinkedList()  # 返回一个空的链表
    new_list = LinkedList()
    for _ in range(n):
        current = self.head
        while current:
            new_list.append(current.data)
            current = current.next
    return new_list

该方法的实现逻辑如下:

  • 首先,检查乘数 $n$ 是否为整数。如果不是,返回 NotImplemented
  • 其次,如果 $n$ 小于或等于 0,返回一个新的空链表,因为任何东西乘以 0 或负数在逻辑上应该为空或不存在。
  • 创建一个新的 LinkedList 实例 new_list
  • 使用两层循环来实现重复:外层循环控制重复次数,内层循环遍历当前链表的所有元素,并将它们添加到新链表中。
  • 最后,返回新的重复后的链表。

根据如上定义,我们可以使用乘法操作符 * 来重复链表,比如:

# 使用 __mul__ 方法重复链表内容
# 假设我们想将整个链表内容重复3次
repeated_list = heroes_list * 3

# 显示重复后的链表
print("Repeated LinkedList (3 times):")
repeated_list.display()

这将输出下入下信息:

Repeated LinkedList (3 times):
妲己 安琪拉 王昭君 妲己 安琪拉 王昭君 妲己 安琪拉 王昭君

其实,按照如上定义,整数的在表达式的位置比较重要,只能在旧的链表的右边(如 heroes_list * 3)。当我们将整数放在左边时(3 * heroes_list),会出现如下错误:

Traceback (most recent call last):
  File "d:\git\web\hakuna\content\posts\2024-04-16-oop-data-structure-algorithms\code\linkedlist.py.py", line 117, in <module>
    main()
  File "d:\git\web\hakuna\content\posts\2024-04-16-oop-data-structure-algorithms\code\linkedlist.py.py", line 112, in main
    tripled_list_reversed = 3 * heroes_list
                            ~~^~~~~~~~~~~~~
TypeError: unsupported operand type(s) for *: 'int' and 'LinkedList'

要使 LinkedList 类的实例支持将整数放在乘法表达式的左侧(例如 3 * heroes_list),我们需要实现 Python 的反向特殊方法 __rmul__()。这个方法在正常的方法(如 __mul__)无法找到合适的实现或者操作数的顺序反向时被调用。示例如下:

def __rmul__(self, n):
    return self.__mul__(n)  # 可以直接调用 __mul__ 来实现相同的功能

当乘法操作的顺序颠倒时,如 3 * heroes_list,该方法将被自动调用。在这个实现中,__rmul__ 方法简单地调用 __mul__ 方法,因为乘法是交换的,而我们的 __mul__ 方法已经足够通用,可以处理乘法的逻辑。

定义 __iadd__ (In-place 加法)方法通常意味着要在原有对象的基础上修改而非创建一个新的对象。这与 __add__ 方法的典型实现有所不同,后者常常返回一个全新的对象。

def __iadd__(self, other):
    if not isinstance(other, LinkedList):
        return NotImplemented
    current = self.head
    if not current:  
        self.head = other.head
    else:
        last = current
        while last.next:  
            last = last.next
        last.next = other.head  
    return self

与前面的方法类似,该方法首先检查 other 是否也是一个 LinkedList 实例。如果 other 不是 LinkedList 类型,方法返回 NotImplemented

然后,将当前链表的头节点赋值给变量 current,并判断当前链表是否为空。如果当前链表为空,则直接将 self.head 指向 other 链表的头节点,将 other 链表直接接入到当前链表的头部。如果当前链表不为空。则初始化 last 变量以遍历链表找到最后一个节点。将找到的最后一个节点的 next 指向 other 链表的头节点,从而将 other 链表接在当前链表的末尾。

最后,方法返回 self,即经过修改后的原链表对象。

__imul__() 方法的实现用于链表的就地乘法操作,允许通过 *= 运算符将一个 LinkedList 实例的内容重复指定次数并更新原始链表。这也意味着,这种操作直接修改调用它的对象(即 self),而不是创建一个新的链表对象。

def __imul__(self, n):
    if not isinstance(n, int):
        return NotImplemented
    original_data = list(self)  # # Convert the linked list to a list, which relies on the __iter__ method for iteration

    if n <= 0:
        self.head = None
    else:
        self.head = None
        for _ in range(n):
            for item in original_data:
                self.append(item)
    return self

该方法的实现逻辑如下:

  • 首先,检查乘数 $n$ 是否为整数。如果不是,返回 NotImplemented
  • 其次,将链表转换为列表,以便存储原始链表中的所有元素。这一转换依赖于 __iter__() 方法的实现,该方法允许通过迭代器来遍历链表的每个节点,并获取它们的数据。我们将在下面介绍。
  • 在此基础上,检查整数 $n$ 是否小于或者等于0。如果 $n$ 小于或等于0,意味着链表应被清空(因为重复0次或负数次没有逻辑意义)。此时将 self.head 设置为 None,这样做将清除链表中的所有元素。 如果 $n$ 是正整数,那么首先清空原链表。这是重建链表之前的准备步骤,确保链表从空状态开始。然后使用两层循环,外层循环控制重复次数,即将整个链表内容重复 $n$ 次,内层循环遍历之前保存的原始数据列表。每个元素通过 self.append(item) 方法被重新添加到链表中。这样,原始链表的每个元素都会按原顺序被添加 $n$ 次。
  • 最后,方法返回经过就地修改后的链表实例(self)。

以下示例展示了该方法的基本使用方式:

# 使用 __imul__ 方法就地重复链表内容
heroes_list *= 3
print("\nHeroes List after using __imul__ (tripled in-place):")
heroes_list.display()
print("\nNumber of heroes after __imul__:", len(heroes_list))

运行后将显示:

Heroes List after using __imul__ (tripled in-place):
妲己 安琪拉 王昭君 妲己 安琪拉 王昭君 妲己 安琪拉 王昭君

Number of heroes after __imul__: 9

在实现 __imul__() 方法时,我们指出,该算法依赖于迭代器来将链表转换为列表,以便存储原始链表中的所有元素。那么什么是迭代器,甚至什么是迭代?

在编程中,迭代(Iteration,又大致可以称为遍历)指的是按照一定的顺序反复访问数据集中的每个元素的过程。迭代可以应用于各种数据结构,如列表、数组、树、图等。在 Python 中,迭代通常是通过使用循环结构(如 for 循环或 while 循环)来实现的,让程序可以执行重复的处理,直到满足某个条件为止。

比如说,我们可以借助于 for 循环输出一个列表中的所有元素:

for number in [1, 2, 3, 4, 5]:
    print(number)

这一过程就可以称为迭代。由此,也衍生出来了迭代器(Iterator)以及可迭代对象(Iterable)等概念。迭代器是实现了迭代器协议(Iterator Protocol)的对象,该协议主要包括 __iter__()__next__() 两个特殊方法。对于迭代器来说,这两个方法均为必须实现。__iter__() 返回迭代器自身,而 __next__() 返回集合中的下一个元素。当元素耗尽时,__next__() 必须抛出一个 StopIteration 异常,标志迭代的结束。在 Python 中,任何实现了 __iter__() 方法的对象或内置的序列对象(如列表和元组)都是可迭代的。当这些对象用于 forin 语句时,Python 会自动调用它们的 __iter__() 方法以获取迭代器。

值得注意的是,所有的迭代器都是可迭代对象,但并不是所有的可迭代对象均是迭代器。例如,列表是一个可迭代对象,因为它实现了 __iter__() 方法,但列表本身不包含 __next__() 方法,因此不是迭代器。从概念定义上讲,迭代主要是提供遍历数据的框架,而迭代器则提供在这个框架内操作的具体方法和机制。

为了在 LinkedList 中实现迭代器的功能,我们可以先定义一个单独的 LinkedListIterator 类。

class LinkedListIterator:
    def __init__(self, head):
        self.current = head

    def __iter__(self):
        return self

    def __next__(self):
        if not self.current:
            raise StopIteration
        data = self.current.data
        self.current = self.current.next
        return data

在这个新类中,__init__() 构造函数接收链表的头节点并初始化迭代器的当前节点 (self.current)。__iter__() 方法则返回迭代器对象本身,符合迭代器协议。__next__() 方法检查当前节点。如果当前节点为 None,则抛出 StopIteration 异常,标志迭代结束;否则,获取当前节点的数据,并将迭代器移向下一个节点,最后返回当前节点的数据。

在 Python 中,其实还可以使用生成器来实现这一功能。生成器自动处理 __iter__()__next__() 方法的实现:

def __iter__(self):
    current = self.head
    while current:
        yield current.data
        current = current.next

LinkedList 类中,方法 __iter__() 起着生成器的作用,它将遍历链表中的每个元素。其中,current 初始化为链表的头节点 (self.head)。只要 current 不为 None,就继续迭代。yield current.data 表达式用于一次返回链表中的一个元素,并在下次迭代时从停下的地方继续。current = current.next 将迭代器移动到链表的下一个节点。

由此,我们可以在 LinkedList 类中构建一个 __iter__() 方法,该方法创建并返回 LinkedListIterator 对象:

def __iter__(self):
    return LinkedListIterator(self.head)

这样我们就可以使用 for 循环迭代 LinkedList:

# 使用 for 循环迭代 LinkedList
for data in more_heroes_list:
    print(data, end=" ")     # 输出:露娜 娜可露露 貂蝉

__contains__() 方法的实现可以为 LinkedList 类提供了能力来检查某个元素是否存在于链表中(使用 in 关键字)。对于简单的数据结构,__contains__() 方法可以通过遍历来实现,对每个节点的数据进行比较。

def __contains__(self, item):
    current = self.head
    while current:
        if current.data == item:
            return True
        current = current.next
    return False
# 使用 __contains__ 方法实现检查某个元素是否在LinkedList对象中
print("安琪拉" in heroes_list)      # 输出:True
print("安琪拉" in more_heroes_list) # 输出:False

LinkedList 类中实现 __getitem__()__setitem__()__delitem__() 特殊方法可以使其支持支持通过索引进行获取、设置和删除元素的操作,类似于标准的序列类型如列表。以下是这三种方法实现示例:

# 实现 __getitem__
def __getitem__(self, index):
    if index < 0:
        raise IndexError("Negative indices are not supported")
    current = self.head
    for _ in range(index):
        if current is None:
            raise IndexError("Index out of bounds")
        current = current.next
    if current is None:
        raise IndexError("Index out of bounds")
    return current.data

这里,我们首先检查传入的索引是否为负数。考虑到 LinkedList 的特征,该链表并不支持负索引(与Python列表等不同),因此如果索引是负数,则抛出 IndexError 表示索引无效。然后,我们从链表的头节点开始,沿着链表向后遍历。使用一个循环来跳过直到达到指定的索引位置的元素。每次循环中,更新当前节点指向下一个节点。在遍历过程中,如果当前节点变为 None,说明已经超出了链表的尾部,这时也应抛出 IndexError 表示索引超出了链表的有效范围。一旦到达正确的位置,返回当前节点的数据。这里通过访问节点的 data 属性来实现的。

# 实现 __setitem__
def __setitem__(self, index, value):
    if index < 0:
        raise IndexError("Negative indices are not supported")
    current = self.head
    for _ in range(index):
        if current is None:
            raise IndexError("Index out of bounds")
        current = current.next
    if current is None:
        raise IndexError("Index out of bounds")
    current.data = value

这里,我们仍然先检查传入的索引是否为负数。由于该链表没有记录反向索引,因此,负数索引在这里是不被接受的,如果发现索引为负,则抛出 IndexError。接着,从链表的头节点开始,顺序遍历链表节点直到达到指定的索引位置。这一过程中,通过循环逐个访问链表的节点,每次循环将当前节点向前移动到下一个节点。如果在达到所需索引前当前节点已经为 None(即已经到达链表尾部而没有足够的元素),则表明索引超出了链表当前的长度范围,这时也应抛出 IndexError。一旦到达指定索引的节点,将该节点的数据设置为新的值。这通过直接赋值给节点的 data 属性来实现。注意该方法的实现与链表插入功能的区别。

# 实现 __delitem__
def __delitem__(self, index):
    if index < 0:
        raise IndexError("Negative indices are not supported")
    if self.head is None:
        raise IndexError("Index out of bounds")
    if index == 0:
        self.head = self.head.next
        return
    current = self.head
    previous = None
    for _ in range(index):
        previous = current
        if current is None or current.next is None:
            raise IndexError("Index out of bounds")
        current = current.next
    if current is None:
        raise IndexError("Index out of bounds")
    previous.next = current.next

__delitem__ 方法仍然首先检查提供的索引是否为负数,如果索引为负,则抛出 IndexError,表明索引无效。同时检查需要删除的是否为链表的第一个元素(即索引为0),此时,可以直接将头节点 (self.head) 指向下一个节点 (self.head.next),从而将当前头节点从链表中移除。如果不是删除第一个节点,则从头节点开始,遍历链表以找到指定索引的节点。同时保持对当前节点的前一个节点的引用(previous),这是为了在删除当前节点时能够重新连接链表。在遍历过程中,如果到达链表尾部(当前节点为 None)而还没有到达指定的索引,表示索引超出了链表的当前长度,这时应抛出 IndexError。一旦找到指定索引的节点,通过设置前一个节点的 next 指针绕过当前节点(即 previous.next = current.next),从而实现删除操作。由于删除操作不需要返回特定的值,方法完成后返回 None

使用示例:

# 使用 __getitem__ 访问元素
print(more_heroes_list[0])

# 使用 __setitem__ 修改元素
more_heroes_list[2] = '孙悟空'
print("\nHeros List after using __setitem__: ")
more_heroes_list.display()

# 使用 __delitem__ 删除元素
del more_heroes_list[2]
print("\nHeros List after using __delitem__: ")
more_heroes_list.display() 
露娜

Heros List after using __setitem__:
露娜 娜可露露 孙悟空

Heros List after using __delitem__:
露娜 娜可露露

至于 LinkedList 链表的比较操作,请参考 linkedlist.py

classDiagram direction TB class Collection { __getitem__() __contains__() __iter__() } class Iterable { __iter__()* } class Sized { __len__()* } class Container { __contains__()* } class Reversible { __reversed__()* } class Sequence { __getitem__()* __contains__() __iter__() __reversed__() index() count() } class Mapping { __getitem__()* __contains__() __eq__() __ne__() get() items() keys() values() } class Set { isdisjoint() __le__() __lt__() __gt__() __ge__() __eq__() __ne__() __and__() __or__() __sub__() __xor__() } Collection <|-- Iterable Collection <|-- Sized Collection <|-- Container Collection <|-- Reversible Collection <|-- Sequence Collection <|-- Mapping Collection <|-- Set

该图展示了 Python 中的基本集合类型(Collections)接口,包括 Iterable(可迭代)、Sized(有大小)、Container(容器)、Reversible(可反转)、Sequence(序列)、Mapping(映射)和 Set(集合)。这些接口定义了集合类型的行为。

图中的方法名用斜体表示时,它们代表的是抽象方法。抽象方法指的是那些必须由具体的子类(例如 Python 中的 listdict)来实现的功能。例如,__getitem____contains__ 是抽象方法,具体的 listdict 类必须提供这些方法的具体实现。

图中未使用斜体表示的方法已经有了默认实现。子类继承这些接口时,可以直接使用这些已实现的方法,而不需要重新实现它们。换句话说,子类只需要实现那些抽象方法,其他已有实现的方法会自动继承并可以直接使用。

比如:当你定义一个新的类继承自 SequenceMapping 接口时,必须实现图中斜体表示的方法,例如 __getitem____contains__。而那些没有使用斜体表示的方法(例如 count()items()),则已经有了具体实现,你可以直接使用它们。