内容


使用 R 编写统计程序,第 3 部分

可重用和面向对象编程

了解 R 的底层特性

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 使用 R 编写统计程序,第 3 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:使用 R 编写统计程序,第 3 部分

敬请期待该系列的后续内容。

本系列的 前两期 研究了 R 在 “真实环境” 中的使用方法。我们使用这几期的合作者收集的大量温度数据研究了各种统计分析和制图功能。正如前几篇文章中提到的,我们实际上只接触到了 R 中丰富的统计库的皮毛。

在本文中,我希望把对进一步统计分析本身 的讨论放在一边(这在很大程度上是因为我自己还没有掌握决定最相关技术所需的统计知识;我以前的合作者 Brad Huntting 和许多读者在这些方面比我的知识丰富)。为了补充前两篇文章中提供的富统计概念,我希望带领读者探索一下 R 的底层语言设施。前两篇文章主要针对 R 的功能性编程方面;通过这些文章,读者会更熟悉面向对象语言。

另外,以前我们只以相当特定的 方式讨论了 R。在本期中,我将讨论如何为 R 开发创建可重用的和模块化的组件。

回到基础

在讨论 R 的面向对象概念之前,我们先回顾和澄清一下 R 的数据和函数概念。关于数据要记住的主要概念是 “所有东西都是向量”。尽管对象从表面上看与向量(矩阵、数组、data.frames)不同,但是对象实际上只是向量加上额外的(可变的)属性,让 R 能够以特殊方式处理它们。

维(拼写为 dim)是(某些)R 向量最重要的属性之一。函数 matrix()array()dim() 是用于设置向量的维的简单函数。R 的 OOP 系统类似于把某些东西压缩在对象的 class 属性中。

我们先通过清单 1 中的代码回顾一下维的概念:

清单 1. 创建向量并分配维
> v = 1:1000
> typeof(v)
[1] "integer"
> attributes(v)
NULL
> dim(v) = c(10,10,10)  # (Re)dimension
> attributes(v)
$dim
[1] 10 10 10
> v2 = matrix(1:1000, nrow=100, ncol=10)
> typeof(v2)
[1] "integer"
> attributes(v2)
$dim
[1] 100  10
> attr(v2,'dim') = c(10,10,10)  # Redimension
> attributes(v2)
$dim
[1] 10 10 10

简单地说,将 dim 属性附着到向量上有好几种语法,但是这些语法在本质上做的事是一样的。

关于 R 的 “所有东西都是向量” 方式,容易引起混乱的一点是行操作和列操作可能不符合直觉。例如,以下代码创建一个 2D 数组(矩阵)并操作单列或单行:

清单 2. 在矩阵向量上按行操作
> m = matrix(1:12, nrow=3, ncol=4)
> m
     [,1] [,2] [,3] [,4]
[1,]    1    4    7   10
[2,]    2    5    8   11
[3,]    3    6    9   12
> sum(m)  # sum of all elements of m
[1] 78
> sum(m[1,])  # sum of first row
[1] 22

但是,如果希望创建一个向量来求每行的总和,您可能会编写下面这样的代码:

清单 3. 执行多个行操作的错误方式
> sum(m[c(1,2,3),])  # NOT sum of each row
[1] 78

可能会 在这里构造一个循环,但是这与 R 的功能和面向向量的操作不一致。实际上,应该使用函数 apply()

清单 4. 用 apply() 函数进行行操作
> apply(m, 1, sum) # by row
[1] 22 26 30
> apply(m, 2, sum) # by column
[1]  6 15 24 33
> apply(m, c(1,2), sum) # by column AND row (sum of each single cell)
     [,1] [,2] [,3] [,4]
[1,]    1    4    7   10
[2,]    2    5    8   11
[3,]    3    6    9   12
# Kinda worthless to sum each single cell, but what about this:
> a = array(1:24,c(3,4,2))
> apply(a, c(1,2), sum)  # sum by depth in 3-D array
     [,1] [,2] [,3] [,4]
[1,]   14   20   26   32
[2,]   16   22   28   34
[3,]   18   24   30   36
> apply(a, c(3), sum)    # sum of each depth slice
[1]  78 222

