1. 为什么在R里谈“对象”和“类”,不是在硬套Java或Python那套?

很多人第一次看到“R中的面向对象编程”这个标题,心里会咯噔一下:R不是那个写统计模型、画散点图、跑 lm() 和 ggplot2() 的函数式语言吗?怎么突然冒出 class 、 object 、 slot 、 generic method 这些词?是不是要我立刻转行去写企业级Java应用?别急——这恰恰是R最被误解也最有价值的一层能力。

我从2012年开始用R做生物信息分析,最早只用 data.frame 和 list ,后来接手一个需要对接十多个测序平台API的项目,数据结构五花八门:有的返回JSON嵌套字典,有的是带元数据的矩阵,有的还附带校验签名和缓存策略。那时我才真正意识到: R的“对象”不是语法糖,而是应对真实世界数据复杂性的工程缓冲带 。它不追求C++式的内存控制,也不模仿Java的强类型继承树;它的对象系统,是为“数据即实体”这个核心场景量身定制的——学生有姓名、年龄、学号;基因有ID、染色体位置、表达值、GO注释;模型有系数、残差、AIC、预测方法……这些不是抽象概念,而是你每天 str() 、 summary() 、 plot() 时真正在打交道的“东西”。

R里没有“万物皆对象”的哲学宣言,但有 事实上的万物皆对象 : 123 是 numeric 类对象, "hello" 是 character 类对象, c(1,2,3) 是 integer 或 numeric 对象,连 function(x) x+1 本身也是 function 类对象。你敲下 class(mtcars) ,得到 "data.frame" ;敲 class(lm(mpg~wt,mtcars)) ,得到 c("lm", "list") ——这不是巧合,是R把类型信息牢牢焊死在每个值身上的设计选择。这种设计让 print() 、 plot() 、 summary() 这些通用函数能自动适配不同数据形态:你不用写 print_data_frame() 、 print_linear_model() 、 print_time_series() ,只要调用 print() ,R就根据对象的 class 属性,自动找到对应的 print.data.frame() 或 print.lm() 来执行。这就是OOP在R里的第一重价值: 免去手动类型分发,让通用接口真正通用 。

而S3和S4这两套系统,并非并列的“两种OOP风格”,而是R语言进化中不同阶段的务实解法。S3是R早期的“轻量协议”:它不定义类结构,只靠约定俗成的命名( generic.class )和 UseMethod() 触发分发;它像一个灵活的插件系统,适合快速包装已有函数。S4则是为大型包(如Bioconductor)设计的“重型规范”:它强制声明槽位(slots)、类型约束、验证逻辑,像一份带法律效力的契约,确保数据结构不被随意篡改。你不会在写一个三行脚本时用S4,但当你开发一个要被上千人依赖的统计模型包时,S4的严格性就是你的质量防火墙。

所以,这篇文章不教你怎么“用R写Java”,而是带你亲手拆开R对象系统的齿轮:看清 class(x) <- "MyClass" 背后发生了什么,理解为什么 $ 不能访问S4的槽位而 @ 可以,搞懂 setMethod("show", "MyClass", ...) 和 print.MyClass <- function(x) ... 在底层如何协作。你会学到的不是理论教条,而是明天就能用上的判断力:什么时候该用S3快速封装一个绘图函数?什么时候必须上S4保证模型对象的完整性?当 methods("plot") 返回37个方法时,你如何精准定位到自己数据类型对应的那个?这才是R OOP的实操内核。

2. R的对象本质:一切皆对象,但对象≠类实例

2.1 对象的物理存在:属性(attributes)才是真相

在R里,“对象”这个词比日常理解更实在。它不是一个虚无缥缈的概念,而是一个 带有数据主体(data body)和附属元数据(attributes)的内存实体 。你可以把它想象成一个带标签的快递箱:箱子本身装着货物(向量、列表、函数等),而贴在箱子上的几张小纸条(attributes),写着“收件人:张三”、“重量:2.3kg”、“签收时间:2024-05-20”——这些纸条就是对象的属性。

