R語言物件導向指南

九茶發表於2015-09-21

原文連結:OO field guide


物件導向指南:

這一章主要介紹怎樣識別和使用 R 語言的物件導向系統(以下簡稱 OO)。R 語言主要有三種 OO 系統(加上基本型別)。本指南的目的不是讓你精通 R 語言的 OO,而是讓你熟悉各種系統,並且能夠準確地區分和使用它們。
OO 最核心的就是類和方法的思想,類在定義物件的行為時主要是通過物件的屬性以及它和其它類之間的關係。根據類的輸入不同,類對方法、函式的選擇也會不同。類的建造是有層次結構的:如果一個方法在子類中不存在,則使用父類中的方法;如果存在則繼承父類中方法。

三種 OO 系統在定義類和方法的時候有以下不同:

  • S3 實現的是泛型函式式 OO ,這與大部分的程式語言不同,像 Java、C++ 和 C# 它們實現的是訊息傳遞式的 OO 。如果是訊息傳遞,訊息(方法)是傳給一個物件,再由物件去決定呼叫哪個方法的。通常呼叫方法的形式是“物件名.方法名”,例如:canvas.drawRect(“blue”) 。而 S3 不同,S3 呼叫哪個方法是由泛型函式決定的,例如:drawRect(canvas, “blue”)。S3 是一種非正式的 OO 模式,它甚至都沒有正式定義類這個概念。
  • S4 與 S3 很相似,但是比 S3 正規。S4 與 S3 的不同主要有兩點:S4 對類有更加正式的定義(描述了每個類的表現形式和繼承情況,並且對泛型和方法的定義新增了特殊的輔助函式);S4 支援多排程(這意味著泛型函式在呼叫方法的時候可以選擇多個引數)。
  • Reference classes (引用類),簡稱 RC ,和 S3、S4有很大區別。RC 實現的是訊息傳遞式 OO ,所以方法是屬於類的,而不是函式。物件和方法之間用”$”隔開,所以呼叫方法的形式如:canvas$drawRect(“blue”) 。RC 物件也總是可變的,它用的不是 R 平常的 copy-on-modify 語義,而是做了部分修改。從而可以解決 S3、S4 難以解決的問題。

還有另外一種系統,雖然不是完全的物件導向,但還是有必要提一下:

  • base types(基本型別),主要使用C語言程式碼來操作。它之所以重要是因為它能為其它 OO 系統提供構建塊。

以下內容從基本型別開始,逐個介紹每種 OO 系統。你將學習到怎樣識別一個物件是屬於哪種 OO 系統、方法的呼叫和使用,以及在該 OO 系統下如何建立新的物件、類、泛型和方法。本章節的結尾也有講述哪種情況應該使用哪種系統。

前提:

你首先需要安裝 pryr 包來獲取某些函式:install.packages(“pryr”) 。

問題:

你是否已經瞭解本文要講述的內容?如果你能準確地回答出以下問題,則可以跳過本章節了。答案請見本文末尾的問題答案

  1. 你怎樣區分一個物件屬於哪種 OO 系統?
  2. 如何確定基本型別(如整型或者列表)的物件?
  3. 什麼是類的函式?
  4. S3 和 S4 之間的主要差異是什麼? S4 和 RC 之間最主要的差異又是什麼?

文章梗概:


基本型別:

基本上每個 R 物件都類似於描述記憶體儲存的 C 語言結構體,這個結構體包含了物件的所有內容(包括記憶體管理需要的資訊,還有物件的基本型別)。基本型別並不是真的物件系統,因為只有 R 語言的核心團隊才能建立新的型別。但結果新的基本型別竟然也很少見地被新增了:最近是在2011年,新增了兩個你從來沒在 R 裡面見過的奇異型別(NEWSXP 和 FREESXP),它們能夠有效地診斷出記憶體上的問題。在此之前,2005年為 S4 物件新增了一個特殊的基本型別(S4SXP)。

