打印图表的列

由于 Emacs 设计上追求灵活,可适配各类终端(包括纯字符终端),因此图表需要使用打字机符号来构成。星号就很合适;后续完善图表打印函数时,我们可以把符号的选择改为用户可配置项。

我们可以将该函数命名为 graph-body-print,它只接收一个 numbers-list 作为参数。在当前阶段,我们不会为图表添加标注,只打印其主体部分。

graph-body-print 函数会为 numbers-list 中的每个元素插入一列垂直的星号。每列的高度由 numbers-list 中对应元素的值决定。

插入多列是重复操作,这意味着该函数既可以用 while 循环实现,也可以用递归实现。

我们首先要解决的问题是如何打印一列星号。在 Emacs 中,我们通常是逐行横向输入字符到屏幕上。这里有两条思路:自己编写列插入函数,或是查找 Emacs 中是否已有现成的实现。

想要查找 Emacs 中是否存在相关功能,可以使用 M-x apropos 命令。该命令与 C-h acommand-apropos)类似,区别是后者只查找作为命令的函数,而 M-x apropos 会列出所有匹配正则表达式的符号,包括非交互式函数。

我们需要查找用于打印或插入列的相关命令。函数名中很可能包含 “print”、“insert” 或 “column” 这类单词。因此可以直接输入 M-x apropos RET print\|insert\|column RET 并查看结果。在我的系统上,该命令曾经需要较长时间执行,最终列出 79 个函数和变量;如今运行速度很快,会列出 211 个函数和变量。浏览列表后,看起来能满足需求的函数只有 insert-rectangle

事实上这正是我们需要的函数,其文档说明如下:

insert-rectangle:
以光标位置为左上角插入矩形区域的文本。
矩形区域的第一行插入在光标位置,
第二行插入在光标正下方位置,依此类推。
RECTANGLE 应为字符串列表。
执行该命令后,标记位于左上角,
光标位于右下角。

我们可以快速测试,确认它的行为符合预期。

将光标放在 insert-rectangle 表达式之后并输入 C-u C-x C-eeval-last-sexp),结果如下。该函数会在光标及下方位置插入字符串 ‘"first"’、‘"second"’、‘"third"’,同时返回 nil

