X

A blog about Oracle Technology Network Japan

Javaにレコードが登場

Guest Author

※本記事は、Ben Evansによる"Records Come to Java"を翻訳したものです。


Java 14のデータ・レコードがコーディングにもたらす変革を初解説

 

著者:Ben Evans

2020年1月10日

 

本記事では、Javaにおけるレコードの概要について紹介します。レコードはJavaクラスの新しい形態で、以下の目的で設計されています。

  • データのみを組み合わせた構造をモデリングするためのファーストクラスな手段を提供する

  • Javaの型システムに生じる可能性があるギャップを解消する

  • 一般的なプログラミング・パターンに言語レベルの構文を提供する

  • クラスの定型挿入文を減らす

現在、レコード機能は活発に開発が行われており、Java 14のプレビュー機能として登場する予定です(2020年3月リリース予定)。本記事の内容を活用するためには、Javaプログラミングについてのかなりの知識と、プログラミング言語の設計や実装についての興味が必要です。


それでは、Javaのレコードとは何かという基本的な考え方の説明から始めます。


Javaのレコードとは


Javaに関して特によく耳にする不満の1つは、便利なクラスを作るためには大量のコードを書く必要があるという点です。以下に挙げるものは、特に頻繁に書くことになります。

  • toString()
  • hashCode()とequals()
  • getterメソッド
  • パブリック・コンストラクタ

簡単なドメイン・クラスの場合、こういったメソッドは反復的で退屈であることが一般的です。また、機械的に簡単に生成できるタイプのものでもあります(多くの場合、この機能はIDEで提供されています)。それにもかかわらず、現時点において言語自体では上記のようなメソッドを生成する方法を一切提供していません。


このギャップはフラストレーションです。実際、他人のコードを読むときよりもひどいかもしれません。たとえば、コードの作成者が、IDEで生成され、クラスのすべてのフィールドを対象としたhashCode()およびequals()を使っているように見えても、どうすれば実装のすべての行をチェックせずにそのように確信できるでしょうか。また、リファクタリングの際にフィールドが追加され、メソッドが再生成されなかった場合はどうなるでしょうか。


レコードの目的は、Java言語の構文を拡張し、あるクラスが「フィールドの集合であり、フィールドの集合でしかなく、フィールドの集合以外の何者でもない」ことを宣言できるようにすることです。クラスに対してその宣言を行うことにより、コンパイラですべてのメソッドが自動作成され、すべてのフィールドがhashCode()などのメソッドに含まれるようにすることができます。

 

レコードを使わない場合の例

本記事では、レコードを説明するためのサンプル・ドメインとして、外国為替(FX)取引を使用します。レコードを使ってドメインのモデリングを改善し、結果として、冗長性が減少してクリーンでシンプルになったコードを得ることができる方法を紹介します。

なお、取引を完全に実現した本番グレードのシステムについて、短い記事で説明するのは不可能であるため、いくつかの基本的な側面に注目することにします。

FX取引の際に注文を行う方法について考えてみます。基本的な注文のタイプは、以下から構成されると考えることができます。

  • 売買の単位を表す数(100万通貨単位)
  • 「サイド」、すなわち買う側か売る側か(通常はそれぞれ「BID」、「ASK」と呼ばれます)
  • 交換する通貨(「通貨ペア」)
  • 注文を行った時刻
  • 注文がタイムアウトするまでの有効期間(「TTL」(Time To Live:生存期間))


したがって、1ポンドあたり1.25ドルで100万ポンドを売って米ドルにしたい場合、FX取引の業界用語では「レート1.25ドルでGBP/USDを100万買う」と言います。トレーダーは注文の作成時刻(通常は現在時刻)と注文の有効期間(通常は1秒以下)も示します。

Javaでは、次のようなドメイン・クラスを宣言することになるでしょう(現在のクラスでこの宣言が必要なことを強調するため、「古典的」という意味を持つ「Classic」という名前にしています)。

public final class FXOrderClassic {
    private final int units;
    private final CurrencyPair pair;
    private final Side side;
    private final double price;
    private final LocalDateTime sentAt;
    private final int ttl;

