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

Строки и руны

Строка в Go — это неизменяемая последовательность байт плюс её длина. На уровне рантайма это структура из двух машинных слов: runtime.stringStruct{str unsafe.Pointer, len int} — 16 байт на 64-битной платформе. Никакого терминирующего нуля, никакой кодировки внутри заголовка, никакого счётчика ссылок: только указатель на байты и длина. Строка передаётся по значению, но копируется при этом только заголовок — сами байты общие, поэтому передача строки в функцию и взятие подстроки s[a:b] стоят O(1) и не аллоцируют.

Ключевое свойство — неизменяемость. Байты за указателем менять нельзя: компилятор запрещает s[0] = 'x' на этапе компиляции, а строковые литералы вообще живут в read-only секции бинаря, так что попытка записи через unsafe даёт не «изменённую строку», а SIGSEGV. Из неизменяемости растут все остальные удобства: строку можно безопасно шарить между горутинами без синхронизации, использовать как ключ map (хеш стабилен), нарезать подстроки без копирования, а компилятор может складывать одинаковые литералы в один объект.

Вторая половина модели — кодировка. Go не хранит в строке метаданных о кодировке: там просто байты, но вся стандартная библиотека и синтаксис языка исходят из того, что это UTF-8 (исходники Go по спецификации — UTF-8). Отсюда двойственность доступа: len(s) и s[i] работают с байтами, а for i, r := range s декодирует руны (кодовые точки) и отдаёт байтовое смещение + значение руны. rune — это просто алиас int32, то есть кодовая точка Unicode как число; в памяти строки одна руна занимает 1–4 байта, ASCII — один байт, кириллица — два, большинство CJK — три, эмодзи — четыре.

Практическая часть темы — конверсии и конкатенация. []byte(s), string(b), []rune(s) в общем случае копируют данные (потому что слайс изменяем, а строка нет), хотя компилятор умеет вырезать копию в нескольких заученных паттернах (m[string(b)], for range []byte(s), сравнение string(b) == "lit"). Оператор + каждый раз создаёт новую строку и копирует оба операнда, поэтому конкатенация в цикле — это O(n²) по копированию и куча мусора; правильный инструмент — strings.Builder (или strings.Join, если куски уже в слайсе).

Task: Sort a string alphabetically. If the letter is a vowel, the uppercase letter is greater; if it is a consonant, it is smaller

Заголовок раздела «Task: Sort a string alphabetically. If the letter is a vowel, the uppercase letter is greater; if it is a consonant, it is smaller»

Коротко. Задача сводится к сортировке рун с составным компаратором: первичный ключ — буква без учёта регистра (алфавитный порядок), вторичный — регистр, причём направление вторичного ключа зависит от того, гласная буква или согласная: для гласных a < A, для согласных B < b.

Глубже. Берём []rune(s) (не []byte — иначе сломаемся на не-ASCII), сортируем через slices.SortFunc (Go 1.21+) со стабильным вариантом, если важен исходный порядок равных элементов. Компаратор возвращает отрицательное/ноль/положительное.

package main
import (
"fmt"
"slices"
"strings"
"unicode"
)
func isVowel(r rune) bool {
return strings.ContainsRune("aeiouAEIOU", r)
}
func sortSpecial(s string) string {
rs := []rune(s)
slices.SortStableFunc(rs, func(a, b rune) int {
la, lb := unicode.ToLower(a), unicode.ToLower(b)
if la != lb {
return int(la) - int(lb) // алфавитный порядок, регистр не важен
}
if a == b {
return 0
}
// одна и та же буква в разных регистрах
aUpper := unicode.IsUpper(a)
if isVowel(a) {
// у гласных заглавная "больше": a < A
if aUpper {
return 1
}
return -1
}
// у согласных заглавная "меньше": B < b
if aUpper {
return -1
}
return 1
})
return string(rs)
}
func main() {
fmt.Println(sortSpecial("bBaAcCeE")) // aABbCceE
}

Подводные камни, которые стоит проговорить вслух: int(la) - int(lb) безопасно, потому что руны — int32, а компаратор возвращает int (на 64 битах переполнения не будет); если бы возвращали int32, надо было бы сравнивать явно. И обязательно уточните у интервьюера, что делать с не-буквами и с не-ASCII алфавитом — это половина оценки за задачу.

Строка в Go. Длина строки “Привет ”в байтах. Что будет при обращении к нулевому элементу?

Заголовок раздела «Строка в Go. Длина строки “Привет ”в байтах. Что будет при обращении к нулевому элементу?»

Коротко. len("Привет") == 12: шесть кириллических букв по 2 байта в UTF-8 (если в строке есть завершающий пробел — 13). s[0] вернёт байт 0xD0 == 208 — первый байт двухбайтовой последовательности буквы «П» (U+041F → 0xD0 0x9F), а не саму букву.

Глубже. Тип s[0]byte (он же uint8), поэтому fmt.Println(s[0]) печатает 208, а fmt.Printf("%c", s[0]) — мусорный символ Ð. Чтобы получить первую руну, нужен []rune(s)[0] (копирует всю строку, O(n)), или utf8.DecodeRuneInString(s) (O(1), возвращает руну и её размер в байтах), или первый шаг for _, r := range s. Отдельно: обращение по индексу к пустой строке ""[0] — это паника index out of range в рантайме (а для константного индекса по константной строке — ошибка компиляции).

s := "Привет"
fmt.Println(len(s)) // 12
fmt.Println(utf8.RuneCountInString(s)) // 6
fmt.Println(s[0]) // 208
r, size := utf8.DecodeRuneInString(s) // 'П', 2

Что именно возвращает for range при итерации по строке и почему это считается правильным способом обхода Unicode-строк?

Заголовок раздела «Что именно возвращает for range при итерации по строке и почему это считается правильным способом обхода Unicode-строк?»

Коротко. for i, r := range s возвращает байтовое смещение начала руны в i и саму руну (rune, декодированную кодовую точку UTF-8) в r. Это правильный способ, потому что он декодирует UTF-8 на лету, не аллоцирует и корректно шагает на 1–4 байта вместо наивного побайтового прохода.

Глубже. Важно, что i — это не порядковый номер символа: для «Привет» он пойдёт 0, 2, 4, 6, 8, 10. Если нужен именно порядковый номер руны — заводите свой счётчик. Компилятор разворачивает range по строке в вызовы декодера (runtime.decoderune) с быстрым путём для ASCII, поэтому обход не аллоцирует — в отличие от for _, r := range []rune(s), который сначала делает копию в слайс рун (хотя и этот паттерн компилятор в ряде случаев оптимизирует). Если нужны только байты — используйте for i := 0; i < len(s); i++, это принципиально другой обход.

Как преобразовать строку в число в Go? Какие функции для этого существуют и в чем их особенности?

Заголовок раздела «Как преобразовать строку в число в Go? Какие функции для этого существуют и в чем их особенности?»

Коротко. Пакет strconv: strconv.Atoi для быстрого string → int, strconv.ParseInt/ParseUint с указанием базы и разрядности, strconv.ParseFloat, strconv.ParseBool. Обратно — Itoa, FormatInt, FormatFloat и Append*-варианты без аллокации.

