Fun with Composition
We can do a lot with composition. Let's see another example that shows a bit more of its power. Remember the concentric circles exercise we used as an example:
def concentricCircles(count: Int, size: Int): Image =
count match {
case 0 => Image.empty
case n => Image.circle(size).on(concentricCircles(n-1, size + 10))
}
This pattern allows us to create many different images
by changing the use of Image.circle
to another shape.
However, each time we provide a new replacement for Image.circle
,
we also need a new definition of concentricCircles
to go with it.
We can make concentricCircles
completely general by supplying
the replacement for Image.circle
as a parameter.
Here we've renamed the method to concentricShapes
, as we're no longer restricted to drawing circles,
and made the singleShape
parameter responsible for drawing an appropriately sized shape.
def concentricShapes(count: Int, singleShape: Int => Image): Image =
count match {
case 0 => Image.empty
case n => singleShape(n).on(concentricShapes(n-1, singleShape))
}
Now we can re-use the same definition of concentricShapes
to produce plain circles, squares of different hue,
circles with different opacity, and so on.
All we have to do is pass in a suitable definition of singleShape
:
// Passing a function literal directly:
val blackCircles: Image =
concentricShapes(10, (n: Int) => Image.circle(100 + 24*n))
// Converting a method to a function:
def redCircle(n: Int): Image =
Image.circle(100 + 24*n).strokeColor(Color.red)
val redCircles: Image =
concentricShapes(10, redCircle)
You might notice two things about this example: we're not using function composition, and we have duplication in the definitions. In both cases we draw circles, but they differ in color. This feels like a problem we can solve with function composition. Let's give it a go.
In both cases the radius of the circle is 100 + 24*n
, where n
is the iteration count.
We can extract this into a function.
val size: Int => Int = n => 100 + 24*n
Now we can build a function that creates a circle of size computed from n
.
val circle: Int => Image = size => Image.circle(size)
Now we just need a function that can change the stroke color.
def strokeColor(color: Color): Image => Image =
image => image.strokeColor(color)
With this we can construct the functions we need.
val blackCircle = circle // Black is the default stroke
val redCircle = size.andThen(circle).andThen(strokeColor(Color.red))
Using andThen
is a way to compose functions, but not the only way to compose functions.
We can write all sorts of functions that compose functions.
Sometimes we use the term combinator for a method or function whose job is to compose its parameters.
For example, the on
, beside
, and above
combinators compose Images
.
Here's an example of a function combinator that combines two Int => Image
functions to produce another Int => Image
function.
def withSizeOn(f: Int => Image, g: Int => Image): Int => Image =
size => f(size).on(g(size))
With this definition we can produce the image below.
To create it we define a function to make a square given a size, and then compose it with our existing circle
function as shown below.
def square(n: Int): Image =
Image.square(n)
concentricShapes(
10,
size
.andThen(withSizeOn(circle, square))
.andThen(strokeColor(Color.royalBlue))
)
Exercise: The Colour and the Shape
Your mission is to write code to produce the image below.
Let's think about the problem a little. We need to do two things:
- write an appropriate definition of
singleShape
for each of the three shapes in the target image; and
- call
concentricShapes
three times, passing in the appropriate definition ofsingleShape
each time and putting the resultsbeside
one another.
We also want to use function composition,
to create the definitions of singleShape
from small, reusable components.
The variation in the image comes from changing the shape and the color. For shapes we have circle, triangle, and square. The color changes by spinning the hue of the circle and square, and fading the color for the triangle. This suggests functions for each shape, and functions for each color manipulation.
That's probably enough guidance. It's now over to you to finish up the code.
We can start with the basic functions for shapes we've already used, and add a function for triangles.
def circle(n: Int): Image =
Image.circle(n)
def square(n: Int): Image =
Image.square(n)
def triangle(n: Int): Image =
Image.triangle(n, n)
We also need functions that produce the color as a function of the iteration count.
def fading(n: Int): Color =
Color.blue.fadeOut((1 - n / 20.0).normalized)
def spinning(n: Int): Color =
Color.blue.desaturate(0.5.normalized).spin((n * 30).degrees)
Now we need to put these functions together.
We have one group of functions of type Int => Image
and another with type Int => Color
.
We cannot compose these with andThen
as the types don't work out.
However we can create our own combinator that does what we want.
def colored(shape: Int => Image, color: Int => Color): Int => Image =
(n: Int) => shape(n).strokeWidth(10).strokeColor(color(n))
Putting it all together gets us the output we want.
concentricShapes(10, colored(size.andThen(circle), spinning))
.beside(
concentricShapes(10, colored(size.andThen(triangle), fading))
.beside(concentricShapes(10, colored(size.andThen(square), spinning)))
)
Exercise: More Shapes
The concentricShapes
methods takes an Int => Image
function. We can make a function with this type using drawCurve
, the parametric curves we created earlier, and the various utilities we have created along the way. There is an example below.
The code to create this is below.
def dottyCircle(n: Int): Image =
drawCurve(
72,
parametricCircle.andThen(scale(100 + n * 24)).andThen(growingDot)
)
concentricShapes(10, colored(dottyCircle, spinning))
Use the techniques we've seen so far to create a picture of your choosing (perhaps using the flowers we have seen earlier). No solution here; there is no right or wrong answer.