请在2024 年 Clojure 状态调查!中分享您的想法。

欢迎!有关更多信息,请参阅关于页面。

+2
ClojureScript

我绝不是编译器专家,所以不清楚“提升”这个名称是否适用于这个问题。当你不知道要找的术语时,谷歌搜起来真的很困难。

我正在编写一个纯 ClojureScript 实现,用于处理 DOM。有点像是 Svelte 和 React 的混合体。在这个实现中,我有一个宏,它会重写一个常见的 Hiccup 形式,以创建一个“创建”函数和一个“更新”函数。为了使这个宏高效运行,我想能够创建命名空间级别的“变量”。

这是一个示例

(defn card [{:keys [title body]}]
  (<> [:div.card
       [:div.card-title title]
       [:div.card-body body]
       [:div.card-footer
        [:div.card-actions
         [:button "ok"]
         [:button "cancel"]]]]))

所以,<>宏(称为“片段”)将这段代码重写为两个函数。一个是创建初始 DOM 的函数,另一个是更新实际可以改变的部分的函数。这样做是为了尽量减少“diffing”。

关于这一点就不展开过多细节了,它基本上生成以下内容

(defn card [{:keys [title body]}]
  (fragment-create "dummy-id" [title body]
    (fn create-fn [state] ...)
    (fn update-fn [state] ...)))

但问题是,每次调用card函数时,都会重新创建这些函数。由于在 JavaScript 中函数没有“身份”,运行时无法判断这个从前的同一identical?片段是否已经被更新(无论是通过 REPL 还是热重载),它会依赖于某个唯一的标识符来区分。JavaScript 运行时也会在每个调用中都分配这些函数,即使运行时可能决定重新使用之前的片段并立即将其丢弃。

所以,我想生成的是像以下这样的东西

(def fragment-123
  (fragment-create
    (fn create-fn [state] ...)
    (fn update-fn [state] ...)))

(defn card [{:keys [title body]}]
  (fragment-use fragment-123 [title body]))

因此,这个片段“handler”只创建一次(目前使用的是 deftype,但也可以是一个映射)。然后,每当调用card函数时,就会“使用”它。

没有“提升”支持,代码只能以内联的方式生成def

(defn card [{:keys [title body]}]
  (do (def fragment-123
        (fragment-create
          (fn create-fn [state] ...)
          (fn update-fn [state] ...)))
      (fragment-use fragment-123 [title body])))

这显然是个问题。所以它需要检查这个片段是否已经被定义等等。reify 目前正在做这件事,但这代码看起来并不令人愉快。

我在 shadow-cljs 中将其实现为一个实验,其中想要使用这个功能的宏可以调用一个特殊函数(目前来自于 &env,也可以是一个绑定),这个函数基本上允许他们将一个形式“追加”到它目前正在处理的顶层形式上。

这应该是由编译器原生支持的特性吗?

我知道Clojure也没有这个特性,但是它通过创建类并在需要时访问它们,所以在某种程度上做到了这一点。

实现可能会不同。我只是想确定我一开始想要这样做是否疯了。之前实现的方式工作得很棒,只是代码量更大,进行了很多额外的检查,因为它不能依赖 identical?

2 个答案

+1

在当前语言/编译器中思考——期望的行为看起来像 defonce,但支持 REPL/实时重新加载的更改。如果 defonce 中的 exists? 检查太慢,我认为可以编写一个类似的宏,每次调用的开销约为一个javascript对象属性查找(在 def 的顶部或在注册表中使用,正如Tom提到的)。

在开发过程中确定何时如何使先前的值无效更复杂。可以在宏展开期间使用 gensym 以便在顶层形式重新编译时始终获得新条目。有没有可能从您的编辑器重新评估某些内容,但是影子不会重新编译,而是使用已缓存的版本,从而使您陷入无效值?

"提升"听起来类似于提升循环不变代码运动,但这些编译器优化与此处的欲望不同,因为这里想要改变语义,所以对我来说这是一件相当不同的事情(即使机制相似)。

"提升"听起来可能相似。我的欲望不止于改变语义,这在我看来并不是。

我并不真的在寻找其他的建议。我已经看过很多,而且它们在需要时都很好地完成了任务。然而,这里提出的解决方案代码量最少,在理论上应该是最高效的,因为不需要在任何地方执行检查。仍然需要编写基准测试,所以我猜我应该首先完成这件事。

我创建了一个[示例片段](https://gist.github.com/thheller/8688986f8c7a1f6aef1b4a411ed3bf0f#file-reify-current-js)来展示`reify`的输出。请注意,所有内容都嵌套在`make_thing`中。我认为这表明在`cljs.core`本身中确实有一个明确的使用场景。
by
有趣的事实是,在编写基准时,提取的变体比其他一切都要快一个数量级...但是并不是因为代码本身更快。闭包能够更好地分析它,并在基准测试中决定删除大部分代码,而其他所有代码都保留了所有的代码。
by
尽管如此,结果很有趣。我的直觉是对的,提取的变体是最快的,但比预期的慢。

node out/bench-fragment.js
fragment-lift x 71,860,782 ops/sec ±2.76% (83 runs sampled)
fragment-with-checks x 24,358,151 ops/sec ±1.08% (93 runs sampled)
fragment-always x 62,388,334 ops/sec ±1.50% (85 runs sampled)

with-checks(检查是否在注册表中)是最慢的(有点意料之中),但我没有料到总是盲目分配两个函数的变体会这么接近。
by
有趣——检查变体的代码看起来是什么样子?
by
https://github.com/thheller/shadow-experiments/blob/c60281cad708928a4f3d4b9b55fca94b133b9644/src/main/shadow/experiments/arborist/fragments.clj#L393-L400

“fragment-get”会对原子进行解引用/映射查找,这可能是导致性能下降的原因。
> 的确,原子解引用/映射查找可能是性能下降的原因。

我认为您在这里是对的。我在读取 def、读取单个对象属性(如何实现高效注册的方式)和执行 exists? 检查(reify 所做的工作)之间看到了很小的差异,尽管显然 exists? 在这个特定情况下比必要的做了更多的工作。但读取原子的速度要慢得多。

```
exists?         x 927,315,604 ops/sec ±1.02% (91 runs sampled)
cache             x 932,548,414 ops/sec ±1.18% (90 runs sampled)
def                    x 933,832,352 ops/sec ±1.13% (92 runs sampled)

read-from-atom   x 70,540,398 ops/sec ±0.26% (97 runs sampled)
```

source w compiled js: https://gist.github.com/mhuebert/2781f9d1b2481301a8eb17ce2c5d0e3e

> “提升”听起来很相似。我认为我的愿望不在于改变语义,这在个人看来并没有这样的变化。

如果我们提升表达式如 `42`,则没有任何程序会崩溃,我们可以静态地确定这是安全的优化,这就是 cljs 常量表所做的。我认为在无法证明程序没有依赖现有的、不同行为的情况下,对返回函数的表达式进行提升不是一个安全的优化,这种行为是每个函数都是一个新的唯一对象,它可以独立地进行修改和比较。即这些函数的语义可能仅在与你使用这些函数的方式中是等价的,而不是普遍等价的,很多 JavaScript 都依赖它目前的工作方式。(我也不是编译器专家。)
我不太明白您的关于提升的评论。这不会在许多地方使用。我在核心中唯一能想到的是 reify。整个目的是获取 ns 级别 def 的确切语义。任何用于其他目的使用此代码的代码都是不正确的。它甚至无法访问宏运行时的本地变量。

看到 Closure Compiler 对代码在 :advanced 中所做的事情已经使我确信要将其保留在 shadow-cljs 中。它不需要进入核心,因为我添加的回退也适用,只是运行速度稍慢,生成的代码也多了点。

编辑了
没有完全跟进你在做什么,但你不可以在宏展开时执行 def 嘛?这在我的 Clojure 代码中就是这样做的。

而不是返回

    (def fragment-123
              (fragment-create
               (fn create-fn [state] ...)
              (fn update-fn [state] ...)))

我会在宏中执行它,然后只返回

    (fragment-use fragment-123 [title body])

但在 CLJS 中可能不行,因为宏在运行时是不可用的,不确定。

另外,为了检查变量是否已经定义,使用 defonce 而不是 def 能不能达到同样的效果,而不使代码更加复杂?虽然我理解这可能会带来性能问题。

    (defn card [{:keys [title body]}]
      (do (defonce fragment-123
              (fragment-create
               (fn create-fn [state] ...)
              (fn update-fn [state] ...)))
          (fragment-use fragment-123 [title body])))
仅看到 defn 中的 defonce 就让我感到不舒服。:P

但在一些调整之后,我得到了这个结果,在 Chrome 和 Firefox 中都没有可测量的区别,Firefox 使用非-def 变体的性能只慢了 ~10%。

非-def 变体产生的代码略多,但每个片段只有约 50 字节,并且足够压缩,因此这并不重要。不知道解析/加载性能如何,还需要找出如何进行基准测试。

Hiccup 在 Chrome 中也非常有竞争力,只比创建慢 10%,但更新慢 2 倍。然而,在 Firefox 中创建慢 5 倍,更新慢 10 倍,在更复杂的情况下,差异会更大。更不用说由于所有非优化的关键字构造,:none 的性能差距达到了 100 倍。

在尝试基准测试此内容上浪费了太多时间。我对结果表示满意,这表明这总体上并不是一个真正值得担心的问题。
by
哈哈,当然啦,但我不明白为什么defonce让你觉得不舒服,跟看到里面的def一样 :p

我的宏总是返回一些令人惊讶的东西,但这些都被隐藏在一个漂亮的宏界面后面 :p 我认为用户不知道的事情对他们没有伤害 :p
0
by
编辑 by

我使用宏通过defn实现了词法绑定,以获取你描述用例。

(defmacro fragment [name args & body]
  `(let [frag# (fragment-create ~args ~@body)]
     (defn ~name ~args
       (frag# ~@args))))

不确定具体的实现,但看起来它和显示的def版本的工作方式完全相同,而不必污染全局ns。基本上是使用由辅助宏提供的lambda-over-let方法来消除样板代码。

如果你“需要”一些全局函数注册表(如def),这种方法的副作用是影响一些注册表(一个原子)并将片段函数插入其中。然后你可以通过查找函数在其他片段中引用它(或者也许可以通过宏定义一些语法糖将其转换为查找)。再次不确定具体用例,但我想你可以避免提升变量。

by
如果我添加一个条件检查或其他“粘合”代码,我可以避免提升变量。要点是移除那个条件检查,只做一次。片段宏可以在任何地方使用,所以我不可以把它本身也定义为defn。
...