Skip to content

Latest commit

 

History

History
350 lines (288 loc) · 17.7 KB

04-oop.adoc

File metadata and controls

350 lines (288 loc) · 17.7 KB

ООП в Go

Вопрос о том является ли Go объектно ориентированным или нет достаточно спорный. Кто-то скажет, что да, кто-то — нет. В целом можно сказать, что Go всё же процедурный язык, а все концепции ООП в нём являются лишь синтаксическим сахаром. Именно из-за этого многие шаблоны, распространённые в ООП языках не всегда применимы в Go. Разрабатывая на Go нужно помнить о том, что

Clear is better than clever.
— Rob Pike

Не старайтесь скрывать логику программы за сложными конструкциями, оставляйте код максимально чистым и понятным.

Методы

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

type PowerInt int

func (x PowerInt) Power(n int) (res PowerInt) {
    for res = 1; n > 0; n-- {
        res *= x
    }
    return res
}

Полученная функция будет методом типа PowerInt и вызвать её можно будет следующим образом:

x := PowerInt(2)
fmt.Println(x.Power(8)) // 256

Но по сути это будет функция от двух переменных, со специальным префиксом после компиляции, для того, чтобы не было конфликтов в глобальном пространстве имён пакета.

Помимо методов, объявленных на самом типе, можно определить метод на указателе:

type PowerInt int

func (x *PowerInt) Power(n int) {
    for y := *x; n > 1; n-- {
        *x *= y
    }
}

Тогда этот метод можно вызвать как на указателе, так и на переменной типа PowerInt, при этом в момент вызова в функцию будет передан указатель на эту переменную, а значит изменения в методе изменят эту переменную:

x := PowerInt(2)
x.Power(8)
fmt.Println(x) // 256

Наследование

Конечно, говоря объекты, мы подразумеваем совокупность данных и методов. Чаще всего для хранения данных используется структура, к которой добавлены методы. Приведём стандартный пример:

type Monster struct {
    HitPoints   int
    AttackBonus int
    ArmorClass  int
}

func (m *Monster) Attack() int {
    return rand.Intn(20) + 1 + m.AttackBonus
}

func (m *Monster) Defence() int {
    return 10 + m.ArmorClass
}

Для структур также поддерживается некоторое подобие наследования:

type Armadillo struct {
    Monster
    HanukkahBonus bool
}

Мы просто включили тип Monster в описании структуры Armadillo. Таким образом с одной стороны мы добавили в описание поле с типом Monster и именем Monster. При этом, к полям этого поля можно обращаться без указания Monster (если, уровнем выше нет полей с таким же именем):

a := Armadillo{}
a.ArmorClass = 3 // ~ a.Monster.ArmorClass = 3

Аналогично можно вызывать и методы вложенного типа:

a.Attack() // ~ a.Monster.Attack()

С другой стороны можно переопределить методы в «наследнике»:

func (a Armadillo) Defence() int {
    defence := a.Monster.Defence()
    if a.HanukkahBonus {
        defence += 10
    }
    return defence
}

Но, это всего лишь сахар, никакой дополнительной магии здесь не может произойти. Так, например, если мы определим на типе Monster другой метод, использующий метод Defence, то вызов его на переменной типа Armadillo вызовет не переопределённый метод:

func (m Monster) CMD() int {
    return m.Defence() + m.AttackBonus
}
a.HanukkahBonus = true
a.Defence() // 23
a.CMD() // 13

Таким образом это больше похоже на композицию, а не наследование. Однако, с помощью интерфейса можно сделать полиморфизм.

Интерфейсы и полиморфизм

Итак, уже много раз упомянутые интерфейсы. Интерфейс это специальный тип, который описывает какие методы должны быть у объекта, для того, чтобы объект удовлетворял интерфейсу. Описывается он следующим образом:

type Creature interface {
    Attack() int
    Defence() int
}

Это означает, что у объекта, удовлетворяющего этому интерфейсу есть два метода: Attack и Defence. Оба не принимают никаких аргументов и возвращают int. То есть описанные выше типы Monster и Armadillo удовлетворяют этому интерфейсу. Отлично! Что дальше? Теперь мы можем в определении переменных (в том числе и аргументов функции использовать этот тип). Соответственно функцию расчёта Combat Maneuver Defense мы можем описать как внешнюю функцию, добавив в интерфейс и реализации дополнительный метод:

type Creature interface {
    Attack() int
    Defence() int
    GetAttackBonus() int
}

func (m Monster) GetAttackBonus() int {
    return m.AttackBonus
}

func CMD(c Creature) int {
    return c.Defence() + m.GetAttackBonus()
}

Таким образом реализуется полиморфизм в языке Go.

Наследование интерфейсов

Описание интерфейсов напоминают описание структур. И возникает вопрос: а можно ли включить один интерфейс в другой, также как мы делали это со структурами? Ответ можно:

type Reader interface {
    Read([]byte) (int, error)
}

type Writer interface {
    Write([]byte) (int, error)
}

type ReadWriter interface {
    Reader
    Writer
}

Получившийся интерфейс будет описывать объекты с двумя методами: Read и Write. Правда до версии 1.14 нельзя объединить пересекающиеся по методам интерфейсы, то есть код

type Closer interface {
    Close() error
}

type ReadCloser interface {
    Reader
    Closer
}

type ReadWriteCloser interface {
    ReadWriter
    ReadCloser
}

Выдаст ошибку на этапе компиляции: duplicate method Read.

Правила этикета интерфейсов

