Go Основы Go: типы, срезы, карты, управление потоком и модель значений
0%

Основы Go: типы, срезы, карты, управление потоком и модель значений

Основы языка Go

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

Первая программа и структура файла

// Пакет main — точка входа исполняемой программы.
package main

import "fmt"

func main() {
	fmt.Println("Привет, Go")
}

Каждый файл принадлежит пакету (package). Пакет — единица компиляции и инкапсуляции. Исполняемая программа обязана иметь пакет main с функцией main(). Всё в одном каталоге — один пакет.

Экспорт через регистр. В Go нет ключевых слов public/private. Правило простое: идентификатор, начинающийся с заглавной буквы, экспортируется (виден вне пакета); со строчной — приватный для пакета. fmt.Println экспортирован, fmt.newPrinter — нет. Это правило действует на функции, типы, поля структур, методы, константы — на всё.

Переменные, константы, нулевые значения

var x int = 42       // полная форма
var y = 42           // тип выводится
z := 42              // короткая форма, только внутри функций
const Pi = 3.14159   // константа, вычисляется на этапе компиляции

var s string         // s == "" — нулевое значение
var n int            // n == 0
var b bool           // b == false
var p *int           // p == nil

Ключевая идея, которой нет во многих языках: нулевое значение (zero value). В Go нет «неинициализированных» переменных. Любая переменная без явного значения получает нулевое значение своего типа: 0 для чисел, "" для строк, false для булевых, nil для указателей, срезов, карт, каналов, интерфейсов и функций. Это осознанный дизайн — многие типы делают так, что нулевое значение уже полезно и готово к работе (например, sync.Mutex или bytes.Buffer работают сразу без инициализации).

Неиспользуемая локальная переменная или импорт — это ошибка компиляции, а не предупреждение. Go принуждает к чистоте.

Базовые типы

  • Целые: int, int8/16/32/64, uint, uintptr. int — машинное слово (64 бита на современных платформах).
  • Числа с плавающей точкой: float32, float64.
  • byte — псевдоним uint8, rune — псевдоним int32 (одна кодовая точка Unicode).
  • string — неизменяемая последовательность байтов (обычно UTF-8).
  • bool.
  • Комплексные complex64/128 (редко нужны).

Go не делает неявных числовых преобразований. int32 + int64 не скомпилируется — нужно явно int64(x) + y. Это спасает от классических багов переполнения и потери точности.

Строки, байты и руны

Строка — это байты, а не символы. Индексация s[i] даёт байт, а не символ. Итерация for range по строке даёт руны (кодовые точки):

s := "Привет"
fmt.Println(len(s))          // 12 — БАЙТ, потому что кириллица в UTF-8 по 2 байта
for i, r := range s {        // i — индекс байта, r — руна
	fmt.Printf("%d:%c ", i, r)
}
// 0:П 2:р 4:и 6:в 8:е 10:т

runes := []rune(s)
fmt.Println(len(runes))      // 6 — количество символов

Это частый источник багов у пришедших из языков, где строка = массив символов. Для сложной работы с текстом есть strings, unicode/utf8, unicode.

Управление потоком

Управляющих конструкций мало и они без скобок вокруг условия.

// if с инициализатором — идиома Go
if v, err := doSomething(); err != nil {
	return err
} else {
	use(v) // v виден только внутри if/else
}

// for — единственный цикл в языке, во всех обличьях
for i := 0; i < 10; i++ { }        // классический
for i < 10 { }                     // как while
for { break }                      // бесконечный
for i, v := range slice { }        // по коллекции

// switch — без проваливания (break не нужен), с любыми типами
switch {                           // switch без выражения = цепочка if
case x > 100:
	fmt.Println("много")
case x > 10:
	fmt.Println("средне")
default:
	fmt.Println("мало")
}

// type switch — переключение по динамическому типу
switch v := i.(type) {
case string:
	fmt.Println("строка длины", len(v))
case int:
	fmt.Println("число", v)
}

