ロジニキ雑記

マイクラ、プログラミング等雑記

Javaによる型制約を持ったインスタンスメソッドの模倣

概要

Javaではレシーバーが特定のParameterized Typeである時のみ呼び出す事が出来るインスタンスメソッドを定義するという言語機能はありません。しかし、Parameterized Typeを引数に取る関数を外部で定義し、ジェネリック型には外部から渡された関数を自身のインスタンスで評価するメソッドを定義する事により模倣することができます。

型制約

インスタンスメソッドを呼び出す事が出来る条件として型変数の継承関係を取る事を本記事では型制約と呼びます。
型制約のモチベーションを確認するために次のコードを考えましょう。

public final class Holder<T> {
    private final T t;
    public Holder(T t) { this.t = t; }
    public T t() { return t; }

    // ネストしたHolderを「潰す」
    // Holder<Holder<T>> -> Holder<T>
    public <R> Holder<R> flatten() {
    // どうやって実装する?
    }

    // Holder<String> -> Holder<String> -> Holder<String>
    // 保持した文字列とrightの文字列を結合
    public Holder<String> concat(Holder<String> right) {
        // ???
    }
}

これらのメソッドはいずれも型制約を必要とするメソッドであり、残念ながらJavaにはこれらをインスタンスメソッドとして実装する方法は存在しません。
型制約を行えない事への対策としてstaticなメソッドとして実装する場合がありますが、これはあまり使い勝手がよくありません。
次の例を考えてみます。

public class Holder<T> {
    //略

    public static <T> Holder<T> flatten(Holder<Holder<T>> nested) {
        return nested.t();
    }

    public static Holder<Integer> increment(Holder<Integer> intHolder) {
        return new Holder(intHolder.t()+1);
    }

    public static Holder<String> concat(Holder<String> left, Holder<String> right) {
        return new Holder(left.t() + right.t());
    }

    public static void example() {
        var nested = new Holder(new Holder(new Holder(1)));
        var s1 = new Holder("s1");
        var s2 = new Holder("s2");
        var s3 = new Holder("s3");

        var one = Holder.flatten(Holder.flatten(nested)).t();
        var s1s2s3 = Holder.concat(Holder.concat(s1, s2), s3).t();

        // 理想的な呼び出し
        // var one = nested.flatten().flatten().t();
        // var s1s2s3 = s1.concat(s2).concat(s3).t();
    }
}

インスタンスメソッドとしての呼び出しでは無いのでHolderが引数リストの中に移動しています。
注目すべきはoneやs1s2s3を取り出す式の可読性が著しく損なわれている事です。やりたい事は二重にネストしたHolderから中身を取り出す、3つの文字列を連結した結果を取り出す、というだけなのに引数の括弧がネストしてしまっています。これらは理想的にはコメントのようにメソッドチェーンを用いて呼び出したい所です。このように型制約を必要とするメソッドは特にメソッドチェーンを利用するAPIを設計したい場合には厄介です。

手法

型制約を模倣するための手順は次の通りです。

  1. 型制約を行いたいクラスに、自身を引数に取る関数を受け取り、自身で評価した結果を返すジェネリックなメソッドを定義する
  2. インスタンスメソッドとしての引数を受け取り、型引数の制限により型制約を表現した関数オブジェクトを生成するメソッドを定義する。

opメソッドとoperation生成メソッド

本記事の手法を理解する上で鍵になる考え方はインスタンスメソッドを、関数にインスタンスを部分適用できる仕組みと見なすことが出来るという事です。このことを抽象化するために、データ型から見て自身と同じ型を引数に取る関数をoperationと呼び、operationを自身で評価するメソッドをopメソッドと呼びます。
まずはHolderにopメソッドを定義します。

public final class Holder<T> {
    private final T t;
    public Holder(T t) { this.t = t; }
    public T t() { return t; }

    public <R> op(Function<? super Holder<T>, ? extends R> operation) {
        return operation.apply(this);
    }

次にoperationを生成するメソッドを定義します。

public class OPSHolder {
    public static <T> Function<Holder<Holder<T>>, Holder<T>> flatten() {
        return tt -> tt.t();
    }

