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

Strings, Runes и Bytes в Go — под капотом

Тема, на которой “плавает” 80% джунов: разница между байтом, руной и строкой; почему len("Привет") это 12, а не 6; чем отличается for i := range s от for i := 0; i < len(s); i++; когда []byte(s) копирует, а когда нет. Если вы понимаете UTF-8 и stringStruct — собес по строкам пройдёте легко.

  1. Базовое определение и API
  2. Внутреннее устройство (под капотом)
  3. Тонкие моменты / Gotchas
  4. Производительность
  5. Типичные вопросы на собеседовании Junior
  6. Practice — задачки на проверку
  7. Источники и дополнительно

ТипРазмерЧто этоАлиас для
byte1 байтсырой байтuint8
rune4 байтаUnicode code pointint32
string16 байт headerimmutable UTF-8 закодированная последовательность байт
var b byte = 65 // ASCII 'A'
var r rune = '' // Unicode code point 0x65E5 (26085)
var s string = "Привет" // 6 рун, но 12 байт (UTF-8)

string — это последовательность байт (не символов!), immutable, обычно содержит UTF-8 текст. Но в принципе может содержать любые байты.

s := "hello"
fmt.Println(len(s)) // 5 — байт
fmt.Println(s[0]) // 104 — byte (uint8), не "h" как символ
fmt.Println(string(s[0])) // "h"
s := "Hello, World!"
// Длина (в байтах!)
len(s) // 13
// Индексация — возвращает byte (uint8), не rune!
s[0] // 72 (byte, ASCII 'H')
// Срез — это новая string (но без копирования, см. ниже)
s[7:12] // "World"
// Конкатенация
s2 := s + " hi"
// Сравнение — по байтам
s == "Hello, World!" // true
s < "Z" // лексикографическое
// Преобразования
b := []byte(s)
r := []rune(s)
back := string(b)
back2 := string(r)
s := "Привет"
// 1. По БАЙТАМ — for i; i++
for i := 0; i < len(s); i++ {
fmt.Printf("%d: %d (%c)\n", i, s[i], s[i])
// выведет 12 итераций, каждый — байт
}
// 2. По РУНАМ — for range
for i, r := range s {
fmt.Printf("%d: %d (%c)\n", i, r, r)
// выведет 6 итераций (по числу рун)
// i — байтовое смещение (0, 2, 4, ...)
// r — rune (int32)
}

⚠️ В for i, r := range s индекс i — это байтовое смещение, а не индекс руны!

s := "Привет"
for i, r := range s {
fmt.Println(i, string(r))
}
// 0 П
// 2 р
// 4 и
// 6 в
// 8 е
// 10 т
import (
"strings" // утилиты: Split, Join, Replace, Contains, Builder
"strconv" // Atoi, Itoa, Quote, ParseFloat
"unicode" // IsLetter, IsDigit, ToLower
"unicode/utf8" // RuneCountInString, DecodeRuneInString
"bytes" // те же утилиты для []byte
)

// runtime/string.go (упрощённо)
type stringStruct struct {
str unsafe.Pointer // указатель на байты
len int // длина в БАЙТАХ
}
// reflect/value.go (deprecated с Go 1.20)
type StringHeader struct {
Data uintptr
Len int
}

Размер на 64-bit: 16 байт (2 × 8).

⚠️ В отличие от SliceHeader, у строки нет cap — строка immutable, ёмкость не нужна.

s := "Привет"
string header (16 байт)
┌──────────────┐
│ ptr ────────┼─────┐
│ len = 12 │ │
└──────────────┘ │
Read-only data segment (например, .rodata):
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ D0 │ 9F │ D1 │ 80 │ D0 │ B8 │ D0 │ B2 │ D0 │ B5 │ D1 │ 82 │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
П р и в е т

⚠️ Строки-литералы обычно живут в read-only сегменте. Попытка модифицировать (через unsafe) приведёт к segfault.

UTF-8 — variable-width кодировка Unicode:

Code pointBytesPattern
U+0000 - U+007F10xxxxxxx
U+0080 - U+07FF2110xxxxx 10xxxxxx
U+0800 - U+FFFF31110xxxx 10xxxxxx 10xxxxxx
U+10000 - U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Примеры:

  • 'A' (U+0041): 1 байт — 0x41
  • 'Я' (U+042F): 2 байта — 0xD0 0xAF
  • '日' (U+65E5): 3 байта — 0xE6 0x97 0xA5
  • '🎉' (U+1F389): 4 байта — 0xF0 0x9F 0x8E 0x89
s := "A日🎉"
fmt.Println(len(s)) // 8 (1+3+4)
fmt.Println(utf8.RuneCountInString(s)) // 3 (количество РУН)

Rune — это Unicode code point, тип int32. Один rune может занимать 1-4 байта в UTF-8.

r := ''
fmt.Printf("%d %x %c\n", r, r, r) // 26085 65e5 日
s := "Hello"
// string → []byte (копирует!)
b := []byte(s)
// []byte → string (копирует!)
s2 := string(b)
// string → []rune (декодирует UTF-8, копирует!)
r := []rune("日本語") // [26085 26412 35486]
// []rune → string (кодирует в UTF-8)
s3 := string([]rune{72, 101, 108, 108, 111}) // "Hello"

Компилятор иногда избегает копирования:

  1. string(b) в map lookup:

    m := map[string]int{"a": 1}
    b := []byte{'a'}
    v := m[string(b)] // НЕ копирует, оптимизация!
  2. []byte(s) в for range:

    s := "hello"
    for _, c := range []byte(s) { // компилятор может не копировать
    _ = c
    }
  3. s += "x" в hot loop — компилятор иногда оптимизирует, но не всегда.

  4. С Go 1.20+ есть unsafe.String и unsafe.StringData / unsafe.SliceData — явный способ без копирования (см. ниже).


s := "Привет"
fmt.Println(len(s)) // 12 (байт)
fmt.Println(utf8.RuneCountInString(s)) // 6 (рун)

⚠️ Самая популярная ошибка джунов: ожидают, что len("Привет") — 6.

s := "Привет"
fmt.Println(s[0]) // 208 (первый байт UTF-8)
fmt.Println(string(s[0])) // "Ð" — мусор!
// Правильно для первого символа:
r, _ := utf8.DecodeRuneInString(s)
fmt.Println(string(r)) // "П"
// Или через []rune (если нужны все):
rs := []rune(s)
fmt.Println(string(rs[0])) // "П"
s := ""
for i := 0; i < len(s); i++ {
fmt.Printf("%d: %d\n", i, s[i])
}
// 0: 230
// 1: 151
// 2: 165
for i, r := range s {
fmt.Printf("%d: %d\n", i, r)
}
// 0: 26085 — одна итерация

Самая каверзная ловушка:

n := 65
s := string(n)
fmt.Println(s) // "A" — это rune 65, не "65"!
s2 := string(72) // "H"

✅ Чтобы получить число как строку:

s := strconv.Itoa(65) // "65"
s2 := fmt.Sprintf("%d", 65)

⚠️ С Go 1.15 go vet ругается на string(int) без явного rune-конверта:

string(rune(65)) // явно

Gotcha 5: Substring — это срез без копирования (memory leak!)

Заголовок раздела «Gotcha 5: Substring — это срез без копирования (memory leak!)»
s := loadBigString() // допустим, 1 GB
small := s[:10] // small ссылается на тот же backing array
s = "" // не помогает! backing array жив через small

✅ Решение — явная копия:

small := string([]byte(s[:10]))
// или strings.Clone (Go 1.18+):
small := strings.Clone(s[:10])
// ПЛОХО: каждая итерация — новая аллокация
var s string
for i := 0; i < 1_000_000; i++ {
s += "x"
}
// ХОРОШО: strings.Builder
var b strings.Builder
b.Grow(1_000_000) // preallocation
for i := 0; i < 1_000_000; i++ {
b.WriteByte('x')
}
s := b.String()

strings.Builder использует []byte под капотом и избегает копирования при финальном String() (через unsafe).

s := "hello"
// s[0] = 'H' // ОШИБКА компиляции: cannot assign to s[0]

Нужно: b := []byte(s); b[0] = 'H'; s = string(b).

Gotcha 8: []byte(s) и string(b) — почти всегда копируют

Заголовок раздела «Gotcha 8: []byte(s) и string(b) — почти всегда копируют»
s := "hello"
b := []byte(s) // КОПИЯ
b[0] = 'H' // не влияет на s
fmt.Println(s) // "hello"

Это сделано специально: чтобы string был immutable (если бы byte slice ссылался на ту же память, можно было бы мутировать строку через unsafe).

