$ sudo teach IT
МОДУЛЬ 3 · УРОК 3.3

Строки и работа с ними

Разбираемся, как Go хранит текст, учимся резать, склеивать, искать подстроки и правильно работать с кириллицей

В прошлых уроках мы изучили массивы, срезы и map. Все эти структуры хранят данные, но одна из самых повседневных структур данных в любом языке программирования — это строка. Имена пользователей, сообщения, адреса, пароли — всё это строки. В Go строки устроены немного иначе, чем в большинстве языков, и понимание их внутреннего устройства сэкономит вам часы отладки. Сегодня мы разберёмся, что такое строка в Go на самом деле, научимся работать с пакетами strings и strconv, поймём разницу между byte и rune, а в конце напишем настоящий анализатор текста.

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

В Go строка — это неизменяемая последовательность байтов. Не символов, а именно байтов. Это ключевой момент, который отличает Go от многих других языков. Строка в Go кодируется в UTF-8 — это стандарт, в котором один символ может занимать от 1 до 4 байт.

Давайте посмотрим на примере:

package main

import "fmt"

func main() {
    greeting := "Hello"
    fmt.Println(greeting)       // Hello
    fmt.Println(len(greeting))  // 5
}

Функция len() возвращает количество байтов в строке. Для латинских символов один символ = один байт, поэтому len("Hello") вернёт 5. Но посмотрите, что произойдёт с русским текстом:

package main

import "fmt"

func main() {
    word := "Привет"
    fmt.Println(word)       // Привет
    fmt.Println(len(word))  // 12, а не 6!
}

Слово «Привет» содержит 6 символов, но len() вернул 12. Почему? Потому что каждая кириллическая буква в кодировке UTF-8 занимает 2 байта, а len() считает именно байты.

💡 Подсказка: len() для строки возвращает количество байтов, а не символов. Чтобы узнать количество символов (рун), используйте utf8.RuneCountInString() из пакета unicode/utf8. Мы разберём это подробно чуть ниже.

Строки в Go неизменяемы. Вы не можете поменять отдельный байт в строке:

package main

func main() {
    s := "Hello"
    // s[0] = 'h'  // Ошибка компиляции! Строки неизменяемы.
}

Если вам нужно изменить строку, вы создаёте новую. Это может показаться неудобным, но на практике это делает строки безопасными для конкурентного использования и позволяет компилятору оптимизировать код.

Строки можно склеивать оператором +:

package main

import "fmt"

func main() {
    first := "Привет"
    second := "мир"
    result := first + ", " + second + "!"
    fmt.Println(result) // Привет, мир!
}

🔄 Обход строки: по байтам и по символам

Есть два способа пройтись по строке. Первый — обычный цикл for с индексом, который перебирает байты:

package main

import "fmt"

func main() {
    s := "Go!"
    for i := 0; i < len(s); i++ {
        fmt.Printf("Байт %d: %c (%d)\n", i, s[i], s[i])
    }
}

// Вывод:
// Байт 0: G (71)
// Байт 1: o (111)
// Байт 2: ! (33)

Это работает прекрасно для ASCII-символов (латиница, цифры, знаки). Но что если мы попробуем перебрать кириллицу таким же образом?

package main

import "fmt"

func main() {
    s := "Го"
    for i := 0; i < len(s); i++ {
        fmt.Printf("Байт %d: %d\n", i, s[i])
    }
}

// Вывод:
// Байт 0: 208
// Байт 1: 147
// Байт 2: 208
// Байт 3: 190

Вместо двух символов мы видим четыре байта! Буква «Г» состоит из байтов 208 и 147, а буква «о» — из байтов 208 и 190. Это и есть UTF-8 в действии.

Второй способ — for range. Он автоматически декодирует UTF-8 и перебирает строку посимвольно:

package main

import "fmt"

func main() {
    s := "Го!"
    for i, ch := range s {
        fmt.Printf("Позиция %d: %c (руна %d)\n", i, ch, ch)
    }
}

// Вывод:
// Позиция 0: Г (руна 1043)
// Позиция 2: о (руна 1086)
// Позиция 4: ! (руна 33)

Обратите внимание: for range выдаёт правильные символы, но позиции — байтовые. «Г» начинается с байта 0, «о» — с байта 2 (потому что «Г» заняла 2 байта), а «!» — с байта 4. Переменная ch здесь имеет тип rune, а не byte. Что такое rune, мы разберём совсем скоро.

🧰 Пакет strings — швейцарский нож для строк

