At Apiumhub, we’re passionate about sharing knowledge and empowering developers to explore new programming paradigms. A while back, we launched an exciting video series on our YouTube channel titled “Learn Functional Programming from Zero“. This series uses Kotlin to introduce functional programming concepts in an engaging and accessible way, starting from the fundamentals and progressively exploring more complex ideas.
Functional Programming From Zero: What Do We Mean by Functional Programming?
When we hear “functional programming,” we might instinctively think, “Oh, it’s programming using functions.” You might even assume that since your code already contains functions, you’ve unknowingly practiced functional programming. However, while you may indeed be using some functional constructs in your code, this programming paradigm goes much deeper than simply utilizing functions. Keep reading to learn more about functional programming from zero.
Functional programming is built upon several key pillars that set it apart from other programming paradigms:
- Immutability
- Pure Functions
- Functions as First-Class Citizens
These foundational concepts form the backbone of functional programming and contribute to its unique approach to solving problems and structuring code.
Immutability
Immutability refers to the fact that our data structures or objects cannot be modified after their creation. Once an object is created, it will always remain the same. This guarantees that once we obtain an instance of it, no other process can modify it while we are working with it. In Kotlin, we can use data classes to achieve this effect:
data class Student(val age: Int, val name: String)
The advantage of using data classes is that they provide us with a solution to the problem of, for example, wanting to change a student’s age: the “copy” operator.
val underAgePeter = Student(17, "Peter")
val adultPeter = underAgePeter.copy(age = 18)
println(underAgePeter)
//Student(age=17, name=Peter)
Copy gives us a new instance with the specified values changed but without modifying the original.
Pure Functions
Pure functions must meet two requirements:
- When you call the function with a set of parameters, the result of that function should always be the same. In other words, the function should not be affected by external elements. This includes not reading mutable global states, mutable input parameters, etc.
- The function cannot have “side effects,” meaning a function cannot affect elements external to it. It cannot mutate anything outside its scope.
Example of a pure function:
fun add(a: Int, b: Int) = a + b
Example of a function with side effects:
ar sideEffect = "Side Effect"
fun add(a: Int, b: Int): Int {
if(sideEffect.isEmpty()) return a + b
else {
sideEffect = ""
return 0
}
}
This, along with immutability, guarantees that when we call a function, its result will always be the same. It may seem trivial, but if the “sum” function changed its result depending on whether we had previously used some of those numbers to perform a subtraction, we would go crazy trying to understand what was happening.
Functions as First-Class Citizens
If the two previous pillars limit what we can do, this pillar is quite the opposite: it expands the possibilities of expression in our code. Functions being first-class citizens means that we can use them like any other data type, for example, storing functions in variables or returning a function when we call another one.
We can store a function in a variable:
fun add(a: Int, b: Int) = a + b
var addFunction = ::add
println(addFunction(1, 2))
//3
Or return them from another function:
fun myFunction() = fun() {
println("hello")
}
When a function accepts another function as a parameter or returns a function, it is called a Higher-order function. This is so common in functional programming that unnamed, anonymous functions are normally used, as in the last example. In Kotlin, there is a more friendly syntax available for this type of function, and they are called lambda expressions, in which we can omit the keyword ‘fun’.
val add = { a: Int, b: Int ->
a + b
}
println(add(4,5))
//9
Using the syntax for lambdas, we can redefine the previous sum like this. In this case, it may seem that it’s not useful, but when the function is an input parameter, they are extremely useful.
fun printAction(action: (Int) -> String) {
println(action(4))
}
printAction { number -> "The number I was given is $number" }
//The number I was given is 4
As can be observed, when calling printAction, we pass it a lambda that defines how to convert a number into a string, while the printAction function only takes care of printing and doesn’t know how the number will be transformed.
Another relevant thing is the type of the “action” parameter (Int) -> String
. This type indicates that it is a function that receives an integer as a parameter and returns a String. The types of functions follow the pattern: (input types separated by commas) -> return type.
() -> Int
(Int, Int) -> Int
((Int) -> Int) -> String
(Int) -> ((String ) -> Int)
These types can be used both as input and return types since functions are just another type within our type system. When a lambda has a single parameter, we can omit its definition and we will have default access to it with the name “it”.
val printIt: (Any) -> Unit = { println(it) }
printIt(4)
//4
Functional Programming from Zero: Functional Constructs
Now that we have an idea of what functional programming is, as well as some basics to interpret the code, we’re going to look at a series of functional constructs that can be useful both within this paradigm and in object-oriented programming if our language allows it.
Before we begin, we should clarify the idea of “extension functions” in Kotlin. Kotlin provides a way to define functions outside of the class itself. This allows us to extend the behavior of classes over which we don’t have control, and it’s equivalent to writing a static method that receives an instance of the class as its first parameter.
Using the Student class from before as an example, we can define a “greeting” function that returns a string in which it introduces itself.
fun Student.greeting() = "Hello I'm $name and I am $age years old"
val joe = Student(18, "Joe")
println(joe.greeting())
//Hello I'm Joe and I am 18 years old
To write this function, it’s not necessary to have access to the Student code to modify it.
In this section, we will use this syntax to facilitate the definition of functions.
Map
fun <T, R> List<T>.map(transform: (T) -> R): List<R>
Map as a concept is quite easy, but when we see it for the first time, it can seem complicated. What Map does is map elements from a set T to a set R, where for each element of T there corresponds only one element of R. In other words, it transforms Ts into Rs.
Let’s see an example:
val names = listOf("Joe", "Jane", "Danny")
val nameLengths = names.map { it.length }
println(nameLengths)
//[3, 4, 5]
In this example, we have a list of names and we want to know the length of those names. For this, we use “map”. We pass to map the function of how we want to map or transform the elements of the list; in this case, we want to know their size.
We can also chain as many maps as we want.
val students = listOf(Student(19, "Joe"), Student(18, "Jane"), Student(17, "Danny"))
val fieldLenghts = students
.map { it.name }
.map { it.length }
.map { it*2 }
println(fieldLenghts)
In this case, we get the length of the names, then we double it. This could represent, for example, the maximum size needed for some type of form. As you can see in the last step, the transformation doesn’t necessarily have to lead us to different types; we can simply transform from Int to Int if needed.
In addition to the normal map, Kotlin provides us with other functions to cover cases that often occur.
mapIndexed
It provides us, in addition to the element to be transformed, its position in the list. If the list represents the results of a race, we could write something like this:
val results = names.mapIndexed { index, element ->
"$element ended in position ${index+1}"
}
println(results)
//[Joe ended in position 1, Jane ended in position 2, Danny ended in position 3]
mapNotNull
It returns the result of the transformation but eliminates any null results that may exist.
val names = listOf("Joe", null, "Jane", "Danny")
val results = names.map { it?.length }
//[3, null, 4, 5]
val resultsNoNulls = names.mapNotNull { it?.length }
//[3, 4, 5]
Filter
fun <T> List<T>.filter(predicate: (T) -> Boolean): List<T>
Compared to map, filter is easier to visualize; it filters elements from a list. We give it a function that must return true if the element should stay and false if we want to get rid of it.
val students = listOf(Student(19, "Joe"), Student(18, "Jane"), Student(17, "Danny"))
val adultStudents = students.filter { it.age >= 18 }
println(adultStudents)
//[Student(age=19, name=Joe), Student(age=18, name=Jane)]
Just like with map, we can combine these operators to create more complex logic.
val adultStudentsShortNames = students
.filter { it.age >= 18 }
.map { it.name }
.filter { it.length < 4 }
println(adultStudentsShortNames)
//[Joe]
Accumulators
Accumulators are terminal functions. They involve switching from working with a list to working with another type of data. Some examples of terminal operators could be:
- “max” which returns the largest element of the list
val maxStudentAge = students
.map { it.age }
.max()
println(maxStudentAge)
//19
- “maxOf” which allows us to combine the map and the call to max
val maxStudentAge = students.maxOf { it.age }
println(maxStudentAge)
//19
- or “count” which counts how many elements are in the list
val numberOfAdultStudents = students.count { it.age >= 18 }
println(numberOfAdultStudents)
//2
But if we have more complex needs, there are accumulators where we can define their behavior in more detail.
fold
fun <T, R> Iterable<T>.fold(initial: R, operation: (acc: R, T) -> R): R
fold has, in addition to the list, two more arguments: initial: the initial value with which it will perform the operations, and operation: the operation to perform. What it does is, taking initial as the first value, it combines it with the first element of the list, and the result of this operation is combined with the second, and so on. If the list is empty, it directly returns the initial value. This allows us to combine an entire list into a single value as we need at any given time. For example, this code sums up the length of all the students’ names:
val totalNamesLength = students.fold(0) { acc, next ->
acc + next.name.length
}
println(totalNamesLength)
//12
It’s worth noting that this operation is also common enough to have its function “sumOf”, but it serves to illustrate how the result of fold doesn’t have to be of the same type contained in the list. In practice, the operation can be as complex as we can imagine. For example, having as an initial state the state of a view, and each element of the list being an operation to perform on this, ultimately producing the final state of the view after all operations.
reduce
fun <S, T : S> Iterable<T>.reduce(operation: (acc: S, T) -> S): S
reduce is very similar to fold but has a crucial difference: we don’t provide an initial element, it takes the first element of the list as the initial value. This has two important consequences: first if the list is empty, the function is not defined and will fail. Second, the return type must be the same type as the elements of the list (or a supertype of these).
The same thing we did in the previous example with fold can be done like this with reduce:
val totalNamesLength = students
.map { it.name.length }
.reduce { acc, next -> acc+next }
println(totalNamesLength)
//12
But if the list were empty, it would give us the error:
java.lang.UnsupportedOperationException: Empty collection can't be reduced.
It has more limited use cases than “fold”, but in cases where both the result and the list type are the same, and we can guarantee that the list is not empty or we want to fail if it is, reduce allows us to do the same work without having to invent an initial value that doesn’t distort the final result.
Functional Programming From Zero: Conclusion
With this, we have covered the first four chapters of our series Functional Programming From Zero. On Apiumhub’s YouTube channel, you have two more videos where we put to the test the concepts introduced in this article using exercises from the Advent of Code 2023, as well as two additional videos that introduce slightly more advanced concepts that are no less useful for our daily work.
The conclusion we aim to present both in the series and this article is that functional programming doesn’t have to be complicated, and we can introduce it into our workflow to the extent that it’s comfortable and useful for us. Personally, the concepts introduced are things I use practically every day in my work, and they often make my life much easier.
I hope you enjoyed it and that it has sparked your curiosity to see what else this programming paradigm has to offer.
Top comments (0)