Generic function over enum with different types

Rust Playground 1:

Not exactly sure what I'm missing here. I would like fn default() to return either a String or an i16 depending on the key provided. But when I add another enum variant (lines 103-105), it does not compile.

Is this a generics syntax thing?
Or an issue with the structs/enums?
Or something else?

I'm trying to make some getter functions because I would like to not have to repeat these (huge to me) pyramids in my code when I need to access a deeply nested field or index from a struct.

Thanks in advance.

Edit: I was able to reduce the pyramids 40% just by de-duplicating - no traits needed, because each sort is only for one type. So a win-win there.

Rust is strictly and statically typed, so inside of default, result can't be both an Option<i16> and an Option<String>.

Additionally/more generally, the caller of default (or return_default) chooses the concrete type that the generic parameter takes on (within the stated bounds). So inside the function, the generic T represents a single concrete type. You can't return an Option<String> if you said you were going to return an Option<T> for example. The function has to work for every possible choice of T within the bounds.

So, I guess that I will have to (in the playground case) make 2 getters, one for each type.

But it makes me think if using enums is worth it all then, since I have implement different/duplicate behaviors anyway - so maybe instead of 1 struct with 2 enum variants, I just make 2 structs with different types.

As a reference here is a previous version of the function returning two different types, but only to the console. The generics are working as expected.

Rust Playground 2:

It was this code I was trying to modify to return the values instead of just printing them. This is what I am unclear on.

You can use the enum as the return type, and then return the entire enum instead of the inner part. Enums let you fit multiple kinds of data into something that can only handle one type, which is the same reason you're using the enum for the Vec. Rust Playground

I added fn key and modified your default function.

impl ModelSettingType {
    fn key(&self) -> &str {
        match self {
            Self::Int(settings) => &settings.key,
            Self::Str(settings) => &settings.key,
        }
    }
}

impl ModelSettings {
    fn default(&self, key: &str) -> Option<&ModelSettingType> {
        for model_setting_type in &self.settings {
            if model_setting_type.key() == key {
                return Some(model_setting_type);
            }
        }
        None
    }
}
1 Like

