猫と一緒にガジェットライフ♪ムチャです。
転職の面接の時にとあるプログラミングの問題が出されたのですが、それを解くのにまずダミーのテキストが必要で、そういうサービスは探せばいくらでもあるのですが、せっかくなので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#ints
はIntStream
、Random#doubles
はDoubleStream
、Random#longs
はLongStream
を生成します。各メソッドは引数によって4つあり、引数無しは無限ストリームを生成します。
(
limit
などの短絡操作を前提とする)他は生成する個数や乱数の範囲を指定できます。
上の例では100個の0から
chars.size()
未満の乱数ストリームを生成します。上端は含まないので注意が必要です。得られた乱数を使って、
IntStream#mapToObj
で文字へ変換します。乱数の範囲は.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
には条件を組み合わせるand
やor
などがあります。これを使うと、
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おじさん(お姉さん)達はぜひ使ってみて下さい。
それではみなさまよきガジェットライフを(´∀`)ノ