同一个符号 @,在 Python 里有两份完全不同的工作:夹在两个操作数中间,它是矩阵乘法运算符,为 numpy、PyTorch 这类科学计算库而生;独占函数定义的上一行,它是装饰器语法,@staticmethod、Agent 框架里的 @tool 都是这个身份。初学者通常先在张量运算和框架源码里分别撞见它,看到位置才能分辨身份。
但 @ 只是个入口。它背后是 Python 运算符体系的统一机制——一套"语法与协议"的分工:语言管语法,类型管含义。这套机制解释了为什么 @ 对内置类型是未定义的、为什么任何类都能接入它、以及为什么它诞生十年基本只出现在数学库里。本文按"矩阵乘法 → 语法与含义的分工 → 自定义接入 → 协议对照表 → 装饰器身份 → 重载的分寸"展开。
1、矩阵乘法运算符
@ 是 Python 3.5 加入的中缀运算符,出自 PEP 465——2014 年立项,动机是数值计算社区长期被一个歧义折磨:* 在矩阵语境下到底是逐元素相乘还是矩阵乘?numpy 里两者都合法、含义完全不同,写错就是静默的错误结果。@ 的出现让矩阵乘有了专属符号。对内置类型它是未定义的,2 @ 3 直接抛 TypeError。numpy 里的两种乘法对照,示例如下:
import numpy as np
a = np.array([[1, 2],
[3, 4]])
b = np.array([[5, 6],
[7, 8]])
print(a @ b) # 矩阵乘法
print(a * b) # 逐元素相乘,完全不同的东西
解读:a @ b 是线性代数里的矩阵乘,第一行结果是 1*5+2*7=19、1*6+2*8=22,整个矩阵为 [[19, 22], [43, 50]];a * b 则是按下标两两相乘得 [[5, 12], [21, 32]]。两者都合法、结果天差地别,这正是当年必须拆出专属符号的原因,运算细节见 numpy.matmul 文档。
一维向量时 @ 退化为点积:
v = np.array([1, 2, 3])
w = np.array([4, 5, 6])
print(v @ w) # 32:点积,即 1*4 + 2*5 + 3*6
解读:点积是向量相似度的基础——做 RAG 时,余弦相似度本质就是归一化向量的点积,检索代码里满眼都是 query @ doc 这样的写法。看到 @ 脑子里反应"点积或矩阵乘"就对了。
2、语法与含义的分工
一个常见的理解偏差需要先纠正:@ 不是"为 ndarray 服务的",而是"被 ndarray 需要所以诞生"——方向很重要。Python 语言本身完全不认识 ndarray,加入 @ 时两者零耦合。语言只做两件事:让解析器认识 a @ b 这个语法,以及规定它调用 a.__matmul__(b)。到此为止,语言的活就干完了;__matmul__ 里写什么逻辑,是每个类型自己的事。
用下标运算符类比就通了:[] 也是语言内置语法,但没人说 [] 是"为 list 服务的"——dict、str、numpy 数组都实现了 __getitem__,所以都能用 [],自己写的类实现它也行。@ 一模一样,只是历史上恰好只有科学计算社区强烈需要这个符号,PEP 465 才为它开了口子。语法是公共设施,谁实现了协议谁就能用。
这套分工可以一句话记住:语言管语法,类型管含义。
3、自定义类型接入运算符
自定义一个类实现 @ 完全合法,含义由你定义。给二维向量接上点积,示例如下:
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __matmul__(self, other): # v @ w:正常路径
return self.x * other.x + self.y * other.y
def __rmatmul__(self, other): # 3 @ v:左边不认识 @ 时兜底
return Vector(other * self.x, other * self.y)
v = Vector(1, 2)
w = Vector(4, 5)
print(v @ w) # 14:点积
print(3 @ v) # Vector(3, 6):反射路径
解读:v @ w 执行时 Python 调用 v.__matmul__(w),返回什么完全由实现决定——点积、相似度都可以,Python 不检查"这像不像矩阵乘法"。3 @ v 里 int 没有实现 __matmul__,Python 不会立刻报错,而是回头问右操作数:调用 v.__rmatmul__(3),这就是反射协议。配套还有 __imatmul__,支撑就地写法 v @= w;不定义它时会自动退化为 v = v @ w,所以小类型不写它也行。
4、语法与协议的对照表
@ 的这套机制适用于全部运算符,Python 的运算符体系本质上是一张"语法到 dunder 协议"的对照表。先看一个非算术的例子,感受协议的覆盖面:
class Playlist:
def __init__(self, *songs):
self.songs = list(songs)
def __len__(self): # len(p) 走这里
return len(self.songs)
def __contains__(self, song): # song in p 走这里
return song in self.songs
def __call__(self): # p() 走这里
return self.songs[0]
p = Playlist("晴天", "稻香")
print(len(p)) # 2
print("晴天" in p) # True
print(p()) # 晴天
解读:不止 + 与 @,len()、in、甚至"把对象当函数调用"都走协议。常用语法与协议的对照如下:
| 写法 | 协议方法 | 对应行为 |
|---|---|---|
a + b |
__add__ |
加法 |
a == b |
__eq__ |
相等比较 |
a @ b |
__matmul__ |
矩阵乘 |
x in a |
__contains__ |
成员判断 |
a[i] |
__getitem__ |
下标取值 |
len(a) |
__len__ |
长度 |
a() |
__call__ |
调用对象 |
从表里可以看出,"运算符重载"这个说法甚至窄了——整张表是同一套协议系统,完整清单见数据模型文档。三门语言的路线在此分野:Java 干脆不开放运算符重载(只给 String 留了 + 的后门);Kotlin 走"约定"路线,operator fun plus 这类命名约定映射到符号;Python 把协议名写在明面上,任何一个 dunder 都是公开接口。掌握这张表,任何库的重载行为都能逆向拆解。
5、前缀位置的装饰器
回到 @ 的第二份工作。行首位置、独占函数定义上一行的 @ 是装饰器语法,不是运算符。两种身份对照如下:
| 位置 | 身份 | 机制 |
|---|---|---|
| 夹在两个操作数中间 | 矩阵乘运算符 | 调用 __matmul__ 协议 |
| 独占函数定义上一行 | 装饰器语法 | 定义时把函数交给装饰器包装 |
同符号并排直观看,示例如下:
result = a @ b # 中缀:矩阵乘法,触发 __matmul__
@staticmethod # 前缀:装饰器,包装下面的函数
def util():
...
解读:装饰器的 @ 读作"把下面的函数交给这个装饰器处理"——@staticmethod 把方法标记为静态,@tool 把函数注册成工具,机制都是定义时刻的函数包装。它长得像 Java 注解,但注解是附加在代码上的元数据,装饰器是货真价实的运行时变换:原函数被装饰器的返回值替换。术语定义见官方术语表。
6、重载的分寸
机制上任何类都能接任何运算符,工程上却要克制。重载要用在含义不言自明的场景:点积、相似度这类数学语义,用 @ 读者一看就懂,是好用法;可假如让 user @ order 表示"下单",可读性就是灾难——这种时候老实写 user.place_order(order)。经验法则:运算符的行为要符合直觉,超出直觉的操作就该配一个有名字的方法。
这条法则反过来解释了一个现象:@ 诞生十年,基本只在数学库和向量检索代码里出现,业务代码几乎不用——不是不能用,是不该用。符号表达的是"读者不必查文档也能猜中的语义",猜不中的语义交给方法名。
7、总结
回顾要点:中缀 @ 是 PEP 465 为矩阵乘专设的运算符,一维时退化为点积,是 RAG 相似度计算的日常符号;语言只提供语法与协议约定,含义由实现协议的类型决定,@ 与 ndarray 零耦合;__matmul__、__rmatmul__、__imatmul__ 是反射与就地三条路径,整套机制适用于 +、==、in、len()、() 等全部语法;行首的 @ 是装饰器语法,与运算符同名不同物;重载的分寸是"直觉内用符号,直觉外用方法名"。对于正在深入 Python 的开发者,建议把那张对照表存进笔记,读框架源码遇到陌生行为时先查协议——绝大多数"魔法",拆开都是这张表上的一行。

