LoginSignup
154
148

More than 5 years have passed since last update.

Java 8 Stream API にテキストを流してみる(生成編)

Last updated at Posted at 2015-06-01

Java 8 が出てからだいぶたつわけだが

その目玉機能の一つだった Stream API は今、開発現場でどれだけ使われているのだろうか。自分も出始めたころにちょっと触って理解しきれないまま敬遠してきた一人だが、最近また使う機会があったので情報など漁っている。

しかしまだ入門的な紹介かディープな検証ばかりで、どうもピンと来ない。もし Stream が便利で広く開発現場で使われているなら、そろそろもう少し実践的な情報が上がってきていいと思うのだが。

現状、Stream をちゃんとは理解しようとするなら、やはり自分の手で具体的なコードを書いてみるしかないようだ。

とりあえず、ありがちなテキスト処理をネタにロジックを Stream API で書いてみようと思う。よくある要件の実装を通じて、Stream 流の作法とその優位性、またその限界などを浮き彫りにしていきたい。

調べながらなので、間違いや慣習にそぐわないところがあったら指摘して欲しい。

[2016/05/28] 文章とコードを見直し、間違いや分かりにくい箇所を修正した。また、サンプルコードもいくつか追加した。

1 テキスト Stream の生成

Stream を生成するメソッドはあちこちに仕込まれていてわかりにくい。API ドキュメントを漁ってテキスト処理に使えそうなものを拾えば、だいたい以下のようなところか。

  • java.util.stream.Stream#of(T...) : Stream<T>
  • java.util.stream.Stream.Builder#build() : Stream<T>
  • java.util.Collection#stream() : Stream<T>
  • java.util.Arrays#stream(T[]) : Stream<T>
  • java.io.BufferedReader#lines() : Stream<String>
  • java.nio.Files#lines(Path) : Stream<String>
  • java.nio.Files#lines(Path, Charset) : Stream<String>
  • java.util.regex.Pattern#splitAsStream(CharSequence) : Stream<String>
  • java.lang.CharSequence#chars() : IntStream
  • java.lang.CharSequence#charPoints() : IntStream

Stream とテキスト処理は相性が良さそうなものだが、従来のテキスト関連 API には今のところ反映されてない。 Scanner/StringTokenizer をはじめ java.text.*java.nio.*javax.swing.text.*、XML関連等を見ても特筆すべき Stream のサポートはないようだ。

テキスト系 API 以外にも Stream を生成するクラスはある。たとえばjava.util.Random には、乱数数列を無限 Stream として生成する各種メソッドが追加されている。Random#ints()などはランダム文字列の生成に使えるかもしれない。

1.1 Stream#of()

まずは本家 Stream クラス。
Stream#of() メソッドは可変長引数に渡されたデータ列を要素とする Stream を生成する。

List.1-1_Stream.of()の基本

    // 可変長引数
    Stream<String> stream = Stream.of("She", "He", "Me", "We", "We");
    stream.forEach(s -> {
        System.out.println(s + "go!");
    });

    // 配列
    String[] array = {"おそ", "カラ", "チョロ", "一", "十四", "トド"};
    Stream<String> stream = Stream.of(array);
    stream.forEach(s -> {
        System.out.println(s + "松");
    });

要素の型は、引数に渡したオブジェクトの型から判断される。

of() の引数は可変長任意の個数の要素を列挙できるが、実際のプログラムでは String 配列で渡すことのほうが多いだろう。

List.1-2_ファイル名のフルパス
// コマンド引数に渡されたファイル名をフルパスにして表示する。
public class FullPath {
    public static void main(String[] args) {
        // コマンド引数の String 配列をそのままof()に渡して Stream<String> にする
        Stream.of(args)
                .forEach(arg -> {
                    System.out.println(Paths.get(arg).toAbsolutePath().normalize());
                });
    }
}
List.1-3_スネークケースをキャメルケースに変換する
    String snake = "looks_like_a_snaked";
    String camel = Stream.of(snake.split("_+")) // String.split() は String[] を返す
            .map(s -> String.format("%S%s", s.split("",2)))
            .collect(Collectors.joining());
    // LooksLikeASnaked

Stream クラスにはof()のほかにも様々な Stream 生成メソッドが提供されてはいるが、テキスト処理の文脈ではあまり使い道がなさそうだ。

  • concat(Stream<String>, Stream<String>) : Stream<String>
  • empty() : Stream<String>
  • generate(Supplier<String>) : Stream<String>
  • iterate(String, UnaryOperator<String>) : Stream<String>
  • builder() : Stream.Builder<String>

concat() メソッドは2本の Stream を連結して1本にする。もし引数が2つだけでなく可変だったら、色々使い道があったのに、と思う。(zip()も欲しい)

List.1-4_2つのリストをマージする
    List<String> marged = Stream.concat(list1.stream(), list2.stream())
            .distinct()
            .sorted()
            .collect(Collectors.toList());

