LoginSignup
34
5

More than 3 years have passed since last update.

列挙型と switch 文の進化形!?Java で代数的データ型とパターンマッチングを実現してみる

Last updated at Posted at 2019-12-07

Mikatus Advent Calendar 2019 8日目の記事。

Reactive in practice: A complete guide to event-driven systems development in Java を読んでいたら、The algebraic data type pattern(代数的データ型パターン)なんてものが出てきて面白かったのでメモっとく。

例題:株取引システムにおける注文種別を表現する

Reactive in practice には株取引システム Stock Trader における注文の種類を表現する方法が例題として掲載されている。株を取引したことがある人は知っていると思うのだが、株の注文には下記のような種類がある。

  • 成行 (market)
  • 指値 (limit)
  • 逆指値 (stop limit)

これらの注文を表現するために、Reactive in practice で代数的データ型パターンと呼んでいるデザインパターンが使われている。なぜ列挙型 (Enum) ではないのか?その謎を紐解いていこう。

実験用の Gradle プロジェクトを生成する

Lombok を使いたかったりもするので JShell ではなく Gradle プロジェクトで実験する。下記のコマンドで Gradle プロジェクトを生成しよう。

$ mkdir stock-trader
$ cd stock-trader
$ gradle init \
    --type java-library \
    --dsl groovy \
    --test-framework junit \
    --project-name stock-trader \
    --package com.example

株取引の注文種別を列挙型として実装する

まずは成行注文と指値注文のみを列挙型として実装してみよう。指値注文は指値 (limit price) を保持する必要があるので、列挙型に limitPrice フィールドを持たせる。ゲッターとなる getLimitPrice メソッドとセッターとなる setLimitPrice メソッドも定義する。注文が実行可能かを判定する isExecutable 抽象メソッドを定義して、各列挙値でオーバーライドしている。

src/main/java/com/example/order/OrderType.java
package com.example.order;

public enum OrderType {
    MARKET {
        @Override
        public boolean isExecutable(int currentPrice) {
            return true;
        }
    },
    LIMIT {
        @Override
        public boolean isExecutable(int currentPrice) {
            return currentPrice <= getLimitPrice();
        }
    };

    private int limitPrice;

    public int getLimitPrice() {
        return limitPrice;
    }

    public void setLimitPrice(int limitPrice) {
        this.limitPrice = limitPrice;
    }

    public abstract boolean isExecutable(int currentPrice);
}

この OrderType 列挙型を使用するテストを書いてみよう。

src/test/java/com/example/order/OrderTypeTest.java
package com.example.order;

import org.junit.Test;
import static org.junit.Assert.*;

public class OrderTypeTest {
    @Test
    public void testIsExecutableOnMarketOrder() {
        OrderType orderType = OrderType.MARKET;

        assertTrue(orderType.isExecutable(100));
    }

    @Test
    public void testIsExecutableOnLimitOrder() {
        OrderType orderType = OrderType.LIMIT;
        orderType.setLimitPrice(100);

        assertTrue(orderType.isExecutable(100));
    }
}

実際のところ株取引の注文種別を列挙型で実装することには下記のような問題がある。

  • 成行注文で setLimitPrice メソッドによって指値の指定ができる
  • 指値注文で指値なしの状態が実現できる

これらの問題の解決策は様々あるかもしれないが、今回は代数的データ型パターンを用いて解決してみようと思う。

シールドクラスパターンを用いて株取引の注文種別を代数的データ型として実装する

シールドクラスパターン (Sealed Class Pattern) として Maybe in Java という記事が Reactive in practice で参照されている。そのシールドクラスパターンで成行注文と指値注文のみを代数的データ型として実装してみよう。

src/main/java/com/example/order/OrderType.java
package com.example.order;

public abstract class OrderType {
    private OrderType() {
    }

    public static final class Market extends OrderType {
        @Override
        public boolean isExecutable(int currentPrice) {
            return true;
        }
    }

    public static final class Limit extends OrderType {
        private int limitPrice;

        public Limit(int limitPrice) {
            this.limitPrice = limitPrice;
        }

        public int getLimitPrice() {
            return limitPrice;
        }

        @Override
        public boolean isExecutable(int currentPrice) {
            return currentPrice <= limitPrice;
        }
    }

    public abstract boolean isExecutable(int currentPrice);
}

ここで OrderType クラスについていくつか補足しておこう。OrderType を抽象クラスとして宣言し、プライベートコンストラクタを定義することで、OrderType を継承できるクラスを OrderType の内部クラスに限定している。また、Market クラスと Limit クラスを final クラスとして宣言することで、Market クラスと Limit クラスを継承できないようにしている。これシールドクラスパターンと呼ばれる理由だと思う。

この OrderType クラスを使用するテストを書いてみよう。

src/test/java/com/example/order/OrderTypeTest.java
package com.example.order;

import org.junit.Test;
import static org.junit.Assert.*;

public class OrderTypeTest {
    @Test
    public void testIsExecutableOnMarketOrder() {
        OrderType orderType = new OrderType.Market();

        assertTrue(orderType.isExecutable(100));
    }

