Polymorphism and Behavioral SubstitutionIntroductionPolymorphism is the ability of one interface, operation, or abstraction to work with objects of different concrete types. The word means “many forms.” In object-oriented programming, polymorphism allows software to depend on stable contracts rather than on particular implementations. A payment service, for example, can process a card payment, a bank transfer, or a digital-wallet payment through a common operation such as pay(). Polymorphism is central to extensible software. Without it, a program commonly accumulates conditional logic: def render(item):
if isinstance(item, Button):
...
elif isinstance(item, TextField):
...
elif isinstance(item, Image):
...A polymorphic design delegates the operation to the object: def render(item):
item.render()The second version is easier to extend because the rendering client need not change whenever a new renderable type is introduced. This chapter examines subtype, interface, parametric, and duck-typed polymorphism; dynamic dispatch; overloading; abstract base classes; protocols; and the Liskov Substitution Principle (LSP). Learning objectivesBy the end of this chapter, you should be able to: - explain the relationship between polymorphism, abstraction, inheritance, and composition;
- distinguish subtype, interface, parametric, ad hoc, and duck-typed polymorphism;
- describe how Java and Python select methods at compile time and run time;
- implement dynamic dispatch using Java classes and interfaces and Python classes and protocols;
- use Java generics and Python generics for parametric polymorphism;
- apply abstract base classes and recognize their limitations;
- evaluate designs using the Liskov Substitution Principle;
- identify common substitution failures involving exceptions, state, invariants, and strengthened preconditions;
- choose an appropriate polymorphic technique in a professional software project.
The conceptual foundationAn object has both a representation and a set of observable behaviors. Clients should normally depend on the latter. An abstraction specifies what a client may request; an implementation determines how the request is performed. Three ideas work together: - Encapsulation hides representation and protects invariants.
- Abstraction identifies essential operations and constraints.
- Polymorphism permits multiple implementations of the abstraction to be used through one client-facing type.
Consider a NotificationSender. A client may require send(message), but it should not need to know whether the sender uses email, SMS, or a queue. The common operation is the contract; the concrete transport is an implementation detail. Static type and dynamic typeIn a statically typed language, a variable commonly has a declared, or static, type and refers at run time to an object with a dynamic type. In Java: NotificationSender sender = new EmailSender();
sender.send("Deployment completed");The compiler checks that send is available on NotificationSender. At run time, dynamic dispatch selects EmailSender.send. The reference type determines what may be requested; the object type determines which overridden implementation executes. Python variables do not require declared types, but the same conceptual distinction exists. A variable can refer to an object whose class supplies the operation requested by the client. Forms of polymorphismSubtype polymorphismSubtype polymorphism occurs when a value of a subtype is used wherever a value of a supertype is expected. It is commonly implemented with class inheritance or interface implementation. interface Shape {
double area();
}
final class Circle implements Shape {
private final double radius;
Circle(double radius) {
if (radius < 0) throw new IllegalArgumentException("radius");
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
final class Rectangle implements Shape {
private final double width;
private final double height;
Rectangle(double width, double height) {
if (width < 0 || height < 0) throw new IllegalArgumentException("dimension");
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
static double totalArea(List<Shape> shapes) {
double total = 0;
for (Shape shape : shapes) total += shape.area();
return total;
}totalArea is closed to the details of particular shapes. A new class such as Triangle can participate by implementing Shape, without changing the summation algorithm.
Subtype polymorphism is powerful only when the subtype genuinely honors the supertype’s behavioral contract. Merely sharing a name or inheriting code is insufficient. Interface polymorphismInterface polymorphism emphasizes a capability rather than a taxonomic identity. A FileLogger, CloudLogger, and TestLogger may be unrelated classes but all implement Logger. interface Logger {
void info(String message);
}
final class Service {
private final Logger logger;
Service(Logger logger) {
this.logger = logger;
}
void start() {
logger.info("Service started");
}
}Interfaces support dependency inversion: high-level policy depends on an abstraction, while low-level infrastructure supplies an implementation. This improves testing because a fake or in-memory implementation can replace a production logger. Python expresses interface-like contracts in several ways: informal conventions, abstract base classes, and structural protocols. Unlike nominal Java interfaces, protocols can recognize compatibility from structure. Parametric polymorphismParametric polymorphism allows an algorithm or data structure to operate uniformly over a type parameter. Java generics and Python generics provide this form. static <T> T first(List<T> values) {
if (values.isEmpty()) throw new IllegalArgumentException("empty list");
return values.get(0);
}The method is not written separately for strings, integers, or domain objects. The parameter T represents an unknown but consistent type. Java’s generic type checking is primarily compile-time; type erasure means that ordinary generic type arguments are not generally available at run time. Python typing is also primarily a static-analysis aid: from typing import TypeVar, Sequence
T = TypeVar("T")
def first(values: Sequence[T]) -> T:
if not values:
raise ValueError("empty sequence")
return values[0]Python does not normally enforce T at run time. The annotation helps tools such as type checkers and communicates the function’s contract. Parametric polymorphism differs from subtype polymorphism. A generic function does not need every T to inherit from one application-specific base class. It applies uniformly, subject to any stated constraints. Duck-typed polymorphismDuck typing is structural run-time polymorphism: an object is acceptable if it supports the operations the client uses. The phrase comes from the informal principle that an object capable of behaving like a required abstraction can be used as one. def export_report(writer, report):
writer.write(report.to_text())writer may be a file, an HTTP response adapter, or a test double. No shared superclass is required. If the object lacks write, Python raises an error when the operation is attempted.
Duck typing encourages small, focused interfaces, but it moves some errors from static analysis to run time. Type hints and protocols can preserve much of the flexibility while documenting expected structure. Ad hoc polymorphism and overloadingAd hoc polymorphism provides different implementations for different signatures or cases. Method overloading is a familiar example. Java resolves an overloaded method at compile time using the declared argument types: void print(int value) { ... }
void print(String value) { ... }This is distinct from overriding. Overloading uses the same method name with different parameter lists in one class or inheritance scope. Overriding replaces an inherited implementation with the same signature and is selected through dynamic dispatch. Java does not allow methods to differ only by return type. Overload resolution can also become confusing when conversions, boxing, varargs, or null are involved. Keep overload sets small and semantically consistent. Python does not support traditional signature-based method overloading: defining a method twice replaces the earlier definition. Alternatives include default arguments, keyword-only arguments, functools.singledispatch, or explicit separate method names. These alternatives should not obscure the API. def connect(host, port=443, *, timeout=5):
...This is one function with optional parameters, not compile-time overloading. Dynamic dispatch and method selectionDynamic dispatch is the run-time selection of an overridden method according to the receiver object’s dynamic type. It is the mechanism that makes subtype polymorphism operational. List<Shape> shapes = List.of(
new Circle(2),
new Rectangle(3, 4)
);
for (Shape shape : shapes) {
System.out.println(shape.area());
}Each call to area() is dispatched to the concrete object. The loop does not contain a type test. In Python, attribute lookup and method binding produce similar behavior: class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.141592653589793 * self.radius ** 2
class Rectangle:
def __init__(self, width, height):
self.width, self.height = width, height
def area(self):
return self.width * self.height
def total_area(shapes):
return sum(shape.area() for shape in shapes)A practical distinction is that Java checks the declared contract before execution, whereas ordinary Python code often discovers incompatibility only when an operation is attempted. Python’s static typing tools can detect many such problems before execution, but annotations do not automatically enforce them. Dispatch hazardsPolymorphic code can fail when developers confuse dispatch mechanisms. In Java, static methods are hidden rather than overridden, private methods are not dynamically dispatched, and fields are resolved according to the reference type. Instance methods that are intended to override should be marked with @Override; the annotation lets the compiler detect signature mistakes. Calls from constructors to overridable methods are risky because subclass state may not yet be initialized. Prefer private initialization helpers or final construction logic. Abstract classes, interfaces, and protocolsJava abstract classesAn abstract class can combine shared state, implemented methods, and abstract operations: abstract class DocumentParser {
protected final String source;
protected DocumentParser(String source) {
this.source = source;
}
public final Document parse() {
String normalized = normalize(source);
return parseNormalized(normalized);
}
private String normalize(String value) {
return value.trim();
}
protected abstract Document parseNormalized(String value);
}The template method parse fixes the invariant-preserving sequence while subclasses provide the variable step. Make methods final when subclasses must not alter essential policy. An abstract class is appropriate when implementations share representation or trusted partial behavior. It is less appropriate when the relationship is merely a capability or when multiple unrelated abstractions are needed. Java permits one direct superclass but multiple interfaces, so interfaces often provide greater compositional flexibility. Python abstract base classesPython’s abc module supports nominal abstract contracts: from abc import ABC, abstractmethod
class Repository(ABC):
@abstractmethod
def get(self, identifier: str):
"""Return an entity or raise a documented not-found error."""
raise NotImplementedError
class MemoryRepository(Repository):
def __init__(self):
self.items = {}
def get(self, identifier: str):
return self.items[identifier]A class with unimplemented abstract methods cannot normally be instantiated. ABCs are useful where explicit registration, shared behavior, or a nominal family is valuable. They do not automatically guarantee that a concrete implementation behaves correctly. Python protocolsA protocol expresses a structural contract for static type checkers and, optionally, limited run-time checks: from typing import Protocol
class SupportsWrite(Protocol):
def write(self, text: str) -> int:
...
def save(writer: SupportsWrite, text: str) -> int:
return writer.write(text)A class need not inherit from SupportsWrite; it is compatible if its structure satisfies the protocol. @runtime_checkable can permit selected isinstance checks, but run-time protocol checks generally verify attribute presence, not full signature semantics or behavior. Therefore, a protocol is documentation and a static-analysis contract, not a substitute for tests. The Liskov Substitution PrincipleThe Liskov Substitution Principle states that objects of a subtype should be usable wherever objects of the supertype are expected without changing the correctness of the client program. The principle is named after Barbara Liskov and is a behavioral requirement, not merely a rule about inheritance syntax. A useful contract model separates: - preconditions: what must be true before an operation;
- postconditions: what the operation guarantees afterward;
- invariants: properties that remain true throughout the object’s valid lifetime;
- observable effects: exceptions, state changes, timing expectations, and externally visible interactions.
A sound subtype generally follows these directions: - It must not strengthen preconditions. If the base accepts any positive amount, a subtype should not unexpectedly accept only multiples of ten.
- It must not weaken postconditions. If the base promises persistence, a subtype should not merely place data in a temporary buffer.
- It must preserve invariants. A subtype must not create states that violate assumptions made by base clients.
- It should preserve meaningful behavioral expectations, including documented exception types and side effects.
The classic rectangle–square problemA mutable rectangle may expose independent setWidth and setHeight operations. A square, by definition, must keep both dimensions equal. If a square is substituted for that rectangle, a client that sets width to 5 and height to 3 expects a 5-by-3 rectangle but receives a square whose dimensions must remain equal. The subtype cannot honor both the rectangle’s mutable contract and the square invariant. The issue is not that squares are mathematically unrelated to rectangles. It is that the proposed software contract includes independent mutation. Better designs use an immutable Shape with an area operation, separate immutable Rectangle and Square value objects, or a more precise abstraction. A practical LSP testFor every operation in a proposed abstraction, ask: - What inputs does the client reasonably provide?
- What result and state change does the client rely on?
- What exceptions and side effects are documented?
- Can every subtype honor these expectations without type checks or special cases?
If clients repeatedly write if object is SpecialSubtype, the abstraction may be too broad, the subtype may violate LSP, or the operation may belong in a more specific interface. Worked example: a payment-processing designSuppose an online store must charge customers through card, bank-transfer, and test providers. A weak design puts provider-specific branches into checkout code. A stronger design defines a narrow port. from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class PaymentResult:
transaction_id: str
approved: bool
class PaymentGateway(Protocol):
def charge(self, amount_cents: int, token: str) -> PaymentResult:
...
class Checkout:
def __init__(self, gateway: PaymentGateway):
self.gateway = gateway
def pay(self, amount_cents: int, token: str) -> PaymentResult:
if amount_cents <= 0:
raise ValueError("amount must be positive")
return self.gateway.charge(amount_cents, token)A production gateway, a sandbox gateway, and a recording fake can all satisfy the protocol. The Checkout class owns the precondition that an amount must be positive. The gateway contract should specify whether invalid credentials produce a result or a documented exception, whether retries are safe, and whether a successful response guarantees that funds were captured or merely authorized. A faulty subtype might define charge only for amounts below a proprietary limit, without documenting that additional precondition. It would violate substitutability because clients written for PaymentGateway could pass any positive amount. Another faulty implementation might return approved=True while silently failing to create a transaction, weakening the postcondition. Java version with an explicit interfacerecord PaymentResult(String transactionId, boolean approved) {}
interface PaymentGateway {
PaymentResult charge(long amountCents, String token);
}
final class Checkout {
private final PaymentGateway gateway;
Checkout(PaymentGateway gateway) {
this.gateway = gateway;
}
PaymentResult pay(long amountCents, String token) {
if (amountCents <= 0) throw new IllegalArgumentException("amount");
return gateway.charge(amountCents, token);
}
}The long value represents cents, avoiding binary floating-point representation for monetary amounts. The unit is part of the contract. A gateway must not interpret the same value as dollars. This small example illustrates that behavioral contracts include data meaning, not only method signatures. Case study: replacing a notification providerThis is an illustrative case. A university learning platform sends enrollment, password-reset, and assessment notifications. Initially, a single NotificationService directly calls an email SDK. During a traffic surge, the institution wants to add an asynchronous queue and an SMS fallback. Background and problemThe existing service contains provider checks, catches every exception, and returns a Boolean. Tests require network credentials, and adding SMS would require changes to enrollment and password-reset workflows. The team proposes a Notifier abstraction with send(recipient, message). AnalysisThe team identifies the actual contract: a caller requests delivery, receives a delivery identifier, and can distinguish a temporary failure from a permanent rejection. It also identifies a hidden assumption: callers expect send to return only after the provider has accepted the message, not necessarily after the recipient has read it. A single interface is designed: from typing import Protocol
from dataclasses import dataclass
@dataclass(frozen=True)
class Delivery:
id: str
accepted: bool
class Notifier(Protocol):
def send(self, recipient: str, message: str) -> Delivery:
...EmailNotifier and SmsNotifier implement the capability. QueueNotifier accepts the message into a durable queue and returns a queue identifier. Its documentation explicitly states that acceptance means durable enqueueing, not final delivery. A fake notifier records requests for unit tests.
Approach and solutionApplication services depend only on Notifier. Configuration selects an implementation at the composition boundary. Provider-specific retry policies remain inside the provider adapter, while the application receives stable domain-level outcomes. The team adds contract tests that every notifier must pass: valid requests produce an identifier; invalid recipients produce a documented permanent error; temporary provider failures are represented consistently; and the message is not silently altered. OutcomeThe enrollment workflow remains unchanged while providers are added. Unit tests run without networks. Operational dashboards record delivery identifiers and failure categories. The team discovers that SMS has a shorter message limit, so it introduces a separate MessagePolicy rather than weakening the common notifier contract with unexplained truncation. Learning pointsThe case demonstrates interface polymorphism, dependency inversion, structural typing, contract testing, and LSP. It also shows that a common method signature does not prove substitutability. Delivery semantics, error behavior, units, and side effects must be made explicit. Design guidance and professional practicePrefer capability interfacesDesign interfaces around client needs rather than around large domain classes. A read-only consumer may need find, not every repository operation. Small interfaces reduce accidental coupling and make fakes easier to construct. Favor composition over inheritance when reuse is the goalInheritance creates a substitutability relationship. If the real requirement is to reuse logging, caching, or validation, composition usually communicates the design more accurately. Use inheritance when the subtype relationship is stable, meaningful, and behaviorally valid. Keep polymorphic contracts preciseDocument accepted inputs, returned values, units, state transitions, exceptions, concurrency expectations, and idempotency. A method named save is ambiguous unless the contract states whether it inserts, updates, overwrites, or merely queues data. Test behavior, not implementation identityContract tests can run against every implementation of an interface or protocol. Tests should verify externally observable guarantees rather than private fields or the exact inheritance tree. Property-based testing can be useful for checking invariants across many generated inputs. Control type checksA type check is sometimes appropriate at a boundary, such as serialization or security validation. Repeated type checks inside business logic often indicate a missing abstraction. Replace them with polymorphic operations only when the operation belongs to the abstraction and all implementations can honor its contract. Consider performance and observabilityDynamic dispatch has a run-time cost, although it is often small compared with network, storage, or parsing costs. Do not sacrifice clear contracts for speculative micro-optimization. In performance-sensitive paths, benchmark representative workloads. Instrument polymorphic boundaries with stable operation names and correlation identifiers so that behavior remains observable across implementations. Limitations, risks, and contemporary practicePolymorphism can be overused. A hierarchy with many tiny classes may obscure simple logic. An interface with dozens of methods becomes difficult to implement and encourages empty or unsupported operations. Generic abstractions can become so flexible that their contracts are unclear. Duck typing can produce late failures when static checks and tests are absent. Security is also relevant. Substitutability does not mean every implementation should be trusted equally. A plugin loaded through an interface still requires authentication, authorization, input validation, dependency management, and isolation appropriate to its risk. A malicious implementation can satisfy a type contract while exfiltrating data. Modern development commonly combines nominal and structural approaches. Java uses interfaces, records, sealed classes, pattern matching, and generics to make permitted variation explicit. Python combines protocols, type checkers, dataclasses, abstract base classes, and runtime tests. Sealed hierarchies are useful when the set of variants is intentionally closed; interfaces and protocols are preferable when third-party extension is required. AI-assisted code generation makes contract clarity more important, not less. Generated subclasses may compile while violating invariants, mishandling exceptions, or strengthening preconditions. Review should therefore examine behavioral substitutability, tests, security effects, and domain semantics rather than accepting a matching method signature as proof of correctness. Chapter SummaryPolymorphism allows one client-facing abstraction to work with multiple implementations. Subtype and interface polymorphism rely on substitutable objects and dynamic dispatch. Parametric polymorphism uses type parameters to express algorithms that operate uniformly across types. Duck typing uses structural behavior at run time, while protocols document structural expectations for Python type checkers. Java supports compile-time overload resolution and run-time overriding; Python offers alternatives to traditional overloading and commonly relies on dynamic attribute lookup. Abstract classes are useful when shared state or implementation belongs to a genuine family. Interfaces and protocols are often better for capabilities and dependency inversion. The Liskov Substitution Principle requires more than compatible signatures: subtypes must preserve preconditions, postconditions, invariants, exceptions, units, and observable semantics. Precise contracts, composition, narrow interfaces, contract tests, and explicit composition boundaries produce polymorphic systems that are extensible and maintainable. Key TermsPolymorphism: The ability of one abstraction or operation to work with multiple forms. Subtype polymorphism: Use of a subtype where its supertype is expected. Interface polymorphism: Polymorphism based on a shared capability contract. Parametric polymorphism: Uniform operation over one or more type parameters. Duck typing: Run-time structural compatibility based on supported operations. Ad hoc polymorphism: Different implementations selected for different cases or signatures. Overloading: Same method name with different parameter lists. Overriding: A subtype’s replacement implementation of an inherited instance method. Dynamic dispatch: Run-time selection of an overridden method based on the receiver object. Static type: The type used by a compiler or checker when validating an expression. Dynamic type: The concrete type of an object at run time. Abstract class: A class intended to provide shared behavior or an incomplete contract. Abstract base class: A Python nominal abstraction supported by the abc module. Protocol: A structural contract, especially one used by Python static type checkers. Generic: A class or function expressed using type parameters. Contract: Documented expectations about inputs, outputs, state, and effects. Precondition: A condition that must hold before an operation. Postcondition: A guarantee established after an operation. Invariant: A property that remains true for all valid object states. Liskov Substitution Principle: The requirement that subtypes preserve the behavioral expectations of their supertypes. Review QuestionsShort Answer Questions- Distinguish method overloading from method overriding.
- What is dynamic dispatch, and why is it important to subtype polymorphism?
- How does a Python protocol differ from a Python abstract base class?
- State the main behavioral requirements of the Liskov Substitution Principle.
- Give one advantage and one risk of duck typing.
Long Answer Questions- Compare subtype, interface, parametric, and duck-typed polymorphism, using Java and Python examples.
- Explain how dependency inversion and narrow interfaces improve a notification or payment system.
- Discuss why a common method signature is insufficient to establish behavioral substitutability.
- Analyze the rectangle–square example using preconditions, postconditions, and invariants.
- Compare Java overload resolution with Python’s approaches to providing multiple calling patterns.
Analytical / Application Questions- Design a polymorphic storage abstraction for local files, cloud objects, and an in-memory test double. Specify operations, error behavior, and at least two LSP tests.
- A subclass rejects inputs that the base class accepts and converts a documented temporary error into a successful empty result. Identify the violated contracts and propose a redesign.
- A Python service accepts any object with
send(message), but production failures occur because one implementation sends synchronously and another only queues messages. Define a precise protocol and explain how the semantic difference should be represented.
Multiple Choice Questions with AnswersQuestion 1Which mechanism selects an overridden Java instance method according to the concrete object at run time? A. Dynamic dispatch B. Method overloading C. Generic type erasure D. Field hiding Correct Answer: A. Dynamic dispatch Question 2Which statement best describes parametric polymorphism? A. A subtype replaces an inherited method B. One algorithm operates uniformly over a type parameter C. A method is selected by a run-time type test D. A class inherits implementation from two superclasses Correct Answer: B. One algorithm operates uniformly over a type parameter Question 3What does the Liskov Substitution Principle prohibit a sound subtype from doing? A. Providing a more specific implementation B. Adding private helper methods C. Strengthening the supertype’s preconditions D. Implementing an interface Correct Answer: C. Strengthening the supertype’s preconditions Question 4In ordinary Python, what happens when a method is defined twice with the same name in a class? A. The methods are automatically overloaded B. The later definition replaces the earlier one C. Both methods are selected dynamically D. The class becomes abstract Correct Answer: B. The later definition replaces the earlier one Question 5Which Python feature expresses a structural contract for static type checking? A. Protocol B. Tuple unpacking C. Property decorator D. Generator expression Correct Answer: A. Protocol Question 6A Java variable is declared as Shape but refers to a Circle. What does the declared type primarily determine? A. The concrete implementation executed by area() B. The fields physically stored in the object C. Which members the compiler permits the client to request D. Whether the object can ever be garbage-collected Correct Answer: C. Which members the compiler permits the client to request Question 7Which design is most likely to violate LSP? A. A logger interface implemented by file and console loggers B. A collection interface implemented by an array-backed list C. A payment gateway that documents its delivery semantics D. A subtype that rejects valid inputs accepted by the base contract Correct Answer: D. A subtype that rejects valid inputs accepted by the base contract Question 8Why are narrow interfaces generally useful? A. They require every implementation to expose more operations B. They reduce coupling and make substitution easier to test C. They eliminate the need for behavioral contracts D. They force all classes into one inheritance hierarchy Correct Answer: B. They reduce coupling and make substitution easier to test Question 9Which statement about Java overloading is correct? A. It is resolved primarily from argument types at compile time B. It always uses the object’s dynamic type at run time C. It permits methods differing only by return type D. It is identical to overriding Correct Answer: A. It is resolved primarily from argument types at compile time Question 10A protocol check at run time generally verifies what? A. That every semantic postcondition has been fulfilled B. That the implementation is secure C. That required attributes or methods are present D. That network delivery has completed Correct Answer: C. That required attributes or methods are present
|