Введение
В этой статье мы подробно разберем одни из самых фундаментальных аспектов языка 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
Объяснение этого примера:
- Функция adder() возвращает функцию, которая имеет доступ к переменной sum
- Каждый вызов adder() создает новое замыкание со своей собственной переменной sum
- pos и neg — это два разных замыкания, каждое с собственным sum
- pos(i) прибавляет i к своему sum и возвращает новое значение
- neg(-2*i) прибавляет -2*i к своему sum и возвращает новое значение
- На каждой итерации эти замыкания "помнят" значения своих переменных 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 — это полноценные значения, которые можно передавать и возвращать
- Это открывает возможности для функционального подхода к программированию
- Функции высшего порядка (функции, которые принимают или возвращают функции) являются мощным инструментом
Замыкания: состояние и поведение
- Замыкания позволяют функциям "помнить" переменные из их лексического окружения
- Это позволяет создавать более гибкие и мощные абстракции
- Каждое замыкание имеет собственное копирование переменных из своей области видимости