Introduction
Especially in multi-threaded applications it can be necessary to synchronize properties between objects, or at least be notified of changes on certain properties. This is where the Binding Properties comes in, which is basically a form of the Observer
pattern.
In this pattern, observers can subscribe to a special ‘event’-handler on an object. When a property changes, all the subscribers, if any, will be notified.
The flow is as follows:
- An object with bindable properties is created
- One or more observers subscribe to this object
- If a property changes in this object, the subscribers get notified and handle the change accordingly.
Implementation in Go
In this example we will build a simple Person
struct. In our world, a person just has a name and an age. This struct has a number, or as the case may be no, observers which will be notified any time a property changes:
package main
import (
"fmt"
"strconv"
"sync"
)
type Observer func(propertyName, newValue string)
type Person struct {
name string
age int
observers []Observer
mu sync.Mutex
}
func NewPerson(name string, age int) *Person {
return &Person{
name: name,
age: age,
}
}
func (p *Person) GetName() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.name
}
func (p *Person) SetName(name string) {
p.mu.Lock()
p.name = name
p.mu.Unlock()
p.notifyObservers("name", name)
}
func (p *Person) GetAge() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.age
}
func (p *Person) SetAge(age int) {
p.mu.Lock()
p.age = age
p.mu.Unlock()
p.notifyObservers("age", strconv.Itoa(age))
}
func (p *Person) Subscribe(observer Observer) {
p.mu.Lock()
defer p.mu.Unlock()
p.observers = append(p.observers, observer)
}
func (p *Person) notifyObservers(property, value string) {
p.mu.Lock()
defer p.mu.Unlock()
for _, observer := range p.observers {
observer(property, value)
}
}
A few notes:
- We use
sync.Mutex
, a standard library function in Go, to protect shared data from concurrent access. - The
observers
slice holds the list of observers, and we use the mutex to synchronize access to this slice. - In the two
Get
lock the mutex to guarantee exclusive access. We then use thedefer
keyword to make sure the mutex is unlocked, even in the case of a panic. - In the two
Set
methods we lock the mutex as well, set the appropiate value, and then unlock the mutex. We do this before calling thenotifyObservers()
method, informing the observers about the change. We do this to avoid a possible deadlock, by not holding a lock while we call the observers. - In the
Subscribe()
method we add an observer in a thread-safe manner, by locking and unlocking our mutex. - For the
notifyObservers()
method, which is called from the twoSet
methods, we lock the mutex to ensure the notification is thread-safe. Again, we use thedefer
method to unlock the mutex, even if the method panics.
Testing time
Now we can test our setup:
func main() {
person := NewPerson("Test", 55)
person.Subscribe(func(property, value string) {
fmt.Printf("main thread: %s changed to %s\n", property, value)
})
go func() {
person.SetName("Jane")
person.SetAge(21)
person.Subscribe(func(property, value string) {
fmt.Printf("goroutine: %s changed to %s\n", property, value)
})
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
person.SetName("John")
}()
person.SetAge(32)
wg.Wait()
}
Here you can see we simulate concurrent updates to the Person
struct, by using a goroutine. We use a sync.WaitGroup
to wait for the goroutine to finish execution before the program terminates.
Conclusion
Implementing this pattern in Go is quite straightforward, using the sync
package. The code is also quite clear. One possible enhancement could be to set a different subscriber for each property, so that our pattern becomes a bit more fine-grained. However, that is the subject for a next post.