The post of this week is special, as it’s about Object-Oriented Programming. It’s quite popular nowadays to dismiss OOP. There’s a lot of confusion around it. Some people conflate OOP with accessors (i.e. getters and setters), or shared mutable state (or even both). This is not true, as we will see in this post.
This is the 7th post in the Exercises in Programming Style focus series.Other posts include:
- Introducing Exercises in Programming Style
- Exercises in Programming Style, stacking things up
- Exercises in Programming Style, Kwisatz Haderach-style
- Exercises in Programming Style, recursion
- Exercises in Programming Style with higher-order functions
- Composing Exercises in Programming Style
- Exercises in Programming Style, back to Object-Oriented Programming (this post)
- Exercises in Programming Style: maps are objects too
- Exercises in Programming Style: Event-Driven Programming
- Exercises in Programming Style and the Event Bus
- Reflecting over Exercises in Programming Style
- Exercises in Aspect-Oriented Programming Style
- Exercises in Programming Style: FP & I/O
- Exercises in Relational Database Style
- Exercises in Programming Style: spreadsheets
- Exercises in Concurrent Programming Style
- Exercises in Programming Style: sharing data among threads
- Exercises in Programming Style with Hazelcast
- Exercises in MapReduce Style
- Conclusion of Exercises in Programming Style
A short reminder on OOP
The tenet of OOP is to model the system as objects that map the real world. However, one of the reason OOP came to be was in reaction to the previous usage of global variables - shared mutable state. Global variables can be accessed and set from anywhere in a program; hence, it’s easy to introduce bugs.
OOP principles are two-fold:
- The processing and the storage of a specific piece of data should be co-located in an object - encapsulation
- Objects communicate with each other through messages that may contain data… or not
Those principles aren’t about mutability nor getters/setters! While I agree in practice, some languages/frameworks/practices use mutability and accessors, nothing prevents you from doing without. This would still be OOP code - or maybe, only through their removal will you do true OOP.
Modelling the system
The original Python code offers the following model, which directly maps to classes:
Class | Responsibilities |
---|---|
|
|
|
|
|
|
|
Manage and order the flow of messages between the previous objects |
Improving the initial design with the type system
The original Python code dispatches String
messages, which is pretty error-prone and unfriendly to refactoring.
To benefit from the type system, the dispatch could be achieved through a Message
marker interface.
interface Message
In some cases, additional information is required.
For that, a new abstract PayloadMessage<T>
class implements Message
, and provides a payload of type T
the receiver can use.
abstract class PayloadMessage<T> : Message {
abstract val payload: T
}
This leads to the following design:
This is more than enough for the sample application.
For more complex system, one could think about offering additional classes to hold more than one payload parameter e.g. PayloadMessage2<T, V>
, PayloadMessage3<T, V, X>
, etc.
Instead, an OOP design could create actually create a concept around all parameters.
For example, in order to create a Person
, one would want to send the first name, the last name, and the birth date:
instead of a PayloadMessage3<String, String, LocalDate>
class, one could actually reuse the Person
abstraction, or even better, create a dedicated PersonMessage(String, String, LocalDate)
.
The flow of the application is the following, it just needs to be implemented with Kotlin:
Trading off type-safety for easier future changes
The core feature here is the dispatch()
function.
In general, classes offer a very strict API through their functions.
To introduce more flexibility, one creates an interface, and then the implementation can change more freely. Unfortunately, this makes designing the interface’s functions a one-time gamble. Once designed, any change beside adding a new function becomes a breaking change.
Having a more generic dispatch()
function allows a lot more options.
Unfortunately, this comes at a cost.
The calling code makes use of the dispatch()
method return value.
Unfortunately, its return type is of type Any?
, because it needs to be used in every context possible.
interface MessageDispatch {
fun dispatch(message: Message): Any?
}
For that reason, the calling code needs to cast the return value every time, lowering type safety.
Show me the code!
Here’s a sample of one of the classes above:
class DataStorageManager : MessageDispatch { (1)
class WordsMessage : Message (2)
class InitMessage(override val payload: String) : PayloadMessage<String>() (2)
private lateinit var filename: String
private val words: List<String> by lazy {
read(filename)
.flatMap { it.split("\\W|_".toRegex()) }
.filter { it.isNotBlank() && it.length >= 2 }
.map(String::toLowerCase)
}
override fun dispatch(message: Message) = when (message) { (3)
is InitMessage -> filename = message.payload
is WordsMessage -> words
else -> throw Exception("Uknown message $message")
}
}
1 | All classes implement MessageDispatch |
2 | Classes provide the messages they are able to handle in their dispatch() implementation |
3 | The dispatch() function is the single entry-point to a class |
Conclusion
In this exercise, we were able to use OOP without using accessors or shared mutable state. Hence, one should never conflate them together.
Also, designing OOP classes is a matter of trade-off(s). Types are a great way to catch possible bugs at compile-time. However, this safety has a cost: it makes the initial design a gamble, as one generally doesn’t know what changes will be required in the future. Having a single entry-point function decreases the return type safety, but allows for more changes to be non-breaking.