Lifetime rules for functions without arguments

Hello,

I wonder why a function without arguments cannot automatically deduce the lifetime of its return type.

Example:

use std::borrow::Cow;

#[derive(Debug)]
pub struct Foo<'a> {
    pub content: Cow<'a, str>,
}

fn foo() -> Foo {
    Foo { content : Cow::default() }
}

fn main() {
    println!("{:?}", foo());
}

The compiler complains that Foo in the return type of foo is missing a lifetime specifier.

Why does the return type need an explicit lifetime specifier when there is no input argument from which it could borrow? What could be an example where automatic deduction would fail or give the wrong result?

Thanks

3 Likes

Rust is statically typed, and a non-generic function item can only return one concrete return type -- including the lifetime. So in the non-generic case, only one lifetime is possible -- deduction is not an option. If there was to be a default, it would need to be some nameable lifetime.

In the global context, like in fn foo(), the only nameable lifetime is 'static. That could be a default, but in the larger context of lifetime elision, it would often be wrong.

In this particular case, 'static is probably a sufficient choice -- it could coerce to a shorter lifetime if needed. But with other types, it can be problematic.

If you want to be able to rely on inference instead, you can make the function generic over the lifetime.


Incidentally, the ability to completely elide a lifetime parameter is considered a mistake. There's a lint you can use to deny the complete elision, which ends up offering suggestions on what you may have meant. And you can use '_ where elision would have otherwise applied.

4 Likes

Two follow up question:

  1. Which rule allows the compiler to assume that in the example below foo() is in fact foo::<'static'>()? Why is an explicit template argument / lifetime not necessary here?
fn foo<'a>() -> &'a str { "yo" }
fn main() { println!("{}", foo()); }
  1. In the first lifetime elision rule it says "a function with one parameter gets one lifetime parameter". Could this not be changed to "a function with zero or one parameter gets one lifetime parameter"?

Just as here

fn foo() -> &'static str { "hi" }
fn main() {
    s = foo();
}

the returned &'static str can be coerced to an arbitrarily shorter lifetime (due to covariance), here

fn foo<'a>() -> &'a str { "hi" }

The literal "hi" is const promoted to a &'static str which can then be coerced to an arbitrarily shorter lifetime.

It probably wasn't technically inferred to be 'static, it was probably inferred to be a lifetime that ended at the end of the statement. But a higher-level view is that it doesn't matter what it was inferred to be exactly -- it was inferred to be a lifetime that upheld soundness (and then that lifetime was erased by runtime).

The example does illustrate again that returning &'static is adequate for covariant types. It does mean that foo::<'non_static_lifetime> isn't a function item type. Though that's a pretty niche consideration.


Output lifetime can't be late bound like that in the current compiler, though that may change in the future.

(Late-bound is a mostly internal concept, but the short gist of it is that if the lifetime isn't generic on the type of the function, then you can't

impl<'a> Fn() -> &'a str for TheFunction { /* ... */ }

// unsugared, nightly
impl<'a> Fn(()) for TheFunction {
    type Output = &'a str;
    // ...
}

similar to how you can't, say,

struct Iter;
impl<'a> Iterator for Iter {
    type Item = &'a str;
    fn next() -> Option<Self::Item> {
        None
    }
}

)


It could be an early bound yet anonymous lifetime I suppose; I think argument-position impl Trait works that way for types now.

Whether there's enough motivation to do so is another question. Returning a lifetime out of nowhere just isn't that common of a legitimate pattern.

2 Likes

To level-set, per the nomicon

Given a function, any output lifetimes that don't derive from inputs are unbounded. For instance:

fn get_str<'a>() -> &'a str;

will produce an &str with an unbounded lifetime.

Unbounded lifetimes are unsafe because the lifetime will expand to whatever is required by the context - “hit and miss” type of behavior (miss ~> UB).

Double clicking on this though, there is a sure way to avoid UB in this circumstance: find evidence of a lifetime that avoids the problem. There is only one of those: 'static (“always long enough”). If the compiler can safely make the determination the value being referenced has a static lifetime, “you got away with it” because the compiler “did you a solid” by letting you use the unbounded lifetime (lets your code compile, the only thing that matters because “all is forgotten” in the executable).

This would explain the examples of how it appeared to circumvent this phenomena when in fact, it was always in-play - you are working with an unbounded lifetime, that is unsafe.

…

A function with 'a in the return, given the covariant nature of lifetimes, will accept 'static in place of 'a

1 Like

I don't believe this observation is correct. As far as I know, in the call to foo(), the precise lifetime argument to foo is not 'static but simply under-specified. The compiler doesn't know the precise lifetime, it just knows its “some lifetime”, and in this case this lifetime is involved in non further bounds, so it's completely unrestricted. Since “some lifetime” exists, the program type-checks; unlike for type parameters which need to be nailed down as their choice can affect program behavior, lifetimes get erased, which allows the compiler to just keep the thing underspecified, as long as it can determine that there aren't any contradictory requirements in bounds involving this lifetime that cannot be fulfilled at all.

2 Likes

In that code snippet, my read is that the lifetime is unbounded. Is that what you mean by “under specified”?

Unbounded would mean "no bounds" which is certainly true as well in this particular case. Under-specified is supposed to mean that they may be bounds but there's nothing nailing it down to one specific lifetime in particular.

Here's an example a a type parameter not being unbounded (there is a T: Display bound) but under-specified at a call site, which - unlike lifetime arguments - results in a compilation error requesting you to specify the type.

use core::fmt::Display;
fn foo<T: Display>() {}

fn main() {
    foo()
}

(Playground)

Errors:

   Compiling playground v0.0.1 (/playground)
error[E0282]: type annotations needed
 --> src/main.rs:5:5
  |
5 |     foo()
  |     ^^^ cannot infer type of the type parameter `T` declared on the function `foo`
  |
help: consider specifying the generic argument
  |
5 |     foo::<T>()
  |        +++++

For more information about this error, try `rustc --explain E0282`.
error: could not compile `playground` due to previous error

2 Likes

Perfect. Thank you.

When asked to “disambiguate”, it’s either because the caller is not being specific enough (a call to collect() is a seminal example), or that the specification was made “too generic”… under-specified?

When designing functions don’t we want to make them as generic as possible/reasonable? That would mean that “under-specified” does not carry with it a negative connotation, but just describes what it is. Yes?

Still wondering why this is not allowed to work out of the box:

fn hello() -> &str { "hey" }
fn main() { println!("{}", hello()); }

The compiler could just use lifetime elision rules to say "I see no inputs, but let me create a lifetime for all those outputs" and then use the rules you mentioned above to call "hello()" without the need of additional lifetime annotations.

This feels like less of a stretch for lifetime elision rules than this example:

fn hello(x: &str) -> &str { "hey" }
fn main() { println!("{}", hello("hi")); }

.. which compiles fine, but the automatic lifetime annotations make no sense because the return value of hello does not depend on the lifetime of its input. "make no sense" of course not in the sense of undefined behavior, just an unnecessary induction dependency.

1 Like

This would really only work to return references created with unsafe (which would be an incredibly dangerous interaction) or with a 'static lifetime

One thing to keep in mind is that the body of a function does not change its declared API [1], and that's a deliberate choice. The API is the contract -- both for the function writer, and the consumer. So here

fn hello(x: &str) -> &str {
    "hey" 
}

not overriding the elision rules to make this return 'static instead makes perfect sense. If it returned 'static, it would be leaking implementation details, consumers would have to read the function body to know what's going on, and it would make this a breaking change:

 fn hello(x: &str) -> &str {
+    if x.len() <= 3 {
+        x
+    } else {
         "hey" 
+    }
 }

Inferring 'static from the body would in a sense negate your reservation of the "right" [2] to return something derived from x that you declared via the API.


  1. with some exceptions in particular cases I'll not go into here ↩︎

  2. ability to make such a change without breakage ↩︎

2 Likes

It makes perfect sense if you don't want to declare strings "1e3" and "1000" equal (like PHP does).

Basically the whole discussion looks centered around the question “why can't Rust do what I meant, but only does what I wrote”.

And the answer is simple: when you start going that way it's fairly hard to stop. You add one rule which makes some sense in one narrow case, then another rule which makes some sense in another narrow case and pretty soon you end up with comparison which is not transitive and equality which is not reflexive and suddenly your language starts behaving quite erratically and unpredictable.

This example assumes that implementation of function may affect it's signature.

This a big no-no in Rust and for good reason.

Even C++ (which is half-way between PHP and Rust) doesn't allow that. You either have to specify that function content would fully determine what happens with auto or you fully specify the type of result, you never mix them.

You really think breaking fundamental promises like that just to save few keystrokes in a trivial programs is worth it? What should happen tomorrow when hello would be changed and it's return value would start depending on x?

The API reminder was helpful. And a good decision from a design perspective.

That sounds a little bit infantile and it was not the question.. I am interested in: Why did the developers choose to not allow lifetime elision in case A, but did allow it in case B. What would be an example where "inventing" a lifetime for a function without arguments would go bad?

Can't follow that. There are two possibilities with this simple example: either the lifetime of the return type depend on its argument, or it does not. Both are equally logical and useful. From how I see it at the moment the specification just arbitrarily decided to favor one over the other.

Because it's the unusual case. The vast majority of time, someone who writes fn foo() -> &str is actually better off having it complain about the elision, because they didn't actually want that -- they wanted fn foo() -> String instead. It's perfectly reasonable to ask people to write -> &'static str if they really want to restrict themselves to only string literals.

Said otherwise, I'd much rather that the conversation on the community discord be "Why can't I use -> &str here? You want -> String." instead of "How to I format something as a &'static str? Why do you want a &'static str? The compiler says I need one here."

Though the error message could definitely be improved for it, which I previously filed as Suggest returning an owned type instead of a reference in E0106 · Issue #76007 · rust-lang/rust · GitHub -- which, looking at the bug, I opened after a thread just like this one, so you could also read Why not automatic inference of 'static in foo() -> &str? - #8 by scottmcm

7 Likes

Does the design decision to use the signature as the API without considering the body not the dominant driver here?

@scottmcm Thank you for that reply. &str was actually just a simplification to reduce to a minimum reproduction of my issue. Allow me to elaborate. In my actual case the function looked like fn foo() -> Span and the reference was hidden inside the Span class of some library I am using. Which was quite confusing because a similar looking fn foo() -> Style worked just fine because Style did not happen to store a reference. I guess that just reinforces your argument to require explicit lifetime annotation and to force me to realize that Span holds a reference. (Although it is quite irrelevant for me as that reference is some kind of shared pointer and an implementation detail). This case just got me wondering why I don't have to worry about that in my other innocent perfectly fine function fn foo(&self) -> Span where the compiler does not complain, but Span was created exactly in the same way as in the free function. I understand now that in this case the compiler assumes that the lifetime of the returned Span depends on self. Which of course is "overzealous" as the implementation is exactly the same as the free function (in both cases just a call to Span::new() with some non-reference arguments).

I am trying to understand that "imbalance" in the elision rules: Why allow elision for fn foo(&X) -> &Y and choose fn foo<'a>(&'a X) -> &'a Y over fn foo<a'>(&X) -> &'a Y but not allow it for fn foo() -> &Y where really the only option is fn foo<'a>() -> &'a Y (or fn foo() -> &'static Y which if I understand correctly is pretty much equivalent).

Well if there's no input lifetime, the only output lifetime that makes any sense is 'static. (Or a generic 'a, which is essentially equivalent.)

It's certainly true that it shouldn't look at what the body does. But there could be an elision rule of "if there's no input lifetime then the return type assumes 'static", like happens in const and statics where any lifetimes are always 'static. That would work without looking at the body -- it'd then error if the body doesn't like the post-elision lifetimes, like all the other elision rules. (That's why it's called elision not inference, BTW.)

You might be interested in Tracking Issue for enabling `elided_lifetimes_in_paths` lints · Issue #91639 · rust-lang/rust · GitHub -- the plan for this is to always require lifetimes be written out in return types, because this confusion is so common. That way it'd be -> Span<'_> vs -> Style, which communicates exactly the difference you mention.

5 Likes