Вопрос «какие типы данных есть в Go» кажется самым простым на собесе. Ровно до уточнений: почему len("Привет") равен 12, какой длины руна и что будет при переполнении int8. В этой статье решил разложить все базовые типы по полочкам: какие бывают, где ломаются и что про них спрашивают.
- Какие типы вообще бывают
- Какой int выбрать
- Что будет при переполнении
- Почему 0.1 + 0.2 иногда не равно 0.3
- Как хранить деньги
- Комплексные числа
- Что лежит в переменной, которую вы не заполнили
- Почему len("Привет") равен 12
- Чем byte отличается от rune
- Как перебирать строку
- Пример из жизни: строка на чеке
- Почему строку нельзя изменить
- Где типы ломаются при преобразовании
- Два спорных решения
- Ключевой вывод
Какие типы вообще бывают
Все типы Go делятся на две группы. Базовые хранят одно простое значение: число, строку, «да» или «нет». Составные собираются из других типов, как шкаф из полок.
| Группа | Типы |
| Логический | bool |
| Целые со знаком | int, int8, int16, int32, int64 |
| Целые без знака | uint, uint8, uint16, uint32, uint64, uintptr |
| Дробные | float32, float64 |
| Комплексные | complex64, complex128 |
| Строки | string |
| Псевдонимы | byte (=uint8), rune (=int32) |
| Составные | массивы, слайсы, мапы, структуры, указатели, функции, интерфейсы, каналы |
Псевдоним — это второе имя того же типа: byte и uint8 для компилятора одно и то же. У каждого составного типа своё устройство под капотом, так что им нужны отдельные статьи. Здесь разберём фундамент.
Какой int выбрать
Цифра в названии — сколько бит занимает тип: int8 — 8 бит, int64 — 64. Буква u спереди значит unsigned, «без знака». Отрицательных чисел такой тип не хранит, зато положительный диапазон у него вдвое больше.
| Тип | Размер | Диапазон |
| int8 | 1 байт | от -128 до 127 |
| uint8 (byte) | 1 байт | от 0 до 255 |
| int16 | 2 байта | от -32 768 до 32 767 |
| uint16 | 2 байта | от 0 до 65 535 |
| int32 (rune) | 4 байта | примерно ±2,1 млрд |
| uint32 | 4 байта | от 0 до ~4,3 млрд |
| int64 | 8 байт | примерно ±9,2 × 10¹⁸ |
| uint64 | 8 байт | от 0 до ~1,8 × 10¹⁹ |
Учить таблицу наизусть не нужно, достаточно уловить закономерность.
Отдельно стоят int и uint: их размер зависит от платформы. На 32-битной системе это 4 байта, на 64-битной — 8. Проверяется одной строкой
fmt.Println(unsafe.Sizeof(int(0)), math.MaxInt) // 8 9223372036854775807
unsafe.Sizeof возвращает размер значения в байтах, а math.MaxInt — самое большое число, которое влезает в int. На обычном 64-битном ноутбуке получаем 8 байт. Константа math.MaxInt появилась в Go 1.17, раньше её вычисляли сдвигами битов.
На практике обычно берут int. На него рассчитаны len(), индексы слайсов и почти вся стандартная библиотека. Типы с явным размером нужны, когда размер важен сам по себе: бинарный протокол, формат файла, миллионы значений в памяти. uintptr в прикладном коде почти не встречается: он хранит адрес в памяти как число и используется вместе с пакетом unsafe.
Что будет при переполнении
Вспомним механический одометр в старых «девятках»: накрутил 99 999 км, и барабанчики снова показывают 00 000. Целые числа в Go ведут себя так же, если переполнение случилось во время работы программы:
var cup uint8 = 255 cup++ fmt.Println(cup) // 0 var t int8 = 127 t++ fmt.Println(t) // -128
uint8 дошёл до потолка 255 и после прибавления единицы начал с нуля. int8 с максимума 127 перескочил на минимум -128. Паники и ошибок не будет. Такое поведение называют wrap-around — «заворачивание» значения на другой край диапазона. В подсчёте остатков на складе оно превращается в очень неприятный баг.
С константами компилятор строже и ловит переполнение сразу:
var a int8 = 128 // cannot use 128 (untyped int constant) as int8 value // in variable declaration (overflows)
Значение 128 известно ещё до запуска, и компилятор видит, что в int8 оно не помещается. На собесе эти два случая часто спрашивают парой: записать 128 в int8 — ошибка компиляции, прибавить 1 к uint8 со значением 255 — получится 0.
Почему 0.1 + 0.2 иногда не равно 0.3
Для дробных чисел в Go два типа. float32 держит примерно 6-7 значащих цифр, float64 — около 15-16. Если написать x := 3.14, Go выберет float64, и почти всегда нужен именно он.
Теперь попробуйте угадать вывод, прежде чем читать дальше:
fmt.Println(0.1 + 0.2) // 0.3 a, c := 0.1, 0.2 fmt.Println(a + c) // 0.30000000000000004
В первой строке складываются нетипизированные константы — числа, записанные прямо в коде, которым ещё не назначен конкретный тип. Компилятор считает их с произвольной точностью и получает ровно 0.3. Во второй строке складываются уже переменные типа float64. Число 0.1 в двоичном виде точно не записывается, у него всегда есть крошечная погрешность, и она вылезает в хвосте результата.
Как хранить деньги
Возьмём чек: товар за 199,99 ₽, три штуки. Погрешность в доли копейки на одной позиции незаметна, но за смену позиций тысячи, и в конце кассы не сойдутся. Поэтому деньги хранят целыми числами в копейках:
price := 19999 // 199,99 ₽ в копейках
qty := 3
total := price * qty
fmt.Printf("%d.%02d\n", total/100, total%100) // 599.97
total/100 даёт рубли, total%100 — остаток в копейках, а %02d дописывает ведущий ноль, чтобы 5 копеек выглядели как 05. Для сложной финансовой математики есть сторонние пакеты с десятичной арифметикой, но в большинстве задач хватает копеек в int.
Комплексные числа
complex64 и complex128 хранят числа с вещественной и мнимой частью. В прикладном бэкенде они встречаются редко, их место — обработка сигналов и научные расчёты. Знать про них стоит хотя бы для полного ответа на собесе:
z := complex(1, 2) fmt.Println(z, real(z), imag(z)) // (1+2i) 1 2
complex() собирает число из двух частей, а real() и imag() достают их обратно.
Что лежит в переменной, которую вы не заполнили
Начнём с bool: у него два значения — true и false. Если вы пришли из C или JavaScript, нужно запомнить одно: число в Go само в логическое значение не превращается.
if 1 {}
// non-boolean condition in if statement
Компилятор требует явное условие: if count > 0, if err != nil. Поначалу выглядит занудно, зато код читается без догадок.
Теперь про нулевые значения. В Go объявленная переменная всегда чем-то заполнена, даже если вы ничего ей не присвоили. Мусора из памяти или undefined тут не бывает. Похоже на новый телефон из коробки: контактов ноль, но телефонная книга уже есть и открывается.
var i int
var f float64
var s string
var b bool
var p *int
fmt.Printf("%d %v %q %v %v\n", i, f, s, b, p)
// 0 0 "" false <nil>
Формат %q печатает строку в кавычках, поэтому пустая строка видна как «». nil — «пустое» значение для указателей и ссылочных типов.
| Тип | Нулевое значение |
| Числа (int, float, complex) | 0 |
| bool | false |
| string | «» (пустая строка) |
| Указатели, слайсы, мапы, каналы, функции, интерфейсы | nil |
Отсюда полезная привычка: проектировать структуры так, чтобы их нулевое значение сразу работало. Так устроены sync.Mutex и bytes.Buffer: объявили и пользуетесь, инициализация не нужна.
Почему len("Привет") равен 12
Строка в Go — неизменяемая последовательность байтов. Исходники Go всегда в UTF-8, поэтому строковые литералы тоже хранятся в UTF-8. UTF-8 — кодировка, в которой символы занимают разное число байтов: латинская буква 1 байт, русская 2, эмодзи 4.
word := "Привет" fmt.Println(len(word)) // 12 fmt.Println(utf8.RuneCountInString(word)) // 6 fmt.Println(len([]rune(word))) // 6 fmt.Println(word[0]) // 208
len() считает байты: шесть букв по два байта дают 12. Символы считают utf8.RuneCountInString или перевод в []rune. А word[0] возвращает первый байт буквы «П» — число 208, саму букву так не достать.
Чем byte отличается от rune
byte— этоuint8, один байт. Подходит для сырых данных: файлов, сети, бинарных протоколов.rune— этоint32, 4 байта. Хранит номер символа в таблице Unicode, его ещё называют кодовой точкой.
r := 'Я'
fmt.Println(r) // 1071
fmt.Printf("%c %T %d\n", r, r, unsafe.Sizeof(r)) // Я int32 4
var by byte = 'A'
fmt.Printf("%T %d\n", by, by) // uint8 65
Одинарные кавычки создают руну, то есть число. Println печатает 1071 — номер буквы «Я» в Unicode. Чтобы увидеть сам символ, нужен формат %с или string(r). Формат %T показывает тип: у руны int32, у byte — uint8.
Как перебирать строку
Цикл for range идёт по строке рунами и возвращает индекс первого байта каждого символа:
for i, ch := range "Go, мир" {
fmt.Print(i, ":", string(ch), " ")
}
// 0:G 1:o 2:, 3: 4:м 6:и 8:р
На латинице индексы идут подряд, на кириллице прыгают через два: каждая русская буква занимает два байта.
Пример из жизни: строка на чеке
На кассовом чеке ширина строки ограничена, и длинные названия товаров приходится обрезать. Допустим, режете через name[:20] — срез первых 20 байт. «Молоко 3,2% 1л ультрапастеризованное» разрежется посреди буквы «л», и на чеке появится кракозябра. А короткое «Milk 3.2%» вообще уронит программу: в нём всего 9 байт, и срез [:20] вызовет панику. Лечится так: переводите строку в []rune, проверяете длину и режете уже ее, тогда 20 означает 20 символов:
name := "Молоко 3,2% 1л ультрапастеризованное"
fmt.Println(name[:20]) // Молоко 3,2% 1� — от "л" остался один байт
if r := []rune(name); len(r) > 20 {
name = string(r[:20])
}
fmt.Println(name) // Молоко 3,2% 1л ультр
Почему строку нельзя изменить
s := "кот" s[0] = 'р' // cannot assign to s[0] (neither addressable nor a map index expression)
Строки в Go неизменяемы. Благодаря этому их безопасно передавать между горутинами, а подстроки могут делить одну память без копирования. Если нужно что-то поменять, делаете копию, правите её и собираете строку заново:
b := []rune("кот")
b[0] = 'p'
fmt.Println(string(b)) // рот
[]rune("кот") создаёт новый слайс рун, его уже можно менять, а string(b) собирает из него новую строку.
Где типы ломаются при преобразовании
Для Go int и int64 — это разные типы, даже на 64-битной машине, где размер у них одинаковый. Сложить их напрямую нельзя:
var x int = 10 var y int64 = 20 fmt.Println(x + y) // invalid operation: x + y (mismatched types int and int64) fmt.Println(int64(x) + y) // 30
Преобразование пишется явно: T(значение), где T — нужный тип. Поэтому каждое преобразование видно в коде, и места, где данные могут потеряться, легко найти глазами. А теряются они в трёх типичных случаях.
1. Сужение типа молча отрезает старшие биты
var big int64 = 300 fmt.Println(int8(big)) // 44
300 в int8 не помещается, остаётся только младший байт: 300 - 256 = 44. Предупреждений не будет.
2. Дробная часть отбрасывается без округления
fl := 3.99 fmt.Println(int(fl)) // 3
Было почти четыре, стало три. Если написать константой int(3.99), код не скомпилируется: компилятор видит потерю точности заранее.
3. string(число) превращает число в символ
fmt.Println(string(rune(65)), strconv.Itoa(65)) // A 65
string(rune(65)) даёт букву «A», потому что у неё код 65 в Unicode. go vet предупреждает о string(65) без rune. Чтобы получить строку «65», используйте strconv.Itoa или fmt.Sprint.
Два спорных решения
Нужен ли uint для значений, которые не бывают отрицательными?
Логика вроде железная: количество товара или возраст меньше нуля не бывают, значит берём uint. Но у такого выбора есть подвох:
var n uint = 0
fmt.Println(n - 1) // 18446744073709551615
items := []string{}
fmt.Println(len(items) - 1) // -1
С uint вычитание из нуля заворачивается в огромное число, и проверка if n-1 < 0 никогда не сработает. С int получаете честный -1, который легко поймать. К тому же len() возвращает int, так что с uint придётся конвертировать на каждом шагу. Поэтому в прикладном коде обычно берут int, а uint оставляют для битовых операций и бинарных форматов.
Стоит ли заводить отдельный тип для денег
Если копейки хранятся в int64, их легко случайно сложить с количеством или с id товара. Компилятор промолчит: для него это просто числа. Отдельный тип закрывает эту дыру:
type Kopecks int64 var price Kopecks = 19999 var qty int64 = 3 fmt.Println(price * qty) // invalid operation: price * qty (mismatched types Kopecks and int64) fmt.Println(price * Kopecks(qty)) // 59997
Kopecks устроен как int64, но компилятор считает его отдельным типом. Каждое смешивание денег с обычными числами становится явным. Цена решения — лишние преобразования в коде, так что для маленького скрипта это может быть перебором, а для сервиса с расчётами окупается.
Ключевой вывод
Типы данных кажутся скучной базой ровно до первой кракозябры на чеке или копейки, потерянной при расчёте. Надеюсь, теперь вы понимаете типы данных и в теории, и на практике.
А вам попадались ловушки с типами данных на собесе или в рабочем коде? Пишите в комментариях.