Forth 语言基础(二)

本部分是一些方法论性质的材料,与语言本身的关系没有那么大,供感兴趣的读者阅读。这一部分主要来自 Thinking Forth 的相关章节(但不是简单的翻译,部分内容经过了很激进的改编),有些例子也来自 Forth Programmer’s Handbook 3rd ed 以及 Real Time Forth。这一部分的例子大部分都可在 My4TH Forth 上实际运行,但也有一些是纯粹用于说明概念的,不可实际运行。

本文按 CC BY-NC-SA 4.0 协议发布。

Forth 的特点总结1

  • 大部分其他程序设计语言基于句法模型。Forth 与它们最大的不同是,它基于语义模型。Forth 注重符号的意义,而不是它的表达形式。Forth 中每个已定义的符号都是可以执行的。除了少数特例之外,标准符号的含义就是它的运行效果。每个符号都有其动作,而这个动作就是它的含义。Forth 语言中,一个短语的含义(也就是动作)可以从组成它的符号的含义直接推断出来。整个 Forth 程序的含义也可以从组成它的各部分的含义直接推断出来。

  • 可以说,应用程序决定其自身的自然语法。虽然 Forth 本身几乎不包含预设语法,但它具备可扩展性,能够直接体现任何应用程序的自然语法。这一模式允许将应用程序的自然“语言”与 Forth 本身相融合,最终使二者合而为一。

  • 与任何计算机语言一样,Forth 弥合了计算机硬件与人类之间的语义鸿沟。人类交流主要基于自然语言词汇,而计算机仅对位序列产生反应。Forth 通过将适当的位序列打包成“词”来实现从人类层面到计算机硬件层面的转换,允许用先前定义的词构建更高层级的词,直至达到需要的功能层级。Forth 程序员定义的词本质上是由一个或多个已定义命令(词)组成的包,其中每个命令本身也是这样的包。因此,Forth 程序中具有基础意义的结构单元就是词。

  • 自然语言具有不同类型的词(如名词、动词、介词等)。同样,Forth 也预定义了不同类型的词。但与自然语言不同,Forth 鼓励程序员根据需求开发新的类型的词。Forth 编程系统将这些词组织成可遍历的数据结构,称为“词典”。将新词的源代码转换为可执行结构并添加至词典的过程称为编译。通常而言,Forth 中每种类型的词都对应着不同的专用编译器。

  • 自然语言允许词语在不同语境中具有不同含义,与此相似,Forth 允许同一词在词典中拥有多个定义,每个定义对应不同功能。Forth 通过将词典组织为词表(word list)实现这一特性——同一词的不同定义分别位于不同的词表中。Forth 提供标准词用于指定新词编译到的词表,以及指定编译和执行期间的搜索顺序2。

  • Forth 语言中一个适用于多种应用场景的核心概念是定义词(也可以称作“词类编译器”)。这一强大机制使得某个词可被赋予特殊扩展能力,用于定义(编译)具有程序员指定行为的新类型的词。系统包含若干标准定义词:其中部分用于创建常量、变量等标准编程元素;另一些则专为支持程序员针对特定应用需求,创建面向定制词类的新定义词(编译器)。

  • 每个定义词都具备定义时行为与实例行为。定义时行为负责创建包含用户自定义数据结构的新字典条目;实例行为则规定新创建词的具体行为。这一机制的强大之处在于:指定的实例行为可包含对编译器的调用,从而使新定义出的词本身也成为定义词。通过高阶定义词(即能定义其他定义词的定义词),开发者能够构建出非常简洁的高级应用程序代码。

自然的语序3

数字+词

Forth 采用栈暂存数据。因此,自然状态下数字跟在词前面:

10 cells	\ 获得 10 个单元的寻址单位(字节)数。
20 spaces	\ 打印 20 个空格。

这跟自然语言的语序相符。然而有些情况下,尤其是计算表达式时,语序跟自然语言不一样:

3 4 +		\ 计算 3+4。

前面已经讲过,这叫做后缀表达式(逆波兰表达式)。

当然 Forth 系统提供了最大的灵活性。如果硬要采用自然语序(中缀表达式)计算两个数的加法,可以这样:

\ 下面重新定义的 “+ number ( n -- n )” 将栈中数字与从输入流中取得的下一个数字相加。
: +  0. parse-name >number 2drop d>s + ;↵  ok
3 + 4 .↵ 7  ok

不过这个重新定义的 “+” 无法在其他冒号定义中使用(因为后面的数字是从输入流中取得的),后面跟的那个东西也只能是一个字面上的数字(不能是栈中的参数)。所以一般情况下不要这么做。

名词+动词

众所周知,自然语言的语序一般是动词跟在名词后面:

S → NP VP

借助栈,在 Forth 下这一语序也是自然的,而且最好实现。 NP 将代表它自身的元素(往往是一个数字、一个地址或任意类型的指针)压栈,VP 从栈中取得它,对 NP 代表的元素执行某种操作。例如:

key emit	\ key 从键盘取得按键的代码,emit 将它显示在屏幕上。

动词+宾语

在汉语和英语等语言中宾语往往跟在动词后面:

VP → V NP

这一语序在 Forth 中也是可以实现的,当然它往往会增加程序的复杂程度。这一语序有不止一种常见的实现方式。第一种方式是 V 设置一个标志,NP 根据标志的内容执行相应的操作。这种方式的不足是,NP 往往需要很复杂,使其能够执行所有可能的 V 代表的操作。

第二种方式是:前面的 V 设置一个执行向量(通过 xt 或 DEFER 等方式),然后用 NP 执行它。这一方式比第一种方式清晰。例如:

price orange	\ price 是一个数据类型,代表价格
100 set orange	\ 设置 orange 的价格为 $1.00
show orange		\ 打印 orange 的价格

可以借助 CREATE…DOES> 和 DEFER 这样实现:

defer (price-action)
: price  create 0 ,   does> (price-action) ;
: (print-price)  @ dup abs 0
   <# # # [char] . hold #s rot sign [char] $ hold #>
   type space ;