Стандартная библиотека Go предоставляет мощный пакет strings с десятками полезных функций. Разберём самые важные.

strings.Contains — проверка вхождения

Проверяет, содержит ли строка указанную подстроку:

package main

import (
    "fmt"
    "strings"
)

func main() {
    text := "Go — отличный язык программирования"

    fmt.Println(strings.Contains(text, "Go"))     // true
    fmt.Println(strings.Contains(text, "Python"))  // false
    fmt.Println(strings.Contains(text, "отлич"))   // true
}

strings.Split — разделение строки

Разбивает строку по указанному разделителю и возвращает срез строк:

package main

import (
    "fmt"
    "strings"
)

func main() {
    csv := "яблоко,банан,вишня,груша"
    fruits := strings.Split(csv, ",")

    fmt.Println(fruits)        // [яблоко банан вишня груша]
    fmt.Println(len(fruits))   // 4
    fmt.Println(fruits[0])     // яблоко
    fmt.Println(fruits[2])     // вишня

    // Разделение по пробелам
    sentence := "Go это круто"
    words := strings.Split(sentence, " ")
    fmt.Println(words)         // [Go это круто]
}

strings.Join — склеивание среза строк

Обратная операция к Split — собирает срез строк в одну строку с указанным разделителем:

package main

import (
    "fmt"
    "strings"
)

func main() {
    words := []string{"Я", "учу", "Go"}
    sentence := strings.Join(words, " ")
    fmt.Println(sentence) // Я учу Go

    // Полезно для создания CSV
    data := []string{"Иван", "25", "Москва"}
    csv := strings.Join(data, ";")
    fmt.Println(csv) // Иван;25;Москва
}

strings.ToUpper и strings.ToLower — регистр

package main

import (
    "fmt"
    "strings"
)

func main() {
    text := "Привет, Мир!"

    fmt.Println(strings.ToUpper(text))  // ПРИВЕТ, МИР!
    fmt.Println(strings.ToLower(text))  // привет, мир!
}

strings.TrimSpace — удаление пробелов

Убирает пробелы, табуляции и переносы строк в начале и конце строки. Очень полезно при обработке пользовательского ввода:

package main

import (
    "fmt"
    "strings"
)

func main() {
    input := "   Привет, мир!   \n"
    clean := strings.TrimSpace(input)
    fmt.Printf("[%s]\n", clean) // [Привет, мир!]
}

strings.Replace — замена подстрок

Заменяет вхождения подстроки. Последний аргумент — количество замен (-1 означает «заменить все»):

package main

import (
    "fmt"
    "strings"
)

func main() {
    text := "кот сидел на коте, а кот смотрел на кота"

    // Заменить первые 2 вхождения
    fmt.Println(strings.Replace(text, "кот", "пёс", 2))
    // пёс сидел на пёсе, а кот смотрел на кота

    // Заменить все вхождения
    fmt.Println(strings.Replace(text, "кот", "пёс", -1))
    // пёс сидел на пёсе, а пёс смотрел на пёса

    // Или используйте ReplaceAll — то же самое, что Replace с -1
    fmt.Println(strings.ReplaceAll(text, "кот", "пёс"))
}

strings.HasPrefix и strings.HasSuffix — проверка начала и конца

package main

import (
    "fmt"
    "strings"
)

func main() {
    filename := "photo.jpg"

    fmt.Println(strings.HasPrefix(filename, "photo"))  // true
    fmt.Println(strings.HasSuffix(filename, ".jpg"))   // true
    fmt.Println(strings.HasSuffix(filename, ".png"))   // false
}

strings.Count — подсчёт вхождений

package main

import (
    "fmt"
    "strings"
)

func main() {
    text := "бананы и ананасы"
    fmt.Println(strings.Count(text, "ан"))  // 3
    fmt.Println(strings.Count(text, "а"))   // 4
}

Шпаргалка по пакету strings: Contains — содержит ли, Split — разделить, Join — склеить, ToUpper/ToLower — регистр, TrimSpace — убрать пробелы, Replace/ReplaceAll — заменить, HasPrefix/HasSuffix — проверить начало/конец, Count — посчитать вхождения.

🔢 Пакет strconv — строки в числа и обратно

Очень частая задача — преобразовать строку в число или число в строку. Для этого в Go есть пакет strconv (сокращение от string conversion).

strconv.Itoa — число в строку

Название Itoa расшифровывается как Integer to ASCII. Преобразует int в строку:

package main

import (
    "fmt"
    "strconv"
)

