2024年度Clojure调查!中分享您的想法。

欢迎!请参阅关于页面以获取更多关于如何使用此功能的信息。

+5
错误

背景

我看到了关于错误信息的Reddit讨论,以及用户关于信号与噪声比的评论引起了我的共鸣,并让我思考REPL中向用户展示堆栈跟踪的方式。

上下文

我查看了该领域的一些发展,包括
- Stuart Holloway的观点:堆栈跟踪长度总是合适的
- clj-stacktrace
- pretty
- clojure存档页面上的设计迭代,包括这个片段

我认为现有的库要么试图隐藏太多(pretty),要么主要关注展示/格式化,而不是实际内容(clj-stacktrace)。我一般同意Stuart的观点,即堆栈跟踪长度是合适的,因为每个堆栈元素都是真实的信息,是异常发生时执行的方法的明确描述。

范围

这次讨论是关于REPL向程序员展示的堆栈跟踪,思考Throwable->mapclojure.core/print-throwable,而不是重包装所有抛出的异常。

问题

堆栈跟踪的目的是展示关于执行方法的有用信息,这些信息可以为异常提供上下文。我认为,使这些信息有用的一个方面是能够查看/导航到导致异常的代码。对于Clojure代码中定义的函数,因为没有方法/包含类可以查看/导航到,因为这些的字节码是动态从函数定义中生成的。此外,对于在语义上是一个单个函数调用的堆栈跟踪来说,通常包含多个元素,其中一些元素指向函数定义所在的行,这也不太有用。

图示

给定一个文件repro.clj(我包含了行号)

1 (defn foo []
2   (/ 1 0))
3 
4 (defn bar []
5   (foo))
6 
7 (bar)

在REPL中加载此文件并显示错误将显示以下堆栈跟踪

clj
Clojure 1.10.1
user=> (load-file "repro.clj")
Syntax error (ArithmeticException) compiling at (C:\Users\Vlaaad\Projects\vlaaad.github.io\repro.clj:7:1).
Divide by zero
user=> *e
#error {
 :cause "Divide by zero"
 :via
 [{:type clojure.lang.Compiler$CompilerException
   :message "Syntax error compiling at (C:\\Users\\Vlaaad\\Projects\\vlaaad.github.io\\repro.clj:7:1)."
   :data #:clojure.error{:phase :compile-syntax-check, :line 7, :column 1, :source "C:\\Users\\Vlaaad\\Projects\\vlaaad.github.io\\repro.clj"}
   :at [clojure.lang.Compiler load "Compiler.java" 7648]}
  {:type java.lang.ArithmeticException
   :message "Divide by zero"
   :at [clojure.lang.Numbers divide "Numbers.java" 188]}]
 :trace
 [[clojure.lang.Numbers divide "Numbers.java" 188]
  [clojure.lang.Numbers divide "Numbers.java" 3901]
  [user$foo invokeStatic "repro.clj" 2]
  [user$foo invoke "repro.clj" 1]
  [user$bar invokeStatic "repro.clj" 5]
  [user$bar invoke "repro.clj" 4]
  [user$eval140 invokeStatic "repro.clj" 7]
  [user$eval140 invoke "repro.clj" 7]
  [clojure.lang.Compiler eval "Compiler.java" 7177]
  [clojure.lang.Compiler load "Compiler.java" 7636]
  [clojure.lang.Compiler loadFile "Compiler.java" 7574]
  [clojure.lang.RT$3 invoke "RT.java" 327]
  [user$eval1 invokeStatic "NO_SOURCE_FILE" 1]
  [user$eval1 invoke "NO_SOURCE_FILE" 1]
  [clojure.lang.Compiler eval "Compiler.java" 7177]
  [clojure.lang.Compiler eval "Compiler.java" 7132]
  [clojure.core$eval invokeStatic "core.clj" 3214]
  [clojure.core$eval invoke "core.clj" 3210]
  [clojure.main$repl$read_eval_print__9086$fn__9089 invoke "main.clj" 437]
  [clojure.main$repl$read_eval_print__9086 invoke "main.clj" 437]
  [clojure.main$repl$fn__9095 invoke "main.clj" 458]
  [clojure.main$repl invokeStatic "main.clj" 458]
  [clojure.main$repl_opt invokeStatic "main.clj" 522]
  [clojure.main$main invokeStatic "main.clj" 667]
  [clojure.main$main doInvoke "main.clj" 616]
  [clojure.lang.RestFn invoke "RestFn.java" 397]
  [clojure.lang.AFn applyToHelper "AFn.java" 152]
  [clojure.lang.RestFn applyTo "RestFn.java" 132]
  [clojure.lang.Var applyTo "Var.java" 705]
  [clojure.main main "main.java" 40]]}

让我们关注属于repro.clj的堆栈跟踪元素

