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函数非常简单——其实只需要一行:

  1. print
  2. ## function (x, ...)
  3. ## UseMethod("print")
  4. ## <bytecode: 0x0000000018fad228>
  5. ## <environment: namespace:base>

它接受一个输入参数x(以及,其中的省略号是必要的),并调用UseMethod("print")UseMethod检查x的类,并寻找另一个名为print.class_of_x的函数,如果能找到就调用它。如果找不到,它会尝试调用print.default

例如,如果我们想打印一个Date变量,只需键入:

  1. today <- Sys.Date()
  2. print(today)
  3. ## [1] "2013-07-17"

print将调用与Date相关的函数print.Date:

  1. print.Date
  2. ## function (x, max = NULL, ...)
  3. ## {
  4. ## if (is.null(max))
  5. ## max <- getOption("max.print", 9999L)
  6. ## if (max < length(x)) {
  7. ## print(format(x[seq_len(max)]), max = max, ...)
  8. ## cat(" [ reached getOption(\"max.print\") -- omitted",
  9. ## length(x) - max, "entries ]\n")
  10. ## }
  11. ## else print(format(x), max = max, ...)
  12. ## invisible(x)
  13. ## }
  14. ## <bytecode: 0x0000000006dc19f0>
  15. ## <environment: namespace:base>

print.Date里,我们的日期先被转换为一个字符向量(通过format),然后print才再次被调用。因为没有print.character函数存在,所以这时UseMethod把任务委托给print.default,这样我们的日期字符串就出现在控制台中。

警告

如果一个类的特定方法不能被发现,且没有默认的方法,那么将抛出错误。

你可以使用methods函数查看函数中所有可用的方法。print函数拥有超过100个方法,所以在这里我们只显示了前几个:

  1. head(methods(print))
  2. ## [1] "print.abbrev" "print.acf" "print.AES"
  3. ## [4] "print.anova" "print.Anova" "print.anova.loglm"
  4. methods(mean)
  5. ## [1] mean.Date mean.default mean.difftime mean.POSIXct mean.POSIXlt
  6. ## [6] mean.times* mean.yearmon* mean.yearqtr* mean.zoo*
  7. ##
  8. ## Non-visible functions are asterisked

提示

如果你在函数名中使用了点号,例如data.frame,那么在S3中的方法调用中就可能会混乱。例如,print.data.frame的意思可能是一个输入参数为frameprint.data方法,而正确含义却是data.frame对象的print方法。因此,对于新的函数名,使用lower_under_caselowerCamelCase是首选。

16.7.2 引用类

引用类比S3和S4更接近于经典的OOP系统,且对于使用过C++的类及其衍生物的人来说这会比较直观。

一个(class)是变量如何被构建的通用模板。对象(object)是类的一个特定实例。举例来说, 1:10numeric的一个对象

setRefClass函数创建一个类的模板。以R的术语来说,它就是类生成器(class generator)。在其他一些语言中,这会被称为一个类工厂(class factory)。

让我们尝试构建一个2维点类来作为例子。这样使用setRefClass

  1. my_class_generator <- setRefClass(
  2. "MyClass",
  3. fields = list(
  4. #在此定义数据变量
  5. ),
  6. methods = list(
  7. #在此定义操作数据的函数
  8. initialize = function(...)
  9. {
  10. #初始化是一个特殊的函数
  11. #它在对象被创建时调用。
  12. }
  13. )
  14. )

我们的类需要xy坐标来存储它的位置,希望它们都是数值类型(numeric)。

在下例中,我们将xy声明为数字:

如果不关心xy的类型,则把它们声明为特殊值ANY

  1. point_generator <- setRefClass(
  2. "point",
  3. fields = list(
  4. x = "numeric",
  5. y = "numeric"
  6. ),
  7. methods = list(
  8. #TODO
  9. )
  10. )

这意味着,如果我们试图为它们分配另一种类型的值就会抛出错误。故意限制用户的输入可能听上去有些反常,但它能把你从之后可能出现的各种莫名其妙的错误中拯救出来。

接下来,我们需要添加一个initialize方法。在每次创建point对象时,它都会被调用。此方法接受xy作为输入的数值,并将它们分配给xy字段。有三种比较有趣的事情需要注意。

  • 如果方法的第一行是一个字符串,那么它会被认为是此方法的帮助文本。
  • 全局赋值运算符<<-用于字段赋值。本地分配符(使用<-)只不过是在方法内创建局部变量。
  • 最好的做法是不要让initialize函数接受任何参数,因为这会使各继承更容易一些,我们将在稍后看到这一点。这就是为什么xy参数会有默认值的原因 6。

6如果你仍然感到困惑,NA_real_是一个缺失值。通常对于缺失值来说我们只用NA以及让R来判断它所需要的类型,但在这种情况下,因为指定的字段必须是数字,所以我们必须明确地说明其类型。

通过使用initialize方法,我们的类生成器现在看起来是这样:

  1. point_generator <- setRefClass(
  2. "point",
  3. fields = list(
  4. x = "numeric",
  5. y = "numeric"
  6. ),
  7. methods = list(
  8. initialize = function(x = NA_real_, y = NA_real_)
  9. {
  10. "Assign x and y upon object creation."
  11. x <<- x
  12. y <<- y
  13. }
  14. )
  15. )