Глубже. Особенности, которые ждут услышать:

  • Atoi(s) — это по сути ParseInt(s, 10, 0) с быстрым путём для коротких строк; возвращает int, а не int64.
  • ParseInt(s, base, bitSize): base == 0 включает распознавание префиксов 0x, 0b, 0o, 0, а также подчёркиваний-разделителей (1_000) — как в литералах Go. bitSize 8/16/32/64 задаёт диапазон проверки; 0 = int.
  • Ошибка всегда *strconv.NumError, обёрнутая вокруг strconv.ErrSyntax или strconv.ErrRange. Проверять надо через errors.Is(err, strconv.ErrRange), а не сравнением строк. При ErrRange возвращается насыщенное значение (MaxInt64/MinInt64), а не ноль — частая ловушка.
  • ParseFloat понимает Inf, NaN, шестнадцатеричные float-литералы; для денег его использовать нельзя — только целые копейки или decimal-библиотека.
  • fmt.Sscanf/fmt.Sscan тоже умеют парсить, но в разы медленнее и молча игнорируют хвост — на горячем пути их не используют.
n, err := strconv.Atoi("42")
if err != nil { /* ... */ }
v, err := strconv.ParseInt("0xFF", 0, 64) // 255
u, err := strconv.ParseUint("1_000", 0, 32) // 1000
f, err := strconv.ParseFloat("3.14", 64)
if errors.Is(err, strconv.ErrRange) { /* переполнение */ }

Как мигрировать JSON-поле в три отдельных столбца в 100 тыс. строк без простоя?

Заголовок раздела «Как мигрировать JSON-поле в три отдельных столбца в 100 тыс. строк без простоя?»

Коротко. Схема expand/contract: (1) добавить три nullable-колонки без дефолта — это мгновенная операция в PostgreSQL; (2) включить двойную запись в приложении (пишем и в JSON, и в колонки); (3) бэкфилл старых строк батчами по 1–5 тыс. в отдельных транзакциях; (4) переключить чтение на колонки, проверить сходимость; (5) в следующем релизе снять двойную запись, навесить NOT NULL/индексы и удалить поле из JSON.

Глубже. 100 тыс. строк — это мало, бэкфилл займёт секунды, но методику ждут именно такую, потому что она масштабируется. Детали, которые стоит назвать: ALTER TABLE ... ADD COLUMN x text без DEFAULT не переписывает таблицу; в PostgreSQL 11+ и ADD COLUMN ... DEFAULT не переписывает, но NOT NULL сразу ставить нельзя, пока не заполнены строки. Бэкфилл делаем курсором по PK (WHERE id > $1 ORDER BY id LIMIT 1000), а не OFFSET, каждый батч — своя транзакция, между батчами sleep чтобы не разгонять bloat и репликационный лаг. Индексы создаём через CREATE INDEX CONCURRENTLY, NOT NULL добавляем в два шага (ADD CONSTRAINT ... NOT VALID + VALIDATE CONSTRAINT), чтобы не брать долгий ACCESS EXCLUSIVE. На стороне Go — фича-флаг на источник чтения и сверочный джоб, который сравнивает JSON и колонки на выборке. Обязательно предусмотреть идемпотентность и возможность откатить релиз: пока идёт шаг 2, старая версия приложения должна продолжать работать, поэтому колонки и остаются nullable.

Коротко. Строка — это заголовок из двух слов: указатель на массив байт и длина (runtime.stringStruct), 16 байт на 64-битной платформе. Сами байты неизменяемы, терминирующего нуля нет, кодировка по соглашению — UTF-8.

Глубже. Из-за отсутствия \0 строку нельзя просто отдать в C — нужен C.CString (копия с нулём) и последующий free. Литералы компилятор кладёт в read-only сегмент и дедуплицирует, поэтому два одинаковых литерала обычно указывают на одни и те же байты. Подстрока s[a:b] создаёт новый заголовок, ссылающийся внутрь тех же байт: это O(1), но означает, что маленькая подстрока может удерживать в живых мегабайтный буфер — если такое возможно, делайте явную копию через strings.Clone (Go 1.18+).

Что из себя представляют строки, почему не изменяемы?

Заголовок раздела «Что из себя представляют строки, почему не изменяемы?»

Коротко. См. выше: это {ptr, len} над байтами. Неизменяемость выбрана ради безопасного шаринга без копирования: строку можно передавать между горутинами и функциями, класть в map как ключ и резать на подстроки, зная, что байты никто не перепишет под тобой.

Глубже. Конкретные следствия: (1) s[a:b] бесплатен — в языке с изменяемыми строками пришлось бы копировать; (2) хеш строкового ключа map можно считать один раз и не бояться, что ключ «поедет»; (3) не нужны блокировки при конкурентном чтении; (4) литералы живут в rodata, что даёт защиту от записи на уровне страниц памяти; (5) сравнение == — это memequal с быстрым выходом по разной длине и по совпадению указателей. Цена — конкатенация всегда аллоцирует.

Как происходит итерация по строке в цикле? Что в key, val?

Заголовок раздела «Как происходит итерация по строке в цикле? Что в key, val?»

Коротко. В for i, r := range s «ключ» — это байтовый индекс начала очередной руны, а «значение» — сама руна типа rune (int32). Индекс растёт не на 1, а на размер руны в UTF-8.

Глубже. Классический пример, который стоит написать на доске:

for i, r := range "Привет" {
fmt.Printf("%d %c %d\n", i, r, r)
}
// 0 П 1055
// 2 р 1088
// 4 и 1080
// 6 в 1074
// 8 е 1077
// 10 т 1090

Если нужен обход байтов — обычный for i := 0; i < len(s); i++, тип s[i]byte. Начиная с Go 1.22 переменные цикла создаются заново на каждой итерации, так что брать &r или захватывать r в горутину теперь безопасно (в Go 1.21 и раньше это была классическая ошибка).

Как будет итерация по строке если там битый utf8?

Заголовок раздела «Как будет итерация по строке если там битый utf8?»

Коротко. range не паникует и не пропускает данные: каждый невалидный байт отдаётся как utf8.RuneError (U+FFFD, «replacement character»), и итератор сдвигается ровно на один байт.

Глубже. Проверяется тривиально:

for i, r := range "a\xffb" {
fmt.Printf("%d %U\n", i, r)
}
// 0 U+0061
// 1 U+FFFD <- битый байт 0xFF
// 2 U+0062

Тонкость: отличить «настоящий» символ U+FFFD, реально записанный в строке (3 байта EF BF BD), от ошибки декодирования по значению руны нельзя — нужен utf8.DecodeRuneInString, который вернёт (RuneError, 1) для битого байта и (RuneError, 3) для валидного U+FFFD. Проверить строку целиком — utf8.ValidString(s); починить, заменив мусор на U+FFFD, — strings.ToValidUTF8(s, "") (Go 1.13+). Ещё одно следствие: []rune(s) на битой строке тоже подставит U+FFFD, поэтому round-trip string([]rune(s)) == s для невалидного UTF-8 не выполняется.

Коротко. См. выше: неизменяемая последовательность байт ({ptr, len}), по соглашению в UTF-8; базовый тип языка, сравнимый (==, <), пригодный как ключ map, индексируемый по байтам и итерируемый по рунам.

Глубже. Отличие от []byte, которое стоит проговорить: []byte — это три слова и изменяемые данные, поэтому его нельзя сравнивать через == (только bytes.Equal) и нельзя использовать как ключ map. Строка — «значение», слайс байт — «буфер»; в API-сигнатурах строку берут, когда данные только читают, []byte — когда буфер переиспользуют.

Коротко. Чтобы можно было шарить байты без копирования и без синхронизации: подстроки O(1), безопасное конкурентное чтение, стабильные ключи map, литералы в read-only памяти.

