在 R 中推广对函数式 OOP 的支持



在 R 中推广对函数式 OOP 的支持

R 内置了对两个函数式面向对象编程 (OOP) 系统的支持:S3 和 S4,分别对应于 S 语言的第三版和第四版。这两个系统在很大程度上兼容;但是,它们是两个根本不同的系统,用户需要理解两个系统,开发人员在使他们的包与另一个包进行交互操作时需要处理两个系统,R 核心需要维护两个系统。

S7 是一个新的 OOP 系统,由 R-Core、Bioconductor、tidyverse/Posit、ROpenSci 和更广泛的 R 社区的代表合作开发,目标是统一 S3 和 S4 并促进互操作性。长期的希望是将 S7 纳入基础 R,在此期间,S7 的概念已在独立的 R 包中得到验证。

S7 的开发促使它的作者思考如何普遍启用像 S3、S4 和 S7 这样的函数式 OOP 系统,因为在这个领域还有很多创新的空间。仅考虑提到的三个系统,就会发现许多共性,包括

  1. 将类、泛型和方法具体化为对象(在 S4 和 S7 中),

  2. 通过泛型分派到基于一个或多个输入的实现(方法)来实现多态性,以及

  3. 将对象建模为组件集合,例如可以访问和修改的槽或属性。

这篇博文概述了已集成到基础 R 中的四个补丁,以使像 S7 这样的基于 S3 的包更容易实现上述特性。

这四个补丁中的每一个都引入了一个新的 S3 泛型

  • chooseOpsMethod()
  • %*%
  • nameOfClass()
  • @

这篇博文描述了这些新泛型、促使它们实现的 S7 中的特性,以及它们对 S7 之外的更广泛的 R 社区的效用。这些增强通常有利于集成或实现替代 OOP 系统的包。为了说明这些额外的好处,我们使用 reticulate 包作为示例。Reticulate 将 Python 的 OOP 语义与 R 的 S3 系统桥接起来。

chooseOpsMethod()

S3 泛型只支持对它们的第一个参数进行分派,而 S4 泛型可以对任意数量的参数进行分派。S7 在 S3 分派之上实现了双重分派(即对两个参数进行多重分派)。S7 中的分派分两个阶段进行:S3 泛型首先对第一个参数分派到一个 S7 感知方法,然后调用支持多重分派的 S7 内部分派例程。这些是实现细节,当一切正常时,它们对 S7 用户是隐藏的。实现任何类型分派的一个普遍复杂之处在于,具有内部实现的基元和其他函数必须在 C 代码中进行内部分派。内部分派和多重分派在 Ops 组泛型中相交。

与其他 S3 泛型不同,由中缀二元运算符(如 +-* 等)组成的 Ops 组泛型支持对第一个或第二个参数上的 S3 方法进行分派。在 R 4.3.0 之前,如果两个参数会导致对不同方法进行单一分派,R 会发出警告并且不使用任何方法。从 R 4.3.0 开始,如果为 Ops 泛型的第一个和第二个参数找到了不同的方法,R 将调用 chooseOpsMethod(),从而为对象类型声明其 Ops 方法实现可以处理组合提供机会。如果对象的 chooseOpsMethod() 方法返回 TRUE,则 Ops 泛型将使用为该对象找到的方法。

由于 S7 实现了其自己的泛型分派机制,因此 S7 对象的 chooseOpsMethod() 方法始终返回 TRUE。例如,这允许 S7 对象类型定义一个与其他 S3 对象(如 Sys.Date())一起使用的 + 方法。

library(S7)

ClassX <- new_class("ClassX")

method(`+`, list(ClassX, class_any)) <- function(e1, e2) {
  "method: X + class_any"
}

x <- ClassX()

x + Sys.Date()
## [1] "method: X + class_any"

这对于实现其他 OO 系统的其他包也很有用。例如,reticulate 是一个在 R 会话中嵌入 Python 的 R 包,它允许 R 用户直接使用 Python 对象。Python 有自己的中缀运算符(如 +)方法分派实现。添加 chooseOpsMethod() 使 reticulate 能够实现一个默认后备 + 方法,该方法分派到相应的 Python 例程,同时仍然允许 R 用户为特定的 Python 对象类型定义自定义 + 方法。

例如,当传递 TensorFlow Tensor 和其他一些 Python 对象时,+ 运算符现在可以选择适当的方法。以前,分派失败,因为两个参数都是 S3 类的实例,但会导致分派到不同的方法。

