点唱机里面只有一首歌不会太受欢迎(或许在有些酒吧可以),因此我们需要尽快考虑为歌曲做一个目录,以及为待放歌曲增加歌单。这些可以用容器解决,就是那些保存一个或多个对象引用的工具。
目录和歌单都需要类似的设置方法,比如添加歌曲,删除歌曲,返回歌曲列表等等。播放列表需要执行其他的一些功能,比如插入广告,或者显示累计的播放时间,但是我们稍后再考虑这些问题。与此同时,开发一些
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
接着我们还会添加 deleteFirst
和 deleteLast
方法,一般使用
Array#shift
和 Array#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
方法。还有两个是 each
和 collect
。each
应该是最简单的一个迭代器,它都是产出容器中的连续元素。
[ 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》,主要目的是自己学习使用,文中翻译不到位之处烦请指正,如需转载请注明出处