Однажды делал приложение для ресторана, всё тестировал, всё работало. Заказчик доволен, запускаем. Через два дня звонок — "у нас приложение падает при сохранении заказов". Смотрю логи... оказывается один клиент добавил в название блюда эмодзи пиццы 🍕, и бах — всё сломалось.
Тогда я вообще не понимал как Java работает с символами больше 16 бит. Пришлось разбираться. Теперь расскажу.
Почему char перестал быть "символом"
В 1995 когда делали Java, char сделали 16-битным. Логика была простая — Unicode 1.0 содержал около 38 тысяч символов, а 16 бит дают 65536 значений. С запасом! Но потом Unicode начал расти. Китайцам добавили ещё иероглифов (им всегда мало), древние письменности, математические символы... А в 2010 добавили эмодзи — и понеслось. Сейчас больше 140 тысяч символов, а в 16 бит влезает только 65536. И что делать? Java не может просто взять и поменять char на 32 бита — весь код сломается. Придумали костыль... то есть изящное решение — суррогатные пары.CharSequence — интерфейс который надо знать
Перед тем как копать глубже, разберёмся с CharSequence. Это базовый интерфейс для всего что работает с текстом:public interface CharSequence {
int length();
char charAt(int index);
CharSequence subSequence(int start, int end);
String toString();
}
Его реализуют три основных класса:
- String (не меняется)
- StringBuilder (меняется, быстрый)
- StringBuffer (меняется, потокобезопасный)
Начиная с Java 8 у CharSequence появились методы chars() и codePoints(). И вот тут начинается самое интересное.chars() vs codePoints() — в чём разница
Смотри сам. Обычная строка:String s = "Hello";
System.out.println("chars: " + s.chars().count());
System.out.println("codePoints: " + s.codePoints().count());
Оба выведут 5. Нормально.
Теперь с эмодзи:
String s = "Hello 👋";
long chars_count = s.chars().count();
long cp_count = s.codePoints().count();
System.out.println("chars: " + chars_count); // выведет 8
System.out.println("codePoints: " + cp_count); // выведет 7
Погоди, что?! Почему разные числа?
Потому что эмодзи "👋" занимает ДВА char'а. Для Java это два 16-битных значения (суррогатная пара), но для человека — один символ.
chars() считает технические char'ы, а codePoints() — реальные символы.Пример: считаем уникальные символы
Допустим надо посчитать уникальные символы:String line = "aaabccdddc";
long unique = line.chars().distinct().count();
System.out.println(unique); // 4: a, b, c, d
Окей, работает. Добавим эмодзи:
String line = "aaa😀bcc😀dddc";
// неправильно!
long wrong = line.chars().distinct().count();
System.out.println(wrong); // 6 — фигня какая-то
// правильно
long right = line.codePoints().distinct().count();
System.out.println(right); // 5: a, b, c, d, 😀
chars() видит эмодзи как два разных char'а и считает их отдельно. codePoints() правильно понимает что это один символ.Суррогатные пары — костыль который работает
Суррогатная пара — это когда один символ кодируется двумя char'ами. Unicode поделили так: - 0x0000-0xD7FF: обычные символы, один char - 0xD800-0xDFFF: суррогатная зона (зарезервирована) - 0xE000-0xFFFF: обычные символы, один char - 0x10000 и больше: нужна суррогатная пара Пример. Эмодзи призрак 👻 имеет код U+1F47B:String ghost = "👻";
System.out.println("length: " + ghost.length()); // 2 (!!)
System.out.println("codePoints: " + ghost.codePointCount(0, ghost.length())); // 1
char c1 = ghost.charAt(0);
char c2 = ghost.charAt(1);
System.out.println("char[0]: " + Integer.toHexString(c1)); // d83d
System.out.println("char[1]: " + Integer.toHexString(c2)); // dc7b
Один эмодзи, а length() возвращает 2! Первый char (d83d) называется "high surrogate", второй (dc7b) — "low surrogate". Вместе они кодируют U+1F47B.
Кстати. Я пробовал вывести музыкальный символ скрипичного ключа U+1D120. Java его поддерживает, но... шрифтов нет. Выводится квадратик. Так что Unicode выше базовой плоскости — пока больше теория чем практика.Проблемы с эмодзи — как всё ломается
Это самая частая проблема в реальных проектах. Пользователи обожают эмодзи, а код постоянно ломается.Проблема первая: обрезание строки
Классика жанра:String msg = "Привет 👋";
// НЕПРАВИЛЬНО
String cut = msg.substring(0, 8);
System.out.println(cut); // Привет � <- сломанный эмодзи!
Что случилось? substring() работает с char'ами. Мы обрезали строку прямо посередине суррогатной пары — взяли только первый char из двух. Получился битый символ.
Правильно так:
String msg = "Привет 👋";
// правильный способ
int maxCP = 7;
int[] cps = msg.codePoints().limit(maxCP).toArray();
String cut = new String(cps, 0, cps.length);
System.out.println(cut); // Привет <- эмодзи не влез, но ничего не сломалось
Да, код сложнее. Зато работает.Проблема вторая: проверка длины
У тебя лимит — максимум 10 символов:String input = "Привет🙂🎉";
// НЕПРАВИЛЬНО
if(input.length() > 10) {
System.out.println("Слишком длинно");
}
// length() вернёт 10 (6 букв + 4 char'а для эмодзи)
// но по факту символов 8, не 10!
// ПРАВИЛЬНО
long count = input.codePoints().count();
if(count > 10) {
System.out.println("Слишком длинно");
}
Я на этом обжёгся. Делали форму регистрации, поле "имя" максимум 20 символов. length() проверяли. Всё работало, пока кто-то не добавил эмодзи в имя. База ждала 20 символов, получила больше — упала. Пришлось переделывать на codePoints().Character — полезные методы
Character — обёртка над char с кучей полезных методов для Unicode.Определяем тип символа
char ch = 'А';
System.out.println(Character.isLetter(ch)); // true
System.out.println(Character.isDigit(ch)); // false
System.out.println(Character.isUpperCase(ch)); // true
System.out.println(Character.isAlphabetic(ch)); // true
System.out.println(Character.isSpaceChar(' ')); // true
Есть даже проверка зеркальных символов:
System.out.println(Character.isMirrored('(')); // true
System.out.println(Character.isMirrored(')')); // true
// у ( есть зеркальное )
Меняем регистр правильно
Character работает с любыми языками:String text = "организация объединённых наций";
char[] chars = text.toCharArray();
for(int i=0; i < chars.length; i++) {
if(i == 0 || chars[i-1] == ' ') {
chars[i] = Character.toUpperCase(chars[i]);
}
}
System.out.println(new String(chars));
// Организация Объединённых Наций
Работает не только для латиницы и кириллицы, но и для греческого, армянского, грузинского.Реальная боль с разными языками
Арабский и RTL
Арабский, иврит пишутся справа налево (RTL):String arabic = "مرحبا"; // привет по-арабски
String hebrew = "שלום"; // привет на иврите
System.out.println("Арабский: " + arabic);
System.out.println("Иврит: " + hebrew);
В консоли может отображаться криво, в GUI всё ок.Китайские/японские иероглифы
Иероглифы = один code point, но отображаются шире:String chinese = "你好"; // привет по-китайски
String japanese = "こんにちは"; // привет по-японски
System.out.println("Китайский length: " + chinese.length()); // 2
System.out.println("Китайский codePoints: " + chinese.codePointCount(0, chinese.length())); // 2
System.out.println("Японский length: " + japanese.length()); // 5
System.out.println("Японский codePoints: " + japanese.codePointCount(0, japanese.length())); // 5
Тут всё нормально — один символ = один code point.Эмодзи с модификаторами
Некоторые эмодзи можно менять. Например цвет кожи:String w1 = "👋"; // жёлтая
String w2 = "👋🏻"; // светлая кожа
String w3 = "👋🏿"; // тёмная кожа
System.out.println("w1 length: " + w1.length()); // 2
System.out.println("w2 length: " + w2.length()); // 4 (!!)
System.out.println("w3 length: " + w3.length()); // 4 (!!)
System.out.println("w1 codePoints: " + w1.codePointCount(0, w1.length())); // 1
System.out.println("w2 codePoints: " + w2.codePointCount(0, w2.length())); // 2
System.out.println("w3 codePoints: " + w3.codePointCount(0, w3.length())); // 2
Эмодзи с модификатором — это ДВА code points! Базовый + модификатор. Визуально один, технически — два.
Ещё веселее составные эмодзи типа "семья":
String fam = "👨👩👧👦"; // семья
System.out.println("Length: " + fam.length()); // 11 (!!!)
System.out.println("CodePoints: " + fam.codePointCount(0, fam.length())); // 7
Это СЕМЬ code points: мужчина + ZWJ + женщина + ZWJ + девочка + ZWJ + мальчик. ZWJ (Zero Width Joiner) — невидимый соединитель.
Визуально — один эмодзи. Технически — 11 char'ов или 7 code points. Весело, да?Что делать — практические советы
1. Используй codePoints() для подсчёта Если нужно знать сколько символов видит пользователь — используй codePoints(), не length().String txt = getUserInput();
long realCount = txt.codePoints().count();
2. Осторожно с substring()
substring() работает с char'ами. Обрежешь посередине суррогатной пары — битый символ.
// небезопасно если есть эмодзи
String cut = txt.substring(0, 10);
// безопаснее
int[] cps = txt.codePoints().limit(10).toArray();
String cut = new String(cps, 0, cps.length);
3. Юзай методы Character
Для определения типа, регистра и т.д. используй Character — он работает с Unicode правильно.
Character.isLetter(cp);
Character.toUpperCase(cp);
Character.isAlphabetic(cp);
4. Учитывай составные эмодзи
Некоторые эмодзи = несколько code points. Если нужны grapheme clusters, придётся использовать ICU4J.
5. Тестируй с разными языками
Обязательно проверяй с:
- Эмодзи (👋😀🎉)
- Китайскими/японскими (你好, こんにちは)
- Арабским/ивритом (مرحبا, שלום)
- Составными эмодзи (👨👩👧👦)Кодировки и кракозябры
Unicode — это стандарт для кодирования символов. Но есть разные способы ХРАНИТЬ эти коды: - UTF-8 — 1-4 байта на символ (самая популярная) - UTF-16 — 2 или 4 байта (Java, JavaScript, Windows) - UTF-32 — фиксированно 4 байта (расточительно) Java внутри использует UTF-16. Но когда читаешь файлы — там может быть что угодно.Кракозябры — почему появляются
Видел "Привет" вместо "Привет"? Это проблема кодировок:String text = "Привет";
// пишем в UTF-8
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
// но читаем как ISO-8859-1 (НЕПРАВИЛЬНО!)
String broken = new String(bytes, StandardCharsets.ISO_8859_1);
System.out.println(broken); // ÐÑÐ¸Ð²ÐµÑ <- кракозябры
// правильно - та же кодировка
String ok = new String(bytes, StandardCharsets.UTF_8);
System.out.println(ok); // Привет
Золотое правило: всегда ЯВНО указывай кодировку при чтении/записи. Не полагайся на дефолтную — она разная на разных системах.
// плохо
FileReader r = new FileReader("file.txt"); // НЕ ТАК
// хорошо
BufferedReader r = Files.newBufferedReader(
Paths.get("file.txt"),
StandardCharsets.UTF_8
);
Когда нужны библиотеки
Встроенных возможностей Java обычно хватает. Но иногда нужно больше: ICU4J (International Components for Unicode) Нужна для: - Grapheme clusters (визуальные символы учитывая модификаторы) - Сложной сортировки текста - Разбиения по словам для языков без пробелов - Транслитерации// пример с ICU4J
BreakIterator bi = BreakIterator.getCharacterInstance();
bi.setText("👨👩👧👦");
int cnt = 0;
while(bi.next() != BreakIterator.DONE) {
cnt++;
}
System.out.println("Grapheme clusters: " + cnt); // 1
Но для обычных задач хватает стандартной Java.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