Go — язык со строгой статической типизацией, и понимание базовых типов — это фундамент, без которого сложно объяснить что угодно: от работы со слайсами до устройства строк. Начнём с самого простого и постепенно дойдём до нюансов, которые регулярно всплывают на собеседованиях.
Целочисленные типы
Go предоставляет полный набор целочисленных типов с явным указанием размера:
| Тип | Размер | Диапазон |
|---|---|---|
int8 | 8 бит | -128 … 127 |
int16 | 16 бит | -32 768 … 32 767 |
int32 | 32 бита | -2 147 483 648 … 2 147 483 647 |
int64 | 64 бита | -9.2×10¹⁸ … 9.2×10¹⁸ |
uint8 | 8 бит | 0 … 255 |
uint16 | 16 бит | 0 … 65 535 |
uint32 | 32 бита | 0 … 4 294 967 295 |
uint64 | 64 бита | 0 … 1.8×10¹⁹ |
int | 32 или 64 бита* | зависит от платформы |
uint | 32 или 64 бита* | зависит от платформы |
uintptr | зависит от платформы | для хранения указателей |
int и uint — платформозависимые типы: на 64-битной системе это 64 бита, на 32-битной — 32. Важно понимать, что int не является псевдонимом для int64 — это самостоятельный тип, и компилятор не позволит их смешивать без явного приведения.
var a int = 10
var b int64 = 20
// Ошибка компиляции: cannot use b (type int64) as type int
// c := a + b
// Правильно — явное приведение
c := a + int(b)
fmt.Println(c) // 30
Эта строгость — намеренное решение. В C неявные преобразования между числовыми типами — источник целого класса багов. Go убирает проблему на уровне компилятора.
Нулевые значения (zero values)
Прежде чем двигаться дальше, стоит сразу усвоить одно из ключевых правил Go: в языке нет неинициализированных переменных. Каждый тип имеет нулевое значение, которое присваивается автоматически при объявлении через var:
var i int // 0
var f float64 // 0.0
var b bool // false
var s string // ""
Это не просто синтаксический сахар — это архитектурное решение. Вы всегда знаете, в каком состоянии находится переменная, даже если забыли её явно инициализировать. Концепция zero value пронизывает весь язык: структуры, слайсы, каналы — везде применяется один и тот же принцип.
Числа с плавающей точкой
Разобравшись с целыми, перейдём к вещественным числам. Go предоставляет два типа: float32 и float64. float64 — тип по умолчанию для нетипизированных вещественных констант и предпочтителен в большинстве задач из-за точности. float32 оправдан только там, где критична память — например, в больших числовых массивах или при работе с GPU.
var f32 float32 = 3.14
var f64 float64 = 3.141592653589793
f := 3.14 // тип float64
Здесь есть классическая ловушка, которая встречается на каждом втором собеседовании: сравнение через ==.
a := 0.1 + 0.2
b := 0.3
fmt.Println(a == b) // false (!)
fmt.Println(math.Abs(a-b) < 1e-9) // true — правильный способ
Числа с плавающей точкой хранятся в формате IEEE 754, и большинство десятичных дробей не имеют точного двоичного представления. 0.1 в двоичной системе — это бесконечная дробь, поэтому после арифметики накапливается ошибка представления. Это не баг Go — это фундаментальное ограничение формата, одинаковое для всех языков.
bool
bool в Go максимально лаконичен — только true и false, никакого неявного приведения из других типов:
var active bool // false
isReady := true
// В отличие от C — if (1) {} в Go не скомпилируется
if isReady {
fmt.Println("ready")
}
Это ещё одно проявление философии языка: явное лучше неявного.
byte и rune — псевдонимы, а не отдельные типы
Понимание byte и rune критично для работы со строками, поэтому разберём их подробно, прежде чем перейти к string.
type byte = uint8 // псевдоним uint8
type rune = int32 // псевдоним int32
Это именно псевдонимы (=), а не определения новых типов (type byte uint8 было бы другим). Это значит, что byte и uint8 — буквально одно и то же, компилятор не видит между ними разницы.
byteиспользуется при работе с бинарными данными, сетевыми протоколами и ASCII-строками. Он сигнализирует читателю кода: "здесь мы работаем с байтами, а не символами".runeпредставляет один Unicode code point — то, что в других языках называют "символом". Занимает 4 байта, что позволяет вместить весь Unicode (более 1 млн символов).
var b byte = 'A' // 65
var r rune = 'Я' // 1071
fmt.Printf("%T %v\n", b, b) // uint8 65
fmt.Printf("%T %v\n", r, r) // int32 1071
С пониманием rune можно переходить к строкам — там это знание пригодится немедленно.
string — неизменяемый срез байт
Строки в Go устроены иначе, чем кажется на первый взгляд. Под капотом строка — это структура из двух полей:
// Внутреннее представление (из пакета reflect)
type StringHeader struct {
Data uintptr // указатель на массив байт в памяти
Len int // длина в байтах, не в символах
}
Два ключевых следствия из этого устройства. Первое: len() возвращает байты, а не символы. Второе: строка неизменяема — данные, на которые указывает Data, нельзя изменить.
Байты vs символы — главная ловушка
s := "Привет"
fmt.Println(len(s)) // 12 — байты!
fmt.Println(utf8.RuneCountInString(s)) // 6 — символы
fmt.Println(len([]rune(s))) // 6 — тоже символы, но с выделением памяти
Кириллица в UTF-8 занимает по 2 байта на символ, поэтому "Привет" — это 6 символов и 12 байт. Эмодзи занимают 4 байта. Латиница — 1 байт. Вот почему наивный len() обманывает.
Итерация: два способа с разным смыслом
s := "Привет"
// Итерация по байтам — неправильно для многобайтных символов
for i := 0; i < len(s); i++ {
fmt.Printf("%x ", s[i]) // c0 d0 d1 80 d0 b8 ...
}
// Итерация по рунам через range — правильно
for i, r := range s {
fmt.Printf("[байт %d] = %c\n", i, r)
}
// [байт 0] = П
// [байт 2] = р <-- прыжок на 2, а не на 1!
// [байт 4] = и
// [байт 6] = в
// [байт 8] = е
// [байт 10] = т
Обратите внимание: i в range — это байтовая позиция, а не порядковый номер символа. range по строке автоматически декодирует UTF-8 и возвращает руны.
Обращение по индексу возвращает байт, а не символ
s := "Привет"
fmt.Println(s[0]) // 208 — первый байт руны 'П', не сама 'П'
fmt.Printf("%c\n", s[0]) // Ð — мусор, потому что 208 — это не полный символ
// Правильно — получить руну по позиции
runes := []rune(s)
fmt.Printf("%c\n", runes[0]) // П
Строка неизменяема
s := "hello"
// s[0] = 'H' // Ошибка компиляции: cannot assign to s[0]
// Изменить можно только через конвертацию — и это копирование
b := []byte(s)
b[0] = 'H'
s = string(b)
fmt.Println(s) // Hello
Каждое []byte(s) и string(b) — это аллокация и копирование данных. В горячих путях, где строки собираются по частям, это критично. Используйте strings.Builder:
var sb strings.Builder
for i := 0; i < 5; i++ {
sb.WriteString("hello ")
}
result := sb.String() // единственная аллокация в конце
Передача строки — это копирование заголовка, не данных
func printString(s string) {
fmt.Println(s)
}
s := "очень длинная строка..."
printString(s) // копируется только StringHeader (16 байт), не сами данные
Строки дёшевы для передачи в функцию, потому что копируется только заголовок (указатель + длина = 16 байт на 64-битной системе). Сами байты остаются на месте. Это одна из причин, почему в Go не нужно передавать строки по указателю.
Конкатенация оператором +
s := "Hello" + ", " + "World" // три строки → одна аллокация (компилятор оптимизирует)
// Но в цикле — катастрофа
result := ""
for i := 0; i < 1000; i++ {
result += "x" // 1000 аллокаций и копирований!
}
// Правильно
var sb strings.Builder
for i := 0; i < 1000; i++ {
sb.WriteByte('x')
}
result = sb.String()
Конвертация string ↔ byte
s := "hello"
b := []byte(s) // копирование
s2 := string(b) // копирование обратно
Оба преобразования — аллокация. Компилятор умеет избегать лишних копий в некоторых случаях (например, при string(b) в выражении сравнения), но рассчитывать на это не стоит.
Константы и iota
Константы в Go существуют в двух формах. Нетипизированные константы обладают большей гибкостью: они хранятся с произвольной точностью и могут использоваться с любым совместимым типом. Типизированные константы жёстко привязаны к типу.
const Pi = 3.14159 // нетипизированная — подойдёт и для float32, и для float64
const E float64 = 2.71828 // типизированная — только float64
var x float32 = Pi // ок: нетипизированная константа подстраивается
var y float32 = E // ошибка: E уже типизирована как float64
iota — встроенный счётчик в блоках const. Сбрасывается до 0 в каждом новом блоке и инкрементируется с каждой константой:
type Direction int
const (
North Direction = iota // 0
East // 1
South // 2
West // 3
)
// iota можно использовать в выражениях
type ByteSize float64
const (
_ = iota // пропускаем 0 через blank identifier
KB ByteSize = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20
GB // 1 << 30
TB // 1 << 40
)
Явное приведение типов
Все примеры выше используют явное приведение, и это не случайно — в Go нет неявных числовых преобразований вообще. Даже int и int64 нельзя сложить без явного каста:
var i int = 42
var f float64 = float64(i)
var u uint = uint(f)
При приведении к меньшему типу происходит усечение без паники:
var big int = 300
var small int8 = int8(big)
fmt.Println(small) // -44 — переполнение, Go не проверяет это в рантайме
300 в двоичном виде: 100101100. В int8 помещается только 8 бит: 00101100 = 44. Но компилятор трактует старший бит как знаковый — получаем -44. Никаких паник, никаких ошибок — ответственность на разработчике.
Вопросы на собеседовании
Q: Чем int отличается от int64?
A: int — платформозависимый тип (32 или 64 бита в зависимости от архитектуры), int64 — всегда 64 бита. Это разные типы: компилятор не позволит их смешать без явного приведения. На 64-битной системе они одного размера, но всё равно не совместимы.
Q: Что такое zero value? Приведите примеры.
A: Значение по умолчанию, которое Go присваивает переменной при объявлении через var без инициализации. int → 0, float64 → 0.0, bool → false, string → "", указатели, слайсы, каналы, функции → nil. Это гарантирует отсутствие "мусора" в памяти и убирает целый класс ошибок.
Q: Почему 0.1 + 0.2 != 0.3 в Go?
A: Числа с плавающей точкой хранятся в формате IEEE 754. Большинство десятичных дробей не имеют точного двоичного представления — 0.1 в двоичном виде это бесконечная дробь. При арифметике накапливается ошибка округления. Это не баг Go — одно и то же поведение в Python, Java, C. Правильный способ сравнения: math.Abs(a-b) < epsilon.
Q: Почему len("Привет") возвращает 12, а не 6?
A: len() возвращает длину строки в байтах, не в символах. "Привет" в UTF-8 — 6 кириллических символов по 2 байта = 12 байт. Для подсчёта символов используют utf8.RuneCountInString(s) или len([]rune(s)).
Q: Что вернёт s[0] для строки s := "Привет"?
A: Тип byte (uint8) со значением 208 — это первый байт двухбайтной UTF-8 кодировки символа 'П'. Не сам символ 'П'. Чтобы получить символ, нужно итерироваться через range или конвертировать в []rune.
Q: Чем range по строке отличается от итерации через индекс?
A: for i := range s декодирует UTF-8 и возвращает руны (rune), а i — байтовая позиция начала руны. for i := 0; i < len(s); i++ итерируется по байтам — для многобайтных символов это даёт "сломанные" куски символов.
Q: Можно ли изменить строку в Go? Почему?
A: Нет. Строки неизменяемы — Data в StringHeader указывает на readonly память. Это позволяет безопасно передавать строки между горутинами без копирования и синхронизации. Для изменения нужно конвертировать в []byte, изменить, конвертировать обратно — каждый шаг это аллокация.
Q: Почему конкатенация строк в цикле через + — это плохо?
A: При каждом s += x создаётся новый строковый объект: выделяется память под новую строку, данные копируются. В цикле на 1000 итераций — 1000 аллокаций. Правильный способ — strings.Builder, который аккумулирует данные и делает одну аллокацию в конце при вызове String().
Q: Дорого ли передавать строку в функцию?
A: Нет. При передаче строки копируется только заголовок StringHeader — 16 байт (указатель + длина). Сами данные не копируются. Поэтому передавать строки по указателю (*string) не нужно и является антипаттерном.
Q: Чем byte отличается от rune?
A: byte — псевдоним uint8, один байт. rune — псевдоним int32, один Unicode code point. Один символ может состоять из нескольких байт: латиница — 1 байт, кириллица — 2, некоторые иероглифы — 3, эмодзи — 4. Если работаете с "символами" в человеческом понимании — используйте rune.
Q: Что такое нетипизированная константа? Чем она отличается от типизированной?
A: Нетипизированная константа (const Pi = 3.14) хранится с произвольной точностью и может использоваться с любым совместимым типом — float32 или float64. Типизированная (const E float64 = 2.71) жёстко привязана к типу и не будет неявно конвертирована.
Q: Что произойдёт при int8(300)?
A: Переполнение без паники. 300 в двоичном виде — 100101100, в int8 помещается только 8 бит — 00101100 = 44, но знаковый бит (1) даёт -44. Go не проверяет переполнение при явном приведении в рантайме. Переполнение при приведении — ответственность разработчика.
Задачи: Скалярные типы
Задача 1: Границы типа
Уровень: Лёгкая
Что проверяет: понимание переполнения при приведении типов
Условие: Что выведет следующий код? Объясни почему.
package main
import "fmt"
func main() {
var a int32 = 2147483647
a++
fmt.Println(a)
var b uint8 = 0
b--
fmt.Println(b)
var c int = 300
d := int8(c)
fmt.Println(d)
}
Ожидаемый ответ:
-2147483648
255
44
Решение:
// a: int32 максимум = 2147483647 (2^31 - 1).
// При инкременте происходит переполнение —
// старший бит становится 1 (знаковый), результат -2147483648.
// b: uint8 минимум = 0. Декремент беззнакового 0
// оборачивается до максимума: 255 (2^8 - 1).
// c: 300 в двоичном виде = 100101100.
// int8 хранит только 8 бит: 00101100 = 44.
// Go не проверяет переполнение при явном приведении.
Задача 2: Сравнение float
Уровень: Средняя
Что проверяет: понимание IEEE 754 и правильного сравнения вещественных чисел
Условие: Напиши функцию equal(a, b float64) bool которая корректно сравнивает два числа с плавающей точкой. Функция должна возвращать true если числа равны с точностью до 1e-9. Также объясни почему стандартный == не работает для float.
Примеры:
equal(0.1+0.2, 0.3) → true
equal(1.0, 1.0) → true
equal(1.0, 1.000000001) → false
equal(1.0, 1.0000000001) → true (разница меньше 1e-9)
Решение:
package main
import (
"fmt"
"math"
)
func equal(a, b float64) bool {
return math.Abs(a-b) < 1e-9
}
func main() {
fmt.Println(equal(0.1+0.2, 0.3)) // true
fmt.Println(equal(1.0, 1.0)) // true
fmt.Println(equal(1.0, 1.000000001)) // false
fmt.Println(equal(1.0, 1.0000000001)) // true
}
// Почему == не работает:
// 0.1 в двоичной системе — бесконечная дробь.
// После арифметических операций накапливается ошибка представления.
// 0.1 + 0.2 в IEEE 754 = 0.30000000000000004, а не 0.3.
// Поэтому 0.1+0.2 == 0.3 → false.
Задача 3: Подсчёт байт и рун
Уровень: Сложная
Что проверяет: глубокое понимание string, byte, rune и UTF-8
Условие: Напиши функцию analyzeString(s string) которая выводит:
- Длину строки в байтах
- Длину строки в символах (рунах)
- Каждый символ с его байтовой позицией, рунным значением и количеством байт которые он занимает
- Является ли строка валидным UTF-8
Примеры:
analyzeString("Hi!")
→ Байт: 3, Символов: 3
→ [0] 'H' rune=72 bytes=1
→ [1] 'i' rune=105 bytes=1
→ [2] '!' rune=33 bytes=1
→ Валидный UTF-8: true
analyzeString("Го!")
→ Байт: 5, Символов: 3
→ [0] 'Г' rune=1043 bytes=2
→ [2] 'о' rune=1086 bytes=2
→ [4] '!' rune=33 bytes=1
→ Валидный UTF-8: true
Подсказка: Для определения размера руны используй utf8.RuneLen(r). Для валидации — utf8.ValidString(s).
Решение:
package main
import (
"fmt"
"unicode/utf8"
)
func analyzeString(s string) {
fmt.Printf("Байт: %d, Символов: %d\n", len(s), utf8.RuneCountInString(s))
for i, r := range s {
size := utf8.RuneLen(r)
fmt.Printf("[%d] '%c' rune=%-6d bytes=%d\n", i, r, r, size)
}
fmt.Printf("Валидный UTF-8: %v\n", utf8.ValidString(s))
}
func main() {
analyzeString("Hi!")
analyzeString("Го!")
analyzeString("Hello, 世界")
}
// Ключевые моменты:
// - range по строке возвращает байтовую позицию и руну (не индекс символа)
// - len() считает байты, utf8.RuneCountInString() считает символы
// - кириллица занимает 2 байта, некоторые иероглифы — 3
Практика
Какой будет результат выражения 7 / 2 в Go, если обе переменные типа int?
Что выведет этот код?
x := 10
y := 3
fmt.Println(x % y + x/y)
Объявите переменную msg типа string со значением "Привет, Go!" и выведите её.