Skip to content

OOP with S4 Classes

So far you have written R code in a functional style. You create data, then pass it through functions. Each function takes an input, does something, and returns an output. This works well for straightforward tasks like filtering a data frame or computing a mean.

Object-oriented programming takes a different approach. Instead of separating data and functions, OOP bundles them together into objects. An object holds both data and the operations that work on that data. For example, a SummarizedExperiment object holds a count matrix, sample metadata, and gene annotations all in one place. It also knows how to subset itself, keeping all three pieces in sync.

Why does this matter? In bioinformatics, a single analysis produces many related pieces of data. An RNA-seq experiment has counts, sample conditions, gene coordinates, and normalization factors. Keeping these in separate variables is fragile. Rename one and the others break. OOP solves this by storing everything in a single object with a defined structure.

Bioconductor packages use the S4 object system extensively. When you run DESeq2, work with genomic ranges, or analyze single-cell data, you are working with S4 objects. Understanding S4 helps you use these tools effectively.

You do not need to become an S4 developer. The goal is to understand the objects you encounter in real analyses. You will learn how to inspect them, access their data, and make sense of their structure.

R has multiple object systems. S3 is the simplest. An S3 object is just a list with a class label attached.

gene_result <- list(
gene = "TP53",
pvalue = 0.003,
log2fc = 2.1
)
class(gene_result) <- "DEResult"
gene_result
$gene
[1] "TP53"
$pvalue
[1] 0.003
$log2fc
[1] 2.1
attr(,"class")
[1] "DEResult"
class(gene_result)
[1] "DEResult"

S3 has no formal structure. Any list can become any class. Nothing prevents you from assigning a nonsensical class label to any object. S4 adds formal definitions and validation. This makes S4 objects more reliable and predictable.

An S4 class has a formal definition. You declare the class name and its slots. Slots are named fields with required types.

setClass("GeneSet",
slots = list(
name = "character",
genes = "character",
organism = "character"
)
)

This defines a GeneSet class with three slots. Each slot must contain a specific type. Trying to put a number in the name slot will produce an error. This enforcement catches bugs early.

Use new() to create an instance of an S4 class.

my_geneset <- new("GeneSet",
name = "tumor_suppressors",
genes = c("TP53", "RB1", "PTEN", "APC", "BRCA1"),
organism = "Homo sapiens"
)
my_geneset
An object of class "GeneSet"
Slot "name":
[1] "tumor_suppressors"
Slot "genes":
[1] "TP53" "RB1" "PTEN" "APC" "BRCA1"
Slot "organism":
[1] "Homo sapiens"

The default output shows each slot and its contents. Later we will customize this display.

S4 objects use the @ operator to access slots. This is different from the $ operator used with lists and data frames.

my_geneset@name
[1] "tumor_suppressors"
my_geneset@genes
[1] "TP53" "RB1" "PTEN" "APC" "BRCA1"

You can also use the slot() function. This is useful when the slot name is stored in a variable.

slot(my_geneset, "organism")
[1] "Homo sapiens"

Use is() to check whether an object belongs to a particular class.

is(my_geneset, "GeneSet")
[1] TRUE

S4 classes can include validation rules. These run automatically when an object is created. They ensure the data makes biological sense.

setClass("ExpressionResult",
slots = list(
gene = "character",
log2fc = "numeric",
pvalue = "numeric"
),
validity = function(object) {
errors <- character()
if (any(object@pvalue < 0) || any(object@pvalue > 1)) {
errors <- c(errors, "p-values must be between 0 and 1")
}
if (length(object@gene) != length(object@pvalue)) {
errors <- c(errors, "gene and pvalue must have the same length")
}
if (length(errors) > 0) return(errors)
return(TRUE)
}
)
result <- new("ExpressionResult",
gene = c("TP53", "BRCA1", "EGFR"),
log2fc = c(2.1, -1.5, 3.0),
pvalue = c(0.001, 0.04, 0.0002)
)
validObject(result)
[1] TRUE

The validity function checks two things. First, p-values must fall between 0 and 1. Second, the gene and pvalue vectors must have the same length. If either check fails, R reports an error immediately. This prevents corrupted objects from entering your analysis.

In S4, functions that behave differently depending on the object type are called generics. You define a generic function first. Then you write specific methods for each class.

setGeneric("significant_genes", function(object, alpha = 0.05) {
standardGeneric("significant_genes")
})
[1] "significant_genes"
setMethod("significant_genes", "ExpressionResult", function(object, alpha = 0.05) {
mask <- object@pvalue < alpha
object@gene[mask]
})
significant_genes(result)
[1] "TP53" "BRCA1" "EGFR"
significant_genes(result, alpha = 0.01)
[1] "TP53" "EGFR"

