The Go Blog
Строки, байты, руны и символы в Go
Введение
Предыдущая запись в блоге объясняла, как работают срезы в Go, используя ряд примеров для иллюстрации механизма их реализации. Опираясь на этот материал, в данной записи обсуждаются строки в Go. Сначала строки могут показаться слишком простой темой для записи в блоге, но чтобы использовать их правильно, необходимо понимать не только то, как они работают, но и различие между байтом, символом и руной, различие между Unicode и UTF-8, различие между строкой и строковым литералом, а также и другие еще более тонкие различия.
Один из способов подойти к этой теме — это рассматривать её как ответ на часто задаваемый вопрос: «Почему, когда я индексирую строку Go по позиции n, я не получаю n-й символ?» Как вы увидите, этот вопрос приводит нас ко многим деталям о том, как работает текст в современном мире.
Отличное введение в некоторые из этих вопросов, независимо от Go, — это знаменитая запись в блоге Джоэла Сполски, The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!). Многие из его рассуждений будут повторены здесь.
Что такое строка?
Начнем с основ.
В Go строка по сути является неизменяемым срезом байтов. Если вы не уверены в том, что такое срез байтов или как он работает, пожалуйста, прочитайте предыдущую запись в блоге; мы будем предполагать, что вы уже это знаете.
Важно сразу сказать, что строка содержит произвольные байты. Не требуется, чтобы строка содержала текст в формате Unicode, UTF-8 или любой другой предопределенной кодировке. Что касается содержимого строки, она эквивалентна срезу байтов.
Вот строковой литерал (о котором мы скоро расскажем), который использует
запись \xNN для определения строковой константы, содержащей некоторые необычные значения байтов.
(Конечно, байты находятся в диапазоне от шестнадцатеричных значений 00 до FF, включительно.)
const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"
Вывод строк
Поскольку некоторые байты в нашей строке не являются допустимыми ASCII, а даже не являются допустимыми UTF-8, прямой вывод строки даст некрасивый результат. Простая инструкция печати
fmt.Println(sample)
выдает следующий беспорядок (его точный вид зависит от окружения):
<code>��=� ⌘ </code>
Чтобы узнать, что именно содержит эта строка, необходимо разобрать её и изучить составляющие.
Существует несколько способов сделать это.
Наиболее очевидный — это перебирать её содержимое и извлекать байты
по одному, как в этом цикле for:
for i := 0; i < len(sample); i++ {
fmt.Printf("%x ", sample[i])
}
Как уже упоминалось выше, индексация строки позволяет получить доступ к отдельным байтам, а не к символам. Мы вернемся к этой теме подробно ниже. Пока что остановимся только на байтах. Вот вывод цикла по байтам:
<code>bd b2 3d bc 20 e2 8c 98 </code>
Обратите внимание, что отдельные байты соответствуют шестнадцатеричным escape-последовательностям, которые определяли строку.
Более короткий способ получить читаемый вывод для строки с «грязными» данными — использовать форматный спецификатор %x (шестнадцатеричный) функции fmt.Printf.
Он просто выводит последовательные байты строки в шестнадцатеричном виде, по два символа на байт.
fmt.Printf("%x\n", sample)
Сравните его вывод с приведённым выше:
<code>bdb23dbc20e28c98 </code>
Хорошая хитрость — использовать флаг «пробел» в формате, размещая пробел между % и x. Сравните строку формата, используемую здесь, с предыдущей,
fmt.Printf("% x\n", sample)
и обратите внимание, как байты выводятся с пробелами между ними, делая результат менее громоздким:
<code>bd b2 3d bc 20 e2 8c 98 </code>
Есть ещё больше. Спецификатор %q (в кавычках) экранирует любые непечатаемые последовательности байтов в строке, чтобы вывод был однозначным.
fmt.Printf("%q\n", sample)
Этот приём полезен, когда большая часть строки понятна как текст, но есть особенности, которые нужно выявить; он даёт следующий результат:
<code>"\xbd\xb2=\xbc ⌘" </code>
Если присмотреться к нему, можно увидеть, что среди шума скрыт один знак равенства в ASCII, а также обычный пробел, и в конце появляется хорошо знакомый символ «Место интереса» из Швеции.
Этот символ имеет значение Unicode U+2318, закодированное в UTF-8 байтами после пробела (шестнадцатеричное значение 20): e2 8c 98.
Если вы не знакомы или запутались в странных значениях в строке, можно использовать флаг «плюс» для спецификатора %q. Этот флаг заставляет вывод экранировать не только непечатаемые последовательности, но и любые байты, не являющиеся ASCII, при этом интерпретируя UTF-8.
В результате это раскрывает значения Unicode правильно оформленных UTF-8, представляющих данные не в ASCII в строке:
fmt.Printf("%+q\n", sample)
С таким форматом значение Unicode шведского символа отображается как экранирование \u:
<code>"\xbd\xb2=\xbc \u2318" </code>
Эти методы печати полезно знать при отладке содержимого строк и будут полезны в последующем обсуждении. Стоит также отметить, что все эти методы ведут себя точно так же и для срезов байтов, и для строк.
Вот полный набор опций печати, которые мы перечислили, представленный в виде полной программы, которую можно запустить (и отредактировать) прямо в браузере:
package main
import "fmt"
func main() {
const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"
fmt.Println("Println:")
fmt.Println(sample)
fmt.Println("Byte loop:")
for i := 0; i < len(sample); i++ {
fmt.Printf("%x ", sample[i])
}
fmt.Printf("\n")
fmt.Println("Printf with %x:")
fmt.Printf("%x\n", sample)
fmt.Println("Printf with % x:")
fmt.Printf("% x\n", sample)
fmt.Println("Printf with %q:")
fmt.Printf("%q\n", sample)
fmt.Println("Printf with %+q:")
fmt.Printf("%+q\n", sample)
}
[Упражнение: Измените приведённые выше примеры, чтобы использовать срез байтов вместо строки. Подсказка: используйте преобразование для создания среза.]
[Упражнение: Пройдитесь по строке с использованием формата %q для каждого байта. Что говорит нам вывод?]
UTF-8 и строковые литералы
Как мы видели, индексация строки даёт её байты, а не символы: строка — это просто набор байтов. Это значит, что когда мы сохраняем значение символа в строку, мы сохраняем его представление по байтам. Рассмотрим более контролируемый пример, чтобы понять, как это происходит.
Вот простая программа, которая выводит константу строки с одним символом тремя разными способами: один раз как обычную строку, один раз как строку в кавычках, содержащую только ASCII-символы, и один раз как отдельные байты в шестнадцатеричном виде. Чтобы избежать любых недоразумений, мы создаём «сырую строку», заключённую в обратные кавычки, таким образом она может содержать только буквальный текст. (Обычные строки, заключённые в двойные кавычки, могут содержать escape-последовательности, как мы показали выше.)
func main() {
const placeOfInterest = `⌘`
fmt.Printf("plain string: ")
fmt.Printf("%s", placeOfInterest)
fmt.Printf("\n")
fmt.Printf("quoted string: ")
fmt.Printf("%+q", placeOfInterest)
fmt.Printf("\n")
fmt.Printf("hex bytes: ")
for i := 0; i < len(placeOfInterest); i++ {
fmt.Printf("%x ", placeOfInterest[i])
}
fmt.Printf("\n")
}
Вывод:
<code>plain string: ⌘ quoted string: "\u2318" hex bytes: e2 8c 98 </code>
что напоминает нам, что значение символа Юникода U+2318, символ «Место интереса» ⌘, представляется байтами e2 8c 98, и
что эти байты являются UTF-8 кодировкой шестнадцатеричного значения 2318.
Может быть очевидным или сложным, в зависимости от знакомства с UTF-8, но стоит немного остановиться и объяснить, как была создана UTF-8 представление строки. Простой факт: оно было создано при написании исходного кода.
Исходный код на Go определяется как текст в кодировке UTF-8; другие представления не допускаются. Это означает, что когда в исходном коде мы пишем текст
<code>`⌘` </code>
текстовый редактор, использованный для создания программы, помещает в исходный текст UTF-8 код символа ⌘. Когда мы выводим шестнадцатеричные байты, мы просто выводим данные, которые поместил в файл редактор.
Короче говоря, исходный код Go — это UTF-8, поэтому исходный код строкового литерала — это текст в кодировке UTF-8. Если этот строковый литерал не содержит последовательностей экранирования, как это допустимо только для необработанных строк, то полученная строка будет содержать точно такой же текст, какой был в исходном коде между кавычками. Таким образом, по определению и по конструкции необработанная строка всегда будет содержать корректное представление UTF-8 своих содержимых. Аналогично, если обычный строковый литерал не содержит экранирующих последовательностей, нарушающих UTF-8 (например, как описано в предыдущем разделе), он также всегда будет содержать корректный UTF-8.
Некоторые считают, что строки в Go всегда являются UTF-8, но это не так: только строковые литералы являются UTF-8. Как мы показали в предыдущем разделе, значения строк могут содержать произвольные байты; как мы показали в этом разделе, строковые литералы всегда содержат текст в кодировке UTF-8, при условии, что они не содержат экранирующих последовательностей на уровне байтов.
В заключение, строки могут содержать произвольные байты, но когда они создаются из строковых литералов, эти байты (почти всегда) являются UTF-8.
Кодовые точки, символы и руны
До сих пор мы были очень осторожны в использовании слов «байт» и «символ». Это частично связано с тем, что строки содержат байты, и частично потому, что понятие «символ» немного трудно определить. Стандарт Unicode использует термин «кодовая точка» для обозначения элемента, представленного одним значением. Кодовая точка U+2318, с шестнадцатеричным значением 2318, представляет символ ⌘. (За дополнительной информацией о данной кодовой точке см. страницу Unicode.)
Возьмём более простой пример: кодовая точка Unicode U+0061 — это строчная латинская буква «a»: a.
А как насчёт строчной буквы «a» с грависом à? Это символ, и также кодовая точка (U+00E0), но у него есть другие представления. Например, мы можем использовать кодовую точку «связующего» грависа U+0300 и прикрепить её к строчной букве a, U+0061, чтобы получить тот же символ à. В общем случае символ может быть представлен различными последовательностями кодовых точек, а значит, и различными последовательностями байтов UTF-8.
Понятие «символ» в вычислительной технике поэтому является неоднозначным или, по крайней мере, запутанным, поэтому мы используем его с осторожностью. Чтобы сделать всё более надёжным, существуют техники нормализации, которые гарантируют, что заданный символ всегда представляется одним и тем же набором кодовых точек, но эта тема выходит за рамки текущей статьи. В более позднем посте в блоге будет объяснено, как библиотеки Go решают вопрос нормализации.
«Code point» — это somewhat длинное выражение, поэтому в Go введён более короткий термин для обозначения этого понятия: rune. Этот термин встречается в библиотеках и исходном коде, и он означает именно то же самое, что и «code point», с одной интересной особенностью.
Язык Go определяет слово rune как псевдоним типа int32, так что программы могут быть ясными, когда целочисленное значение представляет code point.
Кроме того, то, что вы можете считать константой символа, в Go называется rune constant.
Тип и значение выражения
<code>'⌘' </code>
имеет тип rune с целочисленным значением 0x2218.
В заключение, вот основные моменты:
- Исходный код на Go всегда в кодировке UTF-8.
- Строка содержит произвольные байты.
- Строковый литерал без escape-последовательностей на уровне байтов всегда содержит корректные последовательности UTF-8.
- Эти последовательности представляют Unicode code points, называемые rune.
- В Go не гарантируется, что символы в строках будут нормализованы.
Циклы по диапазону
Помимо очевидного факта, что исходный код на Go записывается в UTF-8,
есть лишь один способ, которым Go специальным образом обрабатывает UTF-8, а именно — при использовании цикла for range над строкой.
Мы уже видели, что происходит с обычным циклом for.
Цикл for range, напротив, декодирует один UTF-8-закодированный rune на каждой итерации.
Каждый раз в цикле индекс цикла — это начальная позиция текущего rune, измеренная в байтах, а значение — это сам code point.
Вот пример, использующий ещё один полезный формат Printf, %#U, который показывает значение Unicode code point и его отображаемое представление:
const nihongo = "日本語"
for index, runeValue := range nihongo {
fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
}
Вывод показывает, как каждый code point занимает несколько байтов:
<code>U+65E5 '日' starts at byte position 0 U+672C '本' starts at byte position 3 U+8A9E '語' starts at byte position 6 </code>
[Упражнение: Вставьте недопустимую последовательность байтов UTF-8 в строку. (Как?) Что происходит с итерациями цикла?]
Библиотеки
Стандартная библиотека Go предоставляет мощную поддержку для работы с текстом в UTF-8.
Если цикл for range не подходит для ваших целей,
то, скорее всего, нужная функциональность предоставляется пакетом из библиотеки.
Самый важный из таких пакетов —
unicode/utf8,
который содержит вспомогательные функции для проверки, разбора и восстановления строк в UTF-8.
Вот программа, эквивалентная примеру с циклом for range выше,
но использующая функцию DecodeRuneInString из этого пакета для выполнения работы.
Возвращаемые функцией значения — это rune и его ширина в байтах UTF-8.
const nihongo = "日本語"
for i, w := 0, 0; i < len(nihongo); i += w {
runeValue, width := utf8.DecodeRuneInString(nihongo[i:])
fmt.Printf("%#U starts at byte position %d\n", runeValue, i)
w = width
}
Запустите программу, чтобы увидеть, что она выполняет те же действия.
Цикл for range и функция DecodeRuneInString определены так, чтобы
производить точно ту же последовательность итераций.
Ознакомьтесь с
документацией
для пакета unicode/utf8, чтобы увидеть другие предоставляемые им возможности.
Заключение
Чтобы ответить на вопрос, поставленный в начале: строки состоят из байтов, поэтому индексация строк возвращает байты, а не символы. Строка может даже не содержать символов. На самом деле, определение «символ» является неоднозначным, и было бы ошибкой пытаться разрешить эту неоднозначность, определяя, что строки состоят из символов.
О Unicode, UTF-8 и мире многоязычной обработки текста можно сказать гораздо больше, но это можно оставить на другой пост. Пока что мы надеемся, что у вас лучше понимание того, как ведут себя строки в Go, и что, хотя строки могут содержать произвольные байты, UTF-8 является центральной частью их дизайна.
Следующая статья: Четыре года Go
Предыдущая статья: Массивы, срезы (и строки): Механика 'append'
Индекс блога