Issue expressing lifetimes with HRTB

I am implementing a routing abstraction and having trouble expressing HRTB, lifetimes, and generics. This is my first time using HRTB, so I am probably missing something.

The Router trait

The Router trait is defined as follows:

pub trait Router<R> {
    fn route(&self, request: &R) -> Option<String>;
}

A leaf router works with a specific request type (MyRequest<'request>) and is defined as follows:

pub struct SubRouter {}

impl<'request> Router<MyRequest<'request>> for SubRouter {
    fn route(&self, _request: &MyRequest<'request>) -> Option<String> {
        None
    }
}

pub struct MyRequest<'a> {
    method: &'a str,
}

I have a general-purpose router that works with any "request" implementation. For example:

pub struct LoggingRouter<R> {
    underlying: Box<dyn Router<R>>,
}

impl<R> Router<R> for LoggingRouter<R> {
    fn route(&self, _request: &R) -> Option<String> {
        println!("start");
        // ...self.underlying.route(..)
        None
    }
}

Finally, I have a top-level SystemRouter that works with an underlying router of a specific request type. However, I run into issues when I try to implement it.

Attempt 1: Lifetime on SystemRouter

Playground

The SystemRouter needs the underlying router to work with a specific request type (which has a lifetime). So, I defined it as follows:

pub struct SystemRouter<'request> {
    underlying: Box<dyn Router<MyRequest<'request>>>,
}

impl<'request> Router<String> for SystemRouter<'request> {
    fn route(&self, request: &String) -> Option<String> {
        self.underlying.route(&MyRequest {
            method: request.as_str(),
        })
    }
}

This produces the following error:

error: lifetime may not live long enough
  --> src/main.rs:52:9
   |
50 |   impl<'request> Router<String> for SystemRouter<'request> {
   |        -------- lifetime `'request` defined here
51 |       fn route(&self, request: &String) -> Option<String> {
   |                                - let's call the lifetime of this reference `'1`
52 | /         self.underlying.route(&MyRequest {
53 | |             method: request.as_str(),
54 | |         })
   | |__________^ argument requires that `'1` must outlive `'request`

I think putting a lifetime on SystemRouter is a mistake (none of the routers have references of any lifetime).

Attempt 2: HTRB

Playground

To avoid putting any lifetime on SystemRouter, the second attempt uses HTRB.

pub struct SystemRouter {
    underlying: Box<dyn for<'request> Router<MyRequest<'request>>>,
}

impl<'request> Router<String> for SystemRouter {
    fn route(&self, request: &String) -> Option<String> {
        self.underlying.route(&MyRequest {
            method: request.as_str(),
        })
    }
}

This fails with the following error:

error: implementation of `Router` is not general enough
 --> src/main.rs:6:21
  |
6 |         underlying: Box::new(logging_router),
  |                     ^^^^^^^^^^^^^^^^^^^^^^^^ implementation of `Router` is not general enough
  |
  = note: `LoggingRouter<MyRequest<'2>>` must implement `Router<MyRequest<'1>>`, for any lifetime `'1`...
  = note: ...but it actually implements `Router<MyRequest<'2>>`, for some specific lifetime `'2`

The main function

In either case, this is how I am trying to compose them:

fn main() {
    let logging_router = LoggingRouter {
        underlying: Box::new(SubRouter {}),
    };
    let system_router = SystemRouter::new(Box::new(logging_router));

    let result = system_router.route(&());
    println!("{:?}", result);
}

I'd appreciate any pointers on how to fix this or an alternative design that allows me to keep the LoggingRouter general (in the real system, I have several such routers).

Do you have a specific reason for storing Box<dyn Router<R>> in LoggingRouter and SystemRouter?

Using static dispatch, this becomes a lot easier:

Primary differences:

pub struct LoggingRouter<R> {
    underlying: R,
}

impl<'request, R> Router<MyRequest<'request>> for LoggingRouter<R> {
    // ...
}

pub struct SystemRouter<R> {
    underlying: LoggingRouter<R>,
}

If you need to change the type at runtime, consider using an enum in place of the R parameter.

Thanks for your reply.

For SystemRouter, storing a concrete router would be okay.

The equivalent of LoggingRouter in the real system holds a Vec of multiple subrouters and goes through them until one returns Some. Furthermore, runtime configuration decides which of those subrouters are instantiated. So, storing dyn Router works better.

You can keep the type erased Router(s) owned by LoggingRouter. With the other changes to its Router impl and SystemRouter as shown, that should fix the compile error.

edit: The other thing to do is use dynamic dispatch with HRTB on LoggingRouter in addition to SystemRouter. The error message in the second scenario is trying to tell you this, but it really does not know how.

One of my attempts was to use HRTB in LoggingRouter. I tried the following, but the compiler doesn't like the syntax.

pub struct LoggingRouter<R> {
    underlying: Box<dyn for<'request> Router<R: 'request>>,
}

I tried some variations such as where R: 'request to no avail.

Let me know if I understood your suggestion correctly or if there is a different way to express the same constraints.

As for an earlier suggestion:

This will make me implement LoggingRouter specifically for MyRequest. I have several such request-agnostic routers, which ideally will work with a generic R.

It's already the case that your SystemRouter is constructing a temporary &MyRequest { ... }. So only impl Router<MyRequest<'_>> can satisfy the call.


edit: I also wanted to address these previous comments and try to understand how they are connected:

I was suggesting using this:

pub struct LoggingRouter {
    underlying: Box<dyn for<'request> Router<MyRequest<'request>>>
}

Because it was previously stated that LoggingRouter uses type erasure for an internal heterogenous collection:

But the R is not type erased, so all elements in the Vec have the same request type. Which, as far as the demo code goes, is just MyRequest<'_>.

Was this concrete R overlooked when you minimized the code for demonstration? Vec<Box<dyn Router<R>>> is also kind of suspicious because only MyRequest<'_> can be substituted for R due to the way impl Router for SystemRouter is written.

Maybe this is just underspecification of both the original problem statement and demo code.

1 Like

To a point, but note a crucial difference between a/A and b/B here:

SomethingIntroducingTypeParameter<Request> {
    ...
    a: ... dyn Router<Request> ...,
    b: ... dyn for<'any> Router<MyRequest<'any>> ...,
}

// Or in terms of bounds
<A, B, Request> ... where
   A: Router<Request>,
   B: for<'any> Router<MyRequest<'any>>,

The usable a/A implementations of Router can only take a single type Request, where as with b/B the usable implementation can take an infinite set of types, Router<MyRequest<'one>>, Router<MyRequest<'two>>, etc. And in particular that includes types with local lifetimes. (Attempt 1 was basically approach A at the SystemRouter and LoggingRouter levels, and attempt 2 was approach B at the SystemRouter level and approach A at the LoggingRouter level.)

If you wanted to support approach B in LoggingRouter while still using Box<dyn ... Router ...> specifically, but generically instead of just for MyRequest, you'd have to emulate generic type parameters/HKTs to some extent by way of a trait. Or perhaps refactor things so that your traits are GAT-like but object safe (with a big step up in complexity). The key part is you wouldn't be able to have a type parameter R for the request type, because that can only represent one type, but you need to work for the infinite number of MyRequest<'lifetime> types.

Instead of doing that, it's more straight-forward to be generic over the router instead of the request:

pub struct LoggingRouter<Rtr> {
    underlying: Rtr,
}

impl<Rtr: Router<Req>, Req> Router<Req> for LoggingRouter<Rtr> {
    fn route(&self, _request: &Req) -> Option<String> {
        println!("start");
        // ...self.underlying.route(..)
        None
    }
}

// And you'll probably want/need
impl<Rtr: ?Sized + Router<Req>, Req> Router<Req> for Box<Rtr> {
    fn route(&self, request: &Req) -> Option<String> {
        (**self).route(request)
    }
}

Now you can have any of

LoggingRouter<Box<dyn Router<SomeNonLifetimeType>>>
LoggingRouter<Box<dyn for<'a> Router<MyRequest<'a>>>>
LoggingRouter<Box<dyn for<'a> Router<SomeOtherLifetimeType<'a>>>>

and they'll have the corresponding Router implementation themselves.

It does put an annotation burden on the construction site when you want to type erase.

2 Likes

Really appreciate your help.

For the Vec part, this is how the real system looks like (quite a few details omitted such as async and other type bounds). So the R is fixed for all subrouters, but with each element is a different implementation of Router

pub struct LoggingRouter<R> {
    underlying: Vec<Box<dyn Router<R>>>,
}

impl<R> Router<R> for LoggingRouter<R> {
    fn route(&self, _request: &R) -> Option<String> {
        println!("start");
        for router in &self.underlying {
            if let Some(result) = router.route(_request) {
                return Some(result);
            }
        }
        None
    }
}

This is awesome. Thank you for the solution, explanation, and pointers to other possible design choices.

This meets my requirement of keeping LoggingRouter work with any kind of request (at least regarding the demo code). I will translate this to the real system, and hopefully, that, too, will work.

To try and elaborate on this again, making R fixed (a single type for all subrouters) means that you can only support a specific lifetime for MyRequest<'lifetime> -- and when that shows up in a function argument, for example, the lifetime has to be valid for longer than the function. So this will never work:

pub struct LoggingRouter<R> {
    underlying: Vec<Box<dyn Router<R>>>,
}

// The caller supplies this lifetime (the function body doesn't get to choose it),
// and as such, it must be valid everywhere in the function (thus longer than it)
//                                         vv
fn cannot_work(lr: LoggingRouter<MyRequest<'_>>) {
    let local = String::new();
    // You can't borrow a local for a caller-supplied lifetime, because the
    // local always gets dropped or moved by the end of the function call
    // (i.e. while the caller-supplied lifetime has to remain valid)
    lr.route(&MyRequest { method: &local });
}

That's why you need higher-ranked bounds/types somewhere; the challenge is that a type parameter like R on LoggingRouter<R> cannot represent something utilizing a lifetime under a lifetime binder, it can only represent a single type. (There is no single for<'a> MyRequest<'a> type.)

So all the workarounds involve avoiding "naming" the type that has different lifetimes with a type parameter like R. Making LoggingRouter generic over the router type instead of the request type is one such workaround.

3 Likes

Indeed, that was a gap in my conceptual understanding. Thanks for being patient with my queries and taking your time to explain.

I translated this design in my real system and it all held together well. Here is an updated playground for anyone who comes across this (the main difference: it uses ErasedMyReqRouter from your code).

I truly appreciate your help @quinedot @parasyte. I learned a lot!

2 Likes