LoginSignup
1
0

More than 3 years have passed since last update.

ShapelessとPhantomTypeで値のコンテキストを型で表現する

Posted at

scala ネタ

背景

ある型 A に置いて特定の処理を通ったこと(値が加工されたことを)を型としてラベル付けしたいときってありますよね
コードにすると

case class UserList(
  underlying: List[User]
) {
  def filterByEnabled: UserList = UserList(
    underlying.filter(_.isEnabled)
  )
}

object UI {
  def view(values: UserList): Unit = ???
}

object UserRepository {
  def getLists(): UserList = ???
}

object Main {
  def run(): Unit = {
    val xs = UserRepository.getLists()

    val filtered = xs.filterByEnabled

    // ここには必ずFilter後の値を渡したい
    UI.view(filtered)
  }
}

このとき UI.view には必ずFilter処理を行った値を渡したいとします
そうすると間違って未処理の xs を渡す可能性を無くしたいですよね

目的

背景を踏まえた上で
UI.view には必ず filterByEnabled を行った UserList のみを渡すよう型を構成します
つまり、型チェックによって未処理の値を渡してしまうのを防ぎます

以下段階的に対応を書いていきますが
タイトルにも書いてある shapeless を使用しているのは 解決案3 のみです

解決案1

「処理後」を表す型を用意し、引数側でそれを受け取る
(パターンとして名前があるかは存じないですが割と一般的に使われていると思っています)

case class UserList(
  underlying: List[User]
) {
  def filterByEnabled: UserList = UserList(
    underlying.filter(_.isEnabled)
  )
}

case class EnabledUserList(
  underlying: List[User]
)

object UI {
  def view(values: EnabledUserList): Unit = ???
}

pros

  • 一般的なclass機能のみで完結している
  • 型の名前でどういう処理が行われたのあとなのかが一眼でわかる

cons

  • 値がもつコンテキスト以外は同じ型を複数回定義することになる
  • 型チェックしたい処理が増えれば増えるほどクラスが増える
  • 複数の状態を定義したとき、掛け合わせ状態を表現するのが辛い

解決案2

PhantomType
いわゆる実行時には何も影響を与えない、型チェック時にのみ影響を与える型パラメタのことです

case class UserList[S <: UserList.State](
  underlying: List[User]
) {
  def filterByEnabled(implicit ev: S =:= UserList.Init): UserList[UserList.OnlyEnabled] = UserList(
    underlying.filter(_.isEnabled)
  )
}

object UserList {
  sealed trait State
  trait Init extends State
  trait OnlyEnabled extends State

  def init(values: List[User]): UserList[UserList.Init] = UserList(values)
}
object UI {
  def view(values: UserList[UserList.OnlyEnabled]): Unit = ???
}

pros

  • ひとつのクラスでまとまっている

cons

  • 複数の状態を定義したとき、掛け合わせ状態を表現するのが辛い

解決案3

PhantomType & Coproduct
Coproduct は直和の英訳、および直和を表現するShapelessのclassです
前2案で解決できなかった「複数の状態を定義したとき、掛け合わせ状態を表現するのが辛い」という短所を解決するために導入します

型の集合として扱うCoproduct

(本来の使い方ではありません、ただ用途に対して必要な要素が揃っているためCoproductを使います)
ShapelessにCoproduct型は type X = A :+: B :+: CNil のように構築できます
これは X = A | B という直和型を生成しています(CNilは実装の都合上付随するものなので無視します)
今回は型の集合として扱いたいのでこれを XAB の要素で構成された集合、とします
集合でありながら A :+: B :+: A :+: CNil など同じ型が複数回出現する集合もかけてしまいますが、用途の上
で支障はないので黙認することにします

この型に対する値は A or B のどちらか、なのですがPhantomTypeとして扱うので値は気にしなくもよくなります

集合 X に任意型 A が含まれていることを型チェックする

型が含まれているかを調べるには Selector 型をimplicitパラメタとして取得することでチェックできます

import shapeless._
import shapeless.ops.coproduct._
import shapeless.syntax._

