[RFC] Lifetime annotations for C++

Two meta points:

• I’d prefer that new attributes be invented over using the plain annotate attribute in any features that land in the main line. I’ve used the plain annotate attribute extensively for plugins to generate language-specific bindings similar to libffi and I think it’s best to leave that one complete extensible.

• Is any of this proposed for the actual Clang Static Analyzer, or just clang-tidy? I’m far more interested in extending Clang according to its design principles instead of adding to external tools.

2 Likes

Rust dev here, I think this proposal is off to a pretty good start, and will probably have significant safety benefits. However, there are still some things I think need to be addressed at some point.

First off, there is the 'static lifetime. In Rust, the 'static lifetime is a special lifetime that outlives all other lifetimes. Roughly speaking, any 'static object can be assumed to be valid for the entire length of the program. For example, values, references to global variables, and data leaked on the heap can all typically be considered to have a 'static lifetime.

Another thing to consider is what happens when the returned lifetime of a pointer is not bounded to the argument lifetimes. In the most simple case, something like

int* $a leak(){
    //create an int and leak it on the heap
}

In rust, this is handled by forcing the returned lifetime to be either 'static or unbounded. Unbounded lifetimes are chosen by the caller of the function and are equivalent to 'static in terms of inference most of the time.

Another consideration is what to do with function pointers. Getting these right is surprisingly difficult. Naively one might think the following code is valid:

void use_callback( (*callback)(int* $a) ){
    int foo = 42;
    callback(&foo);
}

However, it isn’t- the callback requires a lifetime of at least as long as the call site of use_callback, while the pointer passed is defined in the local scope and will be invalidated upon return. Rust solves this problem with Higher-Ranked Trait Bounds (HRTBs), which confusingly have far more to do with lifetimes than traits. Rust desugars the function pointer fn(&i32) into for<'a> fn(&'a i32) instead of fn(&'a i32). In words, using HRTBs means the function is valid for all lifetimes 'a rather than some specific lifetime 'a. In practice, this means the lifetime of the function pointer and its arguments are dissociated, so

fn use_callback(callback: for<'a> fn(&'a i32)){
    let foo = 42;
    callback(&foo);
}

is valid, but

fn use_callback<'a> (callback: fn(&'a i32)){
    let foo = 42;
    callback(&foo); //error[E0597]: `foo` does not live long enough
}

is not.

Those were just the first few things that popped into my head, there are plenty of other edge cases to consider as well:

  • I want to use lifetime checks in most of my code, but some of the stuff I’m doing can’t be modeled with lifetimes. How can I opt-out of checking just those parts?*

  • I casted my class with a lifetime to a base class without one. Now what?**

  • For my code to be modeled correctly, I need to lengthen a lifetime. How can I do that?***

  • When working with a template parameter T can I assert that T must have a certain lifetime?****

  • My object Foo shouldn’t be used after the object Bar is destroyed, but Foo doesn’t have a reference to Bar. Is there a way to add a phantom lifetime to Foo?*****

There are most definitely other small issues to worry about beyond those, but that’s what I can think of at the moment. My biggest concern with all of this is that the lifetime model may struggle to integrate properly with the language, especially since there is no borrow checker, and might not be as useful as it seems because of it.

Anyways, while I don’t work with C++ very often, I think this could be quite useful, and I am interested to see where this goes.

*Rust handles this with a divide between references and safe code; and raw pointers and unsafe code. This will obviously not work for C++.

**Rust has a similar problem with trait objects and handles this by associating a lifetime with trait objects that are 'static by default.

***In Rust, the safest way to do this is mem::transmute, although casting a reference to a pointer and back can also do this.

****This is an important part of rust’s lifetime system because references aren’t allowed to outlive the data they point to.

*****Rust uses PhantomData for this purpose. However, it is mainly used to add lifetime information to a pointer that otherwise wouldn’t have any. Because C++ doesn’t have the same distinction between a pointer and reference that rust does, this would be a much more niche use case, because most of the time just adding a pointer to the parent object would be fine.