Как и всё, что связано с ООП, тема интерфейсов неоднозначна и периодически вызывает холивары. О видении самих разработчиков можно прочитать в статье Go Code Review Comments в вики проекта на https://github.com/golang/go. Мы же рассмотрим имеющиеся ограничения как данность.

Первое о чём необходимо помнить, это то, что интерфейсы описывают только методы, никаких свойств объектов, только поведение. Когда мы говорим «плоская геометрическая фигура», мы не можем говорить о её радиусе или длине диагонали, однако мы можем говорить о том, что фигура имеет площадь или диаметр (максимальная длина отрезка, вмещающегося в эту фигуру). Также, например, для формирования отчёта по факту не нужны фамилия, имя и отчество объекта пользователь, необходимо, чтобы этот объект мог сериализоваться в строку.

Go не содержит классов и негде написать привычное многим слово implements. Соответствие интерфейсу производится в момент присвоения объекта переменной с типом интерфейс (в том числе при вызове функции). Этот факт многих смущает, ведь для того, чтобы написать реализацию интерфейса хочется видеть соответствие получающегося объекта интерфейсу. Здесь же мы увидим ошибку на строке присвоения или вызова функции, но не рядом с реализацией.

Хорошим тоном считается писать маленькие интерфейсы рядом с использованием. То есть, если ваш код требует всего один метод от сущности, опишите рядом с ним интерфейс с одним методом и используйте его. Другой одобряемой практикой является описание интерфейсов рядом с местом использования, а не рядом с имплементацией, потому что интерфейс в Go это контракт вызываемой стороны, а не вызывающей.

В Go вместо конструкторов используются обычные функции, не привязанные к типу. Даже если сейчас конструктор используется исключительно для того, чтобы вернуть сущность, удовлетворяющую интерфейсу, по возможности возвращайте конкретный тип, а не интерфейс, то есть вместо

func NewArmadillo() Creature {
    // ...
}

пишите

func NewArmadillo() *Armadillo {
    // ...
}

Особенно, если интерфейс лежит в другом пакете.

interface{}

Конструкция interface{} описывает объект без методов. Такому интерфейсу удовлетворяет всё, что угодно. Это некоторая замена any или unknown из других языков. Такой тип часто используют для определения аргументов функций, которые могут обрабатывать несколько типов. Например, функция fmt.Println принимает сколько угодно каких угодно аргументов и имеет объявление

func Println(args ...interface{}) {
    // ...
}

Кастинг типа

Когда же возникает необходимость определить тип, спрятанный за интерфейсом, можно воспользоваться кастингом типов. Если вы абсолютно точно уверены какой тип спрятан под интерфейсом, то можно просто явно привести к конкретному типу (в том числе и к другому интерфейсу):

x := y.(float64)

Однако, если под y окажется спрятана переменная другого типа, то программа упадёт с ошибкой. Для того, чтобы проверить, действительно ли переменная имеет этот тип, можно воспользоваться формой

if x, ok := y.(float64); ok {
    // do something with x
}

Если же для разных типов необходимо вести себя по разному, можно воспользоваться специальной формой конструкции switch:

switch x := y.(type) {
case float64:
    // do something with x like float64
case int:
    // do something with x like int
}

Например, можно сделать суммирование любых чисел следующим образом:

func sum(args ...interface{}) (res float64) {
    switch x := args.(type) {
    case float64:
        res += x
    case int:
        res += x
    }
    return res
}

Инструкция fallthrough в данной конструкции не работает по очевидным причинам.

При кастинге типа необходимо помнить про следующее ограничение: если переменная содержит значение типа A, который основан на типе B, то её можно скастить в тип A, но нельзя в B. Например

type MyType int

var x interface{} = MyType(10)

j, ok := x.(MyType)
fmt.Println(j, ok) // 10, true

i, ok := x.(int)
fmt.Println(i, ok) // 0, false

Для более глубокого анализа значений и их типов необходимо использовать рефлексию.

Почему nil != nil?

Ещё одна проблема связана со значением nil. Проблема заключается в том, что под капотом каждая переменная описывается значением и типом. Таким образом даже пустое значение, даже nil имеет тип. Чтобы было понятнее приведём пример:

var a []int
fmt.Println(a == nil) // true
var x interface{} = a
fmt.Println(x == nil) // false

В первом случае переменная a имеет тип срез и nil приводится к этому типу, соответственно сравнение оказывается истинным. Во втором же случае переменная x имеет тип интерфейс, но при этом значение всё тот же неинициализированный срез, правая часть сравнения приводится к типу интерфейсу и сравнение оказывается ложным. Сложности с этим часто возникают при создании кастомных ошибок на основе срезов, например:

type MyError []string

func (merr MyError) Error() string {
	return strings.Join(merr, "\n")
}

func Foo(throwError bool) error {
	var merr MyError
	if throwError {
		merr = append(merr, "Something went wrong")
	}
	return merr
}

if err := Foo(false); err != nil {
    fmt.Println("Unexpected error:", err)
}

В данном примере программа зайдёт в условие, потому что пустой срез будет сравниваться с интефейсным nil. Для того, чтобы избежать такой ситуации необходимо модифицировать программу следующим образом:

func Foo(throwError bool) error {
	var merr MyError
	if throwError {
		merr = append(merr, "Something went wrong")
	}
    if merr == nil {
        return nil
    }
	return merr
}

В данном случае будет возвращён именно интерфейсный nil. Также полезным оказывается добавление специального метода к такой ошибке:

func (merr MyError) ToError() error {
    if len(merr) == 0 {
        return nil
    }
    return merr
}

Задания

  1. Написать HTTP эхо сервис. На любой POST запрос он должен возвращать тело запроса как ответ.