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,一个用于更新实际可更改的部分。这是为了避免尽可能多的“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]))

所以这个“处理”片段只创建一次(目前使用 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 提到的。)

在开发过程中找出何时使先前值无效似乎更困难。在宏展开期间,可以使用 gensym 在顶层的表达形式被重新编译时获取新条目。你会在什么情况下从你的编辑器重新评估某些内容,但 shadow 不重新编译而是使用缓存的版本,因此你将陷入一个过时的值吗?

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

"提升"听起来可能相似。我的愿望不是改变语义,我认为这并不改变语义。

我并不是真的在寻找替代建议。我已经经历过很多这样的替代建议,并且它们在需要时都很好地完成了工作。然而,在这里我所提出的方法需要的代码最少,在理论上应该是最高效的,因为不需要在任何地方进行检查。还需要编写基准测试,所以我想我应该首先做那件事。

我创建了一个[示例 gist](https://gist.github.com/thheller/8688986f8c7a1f6aef1b4a411ed3bf0f#file-reify-current-js)来展示 `reify` 的输出。请注意,所有内容都嵌套在 `make_thing` 中。在我看来,这表明在 `cljs.core` 自身中确有此用法。
编写基准测试时,提升变体的性能优于其他所有方法,但并非因为实际代码更快。 Closure 可以更好地分析它,并决定从基准测试中删除大部分代码,而其他人则保留了所有代码。
尽管如此,结果很有趣。我直觉上认为提升变体是最快的,但比预期的要快。

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 (检查是否在注册表中) 远远是最慢的(有点预料之中),但我没有预料到总是盲目分配两个函数的变体会如此接近。
有趣的是,检查版本的代码是什么样的?
>> 原因很可能是这个原子 deref/map 查找速度较慢。

我认为你在这里是对的。我在读取 def、读取单个对象属性(如何实现一个高性能的注册)和执行 exists? 检查(reify 所做)之间看到的区别很小,尽管显然 exists? 在这个特定案例中做了更多不必要的操作。但读取原子的速度太慢了。

```
exists?         每秒操作数 927,315,604 ±1.02%(随机取样 91 次)
cache             每秒操作数 932,548,414 ±1.18%(随机取样子 90 次)
def             每秒操作数 933,832,352 ±1.13%(随机取样子 92 次)

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

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

>> 恕我直言,“提升”听起来很相似。我并不想改变语义,我认为这不是什么改变语义的事情。

如果我们提升一个像 `42` 这样的表达式,没有任何程序会出错,我们可以静态地确定这是一个安全的优化,就像 cljs 的常量表所做的那样。我认为提升一个返回函数的表达式不是一个安全的优化,除非我们能证明程序没有依赖现有的不同行为,即每个函数都是一个可以独立修改和比较的新唯一对象。(我也不是编译器专家。)
我不太懂你关于提升的评论。这并不常用,在 core 中我只想到 reify。关键点是要得到 ns 级 def 的确切语义。任何为了其他目的使用此代码的代码都是不正确的。它甚至无法访问宏运行的局部变量。

看到 Closure Compiler 在 :advanced 上对代码所做的操作已经让我决定将其保留在 shadow-cljs 中。因为这已经有了回退,它不需要进入 core,因为它的工作也足够好,只是稍慢一些,生成的代码也稍多。
by
编辑 by
没有跟进你所做的一切,但你是否可以在宏展开时执行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])))
by
根据defn内部的defonce看,我感到很不舒服。 :P

但是在一些修改后,我让它在Chrome和Firefox中几乎没有什么可衡量的差异,Firefox使用非def变体只慢了大约10%。

非-def变体产生的代码略多,但每个片段大约只多50字节,并且在gzip中可能压缩得很好,所以这并不重要。不知道关于解析/加载性能,还需要找出如何基准测试那。

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 版本完全相同,而不必污染全局命名空间。基本上是使用由辅助宏促进的 let-over-lambda 方法来消除样板代码。

如果你“需要”某种全局函数注册表(如 def),这种做法不会阻止其对某些注册表(一个原子)进行副作用,并将片段函数插入其中。然后你可以通过查找函数在其他片段中引用它(或者也许可以通过宏定义一些语法糖来将内容转换为查找)。再次,不确定具体的使用场景,但我想你可以避免变量的提升。

如果我在宏中添加条件检查或其他“粘合”代码,我可以避免变量的提升。重点是移除这个条件检查,并且只做一次。片段宏可以在任何地方使用,所以我不可以把它本身也做成一个 defn。
...