С начала 1990-х годов объектно-ориентированное программирование (ООП) стало доминирующей парадигмой программирования в промышленности и образовании, и почти все широко используемые языки, разработанные с того времени, включают его поддержку. Не является исключением и Go.
Хотя общепринятого определения ООП нет, для наших целей определим, что объект представляет собой просто значение или переменную, которая имеет методы, а метод - это функция, связанная с определенным типом. Объектно-ориентированная программа - это программа, которая использует методы для выражения свойств и операций каждой структуры данных так, что клиентам не требуется прямой доступ к представлению объекта.
В предыдущих главах мы регулярно использовали методы из стандартной библиотеки, такие как метод Seconds
типа time.Duration
:
const day = 24 * time.Hour
fmt.Println(day.Seconds()) // 86400
Мы также определяли собственный метод в разделе 2.5 - метод String
типа Celsius
:
func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
В этой главе, первой из двух, посвященных ООП, мы покажем, как эффективно определять и использовать методы. Мы также
рассмотрим два ключевых принципа ООП - инкапсуляцию
и композицию
.
- 6.1. Объявления методов
- 6.2. Методы с указателем в роли получателя
- 6.3. Создание типов путем встраивания структур
- 6.4. Значения-методы и выражения-методы
- 6.5. Пример: тип битового вектора
- 6.6. Инкапсуляция
- Метод объявляется следующим образом:
func (получатель тип) имя_метода(аргументы) возвращаемое_значение
; - Название "получатель" унаследовано от ранних ООП языков, которые описывали вызов метода как "отправку сообщения объекту";
- В Go для получателя не используется специальное имя, такое как
this
илиself
; - Для имени получателя принято брать первую букву имени типа;
- Вызов метода осуществляется таким же синтаксисом, как доступ к полям структуры с использованием селекторов, например,
p.Distance(q)
вызывает методDistance
у переменнойp
типаPoint
; - Если есть два метода или функции с одинаковым именем, но разными типами, конфликта не будет, так как каждый тип имеет разные пространства имен для методов;
- Выражение
получатель.Метод
называется селектором. Оно выбирает подходящий метод для получателя с его типом. Селекторы также используются для выбора полей структурных типов; - Разрешая связывать методы с любым типом, Go отличается от многих других ООП ЯП. Это позволяет удобно определить дополнительное поведение для простых типов, таких как числа, строки, срезы, карты, иногда даже функции;
- Методы могут быть объявлены для любого именованного типа в том же пакете, за исключением указателей и интерфейсов;
- Компилятор определяет, какой метод должен быть вызван, исходя из имени метода и типа получателя;
- Все методы в конкретном типе должны иметь уникальные имена, но одно и то же имя метода может использоваться разными типами;
- Нет необходимости квалифицировать имена функций (например
PathDistance
) для устранения неоднозначности; - Первое преимущество методов перед функциями: имена методов могут быть короче, особенно, когда мы выполняем их за пределами пакета, так как они могут использовать более короткие имена и опускать имя пакета.
- При вызове функции создается копия каждого значения аргумента. Если нужно обновить переменную или избежать
копирования, нужно передать в функцию адрес переменной с помощью указателя. То же самое относится к методам - их нужно
присоединять к типу указателя
*T
. Пример:func (p *Point) ScaleBy(factor float64) { p.X = factor p.Y = factor }
- Имя метода выглядит так:
(*T).Method
. Скобки необходимы, без них выражение будет трактоваться как*(T).Method
; - По соглашению, если какой-либо метод именованного типа
T
имеет тип получателя указателя*T
, следует использовать указатели в качестве получателей для всех методов этого типа; - Получатель в объявлении метода может быть только именованным типом
T
или указателем на него*T
; - Объявления методов не разрешены для именованных типов, которые сами являются типами указателей
var p &int
; - Если получатель является переменной типа
T
, но методу необходим получатель*T
, можно использовать сокращенную записьp.Method()
. При такой инструкции компилятор выполнит неявное получение адреса из&p
этой переменной. Это работает для только для переменных, включая поля структур, наподобиеp.X
и элементов массивов и срезов, наподобиеperim[0]
; - Нельзя вызвать метод
*T
для не адресуемого получателяT
, так как нет никакого способа получения адреса временного значения; - Если:
r := T{1, 2}; pptr := &r
мы можем вызвать метод типаT
наподобиеT.Method()
с получателем типа*T
, поскольку есть способ получить значение из адреса. Компилятор вставит неявный оператор*
; - В каждом корректном выражении вызова метода истинным является только одно из трех утверждений:
- Либо аргумент получателя имеет тот же тип, что и параметр получателя (оба имеют тип
*T
либоT
); - Либо аргумент получателя является переменной типа
T
, а параметр получателя имеет тип*T
- компилятор неявно получит адрес переменнойr.Method(2) // (&r)
; - Либо аргумент получателя имеет тип
*T
, а параметр получателя имеет типT
- компилятор выполнит разыменовывание получателяpptr.Distance(q) // (*pptr)
.
- Либо аргумент получателя имеет тот же тип, что и параметр получателя (оба имеют тип
- Если все методы именованного типа
T
имеют тип получателяT
(не*T
) - копирование экземпляров этого типа безопасно. Вызов любого из его методов обязательно делает копию. - Если какой-то метод имеет в качестве получателя указатель, следует избегать копирования экземпляров
T
, так как это может нарушать внутренние инварианты. Например, копирование экземпляраbytes.Buffer
может привести к тому, что оригинал и копия будут псевдонимами одного и того же базового массива байтов, последующие вызовы методов будут иметь непредсказуемые результаты. Внутренние инварианты
- это ограничения на значения полей и состояние объекта, которые должны соблюдаться для правильной работы программы или модуля. Например, для структуры, представляющей дерево, внутренний инвариант может заключаться в том, что каждый узел имеет не более двух потомков.
- Некоторые методы могут принимать нулевой указатель в качестве получателя, особенно для ссылочных типов данных, таких как карта или срез;
- Желательно указывать в комментариях, если методы типа допускают нулевое значение получателя;
- Для типа-карты можно использовать встроенные операторы (например, make, литералы срезов, m[key]) или его методы, или и то, и другое вместе;
- Если мы вызовем метод обновления карты, которая является нулевой - это приведет к панике;
- Карты обращаются к своим парам "ключ-значение" косвенно (по ссылке), из-за этого любые обновления и удаления, которые
будут делать вызовы методов с элементами карты, будут видны вызывающей функции. Однако, как и для обычных функций,
любые
изменения, которые метод делает с самой ссылкой, например установка ее значения равным
nil
или ее перенаправление на другую карту - не будут видны вызывающей функции.
- Чтобы использовать синтаксические сокращения для всех полей, которые содержит встраиваемая структура, можно создавать типы путем встраивания структур;
- Можно выбрать поля одной структуры из второй структуры, без упоминания ее имени;
- При встраивании структур можно вызывать методы встроенного поля
T
с использованием получателя типаTName
, даже еслиTname
не имеет собственных методов. МетодыT
будут доступны как методыTname
; - Встраивание допускает наличие сложных типов со многими методами, при этом указанные типы создаются путем
композиции
полей, каждое из которых предоставляет несколько методов; - Неверно рассматривать
T
как базовый класс, аTname
- как подкласс при встраивании структур (другие ООП языки); - Типом анонимного поля может быть указатель на именованный тип. В этом случае поля и методы косвенно повышаются из указываемого объекта. Добавление еще одного уровня косвенности позволяет нам совместно использовать общие структуры и динамически изменять взаимоотношение между объектами;
- Тип
Tname
не являетсяT
, хоть и содержит его и имеет его методы, повышенные изT
; - Встроенное поле указывает компилятору на необходимость генерации дополнительных
методов-оберток
, которые делегируют вызов объявленным методам. КогдаTname.T.Method
вызывается первым из этих методов-оберток, значением его получателя являетсяT
а неTname
; - Структурный тип может иметь более одного анонимного поля. В таком случае, значение этого типа будет иметь все методы всех анонимных полей и свои собственные методы;
p.ScaleBy
- компилятор сначала ищет объявленный метод с именемScaleBy
, затем метод, однократно повышенный из встроенных полейTname
, после этого дважды повышенный метод из анонимных полей и т.д. Компилятор сообщит об ошибке, если селектор неоднозначный, т.к. имеются несколько методов с одним именем с одинаковым рангом повышения;- Методы могут быть объявлены только для именованных типов и указателей на них
T
и*T
, но благодаря встраиваниюнеименованные
структурные типы также могут иметь методы.
- Селектор дает значение-метод (функцию), которая связывает метод селектора со значением конкретного получателя;
- Значения-методы полезны, когда API пакета требует значение-функцию, а для клиента желаемым поведением для этой функции является вызов метода для конкретного получателя;
- Синтаксис с использованием значения-метода оказывается более коротким;
- Выражение-метод - это функция, связанная с типом, которая может быть вызвана как обычная функция и принимает первым параметром получателя. Его можно вызвать как обычную функцию;
- Выражения-методы могут быть использованы для выбора нужного метода из нескольких методов, принадлежащих к одному типу, и вызывать выбранный метод для разных получателей.
- Множества в Go реализуются как
map[T]bool
, гдеT
является типом элемента; - Множество, представленное картой, очень гибкое, но для некоторых задач специализированное представление может его
превзойти (например, в анализе потока данных, где элементы множества - небольшие неотрицательные целые числа,
множества имеют много элементов, а распространенными операциями являются объединение и пересечение множеств. Для такой
задачи идеальным решением оказывается
битовый вектор
); Битовый вектор
использует срез беззнаковых целочисленных значений, каждый бит которых, представляет возможный элемент множества. Множество содержитi
, еслиi-й
бит установлен;- Каждое слово может иметь 32, либо 64 бита (в зависимости от архитектуры устройства). Чтобы найти бит для значения
x
, нужно использовать частноеx/64
в качестве индекса значения, а остатокx%64
- как индекс бита внутри этого слова; - Операция объединения использует оператор побитового
ИЛИ (|)
для вычисления объединения 64 элементов за один раз; - Для формирования удобочитаемой строки из структуры данных часто используют
bytes.Buffer
илиstrings.Builder
, а методString
определен в интерфейсе. - Если мы объявляем метод
String
для типа указателя*T
, то значениеT
не будет иметь методаString
. Чтобы вызвать методString
для значенияT
, можно использоватьfmt.Println(x.String())
илиfmt.Println(&x)
. Вывод значенияT
с помощьюfmt.Println(x)
не даст форматированного представления.
- Переменная или метод объекта называется инкапсулированным, если она недоступна для обращения вне этого объекта. Это называется инкапсуляцией или сокрытием информации, и она является важной частью объектно-ориентированного программирования (ООП).
- В языке программирования Go есть только один механизм для управления видимостью имен: идентификаторы с Прописной буквы - экспортируемые, со строчной - нет. Этот механизм ограничивает доступ к переменным и к полям структуры или методам.
- Для инкапсуляции объекта мы должны сделать его структурой.
type T struct
, выражениеs.field
может появиться только в пакете, в котором определенT
; - Если мы определяем тип как срез или другой что-то другое
type T []int
, выражение*s
может быть использовано в любом пакете; - Единицей инкапсуляции является пакет, а не тип, как во многих других ЯП;
- Поля структурного типа являются видимыми для всего кода в том же пакете;
- Инкапсуляция имеет три преимущества:
- Так, как переменные объекта
не могут быть изменены непосредственно
(вне этого объекта), нужно изучать меньше инструкций для понимания возможных значений этих переменных; Сокрытие деталей реализации
устраняет зависимость клиентов от сущностей, которые могут изменяться, что дает проектировщику большую свободу в развитии реализации без нарушения совместимостиAPI
;Инкапсуляция
не позволяет клиентам произвольным образом устанавливать значения переменных объекта. Доступ только черезгеттеры
исеттеры
. Они могут устанавливаться только функциями из одного пакета, автор пакета, может гарантировать, что все функции поддерживают внутренние инварианты объектов;Кратко
: Инкапсуляция имеет три преимущества: она уменьшает сложность кода, устраняет зависимость клиентов от деталей реализации, и предотвращает произвольное изменение значений переменных объекта.
- Так, как переменные объекта
- При именовании метода получения значения обычно префикс
Get
опускается, а при именовании метода установки значения - используется префиксSet
. - В целом, все излишние префиксы должны опускаться в наименовании методов;
- В целом, в Go можно экспортировать поля, но это нужно делать осознанно, учитывая сложность поддержки инвариантов,
вероятность будущих изменений и количество клиентского кода, который будет подвержен внесению изменений.
- Сложность поддержки инвариантов в Go связана с тем, что экспортирование полей может нарушить внутреннее состояние структуры и привести к некорректной работе программы. Инварианты - это условия, которые всегда должны выполняться внутри структуры или объекта, чтобы он работал корректно;
- Когда экспортируются поля, клиентский код может изменять их значения напрямую, что может привести к нарушению инвариантов. При этом, если изменения внесены в экспортированные поля, но не были учтены при обновлении инвариантов, то это может привести к ошибкам в работе программы;
Инварианты
- ограничения на значения переменных, которые должны сохраняться в любой момент выполнения программы.
- Инкапсуляция может быть нежелательной в случаях, когда требуется широкий доступ к переменным объекта, или когда общий доступ к ним более эффективен, чем использование геттеров и сеттеров. Также может быть нежелательно использование инкапсуляции при создании простых типов данных или для объектов, которые не изменяются.