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/实时重新加载的变化。如果defonce中的exists?检查太慢,我认为可以写一个类似的宏,每次调用的成本约为一次JavaScript对象属性查找(在def之上或使用注册表,正如Tom提到的)。

确定在开发过程中何时以及如何使旧值无效似乎更加复杂。在宏展开期间使用类似gensym的东西可以在顶层形式重新编译时获得一个新的条目。有没有可能重新评估编辑器中的某些内容,而shadow不会重新编译但使用缓存的版本,导致你卡在一个过时的值上?

"提升"这个词听起来有点像提升循环不变代码移动,但与这些编译器优化不同,这里的愿望是改变语义,所以它给我感觉是一种相当不同的东西(即使机制类似)。

by
"提升"听起来可能有点像。我个人认为我不想改变语义,而这点并不需要。

我并不真的需要替代建议。我已经尝试过很多,它们都在需要时表现良好。然而,我在这里提出的方法需要的代码最少,从理论上讲,应该是最高效的,因为不需要在任何一个地方进行检查。基准测试仍然需要编写,所以我想我第一步应该就是做这个。

我创建了一个[示例代码片段](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 (检查是否在注册表中)明显是最慢的(正如预期的那样),但我没有预料到总是盲目分配两个函数的变种会如此接近。
有趣的是,检查变体的代码是什么样的?
> 一个原子引用/映射查找,这可能是性能下降的原因。

我想你可能在这里是对的。我在读取 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  每秒 70,540,398 次操作 ±0.26%(采样 97 次)
```

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

> “提升”听起来很相似,我猜。我的愿望不是改变语义,我认为这个并没有。

如果我们提升像 `42` 这样的表达式,则没有任何程序会中断,我们可以静态地确定这是一次安全优化,这与 cljs 常量表所做的是一样的。我认为将返回函数的表达式提升不是一种安全的优化,除非我们能证明程序中没有部分依赖于现有的、不同的行为,即每个函数都是可以独立地突变和比较的新唯一对象。换句话说,对于你使用这些函数的特定方式,语义可能是等价的,而并不是普遍等价的,很多 JavaScript 都依赖于当前的工作方式。(我也不是编译器专家。)
我不确定我能否理解你对提升(hoisting)的看法。这并非在很多地方都会使用的东西。我在核心中能想到的唯一一个东西就是“具体化”(reify)。主要目的是获取ns级别的定义的确切语义。任何使用此方法进行其他操作的错误代码。它连运行宏的位置的局部变量都无法访问。

看到Closure Compiler对:advanced中的代码做了什么已经让我决定保持它在shadow-cljs中。它不需要进入核心,因为我在里面添加的回退(fallback)也可以正常工作,只是运行慢一点,生成的代码也多一点。

编辑
没太明白你做的事情,但你不可以在宏展开时执行这个def吗?这是我在Clojure中会做的事情。

而不是返回

    (def fragment-123
              (fragment-create
              (fn create-fn [state] ...)
              (fn update-fn [state] ...)))

我会在宏中执行它,并只返回

    (fragment-use fragment-123 [title body])

但是也许在CLJS中这样做是不可能的,因为宏无法在运行时使用,我也不确定。

另外,为了检查变量是否已定义,用defonce而不是def不是吗?代替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性能,还需要找出如何进行基准测试。

小颠也是一个意想不到的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。
...