OOP with Classes
Two ways to organize code
Section titled “Two ways to organize code”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 approachgene_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 approachtp53 = 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.
Defining a class
Section titled “Defining a class”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.selfis 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 byprint().
Using the class
Section titled “Using the class”Create an instance by calling the class like a function.
tp53 = Gene("TP53", "chr17", 23.5)print(tp53)TP53 (chr17): 23.5The 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)TP53Call methods the same way.
print(tp53.is_expressed())Trueprint(tp53.is_expressed(threshold=50.0))FalseWorking with multiple instances
Section titled “Working with multiple instances”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: expressedBRCA1: expressedEGFR: not expressedInheritance
Section titled “Inheritance”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.5The __str__ method was inherited from Gene, so print() uses that format. The is_expressed method is also inherited.
print(deg.is_expressed())TrueThe new methods work as expected.
print(deg.is_significant())Trueprint(deg.direction())upDataclasses
Section titled “Dataclasses”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
@dataclassclass 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 = TrueYou 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)FalseDataclasses 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/4Use dataclasses when your class is primarily about storing data. Use regular classes when you need more complex behavior or custom initialization logic.
AnnData: OOP in practice
Section titled “AnnData: OOP in practice”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 pdimport anndata as adimport 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) conditionSample1 controlSample2 treatedSample3 treatedThe 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.
Quick reference
Section titled “Quick reference”| 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.