Вопрос о том является ли Go объектно ориентированным или нет достаточно спорный. Кто-то скажет, что да, кто-то — нет. В целом можно сказать, что Go всё же процедурный язык, а все концепции ООП в нём являются лишь синтаксическим сахаром. Именно из-за этого многие шаблоны, распространённые в ООП языках не всегда применимы в Go. Разрабатывая на Go нужно помнить о том, что
Clear is better than clever.
Не старайтесь скрывать логику программы за сложными конструкциями, оставляйте код максимально чистым и понятным.
Первая ложка сахара, предоставляемая языком — методы. К любому пользовательскому типу (кроме интерфейсов, но об этом позже) можно добавить метод. Для этого в том же пакете, где объявлен тип нужно определить функцию следующего вида:
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{}
описывает объект без методов. Такому интерфейсу удовлетворяет всё, что угодно. Это некоторая замена 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
имеет тип. Чтобы было понятнее приведём пример:
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
}