虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Javaで今後追加されるかもしれないSealed TypesとKotlinのSealed Classを比べてみる!

みなさんこんにちは、虎の穴ラボのH.Kです。
Java 14のリリースが2020/03に予定されておりますが、今回はさらに先のリリースで入ると言われているSealed Typesという機能を詳しく見ていきます。

Sealed Typesと聞いて最初に感じたことは「Kotlinでも似たような機能(Sealed Class)がある」ということだったのでKotlinの機能との比較を通して内容を整理できたらと思います。

Sealed Typesとは

大まかな言い方をしてしまえば、継承先を制限できる機能です。
詳細については以下のJEPに記載があります。

openjdk.java.net

執筆時点(2020/02)ではPreviewとなっているため、この機能を利用するためにはjavacを実行する際に--enable-previewオプションをつけて有効化する必要があります。

JEPのMotivation(動機)の内容を確認すると以下のようなことが述べられています。
実装者が特定の継承先を想定して実装していても、現在のJavaの型システムでは制限できません。結果として、継承先を正確に列挙することができず、また、利用者が自由に(あるいは勝手に)継承先を増やせてしまうことにより、確実な実装が難しくなることがあるため、Sealed Typesの導入を推めたい、としています。

また、このSealed Typesrecordsを用いることで、Algebraic data type(代数的データ型)と呼ばれることが多いデータ構造を形成できることにも触れられています。
Algebraic data typeは関数型プログラミングで良く利用されるデータ構造です。

JEPとは

JDK Enhancement-Proposalの略で、直訳すると「JDKの拡張提案」となります。JDKに新機能を追加する際の管理単位がJEPです。つまり、JEPを追っていけば追加されるであろう機能がわかります。

KotlinのSealed Class

Kotlinでは、すでにSealed Classというものが利用できます。 同じファイル内でしか継承できないようにする仕組みがKotlinのSealed Classです。
kotlinlang.org

Kotlinで通常の継承を行う場合、openをつけて継承可能なクラスであることを明示して定義します。

// main/kotlin/NotSealedClassesSample.kt

open class ExprOpen
data class ConstOpen(val number: Double) : ExprOpen() // ExprOpenを継承して定義したクラス
data class SumOpen(val e1: ExprOpen, val e2: ExprOpen) : ExprOpen() // ExprOpenを継承して定義したクラス
object NotANumberOpen : ExprOpen() // ExprOpenを継承して定義したクラス

上記で作成したクラスはwhen式などで利用することができます。
when式とはJavaで言うswitch文を式(値を返す形式)として利用できるようにしたものです。
なお、Javaでも14から新機能としてSwitch Expressions(=switchを式で扱えるようにするというJEP)がリリースされる予定です。

openjdk.java.net

継承したクラスを利用した簡単な実装を用意して実行してみます。

// main/kotlin/Main.kt

fun main(args: Array<String>) {
    println(eval(ConstOpen(120.0))) // 120.0
    println(eval(SumOpen(ConstOpen(10.0), ConstOpen(20.0)))) // 30.0
    println(eval(NotANumberOpen)) // NaN
}

fun eval(expr: ExprOpen): Double = when(expr) {
    is ConstOpen -> expr.number
    is SumOpen -> eval(expr.e1) + eval(expr.e2) // 再起的に呼び出して値を取得する
    is NotANumberOpen -> Double.NaN
    else -> Double.NaN // 継承対象が絞り込めないのでelseが必要
}

// openなので別ファイルでの継承も可能
data class Multiply(val e1: ExprOpen, val e2: ExprOpen) : ExprOpen()

openを使った通常の継承に対して、Sealed Classで継承元となるクラスを作成します。(サンプルコードのコピーです。)

// main/kotlin/SealedClassesSample.kt

sealed class Expr
data class Const(val number: Double) : Expr()
data class Sum(val e1: Expr, val e2: Expr) : Expr()
object NotANumber : Expr()

先ほどの実装をsealedで定義した継承関係を利用して書き換えます。

// main/kotlin/Main.kt

fun main(args: Array<String>) {
    println(eval(Const(120.0))) // 120.0
    println(eval(Sum(Const(10.0), Const(20.0)))) // 30.0
    println(eval(NotANumber)) // NaN
}

fun eval(expr: Expr): Double = when(expr) {
    is Const -> expr.number
    is Sum -> eval(expr.e1) + eval(expr.e2) // 再起的に呼び出して値を取得する
    is NotANumber -> Double.NaN
    // 継承が制限されているためelseが不要
}
// sealedで宣言されたExprクラスにアクセスできないため、コンパイルエラーとなる
// data class Multiply(val e1: Expr, val e2: Expr) : Expr()

JavaでのSealed Types

まず、前述のKotlinで実装した内容をJava 1.8で実装してみます。

// main/java/NonSealedTypesSample;

public class NonSealedTypesSample {
    interface Expr{}
    class Const implements Expr { Double i; Const(Double i){this.i = i; } }
    class Sum implements Expr { Const a; Const b; Sum(Const a, Const b){this.a = a; this.b = b; } }
    class NotANumber implements  Expr{}
}

Kotlinやこの後に書くコードとの比較のために各インナークラスはワンライナーで実装しています。

