Pharo By Example - 第八章 语法简述

标签: pharo ; smalltalk ;


Pharo采用了与其祖先Smalltalk非常接近的语法。

这种语法设计的初衷是让程序文本能够像某种简化版的英语一样被朗读出来。以下示例展示了类 Week 中的一个方法,它演示了这种语法。该方法检查 DayNames 是否已经包含了传入的参数,即判断该参数是否代表一个正确的日期名称。如果是,则将其赋值给类变量 StartDay

startDay: aSymbol

  (DayNames includes: aSymbol)
    ifTrue: [ StartDay := aSymbol ]
    ifFalse: [ self error: aSymbol, ' is not a recognised day name' ]

Pharo 的语法非常简洁。本质上,它只有发送消息(即表达式)的语法。表达式由极少数基本元素构成(如消息发送、赋值、闭包、返回等)。Pharo 中只有 6 个保留关键字(即伪变量),并且没有专门用于控制结构或声明新类的语法构造。相反,几乎所有功能都是通过向对象发送消息来实现的。

例如,条件语句不是 if-then-else 控制结构,而是表示为发送给布尔对象的消息(如ifTrue:)。通过给超类发送消息来创建新的子类。

8.1 语法元素

表达式由以下元素构成:

  1. 6个伪变量:self, super, nil, true, false以及thisContext

  2. 字面量对象的常量表达式, 包含:数字、字符、字符串、符号和数组

  3. 变量声明

  4. 赋值

  5. 块(闭包)

  6. 消息

  7. 方法返回

我们可以在下表中看到各种语法元素的示例

语法 语义
startPoint 一个变量名
Transcript 一个全局变量名
self 一个伪变量
1 十进制整数
2r101 二进制整数
1.5 浮点数
2.4e7 科学计数法表示的数字
$a 字符'a'
'Hello' 字符串 'Hello'
#Hello 符号 #Hello
#(1 2 3) 字面量数组
{ 1 . 2 . 1 + 2 } 一个动态数组
"a comment" 注释
| x y | 声明变量x和y
[:x | x + 2] 一个求值为x + 2的块
<primitive: 1> 方法注释
3 factorial 一元消息factorial
2 raisedTo: 6 modulo: 10 关键字消息raisedTo:modulo:
^ true 返回true
x := 2 . x := x + x 用分隔符(.)隔开的两个表达式
Transcript show: 'hello'; cr 用分隔符(;)隔开的两条级联消息

局部变量 startPoint是一个变量名,或者说标识符。按照惯例,标识符采用驼峰命名法(即,除第一个单词外,其余单词的首字母都大写)。实例变量、方法参数或块参数或临时变量的首字母必须小写。以向读者表明变量的作用域是私有的。

共享变量 以大写字母开头的标识符是全局变量、类变量、池字典或者类名。Transcript是一个全局变量,它是ThreadSafeTranscript类的实例。

消息接收者 self是一个伪变量,引用的是接收消息的对象本身。它给我们提供一种向它发送消息的方式。我们将self称为“接收者”,是因为这个对象会接收消息,然后执行方法。最后,self被称为“伪”变量,因为我们不能直接改变它的值或者给它赋值。

整数 除了普通的十进制整数(如42),Pharo还提供基数表示法。2r101是以2为基数的101(即二进制),等于十进制5

浮点数 浮点数可能用以10为基数的指数来指定:2.4e7等于2.4 x 10^7

字符 $符号引入了一个字面量字符:$A表示字符'A'。通过向Character类发送适当的消息(例如Character spaceCharacter tab)可以获得特殊的、非打印字符的实例。

字符串 单引号' '用于定义字面量字符串。如果你想要一个包含单引号的字符串,只需要使用两个连续的单引号,例如'G''day'

符号 符号类似于字符串,因为它们包含一个字符序列。但是,与字符串不同的是,符号保证全局唯一性。同一时间,系统中只能有一个符号对象#Hello的实例,但是可以有多个值为'Hello'的字符串对象。

编译时字面量数组 它们通过#()进行定义,括号中是通过空格隔开的字面量。括号中的所有内容都必须是编译时常量。例如#(27 (true) abc 1+2)是一个包含6个元素的字面量数组:整数27,包含对象true的编译时数组,符号#abc,整数1, 符号+以及整数2。注意,它与#(27 #(true) #abc 1 #+ 2)等价。

运行时动态数组 花括号{}定义了一个动态数组,其元素为表达式,由句点分隔,并且在运行时求值。因此{1 . 2 . 1 + 2}定义了一个数组,其中包含元素1, 2, 331 + 2的计算结果。

注释 双引号括起来的是注释。"hello"是注释而不是字符串,Pharo编译器会忽略它。注释可以跨越多行,但是不能嵌套。

局部变量定义 两个竖线||在方法或者块的开始之前包含了一个或多个局部变量的声明。

赋值 符号:=指定变量指向某个对象。

方括号[]用于定义一个块,也称之为闭包或者词法闭包。块是表示函数的一级对象。正如我们即将看到的,块可以接受参数([:i | ...]),并且可以拥有局部变量([|x| ...])。块携带了定义时的环境,也就是说,它可以引用在定义时能够访问的变量。

Pragmas 和原语< primitive: ... > 是一种方法注解。这种特定的注解表示对虚拟机(VM)原语的调用。在原语的情况下,它后面的代码要么用于解释原语的功能(对于必要原语),要么仅在原语失败时执行(对于可选原语)。同样的 < > 内消息语法也用于其他类型的方法注解,这些注解也被称为 pragmas。

一元消息 它们由发送给接收者(比如3)的单个单词(比如factorial)组成。在3 factorial中,3是接收者,factorial是消息选择器。

二元消息 是带有单个参数的消息,其选择器看起来像数学运算符(比如+)。在3 + 4中,接收者是3,消息选择器是+, 参数是4

关键字消息 它们的选择器由一个或多个关键字组成(比如raisedTo:modulo:), 每个关键字都以冒号结尾,并接受一个参数。在表达式2 raisedTo: 6 modulo: 10中,消息选择器raisedTo:modulo:接受两个参数610,每个冒号后面一个参数。我们把消息发送给接收者2

语句序列 句点(.)是语句分隔符。在两个表达式之间加上句点,可以将它们转换成独立的语句,如x := 2. x := x + x。这里我们首先将2赋值给变量x,然后通过赋值x + x来复制它的值。

级联 分号(;)用于向同一个的接收者发送级联消息。在stream nextPutAll: 'Hello World'; close中,我们首先向接收者stream发送关键字消息nextPutAll: 'Hello World', 然后给相同的接收者发送一元消息close

方法返回 ^ 用于从方法中return一个值。

基本类Number, Character, StringBoolean将在第13章中进行描述。

8.2 伪变量

在Pharo中,有6个伪变量:niltruefalseselfsuperthisContext。它们被称为伪变量,因为它们是预定义的,不能给它们赋值。true, falsenil是常量,而self, superthisContext的值随着代码的执行而动态变化。

  • truefalse分别是True类和False类的唯一实例,它们是Boolean类的子类。更多细节请参见:第13章.基本类

  • self总是指向消息的接收者,用于表示将要执行方法的对象本身。因此,self的值在程序执行期间动态变化,但是不能在代码中赋值。

  • super也总是指向消息的接收者,但是当您向super发送消息时,方法查找将发生变化,它将从当前对象(即向super发送消息的对象)的超类开始。欲了解更多细节,请参阅:第10章.Pharo对象模型

  • nil是未定义的对象。它是UndefinedObject类唯一的实例。实例变量、类变量和局部变量在默认情况下初始化为nil

  • thisContext是一个伪变量,表示执行栈的顶部帧,并提供对当前执行点的访问。大多数程序员通常对thisContext不感兴趣,但它对于实现诸如调试器之类的开发工具是必不可少的,而且它还用于实现异常处理和延续。

8.3 消息和消息发送

