Java新機能の文字列補間 - JEP 430 String Templates(Preview)

本記事はプレビュー公開時のものです。正式リリース版については以下を参照してください。

blog1.mammb.com

blog1.mammb.com


はじめに

JEP 430 String Templates がプレビュー公開されました。 ようやく Java でも文字列補完(string interpolation)が使えるようになる予定です。

JavaScriptでは ${x} plus ${y} equals ${x + y} のように書く文字列補完ですが、JEP 430 では、単なる文字列補完を超えた、String Templates として提案されています。

String Templates では、埋め込み式のエスケープやバリデート、テンプレートからオブジェクトを生成といったことが可能です。 プレビュー段階の JEP ではありますが、どのようになるのかを予習しておきましょう。


文字列テンプレート

文字列補完の無い Java で、式の結果を含む文字列を構築するには、以下のように書く必要があります。

String s = x + " plus " + y + " equals " + (x + y);

バインド変数として埋め込む場合は以下のようになります。

String s = String.format("%2$d plus %1$d equals %3$d", x, y, x + y);
String t = "%2$d plus %1$d equals %3$d".formatted(x, y, x + y);

いずれの場合でも、可読性に難があります。

文字列テンプレートを使えば以下のように書くことができます。

int x = 10, y = 20;
String s = STR."\{x} plus \{y} equals \{x + y}";

以下のように、より複雑な式を埋め込むこともできます(式の中で、" をエスケープ無しで使えることに注意してください)。

File file = new File(filePath);
String msg = STR."The file \{filePath} \{file.exists() ? "does" : "does not"} exist";

式は複数行に跨ぐことも可能です。

String time = STR."The time is \{
    // The java.time.format package is very useful
    DateTimeFormatter
      .ofPattern("HH:mm:ss")
      .format(LocalTime.now())
} right now";

さらに、Java 15 で導入されたテキストブロックも同じように利用できます。

String title = "My Web Page";
String text  = "Hello, world";
String html = STR."""
        <html>
          <head>
            <title>\{title}</title>
          </head>
          <body>
            <p>\{text}</p>
          </body>
        </html>
        """;

STR. としてテンプレートプロセッサを指定し、\{x + y} のようにテンプレート式を記載します。他の言語の文字列補間と比べると、多少野暮ったい感じにはなりますが、その分より強力な機能が提供されます(STR インスタンスは、シングルトン・インスタンスによるステートレス補間を行うので、大文字のフィールド名になっています)。

テンプレート式(\{x + y})は、Javaプログラミング言語における新しい種類の式で、文字列テンプレートに式の結果を埋め込むために使用します。

STR は、Javaプラットフォームで定義されたテンプレートプロセッサで、他に FMTRAW などが提供されます。

STR は、テンプレートに埋め込まれた各式を、文字列化された式の結果で置き換えます。一般的な文字列補完の用途で使うことができます。

FMT は、STR の文字列補完に加え、埋め込み式に書式指定子適用することができます。表示幅を定義したり、小数点表記を指定したりすることができます。

RAW は、StringTemplate オブジェクトを生成するだけの標準的なテンプレートプロセッサです。カスタマイズされたテンプレートプロセッサを自作する場合などで利用します。


StringTemplate

文字列テンプレートは、StringTemplate インターフェースとして定義されています。

package java.lang;
public interface StringTemplate {
    // ...
    @FunctionalInterface
    public interface Processor<R, E extends Throwable> {
        R process(StringTemplate st) throws E;
    }
    // ...
}

StringTemplate.Processor がテンプレートプロセッサの関数インターフェースで、process というメソッドで、StringTemplate を引数に取り、R 型の処理後の値を生成します。 STR の場合は、処理後の値として String を生成することになります。

つまり、文字列補完を行う以下の STR テンプレートプロセッサは、

String info = STR."My name is \{name}";

以下と同等になります。

StringTemplate st = RAW."My name is \{name}";
String info = STR.process(st);

RAW は、StringTemplate のインスタンスを生成するテンプレートプロセッサです。 Javaコンパイラは文字列テンプレートを、自動的に StringTemplate に変換します。

StringTemplate には、fragments()values() というメソッドが定義されています。 以下のコードがあれば、

int x = 10, y = 20;
StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";

StringTemplate は以下の状態になります。

StringTemplate{ fragments = [ "", " plus ", " equals ", "" ], values = [10, 20, 30] }

fragmentsStringTemplate のインスタンス生成後は不変ですが、values は各評価毎で新たに計算されたものとなります。

fragments()values() を使い、文字列補間を行うテンプレートプロセッサを簡単に自作することができます。

var INTER = StringTemplate.Processor.of((StringTemplate st) -> {
    StringBuilder sb = new StringBuilder();
    Iterator<String> fragIter = st.fragments().iterator();
    for (Object value : st.values()) {
        sb.append(fragIter.next());
        sb.append(value);
    }
    sb.append(fragIter.next());
    return sb.toString();
});

int x = 10, y = 20;
String s = INTER."\{x} plus \{y} equals \{x + y}";
// -> 10 plus 20 equals 30

事前提供されているユーティリティメソッド StringTemplate::interpolate は、上記と同様のことを行うため、以下のように書いても同等です。

var INTER = StringTemplate.Processor.of(StringTemplate::interpolate);


FMT テンプレートプロセッサ

FMT は、補間を行う点では STR と同じですが、埋め込み式の左側に現れる書式指定子を解釈することができます。 フォーマット指定子は、java.util.Formatterで定義されているものと同じです。

以下はゾーンテーブルの例で、テンプレートの書式指定子によってフォーマッティングしています。

record Rectangle(String name, double width, double height) {
    double area() { return width * height; }
}

Rectangle[] zone = new Rectangle[] {
    new Rectangle("Alfa", 17.8, 31.4),
    new Rectangle("Bravo", 9.6, 12.4),
    new Rectangle("Charlie", 7.1, 11.23),
};

String table = FMT."""
    Description     Width    Height     Area
    %-12s\{zone[0].name}  %7.2f\{zone[0].width}  %7.2f\{zone[0].height}     %7.2f\{zone[0].area()}
    %-12s\{zone[1].name}  %7.2f\{zone[1].width}  %7.2f\{zone[1].height}     %7.2f\{zone[1].area()}
    %-12s\{zone[2].name}  %7.2f\{zone[2].width}  %7.2f\{zone[2].height}     %7.2f\{zone[2].area()}
    \{" ".repeat(28)} Total %7.2f\{zone[0].area() + zone[1].area() + zone[2].area()}
    """;

以下のような整形された文字列を得ることができます。

 Description     Width    Height     Area
 Alfa            17.80    31.40      558.92
 Bravo            9.60    12.40      119.04
 Charlie          7.10    11.23       79.73
                              Total  757.69


テンプレートプロセッサの自作

JSON オブジェクトを、入力値の検証付きで生成するテンプレートプロセッサを考えます。

String name    = "Joan Smith";
String phone   = "555-123-4567";
String address = "1 Maple Drive, Anytown";
try {
    JSONObject doc = JSON_VALIDATE."""
        {
            "name":    \{name},
            "phone":   \{phone},
            "address": \{address}
        };
        """;
} catch (JSONException ex) {
    ...
}

StringTemplate を受け取り、values の入力チェックを行い、JSONオブジェクトのインスタンスを返すテンプレートプロセッサは、以下のように書くことができます。

StringTemplate.Processor<JSONObject, JSONException> JSON_VALIDATE =
    (StringTemplate st) -> {
        String quote = "\"";
        List<Object> filtered = new ArrayList<>();
        for (Object value : st.values()) {
            if (value instanceof String str) {
                if (str.contains(quote)) {
                    throw new JSONException("Injection vulnerability");
                }
                filtered.add(quote + str + quote);
            } else if (value instanceof Number ||
                       value instanceof Boolean) {
                filtered.add(value);
            } else {
                throw new JSONException("Invalid value type");
            }
        }
        String jsonSource =
            StringTemplate.interpolate(st.fragments(), filtered);
        return new JSONObject(jsonSource);
    };


JDBC接続から ResultSet を取得することを考えます。

以下のようなテンプレートプロセッサを用意すれば、

record QueryProcessor(Connection conn)
  implements StringTemplate.Processor<ResultSet, SQLException> {

    public ResultSet process(StringTemplate st) throws SQLException {
        // 1. Replace StringTemplate placeholders with PreparedStatement placeholders
        String query = String.join("?", st.fragments());

        // 2. Create the PreparedStatement on the connection
        PreparedStatement ps = conn.prepareStatement(query);

        // 3. Set parameters of the PreparedStatement
        int index = 1;
        for (Object value : st.values()) {
            switch (value) {
                case Integer i -> ps.setInt(index++, i);
                case Float f   -> ps.setFloat(index++, f);
                case Double d  -> ps.setDouble(index++, d);
                case Boolean b -> ps.setBoolean(index++, b);
                default        -> ps.setString(index++, String.valueOf(value));
            }
        }

        // 4. Execute the PreparedStatement, returning a ResultSet
        return ps.executeQuery();
    }
}

以下のようにしてSQLを発行できます。

StringTemplate.Processor<ResultSet, SQLException> DB = new QueryProcessor(conn);

ResultSet rs = DB."SELECT * FROM Person p WHERE p.last_name = \{name}";

ここで注目したいのが、以下のように文字列連結をした場合の、SQLインジェクションのリスクを回避できている ということです。

String query = "SELECT * FROM Person p WHERE p.last_name = '" + name + "'";
ResultSet rs = conn.createStatement().executeQuery(query);


さらに、ResourceBundleからメッセージを構築するテンプレートプロセッサも考えられます。

record LocalizationProcessor(Locale locale)
  implements StringTemplate.Processor<String, RuntimeException> {

    public String process(StringTemplate st) {
        ResourceBundle resource = ResourceBundle.getBundle("resources", locale);
        String stencil = String.join("_", st.fragments());
        String msgFormat = resource.getString(stencil.replace(' ', '.'));
        return MessageFormat.format(msgFormat, st.values().toArray());
    }
}

resources_jp.properties に以下のメッセージを定義しておけき、

no.suitable._.found.for._(_)={1}に適切な{0}が見つかりません({2})

日本語ロケールで使用できます。

var LOCALIZE = new LocalizationProcessor(new Locale("jp"));
var symbolKind = "field", name = "tax", type = "double";

LOCALIZE."no suitable \{symbolKind} found for \{name}(\{type})");
taxに適切なfieldが見つかりません(double)


まとめ

JEP 430 String Templates としてプレビューとなっている文字列テンプレートを見てきました。

文字列補間は、ほとんどの近代言語に存在しますし、HTMLやJSON、SQLといった言語間I/Fとして、テンプレートプロセッサは強力なツールとなるので、早いところ導入してほしいですね。