巨集爪子与黏土代码

宏(macro ,也称「巨集」),在计算机领域一般用来表示一打小命令组合成的批量处理,例如键盘宏或鼠标宏。具体到 编程领域来说,对于 C 语言来说宏是文本的搜索和替换,虽然我对 C 语言了解不多,但发现很多人对 C 语言的 宏系统颇有微词,说要尽量少用不要折腾太多宏的黑魔法……不过这篇文章倒不是讲 C 语言的宏系统,而是讲 Lisp 里 的宏系统。后者的宏系统和前者可以说是完全不是同一种东西,只能说计算机领域起名烂的传统依然在发力,到现在搞出 了那么多似是而非的命名。

对于 lisp 程序员来说,宏不是简单的对代码的搜索和替换,而是对代码的操作和计算(更多可参考:直观理解 Lisp 语法 )。在 对代码可以进行条件判断、循环、递归等操作,这种将操作数据的方式应用到代码上宏系统带来了这些好处:

  • 管控数据的「解释权」
  • 维持概念一致性

这种好处空说无益,来看看具体的代码:

例如 python 中的 sql 查询,很容易写出这种查询代码:

(await db_session.scalars(
    select(models.OutboxObject)
    .where(
        models.OutboxObject.ap_type == "Note",
        models.OutboxObject.is_deleted.is_(False),
        models.OutboxObject.visibility.in_([VisibilityEnum.PUBLIC, VisibilityEnum.UNLISTED]),
    )
    .order_by(models.OutboxObject.created_at.desc()))
).all()

看上去很直接,但写出来确实很是别扭,就算转换成 lisp ,也不见得有多好:

(.all (await
      (.scalars db_session
        (.order_by
          (.where (select models.OutboxObject)
            (= models.OutboxObject.ap_type "Note")
            (.in_ models.OutboxObject.visibility [VisibilityEnum.PUBLIC VisibilityEnum.UNLISTED])
            (.is_ models.OutboxObject.is_deleted False))
        (.desc models.OutboxObject.created_at)))))

究其原因是所用的 SQLAlchemy 这偏向底层的库暴露并纠缠了很多不必要的细节,例如莫名奇妙蹦出来的 scalars ;为了避免关键词 冲突而出现的 in_ 和 is_ 也很让人摸不着头脑。对此忽略掉所有其它细节追求简洁的表现,那么代码可能是 这样的:

(db select models.OutboxObject
    where [(= .ap_type "Note")
           (in .visibility [VisibilityEnum.PUBLIC VisibilityEnum.UNLISTED])
           (is .is_deleted False)]
    order-by (desc .created_at)))

其中为了偷懒规定以点开头的为所查询对象的属性,如 .visibility 表示为 models.OutboxObject.visibility ,看着这个简洁形式 写法,如何将其「解释」成对应的实际代码?

不难注意到代码中是有规律的嵌套调用,如 order-by 则 为 (.order_by ... (.desc ...)) 。不难想出可以使用循环控制流,逐元素检查代码,发现是 select, where, order-by ,则 将其变化成对应的实际代码,如:

(db select models.OutboxObject
    where [(= .ap_type "Note")
           (in .visibility [VisibilityEnum.PUBLIC VisibilityEnum.UNLISTED])
           (is .is_deleted False)]
    order-by (desc .created_at)))
;; ↓
((.select db models.OutboxObject)
    where [(= .ap_type "Note")
           (in .visibility [VisibilityEnum.PUBLIC VisibilityEnum.UNLISTED])
           (is .is_deleted False)]
    order-by (desc .created_at)))
;; ↓
((.where ...
    (= models.OutboxObject.ap_type "Note")
    (.in_ models.OutboxObject.visibility [VisibilityEnum.PUBLIC VisibilityEnum.UNLISTED])
    (.is_ models.OutboxObject.is_deleted False))
order-by (desc .created_at))
;; ↓
(.order_by ... (.desc models.OutboxObject.created_at))
;; 最后得到
(.order_by (.where (select models.OutboxObject)
             (= models.OutboxObject.ap_type "Note")
             (.in_ models.OutboxObject.visibility [VisibilityEnum.PUBLIC VisibilityEnum.UNLISTED])
             (.is_ models.OutboxObject.is_deleted False))
  (.desc models.OutboxObject.created_at))
;; 有过相关经验的人很容易想到可以用递归将结果一步一步传递到下次调用

其中,点形式简写表示取属性字段操作——可以检查对应符号是否以 . 开头,如果是的话就拼接成完整的符号,例如:

;; 伪代码,其中:
;; symbol 将字符串转换成符号,如 (symbol "foo") -> foo
;; symbol-get 为取对应符号的的位数,如 (symbol-get foo 0) -> f
;; symbol-join 为拼接符号,如 (symbol-join foo - bar) -> foo-bar
;; symbol-cut 为取对应符号的的位数后续,如 (symbol-cut foo-bar 3) -> -bar
(let [head (symbol-get name 0)
      basename (symbol "models.OutboxObject")]
  (when (= head (symbol "."))
    (return (symbol-join basename . (symbol-cut name 1)))))

以及如何将 .in .is 翻译成对应的 .in_ .is_ ?可以提前写下一张表存下对应映射:

{"in" "in_"
 "not-in" "not_in"
 "is" "is_"
 "not" "is_not"
 "between" "between"
 "=" "__eq__"
 "!=" "__ne__"
 ">" "__gt__"
 ">=" "__ge__"
 "<" "__lt__"
 "<=" "__le__"}

