Thank Go!
1.74K subscribers
3 photos
73 links
Неожиданный взгляд на язык программирования Go. Конструктив от @mikeberezin с нотками сарказма от @nalgeon
Download Telegram
Пустой срез vs. nil-срез

Как вы знаете, объявленная без инициализации переменная в Go автоматически получает нулевое значение соответствующего типа:

var num int // 0
var str string // ""
var flag bool // false


Для среза нулевое значение — nil:

var snil []int
// []int(nil)


С другой стороны, бывает инициализированный, но пустой срез:

sempty := []int{}
// or
// sempty = make([]int, 0)


Это разные значения, которые не равны между собой:

reflect.DeepEqual(snil, sempty)
// false


И в то же время, пустой срез и nil-срез почти всегда взаимозаменямы:

len(snil) // 0
cap(snil) // 0
snil = append(snil, 1) // []int{1}

len(sempty) // 0
cap(sempty) // 0
sempty = append(sempty, 1) // []int{1}

reflect.DeepEqual(snil, sempty)
// true


Почти всегда ваш код не должен делать разницы между пустым и nil-срезом.

Есть и исключения, конечно. Приглашаю поделиться ими в комментариях.
Обновил курс до Go 1.22

Добавил в базовый курс по Go новые блоки и темы:

— range по целым числам;
— встроенные функции min, max и clear;
— комбинация ошибок через errors.Join;
— причина отмены контекста и context.AfterFunc (+ забористая задачка);
— дженерики, пакеты slices и maps (про них уже писал выше);
— тип-обертка Null в database/sql.

Заодно добавил маленький урок 1.9 со всякой всячиной (пока там блоки, группы и йота).

P.S. А еще на этом уроке вам предстоит победить Сквернолапа. Ну или Когтевика. В крайнем случае — Огнежора 😅
Баян [::]

Гошный баян (вообще он «полносрезное выражение» или full slice expression, но «баян» мне ближе) имеет такой синтаксис:

s[low : high : max]


Баян создает срез длиной high-low и емкостью max-low. Используется крайне редко.

Чтобы понять разницу между обычным срезом и баяном, рассмотрим пример.

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

arr := [5]int{1, 2, 3}
// [1 2 3 0 0]

s := arr[0:3]
// [1 2 3]

len(s) // 3
cap(s) // 5


Срез s указывает на массив arr. Его длина (length) равна 3, а емкость (capacity, размер массива под срезом) равна 5.

Добавление элемента в срез добавляет его в массив, поскольку емкость это позволяет:

s = append(s, 4)

fmt.Println(arr)
// [1 2 3 4 0]

fmt.Println(s)
// [1 2 3 4]


А вот что будет, если создать срез с помощью баяна:

arr := [5]int{1, 2, 3}
// [1 2 3 0 0]

s := arr[0:3:3]
// [1 2 3]

len(s) // 3
cap(s) // 3


Все как раньше, только емкость среза равна 3. Поэтому добавление элемента в срез приведет к созданию нового массива под срезом. Исходный массив arr не изменится:

s = append(s, 4)

fmt.Println(arr)
// [1 2 3 0 0]

fmt.Println(s)
// [1 2 3 4]


Такие дела. За разновидностями баянов приглашаю в комментарии.

🪗
Помоги Маше закрыть ворота

Друзья, тут такое дело. Маша закрывает ворота вот так:

type Gates struct {
// признак закрытия ворот
closed chan struct{}
}

func (g *Gates) Close() {
select {
case <-g.closed:
// игнорируем повторное закрытие
return
default:
// закрыть ворота
close(g.closed)
}
}


Безопасно ли одновременно вызывать Close() из нескольких горутин? Опрос следует.

P.S. Чур в комментариях ответ не спойлерить!
Безопасно ли одновременно вызывать Close() из нескольких горутин?
Final Results
36%
Безопасно
46%
Небезопасно
18%
Огнеопасно
Опасные ворота

Вот наши ворота:

type Gates struct {
// признак закрытия ворот
closed chan struct{}
}

func (g *Gates) Close() {
select {
case <-g.closed:
// игнорируем повторное закрытие
return
default:
// закрыть ворота
close(g.closed)
// освободить ресурсы
}
}


Метод Close — небезопасный. Если две горутины одновременно вызовут Close, обе могут провалиться в default-ветку селекта, обе попытаются закрыть канал, и второе закрытие приведет к панике.

Другими словами, здесь гонки на закрытии канала. Селект сам по себе не защищает от гонок. Sad but true.

Что с этим делать — традиционно в следующей заметке.
Безопасные ворота

Вот три способа безопасно закрыть ворота, один другого краше.

➊ sync.Mutex

Дубовый, но надежный способ. Защищаем изменение логического поля closed мьютексом:

type Gates struct {
// признак закрытия ворот
closed bool
// мьютекс для защиты closed
mu sync.Mutex
}

func (g *Gates) Close() {
g.mu.Lock()
defer g.mu.Unlock()
if g.closed {
// игнорируем повторное закрытие
return
}
// закрыть ворота
g.closed = true
// освободить ресурсы
}


➋ atomic.Bool

Compare-and-set на атомарном bool гарантирует, что только одна горутина сможет поменять значение с false на true:

type Gates struct {
// признак закрытия ворот
closed atomic.Bool
}

func (g *Gates) Close() {
if !g.closed.CompareAndSwap(false, true) {
// игнорируем повторное закрытие
return
}
// закрыли ворота,
// можно освободить ресурсы
}


➌ sync.Once

Once.Do гарантирует однократное выполнение в конкурентной среде, поэтому не приходится даже явно хранить состояние:

type Gates struct {
// гарантирует однократное выполнение
once sync.Once
}

func (g *Gates) Close() {
g.once.Do(func() {
// освободить ресурсы
})
}


Правда, такие ворота уже не получится открыть обратно, в отличие от предыдущих вариантов.

Кто ваш любимчик? Поделитесь в комментариях.
Git в примерах

Зашел я на Степик в новые курсы, а там «Основы Git». В связи этим вспомнил, что у меня тоже есть интерактивная книга / сборник рецептов по гиту, называется Git by example.

Удобный краткий формат с конкретными примерами, я сам туда постоянно подглядываю (особенно в раздел undo).

Загляните и вы, если не обзавелись еще черным поясом по гиту.

https://antonz.org/git-by-example

P.S. А вы же знаете, что по Go тоже такая есть?
Таймеры в Go 1.23

Тут прям детективная история приключилась. В Go есть таймер (тип Timer), а в нем — поле с каналом (Timer.C), в который таймер тикает спустя указанное время.

В коде стдлибы таймер создается так:

func NewTimer(d Duration) *Timer {
c := make(chan Time, 1)
t := &Timer{
C: c,
// ...
}
startTimer(&t.r)
return t
}


Такая реализация привела к проблемам с time.After и Reset, от которых многие страдали.

И вот в Go 1.23 решили это исправить, для чего сделали канал в таймере небуферизованным:

// As of Go 1.23, the channel is synchronous (unbuffered, capacity 0),
// eliminating the possibility of those stale values.
func NewTimer(d Duration) *Timer {
c := make(chan Time, 1)
t := (*Timer)(newTimer(when(d), 0, sendTime, c, syncTimer(c)))
t.C = c
return t
}


Вот только если вы посмотрите на фактический код, то канал-то остался буферизованным 😁

c := make(chan Time, 1)


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

Specifically, the timer channel has a 1-element buffer like it always has, but len(t.C) and cap(t.C) are special-cased to return 0 anyway, so user code cannot see what's in the buffer except with a receive.

Эту логику вкорячили прямо в реализацию канала (тип chan).

Что тут скажешь. Ну и дичь.
Курс «Многозадачность в Go»

Закончил курс по многозадачности! Вот какие темы в нем разобраны:

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

Если вы совсем не знакомы с многозадачностью, курс поможет освоить ее с нуля. А если уже прошли модуль «Многозадачность» на курсе «Go на практике» — детально разберетесь в гонках, синхронизации и пакетах sync и atomic.

Как обычно, все концепции разобраны на практических примерах и закреплены задачками с автоматической проверкой.

Курс непростой. Подойдет практикующим разработчикам с уверенным знанием основ Go.

https://stepik.org/a/133280

Цена скоро вырастет.

Всем go 💪
Ча-ча-ча

(несерьезное) В чате спросили, разбирается ли на курсе тема канала каналов (chan chan). Пользуясь случаем, хочу вам его представить.

Встречайте: ча, чача, и ча-ча-ча:

cha := make(chan int, 1)
chacha := make(chan chan int, 1)
chachacha := make(chan chan chan int, 1)

cha <- 1
chacha <- cha
chachacha <- chacha

fmt.Printf("%#v\n", chachacha)
// (chan chan chan int)(0x14000102180)


Если серьезно, то канал — это всего лишь указатель на структуру. Ничего особенного в нем нет.

Поэтому канал может содержать другие каналы точно так же, как указатели на строки или числа или любые другие значения. Нет принципиальной разницы.
Пустышки

Вряд ли вы об этом задумывались, но все эти конструкции в Go разрешены:

// ничего не делает
{}

// ничего не делает
switch {}

// бесконечный цикл
for {}

// блокирует горутину
select {}


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

Например:

func main() {
// тикающая горутина
go func() {
ticker := time.NewTicker(1 * time.Second)
for range ticker.C {
fmt.Print(".")
}
}()

// блокирует горутину main
select {}
}
Уникальные значения

Продолжаем разбирать нововведения в Go 1.23.

Новый пакет unique помогает сэкономить память, если обрабатываются неуникальные значения.

Рассмотрим пример. У нас есть генератор слов:

const nDistinct = 100
const wordLen = 40
generate := wordGen(nDistinct, wordLen)

fmt.Println(generate())
// nlfgseuif...
fmt.Println(generate())
// anixapidn...
fmt.Println(generate())
// czedtcbxa...


Размер словаря ограничен (100 слов), так что генерируемые значения будут часто повторяться.

Сгенерим 10000 слов и запишем их в срез строк:

words = make([]string, nWords)
for i := range nWords {
words[i] = generate()
}


Memory used: 622 KB


10К слов заняли 600 Кб в куче.

Попробуем другой подход. Используем unique.Handle, чтобы назначить дескриптор каждому уникальному слову, и будем хранить эти дескрипторы вместо самих слов:

words = make([]unique.Handle[string], nWords)
for i := range nWords {
words[i] = unique.Make(generate())
}


Memory used: 95 KB


100 Кб вместо 600 Кб — в 6 раз меньше памяти.

Функция Make создает уникальный дескриптор для значения любого comparable-типа. Она возвращает ссылку на «каноническую» копию значения в виде объекта Handle.

Два Handle равны только в том случае, если равны исходные значения. Сравнение двух Handle эффективно, потому что сводится к сравнению указателей.

Под капотом пакет unique ведет глобальный конкурентно-безопасный кеш всех добавленных значений, гарантируя их уникальность и повторное использование.

песочница
Скопировать каталог

Раньше, чтобы рекурсивно скопировать каталог со всем содержимым, вам пришлось бы написать 50 строк кода.

Теперь, благодаря os.CopyFS в Go 1.23, будет достаточно одной:

src := os.DirFS("/home/src")
dst := "/home/dst"
err := os.CopyFS(dst, src)


Вроде и мелочь, но весьма уместная.
Про печеньки

Давайте поговорим об http-куках. Если вы думаете, что это такие пары «ключ-значение», которые сервер и клиент гоняют туда-сюда в каждом запросе, то это верно лишь отчасти.

За годы развития веба куки обзавелись дополнительной атрибутикой. Вот как выглядит структура Cookie в Go 1.23:

Name и Value — ключ и значение.
Quoted — true, если значение передано в кавычках.
Path — разрешает куку на страницах, которые начинаются с указанного пути.
Domain — разрешает куку на указанном домене и всех его поддоменах.
Expires — дата окончания годности куки.
MaxAge — срок жизни куки в секундах.
Secure — разрешает куку только по HTTPS.
HttpOnly — закрывает доступ к куке из JavaScript.
SameSite — разрешает или запрещает куку при кросс-доменных запросах.
Partitioned — ограничивает доступ к third-party кукам.

Неслабо, да?

Начиная с версии Go 1.23, серверную куку можно распарсить из строки с помощью http.ParseSetCookie:

line := "session_id=abc123; SameSite=None; Secure; Partitioned; Path=/; Domain=example.com"
cookie, err := http.ParseSetCookie(line)


Браузерные куки тоже можно распарсить из строки, с помощью http.ParseCookie:

line := "session_id=abc123; dnt=1; lang=en; lang=de"
cookies, err := http.ParseCookie(line)


песочница
Канал завершения

Как вы знаете, с помощью done-канала горутина сигнализирует вызывающему, что закончила работать.

Есть пара вариантов реализации:

➊ Принимаем done-канал на входе

func worker1(done chan struct{}) {
// do work
time.Sleep(10 * time.Millisecond)
close(done)
}


done := make(chan struct{})
go worker1(done)
<-done


➋ Возвращаем done-канал из функции

func worker2() chan struct{} {
done := make(chan struct{})
go func() {
// do work
time.Sleep(10 * time.Millisecond)
close(done)
}()
return done
}


done := worker2()
<-done


песочница

Какой вариант вам больше по душе и почему?
Кто канал создал, тот и закрывает

В Go есть одна эвристика, которую лучше не нарушать без веских причин: кто канал создал, тот и закрывает.

Поэтому мне больше по душе такая реализация канала завершения:

func work() <-chan struct{} {
done := make(chan struct{})
go func() {
// do work
time.Sleep(10 * time.Millisecond)
close(done)
}()
return done
}


done := work()
<-done


Если нужно не просто сигнализировать о завершении, а возвращать значение — заменяем chan struct{} на нужный тип вроде chan int, и готово.

А если хотим возвращать еще и ошибку, то так:

type Result[T any] struct {
Value T
Err error
}

func work() <-chan Result[int] {
out := make(chan Result[int], 1)
go func() {
// do work
time.Sleep(10 * time.Millisecond)
out <- Result[int]{Value: 42}
close(out)
}()
return out
}


out := work()
result := <-out
fmt.Println(result)
// {42 <nil>}


песочница

Удобно!
Статический HTTP-сервер

Вы, наверно, слышали про встроенный в Python статический сервер:

python -m http.server 8080


На Go его можно реализовать в десять строчек кода (плюс импорты):

func main() {
port := "8000"
if len(os.Args) > 1 {
port = os.Args[1]
}

fs := http.FileServer(http.Dir("."))
http.Handle("/", fs)

log.Printf("Serving HTTP on port %s...\n", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}


И запускать вот так:

go run http.go 8080
Большая крыса Go

Прежде чем вы решите, что я сошел с ума — речь на самом деле о типе big.Rat.

В отличие от float64, он позволяет работать с обыкновенными дробями (a/b) без потери точности.

Например, из школьного курса математики мы знаем, что 1/10 + 2/10 = 3/10. Однако, float64 другого мнения:

x := 0.1
y := 0.2
fmt.Println(x + y)
// 0.30000000000000004


А вот big.Rat справляется с такими вычислениями без проблем:

x := big.NewRat(1, 10)
y := big.NewRat(2, 10)
z := new(big.Rat)
z.Add(x, y)
fmt.Println(z)
// 3/10


Если вдруг придется работать с обыкновенными дробями — имейте «крысу» в виду.
Go 1.23

Тут вышел Go 1.23, ну а мы с вами успели разобрать основные изменения заранее:

Итераторы
Таймеры
Уникальные значения
Скопировать каталог
Куки

Все вместе с интерактивными примерами:
https://antonz.org/go-1-23