null

Go вместе изучать Go. Часть 3

Введение

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

Давайте начнем наше путешествие в мир Go, начиная с одной из самых важных и часто неправильно понимаемых концепций — указателей.

1. Указатели (Pointers): Управление памятью и ссылками на данные

Что такое указатель и зачем он нужен?

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

Представьте себе реальный пример: в почтовой системе письмо содержит не саму квартиру, а адрес квартиры. Указатель работает по тому же принципу — это "адрес" переменной в памяти, а не сама переменная.

Синтаксис указателей в Go

В Go указатели объявляются с помощью префикса *.

Вот основные операции:

package main

import "fmt"

func main() {
	// Объявляем переменную типа int
	i := 42
	
	// Создаем указатель на переменную i
	// & оператор "взять адрес" — возвращает адрес памяти переменной
	p := &i
	
	// *p — разыменование указателя, получение значения по адресу
	fmt.Println(*p)  // Выведет: 42
	
	// Можем изменить значение через указатель
	*p = 21
	fmt.Println(i)   // Выведет: 21, так как i изменилась через указатель
}

Объяснение кода:

  •  var p *int — объявляет переменную p как указатель на значение типа int. Начальное значение nil (нулевое значение указателя).
  •  &i — оператор взятия адреса (address-of operator). Возвращает адрес памяти, где хранится переменная i.
  •  *p — оператор разыменования (dereference operator). Получает значение, на которое указывает p.

Практический пример: Работа с указателями

package main

import "fmt"

func main() {
	i, j := 42, 2701

	p := &i         // p указывает на i
	fmt.Println(*p) // Читаем значение i через указатель: 42
	*p = 21         // Изменяем значение i через указатель
	fmt.Println(i)  // Видим новое значение i: 21

	p = &j         // p теперь указывает на j
	*p = *p / 37   // Делим значение j на 37 через указатель
	fmt.Println(j) // Видим новое значение j: 73
}

//Вывод:
//42
//21
//73

Еще пример: Система управления банковскими счетами

В банковской системе часто требуется изменять баланс счета через различные операции (снятие, пополнение, переводы). Указатели позволяют безопасно передавать и изменять данные счета:

type BankAccount struct {
	Balance float64
}

func Withdraw(account *BankAccount, amount float64) bool {
	if account.Balance >= amount {
		*(&account.Balance) = account.Balance - amount
		return true
	}
	return false
}

// Использование:
account := &BankAccount{Balance: 1000}
Withdraw(account, 250) // Баланс теперь 750

Важные особенности указателей в Go

  • Отсутствие арифметики указателей: В отличие от C, Go не позволяет выполнять арифметические операции с указателями (например, p++). Это безопаснее и предотвращает многие типичные ошибки.
  • Нулевое значение: Нулевое значение указателя — это nil, что означает, что указатель не указывает ни на что.
  • Безопасность памяти: Go использует сборку мусора, поэтому вам не нужно беспокоиться об освобождении памяти, как в C.

Зачем нужны указатели если есть обычные переменные?

Коротко: указатели в Go нужны, чтобы работать не с копиями данных, а с самими данными по их адресу в памяти. Обычные переменные передаются и присваиваются по значению (копируются). Если вам нужно изменить исходный объект, избежать дорогостоящих копий или выразить «отсутствие значения» через nil — нужны указатели.

Рассмотрим примеры:

а) Изменение данных внутри функции

Если передать значение, изменится только копия. С указателем меняется оригинал.

package main

import "fmt"

type Person struct {
	Name string
	Age  int
}

func RenameByValue(p Person, newName string) {
	p.Name = newName // меняем копию, оригинал останется прежним
}

func RenameByPointer(p *Person, newName string) {
	p.Name = newName // меняем оригинал через указатель
}

func main() {
	alex := Person{Name: "Alex", Age: 30}

	RenameByValue(alex, "Alexandr")
	fmt.Println(alex.Name) // Alex (не изменилось)

	RenameByPointer(&alex, "Alexandr")
	fmt.Println(alex.Name) // Alexandr (изменилось)
}

б) Производительность: избегаем дорогостоящих копий

Копирование больших структур может заметно поднимать нагрузку на CPU и GC.

BigReport struct {
	Title string
	Data  [1_000_000]byte // условно большой массив
}

func ProcessByValue(r BigReport) {
	// r скопируется целиком: дорого
}

