Recently, an eye-catching
article with the same name about the Haskell language was caught. The author suggested to the reader to follow the thought of the programmer solving the typical OOP problem but in Haskell. In addition to the obvious benefits of expanding the reader’s perception that OOP is not “classes” and “inheritance,” such articles are useful for understanding how to properly use the language. I suggest the reader to solve the same problem, but in the Go language, in which the PLO is also implemented unusually.
Task
So, the original task looked 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 a certain function will output to some output, for simplicity of an example - to stdout. However, some primitives may be variations of others.
The information output format, for example, 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 merge into a uniform list.
Decision
Structures and properties
Let's start with the obvious - with the declaration of primitives and their properties. Structures are responsible for properties in Go, so we simply declare the required fields for the Rectangle and Circle primitives:
type Rectangle struct { Left, Right, Top, Bottom int64 } type Circle struct { X, Y, Radius int64 }
In Go, abbreviated entries in one line are not much welcomed - it is better to take each field to a separate line, but for such a simple example this is forgivable. The type int64 is selected as base. In the future, if you really need speed optimizations, you can choose the type best, based on a real task, say uint16, or try to change the structure so that field alignment is effectively used in memory, but do not forget that premature optimization is evil. You need to do this only if you really need it. For now, feel free to choose int64.
')
We will write the names of the 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 for us (in Go the name with a capital letter is an analogue of public, with a small one - private).
Interfaces and behavior
Further, by the definition of the original task, the primitives must 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 a “normal OOP”?
Here in Go, one does not even have to guess, since the definition of “properties” and “behavior” is very clearly separated in language.
Properties are structures, behavior is interfaces. This simple and powerful concept immediately gives us the answer to what to do next. We define the necessary interface with the necessary methods:
type Figure interface { Say() string Square() float64 }
The choice of the interface name (Figure) is dictated here 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 understand the purpose of the interface and reflect its behavior. But in this case, the Figure fits rather well and describes the essence of the “figure” or “graphic primitive”.
Methods
Now, in order for the Rectangle and Circle types to become "figures", 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, cX, cY) } 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 now - “c Circle”), or it can be a pointer "(c * Circle)". The general rule here is - if the method should change the value of c or if Circle is a tremendous structure that takes up a lot of space in memory - then use a pointer. In other cases, it will be cheaper and more efficient to transfer the value as a receiver method.
More experienced gophers will notice that the Say method is exactly the same as the standard
Stringer interface that is used in the standard library, including the fmt package. Therefore, you can rename Say to String, remove this method from the Figure interface in general, and then simply transfer an object of this type in the fmt function for output, but for the time being we will leave it 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 type [] Figure, and pass functions to the Figure and calling 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 complicated things, then the signature looks something like this:
func NewCircle(x, y, radius int64) (*Circle, error) {...}
You can also find signatures with the prefix Must instead of New - MustCircle (x, y, radius int64) * Circle - this usually means that the function will throw out a panic, in case of an error.
Delve into the subject
The observant reader may notice that we put the type variables * Rectangle and * Circle (that is, the pointer to Rectangle and Circle pointer) into the array of figures ([] Figure), although we have defined the methods for the value, not for the pointer (func (with Circle) Say () string). But this is the correct code,
so Go works with the receivers of the methods , simplifying the programmers life - if the type implements the interface, then the “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 say to the compiler “call method” - the Go compiler will do it himself. But the reverse side - which is also obvious - this will not work. If the interface method is implemented for a “pointer to type”, then a method call from 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 type the output of the Say () method. It is important to understand that each variable of the interface type Figure contains inside information about a "specific" type. A 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 (}.
Complicate the code
Further, the author complicates the task by adding a new “rounded rectangle” primitive - RoundRectangle. This is, in essence, the same primitive Rectangle, but with the additional property “rounding radius”. At the same time, in order to avoid duplication of the code, we must somehow reuse the ready-made Rectangle code.
Again, Go gives an absolutely clear answer, how to do it - there are no “multiple ways to do it”. And this answer is embedding, or “embedding” one type into 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 embedded Rectangle. But we can override 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, dereferencing the pointer (since we embed Rectangle, not the Rectangle pointer), 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 (embedding) in Go is a very powerful tool, you can even embed interfaces into interfaces, but for our task it is not necessary. I suggest the reader to get acquainted with 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 it, we just used the necessary language tools for their very direct purpose. This allowed, without losing too much time, 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 to all methods, even to designers. 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 documentation for the entire package using go doc, even if it is not needed yet, and in general, this is not a library, but a program being started. But, if in the future such code will be separated into a separate package library, we automatically receive a documented package. Even for now, the descriptions are banal, but it's not difficult for me to spend 5 seconds writing one line of text, but there is a feeling of “fullness” of the code, and the linters (go vet) will not swear, which is also nice.
Further, it seems logical to place the code into several separate files - the interface definition and main () are left in main.go, and for each primitive and its functions create separate files - circle.go, rectangle.go and roundrectangle.go. Interface description, however, can also be put in a separate file.
The final touch will be a
run through
GoMetaLinter - this is a package that runs in parallel all the linters and static code analyzers that can catch and prompt a lot of things, allowing you to make the code even better, cleaner and more readable. If gometalinter did not display any messages, great, the code is clean enough.
Full code heremain.go:
package main import "fmt"
rectangle.go:
package main import ( "fmt" "math" )
circle.go:
package main import ( "fmt" "math" )
roundrectangle.go:
package main import "fmt"
findings
I hope the article helped to follow the train of thought, to draw attention to some aspects of Go. Go is just that - straightforward and not conducive to wasting time thinking about what features to 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 the Unix philosophy.
In addition, I hope, for beginners, it has become more clear how to implement OOP without classes and inheritance. On this topic there are a
couple of articles on Habré in which the PLO in Go is examined in more detail, and even a small historical excursion into what the PLO 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, I was terribly interested to “peep” at the thought flow in the original article, and I am more than sure that this is the best way to learn and master new things. Special thanks to the author of the original material (@KolodeznyDiver).