Недавно я снова разбирался со строками и Unicode в Go и наткнулся на небольшой вопрос. На первый взгляд ответ на него должен занимать несколько секунд:
package main
import "fmt"
func main() {
s1 := "✍️"
s2 := "👨💻"
s3 := "😆"
fmt.Println(len(s1), len(s2), len(s3))
}
Что выведет программа?
Большинство Go-разработчиков знают базовое правило: len для строки возвращает ее длину в байтах. Мы также знаем, что UTF-8 использует от одного до четырех байт для кодирования одной кодовой точки Unicode, а многие привычные эмодзи занимают четыре байта.
Поэтому, если пробежать код глазами слишком быстро, ответ 4 4 4 кажется вполне логичным.
На самом деле вывод будет таким:
6 11 4
Сначала может показаться, что UTF-8 здесь делает что-то странное. Но с UTF-8 все в порядке.
Одна кодовая точка Unicode по-прежнему занимает в UTF-8 не больше четырех байт. Подвох в другом: один эмодзи, который мы видим на экране, не обязан состоять из одной кодовой точки Unicode.
Почему len() в Go возвращает 6, 11 и 4
Строка в Go - это последовательность байт. Она вообще может содержать произвольные байты, хотя обычный Unicode-текст, записанный прямо в исходном коде Go, хранится в UTF-8.
Поэтому:
len(s)
возвращает количество байт, а не количество кодовых точек Unicode и не количество символов, которые видит пользователь.
Начнем с самого простого значения из примера:
😆
Это эмодзи соответствует кодовой точке Unicode U+1F606.
Кодовая точка одна, и в UTF-8 она занимает четыре байта:
fmt.Println(len("😆")) // 4
Скорее всего, именно отсюда и появляется полезное, но неполное правило: “эмодзи занимает четыре байта”.
Теперь посмотрим на:
✍️
Визуально это один эмодзи, но внутри находятся две кодовые точки Unicode:
U+270D WRITING HAND
U+FE0F VARIATION SELECTOR-16
U+FE0F - это вариационный селектор. В этой последовательности он просит систему отображения использовать emoji-вариант предыдущего символа.
Обе кодовые точки занимают по три байта в UTF-8:
U+270D 3 байта
U+FE0F 3 байта
всего 6 байт
Поэтому:
fmt.Println(len("✍️")) // 6
С эмодзи разработчика ситуация еще интереснее:
👨💻
Он состоит уже из трех кодовых точек:
U+1F468 MAN
U+200D ZERO WIDTH JOINER
U+1F4BB PERSONAL COMPUTER
U+200D - это Zero Width Joiner, обычно сокращенно ZWJ. Это невидимый Unicode-символ, который позволяет объединять соседние элементы в одну визуальную последовательность.
Без ZWJ:
👨💻
С ZWJ:
👨💻
Размеры в UTF-8 такие:
U+1F468 4 байта
U+200D 3 байта
U+1F4BB 4 байта
всего 11 байт
Теперь исходный результат уже выглядит вполне объяснимо:
✍️ 6 байт
👨💻 11 байт
😆 4 байта
Никакой 11-байтовой кодовой точки UTF-8 здесь нет. В строке находятся три отдельные кодовые точки, их UTF-8 представления в сумме занимают 11 байт, а рендерер показывает всю последовательность как один эмодзи.
Руна - это кодовая точка Unicode, а не обязательно видимый символ
Следующая естественная мысль в Go - перестать считать байты и перейти к рунам.
В Go rune является алиасом для int32 и используется для представления кодовых точек Unicode.
Например:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
fmt.Println(utf8.RuneCountInString("✍️"))
fmt.Println(utf8.RuneCountInString("👨💻"))
fmt.Println(utf8.RuneCountInString("😆"))
}
Вывод:
2
3
1
Теперь одни и те же строки можно посчитать сразу тремя способами:
| Текст | Байты UTF-8 | Руны | Видимые эмодзи |
|---|---|---|---|
✍️ | 6 | 2 | 1 |
👨💻 | 11 | 3 | 1 |
😆 | 4 | 1 | 1 |
Вот этот момент легко упустить, даже если вы уже хорошо знаете, как работает len в Go.
Руна - это не обязательно один символ в том смысле, в котором его воспринимает пользователь.
Внутреннее устройство строки удобно посмотреть через for range:
package main
import "fmt"
func main() {
s := "👨💻"
for i, r := range s {
fmt.Printf("byte index: %d, rune: %U\n", i, r)
}
}
Вывод:
byte index: 0, rune: U+1F468
byte index: 4, rune: U+200D
byte index: 7, rune: U+1F4BB
Обратите внимание, что i здесь - байтовое смещение, а не номер руны.
Первая руна начинается с байта 0 и занимает четыре байта. Поэтому ZWJ начинается с байта 4. Он занимает три байта, значит ноутбук начинается с байта 7.
Полная строка занимает 11 байт.
Графемные кластеры: как посчитать то, что видит пользователь
В Unicode есть еще одно важное понятие - графемный кластер.
Если немного упростить, графемный кластер - это последовательность, которую пользователь воспринимает как один символ. Внутри такого кластера может быть как одна кодовая точка, так и несколько.
Для наших примеров:
✍️ 2 руны, 1 графемный кластер
👨💻 3 руны, 1 графемный кластер
😆 1 руна, 1 графемный кластер
Стандартная библиотека Go дает нам операции с байтами и рунами UTF-8, но отдельного общего API для подсчета графемных кластеров в ней нет.
Если приложению действительно нужно считать символы так, как их воспринимает пользователь, можно использовать библиотеку для Unicode-сегментации. Например, github.com/rivo/uniseg:
package main
import (
"fmt"
"github.com/rivo/uniseg"
)
func main() {
fmt.Println(uniseg.GraphemeClusterCount("✍️"))
fmt.Println(uniseg.GraphemeClusterCount("👨💻"))
fmt.Println(uniseg.GraphemeClusterCount("😆"))
}
Результат:
1
1
1
Это уже другой вопрос, не тот, на который отвечают len или utf8.RuneCountInString. Поэтому и результат другой.
С модификаторами эмодзи становится еще интереснее
Хороший пример того, насколько составным может быть один визуальный эмодзи:
👩🏿💻
На экране это один эмодзи - женщина в сфере технологий с очень темным тоном кожи.
Внутри находятся четыре кодовые точки:
U+1F469 WOMAN
U+1F3FF EMOJI MODIFIER FITZPATRICK TYPE-6
U+200D ZERO WIDTH JOINER
U+1F4BB PERSONAL COMPUTER
В UTF-8 они занимают:
U+1F469 4 байта
U+1F3FF 4 байта
U+200D 3 байта
U+1F4BB 4 байта
всего 15 байт
В Go это можно увидеть напрямую:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "👩🏿💻"
fmt.Println(len(s))
fmt.Println(utf8.RuneCountInString(s))
for i, r := range s {
fmt.Printf("byte index: %d, rune: %U\n", i, r)
}
}
Вывод:
15
4
byte index: 0, rune: U+1F469
byte index: 4, rune: U+1F3FF
byte index: 8, rune: U+200D
byte index: 11, rune: U+1F4BB
Один объект на экране. Четыре руны. Пятнадцать байт.
На этой последовательности хорошо видно и то, почему редактирование эмодзи в текстовом редакторе иногда ведет себя неожиданно.
Визуально 👩🏿💻 выглядит как единый символ. Но внутри это несколько кодовых точек. Если поставить курсор в конец строки и начать стирать справа налево, промежуточные состояния могут отличаться в зависимости от редактора, позиции курсора и того, какую Unicode-единицу редактор считает одним шагом удаления.
Если курсор стоит в конце строки, справа налево обычно стирает Backspace. Одна из возможных последовательностей выглядит так:
"👩🏿💻" -> один раз нажимаем Backspace -> "👩🏿" -> "👩🏿" -> "👩�" -> "👩" -> ""
Также возможна и такая последовательность:
"👩🏿💻" -> "👩🏿💻" -> "👩�💻" -> "👩💻" -> "💻" -> ""
В первом варианте сначала исчезает ноутбук, затем ZWJ и модификатор тона кожи. На шаге "👩🏿" в строке все еще находится невидимый ZWJ, поэтому визуально этот вариант может выглядеть точно так же, как "👩🏿", хотя содержимое строки уже другое.
Во втором варианте первым исчезает ZWJ. Как только соединителя нет, рендерер перестает собирать женщину и ноутбук в один эмодзи, поэтому на экране появляется 👩🏿💻.
Отдельно стоит сказать про �. Это U+FFFD REPLACEMENT CHARACTER, и его не было в исходном эмодзи. Такой символ может появиться, если редактор или этап декодирования столкнулся с некорректной либо неполной последовательностью code units/байт, например с половиной суррогатной пары или обрезанной многобайтовой последовательностью. Unicode-aware редактор может вместо этого удалять целую кодовую точку или сразу весь графемный кластер, поэтому конкретная цепочка удаления не гарантирована и зависит от редактора.
В этом и заключается интересный момент: визуально перед нами один символ, а внутри могут находиться несколько кодовых точек, модификаторы и невидимые соединители. Результат одного нажатия клавиши удаления зависит от того, по каким границам редактор режет текст.
Для сравнения: как это выглядит в C#
Я также работаю с C#, и здесь получается полезное сравнение, потому что .NET по умолчанию показывает разработчику другую единицу текста.
Возьмем те же значения:
string s1 = "✍️";
string s2 = "👨💻";
string s3 = "😆";
Console.WriteLine(s1.Length);
Console.WriteLine(s2.Length);
Console.WriteLine(s3.Length);
Вывод:
2
5
2
Числа отличаются от Go, но C# тоже не считает видимые символы.
string.Length в C# возвращает количество единиц кода UTF-16. Один C# char представляет одну 16-битную единицу кода UTF-16.
Кодовые точки из основной многоязыковой плоскости (BMP) помещаются в одну единицу кода UTF-16. Для кодовых точек выше U+FFFF нужна суррогатная пара, то есть два значения char.
Для руки:
U+270D 1 char
U+FE0F 1 char
всего 2 char
Для разработчика:
U+1F468 2 char
U+200D 1 char
U+1F4BB 2 char
всего 5 char
И даже 😆, несмотря на то что это одна кодовая точка Unicode, занимает две единицы кода UTF-16:
U+1F606 2 char
Поэтому следующий код C# не компилируется:
char c = '😆';
Для 😆 нужна суррогатная пара, а один char хранит ровно одну единицу кода UTF-16.
В современном .NET есть еще System.Text.Rune, который по назначению намного ближе к Go rune:
using System.Text;
Rune r = new Rune(0x1F606);
Console.WriteLine(r); // 😆
Но базовая проблема Unicode никуда не исчезает.
Один Rune представляет одно скалярное значение Unicode. Он не может целиком представить последовательность из нескольких кодовых точек, например:
✍️
👨💻
👩🏿💻
Смена языка программирования не убирает эту особенность Unicode. Меняется в основном то, какую единицу текста стандартный строковый API показывает разработчику по умолчанию.
Что это меняет при работе со строками в Go
Во многих backend-задачах len(s) - именно то, что нужно.
Если вы проверяете размер payload, выделяете буферы, работаете с протоколами или бинарным представлением данных, считать байты правильно.
Если нужны кодовые точки Unicode, используйте utf8.RuneCountInString, []rune(s) или for range.
Неоднозначность появляется, когда в требованиях написано что-то вроде:
Имя пользователя должно быть не длиннее 20 символов.
Что именно означает “20 символов”?
Если считать байты, ASCII и эмодзи будут вести себя совершенно по-разному.
Считать руны обычно ближе к ожидаемому смыслу, но 👩🏿💻 все равно будет считаться как четыре руны, хотя пользователь видит один эмодзи.
Если ограничение должно соответствовать количеству символов, воспринимаемых человеком на экране, нужна сегментация по графемным кластерам.
То же самое относится к обрезке строк и текстовым редакторам.
Если разрезать строку по произвольному байтовому смещению, можно попасть внутрь UTF-8 последовательности и получить некорректный UTF-8. Преобразование в []rune решает именно эту проблему, но даже по границам рун все еще можно разрезать графемный кластер посередине.
Так код может случайно оставить женщину, но удалить ноутбук, убрать модификатор тона кожи или оставить в строке невидимый соединитель.
За словом “символ” на практике скрываются как минимум три разных вопроса:
Сколько здесь байт?
Сколько здесь кодовых точек Unicode?
Сколько отдельных символов воспринимает пользователь?
В Go len отвечает на первый вопрос.
Руны отвечают на второй.
Сегментация по графемным кластерам отвечает на третий.
Исходный результат 6 11 4 не нарушает правило UTF-8 про один-четыре байта на кодовую точку.
UTF-8 это правило не нарушал.
Просто мы считали не то, что в итоге увидели на экране.