func ProcessByPointer(r *BigReport) {
	// копируется только адрес: дёшево
}

в) Выразить «отсутствие» через nil

Nil — нулевое значение указателя. Удобно для «нет результата».

type User struct {
	ID   int
	Name string
}

func FindUser(id int) (*User, error) {
	// ... поиск
	return nil, nil // не найдено: nil даёт понять, что пользователя нет
}

func main() {
	u, err := FindUser(42)
	if err != nil { /* ... */ }
	if u == nil {
		fmt.Println("Пользователь не найден")
	}
}

г) Совместное состояние между частями программы

Указатель позволяет нескольким функциям/модулям работать с одним и тем же объектом.

type Config struct {
	Locale string
	Debug  bool
}

func EnableDebug(cfg *Config) { cfg.Debug = true }
func SetLocale(cfg *Config, loc string) { cfg.Locale = loc }

func main() {
	cfg := &Config{Locale: "ru"}
	EnableDebug(cfg)
	SetLocale(cfg, "ka")
	// cfg теперь {Locale:"ka", Debug:true}
}

Важно: в многопоточном коде (goroutines) совместное состояние требует синхронизации (sync.Mutex), иначе будут гонки данных.

д) Структуры данных со связями (списки, деревья)

Связанные структуры строятся на указателях — узлы ссылаются друг на друга.

type Node struct {
	Value int
	Next  *Node
}

func Push(head **Node, v int) {
	newNode := &Node{Value: v, Next: *head}
	*head = newNode
}

func main() {
	var head *Node
	Push(&head, 1)
	Push(&head, 2)
	// head -> 2 -> 1
}

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

е) Методы с pointer receiver и интерфейсы

Если методы объявлены на *T, то их нельзя вызвать на T (значении) без взятия адреса.

type Counter struct{ n int }

func (c *Counter) Inc() { c.n++ } // pointer receiver: меняет состояние

func main() {
	var c Counter
	// c.Inc() // работает, компилятор сам возьмёт адрес
	fmt.Println(c.n) // 1
}

типы со внутренним состоянием (счётчик, буфер, соединение) обычно имеют методы на *T, чтобы изменять состояние без лишних копий. Интерфейсы в Go чувствительны к метод-сетам: если интерфейс требует метода с pointer receiver, то реализует его только *T, не T.

2. Структуры (Structs): Организация связанных данных

Что такое структура?

Структура — это композитный тип данных, который группирует несколько переменных (полей) разных типов в одну единицу. Структуры являются фундаментом объектно-ориентированного программирования в Go и используются для представления реальных объектов и сущностей.

Если указатели — это "адреса в памяти", то структуры — это "чертежи зданий". Структура определяет, какие данные должны быть связаны вместе.

Объявление и использование структур

package main

import "fmt"

// Определяем структуру Vertex (вершина)
type Vertex struct {
	X int  // первое поле
	Y int  // второе поле
}

func main() {
	// Создаем экземпляр структуры
	fmt.Println(Vertex{1, 2})
}

//Вывод:
//{1 2}

Объяснение:

  • type Vertex struct { ... } — определяет новый тип структуры с именем Vertex
  • У структуры два поля: X и Y, оба типа int
  • При создании Vertex{1, 2} первое значение присваивается X, второе — Y

Доступ к полям структуры

package main

import "fmt"

type Vertex struct {
	X int
	Y int
}

func main() {
	v := Vertex{1, 2}
	
	// Доступ к полям структуры через точку
	v.X = 4
	fmt.Println(v.X)  // Вывод: 4
}

Поля структуры в Go доступны через оператор точки (.). Это простой и интуитивный синтаксис.

Указатели на структуры

Часто в Go передают структуры через указатели, особенно если структура большая или нужно изменять ее данные:

package main

import "fmt"

type Vertex struct {
	X int
	Y int
}

func main() {
	v := Vertex{1, 2}
	p := &v  // Указатель на структуру
	
	// Важно: в Go можно использовать p.X вместо (*p).X
	// Это синтаксический сахар для удобства
	p.X = 1e9  // Присваиваем 1 миллиард
	fmt.Println(v)
}
//Вывод:
//{1000000000 2}

Объяснение:

  • p := &v создает указатель на структуру v
  • Вместо (*p).X = 1e9 (явное разыменование) Go позволяет писать просто p.X = 1e9
  • Это делает код более читаемым и удобным

