Pharo By Example - 第十三章 基本的类

标签: pharo ; smalltalk ;


Pharo 是一种非常简单但功能强大的语言。其部分力量不在于语言本身,而在于它的类库。为了在其中高效编程,你需要学习类库如何支持语言和环境。类库完全用 Pharo 编写,并且可以轻松扩展。(回想一下,一个包可以向类添加新功能,即使它没有定义这个类。)

我们的目标不是以繁琐的细节介绍整个 Pharo 类库,而是指出你需要使用(或子类化/重写)的关键类和方法,以便高效编程。在本章中,我们将介绍几乎所有应用程序都需要的基本类:ObjectNumber 及其子类、CharacterStringSymbolBoolean

13.1 Object

无论如何,Object都是继承层次结构的根。实际上,在Pharo中,层次结构真正的根是ProtoObject,它用于定义伪装成对象的最小实体,但我们可以暂时忽略这一点。

Object定义了将近400个方法(换句话说,您定义的每个类都会自动提供所有这些方法)。注意:你可以像这样计算一个类中方法的数量:

Object selectors size         "实例方法的数量"
Object class selectors size   "类方法的数量"

Object 提供了所有普通对象共有的默认行为,例如访问、复制、比较、错误处理、消息发送和反射。此外,所有对象都应响应的实用消息也在这里定义。Object 没有实例变量,也不应添加任何实例变量。这是因为有几类从 Object 继承的对象具有特殊的实现(例如 SmallIntegerUndefinedObject),虚拟机(VM)知道这些实现并依赖于某些标准类的结构和布局。

Color >> printOn: aStream
    | name |
    (name := self name).
    name = #unnamed
        ifFalse: [
            ^ aStream
              nextPutAll: 'Color';
              nextPutAll: name ].
    self storeOn: aStream

如果我们开始浏览Object实例端的方法协议,我们将开始看到它提供的一些关键行为。

13.2 打印Object

每个对象都可以返回其打印形式。你可以在文本窗格中选择任何表达式并选择 Print it 菜单项:这将执行表达式并要求返回的对象打印自身。实际上,这会向返回的对象发送消息 printString。方法 printString 是一个模板方法,其核心是向接收者发送消息 printOn:。消息 printOn: 是一个可以专门化的钩子。

Object>>printOn:方法很可能是您重写得最多的方法之一。该方法接受一个Stream作为参数,将对象的String表示写入stream。默认实现只是将类名前面加上aanObject>>printString返回被写入的String。

例如,OpalCompiler类没有重新定义方法printOn:并且将消息printString发送给实例将执行Object中定义的方法。

OpalCompiler new printString
>>> 'an OpalCompiler'

Color类显示了printOn:专门化的一个例子。它输出类的名称,后跟用于生成该颜色的类方法的名称。

Color red printString
>>> 'Color red'

printOn: vs. displayStringOn:

你应该考虑到消息printOn:是用来在开发时更好地描述你的对象。事实上,当你使用检查器或调试器时,查看对象的精确描述比查看通用描述要有效率得多。printOn:不是用来在UI列表中很好地显示对象的,因为你通常想要显示不同种类的信息。出于此目的,你应该使用displayStringOn:displayStringOn:的默认实现是调用printOn:

请注意,displayStringOn:是最近才引入的,所以很多库仍然没有对此进行区分。这并不是一个真正的问题,但当您编写新代码时,您应该意识到这一点。

printOn: vs. storeOn:

注意,消息printOn:与消息storeOn:是不同的。消息storeOn:将一个表达式写入其参数流,该表达式可用于重新创建接收方。该表达式在使用消息readFrom:读取流时执行。另一方面,消息printOn:只返回接收方的文本表示。当然,这种文本表示可能会将接收者表示为一个自求值的表达式。

10.3 表示和自求值表示

在函数式编程中,表达式在执行时返回值。在Pharo中,消息发送(表达式)返回对象(值)。有些对象有一个很好的属性,它们的值就是它们自己。例如,对象true的值就是对象本身,即对象true。我们称这种对象为自求值对象。当您在Playground上打印对象时,您可以看到对象值的打印版本。下面是一些自求值的例子。

true
>>> true

3@4
>>> (3@4)

$a
>>> $a

#(1 2 3)
>>> #(1 2 3)

Color red
>>> Color red

注意,有些对象(如数组)可能是自求值的,也可能不是,取决于它们所包含的对象。例如,一个布尔值数组是自求值的,而一个person数组不是。下面的例子表明,只有在元素是自求值的情况下动态数组才是自求值的:

{ 10@10 . 100@100 }
>>> {(10@10) . (100@100)}

{OpalCompiler new . 10@100}
>>> an Array(an OpalCompiler (100@100))

记住,字面量数组只能包含字面量。因此,下面的数组包含的不是两个Point,而是六个字面量元素。

#(10@10 100@100)
>>> #(10 #@ 10 100 #@ 100)

许多printOn:方法的专门化实现了自求值行为。Point>>printOn:Interval>>printOn:的实现是自求值的。

Point >> printOn: aStream
  "The receiver prints on aStream in terms of infix notation."
  
  aStream nextPut: $(.
  x printOn: aStream.
  aStream nextPut: $@.
  (y notNil and: [ y negative ])
    ifTrue: [
      "Avoid ambiguous @- construct"
      aStream space ].
  y printOn: aStream.
  aStream nextPut: $).
Interval >> printOn: aStream
    aStream nextPut: $(;
        print: start;
        nextPutAll: ' to ';
        print: stop.
    step ~= 1 ifTrue: [ aStream nextPutAll: ' by: '; print: step ].
    aStream nextPut: $)
1 to: 10
>>> (1 to: 10)  "区间是自求值的"

13.4 身份与相等

在Pharo中, 消息=测试对象的相等性,而消息==测试对象的身份。前者用于检查两个对象是否代表着相同的值,而后者用于检查两个表达式是否代表相同的对象。

对象相等性的默认实现是测试对象的身份:

你如你打算覆盖=, 则应该考虑覆盖hash。如果您的类的实例会被用作Dictionary中的键,那么您应该确保被认为相等的实例具有相同的散列值。

==永远不应该被覆盖,对象识别的语义对于所有类都是相同的。消息==ProtoObject的基本方法。

请注意,与其他Smalltalk相比,Pharo有一些奇怪的相等行为。例如,符号和字符串可以是相等的。(我们认为这是一个bug, 而不是一个特性)

#'lulu' = 'lulu'
>>> true

'lulu' = #'lulu'
>>> true

13.5 类成员

有几个方法允许你查询对象的类。

class

你可以使用 class 消息查询任意对象的类信息。

1 class
>>> SmallInteger

isKindOf:

Object>>isKindOf: 回答接收者的类与参数的类是否相同,或者是参数的子类。

1 isKindOf: SmallInteger
>>> true

1 isKindOf: Integer
>>> true

1 isKindOf: Number
>>> true

1 isKindOf: Object
>>> true

1 isKindOf: String
>>> false

1/3 isKindOf: Number
>>> true

1/3 isKindOf: Integer
>>> false

1/3是一个分数,是一种数字,因为数字类是分数类的超类,但1/3不是一个整数。

respondsTo:

Object>>respondsTo: 回答接收者是否理解作为参数给出的消息选择器。

1 respondsTo: #,
>>> false

13.6 关于 isKindOf: 和 respondsTo:

关于 isKindOf:respondsTo: 的使用说明。通常情况下,查询对象的类或询问它理解哪些消息是一个坏主意。与其基于对象的类做出决策,不如简单地发送消息给对象,并让它(基于其类)决定应该如何行为。对象的客户端不应查询对象以决定发送哪些消息。“不问,只告诉”是良好面向对象设计的重要基石。因此,如果你需要使用这些消息,就要小心了。

13.7 浅拷贝(shallowCopy)

拷贝对象会带来一个微妙的问题。由于实例变量是通过引用访问的,所以对象的浅拷贝会与原始对象共享实例变量的引用:

a1 := { { 'harry' } }.
a1
>>> #(#('harry'))

a2 := a1 shallowCopy.
a2
>>> #(#('harry'))