Глубже. Дополнительно это упрощает жизнь GC и компилятору: раз байты не меняются, можно дедуплицировать литералы, кешировать хеши и не бояться, что значение изменится между проверкой и использованием (классический TOCTOU в API вида os.Open(path) — если бы path мог измениться из другой горутины, валидация пути была бы бессмысленна). Цена решения — аллокация на каждую конкатенацию и на каждый []byte(s); язык компенсирует это strings.Builder, оптимизациями компилятора и unsafe.String/unsafe.Slice для тех, кто точно знает, что делает.

Сколько максимум байт занимает один символ?

Заголовок раздела «Сколько максимум байт занимает один символ?»

Коротко. В UTF-8 одна кодовая точка занимает максимум 4 байта (utf8.UTFMax == 4), потому что Unicode ограничен U+10FFFF. Но «символ» в человеческом смысле (графемный кластер) может занимать намного больше.

Глубже. Разбивка: U+0000–U+007F → 1 байт, U+0080–U+07FF → 2 (кириллица, греческий, иврит), U+0800–U+FFFF → 3 (CJK, большинство остальных BMP), U+10000–U+10FFFF → 4 (эмодзи, редкие письменности). Исходный UTF-8 Кена Томпсона допускал до 6 байт, но RFC 3629 обрезал диапазон до U+10FFFF ради совместимости с UTF-16, и Go следует ему. Отдельно стоит сказать, что эмодзи вида «семья» — это несколько кодовых точек, склеенных ZWJ (U+200D), плюс модификаторы цвета кожи: len("👨‍👩‍👧‍👦") == 25 байт и 7 рун при одном визуальном символе. Работать с графемными кластерами стандартная библиотека не умеет — нужен github.com/rivo/uniseg или golang.org/x/text.

Коротко. rune — встроенный алиас типа int32, хранящий номер кодовой точки Unicode. Это «символ» в терминах Unicode, а не байт и не графема.

Глубже. Именно алиас (type rune = int32), а не отдельный тип: rune и int32 взаимозаменяемы, и fmt.Printf("%T", 'a') напечатает int32. Рунные литералы пишутся в одинарных кавычках ('a', 'П', 'П') и являются нетипизированными константами, поэтому var b byte = 'a' компилируется, а var b byte = 'П' — нет (константа 1055 не влезает в byte). Конверсия string(r) кодирует руну в UTF-8; конверсия string(65) от int — то же самое, но с Go 1.15 go vet ругается на неё, потому что обычно человек имел в виду strconv.Itoa. Значения вне диапазона Unicode или суррогаты (U+D800–U+DFFF) при конвертации превращаются в U+FFFD.

Коротко. Как значение типа rune — всегда 4 байта (int32). Внутри UTF-8-строки та же руна занимает 1–4 байта в зависимости от кодовой точки.

Глубже. Это два разных вопроса, и путаница между ними — типичная ошибка. unsafe.Sizeof(rune('a')) == 4, и []rune("Привет") занимает 6 × 4 = 24 байта данных против 12 байт исходной строки. Поэтому []rune(s) — не бесплатная операция: это аллокация примерно вчетверо больше строки для ASCII-текста. Узнать длину конкретной руны в UTF-8 — utf8.RuneLen(r).

На вход подается строка s, содержащая только символы ‘(’, ‘)’, ‘{’, ‘}’, ‘[’и ‘]’. ‘<’’>’‘a ’‘я ’Определите, является ли входная строка валидной.

Заголовок раздела «На вход подается строка s, содержащая только символы ‘(’, ‘)’, ‘{’, ‘}’, ‘[’и ‘]’. ‘<’’>’‘a ’‘я ’Определите, является ли входная строка валидной.»

Коротко. Классическая задача на стек: идём по рунам, открывающую скобку кладём в стек, закрывающую сверяем с вершиной; в конце стек должен быть пуст. Пары задаём таблицей, чтобы легко добавить <> и произвольные «скобки» вроде a/я.

Глубже. Идём именно по рунам (for _, r := range s), потому что я — не ASCII. Сложность O(n) по времени и O(n) по памяти в худшем случае.

package main
import "fmt"
func isValid(s string) bool {
pairs := map[rune]rune{')': '(', ']': '[', '}': '{', '>': '<', 'я': 'a'}
opens := map[rune]bool{'(': true, '[': true, '{': true, '<': true, 'a': true}
stack := make([]rune, 0, len(s))
for _, r := range s {
switch {
case opens[r]:
stack = append(stack, r)
default:
open, ok := pairs[r]
if !ok {
return false // посторонний символ
}
if len(stack) == 0 || stack[len(stack)-1] != open {
return false
}
stack = stack[:len(stack)-1]
}
}
return len(stack) == 0
}
func main() {
fmt.Println(isValid("([{<aя>}])")) // true
fmt.Println(isValid("([)]")) // false
}

Что часто ломают: забывают проверку len(stack) == 0 перед взятием вершины (паника на строке ")("), и забывают финальную проверку пустоты стека (строка "((" ошибочно считается валидной). Уточните у интервьюера, что делать с посторонними символами — игнорировать или считать невалидом.

Коротко. Нет. Строка неизменяема: s[0] = 'a' не компилируется. Можно переприсвоить переменную (s = "другое"), но это замена заголовка, а не изменение байт.

Глубже. Формально в спецификации: «Strings are immutable: once created, it is impossible to change the contents of a string». Присваивание s[0] = 'a' даёт ошибку компиляции cannot assign to s[0] (neither addressable nor a map index expression) — элементы строки даже не адресуемы, поэтому и &s[0] не скомпилируется. Единственный способ «изменить» — построить новую строку.

Что такое строки? Есть ли способ изменить строку, какой пакет?

Заголовок раздела «Что такое строки? Есть ли способ изменить строку, какой пакет?»

Коротко. Строка неизменяема, поэтому «изменить» её нельзя — можно только собрать новую. Инструменты: конверсия в []byte/[]rune, правка и обратная конверсия; пакеты strings (Replace, ReplaceAll, Map, Builder, Title-подобные функции), bytes для буферов, unicode/unicode/utf8 для посимвольной логики.

Глубже. Ни одна функция стандартной библиотеки не меняет строку на месте — все возвращают новую. strings.Map(f, s) удобен, когда нужно преобразовать/выбросить руны (возврат отрицательного значения из f удаляет руну). Если хочется буквально «мутировать» — работайте с []byte от начала и до конца, а в строку конвертируйте один раз в самом конце. unsafe.String/unsafe.StringData (Go 1.20+) позволяют построить строку поверх существующих байт без копии, но после этого писать в эти байты — undefined behavior, а запись в байты литерала гарантированно уронит процесс.

b := []byte("hello")
b[0] = 'H'
s := string(b) // "Hello" — новая строка
s2 := strings.Map(func(r rune) rune {
if unicode.IsDigit(r) {
return -1 // выбросить руну
}
return r
}, "a1b2c3") // "abc"

Что такое руны? Если по строке пойдем через range, то что будет в ключе, а что в значении?

Заголовок раздела «Что такое руны? Если по строке пойдем через range, то что будет в ключе, а что в значении?»

Коротко. Руна — кодовая точка Unicode, алиас int32. В for i, r := range s в «ключе» — байтовое смещение начала руны, в «значении» — сама руна.

Глубже. См. выше два отдельных вопроса про руны и про range. Здесь важно не сказать «в ключе индекс символа»: для «Привет» смещения будут 0,2,4,6,8,10, а не 0,1,2,3,4,5. И если написать for i := range s с одной переменной — получите только байтовые смещения начал рун (не все индексы подряд!), что тоже часто путают с побайтовым обходом.

В какой кодировке байты в строке? что за кодировка UTF-8, какие особенности?

