An example of solving a typical OOP problem in Go

  • Tutorial
Recently, an entertaining article with a similar title about the Haskell language caught my eye . The author suggested that the reader follow the thought of a programmer who solves a typical OOP problem but in Haskell. In addition to the obvious benefits of broadening readers' perceptions that OOP is by no means “classes” and “inheritance”, such articles are useful for understanding how to use the language correctly. I suggest the reader to solve the same problem, but in the Go language, in which OOP is also implemented unusually.

Task


So, the original task looked something like this: there are graphic primitives (figures) with different properties, but the same actions can be performed on each figure. Primitives should be able to give information about themselves in a certain format, which some function will output to some output, for simplicity of the example - to stdout. However, some primitives may be variations of others.

The format for outputting information, using the example of a rectangle and a circle, should be like this:
paint rectangle, Rect {left = 10, top = 20, right = 600, bottom = 400}
paint circle, radius = 150 and center = (50,300)

In addition, primitives need to be able to combine into a homogeneous list.

Decision


Structures and Properties

Let's start with the obvious - with the declaration of primitives and their properties. Structures are responsible for the properties in Go, so just declare the necessary fields for the Rectangle and Circle primitives:
type Rectangle struct {
	Left, Right, Top, Bottom int64
}
type Circle struct {
	X, Y, Radius int64
}

Go does not greatly welcome abbreviations in one line - it is better to put each field on a separate line, but for such a simple example it is excusable. Type int64 is selected as the base. In the future, if speed optimizations are really needed, it will be possible to select the type more successfully based on the real task, say uint16, or try to change the structure so that the alignment of fields in memory is effectively used, but do not forget that premature optimization is evil. Such needs to be dealt with only if there really is a need. So far, boldly choose int64.

We will write the names of fields and methods with a capital letter, since this is not a library, but an executable program, and visibility outside the package is not important to us (in Go, the name with a capital letter is an analogue of public, with a small one - private).

Interfaces and Behavior

Further, by definition of the original problem, primitives should be able to give information about themselves in a certain format and give the value of the area of ​​the primitive. How do we do this in Go if we don't have classes and “normal OOP”?

Here in Go one does not even have to guess, since the definition of “properties” and “behavior” is very clearly separated in the language. Properties are structures, behavior are interfaces. This simple and powerful concept immediately gives us the answer what to do next. We determine the desired interface with the necessary methods:
type Figure interface {
	Say() string
	Square() float64
}

The choice of the interface name (Figure) here is dictated by the original example and task, but usually in Go interfaces , especially with one method, are called with the suffix -er - Reader, Painter, Stringer and so on. In theory, the name should help to understand the purpose of the interface and reflect its behavior. But in this case, Figure is quite well suited and describes the essence of the “figure” or “graphic primitive”.

Methods

Now, in order for the Rectangle and Circle types to become “shapes,” they must satisfy the Figure interface, that is, the Say and Square methods must be defined for them. Let's write them:
func (r Rectangle) Say() string {
	return fmt.Sprintf("rectangle, Rect {left=%d,top=%d,right=%d,bottom=%d)", r.Left, r.Top, r.Right, r.Bottom)
}
func (r Rectangle) Square() float64 {
	return math.Abs(float64((r.Right - r.Left) * (r.Top - r.Bottom)))
}
func (c Circle) Say() string {
	return fmt.Sprintf("circle, radius=%d and centre=(%d,%d)", c.Radius, c.X, c.Y)
}
func (c Circle) Square() float64 {
	return math.Pi * math.Pow(float64(c.Radius), 2)
}

What you should pay attention to is the receiver of the method, which can be a value (as of now - “c Circle”), or it can be a pointer to "(c * Circle)". The general rule here is - if the method should change the value of c or if Circle is a huge structure that takes up a lot of memory space - then use a pointer. In other cases, it will be cheaper and more efficient to transfer the value as a receiver of the method.

More experienced gophers will notice that the Say method is exactly like the standard Stringer interface., which is used in the standard library, including the fmt package. Therefore, you can rename Say to String, remove this method altogether from the Figure interface, and then simply pass an object of this type to the fmt function for output, but for now let’s leave it that way, for clarity and similarity with the original solution.

Constructors

Actually, everything - now you can create a Rectangle or Circle structure, initialize its values, save it in a slice ( dynamic array in Go ) of the [] Figure type and pass it to a function that takes a Figure and calls the Say or Square methods for further work with our graphic primitives. For example, like this:
func main() {
	figures := []Figure{
		NewRectangle(10, 20, 600, 400),
		NewCircle(50, 300, 150),
	}
	for _, figure := range figures {
		fmt.Println(figure.Say())
	}
}
func NewRectangle(left, top, right, bottom int64) *Rectangle {
	return &Rectangle{
		Left:   left,
		Top:    top,
		Right:  right,
		Bottom: bottom,
	}
}
func NewCircle(x, y, radius int64) *Circle {
	return &Circle{
		X:      x,
		Y:      y,
		Radius: radius,
	}
}

The NewRectangle and NewCircle methods are simply constructor functions that create new values ​​of the desired type by initializing them. This is a common practice in Go, such constructors can often still return an error if the constructor does more complex things, then the signature looks something like this:
func NewCircle(x, y, radius int64) (*Circle, error) {...}

You may also see signatures with the Must prefix instead of New - MustCircle (x, y, radius int64) * Circle - this usually means that the function will throw panic in case of an error.

We delve into the topic

An observant reader may notice that we put * Rectangle and * Circle type variables (that is, a Rectangle pointer and a Circle pointer) into the array of figures ([] Figure), although we still defined the methods by value and not by pointer (func (c Circle) Say () string). But this is the correct code, so Go works with method receivers , making life easier for programmers - if a type implements an interface, then a "pointer to this type" also implements it. It’s logical, isn’t it? But in order not to force the programmer to dereference the pointer once again, to tell the compiler “call the method” - the Go compiler will do it itself. But the reverse side - which is also obvious - this will not work. If the interface method is implemented for a "type pointer", then calling the method on a non-pointer variable will return a compilation error.

To call the Say method on each primitive, we simply go through the slice using the range keyword and print the output of the Say () method. It is important to understand that each variable of the interface type Figure contains inside information about the "specific" type. figure in a loop is always a type of Figure, and, at the same time, either Rectangle or Circle. This is true for all cases when you work with interface types, even with empty interfaces (interface {}).

We complicate the code


Further, the author complicates the task by adding a new "rounded rectangle" primitive - RoundRectangle. This, in fact, is the same Rectangle primitive, but with the additional property “radius of curvature". At the same time, in order to avoid code duplication, we must somehow reuse the ready-made Rectangle code.

Again, Go gives an absolutely clear answer on how to do this - there are no “multiple ways to do this” here. And this answer is embedding, or “embedding” one type in another. Like this:
type RoundRectangle struct {
	Rectangle
	RoundRadius int64
}

We define a new type-structure that already contains all the properties of the Rectangle type plus one new one - RoundRadius. Moreover, RoundRectangle already automatically satisfies the Figure interface , since it is satisfied by the built-in Rectangle. But we can redefine functions, and call functions of the built-in type directly, if necessary. Here's what it looks like:
func NewRoundRectangle(left, top, right, bottom, round int64) *RoundRectangle {
	return &RoundRectangle{
		*NewRectangle(left, top, right, bottom),
		round,
	}
}
func (r RoundRectangle) Say() string {
	return fmt.Sprintf("round rectangle, %s and roundRadius=%d", r.Rectangle.Say(), r.RoundRadius)
}

The type constructor uses the NewRectangle constructor, while dereferencing the pointer (since we embed a Rectangle, not a pointer to a Rectangle), and the Say method calls r.Rectangle.Say () so that the output is exactly the same as for Rectangle, without duplicating the code .

Embedding types in Go is a very powerful tool, you can even embed interfaces in interfaces, but this is not necessary for our task. I invite the reader to get to know this on their own.

Now just add a new primitive to the slice:
figures := []Figure{
		NewRectangle(10, 20, 600, 400),
		NewCircle(50, 300, 150),
		NewRoundRectangle(30, 40, 500, 200, 5),
}

As you can see, it was quite simple, we did not think about how to do this, we just used the necessary language tools for their most direct purpose. This allowed, without wasting time, to simply and quickly implement what we need.

Final edits


Although this code is a synthetic example, I will describe a couple of points that I would do next. First of all, I will write comments on all methods, even on constructors. The latter, of course, is not necessary, but I like the idea that it is enough to write one line at a time to get the documentation for the whole package using go doc, even if it is not needed yet, and in general, it is not a library, but a running program. But, if in the future such code is allocated to a separate library package, then we will automatically receive a documented package. Even though the descriptions so far are banal, it’s not difficult for me to spend 5 seconds writing one line of text, but there is a feeling of “completeness” of the code, and linters (go vet) will not swear, which is also nice.

Further, it seems logical to distribute the code into several separate files - leave the interface definition and main () in main.go, and create separate files for each primitive and its functions - circle.go, rectangle.go and roundrectangle.go. The description of the interface, however, can also be moved to a separate file.

The final touch will be a run through GoMetaLinter - this is a package that runs in parallel all linter and static code analyzers that can catch a lot of things and help, allowing you to make the code even better, cleaner and more readable. If gometalinter did not display messages - fine, the code is clean enough.

Full code here
main.go:
package main
import "fmt"
// Figure describes graphical primitive, which can Say
// own information and return it's Square.
type Figure interface {
	Say() string
	Square() float64
}
func main() {
	figures := []Figure{
		NewRectangle(10, 20, 600, 400),
		NewCircle(50, 300, 150),
		NewRoundRectangle(30, 40, 500, 200, 5),
	}
	for _, figure := range figures {
		fmt.Println(figure.Say())
	}
}

rectangle.go:
package main
import (
	"fmt"
	"math"
)
// Rectangle defines graphical primitive for drawing rectangles.
type Rectangle struct {
	Left, Right, Top, Bottom int64
}
// NewRectangle inits new Rectangle.
func NewRectangle(left, top, right, bottom int64) *Rectangle {
	return &Rectangle{
		Left:   left,
		Top:    top,
		Right:  right,
		Bottom: bottom,
	}
}
// Say returns rectangle details in special format. Implements Figure.
func (r Rectangle) Say() string {
	return fmt.Sprintf("rectangle, Rect {left=%d,top=%d,right=%d,bottom=%d)", r.Left, r.Top, r.Right, r.Bottom)
}
// Square returns square of the rectangle. Implements Figure.
func (r Rectangle) Square() float64 {
	return math.Abs(float64((r.Right - r.Left) * (r.Top - r.Bottom)))
}

circle.go:
package main
import (
	"fmt"
	"math"
)
// Circle defines graphical primitive for drawing circles.
type Circle struct {
	X, Y, Radius int64
}
// NewCircle inits new Circle.
func NewCircle(x, y, radius int64) *Circle {
	return &Circle{
		X:      x,
		Y:      y,
		Radius: radius,
	}
}
// Say returns circle details in special format. Implements Figure.
func (c Circle) Say() string {
	return fmt.Sprintf("circle, radius=%d and centre=(%d,%d)", c.Radius, c.X, c.Y)
}
// Square returns square of the circle. Implements Figure.
func (c Circle) Square() float64 {
	return math.Pi * math.Pow(float64(c.Radius), 2)
}

roundrectangle.go:
package main
import "fmt"
// RoundRectangle defines graphical primitive for drawing rounded rectangles.
type RoundRectangle struct {
	Rectangle
	RoundRadius int64
}
// NewRoundRectangle inits new Round Rectangle and underlying Rectangle.
func NewRoundRectangle(left, top, right, bottom, round int64) *RoundRectangle {
	return &RoundRectangle{
		*NewRectangle(left, top, right, bottom),
		round,
	}
}
// Say returns round rectangle details in special format. Implements Figure.
func (r RoundRectangle) Say() string {
	return fmt.Sprintf("round rectangle, %s and roundRadius=%d", r.Rectangle.Say(), r.RoundRadius)
}


conclusions


I hope the article helped to follow the train of thought, draw attention to some aspects of Go. Go is just that - straightforward and not conducive to wasting time thinking, no matter what features solve this or that problem. Its simplicity and minimalism is to provide just what is needed to solve practical problems. No Existential Quantization, just a carefully selected set of building blocks in the spirit of Unix philosophy.

In addition, I hope that for beginners it will become more clear how OOP can be implemented without classes and inheritance. There are a couple of articles on Habré on this topic in which OOP in Go is considered in more detail, and even a small historical digression into what OOP really is.

And, of course, it would be interesting to see the continuation answers to the original article in other new and not very languages. For example, it was terribly interesting for me to “spy” on the train of thought in the original article, and I am more than sure that this is one of the best ways to learn and learn new things. Special thanks to the author of the original material (@KolodeznyDiver).

Also popular now: