4.3 其他数据类型

虽然 Web 应用最终都是处理字符串,但也需要其他的数据类型来生成字符串。本节介绍一些对开发 Rails 应用很重要的其他 Ruby 数据类型。

4.3.1 数组和值域

数组是一组具有特定顺序的元素。前面还没用过数组,不过理解数组对理解哈希有很大帮助(4.3.3 节),也有助于理解 Rails 中的数据模型(例如 2.3.3 节用到的 has_many 关联,11.1.3 节会做详细介绍)。

目前,我们已经花了很多时间理解字符串,从字符串过渡到数组可以从 split 方法开始:

>>  "foo bar     baz".split     # 把字符串拆分成有三个元素的数组
=> ["foo", "bar", "baz"]

上述操作得到的结果是一个有三个字符串的数组。默认情况下,split 在空格处把字符串拆分成数组,不过也可以在几乎任何地方拆分:

>> "fooxbarxbazx".split('x')
=> ["foo", "bar", "baz"]

和大多数编程语言的习惯一样,Ruby 数组的索引也从零开始,因此数组中第一个元素的索引是 0,第二个元素的索引是 1,依此类推:

>> a = [42, 8, 17]
=> [42, 8, 17]
>> a[0]               # Ruby 使用方括号获取数组元素
=> 42
>> a[1]
=> 8
>> a[2]
=> 17
>> a[-1]              # 索引还可以是负数
=> 17

我们看到,Ruby 使用方括号获取数组中的元素。除了方括号之外,Ruby 还为一些经常需要获取的元素提供了别名:[8]

>> a                  # 只是为了看一下 a 的值是什么
=> [42, 8, 17]
>> a.first
=> 42
>> a.second
=> 8
>> a.last
=> 17
>> a.last == a[-1]    # 用 == 符号对比
=> true

最后一行用到了相等比较操作符 ==,Ruby 和其他语言一样还提供了 !=(不等)等其他操作符:

>> x = a.length       # 和字符串一样,数组也可以响应 length 方法
=> 3
>> x == 3
=> true
>> x == 1
=> false
>> x != 1
=> true
>> x >= 1
=> true
>> x < 1
=> false

除了 length(上述代码的第一行)之外,数组还可以响应一系列其他方法:

>> a
=> [42, 8, 17]
>> a.empty?
=> false
>> a.include?(42)
=> true
>> a.sort
=> [8, 17, 42]
>> a.reverse
=> [17, 8, 42]
>> a.shuffle
=> [17, 42, 8]
>> a
=> [42, 8, 17]

注意,上面的方法都没有修改 a 的值。如果想修改数组的值,要使用相应的“炸弹”(bang)方法(之所以这么叫是因为,这里的感叹号经常都读作“bang”):

>> a
=> [42, 8, 17]
>> a.sort!
=> [8, 17, 42]
>> a
=> [8, 17, 42]

还可以使用 push 方法向数组中添加元素,或者使用等价的 &lt;&lt; 操作符:

>> a.push(6)                  # 把 6 加到数组结尾
=> [42, 8, 17, 6]
>> a << 7                     # 把 7 加到数组结尾
=> [42, 8, 17, 6, 7]
>> a << "foo" << "bar"        # 串联操作
=> [42, 8, 17, 6, 7, "foo", "bar"]

最后一个命令说明,可以把添加操作串在一起使用;也说明,Ruby 不像很多其他语言,数组中可以包含不同类型的数据(本例中包含整数和字符串)。

前面用 split 把字符串拆分成数组,我们还可以使用 join 方法进行相反的操作:

>> a
=> [42, 8, 17, 7, "foo", "bar"]
>> a.join                       # 没有连接符
=> "428177foobar"
>> a.join(', ')                 # 连接符是一个逗号和空格
=> "42, 8, 17, 7, foo, bar"

和数组有点类似的是值域(range),使用 to_a 方法把它转换成数组或许更好理解:

>> 0..9
=> 0..9
>> 0..9.to_a              # 错了,to_a 在 9 上调用了
NoMethodError: undefined method `to_a' for 9:Fixnum
>> (0..9).to_a            # 调用 to_a 要用括号包住值域
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

虽然 0..9 是有效的值域,不过上面第二个表达式告诉我们,调用方法时要加上括号。

值域经常用来获取数组中的一组元素:

>> a = %w[foo bar baz quux]         # %w 创建一个元素为字符串的数组
=> ["foo", "bar", "baz", "quux"]
>> a[0..2]
=> ["foo", "bar", "baz"]

有个特别有用的技巧:值域的结束值使用 -1 时,不用知道数组的长度就能从起始值开始一直获取到最后一个元素:

>> a = (0..9).to_a
=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>> a[2..(a.length-1)]               # 显式使用数组的长度
=> [2, 3, 4, 5, 6, 7, 8, 9]
>> a[2..-1]                         # 小技巧,索引使用 -1
=> [2, 3, 4, 5, 6, 7, 8, 9]

值域也可以使用字母:

>> ('a'..'e').to_a
=> ["a", "b", "c", "d", "e"]

4.3.2 块

数组和值域可以响应的方法中有很多都可以跟着一个块(block),这是 Ruby 最强大也是最难理解的功能:

>> (1..5).each { |i| puts 2 * i }
2
4
6
8
10
=> 1..5

这段代码在值域 (1..5) 上调用 each 方法,然后又把 { |i| puts 2 * i } 这个块传给 each 方法。|i| 两边的竖线在 Ruby 中用来定义块变量。只有这个方法才知道如何处理后面跟着的块。本例中,值域的 each 方法会处理后面的块,块中有一个本地变量 ieach 会把值域中的各个值传进块中,然后执行其中的代码。

花括号是表示块的一种方式,除此之外还有另一种方式:

>> (1..5).each do |i|
?>   puts 2 * i
>> end
2
4
6
8
10
=> 1..5

块中的内容可以多于一行,而且经常多于一行。本书遵照一个常用的约定,当块只有一行简单的代码时使用花括号形式;当块是一行很长的代码,或者有多行时使用 do..end 形式:

>> (1..5).each do |number|
?>   puts 2 * number
>>   puts '-'
>> end
2
-
4
-
6
-
8
-
10
-
=> 1..5

上面的代码用 number 代替了 i,我想告诉你的是,变量名可以使用任何值。

除非你已经有了一些编程知识,否则对块的理解是没有捷径的。你要做的是多看,看多了就会习惯这种用法。[9]幸好人类擅长从实例中归纳出一般性。下面是一些例子,其中几个用到了 map 方法:

>> 3.times { puts "Betelgeuse!" }   # 3.times 后跟的块没有变量
"Betelgeuse!"
"Betelgeuse!"
"Betelgeuse!"
=> 3
>> (1..5).map { |i| i**2 }          # ** 表示幂运算
=> [1, 4, 9, 16, 25]
>> %w[a b c]                        # 再说一下,%w 用来创建元素为字符串的数组
=> ["a", "b", "c"]
>> %w[a b c].map { |char| char.upcase }
=> ["A", "B", "C"]
>> %w[A B C].map { |char| char.downcase }
=> ["a", "b", "c"]

可以看出,map 方法返回的是在数组或值域中每个元素上执行块中代码后得到的结果。在最后两个命令中,map 后面的块在块变量上调用一个方法,这种操作经常使用简写形式:

>> %w[A B C].map { |char| char.downcase }
=> ["a", "b", "c"]
>> %w[A B C].map(&:downcase)
=> ["a", "b", "c"]

(简写形式看起来有点儿奇怪,其中用到了符号,4.3.3 节会介绍。)这种写法比较有趣,一开始是由 Rails 扩展实现的,但人们太喜欢了,现在已经集成到 Ruby 核心代码中。

最后再看一个使用块的例子。我们看一下代码清单 4.4 中的一个测试用例:

test "should get home" do
  get :home
  assert_response :success
  assert_select "title", "Ruby on Rails Tutorial Sample App"
end

现在不需要理解细节(其实我也不懂),从 do 关键字可以看出,测试的主体其实就是个块。test 方法的参数是一个字符串(测试的描述)和一个块,运行测试组件时会执行块中的内容。

现在我们来分析一下我在 1.5.4 节生成随机二级域名时使用的那行 Ruby 代码:

('a'..'z').to_a.shuffle[0..7].join

我们一步步分解:

>> ('a'..'z').to_a                     # 由全部英文字母组成的数组
=> ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o",
"p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]
>> ('a'..'z').to_a.shuffle             # 打乱数组
=> ["c", "g", "l", "k", "h", "z", "s", "i", "n", "d", "y", "u", "t", "j", "q",
"b", "r", "o", "f", "e", "w", "v", "m", "a", "x", "p"]
>> ('a'..'z').to_a.shuffle[0..7]       # 取出前 8 个元素
=> ["f", "w", "i", "a", "h", "p", "c", "x"]
>> ('a'..'z').to_a.shuffle[0..7].join  # 把取出的元素合并成字符串
=> "mznpybuj"

4.3.3 哈希和符号

哈希(Hash)本质上就是数组,只不过它的索引不局限于只能使用数字。(实际上在一些语言中,特别是 Perl,因为这个原因把哈希叫做“关联数组”。)哈希的索引(或者叫“键”)几乎可以使用任何对象。例如,可以使用字符串当键:

>> user = {}                          # {} 是一个空哈希
=> {}
>> user["first_name"] = "Michael"     # 键为 "first_name",值为 "Michael"
=> "Michael"
>> user["last_name"] = "Hartl"        # 键为 "last_name",值为 "Hartl"
=> "Hartl"
>> user["first_name"]                 # 获取元素的方式和数组类似
=> "Michael"
>> user                               # 哈希的字面量形式
=> {"last_name"=>"Hartl", "first_name"=>"Michael"}

哈希通过一对花括号中包含一些键值对的形式表示,如果只有一对花括号而没有键值对({})就是一个空哈希。注意,哈希中的花括号和块中的花括号不是一个概念。(是的,这可能会让你困惑。)哈希虽然和数组类似,但二者却有一个很重要的区别:哈希中的元素没有特定的顺序。[10]如果顺序很重要的话就要使用数组。

通过方括号的形式每次定义一个元素的方式不太敏捷,使用 分隔的键值对这种字面量形式定义哈希要简洁得多:

>> user = { "first_name" => "Michael", "last_name" => "Hartl" }
=> {"last_name"=>"Hartl", "first_name"=>"Michael"}

在上面的代码中我用到了一个 Ruby 句法约定,在左花括号后面和右花括号前面加入了一个空格,不过控制台会忽略这些空格。(不要问我为什么这些空格是约定俗成的,或许是某个 Ruby 编程大牛喜欢这种形式,然后约定就产生了。)

目前为止哈希的键都使用字符串,在 Rails 中用“符号”(Symbol)当键很常见。符号看起来有点儿像字符串,只不过没有包含在一对引号中,而是在前面加一个冒号。例如,:name 就是一个符号。你可以把符号看成没有约束的字符串:[11]

>> "name".split('')
=> ["n", "a", "m", "e"]
>> :name.split('')
NoMethodError: undefined method `split' for :name:Symbol
>> "foobar".reverse
=> "raboof"
>> :foobar.reverse
NoMethodError: undefined method `reverse' for :foobar:Symbol

符号是 Ruby 特有的数据类型,其他语言很少用到。初看起来感觉很奇怪,不过 Rails 经常用到,所以你很快就会习惯。符号和字符串不同,并不是所有字符都能在符号中使用:

>> :foo-bar
NameError: undefined local variable or method `bar' for main:Object
>> :2foo
SyntaxError