(a1 at: 1) at: 1 put: 'sally'.
a1
>>> #(#('sally'))

a2
>>> #(#('sally))

13.8 深拷贝

有两种方法可以解决由浅拷贝引起的共享问题:(1)使用deepCopy(2)专门化postCopycopy

deepCopy

Object>>deepCopy 创建对象任意深度的副本。

a1 := { { { 'harry' } } }.
a2 := a1 deepCopy.
(a1 at: 1) at: 1 put: 'sally'.
a1
>>> #(#('sally'))

a2
>>> #(#(#('harry')))

deepCopy的问题是,当应用到一个相互递归的结构时,它不会终止:

a1 := { 'harry' }.
a2 := { a1 }.
a1 at: 1 put: a2.
a1 deepCopy
>>> !''... does not terminate!''!

copy

另一个解决方案是使用消息copy。在Object上实现如下:

Object>>copy
  "Answer another instance just like the receiver.
  Subclasses typically override postCopy;
  they typically do not override shallowCopy."
  ^ self shallowCopy postCopy

默认情况下,postCopy返回self。这意味着默认情况下,copyshallowCopy是一样的,但是每个子类可以决定自定义postCopy方法,它充当了一个hook.您应该重写postCopy来复制任何不应该被共享的实例变量。此外,很有可能postCopy应该始终执行super postCopy,以确保父类的状态也被复制。

13.9 Debugging

Object类还定义了一些与调试相关的方法。

halt

最重要的方法是halt,要在方法中设置断点,只需在方法体的某个点插入表达式self halt.注意,因为halt是在Object类中定义的,所以,你也可以写成1 halt

halt消息被发送后,执行将中断,并且程序中的调试器将在该点打开。

你可以使用Halt once或者Halt if: aCondition. 看一看Halt类,它是一个专门用于调试的异常。

assert:

下一个重要的消息是assert:,它期望一个block作为参数。如果block的计算结果为true,则继续执行。否则将引发AssertionFailure异常。如果未以其他方式捕获些异常,则调试器将在此位置打开。assert:对于支持契约式设计特别有用。最典型的用法是检查对象的公共方法的重要前提条件。Stack>>pop可以很容易地实现如下(注意,这个定义是一个假设的例子,不是在Pharo 8.0系统中的真实代码):

Stack >> pop
    "Return the first element and remove it from the stack."
    
    self assert: [ self isNotEmpay ].
    ^ self lindedList removeFirst element

不要混淆了Object>>assert:TestCase>>assert:,前者期望一个block作为它的参数(实际上,它可以接受任意参数,只要参数可以理解value消息,包括一个布尔值);后者出现在SUnit测试框架中,期望一个布尔值作为参数。虽然这两种方法都对调试有用,但它们的用途完全不同。

13.10 错误处理

此协议包含几个用于发送运行时信号的方法

doesNotUnderstand:

当消息查找失败时,会发送消息 doesNotUnderstand:(在讨论中通常缩写为 DNU 或 MNU)。默认实现,即 Object>>doesNotUnderstand:,将在此处触发调试器。重写 doesNotUnderstand: 以提供其他行为可能是有用的。

error

Object>>errorObject>>error:是可用于引发异常的通用方法。通常,更好的方法是引发您自己定义的异常,这样您就可以将代码中产生的错误与内核类产生的错误区分开来。

subclassResponsibility

抽象方法通过self subclassResponsibilyty来实现。如果一个抽象类被意外实例化,那么对抽象方法的调用将导致Object>>subclassResponsibility被执行。

Object >> subclassResponsibility
    "This message sets up a framework for the behavior of the class'
    subclasses.
    Announce that the subclass should have implemented this message."
    
    SubclassResponsibility signalFor: thisContext sender selector
Number new + 1
>>> !''Error: Number is an abstract class. Make a concrete
    subclass.''!

shouldNotImplement

self shouldNotImplement表示继承的方法不适合这个子类。这通常表明类层次结构设计得不太合理。然而,由于单继承的局限性,有时候很难避免这种变通方法。

