Все данные, с которыми работает компьютер, представлены внутри него в виде чисел. Это неудивительно - ведь компьютеры создавались в первую очередь для вычислений (что и отражено в названии). Внутри компьютера данные хранятся и передаются в двоичном виде. Один бит может принимать значения 0 или 1, а 8 бит образуют байт.
Память компьютера состоит из ячеек, каждая из которых хранит 1 байт (или 8 бит) информации. Данные на жестком диске записаны в виде секторов, хранящих байты. По сети передаются пакеты, состоящие из байтов.
Байт состоит из 8 бит, каждый из которых может принимать 2 значения (0 или 1). В совокупности получается 28 = 256 значений. Их можно обозначить числами от 0 до 255, и получается, что содержимое каждой ячейки памяти можно представить таким числом.
Первые компьютеры разрабатывались в западных странах, где используется латиница. Текст разбивался на символы, каждому символу соответствовало какое-то число, и эти числа записывалось в память. Таблица, которая отражает соответствие символов и цифр, называется кодировка (charset).
Одна из самых древних кодировок - это ascii (американский стандартный код для обмена информацией), придуманная еще в 1963 году. Ascii содержит 96 видимых символов и 32 невидимых, управляющих символов. Управляющие символы - это символы вроде "перевод строки", которые не вызывают печать символов, а управляют выводом текста на экран или печать.
Каждый символ кодируется ровно 1 байтом (вообще-то, в Ascii 128 символов, и для их кодирования достаточно 7, а не 8 бит, но удобнее, когда каждый символ хранится в своей ячейке. Оставшийся ненужным бит иногда использовался для других целей, например, обозначения конца текста).
Вот таблица кодов Ascii:
+0 | +1 | +2 | +3 | +4 | +5 | +6 | +7 | +8 | +9 | +10 | +11 | +12 | +13 | +14 | +15 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0+ | NUL | SOH | STX | ETX | EOT | ENQ | ACK | BEL | BS | HT | LF | VT | FF | CR | SO | SI |
16+ | DLE | DC1 | DC2 | DC3 | DC4 | NAK | SYN | ETB | CAN | EM | SUB | ESC | FS | GS | RS | US |
32+ | ! | " | # | $ | % | & | ' | ( | ) | * | + | , | - | . | / | |
48+ | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | : | ; | < | = | > | ? |
64+ | @ | A | B | C | D | E | F | G | H | I | J | K | L | M | N | O |
80+ | P | Q | R | S | T | U | V | W | X | Y | Z | [ | \ | ] | ^ | _ |
96+ | ` | a | b | c | d | e | f | g | h | i | j | k | l | m | n | o |
112+ | p | q | r | s | t | u | v | w | x | y | z | { | | | } | ~ | DEL |
Чтобы определить код символа, надо сложить числа слева и сверху от этого символа. Ну например, символ E
имеет код 64 + 5 = 69. А строка cat
кодируется 3 байтами с значениями 99
, 97
, 116
.
Как видно, кодировка Ascii содержит такие виды символов:
- заглавные и прописные латинские буквы
- цифры
- математические знаки
+-*/=<>%
(звездочка*
обозначает умножение, а косая черта/
- деление), скобки трех видов (()[]{}
), знаки препинания, кавычки"
, апостроф'
и дополнительные символы вроде#&|^~
- пробел (код 32)
- управляющие, невидимые символы с кодами 0-31 и 127 (обозначенные сокращениями вроде
HT
илиLF
)
На кнопках стандартной компьютерной клавиатуры представлены все видимые символы ASCII.
Большинство управляющих символов сейчас не используется, а те, что стоит знать, я выделил жирным. Вот они:
-
HT (код 9), табуляция. Она использовалась для форматирования таблиц на печати. Табуляция передвигает курсор (на экране) или печатающую головку (в принтере) на ближайшую позицию, номер которой кратен 8 (или другому числу, которое задает шаг табуляции). Например, если попытаться напечатать hello HT world с шагом табуляции 8, то после hello будет вставлено 3 пробела. Подробнее написано в статье Википедии про табуляцию
В языках программирования вроде PHP и Javascript вставить символ табуляции в строку можно специальной последовательностью
\t
, например:echo "Hello\tworld";
-
LF (код 10), line feed, перевод строки. Переставляет курсор на начало новой строки. В языках программирования PHP, Javascript и многих других обозначается последовательностью
\n
.В текстовых файлах обозначает конец строки (однако, под Windows по историческим причинам принято обозначать конец строки двумя идущими подряд символами,
CR LF
(коды 13 и 10). Когда создавали MS-DOS, в Макинтошах для обозначения конца строки использовалсяCR
, а в ЮниксеLF
. Майкрософт решила использовать сразу оба символа, чтобы тексты отображались корректно в других системах. В наше время Маки перешли на символLF
, как и Юникс/Линукс). -
CR (код 13), carriage return, переставляет курсор в начало строки. Используется в текстовых файлах под Windows совместно с LF, чтобы обозначить конец строки. В коде часто пишется как
\r
. -
ESC (код 27), используется для создания последовательностей, позволяющих управлять такими параметрами, как цвет выводимого в консоли текста. Подробнее можно прочитать в Википедии в статье про последовательности ANSI
Подробнее эта кодировка описана в статье про ASCII на Википедии.
В старых языках программирования (вроде Си) также использовался символ NUL
(код 0) для обозначения конца строки в памяти. В современных языках вроде PHP или Javascript обычно длина строки хранится в памяти отдельно и обозначать ее конец не требуется, и строка может содержать внутри символы NUL, не имеющие специального значения. В PHP и JS вставить в строку символ NUL
можно с помощью последовательности символов \0
По мере распространения компьютеров возможностей кодировки Ascii стало не хватать. В европейских языках используются символы вроде Ä
, в России используется кириллица, а их в ASCII не было. Потому таблицу Ascii стали расширять, используя ранее не занятые коды 128-255. Коды 0-127 обозначали точно те же символы, что и ранее, и это обеспечивало совместимость со старыми программами и текстовыми файлами. Текстовый ASCII файл правильно отображался в более новых программах.
Также в некоторые кодировки были добавлены символы псевдографики, позволявшие рисовать рамки и окошки в текстовом режиме, вроде таких:
╓────────╖
║ ░ OK ║
╙────────╜
К сожалению, какого-то единого стандарта не было, и каждый производитель компьютеров и программ придумывал собственную кодировку. Например, только для поддержки кириллицы были разработаны такие кодировки:
- КОИ-8 (Код Обмена Информацией), описанная в ГОСТ в СССР еще в 1974 году. На ее основе позже была сделана KOI8-R, использовавшаяся в основном в linux. Также, было еще несколько разновидностей КОИ-8, отличавшиеся добавлением украинских, белорусских и среднеазиатских символов: https://ru.wikipedia.org/wiki/%D0%9A%D0%9E%D0%98-8
- кодировка CP866, разработанная IBM для русской версии MSDOS
- кодировка Windows-1251, разработанная Microsoft для русскоязычной версии Windows. К сожалению, в ней не было казахских символов, потому в этой стране сделали свой вариант этой кодировки.
- MacCyrillic, когда-то использовавшаяся на компьютерах Макинтош
- ISO_8859-5, которую пытались сделать как единый стандарт и замену остальным кодировкам, но было поздно. В итоге эта кодировка почти нигде и не использовалась.
В этих 5 кодировках (а также их альтернативных версиях для стран вроде Украины или Беларуси) одни и те же русские буквы кодировались разными символами (а латинские - везде одинаково). И чтобы просмотреть текстовый файл, надо было угадать кодировку, в которой он записан. Более того, программное обеспечение в те времена часто ничего не знало о кодировках и использовались какие-то единые для всей операционной системы настройки.
С распространением интернета, электронной почты, обмена файлами это начало вызывать проблемы. Браузер или текстовый редактор мог не отобразить корректно текст, созданный на другом компьютере, а средств выбора кодировки в них не было.
Потому в 90-е годы каждый уважающий себя русскоязычный сайт имел версии своих страниц в нескольких кодировках. Вот, например, как выглядела ранняя версия Яндекса: https://www.artlebedev.ru/yandex/site/saved/ - вверху видны ссылки для выбора кодировки.
В азиатских странах проблем было еще больше. Например, кандзи содержат тысячи символов, и закодировать их одним байтом невозможно. Пришлось придумывать кодировку, где символы кандзи кодируются 2 подряд идущими байтами, и это тоже вызывало проблемы, например, многие программы ожидали, что символ занимает 1 байт и не могли верно посчитать их количество. И наоборот, операционные системы, в которые костылями была вбита поддержка кандзи, не могли отображать тексты в европейских кодировках.
Решить проблему в рамках 8-битных (где символ занимает 1 байт) кодировок было нельзя. Число разных символов было больше, чем 256. Потому при разработке нового стандарта Unicode решили отказаться от идеи «1 символ = 1 байт» и просто присваивать символам номера по порядку (эти номера называются codepoint), не ограничиваясь каким-либо числом. Таким образом, получается единая таблица кодов символов, которую не придется менять, и можно добавлять новые коды в конец таблицы.
Первые 128 символов с кодами 0-127 повторяют таблицу ASCII, а далее идут символы и буквы различных алфавитов. Цель Юникода - присвоить код всем существующим и существовавшим когда-либо символам. Юникод включает даже буквы умерших алфавитов вроде древнеегипетского, а также современные значки вроде эмодзи. Сейчас Юникод содержит около 110 000 символов.
Кроме обычных символов, Юникод содержит символы-модификаторы. Они печатаются поверх предыдущего символа. Это позволяет, например, добавить кружочек или черточку сверху к любому символу. С другой стороны, это немного усложняет работу с текстом, так как один символ на печати кодируется несколькими кодами (символ + модификаторы). Например, текстовый редактор должен уметь воспринимать такую комбинацию как один символ.
Я советую посмотреть, какие виды символов есть в Юникоде:
Казалось бы, с приходом Юникода проблема разных кодировок исчезнет, но не тут-то было.
Поскольку один байт не позволяет вместить код символа из Юникода, для их хранения приходится выделять несколько байт. 2 байта содержат 16 бит, которыми вместе можно закодировать число от 0 до 65535. В первых версиях Юникода было меньше 65 000 символов, и поначалу их решили кодировать 2 байтами (эта таблица кодов называлась UCS-2), позже, когда символов стало больше, символы решили кодировать 4 байтами (UCS-4, позволяет закодировать чуть более 4 млрд. символов). Но даже с 2 байтами на символ разработчики умудрились сделать 2 (две!) разновидности их кодирования: UTF-16 LE и UTF-16 BE. Они отличаются друг от друга порядком байт. Если в одной версии кодировке символ кодируется байтами A B
, то в другой - идущими в обратном порядке байтами B A
. Аналогично и для UCS-4 сделали 2 разновидности UTF-32 LE, UTF-32 BE, где байты идут в прямом или обратном порядке. Код символа один и тот же, но он записывается в памяти разными способами. LE расшифровывается как Little Endian, BE как Big Endian, подробнее в статье про порядок байтов.
В UTF-16 символы с кодами менее 55296 (D800 в 16-чной системе счисления) кодируются 2 байтами, и символы с кодами выше (которые появились в UCS-4) кодируются как суррогатная пара, 4 байтами, как будто это 2 обычных символа с кодами больше 55296.
Переход на Юникод требовал полной переделки программ. Библиотеки для операций со строками считали, что один символ занимает 1 байт, и не могли даже посчитать число символов в юникодной строке, не говоря о более сложных операциях. Майкрософт, проделав огромный объем работы, сделала поддержку UTF-16 в Windows, и ей пришлось сделать по 2 варианта каждой системной функции - для старых программ с 1-байтными кодировками, и для новых, юникодных. Появившийся в то время язык Java тоже решил использовать UTF-16 для хранения строк.
Но многие разработчики не хотели переделывать код. Также, англоязычным разработчикам не нравилась необходимость тратить в 2 раза больше места на хранение строк. Потому был придуман еще один способ кодирования Юникода - UTF-8. В этой кодировке символы с кодами 0-127 (из ASCII) кодируются одним байтом, и текст из ASCII-символов кодируется одинаково и в ASCII и в UTF-8. Таким образом, старая программа может обрабатывать UTF-8 текст, если он содержит только латиницу.
Другие символы Юникода в UTF-8 кодируются большим числом байт - от 1 до 6, чем больше код символа, тем больше байт требуется. Кириллица, например, требует 2 байта на символ. Старые программы воспримут символ кириллицы как 2 отдельных символа и могут повредить строку, например, отрезав один байт из двух.
Подробнее про UTF-8 (wiki), про UTF-16 (wiki), UTF-32 (wiki).
Таким образом, есть как минимум 5 вариантов записи кодов Юникода в памяти: UTF-32 LE/BE, UTF-16 LE/BE, UTF-8. Вот их преимущества и недостатки:
- в UTF-32 каждый символ занимает ровно 4 байта, что позволяет делать операции со строками быстрее. Ну например, чтобы перейти к 500-му символу, нам достаточно умножить 500 на 4 и пропустить 2000 байт от начала строки. В кодировках вроде UTF-8 с разным размером символов нам придется просматривать байты от начала строки, отсчитывая эти 500 символов, что делает любые операции со строками намного медленее. С другой стороны, строки обычно не очень большие по размеру, а процессоры - быстрые, так что в большинстве случаев это не влияет на производительность программы.
- текст на латинице в UTF-32 занимает в 4 раза больше памяти и места на диске, чем в UTF-8
- в UTF-16 символ занимает ровно 2 байта при отказе от суррогатных пар (они кодируют редко используемые символы и не всегда нужны), что экономнее, при этом работа со строками оптимизируется. С другой стороны, символы эмодзи (смайлики) кодируются именно суррогатными парами, и для их поддержки приходится отказываться от оптимизаций.
- UTF-8 позволяет использовать старые библиотеки при условии обработки текстов на латинице и строка в этой кодировке, как правило, занимает меньше места, чем UTF-16 и UTF-32. Но операции со строками становятся медленнее.
На практике в большинстве случаев используют UTF-8. Windows, Java и Javascript внутри используют UTF-16.
Стоит помнить, что текст в памяти или в файле - это лишь набор байт. Чтобы понять, каким символам они соответствуют, надо знать кодировку текста.
Для автоматического определения разновидности кодировки Юникода было предложено использовать специальный символ BOM (Byte Order Mark) в начале текста. Он кодируется по-разному в каждой из кодировок и не должен выводиться на печать. Увы, если программа не знает о BOM, то этот символ может вызвать какую-то ошибку или вывестись в виде знака вопроса. Например, в PHP наличие этого символа в начале файла может вызвать ошибку при попытке установить куки или выдать HTTP-заголовки. Важно сохранять PHP код без BOM.
Подробнее про BOM (Byte Order Mark)
В PHP решили принципиально не поддерживать какую-то определенную кодировку (из-за большого объема работы, нужного для переключения на Юникод). Строки в PHP - это просто последовательности произвольных байт. Строки в исходном коде закодированы в кодировке, использованной при создании PHP файла. Рекомендуется использовать кодировку UTF-8.
При выводе в консоль (например, с помощью echo) байты будут интерпретироваться в зависимости от настроек ОС. В русской версии Windows консоль по умолчанию будет отображать выводимые байты в 8-битной кодировке CP866, в линуксах и маках обычно по умолчанию используется UTF-8. Если исходный код сохранен в UTF-8, под Windows он не сможет без перекодирования выводить русский текст в консоль.
При отображении результата работы скрипта в браузере тот будет воспринимать текст в той кодировке, которая указана в теге <meta charset="utf-8">
либо в опции charset
в полученном от сервера заголовке Content-Type: text/html; charset=utf-8
. Заголовок имеет приоритет над метатегом, а если кодировка не указана, браузер использует какую-нибудь кодировку по умолчанию, например, Windows-1251 или UTF-8. Стоит всегда указывать кодировку для отдаваемых HTML-страниц.
Многие функции для работы со строками в PHP унаследованы со старых времен и предполагают, что 1 символ занимает 1 байт. Они не могут работать с Юникодом, разве что с UTF-8 текстом на латинице. Например, функция strlen()
, которая должна считать длину строки, вернет число байт в строке, а не число символов. Обращение к строке по индексу $s = "Hello"; $char = $s{0};
вернет N-й байт, а не N-й символ строки. Для многобайтовых кодировок функции вроде substr()
будут возвращать неправильные результаты.
Не работают с Unicode: strrev
, strlen
, substr
, strpos
, ucfirst
, wordwrap
, str_pad
и большинство других строковых функций, для работы которых нужно правильно считать число символов. Не работает задание ширины в функциях вроде sprintf
и printf
.
Небольшая часть старых функций работает корректно с UTF-8 (но не UTF-16 или UTF-32), так как им не требуется уметь выделять в строке отдельные символы. Вот они: strtr
(если передавать массив), str_replace
, str_repeat
, explode
, addslashes
, trim
.
Подробности можно прочитать в моем уроке про работу с UTF-8 строками.
Функции работы с регулярными выражениями вроде preg_match()
требуют наличие флага u
в регулярном выражении, чтобы воспринимать строки как UTF-8. Без него они будут считать, что строки закодированы в 1-байтовой кодировке и работать некорректно. Другие варианты Юникода, вроде UTF-16, не поддерживаются.
Для работы с многобайтовыми кодировками в PHP есть расширение mbstring. Оно содержит функции вроде mb_strlen()
, поддерживающие текст в разных кодировках. Кодировка указывается либо в аргументах функции, либо в начале программы вызовом mb_internal_encoding()
.
Если необходимо работать со строкой в UTF-8 посимвольно, можно разбить ее на массив символов таким кодом:
$chars = preg_split("//u", $string, null, PREG_SPLIT_NO_EMPTY);
Выражение //u
соответствует границам между UTF-8 символами, а PREG_SPLIT_NO_EMPTY убирает из массива символов 2 пустых строки в начале и конце.
Текст можно преобразовать из одной кодировки в другую функцией iconv.
Функция mb_detect_encoding
, и аналогичные, обещающие автоматически определить кодировку текста, на самом деле не работают.
При сравнении (if ($a > $b)
) и сортировке строк сравниваются значения байт, которыми они закодированы. Ну например, если попытаться функцией sort
отсортировать массив ['apple', 'cat', 'BYTE'], то получится ['BYTE', 'apple', 'cat'], так как буква B
кодируется как 66, a
как 97, а c
как 99. На практике такая сорировка малополезна.
Для правильного сравнения и сортировки строк стоит использовать класс Collator из расширения intl. Стоит учесть, что в разных странах приняты разные правила сортировки (особенно для букв с точечками сверху), и надо указать нужные правила при использовании класса. Подробнее я это описал в моем уроке про сравнение строк.
При соединении с базой данных через расширение PDO или mysqli необходимо указывать, в какой кодировке передаются данные (обычно это UTF-8).
Получить i-й (начиная с нуля) байт строки в виде числа можно функцией $byte = ord($string{$i})
. Собрать строку из байтов можно кодом вида $string = chr(65) . chr(66) . chr(67);
или $string = pack("c*", 65, 66, 67);
.
Если строка записана в двойных кавычках, то в нее можно вставить произвольные байты, зная их код в 16-чной системе счисления. Например, символ @
кодируется в utf-8 кодом 64, или 4016 в 16-чной системе счисления. Соответственно, последовательность \x40
вставляет в строку байт с кодом 64, и echo "\x40";
выведет символ @
. С помощью таких кодов в строку можно вставить любые байты, от \x00
(код 0) до \xff
(код 255), надо только перевести коды в 16-чную систему.
В Яваскрипт строки хранят символы, закодированные в UTF-16. Яваскрипт считает, что 1 символ занимает 2 байта, что верно для большинства, но не для всех символов Юникода. Новые символы вроде эмодзи с большими кодами кодируются 4 байтами (суррогатной парой) и будут восприниматься Яваскриптом как 2 отдельных символа.
Если яваскрипт встроен в HTML-страницу, то исходный код воспринимается в кодировке страницы. Если он хранится в отдельном .js файле, он будет интерпретироваться как код в кодировке UTF-8 (речь идет имеено об интерпретации кода, при выполнении кода строки все равно будут преобразованы в UTF-16).
Так как Яваскрипт воспринимает строки как набор символов, то встроенные функции работы со строками и регулярные выражения работают корректно (за исключением суррогатных пар). Например, метод str.charAt(i)
вернет i-й символ строки.
Сравнение и сортировка строк по умолчанию происходит с использованием кодов символов. Для правильного сравнения по алфавиту с учетом особенностей языка необходимо использовать либо метод localeCompare, либо класс Intl.Collator. Эти возможности есть только в новых браузерах, и для поддержки старых браузеров надо искать библиотеку-полифилл.
Получить Юникодный код i-го символа строки можно методом charCodeAt: var code = s.charCodeAt(i)
, собрать строку из кодов можно так: var s = String.fromCharCode(66, 67, 68);
. Но символы, кодируемые суррогатными парами, будут восприниматся как 2 отдельных символа. В новой версии Яваскрипт предложены методы String.prototype.codePointAt(i) и String.fromCodePoint(), не имеющие этого недостатка.
Подробнее о строках в Javascript.
На первый взгляд, определить кодировку, имея текстовый файл, невозможно, так как она в нем не задана. Но если человек попробует просматривать этот файл в различных кодировках, он скорее всего сможет сказать, какая из них правильная. Ведь например текст на русском языке состоит в основном из букв кириллицы, а не из случайных символов. В разных кодировках эти буквы кодируются разными байтами, и посчитав частоту, с которой каждый байт встречается в тексте, можно сделать выводы о вероятности, что текст закодирован в той или иной кодировке.
К примеру, в кодировке Windows-1251 байты с кодами 192-255 кодируют русские буквы, а байты с кодами 128-191 - различные редковстречающиеся символы и буквы вроде ¤ или Љ. Если в тексте часто встречаются коды 192-255, и редко - 128-191, то, возможно этот текст использует кодировку Windows-1251. И наоборот, если текст содержит в основном байты 128-191, кодировка скорее всего другая.
Также, можно анализировать сочетания (пары или тройки) букв. В русском языке часто встречаются сочетания вроде "кот", но не встречаются тройки вроде "бзщ".
Еще можно анализировать частоту появления букв. Буква "а" встречается гораздо чаще, чем буква "щ".
Сопоставив процент правильных и неправильных сочетаний, можно получить вероятность того, что текст закодирован в данной кодировке.
Также, можно анализировать особенности кодировки. Например, в UTF-8 не могут встречаться определенные последовательности байт. Если их много, то текст скорее всего в другой кодировке.
Задачи для того, чтобы закрепить материал и лучше понять способы кодирования текста.
-
Дана строка в UTF-8. Выведите коды Юникодных символов (codepoint), из которых состоит текст
Подсказка: можно конечно написать алгоритм раскодировки UTF-8 в коды символов, но это долго и легко сделать ошибку. Проще будет сконвертирвать строку в UTF-16 или UTF-32, где каждый символ кодируется одинаковым числом байт. Например, для UTF-16 (без суррогатных пар) код символа получается как
$byte1 * 256 + $byte2
, где$byte1
и$byte2
- это значение 2 байтов, которыми закодирован символ. -
Дан файл с текстом из русских или английских букв в неизвестной кодировке. Определите кодировку текста.
Подсказка: надо написать функцию, определяющую вероятность, что текст закодирован в кодировке N. Вызывая эту функцию для всех кодировок, надо найти ту, которая даст наибольшую вероятность (при этом другие кодировки должны показывать низкую вероятность).