(insert-rectangle '("first" "second" "third"))first
                                              second
                                              thirdnil

当然,我们不会把 insert-rectangle 表达式本身插入到绘制图表的缓冲区中,而是在程序中调用该函数。不过我们必须确保光标位于缓冲区中合适的位置,以便 insert-rectangle 插入字符串列。

如果你正在 Info 中阅读本文,可以切换到另一个缓冲区(例如 *scratch* 缓冲区),将光标放在缓冲区某处,输入 M-:,在小缓冲的提示符后输入 insert-rectangle 表达式,然后按 RET。这会让 Emacs 在小缓冲中求值表达式,但使用 *scratch* 缓冲区中的光标位置。(M-:eval-expression 的按键绑定。另外 nil 不会出现在 *scratch* 缓冲区中,因为表达式是在小缓冲中执行的。)

测试后可以发现,执行结束时光标会停在最后插入行的末尾 — 也就是说该函数会附带移动光标的副作用。如果在该位置重复执行命令,下一次插入会出现在上一次插入的右下方,这并不是我们想要的效果!绘制柱状图时,各列应当并排排列。

由此可知,执行列插入的 while 循环每次都需要将光标重新定位到目标位置,且该位置应在列的顶部而非底部。此外我们知道,打印图表时各列高度并不一定相同,这意味着每列的顶部位置可能不同。我们不能每次都简单地将光标定位到同一行再向右移动 — 或许也并非完全不行…

我们计划使用星号构成柱状图的每一列。列中的星号数量由 numbers-list 当前元素的值指定。每次调用 insert-rectangle 时,都需要构造一个长度合适的星号列表。如果该列表只包含对应数量的星号,就必须将光标向上移动对应行数,才能让图表正确打印,这会比较麻烦。

另一种思路是,设法让每次传给 insert-rectangle 的列表长度都相同,这样就可以每次都将光标放在同一行,只是每新增一列就向右移动一列。不过这样一来,传给 insert-rectangle 的列表中部分元素需要是空格而非星号。例如,图表最大高度为 5,而当前列高度为 3,则 insert-rectangle 的参数应如下所示:

(" " " " "*" "*" "*")

只要能确定列高度,后一种方案实现起来并不复杂。指定列高度有两种方式:可以人为指定固定高度,这对对应高度的图表有效;也可以遍历数字列表,将列表中的最大值作为图表的最大高度。如果后一种操作很复杂,那么前一种方式更简单,但 Emacs 内置了可获取参数最大值的函数,我们可以直接使用。该函数名为 max,会返回所有数字参数中的最大值。例如:

(max  3 4 6 5 7 3)

会返回 7。(对应的函数 min 会返回所有参数中的最小值。)

不过我们不能直接对 numbers-list 调用 maxmax 函数接收的参数是数字,而非数字列表。因此下面的表达式:

(max  '(3 4 6 5 7 3))

会产生如下错误信息:

Wrong type of argument:  number-or-marker-p, (3 4 6 5 7 3)

我们需要一个能将列表中的元素作为参数传给函数的工具,这个函数就是 apply。它会将第一个参数(函数名)应用到后续参数上,最后一个参数可以是列表。

例如:

(apply 'max 3 4 7 3 '(4 8 5))

会返回 8。

(顺带一提,如果没有这类书籍参考,你可能很难发现这个函数。像 search-forwardinsert-rectangle 这类函数可以通过猜测部分名称再使用 apropos 查找,但 apply 即便从语义上很明确 — 将第一个参数应用到其余参数上 — 新手在使用 apropos 或其他工具时也不太可能想到这个单词。当然我也可能判断有误,毕竟该函数最初也是由开发者命名的。)

apply 的第二个及后续参数是可选的,因此我们可以用它调用函数并将列表元素作为参数传入,如下例同样返回 8:

(apply 'max '(4 8 5))

这正是我们使用 apply 的方式。recursive-lengths-list-many-files 函数会返回一个数字列表,我们可以对其应用 max(也可以对排序后的数字列表应用 max,列表是否排序不影响结果)。

因此,获取图表最大高度的操作如下:

(setq max-graph-height (apply 'max numbers-list))

现在回到如何为图表列构造字符串列表的问题。已知图表最大高度与当前列应显示的星号数量,函数需要返回一个可供 insert-rectangle 插入的字符串列表。

每一列由星号或空格构成。函数接收列高度与列中星号数量,空格数量可通过列高度减去星号数量得到。已知空格数与星号数后,可以使用两个 while 循环构造该列表:

;;; First version.
(defun column-of-graph (max-graph-height actual-height)
  "Return list of strings that is one column of a graph."
  (let ((insert-list nil)
        (number-of-top-blanks
         (- max-graph-height actual-height)))

    ;; Fill in asterisks.
    (while (> actual-height 0)
      (setq insert-list (cons "*" insert-list))
      (setq actual-height (1- actual-height)))

    ;; Fill in blanks.
    (while (> number-of-top-blanks 0)
      (setq insert-list (cons " " insert-list))
      (setq number-of-top-blanks
            (1- number-of-top-blanks)))

    ;; Return whole list.
    insert-list))

如果你安装该函数并执行下面的表达式,会看到它返回了预期的列表:

(column-of-graph 5 3)

returns

(" " " " "*" "*" "*")

当前版本的 column-of-graph 存在一个明显缺陷:用于表示空格和标记的符号被硬编码为空格和星号。这对原型来说尚可,但你或其他用户可能希望使用其他符号。例如在测试图表函数时,可能想用点号代替空格,以确保每次调用 insert-rectangle 时光标都被正确重定位;也可能想用 ‘+’ 或其他符号代替星号。你甚至可能希望图表列的显示宽度不止一列。程序应当更灵活。解决方法是将空格和星号替换为两个变量,分别命名为 graph-blankgraph-symbol,并单独定义这两个变量。

此外文档说明也不够完善。基于这些考虑,我们给出该函数的第二版:

(defvar graph-symbol "*"
  "图表中使用的符号,通常为星号。")

(defvar graph-blank " "
  "图表中使用的空白符号,通常为空格。
graph-blank 的列宽度必须与 graph-symbol 一致。")

(关于 defvar 的说明,参见 使用 defvar 初始化变量。)

;;; Second version.
(defun column-of-graph (max-graph-height actual-height)
  "返回长度为 MAX-GRAPH-HEIGHT 的字符串;其中 ACTUAL-HEIGHT 个为 graph-symbols。

graph-symbols 为列表末尾连续的元素。
该列表会作为图表的一列插入。
字符串内容为 graph-blank 或 graph-symbol。"


  (let ((insert-list nil)
        (number-of-top-blanks
         (- max-graph-height actual-height)))

    ;; Fill in graph-symbols.
    (while (> actual-height 0)
      (setq insert-list (cons graph-symbol insert-list))
      (setq actual-height (1- actual-height)))

    ;; Fill in graph-blanks.
    (while (> number-of-top-blanks 0)
      (setq insert-list (cons graph-blank insert-list))
      (setq number-of-top-blanks
            (1- number-of-top-blanks)))

    ;; Return whole list.
    insert-list))

如果需要,我们可以第三次重写 column-of-graph,使其同时支持折线图与柱状图。这并不难实现。折线图可以看作一种特殊的柱状图,每个柱子除顶部外其余部分均为空白。要构造折线图的列,函数可以先生成一个长度比目标值短 1 的空格列表,然后用 cons 将图表符号附加到列表头部,再用 cons 将顶部空格附加到列表最前方。

编写该函数的思路很清晰,但由于当前并不需要,我们暂不实现。不过该功能完全可以实现,且会在 column-of-graph 内部完成。更重要的是,其他代码几乎不需要修改。后续若想添加该功能,实现起来会很简单。

现在,我们终于可以编写第一个实际可用的图表打印函数。该函数只打印图表主体,不打印横竖坐标轴的标注,因此命名为 graph-body-print