Go program pattern 03: Inversion of Control

In the previous article, I briefly introduced the composite pattern in Go, which was explained in a simple manner. We understood that Go can achieve polymorphism in object-oriented programming through composition.

This article is first published in the medium MPP plan. If you are a medium user, please follow me in medium. Thank you very much.

In this article, let’s learn about Inversion of Control (IoC). Inversion of Control is a software design method that involves separating control logic from business logic. Instead of writing control logic within the business logic, which creates a dependency of control logic on business logic, IoC reverses this relationship and makes the business logic dependent on the control logic.

Inversion of Control

Let’s consider an example where we want to implement a functionality to record the existence of numbers. We can easily implement the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type IntSet struct {  
data map[int]struct{}
}

func NewIntSet() IntSet {
return IntSet{make(map[int]struct{})}
}
func (set *IntSet) Add(x int) {
set.data[x] = struct{}{}
}
func (set *IntSet) Delete(x int) {
delete(set.data, x)
}
func (set *IntSet) Contains(x int) bool {
_, ok := set.data[x]
return ok
}

The above code uses a map to store numbers and provides functionalities for adding, deleting, and checking the existence of numbers. Everything seems perfect.

Now, suppose we want to add an undo feature to this functionality. How can we do that? With a little thought, we can write clear code by wrapping IntSet into UndoableIntSet. Here’s the code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type UndoableIntSet struct { // Poor style
IntSet // Embedding (delegation)
functions []func()
}

func NewUndoableIntSet() UndoableIntSet {
return UndoableIntSet{NewIntSet(), nil}
}


func (set *UndoableIntSet) Add(x int) { // Override
if !set.Contains(x) {
set.data[x] = true
set.functions = append(set.functions, func() { set.Delete(x) })
} else {
set.functions = append(set.functions, nil)
}
}


func (set *UndoableIntSet) Delete(x int) { // Override
if set.Contains(x) {
delete(set.data, x)
set.functions = append(set.functions, func() { set.Add(x) })
} else {
set.functions = append(set.functions, nil)
}
}

func (set *UndoableIntSet) Undo() error {
if len(set.functions) == 0 {
return errors.New("No functions to undo")
}
// invert the order of calls
index := len(set.functions) - 1
if function := set.functions[index]; function != nil {
function()
}
set.functions = set.functions[:index]
return nil
}

This approach is a good choice for extending existing code with new functionalities. It allows for a balance between reusing the existing code and adding new features. However, the main issue with this approach is that the Undo operation is actually a form of control logic, not business logic. The Undo feature cannot be reused because it contains a lot of business logic related to IntSet.

Dependency Inversion

Let’s explore another implementation approach where we extract the undo feature and make IntSet depend on it:

1
2
3
4
5
6
7
8
9
10
11
12
type Undo []func()
func (undo *Undo) Add(u func()) {
*undo = append(*undo, u)
}
func (undo *Undo) Undo() {
if len(*undo) == 0 {
return
}
index := len(*undo) - 1
(*undo)[index]()
*undo = (*undo)[:index]
}

Next, we embed Undo in IntSet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type IntSet struct {  
data map[int]struct{}
undo Undo
}

func NewIntSet() IntSet {
return IntSet{make(map[int]struct{}), make(Undo, 0)}
}
func (set *IntSet) Undo() {
set.undo.Undo()
}
func (set *IntSet) Add(x int) {
if set.Contains(x) {
return
} else {
set.undo.Add(func() {
set.Delete(x)
})
set.data[x] = struct{}{}
}
}
func (set *IntSet) Delete(x int) {
if !set.Contains(x) {
return
} else {
set.undo.Add(func() {
set.Add(x)
})
delete(set.data, x)
}
}
func (set *IntSet) Contains(x int) bool {
_, ok := set.data[x]
return ok
}

In our application, we can use it as follows:

1
2
3
4
5
6
7
8
9
10
11
12
func main() {  
set := NewIntSet()
set.Add(1)
set.Add(2)
fmt.Println(set.Contains(2))
set.Undo()
fmt.Println(set.Contains(2))
set.Delete(1)
fmt.Println(set.Contains(1))
set.Undo()
fmt.Println(set.Contains(1))
}

Output:

1
2
3
4
5
/Users/hxzhouh/Library/Caches/JetBrains/GoLand2023.3/tmp/GoLand/___go_build_github_com_hxzhouh_go_example_pattern_ioc
true
false
false
true

This is Inversion of Control, where the control logic Undo no longer depends on the business logic IntSet, but rather the business logic IntSet depends on Undo. Now, the Undo feature can be easily used by other business logics.