    public FXOrderClassic(int units, 
               CurrencyPair pair, 
               Side side, 
               double price, 
               LocalDateTime sentAt, 
               int ttl) {
        this.units = units;
        this.pair = pair; // CurrencyPairはシンプルなenum
        this.side = side; // Sideはシンプルなenum
        this.price = price;
        this.sentAt = sentAt;
        this.ttl = ttl;
    }

    public int units() {
        return units;
    }

    public CurrencyPair pair() {
        return pair;
    }

    public Side side() {
        return side;
    }

    public double price() { return price; }

    public LocalDateTime sentAt() {
        return sentAt;
    }

    public int ttl() {
        return ttl;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) 
            return false;

        FXOrderClassic that = (FXOrderClassic) o;

        if (units != that.units) return false;
        if (Double.compare(that.price, price) != 0) 
            return false;
        if (ttl != that.ttl) return false;
        if (pair != that.pair) return false;
        if (side != that.side) return false;
        return sentAt != null ? 
            sentAt.equals(that.sentAt) : that.sentAt == null;
    }

    @Override
    public int hashCode() {
        int result;
        long temp;
        result = units;
        result = 31 * result + 
                   (pair != null ? pair.hashCode() : 0);
        result = 31 * result + 
                   (side != null ? side.hashCode() : 0);
        temp = Double.doubleToLongBits(price);
        result = 31 * result + 
                   (int) (temp ^ (temp >>> 32));
        result = 31 * result + 
                   (sentAt != null ? sentAt.hashCode() : 0);
        result = 31 * result + ttl;
        return result;
    }

    @Override
    public String toString() {
        return "FXOrderClassic{" +
                "units=" + units +
                ", pair=" + pair +
                ", side=" + side +
                ", price=" + price +
                ", sentAt=" + sentAt +
                ", ttl=" + ttl +
                '}';
    }
}

 

注文は次のようにして作成できます。

var order = new FXOrderClassic(1, 
                    CurrencyPair.GBPUSD, 
                    Side.Bid, 1.25, 
                    LocalDateTime.now(), 1000);

 

しかし、このクラスを宣言しているコードのうち、実際に必要なのはどれほどでしょうか。現在のバージョンのJavaでは、フィールドだけを宣言し、IDEを使ってすべてのメソッドを自動生成している開発者がほとんどではないでしょうか。この状況がレコードによってどのように改善されるかを見ていきます。

補足ですが、Javaにおいてデータの組合せを表現する手段は、クラスの定義によって行うこと以外、何も提供されていません。そのため、「フィールドのみ」を含む型がクラスにしかなり得ないことは明らかです。

 

レコードを使った場合の例

新しい概念であるレコード・クラス(通常は、単にレコードと呼びます)は、不変(通常の「浅い」Java的な意味です)で透過的なキャリア(入れもの)であり、レコード・コンポーネントと呼ばれる、決まった個数の値が格納されます。各コンポーネントは、提供された値と、その値を取得するためのアクセッサ・メソッドを保持するfinalフィールドになります。フィールドの名前とアクセッサの名前は、コンポーネントの名前に対応します。

フィールドのリストは、そのレコードの状態記述です。クラスでは、フィールドx、コンストラクタ引数x、アクセッサx()の間に何の関係もないことがあります。しかし、レコードでは、定義上、同じものを表すことになります。つまり、レコードとは、レコードの状態を表すものです。

レコード・クラスの新しいインスタンスを作れるように、カノニカル・コンストラクタと呼ばれるコンストラクタも生成されます。このコンストラクタのパラメータ・リストは、宣言されている状態記述に厳密に一致します。

Java言語(Java 14のプレビュー機能の時点)では、レコードを宣言するための簡潔な構文が提供されます。プログラマーが行う必要があるのは、レコードを構成するコンポーネントの名前と型を宣言することだけです。次に例を示します。

public record FXOrder(int units,
                      CurrencyPair pair,
                      Side side,
                      double price,
                      LocalDateTime sentAt,
                      int ttl) {}

 

