2016年7月31日日曜日

[SDN] Generalized type constraints

Scalaの持つ型機能にGeneralized type constraintsがあります。訳語がよく分からなかったのでここでは型制約と呼びます。

Scalaプログラミングの要諦は、いかにプログラムのバグをコンパイルエラーで検出するか、だとすると型制約はこの目的を進めるために有効な言語機能です。使えるところではきっちりと使っていきたいところです。

そんなこともあり型制約の利用方法の最新状況が気になったので調べてみました。

型制約については先達の素晴らしい解説があります。

調べた範囲では、型制約の利用方法は上記ブログで取り上げている以下の2つにつきるようです。

  • 型によって有効になるメソッド
  • ビルダ

また上記の調査をしている最中に以下の用途もあるかもというのを思いつきました。今の所使用例は見かけていませんが、もしかして有効かもという使い方です。

  • 状態/状態遷移

おさらいも含めて上記3つの使い方について説明します。

型によって有効になるメソッド

「型によって有効になるメソッド」は型制約の基本的な使い方です。型制約が元々想定している利用方法だと思います。

以下では型パラメタTを持ったケースクラスResourceを定義しています。

openFileメソッドは「<:<」による型制約定義でResourceが保持しているリソースがFile(またはFileのサブクラス)だった時のみ有効となります。

  1. case class Resource[T](r: T) {  
  2.     def openFile(implicit ev: T <:< File): InputStream = {  
  3.       new FileInputStream(r)  
  4.     }  
  5.   }  

この目的で型制約を使うことによって以下の効能があります。

  • よく使われる特定のリソース向けのメソッドを簡単に定義できる。(この機能がないとトレイトの継承といった大掛かりな仕組みを使う必要がある。)
  • 有効でないリソースに対して誤ったメソッドが呼ばれることを防ぐ。
  • リソースの型がFileに定義された状態でメソッドの実装を行うことができる。
コンパイルエラーによる検出

コンパイルエラーとなる例です。

数値100を格納したResourceを作成し、openFileメソッドを呼び出します。

  1. val r = Resource(100)  
  2.     val in = r.openFile  

このコードは以下のように型チェックでコンパイルエラーとなります。このように誤った使い方をした場合、コンパイルエラーとして検出することができるわけです。

[error] .../Main.scala:45: Cannot prove that Int <:< java.io.File.
[error]     val in = r.openFile
[error]                ^
[error] one error found

ビルダ

型制約の使い方として有名なのがビルダです。

ビルダで必須項目が設定されていない状態でビルドを行うコードはコンパイルエラーになります。

以下のUrlBuilderはjava.net.URL用ビルダの例です。

  1. import java.net.URL  
  2. import UrlBuilder._  
  3.   
  4. case class UrlBuilder[HasProtocol <: YesNo, HasHost <: YesNo] private (  
  5.   private val _protocol: Option[String] = None,  
  6.   private val _host: Option[String] = None,  
  7.   private val _port: Option[Int] = None,  
  8.   private val _file: Option[String] = None  
  9. ) {  
  10.   def isCompleted = _protocol.isDefined && _host.isDefined  
  11.   
  12.   def protocol(s: String) = new UrlBuilder[Yes, HasHost](  
  13.     Some(s), _host, _port, _file)  
  14.   def host(s: String) = new UrlBuilder[HasProtocol, Yes](  
  15.     _protocol, Some(s), _port, _file)  
  16.   def port(s: Int) = copy(_port = Some(s))  
  17.   def file(s: String) = copy(_file = Some(s))  
  18.   
  19.   def build(implicit ev1: HasProtocol =:= Yes, ev2: HasHost =:= Yes): URL =  
  20.     (_protocol, _host, _port, _file) match {  
  21.       case (Some(s), Some(a), Some(p), Some(f)) => new URL(s, a, p, f)  
  22.       case (Some(s), Some(a), Some(p), None) => new URL(s, a, p, "")  
  23.       case (Some(s), Some(a), None, None) => new URL(s, a, "")  
  24.       case (Some(s), Some(a), None, Some(f)) => new URL(s, a, f)  
  25.       case _ => throw new IllegalStateException(s"Illegal parameters $this")  
  26.     }  
  27. }  
  28.   
  29. object UrlBuilder {  
  30.   sealed trait YesNo  
  31.   sealed trait Yes extends YesNo  
  32.   sealed trait No extends YesNo  
  33.   
  34.   def builder = new UrlBuilder[No, No]()  
  35. }  

