Skip to main content

Что такое строка в Go? Изменяемая ли она?

Строка в Go — это неизменяемая последовательность байт. Не символов, а именно байт. Чаще всего эти байты представляют текст в кодировке UTF-8, но Go никак не проверяет это — в строку можно положить вообще любые байты, включая «битый» текст или бинарные данные.

s := "Hello"      // строка из текста
b := "\x00\x01\xFF"  // строка из произвольных байт — тоже валидно

Как строка устроена под капотом

Внутри Go строка — это структура из двух полей:

type stringHeader struct {
    Data unsafe.Pointer  // указатель на байты в памяти
    Len  int             // длина в байтах
}

То есть переменная типа string сама по себе занимает 16 байт на 64-битной системе (8 байт на указатель + 8 байт на длину), а сами байты строки лежат где-то в памяти отдельно.

Визуально:

s := "Hello"

s ─── ┌──────────────────────┐         ┌─────────────────┐
      │ Data: 0xc000010050  ─┼────────▶│ H │ e │ l │ l │ o │
      │ Len:  5              │         └─────────────────┘
      └──────────────────────┘             (5 байт в памяти)

Это важный момент: когда ты «передаёшь строку», ты передаёшь только эту маленькую структуру (16 байт) — сами данные не копируются.

Неизменяемость

Строки в Go нельзя изменить после создания. Это главная их особенность.

s := "hello"
s[0] = 'H'   // ❌ ошибка компиляции: cannot assign to s[0]

Сообщение компилятора:

cannot assign to s[0] (strings are immutable)

Что можно делать — это присвоить переменной новую строку:

s := "hello"
s = "Hello"   // ✅ это работает — мы поменяли, на какую строку указывает s
              //   старая строка "hello" остаётся неизменной (и удалится сборщиком мусора)

⚠️ Не путай: «переменную можно переприсвоить» и «строку нельзя изменить» — разные вещи. Сама строка как набор байт в памяти неизменяема. Меняется только то, на какую строку указывает переменная.

Что произойдёт при «изменении»

s := "hello"
s = s + " world"   // создалась НОВАЯ строка "hello world"
                   // старая "hello" — отдельный объект в памяти

Каждая операция, которая «меняет» строку, на самом деле создаёт новую строку.

Зачем сделано неизменяемым

1. Безопасность в многопоточном коде

Несколько горутин могут читать одну и ту же строку одновременно — никаких гонок данных, никаких мьютексов. Это огромное преимущество для конкурентного кода.

s := "config"
go func() { fmt.Println(s) }()  // безопасно
go func() { fmt.Println(s) }()  // безопасно

2. Дешёвое копирование и передача

Когда ты передаёшь строку в функцию, копируется только заголовок (16 байт), а не сами байты:

func process(s string) {  // копируется только структура {Data, Len}
    ...                   // сами байты остаются на одном месте в памяти
}

bigString := strings.Repeat("a", 10_000_000)  // 10 МБ
process(bigString)  // дёшево — копируется 16 байт, не 10 МБ

3. Можно безопасно использовать строки как ключи мап

Если строки можно было бы менять, ключи в map[string]int могли бы измениться после вставки, и мапа сломалась бы. Неизменяемость гарантирует целостность.

4. Возможность шаринга подстрок

Когда ты делаешь срез строки s[1:4], новая строка использует те же самые байты в памяти — просто с другим указателем и длиной. Никакого копирования, потому что менять данные всё равно нельзя.

s := "hello world"
sub := s[6:]  // "world" — указывает в ту же память, что и s, со смещением 6
s   ─▶ Data: 0xc0000a0000, Len: 11   ─┐
                                       │
                                       ▼
                                 ┌──────────────────────────┐
                                 │ h e l l o   w o r l d    │
                                 └──────────────────────────┘
                                              ▲
                                              │
sub ─▶ Data: 0xc0000a0006, Len: 5    ────────┘

Что можно делать со строкой

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

s := "hello"
fmt.Println(s[0])           // 104 — байт со значением 'h'
fmt.Printf("%c\n", s[0])    // h — тот же байт, отформатированный как символ

⚠️ Помни из прошлого вопроса: s[0] — это байт, а не символ. Для русских строк по индексу получишь не букву, а половину буквы:

s := "Привет"
fmt.Println(s[0])  // 208 — это первый байт буквы П, а не сама П

Конкатенация (склейка)

a := "hello"
b := "world"
c := a + " " + b  // создаётся новая строка "hello world"

Срез (срезание подстроки)

s := "hello world"
sub := s[0:5]   // "hello"
sub2 := s[6:]   // "world"
sub3 := s[:5]   // "hello"

