null

Go вместе изучать Go. Часть 6 (Заключительная)

Введение

Добро пожаловать в заключительную статью нашей серии о языке программирования Go.

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

Работа с изображениями

Go использует интерфейсы для определения поведения. Интерфейс Image демонстрирует, как определить набор методов, которые должна реализовывать структура, чтобы считаться "изображением".

package image

type Image interface {
    ColorModel() color.Model
    Bounds() Rectangle
    At(x, y int) color.Color
}

Этот интерфейс описывает три метода: ColorModel() возвращает цветовую модель изображения, Bounds() — его границы в виде прямоугольника, а At(x, y int) — цвет пикселя по заданным координатам.

Обратите внимание: возвращаемое значение Rectangle метода Bounds на самом деле является типом image.Rectangle, поскольку объявление находится внутри пакета image.

Типы color.Color и color.Model также являются интерфейсами, но для простоты мы будем использовать готовые реализации color.RGBA и color.RGBAModel из пакета image/color.

package main

import (
	"fmt"
	"image"
)

func main() {
	// Создаем 100x100 пикселей типа RGBA
	m := image.NewRGBA(image.Rect(0, 0, 100, 100)) 
	
    // Мы можем вызвать методы, потому что *image.RGBA 
    // неявно реализует интерфейс image.Image.
	fmt.Println(m.Bounds())
	fmt.Println(m.At(0, 0).RGBA())
}
// Вывод:
// (0,0)-(100,100)
// 0 0 0 0

В этом примере мы создаём RGBA-изображение размером 100×100 пикселей. Функция Bounds() возвращает прямоугольник от точки (0,0) до точки (100,100). Метод At(0, 0).RGBA() возвращает четыре нуля — это значения красного, зелёного, синего и альфа-каналов для пикселя в левом верхнем углу. Нули означают полностью прозрачный чёрный цвет.

Универсальность кода: Обобщения (Generics)

Обобщения, появившиеся в Go 1.18, позволяют писать функции и типы, которые могут работать с любым типом данных, сохраняя при этом типобезопасность.

Параметры типов

Функции в Go можно писать так, чтобы они работали с несколькими типами, используя параметры типов. Параметры типов указываются в квадратных скобках перед аргументами функции:

func Index[T comparable](s []T, x T) int

Это объявление означает, что s — это срез элементов любого типа T, который удовлетворяет встроенному ограничению comparable. Значение x имеет тот же тип.

Ограничение comparable позволяет использовать операторы == и != для значений данного типа. В примере ниже мы используем его для сравнения значения со всеми элементами среза до нахождения совпадения:

package main

import "fmt"

// Index возвращает индекс x в срезе s или -1, если элемент не найден.
func Index[T comparable](s []T, x T) int {
    for i, v := range s {
        if v == x {
            return i
        }
    }
    return -1
}

func main() {
    // Index работает со срезом целых чисел
    si := []int{10, 20, 15, -10}
    fmt.Println(Index(si, 15))

    // Index также работает со срезом строк
    ss := []string{"foo", "bar", "baz"}
    fmt.Println(Index(ss, "hello"))
}

// Вывод:
// 2
// -1

Функция Index универсальна: она работает и с числами, и со строками — с любым типом, поддерживающим сравнение. Компилятор сам определяет конкретный тип T на основе переданных аргументов.

Обобщённые типы

Помимо обобщённых функций, Go поддерживает обобщённые типы. Тип можно параметризовать параметром типа, что особенно полезно для реализации универсальных структур данных.

Вот пример объявления типа для односвязного списка, хранящего значения любого типа, с добавленной функциональностью:

package main

import "fmt"

// List представляет односвязный список, хранящий значения любого типа.
type List[T any] struct {
    next *List[T]
    val  T
}

// Push добавляет новый элемент в начало списка и возвращает новую голову.
func (l *List[T]) Push(val T) *List[T] {
    return &List[T]{next: l, val: val}
}

