# **第 8 章 类和模块**
到目前为止,我们已经向大家介绍了基本数据类型(数值、字符串、数组、散列)、方法(操作数据的工具)、控制结构(描述程序如何运行)等程序运行所必需的要素。这些也都是大部分编程语言共通的元素,从某种意义上可以说是程序运行的基础。
作为面向对象的脚本语言的 Ruby,还为我们提供了面向对象程序设计的支持。接下来,我们首先会了解面向对象程序设计的共通概念——类,以及 Ruby 独有的功能——模块,然后再讨论面向对象程序设计的基本设计方法。
![{%}](https://box.kancloud.cn/2015-10-26_562e01e4e308d.png)
### **8.1 类是什么**
类(class)是面向对象中一个重要的术语。关于类,我们在第 4 章中已经做过简单的说明,现在就进一步讨论一下在面向对象语言中如何使用类。
### **8.1.1 类和实例**
类表示对象的种类。Ruby 中的对象都一定属于某个类。例如,我们常说的“数组对象”“数组”,实际上都是 `Array` 类的对象(实例)。还有字符串对象,实际上是 `String` 类的对象(实例)。
相同类的对象所使用的方法也相同。类就像是对象的雏形或设计图(图 8.1),决定了对象的行为。
![{%}](https://box.kancloud.cn/2015-10-26_562e01e541bfe.png)
**图 8.1 类和实例的关系**
> **1**一种传统的日本食物。——译者注
我们在生成新的对象时,一般会用到各个类的 `new` 方法。例如,使用 `Array.new` 方法可以生成新的数组对象。
~~~
ary = Array.new
p ary #=> []
~~~
> **备注** 像数组、字符串这样的类,也可以使用字面量(像 `[1, 2, 3]`、`"abc"` 这样的写法)来生成对象。
当想知道某个对象属于哪个类时,我们可以使用 `class` 方法。
~~~
ary = []
str = "Hello world."
p ary.class #=> Array
p str.class #=> String
~~~
当判断某个对象是否属于某个类时,我们可以使用 `instance_of?` 方法。
~~~
ary = []
str = "Hello world."
p ary.instance_of?(Array) #=> true
p str.instance_of?(String) #=> true
p ary.instance_of?(String) #=> false
p str.instance_of?(Array) #=> false
~~~
### **8.1.2 继承**
我们把通过扩展已定义的类来创建新类称为继承。
假设我们需要编写一个在屏幕中显示时间的小程序。根据用户的喜好,这个小程序能以模拟时钟或者电子时钟的方式显示。
模拟时钟与电子时钟,两者只是在时间的表现形式上不一样,获取当前时间的方法以及闹钟等基本功能都是相同的。因此,我们可以首先定义一个拥有基本功能的时钟类,然后再通过继承来分别创建模拟时钟类和电子时钟类(图 8.2)。
![{%}](https://box.kancloud.cn/2015-10-26_562e01e56071a.png)
**图 8.2 继承时钟类的模拟时钟类与电子时钟类**
继承后创建的新类称为子类(subclass),被继承的类被称为父类(superclass)2。通过继承我们可以实现以下事情:
2也称超类。——译者注
-
**在不影响原有功能的前提下追加新功能。**
-
**重定义原有功能,使名称相同的方法产生不同的效果。**
-
**在已有功能的基础上追加处理,扩展已有功能。**
此外,我们还可以利用继承来轻松地创建多个具有相似功能的类。
`BasicObject` 类是 Ruby 中所有类的父类,它定义了作为 Ruby 对象的最基本功能。
> **备注** `BasicObject` 类是最最基础的类,甚至连一般对象需要的功能都没有定义。因此普通对象所需要的类一般都被定义为 `Object` 类。字符串、数组等都是 `Object` 类的子类。关于 `BasicObject` 和 `Object`,我们会在 8.3.2 节中再详细说明。
图 8.3 是本书中涉及的类继承关系图。另外,`Exception` 类下还有众多子类,这里不再详细罗列。
![{%}](https://box.kancloud.cn/2015-10-26_562e01e580c09.png)
**图 8.3 类的继承关系**
子类与父类的关系称为“is-a 关系”3。例如,`String` 类与它的父类 `Object` 就是 is-a 关系。
3这里的“a”指的是英语中的不定冠词“a”。——译者注
之前我们提到过查找对象所属的类时使用 `instance_of?` 方法,而根据类的继承关系反向追查对象是否属于某个类时,则可以使用 `is_a?` 方法。
~~~
str = "This is a String."
p str.is_a?(String) #=> true
p str.is_a?(Object) #=> true
~~~
顺便提一下,由于 `instance_of?` 方法与 `is_a?` 方法都已经在 `Object` 类中定义过了,因此普通的对象都可以使用这两个方法。
在本章的最后,我们会介绍类与模块的创建方法。在自己创建类之前,如果想对 Ruby 预先提供的类的使用方法一睹为快,请跳过第 2 部分余下的内容,直接进入到第 3 部分。
### **8.2 类的创建**
事不宜迟,让我们来看看如何创建类。定义类时有很多约束,我们先从最基础的开始。
代码清单 8.1 就是一个创建类的例子:
**代码清单 8.1 hello_class.rb**
~~~
class HelloWorld # class 关键字
def initialize(myname = "Ruby") # initialize 方法
@name = myname # 初始化实例变量
end
def hello # 实例方法
puts "Hello, world. I am #{@name}."
end
end
bob = HelloWorld.new("Bob")
alice = HelloWorld.new("Alice")
ruby = HelloWorld.new
bob.hello
~~~
### **8.2.1 class 关键字**
`class` 关键字在定义类时使用。以下是 `class` 关键字的一般用法:
**`class` 类名
类的定义
`end`**
类名的首字母必须大写。
### **8.2.2 initialize 方法**
在 `class` 关键字中定义的方法为该类的实例方法。代码清单 8.1 的 `hello` 方法就是实例方法。
其中,名为 `initialize` 的方法比较特别。使用 `new` 方法生成新的对象时,`initialize` 方法会被调用,同时 `new` 方法的参数也会被原封不动地传给 `initialize` 方法。因此初始化对象时需要的处理一般都写在这个方法中。
~~~
def initialize(myname = "Ruby") # initialize 方法
@name = myname # 初始化实例变量
end
~~~
在这个例子中,`initialize` 方法接受了参数 `myname`。因此,
~~~
bob = HelloWorld.new("Bob")
~~~
像这样,就可以把 `"Bob"` 传给 `initialize` 方法生成对象。由于 `initialize` 方法的参数指定了默认值 `"Ruby"`,因此,像下面这样没有指定参数时,
~~~
ruby = HelloWorld.new
~~~
会自动把 `"Ruby"` 传给 `initialize` 方法。
### **8.2.3 实例变量与实例方法**
我们再回头看看代码清单 8.1 的 `initialize` 方法。
~~~
def initialize(myname = "Ruby") # initialize 方法
@name = myname # 初始化实例变量
end
~~~
通过 `@name = myname` 这行程序,作为参数传进来的对象会被赋值给变量 `@name`。我们把以 `@` 开头的变量称为实例变量。在不同的方法中,程序会把局部变量看作是不同的变量来对待。而只要在同一个实例中,程序就可以超越方法定义,任意引用、修改实例变量的值。另外,引用未初始化的实例变量时的返回值为 `nil`。
不同实例的实例变量值可以不同。只要实例存在,实例变量的值就不会消失,并且可以被任意使用。而局部变量则是在调用方法时被创建,而且只能在该方法内使用。
我们来看看下面的例子:
~~~
alice = HelloWorld.new("Alice")
bob = HelloWorld.new("Bob")
ruby = helloWorld.new
~~~
`alice`、`bob`、`ruby` 各自拥有不同的 `@name`(图 8.4)。
![{%}](https://box.kancloud.cn/2015-10-26_562e01e59e79c.png)
**图 8.4 HelloWorld 类与实例**
可以在实例方法中引用实例变量,下面是 `HelloWorld` 类定义的 `hello` 方法引用 `@name` 的例子:
~~~
class HelloWorld
┊
def hello # 实例方法
puts "Hello, world. I am #{@name}."
end
end
~~~
通过以下方式调用 `HelloWolrd` 类定义的 `hello` 方法:
~~~
bob.hello
~~~
输出结果如下所示:
~~~
Hello, world. I am Bob.
~~~
### **8.2.4 存取器**
在 Ruby 中,从对象外部不能直接访问实例变量或对实例变量赋值,需要通过方法来访问对象的内部。
为了访问代码清单 8.1 中 `HelloWorld` 类的 `@name` 实例变量,我们需要定义以下方法:
**代码清单 8.2 hello_class.rb(部分)**
~~~
class HelloWorld
┊
def name # 获取@name
@name
end
def name=(value) # 修改@name
@name = value
end
┊
end
~~~
第一个方法 `name` 只是简单地返回 `@name` 的值,我们可以像访问属性一样使用该方法。
~~~
p bob.name #=> "Bob"
~~~
第二个方法的方法名为 `name=`,使用方法如下:
~~~
bob.name = "Robert"
~~~
乍一看,该语法很像是在给对象的属性赋值,但实际上却是在调用 `name=("Robert")` 这个方法。利用这样的方法,我们就可以突破 Ruby 原有的限制,从外部来自由地访问对象内部的实例变量了。
当对象的实例变量有多个时,如果逐个定义存取器,就会使程序变得难懂,而且也容易写错。为此,Ruby 为了我们提供了更简便的定义方法 `attr_reader`、`attr_writer`、`attr_accessor`(表 8.1)。只要指定实例变量名的符号(symbol),Ruby 就会自动帮我们定义相应的存取器。
**表 8.1 存取器的定义**
<table border="1" data-line-num="216 217 218 219 220 221" width="90%"><thead><tr><th> <p class="表头单元格">定义</p> </th> <th> <p class="表头单元格">意义</p> </th> </tr></thead><tbody><tr><td> <p class="表格单元格"><code>attr_reader :name</code></p> </td> <td> <p class="表格单元格">只读(定义 <code>name</code> 方法)</p> </td> </tr><tr><td> <p class="表格单元格"><code>attr_writer :name</code></p> </td> <td> <p class="表格单元格">只写(定义 <code>name=</code> 方法)</p> </td> </tr><tr><td> <p class="表格单元格"><code>attr_accessor :name</code></p> </td> <td> <p class="表格单元格">读写(定义以上两个方法)</p> </td> </tr></tbody></table>
也可以像下面这样只写一行代码,其效果与刚才的 `name` 方法以及 `name=` 方法的效果是一样的。
~~~
class HelloWorld
attr_accessor :name
end
~~~
> **备注** Ruby 中一般把设定实例变量的方法称为 writer,读取实例变量的方法称为 reader,这两个方法合称为 accessor。另外,有时也把 reader 称为 getter,writer 称为 setter,合称为 accessor method4。
4一般把 accessor(method)翻译为存取器或者访问器,本书统一翻译为存取器。——译者注
### **8.2.5 特殊变量 self**
在实例方法中,可以用 `self` 这个特殊的变量来引用方法的接收者。接下来就让我们来看看其他的实例方法如何调用 `name` 方法。
**代码清单 8.3 hello_class.rb(部分)**
~~~
class HelloWorld
attr_accessor :name
┊
def greet
puts "Hi, I am #{self.name}."
end
end
┊
~~~
`greet` 方法里的 `self.name` 引用了调用 `greet` 方法时的接收者。
调用方法时,如果省略了接收者,Ruby 就会默认把 `self` 作为该方法的接收者。因此,即使省略了 `self`,也还是可以调用 `name` 方法,如下所示:
~~~
def greet
print "Hi, I am #{name}"
end
~~~
另外,在调用像 `name=` 方法这样的以 `=` 结束的方法时,有一点需要特别注意。即使实例方法中已经有了 `name = "Ruby"` 这样的定义,但如果仅在方法内部定义名为 `name` 的局部变量,也不能以缺省接收者的方式调用 `name=` 方法。这种情况下,我们需要用 `self.name = "Ruby"` 的形式来显式调用 `name` 方法。
~~~
def test_name
name = "Ruby" # 为局部变量赋值
self.name = "Ruby" # 调用name= 方法
end
~~~
> **备注** 虽然 `self` 本身与局部变量形式相同,但由于它是引用对象本身时的保留字,因此我们即使对它进行赋值,也不会对其本身的值有任何影响。像这样,已经被系统使用且不能被我们自定义的变量名还有 `nil`、`true`、`false`、`__FILE__`、`__LINE__`、`__ENCODING__` 等。
### **8.2.6 类方法**
方法的接收者就是类本身(类对象)的方法称为类方法。正如我们在 7.2.2 节中提到的那样,类方法的操作对象不是实例,而是类本身。
下面,让我们在 `class << 类名 ~ end` 这个特殊的类定义中,以定义实例方法的形式来定义类方法。
~~~
class << HelloWorld
def hello(name)
puts "#{name} said hello."
end
end
HelloWorld.hello("John") #=> John said hello.
~~~
在 `class` 上下文中使用 `self` 时,引用的对象是该类本身,因此,我们可以使用 `class << self ~ end` 这样的形式,在 `class` 上下文中定义类方法。
~~~
class HelloWorld
class << self
def hello(name)
puts "#{name} said hello."
end
end
end
~~~
除此以外,我们还可以使用 `def 类名 . 方法名 ~ end` 这样的形式来定义类方法。
~~~
def HelloWorld.hello(name)
puts "#{name} said hello."
end
HelloWorld.hello("John") #=> John said hello.
~~~
同样,只要是在 `class` 上下文中,这种形式下也可以像下面的例子那样使用 `self`。
~~~
class HelloWorld
def self.hello(name)
puts "#{name} said hello."
end
end
~~~
> **备注** `class << 类名 ~ end` 这种写法的类定义称为单例类定义,单例类定义中定义的方法称为单例方法。
### **8.2.7 常量**
在 class 上下文中可以定义常量。
~~~
class HelloWorld
Version = "1.0"
┊
end
~~~
对于在类中定义的常量,我们可以像下面那样使用 `::`,通过类名来实现外部访问。
~~~
p HelloWorld::Version #=> "1.0"
~~~
### **8.2.8 类变量**
以 `@@` 开头的变量称为类变量。类变量是该类所有实例的共享变量,这一点与常量类似,不同的是我们可以多次修改类变量的值。另外,与实例变量一样,从类的外部访问类变量时也需要存取器。不过,由于 `attr_accessor` 等存取器都不能使用,因此需要直接定义。代码清单 8.4 的程序在代码清单 8.1 的 `HelloWorld` 类的基础上,添加了统计 `hello` 方法被调用次数的功能。
**代码清单 8.4 hello_count.rb**
~~~
class HelloCount
@@count = 0 # 调用hello 方法的次数
def HelloCount.count # 读取调用次数的类方法
@@count
end
def initialize(myname="Ruby")
@name = myname
end
def hello
@@count += 1 # 累加调用次数
puts "Hello, world. I am #{@name}.\n"
end
end
bob = HelloCount.new("Bob")
alice = HelloCount.new("Alice")
ruby = HelloCount.new
p HelloCount.count #=> 0
bob.hello
alice.hello
ruby.hello
p HelloCount.count #=> 3
~~~
### **8.2.9 限制方法的调用**
到目前为止,我们定义的方法,都能作为实例方法被任意调用,但是有时候我们可能并不希望这样。例如,只是为了汇总多个方法的共同处理而定义的方法,一般不会公开给外部使用。
Ruby 提供了 3 种方法的访问级别,我们可以按照需要来灵活调整。
-
**`public`……以实例方法的形式向外部公开该方法**
-
**`private`……在指定接收者的情况下不能调用该方法(只能使用缺省接收者的方式调用该方法,因此无法从实例的外部访问)**
-
**`protected`……在同一个类中时可将该方法作为实例方法调用**
在修改方法的访问级别时,我们会为这 3 个关键字指定表示方法名的符号。
首先来看看使用 `public` 和 `private` 的例子(代码清单 8.5)。
**代码清单 8.5 acc_test.rb**
~~~
class AccTest
def pub
puts "pub is a public method."
end
public :pub # 把pub 方法设定为public(可省略)
def priv
puts "priv is a private method."
end
private :priv # 把priv 方法设定为private
end
acc = AccTest.new
acc.pub
acc.priv
~~~
`AccTest` 类的两个方法中,`pub` 方法可以正常调用,但是在调用 `priv` 方法时程序会发生异常,并出现以下错误信息 :
> **执行示例**
~~~
> ruby acc_test.rb
pub is a public method.
acc_test.rb:17:in `<main>': private method `priv' called for
#<AccTest:0x007fb4089293e8> (NoMethodError)
~~~
希望统一定义多个方法的访问级别时,可以使用下面的语法 :
~~~
class AccTest
public # 不指定参数时,
# 以下的方法都被定义为public
def pub
puts "pub is a public method."
end
private # 以下的方法都被定义为private
def priv
puts "priv is a private method."
end
end
~~~
> **备注** 没有指定访问级别的方法默认为 `public`,但 `initialize` 方法是个例外,它通常会被定义为 `private`。
定义为 `protected` 的方法,在同一个类(及其子类)中可作为实例方法使用,而在除此以外的地方则无法使用。
代码清单 8.6 定义了拥有 X、Y 坐标的 `Point` 类。在这个类中,实例中的坐标可以被外部读取,但不能被修改。为此,我们可以利用 `protected` 来实现交换两个坐标值的方法 `swap`。
**代码清单 8.6 point.rb**
~~~
class Point
attr_accessor :x, :y # 定义存取器
protected :x=, :y= # 把x= 与y= 设定为protected
def initialize(x=0.0, y=0.0)
@x, @y = x, y
end
def swap(other) # 交换x、y 值的方法
tmp_x, tmp_y = @x, @y
@x, @y = other.x, other.y
other.x, other.y = tmp_x, tmp_y # 在同一个类中
# 可以被调用
return self
end
end
p0 = Point.new
p1 = Point.new(1.0, 2.0)
p [ p0.x, p0.y ] #=> [0.0, 0.0]
p [ p1.x, p1.y ] #=> [1.0, 2.0]
p0.swap(p1)
p [ p0.x, p0.y ] #=> [1.0, 2.0]
p [ p1.x, p1.y ] #=> [0.0, 0.0]
p0.x = 10.0 #=> 错误(NoMethodError)
~~~
### **8.3 扩展类**
### **8.3.1 在原有类的基础上添加方法**
Ruby 允许我们在已经定义好的类中添加方法。下面,我们来试试给 `String` 类添加一个计算字符串单词数的实例方法 `count_word`(代码清单 8.7)。
**代码清单 8.7 ext_string.rb**
~~~
class String
def count_word
ary = self.split(/\s+/) # 用空格分割接收者
return ary.size # 返回分割后的数组的元素总数
end
end
str = "Just Another Ruby Newbie"
p str.count_word #=> 4
~~~
### **8.3.2 继承**
正如在 8.1.2 节中介绍的那样,利用继承,我们可以在不对已有的类进行修改的前提下,通过增加新功能或重定义已有功能等手段来创建新的类。
定义继承时,在使用 `class` 关键字指定类名的同时指定父类名。
**`class` 类名`<` 父类名
类定义
`end`**
在程序清单 8.8 中,创建一个继承了 `Array` 类的 `RingArray` 类。`RingArray` 类只是重定义了读取数组内容时使用的 `[]` 运算符。该程序通过 `super` 关键字调用父类中同名的方法(在本例中也就是 `Array#[]`)。
**代码清单 8.8 ring_array.rb**
~~~
class RingArray < Array # 指定父类
def [](i) # 重定义运算符[]
idx = i % size # 计算新索引值
super(idx) # 调用父类中同名的方法
end
end
wday = RingArray["日", "月", "火", "水", "木", "金", "土"]
p wday[6] #=> "土"
p wday[11] #=> "木"
p wday[15] #=> "月"
p wday[-1] #=> "土"
~~~
对 `RingArray` 类指定了超过数组长度的索引时,结果就会从溢出部分的开头开始重新计算索引(图 8.5)。
![{%}](https://box.kancloud.cn/2015-10-26_562e01e5afcb5.png)
**图 8.5 RingArray 类**
利用继承,我们可以把共同的功能定义在父类,把各自独有的功能定义在子类。
定义类时没有指定父类的情况下,Ruby 会默认该类为 `Object` 类的子类。
`Object` 类提供了许多便于实际编程的方法。但在某些情况下,我们也有可能会希望使用更轻量级的类,而这时就可以使用 `BasicObject` 类。
`BasicObject` 类只提供了组成 Ruby 对象所需的最低限度的方法。类对象调用 `instance_methods` 方法后,就会以符号的形式返回该类的实例方法列表。下面我们就用这个方法来对比一下 `Object` 类和 `BasicObject` 类的实例方法。
> **执行示例**
~~~
> irb --simple-prompt
>> Object.instance_methods
=> [:nil?, :===, :=~, :!~, :eql?, :hash, :<=>, :class, :singleton_class, :clone,
:dup, :taint, :tainted?, :untaint, :untrust, :untrusted?, :trust, :freeze,
:frozen?, :to_s, ...... 等众多方法名......]
>> BasicObject.instance_methods
=> [:==, :equal?, :!, :!=, :instance_eval, :instance_exec, :__send__, :__id__]
~~~
虽然大部分方法我们都还没有接触到,但据此也可以看出,相对于 `Object` 类持有多种方法,`BacsicObject` 类所拥有的功能都是最基本的。 定义 `BasicObject` 的子类时,与 `Object` 类不同,需要明确指定 `BasicObject` 类为父类,如下所示 :
~~~
class MySimpleClass < BasicObject
┊
end
~~~
### **8.4 alias 与 undef**
### **8.4.1 alias**
有时我们会希望给已经存在的方法设置别名。这种情况下就需要使用 `alias` 方法。`alias` 方法的参数为方法名或者符号名。
**`alias` 别名 原名 `#` 直接使用方法名
`alias``:` 别名 `:` 原名 `#` 使用符号名**
像 `Array#size` 与 `Array#length` 这样,为同一种功能设置多个名称时,我们会使用到 `alias`。
另外,除了为方法设置别名外,在重定义已经存在的方法时,为了能用别名调用原来的方法,我们也需要用到 `alias`。
下面的例子中定义了类 `C1` 及其子类 `C2`。在类 `C2` 中,对 `hello` 方法设置别名 `old_hello` 后,重定义了 `hello` 方法。
~~~
class C1 # 定义C1
def hello # 定义hello
"Hello"
end
end
class C2 < C1 # 定义继承了C1 的子类C2
alias old_hello hello # 设定别名old_hello
def hello # 重定义hello
"#{old_hello}, again"
end
end
obj = C2.new
p obj.old_hello #=> "Hello"
p obj.hello #=> "Hello, again
~~~
### **8.4.2 undef**
`undef` 用于删除已有方法的定义。与 `alias` 一样,参数可以指定方法名或者符号名。
**`undef` 方法名 `#` 直接使用方法名
`undef :` 方法名 `#` 使用符号名**
例如,在子类中希望删除父类定义的方法时可以使用 `undef`。
> **专栏**
> **单例类**
> 在 8.2.6 节中介绍定义类方法的方法时,我们提到了单例类定义,而通过利用单例类定义,就可以给对象添加方法(单例方法)。单例类定义被用于定义对象的专属实例方法。在下面的例子中,我们分别将 `"Ruby"` 赋值给 `str1` 对象和 `str2` 对象,然后只对 `str1` 对象添加 `hello` 方法。这样一来,两个对象分别调用 `hello` 方法时,`str1` 对象可以正常调用,但 `str2` 对象调用时程序就会发生错误。
~~~
str1 = "Ruby"
str2 = "Ruby"
class << str1
def hello
"Hello, #{self}!"
end
end
p str1.hello #=> "Hello, Ruby!"
p str2.hello #=> 错误(NoMethodError)
~~~
> Ruby 中所有的类都是 `Class` 类的实例,对 `Class` 类添加实例方法,就等于给所有的类都添加了该类方法。因此,只希望对某个实例添加方法时,就需要利用单例方法。
> 单例类的英语为 singleton class 或者 eigenclass。
### **8.5 模块是什么**
模块是 Ruby 的特色功能之一。如果说类表现的是事物的实体(数据)及其行为(处理),那么模块表现的就只是事物的行为部分。模块与类有以下两点不同:
-
**模块不能拥有实例**
-
**模块不能被继承**
### **8.6 模块的使用方法**
接下来,我们就来介绍一下模块的典型用法。
### **8.6.1 提供命名空间**
所谓命名空间(namespace),就是对方法、常量、类等名称进行区分及管理的单位。由于模块提供各自独立的命名空间,因此 `A` 模块中的 `foo` 方法与 `B` 模块中的 `foo` 方法,就会被程序认为是两个不同的方法。同样,`A` 模块中的 `FOO` 常量与 `B` 模块的 `FOO` 常量,也是两个不同的常量。
无论是方法名还是类名,当然都是越简洁越好,但是像 `size`、`start` 等这种普通的名称,可能在很多地方都会使用到。因此,通过在模块内定义名称,就可以解决命名冲突的问题。
例如,在 `FileTest` 模块中存在与获取文件信息相关的方法。我们使用“模块名 `.` 方法名”的形式来调用在模块中定义的方法,这样的方法称为模块函数。
~~~
# 检查文件是否存在
p FileTest.exist?("/usr/bin/ruby") #=> true
# 文件大小
p FileTest.size("/usr/bin/ruby") #=> 1374684
~~~
如果没有定义与模块内的方法、常量等同名的名称,那么引用时就可以省略模块名。通过 `include` 可以把模块内的方法名、常量名合并到当前的命名空间。下面是与数学运算有关的 `Math` 模块的例子。
~~~
# 圆周率(常量)
p Math::PI #=> 3.141592653589793
# 2 的平方根
p Math.sqrt(2) #=> 1.4142135623730951
include Math # 包含Math 模块
p PI #=> 3.141592653589793
p sqrt(2) #=> 1.4142135623730951
~~~
像这样,通过把一系列相关的功能汇总在一个模块中,就可以集中管理相关的命名。
### **8.6.2 利用 Mix-in 扩展功能**
Mix-in 就是将模块混合到类中。在定义类时使用 `include`,模块里的方法、常量就都能被类使用。
像代码清单 8.9 那样,我们可以把 `MyClass1` 和 `MyClass2` 中两者共通的功能定义在 `MyModule` 中。虽然有点类似于类的继承,但 Mix-in 可以更加灵活地解决下面的问题。
-
**虽然两个类拥有相似的功能,但是不希望把它们作为相同的种类(`Class`)来考虑的时候**
-
**`Ruby` 不支持父类的多重继承,因此无法对已经继承的类添加共通的功能的时候**
关于继承和 Mix-in 之间的关系,我们会在 8.8 节中进行说明。
**代码清单 8.9 mixin_sample.rb**
~~~
module MyModule
# 共通的方法等
end
class MyClass1
include MyModule
# MyClass1 中独有的方法
end
class MyClass2
include MyModule
# MyClass2 中独有的方法
end
~~~
### **8.7 创建模块**
我们使用 `module` 关键字来创建模块。
语法与创建类时几乎相同。模块名的首字母必须大写。
**`module` 模块名
模块定义
`end`**
下面,让我们参考着代码清单 8.1 的 `HelloWold` 类,来看看如何创建模块(代码清单 8.10)。
**代码清单 8.10 hello_module.rb**
~~~
module HelloModule # module 关键字
Version = "1.0" # 定义常量
def hello(name) # 定义方法
puts "Hello, #{name}."
end
module_function :hello # 指定hello 方法为模块函数
end
p HelloModule::Version #=> "1.0"
HelloModule.hello("Alice") #=> Hello, Alice.
include HelloModule # 包含模块
p Version #=> "1.0"
hello("Alice") #=> Hello, Alice.
~~~
### **8.7.1 常量**
和类一样,在模块中定义的常量可以通过模块名访问。
~~~
p HelloModule::Version #=> "1.0"
~~~
### **8.7.2 方法的定义**
和类一样,我们也可以在 `module` 上下文中定义方法。
然而,如果仅仅定义了方法,虽然在模块内部与包含此模块的上文中都可以直接调用,但却不能以“模块名 . 方法名”的形式调用。如果希望把方法作为模块函数公开给外部使用,就需要用到 `module_function` 方法。`module_function` 的参数是表示方法名的符号。
~~~
def hello(name)
puts "Hello, #{name}."
end
module_function :hello
~~~
以“模块名 `.` 方法名”的形式调用时,如果在方法中调用 `self`(接收者),就会获得该模块的对象。
~~~
module FooMoudle
def foo
p self
end
module_function :foo
end
FooMoudle.foo #=> FooMoudle
~~~
此外,如果类 Mix-in 了模块,就相当于为该类添加了实例方法。在这种情况下,`self` 代表的就是被 Mix-in 的类的对象。
即使是相同的方法,在不同的上下文调用时,其含义也会不一样,因此对于 Mix-in 的模块,我们要注意根据实际情况判断是否使用模块函数功能。一般不建议在定义为模块函数的方法中使用 `self`。
### **8.8 Mix-in**
现在,我们来详细讨论一下本章开头就提到的 Mix-in。这里使用 `include` 使类包含模块(代码清单 8.11)。
**代码清单 8.11 mixin_test.rb**
~~~
module M
def meth
"meth"
end
end
class C
include M # 包含M 模块
end
c = C.new
p c.meth #=> meth
~~~
类 `C` 包含模块 `M` 后,模块 `M` 中定义的方法就可以作为类 `C` 的实例方法供程序调用。
另外,如果想知道类是否包含某个模块,可以使用 `include?` 方法。
~~~
C.include?(M) #=> true
~~~
类 `C` 的实例在调用方法时,Ruby 会按类 `C`、模块 `M`、类 `C` 的父类 `Object` 这个顺序查找该方法,并执行第一个找到的方法。被包含的模块的作用就类似于虚拟的父类。
![{%}](https://box.kancloud.cn/2015-10-26_562e01e60512b.png)
**图 8.6 类的继承关系**
我们用 `ancestors` 方法和 `superclass` 方法调查类的继承关系。在代码清单 8.11 中追加以下代码并执行,我们就可以通过 `ancestors` 取得继承关系的列表。进而也就可以看出,被包含的模块 `M` 也被认为是类 `C` 的一个“祖先”。而 `superclass` 方法则直接返回类 `C` 的父类。
~~~
p C.ancestors #=> [C, M, Object, Kernel, BasicObject]
p C.superclass #=> Object
~~~
> **备注** `ancestors` 方法的返回值中的 `Kernel` 是 Ruby 内部的一个核心模块,Ruby 程序运行时所需的共通函数都封装在此模块中。例如 `p` 方法、`raise` 方法等都是由 `Kernel` 模块提供的模块函数。
虽然 Ruby 采用的是不允许多个父类的单一继承模型,但是通过利用 Mix-in,我们就既可以保持单一继承的关系,又可以同时让多个类共享其他功能。
在 Ruby 标准类库中,`Enumerable` 模块就是利用 Mix-in 扩展功能的一个典型例子。使用 `each` 方法的类中包含 `Enumerable` 模块后,就可以使用 `each_with_index` 方法、`collect` 方法等对元素进行排序处理的方法。`Array`、`Hash`、`IO` 类等都包含了 `Enumerable` 模块(图 8.7)。这些类虽然没有继承这样的血缘关系,但是从“可以使用 `each` 方法遍历元素”这一点来看,可以说它们都拥有了某种相似甚至相同的属性。
![{%}](https://box.kancloud.cn/2015-10-26_562e01e615a56.png)
**图 8.7 Enumerable 模块和各类的关系**
单一继承的优点就是简单,不会因为过多的继承而导致类之间的关系变得复杂。但是另一方面,有时我们又会希望更加积极地重用已有的类,或者把多个类的特性合并为更高级的类,在那样的情况下,灵活使用单一继承和 Mix-in,既能使类结构简单易懂,又能灵活地应对各种需求。
### **8.8.1 查找方法的规则**
首先,我们来了解一下使用 Mix-in 时方法的查找顺序。
① **同继承关系一样,原类中已经定义了同名的方法时,优先使用该方法。**
~~~
module M
def meth
"M#meth"
end
end
class C
include M # 包含M
def meth
"C#meth"
end
end
c = C.new
p c.meth #=> C#meth
~~~
② **在同一个类中包含多个模块时,优先使用最后一个包含的模块。**
~~~
module M1
┊
end
module M2
┊
end
class C
include M1 #=> 包含M1
include M2 #=> 包含M2
end
p C.ancestors #=> [C, M2, M1, Object, Kernel]
~~~
③ **嵌套 `include` 时,查找顺序也是线性的,此时的关系如图 8.8 所示。**
~~~
module M1
┊
end
module M2
┊
end
module M3
include M2 #=> 包含M2
end
class C
include M1 #=> 包含M1
include M3 #=> 包含M3
end
p C.ancestors #=> [C, M3, M2, M1, Object, Kernel]
~~~
![{%}](https://box.kancloud.cn/2015-10-26_562e01e628d95.png)
**图 8.8 嵌套 include 时的关系**
④ **相同的模块被包含两次以上时,第 2 次以后的会被省略。**
~~~
module M1
┊
end
module M2
┊
end
class C
include M1 #=> 包含M1
include M2 #=> 包含M2
include M1 #=> 包含M1
end
p C.ancestors #=> [C, M2, M1, Object, Kernel, BasicObject]
~~~
### **8.8.2 extend 方法**
在之前的专栏中,我们已经介绍了如何逐个定义单例方法,而利用 `Object#extend` 方法,我们还可以实现批量定义单例方法。`extend` 方法可以使单例类包含模块,并把模块的功能扩展到对象中。
~~~
module Edition
def edition(n)
"#{self} 第#{n} 版"
end
end
str = "Ruby 基础教程"
str.extend(Edition) #=> 将模块Mix-in 进对象
p str.edition(4) #=> "Ruby 基础教程第4 版"
~~~
`include` 可以帮助我们突破继承的限制,通过模块扩展类的功能;而 `extend` 则可以帮助我们跨过类,直接通过模块扩展对象的功能。
### **8.8.3 类与 Mix-in**
在 Ruby 中,所有类本身都是 `Class` 类的对象。我们之前也介绍过接收者为类本身的方法就是类方法。也就是说,类方法就是类对象的实例方法。我们可以把类方法理解为:
-
**`Class` 类的实例方法**
-
**类对象的单例方法**
继承类后,这些方法就会作为类方法被子类继承。对子类定义单例方法,实际上也就是定义新的类方法。
除了之前介绍的定义类方法的语法外,使用 `extend` 方法也同样能为类对象追加类方法。下面是使用 `extend` 方法追加类方法,并使用 `include` 方法追加实例方法的一个例子。
~~~
module ClassMethods # 定义类方法的模块
def cmethod
"class method"
end
end
module InstanceMethods # 定义实例方法的模块
def imethod
"instance method"
end
end
class MyClass
# 使用extend 方法定义类方法
extend ClassMethods
# 使用include 定义实例方法
include InstanceMethods
end
p MyClass.cmethod #=> "class method"
p Myclass.new.imethod #=> "instance method"
~~~
> **备注** 在 Ruby 中,所有方法的执行,都需要通过作为接收者的某个对象的调用。换句话说,Ruby 的方法(包括单例方法)都一定属于某个类,并且作为接收者对象的实例方法被程序调用。从这个角度来说,人们只是为了便于识别接收者的类型,才分别使用了“实例方法”和“类方法”这样的说法。
### **8.9 面向对象程序设计**
“面向对象”这个概念,被广泛地应用在问题分析、系统设计或程序设计等系统和程序开发领域中。虽然这个概念目前被用在了各种各样的领域中,但首先使用这个概念的是与程序设计相关的领域。
由于本书是程序设计语言的入门书,因此这里并不会向其他领域过多延伸,而只会介绍与程序设计语言(当然就是 Ruby 了)相关的对象和面向对象方面的基础知识。
下面,我们暂且不讨论具体如何编写程序,而是先来了解一下编写程序时的一些思考方法。因为话题比较抽象,所以可能会有点难懂,不过请大家放轻松,耐心地读下去你就会抓住窍门的。
### **8.9.1 对象是什么**
包括 Ruby 在内,世界上有多种面向对象的程序设计语言。不同的语言,不仅语法不一样,功能也千差万别,但它们几乎都有一个共通点,就是将程序处理的主体作为“对象”来考虑。
一般情况下,程序语言的处理主体是数据。之前提到的数值、字符串、数组等都是简单的数据。
而面向对象的语言中的“对象”就是指数据(或者说数据的集合)及操作该数据的方法的组合。之前我们提到过 Ruby 里的数值 `3.14` 是 `Float` 类的实例。这个 `3.14` 不仅是表示 3.14 这个数值的数据,还包括与数值相关的操作方法。
~~~
f = 3.14
p f.round #=> 3 (四舍五入)
p f.ceil #=> 4 (进位)
p f.to_i #=> 3 (整数变换)
~~~
像这样,把数据以及处理数据的操作方法作为对象合并在一起贯穿整体,在面向对象程序设计中是很常见的。例如,将浮点数做四舍五入处理的 `round` 方法是可以被作为 `Float` 类的一部分来提供的,这样一来,数据以及处理数据的方法的组合也不会出现错误。
如果只是简单的数值处理,并不会有太大的问题,但大部分程序都需要更复杂的数据构成。例如,在一个处理图片的程序中,图片的长宽、颜色、包括图片本身的内容都需要转换为二进制数据。如果能把一个图片作为一个零部件来处理,那么像图册这样复杂的应用程序也就变得容易编写了(图 8.9)。
![{%}](http://www.ituring.com.cn/figures/2015/Ruby4/14.d08z.010.png)
**图 8.9 结构化后的数据**
在开发大型程序的时候,若不将大量的数据整合到一起并根据一定的规则进行整理,程序处理本身的统一性会荡然无存。面向对象程序设计会把这种归类统一的数据作为各种各样的对象来看待。在对象中,数据以及处理数据的方法也是成套存在的,而且还负有处理数据的责任。
另外,就像网络上的服务器管理的文件一样,远程数据也可以作为程序的处理对象来考虑。在网络程序设计里,Web、邮件等不同的应用程序,都需要遵守各自不同的规范(也称为协议)。用程序来实现协议的情况下,一般会把管理消息格式、规范等的程序抽象成库(library)。Ruby 的库里就有现成的 `Net::HTTP`、`Net::POP` 等类,可以非常轻松地编写网络程序。
### **8.9.2 面向对象的特征**
上文中我们简单地介绍了面向对象程序设计的思考方法,下面就让我们来整理一下面向对象的特征。
-
**封装**
所谓封装(encapsulation),就是指使对象管理的数据不能直接从外部进行操作,数据的更新、查询等操作都必须通过调用对象的方法来完成。通过封装,可以防止因把非法数据设置给对象而使程序产生异常的情况发生。
为此,就需要编写不会让对象内部产生异常的方法。最理想的做法是,在定义方法时就考虑如何避免错误的发生,而不是在使用方法编写程序时才开始注意。
Ruby 对象在默认情况下是强制被封装的,因此无法从外部直接访问 Ruby 对象的实例变量。虽然有像 `attr_accessor`(8.2.4 节)这样简单定义访问级别的方法,但也不要过度使用,建议只在需要公开时才使用。
封装的另外一个好处就是,可以隐藏对象内部数据处理的具体细节,把内部逻辑抽象地表现出来。例如,通过使用 `Time` 类,就可以进行从系统获取当前时间、从时间里提取年月日等操作。
~~~
t = Time.now # 从系统获取当前时间
p t.year #=> 2013(从时间里提取年)
~~~
从系统获取当前时间时是如何处理的、`Time` 对象内部是以什么形式管理时间的、以及从时间中提取年时要进行何种运算等等,以上这些事情都由 `Time` 类的方法来实现。就算对象内部的数据结构改变了,只要公开给外部的方法名、功能等没有改变,类的使用者就完全不需要理会内部逻辑作出了怎样的修改,照常使用即可。相反,类的编写者只要提供的方法恰当,就可以直接修改类的内部逻辑,而不需要在意类的使用者。可见,封装对类的编写者和使用者来说都非常有好处。
-
**多态**
对象是数据及其处理的组合。对象知道数据是怎样被处理的。换句话说,各个对象都有自己独有的消息解释权。一个方法名属于多个对象(不同对象的处理结果也不一样)这种现象,用面向对象的术语来说,就是多态(polymorphism)。
例如,我们可以观察一下对 `Object` 类、`String` 类和 `Float` 类的各对象调用 `to_s` 方法的运行结果,可以看出,不同的类得到的结果是不一样的。
~~~
obj = Object.new # 对象(Object)
str = "Ruby" # 字符串(String)
num = Math::PI # 数值(Float)
p obj.to_s #=> "#<Object:0x07fa1d6bd1008>"
p str.to_s #=> "Ruby"
p num.to_s #=> "3.141592653589793"
~~~
三者的 `to_s` 方法名一样,含义也都是“以可以显示的形式把数据转换为字符串”,但实际的字符串转换方式却因对象而异(图 8.10)。`String` 类和 `Float` 类都是继承自 `Object` 类,也都重新定义了从 `Object` 类继承的 `to_s` 方法,并提供了更适合自己语义的 `to_s` 方法。
![{%}](http://www.ituring.com.cn/figures/2015/Ruby4/14.d08z.011.png)
※ 虽然方法名一样,但调用的却是各个类专用的版本。
**图 8.10 多态**
### **8.9.3 鸭子类型**
下面,我们来看一种结合对象特征,灵活运用多态的思考方法——鸭子类型(duck typing)。鸭子类型的说法来自于“能像鸭子那样走路,能像鸭子一样啼叫的,那一定就是鸭子”这句话。这句话的意思是,对象的特征并不是由其种类(类及其继承关系)决定的,而是由对象本身具有什么样的行为(拥有什么方法)决定的。例如,假设我们希望从字符串数组中取出元素,并将字母转换成小写后返回结果(代码清单 8.12)。
**代码清单 8.12 fetch_and_downcase.rb**
~~~
def fetch_and_downcase(ary, index)
if str = ary[index]
return str.downcase
end
end
ary = ["Boo", "Foo", "Woo"]
p fetch_and_downcase(ary, 1) #=> "foo"
~~~
实际上,除了数组外,我们也可以像下面那样,把散列传给该方法处理。
~~~
hash = {0 => "Boo", 1 => "Foo", 2 => "Woo"}
p fetch_and_downcase(hash, 1) #=> "foo"
~~~
`fetch_and_downcase` 方法对传进来的参数只有两个要求:
-
**能以 `ary[index]` 形式获取元素**
-
**获取的元素可以执行 `downcase` 方法**
只要参数符合这两个要求,`fetch_and_downcase` 方法并不关心传进来的到底是数组还是散列。
Ruby 中的变量没有限制类型,所以不会出现不是某个特定的类的对象,就不能给变量赋值的情况。因此,在程序开始运行之前,我们都无法知道变量指定的对象的方法调用是否正确。
这样的做法有个缺点,就是增加了程序运行前检查错误的难度。但是,从另外一个角度来看,则可以非常简单地使没有明确继承关系的对象之间的处理变得通用。只要能执行相同的操作,我们并不介意执行者是否一样;相反,虽然实际上是不同的执行者,但通过定义相同名称的方法,也可以实现处理通用化。这就是鸭子类型思考问题的方法。
利用鸭子类型实现处理通用化,并不要求对象之间有明确的继承关系,因此,要想灵活运用,可能还需要花不少功夫。例如刚才介绍的 `obj[index]` 的形式,就被众多的类作为访问内部元素的手段而使用。刚开始时,我们可以先有意识地留意这种简单易懂的方法,然后再由浅入深,慢慢地就可以抓住窍门了。
### **8.9.4 面向对象的例子**
接下来让我们通过一个实际的例子,来看看对象是如何被构造的。代码清单 8.13 是一个利用 `Net::HTTP` 类取得 Ruby 官网首页的 HTML,并将其输出到控制台的脚本。
**代码清单 8.13 http_get.rb**
~~~
1: require "net/http"
2: require "uri"
3: url = URI.parse("http://www.ruby-lang.org/ja/")
4: http = Net::HTTP.start(url.host, url.port)
5: doc = http.get(url.path)
6: puts doc
~~~
程序的第 1、2 行中引用了 `net/http` 库以及 `uri` 库。这样,我们就可以使用 `Net::HTTP` 类和 `URI` 模块了。第 3 行使用了 `URI` 模块的 `parse` 方法来解析 URL 的字符串,返回的结果是字符串解析后的 `URI::HTTP` 类的对象。根据 URL 的编写规则,URL 会被分解成多个属性。
~~~
require "uri"
url = URI.parse("http://www.ruby-lang.org/ja/")
p url.scheme #=> "http" (体系:URL 的种类)
p url.host #=> "www.ruby-lang.org" (主机名)
p url.port #=> "80" (端口号)
p url.path #=> "/ja/" (路径)
p url.to_s #=> "http://www.ruby-lang.org/ja/"
~~~
体系(scheme)是指使用哪种通信协议。连接网络上的服务器时,需要知道服务器的主机名以及端口号。路径用于定位服务器上管理的文件。`URI::HTTP` 类的作用就是,把 URL 字符串解析后分解出来的信息,以对象的形式再次整合在一起。
需要注意的是,模块名是 `URI` 而不是 `URL`。URL 指的是 URI 标识符中某种特定种类的东西。关于两者的关系,这里不再详细介绍,现阶段我们只需要知道 URL 是 URI 的一种就可以了。
让我们再次回到代码清单 8.13,在程序第 4 行,把主机名和端口号传给 `Net::HTTP` 类的 `start` 方法,并创建 `Net::HTTP` 对象。在程序第 5 行,对 `Net::HTTP` 的 `get` 方法指定路径,获取文档内容。最后,在程序第 6 行,把得到的文档内容输出到控制台。由于得到的文档内容是 `String` 对象,因此后续处理与 `Net::HTTP` 类没有关系。
调用 `Net::HTTP#get` 方法的时候,对象内部会做以下处理:
① **使用主机名和端口号,与服务器建立通信(叫做 socket,套接字)**
② **使用路径,创建代表请求信息的 `Net::HTTPRequest` 对象**
③ **对套接字写入请求信息**
④ **从套接字中读取数据,并将其保存到代表响应信息的 `Net::HTTPResponse` 对象中**
⑤ **利用 `Net::HTTPResponse` 本身提供的功能,解析响应信息,提取文档部分并返回。**
流程图如下所示。
![{%}](http://www.ituring.com.cn/figures/2015/Ruby4/14.d08z.012.png)
**图 8.11 http_get.rb 的流程图**
在这个例子中,URL 解析由 `URI::HTTP` 负责,网络连接由套接字负责,与信息交换相关的操作由 `Net::HTTPRequest` 和 `Net::HTTPResponse` 负责,通信中必要的套接字、请求、响应等相关操作由 `Net::HTTP` 负责。像这样,不同的对象各司其职,决定应该如何配置参数、该执行什么样的处理等事项。
这些事项不仅在新建程序时有用,在扩展、修改已有程序时也非常有用。对象之间通过方法交换信息,而至于这些信息在彼此内部是如何被处理的,则并不需要关心。在生成类时,我们只要牢记把适当的信息交给适当的方法处理,就可以设计出易于读写的程序。
重要的是如何自然地写出程序 5 这除了需要丰富的程序设计经验外,还需要拥有设计模式等类结构相关的知识。“自然”这样的说法可能有点夸张,但通过指把事物的外部特征作为参考依据,我们就可以使用与实际事物相近的模型去组织、构建程序。
5这里指符合人脑思维的程序。一般认为,人脑的思维方式是面向对象的。——译者注。
- 推荐序
- 译者序
- 前言
- 本书的读者对象
- 第 1 部分 Ruby 初体验
- 第 1 章 Ruby 初探
- 第 2 章 便利的对象
- 第 3 章 创建命令
- 第 2 部分 Ruby 的基础
- 第 4 章 对象、变量和常量
- 第 5 章 条件判断
- 第 6 章 循环
- 第 7 章 方法
- 第 8 章 类和模块
- 第 9 章 运算符
- 第 10 章 错误处理与异常
- 第 11 章 块
- 第 3 部分 Ruby 的类
- 第 12 章 数值类
- 第 13 章 数组类
- 第 14 章 字符串类
- 第 15 章 散列类
- 第 16 章 正则表达式类
- 第 17 章 IO 类
- 第 18 章 File 类与 Dir 类
- 第 19 章 Encoding 类
- 第 20 章 Time 类与 Date 类
- 第 21 章 Proc 类
- 第 4 部分 动手制作工具
- 第 22 章 文本处理
- 第 23 章 检索邮政编码
- 附录
- 附录 A Ruby 运行环境的构建
- 附录 B Ruby 参考集
- 后记
- 谢辞