From ed696cef74be8f966b2ec20b1f311d71e9d25c62 Mon Sep 17 00:00:00 2001 From: Rui Morais Date: Wed, 10 Jul 2019 18:23:39 +0100 Subject: [PATCH] finished post about encoding invariants in scala --- .gitignore | 3 +- posts/encoding-invariants-scala.md | 137 ++++++++++++++++------------- 2 files changed, 80 insertions(+), 60 deletions(-) diff --git a/.gitignore b/.gitignore index b9d261f..44183f2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ public .bloop/ .metals/ target/ -/hugo/content/ \ No newline at end of file +/hugo/content/ +.idea/ \ No newline at end of file diff --git a/posts/encoding-invariants-scala.md b/posts/encoding-invariants-scala.md index a820b80..dcf5a15 100644 --- a/posts/encoding-invariants-scala.md +++ b/posts/encoding-invariants-scala.md @@ -1,8 +1,8 @@ --- -title: "Encoding Invariants Scala" -date: 2019-04-05T11:00:16+01:00 -lastmod: 2019-04-05T11:00:16+01:00 -draft: true +title: "Encoding Invariants in Scala" +date: 2019-06-30T11:00:16+01:00 +lastmod: 2019-06-30T11:00:16+01:00 +draft: false keywords: [] description: "" tags: [Scala, Invariants, Type Safety] @@ -23,47 +23,45 @@ mathjax: false ## Motivation -Scala is a statically type language with a very powerful type systems. We can take advantage of this and let the compiler make our programs more robust. -I want to take advantage of this and explore how we can use the compiler to help us create more robust types which can't have invalid values. +Scala is a statically type language with a very powerful type system. We can take advantage of this and let the compiler make our programs more robust. -Let's say we want to encode a `TimeFrame` which contains a `StartDate` and an `EndDate`. +I want to show how we can take advantage of this and explore how we can use the compiler to help us create more robust types which can't have invalid values. -We can use a case class to encode it. +Let's say we want to encode a `Counter`. We can use a case class to encode it. ```scala mdoc -import java.time.LocalDate - -case class TimeFrame(startDate: LocalDate, endDate: LocalDate) +case class Counter(value: Int) ``` -Now imagine that we want to impose a restriction on the allowed values. Let's say the `EndDate` should always be greater than `StartDate`. +Now imagine that we want our counter to have only positive values. There is another requirement that non positive values should default to the initial value of `1`. There are a couple of ways we can achieve this which I will explore below. +This example is a bit contrived but will suffice for the purpose of our demonstration. + ## Using preconditions -Scala provides a set of preconditions that we use to validate our data, being one of them `require`. +Scala provides a set of preconditions that we can use to validate our data, being one of them `require`. We can use it in the following way. ```scala mdoc:reset -import java.time.LocalDate - -case class TimeFrame(startDate: LocalDate, endDate: LocalDate) { - require(endDate.isAfter(startDate)) +case class Counter(value: Int) { + require(value > 0) } ``` -This will make sure that we can't create an instance of `TimeFrame` that doesn't respect the restriction, as we can see bellow. +This will make sure that we can't create an instance of `Counter` that doesn't respect the restriction, as we can see bellow. ```scala mdoc:crash -TimeFrame(LocalDate.now, LocalDate.now) +Counter(-1) ``` -As the values provided doesn't respect the pre conditiion, `require` will throw an exception. -While this solves our problem, it's not a good solution. -The main issue is that it will fail at runtime. Also the user of this class, doesn't know that creating an instance can fail. This can lead to unexpected errors while running the application. +As the value provided doesn't respect the pre condition `require` will throw an exception. +While this partially solves our problem, it isn't a good solution. + +One issue is that it will fail at runtime whenever we create an instance with a negative value. The other problem is that users of this class don't know that it can fail. This can lead to unexpected errors while running the application. In this case we are not really using the compiler in our favour. Let's see if we can leverage the scala type system and lift this restriction into the type level. @@ -77,74 +75,95 @@ Let's see how it looks like. ```scala mdoc:reset -import java.time.LocalDate +final case class Counter private (value: Int) -val today = LocalDate.now -val yesterday = today.minusDays(1) - -case class TimeFrame private (startDate: LocalDate, endDate: LocalDate) - -object TimeFrame { - def smartConstructor(startDate: LocalDate, endDate: LocalDate): Either[String, TimeFrame] = - if (endDate.isAfter(startDate)) - Right(TimeFrame(startDate, endDate)) - else - Left(s"Error: endDate [$endDate] must be after startDate [$startDate]") +object Counter { + def fromInt(value: Int): Counter = if (value > 0) Counter(value) else Counter(1) } ``` ```scala mdoc:fail -new TimeFrame(today, yesterday) {} +new Counter(20) ``` + As you can see, we can no longer use the default constructor. -Now that we have define our own constructor, we can create only valid instances. +Now that we have define our own constructor, we can create valid instances. + ```scala mdoc -val fail = TimeFrame.smartConstructor(startDate = today, endDate = yesterday) +Counter.fromInt(-3) ``` -The custom constructor now returns a `Either[String, TimeFrame]`. This informs our user that they must deal with a possible failure. -We are no longer rely on runtime validation but are instead using the compiler to inforce this using the type system. -From the example above, when we try to construct an invalid instance we will get a `Left` indicating that something has failed. The user has to explicitly handle this. +The custom constructor will always return a valid instance for our counter. +We are no longer relying on runtime validation but are instead relying on compile time validation. Given that we are using a case class, the compiler will generate some synthetic methods for us. Generally, these are quite handy but for our case they are causing some harm. -`apply` or `copy` are two of the synthetic methods generated. And they can be used to bypass our smart constructor as we can see below. +`apply` and `copy` are two of the synthetic methods generated. And they can be used to bypass our smart constructor as we can see below. ```scala mdoc -val t = TimeFrame(startDate = today, endDate = yesterday) +val c = Counter.apply(-5) -val t1 = TimeFrame(startDate = today, endDate = today.plusDays(1)) +val c1 = Counter(10).copy(-5) -val t2 = t1.copy(endDate = yesterday) ``` -To fix this, we need to tweaks our smart constructor. We will need to define our own `apply` and `copy` to supress the synthetic ones. +To fix this, we need to tweak our smart constructor. We will need to define our own `apply` and `copy` to supress the synthetic ones. ```scala mdoc:reset +final case class Counter private (value: Int) { + def copy(number: Int = value): Counter = Counter.fromInt(number) +} -import java.time.LocalDate - -val today = LocalDate.now -val yesterday = today.minusDays(1) +object Counter { + def apply(value: Int): Counter = fromInt(value) -case class TimeFrame private (startDate: LocalDate, endDate: LocalDate) { - def copy(startDate: LocalDate = startDate, endDate: LocalDate = endDate): TimeFrame = TimeFrame(starDate, endDate) + def fromInt(value: Int): Counter = + if (value > 0) new Counter(value) else new Counter(1) } +``` + +Now if we try to use again the `apply` or the `copy` methods, they will just delegate to the smart constructor. + +```scala mdoc +val c = Counter.apply(-5) -object TimeFrame { - def apply(startDate: LocalDate, endDate: LocalDate): TimeFrame = new TimeFrame(startDate, endDate) +val c1 = Counter(10).copy(-5) +``` + +This solution thicks all the boxes for our problem. The downside is that we have to remember to suppress the synthetic methods and this only works from scala `2.12.2+`. + +## Using sealed abstract case classes + +The last alternative I want to explore is to use an abstract class together with anonynous subclassing. It looks like this: + +```scala mdoc:reset + +sealed abstract case class Counter private (value: Int) + +object Counter { + def fromInt(value: Int): Counter = + if (value > 0) new Counter(value) {} else new Counter(1) {} } ``` +We define our counter as a `sealed` class, to limit the scope in which subclasses can be defined. In this case the scope is limited to the source file where our counter is defined. +And by defining our case class `abstract`, we disable the synthetic methods `apply` and `copy` provided by the compiler. It cannot generate this methods because there is no default constructor. + +The only possible way to create an instance of our counter is through the use of our smart constructor. + +As we can see below, the synthetic methods are not available, reducing the ways of bypassing our smart constructor. + ```scala mdoc:fail -TimeFrame(today, today.plusDays(1)).copy(endDate = yesterday) +val c1 = Counter.apply(-5) + +val c2 = Counter.fromInt(3).copy(-5) + ``` +This last solution is more safe because the compiler can't generate `apply` and `copy` methods. The downside is that we have to define a `sealed abstract` class plus anonymous subtyping instead of just a `final` class. -## Using Sealed abstract case classes -```scala mdoc:reset +I prefer this last option mainly because I don't have to remember to supress the shyntetic methods generated by the compiler. -import java.time.LocalDate +I think both alternatives works well and there are no clear advantages or disdvantages of using one over the other. It's more of a style preference and I find myself enforcing invariants by using primarly `sealed abstract` classes. -sealed abstract case class TimeFrame(startDate: LocalDate, endDate: LocalDate) -``` \ No newline at end of file +[refined]: https://github.com/fthomas/refined \ No newline at end of file