switch в Go не проваливается сквозь case по умолчанию — это исправление одной из главных ошибок C. Если проваливание нужно, есть явное fallthrough.

Отдельно — defer. Отложенный вызов выполняется при выходе из функции, в порядке LIFO. Незаменим для гарантированного освобождения ресурсов:

f, err := os.Open("data.txt")
if err != nil {
	return err
}
defer f.Close() // выполнится при любом выходе из функции — return, panic, что угодно
// ... работаем с f

Указатели без арифметики

В Go есть указатели, но нет арифметики указателей (безопасность). &x берёт адрес, *p разыменовывает. Указатель нужен, чтобы изменить значение через функцию и чтобы не копировать большие структуры.

func increment(n *int) { *n++ }

x := 5
increment(&x)
fmt.Println(x) // 6

Здесь мы подходим к важнейшей теме.

Модель значений против ссылок — сердце Go

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

type Point struct{ X, Y int }

func moveByValue(p Point)  { p.X = 100 } // меняет КОПИЮ, снаружи не видно
func moveByPointer(p *Point) { p.X = 100 } // меняет оригинал через указатель

pt := Point{1, 2}
moveByValue(pt)
fmt.Println(pt.X) // 1 — не изменилось
moveByPointer(&pt)
fmt.Println(pt.X) // 100 — изменилось

Это причина, по которой методы часто объявляют на указателе-получателе (*T): чтобы менять состояние и не копировать структуру на каждом вызове. Правило большого пальца: если метод меняет получателя или структура большая — используйте *T; если структура маленькая и неизменяемая — можно T. Главное — не смешивать value- и pointer-получатели в рамках одного типа.

Структуры и композиция

type User struct {
	ID    int64
	Name  string
	Email string
}

type Admin struct {
	User        // встраивание (embedding), НЕ наследование
	Permissions []string
}

a := Admin{User: User{Name: "Аня"}, Permissions: []string{"all"}}
fmt.Println(a.Name) // "Аня" — поля встроенной структуры доступны напрямую (promotion)

Встраивание (embedding) — способ композиции. Admin не наследует User, а содержит его, но поля и методы «поднимаются» наверх и доступны напрямую. Это выглядит как наследование, но работает иначе: нет виртуальных вызовов, нет иерархии типов. Композиция вместо наследования — фундамент дизайна в Go.

Срезы (slices) — не массивы

Тут ломается больше всего новичков. В Go есть массивы фиксированной длины ([5]int) — они редки. И есть срезы ([]int) — динамические, ими пользуются постоянно.

Срез — это маленькая структура из трёх полей: указатель на нижележащий массив, длина (len) и вместимость (cap).

s := []int{10, 20, 30}
fmt.Println(len(s), cap(s)) // 3 3
s = append(s, 40)           // append растит срез, при нехватке cap — перевыделяет массив

Опасность: два среза могут ссылаться на один массив. Изменение через один виден через другой.

a := []int{1, 2, 3, 4, 5}
b := a[1:3]      // b смотрит на тот же массив: [2, 3], но cap=4
b[0] = 99
fmt.Println(a)   // [1 99 3 4 5] — изменили a через b!

Ещё коварнее — append внутри функции: если capacity хватает, он пишет в общий массив; если нет — перевыделяет, и связь теряется. Отсюда правило: если функция принимает срез и может его модифицировать неожиданно для вызывающего, лучше явно копировать (slices.Clone). Полное объяснение — в классической статье Go Slices: usage and internals в официальном блоге.

С Go 1.21 появился стандартный пакет slices с дженериками: slices.Sort, slices.Contains, slices.Index, slices.Clone, slices.Equal — пользуйтесь им вместо ручных циклов.

Карты (maps)

Карта — хеш-таблица, ссылочный тип. Нулевое значение карты — nil, и в неё нельзя писать — паника. Карту надо создать через make или литерал:

m := make(map[string]int)
m["ключ"] = 1

