2017年3月16日木曜日

[Java8] Streamとラムダ式を使ってダミーテキストを生成する


猫と一緒にガジェットライフ♪ムチャです。

転職の面接の時にとあるプログラミングの問題が出されたのですが、それを解くのにまずダミーのテキストが必要で、そういうサービスは探せばいくらでもあるのですが、せっかくなのでJava8のStreamを使って生成する方法を考えてみました。
多分役に立たないと思いますが、Streamを使った処理など参考になればと思います。


作るもの

  • 英数字+区切り文字によるランダムなテキストを生成する
  • 実際にある単語ではなくてよい
  • 区切り文字も複数指定可能でランダムに選択する
  • Streamを使ってエレガントに
英単語や文法などは無視です。ランダムな文字列を区切り文字で連結します。
きちんとした文章になっていないといけない場合はそういうサービスがいくつかあるのでそちらを利用しましょう。


1.使用する文字を列挙する

単純に考えれば、文字を列挙してリストにした後、乱数でインデックスを取れば作れるでしょう。
まずは使用する文字のリストを作ります。

Streamを使わない場合

使う文字を手打ちすればこんな感じでもできます。
//英数字のリストを作る
String alphabet = "abcdefghitjklmnopqrstuvwxyz";
String alnum = "0123456789" + alphabet + alphabet.toUpperCase();
//splitを空文字で呼ぶと1文字ずつ分割される
List<String> chars = Arrays.asList(alnum.split(""));
//→[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e, f, g, h, t, j, k,....

Streamで作る場合

でもそれでは芸がないので、Streamを使って作ってみます。
List<String> chars = IntStream.rangeClosed(0x30, 0x7a)
  .filter(Character::isLetterOrDigit)
  .mapToObj(i -> String.valueOf(Character.toChars(i)))
  .collect(Collectors.toList());

IntStream#rangeClosedは開始値から終了値まで1ずつ増えるストリームを生成します。ただのrangeメソッドもあり、こちらは終了値を含みません。
範囲はUnicodeのコードポイントで0x30('0')から0x7a('z')を指定してます。

filterではCharacter#isLetterOrDigitで英数字だけ残しています。IntStreamのfilterなので、渡すのはIntPredicate(引数intで戻りはboolean)となります。isLetterOrDigitは条件に合っているので、メソッド参照で簡潔に書いています。

mapToObjではintからStringに変換しています。メソッド参照でString::valueOfと書いてしまいたいところですが、普通にString#valueOf(int)を使うと数値→文字変換になってしまいます。(0x41は"65"となってしまう。欲しいのは"A"。)

UnicodeコードポイントからStringを一発で得るのがなさそうなので、Character#toCharsでコードポイントからchar[]を得て、String#valueOfで文字にしています。

(キャストしてString.valueOf((char)i)でも行けますが、UnicodeコードポイントからJavaで使うUTF-16へ無理矢理置き換えてしまうので、intの範囲がサロゲートペアになるとうまく置き換えられないと思います。今回は英数字とひらがなカタカナだけ考えるので問題ありませんが、上記コードの手順の方が正しいです。)

最後にcollect(Collectors.toList())Listに変換しています。
これで1文字ずつ英数字が格納されたリストが得られました。


2.乱数のストリームを使ってランダムな文字列(英数字)を作る

乱数を使って1.で作ったリストから文字を取得して文字列を作ります。
String s = new Random().ints(100, 0, chars.size())
    .mapToObj(chars::get)
    .collect(Collectors.joining());
    System.out.println(s);

java.util.Randomクラスにも直接ストリームを生成するメソッドが用意されました。
Random#intsIntStreamRandom#doublesDoubleStreamRandom#longsLongStreamを生成します。
各メソッドは引数によって4つあり、引数無しは無限ストリームを生成します。
limitなどの短絡操作を前提とする)
他は生成する個数や乱数の範囲を指定できます。

上の例では100個の0からchars.size()未満の乱数ストリームを生成します。上端は含まないので注意が必要です。

得られた乱数を使って、IntStream#mapToObjで文字へ変換します。乱数の範囲はシャアcharsのインデックスになっているので、getで文字を取り出します。これはメソッド参照で簡潔に書いていますが、以下と同じです。
.mapToObj(i -> chars.get(i))

最後にcollectで全ての文字を結合します。Collectors#joiningを使うと要素を全て結合して1つの文字列にしてくれます。便利ですね。
引数に区切り文字を渡すと、要素ごとに区切り文字を付けて結合してくれます。

出力結果
lFz24fVDTVXQVTF9XTMHCtaawNcjBZtHFP7jxmQmYV4InGoCiv3aok1ivDysJMlikgBBT2TwjnE4Sei31dtcn5DKwnOtWjxGFXHT

3.ラムダ式で条件判定を作ってひらがなやカタカナも入れてみる

上の例では英数字だけなので、ひらがなカタカナも入れてみましょう。
しかし、単純にrangeを広げるだけだとえらいことになります。
制御コード出しちゃってるのか、自分の環境ではeclipseが固まりました(;´∀`)

なので出しても問題無い文字だけ抽出します。
しかしCharacterクラスにはそこまで細かい判定メソッドがありません。
仕方がないので、地道に範囲指定でやることにします。

例えばasciiの記号はコードポイントで0x20~0x2fなので、素直に書くとfilterに渡すラムダ式は以下のようになります。
.filter(i -> 0x20 <= i && i <= 0x2f;)

標準の関数型インタフェースjava.util.functionでbooleanを返すのは引数1つのPredicateか、2つのBiPredicateしかありません。というかそもそも引数3つ以上インタフェースは用意されていないんですね。

ここで1つ疑問が。接頭語は引数1つはUnary~、2つはBinary~となっていて、じゃあ3つは何ていうんだろうと思ったら、Ternary~だそうです。
で、この手の引数の個数を表す言葉は「アリティ(arity)」というそうです。


それはいいとして・・・
じゃあせっかくなので引数を3つ取る関数型インタフェースを定義しましょう。
@FunctionalInterface
    public static interface IntTernaryPredicate{
        boolean is(int a, int b, int c);
    }

で、実体は以下のようにします。
IntTernaryPredicate withinRange = (val, l, u) -> {
  return l <= val && val <= u;
};

これで引数3つ取るラムダ式が書けます。
と言ってもStream#filterに渡せるのはPredicateなので直接渡せないのですが・・・。
なので以下のようにそれぞれの文字種毎にIntPredicateを定義してみます。
//0x20はスペース、21~2Fは記号
IntPredicate symbol = i -> withinRange.is(i, 0x20, 0x2f);
//alphabetと数字 0x5cはバックスラッシュなので除く
IntPredicate alnum = i -> withinRange.is(i, 0x30, 0x7e)  && i != 0x5c;
//ひらがな
IntPredicate hiragana = i -> withinRange.is(i, 0x3041, 0x3094);
//カタカナ
IntPredicate katakana = i -> withinRange.is(i, 0x30a1, 0x30f4);
//半角カナ
IntPredicate hkana = i -> withinRange.is(i, 0xff66, 0xff9f);

標準で用意されている関数型インタフェース(java.util.function)には、処理を組み合わせることができるdefaultメソッドがいくつか定義されています。
Predicateには条件を組み合わせるandorなどがあります。
これを使うと、Predicateを組み合わせることができます。
IntPredicate acceptLetter = alnum.or(hiragana).or(katakana).or(hkana);

List<String> chars = IntStream.rangeClosed(0x1, 0xff9f)
            .filter(acceptLetter)
            .mapToObj(i -> String.valueOf(Character.toChars(i)))
            .collect(Collectors.toList());

これでひらがなカタカナも含めた文字リストができました。


4.区切り文字をランダムに選択する

区切り文字もスペースだけでなく複数の文字からランダムに選択できるようにしてみます。
イメージとしてはgetするたびに区切り文字が出てくるようにするので、関数型インタフェースではSupplierになります。
List<String> delimiters = Arrays.asList(" ");
Random r = new Random();
Supplier<String> delm = () -> delimiters.get(
  r.nextInt(delimiters.size()));

Random#nextIntの引数を取る方は0から引数未満の乱数を返します。そのままリストのインデックスにできます。

5.指定した文字数の範囲で単語にする

区切り文字を挿入するにはランダムな文字列を適当に分割しないといけません。
ここでもRandom#intsメソッドを使います。引数に単語数、文字数下限、文字数上限を与えれば、文字数の乱数が得られます。

あとはその文字数を元に文字列を作って、最後に区切り文字を含めて1つの文字列に合成すればいいのですが・・・。
せっかくだから並列ストリームを使いましょう。
APIドキュメントを見るとRandomクラスはスレッドセーフですが、複数スレッドで使用するとパフォーマンスが落ちる可能性があるので、java.util.concurrent.ThreadLocalRandomクラスの使用を検討してくださいとあります。
ThreadLocalRandomはJava7で追加されたようです。知らなかった・・・。

ThreadLocalRandomはnewで生成するのではなく、currentメソッドを呼んでインスタンスを取得します。それ以外はRandomと同じでストリームも得られます。

単語を生成するのはこんな感じになりました。
//引数の文字数でランダムな文字列を生成するラムダ式
IntFunction<String> getWord = i ->
    //リストのインデックスの範囲で乱数生成
    ThreadLocalRandom.current().ints(0, chars.size())
        //渡された長さまで
        .limit(i)
        //インデックスから文字を取り出す
        .mapToObj(chars::get)
        //1つの文字列へ結合
        .collect(Collectors.joining());

intsメソッドは2.で引数3つのものを使いましたが、今度は2つです。こちらは乱数の取る範囲を決めるだけで、何もしないと永遠と乱数が出てきます。(すぐforEachでコンソールに出力すると分かります。)
そのため、limitを呼んで個数、ここではラムダ式の引数で受け取る文字数で制限します。


6.完成

これらを組み合わせて、ランダムな文字で作られた単語と区切り文字を1つの文字列にします。
String result = ThreadLocalRandom.current().ints(words, lower, upper)
    .parallel()
    .mapToObj(getWord)
    .collect(StringBuilder::new,
            (sb, str) -> sb.append(delm.get()).append(str),
            StringBuilder::append)
    .substring(1);

wordsに単語数、lowerに文字数下限、upperに文字数上限を渡します。
mapToObj内では、5.で作ったラムダ式を渡します。

collectでは、これまでのようにCollectors#joiningを使うと1つの区切り文字しか指定できないため、3つ引数を取る方を使います。
1つ目のsupplierにはStringBuilderのコンストラクタをメソッド参照で渡します。

2つ目のaccumulatorは生成したコンテナ(StringBuilder)と次の要素が渡されるので、区切り文字と合わせて追加します。4.で作ったSupplierをgetするたびにランダムで区切り文字が出てきます。

3つ目のcombinerにはStringBuilder::appendを指定します。ここには並列ストリームを使用した際にコンテナ同士を結合する手段を指定しますが、StringBuilder同士は他の型と同じようにappendで追加できるのでこれでOKです。

collectの戻りの型はStringBuilderになりますが、常に区切り文字を前に追加しているため、文字列の一番先頭にも区切り文字が来てしまいます。(Collectors#joiningではそんなことはありません。)
そのためsubstringで1文字目以降をStringに変換して返しています。

※昔同じような状況でdelateCharAtを使っていたのですが、内部を見るとバッファをコピーし直しており、さらにStringにするにはその後toStringを呼ぶ必要があってここでもまた新しくオブジェクトが作られるので無駄だと分かりました(;´∀`)
substringならそのままStringが得られるし、単に位置を指定したStringのコンストラクタが呼ばれるだけなので無駄がありません。

parallelを呼んでおくことで、並列に処理されるのでコア数が多ければその分早く終わります。といっても、単語数を相当多くしないと一瞬で終わるのであまり意味は無いです(;´∀`)

mainメソッドを含むコード全体は以下になります。



おしまいのひとこと

長々と書いた割にはあまり中身がなくてすみません(;´∀`)

ストリームAPIは覚えると楽しいので、Javaおじさん(お姉さん)達はぜひ使ってみて下さい。

それではみなさまよきガジェットライフを(´∀`)ノ


▼こちらの記事もどうぞ

▼ブログを気に入っていただけたらRSS登録をお願いします!
▼ブログランキング参加中!応援よろしくお願いします。

スポンサーリンク