Data structures 章節講述了大部分普通的基本型別(原子向量和列表),但基本型別還包括 functions、environments,以及其它更加奇異的物件,如 names、calls、promises,之後你將會在本書中學到。你可以使用 typeof() 來了解物件的基本型別。但基本型別的名字在 R 中並不總是有效的,並且型別和 “is” 函式可能會使用不同的名字:

# The type of a function is "closure"
f <- function() {}
typeof(f)
#> [1] "closure"
is.function(f)
#> [1] TRUE

# The type of a primitive function is "builtin"
typeof(sum)
#> [1] "builtin"
is.primitive(sum)
#> [1] TRUE

你可能聽過 mode() 和 storage.mode(),我建議不要使用這兩個函式,因為它們只是 typeof() 返回值的別名,而且只使用與 S 語言。如果你想了解它們具體如何實現,可以去看一下它們的原始碼。

不同基本型別的函式一般都是用 C 語言編寫的,在排程時使用switch語句(例如:switch(TYPEOF(x)))。儘管你可能沒有寫過 C 語言,但理解基本型別仍然很有必要,因為其他系統都是在此基礎上的:S3 物件可以建立在所有基本型別上,S4 使用一個特殊的基本型別,而 RC 物件是 S4 和 environments(一個特殊的基本型別)的結合體。檢視物件是否是一個單純基本型別(即它不同時含 S3、S4、RC 的行為),使用 is.object(x) ,返回TRUE/FALSSE。


S3:

S3 是 R 語言的第一種也是最簡單的一種 OO 系統。它還是唯一一種在基礎包和統計包使用的 OO 系統,CRAN包中最平常使用的 OO 系統。

識別物件、泛型函式、方法:

你遇到的大部分物件都是 S3 物件。但不幸的是在 R 中並沒有可以簡單檢測一個方法是否是 S3 的方法。最接近的方法就是 is.object(x) & !isS4(x),即它是一個物件,但不是 S4 物件。一個更簡單的方法就是使用 pryr::otype() :

library(pryr)

df <- data.frame(x = 1:10, y = letters[1:10])
otype(df)    # A data frame is an S3 class
#> [1] "S3"
otype(df$x)  # A numeric vector isn't
#> [1] "base"
otype(df$y)  # A factor is
#> [1] "S3"

在 S3,方法是屬於函式的,這些函式叫做泛型函式,或簡稱泛型。S3 的方法不屬於物件或者類。這和大部分的程式語言都不同,但它確實是一種合法的 OO 方式。

你可以呼叫 UseMethod() 方法來檢視某個函式的原始碼,從而確定它是否是 S3 泛型。和 otype() 類似,prpy 也提供了 ftype() 來聯絡著一個函式(如果有的話)描述物件系統。

mean
#> function (x, ...) 
#> UseMethod("mean")
#> <bytecode: 0x24bfa50>
#> <environment: namespace:base>
ftype(mean)
#> [1] "s3"      "generic"

