类和模块
类和模块
https://www.kancloud.cn/imxieke/ruby-base/107295
类是什么
类(class)是面向对象中一个重要的术语。
类和实例
类表示对象的种类。Ruby 中的对象都一定属于某个类。例如,我们常说的“数组对象”“数组”,实际上都是 Array
类的对象(实例)。还有字符串对象,实际上是 String
类的对象(实例)。
相同类的对象所使用的方法也相同。类就像是对象的雏形或设计图,决定了对象的行为。
我们在生成新的对象时,一般会用到各个类的 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
继承
我们把通过扩展已定义的类来创建新类称为继承。
假设我们需要编写一个在屏幕中显示时间的小程序。根据用户的喜好,这个小程序能以模拟时钟或者电子时钟的方式显示。
模拟时钟与电子时钟,两者只是在时间的表现形式上不一样,获取当前时间的方法以及闹钟等基本功能都是相同的。因此,我们可以首先定义一个拥有基本功能的时钟类,然后再通过继承来分别创建模拟时钟类和电子时钟类。
继承后创建的新类称为子类(subclass),被继承的类被称为父类(superclass)。
通过继承我们可以实现以下事情:
在不影响原有功能的前提下追加新功能。
重定义原有功能,使名称相同的方法产生不同的效果。
在已有功能的基础上追加处理,扩展已有功能。
此外,我们还可以利用继承来轻松地创建多个具有相似功能的类。
BasicObject
类是 Ruby 中所有类的父类,它定义了作为 Ruby 对象的最基本功能。
BasicObject
类是最最基础的类,甚至连一般对象需要的功能都没有定义。因此普通对象所需要的类一般都被定义为Object
类。字符串、数组等都是 Object 类的子类。
子类与父类的关系称为“is-a 关系”。例如,String
类与它的父类 Object
就是 is-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
类中定义过了,因此普通的对象都可以使用这两个方法。
类的创建
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
class 关键字
class
关键字在定义类时使用。以下是 class
关键字的一般用法:
class 类名
类的定义
end
类名的首字母必须大写。
initialize 方法
在 class
关键字中定义的方法为该类的实例方法。
其中,名为 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
方法。
实例变量与实例方法
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
可以在实例方法中引用实例变量,下面是 HelloWorld
类定义的 hello
方法引用 @name
的例子:
class HelloWorld
┊
def hello # 实例方法
puts "Hello, world. I am #{@name}."
end
end
通过以下方式调用 HelloWolrd
类定义的 hello
方法:
bob.hello
输出结果如下所示:
Hello, world. I am Bob.
存取器
在 Ruby 中,从对象外部不能直接访问实例变量或对实例变量赋值,需要通过方法来访问对象的内部。
为了访问 HelloWorld
类的 @name
实例变量,我们需要定义以下方法:
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
。只要指定实例变量名的符号(symbol),Ruby 就会自动帮我们定义相应的存取器。
定义 | 意义
| - attr_reader :name | 只读(定义 name 方法) attr_writer :name | 只写(定义 name= 方法) attr_accessor :name | 读写(定义以上两个方法)
也可以像下面这样只写一行代码,其效果与刚才的 name
方法以及 name=
方法的效果是一样的。
class HelloWorld
attr_accessor :name
end
Ruby 中一般把设定实例变量的方法称为 writer,读取实例变量的方法称为 reader,这两个方法合称为 accessor。另外,有时也把 reader 称为 getter,writer 称为 setter,合称为 accessor method4。
特殊变量 self
在实例方法中,可以用 self
这个特殊的变量来引用方法的接收者。接下来就让我们来看看其他的实例方法如何调用 name
方法。
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__
等。
类方法
方法的接收者就是类本身(类对象)的方法称为类方法。类方法的操作对象不是实例,而是类本身。
下面,让我们在 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
这种写法的类定义称为单例类定义,单例类定义中定义的方法称为单例方法。
常量
在 class
上下文中可以定义常量。
class HelloWorld
Version = "1.0"
┊
end
对于在类中定义的常量,我们可以像下面那样使用 ::
,通过类名来实现外部访问。
p HelloWorld::Version #=> "1.0"
类变量
以 @@
开头的变量称为类变量。类变量是该类所有实例的共享变量,这一点与常量类似,不同的是我们可以多次修改类变量的值。另外,与实例变量一样,从类的外部访问类变量时也需要存取器。不过,由于 attr_accessor
等存取器都不能使用,因此需要直接定义。
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
限制方法的调用
到目前为止,我们定义的方法,都能作为实例方法被任意调用,但是有时候我们可能并不希望这样。例如,只是为了汇总多个方法的共同处理而定义的方法,一般不会公开给外部使用。
Ruby 提供了 3 种方法的访问级别,我们可以按照需要来灵活调整。
public
……以实例方法的形式向外部公开该方法private
……在指定接收者的情况下不能调用该方法(只能使用缺省接收者的方式调用该方法,因此无法从实例的外部访问)protected
……在同一个类中时可将该方法作为实例方法调用
在修改方法的访问级别时,我们会为这 3 个关键字指定表示方法名的符号。
首先来看看使用 public
和 private
的例子
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
的方法,在同一个类(及其子类)中可作为实例方法使用,而在除此以外的地方则无法使用。
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)
定义了拥有 X、Y 坐标的 Point
类。在这个类中,实例中的坐标可以被外部读取,但不能被修改。为此,我们可以利用 protected
来实现交换两个坐标值的方法 swap
。
扩展类
在原有类的基础上添加方法
Ruby 允许我们在已经定义好的类中添加方法。下面,我们来试试给 String
类添加一个计算字符串单词数的实例方法 count_word
class String
def count_word
ary = self.split(/\s+/) # 用空格分割接收者
return ary.size # 返回分割后的数组的元素总数
end
end
str = "Just Another Ruby Newbie"
p str.count_word #=> 4
继承
利用继承,我们可以在不对已有的类进行修改的前提下,通过增加新功能或重定义已有功能等手段来创建新的类。
定义继承时,在使用 class
关键字指定类名的同时指定父类名。
class 类名< 父类名
类定义
end
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] #=> "土"
创建一个继承了 Array
类的 RingArray
类。RingArray
类只是重定义了读取数组内容时使用的 []
运算符。该程序通过 super
关键字调用父类中同名的方法(在本例中也就是 Array#[]
)。
对 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
alias 与 undef
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
undef
undef
用于删除已有方法的定义。与 alias
一样,参数可以指定方法名或者符号名。
undef 方法名 # 直接使用方法名
undef : 方法名 # 使用符号名
例如,在子类中希望删除父类定义的方法时可以使用 undef
。
单例类
通过利用单例类定义,就可以给对象添加方法(单例方法)。单例类定义被用于定义对象的专属实例方法。在下面的例子中,我们分别将 "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
类添加实例方法,就等于给所有的类都添加了该类方法。因此,只希望对某个实例添加方法时,就需要利用单例方法。
模块
模块是 Ruby 的特色功能之一。如果说类表现的是事物的实体(数据)及其行为(处理),那么模块表现的就只是事物的行为部分。模块与类有以下两点不同:
模块不能拥有实例
模块不能被继承
提供命名空间
所谓命名空间(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
像这样,通过把一系列相关的功能汇总在一个模块中,就可以集中管理相关的命名。
利用 Mix-in 扩展功能
Mix-in
就是将模块混合到类中。在定义类时使用 include
,模块里的方法、常量就都能被类使用。
我们可以把 MyClass1
和 MyClass2
中两者共通的功能定义在 MyModule
中。虽然有点类似于类的继承,但 Mix-in
可以更加灵活地解决下面的问题。
虽然两个类拥有相似的功能,但是不希望把它们作为相同的种类(Class)来考虑的时候
Ruby 不支持父类的多重继承,因此无法对已经继承的类添加共通的功能的时候
module MyModule
# 共通的方法等
end
class MyClass1
include MyModule
# MyClass1 中独有的方法
end
class MyClass2
include MyModule
# MyClass2 中独有的方法
end
创建模块
我们使用 module
关键字来创建模块。
语法与创建类时几乎相同。模块名的首字母必须大写。
module 模块名
模块定义
end
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.
常量
和类一样,在模块中定义的常量可以通过模块名访问。
p HelloModule::Version #=> "1.0"
方法的定义
和类一样,我们也可以在 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
。
Mix-in
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
这个顺序查找该方法,并执行第一个找到的方法。被包含的模块的作用就类似于虚拟的父类。
我们用 ancestors
方法和 superclass
方法调查类的继承关系
追加以下代码并执行,我们就可以通过 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
模块。这些类虽然没有继承这样的血缘关系,但是从“可以使用 each
方法遍历元素”这一点来看,可以说它们都拥有了某种相似甚至相同的属性。
单一继承的优点就是简单,不会因为过多的继承而导致类之间的关系变得复杂。但是另一方面,有时我们又会希望更加积极地重用已有的类,或者把多个类的特性合并为更高级的类,在那样的情况下,灵活使用单一继承和 Mix-in,既能使类结构简单易懂,又能灵活地应对各种需求。
查找方法的规则
首先,我们来了解一下使用 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
时,查找顺序也是线性的。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]
相同的模块被包含两次以上时,第 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]
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
则可以帮助我们跨过类,直接通过模块扩展对象的功能。
类与 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 的方法(包括单例方法)都一定属于某个类,并且作为接收者对象的实例方法被程序调用。从这个角度来说,人们只是为了便于识别接收者的类型,才分别使用了“实例方法”和 “类方法”这样的说法。
面向对象程序设计
“面向对象”这个概念,被广泛地应用在问题分析、系统设计或程序设计等系统和程序开发领域中。虽然这个概念目前被用在了各种各样的领域中,但首先使用这个概念的是与程序设计相关的领域。
由于本书是程序设计语言的入门书,因此这里并不会向其他领域过多延伸,而只会介绍与程序设计语言(当然就是 Ruby 了)相关的对象和面向对象方面的基础知识。
下面,我们暂且不讨论具体如何编写程序,而是先来了解一下编写程序时的一些思考方法。
对象是什么
包括 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
类的一部分来提供的,这样一来,数据以及处理数据的方法的组合也不会出现错误。
如果只是简单的数值处理,并不会有太大的问题,但大部分程序都需要更复杂的数据构成。例如,在一个处理图片的程序中,图片的长宽、颜色、包括图片本身的内容都需要转换为二进制数据。如果能把一个图片作为一个零部件来处理,那么像图册这样复杂的应用程序也就变得容易编写了
在开发大型程序的时候,若不将大量的数据整合到一起并根据一定的规则进行整理,程序处理本身的统一性会荡然无存。面向对象程序设计会把这种归类统一的数据作为各种各样的对象来看待。在对象中,数据以及处理数据的方法也是成套存在的,而且还负有处理数据的责任。
另外,就像网络上的服务器管理的文件一样,远程数据也可以作为程序的处理对象来考虑。在网络程序设计里,Web、邮件等不同的应用程序,都需要遵守各自不同的规范(也称为协议)。用程序来实现协议的情况下,一般会把管理消息格式、规范等的程序抽象成库(library)。Ruby 的库里就有现成的 Net::HTTP
、Net::POP
等类,可以非常轻松地编写网络程序。
面向对象的特征
封装
所谓封装(encapsulation),就是指使对象管理的数据不能直接从外部进行操作,数据的更新、查询等操作都必须通过调用对象的方法来完成。通过封装,可以防止因把非法数据设置给对象而使程序产生异常的情况发生。
为此,就需要编写不会让对象内部产生异常的方法。最理想的做法是,在定义方法时就考虑如何避免错误的发生,而不是在使用方法编写程序时才开始注意。
Ruby 对象在默认情况下是强制被封装的,因此无法从外部直接访问 Ruby 对象的实例变量。虽然有像
attr_accessor
这样简单定义访问级别的方法,但也不要过度使用,建议只在需要公开时才使用。封装的另外一个好处就是,可以隐藏对象内部数据处理的具体细节,把内部逻辑抽象地表现出来。例如,通过使用
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
方法名一样,含义也都是“以可以显示的形式把数据转换为字符串”,但实际的字符串转换方式却因对象而异。String
类和Float
类都是继承自Object
类,也都重新定义了从Object
类继承的to_s
方法,并提供了更适合自己语义的to_s
方法。
鸭子类型
下面,我们来看一种结合对象特征,灵活运用多态的思考方法——鸭子类型(duck typing)。鸭子类型的说法来自于“能像鸭子那样走路,能像鸭子一样啼叫的,那一定就是鸭子”这句话。这句话的意思是,对象的特征并不是由其种类(类及其继承关系)决定的,而是由对象本身具有什么样的行为(拥有什么方法)决定的。例如,假设我们希望从字符串数组中取出元素,并将字母转换成小写后返回结果。
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]
的形式,就被众多的类作为访问内部元素的手段而使用。刚开始时,我们可以先有意识地留意这种简单易懂的方法,然后再由浅入深,慢慢地就可以抓住窍门了。
面向对象的例子
接下来让我们通过一个实际的例子,来看看对象是如何被构造的。
利用 Net::HTTP
类取得 Ruby 官网首页的 HTML,并将其输出到控制台的脚本。
require "net/http"
require "uri"
url = URI.parse("http://www.ruby-lang.org/ja/")
http = Net::HTTP.start(url.host, url.port)
doc = http.get(url.path)
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 的一种就可以了。
在程序第 4 行,把主机名和端口号传给 Net::HTTP
类的 start
方法,并创建 Net::HTTP
对象。在程序第 5 行,对 Net::HTTP
的 get
方法指定路径,获取文档内容。最后,在程序第 6 行,把得到的文档内容输出到控制台。由于得到的文档内容是 String
对象,因此后续处理与 Net::HTTP
类没有关系。
调用 Net::HTTP#get
方法的时候,对象内部会做以下处理:
使用主机名和端口号,与服务器建立通信(叫做 socket,套接字)
使用路径,创建代表请求信息的
Net::HTTPRequest
对象对套接字写入请求信息
从套接字中读取数据,并将其保存到代表响应信息的
Net::HTTPResponse
对象中利用
Net::HTTPResponse
本身提供的功能,解析响应信息,提取文档部分并返回。
流程图如下所示。
在这个例子中,URL 解析由 URI::HTTP
负责,网络连接由套接字负责,与信息交换相关的操作由 Net::HTTPRequest
和 Net::HTTPResponse
负责,通信中必要的套接字、请求、响应等相关操作由 Net::HTTP
负责。像这样,不同的对象各司其职,决定应该如何配置参数、该执行什么样的处理等事项。
这些事项不仅在新建程序时有用,在扩展、修改已有程序时也非常有用。对象之间通过方法交换信息,而至于这些信息在彼此内部是如何被处理的,则并不需要关心。在生成类时,我们只要牢记把适当的信息交给适当的方法处理,就可以设计出易于读写的程序。
重要的是如何自然地写出程序 这除了需要丰富的程序设计经验外,还需要拥有设计模式等类结构相关的知识。“自然”这样的说法可能有点夸张,但通过指把事物的外部特征作为参考依据,我们就可以使用与实际事物相近的模型去组织、构建程序。