无限序列

在实践中,一种非常有用的构造是无限数字序列。例如,前几期的合作者对蒙特卡罗积分技术做了一些分析,为此他需要一个无限长的随机数序列。要知道,所需的无限序列类型并不 仅仅是能够在需要时生成一个新数字;还需要能够引用以前引用的特定数字,而且是引用与以前相同的值。

显然,没有任何计算机语言或计算机能够存储无限序列 —— 它们能够存储的是惰性的无限制的 序列。只能在需要访问时在现有列表中添加更多元素。例如,在 Python 中,可以这样实现:创建一个类似列表的对象和一个定制的 .__getitem__() 方法,这个方法会根据需要扩展内部列表。在 Haskell 中,惰性是内置在语言中的 —— 实际上,所有东西都是惰性的。在我的 Haskell 教程(参考资料)中,我使用了一个创建所有 素数列表的示例:

清单 5. 用爱拉托逊斯筛法创建所有素数的 Haskell 列表
primes :: [Int]
primes = sieve [2 .. ]
sieve (x:xs) = x : sieve [y | y <- xs, (y `rem` x)/=0]

在无限序列方面, R 比 Python 更接近 Haskell。在需要时可以显式地构造更多的列表元素。我们需要等到 OOP 一节再让向量索引本身启动幕后的机制;仍然不需要涉及很多脚手架。

清单 6. 定义一个向量和一个动态扩展它的方法
inf_vector = rnorm(10, 0, 1)   # arbritrarily start w/ init 10 items
assure <- function(index) {
  extend_by = max(index-length(inf_vector), 0)
  extras = rnorm(extend_by, 0, 1)
  v <- c(inf_vector, extras)
  assign("inf_vector", v, env=.GlobalEnv)
  return(index)
}
getRand <- function(index) {
  assure(index)
  return(inf_vector[index])
}

首选的用法可能是通过包装器函数 getRand() 来访问值。注意,完全可以使用切片或计算的值以及单一索引:

清单 7. 使用包装器函数作为无限虚拟向量的代理
> getRand(3)                # Single index
[1] 0.5557101
> getRand(1:5)              # Range
[1] -0.05472011 -0.30419695  0.55571013  0.91667175 -0.40644081
> getRand(sqrt(c(4,16)))    # Computed index collection
[1] -0.3041970  0.9166717
> getRand(100)              # Force background vector extension
[1] 0.6577079

如果愿意,可以在使用元素前用 assure() 确保向量足够长:

清单 8. 在访问前扩展向量(如果需要的话)
> assure(2000)
[1] 2000
> inf_vector[1500]
[1] 1.267652

面向对象的 R

R 能够进行完全面向对象的编程,但是要理解这种方式,需要重新回顾一下您对 OOP 的认识。Java™ 和 C++ 或者 Python、Ruby 或 Smalltalk 等语言的用户可能已经对面向对象形成了一种相当明确的认识。这是对的,但是面向对象并不只限于一种模型。

R 的 OOP 方式基于泛型函数(generic function),而不是基于类层次结构。对于使用过 Lisp 的 CLOS 或者读过我对使用 Python 进行多种分派的讨论(参考资料)的读者,这个概念应该是熟悉的。不幸的是,R 的方式仍然是单一分派方式;在这方面,它与 C++、Java 等 “传统”语言相同。

我应该提醒您(尽管在本文中并不详细讨论这个问题):R 最近的版本附带有一个称为 methods 的包,这个包定义和操作所谓的 “形式方法(formal method)”。在许多方面,使用这些形式方法会采用传统 OOP 语言中的许多原理(和限制)。在任何情况下,R 中的形式 OOP 都基于本文中介绍的 “非形式 OOP”。methods 包仍然处于试验性阶段,但是经过某些调整后的版本肯定会出现在以后的 R 版本中。参考资料 提供了更多的背景知识。

要完全理解 OOP,有一点必须记住:OOP 其实并不一定意味着继承,而是更一般的分派决策(dispatch decision)。也就是说,在传统 OOP 语言中的 obj.method() 调用会通过对象的方法解析次序(method resolution order,MRO) 寻找 “第一个” 具有 .method() 方法的 obj 祖先类。

“第一个” 的意思比较微妙(参见 参考资料 中对 Python 中 MRO 设计的讨论)。R 采用相同的决策方式,但是它将继承的概念从内部转到了外部。R 并不用一系列 来定义和覆盖各种方法,而是创建一系列泛型函数,这些函数带有一个标记,指出它们应该在什么类型的对象上进行操作。

泛型函数

作为一个简单的例子,我们来创建一个称为 whoami() 的泛型函数以及一些要被分派标记的方法:

清单 9. 创建泛型函数和一些标记方法
#------------- Create a generic method
> whoami <- function(x, ...) UseMethod("whoami")
> whoami.foo <- function(x) print("I am a foo")
> whoami.bar <- function(x) print("I am a bar")
> whoami.default <- function(x) print("I don't know who I am")

这里的关键是,R 中的每个对象可能属于零个、一个或多个类。具体地说,任何给定对象的 MRO(相对于某个特定方法)仅仅是 class 属性(如果有的话)中的命名类向量。例如:

清单 10. 用类成员关系标记对象
> a = 1:10
> b = 2:20
> whoami(a)                 # No class assigned
[1] "I don't know who I am"
> attr(a,'class') <- 'foo'
> attr(b,'class') <- c('baz','bam','bar')
> whoami(a)
[1] "I am a foo"
> whoami(b)                 # Search MRO for defined method
[1] "I am a bar"
> attr(a,'class') <- 'bar'  # Change the class of 'a'
> whoami(a)
[1] "I am a bar"

与传统的继承式语言一样,对象不必将同一个类用于它调用的每个方法。按照传统方式,如果 Child 继承自 MomDad,那么 Child 类型的对象可能利用来自 Mom.meth1(),以及来自 Dad.meth2()。在 R 中可以很自然地实现这种结构,但是 MomDad 没有实质性内容,只是名称:

清单 11. 每个方法的分派解析
> meth1 <- function(x) UseMethod("meth1")
> meth1.Mom <- function(x) print("Mom's meth1")
> meth1.Dad <- function(x) print("Dad's meth1")
> meth2 <- function(x) UseMethod("meth2")
> meth2.Dad <- function(x) print("Dad's meth2")
> attr(a,'class') <- c('Mom','Dad')
> meth1(a)   # Even though meth1.Dad exists, Mom comes first for a
[1] "Mom's meth1"
> meth2(a)
[1] "Dad's meth2"

包含祖先

需要显式地指定对象的 MRO,而不是依赖于通过继承语法建立的隐式解析,这似乎不太方便。但是实际上,可以用很简单的包装器函数轻松地实现基于继承的 MRO。清单 11 中使用的 MRO 可能不是最好的(参见 参考资料 中 Simionato 的文章),但是它展示了这种思想:

清单 12. 用很简单的包装器函数实现基于继承的 MRO
char0 = character(0)
makeMRO <- function(classes=char0, parents=char0) {
    # Create a method resolution order from an optional
    # explicit list and an optional list of parents
    mro <- c(classes)
    for (name in parents) {
        mro <- c(mro, name)
        ancestors <- attr(get(name),'class')
        mro <- c(mro, ancestors[ancestors != name])
    }
    return(mro)
}
NewInstance <- function(value=0, classes=char0, parents=char0) {
    # Create a new object based on initial value,
    # explicit classes and parents (all optional)
    obj <- value
    attr(obj,'class') <- makeMRO(classes, parents)
    return(obj)
}
MaternalGrandma <- NewInstance()
PaternalGrandma <- NewInstance()
Mom <- NewInstance(classes='Mom', parents='MaternalGrandma')
Dad <- NewInstance(0, classes=c('Dad','Uncle'), 'PaternalGrandma')
Me <- NewInstance(value='Hello World', 'Me', c('Mom','Dad'))

清单 12 中代码的效果如下:

清单 13. 具有继承式 MRO 的对象
> print(Me)
[1] "Hello World"
attr(,"class")
[1] "Me"              "Mom"             "MaternalGrandma" "Dad"
[5] "Uncle"           "PaternalGrandma"

如果希望按照传统方式建立类/继承关系,那么需要包含创建的类的名称(比如在它的 classes 参数中包含 Mom)。实际上,如果每个类本身都是一个对象,那么以上系统更接近基于原型的 OOP 系统而不是基于类的系统。

而且,整个系统足够灵活,能够包含所有变体。如果愿意,可以自由地从实例对象分离出类对象 —— 可以通过附加另一个属性(比如 type 可以是 classinstance;并用实用函数进行检查)建立一种命名约定来区分类,或者通过其他方式。

再论无限向量

既然已经有了 OOP 设施,我们实际上可以更好地处理前面提到的无限向量。第一个解决方案是有效的,但是最好能够有更无缝的和不可见的无限向量。

R 中的操作符只是进行函数调用的简写方式;可以自由地专门化操作符在类上的行为,产生任何其他函数调用的效果。采用这种方式,可以修复第一个系统中的一些缺点:

  • 希望能够根据需要生成任意数量的无限向量。
  • 希望能够配置使用的随机分布。
  • 希望能够用另一个向量中的值对一个无限随机向量进行初始化。

现在就实现这些功能:

清单 14. 定义一个可索引的无限随机向量
"[.infinite_random" <- function(v, index) {
    name <- attr(v, 'name')
    rfunc <- attr(v, 'rfunc')
    extend_by = max(index-length(v), 0)
    extras = rfunc(extend_by)
    new <- c(v, extras)
    makeInfiniteRandomVector(name, v=new, rfunc)
    return(new[index])
}
unitnorm <- function(n) return(rnorm(n,0,1))
empty <- vector('numeric', 0)
makeInfiniteRandomVector <- function(name, v=empty, rfunc=unitnorm) {
    # Create an infinite vector
    # optionally extend existing vector, configurable rand func
    attr(v,'class') <- 'infinite_random'
    attr(v,'name') <- name
    attr(v,'rfunc') <- rfunc
    assign(name, v, env=.GlobalEnv)
}
makeInfiniteRandomVector('v')
# makeInfiniteRandomVector('inf_poisson', rfunc=my_poisson)
# Usage is just, e.g.: v[1]; v[10]; v[9:12]; etc.

索引已经由 R 定义为泛型函数,所以不需要调用 UseMethod() 来设置它;只需要根据需要进行新的专门化。同样,内置的 print() 函数也是泛型函数。可以像下面这样进行专门化:

清单 15. 打印无限向量
print.infinite_random <- function(v) {
    a_few = 5
    len = length(v)
    end_range = (len-a_few)+1
    cat('* Infinite Random Vector *\n')
    cat('[1] ', v[1:a_few], '...\n')
    cat('[')
    cat(end_range)
    cat('] ', v[end_range:len], '...\n')
}

此代码产生以下输出:

清单 16. 打印无限向量的示例
> v[1000]
[1] -1.341881
> print(v)
* Infinite Random Vector *
[1]  -0.6247392 1.308057 1.654919 1.691754 -2.251065 ...
[996]  1.027440 0.8376 -0.7066545 -0.7778386 -1.341881 ...

结束语

要用 R 编写通用的函数、对象和类,需要重新思考已经习惯的传统过程式编程和面向对象编程。前两期文章展示了一些特定的 统计研究示例,这实际上不要求重新思考原来的模型,但是如果希望重用代码,就需要理解泛型函数和可以用它们编写的 “外翻式” OOP(外翻形式实际上更一般化)。

关键是 OOP 只涉及两个问题:“调用什么代码” 以及 “如何做出这一决策”。并非只能使用传统语言(比如 C++、Objective C、Java、Ruby 或 Python)的特定语法来表达这些;要将注意力放在分派概念本身上。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux
ArticleID=163255
ArticleTitle=使用 R 编写统计程序,第 3 部分: 可重用和面向对象编程
publish-date=03092006