empty() は空(要素0)の Stream を返すが、何に使うのか。

generate()iterate()builder()は、データ列からではなくロジックで Stream を生成する。generate(), iterate()は無限の Stream の生成を前提としていて、要素列生成側のロジックで終了させる手段がないので注意が必要だ。無限の Stream を止めるには limit()findFirst() などの短絡操作を使う。

追記: Java 9 の Stream API では、for 文のように条件で打ち切れるバージョンの iterate() が追加されるようだ。

List.1-5_パスワード用文字列を生成する
// Stream.generate()の例
// len パスワード長、patterns パスワード文字列の要求パターンを正規表現で表したもの 
public static Optional<String> passgen(int len, String... patterns) {
    Random random = new Random(System.currentTimeMillis());
    // ランダム文字列の Stream を生成する
    Stream<String> stream = Stream.generate(() ->
                new String(random.ints('!', '~' + 1).limit(len).toArray(), 0, len)
            )
            .limit(10000); // 適当かつ重要

    // Stream に要求パターンで適合テストするフィルタを追加する。
    for (String p : patterns)
        stream = stream.filter(s -> s.matches(p));

    // 最初にすべてのテストをパスした文字列を返す。
    return stream.findFirst();
}

...

    // 20文字であること
    System.out.println(passgen(20).get()); 
    // CEIJC'Hvd{&C9@!,T][7

    // 英数字のみ、8文字
    System.out.println(passgen(8, "\\w+", "[^_]+").get()); 
    // 5cPvpSXd

    // 組み合わせ
    System.out.println(
            passgen(8,                      // 8文字
                "[\\w!?#$%&~^:;|=+*/-]+",   // 使用可能な文字
                ".*[A-Z].*",                // 英大文字必須
                ".*[a-z].*",                // 英子文字必須 
                ".*[0-9].*",                // 数字必須 
                ".*[!?#$%&~^:;|=+*/-].*",   // 記号必須 
                "(?!.*(.).*\\1).+"          // 重複排除
            ).get());
    // /|DsG9^!
List.1-6_簡易カレンダー表示
// cal コマンド的に今月のカレンダーを出力する

// LocalDateなど日時 API も Java 8 で追加されたので使ってみる
import java.stream.Stream
import java.time.LocalDate
import static java.time.temporal.TemporalAdjusters.*;

...

    // 今月の第1週の日曜日を取得する。
    LocalDate start = LocalDate.now()
            .with(firstDayOfMonth())
            .with(previousOrSame(DayOfWeek.SUNDAY));

    // 日付を1日ずつ進めた Stream<LocalDate> を生成する
    Stream.iterate(start, d -> d.plusDays(1L))
            .limit(7 * 6)      // 6週間分の要素で打ち切る
            .map(d -> {
                // 日(day)の文字列にする
                String day = String.format("%3d", d.getDayOfMonth());
                switch (d.getDayOfWeek()) {
                    case SATURDAY: return day += "\n"; // 土曜日なら改行
                    default: return day;
                }
            }).forEach(s -> {
                System.out.print(s);
            });       
 26 27 28 29 30  1  2
  3  4  5  6  7  8  9
 10 11 12 13 14 15 16
 17 18 19 20 21 22 23
 24 25 26 27 28 29 30
 31  1  2  3  4  5  6
List-1-38_文字bigramの抽出

// 指定の文字列から文字の bigram の Stream を生成する。
public Stream<String> bigramOf(final String text) {
    // Stream.Builderでは遅延処理にならないのが残念。
    // generate()やiterate()が使えれば遅延処理になるが無限Streamなので止められない。
    Stream.Builder<String> builder = Stream.builder();
    Pattern bigram = Pattern.compile("(?=(..))");
    Matcher m = bigram.matcher(text);
    while (m.find()) {
        builder.add(m.group(1));
    }
    return builder.build();
}

...

    String text = "おれはジャイアン";
    bigramOf(text)
            .forEach(bi -> {
                System.out.println(bi);
            });
おれ
れは
はジ
ジャ
ャイ
イア
アン

1.2. Collection#stream()

テキスト処理では Stream を自前で生成すことはあまりなくて、ほとんどの場合何らかの Source から Stream を引き出して使うことになる。

ListSet など java.util.Collection インターフェースを継承するクラスは、格納する要素を Stream 化する stream() メソッドを備えている。Collection 自体は Stream ではない。

List.1-31_Collection.stream()の基本
    // List
    List<String> list = Arrays.asList("boo", "foo", "woo");
    list.stream()
            .forEach(s -> {
                System.out.println(s);
            });

    // Set
    Set<String> set = new HashSet<>(list);
    set.stream()
            .forEach(s -> {
                System.out.println(s);
            });

    // Mapはstream()を持たない。
    Map<String, String> map = new HashMap<>();
    map.entrySet().stream()
            .forEach(s -> {
                System.out.println(s);
            });

    // map.values()はCollectionを返す
    map.values().stream()
            .forEach(s -> {
                System.out.println(s);
            });