func main() {
    age := 25
    ageStr := strconv.Itoa(age)
    fmt.Println("Мне " + ageStr + " лет") // Мне 25 лет

    // Без strconv.Itoa так не получится:
    // fmt.Println("Мне " + age + " лет")  // Ошибка! Нельзя сложить string и int
}

strconv.Atoi — строку в число

Atoi — это ASCII to Integer. Преобразует строку в int. Эта функция возвращает два значения: число и ошибку:

package main

import (
    "fmt"
    "strconv"
)

func main() {
    // Успешное преобразование
    num, err := strconv.Atoi("42")
    if err != nil {
        fmt.Println("Ошибка:", err)
    } else {
        fmt.Println(num + 8) // 50
    }

    // Неуспешное преобразование
    num2, err2 := strconv.Atoi("привет")
    if err2 != nil {
        fmt.Println("Ошибка:", err2) // Ошибка: strconv.Atoi: parsing "привет": invalid syntax
    }
    fmt.Println(num2) // 0 — значение по умолчанию при ошибке
}

💡 Подсказка: strconv.Atoi() возвращает два значения — результат и ошибку. Это типичный паттерн в Go: вместо исключений (exceptions) Go возвращает ошибки как обычные значения. Всегда проверяйте ошибку! Если строку нельзя преобразовать в число, err будет не nil, а num будет 0.

Работа с дробными числами

Для преобразования строки в дробное число (float64) и обратно используются strconv.ParseFloat и strconv.FormatFloat:

package main

import (
    "fmt"
    "strconv"
)

func main() {
    // Строку в float64
    pi, err := strconv.ParseFloat("3.14159", 64)
    if err != nil {
        fmt.Println("Ошибка:", err)
        return
    }
    fmt.Println(pi * 2) // 6.28318

    // float64 в строку
    s := strconv.FormatFloat(pi, 'f', 2, 64)
    fmt.Println(s) // 3.14

    // Самый простой способ — через fmt.Sprintf
    s2 := fmt.Sprintf("%.2f", pi)
    fmt.Println(s2) // 3.14
}

На практике для простого преобразования числа в строку часто используют fmt.Sprintf() — он проще и универсальнее. А strconv.Itoa и strconv.Atoi удобны, когда работа идёт именно с целыми числами и важна производительность.

🔤 rune vs byte — два взгляда на символ

Это одна из самых важных тем при работе со строками в Go. Давайте разберёмся раз и навсегда.

byte — это псевдоним (alias) для типа uint8. Один байт, значение от 0 до 255. Достаточно для ASCII-символов (латиница, цифры, базовые знаки).

rune — это псевдоним для типа int32. Представляет один символ Юникода (кодовую точку). Может хранить любой символ: кириллицу, китайские иероглифы, эмодзи.

Представьте себе аналогию. Буква «A» — это маленькая коробочка, один байт. А буква «Я» — коробка побольше, два байта. Иероглиф или эмодзи — ещё больше. byte видит только маленькие коробочки, а rune видит целые символы, независимо от их размера.

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    text := "Привет, Go!"

    // Количество байтов
    fmt.Println("Байтов:", len(text))
    // 18 (6 кириллических × 2 + ", " + "Go!" = 12 + 2 + 1 + 2 + 1 = 18)

    // Количество символов (рун)
    fmt.Println("Символов:", utf8.RuneCountInString(text))
    // 11 (П-р-и-в-е-т-,-пробел-G-o-!)
}

Когда вы обращаетесь к строке по индексу s[i], вы получаете byte. Когда вы перебираете строку через for range, вы получаете rune:

package main

import "fmt"

func main() {
    s := "Яблоко"

    // По индексу — байт
    fmt.Printf("s[0] = %d (тип byte)\n", s[0])  // 208

    // Через for range — руна (символ)
    for _, ch := range s {
        fmt.Printf("%c ", ch)  // Я б л о к о
    }
    fmt.Println()
}

Если вам нужно изменить строку посимвольно, преобразуйте её в срез рун:

package main

import "fmt"

func main() {
    s := "Привет"

    // Преобразуем в срез рун
    runes := []rune(s)
    fmt.Println(runes)       // [1055 1088 1080 1074 1077 1090]
    fmt.Println(len(runes))  // 6 — теперь правильное количество символов

    // Можем менять отдельные символы
    runes[0] = 'М'           // Заменяем П на М
    runes[5] = 'р'           // Заменяем т на р
    result := string(runes)
    fmt.Println(result)      // Мривер
}

💡 Правило: Если вы работаете только с латиницей и ASCII — byte достаточно. Если текст может содержать кириллицу, иероглифы или эмодзи — всегда используйте rune и for range.