Заголовок раздела «В какой кодировке байты в строке? что за кодировка UTF-8, какие особенности?»

Коротко. Формально в строке лежат произвольные байты — тип ничего не навязывает. Но по соглашению языка и всей стандартной библиотеки это UTF-8: исходники Go по спецификации в UTF-8, литералы кодируются в UTF-8, range и unicode/utf8 предполагают UTF-8.

Глубже. Свойства UTF-8, которые ждут услышать:

  • Переменная длина 1–4 байта; ASCII (0x00–0x7F) кодируется одним байтом и совпадает с ASCII — старый C-код и протоколы вроде HTTP работают без изменений.
  • Самосинхронизация: у ведущего байта старшие биты 0xxxxxxx/110xxxxx/1110xxxx/11110xxx, у продолжающих — всегда 10xxxxxx. Поэтому по любому байту видно, ведущий он или продолжение, и можно откатиться к началу символа без чтения строки с нуля.
  • Отсутствие нулевых байт внутри многобайтовых последовательностей — UTF-8 совместим с нуль-терминированными строками.
  • Сохранение порядка: побайтовое сравнение UTF-8 даёт тот же порядок, что сравнение кодовых точек. Именно поэтому s1 < s2 в Go даёт осмысленный (хотя и не «человеческий») порядок для Unicode. Языковую сортировку это не заменяет — для неё есть golang.org/x/text/collate.
  • Нет проблемы endianness и BOM — в отличие от UTF-16/UTF-32.
  • Не всякий байтовый массив валиден: бывают оборванные последовательности, overlong-кодировки и суррогаты — Go их отвергает, подставляя U+FFFD.

Для других кодировок (Windows-1251, KOI8-R, Shift-JIS) есть golang.org/x/text/encoding.

Коротко. См. выше: значение из указателя и длины над неизменяемыми UTF-8 байтами. Сравнима, хешируема, нарезается за O(1), индексируется побайтово, обходится по рунам через range.

Глубже. Один нюанс, который тут удобно добавить: нулевое значение строки — "", и это полноценная валидная строка с len == 0; отдельного nil у строк нет, поэтому «отсутствие значения» моделируют через *string, sql.NullString или отдельный булев флаг.

Какие из следующих операций можно выполнить со строкой?

Заголовок раздела «Какие из следующих операций можно выполнить со строкой?»

Коротко. Можно: сравнение (==, !=, <, > — лексикографически побайтово), конкатенация + и +=, len(s), чтение байта s[i], срез s[a:b], range, конверсии в []byte/[]rune и обратно. Нельзя: присваивать элемент s[i] = x, брать адрес элемента &s[i], применять арифметику вроде s++, s * 2, s - "a".

Глубже. Ещё детали: срез строки даёт строку (s[1:3] — тип string), а индексация — байт; трёхиндексный срез s[a:b:c] для строк не разрешён. Сравнение через < работает по байтам, поэтому "Z" < "a" (0x5A < 0x61) и "ё" > "я" — для человеческой сортировки нужен x/text/collate. Строку можно использовать как ключ map, как значение в switch, как элемент константы (const s = "x"), в отличие от слайсов.

Коротко. Операция допустима: += для строк — это конкатенация, эквивалент S = S + "abc". Никакой мутации не происходит — создаётся новая строка, а переменная S начинает указывать на неё.

Глубже. Под капотом компилятор вызывает runtime.concatstring2 (для двух операндов; есть варианты до concatstrings с произвольным слайсом). Рантайм считает суммарную длину, аллоцирует новый массив байт и копирует туда оба операнда. Есть оптимизация: если результат заведомо не «убегает» из функции и помещается в 32 байта, компилятор передаёт в concatstring* временный буфер на стеке (tmpBuf), и аллокации в куче не будет. В цикле for i := 0; i < n; i++ { S += x } это выливается в O(n²) копирований и n аллокаций — тот самый антипаттерн, ради которого существует strings.Builder.

Будет возвращено нулевое значение для типа данных значения малы (например, 0 для int, пустая строка);

Заголовок раздела «Будет возвращено нулевое значение для типа данных значения малы (например, 0 для int, пустая строка);»

Коротко. Это вариант ответа из теста про чтение отсутствующего ключа map: при v := m[k] для несуществующего ключа возвращается нулевое значение типа значения — 0 для чисел, "" для строк, nil для указателей/слайсов/мап, false для bool. Утверждение верное, паники не будет.

Глубже. Отличить «нет ключа» от «есть ключ с нулевым значением» позволяет только comma-ok форма: v, ok := m[k]. Тут же полезно вспомнить, что чтение из nil-map тоже безопасно и даёт нулевое значение, а вот запись в nil-map — паника assignment to entry in nil map. И ещё: значение map неадресуемо, поэтому m[k].Field = 1 для структурного значения не компилируется — нужно читать в переменную, менять и класть обратно, либо хранить указатели.

Коротко. См. выше: встроенный тип — неизменяемая последовательность байт, физически {ptr *byte, len int}, по соглашению UTF-8.

Глубже. Здесь уместно добавить сравнение размеров, если спросят «сколько занимает строка»: сам заголовок — 16 байт на amd64/arm64 независимо от содержимого; данные — ещё len(s) байт где-то в rodata или куче. unsafe.Sizeof(s) вернёт именно 16, а не длину текста, — это популярная ловушка на собеседовании.

Коротко. rune — вариант ответа-«типа»: алиас int32, представляющий кодовую точку Unicode. Именно его отдаёт range по строке в качестве значения и именно из него состоит []rune.

Глубже. Если вопрос звучит как «какой тип у s[i] / у значения в range» — ответ разный: s[i] даёт byte (uint8), а range даёт rune (int32). Пара алиасов byte = uint8 и rune = int32 — единственные псевдонимы среди встроенных числовых типов, и нужны они исключительно для читаемости кода.

Преобразовывая срезы к строкам и сравнивая результаты;

Заголовок раздела «Преобразовывая срезы к строкам и сравнивая результаты;»

Коротко. Это про сравнение двух []byte: оператором == слайсы сравнивать нельзя, но можно написать string(a) == string(b). Способ рабочий и — благодаря оптимизации компилятора — не аллоцирующий, хотя идиоматичнее bytes.Equal(a, b).

Глубже. Компилятор распознаёт паттерн string(b1) == string(b2) и заменяет его на прямое сравнение байт без создания строк (та же оптимизация работает для m[string(b)], string(b) == "литерал" и switch string(b)). Для сравнения на «больше/меньше» есть bytes.Compare. А вот сравнивать []rune через конверсию в строку без нормализации Unicode некорректно: «é» может быть одной руной U+00E9 или двумя (U+0065 U+0301), и байтово это разные строки — нужна нормализация golang.org/x/text/unicode/norm (NFC/NFD). Ещё: reflect.DeepEqual для слайсов байт работает, но на порядок медленнее bytes.Equal.

Допустим есть операция set. На вход подается ключ - строка, а значение - число. Что происходит внутри функции?

Заголовок раздела «Допустим есть операция set. На вход подается ключ - строка, а значение - число. Что происходит внутри функции?»

Коротко. Считается хеш строки (с учётом случайного seed конкретной map), по хешу выбирается группа/бакет, внутри неё сравниваются короткие метки хеша и затем сами ключи; если ключ найден — значение перезаписывается, если нет — вставляется новый слот, при необходимости map предварительно растёт. Ключ-строка при вставке копируется как заголовок ({ptr,len}), сами байты не дублируются.

