Skip to content

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:

class Bad:
    def __init__(self, value):
        value = value   # sets a local variable, NOT self.value

b = Bad(42)
# b.value raises AttributeError — 'Bad' object has no attribute 'value'

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:

class Base:
    def __init__(self):
        print("Base.__init__")

class Child(Base):
    def __init__(self):
        super().__init__()   # calls Base.__init__ following MRO
        print("Child.__init__")

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.