[C++]コンソール出力にchar8_t文字列を出力したい!

Windows

おそらくほとんどの場合、非Windows環境ではcharエンコードUTF-8なのでそのまま出力できるはずです。しかし、C++20では標準出力ストリームに対するchar8_t char16_t char32_toperator<<deleteされているため、そのままではコンパイルエラーになります。でもまあcharUTF-8なのですから、こう、ちょっとひねってやれば、無事出力できます・・・

#include <iostream>
#include <string_view>

int main() {
  using namespace std::string_view_literals;
  auto u8str = u8R"(日本語出力テスト 🤔 😢 🙇<200d>♂️ 🎉 😰 😊 😭 😥 終端)"sv;

  std::cout << reinterpret_cast<const char*>(u8str.data()) << std::endl;
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

問題なのはWindowsさんです・・・

Windowsのコンソール出力と標準出力

C言語ではI/Oをファイルとそれに対するデータのストリームとして抽象化しています。ファイルの読み書きによって動作環境との通信(すなわちI/O)を制御し、規定しています。標準出力(stdout)や標準入力は(stdin)は標準によって予め開くファイルが規定されているストリームで、これらにおいてのファイルとはコンソール(端末)です。

C++もCからこれらのことを継承し、多くのI/O関数はCのものを参照しているため、この辺りの標準I/Oストリームに関することは共通しています。

従って、C/C++の範囲から見た標準出力とはとりあえずは何かのファイルに対する出力として考えることができます。特に、標準出力が受け取った文字列をどう表示するかというところはファイル出力の先の話であり、C/C++が感知するところではありません。

標準IOストリームのモード

C言語においてのファイルストリームには3つのモードがあり、ファイルオープンに使う関数種別fopen/wfopenおよびその引数、もしくはそのファイルストリームに対して最初に使用した関数で決定されます。

テキストモードではストリームへの入力データをテキストデータ(マルチバイト文字列 : char文字列)だとして処理します。改行コードの変換やロケール対応がここで行われます。標準入出力のデフォルトはこのモードです。この時、ワイド文字列版のI/O関数(std::wcoutなど)を使用すると、内部でマルチバイト文字列へ変換されたうえでストリームへ入力されます。

ユニコードモードではテキストモード時の入力データをワイド文字列(wchar_t文字列)として扱います。それ以外はテキストモードと同様ですが、マルチバイト文字列(char文字列)を処理するI/O関数(std::coutなど)が使用できなくなります。

バイナリモードはその名の通り入力データをバイト列として扱います。ワイド文字、マルチバイト文字版何れの関数でもその入力データをバイト列として扱い、何の変換も行われません。

ここでのマルチバイト文字列/ワイド文字列のエンコードは規定されていません。WindowsではそれぞれANSI/UTF-16になります。

出力だけに注目すると、最終的にファイルにはモードに応じて次のように書きこまれます。

  • テキストモード
    • char : そのまま書き込み
    • wchar_t : charに変換されて書き込み
  • ユニコードモード
    • char : 使用不可
    • wchar_t : そのまま書き込み
  • バイナリモード
    • 何れの場合もそのまま書き込み

コンソールのコードページ

ここからはWindowsのお仕事です。

Windowsにおける標準出力として設定されているファイルの中はコンソール出力へ繋がっています。C/C++のI/O関数としてはこのファイルに対してテキストモードではcharエンコード、すなわちWindows環境ごとのANSI(日本語ならShift-JIS)エンコードで文字列を書きこんでおり、ユニコードモードならばUTF-16で文字列を書きこんでいます。

文字列を表示するためには、その環境の言語毎に最適なエンコードを選択して文字列をそれに変換したうえで表示する必要があります。例えば、ANSI文字列と言っても言語設定によってその解釈に使用すべき文字コードは変化します。
Windowsのコンソールにおいてそれを指定しているのがコードページです。日本語環境ならばCP932というコードページがデフォルトであり、その文字コードはShift-JiSが利用されます。

おおよその場合デフォルトのコードページはその言語環境に合わせたANSIを示すコードページになっているはずなので、テキストモードではコードページに合わせた何かをする必要はありません。バイナリモードの場合も、ファイル出力されてきたバイト列をコードページに従ったエンコードで解釈するだけです。

しかし、ユニコードモード時はそのコードページに対応するエンコードへ変換する必要があります。無駄に思われるかもしれませんが、標準ストリームのモードとコードページは別なのです。コードページはOSで指定されているものなので、表示に当たってはそちらが優先されます。

そのため、ここのコードページを変更してやればユニコードモードにおいてはUTF-16を無変換で通すこともできるかもしれません。

スクリーンバッファ

コードページに従ったエンコードに変換された文字列は最後にスクリーンバッファに出力され、そのままフォントレンダラに渡され表示されます。

このスクリーンバッファ1文字辺りはCHAR_INFO構造体1つによって表現されます。定義を見るに、1文字はwchar_t1つかchar1つのどちらかです。これはおそらくVSプロジェクト設定にある文字セットの設定によってどちらが使われるか決定されると思われます。

あえて変更しなければ今時はユニコードになっているはずなので、スクリーンバッファの文字コードひいてはコンソール最終出力の文字コードUTF-16になっています。従って、コードページのエンコードからスクリーンバッファのエンコードへ再び変換され、スクリーンバッファへと出力・表示されることになります。

CHAR_INFO1つがコンソールスクリーン上の1文字に当たり、それはwchar_t1つ分なので、コンソール出力ではサロゲートペアや合字をそのまま扱えなさそうなことがうかがえます・・・

1. 素直に変換してstd::coutする

一番簡便かつ確実な方法は、UTF-8文字列をANSI(Shift-JIS)文字列へ変換してstd::coutへ出力することです。

std::codecvtC++17で非推奨化してしまったので変換にはWinAPIを利用することにしますが、UTF-8 -> Shift-JISの変換を実はそのままできません。MultiByteToWideCharchar* -> wchar_t*へ、WideCharToMultiBytewchar_t* -> char*へ変換するので、どうしても型を合わせられないのです・・・

なのでこれらを連続適用して、UTF-8 -> UTF-16 -> Shift-JISという2段階変換することになります。

#include <iostream>
#include <string_view>

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>

int main() {
  using namespace std::string_view_literals;
  auto u8str = u8R"(日本語出力テスト 🤔 😢 🙇<200d>♂️ 🎉 😰 😊 😭 😥 終端)"sv;

  //UTF-8 -> UTF-16
  auto length = ::MultiByteToWideChar(CP_UTF8, 0, 
    reinterpret_cast<const char*>(u8str.data()), static_cast<int>(u8str.length()),
    nullptr, 0);
  
  std::wstring temp(length, '\0');

  auto res = ::MultiByteToWideChar(CP_UTF8, 0,
    reinterpret_cast<const char*>(u8str.data()), static_cast<int>(u8str.length()),
    temp.data(), temp.length());

  //UTF-16 -> Shift-JIS
  length = ::WideCharToMultiByte(CP_ACP, 0,
    temp.data(), static_cast<int>(temp.length()),
    nullptr, 0,
    nullptr, nullptr);

  std::string result(length, '\0');

  res = ::WideCharToMultiByte(CP_ACP, 0,
    temp.data(), static_cast<int>(temp.length()),
    result.data(), static_cast<int>(result.length()),
    nullptr, nullptr);

  std::cout << result;
}

出力結果

変換の実装を信用すれば、UTF-8 -> UTF-16の変換で文字が落ちることはありませんが、UTF-16 -> Shift-JISの変換では当然Shift-JISでは受けきれないものが出てきます(絵文字とか)。それはWideCharToMultiByteがシステムデフォルト値(どうやら??)で埋めてくれます。

後面倒なのでしてませんが、コード内resで受けてる変換結果が0だとエラーが起きてるのでケアした方が良いでしょう。

1.2 UTF-16に変換してstd::wcoutする

しかしとはいえ、二段階変換はさすがに気になりますし、途中でバッファ(wstring)を確保しなければいけないのも少し気になります。むしろ、std::wcoutUTF-16出力したくなりますよね。しかし、そのままだとなぜかAscii範囲外の文字が出力されません・・・

std::wcoutと言えども出力先はstd::coutと一緒です。すなわち、wchar_tを内部でcharに変換してから出力しています。そしてどうやら、std::wcoutのデフォルトはCロケールになっており、Cロケールでは変換時に非Ascii範囲の文字をスルーしてくれるようです。華麗です・・・

つまりは、明示的にロケールを指定してあげればいいのです。何を指定すればいいのかさっぱりですが、幸いWindowsではstd::locale("")とするとその環境のシステムデフォルトのロケールが取得できます。これはWindows限定でポータブルで、外国語環境に行っても適切にその環境のデフォルトロケールを取得することができます。後はこれをstd::wcoutにセットしてやればいいのです。

#include <iostream>
#include <string_view>

#define WIN32_LEAN_AND_MEANv
#include <Windows.h>

int main() {
  using namespace std::string_view_literals;
  auto u8str = u8R"(日本語出力テスト 🤔 😢 🙇<200d>♂️ 🎉 😰 😊 😭 😥 終端)"sv;

  //UTF-8 -> UTF-16
  auto length = ::MultiByteToWideChar(CP_UTF8, 0, 
    reinterpret_cast<const char*>(u8str.data()), static_cast<int>(u8str.length()),
    nullptr, 0);

  std::wstring result(length, '\0');

  auto res = ::MultiByteToWideChar(CP_UTF8, 0,
    reinterpret_cast<const char*>(u8str.data()), static_cast<int>(u8str.length()),
    result.data(), static_cast<int>(result.length()));

  // wcoutにシステムデフォルトのロケールを設定(Cロケールから変更
  std::wcout.imbue(std::locale(""));

  // 出力
  std::wcout << result;
}

出力結果

絵文字は消えましたが日本語出力は出来ているように見えます。しかし、これ以降同じプログラム内でstd::wcoutに何か出力しようとしても何も出てきません。

絵文字が消えているまさにそれが問題で、Shift-JISは絵文字を表現できないので絵文字の変換の際に内部でエラーとなってしまい、それ以降fail状態となり何も出てこなくなるのです。これはfail()によって検出でき、clear()によって回復できます。

  // wcoutにシステムデフォルトのロケールを設定
  std::wcout.imbue(std::locale(""));

  // 出力
  std::wcout << result;

  // fail状態なら状態を復帰する
  if (std::wcout.fail()) {
    std::wcout.clear();
  }

std::wcoutで出力したとしてもその内部でコードページに従った変換(結局Shift-JISへの変換)が走っているうえに、変換エラーによって出力できなくなるというのはこれはこれでイケてないですね・・・

2. UTF-16に変換してWriteConsoleW()する

Windowsにおいて、スクリーンバッファに直接出力するためのAPIWriteConsoleW()関数です。この関数はUTF-16文字列を受け取り、指定されたコンソールのスクリーンバッファに直接出力します。

#include <iostream>
#include <string_view>

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>

int main() {
  using namespace std::string_view_literals;
  auto u8str = u8R"(日本語出力テスト 🤔 😢 🙇<200d>♂️ 🎉 😰 😊 😭 😥 終端)"sv;

  //UTF-8 -> UTF-16
  auto length = ::MultiByteToWideChar(CP_UTF8, 0, 
    reinterpret_cast<const char*>(u8str.data()), static_cast<int>(u8str.length()),
    nullptr, 0);

  std::wstring result(length, '\0');

  auto res = ::MultiByteToWideChar(CP_UTF8, 0,
    reinterpret_cast<const char*>(u8str.data()), static_cast<int>(u8str.length()),
    result.data(), static_cast<int>(result.length()));

  // 出力
  ::WriteConsoleW(::GetStdHandle(STD_OUTPUT_HANDLE), result.data(), result.length(), nullptr, nullptr);
}

出力結果

WriteConsoleW()関数は指定されたコンソールのスクリーンバッファに対して指定されたUTF-16文字列を直接書き込む関数です。スクリーンバッファはコンソールの出力そのもので、ここに書き込まれているデータがフォントレンダラによって表示されます。

出力結果をコピペしてみると分かるのですが、絵文字列は表示出来ていないだけでコピペ先が表示できるもの(VSCodeとか)ならばちゃんと表示されます。すなわち、文字コードとしては出力までUTF-16で行われています。絵文字が出ないのはおそらくコンソールの表示部分がサロゲートペアを扱えないのに起因していると思われます。

WriteConsoleW()関数は名前の通りコンソール出力専用の関数なので、起動したプログラムにコンソールが割り当てられていない場合に失敗します。すなわち、この関数による出力ではリダイレクトができません。

3. 標準出力をユニコードモードにする

冒頭で説明したように、ユニコード出力だけを使うのであれば標準ストリームをユニコードモードにしてしまえばいいでしょう。Windowsでは_setmode()関数によってストリームのモードを後から変更できます。

#include <iostream>
#include <string_view>

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>

int main() {
  using namespace std::string_view_literals;
  auto u8str = u8R"(日本語出力テスト 🤔 😢 🙇<200d>♂️ 🎉 😰 😊 😭 😥 終端)"sv;

  //UTF-8 -> UTF-16
  auto length = ::MultiByteToWideChar(CP_UTF8, 0, 
    reinterpret_cast<const char*>(u8str.data()), static_cast<int>(u8str.length()),
    nullptr, 0);

  std::wstring result(length, '\0');

  auto res = ::MultiByteToWideChar(CP_UTF8, 0,
    reinterpret_cast<const char*>(u8str.data()), static_cast<int>(u8str.length()),
    result.data(), static_cast<int>(result.length()));

  // 標準出力をユニコードモードにする
  ::_setmode(_fileno(stdout), _O_U16TEXT);
  // 出力
  std::wcout << result;
}

出力結果

この方法実は、ユニコードモードといいつつユニコード直接出力出来ているわけではありませんので、コピペしてみると表示できないものは表示できない事が分かるでしょう。内部でUTF-16 -> Shift-JIS -> UTF-16変換が行われています。変換に失敗した文字列はスペースが当てられているのでしょうか。試してませんが、UTF-16コードページに変更すればあるいは・・・

なお、この方法だとstd::coutが使用できなくなります。出力するとエラー吐いて止まります・・・。他人の書いたライブラリを使っているときなどはログ出力にstd::coutが使用されている可能性があるので注意が必要です。

4. コンソールのコードページを変更してUTF-8バイト列を直接流し込む

C/C++I/O関数の範囲内においてはバイナリモードで出力しておき、コンソールのコードページをUTF-8に変更してしまえば、スクリーンバッファへの出力時のUTF-16変換一回で済みそうです。これならばUTF-8文字列をなるべく変換させず、文字が落ちることもほぼないはず・・・

#include <iostream>
#include <string_view>

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>

int main() {
  using namespace std::string_view_literals;
  auto u8str = u8R"(日本語出力テスト 🤔 😢 🙇<200d>♂️ 🎉 😰 😊 😭 😥 終端)"sv;

  // 出力先コンソールのコードページをUTF-8にする
  ::SetConsoleOutputCP(65001u);
  // 標準出力をバイナリモードにする
  ::_setmode(_fileno(stdout), _O_BINARY);
  // バイナリ列として直接出力
  std::cout.write(reinterpret_cast<const char*>(u8str.data()), u8str.length());
}

出力結果

最後の方がダブってるのはなんでしょうか、3バイト以上の文字が悪さをしているのでしょうか・・・

この方法でも、VSCodeなどにコピペしてみれば絵文字が正しく表示されるので意図通りになっているようです。また、Ascii範囲内の文字ならばstd::coutは依然として使用可能ですが、std::wcoutは文字化けします。

コードページを変更してあるので、コンソールはまず入ってきたバイト列をUTF-8文字列として解釈します。UTF-8はAscii文字と下位互換性があるのでstd::coutはAscii範囲内に限って使用可能となります。しかし、std::wcoutは通常wchar_tUTF-16)を受け付けますが、バイナリモードでは無変換でコンソール入力へ到達し、そこでのコードページに従った解釈の際、UTF-16文字列をUTF-8文字列だと思って処理してしまうため、文字化けします・・・