このレコード宣言を書くことで、タイプする量が節約されるだけではありません。はるかに強力で意味のある文を作っていることになります。つまり、FXOrder型は提供される状態にすぎず、どのインスタンスもフィールドの値を透過的に組み合わせただけのものだということを表しています。

その結果の1つとして、フィールドの名前がそのままAPIになります。そのため、適切な名前を選ぶことがますます重要になります(たとえば、Pairは型の名称として適切ではありません。靴のペアを表すかもしれないからです)。

この新しい言語機能を使うためには、レコードを宣言したコードを、プレビュー・フラグを指定してコンパイルする必要があります。

javac --enable-preview -source 14 FXOrder.java

javapでクラス・ファイルを調べてみると、コンパイラが大量の定型コードを自動生成していることがわかります(以下では、逆コンパイルしたメソッドとそのシグネチャのみを示します)。

$ javap FXOrder.class
Compiled from "FXOrder.java"
public final class FXOrder extends java.lang.Record {
  public FXOrder(int, CurrencyPair, Side, double, 
      java.time.LocalDateTime, int);
  public java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public int units();
  public CurrencyPair pair();
  public Side side();
  public double price();
  public java.time.LocalDateTime sentAt();
  public int ttl();
}

これは、クラスベースの実装をしたコードに含まれる一連のメソッドとうり二つです。実際に、コンストラクタとアクセッサ・メソッドは、すべて前のものと同じように動作します。

 

レコードの特異性

ただし、toString()やequals()などのメソッドには、一部の開発者が驚くような実装が使われています。

public java.lang.String toString();
    Code:
       0: aload_0
       1: invokedynamic #51,  0    // InvokeDynamic #0:toString:(LFXOrder;)Ljava/lang/String;
       6: areturn

 

なぜかと言えば、toString()メソッド(equals()やhashCode()も同様です)は、invokedynamicベースのメカニズムを使って実装されているからです。この方法は、最近のバージョンのJavaにおいて、文字列結合でinvokedynamicを使うように移行されたときと同様のものです。

java.lang.Recordという新しいクラスがあることもわかります。このクラスは、すべてのレコード・クラスのスーパータイプになる抽象クラスで、抽象メソッドとしてequals()、hashCode()、toString()が宣言されています。

java.lang.Recordクラスを直接拡張することはできません。そのことは、次のようなコードをコンパイルしようとすればわかります。

public final class FXOrderClassic extends Record {
    private final int units;
    private final CurrencyPair pair;
    private final Side side;
    private final double price;
    private final LocalDateTime sentAt;
    private final int ttl;
    
    // ... クラスの残りの部分は省略
}

 

コンパイラでは、次のメッセージを出してコンパイルを失敗させます。

$ javac --enable-preview -source 14 FXOrderClassic.java

FXOrderClassic.java:3: error: records cannot directly extend Record
public final class FXOrderClassic extends Record {
             ^
Note:FXOrderClassic.java uses preview language features.
Note:Recompile with -Xlint:preview for details.
1 error

 

つまり、レコードを取得する唯一の方法は、明示的にレコードを宣言してjavacでクラス・ファイルを作成することだけです。この方法により、すべてのレコード・クラスがfinalとして作成されることも保証されます。

レコードに適用されることで特殊な性質が生じるJavaの中核機能は、他にもいくつかあります。

まず、レコードはequals()メソッドに関連する特殊な契約に従わなければなりません。

レコードRのコンポーネントにc1、c2、...、cnがあり、レコードのインスタンスが次のようにしてコピーされたとします。

R copy = new R(r.c1(), r.c2(), ..., r.cn());

この場合、r.equals(copy)はtrueでなければなりません。この不変式は、equals()とhashCode()に関するおなじみの契約に追加されるものであり、それを置き換えるものではない点に注意してください。

次に、Javaでのレコードのシリアライズは、通常のクラスのシリアライズとは異なります。これは望ましいことだと言えます。今では広く認識されているように、Javaのシリアライズ・メカニズムは一般的に欠点が多いからです。Java言語アーキテクトのBrian Goetzは、「シリアライズは、見えないにもかかわらずパブリックなコンストラクタと、見えないにもかかわらずパブリックな一連の、内部状態へのアクセッサでできています」と述べています。

ありがたいことに、レコードは非常にシンプルに設計されています。レコードはフィールド用の透過的なキャリアにすぎないため、シリアライズ・メカニズムの詳細にまつわる奇妙な状況を引き起こす必要はありません。レコードのシリアライズとデシリアライズには、常にパブリックAPIとカノニカル・コンストラクタを使うことができます。

さらに、レコード・クラスのserialVersionUIDは、明示的に宣言されない限り0Lになります。レコード・クラスには、serialVersionUIDの値が一致しなければならないという要件も適用されません。

次のセクションに進む前に、新しいプログラミング・パターンと、少ない定型挿入文でクラスを宣言するための新しい構文があり、このパターンと構文はProject Valhallaで開発されているインライン・クラス機能とは関係ないということを強調しておきたいと思います。

 

設計レベルでの考慮事項

次は、設計レベルにおける、レコード機能の側面のいくつかについて考えてみます。考察にあたり参考にするため、Javaの列挙型の仕組みを振り返ってみます。Javaの列挙型は、パターンを実装するにもかかわらず、最低限の構文しか必要としないという特殊な形態のクラスです。コンパイラが、私たちに代わって多くのコードを生成してくれます。

同じように、Javaのレコードも、最低限の構文でデータ・キャリア・パターンを実装するという特殊な形態のクラスです。要求される定型コードは、すべてコンパイラが自動生成してくれます。

しかし、フィールドを保持するだけのデータ・キャリア・クラスという単純な考え方は、直感的にはわかるものの、厳密に意味するのはどういうことでしょうか。

初めてレコードが議論されたとき、さまざまな設計が検討されました。次に例を示します。

  • Plain Old Java Object(POJO)の定型挿入文の削減
  • JavaBeans 2.0
  • 名前付きタプル
  • 直積型(代数データ型の1形態)

こういった可能性は、Brian Goetzのオリジナル設計草案で多少詳しく議論されています。それぞれの設計オプションには、レコードの設計の中心として選択したものから生じる二次的な問いがさらに伴います。たとえば、次のようなものです。

