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

不深入更多细节,它基本上生成

(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]))

因此,“处理程序”片段只在一次(目前使用 `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
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`本身中确实有一个明确的使用场景。
编写基准测试时,有趣的事实:提升变体的性能比其他所有方法都高一个数量级……但这并非因为代码本身更高效。闭包分析得更优秀,并在基准测试中去除了大部分代码,而其他方法则保留了全部代码。
尽管如此,结果很有趣。我本能地认为提升变体是最快的,但性能提升幅度小于预期。

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)

带检查(检查是否在注册表中)的速度最慢(意料之中),但出乎意料的是,总是盲目分配两个函数的变体性能如此接近。
很有趣,检查变体的代码看起来是什么样子?
作者
> 这可能是导致速度缓慢的原因的原子 deref/map 查找。

我认为在这里你是正确的。我在阅读 def、读取单个对象属性(一种高性能注册表的实现方式)以及进行存在性检查(reify 所做的工作)之间看到很少的差异,尽管显然 exists? 在这个特定情况下做了更多不必要的操作。但是读取原子的速度要慢得多。

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

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

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

> "提升"听起来很相似。我认为我的愿望不是改变语义,对此我不这么认为。

如果我们提升一个如 `42` 这样的表达式,没有任何程序会崩溃,我们可以静态地确定这是一个安全的优化,就像 cljs 常数表所做的那样。我认为提升返回函数的表达式不是一个安全的优化措施,除非我们能证明程序中没有部分依赖于现有的、不同的行为,即每个函数都是一个可以独立修改和比较的新唯一对象。(我也不是 compiler 专家。)
作者
我不太理解你关于提升的看法。这不会在许多地方使用。在 core 中我能想到的唯一一点是 reify。整个目的是获取 ns层面 def 的确切语义。任何用这个做其他事的代码都是不正确的。它甚至不能访问宏运行位置中的 locals。

看到 Closure Compiler 在 :advanced 中对代码所做的处理后,我已经决定将其保留在 shadow-cljs 中。它不需要进入 core,因为我添加的回退方法也工作得很好,只是运行速度略慢,生成的代码略多。

编辑了
没有完全跟上你所做的一切,但你能不能在宏展开时执行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倍。

花了很多时间去尝试为这个基准测试。我对结果很满意,这表明总体上并不值得担心这个问题。
哈哈,当然,但我不明白为什么 defonce 让你感到不适,就像看到其中的 def 一样 :p

我的宏总是返回一些巨大的东西,但它在漂亮的宏界面后面隐藏起来 :p 我认为用户不知道的事情不会伤害他们 :p
0

编辑了

我已经使用通过宏创建的 defn 和词法绑定来处理你所描述的情况。

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

不确定确切的配置,但这似乎与你在代码示例中展示的 def 版本的工作方式相同,无需污染全局 ns。基本上使用 let-over-lambda 方法,通过辅助宏来消除样板代码。

如果你“需要”一些全局函数注册(如 def),没有阻止这种方法副作用一些注册(一个原子)并将片段函数插入那里的。然后你可以通过查找函数在其他片段中引用它,或者也许可以通过宏定义一些语法糖来翻译内容到一个查找中)。再次,不确定具体的用例是什么,但我想你可以避免提升变量。

如果我在其中添加一个条件检查或某些其他“粘合”代码,我可以避免提升变量。关键是删除那个条件检查并且只做一次。片段宏可以在任何地方使用,所以我不能使它自己只是一个 defn。
...