Expressing HRTB-like bound on generic struct

Suppose you have a Writer<T> struct as follows:

struct Writer<T> {
    m: std::marker::PhantomData<fn(&T)>,
}

impl<T> Writer<T> {
    fn write(&mut self, val: &T) {} // somehow knows what to do - that's not important
}

The Writer is generic only to associate the T with the method parameter in write() - it otherwise doesn't store or use the T.

Next, you have a struct like this:

struct Record<'a> {
    x: &'a i32,
}

And now you want to have a Logger struct as follows:

// Don't want a lifetime parameter on `Logger`
struct Logger {
    rec_writer: Writer<Record<'static>>, // it's not really 'static
}

impl Logger {
    fn write_record(&mut self) {
        let x = 5;
        let rec = Record {x: &x};
        let rec: Record<'static> = unsafe {
            std::mem::transmute(rec)
        };
        self.rec_writer.write(&rec);
    }
}

So here I'm transmuting to 'static to satisfy the type system, and I know that rec_writer.write(...) will not attempt to stash any values out of Record on the premise that they're 'static (which they're not). Ideally, what I'd like to express is:

struct Logger {
    rec_writer: for<'a> Writer<Record<'a>>,
}

which of course is invalid since HRTB only works on traits.

Is there a clean way to achieve this? Am I forgetting something? I can probably redesign the code to not be structured like this, but I'm curious if this is feasible as-is.

2 Likes

There are probably reasons for that in your case, but I'm getting very queasy by the definition struct Writer<T>. I would likely try to go for something like this:

struct Writer;
impl Writer {
    fn write<R: Record>(&mut self, record: &R) { ... }
}

trait Record { ... }

struct I32Record<'a>(&'a i32);
impl<'a> Record for I32Record<'a> { ... }

struct Logger { rec_writer: Writer }

impl Logger {
    fn write_record(&mut self) {
        let x = 5;
        let rec = I32Record {x: &x};
        self.rec_writer.write(&rec);
    }
}

that is, move the generic parameter from Writer to write(). Then again, coming up with a good definition of trait Record might be just as difficult as the original problem …

This has different semantics. Writer<T> says it can only log a specific T, which is what I'd like. write<R: Record> means it can write any per-call chosen R: Record. The idea here is a writer is associated with a specific type, and can only write that one type.

Is there a reason why you want to tie a Writer to a specific type of record? My feeling is that this introduces too tight a coupling; the Writer should not care about the exact type as long as it can get the information/string that it needs.

It’s a binary logger of a specific format that doesn’t allow a file (or whatever underlying sink) to have heterogeneous records (there’s a header in the blob describing the layout of records, but it’s a fixed layout for just a single type). And Writer on its own knows to work with any T that implements some other traits. In practice, this is a custom serde serializer internally.

In some ways, it’s like a Vec<T> - once you choose the T, all vec operations require that specific T.

I see. Is there maybe a way to split the responsibilities of T into two? Your Writer could take a generic type that describes the format of your record, and that type has no lifetime parameters. The Write::write() method then doesn't take T, but instead a parameter that can be converted to T (either through From or a specialty trait).

This is just an idea, which is why I'm being so vague here, but maybe that's enough to keep the lifetime out of the declaration of rec_writer's type.

3 Likes

Splitting the metadata from the value written would likely work, but IMO, it’s unnecessary ceremony and abstraction here (hence I was wondering if there’s a “clean” approach).

The current way “works” if you turn a blind eye to the transmute, and it’s straightforward and clear conceptually (if the lifetime could be higher rank). The missing ability is to be polymorphic over any lifetime of the struct.

But I appreciate your suggestions @troiganto - thanks! I wonder if any future planned changes would enable this; I don’t think GATs solve this, but maybe an HKT-like ability over lifetimes alone would work.

1 Like

You could use this kind of pattern to avoid actually separating metadata:

struct Writer<T>(PhantomData(T));

impl Writer<T> { fn write<R: Record<Tag=T>>(&mut self, record: &R); }

trait Record {
    type Tag; // Perhaps Tag: Into<Self> to state the intent more clearly?
    // ...
}

impl<'a> Record for MyRecord<'a> {
    type Tag = Record<'static>;
    // ...
}

I guess the trait would have to be unsafe to prevent random implementations to set the same tag for different structs. It won't be feasible if you have some blanket impls though.

This seems really interesting! Out of sheer curiosity — is this some kind of existing format, or is it something new? Or perhaps, just a part of something bigger?

@krdln, thanks for the ideas.

Yeah, the tag approach is sort of C++'esque and as you say, doesn't statically guarantee that there won't be a mixup of the tags. I don't have any blanket impls to worry about though, at least at the moment.

I'm not sure if this is better than just transmuting locally like I did.

It's a proprietary format to store timeseries data. But I think the design/impl scenario is, of course, more generic; you can imagine wanting to do this for a csv file and statically enforce that callers give you the exact same T that the Writer is defined with. In addition, the Writer<T> will write out the header row on construction if T: Default by running through serde serialization of it and recording the field names.

So I played around with this a bit more but couldn’t come up with a design that I liked. So I’ve kept the current unsafe hack for now. Maybe some form of HKT in the future will make this better.

This could be another interesting usecase for the 'unsafe lifetime RFC. Using 'static as a stand-in for inexpressible lifetimes never sat right with me, since 'static has strong implications in relationship to other lifetimes. Hopefully GAT or something will help solve cases like this, but I still think it's valuable to have a way to express a fully unconstrained lifetime.

1 Like