请分享您的观点,参加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,另一个用于更新实际可以更改的部分。这样做是为了尽量减少“diffing”(差异比较)。

关于这个话题就不再过多展开。基本上,它会生成以下内容

(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的,也可以是一个绑定),它基本上允许它们在没有工作时将某个形式“添加”到它目前正在处理的顶部形式。

编译器是否应该原生支持这一点?

我知道Clojure也没有它,但它通过创建类并在需要时访问它们来进行,因此某种程度上是在这样做。

实施的方案可能有所不同。我只是试图确定自己是否疯了,竟然想最初做这件事。之前的方法运行良好,只是代码量增加了,因为不能依赖于 identical?,所以增加了一些额外的检查。

2 个回答

+1投票

在当前的语言/编译器中思考,希望的行为类似于 defonce,但支持REPL/实时重载的更改。如果 defonce 中的 exists? 检查太慢,那么我认为可以写一个类似的宏,每次调用的成本大约是一个JavaScript对象属性查找(在 def 上或使用汤姆提到的注册表进行。)

在开发过程中确定何时以及如何使先前值无效似乎更棘手。在宏展开期间使用 gensym 可以在顶级形式重新编译时获得新的条目。你会重新评估编辑器中的某些内容吗?阴影(shadow)不会重新编译但会使用缓存版本,因此你会陷入无用值中?

"提升"听起来像 提升操作循环不变式代码移动,但与这些编译器优化不同,这里的目的是改变语义,所以在我看来这是一件相当不同的事情(即使机理相似)。

"提升"听起来可能类似。我的目的是不改变语义,我认为这不是。

我实际上并不寻求替代建议。我已经尝试了很多替代方案,它们在需要时都很好地完成了任务。然而,我这里提出的解决方案代码量最少,从理论上看应该是最高效的,因为不需要进行任何检查。还需要编写基准测试,所以我应该首先做那件事。

我创建了一个[示例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
有趣——经过检查的代码的样子是什么样子?
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常量表就是这样做的。我认为提升返回函数的表达式不是一个安全的优化,除非我们可以证明程序中的任何部分都不依赖现有的、不同的行为,即每个函数都是一个可以独立修改和比较的新唯一对象。也就是说,对你使用这些函数的特定方式,语义可能是一致的,但并不是普遍的,很多js依赖其当前的工作方式。(我也不是编译器专家。)
by
我不太理解您对提升的评论。这不会在许多地方使用。我唯一能想到的是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])))
看到 defn 中的 defonce 让我有些总局促不安。:P

但经过一些调整后,我在 Chrome 和 Firefox 中得到的性能差异无法衡量。Firefox 使用非-def 变异版本时只比 Chrome 慢约 10%。

非-def 变异版本生成的代码略多,但每个片段只有约 50 字节,而且很可能足够压缩,所以这并不重要。不知道解析/加载性能如何,还需要找出如何进行基准测试。

Hiccup 在 Chrome 中也相当有竞争力,创建和更新时只慢 10%,但创建在 Firefox 中慢 5 倍,更新慢 10 倍,在更复杂的场景中差异更加明显。更不用提在 :none 中的 100 倍差异,这是由于所有的非优化关键字构造导致的。

尝试基准测试这个功能已经浪费了很多时间。我对结果感到满意,结果显示这并不值得我们担心。
哈哈,当然,但我不明白为什么像看到 def 一样,defonce 会让你浑身起鸡皮疙瘩 :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),没有任何阻止此方法对某些注册表(一个原子)产生副作用并插入片段函数。然后你可以通过查找函数在其他片段中引用它(或者也许可以通过宏定义一些语法糖来将东西转换为查找)。再次,不确定具体用例是什么,但我认为你可以避免提升变量。

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