11 марта 2024

🦫 Самоучитель по Go для начинающих. Часть 10. Введение в ООП. Наследование, абстракция, полиморфизм, инкапсуляция.

Энтузиаст-разработчик, автор статей по программированию.
В этой части самоучителя разберем основные понятия и принципы объектно-ориентированного программирования, а также рассмотрим примеры их реализации в языке Go. В конце статьи применим изученный материал на практике, решив 2 интересные задачи.
🦫 Самоучитель по Go для начинающих. Часть 10. Введение в ООП. Наследование, абстракция, полиморфизм, инкапсуляция.

Основные понятия

Что такое ООП

Объектно-ориентированное программирование (далее ООП) представляет собой методологию, в которой программа понимается как совокупность объектов, имеющих определенное состояние и поведение. Каждый из них является экземпляром класса, которые, в свою очередь, образуют иерархическую структуру наследования.

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

Например, в программе, написанной по методологии ООП, можно определить класс автомобиль со свойствами «производитель», «модель», «год выпуска» и методами «ехать», «тормозить», «поворачивать» и так далее.

Перейдем к рассмотрению основных определений ООП.

Что такое класс

Класс – это набор полей и методов для работы с ними, описывающий свойства и поведение определенного объекта. Поля могут быть приватными, то есть недоступными извне, или публичными, которые доступны для просмотра и изменения. Таким образом, классы описывают:

  1. Свойства (атрибуты) и состояние объекта
  2. Операции, доступные для взаимодействия с данными (методы).
  3. Структуры данных объекта.

В отличие от других языков ООП, Go не имеет отдельного обозначения для объектов и классов. Вместо этих сущностей, здесь используются структуры (тип struct), содержащие именованные поля и предоставляющие основной способ создания новых типов данных. Тем не менее, классы и структуры – это не одно и то же.

Что такое объект

Объект – это экземпляр конкретного класса с заданным состоянием (данными) и поведением. Каждый объект существует независимо от других, но способен взаимодействовать с ними при помощи методов.

Для инициализации объектов класса используются специальные функции, называемые конструкторами. Go не предусматривает отдельных инструментов для их создания, как, например init() в Python. Идиоматический способ определения конструкторов в Go заключается в использовании переменных, заполняющих поля структуры нулевыми значениями, или обычных функций с названием New.

Для примера создадим структуру Person с полями Name и Age и конструктор для неё:

        // структура Person
type Person struct {
	Name string
	Age  int
}

// конструктор для инициализации объектов структуры Person
func NewPerson(name string, age int) *Person {
	return &Person{
		Name: name,
		Age:  age,
	}
}

func main() {
	person := NewPerson("Иван", 14) // использование конструктора
}

    

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

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

Отношения между объектами

В этом пункте под объектом будем подразумевать не экземпляр конкретного класса, а более широкое понятие, обозначающее некую сущность в программе (класс, структуру и так далее).

Выделяют несколько видов отношений между объектами. Условно их делят на две группы: is-a (является) и has-a (имеет, содержит). Давайте поближе познакомимся с их особенностями и выясним, чем они отличаются друг от друга.

Ассоциация (has-a)

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

Выделяют две разновидности ассоциации: агрегация и композиция.

Агрегация (has-a)

Агрегация – это отношение «часть-целое» с более сильной зависимостью по сравнению с ассоциацией. В этом случае один объект содержит другой, но последний из них может существовать сам по себе. Например, водитель использует машину, но она может существовать независимо от него и даже иметь несколько владельцев.

Агрегация в Go осуществляется с помощью встраивания одной структуры в другую в качестве одного из полей. При этом поля и методы внутренней структуры недоступны через внешнюю:

        type Car struct {
	Make  string
	Color string
}

type Driver struct {
	Name string
	Car  Car
}

func main() {
	driver := Driver{Name: "Игорь", Car: Car{Make: "BMW", Color: "black"}}

	fmt.Println(driver.Name)     // Игорь
	fmt.Println(driver.Car.Make) // BMW
	fmt.Println(driver.Color)    // ошибка
}

    

Композиция (has-a)

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