When you are saying clang-tidy and api notes, I am quite happy. You can experiment upstream without interfering with other people. At the same, I like the idea and it seems useful. They are too many memory bugs in C++. Improving the interface between C++ and Swift/Rust sounds even better.

Prior Art:
https://news.ycombinator.com/item?id=22137650

LLVM Prior Art:
https://reviews.llvm.org/D15032
https://reviews.llvm.org/D63954
https://github.com/mgehre/llvm-project/issues/98
I wonder wether this RFC reuse the code from those PRs ? It would be unfortunate to ignore those previous works.
If this RFC is a followup of those previous works, then my bad.
Either way, inspiration, code reuse and ideally interop with the CPP guideline lifetime checker would be a desirable goal.

1 Like

Could this proposal perhaps be used as a catalyst for improving the overall Clang plugin story? One of the major blockers when I endeavored down that path was the sheer complexity of adding new attribute types for plugins to use when analyzing/transforming ASTs - it tied very deeply into tablegen, which means hardcoding new attribute types as opposed to registering or otherwise processing them via plugins.

It also required a lot of playing with Sema, and there was a loose conversation on the mailing lists that didn’t go anywhere but ultimately discussed the right direction for plugin designs to go to support such usecases. It appears there is some overlap here.

If such a thing could be the basis for these additions into mainline Clang at some point (instead of just keeping it in clang-tidy) that would be a massive improvement to the overall ecosystem, in my opinion. It would certainly open up Clang to more interesting usecases and tooling that wasn’t possible, or at least wasn’t as ergonomic, as before.

Very excited to see what comes of this.

1 Like

Several weeks ago I made a similar post here, interestingly, also motivated by essentially the same kind of lifetime annotations:

(Btw, my post links to a tool that already implements enforcement of lifetime constraint attributes on function interfaces for those interested.)

The problem is that in any case there’s no way to avoid using macros rather than attributes directly because even if you can get clang to support the attributes, other compilers that don’t will throw an “unrecognized attribute” warning. The compiler vendors seem to consider attributes to be necessarily compiler-specific. It seems we would need the standards body to explicitly mandate a subset of the attribute namespace to be available for third party attributes, for example, as proposed by the paper linked in this reddit post:

(code example omitted)

No. Like the iterator example that this is a modification of, this could be caught by enforcing the “borrowing rule” discussed there, but as the discussion also notes, a lot of existing C++ intentionally violates this rule. We’re definitely interested in exploring how we could catch these kinds of errors too, but that would be future work.

1 Like

What do you mean by “main line” – Clang itself, or everything in the LLVM/Clang repository (e.g. also Clang-Tidy)?

I’m not sure what you mean here – can you elaborate? FWIW, we’re not proposing to limit the generality or extensibility of the annotate attribute in any way. We are proposing to introduce a new general-purpose attribute annotate_type that is analogous to annotate but for use on types. (The linked RFC discusses why we’re proposing a new attribute rather than extending annotate to types.)

Currrently, we’re proposing adding the checks only to Clang-TIdy because our approach is still experimental and doing the work in Clang-Tidy has the least impact on other parts of the codebase. However, if our evaluations of the approach on large real-world codebases show that it works well, we would definitely be interested in integrating the check into Clang Static Analyzer or Clang itself.

Thanks for the detailed comments and pointers!

Some of these (e.g. static lifetimes, forcing returning lifetimes to be static or unbounded, HRTBs) are things that we’ve already considered. We haven’t described them in this RFC because a complete spec would take many pages (our internal spec for lifetime annotations currently runs to 25 pages), and we felt it wasn’t productive to go into this level of detail in this high-level RFC. We do however plan to provide more complete design docs as patches with the implementation, and of course we’re happy to answer specific questions here.

