「正しい番号が入力されていること」という難しさ

バリデーションの難しさについて、少し考えてみましょう。
(結論だけ読みたい人は、こちら

例えば、通販サイトの基本設計書で「入力フォームの電話番号の項目に、正しい電話番号が入力されていない場合はエラーとすること」と書かれていたとします。
では、正しい電話番号が入力されているかどうかの判定とは、どのように実装すればいいのでしょうか。

「正しい電話番号」とは?

電話番号とはどのようなものでしょうか。
総務省のホームページに分かりやすくまとめられていました。1
総務省|電気通信番号制度|電話番号に関するQ&A

この中から一般的2に使われている市外局番から始まる電話番号3、または携帯電話4の番号で検討を進めます。
上記のページから関連する部分をピックアップすると、このようになっています。

◆電話番号とは、通話の相手を識別するために使われる、0から9までの数字を組み合わせた番号です。

「0A0から始まる番号(Aは0以外)」 ◆携帯電話とPHS「070」、「080」又は「090」から始まる11桁の番号です。

「0ABCから始まる番号(A、B、Cは0以外)」 ◆普通の固定電話の電話番号は、次のようになっています。 固定電話の桁数 国内プレフィックス「0」市外局番「1~4桁」市内局番「1~4桁」加入者番号「4桁」 ※市外局番と市内局番は合計5桁 5

これを基にすると、(ハイフンを取り除いて)以下の正規表現にマッチすれば「正しい電話番号」と言えそうです。

((090|080|070)[0-9]{11}|0[0-9]{9})

しかし、この判定には問題があります。

有効な電話番号の判定

例えば、"0998-76-5432" は正しい番号番号と判定します。
しかし、今日時点で 998 という市外局番は存在しません。そのため、この電話番号は存在しない無効なものだと分かります。
つまり、厳密に正しい番号かどうかを判定するには、市外局番が存在するものかどうかも判定しなければいけません。

また、"060-4321-1234" は正しくない電話番号と判定します。
ですが、060 は携帯電話への割り当てが検討されており6、これはいずれ有効になる番号です。これを、正しくないものとしてエラーにしてしまうと、今後改修が必要になってしまいます。

しかも、ここまで考えても、まだ使われていない無効な電話番号という可能性が残されています。
「正しい番号 = 存在する有効な番号」なのかは微妙なところですが世間一般としてはそういう認識のようです。

存在する有効な番号の判定

では、「正しい番号 = 存在する有効な番号」を判定するにはどうすればいいのでしょうか。
解決策は、実際にその電話番号にかけてみるということです。

これならば、存在する有効な番号であることが確認できます。
銀行などは、実際にこの方式をとっています。

ただし、単純に実装すると脆弱性となります。
本の虫: ダイヤルQ2風の電話番号でInstagramやGoogleやMicrosoftから金をむしりとれる脆弱性

国内ではダイヤルQ2が終了しているので、このままの方法は取れません7が、ひたすら電話をかけさせて相手に莫大な電話料金を支払わせる、他人の番号を入力して DDoS させるといったことが考えられます。
このような可能性を考慮して、慎重に実装を行う必要があります。

簡単な判定

しかし、ここまで厳密な判定は必要なのでしょうか。
「存在しうる番号かどうかの判定」や「有効な電話番号の判定」を行うのはとても大変です。実装にも時間もかかります。

一番手っ取り早く、確実な方法は「正しい電話番号が入力されていること」という判定をしないことです。
シンプルに「数字のみが入力されていること」だけ判定すれば、考えなくてはいけないことがぐっと減ります。

その代わり、架空の番号で登録できてしまうことを許容する必要があります。

どこまでを求めるかは、要件次第です。

ちなみに

「正しい電話番号の判定」はこれでも簡単な方です。
なぜなら、電話局という(仮想的な)一つのデータベースに問い合わせすれば、存在する正しい番号かどうかわかるからです。

もし複数のデータベースに分散していて、それぞれに問い合わせる必要がある場合は、とても大変です。
分散しているすべてのデータベースに接続する必要があるためです。もしくは、番号を一つのデータベースに集約するという方法もありますが、これだと更新をどのように行うかを考えなくてはいけません。

例えば、ワクチンの接種番号は全国の市町村(今日時点で 1,7188)が管理しています。東京都区部に限っても23の区がそれぞれ管理しています。 これが正しいかどうかを確認するには、すべての市町村のデータベースと接続するか、番号を集約し常に更新し続ける必要があります。 参考:新型コロナウイルスワクチンに係る接種券等の印刷及び発送について - 総務省

それをやろうと思うと、時間も費用もかかると思います。


  1. 仕様としては ITU-TE.164 勧告ですが、これは国別割り当て電話番号「国番号+国内電話番号を合わせた最大15桁」とだけ定められており、国内電話番号の部分(090-XXXX-XXXX、03-XXXX-XXXX など)は各国の方針となっています。

  2. 119 や 110 といった 1XY 特番、フリーダイヤルといった電話サービスの番号は除く

  3. 中継する電話会社を明示する番号を指定しない番号

  4. 衛星携帯電話は、国際電話扱いのようなのでここでは検討しない。衛星電話(インマルサット)あての電話のかけ方を教えてください。【インマルサット】

  5. 市外局番と市内局番の合計が4桁の地域もありましたが、現在はなくなりました。 9桁の電話番号 ‐ 通信用語の基礎知識

  6. FMC(Fixed-Mobile Convergence)にすでに使われていますが、利用が伸びないので携帯電話にも使用しようという見当が行われています。 携帯電話の「060」がまもなく開放 そもそも「090」「080」「070」の違い、知ってる?|TIME&SPACE by KDDI

  7. 情報料代理徴収サービスの一種である災害募金サービスは残っているので、意図しない募金を行うということは可能です。

  8. 総務省|地方自治制度|広域行政・市町村合併

Javaのバージョン別、テキストファイルを一括で読み込む方法まとめ

前回の記事(Javaのバージョン別、1行ずつファイルを読む方法まとめ)への感想で、このような話がありました。

というわけで、今回はテキストファイルを一括で読み込む方法をまとめました。
(前回と被っている点は省略しているので、まだ読んでない人は先に前回の記事をどうぞ)

Java 1.1, 1.2, 1.3

private static String readString(File file) throws IOException {
    Reader reader = null;
    try {
        reader = new InputStreamReader(new FileInputStream(file), "UTF-8");

        StringBuffer sb = new StringBuffer();
        int len;
        char[] buffer = new char[1024 * 8];
        while ((len = reader.read(buffer)) != -1) {
            sb.append(buffer, 0, len);
        }
        return sb.toString();
    } finally {
        if (reader != null) {
            reader.close();
        }
    }
}

まだこのころはめんどくさいです。

FileInputStream でファイルを読み込み、InputStreamReader で指定した文字コードにデコードするようにしています。
読み込む際は、char[] でバッファして StringBuffer に追加していっています。

ちなみに、char[] でバッファしながら読み込んでいるので、 BufferedReader でラップする必要はありません。

Java 1.4, 1.5, 6

private static String readString(File file) throws IOException {
    CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();

    try (FileInputStream stream = new FileInputStream(file)) {
        FileChannel channel = stream.getChannel();
        ByteBuffer bb = channel.map(MapMode.READ_ONLY, 0, channel.size());

        CharBuffer cb = decoder.decode(bb);

        return cb.toString();
    }
}

ループがなくなって、だいぶ処理がすっきりしました。

このバージョンで追加された Charset クラスから、CharsetDecoder というデコーダを取得できます。
これに FileChannel#map​(FileChannel.MapMode mode, long position, long size) で取得した MappedByteBuffer を渡すことで、ファイルを読み込めます。

MappedByteBuffer はファイルの内容をメモリにマッピングして扱う(メモリマップドファイル)ので、大きなファイルの時に効率的に処理が行われます。
逆に言うと、小さいファイルを読み込む場合は性能が悪いです。
その場合は、今まで通り InputStream で読み込みましょう。

ほとんどのオペレーティング・システムでは、ファイルをメモリーマッピングするほうが、通常のreadメソッドまたはwriteメソッドを使って数十キロバイトのデータの読み込みまたは書込みを行うよりも負荷が大きくなります。 性能を重視するなら、比較的大きめのファイルだけをマッピングすることをお薦めします。 FileChannel (Java SE 15 & JDK 15))

