Типы, методы и интерфейсы
- среда, 28 июня 2023 г. в 00:00:13
IBM Senior DevOps Engineer & Integration Architect. Официальный DevOps ментор и коуч в IBM
Всем привет. Сегодня на примере разберем методы и интерфейсы в go.
Большая часть того, что называют написанием идиоматического кода на Go — это изучение использования преимуществ определяемых пользователем типов Go. Интерфейсы — единственный тип в Go с динамической диспетчеризацией. Поскольку они реализованы неявно,то позволяют разработчикам создавать несвязанный, удобный для сопровождения код. Встраивание типов в Go позволяет совместно использовать код без сложностей наследования. Наконец, способность Go прикреплять методы к любому определяемому пользователем типу, позволяет использовать некоторые очень умные функции, включая типы функций с методами, которые могут реализовывать интерфейсы.
В статье вы узнаете про:
Объявление собственных типов;
Добавление методов к типам;
Объявление и использование интерфейсов.
Начнем с объявления собственных типов.
Давайте напишем простую систему управления персоналом, чтобы показать пользовательские типы. Чтобы увидеть основы объявления и использования типов, начнем с определения Employee.
Скопируйте следующий код в новый файл с именем people.go
:
package main
import (
"fmt"
"time"
)
type Employee struct {
ID int
FirstName string
LastName string
DateHired time.Time
}
func main() {
e1 := Employee{
ID: 1,
FirstName: "Bob",
LastName: "Bobson",
DateHired: time.Date(2020, time.January, 10, 0, 0, 0, 0, time.UTC),
}
e2 := Employee{
ID: 2,
FirstName: "Mary",
LastName: "Maryson",
DateHired: time.Date(2007, time.March, 30, 0, 0, 0, 0, time.UTC),
}
fmt.Println(e1.FirstName)
fmt.Println(e2.DateHired)
// Bob got married and changed his last name
e1.LastName = "Bobson-Smith"
fmt.Println(e1.LastName)
}
В терминале введите: go run people.go
Объявим пользовательский тип с ключевым словом type
, за которым следует имя типа (в данном случае Employee), а затем тип, которому мы даем имя. В большинстве случаев это будет структура, в которой перечислены поля. Как и во всех объявлениях Go, имя поля идет первым, а тип поля — вторым.
Создаем экземпляр нашего типа Employee, указав его значение. Далее читаем и записываем поля в структуре, используя точечную запись.
Тип Time
в пакете time
— это стандартный способ Go для представления момента времени. В этой программе мы используем два других типа из пакета time
. Когда вызываем функцию time.Date
для создания экземпляра time
. Time, мы передаем экземпляр time.Month
для представления месяца и экземпляр *time.Location
для представления часового пояса. Если вы посмотрите исходный код в стандартной библиотеке, то увидите кое-что интересное: тип time.Month
объявлен как:
type Month int
Вы не ограничены только созданием собственных структур в Go. Также можно определить свой собственный пользовательский тип, строку или мапу. В случае с time.Month
мы хотим четко указать допустимые значения для месяца и дать им имя. Определение типа для представления этих значений и последующее определение этих значений позволяет вам это сделать.
Давайте сделаем немного интереснее. Мы собираемся хранить информацию о сотрудниках. Как правило, вы не должны хранить изменяемые данные в переменных уровня пакета, потому что это затрудняет понимание и управление потоком данных через вашу программу. Однако, для этого простого примера, мы собираемся использовать мапу на уровне пакета для управления данными о сотрудниках.
Измените код в people.go
на:
package main
import (
"fmt"
"time"
)
type Employee struct {
ID int
FirstName string
LastName string
DateHired time.Time
}
var Employees = map[int]Employee{}
var nextID = 0
func AddEmployee(firstName, lastName string, dateHired time.Time) int {
nextID++
Employees[nextID] = Employee{
ID: nextID,
FirstName: firstName,
LastName: lastName,
DateHired: dateHired,
}
return nextID
}
func GetEmployee(id int) (Employee, bool) {
p, ok := Employees[id]
return p, ok
}
func DMYToTime(day int, month time.Month, year int) time.Time {
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}
func main() {
e1ID := AddEmployee("Bob", "Bobson", DMYToTime(10, time.January, 2020))
e2ID := AddEmployee("Mary", "Maryson", DMYToTime(30, time.March, 2007))
e1, exists1 := GetEmployee(e1ID)
e2, exists2 := GetEmployee(e2ID)
fmt.Println(e1, exists1)
fmt.Println(e2, exists2)
e3, exists3 := GetEmployee(2000)
fmt.Println(e3, exists3)
}
Строка var Employees = map[int]Employee{}
объявляет и создает хранилище данных для наших сотрудников.
В следующей строке var nextID = 0
мы создаем переменную для хранения следующего уникального идентификатора вновь созданного сотрудника.
Вместо прямого доступа к карте Employees
мы используем функции AddEmployee
и GetEmployee
для изменения и чтения ее состояния. AddEmployee
получает информацию о новом сотруднике, создает новый идентификатор сотрудника, сохраняет экземпляр структуры Employee
, представляющий сотрудника, в Employees
и возвращает новый идентификатор. GetEmployee
ищет сотрудника, используя идентификатор, возвращая логическое значение, указывающее, был ли соответствующий сотрудник для предоставленного идентификатора. Это распространенный шаблон в Go.
Последней новой функцией является DMYToTime
, вспомогательная функция, которая создает экземпляр time
. Time
из предоставленных дня, месяца и года. Обратите внимание, что день и год имеют тип int
, а месяц — тип month.Month
.
В основном мы больше не создаем экземпляры Employee
напрямую. Вместо этого мы используем вызовы AddEmployee
для создания экземпляров Employee
, которые хранятся в мапе Employees
, и вызываем GetEmployee
для доступа к ним.
В терминале снова введите: go run people.go
У нас есть функции, которые принимают пользовательские типы в качестве входных параметров и функции, которые возвращают пользовательские типы. Однако, как упоминалось ранее, хранение сотрудников в переменной уровня пакета и ее обновление с помощью функций — не лучший способ управления состоянием. Точно так же, как мы группируем данные о наших сотрудниках в один тип данных, мы хотим инкапсулировать мапу и счетчик идентификаторов. На следующем шаге увидим, как это сделать.
Как и многие другие языки, Go позволяет объявлять методы для любого определяемого пользователем типа. Давайте перестанем использовать состояние уровня пакета для информации о нашем сотруднике и поместим его в структуру. Измените people.go
на следующее:
package main
import (
"fmt"
"time"
)
type Employee struct {
ID int
FirstName string
LastName string
DateHired time.Time
}
//more1
type SimpleEmployeeData struct {
employees map[int]Employee
//more2
nextID int
}
func NewSimpleEmployeeData() *SimpleEmployeeData {
return &SimpleEmployeeData{
employees: map[int]Employee{},
//more3
nextID: 0,
}
}
func (ed *SimpleEmployeeData) AddEmployee(firstName, lastName string, dateHired time.Time) int {
ed.nextID++
ed.employees[ed.nextID] = Employee{
ID: ed.nextID,
FirstName: firstName,
LastName: lastName,
DateHired: dateHired,
}
return ed.nextID
}
func (ed SimpleEmployeeData) GetEmployee(id int) (Employee, bool) {
e, ok := ed.employees[id]
return e, ok
}
//more4
func DMYToTime(day int, month time.Month, year int) time.Time {
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}
func main() {
ed := NewSimpleEmployeeData()
manageEmployees(ed)
}
func manageEmployees(ed *SimpleEmployeeData) {
e1ID := ed.AddEmployee("Bob", "Bobson", DMYToTime(10, time.January, 2020))
e2ID := ed.AddEmployee("Mary", "Maryson", DMYToTime(30, time.March, 2007))
e1, exists1 := ed.GetEmployee(e1ID)
e2, exists2 := ed.GetEmployee(e2ID)
fmt.Println(e1, exists1)
fmt.Println(e2, exists2)
e3, exists3 := ed.GetEmployee(2000)
fmt.Println(e3, exists3)
//more5
}
//more6
Наш новый тип SimpleEmployeeData
объединяет состояние и бизнес-логику. Мапа Employees
и nextID int
, которые у нас были как состояние уровня пакета в предыдущем примере, теперь являются полями в SimpleEmployeeData
, а функции AddEmployee
и GetEmployee
теперь являются методами в SimpleEmployeeData
.
Объявления методов и объявлений функций в Go очень похожи. Например, объявление функции GetEmployee
выглядело так:
func GetEmployee(id int) (Employee, bool)
и объявление метода GetEmployee
выглядит следующим образом:
func (ed SimpleEmployeeData) GetEmployee(id int) (Employee, bool)
Единственное различие заключается в объявлении приемника между ключевым словом func
и именем метода. В теле метода GetEmployee
мы получаем доступ к полям экземпляра SimpleEmployeeData
с помощью приемника ed
:
e, ok := ed.employees[id]
У нас также есть фабричная функция NewSimpleEmployeeData
, чтобы убедиться, что мы используем правильно сконструированный экземпляр SimpleEmployeeData
не нужно писать фабричную функцию, но это хорошая практика, если вам нужно убедиться, что определенные поля в структуре правильно заполнены, прежде чем они будут использоваться. В этом случае необходимо убедиться, что карта сотрудников не равна нулю.
Наша основная функция теперь намного меньше. Она вызывает NewSimpleEmployeeData
для создания экземпляра *SimpleEmployeeData
, а затем вызывает manageEmployees
для выполнения этой работы. В общем, структурируйте свои программы таким образом, чтобы работа основной функции заключалась в загрузке исходной информации о конфигурации, создании экземпляров структур данных и последующем вызове функции или метода для запуска бизнес-логики. Это делает ваши программы более модульными и тестируемыми.
Между тем, функциональность, которая раньше была в main
, теперь находится в функции manageEmployees
. Эта функция принимает экземпляр *SimpleEmployeeData
, который является указателем. Поскольку это указатель, мы можем изменить состояние экземпляра.
У нас есть два метода для SimpleEmployeeData
: AddEmployee
и GetEmployee
. Обратите внимание, что получателем для AddEmployee
является указатель; он объявлен как (ed *SimpleEmployeeData
). Точно так же, как аргумент указателя, переданный функции, означает, что вы можете изменить значение этого аргумента внутри функции и увидеть его отражение вне функции, получатель указателя означает, что вы можете изменить состояние структуры внутри метода и состояние останется измененным, когда метод выходит. Метод GetEmployee
имеет приемник значения, объявленный как (ed SimpleEmployeeData
). Поскольку этот метод не изменяет состояние структуры, нет необходимости использовать приемник указателя.go run people.go
У нас немного больше кода, чем раньше, но программа лучше структурирована и ее легче понять.
Чтобы лучше понять разницу между приемником указателя и приемником значения, давайте посмотрим, что произойдет, если мы используем приемник значения для AddEmployee
. Измените строку:
func (ed *SimpleEmployeeData) AddEmployee(firstName, lastName string, dateHired time.Time) int {
На:
func (ed SimpleEmployeeData) AddEmployee (firstName, lastName string, dateHired time.Time) int {
(Разница незначительна; все, что мы сделали, это удалили *
перед SimpleEmployeeData
.)
Что случилось? Здесь мы рассмотрим разницу между полем указателя и полем значения в структуре. Мы распечатали запись сотрудника для Мэри Мэрисон дважды, потому что поле nextID
сбрасывается обратно на 0 каждый раз, когда вызывается ошибочный метод AddEmployee
. Мы записали запись для Боба Бобсона при первом вызове AddEmployee
, а затем перезаписали ее, когда снова вызвали AddEmployee
для Мэри Мэрисон.
Но вы можете подумать, а зачем вообще что-то было на мапе сотрудников? Похоже, мы не объявили employee
полем указателя, но это так. Все мапы в Go являются значениями указателей, независимо от того, являются ли они полями в структуре или простыми переменными.
(Кстати, если сделать GetEmployee
приемником указателя, код все равно будет работать корректно.)
Отмените редактирование AddEmployee
и снова сделайте его получателем указателя.
Давайте добавим в нашу систему новый тип человека, менеджера. Мы также собираемся хранить их в нашей структуре SimpleEmployeeData
.
Замените //more1
на:
type Manager struct {
Employee
Reports []int
}
Вместо того, чтобы создавать менеджера с нуля, мы повторно используем определение сотрудника. Мы сделали это, добавив его как поле без имени. Это называется встроенным полем. Скоро увидим преимущества встроенных полей.
Далее добавим поддержку нашего менеджера в программу. Сначала изменим SimpleEmployeeData
, чтобы в нем было поле для хранения информации о менеджере. Замените //more2
объявлением поля:
managers map[int]Manager
Мы добавили новое поле, называемое менеджерами, в нашу структуру SimpleEmployeeData
, чтобы удерживать связь между идентификатором сотрудника и экземплярами менеджера.
Также нужно убедиться, что поле менеджеров правильно инициализировано. Для этого мы также модифицируем NewSimpleEmployeeData
. Замените //more3
следующей инициализацией поля:
managers: map[int]Manager{}
Теперь мы инициализируем поле менеджеров в SimpleEmployeeData
точно так же, как мы инициализируем поле сотрудников.
Далее нам нужны методы для взаимодействия с нашим новым полем. Добавьте эти два объявления метода, где //more4 находится в коде:
func (ed *SimpleEmployeeData) AddManager(firstName, lastName string, dateHired time.Time, reports []int) int {
ed.nextID++
ed.managers[ed.nextID] = Manager{
Employee: Employee{
ID: ed.nextID,
FirstName: firstName,
LastName: lastName,
DateHired: dateHired,
},
Reports: reports,
}
return ed.nextID
}
func (ed SimpleEmployeeData) GetManager(id int) (Manager, bool) {
m, ok := ed.managers[id]
return m, ok
}
Методы AddManager
и GetManager
являются аналогами методов AddEmployee
и GetEmployee
и позволяют добавлять и получать экземпляры Manager
. Обратите внимание, что мы используем одно и то же поле nextID
для создания идентификаторов как для экземпляров Manager
, так и для экземпляров Employee
. В AddManager
обратите внимание, как мы инициализировали встроенное поле. При инициализации менеджера вы указываете поле «Employee» и передаете экземпляр «Employee».
Наконец, чтобы продемонстрировать, что наш новый код работает правильно, добавьте следующие строки в конец manageEmployees
:
m1ID := ed.AddManager("Boss", "BossPerson", DMYToTime(17, time.June, 1982), []int{e1ID, e2ID})
m1, _ := ed.GetManager(m1ID)
fmt.Println(m1.FirstName, m1.Reports)
//more5
Здесь мы начинаем видеть преимущества встроенных полей. Когда вы получаете доступ к полям из Employee в Manager, вы делаете это так, как если бы поля были объявлены непосредственно в Manager. Вот почему мы могли бы написать fmt.Println(m1.FirstName, m1.Reports)
даже несмотря на то, что FirstName
является полем в Employee
.
go run people.go
Имейте в виду, что встраивание не является наследованием. Вы не можете присвоить значение типа Manager переменной или полю типа Employee. Добавьте следующие строки в конец файла manageEmployees
:
var e4 Employee = m1
fmt.Println(e4.LastName)
//more5
go run people.go
Должна быть ошибка
Если вы хотите получить доступ к экземпляру Employee
напрямую в Manager
, вы делаете это, используя Employee в качестве имени поля.
Измените строку var e4 Employee = m1
на:e4 := m1.Employee
Иногда вы хотите иметь абстрактный тип, который может представлять несколько конкретных типов. На следующем этапе мы рассмотрим, как использовать интерфейсы, единственный абстрактный тип в Go.
На последнем шаге мы рассмотрим интерфейсы, абстрактный тип данных Go. Интерфейсы позволяют нам использовать одну и ту же логику для работы с несколькими конкретными типами. В отличие от абстрактных типов в большинстве других языков, Go не позволяет указать, что тип реализует интерфейс, когда вы определяете тип. Вместо этого тип автоматически соответствует любому интерфейсу, когда все методы интерфейса реализованы этим типом.
Мы объявляем интерфейс, используя ключевое слово type
. Замените //more6
следующим кодом в people.go
:
type Dater interface {
TimeAtCompany() time.Duration
}
func FormatTenure(d Dater) string {
// convert from hours to years and days (ignoring leap years)
hours := int(d.TimeAtCompany().Hours())
years := hours / (24 * 365)
hours = hours % (24 * 365)
days := hours / 24
return fmt.Sprintf("%d years, %d days", years, days)
}
//more6
В интерфейсе мы перечисляем методы, которые необходимо реализовать, чтобы соответствовать интерфейсу. Для интерфейса Dater у нас есть только один метод, TimeAtCompany
, который возвращает time.Duration
(тип в стандартной библиотеке, представляющий период времени). В функции FormatTenure
мы принимаем параметр типа Dater
и вызываем метод TimeAtCompany
для параметра (и метод Hours
для time.Duration
). Вычисляем годы и дни, а затем используем функцию fmt.Sprintf
для построения возвращаемой строки.
Теперь давайте добавим метод в Employee
. Замените комментарий //more6
на:
func (e Employee) TimeAtCompany() time.Duration {
return time.Since(e.DateHired)
}
//more6
Мы также добавим код в конец manageEmployees:
fmt.Println(FormatTenure(e1))
fmt.Println(FormatTenure(e2))
//more5
Добавив метод в Employee
, он автоматически встретился с интерфейсом Dater
.
Давайте заставим Employee реализовать другой интерфейс. В стандартной библиотеке есть интерфейс fmt.Stringer
. Реализуя этот интерфейс, вы указываете, как будет выглядеть вывод, когда ваш тип передается в fmt.Println
(и во многие другие функции). Замените комментарий //more6
в people.go
следующим кодом:
func (e Employee) String() string {
return e.FirstName + " " + e.LastName + ": Tenure " + FormatTenure(e)
}
//more6
Мы уже видели, как мы получаем доступ к полям встроенного поля, как если бы оно было объявлено непосредственно во внешней структуре. Мы можем сделать то же самое с методами встроенного поля.
Добавьте следующее в конец manageEmployees
:
fmt.Println(m1.TimeAtCompany())
//more5
Мало того, что мы можем вызвать метод TimeAtCompany
непосредственно для экземпляра типа Manager, менеджер автоматически реализует любые интерфейсы, которые делает Employee
. Добавьте следующие строки в конец manageEmployees
:
fmt.Println(FormatTenure(m1))
fmt.Println(m1)
//more5
Тип Manager
реализует Dater
и Stringer
. Хотя результаты от реализации Dater
такие, как мы и ожидали, можно было бы захотеть получить другой вывод, когда распечатываем Manager
. Давайте заменим //more6
другим определением метода:
func (m Manager) String() string {
return fmt.Sprintf("%s, reports: %v", m.Employee, m.Reports)
}
//more6
Если мы хотим расширить приложение до полноценной кадровой системы, нам потребуется хранить данные в чем-то более постоянном, чем мапа в памяти. Давайте посмотрим, как абстрагировать существующую реализацию.
Замените //more6
следующим определением типа:
type EmployeeData interface {
AddEmployee(firstName, lastName string, dateHired time.Time) int
GetEmployee(id int) (Employee, bool)
AddManager(firstName, lastName string, dateHired time.Time, reports []int) int
GetManager(id int) (Manager, bool)
}
Измените определение manageEmployees
на:func manageEmployees(ed EmployeeData) {
Отлично!
Полный код получится таким:
package main
import (
"fmt"
"time"
)
type Employee struct {
ID int
FirstName string
LastName string
DateHired time.Time
}
type Manager struct {
Employee
Reports []int
}
type SimpleEmployeeData struct {
employees map[int]Employee
managers map[int]Manager
nextID int
}
func NewSimpleEmployeeData() *SimpleEmployeeData {
return &SimpleEmployeeData{
employees: map[int]Employee{},
managers: map[int]Manager{},
nextID: 0,
}
}
func (ed *SimpleEmployeeData) AddEmployee(firstName, lastName string, dateHired time.Time) int {
ed.nextID++
ed.employees[ed.nextID] = Employee{
ID: ed.nextID,
FirstName: firstName,
LastName: lastName,
DateHired: dateHired,
}
return ed.nextID
}
func (ed SimpleEmployeeData) GetEmployee(id int) (Employee, bool) {
e, ok := ed.employees[id]
return e, ok
}
func (ed *SimpleEmployeeData) AddManager(firstName, lastName string, dateHired time.Time, reports []int) int {
ed.nextID++
ed.managers[ed.nextID] = Manager{
Employee: Employee{
ID: ed.nextID,
FirstName: firstName,
LastName: lastName,
DateHired: dateHired,
},
Reports: reports,
}
return ed.nextID
}
func (ed SimpleEmployeeData) GetManager(id int) (Manager, bool) {
m, ok := ed.managers[id]
return m, ok
}
func DMYToTime(day int, month time.Month, year int) time.Time {
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}
func main() {
ed := NewSimpleEmployeeData()
manageEmployees(ed)
}
func manageEmployees(ed EmployeeData) {
e1ID := ed.AddEmployee("Bob", "Bobson", DMYToTime(10, time.January, 2020))
e2ID := ed.AddEmployee("Mary", "Maryson", DMYToTime(30, time.March, 2007))
e1, exists1 := ed.GetEmployee(e1ID)
e2, exists2 := ed.GetEmployee(e2ID)
fmt.Println(e1, exists1)
fmt.Println(e2, exists2)
e3, exists3 := ed.GetEmployee(2000)
fmt.Println(e3, exists3)
m1ID := ed.AddManager("Boss", "BossPerson", DMYToTime(17, time.June, 1982), []int{e1ID, e2ID})
m1, _ := ed.GetManager(m1ID)
fmt.Println(m1.FirstName, m1.Reports)
e4 := m1.Employee
fmt.Println(e4.LastName)
fmt.Println(FormatTenure(e1))
fmt.Println(FormatTenure(e2))
fmt.Println(FormatTenure(m1))
fmt.Println(m1)
//more5
}
type Dater interface {
TimeAtCompany() time.Duration
}
func FormatTenure(d Dater) string {
// convert from hours to years and days (ignoring leap years)
hours := int(d.TimeAtCompany().Hours())
years := hours / (24 * 365)
hours = hours % (24 * 365)
days := hours / 24
return fmt.Sprintf("%d years, %d days", years, days)
}
func (e Employee) TimeAtCompany() time.Duration {
return time.Since(e.DateHired)
}
func (e Employee) String() string {
return e.FirstName + " " + e.LastName + ": Tenure " + FormatTenure(e)
}
func (m Manager) String() string {
return fmt.Sprintf("%s, reports: %v", m.Employee, m.Reports)
}
type EmployeeData interface {
AddEmployee(firstName, lastName string, dateHired time.Time) int
GetEmployee(id int) (Employee, bool)
AddManager(firstName, lastName string, dateHired time.Time, reports []int) int
GetManager(id int) (Manager, bool)
}
На этом все. Напоследок хочу порекомендовать вам бесплатный вебинар от моих коллег из OTUS, по теме: "пишем веб-сервер на Go". Узнать подробнее о вебинаре можно по этой ссылке.