Share
Embed
record4s --- Extensible Records for Scala 3, and Domain Modeling with Structural Types
In this talk, we learn about the basics of my library called "record4s", which provides type-safe extensible records for Scala 3. It runs on JVM, JS, and Native platforms. You will see how it is implemented efficiently by using Scala 3 macros, and how record types as structural types, which work without reflection in Scala 3, are helpful in domain modeling.
record4s --- Scala 3のための拡張可能レコード、そして構造的型によるドメインモデリング
Scala 3向けの拙作の拡張可能レコードのライブラリ"record4s"を紹介します。JVM・JS・ネイティブのどのプラットフォームでも動作し、型安全なレコード型を提供するものです。Scala 3のマクロを用いて効率的に実装されている点や、Scala 3でリフレクションを必要としなくなった構造的型として表現されたレコード型がドメインモデリングにどう役立つかなどを説明します。
INA Lintaro
June 08, 2024
Transcript
44 slides
-
record4s — Extensible Records for Scala 3, and Domain Modeling
with Structural Types Lintaro INA id:tarao @tarao @oarat 2024-06-08 ScalaMatsuri 2024 record4s — Scala 3 ͷͨΊͷ֦ுՄೳϨίʔυɺ ͦͯ͠ߏతܕʹΑΔυϝΠϯϞσϦϯά -
Are you tired of defining similar models? Example: a few
difference case class BlogPost( id: PostId, title: String, body: String, url: URL, publishedAt: DateTime, ) case class BlogPostWithPaywall( id: PostId, title: String, body: String, paidPartOfBody: Option[String], url: URL, publishedAt: DateTime, ) ྫɿ ϒϩάهࣄΛද͢Ϟσϧʢࠨʣͱɺهࣄͷ༗ྉ෦࣋ͭϞσϧʢӈʣ paidPartOfBody ͷ༗ແ͚ͩҟͳΔ -
Are you tired of defining similar models? Example blogPostRepository.find: PostId
=> Option[ BlogPost ] paywallRepository.findPaidBody: PostId => Option[String] def blogPostWithPaywall(postId: PostId): Option[ BlogPostWithPaywall ] = blogPostRepository.find(postId).map: post => // get a blog post BlogPostWithPaywall( id = post.id, title = post.title, body = post.body, paidPartOfBody = paywallRepository.findPaidBody(post.id), // add paid part of body url = post.url, publishedAt = post.publishedAt, ) ྫɿ ϒϩάهࣄΛऔಘͨ͠ޙɺ༗ྉ෦औಘ͢Δ -
Are you tired of defining similar models? Example: getting a
BlogPost blogPostRepository.find: PostId => Option[ BlogPost ] paywallRepository.findPaidBody: PostId => Option[String] def blogPostWithPaywall(postId: PostId): Option[ BlogPostWithPaywall ] = blogPostRepository.find(postId).map: post => // get a blog post BlogPostWithPaywall( id = post.id, title = post.title, body = post.body, paidPartOfBody = paywallRepository.findPaidBody(post.id), // add paid part of body url = post.url, publishedAt = post.publishedAt, ) ྫɿ ϒϩάهࣄΛऔಘͨ͠ޙɺ༗ྉ෦औಘ͢Δ ·ͣ BlogPost ΛಘΔ -
Are you tired of defining similar models? Example: adding a
field blogPostRepository.find: PostId => Option[ BlogPost ] paywallRepository.findPaidBody: PostId => Option[String] def blogPostWithPaywall(postId: PostId): Option[ BlogPostWithPaywall ] = blogPostRepository.find(postId).map: post => // get a blog post BlogPostWithPaywall( id = post.id, title = post.title, body = post.body, paidPartOfBody = paywallRepository.findPaidBody(post.id), // add paid part of body url = post.url, publishedAt = post.publishedAt, ) ྫɿ ϒϩάهࣄΛऔಘͨ͠ޙɺ༗ྉ෦औಘ͢Δ paidPartOfBody Λͯ͠ BlogPostWithPaywall ʹ͢Δ -
Are you tired of defining similar models? Example: boring passing
around blogPostRepository.find: PostId => Option[ BlogPost ] paywallRepository.findPaidBody: PostId => Option[String] def blogPostWithPaywall(postId: PostId): Option[ BlogPostWithPaywall ] = blogPostRepository.find(postId).map: post => // get a blog post BlogPostWithPaywall( id = post.id, title = post.title, body = post.body, paidPartOfBody = paywallRepository.findPaidBody(post.id), // add paid part of body url = post.url, publishedAt = post.publishedAt, ) ྫɿ ϒϩάهࣄΛऔಘͨ͠ޙɺ༗ྉ෦औಘ͢Δ BlogPost ͔Β BlogPostWithPaywall ͷϑΟʔϧυͷ٧Ίସ͕͑໘ -
Are you tired of defining similar models? Example product owner:
“Hey, we need draft posts, with paywall of course!” case class DraftPost( id: PostId, title: String, body: String, previewUrl: URL, ) case class DraftPostWithPaywall( id: PostId, title: String, body: String, paidPartOfBody: Option[String], previewUrl: URL, ) ྫɿ ϓϩμΫτΦʔφʔʮԼॻ͖ػೳ͕΄͍͠Μ͚ͩͲɻ༗ྉ෦ѻ͑Δͭʂʯ -
Are you tired of defining similar models? Example: writing the
same thing draftPostRepository.find: PostId => Option[DraftPost] paywallRepository.findPaidBody: PostId => Option[String] def draftPostWithPaywall(postId: PostId): Option[DraftPostWithPaywall] = draftPostRepository.find(postId).map: post => DraftPostWithPaywall( id = post.id, title = post.title, body = post.body, paidPartOfBody = paywallRepository.findPaidBody(post.id), previewUrl = post.previewUrl, ) ྫɿ ಉ͡Α͏ͳ͜ͱΛॻ͘Ӌʹ -
Are you tired of defining similar models? Example product owner:
“Hey, we need modification date, for both published and draft posts!” ྫɿ ϓϩμΫτΦʔφʔʮߋ৽͕࣌΄͍͠Μ͚ͩͲɻެ։ࡁΈهࣄͱԼॻ͖྆ํʂʯ -
Are you tired of defining similar models? Example product owner:
“Hey, we need modification date, for both published and draft posts!” ྫɿ ϓϩμΫτΦʔφʔʮߋ৽͕࣌΄͍͠Μ͚ͩͲɻެ։ࡁΈهࣄͱԼॻ͖྆ํʂʯ ͥΜͿॻ͖͑Δͷ໘ʜ -
Wait, we have structural types in Scala 3, right? ͬͯɺ
Scala 3 ʹߏతܕ͕͋Δɺͦ͏Ͱ͠ΐʁ -
Modeling with structural types Typing in Scala 3 type Model
= ... // just some type type AbstractBlogPost = Model { val id: PostId val title: String val body: String } type BlogPost = AbstractBlogPost & Model { val url: URL val publishedAt: DateTime } type Paid = Model { val paidPartOfBody: Option[String] } type BlogPostWithPaywall = BlogPost & Paid type DraftPost = AbstractBlogPost & Model { val previewUrl: URL } type DraftPostWithPaywall = DraftPost & Paid ▶ Listing field types by val ▶ Merging field sets by & ▶ No repeat val ͱ &ͰܕΛఆٛ͢Δ / ಉ͜͡ͱΛ܁Γฦ͠ॻ͔ͳ͍ͰࡁΉ ͜Ε Scala 3 ͷܕγεςϜͷػೳ -
Modeling with structural types A way described in Scala 3
Book class Model(elems: (String, Any)*) extends Selectable: val fields = elems.toMap def selectDynamic(name: String): Any = fields(name) val post = Model( "id" -> postId, "title" -> title, "body" -> body, "url" -> url, "publishedAt" -> publishedAt, ). asInstanceOf[BlogPost] ߏతܕͷΦϒδΣΫτͷఆٛͷ͔ͨ͠ެࣜ Scala 3 Book ʹઆ໌͕͋Δ -
Modeling with structural types A way described in Scala 3
Book class Model(elems: (String, Any)*) extends Selectable: val fields = elems.toMap def selectDynamic(name: String): Any = fields(name) val post = Model( "id" -> postId, "title" -> title, "body" -> body, "url" -> url, "publishedAt" -> publishedAt, ). asInstanceOf[BlogPost] ▶ Unsafe asInstanceOf ߏతܕͷΦϒδΣΫτͷఆٛͷ͔ͨ͠ެࣜ Scala 3 Book ʹઆ໌͕͋Δ ͨͩ͠ɺ asInstanceOf ͕ඞཁͰΠέͯͳ͍ -
Modeling with structural types A way described in Scala 3
Book class Model(elems: (String, Any)*) extends Selectable: val fields = elems.toMap def selectDynamic(name: String): Any = fields(name) val post = Model( "id" -> postId, "title" -> title, "body" -> body, "url" -> url, "publishedAt" -> publishedAt, ). asInstanceOf[BlogPost] val fields = post.fields + ("paidPartOfBody" -> paidPartOfBody) val postWithPaywall = Model(fields.toSeq*) .asInstanceOf[BlogPostWithPaywall] ▶ Unsafe asInstanceOf ▶ Adding a field by hand ߏతܕͷΦϒδΣΫτͷఆٛͷ͔ͨ͠ެࣜ Scala 3 Book ʹઆ໌͕͋Δ ͨͩ͠ɺ asInstanceOf ͕ඞཁͰΠέͯͳ͍ / ϑΟʔϧυͷՃ͕ແཧΓ -
Modeling with structural types A way described in Scala 3
Book class Model(elems: (String, Any)*) extends Selectable: val fields = elems.toMap def selectDynamic(name: String): Any = fields(name) val post = Model( "id" -> postId, "title" -> title, "body" -> body, "url" -> url, "publishedAt" -> publishedAt, ). asInstanceOf[BlogPost] val fields = post.fields + ("paidPartOfBody" -> paidPartOfBody) val postWithPaywall = Model(fields.toSeq*) .asInstanceOf[BlogPostWithPaywall] ▶ Unsafe asInstanceOf ▶ Adding a field by hand ߏతܕͷΦϒδΣΫτͷఆٛͷ͔ͨ͠ެࣜ Scala 3 Book ʹઆ໌͕͋Δ ͨͩ͠ɺ asInstanceOf ͕ඞཁͰΠέͯͳ͍ / ϑΟʔϧυͷՃ͕ແཧΓ -
Modeling with structural types Imagine that instantiation goes straightforward An
ideal way of instantiation val post: BlogPost = Model( id = postId, title = title, body = body, url = url, publishedAt = publishedAt ) ૉʹΠϯελϯεԽ͍ͨ͠ -
Modeling with structural types Imagine that objects can be extended
An ideal way of adding a field val postWithPaywall: BlogPostWithPaywall = post + (paidPartOfBody = paidPartOfBody) ΦϒδΣΫτʹϑΟʔϧυΛ֦ͯ͠ு͍ͨ͠ -
tarao/record4s Extensible records for Scala github.com Try now with Scala
CLI: //> using dep "com.github.tarao::record4s:0.12.0" import com.github.tarao.record4s.% The previous examples work with: import com.github.tarao.record4s.% type Model = % val Model = % GitHub Ͱެ։ࡁΈ / Scala CLI Ͱࠓ͙͢ࢼͦ͏ʂ લͷྫ Model ΛΤΠϦΞεʹ͢Δͱͦͷ··ಈ͘ -
What record4s can do Record construction val person = %(
name = "tarao", age = 3, ) // person: % { // val name: String // val age: Int // } = %(name = tarao, age = 3) record4s ͰͰ͖Δ͜ͱ Ϩίʔυͷ࡞ɿ ߏతܕͷΦϒδΣΫτ͕࡞ΒΕΔ -
What record4s can do Record construction Type-safe field access person.name
// res0: String = "tarao" person.age // res1: Int = 3 person.email // error: // value email is not a member of // %{val name: String; val age: Int} record4s ͰͰ͖Δ͜ͱ ܕ҆શͳϑΟʔϧυΞΫηεɿ ଘࡏ͠ͳ͍ϑΟʔϧυίϯύΠϧΤϥʔʹ -
What record4s can do Record construction Type-safe field access Extension
person + ( email = "[email protected]", occupation = "engineer", ) // res2: % { // val name: String // val age: Int // val email: String // val occupation: String // } = %(name = tarao, age = 3, // email = [email protected], // occupation = engineer) record4s ͰͰ͖Δ͜ͱ Ϩίʔυͷ֦ுʢෳϑΟʔϧυಉ࣌ʹՃՄೳʣ -
What record4s can do Record construction Type-safe field access Extension
Concatenation val email = %(email = "[email protected]") // email: % { // val email: String // } = %(email = [email protected]) person ++ email // res3: % { // val name: String // val age: Int // val email: String // } = %(name = tarao, age = 3, // email = [email protected]) record4s ͰͰ͖Δ͜ͱ Ϩίʔυͷ݁߹ -
What record4s can do Record construction Type-safe field access Extension
Concatenation Field update person + (age = person.age + 1) // res4: % { // val name: String // val age: Int // } = %(name = tarao, age = 4) person + (name = %(first = "tarao", last = "fuguta")) // res5: % { // val name: % { // val first: String // val last: String // } // val age: Int // } = %(name = %(first = tarao, last = fuguta), age = 3) record4s ͰͰ͖Δ͜ͱ ϑΟʔϧυͷߋ৽ɿ ผͷܕͰ্ॻ͖ͯ͠Α͍ -
Other features Type tagging Selecting/unselecting/reordering/renaming fields Pattern matching Conversion from/to
case classes JSON conversion (using circe or uPickle) Scala.js support Scala Native support ଞʹ͍Ζ͍Ζػೳ͕๛ -
Tips — Methods on records Methods on records can be
defined as extension methods extension (post: BlogPost) def summary(length: Int = 200): String = post.body.take(length) val post: BlogPost = ... val postWithPaywall: BlogPostWithPaywall = ... post.summary() // works postWithPaywall.summary() // also works // Note: we have BlogPostWithPaywall <: BlogPost // since BlogPostWithPaywall has more fields // (That is structural subtyping) Ϩίʔυͷϝιου֦ுϝιουͱͯ͠ఆٛՄೳ ߏత෦ܕʹͳ͍ͬͯΔΦϒδΣΫτʹରͯ͠ಉ͡ϝιουΛݺΔ -
Tips — Decoding JSON by example Normally, you have to
specify record type to decode from JSON import com.github.tarao.record4s.upickle.Record.readWriter import upickle.default.read val json = """{"name":"tarao","age":3}""" type Person = % { val name: String; val age: Int } // you have to write this! val r = read[Person](json) ௨ৗɺ JSON ͔Βσίʔυ͢ΔʹσίʔυઌͷܕΛॻ͘ඞཁ͕͋Δ ʢͦͯͦ͠Ε͠͠໘ʣ -
Tips — Decoding JSON by example Giving an example let
you omit the type definition import com.github.tarao.record4s.upickle.Record.readWriter import upickle.default.{Reader, read} def decodeByExample[R <: `%`: Reader](json: String, example: R): R = read[R](json) val json = """{"name":"tarao","age":3}""" val example = %(name = "ikura", age = 1) // no type definition val r = decodeByExample(json, example) ۩ମྫΛ༩͑ΕܕఆٛলུՄೳ -
Field access 1. Use Selectable from the standard library abstract
class % extends Selectable: def __data: Map[String, Any] def selectDynamic(name: String): Any = __data(name) final class MapRecord(val __data: Map[String, Any]) extends % 2. Field access compiles to selectDynamic call val r = %(name = "tarao", age = 3) // returns a MapRecord r.age // ⇝ r.selectDynamic("age").asInstanceOf[Int] 1. ඪ४ϥΠϒϥϦͷ Selectable Λ͏ 2. ϑΟʔϧυΞΫηείϯύΠϧ࣌ʹselectDynamic ͷݺͼग़͠ʹஔ͞ΕΔ -
Concatenation val r1 = %(...) val r2 = %(...) r1
++ r2 inlining ⇝ val r1: R1 = %(...) val r2: R2 = %(...) val c = summon[Concat[R1, R2]] new MapRecord(r1.__data.concat(r2.__data)) .asInstanceOf[c.Out] Ϩίʔυͷ݁߹ Map ͷ concat ʹ͢Δ -
Concatenation val r1 = %(...) val r2 = %(...) r1
++ r2 inlining ⇝ val r1: R1 = %(...) val r2: R2 = %(...) val c = summon[Concat[R1, R2]] new MapRecord(r1.__data.concat(r2.__data)) .asInstanceOf[c.Out] // transparent macro to calculate Out from R1 and R2 transparent inline given [R1, R2]: Concat[R1, R2] = ${ Macros.derivedTypingConcatImpl } Ϩίʔυͷ݁߹ Map ͷ concat ʹ͢Δ Concat given ͕ϚΫϩͰ࣮͞Ε͍ͯͯɺ݁߹݁ՌͷܕΛܭࢉ͢Δ -
Concatenation val r1 = %(...) val r2 = %(...) r1
++ r2 inlining ⇝ val r1: R1 = %(...) val r2: R2 = %(...) val c = summon[Concat[R1, R2]] new MapRecord(r1.__data.concat(r2.__data)) .asInstanceOf[c.Out] Wait, is this really safe? Ϩίʔυͷ݁߹ Map ͷ concat ʹ͢Δ ͱ͜ΖͰ͜Εຊʹ҆શʁ -
Key duplication problem val r1 = %(age = 3) val
r2: % { val name: String } = %(name = "tarao", age = "unknown") // legal since % { val name: String; val age: String } <: % { val name: String } val r = r1 ++ r2 // r: % { // val age: Int // val name: String // } = %(age = "unknwon", name = "tarao") r.age // java.lang.ClassCastException: // class java.lang.String cannot be cast to class java.lang.Integer ݁߹ͷӈลͷ੩తܕʹݱΕͳ͍ϑΟʔϧυ͕ඇޓͳܕͰ্ॻ͖Ͱ͖ͯ͠·͏ -
Key duplication problem val r1 = %(age = 3) val
r2: % { val name: String } = %(name = "tarao", age = "unknown") // legal since % { val name: String; val age: String } <: % { val name: String } val r = r1 ++ r2 // r: % { // val age: Int // val name: String // } = %(age = "unknwon", name = "tarao") r.age // java.lang.ClassCastException: // class java.lang.String cannot be cast to class java.lang.Integer We need to “tidy up” RHS value by its static type ݁߹ͷӈลͷ੩తܕʹݱΕͳ͍ϑΟʔϧυ͕ඇޓͳܕͰ্ॻ͖Ͱ͖ͯ͠·͏ ӈล੩తܕʹ ἧ͓͑ͯ͘ඞཁ͕͋Δ -
Key duplication problem Something like this: val r1 = %(...)
val r2 = %(...) r1 ++ r2 inlining ⇝ val r1: R1 = %(...) val r2: R2 = %(...) val c = summon[Concat[R1, R2]] new MapRecord(r1.__data.concat(r2.__data)) .asInstanceOf[c.Out] ͜͜Λ -
Key duplication problem Something like this: val r1 = %(...)
val r2 = %(...) r1 ++ r2 inlining ⇝ val r1: R1 = %(...) val r2: R2 = %(...) val c = summon[Concat[R1, R2]] new MapRecord(r1.__data.concat(r2.as[R2].__data)) .asInstanceOf[c.Out] ͳΜΒ͔͜ͷΑ͏ʹ -
Runtime performance Creation time ▶ Grows linearly against record size
▶ Almost the same as Map Ϩίʔυͷ࡞࣌ؒαΠζʹରͯ͠ઢܗ Map ͱಉ -
Compile-time performance Compilation time of concatenation ▶ Grows quadratically against
record size ▶ Due to subtyping check of structural types ▶ But far better than shapeless Record Ϩίʔυ݁߹ͷίϯύΠϧ࣌ؒαΠζͷೋʹൺྫʢߏత෦ܕͷݕࠪͷͨΊʣ ͱ͍͑ shapeless ΑΓͣͬͱ͍͍ -
Conclusion record4s provides extensible records for Scala 3 It is
useful for some kind of domain modeling It is carefully designed using inlining and macros It has good enough performance characteristics record4s Scala 3 ͚ͷ֦ுՄೳϨίʔυΛఏڙ͠ɺυϝΠϯϞσϦϯάʹཱͭ ϚΫϩΛ༻͍ͯҙਂ࣮͘͞Ε͍ͯͯɺेͳύϑΥʔϚϯεಛੑΛඋ͍͑ͯΔ