16.7 面向对象编程
到目前为止,我们看到的R程序大部分都是函数式编程风格的程序。也就是说函数是第一类对象,但是我们通常会使用一个数据分析脚本来逐行运行它们。
在某数情况下,使用面向对象编程(OOP)是非常有用的。这意味着数据和被允许操作的函数将被存储在类的内部。在管理大型复杂的程序时,这是一个很好的工具,而且特别适合于GUI的开发(在R或其他地方)。有关此主题的更多内容请参考Michael Lawrence的Programming Graphical User Interface in R一书。
R有六种不同的面向对象的系统。不过不用担心,对于新项目,你只需要其中的两种。
R中内建有三个系统 。
S3是一个轻量级的用于重载函数(例如,根据输入类型的不同调用不同版本的函数)的系统。
S4是一个功能完备的面向对象系统,但它非常笨重和难于调试。所以通常它只用于历史遗留代码中。
引用类是替代S4的现代系统。
还有其他三个系统可以在插件包里找到(但对于新代码,一般只使用引用类即可) 。
proto
是一个基于原型编程的轻量级的包装。R.oo
把S3扩展为一个完全成熟的面向对象的系统。OOP
是引用类的早期版本,现已不存在。
注意
在许多面向对象的编程语言中,函数被称为方法。在R中,这两个词是可以互换的,但“方法”往往用于面向对象的上下文。
16.7.1 S3类
有时,我们需要一个函数根据不同的输入类型有不同的行为。一个典型的例子就是print
函数,它为不同的变量给出了不同样式的输出。S3让我们对不同类型的变量调用不同的函数,而不必记住每个变量的名字。
print
函数非常简单——其实只需要一行:
## function (x, ...)
## UseMethod("print")
## <bytecode: 0x0000000018fad228>
## <environment: namespace:base>
它接受一个输入参数x
(以及…
,其中的省略号是必要的),并调用UseMethod("print")
。 UseMethod
检查x
的类,并寻找另一个名为print.class_of_x
的函数,如果能找到就调用它。如果找不到,它会尝试调用print.default
。
例如,如果我们想打印一个Date
变量,只需键入:
today <- Sys.Date()
print(today)
## [1] "2013-07-17"
print
将调用与Date
相关的函数print.Date
:
print.Date
## function (x, max = NULL, ...)
## {
## if (is.null(max))
## max <- getOption("max.print", 9999L)
## if (max < length(x)) {
## print(format(x[seq_len(max)]), max = max, ...)
## cat(" [ reached getOption(\"max.print\") -- omitted",
## length(x) - max, "entries ]\n")
## }
## else print(format(x), max = max, ...)
## invisible(x)
## }
## <bytecode: 0x0000000006dc19f0>
## <environment: namespace:base>
在print.Date
里,我们的日期先被转换为一个字符向量(通过format
),然后print
才再次被调用。因为没有print.character
函数存在,所以这时UseMethod
把任务委托给print.default
,这样我们的日期字符串就出现在控制台中。
警告
如果一个类的特定方法不能被发现,且没有默认的方法,那么将抛出错误。
你可以使用methods
函数查看函数中所有可用的方法。print
函数拥有超过100个方法,所以在这里我们只显示了前几个:
head(methods(print))
## [1] "print.abbrev" "print.acf" "print.AES"
## [4] "print.anova" "print.Anova" "print.anova.loglm"
methods(mean)
## [1] mean.Date mean.default mean.difftime mean.POSIXct mean.POSIXlt
## [6] mean.times* mean.yearmon* mean.yearqtr* mean.zoo*
##
## Non-visible functions are asterisked
提示
如果你在函数名中使用了点号,例如
data.frame
,那么在S3中的方法调用中就可能会混乱。例如,print.data.frame
的意思可能是一个输入参数为frame
的print.data
方法,而正确含义却是data.frame
对象的lower_under_case
或lowerCamelCase
是首选。
16.7.2 引用类
引用类比S3和S4更接近于经典的OOP系统,且对于使用过C++的类及其衍生物的人来说这会比较直观。
注
一个类(class)是变量如何被构建的通用模板。对象(object)是类的一个特定实例。举例来说,
1:10
是numeric
类的一个对象。
setRefClass
函数创建一个类的模板。以R的术语来说,它就是类生成器(class generator)。在其他一些语言中,这会被称为一个类工厂(class factory)。
让我们尝试构建一个2维点类来作为例子。这样使用setRefClass
:
my_class_generator <- setRefClass(
"MyClass",
fields = list(
#在此定义数据变量
),
methods = list(
#在此定义操作数据的函数
initialize = function(...)
{
#初始化是一个特殊的函数
#它在对象被创建时调用。
}
)
)
我们的类需要x
和y
坐标来存储它的位置,希望它们都是数值类型(numeric)。
在下例中,我们将x
和y
声明为数字:
注
如果不关心
x
和y
的类型,则把它们声明为特殊值ANY
。
point_generator <- setRefClass(
"point",
fields = list(
x = "numeric",
y = "numeric"
),
methods = list(
#TODO
)
)
这意味着,如果我们试图为它们分配另一种类型的值就会抛出错误。故意限制用户的输入可能听上去有些反常,但它能把你从之后可能出现的各种莫名其妙的错误中拯救出来。
接下来,我们需要添加一个initialize
方法。在每次创建point
对象时,它都会被调用。此方法接受x
和y
作为输入的数值,并将它们分配给x
和y
字段。有三种比较有趣的事情需要注意。
- 如果方法的第一行是一个字符串,那么它会被认为是此方法的帮助文本。
- 全局赋值运算符
<<-
用于字段赋值。本地分配符(使用<-
)只不过是在方法内创建局部变量。 - 最好的做法是不要让
initialize
函数接受任何参数,因为这会使各继承更容易一些,我们将在稍后看到这一点。这就是为什么x
和y
参数会有默认值的原因 6。
6如果你仍然感到困惑,NA_real_
是一个缺失值。通常对于缺失值来说我们只用NA
以及让R来判断它所需要的类型,但在这种情况下,因为指定的字段必须是数字,所以我们必须明确地说明其类型。
通过使用initialize
方法,我们的类生成器现在看起来是这样:
point_generator <- setRefClass(
"point",
fields = list(
x = "numeric",
y = "numeric"
),
methods = list(
initialize = function(x = NA_real_, y = NA_real_)
{
"Assign x and y upon object creation."
x <<- x
y <<- y
}
)
)
我们点类生成器已经完成,所以我们现在可以创建一个point
对象。每个生成器都有一个用于此目的的new
方法。作为对象创建流程的一部分,new
方法将调用initialize
(如果存在的话):
(a_point <- point_generator$new(5, 3))
## Reference class object of class "point"
## Field "x":
## [1] 5
## Field "y":
## [1] 3
生成器还有一个能返回你所指定的的帮助字符串的help
方法:
point_generator$help("initialize")
## Call:
## $initialize(x = , y = )
##
##
## Assign x and y upon object creation.
你也可以通过把类的方法包装在某个其他的函数里,这样就为面向对象的代码提供一个传统的接口。当你把代码发布给他人,又不想教其OOP时,这种方法比较有用:
create_point <- function(x, y)
{
point_generator$new(x, y)
}
目前,此类不太有趣,因为它还不会做任何事情。让我们重新为它定义一些更多的方法:
point_generator <- setRefClass(
"point",
fields = list(
x = "numeric",
y = "numeric"
),
methods = list(
initialize = function(x = NA_real_, y = NA_real_)
{
"Assign x and y upon object creation."
x <<- x
y <<- y
},
distanceFromOrigin = function()
{
"Euclidean distance from the origin"
sqrt(x ^ 2 + y ^ 2)
},
add = function(point)
{
"Add another point to this point"
x <<- x + point$x
y <<- y + point$y
.self
}
)
)
这些额外的方法属于point
对象,与属于类生成器的new
和help
不同(在面向对象编程的术语中,new
和help
是静态方法):
a_point <- create_point(3, 4)
a_point$distanceFromOrigin()
## [1] 5
another_point <- create_point(4, 2)
(a_point$add(another_point))
## Reference class object of class "point"
## Field "x":
## [1] 7
## Field "y":
## [1] 6
除了new
和help
,生成器类还有几个其他的方法。fields
和methods
能分别列出类的字段和方法,而lock
则能使一个字段只读:
point_generator$fields()
## x y
## "numeric" "numeric"
point_generator$methods()
## [1] "add" "callSuper" "copy"
## [4] "distanceFromOrigin" "export" "field"
## [7] "getClass" "getRefClass" "import"
## [10] "initFields" "initialize" "show"
## [13] "trace" "untrace" "usingMethods"
还有一些其他的方法可以从生成器对象或实例对象中调用。例如show
能打印对象,trace
和untrace
可让你在一个方法上使用trace
函数,export
能把对象转换为另一个种类型,而copy
则能生成拷贝。
参考类支持继承,其子类可扩展其功能。例如,我们可以创建一个新的、包含原来二维点类的三维点类,它包括了一个额外的z
坐标。
类可以使用contain
参数继承其他类的字段和方法:
three_d_point_generator <- setRefClass(
"three_d_point",
fields = list(
z = "numeric"
),
contains = "point", # 这一行让我们继承
methods = list(
initialize = function(x, y, z)
{
"Assign x and y upon object creation."
x <<- x
y <<- y
z <<- z
}
)
)
a_three_d_point <- three_d_point_generator$new(3, 4, 5)
此时,distanceFromOrigin
函数是错误的,因为它没有把z
维度考虑在内:
a_three_d_point$distanceFromOrigin() #错了!
## [1] 5
我们需要重写它,使它在新类中赋以新的意义。这可通过把相同的名称添加到类生成器中完成:
three_d_point_generator <- setRefClass(
"three_d_point",
fields = list(
z = "numeric"
),
contains = "point",
methods = list(
initialize = function(x, y, z)
{
"Assign x and y upon object creation."
x <<- x
y <<- y
z <<- z
},
distanceFromOrigin = function()
{
"Euclidean distance from the origin"
sqrt(x ^ 2 + y ^ 2 + z ^ 2)
}
)
)
为了使用新的定义,我们需要重新创建我们的点:
a_three_d_point <- three_d_point_generator$new(3, 4, 5)
a_three_d_point$distanceFromOrigin()
## [1] 7.071
有时候,我们想要使用父类(又名超类)的方法。callSuper
方法正是做这个用的,因此可(低效地)重写3D distanceFromOrigin
代码为:
distanceFromOrigin = function()
{
"Euclidean distance from the origin"
two_d_distance <- callSuper()
sqrt(two_d_distance ^ 2 + z ^ 2)
}
面向对象编程是一个很大的话题,即使仅限于引用类,它完全可独立成书。John Chambers(S语言创建者、R核心成员以及参考类代码的作者)目前正在写一本关于R的面向对象编程的书。在落实之前,?ReferenceClasses
的帮助页面是目前最权威的参考类的参考。