Skip to content

Latest commit

 

History

History

chapter6

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

6. Методы

С начала 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. Объявления методов

  • Метод объявляется следующим образом: func (получатель тип) имя_метода(аргументы) возвращаемое_значение;
  • Название "получатель" унаследовано от ранних ООП языков, которые описывали вызов метода как "отправку сообщения объекту";
  • В Go для получателя не используется специальное имя, такое как this или self;
  • Для имени получателя принято брать первую букву имени типа;
  • Вызов метода осуществляется таким же синтаксисом, как доступ к полям структуры с использованием селекторов, например, p.Distance(q) вызывает метод Distance у переменной p типа Point;
  • Если есть два метода или функции с одинаковым именем, но разными типами, конфликта не будет, так как каждый тип имеет разные пространства имен для методов;
  • Выражение получатель.Метод называется селектором. Оно выбирает подходящий метод для получателя с его типом. Селекторы также используются для выбора полей структурных типов;
  • Разрешая связывать методы с любым типом, Go отличается от многих других ООП ЯП. Это позволяет удобно определить дополнительное поведение для простых типов, таких как числа, строки, срезы, карты, иногда даже функции;
  • Методы могут быть объявлены для любого именованного типа в том же пакете, за исключением указателей и интерфейсов;
  • Компилятор определяет, какой метод должен быть вызван, исходя из имени метода и типа получателя;
  • Все методы в конкретном типе должны иметь уникальные имена, но одно и то же имя метода может использоваться разными типами;
  • Нет необходимости квалифицировать имена функций (например PathDistance) для устранения неоднозначности;
  • Первое преимущество методов перед функциями: имена методов могут быть короче, особенно, когда мы выполняем их за пределами пакета, так как они могут использовать более короткие имена и опускать имя пакета.

6.2. Методы с указателем в роли получателя

  • При вызове функции создается копия каждого значения аргумента. Если нужно обновить переменную или избежать копирования, нужно передать в функцию адрес переменной с помощью указателя. То же самое относится к методам - их нужно присоединять к типу указателя *T. Пример:
    func (p *Point) ScaleBy(factor float64) {
          p.X = factor
          p.Y = factor
      }
  • Имя метода выглядит так: (*T).Method. Скобки необходимы, без них выражение будет трактоваться как *(T).Method;
  • По соглашению, если какой-либо метод именованного типа T имеет тип получателя указателя *T, следует использовать указатели в качестве получателей для всех методов этого типа;
  • Получатель в объявлении метода может быть только именованным типом T или указателем на него *T;
  • Объявления методов не разрешены для именованных типов, которые сами являются типами указателей var p ∫
  • Если получатель является переменной типа 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 может привести к тому, что оригинал и копия будут псевдонимами одного и того же базового массива байтов, последующие вызовы методов будут иметь непредсказуемые результаты.
  • Внутренние инварианты - это ограничения на значения полей и состояние объекта, которые должны соблюдаться для правильной работы программы или модуля. Например, для структуры, представляющей дерево, внутренний инвариант может заключаться в том, что каждый узел имеет не более двух потомков.

6.2.1 Значение nil является корректным получателем

  • Некоторые методы могут принимать нулевой указатель в качестве получателя, особенно для ссылочных типов данных, таких как карта или срез;
  • Желательно указывать в комментариях, если методы типа допускают нулевое значение получателя;
  • Для типа-карты можно использовать встроенные операторы (например, make, литералы срезов, m[key]) или его методы, или и то, и другое вместе;
  • Если мы вызовем метод обновления карты, которая является нулевой - это приведет к панике;
  • Карты обращаются к своим парам "ключ-значение" косвенно (по ссылке), из-за этого любые обновления и удаления, которые будут делать вызовы методов с элементами карты, будут видны вызывающей функции. Однако, как и для обычных функций, любые изменения, которые метод делает с самой ссылкой, например установка ее значения равным nil или ее перенаправление на другую карту - не будут видны вызывающей функции.