有些 S3 泛型,例如 [ 、sum()、cbind(),不能呼叫 UseMethod(),因為它們是用 C 語言來執行的。不過它們可以呼叫 C 語言的函式 DispatchGroup() 和 DispatchOrEval()。利用 C 程式碼進行方法呼叫的函式叫作內部泛型。可以使用 ?”internal generic” 檢視。

給定一個類,S3 泛型的工作是呼叫正確的 S3 方法。你可以通過 S3 方法的名字來識別(形如 generic.class())。例如,泛型 mean() 的 Date 方法為 mean.Date(),泛型print() 的向量方法為 print.factor() 。
這也就是為什麼現代風格不鼓勵在函式名字裡使用 “.” 的原因了。類的名字也不使用 “.” 。pryr::ftype() 可以發現這些異常,所以你可以用它來識別一個函式是 S3 方法還是泛型:

ftype(t.data.frame) # data frame method for t()
#> [1] "s3"     "method"
ftype(t.test)       # generic function for t tests
#> [1] "s3"      "generic"

你可以呼叫 methods() 來檢視屬於某個泛型的所有方法:

methods("mean")
#> [1] mean.Date     mean.default  mean.difftime mean.POSIXct  mean.POSIXlt 
#> see '?methods' for accessing help and source code
methods("t.test")
#> [1] t.test.default* t.test.formula*
#> see '?methods' for accessing help and source code

(除了在基礎包裡面定義的一些方法,大多數 S3 的方法都是不可見的使用 getS3method() 來閱讀它們的原始碼。)

你也可以列出一個給出類中包含某個方法的所有泛型:

methods(class = "ts")
#>  [1] aggregate     as.data.frame cbind         coerce        cycle        
#>  [6] diffinv       diff          initialize    kernapply     lines        
#> [11] Math2         Math          monthplot     na.omit       Ops          
#> [16] plot          print         show          slotsFromS3   time         
#> [21] [<-           [             t             window<-      window       
#> see '?methods' for accessing help and source code

你也可以從接下來的部分知道,要列出所有的 S3 類是不可能的。

定義類和建立物件:

S3 是一個簡單而特殊的系統,它對類沒有正式的定義。要例項化一個類,你只能拿一個已有的基礎物件,再設定類的屬性。你可以在建立類的時候使用 structure(),或者事後用 class<-():

# Create and assign class in one step
foo <- structure(list(), class = "foo")

# Create, then set class
foo <- list()
class(foo) <- "foo"

S3 物件的屬性通常建立在列表或者原子向量之上(你可以用這個屬性去重新整理你的記憶體屬性),你也能把函式轉成 S3 物件,其他基本型別要麼在 R 中很少見,要麼就是該語義不能很好地在屬性下執行。
你可以通過 class() 把類看作任意的物件,也可以通過 inherits(x, “classname”) 來檢視某個物件是否繼承自某個具體的類。

class(foo)
#> [1] "foo"
inherits(foo, "foo")
#> [1] TRUE

S3 物件所屬於的類可以被看成是一個向量,一個通過最重要的特性來描述物件行為的向量。例如物件 glm() 的類是 c(“glm”, “lm”),它表明著廣義線性模型的行為繼承自線性模型。類名通常是小寫的,並且應該避免使用 “.” 。否則該類名將會混淆為下劃線形式的 my_class,或者 CamelCase 寫法的 MyClass。

大多數的 S3 類都提供了建構函式:

foo <- function(x) {
  if (!is.numeric(x)) stop("X must be numeric")
  structure(list(x), class = "foo")
}

如果它是可用的,則你應該使用它(例如 factor() 和 data.frame())。這能確保你在創造類的時候使用正確的元件。建構函式的名字一般是和類名是相同的。

開發者提供了建構函式之後,S3 並沒有對它的正確性做檢查。這意味著你可以改變現有物件所屬於的類:

# Create a linear model
mod <- lm(log(mpg) ~ log(disp), data = mtcars)
class(mod)
#> [1] "lm"
print(mod)
#> 
#> Call:
#> lm(formula = log(mpg) ~ log(disp), data = mtcars)
#> 
#> Coefficients:
#> (Intercept)    log(disp)  
#>      5.3810      -0.4586

# Turn it into a data frame (?!)
class(mod) <- "data.frame"
# But unsurprisingly this doesn't work very well
print(mod)
#>  [1] coefficients  residuals     effects       rank          fitted.values
#>  [6] assign        qr            df.residual   xlevels       call         
#> [11] terms         model        
#> <0 rows> (or 0-length row.names)
# However, the data is still there
mod$coefficients
#> (Intercept)   log(disp) 
#>   5.3809725  -0.4585683

如果你在之前使用過其他的 OO 語言,S3 可能會讓你覺得很噁心。但令人驚訝的是,這種靈活性帶來的問題很少:雖然你能改變物件的型別,但你並不會這麼做。R 並不用提防自己:你可以很容易射自己的腳,只要你不把搶瞄在你的腳上並扣動扳機,你就不會有問題。

建立新的方法和泛型:

如果要新增一個新的泛型,你只要建立一個叫做 UseMethod() 的函式。UseMethod() 有兩個引數:泛型函式的名字和用來排程方法的引數。如果第二個引數省略了,則根據第一個引數來排程方法。但是沒有必要去省略 UseMethod() 的引數,你也不應該這麼做。

f <- function(x) UseMethod("f")

沒有方法的泛型是沒有用的。如果要新增方法,你只需要用 generic.class 建立一個合法的函式:

f.a <- function(x) "Class a"

a <- structure(list(), class = "a")
class(a)
#> [1] "a"
f(a)
#> [1] "Class a"

用同樣的方法可以對已有的泛型新增方法:

mean.a <- function(x) "a"
mean(a)
#> [1] "a"

如你所看到的,它並沒有確保類和泛型相容的檢查機制,它主要是靠程式設計者自己來確定自己的方法不會違反現有程式碼的期望。

方法排程:

S3 的方法排程比較簡單。UseMethod() 建立一個向量或者一個函式名字(例如:paste0(“generic”, “.”, c(class(x), “default”))),並逐個查詢。default 類作為回落的方法,以防其他未知類的情況。

f <- function(x) UseMethod("f")
f.a <- function(x) "Class a"
f.default <- function(x) "Unknown class"

f(structure(list(), class = "a"))
#> [1] "Class a"
# No method for b class, so uses method for a class
f(structure(list(), class = c("b", "a")))
#> [1] "Class a"
# No method for c class, so falls back to default
f(structure(list(), class = "c"))
#> [1] "Unknown class"

組泛型方法增加了一些複雜性,組泛型為一個函式實現複合泛型的多個方法提供了可能性。它們包含的四組泛型和函式如下:

  • Math: abs, sign, sqrt, floor, cos, sin, log, exp, …
  • Ops: +, -, *, /, ^, %%, %/%, &, |, !, ==, !=, <, <=, >=, >
  • Summary: all, any, sum, prod, min, max, range
  • Complex: Arg, Conj, Im, Mod, Re

組泛型是相對比較先進的技術,超出了本章的範圍。但是你可以通過 ?groupGeneric 檢視更多相關資訊。區分組泛型最關鍵的是要意識到 Math、Ops、Summary 和 Complex 並不是真正的函式,而是代表著函式。注意在組泛型中有特殊的變數 .Generic 提供實際的泛型函式呼叫。

如果你有複數類别範本的層次結構,那麼呼叫“父”方法是有用的。要準確定義它的意義的話有點難度,但如果當前方法不存在的話它基本上都會被呼叫。同樣的,你可以使用 ?NextMethod 檢視相關資訊。

因為方法是正規的 R 函式,所以你可以直接呼叫它:

c <- structure(list(), class = "c")
# Call the correct method:
f.default(c)
#> [1] "Unknown class"
# Force R to call the wrong method:
f.a(c)
#> [1] "Class a"

不過這種呼叫的方法和改變物件的類屬性一樣危險,所以一般都不這樣做。不要把上膛了的槍瞄在自己的腳上。使用上述方法的唯一原因是它可以通過跳過方法呼叫達到很大的效能改進,你可以檢視效能章節檢視詳情。

非 S3 物件也可以呼叫 S3 泛型,非內部的泛型會呼叫基本型別的隱式類。(因為效能上的原因,內部的泛型並不會這樣做。)確定基本型別的隱式類有點難,如下面的函式所示:

iclass <- function(x) {
  if (is.object(x)) {
    stop("x is not a primitive type", call. = FALSE)
  }

  c(
    if (is.matrix(x)) "matrix",
    if (is.array(x) && !is.matrix(x)) "array",
    if (is.double(x)) "double",
    if (is.integer(x)) "integer",
    mode(x)
  )
}
iclass(matrix(1:5))
#> [1] "matrix"  "integer" "numeric"
iclass(array(1.5))
#> [1] "array"   "double"  "numeric"

練習:

  1. 查閱 t() 和 t.test() 的原始碼,並證明 t.test() 是一個 S3 泛型而不是 S3 方法。如果你用 test 類建立一個物件並用它呼叫 t() 會發生什麼?
  2. 在 R 語言的基本型別中什麼類有 Math 組泛型?查閱原始碼,該方法是如何工作的?
  3. R 語言在日期時間上有兩種類,POSIXct 和 POSIXlt(兩者都繼承自 POSIXt)。哪些泛型對於這兩個類是有不同行為的?哪個泛型共享相同的行為?
  4. 哪個基本泛型定義的方法最多?
  5. UseMethod() 通過特殊的方式呼叫方法。請預測下列程式碼將會返回什麼,然後執行一下,並且檢視 UseMethod() 的幫助文件,推測一下發生了什麼。用最簡單的方式記下這些規則。
y <-1
g <-function(x) {
  y <-2UseMethod("g")
}
g.numeric <-function(x) y
g(10)

h <-function(x) {
  x <-10UseMethod("h")
}
h.character <-function(x) paste("char", x)
h.numeric <-function(x) paste("num", x)

h("a")
  1. 內部泛型不分配在基類型別的隱式類。仔細查閱 ?”internal generic”,為什麼下面例子中的 f 和 g 的長度不一樣?哪個函式可以區分 f 和 g 的行為?
f <- function() 1
g <- function() 2
class(g) <- "function"

class(f)
class(g)

length.function <- function(x) "function"
length(f)
length(g)


S4:

S4 工作的方式和 S3 比較相似,但它更加正式和嚴謹。方法還是屬於函式,而不是類。但是:

  • 類在描述欄位和繼承結構(父類)上有更加正式的定義。
  • 方法呼叫可以傳遞多個引數,而不僅僅是一個。
  • 出現了一個特殊的運算子——@,從 S4 物件中提取 slots(又名欄位)。

所以 S4 的相關程式碼都儲存在 methods 包裡面。當你互動執行 R 程式的時候這個包都是可用的,但在批處理的模式下則可能不可用。所以,我們在使用 S4 的時候一般直接使用 library(methods) 。
S4 是一種豐富、複雜的系統,並不是一兩頁紙能解釋完的。所以在此我把重點放在 S4 背後的物件導向思想,這樣大家就可以比較好地使用 S4 物件了。如果想要了解更多,可以參考以下文獻:

  • S4 系統在 Bioconductor 中的發展歷程
  • John Chambers 寫的《Software for Data Analysis》
  • Martin Morgan 在 stackoverflow 上關於 S4 問題的回答

識別物件、泛型函式和方法:

要識別 S4 物件 、泛型、方法還是很簡單的。對於 S4 物件:str() 將它描述成一個正式的類,isS4() 會返回 TRUE,prpy::otype() 會返回 “S4” 。對於 S4 泛型函式:它們是帶有很好類定義的 S4 物件。
常用的基礎包裡面是沒有 S4 物件的(stats, graphics, utils, datasets, 和 base),所以我們要從內建的 stats4 包新建一個 S4 物件開始,它提供了一些 S4 類和方法與最大似然估計:

library(stats4)

# From example(mle)
y <- c(26, 17, 13, 12, 20, 5, 9, 8, 5, 4, 8)
nLL <- function(lambda) - sum(dpois(y, lambda, log = TRUE))
fit <- mle(nLL, start = list(lambda = 5), nobs = length(y))

# An S4 object
isS4(fit)
#> [1] TRUE
otype(fit)
#> [1] "S4"

# An S4 generic
isS4(nobs)
#> [1] TRUE
ftype(nobs)
#> [1] "s4"      "generic"

# Retrieve an S4 method, described later
mle_nobs <- method_from_call(nobs(fit))
isS4(mle_nobs)
#> [1] TRUE
ftype(mle_nobs)
#> [1] "s4"     "method"

用帶有一個引數的 is() 來列出物件繼承的所有父類。用帶有兩個引數的 is() 來驗證一個物件是否繼承自該類:

is(fit)
#> [1] "mle"
is(fit, "mle")
#> [1] TRUE

你可以使用 getGenerics() 來獲取 S4 的所有泛型函式,或者使用 getClasses() 來獲取 S4 的所有類。這些類包括 S3 對 shim classes 和基本型別。另外你可以使用 showMethods() 來獲取 S4 的所有方法。

定義類和新建物件

在 S3,你可以通過更改類的屬性就可以改變任意一個物件,但是在 S4 要求比較嚴格:你必須使用 setClass() 定義類的宣告,並且用 new() 新建一個物件。你可以用特殊的語法 class?className(例如:class?mle)找到該類的相關文件。
S4 類有三個主要的特性:

  • 名字:一個字母-數字的類識別符號。按照慣例,S4 類名稱使用 UpperCamelCase 。
  • 已命名的 slots(欄位),它用來定義欄位名稱和允許類。例如,一個 person 類可能由字元型的名稱和數字型的年齡所表徵:list(name = "character", age = "numeric")
  • 父類。你可以給出多重繼承的多個類,但這項先進的技能增加了它的複雜性。

slotscontains,你可以使用setOldClass()來註冊新的 S3 或 S4 類,或者基本型別的隱式類。在slots,你可以使用特殊的ANY類,它不限制輸入。
S4 類有像 validity 方法的可選屬性,validity 方法可以檢驗一個物件是否是有效的,是否是定義了預設欄位值的 prototype 物件。使用?setClass檢視更多細節。
下面的例子新建了一個具有 name 欄位和 age 欄位的 Person 類,還有繼承自 Person 類的 Employee 類。Employee 類從 Person 類繼承欄位和方法,並且增加了欄位 boss 。我們呼叫 new() 方法和類的名字,還有name-values這樣成對的引數值來新建一個物件。

setClass("Person",
  slots = list(name = "character", age = "numeric"))
setClass("Employee",
  slots = list(boss = "Person"),
  contains = "Person")

alice <- new("Person", name = "Alice", age = 40)
john <- new("Employee", name = "John", age = 20, boss = alice)

大部分 S4 類都有一個和類名相同名字的建構函式:如果有,可以直接用它來取代 new()
要訪問 S4 物件的欄位,可以用 @ 或者 slot()

alice@age
#> [1] 40
slot(john, "boss")
#> An object of class "Person"
#> Slot "name":
#> [1] "Alice"
#> 
#> Slot "age":
#> [1] 40

@$ 等價,slot()[] 等價)
如果一個 S4 物件繼承自 S3 類或者基本型別,它會有特殊的屬性 .Data

setClass("RangedNumeric",
  contains = "numeric",
  slots = list(min = "numeric", max = "numeric"))
rn <- new("RangedNumeric", 1:10, min = 1, max = 10)
rn@min
#> [1] 1
rn@.Data
#>  [1]  1  2  3  4  5  6  7  8  9 10

因為 R 是響應式程式設計的語言,所以它可以隨時建立新的類或者重新定義現有類。這將會造成一個問題:當你在響應式地除錯 S4 的時候,如果你更改了一個類,你要知道你已經把該類的所有物件都更改了。

新建方法和泛型函式

S4 提供了特殊的函式來新建方法和泛型。setGeneric() 將產生一個新的泛型,或者把已有函式轉成泛型。

setGeneric("union")
#> [1] "union"
setMethod("union",
  c(x = "data.frame", y = "data.frame"),
  function(x, y) {
    unique(rbind(x, y))
  }
)
#> [1] "union"

如果你要重新建立了一個泛型,你需要呼叫 standardGeneric() :

setGeneric("myGeneric", function(x) {
  standardGeneric("myGeneric")
})
#> [1] "myGeneric"

S4 中的 standardGeneric() 相當於 UseMethod()


測試的答案

  1. 要確定一個物件屬於哪種物件導向系統,你可以用排除法,如果 !is.object(x) 返回 TRUE,那麼它是一個基本物件。如果 !isS4(x) 返回 TRUE,那麼它是一個 S3 。如果 !is(x, "refClass") 返回 TRUE, 那麼它是一個 S4 ,否則它是 RC 。
  2. typeof() 來確定基本型別的物件。
  3. 泛型函式呼叫特殊方法的時候主要是通過它的引數輸入來確定的,在 S3 和 S4 系統,方法屬於泛型函式,不像其他程式語言那樣屬於類。
  4. S4 比 S3 更加正式,並且支援多重繼承和多重排程,RC 物件的語義和方法是屬於類的,而不屬於函式。

相關文章