The last article was a short introduction into currying in rust based on some experiments with a friend of mine.
To really use the benefits of currying and creating a smaller library for it, we need to understand a little bit more about the application of it. And we're going to dip our toe into creating more generic functions that we can reuse.
We'll start with parts of where we left off the last time
fn main() {
let c_add = add(12);
let r1 = c_add(3);
println!("{}", r1) // 15
}
fn add(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
right now we only take integers. If we take a look at the function add, we see the following:
- name: add
- in i32
- out f(i32) -> i32
so we have a function that takes an input of type integer and returns a function that takes the same type as input and returns a value of the same type
If we write this as a generic it would look something like this:
fn add<T>(x: T) -> impl Fn(T) -> T {
move | y: T | x + y
}
Our compiler now will be confused to which types do we mean. As a programmer we just assume our system will know our intentions.
The compiler should deduce that: if we're passing in an i32 it should know and behave in the way we want it to. But our compiler cannot be sure what <T> means and it will get confused.
To remove complexity for a bit lets dumb our generic a little bit down and focus on the generic problem first before we add our currying again.
let start with this function:
fn add<T>(x: T, y: T) -> T {
x + y
}
We now defined a function that takes in two parameters of a type <T> and return result of the type <T>
the <T> after add is just a placeholder for the type. So our compiler knows the letter <T> in this context means our type.
We could also write:
fn add<A_NUMBER>(x: A_NUMBER, y: A_NUMBER) -> A_NUMBER
It is more expressive, but also more to write. So you see it's not the letter <T> that's important, but it's an ID for a type we want to reuse so for example.
So lets have a look some a function signatures:
fn add(x: i32, y: i32) -> i32
fn add(x: i8, y: i8) -> i8
fn add(x: u16, y: u16) -> u16
fn add(x: f32, y: f32) -> f32
all those functions are on some level the same. They take in 2 parameters of a certain type and return 1 result of a certain type.
Since the types remain the same we boldly assume that they can be generalized / abstracted / made generic by picking a description that all of them have in common.
fn add<T>(x: T, y: T) -> T
We write less code for things that for some intent and purpose are the same. By definition being generic removes precision. If our code gets complex, nested or we do something wrong the compiler cannot guess anymore and will hopefully deny compilation before doing something wrong.
we've stopped at :
fn add<T>(x: T, y: T) -> T {
x + y
}
our compile will now complain:
error[E0369]: binary operation `+` cannot be applied to type `T`
--> src/main.rs:11:5
|
11 | x + y
| ^^^^^
|
= note: `T` might need a bound for `std::ops::Add`
As mentioned before our compiler does not know if T is a number in our case and if it can apply the + operation.
And again a short detour is needed, because this is important to understand. The + sign is just a symbol, it has no meaning unless we give it one.
As a real life example think about school math and multiplications
1 x 1 = 1
vs
1*1 = 1
vs
1 multiplied by 1 is 1
the 3rd one is a more human readable form, but we see the x and the * do the same thing. Still we have 2 different symbols doing the same thing. Which means the are synonymous to each other.
The + operator in our case is not a synonym it's the opposite
2 = 1 + 1
vs
aa = a + a
Where + does different things based on the context it's used in. one time it adds 2 integers, the other time it concatenates 2 characters to a string. This is called homonym where we need context to decide what something means.
In the english language an example would be: Bank ... a river bank probably wont handle your money and a bank institute is not necessarily built around rivers.
This is just so we understand this particular error message. Only because we think + has a certain meaning the computer does not. Even if the computer knows the + as operator we still have the problem that types in memory don't look the same so we cannot apply the same Operations on CPU level to them.
We usually refer to operators that decide based on the types they are to as overloaded. For overloading operations in rust we need to provide more information about the types
In our case we need our System to understand generic numbers and since this article is not about implementing generic mathematical types. We use the external rust num create for generic math.
the crate has to be added in the toml file.
extern crate num;
fn main() {
let r1 = add(1, 1);
println!("{}", r1)
}
fn add<T>(x: T, y: T) -> T
where T: num::Num
{
x + y
}
we add a where, which we use to say the following: every type that is getting in is a number.
so if we want to do some more math like description it would be something along the lines of this.
f(x,y) = {x,y| x ∈ Number and y ∈ Number}
f(x + y) -> {z | z ∈ Number}
our generics now work. we can now pass in different types of numeric values and get a valid result
extern crate num;
fn main() {
let r1 = add(1, 1);
let r2 = add(1.2, 1.2);
let r3 = add(0x12, 0x13);
println!("{}", r1);
println!("{}", r2);
println!("{}", r3);
}
fn add<T>(x: T, y: T) -> T
where T: num::Num
{
x + y
}
To get to our currying we will add the closure again so we can apply our generics.
extern crate num;
fn main() {
let add_c = add(1);
let r1 = add_c(2);
println!("{}", r1);
}
fn add<T>(x: T) -> impl Fn(T)-> T
where T: num::Num
{
move |y: T| x + y
}
if we compile this now our compiler will complain
error[E0507]: cannot move out of captured outer variable in an `Fn` closure
--> src/main.rs:13:17
|
10 | fn add<T>(x: T) -> impl Fn(T)-> T
| - captured outer variable
...
13 | move |y: T| x + y
| ^ cannot move out of captured outer variable in an `Fn` closure
error: aborting due to previous error
This tells us the following problem. The move command moves our x we pass in the first function call in the lambda inside of our add function as a reference.
We should either make a copy of it - use it as a value - or we avoid switching context or we return it.
The numeric types per default implement the Copy trait which comes in handy. We just need to give the compiler the hint that it only applies to Types that implement the copy trait.
fn add<T>(x: T) -> impl Fn(T)-> T
where T: num::Num + Copy
{
move |y: T| x + y
}
The + operator in the where context is a type compound operator.
Now we can curry in a generic way, the next thing will probably be about lifetimes
Thanks for reading.
Addendum generics behaviour
I need to point out an additional feature of generic implementations esp in libraries: only the applied generics get compiled!
so if i don't use the function add in any way, it will never reach the binary. However as soon as we use it, the particular case we invoked will get compiled. As an example:
fn main() {
let _int_res = add(13, 13);
}
In this case will only compile the integer version in our source. Which means or compiled code now will contain something like this:
fn add(x: i32) -> impl Fn(i32)-> i32
{
move |y: i32| x + y
}
Please take this explanation with a grain of salt. It is not accurate in as to how it works or how the function is really named or how the linker / compiler works. In other words .... it's just a simplification to understand the application and not the details of the implementation.
So generics are somewhat like the C++ templates , not as powerful but still very useful.