Глубже. Начиная с Go 1.24 реализация map — Swiss Tables: вместо старых бакетов по 8 элементов с массивом tophash используются группы по 8 слотов с control-словом, где на каждый слот приходится один байт метаданных (7 бит хеша + признак пустоты/удаления), и поиск внутри группы делается SWAR/SIMD-подобным сравнением всего control-слова сразу. Рост происходит при превышении load factor, и он инкрементальный: большие таблицы разбиты на независимые группы, что убирает старые «пилы» задержек при эвакуации. Что стоит упомянуть независимо от версии: итерация по map намеренно рандомизирована; адрес значения брать нельзя (&m[k] не компилируется), потому что при росте элементы переезжают; конкурентная запись без синхронизации детектируется рантаймом и валит процесс с concurrent map writes; если ключ формируется из []byte, пишите m[string(b)] — компилятор избежит аллокации при чтении, но при записи ключ придётся материализовать.

Коротко. См. выше. Главная особенность в одном предложении: строка — неизменяемое value-представление байт с O(1) шарингом и подстроками, из-за чего любая «модификация» стоит аллокации и копирования.

Глубже. Второй по важности особенностью назовите разрыв между «длиной» и «количеством символов»: len — байты, utf8.RuneCountInString — руны, а видимых глифов может быть ещё меньше. Третья — то, что подстрока удерживает весь исходный буфер (лечится strings.Clone).

Коротко. Только создав новую: b := []byte(s); b[i] = 'x'; s = string(b) для ASCII-правок, []rune(s) — когда нужно менять символы в не-ASCII тексте, либо готовые функции strings.Replace, strings.Map, strings.Builder.

Глубже. Выбор между []byte и []rune принципиален: замена «одного символа» в кириллице через []byte испортит UTF-8, потому что символ занимает 2 байта. И даже с []rune нельзя заменить руну на руну другой длины «на месте» в исходных байтах — вы всё равно строите новую строку. Если правок много, соберите результат в strings.Builder за один проход, а не делайте цепочку Replace.

// заменить первую букву в кириллической строке
rs := []rune("привет")
rs[0] = 'П'
s := string(rs) // "Привет"

Какими способами можно эффективно складывать строки?

Заголовок раздела «Какими способами можно эффективно складывать строки?»

Коротко. strings.BuilderGrow, если размер известен) — основной инструмент; strings.Join, если куски уже лежат в слайсе; bytes.Buffer, если результат всё равно нужен как []byte или как io.Writer; fmt.Sprintf — только там, где важна читаемость, а не скорость. + допустим для двух-трёх литералов, но не в цикле.

Глубже. strings.Builder копит байты в []byte с амортизированным ростом и в String() отдаёт строку через unsafe.String без копирования — это его главное преимущество над bytes.Buffer, у которого String() копирует. Расплата — Builder нельзя копировать после первого Write (есть runtime-проверка copyCheck, паникующая с illegal use of non-zero Builder copied by value), и он не переиспользуется без Reset. strings.Join заранее считает итоговую длину и делает ровно одну аллокацию — для готового слайса это оптимум. Если строки приходят потоком и итог не нужен целиком в памяти — пишите напрямую в io.Writer.

var b strings.Builder
b.Grow(1024) // одна аллокация вместо log(n)
for i := 0; i < 100; i++ {
b.WriteString("x")
b.WriteByte(',')
b.WriteRune('я')
}
s := b.String()

вернуть все города в результат и добавить столбец с порядковым номером строки; сохранив порядок вывода строк, реализовать в столбце с номерами строк обратный порядок.

Заголовок раздела «вернуть все города в результат и добавить столбец с порядковым номером строки; сохранив порядок вывода строк, реализовать в столбце с номерами строк обратный порядок.»

Коротко. SQL-задача на оконные функции: ROW_NUMBER() OVER (ORDER BY ...) даёт прямую нумерацию, а обратная получается как COUNT(*) OVER () - ROW_NUMBER() OVER (ORDER BY ...) + 1 — при этом сам ORDER BY запроса остаётся прежним, так что порядок строк не меняется.

Глубже. Второй вариант — ROW_NUMBER() OVER (ORDER BY ... DESC) в том же запросе: окно сортируется в обратную сторону, а внешний ORDER BY продолжает выдавать строки в прямом порядке.

SELECT
city,
ROW_NUMBER() OVER (ORDER BY city) AS rn,
COUNT(*) OVER () - ROW_NUMBER() OVER (ORDER BY city) + 1 AS rn_desc,
ROW_NUMBER() OVER (ORDER BY city DESC) AS rn_desc2
FROM cities
ORDER BY city;

Важные оговорки: ROW_NUMBER без ORDER BY внутри окна недетерминирован; при дубликатах города ROW_NUMBER всё равно даст уникальные номера (в отличие от RANK/DENSE_RANK), но конкретное распределение среди равных не гарантировано — добавьте tie-breaker по первичному ключу. Оконные функции считаются после WHERE/GROUP BY, но до ORDER BY и LIMIT, поэтому фильтровать по rn можно только через подзапрос или QUALIFY (там, где он поддерживается).

Коротко. len(s) — длина в байтах, O(1). Количество символов (рун) — utf8.RuneCountInString(s), O(n). Количество видимых глифов — только сторонней библиотекой.

Глубже. len(s) буквально читает второе слово заголовка, поэтому бесплатен и работает даже для битого UTF-8. utf8.RuneCountInString не аллоцирует, в отличие от len([]rune(s)), который сначала создаёт слайс — используйте первый. Для «человеческой длины» (эмодзи с ZWJ, комбинирующие диакритики, флаги) нужен подсчёт графемных кластеров: uniseg.GraphemeClusterCount. И помните про ширину при выводе в терминал — это ещё третья метрика (x/text/width, go-runewidth).

Допустим есть структура A, и внутри нее поле B типа string. Далее в main мы создаем переменную C, которая является указателем на пустую структуру A. После чего мы хотим сравнить является ли C == nil и результат выводим на экран. Что будет выведено?

Заголовок раздела «Допустим есть структура A, и внутри нее поле B типа string. Далее в main мы создаем переменную C, которая является указателем на пустую структуру A. После чего мы хотим сравнить является ли C == nil и результат выводим на экран. Что будет выведено?»

Коротко. Если C := &A{} (указатель на пустую структуру), выведется false: указатель ненулевой, он указывает на валидный объект, просто поля этого объекта — нулевые. true было бы только при var C *A (нулевой указатель).

Глубже. Ключевая мысль: «пустая структура» и «нулевой указатель» — разные вещи. &A{} всегда даёт валидный адрес; сравнение с nil смотрит на сам указатель, а не на содержимое.

type A struct{ B string }
var C *A = &A{}
fmt.Println(C == nil) // false
fmt.Println(*C) // { } — поле B == ""
var D *A
fmt.Println(D == nil) // true

Забавная деталь для «глубже»: для структур нулевого размера (struct{}{}) рантайм возвращает адрес общей переменной runtime.zerobase, так что &struct{}{} == &struct{}{} может быть true — но nil он всё равно не будет. И отдельная классика рядом: указатель на структуру, положенный в интерфейс, делает интерфейс не-nil, даже если сам указатель nil (var p *A = nil; var i any = p; i != nil).

Коротко. См. выше: два слова {ptr, len} над неизменяемыми байтами в UTF-8.

Глубже. Отличие от других языков, если хотят сравнения: в Java/C# строка — объект с заголовком, длиной в UTF-16 code units и кешем хеша; в C — нуль-терминированный char* без длины; в Rust String/&str — тоже UTF-8, но &str с гарантией валидности UTF-8 на уровне типа, чего в Go нет: Go-строка вполне может содержать произвольные байты, и язык это допускает сознательно (например, чтобы читать бинарные данные).

