Типы данных в Go: полный обзор от int до rune

Типы данных в Go

Вопрос «какие типы данных есть в Go» кажется самым простым на собесе. Ровно до уточнений: почему len("Привет") равен 12, какой длины руна и что будет при переполнении int8. В этой статье решил разложить все базовые типы по полочкам: какие бывают, где ломаются и что про них спрашивают.

Какие типы вообще бывают

Все типы 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, но компилятор считает его отдельным типом. Каждое смешивание денег с обычными числами становится явным. Цена решения — лишние преобразования в коде, так что для маленького скрипта это может быть перебором, а для сервиса с расчётами окупается.

Ключевой вывод

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

А вам попадались ловушки с типами данных на собесе или в рабочем коде? Пишите в комментариях.

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: