マルチバイト文字 | Programming Place Plus 新C++編

トップページ新C++編

このページの概要

このページでは、これまで避けてきた日本語の文字をプログラムの中で使ってみます。蔵書リストのプログラムには、本や著者の名前が必要なので、どうしても日本語の文字を避けることができませんが、日本語の文字を使いはじめると、いくつか厄介な問題に直面することがあります。すべての問題をきちんと解説すると非常に難しくなるので、ここでは一部の問題を紹介するにとどめます。蔵書リストのプログラムをいったんの完成形まで導きます。

以下は目次です。要点だけをさっと確認したい方は、「まとめ」をご覧ください。



マルチバイト文字

これまでのページでは日本語の文字を扱うことを避けてきましたが、蔵書リストのプログラムには書名や著者名があるため、日本語の文字は避けられません。このページでは日本語の文字を使うとどうなるかを見ていくことにします。

ここまで日本語を扱うことを避け続けてきたのは、単純に「難しい」からです。難しさの原因の1つは、見た目のうえでの1文字を、1文字のように扱えないことがある、という点が挙げられます。

次のプログラムを見てください。

#include <iostream>
#include <string>

int main()
{
    std::string s {"Hello, 日本!"};
    char c {s.at(7)};
    std::cout << c << "\n";
}

実行結果:

"Hello, 日本!" という文字列に対して、s.at(7) で文字を取得し、出力しています。7文字目にあたる が出力されることを期待しますが、そうはならず、何も出力されていません。

int型で表現できる整数の範囲に限界があったように(「int型の限界」のページを参照)、char型で表現できる文字の種類にも限界があります。char型の大きさは必ず 1バイトであると定められており、これは、char型で表現できる文字の種類は最大でも 256種類しかないことを意味します(1バイト=8ビットなので、28 = 256)。256通りでは、漢字という膨大な文字を持つ日本語のすべての文字を表現することはそもそも不可能なのです。

文字は、文字コードと呼ばれる整数によって表現されていることを思い出してください(「文字」のページを参照)。

日本語の文字も表現できるようにするためには、1つの文字に 1バイトを超える大きさを割り当てる必要があります。その際の考え方が大きく2種類あって、1つはすべての文字を同じ大きさ(たとえば 4バイト)に統一して表現する方法で、これをワイド文字 (wide character) と呼びます。もう1つは、ある文字は 1バイトだが、ほかのある文字は 2バイトや 3バイト(あるいはそれ以上)といったように変化を付けて表現する方法で、これはマルチバイト文字 (multibyte character) と呼びます。文字列リテラルや文字リテラルはマルチバイト文字です。

このページではワイド文字は扱いません。

ある文字をどんな整数で表現するかを決めるルールを、エンコーディング形式(符号化方式) (encoding scheme) と呼びます。また、ある文字をルールにしたがって整数で表現することを、エンコード(符号化) (encode) と呼びます。マルチバイト文字のある文字が、何バイトで表現されているのかについても、エンコーディング形式から知ることができます。

用語の使い分けが混乱している分野です。ここで説明した意味でのエンコーディング形式のことを指して文字コードと呼ぶこともありますが、新C++編での「文字コード」は、文字を表現する整数のことを指すものとします。

文字リテラルや文字列リテラルのエンコーディング形式は処理系定義です1 2

【上級】文字リテラルや文字列リテラルにプリフィックスを付加することで、エンコーディング形式を強制する機能があります。

Visual Studio の場合(そして日本語版の Windows上で実行しているのなら)、文字リテラルや文字列リテラルのエンコーディング形式はデフォルトでは、CP932 という、Shift_JIS を拡張した形式になります3。使える文字の種類が増えているだけなので、区別せずに Shift_JIS(シフトJIS)と呼ぶこともあります。

【上級】Windows を除くほとんどの現代の環境では、UTF-8 というエンコーディング形式を使っているため、Windows も UTF-8 がデフォルトになるように変わる可能性があります。今のところのデフォルト設定は CP932 ですが、UTF-8 に変更することは可能になっています。Windows 10 の設定画面より、【時刻と言語】>【言語】>【管理用の言語の設定】と進み、【管理】タブの【システムロケールの変更】を選択。【ベータ: ワールドワイド言語サポートで Unicode UTF-8 を使用】にチェックを入れると、UTF-8 に変更できます。ただしあくまでベータ版扱いの機能ですし、Windows上で動作するすべてのプログラムに影響を与える設定なので、過去に作られたプログラムが誤動作する可能性もあります。

CP932 (Shift_JIS) で 1文字を表現するために必要な大きさは 1~2バイトです。Hello, に含まれる文字や ! はそれぞれ 1バイトで表現できますが、日本 の 2文字はそれぞれ 2バイト必要です。基本ソース文字セットに含まれている文字は、1バイトで表現できると考えていいです(「文字」のページを参照)。