Коротко. Обрывок вопроса — без исходного байта/кода восстановить нельзя. Обычно спрашивают про конкретный код: например, байты 0xD0 0x9F — это U+041F «П», а одиночный 0xD0 — не символ вовсе, а ведущий байт двухбайтовой последовательности.

Глубже. Скелет ответа на любую формулировку такого вопроса: смотрим на старшие биты первого байта, определяем длину последовательности (0xxxxxxx → 1 байт, 110xxxxx → 2, 1110xxxx → 3, 11110xxx → 4), собираем кодовую точку из значащих бит, затем ищем её в таблице Unicode. В Go это делается одной строкой:

r, size := utf8.DecodeRuneInString("\xd0\x9f") // r = 'П' (U+041F), size = 2
fmt.Printf("%c %U %d\n", r, r, size)

Как можно итерироваться по строке? (написать два примера)

Заголовок раздела «Как можно итерироваться по строке? (написать два примера)»

Коротко. Два канонических способа: (1) for i, r := range s — по рунам, i — байтовое смещение; (2) for i := 0; i < len(s); i++ — по байтам, s[i] типа byte.

Глубже. Есть и третий-четвёртый: for _, r := range []rune(s) (по рунам с последовательными индексами, но с аллокацией) и ручной цикл через utf8.DecodeRuneInString (когда нужен и размер руны, и контроль над невалидными байтами).

s := "Привет"
// 1. по рунам
for i, r := range s {
fmt.Printf("%d:%c ", i, r) // 0:П 2:р 4:и 6:в 8:е 10:т
}
// 2. по байтам
for i := 0; i < len(s); i++ {
fmt.Printf("%d:%x ", i, s[i]) // 0:d0 1:9f 2:d1 ...
}
// 3. ручной декодер, если нужны размеры и валидация
for i := 0; i < len(s); {
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size <= 1 {
// битый байт
}
i += size
}

Коротко. См. выше: встроенный неизменяемый тип-значение из указателя и длины, сравнимый и хешируемый, с UTF-8 по соглашению.

Глубже. Формально string — предопределённый тип из группы «basic types» спецификации; он comparable (значит, годится в ключи map и в ==), ordered (значит, работает в <, sort.Strings, и подходит под констрейнт cmp.Ordered в дженериках), и у него есть нетипизированные строковые константы, вычисляемые на этапе компиляции (const greeting = "hello, " + "world" не делает никакой работы в рантайме).

Коротко. Аллоцируется новый массив байт под сумму длин, туда копируются оба (или все) операнда, возвращается новый заголовок. Исходные строки не меняются. В цикле это даёт O(n²).

Глубже. Компилятор группирует цепочку a + b + c + d в один вызов runtime.concatstrings со слайсом операндов — то есть одна аллокация на всё выражение, а не три. Если суммарная длина ≤ 32 байт и результат не убегает из функции, используется стековый tmpBuf, и кучи не будет вовсе. Отдельная оптимизация: конкатенация констант вычисляется на этапе компиляции. Ничего из этого не спасает цикл — там на каждой итерации новая аллокация всё большего размера и копирование уже накопленного, поэтому 100 тыс. += по 10 байт скопируют около 50 ГБ. Проверяется бенчмарком с -benchmem: Builder даст единицы аллокаций, += — десятки тысяч.

Коротко. Если куски уже в слайсе — strings.Join(parts, sep): он считает итоговую длину и делает ровно одну аллокацию. Если куски приходят потоком — strings.Builder с предварительным Grow(estimatedSize).

Глубже. Разница по порядку величин: += в цикле — O(n²) копирований и n аллокаций; Builder без Grow — амортизированное O(n) с ~log₂(n) реаллокаций; Builder с точным Grow или Join — одна аллокация и одно копирование каждого байта. Если результат не нужен как строка (например, пишем в файл или в HTTP-ответ), не собирайте его вовсе — пишите в io.Writer через bufio.Writer, и пик памяти будет равен размеру буфера, а не 100к строк.

// вариант A: куски известны
s := strings.Join(parts, "")
// вариант B: поток
var b strings.Builder
b.Grow(total) // если total можно оценить
for _, p := range parts {
b.WriteString(p)
}
s = b.String()

Коротко. Потому что кодовые точки кириллицы лежат в диапазоне U+0400–U+04FF, а UTF-8 кодирует одним байтом только U+0000–U+007F (ASCII); всё до U+07FF — двумя байтами.

Глубже. Механика: двухбайтовая форма — 110xxxxx 10xxxxxx, то есть 11 значащих бит, максимум U+07FF. «П» = U+041F = 100 0001 111111010000 10011111 = 0xD0 0x9F. Это цена совместимости: разработчики UTF-8 сознательно отдали однобайтовое пространство целиком под ASCII, чтобы существующие программы и протоколы продолжили работать без изменений, а всё остальное сделали многобайтовым, но самосинхронизирующимся. Если бы кириллице выдали однобайтовые коды (как в CP1251/KOI8-R), пришлось бы таскать с каждым текстом информацию о кодировке — ровно та боль, от которой UTF-8 и избавляет.

Коротко. Не построчными INSERT, а массовой загрузкой: в PostgreSQL — COPY (в Go — pgx.CopyFrom), в MySQL — LOAD DATA INFILE или мультизначные INSERT пачками; батчи по 1–10 тыс. строк в одной транзакции, параллельно несколькими воркерами, с отключёнными на время загрузки вторичными индексами и триггерами.

Глубже. Чек-лист, который ждут:

  • Протокол. Построчный INSERT — это round-trip и парсинг запроса на каждую строку. COPY через pgx.CopyFrom даёт бинарный поток и на порядок-два выше пропускную способность; без pgx — хотя бы prepared statement + INSERT ... VALUES (...),(...),... на 1000 строк.
  • Транзакции. Одна транзакция на 100 млн строк — это гигантский WAL, долгий откат и распухший undo/xmin horizon. Батчи по 1–10 тыс. с коммитом и возможностью возобновиться с последнего offset.
  • Индексы и ограничения. Создавать индексы после загрузки (CREATE INDEX на заполненной таблице быстрее, чем поддержание при вставке), внешние ключи и триггеры отключать/добавлять потом.
  • Идемпотентность. ON CONFLICT DO NOTHING/DO UPDATE или staging-таблица с последующим INSERT ... SELECT, чтобы перезапуск после падения не дублировал данные.
  • Параллелизм. Несколько воркеров по непересекающимся диапазонам ключей; узкое место обычно диск и WAL, поэтому наращивать конкурентность нужно по метрикам, а не «на глаз». В Go — пул горутин + канал батчей + errgroup для отмены по первой ошибке.
  • Настройки БД на время загрузки. Увеличить maintenance_work_mem, при возможности загружать в UNLOGGED таблицу и потом делать SET LOGGED, отложить ANALYZE/VACUUM до конца.
  • Партиционирование. Если таблица партиционирована по времени/хешу, грузим партиции независимо и параллельно.

Коротко. Вопрос двусмысленный. Если про Go-строку — блокировать нечего: строка неизменяема, конкурентное чтение безопасно без синхронизации; защищать нужно переменную, в которой лежит строка (sync.Mutex, sync.RWMutex или atomic.Pointer[string]/atomic.Value). Если про строку в БД — SELECT ... FOR UPDATE внутри транзакции.

