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


Целочисленные типы

Go предоставляет полный набор целочисленных типов с явным указанием размера:

ТипРазмерДиапазон
int88 бит-128 … 127
int1616 бит-32 768 … 32 767
int3232 бита-2 147 483 648 … 2 147 483 647
int6464 бита-9.2×10¹⁸ … 9.2×10¹⁸
uint88 бит0 … 255
uint1616 бит0 … 65 535
uint3232 бита0 … 4 294 967 295
uint6464 бита0 … 1.8×10¹⁹
int32 или 64 бита*зависит от платформы
uint32 или 64 бита*зависит от платформы
uintptrзависит от платформыдля хранения указателей

int и uint — платформозависимые типы: на 64-битной системе это 64 бита, на 32-битной — 32. Важно понимать, что int не является псевдонимом для int64 — это самостоятельный тип, и компилятор не позволит их смешивать без явного приведения.

go
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:

go
var i int // 0 var f float64 // 0.0 var b bool // false var s string // ""

Это не просто синтаксический сахар — это архитектурное решение. Вы всегда знаете, в каком состоянии находится переменная, даже если забыли её явно инициализировать. Концепция zero value пронизывает весь язык: структуры, слайсы, каналы — везде применяется один и тот же принцип.


Числа с плавающей точкой

Разобравшись с целыми, перейдём к вещественным числам. Go предоставляет два типа: float32 и float64. float64 — тип по умолчанию для нетипизированных вещественных констант и предпочтителен в большинстве задач из-за точности. float32 оправдан только там, где критична память — например, в больших числовых массивах или при работе с GPU.

go
var f32 float32 = 3.14 var f64 float64 = 3.141592653589793 f := 3.14 // тип float64

Здесь есть классическая ловушка, которая встречается на каждом втором собеседовании: сравнение через ==.

go
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, никакого неявного приведения из других типов:

go
var active bool // false isReady := true // В отличие от C — if (1) {} в Go не скомпилируется if isReady { fmt.Println("ready") }

Это ещё одно проявление философии языка: явное лучше неявного.


byte и rune — псевдонимы, а не отдельные типы

Понимание byte и rune критично для работы со строками, поэтому разберём их подробно, прежде чем перейти к string.

go
type byte = uint8 // псевдоним uint8 type rune = int32 // псевдоним int32

Это именно псевдонимы (=), а не определения новых типов (type byte uint8 было бы другим). Это значит, что byte и uint8 — буквально одно и то же, компилятор не видит между ними разницы.

  • byte используется при работе с бинарными данными, сетевыми протоколами и ASCII-строками. Он сигнализирует читателю кода: "здесь мы работаем с байтами, а не символами".
  • rune представляет один Unicode code point — то, что в других языках называют "символом". Занимает 4 байта, что позволяет вместить весь Unicode (более 1 млн символов).
go
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 устроены иначе, чем кажется на первый взгляд. Под капотом строка — это структура из двух полей:

go
// Внутреннее представление (из пакета reflect) type StringHeader struct { Data uintptr // указатель на массив байт в памяти Len int // длина в байтах, не в символах }

Два ключевых следствия из этого устройства. Первое: len() возвращает байты, а не символы. Второе: строка неизменяема — данные, на которые указывает Data, нельзя изменить.

Байты vs символы — главная ловушка

go
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() обманывает.

Итерация: два способа с разным смыслом

go
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 и возвращает руны.

Обращение по индексу возвращает байт, а не символ

go
s := "Привет" fmt.Println(s[0]) // 208 — первый байт руны 'П', не сама 'П' fmt.Printf("%c\n", s[0]) // Ð — мусор, потому что 208 — это не полный символ // Правильно — получить руну по позиции runes := []rune(s) fmt.Printf("%c\n", runes[0]) // П

Строка неизменяема

go
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:

go
var sb strings.Builder for i := 0; i < 5; i++ { sb.WriteString("hello ") } result := sb.String() // единственная аллокация в конце

Передача строки — это копирование заголовка, не данных

go
func printString(s string) { fmt.Println(s) } s := "очень длинная строка..." printString(s) // копируется только StringHeader (16 байт), не сами данные

Строки дёшевы для передачи в функцию, потому что копируется только заголовок (указатель + длина = 16 байт на 64-битной системе). Сами байты остаются на месте. Это одна из причин, почему в Go не нужно передавать строки по указателю.

Конкатенация оператором +

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

go
s := "hello" b := []byte(s) // копирование s2 := string(b) // копирование обратно

Оба преобразования — аллокация. Компилятор умеет избегать лишних копий в некоторых случаях (например, при string(b) в выражении сравнения), но рассчитывать на это не стоит.


Константы и iota

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

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 в каждом новом блоке и инкрементируется с каждой константой:

go
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 нельзя сложить без явного каста:

go
var i int = 42 var f float64 = float64(i) var u uint = uint(f)

При приведении к меньшему типу происходит усечение без паники:

go
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 без инициализации. int0, float640.0, boolfalse, 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: Границы типа

Уровень: Лёгкая

Что проверяет: понимание переполнения при приведении типов

Условие: Что выведет следующий код? Объясни почему.

go
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) }

Ожидаемый ответ:

text
-2147483648 255 44

Решение:

go
// 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.

Примеры:

text
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)

Решение:

go
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

Примеры:

text
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).

Решение:

go
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

Практика

Quiz+10 XP

Какой будет результат выражения 7 / 2 в Go, если обе переменные типа int?

  • 3.5
  • 4
  • 3
  • Ошибка компиляции
Predict+15 XP

Что выведет этот код?

go
x := 10 y := 3 fmt.Println(x % y + x/y)
Задача+20 XP

Объявите переменную msg типа string со значением "Привет, Go!" и выведите её.