不知不觉就来到了第三部分 类和协议
之前的两部分分别是数据结构
和函数即对象
1、介绍
对于一个库或框架而言,符合Python风格意味着让Python程序员能够尽可能轻松自然地掌握如何完成一项任务。
—— 马丁·法森(Martijn Faassen),Python和JavaScript框架的创造者。
得益于Python数据模型,你自定义的类型可以表现得和内置类型一样自然。并且,这可以在不使用继承的情况下,按照鸭子类型的理念实现:你只需实现让对象按预期行为表现所需的方法即可。
在前面的章节中,我们研究了许多内置对象的行为。现在,**我们将构建表现得像真正Python对象的用户自定义类。**你的应用程序类可能不需要也不应该像本章中的示例那样实现那么多特殊方法。但是,如果你正在编写一个库或框架,使用你所编写类的程序员可能期望它们的行为与Python提供的类相似。满足这种期望是 “符合Python风格” 的一种方式。
本章从第1章结束的地方开始,展示如何实现几种在许多不同类型的Python对象中常见的特殊方法。
在本章中,我们将学习:
- 支持将对象转换为其他类型的内置函数(例如,
repr()
、bytes()
、complex()
等)。 - 将替代构造函数实现为类方法。
- 扩展f字符串、内置的
format()
函数和str.format()
方法使用的格式化迷你语言。 - 提供对属性的只读访问。 - 使对象可哈希,以便在集合中使用或作为字典键。
- 使用
__slots__
节省内存。
我们将在开发Vector2d
(一种简单的二维欧几里得向量类型)的过程中完成上述所有内容。这段代码将作为第12章中N维向量类的基础。 在示例的演进过程中,我们将暂停一下,讨论两个概念性话题:
-
如何以及何时使用
@classmethod
和@staticmethod
装饰器。 -
Python中的私有和受保护属性:用法、约定和限制。
2、Object Representations
一、Python 对象表示的两种标准方法
repr()
的作用和用法
-
repr()
的用途:
repr()
的结果通常是面向开发者的,旨在提供明确且尽可能准确的对象表示,通常用于调试或日志记录。如果可能,它返回的字符串应该是合法的 Python 表达式,可以用来重新创建该对象。 -
实现方式:
内部对应特殊方法__repr__()
。 -
例子:
class MyClass:
def __repr__(self):
return "MyClass()"
obj = MyClass()
print(repr(obj)) # 输出: MyClass(),即调用了 obj.__repr__()
obj # 在交互式解释器中也会输出: MyClass()
str()
的作用和用法
-
str()
的用途:
str()
的结果更直观,它是面向终端用户的字符串表示。一些对象可能有多个内部信息,但在str()
表示中仅需要显示对用户有意义的部分。 -
实现方式:
内部对应特殊方法__str__()
。 -
例子:
class MyClass:
def __str__(self):
return "This is MyClass object"
obj = MyClass()
print(str(obj)) # 输出: This is MyClass object
print(obj) # 输出: This is MyClass object,因为 print() 底层调用 obj.__str__()
对比 repr()
和 str()
的行为
通常 repr()
和 str()
的行为是不同的:
repr()
给开发者看,显示尽可能多的具体细节。str()
给用户看,显示简单、清晰和友好的人类可读信息。
然而当只实现了其中一个方法时,Python 会尝试用 repr()
或其默认实现来代替 str()
:
class MyClass:
def __repr__(self):
return "MyClass()"
obj = MyClass()
print(repr(obj)) # 输出: MyClass()
print(str(obj)) # 输出: MyClass(),因为未实现 __str__(),它退回到使用 __repr__()
二、扩展表示方法:bytes()
和 format()
bytes()
方法
-
用途:
当需要把对象表示为字节序列(类型为bytes
)时使用,比如需要将数据以二进制格式存储或者传输。内部对应特殊方法__bytes__()
。 -
注意事项:
bytes()
的实现必须返回一个bytes
对象。- 应用于表示底层的字节信息,比如网络数据的通信格式、文件存储等。
例子:
class MyClass:
def __bytes__(self):
return b"MyClassBytes"
obj = MyClass()
print(bytes(obj)) # 输出: b'MyClassBytes',调用了 obj.__bytes__()
format()
方法
-
用途:
format()
方法允许对字符串表示进行高度灵活的自定义格式化。它通过特殊方法__format__(self, format_spec)
实现。 -
适用场景:
- 用于
f-string
(格式化字符串字面值)。 - 用于内置的
format()
函数。 - 用于
str.format()
方法。
- 用于
-
注意事项:
__format__()
必须返回一个字符串结果。- 它接收一个参数
format_spec
,用来指定格式,例如日期格式、数字格式等。
例子:
class Coordinate:
def __init__(self, x, y):
self.x = x
self.y = y
def __format__(self, format_spec):
if format_spec == "comma":
return f"{self.x},{self.y}"
elif format_spec == "bracket":
return f"({self.x}, {self.y})"
return str(self) # 默认返回值
point = Coordinate(3, 4)
print(format(point, "comma")) # 输出: 3,4
print(format(point, "bracket")) # 输出: (3, 4)
三、Python 3 和 Python 2 的差异点
在 Python 2 和 Python 3 的表示方法中,需要注意以下显著区别:
- 在 Python 3 中:
__repr__()
和__str__()
必须返回 Unicode 字符串(类型为str
)。- 只有
__bytes__()
是专门用来返回字节序列的(类型为bytes
)。
- 在 Python 2 中:
- 常常返回字节字符串(类型为
str
,与 Python 3 不同)。 - 比如
repr()
和str()
的实现都可以返回字节字符串。
- 常常返回字节字符串(类型为
四、扩展应用与常见错误
案例 1:日志记录和调试信息
在实际项目中,我们经常需要用 repr()
提供完整的信息,并用 str()
生成友好的字符串给用户:
class Transaction:
def __init__(self, id, amount):
self.id = id
self.amount = amount
def __repr__(self): # 用于调试
return f"Transaction(id={self.id}, amount={self.amount})"
def __str__(self): # 用于展示
return f"Transaction #{self.id}: ${self.amount}"
t = Transaction(42, 100.5)
print(repr(t)) # 调试输出: Transaction(id=42, amount=100.5)
print(str(t)) # 用户展示: Transaction #42: $100.5
案例 2:自定义数据格式
通过 format()
,我们可以直接转换对象为特定格式,比如处理日期、数字等:
from datetime import date
class CustomDate:
def __init__(self, year, month, day):
self.date = date(year, month, day)
def __format__(self, format_spec):
if format_spec == "short":
return self.date.strftime("%Y-%m-%d")
elif format_spec == "long":
return self.date.strftime("%A, %B %d, %Y")
return str(self.date)
d = CustomDate(2023, 10, 15)
print(f"Short format: {d:short}") # 输出: Short format: 2023-10-15
print(f"Long format: {d:long}") # 输出: Long format: Sunday, October 15, 2023
{d:short}
分析
-
d
:这部分表示一个变量或对象。在你的例子中,d
是一个CustomDate
对象。 -
:
:冒号用于分隔变量名和格式说明符。在 f-string 中,冒号后面的内容指定了如何格式化变量的值。 -
short
:这个是格式说明符,传递给对象的__format__
方法。在你的CustomDate
类中,__format__
方法根据传入的格式说明符(short
或long
)来返回不同的日期字符串格式。
工作原理
当 Python 解释器遇到 f-string 中的 {d:short}
时:
- 它识别出
d
是一个对象,并且short
是格式说明符。 - 它调用
d
对象的__format__
方法,并将short
作为参数传递给该方法。 __format__
方法根据指定的格式返回相应的字符串。- 返回的字符串替换掉 f-string 中的
{d:short}
部分。
五、总结
方法 | 实现方法 | 用途 | 返回类型 |
---|---|---|---|
repr() | __repr__() | 面向开发者的调试表示 | 字符串 |
str() | __str__() | 面向用户的友好表示 | 字符串 |
bytes() | __bytes__() | 字节序列表示 | 字节序列 |
format() | __format__() | 定制化格式化输出 | 字符串 |
3、Vector Class Redux
一、类的基本功能展示
示例 1:使用 Vector2d
类
>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y)
3.0 4.0
>>> x, y = v1
>>> x, y
(3.0, 4.0)
>>> v1
Vector2d(3.0, 4.0)
>>> v1_clone = eval(repr(v1))
>>> v1 == v1_clone
True
>>> print(v1)
(3.0, 4.0)
>>> octets = bytes(v1)
>>> octets
b'd\x00\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x00\x00\x10@'
>>> abs(v1)
5.0
>>> bool(v1), bool(Vector2d(0, 0))
(True, False)
通过上述示例,可以看出 Vector2d
实例具备以下特点:
- 直接属性访问:可以通过
v1.x
和v1.y
分别访问横纵坐标。 - 解包操作:实例支持解包操作
x, y = v1
。 - 官方表示形式(
repr
):可以通过repr(v1)
的结果生成相同的对象。 - 比较操作:两个值相等的向量可以直接比较,例如通过
==
判定。 - 字符串化(
str
):print(v1)
产生的是友好的(x, y)
对偶形式。 - 二进制表示:可以通过
bytes(v1)
获取其字节表示,便于序列化或传输。 - 大小/magnitude:可通过
abs(v1)
返回向量的模(即长度)。 - 布尔值转换:非零向量为
True
,零向量为False
。
二、Vector2d
类的详细实现
以下是实现代码与逐步讲解。
from array import array
import math
class Vector2d:
typecode = 'd' # 用于数组、字节序列转换的类型码,表明是双精度浮点数
def __init__(self, x, y):
self.x = float(x)
self.y = float(y)
def __iter__(self):
# 使实例可迭代,从而支持解包
return (i for i in (self.x, self.y))
def __repr__(self):
# 返回实例的官方字符串表示
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, self.x, self.y)
def __str__(self):
# 返回实例用户友好的字符串表示
return str(tuple(self))
def __bytes__(self):
# 返回对象的字节表示
return (bytes([ord(self.typecode)]) +
bytes(array(self.typecode, self)))
def __eq__(self, other):
# 比较两个向量是否相等(逐元素比较)
return tuple(self) == tuple(other)
def __abs__(self):
# 返回向量的模(使用数学公式计算)
return math.hypot(self.x, self.y)
def __bool__(self):
# 模为0的向量表示False,否则为True
return bool(abs(self))
三、重点讲解与易错点剖析
1. __iter__
方法:实现可迭代性
- 实现了
__iter__
方法后,Vector2d
对象就可以被迭代,例如允许解包x, y = v1
。 - 实现方式:使用生成器表达式。
🔍 例子:
v = Vector2d(3, 4)
x, y = v # 利用 __iter__
print(x, y) # 输出:3.0 4.0
易错点:
不实现 __iter__
时,无法直接解包实例。如果需要更多定制行为,可以返回更复杂的生成器。
2. __repr__
和 __str__
的区别
__repr__
:提供“官方”表示,供开发者使用,通常能生成可以重新创建该对象的表达式。__str__
:提供“用户友好”表示,供用户(通过print
或str
调用)使用,看起来更直观。
🔍 对比例子:
v = Vector2d(3, 4)
print(repr(v)) # Vector2d(3.0, 4.0) —— 开发者用
print(str(v)) # (3.0, 4.0) —— 用户用
工程应用:
__repr__
实现的代码传递给 eval
,可以重新创建相同的对象。
v = Vector2d(3, 4)
v_clone = eval(repr(v))
assert v_clone == v # repr 可用作 self-documenting 格式
3. __bytes__
方法:为对象提供序列化能力
- 将向量对象转换为字节流,非常适合在网络传输或保存到文件时使用。
🔍 例子:
v = Vector2d(3, 4)
octets = bytes(v) # b'd...\x08@\x00...\x10@'
print(octets)
- 实现细节:
typecode
确保数据的统一性。array
模块使得字节流转换更高效。
扩展说明:
序列化后的数据可以通过额外方法(未实现于此)的解码功能转化为新的实例。
4. __eq__
方法:定制比较操作
这个方法允许我们直接比较两个向量是否相等。但目前实现可能导致如下问题:
- 如果比较对象是一个普通的序列(如
[3, 4]
),也会返回True
。
🔍 问题例子:
v = Vector2d(3, 4)
print(v == [3, 4]) # 输出:True —— 可能不是期望的行为
改进建议:
可以通过增加类型检查限制比较逻辑:
def __eq__(self, other):
if isinstance(other, Vector2d):
return tuple(self) == tuple(other)
return NotImplemented
5. __abs__
和 __bool__
的联合使用
__abs__
:计算向量的模(sqrt(x**2 + y**2)
)。__bool__
:基于模与 0 的比较,决定向量是否为真值。
🔍 示例:
v1 = Vector2d(3, 4)
v2 = Vector2d(0, 0)
print(abs(v1)) # 5.0
print(bool(v1)) # True
print(bool(v2)) # False
4、An Alternative Constructor 与 classmethod Versus staticmethod
一、替代构造器(Alternative Constructor)
class MyClass:
def __init__(self, x, y):
self.x = x
self.y = y
obj = MyClass(10, 20) # 通过构造器以参数 x=10, y=20 创建对象
在 Python 的面向对象编程中,构造方法(__init__
)以初始化对象为核心职责。然而,有时需要设计“替代构造方法”,从不同的数据格式(如二进制序列)中初始化对象。
默认的构造器(__init__
方法)可以直接用来创建实例,但在实际工程中,我们可能需要帮助程序根据不同的输入格式生成同一个类的实例,例如从字符串、JSON、字节序列或数据库中创建对象,这时候默认构造器显得力不从心。
替代构造器(Alternative Constructor) 是一种通过额外的方法为类定义多种实例化方式的技术。它并不会直接取代默认构造器,而是为特定场景提供一种新的构造途径。
为什么使用 classmethod
定义替代构造器?
classmethod
的核心特点是方法的第一个参数是类本身(cls
),而不是实例(self
)。这使得它可以直接通过类名调用,同时还能访问类中的属性、方法或直接生成类的实例。对替代构造器来说,这点至关重要,因为它允许我们通过类实现自定义的实例化逻辑,构造符合需求的对象。
使用 classmethod
作为替代构造器的优势:
-
独立于实例:
替代构造器通过类本身来定义和调用,而不是通过某个特定实例。这非常适合需要从外部数据初始化对象的情况。 -
灵活性:
替代构造器可以接受多种输入格式(如字符串、字节序列、字典等),从而让实例化逻辑适应多元化的数据来源。 -
扩展性:
通过cls
作为第一个参数,替代构造器可以支持继承——子类调用时会传递子类作为cls
,无需手动改写实例化逻辑。 -
更清晰的语义:
替代构造器通常采用直观的命名(如frombytes
、fromjson
等),明确表明此方法的实例化逻辑,与默认构造器在功能上区分开。
示例:为什么说它允许我们按照不同数据格式创建对象实例?
示例 1:从字节序列创建对象
frombytes
是受标准库中 array.array.frombytes
启发的一个替代构造方法,它允许从二进制数据创建自定义类的实例。
class Vector2d:
def __init__(self, x, y):
self.x = x
self.y = y
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0]) # 取出第一个字节表示的数据类型
memv = memoryview(octets[1:]).cast(typecode) # 解读字节序列,并按照 typecode 转换
return cls(*memv) # 调用构造器,解包 memoryview 生成实例
# 示例用法
bytes_data = b'f\x00\x00\x80?\x00\x00\x00@' # 二进制数据,表示两个浮点数:1.0 和 2.0
v = Vector2d.frombytes(bytes_data)
print(v.x, v.y) # 输出:1.0 2.0
在上述例子中,我们从已有的 bytes_data
(二进制格式)中重建了一个 Vector2d
对象。这是一种典型的替代构造器用例,因为:
- 默认构造器(
__init__
)需要传入具体的数值(如Vector2d(1.0, 2.0)
)。 - 替代构造器
frombytes
则允许从二进制格式直接构建实例。
示例 2:从字符串创建对象
假设我们有一个表示二维向量的字符串形式(如 "1.0,2.0"
),可以通过替代构造器解析字符串并生成对象。
class Vector2d:
def __init__(self, x, y):
self.x = x
self.y = y
@classmethod
def fromstring(cls, data_str):
x, y = map(float, data_str.split(',')) # 将字符串解析为两部分,并转为浮点数
return cls(x, y)
# 示例用法
string_data = "1.0,2.0"
v = Vector2d.fromstring(string_data)
print(v.x, v.y) # 输出:1.0 2.0
这里的替代构造方法明确帮助我们从字符串创建了向量对象,而不需要调用者自己先解析字符串再传入构造函数。
示例 3:从 JSON 创建对象
如果数据来源是 JSON 格式的字符串,我们可以提供一个 fromjson
方法。
import json
class Vector2d:
def __init__(self, x, y):
self.x = x
self.y = y
@classmethod
def fromjson(cls, json_str):
data = json.loads(json_str) # 使用 json 库解析 JSON 字符串为字典
return cls(data['x'], data['y']) # 从字典中提取 x、y 值创建实例
# 示例用法
json_data = '{"x": 3.0, "y": 4.0}'
v = Vector2d.fromjson(json_data)
print(v.x, v.y) # 输出:3.0 4.0
替代构造器的关键点与总结
-
与默认构造器的关系:
- 默认构造器主要用于程序内部直接使用类初始化对象时。
- 替代构造器适用于接收外部数据的多样性需求。例如处理字节序列、字符串、JSON 或数据库结果时,定义特定的构造逻辑。
-
为什么使用
classmethod
:- 替代构造器需要通过类(
cls
)操作,而不是具体的实例。 cls
允许在构造器中动态地选择实例化哪一个类,简化子类继承的逻辑。
- 替代构造器需要通过类(
-
实际工程中的案例:
替代构造器是一种工厂模式的实现,能通过不同方法创建同类对象。例如:- 数据库交互:从数据库记录中生成对象。
- 文件操作:从文件数据(如 CSV、JSON)中初始化对象。
- 网络请求:从 API 响应获取数据并创建对象。
如果你知道替代构造器的优点和用途,那么当需求多样化时,就可以灵活地让一个类拥有多种初始化方式,这是一种提升代码适应性和可读性的最佳实践。
分析范例:实现 frombytes
类方法
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0]) # 从字节序列的第一个字节读取 typecode
memv = memoryview(octets[1:]).cast(typecode) # 创建 memoryview,并按 typecode 转换
return cls(*memv) # 解包 memoryview 中的值,调用构造器
详解:
-
@classmethod
修饰器:- 将方法定义为类方法,而非实例方法。
- 类方法不依赖具体的实例,可以直接通过类名调用。
- 第一个参数
cls
代表调用该方法的类(通常命名为cls
,类似实例方法的self
)。
-
关键步骤:
- 读取
typecode
:typecode
是首字节的字符,决定了字节序列存储的数据类型(例如:f
表示浮点数)。 - 创建
memoryview
:memoryview
为在不复制内容的前提下操作二进制数据提供了高效方式。它支持.cast(type)
方法将底层字节转换为指定的数据类型。 - 调用构造器:
cls(*memv)
等价于调用Vector2d(x, y)
,这里通过解包传入构造函数的参数。
- 读取
理解 memoryview
的补充例子:
# 使用 memoryview 操作二进制数据
data = bytes([1, 0, 0, 0, 2, 0, 0, 0]) # 二进制字节数据,表示两个整数
typecode = 'i' # 设置整数类型的 typecode
# 利用 memoryview 解读二进制数据
memv = memoryview(data).cast(typecode)
print(list(memv)) # 输出 [1, 2],原始二进制数据已转换为整数序列
注:memoryview
让我们可以高效处理大型数据块,而无需复制。
理解字节数据和如何将其转换为数字
字节数据(bytes
)是一系列原始字节的集合,存储的是数据的编码形式。数字(整数、浮点数等)在计算机中会以固定的格式存储成字节形式,因此通过特定的规则解释这些字节就能还原为其对应的数字。因此关键在于两点:
-
字节的存储结构:
- 对于整数,它们存储的是二进制表示,按固定字节数存储(小端或大端模式)。
- 对于浮点数,它们遵循 IEEE 754 浮点数标准,用特定的字节布局表示一个浮点值。
-
内存解释方法:
- 我们需要明确这些字节的数据类型(通过
typecode
指定)。 - 使用工具(如 Python 的
memoryview
和cast
方法)来按照正确的类型解析这些字节。
- 我们需要明确这些字节的数据类型(通过
下面我会按照你提供的例子,带你逐步解析字节数据,看看具体是怎么转换为数字的。
例子 1:解析整数
data = bytes([1, 0, 0, 0, 2, 0, 0, 0])
typecode = 'i' # 'i' 表示 4 字节的有符号整数
- 字节解释规则
'i'
是typecode
,它告诉我们这些字节对应的是一个 有符号整数。- 每个整数占用 4 字节(固定长度)。
- 字节存储模式是默认的小端(little-endian),即最低有效字节(LSB)在最前面。
首先,分组字节:
字节数据是 [1, 0, 0, 0, 2, 0, 0, 0]
,以 4 字节为一组:
-
[1, 0, 0, 0]
-
[2, 0, 0, 0]
-
每 4 字节如何转换为整数
小端存储定规则是:将字节顺序翻转(最低有效字节放最后),再拼接为二进制。例如:
转换第一个整数:
-
原始字节是
[1, 0, 0, 0]
。 -
按小端结构解读:将 1 放在最低字节的位置,实际表示的值为(二进制组合):
0x00000001 (十六进制) = 1 (十进制)
转换第二个整数:
-
原始字节是
[2, 0, 0, 0]
。 -
按小端结构解读:将 2 放在最低字节的位置,表示的值是:
0x00000002 (十六进制) = 2 (十进制)
最终结果是两个整数:1
和 2
。
例子 2:解析双精度浮点数
data = bytes([64, 73, 15, 219, 64, 18, 34, 251]) # 原始字节数据
typecode = 'd' # 'd' 表示 8 字节的双精度浮点数 (double)
- 字节解释规则
'd'
是typecode
,表示这些字节对应的是 IEEE 754 双精度浮点数。- 每个双精度浮点数需要 8 字节 数据(定长)。
- 字节的存储模式是默认的小端模式(最低有效字节在前)。
首先,分组字节:
字节数据是 [64, 73, 15, 219, 64, 18, 34, 251]
,刚好是 8 个字节,可以直接作为一个双精度浮点数来解析。
- 按 IEEE 754 解读浮点数字节
浮点数的存储格式(IEEE 754 标准,双精度)具体如下:
- 第 1 位(最高位):符号位 — 表示正数(0)或者负数(1)。
- 接下来的 11 位:指数(阶码) — 用偏移的指数值表示(偏移 1023)。
- 接下来的 52 位:尾数(有效数字、小数部分) — 隐含一个最高有效位为 1。
分解字节数据:
字节是 [64, 73, 15, 219, 64, 18, 34, 251]
,将其组合为二进制:
01000000 01001001 00001111 11011011 01000000 00010010 00100010 11111011
- 符号位:
0
,表示正数。 - 指数部分:
[10000000100]
(11 位),表示偏移量为 1024 的实际指数。(10000000100
= 1028,真实指数是1028 - 1023 = 5
)
- 尾数部分:
- 剩下的 52 位,表示约数小数部分,隐含最高位
1
。
- 剩下的 52 位,表示约数小数部分,隐含最高位
最终,这些字节被解释为一个特定的浮点数,值为 6.8484154813471e-233(Python 会自动完成解析)。
二、classmethod
与 staticmethod
的对比
Python 中常见的两种方法装饰器是 classmethod
和 staticmethod
,它们常被初学者混淆。在深入了解概念之前,我们先通过代码观察它们的实际行为:
代码对比:
class Demo:
@classmethod
def klassmeth(*args):
return args
@staticmethod
def statmeth(*args):
return args
# 调用示例
print(Demo.klassmeth()) # (<class '__main__.Demo'>,)
print(Demo.klassmeth('spam')) # (<class '__main__.Demo'>, 'spam')
print(Demo.statmeth()) # ()
print(Demo.statmeth('spam')) # ('spam',)
解析关键点:
-
@classmethod
:- 方法的第一个参数永远是类本身(
cls
),即便通过类调用方法(如Demo.klassmeth()
)。 - 因此该装饰器特别适合定义基于类逻辑的方法,例如 类的替代构造器。
- 方法的第一个参数永远是类本身(
-
@staticmethod
:- 方法没有任何特殊的默认参数,其行为和普通函数一样。
- 本质上,它只是被限定在类作用域内而已。
总结对比:
特性 | @classmethod | @staticmethod |
---|---|---|
第一个参数是什么? | 类对象(cls ) | 无(普通函数形式) |
是否依赖类或实例? | 是,至少依赖类本身 | 否,完全独立 |
常见用途 | 替代构造器、与类相关的逻辑 | 与类逻辑无关的工具函数 |
工程实践中的具体应用场景
场景 1:替代构造器
classmethod
常被用来提供统一的工厂方法,定义在某个类中但适用于不同输入格式。frombytes
就是此类的典型示例。
额外示例:从 JSON 字符串构造对象
import json
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
@classmethod
def from_json(cls, json_str):
data = json.loads(json_str)
return cls(data['x'], data['y'])
# 示例
json_data = '{"x": 10, "y": 20}'
point = Point.from_json(json_data)
print(point.x, point.y) # 输出 10 20
场景 2:工具方法
staticmethod
适合完全独立于类的工具方法,但依然将其放置在类的作用域下使其更有意义。例如:
class Calculator:
@staticmethod
def add(a, b):
return a + b
@staticmethod
def multiply(a, b):
return a * b
# 使用示例
print(Calculator.add(5, 3)) # 输出 8
print(Calculator.multiply(5, 3)) # 输出 15
在工程实践中,如果该功能很可能被复用,通常会放在模块级别,而不是定义为静态方法。
5、Formatted Displays
-
格式化工具
format()
函数:最基础的格式化函数,直接调用目标对象的.__format__(format_spec)
方法。f-strings
:通过在字符串中使用{}
包裹快速插值,可附加格式说明符(format_spec
)。str.format()
方法:通过{}
替换字段完成字符串插值,支持多种格式说明符参数。
-
格式说明符(
format_spec
)- 在字符串格式化中,
format_spec
表示格式规则,通过微型语言(Format Specification Mini-Language)实现。 - 示例结构:
format(my_obj, format_spec)
"{:format_spec}".format(obj)
f"{obj:format_spec}"
- 在字符串格式化中,
-
应用案例与基础示例
brl = 1 / 4.82 # BRL to USD 汇率 print(format(brl, '0.4f')) # 输出小数点后 4 位 # 输出: '0.2075' print('1 BRL = {rate:0.2f} USD'.format(rate=brl)) # 输出: '1 BRL = 0.21 USD' print(f'1 USD = {1 / brl:0.2f} BRL') # 输出: '1 USD = 4.82 BRL'
0.4f
是格式说明符,表示浮点数(f
),保留 4 位小数。- 注意:大括号
{}
内的代码在str.format()
和f-string
中可以是表达式,对于f-string
,会在运行时动态计算。
-
格式说明符的分解
示例:'{0.mass:5.3e}'
field_name
(左侧):0.mass
指定了待格式化的对象,可以是复杂路径或公式。format_spec
(右侧):5.3e
指的是用于格式化的规则。- 微型语言支持的格式说明详细参见微型语言参考。
工程中的扩展应用
示例:支持自定义对象的格式化
编写支持自定义格式的方法,需在对象中实现 __format__
方法,从而控制 format()
如何作用于对象。
实现目标
- 支持不同格式说明符(如:保留小数点后 N 位、科学记数法等)。
- 支持自定义的格式扩展(例如显示极坐标格式)。
构造一个矢量类
class Vector2d:
def __init__(self, x, y):
self.x = float(x)
self.y = float(y)
def __iter__(self):
return (i for i in (self.x, self.y))
def __format__(self, fmt_spec=''):
# 默认格式
components = (format(c, fmt_spec) for c in self)
return '({}, {})'.format(*components)
使用默认 __format__
v1 = Vector2d(3, 4)
print(format(v1)) # 默认格式
# 输出:'(3.0, 4.0)'
print(format(v1, '.2f')) # 每个组件保留 2 位小数
# 输出:'(3.00, 4.00)'
print(format(v1, '.3e')) # 科学记数法保留 3 位有效数字
# 输出:'(3.000e+00, 4.000e+00)'
扩展自定义格式:支持极坐标
极坐标表示矢量的大小和角度:<r, θ>
。其中:
- r 是矢量的模(
magnitude
)。 - θ 是矢量的角度(弧度单位)。
import math
class Vector2d:
def __init__(self, x, y):
self.x = float(x)
self.y = float(y)
def __iter__(self):
return (i for i in (self.x, self.y))
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1] # 移除 'p'
coords = (abs(self), self.angle())
outer_fmt = '<{}, {}>' # 使用尖括号包裹
else:
coords = self
outer_fmt = '({}, {})' # 使用括号包裹
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
def __abs__(self): # 计算大小
return math.hypot(self.x, self.y)
def angle(self): # 计算角度
return math.atan2(self.y, self.x)
v1 = Vector2d(1, 1)
print(format(v1, 'p')) # 默认极坐标格式
# 输出:'<1.4142135623730951, 0.7853981633974483>'
print(format(v1, '.3ep')) # 极坐标中使用科学记数法
# 输出:'<1.414e+00, 7.854e-01>'
print(format(v1, '0.5fp')) # 极坐标中保留 5 位小数
# 输出:'<1.41421, 0.78540>'
详细解释
outer_fmt.format(*components)
是Python中字符串格式化的一种用法。这里的outer_fmt
是一个字符串,包含格式占位符(如{}
),而components
是一个可迭代对象(在这个例子中是一个生成器),包含要插入到这些占位符中的值。
str.format()
方法
str.format()
是Python字符串对象的方法,用于格式化字符串。- 你在字符串中添加占位符
{}
,这些占位符会被format()
方法中的参数替换。
outer_fmt
outer_fmt
是在__format__
方法中定义的字符串模板。- 它根据是否以
'p'
结尾的fmt_spec
,被设置为'<{}, {}>'
或'({}, {})'
,其中{}
是占位符。
components
components
是一个生成器表达式,生成格式化后的坐标值。- 例如,在极坐标情况下,它生成的是大小和角度的格式化字符串。
使用*
解包
outer_fmt.format(*components)
*components
使用星号*
操作符来解包生成器或可迭代对象。- 这意味着生成的每个格式化字符串值都会作为单独的参数传递给
format()
方法。
整合
- 例如,假设
components
生成两个格式化字符串'1.414'
和'0.785'
。 outer_fmt.format(*components)
相当于outer_fmt.format('1.414', '0.785')
。- 如果
outer_fmt
是'<{}, {}>'
,格式化后的结果就是'<1.414, 0.785>'
。
这种方法非常灵活,因为你可以根据需要动态地创建不同的格式模板,并将任意数量的参数插入到这些模板中。
注意点
-
选择自定义格式字符
- 确保不与内置格式冲突。例如
p
不会与整数的b, c, d, o, x, X
或浮点数的f, e, %
重叠。
- 确保不与内置格式冲突。例如
-
可复用性
- 微型语言的扩展与类绑定,每个类可以自由解析
format_spec
,在设计中避免歧义。
- 微型语言的扩展与类绑定,每个类可以自由解析
6、A Hashable Vector2d
1. 背景:为什么要实现可哈希性?
在 Python 中,一个对象是否可哈希,决定了:
- 它是否可以作为键存储在字典中。
- 它是否可以存储到集合(
set
)中。
判断标准:一个对象是可哈希的需要满足以下条件:
- 实现了特殊方法
__hash__
。 - 实现了
__eq__
方法(用于比较是否相等)。 - 哈希值不能因为对象内容的变化而发生改变。因此,对象应当是不可变的。
案例:未实现可哈希性的 Vector2d
在初始实现中,Vector2d
类并没有实现 __hash__
方法,因此其对象是不可哈希的,也不能用于集合或字典键:
v1 = Vector2d(3, 4)
# 计算 hash 会报错
>>> hash(v1)
Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'
# 将 v1 放入集合也会报错
>>> set([v1])
Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'
2. 使 Vector2d
可哈希:步骤详解
为了让 Vector2d
实现可哈希性,我们需要以下几个步骤:
- 实现
__hash__
方法。 - 如果类的实例可变,则属性的变化可能破坏哈希的正确性。因此必须使
Vector2d
对象不可变。 - 已经实现
__eq__
方法(满足了比较相等的要求),所以不需要再修改。
下面我们逐一讲解并实际实现这些步骤:
2.1 不可变性:使用只读属性保护字段
属性的可变性会带来问题。例如:
v1 = Vector2d(3, 4)
print(v1.x, v1.y) # 输出 (3.0, 4.0)
# 修改属性值
v1.x = 7 # 直接导致对象被修改,这会破坏哈希一致性
为了解决这个问题,我们需要让 x
和 y
变为只读属性。实现方式是使用 @property
装饰器并将实际的值存储在私有字段中。在 Python 中,用双下划线(__attr
)可以将字段设为私有(尽管并非完全封装,但可以在一定程度上保护字段)。
具体实现如下:
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x) # 私有属性 __x
self.__y = float(y) # 私有属性 __y
@property
def x(self):
"""定义只读属性 x"""
return self.__x
@property
def y(self):
"""定义只读属性 y"""
return self.__y
验证:只读属性效果
v1 = Vector2d(3, 4)
print(v1.x, v1.y) # 输出:(3.0, 4.0)
# 试图修改属性会报错
>>> v1.x = 7
Traceback (most recent call last):
...
AttributeError: can't set attribute
这样一来,我们保证了 Vector2d
的属性一旦初始化,就不可修改。
用 __
定义的私有属性:
在 Python 中,双下划线前缀(__
)被称为 名称改写(name-mangling),它的主要作用是将类中的属性或方法进行"内部改名",以避免子类发生命名冲突,提供一定程度的封装。
class MyClass:
def __init__(self, value):
self.__value = value # 定义私有属性
obj = MyClass(10)
print(obj.__value) # 直接访问会报错,因为 __value 是私有的
输出:
AttributeError: 'MyClass' object has no attribute '__value'
为什么会报错?
- 双下划线会触发"名称改写",属性名
__value
在内部实际上被改为_MyClass__value
,从而阻止外部代码直接访问。 - 这是一种伪私有机制,主要目的是防止意外覆盖属性,但不是真正的安全保护机制(仍然可以通过改写后的名称访问)。
可以通过以下方式访问:
print(obj._MyClass__value) # 通过改写后的名称访问
输出:
10
2.2 实现 __hash__
方法
为了让 Vector2d
可哈希,我们还需要实现 __hash__
方法。__hash__
方法的主要逻辑是基于类中用于比较相等的字段计算哈希值。在 Vector2d
中,我们使用了 x
和 y
属性进行比较,因此哈希值也应该基于它们。
Python 的官方文档建议使用 hash
函数计算一个元组的哈希值,因为元组本身是不可变的——这正好符合哈希对象的要求:
class Vector2d:
# 前面的代码保持不变
def __hash__(self):
"""基于 x 和 y 属性的值计算哈希值"""
return hash((self.x, self.y))
hash((self.x, self.y))
是 Python 中计算一个包含 self.x
和 self.y
值的 元组的哈希值的表达式。下面解释是如何工作的:
背景知识
-
hash
函数:- Python 的内置函数
hash(obj)
用于返回一个对象的哈希值。 - 哈希值是一个整数,它是从对象的值派生出来的,常用于字典的键或集合的元素。可哈希对象的特点是其值在整个生命周期中是不可变的,并且实现了
__hash__
方法。
- Python 的内置函数
-
为什么用
hash((self.x, self.y))
:Vector2d
类的__hash__
方法是为了让这个类的对象成为可哈希的。- 在计算哈希值时,包含多个属性的哈希值可以通过元组进行组合计算。因为元组是不可变的,因此可以用作另一个哈希值的输入。
hash((self.x, self.y))
的意思是:- 把
self.x
和self.y
作为一个元组(self.x, self.y)
。 - 调用元组的哈希方法,计算这个元组的哈希值。
- 把
- 这样,
Vector2d
的哈希值依赖于self.x
和self.y
的值。
代码解释
class Vector2d:
...
def __hash__(self):
"""基于 x 和 y 属性的值计算哈希值"""
return hash((self.x, self.y))
- 如果有一个
Vector2d
对象v = Vector2d(3, 4)
:self.x = 3
,self.y = 4
;- 通过
hash((self.x, self.y))
,相当于计算hash((3, 4))
。 - 结果是某个惟一的整数值,这个值可以用作哈希表中代表该
Vector2d
对象的键。
为什么这样设计
- 使用
(self.x, self.y)
作为哈希的基础是因为元组的哈希方法已经实现得非常高效且可靠。 - 如果两个
Vector2d
对象的x
和y
相同,则它们的哈希值也会相同。这符合哈希的一般要求:在__eq__
返回True
的情况下,两个对象的哈希值必须相等。
2.3 验证:Vector2d
对象是否可哈希
现在我们测试一下成果:
v1 = Vector2d(3, 4)
v2 = Vector2d(3.1, 4.2)
# 计算哈希值
print(hash(v1), hash(v2))
# 示例输出:
# (1079245023883434373, 1994163070182233067)
# 放入集合
vectors = {v1, v2}
print(vectors)
# 示例输出:
# {Vector2d(3.0, 4.0), Vector2d(3.1, 4.2)}
此时,Vector2d
对象已经可以被成功地放入集合中了,这便证明了可哈希性的完成。
2.4 总结:可哈希性并不严格依赖于不可变性
从技术上讲,只要正确实现了 __eq__
和 __hash__
方法,一个类的对象就可以是可哈希的。然而,为了符合哈希的实际用途,哈希值应当固定不变(即:相等的对象具有相同的哈希值)。因此,使对象不可变是一个明智的选择。
3. 完整实现代码
以下是我们最终的 Vector2d
类的完整实现:
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
"""使 Vector2d 支持迭代"""
return (i for i in (self.x, self.y))
def __hash__(self):
"""基于 x 和 y 返回一个哈希值"""
return hash((self.x, self.y))
@property
装饰器的用途与用法
@property
是 Python 内置的一个装饰器,用于创建"只读属性"或"把方法伪装成属性"。它是实现封装的一种更优雅的方式,可以让类的用户通过属性访问数据,而不是直观调用方法。
使用 @property
定义只读属性:
在 Vector2d
中,我们通过 @property
为 x
和 y
创建了只读属性:
@property
def x(self):
"""定义只读属性 x"""
return self.__x
- 通过
@property
装饰器,把x()
方法伪装成属性:- 外部通过
obj.x
访问时,实际上调用了x()
方法。 - 但外部不可直接修改
x
的值。
- 外部通过
v = Vector2d(3, 4)
print(v.x) # 通过 @property 定义的 "只读属性"
v.x = 100 # 报错,因为 x 是只读的
输出:
3.0
AttributeError: can't set attribute
为什么要用 @property
?
@property
提供了一种类的封装方法:数据在类内部存储,但可以通过只读属性(或带条件的读写属性)访问它们,防止外部直接操作底层数据。
带条件的读写属性:
通过 @property
和 @x.setter
,可以定义"可读可写"属性。
例如:
class MyClass:
def __init__(self, value):
self.__value = value # 私有属性
@property
def value(self): # 获取属性
return self.__value
@value.setter
def value(self, new_value): # 设置属性
if new_value > 0: # 添加限制条件
self.__value = new_value
else:
raise ValueError("Value must be positive!")
obj = MyClass(10)
print(obj.value) # 调用 getter,输出: 10
obj.value = 20 # 调用 setter 修改值
print(obj.value) # 再次获取新值,输出: 20
obj.value = -5 # 抛出 ValueError
输出:
10
20
ValueError: Value must be positive!
小结 @property
的用途:
- 优雅的封装:把方法伪装为属性,无需显式调用。
- 可控的数据访问:可以通过自定义的
getter
和setter
对数据进行验证或限制。 - 只读保护:通过只定义
getter
,而不定义setter
,实现只读属性。
总结:
- 双下划线
__
用于名称改写,以实现伪私有化(防止命名冲突,隐藏实现细节)。 @property
提供了一种更优雅的封装方式,通过将方法伪装成属性,允许用户可读不可写,或在写入时添加限制条件,有助于实现信息隐藏和控制。
7、Supporting Positional Pattern Matching
背景与介绍
在 Python 3.10 中引入的模式匹配(Pattern Matching)是一种类似于 switch-case
的语法结构,用来简化对数据结构的匹配和解构操作。
在某些场景下,我们希望某个类的实例可以支持同时使用 关键字模式匹配 (Keyword Class Pattern) 和 位置模式匹配 (Positional Class Pattern)。本文将以 Vector2d
类为例,详细说明如何实现支持这两种匹配模式,同时分析其中的难点和容易出错的地方。
关键字模式匹配 (Keyword Class Pattern)
示例代码
以下是对 Vector2d
对象进行 关键字模式匹配 的案例代码:
def keyword_pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(x=0, y=0):
print(f'{v!r} is null') # 原点
case Vector2d(x=0):
print(f'{v!r} is vertical') # 竖直方向上的向量
case Vector2d(y=0):
print(f'{v!r} is horizontal') # 水平方向上的向量
case Vector2d(x=x, y=y) if x == y:
print(f'{v!r} is diagonal') # 横纵坐标相等,表示沿对角线
case _:
print(f'{v!r} is awesome') # 默认匹配
示例解读
在这个示例中,我们可以看到模式匹配中使用了关键字参数 x
和 y
来匹配 Vector2d
对象的实例属性:
case Vector2d(x=0, y=0)
:匹配x=0
且y=0
的实例,表示原点。case Vector2d(x=0)
:只匹配x=0
的实例,表示竖直方向上的向量。case Vector2d(y=0)
:只匹配y=0
的实例,表示水平方向上的向量。case Vector2d(x=x, y=y) if x == y
:匹配横纵坐标相等的实例,例如(1,1)
表示沿对角线。case _
:匹配所有其他情况。
运行结果示例(假设 Vector2d
对象有 x
和 y
属性):
v1 = Vector2d(0, 0)
keyword_pattern_demo(v1) # 输出: Vector2d(x=0, y=0) is null
v2 = Vector2d(0, 5)
keyword_pattern_demo(v2) # 输出: Vector2d(x=0, y=5) is vertical
v3 = Vector2d(7, 0)
keyword_pattern_demo(v3) # 输出: Vector2d(x=7, y=0) is horizontal
v4 = Vector2d(4, 4)
keyword_pattern_demo(v4) # 输出: Vector2d(x=4, y=4) is diagonal
v5 = Vector2d(6, 5)
keyword_pattern_demo(v5) # 输出: Vector2d(x=6, y=5) is awesome
为什么位置模式匹配会报错?
在尝试为 Vector2d
实现 位置模式匹配 时,我们可能会尝试这样的代码:
case Vector2d(_, 0):
print(f'{v!r} is horizontal')
但这会引发错误:
TypeError: Vector2d() accepts 0 positional sub-patterns (1 given)
报错原因
默认情况下,类中的模式匹配只支持关键字参数,而不支持通过位置参数来解构实例的属性。这是因为位置模式匹配需要类显式地声明如何通过位置解构实例属性。
Python 的一个核心设计哲学是 “显式优于隐式”(详见《The Zen of Python》)。如果某种行为可能带来模棱两可的情况,Python 倾向于要求开发者明确做出选择,而不是提供一个隐式的行为。
默认不支持位置模式并不是因为技术上不能实现,而是因为:
明确需要声明 match_args 是一个显式的选择,符合 Python 的设计哲学。
避免开发者无意中依赖了顺序耦合这种隐式行为。
解决方案:使用 __match_args__
支持位置模式匹配
要让类支持位置模式匹配,需要在类中定义一个特殊的类属性 __match_args__
。该属性是一个元组,列出了实例属性解构时的位置参数顺序。
示例修改后的 Vector2d
类
下面是添加了 __match_args__
属性后的 Vector2d
类:
class Vector2d:
__match_args__ = ('x', 'y') # 定义位置参数顺序
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f'Vector2d(x={self.x}, y={self.y})'
通过 __match_args__ = ('x', 'y')
,我们声明了 x
和 y
是按照顺序解构的实例属性。
示例:位置模式匹配代码
def positional_pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(0, 0):
print(f'{v!r} is null') # 原点
case Vector2d(0):
print(f'{v!r} is vertical') # 竖直方向上的向量
case Vector2d(_, 0):
print(f'{v!r} is horizontal') # 水平方向上的向量
case Vector2d(x, y) if x == y:
print(f'{v!r} is diagonal') # 横纵坐标相等
case _:
print(f'{v!r} is awesome') # 默认情况
示例运行结果
假设 Vector2d
对象定义如上,我们可以运行以下代码并观察结果:
v1 = Vector2d(0, 0)
positional_pattern_demo(v1) # 输出: Vector2d(x=0, y=0) is null
v2 = Vector2d(0, 5)
positional_pattern_demo(v2) # 输出: Vector2d(x=0, y=5) is vertical
v3 = Vector2d(7, 0)
positional_pattern_demo(v3) # 输出: Vector2d(x=7, y=0) is horizontal
v4 = Vector2d(4, 4)
positional_pattern_demo(v4) # 输出: Vector2d(x=4, y=4) is diagonal
v5 = Vector2d(6, 5)
positional_pattern_demo(v5) # 输出: Vector2d(x=6, y=5) is awesome
__match_args__
的使用注意事项
-
顺序对应:
__match_args__
中的属性顺序必须与你希望通过位置参数访问的属性顺序一致。例如('x', 'y')
意味着case Vector2d(10, 20)
中,10
对应x
,20
对应y
。 -
可选 vs 必选:
__match_args__
不需要包含类的所有实例属性,通常我们只声明那些在模式匹配中需要使用的 必选属性,忽略可选属性。 -
简化模式匹配代码:
使用__match_args__
后,可以用位置匹配代替关键字匹配,这在代码中可以减少重复写属性名的代码,更简洁。
关键字匹配与位置匹配对比
为了更直观地对比关键字模式和位置模式的不同,我们针对同样的用例分别给出代码:
关键字模式匹配
case Vector2d(x=0, y=0):
print('null')
位置模式匹配
case Vector2d(0, 0):
print('null')
结论
在 Python 项目的实际开发中:
- 如果目标类的属性对顺序有明确意义(如二维向量、点等),推荐使用位置模式匹配,配合
__match_args__
提供更简洁的代码。 - 对于一般性类或含较多属性的类,关键字模式匹配更直观,维护成本更低。
8、Complete Listing of Vector2d, Version 3
1. 背景与意义
Vector2d
是一个二维向量类。通过该类,我们将深入学习以下内容:
- Python 的特殊方法(
__str__
、__repr__
、__bytes__
等) - 属性(
property
)和不可变性的设计 - 比较操作(
__eq__
和哈希) - 内存协议(
frombytes
和bytes
) - 数学应用(
abs
,math.hypot
,angle
,format(p)
处理笛卡尔坐标与极坐标)
这些功能并不是为了堆积技术,而是演示了如何设计一个“Pythonic”的类,使其具备高可用性和清晰的语义。
2. 完整代码
以下是 Vector2d
的完整代码:
from array import array
import math
class Vector2d:
__match_args__ = ('x', 'y') # 支持结构匹配(Python 3.10+)
typecode = 'd' # 指定使用 'double' 类型存储二进制数据
def __init__(self, x, y):
self.__x = float(x) # 将 x 转为浮点数
self.__y = float(y) # 将 y 转为浮点数
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y))
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)
def __str__(self):
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) +
bytes(array(self.typecode, self)))
def __eq__(self, other):
return tuple(self) == tuple(other)
def __hash__(self):
return hash((self.x, self.y))
def __abs__(self):
return math.hypot(self.x, self.y)
def __bool__(self):
return bool(abs(self))
def angle(self):
return math.atan2(self.y, self.x)
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1]
coords = (abs(self), self.angle())
outer_fmt = '<{}, {}>'
else:
coords = self
outer_fmt = '({}, {})'
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0])
memv = memoryview(octets[1:]).cast(typecode)
return cls(*memv)
3. 关键功能与讲解
3.1 一类常用特殊方法详解
以下特殊方法为 Vector2d
添加了核心的 Pythonic 功能。
__repr__
和 __str__
__repr__
: 提供对象的正式字符串表示,主要供开发者调试使用。__str__
: 提供对象的用户友好表示,主要供用户查看。
示例:
v1 = Vector2d(3, 4)
print(repr(v1)) # Vector2d(3.0, 4.0)
print(str(v1)) # (3.0, 4.0)
工程提示:
在调试环境中(如 REPL 或日志系统),__repr__
格式必须确保可用于重新构造对象(如上所示,eval(repr(v1))
重建对象),这是一种良好的实践。
__bytes__
和 frombytes
__bytes__
: 将向量对象转换为字节流。此处使用array
模块将坐标以二进制浮点数格式存储。frombytes
: 从字节序列还原Vector2d
对象,兼容__bytes__
的输出格式。
示例:
v1 = Vector2d(3, 4)
octets = bytes(v1)
print(octets) # 二进制表示 b'd\x00\x00\x00\x00...'
v2 = Vector2d.frombytes(octets)
print(v2) # 重建为 (3.0, 4.0)
assert v1 == v2 # True
注意:
frombytes
是一个类方法,通过字节流解码时,非常常见于序列化/反序列化应用,如文件存储或网络传输。
__eq__
和 __hash__
:
__eq__
: 判断两个向量是否相等,基于它们的坐标对比。__hash__
: 将向量转为哈希值,这使得向量可以放入哈希表(如set
和dict
)。
示例:
v1 = Vector2d(3, 4)
v2 = Vector2d(3, 4)
v3 = Vector2d(1, 0)
print(v1 == v2) # True
print(hash(v1) == hash(v2)) # True
print(v1 == v3) # False
s = {v1, v3}
print(len(s)) # 2
3.2 数据不可变性
使用了 @property
和私有属性 __x, __y
,从而保护了数据的不可变性,防止用户直接修改 x
和 y
。
示例:
v1 = Vector2d(3, 4)
print(v1.x, v1.y) # 3.0 4.0
v1.x = 5 # 报错:AttributeError: can't set attribute 'x'
3.3 数学与几何
abs()
和 bool()
abs
: 基于勾股定理计算向量的模长。bool
: 判断向量是否为零向量,基于其模长是否大于0
。
示例:
v1 = Vector2d(3, 4)
print(abs(v1)) # 5.0(√(3² + 4²))
print(bool(v1)) # True
print(bool(Vector2d(0, 0))) # False
angle
和极坐标表示
angle
: 基于atan2
计算向量的角度(弧度)。- 支持极坐标格式化,允许用户以极坐标形式查看向量内容。
示例:
v1 = Vector2d(1, 1)
print(v1.angle()) # 0.785...(即 π/4 弧度)
print(format(v1, 'p')) # '<1.414213..., 0.785398...>'
print(format(v1, '.2fp')) # '<1.41, 0.79>'
4. 工程中的现实应用
-
不可变对象 (Immutability):
不可变的数据结构适合在多线程环境中使用,如共享数据,而无需担心状态被随意修改。 -
序列化与反序列化:
bytes
和frombytes
功能在网络协议或二进制文件存储中非常重要,比如存储大规模矢量数据。 -
数学处理与几何应用:
这种向量类设计在渲染、物理模拟和机器学习中非常常见。 -
哈希与集合操作:
通过实现__hash__
,使向量能够用于set
和dict
,比如统计独立的向量集合。
5. 总结
Vector2d
是一个小巧但功能强大的类,通过精心设计的特殊方法,完全符合 Pythonic 的设计理念:
- 使用约定俗成的方法为类提供直观的行为。
- 确保类能够友好地与 Python 的内置功能(如序列化、集合操作)交互。
- 读写分离,确保数据的安全性与一致性。
9、Private and “Protected” Attributes in Python
在 Python 中,并没有像 Java 中那样的 private
修饰符来定义私有变量。一切基于一种约定俗成的机制以及部分语言特性,来阻止意外覆盖或访问私有/受保护的属性。下面将围绕易懂的实践例子,结合实际应用场景,深入探讨这个重要但容易误解的概念。
私有属性(Private Attributes)的机制与用法
什么是私有属性?
Python 中的一种特殊机制称为 名字修改(Name Mangling),用于防止在继承或其他场景中,意外覆盖父类的重要属性。
名字修改的原理
- 定义方式: 如果一个类的属性以两个下划线开头但不以两个下划线结尾 (
__attribute
),Python 自动将其重命名为_类名__attribute
。 - 目的: 防止子类中的属性与父类中的私有属性发生冲突。
这种修改并不是真正的“隐藏”属性,而是将其重命名以使直接访问更困难(安全性目的,而非安全机制)。但请注意,任何了解 Python 名字修改规则的人,都可以通过其重命名后的方式直接访问这些私有属性。
实例讲解:名字修改
class Dog:
def __init__(self, name, mood):
self.name = name
self.__mood = mood # 定义私有属性 __mood,无法直接被类外访问和修改
class Beagle(Dog):
def __init__(self, name, mood, size):
super().__init__(name, mood) # 调用父类 Dog 的 __init__ 方法
self.__mood = "excited" # 在子类中重新定义了一个私有属性 __mood,
# 它与父类 Dog 中的 __mood 是独立的,互不影响
分析:
Dog
类拥有__mood
属性。Beagle
子类也定义了__mood
属性。- 即便名字相同,Python 会修改子类的
__mood
变量为_Beagle__mood
。 - 父类的
__mood
会被修改为_Dog__mood
。
- 即便名字相同,Python 会修改子类的
可以通过以下代码测试:
beagle = Beagle("Buddy", "happy", "small")
print(beagle.__dict__)
# 输出:
# {'name': 'Buddy', '_Dog__mood': 'happy', '_Beagle__mood': 'excited'}
总结: 子类和父类的
__mood
属性不会互相冲突,因为它们被自动重命名。这增加了安全性,但并非真正隐藏。
直接访问私有属性
虽然 Python 会将双下划线命名的属性改名存储,但我们依然可以访问。
# 创建 Beagle 实例
beagle = Beagle("Buddy", "happy", "small")
# 通过访问 mangled 名字修改的形式,直接访问父类 Dog 的私有属性 __mood
print(beagle._Dog__mood)
# 输出:happy,因为 _Dog__mood 是父类 Dog 定义的 __mood 的 mangled 名字表示
beagle._Dog__mood = "angry"
# 修改父类 Dog 的 __mood 的值
print(beagle._Dog__mood)
# 输出:angry,表明对应的属性值已经被成功修改
提示: 这种直接访问方式在调试时可能会有用,但不推荐在正式代码中使用。
beagle.__mood = "yeahhou"
# 为 beagle 创建了一个新的实例属性 __mood,与父类或子类的 __mood 没有任何关系
print(beagle.__mood)
# 输出:yeahhou,访问的是实例对象的新增属性
print(beagle.__dict__)
# 输出:{'name': 'Buddy', '_Dog__mood': 'angry', '_Beagle__mood': 'excited', '__mood': 'yeahhou'}
# 实例的 __dict__ 显示了所有与实例绑定的属性:
# - _Dog__mood 和 _Beagle__mood 分别对应父类和子类的私有属性
# - __mood 是在实例上动态添加的新属性,属于该实例自身
易混淆之处:私有 vs. 受保护
“私有”属性定义
Python 并没有像 Java 中严格的 private
访问限制,其“私有”仅通过名字修改为 _ClassName__attribute
来区分,从而避免潜在冲突。“私有”属性适合保护那些属于类内部实现细节的部分。
“受保护”属性定义
以单下划线 _attribute
命名的属性被称为“受保护”的属性,这是一种约定俗成的用法。
- 单下划线并不会触发名字修改,但它表示“请不要直接访问,除非必要”。
- 它通常用于标记一个属性或方法是 供内部使用的。
区别与对比
特性 | 双下划线私有属性 | 单下划线受保护属性 |
---|---|---|
定义语法 | __attribute | _attribute |
名字修改机制 | 有 | 无 |
用途 | 防止无意覆盖 | 面向开发者的访问提示工作 |
推荐访问方式 | 在类内部或特殊情况调试中使用 | 面向子类安全拓展 |
示例对比:私有 vs. 受保护
私有属性示例:
class Vector:
def __init__(self, x, y):
self.__x = x # 私有变量
self.__y = y
v = Vector(3, 4)
print(v.__dict__) # 查看存储的属性
# 输出:{'_Vector__x': 3, '_Vector__y': 4}
可以通过语法绕过访问:
print(v._Vector__x)
# 输出:3
受保护属性示例:
class Vector:
def __init__(self, x, y):
self._x = x # 受保护变量
self._y = y
v = Vector(3, 4)
print(v._x)
# 输出:3
- 注意: 直接访问
_x
并不会引发错误,但在开发者社区中,这种行为通常被认为不符合规范。
推荐的实践方法
1. 使用双下划线时的注意事项
- 仅在需要绝对避免冲突的情况下使用双下划线作为名前缀。
- 如果需要定义多个“私有”属性,考虑合理命名以避免不必要的复杂性。
2. 面向开发者的“受保护”规则
- 使用单下划线前缀(如
_attribute
)来标记那些仅供内部或子类使用的属性。 - 遵守即用即避原则:使用
_attribute
的对象属性时,尽量通过类的公共接口访问(如 getter/setter),而不是直接访问。
class User:
# 初始化方法(构造器):定义了两个内部属性 `_name` 和 `_age`
def __init__(self, name, age):
# 使用单下划线前缀,表明这些属性是“内部使用”的(私有约定)
self._name = name
self._age = age
# Getter 方法:提供一个公共接口来读取 `_name`,以避免外部直接访问
def get_name(self):
return self._name
# Setter 方法:提供一个公共接口来修改 `_name`
def set_name(self, name):
if name: # 简单验证:name 不能为空
self._name = name
else:
raise ValueError("Name cannot be empty!")
# Getter 方法:提供一个公共接口来读取 `_age`
def get_age(self):
return self._age
# Setter 方法:提供一个公共接口来修改 `_age`
def set_age(self, age):
if age > 0: # 验证逻辑:age 必须大于 0
self._age = age
else:
raise ValueError("Age must be positive!")
# 一个实例方法:用来展示用户的完整个人信息
# 这里也是通过公共接口(如 get_name 和 get_age)来访问属性
def display_info(self):
return f"Name: {self.get_name()}, Age: {self.get_age()}"
# 使用类
user = User("Alice", 25)
# 通过公共接口访问和修改内部属性(遵守即用即避原则)
# 用公共 getter 获取 name 和 age
print(user.get_name()) # 输出: Alice
print(user.get_age()) # 输出: 25
# 用公共 setter 更新值
user.set_name("Bob")
print(user.get_name()) # 输出: Bob
user.set_age(30)
print(user.get_age()) # 输出: 30
# 显示用户完整信息(通过 `display_info` 方法调用 getter 方法获取属性)
print(user.display_info()) # 输出: Name: Bob, Age: 30
# 错误用法:直接访问内部属性(这种用法并不推荐)
print(user._name) # 输出: Bob,但不推荐直接访问
user._name = "Eve" # 修改成功,但绕过了验证逻辑,这是潜在风险
print(user._name) # 输出: Eve
# 正确用法:避免直接操作 _name 和 _age,始终通过公共接口访问
user.set_name("Charlie")
print(user.display_info()) # 输出: Name: Charlie, Age: 30
3. 实践中的命名约定
如果双下划线命名前缀在你的项目中显得冗长,可以通过显式命名来避免冲突:
class Dog:
def __init__(self, name, mood):
self._Dog_mood = mood
这种方法与名字修改极为接近,但更加直观。
总结
- 双下划线开头的属性触发名字修改机制,其设计目的是 安全性(避免属性冲突),而不是 隐藏性。
- 单下划线是开发者之间的约定,表示“受保护”的属性,但其访问并不受严格限制。
- 推荐优先使用单下划线命名约定,而不是双下划线,除非确实有避免名称冲突的需求。
- 在实际工程中,始终优先通过公开的接口(如方法)操作受保护属性,而不是直接访问。
10、Saving Memory with __slots__
什么是 __slots__
?
在默认情况下,Python 会为每个实例分配一个字典(__dict__
),用来存储实例属性。这种方式非常灵活,但需要占用较多内存,这是因为字典本质上是一种带有内存开销的数据结构——它需要额外存储散列表等信息。
__slots__
提供了一种优化选项。通过在类中定义 __slots__
属性,可以明确指定这个类的实例允许哪些固定的属性。这样,Python 会放弃为这些实例分配 __dict__
,而是采用更高效的内存存储方式(类似于数组)。
使用 __slots__
的对象不仅节省了内存,还带来了一些限制。在下文中,我们会通过各个例子进行细致的解析。
基础示例:如何定义并使用 __slots__
class Pixel:
__slots__ = ('x', 'y') # 指定仅允许 x 和 y 这两个属性
解释与操作:
__slots__
接受一个可迭代对象(通常是元组或列表),包含了类中允许使用的属性名。- Python 会在后端为这些属性分配一个固定的存储结构,而不为实例创建
__dict__
。
例子:Pixel 类
class Pixel:
__slots__ = ('x', 'y')
# 实例化 Pixel 类
p = Pixel()
# 检查 __dict__ 属性是否存在
print(p.__dict__) # 抛出 AttributeError: 'Pixel' object has no attribute '__dict__'
# 设置允许的属性
p.x = 10
p.y = 20
# 尝试设置未定义的属性
p.color = 'red' # 抛出 AttributeError: 'Pixel' object has no attribute 'color'
运行结果:
- 对象没有
__dict__
。 - 只能设置在
__slots__
中列出的属性,任何未定义的属性都会抛出AttributeError
。
注意点:
- 定义
__slots__
的位置非常重要,必须在类创建时直接声明。如果在类定义后动态添加或修改__slots__
,不会生效。 - 我们更推荐将
__slots__
定义成元组。这是因为元组是不可变的,能够清晰表示开发意图:__slots__
定义的属性列表不应被修改。
why
为什么没有p.__dict__
属性?
为什么 __slots__
的位置非常重要,必须在类创建时直接声明。如果在类定义后动态添加或修改 __slots__
,不会生效。可运行的代码例子讲解?
为什么更推荐将 __slots__
定义成元组,举例子对比讲解?
解答
1. 为什么没有 p.__dict__
属性?
原理分析
- 普通Python类的实例,每次添加属性,都是保存在实例的
__dict__
内,这样任何属性都可以动态添加,灵活但占用更多内存。 - 当类定义了
__slots__
,Python内部不会为该实例创建__dict__
,而是用更紧凑的固定结构(slots数组)存储属性,只能有指定的这些属性,省内存,且不能再随意“扩展”属性。
代码举例
class Normal:
pass
class WithSlots:
__slots__ = ('x', 'y')
a = Normal()
b = WithSlots()
a.x = 10
b.x = 20
print(a.__dict__) # {'x': 10}
try:
print(b.__dict__) # 会报错:AttributeError
except AttributeError as e:
print("WithSlots对象没有__dict__属性:", e)
结论
- 加了
__slots__
,实例就没有__dict__
属性了。 - 这样节省内存,也限制了实例只能拥有固定属性(
x
,y
),其他属性无法被动态添加。
2. 为什么 __slots__
的位置重要,必须在类创建时直接声明,类定义后动态添加或修改无效?(代码说明)
原理分析
- Python在创建类时(即解释
class
语句时),会检测__slots__
,并用它来定制类的实例结构。 - 如果在类定义后才添加或修改
__slots__
,Python不会“回过头”去改变实例结构(比如变成没有__dict__
),这样做根本不会生效。
代码举例
# 没有在定义时设置__slots__
class A:
pass
a = A()
A.__slots__ = ('x',) # 现在才设置slots —— 无效!
a.x = 1 # OK,因为有__dict__允许随意加属性
a.y = 2 # 也是OK
print(a.__dict__) # {'x': 1, 'y': 2}
再看“正确”的做法:
class B:
__slots__ = ('x',)
b = B()
b.x = 1 # OK
try:
b.y = 2 # 错!AttributeError
except AttributeError as e:
print("不能动态加属性:", e)
结论
- 必须 在class定义时直接声明
__slots__
,创建类对象的时候生效。 - 后续动态加改
__slots__
,Python“不会理你”,实例结构不会因此变。
3. 为什么推荐将 __slots__
定义成元组,举例子说明?
原理分析
__slots__
可以用任何可迭代对象(如列表、元组、字符串等),但元组不可变,这样属性列表不会被不小心改动,传达出“这是固定的声明”含义,代码更安全、风格一致。- 列表可变,后续在其它地方被误改,可能造成难以追查的Bug。
代码对比
用元组,表达属性是固定不变
class C:
__slots__ = ('x', 'y')
print(C.__slots__) # ('x', 'y')
# 尝试修改
try:
C.__slots__[0] = 'a' # TypeError: 'tuple' object does not support item assignment
except TypeError:
print("元组不可变,改不了,不会出bug。")
如果用列表,可能被无意修改
class D:
__slots__ = ['x', 'y']
print(D.__slots__) # ['x', 'y']
D.__slots__[0] = 'a' # 列表元素被修改了!
print(D.__slots__) # ['a', 'y']
# 但 slots 只在类定义时解析,属性名已经定死是 x/y,所以这种误操作没法真的影响实例,但是这样的代码风格很容易给维护者带来困惑和不可预测的行为。
d = D()
d.x = 10
print(d.x)
d.y = 20
print(d.y)
d.a = 30
print(d.a)
# 10
# 20
# Traceback (most recent call last):
# File "d:\vscode_code\decision_tree\test.py", line 15, in <module>
# d.a = 30
# AttributeError: 'D' object has no attribute 'a'
重点说明
__slots__
只在类对象创建的时候起作用(如问题2所述),所以即便你后来改动了它的内容,也不会影响已经生成的属性布局。但是元组本身不可变性明显传达意图,写法更安全、直观。
结论
- 虽然用列表不会实际出bug,但代码容易出混淆和误解,不符合Python惯例。
- 推荐用元组,清晰、安全、规范。
汇总对比
问题 | 不加__slots__ | 正确用法 | 错误用法/坏味道 |
---|---|---|---|
__dict__ | 有、可任意加属性 | 没有,可控属性 | 使用后动态加/改无效 |
__slots__ 位置 | 不限制,没用 | class体内写死 | 运行后加/改无用 |
__slots__ 类型 | 任意可迭代对象,推荐元组 | 元组安全表达“不可变声明” | 列表可被随意改动 |
如还有相关深入疑问,欢迎继续提问!
子类继承与 __slots__
子类无额外定义 __slots__
即使父类已经定义了 __slots__
,子类的行为可能会有所不同。
例子:子类 OpenPixel
class Pixel:
__slots__ = ('x', 'y')
class OpenPixel(Pixel):
pass
op = OpenPixel()
print(op.__dict__) # 输出空字典 {}
op.x = 8
print(op.__dict__) # 默认仍使用父类中的 __slots__ : {}
print(op.x) # 正常访问 slot-defined 属性: 8
op.color = 'green' # 子类中定义的新属性存储在 __dict__ 中
print(op.__dict__) # 输出 {'color': 'green'}
关键点:
- 如果子类没有定义
__slots__
,子类实例将拥有__dict__
,允许动态设置未在父类__slots__
中声明的属性。 - 因此,子类中动态添加的属性会存在内存分配上的开销,破坏了
__slots__
的优化效果。
why
class Pixel:
__slots__ = ('x', 'y')
# 实例化 Pixel 类
p = Pixel()
# 设置允许的属性
p.x = 10
p.y = 20
class OpenPixel(Pixel):
pass
op = OpenPixel()
print(op.__dict__) # 输出空字典 {}
op.x = 8
print(op.__dict__) # 默认仍使用父类中的 __slots__ : {}
print(op.x) # 正常访问 slot-defined 属性: 8
op.color = 'green' # 子类中定义的新属性存储在 __dict__ 中
print(op.__dict__) # 输出 {'color': 'green'}
1.为什么之前的
# 检查 __dict__ 属性是否存在
print(p.__dict__) # 抛出 AttributeError: 'Pixel' object has no attribute '__dict__'
现在这里,又不报错了?
print(op.__dict__) # 输出空字典 {}
op.x = 8
print(op.__dict__) # 默认仍使用父类中的 __slots__ : {}
2.都是赋值
op.x = 8
print(op.__dict__) # 默认仍使用父类中的 __slots__ : {}
print(op.x) # 正常访问 slot-defined 属性: 8
op.color = 'green' # 子类中定义的新属性存储在 __dict__ 中
print(op.__dict__) # 输出 {'color': 'green'}
为什么一个op.__dict__
就打印出{‘color’: ‘green’},而另一个没打印出来{‘x’: 8}?
3.父类和子类的slots有相同元素,那么如果赋值的话,是给哪个赋值。
4.为什么c.__dict__
就会报错,Child.__dict__
就不会报错 (1class Child; c = Child()
)
解答
好的,这两个问题是Python面试和工程开发中关于__slots__
和实例属性行为的高频疑惑,背后涉及了类继承、属性存储和内存模型。
1. 为什么父类有__slots__
时实例(p)没有__dict__
,而子类没声明__slots__
的实例(op)却有__dict__
1.1 __slots__
与__dict__
机制简述
- 普通类: 每个实例分配
__dict__
,属性存于dict中,随时可加新属性。 - 声明了
__slots__
的类: 只有slots定义的属性能被赋值,实例不会自动分配__dict__
,存储位置是底层slots(不是dict)。
1.2 继承链下slots的行为(关键)
- 子类要延续slots属性,不留dict,必须显式声明__slots__(哪怕是空元组)。
- 如果子类未声明
__slots__
,Python出于兼容考虑,对子类实例恢复了__dict__
,可以动态加属性。 - 不会因为父类有slots,子类就自动沿用slots机制! 子类需要主动声明。
1.3 对应例子解释
class Pixel:
__slots__ = ('x', 'y')
p = Pixel()
print(p.__dict__) # 抛出异常 AttributeError:没有__dict__
Pixel
实例只分配了slots区域,没有dict。- 不能动态加属性,也访问不到
__dict__
,相关的vars(p)
等全都会出错。
class OpenPixel(Pixel):
pass
op = OpenPixel()
print(op.__dict__) # 输出 {}
OpenPixel
没有自己声明__slots__
,所以恢复了__dict__。- 尽管父类是slots类,但只要子类未声明,slots机制“断裂”,子类实例又有属性字典了。
- 一切动态属性都可以用,性能优化和内存缩减也就失效了!
1.4 工程/面试总结
- 想让子类锁死slots机制,子类必须显式声明
__slots__
(可以为()
)。 - 如果漏掉
__slots__
,子类行为就回到常规类,实例还是有__dict__
。
例子
class Base:
__slots__ = ('foo',)
class GoodChild(Base):
__slots__ = ('bar',) # 继承+扩展slots,无__dict__
class BadChild(Base):
pass # 没声明slots,有__dict__
print(hasattr(GoodChild(), '__dict__')) # False
print(hasattr(BadChild(), '__dict__')) # True
2. 为什么slots属性赋值看不到,而动态属性却能在__dict__
中看到?
2.1 slots属性与__dict__
的本质差异
- slots属性:底层分配了额外的固定内存槽,属性直接存在特定的slot存储区域。不会出现在
__dict__
里! - 额外/dict属性:所有类未定义在slots中的属性,一律塞进
__dict__
。
技术细节
- 赋值
op.x = 8
:由于x属于父类Pixel的slots声明,属性值存进了底层slots空间。 op.__dict__
一开始为空,因为还没新增任何非slots属性。- 赋值
op.color = 'green'
:因为OpenPixel自己没声明slots,允许任意新属性,color属性被直接存在__dict__
。
结果:
op.__dict__
永远看不到x/y,只能看到非slot定义的属性!- slot属性只能通过直接
getattr
访问,无内置统一数据字典展示。
例子验证
class Pixel:
__slots__ = ('x', 'y')
class OpenPixel(Pixel):
pass
op = OpenPixel()
op.x = 8
print(op.__dict__) # {}
op.color = 'green'
print(op.__dict__) # {'color': 'green'}
2.2 如何查看所有slots属性的值?slots + __dict__混合属性枚举技巧
由于slots属性和__dict__
不是一个机制,要查看全部属性,得两步走:
- 遍历类继承链中的slots声明并用
getattr(obj, slot)
- 检查
__dict__
中所有非slots属性
for cls in op.__class__.__mro__:
if '__slots__' in cls.__dict__:
for slot in cls.__slots__:
print(slot, getattr(op, slot, None))
print(op.__dict__) # 打印所有非slot属性
2.3 面试&工程师要点总结
- slots属性永远不会出现在
__dict__
。 - 非slots属性才会进
__dict__
。 - 动态属性建议彻底禁用时必须每层都声明slots,避免slots被子类破坏。
3. 父类和子类的slots有相同元素,那么如果赋值的话,是给哪个赋值。
- Base 类:
__slots__ = ('foo',)
- Child 类:
__slots__ = ('foo',)
(和父类重名)
如果子类的 __slots__
与父类有一样的名字,比如 foo
,我们实例化子类后:
class Base:
__slots__ = ('foo',)
class Child(Base):
__slots__ = ('foo',) # 注意又声明了一遍'foo'
c = Child()
c.foo = 42
那么,这个 foo
到底存在哪里?父类的槽,还是子类的槽,还是其它什么?
原理解析
3.1 __slots__
的底层实现
__slots__
依赖于 描述符 和底层 C 结构(PyMemberDef
)。每个包含 __slots__
的类,Python 解释器会为它生成一个“槽描述符”对象,功能类似于:
<member 'foo' of 'Base' objects>
<member 'foo' of 'Child' objects>
当子类 __slots__
再次声明了 "foo"
,Python 不会合并父、子类的 foo,而是分别在父、子类的类型对象中建立两个不同的描述符,指向实例内部的两个独立空间。
也就是说,子类有一个自己的
foo
槽,父类也有一个自己的foo
槽,它们是俩不同的槽!
3。2 赋值时的解析顺序
实例属性查找顺序是:
- 类,从最近向上查找,找到第一个有
foo
的槽描述符。 - 使用这个描述符对象来进行存取操作。
所以当你执行 c.foo = 42
时:
- 解释器从
Child
的类型对象找foo
,发现它有(自己的__slots__
里声明了),就把这个foo
槽分配给你存值; - 父类的
foo
(Base.__slots__['foo']
) 就没用上。
甚至于:这两个槽严重冗余,不能共享,也互不影响!!!
让我们做个验证实验:
class Base:
__slots__ = ('foo',)
class Child(Base):
__slots__ = ('foo',)
c = Child()
print(Child.__slots__) # ('foo',)
print(Base.__slots__) # ('foo',)
# 槽描述符对象
print(Child.__dict__['foo'])
print(Base.__dict__['foo'])
print(Child.__dict__['foo'] is Base.__dict__['foo']) # False
c.foo = '子类foo'
print(c.foo) # 子类foo
# 通过父类描述符直接访问父类的foo槽
Base.__dict__['foo'].__set__(c, '父类foo')
print(Base.__dict__['foo'].__get__(c)) # 父类foo
print(Child.__dict__['foo'].__get__(c)) # 子类foo
输出示例:
('foo',)
('foo',)
<member 'foo' of 'Child' objects>
<member 'foo' of 'Base' objects>
False
子类foo
父类foo
子类foo
3.3 结论
当父子类都声明了
__slots__ = ('foo',)
,它们各自生成独立的槽(内存位置),赋值时优先用子类的槽,父类的那个其实被“隐藏”了。但是,利用描述符对象还能访问到父类的那个槽(比如Base.__dict__['foo'].__get__(obj)
)。
推荐:实际开发中,避免父子类 slots 名重复,避免迷惑和难以调试的行为。
4. 为什么c.__dict__就会报错,Child.__dict__就不会报错 (1class Child; c = Child())
4.1 为什么 Child.__dict__
不会报错?
Child.__dict__
Child
是一个“类对象”,所有的类对象都总是有一个字典属性,用于存放类的属性、方法、描述符等,即类命名空间字典。- 这个
__dict__
,类型其实是mappingproxy
,是只读的映射视图,不直接反映 slots 机制。
举例:
class Base:
__slots__ = ('foo',)
class Child(Base):
__slots__ = ('bar',)
print(type(Child.__dict__)) # <class 'mappingproxy'>
总结:
类.__dict__
总是有!- 不管有无 slots,永远存在。
4.2 为什么 c.__dict__
会报错?
c 是实例对象(Child的实例)
当一个类有 __slots__
并且没有在 __slots__
中包含 '__dict__'
,该类的实例不再具有「属性字典」。
__dict__
属性只会在需要时由解释器给实例分配,(例如你没有声明 slots,或在 slots 里显式加上'__dict__'
)。
所以当你访问:
c = Child()
c.__dict__ # 报错
会报错:
AttributeError: ‘Child’ object has no attribute ‘dict’
原因:实例对象无法动态添加属性,也没有实例属性字典(节省内存),你不能通过 c.__dict__
获得或注入属性。
4.3 小结
类.__dict__
:类的所有命名空间(属性、方法、描述符),始终存在。实例.__dict__
:实例的动态属性字典,只有slots 没被用或slots 包含'__dict__'
才有。不然不存在,访问就报错。
4.4 补充代码验证
class Base:
__slots__ = ('foo',)
class Child(Base):
__slots__ = ('bar',)
obj = Child()
print(Child.__dict__) # OK,无论如何都能用
print(hasattr(obj, '__dict__')) # False
# print(obj.__dict__) # AttributeError!
如果这样写:
class Child(Base):
__slots__ = ('__dict__', 'bar',)
再试:
obj = Child()
print(obj.__dict__) # OK, 现在有了
添加动态属性与弱引用支持
动态属性
出于某些需求,你可能希望同时享受 __slots__
的内存优化和能动态设置属性的灵活性。此时,可以将 '__dict__'
加入 __slots__
。
例子:允许动态属性
class FlexiblePixel:
__slots__ = ('x', 'y', '__dict__')
fp = FlexiblePixel()
fp.x = 3
fp.y = 5
fp.color = 'red' # 动态设置的属性存储在 __dict__
print(fp.__dict__) # 输出 {'color': 'red'}
尽管这样可以同时支持 __slots__
和动态属性,但这样做可能会削弱内存节省效果,因此建议根据实际需求权衡。
支持弱引用
如果你的对象需要被弱引用(常见于缓存优化场景中),则需要在 __slots__
中包含 '__weakref__'
。
例子:支持弱引用
import weakref
class WeakRefExample:
__slots__ = ('name', '__weakref__') # 添加 __weakref__
obj = WeakRefExample()
obj.name = "Demo"
ref = weakref.ref(obj)
print(ref()) # 输出对象本身
性能和内存测试
性能与内存优化对比
通过对比无 __slots__
与有 __slots__
的版本,可以看到显著的性能提升和内存节约。
测试脚本:创建 10,000,000 个对象
$ python3 mem_test.py vector2d_v3
Selected Vector2d type: vector2d_v3.Vector2d
Final RAM usage: 1,666,535,424 # 约 1.55 GiB
$ python3 mem_test.py vector2d_v3_slots
Selected Vector2d type: vector2d_v3_slots.Vector2d
Final RAM usage: 577,839,104 # 约 551 MiB
结果分析:
- 启用
__slots__
节省了超过 65% 的内存使用。 __slots__
版本更快,因为减少了动态操作和字典操作的开销。
使用 __slots__
的注意事项
- 如果子类需要维持内存优化行为,必须显式在子类中声明
__slots__
。 - 当需要支持动态属性时,可以在
__slots__
中包含'__dict__'
,但这可能破坏原本的优化效果。 - 为支持弱引用,应在
__slots__
中加入'__weakref__'
。 - 一定要权衡实际需求,避免 “过度优化”——特别是如果代码需要频繁动态添加属性,
__slots__
可能并不适合。
11、Overriding Class Attributes
1. 概念简介
1.1 类属性和实例属性
Python 类属性和实例属性之间的行为区别是理解 OOP(面向对象编程)的核心知识之一:
- 类属性:绑定到类本身,所有实例共享该属性。
- 实例属性:绑定到具体对象(实例),是该对象独有的。
默认行为
- 如果尝试读取一个不存在的实例属性,Python 会继续在类属性中查找。
- 如果为一个不存在的属性赋值(即写入),Python 会创建 该实例 的一个新属性,并不会影响原有的类属性。
1.2 使用默认的类属性 & 属性覆盖
将类中的属性设为 “默认值” 是 Python 中常用的设计模式。实例通常会继承类的属性,除非显式在实例中定义了新的属性。覆盖行为一旦发生,实例的同名属性就会 “遮盖” 类属性,即后续对属性的读操作将仅访问实例属性。
特性
- 覆盖并不会改变类属性,类的其他实例仍然使用类属性的默认值。
- 如果想更改类属性,必须通过类本身操作,而不是具体的实例。
2. 示例 11-18:自定义实例的 typecode
属性
示例代码(重现原文)
>>> from vector2d_v3 import Vector2d
>>> v1 = Vector2d(1.1, 2.2) # 创建一个 Vector2d 实例,默认 `typecode` 为 'd'
>>> dumpd = bytes(v1) # 导出 v1 向量的字节序列
>>> dumpd
b'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@'
>>> len(dumpd) # 字节序列长度
17
>>> v1.typecode = 'f' # 修改实例的 `typecode` 属性为 'f' (4 字节单精度浮点数)
>>> dumpf = bytes(v1) # 再次导出字节序列
>>> dumpf
b'f\xcd\xcc\x8c?\xcd\xcc\x0c@'
>>> len(dumpf) # 新的字节序列长度
9
>>> Vector2d.typecode # 确保类属性未受影响
'd'
2.1 示例运行过程分析
-
实例初始化:
- 创建
v1
时,实例是没有名为typecode
的属性的,因此默认使用类的typecode
属性,即'd'
。
- 创建
-
bytes 输出:
Vector2d
对象通过bytes
方法导出,它读取的是当前对象的typecode
。默认情况下导出的字节序列前缀为'd'
表示双精度浮点数,并且字节长度为 17。
-
覆盖
typecode
:- 给
v1
赋值typecode = 'f'
。此时,v1
的实例属性typecode
被创建并覆盖了类的同名属性。 - 在重新调用
bytes()
时,此时使用的是实例的typecode
'f'
,表示单精度浮点数,最终产生更短的字节序列(9 字节)。
- 给
-
验证不影响类和其他实例:
- 打印
Vector2d.typecode
才会发现,类属性'd'
不受实例覆盖的影响。 - 类的其他实例则继续继承默认的类属性
'd'
。
- 打印
2.2 注意事项与常见易错点
-
写操作创建实例属性:
如果实例中本不存在某个属性,你直接对该属性赋值,就会新建一个实例属性,并不会更改类本身。>>> v2 = Vector2d(3, 4) >>> v2.typecode # 继承类属性 'd' >>> v2.typecode = 'f' # 覆盖类属性 >>> v2.typecode # 实例独有了 'f',对其他实例没有影响 'f'
-
确保类属性不意外被误动作更改:
如果你需要修改类的默认行为,必须直接通过类名来修改:>>> Vector2d.typecode = 'f' >>> v3 = Vector2d(5, 6) >>> v3.typecode 'f'
-
实例和类的行为独立性:
类比动态语言的特性,实例的行为本质是可以与类脱钩的,因此可以灵活地调整实例化对象的功能。
2.3 实际工程应用场景:数据导出格式动态调整
typecode
是一个控制导出格式的属性。通过这样的设计方式,程序实现了数据导出格式的动态可配置性:
- 某些实例需要存储精确数据(使用双精度浮点),而其他实例可能只需要更小内存的近似表示(使用单精度浮点)。
- 基于实例特性的动态修改,是软件工程中追求灵活性和资源优化的体现。
3. 示例 11-19:通过子类实现统一的覆盖
在示例 11-18 中,我们通过设置实例的 typecode
动态修改了个别实例的行为。但有时,我们需要对一组对象的默认行为作出统一更新。这种情况下,可以通过 子类化 来实现。
示例代码(重现原文)
>>> from vector2d_v3 import Vector2d
>>> class ShortVector2d(Vector2d): # 子类化 Vector2d,并更改默认 typecode
... typecode = 'f'
...
>>> sv = ShortVector2d(1 / 11, 1 / 27)
>>> sv
ShortVector2d(0.09090909090909091, 0.037037037037037035)
>>> len(bytes(sv))
9
3.1 示例运行过程分析
-
子类化目的:
- 通过创建子类
ShortVector2d
,我们重写了typecode
的默认值(从'd'
改为'f'
)。 - 子类的所有实例都默认导出单精度浮点数。
- 通过创建子类
-
动态类名反映:
- 在
Vector2d
的__repr__
方法中,我们通过type(self).__name__
动态读取当前类名。这种方式避免了手动硬编码类名,使得 子类的类型信息正确反映 到其表现中。
def __repr__(self): class_name = type(self).__name__ # 动态读取类名字 return '{}({!r}, {!r})'.format(class_name, self.x, self.y)
- 在
3.2 使用子类调整默认行为的优点
- 代码更简洁:通过子类化,一次性调整默认行为,不需要逐一修改每个实例。
- 保持默认类稳定性:不直接更改基类,保证原本
Vector2d
的通用适用性。
4. 额外实践和延伸例子
区分实例属性和类属性
以下代码举例说明类与实例属性的不同层级:
>>> class Example:
... class_attr = 'I am a class attribute'
...
>>> obj1 = Example()
>>> obj2 = Example()
>>> obj1.class_attr # 实例继承类属性
'I am a class attribute'
# 修改类属性会同步影响所有实例
>>> Example.class_attr = 'Changed globally'
>>> obj1.class_attr
'Changed globally'
# 为实例创建同名属性,则不再受类属性影响
>>> obj1.class_attr = 'I am unique to obj1'
>>> obj1.class_attr # 实例的独立属性
'I am unique to obj1'
>>> obj2.class_attr # 未修改的实例仍然依赖类属性
'Changed globally'
12、章节总结
本章旨在展示在构建一个行为良好的Python风格类时,特殊方法和约定的使用。
vector2d_v3.py
(见示例11 - 11)中的Vector2d
类比vector2d_v0.py
(见示例11 - 2)中的更符合Python风格吗?vector2d_v3.py
中的Vector2d
类确实展现出了更多的Python特性。但Vector2d
的最初实现和最终实现哪个更合适,取决于其使用的场景。蒂姆·彼得斯(Tim Peters)的“Python之禅”说道:
简单胜于复杂。
一个对象应该根据需求尽可能简单,而不是堆砌语言特性。如果代码是用于应用程序,那么它应该专注于满足最终用户所需的功能,而不是更多。如果代码是用于供其他程序员使用的库,那么实现一些特殊方法来支持Python开发者期望的行为是合理的。例如,__eq__
方法对于支持业务需求可能并非必要,但它使类更易于测试。
我扩展Vector2d
代码的目的是为讨论Python特殊方法和编码约定提供背景。本章的示例展示了我们在第1章表1 - 1中首次见到的几个特殊方法:
- 字符串/字节表示方法:
__repr__
、__str__
、__format__
和__bytes__
。 - 将对象转换为数字的方法:
__abs__
、__bool__
和__hash__
。 __eq__
运算符:用于支持测试和哈希(与__hash__
一起)。
(所在页码:391 | 章节总结 )
在支持转换为字节的同时,我们还实现了一个替代构造函数Vector2d.frombytes()
,这为讨论@classmethod
装饰器(非常实用)和@staticmethod
装饰器(不太实用,模块级函数更简单)提供了背景。frombytes
方法的灵感来自于array.array
类中的同名方法。
我们发现,通过实现__format__
方法,可以扩展格式化规范迷你语言。该方法解析传递给内置format(obj, format_spec)
函数的format_spec
,或者解析f字符串中'{:«format_spec»}'
替换字段,以及str.format()
方法使用的字符串中的format_spec
。
为了使Vector2d
实例可哈希,我们努力使它们不可变,至少通过将x
和y
属性设为私有并将其作为只读属性公开来防止意外更改。然后,我们使用推荐的方法,对实例属性的哈希值进行异或运算,实现了__hash__
方法。
接着,我们讨论了在Vector2d
中声明__slots__
属性的内存节省效果和注意事项。由于使用__slots__
有副作用,只有在处理大量实例(比如数百万个实例,而不仅仅是数千个)时才有意义。在许多这种情况下,使用pandas
可能是最佳选择。
我们最后讨论的主题是通过实例(例如self.typecode
)访问时对类属性的覆盖。我们首先通过创建实例属性来实现,然后通过子类化并在类级别进行覆盖。
在本章中,我多次提到示例中的设计选择是如何通过研究标准Python对象的API得出的。如果用一句话来总结本章内容,那就是:
要构建符合Python风格的对象,就观察真实Python对象的行为。
—— 中国古代谚语