List.1-7_先頭文字を合わせる
    List<String> words = new ArrayList<>();
    words.add("あいしてる");
    words.add("きらい");
    words.add("すき");
    words.add("ともだち");
    words.add("ぜっこう");
    words.add("ねっちゅう");
    words.add("こいびと");

    String name = words.stream()
            .map(s -> s.substring(0, 1))
            .collect(Collectors.joining());
List.1-8_オブジェクトのListを文字列のListへ変換する
    List<Bookmark> bookmarks = dao.findBookmarks(userId);

    List<String> links = bookmarks.stream()
            .sorted(b -> b.getTitle())
            .map(b -> {
                // markdown
                return String.format("* [%s](%s)", e.getTitle(), e.getUrl());
            })
            .collect(Collectors.toList());
List.1-9_システムプロパティを列挙する
    // java.util.Property は委譲関係の子オブジェクトを持てる。
    // Map 系メソッドでは委譲先まで拾いきれない。
    Set<String> keys = System.getProperties().stringPropertyNames();
    keys.stream()
            .filter(k -> k.startsWith("user.")) // キーのプレフィックス
            .sorted()
            .forEach(k -> {
                System.out.println(k + "=" + System.getProperty(k));
            }); 

1.3 Arrays#stream()

配列の要素も Stream 化できる。
java.util.Arrays に追加されたstream()メソッドに配列を渡すだけでよい。

List.1-32_Arrays.stream()の基本
    String[] array = {"Tokyo", "Osaka", "Nagoya", ... };       
    Arrays.stream(array)
            .forEach(s -> {
                System.out.println(s);
            });
    }
List.1-10_配列の空要素を除去する
public static String[] removeBlanks(String[] values) {
    return Arrays.stream(values)
        .filter(s -> s != null)
        .map(s -> s.trim())
        .filter(s -> !s.isEmpty())
        .toArray(String[]::new);
} 
List.1-11_配列を連結する
    String[] arr1 = {"txt", "csv", "tsv"};
    String[] arr2 = {"rtf", "doc", "html"};       

    String[] concat = Stream.concat(Arrays.stream(arr1), Arrays.stream(arr2))
            .toArray(String[]::new);
    // [txt, csv, tsv, rtf, doc, html]

気分的には先の Stream#of() が単発の短めの配列用、こちらは長い配列データ用などと使い分けられそうだが、Stream#of() は中で Arrays#stream() に引数配列を丸投げしているだけなので実質的な違いはない。

String の長大な配列を使う状況というのはあまり思い当たらないのだが、もしあれば並列化によるの高速化が見込めるという話題もある。

たとえばドキュメントのテキストを大量の NG ワードでチェックするプログラムがあって判定時間を改善したい。パターンの検索に順序は関係ないので並列処理による高速化が望めそうだが、どの程度の効果があるのか試してみる価値はある。

Stream なら並列化するのは笑っちゃうほど簡単だ。

List.1-12_NGワードの存在をチェックする
public static boolean hasNGWords(final String doc, final String[] ngwords) {
    return Arrays.stream(ngwords)
            .parallel()  // 並列化しちゃった
            .map(w -> Pattern.compile(w).matcher(doc)) // 正規表現あり
            .filter(m -> m.find())
            .findAny()
            .isPresent();
}

部分配列から Stream を得る事もできる。ページングするように領域のはじめと終わりの位置が分かっているなら、全体 Stream で skip/limit するよりも効率がいいだろう。

List.1-13_メール本文を引用する
    final String br = "\r\n";
    String[] arr = message.split(br);
    List<String> lines = Arrays.asList(arr);
    final int begin = lines.indexOf("") + 1; // 最初の空行までヘッダ
    final int end = lines.indexOf("-- ");    // sig-dashes 以降は署名
    if (end < begin) end = lines.size();     // 書名はないかもしれない

    String quoted = Arrays.stream(arr, begin, end)
            .map(line -> "> " + line)        // 本文引用
            .collect(Collectors.joining(br));

1.4 CharSequence#chars() / codePoints()

インターフェース java.lang.CharSequence には chars()codePoints() が追加され、文字を IntStream で取得できるようになった。CharSequence を継承する StringStringBuiler などで使用できる。

List.1-14_文字情報を表示する
    "ヽ(`Д´)ノ彡┻━┻".chars()
            .forEach(c -> {
                System.out.format("%05d %04X %c %s", c, c, c, Character.getName(c)).println();
            });
