Pharo by Example - 第十五章 流(Stream)

标签: pharo ; smalltalk ;


是用于迭代元素的序列,如序列集合、文件和网络流。流可以是可读的、可写的,或两者兼而有之。读或写总是相对于流中的当前位置。流可以很容易地转换为集合,反之亦然。

15.1 两个元素序列

可以将流比喻为两个元素序列:过去的元素序列和未来的元素序列。流位于两个序列之间。理解这个模型很重要,因为Pharo中所有的流操作都依赖于它。由于这个原因,大多数流都是PositionableStream的子类。图15-1以一个5个字符的流为例。这个流位于它的初始位置,即,“过去的元素”为空。你可以使用PositionableStream中定义的reset消息返回到该位置。

从概念上讲,读取元素意味着删除“未来元素序列”的第一个元素,并将其放在“过去的元素序列”的最后一个元素之后。在使用next消息读取一个元素之后,流的状态如图15-2所示。

写入一个元素意味着用新元素替换“未来的序列”的第一个元素,并将其移到过去的位置。图15-3显示了同一个流在使用定义在Stream中的nextPut: anElement消息写入后的状态。

15.2 Streams vs. 集合s

集合协议支持集合元素的存储、删除和枚举,但不允许这些操作混合使用。例如,如果 Ordered集合 的元素由 do: 方法处理,则无法从 do: 块内部添加或删除元素。集合协议也不提供同时迭代两个集合的方法,选择哪个集合前进,哪个不前进。像这样的过程需要在集合本身之外维护遍历索引或位置引用:这正是 ReadStreamWriteStreamReadWriteStream 的作用。

这三个类被定义为某个集合之上的流,例如,下面的代码片段在一个Interval上创建流,然后读取两个元素。

| r |
r := ReadStream on: (1 to: 1000).
r next.
>>> 1

r next.
>>> 2

r atEnd.
>>> false

WriteStream可以将数据写入集合

| w |
w := WriteStream on: (String new: 5).
w nextPut: $a.
w nextPut: $b.
w contents.
>>> 'ab'

还可以创建同时支持读写协议的ReadWriteStream.

下面几节将更深入地介绍这些协议。

流在处理集合元素时非常有用,可以用来读写这些元素.现在我们将探讨流的集合特性。

15.3 读取集合

使用流读取集合实际上为你提供了一个指向集合的指针。指针将在读取时向前移动,你可以将它放在你想要的任何地方。ReadStream类应该用于从集合中读取元素。

定义在ReadStream中的消息nextnext:用于从集合中检索一个或多个元素。

| stream |
stream := ReadStream on: #(1 (a b c) false).
stream next.
>>> 1

stream next.
>>> #(#a #b #c)

stream next.
>>> false
| stream |
stream := ReadStream on: 'abcdef'.
stream next: 0.
>>> ''

stream next: 1.
>>> 'a'

stream next: 3.
>>> 'bcd'

stream next: 2.
>>> 'ef'

15.4 Peek

当你需要知道一个流里面的下一个元素是什么,而不继续前进的时候,使用PositionableStream中定义的消息peek

| stream negative number |
stream := ReadStream on: '-143'.
"look at the first element without consuming it."
negative := (stream peek = $-).
negative.
>>> true

"ignores the minus character"
negative ifTrue: [ stream next ].
number := stream upToEnd.
number.
>>> '143'

这段代码根据流中数字的符号将布尔变量设置为负值,并将number设置为其绝对值。ReadStream中定义的消息upToEnd返回从当前位置到流的结束之间的所有内容,并将流指针移动到末尾。这段代码可以使用PositionableStream中定义的消息peekFor:进行简化,如果下一个元素等于它的参数则向前移动,否则就不移动。

| stream |
stream := '-143' readStream.
(stream peekFor: $-).
>>> true

stream upToEnd
>>> '143'

peekFor:返回一个布尔值,指示下一个元素是否等于其参数。

你可能已经注意到,在上面的示例中构造流的一种新方法:可以简单地将消息readStream发送到序列化集合(例如字符串)。可以从特定的序列上获得一个流。

15.5 定位到索引

有专门用于定位流指针的消息。如果你知道具体的索引,你可以直接使用PositionableStream当中定义的position:。可以使用position请求当前位置。请记住,流不是位于元素上,而是位于两个元素之间。流的开头的索引为0.

你可以通过下面的代码获得15-4中描述的流的状态:

| stream |
stream := 'abcde' readStream.
stream position: 2.
stream peek
>>> $c

要将流指针定位到开头或结尾,可以使用消息resetsetToEnd

15.6 跳过元素

消息skip:skipTo:用于向前移动到相对于当前位置的位置,skip:接受一个数字作为参数,并跳过指定数量的元素,而skipTo:则一直跳到与其参数相等的元素。注意,它将流指针定位在匹配的元素之后。

| stream |
stream := 'abcdef' readStream.
stream next
>>> $a

