Skip to content

OOP with Classes

So far, you have written functions that take data as input and return results. This is called functional programming. Your data lives in variables. Your logic lives in functions. They exist separately.

# Functional approach
gene_name = "TP53"
gene_chr = "chr17"
gene_expr = 23.5
def is_expressed(expression, threshold=1.0):
return expression > threshold
print(is_expressed(gene_expr))

This works fine for simple cases. But what happens when you have dozens of genes, each with a name, chromosome, and expression value? You end up passing many variables to every function. It becomes easy to mix up which name goes with which expression value.

Object-oriented programming solves this by bundling data and behavior together into a single unit called a class. A class is like a blueprint. Each instance of that class holds its own data and knows how to operate on it.

# OOP approach
tp53 = Gene("TP53", "chr17", 23.5)
print(tp53.is_expressed())

OOP is most useful when you have related pieces of data that need to stay in sync. A gene’s name, chromosome, and expression value belong together. A sequencing sample’s ID, condition, and read count belong together. Classes keep these grouped so they cannot drift apart.

You do not need to choose one style exclusively. Most bioinformatics code mixes both. Understanding classes helps you use libraries like scanpy, Biopython, and pandas, which are built with OOP.

A class definition starts with the class keyword. The __init__ method runs when you create a new instance. The self parameter refers to the instance being created.

class Gene:
"""Represents a gene with expression data.
Attributes:
name: The gene symbol.
chromosome: The chromosome location.
expression: The expression value.
"""
def __init__(self, name, chromosome, expression=0.0):
"""Initializes a Gene instance.
Args:
name: The gene symbol.
chromosome: The chromosome location.
expression: The expression value. Defaults to 0.0.
"""
self.name = name
self.chromosome = chromosome
self.expression = expression
def is_expressed(self, threshold=1.0):
"""Checks if the gene is expressed above a threshold.
Args:
threshold: Minimum expression value. Defaults to 1.0.
Returns:
True if expression exceeds the threshold.
"""
return self.expression > threshold
def __repr__(self):
return f"Gene(name='{self.name}', chr='{self.chromosome}', expr={self.expression})"
def __str__(self):
return f"{self.name} ({self.chromosome}): {self.expression}"

A few things to note:

  • __init__ is the constructor. It sets up the instance’s data.
  • self is always the first parameter of any method. Python passes it automatically.
  • __repr__ returns a developer-friendly string. It is shown when you inspect an object in the REPL.
  • __str__ returns a human-readable string. It is used by print().

Create an instance by calling the class like a function.

tp53 = Gene("TP53", "chr17", 23.5)
print(tp53)
TP53 (chr17): 23.5

The repr() function calls __repr__, giving you the developer view.

print(repr(tp53))
Gene(name='TP53', chr='chr17', expr=23.5)

Access attributes directly with dot notation.

print(tp53.name)
TP53

Call methods the same way.

print(tp53.is_expressed())
True
print(tp53.is_expressed(threshold=50.0))
False

Each instance holds its own data. You can store instances in a list and loop over them.

genes = [
Gene("TP53", "chr17", 23.5),
Gene("BRCA1", "chr17", 45.2),
Gene("EGFR", "chr7", 0.3),
]
for gene in genes:
status = "expressed" if gene.is_expressed() else "not expressed"
print(f"{gene.name}: {status}")
TP53: expressed
BRCA1: expressed
EGFR: not expressed

Inheritance lets you create a new class based on an existing one. The new class gets all the methods and attributes of the parent. You can then add or override behavior.

This is useful when you have a specialized version of something. A differential expression result is still a gene, but with extra fields like fold change and p-value.

class DifferentialGene(Gene):
"""A gene with differential expression results.
Attributes:
name: The gene symbol.
chromosome: The chromosome location.
expression: The expression value.
log2fc: The log2 fold change.
pvalue: The p-value from a statistical test.
"""
def __init__(self, name, chromosome, expression, log2fc, pvalue):
super().__init__(name, chromosome, expression)
self.log2fc = log2fc
self.pvalue = pvalue
def is_significant(self, alpha=0.05, fc_threshold=1.0):
"""Checks if the gene is differentially expressed."""
return self.pvalue < alpha and abs(self.log2fc) > fc_threshold
def direction(self):
"""Returns the direction of change."""
return "up" if self.log2fc > 0 else "down"
def __repr__(self):
return (
f"DifferentialGene('{self.name}', log2fc={self.log2fc}, "
f"p={self.pvalue})"
)