我们点类生成器已经完成,所以我们现在可以创建一个point对象。每个生成器都有一个用于此目的的new方法。作为对象创建流程的一部分,new方法将调用initialize(如果存在的话):

  1. (a_point <- point_generator$new(5, 3))
  2. ## Reference class object of class "point"
  3. ## Field "x":
  4. ## [1] 5
  5. ## Field "y":
  6. ## [1] 3

生成器还有一个能返回你所指定的的帮助字符串的help方法:

  1. point_generator$help("initialize")
  2. ## Call:
  3. ## $initialize(x = , y = )
  4. ##
  5. ##
  6. ## Assign x and y upon object creation.

你也可以通过把类的方法包装在某个其他的函数里,这样就为面向对象的代码提供一个传统的接口。当你把代码发布给他人,又不想教其OOP时,这种方法比较有用:

  1. create_point <- function(x, y)
  2. {
  3. point_generator$new(x, y)
  4. }

目前,此类不太有趣,因为它还不会做任何事情。让我们重新为它定义一些更多的方法:

  1. point_generator <- setRefClass(
  2. "point",
  3. fields = list(
  4. x = "numeric",
  5. y = "numeric"
  6. ),
  7. methods = list(
  8. initialize = function(x = NA_real_, y = NA_real_)
  9. {
  10. "Assign x and y upon object creation."
  11. x <<- x
  12. y <<- y
  13. },
  14. distanceFromOrigin = function()
  15. {
  16. "Euclidean distance from the origin"
  17. sqrt(x ^ 2 + y ^ 2)
  18. },
  19. add = function(point)
  20. {
  21. "Add another point to this point"
  22. x <<- x + point$x
  23. y <<- y + point$y
  24. .self
  25. }
  26. )
  27. )

这些额外的方法属于point对象,与属于类生成器的newhelp不同(在面向对象编程的术语中,newhelp静态方法):

  1. a_point <- create_point(3, 4)
  2. a_point$distanceFromOrigin()
  3. ## [1] 5
  4. another_point <- create_point(4, 2)
  5. (a_point$add(another_point))
  6. ## Reference class object of class "point"
  7. ## Field "x":
  8. ## [1] 7
  9. ## Field "y":
  10. ## [1] 6

除了newhelp,生成器类还有几个其他的方法。fieldsmethods能分别列出类的字段和方法,而lock则能使一个字段只读:

  1. point_generator$fields()
  2. ## x y
  3. ## "numeric" "numeric"
  4. point_generator$methods()
  5. ## [1] "add" "callSuper" "copy"
  6. ## [4] "distanceFromOrigin" "export" "field"
  7. ## [7] "getClass" "getRefClass" "import"
  8. ## [10] "initFields" "initialize" "show"
  9. ## [13] "trace" "untrace" "usingMethods"

还有一些其他的方法可以从生成器对象或实例对象中调用。例如show能打印对象,traceuntrace可让你在一个方法上使用trace函数,export能把对象转换为另一个种类型,而copy则能生成拷贝。

参考类支持继承,其子类可扩展其功能。例如,我们可以创建一个新的、包含原来二维点类的三维点类,它包括了一个额外的z坐标。

类可以使用contain参数继承其他类的字段和方法:

  1. three_d_point_generator <- setRefClass(
  2. "three_d_point",
  3. fields = list(
  4. z = "numeric"
  5. ),
  6. contains = "point", # 这一行让我们继承
  7. methods = list(
  8. initialize = function(x, y, z)
  9. {
  10. "Assign x and y upon object creation."
  11. x <<- x
  12. y <<- y
  13. z <<- z
  14. }
  15. )
  16. )
  17. a_three_d_point <- three_d_point_generator$new(3, 4, 5)

此时,distanceFromOrigin函数是错误的,因为它没有把z维度考虑在内:

  1. a_three_d_point$distanceFromOrigin() #错了!
  2. ## [1] 5

我们需要重写它,使它在新类中赋以新的意义。这可通过把相同的名称添加到类生成器中完成:

  1. three_d_point_generator <- setRefClass(
  2. "three_d_point",
  3. fields = list(
  4. z = "numeric"
  5. ),
  6. contains = "point",
  7. methods = list(
  8. initialize = function(x, y, z)
  9. {
  10. "Assign x and y upon object creation."
  11. x <<- x
  12. y <<- y
  13. z <<- z
  14. },
  15. distanceFromOrigin = function()
  16. {
  17. "Euclidean distance from the origin"
  18. sqrt(x ^ 2 + y ^ 2 + z ^ 2)
  19. }
  20. )
  21. )

为了使用新的定义,我们需要重新创建我们的点:

  1. a_three_d_point <- three_d_point_generator$new(3, 4, 5)
  2. a_three_d_point$distanceFromOrigin()
  3. ## [1] 7.071

有时候,我们想要使用父类(又名超类)的方法。callSuper方法正是做这个用的,因此可(低效地)重写3D distanceFromOrigin代码为:

  1. distanceFromOrigin = function()
  2. {
  3. "Euclidean distance from the origin"
  4. two_d_distance <- callSuper()
  5. sqrt(two_d_distance ^ 2 + z ^ 2)
  6. }

面向对象编程是一个很大的话题,即使仅限于引用类,它完全可独立成书。John Chambers(S语言创建者、R核心成员以及参考类代码的作者)目前正在写一本关于R的面向对象编程的书。在落实之前,?ReferenceClasses的帮助页面是目前最权威的参考类的参考。