Один из лучших способов узнать что-то новое - регулярно записывать то, что узнали. В течение последнего года я делал это для Go. Вот несколько моих любимых, но малоизвестных фактов об этом языке программирования.
Прямой диапазон целых чисел
Начиная с Go 1.22, мы можем перебирать целые числа таким способом:
for i := range 10 {
fmt.Println(i + 1) // 1, 2, 3 ... 10
}
Переименование пакетов
LSP (language server protocol - протокол языкового сервера) Go можно использовать для переименования пакетов, а не только обычных переменных. Новое название пакета будет обновлено во всех ссылках. Даже директория будет переименована.
Ограничение сигнатур обобщенных функций
Мы можем использовать оператор ~ для ограничения сигнатуры общего (generic) типа. Например, для типизированных констант это можно сделать так:
package main
import (
"fmt"
)
type someConstantType string
const someConstant someConstantType = "foo" // Базовый тип - string
func main() {
msg := buildMessage(someConstant)
fmt.Println(msg)
}
func buildMessage[T ~string](value T) string { // Принимает любое значение, базовым типом которого является string
return fmt.Sprintf("Базовое строковое значение: '%s'", value)
}
Это может быть действительно полезным, когда конкретный тип является типизированной константой. Это похоже на enum в других языках программирования.
Интерполяция строк на основе индексов
В Go можно выполнять интерполяцию строк на основе индексов:
package main
import (
"fmt"
)
func main() {
fmt.Printf("%[1]s %[1]s %[2]s %[2]s %[3]s", "one", "two", "three") // "one one two two three"
}
Это может пригодиться, когда требуется интерполировать одно и то же значение несколько раз, и мы хотим уменьшить дублирование и упростить отслеживание интерполяции.
Функция time.After
Функция time.After создает канал, в который через x секунд отправляется сообщение. В сочетании с оператором SELECT, это может быть простым способом установить крайний срок выполнения другой процедуры.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string, 1)
go func() {
time.Sleep(2 * time.Second)
ch <- "result"
}()
select {
case res := <-ch:
fmt.Println("Получено:", res)
case <-time.After(1 * time.Second):
fmt.Println("Таймаут: результат не получен вовремя")
}
}
Пакет embed
Пакет embed позволяет встраивать файлы прямо в бинарник Go. Нет необходимости читать их с диска по время выполнения.
Мы можем внедрить HTML, JS, даже изображения. Компиляция ресурсов прямо в двоичный файл может существенно упростить деплой.
Использование len() со строками и особенности UTF-8
Встроенная функция len не возвращает количество символов в строке, она возвращает количество байтов, поскольку нельзя предполагать, что строковые литералы содержат один байт на символ (отсюда и руны (runes)).
package main
import (
"fmt"
)
func main() {
s := "Hello 世界"
fmt.Println(len(s)) // Prints 11!
for i := 0; i < len(s); i++ {
fmt.Printf("index %d: value %c\n", i, s[i]) // Перебирает байты. Работает не так, как ожидается...
/*
index 0: value H
index 1: value e
index 2: value l
index 3: value l
index 4: value o
index 5: value
index 6: value ä
index 7: value ¸
index 8: value <96>
index 9: value ç
index 10: value <95>
index 11: value <8c>
*/
}
for i, r := range s { // range перебирает руны.
fmt.Printf("byte %d: %s\n", i, string(r))
/*
byte 0: H
byte 1: e
byte 2: l
byte 3: l
byte 4: o
byte 5:
byte 6: 世
byte 9: 界
*/
}
}
Руны соответствуют кодовым точкам (code points) в Go, длина которых составляет от 1 до 4 байт. Также следует отметить, что, хотя строковые литералы кодируются в UTF-8, они представляют собой лишь произвольные наборы байтов, а это значит, что технически могут существовать строки, содержащие невалидные данные. В этом случае, Go заменяет невалидные данные UTF-8 символами замены (replacement characters).
package main
import (
"fmt"
)
func main() {
invalidBytes := []byte{0x48, 0x65, 0x6C, 0x6C, 0x6F, 0xFF} // "Hello" + невалидный байт
s := string(invalidBytes)
for _, r := range s {
fmt.Printf("%c ", r) // H e l l o �
}
}
Нулевые интерфейсы
Как думаете, что будет выведено на экран?
package main
import "fmt"
type Animal interface {
Speak()
}
type Dog struct{}
func (d *Dog) Speak() {}
func main() {
var d *Dog = nil
var a Animal = d
fmt.Println(a == nil)
}
Ответ: false.
Несмотря на то, что значение равно nil, тип переменной представляет собой ненулевой интерфейс.
Go "упаковывает" (boxes) это значение в интерфейс, который не равен nil. Это может создать серьезные проблемы, если вы возвращаете интерфейсы из функций. После возврата значения, даже если оно равно nil, если возвращаемое значение указано как интерфейс, проверка на nil перестанет работать должным образом. Например:
package main
import "fmt"
type Car interface {
Honk()
}
type Honda struct{}
func (h *Honda) Honk() {
fmt.Println("Бип!")
}
func giveCar() Car {
var h *Honda // h равняется nil
return h // nil *Honda оборачивается в интерфейс Car
}
func main() {
c := giveCar()
if c == nil {
fmt.Println("Это никогда не выведется на экран")
}
}
В этом примере c - это упакованное значение nil, поэтому проверка c == nil всегда возвращает false.
Вызов методов для нулевых значений
В Go допустимо вызывать методы структуры, равной nil:
package main
import "fmt"
type Foo struct {
Val string
}
func (f *Foo) Hello() {
fmt.Println("привет из получателя нулевого указателя")
}
func main() {
var f *Foo = nil
f.Hello() // Это работает
fmt.Println(f.Val) // Это не работает
}
Разумеется, попытка получить доступ к свойству такой структуры завершится паникой.
Ссылки на переменные при работе с диапазонами карт
При обновлении карты (map) внутри цикла нет гарантии, что обновление будет выполнено в течение этой итерации.
Единственная гарантия заключается в том, что к моменту завершения цикла карта будет содержать наши обновления. Конечно, мы, вероятно, никогда не захотим так делать (это просто плохой код), но тем не менее, это интересно понять.
func main() {
m := map[int]int{1: 1, 2: 2, 3: 3}
for key, value := range m {
fmt.Printf("%d = %d\n", key, value)
if key == 1 {
for i := 10; i < 20; i++ {
m[i] = i * 10 // Добавляем множество сущностей
}
}
}
}
В приведенном коде значения, которые мы добавляем внутри цикла, могут быть, а могут и не быть выведены на экран.
Это связано с тем, как Go управляет объектами под капотом. В Go, когда мы добавляем новый ключ/значение, язык хеширует этот ключ и помещает его в хранилище (storage bucket). Если итерация Go уже "проверила" это хранилище в объекте, новая запись не будет обработана в цикле.
Это отличается, например, от Python, где используется "стабильный порядок вставки" (stable insertion order), гарантирующий отсутствие подобных ошибок. В Go это делается из соображений скорости.
Возврат пользовательских ошибок
В Go часто бывает полезно возвращать неожиданные (unexpected) ошибки в виде типизированных (typed) ошибок для предоставления дополнительного контекста для отладки или других целей. Определение их в виде типов позволяет прикреплять структурированные данные через errors.As и реализовывать собственную логику, при этом удовлетворяя интерфейсу обработки ошибок.
package main
import (
"errors"
"fmt"
)
type MyError struct {
Message string
Code int
}
func (e *MyError) Error() string {
return fmt.Sprintf("Ошибка %d: %s", e.Code, e.Message)
}
func someFunction() error {
return &MyError{Message: "что-то пошло не так", Code: 404}
}
func main() {
err := someFunction()
if err != nil {
var myErr *MyError
if errors.As(err, &myErr) {
fmt.Printf("Обработанная типизированная ошибка: %s\n", myErr.Error())
} else {
fmt.Printf("Необработанная ошибка: %s\n", err)
}
}
}
Контекстно-зависимые функции Go
В контекстно-зависимой функции всегда следует выбирать (select) значение как в контексте, так и в канале. Это связано с тем, что мы можем напрасно ждать завершения операции после отмены контекста.
Например, в коде ниже мы либо отправляем в канал сообщение "операция завершена" по завершении time.After, либо выходим досрочно при отмене контекста.
Поскольку наша функция sendSignal обнаруживает отмену контекста, мы можем завершить работу досрочно.
package main
import (
"context"
"fmt"
"time"
)
func sendSignal(ctx context.Context, ch chan<- string) {
select {
case <-time.After(5 * time.Second): // Фиктивная операция занимает 5 секунд...
ch <- "операция завершена"
case <-ctx.Done(): // Мы можем досрочно выйти при отмене контекста. Без этого отмена будет игнорироваться
ch <- "операция отменена"
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) // Таймаут в 1 секунду
defer cancel()
ch := make(chan string)
go sendSignal(ctx, ch)
msg := <-ch
close(ch)
fmt.Println(msg)
}
Это хороший пример того, почему необходимо выбирать контекст при работе с каналами — без этого функция будет ждать все 5 секунд, даже если контекст будет отменен через 1 секунду.
Дополнительный факт: контекст Go аннулируется после завершения работы обработчика HTTP и полной отправки ответа, даже в случае успешного ответа. Именно поэтому необходимо быть осторожным с распространением контекста (context propagation). Например, передача контекста из HTTP-запроса издателю событий (event publisher) может привести к возникновению состояния гонки (race conditions), поскольку быстрый HTTP-ответ может аннулировать переданный контекст и предотвратить публикацию события.
Пустые структуры
Часто можно увидеть, как разработчики Go используют пустые структуры. Почему они используют их, а не, например, логическое значение?
Все дело в том, что пустые структуры в Go ничего не весят, т.е. занимают 0 байт. Среда выполнения Go обрабатывает все выделения памяти нулевого размера (zero-sized allocations), включая пустые структуры, возвращая один специальный адрес памяти, который не занимает места.
Вот почему пустые структуры часто используют для передачи сигналов по каналам, когда нет необходимости отправлять какие-либо данные. Логические значения, в свою очередь, все равно занимают некоторое пространство.
Компилятор Go и ключевое слово range
Компилятор Go преобразует ключевое слово range в простые циклы перед дальнейшей компиляцией. Реализация различается в зависимости от того, что именно преобразуется: карта, срез или последовательность (sequence) из пакета iter.
Интересно, что в случае с iter, вызов break внутри диапазона фактически преобразуется в значение false, которое обычно возвращается yield для прерывания цикла.
Удовлетворение скрытого интерфейса
Удовлетворение скрытого интерфейса (hidden interface satisfaction) вызывает проблемы с встраиванием структур.
Допустим, мы встраиваем структуру time.Time в поле JSON-ответа и пытаемся сериализовать (marshal) этот JSON.
При встраивании структур, мы неявно повышаем статус любых методов, которые они содержат. Поскольку time.Time содержит метод MarshalJSON, компилятор будет использовать его, вместо стандартного метода сериализации.
package main
import (
"encoding/json"
"fmt"
"time"
)
type Event struct {
Name string `json:"name"`
time.Time `json:"timestamp"`
}
func main() {
event := Event{
Name: "Launch",
Time: time.Date(2023, time.November, 10, 23, 0, 0, 0, time.UTC),
}
jsonData, _ := json.Marshal(event)
fmt.Println(string(jsonData)) // "2023-11-10T23:00:00Z" странно, правда?
}
В этом примере структура Event содержит встроенное поле time.Time. При преобразовании этой структуры в JSON, автоматически вызывается метод MarshalJSON типа time.Time для форматирования всего результата. Как следствие, мы получаем не то, что ожидали.
Это справедливо и для других методов, что может приводить к странным и трудноуловимым ошибкам. Будьте очень осторожны с внедрением структур.
Тег "-" для JSON
Тег "-" позволяет пропускать поля при сериализации структуры в JSON. Это удобно, если у нас есть, например, конфиденциальные данные в поле, и мы хотим исключить это поле из ответа API.
package main
import (
"encoding/json"
"fmt"
)
type User struct {
Name string `json:"name"`
Password string `json:"-"`
Email string `json:"email"`
}
func main() {
user := User{
Name: "John Doe",
Password: "supersecret",
Email: "john.doe@example.com",
}
data, err := json.Marshal(user)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(data)) // Только {"name":"John Doe","email":"john.doe@example.com"}, без пароля
}
Это надуманный пример, вы, очевидно, не стали бы так небрежно обращаться с паролем. Тем не менее, это полезная возможность.
Сравнение времени
При преобразовании Time в строку, сериализатор (stringer) автоматически добавляет информацию о часовом поясе, поэтому сравнение строк не работает. Вместо этого для сравнения времени следует использовать метод Equal: "Метод Equal сообщает, представляют ли t и u один и тот же момент времени (time instant). Два значения времени могут быть равны, даже если они "находятся" в разных локациях".
package main
import (
"fmt"
"time"
)
func main() {
t1 := time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC)
t2 := t1.In(time.FixedZone("EST", -5*3600)) // Добавляет информацию о временной зоне
fmt.Println(t1.String() == t2.String()) // false
fmt.Println(t1.Equal(t2)) // true
}
Это часто встречается в тестировании и непрерывной интеграции.
Функция wg.Go
В Go 1.25 появилась функция waitgroup.Go, облегчающая добавления горутин в waitgroup (группу ожидания). Она заменяет использование ключевого слова go и выглядит следующим образом:
wg.Go(func() {
// Код горутины
})
wg.Go - это всего лишь обертка (синтаксический сахар):
func (wg *WaitGroup) Go(f func()) {
wg.Add(1)
go func() {
defer wg.Done()
f()
}()
}
Надеюсь, вы узнали что-то новое и не зря потратили время. Happy coding!