Created
October 23, 2018 11:00
-
-
Save JonNorman/7afbab7edce3cb6bb88162ede1be4baf to your computer and use it in GitHub Desktop.
A quick overview of the concepts covered in week 4 of scala-school (part 2)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*********************************************************************************************** | |
* Traits | |
* | |
* In the same way that case classes allow us to express our ideas at a higher, more conceptual | |
* level, traits can be used to link case classes together that share some commonality. | |
**********************************************************************************************/ | |
/* | |
Sticking with our geometry domain, let's say that we want to create an app | |
that will do some basic geometry for us, we can start with some squares. | |
We will stick our code in an Example object so that we can define things of the same | |
name in a different object later on i.e. create some namespaces. | |
*/ | |
object Example1 { | |
case class Rectangle(height: Int, width: Int) { | |
val area = height * width | |
val perimeter = height * 2 + width * 2 | |
} | |
def combinedArea(rectangles: List[Rectangle]): Int = { | |
rectangles.map(_.area).sum | |
} | |
def combinedPerimeter(rectangles: List[Rectangle]): Int = { | |
rectangles.map(_.perimeter).sum | |
} | |
} | |
/* this means import ALL of the things defined in the Example1 namespace (object) into the 'current' namespace | |
so that we can use them. Without this, we would have to refer to everything with an 'Example1.' prefix | |
e.g. Example1.Rectangle(6, 10) | |
The underscore tells the compiler to import ALL of the things, if we just want to import a single method | |
then we can do that with 'import Example1.combinedPerimeter', or if we want a number of things we can | |
put them in curly braces and list them e.g. 'import Example1.{combinedPerimeter, combinedArea}' | |
*/ | |
import Example1._ | |
val rec = Rectangle(6, 10) | |
rec.area | |
rec.perimeter | |
/* | |
Now let's see we want to expand our horizons and create a new kind of shape, a circle and a triangle perhaps? | |
*/ | |
object Example2 { | |
case class Rectangle(height: Int, width: Int) { | |
val area: Int = height * width | |
val perimeter: Int = height * 2 + width * 2 | |
} | |
def combinedRectangleAreas(rectangles: List[Rectangle]): Int = { | |
rectangles.map(_.area).sum | |
} | |
def combinedRectanglePerimeters(rectangles: List[Rectangle]): Int = { | |
rectangles.map(_.perimeter).sum | |
} | |
case class Circle(radius: Int) { | |
val area: Double = Math.PI * Math.pow(radius, 2) | |
val perimeter: Double = Math.PI * radius * 2 | |
} | |
def combinedCircleAreas(circles: List[Circle]): Double = { | |
circles.map(_.area).sum | |
} | |
def combinedCirclePermeters(circles: List[Circle]): Double = { | |
circles.map(_.perimeter).sum | |
} | |
case class EquilateralTriangle(length: Int) { | |
val area: Double = Math.pow(length, 2) / 2 | |
val perimeter: Int = length * 3 | |
} | |
def combinedTriangleAreas(triangles: List[EquilateralTriangle]): Double = { | |
triangles.map(_.area).sum | |
} | |
def combinedTrianglePermeters(triangles: List[EquilateralTriangle]): Double = { | |
triangles.map(_.perimeter).sum | |
} | |
} | |
/* | |
What's the problem with the above? | |
1. What if we want to get the combined area of a list of circles and triangles? What about circles and rectangles? | |
What about rectangles and triangles? All 3? What if we introduce 20 more shapes? | |
*/ | |
object Example3 { | |
import Example2._ | |
def combinedCircleTriangleAreas(circles: List[Circle], triangles: List[EquilateralTriangle]): Double = { | |
combinedCircleAreas(circles) + combinedTriangleAreas(triangles) | |
} | |
def combinedCircleRectangleAreas(circles: List[Circle], rectangles: List[Rectangle]): Double = { | |
combinedCircleAreas(circles) + combinedRectangleAreas(rectangles) | |
} | |
// and so on, and so on and so on.... | |
} | |
import Example2._ | |
import Example3._ | |
val circles = List( | |
Circle(4), | |
Circle(6) | |
) | |
val triangles = List( | |
EquilateralTriangle(6), | |
EquilateralTriangle(20) | |
) | |
combinedCircleTriangleAreas(circles, triangles) | |
/* | |
This is VERY tedious to write and use, hard to understand, and difficult to express. | |
We as the programmers know that all of our case classes share some commonality - they have areas and | |
they have perimeters. They might have a different number of sides, completely different properties that others | |
don't - e.g. a radius - but if ALL we care about at the moment is those two attributes, then we shouldn't have | |
to care about what KIND of shape they are or these other differences. | |
We have to show the scala compiler these relationships, and then we can exploit them. We can do this with traits. | |
Traits can be thought of as specific interfaces or behaviours that tell us SOMETHING about the classes that extend them. | |
In the case of our case classes, they all share an area and perimiter, so we can encode this in a trait by using the | |
'extends' syntax. | |
*/ | |
object Example4 { | |
trait Shape | |
case class Rectangle(height: Int, width: Int) extends Shape { | |
val area: Int = height * width | |
val perimeter: Int = height * 2 + width * 2 | |
} | |
case class Circle(radius: Int) extends Shape { | |
val area: Double = Math.PI * Math.pow(radius, 2) | |
val perimeter: Double = Math.PI * radius * 2 | |
} | |
case class EquilateralTriangle(length: Int) extends Shape { | |
val area: Double = Math.pow(length, 2) / 2 | |
val perimeter: Int = length * 3 | |
} | |
// now we can just have two methods that take in a List[Shape] and handle it all for us | |
def combinedArea(shapes: List[Shape]): Double = { | |
// convert each shape into it's area and then sum it | |
shapes.map { shape => | |
shape match { | |
case r: Rectangle => r.area | |
case c: Circle => c.area | |
case et: EquilateralTriangle => et.area | |
} | |
}.sum | |
} | |
def combinedPerimeter(shapes: List[Shape]): Double = { | |
// convert each shape into it's perimeter and then sum it | |
// NOTE: if you are calling .map(...) on a collection and then pattern matching on it using a | |
// case statement, you can use the shorthand like so - compare with the above combinedArea definition. | |
shapes.map { | |
case r: Rectangle => r.perimeter | |
case c: Circle => c.perimeter | |
case et: EquilateralTriangle => et.perimeter | |
}.sum | |
} | |
} | |
import Example4._ | |
val shapes: List[Shape] = List( | |
EquilateralTriangle(5), | |
Circle(1), | |
Rectangle(5,8) | |
) | |
combinedArea(shapes) | |
combinedPerimeter(shapes) | |
/* | |
Now the compiler has sort of the same understanding that we do! A collection of Shapes can be treated similarly by | |
being put in the same collection and pattern matched over. But we are stil repeating ourselves a lot. Imagine if | |
we have 100 different types of Shape, all with areas! Our pattern match would be massive and repetitive. | |
We can define our Shapes trait to express that not only are all of the Shapes comparable in some abstract sense, | |
but that they also have the same attributes in some specific sense, like so: | |
*/ | |
object Example5 { | |
// here we are saying that anything that extends Shape MUST provide some value for area and perimeter | |
trait Shape { | |
val area: Double | |
val perimeter: Double | |
} | |
/* | |
By using the 'override' keyword, we are telling the compiler that the following expression is intended | |
to override some value that already exists. In this case, our Shape trait is defining 'area' and 'perimeter' | |
but is not providing a value for them. Each of our extending case classes is overriding that useless | |
definition with one of their own. | |
Note: you don't HAVE to put in the override keyword, but it is a helpful prompt to indicate that 'this value | |
is originally defined elsewhere and I'm providing a different implementation of it'. It also means that if you | |
accidentally misspell the val/def that you are overriding, then scala will complain that you have the overriding | |
keyword but that you aren't overriding anything, which can be useful! | |
*/ | |
case class Rectangle(height: Int, width: Int) extends Shape { | |
override val area: Double = height * width | |
override val perimeter: Double = height * 2 + width * 2 | |
} | |
case class Circle(radius: Int) extends Shape { | |
override val area: Double = Math.PI * Math.pow(radius, 2) | |
override val perimeter: Double = Math.PI * radius * 2 | |
} | |
case class EquilateralTriangle(length: Int) extends Shape { | |
override val area: Double = Math.pow(length, 2) / 2 | |
override val perimeter: Double = length * 3 | |
} | |
/* | |
Now we can write our functions with ease! And no matter how many new extensions of Shape we add, we don't have | |
to change our implementation at all. Happy days. | |
*/ | |
def combinedArea(shapes: List[Shape]): Double = { | |
shapes.map(_.area).sum | |
} | |
def combinedPerimeter(shapes: List[Shape]): Double = { | |
shapes.map(_.perimeter).sum | |
} | |
} | |
/* | |
What follows are some additional points that haven't been covered above but that you may have seen in previous | |
exercises or demos. Feel free to have a read through! | |
*/ | |
/* | |
When looking at traits, you may see the statement 'sealed trait'. All this means is that you are telling the | |
compiler that ALL of the classes that extend this trait are defined in the same file. | |
What is the point of that? | |
It means that when you do a pattern match over something with that trait, the compiler can ensure that all of the | |
possible cases are covered - that they are 'exhaustively checked', because it can look in the same file, see | |
all of the extensions and check them off. If the trait isn't sealed then extensions of the trait could be defined | |
anywhere and it would be impossible or very hard to the compiler to check that a case statement is covering all | |
the bases. | |
If the trait is sealed and the compiler finds that there is a case that isn't checked then it will provide | |
a warning at compile time indicating this e.g. the following code would generate this error: | |
warning: match may not be exhaustive. | |
It would fail on the following input: C(_) | |
because it knows that a C is a Thing and could be passed into our doSomeThing function, and there isn't a case | |
in our case statement that would handle that. | |
*/ | |
object Example6 { | |
sealed trait Thing | |
case class A(x: Int) extends Thing | |
case class B(x: Int) extends Thing | |
case class C(x: Int) extends Thing | |
def doSomeThing(thing: Thing): Int = { | |
thing match { | |
case a: A => 1 | |
case b: B => 2 | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment