пятница, 20 сентября 2019 г.

Строка, байт, руна, символ в Golang

В этом посте обсуждаются строки в Go. Поначалу строки могут показаться слишком простой темой для поста в блоге, но для их правильного использования требуется понимание не только того, как они работают, но и разницы между байтом (byte), символом и руной (rune), разницы между Unicode и UTF-8, разницы между строкой и строковым литералом и другие различия.

Что такое строка?

Начнем с некоторых основ.

В Go строка в действительности является срезом (slice) байтов, доступным только для чтения.

Важно сразу указать, что строка содержит произвольные байты. Не требуется хранить текст Unicode, текст UTF-8 или любой другой предопределенный формат. Что касается содержимого строки, оно в точности эквивалентно срезу байтов.

Вот строковый литерал (подробнее о них ниже), который использует нотацию \xNN для определения строковой константы, содержащей некоторые специфические байтовые значения. (Конечно, байты варьируются в шестнадцатеричном представлении от 00 до FF включительно.)

const sample = "\xbd\xb2\x3d\xbc\x20\xe2\x8c\x98"

Печать строк

Поскольку некоторые байты в нашем примере строки не являются допустимыми ASCII, даже не допустимыми UTF-8, прямая печать строки приведет к ужасному выводу. Простая печать

fmt.Println(sample)

выводит этот беспорядок (точный вид зависит от окружения запуска):

�� = � ⌘

Чтобы выяснить, что на самом деле содержит эта строка, нам нужно разобрать ее на части и изучить кусочки. Есть несколько способов сделать это. Наиболее очевидным является цикл по его содержимому и индивидуальное извлечение байтов, как в этом цикле for:

for i := 0; i < len(sample); i++ {
    fmt.Printf("%x ", sample[i])
}

Как и предполагалось, индексирование строки обращается к отдельным байтам, а не к символам. Мы вернемся к этой теме подробно ниже. А пока давайте придерживаться только байтов. Это вывод из байтового цикла:

bd b2 3d bc 20 e2 8c 98

Обратите внимание, как отдельные байты соответствуют шестнадцатеричным экранировкам, которые определили строку.

Более короткий способ создания презентабельного вывода для грязной строки - использовать %x (шестнадцатеричный) формат в fmt.Printf. Он просто выводит последовательные байты строки в виде шестнадцатеричных цифр, по две на байт.

fmt.Printf("%x\n", sample)

Сравните его вывод с вышеупомянутым:

bdb23dbc20e28c98

Хорошим трюком является использование "пробел" флага в этом формате, помещая пробел между % и x. Сравните используемый здесь метод форматирования строки с приведенным выше,

fmt.Printf("% x\n", sample)

и обратите внимание, как байты выходят с пробелами между ними, делая результат немного менее впечатляющим:

bd b2 3d bc 20 e2 8c 98

Есть еще кое-что. Форма %q (в кавычках) будет экранировать любые непечатаемые последовательности байтов в строке, поэтому вывод будет однозначным.

fmt.Printf("%q\n", sample)

Этот метод удобен, когда большая часть строки понятна как текст, но есть особенности, которые нужно искоренить; он производит:

"\xbd\xb2=\xbc ⌘"

Если мы взглянем пристально на этот вывод, то увидим, что в шуме погребен один знак равенства ASCII вместе с обычным пробелом, и в конце появляется известный шведский символ "Достопримечательность" ("Place of Interest" symbol). Этот символ имеет значение Unicode U+2318, закодированное как UTF-8 байтами после пробела (шестнадцатеричное значение 20): e2 8c 98.

Если мы незнакомы или смущены странными значениями в строке, мы можем использовать "плюс" флаг к %q. Этот флаг заставляет выходные данные экранировать не только непечатаемые последовательности, но также и любые байты, отличные от ASCII, при интерпретации UTF-8. В результате он предоставляет Unicode значения правильно отформатированного UTF-8, который представляет не-ASCII данные в строке:

fmt.Printf("%+q\n", sample)

В этом формате Unicode значение для шведского символа отображается как \u экранированными:

"\xbd\xb2=\xbc \u2318"

Эти методы печати полезно знать при отладке содержимого строк, и они пригодятся в следующем обсуждении. Стоит также отметить, что все эти методы ведут себя точно так же для байтовых срезов, как и для строк.

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

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

Выполнить в песочнице

UTF-8 и строковые литералы

Как мы видели, индексирование строки дает ее байты, а не символы: строка - это просто набор байтов. Это означает, что когда мы храним символьное значение в строке, мы храним его представление в байтах-за-раз. Давайте посмотрим на более контролируемый пример, чтобы увидеть, как это происходит.

Вот простая программа, которая печатает строковую константу с одним символом тремя различными способами, один раз как обычную строку, один раз как только-ASCII строку в кавычках и один раз как отдельные байты в шестнадцатеричном формате. Чтобы избежать путаницы, мы создаем "сырую строку" (raw string), заключенную в обратные кавычки, чтобы она могла содержать только буквальный текст. (Обычные строки, заключенные в двойные кавычки, могут содержать 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")
}

Запустить в песочнице

Вывод:

plain string: ⌘
quoted string: "\u2318"
hex bytes: e2 8c 98

что напоминает нам, что значение Unicode символа U+2318, символ "Достопримечательность" ⌘, представлено байтами e2 8c 98, и что эти байты являются кодировкой UTF-8 шестнадцатеричного значения 2318.