Литералы структур: создание структур с частичной инициализацией

package main

import "fmt"

type Vertex struct {
	X, Y int
}

var (
	v1 = Vertex{1, 2}  // оба поля инициализированы
	v2 = Vertex{X: 1}  // Y получит значение по умолчанию (0)
	v3 = Vertex{}      // оба поля = 0
	p  = &Vertex{1, 2} // указатель на структуру
)

func main() {
	fmt.Println(v1, p, v2, v3)
}
//Вывод:
//{1 2} &{1 2} {1 0} {0 0}

Объяснение:

  • Vertex{X: 1} — именованная инициализация. Это удобно, когда у структуры много полей и нужно явно указать, какому полю какое значение
  • Поля без явного значения получают нулевое значение типа (для int это 0)
  • &Vertex{1, 2} создает новую структуру и сразу возвращает на нее указатель

Пример: Система управления недвижимостью

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

type Property struct {
	ID           int
	Address      string
	Price        float64
	IsAvailable  bool
	Owner        string
	Bedrooms     int
	SquareMeters float64
}

type Location struct {
	Latitude  float64
	Longitude float64
	City      string
}

type RealEstateAgent struct {
	Name        string
	PhoneNumber string
	Email       string
	Properties  []Property
	Locations   []Location
}

// Использование:
agent := RealEstateAgent{
	Name:        "John Smith",
	PhoneNumber: "+995 555 555 555",
	Email:       "john@realty.ge",
	Properties:  []Property{},
}

Структуры в Go идеально подходят для моделирования сложных сущностей и отношений между ними.

3. Массивы (Arrays): Фиксированный размер, гарантированная емкость

Основы массивов

Массив — это последовательность элементов одного типа с фиксированным размером. Размер массива — это часть его типа.

package main

import "fmt"

func main() {
	// Объявляем массив из 2 строк
	var a [2]string
	a[0] = "Hello"
	a[1] = "World"
	fmt.Println(a[0], a[1])  // Hello World
	fmt.Println(a)            // [Hello World]

	// Массив с инициализацией значений
	primes := [6]int{2, 3, 5, 7, 11, 13}
	fmt.Println(primes)
}
//Вывод:
//Hello World
//[Hello World]
//[2 3 5 7 11 13]

Объяснение:

  • var a [2]string — объявляет массив из 2 элементов типа string
  • Индексация начинается с 0: a[0] и a[1]
  • [6]int{2, 3, 5, 7, 11, 13} — создает массив и инициализирует все элементы

Ограничения массивов

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

4. Срезы (Slices): Динамические, гибкие представления данных

Что такое срез?

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

Срез состоит из трех компонентов:

  • Указатель на базовый массив
  • Длина (количество элементов в срезе)
  • Емкость (количество элементов в базовом массиве, начиная с первого элемента среза)

Создание срезов

package main

import "fmt"

func main() {
	// Создаем базовый массив
	primes := [6]int{2, 3, 5, 7, 11, 13}

	// Создаем срез из элементов 1-3 (индексы от 1 включительно до 4 исключительно)
	var s []int = primes[1:4]
	fmt.Println(s)  // [3 5 7]
}
// Вывод:
// [3 5 7]

Объяснение синтаксиса срезов:

  • primes — срез от индекса 1 (включительно) до индекса 4 (исключительно)
  • Это полуоткрытый интервал: первый элемент включен, последний исключен
  • Срез содержит элементы: primes[1] (3), primes[2] (5), primes[3] (7)

Срезы как ссылки на массивы

Критически важный момент: срез не хранит данные, он только описывает часть базового массива. Если вы изменяете элемент через срез, изменяется и базовый массив:

package main

import "fmt"

func main() {
	names := [4]string{
		"John",
		"Paul",
		"George",
		"Ringo",
	}
	fmt.Println(names)  // [John Paul George Ringo]

	a := names[0:2]    // Срез первых двух элементов
	b := names[1:3]     // Срез элементов с индекса 1 по 2
	fmt.Println(a, b)   // [John Paul] [Paul George]

	b[0] = "XXX"      // Меняем элемент через срез b
	fmt.Println(a, b)   // [John XXX] [XXX George]
	fmt.Println(names)  // [John XXX George Ringo]
}