(in ...) -> (.in_ ...)
(!= ...) -> (.__ne__ ...)

越往下思考,就越发觉得这种方式似乎……很熟悉?就像操作某种数据一样,从其它地方传来的 json :查询某个键,如果有……如果没有,某个值对应 的值……检查转换,这种感觉是一样的!像对待数据一样对待代码,所以可以将某种代码「翻译」成其它代码,相当于自行实现了某种小解释器。不要小 瞧这种「翻译」带来的好处。例如我们已经习惯了在阿拉伯数字上进行四则运算,不如反过来想象下在罗马数字体系下做四则运算有多痛苦?它们后面 对应的结果是一致的,用阿拉伯数字、罗马数字、某种拥有不定指头的外星人二进制、十六进制产生的表达都是一样的,但就算这样我们也能发现确实 存在某种更简单的「解释」。而 Lisp 的宏系统,就是这种可以让程序员将「解释权」掌握在手中的手段。那么有了「解释权」后,下一个 观点就更显而易见了——维持整体的概念一致性:

例如还是在 Python 项目中,流行用 Pydantic 来做数据的模式定义和校验,如:

class CommentCreate(pydantic.BaseModel):
    author_name: str
    author_site: str
    content: str
    parent_id: int | None = None

很直接,规定对应的字段以及默认值,但这里有种怪坑就是: parent_id: int | None = Noneparent_id: int = None ,对应 不是一种东西!前者会放行传进来的 None 值,但后者不会,后者会阻拦传进来的 None 值只是将 None 值作为不定值,可这会迷惑检查器,默认 值为 None 那类型标注就应该带上 None 然后为您附上红色下划线作为温暖提示。可这确实不一样,究其原因是:

  • python 没有良好的形式定义缺省值(当然现在还没推出的 3.15 sentinel 值可以作为这种场景的缓解)
  • pydantic 将实现绑在了 python 上的 class 定义和类型标注上,里面的不同方向的设计缠绕在一起。

这种形式就算是使用 lisp 来写也不见得好上多少:

(defclass CommentCreate [pydantic.BaseModel]
  (setv #^ str author_name)
  (setv #^ str author_site)
  (setv #^ str content)
  (setv #^ (| int None) parent_id None))

而且因为底下所用的 lisp 的 Hylang 设计考虑,其中的语法反而比 Python 更加让人头晕目眩。 是的是的,又是该请出宏了,可以折腾出叫做 define-schema 的宏:

(define-schema CommentCreate
  [author_name str]
  [author_site str]
  [content str]
  [parent_id int :default-none])

其中,将属性对变成了方括号的绑定对,并附上了 :default-none 的额外参数,底下的宏定义发现这样的标注可以自动将类型标注加上 None 变 成 (| int None) 并将默认值设为 None 。当然现在没有什么必要更多作为概念验证,可作为手握「解释权」的 lisp ,将其发展成复杂的类型 系统是可行的。例如设置些必须包含某种前缀的类型名、不相交的互斥类、当提供了 A 则必须提供 B 等等……并且 define-schema 的 命名也隐藏了底下是在使用 class 的事实。毕竟 python 作为通用编程语言,要在设计的通用性和简洁性上寻找平衡,且必定很难将更特定 的用途作(例如作为模式定义)作为首要考虑。但 lisp 作为「下放」了语言设计权力的语言,作为编程语言的使用者也能站在语言设计者的角度 设计更符合自己用途的语法形式,例如作为路由系统定义可以实现叫 define-route 的宏而不用挂载在函数定义上而使用然后使用 更加自然的标注:

(define-route get-interactions
  [#^ str rid [db_session (Depends get_db_session)]]
  {:get "/api/site/resource/{rid}/interactions"
   :guard [resource
           (qone? db_session models.SiteResource
                  :public_id rid
                  :load [.outbox_object])
           (:is resource None) 404
           (:= .outbox_object.is_deleted True) [410 "resource gone"]
           (:not-in .outbox_object.visibility [VisibilityEnum.PUBLIC VisibilityEnum.UNLISTED]) 401]}
  ...
  )

这里,将 GET POST PUT DELETE 的 HTTP 的方法放在了元数据 map 中,并还额外实现了 :guard 标注,是作为之前维护相关系统时,要 在实际代码里写上百八十行校验代码这种绝望场景的有感而发:

resource = await exec_one(hy.I.sqlalchemy.select(models.SiteResource).where(models.SiteResource.public_id == rid).limit(1), db_session)
if resource is None:
    raise HTTPException(status_code=404, detail='Error')
if resource.outbox_object.is_deleted == True:
    raise HTTPException(status_code=410, detail='resource gone')
if not resource.outbox_object.visibility in [VisibilityEnum.PUBLIC, VisibilityEnum.UNLISTED]:
    raise HTTPException(status_code=401, detail='Error')
...

所以不如将其踢到标注中,作为前置校验与实际业务代码分离。

当然宏这种灵活的形式遭到滥用也很容易形成黑魔法满天飞的绝望场景,有时没有找出简洁的表现形式就像自创了个不稳定的 魔咒然后导致大爆炸。不过在适当使用时将某些泄露的抽象扫进毯子里隐藏起来并维护各个系统 的一致性还是让人感到「身心愉快」的。

P.S. 标题中提到的黏土来源于这篇演讲: FOSDEM 2026 - Lisp is clay: the power of composable DSLs