Instead of lifetimes, why not use input-output dependency?

I am a beginner in Rust. So, I have a doubt.

Instead of using lifetimes, why not the functions denote which outputs depend on which inputs.

Currently, this is how Rust works.

fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

Instead of the above, what about the following.

fn longest(s1: &str, s2: &str) -> &str 
return depends_on s1 & s2
{
    if s1.len() > s2.len() {
        s1
    } else {
        s2
    }
}

This need not be the exact syntax. But what about something similar to this instead of lifetimes.

If there are multiple return values, then give names to each return value in the signature, but it does not need to create a variable like in Go-lang.

Example:

fn shortest_and_longest(s1: &str, s2: &str) -> (shortest: &str, longest: &str)
shortest depends_on s1 & s2,
longest depends_on s1 & s2,
{
    ...
}

Is there any technical reasons for not using this syntax. Am I missing something?

I would be extremely glad if someone can explain the technical details behind it.

That's just lifetimes, except you tie them to parameters instead of to types, and make them less expressive by the way, as you cannot model lifetimes that are not tied to parameters this way.

9 Likes

I understand that it is just another way to denote lifetimes. But for most cases, isn't this enough? Are there any common cases where this syntax does not work?

Lifetimes are essentially denoting exactly this right now. Why do you think explicit depends_on syntax is better?

5 Likes

What if you're returning a struct containing a reference (or multiple references), a generic type containing a reference (e.g. Vec<&'a str>), you need to refer to a lifetime in a trait (e.g. MyTrait<'a>) etc etc? You need something more flexible than just an ad-hoc relation between inputs and outputs.

This doesn't mean that lifetimes must be the only way to make this work, there have been ideas of more directly relating borrows to places instead, see e.g. Borrow checking without lifetimes · baby steps

3 Likes

I mean, maybe? I don't think, but even if true, why would you prefer the less expressive method?

I think connecting output to inputs is much more easy for most programmers because this is what we already do when programming a function; making a new output from a bunch of inputs.

How would you write this?

fn insert<'long, 'short>(
    target: &'short mut &'long str,
    value: &'long str
) -> &'long str {
    let old_value = *target;
    *target = value;
    old_value
}
1 Like

For the above program, I changed it like below. This is just a rough syntax.

fn insert(
    target: &mut &str,
    value: &str
) -> &str
return depends_on *target:old
target:new depends_on value:old
{
    let old_value = *target;
    *target = value;
    old_value
}

I understand that my annotation is basically same as the assignments in the function body. But I think this is much easier to understand. And this similarity is very interesting in a language design perspective.

This is exactly my point. That example code you provided looks very complicated. It took me some time to understand what those lifetimes mixed with type signature is doing. More complex functions will only make it worse.

I understand that lifetimes allows expressive annotations. But do we need these complex annotations most of the time.

We often say that many nested indentation is bad code and we need to simplify things. There are also other examples in programming such as long complicated functions, expressions, etc.

FYI you can currently write the following, which is not that far from what you're proposing:

fn insert<'target_inner, 'value, 'returned>(
    target: &mut &'target_inner str,
    value: &'value str
) -> &'returned str
where
  'value: 'target_inner,
  'target_inner: 'returned
 {
    let old_value = *target;
    *target = value;
    old_value
}
3 Likes

Here, there are 3 new identifiers and they are interspersed with type signatures. I think it makes it harder to understand, especially for a non-trivial function other than this.

Has anyone looked into this kind of design I proposed?

You earlier mentioned:

Borrow checking without lifetimes · baby steps

And that article is even more complex for my simple brain.

  • At first, when I was learning Lifetimes, I didn't understand anything.
  • Then it finally clicked.
  • It only lasted until I tried to write a non-trivial function.
  • After some time, it again clicked.
  • This repeated like a loop whenever I try to write some new kind of function or struct.
  • Everytime I think, "This is it. I finally understood Lifetimes."
  • That feeling only lasts until I see or write another complex code.

I am trying to find if there is an easier solution to this.

Well the thing that jumps out to me most is that there can be multiple different lifetimes stored in a single type, and thus in a single parameter, so you'd always at least need enough to distinguish that -- perhaps by naming them separately, which would bring you right back to basically the same thing again.

More likely, I think, would be a shorthand for "well there's only one lifetime in that type" so you could do something like

fn find(needle: &str, haystack: &str) -> &'haystack str

But that reintroduces some of the problems of in-band lifetimes, so I'm not sure it's necessarily great either.

2 Likes

Do we really need to denote all the lifetimes of a type in a function signature?

Instead, why not describe the dependency of values directly without the use of lifetimes, and let the compiler take care of the rest?

Example:

struct Pair<'a, 'b> {
    first: &'a str,
    second: &'b str,
}

fn make_pair<'a, 'b>(x: &'a str, y: &'b str) -> Pair<'a, 'b> {
    Pair { first: x, second: y }
}

Instead of the above, what about this:

struct Pair<&> {
    first: &str,
    second: &str,
}

fn make_pair(x: &str, y: &str) -> Pair<&> 
return depends_on x & y
{
    Pair { first: x, second: y }
}

Pair<&> denotes that Pair has references.

This has the same problem as saying “all structs with lifetime parameters shall have exactly one lifetime parameter”: it means that it’s no longer possible for the struct to distinguish long-lived references from short-lived ones — for example, if I call make_pair() on one &'static str and one &'a str, it forgets which one is 'static. This can be a meaningful limitation when working with shared references, but it's fatal for structs containing mutable references to partially-borrowed data. Imagine some data structure like

struct StackRef<'stack, 'data> {
    s: &'stack mut Vec<&'data str>,
}

If you write this with one lifetime, it will become impossible to use.

There are plenty of structs that can work with a single lifetime parameter, but not all of them.

6 Likes

What I meant is that structs can have multiple lifetimes, but all of them are taken care by the compiler. Programmers only need to describe the dependencies between values.

It seems like you're really focused on improving the ergonomics of functions that need to be annotated with lifetimes. The problem is, that's only just one facet of what lifetimes are for, and it's probably both the least important facet, and the easiest to understand. In @kpreid's example

struct StackRef<'stack, 'data> {
   s: &'stack mut Vec<&'data str>,
}

the 'stack and 'data lifetimes are there to express restrictions on how a value of this type can be used (most importantly, how long it can exist, hence "lifetime"). The exact consequences of the lifetimes in this struct definition are subtle enough that I'm not sure I fully understand them myself.

A proposal for redoing lifetime annotations really needs to grapple with all uses of lifetime annotations—so: what's your replacement for this struct? This struct specifically, not the functions that use it.

7 Likes

This is more complex than what you think. Let's take the Pair example, what the programmer describes is this:

... -> Pair<&>
return depends_on x & y

What are the dependencies the programmer actually described? Which lifetime of Pair should depend on x and which on y? Do they both depend on both? Or does each one depend on a different one? Does Pair even have two lifetimes here or just one? Whatever the answer is, how do we write the other cases?

And note that we don't want to have the compiler infer this based on the function body, because this has both technical issues (it would create cyclic dependencies for borrow checking, and what do we do with types?) and social issues (it would make breaking changes much more subtle).

5 Likes

Because having a signature that actually tells you how the value can be used is important. You don't want the compiler determining that from the body -- especially if that body is currently todo!() so you're allowed to do anything then all the callers break when you actually implement it.

2 Likes