14 R6

Published

July 31, 2025

Modified

August 1, 2025

Introduction

本章介绍R6 OOP系统,它有两大特点:

  • R6使用了封装的OOP范式,意味着方法(method)属于对象(object)而不是泛型函数(generic),调用方法的范式为object$method()

  • R6对象是可改变的,意味着它们可以原地修改并具有引用语义。当你将一个R6对象赋值给另一个变量时,实际上是将指向该R6对象的引用来赋值给新变量。这样,任何对该对象所做的更改都会反映在所有引用它的变量中。

虽然R6 OOP系统与其他语言中的OOP范式相同,使用起来更容易上手,但它缺点就是不符合R的使用习惯,我们将在第16章中讨论它们。

Outline

  • 14.2节:介绍使用R6::R6Class()创建R6类,使用构造器$new()创建新的R6对象。
  • 14.3节:讨论R6的访问机制:私有域和主动域。
  • 14.4节:探讨R6的引用语义的影响。学习如何使用终结器自动清理初始化器中执行的任何操作,以及如何在另一个R6对象中将一个R6对象作为字段使用。
  • 14.5节:对比R6系统和RC系统。

Prerequisites

# install.packages("R6")
library(R6)

Classes and methods

R6::R6Class()函数可以同时构建类(class)和方法(method),同时也是R6包中唯一需要使用的函数。

R6Class()函数有两个极其重要的参数:

  • classname:类名,它不是必须的,但它改进了错误消息,并使得R6对象可以与S3类的泛型函数结合使用。R6 class名称通常使用UpperCamelCase命名法。
  • public:一个列表,包含类的属性(field)和方法,可以通过self$的方法获取。属性和方法通常使用snake_case命名法。
Accumulator <- R6Class(
  classname = "Accumulator",
  public = list(
    sum = 0,
    add = function(x = 1) {
      self$sum <- self$sum + x
      invisible(self)
    }
  )
)

在使用R6Class()创建对象时,需要始终将创建的结果赋值给与类名相同的变量。

Accumulator
#> <Accumulator> object generator
#>   Public:
#>     sum: 0
#>     add: function (x = 1) 
#>     clone: function (deep = FALSE) 
#>   Parent env: <environment: R_GlobalEnv>
#>   Locked objects: TRUE
#>   Locked class: FALSE
#>   Portable: TRUE

可以使用object$new()的方法创建新对象。

x <- Accumulator$new()

同样地,使用$获取对的属性和方法。

x$add(4)
x$sum
#> [1] 4

后续我们以()区分$获取的时属性还是方法——$add()表示方法,$sum表示属性。

Method chaining

$add()方法返回的是self而不是$sum时,我们就可以使用方法链(method chaining),类似管道符。通常我们使用return()来返回,但鉴于self的隐私性,这里使用invisible()

x$add(10)$add(10)$sum
#> [1] 24

x$
  add(10)$
  add(10)$
  sum
#> [1] 44

Important methods

对大多数R6对象,有两个重要的方法需要定义——$initialize()$print()。它们非必须,但会提升对象的使用性。

$initialize()方法会覆盖默认的$new()方法。例如下面的“Person”类,我在$initialize()方法中判断了$name属性只能是单一的字符串,$age属性只能是单一的数字。如果你有更多对输入的检查,将它们放在$validate()方法中更合适。

Person <- R6Class("Person", list(
  name = NULL,
  age = NA,
  initialize = function(name, age = NA) {
    stopifnot(is.character(name), length(name) == 1)
    stopifnot(is.numeric(age), length(age) == 1)

    self$name <- name
    self$age <- age
  }
))

hadley <- Person$new("Hadley", age = "thirty-eight")
#> Error in initialize(...): is.numeric(age) is not TRUE

hadley <- Person$new("Hadley", age = 38)

$print()方法会覆盖默认的print()方法,允许你自定义对象的打印输出。和其他R6对象的方法一样,最终使用invisible()来返回。

Person <- R6Class("Person", list(
  name = NULL,
  age = NA,
  initialize = function(name, age = NA) {
    self$name <- name
    self$age <- age
  },
  print = function(...) {
    cat("Person: \n")
    cat("  Name: ", self$name, "\n", sep = "")
    cat("  Age:  ", self$age, "\n", sep = "")
    invisible(self)
  }
))

hadley2 <- Person$new("Hadley")
hadley2
#> Person: 
#>   Name: Hadley
#>   Age:  NA

Adding methods after creation

可以使用$set()修改R6对象的属性和方法。