Объяснение:

  • Срезы a и b указывают на один и тот же базовый массив
  • Когда мы меняем b[0] = "XXX", мы фактически меняем names[1]
  • Это видно как в срезе b, так и в исходном массиве names, и даже в срезе a (который перекрывается с b)

Литералы срезов

Литерал среза — как литерал массива, только без длины

package main

import "fmt"

func main() {
	// Литерал целых чисел (без указания длины — это срез, не массив)
	q := []int{2, 3, 5, 7, 11, 13}
	fmt.Println(q)

	// Литерал булевых значений
	r := []bool{true, false, true, true, false, true}
	fmt.Println(r)

	// Срез структур
	s := []struct {
		i int
		b bool
	}{
		{2, true},
		{3, false},
		{5, true},
		{7, true},
		{11, false},
		{13, true},
	}
	fmt.Println(s)
}
// Вывод:
// [2 3 5 7 11 13]
// [true false true true false true]
// [{2 true} {3 false} {5 true} {7 true} {11 false} {13 true}]

Ключевое отличие:

  • [6]int{...} — это массив (размер фиксирован)
  • []int{...} — это срез (размер может изменяться)

Значения по умолчанию при срезе

При срезе можно опускать индексы:

package main

import "fmt"

func main() {
	s := []int{2, 3, 5, 7, 11, 13}

	s = s[1:4]  // [3 5 7]
	fmt.Println(s)

	s = s[:2]    // Опускаем нижний индекс, начинается с 0: [3 5]
	fmt.Println(s)

	s = s[1:]    // Опускаем верхний индекс, идет до конца: [5]
	fmt.Println(s)
}

Длина и емкость срезов

package main

import "fmt"

func main() {
	s := []int{2, 3, 5, 7, 11, 13}
	printSlice(s)  // len=6 cap=6 [2 3 5 7 11 13]

	s = s[:0]      // Срез с нулевой длиной, но полной емкостью
	printSlice(s)  // len=0 cap=6 []

	s = s[:4]      // Расширяем длину
	printSlice(s)  // len=4 cap=6 [2 3 5 7]

	s = s[2:]      // Сдвигаем начало
	printSlice(s)  // len=2 cap=4 [5 7]
}

func printSlice(s []int) {
	fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}

Объяснение:

  • len(s) — возвращает количество элементов в срезе
  • cap(s) — возвращает емкость (размер базового массива от начала среза)
  • Емкость важна для оптимизации: если срез растет, Go использует существующую емкость до предела, прежде чем выделять новую память

Нулевые срезы

package main

import "fmt"

func main() {
	var s []int
	fmt.Println(s, len(s), cap(s))  // [] 0 0
	if s == nil {
		fmt.Println("nil!")
	}
}
// Вывод:
// [] 0 0
// nil!

Нулевое значение среза — это nil. Такой срез имеет длину 0 и емкость 0, и у него нет базового массива.

Часто в API считается нормой возвращать nil‑срез вместо пустого.

Создание срезов с помощью make

Функция make используется для создания динамического среза с определенными длиной и емкостью:

package main

import "fmt"

func main() {
	// Создаем срез длины 5
	a := make([]int, 5)
	printSlice("a", a)  // a len=5 cap=5 [0 0 0 0 0]

	// Создаем срез длины 0 и емкости 5
	b := make([]int, 0, 5)
	printSlice("b", b)  // b len=0 cap=5 []

	// Расширяем срез b до его емкости
	c := b[:2]
	printSlice("c", c)  // c len=2 cap=5 [0 0]

	// Еще больше сдвигаем и расширяем
	d := c[2:5]
	printSlice("d", d)  // d len=3 cap=3 [0 0 0]
}

func printSlice(s string, x []int) {
	fmt.Printf("%s len=%d cap=%d %v\n", s, len(x), cap(x), x)
}

Объяснение:

  • make([]int, 5) создает срез длины 5 с емкостью 5, все элементы инициализированы нулевыми значениями.
  • make([]int, 0, 5) создает "пустой" срез (длина 0) с емкостью 5. Это полезно, когда вы планируете добавлять элементы.

Срезы срезов

Срезы могут содержать любой тип, включая другие срезы:

package main

import (
	"fmt"
	"strings"
)

