2024 Clojure调查!(a)中分享您的想法!

欢迎!请参阅关于页面,了解如何工作的更多信息。

+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中没有“身份”,运行时无法分辨这是否是之前的相同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,也可以是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来展示 `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) 是最慢的(预期之内),但我没有预料到总是盲目分配两个函数的变种会如此接近。
by
有趣,已检查变体的代码是什么样的?
> 这是一个很可能是导致速度变慢的原因的原子引用/映射查找。

我觉得你在这里是对的。我在读取def,读取单个对象属性(如何实现高性能的注册),以及进行exists?检查(reify所做),之间没有看到太大的区别,尽管很明显exists?对于这个特定情况来说做的工作远多于必要。但读取原子要慢得多。

```
exists?         x 927,315,604 ops/sec ±1.02%(采样运行91次)
缓存                                               x 932,548,414 ops/sec ±1.18%(采样运行90次)
def                                                     x 933,832,352 ops/sec ±1.13%(采样运行92次)

从原子读取                                             x 70,540,398 ops/sec ±0.26%(采样运行97次)
```

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

> "提升"听起来很相似。我的期望不是改变语义,我认为这不是我要改变的内容。

如果我们提升一个像`42`这样的表达式,没有任何程序会崩溃,我们可以静态地确定这是一个安全的优化,这就是cljs常量表所做的工作。我觉得在无法证明程序没有部分依赖于现有的不同行为的情况下提升一个返回函数的表达式不是一个安全的优化。这种行为是:每个函数都是一个可以独立变异和比较的新唯一对象。(我也不是编译器专家。)
我不确定我是否理解了你对提升的评论。这不是在许多地方都会用到的东西。我想到的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 变体时仅慢约 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版本完全相同,而无需污染全局ns。基本上,使用由辅助宏提供的让-over-lambda方法来消除样板。

如果你“需要”一些类似def的全局函数注册表,阻止此方法对一些注册表(原子)产生副作用并入录片段函数。然后你可以在其他片段通过查找函数引用它(或者 perhaps 定义一些通过宏的语法糖来将东西翻译为查找)。再次,不确定具体用例是什么,但我认为你可以避免提升变量。

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