Самой первой программой, которую пишет новичок, является вывод в консоль "Hello, world!".

#include <iostream>

int main() {
    std::cout << "Hello, world!\n";
}

Некоторые туториалы и книги переводят эту фразу на русский язык и вот что в таком случае видит начинающий программист, когда запускает такую программу:

╨Я╤А╨╕╨▓╨╡╤В ╨╝╨╕╤А!

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

TL;DR

#ifdef _WIN32
#include <windows.h>

    SetConsoleOutputCP(CP_UTF8);
    SetConsoleCP(CP_UTF8);

#endif

Кодировки

Компьютер не знает, что такое буква А или символ @. Он оперирует только байтами. Чтобы мы могли видеть текст, человечество договорилось использовать кодировки - таблицы, где каждому числу присваивается определенный символ.

Однобайтовые кодировки

В 60-х появилась первая кодировка - ASCII. В ней было всего 128 символов.

Один символ занимал 7 бит (в рамках 8-битного байта), и этого хватало для английского языка. Но как только компьютеры вышли за пределы США, начались проблемы.

Поскольку 7 бит оставляли лишнее место (еще 128 значений в одном байте), производители начали заполнять его своими символами. Так появились кодовые страницы (Code Pages):

  • CP866 - для DOS
  • Windows-1251 - для Windows
  • KOI8-R - для Unix-систем
  • И многие другие для различных систем

Проблема в том, что они несовместимы. Если вы сохранили файл в Windows-1251, а открыли в CP866, вместо нормального текста вы увидите те кракозябры.

Именно это происходит в нашем консольном приложении: исходный код сохранен в одной кодировке (скорее всего, UTF-8), а консоль пытается прочитать его в другой (например, CP866).

Unicode

Чтобы прекратить этот хаос, создали Unicode.

Важно понимать: Unicode - это не кодировка. Это гигантский стандарт (в виде огромных PDF-таблиц), где каждому возможному символу в мире присвоено уникальное число - Code Point, например U+1F940 (🥀).

А вот то, как эти числа записываются в памяти компьютера, определяют кодировки семейства UTF (Unicode Transformation Format):

КодировкаРазмер символаОсобенности
UTF-8от 1 до 4 байтСамая популярная. Экономная: английские буквы весят 1 байт (как в ASCII)
UTF-162 или 4 байтаЧасто используется внутри Windows и Java
UTF-32строго 4 байтаМаксимально простая для вычислений, но очень тяжёлая

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

Самая лучшая кодировка сейчас - это UTF-8, потому что она меньше всего весит и используется везде. А кодовые страницы устарели, легаси.

Кстати, раньше, чтобы отличать UTF-8 от других кодировок, придумали UTF-8 с сигнатурой (BOM) - в начало строки или файла ставится 2 специальных байта, которые показывают, что это именно UTF-8. Сейчас UTF-8 with BOM лучше не использовать, тк все и так по дефолту предполагают файл в UTF-8.

Существует манифест UTF-8 Everywhere, который призывает всех везде использовать только UTF-8 для общей согласованности и в основном все так и делают.

Что в C++?

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

Строковые литералы

Любой строковой литерал храниться прямо в exe файле.

В C++ есть несколько видов строковых литералов:

const char[] ordinary_literal = "Hello, world!";
const wchar_t[] wide_literal = L"Hello, world!";
const char[] utf_8 = u8"Hello, world!" // const char8_t[] с C++20
const wchar_t[] utf_16 = u"Hello, world!";
const wchar_t[] utf_32 = U"Hello, world!";

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

По умолчанию, во многих текстовых редакторах исходный файл сохраняется в UTF-8. Но как компилятор определяет в какой кодировке был сохранён исходный файл?

Ответ: никак. Clang и GCC по умолчанию читают исходный файл в UTF-8 и сохраняют строки тоже в нём. В современных реалиях это действительно хорошее решение, так как все везде используют UTF8.

MSVC...

Но вот microsoft, как обычно со своим легаси говнокодом до сих пор используют кодовые страницы. В русской винде, по умолчанию, msvc читает исходные файлы в кодировке Windows-1251 и сохраняет обычные строки тоже в ней.

Из этого следует, что он не может сохранить некоторые символы юникода, записанные через \u, так как их нет в Windows-1251. Также msvc не может нормально сохранять UTF строковые литералы, так как он читает любой utf символ побайтово, думая, что это Windows-1251 и в exe'шнике сохранит эту строку в utf8 не правильно.

