请分享您的想法参加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,另一个用于更新真正可变的部分。这样做是为了尽可能减少“差异”。

关于这一点,不再过多详细说明,它基本上生成以下内容

(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
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` 本身中确实有一个明确的使用案例。
编写基准测试的事实小趣闻:提升变体比其他任何东西都要好一个数量级 ... 但并不是因为实际代码更快。闭包可以更好地分析它,并决定在基准测试中删除大部分代码,而其他所有的代码都保留下来了。
尽管如此,结果很有趣。我在提升变体最快这一方面的直觉是正确的,但超出预期的快。

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
> 可能是这个原子解引用/查找操作导致的性能下降。

我认为你说得对。我在读取-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)
```

source w compiled js: https://gist.github.com/mhuebert/2781f9d1b2481301a8eb17ce2c5d0e3e

> "提升"听起来有点相似。我认为我的意图不是改变语义,这不是我所希望的。

如果我们像`42`这样的表达式进行提升,没有程序会出错,我们可以静态地确定这是一个安全的优化,这也是cljs常数表所做的。我认为如果无法证明程序中没有依赖于现有不同的行为,即每个函数都是一个可以独立修改和比较的新唯一对象,那么提升一个返回函数的表达式不是一个安全的优化。换句话说,对于你使用这些函数的方式,可能会有等价的语义,但并不普遍等价,很多js依赖于目前的工作方式。(我也不是编译器专家。)
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 在使用非 def 变体时只有大约 10% 的速度减慢。尽管我不太相信基准测试结果,因为它们每次运行之间的变化太大。

非 def 变体产生的代码略多,但每个片段大约只多出 50 字节,并且可能压缩得足够好,所以不太重要。不知道 parse/load 的性能,还需要找出如何进行基准测试。

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。基本上使用let-over-lambda方法,通过辅助宏消除样板代码。

如果你“需要”一些全局函数注册表(如def),这个方法不会有任何阻止它对某些注册表(一个atom)产生副作用,并将片段函数插入其中。然后你可以通过查找函数在其他片段中引用它(或者通过宏定义一些语法糖来将东西转换为查找)。再次,不确定确切的用例是什么,但我想你可以避免提升变量。

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