v, ok := m["нет"]   // идиома comma-ok: ok == false, v == 0
if !ok {
	// ключа не было
}

delete(m, "ключ")

for k, v := range m { // порядок итерации СЛУЧАЙНЫЙ — намеренно, не полагайтесь на него
	fmt.Println(k, v)
}

Карты не потокобезопасны. Конкурентная запись в карту из нескольких горутин без синхронизации — это паника рантайма (Go специально это детектит). Для конкурентного доступа используйте sync.Mutex или sync.Map (об этом в файле про конкурентность). С Go 1.21 есть пакет maps с хелперами maps.Keys, maps.Clone и т.д.

Интерфейсы — неявные и мощные

Интерфейс описывает поведение — набор методов. Магия Go в том, что интерфейсы удовлетворяются неявно: тип реализует интерфейс автоматически, если у него есть нужные методы. Не надо писать implements.

type Stringer interface {
	String() string
}

type Color struct{ R, G, B uint8 }

// Color автоматически удовлетворяет Stringer — просто потому что есть метод String()
func (c Color) String() string {
	return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B)
}

Это включает главный принцип дизайна: «accept interfaces, return structs». Функции принимают интерфейсы (гибкость: подойдёт любой подходящий тип), а возвращают конкретные структуры (ясность). Канонический пример — io.Reader и io.Writer: два крошечных интерфейса с одним методом, на которых держится вся стандартная библиотека ввода-вывода.

Опасная тонкость — nil-интерфейс. Интерфейс внутри хранит пару (тип, значение). Интерфейс равен nil, только если обе части nil. Классический баг:

func doWork() error {
	var err *MyError = nil
	// ... err остался nil
	return err // ВНИМАНИЕ: возвращаем интерфейс с типом *MyError и nil-значением
}
// вызывающий: if err != nil — ИСТИНА! Потому что тип не nil, хотя значение nil.

Правило: возвращайте nil буквально, не типизированный nil-указатель. Об этом подробнее — в главе про ошибки.

Дженерики (с Go 1.18)

Go долго жил без дженериков; их добавили в 1.18. Синтаксис — параметры типа в квадратных скобках с ограничениями (constraints):

import "cmp"

// T может быть любым упорядочиваемым типом (cmp.Ordered — из стандартной библиотеки)
func Max[T cmp.Ordered](a, b T) T {
	if a > b {
		return a
	}
	return b
}

fmt.Println(Max(3, 7))        // 7
fmt.Println(Max("a", "b"))    // "b"

Важная культурная норма: дженерики в Go нужны для контейнеров и алгоритмов (slices, maps), а не для абстрагирования всего подряд. Совет команды Go: если сомневаетесь, пишите конкретный тип. Интерфейсы по-прежнему главный инструмент полиморфизма.

Тонкости, которые стоит запомнить

  • iota — генератор последовательных констант, идиома для перечислений:
    type State int
    const (
    	Pending State = iota // 0
    	Active               // 1
    	Closed               // 2
    )
    
  • Множественные возвращаемые значения — норма, особенно (результат, error).
  • Именованные возвращаемые значения существуют, но злоупотреблять ими не стоит — снижают читаемость.
  • Нет перегрузки функций и значений по умолчанию у аргументов — вместо этого разные имена функций или паттерн functional options.

Источники для углубления

  • A Tour of Go — интерактивно, обязательно к прохождению.
  • Effective Go — идиомы от авторов.
  • Go by Example — запускаемые примеры на каждую тему.
  • Спецификация языка: The Go Programming Language Specification — короткая и читаемая, в отличие от многих языков.
  • «100 Go Mistakes and How to Avoid Them» Тейвы Харшаньи — разбор реальных ловушек, многие про срезы и интерфейсы.

Что дальше

Синтаксис — половина дела. Теперь научимся писать идиоматичный Go: правильно обрабатывать ошибки и следовать стилю, принятому в сообществе.

03. Идиомы и ошибки

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов