Functional programming is a programming paradigm that can be understood as encapsulating the operation process using functions, and computing results through the combination of various functions. The main difference between functional programming and imperative programming is that functional programming focuses on data mapping, while imperative programming focuses on solving problems through steps.
In recent years, functional programming has regained popularity in the programming world due to its elegant and simple characteristics. Mainstream languages, without exception, have incorporated more functional features such as Lambda
expressions, native support for map
, reduce
, etc. For example, Java8
started supporting functional programming.
In the frontend domain, we can also see the influence of functional programming. ES6
introduced arrow functions, Redux
adopted ideas from Elm
to reduce the complexity of Flux
, React16.6
introduced React.memo()
, making pure functional components
possible, and 16.8
started promoting Hooks
and suggesting the use of pure functions
for component development.
In fact, this concept is quite abstract. Let's illustrate it with an example. Suppose we have a requirement to modify a data structure.
In traditional imperative programming, we usually use loops to perform operations such as concatenation in order to obtain the final result.
This approach does complete the task, but it involves a lot of intermediate variables and complex logic. If we were to handle this as a function and return a value, it would require thorough reading to understand the entire logic. Additionally, it would be difficult to locate issues once they occur.
If we switch our approach and use the principles of functional programming, we can ignore concepts like curry
, compose
, and map
for now. When we implement these two functions later, we will revisit this example. If we simply focus on the programming approach, it's clear that the thought process in functional programming is completely different. It focuses on functions rather than processes. It emphasizes how to solve problems through the transformation of function composition, rather than focusing on what statements to write. As your code grows, this type of function decomposition and composition will produce powerful results. You can directly run the example below in a Ramda
environment, but you will need to prefix undefined methods with R.
to invoke them as methods of the R
object.
Based on the academic definition of a function, it is a description of the relationship and transformation between sets. Any input through a function will result in only one output value. Therefore, a function is actually a relationship or a mapping, and this mapping relationship can be composed. Once we know that the output type of one function can match the input of another, they can be combined. For example, the compose
function above actually completes the combination of mapping relationships, transforming data from a String
to another String
, and then to an Object
, similar to the mathematical composition operation g°f = g(f(x))
.
In the programming world, what we actually need to deal with is just data and relationships, and relationships are functions. What we call programming is just finding a kind of mapping relationship. Once the relationship is found, the problem is solved. The remaining task is to let the data flow through this relationship and then transform it into another data. This is actually a kind of work that is similar to an assembly line, where the input is considered as raw materials and the output is considered as the product. Data can continuously flow from the output of one function to the input of another function, and finally, output the result. So, it can be understood from here that functional programming actually emphasizes putting more focus on how to build relationships in the programming process, and solving all problems at once by building an efficient pipeline, rather than dispersing effort in different processing factories to transfer data back and forth.
Functions as first-class citizens are the prerequisite for the implementation of functional programming because our basic operations mainly involve handling functions. This feature means that functions are equally standing with other data types, can be assigned to other variables, can be passed as parameters to another function, or can be returned as values of other functions.
Declarative programming is mainly focused on declaring what needs to be done rather than how to do it. This programming style is known as declarative programming. The advantage of this approach lies in the high readability of the code, as declarative code is mostly close to natural language. It also frees up a lot of human effort because it does not concern the specific implementation. Hence, it can delegate the optimization capability to the specific implementation, making it convenient for division of labor and collaboration. SQL statements are declarative, and it does not require you to worry about how the SELECT
statement is implemented. Different databases will implement their own methods and optimize them. React is also declarative; you just need to describe your UI, and then how the UI updates after the state changes is handled by React during runtime, rather than relying on yourself to render and optimize the diff algorithm.
Statelessness and immutable data are the core concepts of functional programming. To achieve this goal, functional programming proposes the characteristics that functions should possess: no side effects and pure functions.
No side effects refer to the accomplishment of other secondary functions outside the main function in the function. In our functions, the main function is, of course, to return a result based on input, and the most common side effect in functions is the arbitrary manipulation of external variables. Since JavaScript passes objects by reference, even if we declare an object using the const
keyword, it can still be changed. Ensuring that a function has no side effects can not only guarantee the immutability of data but also avoid many problems caused by shared state. When you are maintaining the code alone, this may not be obvious, but with project iterations and an increasing number of participants, the dependence and reference to the same variable will become more and more serious, leading in the end to even the maintainer being unclear about where the variable is being changed and causing bugs. Passing a reference may be fun for a while, but it's a code refactoring bonfire.
Pure functions further emphasize the requirement of having no side effects. In Redux's three principles, we can see that it requires all modifications to be made using pure functions. Pure functions are the true meaning of functions and it means that given the same input, you will always get the same output. In fact, the concept of pure functions is very simple, it emphasizes two main points:
this
pointer, I/O operations, etc.If there are two indispensable operations in functional programming, then without a doubt, it is currying and function composition. Currying is actually a processing station on the assembly line, and function composition is our assembly line, which is composed of multiple processing stations.
In the case of currying, simply put, it is the transformation of a multi-parameter function into a single-unit function that is called in sequence. It is a method that transforms a multi-parameter function into a single-parameter function. Currying emphasizes the splitting of an operation into multiple steps, and can change the behavior of the function. In my understanding, currying actually implements a state machine, which transitions from the state of receiving parameters to the state of executing the function when the specified parameters are reached. In simple terms, currying can change the form of function calls.
There are some concepts that are very similar to currying, such as partial function application. These two are not the same. Partial function application emphasizes fixing a certain number of parameters and returning a smaller element of the function.
Currying emphasizes the generation of unit functions, while partial function application emphasizes fixing any number of parameters. In our daily lives, what we actually use is mostly partial function application. The advantage of this is that it can fix parameters, reduce the generality of functions, and improve the applicability of functions. In many library functions, the curry
function has done a lot of optimization and is no longer a pure currying function. It can be called advanced currying, and these versions can return a currying function/result value based on the number of parameters you input, that is, if the number of parameters you provide meets the function's conditions, it returns a value.
Implementing a simple currying function can be done through closures.
When there are multiple parameters, obviously, this is not elegant enough, so we encapsulate a function that transforms a normal function into a curried function.
Let's take an example of currying to filter mobile phone numbers and email addresses using regular expressions.
Advanced currying has an application in the Thunk
function. The Thunk
function is an implementation of call by name for compilers, often putting parameters into a temporary function, and then passing this temporary function into the function body, called the Thunk
function. The Thunk
function replaces the parameters with a single parameter version and only accepts a callback function as a parameter.
Implement a simple, Thunk
function transformer that can convert any function into the form of a Thunk
function as long as the parameters have a callback function.
Thunk
functions might have been less utilized before ES6
, but after that, Generator
functions emerged. By using Thunk
functions, they can be utilized for automatic flow control of Generator
functions. First, let's talk about the basic usage of Generator
functions. Invoking a generator function does not immediately execute the statements inside it. Instead, it returns an iterator object, which is a pointer to the internal state object. When the next()
method of this iterator is first (or subsequent times) called, the statements inside it will execute until the first (or subsequent) occurrence of yield
, and what comes after yield
is the value that the iterator will return. The pointer will then resume execution from the beginning of the function or the point where it last stopped to the next yield
. Alternatively, if yield*
is used, it indicates transferring the control to another generator function (pausing the execution of the current generator).
Because Generator
functions can temporarily suspend the execution of a function, they can handle an asynchronous task. When one task is completed, the next task can be continued. The following example synchronizes an asynchronous task. The next timer task will only be started after the previous delayed timer completes. This approach can solve issues related to nested asynchronous operations. For instance, using callbacks after a network request can easily lead to callback hell. However, such issues can be resolved using Generator
functions. In fact, async/await
uses Generator
functions and Promise
to achieve asynchronous solutions.
Although the above example can execute automatically, it's not very convenient. Now, we will implement an automatic flow management function for Thunk
. This function will automatically handle the callback functions, requiring only the parameters needed for the function execution in the Thunk
function, for example, the index
in the example. Then, you can write the function body of the Generator
function. When using Thunk
function for automatic flow management, it's essential to ensure that after yield
, there is a Thunk
function.
Regarding the run
function for automatic flow management, when the next()
method is called with an argument, this argument will be passed to the variable on the left of the last executed yield
statement. In this function, when next
is first executed without passing any argument, and no statement to receive the variable exists above the first yield
, there is no need to pass an argument. Then, it is determined whether the generator function has completed execution. Since it has not completed, the custom next
function is passed to res.value
. It's important to note that res.value
is a function, as can be seen by executing the commented line in the example below, which will show that the value is f(funct){...}
. When the custom next
function is passed, the execution control is handed over to the f
function. Once this function completes the asynchronous task, it will execute the callback function. This callback function will trigger the next next
method of the generator, and this time, an argument is passed to it. As mentioned earlier, when an argument is passed, it will be passed to the variable on the left of the last executed yield
statement. In this execution, this value will be passed to r1
, and then next
will continue to be called repeatedly until the generator function completes its execution. This way, the automatic flow management is achieved.
The purpose of function composition is to combine multiple functions into one. Here's a simple example.
As we can see, compose
achieves a simple function that forms a new function, which serves as a pipeline from g -> f
. It's easy to notice that compose
actually satisfies the associative law.
As long as the order is consistent, the final result is consistent. Therefore, we can write a more advanced compose
that supports combining multiple functions.
Now, let's consider a small requirement: to capitalize the last element of an array. Assuming log
, head
, reverse
, and toUpperCase
functions exist, the imperative way of writing it would be:
The object-oriented way:
The function composition way:
This is similar to the concept of a pipeline pipe
in Linux commands, such as the combination ps grep
. The execution direction of the pipeline and the compose
function (from right to left) seems to be the opposite. Therefore, many function libraries like Lodash
, Ramda
, etc. also offer another way of composing functions called pipe
.
Finally, if we come back to the first example, we can complete it as a runnable example.