In this post I will cover roughly:
- What the Liskov Substitution Principle is and how to view it as a Go developer.
- Simple rules to make it easier to not violate LSP as we develop our interfaces.
- Example interfaces demonstrating how to abide by LSP in real world systems.
What is the Liskov Substitution Principle
Require no more, promise no less.
– Jim Weirich
First off, Liskov Substitution or LSP as I will refer to it below, is just one of the design principles comprising, SOLID. SOLID is a simply a mnemonic acronym for five design principles intended to make software designs more understandable, robust, and most importantly maintainable. For this post I'll only be covering LSP.
LSP can be stated roughly: Given two interfaces, the two interfaces are substitutable if and only if, they exhibit behavior such that the caller is unable to tell the difference between any two implementations of the interface. In other words the caller of an interface can assume the same behavior when swapping out one implementation for another.
It's hard to get a good feel for how this principle can be followed in practice, therefore I'll go over such examples later. For now, lets instead explore a contrived but useful example demonstrating how the principle is violated.
Example - Shapes
To setup the example demonstrating how LSP is violated, we drop into a system with a single Shape
interface and two concrete implementations of this interface, Ellipse
, and Circle
. It also has some calling code which uses the Shape
interface.
type Shape interface {
Radius() float64
// TransformX stretches the shape in the x dimension
TransformX(x float64)
}
type circle struct {/* ... */}
func (c *circle) Radius() float64 {/* ... */}
func (c *circle) TransformX(x float64) {/* ... */}
type ellipse struct {/* ... */}
func (e *ellipse) Radius() float64 {/* ... */}
func (e *ellipse) TransformX(x float64) {/* ... */}
func manipulateShapes(s Shape) Shape {
copyS := s
copyS.TransformX(1.5)
return copy
}
LSP is violated here in how the caller manipulateShapes
would experience different behavior depending on the different implementations of ellipse
and circle
.
For a call using the circle
implementation, what would a call on TransformX
mean? If the circle was to transform itself, and maintain its mathematical invariant, then its more of a transformXAndY
, which is an assumption the caller may not have. Or perhaps the circle implementation just panics out, saying it can't stretch. This would obviously be different behavior to the same call on an ellipse.
Additionally if manipulateShapes
was called with an ellipse
, the caller manipulateShapes
would be placing the ellipse implementation into a subtly undefined state. After TransformX(1.5)
, what would a call to Radius
provide? The radius in the X direction only? What about the Y direction? We could assume it didn't affect it at all, or something else?
Violations Summarized
What has happened in the first implementation is the circle cannot simultaneously satisfy its own invariant and the behavioral requirement of the Shape
interface. And for the second, the requirements of the interface are under defined for an ellipse
and the caller must assume more than what the interface provides.
If your thinking ok, that sort of makes sense, but that example would never happen, who would ever create such an interface and two such implementations at the same time. In thinking this, you are right! But bear with me because in practice its never this clear cut.
Time + Real Constraints, Equals Subtler Violations
In real systems the types of behaviors are far more complex, interfaces are larger, reused in many different ways, by different developers, and each of those occurs over long periods time. Given real systems, violations of LSP are far more difficult to pinpoint and are often subtle.
Now lets come back to Go. Specifically in how structs implicitly satisfy an interface when method signatures of the struct are overlapping with an interface. Said another way, in Go a previously defined public interface can be implemented after the fact and reimplemented as long as the interface happens to match.
// initialPackage/foo.go
type foo struct {}
func(f *foo) ID() int {return /* ... */}
// IDer defined, by this package
type IDer interface {
ID() int
}
// 3 months later, new requirements...
// anotherPackage/bar.go
// satifies the initialPackage.IDer interface implicitly
type bar struct {}
func(b *bar) ID() int {return /* ... */}
In this example bar
could be swapped into situations which were originally only the foo
implementation.
Now combine the two notions of this section:
- Real world interfaces evolve over time, and are reused in varying ways.
- With how Go's interfaces are implicitly satisfied.
And it should be apparent that in real world settings its important to figure out how to design interfaces carefully which can maintain the Liskov substitution principle over time as concrete implementations evolve, built by different teams, different constraints, etc.
How to Abide by the Liskov Substitution Principle
Please excuse the lack of practical examples thus far. In due time I'll show a few interfaces from real systems and demonstrate how they provide value as more implementations are created over time.
But for right now, I want to put in front of you a list of rules on how to create interfaces which abide by the Liskov Substitution principle. Later when we get to the examples in the next section, we can recall back here and think on how each example abides by the rules.
- Small method counts. We are talking on the order of 2 or even just a single method. See, io.Reader. There is of course an exception to every rule, and this interface is one such example. Just remember by committing to a large interface, you generally will only ever have one concrete implementation. This is due to the cost/time required to implement the next implementation and its ability to be reused for other use cases.
- Their applicability is kept open, but specific by using the right granularity naming conventions. I've found this to be an almost artistic talent. And one which I personally hone as my understanding grows in the domain I am creating systems in.
- Their method parameters are expandable, and they work on an a single struct or main type at a time. Again, echoing the sentiment in the sections opening quote, we keep the interfaces concise to prevent incorrect assumptions during the present, from propagating forward in time and hindsight from being wrong. In other words it's far easier to add then to take, away, and if you are following semver then you don't want to force a breaking change. (Its a breaking change when you have to remove your ill thought out method, or add or remove parameters).
There is of course more ways then just these. I'll add more as time goes on, but for now this should be a good starting checklist to use when developing your own Go programs.
Real System Examples
My experiences where it was useful to keep in mind the Liskov Substitution principle within Go have generally revolved around the following scenarios.
- When I need to create robust and long lasting storage interfaces.
- When I need flexible but controlled functional options.
Storage & Persistence
When designing loosely coupled components, the storage layers are always easy to identify as good candidates for decoupling from other components. A common theme in the projects I've worked on, is the storage of a datatypes and ways to retrieve it. E.g.
// EmpRecord is the known information on an employee
type EmpRecord struct {
ID int
Name string
// ...
}
// EmpStorer provides access to our peristance layers
// which manipulate employee information
type EmpStorer interface {
// Upsert employees. Given an id of 0, a new employee is created,
// and id assigned. Given a non-zero id, and existing employee is updated.
Upsert(*EmpRecord) error
// Get returns an employee with the given id, or nil.
// If an error is encountered, a nil employee and non-nil error is returned
Get(id int) (*EmpRecord error)
}
The EmpStorer
interface can then be implemented as needed by various storage mediums. I.e. a source of truth sql db or a temporal shared heap for fast response times.
struct empSQLCache {/* ... */}
func (s *empSQLCache) Upsert(e *EmpRecord) error {/* ... */}
func (s *empSQLCache) Get(id int) (*EmpRecord error) {/* ... */}
// Common after the fact need, as the original sql implementation
// becomes strained, complex, or swapped out entirely
struct empRedisCache {/* ... */}
func (s *empRedisCache) Upsert(e *EmpRecord) error {/* ... */}
func (s *empRedisCache) Get(id int) (*EmpRecord error) {/* ... */}
With this EmpStorer
interface, we are allowing ourselves the chance to abide by the Liskov Substitution principle over time. One aspect to focus on is how the Upsert
and Get
behavior is documented on the interface. This kind of non-language enforced detail, is important as many IDE's will jump to the main interface definition but not the implementation when invoking "jump to source". This provides a rallying point for developers both implementing and using the interface. This of course doesn't prevent implementations from deviating from the docs, but its one of those subtle important details.
Next to focus on is the fact the interface is deliberately concise with just two methods, and not something larger. E.g. 4 if we were to go all CRUD. By staying small, we expand the future set of persistence mediums which can be used to satisfy such an interface, and more to the point, not violate LSP. For example, if we allowed the Get to take in more than just the id, we might be unable to satisfy the behavior with storage systems which have specific querying requirements e.g. Cassandra and it's clustering keys.
Functional Options
One potential trip up, or useful behavior depending on your use case, occurs if you give a public interface a private method.
// An Option configures our thingy.
type Option interface {
// 😈 apply is a private method, meaning this interface is
// now ours and ours alone to implement
apply(t *Thingy)
}
The consequence of this are the following:
Your interface now has lost its ability to be implemented by any other package. This is due to how the private method is still a part of the interface and external packages do not have access to such private methods.
At first glance this might seem impractical knowledge, but it is actually useful when you want to control your consumption of possible implementations of this interface. Which is exactly what the function option pattern is. E.g. NewThingy(opts ...Option) *Thingy
. See this complete example of this in action for server options. Note, there is another way of making public interfaces unimplementable, but that is via another mechanism.
Wrap Up
Recapping on what was covered:
- What adhering to the Liskov Substitution Principle(LSP) provides - callers of an interface can assume the same overall behavior across implementations.
- Simple rules for abiding by LSP - minimal interfaces, explicit assumptions, naming granularity.
- Real examples - persistence and options interfaces.
Lastly, I thank you for reading all the way to the end. In return I hope I was able to provide you with tools and ideas to help improve your future projects.
Other Notes
- If your coming from an object-oriented paradigm, you might find it odd, I'm avoiding terms such as "subclassing". However Go does not have inheritance per se, instead it leverages embedding of interfaces, which are another means to an end.
- See design by contract to get a feel for how timeless this principle is.