2024年Clojure状态调查中分享您的想法!

欢迎!请参阅关于页面以了解更多有关该方式的信息。

+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]))

因此,片段“handler”仅创建一次(目前使用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,可能是一个绑定),它基本上允许它们将形式“prepend”到它们当前正在工作的任何顶层形式上。

这是否应该是编译器原生支持的功能?

我知道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)

with-checks (checking if in registry) 是最慢的(预期如此),但我没有预料到始终盲目分配两个函数的变体会如此接近。
by
很有趣——检查变体的代码是什么样的?
>> 这是一个导致性能下降的可能的原因:一个原子引用/查找操作。

我认为你在这里是对的。我在读取 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 次)
```

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

>> "提升"听起来很相似。我个人不愿意更改语义,我认为这不是改变语义。

如果我们提升一个如 `42` 这样的表达式,没有任何程序会出问题,我们可以静态地确定这是一个安全的优化,正如 cljs 常量表所做的。我认为如果无法证明程序中没有部分依赖于现有的、不同的行为(即每个函数是一个可以独立被突变和比较的新唯一对象),就冒险进行提升返回函数的优化是不安全的,除非我们可以证明程序中没有部分依赖于现有的行为。也就是说,语义可能在你使用这些函数的特定方式下是等效的,而不是普遍等效的;很多 JavaScript 都依赖于当前的这种工作方式。(我也不是编译器的专家。)
我不太确定我是否理解了你对提升的评论。这不会在许多地方使用。我想起 core 中唯一可能用到的东西就是 reify。整个目的就是要得到 ns 级别 def 的确切语义。任何为此目的而使用此代码的代码都是不正确的。它甚至无法访问宏运行时的局部变量。

看到 Closure Compiler 对 `:advanced` 具体设置的代码所做的事情已经使我决定将此留在 shadow-cljs 中。它不需要进入 core,因为我在它上面添加的备用方案也能工作,只是运行得稍微慢一些并生成更多的代码。

编辑了
没有跟上你的所有操作,但你不可以在宏展开时执行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

我使用通过宏创建的lexical bindings和defn来访问你所描述的使用案例。

(defmacro fragment [name args & body]
  `(let [frag# (fragment-create ~args ~@body)]
     (defn ~name ~args
       (frag# ~@args))))

不确定确切的管道,但看起来它会像你展示的def版本一样工作,而不必污染全局命名空间。基本上,使用由辅助宏促进的let-over-lambda方法,以消除样板代码。

如果你“需要”一些全局函数注册表(如def),没有阻止这种方法通过副作用来注册(一个原子)并插入片段函数。然后你可以通过查询函数在其它片段中引用该函数,或者定义一些通过宏转换为查询的语法糖。再次,不确定确切的用例是什么,但我认为你可以避免提升变量。

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