func main() {
	// Создаем доску для крестиков-ноликов
	board := [][]string{
		[]string{"_", "_", "_"},
		[]string{"_", "_", "_"},
		[]string{"_", "_", "_"},
	}

	// Игроки ходят по очереди
	board[0][0] = "X"
	board[2][2] = "O"
	board[1][2] = "X"
	board[1][0] = "O"
	board[0][2] = "X"

	// Выводим доску
	for i := 0; i < len(board); i++ {
		fmt.Printf("%s\n", strings.Join(board[i], " "))
	}
}
// Вывод:
// X _ X
// O _ X
// _ _ O

Это пример того, как можно использовать многомерные структуры данных.

Добавление элементов в срезы (append)

Функция append добавляет элементы в конец среза, при необходимости расширяя подлежащий массив:

package main

import "fmt"

func main() {
	var s []int
	printSlice(s)  // len=0 cap=0 []

	s = append(s, 0)
	printSlice(s)  // len=1 cap=1 [0]

	s = append(s, 1)
	printSlice(s)  // len=2 cap=2 [0 1]

	// Добавляем сразу несколько элементов
	s = append(s, 2, 3, 4)
	printSlice(s)  // len=5 cap=6 [0 1 2 3 4]
}

func printSlice(s []int) {
	fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}

Объяснение:

  • append работает даже с нулевыми срезами
  • Когда текущей емкости недостаточно, Go выделяет новый массив с большей емкостью
  • Обратите внимание: при переходе от емкости 4 к 5 элементам, новая емкость стала 6 (Go обычно удваивает емкость при расширении)
  • append возвращает новый срез, и его нужно присвоить переменной

Итерирование по срезам (range)

package main

import "fmt"

var pow = []int{1, 2, 4, 8, 16, 32, 64, 128}

func main() {
	for i, v := range pow {
		fmt.Printf("2**%d = %d\n", i, v)
	}
}
// Вывод:
// 2**0 = 1
// 2**1 = 2
// 2**2 = 4
// 2**3 = 8
// 2**4 = 16
// 2**5 = 32
// 2**6 = 64
// 2**7 = 128

Конструкция range изящно решает проблему итерации по срезу.

Она является разновидностью цикла «for». Для каждой итерации возвращаются два значения. Первое — это индекс, а второе — копия элемента с этим индексом.

Важно отметить, что при необходимости можно пропускать индекс или значение:

for i := range pow { ... pow[i] = some-value... }
for _, v := range pow { ... fmt.Println(v) ... }

5. Карты (Maps): Ассоциативные массивы и словари

Основы карт

Карта (map) — это неупорядоченная коллекция пар "ключ-значение". Карты в Go похожи на словари в Python или хэш-таблицы в других языках. 

package main

import "fmt"

type Vertex struct {
	Lat, Long float64
}

var m map[string]Vertex

func main() {
	m = make(map[string]Vertex)
	m["Bell Labs"] = Vertex{40.68433, -74.39967}
	fmt.Println(m["Bell Labs"])
}
// Вывод:
// {40.68433 -74.39967}

Объяснение:

  • map[string]Vertex — карта с ключами типа string и значениями типа Vertex
  • make(map[string]Vertex) создает и инициализирует карту
  • Доступ к элементам карты осуществляется также как к элементам массива: m[key] = value

Литералы карт

Литералы похожи на литералы структур, но с ключами:

package main

import "fmt"

type Vertex struct {
	Lat, Long float64
}

var m = map[string]Vertex{
	"Bell Labs": {40.68433, -74.39967},
	"Google":    {37.42202, -122.08408},
}

func main() {
	fmt.Println(m)
}
// Вывод:
// map[Bell Labs:{40.68433 -74.39967} Google:{37.42202 -122.08408}]

Если тип значений указан на верхнем уровне, его можно опустить внутри:

var m = map[string]Vertex{
	"Bell Labs": {40.68433, -74.39967},
	"Google":    {37.42202, -122.08408},
}

Операции с картами: вставка, получение, удаление

package main

import "fmt"

func main() {
	m := make(map[string]int)

	// Вставка/обновление
	m["Answer"] = 42
	fmt.Println("The value:", m["Answer"])  // 42

	// Обновление
	m["Answer"] = 48
	fmt.Println("The value:", m["Answer"])  // 48

	// Удаление
	delete(m, "Answer")
	fmt.Println("The value:", m["Answer"])  // 0 (нулевое значение для int)

	// Проверка наличия ключа
	v, ok := m["Answer"]
	fmt.Println("The value:", v, "Present?", ok)  // 0 false
}

