Строки и работа с ними
Разбираемся, как 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. Структуры позволяют объединять несколько полей разных типов в единый тип данных. Вы научитесь создавать свои типы, описывать объекты реального мира (пользователь, товар, заказ) и работать с ними. Это первый шаг к настоящей архитектуре программ!