: set  ['] ! is (price-action) ;
: show  ['] (print-price) is (price-action) ;

运行结果符合预期:

price orange↵  ok
100 set orange↵  ok
show orange↵ $1.00  ok
price apple↵  ok
200 set apple↵  ok
show apple↵ $2.00  ok
show orange↵ $1.00  ok

当然如果接受操作对象(宾语)前置的语序,就像日语里那样

price orange	\ price 是一个数据类型,代表价格
100 orange set	\ 设置 orange 的价格为 $1.00
orange show		\ 打印 orange 的价格

那么实现起来更简单:

: price  variable ;	\ price 就是一个普通的变量
: set  ! ;			\ set 就是 ! 的别名
: show  @ dup abs 0	\ show ( price -- ) 就是普通的格式化输出
   <# # # [char] . hold #s rot sign [char] $ hold #>
   type space ;

运行结果:

price orange↵  ok
100 orange set↵  ok
orange show↵ $1.00  ok

第三种方式是让 V 取得输入流中的下一个元素 NP。这种方式其实相当常用,例如 FORTH 系统本身提供的 CREATE 和 FORGET 等词。此时跟在后面的 NP 应理解为 V 对其进行处理的记号或字符串,它未必是一个已经定义的词。

实现这种方式可以使用 PARSE 或 PARSE-NAME 等系统提供的词对输入流进行处理,或在冒号定义中使用其他要求输入流中下一个元素的词(第一部分中所有关于 CREATE…DOES> 的例子都是这样使用 CREATE 的)。

需要注意的是,这类词在冒号定义中使用时,如果想让它取得定义中的下一个元素的内容,而不是在运行时取得输入流中的下一个元素的内容,可能需要将其编写为立即词(例如系统提供的 .")。

形容词+名词

自然语言里还有修饰其他词的词,如形容词:

NP → ADJ N

在 Forth 中实现这一语序的推荐方式是:让 ADJ 设置一个标志,在 N 里面根据标志返回相应的值。

例如,假设系统用 0..7 表示八种颜色(如 1 表示蓝色),将颜色代码的第 3 位设置为 1 表示对应的亮色(如 9 表示亮蓝色)。那么可以有:

variable light-mask	\ 是否亮色的掩码。
					\ 0 表示普通颜色,8(第 3 位为 1 )表示亮色。与 颜色代码按位或。
0 light-mask !
: light  8 light-mask ! ;	\ 亮色:将掩码 light-mask 设置为 8
: color ( color-code -- color-code-with-light-mask )
   light-mask @ or	\ 颜色代码与 light-mask 取或
   0 light-mask ! ;	\ light 只管到最近的一个颜色词,所以用完之后设置回 0 
: blue  1 color ;

程序的行为符合预期:

blue .↵ 1  ok
light blue .↵ 9  ok
blue .↵ 1  ok

(这段程序有另一种写法,参见后面“用 CREATE…DOES> 提取功能”的内容。)

前面的描述中借用了句法学常用的符号(如 S → NP VP),这不应理解为 Forth 解释器可以处理自然语言或其他程序设计语言中常见的递归的句法。对编译技术或句法学有所了解的读者可能已经发现,Forth 解释器不包含通常意义的句法分析器,它的行为是从左向右依次处理输入流中的记号4。因此,即使采用了前面提到的技巧,它对句法的处理能力也是有限的。例如:

: +  0. parse-name >number 2drop d>s + ;↵  ok
: *  0. parse-name >number 2drop d>s * ;↵  ok
3 + 4 .↵ 7  ok		\ 可以处理
3 * 4 .↵ 12  ok	\ 可以处理
1 + 2 * 3 .↵ 9  ok	\ 从左向右依次处理,不能处理优先级
100 constant hundred↵  ok
hundred + 1 .↵ 101  ok	\ 先将 hundred ( -- 100 ) 的结果压栈,可以处理
1 + hundred .↵ 1  ok	\ 重新定义的 “+” 右边的东西只能是字面量,没有能力先将其求值

关于程序风格的惯例与建议5

一般惯例

  • 多定义几个简单的词,让每一个词执行简单确定的工作。

    第一部分正文中已经重复多次。读者也可以从例子中体会到。

  • 让数字跟在词前面,文本跟在词后面。

    例:

    20 spaces				\ 数字跟在词前面
    forget blue				\ 文本跟在词后面
    100 constant hundred	\ 同时有数字和文本的情况
    s" max-n" environment?	\ 如果要在编译模式和立即模式下都能使用,用 s" 可能更方便
    
  • 词应该把它需要的参数从栈里消耗掉。

    例如,编写一个发射导弹的程序。发射系统有下面的几个词:

    load	\ 装弹。
    aim		\ 瞄准。
    fire	\ 开火。
    

    它们都需要发射架的编号 pad# 作为参数。如果要定义

    launch	( pad# -- )	\ 发射导弹:先装弹,再瞄准,最后开火。
    

    显然,程序的某处需要两个 dup。不推荐将这两个 dup 写在 load 和 aim 里:

    load	( pad# -- pad# )
    aim		( pad# -- pad# )
    fire	( pad# -- )
    

    虽然定义 launch 看起来很简洁:

    : launch ( pad# -- ) load aim fire ;
    

    然而如果想定义 ready(先装弹再瞄准)就必须在最后把 pad# drop 掉:

    : ready ( pad# -- ) load aim drop ;
    

    推荐的方式是让三个词对栈的作用一致。此时建议遵循将参数消耗掉的惯例:

    load	( pad# -- )
    aim		( pad# -- )
    fire	( pad# -- )
    

    定义 launch 和 ready 如下:

    : launch ( pad# -- ) dup load  dup aim  fire ;
    : ready ( pad# -- )  dup load  aim ;
    
  • 序号从 0 开始。

    自然语言习惯从 1 开始数数。但是从 0 开始数数能够大大简化处理。例如有一个表格,里面有若干条记录,每条记录占 8 字节。如果记录序号和字节数都从 0 开始数,那么计算记录的偏移量是最简单的:

    : record ( record# -- adr ) 8 * table + ;
    \ 记录的偏移量 = 记录序号 × 8
    \ 第一个记录(记录 0)的偏移量:0 × 8 = 0
    \ 第二个记录(记录 1)的偏移量:1 × 8 = 8
    \ 第三个记录(记录 2)的偏移量:2 × 8 = 16
    \ ...
    

    显然,两个里任何一个从 1 开始数,都会在公式中增加“减一”的环节。如果要兼顾自然习惯,暴露给用户“从 1 开始”的界面,可以定义:

    : item ( n -- adr ) 1- record ;
    
  • 参数中,地址在长度前面。

    标准中遵循这一惯例。如处理字符串的词要求首地址和长度 ( c-addr u ) 二元组。

  • 参数中,源在目标前面。

    如第一部分的例子中定义的块拷贝词 cp ( src dst -- )。

    系统提供的 MOVE 同时遵循这两条惯例:

    MOVE	( addr1 addr2 u -- )	\ 从 addr1 向 addr2 搬移 u 字节的内容
    
  • 参数中,越不经常更改的元素越放在后面。

    这样可以避免很多无谓的 SWAP、ROT 等栈操作。前两条惯例背后的逻辑与本条相合。

    如“写 I2C”的词这样定义:

    I2C-WR	( data regnum addr -- flag )	\ 向 I2C 总线上地址 addr 的器件的 
    										\ 寄存器 regnum 写数据 data,返回状态 flag
    

    比这样定义:

    I2C-WR2	( addr regnum data -- flag )
    

    使用起来要方便得多。想想如何定义一个新词,给某个特定地址的器件写内容,或给某个特定地址器件的特定的寄存器写内容!

  • 尽量避免词依赖输入流中后面的词的特性。

    例如,有些读者可能想这样实现“形容词+名词”的语序:

    1 constant blue
    : light \ "color" ( -- color-value )
       ' execute 8 or ;  
    

    这里定义的 light 从输入流取得下一个记号,获取它的 xt,执行它,将返回值的位 3 置一。 light 对下一个记号 "color" 的特性有很多预期:

    1. "color" 必须是词典中已有的词(这样才能获取 xt 和执行);
    2. "color" 必须代表颜色;
    3. "color" 必须有一个单精度数字返回值(对栈的作用为 ( ... -- n ))。

    如果不满足这些预期,那么 light 就会(可能是一声不响地)出错。另外,这里的 light 在冒号定义中无法使用,它会在执行时从输入流取得 "color"。

    相比之下,前面介绍过的设置标志的方法就不存在上面这些问题。

  • 让词只做它自身的工作。

    最好的例子是 Forth 编译器本身。在编译一个冒号定义的过程中,编译器只做下列工作:

    • 获取输入流中的下一个记号,在字典中查找它;

    • 如果它是一个普通词,编译它的地址;

    • 如果它是一个立即词,执行它;

    • 如果它不是一个已有的词,试图将其按数字解析,并作为字面量编译进程序;

    • 如果它也不是一个数字,那么中止并打印错误信息。

    诸如 IF、ELSE、THEN 的词由这些词本身处理,编译器甚至不知道它们的具体作用。编译器只知道它们是立即词,遇到的时候执行它们,让它们做自身要做的工作。

    甚至注释用的 ( 都可以定义为一个词,而不是让编译(解释)器对它进行特殊处理6:

    \ `(` 是一个立即词。它的作用是获取输入流中直到 ) 的内容,不做任何处理
    \ (将其 ( c-addr u ) 抛弃)。
    : (  [char] ) parse 2drop ; immediate
    
  • 如果要解释专用的语言,不要编写专用的解释(编译)器,而要借助 Forth 解释器本身。

    很多应用都需要解释专用的语言,也就是实现目的所需的一套专门的指令。例如 bash 解释 cd、echo、pwd 等内部命令,并对变量、表达式、控制结构等进行处理;汇编器解释 ADD、SUB、JMP 等助记符,有些还具有宏替换、符号标号等高级功能。

    其他语言的用户习惯于编写解释器来完成这些工作。不同的是,Forth 用户习惯于借助 Forth 解释器本身,定义一组词来完成同样的工作。

    Forth 自身的数字格式化输出是最好的例子。C 语言借助 printf() 完成类似的工作。然而 printf() 的格式串与 C 语言本身相差甚远,而且在运行时解析。相比之下,Forth 的 #、#S、SIGN、HOLD 和 HOLDS 本身就是词,而初始化输出缓冲区的 <# 和将未处理数字丢弃、输出缓冲区的 ( c-addr u ) 二元组压栈的 #> 组成了一对对称的括弧,让语义变得清晰(格式化输出的词在括弧内使用)。<#…#> 在编译时解析,提高了运行效率;而且其内部可以使用任何其他的 Forth 词(如 BEGIN…WHILE…REPEAT、IF…ELSE…THEN 等等),printf() 的格式串是没有这个灵活程度的。

    再举一个例子:想让 Forth 演奏音乐,那么可以将音名定义为词,对栈的作用为 ( 时值 -- ),行为为按时值播放对应音符。这样一段乐谱可以写成:

    : freude1  8 e4 8 e4 8 f4 8 g4 8 g4 8 f4 8 e4 8 d4
       8 c4 8 c4 8 d4 8 e4 ;
    : freude2  freude1 12 e4 4 d4 16 d4 ;
    : freude3  freude1 12 d4 4 c4 16 c4 ;
    : freude4  8 d4 8 d4 8 e4 8 c4 8 d4 4 e4 4 f4 8 e4 8 c4
       8 d4 4 e4 4 f4 8 e4 8 d4 8 c4 8 d4 16 g3 ;
    : freude  freude2 freude3 freude4 freude3 ;
    

    有一点音乐常识的人很容易理解这种乐谱。而且在编写这种乐谱的时候,可以自然地利用 Forth 本身提供的嵌套调用(用来复用重复出现的乐段,例如上面的例子中,freude 调用了 freude3,而freude3 又调用了 freude1)、循环(用来实现反复记号或多段歌曲)等语言特性。

    为了对比,给出一个传统语言播放音乐的例子。CEC-I BASIC 提供了 MUSIC 指令:

    MUSIC x,y 'x 对应频率,y 对应时值
    

    用 MUSIC 指令播放音乐的程序往往长得像这样:

    10 FOR I=1 TO 15
    20 READ X,Y
    30 MUSIC X,Y
    40 NEXT I
    50 DATA 255,160, 228,160, 205,160, 192,160, ...
    

    与上面的 Forth 程序相比,可读性高下立判。

    QBasic 中的 PLAY 语句是一个小型的专用语言解释器。它解释后面的字符串,并将其作为乐谱播放。如:

    music1$ = "o2 c1 d4 f4 e4 d4 g2 g2 g4 a4 e4 f4 d2 d2 d4 f4 e4 d4 c4"
    PLAY music1$
    

    可以利用 BASIC 语言的字符串操作、流控制等语言特性来实现复杂的乐谱。但是,这样做也会影响可读性(也许只能称 PLAY 语句解释的字符串为“乐谱”,字符串外面组织乐谱的程序只能称为程序,他们的句法是完全不同的),效率也比 Forth 程序低得多。

    这种例子比比皆是。下面再举一例:如果要实现一个汇编器,那么可以将 ADD、SUB、JMP 等助记符定义成立即词,它们从栈中取得参数,并将对应的代码编译进词典。这不需要用户单独编写专用的解释器,借助 Forth 解释器本身就可以实现目的。

程序结构

本部分内容着重介绍传统的用屏(也就是 1024 字节大小的块,每块 16 行,每行 64 个字节)组织程序的方法。现代的 Forth 系统往往支持文件;用文件组织程序的自由度大很多,但关于空格、缩进等的惯例仍然值得考虑。

  • 程序的整体结构应该有层次,像一本书。

    一个完整的应用程序包含:

    • 屏:Forth 源代码的最小单位,16 行 × 64 字节大小。

    • 词汇表(lexicon):实现一个模块的一组词,大小往往是 1~3 屏。

    • 章节(chapter):一组相关的词汇表。

    • 装入屏(load screen):类似书的目录,用途是将一组章节按正确的顺序装入。

    一个典型的装入屏的例子如下:

    -----------------------------[Screen 001]-----------------------------
    00| \ C QTF+ Load Screen                                    07/09/83 |
    01| : release#  ." 2.01" ;                                           |
    02| 9   load  \ compiler tools, language primitives                  |
    03| 12  load  \ video primitives                                     |
    04| 21  load  \ editor                                               |
    05| 39  load  \ line display                                         |
    06| 48  load  \ formatter                                            |
    07| 69  load  \ boxes                                                |
    08| 81  load  \ deferring                                            |
    09| 90  load  \ framing                                              |
    10| 96  load  \ labels, figures, tables                              |
    11| 102 load  \ table of contents generator                          |
    12|                                                                  |
    13|                                                                  |
    14|                                                                  |
    15|                                                                  |
    ----------------------------------------------------------------------
    

    只要用 1 load 一条指令,就可以装入整个应用程序。

    这个装入屏指向应用程序的每一个章节对应的装入屏。如屏 12 是视频基本指令的装入屏。

    装入屏起到目录的作用。比如用户想寻找关于编辑器的程序,看到装入屏之后,就可以了解编辑器从屏 21 开始。

    章节的装入屏可以采用相对编号的方式实现。例如定义下面的词 fh(意思是 from here):

    : fh	\ ( offset -- offset-block) "from here"
      blk @ + ;
    

    回顾 blk 中存储的是当前 load 的块号。这样一个章节的装入屏可以长这样:

    -----------------------------[Screen 100]-----------------------------
    00| \ C GRAPHICS            Chapter load                    07/11/83 |
    01|                                                                  |
    02| 1 fh load           \ dot-drawing primitive                      |
    03| 2 fh 3 fh thru      \ line-drawing primitives                    |
    04| 4 fh 7 fh thru      \ scaling, rotation                          |
    05| 8 fh load           \ box                                        |
    06| 9 fh 11 fh thru     \ circle                                     |
    07|                                                                  |
    08|                                                                  |
    09|                                                                  |
    10| corner  \ initialize relative position to low-left corner        |
    11|                                                                  |
    12|                                                                  |
    13|                                                                  |
    14|                                                                  |
    15|                                                                  |
    ----------------------------------------------------------------------
    

    屏 101(从当前屏往后算第 1 屏,也就是 1 fh)存储画点的基本指令,屏 102~103 存储画线的基本指令,依此类推。这样做的优点是,屏 100~111 的这段画图程序可以任意搬移而不用修改。 下面是一个改进的 fh 定义,可以在立即模式下用在 list 中:

    : fh	\ ( offset -- offset-block) "from here"
      blk @  ?dup 0= if  scr @  then  + ;
    

    推荐在整个应用程序的装入屏中使用绝对屏号,在章节的装入屏中使用相对屏号。当然,足够小的应用程序可能不分章节,这样只需要一个装入屏(此时可以使用相对屏号)。

    层次结构的主要优点是,用户可以自由地修改、装入、测试和使用程序的任何一部分而不影响其他部分。这非常有利于快速迭代开发。层次化组织的屏就像是一本书,可以独立而快速地“随机访问”其中的任何一个章节。

    如果程序的某一个章节有一个存放在不同屏号位置的新版本,那么只需要修改装入屏中的屏号就可以进行测试,而不需要将大块内容在非挥发性存储器内部搬来搬去。

    一屏内容要么是程序,要么是装入屏,不要将它们混合在一起。在一大堆程序中间掺杂一个 load 或者 thru 会大大影响可读性。

  • 定义尽量不要跨屏。

    跨屏的定义难以调试。如果遵循多定义几个简单的词的建议,那么一个定义的长度很难超过一屏7。

  • 一屏尽量留一两行空行,以备不时之需。

    调试程序的时候万一需要加几条指令的话,这一两行空行可以让编程者免去将块搬来搬去的麻烦。

  • 用一屏的第 0 行书写关于从该屏开始的一部分程序的注释。

    建议使用每个词汇表的开头一屏的最前面一行书写关于这个词汇表作用的简要注释。

    在只支持块而不支持文件的 Forth 系统(如 My4TH Forth)中,编程者很容易忘记某一个具体的块的内容。第一部分“块操作”一节中有一个例子讲过如何遍历块,并打印所有位于某屏的最前面一行并以 \ C 开头的注释。借助这一小段程序,可以在忘记某道程序存储在何处的时候迅速找到它(但是不要忘了这个例子本身存储在何处,建议在屏 0 中存放关于这段程序位置的注释)。

  • 冒号定义从一行的最左边开始。

    建议每一个定义都至少占一行,冒号从行首开始:

    : hello  ." Hello" ;
    : goodbye  ." Goodbye" ;
    

    而不是:

    : hello  ." Hello" ;  : goodbye  ." Goodbye" ;	\ goodbye 不容易找到!
    

    如果实在空间不足,那么建议在占用同一行的定义之间空三个以上的空格:

    : hello  ." Hello" ;   : goodbye  ." Goodbye" ;	\ 空三格好一些
    
  • 注意空格和缩进。

    众所周知,Forth 解释器接受输入流中由空白字符分隔的记号,对空白字符的个数没有特别要求。但是对人来说,在编写程序时将联系紧密的一组记号用 1 个空格分隔,联系相对不那么紧密的记号之间用 2 个或多个空格分隔,对提高可读性有良好作用。这里本文作者给出自己习惯的例子(与 Thinking Forth 中的建议未必完全相同):

    : hello  ." Hello" ;	\ 冒号与名称之间 1 个空格,名称与冒号定义体之间 2 个空格
    
    : fh ( offset -- offset-block ) blk @ + ;	\ 如果要注释出词对栈的作用,
    											\ 栈表示法写在名称与冒号定义体之间,
    											\ 前后各 1 个空格即可
    : fh	\ ( offset -- offset-block)			\ 多行定义从第二行起缩进。
      											\ 建议以 3 个空格为单位(2 个也可接受)
       blk @  ?dup 0= if  scr @  then  + ;		\ 逻辑上联系相对紧密的一组记号之间
       											\ 空 1 格,组与组之间空 2 格
    
    : hello  ." Hello" ;   : goodbye  ." Goodbye" ;	\ 实在空间不足要把两个冒号定义写一行
      												\ 时,中间空 3 格
    
    : price  create 0 ,
       does> (price-action) ;	\ does> 最好另起一行
    
    : price  create 0 ,   does> (price-action) ;	\ 不想另起一行的话,中间空 3 个空格吧
    
    
    create buttons  ' btn0 , ' btn1 , ' btn2 , ' btn3 , ' btn4 ,	\ create 也与
      ' btn5 ,														\ 冒号定义类似
    
    : cond ( f -- )
       if 
          ." True." 	\ 以 3 个空格为单位进一步缩进
       else				\ 如果空间足够,建议用回车分隔控制结构,
          ." False."	\ 用类似其他语言的缩进规则来缩进程序
       then  cr ;		\ 为省空间,cr 可以空两格放在 then 后面,最后的分号没必要另起一行
    
    \ 为省空间也可以用空格分隔控制结构,但多重缩进时不容易看清楚,建议只在最内层这样做
    : cond ( f -- ) if  ." True."  else  ." False."  then  cr ;	
    
  • 如果一屏的开始改变了缺省的数制,那么在一屏的最后要将数制切换回十进制。

    数制对 Forth 系统来说属于全局状态。如果程序必须改变数制,建议在每一屏的开始改变,最后用 DECIMAL 恢复,或更讲究的办法是一开始用 base @ 将数制压在栈中,最末了儿用 base ! 恢复。

    当然 Forth 2012 标准规定了 #(十进制)、$(十六进制)、%(二进制)等数制前缀。利用它们可免去改变和恢复数制的麻烦。

    如果系统不支持这些前缀,那么还有一条建议——

  • 在 HEX 状态下,16 进制数总是用 “0” 起头,以和十进制数与词区分。

    如:

    12	\ 在 decimal 模式下是十进制的 12,在 hex 模式下是十六进制的 12,
    	\ 阅读程序到一半的读者无法区分
    012	\ 如果总是用 12 表示十进制的 12,用 012 表示十六进制的 12,那么就可以区分了。
    
    ADD		\ 到底是一个词还是一个十六进制数,完全取决于词典中是否有定义
    0ADD	\ 只需要保证不定义 0 开头且后面只包含数字及 A~F 的词,
    		\ 就可以区分十六进制数字和词了。
    		\ 当然更好的办法是系统一直处在 decimal 模式下,
    		\ 使用 12 和 $12、ADD 和 $ADD。但不是所有的系统都支持
    

注释

  • 尽量让代码的意义自明。

    这条建议的意义是自明的。

  • 在注释中说明词的用途。

    例:

    : range ( n min max -- n1 ) \ if min<=n<max, n1=n; else n1=0
       2 pick -rot within 0= if  drop 0  then ;
    
  • 在注释中善用栈表示法。

    关于一个词对栈的作用,前面已经有很多例子。如果有复杂的栈操作,多加一些栈表示法的注释会非常有助于理解。比如前面二分查找的例子。

  • 为定义词的定义时行为和实例行为分别撰写注释。

    例:

    : array \ name ( #cells -- )	\ example: 4 array name
       create cells allot
       does> ( i -- 'cell ) swap cells + ;	\ 'cell 代表 cell 的地址。
        									\ 对实例行为的注释中不用写出 does> 的行为
        									\ (压入栈顶的数据区域地址)
    
  • 为立即词的编译时行为和执行时行为分别撰写注释。

    例:

    : if ( f -- )	\ 执行时行为:根据栈顶标志判断是否需要执行下面的程序段
    \ Compile: ( -- address-of-unresolved-branch )	\ 编译时行为:
    												\ 将下面要处理的分支地址压栈
    ... ; immediate	\ 这是一个立即词
    
    : abort" ( f -- )			\ 执行时行为:根据栈顶标志判断是否中止程序并打印信息
    \ Compile: string" ( -- )	\ 编译时需要执行时打印信息的字符串 string,以 " 结尾
    ...
    

    有时一个词既可以在编译模式下使用,又可以在立即模式下使用,那么可以这样编写注释:

    : s"	( -- c-addr u )		\ 执行时行为:将编译时后跟的字符串对应的
    							\ ( c-addr u ) 二元组压栈
    \ Compile: string" ( -- )	\ 编译时需要字符串 string,以 " 结尾
    \ Interpret: string" ( -- c-addr u )	\ 如果在立即模式下使用,后面需跟字符串 
    										\ string,以 " 结尾,返回字符串对应的
    										\ ( c-addr u ) 二元组
    ...
    

词的名称

  • 词的名称要说明一个词“做什么”而不是“怎么做”。

    名称是一个代号(做什么),而不是内容的缩写(怎么做)。比如

    Oedipus complex
    

    就比

    subconscious-attachment-to-parent-of-opposite-sex complex
    

    更适合作为一个名称,尽管读者不一定知道俄狄浦斯王弑父娶母的典故(现在不知道的读者也知道了)。

    具体到 Forth 上,不要把名称当成程序的缩写。例如:

    词 ALLOT 做的事情是分配内存。在大部分系统上,ALLOT 的做法是将 HERE 增加相应的字节数。然而 ALLOT 这个名字比诸如 HERE+! 的名字好,因为编程者想的是分配内存(做什么),而不是移动指针(怎么做)。

    当然懂得语言学的读者肯定听过凡是规则必有例外这半句话,况且这部分的内容都是惯例与建议,称不上规则(Forth 社群并没有其他语言的社群一样注重标准、规范之类的东西,甚至 Forth 2012 标准官方网站也更像一个论坛而不是死的文档,每一条下面读者都可以讨论)。诸如 1+ 这种“做什么”能通过“怎么做”自明而且定义非常简单的词,名称当然可以既说“做什么”,又说“怎么做”。

  • 选择最能表达意思的名称。

    例:列出所有的词,用 WORDS 比用 VLIST(vocabulary list 的缩写)要更加达意。

    注:有些 Forth 系统支持在不同词表之间进行切换(My4TH Forth 暂无此项功能)。如 EDITOR 切换至命令式行编辑器的词表,ASSEMBLER 切换至汇编器的词表。此时 editor words 或者 assembler words 的意义就自明了。

  • 选择在自然语言中能跟前后的词构成词组,且行为符合词组意义的名称。

    这有利于让意义自明。例:

    shutter open	\ shutter 将代表快门的 I/O 地址压栈,
    				\ open 将这个地址代表输出的位设置为 1。
    
    3 button does ignition	
    	\ button 是 xt 的数组,3 button 取得这个数组的第 3 个元素的地址
    	\ (可以方便地用 create...does> 定义这样的数据类型)。
    	\ does 的作用是,将输入流中下一个词(此处为 ignition)的 xt 写到栈顶地址中。
    	\ 这个程序段的意义与自然语言的意义(按钮 3 执行点火)完全符合。
    
    i'm harry		\ 登录某个系统的词命名为 i'm,
    				\ 这比 logon,login 或者 session 之类的名称都自然。
    
  • 选择简洁的名称。

    如果词的名称有多个选择,选择更短的那个。例如:

    • HERE 强于 CURRENT-POSITION;
    • ENABLE 强于 ACTIVATE,GO、RUN 或者 ON 可能更好;
    • BRIGHT 强于 INTENSE。
  • 词的名称尽量使用全称而非缩写。

    在惜存储空间如金的年代,人们经常使用缩写,如:

    • 用 DSPL-BFR 来缩写 DISPLAY-BUFFER

    • 用 TMNL 来缩写 TERMINAL

      等等等等。

    从全称得到缩写容易,从缩写猜出全称难(我到底用什么缩写了 DISPLAY?是 DISP 还是 DSPL 还是 DSPLY?)。在存储极大丰富的当代尽量不要这样做。

    如果碰巧系统的存储空间很小,实在要求编程者惜字如金,可以与上一条建议结合起来考虑。如:

    • GO 强于 ENBL

    凡是规则必有例外的下半句是凡是例外均有规则,这条建议的例外能让读者理解这半句话。有一些词习惯用缩写:

    • Forth 系统中使用非常非常多的一些词用单个符号表示,如:

      : ; @ ! . ,
      

      把它们的名字起成

      DEFINE END-DEFINITION FETCH STORE PRINT COMPILE#
      

      不是不行,但是太过于冗长了(熟悉其他编程语言的读者可以比较一下 C 和 Pascal)。

      有趣的是,这些符号的名称,有一些已经进入自然语言,用来描述 Forth 语言独有的特性,如:

      • 冒号定义(有其他的词么?)

      • 将一个数逗进词典(逗号把一个数写进词典,跟 Forth 中编译的过程类似,但是跟冒号编译器的行为还不完全一致)

    • 在终端下经常使用的词有时适合用单个字母的名称。如行编辑器的指令(i 代表插入行,d 代表删除行这类)。

    • 大家已经很熟悉的缩写。如汇编器中的助记符(JMP、MOV 等等)。

  • 注意有连字符的词,它们往往可以拆分。

    例:

    enable-left-motor			left motor on
    enable-right-motor			right motor on
    disable-left-motor			left motor off
    disable-right-motor			right motor off
    enable-left-solenoid		left solenoid on
    enable-right-solenoid		right solenoid on
    disable-left-solenoid		left solenoid off
    disable-right-solenoid		right solenoid off
    

    上面的例子中,左边一列需要定义 8 个词,而右边一列只需要定义 6 个词。而且如果系统发生了扩展(例如添加了中间的电动机和电磁铁),那么左边一列就需要再增加 8 个词(总共 16 个),右边一列只需要增加 middle 一个词(总共 7 个)。

  • 注意名称中包含数字的词,它们往往可以拆分。

    例如一个音频处理程序有三个通道,用 1channel、2channel 和 3channel 三个词代表,那么在多数情况下用 channel ( n -- ...) 一个词就可以取代,用法是:

    1 channel
    2 channel
    3 channel
    

    当然这两种方式的意义可能同样不显然。更好的办法是用描述性的名称来指代这三个通道,比如 voice、guitar 和 bass。

  • 学习并采用 Forth 系统中词的命名方法。

    Forth 长期的发展中已经形成了一套给词命名的惯例。它们的最好体现就是 Forth 系统的标准词集本身。

    例如,. 代表打印或显示,d.、u.、.r 和 u.r 用来按不同的格式打印不同类型的数字。不妨将打印日期的词起名叫 .date,这样它的意义是自明的。

    符号或包含符号的词应该能念出来,这样才能跟别人讨论程序。如 >R 可以念作“to-R”或“去返回栈”,R> 可以念作“R-from”或“从返回栈来”。Forth 2012 标准中提供了这些包含符号的词的推荐英文念法。

    后面的“词的前后缀约定”一节中提供了给词起名时使用的前后缀的惯例。Forth 系统中提供的词大都遵循这些惯例。但是应用这些前后缀时需要注意——

  • 给词起名时,善用而不滥用前后缀。

    不要让前后缀提供冗余的信息,而将它们用于区分相似的词。例如:

    ... done if  close  then ...
    

    并不比

    ... done? if  close  then ...
    

    难懂。那么就不妨省掉这个 ?,除非程序中还有一个词叫 DONE。

拆分(factoring)与提取(factoring out)8

拆分指将程序组织成便于使用的小片段。任何程序都有可复用的部分和不可复用的部分,通过良好的拆分,程序中可复用的部分定义为词,不可复用的部分变成词的参数。

这一过程也就是将上面说到的这些部分提取出来。下面给出了一些技巧与原则。

拆分和提取的技巧

  • 提取出数据。

    借助栈,这是很自然的。例如要计算 1000 的三分之二:

    1000 2 3 */
    

    如果要计算任意数值的三分之二,可以定义

    : two-thirds ( n1 -- n2 ) 2 3 */
    

    计算 800 的三分之二就是 800 two-thirds。

    如果数据在程序中间使用,那么需要借助栈操作的词将其移动到合适位置。例如,想在 80 列的屏幕上将一个 10 字节长的字符串居中:

    80 10 - 2/ spaces
    

    但是字符串的长度不一定总是 10。为了让这一小段程序对任何长度的字符串都合用:

    : center ( length -- )
       80 	( length 80 )
       swap	( 80 length )
       - 2/ spaces ;
    

    字符串的长度为 20 的时候就可以用 20 center。

    数据栈也可以用来传递地址。因此,提取出来的数据不一定是一个数字,它可以是一个数字、一个数组、一个字符串(通过带长度的字符串或者 ( c-addr u ) 二元组的格式),甚至是一段程序(通过 xt)。

    有时想要提取的部分看起来像一段程序,但实际上是一个数字:

    程序段 1: willy nilly puddin' pie and
    程序段 2: willy nilly 8 * puddin' pie and
    

    这里程序段 2 多出来了一个 “8 *”。注意到 1 乘以任何数得其本身,可以定义

    : new ( n -- ) willy nilly * puddin' pie and ;
    

    这样有

    程序段 1: 1 new
    程序段 2: 8 new
    

    当然如果程序段 “willy nilly” 改变了栈的内容,那么需要用合适的栈操作词将 n 移动到合适位置。

    如果运算涉及加减法,那 0 就可以起到乘除法中 1 的作用。

    提示:将两段程序的差异用数字(可以是值或者地址)的差异而不是操作过程的差异来表达。

  • 提取出同样的功能。

    当然,有时两段程序确实有功能上的差异。如:

    程序段 1:bletch-a bletch-b bletch-c   bletch-d bletch-e bletch-f
    程序段 2:bletch-a bletch-b perversity bletch-d bletch-e bletch-f
    

    不好的拆分:

    : bletches ( t=do-bletch-c | f=do-perversity -- )
       bletch-a bletch-b
       if bletch-c else perversity then
       bletch-d bletch-e bletch-f ;
    
    程序段 1:true bletches
    程序段 2:false bletches
    

    好的拆分:

    : bletch-ab  bletch-a bletch-b ;
    : bletch-def  bletch-d bletch-e bletch-f ;
    
    程序段 1:bletch-ab bletch-c   bletch-def
    程序段 2:bletch-ab perversity bletch-def
    

    在提取功能时不要向下传递标志。首先,在这种情况下,是应该进行一种操作还是进行另一种操作,编程时早已知道。向下传递标志的方法会把这一判断放在运行时进行,影响程序的效率。其次,这种方式的语义是模糊的,会造成程序不可读:true bletches 和 false bletches 真是相反的么?

  • 提取出控制结构中的代码。

    下面的程序片段的作用是,将可打印字符(ASCII 码 32..126 的字符)打印出来,遇到不可打印字符打印“.”:

    ... ( c ) dup 32 127 within if  emit  else  drop [char] . emit  then ...
    

    显然,两个分支都执行了 emit。这个 emit 应该从控制结构内提取出来(记得执行逻辑非操作用 0=):

    ... ( c ) dup 32 127 within 0= if  drop [char] .  then  emit ...
    

    更难的情况是复杂的控制结构相同,但控制结构内部执行的功能不同。这种情况下如果难以提取,可以用栈传递 xt,或者借助 DEFER 定义的可执行变量来实现拆分。

    需要提醒读者:如果要将 DO…LOOP 中间的内容提取出来组成一个单独的定义,注意要把其中的 I、J、K 和 L 等“循环变量”改成用栈传递的参数。

  • 提取出控制结构本身。

    下面的两个程序片段中,IF…THEN 控制结构相同,但执行的内容不同:

    : active  a b or c and if  tumble juggle jump  then ;
    : lazy    a b or c and if  sit eat sleep  then ;
    

    可以这样提取:

    : conditions? ( -- f ) a b or c and ;
    : active  conditions? if  tumble juggle jump  then ;
    : lazy    conditions? if  sit eat sleep  then ;
    

    多重的 IF…ELSE…THEN 结构或复杂的 BEGIN…多个 WHILE…REPEAT 结构可通过合理的 EXIT 简化。本文第一部分给出了例子。

  • 提取出名字。

    如果程序中出现一系列有规律的名字,那么其中的规律往往可以提取出来变成一组更简单的词,甚至一个词和参数。前面“词的名称”一节已经给出了例子。

  • 用 CREATE…DOES> 提取功能。

    下面这个例子在前面已经出现过:

    variable light-mask	\ 是否亮色的掩码。
    					\ 0 表示普通颜色,8(第 3 位为 1 )表示亮色。与 颜色代码按位或。
    0 light-mask !
    : light  8 light-mask ! ;	\ 亮色:将掩码 light-mask 设置为 8
    : color ( color-code -- color-code-with-light-mask )
       light-mask @ or	\ 颜色代码与 light-mask 取或
       0 light-mask ! ;	\ light 只管到最近的一个颜色词,所以用完之后设置回 0 
    
    : black  0 color ;   : blue  1 color ;   : green  2 color ;
    : cyan  3 color ;   : red  4 color ;   : magenta  5 color ;
    : brown  6 color ;   : gray  7 color ;
    

    另一种写法是借助 CREATE…DOES>:

    variable light-mask	\ 是否亮色的掩码。
    					\ 0 表示普通颜色,8(第 3 位为 1 )表示亮色。与颜色代码按位或。
    0 light-mask !
    : light  8 light-mask ! ;	\ 亮色:将掩码 light-mask 设置为 8
    : color \ name ( color-code -- ) 
       create ,
       does> ( -- color ) @ light-mask @ or	\ 颜色代码与 light-mask 取或
       0 light-mask ! ;	\ light 只管到最近的一个颜色词,所以用完之后设置回 0 
    
    0 color black   1 color blue   2 color green
    3 color cyan   4 color red   5 color magenta
    6 color brown  7 color gray
    

    对于使用“经典的”(间接或直接)穿线式代码的 Forth 系统而言,第二种写法更省内存空间;然而对 My4TH Forth 这一使用子例程穿线式代码的 Forth 系统,第一种写法更省内存空间。

    当然第二种写法有它的优势:

    • 一眼可以看出,这些颜色都是同类的东西。

    • 这些颜色的定义时行为相同,由 CREATE 后面的程序段统一规定。如果要对数据的结构进行修改,只需要修改一个地方而不是 8 个地方。

拆分和提取的原则

  • 保持定义简短。

    最硬核的的拆分论者也许是 Forth 语言的创始人 Chuck Moore。他认为一个定义的长度不要超过一行。然而即使是他本人也无法完全做到这一点:他自己写的程序中,一行的定义和两行的定义大概一半对一半。

    众所周知,人的注意力只能集中在最多 7 个左右的东西上,简短的定义最符合人类本身的认知规律。当你觉得对一段代码没有把握,也许就是时候拆分它了。

    关于这个问题,Moore 还谈过两点:

    • 双重循环不好调试,因此它的内层一般都应该提取出来,定义一个新词;

    • 如果为了测试提取出一个新词,那么不要把它塞回原来(更大的)词中。它能提出来单独测试,就说明它能单独使用。

    这个原则的另一面是:当你觉得需要一个注释,尤其是在定义中间需要提醒你自己栈里有什么东西,也许就是时候拆分它了。

    例如:

    ... balance  dup xxx xxx xxx xxx xxx xxx xxx xxx xxx
         xxx xxx xxx xxx xxx xxx   ( balance ) show ...
    

    这一程序段的开始计算 balance,最后打印它。中间的一长串 xxx 利用 balance 做自己的工作。因为中间这串处理代码太长,到了 show 那个地方,编程者需要提醒自己,show 需要栈内的 balance 作为参数。

    这时候把中间的一长串 xxx 拆分出来会让代码更清晰:

    : revise ( balance -- ) xxx xxx xxx xxx xxx xxx xxx
       xxx xxx xxx xxx xxx xxx xxx ;
    ... balance  dup revise show  ...
    

    当然编程者可能会担心,过多的短定义会浪费过多的内存(因为每个定义都有不短的头部)。这当然是事实,然而简短的定义带来的测试、调试与使用上的便利,让这种对内存的占用很难称为“浪费”。而且良好的拆分与提取消除了代码的重复部分,从总体上看占用的内存可能更少。况且,与其他语言相比,Forth 占用的内存已经够少了。

    另外的一个担心是,过多的短定义带来的转子程序和返回操作会影响程序的执行效率。这一担心也是事实,然而一般来说这种转子和返回对效率的影响微乎其微。有些专门为 Forth 系统优化的体系结构(例如 J1)甚至提供了单周期转子和零周期返回(爱整活的读者可以试试在 J1 的基础上实现零周期转子)!如果效率真正成为了卡脖子的要素,那么也许应该用汇编重写真正卡脖子的部分。

    为了节约内存,有些 Forth 系统可以限制名称的宽度,也就是在词典中只存储定义名称的长度和前几个(如 3 个)字符。有些系统甚至可以默认限制宽度,必要(有两个名称的长度和前几个字符相同)时存储全长的名称。限于篇幅,本文不讨论这些高级技巧。

  • 提取出重复的代码片段。

    这一点可能比保持定义简短还重要。Moore 认为,一个简短的词固然好(比起一大篇代码中的一小部分,它更清晰,也方便调试),但是如果能用多次那就更好了。如果一个词只用一次,程序编写者可能会质疑它的意义。当程序变得很大的时候应该回头看一下,找找有没有可以提取出的部分。

    例如,编写一个编辑器时,下面的片段可能会出现很多次:

    frame  cursor @  +
    

    按其作用起名,不妨将其定义为 at。编程者需要动脑识别长得不同但是作用相同的代码,例如:

    frame  cursor @ 1-  +
    

    其实就是

    at 1-
    

    在编程时,如果发现自己要从前面复制代码片段过来用,那么复制的这个片段也许就应该提取出来定义成一个单独的词,以便后续使用。

    有时提取的部分也不是越多越好。看下面的例子:

    blk @ block >in @ +  c@
    

    编程者将这个程序段提取出来定义为 letter,因为它返回解释器当前处理的那个字符。

    后来编程者发现他需要使用下面的程序段:

    blk @ block >in @ +  c!
    

    于是他改变了 letter 的定义

    : letter  blk @ block >in @ + ;
    

    在程序中使用 letter c@ 和 letter c!。回过头来再看,这一定义比上一个更紧凑:它返回解释器当前处理的字符的地址,把对这个字符进行的操作剥离开了(当然一开始的时候不一定能意识到这点,这也是符合人类认知的规律的)。

  • 拆分出的程序段要能够命名。

    参考“词的名称”一节。

  • 为隐藏信息(尤其是可能变化的细节)进行拆分。

    Forth 系统的 >BODY(获得 CREATE 创建的词的数据区域的地址)是一个非常好的例子。前面已经讲过,在 My4TH Forth 中,>BODY 这样定义:

    : >body ( xt -- a-addr ) 13 + ;
    

    在使用经典的穿线式代码的 Forth 系统中,>BODY 可能这样定义:

    : >body cell+ ;
    

    如果直接用 cell+ 甚至 2 + 来代替 >BODY 的话,那么把使用了这一特性的程序移植到 My4TH Forth 就会变得非常麻烦:需要找到每一处然后替换,还需要判断每一个 cell+ 或者 2 + 到底是在找 body 的地址,还是在进行其他的操作(2 + 的可移植性更差,因为在诸如 32 位或者 64 位的 Forth 系统下面,cell 的大小就不是 2 了)。>BODY 隐藏了 CREATE 创建的词的具体实现,使程序变得可移植。

    另一个例子是上面举过的编辑器。一个对块进行编辑的编辑器可能有:

    : frame ( -- addr ) scr @ block ;
    : cursor ( -- addr ) r# ;
    : at ( -- addr ) frame cursor @ + ;
    

    调试的时候,可能不希望编辑器对实际的块进行修改(可能会弄坏一整块的内容!),那么就可以重新定义头两个词:

    create frame 1024 allot
    variable cursor
    

    这样不需要修改其他任何东西,就可以将编辑对象从真实的块变成内存中的一个缓冲区。

    隐藏信息有时也是不去拆分的原因。例如上面的例子

    : frame ( -- addr ) scr @ block ;
    

    不要盲目地将所有的 scr @ block 都替换成 frame,而只替换意义为“在编辑程序中,将当前编辑的块设置为列出的块”的那些。有时你的 scr @ block 有其他的用途,那就不要替换。因为将来如果你修改了 frame 的定义,那么这一小段又要替换回去!

  • 将处理数据的程序段和输出数据的程序段分解开。

    例如,下面的词可以计算 n 个人之间有多少条一对一的信息传输途径:

    : people>paths ( #people -- #paths ) dup 1- * 2/ ;
    

    下面的词调用 people>paths 并打印结果:

    : people ( #people -- )
       ." = " people>paths . ." paths " ;
    

    运行结果:

    2 people↵ = 1 paths  ok
    3 people↵ = 3 paths  ok
    5 people↵ = 10 paths  ok
    10 people↵ = 45 paths  ok
    20 people↵ = 190 paths  ok
    

    不要寻思着一个结果只用一次,就把它和输出的程序写在一起。你总有需要把它们拆分开的时候——明天你可能需要右对齐输出这个结果,后天你可能需要用不同的数制输出这个结果,这是没法预测的!

  • 进行拆分以简化用户界面。

    拆分不一定会得到更多的词。很多情况下,拆分的结果是更少的词。前面“词的名称”一节中 left motor on 和 1 channel 的例子已经说明了这一点。

  • 不要为了拆分而拆分。

    最后要记住:

    • 拆分词不是目的,而是让程序变得更加整洁易懂的手段。
    • 拆分词不是原因,而是拆分问题的结果。

    有些特别短(两三个词)、只涉及栈操作和简单算术运算的固定用法,如果不涉及信息隐藏,Forth 用户习惯直接使用这些成语,而不是再去新造词。例如9:

    1+ swap ( first-number last-number -- lim n )	\ 将人类习惯的“第一个数 最后一个数”
    												\ 转换为 do 或 ?do 使用的格式
    over + swap ( addr u –- addr+u addr )	\ 将 ( addr u ) 二元组转换为
    										\ do 或 ?do 使用的格式
    

冒号定义外的拆分和提取

拆分和提取不必须发生在冒号定义里。程序中往往会用 CONSTANT、VARIABLE 和 CREATE 等定义词定义数据,用 ! 给数据赋初值。此时可能也能发现需要拆分和提取的内容。

  • 如果一个常数的值依赖前面的常数,那么请借助 Forth 系统计算这个常数的值。

    例如,要打印下面的图形:

    ********    ********    ********
    ********    ********    ********
    ********    ********    ********
    ********    ********    ********
    ********    ********    ********
    
    
    ********    ********    ********
    ********    ********    ********
    ********    ********    ********
    ********    ********    ********
    ********    ********    ********
    
    
    ********    ********    ********
    ********    ********    ********
    ********    ********    ********
    ********    ********    ********
    ********    ********    ********
    

    很显然可以定义:

    8 constant wide		\ 方块的宽度(字符数)
    5 constant high		\ 方块的高度
    4 constant ave		\ 南北向间隔的宽度
    2 constant street	\ 东西向间隔的宽度
    

    要把这个图形在 80 列的屏幕上居中显示,那么左边界应该是 (80-图形总宽度)/2。

    图形总宽度是

    8 + 4 + 8 + 4 + 8 = 32
    

    显然左边界就是

    (80-32) / 2 = 24.
    

    可以定义

    24 constant leftmargin
    

    但是更好的做法是,将上面的计算交给 Forth 系统:

    wide 3 *  ave 2 *  +  80 swap -  2/ constant leftmargin
    

    这样如果更改了方块或者南北向间隔的宽度,不用重新人工计算 leftmargin。

  • 定义合适的定义词来简化定义。

    这个标题可能有点绕口。看下面的例子:

    假设需要定义一系列 I/O 端口的地址,它们从 $1a0 开始:

    $1a0 constant base.port.address
    
    base.port.address constant speaker
    base.port.address 2 + constant flipper-a
    base.port.address 4 + constant flipper-b
    base.port.address 6 + constant win-light
    

    这在功能上是正确的,但是代码有大量的重复。看下面借助 CREATE…DOES> 进行的改进:

    : port ( offset -- ) create ,
       does> ( -- 'port ) @ base.port.address + ;
    0 port speaker
    2 port flipper-a
    4 port flipper-b
    6 port win-light
    

    代码紧凑了很多。但是这段代码在运行时计算偏移量,这会降低运行效率。更好的改进是:

    : port ( offset -- ) base.port.address + constant ;
    0 port speaker
    2 port flipper-a
    4 port flipper-b
    6 port win-light
    

    这个例子中,偏移量在定义时计算,与最前面的那个例子相当。

    注意到每个端口的地址依次加 2,可以让代码更清爽:

    : port ( 'port -- 'next-port ) dup constant 2 + ;
    base.port.address
       port speaker
       port flipper-a
       port flipper-b
       port win-light
    drop ( port.address )
    

    到了这一步,注意到 base.port.address 只出现了一次,而且不是在任何定义当中。这种情况下不把它定义成一个常量而是直接按字面量使用,不会降低程序的可维护性,而且还能节约一点内存:

    : port ( 'port -- 'next-port ) dup constant 2 + ;
    $1a0 \ base port address
       port speaker
       port flipper-a
       port flipper-b
       port win-light
    drop
    

迭代式编程

  • 每次只关注问题的一个方面。

    继续前面打印星号组成的图形的例子。现在需要在指定的 x,y 坐标绘制或擦除由星号组成的方块。首先关注如何绘制方块(先不要管怎么擦除):

    8 constant wide   5 constant high
    : layer  wide 0 do  [char] * emit  loop ;
    : box ( upper-left-x upper-left-y -- )
       high 0 do  2dup i + at-xy layer  loop  2drop ;
    

    确认代码工作正常后,再研究如何利用这一代码擦除方块。显然,只要改变 layer 中打印的字符,把 [char] * 改成 bl 就可以了。用一个变量存储打印的字符:

    variable ink
    : draw  [char] * ink ! ;
    : undraw  bl ink ! ;
    : layer  wide 0 do  ink @ emit  loop ;
    

    这样,绘制或擦除方块可以使用

    10 10 draw box
    10 10 undraw box
    

    这一语序也是自然的。

    每次只关注问题的一个方面可以让程序更容易调试。在不受其他没测试过的代码的影响的情况下,更容易发现和改正出现的问题。

  • 不要一次更改过多的代码。

    在为了除错或增加功能修改程序的时候,编程者往往倾向于一次更改几个不同的地方。尽量不要这样做,而应该一次只更改一个地方,测试成功后再更改下一个地方。否则你会发现,但凡一次改了两三个地方,很容易就让整个程序工作不正常。一次只更改一个地方的好处是,如果改完之后程序工作不正常,那么很容易发现原因。

  • 不要过早花费精力考虑如何拆分。

    很多用户会好奇,为啥大部分 Forth 系统都没提供数组(ARRAY)这种在其他语言中非常基本的数据类型。对此,Moore 评论道(例子按 Forth 2012 标准稍作更改):

    编程的时候经常会遇到称作“数组”的一组对象。最简单的数组就是把下标加在地址上,返回新的地址。这种数组可以这样定义:

    create x 100 cells allot
    

    使用方法是:

    10 cells x +
    

    或者可以定义

    : x cells x + ;
    

    使用时写

    10 x
    

    编程时最令人纠结的事情之一,就是决定到底要不要给一个特定的数据结构建立定义词。到底有没有足够多的实例,让人值得这么做?

    我几乎不会提前知道程序里到底有没有多于一个的数组,因此我不会预先定义 ARRAY 这个词。

    如果程序里有两个数组,那么这个词可定义可不定义。

    如果程序里有三个数组,按常理就应该建立一个定义词了,除非它们各不相同——出奇的是,它们会各不相同的。有时候你想省去 @ 让数组直接返回值;有时候你需要字节数组,甚至是位数组;有时候你想做边界检查,或者存储数组的长度,以便在后面追加元素。

    难道要为了用上已经有的 ARRAY,把一个字节数组定义成单元(2 字节)的数组么?

    问题越复杂,越难找到一个通用的数据结构。真正复杂的通用数据结构非常少见。一个成功的例子是 Forth 的词典:它的结构紧凑、通用性很好。Forth 系统中各处都依赖它。但是这样的例子真的很稀有。

    如果编程者选择定义 ARRAY,他就提前对问题进行了分解,预先将数组这个概念从将要编写的词里提取了出来,提前进入了另一个抽象层面。而对问题进行抽象是一个动态过程,不应该进行预判。

  • 今天让代码工作,明天再优化它。

    继续放一段 Moore 的评论。背景是 Moore 在设计一台运行 Forth 的单板计算机,使用市场上供应的集成电路。他用 Forth 编写了模拟器,以测试计算机的逻辑。

    今早我意识到,我混淆了集成电路的型号与它在板子上的位号。现在这个阶段没什么问题,然而如果要把程序用来模拟使用了同样集成电路的其他板子,那么就能看出我把事情搞混了。

    我本来应该把集成电路的描述本身和它的应用场合拆分开,这样就有了一种“芯片描述语言”。当然在写这段程序的当时,我没有关注到这个级别的优化。

    即使现在想到了这一点,我可能也会说:“好吧,后面再干这个。”然后继续还没完成的工作。目前这个阶段,优化不是最重要的。

    当然我会努力进行良好的拆分。但是如果似乎没有好办法,我会先让程序能够工作。这么做的动机并不是懒惰,而是知道在过程中总会有其他的东西出现,并以无法预料的方式影响全局。在当前阶段就进行优化不是明智的选择,因为在观察到问题全貌之前,不可能知道到底什么才是最优的。

    上面提到的这些建议不应与前面讲的关于信息隐藏的建议矛盾。一种策略是提前提取出可能变化的部分以让程序易移植,另一种策略是直到必要时再修改程序。明智的编程者会在它们之间取得平衡。

    当然,在这两者中做决定需要经验。下面是一条一般的规律——

  • 用组织信息而不是增加复杂度的方式来解决未来可能会变化的事情。只有在当前迭代需要的时候增加复杂度,以使其正常工作。

编写可读的 Forth 程序

Leo Wong 总结了 10 条编写可读的 Forth 程序的指南:

  1. 使用与处理的数据相符的词。

  2. 冒号定义中不要使用 ASCII 码或其他“魔法数字”。

  3. 不要单纯为了把词拆分开而拆分词。

  4. 保证程序、注释、名称三者正确且一致。

  5. 不要使用语法糖。

  6. 避免把问题复杂化。

  7. 进行测试,即使非常显然的东西也要测试。

  8. 不要“前门拒虎,后门进狼”。

  9. 充分利用栈。

  10. 为词取名要有规律。

下面的例子主要来自 Forth Programmer’s Handbook 3rd ed(略有修改)。其中有些例子来源于 Starting Forth 和 Thinking Forth 等传统经典 Forth 入门著作,注意 Forth Programmer’s Handbook 的作者并不完全同意这些传统经典著作中的做法,这些例子有正面的也有反面的。

例 1:魔法数字

考察以下的三个例子:

: star  42 emit ;
: star  ." *" ;
: star  [char] * emit ;

它们各有自己的问题:

  • 第一个例子要求读者知道 “*” 的 ASCII 码为 42。(当然可以在注释里标明这点来解决问题。)

  • 本来要输出一个字符,然而第二个例子使用了输出字符串的词 ."。

  • 第三个例子可读性好,生成的代码与第一个完全相同,但是比前两个例子都长。

这三个相比,第三个例子是相对最佳的。

对应的规则:

    1. 使用与处理的数据相符的词。
    1. 冒号定义中不要使用 ASCII 码或其他“魔法数字”。

例 2:拆分词

设计时要拆分问题而不是单纯地拆分词。拆分词只是拆分问题的结果。

第一个例子来自 Starting Forth 第一版 43 页:

: QUARTERS  4 /MOD . ." ONES AND " . ." QUARTERS " ;

第二个例子来自 Starting Forth 第二版 40 页:

: $>QUARTERS ( dollars -- quarters dollars) 4 /MOD ;
: .DOLLARS ( dollars -- ) . ." dollar bills" ;
: .QUARTERS ( quarters -- ) . ." quarters " ;
: QUARTERS ( dollars -- )
   $>QUARTERS ." Gives " .DOLLARS ." and " .QUARTERS ;

显然,第二个例子有过度拆分的倾向,其中一个词的名字和两个注释也有问题。这种拆分让程序变得更大更冗长,但是没有增加程序的功能,也没有提高程序的可读性。

类似地,有前面播放音乐的例子:

: freude1  8 e4 8 e4 8 f4 8 g4 8 g4 8 f4 8 e4 8 d4
   8 c4 8 c4 8 d4 8 e4 ;

懂一点音乐的人很容易理解。

极端的拆分论者可能会只定义 c d e f g a b 七个音名的词,然后将八度的序号作为参数传递(也就是( 时值 八度序号 -- ):

: freude1  8 4 e 8 4 e 8 4 f 8 4 g 8 4 g 8 4 f 8 4 e 8 4 d
   8 4 c 8 4 c 8 4 d 8 4 e ;

这样做在大部分情况下并不是最优的,因为:

  • 差一个八度的音频率差一倍,因此每次执行的时候都需要根据八度序号计算参数。这需要乘法或循环移位操作,对 My4TH 这种运算非常慢的计算机来说会显著影响播放效果和时值。

  • 由于计算机的实际限制,播放程序中对应的参数往往不是严格的整倍数关系,会有正负几的误差(如 CEC-I BASIC 中,中音 c 的参数是 192,高音 c 是 95 而非 96)。这会让处理音名的程序包含乘法和分支判断,变得十分复杂。

  • 书写格式与乐理上的通用格式差别太大。

更极端的拆分论者可能会只定义一个词,将音名的代码也用数字代替(比如 c d e f g a b 用 1~7 编码,与简谱相同):

note	( duration octave note-number -- )	\ 如:8 4 3 note

这会把播放音乐的程序搞得比用 CEC-I BASIC 的 MUSIC 频率参数,时值参数 指令还复杂,严重影响了可读性。

对应的规则:

    1. 不要单纯为了把词拆分开而拆分词。
    1. 保证程序、注释、名称三者正确且一致。
    1. 为词取名要有规律。

例 3:简化

考察 Starting Forth 第二版 277~278 页的例子(按 Forth 2012 标准修改)。假设系统提供了下面两个词:

BRIGHT	( -- )	\ 将显示切换到高亮模式。
-BRIGHT	( -- )	\ 将显示切换回缺省模式。

现在需要一个输出高亮字符串的词,其具体行为为:将显示切换到高亮模式,输出字符串,然后将显示切换回缺省模式。

书中的第一个例子:

: bdot"  BRIGHT R> COUNT 2DUP + >R TYPE -BRIGHT ;
: B."  POSTPONE bdot" [CHAR] " WORD C@ 1+ ALLOT ; IMMEDIATE

原作者 Leo Brodie 的评价:“这个例子很凌乱,并且可能不好移植。”

第二个例子:

: B."  POSTPONE BRIGHT POSTPONE ." POSTPONE -BRIGHT ; IMMEDIATE

原作者的评价:“与上一个例子相比,第二个例子的主要缺点是,每次使用 B." 时都多编译了两个地址。第一个例子更加高效,如果读者有目标 Forth 系统的源码,确认第一个例子能够在目标系统上工作,并且使用 B." 很频繁,那么可以选用第一个例子。第二个例子的优点是实现更简单,适合使用 B." 不是很频繁的场合。”

“其他语言也许更好学,但是只有 Forth 语言能够这么灵活地扩展编译器。”

原作者举这两个例子的目的是介绍 POSTPONE 和 IMMEDIATE 的用法。如果不使用推迟执行等编译器特性,而使用 ( c-addr u ) 二元组表示字符串,可以这么定义:

: .BRIGHT ( c-addr u -- ) BRIGHT TYPE -BRIGHT ;

对应的规则:

    1. 不要使用语法糖。
    1. 避免把问题复杂化。

例 4:充分的测试

例子来自 Thinking Forth(重印版)第 219 页。原作者想表达的意思是,用逻辑运算获得标志比用 IF 简单、快、好(当然,对使用串行运算器的 My4TH 来说,这一点不一定成立)。举的例子是:

x=0 当 t<0 时
x=1 当 t>=0 时

与单个表达式

t 0< 1 AND

等价。

打开任何一个 Forth 系统进行测试,就知道问题出在哪里了。

对应的规则:

    1. 进行测试,即使非常显然的东西也要测试。

例 5:要不要避免 IF?

像上一个例子所描述的,很多 Forth 用户都付出很大的努力来避免使用 IF,有些人甚至极端到只要有可能就使用 CASE、查表或向量执行。下面的例子来自 Starting Forth 第二版:

第一个例子,使用 IF(183页):

: CATEGORY ( weight-per-dozen -- category#)
   DUP 18 < IF 0 ELSE
   DUP 21 < IF 1 ELSE
   DUP 24 < IF 2 ELSE
   DUP 27 < IF 3 ELSE
   DUP 30 < IF 4 ELSE
               5
   THEN THEN THEN THEN THEN SWAP DROP ;

: LABEL ( category# -- )
   DUP 0= IF ." Reject " ELSE
   DUP 1 = IF ." Small " ELSE
   DUP 2 = IF ." Medium " ELSE
   DUP 3 = IF ." Large " ELSE
   DUP 4 = IF ." Extra Large " ELSE
              ." Error "
   THEN THEN THEN THEN THEN DROP ;

第二个例子如下。原作者称它“对行家来说既简单又讲究”:

CREATE SIZES 18 C, 21 C, 24 C, 27 C, 30 C, 255 C,
: CATEGORY ( weight-per-dozen -- category# )
   6 0 DO DUP SIZES I + C@
   < IF DROP I LEAVE THEN LOOP ;
CREATE "LABEL"
   ASCII " STRING Reject  Small   Medium  Large   Xtra LrgError   "
: LABEL ( category# -- )
   8 * "LABEL" + 8 TYPE SPACE ;
: LABEL  0 MAX 5 MIN LABEL ;

(其中 ASCII 和 STRING 是 polyFORTH 系统的扩展:ASCII " 相当于标准的 CHAR ",STRING str<char> ( char --) 将后面直到 char 的字符按带长度字符串存入当前堆地址(类似于前面定义的,")。)

这里不再展开介绍这段代码,因为(此处应有黄色狗头 emoji):

  • 对行家来说这段代码很“简单”,不需要解释;

  • 一解释就得给这段“讲究”的代码除错。

你会用哪种实现方式?

对应的规则:

    1. 不要“前门拒虎,后门进狼”。

例 6:栈操作

对人看似很复杂的东西对 Forth 系统来说可能很简单。然而对 Forth 系统来说很简单的东西对人而言未必很复杂。

第一个例子( Thinking Forth(重印版)第 222 页):

\ Telephone rates                       03/30/84
CREATE FULL  30 , 20 , 12 ,
CREATE LOWER  22 , 15 , 10 ,
CREATE LOWEST  12 , 9 , 6 ,
VARIABLE RATE	\ Points to FULL, LOWER or LOWEST
				\ depending on time of day
FULL RATE ! 	\ For instance
: CHARGE ( o -- ) CREATE ,
   DOES> ( -- rate ) @ RATE @ + @ ;
0 CHARGE 1MINUTE 	\ Rate for first minute
2 CHARGE +MINUTES 	\ Rate for each additional minute
4 CHARGE /MILES 	\ Rate per each 100 miles
VARIABLE OPERATOR? 	\ 90 if operator assisted; else 0
VARIABLE #MILES 	\ Hundreds of miles
: ?ASSISTANCE ( Direct-dial charge -- total charge)
   OPERATOR? @ + ;
: MILEAGE ( -- charge ) #MILES @ /MILES * ;
: FIRST ( -- charge ) 1MINUTE ?ASSISTANCE MILEAGE + ;
: ADDITIONAL ( -- charge) +MINUTES MILEAGE + ;
: TOTAL ( #minutes -- total charge)
   1- ADDITIONAL * FIRST + ;

这个例子使用了很多 VARIABLE 来避免栈操作。可读么?

第二个例子:

\ Phone-rate table from Brodie, Thinking Forth,
\ reprint edition, p. 51
\ Rates are used as offsets into arrays
0 CELLS CONSTANT FULL
1 CELLS CONSTANT LOWER
2 CELLS CONSTANT LOWEST
\ Array-defining word
: FOR  CREATE DOES> ( rate - charge-per-minute) + @ ;
\ Table comprises three arrays
\ Charge-per-minute at FULL LOWER LOWEST rate
FOR FIRST 30 , 22 , 12 ,
FOR +MINUTES 20 , 15 , 9 ,
FOR DISTANCE 12 , 10 , 6 ,
90 CONSTANT ASSISTANCE \ Charge for operator assistance
: ?ASSISTANCE ( flag - charge) ASSISTANCE AND ;
: ADDITIONAL ( #minutes-1 rate - charge) +MINUTES * ;
: MINUTES ( #minutes rate - charge)
   DUP FIRST ROT 1- ROT ADDITIONAL + ;
: MILES ( distance #minutes rate - charge) DISTANCE * * ;
: TOTAL ( distance #minutes rate assistance-flag - charge)
   ?ASSISTANCE >R 2DUP MINUTES >R MILES 2R> + + ;

这个例子利用了栈操作。可读性下降了还是上升了?

对应的规则:

    1. 充分利用栈。

词的前后缀约定

格式 意义 例子
!name 存储到 name !DATA
#name 大小或数量 #PIXELS
输出数字 #S
缓冲区名称 #I
'name name 的地址 'S
指向 name 的指针的地址 'TYPE
(name) name 是内部组成部分,通常不希望用户直接使用 (IF) (FIND)
name 的运行时过程(实例行为对应的程序入口) (DOES>)
文件索引 (PEOPLE)
*name 乘 *DIGIT
词需要缩放过的参数 *DRAW
+name 加 +LOOP
向前移动指针 +BUF
使能 +CLOCK
更强大的 +INITIALIZE
词需要相对的参数 +DRAW
-name 减、移除 -TRAILING
禁止 -CLOCK
与 name 的作用相反 -DONE
返回取反的标志(非零代表条件不成立,零代表条件成立) -MATCH
文件中的指针 -JOB
.name 打印 name 提示的内容 .S
按 name 提示的格式打印栈中内容 .R .$
打印后面的字符串 ." string"
(可以前缀类型提示) D. U. U.R
/name 除 /DIGIT
初始化程序或设备 /COUNTER
“每” /SIDE
1name 一组元素的第一个 1SWITCH
整数 1 1+
大小为 1 字节 1@
2name 一组元素的第二个 2SWITCH
整数 2 2/
大小为 2 个单元 2@
;name 结束一部分内容 ;S
结束一部分内容,开始另一部分内容 ;CODE
<name 小于 <LIMIT
左括弧 <#
来自设备 <TAPE
<name> 设备驱动的内部组成部分的名称 <TYPE>
>name 数据去到 name >R >TAPE
索引指针 >IN
>name< 交换(尤其指字节) ><:交换字节; >MOVE<:移动并交换字节
?name 检查 name 代表的条件,如果满足则返回真 ?TERMINAL
条件满足时执行操作 ?DUP
检查 name 代表的条件,如果不满足则中止程序 ?STACK
取出 name 的内容并打印 ?N
@name 取出 name 的内容 @INDEX
Cname 大小为 1 字节 C@
Dname 大小为 2 个单元 D+
Mname 单精度与双精度整数的混合运算 M*
Tname 大小为 3 个单元 T*
Uname 无符号整数 U.
[name] 在编译时执行(与执行时使用的 name 作用相同的立即词) [']
\name 无符号减 \LOOP
name! 存储到 name B!
name" 后跟字符串,以 " 结束 ABORT" string"
name, 将内容存入堆中 C,
name: 开始编译 NONAME:
name> 右括弧 #>
数据来自 name R>
结束一部分内容,开始另一部分内容 DOES>
name? 与 ?name 相同 B?
name@ 取出 name 的内容 B@

  1. 本节内容改编自 Real Time Forth 前言。 ↩︎

  2. 选择词表的功能在 Forth 2012 标准中由 Search-Order 词集实现。My4TH Forth 目前没有实现这一词集。 ↩︎

  3. 本节内容部分改编自 Thinking Forth 第四章。 ↩︎

  4. a. 事实上一个全功能的句法分析器可能比一个 Forth 解释器还大。

    b. 读者可以思考一个问题:为什么 Forth 不支持冒号定义嵌套? ↩︎

  5. 本部分内容主要改编自 Thinking Forth 第四章和第五章。 ↩︎

  6. a. 到这里,前面提到过的“为什么 Forth 不支持冒号定义嵌套”的问题,读者应该已经有了答案。

    b. 当然为了效率,实现解释器时对 ( 和 \ 等进行特殊处理也不是不能接受的。具体的工程问题总是充满了折衷。

    c. 读者可能会感兴趣:实现一个 Forth 系统,最少需要多少个不能用其他词定义的词(称为原语)?先说结论:

    • 极端的简化论者可以用个位数个原语实现基本的 Forth 系统(功能不一定完整,效率也可能受影响)。

    • 实现完整的 Forth 系统最少需要 13 个原语。

    • 要想实用,需要 30 个左右或者更多的原语。

    Rod Pemberton 曾经做过一个不完全的统计(发布在新闻组 comp.lang.forth),摘录如下:

    ---
    FORTH Primitives Comparison (use a fixed width font)
    ---
    3 primitives - Frank Sargent's "3 Instruction Forth"
    9 primitives - Mark Hayes theoretical minimal Forth bootstrap
    9,11 primitives - Mikael Patel's Minimal Forth Machine (9 minimum, 11 full)
    13 primitives - theoretical minimum for a complete FORTH (Brad Rodriguez)
    16,29 primitives - C. Moore's word set for the F21 CPU (16 minimum, 29 full)
    20 primitives - Philip Koopman's "dynamic instruction frequencies"
    23 primitives - Mark Hayes MRForth
    25 primitives - C. Moore's instruction set for MuP21 CPU
    36 primitives - Dr. C.H. Ting's eForth, a highly portable forth
    46 primitives - GNU's GFORTH for 8086
    60-63 primitives - considered the essence of FORTH by C. Moore (unknown)
    72 primitives - Brad Rodriguez's 6809 CamelForth
        
    58-255 functions - FORTH-83 Standard (255 defined, 132 required, 58 nucleus)
    74-236 functions - FORTH-79 Standard (236 defined, 147 required, 74 nucleus)
    94-229 functions - fig-FORTH Std. (229 defined, 117 required, 94 level zero)
    133-? functions - ANS-FORTH Standard (? defined, 133 required, 133 core)
    200 functions - FORTH 1970, the original Forth by C. Moore
    240 functions - MVP-FORTH (FORTH-79)
    ~1000 functions - F83 FORTH
    ~2500 functions - F-PC FORTH
        
    eForth primitives (9 optional)
    ----
    doLIT doLIST BYE EXECUTE EXIT next ?branch branch ! @ C! C@ RP@ RP! R> R@ >R
    SP@ SP! DROP DUP SWAP OVER 0< AND OR XOR UM+ TX!
    ?RX !IO $CODE $COLON $USER D$ $NEXT COLD IO?
        
    9 MRForth bootstrap theoretical
    ----
    @ ! + AND XOR (URSHIFT) (LITERAL) (ABORT) EXECUTE
        
    9 Minimal Forth (3 optional)
    ----
    >r r> 1+ 0= nand @ dup! execute exit
    drop dup swap
        
    23 MRForth primitives
    ----
    C@ C! @ ! DROP DUP SWAP OVER $>$R R$>$ + AND OR XOR (URSHIFT) 0$<$ 0=
    (LITERAL) EXIT (ABORT) (EMIT) (KEY)
        
    20 Koopman high execution, Dynamic Freq.
    ----
    CALL EXIT EXECUTE VARIABLE USER LIT CONSTANT 0BRANCH BRANCH I @ C@ R> >R
    SWAP DUP ROT + = AND
        
    46 Gforth
    ----
    :DOCOL :DOCON :DODEFER :DOVAR :DODOES ;S BYE EXECUTE BRANCH ?BRANCH LIT @ !
    C@ C! SP@ SP! R> R@ >R RP@ RP! + - OR XOR AND 2/ (EMIT) EMIT? (KEY) (KEY?)
    DUP 2DUP DROP 2DROP SWAP OVER ROT -ROT UM* UM/MOD LSHIFT RSHIFT 0= =
        
    36 eForth
    -------
    BYE ?RX TX! !IO doLIT doLIST EXIT EXECUTE next ?branch branch ! @ C! C@ RP@
    RP! R> R@ >R SP@ SP! DROP DUP SWAP OVER 0< AND OR XOR UM+ $NEXT D$ $USER
    $COLON $CODE
    

    对这个问题十分感兴趣的读者可以参考新闻组 comp.lang.forth 1996 年 8 月 26 日的帖子 “What is Minimum Assembly Coded Words set in Forth?” 和 2008 年 6 月 28 日的帖子 “seeking a special Forth”。 ↩︎

  7. 一般的 Forth 系统都允许跨屏定义。跨屏定义可以用 THRU 装入。

    Thinking Forth 中介绍了“装入下一屏”的词 -->。在 Forth 2012 标准中,这一词可以这么定义:

    : -->  refill drop ; immediate
    

    这一词可以用在编译模式,也可以用在立即模式。需要注意,--> 与 THRU 不能同时使用(否则每一屏都会被 LOAD 不止一次)。--> 每次都会装入一系列的屏,不如 THRU 灵活。因此建议尽量使用 THRU。 ↩︎

  8. 本部分内容主要改编自 Thinking Forth 第六章。 ↩︎

  9. over + swap 这个成语的复杂程度恰好让 Forth 用户们见仁见智。比它更复杂的用法,大部分用户都会造一个词;比它更简单的用法,大部分用户都不会专门造词。My4th Forth 碰巧定义了不包含在 Forth 2012 标准中的 BOUNDS 一词来代表它(继承自 Gforth)。 ↩︎