R中最关键的属性是 class ,但它只是众多属性之一。其他常见属性包括:

  • names :给向量或列表元素起名字,比如 c(a=1, b=2) 的 names 是 c("a","b")
  • dim :给向量加维度,变成矩阵或数组, attr(x, "dim") <- c(2,3) 就把x变成2×3矩阵
  • dimnames :给矩阵的行列起名, attr(x, "dimnames") <- list(c("row1","row2"), c("colA","colB"))
  • tsp :时间序列的起始、结束、频率信息
  • row.names : data.frame 特有的行名属性

我们来亲手验证这一点。创建一个普通数值向量:

x <- c(1, 2, 3)
str(x)
#  num [1:3] 1 2 3
# attributes(x)  # 查看所有属性
# NULL

此时 x 只有数据主体,没有附加属性。现在给它加上 class 和 names :

class(x) <- "myNumber"
names(x) <- c("first", "second", "third")
attributes(x)
# $class
# [1] "myNumber"
# $names
# [1] "first"  "second" "third"

看, x 瞬间变成了一个带“身份标签”( myNumber 类)和“元素标签”( names )的对象。但注意: class 属性本身并不改变数据的存储方式 。 x[1] 还是取第一个数, x + 1 依然能算——R只是记住了“这个东西自称myNumber”,等你调用 print(x) 时,它会去找 print.myNumber() 函数来处理,而不是默认的 print.numeric() 。

提示: class() 函数是获取/设置 class 属性的快捷方式,等价于 attr(x, "class") 。但不要混淆: class(x) <- "A" 是设置属性,而 is.object(x) 是检查对象性(几乎所有R值都是对象,此函数实际意义有限)。

2.2 类(Class)是蓝图,不是模具:S3的“鸭子类型”哲学

R的“类”概念与Java/C++有根本区别。在那些语言里,类是编译时的模具,对象必须严格按模具铸造,类型检查在运行前就完成。R的S3类则更像一个 运行时的自我介绍标签 。你不需要提前声明“Student类有name、age字段”,只需在创建对象后,给它贴上 "Student" 这个标签,再提供一套叫 print.Student() 、 summary.Student() 的专用处理函数即可。

这种设计源于R的动态本质。试想你读入一个CSV文件:

raw_data <- read.csv("student.csv")
# raw_data 是 data.frame 类对象

你立刻就能用 head(raw_data) 、 plot(raw_data$age) ,因为 data.frame 类已预装了这些方法。但如果你发现数据里混入了缺失值标记 "N/A" ,想自定义一个清洗函数:

clean_student <- function(df) {
  df$age <- as.numeric(gsub("N/A", NA, as.character(df$age)))
  df$name <- gsub("  ", " ", df$name)  # 清理多余空格
  class(df) <- c("CleanedStudent", "data.frame")  # 在原有类基础上叠加新类
  return(df)
}

这里 class(df) <- c("CleanedStudent", "data.frame") 做了两件事:一是让 df 获得新身份 "CleanedStudent" ,二是保留原有 "data.frame" 身份。这样,当你调用 plot(CleanedStudent_obj) 时,R会先找 plot.CleanedStudent() ,找不到就回退到 plot.data.frame() ——完美继承父类行为,又可定制化扩展。这就是S3的“方法分发链”(method dispatch chain),它不靠继承关系,而靠类名字符串的匹配顺序。

注意:S3没有真正的“继承”机制, c("Child", "Parent") 只是告诉R:“先试试Child的方法,不行再试Parent的”。这比传统OOP更轻量,也更易出错——如果拼错类名,R就默默用默认方法,不会报错。

2.3 S4:从“贴标签”到“签合同”的范式升级

当项目规模扩大,仅靠“贴标签”就不够用了。比如你开发一个金融风险模型包,用户传入的“投资组合”对象必须包含 assets (资产列表)、 weights (权重向量)、 currency (币种)三个字段,且 weights 必须是正数、总和为1。S3无法在创建对象时强制校验这些约束,只能靠文档警告和运行时 stopifnot() 检查。

S4正是为此而生。它把“类定义”从隐式约定变为显式契约。 setClass("Portfolio", slots = list(assets = "list", weights = "numeric", currency = "character")) 这行代码,相当于在R解释器里注册了一份法律合同:任何声称是 Portfolio 的对象,都必须有这三个槽位(slot),且类型必须匹配。更进一步,你可以添加 validity 函数,在对象创建时自动执行校验:

setClass("Portfolio",
  slots = list(
    assets = "list",
    weights = "numeric",
    currency = "character"
  ),
  validity = function(object) {
    if (length(object@weights) == 0) return("weights cannot be empty")
    if (any(object@weights < 0)) return("weights must be non-negative")
    if (abs(sum(object@weights) - 1) > 1e-6) return("weights must sum to 1")
    TRUE
  }
)

现在,当你试图创建一个违规对象:

bad_port <- new("Portfolio", 
                assets = list("AAPL", "GOOGL"),
                weights = c(-0.5, 0.5),  # 违反非负约束
                currency = "USD")
# Error in validObject(.Object) : 
#   invalid class “Portfolio” object: weights must be non-negative

R直接在 new() 调用时抛出错误,而不是等到你调用 risk_calculation(bad_port) 时才崩溃。这种“防御性编程”对生产环境至关重要。S4还支持多分发(multiple dispatch): setMethod("risk", signature(x="Portfolio", y="MarketData"), ...) 能同时根据 x 和 y 的类来选择方法,这是S3单参数分发做不到的。

3. S3实战:从零构建一个可打印、可绘图的学生信息系统

3.1 构建S3对象:三步走,不写一行类定义

S3对象的创建极其简单,遵循“数据先行,标签后贴,方法配套”三原则。我们以学生信息为例,目标是创建一个能被 print() 友好显示、能用 plot() 画出成绩分布的对象。

第一步:准备数据主体(Data Body)

S3不限制数据结构,但列表( list )是最常用、最灵活的选择,因为它能容纳不同类型字段:

# 创建学生基本信息列表
student_bio <- list(
  name = "Alice Johnson",
  age = 20,
  major = "Statistics",
  gpa = 3.85,
  courses = c("R Programming", "Linear Algebra", "Probability"),
  grades = c(95, 88, 92)  # 对应courses的成绩
)
# 此时 student_bio 只是一个普通列表
class(student_bio)  # "list"

第二步:贴上类标签(Class Tag)

用 class() 赋值函数给列表打上专属身份:

class(student_bio) <- "Student"
# 或者更安全的写法(避免覆盖原有类)
class(student_bio) <- c("Student", "list")
student_bio
# $name
# [1] "Alice Johnson"
# $age
# [1] 20
# ...
# attr(,"class")
# [1] "Student" "list"

注意 c("Student", "list") 的写法:这既声明了新身份,又保留了 list 的通用能力(如 lapply() 仍可用)。 attr(,"class") 显示的就是这个标签。

第三步:编写配套方法(Generic + Method)

S3的核心是“泛型函数(generic function)”和“具体方法(specific method)”。泛型函数是用户调用的统一入口(如 print() 、 plot() ),方法是针对特定类的实现。

首先,定义泛型函数(如果标准泛型已存在,可跳过):

# 我们要扩展 print(),所以无需重定义 print()
# 但若需新功能,如 summary.Student(),则需先定义泛型
# summary <- function(object, ...) UseMethod("summary")

然后,为 Student 类编写 print 方法。方法名必须是 generic.class 格式:

print.Student <- function(x, ...) {
  cat("=== Student Profile ===\n")
  cat("Name:", x$name, "\n")
  cat("Age:", x$age, "\n")
  cat("Major:", x$major, "\n")
  cat("GPA:", x$gpa, "\n")
  cat("Courses & Grades:\n")
  # 用 data.frame 展示课程和成绩,利用其自带的 print 方法
  course_df <- data.frame(Course = x$courses, Grade = x$grades, stringsAsFactors = FALSE)
  print(course_df, row.names = FALSE)
  cat("========================\n")
  invisible(x)  # 返回原对象,但不打印(符合 print 函数惯例)
}

现在测试:

print(student_bio)
# === Student Profile ===
# Name: Alice Johnson 
# Age: 20 
# Major: Statistics 
# GPA: 3.85 
# Courses & Grades:
#            Course Grade
# 1 R Programming    95
# 2 Linear Algebra    88
# 3      Probability    92
# ========================

