Search code examples
rustoverloadingtraits

Bring function overload (via trait) into scope


I am trying to overload member functions of my class (similar to what can be done in C++). So I read that in Rust one has to use traits to achieve this. Below is some sample code (note, this is just to demonstrate the idea):

/* my_class.rs */
pub struct MyClass {
    pub a: i32,
    pub b: i32,
}

pub trait Func<T> {
    fn func(&self, t: T) -> Self;
}

impl Func<i32> for MyClass {
    fn func(&self, t: i32) -> MyClass {
        MyClass { a: self.a + t, b: self.b }
    }
}

impl Func<&str> for MyClass {
    fn func(&self, t: &str) -> MyClass {
        MyClass { a: self.a, b: self.b + t.parse::<i32>().unwrap() }
    }
}

and

/* main.rs */
mod my_class;
use crate::my_class::MyClass;
use crate::my_class::Func;

fn main() {
    let m1 = MyClass {a: 10, b:20}.func(5);
    let m2 = MyClass {a: 10, b:20}.func("-8");
    println!("a={}, b={}", m1.a, m1.b);
    println!("a={}, b={}", m2.a, m2.b);
}

Firstly, is this the correct way to overload class-member functions? It seems a little cumbersome, as one needs to add boilerplate pub trait Func<T> for every function overload.

And secondly, is there a way so that I do not have to write use crate::my_class::Func; for every trait? That is, how can I bring all functions of MyClass (both defined via impl MyClass and impl Func<T> for MyClass) into scope when I import MyClass?


Solution

  • If you want to emulate full function overloading, then yes, traits are the way to go. If you don't want to import them, you could just put all your related traits in the same module as the struct then import them all with use crate::my_class::*.

    But don't. There are a lot of reasons why C++/Java-style function overloading isn't a good idea:

    1. It's confusing. Sure, you could come up with examples that aren't confusing, but a lot of overloaded functions do drastically different things depending on the type of their argument, at which point, why not just make a new function? In your example, it's incredibly unintuitive that .func(5) would add 5 to a, while .func("5") would add 5 to b.
    2. It puts an unnecessary burden on the caller. Imagine you're writing a function foo that takes in some T that can be passed to func. How would you bound it? It'd look something like this:
    fn foo<T>
    where
        MyClass: Func<T>
    { unimplemented!() }
    

    This is already kind of ugly. Now imagine you have a MyClass2 that has an overloaded function that also takes in an int-like value (i32 or &str that can be parsed to an i32). Your bound now looks like this:

    fn foo<T>
    where
        MyClass: Func<T>,
        MyClass2: Func<T>
    { unimplemented!() }
    

    even though they're conceptually the same bound on int-like values. When more generics and overloads get added, it only gets uglier and uglier.

    1. It doesn't scale to multiple arguments. Say you want to have a function that takes in one value to add to a, and one value to add to b. You now need 4 implementations:
    fn func(&self, t1: i32, t2: i32) -> Self;
    fn func(&self, t1: i32, t2: &str) -> Self;
    fn func(&self, t1: &str, t2: i32) -> Self;
    fn func(&self, t1: &str, t2: &str) -> Self;
    

    What if you want to support i64 as well? Now you have 9 implementations. And if you want to add a third argument? Now you have 27.

    All of these issues stem from the fact that, conceptually, the bound is really on the argument instead of the function. So, write the code to match the concept and put the trait bound on the argument instead of the function. It prevents confusing overloads that do fundamentally different operations, takes the burden of use off the caller, and stops implementations from exponentially exploding. Best of all, the trait doesn't even need to be imported for the method to be used. Consider this:

    /* my_class.rs */
    pub struct MyClass {
        pub a: i32,
        pub b: i32,
    }
    
    pub trait IntLike {
        fn to_i32(self) -> i32;
    }
    
    impl IntLike for i32 {
        fn to_i32(self) -> i32 { self }
    }
    
    impl IntLike for &str {
        fn to_i32(self) -> i32 { self.parse().unwrap() }
    }
    
    impl MyClass {
        pub fn func<T: IntLike>(&self, t: T) -> Self {
            Self { a: self.a + t.to_i32(), b: self.b }
        }
    }
    

    and

    /* main.rs */
    mod my_class;
    // don't even need to import the trait
    use crate::my_class::MyClass;
    
    fn main() {
        let m1 = MyClass {a: 10, b:20}.func(5);
        let m2 = MyClass {a: 10, b:20}.func("-8");
        println!("a={}, b={}", m1.a, m1.b);
        println!("a={}, b={}", m2.a, m2.b);
    }
    

    Isn't that just nicer?


    Addendum

    There are a lot more reasons why traditional function overloading isn't a good idea that have been omitted from the main body of the post due to being besides the point. Here's some more:
    1. It causes you to write the same code over and over again. If you have a function that finds the prime factorization of an integer and you want it to work on any int-like values, then you have to copy/paste the prime factorization code for every single argument type, only to change the one line that converts the argument to an i32. You could refactor the shared code to a separate function then just call that function from the overloaded ones, but isn't that literally what bounding the argument on a trait does?
    2. It's not extensible. Say someone's writing a crate with a BigInt type, and they want it to work with your function. They'd have to import your trait, then copy-paste your implementation, and change one line to convert their BigInt to an i32. Not only is this ugly, it's actually impossible if your implementation references any private methods or properties. As the cherry on top, if you change your implementation (and its 20 overloads) to fix a bug, the developer of the external crate now needs to manually add in the bug fix. Other developers shouldn't ever have to concern themselves with your internals. An IntLike trait would allow other developers to just handle the logic of converting to an i32, then let you handle the rest.
    3. It goes against the design of the language. The Rust book chapter on traits is titled "Traits: Defining Shared Behavior". They're primarily designed for just that: shared behavior. Using them for function overloading is just a hack that's a side-effect of how traits are implemented in Rust, hence why it poses so many critical issues. When you bound the argument instead of the function, you enable yourself to compose your own constraints as well as the numerous pre-implemented traits in the standard library such as Debug and Clone. In fact, most of the time, you won't even have to make your own trait since there'll already be a trait for it.

    TL;DR idiomatic C++ is horrendous Rust.