背景
我看到了关于错误信息的Reddit讨论,以及用户关于信号与噪声比的评论引起了我的共鸣,并让我思考REPL中向用户展示堆栈跟踪的方式。
上下文
我查看了该领域的一些发展,包括
- Stuart Holloway的观点:堆栈跟踪长度总是合适的
- clj-stacktrace库
- pretty库
- clojure存档页面上的设计迭代,包括这个片段
我认为现有的库要么试图隐藏太多(pretty),要么主要关注展示/格式化,而不是实际内容(clj-stacktrace)。我一般同意Stuart的观点,即堆栈跟踪长度是合适的,因为每个堆栈元素都是真实的信息,是异常发生时执行的方法的明确描述。
范围
这次讨论是关于REPL向程序员展示的堆栈跟踪,思考Throwable->map
和clojure.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个堆栈元素:invoke
和invokeStatic
。类user$foo
和user$bar
的invoke
方法指向第1行和第4行。如果您查看repro.clj
,会注意到这些是函数foo
和bar
被定义的行——这些信息并不重要,并且通常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 applyToHelper
和clojure.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/eval
与clojure.lang.Compiler.eval
还是其他什么?
如何显示deftypes/reifies的方法调用?这两个都是Clojure和方法...
Var/AFn/RestFn堆栈元素真的无关紧要吗?
其他选项
可能会有其他的选项...
你怎么看?