⚠️ Срез работает по байтам. Для UTF-8 строк с русским текстом нужно сначала преобразовать в []rune, иначе можно отрезать половину символа:

s := "Привет"
fmt.Println(s[:3])  // "П�" — мусор, потому что отрезали середину буквы 'р'

Перебор

s := "hello"

// Перебор по байтам
for i := 0; i < len(s); i++ {
    fmt.Println(s[i])  // байт за байтом
}

// Перебор по символам (Go сам декодирует UTF-8)
for i, r := range s {
    fmt.Printf("позиция %d: символ %c\n", i, r)
}

Длина

s := "Привет"
fmt.Println(len(s))                       // 12 — длина в БАЙТАХ
fmt.Println(utf8.RuneCountInString(s))    // 6  — длина в СИМВОЛАХ

Сравнение

Строки можно сравнивать обычными операторами — побайтно, в лексикографическом порядке:

"apple" == "apple"   // true
"apple" < "banana"   // true
"abc" < "abcd"       // true

Что нельзя делать

s := "hello"

s[0] = 'H'                  // ❌ изменить байт
s = append(s, 'X')          // ❌ append не работает со string (только с []byte)

Как «изменить» строку — через преобразование

Если действительно нужно поменять отдельные байты или символы, нужно преобразовать строку в []byte или []rune, изменить, и преобразовать обратно:

Через []byte — для ASCII

s := "hello"
b := []byte(s)    // создаётся КОПИЯ байтов в новом слайсе
b[0] = 'H'
s = string(b)     // обратно в строку (тоже создаётся новая строка)
fmt.Println(s)    // Hello

Через []rune — для Юникода

s := "привет"
r := []rune(s)    // создаётся слайс символов
r[0] = 'П'
s = string(r)
fmt.Println(s)    // Привет

⚠️ Каждое преобразование string → []byte → string или string → []rune → string создаёт копию в памяти. На больших строках это дорого.

Эффективное построение строк

Поскольку каждая операция + создаёт новую строку, в цикле это очень неэффективно:

// ❌ Очень медленно для больших объёмов
s := ""
for i := 0; i < 10000; i++ {
    s += "x"   // каждый раз создаётся НОВАЯ строка длиннее предыдущей
               // итого: O(n²) операций
}

Для эффективной склейки используют strings.Builder:

import "strings"

var b strings.Builder
for i := 0; i < 10000; i++ {
    b.WriteString("x")   // дописываем в буфер, без создания промежуточных строк
}
s := b.String()          // готовая строка в конце

Внутри strings.Builder использует []byte и растёт как обычный слайс, поэтому это примерно в 100-1000 раз быстрее на больших объёмах.

Полезные функции из стандартной библиотеки

Все они возвращают новые строки, не меняя исходную:

import "strings"

strings.ToUpper("hello")              // "HELLO"
strings.ToLower("HELLO")              // "hello"
strings.Replace("hello", "l", "L", -1) // "heLLo"
strings.Split("a,b,c", ",")           // ["a", "b", "c"]
strings.Join([]string{"a", "b"}, "-") // "a-b"
strings.Contains("hello", "ell")      // true
strings.TrimSpace("  hi  ")           // "hi"
strings.Repeat("ab", 3)               // "ababab"

Важные практические следствия

1. Срез строки удерживает память исходной строки

Поскольку срез использует тот же массив байт, большая строка не освободится сборщиком мусора, пока существует хотя бы один срез от неё:

func getName(filePath string) string {
    fileContent := readHugeFile(filePath)  // строка на 100 МБ
    return fileContent[:10]                // вернули "первые 10 байт"
                                           // НО: вся строка на 100 МБ продолжает жить в памяти!
}

Решение — явно скопировать:

func getName(filePath string) string {
    fileContent := readHugeFile(filePath)
    return string([]byte(fileContent[:10]))   // копия — оригинал может освободиться
}

2. Преобразование string ↔ []byte копирует данные

s := "hello"
b := []byte(s)     // копия 5 байт
s2 := string(b)    // ещё одна копия 5 байт

На горячих участках кода (например, веб-сервер с миллионом запросов в секунду) это заметно. Иногда оптимизируют через пакет unsafe, но это редкая необходимость.

3. Сравнение через == сравнивает содержимое, а не адреса

В отличие от Java/JavaScript, в Go == для строк сравнивает байты, а не ссылки:

a := "hello"
b := "hello"
fmt.Println(a == b)  // true — сравнение по содержимому, не по адресу

Краткое резюме

Что такое строка в Go:

Изменяемость:

Зачем неизменяемость: