首页/Python 与数据实践/魔术方法全景
Python 与数据实践 · 第 08 章

魔术方法全景

平时写 Python,我们其实一直在和一套"看不见的约定"打交道:print(obj) 能打印出东西、obj[0] 能按下标取值、a + b 能做加法、len(obj) 能算长度。这些写法之所以对自定义类也照样好使,是因…

约 32 分钟12,959 字21 个代码示例

平时写 Python,我们其实一直在和一套"看不见的约定"打交道:print(obj) 能打印出东西、obj[0] 能按下标取值、a + b 能做加法、len(obj) 能算长度。这些写法之所以对自定义类也照样好使,是因为 Python 会去对象身上找一批名字很特殊的方法——它们都以双下划线开头、双下划线结尾,比如我们已经非常熟悉的 __init__。这类方法俗称魔术方法(Magic Methods),也有人叫它们双下方法(dunder methods)。

魔术方法有一个共同脾气:几乎从不被你手动调用,全部由解释器在特定时机自动触发。你写 a + b,Python 背后找的是 a.__add__;你写 len(a),Python 找的是 a.__len__;你写 print(a),Python 找的是 a.__str__。换句话说,魔术方法就是你自定义类与 Python 语法之间的"接线端子"——接对了线,你的对象就能无缝融入这门语言的各种语法里。

这一章我们沿着"一个对象从出生到销毁"的时间线,把这些端子挨个接一遍:先弄清对象是怎么被造出来的(__new__、__init__),再看对象怎么才能被当函数调用(__call__),然后依次展开字符串表示、比较、算术、容器、类型转换、属性拦截,最后送别对象(__del__)。


1. 对象的诞生:先造壳,再装修

平时写 Employee("阿明", 7),感觉是一步完成的,实际上解释器背地里分了两步走:

  • 第一步,调用 __new__:负责向内存要一块空间,把对象的"空壳"造出来;
  • 第二步,调用 __init__:拿到这个壳之后,往里面填属性,完成"装修"。

我们完全可以亲手把这套流程模拟一遍:

def simulate_instantiation(cls, *args, **kwargs):
    # 第一步:请 __new__ 造出空壳(毛坯对象)
    obj = cls.__new__(cls, *args, **kwargs)

    # 第二步:只有当这个壳确实是 cls(或其子类)的实例时,才会进行装修
    if isinstance(obj, cls):
        obj.__init__(*args, **kwargs)

    # 第三步:交出成品
    return obj


class Employee:
    def __init__(self, name, level):
        self.name = name
        self.level = level

    def report(self):
        print(f"我是 {self.name},职级 L{self.level}")


emp = simulate_instantiation(Employee, "阿明", 7)
emp.report()  # 我是 阿明,职级 L7

注意上面那个 isinstance 检查,它不是摆设——__new__ 是有机会"不听话"的,它可以返回一个根本不属于当前类的对象。遇到这种情况,__init__ 会被直接跳过。这个细节正是下一节几个巧妙玩法的根基。

2. 重写 __new__ 的三种经典玩法

2.1 全局只此一个:单例模式

有些东西天然只该存在一份,比如全局配置。让类管住自己:第一次有人来要实例,就造一个存好;之后再来要,直接把存货递出去。

class AppConfig:
    _sole = None

    def __new__(cls):
        if cls._sole is None:
            cls._sole = super().__new__(cls)
        return cls._sole


cfg_a = AppConfig()
cfg_b = AppConfig()
print(cfg_a is cfg_b)  # True —— 无论要多少次,给的都是同一个对象

2.2 造过就不重造:对象池

单例是"一个类一个对象",再进一步就是"同一把钥匙开同一个锁":以参数为钥匙,造过的对象缓存起来,重复请求直接复用。

class TileFactory:
    """游戏里的地砖工厂:同一种地砖只生产一次,之后反复复用"""

    _pool = {}

    def __new__(cls, tile_id):
        if tile_id not in cls._pool:
            cls._pool[tile_id] = super().__new__(cls)
        return cls._pool[tile_id]


grass_a = TileFactory("grass")
grass_b = TileFactory("grass")
water = TileFactory("water")

print(grass_a is grass_b)  # True —— 草地砖是同一块
print(grass_a is water)    # False —— 水地砖另造了一块

2.3 偷梁换柱:让 __new__ 返回别的东西

__new__ 并不强制返回本类实例。一旦它返回了"外人",__init__ 就不会执行——我们可以利用这一点做数据兜底:

class ScoreInt:
    """百分制分数:传入负数时,不报错,直接退回一个普通的 0"""

    def __new__(cls, value):
        if value < 0:
            return 0  # 注意:这是 int 类型的 0,不是 ScoreInt 的实例
        return super().__new__(cls)

    def __init__(self, value):
        print("ScoreInt.__init__ 执行了")
        self.value = value


good = ScoreInt(88)
print(type(good), good.value)  # <class 'ScoreInt'> 88

bad = ScoreInt(-5)
print(type(bad), bad)          # <class 'int'> 0,且上面那行 __init__ 的打印没出现!

3. 让实例像函数一样被调用:__call__

Python 里有个词叫可调用对象(Callable),指一切能紧跟一对括号 () 的东西。用内置函数 callable() 可以当场验明正身:

print(callable(len))          # True,内置函数
print(callable(dict))         # True,类本身也是可调用的(调用它就是造实例)
print(callable([1, 2]))       # False,列表不能调用
print(callable(lambda x: x))  # True,lambda 表达式

函数是天然的 callable,类也是(所以 Employee("阿明", 7) 本质上是在"调用"这个类)。但普通实例默认不是 callable——想让它也能被一对括号调用,就得在类里实现 __call__:

class Calibrator:
    """仪器校准器:给每次读数加上一个固定偏移量"""

    def __init__(self, offset):
        self.offset = offset

    def __call__(self, reading):
        return reading + self.offset


fix = Calibrator(-2)
print(callable(fix))  # True —— 从此实例也" callable "了
print(fix(100))       # 98
print(fix(50))        # 48

这里务必建立一个直觉:fix(100) 和 Calibrator.__call__(fix, 100) 是同一件事。括号里的参数原封不动传给 __call__。

3.1 玩法一:造出"预配置"的函数

闭包能干的事,__call__ 都能干,而且状态放在实例身上,更直观:

class Resizer:
    """图片缩放器:出厂时定好缩放比例"""

    def __init__(self, ratio):
        self.ratio = ratio

    def __call__(self, pixel):
        return round(pixel * self.ratio)


shrink = Resizer(0.5)
enlarge = Resizer(2)

print(shrink(1920))   # 960
print(enlarge(1920))  # 3840

3.2 玩法二:自带记忆的回调

函数每次被调用都是"失忆"的,而实例能记住历史。做一个带计数器的审计日志就是几行代码的事:

class AuditTrail:
    def __init__(self, tag):
        self.tag = tag
        self.entries = 0

    def __call__(self, message):
        self.entries += 1
        print(f"[{self.tag}] 第{self.entries}条:{message}")


audit = AuditTrail("支付")
audit("订单已创建")    # [支付] 第1条:订单已创建
audit("扣款成功")      # [支付] 第2条:扣款成功

4. 把对象打印得体面一点:__str__ 与 __repr__

不实现这两个方法时,print(obj) 吐出来的是类似 <__main__.Employee object at 0x10c8b2f10> 的东西,对人极不友好。两个方法各管一摊:

class Movie:
    def __init__(self, title, minutes):
        self.title = title
        self.minutes = minutes

    def __str__(self):
        """给普通用户看:说人话"""
        return f"《{self.title}》片长 {self.minutes} 分钟"

    def __repr__(self):
        """给开发者看:最好能直接复制粘贴来重建对象"""
        return f"Movie({self.title!r}, {self.minutes})"


film = Movie("星际漫游", 148)
print(film)        # 《星际漫游》片长 148 分钟 —— 走的是 __str__
print(str(film))   # 同上
print(repr(film))  # Movie('星际漫游', 148) —— 走的是 __repr__
# 在交互式解释器里直接敲变量名回车,显示的也是 __repr__ 的结果

实践建议:两个都写。如果图省事只写一个,那就写 __repr__——因为没找到 __str__ 时,Python 会退回来用 __repr__ 顶上;反过来则不行。

5. 让对象支持比较:==、< 一家人

想让两个自定义对象能比大小,就把对应的方法补齐:

运算符 魔术方法
== __eq__
!= __ne__
< __lt__
<= __le__
> __gt__
>= __ge__
class Staff:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def __eq__(self, other):
        if not isinstance(other, Staff):
            return NotImplemented  # 告诉 Python:"比不了",让它另想办法
        return self.salary == other.salary

    def __lt__(self, other):
        if not isinstance(other, Staff):
            return NotImplemented
        return self.salary < other.salary

    def __le__(self, other):
        return self < other or self == other

    def __gt__(self, other):
        return not self <= other

    def __ge__(self, other):
        return not self < other

    def __ne__(self, other):
        return not self == other

    def __repr__(self):
        return f"Staff({self.name!r}, {self.salary})"


lao_wang = Staff("老王", 12000)
xiao_li = Staff("小李", 9500)

print(lao_wang == xiao_li)  # False —— 薪资不同
print(lao_wang > xiao_li)   # True
print(lao_wang <= xiao_li)  # False

# 比较方法就位后,sorted 也能直接用了
team = [xiao_li, lao_wang]
print(sorted(team))  # [Staff('小李', 9500), Staff('老王', 12000)]

这里出现的 NotImplemented 值得多说一句:它不是异常,而是一个信号,意思是"本对象不认识对方,没法比",Python 收到信号后会转而尝试对方的反向方法(比如 b.__gt__(a))。拿它和 NotImplementedError 区分清楚——后者是异常,表示"这功能还没写"。

偷懒的办法:total_ordering

六个比较方法全写一遍确实啰嗦。标准库提供了 @functools.total_ordering,只要给出 __eq__ 和剩下任意一个(通常是 __lt__),其余四个它会自动帮你推导:

from functools import total_ordering


@total_ordering
class Staff:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def __eq__(self, other):
        if not isinstance(other, Staff):
            return NotImplemented
        return self.salary == other.salary

    def __lt__(self, other):
        if not isinstance(other, Staff):
            return NotImplemented
        return self.salary < other.salary

    def __repr__(self):
        return f"Staff({self.name!r}, {self.salary})"

6. 让对象参与四则运算

同样一套思路,把运算符也接管过来。以平面位移为例:

class Move:
    """机器人在平面上的位移(横向 dx,纵向 dy)"""

    def __init__(self, dx, dy):
        self.dx = dx
        self.dy = dy

    def __add__(self, other):
        """+:位移叠加"""
        if isinstance(other, Move):
            return Move(self.dx + other.dx, self.dy + other.dy)
        return NotImplemented  # 交给 Python 去试 other 的 __radd__

    def __sub__(self, other):
        """-:位移相减"""
        if isinstance(other, Move):
            return Move(self.dx - other.dx, self.dy - other.dy)
        return NotImplemented

    def __mul__(self, factor):
        """*:位移 × 倍数"""
        if isinstance(factor, (int, float)):
            return Move(self.dx * factor, self.dy * factor)
        return NotImplemented

    def __rmul__(self, factor):
        """右乘:倍数 × 位移(3 * m 时,int 不知道怎么乘 Move,就反过来找这里)"""
        return self * factor  # 直接复用 __mul__

    def __truediv__(self, factor):
        """/:位移 ÷ 倍数"""
        if isinstance(factor, (int, float)):
            return Move(self.dx / factor, self.dy / factor)
        return NotImplemented

    def __neg__(self):
        """取反:-m,方向调转 180 度"""
        return Move(-self.dx, -self.dy)

    def __abs__(self):
        """abs(m):位移的直线距离"""
        return (self.dx ** 2 + self.dy ** 2) ** 0.5

    def __repr__(self):
        return f"Move({self.dx}, {self.dy})"


step_a = Move(1, 2)
step_b = Move(3, 4)

print(step_a + step_b)   # Move(4, 6)
print(step_b - step_a)   # Move(2, 2)
print(step_a * 3)        # Move(3, 6)
print(2 * step_a)        # Move(2, 4) —— int 不会乘 Move,触发的是 __rmul__
print(-step_a)           # Move(-1, -2)
print(abs(step_a))       # 2.23606797749979

__rmul__ 这类"反向"方法容易被忽略:当左操作数不认识右操作数时(比如 2 * step_a,整数类型可不知道怎么乘一个 Move),Python 会退一步,试试右操作数的 __rmul__。这就是上面 2 * step_a 能跑通的原因。

常用算术魔术方法对照表:

写法 魔术方法 含义
a + b __add__ 加
a - b __sub__ 减
a * b __mul__ 乘
a / b __truediv__ 真除法
a // b __floordiv__ 整除
a % b __mod__ 取余
a ** b __pow__ 幂
+a __pos__ 正号
-a __neg__ 负号
abs(a) __abs__ 绝对值

7. 让对象像容器一样工作

列表、字典之所以能 len()、取下标、in 判断、for 遍历,靠的也是一组魔术方法,合称容器协议。自定义类照着实现,就能获得同等待遇:

class Playlist:
    """播放列表:对外的用法和列表几乎一模一样"""

    def __init__(self):
        self._tracks = []

    def add(self, track):
        self._tracks.append(track)

    def __len__(self):
        """len(playlist)"""
        return len(self._tracks)

    def __getitem__(self, index):
        """playlist[index],天然支持切片"""
        return self._tracks[index]

    def __setitem__(self, index, track):
        """playlist[index] = track"""
        self._tracks[index] = track

    def __delitem__(self, index):
        """del playlist[index]"""
        del self._tracks[index]

    def __contains__(self, track):
        """track in playlist"""
        return track in self._tracks

    def __iter__(self):
        """for track in playlist"""
        return iter(self._tracks)

    def __repr__(self):
        return f"Playlist({self._tracks!r})"


my_list = Playlist()
my_list.add("晴天")
my_list.add("海阔天空")
my_list.add("夜曲")

print(len(my_list))          # 3
print(my_list[0])            # 晴天
print(my_list[1:])           # ['海阔天空', '夜曲'] —— 切片也没问题
print("夜曲" in my_list)     # True

for track in my_list:
    print(track)
# 晴天
# 海阔天空
# 夜曲

8. 类型转换三件套

int()、float()、bool() 这三个内置转换同样有对应的魔术方法可接:

class Percent:
    """百分比数值,比如 85.5 表示 85.5%"""

    def __init__(self, value):
        self.value = value

    def __int__(self):
        """int(p):抹掉小数取整"""
        return int(self.value)

    def __float__(self):
        """float(p):转成浮点数"""
        return float(self.value)

    def __bool__(self):
        """bool(p):0% 视为 False,其余为 True"""
        return self.value != 0

    def __repr__(self):
        return f"Percent({self.value})"


p = Percent(85.5)
print(int(p))    # 85
print(float(p))  # 85.5
print(bool(p))   # True

print(bool(Percent(0)))  # False

__bool__ 还有个隐藏福利:任何需要真假判断的场合(if obj:)都会用到它。顺带一提,如果只实现 __len__ 不实现 __bool__,Python 会用 len(obj) != 0 来顶替真假判断。

9. 属性访问的"安检门"

对象的属性读取、赋值、删除,其实都能被拦下来做检查。三个方法各守一道门:

  • __getattr__:访问不存在的属性时触发(兜底);
  • __setattr__:任何属性赋值时触发;
  • __delattr__:删除属性时触发。

一个把属性收进字典管理的会话对象:

class Session:
    def __init__(self):
        # 注意:这里必须借道 object.__setattr__,否则会陷入无限递归
        object.__setattr__(self, "_store", {})

    def __getattr__(self, name):
        if name in self._store:
            return self._store[name]
        raise AttributeError(f"'{type(self).__name__}' 身上没有 '{name}' 这个属性")

    def __setattr__(self, name, value):
        if name.startswith("_"):
            # 下划线开头的内部属性走正常通道,绕开拦截,避免递归
            object.__setattr__(self, name, value)
        else:
            self._store[name] = value

    def __delattr__(self, name):
        if name in self._store:
            del self._store[name]
        else:
            raise AttributeError(f"'{type(self).__name__}' 身上没有 '{name}' 这个属性")

    def __repr__(self):
        return f"Session({self._store!r})"


sess = Session()
sess.user = "阿明"     # 触发 __setattr__,存入 _store
sess.vip = True
print(sess.user)       # 阿明 —— 触发 __getattr__
del sess.vip           # 触发 __delattr__
print(sess)            # Session({'user': '阿明'})

重点警告: __setattr__ 会拦下所有赋值动作,包括它自己内部写的 self.xxx = value——那会再次触发 __setattr__,于是套娃式递归直到爆栈。所以内部赋值必须改走 object.__setattr__(self, name, value) 这条直达通道。

__getattr__ 与 __getattribute__ 的分工

还有个更底层的 __getattribute__,两者名字只差一截,行为却大不相同:

  • __getattr__:只在属性找不到时才被调用(救场队员);
  • __getattribute__:每一次属性访问都会先路过它(总闸门)。
class Tracer:
    def __init__(self):
        self.present = 100

    def __getattribute__(self, name):
        # 总闸门:无论访问什么属性,都会先到这里报到
        print(f"有代码正在读取属性: {name}")
        # 必须借道 object.__getattribute__,否则自己访问自己,递归到死
        return object.__getattribute__(self, name)

    def __getattr__(self, name):
        # 能走到这里,说明 __getattribute__ 没找到,来问我要个说法
        return f"'{name}' 不存在,给个默认值顶一下"