// Len возвращает длину списка.
func (l *List[T]) Len() int {
    count := 0
    for current := l; current != nil; current = current.next {
        count++
    }
    return count
}

// ToSlice преобразует список в срез.
func (l *List[T]) ToSlice() []T {
    var result []T
    for current := l; current != nil; current = current.next {
        result = append(result, current.val)
    }
    return result
}

// Find ищет элемент в списке (работает только для comparable типов).
func Find[T comparable](l *List[T], val T) bool {
    for current := l; current != nil; current = current.next {
        if current.val == val {
            return true
        }
    }
    return false
}

func main() {
    // Создаём список целых чисел
    var head *List[int]
    head = head.Push(1)
    head = head.Push(2)
    head = head.Push(3)

    fmt.Println("Длина списка:", head.Len())
    fmt.Println("Элементы:", head.ToSlice())
    fmt.Println("Содержит 2?", Find(head, 2))
    fmt.Println("Содержит 5?", Find(head, 5))
}

// Вывод:
// Длина списка: 3
// Элементы: [3 2 1]
// Содержит 2? true
// Содержит 5? false

Обратите внимание: метод Push возвращает новую голову списка, поскольку мы добавляем элементы в начало. Функция Find объявлена отдельно с ограничением comparable, потому что не все типы поддерживают сравнение через ==.

Конкурентность

Горутины

Горутина — это легковесный поток, управляемый средой выполнения Go:

go f(x, y, z)

Эта команда запускает новую горутину, выполняющую f(x, y, z). Вычисление f, x, y и z происходит в текущей горутине, а выполнение f — в новой.

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

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("world")
    say("hello")
}

В этом примере две горутины выполняются параллельно: одна печатает «world», другая — «hello». Порядок вывода может варьироваться от запуска к запуску, поскольку горутины работают конкурентно.

// Пример вывода (порядок может меняться):
// hello
// hello
// world
// world
// ...

Каналы

Каналы — это типизированные каналы связи, через которые можно отправлять и получать значения с помощью оператора <-:

Каналы (chan) служат для безопасного обмена данными между горутинами.

ch <- v    // Отправить v в канал ch.
v := <-ch  // Получить значение из ch и присвоить его v.

Данные «текут» в направлении стрелки. Как и карты (maps) и срезы, каналы нужно создавать перед использованием:

ch := make(chan int)

По умолчанию операции отправки и получения блокируются, пока другая сторона не будет готова. Это позволяет горутинам синхронизироваться без явных блокировок.

package main

import "fmt"

func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum // отправить сумму в канал c
}

func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)
    x, y := <-c, <-c // получить из канала c

    fmt.Println(x, y, x+y)
}

// Вывод: -5 17 12

Здесь работа по суммированию распределяется между двумя горутинами. Каждая считает сумму своей половины среза и отправляет результат в канал. Главная горутина получает оба значения и складывает их.

Буферизованные каналы

Каналы могут быть буферизованными. Размер буфера указывается вторым аргументом make.

Буферизованные каналы позволяют отправить несколько значений, не блокируя отправителя, пока буфер не заполнится.

ch := make(chan int, 100)

Отправка в буферизованный канал блокируется только когда буфер заполнен. Получение блокируется, когда буфер пуст.

Вот что происходит при переполнении буфера:

package main

import "fmt"