----
ちなみに、この FileChannelmap メソッドを使うやり方はひしだまさんの記事を読んで知りました!
Javaバッファークラスメモ(Hishidama's Java Buffer Memo)

Java 7, 8, 9, 10

private static String readString(Path path) throws IOException {
    byte[] bytes = Files.readAllBytes(path);
    
    return new String(bytes, StandardCharsets.UTF_8);
}

Files クラスが追加され、これに一括でファイルを byte[] として読み込むメソッドが用意されました。
これを使ってファイルを読み込めば、あとは String クラスを new するだけです。

ちなみに、このバージョンでファイルからすべての行を読み取る Files#readAllLines​(Path, Charset) も追加されました。
この戻り値は List<String> です。行ごとに処理をしたい場合はこちらが便利です。

Java 11~

private static String readString(Path path) throws IOException {
    return Files.readString(path, StandardCharsets.UTF_8);
}

ひとつのメソッドでファイルをすべて読み込めるようになりました。 1

このメソッドの実装では StringCoding というインターナルなクラスを使っているので、new String(bytes, StandardCharsets.UTF_8) とするよりも効率的な処理になっています。

まとめ

めんどくさかった処理も、Java のバージョンが上がるごとに簡単に書けるようになっています。
なので、積極的に新しい Java を使っていきましょう!

関連記事


  1. Google で検索して一番上に出てくる JavaDocJava 8 なせいで、この Java 11 で追加されたメソッドを忘れられがちです…。

Javaのバージョン別、1行ずつファイルを読む方法まとめ

Java でファイルを読み込む処理は、バージョンが上がるごとにどんどん簡単に書けるようになっていきました。
今回は、どれだけ簡単になっていったかを Java のバージョンごとにまとめて説明します。

なお、ここでは以下の処理を行うコードをもとにしています。

  • そこそこ大きいテキストファイルを一行ずつ読み込む
  • 文字コードUTF-8

Java 1.1 ~ 1.3

public static void main(String[] args) throws IOException {
    File file = new File(args[0]);
    BufferedReader reader = null;
    try {
        reader = new BufferedReader(
                    new InputStreamReader(
                            new FileInputStream(file)
                            , "UTF-8"));
        
        String line;
        while((line = reader.readLine()) != null) {
            // 処理
        }
    } finally {
        if (reader != null) {
            reader.close();
        }
    }
}

長いですね。

この時代に FileReader というファイルからテキストを読み込むクラスがあるにはあります。
ただ、このクラスでは 問答無用でシステムデフォルトの文字コードが使われてしまいます。
そのため、Windows から Linux に持っていった場合に文字化けが起きる問題がよく起こっていました。

それを避けるためには、文字コードの指定ができる InputStreamReader を使う必要があります。
InputStreamReader 自体ではファイルの読み込みができないので、FileInputStream を使う必要があります。

さらに、一行ずつ読み込むためには BufferedReader を使う必要があります。これ自体はバッファしかしてくれません。

なので、結果として FileInputStreamInputStreamReaderBufferedReader とラップしていく必要があります。

Java 1.4 ~ 1.5

public static void main(String[] args) throws IOException {
    File file = new File(args[0]);
    Charset charset = Charset.forName("UTF-8");

    BufferedReader reader = null;
    try {
        reader = new BufferedReader(
                    new InputStreamReader(
                        new FileInputStream(file), charset));

        String line;
        while ((line = reader.readLine()) != null) {
            // 処理
        }
    } finally {
        if (reader != null) {
            reader.close();
        }
    }
}

Charset クラスができました。
これにより、文字コードを型で表せるようになりました。

ただ、UTF-8 を使う場合には Charset.forName("UTF-8") とする必要がありました。
なので、型安全ではあるけれど、文字列で指定した方が楽という状況でした。

Java 7

public static void main(String[] args) throws IOException {
    Path path = Paths.get(args[0]);
    Charset charset = StandardCharsets.UTF_8;

    try (BufferedReader reader = Files.newBufferedReader(path, charset)){
        String line;
        while ((line = reader.readLine()) != null) {
            // 処理
        }
    }
}

一気にシンプルになりました。

まず、StandardCharsets クラスが追加されました。これにより、わざわざ標準の文字コード1であれば文字列で指定せずに済むようになりました。
また、Files クラスと newBufferedReader(path, charset) メソッドが追加され、いちいちいろんなクラスを new する必要がなくなりました。2

あと、このバージョンで Path クラスが追加されました。
ファイルを読み込むだけだと File クラスと大差ないですが、パスの操作が簡単になりました。

さらに、try-with-resources が追加され、めんどくさかった close 処理をいい感じにやってくれるようになりました。

----
ちなみに…。 このバージョンから まとめて読み込んで List<String> に格納してくれる Files.readAllLines​(Path, Charset) も使えるようになりました。 ただし、これは大きなファイルに使うとメモリを食ってしまうので要注意です。

Java 8 ~ 10

public static void main(String[] args) throws IOException {
    Path path = Paths.get(args[0]);

    try (Stream<String> stream = Files.lines(path)){
        stream.forEach(line -> /* 処理 */);
    }
}

Stream クラスによって、行の読み込みと処理を反復させることができるようになりました。
これのおかげで、1行ずつ処理をしたいという場合にとてもシンプルに書けるようになりました。

また、Files クラスを使う際に、UTF-8 であれば文字コードの指定を省略できるようになりました。
InputStreamReader などは省略時にシステムのデフォルト文字コードが指定されたものとみなされますが、こちらは UTF-8 を指定したものとみなされる という点には注意が必要です。 ただ、最近のテキストファイルが UTF-8 であることを踏まえると、妥当な判断かなと思います。

Java 11 ~

public static void main(String[] args) throws IOException {
    Path path = Path.of(args[0]);

    try (Stream<String> stream = Files.lines(path)){
        stream.forEach(line -> /* 処理 */);
    }
}

Path.of​(String first, String... more) というメソッドが追加されました。
内部の処理は Paths.get(String first, String... more) とまったく同じですが、このおかげで Path クラスを使うにはどうすればいいんだっけと迷わずに済むようになりました。

----
上記のサンプルでは使っていないのですが…。 なんと、このバージョンで FileReader クラスのコンストラクタで Charset が指定できるようになりました。 Java 1.1 のときに欲しかった…。

なお、InputStreamReader クラスのように文字コードを "UTF-8" のような文字列で指定することはできません。 既存のクラスは互換性のために文字列でも Charset でも指定できるようになっていますが、今後追加されるクラスは Charset のみになるようです。

まとめ

Java はファイルを読み込むだけでもなんて面倒くさいんだと言われていました。 でも、それは昔のお話。

今ではとても簡単になっているんだよ、というのが伝われば幸いです。


  1. US-ASCII, ISO-8859-1, UTF-8, UTF-16BE, UTF-16LE, UTF-16。これらは、どの Java 実装でも必ずサポートされています。

  2. Files.newBufferedReader の中で、FileInputStreamInputStreamReaderBufferedReader とラップしてくれています。

JDK 1.0 (Java 1.0.2) を入手したので、GitHub に置いておきました。

JDK 1.0 (Java 1.0.2) を入手したので、GitHub に置いておきました。 https://github.com/YujiSoftware/JDK1.0

なんと、Windows 10 上でも動きます。
あと、src.zip も展開して置いた ので、GitHub 上で読めます。

Java 考古学者のみなさま、ぜひご活用ください!

入手の経緯

前々から Java 1.0 を触ってみたいなーと思っていたのですが、OracleJava Archive にはなぜか Java 1.1 以降しか置いてありませんでした。

ところが、最近になってぐぐってみたら Reddit にこんな書き込みがあるのを発見しました。

On archive.org, I found Java Starter Kit 1.1 + JDK 1.0, which is a disc image of a CD that accompanied a book, and apparently includes JDK 1.0. I haven't check it myself though. It looks like you'll need something that can read an MDF file to view the disc image. Download of Java 1.0.2 : java

この書き込みに沿って、Internet Archive からイメージファイルをダウンロードし、展開したところ無事に Java 1.0 を入手できました。

再配布していいの?

展開したファイルの COPYRIGHT を読むとこんな記載がありました。

Sun grants to you ("Licensee") a non-exclusive, non-transferable license to use the HotJava and Java binary code versions (hereafter, "Binary Software") without fee. Licensee may distribute the Binary Software to third parties provided that the copyright notice and this statement appear on all copies.

Sun は、Hot Java および Java のバイナリコードバージョン(以下、「バイナリソフトウェア」)を無料で使用するための非独占的で譲渡不可能なライセンスをお客様(「ライセンシー」)に付与します。ライセンシーは、著作権表示とこの声明がすべてのコピーに記載されている場合に限り、バイナリソフトウェアを第三者に配布することができます。

JDK1.0/COPYRIGHTon GitHub

ということで、COPYRIGHT を含めれば1再配布しても問題ないようです。


  1. 著作権表示は COPYRIGHT の冒頭に記載されています。

jq の入っていない環境で、JSON をフォーマットする方法

JSON をフォーマットしたいけど、jq が入っていない!ということがよくあります。
そういう時は、RubyPHP を使いましょう!

Ruby の場合

ruby -rjson -e 'puts JSON.pretty_generate(JSON.parse(STDIN.read))'

↓ こんな風に表示されます!

[qiita@example ~]$ echo '{"key": {"format": ["json", "XML"]}}' \
    | ruby -rjson -e 'puts JSON.pretty_generate(JSON.parse(STDIN.read))'
{
  "key": {
    "format": [
      "json",
      "XML"
    ]
  }
}

また、JSON.parse(STDIN.read)["key"] とすることで、特定の項目だけ表示することもできます。

[qiita@example ~]$ echo '{"key": {"format": ["json", "XML"]}}' \
    | ruby -rjson -e 'puts JSON.pretty_generate(JSON.parse(STDIN.read)["key"])'
{
  "format": [
    "json",
    "XML"
  ]
}

PHP の場合

php -r 'echo json_encode(json_decode(stream_get_contents(STDIN)), JSON_PRETTY_PRINT);'

↓ こんな風に表示されます!

[qiita@example ~]$ echo '{"key": {"format": ["json", "XML"]}}' \
    | php -r 'echo json_encode(json_decode(stream_get_contents(STDIN)), JSON_PRETTY_PRINT);'
{
    "key": {
        "format": [
            "json",
            "XML"
        ]
    }
}

また、json_decode(stream_get_contents(STDIN))->key とすることで、特定の項目だけ表示することもできます。

[qiita@example ~]$ echo '{"key": {"format": ["json", "XML"]}}' \
    | php -r 'echo json_encode(json_decode(stream_get_contents(STDIN))->key, JSON_PRETTY_PRINT);'
{
    "format": [
        "json",
        "XML"
    ]
}

M5StickC と MH-Z19B を使って、二酸化炭素濃度計を作りました

ネットの情報をもとに、M5StickC とMH-Z19B (CO2センサー)を使って二酸化炭素濃度計を作りました。 作ったといっても、M5StickC と MH-Z19B をつないで、さくっとプログラミングしただけ。簡単でした。

一年ぐらい運用していますが、なかなか便利です。 P_20200525_095646_vHDR_Auto.jpg

グラフ表示モードも作ったので、こんな風に直近の濃度の変化もわかります。 P_20200525_095559_vHDR_Auto.jpg

ソースコードはこちら https://gist.github.com/YujiSoftware/9274366a93f1ac7f9208bd4abf096527

実装した機能

  • 現在の二酸化炭素濃度を表示
  • 過去1時間ぐらいの二酸化炭素濃度の推移をグラフで表示
  • 1,200ppm 1を超えたときは、LED 点滅してお知らせ
  • 画面表示のオン・オフ

用意したもの

  • M5StickC(1,980円)
  • CO2センサー MH-Z19B2 (2,019円)
  • ジャンプワイヤ

MH-Z19B は AliExpress で買いました。 買うときに、取り付けが簡単かなと思って「MH-Z19B with Cable」を選んだのですが、これはおススメしません。というのも、このピンと M5StickC をつなぐようなケーブルというのはない3らしく、コネクタとコンタクトピンを買ってきてケーブルを自作する羽目になりました。部品が細かくて、とてもめんどくさかったです…。

「MH-Z19B with Pin」ならば、オス-メスのジャンプワイヤ でつなぐだけだと思うので、こちらの方がよさそうです。

作り方

M5StickC と MH-Z19B のピンを以下の組み合わせでつなぎます。

M5StickC MH-Z19B
G36 Tx
G26 Rx
5V Vin
GND GND

あとは、Arduino IDE で冒頭のソースコードを M5StickC に流し込んで完成です。

感想

市販の二酸化炭素計と違って、自分で好きなようにプログラミングできるのが面白いです。

M5StickC なら、いろいろな機能(LED、LCD画面、ボタン、WiFiBluetooth、バッテリー など)が All in One で載っているので、難易度もかなり低いです。 みなさんにも、ぜひおススメです。


  1. 1,200ppm を超えると眠気を感じるといわれています。

  2. 後継機 MH-Z19C が出ているそうです。

  3. マルツ秋葉原本店で聞いて「ない」と言われました。

Java の + 演算子による文字列結合は、どのように処理されているのか

Java 9 以降 JEP 280: Indify String Concatenation に基づき、 + 演算子による文字列結合は以下のようにコンパイルされるように変わりました。

invokedynamic #7,  0
// InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/Object;I)Ljava/lang/String;

(InvokeDynamic を使うように変更された理由は、「Java の + 演算子による文字列結合で、StringBuilder は使われなくなりました。」を参照)

この InvokeDynamic によって、最終的にどのように文字列結合が行われているのでしょうか。

環境

Adopt OpenJDK 15 で調査しました。

openjdk version "15" 2020-09-15
OpenJDK Runtime Environment (build 15+36-1562)
OpenJDK 64-Bit Server VM (build 15+36-1562, mixed mode, sharing)

前提知識

Java 9 以降 JEP 254: Compact Strings に基づき、String クラスの内部で文字を以下のように保持するように変わりました。

  • すべての文字が LATIN1 の範囲内であれば、LATIN1(1文字 = 1byte)
  • (日本語など)LATIN1 の範囲外の文字があれば、UTF-16 (1文字 = 2byte)

これについての詳細は、下記の記事でご確認ください。 Java9 でも String クラスがリファクタリングされていました (JEP 254: Compact Strings 編) - 地平線に行く

処理の概要

invokedynamic #7,  0
// InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/Object;I)Ljava/lang/String;

この部分が最初に実行された時、ブートストラップメソッド StringConcatFactory.makeConcatWithConstants によって、文字列結合を行う匿名メソッドが作られます。 そして、以降は InvokeDynamic の代わりにその匿名メソッドが呼ばれるようになります。

なお、最終的にこの匿名メソッドは呼び元のメソッドにインライン展開されます(たぶん…)。

匿名メソッドの処理

匿名メソッドは、以下の処理を行います。

  1. double, float, Object 型の変数を文字列に変換
  2. (最終的に出来上がる)文字数と文字コードを調べる
  3. 文字を格納するのに必要な長さの byte 配列を作成
  4. byte 配列に、文字を詰る
  5. byte 配列を文字列にする

つまり、文字列結合というのは、最終的に 文字を byte 配列に詰める処理 になります。

具体例

下記のソースコードを例に、具体的に見ていきます。

public static String call(Object foo, int bar) {
    return "arg0: " + foo + ", arg1: " + bar;
}

このメソッドをコンパイルしたクラスファイルを、 javap で逆アセンブルします。 すると、以下のように invokedynamic が使われていることが分かります。

{
  public static java.lang.String call(java.lang.Object, int);
    descriptor: (Ljava/lang/Object;I)Ljava/lang/String;
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: invokedynamic #7,  0              // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/Object;I)Ljava/lang/String;
         7: areturn
}
BootstrapMethods:
  0: #26 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #32 arg0: \u0001, arg1: \u0001

この invokedynamic は、ブートストラップメソッドとして、 StringConcatFactory#makeConcatWithConstants(MethodHandles.Lookup lookup, String name, MethodType concatType, String recipe, Object... constants) が指定されています。

ここで重要なのは、引数の concatType, recipe です。 今回は、以下の値が渡されています。

  • concatType
    • MethodType という型
    • 作成する匿名メソッドのシグネチャの情報が保持されている
      • この中の引数の情報 parameterArray に、結合する変数の型情報が保持されている
        • 今回の場合 [ java.lang.Object.class, int.class ]
  • recipe
    • 変数に置換する箇所を \0001 に、それ以外を結合した文字列
    • 今回の場合、 "arg0: \u0001, arg1: \u0001"

このブートストラップメソッドによって、以下の動的メソッドが作られます。

String anonymous(Object foo, int bar) {
    // 引数を文字列に変換
    String t4 = StringConcatHelper.stringOf(foo);

    // 文字数と文字コードを調べる
    long t6 = StringConcatHelper.mix(14L, bar);
    long t8 = StringConcatHelper.mix(t6, t4);

    // 必要なサイズの byte 配列を作成
    byte[] t10 = StringConcatHelper.newArray(t8);

    // 配列に文字列を詰める
    long t12 = StringConcatHelper.prepend(t8, t10, foo, ", arg1: ");
    long t14 = StringConcatHelper.prepend(t12, t10, t4, "arg0: ");

    // 配列を文字列にする
    return StringConcatHelper.newString(t10, t14);
}

これを細かく見ていきましょう。

文字列への変換

結合する変数が double, float, Object 型の場合、以下のメソッドを呼び出して文字列に変換します。

  • doubleString.valueOf(double)
  • floatString.valueOf(float)
  • ObjectStringConcatHelper.stringOf(Object)

なお、boolean, char, byte, short, int, long の場合、あとで直接 byte 配列に格納するため、ここで文字列に変換しません。

今回の例では、引数の Object 型の変数 fooStringConcatHelper.stringOf(Object) メソッドで文字列に変換しています。

String t4 = StringConcatHelper.stringOf(foo);  // args: Object

この StringConcatHelper.stringOf(Object) の実装はこのようになっています。

/**
 * We need some additional conversion for Objects in general, because
 * {@code String.valueOf(Object)} may return null. String conversion rules
 * in Java state we need to produce "null" String in this case, so we
 * provide a customized version that deals with this problematic corner case.
 */
static String stringOf(Object value) {
    String s;
    return (value == null || (s = value.toString()) == null) ? "null" : s;
}

引数が null ならば "null" という文字列を、引数が null でなければ value.toString() した文字列を返しています。

文字数と文字コードを調べる

次に、結合する各変数の文字数と文字コードを、StringConcatHelper#mix メソッドを使って後ろから順に調べていきます。 今回だと、bar → foo の順です。

// MEMO:
//   bar: 結合する int 型の変数
//   t4: 結合する Object 型の変数を文字列にしたもの (`foo.toString()`) 
long t6 = StringConcatHelper.mix(14L, bar);  // args: long, int
long t8 = StringConcatHelper.mix(t6, t4);  // args: long, String

StringConcatHelper#mix メソッドは、戻り値として「第一引数の値 + 第二引数で渡した変数の文字数(※)」を返します。 それを、次の第一引数に渡します。 (※ 正確には、後述の lengthCoder という、文字数と文字コードの両方を保持した値です。この点は、後述します)

なお、最初は recipe から \u0001 を取り除いた長さを渡します。今回は recipe = "arg0: , arg1: " なので、14 です。 匿名メソッドを作る際に計算しているので、ここでは定数になっています。

StringConcatHelper#mix メソッドの実装は、第二引数で渡す変数の型によって若干処理が異なります。 今回は、上記の例で使われている int 型と String1の処理をそれぞれ見ていきましょう。

int 型の場合

StringConcatHelper.mix(long, int) の実装はこのようになっています。

/**
 * Mix value length and coder into current length and coder.
 */
static long mix(long lengthCoder, int value) {
    return checkOverflow(lengthCoder + Integer.stringSize(value));
}

Integer.stringSize(int) というパッケージプライベートなメソッドを呼び出し、value を10進数の文字列にした時の長さを計算しています。

これを、第一引数と足した値を戻り値としています。 ただし、この値が int の範囲を超えていたら OutOfMemoryError("Overflow: String length out of range"); をスローします。

文字列型の場合

StringConcatHelper.mix(long, String) の実装はこのようになっています。

/**
 * Mix value length and coder into current length and coder.
 */
static long mix(long lengthCoder, String value) {
    lengthCoder += value.length();
    if (value.coder() == String.UTF16) {
        lengthCoder |= UTF16;
    }
    return checkOverflow(lengthCoder);
}

まず、文字列の長さを String#length() で取得し、lengthCoder に足しています。

次に、引数の文字列の文字コードを確認します。 もし文字コードUTF-16 ならば、lengthCoder の32ビット目を 1 にします。 このように「文字列の長さ」として使うのは long 型の下位32ビットだけで、32ビット目2はこのように文字コードUTF-16 かどうかのフラグとして使います。

そうしてできた値を、戻り値としています。 (オーバーフローの処理は先ほどと同様です)

必要なサイズの byte 配列を作成

// MEMO:
//   t8: さきほどの戻り値 (`lengthCoder`)
byte[] t10 = StringConcatHelper.newArray(t8);  // args: long

ここまでで確定した文字数と文字コードをもとに、byte 配列を作成しています。

この StringConcatHelper.newArray(long) の実装はこのようになっています。

/**
 * Allocates an uninitialized byte array based on the length and coder information
 * in indexCoder
 */
@ForceInline
static byte[] newArray(long indexCoder) {
    byte coder = (byte)(indexCoder >> 32);
    int index = (int)indexCoder;
    return (byte[]) UNSAFE.allocateUninitializedArray(byte.class, index << coder);
}

必要な byte 配列の長さは、文字コードによって異なります。

  • LATIN なら 1文字 = 1byte なので、必要な byte 配列の長さ = 文字数
  • UTF-16 なら 1文字 = 2byte なので、必要な byte 配列の長さ = 文字数 × 2

その計算をやっているのが index << coder です。 coder には、LATIN1 なら 0UTF-16 なら 1 が格納されているので、UTF-16 の時だけ1ビット左シフト、つまり値が倍になるという仕組みです。

なお、配列の作成は new byte[length] ではなく、UNSAFE.allocateUninitializedArray(Class<?>, int) で行っています。 なぜかというと、new byte[length] だと配列を作った後に要素をゼロで初期化する処理を行ってしてしまうためです。 今回の処理では 必ず あとですべての要素に書き込むので、ゼロ初期化するのは無駄です。 そのため、アンセーフなメソッドを使ってゼロ初期化を省いて配列を作っています。

配列に文字列を詰める

出来上がった配列に、結合する各変数を StringConcatHelper#prepend メソッドを使って後ろから順に詰めていきます。 今回だと、bar → foo の順です。

// MEMO:
//   t8: さきほどの戻り値 (`lengthCoder`)
//   t10: 文字列を詰め込む byte 配列
//   bar: 結合する int 型の変数
//   t4: 結合する Object 型の変数を文字列にしたもの (`foo.toString()`) 
long t12 = StringConcatHelper.prepend(t8, t10, bar, ", arg1: ");  // args: long, byte[], int, String
long t14 = StringConcatHelper.prepend(t12, t10, t4, "arg0: ");  // args: long, byte[], String, String

StringConcatHelper#prepend メソッドは、第二引数で渡された byte 配列に第一引数で指定された位置のひとつ前から文字を詰め込みします。 詰め込む内容は、第三引数で渡された変数、および第四引数で渡された文字列です。

このように、結合する変数と\u0001 で区切った recipe をまとめて渡すことで、メソッドの呼び出し回数を削減しています。

StringConcatHelper#prepend メソッドの実装は、第三引数で渡す変数の型によって若干処理が異なります。 今回は、上記の例で使われている int 型と String 型[^primitive]の処理をそれぞれ見ていきましょう。

int 型の場合

StringConcatHelper.prepend(long, byte[], int, String) の実装はこのようになっています。

/**
 * Prepends constant and the stringly representation of value into buffer,
 * given the coder and final index. Index is measured in chars, not in bytes!
 */
static long prepend(long indexCoder, byte[] buf, int value, String prefix) {
    indexCoder = prepend(indexCoder, buf, value);
    if (prefix != null) indexCoder = prepend(indexCoder, buf, prefix);
    return indexCoder;
}

この prepend(long, byte[], int) の実装はこのようになっています。

/**
 * Prepends the stringly representation of integer value into buffer,
 * given the coder and final index. Index is measured in chars, not in bytes!
 */
private static long prepend(long indexCoder, byte[] buf, int value) {
    if (indexCoder < UTF16) {
        return Integer.getChars(value, (int)indexCoder, buf);
    } else {
        return StringUTF16.getChars(value, (int)indexCoder, buf) | UTF16;
    }
}

Integer.getChars または StringUTF16.getChars で、byte配列に変数の値を詰めています。 詳しい解説は省きますが、これらの中では面白い実装によって int 型から10進数文字列に変換しています。 → Tech Tips: Integer.getCharsが面白い

文字列型の場合

StringConcatHelper.prepend(long, byte[], String, String) の実装はこのようになっています。

/**
 * Prepends constant and the stringly representation of value into buffer,
 * given the coder and final index. Index is measured in chars, not in bytes!
 */
static long prepend(long indexCoder, byte[] buf, String value, String prefix) {
    indexCoder = prepend(indexCoder, buf, value);
    if (prefix != null) indexCoder = prepend(indexCoder, buf, prefix);
    return indexCoder;
}

この prepend(long, byte[], String) の実装はこのようになっています。

/**
 * Prepends the stringly representation of String value into buffer,
 * given the coder and final index. Index is measured in chars, not in bytes!
 */
private static long prepend(long indexCoder, byte[] buf, String value) {
    indexCoder -= value.length();
    if (indexCoder < UTF16) {
        value.getBytes(buf, (int)indexCoder, String.LATIN1);
    } else {
        value.getBytes(buf, (int)indexCoder, String.UTF16);
    }
    return indexCoder;
}

String#getBytes メソッドを使って、byte配列に変数の値を詰めています。

配列を文字列にする

// MEMO:
//   t10: 文字列を詰め込んだ byte 配列
//   t14: 文字列の先頭を指した状態の `indexCoder`
return StringConcatHelper.newString(t10, t14);

この StringConcatHelper.newString(byte[], long) の実装はこのようになっています。

/**
 * Instantiates the String with given buffer and coder
 */
static String newString(byte[] buf, long indexCoder) {
    // Use the private, non-copying constructor (unsafe!)
    if (indexCoder == LATIN1) {
        return new String(buf, String.LATIN1);
    } else if (indexCoder == UTF16) {
        return new String(buf, String.UTF16);
    } else {
        throw new InternalError("Storage is not completely initialized, " + (int)indexCoder + " bytes left");
    }
}

このnew String(byte[], byte) の実装はこのようになっています。

/*
 * Package private constructor which shares value array for speed.
 */
String(byte[] value, byte coder) {
    this.value = value;
    this.coder = coder;
}

引数の byte 配列はコピーすることなく、そのままフィールドに格納しています。3

落穂拾い

以下、雑多な点を記載します。

最適化

以下の量帆の条件を満たす場合、処理が最適化されます。

  • 文字列と1つの変数を結合する、または 2つの変数を結合する
  • 変数はプリミティブ型ではない
public String optimized(String foo) {
    // 文字列と1つの変数を結合する
    return "Hello " + foo;
}

public String optimized(String foo, String bar) {
    // 2つの変数を結合する
    return foo + bar;
}

この場合、StringConcatHelper#simpleConcat(Object, Object) であらかじめ用意されたパターンが使われます。 (出来上がる匿名メソッドの処理の流れは、通常の場合と同じ)

    /**
     * Perform a simple concatenation between two objects. Added for startup
     * performance, but also demonstrates the code that would be emitted by
     * {@code java.lang.invoke.StringConcatFactory$MethodHandleInlineCopyStrategy}
     * for two Object arguments.
     */
    @ForceInline
    static String simpleConcat(Object first, Object second) {
        String s1 = stringOf(first);
        String s2 = stringOf(second);
        // start "mixing" in length and coder or arguments, order is not
        // important
        long indexCoder = mix(initialCoder(), s1);
        indexCoder = mix(indexCoder, s2);
        byte[] buf = newArray(indexCoder);
        // prepend each argument in reverse order, since we prepending
        // from the end of the byte array
        indexCoder = prepend(indexCoder, buf, s2);
        indexCoder = prepend(indexCoder, buf, s1);
        return newString(buf, indexCoder);
    }

文字列に \0001 を含む場合

上記した通り、 StringConcatFactory#makeConcatWithConstants(MethodHandles.Lookup lookup, String name, MethodType concatType, String recipe, Object... constants)recipe に、結合する文字列のフォーマットを渡します。 このフォーマットは、変数を埋め込む箇所に \u0001 を指定するというものでした。

しかし、下記のように文字列の中に \u0001 が使われている事がありえます。

"Hello\u0001 " + foo + "!";

この場合は、該当の文字列を \u0002 に置き換えて、 \u0001 を含む文字列を引数の constants に格納します。

  • recipe: "\u0002\u0001!"
  • constants: [ "Hello\u0001 " ]

これを受け取った StringConcatFactory は、recipe をパースする際に \u0002 となっている個所に constants の値を埋め込み、フォーマットを再構築します。

引数の上限

結合する変数が200個以上の場合、処理が分割されます。 例えば、300個の変数を文字列結合する場合は以下の順で処理されます。

  1. 199個の変数を結合
  2. 101個の変数を結合
  3. 1 と 2 で出来上がった文字列を結合

この点は、JavaDoc にも明記されています。

APIのノート: JVMの制限があります(クラス・ファイル構造制約): 255個以上のスロットで呼び出すことはできません。 これは、ブートストラップ・メソッドに渡すことができる静的および動的引数の数を制限します。 MethodHandleコンビネータを使用する可能性のある連結ストラテジがあるので、一時的な結果をキャプチャするためにパラメータ・リストに空のスロットをいくつか予約する必要があります。 これは、このファクトリのブートストラップ・メソッドが200を超える引数スロットを受け入れない理由です。 連結で200以上の引数スロットを必要とするユーザーは、大きな連結をより小さな式で分割することが予想されます。 StringConcatFactory (Java SE 15 & JDK 15)

コンパイルオプション(実装の選択)

コンパイル時に、オプション-XDstringConcat=inline を付けると、過去の StringBuilder を使った実装を使うようにもコンパイルできます。

ちなみに、このオプションには以下の値を指定できます。

処理内容
inline StringBuilder で処理する。
indy InvokeDynamic で動的に処理を作る。
ブートストラップメソッドとしてStringConcatHelper#makeConcat を使う。
indyWithConstants InvokeDynamic で動的に処理を作る。
ブートストラップメソッドとしてStringConcatHelper#makeConcatWithConstants を使う。

デフォルトは、indyWithConstants です。

indy を指定した場合も、indyWithConstants とほぼ同じ処理が行われます。 違いは recipe を動的に作るかどうかです。最終的には makeConcatWithConstants が呼ばれます。 → makeConcat​(MethodHandles.Lookup, String, MethodType) の JavaDoc)