12541 30FD ヽ KATAKANA ITERATION MARK
00040 0028 ( LEFT PARENTHESIS
00096 0060 ` GRAVE ACCENT
01044 0414 Д CYRILLIC CAPITAL LETTER DE
00180 00B4 ´ ACUTE ACCENT
00041 0029 ) RIGHT PARENTHESIS
65417 FF89 ノ HALFWIDTH KATAKANA LETTER NO
24417 5F61 彡 CJK UNIFIED IDEOGRAPHS 5F61
09531 253B ┻ BOX DRAWINGS HEAVY UP AND HORIZONTAL
09473 2501 ━ BOX DRAWINGS HEAVY HORIZONTAL
09531 253B ┻ BOX DRAWINGS HEAVY UP AND HORIZONTAL
List.1-15_Unicodo補助文字の存在チェック
    if (text.codePoints().anyMatch(Character::isSupplementaryCodePoint)) {
        throw new IllegalArgumentException("Unicode補助文字はサポートされません。");
    }
List.1-16_コードポイントで文字数を数える。
    String text = "𠀀𠀁𠀂𠀃𠀄𠀅𠀆𠀇𠀈𠀉"; // Unicode補助文字
    int  size  = text.length(); // 20
    long count = text.codePoints().count(); // 10

しかし int で文字を貰っても意外とできる事が少なくて途方に暮れる。せめて chars()int ではなく char で返してほしいものだが、Java 8 では CharStream クラス自体の提供が見送られている。

List.1-17_汎用文字の収集
    Set<Character> letters = text.chars()
            .filter(c -> Character.isLetter((char)c))
            .mapToObj(c -> (char)c) // autoboxing
            .collect(Collectors.toSet());

テキスト処理では文字 Stream を最終的に String に戻す事がよくあるだろうが、char にしろコードポイントにしろ、素直に String に変換する方法がないようだ。

List.1-18_文字IntStreamからStringに変換してみる
    // 案1
    int[] seq = text.codePoints().toArray();
    text = new String(seq, 0, seq.length);

    // 案2
    text = text.chars()
                .mapToObj(c -> String.valueOf((char) c))
                .collect(Collectors.joining());

    // 案3
    text = text.codePoints()
                .mapToObj(cp -> String.format("%c", cp))
                .collect(Collectors.joining());

    // 案4
    text = text.codePoints()
                .collect(StringBuilder::new,
                    (sb, cp) -> sb.appendCodePoint(cp),
                    (sbl, sbr) -> sbl.append(sbr))
                .toString();

int 文字コードは結局どこかで Integer/Character/String などのオブジェクトに変換する必要があって、パフォーマンス的によくない。文字コードに関心がないならあえて文字を IntStream で扱う理由もないだろう。

List.1-19_Unicode補助文字を数値文字参照に置換する
   // Chromeだと表示されない??
    String text = "𠮷野屋で𠮟られる𠀋一郎"; 
    text = text.codePoints().mapToObj( c -> {
            return c > 0xFFFF ? String.format("&#x%x;", c) : String.valueOf((char) c);
    }).collect(Collectors.joining());
    // &#x20bb7;野屋で&#x20b9f;られる&#x2000b;一郎
List.1-20_文字の出現回数
    String text = "I can't can a can you can can, but you can't can a can I can can, can you ?";

    // 文字ごとの出現回数
    Map<Integer, Long> occ = text.codePoints()
            .boxed()
            .collect(Collectors.groupingBy(cp -> cp, Collectors.counting()));

    // カウントの多い順にソートしたMapにする
    occ = occ.entrySet().stream()
            .sorted((l, r) -> - l.getValue().compareTo(r.getValue()))
            .collect(Collectors.toMap(
                    e -> e.getKey(),
                    e -> e.getValue(),
                    (l,r) -> r,
                    LinkedHashMap::new));
    occ.forEach((cp, count) -> {
        System.out.format("%c\t%d\n", cp, count);
    });

    // 単純に文字列を1文字ずつに分解したいだけなら`split()`に
    // 区切りパターンとして "" を渡すのが手っ取り早い。
    // ただし、補助文字のサロゲートペアは泣き別れになる。
    // それで困るなら、補助文字を壊さずに文字分解するパターンとして 
    // "(?!\\p{InLowSurrogates})" などが使える。
    Map<String, Long> occ2 = Stream.of(text.split(""))
            .collect(Collectors.groupingBy(s -> s, Collectors.counting()));

List.1-21_ひらがなカタカナ相互置換
    String text = "あんたバカぁ?";
    text = text.chars().mapToObj(c -> {
        switch (UnicodeBlock.of(c).toString()) {
            case "HIRAGANA": c += 'ァ' - 'ぁ'; break;
            case "KATAKANA": c -= 'ァ' - 'ぁ'; break;
            default: break;
        }
        return String.valueOf((char) c);
    }).collect(Collectors.joining());
    // アンタばかァ?

1.5 BufferedReader#lines() / Files#lines()

Java に IO ストリーム系(ややこしい)のクラスは多いが、テキストファイルを読み込む場合は、java.io.BufferedReader がほとんど唯一の選択肢だ。

それは Java 8 になっても相変わらずだが、その代り BufferedReaderクラスに Stream のサポートが提供された。

BufferedReader に追加された lines() メソッドは、InputStream のテキスト行を要素にした Stream を返す。改行コードは含まない。

java1-33_BufferedReader.lines()の基本

    BufferedReader br = .... .. . . .  .    ;
    Stream<String> lines = br.lines();
    lines.forEach(line -> {
        System.out.println(line);
    });
    br.close();

テキスト行の読み込みは遅延的だ。つまり内部にデータがいったん溜め込まれるわけでなく、終端操作で要素が要求されるたびに逐次読み込まれている。

List.1-22_文字コード変換フィルタ
// iconv コマンドのマネ。
// 指定の文字コードに従い、標準入力のテキストの文字コードを変換して標準出力に表示する。
public class Conv {
    public static void main(String[] args) throws IOException {
        // 入力側の文字コード
        String dec = args.length > 0 ? args[0] : "UTF-8";
        // 出力用の文字コード
        String enc = args.length > 1 ? args[1] : Charset.defaultCharset().name();

        BufferedReader br = new BufferedReader(
                new InputStreamReader(System.in, dec));       
        br.lines()
                .map(line -> line + "\n") // 改行も付加
                .forEach(line -> {
                    try {
                        System.out.write(line.getBytes(enc));
                        System.out.flush();
                    } catch (IOException e) {
                        // ラムダ内からチェック例外を投げられない。
                        throw new UncheckedIOException(e);
                    }
                });
    }
}
Windowsでの使用例
C:\>chcp
現在のコード ページ: 932

C:\>type readme.utf8
縺ソ縺・◆縺√↑縺√≠
C:\>type readme.utf8 | java Conv
みぃたぁなぁあ

C:\>type readme.utf8 | java Conv utf-8 euc-jp > readme1st.euc

ところで Java では昔からたかがテキストファイルを読み込む手順がひどく面倒であった。Java 7 からは API 側が折れてFiles#newBUfferedReader() メソッドが用意されたためすこし労力が減っている。Java 8 ではさらに文字コード指定を省略した UTF-8 限定版が追加されている。(システムのdefault Charset ではないので注意)

List-1-34_UTF-8のテキストファイルを読み込む

    String file = "sample.txt";

    // Java 8 世代のやり方
    BufferedReader br = Files.newBufferedReader(Paths.get(file)) ;
    Stream<String> lines = br.lines();
    lines.forEach(line -> {
        System.out.println(line);
    });
    br.close();

    // いにしえのやり方
    BufferedReader br = new BufferedReader(
                                new InputStreamReader(
                                        new FileInputStream(file),
                                        StandardCharsets.UTF_8));
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
    br.close();

あぁ便利だ。確かに便利なのだが、なにか釈然としないものを感じるのはおっさんだからなのか。

List.1-23_CSVファイルをMapのListに変換する

@SuppressWarnings("serial")
public static List<Map<String, String>> csvToMap(Path csvPath) throws IOException {
    try (BufferedReader csv = Files.newBufferedReader(csvPath)) { // UTF-8

        // 1行めを見出しとして読み込む
        final String head = csv.readLine(); 
        if (head == null)
            return Collections.emptyList();

        // フィールド名の配列
        final String[] flds = head.split(",\\s*");

        // データ行読み込み
        // 2行目から Stream にして読み込む
        return csv.lines()  
                .map(line -> line.split(",\\s*", flds.length))
                .map(vals -> new LinkedHashMap<String,String>(){{
                    for (int i = 0; i < flds.length; i++)
                        put(flds[i], vals[i]);
                }})
                .collect(Collectors.toList());
    } catch (IOException e) {
        throw e;
    }
}

さらに便利なことに、そのFilesクラス自体にも lines() メソッドが追加されている。

List-1-35_Files.lines()の基本
    String fileName;

    Stream<String> lines = Files.lines(Paths.get(fileName));
    files.forEach(line -> {
        System.out.println(line);
    });
    // lines.close();


    // try-with-resources構文を使う
    try (Stream<String> lines = Files.lines(Paths.get(fileName))) {
        files.forEach(line -> {
            System.out.println(line);
        });
    } catch (IOException e) {
        e.printStackTrace();
    }

Files#lines() メソッドはファイルパスを指定するだけでテキスト行を Stream<String> として返す。これも Charset指定ありと、なしの UTF-8 限定版がある。

定番のテキスト読み込みループが簡潔に書けるようになってうれしい。うれしいはずなのだが、これもまた素直に喜べないのはなぜなのか。

List.1-24_テキストファイルの末尾行を表示(その1)
// tail コマンドのエミュレート
// Stream を close しないのはお行儀が悪い(後述)が単発ですぐ終了するので気にしない。
public class Tail {
    public static void main(String[] args) throws IOException {
        if (args.length < 2) {
            System.out.println("java Tail n file");
            return;
        }

        final long n = Long.parseLong(args[0]); // 取得する行数
        final Path file = Paths.get(args[1]);

        // ファイル行数を得るため一回空読みする。
        final long total = Files.lines(file).count();

        // ファイル行数からtail行数を引いた位置までスキップする。
        final long skip = total > n ? total - n : 0;
        Files.lines(file)
                .skip(skip)
                .forEach(line -> {
                    System.out.println(line);
                });
    }
}
List.1-25_テキストファイルを連結する
// cat コマンドのエミュレート
// 引数で与えたファイルのテキストを連結して標準出力に吐き出す
public class Cat {
    public static void main(String[] args) {
        Stream.of(args)
                .flatMap(f -> {
                    try {
                        return Files.lines(Paths.get(f)); // flatMap が close する
                    } catch (IOException e) {
                        // ラムダ内からチェック例外を投げられない。
                        throw new UncheckedIOException(e);
                    }
                })
                .forEach(l -> {
                    System.out.println(l);
                });
    }
}

ところで Files#lines() の返す Stream は内部で開いたファイルを抱えているはずだが、それがどういうタイミングで close されるのか気になるだろう。ファイルストリームが EOF になったらなのか、Stream の終端処理が終了した時か、あるいは Stream オブジェクトが GC に破棄される時なのか。

ぶっちゃけ言うとどれもあてにできない。

lines() の返す Stream の内部のファイルを close する処理は、Stream の onClose() ハンドラで行われるが、それは Stream オブジェクト自身が自分のタイミングで呼ぶわけではない。

onClose() に登録されたハンドラが実行されるのは、Stream の close() メソッドが外部から呼ばれた時だ。この close()java.lang.AutoCloseable インターフェース由来のメソッドで、直接呼び出しも可能ではあるが、基本的には Java 7 で導入された try-with-resources 構文を使った自動解放を利用する前提になっている。Stream でも try-with-resources 構文を使えば、内部のファイルリソースは適切なタイミングで確実にリリースされる。

ちょ、ちょっとまてよ。

Stream が(正確には BaseStream が) AutoCloseable を継承した閉じられるべき定めのあるものなら、List や配列から生成したこれまでの例の Stream オブジェクトも、実は close() の呼び出しを意識しておかなければならなかったのでは?

改めて Java 8 API の JavaDoc の AutoCloseable の説明をよく読むと、注が追加されていて、Listなんかの場合には close しなくても問題ないよ、的なことがものすごい難解な日本語で書いてあるようだ。

Venkat Subramaniam 先生の「Javaによる関数型プログラミング――Java 8ラムダ式とStream」 p113 の解説によれば

Java 8 では AutoCloseable に関連する変更がいくつか行われました。《中略》 これに伴い、AutoCloseable は以前の厳密な「リソースは閉じられなければならない」ではなく、より緩い「リソースは閉じられる可能性がある」という取り決めに変更されました。《後略》

...だ、そうです。

List.1-26_試しに普通のStreamをcloseしてみる
    Stream<String> stream = Stream.of("流されたいー", "おれもー");
    stream.onClose(() -> System.out.println("だが断る"));
    System.out.println("準備OK!");

    // close() を呼んでみる。
    stream.close();

    System.out.println("スタート!");
    stream.forEach(s -> {
        System.out.println(s);
    });

    // 準備OK!
    // だが断る
    // スタート!
    // java.lang.IllegalStateException: stream has already been operated upon or closed
    // ↑
    // Stream の終了と close 後の状態が違いがよくわからん。
    // 上記 close() 行をコメントアウトするとハンドラ呼ばれない。

結局、Files#lines() は、内部のファイルを適切に close するため、try-with-resouces 構文で使用するのが定石になる。その代り当初の期待された簡潔さは幾分損なわれる。

List.1-27_テキストファイルの末尾行を表示(その2)
// tail コマンドのエミュレート
// Stream を close する行儀がよいバージョン
public class Tail2 {
    public static void main(String[] args) {
        if (args.length < 2) {
            System.out.println("java Tail2 n file");
            return;
        }

        final long n = Long.parseLong(args[0]); // last n lines
        final Path file = Paths.get(args[1]);

        // 行数を得るため一回空読みする。
        final long total;
        try (Stream<String> lines = Files.lines(file)) {
            total = lines.count();
        } catch (IOException e) {
            e.printStackTrace();
            return;
        }

        // ファイル行数からtail行数を引いた位置までスキップする。
        final long skip = total > n ? total - n : 0;
        try (Stream<String> lines = Files.lines(file)) {
            lines.skip(skip)
                    .forEach(line -> {
                        System.out.println(line);
                    });
        } catch (IOException e) {
            e.printStackTrace();
            return;
        }
    }
}

Filesにはfind()という Stream<Path> を返すメソッドもあるが、これも内部的になんらかのシステムリソースをつかんでいるようでそのcloseのタイミングのために try-with-resouces 構文を使った方がいいようだ。

ちなみに flatMap()concat() などで Stream に預けた Stream は、内部で終了後適切に close してもらえるそうなので、そこはあてにしていい。

List.1-28_テキストファイルを正規表現で検索する
// grep コマンドのエミュレート
// grep オプションの -E -r に相当する。
public class GrepEr {
    public static void main(String[] args) throws IOException {
        if (args.length < 1) {
            // showUsage();
            return;
        }
     // 最初の引数に検索するパターンを正規表現として与える
        final Pattern pattern = Pattern.compile(args[0]);
        // 2盤目の引数で対象ファイルを指定する。ディレクトリが指定された場合、
        // 再帰的ディレクトリ内のファイルの内容を検索する。
        final Path basePath = Paths.get(args.length > 1 ? args[1] : ".");
        final String margin = "                              "; // 30

        // Path の Stream
        // 本当はtry-with-resoucesしたほうがいいが、1回限りですぐに終了するので気にしない。
        Files.find(basePath, 100, (path, attr) -> attr.isRegularFile())
                .parallel()         // パラっちゃおー
                .forEach(path -> {
                    try (Stream<String> lines = Files.lines(path)) {
                        lines.forEach(line -> {
                            final Matcher m = pattern.matcher(line);
                            while(m.find()) {
                                final String window = margin.concat(line).concat(margin)
                                        .substring(m.start(), m.start() + margin.length() * 2 );

                                synchronized (System.out) {
                                    System.out
                                            .format("%s\t%s", window, path.getFileName())
                                            .println();
                                }
                            }
                        });
                    } catch (Exception e) {
                        System.err.println(e.getMessage() + " with " + path);
                    }
                });
        System.out.println("END");
    }
}
使用例
$ java GrepEr "FIXME" ./jdk1.8.0_45/src
                           // FIXME: is the right exception     SnmpAdaptorServer.java
                           // FIXME: is the right exception     SnmpAdaptorServer.java
                           // FIXME: may be it's not the bes    SnmpInformRequest.java
utBytes/*, byteCount */) ; // FIXME                             SnmpMessage.java
                            //FIXME: This method is not used    ThreadPoolManagerImpl.java
                            //FIXME: This method is not used    ThreadPoolManagerImpl.java
                            //FIXME: This method is not used    ThreadPoolManagerImpl.java
                            //FIXME: This method is not used    ThreadPoolManagerImpl.java
                        // [[[FIXME:  WDW - we probably shou    JComponent.java
                           // FIXME:  [[[WDW - should also r    JFrame.java
                           // FIXME:  [[[WDW - probably shou    JMenu.java
                           // FIXME: [[[WDW - Should also ad    JSplitPane.java
                           // FIXME:  [[[WDW - need to add o    JToolBar.java
                           // FIXME:  [[[WDW - need to do SE    JToolBar.java
                           // FIXME: // This is a _hack_ to     RECompiler.java
         return null; // fredxFIXME Not implemented yet         List.java
                        // [[[FIXME]]] need to finish implem    List.java
                        // [[[FIXME]]] needs to work like is    List.java

:
:
:

I/O リソースの扱いに気を使いたくなければ、Files#readAllLines() というこれまた安直なメソッドを使う手もある。これは Stream API というわけではないが、これも Java 8 で Chaset 指定を省略した UTF-8 限定版が追加されている。

readAllLines()は指定のテキストファイルを全て読み込み、テキスト行を List<String> にして返す。ファイル 処理が隠蔽され内部で完結するので、戸締りの心配もいらない。

たとえば Stream では行をまたぐような処理は難しいが、これで全行のリストで得てからまとめて処理してしまえれば楽だろう。一発でお手軽だが、当然メモリを圧迫するのでファイルサイズには気を付けなればならない。

List.1-29_HTMLタグを除去する
    // タグ内改行にも対応する。
    String htmlfile = "index.html";
    List<String> lines = Files.readAllLines(Paths.get(htmlfile));
    String text = String.join("\n", lines)
            .replaceAll("(?s)<!--.+?-->", "")   // コメント削除
            .replaceAll("(?si)<(script|style).+?/\\1>", "") // script/style は要素ごと削除
            .replaceAll("(?s)<.+?>", "")        // HTML タグ削除
            .replaceAll("(?m)^\\s+$", "")       // 空白うぜー
            .replaceAll("\\n{3,}", "\n\n");     // 空行うぜー
    Files.write(Paths.get(htmlfile + ".txt"), Arrays.asList(text), StandardCharsets.UTF_8);

1.6 Pattern#splitAsStream()

正規表現エンジンである java.util.regex.Pattern に追加された splitAsStream() メソッドの存在は、一般に見過ごされている感がある。

splitAsStream() メソッドはテキストを指定の正規表現パターンで区切った文字列を要素とする Stream を生成する。

List-1-36_java
    String text = "A very very long story ...";
    Pattern pattern = Pattern.compile("\\s");
    Stream<String> stream = pattern.splitAsStream(text);
    stream.forEach(s -> {
        System.out.println(s);
    });
List.1-30_分ち書きの単語生起頻度を集計する
    final Path file = Paths.get("hamlet.txt");
    final Pattern delim = Pattern.compile("\\s+"); // 空白区切り
    Map<String, Long> wordCounts = null;

    try(Stream<String> lines = Files.lines(file)) { // UTF-8
        wordCounts = lines
                .flatMap(line -> delim.splitAsStream(line)) // 行 Stream ->単語 Stream  
                .collect(Collectors.groupingBy(
                        w -> w,
                        TreeMap::new,  // 単語順
                        Collectors.counting()));
    } catch (IOException e) {
        e.printStackTrace();
    }

    wordCounts.forEach((w, c) -> {
        System.out.println(w + " " + c);
    });

結果的に Stream.of(text.split(pattern)) と同じで、ちょっとお手軽になっただけのようだが、それだけではない。

従来の String#split()Pattern#split() は、文字列全体を解析後、結果の全要素をString 配列に詰めなおしてから返している。

一方 splitAsStream() は実装的にパターンマッチの逐次結果を Stream に回している。つまり、Stream の次の要素の検出パターンの適用は、遅延的に実行される。

文字列全体を解析せずに途中までで中断することもできるし、コンパイル済みの正規表現も再利用できるので、長いテキストでなら splitAsStream() の方がパフォーマンス的に有利になるはずだ。

ところで、テキスト処理の実用としては、正規表現を区切りではなく抽出対象そのもののパターンで与えたいところだ。しかし残念ながらそのようなメソッドは用意されていない。ちょっと考えれば splitAsStream() でも、抽出対象の正規表現の反転(否定)パターンを与えれば結果的に同じことが実現できるはずと思うかもしれないが、それは正規表現の特性上言うほど簡単なことではない。天才的な職人の技で何とかなるかもしれないが、誰もメンテナンスできない。

ここは多少面倒でもMacherで取り回したほうがずっと処理効率もいいし、なにより安全だ。

List-1-37_テキストファイルからURLを抽出する

// splitAsStream() を使った実装
// 指定の正規表現を反転し、splitAsStream()を使ってテキストから文字列を抽出する。
public List<String> extract1(String file, String regex) throws IOException {
    // 任意の正規表現を反転(否定)した正規表現にするが、保証はできない。
    // バックトラックがかかりまくるので重くなる。ものによっては帰ってこれないかもしれない。
    final String inverse = String.format("(?<=^|(%1$s))((?!(%1$s)).)+", regex);
    final Pattern invertedPattern = Pattern.compile(inverse);
    try(Stream<String> lines = Files.lines(Paths.get(file))) {
        return lines
                .flatMap(line -> {
                    // 対象文字列「以外」で区切る
                    return invertedPattern.splitAsStream(line);
                })
                .filter(s -> !s.isEmpty()) // 最初の要素は大抵空になる
                .distinct()
                .sorted()
                .collect(Collectors.toList());
    }
}

// Macher を使った素直な実装。
public List<String> extract2(String file, String regex) throws IOException {
    final Pattern pattern = Pattern.compile(regex);
    try(Stream<String> lines = Files.lines(Paths.get(file))) {
        return lines
                .map(line -> pattern.matcher(line))
                .flatMap(m -> {
                    // マッチした文字列を Stream.Builder に詰める。
                    Stream.Builder<String> builder = Stream.builder(); 
                    while (m.find())
                        builder.add(m.group());
                    return builder.build();
                })
                .distinct()
                .sorted()
                .collect(Collectors.toList());
    }
}

    {
        // splitAsStream()による実装
        // 内部的に先読み後読みを使っているので、パターンに長さ制限をつけないと動かない。
        final String HTTP_URL_PATTERN = "(https?:)?//[-\\w:/._-~%?=+&#]{1,1000}(?=\"|'|\\s)";
        //final String HTTP_URL_PATTERN = "(?<=(href|src)=[\"'])https?://.{1,1000}?(?=\\s*[\"'])";
        List<String> urls = extract1("index.html", HTTP_URL_PATTERN);
        urls.forEach(url -> System.out.println(url));
    }
    System.out.println();
    {
        // Matcher による実装
        // 長さ制限は特に求められない。
        final String HTTP_URL_PATTERN = "(https?:)?//[-\\w:/._-~%?=+&#]+(?=\"|\\s)";
        //final String HTTP_URL_PATTERN = "(?<=(href|src)=[\"'])https?://.+?(?=\\s*[\"'])";
        List<String> urls = extract2("index.html", HTTP_URL_PATTERN);
        urls.forEach(url -> System.out.println(url));
    }
}

つづく…

参考資料

154
148
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
154
148