Software Design/Code smells/Object access chain
Checklist questions:
- Could an object access chain like
foo.getBar().doBaz()
be encapsulated behind a single function call likedoBazOnBar()
on objectfoo
?
This is also known as the Law of Demeter or the "rule of one dot".
This code smell could be removed by applying Hide Delegate or Extract Function refactorings described in Refactoring.[1][2]
Why
editCoupling and easiness of change
editCode with object access chains depends on the classes of all intermediate objects in the chain. In case they are not common reusable data structures such as collections, this makes refactoring these classes harder, e. g. by amplifying the amount of code changes needed.[3]
This factor is more important when the enclosing object is used outside of the codebase where it is defined, i. e. when it resides in a library. Hiding an object access chain behind a function allows refactoring the library and the dependant projects at different times. Refactoring of the API of the enclosing object may still be needed, however, to avoid divergence in terminology. For example, if there is a function addMonitoringSync(Sync)
in some class and the concept of "monitoring" is renamed to "metrics", the class may still need to rename the function to addMetricsSync(Sync)
sooner or later, though it may give the dependant projects time to migrate, e. g. by deprecating the old function while adding a new one with the updated name.
If the enclosing object is used only within a single codebase and automated refactorings are possible, this factor may reverse: see section Change amplification below.
Information hiding and complexity
editEncapsulating object access chains reduces the API surface of the enclosing object and thus makes it easier to test it and to reason about all possible state transitions of the enclosing object if the child object is mutable.
Code repetition
editAll occurrences of the same object access chain are repeated code. See practice Don't repeat logic in several places.
Cognitive load and semantics
editProviding a function that encapsulates an object access chain reduces the cognitive load if the last function call in the chain changes the state of the respective object. When observing an object access chain, readers have to prove for themselves that the state-changing call doesn't break the abstraction provided by the previous objects in the chain. For example, consider the following code:
class Registry {
fun getListeners(): List<Listener>
fun addListener(listener: Listener)
}
// somewhere else
registry.getListeners().add(listener)
// vs.
registry.addListener(listener)
When readers see the access chain registry.getListeners().add(listener)
they may not be sure that the logic of classes Registry
and Listener
doesn't require to notify newly added listeners immediately about some events that may have already happened with an object registry
, for example:
registry.getListeners().add(listener)
if (registry.isInitialized()) {
listener.registryInitialized()
}
Or, that the collection of Listeners in the Registry is not copy-on-write and therefore requires an update via a separate setListeners
operation:
registry.setListeners(registry.getListeners().append(listener))
Developers may have to read the API documentation for classes Registry
and Listener
, or their source code to clarify this question for themselves.
On the other hand, when developers see registry.addListener(listener)
readers may be sure (if they trust the developers of the Registry
class) that function addListener()
handles everything that is needed for adding a listener to a registry.
Encapsulating an object access chain as a single function is an opportunity to give this function a descriptive (or a colorful) name which better reflects the semantics of the operation than an object access chain.
Debuggability
editIn programming environments with a null pointer, if one of the objects in the chain is null and null dereferencing occurs, it may be unknowable which exact dereferencing in the chain failed, making debugging harder. For example, in Java, this is the case until OpenJDK 14.[4]
Discoverability
editIn the context of the example from section Cognitive load and semantics above, when a developer wants to add a listener to a registry, they may type registry.add...
to discover the appropriate function via the autocompletion feature in their IDE. If there are intermediate objects in the chain, for example, registry.observability.listeners.add()
it's harder to discover the functionality.
Robustness in concurrent environment
editObject access chain on a thread-safe object may expose parts of the object's state bypassing lock protection. If the exposed object is not immutable, there is a race condition possible.[5] Thus, avoidance of object access chains on mutable objects used in a concurrent environment protects the code from a certain class of concurrency bugs, i. e. increases the robustness of the code.
Why not
editCode and interface size, steepness of the learning curve
editFunctions that hide the object access chains are boilerplate. They increase the amount of code in the root class, as well as its API size, which may increase the steepness of the learning curve for the class.
Cognitive load, apparency of dependencies and semantics
editAvoiding object access chains may increase the cognitive load imposed on users as well as decrease it (see section Why § Cognitive load and semantics above). Exposing parts of the state and functions on the sub-object through distinct functions on the root object may obscure the dependency between these parts of the state and functions, making the users check that for themselves in the API documentation or the source code of the class. Exposing a lot of weakly clustered functions on the root class may make grasping the abstraction embodied by it harder.
Consistency
editIf the object exposes object access to one of its parts anyway, providing access to other parts using object access chains rather than via encapsulating functions on the root class may be taken as a form of structural alignment in the code and the interface of the class.
Change amplification
editWhen the enclosing object is used within a single codebase and automated refactorings are available, encapsulating object access chains within functions may only increase the change amplification. When the structure or names of objects in the chain are modified, this should may also need to be reflected in the name of the encapsulating function to preserve the consistency in naming.
When the codebase in question is a library or the automated symbol renames are not available in the programming environment, this factor may reverse: see the section Coupling and easiness of change above.
Using both approaches
editMany of the factors contributing to why object access chains should be considered a code smell: information hiding and complexity, cognitive load and semantics, robustness in concurrent environment are largely (or completely) irrelevant when the leaf object in the access chain is immutable. On the other hand, an unprotected object access chain to a mutable object in some cases, e. g. in a concurrent environment is not just a matter of design but a bug.
Related
editSee also
editReferences
edit- ↑ Refactoring: Improving the Design of Existing Code (2 ed.). 2018. ISBN 978-0134757599. https://martinfowler.com/books/refactoring.html. Chapter 7, "Hide Delegate" section
- ↑ Refactoring: Improving the Design of Existing Code (2 ed.). 2018. ISBN 978-0134757599. https://martinfowler.com/books/refactoring.html. Chapter 6, "Extract Function" section
- ↑ Refactoring: Improving the Design of Existing Code (2 ed.). 2018. ISBN 978-0134757599. https://martinfowler.com/books/refactoring.html. Chapter 3, "Message Chains" section
- ↑ "JEP 358: Helpful NullPointerExceptions".
- ↑ "Code review checklist for Java concurrency: Non-trivial mutable object is not returned from a getter in a thread-safe class?".