[clojure.lang.Numbers divide "Numbers.java" 188]
[clojure.lang.Numbers divide "Numbers.java" 3901]
[user$foo invokeStatic "repro.clj" 2]
[user$foo invoke "repro.clj" 1]
[user$bar invokeStatic "repro.clj" 5]
[user$bar invoke "repro.clj" 4]
[user$eval140 invokeStatic "repro.clj" 7]
[user$eval140 invoke "repro.clj" 7]

代码中的每个Clojure函数调用都对应2个堆栈元素:invokeinvokeStatic。类user$foouser$barinvoke方法指向第1行和第4行。如果您查看repro.clj,会注意到这些是函数foobar被定义的行——这些信息并不重要,并且通常Java代码的堆栈跟踪并不指向定义方法所在的行,而只是指向堆栈上调用更高方法的方法体内部的行。虽然可以使用interop来直接调用.invoke,因为这是在IFn中定义的,但invokeStatic是编译器发出的方法,可以说是实现细节。

可能的解决方案?

什么也不做

Stuart可能是对的——如果我在调试与字节码相关的问题,我对这些详细信息将会有兴趣。此外,Clojure的某些未来版本可能以不同的方式发出函数字节码,导致堆栈跟踪更加简洁。

合并堆栈元素

这种方法将相同函数的堆栈元素合并,只显示公开的invoke方法,并报告调用堆栈中下一个方法的实际行。这是一种非常基本的过滤,将使堆栈跟踪在重新生产异常时看起来像这样

[clojure.lang.Numbers divide "Numbers.java" 188]
[clojure.lang.Numbers divide "Numbers.java" 3901]
[user$foo invoke "repro.clj" 2]
[user$bar invoke "repro.clj" 5]
[user$eval140 invoke "repro.clj" 7]
[clojure.lang.Compiler eval "Compiler.java" 7177]
[clojure.lang.Compiler load "Compiler.java" 7636]
[clojure.lang.Compiler loadFile "Compiler.java" 7574]
[clojure.lang.RT$3 invoke "RT.java" 327]
[user$eval1 invoke "NO_SOURCE_FILE" 1]
[clojure.lang.Compiler eval "Compiler.java" 7177]
[clojure.lang.Compiler eval "Compiler.java" 7132]
[clojure.core$eval invoke "core.clj" 3214]
[clojure.main$repl$read_eval_print__9086$fn__9089 invoke "main.clj" 437]
[clojure.main$repl$fn__9095 invoke "main.clj" 458]
[clojure.main$repl invoke "main.clj" 458]
[clojure.main$repl_opt invoke "main.clj" 522]
[clojure.main$main invoke "main.clj" 667]
[clojure.lang.RestFn invoke "RestFn.java" 397]
[clojure.lang.AFn applyToHelper "AFn.java" 152]
[clojure.lang.RestFn applyTo "RestFn.java" 132]
[clojure.lang.Var applyTo "Var.java" 705]
[clojure.main main "main.java" 40]

我认为这已经相当有效地降低了信号与噪声比,如果在调试与字节码相关的问题时,您仍然可以通过在异常对象上使用.getStackTrace来获取完整的堆栈跟踪。

使Clojure堆栈元素类似于代码

Clojure哲学的一部分是优先考虑函数而不是方法,也许应该让指向Clojure函数的堆栈元素看起来像函数而不是方法?大多数情况下代码中并没有.invoke互操作。我更希望对user/foo执行操作,而不是对user$foo invoke执行操作,因为这是我可以在代码中看到的东西。有人可能会争辩,即使像clojure.lang.AFn applyToHelperclojure.lang.RestFn invoke这样的堆栈元素也是不必要的,因为它们是使Clojure函数可以以可变参数调用的实现细节(已有一些过滤这些细节的)。在这个方向上还有很多未知数,我没有尝试回答,因为我不确定是否值得追求,但这是堆栈跟踪可能看起来样的一个示例。

[clojure.lang.Numbers.divide "Numbers.java" 188]
[clojure.lang.Numbers.divide "Numbers.java" 3901]
[user/foo "repro.clj" 2]
[user/bar "repro.clj" 5]
[user/eval140 "repro.clj" 7]
[clojure.lang.Compiler.eval "Compiler.java" 7177]
[clojure.lang.Compiler.load "Compiler.java" 7636]
[clojure.lang.Compiler.loadFile "Compiler.java" 7574]
[clojure.lang.RT$3.invoke "RT.java" 327]
[user/eval1 "NO_SOURCE_FILE" 1]
[clojure.lang.Compiler.eval "Compiler.java" 7177]
[clojure.lang.Compiler.eval "Compiler.java" 7132]
[clojure.core/eval "core.clj" 3214]
[clojure.main/repl/read-eval-print--9086/fn--9089 "main.clj" 437]
[clojure.main/repl/fn--9095 "main.clj" 458]
[clojure.main/repl "main.clj" 458]
[clojure.main/repl-opt "main.clj" 522]
[clojure.main/main "main.clj" 667]
[clojure.main.main "main.java" 40]