Исключения — compiler optimizations (см. выше) и unsafe.String/Slice (Go 1.20+).

r := 'A'
fmt.Printf("%T\n", r) // int32 (rune)
// Можно: r == 65 (untyped constant → конвертируется)
// Нельзя: var i int = 65; r == i // ОШИБКА: разные типы
r == int32(i) // OK
r == rune(i) // OK
int(r) == i // OK

При итерации по невалидному UTF-8 for range подставит utf8.RuneError ('�') — символ замены.

s := string([]byte{0xFF, 0xFE}) // невалидно
for _, r := range s {
fmt.Println(r) // 65533 (RuneError)
}

Стандартный strings.ToUpper использует Unicode default case mapping, не локаль.

strings.ToUpper("straße") // "STRASSE" (особый случай ß → SS)
strings.ToUpper("istanbul") // "ISTANBUL" (но в турецком: İSTANBUL)

Для локализации — golang.org/x/text/cases + language.Turkish.

strings.EqualFold("Hello", "HELLO") // true
"Hello" == "HELLO" // false
// Не используйте ToLower/ToUpper для сравнения:
strings.ToLower("Hello") == strings.ToLower("HELLO") // работает, но 2 аллокации

Gotcha 13: Сравнение строк с unicode-нормализацией

Заголовок раздела «Gotcha 13: Сравнение строк с unicode-нормализацией»
// Эти строки выглядят одинаково, но разные code point:
a := "café" // c-a-f-é (4 руны)
b := "café" // c-a-f-e-(combining acute) (5 рун)
fmt.Println(a == b) // false!

Решение — нормализация через golang.org/x/text/unicode/norm:

import "golang.org/x/text/unicode/norm"
norm.NFC.String(a) == norm.NFC.String(b) // true
var b strings.Builder
b.WriteString("hello")
b2 := b // ⚠️ копирование запрещено (warning от go vet)
b2.WriteString(" world") // может вызвать panic

Builder содержит указатель и проверяет, не был ли он скопирован. Используйте *strings.Builder для передачи.

import "unsafe"
s := "hello"
// string → []byte без копирования (опасно: нельзя модифицировать!)
b := unsafe.Slice(unsafe.StringData(s), len(s))
// []byte → string без копирования
b2 := []byte{'h', 'i'}
s2 := unsafe.String(&b2[0], len(b2))
// ⚠️ Если потом изменить b2, изменится и s2 — что нарушает immutability!

✅ Используйте только в hot path, после бенчмарков, понимая риски.

Gotcha 16: Хеш строк рандомизирован при старте

Заголовок раздела «Gotcha 16: Хеш строк рандомизирован при старте»
// Hash для одной и той же строки в разных запусках программы — разный
// Это защита от hash flood DoS (см. maps)

Поэтому не сериализуйте hash строки между запусками.

s1 == s2 — это сравнение по байтам, O(n) в худшем случае. Сначала Go проверяет длину (O(1)), потом байты.

// "abc" == "abc" → сначала len: 3==3, потом memcmp

Бенчмарк показывает 100-1000x разницу для длинных строк:

// O(n²)
var s string
for _, w := range words {
s += w
}
// O(n)
var b strings.Builder
for _, w := range words {
b.WriteString(w)
}
return b.String()

Builder.String() использует unsafe для избежания копии при возврате.

4.2 strings.Join — самый быстрый для известного списка

Заголовок раздела «4.2 strings.Join — самый быстрый для известного списка»
s := strings.Join(words, "")

Под капотом — preallocate точного размера + memcpy. Лучший вариант, когда все слова известны заранее.

var b strings.Builder
b.Grow(1024) // если знаете примерный размер
for ... { b.WriteString(...) }

[]byte мутабельный → меньше аллокаций.

// Если работаете с большими буферами:
buf := make([]byte, 0, 4096)
for ... {
buf = append(buf, data...)
}
return string(buf) // ОДНА копия в конце
strconv.Itoa(123) // быстро, no reflection
fmt.Sprintf("%d", 123) // медленнее, reflection

Разница 5-10x. В hot path используйте strconv.

Если данные гарантированно ASCII (например, JSON keys), итерация по байтам быстрее range:

// Быстрее (если ASCII):
for i := 0; i < len(s); i++ {
if s[i] == '"' { ... }
}
// Медленнее (декодирует UTF-8):
for _, r := range s {
if r == '"' { ... }
}
// Эффективная замена для SplitN(s, sep, 2):
before, after, found := strings.Cut("key=value", "=")