t = Tracer()
print(t.present)   # 先打印访问日志,再输出 100
print(t.ghost)     # 先触发 __getattribute__(找不到),再落入 __getattr__

__getattribute__ 性能敏感又极易递归,除非真的要监控所有属性访问,一般别碰它。

10. 对象的谢幕:__del__

有生就有死。当对象的引用计数归零、即将被回收时,__del__ 会被调用,适合放一点"临终留言":

class FileHandle:
    def __init__(self, path):
        self.path = path
        print(f"打开文件: {path}")

    def __del__(self):
        print(f"关闭文件: {self.path}")


fh = FileHandle("report.csv")
del fh  # 关闭文件: report.csv

不过要泼一盆冷水:__del__ 被调用的确切时机并不保证(受垃圾回收机制和循环引用影响,解释器退出时也可能干脆不调)。所以千万别把关键资源清理押在它身上——真正可靠的资源管理方案是上下文管理器,后面的章节会专门安排。

11. 常用魔术方法速查表

分类 方法 什么情况下触发
创建 __new__ 造出实例本体
创建 __init__ 实例造好后做初始化
可调用 __call__ obj()
字符串 __str__ print()、str()
字符串 __repr__ repr()、交互式环境直接回显
比较 __eq__ ==
比较 __ne__ !=
比较 __lt__ <
比较 __le__ <=
比较 __gt__ >
比较 __ge__ >=
算术 __add__ +
算术 __sub__ -
算术 __mul__ *
算术 __truediv__ /
算术 __neg__ 一元 -
算术 __abs__ abs()
容器 __len__ len()
容器 __getitem__ obj[key]
容器 __setitem__ obj[key] = value
容器 __delitem__ del obj[key]
容器 __contains__ in
容器 __iter__ for ... in
转换 __int__ int()
转换 __float__ float()
转换 __bool__ bool()、if obj:
属性 __getattr__ 访问不存在的属性
属性 __getattribute__ 访问任意属性
属性 __setattr__ 给属性赋值
属性 __delattr__ 删除属性
生命周期 __del__ 对象被回收

习题

一、全局唯一的事件总线

亲手实现一遍单例模式:写一个 EventBus 类,无论谁实例化多少次,拿到的都是同一个对象。

bus_a = EventBus()
bus_b = EventBus()
print(bus_a is bus_b)  # 应该输出 True

二、带记忆的里程表

编写一个 Odometer 类:

  1. 实例化时传入初始读数;
  2. 每调用一次实例,读数 +1,并返回新读数;
  3. reset() 方法把读数归位到初始值;
  4. get() 方法查看当前读数。
odo = Odometer(1000)
print(odo())      # 1001
odo()             # 1002
print(odo.get())  # 1002
odo.reset()
print(odo.get())  # 1000

三、思考题

不运行代码,先脑内推演:下面这段代码输出什么?为什么?

class Bell:
    def __call__(self):
        print("叮 —— 基础铃声")

class Alarm(Bell):
    def __call__(self):
        print("嘀嘀!警报铃声")
        super().__call__()

alarm = Alarm()
alarm()

四、频率类(综合练习,可借助 AI)

编写一个 Frequency 类,表示"每 span 个单位时间里发生 count 次"的频率(例如 Frequency(1, 2) 表示每 2 秒发生 1 次),要求:

f1 = Frequency(1, 2)  # 每 2 秒 1 次
f2 = Frequency(1, 3)  # 每 3 秒 1 次

print(f1 + f2)    # 5/6 —— 两路事件合并后的频率
print(f1 - f2)    # 1/6
print(f1 * f2)    # 1/6
print(f1 / f2)    # 3/2
print(f1 == f2)   # False
print(f1 > f2)    # True
print(float(f1))  # 0.5
print(str(f1))    # 1/2
print(repr(f1))   # Frequency(1, 2)

提示:

  • 构造时对 count/span 做约分,表示就唯一了(Frequency(2, 4) 和 Frequency(1, 2) 应视为相等);
  • 实现 __str__、__repr__;
  • 实现 __eq__、__lt__(其余比较可交给 @total_ordering 推导);
  • 实现 __add__、__sub__、__mul__、__truediv__;
  • 实现 __float__。

下一章:09 · 描述符与元类