型パラメタに設定する目的のトレイトYesNoとそのサブトレイトYes、Noを定義しています。

UrlBuilderは2つの型パラメタHasProtocolとHasHostを定義していて、いずれもトレイトYesNoとそのサブトレイトを型として設定できるようにしています。

ポイントはbuildメソッドで型制約「=:=」を使って型パラメタHasProtocolとHasHostの型をYesに限定しているところです。こうすることで型パラメタHasProtocolまたはHasHostにYesが設定されていない場合はコンパイルエラーとなります。

型パラメタHasProtocolにYesが設定されるのは、protocolメソッドが呼ばれてprotocolが設定された時です。また型パラメタHasHostにYesが設定されるのは、hostメソッドが呼ばれてHostが設定された時です。

つまり必須項目であるプロトコルとホストが設定されていない状態でbuildメソッドを使ってURLを生成しようとするとコンパイルエラーとなるわけです。

使い方

以下のようにprotocolとhostを設定後にbuildメソッドを呼び出すとコンパイルエラーにはなりません。

  1. UrlBuilder.builder.protocol("http").host("example.com").build  
コンパイルエラーによる検出

UrlBuilderにパラメタを設定しないでbuildメソッドを呼び出します。

  1. UrlBuilder.builder.build  

すると以下のようにコンパイルエラーとなります。

[error] .../Main.scala:16: Cannot prove that sample.UrlBuilder.No =:= sample.UrlBuilder.Yes.
[error]     UrlBuilder.builder.build
[error]                        ^

状態

型制約の使い方として「型によって有効になるメソッド」と「ビルダ」以外に、状態や状態遷移の記述にも使えるのではと思いついたので、そのアイデアの紹介です。

状態や状態遷移の操作に対するバグをコンパイル時に検出できればメリットは非常に大きいと思います。ただ、イベント駆動プログラムのような動的な処理で使用できる範囲は狭いと思われるので、ぴったりフィットするユースケースがあるかは将来課題です。

GreetingServiceは、指定された範囲に挨拶メッセージを送るサービスです。

以下の2つの状態を持ちます。

  • Openされているか否か
  • Assignされているか否か

GreetingServiceは全世界にメッセージを送る場合は、大量配信になるため復数のリクエスをと同時実行できないので、事前にリソースをアサインする必要があるという設定です。

  1. import java.net.URL  
  2. import GreetingService._  
  3.   
  4. case class GreetingService[IsOpened <: YesNo, IsAssigned <: YesNo] private (private val _url: URL) {  
  5.   def open()(implicit ev1: IsOpened =:= No, ev2: IsAssigned =:= No) = new GreetingService[Yes, IsAssigned](_url)  
  6.   def close()(implicit ev: IsOpened =:= Yes) = new GreetingService[No, IsAssigned](_url)  
  7.   def assign()(implicit ev1: IsOpened =:= Yes, ev2: IsAssigned =:= No) = new GreetingService[IsOpened, Yes](_url)  
  8.   def release()(implicit ev1: IsOpened =:= Yes, ev2: IsAssigned =:= Yes) = new GreetingService[IsOpened, No](_url)  
  9.   
  10.   def helloLocalArea(msg: String)(implicit ev: IsOpened =:= Yes) {  
  11.     // do send to local area  
  12.   }  
  13.   
  14.   def helloWorldWide(msg: String)(implicit ev1: IsOpened =:= Yes, ev2: IsAssigned =:= Yes) {  
  15.     // do send to world wide  
  16.   }  
  17. }  
  18.   
  19. object GreetingService {  
  20.   sealed trait YesNo  
  21.   sealed trait Yes extends YesNo  
  22.   sealed trait No extends YesNo  
  23.   
  24.   def create(url: URL) = new GreetingService[No, No](url)  
  25. }  

上記2つの状態を型パラメタIsOpenedとIsAssingedで記述します。

型パラメタに設定する型はビルダで使用したYesNo, Yes, Noと同じ方式のものです。

GreetingServiceのポイントはopen, close, assign, releaseの各メソッドが「=:=」による型制約定義でGreetingServiceの状態によって以下の制約を持っていることです。

open
オープンもアサインのされていない時のみ可
close
オープン済みの時のみ可
assign
オープン済みで未アサインの時のみ可
release
オープン済み、アサイン済みの時のみ可

closeメソッドはエラー処理などでアサイン状態にかかわらずクローズしたいケースを想定してアサインの制約は設定していません。

使い方

GreetingServiceは以下のようにして使います。

  1. val url = UrlBuilder.builder.protocol("http").host("example.com").build  
  2.     val srv = GreetingService.create(url)  
  3.     val opened = srv.open()  
  4.     opened.helloLocalArea("local")  
  5.     val assigned = opened.assign()  
  6.     assigned.helloLocalArea("local")  
  7.     assigned.helloWorldWide("world")  

openメソッドでオープンした後のGreetingServiceではhelloLocalAreaメソッドでローカル向けのメッセージ送信ができます。

assignメソッドでアサインした後のGreetingServiceではhelloLocalAreaメソッドでのローカル向けのメッセージ送信に加えて、helloWorldWideメソッドで全世界向けのメッセージ送信が可能になっています。

コンパイルエラーによる検出

誤った使用をコンパイルエラーで検出できるか見ていきます。

まずオープン前のGreetingServiceでメッセージを送る場合です。

  1. val url = UrlBuilder.builder.protocol("http").host("example.com").build  
  2.     val srv = GreetingService.create(url)  
  3.     srv.helloLocalArea("local")  

無事helloLocalAreaメソッドがコンパイルエラーになりました。

[error] .../Main.scala:50: Cannot prove that sample.GreetingService.No =:= sample.GreetingService.Yes.
[error]     srv.helloLocalArea("local")
[error]                       ^

次にオープン済みで未アサインの場合に全世界向けメッセージ送信する場合です。

  1. val url = UrlBuilder.builder.protocol("http").host("example.com").build  
  2.     val srv = GreetingService.create(url)  
  3.     val opened = srv.open()  
  4.     opened.helloLocalArea("local")  
  5.     opened.helloWorldWide("world")  

こちらも無事helloWorldWideメソッドがコンパイルエラーになりました。

[error] .../Main.scala:58: Cannot prove that sample.GreetingService.No =:= sample.GreetingService.Yes.
[error]     opened.helloWorldWide("world")
[error]                          ^
ユーティリティメソッド

GreetingServiceを引数にするユーティリティメソッドでも型パラメタのチェックを行うことができます。型チェックをしたくない項目は「_」にしておけばよいようです。

  1. def sendToLocalAreal(srv: GreetingService[Yes, _]) {  
  2.     srv.helloLocalArea("local")  
  3.   }  
  4.   
  5.   def sendToWorldWide(srv: GreetingService[Yes, Yes]) {  
  6.     srv.helloWorldWide("world")  
  7.   }  

使い方は以下になります。

  1. val url = UrlBuilder.builder.protocol("http").host("example.com").build  
  2.     val srv = GreetingService.create(url)  
  3.     val opened = srv.open()  
  4.     sendToLocalAreal(opened)  
  5.     val assigned = opened.assign()  
  6.     sendToWorldWide(assigned)  

状態遷移

「状態」の発展形です。

表現できる範囲は限定的ですが、状態遷移を型パラメタを使って実現することも可能です。

以下のGreetingServiceは前述のGreetingServiceと機能は同じですが、型制約の実現方法を変更しています。

具体的には、GreetingServiceは未オープン⇔オープン済⇔アサイン済の階層構造で状態遷移を行うので、この状態遷移の構造を反映した型を使用します。

  1. import java.net.URL  
  2. import GreetingService._  
  3.   
  4. case class GreetingService[+State <: OpenState] private (private val _url: URL) {  
  5.   def open()(implicit ev: State <:< Closed) = new GreetingService[Opened](_url)  
  6.   def close()(implicit ev: State <:< Opened) = new GreetingService[Closed](_url)  
  7.   def assign()(implicit ev: State <:< Opened) = new GreetingService[Assigned](_url)  
  8.   def release()(implicit ev: State <:< Assigned) = new GreetingService[Opened](_url)  
  9.   
  10.   def helloLocalArea(msg: String)(implicit ev: State <:< Opened) {  
  11.     // do send to local area  
  12.   }  
  13.   
  14.   def helloWorldWide(msg: String)(implicit ev1: State <:< Assigned) {  
  15.     // do send to world wide  
  16.   }  
  17. }  
  18.   
  19. object GreetingService {  
  20.   sealed trait OpenState  
  21.   sealed trait Closed extends OpenState  
  22.   sealed trait Opened extends OpenState  
  23.   sealed trait Assigned extends Opened  
  24.   
  25.   def create(url: URL) = new GreetingService[Closed](url)  
  26. }  