Это может быть очевидным или нет, в зависимости от вашего знакомства с UTF-8, но стоит потратить некоторое время на объяснение того, как было создано представление строки в UTF-8. Простой факт: оно было создано, когда исходный код был написан.

Исходный код на Go определен как текст UTF-8; никакое другое представление не допускается. Это означает, что когда в исходном коде мы пишем текст

`⌘`

текстовый редактор, использованный для создания программы, помещает кодировку UTF-8 символа ⌘ в исходный текст. Когда мы распечатываем шестнадцатеричные байты, мы просто выгружаем данные, которые редактор поместил в файл.

Короче говоря, исходный код Go - это UTF-8, поэтому исходный код строкового литерала - это текст UTF-8. Если этот строковый литерал не содержит escape-последовательностей, чего не может необработанная строка, созданная строка будет содержать именно исходный текст между кавычками. Таким образом, по определению и по конструкции необработанная строка всегда будет содержать действительное представление UTF-8 своего содержимого. Точно так же, если только он не содержит выводов, нарушающих UTF-8, как в предыдущем разделе, обычный строковый литерал также всегда будет содержать действительный UTF-8.

Некоторые люди думают, что строки Go - это всегда UTF-8, но это не так: только строковые литералы являются UTF-8. Как мы показали в предыдущем разделе, строковые значения могут содержать произвольные байты; как мы показали в этом, строковые литералы всегда содержат текст UTF-8, если у них нет экранирования на уровне байтов.

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

Кодовые точки, символы и руны

До сих пор мы были очень осторожны в том, как мы используем слова "байт" и "символ". Это отчасти потому, что строки содержат байты, а отчасти потому, что понятие "символ" немного сложно определить. Стандарт Unicode использует термин "кодовая точка" ("code point") для обозначения элемента, представленного одним значением. Кодовая точка U+2318 с шестнадцатеричным значением 2318 представляет символ ⌘.

Чтобы выбрать более прозаичный пример, кодовая точка Unicode U+0061 - это строчная латинская буква 'A': a.

Но как насчет строчной буквы с акцентом 'A', à? Это символ, и это также кодовая точка (U+00E0), но у него есть другие представления. Например, мы можем использовать "комбинирующую" кодовую точку с акцентом U+0300 и прикрепить ее к строчной букве a U+0061, чтобы создать тот же символ à. В общем, символ может быть представлен несколькими различными последовательностями кодовых точек и, следовательно, различными последовательностями UTF-8 байтов.

Следовательно, концепция символа в вычислениях неоднозначна или, по крайней мере, сбивает с толку, поэтому мы используем ее с осторожностью. Чтобы сделать вещи надежными, существуют методы нормализации, которые гарантируют, что данный символ всегда представлен одними и теми же кодовыми точками, но этот предмет пока уводит нас слишком далеко от темы.

"Кодовая точка" - это нечто вроде труднопроизносимого слова, поэтому Go вводит более короткое понятие: руна (rune). Термин появляется в библиотеках и исходном коде и означает то же самое, что и "кодовая точка", с одним интересным дополнением.

Язык Go определяет слово rune как псевдоним для типа int32, поэтому программы могут быть очищены, когда целочисленное значение представляет кодовую точку. Более того, то, что вы можете рассматривать как символьную константу, называется рунной константой (rune constant) в Go. Тип и значение выражения

'⌘'

это руна (rune) с целочисленным значением 0x2318.

Подводя итог, вот основные моменты:

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

Range циклы

Помимо аксиоматической детализации, что исходным кодом Go является UTF-8, на самом деле есть только один способ, которым Go обрабатывает UTF-8 специально, а именно при использовании цикла for для строки.

Мы видели, что происходит с обычным циклом for. Цикл for range, напротив, декодирует одну руну в кодировке UTF-8 на каждую итерацию. Каждый раз, когда происходит цикл, индекс цикла - это начальная позиция текущей руны, измеряемая в байтах, а кодовая точка - ее значение. Вот пример, использующий еще один удобный формат Printf, %#U, который показывает значение Unicode кодовой точки и ее печатное представление:

const nihongo = "日本語"
for index, runeValue := range nihongo {
    fmt.Printf("%#U starts at byte position %d\n", runeValue, index)
}

Запустить в песочнице

Выходные данные показывают, как каждая кодовая точка занимает несколько байтов:

U+65E5 '日' starts at byte position 0
U+672C '本' starts at byte position 3
U+8A9E '語' starts at byte position 6

Библиотеки

Стандартная библиотека Go предоставляет мощную поддержку для интерпретации текста UTF-8. Если цикл for range не подходит для ваших целей, скорее всего, средство, которое вам нужно, предоставляется пакетом из библиотеки.

Наиболее важным таким пакетом является unicode/utf8, который содержит вспомогательные процедуры для проверки, дизассемблирования и повторной сборки строк UTF-8. Вот программа, эквивалентная приведенному выше примеру for range, но для выполнения работы используется функция DecodeRuneInString из этого пакета. Возвращаемыми значениями функции являются руна и ее ширина в байтах в кодировке 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, чтобы увидеть, какие другие возможности он предоставляет.

Заключение

Строки строятся из байтов, поэтому их индексация дает байты, а не символы. Строка может даже не содержать символов. На самом деле, определение "символ" является неоднозначным, и было бы ошибкой пытаться разрешить эту неоднозначность, определяя, что строки состоят из символов.

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


Читайте также:


Комментариев нет:

Отправить комментарий