19 февраля 2024

🦫 Самоучитель по Go для начинающих. Часть 9. Структуры и методы. Интерфейсы. Указатели. Основы ООП

Энтузиаст-разработчик, автор статей по программированию.
В этом уроке самоучителя подробно рассмотрим структуры, методы и интерфейсы в Go, уделим особое внимание их особенностям и применению. В заключение познакомимся с конструкциями type assertion и type switch.
🦫 Самоучитель по Go для начинающих. Часть 9. Структуры и методы. Интерфейсы. Указатели. Основы ООП

Указатели

Указатели в Go
Указатели в Go

Ранее мы уже познакомились с указателями при рассмотрении функций, теперь пришло время изучить их подробнее.

Указатель – это переменная, в которой хранится адрес другой переменной.

В Go для создания и разыменования указателей используется оператор *, а для получения адреса переменной – оператор &. Рассмотрим их применение в коде:

        num := 1
var ptr *int = &num // ptr содержит адрес num
fmt.Println(ptr) // 0x...

    

Альтернативный способ создать указатель – использовать функцию new, которая выделяет память под указанный тип и возвращает на неё указатель:

        num := 1
ptr := new(int)
ptr = &num // ptr содержит адрес num
fmt.Println(ptr) // 0x...

    

Разыменование – это процесс получения данных из блока памяти, на который указывает адрес указателя. В Go для этих целей необходимо поставить оператор * перед указателем:

        num := 1
var ptr *int = &num // ptr содержит адрес num
fmt.Println(*ptr) // 1

    

Значения переменных можно изменять через указатель:

        num := 1
var ptr *int = &num
*ptr = 2
fmt.Println(num, *ptr) // 2 2

    

При работе с указателями стоит помнить, что в Go всё передается по значению, то есть копируется. Это имеет значение при передаче параметров в функцию, что было подробно рассмотрено в соответствующем пункте шестого урока самоучителя.

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

👨‍💻 Библиотека Go разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале«Библиотека Go разработчика»
🎓 Библиотека Go для собеса
Подтянуть свои знания по Go вы можете на нашем телеграм-канале«Библиотека Go для собеса»
🧩 Библиотека задач по Go
Интересные задачи по Go для практики можно найти на нашем телеграм-канале«Библиотека задач по Go»

Структуры

Структуры в Go
Структуры в Go

Структура представляет собой набор полей с определенными названиями и типами данных. В Go она создается с помощью ключевого слова struct с указанием полей в фигурных скобках:

        type User struct {
	ID   int
	Name string
}

    

Рассмотрим различные способы объявления структуры:

        user0 := User{}                    // 0 ""
user1 := User{1, "Гоша"}           // 1 Гоша
user2 := User{ID: 2, Name: "Петя"} // 2 Петя
ptr := &User{}                     // имеет тип *User
ptrnew := new(User)                // имеет тип *User

    

Доступ к полям структуры осуществляется с помощью точки:

        user := User{
	ID:   0,
	Name: "Гоша",
}
fmt.Println(user.ID, user.Name) // 0 Гоша

    

Получить поля структуры можно с помощью указателя:

        user := User{
	ID:   0,
	Name: "Гоша",
}
ptr := &user
fmt.Println(ptr)              // &{0 Гоша}
fmt.Println(ptr.ID, ptr.Name) // 0 Гоша

    

Структурные теги

Поля структур могут иметь специальные теги, которые указывают дополнительную информацию для их обработки другим кодом. Они записываются после типа данных следующим образом:

        // tagname - имя тега, value - его значение
type User struct {
	ID   int    `tagname:"value"`
	Name string `tagname:"value"`
}

    

К примеру, при работе с JSON для парсинга полей структуры используется тег json с указанием определенного ключа:

        type User struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

    

Сравнение структур

Сравнение структур в Go имеет свои особенности, которые мы рассмотрим на конкретных примерах.

Создадим две структуры с идентичными полями, но с разными названиями:

        type User struct {
	ID   int
	Name string
}

type Admin struct {
	ID   int
	Name string
}

    

Два значения одной структуры считаются равными, если равны их соответствующие поля:

        user0 := User{0, "Гоша"}
user1 := User{0, "Петя"}
fmt.Println(user0 == user1) // false

    

При попытке сравнить значения разных структур возникнет ошибка, так как типы значений будут отличаться:

        user := User{}             // тип User
admin := Admin{}           // тип Admin
user = admin               // ошибка
fmt.Println(user == admin) // ошибка

    

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

        user = User(admin)               // нет ошибки
fmt.Println(user == User(admin)) // true

    

Допустимо сравнение именованных (named) и неименованных (unnamed) структур:

        // неименованная структура созадется без ключевого слова type
var admin struct {
	ID   int
	Name string
}
user := User{}
user = admin               // нет ошибки
fmt.Println(user == admin) // true

    

Структуры и пакеты

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

В Go весь написанный код находится в файлах с расширением .go, расположенных в пакетах, которые делят программу на связанные модули. Импорт пакета дает возможность обращаться к его внутренним объектам.