// main/java/Main.java

public class Main {
    public static void main(String[] args) {
        NonSealedTypesSample nonSealedTypesSample = new NonSealedTypesSample();
        System.out.println(eval(nonSealedTypesSample.new Const(120.0)));
        System.out.println(eval(nonSealedTypesSample.new Sum(nonSealedTypesSample.new Const(10.0), nonSealedTypesSample.new Const(20.0))));
        System.out.println(eval(nonSealedTypesSample.new NotANumber()));
    }

    private static Double eval(NonSealedTypesSample.Expr expr) {
        Double result;
        if (expr instanceof NonSealedTypesSample.Const) {
            NonSealedTypesSample.Const constant = (NonSealedTypesSample.Const) expr;
            return constant.i;
        } else if (expr instanceof NonSealedTypesSample.Sum) {
            NonSealedTypesSample.Sum sum = (NonSealedTypesSample.Sum) expr;
            result = sum.a.i + sum.b.i;
        } else if (expr instanceof NonSealedTypesSample.NotANumber) {
            result = Double.NaN;
        } else {
            result = Double.NaN;
        }
        return result;
    }
}

instanceofで比較して、それを型変換して・・・とやや冗長な記述になってしまいました。
このソースコードを、今後追加される予定の機能や、Java 1.9以降に追加された機能を駆使して書き直してみます。

// main/java/SealedTypesSample;

public class SealedTypesSample {
    sealed interface Expr{}
    record Const( Double i ) implements Expr { }
    record Sum( Const a, Const b ) implements Expr { }
    record NotANumber implements  Expr{}
}

まず、sealedで宣言することにより、実装・継承先が同一コンパイル単位内のクラスのみに制限されます。 同一コンパイル単位内でも、さらに特定のクラスにのみ実装・継承を認める場合、以下のように実装します。

sealed interface Expr permits Const, Sum{}

上記ではConstとSumしか実装・継承できないとしているため、NotANumberで実装・継承しようとするとエラーになります。
次にrecordです。これはそのクラスがデータの保持を目的とする型であると宣言するものです(JEP 359: Records)。
Java 14, Java 15ではPreviewとして入り、Java 16で正式化する見込みです。

それではMainクラス側も同様に書き直してみます。

// main/java/Main.java

public class Main {
    public static void main(String[] args) {
        SealedTypesSample sealedTypesSample = new SealedTypesSample();
        System.out.println(eval(sealedTypesSample.new Const(120.0)));
        System.out.println(eval(sealedTypesSample.new Sum(sealedTypesSample.new Const(10.0), sealedTypesSample.new Const(20.0))));
        System.out.println(eval(sealedTypesSample.new NotANumber()));
    }

    private static Double eval(SealedTypesSample.Expr expr) {
        return switch (e) {
            case SealedTypesSample.Const(var i) -> i;
            case SealedTypesSample.Sum(var a, var b) -> eval(a) + eval(b); // 再起的に呼び出して値を取得する
            case SealedTypesSample.NotANumber -> Double.NaN;
            // 継承が制限されているためdefaultが不要
        }
    }
}

evalメソッドの実装が大きく変更されています。
これはSwitch文でパターンマッチが行えるようにするPattern matching for switchというJEP(まだdraft)と、拡張switch(switch式)の実装によるところが大きいです。
参考:Pattern matching for switch
また、Pattern matching for switchでは内部的に、instance ofを改善するためにパターンマッチング機能が使われています。(JEP 305:Pattern Matching for instanceof (Preview)
このパターンマッチング機能はinstance ofで比較したときに型が一致していた場合、trueを返却し、変数への代入も行うものになります。

if (x instanceof Integer i) {
    // xを変数iに代入し、iがIntegerとして使える
}

recodeで宣言されたクラスでは、構造の分解(deconstructible)を行うことで、(var i) -> iという記載でメンバ変数を使えるようになるようです。deconstructibleについては、まだJEPになっていませんが、recodeのpreviewの2次フェーズとして実装される可能性があります。
参考:[records] Record updates for Preview/2

JavaとKotlinを比較してみて

継承の対象を制限するという単体の機能で見るとSealed TypesとSealed Classは同じように思えます。
しかし、switch(when)周りの状況を見ると同じように実装するには少し時間がかかるかもしれません。 ただ、JEPを見ていくと近い将来(Java 16〜17くらい=1、2年後)には、ほとんど同じような記述量で実装ができるようになるように思えます。

調べてみて

JEP周りを一通り眺めて、今後のJavaがどうなるか考えるきっかけになりました。
今回はKotlinとの比較になりましたが、様々な言語の影響を受けてリリースされているので、C#など他の言語との比較なども見て行けたらと思います。

P.S.

初めて札幌で採用説明会を実施することになりました!近隣にお住まいの方はぜひお申し込みください! yumenosora.connpass.com

定期的に行っている秋葉原説明会についても開催します!ご興味があり話を聞いてみたい人も、応募する前に話を聞きたい人も是非お越しください!
yumenosora.connpass.com

最後に、カジュアル面談は随時受付中ですので、気になる方はぜひお申し込みください。
yumenosora.connpass.com