Глубже. По Go: запись в переменную типа string не атомарна — это два слова, и конкурентная запись с чтением даёт настоящую гонку, при которой можно увидеть указатель от одной строки и длину от другой (и, как следствие, чтение за пределами буфера). Поэтому либо мьютекс, либо atomic.Pointer[string] (Go 1.19+), либо публикация через канал.

var mu sync.RWMutex
var s string
func get() string { mu.RLock(); defer mu.RUnlock(); return s }
func set(v string) { mu.Lock(); defer mu.Unlock(); s = v }
// либо без мьютекса:
var p atomic.Pointer[string]

По БД: SELECT ... FOR UPDATE берёт эксклюзивную блокировку строки до конца транзакции; FOR NO KEY UPDATE — более слабая, не мешает FK-проверкам; FOR SHARE — разделяемая. SKIP LOCKED пропускает уже занятые строки (паттерн очереди задач), NOWAIT — падает вместо ожидания. Не забыть про порядок захвата строк, иначе deadlock, и про то, что оптимистичная блокировка через колонку version часто лучше пессимистичной.

Есть очень большое число в виде строки - “23148728156921356234 ”. Нужно умножить его на int цифру 0-9;

Заголовок раздела «Есть очень большое число в виде строки - “23148728156921356234 ”. Нужно умножить его на int цифру 0-9;»

Коротко. Число не влезает в int64, поэтому умножаем «в столбик»: идём по цифрам справа налево, на каждом шаге cur := digit*d + carry, записываем cur%10, переносим cur/10; в конце дописываем оставшийся перенос и разворачиваем результат. O(n) времени, O(n) памяти. Альтернатива «в лоб» — math/big.Int.

Глубже. Ручная реализация (её обычно и хотят увидеть):

package main
import (
"fmt"
"strings"
)
func mulByDigit(s string, d int) string {
if d == 0 || s == "0" {
return "0"
}
buf := make([]byte, 0, len(s)+1)
carry := 0
for i := len(s) - 1; i >= 0; i-- {
if s[i] < '0' || s[i] > '9' {
panic("not a digit")
}
cur := int(s[i]-'0')*d + carry
buf = append(buf, byte('0'+cur%10))
carry = cur / 10
}
for carry > 0 {
buf = append(buf, byte('0'+carry%10))
carry /= 10
}
// развернуть
for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
buf[i], buf[j] = buf[j], buf[i]
}
return string(buf)
}
func main() {
fmt.Println(mulByDigit("23148728156921356234", 7)) // 162041097098449493638
fmt.Println(strings.Repeat("-", 3))
}

Идём по байтам, а не по рунам — цифры ASCII, это корректно и быстрее. Крайние случаи: d == 0 → «0», ведущие нули на входе, знак минус, пустая строка. Если разрешено — new(big.Int).SetString(s, 10) и Mul решают задачу в три строки, но интервьюер обычно хочет ручной алгоритм.

Реализовать RPN-калькулятор (обратная польская нотация). Нужно написать функцию которая на вход принимает строку а на выходе отдает результат. Пример выражения - “3 4 + 2 _ 1 +”

Заголовок раздела «Реализовать RPN-калькулятор (обратная польская нотация). Нужно написать функцию которая на вход принимает строку а на выходе отдает результат. Пример выражения - “3 4 + 2 _ 1 +”»

Коротко. Стек чисел: токен-число кладём в стек, токен-оператор снимает два верхних операнда, применяет операцию и кладёт результат обратно. В конце в стеке должно остаться ровно одно значение. O(n) времени и памяти.

Глубже. Важен порядок операндов: снимается сначала правый, потом левый (b затем a, считаем a op b) — на вычитании и делении перепутанный порядок сразу даёт неверный ответ. Ошибки (недостаточно операндов, деление на ноль, мусорный токен, лишние значения в конце) возвращаем как error, а не паникуем.

package main
import (
"errors"
"fmt"
"strconv"
"strings"
)
func EvalRPN(expr string) (float64, error) {
stack := make([]float64, 0, 8)
for _, tok := range strings.Fields(expr) {
switch tok {
case "+", "-", "*", "/":
if len(stack) < 2 {
return 0, fmt.Errorf("not enough operands for %q", tok)
}
b, a := stack[len(stack)-1], stack[len(stack)-2]
stack = stack[:len(stack)-2]
var res float64
switch tok {
case "+":
res = a + b
case "-":
res = a - b
case "*":
res = a * b
case "/":
if b == 0 {
return 0, errors.New("division by zero")
}
res = a / b
}
stack = append(stack, res)
default:
v, err := strconv.ParseFloat(tok, 64)
if err != nil {
return 0, fmt.Errorf("bad token %q: %w", tok, err)
}
stack = append(stack, v)
}
}
if len(stack) != 1 {
return 0, errors.New("invalid expression")
}
return stack[0], nil
}
func main() {
fmt.Println(EvalRPN("3 4 + 2 * 1 +")) // 15 <nil>
}

strings.Fields удобнее strings.Split(expr, " "), потому что схлопывает любые пробельные последовательности и не даёт пустых токенов. В примере из задания _, судя по всему, — искажённая *; на собеседовании это стоит уточнить вслух. Если попросят целочисленный вариант — замените float64 на int и ParseFloat на Atoi, не забыв про деление на ноль (паника integer divide by zero).

Реализовать функцию которая из текста выпиливает смайлики:

Заголовок раздела «Реализовать функцию которая из текста выпиливает смайлики:»

Коротко. Проходим по рунам и выбрасываем те, что попадают в эмодзи-диапазоны Unicode, плюс служебные: ZWJ (U+200D), селекторы вариаций (U+FE0F/U+FE0E), модификаторы тона кожи (U+1F3FB–U+1F3FF), региональные индикаторы флагов (U+1F1E6–U+1F1FF). Удобно через strings.Map, возвращая -1 для удаляемой руны.

Глубже. В стандартной библиотеке нет таблицы свойства Emoji (пакет unicode содержит только скрипты и категории), поэтому либо перечисляем диапазоны руками, либо берём github.com/forPelevin/gomoji / генерируем таблицу из emoji-data.txt. Ручной вариант:

package main
import (
"fmt"
"strings"
"unicode"
)
var emojiRanges = &unicode.RangeTable{
R16: []unicode.Range16{
{Lo: 0x200D, Hi: 0x200D, Stride: 1}, // ZWJ
{Lo: 0x2190, Hi: 0x21FF, Stride: 1}, // стрелки
{Lo: 0x2300, Hi: 0x23FF, Stride: 1}, // технические символы
{Lo: 0x2600, Hi: 0x27BF, Stride: 1}, // Misc symbols + Dingbats
{Lo: 0x2B00, Hi: 0x2BFF, Stride: 1}, // Misc symbols and arrows
{Lo: 0xFE00, Hi: 0xFE0F, Stride: 1}, // variation selectors
},
R32: []unicode.Range32{
{Lo: 0x1F000, Hi: 0x1FAFF, Stride: 1}, // основной блок эмодзи
{Lo: 0x1F1E6, Hi: 0x1F1FF, Stride: 1}, // региональные индикаторы (флаги)
{Lo: 0xE0020, Hi: 0xE007F, Stride: 1}, // tag characters (флаги регионов)
},
}
func StripEmoji(s string) string {
out := strings.Map(func(r rune) rune {
if unicode.Is(emojiRanges, r) {
return -1
}
return r
}, s)
return strings.Join(strings.Fields(out), " ") // подчистить двойные пробелы
}
func main() {
fmt.Printf("%q\n", StripEmoji("привет 👋 мир 👨‍👩‍👧‍👦!"))
}