We’re planning to include the concept of an “unsafe” lifetime for things that cannot be modeled with lifetimes. Pointers or references with an unsafe lifetime would be analogous to unsafe pointers in Rust.

You mean the derived class has a lifetime parameter, and you’re casting it to a base class that does not?

As nothing in the base class can use the lifetime parameter, you would simply not have access to it. If you want to cast back to the derived class, you would have to do so using an unsafe lifetime_cast operation.

Are there any other issues you’re thinking of that would arise? Do you have an example?

We intend to provide an unsafe lifetime_cast operation that can be used to extend lifetimes or convert unsafe lifetimes to safe ones (when the programmer can prove this is safe). Is this what you meant?

I’m not sure how this would work, as the template argument for T might contain arbitrarily many lifetimes, or none at all. Do you have a motivating example?

You note below that:

We plan to enforce the same constraint, and a similar constraint for template arguments (i.e. an object may not outlive any lifetimes in its template arguments).

As in Rust, you could add a corresponding lifetime parameter to Foo. In Rust, you would use this lifetime parameter merely in a PhantomData field; in C++, you wouldn’t use the lifetime parameter anywhere in the definition of Foo.

In other words, unlike Rust, we would probably allow unused lifetime parameters. If we conclude this is undesirable, we might want to have to a construct that is analogous to Rust’s PhantomData, but we’d have to give this some more thought.

The lack of borrow checking is certainly a limitation (though we also want to explore what can be done in this area at a future point). Anecdotally, since starting this work, we have come across several non-obvious lifetime bugs in our own code that would have been caught by our proposed checks, so we believe they bring significant value. However, this is something that would need to be validated by use of the tools themselves on large code bases.

This is “Lifetime safety: preventing common dangling (WG21 proposal P1179)” / -Wdangling-gsl, which we describe in detail and compare with our proposed approach in the section “Comparison with other work in this area”.

This patch was abandoned (I presume in favor of the -Wdangling-gsl check that is part of Clang?).

This is the first of a series of patches that implement -Wdangling-gsl.

As we are planning to implement our inference and verification tooling as Clang-Tidy checks, it would be hard to reuse parts of the -Wdangling-gsl implementation, which is part of Clang itself. However, if our approach proves successful, we would be interested in contributing it to the Clang core, and at that point we should of course make sure that we don’t duplicate any logic that already exists as part of -Wdangling-gsl.

Regarding interop: Our plan is that our tooling should be able to interpret the attributes used by -Wdangling-gsl and internally translate them to corresponding lifetimes (to the extent that this is possible), so that codebases that use the two annotation schemes can be used together.

1 Like

As you’re probably aware, there is already a mechanism for adding “attribute plugins” to Clang:

https://clang.llvm.org/docs/ClangPlugins.html#defining-attributes

However, this only allows you to define declaration attributes, not type attributes, which is presumably what you are after.

A few months ago, I explored the possibility of extending this plugin mechanism to allow the definition of type attributes and submitted the following (ultimately abandoned) patch:

https://reviews.llvm.org/D114235

Unfortunately, the conclusion was that it’s much harder to make type attributes pluggable than declaration attributes. Type attributes interact with the type system, different attributes may want to do this in different ways, and it’s hard to make the logic for this pluggable.

Instead, we have decided to propose a general-purpose type annotation attribute, see this companion RFC. As @duneroadrunner points out above, attributes are typically hidden behind macros anyway for potability reasons, so a general-purpose type annotation would end up looking the same in the source code as a special-purpose attribute.

Hi! Thanks for working on this, it looks very exciting.

There is a tool that creates automatic python/c++ bindings called cppyy. More details on the general use can be found here. In essence, we use clang’s incremental compilation facilities (via cling, and recently clang-repl) to make Python interoperate with C++ on the fly. If we knew more about memory management we would be able to avoid a class of problems where we don’t know who is responsible for object destruction python or C++.