一个典型的例子是Collection >> remove: 它被Dictionary继承,但是被标记为未实现。相反,Dictionary提供了removeKey:方法。

deprecated: and related

发送 self deprecated: 表示如果已启用弃用,则不应再使用当前方法。你可以使用 Settings browserDebugging 部分中打开/关闭它。参数应描述替代方案。查找消息 deprecated: 和其他相关消息的发送者以了解其用法。

13.11 测试

测试方法和SUnit没有任何关系!测试方法允许你询问有关接收者状态的问题,并返回一个布尔值。

Object 提供了许多测试方法,例如 isArrayisBooleanisBlockisCollection 等。通常应避免使用此类方法,因为查询对象的类是一种违反封装的形式。它们通常用作 isKindOf: 的替代方案,但它们在设计中表现出相同的限制。与其测试对象的类,不如简单地发送消息并让对象决定如何处理它。

然而,不可否认的是,其中一些测试方法非常有用。最有用的可能是 ProtoObject>>isNilObject>>notNilNull Object 设计模式 可以消除对这些方法的需求,但这通常是不可能的,也不是正确的选择。

13.12 初始化

最后一个不是出现在Object中,而是出现在ProtoObject中的关键方法是initialize

它之所以重要,是因为在Pharo中,系统为每一个类定义的new方法为给每一个新创建的实例发送initialize消息。

这意味着,只要覆盖hook方法initialize,类的新实例就会自动初始化。initialize方法通常应该执行一个super initialize来为任何继承来的实例变量进行初始化。

13.13 数字

Pharo中的数字不是原始的数值,而是真实的对象。当然,数字在虚拟机中是高效实现的,但Number的层次结构与其他的类层次一样是完全可访问和可扩展的。

这个层次结构的根是Magnitude,它表示支持比较操作符的所有类。Number添加了各种算术运算符和其他运算符作为抽象方法。FloatFraction分别表示浮点数和小数。Float的子类(BoxedFloat64SmallFloat64)代表特定架构上的Float。例如,BoxedFloat64只适用于64位系统。Integer也是抽象的,区分了子类SmallIntegerLargePositiveIntegerLargeNegativeInteger。在大多数情况下,用户不需要知道这三个整数类之间的区别,因为值会根据需要自动转换。

Object
  └─ Magnitude
      └─ Number
          ├─ Integer
          │   ├─ SmallInteger
          │   ├─ LargePositiveInteger
          │   └─ LargeNegativeInteger
          ├─ Fraction
          │   └─ ScaledDecimal
          └─ Float
              ├─ SmallFloat64
              └─ BoxedFloat64

13.14 Magnitude

Magnitude不仅是Number的父类,也是其他支持比较操作的类的父类,如Character,DurationTimespan

<=方法是抽象的,其它剩下的操作符是一般定义的,例如:

Magnitude >> < aMagnitude
    "Answer whether the receiver is less than the argument."
    
    ^ self subclassResponsibility
    
Magnitude >> > aMagnitude
    "Answer whether the receiver is greater than the argument."
    
    ^ aMagnitude < self

13.15 Number

类似地,Number+-*/定义为抽象的,但所有其他算术运算符都是一般定义的。

所有 Number 对象都支持各种转换操作符,例如 asFloatasInteger。还有许多生成 Duration 的快捷构造函数方法,例如 hourdayweek

Number直接支持常见的数字函数,如sin, log, raiseTo:, squared, sqrt等。

方法Number >> printOn:根据抽象方法 Number >> printOn:base:实现。(默认基数是10)

测试方法有even, odd, positivenegative。不出所料,Number覆盖了isNumber。更有趣的是,isInfinite被定义为返回false

截断方法包括:floor, ceiling, integerPart, fractionPart等。

1 + 2.5
>>> 3.5

3.4 * 5
>>> 17.0

8 / 2
>>> 4

10 - 8.3
>>> 1.7

12 = 11
>>> false

12 ~= 11
>>> true

12 > 9
>>> true

12 >= 10
>>> true

12 < 10
>>> false

100@10
>>> 100@10

以下例子在Pharo中非常有效:

1000 factorial / 999 factorial
>>> 1000

