OOP with S4 Classes
Two ways to organize code
Section titled “Two ways to organize code”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.
Why OOP matters in R
Section titled “Why OOP matters in R”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.
S3: the simple system
Section titled “S3: the simple system”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.
Defining S4 classes
Section titled “Defining S4 classes”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.
Creating S4 objects
Section titled “Creating S4 objects”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_genesetAn 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.
Accessing slots
Section titled “Accessing slots”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] TRUEValidity checking
Section titled “Validity checking”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] TRUEThe 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.
Generics and methods
Section titled “Generics and methods”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.
Custom show method
Section titled “Custom show method”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_genesetGeneSet: tumor_suppressorsOrganism: Homo sapiensGenes: 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.
SummarizedExperiment: S4 in practice
Section titled “SummarizedExperiment: S4 in practice”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)
seclass: SummarizedExperimentdim: 3 3metadata(0):assays(1): countsrownames(3): TP53 BRCA1 EGFRrowData names(0):colnames(3): Sample1 Sample2 Sample3colData names(2): condition batchNotice 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.
Accessing the count matrix
Section titled “Accessing the count matrix”Use the assay() accessor function to retrieve the expression data.
assay(se, "counts") Sample1 Sample2 Sample3TP53 100 120 90BRCA1 250 300 280EGFR 50 75 60Accessing sample metadata
Section titled “Accessing sample metadata”Use colData() to retrieve sample-level information.
colData(se)DataFrame with 3 rows and 2 columns condition batch <character> <numeric>Sample1 control 1Sample2 treated 1Sample3 treated 2Subsetting
Section titled “Subsetting”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: SummarizedExperimentdim: 3 2metadata(0):assays(1): countsrownames(3): TP53 BRCA1 EGFRrowData names(0):colnames(2): Sample2 Sample3colData names(2): condition batchThe 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.
Quick reference
Section titled “Quick reference”| 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) { ... } |
Summary
Section titled “Summary”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.