一些悬而未决的问题
将Clojure函数调用表示为单个符号,而不是一个类+方法对,要求将Java方法调用表示为单个符号,它们应该是什么样子?clojure.lang.Compiler/evalclojure.lang.Compiler.eval还是其他什么?
如何显示deftypes/reifies的方法调用?这两个都是Clojure和方法...
Var/AFn/RestFn堆栈元素真的无关紧要吗?

其他选项

可能会有其他的选项...

你怎么看?

2 答案

+1投票

被选中
 
最佳答案

根据Kevin的评论,我真的很想看到实际的堆栈跟踪,所以我并不认为它应该消失。但思考如何应用工具来分析堆栈跟踪以获得更好的洞察也非常有用。话虽如此,很多人已经投入了时间,但结果(据我所知)并不比原来的更好。我这么说不是要让事情变得复杂,只是指出看似简单的事情实际上很复杂。

我应该提到,您恰好选择了一个场景,它会触发负载中的一个已知错误https://clojure.atlassian.net/browse/CLJ-2529,在这个错误中,阶段被错误地报告了(在这里通过顶层形式评估到达)。

(ns repro)

(defn foo []
  (/ 1 0))

(defn bar []
  (foo))

然后将可重复的场景放在您的源代码中,并调用

% clj
Clojure 1.10.3
user=> (require 'repro)
nil
user=> (repro/bar)
Execution error (ArithmeticException) at repro/foo (repro.clj:4).
Divide by zero

我认为你还应该注意,Clojure提供了一个打印堆栈跟踪(pst)的功能,它可以为你进行一定程度的清理(在一些情况下,它甚至会省略涉及错误报告的顶层帧 - 这在1.10.0中经过调整以改善许多情况)

user=> (pst *e)
ArithmeticException Divide by zero
	clojure.lang.Numbers.divide (Numbers.java:188)
	clojure.lang.Numbers.divide (Numbers.java:3901)
	repro/foo (repro.clj:4)
	repro/foo (repro.clj:3)
	repro/bar (repro.clj:7)
	repro/bar (repro.clj:6)
	user/eval150 (NO_SOURCE_FILE:1)
	user/eval150 (NO_SOURCE_FILE:1)
	clojure.lang.Compiler.eval (Compiler.java:7181)
	clojure.lang.Compiler.eval (Compiler.java:7136)
	clojure.core/eval (core.clj:3202)
	clojure.core/eval (core.clj:3198)

由于这是一种“打印”方法,我们可能可以在此进行更多帮助,比如合并调用帧。检测这些帧是否相同可能会很具挑战性,我不知道。用于在字节码中发出行/列调试符号的工具很奇怪,而且我认为这些工具已经很久没有更新了,甚至可能存在我们没有最初就用的选项。(在编译器和源调试扩展中寻找visitSource和visitLineNumber,如https://github.com/clojure/clojure/blob/master/src/jvm/clojure/lang/Compiler.java#L4326-L4340 - 看看JSR 45)。

我相信JRuby已经在这个相同问题上花费了大量的时间(显示来自JRuby JVM的Ruby堆栈跟踪),这可能是值得深入了解的东西(我还没有研究它)。

+5

我对那些篡改堆栈跟踪的工具的使用体验是,它们似乎没什么问题,直到你有一天遇到了一个奇怪的错误,这个错误让人感到困惑。你花了几天才试图调试它,然后才发现篡改隐藏了一些关键信息,于是你发誓再也不相信这些工具。

即使是成对出现的invoke和invokeStatic框架也能告诉你一些信息。invokeStatic的存在本身就告诉我们一些信息。

  1. 如果你看到同一个函数既有invoke也有invokeStatic,这意味着这个函数不封闭任何东西,调用代码也没有使用直接链接进行编译。
  2. 如果你只看到invokeStatic,这意味着这个函数不封闭任何东西,调用代码是用直接链接进行编译的。
  3. 如果你只看到invoke,这意味着这个函数封闭了某些内容,但它并不能告诉你是否开启了直接链接。

如果你还没看过https://github.com/stuartsierra/stacktrace.raw,那么它是非常有价值的。这是一段代码,试图还原各种异常“美化”工具的效果。它的README中有一个很好的“为什么存在这个?”部分(https://github.com/stuartsierra/stacktrace.raw#why-this-exists)。

Alex已经做了很多工作来尝试改进Clojure的错误和错误报告,因为它一直是一个永恒的抱怨,但据我所知,他没有做任何干扰堆栈跟踪内容的事情。现在Clojure开始做这个似乎不太可能。

关于从`invokeStatic`的存在或缺失中可以提取的所有信息,这些都是很好的观点,我学到了。

我查看了stacktrace.raw中的“为什么存在这个?”部分,它似乎主要关注现有异常“美化”工具中的错误,而不是我想识别的问题。
这些确实是很好的观点,通常我们认为访问真实的堆栈跟踪非常重要。但是,同样没有理由不也有其他工具来解释跟踪。
...