Без аллокации slice результата.

Если у вас миллионы повторяющихся строк (например, имена полей JSON), интернирование экономит память:

var interner = map[string]string{}
func Intern(s string) string {
if v, ok := interner[s]; ok { return v }
interner[s] = s
return s
}

С Go 1.23 появилось unique.Handle[T] для типобезопасного interning.


A: Immutable последовательность байт. Под капотом — header {ptr, len} (16 байт на 64-bit). Обычно содержит UTF-8 текст, но может — любые байты.

A: 12. Это длина в байтах. В русском (Cyrillic) каждая буква — 2 байта в UTF-8. Для количества символов: utf8.RuneCountInString(s) или len([]rune(s)).

A:

  • byte — алиас uint8, 1 байт, обычно для сырых данных или ASCII.
  • rune — алиас int32, 4 байта, Unicode code point.
s := ""
fmt.Println(s[0])

A: 230. Это первый байт UTF-8 представления символа 日 (0xE6 = 230).

s := "Hello"
fmt.Println(string(s[0]))

A: "H". Здесь s[0] — byte (72), string(72) интерпретирует как rune 72, что есть 'H'.

for i := 0; i < len(s); i++ {} // ?
for i, r := range s {} // ?

A:

  • for i; i++ — по байтам, s[i] — byte.
  • for range — по рунам, r — rune, i — байтовое смещение начала руны.

A:

  • Безопасность: можно безопасно делиться без блокировок.
  • Оптимизация: substring — без копирования (просто header указывает в тот же memory).
  • Можно хранить в read-only сегменте бинарника.
  • Хеш можно кэшировать.

A: Почти всегда. Это сделано специально, чтобы immutable string не мутировался через mutable []byte. Compiler optimization избегает копирования в специфичных случаях (map lookup, for-range).

A: Variable-width кодировка Unicode. ASCII (0-127) — 1 байт. Расширенные — 2-4 байта. Самосинхронизируется (можно начать читать с любого байта и понять, где границы рун).

fmt.Println(string(65))

A: "A". Это rune conversion: 65 интерпретируется как Unicode code point. С Go 1.15 go vet ругается без явного string(rune(65)).

A:

strconv.Itoa(123) // "123"
fmt.Sprintf("%d", 123) // "123"
// НЕ: string(123) // "{" — code point 123

A:

  • += создаёт новую строку каждый раз → O(n²).
  • Builder использует []byte под капотом, амортизированный O(1) на запись.
  • Builder.String() возвращает результат без копирования (unsafe).

Q13: Можно ли получить адрес символа в строке?

Заголовок раздела «Q13: Можно ли получить адрес символа в строке?»

A: Нет, &s[0] — ошибка. Строки immutable, доступ только для чтения. Через unsafe.StringData(s) (Go 1.20+) — можно, но мутировать нельзя.

A: Константа utf8.RuneError = '�' (символ замены). Возвращается при декодировании невалидного UTF-8.

Q15: Как сравнить две строки без учёта регистра?

Заголовок раздела «Q15: Как сравнить две строки без учёта регистра?»

A: strings.EqualFold(a, b). Лучше, чем ToLower(a) == ToLower(b) (нет аллокаций, обрабатывает Unicode-edge cases).

A: Да, лексикографическое сравнение по байтам. UTF-8 устроен так, что byte-order сравнение совпадает с code point order.

s := "café"
fmt.Println(len(s))

A: 5. c, a, f — по 1 байту, é — 2 байта в UTF-8 (U+00E9 = 0xC3 0xA9).

A: Substring — это header, указывающий на ту же память. Если оригинальная строка большая, держа substring, мы держим всю память. Решение: strings.Clone(substr).

b := []byte("hello")
b[0] = 'H'
s := string(b)
fmt.Println(s)

A: "Hello". []byte мутабельный.

A: Go 1.18+: разделяет строку по первому вхождению separator. Возвращает before, after, found. Эффективная замена для SplitN(s, sep, 2).

before, after, ok := strings.Cut("key=value", "=")
// before="key", after="value", ok=true

A:

  • Когда нужна мутабельность.
  • В I/O — io.Reader/io.Writer работают с []byte.
  • В hot path, чтобы избежать копирования при []byte(s).
  • Для бинарных данных (картинки, networking).

A: 16 байт на 64-bit (ptr 8 байт + len 8 байт). У строки нет capacity (в отличие от slice).

A: Unicode нормализация. NFC — composed (одна руна на символ когда возможно). NFD — decomposed (комбинируемые символы). Без нормализации “café” может быть представлено двумя способами, и они != по байтам.

Q24: Как корректно итерировать с обоими индексом руны и значением?

Заголовок раздела «Q24: Как корректно итерировать с обоими индексом руны и значением?»

A:

for i, r := range s { // i — байтовое смещение!
_ = i; _ = r
}
// Если нужен ИНДЕКС руны:
ri := 0
for _, r := range s {
_ = ri; _ = r
ri++
}
// Или сначала []rune:
runes := []rune(s)
for i, r := range runes { ... }
var b strings.Builder
b.WriteString("Hello")
b.WriteRune(' ')
b.WriteString("World")
fmt.Println(b.String())

A: "Hello World".


Перевернуть строку с учётом Unicode (НЕ просто байты).

func Reverse(s string) string {
// ???
}

Решение:

func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// Тест:
Reverse("Привет") // "тевирП" — правильно
// Если бы делали через []byte — получили бы мусор

Посчитайте количество рун в строке тремя способами.

Решение:

s := "Привет"
// 1.
n1 := utf8.RuneCountInString(s) // 6
// 2.
n2 := len([]rune(s)) // 6 (но аллокация!)
// 3.
n3 := 0
for range s { n3++ } // 6, без аллокаций

Самый быстрый — №1 или №3 (без аллокаций).

Соедините 1М строк в одну, время покажет разницу.

words := makeWords(1_000_000)
// БЫСТРО:
var b strings.Builder
b.Grow(estimatedSize(words))
for _, w := range words {
b.WriteString(w)
}
result := b.String()
// ИЛИ:
result := strings.Join(words, "")
// МЕДЛЕННО:
var s string
for _, w := range words {
s += w
}

Проверить, является ли строка палиндромом (с поддержкой Unicode, case-insensitive).

Решение:

import "unicode"
func IsPalindrome(s string) bool {
runes := []rune(s)
i, j := 0, len(runes)-1
for i < j {
// Пропускаем не-буквы
for i < j && !unicode.IsLetter(runes[i]) { i++ }
for i < j && !unicode.IsLetter(runes[j]) { j-- }
if unicode.ToLower(runes[i]) != unicode.ToLower(runes[j]) {
return false
}
i++; j--
}
return true
}
IsPalindrome("А роза упала на лапу Азора") // true
IsPalindrome("racecar") // true
IsPalindrome("hello") // false
func ParseCSVRow(row string) []string {
var fields []string
var b strings.Builder
for i := 0; i < len(row); i++ {
c := row[i]
if c == ',' {
fields = append(fields, b.String())
b.Reset()
} else {
b.WriteByte(c)
}
}
fields = append(fields, b.String())
return fields
}

(Упрощённая версия без поддержки кавычек и escape.)

s := "Hello"
b := []byte(s)
b[0] = 'h'
fmt.Println(s, string(b))

Решение: Hello hello. []byte(s) — это копия, мутация b не влияет на s.


  1. Go Blog — “Strings, bytes, runes and characters in Go” (Rob Pike) — https://go.dev/blog/strings — must-read.
  2. Russ Cox — “Strings, bytes, runes and characters” — детали реализации.
  3. The Go Programming Language Specification — Stringshttps://go.dev/ref/spec#String_types
  4. unicode/utf8 packagehttps://pkg.go.dev/unicode/utf8 — RuneCountInString, DecodeRune.
  5. strings packagehttps://pkg.go.dev/strings — Builder, Cut, EqualFold.
  6. unsafe.String/Slice (Go 1.20)https://pkg.go.dev/unsafe#String — zero-copy conversion.
  7. Habr — “Строки в Go: байты, руны и UTF-8” — поиск свежих обзоров 2024-2025.
  8. runtime/string.gohttps://github.com/golang/go/blob/master/src/runtime/string.go — исходники.
  9. “100 Go Mistakes and How to Avoid Them” (Teiva Harsanyi) — разделы про strings.
  10. golang.org/x/text/unicode/norm — для NFC/NFD нормализации.

Строка в Go — это байтовый view. Помнить про UTF-8, immutability и compiler magic при конвертациях — и собес по строкам становится тривиальным.