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は実装の都合上付随するものなので無視します)
今回は型の集合として扱いたいのでこれを X
は A
と B
の要素で構成された集合、とします
集合でありながら 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
を使います
値を必要とする関数の引数では Selector
や NotIn
を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の集合に FilteredByEnabled
と FilteredByViewable
が存在していることを期待します
つまり、 filterByEnabled
filterByViewable
のふたつの関数をクリアしていない値を渡すとコンパイルエラーになります、掛け合わせ状態も表現できていますね
今回は書きませんでしたが Selector
と NotIn
両方受け取ることで A
を処理していて B
を未処理、みたいな複雑なコンテキストで制限をかけることも可能です
pros
- 掛け合わせの状態も対処できる
cons
- 実装が複雑
実際productionコードとして使うならコンパイルエラー時のメッセージの改善やimplicit周りをもう少し完結に書きたい気持ちはありますね
何かのご参考までに