【C++】stringとwstringの変換で日本語が化ける原因とは?|MultiByteToWideCharのコードページ4通りと、-1を渡す落とし穴を実測

【C++】stringとwstringの変換で日本語が化ける原因とは?|MultiByteToWideCharのコードページ4通りと、-1を渡す落とし穴を実測 C++

「あ」という、たった1文字のstringを、wstringに変換したら、3文字になりました。しかも、中身はFFE3 FF81 FF82という、見覚えのない値です。変換した、つもりだったのですが。

std::wstring(s.begin(), s.end())は、stringからwstringを作る、いちばん手軽な書き方として、よく紹介されます。ところが、日本語を含む文字列には、使えません。正しい変換には、MultiByteToWideCharを使いますが、こちらも、渡すコードページと、文字列の中身のエンコーディングが合っていないと、別の形で壊れます。そこで、組み合わせを全部動かして、結果を並べました。

検証は、Visual Studio 2022付属のMSVC(19.44、x64、Release)で行いました。Windowsは、日本語環境(システムのコードページは932)です。

stringとwstringは、何が違うのか?

stringとwstringの違いは、

・stringはchar(1バイト)の並び、wstringはwchar_tの並び。Windowsでは、wchar_tは2バイトで、UTF-16として扱われる

です。ここで大事なのは、stringの中身が、どのエンコーディングなのかは、型からは分からないことです。同じ"こんにちは"でも、コンパイラの設定によって、UTF-8のバイト列にも、CP932(Shift_JIS)のバイト列にもなります。変換のときは、その違いを、自分で把握しておく必要があります。

実験①:wstring(s.begin(), s.end())で変換する

▼w3_naive.cpp
#include <cstdio>
#include <string>

int main()
{
    const std::string  utf8   = "あ";        // UTF-8 では 3バイト(E3 81 82)
    const std::wstring expect = L"あ";       // 正解は、1文字(3042)

    // よくある書き方: 1バイトずつ、そのままwchar_tに広げる
    std::wstring w(utf8.begin(), utf8.end());

    std::printf("元の文字列: %zu バイト\n", utf8.size());
    std::printf("広げた結果: %zu 文字: ", w.size());
    for (wchar_t c : w) std::printf("%04X ", (unsigned)c);
    std::printf("\n");
    std::printf("正解(wide): %zu 文字: %04X\n", expect.size(), (unsigned)expect[0]);
    std::printf("w == 正解 ? %s\n", w == expect ? "true" : "false");
    return 0;
}
元の文字列: 3 バイト
広げた結果: 3 文字: FFE3 FF81 FF82 
正解(wide): 1 文字: 3042
w == 正解 ? false

UTF-8の"あ"は3バイト(E3 81 82)ですが、変換結果も、3文字になりました。しかも、値はFFE3 FF81 FF82で、正解の3042とは、まったく違います。この書き方は、1バイトずつを、そのまま、1文字として広げているだけで、文字コードの変換は、一切していません。charが符号付きの環境(今回のMSVC)では、0xE3が負の値として扱われて、広げるときに、上位の桁がFFで埋められています。

この書き方で正しく動くのは、ASCII(英数字と記号)だけの文字列です。日本語を含むなら、使えません。

実験②:MultiByteToWideCharに、コードページを渡す

Windowsの標準的な変換は、MultiByteToWideCharです。どのエンコーディングとして読むかを、第1引数のコードページで指定します。CP_ACPは、システムの標準(今回は、CP932)、CP_UTF8は、UTF-8です。

2つのコードページ×2つの文字列の中身で、4通りを試しました。文字列は、同じ"こんにちは"ですが、コンパイル時の設定を変えて、中身のバイト列を、UTF-8とCP932の2通りにしました(/utf-8でUTF-8、/source-charset:utf-8 /execution-charset:shift_jisでCP932)。

▼w1_matrix.cpp
#include <windows.h>
#include <cstdio>
#include <string>

// 変換先のワイド文字列を、コードユニットの16進数で表示する
static std::string Hex(const std::wstring& w)
{
    std::string s;
    char buf[16];
    for (wchar_t c : w)
    {
        std::snprintf(buf, sizeof(buf), "%04X ", (unsigned)c);
        s += buf;
    }
    return s;
}

// narrow(char)文字列を、指定のコードページで、wideに変換する
static bool ToWide(const std::string& s, UINT cp, DWORD flags, std::wstring& out)
{
    int len = MultiByteToWideChar(cp, flags, s.data(), (int)s.size(), nullptr, 0);   // 必要な長さを聞く
    if (len == 0) return false;
    out.assign(len, L'\0');
    MultiByteToWideChar(cp, flags, s.data(), (int)s.size(), &out[0], len);
    return true;
}

int main()
{
    const std::string  narrow = "こんにちは";    // コンパイラの設定で、中身のバイト列が変わる
    const std::wstring expect = L"こんにちは";

    // narrowのバイト列
    std::printf("narrowのバイト列: ");
    for (unsigned char c : narrow) std::printf("%02X ", c);
    std::printf("(%zu バイト)\n", narrow.size());
    std::printf("正解のwide      : %s\n\n", Hex(expect).c_str());

    struct { const char* name; UINT cp; DWORD flags; } modes[] = {
        { "CP_ACP  (フラグなし)         ", CP_ACP,  0 },
        { "CP_ACP  (MB_ERR_INVALID_CHARS)", CP_ACP,  MB_ERR_INVALID_CHARS },
        { "CP_UTF8 (フラグなし)         ", CP_UTF8, 0 },
        { "CP_UTF8 (MB_ERR_INVALID_CHARS)", CP_UTF8, MB_ERR_INVALID_CHARS },
    };
    for (auto& m : modes)
    {
        std::wstring w;
        bool ok = ToWide(narrow, m.cp, m.flags, w);
        if (!ok)
        {
            std::printf("%s -> 失敗 (GetLastError = %lu)\n", m.name, GetLastError());
        }
        else
        {
            std::printf("%s -> %2zu文字: %s%s\n", m.name, w.size(), Hex(w).c_str(), w == expect ? " [正しい]" : " [違う]");
        }
    }
    return 0;
}
narrowのバイト列: E3 81 93 E3 82 93 E3 81 AB E3 81 A1 E3 81 AF (15 バイト)
正解のwide      : 3053 3093 306B 3061 306F 

CP_ACP  (フラグなし)          ->  9文字: 7E3A 8599 FF53 7E3A FF6B 7E3A FF61 7E3A FF6F  [違う]
CP_ACP  (MB_ERR_INVALID_CHARS) ->  9文字: 7E3A 8599 FF53 7E3A FF6B 7E3A FF61 7E3A FF6F  [違う]
CP_UTF8 (フラグなし)          ->  5文字: 3053 3093 306B 3061 306F  [正しい]
CP_UTF8 (MB_ERR_INVALID_CHARS) ->  5文字: 3053 3093 306B 3061 306F  [正しい]
narrowのバイト列: 82 B1 82 F1 82 C9 82 BF 82 CD (10 バイト)
正解のwide      : 3053 3093 306B 3061 306F 

CP_ACP  (フラグなし)          ->  5文字: 3053 3093 306B 3061 306F  [正しい]
CP_ACP  (MB_ERR_INVALID_CHARS) ->  5文字: 3053 3093 306B 3061 306F  [正しい]
CP_UTF8 (フラグなし)          ->  8文字: FFFD FFFD FFFD FFFD 0242 FFFD FFFD FFFD  [違う]
CP_UTF8 (MB_ERR_INVALID_CHARS) -> 失敗 (GetLastError = 1113)
文字列の中身コードページ結果
UTF-8(15バイト)CP_ACP(CP932として読む)9文字の、別の文字(7E3A 8599 …。画面に出すと「縺薙s…」のような形)。失敗にはならない
UTF-8(15バイト)CP_UTF8正しい(5文字)
CP932(10バイト)CP_ACP正しい(5文字)
CP932(10バイト)CP_UTF8FFFD(置換文字)だらけ。MB_ERR_INVALID_CHARSを付けると、失敗(エラー1113)
同じ「こんにちは」でも、文字列の中身と、コードページが合っていないと、別の結果になる

