Simple question about generics

I have a simple question about generic types, because it seems there's something I don't quite understand about them. Why doesn't something like this work:

fn test_func<T>() -> T {
    5
}

My understanding is that generics can represent any type, so this function should be able to return anything, since there's no restrictions on the generic T, but I get a mismatched types error.

Furthermore, if I applied restrictions on T, the error persists:

fn test_func<T: Copy>() -> T {
    5
}

again, shouldn't this function be able to return any type that implements the Copy trait, including u32?

Perhaps the following example will help. Suppose we call test_func as:

let k = test_func::<String>();
// Now test_func must return a String
// But it returns 2!

The point being that, for any type T, test_func must return a value of type T. But instead, it returns some integral type. Hence the error.

4 Likes

The caller to function picks the type.

It is impossible in Rust for the function body to pick the type and somehow have the function signature know what the return type is. Rust has a way around this though;
https://doc.rust-lang.org/book/ch10-02-traits.htmll#returning-types-that-implement-traits

2 Likes

Generics which are in the <> are input parameters to the function -- callers of the function choose the concrete types (and lifetimes and const values), and the function must support any type that meets the bounds.

You can write a function that returns any (Sized) type, so long as it matches what the caller chooses.

fn test_func_2<T>(t: T) -> T {
    t
}

fn test_func_3<T: Default>() -> T {
    T::default()
}

fn test_func_4<T: Clone>(rt: &T) -> T {
    rt.clone()
}
1 Like

As explained, the type T is chosen by the caller in your example. If you want the type to be chosen by the callee instead, then you can use what's called an abstract return type, also known as "impl Trait in return position":

fn test_func() -> impl std::fmt::Display {
    5
}

fn main() {
    let some_value = test_func();
    println!("some_value = {}", some_value);
}

(Playground)


Note, however, that there are some caveats. For example, the following code will fail to compile because _some_value can't be an integer and a float at the same time:

fn test_func() -> impl std::fmt::Display { 
    5 
} 
 
fn other_test_func() -> impl std::fmt::Display { 
    5.0 
} 
 
fn main() { 
    let mut _x = test_func(); 
    _x = other_test_func(); 
}

(Playground)

To work around that, you'd need to use trait objects with dyn:

fn test_func() -> Box<dyn std::fmt::Display> { 
    Box::new(5)
} 
 
fn other_test_func() -> Box<dyn std::fmt::Display> { 
    Box::new(5.2)
} 
 
fn main() { 
    let mut _x = test_func(); 
    _x = other_test_func();
    println!("_x = {}", _x);
}

(Playground)

Thus the three approaches are very different:

  • generic implementations
  • using "impl Trait" in return position
  • using boxed dyn objects
3 Likes

A fourth way is to

  • return a concrete type like i32

Let's tease out a bit more detail with regards to your return position impl Trait example. Such a return is a type alias to some underlying, concrete type, inferred from your function. This won't work for example, because the types don't match:

fn f(answer: bool) -> impl Display {
    match answer {
       false => 42,
       true  => 3.14,
    }
}

This probably won't be surprising to anyone who read your examples. However, the types are also opaque. What does this mean? Opaque return position impl Trait types that come from different functions won't be considered the same type even if they have the same underlying types.

So this produces the same error:

fn test_func() -> impl std::fmt::Display { 
    5 
} 
 
fn other_test_func() -> impl std::fmt::Display { 
    5
} 
 
fn main() { 
    let mut _x = test_func(); 
    _x = other_test_func(); 
}
2 Likes

I wasn't aware of that, but it makes sense, because one of the function might get a different implementation in the future, and that should not cause any code to break that was previously working.

Afterall the primary use case of impl with a trait in the return position is to hide the actual type from the caller and to let the caller not worry about what's really happening inside the function as long as the returned type implements some trait(s).

That's indeed worth mentioning, as for example many functions that could return an "impl trait" instead return a concrete type, such as Vec::drain, for example, which returns a std::vec::Drain (instead of impl Iterator<…>).

To summarize:

  • Generic implementations:
    The caller chooses the type.
  • "impl Trait" in return position:
    The callee's implementation determines the type (done by the compiler at compile-time), and that will be exactly one type. This type is opaque such that even if it happens to be identical by accident to another type, it will still be treated as different. (Hope I phrased that right.)
  • Boxed dyn objects:
    The inner type is dynamic and can differ at runtime. There is an extra overhead at runtime, but there are less constraints (and I think the compiled code may be smaller than when using generic implementations).
  • Using concrete types:
    This lets the caller know which types it is actually dealing with.

Yes, that flexibility is intentional. Another primary use case is to return unnameable types like closures (or iterator combinators containing a closure, etc.)

Some day we will have TAIT (type alias impl Trait) aka existential types, which will allow unifying some opaque types. (But not closures.)

1 Like

And the difference is that the TAITs are not opaque then? That's also good to know. I always thought it's just a syntax simplification.

One way to think of return position impl Trait is that it forces the caller to be generic over its return type.

When a function has a type parameter, its caller can choose a type, and the function has to accept it even though the actual type is opaque (that is, the function must compile for any T that meets its explicit bounds).

When a function returns an impl Trait, the function itself can choose a type, and it's the caller that has to accept it even though the actual type is opaque (that is, the caller must compile for any T that implements Trait).

The symmetry is obvious when you write it out like this, but we usually think of generics as being kind of "top down" so the idea of a function making its caller generic is unintuitive.

They're opaque -- return position impl Trait uses them under the hood. But once us peons can create aliases too, we can use the alias in multiple places.

#![feature(type_alias_impl_trait)]

type MyDisplayable = impl std::fmt::Display;

fn test_func() -> MyDisplayable { 
    5 
} 
 
fn other_test_func() -> MyDisplayable { 
    5
} 

GATs are very related, so it's hopefully coming soon.

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.