library(reticulate)

# Adding a NumPy array and an R array dispatches to the default
# `+` method for "python.builtin.object"
np_array(1:3) + array(1:3)
## array([2, 4, 6], dtype=int32)
# Adding a TensorFlow Tensor and an R array invokes
# invokes the specific `+` method for "tensorflow.tensor"
array(1:3) + tensorflow::as_tensor(1:3)
## tf.Tensor([2 4 6], shape=(3), dtype=int32)
# Adding a NumPy array with a TensorFlow Tensor.
# Prior to R 4.3.0, this would signal an error and warn:
#   Incompatible methods ("+.tensorflow.tensor", "+.python.builtin.object")
# Beginning with R 4.3.0, chooseOpsMethod() is called to choose the appropriate
# Ops method, and this now works
np_array(1:3) + tensorflow::as_tensor(1:3)
## tf.Tensor([2 4 6], shape=(3), dtype=int32)

%*%

基础 R 定义了在内部分派并支持某种形式的多重分派的附加运算符,但不在 Ops 组中。矩阵乘法运算符 %*% 是一个典型的例子。

在 R 4.3.0 之前,%*% 仅适用于 S4 对象或基础 R 对象。现在,还可以为此运算符定义 S3 方法。与 Ops 组泛型一样,%*% 将在第二个参数上分派,并使用 chooseOpsMethod() 来解决冲突。

为了启用此新行为,创建了一个新的泛型组:matrixOps。目前,该组的唯一成员是 %*%,尽管将来 crossprod()tcrossprod() 预计将加入该组。

添加 "matrixOps" 组是一项狭义的后向兼容性更改,旨在使现有运算符能够与 S3 和 S7 配合使用。旨在通过 S3 支持双重分派的未来函数将能够利用此通用机制,而无需对 R 的 S3 语义进行进一步更改。

nameOfClass()

虽然 S7 使用 S3 分派,但 S7 将类具体化为对象,并且其用户界面基于这些对象,而不是类名。存储在 S3 分派类向量中的类名被视为实现细节(它是非语法性的,并且被破坏以最大程度地减少冲突的可能性,并且不打算面向用户)。此外,如果泛型定义在包之间迁移,它可能会发生更改。

由于这些原因,希望能够在不直接使用 S3 类名的情况下检查继承。为了对 S7 和可能具有自己类表示的其它包启用此功能,从 R 4.3.0 开始,base::inherits() 现在接受任意对象作为第二个参数:如果第二个参数是 S3 对象,则会调用新的 nameOfClass() S3 泛型来解析适当的类名。

这允许在 S7 中像这样使用

ClassX <- S7::new_class("ClassX")

x <- ClassX()

inherits(x, ClassX)
## [1] TRUE

这对于其他 R 包也很有用,特别是外部类层次结构在 R 中被镜像的包。例如,在 reticulate 中,Python 对象的 S3 类向量是从定义类的 Python 模块生成的。但是,在 Keras 和 TensorFlow 等复杂代码库中,类在 Python 源代码中定义的位置被视为内部实现细节,并且经常在库版本之间更改(即,定义类的模块与用户用于访问类的模块分离)。为了确保 Python 库版本之间和跨版本之间的兼容性,reticulate 现在实现了一个 nameOfClass() 方法,它允许像这样使用 R

library(tensorflow)
x <- tf$Variable(1:3)

inherits(x, tf$Variable)
## [1] TRUE

@

在 R 4.3.0 之前,@ 可用于访问 S4 对象的槽。从 R 4.3.0 开始,此运算符现在也可以执行 S3 分派。这使 @@<-(长期以来能够执行 S3 分派)达到同等水平,并使 @ 成为 $ 的对等方(在分派能力方面)。这是一个严格的后向兼容性更改,因为 S3 分派仅在 S4 例程有机会分派之后才执行。

正式定义槽或属性的能力是 S4 和 S7 的优点之一;它能够以比 S3 类的临时约定更大规模地进行协作工作,并且在 S7 中拥有一个专门用于属性访问的运算符是可取的。我们希望其他 OOP 系统具有类似的概念,并且也受益于这种概括。除了执行 S3 分派之外,base R 中的 @ 没有其他面向用户的更改——在没有 S3 方法的情况下 @ 的默认行为保持不变。