0

As I understand it, rust is a "has a" and not a "is a" language (composition over inheritance). this makes Liskov substitutions slightly more complicated but not impossible using Traits. While I can use LSP, it appears to not be the idiomatic way of implementing type coercion in rust. I'm left confused of how to operate.

minimal example

Let's assume I have two structs

struct Real(i32);
struct Complex(Real, Real);

And a function project which takes a Complex and return a projection of the input.

#[derive(Clone, Copy)]
struct Real(i32);
struct Complex(Real, Real);

// we pass by reference because we need to be blazingly fast
fn project(c : &Complex) -> Complex {
    Complex(c.0, Real(0))
}

fn main() {
    let a = Complex(Real(1), Real(2));
    let x = project(&a);

    println!("{} + {}i", x.0.0, x.1.0)
}

To keep things simple, please assume we are the situation in which we benefit from passing Real by reference and project should not be duplicated as multiple implementation from a trait for Real and Complex. Assume we expect to also use project on Reals from time to time.

Making project somewhat generic

My OOP instincts pushes me to make some supertype for Real and Complex, let's say the trait AsReal

#[derive(Clone, Copy)]
struct Real(i32);
struct Complex(Real, Real);

trait AsReal {
    fn as_real(&self) -> Real;
}

impl AsReal for Real { fn as_real(&self) -> Real { *self } }
impl AsReal for Complex { fn as_real(&self) -> Real { self.0 } }

fn project (r : &impl AsReal) -> Complex {
    Complex( r.as_real(), Real(0) )
}

fn main() {
    let a = Real(1);
    let b = Complex(Real(2), Real(3));

    let x = project(&a);
    let y = project(&b);
    
    println!("{} + {}i", x.0.0, x.1.0);
    println!("{} + {}i", y.0.0, y.1.0);
}

But apparently, the rusty way would be to use AsRef<Real> instead

#[derive(Clone, Copy)]
struct Real(i32);
struct Complex(Real, Real);

fn project<U: AsRef <Real>>(r : U) -> Complex {
    Complex ( *r.as_ref(), Real(0) )
}

impl AsRef<Real> for Complex {
    fn as_ref(&self) -> &Real { &self.0 }
}

impl AsRef<Real> for Real {
    fn as_ref(&self) -> &Real { self }
}

fn main() {
    let a = Real(1);
    let b = Complex(Real(2), Real(3));

    let x = project(&a);
    let y = project(&b);
    
    println!("{} + {}i", x.0.0, x.1.0);
    println!("{} + {}i", y.0.0, y.1.0);
}

Which leaves me unsatisfied : the prototype for project became very wordy and hard to read. So much so it feels like the convenience of use for project is simply not worth it.

Furthermore, it means the function must opt-in for Complex into Real coercion and I dislike that notion as it feel like it pushes me to develop defensively and use AsRef<...> all the time.

I don't feel like I have the full picture, what would be the idiomatic way to interact with rust for situation like this ?

NRagot
  • 113
  • 2
  • 12
  • Why not put the `project` function in a trait which you implement for `Complex` and `Real`? – Jmb Nov 09 '22 at 11:41
  • I've made a minimal example for simplicity sake. Please assume the situation benefit from not duplicating the function (for instance, let's assume it is very long and error prone). My example is heavily inspired from this example https://stackoverflow.com/questions/66026309/when-and-why-to-use-asreft-instead-of-t – NRagot Nov 09 '22 at 11:54
  • Note the Liskov Substitution Principle explicitly references classes, which Rust does not have. Rust technically *does* have subtyping, but given the limited nature, it's not possible for this to break the principle (since it only applies to lifetimes) – cameron1024 Nov 09 '22 at 12:29

1 Answers1

0

Based on your description, it seems like you could go with this:

  • project takes a Real
  • Complex provides an into_real() method that returns a Real

Small sidenote: if your types are small and Copy, a pointer isn't always faster. Compiler explorer can be a great tool for showing you what the assembly for a snippet is/

That being said, I'd write it like this.

fn project(real: Real) -> Real {
  // very complex logic
}

// deriving Copy makes these types behave more like numbers
#[derive(Copy, Clone)]
struct Real(i32);
#[derive(Copy, Clone)]
struct Complex(Real, Real);

impl Complex {
  fn into_real(self) -> Real {
    self.0
  }
}

fn main() {
  let real = Real(0);
  let complex = Complex(Real(0), Real(0));

  project(real);
  project(complex.into_real());
}

If you really hate having to write into_real(), you could make the call-site simpler and make the declaration-site more complex by:

  • implementing From<Complex> for Real (though arguably this needs its own trait since there's more than one way to get a Real from a Complex)
  • making project accept an impl Into<Real>
impl From<Complex> for Real {
  fn from(c: Complex) {
    c.0
  }
}

fn project(real: impl Into<Real>) {
  let real = real.into();
  // real is a `Real` here
}

Though honestly, I'd just go for the first option. Your function doesn't really need to be generic, and that increases monomorphization cost. It's not very OOP, but Rust is not an OOP language.

cameron1024
  • 9,083
  • 2
  • 16
  • 36
  • Thank you for your help, would you mind expanding your explanation to include AsRef? If not to emulate OOP behaviour at near to no cost, what is the use of `AsRef` ? – NRagot Nov 09 '22 at 13:36
  • `AsRef` is for "cheap reference-to-reference` conversions. It might make sense to `impl AsRef for Complex`, but it's not needed here. In a case like this, you're probably better off ignoring references, and just relying on the fact that numbers are cheaply copyable – cameron1024 Nov 09 '22 at 13:37