Дайджесты новостей
Макроснимок анатомического разбора символа Unicode со срезом на уровни байтов UTF-8, кодовых единиц UTF-16, кодовых точек и графемного кластера.

Ловушки Unicode: почему длина строки отличается в JavaScript, Python, Go и Rust

Казалось бы, что может быть проще в программировании, чем узнать длину строки? В любом базовом учебнике на третьей странице написано: вызываешь str.length или len(s) — и дело в шляпе.

Но давай проведем простой эксперимент. Возьмем одну и ту же безобидную строку — эмодзи семьи: "👨‍👩‍👧‍👦". И спросим у четырех популярных языков программирования, какова длина этой строки.

Вот какие ответы мы получим:

  • JavaScript: 11
  • Python 3: 7
  • Go: 25
  • Rust: 25 (байты) или 7 (символы)

Один видимый человеческому глазу символ на экране — и четыре совершенно разных ответа! И если в клиентском коде на JavaScript ты поставил валидацию логина или комментария if (input.length > 10) return error;, то твой пользователь даже не сможет отправить одно-единственное эмодзи, потому что JavaScript считает, что там уже одиннадцать символов.

А если ты передашь эту строку в бэкенд на Go и наивно сделаешь срез первых пяти элементов, сервис мгновенно упадет в панику или превратит текст в битые кракозябры. О том, почему срез s0:3 в Go ломает UTF-8 кодировку, мы уже как-то рассуждали, но сегодня давай разберем всю анатомию Unicode до самого основания.

Четыре уровня реальности в Unicode

Чтобы понять, почему языки дают разные ответы, нужно осознать: понятие «символ» в стандарте Unicode не существует в единственном числе. Есть четыре фундаментальных уровня представления текста:

Сравнительная инфографика форматов контейнеров Docker: классический тарбол v2.2 с монолитными слоями против структуры eStargz с гранулярными чанками и оглавлением TOC.

  1. Байты (Bytes): сырые физические октеты в оперативной памяти или на диске. В кодировке UTF-8 один символ может занимать от 1 до 4 байт.
  2. Кодовые единицы (Code Units): минимальная неделимая ячейка конкретной кодировки. В UTF-16 кодовая единица — это 16 бит (2 байта). В UTF-8 — 8 бит (1 байт).
  3. Кодовые точки (Code Points): абстрактный порядковый номер символа в таблице Unicode (от U+0000 до U+10FFFF). Например, латинская буква A имеет кодовую точку U+0041.
  4. Графемные кластеры (Grapheme Clusters): то, что обычный человек воспринимает как одну неделимую графическую букву или значок на экране смартфона.

Наш эмодзи семьи "👨‍👩‍👧‍👦" на самом деле состоит из четырех отдельных фигурок людей:

  • Мужчина (U+1F468)
  • Женщина (U+1F469)
  • Девочка (U+1F467)
  • Мальчик (U+1F466)

А между ними стоят невидимые склеивающие символы — Zero-Width Joiner (ZWJ, U+200D)! Всего 4 фигуры + 3 склейщика = 7 кодовых точек.

В UTF-8 каждая фигура занимает по 4 байта, а каждый ZWJ — по 3 байта: (4 * 4) + (3 * 3) = 25 байт.

В UTF-16 каждая фигура не влезает в 16 бит и кодируется парой суррогатов (2 кодовые единицы по 16 бит = 8 единиц на фигуры), плюс 3 обычные кодовые единицы на ZWJ = 11 кодовых единиц.

Как устроена память в языках: смотрим в код

Давай посмотрим, как разные среды работают с этой строкой и как правильно вычислять человеческую длину.

1. JavaScript: пленник наследия UTF-16

JavaScript создавался в 1995 году, когда стандарт Unicode надеялся уместить все символы мира в 16 бит. Поэтому строки в JS исторически представляют собой последовательность 16-битных кодовых единиц UTF-16:

const family = "👨‍👩‍👧‍👦";

console.log(family.length); // 11 (кодовые единицы UTF-16)

// Наивный spread оператор разбивает только суррогатные пары, но не ZWJ!
console.log([...family].length); // 7 (кодовые точки)

// ПРАВИЛЬНО: сегментация на графемные кластеры через Intl.Segmenter
const segmenter = new Intl.Segmenter("ru", { granularity: "grapheme" });
const graphemes = [...segmenter.segment(family)];
console.log(graphemes.length); // 1 (настоящий видимый символ!)

Современный Intl.Segmenter — единственный надежный способ узнать реальное количество видимых символов в браузере и Node.js.

2. Python 3: гибкое представление PEP 393

В Python 3 строки внутри оптимизированы: интерпретатор использует 1, 2 или 4 байта на символ в зависимости от максимальной кодовой точки в строке.

Функция len() в Python всегда возвращает количество кодовых точек:

import unicodedata

family = "👨‍👩‍👧‍👦"
print(len(family))  # 7 (кодовые точки: 4 фигуры + 3 ZWJ)

# Для подсчета реальных графем требуется библиотека regex с флагом X
import regex
graphemes = regex.findall(r'X', family)
print(len(graphemes))  # 1 (настоящий графемный кластер)
3. Go: строгий UTF-8 и понятие Руны

В языке Go создатели изначально спроектировали тип string как неизменяемый срез байтов (immutable byte slice). Функция len() возвращает байты. Чтобы работать с символами, Go ввел концепцию rune (псевдоним для int32), представляющую кодовую точку:

package main

import (
    "fmt"
    "unicode/utf8"
)

func main() {
    family := "👨‍👩‍👧‍👦"

    // 1. Длина в байтах UTF-8
    fmt.Println(len(family)) // 25

    // 2. Длина в кодовых точках (рунах)
    fmt.Println(utf8.RuneCountInString(family)) // 7
}

Для сегментации на графемы в Go используется модуль golang.org/x/exp/shiny/materialdesign/colornames или специализированный пакет github.com/rivo/uniseg.

4. Rust: абсолютная безопасность типов

Rust принципиально запрещает операцию индексации по строке s[0], потому что она неоднозначна. Тип &str гарантирует валидный UTF-8, но заставляет программиста явно выбирать, что именно он считает:

fn main() {
    let family = "👨‍👩‍👧‍👦";

    // 1. Количество байтов
    println!("{}", family.len()); // 25

    // 2. Количество кодовых точек (chars)
    println!("{}", family.chars().count()); // 7

    // 3. Графемные кластеры через крейт unicode-segmentation
    // use unicode_segmentation::UnicodeSegmentation;
    // println!("{}", family.graphemes(true).count()); // 1
}

Ловушка составных букв: почему «ё» не всегда «ё»

Думаешь, проблемы с графемными кластерами возникают только у фанатов эмодзи? Отнюдь!

Возьмем русскую букву «ё». В Unicode ее можно закодировать двумя способами:

  1. Precomposed (NFC): единая кодовая точка U+0451 (CYRILLIC SMALL LETTER IO). Длина в JS: 1.
  2. Decomposed (NFD): базовая буква «е» (U+0435) плюс комбинируемое диакритическое двоеточие сверху (U+0308, COMBINING DIAERESIS). Длина в JS: 2!

На экране монитора обе буквы выглядят абсолютно одинаково: ё и ё. Но для базы данных или операции поиска строк это совершенно разные сущности! Если один пользователь ввел логин в форме NFC, а другой в NFD, сравнение user1 === user2 вернет false.

Поэтому золотое правило бэкенда при обработке пользовательского ввода: всегда нормализовать строки в каноническую форму NFC: str.normalize("NFC") в JavaScript или unicodedata.normalize("NFC", s) в Python перед сохранением в базу данных.

Понимание того, как работают кодовые точки и графемы, спасает от самых коварных багов валидации и гарантирует, что интерфейс не развалится при встрече с реальным текстом.