看, print() 自动调用了我们写的 print.Student() ,而不是默认的 print.list() 。这就是S3分发的魔力。

3.2 扩展功能:为Student对象添加绘图和摘要能力

光能打印还不够,我们希望 plot(student_bio) 能画出成绩柱状图, summary(student_bio) 能给出统计摘要。

添加绘图方法(plot.Student):

plot.Student <- function(x, main = paste("Grades for", x$name), ...) {
  # 检查是否有成绩数据
  if (!exists("grades", where = x) || length(x$grades) == 0) {
    warning("No grades data found for plotting")
    return(invisible(NULL))
  }
  
  # 使用基础 graphics 绘图(也可用 ggplot2)
  barplot(x$grades, 
          names.arg = x$courses,
          col = "steelblue",
          main = main,
          ylab = "Score",
          ylim = c(0, 100))
  # 添加平均线
  abline(h = mean(x$grades), col = "red", lty = 2)
  legend("topright", legend = paste("Avg:", round(mean(x$grades), 2)), 
         col = "red", lty = 2)
  invisible(NULL)
}

# 测试绘图
plot(student_bio)

添加摘要方法(summary.Student):

summary.Student <- function(object, ...) {
  # 创建一个结果列表,包含各种统计量
  result <- list(
    Name = object$name,
    Age = object$age,
    Major = object$major,
    GPA = object$gpa,
    Course_Count = length(object$courses),
    Grade_Stats = list(
      Mean = round(mean(object$grades), 2),
      Median = median(object$grades),
      Min = min(object$grades),
      Max = max(object$grades),
      SD = round(sd(object$grades), 2)
    )
  )
  
  # 设置类,以便 summary() 调用时能用自定义 print
  class(result) <- "SummaryStudent"
  return(result)
}

# 为 SummaryStudent 类写 print 方法
print.SummaryStudent <- function(x, ...) {
  cat("=== Student Summary ===\n")
  cat("Name:", x$Name, "\n")
  cat("Age:", x$Age, "\n")
  cat("Major:", x$Major, "\n")
  cat("GPA:", x$GPA, "\n")
  cat("Total Courses:", x$Course_Count, "\n")
  cat("Grade Statistics:\n")
  cat("  Mean:", x$Grade_Stats$Mean, "\n")
  cat("  Median:", x$Grade_Stats$Median, "\n")
  cat("  Range:", x$Grade_Stats$Min, "-", x$Grade_Stats$Max, "\n")
  cat("  Std Dev:", x$Grade_Stats$SD, "\n")
  cat("========================\n")
  invisible(x)
}

# 测试
summary(student_bio)

3.3 关键技巧与避坑指南

在S3实践中,我踩过不少坑,这里分享最痛的几条:

1. 方法名大小写敏感,且必须完全匹配:
print.student() 和 print.Student() 是两个不同的方法。R默认类名首字母大写(如 "data.frame" ),建议你保持一致。调试时用 methods("print") 查看当前有哪些 print.* 方法。

2. $ 操作符的陷阱:
S3对象本质是列表,所以 obj$field 能用。但如果字段名含空格或特殊字符(如 "first name" ), obj$first name 会报错。正确做法是 obj[["first name"]] 或 obj$ first name``(用反引号包裹)。

3. 泛型函数的 ... 参数必须透传:
在 print.Student <- function(x, ...) 中, ... 接收所有未声明的参数(如 quote=TRUE )。如果你在方法内部调用其他函数(如 cat() ),记得把 ... 传过去,否则用户传的参数会被忽略。

4. 避免修改原始对象:
S3方法通常是只读的。如果你想提供“修改”功能(如更新GPA),应该写一个独立函数 update_gpa <- function(student, new_gpa) { student$gpa <- new_gpa; student } ,而不是在 print.Student() 里偷偷改。

5. 调试分发失败:
当 print(obj) 没调用你的 print.MyClass() ,先检查:

  • class(obj) 是否真的返回 "MyClass" ?
  • exists("print.MyClass") 是否为 TRUE ?
  • getAnywhere("print.MyClass") 能否找到函数定义?
  • 如果类名是 c("MyClass", "list") ,R会先找 print.MyClass() ,再找 print.list() 。确保你的方法名和类名完全一致。