К примеру, двигатель является основной составляющей машины, без него она не будет выполнять все необходимые функции. В исключительных случаях это правило может быть сознательно нарушено. Например, если двигатель по какой-то причине необходимо отделить от автомобиля.

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

        type Engine struct {
	Model string
}

type Car struct {
	Make   string
	Color  string
	Engine // анонимное поле
}

func main() {
	car := Car{Make: "BMW", Color: "black", Engine: Engine{"S63"}}

	fmt.Println(car.Make, car.Color) // BMW black
	fmt.Println(car.Model) // S63
}

    

Принципы ООП

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

Наследование (is-a)

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

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

Абстракция

Абстракция – один из важнейших принципов, лежащих в основе понимания ООП. Он заключается в выделении ключевых характеристик объекта, исключении менее значимых и игнорировании деталей реализации. Такой подход позволяет сосредоточиться на том, что объект делает, а не на том, как он это делает.

Абстракция достигается определением абстрактных классов и интерфейсов. Абстрактный класс определяет общие характеристики и поведение объектов, а интерфейс описывает сигнатуры методов без их реализации.

Абстракция имеет несколько ключевых преимуществ для разработки:

  1. Позволяет упростить сложные системы, выделяя общие характеристики и игнорируя несущественные детали.
  2. Способствует гибкости и расширяемости, так как система может быть легко дополнена новыми типами, которые реализуют существующие интерфейсы.
  3. Облегчает повторное использование кода за счет создания моделей объектов.

Полиморфизм

Полиморфизм в ООП – это способность объекта работать с данными разных типов. Полиморфизм позволяет объектам разных классов обрабатываться с использованием общего интерфейса. Выделяется два основных вида полиморфизма: параметрический и ad-hoc (интерфейсный). Первый из них позволяет писать обобщенный код, поддерживающий любые типы данных. При втором подходе объекты разного типа обрабатываются одинаково вне зависимости от их структуры за счет использования интерфейсов. Таким образом, разница между этими двумя видами заключается в способе достижения обобщенности: через параметры типов в первом случае и через интерфейсы во втором. Принято считать, что в Go реализуется ad-hoc полиморфизм, поэтому дальше будем рассматривать именно его.

Отметим, что концепции полиморфизма и абстракции схожи между собой:

  1. Абстракция предоставляет абстрактный класс или интерфейс для работы с объектом и скрывает детали реализации.
  2. Полиморфизм, в свою очередь, позволяет создавать реализации этого интерфейса и использовать его для работы с разными объектами.

Перед рассмотрением реализации полиморфизма в Go вспомним важное правило: если определенный тип реализует все методы интерфейса, то этот тип автоматически удовлетворяет ему. Руководствуясь этим соображением, напишем небольшую программу, содержащую интерфейс Animals с единственным методом Voice() для работы с разными видами животных:

        // интерфейс для работы с произвольными животными
// имеет один метод Voice()
type Animals interface {
	Voice()
}

// структура для описания собаки
type Dog struct {
	Name string
}

// структура Dog реализует метод Voice
// и тем самым удовлетворяет интерфейсу Animals
func (d *Dog) Voice() {
	fmt.Printf("%s: Woof\\n", d.Name)
}

// структура для описания кошки
type Cat struct {
	Name string
}

// структура Cat реализует метод Voice
// и тем самым удовлетворяет интерфейсу Animals
func (c *Cat) Voice() {
	fmt.Printf("%s: Meow\\n", c.Name)
}

// функция для вызова метода Voice
func MakeVoice(a Animals) {
	a.Voice()
}

    

Написанный выше код показывает классический пример полиморфизма: два типа Dog и Cat реализуют один интерфейс Animal, но каждый из них имеет разное поведение, зависящее от типа. Такой подход способствует расширяемости кода, так как позволяет без труда создавать новые методы интерфейса Animals и типы, удовлетворяющие ему.