    @Test
    public void testIsExecutableOnLimitOrder() {
        OrderType orderType = new OrderType.Limit(100);

        assertTrue(orderType.isExecutable(100));
    }
}

株取引の注文種別を列挙型で実装することで生じる下記の問題は、代数的データ型で実装することで解決できた。

  • 成行注文で setLimitPrice メソッドによって指値の指定ができる
  • 指値注文で指値なしの状態が実現できる

その一方、注文が実行できるかどうかの判定はそもそも注文種別の責務なのかという問題がある。これは、列挙型と代数的データ型でそれぞれ例示した実装に共通する問題になる。注文が実行できるかどうかの判定かどうかを問わず、OrderType クラスの外で、下記のように条件分岐を実現したいことがあるのではないだろうか?

    @Test
    public void testSwitchOnLimitOrder() {
        OrderType orderType = OrderType.LIMIT;
        orderType.setLimitPrice(100);
        int currentPrice = 100;

        boolean result = false;
        switch (orderType) {
            case MARKET:
                result = true;
                break;
            case LIMIT:
                result = currentPrice <= orderType.getLimitPrice();
                break;
            default:
                throw new UnsupportedOperationException("Unsupported order type");
        }

        assertTrue(result);
    }
    @Test
    public void testIfOnLimitOrder() {
        OrderType orderType = new OrderType.Limit(100);
        int currentPrice = 100;

        boolean result = false;
        if (orderType instanceof OrderType.Market) {
            result = true;
        } else if (orderType instanceof OrderType.Limit) {
            result = currentPrice <= orderType.getLimitPrice();
        } else {
            throw new UnsupportedOperationException("Unsupported order type");
        }

        assertTrue(result);
    }

このような条件分岐には共通して下記のような問題がある。

  • OrderType クラスに新しい型を追加した場合、すべての OrderType についての条件分岐を探し出して修正する必要がある

この問題を解決するために、Visitor パターンを用いたパターンマッチングを導入してみよう。

Visitor パターンを用いたパターンマッチングを実装する

便宜的にパターンマッチングと呼んでいるが、Visitor パターンを適用するだけなので Scala のようなパターンマッチングを期待しないでほしい。また、isExecutable メソッドはコードを理解しやすくするために削除することにする。

src/main/java/com/example/order/OrderTypeTest.java
package com.example.order;

public abstract class OrderType {
    private OrderType() {
    }

    public static final class Market extends OrderType {
        @Override
        public <T> T match(CaseBlock<T> caseBlock) {
            return caseBlock._case(this);
        }
    }

    public static final class Limit extends OrderType {
        private int limitPrice;

        public Limit(int limitPrice) {
            this.limitPrice = limitPrice;
        }

        public int getLimitPrice() {
            return limitPrice;
        }

        @Override
        public <T> T match(CaseBlock<T> caseBlock) {
            return caseBlock._case(this);
        }
    }

    public interface CaseBlock<T> {
        T _case(Market market);
        T _case(Limit limit);
    }

    public abstract <T> T match(CaseBlock<T> caseBlock);
}

OrderType 抽象クラスに match 抽象メソッドを宣言し、それぞれの型で実装している。その match メソッドは CaseBlock インターフェイスを実装したクラスのインスタンスを受け取り、その _case メソッドを呼び出している。Visitor パターンを改めて説明する必要はないと思うが、ここの _case メソッドがオーバーロードされていることによって、それぞれの型によって処理が分岐することになる。蛇足だが、_case という名前にしているのは、case が予約語で使えないからだ。

この OrderType クラスを使用するテストを書いてみよう。使い方を見た方が理解しやすいと思う。

src/test/java/com/example/order/OrderTypeTest.java
package com.example.order;

import org.junit.Test;
import static org.junit.Assert.*;

public class OrderTypeTest {
    @Test
    public void testPatternMatchingOnLimitOrder() {
        OrderType orderType = new OrderType.Limit(100);
        int currentPrice = 100;

        boolean result = orderType.match(new OrderType.CaseBlock<>() {
            @Override
            public Boolean _case(OrderType.Market market) {
                return true;
            }

            @Override
            public Boolean _case(OrderType.Limit limit) {
                return currentPrice <= limit.getLimitPrice();
            }
        });

        assertTrue(result);
    }
}

これで下記の問題は解決される。

  • OrderType クラスに新しい型を追加した場合、すべての OrderType についての条件分岐を探し出して修正する必要がある

OrderType クラスに新しい型を追加した場合、もちろんコードの修正はしなければならない。しかしながら、条件分岐を上記のようなパターンマッチングで記述している限りは、コンパイル時にエラーが発生するようになるので、修正箇所の特定が容易になり、修正漏れを防ぐことができる。

Lombok で代数的データ型を洗練する

さて、代数的データ型パターンは便利そうだということがわかった。しかしながら、いかんせん定義するのが面倒でコードの見通しも良くない。その点を少し改善するために、ここでは Lombok を導入してみよう。

build.gradle ファイルに下記の行を追加する。

