Перейти к основному содержимому

Структуры и интерфейсы

Привет! Шестой урок — один из самых крутых в Go. Здесь мы узнаем, что такое дженерики и как писать настоящий объектно-ориентированный код, но по-гошному: без классического наследования, зато с мощной композицией и интерфейсами.

Go — не классический ООП-язык, но его подход часто оказывается проще и гибче. Главное правило: композиция важнее наследования.

Структуры (struct)

Структуры (struct) в Go — это как самодельная коробочка, куда ты кладешь разные данные вместе и даёшь ей имя. Это удобно для описания реальных вещей: человек, машина, заказ, точка на карте.

Структуры могут содержать другие структуры, к ним можно прикреплять методы (функции), а ещё добавлять теги для превращения в JSON. Маленькие структуры копируются просто и дёшево, большие лучше использовать через указатель (&Cat{...}), чтобы не тратить время на копирование.

Структуры — это основной способ в Go создавать свои типы данных и держать связанные вещи вместе!

Объявление и создание

type Person struct {
Name string
Age int
City string
}

type Rectangle struct {
Width, Height float64 // можно в одной строке
}

Создание экземпляров

func main() {
// 1. Полная инициализация
p1 := Person{
Name: "Алиса",
Age: 28,
City: "Москва",
}

// 2. Частичная (остальные поля — zero value)
p2 := Person{Name: "Боб", Age: 35}

// 3. По порядку полей
p3 := Person{"Вика", 22, "Питер"}

// 4. Через new() — возвращает указатель
p4 := new(Person)
p4.Name = "Глеб"
p4.Age = 31

// 5. Zero value
var p5 Person // Name="", Age=0, City=""

fmt.Printf("%+v\n", p1) // %+v — выводит имена полей
}

Доступ и изменение полей

person := Person{Name: "Дима", Age: 27}

fmt.Println(person.Name) // Дима
person.Age++ // 28

// Указатели
ptr := &person
fmt.Println(ptr.City) // Go автоматически разыменовывает!
ptr.City = "Сочи" // (*ptr).City = "Сочи"

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

Методы — это функции, привязанные к типу.

type Rectangle struct {
Width, Height float64
}

// Value receiver — получает копию (не меняет оригинал)
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}

// Pointer receiver — может изменять оригинал
func (r *Rectangle) Scale(factor float64) {
r.Width *= factor
r.Height *= factor
}

func (r Rectangle) IsSquare() bool {
return r.Width == r.Height
}

func main() {
rect := Rectangle{10, 5}

fmt.Printf("Площадь: %.1f\n", rect.Area()) // 50.0
fmt.Printf("Квадрат? %t\n", rect.IsSquare()) // false

rect.Scale(2)
fmt.Printf("После x2: %.1f x %.1f\n", rect.Width, rect.Height) // 20 x 10
}


Правило: если метод меняет структуру — используйте pointer receiver (*Rectangle).

Композиция через встраивание (embedding)

Go не имеет наследования, но есть встраивание — "наследование" полей и методов.

type Person struct {
Name string
Age int
}

func (p Person) Greet() string {
return fmt.Sprintf("Привет, я %s, мне %d лет", p.Name, p.Age)
}

type Employee struct {
Person // встраивание — все поля и методы Person доступны
Position string
Salary float64
}

func (e Employee) Greet() string {
return fmt.Sprintf("Здравствуйте, я %s, работаю %s", e.Name, e.Position)
}

func main() {
emp := Employee{
Person: Person{Name: "Олег", Age: 40},
Position: "Senior Go Developer",
Salary: 200000,
}

fmt.Println(emp.Greet()) // переопределённый метод
fmt.Println(emp.Person.Greet()) // метод базовой структуры
fmt.Println(emp.Name) // поле из встроенной структуры!
}

Это и есть "наследование" в Go — через композицию.

Интерфейсы

interface (Интерфейс) в Go — это как список умений: ты говоришь «мне нужна вещь, которая умеет делать вот это», и не важно, кто она такая.

Например, объявляешь type Speaker interface { Speak() string } — значит, нужен кто-то с методом Speak(), который возвращает строку. Любая структура (собака, кошка, человек), у которой есть такой метод, автоматически подходит под этот интерфейс — ничего лишнего писать не надо! Пишешь функцию func greet(s Speaker) { fmt.Println(s.Speak()) } — и она работает с собакой («Гав!»), кошкой («Мяу!») или кем угодно ещё, у кого есть это умение. Это делает код гибким: одна функция — много разных типов.