注意,实际上是计算了1000 factorial,在许多其他语言中,这可能很难计算。这是自动强制和精确处理数字的一个很好的例子。

13.16 浮点数

Float类实现了Number中关于浮点数的抽象方法。Float类(即Float的类侧)提供了返回以下常量的方法:einfinitynanpi

Float pi
>>> 3.141592653589793

Float infinity
>>> Float infinity

Float infinity isInfinite
>>> true

13.17 分数

Fractions由分子和分母的实例变量表示,它们应该是Integer。分数通常使用整数除法构造,而不是构造方法Fraction>>numerator:denominator:

6/8
>>> (3/4)

(6/8) class
>>> Fraction

一个分数乘以一个整数或另外一个分数可以得到一个整数:

6/8 * 4
>>> 3

13.18 整数

Integer是三个具体整数类实现的抽象父类。除了提供了许多Number抽象方法的具体实现外,它还添加了一些特定于整数的方法,比如factorial, atRandom, isPrime, gcd:和许多别的方法。

SmallInteger 的特殊之处在于其实例以紧凑的方式表示——SmallInteger 不是存储为引用,而是直接使用原本用于保存引用的位来表示。对象引用的第一位指示该对象是否为 SmallInteger。虚拟机为你抽象了这一点,因此在检查对象时你无法直接看到这一点。

类方法 minValmaxVal可以告诉我们SmallInteger的范围,注意它取决于image的架构,32位为(2 raisedTo: 30) - 1, 64位为(2 raisedTo: 60) - 1

SmallInteger maxVal = ((2 raisedTo: 30) - 1)
>>> true

SmallInteger minVal = (2 raisedTo: 30) negated
>>> true

SmallInteger超出该范围后,根据需要自动转换为LargePositiveIntegerLargeNegativeInteger:

(SmallInteger maxVal + 1) class
>>> LargePositiveInteger

(SmallInteger minVal - 1) class
>>> LargeNegativeInteger

在适当的时候,大整数也可以转换加SmallInteger

在大多数编程语言中,整数可以用于指定抚今迭代行为。有一个专门的方法timesRepeat:用于重复求值一个块。我们已经在“语法概略”一章中见过一个类似的例子。

| n |
n := 2.
3 timesRepeat: [ n := n * n ].
n
>>> 256

13.19 字符

CharacterMagnitude的子类。可打印的字符在Pharo中表示为$<char>。例如:

$a < $b
>>> true

不可打印的字符可以通过类方法生成。Character class>>value:接受一个Unicode(或ASCII)整数值作为参数,返回相应的字符。accessing untypeable character协议包含了很多方便的构造方法,例如backspace, cr, escape, euro, space, tab

Character space = (Character value: Character space asciiValue)
>>> true

printOn:方法非常聪明,它知道三种生成字符中的哪一种提供了最合适的表示:

Character value: 1
>>> Character home

Character value: 2
>>> Character value: 2

Character value: 32
>>> Character space

Character value: 97
>>> $a

内置了各种方便的测试方法:isAlphaNumeric, isCharacter, isDigit, isLowercase, isVowel等。

要将一个字符转换为仅包含该字符的字符串,可以给该字符发送asString消息。在这种情况下,asStringprintString会产生不同的结果:

$a asString
>>> 'a'

$a
>>> $a

$a printString
>>> '$a'

SmallInteger一样Character也是直接量,而不是对象引用。大多数时候你不会注意到这其中的区别,并且可以象其他对象一样使用Character类的对象。但这意味着,两个相同的字符总是同一相对象:

(Character value: 97) == $a
>>> true

13.20 字符串

String是一个已索引的只包含CharacterCollection

Object
  └─ Collection
       └─ SequenceableCollection
            └─ ArrayedCollection
                 ├─ Array
                 ├─ Text
                 └─ String
                      ├─ ByteString
                      └─ Symbol

事实上,String是抽象的,而Pharo字符串实际上是ByteString类的实例。

'hello world' class
>>> ByteString

String 的另一个重要子类是 Symbol。关键区别在于,对于给定的值,Symbol 只有一个实例。(这有时称为唯一实例属性)。相比之下,两个单独构造的 String,即使包含相同的字符序列,通常也是不同的对象。

'hel','lo' == 'hello'
>>> false

('hel','lo') asSymbol == #hello
>>> true

另一个重要的区别是,String是可变的,而Symbol是不可变的。请注意,改变字符串字面量不是一个好主意,因为它们可以在多个方法执行之间共享。[在Pharo10中,字符串已经不允许修改了]

'hello' at: 2 put: $u; yourself
>>> 'hullo'

#hello at: 2 put: $u
>>> Error: symbols can not be modified.

很容易忘记的一点是,字符串其实是Collection, 所以,它们也可以理解Collection能够理解的消息:

#hello indexOf: $o
>>> 5

虽然String不是Magnitude的子类,但是它也支持通常的比较方法,例如<, =等。此外,String>>match:对于一些基本的全局风格的模式匹配也很有用:

'*or*' match: 'zorro'
>>> true

字符串支持相当多的转换方法。其中许多是其他类的便捷构造方法,如asDate, asInteger等。还有许多将字符串转换为另一个字符串的有用的方法,例如capitalizedtranslateToLowercase

关于字符串和Collection的更多信息,参见章节:Collections

13.21 Booleans

Boolean 类提供了一个有趣的视角,展示了 Pharo 语言的多少部分已被推入类库中。Boolean 是单例类 TrueFalse 的抽象超类。

布尔值的大多数行为可以通过思考方法ifTrue:ifalse:来理解,该方法以两个block作为参数。

4 factorial > 20
  ifTrue: [ 'bigger' ]
  ifFalse: [ 'smaller' ]
>>> 'bigger'

方法ifTrue:ifFalseBoolean类中的抽象方法。具体子类中的实现都很简单:

它们根据消息的接收者执行正确的块。事实上,这就是OOP的本质:当消息被发送给一个对象时,由对象本身来决定将使用哪个方法进行响应。在这种情况下,True的实例只执行真分支,而False的实例只执行假分支。对于TrueFalse,所有的Boolean抽象方法都是这样实现的。例如, 逻辑非(消息 not)使用相同的方式来实现:

True >> not
    "Negation--answer false since the receiver is true."
    ^ false
    
False >> not
    "Negation--answer true since the receiver is false."
    ^ true

布尔值提供了一些有用的方便方法,比如ifTrue:ifalse:ifalse:ifTrue。逻辑运算有短路与非短路版本。

在下面的第一个例子中,两个布尔表达式都会被求值,因为&接受一个布尔参数。即使是(1 > 2)明显为假,仍然会对(3 < 4)进行求值。

( 1 > 2 ) & ( 3 < 4 )
>>> false  "Eager, must evaluate both sides"

在下面的第二和第三个示例中,先对接收者表达式(1 > 2)进行求值, 而不对作为参数的闭包进行求值。注意,消息and:需要一个闭包作为参数。在第三个例子中,表达式[ 1 / 0 ]不会被求值,也不会引发错误,因为and:只有在接收者为true的情况下才会对其参数求值。

( 1 > 2 ) and: [ 3 < 4 ]
>>> false

( 1 > 2 ) and: [ 1 / 0 ]
>>> false  "参数块肯定不会执行,所以没有引发异常"

or:也提供类似的行为。当接收者为false的情况下,才会执行参数块。

试想一下and:or:是如何实现的。检查Boolean, TrueFalse的实现。

13.22 本章总结

  • 如果你想重写=,那么你应该同时重写hash

  • 重写postCopy以实现正确的对象复制

  • 使用self halt设置一个断点

  • 返回self subclassResponsibility使用方法成为一个抽象方法

  • 定制对象的字符串表示需要重写printOn:

  • 重写initialize方法以正确地初始化实例

  • Number方法自动在Floats, FractionInteger之间转换

  • Fraction代表有理数由不是浮点数

  • 每一个Character都是唯一的实例

  • String是可变的,而Symbol不是。但是,注意不要修改字符串字面量!

  • Symbol是唯一的, String不是

  • StringSymbolCollection的子类,因此支持通常的Collection方法