※本記事は、Ben Evans”Inside the Language: Sealed Types“を翻訳したものです。


パターン・マッチング、列挙型およびswitch文の改善に向けて進化するJava

著者:Ben Evans

2019年10月16日

本記事では、Javaにとって新しいプログラミング言語概念である、シールド型について説明します。この機能は現在活発に開発が行われており、将来のバージョンのJavaに導入される見込みです。シールド型の実現に必要な、プラットフォームレベルの重要な仕組みが、Java 11のネストメイトと呼ばれる機能で導入されました。Java 12でも、プレビュー機能としてswitch式が導入されました。そこから、将来のバージョンのJavaでシールド型をどのように使えるかが垣間見えます。

本記事の内容を完全に理解するためには、プログラミング言語の設計や実装について、かなりの知識が必要です(少なくとも、興味を持っている必要があります)。まず、シールド型とは何であるかを説明するにあたり、Javaの列挙型とその内部の仕組みについて詳しく見てみます。

列挙型は、とてもよく知られている、Javaの言語機能です。列挙型を使うことにより、ある型が取り得るすべての値を表現する有限集合をモデリングすることができます。そのため、列挙型は実質的に型安全な定数として扱われます。

たとえば、列挙型Petについて考えてみます。

    enum Pet {
        CAT,
        DOG
    }

プラットフォームでは、特殊な形態のクラス型をJavaコンパイラで自動生成させることによって、この列挙型を実現しています(厳密に言えば、ランタイムは実際にはライブラリ型java.lang.Enumを他のクラスとは少しばかり異なる特殊な方法で扱っていますが、この処理の詳細はここではあまり関係ありません。すべての列挙型クラスはjava.lang.Enumを直接拡張しています)。

この列挙型を逆コンパイルして、コンパイラが何を生成したのかを確認してみます。

    eris:src ben$ javap –c Pet.class
    Compiled from "Pet.java"
    final class Pet extends java.lang.Enum
   {
      public static final Pet CAT;

      public static final Pet DOG;

      …
      // Private constructor
    }
 

クラス・ファイルの中では、列挙型が取り得るすべての値がpublic static final変数として定義されています。コンストラクタはprivateであるため、インスタンスを追加生成することはできません。

実質的に、列挙型はシングルトン・パターンを一般化したものに近くなっていますが、クラスのインスタンスが1つだけ存在するのではなく、有限個存在する点が異なります。このパターンにより、網羅性という考え方を導入できるため、非常に便利です。つまり、NULLでないPetオブジェクトがある場合、CATインスタンスまたはDOGインスタンスのいずれかであることが確実にわかります。

ここで、さまざまな種類の犬と猫を現在のJavaでモデリングすることを考えてみます。その場合、1つのインスタンスではそれぞれの種類のペットを表すのに十分ではないため、2つの好ましくない方法のいずれかを使わざるを得なくなります。

まず、1つの実装クラスPetと、実際の種類を保持する状態フィールドを併用する方法が可能です。状態フィールドは列挙型のような性質を持ち、特定のオブジェクトが実際にどの種類に属するかを表すビットを提供するため、このパターンはうまく動作します。しかし、自らビットを追跡する必要があり、それはまさに型システムの範疇であることから、これは明らかに次善の方法です。

もう1つの方法として、抽象型のベースタイプPetを宣言し、そのサブクラスとして具象型のCatDogを作成することができます。ここでの問題は、Javaが、デフォルトで拡張可能であるオープンな言語として常に設計されてきたことです。クラスが一度コンパイルされれば、何年が経過しても(または何十年後であっても)サブクラスをコンパイルすることができます。

Java 11の時点で、Java言語で許可されているクラス継承構造は、オープン継承(デフォルト)と継承なし(final)のみです。抽象型をベースとして具象型のサブタイプを使うパターンの重大な弱点がここに露呈しています。

ここでの問題は、クラスではパッケージ・プライベートなコンストラクタ(これは実質的に「同じパッケージのクラスのみが拡張できる」ことを意味します)を宣言できるものの、ランタイム・システムにはユーザーがそのパッケージ内に新しいクラスを作成することを防ぐ手段はない点です。したがって、この保護は、どう好意的に見ても不完全です。

つまり、Petクラスを定義した場合、Petを継承したSkunkクラスを第三者が作ることを防ぐのは不可能です。さらに悪いことに、この望まれない拡張は、Pet型がコンパイルされてから何年が経過しても(または何十年後であっても)行われる可能性があります。これは非常に好ましくないことです。

結論として、現在のバージョンのJavaでは、抽象型のベースを使ったアプローチは安全ではありません。つまり、開発者は、Petの実際の種類を保持するためにフィールドを使うしかないということになります。

OpenJDKプロジェクト(標準バージョンのJavaが開発されている場所)全体の中の、Project Amberというプロジェクトに関する最近の取り組みでは、継承性を細かく制御する新しい方法を導入することで、この状態を改善することを目指しています。その新しい方法がシールド型です。

この機能は、さまざまな形態で他のいくつかのプログラミング言語に存在しており、ここ最近の流行になっています(ただし実のところ、この考え方はかなり古くからあります)。

Javaでは、この機能をシールド(封印された)という概念によって実現しています。この概念が表しているのは、クラスは拡張可能であるものの、既知のリストにあるサブタイプにのみ拡張でき、他のクラスには拡張できないということです。

他の言語ではこの機能を違う形で見ているかもしれませんが、Javaでは「ほとんどfinal」なクラスを表す機能と考えます。

簡単な例を使って、現時点での新しい構文を見てみます。

    public abstract sealed class SealedPet permits Cat, Dog {
        protected final String name;
        public abstract void speak();
        public SealedPet(String name) {
            this.name = name;
        }
    }

    public final class Cat extends SealedPet {
        public Cat(String name) {
            super(name);
        }

        public void speak() {
            System.out.println(name +" says Meow");
        }

        public void huntMouse() {
            System.out.println(name +" caught a mouse");
        }
    }

    public final class Dog extends SealedPet {
        public Dog(String name) {
            super(name);
        }

        public void speak() {
            System.out.println(name +" says Woof");
        }        

        public void pullSled() {
            System.out.println(name +" pulled the sled");
        }        
    }

ここでいくつか気づくことがあります。まず、SealedPetabstract sealedクラスになっています。sealedは、今までJavaで許可されていたキーワードではありません。次に、2つ目の新しいキーワードであるpermitsが使われています。開発者はpermitsを使って、そのシールド型に許可されているサブクラスを列挙することができます(サブタイプのリストを記述していない場合、許可されているサブタイプは同じコンパイル・ユニットのサブタイプから推論されます)。

さらに、CatDogは正規のクラスであるため、ネズミを捕まえる、ソリを引くなど、その型に固有な動作を行うことができるというメリットも得られます。オブジェクトの「実際の型」を示すフィールドを使う場合、すべてのサブタイプにおけるすべてのメソッドがベースタイプに存在することが必要になるか、不格好なダウンキャストを行わざるを得なくなるため、ここまで簡単にはいかないでしょう(なお、この例では、サブクラスをfinalにしていますが、staticfinalなインナー・クラスにするなど、修飾子の組合せは他のものも可能です)。

ここでこれらの型を使ってプログラムを作る場合、登場するすべてのPetインスタンスは、CatまたはDogのいずれかであるとわかっていることになります。さらに、コンパイラもこの情報を使うことができます。つまり、ライブラリのコードでは、列挙されたものしかサブクラスが存在しないことを問題なく前提にできるようになり、クライアントのコードもこの前提を破ることはできません。

オブジェクト指向プログラミングの理論で言えば、これは新しい種類の正式な関係を表します。オブジェクトoには、CatまたはDogとis-a関係があると言うことができます。すなわち、oが取り得る型は、CatDog和集合(union)です。

そのため、こういった型は非交和(disjoint union)型と呼ばれます。さまざまな言語では、タグ付き共用型(tagged union)直和型(sum type)とも呼ばれています。ただし、これらはCの共用体(union)とは少しばかり異なることに注意してください。

たとえば、Scalaプログラマーは、ケース・クラスやScala版のsealedキーワードを使って、非交和型と非常によく似た考え方を実現できます。

JVM以外では、Rust言語にも非交和型の考え方が導入されています。ただし、Rustでは非交和型をenumキーワードで表しているため、Javaプログラマーはかなりややこしく感じるかもしれません。

一見したところ、この型はJavaにとってまったく新しい考え方であるように感じるかもしれません。しかし、列挙型にとてもよく似ているため、Javaプログラマーにとっては、その類似点がよい出発点になるはずです。実は、直和型に似たものがすでに存在する箇所があります。マルチキャッチ句の例外パラメータの型です。

Java 11の言語仕様には、「例外パラメータを宣言する型は、例外パラメータの型を和集合(union)D1 | D2 | … | Dnで記述すると、lub(D1, D2, …, Dn)となる」(JLS 11セクション14.20)と書かれています。引用部にあるlubとは「最小上界」で、D1、D2、…、 Dnにもっとも近い共通のスーパータイプという意味です。これはjava.lang.Objectまたはそれよりも具体的な何らかの型になります。マルチキャッチの例外の場合、lubはThrowableまたはそれよりも具体的な何らかの型になります。

シールド型と新しいswitch

しかし、シールド型の実用性はモデリング能力の向上にとどまりません。実際、JDK 12で導入された(そしてJDK 13で強化された)興味深い機能に、switch式があります。そこから、今後Java開発者がシールド型をどのように活用できるかが垣間見えます。これについてもう少し詳しく見ていきます。

