Is wrapping `fn(&mut T)` with `fn(T) -> T` a valid form of functional programming?

I'm trying to lean more into semi-functional programming (internal state is 100% allowed, but not side effects), but I'm not sure how to handle existing methods that mutate state in-place, such as Vec::sort().

The "normal" functional approach of cloning the entire Vec isn't exactly what I want, as I don't need two of them, but I also don't want to be using any functions that mutate external state through references. Ideally, the "main" program would be entirely declarative.

Would wrapping functions that mutate in-place ie. fn(&mut T) in another function that takes ownership ie. fn(T) -> T be a semi-idiomatic way to make this cleaner? Or is there a better method? I'm trying to decide if the following is overkill:

fn main() {
    let example = Vec::from([1, 3, 4, 2]).sorted();
}

trait Sort {
    fn sorted(self) -> Self;
}

impl<T: Ord> Sort for Vec<T> {
    fn sorted(mut self) -> Self {
        self.sort();
        self
    }
}

Shorter alternative for if you don't really care about method chaining:

fn main() {
    let example = sort(Vec::from([1, 3, 4, 2]));
}

fn sort<T: Ord>(mut vec: Vec<T>) -> Vec<T> {
    vec.sort();
    vec
}

Does this all seem sensible? Or am I trying too hard?

This reminds me of

As I said once, pure functional programming is an ingenious trick to show you can code without mutation, but Rust is an even cleverer trick to show you can just have mutation.

from Notes on a smaller Rust - Without boats, dreams dry up

3 Likes

You have correctly identified how to transform a function that mutates through a reference into a pure function. There's absolutely nothing wrong with that, and no caveat, if your goal is to practice pure functional programming and use Rust to do it.

But I imagine most Rust programmers would not consider that idiomatic Rust. Which isn't to say that you shouldn't write it — just that it's solving a different problem than “write good Rust code”.

As to @bjorn3's comment — it's a good point, but I'd like to suggest thinking about two different goals of pure-functional programming:

  • Avoid errors due to unintentionally mutating a shared value. Rust indeed avoids this by single-ownership and explicit borrowing — you can't make this mistake without using interior mutability somewhere.
  • Write a program whose functions match the semantics of mathematical functions — one where, for example, deleting a function call whose result is unused never affects the meaning of the program (except for halting and resource usage, of course). To do this, one does need to write functions in the form you've worked out for sort().
3 Likes

I would simply consider a function that mutates a value through a &mut T reference argument to still be pure. Side-effects only come into play when you have interior mutability (or other effects like IO).

Wrapping a fn(&mut T) function into fn(T) -> T seems pointless, unidiomatic, and can have negative consequences in terms of usability (you cannot easily turn an fn(T) -> T back into fn(&mut T)), and potentially even performance (if size_of::<T>() is nontrivial, the additional moves can have a cost).

So just consider fn(&mut T) to be “just as pure/functional” as fn(T) -> T (they really are mostly equivalent, the only relevant difference is that the fn(&mut T) doesn’t consume the T if it panics).

In line with this trait of thought, compile-time-evaluable functions – const fn – which are intended to be deterministic (i.e. “pure”) are also (eventually going to) support &mut T arguments.

2 Likes

This is an important point. "Functional" is as much a way of thinking about things as it is anything technical.

Take a concatenative language, for example. It's equally valid to think of them as imperative or functional.

  • In the imperative interpretation, you think of add as getting passed something mutable, and the body calls pop twice on it, and then pushes the sum. So something like (C++)
void add(stack<int>& s) {
    int a = s.pop();
    int b = s.pop();
    s.push(a + b);
}
  • In the functional interpretation, you think of add as getting passed a list, where it pattern-matches off the top two items, and returns a new list. So something like (SML):
fun add(a::b::rest) = (a+b)::rest

Both interpretations work great. So you think in terms of whichever you prefer.

2 Likes

It's bound to be an infectious excersise as your versions are less flexible due to the required moves.

struct MyStruct {
    vec: Vec<u32>,
}

fn f(ms: &mut MyStruct) {
    ms.vec = sort(ms.vec);
}

Results in

error[E0507]: cannot move out of `ms.vec` which is behind a mutable reference
  --> src/lib.rs:11:19
   |
11 |     ms.vec = sort(ms.vec);
   |                   ^^^^^^ move occurs because `ms.vec` has type `Vec<u32>`, which does not implement the `Copy` trait

So if you made a library with this approach, say, it wouldn't necessarily compose well with idiomatic Rust.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.