4. S4实战:构建一个带严格校验的基因表达矩阵类

4.1 定义S4类:从 setClass() 到 validity 校验

S4类的定义比S3正式得多,核心是 setClass() 函数。我们以生物信息学中常见的“基因表达矩阵”(Gene Expression Matrix)为例:它是一个 genes × samples 的数值矩阵,每行是基因,每列是样本,还需附带基因名、样本名、实验条件等元数据。这类数据容错性极低,必须在源头就保证结构正确。

第一步:声明类结构(Slots)

# 加载必要的包
library(methods)  # S4 系统必需

# 定义 GeneExpressionMatrix 类
setClass("GeneExpressionMatrix",
  slots = list(
    # 核心数据:必须是 numeric 矩阵
    data = "matrix",
    # 基因名:行名,必须是 character 向量
    gene_names = "character",
    # 样本名:列名,必须是 character 向量
    sample_names = "character",
    # 实验条件:每个样本对应的分组信息
    conditions = "character",
    # 其他元数据:用 list 存储任意额外信息
    metadata = "list"
  )
)

这里 slots 参数定义了对象的“骨架”。每个槽位(slot)都有明确的数据类型约束,R会在创建对象时强制检查。

第二步:添加有效性校验(Validity)

光有类型约束不够,还需业务逻辑校验。 validity 参数接受一个函数,返回 TRUE 表示有效,或返回错误字符串:

setClass("GeneExpressionMatrix",
  slots = list(
    data = "matrix",
    gene_names = "character",
    sample_names = "character",
    conditions = "character",
    metadata = "list"
  ),
  validity = function(object) {
    # 1. 检查 data 是否为数值矩阵
    if (!is.matrix(object@data) || !is.numeric(object@data)) {
      return("data slot must be a numeric matrix")
    }
    
    # 2. 检查维度一致性:行数=基因名数量,列数=样本名数量
    n_genes <- nrow(object@data)
    n_samples <- ncol(object@data)
    
    if (length(object@gene_names) != n_genes) {
      return(paste("gene_names length (", length(object@gene_names), 
                   ") must equal number of rows (", n_genes, ")"))
    }
    
    if (length(object@sample_names) != n_samples) {
      return(paste("sample_names length (", length(object@sample_names), 
                   ") must equal number of columns (", n_samples, ")"))
    }
    
    # 3. 检查 conditions 长度必须等于样本数
    if (length(object@conditions) != n_samples) {
      return(paste("conditions length (", length(object@conditions), 
                   ") must equal number of samples (", n_samples, ")"))
    }
    
    # 4. 检查 metadata 至少包含 'source' 字段
    if (!"source" %in% names(object@metadata)) {
      return("metadata must contain 'source' field")
    }
    
    TRUE  # 所有校验通过
  }
)

第三步:创建对象实例( new() )

现在用 new() 创建一个合规对象:

# 模拟数据
expr_matrix <- matrix(rnorm(100), nrow = 10, ncol = 10)  # 10 genes, 10 samples
rownames(expr_matrix) <- paste("GENE", 1:10, sep = "_")
colnames(expr_matrix) <- paste("SAMPLE", 1:10, sep = "_")

# 创建对象
gem <- new("GeneExpressionMatrix",
  data = expr_matrix,
  gene_names = rownames(expr_matrix),
  sample_names = colnames(expr_matrix),
  conditions = rep(c("Control", "Treated"), each = 5),
  metadata = list(
    source = "RNA-seq experiment",
    platform = "Illumina NovaSeq",
    date = Sys.Date()
  )
)

# 检查是否创建成功
gem
# An object of class "GeneExpressionMatrix"
# Slot "data":
#             SAMPLE_1   SAMPLE_2   SAMPLE_3 ...
# GENE_1     0.123456   -0.789012   1.234567 ...
# ...
# Slot "gene_names": chr [1:10] "GENE_1" "GENE_2" ...
# Slot "sample_names": chr [1:10] "SAMPLE_1" "SAMPLE_2" ...
# Slot "conditions": chr [1:10] "Control" "Control" ...
# Slot "metadata":List of 3 ...

如果尝试创建违规对象,校验会立即拦截:

# 错误:conditions 长度不匹配
bad_gem <- new("GeneExpressionMatrix",
  data = expr_matrix,
  gene_names = rownames(expr_matrix),
  sample_names = colnames(expr_matrix),
  conditions = c("Control"),  # 只有一个值,但需要10个
  metadata = list(source = "test")
)
# Error in validObject(.Object) : 
#   invalid class “GeneExpressionMatrix” object: 
#   conditions length (1) must equal number of samples (10)

4.2 访问与修改槽位: @ vs slot() ,以及为什么不能用 $

S4对象的槽位访问有严格语法,这是与S3最直观的区别。

访问槽位:

  • @ 操作符(推荐): gem@data 获取数据矩阵, gem@gene_names 获取基因名。
  • slot() 函数: slot(gem, "data") 效果相同,但更显式,适合在函数中动态传入槽名。
# 正确访问
head(gem@data[, 1:3])  # 查看前3个样本的数据
gem@sample_names[1:3]  # 前3个样本名

# 错误!S4对象不支持 $ 操作符
# gem$data  # 报错:Error in gem$data : $ operator is invalid for atomic vectors

修改槽位:

  • @ 赋值: gem@data[1,1] <- 999 修改单个元素; gem@conditions <- c("NewCond", gem@conditions[-1]) 修改整个槽。
  • slot<-() 函数: slot(gem, "data") <- new_matrix 替换整个槽。
# 修改第一个样本的条件
gem@conditions[1] <- "Washout"

# 替换整个 metadata 槽
new_meta <- list(
  source = "Updated RNA-seq",
  batch = "Batch_2",
  notes = "Re-processed with new pipeline"
)
slot(gem, "metadata") <- new_meta

注意:直接修改 gem@data 是安全的,因为 data 槽的类型是 "matrix" ,R不会重新校验整个对象。但如果你替换了整个 data 槽( gem@data <- new_matrix ),R会再次运行 validity 函数检查新矩阵是否合规。

4.3 编写S4方法: setMethod() 与多分发优势

S4方法用 setMethod() 定义,语法比S3更严谨。我们为 GeneExpressionMatrix 添加 show() 和 normalize() 方法。

show() 方法(替代 print ):

setMethod("show", "GeneExpressionMatrix", 
  function(object) {
    cat("GeneExpressionMatrix Object\n")
    cat("==========================\n")
    cat("Dimensions:", nrow(object@data), "genes ×", ncol(object@data), "samples\n")
    cat("Genes:", paste(head(object@gene_names, 3), collapse = ", "), "...\n")
    cat("Samples:", paste(head(object@sample_names, 3), collapse = ", "), "...\n")
    cat("Conditions:", paste(unique(object@conditions), collapse = ", "), "\n")
    cat("Source:", object@metadata$source, "\n")
    cat("==========================\n")
  }
)

normalize() 方法(演示多分发):
假设我们想支持两种归一化:基于总和( sum )和基于中位数( median )。S4允许我们为同一个泛型函数 normalize ,定义多个方法,根据第二个参数 method 的类来分发:

# 先定义一个枚举类,用于 method 参数
setClass("NormalizationMethod", 
  slots = list(type = "character"),
  validity = function(object) {
    if (!object@type %in% c("sum", "median")) {
      return("type must be 'sum' or 'median'")
    }
    TRUE
  }
)

# 定义 normalize 泛型函数
setGeneric("normalize", 
  function(object, method, ...) standardGeneric("normalize")
)

# 为 GeneExpressionMatrix + NormalizationMethod 定义方法
setMethod("normalize", 
  signature(object = "GeneExpressionMatrix", method = "NormalizationMethod"),
  function(object, method, ...) {
    data <- object@data
    
    if (method@type == "sum") {
      # 每列除以该列总和
      norm_data <- sweep(data, 2, colSums(data), "/")
      message("Normalized by column sums")
    } else if (method@type == "median") {
      # 每列除以该列中位数
      norm_data <- sweep(data, 2, apply(data, 2, median), "/")
      message("Normalized by column medians")
    }
    
    # 创建新对象,保持元数据
    new("GeneExpressionMatrix",
      data = norm_data,
      gene_names = object@gene_names,
      sample_names = object@sample_names,
      conditions = object@conditions,
      metadata = c(object@metadata, list(normalized_by = method@type))
    )
  }
)