I am happy to elaborate more if you find this useful or worth adding as an interpo use-case.

That’s what I figured, I just wanted to make sure you have a plan for all of the weird edge cases that cropped up in rust’s lifetime system.

Yep, that is what I mean. As for issues that arise, I’m thinking use-after-frees:

#include <memory>
class Foo{
    public:
        virtual int make_int() = 0;
};

class LIFETIME_PARAM(a)  Bar: public Foo {
    private:
        int* $a ptr;
    public:
        Bar(int* $a baz):ptr(baz){}
        int make_int(){
            return *ptr;
        }
};
std::unique_ptr<Foo> make_foo(){
    int local = 42; //local variable
    auto bar = Bar(&local);
    auto owned = std::make_unique<Bar>(bar);
    return owned; //uh-oh, returning a reference to a local
}

I’ve just cast away lifetime information and then took advantage of that fact to return a pointer to an object that no longer exists, even though my code assumes that it does. In rust, the equivalent code:

trait Foo {
   fn make_int(&self) -> i32;
}
struct Bar<'a> {
   ptr: &'a i32,
}
impl Foo for Bar<'_> {
   fn make_int(&self) -> i32 {
       *self.ptr
   }
}
fn make_foo() -> Box<dyn Foo> {
   let local = 42;
   Box::new(Bar { ptr: &local }) // error[E0515]: cannot return value referencing local variable `local`
}

fails to compile.

That is what I meant.

While the argument T might have many lifetimes associated with it, there is only one that really matters: the shortest one. You wouldn’t need to individually handle every lifetime involved, just grab the shortest lifetime in the object and call that the lifetime of the object. The biggest reason would be to ensure something has a 'static lifetime. For example, this code, which creates a single object per-type cache

#include <optional>

template <typename T>
std::optional<T> swap_cache(std::optional<T> t){
    static std::optional<T> cache = std::nullopt;
    cache.swap(t);
    return t;
}

is only sound if T satisfies the 'static bound (and if it’s only called from a single-threaded context, but that’s beside the point).

Rust satisfies this constraint by automatically assuming that &T means &'a T where T:'a in most circumstances, and it’s an error if that condition is violated. This means that sometimes you have to bound a generic variable to satisfy that lifetime. The situation that immediately comes to mind is Generic Associated Types (GATs), where you will often have something like

trait Foo{
    type Bar<'a> where Self: 'a;
}

although those are still unstable.

This is perfectly fine if you can sort out the variance appropriately. Rust disallows unused generic parameters because the compiler had to assume the struct was bivariant over those parameters, which was pretty much always the wrong thing.

I suppose, even if it only catches obvious bugs it will probably help avoid more than a few build-run-segfault-curse cycles. I agree though, the only way to truly know how useful it is is to test it on some real code and see what happens.

Thanks – these are interesting pointers!

One of the stated goals of our project is to support better interop between C++ and other languages. It would be great if we could add Python to this list. I’m not sure to which lifetimes will be able to help with your specific problem though. It sounds as if you need information about ownership, specifically who is responsible for deleting an object, and lifetimes don’t really help with that. Maybe there are other purposes for which you can use the lifetime information though?

@Aiden2207 First of all, thank you for the many insightful issues you raise!

(quoting only the relevant part of the code)

Thank you. This is an interesting example, and I now understand the issue you are getting at.

Initially, I thought that the verification tool should reject this code. Because an object may not outlive any of its lifetime parameters, we infer on the line auto bar = Bar(&local); that the lifetime parameter of Bar is the local lifetime of local, and I thought this would lead us to conclude that the line return owned returns a reference to a local.

This isn’t true, however. The issue is that converting owned to a unique_ptr<Foo> essentially “erases” the lifetime parameter on Bar. The lifetime of the unique_ptr<Foo> can now be extended at will, beyond that of the lifetime parameter on Bar.