switch式は、Javaに古くからある(Cによく似た)switchキーワード(これは文です)を大きくアップグレードするものです。この新機能を使うことで、switchを式として動作させることができます。この設計では、switch(または同じような言語構造)を、値を返す式として扱う、より関数指向型の言語(Haskell、Scala、Kotlinなど)との間の言語的なギャップを埋めることを目指しています。

最終形がJava 12に導入されたバージョンと大幅に異なるものになることは考えにくいですが、本記事はJava 13の形式が確定する前に執筆しているため、ここではJava 12の形式を使って説明します。次に例を示します。

    import java.time.DayOfWeek;

    public static boolean isWorkDay(DayOfWeek day){
        var today = switch(day) {
            case SATURDAY, SUNDAY -> false;
            case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> true;
        };

        // 祝日の考慮など、その他の処理を行う

        return today;
    }

switch式は、入力値に基づいて出力値を生成する関数のように振る舞うことができるようになりました。switch式を使うことで、switchが非常によく使われるパターンを簡単に記述できるため、この機能単独でとても便利です。実のところ、switch式のルールは、すべての入力値が出力値を生成することが保証されている必要があるというものです。

たとえば、intを受け取るswitch式では、取り得るすべての場合を列挙することはできないため、default句を含める必要があります。ただし、鋭い開発者の方なら気づくと思いますが、列挙型の場合、コンパイラでは定数の網羅性を使用できます。ご想像どおり、取り得るすべての列挙型定数がswitch式に存在する場合は、先ほどの例のようにdefault句を含める必要はありません。

また、switch式は、将来のバージョンのJavaで実現される大きな機能へ向けての足がかりでもあります。

その目的は、パターン・マッチングと呼ばれる、より高度な構造を今後のリリースで導入することです。提案されている、言語レベルのこの機能を、正規表現のパターン・マッチング(すなわちregex)と混同しないでください。ここでのパターン・マッチングは、マッチ式(switchのcaseラベルを一般化したもの)をJavaでおなじみの単純な定数以外のものに適用できるように拡張することを指します。

たとえば、対象となるオブジェクトの値ではなくと照合することもできます(これは型パターンと呼ばれています)。プログラマーにとって型パターンは、コンパイル時に型がわからないオブジェクトがあり、種類の異なる有効な選択肢が存在する場合に役立ちます。たとえば、JSONを解析している場合なら、出現する値は文字列、数値、ブール値、その他のJSONオブジェクトのいずれかになります。解析後に型パターンを使えば、値を表すオブジェクトの型と照合を行うことで、値がStringDoubleBooleanの場合に特化した処理を作成できるでしょう。

Javaフレームワークは、この種の動的な柔軟性をJava言語にたびたびもたらしています。この柔軟性が言語レベルで直接サポートされれば、大きな一歩になるでしょう。

シールド型の導入により、このオブジェクトの型はX、Y、Zのいずれかであり、その他の型ではないと断言できるようになります。このオブジェクト指向的な考え方は非常に強力です。switch式における列挙型で紹介した網羅性が、型レベルでも実現できるようになります。シールド型では、Javaの既存の列挙型言語機能に似た機能を提供しますが、列挙型は「存在し得るインスタンスが有限個」であるのに対し、シールド型は「存在し得る型が有限個」となります。

マッチ式、シールド型、そしてその他の開発中のJVM技術を組み合わせれば、関数型スタイルとオブジェクト指向スタイルの両方のプログラミングに対するJavaのサポートがさらに拡張されることになります。

早期アクセス・バイナリはまだ公開されていないため、早速シールド型を試してみたい開発者の方は、ソースから自分用のOpenJDKバイナリをビルドする必要があります。なお、特定の言語機能を含む確定版のJavaが提供されるまでは、そのバイナリを信頼すべきではない点に注意してください。本記事のように、将来のことや、今後導入される見込みの機能について取り上げている場合、純粋に探求目的のみで将来の見込みを説明していることを常に理解しておいてください。本記事の説明は、ここで取り上げた機能をサポートした確定版のJavaが実際に提供されるということを、誰か(オラクルを含む)に代わって確約するものではありません。

Java Magazine 日本版Vol.47の他の記事

Java 13のswitch式と再実装されたSocket APIの内側
Javaにテキスト・ブロックが登場
TeaVMを使ってブラウザでJavaを動かす
ツールをよく知る
クイズに挑戦:1次元配列(中級者向け)
クイズに挑戦:カスタム例外(上級者向け)
クイズに挑戦:ロケールの読取りと設定(上級者向け)
クイズに挑戦:関数型インタフェース(上級者向け)


Ben Evans

Ben Evans(@kittylyst):Java ChampionおよびjClarityの創業者/技術フェローであり、London Java Community(LJC)主催者。Java SE/EE Executive Committeeのメンバーでもある。先日発刊された『Optimizing Java』(O’Reilly)を含め、プログラミングに関する4冊の書籍を執筆している。