Пример решения типичной ООП задачи на языке Go

Недавно попалась на глаза занимательная статья с аналогичным названием о языке Haskell. Автор предлагал читателю проследить за мыслью программиста, решающего типичную ООП задачу, но в Хаскеле. Помимо очевидной пользы расширения представлений читателей о том, что ООП — это отнюдь не «классы» и «наследование», подобные статьи полезны для понимания того, как правильно пользоваться языком. Предлагаю читателю решить ту же самую задачу, но на языке Go, в котором ООП тоже реализован непривычно.

Задача


Итак, оригинальная задача выглядела примерно так: есть графические примитивы (фигуры), с различными свойствами, но над каждой фигурой можно производить одинаковые действия. Примитивы должны уметь отдавать информацию о себе в определенном формате, которую некая функция будет выводить в какой-нибудь вывод, для простоты примера — в stdout. При этом некоторые примитивы могут быть вариациями других.

Формат вывода информации, на примере прямоугольника и круга, должен быть вот таким:

paint rectangle, Rect {left = 10, top = 20, right = 600, bottom = 400}
paint circle, radius=150 and centre=(50,300)


Кроме того, примитивы нужно уметь объединить в однородный список.

Решение


Структуры и свойства
Начнем с очевидного — с объявления примитивов и их свойств. За свойства в Go отвечают структуры, поэтому просто объявим нужные поля для примитивов Rectangle и Circle:

type Rectangle struct {
        Left, Right, Top, Bottom int64
}

type Circle struct {
        X, Y, Radius int64
}


В Go не сильно приветствуются сокращенные записи в одну строчку — лучше каждое поле выносить на отдельную строку, но для такого простого примера это простительно. Тип int64 выбран как базовый. В будущем, если действительно понадобятся оптимизации по скорости — можно будет выбрать тип удачней, исходя из реальной задачи, скажем uint16, или попробовать изменить структуру так, чтобы эффективно использовалось выравнивание полей в памяти, но не забываем, что преждевременная оптимизация — это зло. Подобным нужно заниматься только если действительно есть необходимость. Пока что, смело выбираем int64.

Названия полей и методов будем писать с большой буквы, так как это не библиотека, а исполняемая программа, и видимость за пределами пакета нам не важна (в Go название с большой буквы — это аналог public, с маленькой — private).

Интерфейсы и поведение
Далее, по определению оригинальной задачи, примитивы должны уметь отдавать информацию о себе в определенном формате и отдавать значение площади примитива. Как же мы сделаем это в Go, если у нас нет классов и «нормального ООП»?

Тут в Go даже не приходится гадать, поскольку в языке очень четко разделено определение «свойств» и «поведения». Свойства — это структуры, поведение — это интерфейсы. Этот простой и мощный концепт сразу же дает нам ответ, что делать дальше. Определяем нужный интерфейс с нужными методами:

type Figure interface {
        Say() string
        Square() float64
}


Выбор имени интерфейса (Figure) тут продиктован оригинальным примером и задачей, но обычно в Go интерфейсы, особенно с одним методом, называют с суффиксом -er — Reader, Painter, Stringer и так далее. По идее, имя должно помогать понять назначение интерфейса и отражать его поведение. Но в данном случае Figure достаточно неплохо подходит и описывает сущность «фигуры» или «графического примитива».Методы
Теперь, чтобы типы Rectangle и Circle стали «фигурами», они должны удовлетворять интерфейсу Figure, тоесть для них должны быть определены методы Say и Square. Давайте их напишем:

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 * float64(c.Radius^2)
}


На что здесь стоит обратить внимание — на ресивер метода, который может быть значением (как сейчас — «c Circle»), а может быть указателем »(c *Circle)». Общее правило тут такое — если метод должен изменять значение c или если Circle — большущщая структура, занимающая много места в памяти — тогда использовать указатель. В остальных случаях, будет дешевле и эффективней передавать значение в качестве ресивера метода.

Более опытные гоферы заметят, что метод Say в точности похож на стандартный интерфейс Stringer, который используется в стандартной библиотеке, пакетом fmt в том числе. Поэтому можно переименовать Say в Stringer, убрать метод Say из интерфейса Figure и дальше просто передавать объект данного типа в функции fmt для вывода, но пока что оставим так, для большей ясности и схожести с оригинальным решением.

Конструкторы
Собственно, все — теперь можно создавать структуру Rectangle или Circle, инициализировать ее значения, сохранять в слайсе (динамический массив в Go) типа []Figure и передавать функции, принимающей Figure и вызывающей методы Say или Square для дальнейшей работы с нашими графическими примитивами. К примеру, вот так:

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,
        }
}


Методы NewRectangle и NewCircle — просто функции-конструкторы, которые создают новые значения нужного типа, инициализируя их. Это обычная практика в Go, такие конструкторы нередко еще могут возвращать ошибку, если конструктор делает более сложные вещи, тогда сигнатура выглядит как-нибудь так:

func NewCircle(x, y, radius int64) (*Circle, error) {...}


