Encapsulation and Polymorphism
Concept
Encapsulation and polymorphism solve two different problems, and conflating them is the most common way both get applied badly.
Encapsulation is about where knowledge of internal representation lives. An object bundles its data with the methods that operate on it, and hides that data behind those methods so callers can only reach it through a controlled interface. The point isn't secrecy for its own sake — it's that an object's internal representation can change (a List swapped for a Set, a computed value cached instead of recomputed) without breaking every caller, because callers never depended on the representation, only on the method contract. Without encapsulation, an object's invariants (e.g. "balance never goes negative") live nowhere — any code with direct field access can violate them, and the violation can happen far from where the field is defined.
Polymorphism is about letting one call site work correctly against many types, so the caller doesn't need to know which concrete type it's holding. A caller invokes shape.area() without an if/elif chain checking whether shape is a Circle or a Rectangle — each type provides its own area(), and the right one runs. This resolves through dispatch, and there are two structurally different kinds:
- Static (compile-time) dispatch — the call is resolved by the compiler using the declared type, before the program runs. Method overloading (
draw(Circle)vsdraw(Rectangle)as separate signatures) is static polymorphism: which overload runs is fixed at compile time based on the argument types visible then. - Dynamic (runtime) dispatch — the call is resolved using the object's actual runtime type, looked up through a vtable (or equivalent) at the moment of the call. Method overriding is dynamic polymorphism: a
Shapereference holding aCircleat runtime callsCircle'sarea(), even though the reference's declared type is the base class.
These are independent axes: a language can have encapsulation with no polymorphism (a sealed struct with getters), or polymorphism with weak encapsulation (public fields on classes using virtual methods). They're usually taught together only because both are among the "four pillars" of OOP, alongside abstraction and inheritance.
Tradeoffs
| Mechanism | Benefit | Cost |
|---|---|---|
| Public fields, no encapsulation | Simplest possible code, no indirection | Any invariant on the data is unenforceable — nothing stops external code from setting it to an invalid state; changing the internal representation breaks every caller directly |
| Encapsulated (methods/properties over private state) | Internal representation can change freely; invariants enforced in one place | Adds indirection (a method call instead of a field read) and requires writing/maintaining the accessor surface |
| Static dispatch (overloading) | Resolved at compile time — no runtime lookup cost, errors caught earlier | Chooses based on the declared type of the arguments, not the runtime type — can silently pick the "wrong" overload when a value is passed through a supertype-typed variable |
| Dynamic dispatch (overriding / virtual calls) | Caller code stays generic as new subtypes are added — the classic "open/closed" win | Small runtime cost (vtable indirection); behavior of a call site can't be fully determined by reading the call site alone, only by knowing what's actually passed at runtime |
Type-switch / if isinstance(...) branching instead of polymorphism |
No new abstraction to design; obvious what runs, right at the call site | Every new type requires editing every branch site instead of adding one method; violates open/closed principle as the type set grows |
When to use / when not to
- Encapsulate any state that has an invariant to protect, or any representation likely to change — which in practice is most non-trivial object state. A pure data-holder with no invariants and no expected internal change (a coordinate pair, a config record) doesn't need accessor ceremony around it.
- Reach for polymorphism specifically when you catch yourself branching on a type tag to decide behavior — that branch is the signal that each type should instead implement its own version of the method.
- Don't encapsulate to the point of exposing a getter/setter for every field with no actual logic behind them — that's ceremony without benefit (see Common pitfall).
- Don't reach for dynamic dispatch for a fixed, closed set of two or three cases that will essentially never grow — a plain conditional is more direct and just as maintainable when the type set isn't expected to change.
Common pitfall
Anemic encapsulation — wrapping every field in a trivial getX()/setX() pair that does nothing but read or write the field directly. This looks like encapsulation (callers go through methods, not raw fields) but provides none of its actual benefit: the internal representation is still fully exposed through the setter, any invariant is still unenforced (nothing stops setBalance(-500)), and the code has more ceremony than the public-field version for zero protection. Real encapsulation means the method enforces something — validates an input, recomputes a derived value, maintains an invariant — not just relays the raw field. A trivial-accessor class is a sign the object should either have real behavior added to it, or just expose its fields honestly, since the accessor wrapper isn't buying anything.
Engineering Lens
The design-review test that separates real encapsulation from accessor theater is: "if I change this field's internal representation, does anything outside this class need to change?" If the answer is no, the encapsulation is real. The equivalent test for polymorphism is: "if a new type is added to this hierarchy, how many existing call sites need to be edited?" — with genuine polymorphic dispatch the answer is zero (new type, new method implementation, no change to callers); with type-switch branching disguised as OOP, the answer is "every branch," which is the open/closed principle failing silently. Both tests matter more than whether the code technically uses classes and methods — it's easy to write Java or Python that looks object-oriented while providing neither of these properties.
Related
- Composition vs Inheritance — the fragile-base-class problem inheritance introduces is a separate axis from polymorphism, though the two are often used together
- Strategy Pattern — a direct application of dynamic dispatch to swap behavior at runtime instead of branching on type