Объяснение:

  • m[key] = value — вставляет или обновляет значение
  • v := m[key] — получает значение по ключу (если ключа нет, возвращается нулевое значение)
  • delete(m, key) — удаляет элемент из карты
  • v, ok := m[key] — безопасный способ получить значение и узнать, есть ли такой ключ. ok будет true если ключ присутствует, false если нет

Пример: Кэширование данных

type OnlineCourse struct {
	ID    int
	Price float64
	City  string
}

type OnlineCourseCache struct {
	cache map[int]OnlineCourse
}

func (pc *OnlineCourseCache) Get(id int) (OnlineCourse, bool) {
	onlcrs, ok := pc.cache[id]
	return onlcrs, ok
}

func (occ *OnlineCourseCache) Set(id int, oc OnlineCourse) {
	occ.cache[id] = oc
}

func (occ *OnlineCourseCache) Remove(id int) {
	delete(occ.cache, id)
}

// Использование:
cache := &OnlineCourseCache{cache: make(map[int]OnlineCourse)}
cache.Set(1, OnlineCourse{1, 150000, "Spb"})
if onlcrs, ok := cache.Get(1); ok {
	fmt.Printf("Found: %+v\n", onlcrs)
}

Важно: обычные map не потокобезопасны. Для конкурентного доступа используйте мьютексы или sync.Map.

6. Функции как значения: Функции первого класса

Функции как аргументы

В Go функции — это значения первого класса (first-class values). Это означает, что функции можно присваивать переменным, передавать как аргументы и возвращать как результаты. Это фундаментальная концепция функционального программирования.

package main

import (
	"fmt"
	"math"
)

// Функция, которая принимает другую функцию как аргумент
func compute(fn func(float64, float64) float64) float64 {
	return fn(3, 4)
}

func main() {
	// Определяем анонимную функцию для вычисления гипотенузы
	hypot := func(x, y float64) float64 {
		return math.Sqrt(x*x + y*y)
	}
	
	fmt.Println(hypot(5, 12))      // 13 (напрямую)
	fmt.Println(compute(hypot))    // 5 (3^2 + 4^2 = 5)
	fmt.Println(compute(math.Pow)) // 81 (3^4 = 81)
}

Объяснение:

  • compute принимает функцию fn с сигнатурой func(float64, float64) float64.
  • В main мы создаём анонимную функцию hypot (гипотенуза).
  • compute(hypot) — передаём функцию как значение.
  • compute(math.Pow) — передаём встроенную функцию.
  • Функция compute вызывает переданную ей функцию с аргументами (3, 4)

Пример: Фильтрация и преобразование данных

// Фильтруем онлайн курсы по цене
func FilterByPrice(courses []OnlineCourse, predicate func(OnlineCourse) bool) []OnlineCourse {
	var result []OnlineCourse
	for _, oc := range courses {
		if predicate(oc) {
			result = append(result, oc)
		}
	}
	return result
}

// Использование:
courses := []OnlineCourse{
	{1, 100000, "Spb"},
	{2, 250000, "Msk"},
	{3, 75000, "Tokio"},
}

affordable := FilterByPrice(courses, func(oc OnlineCourse) bool {
	return oc.Price < 200000
})

7. Замыкания (Closures): Функции с памятью

Что такое замыкание?

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

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

Пример простого замыкания:

package main

import "fmt"

func adder() func(int) int {
	sum := 0  // sum — переменная, которую "запомнит" замыкание
	
	return func(x int) int {
		sum += x  // Замыкание имеет доступ к sum
		return sum
	}
}

func main() {
	pos, neg := adder(), adder()
	
	for i := 0; i < 10; i++ {
		fmt.Println(
			pos(i),      // Первое замыкание
			neg(-2*i),   // Второе замыкание
		)
	}
}
// Вывод:
// 0 0
// 1 -2
// 3 -6
// 6 -12
// 10 -20
// 15 -30
// 21 -42
// 28 -56
// 36 -72
// 45 -90

Объяснение этого примера:

  1. Функция adder() возвращает функцию, которая имеет доступ к переменной sum
  2. Каждый вызов adder() создает новое замыкание со своей собственной переменной sum
  3. pos и neg — это два разных замыкания, каждое с собственным sum
  4. pos(i) прибавляет i к своему sum и возвращает новое значение
  5. neg(-2*i) прибавляет -2*i к своему sum и возвращает новое значение
  6. На каждой итерации эти замыкания "помнят" значения своих переменных sum и обновляют их

