Однажды делал приложение для ресторана, всё тестировал, всё работало. Заказчик доволен, запускаем. Через два дня звонок — "у нас приложение падает при сохранении заказов". Смотрю логи... оказывается один клиент добавил в название блюда эмодзи пиццы 🍕, и бах — всё сломалось. Тогда я вообще не понимал как 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.

Итого

Что запомнить: 1. char это не символ — это 16-битная единица UTF-16 2. Один символ может быть два char'а (суррогатная пара) 3. Используй codePoints() для подсчёта реальных символов 4. Эмодзи — боль, особенно составные с модификаторами 5. Всегда указывай кодировку при чтении/записи 6. Тестируй с разными языками и эмодзи Unicode в Java — сложная тема. Но понимание основ спасёт от кучи багов. Особенно если приложение используют люди из разных стран или если пользователи любят эмодзи (а они любят). Держи эту статью под рукой. Проблемы с Unicode всплывают в самый неожиданный момент — лучше быть готовым. Пусть твои строки корректно отображаются на любом языке 🌍