I see two possible solutions to this:

  • Add some equivalent of Rust’s Box<dyn Foo + 'a> to our C++ annotation scheme – something like std::unique_ptr<Foo + $a> in spirit, though we would need to find something that is actually allowed by C++'s grammar. This would imply that all lifetime parameters of the dynamic type of the pointed-to object outlive $a. It would also imply that the lifetime of the unique_ptr may not be extended beyond $a.

  • Require all lifetime parameters to be declared on the base class; do not allow lifetime parameters to be added to derived classes.

For the time being, I would favor the second approach because of its simplicity. The main limitation is that it forces Foo to carry a lifetime parameter around even if not all of its subclasses use that lifetime parameter. If it turns out that this is too burdensome, we would need to implement the first approach.

This reflects a general principle that we’ve been trying to follow: Only add those things to the annotation scheme that we’re convinced we’re going to need. It could be argued that everything in Rust that’s related to lifetimes is there because it’s needed, and will therefore also be needed in C++, but we’re not sure this argument holds – Rust and C++ are different languages. We’re also trying hard to minimize complexity to make the annotation scheme as easy to learn as possible. Because of this, we’re being conservative with how many of Rust’s lifetime concepts we import. Real-world evaluations will be the final determination of which features we actually need.

Thanks, I now understand.

This example highlights an interesting difference between Rust and our approach to lifetimes in C++. Because C++ templates are not generics, we perform lifetime inference and checking on each concrete template instantiation. When doing so, all of the lifetimes in the template argument (in this case T) are “visible” and participate in the lifetime inference and checking, allowing us to flag any violations of lifetime correctness.

We have to do this because C++ templates are syntactic; the semantics can vary significantly depending on the template arguments.

I have to admit this is an area of Rust that I’m not very familiar with. My tendency though would be not to add a corresponding construct to our lifetime annotation scheme until we see a clear need for it. (Do you have an example where this kind of thing would be needed in C++?)

For the time being, we’re taking the simple approach of making all lifetime-parameterized classes covariant with respect to their lifetime parameters. In other words, unlike Rust, we’re don’t infer variance from the way those lifetimes are actually used within the class (though we should emit an error if we see that our assumption of variance is in fact wrong). Again, we’re trying to limit complexity for the time being in hopes that we won’t need the additional complexity. If you have some important Rust use cases you can share where something other than covariance is needed, we would be very interested!

It is still needed to indicate that the function is safe to call, somehow (e.g. with a [[safe]] attribute in the C++ side). Lifetime annotations might be useful to widen the set of functions that may be annotated as safe, but they should not be automatically marked as such, in order to remain sound from Rust’s point of view.

Thank you for this RFC, I think it’s very important to try to bring light to the dark corners where bugs lurk, and lifetime issues is absolutely one of the darker corners.

I have questions about about how you intend to experiment with the semantics:

Will the experimentation take place in tree or out of tree? (I understand the plan is to eventually land the annotations in Clang, but this is about the plan leading up to when we have the design finalized.)

If you plan to do the experimentation in tree, how do you expect to protect users against design changes? Especially when we get the semantics design right and we start thinking about how best to surface the feature (new keywords, an attribute specific to the purpose, god forbid: pragmas, etc).

If you plan to do the experimentation out of tree, will there be a feedback loop with the community as you refine the design, or are you planning to wait until the design looks pretty close to final and then start soliciting community feedback?

Glad I could help.

I think personally the first approach will be the easiest to work with- the second one seems to be rather inflexible, as it seems it forces people writing abstract base classes to plan for other people’s implementations of it. Not having a lifetime and needing one for the derived class is bad, and working with an unnessecary lifetime parameter is definitely awkward.

I think being conservative with how much of Rust’s lifetime system you bring over is a good goal though- a fair bit of what’s there is to deal with issues that C++ simply doesn’t have, on account of the differences between templates and generics, as well as rust’s much stricter rules about what references are permitted to point to and the borrow checker.

