2024 Cloujure 状态调查!中分享你的想法。

欢迎!请参阅关于页面,了解更多关于这个工作原理的信息。

+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

在当前的语言/编译器中思考,所需的 行为看起来像 defonce,但支持REPL/live-reload更改。如果defonce中的exists?检查太慢,我认为可以编写一个具有大约一次JavaScript对象属性查找成本(在def之上或使用Tom提到的登记处)的类似宏。

在开发过程中,确定何时无效化前面的值似乎更复杂。在宏展开期间使用gensym可以在顶层形式重新编译时获取一个新的条目。在你从编辑器中重新评估某些内容时,shadow不会重新编译而使用一个缓存版本,所以你会被一个过时的值固定住吗?

“提升”听起来与提升循环不变代码运动很相似,但与这些编译器优化不同,这里的目的是改变语义,因此它在我看来是相当不同的东西(即使机制相似)。

我想“提升”听起来很相似。我的目的是不改变语义,而这个(在我看来)并不会。

我并不是真的在寻找替代方案。我已经经历了许多替代方案,它们在需要的时候都很好地完成了工作。然而,这里提出的解决方案需要的代码最少,从理论上讲应该是性能最好的,因为不需要在任何地方进行检查。仍然需要编写基准测试,因此我认为我应该首先做那件事。

我创建了一个示例代码仓库([示例代码仓库](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)
```

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

> “提升”听起来很相似。我认为我的目标是不要改变语义,因为在我看来这不会改变语义。

如果我们提升类似于 `42` 这样的表达式,没有任何程序会出错,我们可以静态地确定这是一个安全的优化,就像cljs常量表所做的那样。我认为提升返回函数的表达式不是一种安全的优化方式,除非我们能证明程序中没有部分依赖于现有的不同行为,即每个函数是一个新的唯一对象,它可以独立地修改和比较。换句话说,语义可能在你使用的这些函数的特定方式下是等效的,而不是普遍等效的,因为许多JavaScript依赖于当前的工作方式。(我也不是编译器专家。)
by
我不太明白你对提升的评论。这不会在许多地方被使用。我想起的唯一的例子是core中的reify。整个目的是获取ns级别def的确切语义。任何用它来做别的事情的代码都是不正确的。它甚至不能访问宏运行时的局部变量。

看到Closure Compiler在:advanced级别对代码所做的操作已使我确信要将其保留在shadow-cljs中。它不需要进入核心,因为我添加的回退方案也可以很好地工作,只是运行速度稍慢,并生成更多的代码。
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倍的性能差异。

我已经浪费了很多时间试图对这个进行基准测试。我对结果表明,这整体上并不值得担忧而感到满意。
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),这不会阻止这种方法通过副作用来更新注册表(一个原子),并将片段函数插入其中。然后,你可以在其他片段中通过查找函数引用它(或者也许可以通过宏定义一些语法糖来将东西转换为查找)。再次,不确定具体的使用案例,但我认为你可以避免提升变量。

by
如果添加条件检查或其他“胶水”代码,我可以避免提升变量。关键点是移除那种条件检查并且只执行一次。片段宏可以在任何地方使用,所以我不能让它自己只是一个defn。
...