Go: Second Life of OOP
Greetings, most excellent fellow developer!
I’ve been writing code in Java, Python and Perl professionally, but I’ve recently discovered Go and I’d like to experiment with it and see what works and what doesn’t 🤓
You’ve probably seen Go as well, it’s different in its OOP approach.
Is Go Object Oriented?
First of all, let’s agree on what we’re looking for:
At its core, an object
is some data
(a block of memory) with attached logic
(behavior) that can access that data.
This is exactly what Go provides and this isn’t exactly new. Go seems to draw inspiration from (or from the same sources) as Perl that also has package-level entities and logic attached to existing structural primitives (hash tables, lists etc) rather than full-fledged classes.
Although it’s out of the scope of this post, but I have noticed that this is one of many parallels between Go and Perl and it’s fascinating to me how an effectively dead language has managed to influence one of the fastest growing language communities now. 🧐
Let’s now look at how we can define a new object type:
package main
import "fmt"
import "strings"
// this is a type alias that gives a name to a specific data structure
type StringTable struct {
values [][]string
}
// logic is defined outside of the type declaration, methods are attached to an existing type
func (s StringTable) NoOp() { }
// for example, let's define a readable CSV representation for the StringTable
func (s StringTable) String() string {
lines := make([]string, 0)
for _, row := range s.values {
lines = append(lines, strings.Join(row, ","))
}
return strings.Join(lines, "\n")
}
func main() {
// initialize the object
table := StringTable { values: [][]string{
[]string{ "A", "B", "C" },
[]string{ "1", "2", "3" },
} }
// call a method explicitly
table.NoOp()
// String() is called implicitly
fmt.Println(table)
}
This outputs our table data in a readable CSV format that we can feed into Excel, Python or any other application:
A,B,C
1,2,3
Try and run it yourself in Go Playground
Pillars of OOP: Encapsulation
This pillar is all about controlling access to the enclosed (encapsulated) data in an object. Traditionally, languages use private
and public
(and sometimes protected
) field modifiers to set access and visibility.
In Go access and visibility works on a package level and follows a convention of uppercase names for public
(visible outside of the current package) and lowercase for private
(visible within the package).
So, let’s iterate on the earlier example and extract StringTable
type into a separate package to see how this works!
First, we’ll need to run go mod init example
to initialize a project in the current directory and be able to import other modules under the same path.
Then, we’ll break the code into two files:
main.go:
package main
import "fmt"
import "example/stringtable"
func main() {
// initialize the object
table := stringtable.NewStringTable([][]string{
[]string{ "A", "B", "C" },
[]string{ "1", "2", "3" },
})
// here we cannot access values directly
// table.values would not work
// but we can use a function from the stringtable package that has access
stringtable.ExtractValues(table)
// String() is called implicitly
fmt.Println(table)
}
stringtable/table.go:
package stringtable
import "strings"
// note that StringTable starts with a capital, so it's visible outside of this package
type StringTable struct {
// this field though is package-private and we cannot use it directly in our main anymore
values [][]string
}
// we need a new constructor function to get an object with the values set
func NewStringTable(values [][]string) StringTable {
return StringTable{ values: values }
}
// as before, this method can still access the values field in the object
func (s StringTable) String() string {
lines := make([]string, 0)
for _, row := range s.values {
lines = append(lines, strings.Join(row, ","))
}
return strings.Join(lines, "\n")
}
// this non-attached function can also access private fields since they are in the same package
func ExtractValues(s StringTable) [][]string {
return s.values
}
Now this code still works and gives the same result as before, but we now have a little more control over what we expose or not outside of the stringtable
package.
Pillars of OOP: Inheritance and Polymorphism
This is where it gets tricker with Go.
Go doesn’t have the same familiar model as traditional programming languages. Instead of inheriting, you embed
objects which in many cases feels similar.
If you’re anything like me, this would give you false confidence in understanding how Go works, which is really frustrating when it does something completely different! 😕
I’d suggest always testing your assumptions with simple code first to get a sense for what works and what doesn’t util you feel confident in a new language.
Embedding
is actually quite fun to use, let’s go through an example and move our formatting logic into a separate type:
main.go:
package main
import "fmt"
import "example/stringtable"
func main() {
// initialize the object
table := stringtable.NewStringTable([][]string{
[]string{ "A", "B", "C" },
[]string{ "1", "2", "3" },
})
// go down the embedding graph (breadth-first) and call the first found GetFormat()
// if multiple found at the same embedding depth, throws a compile error
fmt.Println(table.GetFormat())
// String() is called implicitly
fmt.Println(table)
}
stringtable/table.go:
package stringtable
import "strings"
// let's decouple formatting logic into a separate type
type CSVFormatter struct {}
// this type doesn't own the values, but knows how to format them
func (f *CSVFormatter) FormatTable(values [][]string) string {
lines := make([]string, 0)
for _, row := range values {
lines = append(lines, strings.Join(row, ","))
}
return strings.Join(lines, "\n")
}
// this method call could be "proxied" from the embedding "parent"
func (f *CSVFormatter) GetFormat() string {
return "CSV"
}
type StringTable struct {
values [][]string
// embedded type
// no polymorphism here, it's just this type
// you cannot "assign" other types that embed CSVFormatter and "act" like it here
CSVFormatter
}
func NewStringTable(values [][]string) StringTable {
return StringTable{ values: values }
}
func (s StringTable) String() string {
// another example of "proxy" call into the embedded type
// this calls FormatTable on the CSVFormatter
return s.FormatTable(s.values)
}
Polymorphism in Go
Finally, polymorphism feels a little different as well.
Because there’s effectively no subtyping, there’s no way we can treat an object as its base (parent) class and not care about the actual implementation.
Except… there is.
Instead of polymorphism through inheritance, we have Go interfaces`. Various types can implement an
interface`` and still be treated the same in the top level logic.
How do you treat this image? 😉
Let’s now see how we can extend our string table type to support different formats in a uniform way.
Our main.go will stay the same, changes will only be in the stringtable
package.
stringtable/table.go:
package stringtable
import "strings"
// our existing CSV formatter
type CSVFormatter struct {}
func (f CSVFormatter) FormatTable(values [][]string) string {
lines := make([]string, 0)
for _, row := range values {
lines = append(lines, strings.Join(row, ","))
}
return strings.Join(lines, "\n")
}
// fancy HTML formatter, now we'll be able to put our data on the Web! :)
type HTMLFormatter struct {}
func (f HTMLFormatter) FormatTable(values [][]string) string {
lines := make([]string, 0)
for _, row := range values {
vals := make([]string, 0)
for _, val := range row {
vals = append(vals, "<td>" + val + "</td>")
}
lines = append(lines, "<tr>" + strings.Join(vals, "") + "</tr>")
}
return "<table>" + strings.Join(lines, "\n") + "</table>"
}
// common interface for the formatters above
// interfaces are implemented implicitly in Go
// there's no need to declare that a type "implements"
type Formatter interface {
FormatTable([][]string) string
}
type StringTable struct {
values [][]string
// a formatter interface could be embedded as well
Formatter
}
// retain old constructor for compatibility
func NewStringTable(values [][]string) StringTable {
return NewStringTableWithFormat(values, CSVFormatter {})
}
// new constructor that accepts a custom formatter!
func NewStringTableWithFormat(values [][]string, formatter Formatter) StringTable {
return StringTable{ values, formatter }
}
func (s StringTable) String() string {
return s.FormatTable(s.values)
}
What we have learnt
I hope you had fun reading through this OOP refactoring! Throughout all of the changes in this post, the program output that we got in the first code snippet remained the same. In the end however we got to a point when we have well structured, modular and easily maintainable and extendable OOP code.
We have looked at:
- Difference between Go types and traditional classes
- Different approach to encapsulation in Go with package-level visibility and access
- How we can write well-structured code with
embedding
instead ofinheritance
and a little different polymorphism
If you like this material, bookmark my blog :)
Thank you for reading! You’ve been awesome!
Member discussion