Functional programming can greatly reduce the amount and complexity of code required to accomplish tasks. Particularly in Rust, proper application of functional principles may simplify the often complex design requirements, and make programming a much more productive and rewarding experience.
Reducing code weight and complexity
Making generics more generic
Making generics more generic relates to the practice of parameterizing data structures and functions originated in functional languages. In Rust, and other languages, this is called generics. Types and functions can all be parameterized. One or more constraints may be placed on generic types to indicate requirements of a trait or lifetime.
Struct definitions can become redundant without generics. Here is a definition of three structs that define a common concept of a Point. However, the structs use different numerical types, so the singular concept is expanded into three separate PointN type definitions in intro_generics.rs:
struct PointU32
{
x: u32,
y: u32
}
struct PointF32
{
x: f32,
y: f32
}
struct PointI32
{
x: i32,
y: i32
}
Instead, we can use generics to remove duplicate code and make the code more robust. Generic code is more easily adaptable to new requirements because many behaviors (and thus requirements) can be parameterized. If a change is needed, it is better to only change one line rather than a hundred.
This code snippet defines a parameterized Point struct. Now, a single definition can capture all possible numerical types for a Point in intro_generics.rs:
struct Point<T>
{
x: T,
y: T
}
Functions are also problematic without generics.
Here is a simple function to square a number. However, to capture possible numerical types, we define three different functions in intro_generics.rs:
fn foo_u32(x: u32) -> u32
{
x*x
}
fn foo_f32(x: f32) -> f32
{
x*x
}
fn foo_i32(x: i32) -> i32
{
x*x
}
Function parameters, such as this one, may need trait bounds (a constraint specifying one or more traits) to permit any behavior on that type that is used in the function body.
Here is the foo function, redefined with a parameterized type. A single function can define the operation for all numerical types. Explicit bounds must be set for even basic operations, such as multiply or even copy, in intro_generics.rs:
fn foo<T>(x: T) -> T
where T: std::ops::Mul<Output=T> + Copy
{
x*x
}
Even functions can be sent as parameters. We call these higher-order functions.
Here is a trivial function that accepts a function and argument, then calls the function with the argument, returning the result. Note the trait bound Fn, indicating that the provided function is a closure. For an object to be callable, it must implement one of the fn, Fn, FnMut, or FnOnce traits in intro_generics.rs:
fn bar<F,T>(f: F, x: T) -> T
where F: Fn(T) -> T
{
f(x)
}
Functions as values
Functions are nominally the big feature of functional programming. Specifically, functions as values are the keystone of the whole paradigm. Glossing over much detail, we will also introduce the term closure here for future reference. A closure is an object that acts as a function, implementing fn, Fn, FnMut, or FnOnce.
Simple closures can be defined with the built-in closure syntax. This syntax is also beneficial because the fn, Fn, FnMut, and FnOnce traits are automatically implemented if permitted. This syntax is great for shorthand manipulation of data.
Here is an iterator over the range 0 to 10, mapped to the squared value. The square operation is applied using an inline closure definition sent to the map function of the iterator. The result of this expression will be an iterator. Here is an expression in intro_functions.rs:
(0..10).map(|x| x*x);
Closures can also have complex bodies with statements if the block syntax is used.
Here is an iterator from 0 to 10, mapped with a complex equation. The closure provided to map includes a function definition and a variable binding in intro_functions.rs:
(0..10).map(|x| {
fn f(y: u32) -> u32 {
y*y
}
let z = f(x+1) * f(x+2);
z*z
}
It is possible to define functions or methods that accept closures as arguments. To use the closure as a callable function, a bound of Fn, FnMut, or FnOnce must be specified.
Here is a HoF definition accepting a function g and an argument x. The definition constrains g and x to process u32 types, and defines some mathematical operations involving calls to g. An invocation of the f HoF is also provided, as follows, using a simple inline closure definition in intro_functions.rs:
fn f<T>(g: T, x: u32) -> u32
where T: Fn(u32) -> u32
{
g(x+1) * g(x+2)
}
fn main()
{
f(|x|{ x*x }, 2);
}
Many parts of the standard library, particularly iterators, encourage heavy use of functions as arguments.
Here is an iterator from 0 to 10 followed by many chained iterator combinators. The map function returns a new value from an original. inspect looks at a value, does not change it, but permits side-effects. filter omits all values that do not satisfy a predicate. filter_map filters and maps with a single function. The fold reduces all results to a single value, starting from an initial value, working left to right. Here is the expression in intro_functions.rs:
(0..10).map(|x| x*x)
.inspect(|x|{ println!("value {}", *x) })
.filter(|x| *x<3)
.filter_map(|x| Some(x))
.fold(0, |x,y| x+y);
Iterators
Iterators are a common feature of OOP languages, and Rust supports this concept well. Rust iterators are also designed with functional programming in mind, allowing programmers to write more legible code. The specific concept emphasized here is composability. When iterators can be manipulated, transformed, and combined, the mess of for loops can be replaced by individual function calls. These examples can be found in the intro_iterators.rs file. This is depicted in the following table:
Function name with description | Example |
Chain concatenates two iterators: first...second |
(0..10).chain(10..20); |
The zip function combines two iterators into tuple pairs, iterating until the end of the shortest iterator: (a1,b1), (a2, b2), ... |
(0..10).zip(10..20); |
The enumerate function is a special case of zip that creates numbered tuples (0, a1),(1,a2), … |
(0..10).enumerate(); |
The inspect function applies a function to all values in the iterator during iteration |
(0..10).inspect(|x|{ println!("value {}", *x) }); |
The map function applies a function to each element, returning the result in place |
(0..10).map(|x| x*x); |
The filter function restricts elements to those satisfying a predicate |
(0..10).filter(|x| *x<3); |
The fold function accumulates all values into a single result |
(0..10).fold(0, |x,y| x+y); |
When you want to apply the iterator, you can use a for loop or call collect |
for i in (0..10) {} |
Compact legible expressions
In functional languages, all terms are expressions. There are no statements in function bodies, only a single expression. All control flow operators are then formulated as expressions with a return value. In Rust, this is almost the case; the only non-expressions are let statements and item declarations.
Both of these statements can be wrapped in blocks to create an expression along with any other term. An example for this is the following, in intro_expressions.rs:
let x = {
fn f(x: u32) -> u32 {
x * x
}
let y = f(5);
y * 3
};
This nested format is uncommon in the wild, but it illustrates the permissive nature of Rust grammar.
Returning to the concept of functional style expressions, the emphasis should always be on writing legible literate code without much hassle or bloat. When someone else, or you at a later time, comes to read your code, it should be immediately understandable. Ideally, the code should document itself. If you find yourself constantly writing code twice, once in code and again as comments, then you should reconsider how effective your programming practices really are.
To start with some examples of functional expressions, let's look at an expression that exists in most languages, the ternary conditional operator. In a normal if statement, the condition must occupy its own line and thus cannot be used as a sub-expression.
The following is a traditional if statement, initializing a variable in intro_expressions.rs:
let x;
if true {
x = 1;
} else {
x = 2;
}
With the ternary operator, this assignment can be moved to a single line, shown as follows in intro_expressions.rs:
let x = if true { 1 } else { 2 };
Almost every statement from OOP in Rust is also an expression—if, for, while, and so on. One of the more unique expressions to see in Rust that is uncommon in OOP languages is direct constructor expressions. All Rust types can be instantiated by single expressions. Constructors are only necessary in specific cases, for example, when an internal field requires complex initialization. The following is a simple struct and an equivalent tuple in intro_expressions.rs:
struct MyStruct
{
a: u32,
b: f32,
c: String
}
fn main()
{
MyStruct {
a: 1,
b: 1.0,
c: "".to_string()
};
(1, 1.0, "".to_string());
}
Another distinctive expression from functional languages is pattern matching. Pattern matching can be thought of as a more powerful version of a switch statement. Any expression can be sent into a pattern expression and de-structured to bind internal information into local variables before executing a branch expression. Pattern expressions are uniquely suited for working with enums. The two make a perfect pair.
The following snippet defines a Term as a tagged union of expression options. In the main function, a Term t is constructed, then matched with a pattern expression. Note the syntax similarity between the definition of a tagged union and the matching inside of a pattern expression in intro_expressions.rs:
enum Term
{
TermVal { value: String },
TermVar { symbol: String },
TermApp { f: Box<Term>, x: Box<Term> },
TermAbs { arg: String, body: Box<Term> }
}
fn main()
{
let mut t = Term::TermVar {
symbol: "".to_string()
};
match t {
Term::TermVal { value: v1 } => v1,
Term::TermVar { symbol: v1 } => v1,
Term::TermApp { f: ref v1, x: ref v2 } =>
"TermApp(?,?)".to_string(),
Term::TermAbs { arg: ref mut v1, body: ref mut v2 } =>
"TermAbs(?,?)".to_string()
};
}