はじめに

この記事はJava Advent Calendar 2019の13日目の記事です。13日の金曜日です。不吉ですね。

不吉といえば、OS依存ってなんか不吉な感じがしませんか?
たとえばWindows環境で動作確認を行った時は問題なかったけど、Linux環境で動かしたらエラーになった…なんてことがあったりします。Write once, run anywhereなJavaですが、OSごとの違いを考慮しなくていいわけではありません。
そこでこの記事では、Javaの標準ライブラリでOS依存が隠れている例をいくつか紹介してみます。

この記事における「OS依存」

一口にOS依存といっても色々なことが考えられますが、この記事ではプログラムの実行結果がOSによって変わってしまう場合のことを「OS依存」と表現することにします。
内部的にOSネイティブなAPIを呼ぶメソッドはたくさんありそうですが、どのOSでも同じ実行結果になるのであれば、この記事においてはOS依存ではないと考えます。

OS依存ってそんなに悪いの?

良いか悪いかは場合によりけりです。OS依存が原因で特定のOSでしか正常に動作しないコードになってしまう場合がある一方で、OSごとに挙動が違うほうが都合がいい場合もあります。ただ、使おうとしている標準ライブラリの仕様(OSに依存するかどうか)を把握しないと、OSに依存していて良いか悪いかの判断ができません。
そういうわけで、良いか悪いかはさておき、OS依存な標準ライブラリを私の把握している範囲内で紹介してみるというのがこの記事の趣旨です。
とはいえ、私ごときが把握している範囲なんてたかが知れていますし、そもそも全部紹介するのはボリューム的にも現実的ではないので、いくつかピックアップして紹介するような感じです。(予防線)

サンプルコードの動作環境

  • Java 13
  • macOS Mojave
  • Windows 10 Home

Linuxではめんどくさいので確認していませんが、だいたいMacと同じ結果になると思います。

本題

エンコーディング、改行コード関連

InputStreamReaderを使う場合

下記はテキストファイルから文字列を読み取って出力するサンプルコードですが、OS依存なコードが含まれています。さて、どこでしょう?

import java.io.*;

public class InputStreamReaderSample {
    public static void main(String[] args) throws Exception {

        try(var fis = new FileInputStream("hoge.txt");
            var isr = new InputStreamReader(fis);
            var br = new BufferedReader(isr)) {
            String line;
            while((line = br.readLine()) != null) {
                System.out.println(line);
            }
        }
    } 
}

まあ、上に書いてあるじゃんという話なのですが…

お察しの通り、InputStreamReaderのコンストラクタです。ここで入力ファイルのエンコーディングを指定していません。
そうすると、どうなるのでしょうか?JavaDocを見てみると、下記のような記述があります。

デフォルトの文字セットを使うInputStreamReaderを作成します。

これだけではよくわかりませんね。「デフォルトの文字セット」とは何でしょうか。
軽くソースを追ってみましたが、現時点での実装では java.nio.charset.Charset.defaultCharset() の戻り値が「デフォルトの文字セット」のようです。手元で確認したところ、Macだと「UTF-8」、Windowsだと「windows-31j」になりました。OSによって異なるので、OS依存ということになります。

OSに依存しないコードにするにはどうすればいいでしょうか。幸い、InputStreamReaderにはエンコーディングを指定するコンストラクタもあるため、そちらを使えばよいです。
下記はUTF-8を指定する例です。

import java.io.*;
import java.nio.charset.StandardCharsets;

public class InputStreamReaderSample {
    public static void main(String[] args) throws Exception {

        try(var fis = new FileInputStream("hoge.txt");
            var isr = new InputStreamReader(fis, StandardCharsets.UTF_8); // ←ここ
            var br = new BufferedReader(isr)) {
            String line;
            while((line = br.readLine()) != null) {
                System.out.println(line);
            }
        }
    } 
}

ちなみに、改行コードの違いで BufferedReader#readLine() はOS依存になるのではと思った方もいらっしゃるかもしれませんが、JavaDocに下記の記述があるのでOS依存ではなさそうです。(LF、CR、CR+LFのいずれも改行と見なされる)

ラインは、ライン・フィード('')、キャリッジ・リターン('\r')、キャリッジ・リターンの直後に改行、またはファイルの終わり(EOF)に到達することによって終了されるとみなされます。

BufferedReader#lines() も、 BufferedReader#readLine() をラップしたメソッドなので同様です。

OutputStreamWriterを使う場合

上記はファイル入力の例ですが、今度はファイル出力の例です。下記の例にもOS依存のコードがあります。どこがOS依存なのかは、なんとなく想像がつくでしょうか。

import java.io.*;

public class OutputStreamWriterSample {
    public static void main(String[] args) throws Exception {
        try(var fos = new FileOutputStream("hogehoge.txt");
            var osw = new OutputStreamWriter(fos);
            var bw = new BufferedWriter(osw)) {
            bw.write("にほんご");
        }
    }
}

はい、OutputStreamWriterのコンストラクタでもエンコーディングを指定していませんので、ここがOS依存のコードです。JavaDocに下記の記述があります。

デフォルトの文字セットを使うOutputStreamWriterを作成します。

InputStreamReaderの例と同じですね。これも、コンストラクタでエンコーディングを指定すればOS非依存になります。

import java.io.*;
import java.nio.charset.StandardCharsets;

public class OutputStreamWriterSample {
    public static void main(String[] args) throws Exception {
        try(var fos = new FileOutputStream("hogehoge.txt");
            var osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8); // ←ここ
            var bw = new BufferedWriter(osw)) {
            bw.write("にほんご");
        }
    }
}

BufferedWriterを使う場合

上の例では改行コードを出力していませんでしたね。追加してみます。

import java.io.*;
import java.nio.charset.StandardCharsets;

public class OutputStreamWriterSample {
    public static void main(String[] args) throws Exception {
        try(var fos = new FileOutputStream("hogehoge.txt");
            var osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8);
            var bw = new BufferedWriter(osw)) {
            bw.write("にほんご");
            bw.newLine(); // ←ここ
        }
    }
}

しかし、この BufferedWriter#newLine() もOS依存なのです。JavaDocの記述は下記の通りです。

改行文字は、システムのline.separatorプロパティにより定義され、必ずしも単一の改行文字('\n')であるとは限りません。

line.separatorはわりとよく見かけるのでご存知の方も多いと思いますが、これは実行環境のOSで一般的に使われる改行コードを値として持つシステムプロパティです。Macだと \n 、Windowsだと \r\n になります。これに関してはむしろOS依存のほうが望ましい場合が多いような気もしますが、一応取り上げてみました。1
これをOS非依存にしたいのであれば、いくつか方法がありそうですが、 BufferedWriter#newLine() を使わずに固定値を書き込むのが素直なやり方かなと思います。

import java.io.*;
import java.nio.charset.StandardCharsets;

public class OutputStreamWriterSample {
    public static void main(String[] args) throws Exception {
        var fos = new FileOutputStream("hogehoge.txt");
        var osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8);
        try(var bw = new BufferedWriter(osw)) {
            bw.write("にほんご");
            bw.write("\r\n"); // ←ここ
        }
    }
}

Filesを使う場合

ここまで書いておいてアレですが、昨今ではファイル入出力はFilesクラスを使うのが一般的ではないかと思います。Filesクラスを使う場合はどうなるのでしょうか。

FilesクラスはOSごとのファイルシステムに依存するので、OS依存なメソッドがたくさんありそうですね。ただ、私はあまり把握できてないのでそこには触れないことにします。

ここで言いたいのは、少なくともファイル入出力の際のエンコーディングについては、省略時はOSに関わらずUTF-8を使用することになっているので、OSに依存しないということです。2
FilesクラスのJavaDocには、下記の記述がそこかしこにあります。

ファイルから取得したバイトは、UTF-8文字セットを使用して文字にデコードされます。

たとえばファイルから入力した文字列を出力するコードはこんな感じですね。エンコーディングをどこにも指定していませんが、どのOSでもUTF-8が使われるのでOS非依存です。

import java.nio.file.Files;
import java.nio.file.Path;

public class FilesSample {
    public static void main(String[] args) throws Exception {
        try(var lines = Files.lines(Path.of("hoge.txt"))) {
            lines.forEach(System.out::println);
        }
    }
}

OSに関わらずUTF-8を使うようにしたのは良いと思いますが、旧APIからの類推で、OS毎の「デフォルトの文字セット」が使われると勘違いしてしまう場合もあるかもしれません。これはこれで注意が必要です。