--- a/build.gradle
+++ b/build.gradle
@@ -9,6 +9,7 @@
 plugins {
     // Apply the java-library plugin to add support for Java Library
     id 'java-library'
+    id "io.freefair.lombok" version "4.1.5"
 }

 repositories {

Lombok を使用して書くとこうなる。

src/main/java/com/example/order/OrderTypeTest.java
package com.example.order;

import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.Value;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public abstract class OrderType {
    @Value
    @EqualsAndHashCode(callSuper = false)
    public static class Market extends OrderType {
        @Override
        public <T> T match(CaseBlock<T> caseBlock) {
            return caseBlock._case(this);
        }
    }

    @Value
    @EqualsAndHashCode(callSuper = false)
    public static class Limit extends OrderType {
        int limitPrice;

        @Override
        public <T> T match(CaseBlock<T> caseBlock) {
            return caseBlock._case(this);
        }
    }

    public interface CaseBlock<T> {
        T _case(Market market);
        T _case(Limit limit);
    }

    public abstract <T> T match(CaseBlock<T> caseBlock);
}

プライベートコンストラクタの定義を NoArgsConstructor アノテーションで実現する。取引種別は値オブジェクトを生成するクラスとして Value アノテーションを付与する。これで Market クラスも Limit クラスも final クラスになる。Limit クラスのフィールドの宣言を簡潔にしているが、フィールドはすべて private final になるし、コンストラクターとゲッターも自動的に生成される。なお EqualsAndHashCode アノテーションは、EqualsAndHashCode アノテーションを明示しなさいという警告が発生するので付与している。

スタティックコンストラクターを用意する

ここからは好みの問題だと思うけど、スタティックコンストラクターを用意するとよりそれっぽくなる。

src/main/java/com/example/order/OrderTypeTest.java
package com.example.order;

import lombok.*;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public abstract class OrderType {
    @Value
    @EqualsAndHashCode(callSuper = false)
    @AllArgsConstructor(access = AccessLevel.PROTECTED)
    public static class Market extends OrderType {
        @Override
        public <T> T match(CaseBlock<T> caseBlock) {
            return caseBlock._case(this);
        }
    }

    public static OrderType market() {
        return new Market();
    }

    @Value
    @EqualsAndHashCode(callSuper = false)
    @AllArgsConstructor(access = AccessLevel.PROTECTED)
    public static class Limit extends OrderType {
        int limitPrice;

        @Override
        public <T> T match(CaseBlock<T> caseBlock) {
            return caseBlock._case(this);
        }
    }

    public static OrderType limit(int limitPrice) {
        return new Limit(limitPrice);
    }

    public interface CaseBlock<T> {
        T _case(Market market);
        T _case(Limit limit);
    }

    public abstract <T> T match(CaseBlock<T> caseBlock);
}

OrderType.market メソッドと OrderType.limit メソッドのようにスタティックコンストラクターを用意する。その上で Market クラスと Limit クラスのコンストラクターを AllArgsConstructor アノテーションでプロテクティッドコンストラクターにしている。

テストも微修正する。

src/test/java/com/example/order/OrderTypeTest.java
package com.example.order;

import org.junit.Test;
import static org.junit.Assert.*;

public class OrderTypeTest {
    @Test
    public void testPatternMatchingOnLimitOrder() {
        OrderType orderType = OrderType.limit(100);
        int currentPrice = 100;

        boolean result = orderType.match(new OrderType.CaseBlock<>() {
            @Override
            public Boolean _case(OrderType.Market market) {
                return true;
            }

            @Override
            public Boolean _case(OrderType.Limit limit) {
                return currentPrice <= limit.getLimitPrice();
            }
        });

        assertTrue(result);
    }
}

ここまでやるとアノテーションでごちゃごちゃするのでお好みでどうぞ。

株取引の注文種別を代数的データ型として Scala で実装する

これが Scala だと簡単に実現できるということで Scala の REPL でやってみる。

scala> :paste 
// Entering paste mode (ctrl-D to finish)

sealed trait OrderType
case object Market extends OrderType
case class Limit(limitPrice: Int) extends OrderType

// Exiting paste mode, now interpreting.

defined trait OrderType
defined object Market
defined class Limit

scala> :paste 
// Entering paste mode (ctrl-D to finish)

val orderType: OrderType = Limit(100)
val currentPrice = 100

orderType match {
  case Market => true
  case Limit(limitPrice) => currentPrice <= limitPrice
}

// Exiting paste mode, now interpreting.

orderType: OrderType = Limit(100)
currentPrice: Int = 100
res3: Boolean = true

Scala だと簡潔に書ける。Kotlin も簡潔に書けそうだけど試してはいない。


今回は代数的データ型パターンなるものを説明してみた。なんだかんだ Java って表現力豊かだ。また、Java が進化する方向性として代数的データ型パターンを実装しやすくなるような文法が導入されていきそうなので、今後の Java からは目が離せなさそうである。とはいえ、Scala の表現力はやはり魅力的なので、Java も Scala も楽しんでいきたいね。

参考文献

Reactive in practice: A complete guide to event-driven systems development in Java

34
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
5