Со строками в Go новички спотыкаются об одно и то же: len("привет") возвращает не 6, а 12. Разберёмся, почему строка устроена именно так, и соберём рабочий набор приёмов: обход, разбор, сборка, числа из строк.

Короткое знакомство уже было в статье Синтаксис и типы - здесь про строки целиком.

Обязательно

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

Строка - неизменяемая последовательность байт. Чаще всего это байты текста в кодировке UTF-8: латиница занимает один байт на символ, кириллица - два, иероглифы - три.

Отсюда все «странные» результаты:

live example

package main

import "fmt"

func main() {
    s := "привет"
    fmt.Println(len(s)) // 12: байтов
    fmt.Println(s[0])   // 208: числовой код первого байта, не буква
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Три факта из этого блока:

  • len считает байты, а не символы.
  • s[0] - это byte (он же uint8), то есть число; «достать букву» так не получится.
  • Изменять строку по индексу нельзя: s[0] = 'П' - ошибка компиляции. Строка неизменяема, собирают новую.

Неизменяемость - не придирка, а инструмент: скопировать строку дешевле, чем массив (копируется только «шапка»: указатель на байты и длина), и две переменные могут безопасно делить один текст. Обратная сторона - конкатенация + создаёт новую строку каждый раз, и собирать текст в цикле через + дорого. Об этом ниже, в разделе про сборку.

Руны и обход по символам

Символ Unicode в Go называют руной (rune - псевдоним int32). Чтобы работать со строкой как с символами, её переводят в []rune или обходят через range - он сам шагает по рунам, а не по байтам:

live example

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()
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Смотрите на индексы в выводе: range идёт по рунам, но отдаёт индекс первого байта руны - 0, 2, 4, 6, потому что каждая буква занимает два байта. Это отвечает на классический вопрос собеседований: почему индексы «с дырками».

Обратное преобразование тоже простое: string(runes) собирает руну или срез рун обратно в строку. Частые операции, которые отсюда вырастают:

  • Взять i-й символ: string([]rune(s)[i]).
  • Отрезать первые n символов: string([]rune(s)[:n]) - резать строку байтовыми индексами посреди буквы нельзя, получится сломанный символ.
  • Посчитать символы: utf8.RuneCountInString(s).

Для латиницы []rune - лишняя копия: там байт равен символу. Проверяйте данные, прежде чем оптимизировать.

Разбор строки на части

Животрепещущая задача backend-разработчика: разобрать строку с разделителями. Пакет strings:

live example

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, "-")) // кот-и-мышь
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Три инструмента, каждый со своим характером:

  • strings.Split(s, ";") режет по разделителю. Пустые куски между ;; сохраняются - это важно при разборе CSV с пустыми полями. Разделитель-строка может быть длиннее одного символа.
  • strings.Fields(s) режет по любым пробельным символам (пробел, табуляция, перевод строки) и выкидывает пустые - идеально для «слов» текста.
  • strings.Join(parts, "-") собирает обратно - и это самый быстрый способ склеить известный список строк.

Специальные случаи: strings.SplitN(s, ";", 2) отрежет только первый разделитель, остальное оставит как есть (парсить «ключ:значение:с-двоеточиями»), а strings.Split("", ";") вернёт срез из одной пустой строки - не нулевой длины.

Проверки и очистка

Вторая половина работы со строками - проверить и почистить пользовательский ввод:

live example

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 если нет
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Базовый набор: Contains (входит ли), HasPrefix и HasSuffix (начинается ли / заканчивается ли), Index (где встречается, -1 если нигде), TrimSpace (срезать пробелы по краям), ToLower и ToUpper (регистр). У Trim есть версии с набором символов: strings.Trim(s, "(),. ") срежет скобки, запятые и пробелы с обоих концов, TrimLeft и TrimRight - с одного.

Приводить email к нижнему регистру до проверки или записи в базу - типовая практика: пользователь мог ввести адрес капсом, и это всё ещё тот же адрес.

Замена и повторение

Replace меняет части строки, Repeat повторяет её:

live example

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))               // ====================
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Третий аргумент Replace - сколько вхождений менять; strings.ReplaceAll - короткая запись «все». Замена возвращает новую строку: исходная остаётся какой была.

Нюанс с длиной: strings.Repeat и strings.Replace не примут отрицательный счётчик - паника. Счётчик пришёл из пользовательского ввода - проверь его до вызова.

Сборка строки в цикле

Собрать строку из тысяч кусков через + - классическая ошибка по производительности: каждое сложение копирует оба операнда в новую строку, и квадрат объема работы растёт быстро. Правильный инструмент - strings.Builder:

live example

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())
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Builder копит байты внутри и отдаёт результат одним куском через b.String(). Туда же пишут b.WriteString(...) и b.WriteRune(...). После b.String() билдер продолжает работать, а вот копировать его нельзя - передавайте указатель.

Как выбрать способ сборки:

  • Известный список строк - strings.Join.
  • Строится в цикле, особенно с числами - strings.Builder с fmt.Fprintf(&b, ...).
  • Пара-тройка кусков - обычный +, читается лучше всего.

fmt.Sprintf («сделай строку по шаблону») хорош для одного раза, но в цикле создаёт мусор для сборщика - билдер дешевле.

Числа из строк: strconv

Числа приходят строками: из HTTP-запроса, переменных окружения, конфига. Пакет strconv переводит в обе стороны и честно сообщает об ошибке:

live example

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>
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

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), берут сырой литерал в обратных кавычках:

live example

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)
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Внутри обратных кавычек всё буквально: переводы строк остаются переводами строк, \t - это реальная табуляция в тексте, а не символ \ с буквой. Единственное, что нельзя - саму обратную кавычку внутрь. Переводы строк в таком литерале сохраняются как есть: начальная и конечная пустые строки из примера тоже попадут в строку, и их часто срезают TrimSpace-ом при использовании.

Дополнительно: при первом чтении можно пропустить

Глубже: сравнение и сортировкарасширенное

Строки сравниваются операторами ==, <, > - по байтам, лексикографически. Для латиницы это совпадает с алфавитом, для смешанного регистра - нет: все заглавные буквы «меньше» строчных, потому что так расположены их коды. "Яблоко" < "ананас" - истинная проверка: заглавная «Я» по коду меньше строчной «а».

Сортировка - обычный sort.Strings; когда нужен порядок без учёта регистра, задают свой компаратор:

live example

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) // [анна Борис Яша]: без учёта регистра
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Чтобы сравнивать без учёта регистра, приводят обе строки к одному регистру: strings.ToLower(a) < strings.ToLower(b). Для честной лингвистической сортировки (ё после е, диакритика) используют сопоставления из golang.org/x/text/collate - в учебных задачах хватает ToLower.

Сравнение по байтам - быстрое и предсказуемое, на нём работают ключи map и сортировка по умолчанию; «человеческий» порядок - отдельная задача поверх него.

Глубже: глаголы fmtрасширенное

fmt.Printf, Sprintf и Errorf собирают строку по шаблону, а шаблон состоит из глаголов (placeholder-ов), которые подставляют значение в разных видах:

live example

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)
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Рабочий минимум, который решает почти всё:

  • %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 - почему конкатенация в цикле медленная: кто и когда решает, где жить строке.