Строки, руны и байты: почему len не считает символы, а подстрока держит мегабайты
Строка в Go — это заголовок из указателя и длины над неизменяемыми байтами. Отсюда всё остальное: len даёт байты, s[i] даёт байт, range даёт руны с байтовыми смещениями, подстрока бесплатна и потому удерживает весь исходный массив. Замерено: десять байт держат восемь мегабайт, а склейка тысячи кусков через += дороже Builder в 109 раз.
Полное техническое изложение
TL;DR
Строка в Go — это последовательность байтов, а не символов. Текст хранится
в кодировке UTF-8, где один знак занимает от одного до четырёх байтов, — поэтому
длина строки считает байты, обращение по индексу даёт байт, а for range идёт
по кодовым точкам. На чисто латинском тексте все три совпадают, и ровно поэтому
ошибка этой темы живёт в коде до первого нелатинского ввода.
Отсюда то, что выглядит не так, как ждут. У "A世🙂" восемь байт при трёх
рунах: s[0] = 65 и это вся буква «A», а s[1] = 228 и это треть
иероглифа — первый случай работает случайно и потому маскирует второй. range
отдаёт байтовые смещения 0 1 4, а не 0 1 2. Нарезка тоже режет по байтам:
s[:2] разрубает иероглиф пополам, получается строка, некорректная как UTF-8, —
и ни падения, ни предупреждения. И руна — не символ: у é в составной форме
три байта, две руны и один видимый знак, у семьи "👩👩👧" —
восемнадцать байт, пять рун, один знак; разбиения на графемы в
стандартной библиотеке нет вовсе.
Дальше — устройство, числа и границы. Строка — это заголовок из указателя
и длины (16 байт) над неизменяемыми байтами, и из неизменяемости
выводятся и бесплатная подстрока, и квадратичная склейка. Подстрока не
копирует ничего — и ровно поэтому удерживает весь исходный массив:
замерено, что десять байт держат 8 МБ, пока не сделан strings.Clone. В
go1.24 len([]rune(s)) не выделяет память — компилятор узнаёт это
выражение, так что совет «замените на utf8.RuneCountInString» описывает
компилятор, которого давно нет; платить приходится там, где срез сохраняют:
×3,4 и одно выделение. По той же причине бесплатна и часть конвертаций
[]byte → string: m[string(b)], string(b) == s, range string(b) —
ноль выделений. А склейка в цикле квадратична: на одной машине кратность
против Builder росла с числом кусков ×2,3 → ×23,3 → ×111,8, — но на
двух-трёх кусках a + b + c быстрее.
- текст в памяти хранится числами: байт — это число от 0 до 255;
- знаки бывают не только латинские, и в одном тексте они встречаются вперемешку;
- у строки берут длину, отрезают от неё кусок по номерам и проходят по ней циклом.
- UTF-8, кодовая точка, руна, графемный кластер;
utf8.RuneCountInString,strings.Builder,strings.Clone, символ заменыU+FFFD;- из чего сделана сама строковая переменная и почему подстрока не копирует данные.
Что здесь на самом деле спрашивают
Тема выглядит разминочной, и в этом ловушка: вопросы почти всегда идут парами, где второй проверяет, понята ли причина первого.
- «Что вернёт
len(s)для строки с иероглифами?» — и сразу: «а почему?» - «Что такое руна?» — и: «руна и символ — это одно и то же?»
- «Почему строки неизменяемы?» — вопрос не про безопасность, а про подстроки.
- «Сколько стоит
s[2:5]?» — и: «что при этом остаётся в памяти?» - «Чем
+=в цикле плох?» — и: «а на трёх кусках тоже?»
Спина у темы одна, и её стоит проговорить вслух: строка — это заголовок над неизменяемым куском байтов. Неизменяемость делает подстроку бесплатной, бесплатная подстрока удерживает массив, а неизменяемость же делает склейку квадратичной. Три разных вопроса — одно свойство.
База: строка — это байты
Строка хранит текст, а память умеет хранить только числа. Значит, между текстом и памятью стоит договорённость: каким числом записывается какой знак. Такая договорённость называется кодировкой, и в Go она одна — UTF-8.
Устроена она неравномерно: частые знаки записываются одним байтом, остальные — двумя, тремя или четырьмя. Латинская буква занимает один байт, иероглиф — три, эмодзи — четыре. Номер, который кодировка присваивает знаку, называется кодовой точкой; в Go у него есть своё имя — руна.
Возьмём строку из трёх знаков разной длины — "A世🙂" — и пройдём с ней весь
вход в тему.
Длина возвращает байты. Знаков три, а длина — восемь: один плюс три плюс четыре. Длина строки в Go — не число знаков, а место, которое текст занимает.
Обход идёт по знакам. for range по строке шагает по кодовым точкам и
выдаёт «A», «世» и «🙂» — три шага, а не восемь. Но номер, который он выдаёт
вместе со знаком, — это байтовое смещение начала знака: 0, 1 и 4.
Обращение по индексу даёт байт. s[0] — это не первый знак, а первый байт:
65, и он же оказывается целой буквой «A», потому что она однобайтовая. А s[1]
— 228, и это лишь первая треть иероглифа: остальные две трети лежат в
s[2] и s[3].
Вот и весь вход. Три ответа на вопрос «сколько тут всего» — восемь байтов, три знака, восемь позиций для индекса, — и ни один из них не «настоящее» число строки: правильный зависит от того, зачем спрашивают.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования: длина строки в Go считает байты, а не символы, потому что текст хранится в UTF-8, где знак занимает от одного до четырёх байтов. Всё дальнейшее — про то, где эта разница режет: почему руна всё ещё не равна видимому знаку, как срез строки разрубает знак пополам и что следует из того, из чего сделана сама строковая переменная.
Механизм 1: одна строка, три системы отсчёта
Ту же строку — "A世🙂" — урок проносит через все механизмы. Она выбрана так,
чтобы покрыть все три длины UTF-8 сразу: латинская буква занимает один
байт, иероглиф три, эмодзи четыре.
Это не педантизм. На чисто латинской строке ни одна ошибка этой темы не
проявляется: len совпадает с числом символов, s[0] даёт первый символ,
индексы range идут подряд. Ровно поэтому такие ошибки живут в коде до первого
нелатинского ввода — и всплывают в проде, а не в тестах.
Прогон bench/gostring/practice.go печатает:
8 len(s) — байты
3 utf8.RuneCountInString(s) — руны
65 s[0] — байт, и он же целиком буква «A»
228 s[1] — байт, и он ПОЛОВИНА иероглифа
3 len([]rune(s))
0 1 4 байтовые смещения из range
1 len(string(s[0]))
2 len(string(s[1]))
Разберём то, что здесь важно, по трём пунктам.
len считает байты. Восемь против трёх — это одна и та же строка в двух
системах отсчёта, и ни одна из них не «настоящая». Правильный ответ на вопрос
«сколько тут символов» зависит от того, зачем спрашивают.
s[i] — это byte, а не символ. И вот пара, ради которой строка выбрана
такой: s[0] даёт 65, и это действительно вся буква «A»; s[1] даёт 228, и это
первый байт из трёх. Первый случай работает случайно — и потому маскирует
второй.
Отсюда же две последние строки вывода. string(s[i]) не декодирует байт:
это конвертация числа в руну с таким кодом. Из 65 выходит «A» длиной 1 байт
— верно по совпадению. Из 228 выходит «ä» длиной 2 байта — символ, не имеющий к
исходной строке никакого отношения.
range отдаёт байтовые смещения: 0 1 4, а не 0 1 2. Это прямо в
спецификации:
For a string value, the "range" clause iterates over the Unicode code points in
the string starting at byte index 0. On successive iterations, the index value
will be the index of the first byte of successive UTF-8-encoded code points.
Для строкового значения выражение „range“ перебирает кодовые точки Unicode в строке начиная с байтового индекса 0. На последующих итерациях индексом будет индекс первого байта очередной кодовой точки, закодированной в UTF-8.
Практическое следствие: индекс из range годится для нарезки строки
(s[i:]), но не годится как номер символа. Счётчик символов заводят
отдельной переменной.
Механизм 2: байты, руны и графемы — три разных числа
«Руна — это символ» — самое живучее упрощение темы, и его стоит разрушить сразу,
пока оно не встроилось в решения. Прогон bench/gostring/internals.go:
| строка | байт | рун | видимых знаков |
|---|---|---|---|
"A世🙂" | 8 | 3 | 3 |
"é" готовая форма | 2 | 1 | 1 |
"é" составная форма | 3 | 2 | 1 |
"👩👩👧" семья | 18 | 5 | 1 |
Две средние строки — один и тот же знак на экране и разные числа рун. é
существует в двух формах: готовой (U+00E9) и составной (e плюс
комбинирующий акут U+0301). Значит, руна не равна символу, и счётчик рун
отвечает на вопрос «сколько символов» верно только тогда, когда текст заведомо в
готовой форме, — а текст из внешнего мира этого не гарантирует.
Последняя строка доводит различие до предела: восемнадцать байт, пять рун, один видимый знак. Пять рун — это три эмодзи и два соединителя нулевой ширины.
То, что человек называет символом, называется графемным кластером, и
разбиения на кластеры в стандартной библиотеке нет вовсе. Это и есть
содержательный ответ на собеседовании: «посчитать символы» в общем случае
решается отдельным пакетом, а len и []rune отвечают на два других вопроса.
Три уровня стоит держать в голове именно как три:
байты единица хранения len(s)
руны кодовые точки Unicode utf8.RuneCountInString(s)
графемы то, что видит человек отдельный пакет
Механизм 3: нарезка режет по байтам
Индексы среза строки — байтовые, и это значит, что s[:n] с произвольным
n способно разрубить последовательность UTF-8 пополам. Прогон:
| выражение | результат | корректный UTF-8 | рун |
|---|---|---|---|
s[:1] | "A" | true | 1 |
s[:2] | "A\xe4" | false | 2 |
s[:4] | "A世" | true | 2 |
s[:8] | "A世🙂" | true | 3 |
Вторая строка — разрубленный иероглиф. Программа не упала и ничего не сообщила: строка в Go — последовательность произвольных байтов, и некорректный UTF-8 в ней законен.
Обратите внимание на счётчик рун в этой строке: он насчитал две руны там,
где вторая не существует. Обход подставляет вместо каждого некорректного байта
символ замены U+FFFD — то есть повреждение превращается не в ошибку, а в
вопросительный знак в ромбике, который заметят пользователи.
Практический вывод узкий и частый: обрезать текст по длине байтовым срезом
нельзя. Превью на сто символов, отрезанное как s[:100], рано или поздно
разрубит символ. Резать надо по границам, которые даёт range, — он идёт ровно
по началам рун.
Механизм 4: что лежит в переменной — и почему подстрока бесплатна
Замер печатает размеры заголовков:
| размер | |
|---|---|
string | 16 байт (указатель + длина) |
[]byte | 24 байта (указатель + длина + ёмкость) |
Восьми байтов разницы хватает, чтобы объяснить половину темы: у строки нет ёмкости, потому что дописывать в неё нельзя.
A string value is a (possibly empty) sequence of bytes. Strings are immutable:
once created, it is impossible to change the contents of a string.
Строковое значение — это (возможно, пустая) последовательность байтов. Строки неизменяемы: после создания изменить содержимое строки невозможно.
Отсюда сразу: s[0] = 'x' не компилируется, и это не ограничение из
осторожности. Записать в строку — значит испортить все подстроки, которые
смотрят в тот же массив, а смотрят они в него именно потому, что строка
неизменяема. Свойство держит само себя.
Подстрока бесплатна — и поэтому дорога
Вот пара, которую и хотят услышать вместе.
Подстрока s[10:20] — ноль выделений: новый заголовок указывает внутрь того
же массива. И ровно поэтому:
| остаётся в куче | |
|---|---|
| строка на 8 МБ создана | 8,0 МБ |
| оставили из неё подстроку в 10 байт | 8,0 МБ |
после strings.Clone | 0,0 МБ |
Десять байт удерживают восемь мегабайт. Сборщик собирает массив целиком или не собирает вовсе; ссылка внутрь массива держит его весь.
Где это встречается на самом деле: разобрали большой ответ сервиса, вынули из него идентификатор, положили в долгоживущий кеш — и ответ остался в памяти вместе с идентификатором. Симптом характерный: память растёт, а по числу объектов ничего не видно.
Clone returns a fresh copy of s. It guarantees to make a copy of s into a new
allocation, which can be important when retaining only a small substring of a
much larger string.
Clone возвращает свежую копию s. Он гарантирует копирование s в новое выделение памяти, что может быть важно, когда сохраняется лишь небольшая подстрока гораздо большей строки.
Правило, которое из этого следует, узкое: strings.Clone нужен только там,
где короткий кусок переживает длинную строку. Клонировать всё подряд — значит
платить копированием за задачу, которой нет.
Механизм 5: склейка в цикле — почему это не про скорость операции
Прежде чем смотреть на числа, стоит вывести ответ из того, что уже сказано.
Строка неизменяема — это первое утверждение урока. Значит, «дописать к
строке» невозможно в принципе: каждый += строит новую строку и копирует в
неё всё накопленное. На k-м шаге копируется k кусков, а всего за n шагов —
сумма от 1 до n, то есть работа квадратична.
Предсказание отсюда однозначное: кратность против Builder должна расти
вместе с числом кусков. Именно рост, а не сама кратность: одно число ничего не
скажет, потому что зависит от длины цикла.
| кусков | s += x | Builder | Builder+Grow | кратность |
|---|---|---|---|---|
| 10 | 474 нс | 208 нс | 71 нс | ×2,3 |
| 100 | 23 938 нс | 1 028 нс | 502 нс | ×23,3 |
| 1000 | 1 674 914 нс | 14 976 нс | 4 279 нс | ×111,8 |
Предсказание подтвердилось: ×2,3 → ×23,3 → ×111,8. Растущая кратность и есть подпись квадратичной работы; если бы работа была линейной, столбец стоял бы на месте.
Выделений на тысяче кусков: 999 против 15 у
Builder и 1 у Builder с Grow.
Builder пишет в растущий буфер и отдаёт его строкой без копии; Grow убирает
и промежуточные расширения, когда итоговый размер известен заранее.
И оговорка, без которой правило вредит. На двух-трёх кусках a + b + c
быстрее Builder и читается лучше: компилятор склеивает такое одним
вызовом. Builder заводят там, где кусков много и их число заранее
неизвестно — то есть в цикле.
Глубже: конвертации — что копируется, а что нет
Здесь начинается другой сорт утверждений, и разницу стоит проговорить.
Контракт говорит только, что string и []byte — разные типы; всё остальное
ниже — про то, что делает нынешний компилятор, и про числа, снятые на
конкретной машине.
Здесь у темы самое устаревшее общее место.
Первое. len([]rune(s)) — выражение, которое советуют заменять на
utf8.RuneCountInString, — не выделяет памяти. Замер:
| время | выделений | |
|---|---|---|
utf8.RuneCountInString(s) | 98,83 нс | 0 |
len([]rune(s)) | 104,26 нс | 0 |
r := []rune(s), срез сохранён | 333,39 нс | 1 |
Компилятор узнаёт форму len([]rune(s)) и считает руны на месте, среза не
строя. Совет, повторяемый в статьях, описывает компилятор, которого давно нет.
Платить приходится там, где срез действительно нужен — когда его сохраняют
и индексируют.
Второе. Часть конвертаций []byte → string бесплатна:
| выделений | |
|---|---|
m[string(b)] — поиск в карте | 0 |
string(b) == s — сравнение | 0 |
for range string(b) | 0 |
s := string(b) — строка сохранена | 1 |
Правило простое: копия нужна тогда, когда строка переживает выражение. Если
она нужна только внутри одного выражения и наружу не попадает, компилятор её не
строит. Отсюда практический вывод: искать в map[string]T по ключу из
[]byte можно прямо, без unsafe и без ручных обходов.
Как отвечать на собеседовании
Короткий ответ: строка в Go — это последовательность байтов в кодировке
UTF-8, а не последовательность символов. Поэтому длина считает байты,
обращение по индексу даёт байт, а for range идёт по кодовым точкам и отдаёт
байтовые смещения. Вторым предложением добавьте устройство: строка — это
указатель и длина над неизменяемыми байтами, и из неизменяемости выводится
и бесплатная подстрока, и квадратичная склейка.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Про len отвечайте числом и причиной. «Байты. У "A世🙂" — восемь при
трёх рунах: буква занимает один байт, иероглиф три, эмодзи четыре».
Про s[i] скажите тип и приведите обе половины. «byte. У той же строки
s[0] — это 65 и вся буква «A», а s[1] — 228 и треть иероглифа. И
string(s[1]) даёт не иероглиф, а постороннее «ä»: это конвертация числа в
руну с таким кодом, а не декодирование байта».
Про символы разведите три уровня. «Байты, руны и графемы — три разных
числа. é в составной форме — две руны при одном видимом знаке, семья из
эмодзи — пять рун при одном. Разбиения на графемы в стандартной библиотеке
нет».
Про руну добавьте вторую половину. «Руна — кодовая точка, но не символ на
экране: é может быть двумя рунами, и тогда счётчик покажет два при одном
видимом знаке».
Про подстроку называйте обе стороны сразу. «Ноль выделений — и удержание
всего массива. У меня десять байт держали восемь мегабайт; лечится
strings.Clone».
Про склейку говорите о росте, а не о кратности. «Квадратично: каждый +=
копирует накопленное. На тысяче кусков вышло ×109 против Builder. Но на двух-
трёх кусках плюс быстрее».
Дальше спросят
Чем []byte(s) отличается от unsafe-конвертации?
[]byte(s) копирует — и обязан: срез изменяем, а строка нет. Без копии
запись в срез меняла бы строку, на которую могут смотреть другие подстроки.
unsafe.String и unsafe.Slice (Go 1.20) позволяют сделать то же без копии, и
именно поэтому опасны: получившийся срез смотрит в память строки, и любая
запись в него — неопределённое поведение. Их законная область — горячий путь,
где вы точно знаете, что данные только читаются. В ответе на собеседовании
важно назвать не приём, а условие его применимости.
Как правильно перевернуть строку?
Вопрос-ловушка, и правильный ответ начинается со встречного: перевернуть
что? Байты переворачивать нельзя — UTF-8 сломается. Руны перевернуть можно
через []rune, и это то, что имеют в виду в 99% случаев.
Но и это не «перевернуть строку»: комбинирующие знаки отвяжутся от своих
базовых символов, и é из двух рун превратится в акут над соседней буквой.
Полностью корректный переворот требует разбиения на графемные кластеры —
стандартной библиотекой не решается.
Что делает strings.Builder, чего не делает bytes.Buffer?
Оба накапливают в растущем буфере, разница в конце. Builder.String() отдаёт
буфер без копии — он может себе это позволить, потому что запрещает
копирование самого Builder (проверка noCopy даёт панику). Buffer.String()
копирует, потому что буфер после этого можно продолжать использовать.
Отсюда выбор: если результат — строка и буфер больше не нужен, Builder
дешевле ровно на одну копию. Если нужен io.Writer с чтением, — Buffer.
Почему сравнение строк не требует одинаковой длины?
Сравнение сначала смотрит длины: разные — сразу false, без единого сравнения
байтов. Равные — сравниваются байты, и у одинаковых указателей рантайм выходит
сразу.
Практическое следствие: сравнение строк дёшево на разной длине и дорожает только на совпадающих префиксах равной длины. Отсюда же то, что строки — законные ключи карты: хеш считается по байтам, сравнение однозначно, потому что байты неизменяемы.
Строка может содержать некорректный UTF-8?
Да, и это важное «да»: строка — последовательность произвольных байтов. Ничто не мешает положить в неё то, что UTF-8 не является, — например, данные из файла.
Что при этом происходит: range и []rune подставляют вместо каждого
некорректного байта U+FFFD — символ замены, — а utf8.ValidString даёт
false. То есть повреждение не роняет программу, а молча превращается в
вопросительные знаки в ромбике, и найти его можно только проверкой.
Где хранятся строковые литералы?
В неизменяемом сегменте данных исполняемого файла, и это ещё один способ объяснить неизменяемость: запись в литерал была бы записью в страницу памяти, помеченную только на чтение.
Отсюда мелкое, но полезное: s := "abc" не выделяет ничего в куче, подстрока
литерала тоже, а вот []byte("abc") выделяет — потому что срез изменяем и его
нельзя оставить в том сегменте.
Частые заблуждения
len(s) возвращает число символов
Число байтов. У "A世🙂" это 8 при трёх рунах: буква занимает один байт, иероглиф три, эмодзи четыре. Число рун даёт utf8.RuneCountInString, но и оно не равно числу видимых символов: у é в составной форме рун две при одном знаке.
s[0] — это первый символ строки
Это первый байт, тип byte. Для "A世🙂" он равен 65 и случайно совпадает с целой буквой «A» — а вот s[1] равен 228 и является лишь третью иероглифа. И string(s[1]) даёт не «世», а постороннее «ä»: это конвертация числа в руну с таким кодом, а не декодирование байта.
range по строке даёт индексы 0, 1, 2…
Он даёт байтовые смещения начала каждой руны: для "A世🙂" это 0, 1, 4. Индекс из range годится для нарезки строки и не годится как номер символа — счётчик заводят отдельно.
руна — это символ
Руна — одна кодовая точка Unicode. Видимый символ может состоять из нескольких: é записывается либо одной точкой, либо буквой плюс комбинирующим акутом, и во втором случае рун две при одном знаке на экране. Считать символы — задача про графемные кластеры.
подстрока копирует данные, поэтому она дорогая
Наоборот: ноль выделений — новый заголовок указывает внутрь того же массива. Дорого другое: подстрока удерживает весь исходный массив. Замерено: десять байт держат 8 МБ, пока не сделан strings.Clone.
len([]rune(s)) выделяет память, надо заменить на utf8.RuneCountInString
Замер этого не подтверждает: у len([]rune(s)) ноль выделений и то же время — компилятор go1.24 узнаёт выражение и считает руны на месте. Это свойство компилятора, а не обещание языка: спецификация про такие конвертации не говорит ничего. Выделение появляется, когда срез сохраняют: 300,84 нс против 94,52 и одно выделение.
конвертация []byte в string всегда копирует
Только когда строка переживает выражение. m[string(b)], string(b) == s и for range string(b) дают ноль выделений — компилятор go1.24 знает, что строка наружу не попадёт. На другой реализации копия была бы законна: контракт обещает только то, что string и []byte — разные типы.
конкатенацию через + использовать нельзя, только Builder
На двух-трёх кусках a + b + c быстрее Builder и читается лучше — компилятор склеивает такое одним вызовом. Builder нужен там, где кусков много и их число заранее неизвестно: на тысяче кусков разрыв ×109, и он растёт с их числом, потому что работа квадратична.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
fmt.Println(len(s))
fmt.Println(utf8.RuneCountInString(s))
fmt.Println(s[0])
fmt.Println(s[1])
fmt.Println(len([]rune(s)))
var offsets []string
for i := range s {
offsets = append(offsets, strconv.Itoa(i))
}
fmt.Println(strings.Join(offsets, " "))
fmt.Println(len(string(s[0])))
fmt.Println(len(string(s[1])))Практика · оцените
Проверка знаний
s := «A世🙂». Что напечатает fmt.Println(len(s), s[0], s[1])?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Строка в Go — это последовательность байтов, а не символов. Текст хранится в кодировке UTF-8, где один знак занимает от одного до четырёх байтов, — поэтому длина строки считает байты, обращение по индексу даёт байт, а
for rangeидёт по кодовым точкам. На чисто латинском тексте все три совпадают, и ровно поэтому ошибка этой темы живёт в коде до первого нелатинского ввода. - Отсюда то, что выглядит не так, как ждут. У
"A世🙂"восемь байт при трёх рунах:s[0]= 65 и это вся буква «A», аs[1]= 228 и это треть иероглифа — первый случай работает случайно и потому маскирует второй.rangeотдаёт байтовые смещения0 1 4, а не0 1 2. Нарезка тоже режет по байтам:s[:2]разрубает иероглиф пополам, получается строка, некорректная как UTF-8, — и ни падения, ни предупреждения. И руна — не символ: уéв составной форме три байта, две руны и один видимый знак, у семьи"👩👩👧"— восемнадцать байт, пять рун, один знак; разбиения на графемы в стандартной библиотеке нет вовсе. - Дальше — устройство, числа и границы. Строка — это заголовок из указателя и длины (16 байт) над неизменяемыми байтами, и из неизменяемости выводятся и бесплатная подстрока, и квадратичная склейка. Подстрока не копирует ничего — и ровно поэтому удерживает весь исходный массив: замерено, что десять байт держат 8 МБ, пока не сделан
strings.Clone. В go1.24len([]rune(s))не выделяет память — компилятор узнаёт это выражение, так что совет «замените наutf8.RuneCountInString» описывает компилятор, которого давно нет; платить приходится там, где срез сохраняют: ×3,4 и одно выделение. По той же причине бесплатна и часть конвертаций[]byte→string:m[string(b)],string(b) == s,range string(b)— ноль выделений. А склейка в цикле квадратична: на одной машине кратность противBuilderросла с числом кусков ×2,3 → ×23,3 → ×111,8, — но на двух-трёх кускахa + b + cбыстрее.
На самом деле
- Число байтов. У
"A世🙂"это 8 при трёх рунах: буква занимает один байт, иероглиф три, эмодзи четыре. Число рун даётutf8.RuneCountInString, но и оно не равно числу видимых символов: уéв составной форме рун две при одном знаке. - Это первый байт, тип
byte. Для"A世🙂"он равен 65 и случайно совпадает с целой буквой «A» — а вотs[1]равен 228 и является лишь третью иероглифа. Иstring(s[1])даёт не «世», а постороннее «ä»: это конвертация числа в руну с таким кодом, а не декодирование байта. - Он даёт байтовые смещения начала каждой руны: для
"A世🙂"это 0, 1, 4. Индекс из range годится для нарезки строки и не годится как номер символа — счётчик заводят отдельно. - Руна — одна кодовая точка Unicode. Видимый символ может состоять из нескольких:
éзаписывается либо одной точкой, либо буквой плюс комбинирующим акутом, и во втором случае рун две при одном знаке на экране. Считать символы — задача про графемные кластеры. - Наоборот: ноль выделений — новый заголовок указывает внутрь того же массива. Дорого другое: подстрока удерживает весь исходный массив. Замерено: десять байт держат 8 МБ, пока не сделан
strings.Clone. - Замер этого не подтверждает: у
len([]rune(s))ноль выделений и то же время — компилятор go1.24 узнаёт выражение и считает руны на месте. Это свойство компилятора, а не обещание языка: спецификация про такие конвертации не говорит ничего. Выделение появляется, когда срез сохраняют: 300,84 нс против 94,52 и одно выделение. - Только когда строка переживает выражение.
m[string(b)],string(b) == sиfor range string(b)дают ноль выделений — компилятор go1.24 знает, что строка наружу не попадёт. На другой реализации копия была бы законна: контракт обещает только то, чтоstringи[]byte— разные типы. - На двух-трёх кусках
a + b + cбыстрее Builder и читается лучше — компилятор склеивает такое одним вызовом. Builder нужен там, где кусков много и их число заранее неизвестно: на тысяче кусков разрыв ×109, и он растёт с их числом, потому что работа квадратична.
Что разобрано
- Что здесь на самом деле спрашивают
- База: строка — это байты
- Механизм 1: одна строка, три системы отсчёта
- Механизм 2: байты, руны и графемы — три разных числа
- Механизм 3: нарезка режет по байтам
- Механизм 4: что лежит в переменной — и почему подстрока бесплатна
- Механизм 5: склейка в цикле — почему это не про скорость операции
- Глубже: конвертации — что копируется, а что нет
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
4 ИСТОЧНИКА
- Спецификация Go — строковые типыОфициальная документация. Определение, из которого выводится всё остальное: «A string value is a (possibly empty) sequence of bytes. The number of bytes is called the length of the string and is never negative. Strings are immutable: once created, it is impossible to change the contents of a string» (Строковое значение — это (возможно, пустая) последовательность байтов. Число байтов называется длиной строки и никогда не отрицательно. Строки неизменяемы: после создания изменить содержимое строки невозможно). Обратите внимание на слово в определении: последовательность байтов, а не символов.https://go.dev/ref/spec#String_types
- Спецификация Go — оператор range по строкеОфициальная документация. Ответ на вопрос, почему индексы в range идут с пропусками: «For a string value, the "range" clause iterates over the Unicode code points in the string starting at byte index 0. On successive iterations, the index value will be the index of the first byte of successive UTF-8-encoded code points in the string» (Для строкового значения выражение „range“ перебирает кодовые точки Unicode в строке начиная с байтового индекса 0. На последующих итерациях индексом будет индекс первого байта очередной кодовой точки, закодированной в UTF-8).https://go.dev/ref/spec#For_range
- Strings, bytes, runes and characters in Go — блог GoОфициальная документация. Статья Роба Пайка, откуда берётся точное словоупотребление: «a rune is a Go term for a single Unicode code point» (руна — принятое в Go название одной кодовой точки Unicode) и, что важнее, предупреждение против отождествления руны и символа: «Code points can be represented by multiple runes» (Кодовые точки могут быть представлены несколькими рунами) — речь о комбинирующих знаках.https://go.dev/blog/strings
- Пакет strings — Builder и CloneОфициальная документация. Два инструмента урока. Про Builder: «A Builder is used to efficiently build a string using Write methods. It minimizes memory copying» (Builder используется для эффективного построения строки с помощью методов Write. Он минимизирует копирование памяти). Про Clone — прямо про ловушку удержания: «Clone returns a fresh copy of s. It guarantees to make a copy of s into a new allocation, which can be important when retaining only a small substring of a much larger string» (Clone возвращает свежую копию s. Он гарантирует копирование s в новое выделение памяти, что может быть важно, когда сохраняется лишь небольшая подстрока гораздо большей строки).https://pkg.go.dev/strings