Inversion of Control (IoC) – Inversion of Control is a method of software design. Its main idea is to separate control logic from business logic. Don’t write control logic inside business logic, because this will make control logic depend on business logic. Instead, it should be the other way around: make business logic depend on control logic. This kind of programming style can effectively reduce program complexity and improve code reuse.
We won’t talk about object-oriented design patterns here. Let’s take a look at an example in Go that uses the embed
structure.
Embedding and Delegation
Struct Embedding
In Go, we can easily embed one struct into another struct. As shown below:
type Widget struct {
X, Y int
}
type Label struct {
Widget // Embedding (delegation)
Text string // Aggregation
}
In the example above, we embed Widget
into Label
, so we can use it like this:
label := Label{Widget{10, 10}, "State:"}
label.X = 11
label.Y = 12
If a name conflict occurs in the Label
struct, it must be resolved. For example, if member X
is duplicated, label.X
refers to Label
’s own X
, while label.Widget.X
refers to the embedded one.
With this kind of embedding, we can design the structure layer by layer, like UI components. For example, we can create two new structs: Button
and ListBox
:
type Button struct {
Label // Embedding (delegation)
}
type ListBox struct {
Widget // Embedding (delegation)
Texts []string // Aggregation
Index int // Aggregation
}
Method Override
Then, we define two interfaces: Painter
for drawing components and Clicker
for click events:
type Painter interface {
Paint()
}
type Clicker interface {
Click()
}
Of course:
- For
Label
, it only hasPainter
, notClicker
. - For
Button
andListBox
, they both havePainter
andClicker
.
Here are some implementations:
func (label Label) Paint() {
fmt.Printf("%p:Label.Paint(%q)\n", &label, label.Text)
}
Since this interface can be brought into the new struct through Label
embedding, we can override it in Button
:
func (button Button) Paint() { // Override
fmt.Printf("Button.Paint(%s)\n", button.Text)
}
func (button Button) Click() {
fmt.Printf("Button.Click(%s)\n", button.Text)
}
func (listBox ListBox) Paint() {
fmt.Printf("ListBox.Paint(%q)\n", listBox.Texts)
}
func (listBox ListBox) Click() {
fmt.Printf("ListBox.Click(%q)\n", listBox.Texts)
}
Here, we need to especially emphasize that the Button.Paint()
interface can be inherited from Label
via embedding. If Button.Paint()
is not implemented, it will call Label.Paint()
. Therefore, defining Paint()
inside Button
is essentially an override.
Embedded Struct Polymorphism
Through the following program, we can see how the whole polymorphism works:
button1 := Button{Label{Widget{10, 70}, "OK"}}
button2 := NewButton(50, 70, "Cancel")
listBox := ListBox{Widget{10, 40},
[]string{"AL", "AK", "AZ", "AR"}, 0}
for _, painter := range []Painter{label, listBox, button1, button2} {
painter.Paint()
}
for _, widget := range []interface{}{label, listBox, button1, button2} {
widget.(Painter).Paint()
if clicker, ok := widget.(Clicker); ok {
clicker.Click()
}
fmt.Println() // print a empty line
}
We can see that we can use interfaces for polymorphism, and also use the generic interface{}
for polymorphism, but it requires a type assertion.
Inversion of Control
Let’s look at another example. We have a data structure that stores integers, as shown below:
type IntSet struct {
data map[int]bool
}
func NewIntSet() IntSet {
return IntSet{make(map[int]bool)}
}
func (set *IntSet) Add(x int) {
set.data[x] = true
}
func (set *IntSet) Delete(x int) {
delete(set.data, x)
}
func (set *IntSet) Contains(x int) bool {
return set.data[x]
}
This implements three operations: Add()
, Delete()
, and Contains()
. The first two are write operations; the last one is a read operation.
Implementing Undo Functionality
Now we want to implement an Undo feature. We can wrap IntSet
into an UndoableIntSet
, as shown below:
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")
}
index := len(set.functions) - 1
if function := set.functions[index]; function != nil {
function()
set.functions[index] = nil // For garbage collection
}
set.functions = set.functions[:index]
return nil
}
In the above code, we can see:
- We embed
IntSet
inUndoableIntSet
, then override itsAdd()
andDelete()
methods. - The
Contains()
method is not overridden, so it’s brought intoUndoableIntSet
. - In the overridden
Add()
, we record aDelete()
operation. - In the overridden
Delete()
, we record anAdd()
operation. - The newly added
Undo()
method performs the undo operation.
Using this approach to extend new functionality onto existing code is a good choice. It balances reusing original functionality with adding new capabilities. However, the biggest problem with this approach is that Undo
functionality is actually control logic, not business logic. Therefore, reusing the Undo
function is problematic—because it embeds a lot of IntSet
-specific business logic.
Dependency Inversion
Now let’s look at another method:
We first declare a function interface that represents the function signature acceptable for Undo
control:
type Undo []func()
With the above protocol, our undo control logic can be written like this:
func (undo *Undo) Add(function func()) {
*undo = append(*undo, function)
}
func (undo *Undo) Undo() error {
functions := *undo
if len(functions) == 0 {
return errors.New("No functions to undo")
}
index := len(functions) - 1
if function := functions[index]; function != nil {
function()
functions[index] = nil // For garbage collection
}
*undo = functions[:index]
return nil
}
Don’t be surprised here—Undo
is just a type. It doesn’t have to be a struct. Being a function array is perfectly acceptable.
Then, we embed Undo
into our IntSet
, and use the above method inside Add()
and Delete()
to complete the functionality:
type IntSet struct {
data map[int]bool
undo Undo
}
func NewIntSet() IntSet {
return IntSet{data: make(map[int]bool)}
}
func (set *IntSet) Undo() error {
return set.undo.Undo()
}
func (set *IntSet) Contains(x int) bool {
return set.data[x]
}
func (set *IntSet) Add(x int) {
if !set.Contains(x) {
set.data[x] = true
set.undo.Add(func() { set.Delete(x) })
} else {
set.undo.Add(nil)
}
}
func (set *IntSet) Delete(x int) {
if set.Contains(x) {
delete(set.data, x)
set.undo.Add(func() { set.Add(x) })
} else {
set.undo.Add(nil)
}
}
This is inversion of control. The control logic Undo
no longer depends on the business logic IntSet
; instead, the business logic IntSet
depends on Undo
.
What it depends on is actually just a protocol, which is an array of no-parameter functions. As we can also see, our Undo
code is now reusable.