rollwagen / go-ood

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

A path to OOD with Go - Workshop

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.

Logistics:

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

Schedule

  • 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

Introduction to Object-Oriented Programming

What is OOP?

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.

Is Go an Object-Oriented language?

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.

Do you need OOP?

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.

Exercise 1 - Understanding the benefits:

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.

Run the tests:

# 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 ;)

Exercise 1 - how does it all work?

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/godoc@v0.1.12
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:

  1. Cell - an alias type to int
  2. Coords - a new type defined as a pair of integers (an array of 2 ints)
  3. Direction - a new type with an underlying type int (enum)
  4. Maze - a generated 2D maze that is a struct
  5. Wall - a struct that holds 2 neighboring cells

We see that:

  1. There are no constructors in Go (since there are no classes), but we can create functions that serve as constructors.
  2. The godoc tool identified our constructor function New and added it under the Maze type.
  3. We have structs and they can have fields.
  4. You can define a new type out of any underlying type
  5. Any type can have methods (except for primitives)
  6. That means that any type satisfies the interface{} - an interface with no methods
  7. 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())
    }
  8. If you want to add methods to primitives, just define a new type with the desired primitive underlying type
  9. Methods are added to types using Receivers (value or pointer receivers).
  10. 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.

https://giphy.com/gifs/the-matrix-there-is-no- -3o6Zt0hNCfak3QCqsw

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.

Missing Inheritance?

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).

Finally, from the Go FAQ - is Go an object-oriented language?

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.

OO fundamentals and Go

So no CTORs, huh?

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:

  1. Provide a CTOR function.
  2. 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.
  3. Leave things public, comment clearly that the zero value of the type is not ready to use or should not be copied, etc.
  4. Provide default functionality when a required field is zero value.

Composition vs. Inheritance

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.

Exercise 2 - Interfaces and Embedding

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.

  1. We will extract the methods Alive, ApplyXPDiff, ApplyHealthDiff, Health and XP to a common type Character.
  2. We will embed Character inside Minion.
  3. Create new types Gopher and Rubyist that implement the pnp.Player interface, use the failing tests to do this purposefully, see how to run the tests below.
  4. Add NewGopher() and NewRubyist() in cmd/pnp/pnp.go to our list of players.
  5. Run the game.
  6. 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

Stringers

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.

What you need to know about how to work effectively with this feature:

  1. 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 and recover to prevent crashing the app or return errors if the interface allows it.
  2. 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()

The empty interface{} (any):

  • 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 use any as a shorthand for interface{}

Organizing your packages

Whether you choose the common structures with cmd, pkg, etc. you should try to follow certain guidelines:

  1. Support multiple binaries: Your packages structure should allow compiling multiple binaries (have multiple main packages that should be easy to find).
  2. 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.
  3. 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
  4. There are some exceptions to this for instance image/color is a dependency for image, but it's not the rule. In an application it's very easy to have cyclic imports this way.
  5. We already said this, but just to be clear: A package does not provide interfaces except for those it uses for its dependencies.
  6. Use godoc to see what your package looks like without the code. It helps.
  7. Keep your packages' hierarchy relatively flat. Just like your functions, imports don't do spaghetti well.
  8. 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.
  9. Your packages should describe tangible things that have clear boundaries - domain, app, utils, aren't things.
  10. 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.

Code generation, why? When?

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.

More Theory

Emerging patterns:

  1. Constructing complex objects with no constructors (or overloading) Functional options
  2. 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)
}

Short Lived Objects vs. Long Lived Objects

Consider this conversation

Generics

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:

  1. Repositories, databases, data structures that are type specific, etc.
  2. Event handlers and processors that are specific to a type.
  3. The concurrent map in the sync package which uses the empty interface.
  4. 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.

Exercise 3 - Generics

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

Conclusion

What we've learned today:

  1. The value of OOP
  2. Defining types that fit our needs
  3. Writing methods
  4. Value receivers and pointer receivers
  5. Organizing packages
  6. Interfaces
  7. Composition
  8. Generics
  9. To generate code otherwise

About

License:MIT License


Languages

Language:Go 97.6%Language:Makefile 1.6%Language:Dockerfile 0.8%