只要以字母开头,其后都使用单词中常用的字符就没事。

用符号当键,我们可以按照如下的方式定义一个 user 哈希:

>> user = { :name => "Michael Hartl", :email => "[email protected]" }
=> {:name=>"Michael Hartl", :email=>"[email protected]"}
>> user[:name]              # 获取 :name 对应的值
=> "Michael Hartl"
>> user[:password]          # 获取未定义的键对应的值
=> nil

从上面的例子可以看出,哈希中没有定义的键对应的值是 nil

因为符号当键的情况太普遍了,Ruby 1.9 干脆就为这种用法定义了一种新句法:

>> h1 = { :name => "Michael Hartl", :email => "[email protected]" }
=> {:name=>"Michael Hartl", :email=>"[email protected]"}
>> h2 = { name: "Michael Hartl", email: "[email protected]" }
=> {:name=>"Michael Hartl", :email=>"[email protected]"}
>> h1 == h2
=> true

第二中句法把“符号 ⇒”变成了“键的名字:”形式:

{ name: "Michael Hartl", email: "[email protected]" }

这种形式更好地沿袭了其他语言(例如 JavaScript)中哈希的表示方式,在 Rails 社区中也越来越受欢迎。这两种方式现在都在使用,所以你要能识别它们。可是,新句法有点让人困惑,因为 :name 本身是一种数据类型(符号),但 name: 却没有意义。不过在哈希字面量中,:name ⇒name: 作用一样。因此,{ :name ⇒ "Michael Hartl" }{ name: "Michael Hartl" } 是等效的。如果要表示符号,只能使用 :name(冒号在前面)。

哈希中元素的值可以是任何对象,甚至是另一个哈希,如代码清单 4.10 所示。

代码清单 4.10:嵌套哈希
>> params = {}        # 定义一个名为 params(parameters 的简称)的哈希
=> {}
>> params[:user] = { name: "Michael Hartl", email: "[email protected]" }
=> {:name=>"Michael Hartl", :email=>"[email protected]"}
>> params
=> {:user=>{:name=>"Michael Hartl", :email=>"[email protected]"}}
>>  params[:user][:email]
=> "[email protected]"

