So I’m slowly digging my way through the stm32l0xx-hal crate. As written, the crate works with an older version of embedded-hal. My ultimate goal is to bring it up to the latest version or at least start the process.

My question surrounds the implementation of the I2c struct in i2c.rs.

Starting on line 57 is a struct that contains three elements of generic type.

/// I2C abstraction
pub struct I2c<I2C, SDA, SCL> {
    i2c: I2C,
    sda: SDA,
    scl: SCL,
}

I want to specifically talk about the first element. The bounds on what the generic <I2C> type can be start in the implementation where the first element must implement the type Instance.

impl<I, SDA, SCL> I2c<I, SDA, SCL>
where
    I: Instance,
{
    pub fn new(i2c: I, sda: SDA, scl: SCL, freq: Hertz, rcc: &mut Rcc) -> Self
        where
            I: Instance,
            SDA: SDAPin<I>,
            SCL: SCLPin<I>,
        {...}
...
}

Going one step higher, I2c::new() is called inside a macro on line 522 inside an implementation of the I2cExt trait on I2C1, I2C2, etc.

impl I2cExt<$I2CX> for $I2CX {
            fn i2c<SDA, SCL>(
                self,
                sda: SDA,
                scl: SCL,
                freq: Hertz,
                rcc: &mut Rcc,
            ) -> I2c<$I2CX, SDA, SCL>
            where
                SDA: SDAPin<$I2CX>,
                SCL: SCLPin<$I2CX>,
            {
                I2c::new(self, sda, scl, freq, rcc)
            }
        }

So the generic type in the I2cExt trait is the same as the type for which the trait is implemented in this macro. The <$I2CX> type is passed to the I2c return type which ultimately creates an instance of the I2c struct with the first element of type <$I2CX> and along the way, it’s verified that it implements the Instance type.

So my question is: couldn’t we ignore all of this generic nonsense if we just defined the I2c struct to expect a certain trait for its first value?

For example, couldn’t the first line of the struct just be i2c: Instance or some other defined trait that encompasses all of the I2c drivers on the chip? If we’re bounding the type to an Instance anyway, why bother with all the generics?

  • orclev@lemmy.world
    link
    fedilink
    English
    arrow-up
    0
    ·
    edit-2
    6 months ago

    Yes, but also no. Traits can lead to a variety of problems, either because you try to use them with dynamic dispatch via the dyn keyword, or because the compiler ends up tripping over itself trying to verify the trait bounds. Generics avoid all those problems by functioning as essentially a concrete type variable.

    There was a video i watched a couple days ago that did a great job of highlighting the myriad footguns traits can introduce, particularly when used in structs. I’ll see if I can track it down, but for now just know that generics are generally speaking the less headache inducing option for polymorphism.

    Edit: watch this for a taste of the kinds of problems traits can end up introducing: https://youtu.be/9RsgFFp67eo

    • ch00f@lemmy.worldOP
      link
      fedilink
      English
      arrow-up
      0
      ·
      6 months ago

      Watching video: Wow, that’s long! Oh…it covers multiple topics, so it’s just the first section…oh.

      Thanks for the link! It seems like I need to read up more on the object oriented stuff. I’ve never even worked with trait objects nor dynamic dispatch.

      • orclev@lemmy.world
        link
        fedilink
        English
        arrow-up
        0
        ·
        6 months ago

        Rust at its core really prefers to use (at runtime) monomorphism and static dispatch. There are escape hatches such as the dyn keyword to opt into dynamic dispatch (read runtime polymophism), but those escape hatches have lots of gotchas and introduce a bunch of complexity that it’s very easy to get overwhelmed by. Traits by their nature walk the line between monomorphism and polymorphism due to the compiler often being able to derive at compile time what the concrete type implementing the trait is and thus eliminate the polymorphism but not always.

        Generics in contrast don’t have that problem. When you invoke a generic function at compile time that function gets specialized for the generic arguments it’s give. So for example, this code actually generates two version of the foo function in the compiled code:

        trait Quux {
          fn frobnicate(&self);
        }
        
        struct Bar;
        impl Quux for Bar {
          fn frobnicate(&self) { println!("Frobnicating Bar!"); }
        }
        
        struct Baz;
        impl Quux for Baz {
          fn frobnicate(&self) { println!("Frobnicating Baz!"); }
        }
        
        fn foo<T: Quux>(value: &T) {
          value.frobnicate();
        }
        
        pub fn main() {
          foo(&Bar);
          foo(&Baz);
        }
        

        This code compiles to something that’s roughly equivalent to the following:

        fn foo_Bar(value: &Bar) {
          value.frobnicate(); // Statically calls Bars version of frobnicate
        }
        
        fn foo_Baz(value: &Baz) {
          value.frobnicate(); // Statically calls Bazs version of frobnicate
        }
        
        pub fn main() {
          foo_Bar(&Bar);
          foo_Baz(&Baz);
        }
        

        This also factors into generic structs where the compiler can (usually) determine at compile time how much memory needs to be reserved for storing a generic struct because it can work out exactly which concrete type a field contains.

        A lot of the tools Rust gives you allow you to write code that looks polymorphic at compile time, but actually generates monomorphic code at runtime. That’s pretty much always the case with generics, and usually the case with traits.

        • ch00f@lemmy.worldOP
          link
          fedilink
          English
          arrow-up
          0
          ·
          6 months ago

          Thanks for the extended write up! I’m starting to get more of a feel for what the conpiler is doing behind the scenes.

          I was going to ask a follow-up question about why there are instances of generics in traits that are never used:

          // I2C SDA pin
          pub trait SDAPin<I2C> {
              fn setup(&self);
          }
          

          But I think I get it. This allows multiple nearly identical implementations of this trait that vary only by the concrete type used.

          This lets the macro implicitly tie certain hardware pins to certain register banks while maintaining a common setup() function for the SDAPin trait.

          Neat!