2024年Clojure状况调查!中分享您的看法。

欢迎!请查看关于页面,了解更多关于它是如何工作的信息。

+2
ClojureScript

我绝非一个编译器专家,所以我不知道“提升”这个名字是否适用于这个场景。当不知道你查找的术语时,谷歌搜索会变得很困难。

我正在编写一个纯ClojureScript实现来处理DOM,类似于Svelte和React的结合。其中有一个宏,它将常见的Hiccup形式重写为一个创建函数和一个更新函数。为了使该宏高效工作,我想能够创建ns级别的“变量”。

以下是该场景的一个示例:

(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,另一个是更新实际可以更改的部分。这样做是为了尽可能减少“差异”。

在这里不详细介绍这个话题,它基本上生成以下内容:

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

问题在于,每次调用card函数时,都会重新创建这些函数。由于函数在JS中没有“身份”属性,运行时无法确定这是否是之前的同一identical?片段或者是否更新了(通过REPL或热重载)。它必须依赖于某些唯一标识符。JS运行时也总是在每次调用时分配函数,尽管运行时可能会决定重用之前的片段并立即丢弃它们。

所以我想要生成的是以下从原始代码中更接近于这个:

(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,但也可以是一个map)。然后,每次调用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
by

在当前的语言/编译器中思考 - 所需的行为类似于defonce,但支持REPL/live-reload更改。如果defonce中的exists?检查太慢,我认为可以写一个类似的宏,每次调用的开销大约为一个javascript对象属性查找(放在def之上或使用注册表,就像Tom提到的)。

在开发过程中确定何时无效化前面的值似乎更复杂。可以在宏展开期间使用gensym来获取每当顶层形式重新编译时的新条目。是否会有从您的编辑器重新评估某些内容的情况,而shadow不会重新编译却使用缓存版本,因此您会陷入陈旧值的困境?

"提升"听起来类似于提升循环不变代码移动,但与这些编译器优化不同,这里的目的是改变语义,因此对我来说感觉非常不同(即使机制相似)。

by
"提升"听起来可能类似。我的愿望不是改变语义,我认为这不是它所做的事情。

我实际上并不想寻找替代建议。我已经尝试了很多,它们在需要时都做得很好。但是,我这里提出的解决方案需要的代码最少,理论上应该是最高性能的,因为它不需要在任何地方执行检查。仍然需要编写基准测试,所以我可能确实应该先做那件事。

我创建了一个[示例 gist](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
很有趣——检查变体的代码是什么样子?
> 这可能是导致速度下降的原因。

我认为您在这里说得对。我在阅读def、读取单个对象属性(如何实现高效的注册)和存在检查(reify所做的)之间看到了微小的差异,虽然显然exists?在这个特定情况下做了更多不必要的操作。但读取原子运算速度要慢得多。

```
exists?         每秒927,315,604操作 ±1.02%(采样91次)
cache           每秒932,548,414操作 ±1.18%(采样90次)
def             每秒933,832,352操作 ±1.13%(采样92次)

read-from-atom  每秒70,540,398操作 ±0.26%(采样97次)
```

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

> "提升"听起来很相似。我的目标是不要改变语义,我认为这不是。

如果我们提升一个如`42`的表达式,没有程序会出错,我们可以静态地确定这是一个安全的优化,就像cljs常数表做的那样。我认为只有当我们可以证明程序没有任何部分依赖于现有的不同行为,即每个函数都是一个新的唯一对象,可以独立地修改和比较时,才应该对返回函数的表达式进行提升,这是安全优化的。换句话说,特定方式使用这些函数的语义可能等效,但不是普遍等效的,许多js依赖当前的工作方式。 (我也不是编译器专家。)
我不确定是否理解您的提升评论。这不会在许多地方使用。我在核心中唯一能想到的是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])))
by
只看 defonce 在 defn 里面,我的皮肤就发麻。 :P

但经过一些调整后,我在 Chrome 和 Firefox 中找到了没有可测量差异的点,Firefox 使用非 def 变体时只有 ~10% 慢。尽管我不太相信基准测试结果,因为每次运行的结果差异很大。

非-def 变体生成的代码略多,但每个片段大约 50 字节,并且压缩效果可能足够好,所以这并不是问题。还不知道解析/加载性能,还需要找出如何对其基准测试的方法。

Hiccup 在 Chrome 中也非常有竞争力,创建慢 10%,更新慢 2 倍。但在 Firefox 中创建慢 5 倍,更新慢 10 倍,复杂场景下的差异更大。更不用说由于所有未优化的关键字 construction 而导致的 100 倍差异。

在基准测试这个问题上浪费了足够的时间。我对结果显示这不是一个真正值得担心的问题而感到满意。
by
哈哈,没错,但我不知道为什么 defonce 会让你的皮肤发痒,就像看到里面的 def 一样 :p

我的宏总是返回巨大的东西,但它们隐藏在漂亮的宏界面背后 :p 我认为用户不知道的不会伤害他们 :p
0
by
编辑 by

我已使用通过宏构建的 lexical bindings 和 defn 来访问你描述的使用场景。

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

不确定具体的实现细节,但这似乎与您展示的 def 版本工作方式相同,无需污染全局命名空间。基本上,通过辅助宏提供的 let-over-lambda 方法消除了样板代码。

如果您“需要”某些全局函数注册表(如 def),这阻止不了本方法副作用地改变某些注册表(一个原子)并在其中插入片段函数。您可以通过查找函数在其它片段中引用它,或者可能通过宏定义一些语法糖来将东西转换为查找。再次不确定具体的使用场景,但我想您可以避免变量提升。

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