class: center, middle # 型とデータ構造で
制約を表現する Scalaわいわい勉強会 #4 2024/12/13
@gakuzzzz --- class: left, top ## 自己紹介 * 中村 学/Manabu NAKAMURA * Twitter: [@gakuzzzz](https://twitter.com/gakuzzzz) * [Tech to Value Co.,Ltd.](https://www.t2v.jp/) CEO * [Alp, Inc.](https://thealp.co.jp/) Tech Lead --- class: left, top ## 今日話す事 Scala はメジャーなプログラミング言語の中でも、型の表現力が高い言語です。 このセッションでは、この型の表現力を活かして、プログラムにおける制約を型やデータ構造で表現する具体例を紹介します。 ここで言う「制約」とは、たとえば `require` 等で表現するようなプログラム上の事前条件や不変条件などの事です。 ```scala class Enquete(title: String, questions: List[Question]) { // アンケートは必ず設問を一つ以上持つ require(questions.size > 0) } ``` --- class: left, top ## 前提 ところで、そもそも制約を型やデータ構造で表現すると何が嬉しいのでしょうか? --- class: left, top ## 前提 制約を型やデータ構造で表現することのメリットは以下が考えられます 1. **コンパイル時の検査**: 型を利用することでコンパイル時に制約を検査でき、そもそも制約に反するようなコードを書くことができなくなります 2. **再利用性の向上**: 制約を型やデータ構造として定義することで、同じ制約を持つ他の部分でも再利用しやすくなります 3. **データ導出のサポート**: Property-Based Testing などで型からテストデータを自動生成する際に、制約を満たしたテストデータが生成しやすくなります --- class: left, top ## 前提 制約を上手に表現して、堅牢なプログラムを高速に開発していきましょう --- class: center, middle # 最初の例:一つ以上 --- class: left, top ## 一つ以上 一つ以上という制約は非常によく頻出します ```scala class Enquete(title: String, questions: List[Question]) { // アンケートは必ず設問を一つ以上持つ require(questions.size > 0) } ``` この制約を `require` を使わずに表現してみましょう --- class: left, top ## 一つ以上 **案1. そういう型があるライブラリを使う ** ```scala import cats.data.NonEmptyList class Enquete(title: String, questions: NonEmptyList[Question]) ``` この制約は頻出するので、[cats](https://typelevel.org/cats/) などのライブラリでは、直接この制約を満たしたデータ構造が提供されています `map` や `filter` など様々なメソッドもそのまま使えるので、こういったライブラリを使用しているのであればぜひ活用していきましょう --- class: left, top ## 一つ以上 **案2. 先頭もしくは末尾を分離する ** `NonEmptyList` などを提供しているライブラリが使えない場合、先頭や末尾の1件を分離して持つという方法もあります ```scala class Enquete(title: String, head: Question, tail: List[Question]) ``` こうする事で必ず一つの要素が存在する事を保証することができます --- class: left, top ## 一つ以上 **案2. 先頭もしくは末尾を分離する ** ただ、このままでは `map` や `filter` といった操作が扱い辛いので、内部的に連結するような定義を用意しておくと便利です ```scala class Enquete(title: String, head: Question, tail: List[Question]){ private def questions: List[Question] = head +: tail } ``` --- class: left, top ## 一つ以上 **案3.
篩型
(
ふるいがた
)
ライブラリを使う ** ```scala import io.github.iltotore.iron.* import io.github.iltotore.iron.constraint.all.* class Enquete( title: String, questions: List[Question] :| MinLength[1] ) ``` [Iron](https://github.com/Iltotore/iron) や [refined](https://github.com/fthomas/refined) などのライブラリを導入すると、型として条件式を記述する事ができます 条件を細かく指定したり組み合わせたりする事ができるので、「三つ以上」とか「一つ以上五つ以下」といった制約も表現する事ができます --- class: center, middle # 次の例:特定状態時のみの値 --- class: left, top ## 特定状態時のみの値 例として配送システムの注文を考えてみましょう ```scala enum OrderStatus { case Pending, Shipped, Delivered } class Order( id: OrderId, status: OrderStatus, trackingCode: Option[String] ) { // 出荷済みならトラッキングコードを持つ require(status == OrderStatus.Shipped && choices.isDefined) } ``` 注文は `Pending`, `Shipped`, `Delivered` の三つのステータスがあり、`Shipped` の状態では必ずトラッキングコードを持つという制約があります これを別の形で表現してみましょう --- class: left, top ## 特定状態時のみの値 **案1. 状態毎に構造を分ける** ```scala enum Order { case Pending(id: OrderId) case Shipped(id: OrderId, trackingCode: String) case Delivered(id: OrderId) } ``` `Order` そのものを状態毎に構造を分けて定義します こうすることで、 `Shipped` の状態だけ `trackingCode` を持つことを明示する事ができます また、他に用途が無ければ `OrderStatus` 自体を無くす事も可能になります --- class: left, top ## 特定状態時のみの値 **案2. 状態側に構造を持たせる** ```scala enum OrderStatus { case Pending case Shipped(trackingCode: String) case Delivered } class Order(id: OrderId, status: OrderStatus) ``` 状態に依存しない属性が少ない場合は 案1. は単純でわかりやすいです しかし仮に `Order` が状態に依存しない属性を沢山持っていた場合、単純に `Order` 自体を状態毎に分けてしまうと、共通の属性を大量に持たせる必要がでてきてしまい煩雑になってしまいます そこで、状態に依存する属性だけを取り出し別の構造として持たせる事で、状態による構造の差分を管理しやすくすることができます --- class: center, middle # ケーススタディ:料金表の例 --- class: left, top ## 料金表の例 よくある料金表の例です |範囲 |1アカウントあたり| |----------|-----| |50人未満 |120円| |100人未満 |140円| |150人未満 |210円| |250人未満 |250円| |上限なし |300円| --- class: left, top ## 料金表の例 DBに永続化している構造をそのまま表現した形 ```scala case class Tier( upperLimit: Option[Int] pricePerAccount: Money ) ``` ```scala case class Tiers(values: List[Tier]) { def upperLimits = values.flatMap(_.upperLimit) assert(values.nonEmpty, "ティアは必ず一つ以上") assert(values.last.upperLimit.isEmpty, "最後は上限値無し") assert(values.count(_.isEmpty) == 1, "上限値無しは1つだけ") assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` --- class: left, top ## 料金表の例 ここにも最初に話した一つ以上という条件がありますね ```scala case class Tiers(values: List[Tier]) { def upperLimits = values.flatMap(_.upperLimit) * assert(values.nonEmpty, "ティアは必ず一つ以上") assert(values.last.upperLimit.isEmpty, "最後は上限値無し") assert(values.count(_.isEmpty) == 1, "上限値無しは1つだけ") assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` まずはこれを直してみましょう --- class: left, top ## 料金表の例 ここにも最初に話した一つ以上という条件がありますね .diff-rm[ ```scala *case class Tiers(`values: List[Tier]`) { * def upperLimits = `values`.flatMap(_.upperLimit) * assert(values.nonEmpty, "ティアは必ず一つ以上") * assert(`values.`last.upperLimit.isEmpty, "最後は上限値無し") * assert(`values`.count(_.isEmpty) == `1`, "上限値無しは`1つだけ`") assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` ] まずはこれを直してみましょう --- class: left, top ## 料金表の例 末尾を分離して一つ以上の制約を表現してみました .diff-add[ ```scala *case class Tiers(`range: List[Tier], last: Tier`) { * def upperLimits = `range`.flatMap(_.upperLimit) * * assert(last.upperLimit.isEmpty, "最後は上限値無し") * assert(`range`.count(_.isEmpty) == `0`, "上限値無しは`含まれない`") assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` ] この変更に伴い「上限値無しは1つだけ」という制約は「上限値無しは含まれない」という新しい制約に変わりました --- class: left, top ## 料金表の例 こうなると、`last` は `upperLimit` が不要なので `Tier` である必要も無くなります .diff-rm[ ```scala *case class Tiers(range: List[Tier], last: `Tier`) { def upperLimits = range.flatMap(_.upperLimit) * assert(last.upperLimit.isEmpty, "最後は上限値無し") assert(range.count(_.isEmpty) == 0, "上限値無しは含まれない") assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` ] ```scala case class Tier( upperLimit: Option[Int] pricePerAccount: Money ) ``` --- class: left, top ## 料金表の例 `last` を `Money` にすることで「最後は上限値無し」を表現することができます .diff-add[ ```scala *case class Tiers(range: List[Tier], last: `Money`) { def upperLimits = range.flatMap(_.upperLimit) * assert(range.count(_.isEmpty) == 0, "上限値無しは含まれない") assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` ] ```scala case class Tier( upperLimit: Option[Int] pricePerAccount: Money ) ``` --- class: left, top ## 料金表の例 そうすると `Tier` の `upperLimit` が `Option` の必要も無くなりますね .diff-rm[ ```scala case class Tiers(range: List[Tier], last: Money) { * def upperLimits = range.`flatMap`(_.upperLimit) * assert(range.count(_.isEmpty) == 0, "上限値無しは含まれない") assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` ```scala case class Tier( * upperLimit: `Option[Int]` pricePerAccount: Money ) ``` ] --- class: left, top ## 料金表の例 `Option` を外すことで「上限値無しは含まれない」を表現できます .diff-add[ ```scala case class Tiers(range: List[Tier], last: Money) { * def upperLimits = range.`map`(_.upperLimit) * assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` ```scala case class Tier( * upperLimit: `Int` pricePerAccount: Money ) ``` ] --- class: left, top ## 料金表の例 さて残るは重複がなくソートされているという制約ですね .diff-rm[ ```scala case class Tiers(range: List[Tier], last: Money) { def upperLimits = range.map(_.upperLimit) assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` ] 重複を許さないデータ構造として `Set` や `Map` が考えられます さらに順序が必要なため `SortedSet` もしくは `SortedMap` が使えそうです --- class: left, top ## 料金表の例 `range` のデータ構造を `SortedMap` に変えてみましょう .diff-rm[ ```scala *case class Tiers(range: `List[Tier]`, last: Money) { * def upperLimits = range.`map(_.upperLimit)` * assert(upperLimits.distinct == upperLimits, "上限値は重複しない") * assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` ```scala *case class Tier( * upperLimit: Int * pricePerAccount: Money *) ``` ] --- class: left, top ## 料金表の例 `Tier` 自体も不要になってしまいました .diff-add[ ```scala *case class Tiers(range: `SortedMap[Int, Money]`, last: Money) { * def upperLimits = range.`keys` * * } ``` ```scala * * * * ``` ] ずいぶんすっきりしましたね --- class: left, top ## 料金表の例 最初のコードと比較してみましょう ```scala case class Tiers(values: List[Tier]) { def upperLimits = values.flatMap(_.upperLimit) assert(values.nonEmpty, "ティアは必ず一つ以上") assert(values.last.upperLimit.isEmpty, "最後は上限値無し") assert(values.count(_.isEmpty) == 1, "上限値無しは1つだけ") assert(upperLimits.distinct == upperLimits, "上限値は重複しない") assert(upperLimits.sorted == upperLimits, "上限値でソートされてる") } ``` ```scala case class Tier( upperLimit: Option[Int] pricePerAccount: Money ) ``` --- class: left, top ## 料金表の例 最初のコードと比較してみましょう ```scala case class Tiers(range: SortedMap[Int, Money], last: Money) { def upperLimits = range.keys } ``` 型やデータ構造を工夫することで、動的な制約チェックを無くし不正な状態が起きる可能性を排除する事ができました --- class: left, top ## まとめ - Scala は型の表現力の高い言語です - 使えるライブラリは使っていきましょう - 制約を型やデータ構造で表現することで、より安全にコードを記述する事が可能になります --- class: left, top ## 宣伝 - Tech to Value では [Online Scala コードレビュー サービス](https://www.t2v.jp/#service)を提供しています。 - チームに Scala熟練者が居ない等、ぜひご相談ください。 - 株式会社アドウェイズさんにて[導入事例を記事にして頂きました。](https://blog.engineer.adways.net/entry/2022/08/12/120000) - アルプ株式会社では一緒に働くメンバーを募集しています。 - 気軽にお声がけください! - [アルプ採用情報](https://alpinc.notion.site/52af90188305440d86acf72968646054) --- class: center, middle ## 質問とか --- class: left, top