Также вы можете встретить сигнатуры с приставкой Must вместо New — MustCircle (x, y, radius int64) *Circle — обычно это означает, что функция выбросит панику, в случае ошибки.Углубляемся в тему
Наблюдательный читатель может заметить, что мы кладем в массив фигур ([]Figure) переменные типов *Rectangle и *Circle (то есть, указатель на Rectangle и указатель на Circle), хотя методы мы таки определили на значение, а не на указатель (func (c Circle) Say () string). Но это правильный код, так Go работает с ресиверами методов, упрощая программистам жизнь — если тип реализует интерфейс, то «указатель на этот тип» тоже его реализует. Ведь логично, не так ли? Но чтобы не заставлять программиста лишний раз разыменовывать указатель, чтобы сказать компилятору «вызови метод» — Go компилятор сделает это сам. А вот обратную сторону — что тоже очевидно — такое не сработает. Если интерфейсный метод реализован для «указателя на тип», то вызов метода от переменной не-указателя вернет ошибку компиляции.

Чтобы вызвать метод Say у каждого примитива, мы просто проходимся по слайсу с помощью ключевого слова range и печатаем вывод метода Say (). Важно понимать, что каждая переменная интерфейсного типа Figure содержит внутри информацию о «конкретном» типе. figure в цикле всегда является типом Figure, и, одновременно, либо Rectangle, либо Circle. Это справедливо для всех случаев, когда вы работает с интерфейсными типами, даже с пустыми интерфейсами (interface{}).

Усложняем код


Далее автор усложняет задачу, добавляя новый примитив «закругленный прямоугольник» — RoundRectangle. Это, по сути, тот же примитив Rectangle, но с дополнительным свойством «радиус закругления». При этом, чтобы избежать дубликации кода, мы должны как-то переиспользовать уже готовый код Rectangle.

Опять же, Go дает абсолютно четкий ответ, как это делать — никаких «множественных путей сделать это» тут нет. И этот ответ — embedding, или «встраивание» одного типа в другой. Вот так:

type RoundRectangle struct {
        Rectangle
        RoundRadius int64
}


Мы определяем новый тип-структуру, который уже содержит все свойства типа Rectangle плюс одно новое — RoundRadius. Более того, RoundRectangle уже автоматически удовлетворяет интерфейс Figure, так как его удовлетворяет встроенный Rectangle. Но мы можем переопределять функции, и вызывать функции встроенного типа напрямую, если нужно. Вот как это выглядит:

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


Конструктор типа использует конструктор NewRectangle, при этом разыменовывая указатель (так как мы встраиваем Rectangle, а не указатель на Rectangle), а метод Say вызывает r.Rectangle.Say (), чтобы вывод был точно таким же как и для Rectangle, без дубликации кода.

Встраивание типов (embedding) в Go это очень мощный инструмент, можно даже встраивать интерфейсы в интерфейсы, но для нашей задачи это не нужно. Предлагаю читателю познакомиться с этим самостоятельно.

Теперь просто добавим в слайс новый примитив:

figures := []Figure{
                NewRectangle(10, 20, 600, 400),
                NewCircle(50, 300, 150),
                NewRoundRectangle(30, 40, 500, 200, 5),
}


Как видите, это было довольно просто, мы не думали о том, каким бы способом это сделать, мы просто использовали нужные инструменты языка по самому прямому их назначению. Это позволило, не теряя лишнего времени, просто и быстро имплементировать то, что нам нужно.

Финальные правки


Хотя этот код и является синтетическим примером, но опишу пару моментов, которые я бы делал дальше. Первым делом — я напишу комментарии ко всем методам, даже к конструкторам. Последнее, конечно, не обязательно, но мне нравится идея того, что достаточно написать по одной строчке, чтобы получить документацию ко всему пакету с помощью go doc, даже если она пока не нужна, и вообще, это не библиотека, а запускаемая программа. Но, если в будущем подобный код будет выделен в отдельный пакет-библиотеку, то мы автоматом получим документированный пакет. Пусть даже пока что описания банальные, но мне не сложно потратить 5 секунд на написание одной строки текста, зато есть чувство «полноты» кода, да и линтеры (go vet) не будут ругаться, что тоже приятно.

Далее, логичным выглядит разнести код на несколько отдельных файлов — определение интерфейса и main () оставить в main.go, а для каждого примитива и его функций создать отдельные файлы — circle.go, rectangle.go и roundrectangle.go. Описание интерфейса, впрочем, тоже можно вынести в отдельный файл.

Финальным штрихом будет прогонка через GoMetaLinter — это пакет, запускающий параллельно все линтеры и статические анализаторы кода, которые умеют много чего ловить и подсказывать, позволяя делать код еще лучше, чище и читабельней. Если gometalinter не вывел сообщений — отлично, код достаточно чист.

Полный код тут
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 * 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)
}



Выводы


Надеюсь, статья помогла проследить за ходом мысли, обратить внимание на некоторые аспекты Go. Go именно такой — прямолинейный и не способствующий трате времени на размышления, какими бы фичами решить ту или иную задачу. Его простота и минимализм заключается в том, чтобы предоставлять как раз только то, что необходимо для решения практических задач. Никаких ЭкзистенциальныхКвантизаций, только тщательно отобранный набор строительных блоков в духе философии Unix.

Кроме того, надеюсь, новичкам стало более понятно, как можно реализовывать ООП без классов и наследования. На эту тему есть на Хабре пара статей, в которых более подробно рассматривается ООП в Go, и даже небольшой исторический экскурс в то, что такое ООП на самом деле.

И, конечно, было бы интересно увидеть ответы-продолжения на оригинальную статью на других новых и не очень языках. Мне, например, было жутко интересно «подсмотреть» за ходом мысли в оригинальной статье, и, я более чем уверен, что это из лучших способов учиться и осваивать новые вещи. Отдельное спасибо автору оригинального материала (@KolodeznyDiver).

© Habrahabr.ru