6.1. Кодировки и работа с ними

6.1.1. Собственно кодировки

Как вы прекрасно знаете, любые данные, с которыми работает компьютер, — это просто последовательность байт, т.е. чисел, каждое от 0 до 255. Трактовка того, что каждое из этих чисел обозначает, целиком зависит от приложения.

Но нередко возникает необходимость работать с текстом, который нужно так или иначе показывать пользователю. Для этого необходимо уметь как-то последовательность чисел перевести в последовательность картинок, которые человеком будут опознаны как буквы. Конкретный внешний вид каждой картинки определяется, конечно, шрифтом, но все-таки необходимо иметь некоторую договорённость о том, как последовательность чисел переводится в буквы (хотя бы чтобы во всех шрифтах буква A была в одном и том же месте). Эта договорённость, вообще говоря, и называется кодировкой, или кодовой таблицей, или кодовой страницей (codepage), и про различные кодировки я и буду говорить в этом параграфе.

Примечание

Конечно, наличие такой фактически устной договорённости ничего ровным счётом не обозначает. Вполне может найтись шрифт, в котором соответствие между числами и буквами не то — и с этим шрифтом все будет плохо. Более того, в принципе, можно вручную «подломать» тот или иной шрифт — и вы увидите, как все испортится.

Примечание

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

По-видимому, одним из первых таких «соглашений», стандартов был American Standart Code for Information Interchange, ASCII. Он определял размер байта равным 7 битам и, соответственно, определял значения 128 символов. Эти 128 символов и до сих пор неизменны и в любой кодировке (по крайней мере из тех, с которыми вы в первую очередь столкнётесь :) ) следуют таблице ASCII. Первые 32 из них имели (и в основном до сих пор имеют) особое, внутри-компьютерное значение и потому являлись (и до сих пор считаются) так называемыми управляющими, или служебными символами; о них я скажу чуть ниже. Остальные же числа соответствуют различным частоупотребляемым символам, цифрам и латинским буквам. Это соответствие (какому числу, т.е. какому коду, соответствует какая буква) вы легко можете увидеть: как просто написав простую программу на питоне: char(i) — это символ с номером i, — так и посмотрев в «Таблице символов» — стандартной программе Windows (но там управляющие символы не рисуются); и есть еще много других способов посмотреть. Также таблица приведена в этих заметках, в разделе про символы в питоне.

Немного ещё скажу про управляющие, или служебные, символы. Изначально кодировка, видимо, предназначалась непосредственно для передачи данных между разными устройствами; например, видимо, вывод данных на принтер осуществлялся просто копированием текста в порт принтера. При этом все неуправляющие коды напрямую переводились в картинки и выводились на экран или на печать, а управляющие символы не выводились, а именно управляли процессом вывода (т.е. драйвер монитора/принтера трактовал эти символы особым образом). Конечно, каждому символу также соответствовала некоторая картинка (например, рожицы), но также и некоторое действие. Теперь эти действия работают в основном только при выводе текста в консольное окно, но все равно они имеют смысл. Можете с ними поэкспериментировать. Перечислю смысл некоторых символов:

  • Символ #7 «beep»: не выводит ничего на экран, но издаёт звуковой сигнал (писк) системным динамиком. Очень удобное средство издавания звука, например: writeln(#7’An error occcured!’);
  • Символ #8: а-ля backspace: передвигает курсор на позицию влево (но не стирает символ, который был в той позиции!) Например, write(’a’#8); выводит символ a и оставляет курсор под этим символом, а потому write(’a’#8’b’); [1] в итоге эквивалентно write(’b’);
  • Символ #9: Tab: передвигает курсор вправо до ближайшей позиции, кратной восьми, или т.п. (конкретная трактовка этого, равно как и других символов, зависит, конечно, от программы). Вообще, это один из немногих управляющих символов, который, насколько я понимаю, активно используются и сейчас везде, позволяя приложениям выравнивать текст как они хотят.
  • Символ #10: Line feed: перемещает курсор на строку вниз, оставляя его в том же столбце.
  • Символ #13: Carriage return: возвращает курсор в начало текущей строки.
  • Символ #26: EOF (end of file): конец файла. Видимо, подразумевалось, что это будет последний символ в файлах (хотя я далеко не уверен). На самом деле он сейчас в основном, видимо, используется как символ окончания ввода, да и то редко.

Символы с номерами от 1 до 26 также иногда называют соответственно Ctrl-A, Ctrl-B, …, Ctrl-Z (т.е., в частности, Ctrl-Z — это EOF, потому ввод с клавиатуры в команде copy con file надо заканчивать именно символом Ctrl-Z). Это скорее просто обозначение (т.е. это не значит, что всегда по нажатию на клавиатуре Ctrl-Z будет введён символ EOF, а по Ctrl-A — символ #1 — для этого нужна особая обработка программой), причём это обозначение используется весьма редко, но его все равно полезно помнить.

Ещё особые слова про символы номер 10 и 13. Видимо, первые принтеры были устроены как печатающие машинки, и, чтобы начать печать новой строчки, надо было сделать две операции: прокрутить лист бумаги на строчку вниз («скормить» принтеру одну строку бумаги :) — line feed), при этом столбец, где находился курсор, не изменится — и передвинуть печатающую каретку в начало строки — carriage return. Потому для перевода строки использовались именно два символа — 13 (CR) и 10 (LF). С тех пор многое изменилось. Теперь символы имеют скорее условный смысл, и в Windows просто «по договорённости» принят перевод строки как последовательность из двух символов — 13 и 10. В Unix-системах используется перевод только одним символом — 10. В этом смысле говорят от форматах файлов Windows и UNIX — различие только в том, как обозначается перевод строки. Но по-прежнему в Windows при выводе в консольное окно (т.е. в текстовое окно) символы 13 и 10 имеют различные значения: именно то, что написано выше. Если с начала строки вывести write(’abc’#13’d’); то получится dbc и курсор будет под b.

Позже стало ясно, что 7 бит не хватает для обозначения всех нужных символов, и стали использовать 8 бит на байт, что позволило иметь ещё 128 символов. Видимо, довольно быстро придумали, что там нужно иметь: туда поместили западноевропейские символы (типа á, ç и т.п.), а также символы псевдографики (которыми рамочки рисуются) и т.п. Получилась западноевропейская кодировка. (Я точно не знаю, существуют ли различные варианты западноевропейской кодировки, поэтому много про неё писать не буду).

Когда в России (СССР ещё, видимо) решили придумать кодовую таблицу, конечно, за основу решили взять таблицу ASCII и просто поместить русские буквы во второй половине таблицы. Я не буду, наверное, давать тут подробный обзор, только перечислю три наиболее распространённые русские кодировки:

  • Кодировка DOS, или cp866 (вообще, насколько я понимаю, Microsoft или кто-то ещё занумеровали почти все существующие кодировки — не только русские, но и другие). Эта кодировка была придумана, видимо, ещё в СССР и широко использовалась в DOS и до сих пор используется в некоторых случаях. Взяли западноевропейскую кодировку и заменили в ней западноевропейские символы на русские, сохранив псевдографику на местах. Правда, при таком условии не нашлось два блока по 32 свободных символа подряд, поэтому все русские заглавные буквы тут идут подряд в алфавитном порядке, а вот маленькие разбиты на две группы по 16 символов: а–п и р–я вроде. В каждой группе символы идут подряд в алфавитном порядке, но между п и р идут около 32 символов псевдографики.
  • Кодировка KOI8-R. Тоже изобретена давно. Основное её свойство — если у кода русской буквы отбросить старший бит, то получится (в большинстве случаев) некая похожая английская буква. Т.е. раз символ номер 61 — латинская А, то символ номер 128+61 — русская А; 62 — английская B, тогда 128+62 — Б и т.д. В результате русские буквы идут в неалфавитном порядке, но зато если некое устройство умеет выводить только первые 128 символов, то отбросив первый бит, получим хотя бы читабельный текст (типа Russkij Tekst). (Существует ещё и KOI8-U — украинская, насколько я понимаю.)
  • Кодировка Windows, она же cp1251 или прямо Windows-1251. Её, видимо, придумало Microsoft для использования в Windows. Здесь и маленькие, и заглавные русские буквы идут сплошными блоками, без разрывов в алфавитном порядке. Используется довольно часто в Windows.

Естественно, общих символов, т.е. символов, которые присутствуют во всех трёх кодировках (точнее, во вторых половинах всех трёх кодовых таблиц), не так уж и много: это, конечно же, все русские буквы (за исключением, возможно, буквы Ё, которая, может быть, не присутствует в KOI8-R), а также, может быть, ещё несколько символов типа «№», поэтому не имеет смысла говорить о взаимно-однозначном соответствии между кодировками. Но, с другой стороны, в текстах из всей второй половины таблицы в основном используются только русские буквы, и в этом смысле можно говорить о перекодировке текста из одной кодировки в другую: т.е. о замене в тексте одних чисел (значений байтов) на другие, которые соответствуют той же букве, но в другой кодировке. Ещё раз подчеркну, что перекодировка корректно переведёт только русские буквы и, может быть, ещё некоторые символы, но с остальными символами (например, псевдографика из кодировки DOS) ничего толкового сделать не получится: аналогичный символ просто будет отсутствовать в целевой кодировке (что произойдёт в этом случае, зависит, конечно, от самой программы перекодировки; например, она может заменить все такие символы на знаки вопроса и т.п.).

Уточню, что обозначает слово «используется» в тексте выше. На самом деле оно обозначает именно то, что обозначает: что в этих случаях русские буквы кодируются именно в соответствии с данной кодировкой. Например, я несколько раз получал e-mail, в которых, если поглядеть в их исходный текст, русский текст был написан в кодировке KOI8-R. Конечно, прежде чем выводить текст на экран, программа работы с электронной почтой перекодировывала текст.

Ниже на картинках приведены таблицы символов (т.е. символы в порядке кодов) в этих трех кодировках. Первая строка — символы номер 0…31, вторая — 32…63 и т.д.. Получено просто с плагина Character Map к Far Manager. Во второй половине таблицы в DOS корректно показаны все символы, в остальных таблицах — в основном только русские буквы, остальные символы могут быть неправильные (собственно, потому, что для отображения на экране Far был вынужден перекодировать символы в кодировку DOS). Зацените порядок русских букв в KOI8-R.

../_images/dos.png

Кодировка cp866 («dos»)

../_images/win.png

Кодировка cp1251 («windows-1251»)

../_images/koi.png

Кодировка KOI8-R

Но со временем стало ясно, что 8 бит для представления текстов мало. Поэтому была изобретена кодировка Unicode. В отличие от всех остальных распространённых сейчас кодировок, она не подразумевает использования 8 бит на символ. Наиболее распространены три варианта кодировки Unicode:

  • UTF-8, в которой на наиболее часто используемые символы (а именно, первую половину таблицы ASCII) используется один байт (8 бит, первый из которых 0), на некоторые символы (в т.ч. русские) — два байта (при этом, естественно, так, чтобы нельзя было перепутать с однобайтовыми символами — первый бит первого байта обязательно 1), а на некоторые — три или четыре (всегда по первым битам первого байта можно различить, какой из этих четырёх случаев имеет место).
  • UTF-16: в ней часть символов занимает 2 байта, а часть — четыре (это называется «суррогатной парой» из двух двухбайтовых символов). Ещё пять лет назад четырёхбайтовые символы были экзотикой для письменности древних шумеров (помимо прочего), а сейчас же стали встречаться всё чаще из-за эмодзи. Они тоже, как ни странно, входят в таблицу Unicode, начиная с позиции 1F300 (кстати, это символ «циклон» — 🌀). Такой номер уже в два байта не помещается. В остальном же я четырёхбайтовые символы встречаются очень редко, поэтому в первом приближении можно пренебречь их существованием и считать, что каждый символ UTF-16 занимает два байта.
  • UTF-32: все символы кодируются 4 байтами. Я лично сталкивался с этой кодировкой очень редко.

Важная особенность всех юникодных кодировок (в отличие от однобайтовых) — не каждая последовательность байт является корректной строкой на таких кодировках. Не все пары байт являются корректным символом или символами в utf-8 или utf-16, не все четверки байт являются корректным символом в utf-32, и т.д., и поэтому далеко не каждая последовательность байт может быть интерпретирована как последовательность корректных символов в этих кодировках.

Кодировки Unicode сейчас весьма распространены, к данному моменту (2020 г.) практически вытеснили все остальные кодировки.

Примечание

Ещё замечу, что во всех этих кодировках возникает так называемая проблема byte endianness — проблема порядка байт: если на символ требуется больше одного байта, то какой из них писать первым, а какой вторым. Иногда пишут одним способом, иногда другим (на самом деле это проблема не только кодировки, но и вообще представления чисел). [2]

Для решения этой проблемы в кодировке UTF-16 в начале файла может идти специальный символ BOM (byte order mark) с номером FEFF. В зависимости от порядка байт он записывается либо как FE, FF, либо наоборот — это позволяет просмотрщику определить порядок. Некоторые редакторы (особенно старые) добавляют его автоматически при сохранении в UTF-16. Некоторые просмотрщики (особенно новые) про BOM вообще не знают, поэтому в них вы можете иногда увидеть в начале байта странные символы, которые «не видно» в других редакторах. Особенно хорошо это заметно на веб-страницах, которые пытаются показать содержимое файла — BOM случайно оказывается в середине страницы и показывается как странные квадратики. Аналогично, какая-то версия MS Visual Studio сохраняет программу на C++ с BOM, и потом такие программы не компилируются компилятором g++…

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

Наконец, приведу результаты просмотра текста Russian text Русский текст, написанного изначально в разных кодировках, в кодировке DOS (т.е. изначальный текст я написал в разных кодировках, а потом стал просматривать в DOS):

image1 image2 image3
DOS Windows KOI8-R
image4 image5
UTF-8 UTF-16

(Слегка разные пропорции у разных символов — это ничего не значит, мне просто лень было подбирать размеры при вставке картинок в текст.) Конечно, текст, изначально написанные в кодировке DOS, нормально вполне в этой кодировке и просматривается; особых комментариев по тексту, изначально написанному в кодировках Windows и KOI8-R, я не придумал, но обратите внимание на следующие особенности Unicode-кодировок:

  • UTF-8. Обратите внимание, что английский текст (и все три пробела!) получился вполне нормальным, и только русский текст испортился. Обратите также внимание на некоторую довольно заметную двухбайтную периодичность в русском тексте (т.е. на то, что чётные байты довольно сильно отличаются от нечётных: в чётных байтах встречаются то буквы, то символы псевдографики, а в нечётных — только два разных символа псевдографики). Это общий признак кодировки UTF-8: если вы видите, что все английские буквы, цифры, знаки препинания и т.п. выглядят нормально, а вот там, где должны быть русские буквы, написана какая-то чушь с явной двухбайтовой периодичностью, то скорее всего, вы просматриваете кодировку UTF-8. Для примера картинка: отрывок из xml-файла, записанного в кодировке UTF-8 и просмотренного, на этот раз, в кодировке Windows. Все, кроме русских букв, как будто в однобайтовой кодировке, а в русских буквах явно видна периодичность в два байта. Обилие подчерков объясняется тем, что для вывода картинки на экран текст перекодировывался еще раз (из Windows, видимо, в кодировку DOS), и поэтому многие символы были потеряны.

    image6

  • UTF-16. Обратите внимание, что на этот раз все символы занимают по два байта. И английские, и русские буквы, и пробелы занимают два байта; при этом первый байт у английских букв и пробелов — символ номер ноль (который в этом шрифте имеет такую же картинку, что и пробел, и потому выглядит как пробел), а второй байт как раз и есть соответствующий символ (английская буква либо символ 32 для пробела). Первый байт у русских букв, как вы можете видеть из таблиц кодировок выше, есть символ номер 4, ромбик. Этот ромбик на самом деле является характерным признаком русского текста, написанного в кодировке UTF-16 и просматриваемого в однобайтовой кодировке (что DOS, что Windows), а «разрежённые» английские буквы и цифры (на самом деле, ещё раз, между ними не пробелы, а символы номер 0) — характерным признаком английского текста в кодировке UTF-16. Ещё раз подчёркиваю, что, если вы не будете работать с древними символами или эмодзи, можно приближённо считать, что UTF-16 — абсолютно двухбайтовая кодировка.

6.1.2. Работа с кодировками в языках программирования

В современных языках программирования есть два принципиально разных подхода к работе с символами и с кодировками. Я условно их назову «классическим» и «юникодным», соответственно буду говорить про «классические» и «юникодные» языки программирования.

Примечание

На самом деле, конечно, это две крайности, и реальные языки могут и зачастую сочетают особенности того и другого варианта. Более того, понятно, что вы можете использовать не стандартные средства языка, а какие-то внешние библиотеки для работы со строками, и тогда ситуация определяется во многом этими библиотеками, а не языком.

6.1.2.1. Классические языки

К классическим языкам относятся многие достаточно старые языки, в том числе C++ (если речь идет про std::string; там еще есть тип std::wstring, имеющий определенную поддержку юникода) и паскаль. Основной принцип, на котором они строят работу с символами и строками — это «символ — это байт». В этих языках символ — это просто число от 0 до 255, занимающее 1 байт, а соответственно строка — это просто последовательность байт. Это может быть совсем явно, как в C++, где char — это просто целочисленный тип, это может быть не столь явно, как в паскале, где есть специальные команды для конвертации числа в символ и символа в число (chr и ord соответственно), но все равно каждый символ занимает один байт.

Примечание

На самом деле команды chr и ord ничего с точки зрения программного кода не делают, они просто позволяют объяснить компилятору, что вам надо сменить тип данных. Реально в памяти как хранилось число, так и продолжает храниться.

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

Что это обозначает? Во-первых, это обозначает, что внутри программы вы можете работать со строками просто как с последовательностью байт. Вы имеете полное право вообще не задумываться о том, в какой кодировке вы работаете. В частности, вы можете в строке хранить последовательность байт, которая вообще не является текстом (например, вы можете в виде строки хранить картинку в формате jpeg, ведь картинка, закодированная в формате jpeg — это просто последовательность байт). Ну и соответственно можете делать с этими байтами что угодно.

(Сказанное в предыдущем абзаце может показаться очень естественным, и может показаться странным, что я задерживаю на этом внимание. Может возникнуть вопрос: а как же может быть по-другому? А вот читайте до юникодных языков и узнаете.)

Но если вы действительно работаете с текстом, то вам уже самим как программисту придется задумываться, в какой кодировке где у вас данные. В простейшем варианте вы, скорее всего, выберете какую-то конкретную кодировку, и будете писать программу так, чтобы все строки были в этой кодировке. И язык программирования вам тут уже не особо поможет, все нужные операции вы должны будете делать сами. (Конечно, вы можете, в том числе, перекодировать ваши строки из одной кодировки в другую, заменяя байты на другие байты. Для этого даже могут быть стандартные функции, но все равно это по сути работа с массивом байт. Языку все равно, что вы с этими байтами делаете, хотите поменять байты по хитрому правилу — ну дык пожалуйста.)

При этом у вас сразу же возникнут проблемы. Вы можете в качестве основной кодировки выбрать какую-нибудь однобайтовую кодировку (например, cp866). Тогда работа с ней будет достаточно прямолинейна, но ваша программа не сможет работать с символами, не входящими в cp866. Если лет 20, даже 10 назад, это еще могло считаться более-менее нормальным, то сейчас (2020 г.) это уже моветон. Например, пишете вы какой-нибудь мессенджер — неужели вы хотите запретить пользователям общаться на французском языке (со всей его диакритикой типа ç), или пересылать друг другу названия трудов 孔子 в оригинале, или просто пересылать смайлики 😉 (которые тоже входят в таблицу юникода)?

Или вы можете, например, использовать utf-8, но тогда возникает другая проблема: кодировка utf-8 оказывается плохо совместима с библиотечными методами работы со строками. Даже банальное вычисление длины строки будет возвращать неправильный результат; вы не сможете стандартными средствами итерироваться по символам строки (потому что стандартные средства будут итерироваться по байтам строки, а не по символам, ведь символ может занимать несколько байт), возможно, будет плохо работать поиск и замена, и т.д.

У работы с кодировками есть еще один очень важный момент — это ввод-вывод данных. В принципе, то, что описано в абзацах выше, в простейших программах можно игнорировать, вы можете выбрать однобайтовую кодировку и собственно ваша программа будет нормально работать, и черт с ним с французским языком. Но вы же в любом случае хотите, чтобы на экране программы, или в файлах, сформированных вашей программой, хотя бы те символы, которые есть в вашей кодировке (например, русские буквы), отображались бы правильно. А это как раз и есть вопрос ввода-вывода.

Проще всего ситуация обстоит с файловым вводом-выводом. Файл — это тоже последовательность байт. Поэтому классические языки при вводе строки из файла просто прочитают те байты, которые записаны в файле, и запишут эти байты в строку; аналогично при выводе строки в файл будут записаны те байты, которые были в строке.

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

Аналогично и с вводом данных из файла: если вы сначала создали файл в каком-то редакторе, а потом читаете его программно, то ваша программа считает именно те байты, которые редактор записал в файл, а уж как редактор превратил ваши нажатия на клавиши в байты файла — это вопрос к редактору, к тому, в какой кодировке редактор сохранил файл.

Несколько сложнее обстоит дело с вводом в клавиатуры или выводом на экран. Но тут надо понимать, что ваша программа почти наверняка не будет сама рисовать отдельные символы или анализировать нажатия на клавиши, почти всегда вы будете использовать каких-нибудь посредников — операционную систему, ее стандартные библиотеки, программу-окошко консоли и т.д. И вот именно они и занимаются преобразованием клавиш в байты и преобразованием байт в картинки, а ваша программа все равно работает только с байтами. Условно говоря, программа говорит ОС (или библиотеке ввода-вывода, или окну консоли): выведи эту последовательность байт как символы на экран. А уже ОС решает, как эти байты превратить в картинки.

Соответственно, чтобы ваш вывод на экран отображался корректно, вам надо согласовать свои действия с этими посредниками. В простейшем случае вы должны сами разобраться, в какой кодировке посредники ожидают строку, и при необходимости вручную перекодировать ваши строки перед выводом.

Или зачастую вы можете попросить посредника ожидать ту или иную кодировку, или как-то еще настроить работу этого посредника. В частности, в C++ есть функция setlocale, которая пытается делать в том числе что-то подобное. Но при этом надо понимать, что эффект подобных функций полностью зависит от посредника. В разных ОС, да даже в разных окошках консоли может оказаться, что подобные функции работают сильно по-разному.

В частности, особый случай — это если ваш вывод на экран перенаправлен средствами ОС на вывод в файл (в частности, именно так тестируются ваши решения в тестирующих системах). Тогда роль посредников как правило сводится чисто к тому, чтобы направить байты, выведенные вашей программой, в файл, и становится справедливо всё то, что я писал выше про вывод в файл. Никакие функции типа setlocale, как правило, не будут никак влиять на то, что окажется в файле.

Всё сказанное выше относится и к вводу с клавиатуры: там тоже вы или должны разобраться, в какой кодировке посредники отдают вам данные, или как-то настроить посредников; и при перенаправлении ввода на ввод из файла ваша программа будет просто получать дословно байты, записанные в файле.

Примечание

Утверждение про то, что setlocale не влияет на ввод-вывод в файл, может быть не совсем верно, во-первых, потому что упомянутые посредники могут все-таки учитывать локаль даже при перенаправлении вывода в файл, во-вторых, потому что локаль влияет не только на кодировки, но и на многое другое, например, на символ десятичной точки. Но в целом в плане кодировок общее утверждение справедливо: все определяется тем, как поступят ваши посредники, и в целом для каждого конкретного посредника надо разбираться отдельно.

Есть еще один способ как символы могут попасть в вашу программу — вы можете записывать строки и символы напрямую в коде программы, например, через std::string s = "Тест";. Но тут действует тот же принцип, что и с чтением файлов: программа в конечном счете это файл, и компилятор читает ее как файл. А в файле строка будет представлена последовательностью байт, вот компилятор дословно эти байты и запишет в память. А уж какими байтами была представлена эта строка в файле с программой — это вопрос к редактору, в котором вы набирали текст программы, к тому, в какой кодировке редактор сохранил вашу программу.

И наконец, конечно, строки могут попадать в вашу программу при ее взаимодействии с другими программами, например, вы можете скачать веб-страничку из интернета, или загрузить строку из базы данных. И тут, естественно, опять получится, что с точки зрения языка вы получили последовательность байт, и вы опять должны будете сами думать про то, в какой кодировке отдает вам строки база данных (или библиотека, которую вы используете для работы с базой данных), и при необходимости осуществлять перекодировку вручную.

6.1.2.2. Юникодные языки

Юникодные языки — это в первую очередь более современные языки, в том числе питон (начиная с 3 версии!), javascript, также, как я понимаю, еще и java (хотя там есть свои тонкости). Они берут подход, который на первый взгляд может показаться пародоксальным и очень странным, но на самом деле он довольно эффективно решает многие проблемы классических языков.

Основа этого подхода — это то, что символ — это вовсе не байт; символ — это символ, вещь в себе, черный ящик, и программист почти никогда не должен задумываться о том, как этот символ хранится в программе, в какой кодировке, как представляется в байтах и т.д. Аналогично, строка — это массив таких символов. И сам язык программирования представляет нужные функции для работы с символами и строками.

(Это может показаться странным, но на самом деле очень похожая ситуация, например, с вещественными числами. Вы же не задумываетесь, как именно вещественное число представлено в байтах; вам достаточно того, что с вещественными числами работают все нужные вам операции.)

В реальности эти языки хранят строки в какой-нибудь кодировке, основанной на юникоде, и поэтому вы прекрасно можете хранить в своих строках любые символы, которые есть в юникоде. Более того, языки предоставляют вам сразу все базовые операции работы со строками, например, определение длины строки, итерацию по символам строки, взятие символа по индексу в строке и т.д. — сразу с учетом юникодной природы символов, у вас не будет ситуации в стиле что длина строки "Тест" равна восьми.

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

Примечание

На самом деле многие конструкции в языках программирования появились в начале 1990-х годов, когда юникод уже был, а символов с кодами больше 65536 (которые не помещаются в два байта) — ещё нет. Из-за этого в языках Java, JavaScript и C++ соответствующие «как бы юникодные» типы (char в Java, wchar_t в C++) на самом деле просто работают с UTF-16 и предполагают, что четырёхбайтовых символов не существует. То есть в них всё работает, но как только в строке появятся эмоджи, вы получаете всё те же проблемы, что и в «классических» языках — длина строки "🌀" снова равна двум, вы можете случайно распилить один символ на два кусочка (как на это отреагирует просмотрщик — отдельный вопрос)…

А вот в Python 3, Rust и Go, которые появились через десять лет, один символ в строке соответствует одному UTF-32 символу. Так что с простыми эмодзи в них работать можно.

Но при этом получается, что в строке вы можете сохранить только последовательность настоящих юникодных символов, а не произвольные бинарные данные, как это было в классических языках. Вы не сможете в строку сохранить картинку, закодированную в jpeg. (Как я писал выше, далеко не каждая последовательность байт является корректным текстом в юникодных кодировках.) Это на первый взгляд не удобно, но большинство юникодных языков просто представляют отдельный тип для работы с массивом байтов — bytes в питоне, Uint8Array в javascript и т.д. И вот в нем вы можете хранить произвольную последовательность байт. Более того, как правило, у вас будут стандартные функции по конвертации строки в последовательность байт и обратно в указанной кодировке. Взяв строку, вы можете превратить ее в последовательность байт в нужной вам кодировке, используя стандартную функцию, которая принимает строку и кодировку, и возвращает последовательность байт (функция str.encode в питоне). И наоборот, имея последовательность байт, и указав, в какой кодировке надо ее интерпретировать, вы получаете строку (функция bytes.decode в питоне).

Это на самом деле оказывается очень удобно (как и вообще любая типизация): вы сразу понимаете, где у вас строка (у которой не надо задумываться о кодировках), где последовательность байт (которая вообще может быть не-символьной), и именно на стыке этих двух представлений, там, где это действительно надо, вы должны думать про кодировку. А если вы работаете в программе только со строками, то про кодировки вы можете вообще не думать.

Конечно, тут возникает тот же вопрос ввода-вывода, что и в классических языках.

Работа с файлами становится сложнее. Действительно, ведь в файле у вас хранится последовательность байт, а вы хотите считать строку. Для этого, естественно, надо решить, в какой кодировке интерпретировать эту последовательность байт. Ну и соответственно все операции работы с файлами в юникодных языках требуют указания конкретной кодировки, в которой надо читать файл. (Ну или не требуют обязательного указания — но тогда просто будет применяться некоторая определенная кодировка по умолчанию.) В частности, в питоне в команде open есть опциональный параметр encoding, по умолчанию он зависит от настроек ОС (зачастую как раз utf-8).

И соответственно возможна ситуация, когда данные, записанные в файле, невозможно интерпретировать как строку в указанной кодировке. В таком случае программа может упасть, или может какие-то символы из входного файла потерять; в любом случае, получается, что не каждый файл возможно прочитать в строку — ситуация, немыслимая в классических языках. Но, естественно, вы можете прочитать абсолютно любой файл в последовательность байт (например, в питоне это делается указанием символа b при открытии файла: open("input.txt", "rb")). Тогда при чтении данных вы будете получать не объект типа строка, а объект типа последовательность байт.

Работа с экраном и клавиатурой, в целом, сохраняет те же проблемы, что и в классических языках — все зависит от конкретных посредников. Но если в классических языках вы должны были сами разбираться, какую кодировку требуют посредники, и как-то их сами настраивать, то в юникодных языках сам язык (интерпретатор или системная библиотека и т.д.) во многих случаях будут способны сами договориться с посредниками, сделать нужную перекодировку и т.д. Но не всегда; возможно, вам придется отдельно разбираться, и это будет даже сложнее, чем в классическом языке, потому что вам придется разбираться не только в какой кодировке посредник ожидает данные, но и в какой кодировке ваша программа отдает эти данные (потому что, конечно, обмен данными между вашей программой и посредниками осуществляется байтами).

Аналогична ситуация и с получением данных из других программ, например, из баз данных. Но тут все немного попроще, потому что скорее всего для работы с базами данных вы будете использовать какую-нибудь библиотеку для вашего языка программирования, и зачастую она сама разберется, как сохранять строки в базу данных и как их читать назад, и вам не придется ничего специально делать или как-то особо разбираться. (Говоря по-другому, в классических языках только вы знали, в какой кодировке у вас строки, поэтому вам приходилось при чтении данных из внешней базы данных про это думать. А в юникодных языках сам язык знает, в какой кодировке строки, поэтому библиотека работы с базой данных сама разберется, что делать при чтении данных.)

Ну и наконец, возникает вопрос, как трактовать строки, записанные прямо в коде программы (например, s = "Тест"). Программа обычно хранится в виде файла на диске, поэтому в программе это не строка, а последовательность байт. Соответственно, как и при чтении из файла, для этого тот, кто будет читать программу (компилятор или интерпретатор) должен знать, какая кодировка используется для исходного текста программы. Это может быть или просто зафиксировано на уровне языка, компилятора и т.п. (например, может быть жестко сказано: все исходники должна быть в utf-8), или это может быть возможно так или иначе настраивать в программе. Например, в питоне есть специальный формат комментариев coding для указания кодировки входного файла.

В целом, достоинства юникодных языков — это встроенная поддержка всех юникодных символов и работы с юникодными строками, а также то, что во многих случаях вы можете даже не задумываться о том, с какой кодировкой вы работаете; работа с кодировками явным образом вынесена в те места, где это действительно нужно. Недостаток — то, что в объекте «строка» вы не можете сохранить произвольную последовательность байт, ну и в целом классический подход проще, прямолинейнее и понятнее юникодного.

6.1.3. Использование кодировок в олимпиадах

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

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

Раньше (особенно до широкого распространения юникодных языков) встречались и задачи, в которых могут быть байты больше 127; ну и сейчас, наверное, на разных странных олимпиадах такое возможно (ну и уж тем более возможно в разных тренировочных контестах, где используются старые задачи, в том числе на алгопроге есть такие задачи). Такие задачи делятся на две больших группы.

Первая группа — где вам не надо уметь как-то особо различать отдельные символы с кодами больше 127, например, вам не надо отличать русские буквы от всех остальных символов. Характерный пример — задача «Цензура», в ней надо уметь выделять во входном тексте только пробелы, переводы строки, и девять указанных в условии знаков препинания, а из остальных символов вам надо только подсчитывать одинаковые; вас совершенно не интересует, какой из символов, например, является русской буквой, а какой нет. Поэтому тут совершенно не важно, в какой кодировке входной файл, главное чтобы это была однобайтовая кодировка.

На любом классическом языке программирования такие задачи спокойно решаются без каких-либо специальных ухищрений. Вы считываете входные данные как строку, и дальше с ней работаете. И выводите ответ, причем тут нужно вывести просто те же байты, что вы прочитали, а это делается естественно без каких-либо специальных действий, никаких setlocale и т.д. не нужно. На юникодных же языках есть два способа решать такие задачи: можно, конечно, использовать тип данных «массив байт», но в целом проще работать с файлами, указать при чтении данных какую-нибудь однобайтную кодировку (например, cp866), тогда язык прекрасно сконвертирует входные строки в свое внутреннее представление, вы совершенно спокойно с ними поработаете, и при выводе опять вы должны будете указать ту же кодировку.

В обоих случаях (и в классическом, и в юникодном языке) вы можете переживать, что ваш код выводит на экран не совсем то, что было во входных данных, или например (на классических языках) обнаружить, что вы вводите данные, а длина строки оказывается в два раза длиннее… — но, как я писал выше, это скорее вопрос к посредникам при вводе-выводе данных. В тестирующей системе будет работа через файлы (либо напрямую, либо через перенаправление), и всё будет работать. На классическом языке вы просто считаете именно те байты, что записаны во входном файле, обработаете их и выведете; на юникодном языке вы считаете эти байты, сконвертируете их во внутреннюю кодировку, обработаете, и выведете, перекодировав их обратно. В обоих случаях ошибаться именно в работе с кодировками негде, поэтому на самом деле вы вполне можете сами тестировать задачу чисто на английских буквах (на символах с кодами до 127), а потом отправить задачу в тестирующую систему, и у вас само всё заработает. Единственное, что от вас требуется — еще раз повторюсь — на юникодных языках указать явно однобайтную кодировку при вводе и выводе.

(Собственно, примерно потому такие задачи раньше и существовали — раньше все писали на классических языках, и там никаких проблем с этим не было, последовательность байт и есть последовательность байт, с ней вы спокойно работаете, и даже не задумываетесь про кодировки.)

И вторая группа задач — те, где вам надо различать разные символы с кодами больше 127, например, если вам надо русские буквы обрабатывать не так, как другие. В таком случае вы, конечно, должны знать, в какой кодировке входные данные, и соответственно, жюри должно написать это в условии. После этого на юникодных языках вы читаете данные с указанием кодировки, а на классических языках вам надо будет понять, что делать. Но такие задачи — это совсем плохо, на приличных олимпиадах такого быть не должно, и на алгопроге таких задач нет.

6.1.4. Что такое «символ»?

(Раздел, равно как и отдельные замечания в соседних разделах, написан Егором Суворовым.)

(Это довольно продвинутая тема, для начального изучения не особо нужная.)

На самом деле в стандарте Unicode термина «символ» вообще нет. Непонятно, что это. Например, в английском у каждой буквы есть две версии, и это разные символы, отличающиеся регистром — например, I и i. В турецком отдельно существует I без точки и İ с точкой, их строчные версии записываются как ı и i. То есть как только вы запустили программу в Турции, утверждение "i".upper() == "I" может стать неверным.

В корейском языке иероглифы вроде 한 — это на самом деле три буквы алфавита, склеенные в одну графему. Это один символ или три? Непонятно. Вот в китайском 漢 — это отдельный иероглиф. Имя मनीष может визуально выглядеть как три «символа», однако на самом деле नी в юникоде записывается как два символа: сначала न, а потом «дорисовывающий» ी. Аналогичная конструкция может возникнуть с кириллическими буквами «ё» и «й»: если не ошибаюсь, на macOS редакторы регулярно сохраняют её как два символа подряд («е» и «дорисуй две точки», и аналогично «и» и «дорисуй бреве (полукруг над буквой)») вместо одного (чем создают проблемы при просмотре текста на других ОС).

Из-за этого может ломаться даже регистрозависимый поиск подстроки в строке.

Строго говоря, Unicode отдельно определяет:

  • Code unit — минимальные числа, из которых можно кодировать символ. Это 8 бит для UTF-8, 16 бит для UTF-16, 32 для UTF-32. Зависит от кодировки.
  • Code point — то, что выше подразумевалось как «символ», от кодировки не зависит. Они бывают очень разные: некоторые могут выглядеть по-разному в зависимости от соседей (как в арабской вязи), некоторые могут с соседями склеиваться, некоторые управляющие могут изменять направление текста на справо-налево.
  • Grapheme cluster — то, что визуально больше всего похоже на один символ. Может состоять из нескольких code point, склеенных вместе. Например, эможди 👨‍👩‍👧‍👦 («семья») состоит из нескольких code point (определяющих каждого члена семьи), но выглядит как один символ. Кстати, Far Manager при редактировании этой строчки сошёл с ума и не смог правильно посчитать позицию курсора, а во многих редакторах, если поставить курсор после этой эмоджи и начать нажимать Backspace, то эмоджи удаляется не целиком, а отдельными членами семьи.

Тем не менее, даже если вы научились выделять из строчки grapheme cluster, это не поможет. Например, если вы «обрежете» текст на сотом символе и добавите «… читать далее», то символы на границе могут начать выглядеть по-другому и поменяют смысл. При этом вы не можете обрезать «на границе слов» — в китайском не используются пробелы, а слова могут состоять из нескольких иероглифов.

Если вы хотите «просто отсортировать» строчки, то вам нужно как-то учесть, что в немецком символ «ß» (это даже один code point, кстати) надо сортировать как подстрочку «ss». Например, "sa" < "ßa" < "sz" — вы не имеете права считать, что это один символ. В том же немецком буква «Ä» должна сортироваться как «ae», но в шведском это отдельная буква, которая идёт после «z». Вы не можете корректно отсортировать строчки, не зная, немецкий это или шведский. И правильный язык программирования тут не поможет: это принципиальное ограничение юникода.

Единственная операция, которую почти всегда можно делать безопасно — копировать и конкатенировать строчки. Но и это неверно: например, если вы в Telegram вставите в середину своего ника символ right-to-left override с кодом 202E, изменяющий направление текста на «справа-налево», то вместо «Вася присоединился к чату» получится «Ва‮ся присоединился к чату».

Примечание

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

Примечание

Это одна из стандартных уязвимостей, которые упрощают социальную инженерию: можно отправить жертве файл, который будет отображаться как имеющий расширение .png (картинка), но на самом деле будет программой с расширением .exe.

Общих рецептов, к сожалению, нет. Будьте осторожны, если вам показалось, что ваше приложение может корректно работать с любыми языками.

[1]Обратите внимание, как вставлять символы, заданные кодами, в строки в паскале
[2]Термины big-endian и little-endian первоначально не имели отношения к информатике. В сатирическом произведении Джонатана Свифта «Путешествия Гулливера» описываются вымышленные государства Лилипутия и Блефуску, в течение многих лет ведущие между собой войны из-за разногласия по поводу того, с какого конца следует разбивать варёные яйца. Тех, кто считает, что их нужно разбивать с тупого конца, в произведении называют «Big-endians» («тупоконечники»).http://ru.wikipedia.org/wiki/Порядок_байтов.