现在,流指针在$a后面。

stream skip: 3.
stream position
>>> 4

现在,流指针在$d后面。

stream skip: -2.
stream position
>>> 2

现在,流指针在$b后面。

stream reset.
stream position
>>> 0

stream skipTo: $e.
stream next.
>>> $f
stream contents.
>>> 'abcdef'

消息contents总是返回整个流的副本。

15.7 谓词

有些消息允许你测试当前流的状态:atEnd当且仅当不能读取更多元素时返回true,而isEmpty当且仅当集合中根本没有元素时返回true

下面是一个使用atEnd的归并算法实现,它接受两个有序集合作为参数,并将它们合并到成一个有序集合:

| stream1 stream2 result |
stream1 := #(1 4 9 11 12 13) readStream.
stream2 := #(1 2 3 4 5 10 13 14 15) readStream.

"The variable result will contain the sorted 集合."
result := OrderedCollection new.
[ stream1 atEnd not & stream2 atEnd not ]
  whileTrue: [
    stream1 peek < stream2 peek
      "Remove the smallest element from either stream and add it to the result."
      ifTrue: [ result add: stream1 next ]
      ifFalse: [ result add: stream2 next ]].
"One of the two streams might not be at its end. Copy whatever remains."
result
  addAll: stream1 upToEnd;
  addAll: stream2 upToEnd.
result.
>>> an OrderedCollection(1 1 2 3 4 4 5 9 10 11 12 13 13 14 15)

15.8 写入集合

我们已经看到了如何使用readStream来读取集合元素。现在,我们将学习如何使用WriteStream创建集合。

WriteStream对于在不同位置向集合追加大量数据非常有用。它们通常被用来构造基于静态和动态部分的字符串,如下所示:

| stream |
stream := String new writeStream.
steam
  nextPutAll: 'This image contains: ';
  print: Smalltalk globals allClasses size;
  nextPutAll: ' classes.';
  cr;
  nextPutAll: 'This is really a lot.'.

stream contents.
>>> 'This image contains: 9003 classes.
This is really a lot.'

例如,在方法printOn:的不同实现中使用了这个技术。如果你只对流的内容感兴趣,有一种更简单、更有效的方法创建字符串:

| string |
string := String streamContents:
    [ :stream |
        stream
          print: #(1 2 3);
          space;
          nextPutAll: 'size';
          space;
          nextPut: $=;
          space;
          print: 3. ].
string.
>>> '#(1 2 3) size = 3'

SequenceableCollection上定义的消息streamContents为你创建了一个集合,以及集合上的流。然后,它以流为参数执行你传递给它的block。当流终止,streamContents将它的内容作为一个集合返回。

下面的WriteStream方法在这里很有用:

  • nextPut: 将其参数添加到流中;
  • nextPutAll: 将作为参数的集合中的每一个元素都添加到流中;
  • print: 将文本表示的参数添加到流中。

还有一些方便的消息用于将有用的字符打印到流中,例如space,tab以及cr(回车)。另一个有用的方法是ensureASpace确保流中的最后一个字符是空格字符;如果最后一个字符不是空格,则添加一个空格。

15.9 关于字符串连接

WriteStream上使用nextPut:nextPutAll:通常是连接字符的最佳方式。下面的两个示例显示了在执行相同任务时,使用逗号操作符连接字符串的效率远不如使用流的效率。

[ | temp |
    temp := String new.
    (1 to: 100000)
      do: [ :i | temp := temp, i asString, ' ']] timeToRun
>>> 0:00:01:54.758

[ | temp |
    temp := WriteStream on String new.
    (1 to: 100000)
      do: [ :i | temp nextPutAll: i asString; space ].
    temp contents ] timeToRun
>>> 0:00:00:00.024

使用流可能比使用逗号更有效,因为最后一个创建的新字符串包含接收者和参数的连接,因此必须复制它们。当你重复连接到同一个接收者上时,它会变得越来越长,因此必须复制的字符数量会呈指数级增长。这样的字符串连接也会产生大量的垃圾,必须收集这些垃圾。使用流而非,是一种众所周知的优化。

事实上,你可以使用定义在SequenceableCollection类中的消息streamContents:来帮助你做到这一点:

String streamContents: [ :tempStream |
  (1 to: 100000)
    do: [ :i | tempStream nextPutAll: i asString; space ]]

15.10 关于printString

让我们花点时间回顾一下printOn:方法中的流用法。基本上,Object>>#printString方法创建了一个流,并将该流作为printOn:方法的参数,如下所示:

Object >> printString
  "Answer a String whose characters are a description of the receiver.
   If you want to print without a character limit, use fullPrintString."
   
  ^ self printStringLimitedTo: 50000
  
Object >> printStringLimitedTo: limit
  "Answer a String whose characters are a description of the receiver.
   If you want to print without a character limit, use fullPrintString."
   
  ^ self printStringLimitedTo: limit using: [:s | self printOn: s]
  