ただし、コンソールのスクリーンバッファへの出力は通常UTF-16なので、UTF-8がそのまま出力されているわけではなく、スクリーンバッファへの出力にあたってはUTF-8 -> UTF-16の変換が行われます。

5. Boost.Nowideを使用する

boost1.73から追加されたBoost.Nowide<iostream><fstream>UTF-8対応をポータブルにするライブラリです。非Windows環境に対してはcharエンコードUTF-8だと仮定しそのまま、Windows環境ではUTF-8 -> UTF-16変換してWriteConsoleW()などWindowsユニコード対応APIで出力します。

残念ながらchar8_t対応はされていない(おそらく厳しい)のですが、これを利用すれば一番最初に紹介した方法がポータブルになります。

#include <string_view>
#include <boost/nowide/iostream.hpp>

int main() {
  using namespace std::string_view_literals;
  auto u8str = u8R"(日本語出力テスト 🤔 😢 🙇<200d>♂️ 🎉 😰 😊 😭 😥 終端)"sv;

  boost::nowide::cout << reinterpret_cast<const char*>(u8str.data()) << std::endl;
}

試していないので出力がどうなるのかは分かりませんが、実装を見るにおそらくWriteConsoleW()を使用したときと同様になるかと思われます。

UTF-8の直接出力 in Windows

無理です。

絵文字の表示 in Windows

通常のコンソールでは無理ですが、Windows Terminalを使えば表示できます。

上記2の(WriteConsoleW()による)方法での出力 in Windows Terminal

出力結果

上記4の(コードページ変更とバイナリモードによる)方法での出力 in Windows Terminal

出力結果

まだ合字が表示できないみたいですが、今後に期待ですね。

検証環境

参考文献

謝辞

この記事の6割は以下の方々によるご指摘によって成り立っています。

この記事のMarkdownソース