Структуры и их поля могут быть экспортируемыми или неэкспортируемыми. Первые доступны за рамками текущего пакета и указываются с заглавной буквы, а вторые – недоступны извне и пишутся с маленькой буквы.

Чтобы лучше понять механизм экспорта и импорта структур, создадим тестовый проект со следующей организацией папок и файлов:

        myapp/
├── service/
│   └── roles.go
├── main.go
└── go.mod

    

В файле myapp/service/roles.go создадим две структуры: User будет доступна вне пакета service за исключением поля age, а другую структуру admin сделаем неэкспортируемой:

myapp/service/roles.go
        package service

type User struct {
	ID   int
	Name string
	age  int
}

type admin struct {
	name        string
	permissions string
}

    

В главном файле main.go попробуем обратиться к различным объектам обеих структур и посмотрим, что получится:

myapp/main.go
        package main

import "myapp/service"

func main() {
	user0 := service.User{}              // 0 "" 0
	user1 := service.User{1, "Гоша", 15} // ошибка
	admin := service.admin{} // ошибка
}

    

Методы

Методы в Go
Методы в Go

Вместо классов, в Go используются методы. Они представляют собой функции, связанные с определенными типами (type). Сигнатура метода содержит дополнительный аргумент, который указывает получателя и пишется между ключевым словом func и названием:

        func (название_параметра тип_получателя) название_метода (параметры типы_параметров) (типы_возвращаемых_значений) {
    тело_метода
}

    

Для лучшего понимания рассмотрим пример, в котором создадим именованный тип слайса целых чисел и реализуем для него метод squareElements:

        type nums []int

func (n nums) squareElements() {
	for _, val := range n {
		fmt.Println(val * val)
	}
}

func main() {
	newNums := nums{1, 2, 3, 4}
	newNums.squareElements()
}

    

На выходе получим ожидаемый результат:

        1
4
9
16

    

Методы структур

Методы полезны при работе со структурами, так как упрощают работу с их полями, а также улучшают читаемость и связность кода.

В качестве примера создадим структуру User, реализуем для нее два метода GetUserInfo и ChangeUserInfo и вызовем их в main:

        type User struct {
	ID   int
	Name string
	Age  int
}

func (u User) GetUserInfo() {
	fmt.Printf("%d-%s-%d\\n", u.ID, u.Name, u.Age)
}

func (u User) ChangeUserInfo() {
	u.Name = "Вася"
	u.Age = 20
	fmt.Printf("Изменение: %d-%s-%d\\n", u.ID, u.Name, u.Age)
}

func main() {
	user1 := User{1, "Гоша", 18}
	user1.GetUserInfo()    // 1-Гоша-18
	user1.ChangeUserInfo() // Изменение: 1-Вася-20
	user1.GetUserInfo()    // 1-Гоша-18
}

    

Как вы могли заметить, в коде выше структура передается по значению, поэтому изменение её полей в методе ChangeUserInfo никак не повлияет на исходную структуру.

Для изменения значений структуры необходимо передать её по указателю:

        func (u *User) GetUserInfo() {
	fmt.Printf("%d-%s-%d\\n", u.ID, u.Name, u.Age)
}

func (u *User) ChangeUserInfo() {
	u.Name = "Вася"
	u.Age = 20
	fmt.Printf("Изменение: %d-%s-%d\\n", u.ID, u.Name, u.Age)
}

func main() {
	user1 := &User{1, "Гоша", 18}
	user1.GetUserInfo()    // 1-Гоша-18
	user1.ChangeUserInfo() // Изменение: 1-Вася-20
	user1.GetUserInfo()    // 1-Вася-20
}

    

Интерфейсы

Интерфейсы в Go
Интерфейсы в Go

Одна из немаловажных характеристик кода – это его связность. Она показывает, насколько каждый отдельный блок зависит от другого. Если связность кода сильная, то изменение одного его компонента повлияет на множество других, что с высокой вероятностью приведет к ошибкам и запутанности.

Для управления связностью необходимо делить код на независимые блоки, взаимодействующие с помощью определенного интерфейса. Его можно воспринимать как некое соглашение, которое устанавливает правила поведения объектов.

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

Применение интерфейсов

Давайте на практике рассмотрим применение интерфейсов и правила работы с ними. Начнем с создания интерфейса Shape с методом Area для вычисления площади геометрических фигур:

        type Shape interface {
	Area() float64
}

    

Первое правило звучит так: если некая сущность реализует все методы интерфейса, то говорят, что она удовлетворяет ему (реализует его). Данный принцип иногда называют «утиная типизация» (duck typing): если это выглядит как утка, плавает как утка и крякает как утка, тогда, вероятно, это и есть утка.

Создадим структуру Rectangle, удовлетворяющую интерфейсу Shape:

        type Rectangle struct {
	Width  float64
	Height float64
}

// реализация метода Area интерфейса Shape
func (r Rectangle) Area() float64 {
	if r.Height > 0 && r.Width > 0 {
		return r.Height * r.Width
	}
	return -1
}

    

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

