You Don't Need All That Code
Author: Alexander Avery
Posted:
#computer-science
Many blog articles and social media posts are shared online to demonstrate programming “patterns”. This isn’t a new phenomenon, but I only recently realized that most of these posts include code that accomplishes nothing. In the following sections, I will share examples I’ve found (written in Go) juxtaposed with the code actually required to accomplish the task.
The singleton pattern
type Service interface {
Do()
}
type EmailService struct{}
func (e *EmailService) Do() {
fmt.Println("Sending email...")
}
// Service Registry holds registered services
type ServiceRegistry struct {
services map[string]Service
}
func NewRegistry() *ServiceRegistry {
return &ServiceRegistry{
services: make(map[string]Service),
}
}
func (r *ServiceRegistry) Register(name string, s Service) {
r.services[name] = s
}
func (r *ServiceRegistry) Get(name string) Service {
return r.services[name]
}
// Usage
func main() {
reg := NewRegistry()
reg.Register("email", &EmailService{})
}
If you don’t look too closely, this looks pretty impressive. I mean, it’s a lot of code! It has to be doing something useful, right? If we take the poster’s word for it, this demonstrates the singleton pattern.
The first thing we’ll address is the ServiceRegistry
type.
It’s methods essentially wrap a map index expression, but eliminate the potential to use several built-in functions.
The caller can no longer call clear
, delete
, or len
on the map.
It is also impossible to use a for loop with a range clause.
Nor can you use the special form of the index expression v, ok := a[x]
.
If you want to use a map in this capacity, you can just use a map.
type service interface{ do() } // we'll keep the interface for now
var reg = map[string]service{
"email": &EmailService{},
}
// Usage
func main() {
defer clear(reg)
if e, ok := reg["email"]; ok {
e.do()
}
for k := range reg {
fmt.Println("key: ", k)
}
fmt.Println(len(reg))
}
But I think we can do better without the map or interface.
var emailService = EmailService{}
// Usage
func main() {
emailService.SendMail()
doOtherStuff(&emailService)
}
That’s all the code you need. The original poster realizes this, but tricks themselves into seeing a benefit to the original program. The advice was given as follows:
- Define an interface with common methods
- Create one or more types that implement it
- Store them in a map to simulate multiple named singletons
- Provide Set and Get funcs to interact with them
This keeps your code testable and decoupled
Interfaces in Go are not about designing the interface, then creating types that implement them.
The idea is inverted, and you should instead notice where your methods already meaningfully overlap.
Well-designed types that satisfy interface { Do() }
aren’t all that common.
This is a sign that one is butchering methods to fit methods into a predetermined design.
An “email service” wouldn’t “Do”. Instead, it would probably “Ehlo”, “Quit,” and “Data,” among other things. That isn’t to say you should define an interface with all those methods. It does demonstrate, however, that there isn’t a meaningful overlap between “service” methods. As services do different things, they should be different types with distinct methods.
Finally, storing things in a map doesn’t really simulate anything, nor does it decouple code. What it does is create a level of indirection to access desired values. To use the map, you must do at least one of the following.
- Use the map as a global variable.
- Pass the full map to each function that requires at least one service from it.
- Immediately (or repeatedly) unpack the map at runtime to see if each value exists.
Option one is just another global variable. Option two introduces coupling in another format. And option three adds extra code to get around the map that you also wrote extra code to set up.
The countdown timer
In our next example, the poster demonstrates features of the time
package by writing a countdown timer.
func countdownTimer(duration time.Duration) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
done := make(chan bool)
go func() {
time.Sleep(duration)
done <- true
}()
remaining := duration
fmt.Printf("Countdown started: %s remaining\n", remaining.Round(time.Second))
for {
select {
case <-done:
fmt.Println("Countdown finished!")
return
case <-ticker.C:
remaining -= time.Second
fmt.Printf("Time remaining: %s\n", remaining.Round(time.Second))
}
}
}
func main() {
countdownTimer(10 * time.Second)
}
The first thing that jumps out to me is the goroutine.
As useful as goroutines are, they are often misapplied.
Using the channel, done
, necessitates a separate time.Sleep
within the goroutine to keep time.
There are also repeated calls to remaining.Round
, and probably more fmt.Printf
than needed.
Overall, much of the interesting timing code is mired in superfluous prints and unnecessary calculations.
func countdown(d, i time.Duration) {
defer fmt.Println("BLAST OFF!")
t, tk := time.NewTimer(d), time.NewTicker(i)
defer tk.Stop()
for {
fmt.Println(d)
select {
case <-tk.C:
d -= i
case <-t.C:
return
}
}
}
func main() {
countdown(time.Second * 5, time.Second)
}
Since Go 1.23, you don’t need the time.Ticker
(and if d
is always a multiple of i
, you don’t even need the time.Timer
).
func countdown(d, i time.Duration) {
defer fmt.Println("BLAST OFF!")
t:= time.NewTimer(d)
for {
fmt.Println(d)
select {
case <-time.After(i):
d -= i
case <-t.C:
return
}
}
}
Memento state machine
type Originator struct {
state string
}
func (e *Originator) createMemento() *Memento {
return &Memento{state: e.state}
}
func (e *Originator) restoreMemento(m *Memento) {
e.state = m.getSavedState()
}
func (e *Originator) setState(state string) {
e.state = state
}
func (e *Originator) getState() string {
return e.state
}
type Memento struct {
state string
}
func (m *Memento) getSavedState() string {
return m.state
}
type Caretaker struct {
mementoArray []*Memento
}
func (c *Caretaker) addMemento(m *Memento) {
c.mementoArray = append(c.mementoArray, m)
}
func (c *Caretaker) getMemento(index int) *Memento {
return c.mementoArray[index]
}
func main() {
caretaker := &Caretaker{
mementoArray: make([]*Memento, 0),
}
originator := &Originator{
state: "A",
}
fmt.Printf("Originator Current State: %s\n", originator.getState())
caretaker.addMemento(originator.createMemento())
originator.setState("B")
fmt.Printf("Originator Current State: %s\n", originator.getState())
caretaker.addMemento(originator.createMemento())
originator.setState("C")
fmt.Printf("Originator Current State: %s\n", originator.getState())
caretaker.addMemento(originator.createMemento())
originator.restoreMemento(caretaker.getMemento(1))
fmt.Printf("Restored to State: %s\n", originator.getState())
originator.restoreMemento(caretaker.getMemento(0))
fmt.Printf("Restored to State: %s\n", originator.getState())
}
The above program creates a swarm of types to create what amounts to a very basic state machine.
There is a crazy number of getters and setters, and getMemento(i int)
is very error-prone.
The original article suggests that such a state machine could be used in a context where snapshots are taken of a type with many more fields.
Then, using the restoreMemento
method, you can restore state to any one of these snapshots.
Without mapping it out in detail, it seems like that model would create a lot of redundant state.
Here is a version, using the concept of self-referential functions, that gets the job done and demonstrates the power of some overlooked Go features.
type option func(*Foo) option
type Foo struct {
innerInt int
innerString string
}
// Option sets the options specified.
// It returns an option to restore the last arg's previous value.
func (f *Foo) Option(opts ...option) (previous option) {
for _, opt := range opts {
previous = opt(f)
}
return previous
}
func InnerInt(v int) option {
return func(f *Foo) option {
previous := f.innerInt
f.innerInt = v
return InnerInt(previous)
}
}
func InnerString(v string) option {
return func(f *Foo) option {
previous := f.innerString
f.innerString = v
return InnerString(previous)
}
}
func main() {
printFoo := func(f Foo) { fmt.Printf("myFoo: %+v\n", f) }
var myFoo Foo
printFoo(myFoo)
intZero := myFoo.Option(InnerInt(1))
printFoo(myFoo)
stringZero := myFoo.Option(InnerString("A"))
printFoo(myFoo)
stringA := myFoo.Option(InnerString("B"))
printFoo(myFoo)
myFoo.Option(stringA)
printFoo(myFoo)
myFoo.Option(intZero, stringZero)
printFoo(myFoo)
}
Instead of storing redundant fields, you only need to store closures (in any data structure you want) to return to a previous state.
There is also no difference between how Foo.Option
handles new state changes or state restorations.
Notice how in the original example, originator
has two separate methods, setState
and restoreMemento
, that basically do the same thing.
Why do tutorials do this so often?
All the simplified examples (except the functional options) appear underwhelming. That underwhelming feeling is the driving force behind the needlessly complex tutorials. Simple code that solves simple problems doesn’t look impressive. Simple code that solves difficult problems takes lots of effort and thought. As a result, what gets posted are simple problems solved with whatever code blows up the line count to some aesthetic visual minimum.