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,但也可以只是一个map)。然后在调用 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(检查是否在注册表中)是最慢的(有点预料之中),但我没想到总是盲目分配两个函数的变种会这么接近。
by
很有趣——检查版本的代码看起来是什么样子?
by
by
> 一个原子引用/映射查找,这可能是性能减缓的原因。

我认为这里你的看法是正确的。我在读取一个def、读取单个对象的属性(如何实现一个高性能的注册表),以及执行exists?检查(reify所做的)之间看到了微小的差异,尽管显然exists?在这种情况下做的工作远不止必要。但读取原子要慢得多。

```
exists?         x 927,315,604 ops/sec ±1.02% (91 runs sampled)
缓存                       x 932,548,414 ops/sec ±1.18% (90 runs sampled)
def                       x 933,832,352 ops/sec ±1.13% (92 runs sampled)

从原子读取                x 70,540,398 ops/sec ±0.26% (97 runs sampled)
```

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

> “提升”听起来很相似,我想。我的愿望不是改变语义,在我看来这不是这样。

如果我们提升像 `42` 这样的表达式,则没有程序会出错,我们可以静态地确定这是一种安全的优化,就像cljs的常量表所做的那样。我认为提升一个返回函数的表达式不是一种安全的优化,除非我们能证明程序中没有哪个部分依赖于现有的、不同的行为,即每个函数都是一个可以独立变和比较的新唯一对象。即语义可能在你使用这些函数的特定方式下等价,而不一定是总的等价,很多JavaScript依赖于它当前是如何工作的。(我也不是编译器专家。)
by
我不太明白你对提升的评论。这并不是会被许多地方使用的东西。我唯一能想到的是核心中的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在非def变体下几乎无差异,Firefox大概慢10%。尽管我不太信任基准测试结果,因为这些结果每次运行都差异很大。

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

Hiccup在Chrome中也很具竞争力,创建慢10%,更新慢两倍。但在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版本工作方式完全相同,不需要污染全局ns。基本上是通过辅助宏提供的lambda over let方法来消除样板代码。

如果你需要某些全局函数注册表(如def),这种方法不会阻止你副作用影响到某个注册表(一个atom),并将片段函数插入其中。然后你可以通过查找函数在其它片段中引用它(或者也许可以通过宏定义一些句法糖来将东西转换为查找)。再次,不确定具体的使用场景,但我认为你可以避免提升变量。

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