Right, templates are checked at instantiation rather than definition, I forgot about that (I am quite a bit more experienced with rust than C++, if you couldn’t already tell).

Sorry, I meant to show an example of where an explicit bound on a type parameter might be useful, but I chose what is quite possibly the worst one. Long story short, GATs are primarily meant to solve a lifetime issue C++ probably doesn’t have to deal with. Come to think of it, because lifetime checking would come after template instantiation, explicit lifetime bounds on the type parameters probably aren’t necessary in C++, but it might be worth integrating such lifetime constraints with concepts.

The Rustnomicon has a section devoted to variance. Long story short, references are covariant over their lifetime, any Object<T> that permits mutation without ownership is invariant over T (that’s things like &mut T and &Cell<T>), function pointers are weird, and anything else is covariant.

1 Like

To clarify: When you say “in tree”, you mean development and experimentation will happen on the version of Clang-Tidy at https://github.com/llvm/llvm-project?

This is our plan. We have up until this point done our experimentation in a non-public codebase, but we think we’ve answered enough questions that now is a good point to start upstreaming what we have, continue developing in upstream Clang-Tidy, and get feedback from the community. Also, we already have partners who are interested in evaluating the checks but would need them to be in upstream Clang-Tidy to do so.

Our plan is to clearly identify the two proposed Clang-Tidy checks as experimental in the documentation. We will caution users that we expect to make breaking changes to syntax and semantics and that they should therefore use the checks only for testing and experimentation but not for production use. We will work with our early adopters to help them across breaking changes while the Clang-Tidy check is still experimental. Once we consider the design to be sufficiently stabilized, we will remove this experimental qualification from the checks.

We think this approach makes sense because it allows us to get feedback on the design and implementation incrementally (the feedback on this RFC already shows the value of this), rather than in one “big bang”, as would be the case if we did the development out of tree.

Correct.

Okay, if you’re this far along – do you plan to use the annotate_type spelling directly for exposing the attributes, or do you have a plan to start with more concrete syntax (dedicated attributes, keywords, etc)? This is my primary area of concern regarding breaking users with design changes, so I’d like to either nail down those details (which can be done as part of the patch review, I don’t think we need to nail them down in the RFC) or have a plan for how to help users avoid breakage if we’re not ready to pick the syntax yet (like hiding everything behind macros and expecting the user to use the macro spelling, as an example).

There’s some natural tension here – people integrate clang-tidy into their CI pipelines fairly regularly, and so being experimental with checks in clang-tidy can be highly disruptive to folks who enable those checks (either without knowing they’re experimental or accidentally by enabling all checks in a module), so we tend to push back against significant experimenting in tree. It’s a production tool, so experimentation is somewhat unreasonable for it, but not impossible. At the same time, I agree about getting feedback and the benefits of incremental implementation. We should be aware of this when doing design work here and try to get as many of the important details correct up front as we can in terms of things like syntax; it’s more understandable to vary the semantic behavior than the syntax because users will be happier with different (better) diagnostics than they will be with differ syntax.

That said, the choice of clang-tidy seems incorrect to me. clang-tidy checks are based off AST pattern matching, so they’re typically not control-flow sensitive (and I don’t think we have any that are data flow sensitive). The Clang Static Analyzer seems like a more natural fit for lifetime checking because all of the checks are able to do data and control flow analysis. The architecture of checks is sufficiently different between clang-tidy and the static analyzer that I don’t think it’s particularly valuable to experiment in one with the intention of porting to the other, so I’d encourage you to explore a static analysis-based check. [There is a third option that may be useful for lifetime checks that do not require inter-procedural analysis (if you’re able to identify some): you can do a Clang analysis-based warning, which means Clang itself triggers the diagnostic rather than requiring the user to run a separate tool. However, I suspect the limitation to only checking CFGs within the function will be onerous and that’s why I recommend looking at the static analyzer first.]

1 Like