大家好,欢迎来到《基于 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 解释器执行以下步骤:
- 内存分配:首先,解释器为新对象分配内存。这个新对象是类的一个实例,拥有类定义中声明的所有属性和方法。
- 调用
__init__
:一旦内存分配完成,Python 自动调用类的__init__
方法。此时,self
参数指向新创建的内存地址(即新对象),而其他参数则是我们传递给类构造器的值,如name
和health
值。在这分别是"Arthur"
和200
。 - 初始化对象:
__init__
方法内的代码负责设置对象的初始状态。这通常包括给实例变量赋值和执行必要的初始化操作,这里是通过在__init__
内部设置self.name
和self.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__
方法的设计,应该遵循如下标准:
- 精确性:
__repr__
应提供所有关键信息,以充分描述对象的当前状态。这包括所有重要的属性值和可能影响对象行为的内部状态。 - 明确性:输出应清晰到足够让其他开发者(或未来的你)理解对象的构造和行为,无需查看其他文档或源代码。
- 可执行性:在许多情况下,
__repr__
的输出格式是一个有效的 Python 表达式,即复制粘贴这个表达式到 Python 解释器或代码中,应能重新创建出相同的对象。例如,eval(repr(obj))
应该能够创建一个与obj
状态相同的新对象。
在设计类时,通常建议至少实现 __repr__
,因为它的目的是无歧义的重现对象;如果可能的话,也实现 __str__
提供更友好的用户界面。在实际开发中,这样的实践可以大大增强代码的可维护性和调试效率,同时也提升了用户交互的友好性。
属性访问
在面向对象编程中,当咱们定义一个类时,通常会包含实例属性和类属性,如 Character
类中的 name
,experience
,level
和 level_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
类的内部逻辑(如角色死亡或者升级)依赖于特定的属性状态(health
和 experience
的值)。如果允许直接访问 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 模式,我们需要:
- 将属性设置为非公开的。
- 为每个属性编写 accessor 和 mutator 方法。
仍以 Character
类为例,下面运用 accessor 和 mutator 方法来管理角色的属性。为此,我们首先需要将 Character
类的属性 name
、health
和 experience
设置为受保护的属性,即在变量名称前加上单下划线(_
),这样表示这些属性不应该被直接从类的外部访问和修改。
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)
得到的结果中观察到。在这个帮助文档中,我们可以看到 name
,health
以及 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
描述符来管理 health
和 experience
属性:
class Character:
health = NonNegative()
experience = NonNegative()
def __init__(self, name, health, experience):
self.name = name
self.health = health
self.experience = experience
现在,任何尝试将 health
或 experience
设置为负数的操作都会触发异常:
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
类的 health
和 experience
属性不会被设置为负数。这在功能上与使用 @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__
方法来达到确保 health
和 experience
属性不会被设置为负值的目的:
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__()
方法的对象或内置的序列对象(如列表和元组)都是可迭代的。当这些对象用于 for
和 in
语句时,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
。
该图展示了 Python 中的基本集合类型(Collections)接口,包括 Iterable(可迭代)、Sized(有大小)、Container(容器)、Reversible(可反转)、Sequence(序列)、Mapping(映射)和 Set(集合)。这些接口定义了集合类型的行为。
图中的方法名用斜体表示时,它们代表的是抽象方法。抽象方法指的是那些必须由具体的子类(例如 Python 中的 list
或 dict
)来实现的功能。例如,__getitem__
和 __contains__
是抽象方法,具体的 list
或 dict
类必须提供这些方法的具体实现。
图中未使用斜体表示的方法已经有了默认实现。子类继承这些接口时,可以直接使用这些已实现的方法,而不需要重新实现它们。换句话说,子类只需要实现那些抽象方法,其他已有实现的方法会自动继承并可以直接使用。
比如:当你定义一个新的类继承自 Sequence
或 Mapping
接口时,必须实现图中斜体表示的方法,例如 __getitem__
或 __contains__
。而那些没有使用斜体表示的方法(例如 count()
或 items()
),则已经有了具体实现,你可以直接使用它们。