Структуры и интерфейсы
Привет! Шестой урок — один из самых крутых в 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}
или даже просто тип и адрес.
Важные детали
-
Метод должен называться точно
String()и возвращатьstring— иначе не сработает. -
Ресивер может быть по значению или по указателю — оба работают:
func (p Person) String() string { ... } // по значению
func (p *Person) String() string { ... } // по указателю — тоже окОбычно используют по значению (
Person), чтобы избежать лишних указателей. -
%v,%+v,%#vиспользуют String():%v— базовый вывод (использует String(), если есть).%+v— с именами полей (для структур).%#v— Go-синтаксис значения.
-
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
| Обычный assertion | Type 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)
}