Go. Структуры данных
Указатели:
unsafe/weak

Введение
Представьте, что вы живёте в современном доме с сигнализацией, умными замками и системой видеонаблюдения. Это удобно и безопасно — именно так работают обычные указатели в Go. Но иногда нужно открыть служебный люк в подвале, чтобы добраться до инженерных коммуникаций напрямую. Это небезопасно, требует специальных знаний, но иногда необходимо для решения специфических задач.
Пакет unsafe в Go — это как раз такой служебный люк. Он даёт вам прямой доступ к памяти, минуя обычные гарантии безопасности языка. А weak указатели — это специальный механизм, который позволяет ссылаться на объекты, не мешая сборщику мусора их удалить.
Давайте разберёмся, что это такое, когда это действительно нужно и как использовать эти инструменты безопасно.
Содержание
Пакет unsafe
Что такое unsafe
Пакет unsafe предоставляет операции, которые обходят гарантии безопасности типов в Go. Название говорит само за себя — использование этого пакета небезопасно (unsafe).
package main

import (
    "fmt"
    "unsafe"
)

func main() {
    x := 42
    
    // Обычный указатель
    ptr := &x
    fmt.Printf("Обычный указатель: %T\n", ptr) // *int
    
    // Небезопасный указатель
    unsafePtr := unsafe.Pointer(&x)
    fmt.Printf("Небезопасный указатель: %T\n", unsafePtr) // unsafe.Pointer
}
Почему это небезопасно?
Go автоматически следит за тем, чтобы вы не делали опасных операций с памятью: нельзя прочитать за границами массива, нельзя преобразовать *int в *string, нельзя работать с освобождённой памятью. Пакет unsafe снимает эти ограничения, перекладывая ответственность на вас.
Типы в пакете unsafe
Пакет unsafe предоставляет всего один тип и несколько функций:
  • unsafe.Pointer
    Универсальный указатель на любой тип. Это основной инструмент для обхода системы типов Go. Можно преобразовать любой указатель *T в unsafe.Pointer и обратно.
  • unsafe.Sizeof(x)
    Возвращает размер переменной x в байтах. Размер определяется на этапе компиляции и зависит от типа, а не от значения. Например, unsafe.Sizeof(int64(0)) всегда вернёт 8, независимо от значения.
  • unsafe.Alignof(x)
    Возвращает требование по выравниванию (alignment) для типа переменной x. Выравнивание показывает, по какому смещению в памяти должна начинаться переменная. Например, int64 обычно требует выравнивания по 8 байтам.
  • unsafe.Offsetof(x.f)
    Возвращает смещение поля f относительно начала структуры x. Это расстояние в байтах от начала структуры до начала конкретного поля.
Теперь посмотрим, как это работает на практике:
package main

import (
    "fmt"
    "unsafe"
)

type Data struct {
    A int32
    B int64
}

func main() {
    d := Data{A: 10, B: 20}
    
    // unsafe.Pointer - универсальный указатель на любой тип
    ptr := unsafe.Pointer(&d)
    
    // unsafe.Sizeof - размер типа в байтах
    size := unsafe.Sizeof(d)
    fmt.Printf("Размер структуры: %d байт\n", size)
    
    // unsafe.Alignof - требование по выравниванию
    align := unsafe.Alignof(d)
    fmt.Printf("Выравнивание структуры: %d байт\n", align)
    
    // unsafe.Offsetof - смещение поля в структуре
    offset := unsafe.Offsetof(d.B)
    fmt.Printf("Смещение поля B: %d байт\n", offset)
}
unsafe.Pointer
Базовое использование
unsafe.Pointer — это специальный тип указателя, который может указывать на значение любого типа.
package main

import (
    "fmt"
    "unsafe"
)

func main() {
    var x int64 = 42
    var f float64 = 3.14
    
    // unsafe.Pointer может хранить адрес любого типа
    var ptr unsafe.Pointer
    
    ptr = unsafe.Pointer(&x)
    fmt.Printf("Указатель на int64: %p\n", ptr)
    
    ptr = unsafe.Pointer(&f)
    fmt.Printf("Указатель на float64: %p\n", ptr)
}
Четыре разрешённых операции с unsafe.Pointer:
  • Преобразование *T в unsafe.Pointer
  • Преобразование unsafe.Pointer в *T
  • Преобразование unsafe.Pointer в uintptr
  • Преобразование uintptr в unsafe.Pointer
Правила безопасной работы
Нарушение правил работы с unsafe.Pointer может привести к непредсказуемому поведению программы, включая повреждение памяти и крэши.
Давайте рассмотрим основные правила на примерах:
package main

import (
    "fmt"
    "unsafe"
)

func main() {
    x := int64(42)
    
    // ✅ Правильно: преобразование туда и обратно в одном выражении
    ptr := (*int32)(unsafe.Pointer(&x))
    
    // ❌ Неправильно: хранение uintptr в переменной
    // Между этими двумя строками может сработать GC и переместить x!
    addr := uintptr(unsafe.Pointer(&x)) // опасно
    ptr2 := (*int64)(unsafe.Pointer(addr)) // может указывать не туда
    
    fmt.Println(*ptr2) // может привести к панике или неверным данным
}
Почему хранить uintptr опасно?
Когда вы преобразуете указатель в uintptr, вы получаете обычное число — адрес в памяти. Но Go не знает, что это число представляет адрес! Если между преобразованием в uintptr и обратно в unsafe.Pointer сработает сборщик мусора, объект может переместиться в памяти, и ваш адрес станет невалидным.
Преобразование типов через unsafe
Обход системы типов
Иногда нужно интерпретировать одни и те же байты в памяти как разные типы. Это полезно для низкоуровневых операций.
package main

import (
    "fmt"
    "unsafe"
)

func main() {
    // Преобразование float64 в биты (uint64)
    f := 3.14159
    bits := *(*uint64)(unsafe.Pointer(&f))
    
    fmt.Printf("float64: %f\n", f)
    fmt.Printf("Биты в hex: %#x\n", bits)
    fmt.Printf("Биты в bin: %064b\n", bits)
    
    // Обратное преобразование
    f2 := *(*float64)(unsafe.Pointer(&bits))
    fmt.Printf("Обратно в float64: %f\n", f2)
}
Что здесь происходит?
  • &f
    Берём адрес переменной типа float64
  • unsafe.Pointer(&f)
    Преобразуем в универсальный указатель
  • (*uint64)(...)
    Интерпретируем эти же байты как uint64
  • *...
    Разыменовываем, получая значение
Это как если бы вы прочитали одно и то же сообщение сначала как русский текст, а потом как набор чисел (ASCII кодов).
Практическое применение: оптимизация работы со строками.
Обычно преобразование между string и []byte требует копирования данных. С помощью unsafe можно избежать копирования:
package main

import (
    "fmt"
    "unsafe"
)

// Преобразование string в []byte без копирования
func stringToBytes(s string) []byte {
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

// Преобразование []byte в string без копирования
func bytesToString(b []byte) string {
    return unsafe.String(&b[0], len(b))
}

func main() {
    str := "Hello, World!"
    
    // Обычный способ - копирует данные
    bytes1 := []byte(str)
    fmt.Printf("С копированием: %s\n", bytes1)
    
    // Через unsafe - не копирует
    bytes2 := stringToBytes(str)
    fmt.Printf("Без копирования: %s\n", bytes2)
}
Строки в Go неизменяемы (immutable). Если вы получили []byte из строки через unsafe, не пытайтесь его изменить — это приведёт к падению программы.
Работа с внутренним представлением
Через unsafe можно получить доступ к внутреннему представлению встроенных типов. Например, строки в Go внутри представлены как структура с указателем на данные и длиной:
package main

import (
    "fmt"
    "unsafe"
)

// Внутреннее представление строки в Go
type StringHeader struct {
    Data uintptr // указатель на массив байт
    Len  int     // длина строки
}

func main() {
    str := "Hello, World!"
    
    // Получаем доступ к внутренней структуре строки
    header := (*StringHeader)(unsafe.Pointer(&str))
    
    fmt.Printf("Строка: %s\n", str)
    fmt.Printf("Адрес данных: %#x\n", header.Data)
    fmt.Printf("Длина: %d\n", header.Len)
    
    // Можно прочитать байты напрямую по адресу
    firstByte := *(*byte)(unsafe.Pointer(header.Data))
    fmt.Printf("Первый байт: %c (код: %d)\n", firstByte, firstByte)
}
Почему это может быть полезно?
Понимание внутреннего устройства позволяет делать оптимизации, недоступные через обычный API. Например, можно избежать копирования данных при конвертации между типами, если вы точно знаете, что делаете.
Арифметика указателей через uintptr
Доступ к полям структуры
В отличие от C, в Go нет прямой арифметики указателей. Но через unsafe можно добиться похожего эффекта:
package main

import (
    "fmt"
    "unsafe"
)

type Point struct {
    X int32
    Y int32
    Z int32
}

func main() {
    p := Point{X: 10, Y: 20, Z: 30}
    
    // Получаем указатель на начало структуры
    basePtr := uintptr(unsafe.Pointer(&p))
    
    // Вычисляем адреса полей через смещения
    xPtr := (*int32)(unsafe.Pointer(basePtr + 0))  // X на смещении 0
    yPtr := (*int32)(unsafe.Pointer(basePtr + 4))  // Y на смещении 4
    zPtr := (*int32)(unsafe.Pointer(basePtr + 8))  // Z на смещении 8
    
    fmt.Printf("X: %d, Y: %d, Z: %d\n", *xPtr, *yPtr, *zPtr)
    
    // Изменяем через указатели
    *yPtr = 100
    fmt.Printf("После изменения Y: %+v\n", p)
}
Преобразование unsafe.Pointer → uintptr → арифметика → unsafe.Pointer должно происходить в одном выражении, без промежуточного хранения uintptr в переменной.
Вычисление смещений
Вместо ручного вычисления смещений лучше использовать unsafe.Offsetof. Это особенно важно из-за выравнивания (alignment):
package main

import (
    "fmt"
    "unsafe"
)

type Data struct {
    A int8   // 1 байт
    B int64  // 8 байт
    C int32  // 4 байта
}

func main() {
    var d Data
    
    fmt.Printf("Размер Data: %d байт\n", unsafe.Sizeof(d))
    fmt.Printf("Смещение A: %d\n", unsafe.Offsetof(d.A))
    fmt.Printf("Смещение B: %d\n", unsafe.Offsetof(d.B))
    fmt.Printf("Смещение C: %d\n", unsafe.Offsetof(d.C))
    
    // Доступ к полю B через смещение
    d.B = 42
    bPtr := (*int64)(unsafe.Pointer(
        uintptr(unsafe.Pointer(&d)) + unsafe.Offsetof(d.B),
    ))
    
    fmt.Printf("Значение B через указатель: %d\n", *bPtr)
}
Вывод:
Размер Data: 16 байт
Смещение A: 0
Смещение B: 8
Смещение C: 16
Видите разрыв? Поле A занимает 1 байт (offset=0), но поле B начинается с offset=8, а не с offset=1. Это происходит из-за выравнивания.
Что такое выравнивание (alignment)?
Процессор читает память блоками определённого размера (например, по 8 байт за раз). Если int64 (8 байт) начинается с адреса 0, 8, 16, 24 и т. д., процессор прочитает его за одну операцию. Но если int64 начинается, например, с адреса 1, процессору придётся сделать две операции чтения и склеить результат — это медленнее.
Поэтому компилятор добавляет «пустые» байты (padding) между полями A и B, чтобы B начиналось с адреса, кратного 8.
Weak указатели
Что такое weak указатели
Обычные указатели в Go — это «сильные» ссылки: пока на объект есть хотя бы один указатель, сборщик мусора не удалит его. Иногда это создаёт проблемы.
Представьте систему кэширования: вы хотите хранить ссылки на объекты, но не хотите мешать их удалению, если они больше не нужны основной программе. Для этого нужны «слабые» ссылки — weak указатели.
Пакет weak доступен начиная с Go 1.24. Это экспериментальная возможность стандартной библиотеки.
API пакета weak
Пакет weak предоставляет минимальный и понятный API:
  • weak.Pointer[T]
    Тип слабой ссылки. Это generic-тип, который параметризуется типом указателя. Например, weak.Pointer[*MyStruct] — это слабая ссылка на *MyStruct.
  • weak.Make[T](ptr T) weak.Pointer[T]
    Создаёт слабую ссылку на объект. Принимает указатель на объект и возвращает слабую ссылку на него.
  • (w weak.Pointer[T]) Value() T
    Возвращает указатель на объект, если объект всё ещё существует в памяти. Если объект был удалён сборщиком мусора, возвращает nil.
  • unsafe.Offsetof(x.f)
    Возвращает смещение поля f относительно начала структуры x. Это расстояние в байтах от начала структуры до начала конкретного поля.
Создание и использование
Давайте посмотрим, как это работает на практике:
package main

import (
    "fmt"
    "runtime"
    "weak"
)

type Resource struct {
    ID   int
    Data string
}

func main() {
    // Создаём объект
    res := &Resource{ID: 1, Data: "Important data"}
    
    // Создаём слабую ссылку
    weakRef := weak.Make(res)
    
    fmt.Println("Объект создан")
    fmt.Printf("Через сильную ссылку: %+v\n", res)
    fmt.Printf("Через слабую ссылку: %+v\n", weakRef.Value())
    
    // Удаляем сильную ссылку
    res = nil
    
    // Принудительно запускаем GC
    runtime.GC()
    
    // Слабая ссылка больше не работает
    if weakRef.Value() == nil {
        fmt.Println("Объект был удалён сборщиком мусора")
    }
}
Что здесь происходит?
  • weak.Make(res) — создаём слабую ссылку типа weak.Pointer[*Resource]
  • Пока существует сильная ссылка resweakRef.Value() возвращает указатель на объект
  • После res = nil на объект не остаётся сильных ссылок
  • runtime.GC() удаляет объект из памяти
  • Теперь weakRef. Value() возвращает nil — объект больше не существует
Важный момент: слабая ссылка не мешает сборщику мусора удалить объект. Это ключевое отличие от обычных указателей.
Практическое применение: кэширование
Основной сценарий использования weak указателей — это кэши. Кэш может хранить слабые ссылки на объекты:
type Cache struct {
    items map[string]weak.Pointer[*CachedItem]
}
Если объект больше не используется основной программой, GC удалит его из памяти, даже если он есть в кэше. Это предотвращает утечки памяти: кэш не будет «держать» объекты, которые никому не нужны. При попытке получить элемент из кэша проверяете Value() — если вернулся nil, объект уже удалён.
Заключение
Теперь вы знаете о самых продвинутых возможностях работы с указателями в Go.
Ключевые моменты:
  • Пакет unsafe
    Даёт прямой доступ к памяти, обходя гарантии безопасности типов
  • unsafe.Pointer
    Универсальный указатель, который может указывать на любой тип
  • uintptr
    Позволяет делать арифметику указателей, но его опасно хранить в переменных из-за GC
  • unsafe.Sizeofunsafe.Alignof,
    unsafe.Offsetof
    Предоставляют информацию о размерах и расположении данных
  • Использовать unsafe
    Стоит только для низкоуровневых библиотек, взаимодействия с C и критичных по производительности участков
  • weak указатели (Go 1.24+)
    Позволяют ссылаться на объекты, не мешая GC их удалить
  • Слабые ссылки
    Полезны для кэшей, где не нужно «держать» объекты в памяти
Когда использовать unsafe:
  • Взаимодействие с C кодом через cgo (будет в отдельной лекции)
  • Реализация низкоуровневых библиотек (работа с памятью, сетевые протоколы)
  • Критичные по производительности участки, где копирование данных недопустимо
  • Обычная бизнес-логика приложения
  • Когда можно решить задачу безопасными средствами Go
Когда использовать weak:
  • Кэши, которые не должны мешать удалению объектов
  • Ситуации, где нужна «необязательная» ссылка на объект
  • Основное хранение данных (используйте обычные указатели)
  • Когда нужна гарантия, что объект не будет удалён
Что дальше?
Теперь вы готовы изучать структуры данных:
  • Массивы: объявление, инициализация, базовые операции
  • Массивы: как работает с памятью
  • Слайсы: создание, срезы, встроенные функции (append, copy)
Вы освоили все тонкости работы с указателями в Go — от базовых концепций до самых продвинутых техник. Используйте эти знания с осторожностью, и они помогут вам решать сложные задачи эффективно!