Accumulator <- R6Class("Accumulator")
Accumulator$set("public", "sum", 0)
Accumulator$set("public", "add", function(x = 1) {
  self$sum <- self$sum + x
  invisible(self)
})

需要注意:对象添加新的属性和方法后,只有用它创建新的对象时才会添加,已经创建好的对象不会添加新的属性和方法。

Inheritance

参数inherit允许创建继承关系。

AccumulatorChatty <- R6Class(
  "AccumulatorChatty",
  inherit = Accumulator,
  public = list(
    add = function(x = 1) {
      cat("Adding ", x, "\n", sep = "")
      super$add(x = x)
    }
  )
)

x2 <- AccumulatorChatty$new()
x2$add(10)$add(1)$sum
#> Adding 10
#> Adding 1
#> [1] 11

拥有继承关系的子类可以使用父类的方法,但时如何名称相同发生覆盖,则需要使用suppe$来方法父类方法,这与上一章中的NextMethod()函数类似。

Introspection

每一个R6对象中都含有一个S3类。这意味着我们可以对R6对象使用一些S3类常用的函数,上述提到的$print()方法,本质上是print.R6()函数。

class()可以确定是否属于R6类。

class(hadley2)
#> [1] "Person" "R6"

names()可以查看R6类的所有属性和方法名。下面的.__enclos_env__是R6内部的实现细节(R6 = S3 + env)。

names(hadley2)
#> [1] ".__enclos_env__" "age"             "name"            "clone"          
#> [5] "print"           "initialize"

Exercises

  1. Create a bank account R6 class that stores a balance and allows you to deposit and withdraw money. Create a subclass that throws an error if you attempt to go into overdraft. Create another subclass that allows you to go into overdraft, but charges you a fee.
solution
Bank <- R6Class("Bank", list(
  name = "",
  balance = 0,
  initialize = function(name, balance = 0) {
    stopifnot(is.character(name), length(name) == 1)
    stopifnot(is.numeric(balance), length(balance) == 1)

    self$name <- name
    self$balance <- balance
  },
  print = function(...) {
    cat("Bank: \n")
    cat("  Name: ", self$name, "\n", sep = "")
    cat("  Balance:  ", self$balance, "\n", sep = "")
    invisible(self)
  },
  deposit = function(x) {
    self$balance <- self$balance + x
    invisible(self)
  },
  withdraw = function(x) {
    self$balance <- self$balance - x
    invisible(self)
  }
))

a <- Bank$new(name = "a", balance = 1000)
a$deposit(500)$withdraw(2000)
a
#> Bank: 
#>   Name: a
#>   Balance:  -500

Bank2 <- R6Class("Bank2", inherit = Bank, public = list(
  withdraw = function(x) {
    if (self$balance - x < 0) {
      stop("Insufficient funds", call. = FALSE)
    }
  }
))

b <- Bank2$new(name = "b", balance = 1000)
b$deposit(500)$withdraw(2000)
#> Error: Insufficient funds
b
#> Bank: 
#>   Name: b
#>   Balance:  1500

Bank3 <- R6Class("Bank3", inherit = Bank, public = list(
  withdraw = function(x) {
    if (self$balance - x < 0) {
      message("charge of $5 applied")
      self$balance <- self$balance - x - 5
    }
  }
))

c <- Bank3$new(name = "c", balance = 1000)
c$deposit(500)$withdraw(2000)
#> charge of $5 applied
c
#> Bank: 
#>   Name: c
#>   Balance:  -505
  1. Create an R6 class that represents a shuffled deck of cards. You should be able to draw cards from the deck with $draw(n), and return all cards to the deck and reshuffle with $reshuffle(). Use the following code to make a vector of cards.
suit <- c("♠", "♥", "♦", "♣")
value <- c("A", 2:10, "J", "Q", "K")
cards <- paste0(rep(value, 4), suit)
solution
ShuffledDeck <- R6Class(
  classname = "ShuffledDeck",
  public = list(
    deck = NULL,
    initialize = function(deck = cards) {
      self$deck <- sample(deck)
    },
    reshuffle = function() {
      self$deck <- sample(cards)
      invisible(self)
    },
    n = function() {
      length(self$deck)
    },
    draw = function(n = 1) {
      if (n > self$n()) {
        stop("Only ", self$n(), " cards remaining.", call. = FALSE)
      }

      output <- self$deck[seq_len(n)]
      self$deck <- self$deck[-seq_len(n)]
      output
    }
  )
)

my_deck <- ShuffledDeck$new()
my_deck$draw(52)
#>  [1] "Q♦"  "5♥"  "K♦"  "6♣"  "K♠"  "2♠"  "Q♥"  "J♣"  "9♣"  "J♦"  "6♦"  "5♠" 
#> [13] "K♣"  "2♦"  "8♥"  "A♥"  "10♠" "4♦"  "10♥" "7♦"  "9♦"  "3♠"  "3♥"  "7♥" 
#> [25] "K♥"  "5♦"  "7♣"  "8♠"  "A♣"  "10♣" "9♠"  "6♥"  "J♠"  "2♥"  "9♥"  "A♦" 
#> [37] "8♣"  "A♠"  "3♦"  "8♦"  "Q♠"  "4♠"  "4♣"  "3♣"  "4♥"  "6♠"  "J♥"  "Q♣" 
#> [49] "5♣"  "7♠"  "10♦" "2♣"
my_deck$draw(10)
#> Error: Only 0 cards remaining.
my_deck$reshuffle()$draw(5)
#> [1] "10♣" "5♦"  "8♥"  "K♦"  "2♠"
my_deck$reshuffle()$draw(5)
#> [1] "K♦" "4♥" "9♣" "J♠" "K♥"

Controlling access

R6Class()函数有两个与public参数类似的参数:

  • private:创建的R6对象的私有属性和方法,只允许对象内部访问。
  • active:创建的R6对象的动态属性,通过 accessor 函数访问。

Privacy

private参数创建的私有属性和方法有两个特点:

  • 创建方式与public参数一样,都是一个带有name的list。
  • 在对象内部调用时,需要使用private$前缀。

下面是一个私有属性示例:

Person <- R6Class("Person",
  public = list(
    initialize = function(name, age = NA) {
      private$name <- name
      private$age <- age
    },
    print = function(...) {
      cat("Person: \n")
      cat("  Name: ", private$name, "\n", sep = "")
      cat("  Age:  ", private$age, "\n", sep = "")
    }
  ),
  private = list(
    age = NA,
    name = NULL
  )
)

hadley3 <- Person$new("Hadley")
hadley3
#> Person: 
#>   Name: Hadley
#>   Age:  NA
hadley3$name
#> NULL

相交于其他语言,私有方法在R语言中通常不是很重要。

Active fields

动态属性看起来像是公共属性,但实际是由一个active binding函数定义。active binding函数只有一个参数value,如果参数missing(), 则检索该值;否则,将对其进行修改。

下例定义了动态属性random,每次访问时,会返回一个随机数。

Rando <- R6::R6Class("Rando", active = list(
  random = function(value) {
    if (missing(value)) {
      runif(1)
    } else {
      stop("Can't set `$random`", call. = FALSE)
    }
  }
))
x <- Rando$new()
x$random(3)
#> Error: attempt to apply non-function
x$random
#> [1] 0.197574
x$random <- 31
#> Error: Can't set `$random`

动态属性可以使静态属性看起来像公共属性。例如下例中,我们创建了只读的属性age和能确保字符串长度为1的属性name

Person <- R6Class("Person",
  private = list(
    .age = NA,
    .name = NULL
  ),
  active = list(
    age = function(value) {
      if (missing(value)) {
        private$.age
      } else {
        stop("`$age` is read only", call. = FALSE)
      }
    },
    name = function(value) {
      if (missing(value)) {
        private$.name
      } else {
        stopifnot(is.character(value), length(value) == 1)
        private$.name <- value
        self
      }
    }
  ),
  public = list(
    initialize = function(name, age = NA) {
      private$.name <- name
      private$.age <- age
    }
  )
)

hadley4 <- Person$new("Hadley", age = 38)
hadley4$name
#> [1] "Hadley"
hadley4$name <- "Hadley2"
hadley4$name <- 10
#> Error in (function (value) : is.character(value) is not TRUE
hadley4$age <- 20
#> Error: `$age` is read only

子类无法访问到父类的私有属性,但是可以访问到父类的私有方法:

A <- R6Class(
  classname = "A",
  private = list(
    field = "foo",
    method = function() {
      "bar"
    }
  )
)

B <- R6Class(
  classname = "B",
  inherit = A,
  public = list(
    test = function() {
      cat("Field:  ", super$field, "\n", sep = "")
      cat("Method: ", super$method(), "\n", sep = "")
    }
  )
)

B$new()$test()
#> Field:  
#> Method: bar

Reference semantics