func main() {
    ch := make(chan int, 2)
    ch <- 1
    ch <- 2
    // ch <- 3 // Эта строка вызовет deadlock!
    
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

Если раскомментировать строку ch <- 3, программа зависнет (deadlock), потому что буфер размером 2 уже заполнен, и отправка будет ждать, пока кто-то не прочитает из канала. Но читать некому — главная горутина заблокирована на отправке. Результат — взаимная блокировка:

fatal error: all goroutines are asleep - deadlock!

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

Завершение передачи данных (Range и Close)

Отправитель может закрыть канал, чтобы сигнализировать, что больше значений не будет. Получатель может проверить, закрыт ли канал:

v, ok := <-ch

Значение ok будет false, если канал закрыт и пуст.

Цикл for i := range c получает значения из канала до его закрытия.

Важно: закрывать канал должен только отправитель, никогда — получатель. Отправка в закрытый канал вызовет панику. При этом каналы не похожи на файлы — их обычно не нужно закрывать. Закрытие необходимо только когда получателю нужно знать, что значений больше не будет.

package main

import "fmt"

func fibonacci(n int, c chan int) {
    x, y := 0, 1
    for i := 0; i < n; i++ {
        c <- x
        x, y = y, x+y
    }
    close(c) // Сигнал об окончании
}

func main() {
    c := make(chan int, 10)
    go fibonacci(cap(c), c)

	// Цикл range автоматически завершится, когда fibonacci вызовет close(c).
    for i := range c {
        fmt.Println(i)
    }
}

// Вывод: 0 1 1 2 3 5 8 13 21 34

Управляемый выбор: select

Оператор select позволяет горутине ожидать готовности нескольких каналов связи. Он выбирает первый готовый случай, а если их несколько — выбирает случайным образом.

package main

import "fmt"

func fibonacci(c, quit chan int) {
    x, y := 0, 1
    for {
        select {
        case c <- x:
            x, y = y, x+y
        case <-quit:
            fmt.Println("quit")
            return
        }
    }
}

func main() {
    c := make(chan int)
    quit := make(chan int)
    go func() {
        for i := 0; i < 10; i++ {
            fmt.Println(<-c)
        }
        quit <- 0
    }()
    fibonacci(c, quit)
}

select блокируется до тех пор, пока один из его case-ов не сможет выполниться. Если готовы несколько — выбирается случайный.

Default в Select

Ветка default выполняется, если ни один другой case не готов. Это позволяет выполнять неблокирующие операции:

select {
case i := <-c:
    // Данные пришли
default:
    //Данные не пришли немедленно, продолжаем работу
}

Взаимное исключение: sync.Mutex

Каналы отлично подходят для коммуникации между горутинами. Но если нам нужно просто гарантировать, что только одна горутина имеет доступ к переменной в данный момент (общение через каналы в данном случае просто напросто избыточно)?

Для этого существует взаимное исключение (mutex). Стандартная библиотека Go предоставляет sync.Mutex с двумя методами: Lock и Unlock.

package main

import (
    "fmt"
    "sync"
    "time"
)

// SafeCounter безопасен для конкурентного использования.
type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

// Inc увеличивает счётчик для заданного ключа.
func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    c.v[key]++
    c.mu.Unlock()
}

// Value возвращает текущее значение счётчика.
func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}

func main() {
    c := SafeCounter{v: make(map[string]int)}
    for i := 0; i < 1000; i++ {
        go c.Inc("somekey")
    }

    time.Sleep(time.Second)
    fmt.Println(c.Value("somekey"))
}

// Вывод: 1000

Без мьютекса 1000 горутин, одновременно изменяющих карту, вызвали бы гонку данных (data race) и непредсказуемое поведение. Мьютекс гарантирует, что в каждый момент времени только одна горутина работает с картой.

Обратите внимание на использование defer c.mu.Unlock() в методе Value — это гарантирует разблокировку даже при ранних возвратах или панике.

Заключение

Из этой статьи мы научились работать с несколькими важными аспектами Go. Мы узнали, как использовать пакет image для создания и манипулирования изображениями через интерфейс Image. Освоили дженерики — мощный механизм для написания универсального, переиспользуемого кода с параметрами типов и ограничениями вроде comparable и any. Погрузились в мир конкурентности Go: изучили горутины как легковесные потоки, каналы как средство безопасной коммуникации, оператор select для работы с несколькими каналами одновременно и мьютексы для защиты общих данных.

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

Успехов в ваших проектах!

Вперед