Object-Oriented Programming¶
OOP questions in data science interviews test whether you can write maintainable, reusable code — not just scripts. Interviewers want to see that you can design a scikit-learn-style API, build a custom transformer, or model a domain cleanly.
Q1: What is self in Python and why is it explicit?¶
Show answer
self refers to the instance of the class on which a method is called. Python requires it to be declared explicitly as the first parameter of every instance method — it is not a keyword, just a strong convention.
class Animal:
def __init__(self, name, sound):
self.name = name # instance attribute
self.sound = sound
def speak(self):
return f"{self.name} says {self.sound}"
dog = Animal("Rex", "woof")
print(dog.speak()) # Rex says woof
# Method call is syntactic sugar for:
print(Animal.speak(dog)) # Rex says woof
When you call dog.speak(), Python rewrites it as Animal.speak(dog) — passing the instance as the first argument. Making self explicit makes this visible rather than magical.
Common mistake: forgetting self when accessing instance attributes inside methods:
Q2: What is the difference between instance, class, and static methods?¶
Show answer
Instance methods — the default. Receive self (the instance) as the first argument. Can access and modify instance and class state.
Class methods — decorated with @classmethod. Receive cls (the class itself) as the first argument. Often used as alternative constructors.
Static methods — decorated with @staticmethod. Receive neither self nor cls. Just a regular function namespaced inside the class.
class Dataset:
default_split = 0.8 # class attribute
def __init__(self, data):
self.data = data
def describe(self): # instance method
return f"Dataset with {len(self.data)} rows"
@classmethod
def from_csv(cls, path): # class method — alternative constructor
import csv
with open(path) as f:
data = list(csv.reader(f))
return cls(data)
@staticmethod
def validate_split(ratio): # static method — utility, no state needed
if not 0 < ratio < 1:
raise ValueError(f"Split ratio must be between 0 and 1, got {ratio}")
return True
ds = Dataset([[1, 2], [3, 4]])
print(ds.describe()) # Dataset with 2 rows
Dataset.validate_split(0.8) # True
# Dataset.from_csv("train.csv") # returns a Dataset instance
Class methods for alternative constructors (e.g., from_csv, from_json) are a standard Python pattern — pandas, scikit-learn, and SQLAlchemy all use it.
Q3: What is the difference between __repr__ and __str__?¶
Show answer
__repr__ is for developers and debugging — it should return a string that unambiguously identifies the object, ideally valid Python to recreate it.
__str__ is for end users — a readable, friendly description.
When print() is called on an object, it uses __str__. The interactive REPL uses __repr__. If only __repr__ is defined, Python uses it for both.
class ModelConfig:
def __init__(self, n_estimators, max_depth):
self.n_estimators = n_estimators
self.max_depth = max_depth
def __repr__(self):
return f"ModelConfig(n_estimators={self.n_estimators}, max_depth={self.max_depth})"
def __str__(self):
return f"Random Forest: {self.n_estimators} trees, depth {self.max_depth}"
config = ModelConfig(100, 5)
print(repr(config)) # ModelConfig(n_estimators=100, max_depth=5)
print(str(config)) # Random Forest: 100 trees, depth 5
print(config) # Random Forest: 100 trees, depth 5
Rule: always implement __repr__. Without it, debugging a list of your custom objects shows nothing useful. Add __str__ when you want a separate user-facing format.
Q4: How does inheritance work in Python? What is the Method Resolution Order (MRO)?¶
Show answer
Inheritance lets a child class inherit attributes and methods from a parent. Python supports multiple inheritance.
class Estimator:
def fit(self, X, y):
raise NotImplementedError
class LinearModel(Estimator):
def __init__(self, learning_rate=0.01):
self.learning_rate = learning_rate
self.coef_ = None
def fit(self, X, y):
# simplified gradient descent
self.coef_ = [0.0] * X.shape[1]
return self
lm = LinearModel()
print(isinstance(lm, Estimator)) # True
The MRO determines the order in which Python searches classes when resolving a method call. Python uses the C3 linearisation algorithm.
class A:
def hello(self):
return "A"
class B(A):
def hello(self):
return "B"
class C(A):
def hello(self):
return "C"
class D(B, C):
pass
d = D()
print(d.hello()) # "B" — follows MRO
print(D.__mro__)
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)
super() follows the MRO automatically, which matters in cooperative multiple inheritance:
Q5: What is the @property decorator and when should you use it?¶
Show answer
@property lets you define a method that is accessed like an attribute — without calling parentheses. It also lets you add validation logic when a value is set.
class Dataset:
def __init__(self, data):
self._data = data
@property
def size(self):
return len(self._data)
@property
def n_features(self):
if not self._data:
return 0
return len(self._data[0])
ds = Dataset([[1, 2, 3], [4, 5, 6]])
print(ds.size) # 2 — no parentheses
print(ds.n_features) # 3
Pair @property with @<name>.setter for validation on assignment:
class ModelConfig:
def __init__(self, lr):
self.learning_rate = lr # calls the setter
@property
def learning_rate(self):
return self._learning_rate
@learning_rate.setter
def learning_rate(self, value):
if value <= 0:
raise ValueError(f"Learning rate must be positive, got {value}")
self._learning_rate = value
config = ModelConfig(0.01)
config.learning_rate = 0.001 # valid
config.learning_rate = -1 # raises ValueError
Use @property when a value should be computed from state, when you want to enforce invariants on assignment, or when you want to maintain a clean public interface while keeping implementation details private.
Q6: What are dunder methods and how do they enable Pythonic interfaces?¶
Show answer
Dunder (double underscore) methods — also called magic methods — let you define how built-in operations behave on your objects. They make custom classes feel like native Python.
class DataBatch:
def __init__(self, samples):
self.samples = samples
def __len__(self):
return len(self.samples)
def __getitem__(self, index):
return self.samples[index]
def __iter__(self):
return iter(self.samples)
def __contains__(self, item):
return item in self.samples
def __repr__(self):
return f"DataBatch({len(self)} samples)"
batch = DataBatch([[1, 2], [3, 4], [5, 6]])
print(len(batch)) # 3 — __len__
print(batch[1]) # [3, 4] — __getitem__
print([3, 4] in batch) # True — __contains__
for sample in batch: # __iter__
print(sample)
Implementing __len__ and __getitem__ makes an object sequence-like — it becomes compatible with for loops, len(), slicing, and many standard library functions.
Common dunders in data science code:
| Method | Triggered by |
|---|---|
__init__ |
MyClass() |
__repr__ |
repr(obj), REPL display |
__str__ |
str(obj), print(obj) |
__len__ |
len(obj) |
__getitem__ |
obj[key] |
__setitem__ |
obj[key] = val |
__iter__ |
for x in obj |
__eq__ |
obj == other |
__call__ |
obj() |
Q7: What is the difference between composition and inheritance? When do you prefer each?¶
Show answer
Inheritance ("is-a"): a child class extends a parent class. It works well when the child genuinely is a more specific version of the parent.
Composition ("has-a"): a class holds instances of other classes as attributes and delegates behaviour to them.
# Inheritance — LinearRegressor IS-A Regressor
class Regressor:
def score(self, X, y):
predictions = self.predict(X)
return compute_r2(y, predictions)
class LinearRegressor(Regressor):
def predict(self, X):
return X @ self.coef_
# Composition — Pipeline HAS-A scaler and HAS-A model
class Pipeline:
def __init__(self, scaler, model):
self.scaler = scaler # composed object
self.model = model
def fit(self, X, y):
X_scaled = self.scaler.fit_transform(X)
self.model.fit(X_scaled, y)
return self
def predict(self, X):
X_scaled = self.scaler.transform(X)
return self.model.predict(X_scaled)
Prefer composition when: - The relationship is "has-a" rather than "is-a" - You want to swap components at runtime (e.g., swap models inside a pipeline) - You want to avoid deep inheritance hierarchies that are hard to follow
Prefer inheritance when: - You need polymorphism — treating different subclasses uniformly through a shared interface - The child class genuinely specialises the parent without changing its contract
The scikit-learn design is a good example: BaseEstimator provides shared utilities via inheritance, while Pipeline uses composition to chain steps.
Q8: What are Abstract Base Classes (ABCs) and why are they useful?¶
Show answer
An Abstract Base Class defines an interface — a set of methods that subclasses are required to implement. Attempting to instantiate a class that does not implement all abstract methods raises TypeError at instantiation time.
from abc import ABC, abstractmethod
class BaseTransformer(ABC):
@abstractmethod
def fit(self, X):
pass
@abstractmethod
def transform(self, X):
pass
def fit_transform(self, X):
return self.fit(X).transform(X)
class StandardScaler(BaseTransformer):
def fit(self, X):
self.mean_ = X.mean(axis=0)
self.std_ = X.std(axis=0)
return self
def transform(self, X):
return (X - self.mean_) / self.std_
# This raises TypeError at instantiation:
class IncompleteTransformer(BaseTransformer):
def fit(self, X):
return self
# forgot to implement transform
try:
t = IncompleteTransformer()
except TypeError as e:
print(e)
# Can't instantiate abstract class IncompleteTransformer
# with abstract method transform
ABCs are how you enforce a contract across a family of classes — useful when you are designing a plugin system, a suite of models, or a transformer family that others will extend.
Q9: What is a dataclass and what does it give you over a plain class?¶
Show answer
@dataclass (introduced in Python 3.7) auto-generates __init__, __repr__, and __eq__ from type-annotated class attributes, eliminating boilerplate.
from dataclasses import dataclass, field
@dataclass
class TrainingConfig:
model_name: str
learning_rate: float = 0.001
batch_size: int = 32
epochs: int = 10
feature_names: list = field(default_factory=list)
config = TrainingConfig(model_name="XGBoost", learning_rate=0.01)
print(config)
# TrainingConfig(model_name='XGBoost', learning_rate=0.01, batch_size=32,
# epochs=10, feature_names=[])
print(config == TrainingConfig(model_name="XGBoost", learning_rate=0.01))
# True — __eq__ compares all fields
Key options:
- @dataclass(frozen=True) — makes the instance immutable and hashable (like a namedtuple with methods)
- @dataclass(order=True) — generates __lt__, __le__, __gt__, __ge__
- field(default_factory=list) — avoids the mutable default argument trap
Use dataclasses for configuration objects, result containers, and any structured record where you want type hints, a good repr, and equality out of the box.
Q10: When would you choose OOP over a functional or procedural style in data science code?¶
Show answer
OOP is not always the right tool. Choosing appropriately signals engineering maturity.
Reach for OOP when: - You are building a reusable component that needs to hold state across calls (a fitted model, a configured pipeline, a database connection) - You want to design a family of interchangeable components with a shared interface (custom transformers, custom metrics, custom samplers) - You are writing code others will extend — ABCs and inheritance make the extension points explicit
Stick with functions when: - The operation is stateless — input goes in, output comes out - You are writing a one-off analysis script or notebook - The logic is short enough that a class adds more ceremony than value
# Functional — fine for a stateless transform
def normalize(array):
return (array - array.mean()) / array.std()
# OOP — appropriate when state is held across fit and transform
class Normalizer:
def fit(self, array):
self.mean_ = array.mean()
self.std_ = array.std()
return self
def transform(self, array):
return (array - self.mean_) / self.std_
The fit / transform split is critical in production: you fit on training data, then apply the same parameters to test and production data. A stateful object is the natural representation for that.
A common mistake is wrapping every function in a class for "good OOP practice". Flat functions in a module are entirely Pythonic when no shared state is needed.