Длина строки length() и Unicode
- Стандарты кодирования и кодировки
- Unicode и UTF
- UTF-8, UTF-16, UTF-32
- Char и code unit
- Кодовая точка, code point
- Длина строки с эмодзи
- Кодировки символов
- Отличие UTF-8 от UTF-16
- Compact strings в Java 9
- Как посчитать эмодзи
- Суррогатные пары в строках Java
- ASCII и Unicode в Java
- Работа с кодировками строк в Java
Стандарты кодирования и кодировки, Unicode и UTF
Для начала необходимо уметь отличать стандарты кодирования от форматов кодировки.
Стандарт кодирования присваивает уникальный номер (кодовую точку, a.k.a код-поинт, code point) каждому символу независимо от платформы, программы или языка и определяет, какой символ соответствует какому числу.
Какие символы вообще существуют (буквы, цифры, эмодзи, иероглифы и т.д.) и какой уникальный номер (код-поинт) соответствует каждому символу – задается стандартом.
Один из таких стандартов – Unicode.
Кодировки – это конкретные способы представления этих кодовых точек в виде последовательностей байтов для хранения в памяти компьютера или передачи по сети.
Семейство кодировок, реализующих стандарт Unicode: UTF-8, UTF-16, UTF-32.
ASCII – стандарт или кодировка?
Но у нас же есть ASCII. Что это тогда?
На самом деле ASCII 2-в-1: и то, и другое. ASCII расшифровывается как American Standard Code for Information Interchange.
ASCII как стандарт
Это таблица символов, как Unicode, но гораздо меньше: всего 128 символов.
В неё входят:
- Английские буквы (A–Z, a–z)
- Цифры (0–9)
- Знаки препинания и спецсимволы (!, @, #, ~ и т.д.)
- Управляющие символы (например, перевод строки \n, возврат каретки \r)
Каждый символ имеет номер от 0 до 127 – это и есть код-поинт ASCII.
ASCII как кодировка
Эти же номера (0–127) напрямую соответствуют однобайтовым значениям.
- То есть символ A (код-поинт 65) → байт 01000001
- Никаких дополнительных преобразований – один в один.
Поэтому ASCII – это и набор символов, и кодировка, потому что он сам по себе задаёт и что кодировать, и как кодировать.
ASCII 7-битный. Последний символ соответствует байту 01111111.
Старший бит (Most Significant Bit, MSB) – это самый левый бит в байте. Он не является частью стандарта ASCII, но все равно полностью отправляется.
В старых системах он использовался для проверки четности, но в настоящее время он всегда 0.
Национальные версии ASCII
Как мы уже выяснили, оригинальный ASCII – 7-битный.
Существует два типа модификаций ASCII:
- 8-битные расширения ASCII, использующие старший бит оригинального ASCII
- Идея в том, чтобы добавить местный алфавит страны (Windows-1252, KOI8-R, и другие)
- 7-битные интерпретации оригинального ASCII
- Идея в том, что некоторые символы (напр. #, @, [, , ], {, }) можно заменять на национальные буквы.
8-битные расширения ASCII называются своими именами: Windows-1252, KOI8-R, и т.д.
А вот во избежание путаницы с национальными 7-битными интерпретациями оригинального ASCII, используемыми в других странах, рекомендуется обозначать оригинальный вариант кода как US-ASCII.
Приведем пример из Java. В классе StandardCharsets нет константы ASCII, но есть US-ASCII:
String text = "!";
byte[] bytes = text.getBytes(StandardCharsets.US_ASCII);
System.out.println(bytes.length); // 1 byte
System.out.println(new String(bytes, StandardCharsets.US_ASCII));
Code unit, code point
Как мы уже выяснили, стандарт кодирования определяет соответствие символ <-> число, а кодировка – конкретное представление этого числа в памяти компьютера.
Code point (кодовая точка) - это числовое значение символа в Unicode, независимо от его представления. Например, для символа “A” code point - U+0041 (65 в десятичной системе).
Code unit (кодовая единица) - это минимальная единица хранения в конкретной кодировке (например, 8 бит в UTF-8, 16 бит в UTF-16).
В зависимости от кодировки и от того, насколько большое может быть число, кодирующее символ, в памяти ПК оно может быть представлено по-разному:
- Постоянные 4 байта для всех символов.
- Здесь code point = code unit = 4 байта.
- Два байта для большинства символов, но в иногда 2-байтовыми парами в случае сложных символов.
- Здесь code point равен либо 2 байтам, либо 4 байтам. а code unit всегда 2 байта.
Теперь, когда мы четко разделяем code point-ы от code unit-ов, перейдем к кодировкам.
UTF-8, UTF-16 и UTF-32
UTF-8
- Наиболее популярна кодировка
- Переменное количество байтов для каждого символа
- То есть code point = code unit = 1…4 байта.
- Символы, находящиеся в диапазоне ASCII (первые 128 символов Юникода), занимают 1 байт.
- Это означает, что UTF-8 это суперсет ASCII, и все символы ASCII валидны в UTF-8.
- Более сложные символы занимают 2, 3 или 4 байта в UTF-8.
Пример 1-байтового символа (латинская заглавная A):
- Unicode code point: U+0041 (десятичное 65)
- UTF-8 представление: 01000001 (1 code point / 1 байт/ 1 code unit)
Пример символа из двух байтов (кириллическая строчная я):
- Unicode code point: U+044F (десятичное 1103)
- UTF-8 представление: 11010001 10001111 (1 code point / 2 байта/ 2 code units)
Пример символа из трех байтов (знак суммы ∑):
- Unicode code point: U+2211 (десятичное 8721)
- UTF-8 представление: 11100010 10001000 10010001 (1 code point / 3 байта/ 3 code units)
Пример символа из четырех байтов (готская буква 𐍈):
- Unicode code point: U+10348 (десятичное 66376)
- UTF-8 представление: 11110000 10010000 10001101 10001000 (1 code point / 4 байта/ 4 code units)
Обратим внимание на структуру байтов в UTF-8:
1-байтовые символы: 0xxxxxxx
2-байтовые символы: 110xxxxx 10xxxxxx
3-байтовые символы: 1110xxxx 10xxxxxx 10xxxxxx
4-байтовые символы: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
Преимущество: совместимость с ASCII, компактность для большинства текстов (например, английский текст будет занимать столько же места, сколько и в ASCII).
UTF-16
- Использует 16-битные (2-байтовые) code units
- Большинство часто используемых символов (Basic Multilingual Plane) кодируются одной code unit (2 байта)
- Символы за пределами BMP (например, эмодзи, редкие символы) кодируются парой суррогатов - двумя code units (4 байта)
То есть в UTF-16 один code point может кодироваться либо одной, либо двум code units. Но code unit в UTF-16 всегда остается = 2 байтам.
UTF-16 напрямую не совместим с ASCII. Несмотря на то, что младший байт UTF-16 для всех ASCII символов будет соответствовать кодировке ASCII, UTF-16 требует наличие старшего байта.
Если в ASCII code-point (и, соответственно, code-unit) для буквы ‘A’ будет 01000001, то в UTF-16 он будет 00000000 01000001.
UTF-32
- Использует 32-битные (4-байтовые) code units
- Каждый символ Unicode всегда занимает ровно 4 байта
- Здесь всегда 1 code point = 1 code unit = 4 байта
- Преимущество: простота и предсказуемость при обработке
Недостаток: занимает в 2-4 раза больше места, чем UTF-8 или UTF-16 для большинства текстов.
Что не так с char в Java?
Java под капотом использует UTF-16, где code unit всегда два байта, а code point = либо 1 code unit-у, либо 2-м code unit-ам (суррогатная пара).
Char всегда хранит один code unit
Тип char (Character) в Java на самом деле хранит не code point, а code unit.
“По счастливой случайности” для большинства символов этого достаточно, чтобы знать сам символ, НО НЕ ВСЕГДА. Остановимся на этом подробнее.
В Java есть методы для определения, является ли текущие два байта leading (a.k.a. high) code unit-ом в суррогатной паре:
Character.isHighSurrogate();
Либо following (a.k.a low) code unit-ом в суррогатной паре:
Character.isLowSurrogate();
Char и эмодзи
Например, эмодзи в UTF-16 представлены суррогатной парой code unit-ов. Так как мы уже выяснили, что char = 1 code unit, это значит, что в char эмодзи просто не поместится!
char ch = '😎'; // ERROR: Too many characters in character literal
Генерация суррогатной пары code unit-ов
Как получить high surrogate и low surrogate для эмодзи? Вытащить массив char[] из String:
String str = "😎";
char[] arr = str.toCharArray();
char high = arr[0];
char low = arr[1];
Можем убедиться в этом:
Character.isHighSurrogate(high); // true
Character.isLowSurrogate(low); // true
При попытке вывести на экран high или low по-отдельности, ожидаемого результата мы не видим.
Сборка символа (code unit-а) из суррогатной пары
Как “собрать” эмодзи обратно? Использовать конструктор String:
System.out.println(new String(arr));
В наших манипуляциях выше Java неявно использует UTF-16, так как эта кодировка используется в Java под капотом.
Обратим внимание, что при конвертации byte[] в String мы можем указать кодировку:
new String(byteArr, StandardCharsets.UTF-8);
Но при конвертации char[] в String – такой возможности нет, потому что используется UTF-16:
new String(charArr);
Заметка про кодировки в Java / JVM
Для полного понимания необходимо отличать кодировку по-умолчанию (что используется для IO операция и может быть сконфигурирована) от внутренней кодировки (всегда UTF-16, используется при работе JVM со строками).
Кодировка по-умолчанию:
- Используется для операций ввода/вывода, таких как чтение из файлов или запись в потоки.
- Зависит от локальных настроек операционной системы или может быть явно задана с помощью параметров JVM (например,
-Dfile.encoding=UTF-8). - Может различаться в разных средах и конфигурациях.
Внутренняя кодировка:
- Используется для представления объектов String в среде выполнения Java.
- Всегда UTF-16, независимо от настроек системы или JVM.
- Единообразна во всех средах Java, что обеспечивает консистентную обработку данных String.
Конвертация символа в byte[] и BOM (Byte Order Mark)
String <-> char[] всегда UTF-16, но при конвертации String <-> byte[] кодировка по-умолчанию UTF-8!
// не указываем кодировку
byte[] bytes = "abc123😎".getBytes();
// указываем UTF-8, все работает
System.out.println(new String(bytes, StandardCharsets.UTF_8));
Можно конвертировать строку (символ) в байты в UTF-16. Но для начала важно отметить, что у класса StandardCharsets есть три константы, соответствующие UTF-16:
StandardCharsets.UTF_16StandardCharsets.UTF_16BEStandardCharsets.UTF_16LE
Если мы конвертируем один символ в массив байт:
byte[] bytes = "b".getBytes(StandardCharsets.UTF_16);
то ожидамый размер массива byte[] – 2 байта, так как code unit в UTF-16 – 2 байта.
Но если мы это проверим:
byte[] bytes = "b".getBytes(StandardCharsets.UTF_16);
System.out.println(bytes.length); // вывод: 4
Она окажется равной четырем. Почему?
При использовании StandardCharsets.UTF_16 результирующий массив байтов будет содержать метку порядка байтов (BOM) в начале, которая обычно составляет 2 байта (либо FE FF, либо FF FE в зависимости от порядка следования байтов).
Таким образом, для одного символа мы фактически получим 4 байта:
- 2 байта для спецификации
- 2 байта для символа
А при использовании StandardCharsets.UTF_16BE (big endian) либо Standard.Cahrsets.UTF_16LE (little endian), размер результирующего массива ожидаемый – 2 байта:
// char (2 bytes) to 2 bytes in UTF-16BE <- big endian, high comes first
// using String
byte[] bytes = new String(new char[] {ch}).getBytes(StandardCharsets.UTF_16BE);
// char (2 bytes) to 2 bytes in UTF-16LE <- little endian, low comes first
// using String
byte[] bytes = new String(new char[] {ch}).getBytes(StandardCharsets.UTF_16LE);
Но на этом еще не все
Как вычислить количество символов в строке? string.length()?
Строки в Java это массивы char[]. А string.length() на самом деле – длина массива char[], который под капотом.
Поэтому длина такой строки будет равняться 2:
System.out.println("😎".length()); // вывод: 2
Если почитать официальный javadoc к string.length():
The length is equal to the number of Unicode code units in the string.
Все встает на свои места. Но как в итоге посчитать длину строки?
Так как каждый символ всегда кодируется числом (code point), то, чтобы посчитать количество символов в строке, достаточно выяснить количество code point-ов.
Поэтому правильный ответ на поставленный вопрос выглядит так:
String str = "😎";
System.out.println(str.codePoints().count());
Java 9+ Compact Strings
До Java 9 все объекты String в Java хранили символы в формате UTF-16, используя два байта на символ независимо от фактического содержимого.
В Java 9 появилась новая оптимизация Compact Strings, которая динамически выбирает между кодировками Latin-1 (один байт) и UTF-16 (два байта) в зависимости от содержимого строки.
- JVM анализирует все символы в строке
- Задаётся вопрос: “Можно ли представить каждый символ в этой строке, используя только Latin-1?” (символы с кодовыми точками 0-255)
- Если ДА → Используется кодировка Latin-1 (1 байт на символ)
- Если НЕТ → Используется кодировка UTF-16 (2 байта на символ)
Если строка содержит эмодзи, то Java 9+ будет использовать UTF-16 (кодовая единица = 2 байта) вместо Latin-1 (кодовая единица = 1 байт).
Бонус – конвертация char в один byte
Мы уже знаем, что:
- Java использует UTF-16 под капотом для строк
- Char хранит один code unit
- Code unit для UTF-16 всегда 2 байта
- Значит размер char – 2 байта
- Для некоторых символов нужно два char, а не один
- code point-ы ASCII символов (английские символы, цифры и базовые спецсимволы) совпадают с code point-ами в Unicode
А это значит, что базовые символы (ASCII) по типу букв (напр. b) и цифр (напр. 3), даже будучи представленными в UTF-16, помещаются в один байт:
byte b = (byte) 'a';
То есть UTF-16 представление символа b:
0000 0000 0110 0010
Помещается в переменную byte благодаря усечению старшего байта:
0110 0010
Бонус code unit / code point / char
int -> char
// int (code unit) -> char
char ch = (char) 97;
// int (code point) -> char[]
int codePoint = 0x1F60A; // 😊
char[] chars = Character.toChars(codePoint);