Вот таблица, которая поможет запомнить разницу:

len("Go") = 2 байта, utf8.RuneCountInString("Go") = 2 руны

len("Го") = 4 байта, utf8.RuneCountInString("Го") = 2 руны

len("🚀") = 4 байта, utf8.RuneCountInString("🚀") = 1 руна

📝 Многострочные строки и специальные символы

В Go есть два вида строковых литералов. Обычные строки в двойных кавычках поддерживают escape-последовательности:

package main

import "fmt"

func main() {
    // Обычная строка с escape-последовательностями
    fmt.Println("Первая строка\nВторая строка")
    fmt.Println("Табуляция:\tвот тут")
    fmt.Println("Кавычка: \"внутри строки\"")
    fmt.Println("Обратный слеш: \\")
}

Для многострочных текстов удобнее использовать «сырые строки» (raw strings) в обратных кавычках (backticks):

package main

import "fmt"

func main() {
    poem := `Roses are red,
Violets are blue,
Go is awesome,
And so are you!`

    fmt.Println(poem)

    // В raw-строках escape-последовательности НЕ работают
    path := `C:\Users\Documents\file.txt`
    fmt.Println(path) // C:\Users\Documents\file.txt — без проблем!
}

Сырые строки особенно полезны для путей к файлам в Windows, регулярных выражений, SQL-запросов и JSON-шаблонов — везде, где много обратных слешей или переносов строк.

⚡ strings.Builder — эффективная сборка строк

Мы уже видели, что строки можно склеивать оператором +. Но поскольку строки неизменяемы, каждое сложение создаёт новую строку. Если вы склеиваете строки в цикле, это может быть медленным.

Для таких случаев есть strings.Builder:

package main

import (
    "fmt"
    "strings"
)

func main() {
    var builder strings.Builder

    words := []string{"Go", "это", "быстро", "и", "просто"}

    for i, word := range words {
        if i > 0 {
            builder.WriteString(" ")
        }
        builder.WriteString(word)
    }

    result := builder.String()
    fmt.Println(result) // Go это быстро и просто
}

strings.Builder собирает строку по частям внутри буфера, а финальную строку создаёт только один раз при вызове .String(). Для новичков: запомните, что Builder существует, и используйте его, когда склеиваете строки в цикле. Для пары конкатенаций оператор + вполне подходит.

🛠 Практика: анализатор текста

Пришло время применить всё, что мы изучили, в одной программе. Напишем анализатор текста, который определяет:

  • Количество символов (рун) в тексте
  • Количество байтов
  • Количество слов
  • Количество гласных букв (русских и английских)
  • Текст в верхнем и нижнем регистре
package main

import (
    "fmt"
    "strings"
    "unicode/utf8"
)

func main() {
    text := "  Привет, мир! Hello, World!  "

    // Убираем лишние пробелы по краям
    text = strings.TrimSpace(text)
    fmt.Println("Текст:", text)
    fmt.Println(strings.Repeat("-", 40))

    // 1. Количество байтов и символов
    byteCount := len(text)
    runeCount := utf8.RuneCountInString(text)
    fmt.Printf("Байтов: %d\n", byteCount)
    fmt.Printf("Символов (рун): %d\n", runeCount)

    // 2. Количество слов
    words := strings.Fields(text)
    wordCount := len(words)
    fmt.Printf("Слов: %d\n", wordCount)
    fmt.Printf("Список слов: %v\n", words)

    // 3. Подсчёт гласных
    vowels := "аеёиоуыэюяАЕЁИОУЫЭЮЯaeiouAEIOU"
    vowelCount := 0
    for _, ch := range text {
        if strings.ContainsRune(vowels, ch) {
            vowelCount++
        }
    }
    fmt.Printf("Гласных букв: %d\n", vowelCount)

    // 4. Регистр
    fmt.Printf("Верхний регистр: %s\n", strings.ToUpper(text))
    fmt.Printf("Нижний регистр: %s\n", strings.ToLower(text))

    // 5. Бонус: содержит ли текст определённые слова?
    fmt.Println(strings.Repeat("-", 40))
    fmt.Printf("Содержит 'Привет': %t\n", strings.Contains(text, "Привет"))
    fmt.Printf("Содержит 'Go': %t\n", strings.Contains(text, "Go"))
    fmt.Printf("Начинается с 'Привет': %t\n", strings.HasPrefix(text, "Привет"))
    fmt.Printf("Заканчивается на '!': %t\n", strings.HasSuffix(text, "!"))
}