CP932 以外のエンコーディング形式を使う処理系では必要なバイト数が異なります。たとえば UTF-8 というエンコーディング形式では、1文字は 1~4バイトです。

CP932 なら 1文字に 2バイトを割り当てられるからといって、すべての文字を表現できるわけではありません。使える文字として具体的に何があるのかを定義したものを、文字集合(文字セット) (character set) と呼びますが、CP932 には CP932 で定義された文字集合があり、ここに含まれていない文字は使えません。あいまいですが、日常でよく使われる文字に限られていると考えていいです。

std::string の要素は char型であり、char型の値が複数並んだものとして管理しています。そのため、1文字単位で管理しているというよりも、1バイト単位で管理しているという捉え方ができます。さきほどのサンプルプログラムの char c {s.at(7)};'日' を取り出しているようにみえますが、実際は '日' を構成する複数バイト(CP932 なら 2バイト)のうちの、1バイト目のところだけを取り出すという結果になります。

このような理屈なので、2バイト必要な文字でも、1バイトの char型の値を 2つ並べるというかたちでならば表現できます。次のプログラムは、'日' が 2バイトであるつもりで書かれています。

#include <iostream>
#include <string>

int main()
{
    std::string s {"Hello, 日本!"};
    std::string c {s.at(7), s.at(8)};  // 7バイト目と 8バイト目の値で初期化
    std::cout << c << "\n";
}

実行結果:

7バイト目と 8バイト目にある値をそれぞれ別個に取り出して、2文字の文字列として扱うことで、'日' を表せています。これは少しやりづらい方法ですが、もう少しまともな方法を後で取り上げます

文字列の長さとバイト数

std::string の length関数で "Hello, 日本!" の長さを調べてみます。

#include <iostream>
#include <string>

int main()
{
    std::string s {"Hello, 日本!"};
    std::cout << s.length() << "\n";
}

実行結果:

12

文字数としては 10 のはずですが、出力された結果は 12 になりました。

length関数は「見た目どおりの文字数」を返しているのではなく、「char型で何個分の文字があるか」を返しています。char型は必ず 1バイトなので、length関数で得られる値は、文字列のバイト数と一致しています。

部分文字列

どうすれば、マルチバイト文字の文字列に含まれる文字にうまくアクセスできるでしょうか。これには問題が2つあって、簡単にはいきません。

1つには、ここまで説明したとおり、1バイトではない文字があるということです。そのような文字は1つの char型の変数では絶対に表現できないため、std::string型を使って、複数バイトの文字列として取り扱う必要があります。つまり、std::string から一部分(部分文字列 (substring))を切り出すような処理が必要ということになります。前に、s.at(7)s.at(8) で取得した値を合体させて を表現するコードを挙げましたが、理屈はそれと同じです。

もう1つの問題は、その文字が文字列内のどこに(何バイト目に)あるのかが簡単に分からないということです。"Hello, 日本!" の場合、 は、その手前にある文字がすべて 1バイトで表現できることを確かめたうえで、7バイト目のところにあると分かります。 の直後にありますが、8バイト目のところにあるわけではありません。 が何バイト使っているか次第です(CP932 なら 2バイトですが、ほかのエンコーディング形式では異なるかもしれません)。つまり、マルチバイト文字においては、手前に並んでいる文字が何バイトで表現されているかをすべて調べてからでないと、位置を特定できないのです。


部分文字列を取り出すために、std::string の substr関数が使えます。次のプログラムは、"Hello, 日本!" から、"Hello" の部分を取り出しています。

#include <iostream>
#include <string>

int main()
{
    std::string s {"Hello, 日本!"};
    std::cout << s.substr(0, 5) << "\n";
}

実行結果:

Hello

substr関数には2つの符号無し整数を指定できます。1つ目は何バイト目から取り出すか、2つ目は何バイト分取り出すかです。2つ目については省略可能で、その場合は文字列の末尾まで取り出します。結果の部分文字列も std::string型です。元になった std::string の内容は変化しません。

"Hello" は文字列の先頭にある部分文字列ですから、1つ目の値は 0 で良いことがすぐに分かります。また、"Hello" を構成するどの文字も 1バイトであることが分かっているので、2つ目の値は 5 を指定すればいいこともすぐに分かります。

"日本" の部分を取り出す場合はどうでしょう。手前にある文字は "Hello, " で7バイトであることが手動の計算で分かります。"日本" という文字が何バイトで表現されているかは、使っているエンコーディング形式の仕様を調べればわかります。CP932 ならそれぞれ 2バイトなので、s.substr(7, 4) とすればいいということになります。

UTF-8 の場合は はそれぞれ 3バイトなので、s.substr(7, 6) にします。

#include <iostream>
#include <string>

int main()
{
    std::string s {"Hello, 日本!"};
    std::cout << s.substr(7, 4) << "\n";
}

実行結果:

日本

登場する文字が分かっていれば、こうしてエンコーディング形式の仕様を調べながら、手動で計算することによって対応できます。しかし、標準入力から入力される文字列のように、どんな文字が含まれてくるか事前にわからないケースもあるので、この方法がいつも使えるわけではありません。

まずは、プログラム(あるいはアプリケーションの仕様)で工夫できないか考えることが先決です。たとえば、日本語で入力されたフルネームから苗字と名前をそれぞれ切り分けたいとします。素直に実現を考えると、苗字の部分で何バイト使っているのかをなんとか計算しなければ、名前の部分がどこから始まるのかわからないということになります(苗字と名前を区切る明確なマークがないかぎりは)。しかし、苗字と名前を別々に入力してもらえば、それぞれを1つの文字列のままで取り扱えますから、プログラムは簡単になります(フルネームが欲しければ、+ などを使って文字列の連結を行えます(「要素を追加する」のページを参照))。

こういった工夫ができないのなら、各文字が使っているバイト数を地道に計算しなければなりません。使っているエンコーディング形式に応じて、各文字が使うバイト数をプログラムで計算して実装することは可能ですが、これは高度な話題になるので、ここでは割愛します。

蔵書リストプログラム

いくつか日本語の文字を使う難しさを説明しましたが、マルチバイト文字の文字列はこれまでどおり std::string型で扱えます。難しいのは、1文字が 1バイトでない可能性を考慮しなければならない場合ですから、文字列全体を一括で扱う(s1 == s2 のような比較とか)ときには、これまでどおりの考え方が通用します。また、ユーザーに操作を促すメッセージなどで日本語を使うこともできるわけですが、こちらも別に難しいことはありません(std::cout << "整数を入力してください。\n"; のように書ける)。

蔵書リストのプログラムの場合、書名と著者名のところで日本語の文字を使いますが、これまでどおり std::string型で扱うことができます。また、テキストファイルに書き出したり、読み込んだりするときにも、文字列全体を一括で取り扱うようにしていれば、これまでどおりに書けます。

それでは、蔵書リストのプログラムを完成させてみます。標準入力から本のデータを登録・削除・閲覧ができるほか、外部のテキストファイルへの書き出し・読み込みができるようにします。

#include <algorithm>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>

int main()
{
    constexpr auto booklist_path = "booklist.txt";

    struct Book {
        std::string    title;      // 書名
        std::string    author;     // 著者名
        int            price;      // 価格
        int            evaluate;   // 評価
    };

    std::vector<Book> books {};

    while (true) {
        std::cout << "----------------------------\n"
                  << "コマンドを入力してください。\n"
                  << "a: 本を登録する。\n"
                  << "d: 本を削除する。\n"
                  << "p: リストを確認する。\n"
                  << "r: ファイルから読み込む。\n"
                  << "w: ファイルに書き込む。\n"
                  << "e: 終了する。\n"
                  << "----------------------------\n";

        std::string command {};
        std::getline(std::cin, command);
        if (command == "a") {
            // 本を登録する

            Book book {};
            std::istringstream iss {};
            std::string tmp {};

            std::cout << "書名を入力してください。\n";
            std::getline(std::cin, book.title);

            std::cout << "著者名を入力してください。\n";
            std::getline(std::cin, book.author);

            std::cout << "価格を入力してください。\n";
            std::getline(std::cin, tmp);
            iss.str(tmp);
            iss >> book.price;

            std::cout << "評価を入力してください。\n";
            std::getline(std::cin, tmp);
            iss.clear();
            iss.str(tmp);
            iss >> book.evaluate;
            if (!iss) {
                std::cout << "入力内容に不備があります。\n";
            }
            else {
                books.push_back(book);
                std::cout << "登録しました。\n";
            }
        }
        else if (command == "d") {
            // 本を削除する

            std::cout << "書名を入力してください。\n";
            std::string title {};
            std::getline(std::cin, title);

            auto it = std::find_if(std::cbegin(books), std::cend(books), [title](Book e){ return e.title == title; });
            if (it == std::cend(books)) {
                std::cout << "指定された本は登録されていません。\n";
            }
            else {
                books.erase(it);
                std::cout << "削除しました。\n";
            }
        }
        else if (command == "p") {
            // リストを確認する

            for (auto& book : books) {
                std::cout << "書名: " << book.title << "\n"
                          << "著者名: " << book.author << "\n"
                          << "価格: " << book.price << "\n"
                          << "評価: " << book.evaluate << "\n"
                          << "\n";
            }
        }
        else if (command == "r") {
            // ファイルから読み込む

            std::ifstream ifs(booklist_path);
            if (!ifs) {
                std::cout << "ファイルが開けません。\n";
                continue;
            }

            // メモリ上の本のデータを消す
            books.clear();

            while (!ifs.eof()) {
                Book book {};
                std::istringstream iss {};
                std::string tmp {};

                std::getline(ifs, book.title);
                if (book.title.empty()) {
                    break;
                }

                std::getline(ifs, book.author);

                std::getline(ifs, tmp);
                iss.str(tmp);
                iss >> book.price;
                iss.clear();

                std::getline(ifs, tmp);
                iss.str(tmp);
                iss >> book.evaluate;

                books.push_back(book);
            }

            std::cout << "読み込みました。\n";
        }
        else if (command == "w") {
            // ファイルに書き込む

            std::ofstream ofs(booklist_path);
            if (!ofs) {
                std::cout << "ファイルが開けません。\n";
                continue;
            }

            for (auto& book : books) {
                ofs << book.title << "\n"
                    << book.author << "\n"
                    << book.price << "\n"
                    << book.evaluate << "\n";
            }

            ofs.close();
            if (!ofs) {
                std::cout << "書き込みに失敗しました。\n";
                continue;
            }

            std::cout << "書き込みました。\n";
        }
        else if (command == "e") {
            // 終了する

            std::cout << "終了します。\n";
            return 0;
        }
        else {
            // 無効なコマンド
            continue;
        }
    }
}

実行すると、行いたい操作に応じたコマンドの入力を求められます。初期状態では本のデータは空になっており、aコマンドで1冊分ずつ入力するか、rコマンドでファイルから保存済みのデータを読み込みます。ファイルは booklist.txt という名称で固定されており、ほかのファイルを使うことはできません。

dコマンドは、書名を使って本を探して、該当するものを削除します。同名の本が複数あったら、まとめて削除されてしまいます。

wコマンドは、現在の本のデータをファイルへ書き出して保存します。

eコマンドで、プログラムが終了します。ファイルへの保存は行われません。

ファイルには、書名・著者名・価格・評価の4項目をそれぞれ1行ずつ分離して書き込むようになっています。こうしておけば、日本語の文字が混ざっていてもプログラムが難しくならず、これまでどおり、std::getline関数を使った方法で読み取れます。

これは、このページの主題である日本語の文字の扱いを楽にするための工夫です。より本格的なアプリケーションでは、ファイルの読み書きの効率や、ファイルサイズを小さく抑えること、不正なデータに気付きやすいつくりなど、ほかのさまざまな観点を考える必要があるかもしれません。

本のデータがメモリ上にあるのか、ファイルにあるのかを意識するようにしてください。std::vector の変数 books に格納されているデータは、メモリ上にある本のデータです。wコマンドでファイルへ書き出すまでは、データはメモリ上にしかありません。メモリ上にあるデータは、プログラムが終了すると失われてしまいますから、wコマンドを実行せずにプログラムを終了させた場合は、入力していた本のデータが消えてしまいます。


ソースファイルの規模はこれまでで最大で、読み解くのが大変かもしれませんが、if文のところが、コマンドごとに分離されたコードになっているので、1コマンドずつ読んでいくと理解できるでしょう。やっていることはこれまでのページで取り上げてきたことばかりです。

これで最低限の蔵書リストのプログラムは完成しましたが、色々と不満はあるでしょう。いくつかの改造を、このページの練習問題で行うようになっているので、取り組んでみてください。この先のページでも、いくらかの改造を試みます。

まとめ


新C++編の【本編】の各ページには、末尾に練習問題があります。ページ内で学んだ知識を確認する簡単な問題から、これまでに学んだ知識を組み合わせなければならない問題、あるいは更なる自力での調査や模索が必要になるような高難易度な問題をいくつか掲載しています。


参考リンク


練習問題

問題の難易度について。

★は、すべての方が取り組める入門レベルの問題です。
★★は、自力でプログラミングができるようなるために、入門者の方であっても取り組んでほしい問題です。
★★★は、本格的にプログラマーを目指す人のための問題です。

問題1 (基本★)

蔵書リストのプログラムに、メモリ上にあるすべての本のデータを削除する cコマンドを追加してください。

解答・解説

問題2 (基本★★)

蔵書リストのプログラムで、価格として負数や整数でない値が入力されたときに、不適切な入力であることを伝えて入力しなおさせるようにしてください。

解答・解説

問題3 (応用★★)

蔵書リストのプログラムで、ファイルへの保存を行わずに eコマンドを入力したとき、保存せずに終了していいか確認するようにしてください。ユーザーが終了しない意思を示した場合は、コマンド入力に戻すようにしてください。

解答・解説

問題4 (応用★★)

蔵書リストのプログラムに検索機能を追加してください。

解答・解説


解答・解説ページの先頭



更新履歴




はてなブックマーク に保存 Pocket に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る