https://github.com/ronna-s/go-ood/ [Clone me!]
This workshop is aimed to clarify the OOP features that Go provides. It is named A Path to OOD and not OOP because different language features mean different design concepts.
All exercises can be completed using the go tool, docker or a combination docker and the make tool. If you are planning to use the go tool directly you can skip this step.
If planning to use docker and you don't have the make tool, run:
docker build . -t go-ood
If you have the make tool and docker, run:
make build
- 13:00-13:10: Introduction to Object-Oriented Programming link
- 13:10-13:40: Exercise 1 - understanding the benefits link
- 13:40-13:50: Exercise 1 - How does it all work? link
- 13:50-14:00: Break
- 14:00-14:10: Object Oriented Fundamentals and Go link
- 14:10-14:50: Exercise 2 - Interfaces and Embedding link
- 14:50-15:00: Break
- 15:00-15:10: Organizing your Packages link
- 15:10-15:20: Code Generation link
- 15:20-15:30: More Theory link
- 15:30-15:50: Generics link
- 15:50-16:00: Break
- 16:00-16:50: Exercise 3 - Generics link
- 16:50-17:00: Conclusion
What we can all agree on: The central idea behind Object-Oriented is to divide software into "things" or "objects" or "instances" that communicate via "messages" or "methods" or "member functions". Or in short, combining data and functionality. This core idea has not changed in the 4-5+ decades since it was conceptualized. It is meant to allow the developer to build code and separate responsibilities or concerns just like in the real world which is what we are familiar with and how we generally think and solve problems.
It is important to know that in most OOP languages:
- Objects are instances of a class because only classes can define methods (that's how we support messaging).
- Classes have constructor methods that allow for safe instantiation of objects.
- Classes can inherit methods and fields from other classes as well as override them and sometimes overload them (we will get to that later).
- In case of overriding and overloading methods, the method that will eventually run is decided at runtime. This is called late binding or dynamic binding.
Go doesn't offer classes, which means there are no constructors (or destructors) and no inheritance, etc. There is also no late or late late or late late late binding in Go (but there's something else, we'll get to that). These are technical concepts that have become synonymous with Object-Oriented Programming. Go does have a variety of very strong features for Object-Oriented Programming that enable Gophers to express their code in a manner that follows the OO principals. In the end, the answer to the question is Go an OOP language depends on the answer to the question "is t an object" in this sample code
package main
import "fmt"
type MyThing int //Creates a new type MyThing with an underlying type int
// Foo is now a method of my MyThing, in many languages to have a method you have to have a class or a struct
func (t MyThing) Foo() int {
return int(t)
}
func main() {
var t MyThing = 1
fmt.Println(t.Foo()) // Q: is t an object?
}
Whether you think t is an object or not, no gopher is complete without all the tools in the gopher toolbox so let's get (re)acquainted with them.
Just like in the real world, wherever there are things, there can be a mess. That's why Marie Kondo. Just as you can write insane procedural code, you can write sane OO code. You and your team should define best practices that match your needs. This workshop is meant to give you the tools to make better design choices.
Where we will understand some OO basics using an example of a gopher and a maze.
*This exercise is heavily inspired by the Intro to CS first home assignment that Prof. Jeff Rosenschein gave my Intro to CS class in 2003.
To get a sense of what strong OOP can do for us, solve a maze given a Gopher that can perform 4 actions:
// Gopher is an interface to an object that can move around a maze
type Gopher interface {
Finished() bool // Has the Gopher reached the target cell?
Move() error // The Gopher moves one step in its current direction
TurnLeft() // The Gopher will turn left
TurnRight() // The Gopher will turn right
}
Find the function SolveMaze(g Gopher)
in cmd/maze/maze.go and implement it.
# go tool
go test github.com/ronna-s/go-ood/cmd/maze
# make + docker (linux, mac)
make test-maze
# docker directly (linux, mac)
docker run -v $(pwd):/root --rm -it go-ood go test github.com/ronna-s/go-ood/cmd/maze
# docker on windows + powershell
docker run -v $(PWD):/root --rm -it go-ood go test github.com/ronna-s/go-ood/cmd/maze
# docker on windows without powershell
docker run -v %cd%:/root --rm -it go-ood go test github.com/ronna-s/go-ood/cmd/maze
The test checks for very basic navigation. You can also check what your code is doing by running:
# go tool
go run cmd/maze/maze.go > tmp/maze.html
# make + docker
make run-maze > tmp/maze.html
# any other setup with docker
[docker command from before] go run cmd/maze/maze.go > tmp/maze.html
Open tmp/maze.html file in your browser to see the results of your code. You can run the app multiple times to see your gopher running through different mazes.
Done? If not, don't worry. You have the entire conference ;)
Let's review the code that made this possible and examine the Go features it uses.
Run:
# make tool + docker
make godoc
# using docker
docker run --rm -p 8080:8080 go-ood godoc -http=:8080
# or, install godoc and run
go install golang.org/x/tools/cmd/[email protected]
godoc -http=:8080 #assuming $GOBIN is part of your path. For help run `go help install`
The repo started with one package in the pkg directory called maze which offers a basic maze generator and nothing else. Go to: http://127.0.0.1:8080/pkg/github.com/ronna-s/go-ood/pkg/maze
The package defines 5 types:
- Cell - an alias type to int
- Coords - a new type defined as a pair of integers (an array of 2 ints)
- Direction - a new type with an underlying type int (enum)
- Maze - a generated 2D maze that is a struct
- Wall - a struct that holds 2 neighboring cells
We see that:
- There are no constructors in Go (since there are no classes), but we can create functions that serve as constructors.
- The godoc tool identified our constructor function New and added it under the Maze type.
- We have structs and they can have fields.
- You can define a new type out of any underlying type
- Any type can have methods (except for primitives)
- That means that any type satisfies the interface{} - an interface with no methods
- You can alias to any type, but what does alias mean?
// https://go.dev/play/p/SsSQOAFa5Eh package main import "fmt" type A int type B = A func (b B) Foo() int { return int(b) } func main() { var a A = 5 fmt.Println(a.Foo()) }
- If you want to add methods to primitives, just define a new type with the desired primitive underlying type
- Methods are added to types using Receivers (value or pointer receivers).
- Methods that can change/mutate the value of the type need a pointer receiver (the common practice says not to mix receiver types)
Speaking of "Receivers", Remember that we said that OO is about objects communicated via messages? The idea for the receiver was borrowed from Oberon-2 which is an OO version of Oberon. But the receiver is also just a special function parameter, so "there is no spoon" (receiver) but from a design perspective there is.
How do we know that there's no actual receiver? Run this code
// https://go.dev/play/p/iOx0L_p65jz
package main
import "fmt"
type A struct{}
func (a *A) Foo() string {
return "Hi from foo"
}
func main() {
var a *A //a is nil
fmt.Println(a.Foo())
}
Let's proceed to examine the maze code, navigate around to see the travel
package, then the robot
package and finally the main
package in cmd/maze
That package defines the interface that abstracted away our robot.Robot
struct into the Gopher
interface. This ability that Go provides is not common.
The common OOP languages approach is that class A must inherit from class B or implement interface I in order to be used as an instance of B or I, but our Robot type has no idea that Gopher type even exists. Gopher is defined in a completely different package that is not imported by robot. Go was written for the 21st century and allows you to plug-in types into your code from anywhere on the internet so long that they have the correct method signatures.
Scripting languages achieve this with duck-typing, but Go is type-safe and we get compile time validation of our code. Implicit interfaces mean that packages don't have to provide interfaces to the user, the user can define their own interface with the smallest subset of functionality that they need.
In fact our robot.Robot
has another public method Steps
that is not part of the Gopher
interface because we don't need to use it.
This makes plugging-in code and defining and mocking dependencies safely a natural thing in Go and makes the code minimal to its usage.
In conclusion: before you write code make sure it's necessary. Be lazy. Be minimal. Be Marie Kondo.
The problem with object-oriented languages is they've got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle. (Joe Armstrong)
What did he mean by that?
He likely meant that OO is overcomplicated but in reality those rules that we discussed that apply to common OOP languages cause this complication:
The class Banana will have to extend or inherit from Fruit (or a similar Object class) to be considered a fruit, implement a Holdable interface just in case we ever want it to be held, implement a GrowsOnTree just in case we need to know where it came from. etc. What happens if the Banana we imported doesn't implement an interface that we need it to like holdable? We have to write a new implementation of Banana that wraps the original Banana.
Most OO languages limit inheritance to allow every class to inherit functionality from exactly one other class. That means that you can't express that an instance of class A is an instance of class B and class C, for example: a truck can't be both a vehicle and also a container of goods. In the case where you need to express this you will end up doing the same as you would do in Go with interfaces, except as we saw the Go implicit interface implementation is far more powerful. In addition, common language that offer inheritance often force you to inherit from a common Object class which is why objects can only be class instances (and can't be just values with methods, like in Go).
Yes and no. Although Go has types and methods and allows an object-oriented style of programming, there is no type hierarchy. The concept of “interface” in Go provides a different approach that we believe is easy to use and in some ways more general. There are also ways to embed types in other types to provide something analogous—but not identical—to subclassing. Moreover, methods in Go are more general than in C++ or Java: they can be defined for any sort of data, even built-in types such as plain, “unboxed” integers. They are not restricted to structs (classes).
Also, the lack of a type hierarchy makes “objects” in Go feel much more lightweight than in languages such as C++ or Java.
Go doesn't provide us constructors that ensure that users of our types initialize them correctly, but as we saw, we can provide our own ctor function to make our types easy to use. Developers coming from other language often make types and fields private to ensure that users don't make mistakes. If your type is not straight-forward, the Go common practices are:
- Provide a CTOR function.
- The CTOR should return the type that works with all the methods properly so if the type has methods with pointer receivers it will likely return a pointer.
- Leave things public, comment clearly that the zero value of the type is not ready to use or should not be copied, etc.
- Provide default functionality when a required field is zero value.
In Go we don't have inheritance. To express that A is I we use interfaces. To express that A is made of B or composed of B we use embedding like so:
// https://go.dev/play/p/BcNhFRjQ988
type A int //Creates a new type A with an underlying type int
// Foo is now a method of my A
func (a A) Foo() int {
return int(a)
}
type B struct {
// B embeds A so B now has method Foo()
A
}
func (b B) Bar() int {
return int(b.A)
}
type I interface {
Foo() int
}
// to implement J we have to provide implementation for Foo() and Bar()
type J interface {
I
Bar() int
}
func main() {
var j J = B{1}
fmt.Println(j.Foo()) // 1
fmt.Println(j.Bar()) // 1
}
We see that we can embed both interfaces and structs.
We are going to add 2 types of players to the game P&P - Platforms and Programmers who will attempt to take on a Production environment.
The roles that we will implement are pnpdev.Gopher
, pnpdev.Rubyist
.
The player roles are going to be composed of the struct pnpdev.Character
for common traits like XP and Health.
Gopher and Rubyist will also need to implement their own methods for their individual Skills
and AsciiArt
.
Run the game with the minion player:
# go tool
go run cmd/pnp/pnp.go
# make + docker
make run-pnp
# any other setup with docker
[docker command from before] go run github.com/ronna-s/go-ood/cmd/pnp.go
// Player represents a P&P player
type Player interface {
Alive() bool
Health() int
XP() int
ApplyXPDiff(int) int
ApplyHealthDiff(int) int
Skills() []Skill
Art() string
}
We already have a type Minion in package pkg/pnpdev
with some implementations for a player.
- We will extract the methods
Alive
,ApplyXPDiff
,ApplyHealthDiff
,Health
andXP
to a common typeCharacter
. - We will embed
Character
insideMinion
. - Create new types
Gopher
andRubyist
that implement thepnp.Player
interface, use the failing tests to do this purposefully, see how to run the tests below. - Add
NewGopher()
andNewRubyist()
in cmd/pnp/pnp.go to our list of players. - Run the game.
- We notice that the Gopher and the Rubyist's names are not properly serialized... We will fix that in a moment.
To test our players:
# make + docker
make test-pnp
# go tool
go test github.com/ronna-s/go-ood/pkg/pnpdev
# any other setup with docker
[docker command from before] go test github.com/ronna-s/go-ood/pkg/pnpdev
As we saw our Rubyist and Gopher's name were not displayed properly.
We fix this by adding the String() string
method to them:
func (r Rubyist) String() string {
return "Rubyist"
}
func (g Gopher) String() string {
return "Gopher"
}
We run the game and see that it works as expected but what actually happened here? - String() is not part of the Player
interface?
We can check if a type implements an interface at runtime:
https://go.dev/play/p/6Ia8aGJS7Bc
package main
import "fmt"
type fooer interface {
Foo() string
}
type A struct{}
func (_ A) Foo() string {
return "Hello from A"
}
func main() {
var a interface{} = A{}
var i interface{} = 5
if v, ok := a.(fooer); ok {
fmt.Println(v.Foo())
} else {
panic("should not be called")
}
if v, ok := i.(fooer); ok {
panic("should not be called")
} else {
fmt.Println("v is nil:", v)
}
}
Go's print function checked at runtime if our types have the method String() string
by checking if it implements an interface with this method and then invoked it.
Russ Cox compared this to duck typing and explained how it works here.
It's particularly interesting that this information about what types implement what interfaces is cached at runtime to maintain performance. Even though we achieved this behavior without actual receivers that take in messages and check if they can handle them, from design perspective we achieved a similar goal.
This feature only makes sense when interfaces are implicit because in languages when the interface is explicit there's no way a type can suddenly implement a private interface that is used in our code.
- The user of your code might not know what interfaces they are expected to implement or might provide them but cause a panic. Use
defer
andrecover
to prevent crashing the app or return errors if the interface allows it. - If your type is expected to implement an interface, to protect against changes add a line to your code that will fail to compile if your type doesn't implement the interface, like so:
// In the global scope directly
var _ interface{ String() string } = NewGopher()
var _ interface{ String() string } = NewRubyist()
- Since all types can have methods, all types implement the empty interface (
interface {}
) which has no methods. - The empty interface has a built-in alias
any
. So you can now useany
as a shorthand forinterface{}
Whether you choose the common structures with cmd, pkg, etc. you should try to follow certain guidelines:
- Support multiple binaries: Your packages structure should allow compiling multiple binaries (have multiple main packages that should be easy to find).
- Don't try to reduce the number of your imports: If you have a problem it's probably the structure and unclear responsibilities, not the amount.
- An inner package is usually expected to extend the functionality of the upper package and import it (not the other way around), for example:
net/http
image/draw
- and the example in this repo
maze/travel
- There are some exceptions to this for instance
image/color
is a dependency forimage
, but it's not the rule. In an application it's very easy to have cyclic imports this way. - We already said this, but just to be clear: A package does not provide interfaces except for those it uses for its dependencies.
- Use godoc to see what your package looks like without the code. It helps.
- Keep your packages' hierarchy relatively flat. Just like your functions, imports don't do spaghetti well.
- Try to adhere to open/close principals to reduce the number of changes in your code. It's a good sign if you add functionality but not change everything with every feature.
- Your packages should describe tangible things that have clear boundaries - domain, app, utils, aren't things.
- Package path with
internal
cannot be imported. It's for code that you don't want to allow to import, not for your entire application. It's especially useful for anyone using your APIs to be able to import your models for instance.
I like this simple explanation by (Gabriele Tomassetti)[https://tomassetti.me/code-generation/]
The reasons to use code generation are fundamentally four:
- productivity;
- simplification;
- portability;
- consistency
It's about automating a process of writing repetitive error-prone code. Code generation is similar to meta-programming but we compile it and that makes it safer to run. Consider the simple stringer Consider Mockery Both were used to generate code for this workshop.
Also, I beg you to please commit your generated code. A codebase is expected to be complete and runnable.
- Constructing complex objects with no constructors (or overloading) Functional options
- Default variables, exported variables, overrideable and otherwise net/http also in this repo - the
pnp.Rand
function
// https://go.dev/play/p/8hiAeuJ90uz
package main
import (
"errors"
"fmt"
"net/http"
)
func main() {
http.ErrBodyNotAllowed = errors.New("my error")
fmt.Println(http.ErrBodyNotAllowed)
}
It was a long time consensus that "real gophers" don't need generics, so much so that around the time the generics draft of 2020 was released, many gophers still expressed that they are not likely to use them.
Let's understand first the point that they were trying to make.
Consider this code, made using C++.
We see here generic code (templates) that allows an event to add functions (listeners) to its subscribers.
Let's ignore for a second that this code adds functions, not objects and let's assume it did take in objects with the function Handle(e Event)
.
We don't need generics in Go to make this work because interfaces are implicit. As we saw already in C++ an object has to be aware of it's implementations, this is why to allow plugging-in of functionality we have to use generics in C++ (and in Java).
In Go this code would look something like this:
package main
import "fmt"
type Listener interface {
Handle(Event)
}
type Event struct {
Lis []Listener
}
func (e *Event) Add(l Listener) {
e.Lis = append(e.Lis, l)
}
func main() {
var l Listener
var e Event
e.Add(l)
fmt.Println(e)
}
We didn't need generics at all!
However, there are cases in Go where we have to use generics and until recently we used code generation for. Those cases are when the behavior is derived from the type or leaks to the type's behavior:
For example: The linked list
// https://go.dev/play/p/ZpAqvVFAIDZ
package main
import "fmt"
type Node[T any] struct { // any is builtin for interface{}
Value T
Next *Node[T]
}
func main() {
n1 := Node[int]{1, nil}
n2 := Node[int]{3, &n1}
fmt.Println(n2.Value, n2.Next.Value)
}
Example 2 - Addition
package main
import "fmt"
type A int
// Add takes any type with underlying type int
func Add[T ~int](i T, j T) T {
return i + j
}
func main() {
var i, j A
fmt.Println(Add(i, j))
}
Of course, you might not be likely to use linked lists in your day to day, but you are likely to use:
- Repositories, databases, data structures that are type specific, etc.
- Event handlers and processors that are specific to a type.
- The concurrent map in the sync package which uses the empty interface.
- The heap
The common thread to these examples is that before generics we had to trade generalizing certain behavior for type safety (or generate code to do so), now we can have both.
Implement a new generic Heap OOP style in pkg/heap
(as usual failing tests provided).
The heap is used by TOP OF THE POP! cmd/top
to print the top 10 Artists and Songs.
Test:
# go tool
go test github.com/ronna-s/go-oog/pkg/heap
# make + docker
make test-heap
# any other setup with docker
[docker command from before] go test github.com/ronna-s/go-ood/pkg/heap
Run our TOP OF THE POP app:
# go tool
go run cmd/top/top.go
# make + docker
make run-heap
# any other setup with docker
[docker command from before] go run cmd/top/top.go
What we've learned today:
- The value of OOP
- Defining types that fit our needs
- Writing methods
- Value receivers and pointer receivers
- Organizing packages
- Interfaces
- Composition
- Generics
- To generate code otherwise