この通りエンコーディングについてはOS非依存なのですが、改行コードについてはOS依存です。たとえば Files#write(Path, Iterable<? extends CharSequence>, Charset, OpenOption...) を使うと、Listの各要素を一行としたテキストをファイルに書き込むということができます。その際に改行コードは自動的に付与されるわけですが、その改行コードはline.separatorの値になります。
JavaDocの記述は下記の通りです。

システム・プロパティ line.separatorで定義されているように、各行の終端がプラットフォームの行区切り文字で表されるファイルに順々に書き込まれます。

サンプルコードはこんな感じです。

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.List;

public class FilesLineSeparatorSample {
    public static void main(String[] args) throws Exception {
        var words = List.of("アリス", "ボブ", "キャロル");
        Files.write(Path.of("foobar.txt"), words, StandardCharsets.UTF_8);
    }
}

Macなら アリス\nボブ\nキャロル\n になり、Windowsだと アリス\r\nボブ\r\nキャロル\r\n になるはずです。

これをOS非依存にしたいなら、ちょっと面倒ですね。 Files#newBufferedWriter() で取得したBufferedWriterを使って改行コードを固定値として書き込むか、メモリに余裕があれば指定したい改行コードで連結した文字列を作って Files#writeString() を使うか、いっそline.separatorを書き換えてしまうか。
2番目の方法ならこんな感じでしょうか。

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.List;

public class FilesLineSeparatorSample {
    public static void main(String[] args) throws Exception {
        var words = List.of("アリス", "ボブ", "キャロル");
        var lineSeparator = "\r\n";
        var str = String.join(lineSeparator, words) + lineSeparator;
        Files.writeString(Path.of("foobar.txt"), str, StandardCharsets.UTF_8);
    }
}

String#getBytes()を使う場合

ファイル入出力からちょっと離れますが、エンコーディングがOSに依存する例がまだあります。

たとえば、入力値が全て全角文字かどうかを判定する下記のようなコードがあったとします。

public class StringSample {
    public static void main(String[] args) {
        var str = args[0];
        if(str.getBytes().length == str.length() * 2) {
            System.out.println("入力文字は全て全角文字です。");
        } else {
            System.out.println("入力文字に半角文字が含まれます。");
        }
    }
}

「全角文字は2バイトだから、バイト数が文字列の長さの2倍と等しければ全て全角文字のはず」という発想です。3
これはWindowsでは一応うまく動きますが、Macでは想定通りに動きません。理由はもうお察しかもしれませんが、 String#getBytes() でエンコーディングを指定しておらず、「デフォルトの文字セット」が使われるためです。
String#getBytes() はStringが内部的に持っているバイト配列を取得するメソッドではなく、「デフォルトの文字セット」を使ってエンコードした際のバイト配列を取得するメソッドなので、OSに依存します。
JavaDocでは下記のような記述になっています。

プラットフォームのデフォルトの文字セットを使用してこのStringをバイト・シーケンスにエンコード化し、結果を新規バイト配列に格納します。

ソースを軽く追ってみましたが、「プラットフォームのデフォルトの文字セット」はファイル入出力の場合と同じで、 java.nio.charset.Charset.defaultCharset() の戻り値のようです。なので、Macの場合は「UTF-8」、Windowsの場合は「windows-31j」とOS依存になります。
上のほうで「全角文字は2バイト」とかほざいてますが、UTF-8の場合は2バイトではなく3バイトです。なので、その場合は文字列の長さの3倍と比較しないと想定通りに動作しません。4
この問題を解決する方法ですが、やはりお察しのことと思いますがエンコーディングを明示すればいいです。

public class StringSample {
    public static void main(String[] args) throws Exception {
        var str = args[0];
        if(str.getBytes("windows-31j").length == str.length() * 2) {
            System.out.println("入力文字は全て全角文字です。");
        } else {
            System.out.println("入力文字に半角文字が含まれます。");
        }
    }
}

あと、書いていて気づきましたがバイト配列を渡すコンストラクタでも同様の配慮が必要ですね。あまり使わないような気はしますが。

public class StringConstructorSample {
    public static void main(String[] args) throws Exception {
        byte[] bytes = { -109, -6, -106, 123, -116, -22 };
        System.out.println(new String(bytes, "windows-31j")); // -> 日本語
    }
}