Это может показаться сложным на словах, поэтому давайте перейдем к примеру, в котором напишем функцию GetArea для вычисления площади фигур:

        func GetArea(s Shape) float64 {
	return s.Area()
}

    

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

К примеру, можно передать в функцию GetArea экземпляр структуры Rectangle, которая реализует Shape, и получить значение площади прямоугольника:

        func main() {
	rectangle := Rectangle{5, 6}
	fmt.Println(GetArea(rectangle)) // 30
}

    

Пустой интерфейс

Пустой интерфейс в Go
Пустой интерфейс в Go

В Go допускается создание пустого интерфейса interface{} , который не имеет методов, поэтому любой объект удовлетворяет ему. Пустой интерфейс используется при работе с заранее неизвестным типом данных, поэтому применяется для реализации библиотечных функций.

Обратимся к примеру использования пустого интерфейса в коде:

        var obj interface{}
obj = 1
fmt.Println(obj) // 1
obj = "str"
fmt.Println(obj) // str

var slc []interface{} // слайс типа пустой интерфейс
slc = append(slc, 1)
slc = append(slc, "str")
slc = append(slc, []int{2, 4})
fmt.Println(slc) // [1 str [2 4]]

    

Приведение и проверка типа

Работа с пустым интерфейсом имеет ряд особенностей. Чтобы в этом убедиться, давайте попробуем создать мапу со значениями типа interface{} и изменить её элементы по ключу:

        mp := map[int]interface{}{
	1: "go",
	2: 1234,
	3: false,
}

mp[1] = "gogo" // ошибок нет
mp[2] += 100   // mismatched types interface{} and int

    

В результате получим ошибку со следующим описанием: invalid operation: mp[2] += 100 (mismatched types interface{} and int). Она возникла по той причине, что значение мапы по ключу 2 имеет тип interface{}, а не int.

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

        value, ok := obj.(T) // T — произвольный тип

    

Если obj имеет тип T, то в value запишется значение этого типа, а ok станет true, иначе – ok будет false, а value сохранит нулевое значение типа T.

Используем type assertion для приведения типа значения по ключу 2 к целочисленному:

        value, ok := mp[2].(int)
if !ok {
	fmt.Println("error")
}
mp[2] = value + 100 // ошибок нет

    

При попытке преобразования к другому типу в переменной ok будет false, а value сохранит нулевое значение указанного типа:

        value, ok := mp[2].(bool)
if !ok {
	fmt.Println("error") // error
}
fmt.Println(value) // false

    

Иногда возникает необходимость проверить соответствие сразу нескольким типам. В таких случаях используется конструкция type switch. Она имеет синтаксис switch-case, но в case проверяется не значение, а тип:

        // вместо конкретного типа пишется ключевое слово type
switch value := obj.(type) {
case A:
	// value имеет тип A
case B:
	// value имеет тип B
default:
	// value имеет тот же тип, что и obj
}

    

Чтобы лучше понять поведение type switch, напишем функцию, которая будет для переменной строкового типа выводить её длину, для слайса []int – его емкость, в остальных случаях – название типа:

        func ValueInfo(obj interface{}) {
	switch val := obj.(type) {
	case string:
		fmt.Printf("Длина строки: %d\\n", len(val))
	case []int:
		fmt.Println("Емкость слайса:", cap(val))
	default:
		fmt.Printf("Тип %T", val)
	}
}

func main() {
	ValueInfo("str")       // Длина строки: 3
	ValueInfo([]int{1, 2}) // Емкость слайса: 2
	ValueInfo(true)        // Тип bool
}

    

Подведём итоги

Структуры, методы и интерфейсы позволяют создавать гибкий и модульный код, облегчают его понимание и дальнейшее масштабирование. В этой статье мы рассмотрели особенности этих концепций и изучили полезные синтаксические конструкции type assertion и type switch.

В следующем уроке применим полученные знания для погружения в мир объектно-ориентированного программирования.

***

Содержание самоучителя

  1. Особенности и сфера применения Go, установка, настройка
  2. Ресурсы для изучения Go с нуля
  3. Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
  4. Переменные. Типы данных и их преобразования. Основные операторы
  5. Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
  6. Функции и аргументы. Области видимости. Рекурсия. Defer
  7. Массивы и слайсы. Append и сopy. Пакет slices
  8. Строки, руны, байты. Пакет strings. Хеш-таблица (map)
  9. Структуры и методы. Интерфейсы. Указатели. Основы ООП
  10. Наследование, абстракция, полиморфизм, инкапсуляция
  11. Обработка ошибок. Паника. Восстановление. Логирование
  12. Обобщенное программирование. Дженерики
  13. Работа с датой и временем. Пакет time
  14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
  15. Конкурентность. Горутины. Каналы
  16. Тестирование кода и его виды. Table-driven подход. Параллельные тесты
  17. Основы сетевого программирования. Стек TCP/IP. Сокеты. Пакет net

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик С#
от 200000 RUB до 400000 RUB
Go-разработчик
по итогам собеседования
Java Team Lead
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