Пример: Паттерн "счетчик" для отслеживания просмотров


type ViewCounter struct {
	increment func(int) int
	get       func() int
}

func NewViewCounter() *ViewCounter {
	views := 0
	
	return &ViewCounter{
		increment: func(add int) int {
			views += add
			return views
		},
		get: func() int {
			return views
		},
	}
}


counter := NewViewCounter()
counter.increment(1)  // Просмотр 1
counter.increment(3)  // Еще 3 просмотра
fmt.Println(counter.get())  // 4

Еще пример:

// Создаем функцию, которая "запоминает" минимальную цену
func CreatePriceValidator(minPrice float64) func(OnlineCourse) bool {
	return func(oc OnlineCourse) bool {
		return oc.Price >= minPrice
	}
}

// Использование:
expensiveValidator := CreatePriceValidator(200000)
affordableValidator := CreatePriceValidator(100000)

courses := []OnlineCourse{
	{1, 150000, "Spb"},
	{2, 250000, "Msk"},
}

// Фильтруем дорогие курсы
for _, oc := range courses {
	if expensiveValidator(oc) {
		fmt.Println("Expensive:", oc)
	}
}

// Фильтруем доступные курсы
for _, oc := range courses {
	if affordableValidator(oc) {
		fmt.Println("Affordable:", oc)
	}
}

еще пример, генератор значений:

func IDGenerator(start int64) func() int64 {
    id := start
    return func() int64 {
        id++
        return id
    }
}

//использование:
idGenerator := IDGenerator(100)
idGenerator() //101
idGenerator() //102 
и тд

Различия между замыканиями

// Замыкание 1: переменная ВНУТРИ замыкания
func makeCounter1() func() int {
	i := 0
	return func() int {
		i++
		return i
	}
}

// Замыкание 2: переменная СНАРУЖИ замыкания
var globalCounter int
func makeCounter2() func() int {
	return func() int {
		globalCounter++
		return globalCounter
	}
}

// Использование:
c1a := makeCounter1()
c1b := makeCounter1()

fmt.Println(c1a())  // 1 (своя переменная)
fmt.Println(c1a())  // 2
fmt.Println(c1b())  // 1 (новая собственная переменная)

c2a := makeCounter2()
c2b := makeCounter2()

fmt.Println(c2a())  // 1 (общая переменная)
fmt.Println(c2a())  // 2
fmt.Println(c2b())  // 3 (та же глобальная переменная)

Заключение

Мы прошли долгий путь, изучая фундаментальные концепции языка Go.

Давайте подведем итоги и сделаем ключевые выводы:

Указатели и управление памятью

  • Указатели дают нам контроль над адресами в памяти
  • Go автоматически управляет памятью через сборщик мусора, но указатели позволяют нам быть более эффективными
  • Передача указателей на большие структуры экономит память

Структуры для моделирования данных

  • Структуры — это способ группировать связанные данные
  • Они являются основой для создания типов, которые представляют реальные сущности
  • Вместе с методами структуры образуют основу объектно-ориентированного подхода в Go

Массивы: фиксированный размер, предсказуемость

  • Массивы имеют фиксированный размер, что является частью их типа
  • Они используются когда размер данных известен заранее
  • В большинстве случаев вместо массивов используются срезы

Срезы: гибкость и эффективность

  • Срезы — это динамические представления массивов
  • Они являются основным типом коллекции в Go
  • Понимание длины и емкости срезов важно для написания эффективного кода

Карты: быстрый поиск и хранение данных

  • Карты обеспечивают быстрый доступ к данным по ключу
  • Они не упорядочены, что важно помнить
  • Проверка наличия ключа (ok) критична для безопасного кода

Функции как значения: функциональное программирование

  • Функции в Go — это полноценные значения, которые можно передавать и возвращать
  • Это открывает возможности для функционального подхода к программированию
  • Функции высшего порядка (функции, которые принимают или возвращают функции) являются мощным инструментом

Замыкания: состояние и поведение

  • Замыкания позволяют функциям "помнить" переменные из их лексического окружения
  • Это позволяет создавать более гибкие и мощные абстракции
  • Каждое замыкание имеет собственное копирование переменных из своей области видимости
Вперед