Let's look at the playgrounds and see if we can explain why they work, or why they don't. (I won't offer solutions/workarounds/alternatives in this post.) I'll split up the default and return_default functions.

Playground 2

return_default (works)

        fn return_default<T>(key: &String, model_setting: &ModelSetting<T>)
        where
            T: std::fmt::Debug + std::fmt::Display + std::clone::Clone,
        {
            if key == &model_setting.key {
                println!("key: {}, default: {}", key, model_setting.default.clone());
                //                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            }
        }

Here you're mainly dealing with just Strings, except for the highlighted portion. There, you clone model_setting.default, which has type T. So the highlighted code has type T. It's different for every T the caller chooses, T is the same type everywhere within the function.

default (works)

    fn default(&self, key: &String) {
        for model_setting_type in &self.settings {
            if let ModelSettingType::Int(model_setting) = model_setting_type {
                // (A)              vvvvv^^^^^^^^^^^^^
                return_default(key, model_setting);
            }
            if let ModelSettingType::Str(model_setting) = model_setting_type {
                // (B)              vvvvv^^^^^^^^^^^^^
                return_default(key, model_setting);
            }
        }
    }

In this portion, model_setting has a different type at (A) and (B), because you're matching different enum variants. The two model_settings are two different variables, so it's fine that they have two different types. At (A) it's i16 and at (B) it's String. You call return_default::<i16> and return_default::<String> accordingly. Every expression still has one concrete type, as required by a strongly and strictly typed language.

Playground 1

return_default (works)

        fn return_default<T>(key: &String, model_setting: &ModelSetting<T>) -> Option<T>
        where
            T: std::fmt::Debug + std::fmt::Display + std::clone::Clone,
        {
            let mut result = None;
            if key == &model_setting.key {
                result = Some(model_setting.default.clone());
                //            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
            }
            result
        }

As before, the highlighted portion is T. That means result is an Option<T>. That matches the return type, and you don't try to assign it some other type, so there is no error.

This is a good example of how generics work: in the function body, T is a single type that meets the bounds; the function body has to work for every such T, and this body does so. It makes use of the Clone bound and that's it. It doesn't try to force T to be one specific type, or more than one type.

default (errors)

    fn default<T>(&mut self, key: &String) -> Option<T>
    where
        T: std::fmt::Debug + std::fmt::Display + std::clone::Clone,
    {
        let mut result = None;
        for model_setting_type in &self.settings {
            if let ModelSettingType::Int(model_setting) = model_setting_type {
                // (A)                   ^^^^vvvvvvvvvvvvv
                result = return_default(key, model_setting);
                //       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                //       Option<i16>
            }
            if let ModelSettingType::Str(model_setting) = model_setting_type {
                // (B)                   ^^^^vvvvvvvvvvvvv
                result = return_default(key, model_setting);
                //       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                //       Option<String>
            }
        }
        result
    }

Here, the model_settings have different types just as before, and you're calling return_default::<i16> at (A) and return_default::<String> at (B). In Playground 2 those both returned (), but now they return Option<i16> and Option<String> respectively, as labeled. Those are two different types -- but you're trying to assign them both to the same variable, result.

That's the first problem -- what's the type of result? It can't be both Option<i16> and Option<String>; Rust is strictly and statically typed.

We can look at it a different way, through the lens of the error message.
error[E0308]: mismatched types
  --> src/main.rs:97:46
   |
97 |                 result = return_default(key, model_setting);
   |                          --------------      ^^^^^^^^^^^^^ expected `&ModelSetting<i16>`, found `&ModelSetting<String>`
   |                          |
   |                          arguments to this function are incorrect
   |
   = note: expected reference `&ModelSetting<i16>`
              found reference `&ModelSetting<String>`

At (A) the compile decided result must be an Option<i16>, so at (B) where you have another assignment, it decided you must need to call return_default::<i16> again so that it would return an Option<i16> so that the assignment would be valid. But you passed in a &ModelSetting<String>. It highlighted that as the error.

I find my walkthrough more intuitive, but both are ultimately just ways of showing that there's a type conflict in the code.

If we delete the if let that contains (B), we eliminate that problem:

    fn default<T>(&mut self, key: &String) -> Option<T>
    where
        T: std::fmt::Debug + std::fmt::Display + std::clone::Clone,
    {
        let mut result = None;
        for model_setting_type in &self.settings {
                // (A)                   ^^^^vvvvvvvvvvvvv
                result = return_default(key, model_setting);
                //       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                //       Option<i16>
        }
        result
    }

But now we're trying to return an Option<i16> when we said we would return an Option<T>:

error[E0308]: mismatched types
  --> src/main.rs:97:9
   |
77 |     fn default<T>(&mut self, key: &String) -> Option<T>
   |                - expected this type parameter --------- expected `Option<T>` because of return type
...
94 |                 result = return_default(key, model_setting);
   |                 ------ here the type of `result` is inferred to be `Option<i16>`
...
97 |         result
   |         ^^^^^^ expected `Option<T>`, found `Option<i16>`
   |
   = note: expected enum `Option<T>`
              found enum `Option<i16>`

Compare and contrast this with return_default, which works, keeping these things in mind:

  • The caller of the function decides what T is (within the stated bounds)
    • That's why default can call return_default::<i16> or return_default::<String>
  • The function body has to work for every valid choice of T
  • T has to be the same concrete type everywhere in the function body
  • The T on fn default and the T on fn return_default are independent (you just happened to give them the same name). Using U instead of T with return_default doesn't change anything. This is similar to how two functions can have arguments named foo that aren't forced to be the same type, value, etc.
4 Likes

Thank you for the super-detailed answer. It's slowly settling in how generic return types work.

Prior to the if-let, I was just doing a match but coming up with the same problem (match arms must be of same type).

I will continue working on this and post my solution when done.

I did not think to put the getters on the parts of the struct I was trying to access. That is very appropriate (and sane). Thank you so much for providing an example.

@quinedot @drewtato

Rust Playground 3:

This is as far as I got at the moment (separate implementations). I'm still trying different ways to combine them, but if I'm not successful, I'll have to settle for this approach.

Thanks again for the help.

    fn default_str(&self, key: &str) -> Option<String> {
        for model_setting_type in &self.settings {
            if let ModelSettingType::Str(model_setting) = model_setting_type {
                if model_setting.key == key {
                    return Some(model_setting.default.clone());
                }
            }
        }
        None
    }

    fn default_int(&self, key: &str) -> Option<i16> {
        for model_setting_type in &self.settings {
            if let ModelSettingType::Int(model_setting) = model_setting_type {
                if model_setting.key == key {
                    return Some(model_setting.default.clone());
                }
            }
        }
        None
    }

@quinedot @drewtato

Rust Playground 4:

Circular dependency with this one, but it illustrates what I'm trying for:

    fn default<T, U>(&self, key: &str) -> Option<_> {
        for model_setting_type in &self.settings {
            match model_setting_type {
                ModelSettingType::Int(model_setting) => {
                    if model_setting.key == key {
                        return Some(model_setting.default.clone());
                    }
                }
                ModelSettingType::Str(model_setting) => {
                    if model_setting.key == key {
                        return Some(model_setting.default.clone());
                    }
                }
            }
        }
        None
    }

Edit: It does work of course if I only specify one type:

    fn default<T, U>(&self, key: &str) -> Option<i16> {
        for model_setting_type in &self.settings {
            match model_setting_type {
                ModelSettingType::Int(model_setting) => {
                    if model_setting.key == key {
                        return Some(model_setting.default.clone());
                    }
                },
                ModelSettingType::Str(_) => {}
            }
        }
        None
    }

Here’s one way to do this; I unfortunately don’t have time right now to explain how it works…

trait ModelValue: Clone + Debug {
    fn get_settings(settings: &ModelSettingType)->Option<&ModelSetting<Self>>;
}

impl ModelValue for i16 {
    fn get_settings(settings: &ModelSettingType)->Option<&ModelSetting<Self>> {
        match settings {
            ModelSettingType::Int(inner) => Some(inner),
            _ => None
        }
    }
}

impl ModelValue for String {
    fn get_settings(settings: &ModelSettingType)->Option<&ModelSetting<Self>> {
        match settings {
            ModelSettingType::Str(inner) => Some(inner),
            _ => None
        }
    }
}


// …

    fn default<T:ModelValue>(&self, key: &str) -> Option<T> {
        for model_setting_type in &self.settings {
            if let Some(model_setting) = T::get_settings(model_setting_type) {
                if model_setting.key == key {
                    return Some(model_setting.default.clone());
                }
            }
        }
        None
    }
1 Like

This is a great alternative, using traits. Thank you.

I did consider going the trait route but it seemed like I would be implementing the same 2 functions as Rust Playground 3:, just in a different place. It is a totally legit approach though. Thank you for adding it to the thread. This helps.

At some point, you need a bound on the generic parameter T that provides a way to construct the Option<T> you want to return, and that takes as input something that you can produce from any ModelSettingType instance. To the best of my knowledge, that means using either some kind of custom trait or std::any::Any

(Note that this can be a fairly indirect method— I did my best to minimize the duplicated code by producing Option<&ModelSettings<T>> so that the search portion of the code only has to be written once.)

That's important to me. Thanks for pointing that out. I'm hoping to create a few of these getters to deal with my sort_userdata function linked in the first post. It may not make a difference performance-wise whether the getter is inline (like it is now) or abstracted away in a function - but to me, visually, I would prefer the values I'm comparing not sit 14 levels deep.

    // returns `i16` or `String`
    match model.settings.default::<i16, String>(&String::from("power")) {

It's simply not possible to support multiple types in that position. By the point you're notionally executing that method, all generics have been resolved to concrete types. So that's a violation of "Rust is strictly and statically typed"; you're trying to create an expression with more than one type.

If you want to support both possibilities for the "power" key inline like that, you'll have to call some method twice, and you'll have different control flow for the different types.

[1]

Here's the Any approach:


  1. Side note: Don't use &String::from("...") to create a &str. "..." is already a &str. ↩︎

1 Like

No problem. I think this section of the book is covering what is happening here:
The Rust Programming Language - Advanced Traits.

For me, the code you provided makes sense to me if I work backwards:

  • I need a (generic) value (I do not care about the type of the instance it's coming from at the moment).
  • The function I call should return something generic.
  • For generic functions to work, all instances of the generic type, within the function, should match.
  • Which means I cannot match on ModelSettingType > ModelSetting > Value (which returns i16/String), I need to match on something generic (like T).
  • Using:
    • if let Some(model_setting) = T::get_settings(model_setting_type)
    • instead of
    • if let ModelSettingType::Int(model_setting) = model_setting_type
    • will get me the generic T I need when model_setting returns it's value.
  • The way to set up T::get_settings() is through traits which are implemented separately for each return type I need.
1 Like

I'm super impressed that you knew how to put that together.

I'm looking at doc.rust-lang.org - Trait std::any::Any and what you did is not trivial (to me).

I'll need some time to digest that. Thank you++ for mocking that up.

1 Like

That's because in addition to the Any interface, you would need to become familiar with &dyn Trait and how the two interact in order to come up with that code. It's pretty clever, but certainly unintuitive!

3 Likes

I tried the traits method out on some actual code (just one example):

-    // hardcode index[4] (mode) instead of looping
-    if let ModelSettingType::Str(model_setting_mode) = &model.settings[4] {
-        if let Some(model_setting_mode_current) = &model_setting_mode.current {
-            body_element.set_class_name(&model_setting_mode_current);
-        }
-    };

+    let mode = model.settings_current::<String>("mode").expect("Some: `String`");
+    body_element.set_class_name(&mode);

I like this syntax way better.

A cool side-effect of this example is that it shows a trait being applied to a primitive type (i8, String).

In my actual code, there is no ModelSettings struct. model.settings is actually Vec - because it mirrors the user.settings field, which is also a Vec - which mirrors the structure of the JSON object - which is used for serialization and deserialization methods.

I thought only structs could be implemented with methods, so I had all my settings methods in the model struct with a settings_ prefix on each name. Now that I know you can add a method to a primitive, I tried moving default(), aka current() from the Struct in the model to the Vec in settings.

And it worked!

I can now call model.settings.current() instead of model.settings_current() and still keep model.settings as a vector which serializes and deserializes correctly. This is good because now I can move all my settings methods out of the model module and into the settings module. Another win!

1 Like