Перейти к основному содержимому

5.10. Типы данных и переменные

Разработчику Архитектору

Типы данных и переменные

Язык Go, разработанный в Google и представленный в 2009 году, относится к статически типизированным языкам программирования. Это означает, что каждое выражение и переменная в программе обладают строго определённым типом, который не может быть изменён в ходе выполнения программы. Такой подход существенно повышает надёжность кода и упрощает анализ программ как на этапе компиляции, так и в процессе сопровождения. В отличие от динамически типизированных языков, где типы могут меняться произвольно, Go требует от разработчика чёткого понимания и явного указания типов — или предоставляет возможность их неявного вывода, но при этом строго соблюдая правила совместимости.

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

Рассмотрим эти понятия подробно и последовательно.

Базовые типы данных

Базовые, или примитивные, типы данных в Go реализованы непосредственно в языке и не требуют дополнительного определения. Они делятся на три подгруппы: числовые, строковые и логический тип.

Числовые типы

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

Целочисленные типы представлены знаковыми (int8, int16, int32, int64, а также int) и беззнаковыми (uint8, uint16, uint32, uint64, uintptr, а также uint) вариантами. Приставка u означает unsigned, то есть отсутствие знакового бита — такие типы могут хранить только неотрицательные значения. Тип int и uint — это архитектурно-зависимые типы: на 32-битных системах они соответствуют int32 и uint32, а на 64-битных — int64 и uint64. Это сделано для максимальной эффективности при работе с индексами, смещениями, размерами и другими системными величинами. Важно понимать, что int и int32 — это разные типы, даже если на конкретной платформе их размер совпадает; Go не допускает неявного приведения между ними без явного преобразования.

Тип uintptr предназначен для хранения числовых представлений адресов памяти и используется преимущественно в системном программировании и при взаимодействии с низкоуровневыми API через пакет unsafe. Его размер также зависит от архитектуры, и он не должен использоваться для арифметических вычислений вне контекста работы с указателями.

Для удобства чтения и написания кода Go предоставляет псевдонимы byte и rune. Тип byte — это алиас для uint8, и он используется в основном при работе с байтовыми последовательностями, например, при обработке сетевых пакетов или бинарных данных. Тип rune — это алиас для int32, и он применяется для представления отдельных символов в кодировке UTF-8, то есть для хранения кодовых точек Unicode. Это особенно важно, поскольку строка в Go — это неизменяемая последовательность байтов в UTF-8, и прямой доступ к символам требует декодирования.

Типы с плавающей точкой представлены float32 и float64. Оба соответствуют стандарту IEEE 754: float32 обеспечивает точность около 6–7 десятичных цифр, float64 — около 15–16. По умолчанию, при записи литерала вида 3.14 без суффикса, компилятор присваивает ему тип float64. При необходимости использования float32 требуется явное приведение или суффикс f, например 3.14f.

Комплексные типы — complex64 и complex128 — состоят из действительной и мнимой частей, представленных соответственно float32 и float64. Литерал комплексного числа записывается как 1+2i, где i обозначает мнимую единицу. Эти типы используются редко и, как правило, в узкоспециализированных приложениях: обработка сигналов, физическое моделирование, квантовые вычисления на уровне прототипов. В повседневной разработке — веб-серверах, CLI-утилитах, микросервисах — они практически не встречаются.

Строковый тип

Тип string в Go — это неизменяемая последовательность байтов, интерпретируемых как текст в кодировке UTF-8. Это ключевая особенность: строка является именно последовательностью байтов, и длина строки в байтах может отличаться от количества видимых символов (из-за многобайтового представления Unicode). Например, строка "café" содержит 5 байтов, но только 4 Unicode-символа, поскольку é кодируется двумя байтами в UTF-8.

Операции над строками выполняются по значению, а не по ссылке: при присваивании или передаче строки в функцию происходит копирование заголовка строки (указатель на данные и длина), но не самих данных — механизм copy-on-write в Go отсутствует, поэтому копирование заголовка безопасно и эффективно. Сам буфер данных после создания строки изменить нельзя — любая операция, изменяющая содержимое (например, конкатенация), создаёт новую строку.

Логический тип

Тип bool принимает два значения: true и false. В Go нет неявного приведения между логическими значениями и числами — выражение вида if 1 { … } недопустимо. Условия в управляющих конструкциях (if, for, switch) должны иметь тип bool. Это исключает распространённые ошибки, связанные с проверкой ненулевых указателей или чисел как истинности, и заставляет разработчика быть явным в своих намерениях.


Составные типы данных

