PAYATHON 2026

Common Lisp 中如何让 aref 支持自定义类并减少类型检查?

数据小陈

结论

可移植的 Common Lisp 代码不能直接扩展标准 cl:aref,使它接受任意自定义对象。可以为包装类型定义专用访问函数,也可以在自己的 package 中 shadow aref,提供同名接口。

如果希望避免每次访问时都判断对象是包装对象还是数组,最直接的办法是定义专用访问函数。还可以提供准确的类型信息并请求 inline,让编译器有机会消除函数调用和类型检查等开销。最终能否消除取决于实现和调用位置,标准不保证零开销。

为什么不能直接扩展 cl
?

cl:aref 要求数组作为参数,它不是供用户添加方法的 generic function。把数组放进结构体的 slot,外层结构体也不会因此成为数组。AREF 的标准定义

关于继承,你对这里涉及的 built-in classes 的理解是对的:不能使用 defclass 为 built-in class 定义子类。不过,built-in classes 可以用作自定义 generic function 的方法参数特化类型。BUILT-IN-CLASS 的限制

还需要区分两个同名符号:

  • cl:aref 是标准函数的符号。
  • 自己的 package 在 shadow 后得到的 aref 是另一个符号,可以用来定义自己的函数。

直接重定义 cl:aref 或为它定义 compiler macro 都不可移植,标准规定这类操作的后果未定义。sb-ext:without-package-locks 只能绕过相应的包锁保护,不能改变标准约束。COMMON-LISP 包的约束

首选方案:定义专用访问函数

如果确定对象是 mystring,访问函数就无需判断它是否为普通数组。下面的实现支持读取和 setf,适用于包装 string 的一维访问:

(defpackage #:wrapped-array
  (:use #:cl)
  (:shadow #:aref))

(in-package #:wrapped-array)

(defstruct mystring
  (data "" :type string))

(declaim (inline mystring-ref (setf mystring-ref)))

(defun mystring-ref (object index)
  (declare (type mystring object))
  (cl:aref (mystring-data object) index))

(defun (setf mystring-ref) (new-value object index)
  (declare (type mystring object))
  (setf (cl:aref (mystring-data object) index)
        new-value))

(let ((s (make-mystring :data (copy-seq "abc"))))
  (setf (mystring-ref s 1) #\Z)
  (mystring-ref s 1))

这个实现没有判断对象是包装类型还是数组的分支。底层仍可能有结构体类型检查、数组边界检查等;能否消除这些检查,取决于编译器掌握的信息和优化策略。

inline 声明放在函数定义之前,让支持内联的实现有机会保存所需信息。实现也可以忽略 inline,所以它只是一个优化请求。INLINE 与 NOTINLINE

示例使用 copy-seq 创建可修改的字符串,避免修改字符串字面量。

如果希望继续写 aref

在上述 package 中,可以继续定义自己的 aref:

(declaim (inline aref))

(defun aref (object &rest subscripts)
  (etypecase object
    (mystring
     (apply #'cl:aref (mystring-data object) subscripts))
    (array
     (apply #'cl:aref object subscripts))))

调用示例:

(aref (make-mystring :data "abc") 1)
(aref #(10 20 30) 1)

这个实现与问题中的分支方案思路相同,区别是明确建立了 package 的 shadow 关系,并在定义前声明了 inline。

如果调用位置的对象类型已知,并且实现执行了内联和类型传播,编译器可能删去不可能发生的分支。例如:

(defun first-char (object)
  (declare (type mystring object))
  (aref object 0))

如果编译器无法确定类型,仍需要运行期分派。Common Lisp 标准并不要求把这种调用解析成类似 C++ 静态重载的形式。

这个 aref 示例只定义了读取接口。shadow 产生的新符号不会自动继承 cl:aref 的 setf 行为;写入时可以使用前面的 (setf mystring-ref),也可以另行定义完整的写入协议。

原来的 compiler macro 为什么不成立?

问题中的相关代码是:

(typecase array
  (mystring
   `(cl:aref ,(mystring-data array) ,@subscripts))
  (otherwise
   `(cl:aref ,array ,@subscripts)))

这里把调用的源代码与调用时的参数值混淆了。

array 绑定的是参数表达式

假设调用写成:

(aref s 0)

compiler macro 中的 array 通常是符号 s,而不是运行时 s 所保存的 mystring 实例。因此,(typecase array ...) 检查的是这个符号对象的类型。

即使调用位置有:

(declare (type mystring s))

宏参数 array 也不会自动变成 mystring 实例;宏接收的仍然是表达式。Compiler macro 的处理模型

类似地:

,(mystring-data array)

会在展开阶段立即执行 mystring-data。若目的是生成运行时的 slot 读取,语法应是:

`(cl:aref (mystring-data ,array) ,@subscripts)

但这个展开只能用于已经确定参数是包装对象的情形,不能无条件用于同时接受普通数组的接口。

回退展开必须保留原函数语义

如果 compiler macro 关联的是自己定义的包装访问函数,对类型未知的参数直接展开为:

(cl:aref s 0)

就会丢失该函数处理 mystring 的能力。类型未知时,可以保留运行期分派,也可以通过 &whole 返回原始调用形式,拒绝此次优化。DEFINE-COMPILER-MACRO

&environment 不保证提供推导类型

&environment 可以接收宏展开环境,但 ANSI Common Lisp 没有规定通用接口,供 compiler macro 查询任意表达式的编译器推导类型。

因此,要拿到 environment 并根据编译器推导的类型选择展开,通常需要实现扩展,不能直接以此作为 portable CL 的基础。

compiler macro 的“全局”指它关联全局函数名,不表示它能自动取得调用参数的运行时值,也不表示它会影响其他 package 中所有拼写相同的符号。

实现也可以不使用 compiler macro。即使定义正确,也不能保证某次编译一定采用展开结果。Compiler macro 的使用条件

SBCL deftransform 没有触发,该怎么判断?

deftransform 和示例中的内部数组访问操作依赖 SBCL 的编译器实现,不属于 portable Common Lisp。

现有代码省略了辅助宏、所在 package、注册上下文和实际调用位置,仅凭片段无法确定没有触发的原因。定义能够被接受并编译,也不足以证明某个调用会匹配该转换。

如果继续采用这个方案,需要结合使用的 SBCL 版本检查:

  • 转换注册的 aref 符号,是否与调用位置解析到的符号一致。
  • 转换是否在编译目标调用前已经可用。
  • 编译器在相应阶段是否能够确定参数为 mystring。
  • 内部辅助操作的参数要求是否与生成形式一致。

这些只是排查方向,不能据此断言原代码的具体故障点。

如果优先考虑可移植性,可以先使用专用访问函数或自己的 shadow 接口。在目标实现中编译实际调用,再通过性能测量和反汇编确认剩余开销。例如,可以检查:

(disassemble #'first-char)

源代码中出现 typecase,不代表每次访问都必然执行分支;有类型声明,也不代表检查必然消失。可以查看实际生成的代码来判断优化结果。

备注:内容仅供参考。