Advanced OOP Patterns for Bioinformatics
When to Use
- Building a custom container (gene/sequence database) that should support
db['BRCA1'],'BRCA1' in db,len(db), iteration. - Making a sequence class sliceable (
seq[3:10]) while returning the same type, or making an object callable (scorer(window)) with internal state. - Storing millions of variant/read records and memory is the bottleneck.
- Sharing behavior (GC content, FASTA export, reverse complement) across multiple unrelated record classes without deep inheritance.
- Asked to explain/debug Python magic methods,
__slots__, or method resolution order (MRO) in a bio context.
Version Compatibility
Pure Python standard library — no version-sensitive APIs. Examples use Python ≥3.9 (f-strings, from __future__ not needed). typing.Protocol examples require Python ≥3.8.
Prerequisites
- Comfortable with plain Python classes,
__init__, and instance methods (see Course Tier_113_Classes_and_OOP). - No third-party packages required — everything below is stdlib.
Subscriptable Sequence Database
Goal: a dict-like container keyed by gene name, case-insensitive, with helpful errors.
Approach: implement __setitem__, __getitem__, __contains__, __len__, __iter__ on top of an internal dict.
class SequenceDatabase:
"""Dict-like container: db['BRCA1'] = seq, db['BRCA1'], 'BRCA1' in db, len(db)."""
def __init__(self):
self._data = {}
def __setitem__(self, name, sequence):
self._data[name.upper()] = sequence.upper()
def __getitem__(self, name):
try:
return self._data[name.upper()]
except KeyError:
raise KeyError(f"Gene '{name}' not found. Available: {list(self._data)[:5]}")
def __contains__(self, name):
return name.upper() in self._data
def __len__(self):
return len(self._data)
def __iter__(self):
return iter(self._data)
def gc_filter(self, min_gc=0.5):
"""Return names of sequences at or above a GC-content threshold."""
def gc(seq):
return (seq.count('G') + seq.count('C')) / len(seq)
return [name for name, seq in self._data.items() if gc(seq) >= min_gc]
db = SequenceDatabase()
db['BRCA1'] = "ATGGATTTATCTGCTCTTCG"
assert 'brca1' in db # case-insensitive lookup
assert len(db) == 1
Sliceable Sequence Object
Goal: a BioSeq where seq[3:10] returns another BioSeq, not a bare string.
Approach: __getitem__ checks whether key is a slice and wraps the result accordingly.
class BioSeq:
"""A sliceable biological sequence: seq[3], seq[3:10], seq[::3], len(seq), str(seq)."""
def __init__(self, sequence, name="unnamed"):
self.sequence = sequence.upper()
self.name = name
def __getitem__(self, key):
result = self.sequence[key]
if isinstance(key, slice):
return BioSeq(result, name=f"{self.name}[slice]")
return result # single character for int index
def __len__(self):
return len(self.sequence)
def __str__(self):
return self.sequence
def __repr__(self):
return f"BioSeq({self.name!r}, {len(self)} bp)"
def __add__(self, other):
return BioSeq(str(self) + str(other), name=f"{self.name}+{other.name}")
def gc_content(self):
return (self.sequence.count('G') + self.sequence.count('C')) / len(self)
def codons(self):
"""Yield codons from reading frame 0."""
for i in range(0, len(self) - 2, 3):
yield self.sequence[i:i + 3]
seq = BioSeq("ATGGCCGATCGATCGTAGCGA", name="test_gene")
fragment = seq[3:12] # returns BioSeq, not str
assert isinstance(fragment, BioSeq)
assert seq[0] == 'A' # int index returns a plain character
Callable Motif Scorer
Goal: a stateful object usable as a function: scorer(window) scores a window against a motif.
Approach: implement __call__; keep a call counter and expose a scan() helper that slides across a sequence.
class MotifScorer:
"""Callable motif scorer: scorer('TATAAAGCGT') -> mismatch score. Tracks call count."""
def __init__(self, motif, mismatch_penalty=1):
self.motif = motif.upper()
self.mismatch_penalty = mismatch_penalty
self._calls = 0
def __call__(self, window):
"""Score a window: 0 = perfect match, negative = more mismatches."""
self._calls += 1
window = window.upper()[:len(self.motif)]
if len(window) < len(self.motif):
return -len(self.motif) * self.mismatch_penalty
return -sum(self.mismatch_penalty for a, b in zip(self.motif, window) if a != b)
def scan(self, sequence, threshold=0):
"""Slide the motif across sequence; return (pos, window, score) at or above threshold."""
sequence = sequence.upper()
hits = []
for i in range(len(sequence) - len(self.motif) + 1):
window = sequence[i:i + len(self.motif)]
score = self(window)
if score >= threshold:
hits.append((i, window, score))
return hits
def __repr__(self):
return f"MotifScorer(motif={self.motif!r}, calls={self._calls})"
tata_scorer = MotifScorer('TATAAA', mismatch_penalty=2)
dna = "GCGATCGTATAATGCGGTATAAAGCGATCGATATAAGCG"
hits = tata_scorer.scan(dna, threshold=-2) # allow 1 mismatch
__slots__ for Millions of Variant Records
Goal: cut per-object memory for large variant/read collections by removing the per-instance __dict__.
Approach: declare __slots__ with the fixed attribute names; measure with sys.getsizeof.
import sys
class VariantDict:
"""Normal class — has a per-instance __dict__."""
def __init__(self, chrom, pos, ref, alt, qual):
self.chrom, self.pos, self.ref, self.alt, self.qual = chrom, pos, ref, alt, qual
class VariantSlots:
"""Memory-optimized class — no per-instance __dict__, ~40-60% smaller."""
__slots__ = ('chrom', 'pos', 'ref', 'alt', 'qual')
def __init__(self, chrom, pos, ref, alt, qual):
self.chrom, self.pos, self.ref, self.alt, self.qual = chrom, pos, ref, alt, qual
n = 10_000
normal = [VariantDict('chr17', i, 'A', 'G', 40.0) for i in range(n)]
slotted = [VariantSlots('chr17', i, 'A', 'G', 40.0) for i in range(n)]
normal_kb = sum(sys.getsizeof(v) + sys.getsizeof(v.__dict__) for v in normal) / 1024
slotted_kb = sum(sys.getsizeof(v) for v in slotted) / 1024
assert slotted_kb < normal_kb # __slots__ wins
v = VariantSlots('chr17', 43045629, 'G', 'A', 99.5)
try:
v.annotation = "pathogenic" # not in __slots__
except AttributeError:
pass # expected: cannot add arbitrary attributes
Composable Mixins
Goal: share GC/FASTA/reverse-complement behavior across record classes without a rigid inheritance tree.
Approach: each mixin declares the attributes it requires (e.g. self.sequence) in its docstring and adds only methods, no __init__.
class BioSequenceMixin:
"""Requires self.sequence. Adds gc_content(), nucleotide_counts()."""
def gc_content(self):
seq = self.sequence.upper()
return (seq.count('G') + seq.count('C')) / len(seq)
def nucleotide_counts(self):
seq = self.sequence.upper()
return {b: seq.count(b) for b in 'ACGT'}
class FASTASerializableMixin:
"""Requires self.name, self.sequence. Adds to_fasta()."""
def to_fasta(self, line_width=60):
lines = [f'>{self.name}']
for i in range(0, len(self.sequence), line_width):
lines.append(self.sequence[i:i + line_width])
return '\n'.join(lines)
class ReversibleMixin:
"""Requires self.sequence. Adds reverse_complement()."""
_COMPLEMENT = str.maketrans('ATGCatgc', 'TACGtacg')
def reverse_complement(self):
return self.sequence.translate(self._COMPLEMENT)[::-1]
class DNARecord(BioSequenceMixin, FASTASerializableMixin, ReversibleMixin):
def __init__(self, name, sequence):
self.name = name
self.sequence = sequence.upper()
rec = DNARecord("test_gene", "ATGGCCGATCGATCG")
assert 0 <= rec.gc_content() <= 1
assert rec.to_fasta().startswith(">test_gene")
# MRO (Method Resolution Order) — Python uses C3 linearization for diamond inheritance
assert [c.__name__ for c in DNARecord.__mro__][:4] == [
'DNARecord', 'BioSequenceMixin', 'FASTASerializableMixin', 'ReversibleMixin'
]
Pitfalls
__getitem__must return the same wrapper type on slice input, or every downstream method call on a fragment silently breaks (a plain string has no.gc_content()).__slots__classes cannot use multiple inheritance from more than one class that also defines non-empty__slots__, and they block arbitrary attribute assignment — don't add__slots__to a class users expect to monkey-patch.- Mixins must never define
__init__or duplicate attribute names — put mixins after the base class in the MRO list, and document required attributes since Python won't enforce them (no structural typing withoutProtocol). __call__state (likeself._calls) is shared across all uses of that instance — don't reuse oneMotifScoreracross threads without a lock.__eq__/__hash__: if you add__eq__for records, also define__hash__(or set it toNone) or the class becomes unhashable in ways that surpriseset()/dictusage.
See Also
bio-sequence-manipulation-seq-objects— Biopython's ownSeqobject as an alternative to hand-rolledBioSeq.biopython— when to reach for Biopython's built-in classes instead of custom OOP.bio-variant-calling-vcf-basics— real-world variant record structures these patterns model.dask— when even__slots__isn't enough and variant collections need out-of-core/parallel storage.