Интерфейсы — простой и мощный способ сказать «мне всё равно, кто ты, главное — что ты умеешь»

type Shape interface {
Area() float64
Perimeter() float64
}

type Circle struct{ Radius float64 }
type Rectangle struct{ Width, Height float64 }

func (c Circle) Area() float64 { return 3.14159 * c.Radius * c.Radius }
func (c Circle) Perimeter() float64 { return 2 * 3.14159 * c.Radius }

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

Полиморфизм

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

func PrintShapeInfo(s Shape) {  // принимаем ЛЮБОЙ тип, реализующий Shape
fmt.Printf("Площадь: %.2f, Периметр: %.2f\n", s.Area(), s.Perimeter())
}

func main() {
c := Circle{Radius: 5}
r := Rectangle{Width: 4, Height: 6}

PrintShapeInfo(c) // работает с кругом
PrintShapeInfo(r) // работает с прямоугольником
}

Переопределение метода String()

В Go есть встроенный интерфейс fmt.Stringer, который состоит ровно из одного метода:

type Stringer interface {
String() string
}

Если твоя структура (или любой тип) реализует этот метод — Go будет автоматически использовать его при выводе значения через функции пакета fmt (fmt.Println, fmt.Printf("%s"), fmt.Printf("%v") и т.д.).

Это называется переопределением строкового представления объекта — вместо стандартного вывода вроде <main.Person {Ivan 25}> ты получаешь красивую, читаемую строку.

Зачем это нужно?

  • Красивый вывод при отладке: fmt.Println(user) покажет понятную информацию, а не адрес в памяти.
  • Удобство логирования: логи становятся информативными.
  • Пользовательский интерфейс: легко показывать объекты пользователю.

Пример

package main

import "fmt"

type Person struct {
Name string
Age int
}

func (p Person) String() string {
return fmt.Sprintf("👤 %s (возраст: %d лет)", p.Name, p.Age)
}

func main() {
p := Person{Name: "Алексей", Age: 30}

fmt.Println(p) // → 👤 Алексей (возраст: 30 лет)
fmt.Printf("%s\n", p) // → 👤 Алексей (возраст: 30 лет)
fmt.Printf("%v\n", p) // → 👤 Алексей (возраст: 30 лет) (%v тоже использует String())
}

Без метода String() вывод был бы:

{Алексей 30}

или даже просто тип и адрес.

Важные детали

  1. Метод должен называться точно String() и возвращать string — иначе не сработает.

  2. Ресивер может быть по значению или по указателю — оба работают:

    func (p Person) String() string { ... }     // по значению
    func (p *Person) String() string { ... } // по указателю — тоже ок

    Обычно используют по значению (Person), чтобы избежать лишних указателей.

  3. %v, %+v, %#v используют String():

    • %v — базовый вывод (использует String(), если есть).
    • %+v — с именами полей (для структур).
    • %#v — Go-синтаксис значения.
  4. String() не должен вызывать fmt рекурсивно (может привести к бесконечному циклу).
    Лучше собирать строку вручную или через fmt.Sprintf с осторожностью.

Реальный пример с несколькими типами

type Book struct {
Title string
Author string
Pages int
}

func (b Book) String() string {
return fmt.Sprintf("📖 \"%s\" автора %s (%d стр.)", b.Title, b.Author, b.Pages)
}

type Movie struct {
Title string
Year int
}

func (m Movie) String() string {
return fmt.Sprintf("🎬 %s (%d год)", m.Title, m.Year)
}

func main() {
items := []fmt.Stringer{
Person{Name: "Мария", Age: 27},
Book{Title: "Слушай повесть ветра", Author: "Харуки Мураками", Pages: 256},
Movie{Title: "Интерстеллар", Year: 2014},
}

for _, item := range items {
fmt.Println(item)
}
}

Вывод:

👤 Мария (возраст: 27 лет)
📖 "Слушай повесть ветра" автора Харуки Мураками (256 стр.)
🎬 Интерстеллар (2014 год)

Благодаря интерфейсу fmt.Stringer функция fmt.Println автоматически вызывает правильный String() для каждого типа.

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

Пустой интерфейс в Go — это interface{} (или просто any в новых версиях) — специальный тип, у которого нет никаких требований к методам. Это значит, что в него можно положить абсолютно любой значение: число, строку, структуру, слайс, map, указатель, даже другую функцию или интерфейс. Он как универсальная коробка: «кидай сюда что угодно, я приму».

Например: var x any; x = 42; x = "привет"; x = []int{1,2,3} — всё работает! Используют его, когда нужно написать функцию, которая принимает любые данные (например, fmt.Println принимает any), или для хранения разнородных вещей в одном слайсе/map.

Но есть минус: чтобы достать значение и использовать как конкретный тип, нужно делать type assertion: str := x.(string) (или с проверкой str, ok := x.(string)).

Пустой интерфейс — это «ящик для всего», который делает код гибким, но требует осторожности при вытаскивании вещей обратно. 😊

func describe(v any) {
fmt.Printf("Тип: %T, Значение: %v\n", v, v)
}

func main() {
describe(42). // Тип: int, Значение: 42
describe("Привет") // Тип: string, Значение: "Привет"
describe([]int{1, 2, 3}) // Тип: []int, Значение: [1 2 3]
describe(Person{Name: "Катя", Age: 25}) // Тип: main.Person, Значение: {Катя 25}
}

Type switch

Type switch — это специальная конструкция switch в Go, которая позволяет проверить тип значения интерфейса (interface{}) и выполнить разные действия в зависимости от реального типа "под капотом".

Это один из способов работы с динамическими типами в статически типизированном языке Go. Он нужен, когда у тебя есть значение типа interface{}, и ты хочешь узнать, что именно в нём лежит: int, string, структура или что-то ещё.

Синтаксис

switch v := value.(type) {  // value — переменная типа interface{}
case int:
fmt.Println("Это int:", v)
case string:
fmt.Println("Это string:", v)
case bool:
fmt.Println("Это bool:", v)
case MyStruct:
fmt.Println("Это моя структура:", v.Field)
default:
fmt.Println("Неизвестный тип:", reflect.TypeOf(value))
}
  • value.(type) — специальный синтаксис, доступный только внутри switch.
  • v — это переменная, которая будет иметь конкретный тип в каждом case.
  • default — срабатывает, если ни один тип не подошёл.

Простой пример

package main

import "fmt"

func describe(x interface{}) {
switch v := x.(type) {
case int:
fmt.Printf("Это целое число: %d (квадрат: %d)\n", v, v*v)
case string:
fmt.Printf("Это строка длиной %d: %q\n", len(v), v)
case bool:
if v {
fmt.Println("Это правда! ✅")
} else {
fmt.Println("Это ложь ❌")
}
case float64:
fmt.Printf("Это число с плавающей точкой: %.2f\n", v)
default:
fmt.Printf("Неизвестный тип: %T\n", v)
}
}

func main() {
describe(42)
describe("Привет, Go!")
describe(true)
describe(3.14)
describe([]int{1, 2, 3}) // default
}

Вывод:

Это целое число: 42 (квадрат: 1764)
Это строка длиной 10: "Привет, Go!"
Это правда! ✅
Это число с плавающей точкой: 3.14
Неизвестный тип: []int

Сравнение с обычным type assertion

Обычный assertionType switch
v, ok := x.(int) — только один типПроверяет сразу несколько типов
Если тип не совпал — ok = falseАвтоматически идёт в default или другой case
Нужно писать много if-elseЧистый и читаемый код

Полезные нюансы

  • В case можно перечислять несколько типов: case int, int64:
  • В case можно использовать nil: case nil: fmt.Println("Значение nil")
  • Type switch работает только с interface (или совместимыми).
  • Внутри case переменная v имеет именно тот тип, который указан в case — можно обращаться к полям/методам.

Пример: система фигур

type Drawable interface {
Draw()
}

type Circle struct{ X, Y, R float64; Color string }
type Rect struct{ X, Y, W, H float64; Color string }
type Text struct{ X, Y float64; Content string; Size int }

func (c Circle) Draw() { fmt.Printf("Круг: центр (%.1f,%.1f), R=%.1f, цвет %s\n", c.X, c.Y, c.R, c.Color) }
func (r Rect) Draw() { fmt.Printf("Прямоугольник: (%.1f,%.1f) %.1fx%.1f, цвет %s\n", r.X, r.Y, r.W, r.H, r.Color) }
func (t Text) Draw() { fmt.Printf("Текст: \"%s\" в (%.1f,%.1f), размер %d\n", t.Content, t.X, t.Y, t.Size) }

func renderScene(objects []Drawable) {
fmt.Println("=== Рендерим сцену ===")
for _, obj := range objects {
obj.Draw()
}
}

func main() {
scene := []Drawable{
Circle{100, 100, 50, "red"},
Rect{200, 150, 100, 60, "blue"},
Text{50, 300, "Hello Go!", 24},
}
renderScene(scene)
}