# 使用示例
sum_method <- new("NormalizationMethod", type = "sum")
normalized_gem <- normalize(gem, sum_method)

这里 signature(object = "GeneExpressionMatrix", method = "NormalizationMethod") 就是多分发的关键:R同时检查 object 和 method 的类,只有两者都匹配时才调用此方法。这比S3的单参数分发强大得多,是构建复杂领域模型的基础。

4.4 S4高级技巧:继承与组合

S4支持真正的类继承。例如,我们可以定义一个更具体的 DifferentialExpressionResult 类,它继承自 GeneExpressionMatrix ,并添加差异分析特有字段:

setClass("DifferentialExpressionResult",
  contains = "GeneExpressionMatrix",  # 继承自 GeneExpressionMatrix
  slots = list(
    # 差异分析特有字段
    log2_fold_change = "numeric",  # 每个基因的log2FC
    p_value = "numeric",           # p值
    adj_p_value = "numeric",       # 校正后p值
    significant = "logical"        # 是否显著(FDR<0.05)
  )
)

# 创建子类对象
der <- new("DifferentialExpressionResult",
  data = gem@data,
  gene_names = gem@gene_names,
  sample_names = gem@sample_names,
  conditions = gem@conditions,
  metadata = gem@metadata,
  log2_fold_change = rnorm(10),
  p_value = runif(10, 0, 0.05),
  adj_p_value = runif(10, 0, 0.05),
  significant = sample(c(TRUE, FALSE), 10, replace = TRUE)
)

# 因为继承,der 也能用 GeneExpressionMatrix 的 show 方法
show(der)

继承让代码复用变得自然: DifferentialExpressionResult 自动拥有 GeneExpressionMatrix 的所有槽位和方法,只需专注添加新特性。

5. S3 vs S4:何时用哪个?一张表说清所有决策点

选择S3还是S4,不是技术优劣问题,而是工程权衡问题。我总结了12个关键维度,帮你快速决策:

决策维度 S3系统 S4系统 我的实操建议
学习曲线 极低。只需懂 class() 和 function.name <- function(x) {...} 中高。需理解 setClass() , slot , setMethod() , validity 等概念 新手起步必用S3 。先用S3做出MVP,验证需求后再考虑S4。
开发速度 极快。5分钟就能为现有数据结构添加 print 和 plot 方法 较慢。定义类、写校验、设方法,至少半小时起步 快速原型、内部脚本选S3 ; 要发布到CRAN的包,初期也用S3,稳定后再迁移到S4 。
类型安全 无。 class(x) <- "MyClass" 只是贴标签,R不检查 x 是否真有 field1 字段 强。 slots 强制字段存在且类型正确; validity 可加业务规则校验 处理外部输入(如用户上传的CSV)必须用S4 。曾有个客户上传的Excel漏了一列,S3默默用 NULL 填充导致下游分析全错,S4直接在 new() 时报错,救了我们一周debug时间。
性能开销 几乎为零。S3分发是纯字符串匹配,毫秒级 略高。 new() 时运行 validity , @ 访问比 $ 稍慢(但微秒级,通常忽略) 高频循环中避免在循环内创建S4对象 。比如处理百万行数据,先用S3做中间计算,最后一步再用S4封装结果。
调试难度 低。 methods("print") 、 getAnywhere("print.MyClass") 一目了然 中。 showMethods("show") 、 selectMethod("show", "MyClass") 稍复杂, validity 错误信息有时不够直白 S4调试口诀:先 showMethods() 看方法是否存在,再 selectMethod() 看具体实现,最后 trace() 跟踪 validity 函数 。
社区生态 极丰富。 ggplot2 , dplyr , tidyr 等所有tidyverse包都重度依赖S3 少。主要在Bioconductor(如 SummarizedExperiment )和少数严肃统计包中 如果你的包要和tidyverse交互,优先S3 。 dplyr::mutate() 能无缝处理S3对象,但对S4可能需要额外适配。
扩展性 单分发。 print(x) 只看 x 的类 多分发。 combine(x, y)

更多推荐