请在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函数调用都有两个对应的堆栈元素:invokeinvokeStatic。对于类user$foouser$bar的方法invoke指向第1行和第4行。如果您查看repro.clj,你会发现这些是定义函数foobar的行——这些信息并不有用,通常Java代码的堆栈跟踪不会指向方法定义的行,只会指向方法体内部调用堆栈中更高方法行的行。虽然可以直接使用互操作来调用.invoke,因为这在IFn中定义了,但invokeStatic是编译器输出的一种方法,这可以说是实现细节。

可能的解决方案?

什么都不做

Stuart可能是对的——如果我在调试与字节码相关的问题,我一定会对此类细节感兴趣。另外,Clojure的某个未来的版本可能会以不同的方式输出函数字节码,导致堆栈跟踪更加简洁易读。

合并堆栈元素

这种方法合并了相同函数的堆栈元素,只显示对下一个方法调用实际执行的行号,并保持只公开invoke方法可见。这是一个非常基本的过滤,将使堆栈跟踪对异常的repro看起来像这样

[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还是其他什么?
- 如何显示deftype/reifies的方法调用?这些既是Clojure又是方法...
- 是否Var/AFn/RestFn堆栈元素真正无关紧要?

其他事情

可能还有其他选项...

你认为怎么样?

2 答案

+1

已选中
 
最佳答案

根据Kevin的评论,我确实想看到实际的堆栈跟踪,所以我并不认为它应该消失。但是,思考一下可以将哪些工具应用于堆栈跟踪以提供更好的洞察力也是有用的。话虽如此,许多人投入了大量时间,但结果(我认为)并没有比原始的更好。我说这句话,不是为了泼凉水,只是注意到这似乎很简单,但细节很复杂。

我需要指出,你恰好选择了一个场景,它会在加载的已知错误(https://clojure.atlassian.net/browse/CLJ-2529)上引起itect,其中阶段被错误地报告(在这里从顶层形式评估中达到)。虽然这是显而易见的,但你更可能会在没有顶层调用bar的情况下将这段代码放在一个命名空间中,然后再将这一现象放入你的源代码,并调用

(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,它非常有价值。这是一些试图还原各种异常“美化器”效果的代码。它的自述文件有一个关于“为什么存在?”的不错部分(《https://github.com/stuartsierra/stacktrace.raw#why-this-exists》)。

Alex付出了很多努力来尝试改进Clojure的错误和错误报告,因为它一直是一个经常抱怨的问题,但据我所知,他没有做任何事情来干扰堆栈跟踪的内容。Clojure现在开始的可能性似乎不大。

by
关于您可以从`invokeStatic`的存在或不存在中提取的所有信息,很好的观点。我刚才学到了。

我读了 stacktrace.raw 中的“为什么存在?”部分,它似乎主要关注现有的异常美化器中的错误,而不是我试图识别的问题。
by
这些确实是很好的观点,并且我们通常认为访问真实的堆栈跟踪很重要。然而,没有必要没有其他解释跟踪的工具。
...