Programming styles are supposed to be paradigmatic, in that they structure your thinking about creating software by providing unifying theories and methods that you use to plan, design, construct, and operate your software. In that sense, the way that you think about the software is everything, and the tools that you use are nothing.
Take object-oriented programming, for example. Selecting Java as an implementation language does not immediately mean that your solution is object-oriented (though it may have been sufficient to get you some OOP-linked venture capital at the peak of the hype cycle). Similarly, when you see an implementation language that does not have object features, FORTRAN-77 for example, that does not mean that you are not looking at the source code of software built using the object-oriented paradigm.
But this is the issue on functional programming, not OOP. The paradigmatic basis of functional programming is that you design your program as algebraic transformations of structured data types. This affects how you discuss your program with stakeholders, how you model requirements, how you architect solutions and deployment…it is not just a matter of reaching for Haskell (indeed using the do form it is very easy to write procedural software in Haskell anyway).
You compose large programs by combining algebraic transformations into more complex operations. As such, you define transformations that are composable. This composability comes from designing transformations with clear contracts and interfaces, so that the result of applying the transformation is evident and safe in the context of the composition. For example, imagine that you have a need to double every element in a list of numbers. It is easy in many imperative languages to write a loop that iterates over the list and multiplies each number by 2. But thinking about this “problem” using the paradigm of composing algebraic transformations, you can build the solution from two components:
- Apply an operation to every element of a list.
- Multiply a number by 2.
So now you can build two functions, which are demonstrated as procedures written in Pascal here because, as I said, tool use is not paradigmatic; and besides, Pascal and Haskell nearly rhyme and both languages are named after mathematicians, so they are almost the same thing. The first function to create is the Map function, which applies a function to every element in a collection and returns a collection of the results:
unit Functional;
interface
type
generic TArrayU<U> = array of U;
generic TMapFunc<T, U> = function(const Item: T): U;
generic function Map<T, U>(const Arr: array of T; Func:
specialize TMapFunc<T, U>):
specialize TArrayU<U>;
implementation
generic function Map<T, U>(const Arr: array of T; Func:
specialize TMapFunc<T, U>):
specialize TArrayU<U>;
var
i: Integer;
begin
Result := nil;
SetLength(Result, Length(Arr));
for i := 0 to High(Arr) do
Result[i] := Func(Arr[i]);
end;
end.
The generic interface for the Map function means that even if you did not look at the implementation, you would know something about how it works. Its inputs are an array of T, and a function that turns T into U, and its output is an array of U. Nowhere is anything specified about T or U. That means that the function cannot use any of the operations available on either T or U, so if there is a U in the output the only way that Map created it was by applying the function to one of the Ts in its input.
Unfortunately Pascal arrays have a rich interface so it is impossible to say anything about which Ts get turned into Us; the function might always return nil, for example. The implementation shows that this Pascal Map works as you might expect a map function; it applies the Func parameter to each T in the input array, and returns an array where the corresponding U is at the same index as the given T.
The second function you create is the DoubleInt function that multiples a number by 2 and returns the result:
function DoubleInt(const i: Integer): Integer;
begin
Result := i * 2;
end;
Here the interface does not help much at all: the input is an Integer, and the result is an Integer. The function might always return a constant, or square the input, or add it to a constant, or convert it into a String, reverse the characters, and convert that back into an Integer to return. This demonstrates the power of generic component design: the less information available about the types used in a function, the more constraints on the behavior of that function, so the easier that function is to understand.
Now you solve the problem by composing DoubleInt and Map:
program Main;
uses
Functional, SysUtils;
type
TIntArray = array of Integer;
var
Ints, Doubled: TIntArray;
begin
Ints := TIntArray.Create(1, 2, 3, 4, 5);
Write('Original: ');
for i in Ints do Write(i, ' ');
WriteLn;
Doubled := specialize Map<Integer, Integer>(Ints, @DoubleInt);
Write('Mapped (Double): ');
for i in Doubled do Write(i, ' ');
WriteLn;
end.
These two functions are very simple mappings of their input to their output. You could imagine implementing DoubleInt as a lookup table, where the value at index 0 is 0, the value at index 1 is 2, and so on. In principle you could do the same for Map but it would be a very tedious table to write, and it would lose the simplicity of using the Func parameter to achieve the transformation.
A problem for composability arises when you want to use a program to do something; to store or retrieve a result, or to interact with its environment. These kinds of computations do not show up in the function signature, so you cannot tell the difference between “function that accepts a list of T and returns a Boolean” and “function that accepts a list of T, drops all tables in the database, and returns a Boolean”. It is harder to build functions into reusable workflows when they might have side effects like that.
A solution used by people who think in the functional way is monads, a class of types that associate values with context and enables computations on the values while maintaining the association with the context. Again, because there is nothing special about programming language choice, you can build that in Pascal:
unit Monads;
interface
type
generic IMonad<T> = interface
['{7A8D8C30-5A2C-4B9F-8F8D-9E9C9B9A9D9E}']
function Execute: T;
end;
generic TIOReturn<T> = class(TInterfacedObject, specialize IMonad<T>)
private
FVal: T;
public
constructor Create(const V: T);
function Execute: T;
end;
generic TIOBind<A, B> = class(TInterfacedObject, specialize IMonad<B>)
public
type
TBF = function(const V: A): specialize IMonad<B>;
private
FPrev: specialize IMonad<A>;
FFunc: TBF;
public
constructor Create(const P: specialize IMonad<A>; const F: TBF);
function Execute: B;
end;
implementation
{...}
end.
All this makes it look like there is nothing to a “functional programming language” other than the convenience of expressing paradigmatic functional thinking efficiently. For example, if you had access to a programming language that is like Pascal but also has partial application (function currying, to use Haskell Curry’s family name) then you could create the DoubleInt function in the first example above by applying 2 to the Multiply function, and avoid needing to write a custom component for such a simple operation.
Using the idea that all computationally universal models are equally powerful, you can do your functional thinking in your brain and then convert it into Haskell, Pascal, or even languages that do not rhyme and are not named after mathematicians. This statement, true as it goes, is not helpful. Functional programming as a style does benefit from specifically designed programming languages, that perform for software engineering what John Locke achieved for national goverment in the 17th Century: the separation of [Alonzo] Church and state.
The Pascal functions above might present interfaces that represent composable algebraic primitives, but under the hood their implementations are sequential applications of state transitions in a slightly abstracted version of the computer’s memory. Pascal’s model of computation might let it reorder some instructions a little, but fundamentally it, and similar languages like Algol, C, Rust, and Python, are there to make it easy for you to sequence changes to the computer’s memory.
A goal of functional programming is to escape the von Neumann bottleneck, describing programs in ways that are not limited by the sequencing of changes to memory states. Yes, that has to happen, but it does not have to be the programmer’s responsibility. Instead, the programmer’s task is to explain what the program should do, and the computer’s task is to find an efficient way to do it. Tail-call recursion, lazy or applicative evaluation, memoization, parallelism—none of those are your problem.
The most widespread examples of programming tools that embody this notion of communicating intent, not computation, are query languages (and these days, coding AIs). You do not tell SQL, or LINQ, how to loop over the elements in a collection; you tell it which properties you want from what elements (for example, composing primitives expressed in the Relational Algebra like “Select” and “Project”), and the computer works out an efficient way to make that happen.
You can reap the benefits of functional thinking in whatever programming language you happen to use. To truly escape the clutches of von Neumann and take advantage of functional computation, you do in fact need to choose your tools wisely.
Horrific disestablishment pun by Prof. Jeremy Gibbons. Photo by Sanket Shah on Unsplash.