From 711984f4627265bcec5fa5942253dce1f02f414b Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Mon, 2 Oct 2023 10:40:37 +0100 Subject: [PATCH] Edits to ADT chapter - Fix typos - Reorganize sections --- build.sbt | 2 ++ src/pages/adt/algebra.md | 5 +++++ src/pages/adt/applications.md | 1 + src/pages/adt/index.md | 19 +++++-------------- src/pages/adt/scala.md | 22 +++++++++++++++++----- src/pages/adt/structural-recursion.md | 14 +++++++------- 6 files changed, 37 insertions(+), 26 deletions(-) create mode 100644 src/pages/adt/algebra.md create mode 100644 src/pages/adt/applications.md diff --git a/build.sbt b/build.sbt index 8134a195..f722d2bc 100644 --- a/build.sbt +++ b/build.sbt @@ -44,6 +44,8 @@ lazy val pages = List( "adt/scala.md", "adt/structural-recursion.md", "adt/structural-corecursion.md", + "adt/applications.md", + "adt/algebra.md", "adt/conclusions.md", "type-classes/index.md", diff --git a/src/pages/adt/algebra.md b/src/pages/adt/algebra.md new file mode 100644 index 00000000..8c8429bb --- /dev/null +++ b/src/pages/adt/algebra.md @@ -0,0 +1,5 @@ +## The Algebra of Algebraic Data Types + +Algebra of algebraic data types + +Exponential types, quotient types. diff --git a/src/pages/adt/applications.md b/src/pages/adt/applications.md new file mode 100644 index 00000000..b43ef703 --- /dev/null +++ b/src/pages/adt/applications.md @@ -0,0 +1 @@ +## Applications of Algebraic Data Types diff --git a/src/pages/adt/index.md b/src/pages/adt/index.md index 37d5420c..49e5228f 100644 --- a/src/pages/adt/index.md +++ b/src/pages/adt/index.md @@ -2,13 +2,13 @@ In this section we'll see our first example of a programming strategy: **algebraic data types**. Any data we can describe using logical ands and logical ors is an algebraic data type. Once we recognize an algebraic data type we get three things for free: -- the Scala representation of the data; and -- a **structural recursion** skeleton to transform the algebraic data type into any other type. +- the Scala representation of the data; +- a **structural recursion** skeleton to transform the algebraic data type into any other type; and - a **structural co-recursion** skeleton to construct the algebraic data type from any other type. The key point is this: from an implementation independent representation of data we can automatically derive most of the interesting implementation specific parts of working with that data. -We'll start with some examples of data, from which we'll extract the common structure that motivates algebraic data types. We then look at their representation in Scala 2 and Scala 3. We'll then turn to structural recursion, to transform algebraic data types, and structural co-recursion to construct them. We'll finish by looking at the algebra of algebraic data types, which is interesting but not essential. +We'll start with some examples of data, from which we'll extract the common structure that motivates algebraic data types. We will then look at their representation in Scala 2 and Scala 3. We'll then turn to structural recursion for transforming algebraic data types, and structural co-recursion for constructing them. We'll finish by looking at the algebra of algebraic data types, which is interesting but not essential. @@ -40,15 +40,6 @@ So algebraic data types consist of sum and product types. ## Closed Worlds -Algebraic data types are closed worlds, which means they cannot be extended after that fact. In practical terms this means we have to modify the source code where we define the algebraic data type if we want to add or remove elements. +Algebraic data types are closed worlds, which means they cannot be extended after they have been defined. In practical terms this means we have to modify the source code where we define the algebraic data type if we want to add or remove elements. -The closed world property is important because it gives us some guarantees we would not otherwise have. In particular, it allows the compiler to check, when we use an algebraic data type, that we handle all possible cases and alert us if we don't. This is known as **exhaustivity checking**. This is an example of how functional programming prioritizes reasoning about code---in this case automated reasoning by the compiler---over other properties such as extensibility. - - -## Applications of Algebraic Data Types - -## The Algebra of Algebraic Data Types - -Algebra of algebraic data types - -Exponential types, quotient types. +The closed world property is important because it gives us guarantees we would not otherwise have. In particular, it allows the compiler to check, when we use an algebraic data type, that we handle all possible cases and alert us if we don't. This is known as **exhaustivity checking**. This is an example of how functional programming prioritizes reasoning about code---in this case automated reasoning by the compiler---over other properties such as extensibility. diff --git a/src/pages/adt/scala.md b/src/pages/adt/scala.md index 11a361ec..a28f7449 100644 --- a/src/pages/adt/scala.md +++ b/src/pages/adt/scala.md @@ -1,8 +1,8 @@ ## Algebraic Data Types in Scala -Now we know about algebraic data types we can turn to their representation in Scala. The important point here is that the translation to Scala is entirely determined by the structure of the data. No thinking is required. In other words, the work is in finding the structure of the data that best represents the problem at hand. Work out the structure of the data and the code directly follows from it. +Now we know what algebraic data types are, we can turn to their representation in Scala. The important point here is that the translation to Scala is entirely determined by the structure of the data; no thinking is required! This means the work is in finding the structure of the data that best represents the problem at hand. Work out the structure of the data and the code directly follows from it. -As algebraic data types are defined in terms of logical ands and logical ors, to represent algebraic data types in Scala we must know how to represent these two concepts in Scala. Scala 3 simplifies the representation of algebraic data types compared to Scala 2, so we'll look at each separately. +As algebraic data types are defined in terms of logical ands and logical ors, to represent algebraic data types in Scala we must know how to represent these two concepts in Scala. Scala 3 simplifies the representation of algebraic data types compared to Scala 2, so we'll look at each language version separately. I'm assuming that you're familiar with the language features we use to represent algebraic data types in Scala, but not with their correspondence to algebraic data types. @@ -65,7 +65,7 @@ final case class C() extends A Scala 2 has several little tricks to defining algebraic data types. -Firstly, instead of using a `sealed abstract class` you can use a `sealed trait`. There isn't much practical difference between the two. When teaching I'll often use `sealed trait` to avoid having to introduce `abstract class`. I believe `sealed abstract class` has slightly better performance and Java interoperability but I haven't tested this. I also think `sealed abstract class` is closer, semantically, to the meaning of a sum type. +Firstly, instead of using a `sealed abstract class` you can use a `sealed trait`. There isn't much practical difference between the two. When teaching beginners I'll often use `sealed trait` to avoid having to introduce `abstract class`. I believe `sealed abstract class` has slightly better performance and Java interoperability but I haven't tested this. I also think `sealed abstract class` is closer, semantically, to the meaning of a sum type. For extra style points we can `extend Product with Serializable` from `sealed abstract class`. Compare the reported types below with and without this little addition. @@ -77,8 +77,9 @@ final case class B() extends A final case class C() extends A ``` -```scala mdoc +```scala mdoc:silent val list = List(B(), C()) +// list: List[A extends Product with Serializable] = List(B(), C()) ``` Notice how the type of `list` includes `Product` and `Serializable`. @@ -97,13 +98,15 @@ val list = List(B(), C()) Much easier to read! +You'll only see this in Scala 2. Scala 3 has the concept of **transparent traits**, which aren't reported in inferred types, so you'll see the same output in Scala 3 no matter whether you add `Product` and `Serializable` or not. + Finally, if a logical and holds no data we can use a `case object` instead of a `case class`. For example, if we're defining some type `A` that holds no data we can just write ```scala mdoc:silent case object A ``` -Note there is no need to mark the `case object` as `final`, as objects cannot be extended. +There is no need to mark the `case object` as `final`, as objects cannot be extended. ### Examples @@ -183,3 +186,12 @@ final case class Line(end: Point) extends Action final case class Curve(cp1: Point, cp2: Point, end: Point) extends Action final case class Move(end: Point) extends Action ``` + + +### Representing ADTs in Scala 3 + +We've seen that the Scala 3 representation of algebraic data types, using `enum`, is more compact than the Scala 2 representation. However the Scala 2 representation is still avaiable. Should you ever use the Scala 2 representation in Scala 3? There are a few cases where you may want to: + +- Scala 3's doesn't currently support nested `enums` (`enums` within `enums`). This may change in the future, but right now it can be more convenient to use the Scala 2 representation to express this without having to convert to disjunctive normal form. + +- Scala 2's representation can express things that are almost, but not quite, algebraic data types. For example, if you define a method on an `enum` you must be able to define it for all the members of the `enum`. Sometimes you want a case of an `enum` to have methods that are only defined for that case. If so, you'll need to use the Scala 2 representation instead. diff --git a/src/pages/adt/structural-recursion.md b/src/pages/adt/structural-recursion.md index 309f4d8e..ca92839b 100644 --- a/src/pages/adt/structural-recursion.md +++ b/src/pages/adt/structural-recursion.md @@ -6,8 +6,8 @@ Structural recursion tells us how to transform an algebraic data types into any Given an algebraic data type, the transformation can be implemented using structural recursion. Just like with algebraic data types, there is distinction between the concept of structural recursion and the implementation in Scala. -In particular, there are two ways structural recursion can be implemented in Scala: via pattern matching or via dynamic dispatch. -We'll look at both in turn. +This is more obvious because there are two ways to implement structural recursion in Scala: via pattern matching or via dynamic dispatch. +We'll look at each in turn. ### Pattern Matching @@ -87,7 +87,7 @@ enum MyList[A] { } ``` -Our step is to recognize that `map` can be written using a structural recursion. +Our first step is to recognize that `map` can be written using a structural recursion. `MyList` is an algebraic data type, `map` is transforming this algebraic data type, and therefore structural recursion is applicable. We now apply the structural recursion strategy, giving us @@ -120,7 +120,7 @@ enum MyList[A] { } ``` -I've left the `???` to indicate that we haven't finished with this case. +I left the `???` to indicate that we haven't finished with that case. Now we can move on to the problem specific parts. Here we have three strategies to help us: @@ -131,7 +131,7 @@ Here we have three strategies to help us: Let's briefly discuss each and then see how they apply to our example. -The first strategy is relatively simple: when we consider the problem specific code on the right hand side of a pattern matching `case`, we can ignore the code in any other pattern matches. So, for example, when considering the case for `Empty` above, we don't need to worry about the case for `Pair`, and vice versa. +The first strategy is relatively simple: when we consider the problem specific code on the right hand side of a pattern matching `case`, we can ignore the code in any other pattern match cases. So, for example, when considering the case for `Empty` above, we don't need to worry about the case for `Pair`, and vice versa. The next strategy is a little bit more complicated, and has to do with recursion. Remember that the structural recursion strategy tells us where to place any recursive calls. This means we don't have to think about the recursion. The result is guaranteed to be correct so long as we get the non-recursive parts correct. @@ -156,7 +156,7 @@ enum MyList[A] { } ``` -Our first strategy is to consider the cases independently. Let's start with the `Empty` case. There is no recursive call here, so reasoning using structural recursion doesn't come into play here. Let's instead use the types. There is no input here other than the `Empty` case we have already matched, so we cannot use the input types to further restrict the code. However can use the output types. We're trying to create a `MyList[B]`. There are only two ways to create a `MyList[B]`: an `Empty` or a `Pair`. To create a `Pair` we need a `head` of type `B`, which we don't have. So we can only use `Empty`. *This is the only possible code we can write*. The types are sufficiently restrictive that we cannot write incorrect code for this case. +Our first strategy is to consider the cases independently. Let's start with the `Empty` case. There is no recursive call here, so reasoning using structural recursion doesn't come into play. Let's instead use the types. There is no input here other than the `Empty` case we have already matched, so we cannot use the input types to further restrict the code. However can use the output types. We're trying to create a `MyList[B]`. There are only two ways to create a `MyList[B]`: an `Empty` or a `Pair`. To create a `Pair` we need a `head` of type `B`, which we don't have. So we can only use `Empty`. *This is the only possible code we can write*. The types are sufficiently restrictive that we cannot write incorrect code for this case. ```scala enum MyList[A] { @@ -213,7 +213,7 @@ If you've followed this example you've hopefully see how we can use the three st Remember that algebraic data types are a closed world: they cannot be extended once defined. The Scala compiler can use this to check that we handle all possible cases in a pattern match, so long as we write the pattern match in a way the compiler can work with. -This is known as **exhaustivity checking**. +This is known as exhaustivity checking. Here's a simple example. We start by defining a straight-forward algebraic data type.