public static CallSite makeConcat(MethodHandles.Lookup lookup,
                                  String name,
                                  MethodType concatType) throws StringConcatException {
    // This bootstrap method is unlikely to be used in practice,
    // avoid optimizing it at the expense of makeConcatWithConstants

    // Mock the recipe to reuse the concat generator code
    String recipe = "\u0001".repeat(concatType.parameterCount());
    return makeConcatWithConstants(lookup, name, concatType, recipe);
}

実行時オプション(作成する匿名メソッドの選択)

Java 9 ~ Java 14 まで、Java の実行時に VM オプション –D:java.lang.invoke.stringConcat=(ストラテジー名) を付けることで、上記以外の実装も選ぶことがでました。

ストラテジー 概要​
BC_SB​ StringBuilderを使ったバイトコードを生成する(既存と同様)​
BC_SB_SIZED​ StringBuilderを使ったバイトコードを生成する。​
加えて、必要な配列のサイズを「推定」する。​
BC_SB_SIZED_EXACT​ StringBuilderを使ったバイトコードを生成する。​​
加えて、必要な配列のサイズを「正確に計算」する。​
MH_SB_SIZED​ MethodHandleベースのジェネレータを使って、最終的にStringBuilder を呼び出す。​​
加えて、必要な配列のサイズを「推定」する。​
MH_SB_SIZED_EXACT​ MethodHandleベースのジェネレータを使って、最終的にStringBuilder を呼び出す。​​
加えて、必要な配列のサイズを「正確に計算」する。​
MH_INLINE_SIZED_EXACT​ MethodHandleベースのジェネレータを使って、独自にbyte配列を構築する。​​
必要な配列のサイズを「正確に計算」する。​

デフォルトは、MH_INLINE_SIZED_EXACT​ です。 ただし、このオプションは Java 15 で削除されました。

参考資料

ここまで読んだうえで、 StringConcatFactory の JavaDocソースコードを読めば、文字列結合について詳しくなれる… ハズ!


  1. float, double, Object 型の変数は最初に文字列に変換しているので、ここでは文字列型の処理に入ります。

  2. 33ビット目では?と思われるかもしれませんが、0-indexed (最初を0ビットとして数える)ので32ビット目になります。

  3. これはアンセーフな処理です。なぜなら、文字列の配列を外側からいじれるようになってしまい、String が不変でなくなってしまうためです。そのため、通常であれば配列をコピーしてから格納するのですが、今回は配列をいじることがないと保証できるため、コピーをしないアンセーフなコンストラクタを使っています。