R6 OOP系统与其他系统的最大不同就是它的引用语义。引用语义意味着对象被修改时不会被复制。

y1 <- Accumulator$new()
y2 <- y1

y1$add(10)
c(y1 = y1$sum, y2 = y2$sum)
#> y1 y2 
#> 10 10

如果你想要复制对象,你需要使用$clone()方法,添加参数deep = TRUE可以克隆嵌套的对象。

y1 <- Accumulator$new()
y2 <- y1$clone()

y1$add(10)
c(y1 = y1$sum, y2 = y2$sum)
#> y1 y2 
#> 10  0

引用语义的使用同样会带来其他结果:

  • 需要更多的上下文才能理解R6对象。
  • 考虑何时删除 R6对象是有意义的,你可以编写$finalize()来补充$initialize(),。
  • 如果某个属性是R6对象,则必须在$initialize()中创建它,而不是在R6Class()中。

Reasoning

通常,参考语义会导致代码更难推理。考虑下面的例子:

x <- list(a = 1)
y <- list(b = 2)

z <- f(x, y)

因为函数f内部无法修改外部的x,y,所以我们知道函数f只修改了z

但是想象x,y是一个R6对象:

x <- List$new(a = 1)
y <- List$new(b = 2)

z <- f(x, y)

函数f内部可以调用xy内部的属性或方法,并对它们进行修改。我们无法仅从z <- f(x, y)判断函数f是否修改了x,y,我们需要查看函数f内部的代码。

Finalizer

因为R6对象具有引用语义,所以删除一次就会完全删除对象(不发生修改即拷贝)。这意味着我们可以在R6对象被删除时,使用$finalize()执行某些清理工作(类似on.exit()),来补充$initialize()。如下例中,我们实例化一个创建临时文件对象,然后删除该实例,就会删除临时文件。

TemporaryFile <- R6Class(
  "TemporaryFile",
  public = list(
    path = NULL,
    initialize = function() {
      self$path <- tempfile()
    }
  ),
  private = list(
    finalize = function() {
      message("Cleaning up ", self$path)
      unlink(self$path)
    }
  )
)

tf <- TemporaryFile$new()
rm(tf)
gc() # 使用gc()才会触发,书中好像是rm(tf)就会触发。
#>           used (Mb) gc trigger  (Mb) max used  (Mb)
#> Ncells  949190 50.7    1892770 101.1  1892770 101.1
#> Vcells 2058420 15.8    8388608  64.0  3451412  26.4

R6 fields

当使用R6类作为另外一个R6类的属性时,必须在$initialize方法中初始化属性。因为在外部定义的属性,表示该属性在定义R6类时已经创建,后续的所有实例都会继承这个属性。例如下面案例:我们想每次创建临时数据库时都创建一个临时文件,如果在外部定义属性file,实例db_adb_b都会继承这个属性,这样db_adb_b的属性file都指向同一个文件。

TemporaryDatabase <- R6Class(
  "TemporaryDatabase",
  public = list(
    con = NULL,
    file = TemporaryFile$new(),
    initialize = function() {
      self$con <- DBI::dbConnect(RSQLite::SQLite(), path = file$path)
    }
  ),
  private = list(
    finalize = function() {
      DBI::dbDisconnect(self$con)
    }
  )
)

db_a <- TemporaryDatabase$new()
db_b <- TemporaryDatabase$new()

db_a$file$path == db_b$file$path
#> [1] TRUE

相反,使用$initialize()方法,在创建实例时,始终会重新创建属性file

TemporaryDatabase <- R6Class(
  "TemporaryDatabase",
  public = list(
    con = NULL,
    file = NULL,
    initialize = function() {
      self$file <- TemporaryFile$new()
      self$con <- DBI::dbConnect(RSQLite::SQLite(), path = file$path)
    }
  ),
  private = list(
    finalize = function() {
      DBI::dbDisconnect(self$con)
    }
  )
)

db_a <- TemporaryDatabase$new()
db_b <- TemporaryDatabase$new()

db_a$file$path == db_b$file$path
#> [1] FALSE

Why R6?

R6 OOP系统相较于 RC OOP系统的一些优势:

  • R6 更简单。R6 基于S3,RC 基于S4。
  • R6 有全面的文档。https://r6.r-lib.org/
  • R6 提供了一种更简单的跨包子类化机制,这种机制无需思考就能正常工作。
  • R6 对属性方法的管理更加明确。
  • R6 更快。
  • RC 与 base R 绑定,意味着你需要修改不同R版本的bug。
Back to top