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

在此不进一步详细介绍,这基本上生成

(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,却也可以只是一张地图)。然后它就在调用 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/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)

带有检查的功能(检查是否在注册表中)绝对是最慢的(预料之中),但我没有预料到总是盲目分配两个函数的变体会这么接近。
by
很有趣——检查变体的代码是什么样的?
by
> 这可能是导致减速的原因的一个原子解引用/映射查找。

我认为您在这里是正确的。我在读取一个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)
```

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

> "提升"听起来可能很相似。我认为我的本意不是要改变语义,这并不符合我的看法。

如果我们提升一个像`42`这样的表达式,任何程序都不会出错,我们可以静态地确定这是一个安全的优化,这就是cljs常数表所做的那样。我认为提升一个返回函数的表达式并非一个安全的优化,除非我们可以证明程序中没有部分依赖于现有的、不同的行为,即每个函数都是一个可以独立变异和比较的新唯一对象。(我也不是编译器专家。)
by
我不太清楚您对提升的评论。这不是很多地方会用到的东西。我在核心思维中能想到的唯一一个是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 使用 non-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版本相同的方式工作,而不必污染全局命名空间。基本上是使用通过辅助宏促进的let-over-lambda方法来消除冗余。

如果你“需要”一些全局函数注册表(如def),没有任何阻止这种方法影响某个注册表(一个原子)并将片段函数插入其中的。然后您可以通过查找函数引用它(或者 perhaps 通过宏定义一些语法糖来将内容转换为查找)。再 次,不确定具体的用例是什么,但我认为你可以避免提升变量。

by
如果添加条件检查或其他“粘合”代码,我可以避免提升变量。关键在于去除那个条件检查并且只执行一次。片段宏可以用于任何地方,所以我不能将其本身设为仅限defn。
...