Со строками в Go новички спотыкаются об одно и то же: len("привет") возвращает не 6, а 12. Разберёмся, почему строка устроена именно так, и соберём рабочий набор приёмов: обход, разбор, сборка, числа из строк.
Короткое знакомство уже было в статье Синтаксис и типы - здесь про строки целиком.
Что такое строка в Go
Строка - неизменяемая последовательность байт. Чаще всего это байты текста в кодировке UTF-8: латиница занимает один байт на символ, кириллица - два, иероглифы - три.
Отсюда все «странные» результаты:
живой пример
package main
import "fmt"
func main() {
s := "привет"
fmt.Println(len(s)) // 12: байтов
fmt.Println(s[0]) // 208: числовой код первого байта, не буква
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Три факта из этого блока:
lenсчитает байты, а не символы.s[0]- этоbyte(он жеuint8), то есть число; «достать букву» так не получится.- Изменять строку по индексу нельзя:
s[0] = 'П'- ошибка компиляции. Строка неизменяема, собирают новую.
Неизменяемость - не придирка, а инструмент: скопировать строку дешевле, чем массив (копируется только «шапка»: указатель на байты и длина), и две переменные могут безопасно делить один текст. Обратная сторона - конкатенация + создаёт новую строку каждый раз, и собирать текст в цикле через + дорого. Об этом ниже, в разделе про сборку.
Руны и обход по символам
Символ Unicode в Go называют руной (rune - псевдоним int32). Чтобы работать со строкой как с символами, её переводят в []rune или обходят через range - он сам шагает по рунам, а не по байтам:
живой пример
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "привет"
runes := []rune(s)
fmt.Println(len(runes)) // 6: символов
fmt.Println(string(runes[0])) // п: первая буква
fmt.Println(utf8.RuneCountInString(s)) // 6: то же без копии
for i, r := range s {
fmt.Printf("%d:%s ", i, string(r)) // 0:п 2:р 4:и ...
}
fmt.Println()
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Смотрите на индексы в выводе: range идёт по рунам, но отдаёт индекс первого байта руны - 0, 2, 4, 6, потому что каждая буква занимает два байта. Это отвечает на классический вопрос собеседований: почему индексы «с дырками».
Обратное преобразование тоже простое: string(runes) собирает руну или срез рун обратно в строку. Частые операции, которые отсюда вырастают:
- Взять i-й символ:
string([]rune(s)[i]). - Отрезать первые n символов:
string([]rune(s)[:n])- резать строку байтовыми индексами посреди буквы нельзя, получится сломанный символ. - Посчитать символы:
utf8.RuneCountInString(s).
Для латиницы []rune - лишняя копия: там байт равен символу. Проверяйте данные, прежде чем оптимизировать.
Разбор строки на части
Животрепещущая задача backend-разработчика: разобрать строку с разделителями. Пакет strings:
живой пример
package main
import (
"fmt"
"strings"
)
func main() {
csv := "идём;в;лес;по;;грибы"
parts := strings.Split(csv, ";")
fmt.Println(len(parts), parts) // 6 [идём в лес по грибы]
words := strings.Fields(" кот и мышь\n")
fmt.Println(words) // [кот и мышь]
fmt.Println(strings.Join(words, "-")) // кот-и-мышь
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Три инструмента, каждый со своим характером:
strings.Split(s, ";")режет по разделителю. Пустые куски между;;сохраняются - это важно при разборе CSV с пустыми полями. Разделитель-строка может быть длиннее одного символа.strings.Fields(s)режет по любым пробельным символам (пробел, табуляция, перевод строки) и выкидывает пустые - идеально для «слов» текста.strings.Join(parts, "-")собирает обратно - и это самый быстрый способ склеить известный список строк.
Специальные случаи: strings.SplitN(s, ";", 2) отрежет только первый разделитель, остальное оставит как есть (парсить «ключ:значение:с-двоеточиями»), а strings.Split("", ";") вернёт срез из одной пустой строки - не нулевой длины.
Проверки и очистка
Вторая половина работы со строками - проверить и почистить пользовательский ввод:
живой пример
package main
import (
"fmt"
"strings"
)
func main() {
email := " User@Mail.RU "
email = strings.ToLower(strings.TrimSpace(email))
fmt.Println(email) // user@mail.ru
fmt.Println(strings.Contains(email, "@")) // true
fmt.Println(strings.HasSuffix(email, ".ru")) // true
fmt.Println(strings.HasPrefix(email, "user")) // true
fmt.Println(strings.Index(email, "@")) // 4: байтовый индекс, -1 если нет
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Базовый набор: Contains (входит ли), HasPrefix и HasSuffix (начинается ли / заканчивается ли), Index (где встречается, -1 если нигде), TrimSpace (срезать пробелы по краям), ToLower и ToUpper (регистр). У Trim есть версии с набором символов: strings.Trim(s, "(),. ") срежет скобки, запятые и пробелы с обоих концов, TrimLeft и TrimRight - с одного.
Приводить email к нижнему регистру до проверки или записи в базу - типовая практика: пользователь мог ввести адрес капсом, и это всё ещё тот же адрес.
Замена и повторение
Replace меняет части строки, Repeat повторяет её:
живой пример
package main
import (
"fmt"
"strings"
)
func main() {
masked := strings.Replace("карта 4276-1234-5678-9012", "5678-9012", "****-****", 1)
fmt.Println(masked) // карта 4276-1234-****-****
fmt.Println(strings.ReplaceAll("a-b-c", "-", "+")) // a+b+c
fmt.Println(strings.Repeat("=", 20)) // ====================
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Третий аргумент Replace - сколько вхождений менять; strings.ReplaceAll - короткая запись «все». Замена возвращает новую строку: исходная остаётся какой была.
Нюанс с длиной: strings.Repeat и strings.Replace не примут отрицательный счётчик - паника. Счётчик пришёл из пользовательского ввода - проверь его до вызова.
Сборка строки в цикле
Собрать строку из тысяч кусков через + - классическая ошибка по производительности: каждое сложение копирует оба операнда в новую строку, и квадрат объема работы растёт быстро. Правильный инструмент - strings.Builder:
живой пример
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
for i := 1; i <= 5; i++ {
fmt.Fprintf(&b, "строка %d\n", i)
}
fmt.Print(b.String())
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Builder копит байты внутри и отдаёт результат одним куском через b.String(). Туда же пишут b.WriteString(...) и b.WriteRune(...). После b.String() билдер продолжает работать, а вот копировать его нельзя - передавайте указатель.
Как выбрать способ сборки:
- Известный список строк -
strings.Join. - Строится в цикле, особенно с числами -
strings.Builderсfmt.Fprintf(&b, ...). - Пара-тройка кусков - обычный
+, читается лучше всего.
fmt.Sprintf («сделай строку по шаблону») хорош для одного раза, но в цикле создаёт мусор для сборщика - билдер дешевле.
Числа из строк: strconv
Числа приходят строками: из HTTP-запроса, переменных окружения, конфига. Пакет strconv переводит в обе стороны и честно сообщает об ошибке:
живой пример
package main
import (
"fmt"
"strconv"
)
func main() {
n, err := strconv.Atoi("42")
fmt.Println(n, err) // 42 <nil>
bad, err := strconv.Atoi("42кг")
fmt.Println(bad, err != nil) // 0 true
fmt.Println(strconv.Itoa(255)) // "255"
fmt.Println(strconv.FormatInt(255, 16)) // "ff": в шестнадцатеричной
price, err := strconv.ParseFloat("19.90", 64)
fmt.Println(price, err) // 19.9 <nil>
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Atoi («ASCII to integer») возвращает int и error: пришёл мусор - получите ноль и ошибку, а не панику. Обратное направление - strconv.Itoa, строка из числа. Для float есть ParseFloat(s, 64) и FormatFloat. Аналогично устроены ParseBool и ParseUint.
Альтернатива - fmt.Sprintf("%d", n) и обратный fmt.Sscanf. Для единичного вызова разницы нет; в цикле strconv заметно быстрее, потому что не разбирает шаблон.
Правило то же, что с ошибками вообще: проигнорировал err через _ - получил ноль вместо числа и молча пошёл дальше. Для пользовательского ввода так делать не стоит.
Сырые литералы и многострочный текст
Обычный строковый литерал в двойных кавычках поддерживает escape-последовательности: \n, \t, \". Когда текст длинный или в нём много кавычек и обратных слэшей (регулярки, пути Windows, SQL), берут сырой литерал в обратных кавычках:
живой пример
package main
import "fmt"
func main() {
query := `
select id, name
from users
where created_at > now() - interval '7 days'
`
fmt.Println(query)
path := `C:\temp\отчёт.txt`
fmt.Println(path)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Внутри обратных кавычек всё буквально: переводы строк остаются переводами строк, \t - это реальная табуляция в тексте, а не символ \ с буквой. Единственное, что нельзя - саму обратную кавычку внутрь. Переводы строк в таком литерале сохраняются как есть: начальная и конечная пустые строки из примера тоже попадут в строку, и их часто срезают TrimSpace-ом при использовании.
Глубже: сравнение и сортировкарасширенное
Строки сравниваются операторами ==, <, > - по байтам, лексикографически. Для латиницы это совпадает с алфавитом, для смешанного регистра - нет: все заглавные буквы «меньше» строчных, потому что так расположены их коды. "Яблоко" < "ананас" - истинная проверка: заглавная «Я» по коду меньше строчной «а».
Сортировка - обычный sort.Strings; когда нужен порядок без учёта регистра, задают свой компаратор:
живой пример
package main
import (
"fmt"
"sort"
"strings"
)
func main() {
names := []string{"Яша", "анна", "Борис"}
sort.Strings(names)
fmt.Println(names) // [Борис Яша анна]: по байтам заглавные меньше строчных
sort.Slice(names, func(i, j int) bool {
return strings.ToLower(names[i]) < strings.ToLower(names[j])
})
fmt.Println(names) // [анна Борис Яша]: без учёта регистра
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Чтобы сравнивать без учёта регистра, приводят обе строки к одному регистру: strings.ToLower(a) < strings.ToLower(b). Для честной лингвистической сортировки (ё после е, диакритика) используют сопоставления из golang.org/x/text/collate - в учебных задачах хватает ToLower.
Сравнение по байтам - быстрое и предсказуемое, на нём работают ключи map и сортировка по умолчанию; «человеческий» порядок - отдельная задача поверх него.
Глубже: глаголы fmtрасширенное
fmt.Printf, Sprintf и Errorf собирают строку по шаблону, а шаблон состоит из глаголов (placeholder-ов), которые подставляют значение в разных видах:
живой пример
package main
import "fmt"
type User struct {
Name string
Age int
}
func main() {
u := User{Name: "Анна", Age: 30}
fmt.Printf("%v\n", u) // {Анна 30}: значения через пробел
fmt.Printf("%+v\n", u) // {Name:Анна Age:30}: с именами полей
fmt.Printf("%T\n", u) // main.User: тип
fmt.Printf("%q\n", u.Name) // "Анна": строка в кавычках
fmt.Printf("%d лет\n", u.Age) // 30 лет: целое число
s := fmt.Sprintf("пользователь %s, %d лет", u.Name, u.Age)
fmt.Println(s)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Рабочий минимум, который решает почти всё:
%v- любое значение как есть;%+v- то же с именами полей структуры (лучший друг при отладке).%T- тип значения;%q- строка в кавычках (наглядно ловит невидимые пробелы).%s,%d,%f- строка, целое, дробное (у%fможно задать точность:%.2f).Sprintfвозвращает строку,Printfпечатает,Errorfделает ошибку - глаголы одни и те же.
Отладочный принт через %+v быстрее любого логгера, когда нужно просто посмотреть, что за структура ходит по коду.
Коротко
- Строка - неизменяемые байты UTF-8:
lenсчитает байты,s[i]- байт-число, менять по индексу нельзя. - Символ - руна:
[]rune(s)для индексов и нарезки,rangeидёт по рунам и отдаёт индекс первого байта,utf8.RuneCountInStringсчитает символы. - Разбор:
Split(пустые куски сохраняются),Fields(режет по пробелам, пустые выкидывает),SplitN(s, sep, 2)для «первый разделитель и хвост»,Join- обратно. - Проверки и очистка:
Contains,HasPrefix/HasSuffix,Index,TrimSpace,Trim,ToLower/ToUpper- пользовательский ввод чистят до проверки. - Замена:
Replace(s, из, в, сколько)иReplaceAll;Repeatповторяет, отрицательный счётчик - паника. - Сборка:
strings.Joinдля списка,strings.Builderдля цикла,+для пары кусков;fmt.Sprintfв цикле - лишний мусор. - Числа:
strconv.Atoi/Itoa,ParseFloat/FormatFloat- с ошибкой, которую проверяют, а не_. - Сырой литерал в обратных кавычках хранит текст буквально, включая переводы строк.
- Сравнение - по байтам: регистр и алфавит не учитываются; без учёта регистра сравнивают
ToLower-версии.
Что почитать дальше
- Функции в Go - функции-параметры, на которых строятся компараторы сортировки.
- Срезы и map - как хранить и перебирать наборы строк.
- Рантайм Go - почему конкатенация в цикле медленная: кто и когда решает, где жить строке.