Что обсудить вслух: диапазоны пересекаются с обычными типографскими символами (стрелки, ✓, ™), поэтому «правильный» ответ зависит от требований — иногда достаточно вырезать только 0x1F300–0x1FAFF и 0xFE0F. Составные эмодзи (ZWJ-последовательности, флаги из двух региональных индикаторов) удаляются целиком именно потому, что мы выбрасываем все их компоненты, включая ZWJ. И после удаления остаются лишние пробелы — их обычно тоже просят схлопнуть.

Что такое строка и руна, и чем они отличаются?

Заголовок раздела «Что такое строка и руна, и чем они отличаются?»

Коротко. Строка — неизменяемая последовательность байт в UTF-8 ({ptr,len}, 16 байт заголовка). Руна — одно число типа int32, кодовая точка Unicode. Строка — контейнер, руна — элемент; строка индексируется байтами, а рунами только итерируется или конвертируется.

Глубже. Полезная таблица различий в голове: len(s) → байты, utf8.RuneCountInString(s) → руны; s[i]byte, rangerune; []byte(s) копирует len(s) байт, []rune(s) — аллоцирует 4×(число рун); одна руна в строке занимает 1–4 байта, а как значение — всегда 4. И string(rune(1055)) даёт "П" (2 байта), а string(byte(208)) даёт невалидную для UTF-8 однобайтовую строку.

Почему русские символы занимают два, а не один байт? Для чего это сделано?

Заголовок раздела «Почему русские символы занимают два, а не один байт? Для чего это сделано?»

Коротко. См. выше про кириллицу: однобайтовое пространство UTF-8 целиком отдано под ASCII (U+0000–U+007F) ради обратной совместимости, а кириллица (U+0400–U+04FF) попадает в двухбайтовый диапазон U+0080–U+07FF.

Глубже. «Для чего» — три причины. Первая: любой ASCII-текст является валидным UTF-8 без изменений, поэтому весь существовавший софт, протоколы (HTTP, SMTP, C-строки) и файлы продолжили работать. Вторая: самосинхронизация — по любому байту видно, ведущий он или продолжение, значит поиск подстроки и восстановление после ошибки не требуют разбора с начала файла. Третья: одна универсальная кодировка вместо зоопарка CP1251/KOI8-R/ISO-8859-5, где один и тот же байт означал разные буквы и текст было невозможно интерпретировать без внешней информации о кодировке. Плата — русский текст в UTF-8 примерно вдвое объёмнее, чем в CP1251; на практике это лечится сжатием, где разница почти исчезает.

Что будет при попытке изменить байт строки?

Заголовок раздела «Что будет при попытке изменить байт строки?»

Коротко. Ошибка компиляции, а не паника: s[0] = 'a'cannot assign to s[0] (neither addressable nor a map index expression). Строка неизменяема на уровне системы типов, элементы строки даже не адресуемы.

Глубже. Если обойти проверку через unsafe — получите worst case: для строкового литерала байты лежат в read-only секции, и запись даст SIGSEGV (unexpected fault address), а для строки, построенной в рантайме, запись «сработает», но нарушит инварианты — та же строка могла быть ключом map (хеш уже посчитан) или подстрокой, которую видят другие горутины. Поэтому это гарантированное UB, а не «хак».

s := "hello"
// s[0] = 'H' // compile error
// так делать нельзя:
// b := unsafe.Slice(unsafe.StringData(s), len(s)); b[0] = 'H' // SIGSEGV на литерале

Коротко. Скопировать в []byte, изменить нужный байт и собрать новую строку: b := []byte(s); b[0] = 'H'; s = string(b). Это две аллокации и два копирования; исходная строка остаётся прежней.

Глубже. Если правок много, работайте с []byte всё время и конвертируйте один раз в конце. Для замен по шаблону есть strings.Replace/strings.NewReplacer (последний эффективен при множестве пар замены — строит автомат и делает один проход). Помните: правка байта корректна только для ASCII; для не-ASCII символа надо менять всю его многобайтовую последовательность, иначе получите битый UTF-8 и U+FFFD при следующем range.

b := []byte("hello")
b[0] = 'H'
fmt.Println(string(b)) // "Hello"
// то, что делать нельзя с кириллицей:
b2 := []byte("привет")
b2[0] = 'P' // затираем первый байт двухбайтовой 'п' — UTF-8 сломан
fmt.Println(utf8.ValidString(string(b2))) // false

Что происходит при преобразовании строки в []byte или []rune?

Заголовок раздела «Что происходит при преобразовании строки в []byte или []rune?»

Коротко. В общем случае — аллокация и копирование: []byte(s) копирует len(s) байт, []rune(s) декодирует UTF-8 и аллоцирует 4 байта на каждую руну. Иначе нельзя: слайс изменяем, а байты строки — нет, поэтому шарить их запрещено.

Глубже. Компилятор умеет обходиться без копии в нескольких зафиксированных паттернах, и их полезно перечислить: m[string(b)] (чтение из map по ключу из байтов), string(b) == "литерал" и switch string(b), for i, r := range []byte(s), а также []byte(s), если слайс заведомо не убегает и помещается в небольшой стековый буфер (32 байта, tmpBuf). Обратное преобразование string(b) тоже копирует, кроме случаев, когда компилятор доказал, что результат не переживёт вызов. Для явного zero-copy есть unsafe.String(unsafe.StringData(...))/unsafe.Slice (Go 1.20+) — законно только если вы гарантируете, что байты после этого никто не изменит; strings.Builder.String() использует ровно этот приём легально, потому что владеет буфером единолично. И отдельно: []rune(s) на невалидном UTF-8 подставит U+FFFD, так что round-trip string([]rune(s)) == s в общем случае не выполняется.

b := []byte("привет") // копия: 12 байт
r := []rune("привет") // копия: 6*4 = 24 байта, декодирование UTF-8
_ = len(b) + len(r)
// без аллокации — компилятор распознаёт паттерн:
m := map[string]int{"key": 1}
key := []byte("key")
fmt.Println(m[string(key)])
  • Говорят «len(s) возвращает количество символов». Возвращает количество байт; символы — utf8.RuneCountInString, а видимые глифы — вообще графемные кластеры из сторонней библиотеки.
  • Говорят, что в for i, r := range s первая переменная — порядковый номер символа. Это байтовое смещение: для «Привет» — 0,2,4,6,8,10.
  • Путают «руна занимает 4 байта» и «символ в строке занимает 4 байта». Как значение rune всегда 4 байта (int32); в UTF-8-строке — 1–4.
  • Считают, что s[0] = 'a' даст панику в рантайме. Это ошибка компиляции: элементы строки неадресуемы.
  • Уверены, что []byte(s) — бесплатная переинтерпретация без копии. Копия есть почти всегда, кроме нескольких заученных компилятором паттернов.
  • Собирают строку через += в цикле и не могут объяснить, почему это O(n²); либо называют bytes.Buffer оптимальнее strings.Builder, забывая, что у Buffer метод String() копирует.
  • Считают подстроку s[a:b] копией и удивляются, что маленький кусок удерживает в памяти гигантский исходник (лечится strings.Clone).
  • Думают, что битый UTF-8 вызовет панику в range. Нет: каждый невалидный байт становится U+FFFD, и итератор сдвигается на 1 байт.
  • Считают присваивание строковой переменной атомарным. Это два слова, конкурентная запись — настоящая гонка с возможностью «разорванного» заголовка.
  • Отвечают «&A{} == nil → true», путая пустую структуру с нулевым указателем.