MAATRIX / Блог / Почему длина строки — это три разных числа, и все три правильные

Почему длина строки — это три разных числа, и все три правильные

MAATRIX

Спросите у разработчика, сколько символов в строке «café🇷🇺», и есть шанс услышать три разных ответа, причём каждый будет обоснован. Python скажет одно число, длина в байтах на диске — другое, а человек, который просто посмотрит на экран, насчитает третье. Это не баг конкретного языка, а фундаментальное свойство того, как устроен текст в компьютере — и оно тихо ломает лимиты полей в базах, обрезку строк в UI и валидацию на бэкенде каждый раз, когда кто-то забывает, какое именно число он на самом деле мерит.

Три способа посчитать длину — и все три дают разный ответ

Возьмём простую строку: café🇷🇺 — слово «кафе» с ударением на «e» и флаг России. У неё есть минимум три законных числа длины:

  • Байты — сколько реально памяти или трафика занимает строка в кодировке UTF-8. Это то, что вы отправляете по сети и пишете на диск.
  • Кодовые точки Unicode — сколько отдельных «юникод-символов» в строке, если считать по стандарту Unicode. Именно это большинство языков программирования называют .length или len().
  • Графемы (визуальные кластеры) — сколько отдельных «знаков» реально увидит и посчитает глазами человек. Это то, что интуитивно ожидает пользователь, когда вводит «максимум 20 символов».

Для строки «café🇷🇺» эти три числа не совпадают. Если «é» записана одним precomposed-символом (U+00E9), в UTF-8 она займёт 2 байта; флаг России 🇷🇺 — это два «регионных индикатора» (буквы R и U в специальном юникод-алфавите для флагов), и каждый из них лежит в дополнительной плоскости Unicode, где UTF-8 тратит по 4 байта на символ. Итого на «é» и флаг уйдёт 2 + 4 + 4 = 10 байт, хотя кодовых точек там всего 3, а визуально человек увидит два знака: букву «é» в слове и один флаг. Дальше — почему так происходит на уровне кодировки.

Байты UTF-8: почему кириллица и эмодзи «тяжелее» латиницы

UTF-8 — кодировка переменной длины: один символ занимает от 1 до 4 байт, и то, сколько именно, зависит от того, в каком диапазоне Unicode лежит его кодовая точка:

Диапазон кодовых точекБайт в UTF-8Что туда попадает
U+0000 – U+007F1 байтASCII: латиница без диакритики, цифры, базовая пунктуация
U+0080 – U+07FF2 байтаКириллица, большинство латинских букв с диакритикой, греческий, иврит, арабский
U+0800 – U+FFFF3 байтаОсновная часть CJK (китайский/японский/корейский), большинство символов и знаков
U+10000 – U+10FFFF4 байтаБольшинство эмодзи, редкие исторические письменности, некоторые математические символы

Отсюда практический вывод: текст на русском языке в UTF-8 весит примерно вдвое больше по байтам, чем тот же текст на английском той же «длины» в символах, а строка с эмодзи может занять в разы больше места, чем кажется по числу введённых знаков. Именно байты — то число, которое определяет вес HTTP-запроса, размер строки на диске и место, которое займёт колонка в базе, если её тип считает в байтах.

Важно: сколько байт займёт символ, зависит от кодировки, а не от самого символа. UTF-16 (внутреннее представление строк в Java и JavaScript) кодирует символы базовой плоскости двумя байтами («code unit»), а символы из дополнительных плоскостей — суррогатной парой из двух code units, то есть 4 байтами. Число байт в UTF-8 и UTF-16 для одной строки почти всегда разное — сравнивать «вес» строки в разных кодировках напрямую нельзя.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Кодовые точки Unicode: не то же самое, что символы

Кодовая точка — число, которое стандарт Unicode присваивает конкретному «юникод-символу»: U+0041 — это «A», U+0410 — «А» кириллическая, U+1F600 — эмодзи 😀. Именно кодовые точки считает len() в Python 3 и большинство функций «длина строки» без уточнений. Проблема в том, что кодовая точка не всегда совпадает с визуальным символом: один и тот же видимый знак в Unicode часто можно представить двумя разными способами.

Классический пример — буква «é». Её можно записать:

  • одной precomposed-кодовой точкой U+00E9 («LATIN SMALL LETTER E WITH ACUTE») — это форма NFC (Normalization Form C);
  • двумя кодовыми точками: обычная «e» (U+0065) плюс отдельный комбинирующий диакритический знак «ударение» (U+0301, COMBINING ACUTE ACCENT), который накладывается на предыдущий символ при отрисовке — это форма NFD (Normalization Form D).

Обе записи на экране выглядят абсолютно одинаково — «é» — и представляют одну и ту же графему. Но len() на первой строке вернёт 1, а на второй — 2. Это не теоретическая экзотика: файлы из macOS (которая исторически предпочитает NFD) и текст из веб-форм (обычно NFC) могут содержать визуально идентичные строки с разным числом кодовых точек — из-за этого сравнение строк на равенство может неожиданно дать «не равно» для текста, который выглядит одинаково. При импорте данных из разных источников строки стоит нормализовать к одной форме (обычно NFC) до сравнения или сохранения.

Графемы: что реально видит человек

Графема (точнее, extended grapheme cluster — термин из стандарта Unicode Annex #29) — это то, что человек интуитивно называет «одним символом», даже если технически это несколько кодовых точек, образующих один визуальный знак. Кроме пары «буква + диакритический знак» из предыдущего раздела, есть и более выразительные случаи:

  • Флаги — как в примере выше, 🇷🇺 — это два regional indicator symbol подряд; браузер и шрифт договариваются отрисовывать такую пару как один флаг, если знают эту комбинацию, и как две отдельные буквы в квадратиках, если не знают.
  • Эмодзи с тоном кожи — например, 👍🏽 — это базовый эмодзи «большой палец вверх» плюс отдельная кодовая точка-модификатор тона кожи из блока Fitzpatrick modifiers; визуально один знак, кодовых точек — две.
  • Составные эмодзи через ZWJ — например, эмодзи «семья» — это несколько базовых эмодзи-персонажей, соединённых невидимым символом ZERO WIDTH JOINER (U+200D), который говорит рендереру «склей это в один знак, а не показывай по отдельности». Если шрифт не поддерживает конкретную ZWJ-последовательность, вместо одного знака пользователь увидит несколько отдельных эмодзи подряд с невидимыми «швами» между ними — то есть одна и та же строка данных на разных устройствах визуально распадается на разное число знаков, при том что число кодовых точек и байт в ней не меняется.

Отсюда не самая очевидная вещь: даже «число графем» не абсолютно константно для одной и той же последовательности байт — оно зависит от версии алгоритма сегментации и базы эмодзи, которую использует библиотека или ОС. Новая версия Unicode может добавить ZWJ-последовательность, которую старый рендерер ещё не умеет склеивать. «Графемы» — самое близкое к человеческому восприятию число, но не такое стабильное во времени, как байты и кодовые точки, которые для конкретной строки в конкретной кодировке — величины детерминированные.

Почему языки программирования считают по-разному

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

Язык / инструментЧто считает length/len() по умолчаниюКак получить два других числа
Python 3Кодовые точки Unicodelen(s.encode('utf-8')) — байты; для графем нужен сторонний пакет (regex, grapheme)
JavaScriptUTF-16 code units (символ вне базовой плоскости, включая многие эмодзи, считается за 2)[...s].length ближе к кодовым точкам; new Blob([s]).size — байты в UTF-8
GoБайты (строка — это байтовый срез)utf8.RuneCountInString(s) — кодовые точки; для графем нужна библиотека golang.org/x/text/unicode/norm в связке с сегментацией
RustБайты (String — это валидный UTF-8)s.chars().count() — кодовые точки; крейт unicode-segmentation — графемы
Java / KotlinUTF-16 code units, как в JavaScripts.codePointCount(...) — кодовые точки; BreakIterator — графемы
SwiftГрафемы (extended grapheme clusters) — редкое исключение, где длина строки по умолчанию совпадает с тем, что видит человек.unicodeScalars.count — кодовые точки; .utf8.count — байты
C / strlen()Байты, без всякого понятия о Unicode вообщеНужна сторонняя ICU-библиотека для чего угодно, кроме байт
PHPstrlen() — байты; mb_strlen() — кодовые точки (с явным указанием кодировки)Графемы — только через intl-расширение (ICU)

Swift здесь — интересное исключение: разработчики сознательно выбрали графемы по умолчанию, потому что для строк, с которыми работает пользователь (имена, подписи, текстовые поля), это самое интуитивно ожидаемое число. Цена такого выбора — сегментация графем вычислительно не бесплатна и требует таблиц Unicode. Большинство языков выбрали более дешёвый вариант по умолчанию — байты или кодовые точки — оставив графемы отдельной операцией.

Где это ломает реальные системы: лимиты БД и API

Расхождение трёх чисел — не абстрактная лингвистика, а конкретный источник багов, когда где-то в системе стоит ограничение длины.

Лимиты колонок в базе данных. В MySQL VARCHAR(255) с кодировкой utf8mb4 ограничивает длину в символах (примерно соответствует кодовым точкам), а не в байтах — но резервирует под них место с расчётом на худший случай, до 4 байт на символ. Из-за этого суммарный лимит на размер строки таблицы в InnoDB можно исчерпать быстрее, чем кажется по числу объявленных VARCHAR-колонок, если все они в utf8mb4 и пользователи заполняют их не-ASCII текстом. В PostgreSQL character varying(n) тоже считает лимит в символах, а не в байтах, но у Postgres нет того же жёсткого лимита байт на строку — большие значения уходят в TOAST-хранилище отдельно от основной страницы таблицы. Разница по духу похожа на то, как в MySQL когда-то колонка в устаревшей кодировке latin1 тихо портила кириллицу при вставке — расхождение между тем, что вы думаете о кодировке, и тем, что реально происходит на уровне движка БД, обычно всплывает не на тестах с латиницей, а на проде.

Лимиты API и валидация на разных концах. Частый сценарий: фронтенд на JavaScript проверяет input.length <= 100 (это UTF-16 code units), а бэкенд на Go или Rust отдельно проверяет len(input) <= 100 байт в теле запроса. Для текста с эмодзи или иероглифами эти два лимита срабатывают на разной длине ввода: пользователь может пройти проверку на фронте и получить ошибку 400 от бэкенда — или наоборот. В распределённой системе с несколькими точками валидации (веб-форма, мобильное приложение, backend API, очередь сообщений со своим лимитом в байтах) стоит явно задокументировать единицы измерения каждого лимита, а не полагаться на то, что «100 символов» везде означает одно и то же число.

Обрезка строк (truncate). Ещё одна частая проблема — обрезание длинной строки до N единиц для превью или логов. Если резать по байтам «в лоб» (s[:255] для байтовой строки без учёта UTF-8-границ), можно разрезать многобайтовую последовательность посередине — получится невалидный UTF-8 и «битые» символы на конце. Если резать по кодовым точкам — можно оторвать декомпозированный диакритический знак от буквы или разорвать ZWJ-последовательность эмодзи пополам. Безопасное усечение обязано выравниваться либо по границе символа UTF-8, либо, для пользовательского текста, по границе графемы — иначе после обрезки останется «хвост» из мусорных байт или сломанный составной эмодзи.

Как выбрать правильное число под конкретную задачу

Прежде чем ставить лимит длины где угодно — в схеме БД, в валидации API, в UI-компоненте — стоит явно ответить на вопрос: какое из трёх чисел вы на самом деле ограничиваете, и почему.

  • Ограничиваете место на диске или трафик (размер колонки, лимит на тело сообщения в очереди, квота хранилища) — считайте байты. Именно они определяют реальную занятую память, вне зависимости от того, как выглядит текст.
  • Ограничиваете «технически осмысленную» длину строки для сравнения, сортировки, индексации — обычно достаточно кодовых точек; это самый дешёвый по вычислениям вариант и то, что даёт по умолчанию большинство языков и баз данных.
  • Ограничиваете то, что видит и вводит человек (имя, заголовок, сообщение в чате, тег) — по-хорошему нужны графемы, иначе пользователь с эмодзи или диакритикой упрётся в лимит, который субъективно кажется несправедливым: он ввёл условно 20 значков, а система говорит, что их 35.

На практике многие системы сознательно жертвуют графемной точностью ради простоты — считают по кодовым точкам, но с запасом (лимит чуть выше реально нужного), и это разумный компромисс, кроме продуктов, где пользовательский текст с эмодзи — центральная часть опыта (мессенджеры, соцсети). Для API и очередей сообщений отдельно стоит зафиксировать байтовый лимит тела запроса на уровне инфраструктуры (reverse proxy, API gateway) независимо от логики приложения — это защита не столько от «неправильной» длины текста, сколько от переполнения памяти на приёме произвольно большого запроса. Если вы поднимаете свой API или прокси-слой перед бэкендом, разумно сразу заложить явные лимиты на обоих уровнях и держать их совпадающими по единицам измерения.

Отдельно стоит держать в голове, что счёт длины строки — не единственная метрика «размера текста»: для LLM-приложений важнее число токенов, а не символов в любом из трёх смыслов, и токенизация превращает текст в числа по своим правилам, зависящим от модели, а не от Unicode. Если сервис одновременно валидирует длину ввода и отправляет его в LLM, важно не путать эти два независимых лимита — они почти никогда не пропорциональны друг другу.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Какое число «правильнее» использовать по умолчанию, если не уверен?

Если задача не требует конкретно байт (место на диске) или конкретно графем (пользовательский ввод с эмодзи), кодовые точки — разумный дефолт: это то, что даёт большинство языков «из коробки», и для текста без составных символов оно совпадает с интуитивным ожиданием.

Почему в Python len("👨‍👩‍👧‍👦") возвращает число больше 1, хотя это один эмодзи?

Потому что визуально это одна графема, но технически — последовательность эмодзи-кодовых точек, соединённых невидимыми символами ZERO WIDTH JOINER, а len() в Python считает именно кодовые точки, включая эти невидимые соединители.

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

Нужна библиотека на основе алгоритма сегментации графем из Unicode Annex #29 — например, пакет regex или grapheme в Python, unicode-segmentation в Rust, Intl.Segmenter в современном JavaScript, или системная ICU-библиотека.

Нужно ли нормализовать строки (NFC/NFD) перед тем, как сравнивать их длину?

Да, если строки приходят из разных источников (например, часть данных из macOS, часть — введена в браузере): без нормализации к одной форме визуально одинаковый текст может дать разное число кодовых точек и не совпасть при сравнении, даже если человек не увидит разницы на экране.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →