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

欢迎!请参阅关于页面,了解有关此信息的一些更多信息。

+2
ClojureScript

我绝不是编译器专家,所以我不知道“提升”这个名字是否真的合适。如果你不知道你正在查找的术语,那么在Google上查找是很难的。

我正在编写一个纯ClojureScript实现来处理DOM。有点像Svelte和React的结合。在这个实现中,我有一个宏,它可以重写常见的Hiccup形式来创建一个“create”函数和一个“update”函数。为了让这个宏有效地工作,我想要创建ns级别的“vars”。

这是一个可能的示例

(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,但也可以只是使用一个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调用,可能是一个绑定),它基本上允许它们将一个形式“ prepend”到它们目前正在工作的顶级形式上。

这应该是编译器原生支持的功能吗?

我知道Clojure也没有这个功能,但是它通过创建类并在需要时访问它们来做这个事情,它好像也在这样做。

实现可能会有所不同。我只是想确定我是否一开始就想要这样做的想法是疯狂的。之前的实现工作正常,但最终产生了更多代码,其中包括许多额外的检查,因为不能依赖于 identical?

2 个回答

+1
by

在当前的语言/编译器内思考,期望的行为类似于 defonce,但支持REPL/实时重新加载的更改。如果 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 (checking if in registry) is by far the slowest (kinda expected) but I did not expect the variant that always blindly allocates both functions to be so close.
by
有趣的是——检查变体的代码是什么样子的?
作者是
> 这是一个原子 deref/map 查找,很可能导致了性能降低。

我认为在这个问题上,你是对的。我在读取 def、读取单个对象的属性(一个高性能注册表如何实现的例子)和执行 exists? 检查(reify 所做的)之间看到的小差异,尽管显然 exists? 在这个特定情况下做了更多不必要的操作。但读取原子的速度要慢得多。

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

从原子中读取  o 70,540,398 每秒操作 ±0.26%(采样 97 次)
```

源编译后的 js: https://gist.github.com/mhuebert/2781f9d1b2481301a8eb17ce2c5d0e3e

> “提升”听起来可能相似。我不希望改变语义,我个人认为这不是要改变的东西。

如果我们提升一个像`42`这样的表达式,没有任何程序会中断,我们可以静态地确定这是一个安全的优化,正如 cljs 常量表所做的那样。我认为提升一个返回函数的表达式不是一个安全的优化,除非我们可以证明程序中没有部分依赖于现有的不同行为,即每个函数都是一个新的唯一对象,它可以被独立地变异和比较。也就是说,对于你所使用函数的特定方式,语义可能是等价的,但不是普遍等价,很多 js 依赖于它目前的工作方式。(我不是编译器的专家。)
作者是
我不太确定我是否理解你的关于提升的评论。这不会是在很多地方使用的东西。我在核心中唯一能想到的是 reify。整个目的就是得到 ns 级别 def 的确切语义。任何用它做其他事情的代码都是不正确的。它甚至不能从宏运行的地方访问局部变量。

看到 Closure Compiler 对 :advanced 代码做了什么已经让我确信要把这一点保留在 shadow-cljs 中。它不需要进入 core,因为添加的备用方案也能工作,只是运行得慢一些,生成更多的代码。
by
编辑 by
没有全面跟进您的工作,但您能否在宏展开时执行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
只看defn内部的defonce让我感到不舒服。 :P

但在经过几轮调整之后,我做到了在Chrome和Firefox中几乎没有可测量的差异,Firefox使用非def变体时只慢了大约10%。尽管我不怎么相信基准测试结果,因为它们在每次运行之间的变化很大。

非def变体产生的代码略多,但每个片段大约只多了50字节,可能压缩效果足够,不会影响太多。不知道解析/加载的性能,还需要找出如何进行基准测试。

Hiccup在Chrome中的性能也非常出色,创建和更新速度慢大约10%,但在Firefox中创建速度慢5倍,更新速度慢10倍,但在更复杂的情况下,差异会扩大。更不用说在:none中的100倍差异,这是由于所有非优化关键字构造造成的。

花费了大量时间来尝试进行基准测试。对于这样的结果,我已经很满意,认为这整体上并不值得担心。
哈哈,当然,但我不明白为什么那个defonce会让你感到不安,就像看到里面的def一样 :p

我的宏总是会返回一些怪物级的东西,但它们都隐藏在漂亮的宏界面之后 :p 我认为用户不知道的不会伤害他们 :p
0 投票

编辑

我已经使用通过宏构建的defn和词法绑定来访问你描述的使用案例。

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

不确定具体的实施细节,但看起来这会与你所展示的def版本完全相同,而不需要污染全局命名空间。基本上是使用通过辅助宏实现的let-over-lambda方法来消除样板代码。

如果你“需要”一些全局函数注册表(如def),阻止此方法对某些注册表(atom)产生副作用并进行函数片段的插入。然后您可以通过查找函数(或者也许可以通过宏定义一些语法糖)在其他片段中引用它。再次,不确定确切的使用案例,但我想你可以避免提升变量。

我可以通过添加条件检查或其他“胶合”代码来避免提升变量。关键是移除那个条件检查,并只做一次。片段宏本身可以在任何地方使用,所以我不能把它本身变成一个defn。
...