Убедимся в корректной работе программы:

        func main() {
	// создание слайса типа Animals с элементами структур Dog и Cat
	animals := []Animals{ 
		&Dog{Name: "Шарик"},
		&Cat{Name: "Мурка"},
	}

	// Вызов методов Voice у объектов слайса Animals
	for _, animal := range animals {
		MakeVoice(animal)
	}
}

    

В результате выполнения кода на экран будет выведено:

        Шарик: Woof
Мурка: Meow

    

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

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

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

В большинстве языков с методологией ООП используется два основных модификатора доступа: public – публичный, то есть доступный из любой части программы, и private – приватный, недоступный для обращения извне.

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

В коде ниже показаны примеры определения экспортируемых и неэкспортируемых объектов:

        type PublicStruct struct { // экспортируемая структура
	PublicField int // экспортируемое поле
}

type privateStruct struct { // неэкспортируемая структура
	privateField int // неэкспортируемое поле
}

var PublicVar // экспортируемая переменная
var privateVar // неэкспортируемая переменная

func PublicFunc() {} // экспортируемая функция
func privateFunc() {} // неэкспортируемая функция

    

Механизм экспорта структур был детально рассмотрен на примере проекта в 9 части самоучителя в пункте «Структуры и пакеты». Советуем обратиться к нему, чтобы закрепить применение инкапсуляции в Go.

Геттеры и сеттеры

Для обеспечения контролируемого доступа к данным класса используются так называемые геттеры (от английского get – получать) и сеттеры (от английского set – устанавливать). Они представляют собой методы для получения и установки соответственно значений полей класса. Иначе говоря, геттеры и сеттеры являются посредниками между классом и пользователем. Если запрашиваемая операция может быть выполнена без нарушения принципа инкапсуляции, то они её делают, иначе – нет. Это обеспечивает безопасность конфиденциальных данных и предоставляет контролируемый инструмент для работы с ними.

Задачи

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

Задача 1: банковская система

Задача по Go: банковская система
Задача по Go: банковская система

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

Для взаимодействия со счетом нужно реализовать методы:

NewAccount(owner string) *Account – конструктор для структуры Account

SetBalance(quantity float64) error – метод установки баланса (сеттер)

GetBalance() float64 – метод получения баланса (геттер)

Deposit(quantity float64) error – метод зачисления средств на счет

Withdraw(quantity float64) error – метод вывода средств со счета

Учитывайте, что количество и баланс не могут принимать отрицательные значения. Для этого определите собственные типы ошибок и возвращайте их в методах SetBalance, Deposit и Withdraw.

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

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

Подсказка: для создания ошибок с заданным текстом используйте функцию errors.New().

Решение:
        var errNegativeBalance = errors.New("ошибка: баланс не может быть отрицательным")
var errNegativeQuantity = errors.New("ошибка: количество не может быть отрицательным")

type Account struct {
	owner   string
	balance float64
}

func NewAccount(owner string) *Account {
	return &Account{owner: owner}
}

    

Подзадача 2: геттеры и сеттеры

Определите методы SetBalance и GetBalance с указанными в условии сигнатурами.

Решение:
        func (a *Account) SetBalance(balance float64) error {
	if balance < 0 {
		return errNegativeBalance
	}

	a.balance = balance
	return nil
}

func (a *Account) GetBalance() float64 {
	return a.balance
}

    

Подзадача 3: депозит и снятие

Создайте методы с сигнатурами из условия для депозита и снятия средств со счета. Помните, что вводимое количество средств и баланс не могут быть отрицательными.

Решение:
        // метод зачисления средств на счет
func (a *Account) Deposit(quantity float64) error {
	if quantity < 0 {
		return errNegativeQuantity
	}

	a.balance += quantity // зачисление средств
	return nil
}

// метод снятия средств со счета
func (a *Account) Withdraw(quantity float64) error {
	if quantity < 0 {
		return errNegativeQuantity
	}

	if a.balance-quantity < 0 {
		return errNegativeBalance
	}

	a.balance -= quantity // снятие средств
	return nil
}

    

Подзадача 4: тестирование программы

Напишите функцию main() для тестирования написанного кода. Создайте экземпляр структуры Account, протестируйте все реализованные методы с разными аргументами и проверьте результат их выполнения на наличие ошибок.

Пример решения:
        func main() {
	a := &Account{owner: "ownername"}
	a.SetBalance(100)

	fmt.Println(a.GetBalance()) // 100

	err := a.Deposit(10)
	if err != nil {
		panic(err) // вывод сообщения об ошибке с её описанием
	}

	err = a.Withdraw(100)
	if err != nil {
		panic(err) // вывод сообщения об ошибке с её описанием
	}

	fmt.Println(a.GetBalance()) // 10
}

    

Задача 2: управление складом

В этой задаче требуется написать систему управления складом с товарами. Необходимо создать структуры Product для представления товара и Storage для хранения товаров в мапе.

Для взаимодействия с элементами склада используется интерфейс Warehouse со следующими методами:

AddProduct(product Product) error – метод добавления нового товара на склад.

UpdateQuantity(productID int, quantity int) error – метод изменения количества товара на складе.

Структура Storage должна удовлетворять интерфейсу Warehouse.

Подзадача 1: структуры Product и Storage, интерфейс Warehouse

Создайте структуру Product с полями ID, Name и Quantity типов int, string и int соответственно, структуру Storage с полем типа map[int]Product для хранения товаров и интерфейс Warehouse с указанными в условии методами.

Решение:
        // структура представляет товар на складе
type Product struct {
	ID       int
	Name     string
	Quantity int
}

// структура представляет содержимое склада
type Storage struct {
	Products map[int]Product
}

// интерфейс представляет склад и его функциональность
type Warehouse interface {
	AddProduct(product Product) error
	UpdateQuantity(productID int, quantity int) error
}


    

Подзадача 2: метод добавления нового товара на склад

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

Подсказка: не забудьте создать мапу с помощью функции make.

Решение:
        // метод добавления нового товара на склад
func (st *Storage) AddProduct(product Product) error {
	if st.Products == nil {
		st.Products = make(map[int]Product)
	}

	if _, ok := st.Products[product.ID]; ok {
		return fmt.Errorf("товар с ID %d уже существует на складе", product.ID)
	}

	st.Products[product.ID] = product
	return nil
}

    

Подзадача 3: метод изменения количества товара на складе

Напишите код для метода UpdateQuantity, использующийся для изменения количества товара на складе. Возвращайте ошибку с поясняющим текстом, если id товара не найден на складе или количество товара приняло отрицательное значение.

Решение:
        // метод изменения количества товара на складе
func (st *Storage) UpdateQuantity(productID int, quantity int) error {
	product, ok := st.Products[productID]
	if !ok {
		return fmt.Errorf("товар с ID %d не найден на складе", productID)
	}

	if product.Quantity+quantity < 0 {
		return fmt.Errorf("количество товара не может быть отрицательным")
	}

	product.Quantity += quantity
	st.Products[productID] = product
	return nil
}

    

Подзадача 4: тестирование программы

Напишите функцию main() для тестирования написанного кода. Создайте экземпляр структуры Storage, протестируйте методы AddProduct и UpdateQuantity с разными аргументами и проверьте результат их выполнения на наличие ошибок.

Пример решения:
        func main() {
	// создание содержимого склада
	storage := &Storage{
		Products: make(map[int]Product),
	}

	// добавление товаров на склад
	err := storage.AddProduct(Product{ID: 1, Name: "Ноутбук", Quantity: 10})
	if err != nil {
		fmt.Print("Ошибка при добавлении товара: ", err)
		return
	}

	fmt.Println(storage.Products[1]) // {1 Ноутбук 10}

	// изменение количества товаров на складе
	err = storage.UpdateQuantity(1, -5) // продали 5 ноутбуков
	if err != nil {
		fmt.Print("Ошибка при изменении количества товара: ", err)
		return
	}

	fmt.Println(storage.Products[1]) // {1 Ноутбук 5}
}

    

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

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

Для отработки изученных знаний рекомендуем модифицировать решенные задачи или с нуля создать систему по методологии ООП.

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

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

  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
  18. Протокол HTTP. Создание HTTP-сервера и клиента. Пакет net/http

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

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