正如我们所描述的,Pharo中有三种具有预定义优先级的消息。这样做是为了减少强制性括号的使用量。

在这里,我们将简要介绍消息类型以及发送和执行它们的方式,而更详细的描述将在:第9章.理解消息中提供。

  1. 一元消息不带参数。1 factorial将消息factorial发送给对象1.一元消息选择器由字母、数字组成,并以小写字母开头。

  2. 二元消息只接收一个参数。1 + 2将带有参数2的消息+发送给对象1。二元消息选择器由下列集合中的一个或多个字符组成:+ - / * ~ < > = @ % | ? ,

  3. 关键字消息接受任意数量的参数。2 raisedTo: 6 modulo: 10将由消息选择器raisedTo:modulo:和参数610组成的消息发送给对象2。关键字消息的选择器由一系列字母数字关键字组成,其中的每一个关键字都以小写字母开始,以冒号结束。

消息的优先级

一元消息的优先级最高,其次是二元消息,最后是关键字消息,括号可以用于改变求值顺序。

因此,在下面的例子中,我首先将factorial发送给3,得到结果6,然后我们将+ 6发送给1,结果是7,最后我们将raisedTo: 7发送给2.

2 raisedTo: 1 + 3 factorial
>>> 128

撇开优先级不谈,对于相同类型的消息,严格地从左到右执行。因此,由于我们有两个二元消息,下面的示例返回的是9而不是7

1 + 2 * 3
>>> 9

必须用括号来改变求值顺序,如下所示:

1 + (2 * 3)
>>> 7

8.4 序列和级联

所有表达式都可以由句点分隔的序列组成,而消息发送也可以由分号级联组成。由句点分隔的表达式序列导致该序列中的每一个表达式都作为一个单独的语句进行求值,一个接一个进行。

Transcript cr.
Transcript show: 'hello world'.
Transcript cr

这将向Transcript对象发送cr, 然后向Transcript发送消息show: 'hello world', 最后,再向它发送另一个cr

当一系列消息被发送给同一个接收者时,可以更简洁地用级联来表示。接收者只指定一次,消息序列用分号隔开,如下所示:

Transcript
  cr;
  show: 'hello world';
  cr

这种级联的写法与前面的示例中的语句序列完全相同的效果。

8.5 方法的语法

虽然可以在Pharo中的任何地方(例如,Playground、调试器或浏览器)对表达式求值,但方法通常在浏览器窗口或调试器中定义。方法也可以从外部介质中导入,但这不是在Pharo中编程的常见方式。

程序是在给定类的上下文中一个方法一个方法地开发的。类是通过给已有的类发送消息,要求其创建子类来定义的,因此定义一个类并不需要特殊的语法。

下面是在String类中定义的lineCount方法。通常的约定是将方法引用为ClassName>>methodName。这里的方法是String>>lineCount。注意,ClassName>>methodName并不是Pharo语法的一部分,只是本书中用来表示方法所属的类的约定用法。

String >> lineCount
  "Answer the number of lines represented by receiver, where every
    cr adds one line."
    
  | cr count |
  cr := Character cr.
  count := 1 min: self size.
  self do: [:c | c == cr ifTrue: [count := count + 1]].
  ^ count

