LoginSignup
7
1

More than 3 years have passed since last update.

赤鼻のトナカイで多いのは?赤鼻?トナカイ?サンタさん?

Last updated at Posted at 2019-12-21

こちらはC++ Advent Calendar 2019の22日目の記事です。 次は@Catminusminusさんの「コンパイル時ニューラルネットワークライブラリを書いた」です。

クリスマスなのでルドルフくんの出番です

せっかくだからルドルフくんと遊びましょう。
文字列操作の練習です。
初級者向け(入門書終了程度の方)を想定しています。

でも、良い子の皆さんはc++でこんな事しようなんて考えちゃダメですよ。(※※※※)

準備

サンプルデータ
どこかから「赤鼻のトナカイ」の歌詞を持ってきて、テキストファイルrudolph.txtを作っておきましょう。
説明の都合上、英文、ASCIIまたはutf8(bom無し)にします。
イントロやコーラス部分の歌詞の有無や表記揺らぎでいくつかバリエーションがあります。どれでも好きなものを選んでください。
私は以下のページから歌詞をお借りしました。
Red-nosed Reindeer(歌詞)

ソースコード
文字列オブジェクトのリテラルを使うので、using namespace std::string_literals;を宣言しておいて下さい。あとヘッダファイルは必要に応じてインクルードしときます。

ファイルを読み込んで単語出現数を数えてみる

仕様1

  • 英文文字列をテキストファイルから読み込み、単語単位で、出現数を数える
  • 単語区切りは空白文字(空白、タブ、改行等)とする

コード1

#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <numeric>
#include <regex>
#include <sstream>
#include <string>
#include <vector>

#include <cstdlib>


using namespace std::string_literals;

int main(){
    const auto readedText = [](const std::filesystem::path &fn){    //①
        using isbi = std::istreambuf_iterator<char>;
        auto ifs = std::ifstream{fn};
        return std::string(isbi{ifs},isbi{});
    }("rudolph.txt"s);

    const auto wordsSplited = [](const std::string &str){    //②
        using isi = std::istream_iterator<std::string>;
        auto is = std::istringstream{str};
        return std::vector<std::string>(isi{is},isi{});
    }(readedText);

    const auto wordsCounts = [](const auto &words){    //③
        auto tmp = std::map<std::string, int>{};
        tmp = std::accumulate(std::cbegin(words), std::cend(words), tmp, [](auto &tmp, const auto &word){
            tmp[word] += 1;
            return std::move(tmp);
        });
        return tmp;
    }(wordsSplited);

ファイルを文字列として一気に読み込みます。(①)
単語単位に分割してvectorに入れておきます(②)
それを単語単位でmapに追記していきます。(③)
はい!出来上がり。

①②③はまとめて書くこともできますが、今の段階ではこれで置いておきます。人間が頭の中で考えるレベルの粒度にしておきましょう。その方があとから変更をかけるのが楽です。(※)
え?スピード?メモリー?そんなのは問題になってから、プロファイル取ってみて考えましょう。

確認1

確認のためプリントしてみます。

    for(const auto [word, count] : wordsCounts){
        std::cout << word << ": "s << count << std::endl;
    }
'Rudolph: 1
〜〜〜
Rudolph: 2
Rudolph,: 1
〜〜〜

入力テキストファイルと出力を比べてみます。
う〜ん、思っていたのとちょっと違う。
単語の頭とお尻についた記号類が邪魔して別の単語としてカウントされています。行頭の大文字も別カウントになってますね。
これは同じ単語としてカウントしたいよね。
それから、私が使った歌詞テキストが表記揺らぎで赤鼻”Red-Nosed”がハイフン区切りと空白区切りが混在してます。これはどうしましょう?
クリスマスなのでルドルフ君に敬意を表して”Red-Nosed”で1単語として数えることにしましょう。

表記揺らぎに対応する

仕様2(追加仕様)

  • 単語前後の記号類は無いものとして扱う(削除する)
  • 大文字小文字は無視して同じ単語としてカウントする(全て小文字に変換する)
  • “Red Nosed”は特別に1つの単語としてカウントする(空白区切りの場合はハイフン区切りに変換する)

コード2

namespace {
    const auto patDungledSymbol = std::regex{R"((^[\s\W]+)|([\s\W]+$))"s};    //④

    auto trimSymboles(const std::string &str){    //⑤
        return std::regex_replace(str, patDungledSymbol, ""s);
    };

    auto toLowerCase(const std::string &src, const std::locale &loc=std::locale::classic()){    //⑥
        auto dest = std::string{};
        dest.reserve(src.length());
        std::transform(std::cbegin(src), std::cend(src), std::back_inserter(dest), [&loc](const auto &c){
            return std::tolower(c, loc);
        });
        return dest;
    };

    const auto patRedNosed = std::regex{R"(red[-\s]+nosed)"s, std::regex::icase};    //⑦

    auto specializeRedNosed(const std::string &str){    //⑧
        return std::regex_replace(str, patRedNosed, "red-nosed"s);
    };

}

今回関数として書いているので無名名前空間の中に入れています。main()の前に追記して下さい。

単語の前後にある記号類を除去しましょう。今回は正規表現を使います。
正規表現パターンを定数として定義しておきます。(④)
なんだか怪しい呪文みたいですね(※※)。正規表現は「書くに優しく、読むに難い」ことで有名です。私はパターンに分かりやすい名前を付けて定数化することを習慣にしています。こうしておかないとあとから読み直す時に自分でも何をやりたかったのか思い出せないのです。
さくっと説明すると、このパターンは行頭と行末の空白文字、記号類にマッチします。これを空文字列で置き換えれば良さそうですね。
そこでregex_replaceの出番です。使い勝手を考えて関数にしておきましょう。(⑤)
文字列処理は小さな単位で名前をつけて関数にしておくと便利ですよ。
これをどこで使いましょう?
単語単位になっていることを前提で書いたので、③の中がいいでしょう。

    const auto wordsCounts = [](const auto &words){    //③'
        auto tmp = std::map<std::string, int>{};
        tmp = std::accumulate(std::cbegin(words), std::cend(words), tmp, [](auto &tmp, const auto &word){
            tmp[trimSymboles(word)] += 1;
            return std::move(tmp);
        });
        return tmp;
    }(wordsSplited);

プリントしてみましょう。しめしめ、思惑通り単語前後の記号類が無くなってますね。

次は大文字小文字を統一するために全て小文字に変換しましょう。今回はstd::tolower()を使います(c言語のtolower()ではないですよ)。これも独立した関数にしておきましょう。(⑥)
ここでlocaleなんてものが出てきますね。これはもうおまじないだと思ってそのまま書いて下さい。(※※※)
使いどころは、、、、と、やはり③の中ですね。

    const auto wordsCounts = [](const auto &words){    //③''
        auto tmp = std::map<std::string, int>{};
        tmp = std::accumulate(std::cbegin(words), std::cend(words), tmp, [](auto &tmp, const auto &word){
            tmp[toLowerCase(trimSymboles(word))] += 1;
            return std::move(tmp);
        });
        return tmp;
    }(wordsSplited);

プリントしてうまく動作しているか確認してみてください。

次はクリスマス特別仕様!「赤鼻」をひとつの単語として数えます。もう分かりますよね?正規表現を使います。
表記揺らぎに考慮して、大文字小文字はの区別無し、”red”と”nosed”の間に空白文字またはハイフンがある場合にマッチするようにします。(⑦)
マッチした部分全体を”red-nosed”で置換します。(⑧)
さて、適用場所ですがどこがいいと思いますか?複数の単語にまたがる処理ですので単語に分割された後では意味がないですね。①と②の間が良さそうです。(⑩)

    const auto readedText = [](std::filesystem::path fn){    //①
     〜〜〜〜〜
    }("rudolph.txt"s);

    const auto rednosedText = specializeRedNosed(readedText);    //⑩

    const auto wordsSplited = [](std::string str){    //②'
     〜〜〜〜〜
    }(rednosedText);

忘れずに後段で使用する変数名も変更しておきましょう。 (②')

これで完成!

でも、なんか見にくいですよね?そうか!出現数順になってないからだ。
せっかくのアドベントカレンダーですから、カウントダウン、つまり降順にソートしてみましょう。

カウントダウン表示にする

仕様3

  • 単語の出現数で降順にソートする

コード3

    const auto wordsCountDown = [](auto wordsCounts){    //⑨
        auto tmp = std::vector<std::pair<std::string, int>>{};
        tmp.reserve(wordsCounts.size());
        std::transform(std::cbegin(wordsCounts), std::cend(wordsCounts), std::back_inserter(tmp), [](const auto &el){
            return el;
        });
        std::sort(std::begin(tmp), std::end(tmp), [](auto a, auto b){
            return a.second > b.second;
        });
        return tmp;
    }(wordsCounts);

    for(const auto [word, count] : wordsCountDown){
        std::cout << word << ": "s << count << std::endl;
    }

    return EXIT_SUCCESS;
}

今回やることは簡単です。vectorにコピーして、sortするだけです。(⑨)
え?なんでわざわざvectorにコピーするのかって?実はmapは内部では強制的にキー値でソートされてしまいます。ですので今回の場合出現数でソート出来ないのです。

私の使用した歌詞では、多い順に

reindeer: 5
the: 4
rudolph: 4
you: 3
him: 3
they: 2
then: 2
with: 2
say: 2
to: 2
all: 2
red-nosed: 2
nose: 2
〜〜〜

でした。皆さんのところはどうでした?

今度こそこれでおしまい。お疲れ様。
最後に完成版のコード全体を書いておきます。

#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <map>
#include <numeric>
#include <regex>
#include <sstream>
#include <string>
#include <vector>

#include <cstdlib>


using namespace std::string_literals;

namespace {
    const auto patDungledSymbol = std::regex{R"((^[\s\W]+)|([\s\W]+$))"s};    //④

    auto trimSymboles(const std::string &str){    //⑤
        return std::regex_replace(str, patDungledSymbol, ""s);
    };

    auto toLowerCase(const std::string &src, const std::locale &loc=std::locale::classic()){    //⑥
        auto dest = std::string{};
        dest.reserve(src.length());
        std::transform(std::cbegin(src), std::cend(src), std::back_inserter(dest), [&loc](const auto &c){
            return std::tolower(c, loc);
        });
        return dest;
    };

    const auto patRedNosed = std::regex{R"(red[-\s]+nosed)"s, std::regex::icase};    //⑦

    auto specializeRedNosed(const std::string &str){    //⑧
        return std::regex_replace(str, patRedNosed, "red-nosed"s);
    };

}


int main(){
    const auto readedText = [](const std::filesystem::path &fn){    //①
        using isbi = std::istreambuf_iterator<char>;
        auto ifs = std::ifstream{fn};
        return std::string(isbi{ifs},isbi{});
    }("rudolph.txt"s);

    const auto rednosedText = specializeRedNosed(readedText);    //⑩

    const auto wordsSplited = [](const std::string &str){    //②'
        using isi = std::istream_iterator<std::string>;
        auto is = std::istringstream{str};
        return std::vector<std::string>(isi{is},isi{});
    }(rednosedText);

    const auto wordsCounts = [](const auto &words){    //③''
        auto tmp = std::map<std::string, int>{};
        tmp = std::accumulate(std::cbegin(words), std::cend(words), tmp, [](auto &tmp, const auto &word){
            tmp[toLowerCase(trimSymboles(word))] += 1;
            return std::move(tmp);
        });
        return tmp;
    }(wordsSplited);

    const auto wordsCountDown = [](const auto &wordsCounts){    //⑨
        auto tmp = std::vector<std::pair<std::string, int>>{};
        tmp.reserve(wordsCounts.size());
        std::transform(std::cbegin(wordsCounts), std::cend(wordsCounts), std::back_inserter(tmp), [](const auto &el){
            return el;
        });
        std::sort(std::begin(tmp), std::end(tmp), [](const auto &a, const auto &b){
            return a.second > b.second;
        });
        return tmp;
    }(wordsCounts);

    for(const auto [word, count] : wordsCountDown){
        std::cout << word << ": "s << count << std::endl;
    }

    return EXIT_SUCCESS;
}

あとがき

なんか、c++の文字列処理が苦手な人が多い気がして書いてみました(※※※※)。最初チュートリアル的なもので書き始めたのですが、長くてつまらないものになりそうだったので、クリスマスネタに走ってみました。 

気をつけたポイントは、

  • 簡単な処理でも仕様を書いてみる
  • 仕様に書いた日本語の粒度を意識しながらコードを書く。 粒度が細かくなりすぎないように、また処理間で依存が少なくなるように注意
  • 出来るだけデータの塊に対して処理を行う
  • 文字列の場合、正規表現を使うことを考えよう
  • if文、for分は無い方が読みやすい
  • ioとロジックは分離する、特に入力ループ中に処理を書かないようにする
  • autoとかジェネリックとかユニバーサル参照とか考えすぎない。楽できる(記述が簡単になる)ところだけ使います。ライブラリエンジニア向けではなく、初心者向けなので、このコードが動けばそれでOK

補足

このコード、ifやswitchなどの条件分岐、ループらしいループがありません。こういう書き方を意識しておくと、上から下へ素直に読み下せるので、後がとても楽になります。
transeform()、accumurate()、イテレータによるコンテナ初期化は事実上のループですが、名前付きループとも呼ばれ意図を明確に出来ます。

ioとロジックの分離は常に気にしています。入力ループの中に処理を書いてしまいがちですが後で変更が難しくなってしまいます。①で使ったファイルを一度に読み込む方法は、巨大ファイルやパイプライン的入力に対応できませんが、io分離が簡単なのでよく使っています。

ラムダ式を多用してますがこれは私の趣味です。次の効果を期待してよく使います。いや、単に変数名考えるのが面倒くさいだけかも。

  • 一時変数や資源(ファイル等)の使用を局所化できる
  • constなコンテナを作成できる
  • 意味のある塊でまとめる。必要なら名前を付けることができる
  • 外出ししたくなった時すぐに外だしできる

※) ①②③をまとめて書くと例えば次のように書けます。

    // ①+②+③
    using isit = std::istream_iterator<std::string>;
    auto is = std::ifstream{"rudolph.txt"};
    const auto wordsCounts = std::accumurate(isit{is}, isit{}, std::map<std::string, int>{}, [](auto tmp, auto word){
            tmp[word] += 1;
        return std::move(tmp);
    });

意外とコンパクトでしょ? 
でもこれに⑧を適用しようとすると途端に困ってしまいます。
あと、デバッグの時も見たいところが確認できなくて途方にくれたり。

※※) 正規表現はc++言語とは独立したひとつの言語体系です。文字列処理に特化しており便利かつ強力です。是非勉強して使いこなして下さい。c++以外でもいろいろなところで使えますよ。
いくつか種類(流派)があるのですが、c++のデフォルトではEcmaScript(JavaScript)の正規表現です。

※※※) ロケール。現時点で追求することはお勧めしません。この設定は「ASCII文字として解釈し、言語個別の処理・変換は行わない」といった意味になります。
本来であれば日本語など非英語文字列処理の中核を担うライブラリです。んが、日本人の期待する動作をしません。今回例題を英文処理に限定したのは8割がたこいつの出来が悪いせいです。
普通の人はc++の国際化がもう少し進むのを待ちましょう。
どうしても今日本語処理が必要な方はまずICUライブラリを調べてみて下さい。

※※※※) この記事を書くにあたって昔のメモを読み返してみるとあちこちに書いてありました。「c++は生産性が低い。どうしようもない場合を除いて使うべきでない。」と。過去の自分からの貴重なアドバイスです。何年も前からわかっていることなのに、どうして私はいまこの記事を書いているんでしょうね?
今回の処理はunixコマンドラインでならワンライナー(1行コマンド)で書けちゃう内容です。今回のc++コードは83行.....
インクルード文書いてる間に終わっちゃいますね。

参考


Special task force for making string samples,
Secret Society for Propagation of Bright-Side of c++ (PBSC++)

  • PBSC++ conceals members to protect them from Dark-Side forces. Any one can use the name of PBSC++ in spite of fake or real.
  • The Mission
    • Making and spreading c++ samples and tutorials for beginners.
    • Samples and tutorials shall be simple, safe, easy to understanding for beginners. We hope they will be “Defense Against Dark-Side Arts“.
    • Beginners shall be rescued from stacking on C-like-code, and shall be protected from hyper difficulty of too advanced c++ code.

“May the Bright-Side be with you!“

7
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1