Object >> printStringLimitedTo: limit using: printBlock
  "Answer a String whose characters are a description of the receiver
   produced by given printBlock. It ensures the result will be not
   bigger than given limit"
   
  | limitedString |
  limitedString := String streamContents: printBlock limitedTo: limit.
  limitedString size < limit ifTrue: [^ limitedString].
  ^ limitedString , '...etc...'

你应该看到printStringLimitedTo:using:方法创建了一个流并将其传递。

当你在类中重新定义方法printOn:时,如果你在对象的实例变量上发送消息printString,实际上你是在创建另一个流并在第一个流中复制其内容。下面是一个例子:

MessageTally >> displayStringOn: aStream
  self displayIdentifierOn: aStream.
  aStream
    nextPutAll: ' (';
    nextPutAll: self tally printString;
    nextPutAll: ')'

这里的表达式self tally printString调用了相同的机制,并创建了一个额外的流,而不是使用前一个流。这显然是适得其反。最好是将消息print:发送给流或者printOn:发送给实例变量,如下所示:

MessageTally >> displayStringOn: aStream
  self displayIdentifierOn: aStream
  aStream
    nextPutAll: ' (';
    print: self tally;
    nextPutAll: ')'

在这个版本中,使用第一个创建的流,没有创建额外的流。

要理解print:方法,这里是它的定义:

Stream >> print: anObject
  "Have anObject print itself on the receiver."
  
  anObject printOn: self

另外的例子

额外的流创建并不局限于printString逻辑。这里有一个来自Pharo的例子,它表现出了完全相同的问题。

printProtocol: protocol sourceCode: sourceCode

  ^ String streamContents: [ :stream |
    stream
      nextPutAll: '"protocol: ';
      nextPutAll: protocol printString;
      nextPut: $"; cr; cr;
      nextPutAll: sourceCode ]

你应该看到它创建了一个流,然后又创建了另外一个流,在使用表达式protocol printString 调用之后又丢弃了它。更好的实现是:

printProtocol: protocol sourceCode: sourceCode

  ^ String streamContents: [ :stream |
    stream
      nextPutAll: '"protocol: ';
      print: protocol;
      nextPut: $"; cr; cr;
      nextPutAll: sourceCode ]

15.11 同时进行读写

可以使用流访问一个集合并同时进行读写。假设你想创建一个History类来管理Web浏览器中的后退和前进按钮。历史记录的响应如图15-5 - 15-11 所示。

这种行为可以使用ReadWriteStream流来实现。

Object subclass: #History
  instanceVariableNames: 'stream'
  classVariableNames: ''
  package: 'PBE-Streams'
  
History >> initialize
  super initialize.
  stream := ReadWriteStream on: Array new

这里没有什么真正的困难,我们定义了一个包含流的新类。在执行initialize方法时创建流。

我们需要后退和前进的方法:

History >> goBackward
  self canGoBackward
    ifFalse: [ self error: 'Already on the first element' ].
  stream skip: -2.
  ^ stream next.
  
History >> goForward
  self canGoForward
    ifFalse: [ self error: 'Already on the last element' ].
  ^ stream next

到目前为止,代码非常简单。接下来,我们必须处理goTo:方法,该方法应该在用户单击链接时被激活。一种可能的实现是:

History >> goTo: aPage
  stream nextPut: aPage

然而,这个版本是不完整的。这是因为当用户点击链接时,不应该有更多的可以前进的页面,也就是说,前进按钮必须停用。要做到这一点,最简单的解决方案是在后面写入nil,表示历史记录已经处于末尾:

History >> goTo: anObject
  stream nextPut: anObject.
  stream nextPut: nil.
  stream back

现在,只有canGoBackwardcanForward方法还没有实现。

流指针初始位于两个元素之间。要向后移动,在当前位置之前必须有两个页面:一个页面是当前页,另一个是我们想要转到的页面。

History >> canGoBackward
  ^ stream position > 1
  
History >> canGoForward
  ^ stream atEnd not and: [ stream peek notNil ]

让我们添加一个方法来查看流的内容:

History >> contents
  ^ stream contents

接下来,History就可以如同宣称的那样工作:

History new
  goTo: #page1;
  goTo: #page2;
  goTo: #page3;
  goBackward;
  goBackward;
  goTo: #page4;
  contents
>>> #(#page1 #page4 nil nil)

15.12 本章总结

与集合相比,流提供了一种更好的方式来增量地读写一系列元素。有一些简单的方法可以在流和集合之间来回转换。

  • 流可以是可读的、可写的、可以同时读写的。

  • 要将一个集合转换为流,需要在集合上定义一个流,例如:ReadStream on: (1 to: 1000),或者将消息readStream发送给集合对象。

  • 要将流转换为集合, 请向它发送消息contents

  • 要连接大型的集合, 与其用逗号操作符,不如更有效地创建流,使用nextPutAll:将集合附加到流,并通过contents提取结果。