utf8-as-1251

Чтобы msvc читал и сохранял строки в utf8, нужно прописать этот параметр компиляции - /utf8. После этого всё будет работать правильно.

Кстати, visual studio тоже сохраняет файлы в Windows-1251. Из-за этого у меня в моих древних проектах на гитхабе нормально не сохранялись комментарии на русском. Это желательно изменить в настройках

Tools -> Options -> Environment -> Documents -> Save files with a specific encoding -> Unicode (UTF-8 without signature)

Что имеем?

По итогу мы имеем строку, сохранённую в UTF-8 внутри exe'шника, который cout принимает и побайтово отправляет её в stdout.

Консоль

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

Чтобы отобразить последовательность байт как символ, консоли нужно нужно знать, какую кодировку использовать. Почти любая консоль линукса по умолчанию использует UTF-8, но вот винда, до сих пор по умолчанию использует одну из кодовых страниц. Для русской винды это CP866.

Консоль тупо читает байты из stdout и выводит их в соответствии со стандартной кодировкой CP866.

Локали

Консоль винды при выводи символов учитывает локаль.

Я сделал кучу тестов как работает консоль с локалями, но понял, что это не имеет смысла. Локали - это, не то что бы легаси, но ненадёжная часть языка C++, особенно на винде, поэтому все пользуются библиотеками типа GNU gettext или ICU. Короче использовать локали я крайне не рекомендую.

wchar_t

Как ранний этап внедрения Unicode придумали широкие строки.

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

Кодировка не обязательно должена быть одной из кодировок Юникода. То, чем будет широкая строка определяет компилятор. Тем не менее на винде каждый компилятор превратит этот символ в кодовую единицу utf-16 размером в 2 байта, а на линуксе в utf-32 размером 4 байта.

Для этих строк придумали кучу отдельных функций и классов типа wcout и wcin.

Особо зацикливаться на широких строках тоже я не вижу смысла, так как это уже абсолютное легаси.

WinAPI

Когда то там давно в winapi все функции митозом поделились на 2. Одни имеют приписку A и работают со строками в кодировках ANSI (ANSI кодировки это кодировоки, придуманные для винды, например windows 1251 это ANSI кодировка, а вот все кодовые страницы, например CP866, это OEM кодировки). А другие имеют приписку W и работают с широкими строками в utf-16.

Нужно всегда использовать W функции, так как они, очевидно позволяют использовать юникод.

Проблема в том, что у широких строк больше недостатков, чем приемуществ. К этому относиться ещё и полная неспособность конвертации char в wchar, поэтому если вы у себя захотите использовать широкие строки, то единственная область их применения будет в winapi, потому что абсолютное большинство библиотек используют просто char в UTF-8 (vulkan, sdl, glfw). Поэтому нужно полностью исключить использование wchar у себя в программах.

Но что же делать, если для работы с виндой, например чтение файлов, изменение текста окна, чтение параметров запуска приложения, нужно использовать широкие строки?

В таком случае нужно преобразовывать utf-8 char строки в широке и обратно прямо на месте, где ваша программа взаимодействует с виндой.

Существует библиотека, Boost.nowide, почти полностью убрать след от wchar_t.

Решение

Как вы поняли, на линуксе такой проблемы изначально нет, так как все терминалы и компиляторы уже используют UTF-8, так что париться нужно только на винде

  1. Для msvc поставить параметр /utf-8 и в visual studio поменять кодировку сохранения файла.
  2. Поменять уже кодировку вывода и ввода консоли на UTF-8. Это делается через вызов этих функций: SetConsoleOutputCP(CP_UTF8); SetConsoleCP(CP_UTF8);
  3. Не использовать локали, а использовать библиотеки
  4. Не использовать wchar_t

По хорошему бы восстановить кодировку консоли в конце программы, получив её через функции GetConsoleCP и GetConsoleOutputCP, но этим можно пренебречь.

PS

В C++23 добавили std::print, который обязывает корректно выводить в консоль UTF-8, если кодировка обычного строкового литерала такая же, так что можно использовать его, тем более он ещё и форматирование поддерживает.

#include <print>

int main() {
    std::print("Привет, мир! 🌍\n"); // Работает из коробки в C++23
}

Но это не решает проблему ввода, поэтому в любом случае нужно менять кодировку консоли. Как сказал один из контрибьюторов c++, они работают над библиотекой которая будет считывать ввод пользователя в utf-8