ファイルシステム関連

File#listFiles()を使う場合

指定したディレクトリの中のファイル一覧を取得するというのはよくある処理ですが、実はこれもOS依存なのをご存知でしょうか?
何かというと、ファイル一覧の並び順のことです。 File#listFiles() のJavaDocには下記の記述があります。

結果の配列の名前文字列は特定の順序にはなりません。アルファベット順になるわけではありません。

実際にこんな感じのサンプルコードでやってみました。

import java.io.File;
import java.util.Arrays;

public class FileListSample {
    public static void main(String[] args) {
        Arrays.stream(new File("hoge").listFiles())
            .forEach(System.out::println);
    }
}

結果としては、Macの場合はたしかに(人間から見ると)規則性のない並び順になるのですが、Windowsの場合はファイル名で昇順ソートしたかのような並び順になりました。理由はちゃんとは調べてないのですが、おそらく内部的にOSネイティブなAPIが呼ばれて、その取得順がそのまま最終的な出力結果になっているのだと思います。 File#listFiles() の仕様としては、内部的に取得された並び順を変えるようなことは何もしないということでしょうね。それで、WindowsのネイティブなAPIはたまたま昇順ソートしてから返すようになっていると。まあ理由はどうあれ、OS毎に結果が違うのでこれもOS依存ですね。

これをOS非依存にしたいなら、出力結果をソートすればいいでしょうか。

import java.io.File;
import java.util.Arrays;

public class FileListSample {
    public static void main(String[] args) {
        Arrays.stream(new File("hoge").listFiles())
            .sorted()
            .forEach(System.out::println);
    }
}

しかし、これはこれでOS依存のようなんですよね…
File#compareTo() のJavaDocには下記の記述があります。

このメソッドが定義する順序はベースとなるシステムに依存します。 UNIXシステムの場合、アルファベットの大文字と小文字がパス名の比較で意味を持ちます。Microsoft Windowsシステムでは意味を持ちません。

じゃあ文字列としてソートすればOS非依存になるでしょうか。

import java.io.File;
import java.util.*;

public class FileListSample {
    public static void main(String[] args) {
        Arrays.stream(new File("hoge").listFiles())
            .sorted(Comparator.comparing(File::getName))
            .forEach(System.out::println);
    }
}

まあ、そこまでする必要があるケースは稀ではないかとは思いますが。

ちなみに、 File#list() を使う場合も同様の結果になります。

Files#list()を使う場合

Filesクラスにも、もちろん同様なメソッドが用意されています。そして、Macでは不規則に見える並び順になり、Windowsだとファイル名で昇順ソートされた順番で返ってくるのも同じです。
ただ、これに関してはJavaDocにそれらしい記述がないんですよね。謎です。

import java.nio.file.*;

public class FilesListSample {
    public static void main(String[] args) throws Exception {
        try(var files = Files.list(Path.of("hoge"))) {
            files.forEach(System.out::println);
        }
    }
}

OS非依存に近づけたいなら、やはりソートすることになると思います。

import java.nio.file.*;

public class FilesListSample {
    public static void main(String[] args) throws Exception {
        try(var files = Files.list(Path.of("hoge"))) {
            files.sorted()
                .forEach(System.out::println);
        }
    }
}

終わりに

探せばまだまだあるような気がしますが、思いのほか長くなったのでこの辺で…

まとめ

基本的には、JavaDocを読めば大丈夫かなと思います。ただ、 Files#list() のようにJavaDocに書かれていない場合もあります。そういう場合はどうすればいいのか難しいところですが、この例は File#listFiles() からの類推で判明したので、こうやってOS依存な例を色々見て、知識と勘を研いていくしかないのかなと思いました。勉強あるのみですね。

参考


  1. ちなみに、ここまで触れずに何気なく使ってきた System.out.println() もline.separatorの値を改行コードとして使うのでOS依存です。システムプロパティに依存するライブラリはほとんどOS依存かもしれません。 

  2. UTF-8以外の任意のエンコーディングを指定することももちろん可能です。 

  3. エンコーディング以前に、サロゲートペアの場合はダメじゃんとかツッコミどころは色々ありそうですが… 

  4. こういうバグは実際に見たことがあります…