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
抽象メソッドを定義して、各列挙値でオーバーライドしている。
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
列挙型を使用するテストを書いてみよう。
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 で参照されている。そのシールドクラスパターンで成行注文と指値注文のみを代数的データ型として実装してみよう。
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
クラスを使用するテストを書いてみよう。
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
メソッドはコードを理解しやすくするために削除することにする。
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
クラスを使用するテストを書いてみよう。使い方を見た方が理解しやすいと思う。
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 を使用して書くとこうなる。
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
アノテーションを明示しなさいという警告が発生するので付与している。
スタティックコンストラクターを用意する
ここからは好みの問題だと思うけど、スタティックコンストラクターを用意するとよりそれっぽくなる。
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
アノテーションでプロテクティッドコンストラクターにしている。
テストも微修正する。
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