从语法上讲,一个方法包括:

  1. 方法模型,包含名称(即lineCount)和任何参数(本例中没有参数);

  2. 注释可以出现在任何地方,但惯例是在顶部放置一个注释,解释方法的功能;

  3. 声明局部变量(即crcount

  4. 用句点(.)分隔的任意数量的表达式(这里有四个表达式)。

执行任何以^开头的表达式(插入符号或上箭头,在大多数键盘上是Shift-6)将导致该方法从该点上退出,并返回^后面的表达式的值。如果在结束方法时没有显式地返回某个表达式值,将会隐式地返回self对象。

参数和局部变量应该始终以小写字母开头。以大写字母开头的名称被假定为全局变量。类名,例如Character,其实是引用表示该类的对象的全局变量。

8.6 块的语法

块(词法闭包)提供了一种机制来延迟表达式的执行。块本质上是一个带有定义上下文的匿名函数。通过向块发送消息value来让它执行。块返回其主体中最后一个表达式的值,除非有显式的return(带有^),在这种情况下,它返回^表达式的值。

[ 1 + 2 ] value
>>> 3

[ 3 = 3 ifTrue: [ ^ 33 ]. 44 ] value
>>> 33

块可以有参数,每个参数都用前置的冒号声明。一个竖条将参数声明与块的主体分隔开。要用一个参数求值一个块,你必须给它发送带一个参数的value:消息。双参数块必须通过发送带有两个参数的value:value:消息来调用,以此类推,块最多可以有4个参数。

[ :x | 1 + x ] value: 2
>>> 3

[ :x :y | x + y ] value: 1 value: 2
>>> 3

如果你有一个超过四个参数的块,你必须使用valueWithArguments:并在数组中传递参数。然而,当需要用到具有大量参数的块时,通常意味着你的设计存在问题。

在块里面还可以声明局部变量,用竖条包围,就像在方法中声明局部变量一样。局部变量声明在参数和竖条分隔符之后,在块的主体之前。在下面的例子中,xy是参数,z是局部变量。

[ :x :y |
  | z |
  z := x + y.
  z ] value: 1 value: 2
>>> 3

块实际上是词法闭包,因为它们可以引用周围环境的变量。下面的块引用了其外部环境中的变量x:

| x |
x := 1.
[ :y | x + y ] value: 2
>>> 3

块是BlockClosure类的实例。这意味着它们也是对象,所以它们可以像其他对象一样被赋值给变量并作为参数进行传递。

8.7 分支和循环

Pharo没有为控制结构提供特殊的语法。相反,通常它通过向布尔值、数字和集合(collection)发送消息来实现控制结构,并且以块作为参数。

一些分支

分支是通过向布尔表达式的结果发送ifTrue:ifFalse:ifTrue:ifFalse:中的一个消息来表示的。有关布尔值的更多信息,请参见第13章:基本的类

(17 * 13 > 220)
  ifTrue: [ 'bigger' ]
  ifFalse: [ 'smaller' ]
>>> 'bigger'

一些循环

循环通常通过向块、整数或集合(collection)发送消息来实现。由于循环的退出条件可能会被重复多次计算,所以它应该是一个块而不是一个布尔值。下面是一个非常程序化的循环示例:

n := 1.
[ n < 1000 ] whileTrue: [ n := n * 2].
n
>>> 1024

whileFalse:是反转的退出条件。

n := 1.
[ n > 1000 ] whileFalse: [ n := n * 2 ].
n
>>> 1024

timeRepeat:提供了一种简便的方法来实现计数迭代:

n := 1.
10 timesRepeat: [ n := n * 2 ].
n
>>> 1024

我们也可以将消息to:do:发送给一个数字,以该数字作为循环计数器的初始值。两个参数分别是上限,及一个块。该块以循环计数器的当前值作为参数:

result := String new.
1 to: 10 do: [:n | result := result, n printString, ' '].
result
>>> '1 2 3 4 5 6 7 8 9 10 '

高阶迭代器

集合(collection)包含大量不同的类,其中的许多类支持相同的协议。在集合上迭代的最重要的消息包括do:collect:select:reject:detect:inject:into:。这些消息表示高级迭代器,使用它们可以编写出非常紧凑的代码。

Interval是一个集合(collection),它允许从起点到终点迭代一个数字序列。1 to: 10表示1 ~ 10之间的区段。因为它是一个集合(collection),我们可以向它发送消息do:,参数是一个块,针对集合中的每一个元素进行迭代。

result := String new.
(1 to: 10) do: [:n | result := result, n printString, ' '].
result
>>> '1 2 3 4 5 6 7 8 9 10 '

select:(选择)和reject:(排除)在集合上进行迭代,利用满足或不满足条件的元素构建新的集合。

detect:(探测)返回集合中满足条件的第一个元素。

不要忘了,字符串也是字符的集合(collection),所以,你可以遍历其中所有的字符。

'hello there' select: [ :char | char isVowel ]
>>> 'eoee'

'hello there' reject: [ :char | char isVowel ]
>>> 'hll thr'

'hello there' detect: [ :char | char isVowel ]
>>> $e

最后,您应该注意到集合在inject:into:方法中还支持函数式风格的折叠操作符。您也可以将其视为MapReduce编程模型中的Reduce。这允许您使用以某个种子值作为开始,生成累积的结果,并注入集合的每个元素。sum和product就是典型的例子。

(1 to: 10) inject: 0 into: [ :sum :each | sum + each ]
>>> 55

这等价于0 + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10

关于集合的更多信息可以在第14章:集合中找到。

8.8 方法注解:原语与 pragmas

在 Pharo 中,方法也可以被注解。方法注解由 <> 分隔,主要用于两种场景:为语言的原语提供执行特定的元数据,以及一般的元数据。

原语(Primitive)

在 Pharo 中,一切都是对象,一切操作都通过发送消息来完成。然而,在某些情况下,我们会触及底层。某些对象只能通过调用虚拟机原语来完成工作。这些原语被称为必要原语,因为它们无法在 Pharo 中表达。

例如,以下全部实现为原语:内存分配(newnew:),位操作(bitAnd:bitOr:bitShift:),指针和整数算术(+-<>*/===...),以及数组访问(at:at:put:)。

当执行具有元语的方法时,原语代码将代替该方法进行执行。使用这种原语的方法可以包括附加的Pharo代码,只有当原语失败时才会执行这些代码(在原语是可选的情况下)。

在下面的示例中,我们看到SmallInteger>>+的代码。如果原语失败,则将计算表达式super + aNumber并返回其值。

+ aNumber
  "Primitive. Add the receiver to the argument and answer with the
    result
  if it is a SmallInteger. Fail if the argument or the result is not a
  SmallInteger Essential No Lookup. See Object documentation
    whatIsAPrimitive."
    
  <primitive: 1>
  ^ super + aNumber

Pragmas

在Pharo中,尖括号语法也用于被称为杂注(pragmas)的方法注释。一旦使用 pragmas 对方法进行了注释,就可以使用集合来收集注释(请参见PragmaCollector类)。

8.9 本章小结

  • Pharo只有六个被称为伪变量的保留标识符:truefalsenilselfsuperthisContext

  • 有五种字面量对象:数字(52.51.9e152r111)、字符($a)、字符串('Hello')、符号(#Hello)和数组(#('Hello' #hi){ 1 . 2 . 1 + 2 })

  • 字符串用单引号分隔,注释用双引号分隔。要在字符串中包含一个单引号,请连续输入两个单引号。

  • 与字符串不同,符号是全局唯一的。

  • 使用#( ... )在编译时定义字面量数组。使用{ ... }在运行时定义动态数组。请注意,#(1+2) size的结果是3,但{12 + 3} size的结果是1。要观察原因,请比较#(12+3) inspect{1+2} inspect

  • 有三种类型的消息:一元消息(例如,1 asStringArray new)、二元消息(例如,3 + 4'hi', 'here')和关键字消息(例如,'hi' at: 2 put: $o)

  • 级联消息是发送到相同目标的一系列消息,由分号分隔:OrderedCollection new add: #calvin; add: #hobbes; size的结果是2

  • 局部变量声明由竖线分隔。用:=表示赋值。|x| x := 1

  • 表达式由消息发送、级联和赋值组成,从左到右计算(并且可以选择使用括号分组)。语句是由句点分隔的表达式。

  • 块(闭包)是用方括号括起来的表达式。块可以带有参数,并且可以包含临时变量。在向块发送带有正确参数数量的消息value之前,不会计算块中的表达式。[ :x | x + 2 ] value: 4

  • 没有用于控制构造的专用语法,只有发送给条件求值块的消息。