В отличие от базовых типов, составные типы в Go строятся на основе других типов и формируют более сложные структуры данных. Они реализуются в языке как встроенные, но не примитивные: их поведение частично определяется стандартной библиотекой и соглашениями времени выполнения. Составные типы включают массивы, срезы, карты, структуры, указатели, функции, интерфейсы и каналы. Каждый из них решает определённую задачу и обладает собственными гарантиями, ограничениями и производственными характеристиками.

Массивы (array)

Массив в Go — это упорядоченная последовательность фиксированной длины, все элементы которой имеют один и тот же тип. Объявление массива включает в себя указание количества элементов в квадратных скобках и типа элементов: [4]int, [100]string, [3][2]float64 — последнее представляет собой двумерный массив, фиксированный по обеим размерностям. Важнейшая особенность: размер массива является частью его типа. Это означает, что [3]int и [4]int — это совершенно разные типы, несовместимые друг с другом, даже если логически они представляют одно и то же понятие — «набор целых чисел». Невозможно присвоить значение одного массива другому, если их размеры различаются, и невозможно передать массив в функцию, ожидающую массив другого размера, без явного копирования поэлементно.

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

Срезы (slice)

Срез — это абстракция над массивом, обеспечивающая динамический доступ к его подпоследовательности. В отличие от массива, срез не фиксирован по длине и может изменять размер в рамках ёмкости буфера, на который он ссылается. Внутренне срез представлен структурой из трёх полей: указатель на базовый массив, длина (len) и ёмкость (cap). Длина — это количество элементов, доступных через срез; ёмкость — это количество элементов от начала среза до конца базового массива.

Срезы создаются несколькими способами: через литерал ([]int{1, 2, 3}), через встроенную функцию make (make([]int, 5, 10) — длина 5, ёмкость 10), или через операцию «срезания» (arr[1:4]). При изменении элемента через срез происходит модификация базового массива; при добавлении элементов с помощью append, если текущая ёмкость исчерпана, выделяется новый, больший массив, и данные копируются в него — при этом исходный срез продолжает указывать на старый буфер, а результат append возвращает новый срез с обновлёнными полями. Это ключевой момент: append возвращает новый экземпляр. Если результат не сохранить, изменения потеряются.

Срезы — самый часто используемый контейнер для последовательностей в Go. Они эффективны, удобны и интегрированы в стандартную библиотеку на всех уровнях — от обработки JSON до работы с сетевыми буферами.

Карты (map)

Карта — это неупорядоченная коллекция пар «ключ-значение», где ключи должны быть сравнимыми (то есть относиться к типу, допускающему операцию ==). К таким типам относятся все базовые типы, кроме slice, map и function, а также структуры и массивы фиксированного размера, если все их поля сравнимы. Ключи nil допустимы только для указателей, интерфейсов, каналов и функций — при этом nil-значение считается равным самому себе.

Внутренне карта в Go реализована как хэш-таблица с открытой адресацией и динамическим масштабированием. При создании через make(map[string]int) или литерал (map[string]int{"a": 1}) выделяется структура времени выполнения, содержащая метаданные и указатели на бакеты. Карта не является потокобезопасной: одновременный доступ из нескольких горутин без внешней синхронизации приводит к неопределённому поведению, включая панику времени выполнения. Для конкурентного использования предусмотрены обёртки в пакете sync, такие как sync.Map, хотя в большинстве случаев предпочтительнее использовать обычные карты с мьютексами.

Операция обращения по ключу (m[k]) возвращает значение и флаг наличия (value, ok := m[k]), что позволяет отличить отсутствие ключа от случая, когда ключ присутствует, но значение равно нулевому для его типа (например, 0 для int). Удаление ключа выполняется через встроенную функцию delete(m, k); оно не возвращает ошибку и безопасно даже для несуществующих ключей.

Структуры (struct)

Структура — это составной тип, объединяющий набор именованных полей, каждое из которых имеет собственный тип. Структуры могут содержать поля любых типов, включая другие структуры, срезы, карты и указатели. Объявление структуры происходит с помощью ключевого слова type и struct:

type Point struct {
X, Y float64
}

Поля могут быть экспортируемыми (начинаются с заглавной буквы) или неэкспортируемыми (строчная буква), что определяет их видимость за пределами пакета. Go не поддерживает наследование в классическом смысле, но предоставляет механизм встраивания (embedding): поле структуры может быть объявлено без имени, и его методы и поля становятся доступны как будто объявлены в самой структуре. Это композиция с автоматическим делегированием, и она строго ограничена на этапе компиляции — коллизии имён разрешаются явно.

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

Указатели (pointer)

Указатель в Go — это переменная, хранящая адрес памяти другого значения. В отличие от C, указатели в Go не поддерживают арифметику: нельзя прибавлять к указателю целое число или вычитать один указатель из другого. Это ограничение повышает безопасность и упрощает анализ памяти сборщиком мусора.

Указатель объявляется с помощью звёздочки: *int, *Point. Оператор & возвращает адрес переменной, оператор * — разыменовывает указатель. Указатель может иметь значение nil, что означает отсутствие ссылки на какое-либо значение. Обращение к полю или вызов метода через nil-указатель приводит к панике времени выполнения.

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

Функции, интерфейсы и каналы

Функция в Go — это полноценный тип первого класса: её можно присваивать переменным, передавать в другие функции, возвращать из функций. Тип функции определяется списком входных и выходных параметров: func(int, string) bool. Замыкания поддерживаются полностью: функция, захватывающая переменные из внешней области видимости, сохраняет на них ссылки, и их время жизни продлевается до тех пор, пока существует замыкание.

Интерфейс — это набор сигнатур методов. Любой тип, реализующий все методы интерфейса, автоматически удовлетворяет ему — явного объявления наследования не требуется («duck typing» на уровне компилятора). Интерфейсный тип может хранить значение любого совместимого типа и позволяет писать гибкий и тестируемый код. Пустой интерфейс interface{} (в Go 1.18+ рекомендуется any) может хранить значение любого типа, но его использование требует аккуратности из-за необходимости приведения типов.

Канал (chan) — это средство синхронизации и обмена данными между горутинами. Он типизирован по передаваемому значению: chan int, chan *Request. Канал может быть буферизованным или небуферизованным (синхронным). Операции отправки и приёма блокируют вызывающую горутину до тех пор, пока соответствующая сторона не будет готова. Закрытие канала (close(ch)) сигнализирует о завершении передачи и позволяет получателям корректно завершить циклы.


Переменные

Переменная в Go — это именованная область памяти, связанная с определённым типом и текущим значением. Каждая переменная имеет время жизни (duration) — период от её создания до освобождения памяти — и область видимости (scope) — множество точек программы, где имя переменной разрешается в эту конкретную сущность.

Объявление переменной может быть выполнено тремя способами.

Во-первых, с помощью ключевого слова var, за которым следует идентификатор, тип и, опционально, инициализирующее выражение:

var x int
var y, z float64 = 1.5, 2.5
var name = "Go" // тип выводится из инициализатора

Если инициализатор отсутствует, переменная получает нулевое значение своего типа: 0 для чисел, false для bool, "" для string, nil для указателей, карт, срезов, функций, интерфейсов и каналов. Это гарантирует, что все переменные инициализированы, и исключает доступ к «мусору» в памяти.

Во-вторых, внутри функций доступна краткая форма объявления с использованием оператора :=. Этот синтаксис объединяет объявление и инициализацию, при этом тип выводится неявно из правой части:

count := 42
pi, name := 3.14, "universe"

Важно: := может вводить новые переменные, но также может переиспользовать уже существующие при множественном присваивании, если хотя бы одна из переменных в левой части новая. Например:

x := 1
x, y := 2, 3 // x переприсваивается, y — новая переменная

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

В-третьих, переменные могут быть объявлены как параметры функции или результаты возврата:

func f(a int, b string) (result float64, err error) {
// a, b, result, err — все являются переменными в теле функции
result = float64(a)
return
}

В данном случае result и err — именованные возвращаемые значения, инициализированные нулевыми значениями при входе в функцию.

Go строго контролирует использование переменных: объявление без последующего использования приводит к ошибке компиляции. Это требование помогает избегать опечаток, «мёртвого» кода и случайных теневых переменных.

Корректное именование переменных в Go следует устоявшимся конвенциям: короткие имена (i, err, buf) допустимы в узких областях видимости (циклы, короткие функции), тогда как в глобальном контексте или при долгоживущих сущностях используются содержательные имена (userProfile, databaseConnectionTimeout). Экспортируемые имена всегда начинаются с заглавной буквы.


Константы

Константа в Go — это именованное значение, которое вычисляется исключительно на этапе компиляции и не имеет представления в памяти во время выполнения программы. Это принципиальное отличие от переменных, и оно определяет как семантику, так и производственные характеристики констант. В Go константы не являются «неизменяемыми переменными» — они находятся в другой категории сущностей, более близкой к литералам, чем к ячейкам памяти.

Объявление константы осуществляется с помощью ключевого слова const:

const Pi = 3.141592653589793
const MaxWorkers = 100
const Greeting = "Hello, Universe"

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

const (
Sunday = iota // 0
Monday // 1 — выражение = iota подразумевается
Tuesday // 2
)

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

Типы констант и константные выражения

Все константы в Go относятся к одной из шести базовых категорий: целочисленные, с плавающей точкой, комплексные, строковые, логические и нетипизированные. Последняя категория требует особого внимания.

Константа, объявленная без явного типа, считается нетипизированной — это указывает на то, что её тип будет определён контекстом использования. Например:

const DefaultPort = 8080

DefaultPort — целочисленная нетипизированная константа. Её можно присвоить переменной типа int, int32, uint16, uintptr — компилятор проверит, влезает ли значение 8080 в целевой тип, и выполнит неявное преобразование. Если же попытаться присвоить её типу uint8, компилятор выдаст ошибку, потому что 8080 выходит за пределы [0; 255].

То же касается строковых и логических констант: "text" может быть использован как string, []byte (через преобразование), или как часть форматированной строки — но не как int.

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

Константные выражения — это выражения, состоящие исключительно из констант, операторов, встроенных функций, применимых к константам (len, cap для строковых и массивных литералов, real, imag, complex для комплексных), и вызовов функций, которые компилятор может вычислить статически. Например:

const Size = 1024
const BufferLen = Size * 2 + 16
const Message = "Size: " + strconv.Itoa(BufferLen) // ❌ ошибка

Последняя строка не скомпилируется, потому что strconv.Itoa — это обычная функция времени выполнения, вызов которой невозможен на этапе компиляции. Go не поддерживает пользовательские constexpr-функции, как в C++; только встроенные операции и базовые арифметические/логические/битовые действия разрешены в константных выражениях.

Почему константы не занимают память

Когда компилятор встречает идентификатор константы в коде, он не генерирует инструкции загрузки из памяти. Вместо этого он подставляет её вычисленное значение непосредственно в точку использования, как если бы там стоял литерал. Это называется константным свёртыванием (constant folding).

Рассмотрим пример:

const MaxRetries = 3
var attempts int
for attempts < MaxRetries {
// ...
}

После обработки компилятором машинный код будет эквивалентен:

for attempts < 3 {
// ...
}

Значение 3 встроено в инструкцию сравнения. Не выделяется память под MaxRetries, не формируется символ в таблице имён, не происходит обращения к глобальному сегменту данных. Это приводит к двум важным последствиям:

  1. Оптимизация размера и скорости. Бинарный файл не содержит дублирующихся копий константных значений. Все вхождения заменяются непосредственно литералами, которые часто кодируются в составе инструкций процессора (например, как immediate-операнды в x86). Это уменьшает объём сегмента данных и повышает локальность кода.

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

    const N = 1 << 100     // слишком большое смещение для int64
    const M = N % 0 // деление на ноль
    const S = "abc"[10] // выход за пределы строки

    Все три строки вызовут ошибки компиляции, хотя ни одна из них не была бы поймана во время выполнения, если бы N, M, S были переменными. Это повышает надёжность программ и сокращает цикл отладки.

Константы и типобезопасность

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

Компилятор проверяет:

  • корректность операций (деление на ноль, переполнение при сдвиге, выход за границы);
  • совместимость типов в выражениях;
  • попадание значения в диапазон целевого типа при конкретизации.

Эти проверки выполняются в рамках константной арифметики с произвольной точностью. Например, при вычислении 1 << 1000 компилятор использует внутренние представления с неограниченной разрядностью, чтобы точно определить, влезет ли результат в int64 или uint. Только после подтверждения корректности значение усекается до требуемого размера.

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

Практические рекомендации по использованию констант

  • Используйте константы вместо «магических значений». Любое число, строка или флаг, имеющие семантическое значение (лимиты, коды ошибок, пути, заголовки), должны быть вынесены в const. Это повышает читаемость и упрощает рефакторинг.

  • Группируйте связанные константы в блоки. Блок const (...) улучшает структуру кода, и позволяет использовать iota для генерации последовательностей.

  • Избегайте константных выражений, зависящих от внешнего состояния. Поскольку вычисление происходит статически, невозможно использовать time.Now(), переменные окружения, конфигурационные файлы и т.п. Если значение должно определяться динамически — это не кандидат на константу.

  • Предпочитайте нетипизированные константы при определении параметров по умолчанию. Например, const DefaultTimeout = 30 * time.Secondtime.Second имеет тип time.Duration, но при умножении на нетипизированное целое результат остаётся нетипизированным до момента присваивания, что позволяет гибко использовать константу в разных контекстах.

  • Не пытайтесь получить адрес константы. Выражение &MaxRetries недопустимо — константа не имеет адреса, потому что не существует в памяти. Это логическое следствие её природы.