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中没有“身份”,运行时无法确定这是否是之前相同的同名片段,或者它已经被更新(无论是通过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

在当前的语言/编译器内思考 - 所需的行为看起来像 defonce,但支持REPL/实时更新更改。如果defonce中的exists?检查太慢,我认为可以编写类似的宏,每次调用的成本大约是查找一个JavaScript对象属性(要么在def之上,要么使用数据库,就像Tom提到的。)

在开发期间确定何时使以前的值无效似乎更复杂。在宏展开期间可以使用类似gen-sym的技巧来获取每次顶层形式重新编译时的新条目。是否有在编辑器中重新评估某物,而shadow不会重新编译,仍使用缓存版本来使用,所以你会卡在旧值上的情况?

"提升"听起来类似于提升循环不变量代码运动,但与这些编译器优化不同,这里的期望是改变语义,所以它对我来说感觉像是非常不同的东西(即使机械上是相似的。)

我想"提升"听起来很相似。我的愿望不是改变语义,这在我看来并没有改变语义。

我实际上并不想寻找替代方案。我已经接触过很多种方案,它们在需要时都能很好地完成任务。然而,我这里提出的解决方案代码量最少,理论上是性能最好的,因为不需要在任何地方执行检查。仍然需要编写基准测试,所以我想我应该首先做那件事。

我为显示`reify`输出创建了一个[示例gist](https://gist.github.com/thheller/8688986f8c7a1f6aef1b4a411ed3bf0f#file-reify-current-js)。请注意,所有内容都在`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 the slowest by far (I kinda expected) but I did not expect the variant that always blindly allocates both functions to be so close.
by
有趣的是,检查变量版本的代码是什么样的?
> 这可能是导致性能下降的原因:之前的原子引用/映射查找。

我认为你在这里是正确的。我在阅读def、阅读单个对象属性(如何实现一个高性能的注册表)以及进行exists?检查(reify所要做的事情)之间看到了微小的差别,尽管显然exists?在这个特定的案例中做得太多工作了。但读取原子要慢得多。

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

从原子读取    x 70,540,398 操作/秒 ±0.26%(97次样本)
```

有编译服务器源码:https://gist.github.com/mhuebert/2781f9d1b2481301a8eb17ce2c5d0e3e

> "提升"听起来很相似。我认为我的意图并不是改变语义,因为它这个做法我不这么认为。

如果我们提升一个像 `42` 这样的表达式,那么程序不会出错,我们可以确定这是一个安全优化,与cljs常量表所做的类似。我认为提升返回函数的表达式不是一个安全的优化,除非我们可以证明程序中没有部分依赖现有的、不同的行为,即每个函数都是一个新的唯一对象,可以独立于其他对象进行修改和比较。即语义可能在你使用的这些函数的具体方式中相同,而不是普遍相同,很多js依赖于它目前的工作方式。(我也不是编译器专家。)
我不确定我是否理解你关于提升的评论。这不是在许多地方都会用到的东西。我唯一能想到的是core中的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])))
alt
只在 defn 中看到 defonce 让我感到不舒服。 :P

但经过几次调整,我将它改进到了Chrome和Firefox之间的性能差异微乎其微,Firefox仅慢约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版本的效果完全相同,无需污染全局ns。基本上是使用由辅助宏促进的lambda-over-let方法,以消除样板代码。

如果你“需要”一些全局函数注册表(如def),这个方法并不妨碍其副作用到某个注册表(一个原子)中并插入片段函数。然后您可以通过查找函数在其他片段中引用它(或许可以通过宏定义一些语法糖来翻译到查找)。再次,不确定具体的使用案例,但我认为你可以避免提升变量。

如果添加一个条件检查或其他“胶水”代码,我可以避免提升变量。关键是移除这个条件检查,只做一次。片段宏可以在任何地方使用,所以我不能让它只是一个defn。
...