Вывод программы:

Текст: Привет, мир! Hello, World!
----------------------------------------
Байтов: 38
Символов (рун): 28
Слов: 4
Список слов: [Привет, мир! Hello, World!]
Гласных букв: 7
Верхний регистр: ПРИВЕТ, МИР! HELLO, WORLD!
Нижний регистр: привет, мир! hello, world!
----------------------------------------
Содержит 'Привет': true
Содержит 'Go': false
Начинается с 'Привет': true
Заканчивается на '!': true

Разберём несколько моментов, которые встретились впервые:

strings.Fields(text) — похожа на Split, но разделяет по любым пробельным символам (пробелы, табуляции, переносы строк) и автоматически игнорирует лишние пробелы. Идеальна для подсчёта слов.

strings.ContainsRune(s, r) — проверяет, содержит ли строка указанную руну. Используется для проверки каждого символа на принадлежность к гласным.

strings.Repeat(s, n) — повторяет строку n раз. Мы используем её для рисования разделительной линии.

%t в fmt.Printf — формат для вывода булевых значений (true/false).

Попробуйте подставить свой текст и посмотрите результат. Поэкспериментируйте: добавьте подсчёт согласных, или определите, на каком языке написан текст (по преобладанию кириллицы или латиницы), или найдите самое длинное слово.

💎 Полезные приёмы со строками

Напоследок — несколько приёмов, которые вам пригодятся в реальных проектах:

package main

import (
    "fmt"
    "strings"
    "unicode"
)

func main() {
    // 1. Проверить, состоит ли строка только из цифр
    pin := "1234"
    allDigits := true
    for _, ch := range pin {
        if !unicode.IsDigit(ch) {
            allDigits = false
            break
        }
    }
    fmt.Printf("'%s' только из цифр: %t\n", pin, allDigits) // true

    // 2. Перевести первую букву в верхний регистр
    name := "иван"
    runes := []rune(name)
    runes[0] = unicode.ToUpper(runes[0])
    fmt.Println(string(runes)) // Иван

    // 3. Реверс строки (с поддержкой кириллицы!)
    original := "Привет"
    r := []rune(original)
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    fmt.Println(string(r)) // тевирП

    // 4. Подсчёт уникальных символов через map
    text := "banana"
    charCount := make(map[rune]int)
    for _, ch := range text {
        charCount[ch]++
    }
    fmt.Println(charCount) // map[97:3 98:1 110:2]
    for ch, count := range charCount {
        fmt.Printf("'%c': %d раз\n", ch, count)
    }

    // 5. Проверка палиндрома
    word := "казак"
    lower := strings.ToLower(word)
    r2 := []rune(lower)
    isPalindrome := true
    for i := 0; i < len(r2)/2; i++ {
        if r2[i] != r2[len(r2)-1-i] {
            isPalindrome = false
            break
        }
    }
    fmt.Printf("'%s' — палиндром: %t\n", word, isPalindrome) // true
}

💡 Подсказка: Пакет unicode содержит множество полезных функций для работы с рунами: unicode.IsLetter(r) — буква ли, unicode.IsDigit(r) — цифра ли, unicode.IsSpace(r) — пробельный ли, unicode.ToUpper(r) / unicode.ToLower(r) — смена регистра одной руны.

📋 Итоги урока

  • Строка в Go — это неизменяемая последовательность байтов в кодировке UTF-8
  • len(s) возвращает количество байтов, а не символов; для символов — utf8.RuneCountInString(s)
  • byte (uint8) — один байт, rune (int32) — один символ Юникода
  • for range перебирает строку посимвольно (по рунам), обычный for — побайтово
  • Пакет strings: Contains, Split, Join, ToUpper, ToLower, TrimSpace, Replace, HasPrefix, HasSuffix, Fields, Count
  • Пакет strconv: Itoa (int в строку), Atoi (строку в int), ParseFloat, FormatFloat
  • strings.Builder — эффективная сборка строк в цикле
  • Для работы с кириллицей — преобразуйте строку в []rune
  • Мы написали анализатор текста, использующий большинство изученных инструментов

В следующем уроке мы познакомимся со структурами (struct) — одним из важнейших инструментов Go. Структуры позволяют объединять несколько полей разных типов в единый тип данных. Вы научитесь создавать свои типы, описывать объекты реального мира (пользователь, товар, заказ) и работать с ними. Это первый шаг к настоящей архитектуре программ!

Строки и работа с ними — Язык Go – для начинающих! | $ sudo teach IT