SOLID Go: Open Closed Principle

6 minute read

Introducing the Open Closed Principle

QUOTE: "software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification"
- Wikipedia on the Open Closed Principle

In its original definition, the OCP refers to classes. The idea behind OCP is that the implementation of a class “Foo” is complete at some point. Since classes exist to be referenced, other classes will do so. They will trust “Foo” to behave reliably in the software construct. Like the slogan “Never change a running system.”, the OCP wants you to avoid reopening the same class. Because reopening and modifying already proven code might introduce unintentional and unwanted side effects. Put differently: We are talking about bugs.

In case changes in a class are essential, there is no way around changing its source code. If, on the other hand, it is a matter of just modifying the behaviour or introducing new variants of a class, then the OCP recommends that you create a new derived class instead. This new class then extends already existing behaviour. The OCP also recommends inheritance hierarchies, where upper base classes contain weaker behaviour than derivations. Think about it for a bit: This makes total sense! Because the basic rule in the Open-Closed Principle is to avoid behavioural mutations. So if you need to introduce “more sophisticated” behaviour to a concept and you are not allowed to mutate the code of a struct, then you need to extend the existing struct by embedding it into a new struct.

Open-Closed Principle with Golang Structs

Even though Golang does not support classes and only knows the concept of struct embedding, you can adhere to the Open Closed Principle. Embedding a struct works like the name already tells you: Embedding Foo into Bar will make Bar the host for a new attribute of type Foo. See below:

 1package main
 2
 3import "fmt"
 4
 5type Foo struct {
 6	message string
 7}
 8
 9type Bar struct {
10	Foo
11}
12
13func main() {
14	objA := Foo{message: "Hello"}
15	objB := Foo{message: "World"}
16	objC := Bar{Foo: objA}
17
18	fmt.Println("objA.message: ", objA.message) // Hello
19	fmt.Println("objB.message: ", objC.message) // Hello
20	fmt.Println()
21
22	fmt.Println("objC.Foo.message: ", objC.Foo.message) // Hello
23	objC.Foo = objB
24	fmt.Println("objC.message: ", objC.message) // World
25	fmt.Println()
26}

The “Bar” struct does only embed “Foo” and has no properties of its own. But during runtime, you can access the field “Foo.message” even by calling “Bar.message”. As demonstrated by “objC.message” or its extended version, “objC.Foo.message”. Both calls are technically the same.

So you just learned that even though structs behave differently to object-oriented classes, at least the fields of a struct will be available in the embedding struct. You are also able to replace the embedded struct during runtime. That’s very different from classic object-oriented inheritance. The example code demonstrates this behaviour when applying “objC.Foo = objB”. That’s a behaviour closer to object orientation with JavaScript prototypes rather than Java classes. So Golang will try to access an object’s properties, starting with the “most outer” object. Golang will drill one embedding level deeper if the field does not exist to test if any of the embedded types own this exact property.

The following example code will prove this exact point:

 1package main
 2
 3import "fmt"
 4
 5type Foo struct {
 6	message string
 7}
 8
 9type Bar struct {
10	Foo
11}
12
13type Baz struct {
14	Bar
15	message []string
16}
17
18func main() {
19	objFoo := Foo{message: "Hello"}
20	objBar := Bar{Foo: objFoo}
21	objBaz := Baz{Bar: objBar}
22
23	fmt.Println("objA.message: ", objFoo.message) // Hello
24	fmt.Println("objB.message: ", objBar.message) // Hello
25	fmt.Println("objC.message: ", objBaz.message) // []
26	fmt.Println("objC.message: ", objBaz.Bar.message) // Hello
27	fmt.Println()
28}

Again you see three structs: Foo, Bar, and Baz. What happens is:

  1. Foo defines the field message as a string.
  2. Bar inherits only the message field from Foo.
  3. Baz inherits all fields from Bar, therefore also “Foo.message”.
  4. Baz overwrites the message field in its scope as array of strings.

This sequence of steps will make Golang overwrite the previously defined message field of Foo and Bar, though during runtime, “objC.Bar.message” stays untouched. Fascinating, isn’t it? However, the demonstrated scenario comes very close to the OCP’s definition of extending behaviour.

Question:

Here is a question for you. Given that:

    Functions in Golang are first-class citizens
    Golang allows overwriting fields of structs

Does Golang also allow you to overwrite the functions assigned to a struct?

Let’s find out in the next section of this article!

Open Closed Principle with Golang Functions

Let’s find out how Golang behaves when it comes to functions which are assigned to a struct. But first, let’s look at how Golang defines functions on structs.

 1package main
 2
 3import "fmt"
 4
 5// define a struct
 6type Foo struct {
 7	message string
 8}
 9
10// assign a function to this
11func (f *Foo) sayit() {
12	fmt.Println("Foo says: ", f.message)
13}
14
15// Bar embedding Foo,
16// therefore inheriting properties and functions Foo
17type Bar struct {
18	Foo
19}
20
21// Baz embedding Bar,
22// therefore inheritung properties and functions or Bar
23type Baz struct {
24	Bar
25}
26
27// Defining a new function "sayit" which will overwrite "Foo.sayit()"
28// but only in the scope of "Baz"
29func (f *Baz) sayit() {
30	fmt.Println("Baz says: ", f.message)
31}
32
33func main() {
34	objFoo := Foo{message: "Hello"}
35	objFoo.sayit() // Foo says: Hello
36
37	objBar := Bar{Foo: Foo{message: "World"}}
38	objBar.sayit() // Foo says: World
39
40	objBaz := Baz{Bar: Bar{Foo: Foo{message: "!!!"}}}
41	objBaz.sayit() // Baz says: !!!
42
43	objFoo.sayit() // Foo says: Hello
44}

Again, the above example displays three definitions of structs: Foo, Bar, and “Baz.

There is a function “sayit()” defined on “Foo”, which prints whatever value the field “Foo.message” contains. The Bar struct will inherit every struct field from the Foo struct and does not come with behaviour or field definitions of its own. But it gets embedded by “Baz”, which defines the same function as “Foo.sayit()” but with a slightly different output. We created the example code in a way, so you will have the chance to identify within which struct scope a function executes.

When the code executes, you can tell it behaves the same way as in the before section code about the properties: Golang will search for the function defined on the “most outer” layer of the embedding hierarchy. If Golang can find no definition, it will try to identify a function definition in the lower layers

Golang allows extending struct behaviours by embedding parent structs into child structs because the outer (child functions and fields) will overwrite the behaviour of the inner (parent functions and parent fields), but only on the scope of the outer struct.

Conclusion

The Open Closed Principle is valid similarly with structs as it would be valid with classes in more object-oriented programming languages. The one thing that is very different with Golang compared to classic object-oriented languages is that Golang child structs are not allowed to substitute their parents. However, make sure to work your way around this challenge by using Golang interfaces. Learn how to do so by reading our article on the Liskov Substitution Principle with Golang.