object SelectorTest {

  type X = Int :+: String :+: CNil

  def test(): Unit = {
     implicitly[Selector[X, Int]]
     implicitly[Selector[X, String]]
     // implicitly[Selector[X, Boolean]] => compile error!
  }
}

扱いやすくするために以下のtype aliasを定義しましょう

type [A, P <: Coproduct] = Selector[P, A]

集合 X に任意型 B が含まれて "いない" ことを型チェックする

型が含まれていないことを調べるには NotIn 型をimplicitパラメタとして取得することでチェックできます

object NotInTest {
  import shapeless.ops.hlist.ToCoproductTraversable.NotIn

  type X = Int :+: String :+: CNil

  def test(): Unit = {
    implicitly[NotIn[X, Boolean]]
    implicitly[NotIn[X, Double]]
    // implicitly[NotIn[X, String]] => compile error!
  }
}

扱いやすくするために以下のtype aliasを定義しましょう

type [A, P <: Coproduct] = NotIn[P, A]

実装

Coproduct による型の集合表現については説明しましたのであとはこれを用いて当初の目的を実装していきましょう
解決案2 で提示したState PhantomTypeには処理によって付与されたStateの集合である Coproduct を使います
値を必要とする関数の引数では SelectorNotIn をimplicitパラメタとして指定することで値に必要なコンテキストを指定することができます
以下が実装コードとなります(複数の組み合わせによるState表現を示したいので当初よりメソッドの数を増やしています)

import shapeless._
import shapeless.ops.coproduct._
import shapeless.syntax._
import shapeless.ops.hlist.ToCoproductTraversable.NotIn

object Helper {
  type [A, P <: Coproduct] = NotIn[P, A]
  type [A, P <: Coproduct] = Selector[P, A]
}

import Helper._

case class User(
  /* user attributes */
) {
  def isEnabled: Boolean = true
  def isViewable: Boolean = true
}
case class UserList[StateList <: Coproduct](
  underlying: List[User]
) {
  import UserList.State._

  def filterByEnabled(implicit notIn: FilteredByEnabled  StateList): UserList[FilteredByEnabled :+: StateList] = UserList(
    underlying.filter(_.isEnabled)
  )
  def filterByViewable(implicit notIn: FilteredByViewable  StateList): UserList[FilteredByViewable :+: StateList] = UserList(
    underlying.filter(_.isViewable)
  )
}

object UserList {
  def init(values: List[User]): UserList[State.Init] = UserList(values)

  sealed trait State
  case object State {
    type Init = CNil
    trait FilteredByEnabled extends State
    trait FilteredByViewable extends State
  }
}

object UI {
  import UserList.State._

  def view[StateList <: Coproduct](xs: UserList[StateList])(
    implicit s1: FilteredByEnabled  StateList,
             s2: FilteredByViewable  StateList
  ): Unit = println(xs)
}

object UserRepository {
  def getLists(): UserList[UserList.State.Init] = UserList.init(Nil)
}

object Test {
  def run(): Unit = {
    val init = UserRepository.getLists()
    val filtered = init.filterByEnabled
    // filtered.filterByEnabled => compile error
    val viewable = filtered.filterByViewable
    // UI.view(filtered) => compile error
    UI.view(viewable)
  }
}

Stateの初期値としては CNil を利用します
Ui.view 関数はStateの集合に FilteredByEnabledFilteredByViewable が存在していることを期待します
つまり、 filterByEnabled filterByViewable のふたつの関数をクリアしていない値を渡すとコンパイルエラーになります、掛け合わせ状態も表現できていますね
今回は書きませんでしたが SelectorNotIn 両方受け取ることで A を処理していて B を未処理、みたいな複雑なコンテキストで制限をかけることも可能です

pros

  • 掛け合わせの状態も対処できる

cons

  • 実装が複雑

実際productionコードとして使うならコンパイルエラー時のメッセージの改善やimplicit周りをもう少し完結に書きたい気持ちはありますね
何かのご参考までに

1
0
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
1
0