Rails 大量使用这种哈希中有哈希的形式(或称为“嵌套哈希”),我们从 7.3 节起会接触到。

与数组和值域一样,哈希也能响应 each 方法。例如,一个名为 flash 的哈希,它的键是两个判断条件,:success:danger

>> flash = { success: "It worked!", danger: "It failed." }
=> {:success=>"It worked!", :danger=>"It failed."}
>> flash.each do |key, value|
?>   puts "Key #{key.inspect} has value #{value.inspect}"
>> end
Key :success has value "It worked!"
Key :danger has value "It failed."

注意,数组的 each 方法后面的块只有一个变量,而哈希的 each 方法后面的块接受两个变量,分别表示键和对应的值。所以哈希的 each 方法每次遍历都会以一个键值对为单位进行。

这段代码用到了很有用的 inspect 方法,返回被调用对象的字符串字面量表现形式:

>> puts (1..5).to_a            # 把值域转换成数组
1
2
3
4
5
>> puts (1..5).to_a.inspect    # 输出数组的字面量形式
[1, 2, 3, 4, 5]
>> puts :name, :name.inspect
name
:name
>> puts "It worked!", "It worked!".inspect
It worked!
"It worked!"

顺便说一下,因为使用 inspect 打印对象的方式经常使用,为此还有一个专门的快捷方式,p 方法:[12]

>> p :name             # 等价于 'puts :name.inspect'
:name

4.3.4 重温引入 CSS 的代码

现在我们要重新认识一下代码清单 4.1 中在布局中引入层叠样式表的代码:

<%= stylesheet_link_tag 'application', media: 'all',
                                       'data-turbolinks-track' => true %>

我们现在基本上可以理解这行代码了。在 4.1 节简单提到过,Rails 定义了一个特殊的函数用来引入样式表,下面的代码

stylesheet_link_tag 'application', media: 'all',
                                   'data-turbolinks-track' => true

就是对这个函数的调用。不过还有几个奇怪的地方。第一,括号哪去了?在 Ruby 中,括号是可以省略的,所以下面两种写法是等价的:

# 调用函数时可以省略括号
stylesheet_link_tag('application', media: 'all',
                                   'data-turbolinks-track' => true)
stylesheet_link_tag 'application', media: 'all',
                                   'data-turbolinks-track' => true

第二,media 部分显然是一个哈希,但是怎么没用花括号?调用函数时,如果哈希是最后一个参数,可以省略花括号。所以下面两种写法是等价的:

# 如果最后一个参数是哈希,可以省略花括号
stylesheet_link_tag 'application', { media: 'all',
                                     'data-turbolinks-track' => true }
stylesheet_link_tag 'application', media: 'all',
                                   'data-turbolinks-track' => true

还有,为什么 data-turbolinks-track 这个键值对使用旧句法?如果使用新句法,写成

data-turbolinks-track: true

是无效的,因为其中有连字符。(4.3.3 节说过,符号中不能使用连字符。)所以只能使用旧句法,写成

'data-turbolinks-track' => true

最后,为什么换了一行 Ruby 还能正确解析?

stylesheet_link_tag 'application', media: 'all',
                                   'data-turbolinks-track' => true

因为在这种情况下,Ruby 不关心有没有换行。[13]我之所以把代码写成两行,是要保证每行代码不超过 80 个字符。[14]

所以,下面这段代码

stylesheet_link_tag 'application', media: 'all',
                                   'data-turbolinks-track' => true

调用了 stylesheet_link_tag 函数,并且传入两个参数:一个是字符串,指明样式表的路径;另一个是哈希,包含两个元素,第一个指明媒介类型,第二个启用 Rails 4.0 中添加的 Turbolink 功能。因为使用的是 &lt;%= %&gt;,函数的执行结果会通过 ERb 插入模板中。如果在浏览器中查看网页的源码,会看到引入样式表所用的 HTML,如代码清单 4.11 所示。(你可能会在 CSS 的文件名后看到额外的字符,例如 ?body=1。这是 Rails 加入的,确保修改 CSS 后浏览器会重新加载。)

代码清单 4.11:引入 CSS 的代码生成的 HTML
<link data-turbolinks-track="true" href="/assets/application.css" media="all"
rel="stylesheet" />

如果在浏览器中打开 http://localhost:3000/assets/application.css 查看 CSS 的话,会发现是这个文件是空的(但有一些注释)。第 5 章会介绍如何添加样式。