Object Oriented Programming: Evolving code
Mon, Jun 12, 2017 ❝The evolution of code and evolving code.❞Contents
This article was started more than a year ago and was intended to be a close follow up in the series. I never did find the time to release it in that time frame, so now I decided to finish it more than a year later.
In the previous articles we discussed different aspects of Object Oriented Programming. Most of these articles leverage the notions of expectations vs. internal state, and usage logic vs. implementation logic to describe and reason about the various topics. This article lists the steps that can be taken to take the next step with your particular piece of source code. In a certain way, this is a slight summary of earlier articles. In any case, this should sketch a number of scenarios that any programmer encounters regularly.
The evolutionary steps leverage a number of orthogonal dimensions in programming languages. The first is for accessibility and reuse of logic. The second dimension is used for configurability and portability of logic, i.e. the introduction of a level of abstraction.
We can go for “maximum evolution” by making everything maximally abstract, however that will also maximally complicate code and complicate reuse. (Technically, there is no maximum level of abstraction, but you get my point.) Furthermore, it will make reading the code more complicated than strictly necessary. By being aware of the types of improvements that are possible, we can make sure that we do not over-complicate program code. This has the advantage of making code easier: easier to read/understand and easier to modify/extend, and more compact as we reduce unnecessary complexity and unnecessary abstraction as much as possible.
Fields
Controlling and restricting access
From public fields to private fields with accessors.
Although not often used, a very basic class would contain public fields. The next step from uncontrolled and unrestricted access would be to start controlling and restricting access. This is done through making publicly accessible fields more restricted, preferably private.
Controlled and/or restricted access is provided through accessor methods. Accessor methods either provide partial access (read-only, or write-only) or controlled access such that the accessor method acts as a gatekeeper that verifies values before updating the (now private) fields.
Growing from single reference to multi-reference property
From single value in object to multiple values composed into a new object and used in place of original single-value field in internal state. If the multiple values define a single concept, then we need to create an object to represent this. The object can in turn be incorporated into the internal state, replacing the single value field that was originally there.
Logic
Enabling accessibility/reuse
From in-line logic to utility methods.
Given an amount of in-line logic, it is impossible to reuse this logic. That is, assuming that duplicating this logic is not considered re-use. To enable easy re-use of logic we must make logic easily accessible. By introducing static utility methods, we provide an easy way to access usage logic. Through parameters we allow the caller to influence the usage pattern upon calling.
Adding context and/or abstraction
From in-line logic or utility method to utility object. Configurable through provided expectations. Portable/replaceable logic.
For an existing section of logic that is context-independent, we can evolve this into a configurable and/or portable piece of logic, meaning that we sacrifice context-independency in favor of these extra features. We do this in few different ways:
- Creating a configurable and/or portable utility
Create object without internal state that contains logic that calls utility method(s). We preserve the original utility method and, in addition, we add parameters to the constructor (expectations) to provide configurability. Furthermore, since we now have an object, this logic becomes portable/replaceable. - Adding context to existing utility method(s)
Preserving the utility method does not make sense, since we always depend on context-dependent operation parameters. We move the utility methods into a separate object and make the methods non-static. We add parameters to the constructor (expectations) in order to make the object configurable. The configurability allows for context-dependent use. - Adding portability
Introduce a temporal disconnect between the location where logic is determined and where logic is used. Move existing logic into a new object. Create the object at one location, then pass it on to the using logic.
Abstracting from concrete types
We started with a plain concrete type. At some point, we may want to abstract away from the single type we once used. We can do that in a number of different ways. Alternatively, we cannot know or do not want to know how a concrete type behaves. To reduce dependency to a public API we define an interface instead of a concrete type.
Note that there may be logic included as methods to a concrete type that does not strictly need access to the internal state. This logic is actually just usage logic and should be extracted from the concrete type. This logic can be added as utility logic based on the interface type. We tie this utility logic to the interface type as it does not depend on internal state, instead it depends on interface guarantees. An exception holds for usage logic that depends on methods only defined for the concrete type. This logic becomes a utility that is specific to the concrete type.
Defining contract / reduction to interface
Define a concept and write logic based on its conceptual use, abstracting away from the concrete implementation entirely.
There are numerous situations where the concrete implementation for a concept is of no relevance. Using logic can work with any implementation as long as it adheres to the concept: the public API. By defining an interface, we define this concept. By writing logic to work with the interface, we can make the logic fully agnostic of the underlying implementation.
Enabling multiple implementations
Separating a concrete type and its (implicit) conceptual definition.
By refactoring, we extract an interface from the existing concrete type. The interface uses the original name. All existing logic will from then on use the concept rather than the concrete type. The concrete type will be renamed and from now on implements the newly defined interface.
Changes to logic that referenced the original type:
- Parameters: use the defined interface (i.e. code remains identical, as interface assumes the name of the original concrete type)
- Return types: use the defined interface (i.e. code remains identical, as interface assumes the name of the original concrete type)
- Fields, local variables: use the defined interface (i.e. code remains identical, as interface assumes the name of the original concrete type)
- Type instantiation: modified to instantiate (renamed) concrete type. (i.e. code changes)
Objects
Utility object to full-blown object
From internal state-less utility to internal state-full object with its own concerns.
Utilities are limited in their possibilities because they lack internal state. Any behavior of utility objects is restricted to isolated actions that manipulate call parameters and selectively pass on data to expectations. Without “memory”, a utility is not able to “relate” method calls “over time”.
With the introduction of internal state we introduce intelligent behavior, we enable the capability to relate behavior through persisting to internal state. Instead of influencing individual method calls, we can now intelligently manipulate behavior over multiple, temporally disconnected, method calls. A full object has its own concerns and is not restricted to manipulating behavior.
Sharing common (internal) state
If two concepts have a common sub-component, we can extract this common component (internal state + logic) into an (abstract) base class. The base class can now be shared over multiple concepts implemented as classes by deriving from the common base class. With the shared state we inherit the management logic for this shared state, although the shared state should be the sole reason for deciding to use inheritance.
Deriving from an existing concept
Creating a new type that extends from an existing type.
A new type that strictly extends an existing concept can be defined by extending from an existing class. This is rather tricky because the base class and derived class would need to have the exact same conceptual basis, otherwise we get in trouble as soon as we extend the base class in an uncommon direction. At that point, we cannot preserve correct behavior on the deriving class, or we introduce unexpected behavior. However, as long as the concepts match, we can freely extend the base class.
Conclusion
The article described a number of next steps in code evolution. A program or library need only be as abstract as is necessary to make the implementation happen. One typically applies a few of these evolutions while writing, in order to get the right abstractions for the various parts of the implementation. In the end, they’re basic patterns that are applied.