  • Hibernateはレコードのプロキシとなることができるか
  • レコードには従来のJavaBeansとの完全な互換性があるか
  • 同じフィールドを同じ順番で宣言している2つのレコードを同じ型と見なすか
  • 将来的にパターン・マッチングや分割代入などのパターン・マッチング技術に対応させるか

それぞれのアプローチに利点と欠点があるため、そのうちのどれがレコード機能のベースになってもおかしくはありません。しかし、最終的な設計では、レコードは名前付きタプルと決定されています。

この選択には、名前的型付けと呼ばれる、Javaの型システムにおいて設計上重要な考え方が影響した部分がありました。名前的型付けとは、Javaストレージのすべての部品(変数、フィールド)には明確な型があり、それぞれの型に付けられる名前は人間にとって意味のあるものとすべきだという考え方です。

たとえ匿名クラスの場合でも、型には名前があります。名前を割り当てるのがコンパイラであり、Java言語での型としては有効な名前でない(しかし、それでもVM内では問題ありません)というだけのことです。次の例をご覧ください。

jshell> var o = new Object() {
   ...>   public void bar() { System.out.println("bar!"); }
   ...> }
o ==> $0@37f8bb67

jshell> var o2 = new Object() {
   ...>   public void bar() { System.out.println("bar!"); }
   ...> }
o2 ==> $1@31cefde0

jshell> o = o2;
|  Error:
|  incompatible types: $1 cannot be converted to $0
|  o = o2;
|      ^^

 

まったく同じように宣言された匿名クラスであっても、コンパイラで$0および$1という2つの異なる匿名クラスが生成されたことと、これらの変数はJavaの型システムでの型が異なるために代入が認められないことに注目してください。

その他(Java以外)の言語の中には、明示的な型名の代わりに、クラス全体の形(たとえば、クラスのフィールドやメソッド)を型として使うことができるものもあります。これを構造的型付けと呼びます。

レコードがJavaの伝統を破って構造的型付けを持ち込むことになっていたとすれば、大幅な変更になっていたでしょう。結果的に、「レコードは名前付きタプルである」という設計の選択から想定すべき点は、レコードが一番役立つのは他の言語でタプルを使うような場合であるということです。これには、複合マップ・キーや、メソッドから疑似的に複数の値を返却するといったユースケースが含まれます。複合マップ・キーとは、次のようなものです。

record OrderPartition(CurrencyPair pair, Side side) {}

なお、現在JavaBeansを使っている既存のコードをレコードに置き換えても、必ずしもうまく動作するとは限りません。その理由はいくつかあります。JavaBeansは可変ですが、レコードは不変です。また、この2つはアクセッサの規約が異なります。

レコードは純粋なクラスであるため、許可されるのは1行での単純な宣言形態のみにとどまりません。具体的に言えば、自動生成されたデフォルトのもの以外に、追加のメソッドやコンストラクタ、静的フィールドを定義できます。しかし、こういった機能は注意して使うべきです。レコードの設計上の意図は、開発者が、関連するフィールドをグループ化し、1つの不変データ項目を作れるようにすることである点を思い出してください。

経験則として、次のことが言えます。基本的なデータ・キャリアにメソッドを追加したくなればなるほど(または、インタフェースを実装したくなればなるほど)、レコードではなく完全なクラスを使うべきである可能性が高くなります。

 

コンパクト・コンストラクタ

このルールの重要な例外と言えるかもしれないことの1つは、コンパクト・コンストラクタの使用です。Java仕様には、コンパクト・コンストラクタについて次のように書かれています。

コンパクト・コンストラクタを宣言する目的は、カノニカル・コンストラクタの本体で必要となる、検証や正規化のみを行うコードを提供することにあります。その他の初期化コードはコンパイラが提供します。

たとえば、注文を検証し、負の数量の売買や無効なTTL値の設定が行われないようにしたいとします。

public record FXOrder(int units, 
                      CurrencyPair pair, 
                      Side side, 
                      double price, 
                      LocalDateTime sentAt, 
                      int ttl) {
    public FXOrder {
        if (units < 1) {
            throw new IllegalArgumentException(
                "FXOrder units must be positive");
        }
        if (ttl < 0) {
            throw new IllegalArgumentException(
                "FXOrder TTL must be positive, or 0 for market orders");
        }
        if (price <= 0.0) {
            throw new IllegalArgumentException(
                "FXOrder price must be positive");
        }
    }
}

コンパクト・コンストラクタを宣言しても、コンパイラで生成されるものとは別のコンストラクタができることはありません。コンパクト・コンストラクタに指定したコードは、カノニカル・コンストラクタの最初に追加されたコードとして扱われます。コンストラクタのパラメータをフィールドに代入するように指定する必要はありません。その部分はコンストラクタに自動生成され、通常どおり扱われます。

Javaのレコードが他の言語で見られる匿名タプルよりも優れている点の1つは、レコードを作成するときに実行されるコードをレコードのコンストラクタ本体に書くことができる点です。これにより、検証を行うことができます(そして、無効な状態が渡されたときは例外をスローすることができます)。純粋な構造的タプルで、この検証を行うことはできません。

 

代替コンストラクタ

レコード本体の中で静的ファクトリ・メソッドを使うことも可能です。たとえば、これにより、Javaにパラメータのデフォルト値がないという問題を回避できます。為替取引の例で言えば、次のような静的ファクトリを追加することで、デフォルト・パラメータを使って簡単に注文を作成できるようになります。

例で言えば、次のような静的ファクトリを追加することで、デフォルト・パラメータを使って簡単に注文を作成できるようになります。

public static FXOrder of(CurrencyPair pair, 
                             Side side, 
                             double price) {
        return new FXOrder(1, pair, side, price, 
                           LocalDateTime.now(), 1000);
    }

もちろん、これを代替コンストラクタとして宣言することもできます。それぞれの状況で、いずれのアプローチが適切かを選択する必要があります。

代替コンストラクタの別の用途は、複合マップ・キーとして使うレコードを作成する場合です。次の例をご覧ください。

record OrderPartition(CurrencyPair pair, Side side) {
    public OrderPartition(FXOrder order) {
        this(order.pair(), order.side());
    }
}

こうすることで、OrderPartition型をマップのキーとして簡単に使えるようになります。たとえば、取引マッチング・エンジンで使う注文台帳を作りたいとします。

public final class MatchingEngine {
    private final Map<OrderPartition, RankedOrderBook> 
      orderBooks = new TreeMap<>();

    public void addOrder(final FXOrder o) {
        orderBooks.get(new OrderPartition(o)).addAndRank(o);
        checkForCrosses(o.pair());
    }

    public void checkForCrosses(final CurrencyPair pair) {
        // 現在、この売り注文に一致する買い注文は存在するか?
    }

    // ...
}

新しい注文を受け取ると、addOrder()メソッドで適切な注文パーティション(通貨ペアと売買サイドのタプルで構成されるもの)を抽出します。新しい注文は、価格でランク付けされた適切な注文台帳に追加されますが、その際にこのパーティションを使います。新しい注文は、台帳にすでに存在する、反対サイドの注文に一致するかもしれません(これを注文の「食い合い」と言います)。そのため、checkForCrosses()メソッドで食い合いの有無を確認する必要があります。

場合によっては、コンパクト・コンストラクタを使わずに、明示的な完全版のカノニカル・コンストラクタを持たせたくなる場合もあるかもしれません。これは、コンストラクタで何らかの実作業を行う必要があることを示しています。単純なデータ・キャリア・クラスの場合、このようなユースケースは少数です。しかし、受け取ったパラメータを念のためにコピーしておく必要がある場合など、状況によってはこのオプションも必要です。そのため、コンパイラは明示的なカノニカル・コンストラクタを作ることも許可します。しかし、実際にこれを使う前には十分慎重に考えてください。


まとめ

レコードは単純なデータ・キャリアを意図しており、論理性と一貫性の点で定評がある、Javaの型システムになじんだタプルの一種です。レコードの存在は、多くのアプリケーションでドメイン・クラスをクリーンかつ小さくするために役立つでしょう。また、チームはベースとなるパターンの実装をハードコードする大量の作業から解放され、Lombokなどのライブラリは、必要性が低下するか、または不要になるでしょう。

しかし、シールド型と同じように、レコードのもっとも重要なユースケースの一部は、これから登場する機能になるでしょう。パターン・マッチング、その中でも、レコードをコンポーネントに分解できる分割代入パターンは特に有望で、多くの開発者のJavaプログラミング方法を大きく変えることになるかもしれません。シールド型とレコードの組合せにより、代数データ型と呼ばれる、言語機能の一形態がJavaで実現することにもなります。

他のプログラミング言語でこういった機能をよく知っているなら、すばらしいことです。そうでない方も心配しないでください。前述の機能は、皆さんがすでにご存じのJava言語になじむように、そしてコードで使い始めることが容易なように、設計が行われています。

ただし、特定の言語機能を含む確定版のJavaが提供されるまで、そのバイナリに頼るべきではないという点には常に注意しなければなりません。本記事のように、将来導入される見込みの機能について取り上げている場合、純粋に探求目的のみで機能を説明していることを常に理解しておいてください。


Ben Evans

Ben Evans(@kittylyst):Java Champion。New Relicのプリンシパル・エンジニア。先日発刊された『Optimizing Java』(O'Reilly)を含め、プログラミングに関する5冊の書籍を執筆している。jClarity(Microsoftにより買収)の創業者の1人であり、Java SE/EE Executive Committeeの元メンバーである。

 

 

 

Be the first to comment

Comments ( 0 )
Please enter your name.Please provide a valid email address.Please enter a comment.CAPTCHA challenge response provided was incorrect. Please try again.