From b2045ea38b3232e3b064a1ecbe76e771d5a9837c Mon Sep 17 00:00:00 2001 From: Ben Luo Date: Fri, 7 Oct 2022 16:57:01 +0800 Subject: [PATCH 1/4] add code tabs in num22. --- _overviews/scala3-book/domain-modeling-fp.md | 273 ++++++++++++++++++- 1 file changed, 258 insertions(+), 15 deletions(-) diff --git a/_overviews/scala3-book/domain-modeling-fp.md b/_overviews/scala3-book/domain-modeling-fp.md index f950eac7fe..74c414504d 100644 --- a/_overviews/scala3-book/domain-modeling-fp.md +++ b/_overviews/scala3-book/domain-modeling-fp.md @@ -18,8 +18,6 @@ When modeling the world around us with FP, you typically use these Scala constru > If you’re not familiar with algebraic data types (ADTs) and their generalized version (GADTs), you may want to read the [Algebraic Data Types][adts] section before reading this section. - - ## Introduction In FP, the *data* and the *operations on that data* are two separate things; you aren’t forced to encapsulate them together like you do with OOP. @@ -49,8 +47,6 @@ An FP design is implemented in a similar way: In this chapter we’ll model the data and operations for a “pizza” in a pizza store. You’ll see how to implement the “data” portion of the Scala/FP model, and then you’ll see several different ways you can organize the operations on that data. - - ## Modeling the Data In Scala, describing the data model of a programming problem is simple: @@ -58,11 +54,13 @@ In Scala, describing the data model of a programming problem is simple: - If you want to model data with different alternatives, use the `enum` construct - If you only want to group things (or need more fine-grained control) use `case` classes - ### Describing Alternatives Data that simply consists of different alternatives, like crust size, crust type, and toppings, is concisely modeled with the Scala 3 `enum` construct: +{% tabs data_1 class=tabs-scala-version %} +{% tab 'Scala 3 only' for=data_1 %} + ```scala enum CrustSize: case Small, Medium, Large @@ -73,6 +71,10 @@ enum CrustType: enum Topping: case Cheese, Pepperoni, BlackOlives, GreenOlives, Onions ``` + +{% endtab %} +{% endtabs %} + > Data types that describe different alternatives (like `CrustSize`) are also sometimes referred to as _sum types_. ### Describing Compound Data @@ -80,6 +82,25 @@ enum Topping: A pizza can be thought of as a _compound_ container of the different attributes above. We can use a `case` class to describe that a `Pizza` consists of a `crustSize`, `crustType`, and potentially multiple `Topping`s: +{% tabs data_2 class=tabs-scala-version %} +{% tab 'Scala 2' for=data_2 %} + +```scala +import CrustSize._ +import CrustType._ +import Topping._ + +case class Pizza( + crustSize: CrustSize, + crustType: CrustType, + toppings: Seq[Topping] +) +``` + +{% endtab %} + +{% tab 'Scala 3' for=data_2 %} + ```scala import CrustSize.* import CrustType.* @@ -91,6 +112,10 @@ case class Pizza( toppings: Seq[Topping] ) ``` + +{% endtab %} +{% endtabs %} + > Data Types that aggregate multiple components (like `Pizza`) are also sometimes referred to as _product types_. And that’s it. @@ -99,17 +124,25 @@ This solution is very concise because it doesn’t require the operations on a p The data model is easy to read, like declaring the design for a relational database. It is also very easy to create values of our data model and inspect them: +{% tabs data_3 class=tabs-scala-version %} +{% tab 'Scala 2 and 3' for=data_3 %} + ```scala val myFavPizza = Pizza(Small, Regular, Seq(Cheese, Pepperoni)) println(myFavPizza.crustType) // prints Regular ``` +{% endtab %} +{% endtabs %} #### More of the data model We might go on in the same way to model the entire pizza-ordering system. Here are a few other `case` classes that are used to model such a system: +{% tabs data_4 class=tabs-scala-version %} +{% tab 'Scala 2 and 3' for=data_4 %} + ```scala case class Address( street1: String, @@ -131,14 +164,14 @@ case class Order( ) ``` +{% endtab %} +{% endtabs %} + #### “Skinny domain objects” In his book, *Functional and Reactive Domain Modeling*, Debasish Ghosh states that where OOP practitioners describe their classes as “rich domain models” that encapsulate data and behaviors, FP data models can be thought of as “skinny domain objects.” This is because---as this lesson shows---the data models are defined as `case` classes with attributes, but no behaviors, resulting in short and concise data structures. - - - ## Modeling the Operations This leads to an interesting question: Because FP separates the data from the operations on that data, how do you implement those operations in Scala? @@ -146,6 +179,24 @@ This leads to an interesting question: Because FP separates the data from the op The answer is actually quite simple: you simply write functions (or methods) that operate on values of the data we just modeled. For instance, we can define a function that computes the price of a pizza. +{% tabs data_5 class=tabs-scala-version %} +{% tab 'Scala 2' for=data_5 %} + +```scala +def pizzaPrice(p: Pizza): Double = p match { + case Pizza(crustSize, crustType, toppings) => { + val base = 6.00 + val crust = crustPrice(crustSize, crustType) + val tops = toppings.map(toppingPrice).sum + base + crust + tops + } +} +``` + +{% endtab %} + +{% tab 'Scala 3' for=data_5 %} + ```scala def pizzaPrice(p: Pizza): Double = p match case Pizza(crustSize, crustType, toppings) => @@ -154,15 +205,57 @@ def pizzaPrice(p: Pizza): Double = p match val tops = toppings.map(toppingPrice).sum base + crust + tops ``` + +{% endtab %} +{% endtabs %} + You can notice how the implementation of the function simply follows the shape of the data: since `Pizza` is a case class, we use pattern matching to extract the components and call helper functions to compute the individual prices. +{% tabs data_6 class=tabs-scala-version %} +{% tab 'Scala 2' for=data_6 %} + +```scala +def toppingPrice(t: Topping): Double = t match { + case Cheese | Onions => 0.5 + case Pepperoni | BlackOlives | GreenOlives => 0.75 +} +``` + +{% endtab %} + +{% tab 'Scala 3' for=data_6 %} + ```scala def toppingPrice(t: Topping): Double = t match case Cheese | Onions => 0.5 case Pepperoni | BlackOlives | GreenOlives => 0.75 ``` + +{% endtab %} +{% endtabs %} + Similarly, since `Topping` is an enumeration, we use pattern matching to distinguish between the different variants. Cheese and onions are priced at 50ct while the rest is priced at 75ct each. + +{% tabs data_7 class=tabs-scala-version %} +{% tab 'Scala 2' for=data_7 %} + +```scala +def crustPrice(s: CrustSize, t: CrustType): Double = + (s, t) match { + // if the crust size is small or medium, + // the type is not important + case (Small | Medium, _) => 0.25 + case (Large, Thin) => 0.50 + case (Large, Regular) => 0.75 + case (Large, Thick) => 1.00 + } +``` + +{% endtab %} + +{% tab 'Scala 3' for=data_7 %} + ```scala def crustPrice(s: CrustSize, t: CrustType): Double = (s, t) match @@ -173,6 +266,10 @@ def crustPrice(s: CrustSize, t: CrustType): Double = case (Large, Regular) => 0.75 case (Large, Thick) => 1.00 ``` + +{% endtab %} +{% endtabs %} + To compute the price of the crust we simultaneously pattern match on both the size and the type of the crust. > An important point about all functions shown above is that they are *pure functions*: they do not mutate any data or have other side-effects (like throwing exceptions or writing to a file). @@ -204,9 +301,8 @@ Mine (Alvin, now modified, from fp-pure-functions.md): - It doesn’t have any “back doors”: It doesn’t read data from the outside world (including the console, web services, databases, files, etc.), or write data to the outside world {% endcomment %} - - ## How to Organize Functionality + When implementing the `pizzaPrice` function above, we did not say _where_ we would define it. In Scala 3, it would be perfectly valid to define it on the toplevel of your file. However, the language gives us many great tools to organize our logic in different namespaces and modules. @@ -228,6 +324,9 @@ A first approach is to define the behavior---the functions---in a companion obje With this approach, in addition to the enumeration or case class you also define an equally named companion object that contains the behavior. +{% tabs org_1 class=tabs-scala-version %} +{% tab 'Scala 3 only' for=org_1 %} + ```scala case class Pizza( crustSize: CrustSize, @@ -250,13 +349,23 @@ object Topping: case Cheese | Onions => 0.5 case Pepperoni | BlackOlives | GreenOlives => 0.75 ``` + +{% endtab %} +{% endtabs %} + With this approach you can create a `Pizza` and compute its price like this: +{% tabs org_2 class=tabs-scala-version %} +{% tab 'Scala 2 and 3' for=org_2 %} + ```scala val pizza1 = Pizza(Small, Thin, Seq(Cheese, Onions)) Pizza.price(pizza1) ``` +{% endtab %} +{% endtabs %} + Grouping functionality this way has a few advantages: - It associates functionality with data and makes it easier to find for programmers (and the compiler). @@ -269,7 +378,6 @@ However, there are also a few tradeoffs that should be considered: In particular, the companion object needs to be defined in the same file as your `case` class. - It might be unclear where to define functions like `crustPrice` that could equally well be placed in a companion object of `CrustSize` or `CrustType`. - ## Modules A second way to organize behavior is to use a “modular” approach. @@ -281,6 +389,26 @@ Let’s look at what this means. The first thing to think about are the `Pizza`s “behaviors”. When doing this, you sketch a `PizzaServiceInterface` trait like this: +{% tabs module_1 class=tabs-scala-version %} +{% tab 'Scala 2' for=module_1 %} + +```scala +trait PizzaServiceInterface { + + def price(p: Pizza): Double + + def addTopping(p: Pizza, t: Topping): Pizza + def removeAllToppings(p: Pizza): Pizza + + def updateCrustSize(p: Pizza, cs: CrustSize): Pizza + def updateCrustType(p: Pizza, ct: CrustType): Pizza +} +``` + +{% endtab %} + +{% tab 'Scala 3' for=module_1 %} + ```scala trait PizzaServiceInterface: @@ -293,6 +421,9 @@ trait PizzaServiceInterface: def updateCrustType(p: Pizza, ct: CrustType): Pizza ``` +{% endtab %} +{% endtabs %} + As shown, each method takes a `Pizza` as an input parameter---along with other parameters---and then returns a `Pizza` instance as a result When you write a pure interface like this, you can think of it as a contract that states, “all non-abstract classes that extend this trait *must* provide an implementation of these services.” @@ -300,6 +431,9 @@ When you write a pure interface like this, you can think of it as a contract tha What you might also do at this point is imagine that you’re the consumer of this API. When you do that, it helps to sketch out some sample “consumer” code to make sure the API looks like what you want: +{% tabs module_2 class=tabs-scala-version %} +{% tab 'Scala 2 and 3' for=module_2 %} + ```scala val p = Pizza(Small, Thin, Seq(Cheese)) @@ -310,6 +444,9 @@ val p3 = updateCrustType(p2, Thick) val p4 = updateCrustSize(p3, Large) ``` +{% endtab %} +{% endtabs %} + If that code seems okay, you’ll typically start sketching another API---such as an API for orders---but since we’re only looking at pizzas right now, we’ll stop thinking about interfaces and create a concrete implementation of this interface. > Notice that this is usually a two-step process. @@ -317,11 +454,37 @@ If that code seems okay, you’ll typically start sketching another API---such a > In the second step you create a concrete *implementation* of that interface. > In some cases you’ll end up creating multiple concrete implementations of the base interface. - ### Creating a concrete implementation Now that you know what the `PizzaServiceInterface` looks like, you can create a concrete implementation of it by writing the body for all of the methods you defined in the interface: +{% tabs module_3 class=tabs-scala-version %} +{% tab 'Scala 2' for=module_3 %} + +```scala +object PizzaService extends PizzaServiceInterface { + + def price(p: Pizza): Double = + ... // implementation from above + + def addTopping(p: Pizza, t: Topping): Pizza = + p.copy(toppings = p.toppings :+ t) + + def removeAllToppings(p: Pizza): Pizza = + p.copy(toppings = Seq.empty) + + def updateCrustSize(p: Pizza, cs: CrustSize): Pizza = + p.copy(crustSize = cs) + + def updateCrustType(p: Pizza, ct: CrustType): Pizza = + p.copy(crustType = ct) +} +``` + +{% endtab %} + +{% tab 'Scala 3' for=module_3 %} + ```scala object PizzaService extends PizzaServiceInterface: @@ -343,10 +506,34 @@ object PizzaService extends PizzaServiceInterface: end PizzaService ``` +{% endtab %} +{% endtabs %} + While this two-step process of creating an interface followed by an implementation isn’t always necessary, explicitly thinking about the API and its use is a good approach. With everything in place you can use your `Pizza` class and `PizzaService`: +{% tabs module_4 class=tabs-scala-version %} +{% tab 'Scala 2' for=module_4 %} + +```scala +import PizzaService._ + +val p = Pizza(Small, Thin, Seq(Cheese)) + +// use the PizzaService methods +val p1 = addTopping(p, Pepperoni) +val p2 = addTopping(p1, Onions) +val p3 = updateCrustType(p2, Thick) +val p4 = updateCrustSize(p3, Large) + +println(price(p4)) // prints 8.75 +``` + +{% endtab %} + +{% tab 'Scala 3' for=module_4 %} + ```scala import PizzaService.* @@ -361,6 +548,9 @@ val p4 = updateCrustSize(p3, Large) println(price(p4)) // prints 8.75 ``` +{% endtab %} +{% endtabs %} + ### Functional Objects In the book, *Programming in Scala*, the authors define the term, “Functional Objects” as “objects that do not have any mutable state”. @@ -375,11 +565,42 @@ You can think of this approach as a “hybrid FP/OOP design” because you: > This really is a hybrid approach: like in an **OOP design**, the methods are encapsulated in the class with the data, but as typical for a **FP design**, methods are implemented as pure functions that don’t mutate the data - #### Example Using this approach, you can directly implement the functionality on pizzas in the case class: +{% tabs module_5 class=tabs-scala-version %} +{% tab 'Scala 2' for=module_5 %} + +```scala +case class Pizza( + crustSize: CrustSize, + crustType: CrustType, + toppings: Seq[Topping] +) { + + // the operations on the data model + def price: Double = + pizzaPrice(this) // implementation from above + + def addTopping(t: Topping): Pizza = + this.copy(toppings = this.toppings :+ t) + + def removeAllToppings: Pizza = + this.copy(toppings = Seq.empty) + + def updateCrustSize(cs: CrustSize): Pizza = + this.copy(crustSize = cs) + + def updateCrustType(ct: CrustType): Pizza = + this.copy(crustType = ct) +} +``` + +{% endtab %} + +{% tab 'Scala 3' for=module_5 %} + ```scala case class Pizza( crustSize: CrustSize, @@ -404,11 +625,17 @@ case class Pizza( this.copy(crustType = ct) ``` +{% endtab %} +{% endtabs %} + Notice that unlike the previous approaches, because these are methods on the `Pizza` class, they don’t take a `Pizza` reference as an input parameter. Instead, they have their own reference to the current pizza instance as `this`. Now you can use this new design like this: +{% tabs module_6 class=tabs-scala-version %} +{% tab 'Scala 2 and 3' for=module_6 %} + ```scala Pizza(Small, Thin, Seq(Cheese)) .addTopping(Pepperoni) @@ -416,7 +643,11 @@ Pizza(Small, Thin, Seq(Cheese)) .price ``` +{% endtab %} +{% endtabs %} + ### Extension Methods + Finally, we show an approach that lies between the first one (defining functions in the companion object) and the last one (defining functions as methods on the type itself). Extension methods let us create an API that is like the one of functional object, without having to define functions as methods on the type itself. @@ -428,6 +659,9 @@ This can have multiple advantages: Let us revisit our example once more. +{% tabs module_7 class=tabs-scala-version %} +{% tab 'Scala 3 only' for=module_7 %} + ```scala case class Pizza( crustSize: CrustSize, @@ -451,23 +685,33 @@ extension (p: Pizza) def updateCrustType(ct: CrustType): Pizza = p.copy(crustType = ct) ``` + +{% endtab %} +{% endtabs %} + In the above code, we define the different methods on pizzas as _extension methods_. With `extension (p: Pizza)` we say that we want to make the methods available on instances of `Pizza` and refer to the instance we extend as `p` in the following. This way, we can obtain the same API as before +{% tabs module_8 class=tabs-scala-version %} +{% tab 'Scala 2 and 3' for=module_8 %} + ```scala Pizza(Small, Thin, Seq(Cheese)) .addTopping(Pepperoni) .updateCrustType(Thick) .price ``` + +{% endtab %} +{% endtabs %} + while being able to define extensions in any other module. Typically, if you are the designer of the data model, you will define your extension methods in the companion object. This way, they are already available to all users. Otherwise, extension methods need to be imported explicitly to be usable. - ## Summary of this Approach Defining a data model in Scala/FP tends to be simple: Just model variants of the data with enumerations and compound data with `case` classes. @@ -479,6 +723,5 @@ We have seen different ways to organize your functions: - You can use a “functional objects” approach and store the methods on the defined data type - You can use extension methods to equip your data model with functionality - [adts]: {% link _overviews/scala3-book/types-adts-gadts.md %} [modeling-tools]: {% link _overviews/scala3-book/domain-modeling-tools.md %} From 6552e739701d5ba385a0aba0826b75b146f38c26 Mon Sep 17 00:00:00 2001 From: Ben Luo Date: Fri, 7 Oct 2022 23:09:39 +0800 Subject: [PATCH 2/4] correct 2&3 and 3 only tab. --- _overviews/scala3-book/domain-modeling-fp.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/_overviews/scala3-book/domain-modeling-fp.md b/_overviews/scala3-book/domain-modeling-fp.md index 74c414504d..3b2cac75b0 100644 --- a/_overviews/scala3-book/domain-modeling-fp.md +++ b/_overviews/scala3-book/domain-modeling-fp.md @@ -58,7 +58,7 @@ In Scala, describing the data model of a programming problem is simple: Data that simply consists of different alternatives, like crust size, crust type, and toppings, is concisely modeled with the Scala 3 `enum` construct: -{% tabs data_1 class=tabs-scala-version %} +{% tabs data_1 %} {% tab 'Scala 3 only' for=data_1 %} ```scala @@ -124,7 +124,7 @@ This solution is very concise because it doesn’t require the operations on a p The data model is easy to read, like declaring the design for a relational database. It is also very easy to create values of our data model and inspect them: -{% tabs data_3 class=tabs-scala-version %} +{% tabs data_3 %} {% tab 'Scala 2 and 3' for=data_3 %} ```scala @@ -140,7 +140,7 @@ println(myFavPizza.crustType) // prints Regular We might go on in the same way to model the entire pizza-ordering system. Here are a few other `case` classes that are used to model such a system: -{% tabs data_4 class=tabs-scala-version %} +{% tabs data_4 %} {% tab 'Scala 2 and 3' for=data_4 %} ```scala @@ -324,7 +324,7 @@ A first approach is to define the behavior---the functions---in a companion obje With this approach, in addition to the enumeration or case class you also define an equally named companion object that contains the behavior. -{% tabs org_1 class=tabs-scala-version %} +{% tabs org_1 %} {% tab 'Scala 3 only' for=org_1 %} ```scala @@ -355,7 +355,7 @@ object Topping: With this approach you can create a `Pizza` and compute its price like this: -{% tabs org_2 class=tabs-scala-version %} +{% tabs org_2 %} {% tab 'Scala 2 and 3' for=org_2 %} ```scala @@ -431,7 +431,7 @@ When you write a pure interface like this, you can think of it as a contract tha What you might also do at this point is imagine that you’re the consumer of this API. When you do that, it helps to sketch out some sample “consumer” code to make sure the API looks like what you want: -{% tabs module_2 class=tabs-scala-version %} +{% tabs module_2 %} {% tab 'Scala 2 and 3' for=module_2 %} ```scala @@ -633,7 +633,7 @@ Instead, they have their own reference to the current pizza instance as `this`. Now you can use this new design like this: -{% tabs module_6 class=tabs-scala-version %} +{% tabs module_6 %} {% tab 'Scala 2 and 3' for=module_6 %} ```scala @@ -659,7 +659,7 @@ This can have multiple advantages: Let us revisit our example once more. -{% tabs module_7 class=tabs-scala-version %} +{% tabs module_7 %} {% tab 'Scala 3 only' for=module_7 %} ```scala @@ -694,7 +694,7 @@ With `extension (p: Pizza)` we say that we want to make the methods available on This way, we can obtain the same API as before -{% tabs module_8 class=tabs-scala-version %} +{% tabs module_8 %} {% tab 'Scala 2 and 3' for=module_8 %} ```scala From 9bd1fd1b48e3d8e1aee98620d5602463f5816e97 Mon Sep 17 00:00:00 2001 From: Ben Luo Date: Sat, 15 Oct 2022 02:43:51 +0800 Subject: [PATCH 3/4] change to Scala 3 Only. --- _overviews/scala3-book/domain-modeling-fp.md | 26 ++++---------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/_overviews/scala3-book/domain-modeling-fp.md b/_overviews/scala3-book/domain-modeling-fp.md index 3b2cac75b0..78179d52e1 100644 --- a/_overviews/scala3-book/domain-modeling-fp.md +++ b/_overviews/scala3-book/domain-modeling-fp.md @@ -59,7 +59,7 @@ In Scala, describing the data model of a programming problem is simple: Data that simply consists of different alternatives, like crust size, crust type, and toppings, is concisely modeled with the Scala 3 `enum` construct: {% tabs data_1 %} -{% tab 'Scala 3 only' for=data_1 %} +{% tab 'Scala 3 Only' for=data_1 %} ```scala enum CrustSize: @@ -82,24 +82,8 @@ enum Topping: A pizza can be thought of as a _compound_ container of the different attributes above. We can use a `case` class to describe that a `Pizza` consists of a `crustSize`, `crustType`, and potentially multiple `Topping`s: -{% tabs data_2 class=tabs-scala-version %} -{% tab 'Scala 2' for=data_2 %} - -```scala -import CrustSize._ -import CrustType._ -import Topping._ - -case class Pizza( - crustSize: CrustSize, - crustType: CrustType, - toppings: Seq[Topping] -) -``` - -{% endtab %} - -{% tab 'Scala 3' for=data_2 %} +{% tabs data_2 %} +{% tab 'Scala 3 Only' for=data_2 %} ```scala import CrustSize.* @@ -325,7 +309,7 @@ A first approach is to define the behavior---the functions---in a companion obje With this approach, in addition to the enumeration or case class you also define an equally named companion object that contains the behavior. {% tabs org_1 %} -{% tab 'Scala 3 only' for=org_1 %} +{% tab 'Scala 3 Only' for=org_1 %} ```scala case class Pizza( @@ -660,7 +644,7 @@ This can have multiple advantages: Let us revisit our example once more. {% tabs module_7 %} -{% tab 'Scala 3 only' for=module_7 %} +{% tab 'Scala 3 Only' for=module_7 %} ```scala case class Pizza( From df888962718955d94f01a7ff5a6864650749d342 Mon Sep 17 00:00:00 2001 From: Jamie Thompson Date: Mon, 17 Oct 2022 17:43:11 +0200 Subject: [PATCH 4/4] port examples to scala 2 --- _overviews/scala3-book/domain-modeling-fp.md | 147 ++++++++++++++++--- 1 file changed, 127 insertions(+), 20 deletions(-) diff --git a/_overviews/scala3-book/domain-modeling-fp.md b/_overviews/scala3-book/domain-modeling-fp.md index 78179d52e1..669f5269eb 100644 --- a/_overviews/scala3-book/domain-modeling-fp.md +++ b/_overviews/scala3-book/domain-modeling-fp.md @@ -51,15 +51,48 @@ You’ll see how to implement the “data” portion of the Scala/FP model, and In Scala, describing the data model of a programming problem is simple: -- If you want to model data with different alternatives, use the `enum` construct +- If you want to model data with different alternatives, use the `enum` construct, (or `case object` in Scala 2). - If you only want to group things (or need more fine-grained control) use `case` classes ### Describing Alternatives -Data that simply consists of different alternatives, like crust size, crust type, and toppings, is concisely modeled with the Scala 3 `enum` construct: +Data that simply consists of different alternatives, like crust size, crust type, and toppings, is precisely modelled +in Scala by an enumeration. -{% tabs data_1 %} -{% tab 'Scala 3 Only' for=data_1 %} +{% tabs data_1 class=tabs-scala-version %} +{% tab 'Scala 2' for=data_1 %} + +In Scala 2 enumerations are expressed with a combination of a `sealed class` and several `case object` that extend the class: + +```scala +sealed abstract class CrustSize +object CrustSize { + case object Small extends CrustSize + case object Medium extends CrustSize + case object Large extends CrustSize +} + +sealed abstract class CrustType +object CrustType { + case object Thin extends CrustType + case object Thick extends CrustType + case object Regular extends CrustType +} + +sealed abstract class Topping +object Topping { + case object Cheese extends Topping + case object Pepperoni extends Topping + case object BlackOlives extends Topping + case object GreenOlives extends Topping + case object Onions extends Topping +} +``` + +{% endtab %} +{% tab 'Scala 3' for=data_1 %} + +In Scala 3 enumerations are concisely expressed with the `enum` construct: ```scala enum CrustSize: @@ -80,10 +113,25 @@ enum Topping: ### Describing Compound Data A pizza can be thought of as a _compound_ container of the different attributes above. -We can use a `case` class to describe that a `Pizza` consists of a `crustSize`, `crustType`, and potentially multiple `Topping`s: +We can use a `case` class to describe that a `Pizza` consists of a `crustSize`, `crustType`, and potentially multiple `toppings`: -{% tabs data_2 %} -{% tab 'Scala 3 Only' for=data_2 %} +{% tabs data_2 class=tabs-scala-version %} +{% tab 'Scala 2' for=data_2 %} + +```scala +import CrustSize._ +import CrustType._ +import Topping._ + +case class Pizza( + crustSize: CrustSize, + crustType: CrustType, + toppings: Seq[Topping] +) +``` + +{% endtab %} +{% tab 'Scala 3' for=data_2 %} ```scala import CrustSize.* @@ -288,8 +336,7 @@ Mine (Alvin, now modified, from fp-pure-functions.md): ## How to Organize Functionality When implementing the `pizzaPrice` function above, we did not say _where_ we would define it. -In Scala 3, it would be perfectly valid to define it on the toplevel of your file. -However, the language gives us many great tools to organize our logic in different namespaces and modules. +Scala gives you many great tools to organize your logic in different namespaces and modules. There are several different ways to implement and organize behaviors: @@ -308,8 +355,39 @@ A first approach is to define the behavior---the functions---in a companion obje With this approach, in addition to the enumeration or case class you also define an equally named companion object that contains the behavior. -{% tabs org_1 %} -{% tab 'Scala 3 Only' for=org_1 %} +{% tabs org_1 class=tabs-scala-version %} +{% tab 'Scala 2' for=org_1 %} + +```scala +case class Pizza( + crustSize: CrustSize, + crustType: CrustType, + toppings: Seq[Topping] +) + +// the companion object of case class Pizza +object Pizza { + // the implementation of `pizzaPrice` from above + def price(p: Pizza): Double = ... +} + +sealed abstract class Topping + +// the companion object of enumeration Topping +object Topping { + case object Cheese extends Topping + case object Pepperoni extends Topping + case object BlackOlives extends Topping + case object GreenOlives extends Topping + case object Onions extends Topping + + // the implementation of `toppingPrice` above + def price(t: Topping): Double = ... +} +``` + +{% endtab %} +{% tab 'Scala 3' for=org_1 %} ```scala case class Pizza( @@ -329,9 +407,7 @@ enum Topping: // the companion object of enumeration Topping object Topping: // the implementation of `toppingPrice` above - def price(t: Topping): Double = t match - case Cheese | Onions => 0.5 - case Pepperoni | BlackOlives | GreenOlives => 0.75 + def price(t: Topping): Double = ... ``` {% endtab %} @@ -643,8 +719,8 @@ This can have multiple advantages: Let us revisit our example once more. -{% tabs module_7 %} -{% tab 'Scala 3 Only' for=module_7 %} +{% tabs module_7 class=tabs-scala-version %} +{% tab 'Scala 2' for=module_7 %} ```scala case class Pizza( @@ -653,7 +729,7 @@ case class Pizza( toppings: Seq[Topping] ) -extension (p: Pizza) +implicit class PizzaOps(p: Pizza) { def price: Double = pizzaPrice(p) // implementation from above @@ -668,15 +744,46 @@ extension (p: Pizza) def updateCrustType(ct: CrustType): Pizza = p.copy(crustType = ct) +} ``` +In the above code, we define the different methods on pizzas as methods in an _implicit class_. +With `implicit class PizzaOps(p: Pizza)` then wherever `PizzaOps` is imported its methods will be available on +instances of `Pizza`. The reciever in this case is `p`. {% endtab %} -{% endtabs %} +{% tab 'Scala 3' for=module_7 %} + +```scala +case class Pizza( + crustSize: CrustSize, + crustType: CrustType, + toppings: Seq[Topping] +) + +extension (p: Pizza) + def price: Double = + pizzaPrice(p) // implementation from above + + def addTopping(t: Topping): Pizza = + p.copy(toppings = p.toppings :+ t) + + def removeAllToppings: Pizza = + p.copy(toppings = Seq.empty) + def updateCrustSize(cs: CrustSize): Pizza = + p.copy(crustSize = cs) + + def updateCrustType(ct: CrustType): Pizza = + p.copy(crustType = ct) +``` In the above code, we define the different methods on pizzas as _extension methods_. -With `extension (p: Pizza)` we say that we want to make the methods available on instances of `Pizza` and refer to the instance we extend as `p` in the following. +With `extension (p: Pizza)` we say that we want to make the methods available on instances of `Pizza`. The reciever +in this case is `p`. + +{% endtab %} +{% endtabs %} -This way, we can obtain the same API as before +Using our extension methods, we can obtain the same API as before: {% tabs module_8 %} {% tab 'Scala 2 and 3' for=module_8 %}