Skip to content

Instantly share code, notes, and snippets.

@JonNorman
Created October 23, 2018 11:00
Show Gist options
  • Save JonNorman/7afbab7edce3cb6bb88162ede1be4baf to your computer and use it in GitHub Desktop.
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)
/***********************************************************************************************
* 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