6.3. Создание типов путем встраивания структур

  • Чтобы использовать синтаксические сокращения для всех полей, которые содержит встраиваемая структура, можно создавать типы путем встраивания структур;
  • Можно выбрать поля одной структуры из второй структуры, без упоминания ее имени;
  • При встраивании структур можно вызывать методы встроенного поля T с использованием получателя типа TName, даже если Tname не имеет собственных методов. Методы T будут доступны как методы Tname;
  • Встраивание допускает наличие сложных типов со многими методами, при этом указанные типы создаются путем композиции полей, каждое из которых предоставляет несколько методов;
  • Неверно рассматривать T как базовый класс, а Tname- как подкласс при встраивании структур (другие ООП языки);
  • Типом анонимного поля может быть указатель на именованный тип. В этом случае поля и методы косвенно повышаются из указываемого объекта. Добавление еще одного уровня косвенности позволяет нам совместно использовать общие структуры и динамически изменять взаимоотношение между объектами;
  • Тип Tname не является T, хоть и содержит его и имеет его методы, повышенные из T;
  • Встроенное поле указывает компилятору на необходимость генерации дополнительных методов-оберток, которые делегируют вызов объявленным методам. Когда Tname.T.Method вызывается первым из этих методов-оберток, значением его получателя является T а не Tname;
  • Структурный тип может иметь более одного анонимного поля. В таком случае, значение этого типа будет иметь все методы всех анонимных полей и свои собственные методы;
  • p.ScaleBy - компилятор сначала ищет объявленный метод с именем ScaleBy, затем метод, однократно повышенный из встроенных полей Tname, после этого дважды повышенный метод из анонимных полей и т.д. Компилятор сообщит об ошибке, если селектор неоднозначный, т.к. имеются несколько методов с одним именем с одинаковым рангом повышения;
  • Методы могут быть объявлены только для именованных типов и указателей на них T и *T, но благодаря встраиванию неименованные структурные типы также могут иметь методы.

6.4. Значения-методы и выражения-методы

  • Селектор дает значение-метод (функцию), которая связывает метод селектора со значением конкретного получателя;
  • Значения-методы полезны, когда API пакета требует значение-функцию, а для клиента желаемым поведением для этой функции является вызов метода для конкретного получателя;
  • Синтаксис с использованием значения-метода оказывается более коротким;
  • Выражение-метод - это функция, связанная с типом, которая может быть вызвана как обычная функция и принимает первым параметром получателя. Его можно вызвать как обычную функцию;
  • Выражения-методы могут быть использованы для выбора нужного метода из нескольких методов, принадлежащих к одному типу, и вызывать выбранный метод для разных получателей.

6.5. Пример: тип битового вектора

  • Множества в 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) не даст форматированного представления.

6.6. Инкапсуляция

  • Переменная или метод объекта называется инкапсулированным, если она недоступна для обращения вне этого объекта. Это называется инкапсуляцией или сокрытием информации, и она является важной частью объектно-ориентированного программирования (ООП).
  • В языке программирования Go есть только один механизм для управления видимостью имен: идентификаторы с Прописной буквы - экспортируемые, со строчной - нет. Этот механизм ограничивает доступ к переменным и к полям структуры или методам.
  • Для инкапсуляции объекта мы должны сделать его структурой. type T struct, выражение s.field может появиться только в пакете, в котором определен T;
  • Если мы определяем тип как срез или другой что-то другое type T []int, выражение *s может быть использовано в любом пакете;
  • Единицей инкапсуляции является пакет, а не тип, как во многих других ЯП;
  • Поля структурного типа являются видимыми для всего кода в том же пакете;
  • Инкапсуляция имеет три преимущества:
    • Так, как переменные объекта не могут быть изменены непосредственно (вне этого объекта), нужно изучать меньше инструкций для понимания возможных значений этих переменных;
    • Сокрытие деталей реализации устраняет зависимость клиентов от сущностей, которые могут изменяться, что дает проектировщику большую свободу в развитии реализации без нарушения совместимости API;
    • Инкапсуляция не позволяет клиентам произвольным образом устанавливать значения переменных объекта. Доступ только через геттеры и сеттеры. Они могут устанавливаться только функциями из одного пакета, автор пакета, может гарантировать, что все функции поддерживают внутренние инварианты объектов;
    • Кратко: Инкапсуляция имеет три преимущества: она уменьшает сложность кода, устраняет зависимость клиентов от деталей реализации, и предотвращает произвольное изменение значений переменных объекта.
  • При именовании метода получения значения обычно префикс Get опускается, а при именовании метода установки значения - используется префикс Set.
  • В целом, все излишние префиксы должны опускаться в наименовании методов;
  • В целом, в Go можно экспортировать поля, но это нужно делать осознанно, учитывая сложность поддержки инвариантов, вероятность будущих изменений и количество клиентского кода, который будет подвержен внесению изменений.
    • Сложность поддержки инвариантов в Go связана с тем, что экспортирование полей может нарушить внутреннее состояние структуры и привести к некорректной работе программы. Инварианты - это условия, которые всегда должны выполняться внутри структуры или объекта, чтобы он работал корректно;
    • Когда экспортируются поля, клиентский код может изменять их значения напрямую, что может привести к нарушению инвариантов. При этом, если изменения внесены в экспортированные поля, но не были учтены при обновлении инвариантов, то это может привести к ошибкам в работе программы;
    • Инварианты - ограничения на значения переменных, которые должны сохраняться в любой момент выполнения программы.
  • Инкапсуляция может быть нежелательной в случаях, когда требуется широкий доступ к переменным объекта, или когда общий доступ к ним более эффективен, чем использование геттеров и сеттеров. Также может быть нежелательно использование инкапсуляции при создании простых типов данных или для объектов, которые не изменяются.