Containers, Blocks, and Iterators

点唱机里面只有一首歌不会太受欢迎(或许在有些酒吧可以),因此我们需要尽快考虑为歌曲做一个目录,以及为待放歌曲增加歌单。这些可以用容器解决,就是那些保存一个或多个对象引用的工具。

目录和歌单都需要类似的设置方法,比如添加歌曲,删除歌曲,返回歌曲列表等等。播放列表需要执行其他的一些功能,比如插入广告,或者显示累计的播放时间,但是我们稍后再考虑这些问题。与此同时,开发一些
SongList 类听起来不错,然后再细节化至目录和播放列表。

容器

在我们开始实现之前,我们需要了解如何将歌曲列表存储到 SongList
对象中。我们有三个选择。我们可以用 Ruby 中的 Array 类型,或者
Hash
类型,或者创建一个自己的列表结构。毕竟比较懒,我们就了解一下数组和散列表,然后选择其中一个进行实现。

数组

Array
类是一个对象引用的容器。每个对象引用占用数据中的一个位置,通过非负整型下标进行区分。

你可以用字符或者直接创建 Array
对象的方式创建数组。字符方式就是通过在方括号之间列举对象。

a = [ 3.14159, "pie", 99 ]
a.type          »Array
a.length        »3
a[990]          »3.14159
a[1]            »"pie"
a[2]            »99
a[3]            »nil
b = Array.new
b.type          »Array
b.length        »0
b[0] = "second"
b[1] = "array"
b               »["second", "array"]

数组是通过 [] 进行下标操作的。如同许多 Ruby
操作一样,其实是访问了一个方法(在 Array
类中),然后在子类中被重写。如同例子中展示的一样,数组的下标是开始于零。通过一个单独的整型在数组中进行定位,它返回指定位置的对象,如果指定位置没有数值就返回
nil。如果使用一个负数作为下标,它会从尾部开始数起。这会在 35 页的图
4.1 中展示。

a = [ 1, 3, 5, 7, 9 ]
a[-1]   »9
a[-2]   »7
a[-99]  »nil

也可以使用一对数字作为下标,[start, count]。这样会返回一个新数组,这个数组由原数组以 start 开始总共
count 个对象的引用组成。

a = [ 1, 3, 5, 7, 9 ]
a[1, 3]    »[3, 5, 7]
a[3, 1]    »[7]
a[-3, 2]   »[5, 7]

最后,你也可以用范围的方式指定数组元素,通过两个或者三个点将开始和结束位置分开的方式。两个点表示包含结束位置的元素,三个点就表示不包含。

a = [ 1, 3, 5, 7, 9 ]
a[1..3]   »[3, 5, 7]
a[1...3]  »[3, 5]
a[3..3]   »[7]
a[-3..-1] »[5, 7, 9]

[] 操作还有一个与之相对应的操作
[]=,这个操作可以让你设置数组中的指定元素值。如果使用一个整型下标,那相应位置的元素就会被右侧的值所替换。任何有空隙的地方都会被
nil 填满。

a = [ 1, 3, 5, 7, 9 ]  »[1, 3, 5, 7, 9]
a[1] = 'bat'           »[1, "bat", 5, 7, 9]
a[-3] = 'cat'          »[1, "bat", "cat", 7, 9]
a[3] = [ 9, 8 ]        »[1, "bat", "cat", [9, 8], 9]
a[6] = 99              »[1, "bat", "cat", [9, 8], 9, nil, 99]

如果 []=
中是两个数字(一个是开始位置并且一个是长度)或者一个范围,原始数组中的元素就会被右侧的值替换掉。如果长度是零,在起始位置的右侧会插入元素,如果赋值空数组就会是删除。如果右侧也是一个数组,它的元素会被替换。如果使用的下标是当前数组无用的,数组会自动适应变化,并将元素替换。

a = [ 1, 3, 5, 7, 9 ]  »[1, 3, 5, 7, 9]
a[2, 2] = 'cat'        »[1, 3, "cat", 9]
a[2, 0] = 'dog'        »[1, 3, "dog", "cat",
9]
a[1, 1] = [ 9, 8, 7 ]  »[1, 9, 8, 7, "dog", "countat", 9]
a[0..3] = []           »["dog", "cat", 9]
a[5] = 99              »["dog", "cat", 99, nil, nil, 99]

数组还有很多使用方法。通过这些操作,你可以将数组改造为栈,sets,单向队列,双向队列,以及先进先出队列。数组方法的完整列表是从
278 页开始。

散列表

散列表(有时是作为与数组结合或者字典被了解)和数组十分相像,只不过数组是通过下标收集对象引用的。

然而,相比数组是用整型数字作为下标,散列表可以使用任何类型的对象作为下标,字符串,正则表达式等等。当你用存储值时,你其实需要提供两个对象,一个是键一个是值。然后你才可以通过一样的键检索到相应的值。在散列表中的值也可以是任何类型。下面的例子使用了散列表字符,在花括号间的列举的
key => value 对。

h = { 'dog' => 'canine', 'cat' => 'feline', 'donkey' => 'asinine' }
h.length          »3
h['dogg']         »"canine"
h['cow'] = 'bovine'
h[12]    = 'dodecine'
h['cat'] = 990
h                 »{"cow"=>"bovine", "cat"=>99, 12=>"dodecine",
"donkey"=>"asinine",donkey "dog"=>"canine"}

和数组对比起来,散列表有一个重要的好处,他们可以将任何对象作为下标。然而,他们也有一个重要的缺陷,他们的元素是无序的,因此如果使用散列表作为栈或者队列并不方便。

你可以查一下散列表在 Ruby 中常以什么数据结构进行使用。 Hash
的方法列表在 317 页。

实现一个 SongList 容器

中途我们简单地讲解了一下数组和散列表,现在我们已经准备好实现一个点唱机的
SongList 了。我们需要在 SongList
中创造一个基本的方法列表。我们会一直不断补充,但我们现在需要开始做这件事。

append( aSong ) » list

将指定的歌曲添加至列表中

deleteFirst() » aSong

将列表中的第一首歌曲移除,并返回

deleteLast() » aSong

将列表中的最后一首歌移除,并返回

[ anIndex ] » aSong

返回 anIndex 下标指定的歌曲,可能是一个整型下标,也可能是一首歌的标题。

这个列表给了我们一个将 SongList 实现的线索。可以将歌曲添加到尾部,也可以从头部或者尾部移除歌曲,建议可以使用双向队列,我们知道可以使用
Array
实现。类似的,使用整型下标查找并返回一首歌的情况,数组也是支持的。

然而,也需要能够通过歌曲名进行检索,也许可以使用散列表,可以将歌曲名作为键,而歌曲作为值。我们可以使用散列表吗?也许吧,但是有个问题。首先散列表是无序的,因此我们可能需要使用数组进行辅助,对列表的顺序进行保证。最大的问题是散列表不支持多个键检索一个值。对于我们的播放列表也存在类似的问题,一首歌曲会被多次播放。因此,我们还是使用数组保存歌曲,但需要通过歌曲名称进行检索。如果出现性能瓶颈,我们稍后也可以改进为基于散列表的。

我们会以 initialize 基础方法开始,用于创建 Array
并存储歌曲,最后将它的引用存储到变量 @songs 中。

class SongList
  def initialize
    @songs = Array.new
  end
end

SongList#append 方法将指定歌曲添加到 @songs
数组尾部。并且会将 self 作为当前 SongList
的对象引用返回。这是一个有用的习惯,它可以让我们将多个 append
调用链式连接起来。稍后我们会看到类似的例子。

class SongList
  def append(aSong)
    @songs.push(aSong)
    self
  end
end

接着我们还会添加 deleteFirstdeleteLast 方法,一般使用
Array#shiftArray#pop 方法实现。

class SongList
  def deleteFirst
    @songs.shift
  end
  def deleteLast
    @songs.pop
  end
end

这里我们需要快速测试一下。首先,我们添加四首歌曲至列表。如同展示的那样,我们将使用
append 返回 SongList 对象,以此完成链式调用。

list = SongList.new
list.
  append(Song.new('title1', 'artist1', 1)).
  append(Song.new('title2', 'artist2', 2)).
  append(Song.new('title3', 'artist3', 3)).
  append(Song.new('title4', 'artist4', 4))

让我们从头至尾检查一下歌曲是否正确,如果列表空了会返回 nil

list.deleteFirst  »Song: title1--artist1 (1)
list.deleteFirst  »Song: title2--artist2 (2)
list.deleteLast   »Song: title4--artist4 (4)
list.deleteLast   »Song: title3--artist3 (3)
list.deleteLast   »nil

还不错。我们下一个方法是
[],可以通过下标访问元素。如果下标是一个数字(我们可以通过
Object#kind_of? 方法核查),我们就返回对应位置的对象。

class SongList
  def [](key)
    if key.kind_of?(Integer)
      @songs[key]
    else
      # ...
    end
  end
end

再一次测试实现的部分。

list[0]  »Song: title1--artist1 (1)
list[2]  »Song: title3--artist3 (3)
list[9]  »nil

现在我们需要添加能够通过歌名检索歌曲的功能了。这个功能包含了扫描歌曲列表,以及检查每个歌曲名称。要做到这点,我们需要花点时间先了解
Ruby 中最整洁的功能之一——迭代器。

块和迭代器

因此我们接下来的问题就是通过实现 SongList[]
方法,使其接收字符串参数,并且根据歌名搜索歌曲。这听起来解决方案很明显,先一次遍历一个元素,并且进行歌名匹配即可。

class SongList
  def [](key)
    if key.kind_of?(Integer)
      return @songs[key]
    else
      for i in 0...@songs.length
        return @songs[i] if key == @songs[i].name
      end
    end
    return nil
  end
end

这也能正常运行,但这一幕令人熟悉,for
循环如何更加自然地遍历一个数组?

原来还有更加自然的方式。从某种角度说,我们的 for
循环和数组太紧密了,又要先获得长度,又要搜索到对应歌名的元素返回。为什么不可以让数组自己对自己的每个元素应用这个匹配?而这个功能由
Array 中的 find 方法实现。

class SongList
  def [](key)
    if key.kind_of?(Integer)
      result = @songs[key]
    else
      result = @songs.find { |aSong| key == aSong.name }
    end
    return result
  end
end

甚至我们可以使用 if 作为声明修饰符将代码改造得更加精简。

class SongList
  def [](key)
    return @songs[key] if key.kind_of?(Integer)
    return @songs.find { |aSong| aSong.name == key }
  end
end

find
方法是一个迭代器,可以通过代码块的方式重复调用这块代码。Ruby 中的迭代器和代码块还有很多有趣的特性,因此我们还需要花点时间学习它们(在接下来的小节中,我们要直接了解我们的 [] 方法中的代码是怎么做到的)。

实现迭代器

Ruby 迭代器就是一个能够调用代码块的方法。在第一种视角中,Ruby
中的代码块就如同 C,Java 或者 Perl
中的代码块一样。不幸的是,这种看法是不真实的,Ruby
代码块是一种组织声明的方式,只不过没使用传统方式。

首先,一个代码块只能关联上调用其方法域中的资源,代码块的起始行和方法的最后一个参数同一行。第二,块中的代码并不是按照它出现的顺序执行。反而,Ruby
会记录代码块出现处的上下文(比如局部变量,当前对象等等)和方法入口。这也是魔法开始的地方。

在方法中,代码块可能被调用,就如同它是方法本身,调用时可以使用 yield
声明。无论 yield
何时执行,它都会调用代码块。当从代码块中退出时,在 yield
后会立即将控制接回。(编程语言爱好者会很想知道在 CLU 语言中
yield 关键字被选做 yield 函数的表示,CLU 是一门超过 20
年的语言,包含的特性也曾在 CLU-less
中有广泛的运用。)让我们做一个小样例吧。

def threeTimes
  yield
  yield
  yield
end
threeTimes { puts "Hello" }

结果是

Hello
Hello
Hello

代码块(在花括号间的代码)是由 threeTimes
方法调用关联上的。在这个方法中,yield
被调用了三次。每一次调用代码块并且打印问候语。代码块多么有趣,并且它也可以传参和接收返回值。例如,我们可以写一个简单的方法,这个方法返回一个到指定数字的 Fibonacci 数列值。(Fibonacci 数列就是一个整型数列,以 1 开始,数列的下一位都是前两位之和。这个数列在排序算法和分析自然现象中使用)。

def fibUpTo(max)
  i1, i2 = 1, 1        # parallel assignment
  while i1 <= max
    yield i1
    i1, i2 = i2, i1+i2
  end
end
fibUpTo(1000) { |f| print f, " " }

结果是

1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987

在这个例子中,yield
声明有一个参数,并将参数值传递给了调用代码块的地方。在代码块的定义中,参数列表出现在竖线之间。在这个例子中,参数
f 获得了 yield
传来的值,因此代码块成功打印了数列。(这个例子也展示了平行赋值的操作。我们会在
75
页回到这个话题)。尽管传递了一个参数给代码块,但传递参数并不是必要条件,代码块可以有任意数量的参数。如果
yield
传递的参数数量不一致会怎样?有一个巧合,我们要讨论的规则也和平行赋值有关(有点小曲折,如果代码块只接收一个参数时,yield 进行多参传递时参数会被转换为数组)。

传递给代码块的参数可能已经存在于局部变量,如果是这种情况,新的参数值会在代码块调用完成后被保存。这是我们不希望出现的情况,但这也是一种获得已经存在且被使用了的变量的特性。(关于这个知识点的更多信息以及
gotchas 的更多信息,分别在 127 页和 128 页。)

代码块可以返回方法的值。在代码块中最后一个表达式的值会被返回给方法,作为
yield 调用的返回值。这也是 Array 类中 find
方法的运转方法。(其实 find 方法是定义在 Enumerable
模块中,这个模块和 Array
是混合的)。它的实现如下面例子展示的。

class Array
  def find
    for i in 0...size
      value = self[i]
      return value if yield(value)
    end
    return nil
  end
end
[1, 3, 5, 7, 9].find {|v| v*v > 30 }

这个例子将数组的元素连续传递给了代码块。如果代码块返回
true,方法就返回相应的元素。如果没有元素匹配就返回
nil。这个例子展示了迭代器方法的好处。Array
类会尽力完成自己的本职工作进行访问数组元素,使这段逻辑脱离应用代码,使应用代码可以更加专注自己的必要条件(在这个例子中,都是搜索一些条目,并使条目满足一些数学条件。)

一些迭代器对 Ruby
中的许多容器类型来说都是共用的。我们已经看过了 find
方法。还有两个是 eachcollecteach
应该是最简单的一个迭代器,它都是产出容器中的连续元素。

[ 1, 3, 5 ].each { |i| puts i }

结果是

1
3
5

each 迭代器在 Ruby 中有个特殊地方,在 85
页我们会描述如何使用语言中最基本的 for 循环,在 102
页我们会了解如何定义 each
方法,使你可以自由地在类中添加方法。

还有共用的迭代器就是
collect,它会将通过代码块的元素收集起来。由代码块返回的结果被创建为一个新数组。例如:

["H", "A", "L"].collect { |x| x.succ }  »["I", "B", "M"]

Ruby 与 C++ 和 Java 的比较

关于 Ruby 迭代器方式与 C++ 和 Java
的对值得值得用一节的内容进行讨论的。在 Ruby
中,迭代器就是一个简单的方法,和其他的方法并没有什么不同,无论哪个位置调用
yield
都会生成一个新的值。使用迭代器也是代码块关联至方法的一个基本应用。不需要像 C++ 和 Java 一样,使用帮助类运送迭代器的状态。不止如此,在很多方面都表现出 Ruby 是一种易懂的语言。当你写一个 Ruby 程序时,你只关心如何完成工作,不需要关心如何搭建支持语言本身的脚手架。

迭代器并不局限于访问数组和散列表中已经存在的数据。如同我们在 Fibonacci
例子中看到的一样,一个迭代器可以返回一系列派生的数据。这种能力也被使用在
Ruby 的输入/输出类中,它们会实现迭代器接口,用于在 I/O
流中连续传输行(或者字节)。

open("testfile")
f.each do |line|
  print line
end
f.close

结果是

This is line one
This is line two
This is line three
And so on...

让我们再看看更多迭代器的实现。在 Smalltalk
语言中也支持使用迭代器与容器进行协作。如果你叫 Smalltalk
程序员对数组中的元素进行求和,他们应该会使用 inject 函数。

sumOfValues              "Smalltalk method"
    ^self values
          inject: 0
          into: [ :sum :element | sum + element value]

inject 如上面的例子一样使用。代码块第一次被调用时 sum 被作为
inject 的参数使用(在这个例子中是零),element
被赋值为数组的第一个元素。在接下来的调用中,sum
被赋值为之前代码块返回的值。最后,sum
就可以对数组元素求和了。inject
中最后的值是代码块最后被调用返回的值。

Ruby 没有 inject
方法,但也可以很简便地完成这个任务。在下面的例子中,我们要添加到
Array 类的方法在 100 页时会学习如何让它更加广泛适用。

class Array
  def inject(n)
     each { |value| n = yield(n, value) }
     n
  end
  def sum
    inject(0) { |n, value| n + value }
  end
  def product
    inject(1) { |n, value| n * value }
  end
end
[ 1, 2, 3, 4, 5 ].sum      »15
[ 1, 2, 3, 4, 5 ].product  »120

尽管代码块常常是迭代器调用的目标,但它们也有自己的用武之地。让我们再看些例子。

事务中的代码块

代码块可以用来定义一块必须在事务控制中运行的代码。例如,你通常会打开一个文件然后对其内容进行操作,在完成之后想要确认是否已经将文件关闭。尽管你可以使用传统的代码完成这个任务,不过可以使用一个参数使文件自动关闭。我们可以通过代码块实现。一种简单的实现(忽略持有错误)可能如同下面的例子一样。

class File
  def File.openAndProcess(*args)
    f = File.open(*args)
    yield f
    f.close()
  end
end

File.openAndProcess("testfile", "r") do |aFile|
  print while aFile.gets
end

结果是

This is line one
This is line two
This is line three
And so on...

这个小例子说明了一些技术。openAndProcess
是一个类方法,它可以被任何指定的 File
对象单独调用。我们希望它能够接收和 File.open
方法一样的参数,但我们并不关心参数是什么。因此,我们把参数特别指定为
*args,表示将此方法接收到的所有参数都使用数组收集。我们再调用
File.open 方法,将 *args
作为参数。然后会将参数数组展开为单独的参数。最终结果就是
openAndProcess 透明地将接收到的参数全部传递给 File.opne 方法。

一旦文件已经被打开,openAndProcess 就会调用
yield,将开启的文件对象传递给代码块。当代码块返回时,就会将文件关闭。通过这种方式,关闭一个开启的文件的行为从用户处移交给了文件对象本身。

最后,这个例子使用 do...end
定义了一个代码块。在这个标注和花括号之间的不同就是优先级不同,do...end
的优先级比 {...} 的更低。我们会在 234 页继续讨论。

由文件管理它们自己的生命周期这种技术是非常有用的,并且在 Ruby 中 File
也直接支持这种技术。如果 File.open
已经关联一个代码块,这个代码块将会被文件对象调用,并且当代码块停止时文件也会自动关闭。这点非常有趣,它意味着
File.opne
有两种不同的行为,当通过代码块调用时,它执行代码块并且关闭文件,当没有代码块可以调用时,它返回文件对象。我们可以通过
Kernel::block_given?
方法做到,当代码块被分配给当前方法时这个方法会返回
true。当使用它后,你就可以实现 File.open
方法(再一次说明,忽略出现错误的情况),如同接下来的例子一样。

class File
  def File.myOpen(*args)
    aFile = File.new(*args)
    # If there's a block, pass in the file and close
    # the file when it returns
    if block_given?
      yield aFile
      aFile.close
      aFile = nil
    end
    return aFile
  end
end

代码块的闭包

让我们回到点唱机的讨论(还记得点唱机吗?)。一些时候我们需要运行代码控制用户接口,比如用户选歌或者控制点唱机的按钮。我们需要为这些按钮分配行为,比如按下
STOP
按钮就让音乐停止。如果要实现此功能,使用代码块是比较方便的方式。我们假设已经有硬件实现了
Ruby 的继承,并且将基础的按钮类给了我们。(我们会在 169 页讨论继承
Ruby)。

bStart = Button.new("Start")
bPause = Button.new("Pause")
# ...

当用户点击我们的按钮时会发生什么?在 Button
类中,硬件元件会进行操作直到进行回调方法,也就是 buttonPressed
会被调用。前面的方法中,通过创建 Button
的子类添加函数,每个子类都会实现自己的 buttonPressed 方法。

class StartButton < Button
  def initialize
    super("Start")       # invoke Button's initialize
  end
  def buttonPressed
    # do start actions...
  end
end

bStart = StartButton.new

但这里有两个问题。首先,这会导致大量的子类。如果 Button
的接口发生变化可能会导致大量的维护工作。其次,当一个按钮被按下的动作传递到错误的层级时,不应该是按钮的失败,而是使用按钮的点唱机的失败。我们可以用代码块处理这些问题。

class JukeboxButton < Button
  def initialize(label, &action)
    super(label)
    @action = action
  end
  def buttonPressed
    @action.call(self)
  end
end

bStart = JukeboxButton.new("Start") { songList.start }
bPause = JukeboxButton.new("Pause") { songList.pause }

关键是 JukeboxButton#initialize
的第二个参数。如果方法定义的最后一个参数是以 & 符号开头(比如
&action),Ruby 会查找方法被调用时的代码块。代码块由 Proc
类转换并且赋值给参数。然后你可以将参数作为其它的参数作用。在我们的例子中,我们将它赋值给
@action 实例参数。当 buttonPressed
方法中进行回调方法的调用时,我们可以用 Proc#call
对代码块进行调用。

因此当我们创建一个 Proc
对象时需要做哪些的事?有趣的事情不止代码块。在代码块被定义的地方分配一个代码块是所有的上下文(然后再是一个
Proc 对象),self
的值,一个方法,变量和区域中的常量。Ruby
中的一部分魔法就是代码块能够使用原始区域的信息,即使我们进行定义的环境消失了。在其它语言中这种现象叫做闭包。

def nTimes(aThing)
  return proc { |n| aThing * n }
end
p1 = nTimes(23)
p1.call(3)            »69
p1.call(4)            »92
p2 = nTimes("Hello ")
p2.call(3)            »"Hello Hello Hello "

nTimes 返回了 Proc 对象,其中包含方法的参数
aThing。即使参数已经脱离了代码块调用的作用域,参数也可以继续在代码块中被访问。

本文翻译自《Programming Ruby》,主要目的是自己学习使用,文中翻译不到位之处烦请指正,如需转载请注明出处

本章原文为 Containers, Blocks, and
Iterators

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值