Object-Oriented Programming and DesignIntroductionObject-oriented programming (OOP) is a method of designing software around objects that combine data and the operations performed on that data. Python supports several programming styles, including procedural, functional, and object-oriented programming. OOP is especially useful when a system contains entities with state, behavior, identity, and relationships. For AI and machine-learning students, OOP is important because software systems are rarely just collections of isolated functions. A dataset may be represented by an object, a model may expose training and prediction behavior, and a preprocessing pipeline may coordinate several reusable components. OOP can make such systems easier to understand, test, extend, and maintain. However, classes should not be used merely because they are available. Good design begins with the problem domain and chooses abstraction only when it improves clarity or reliability. Learning ObjectivesBy the end of this chapter, you should be able to: - distinguish classes, objects, attributes, methods, and identity;
- define classes using constructors and instance methods;
- apply encapsulation through naming conventions and properties;
- choose between inheritance and composition;
- explain polymorphism and duck typing in Python;
- implement special methods such as `__repr__`, `__eq__`, and `__len__`;
- use abstract base classes and data classes appropriately;
- design classes according to cohesion, loose coupling, and single responsibility;
- evaluate an object-oriented design in a realistic application.
Classes, Objects, and StateA class is a definition or blueprint for objects. An object, also called an instance, is a concrete value created from a class. A class describes possible attributes and operations; an object has actual values for those attributes. Consider a simple model-training experiment: ```python class Experiment: def __init__(self, name, learning_rate): self.name = name self.learning_rate = learning_rate self.metrics = {} def record_metric(self, metric_name, value): self.metrics[metric_name] = value def summary(self): return f"{self.name}: {self.metrics}" experiment = Experiment("baseline", 0.01) experiment.record_metric("accuracy", 0.91) print(experiment.summary()) ``` `Experiment` is the class, while `experiment` is an object. The constructor method `__init__` initializes the object after it has been created. The parameter `self` refers to the current instance and must be used to access instance-specific data and methods. The attribute `name` belongs to each object. Two `Experiment` objects can have different names and metrics even though they share the same class definition. A method is a function defined inside a class. A method may inspect or change object state. Class Attributes and Instance AttributesAn instance attribute is normally created through `self.attribute`. A class attribute is defined directly in the class body and is shared by the class unless an instance shadows it. ```python class ModelRun: framework = "Python" # class attribute def __init__(self, model_name): self.model_name = model_name # instance attribute ``` Class attributes are suitable for constants or values genuinely common to all instances. Mutable class attributes should be used carefully because changes may unintentionally affect every instance. Identity, Equality, and MutabilityObject identity answers whether two references point to the same object. The `is` operator tests identity. Equality answers whether two objects represent equivalent values and is tested with `==`. These concepts are different: ```python first = [1, 2] second = [1, 2] third = first first == second # True: equal contents first is second # False: different objects first is third # True: same object ``` A mutable object can be changed after creation, whereas an immutable object cannot be changed in place. Lists and dictionaries are mutable; strings, integers, and tuples are immutable. A class designer should decide whether an object’s state may change and document that decision. Uncontrolled mutation can make debugging and concurrent execution difficult. Encapsulation and PropertiesEncapsulation means organizing data and operations together while controlling how internal state is accessed. Python does not enforce private fields in the same way as some languages. Instead, it uses conventions and mechanisms. - `_name` means “internal use” by convention.
- `__name` triggers name mangling and reduces accidental name conflicts in subclasses.
- A public name such as `name` is part of the intended interface.
Encapsulation is not simply hiding data. Its main purpose is to protect invariants—conditions that should remain true for valid objects. ```python class Temperature: def __init__(self, celsius): self.celsius = celsius @property def celsius(self): return self._celsius @celsius.setter def celsius(self, value): if value < -273.15: raise ValueError("Temperature cannot be below absolute zero") self._celsius = float(value) ``` The `@property` decorator allows `temperature.celsius` to be used like an attribute while executing validation logic. A setter controls assignment. A read-only property can be created without a setter: ```python class Circle: def __init__(self, radius): if radius <= 0: raise ValueError("radius must be positive") self._radius = radius @property def radius(self): return self._radius @property def area(self): return 3.141592653589793 self._radius * 2 ``` Properties are valuable when a value is computed, validated, or likely to require implementation changes later. They should not be used to make every field unnecessarily elaborate. Inheritance and SpecializationInheritance allows a subclass to reuse or extend the behavior of a superclass. It represents an “is-a” relationship. For example, a `RandomForestModel` may be a type of `PredictiveModel`. ```python class PredictiveModel: def predict(self, features): raise NotImplementedError class ConstantModel(PredictiveModel): def __init__(self, value): self.value = value def predict(self, features): return [self.value for _ in features] ``` A subclass may override a method. `super()` provides access to a superclass implementation: ```python class LoggedModel(ConstantModel): def predict(self, features): predictions = super().predict(features) print(f"Generated {len(predictions)} predictions") return predictions ``` Inheritance is appropriate when the subclass genuinely satisfies the conceptual and behavioral contract of the superclass. It can become harmful when used only to reuse a small amount of code. Deep inheritance trees create strong coupling: a change in a base class may affect many subclasses. Prefer shallow hierarchies and explicit interfaces. Python supports multiple inheritance, but it should be used cautiously. Method resolution order (MRO) determines the order in which Python searches classes. The `super()` function follows this order, rather than simply referring to one fixed parent. Mixins—small classes that provide one focused capability—are a common disciplined use of multiple inheritance. Composition and DelegationComposition builds a class by containing instances of other classes. It represents a “has-a” relationship. A `TrainingPipeline` may have a preprocessor, a model, and an evaluator. ```python class Preprocessor: def transform(self, rows): return [[float(value) for value in row] for row in rows] class TrainingPipeline: def __init__(self, preprocessor, model): self.preprocessor = preprocessor self.model = model def predict(self, rows): prepared = self.preprocessor.transform(rows) return self.model.predict(prepared) ``` The pipeline delegates preprocessing to one object and prediction to another. Components can be replaced independently, which supports testing and extension. For example, a production preprocessor can be replaced with a test double. A practical rule is: use inheritance for a stable “is-a” relationship and polymorphic substitution; use composition when an object is assembled from independent responsibilities. Composition often produces more flexible designs because it limits implementation coupling. Polymorphism and Duck TypingPolymorphism means that one interface can work with objects of different concrete types. In Python, polymorphism commonly uses duck typing: an object is suitable if it provides the required behavior, regardless of its declared class. ```python class LinearModel: def predict(self, features): return [sum(row) for row in features] class ZeroModel: def predict(self, features): return [0 for _ in features] def evaluate(model, data): predictions = model.predict(data) return predictions ``` Both model classes can be passed to `evaluate` because both provide `predict`. This reduces dependence on a particular class hierarchy. The risk is that an incompatible object may fail only at runtime. Clear documentation, tests, type hints, and abstract interfaces can reduce this risk. Python’s structural protocols provide a formal way to express duck-typed requirements for static type checkers: ```python from typing import Protocol class Predictable(Protocol): def predict(self, features: list[list[float]]) -> list[float]: ... ``` A class need not explicitly inherit from `Predictable`; it only needs to have a compatible `predict` method for tools that support structural typing. Special Methods and Python’s Data ModelSpecial methods, often called “dunder” methods because they begin and end with double underscores, allow objects to participate in Python syntax and built-in operations. ```python class Dataset: def __init__(self, rows): self._rows = list(rows) def __len__(self): return len(self._rows) def __getitem__(self, index): return self._rows[index] def __iter__(self): return iter(self._rows) def __repr__(self): return f"Dataset(rows={len(self._rows)})" ``` This class works with `len(dataset)`, indexing, iteration, and informative representation. Common special methods include: - `__init__`: initializes an instance;
- `__repr__`: produces a developer-oriented representation;
- `__str__`: produces a user-oriented string;
- `__eq__`: defines value equality;
- `__lt__`, `__gt__`: support ordering when appropriate;
- `__len__`: supports `len()`;
- `__iter__`: supports iteration;
- `__enter__` and `__exit__`: support context managers;
- `__call__`: allows an instance to be called like a function.
Special methods should preserve reasonable expectations. For example, `__len__` should return a nonnegative integer, and `__repr__` should be informative and preferably unambiguous. Defining too many operators can make a class surprising rather than convenient. Abstract Base ClassesAn abstract base class (ABC) defines an interface that subclasses must implement. It is useful when a family of classes must provide specific operations and direct instantiation of the general concept is meaningless. ```python from abc import ABC, abstractmethod class Metric(ABC): @abstractmethod def score(self, actual, predicted): """Return a numeric evaluation score.""" raise NotImplementedError class Accuracy(Metric): def score(self, actual, predicted): if len(actual) != len(predicted): raise ValueError("sequences must have equal length") if not actual: raise ValueError("sequences must not be empty") return sum(a == p for a, p in zip(actual, predicted)) / len(actual) ``` `Metric()` cannot be instantiated because `score` is abstract. The `Accuracy` class fulfills the contract. ABCs provide runtime enforcement and communicate design intent. They are most useful when a stable interface is central to the architecture; otherwise, a small protocol or ordinary duck typing may be simpler. Data ClassesMany classes primarily store related data. The `dataclasses` module reduces boilerplate for such classes. ```python from dataclasses import dataclass, field @dataclass class TrainingConfig: model_name: str learning_rate: float = 0.01 tags: list[str] = field(default_factory=list) ``` The decorator can generate an initializer, representation, equality method, and other methods. `default_factory` is essential for mutable defaults: it creates a new list for each instance. Writing `tags: list[str] = []` would cause instances to share one list. Useful options include `frozen=True`, which prevents ordinary attribute reassignment and makes the object behave more like an immutable value, and `order=True`, which generates ordering methods when ordering is meaningful. A frozen data class is not automatically deeply immutable: a mutable object stored inside it may still be changed. Data classes are appropriate for configuration, records, and value objects. They are less appropriate when a class mainly coordinates complex behavior, manages resources, or maintains nontrivial invariants that require carefully controlled methods. Design PrinciplesSingle Responsibility and CohesionA class should have a focused responsibility. A `CSVReader` should not also train a model, send email, and create database backups. High cohesion means the members of a class contribute to a closely related purpose. Loose CouplingCoupling describes dependence between components. Low coupling makes components easier to replace and test. Constructor injection is a useful technique: ```python class ReportService: def __init__(self, repository, formatter): self.repository = repository self.formatter = formatter ``` The service receives collaborators rather than constructing concrete dependencies internally. This is a simple form of dependency injection. Information Hiding and Stable InterfacesExpose what clients need, not every implementation detail. A stable public method such as `fit(data)` allows the internal algorithm to change without requiring every caller to change. Avoid exposing mutable internal collections directly unless that behavior is intentional. SubstitutabilityIf a subclass is used wherever its superclass is expected, it should preserve the expectations of that interface. A subclass that raises an exception for ordinary behavior promised by the parent violates substitutability. In practical terms, inheritance should extend behavior without breaking client assumptions. Avoiding OverengineeringA class hierarchy created for hypothetical future requirements can make current code harder to understand. Start with a clear design, identify genuine variation, and introduce abstraction when repeated behavior or changing requirements justify it. Readability and testability are stronger indicators of quality than the number of classes. Worked Example: A Reusable Model Evaluation DesignSuppose a student needs to compare several prediction models using one evaluation procedure. A procedural solution may contain repeated code. An object-oriented solution can separate models, data validation, and evaluation. ```python from abc import ABC, abstractmethod class Model(ABC): @abstractmethod def predict(self, features): pass class MajorityModel(Model): def __init__(self, label): self.label = label def predict(self, features): return [self.label] * len(features) class AccuracyMetric: def score(self, actual, predicted): if len(actual) != len(predicted): raise ValueError("length mismatch") if len(actual) == 0: raise ValueError("empty input") return sum(x == y for x, y in zip(actual, predicted)) / len(actual) class Evaluator: def __init__(self, metric): self.metric = metric def evaluate(self, model, features, labels): predictions = model.predict(features) return self.metric.score(labels, predictions) ``` The `Evaluator` depends on an object with a `score` method rather than being tied to one metric implementation. A different metric can be injected. The model hierarchy guarantees a `predict` operation, while the evaluator does not need to know how prediction is implemented. For example, if the labels are `['cat', 'dog', 'cat', 'cat']` and `MajorityModel('cat')` is used, its predictions are four instances of `'cat'`. Three predictions are correct, so accuracy is `3/4 = 0.75`. This calculation assumes that each label contributes equally and that the lists represent corresponding observations. The design also illustrates a limitation: classes do not automatically make an algorithm correct. Input validation, metric definitions, data leakage prevention, and representative evaluation remain necessary. Illustrative Case Study: Designing a Small ML Experiment PlatformBackgroundA university laboratory is developing a platform for comparing classification experiments. Students need to record configuration, train interchangeable models, evaluate results, and save experiment summaries. ProblemThe initial script contains global variables, duplicated evaluation code, and model-specific conditionals such as `if model_type == 'tree'`. Adding a new model requires editing several unrelated functions. Configuration values are also changed accidentally during execution. AnalysisThe varying concepts are the model implementation, evaluation metric, and experiment configuration. The stable workflow is: prepare data, train a model, predict, evaluate, and record results. Model implementations should be interchangeable, while configuration should be represented as a value object. ApproachA suitable design uses: - a frozen data class for immutable experiment configuration;
- a model interface with `fit` and `predict` operations;
- composition to place a model and metric inside an experiment runner;
- properties to validate configuration values;
- dependency injection so tests can use simple fake models and metrics.
A minimal configuration might be: ```python from dataclasses import dataclass @dataclass(frozen=True) class ExperimentConfig: name: str learning_rate: float def __post_init__(self): if not self.name.strip(): raise ValueError("name cannot be empty") if self.learning_rate <= 0: raise ValueError("learning_rate must be positive") ``` The runner can accept any model that follows the expected interface. It should not contain branches for every concrete model. This supports the open/closed principle in a practical sense: existing workflow code remains closed to modification when a new compatible model is added. OutcomeThe laboratory can add a new model by implementing the model interface and passing an instance to the runner. Tests can verify the runner independently using a deterministic fake model. Configuration errors are detected when objects are created rather than after a long experiment has run. Learning PointsThe case demonstrates that OOP is primarily about managing change. Encapsulation protects valid configurations, composition separates responsibilities, polymorphism removes model-specific conditionals, and data classes represent structured values efficiently. It also shows that an interface should reflect actual variation rather than every conceivable future feature. Testing and MaintainabilityObject-oriented code should be tested at multiple levels. Unit tests can verify a class in isolation, including valid inputs, invalid inputs, boundary cases, and state transitions. Integration tests can verify that composed components work together. Avoid tests that depend heavily on private implementation details. If a test breaks whenever an internal attribute is renamed, the test may be coupled to implementation rather than behavior. Dependency injection makes isolation easier because collaborators can be replaced with fakes or mocks. Type hints improve communication and help tools detect incompatible calls, but they do not replace runtime validation. Documentation should describe units, valid ranges, mutation behavior, exceptions, and whether methods have side effects. Challenges, Limitations, and Responsible UseOOP introduces costs. Classes add indirection, inheritance can create fragile dependencies, and excessive abstraction can obscure simple logic. Mutable state may produce order-dependent bugs. Serialization also requires care: loading untrusted serialized objects can execute harmful code, depending on the format and library. Use safe, documented data formats and validate external input. In AI systems, object design can influence governance. Model objects should make provenance, version, training data references, and evaluation results traceable where appropriate. Access controls should protect sensitive data held by dataset or experiment objects. Encapsulation must not become a way to hide important risks from auditors or users. Ethical design also requires attention to the behavior represented by software. A clean class hierarchy cannot correct biased data, invalid labels, unfair evaluation, or inappropriate deployment. Interfaces should make it possible to record limitations and uncertainty rather than presenting model predictions as unquestionable facts. Contemporary DevelopmentsModern Python projects commonly combine OOP with functional techniques, type checking, dependency injection, and configuration management. Libraries such as `dataclasses`, `typing`, and `abc` are part of the standard library, while external frameworks often define their own model and pipeline interfaces. Machine-learning libraries frequently use estimator-style objects with methods such as `fit`, `transform`, and `predict`. The growing use of automatic differentiation, distributed training, and model-serving systems has increased the importance of clear component boundaries. At the same time, many data-processing tasks are more readable as pipelines of functions. The best design is therefore often hybrid: classes represent stateful components and stable domain entities, while pure functions perform transformations that do not require identity or lifecycle management. Chapter SummaryObject-oriented programming models a system through cooperating objects that combine state and behavior. Classes define structure and behavior; objects are concrete instances with identity and state. Encapsulation protects invariants and can be implemented with naming conventions and properties. Inheritance supports specialization but should be shallow and behaviorally sound. Composition assembles independent components and is often more flexible. Polymorphism allows common workflows to operate on different implementations, while duck typing emphasizes supported behavior over declared ancestry. Python’s special methods integrate user-defined classes with the language’s built-in operations. Abstract base classes express required interfaces, and data classes efficiently represent structured values. Effective design emphasizes cohesion, loose coupling, stable interfaces, dependency injection, meaningful validation, and avoidance of unnecessary abstraction. In AI and ML systems, these principles support reusable models, evaluation pipelines, traceable experiments, and maintainable software, but they do not replace sound data practices, testing, security, or ethical evaluation. Key TermsClass: A definition describing the structure and behavior of objects. Object: A concrete instance of a class. Instance attribute: Data associated with one particular object. Class attribute: Data defined on a class and commonly shared by its instances. Method: A function defined within a class. Constructor: The initialization process performed by `__init__`. Encapsulation: Organization and controlled access of state and behavior. Property: Attribute-like access implemented through methods. Inheritance: Creation of a specialized class from a more general class. Composition: Building an object from other collaborating objects. Polymorphism: Use of one interface with objects of different concrete types. Duck typing: Python’s behavior-oriented approach to compatibility. Special method: A method recognized by Python syntax or built-in operations. Abstract base class: A class defining one or more required operations for subclasses. Data class: A class designed mainly to store structured data with generated methods. Cohesion: The degree to which a class’s responsibilities belong together. Coupling: The degree of dependency between software components. Invariant: A condition that must remain true for a valid object. Dependency injection: Supplying a component’s collaborators from outside it. Protocol: A description of required behavior that supports structural typing. Review QuestionsShort Answer Questions1. What is the difference between a class and an object? 2. Why are mutable default values dangerous in data classes? 3. Distinguish object identity from object equality in Python. 4. What purpose does a property setter serve? 5. Give one reason to prefer composition over inheritance. Long Answer Questions1. Explain encapsulation in Python and discuss how properties can protect class invariants. 2. Compare inheritance, composition, and duck typing in terms of reuse, coupling, and flexibility. 3. Describe the role of special methods and illustrate how they make a custom class behave like a built-in collection. 4. Explain abstract base classes and data classes, including situations in which each is appropriate. 5. Discuss how cohesion, loose coupling, dependency injection, and substitutability contribute to maintainable software. Analytical / Application Questions1. Design an object-oriented architecture for a machine-learning pipeline containing a data validator, preprocessor, model, and evaluator. Justify where you would use composition, interfaces, and properties. 2. A class called `ModelManager` loads data, trains models, sends email reports, writes database records, and renders charts. Analyze the design using the single-responsibility principle and propose a refactoring. 3. A subclass of `PredictiveModel` accepts any input in the parent class but raises an exception for datasets with more than ten rows. Analyze whether this is a valid subtype and explain how you would redesign or document the interface. Multiple Choice Questions with AnswersQuestion 1What does `self` refer to inside an instance method? A. The current instance B. The parent class only C. The Python interpreter D. A new module Correct Answer: A. The current instance Question 2Which relationship is most directly represented by composition? A. A subclass is a type of its superclass B. An object contains or uses another object C. Two variables have identical names D. A function calls itself recursively Correct Answer: B. An object contains or uses another object Question 3What is the main purpose of a property setter? A. To prevent a class from having methods B. To control and validate assignment to an attribute C. To create a class attribute automatically D. To force multiple inheritance Correct Answer: B. To control and validate assignment to an attribute Question 4Which special method enables an object to respond to `len(obj)`? A. `__size__` B. `__count__` C. `__len__` D. `__length__` Correct Answer: C. `__len__` Question 5Why should `default_factory=list` be used in a data class? A. It sorts every list automatically B. It creates a separate list for each instance C. It makes the list immutable D. It converts the list to a tuple Correct Answer: B. It creates a separate list for each instance Question 6What prevents direct instantiation of an abstract base class with an unimplemented abstract method? A. The `@property` decorator B. The `@dataclass` decorator C. The `@abstractmethod` mechanism D. The `__repr__` method Correct Answer: C. The `@abstractmethod` mechanism Question 7A function calls `model.predict(data)` without checking the model’s concrete class. Which concept does this most directly illustrate? A. Duck typing and polymorphism B. Name mangling C. Class attribute shadowing D. Operator precedence Correct Answer: A. Duck typing and polymorphism Question 8If four predictions are made and three are correct, what is the accuracy under the definition used in the chapter? A. 0.25 B. 0.50 C. 0.75 D. 1.33 Correct Answer: C. 0.75 Question 9Which design choice generally reduces coupling? A. Constructing every dependency inside the class B. Using global mutable state C. Injecting collaborators through the constructor D. Making every class inherit from one base class Correct Answer: C. Injecting collaborators through the constructor Question 10Which statement about a frozen data class is correct? A. It guarantees deep immutability of all contained objects B. It prevents ordinary reassignment of its fields C. It cannot contain any methods D. It must use inheritance Correct Answer: B. It prevents ordinary reassignment of its fields
|