Clojure

REPL 編程:資料視覺化

每次我們評估一個表達式,REPL 都會向我們顯示結果的文字表示:那是 讀取-評估-列印迴圈 中的 列印 部分。大多數時候,這個文字表示對程式設計師來說已經夠清楚了,但有時會變得難以閱讀 - 尤其是在處理大型或深度巢狀資料結構時。

幸運的是,REPL 提供了更銳利的資料視覺化工具,我們將在本章中說明。

使用 clojure.pprint 進行漂亮列印

舉例來說,考慮以下程式碼,它計算一些數字的算術屬性的摘要

user=> (defn number-summary
  "Computes a summary of the arithmetic properties of a number, as a data structure."
  [n]
  (let [proper-divisors (into (sorted-set)
                          (filter
                            (fn [d]
                              (zero? (rem n d)))
                            (range 1 n)))
        divisors-sum (apply + proper-divisors)]
    {:n n
     :proper-divisors proper-divisors
     :even? (even? n)
     :prime? (= proper-divisors #{1})
     :perfect-number? (= divisors-sum n)}))
#'user/number-summary
user=> (mapv number-summary [5 6 7 12 28 42])
[{:n 5, :proper-divisors #{1}, :even? false, :prime? true, :perfect-number? false} {:n 6, :proper-divisors #{1 2 3}, :even? true, :prime? false, :perfect-number? true} {:n 7, :proper-divisors #{1}, :even? false, :prime? true, :perfect-number? false} {:n 12, :proper-divisors #{1 2 3 4 6}, :even? true, :prime? false, :perfect-number? false} {:n 28, :proper-divisors #{1 2 4 7 14}, :even? true, :prime? false, :perfect-number? true} {:n 42, :proper-divisors #{1 2 3 6 7 14 21}, :even? true, :prime? false, :perfect-number? false}]
user=>

目前,你不需要了解上面定義的 number-summary 函式的程式碼:我們只是將它用作合成一些複雜資料結構的藉口。針對特定領域的真實世界 Clojure 程式設計將為你提供許多此類複雜資料結構的範例。

正如我們所見,最後一個表達式的結果濃縮在單一行中,這使得它難以閱讀

user=> (mapv number-summary [5 6 7 12 28 42])
[{:n 5, :proper-divisors #{1}, :even? false, :prime? true, :perfect-number? false} {:n 6, :proper-divisors #{1 2 3}, :even? true, :prime? false, :perfect-number? true} {:n 7, :proper-divisors #{1}, :even? false, :prime? true, :perfect-number? false} {:n 12, :proper-divisors #{1 2 3 4 6}, :even? true, :prime? false, :perfect-number? false} {:n 28, :proper-divisors #{1 2 4 7 14}, :even? true, :prime? false, :perfect-number? true} {:n 42, :proper-divisors #{1 2 3 6 7 14 21}, :even? true, :prime? false, :perfect-number? false}]

我們可以使用 clojure.pprint 函式庫以更「視覺化」的格式列印結果

user=> (require '[clojure.pprint :as pp])
nil
user=> (pp/pprint (mapv number-summary [5 6 7 12 28 42]))
[{:n 5,
  :proper-divisors #{1},
  :even? false,
  :prime? true,
  :perfect-number? false}
 {:n 6,
  :proper-divisors #{1 2 3},
  :even? true,
  :prime? false,
  :perfect-number? true}
 {:n 7,
  :proper-divisors #{1},
  :even? false,
  :prime? true,
  :perfect-number? false}
 {:n 12,
  :proper-divisors #{1 2 3 4 6},
  :even? true,
  :prime? false,
  :perfect-number? false}
 {:n 28,
  :proper-divisors #{1 2 4 7 14},
  :even? true,
  :prime? false,
  :perfect-number? true}
 {:n 42,
  :proper-divisors #{1 2 3 6 7 14 21},
  :even? true,
  :prime? false,
  :perfect-number? false}]
nil

提示:使用編輯器來語法突顯結果

如果你希望以更視覺化的對比顯示你的漂亮列印結果,你也可以將它複製到你的編輯器緩衝區(下面使用的編輯器是 Emacs

Copying pretty-printed result to editor

需要漂亮列印最後的 REPL 結果非常常見,因此 clojure.pprint 有個函式可用於此:clojure.pprint/pp

user=> (mapv number-summary [12 28])
[{:n 12, :proper-divisors #{1 2 3 4 6}, :even? true, :prime? false, :perfect-number? false} {:n 28, :proper-divisors #{1 2 4 7 14}, :even? true, :prime? false, :perfect-number? true}]
user=> (pp/pp)
[{:n 12,
  :proper-divisors #{1 2 3 4 6},
  :even? true,
  :prime? false,
  :perfect-number? false}
 {:n 28,
  :proper-divisors #{1 2 4 7 14},
  :even? true,
  :prime? false,
  :perfect-number? true}]
nil

最後,對於結果是映射序列(例如上述結果),你可以使用 clojure.pprint/print-table 將它列印為表格

user=> (pp/print-table (mapv number-summary [6 12 28]))

| :n | :proper-divisors | :even? | :prime? | :perfect-number? |
|----+------------------+--------+---------+------------------|
|  6 |         #{1 2 3} |   true |   false |             true |
| 12 |     #{1 2 3 4 6} |   true |   false |            false |
| 28 |    #{1 2 4 7 14} |   true |   false |             true |
nil

截斷 REPL 輸出

當一個表達式評估為一個大型或深度巢狀資料結構時,閱讀 REPL 輸出可能會變得困難。

當結構巢狀太深時,你可以透過設定 *print-level* 變數來截斷輸出

user=> (set! *print-level* 3)
3
user=> {:a {:b [{:c {:d {:e 42}}}]}} ;; a deeply nested data structure
{:a {:b [#]}}

你可以透過評估 (set! *print-level* nil) 來取消此設定。

同樣地,當資料結構包含長集合時,你可以透過設定 *print-length* 變數來限制顯示的項目數量

user=> (set! *print-length* 3)
3
user=> (repeat 100 (vec (range 100))) ;; a data structure containing looooong collections.
([0 1 2 ...] [0 1 2 ...] [0 1 2 ...] ...)

與上述相同,評估 (set! *print-length* nil) 來取消此設定。

*print-level**print-length* 會影響一般的 REPL 列印和漂亮列印。

存取最近的結果:*1*2*3

在 REPL 中,最後評估的結果可透過評估 *1 擷取;前一個結果儲存在 *2,再前一個則儲存在 *3

user=> (mapv number-summary [6 12 28])
[{:n 6, :proper-divisors #{1 2 3}, :even? true, :prime? false, :perfect-number? true} {:n 12, :proper-divisors #{1 2 3 4 6}, :even? true, :prime? false, :perfect-number? false} {:n 28, :proper-divisors #{1 2 4 7 14}, :even? true, :prime? false, :perfect-number? true}]
user=> (pp/pprint *1) ;; using *1 instead of re-typing the previous expression (or its result)
[{:n 6,
 :proper-divisors #{1 2 3},
 :even? true,
 :prime? false,
 :perfect-number? true}
{:n 12,
 :proper-divisors #{1 2 3 4 6},
 :even? true,
 :prime? false,
 :perfect-number? false}
{:n 28,
 :proper-divisors #{1 2 4 7 14},
 :even? true,
 :prime? false,
 :perfect-number? true}]
nil
user=> *1 ;; now *1 has changed to become nil (because pp/pprint returns nil)
nil
user=> *3 ;; ... which now means that our initial result is in *3:
[{:n 6, :proper-divisors #{1 2 3}, :even? true, :prime? false, :perfect-number? true} {:n 12, :proper-divisors #{1 2 3 4 6}, :even? true, :prime? false, :perfect-number? false} {:n 28, :proper-divisors #{1 2 4 7 14}, :even? true, :prime? false, :perfect-number? true}]
user=>

提示:透過 def 定義來儲存結果

如果您想要保留結果超過 3 次評估,可以評估 (def <some-name> *1)

user=> (mapv number-summary [6 12 28])
[{:n 6, :proper-divisors #{1 2 3}, :even? true, :prime? false ; ...
user=> (def my-summarized-numbers *1) ;; saving the result
#'user/my-summarized-numbers
user=> my-summarized-numbers
[{:n 6, :proper-divisors #{1 2 3}, :even? true, :prime? false ; ...
user=> (count my-summarized-numbers)
3
user=> (first my-summarized-numbers)
{:n 6, :proper-divisors #{1 2 3}, :even? true, :prime? false, ; ...
user=> (pp/print-table my-summarized-numbers)

| :n | :proper-divisors | :even? | :prime? | :perfect-number? |
|----+------------------+--------+---------+------------------|
|  6 |         #{1 2 3} |   true |   false |             true |
| 12 |     #{1 2 3 4 6} |   true |   false |            false |
| 28 |    #{1 2 4 7 14} |   true |   false |             true |
nil
user=>

調查例外

有些表達式在評估時不會傳回結果,而是擲出 例外。擲出例外表示您的程式正在告訴您:「評估表達式時發生錯誤,我不知道如何處理,所以我放棄了。」

例如,如果您將數字除以零,就會擲出例外

user=> (/ 1 0)
Execution error (ArithmeticException) at user/eval163 (REPL:1).
Divide by zero

預設情況下,REPL 會列印例外的兩行摘要。第一行報告錯誤階段(執行、編譯、巨集擴充等)及其位置。第二行報告原因。

在許多情況下,這可能就足夠了,但還有更多資訊可用。

首先,您可以視覺化例外的 堆疊追蹤,也就是導致錯誤指令的函式呼叫鏈。堆疊追蹤可以使用 clojure.repl/pst 列印

user=> (pst *e)
ArithmeticException Divide by zero
	clojure.lang.Numbers.divide (Numbers.java:163)
	clojure.lang.Numbers.divide (Numbers.java:3833)
	user/eval15 (NO_SOURCE_FILE:3)
	user/eval15 (NO_SOURCE_FILE:3)
	clojure.lang.Compiler.eval (Compiler.java:7062)
	clojure.lang.Compiler.eval (Compiler.java:7025)
	clojure.core/eval (core.clj:3206)
	clojure.core/eval (core.clj:3202)
	clojure.main/repl/read-eval-print--8572/fn--8575 (main.clj:243)
	clojure.main/repl/read-eval-print--8572 (main.clj:243)
	clojure.main/repl/fn--8581 (main.clj:261)
	clojure.main/repl (main.clj:261)
nil

提示:最後擲出的例外可以透過評估 *e 取得。

最後,只要在 REPL 中評估例外,就可以提供有用的視覺化

user=> *e
#error {
 :cause "Divide by zero"
 :via
 [{:type java.lang.ArithmeticException
   :message "Divide by zero"
   :at [clojure.lang.Numbers divide "Numbers.java" 163]}]
 :trace
 [[clojure.lang.Numbers divide "Numbers.java" 163]
  [clojure.lang.Numbers divide "Numbers.java" 3833]
  [user$eval15 invokeStatic "NO_SOURCE_FILE" 3]
  [user$eval15 invoke "NO_SOURCE_FILE" 3]
  [clojure.lang.Compiler eval "Compiler.java" 7062]
  [clojure.lang.Compiler eval "Compiler.java" 7025]
  [clojure.core$eval invokeStatic "core.clj" 3206]
  [clojure.core$eval invoke "core.clj" 3202]
  [clojure.main$repl$read_eval_print__8572$fn__8575 invoke "main.clj" 243]
  [clojure.main$repl$read_eval_print__8572 invoke "main.clj" 243]
  [clojure.main$repl$fn__8581 invoke "main.clj" 261]
  [clojure.main$repl invokeStatic "main.clj" 261]
  [clojure.main$repl_opt invokeStatic "main.clj" 325]
  [clojure.main$main invokeStatic "main.clj" 424]
  [clojure.main$main doInvoke "main.clj" 387]
  [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" 702]
  [clojure.main main "main.java" 37]]}

在此簡化的範例中,顯示所有這些資訊可能超過診斷問題所需的資訊;但對於「真實世界」的例外,這種視覺化會更有幫助,這些例外在 Clojure 程式中往往具有下列特徵

  • 例外傳達資料:在 Clojure 程式中,將額外資料附加到例外(不只是人類可讀的錯誤訊息)很常見:這是透過 clojure.core/ex-info 建立例外來完成的。

  • 例外是串連的:例外可以註解上一個可選的 原因,這是另一個(較低階的)例外。

以下是一個示範這些例外類型的範例程式。

(defn divide-verbose
  "Divides two numbers `x` and `y`, but throws more informative Exceptions when it goes wrong.
  Returns a (double-precision) floating-point number."
  [x y]
  (try
    (double (/ x y))
    (catch Throwable cause
      (throw
        (ex-info
          (str "Failed to divide " (pr-str x) " by " (pr-str y))
          {:numerator x
           :denominator y}
          cause)))))

(defn average
  "Computes the average of a collection of numbers."
  [numbers]
  (try
    (let [sum (apply + numbers)
          cardinality (count numbers)]
      (divide-verbose sum cardinality))
    (catch Throwable cause
      (throw
        (ex-info
          "Failed to compute the average of numbers"
          {:numbers numbers}
          cause)))))

我們還不知道,但我們的 average 函式在套用至空的數字集合時會失敗。然而,視覺化例外可以輕鬆診斷。在以下 REPL 會話中,我們可以看到使用一個空的數字向量呼叫我們的函式會導致將零除以零

user=> (average [])
Execution error (ArithmeticException) at user/divide-verbose (REPL:6).
Divide by zero
user=> *e  ;; notice the `:data` key inside the chain of Exceptions represented in `:via`
#error {
 :cause "Divide by zero"
 :via
 [{:type clojure.lang.ExceptionInfo
   :message "Failed to compute the average of numbers"
   :data {:numbers []}
   :at [user$average invokeStatic "NO_SOURCE_FILE" 10]}
  {:type clojure.lang.ExceptionInfo
   :message "Failed to divide 0 by 0"
   :data {:numerator 0, :denominator 0}
   :at [user$divide_verbose invokeStatic "NO_SOURCE_FILE" 9]}
  {:type java.lang.ArithmeticException
   :message "Divide by zero"
   :at [clojure.lang.Numbers divide "Numbers.java" 188]}]
 :trace
 [[clojure.lang.Numbers divide "Numbers.java" 188]
  [user$divide_verbose invokeStatic "NO_SOURCE_FILE" 6]
  [user$divide_verbose invoke "NO_SOURCE_FILE" 1]
  [user$average invokeStatic "NO_SOURCE_FILE" 7]
  [user$average invoke "NO_SOURCE_FILE" 1]
  [user$eval173 invokeStatic "NO_SOURCE_FILE" 1]
  [user$eval173 invoke "NO_SOURCE_FILE" 1]
  [clojure.lang.Compiler eval "Compiler.java" 7176]
  [clojure.lang.Compiler eval "Compiler.java" 7131]
  [clojure.core$eval invokeStatic "core.clj" 3214]
  [clojure.core$eval invoke "core.clj" 3210]
  [clojure.main$repl$read_eval_print__9068$fn__9071 invoke "main.clj" 414]
  [clojure.main$repl$read_eval_print__9068 invoke "main.clj" 414]
  [clojure.main$repl$fn__9077 invoke "main.clj" 435]
  [clojure.main$repl invokeStatic "main.clj" 435]
  [clojure.main$repl_opt invokeStatic "main.clj" 499]
  [clojure.main$main invokeStatic "main.clj" 598]
  [clojure.main$main doInvoke "main.clj" 561]
  [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" 37]]}

圖形和網頁可視化

最後,REPL 是個功能齊全的程式設計環境,它不限於文字可視化。以下是 Clojure 捆綁的一些方便的「圖形」可視化工具

clojure.java.javadoc 讓您檢視類別或物件的 Javadoc。以下是檢視 Java regex Pattern 的 Javadoc

user=> (require '[clojure.java.javadoc :as jdoc])
nil
user=> (jdoc/javadoc #"a+") ;; opens the Javadoc page for java.util.Pattern in a Web browser
true
user=> (jdoc/javadoc java.util.regex.Pattern) ;; equivalent to the above
true

clojure.inspector 讓您開啟資料的 GUI 可視化,例如

user=> (require '[clojure.inspector :as insp])
nil
user=> (insp/inspect-table (mapv number-summary [2 5 6 28 42]))
#object[javax.swing.JFrame 0x26425897 "javax.swing.JFrame[frame1,0,23,400x600,layout=java.awt.BorderLayout,title=Clojure Inspector,resizable,normal,defaultCloseOperation=HIDE_ON_CLOSE,rootPane=javax.swing.JRootPane[,0,22,400x578,layout=javax.swing.JRootPane$RootLayout,alignmentX=0.0,alignmentY=0.0,border=,flags=16777673,maximumSize=,minimumSize=,preferredSize=],rootPaneCheckingEnabled=true]"]

clojure.inspector table viz

clojure.java.browse/browse-url 讓您在網頁瀏覽器中開啟任何 URL,這對於特定需求來說很方便。

最後,還有一些第三方 Clojure 資料可視化工具;我們將在 增強您的 REPL 工作流程 章節中看到它們的選集。

處理神秘的值(進階)

有時候,REPL 中值的列印表示法並不是很具資訊性;有時候,它甚至會對該值的性質造成誤導。[1]這通常發生在透過 Java 互操作取得的值。

舉例來說,我們將使用 clojure.java.io 函式庫建立一個 InputStream 物件。如果您不知道 InputStream 是什麼,那就更好了 - 本節的重點是教您如何在未知領域中找到立足點

user=> (require '[clojure.java.io :as io])
nil
user=> (def v (io/input-stream "https://www.clojure.org")) ;; NOTE won't work if you're not connected to the Internet
#'user/v
user=> v
#object[java.io.BufferedInputStream 0x4ee37ca3 "java.io.BufferedInputStream@4ee37ca3"]

以上的程式碼範例定義了一個名為 v 的 InputStream。

現在想像您不知道 v 從何而來,讓我們嘗試在 REPL 中與它互動,以便更了解它。

使用 typeancestors 檢視類型階層

v 的列印表示法告訴我們一件事:它的執行時期類型,在本例中為 java.io.BufferedInputStream。值的類型可以幫助我們知道我們可以對它呼叫哪些操作。我們可以評估 (type v) 以取得 v具體類型,並評估 (ancestors (type v)) 以取得它的完整類型階層:

user=> (type v) ;; what is the type of our obscure value?
java.io.BufferedInputStream
user=> (ancestors (type v))
#{java.io.InputStream java.lang.AutoCloseable java.io.Closeable java.lang.Object java.io.FilterInputStream}

使用 Javadoc

正如我們在前一節所看到的,我們可以使用 clojure.java.javadoc 函式庫檢視有關 Java 類型的線上文件

user=> (require '[clojure.java.javadoc :as jdoc])
nil
user=> (jdoc/javadoc java.io.InputStream) ;; should open a web page about java.io.InputStream
true

使用 clojure.reflect 檢查 Java 類型

Javadoc 有幫助,但有時 Javadoc 甚至不可用。在這種情況下,我們可以使用 REPL 本身透過 Java 反射來檢查類型。

我們可以使用 clojure.reflect/reflect 函數取得 Java 類型資訊,作為一個純粹的 Clojure 資料結構

user=> (require '[clojure.reflect :as reflect])
nil
user=> (reflect/reflect java.io.InputStream)
{:bases #{java.lang.Object java.io.Closeable}, :flags #{:public :abstract}, :members #{#clojure.reflect.Method{:name close, :return-type void, :declaring-class java.io.InputStream, :parameter-types [], :exception-types [java.io.IOException], :flags #{:public}} #clojure.reflect.Method{:name mark, :return-type void, :declaring-class java.io.InputStream, :parameter-types [int], :exception-types [], :flags #{:public :synchronized}} #clojure.reflect.Method{:name available, :return-type int, :declaring-class java.io.InputStream, :parameter-types [], :exception-types [java.io.IOException], :flags #{:public}} #clojure.reflect.Method{:name read, :return-type int, :declaring-class java.io.InputStream, :parameter-types [], :exception-types [java.io.IOException], :flags #{:public :abstract}} #clojure.reflect.Method{:name markSupported, :return-type boolean, :declaring-class java.io.InputStream, :parameter-types [], :exception-types [], :flags #{:public}} #clojure.reflect.Field{:name MAX_SKIP_BUFFER_SIZE, :type int, :declaring-class java.io.InputStream, :flags #{:private :static :final}} #clojure.reflect.Constructor{:name java.io.InputStream, :declaring-class java.io.InputStream, :parameter-types [], :exception-types [], :flags #{:public}} #clojure.reflect.Method{:name read, :return-type int, :declaring-class java.io.InputStream, :parameter-types [byte<>], :exception-types [java.io.IOException], :flags #{:public}} #clojure.reflect.Method{:name skip, :return-type long, :declaring-class java.io.InputStream, :parameter-types [long], :exception-types [java.io.IOException], :flags #{:public}} #clojure.reflect.Method{:name reset, :return-type void, :declaring-class java.io.InputStream, :parameter-types [], :exception-types [java.io.IOException], :flags #{:public :synchronized}} #clojure.reflect.Method{:name read, :return-type int, :declaring-class java.io.InputStream, :parameter-types [byte<> int int], :exception-types [java.io.IOException], :flags #{:public}}}}

現在,那是一個非常複雜的資料結構。幸運的是,我們已經在本章的 第一部分 中學習如何處理複雜的資料結構:漂亮列印來救援!讓我們使用漂亮列印以表格方式顯示 java.io.InputStream 公開的方法

user=> (->> (reflect/reflect java.io.InputStream) :members (sort-by :name) (pp/print-table [:name :flags :parameter-types :return-type]))

|                :name |                     :flags | :parameter-types | :return-type |
|----------------------+----------------------------+------------------+--------------|
| MAX_SKIP_BUFFER_SIZE | #{:private :static :final} |                  |              |
|            available |                 #{:public} |               [] |          int |
|                close |                 #{:public} |               [] |         void |
|  java.io.InputStream |                 #{:public} |               [] |              |
|                 mark |   #{:public :synchronized} |            [int] |         void |
|        markSupported |                 #{:public} |               [] |      boolean |
|                 read |       #{:public :abstract} |               [] |          int |
|                 read |                 #{:public} |         [byte<>] |          int |
|                 read |                 #{:public} | [byte<> int int] |          int |
|                reset |   #{:public :synchronized} |               [] |         void |
|                 skip |                 #{:public} |           [long] |         long |
nil

例如,這告訴我們我們可以在 v 上呼叫一個沒有參數的 .read 方法,它會傳回一個 int

user=> (.read v)
60
user=> (.read v)
33
user=> (.read v)
68

在沒有任何先備知識的情況下,我們已經設法了解到 v 是 InputStream,並從中讀取位元組。


1. 例如,DatomicDataScript Entity 物件會像 Clojure 映射一樣列印,即使它們與一般的映射有顯著的不同。