The super().__init__() call runs the parent’s constructor. This sets name, chromosome, and expression without duplicating code.

Because DifferentialGene inherits from Gene, it has access to all of Gene’s methods.

deg = DifferentialGene("TP53", "chr17", 23.5, 2.5, 0.001)
print(deg)
TP53 (chr17): 23.5

The __str__ method was inherited from Gene, so print() uses that format. The is_expressed method is also inherited.

print(deg.is_expressed())
True

The new methods work as expected.

print(deg.is_significant())
True
print(deg.direction())
up

Python’s dataclasses module reduces boilerplate. If your class is mainly a container for data, a dataclass generates __init__, __repr__, and __eq__ for you automatically.

from dataclasses import dataclass
@dataclass
class Sample:
"""Represents a sequencing sample.
Attributes:
sample_id: Unique sample identifier.
condition: Experimental condition.
read_count: Total number of reads.
passed_qc: Whether the sample passed quality control.
"""
sample_id: str
condition: str
read_count: int
passed_qc: bool = True

You define the fields with type annotations. Default values go at the end, just like function arguments. No need to write __init__ yourself.

s1 = Sample("S1", "control", 25000000)
print(s1)
Sample(sample_id='S1', condition='control', read_count=25000000, passed_qc=True)

Override defaults by passing the argument explicitly.

s2 = Sample("S2", "treated", 31000000, passed_qc=False)
print(s2)
print(s2.passed_qc)
Sample(sample_id='S2', condition='treated', read_count=31000000, passed_qc=False)
False

Dataclasses work well with list comprehensions and filtering.

samples = [
Sample("S1", "control", 25000000),
Sample("S2", "control", 28000000),
Sample("S3", "treated", 31000000),
Sample("S4", "treated", 27000000, passed_qc=False),
]
passed = [s for s in samples if s.passed_qc]
print(f"Passed QC: {len(passed)}/{len(samples)}")
Passed QC: 3/4

Use dataclasses when your class is primarily about storing data. Use regular classes when you need more complex behavior or custom initialization logic.

AnnData is Python’s equivalent of R’s SummarizedExperiment. It bundles three things into one object: a count matrix, observation metadata, and variable metadata. The scanpy library uses AnnData as its core data structure for single-cell analysis.

This is OOP in action. Instead of keeping separate DataFrames and arrays that you must align manually, AnnData keeps everything together.

import pandas as pd
import anndata as ad
import numpy as np
counts = np.array([
[100, 120, 90],
[250, 300, 280],
[50, 75, 60],
])
adata = ad.AnnData(
X=counts,
obs=pd.DataFrame(
{"condition": ["control", "treated", "treated"]},
index=["Sample1", "Sample2", "Sample3"],
),
var=pd.DataFrame(index=["TP53", "BRCA1", "EGFR"]),
)
print(adata)
AnnData object with n_obs × n_vars = 3 × 3
obs: 'condition'

Access the count matrix with .X.

print(adata.X)
[[100 120 90]
[250 300 280]
[ 50 75 60]]

Access observation metadata with .obs.

print(adata.obs)
condition
Sample1 control
Sample2 treated
Sample3 treated

The real power shows up when you subset. Filtering observations automatically filters the count matrix to match.

print(adata[adata.obs["condition"] == "treated"])
View of AnnData object with n_obs × n_vars = 2 × 3
obs: 'condition'

Subsetting keeps all the pieces in sync. The rows of X always match the rows of obs. The columns of X always match the rows of var. You never have to worry about misaligned indices. This is the same principle behind SummarizedExperiment in R.

Concept Syntax Use case
Class class Gene: Bundle data and behavior
Constructor def __init__(self, ...): Initialize instance attributes
Method def is_expressed(self): Define behavior on the instance
__str__ def __str__(self): Human-readable string for print()
__repr__ def __repr__(self): Developer string for debugging
Inheritance class DifferentialGene(Gene): Extend a class with new features
super() super().__init__(...) Call the parent class constructor
Dataclass @dataclass Auto-generate boilerplate for data containers

This completes the Python programming foundations. You now have the tools to write functions, handle errors, work with files, and organize code with classes. These skills form the base for everything that follows in bioinformatics workflows.