At the default threshold of 0.05, all three genes are significant. At a stricter threshold of 0.01, only TP53 and EGFR pass. The same function name works for any class that has a matching method. This is how Bioconductor packages like DESeq2 provide consistent interfaces across different object types.

The show generic controls how an object prints to the console. You can write a custom show method to display a compact summary.

setMethod("show", "GeneSet", function(object) {
cat("GeneSet:", object@name, "\n")
cat("Organism:", object@organism, "\n")
cat("Genes:", length(object@genes), "\n")
cat(" ", paste(head(object@genes, 3), collapse = ", "))
if (length(object@genes) > 3) cat(", ...")
cat("\n")
})
my_geneset
GeneSet: tumor_suppressors
Organism: Homo sapiens
Genes: 5
TP53, RB1, PTEN, ...

Most Bioconductor objects have custom show methods. This is why printing a DESeqDataSet or GRanges object gives you a clean summary instead of a wall of raw data.

Now you understand S4. Here is why it matters.

SummarizedExperiment is one of the most important S4 classes in Bioconductor. It stores experimental data alongside sample metadata and feature annotations in a single object. DESeq2, edgeR, and many other packages build on this foundation.

library(SummarizedExperiment)
counts <- matrix(
c(100, 250, 50, 120, 300, 75, 90, 280, 60),
nrow = 3,
dimnames = list(
c("TP53", "BRCA1", "EGFR"),
c("Sample1", "Sample2", "Sample3")
)
)
col_data <- DataFrame(
condition = c("control", "treated", "treated"),
batch = c(1, 1, 2)
)
se <- SummarizedExperiment(
assays = list(counts = counts),
colData = col_data
)
se
class: SummarizedExperiment
dim: 3 3
metadata(0):
assays(1): counts
rownames(3): TP53 BRCA1 EGFR
rowData names(0):
colnames(3): Sample1 Sample2 Sample3
colData names(2): condition batch

Notice the compact summary. This comes from a custom show method, just like the one we wrote for GeneSet. The object holds a 3-by-3 count matrix, no row-level metadata, and two columns of sample metadata.

Use the assay() accessor function to retrieve the expression data.

assay(se, "counts")
Sample1 Sample2 Sample3
TP53 100 120 90
BRCA1 250 300 280
EGFR 50 75 60

Use colData() to retrieve sample-level information.

colData(se)
DataFrame with 3 rows and 2 columns
condition batch
<character> <numeric>
Sample1 control 1
Sample2 treated 1
Sample3 treated 2

You can subset a SummarizedExperiment just like a matrix. Rows are features. Columns are samples. The metadata stays in sync automatically.

se[, se$condition == "treated"]
class: SummarizedExperiment
dim: 3 2
metadata(0):
assays(1): counts
rownames(3): TP53 BRCA1 EGFR
rowData names(0):
colnames(2): Sample2 Sample3
colData names(2): condition batch

The subsetted object has only 2 columns. The count matrix and the sample metadata both reflect this change. You did not need to manually subset each piece. The S4 system keeps everything consistent.

This is the foundation of DESeq2 and many other Bioconductor packages. A DESeqDataSet is a SummarizedExperiment with additional slots. Knowing how to subset and access slots makes using these tools much easier.

Concept S3 S4
Define a class class(x) <- "MyClass" setClass("MyClass", slots = ...)
Create an object structure(list(...), class = "MyClass") new("MyClass", ...)
Access data x$element x@slot or slot(x, "name")
Check class is(x, "MyClass") is(x, "MyClass")
Define a generic my_func <- function(x, ...) UseMethod("my_func") setGeneric("my_func", function(x, ...) standardGeneric("my_func"))
Define a method my_func.MyClass <- function(x, ...) { ... } setMethod("my_func", "MyClass", function(x, ...) { ... })
Validation None built in validity = function(object) { ... }

S4 is the object system that powers Bioconductor. You will encounter S4 objects every time you use packages like DESeq2, GenomicRanges, or SummarizedExperiment. The key concepts are simple. Classes define structure with typed slots. Generics and methods allow the same function to work on different object types. Validity checking prevents bad data from entering your analysis.

You do not need to write S4 classes from scratch in your daily work. But understanding how they work will help you read documentation, debug errors, and use Bioconductor tools with confidence.

This completes the R programming foundations. You now have the tools to read data, write functions, handle errors, and work with the object system that underpins modern bioinformatics in R.