まず型制約の用の型としてOpenStateトレイトを定義し、このサブトレイトとしてClosedトレイトとOpenedトレイトを、OpenedトレイトのサブトレイトとしてAssignedトレイトを定義しました。

OpenedトレイトとAssignedトレイトのサブトレイト関係で状態遷移の階層構造を表現しています。

OpenState, Opened, Assignedのトレイトを使用してopen, close, assign, releaseの各メソッドが「<:<」による型制約定義によりGreetingServiceの状態によって以下の制約を課しています。この例ではトレイト間のサブクラス関係も利用するので「=:=」ではなく「<:<」を使用しています。

open
Closedの時のみ使用可(オープンもアサインのされていない時のみ可)
close
Openedの時のみ使用可(オープン済みの時のみ可)
assign
Openedの時のみ使用可(オープン済みの時のみ可)
release
Assginedの時のみ使用可(オープン済み、アサイン済みの時のみ可)

前出の「状態」との違いはassignメソッドの制約条件が若干違うことですが、基本的には同等のものとなっています。

使い方

GreetingServiceの使い方は前出の「状態」のものと同じです。

  1. val url = sample.UrlBuilder.builder.protocol("http").host("example.com").build  
  2.     val srv = GreetingService.create(url)  
  3.     val opened = srv.open()  
  4.     opened.helloLocalArea("local")  
  5.     val assigned = opened.assign()  
  6.     assigned.helloLocalArea("local")  
  7.     assigned.helloWorldWide("world")  
コンパイルエラーによる検出

コンパイルエラーによるエラー検出も前出の「状態」のもの基本的には同じになります。

まずオープン前のGreetingServiceでメッセージを送る場合です。

  1. val url = sample.UrlBuilder.builder.protocol("http").host("example.com").build  
  2.     val srv = GreetingService.create(url)  
  3.     srv.helloLocalArea("local")  

無事helloLocalAreaメソッドがコンパイルエラーになりました。

[error] .../Main.scala:36: Cannot prove that sample2.GreetingService.Closed <:< sample2.GreetingService.Opened.
[error]     srv.helloLocalArea("local")
[error]                       ^

次にオープン済みで未アサインの場合に全世界向けメッセージ送信する場合です。

  1. val url = sample.UrlBuilder.builder.protocol("http").host("example.com").build  
  2.     val srv = GreetingService.create(url)  
  3.     val opened = srv.open()  
  4.     opened.helloLocalArea("local")  
  5.     opened.helloWorldWide("world")  

こちらも無事helloWorldWideメソッドがコンパイルエラーになりました。

[error] .../Main.scala:44: Cannot prove that sample2.GreetingService.Opened <:< sample2.GreetingService.Assigned.
[error]     opened.helloWorldWide("world")
[error]                          ^

まとめ

Generalized type constraintsはかなり以前(2.8)からある機能ですが、新しい使い方などが登場していないかという確認の意味もあり利用方法について調べてみました。

特に新たしい使用方法は見つけることはできませんでしたが、調べている中で「状態/状態遷移」の実装方法について思いついたことがあったので形にしてみました。うまくするとフィットする利用方法が見つかるかもしれません。

Generalized type constraintsについては、当初は暗黙変換も対象にする型制約「<%<」があったのですが、最近の版では使えなくなっているようです。この型制約があると「型によって有効になるメソッド」の応用で色々と技が使えそうだったのですが、色々負担の大きそうな機能なのでいたしかたなさそうです。

いずれにしてもGeneralized type constraintsが強力な機能であることを再認識しました。使えそうなポイントを見つけて製品開発にも適用していきたいと思います。

諸元

  • Java 1.7.0_75
  • Scala 2.11.7