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
    link
    fedilink
    English
    arrow-up
    1
    ·
    edit-2
    5 days 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

    • ch00fOP
      link
      fedilink
      English
      arrow-up
      2
      ·
      5 days 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
        link
        fedilink
        English
        arrow-up
        1
        ·
        4 days 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.

        • ch00fOP
          link
          fedilink
          English
          arrow-up
          2
          ·
          3 days 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!

        • orclev
          link
          fedilink
          English
          arrow-up
          1
          ·
          3 days ago

          Something else to consider is how a function returns an owned value and why knowing the size of a struct at compile time is required for that. At first this might seem like a simple thing if you think about ownership in an abstract sense, but when you actually start to think about how it’s implemented you can start to see some of the complexity.

          Consider the following code:

          use std::any::type_name;
          use std::default::Default;
          
          #[derive(Default, Debug)]
          struct Foo {
              x: u32,
          }
          
          #[derive(Default, Debug)]
          struct Bar {
              y: f32,
          }
          
          fn frobnicate<T: Default>() -> T {
              println!("Creating a new {}", type_name::<T>());
              Default::default()
          }
          
          pub fn main() {
              let foo: Foo = frobnicate::<Foo>();
              let bar: Bar = frobnicate::<Bar>();
              println!("Made a pair of owned {:?} and {:?}", foo, bar);
          }
          

          Where is the memory for foo and bar allocated at? It’s on the stack of the main() function. But the Foo and Bar instances are constructed inside of the call to default() inside of frobnicate(), so how is that memory ending up in the stack of main()? First, lets break this down using what I explained previously about generics being specialized at compile time. This code is roughly equivalent to the following (unchanged parts elided for brevity):

          fn default_Foo() -> Foo {
            Foo {
              x: 0,
            }
          }
          
          fn default_Bar() -> Bar {
            Bar {
              y: 0.0,
            }
          }
          
          fn frobnicate_Foo() -> Foo {
            println!("Creating a new Foo"); // technically there's a genericized version of type_name() invoked here
            default_Foo()
          }
          
          fn frobnicate_Bar() -> Bar {
            println!("Creating a new Bar");
            default_Bar()
          }
          
          pub fn main() {
            let foo: Foo = frobnicate_Foo();
            let bar: Bar = frobnicate_Bar();
            println!("Made a pair of owned {:?} and {:?}", foo, bar);
          }
          

          But this still doesn’t explain how the Foo instance created in default_Foo() ends up on the stack of main(). The answer to that is that Rust is pulling a fast one and there’s a hidden argument on all these functions. The code can’t actually be expressed in Rust in a way that clearly illustrates this, so here’s the C equivalent of the previous Rust code:

          struct Foo {
              unsigned int x;
          };
          
          void default_Foo(struct Foo *foo) {
              foo->x = 0;
          }
          
          struct Bar {
              float y;
          };
          
          void default_Bar(struct Bar *bar) {
              bar->y = 0.0;
          }
          
          void frobnicate_Foo(struct Foo *foo) {
              printf("Creating a new Foo\n");
              default_Foo(foo);
          }
          
          void frobnicate_Bar(struct Bar *bar) {
              printf("Creating a new Bar\n");
              default_Bar(bar);
          }
          
          void main() {
              struct Foo foo;
              struct Bar bar;
              
              frobnicate_Foo(&foo);
              frobnicate_Bar(&bar);
              printf("Made a pair of owned Foo { x: %u } and Bar { y: %f }\n", foo.x, bar.y);
          }
          

          Now that you can see the way that foo and bar are allocated on the stack of main() hopefully you can also see why knowing at compile time what the size of Foo and Bar are is important, otherwise how could the compiler make sure enough space has been allocated on the stack. Now consider what might happen if you tried to return a impl Default from frobnicate() instead of using generics. Setting aside that there would be no way to indicate which version of default() to invoke, if the compiler didn’t know what the concrete type being returned from frobnicate() was, how would it be sure it had allocated enough room on the stack of main() to store the return value?