    public static Holder<String> concat(Holder<String> right) {
        return left -> new Holder(left.t() + right.t());
    }
}

利用側は次のようになります。

import static OPSHolder.*;

//略

public static void example() {
    var nested = new Holder(new Holder(new Holder(1)));
    var s1 = new Holder("s1");
    var s2 = new Holder("s2");
    var s3 = new Holder("s3");

    var one = nested.op(flatten()).op(flatten()).t();
    var s1s2s3 = s1.op(concat(s2)).op(concat(s3)).t();

    // nestedが受け取るoperationの型はFunction<? super Holder<Holder<Integer>>, ? extends R>
    // concatの型はFunction<Holder<String>, Holder<String>>
    // concatはoperationのサブタイプでは無いためコンパイルエラー
    var CE = nested.op(concat(s1));
}

opメソッドの呼び出しや括弧が二重になる事がボイラープレートにはなりますが、opメソッドを利用する事でconcatやflattenをあたかも型制約を持つインスタンスメソッドであるかのように呼び出すことが出来ています。更にCEのように関数の型制約を満たさないデータ型に対してはその関数を渡すことが出来ないため、誤ったデータ型に対するメソッド呼び出しをコンパイルエラーにする事が出来ます。

インスタンスメソッドの追加

この手法の場合Kotlinの拡張関数のように、外部で定義したメソッドをインスタンスメソッドのように呼び出すことができるようになります。次の例ではインスタンスメソッドをHolderではなくOPSHolderに追加しています。

public class OPSHolder {
    //略

    //保持した値をインクリメントする
    public static Function<Holder<Integer>, Holder<Integer>> increment() {
        return ti -> new Holder(ti.t()+1);
    }

    public static void example() {
        var ti = new Holder(1);

        var three = ti.op(increment()).op(increment()).t();
    }
}

今回はOPSHolderにメソッドを追加しましたが、incrementを定義する場所はOPSHolderである必要はありません。他クラスのstaticメソッドやインスタンスメソッド、あるいはローカル変数に関数として定義する事も出来ます。

宣言側変性の模倣

境界付きワイルドカード型引数を利用してキャストメソッドを用意する事でJavaには不可能な宣言側変性を模倣する事が出来ます。

public class OPSHolder {
    //略

    //共変
    @SuppressWarnings("unchecked")
    public static <T> Function<Holder<? extends T>, Holder<T>> cast() {
        return t -> (Holder<T>)t
    }

/*
    //反変
    @SuppressWarnings("unchecked")
    public static <T> Function<Holder<? super T>, Holder<T>> cast() {
        return t -> (Holder<T>)t
    }
*/

    public static void example() {
        var intHolder = new Holder(1);

        Holder<Number> numHolder = intHolder.op(cast());
    }
}

この手法は特に反変キャストが定義出来るのが強力です。Javaでは次のメソッドはコンパイルエラーになります。

public class Holder<T> {
    //略

    //型変数の下限型は定義できないためコンパイルエラー
    public <A super T> Holder<A> cast() { return (Holder<A>)this; }
}

宣言側変性を模倣する場合castメソッドをstaticメソッドとして用意しますが、この方法はflattenやconcatの場合と同様にoperation化する事で、opメソッドを介してインスタンスメソッドのように呼び出すことができます。
宣言側変性に限らず、型の一致ではなく継承関係を利用する場合には同様にワイルドカート型引数を使うことができます。

opメソッドの抽象化

opメソッドの実装は次のように再帰ジェネリクス型を用いる事で抽象化出来ます。

public interface Operand<X extends Operand<X>> {
    @SuppressWarning("unchecked")
    default X cast() {
        return (X)this;
    }

    default <R> op(Function<? super X, ? extends R> operation) {
        return operation.apply(cast());
    }
}

// Overrideを禁止するための骨格実装
public abstract class OperandClass<X extends OperandClass<X>> implements Operand<X> {
    protected AbstractOperand() {}

    public final X cast() {
        return Operand.super.cast();
    }

    public final <R> op(Function<? super X, ? extends R> operation) {
        return Operand.super.op(operation);
    }
}

新たなデータ型ではXにその型を与えてOperandを実装する事でopメソッドを利用できるようになります。スーパータイプを持たないのであればOperandClassを継承する事でメソッドのオーバーライドを封じる事が出来ます。

結論

Javaでは型制約を持つインスタンスメソッドを定義することは出来ませんが、型引数に対する制約を利用する事により型制約を模倣する事は出来ます。データ型には自身を引数に取る関数を自身で評価するopメソッドを定義し、好きな場所に型引数に制約持つ関数であるoperationを生成するメソッドを定義します。データ型のopメソッドが受け取る関数の型と、生成された関数の型が異なる場合コンパイルエラーにする事が出来ます。メソッドを追加したい場合はoperationを好きな場所に定義すれば良く、データ型は一切編集する必要がありません。
operationの引数の型引数を型とする事で型の一致関係を条件にできる他、ワイルドカード型引数を利用する事で継承関係を条件にする事も出来ます。特に下限付きワイルドカード型引数を利用する事でインスタンスメソッドでは定義出来ない反変な型変数を表現できます。
opメソッドの実装は上記のOperand型により抽象化する事が出来ます。