組み合わせが合っているのは、2つだけです。見るべきは、組み合わせが合っていないときです。

  • UTF-8の中身を、CP_ACPで読むと、エラーにならず、別の文字に化けます。変換結果が9文字になっている(正解は5文字)ので、数が合わないことで、気づけます
  • CP932の中身を、CP_UTF8で読むと、規則に合わないバイトが、FFFD(置換文字)に置き換えられます。MB_ERR_INVALID_CHARSを付けると、置き換えずに、失敗(エラー1113)を返すので、間違いに気づけます

MB_ERR_INVALID_CHARSは、今回の4通りでは、CP_UTF8で、不正な入力を検出してくれました。逆に、CP_ACPでは、UTF-8のバイト列を読んでも、失敗にならず、検出できませんでした。

実験③:長さに-1を渡す

MultiByteToWideCharの第3・第4引数に、-1を渡すと、「終端の\0まで」の長さを、自動で数えてくれます。便利ですが、戻り値に、終端の\0ぶんが、含まれます。

▼w2_len.cpp
#include <windows.h>
#include <cstdio>
#include <string>

int main()
{
    const std::string  utf8   = "こんにちは";      // /utf-8 でビルドするので、UTF-8
    const std::wstring expect = L"こんにちは";

    // 悪い例: 長さに-1(終端の'\0'まで)を渡すと、戻り値に終端ぶんが含まれる
    {
        int len = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, nullptr, 0);
        std::wstring w(len, L'\0');
        MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, &w[0], len);
        std::printf("[-1 を渡した]   len = %d, w.size() = %zu, w == 正解 ? %s\n", len, w.size(), w == expect ? "true" : "false");
        std::printf("                 末尾のコードユニット = %04X\n", (unsigned)w.back());
    }
    // 良い例: 文字列の長さを、明示する
    {
        int len = MultiByteToWideChar(CP_UTF8, 0, utf8.data(), (int)utf8.size(), nullptr, 0);
        std::wstring w(len, L'\0');
        MultiByteToWideChar(CP_UTF8, 0, utf8.data(), (int)utf8.size(), &w[0], len);
        std::printf("[長さを渡した]  len = %d, w.size() = %zu, w == 正解 ? %s\n", len, w.size(), w == expect ? "true" : "false");
    }
    return 0;
}
[-1 を渡した]   len = 6, w.size() = 6, w == 正解 ? false
                 末尾のコードユニット = 0000
[長さを渡した]  len = 5, w.size() = 5, w == 正解 ? true

-1を渡した場合の長さは、6でした(「こんにちは」の5文字+終端\0)。この長さで、wstringを作ると、末尾に、\0(0000)の入った、6文字の文字列になり、正解のL"こんにちは"とは、一致しません。画面に出すだけなら、気づきにくい違いです。文字列の長さ(size())を、明示して渡すと、5文字で、正解と一致しました。

直し方:関数にまとめて、往復を確かめる

文字列の中身を、UTF-8に決めて(ソースは/utf-8でビルド)、変換を、2つの関数にまとめました。失敗を検出するために、MB_ERR_INVALID_CHARSも付けています。

▼w4_round.cpp
#include <windows.h>
#include <cstdio>
#include <string>

static std::wstring Utf8ToWide(const std::string& s)
{
    if (s.empty()) return std::wstring();
    int len = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s.data(), (int)s.size(), nullptr, 0);
    if (len == 0) return std::wstring();
    std::wstring w(len, L'\0');
    MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s.data(), (int)s.size(), &w[0], len);
    return w;
}

static std::string WideToUtf8(const std::wstring& w)
{
    if (w.empty()) return std::string();
    int len = WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), nullptr, 0, nullptr, nullptr);
    if (len == 0) return std::string();
    std::string s(len, '\0');
    WideCharToMultiByte(CP_UTF8, 0, w.data(), (int)w.size(), &s[0], len, nullptr, nullptr);
    return s;
}

int main()
{
    // 絵文字(サロゲートペア)や、空の文字列も、往復できるか
    const std::string cases[] = { "こんにちは", "Hello", "", "日本語ABC123", "😀" };
    for (const std::string& s : cases)
    {
        std::wstring w = Utf8ToWide(s);
        std::string back = WideToUtf8(w);
        std::printf("元 %2zu バイト -> wide %zu コードユニット -> 戻した %2zu バイト  往復で一致 = %s\n",
                    s.size(), w.size(), back.size(), back == s ? "true" : "false");
    }
    return 0;
}
元 15 バイト -> wide 5 コードユニット -> 戻した 15 バイト  往復で一致 = true
元  5 バイト -> wide 5 コードユニット -> 戻した  5 バイト  往復で一致 = true
元  0 バイト -> wide 0 コードユニット -> 戻した  0 バイト  往復で一致 = true
元 15 バイト -> wide 9 コードユニット -> 戻した 15 バイト  往復で一致 = true
元  4 バイト -> wide 2 コードユニット -> 戻した  4 バイト  往復で一致 = true

日本語、英数字、空の文字列、混在、絵文字(😀)のすべてで、元に戻して、一致しました。絵文字は、UTF-8で4バイトのものが、UTF-16では、2つのコードユニット(サロゲートペア)になります。wstringのsize()が、文字の数と、一致しないことがあるのは、このためです。

文字列の変換で、よくある失敗例と対処法

①wstring(s.begin(), s.end())を、日本語に使う

実験①のとおり、文字コードの変換は、していません。英数字だけで動いたからと、使い回さないでください。

②文字列の中身が、何のエンコーディングか、把握していない

ソースファイルの文字コード、コンパイルオプション(/utf-8など)、ファイルから読んだデータ。どれが、stringの中身を決めているのかを、先に決めます。決めたうえで、変換のコードページを、合わせます。

③変換の失敗を、確かめない

MultiByteToWideCharは、失敗すると0を返します。MB_ERR_INVALID_CHARSを付けたうえで、戻り値を確かめないと、実験②のCP_UTF8の失敗に、気づけません。

④-1を渡して、末尾の

④-1を渡して、末尾の\0を含めてしまう

を含めてしまう

実験③のとおり、wstringのsize()が、1つ多くなります。比較や連結で、おかしな結果になる原因です。

注意点

  • wstring(s.begin(), s.end())は、1バイトずつ広げるだけで、日本語では壊れる(FFE3 FF81 FF82)
  • stringの中身が、UTF-8なのか、CP932なのかは、型からは分からない。コンパイルの設定で決まる
  • UTF-8をCP_ACPで読むと、失敗せずに、別の文字に化ける。結果の長さが、合わないことで気づける
  • CP932をCP_UTF8で読むと、FFFDが混ざる。MB_ERR_INVALID_CHARSを付けると、失敗として検出できる(エラー1113)
  • 長さに-1を渡すと、終端の\0が含まれる。長さを明示するのが、安全
  • 今回の検証は、Windows(日本語環境、コードページ932)、MSVC 19.44(x64、Release)で行った。他の環境では、結果が変わりうる

この記事のポイント

  • stringからwstringへの変換には、MultiByteToWideChar(逆はWideCharToMultiByte)を使う
  • 文字列の中身と、コードページを、必ず、合わせる。UTF-8に決めるなら、CP_UTF8
  • MB_ERR_INVALID_CHARSと、戻り値の確認で、間違いを検出する
  • 長さは、-1ではなく、size()を渡す

「変換できた」と「正しく変換できた」は、別の話でした。文字数が、期待と合っているか。往復して、元に戻るか。この2点を確かめるだけでも、日本語の変換の失敗は、ほとんど見つけられます。

あわせて読みたい