Functions and Polymorphic Methods
So far, the methods we've dealt with have had strictly defined types. Parameters for these methods were specified in terms of Int
, Double
, String
, etc. Now we are going to look at what it means for a method to be polymorphic, and what functions are in Scala.
If you have any issues understanding the material in this unit, please ask questions in the discord channel I've set up for teaching Scala, or you can discuss on scala-users.
Please remember to run the provided examples via the Scala console unless told otherwise. If you've forgotten how to launch and/or shut down the Scala console, please refer here.
Functions
We've used methods extensively in the previous units. As you may remember, methods are always bound to the instance of a type, are invoked with the .
operator and providing parameters (if needed) to the ()
operator, and allow you to define re-useable aliases for sections of code. Methods are not the only way to define re-useable code though; there exists expressions in Scala that create re-useable sections of code that are not bound to an instance of a type, and which can be passed around your codebase. These expressions are called functions. Let's look at the syntax for a function expression:
(<name>: <type>, <name>: <type>) => <expression>
This looks in some ways like a method definition. The first part in parentheses ()
is where parameters for the function are put. You can have 0 or more parameters. After the parameters section ends, you have a =>
, indicating the transition to the function body, which is the expression in this diagram. The expression can be whatever expression you like, code block, if-expression, for-expression, you name it. The result of this syntax is also an expression! It will return data, but with a type you have not seen before...
scala> val add = (a: Int, b: Int) => a + b
val add: (Int, Int) => Int = Lambda$1285/0x000000080107d000@ff21443
The type of a function expression takes the form of:
(type, type) => type
You might also notice that we don't have a nice output for the function in our console. Unlike 0
or hello
, Lambda$1285/0x000000080107d000@ff21443
is fairly unintelligible. This is because a function is not data in the traditional sense, it's an executable section of code that can be passed around, but representing it with text is... not really feasible.
A function, once used as the definition of a value, can be used just like a method:
scala> add(4,2)
val res0: Int = 6
You can vary the parameters needed for the function as much as you want too:
scala> val sayHello = () => println("hello")
val sayHello: () => Unit = ...
scala> sayHello()
hello
scala> val addThree = (a: Int, b: Int, c: Int) => a + b + c
val addThree: (Int, Int, Int) => Int = ...
scala> addThree(1,2,3)
val res0: Int = 6
They can also be passed to methods, on account of them being expressions:
scala> def flexibleMethod(a: Int, b: Int, fn: (Int, Int) => Int): Int = fn(a,b)
def flexibleMethod(a: Int, b: Int, fn: (Int, Int) => Int): Int
scala> flexibleMethod(4,5,(a: Int, b: Int) => a * b)
val res0: Int = 20
scala> flexibleMethod(6,100, (a: Int, b: Int) => b - a)
val res1: Int = 94
One thing to note about functions, and something that makes them extra nice, is that if the type of the function is already specified, you do not need to specify it again when writing out the function expression:
scala> flexibleMethod(4,5,(a,b) => a * b)
val res0: Int = 20
scala> //we didn't need to write the type for a and b, because it's defined in flexible method for the fn input
scala> val addFive: (Int) => Int = (a) => a + 5
val addFive: Int => Int = ...
scala> //same for addFive. We knew the type, so the type didn't need to be specified in the expression
This leads us to the shortcuts that can be taken when writing a function...
Shortcuts
There are a number of shortcuts that can be taken when writing functions. If Scala knows the type of the function, and it almost certainly will if you're passing it to a method, then you can choose not to specify the types for the parameters, changing our function expression syntax to:
(<name>, <name>) => <expression>
If you have only one input parameter and no need to specify it's type, you can further simplify the expression by dropping the parentheses ()
:
<name> => <expression>
In the case of a single parameter, the type of the function can be expressed similarly; Int => Int
instead of (Int) => Int
.
Methods to Functions
Methods in Scala can automatically be turned into functions if you just pass their name into something expecting a function:
scala> def mul(a: Int, b: Int) = a * b
def mul(a: Int, b: Int): Int
scala> flexibleMethod(4, 5, mul)
val res0: Int = 20
This lets you use methods directly instead of defining a wrapper function for the method like:
scala> flexibleMethod(4,5,(a,b) => mul(a,b))
val res0: Int = 20
Practice
- Write a function that divides two
Int
s - Write a function that takes an
Int
and produces theString
"I'm years old!" - What are the minimum number of parameters that a function can have?
- Do you always have to specify types when writing a function expression?
- Do you always have to write parentheses around the parameters of a function?
- Is a function an expression or a statement?
Polymorphic methods
So far, we've only used methods with pretty specific types. The take
method on List
only takes an Int
parameter for example. However, sometimes a function needs to be able to deal with many different kinds of types with one definition. A method that has been written to do this is called a polymorphic method.
The definition of a polymorphic method is slightly different than the methods we're used to:
def <name>[<typeParameterName>, <typeParameterName>](<name>: <type>) = <expression>
The syntax for a polymorphic method has a new section where type parameter names can be input, between []
square brackets. A polymorphic function will have one or more type parameter names between the []
square brackets. Let's look at how this works in practice:
scala> def meth[A](a: Int) = a
Here, a single type parameter name was provided A
. This creates a new type named A
that can be used in the rest of the method as you please:
scala> def meth[A](a: A): A = a
But what exactly is A
? It's the first type input between the square brackets when you call the method:
scala> meth[Int](4)
val res0: Int = 4
scala> meth[Double](4.0)
val res1: Double = 4.0
scala> meth[String](4.0)
-- [E007] Type Mismatch Error: -------------------------------------------------
1 |meth[String](4.0)
| ^^^
| Found: (4.0d : Double)
| Required: String
Since you have A
also associated with the data input of the method, you can choose to not pass in a type parameter, and let Scala work out what A
should be instead:
scala> meth(4)
val res0: Int = 4
scala> meth(4.0)
val res1: Double = 4.0
scala> meth("hello")
val res2: String = hello
As you can see, our method can take all sorts of data when you make it polymorphic. There's literally no restriction on the kind of data that can be passed in to meth
.
scala> meth(()=> println("hello"))
val res0: () => Unit = ...
However making this method do something useful is quite difficult. You have to prove to Scala the code you write will work for all data, and this is incredibly difficult...
scala> def meth[A](a: A) = a * a
-- [E008] Not Found Error: -----------------------------------------------------
1 |def meth[A](a: A) = a * a
| ^^^
|value * is not a member of A, but could be made available as an extension method.
Programming these kinds of methods will be the subject of a later unit, but you should be aware of what it means when a method is polymorphic and how that changes its signature.
Type parameter restrictions
Type parameters can be restricted in order to make programming a polymorphic method easier. When you restrict the type parameter, Scala is better able to know what your type parameter is, and what you can and can't do with it.
There are two main major restrictions that are used frequently with type parameters: <:
and >:
. <:
is used to create a restriction that says the type parameter is the child of another type. For example A <: Int
would say that A
has to be Int
, or a child of Int
. >:
has the opposite effect, it says that A
is a parent of another type. In essence, A >: Int
says that A
either has to be Int
or its parent.
What exactly a parent or child of a type means is something we'll go over in a later unit, and the types we're dealing with at the moment don't have meaningful parents or children, so when you see these restrictions when defining a type parameter, we'll think of them in a simplified way:
A <: Int
meansA
isInt
A >: Int
meansA
isInt
You can think of these rules as like Bohr's model of an atom, they are imperfect simplifications of the truth that will be helpful to you at these beginner stages, but will need to be abandoned as you dive deeper into Scala. Keep that in mind.
Helpful polymorphic methods of Lists
List
is a polymorphic type. That means that List
has a named type parameter just like a polymorphic method. The name of that type parameter is A
, and a number of methods on List
instances use this type parameter, or an additional one that exists only for that method.
A few extremely valuable polymorphic methods on List
are:
- List.empty
- map
- filter
- sortBy
- reduce
- flatMap
- contains
List.empty
List.empty
is a polymorphic method bound to List
that gives back an empty List
of the type specified. It's signature is def empty[A]: List[A]
and invoking looks like the following:
scala> List.empty[Int]
val res0: List[Int] = List()
The type parameter can be dropped if the type of empty list is already defined:
scala> val emptyIntList: List[Int] = List.empty
val emptyIntList: List[Int] = List()
map
map
is one of the most useful methods on List
, and it actually exists on a lot of other types too. The signature of map
is def map[B](f: A => B): List[B]
where A
is the type parameter of the List
instance. So if you invoke map
on a List[Int]
its signature will change a bit to def map[B](f: Int => B): List[B]
.
map
takes a function from the user, and applies that function to each element of the List
it's invoked from, creating a new List
in the process. This method lets you modify and update all of the contents of your list in place. For example, if you wanted to add one to every number in a List[Int]
, you'd use map
:
scala> val nums = List.range(0,3)
val nums: List[Int] = List(0,1,2)
scala> nums.map(a => a + 1)
val res0: List[Int] = List(1,2,3)
flatMap
flatMap
works very similarly to map
, except its signature is a bit different: def flatMap[B](f: A => List[B]): List[B]
. flatMap
expects the function you give it to give it a List
, and then flattens the List
down.
A comparison of results makes the difference between these methods much clearer:
scala> nums.map(a => List(a))
val res0: List[List[Int]] = List(List(0), List(1), List(2))
scala> nums.flatMap(a => List(a))
val res1: List[Int] = List(0,1,2)
res1
here is just a simple List[Int]
, because the List
s returned from each element got flattened into one unified List
. res0
came from map
, which does not flatten, so its type is List[List[Int]]
.
The flattening behavior of flatMap
can be used to filter List
s:
scala> nums.flatMap(a => if a > 0 then List(a) else List.empty)
val res0: List[Int] = List(1,2)
However, the method filter
is better for that purpose.
It can also be used to grow List
s:
scala> nums.flatMap(a => List.range(0,a+2))
val res0: List[Int] = List(0,1,0,1,2,0,1,2,3)
contains
contains
is a method on List
instances with the signature def contains[A1 >: A](elem: A1): Boolean
. Don't worry about the >: A
restriction on A1
right now. You should just imagine that A1
was written as A
, making the signature of contains
for List[Int]
def contains(elem: Int): Boolean
.
contains
takes an expression and checks if the data returned by that expression is stored in the List
. Let's see how it works:
scala> val nums = List.range(0,3)
val nums: List[Int] = List(0,1,2)
scala> nums.contains(1)
val res0: Boolean = true
scala> nums.contains(-1)
val res1: Boolean = false
As you can see, contains
takes pretty much any type of data, and checks to see if it's in your List
. If it is, it will return true
, otherwise it will return false
.
filter
filter
is a method on instances of List
that can be used to create a new List
with only some of the elements of the original. Its signature is def filter(p: A => Boolean): List[A]
, where A
is the type parameter of the List
. That means for List[Int]
the signature of filter
is def filter(p: Int => Boolean): List[Int]
.
filter
takes a function and processes every element in the List
with it. If the function returns true
, the element will stay in the new List
, otherwise it will be missing. Let's look at an example:
scala> val nums = List.range(0,3)
val nums: List[Int] = List(0,1,2)
scala> nums.filter(a => a > 1)
val res0: List[Int] = List(2)
scala> nums.filter(a => a <= 1)
val res1: List[Int] = List(0,1)
sortBy
sortBy
is related to sorted
, in that it sorts your List
. However, sorted
sorts only based on the data in the List
, while sortBy
can sort by the properties of the data in your List
. Its signature is roughly def sortBy[B](f: A => B): List[A]
, which means for List[Int]
its signature changes to roughly def sortBy[B](f: Int => B): List[A]
.
sortBy
takes a function that transforms the data in your List
into the data you want to perform the sort with. This transformation is thrown away after the sorting is done, and you have a sorted List
with your original data in the order you wanted.
Let's look at a useful application of sortBy
: sorting String
s by number of letters.
scala> val favoriteShows = List("Ozark", "Stranger Things", "Better Call Saul", "Squid Game")
val favoriteShows: List[String] = List(Ozark, Stranger Things, Better Call Saul, Squid Game)
scala> favoriteShows.sortBy(str => str.size)
val res0: List[String] = List(Ozark, Squid Game, Stranger Things, Better Call Saul)
scala> favoriteShows.sorted
val res0: List[String] = List(Better Call Saul, Ozark, Squid Game, Stranger Things)
Using sortBy
we were able to sort the names of shows based on shortest name to longest name, while sorted
would sort the shows by dictionary order (lexicographic order), putting Better Call Saul
first because it starts with a B
.
The True signature of sortBy
Earlier, I said that the signature of sortBy
is roughly def sortBy[B](f: A => B): List[A]
. That's because the true signature is def sortBy[B](f: A => B)(implicit ord: Ordering[B]): List[A]
. This implicit
section is new to us, and it's more complicated than the other things we're discussing here. What it does is ask for proof that the type you get back from the function has information on how to order it. All of the type's we've dealt with so far have this info, but some in the future may not. For example, there is no information on how to sort a List[Any]
.
If you get an error like
1 |List(1,2,3).sortBy(_ => (): Any)
| ^
|No implicit Ordering defined for Any..
Then your issue is that there is no order information available for the type in question. We'll learn how to provide that information later, just be aware of that restriction.
reduce
reduce
is an extremely helpful method on instances of List
, allowing you to turn List[A]
into A
. Basically, if you have a List[Int]
, you would be able to use reduce
to make it into a single Int
(assuming the List
is not empty). The sum
method we mentioned in a previous unit is a reduction; it adds all the elements of a List
together to get a single Int
. Similarly, one can use the reduce
method to calculate a the sum of a List
fairly easily.
The signature of reduce
is def reduce[B >: A](op: (B, B) => B): B
, as I've stated before, at this point you can imagine that the type parameter restriction B >: A
just means B
, so you can imagine the signature as def reduce[B](op: (B,B) => B): B
.
The function passed to reduce
is run on pairs of elements from the List
that you invoked reduce
on. If the List
has only one element, that element is just returned by reduce
, otherwise it chains invocations of the function op
to get a result. Lets look at what that might look like:
scala> val op = (a: Int, b: Int) => a + b
val op: (Int, Int) => Int = ...
scala> List(1,2,3).reduce(op)
val res0: Int = 6
As you can see, by passing in a simple addition function, we reduced List(1,2,3)
to 6
. Lets take a look at how that answer is reached behind the scenes:
List(1,2).reduce(op) ==
op(1,2) ==
3
List(1,2,3).reduce(op) ==
op(op(1,2),3) ==
op(3,3) ==
6
List(1,2,3,4).reduce(op) ==
op(op(op(1,2),3),4) ==
op(op(3,3),4) ==
op(6,4) ==
10
Once you understand how reduce
chains your function call on all of the list's elements, it becomes much simpler to understand.
One thing to note about reduce
is that it will fail if the List
in question is empty:
scala> List.empty[Int].reduce((a,b) => a + b)
java.lang.UnsupportedOperationException: empty.reduceLeft
at scala.collection.IterableOnceOps.reduceLeft(IterableOnce.scala:727)
at scala.collection.IterableOnceOps.reduceLeft$(IterableOnce.scala:724)
at scala.collection.AbstractIterable.reduceLeft(Iterable.scala:919)
at scala.collection.IterableOnceOps.reduce(IterableOnce.scala:698)
at scala.collection.IterableOnceOps.reduce$(IterableOnce.scala:698)
at scala.collection.AbstractIterable.reduce(Iterable.scala:919)
... 26 elided
Practice
- Add one to all elements of a
List
without using a for-expression - Reduce a
List
of numbers by multiplying the numbers together - Filter a
List
ofString
s to only include theString
s that are bigger than 3 letters long. - Reduce a
List
of numbers to the largest number in the list. distinct
exists forString
s, just likeList
s, and it produces aString
with no repeat characters. Define a method nameduniqueLetters
that usessortBy
,map
,distinct
andsize
to sort aList[String]
by the number of unique letters in aString
Answers
Functions
- Write a function that divides two
Int
s - Write a function that takes an
Int
and produces theString
"I'm years old!" - What are the minimum number of parameters that a function can have?
- Do you always have to specify types when writing a function expression?
- Do you always have to write parentheses around the parameters of a function?
- Is a function an expression or a statement?
Helpful polymorphic methods of Lists
- Add one to all elements of a
List
without using a for-expressionList(1,2,3).map(a => a + 1)
- Reduce a
List
of numbers by multiplying the numbers togetherList(1,2,3).reduce((a,b) => a * b)
- Filter a
List
ofString
s to only include theString
s that are bigger than 3 letters long.List("hello", "a", "b", "world").filter(a => a.size > 3)
- Reduce a
List
of numbers to the largest number in the list.List(1,2,3).reduce((a,b) => if a > b then a else b)
distinct
exists forString
s, just likeList
s, and it produces aString
with no repeat characters. Define a method nameduniqueLetters
that usessortBy
,distinct
andsize
to sort aList[String]
by the number of unique letters in aString
def uniqueLetters(strings: List[String]) = strings.sortBy(a => a.distinct.size)