🐹 Go

ООП: Классы и объекты в Go (Golang): подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты решил построить город из конструктора LEGO. У тебя есть коробка с деталями, и ты можешь собрать из них дома, машины, деревья и человечков. Но что, если бы каждый раз, когда тебе нужен новый дом, тебе приходилось бы искать подходящие кирпичики, двери и окна по всей коробке и собирать их заново? Это было бы очень долго и неудобно.

А теперь представь, что у тебя есть чертёж дома. На этом чертеже нарисовано, сколько у дома этажей, какого цвета стены, где находятся окна и какая у него крыша. Теперь, чтобы построить новый дом, тебе не нужно каждый раз придумывать его с нуля — ты просто берёшь чертёж и строишь по нему точную копию. Так можно построить хоть 10, хоть 100 одинаковых домов.

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

Важно запомнить: Класс — это чертёж (шаблон). Объект — это конкретная вещь, созданная по этому чертежу.

Давай разовьём аналогию. У каждого дома есть характеристики: адрес, количество жильцов, цвет стен. В программировании это называются полями или свойствами. А ещё дом умеет что-то делать: открывать двери, включать свет, запускать отопление. Это называются методами.

Зачем это нужно? Представь, что ты пишешь игру, где есть 1000 врагов. Если ты будешь описывать каждого врага отдельно (его здоровье, скорость, оружие), твоя программа превратится в хаос. А если ты создашь класс Враг с полями здоровье, скорость и методом атаковать(), то ты сможешь создать 1000 врагов одной командой, и каждый будет вести себя одинаково, но иметь свои собственные значения здоровья.

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

Теория: как это работает изнутри

Теперь давай заглянем "под капот" и разберёмся, как компьютер обрабатывает классы и объекты.

Память и указатели

Когда ты создаёшь класс, компьютер не выделяет под него память. Класс — это просто инструкция, описание. Память выделяется только тогда, когда ты создаёшь объект (экземпляр).

Представь, что память компьютера — это огромный склад с ячейками. Класс — это инструкция "возьми 5 ячеек рядом: в первую положи число здоровья, во вторую — имя, в третью, четвёртую и пятую — координаты". Когда ты создаёшь объект, компьютер находит свободные 5 ячеек и заполняет их данными.

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

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

Это умное слово означает "скрытие внутреннего устройства". Вернёмся к дому. Ты не заходишь в дом и не начинаешь вручную крутить провода, чтобы включить свет. Ты просто нажимаешь на выключатель. Внутри дома сложная электрика, но тебе не нужно о ней знать.

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

  • Заглавная буква (например, GetAge) — публичный метод, доступный всем.
  • Строчная буква (например, calculateSalary) — приватный метод, доступный только внутри этого же пакета.

Композиция вместо наследования

В классическом ООП (как в Java или C++) есть понятие "наследование" — когда один класс может быть расширением другого (например, класс "Кошка" наследует класс "Животное"). В Go решили пойти другим путём.

Go использует композицию — вместо того чтобы говорить "автомобиль является транспортным средством", мы говорим "автомобиль содержит двигатель, колёса и руль". Это проще и гибче. Если тебе нужно, чтобы объект умел делать что-то, ты просто добавляешь в него нужные компоненты.

Золотое правило Go: "Лучше использовать композицию, чем наследование". Это делает код более гибким и понятным.

Синтаксис в Go (Golang): все формы и варианты

В Go нет ключевого слова class. Вместо этого используются структуры (struct) и методы. Давай разберём все варианты.

1. Объявление структуры (класса)

// Объявляем структуру "Студент"
type Student struct {
    Name    string  // Имя студента (публичное поле)
    Age     int     // Возраст (публичное поле)
    grade   float64 // Оценка (приватное поле, только внутри пакета)
}

2. Создание объекта (экземпляра)

// Способ 1: через литерал структуры
student1 := Student{Name: "Анна", Age: 20, grade: 4.5}

// Способ 2: через ключевое слово new (возвращает указатель)
student2 := new(Student)
student2.Name = "Иван"
student2.Age = 22

// Способ 3: пустая структура, потом заполняем
var student3 Student
student3.Name = "Мария"
student3.Age = 19

3. Методы (функции, привязанные к структуре)

// Метод с "получателем-значением" (копия объекта)
func (s Student) GetInfo() string {
    return fmt.Sprintf("Студент: %s, возраст: %d", s.Name, s.Age)
}

// Метод с "получателем-указателем" (изменяет оригинал)
func (s *Student) SetGrade(newGrade float64) {
    s.grade = newGrade
}

4. Конструкторы (фабричные функции)

В Go нет конструкторов как в других языках, но есть общепринятая практика — функция New...:

func NewStudent(name string, age int, grade float64) *Student {
    return &Student{
        Name:  name,
        Age:   age,
        grade: grade,
    }
}

5. Встраивание структур (композиция)

type Address struct {
    City    string
    Street  string
    House   int
}

type Person struct {
    Name    string
    Address // Встраивание структуры Address
}

// Теперь у Person есть доступ к полям Address напрямую
p := Person{Name: "Олег"}
p.City = "Москва" // Можно обратиться напрямую, а не p.Address.City

6. Интерфейсы (контракты поведения)

// Определяем интерфейс
type Speaker interface {
    Speak() string
}

// Реализуем интерфейс для структуры Dog
type Dog struct {
    Name string
}

func (d Dog) Speak() string {
    return "Гав-гав!"
}

// Реализуем интерфейс для структуры Cat
type Cat struct {
    Name string
}

func (c Cat) Speak() string {
    return "Мяу!"
}

// Функция принимает любой объект, реализующий интерфейс Speaker
func MakeSound(s Speaker) {
    fmt.Println(s.Speak())
}

Примеры кода — от простого к сложному

Пример 1: Простейшая структура "Книга"

package main

import "fmt"

// Определяем структуру "Книга"
type Book struct {
    Title  string // Название книги
    Author string // Автор
    Pages  int    // Количество страниц
}

func main() {
    // Создаём объект книги
    myBook := Book{
        Title:  "Война и мир",
        Author: "Лев Толстой",
        Pages:  1225,
    }
    
    // Выводим информацию о книге
    fmt.Printf("Книга: %s
Автор: %s
Страниц: %d
", 
        myBook.Title, myBook.Author, myBook.Pages)
}

Пример 2: Структура с методом "Банковский счёт"

package main

import "fmt"

// Структура "БанковскийСчёт"
type BankAccount struct {
    owner   string  // Владелец (приватное поле)
    balance float64 // Баланс (приватное поле)
}

// Конструктор для создания нового счёта
func NewBankAccount(owner string) *BankAccount {
    return &BankAccount{
        owner:   owner,
        balance: 0.0,
    }
}

// Метод для внесения денег (депозит)
func (a *BankAccount) Deposit(amount float64) {
    if amount > 0 {
        a.balance += amount
        fmt.Printf("Внесено %.2f руб. Баланс: %.2f руб.
", amount, a.balance)
    } else {
        fmt.Println("Сумма должна быть положительной!")
    }
}

// Метод для снятия денег
func (a *BankAccount) Withdraw(amount float64) {
    if amount > 0 && amount <= a.balance {
        a.balance -= amount
        fmt.Printf("Снято %.2f руб. Баланс: %.2f руб.
", amount, a.balance)
    } else {
        fmt.Println("Недостаточно средств или неверная сумма!")
    }
}

// Метод для получения баланса (геттер)
func (a *BankAccount) GetBalance() float64 {
    return a.balance
}

func main() {
    // Создаём новый счёт
    account := NewBankAccount("Иван Петров")
    
    // Работаем со счётом
    account.Deposit(1000)
    account.Withdraw(500)
    account.Withdraw(600) // Должна быть ошибка
    
    fmt.Printf("Итоговый баланс: %.2f руб.
", account.GetBalance())
}

Пример 3: Композиция структур "Автомобиль"

package main

import "fmt"

// Структура "Двигатель"
type Engine struct {
    HorsePower int    // Мощность в лошадиных силах
    Type       string // Тип двигателя (бензин, дизель, электро)
}

// Метод для запуска двигателя
func (e Engine) Start() {
    fmt.Printf("Двигатель мощностью %d л.с. запущен
", e.HorsePower)
}

// Структура "Колесо"
type Wheel struct {
    Diameter int  // Диаметр в дюймах
    IsWinter bool // Зимняя резина или нет
}

// Структура "Автомобиль" (содержит двигатель и колёса)
type Car struct {
    Brand  string        // Марка автомобиля
    Model  string        // Модель
    Engine Engine        // Встроенный двигатель
    Wheels [4]Wheel      // 4 колеса
}

// Метод для вывода информации об автомобиле
func (c Car) Info() {
    fmt.Printf("Автомобиль: %s %s
", c.Brand, c.Model)
    fmt.Printf("Двигатель: %s, %d л.с.
", c.Engine.Type, c.Engine.HorsePower)
    fmt.Printf("Колёса: диаметр %d дюймов
", c.Wheels[0].Diameter)
}

func main() {
    // Создаём автомобиль
    myCar := Car{
        Brand: "Toyota",
        Model: "Camry",
        Engine: Engine{
            HorsePower: 200,
            Type:       "Бензин",
        },
        Wheels: [4]Wheel{
            {Diameter: 17, IsWinter: false},
            {Diameter: 17, IsWinter: false},
            {Diameter: 17, IsWinter: false},
            {Diameter: 17, IsWinter: false},
        },
    }
    
    // Выводим информацию
    myCar.Info()
    
    // Запускаем двигатель
    myCar.Engine.Start()
}

Пример 4: Интерфейсы и полиморфизм "Фигуры"

package main

import (
    "fmt"
    "math"
)

// Определяем интерфейс "Фигура"
type Shape interface {
    Area() float64      // Метод для вычисления площади
    Perimeter() float64 // Метод для вычисления периметра
}

// Структура "Круг"
type Circle struct {
    Radius float64
}

// Реализуем методы интерфейса для круга
func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}

// Структура "Прямоугольник"
type Rectangle struct {
    Width  float64
    Height float64
}

// Реализуем методы интерфейса для прямоугольника
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

// Функция, которая работает с любой фигурой
func PrintShapeInfo(s Shape) {
    fmt.Printf("Площадь: %.2f
", s.Area())
    fmt.Printf("Периметр: %.2f
", s.Perimeter())
    fmt.Println("---")
}

func main() {
    // Создаём разные фигуры
    circle := Circle{Radius: 5}
    rectangle := Rectangle{Width: 4, Height: 6}
    
    // Передаём их в одну и ту же функцию
    fmt.Println("Информация о круге:")
    PrintShapeInfo(circle)
    
    fmt.Println("Информация о прямоугольнике:")
    PrintShapeInfo(rectangle)
}

Пример 5: Слайс объектов "Список студентов"

package main

import "fmt"

type Student struct {
    Name  string
    Grade int
}

// Метод для вывода информации о студенте
func (s Student) String() string {
    return fmt.Sprintf("%s (оценка: %d)", s.Name, s.Grade)
}

// Структура "Группа студентов"
type Group struct {
    Name     string
    Students []Student
}

// Метод для добавления студента в группу
func (g *Group) AddStudent(s Student) {
    g.Students = append(g.Students, s)
}

// Метод для поиска отличников (оценка 5)
func (g Group) FindExcellentStudents() []Student {
    var excellent []Student
    for _, student := range g.Students {
        if student.Grade == 5 {
            excellent = append(excellent, student)
        }
    }
    return excellent
}

func main() {
    // Создаём группу
    group := Group{Name: "Группа 101"}
    
    // Добавляем студентов
    group.AddStudent(Student{Name: "Анна", Grade: 5})
    group.AddStudent(Student{Name: "Иван", Grade: 4})
    group.AddStudent(Student{Name: "Мария", Grade: 5})
    group.AddStudent(Student{Name: "Пётр", Grade: 3})
    
    // Выводим всех студентов
    fmt.Printf("Группа: %s
", group.Name)
    for _, student := range group.Students {
        fmt.Println(" -", student.String())
    }
    
    // Выводим отличников
    fmt.Println("
Отличники:")
    for _, student := range group.FindExcellentStudents() {
        fmt.Println(" -", student.String())
    }
}

Пример 6: Встраивание и переопределение методов

package main

import "fmt"

// Базовый класс "Животное"
type Animal struct {
    Name string
}

func (a Animal) Speak() string {
    return "Я животное"
}

func (a Animal) Move() string {
    return "Я двигаюсь"
}

// "Собака" встраивает "Животное"
type Dog struct {
    Animal          // Встраиваем Animal
    Breed string    // Порода собаки
}

// Переопределяем метод Speak для собаки
func (d Dog) Speak() string {
    return "Гав-гав!"
}

// "Кошка" встраивает "Животное"
type Cat struct {
    Animal
    Color string
}

// Переопределяем метод Speak для кошки
func (c Cat) Speak() string {
    return "Мяу!"
}

func main() {
    // Создаём животных
    dog := Dog{
        Animal: Animal{Name: "Бобик"},
        Breed:  "Овчарка",
    }
    
    cat := Cat{
        Animal: Animal{Name: "Мурка"},
        Color:  "Рыжий",
    }
    
    // Демонстрируем методы
    fmt.Printf("%s говорит: %s
", dog.Name, dog.Speak())
    fmt.Printf("%s двигается: %s
", dog.Name, dog.Move()) // Унаследованный метод
    
    fmt.Printf("%s говорит: %s
", cat.Name, cat.Speak())
    fmt.Printf("%s двигается: %s
", cat.Name, cat.Move()) // Унаследованный метод
}

Пример 7: Фабричный метод и работа с ошибками

package main

import (
    "errors"
    "fmt"
)

// Структура "Пользователь"
type User struct {
    ID       int
    Username string
    Email    string
    Age      int
}

// Фабричная функция с валидацией
func NewUser(id int, username, email string, age int) (*User, error) {
    // Проверяем, что имя пользователя не пустое
    if username == "" {
        return nil, errors.New("имя пользователя не может быть пустым")
    }
    
    // Проверяем, что email содержит @
    if !containsAt(email) {
        return nil, errors.New("некорректный email")
    }
    
    // Проверяем возраст
    if age < 0 || age > 150 {
        return nil, errors.New("некорректный возраст")
    }
    
    // Создаём и возвращаем пользователя
    return &User{
        ID:       id,
        Username: username,
        Email:    email,
        Age:      age,
    }, nil
}

// Вспомогательная функция для проверки email
func containsAt(email string) bool {
    for _, char := range email {
        if char == '@' {
            return true
        }
    }
    return false
}

// Метод для вывода информации о пользователе
func (u User) Display() string {
    return fmt.Sprintf("ID: %d, Имя: %s, Email: %s, Возраст: %d",
        u.ID, u.Username, u.Email, u.Age)
}

func main() {
    // Создаём корректного пользователя
    user1, err := NewUser(1, "ivan123", "ivan@mail.ru", 25)
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Println("Создан пользователь:", user1.Display())
    }
    
    // Пытаемся создать пользователя с пустым именем
    user2, err := NewUser(2, "", "test@mail.ru", 20)
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Println("Создан пользователь:", user2.Display())
    }
    
    // Пытаемся создать пользователя с некорректным возрастом
    user3, err := NewUser(3, "petr", "petr@mail.ru", 200)
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Println("Создан пользователь:", user3.Display())
    }
}

Сравнение с Python: чем похоже и чем отличается

Если ты уже знаком с Python, тебе будет проще понять Go. Но есть важные различия, которые нужно запомнить.

Характеристика Python Go (Golang)
Ключевое слово для класса class struct (нет слова class)
Создание объекта obj = MyClass() obj := MyStruct{}
Конструктор __init__(self) Функция NewStruct() (по соглашению)
self / this self (явно передаётся) Получатель (receiver) — переменная перед именем метода
Наследование Есть, полноценное Нет, используется композиция
Интерфейсы Абстрактные классы, множественное наследование Неявная реализация (duck typing на уровне компиляции)
Инкапсуляция _private (по соглашению) Заглавные/строчные буквы (на уровне пакета)
Перегрузка операторов Есть (__add__, __str__) Нет
Типизация Динамическая Статическая, строгая
Производительность Ниже (интерпретируемый) Выше (компилируемый)
Важное отличие: В Python ты можешь написать obj.new_field = 5 и у объекта появится новое поле. В Go так не получится — все поля должны быть объявлены в структуре заранее. Это делает Go более строгим, но и более надёжным.

Пример сравнения: один и тот же код на Python и Go

Python:

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def bark(self):
        return f"{self.name} says: Гав!"
    
    def get_human_age(self):
        return self.age * 7

my_dog = Dog("Бобик", 3)
print(my_dog.bark())
print(f"Человеческий возраст: {my_dog.get_human_age()}")

Go:

package main

import "fmt"

type Dog struct {
    Name string
    Age  int
}

func NewDog(name string, age int) *Dog {
    return &Dog{Name: name, Age: age}
}

func (d Dog) Bark() string {
    return fmt.Sprintf("%s говорит: Гав!", d.Name)
}

func (d Dog) GetHumanAge() int {
    return d.Age * 7
}

func main() {
    myDog := NewDog("Бобик", 3)
    fmt.Println(myDog.Bark())
    fmt.Printf("Человеческий возраст: %d
", myDog.GetHumanAge())
}

Типичные ошибки и подводные камни

Ошибка 1: Забыть про указатель при изменении объекта

type Counter struct {
    Value int
}

// НЕПРАВИЛЬНО: метод получает копию, изменения не сохранятся
func (c Counter) Increment() {
    c.Value++ // Изменяется копия, оригинал остаётся без изменений
}

// ПРАВИЛЬНО: метод получает указатель
func (c *Counter) IncrementCorrect() {
    c.Value++ // Изменяется оригинал
}

func main() {
    c := Counter{Value: 0}
    c.Increment()
    fmt.Println(c.Value) // Выведет 0! Ошибка!
    
    c.IncrementCorrect()
    fmt.Println(c.Value) // Выведет 1. Правильно!
}

Ошибка 2: Путаница с nil и нулевыми значениями

type Person struct {
    Name string
    Age  int
}

func main() {
    var p1 Person // Создаётся с нулевыми значениями: Name="", Age=0
    var p2 *Person // Просто указатель, равен nil
    
    // Ошибка: обращение к nil указателю
    // fmt.Println(p2.Name) // Паника!
    
    // Правильно: сначала выделить память
    p2 = new(Person)
    p2.Name = "Иван"
    fmt.Println(p2.Name) // Работает
}

Ошибка 3: Сравнение структур с слайсами или мапами

type Data struct {
    Items []int
}

func main() {
    d1 := Data{Items: []int{1, 2, 3}}
    d2 := Data{Items: []int{1, 2, 3}}
    
    // Ошибка: структуры с слайсами нельзя сравнивать через ==
    // if d1 == d2 { // Ошибка компиляции!
    //     fmt.Println("Равны")
    // }
    
    // Правильно: сравнивать вручную
    if len(d1.Items) == len(d2.Items) {
        equal := true
        for i := range d1.Items {
            if d1.Items[i] != d2.Items[i] {
                equal = false
                break
            }
        }
        if equal {
            fmt.Println("Равны")
        }
    }
}

Ошибка 4: Игнорирование ошибок в конструкторах

type User struct {
    Email string
}

// Плохая практика: конструктор не возвращает ошибку
func NewUserBad(email string) *User {
    // Если email некорректный, мы всё равно создаём пользователя
    return &User{Email: email}
}

// Хорошая практика: конструктор возвращает ошибку
func NewUserGood(email string) (*User, error) {
    if !strings.Contains(email, "@") {
        return nil, fmt.Errorf("некорректный email: %s", email)
    }
    return &User{Email: email}, nil
}

Ошибка 5: Создание методов на встроенных типах

// Ошибка: нельзя создать метод на встроенном типе из другого пакета
// func (s string) Reverse() string { // Ошибка компиляции!
//     // ...
// }

// Правильно: создать свой тип на основе встроенного
type MyString string

func (s MyString) Reverse() string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

Лучшие практики и советы профессионалов

1. Используйте указатели для больших структур

Если структура содержит много полей (например, больше 5-6), передавайте её через указатель. Это экономит память и время, так как не создаётся копия.

// Плохо: копирование большой структуры
func ProcessLargeData(data LargeData) {
    // data — это копия
}

// Хорошо: передача указателя
func ProcessLargeData(data *LargeData) {
    // data — это ссылка на оригинал
}

2. Соблюдайте принцип единственной ответственности

Каждая структура должна отвечать только за одну вещь. Если структура делает слишком много — разбейте её на несколько.

// Плохо: одна структура делает всё
type UserManager struct {
    // ...
}
func (u *UserManager) SaveUser() {}
func (u *UserManager) SendEmail() {}
func (u *UserManager) GenerateReport() {}

// Хорошо: разделение ответственности
type UserRepository struct {}
func (r *UserRepository) SaveUser() {}

type EmailService struct {}
func (e *EmailService) SendEmail() {}

type ReportGenerator struct {}
func (g *ReportGenerator) GenerateReport() {}

3. Используйте интерфейсы для гибкости

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

// Определяем интерфейс для хранилища данных
type Storage interface {
    Save(data []byte) error
    Load() ([]byte, error)
}

// Реализация для файловой системы
type FileStorage struct {
    Path string
}

func (f FileStorage) Save(data []byte) error {
    return os.WriteFile(f.Path, data, 0644)
}

func (f FileStorage) Load() ([]byte, error) {
    return os.ReadFile(f.Path)
}

// Реализация для базы данных
type DBStorage struct {
    Connection string
}

func (d DBStorage) Save(data []byte) error {
    // Сохранение в БД
    return nil
}

func (d DBStorage) Load() ([]byte, error) {
    // Загрузка из БД
    return []byte{}, nil
}

// Функция работает с любым хранилищем
func ProcessData(s Storage) error {
    data, err := s