Document #: | P3444R0 |
Date: | 2024-10-15 |
Project: | Programming Language C++ |
Audience: |
SG23 |
Reply-to: |
Sean Baxter <seanbax.circle@gmail.com> |
This proposal describes the implementation of a memory-safe reference type that does not use lifetime annotations. The goal of the proposal is to:
“Safe C++”[safecpp] introduced a comprehensive design for compile-time memory safety in C++. The borrow checking model in Safe C++ requires lifetime parameters, a feature that increases expressiveness but complicates the language’s type system. This proposal describes an alternative style of borrow checking, guaranteeing lifetime safety without the involvement of lifetime annotations.
First let’s recap how lifetime parameters are declared and used.
lifetimes1.cxx – (Compiler Explorer)
#feature on safety
// Function parameters have different lifetime parameters.
// Return type is constrained by x's lifetime.
auto f1/(a, b)(int^/a x, int^/b y, bool pred) safe -> int^/a {
// Error:
// function auto f1/(a, b)(int^/a, int^/b) -> int^/a returns
// object with lifetime b, but b doesn't outlive a
// return y;
return pred ? x : y;
}
// Function parameters have a common lifetime parameter.
auto f2/(a)(int^/a x, int^/a y, bool pred) safe -> int^/a {
// Ok
return pred ? x : y;
}
// Error:
// cannot use lifetime elision for return type int^
auto f3(int^ x, int^ y) safe -> int^;
In Safe C++, occurrences of the borrow type
T^
in
function declarations and in data members require specialization with
lifetime arguments. Lifetime arguments name
lifetime-parameters declared as part of the function
declaration. Borrow types without lifetime arguments have unbound
lifetimes and borrows with lifetime arguments have bound
lifetimes. These are treated as different entities by the
language’s type system, and there are subtle rules on how bound
lifetimes decay to unbound lifetimes and how unbound lifetimes become
bound. Lifetime annotations greatly improve the capability of safe
references, but extend an already complicated type system.
The above code declares functions
f1
,
f2
and
f3
with
lifetime-parameter-lists. Borrows in function return types must
be constrained by the lifetimes of one or more function parameters.
Failure to match lifetime arguments between function parameters and
return types will cause a borrow checker failure.
f1
fails to borrow check because the
returned parameter y
does not
outlive the lifetime
/a
on the
return type.
Elision rules make lifetime annotations implicit in some cases. But
elision can fail, requiring users to intervene with annotations. In the
example above, the declaration of f3
fails because the elision rules cannot determine the lifetime argument
on the returned borrow.
lifetimes2.cxx – (Compiler Explorer)
#feature on safety
// New elision rules:
// All parameters are constrained by a common lifetime.
// The common lifetime constrains the return type.
int% f4(int% x, int% y, bool pred) safe {
// Can return either x or y, because they outlive the common lifetime
// and the common lifetime outlives the result object.
return pred ? x : y;
}
This proposal introduces a new safe reference marked by the
reference declarator
T%
. Safe
references do not take lifetime arguments and there is no notion of
bound or unbound lifetimes. The lifetime
parameterization is determined by the formation of the function type.
For a free function, all function parameters outlive a single invented
lifetime that extends through the duration of the function call. For a
non-static member function with the
%
ref-qualifier, the implicit object parameter outlives the
invented lifetime. In turn, this invented lifetime outlives the returned
safe reference.
T%
is a
mutable safe reference. It cannot alias other references to
overlapping places.const T%
is a shared safe reference. It may alias shared safe references
to overlapping places, but may never overlap a mutable reference.If lifetime safety can be guaranteed without lifetime parameters, why
involve a new reference type
T%
at all?
Why not perform this form of borrow checking on the existing lvalue- and
rvalue-references
T&
and
T&&
?
The answer is that safe references enforce exclusivity and
legacy references do not. There may be one mutable reference to a place,
or any number of shared (constant) references, but not both at the same
time. This is the universal invariant of borrow checking. Borrow
checking legacy reference types would break all existing code, because
that code was written without upholding the exclusivity invariant.
Exclusivity is a program-wide invariant. It doesn’t hinge on the safeness of a function.
“Valid” borrow and safe reference inputs don’t mutably alias. This is
something a function can just assume; it doesn’t need to check
and there’s no way to check. Borrow checking upholds exclusivity even
for unsafe functions (when compiled under the [safety]
feature). There are other assumptions C++ programmers already make about
the validity of inputs: for instance, references never hold null
addresses. Non-valid inputs are implicated in undefined behavior.
With a desire to simplify, you may suggest “rather than adding a new
safe reference type, just enforce exclusivity on lvalue- and
rvalue-references when compiled under the [safety]
feature.” But that makes the soundness problem worse. New code will
assume legacy references don’t mutably alias, but existing code
doesn’t uphold that invariant because it was written without considering
exclusivity.
If safe code calls legacy code that returns a struct with a pair of
references, do those references alias? Of course they may alias, but the
parsimonious treatment claims that mutable references don’t alias under
the [safety]
feature. We’ve already stumbled on a soundness bug.
Coming from the other direction, it may be necessary to form aliasing
references just to use the APIs for existing code. Consider a call to
vec.push_back(vec[0])
.
This is impossible to express without mutable aliasing: we form
a mutable lvalue reference to vec
and a const lvalue reference to one of
vec
’s elements. If safe code can’t
even form aliased lvalue references, it won’t be able to use this API at
all.
Exclusivity is a program-wide invariant on safe references. We need separate safe and unsafe reference types for both soundness and expressiveness.
vector1.cxx – (Compiler Explorer)
#include <vector>
void f1(std::vector<float>& vec, float& x) {
// Do vec and x alias? If so, the push_back may invalidate x.
.push_back(6);
vec
// Potential UB: x may have been invalidated by the push_back.
= 6;
x }
int main() {
::vector<float> vec { 1.0f };
std
// Legacy references permit aliasing.
(vec, vec[0]);
f1}
This example demonstrates how perilous mutable aliasing in C++ is. In
f1
, the compiler doesn’t know if
vec
and
x
alias. Pushing to the vector may
cause a buffer resize and copy its data into a new allocation,
invalidating existing references or pointers into the container. As C++
doesn’t enforce exclusivity on legacy references, the code in
main
is legal, even though it leads
to a use-after-free defect.
vector2.cxx – (Compiler Explorer)
#feature on safety
#include <cstdint>
template<typename T>
class Vec {
public:
void push_back(T value) % safe;
const T% operator[](size_t idx) const % safe;
% operator[](size_t idx) % safe;
T};
void f2(Vec<float>% vec, float% x) safe {
// Does push_back potentially invalidate x?
// No! Exclusivity prevents vec and x from aliasing.
.push_back(7);
vec
// Okay to store to x, because it doesn't point into vec's data.
*x = 7;
}
int main() safe {
<float> vec { };
Vec.push_back(1);
mut vec
// Ill-formed: mutable borrow of vec between its mutable borrow and its use
(mut vec, mut vec[0]);
f2}
$ circle vector2.cxx
safety: during safety checking of int main() safe
borrow checking: vector2.cxx:27:19
f2(mut vec, mut vec[0]);
^
mutable borrow of vec between its mutable borrow and its use
loan created at vector2.cxx:27:10
f2(mut vec, mut vec[0]); ^
Rewrite the example using our simplified safe references. In
main
, the user attempts to pass a
safe reference to vec
and a safe
reference to one of its elements. This violates exclusivity, causing the
program to be ill-formed.
Mutable safe references are prohibited from aliasing. Exclusivity is
enforced by the same MIR analysis that polices Safe C++’s more general
borrow type
T^
. While
enforcing exclusivity involves more complicated tooling, it simplifies
reasoning about your functions. Since safe reference parameters don’t
alias, users don’t even have to think about aliasing bugs. You’re free
to store to references without worrying about iterator invalidation or
other side effects leading to use-after-free defects.
exclusive1.cxx – (Compiler Explorer)
#feature on safety
void f(int% x, int% y) safe;
void g(int& x, int& y) safe {
{
unsafe // Enter an unsafe block to dereference legacy references.
// The precondition to the unsafe-block is that the legacy
// references *do not alias* and *do not dangle*.
(%*x, %*y);
f}
}
void f(int% x, int% y) safe {
// We can demote safe references to legacy references without
// an unsafe block. The are no preconditions to enforce.
(&*x, &*y);
g}
While safe references and legacy references are different types,
they’re inter-convertible. Converting a safe reference to legacy
reference can be done safely, because it doesn’t involve any
preconditions. Function f
converts a
safe reference x
to an lvalue
reference with a dereference and reference-of:
&*x
.
Going the other way is unsafe: the precondition of the
unsafe-block is that the legacy references do not
alias and do not dangle:
%*x
.
This proposal implements two sets of constraint rules. Free functions constrain return references by the shortest of the argument lifetimes. Non-static member functions constrain return references by the implicit object lifetime.
lifetimes3.cxx – (Compiler Explorer)
#feature on safety
const int% f1(const int% x, const int% y, bool pred) safe {
// The return reference is constrained by all reference parameters: x and y.
return pred ? x : y;
}
struct Obj {
const int% f2(const int% arg) const % safe {
// Non-static member functions are constrained by the implicit
// object lifetime.
// It's OK to return `x`, because self outlives the return.
return %x;
}
const int% f3(const int% arg) const % safe {
// Error: arg does not outlive the return reference.
return arg;
}
const int% f4(const self%, const int% arg) safe {
// OK - f4 is a free function with an explicit self parameter.
return arg;
}
int x;
};
int main() {
int x = 1, y = 2;
(x, y, true); // OK
f1
{ };
Obj obj .f2(x); // OK
obj.f3(x); // Error
obj.f4(x); // OK.
obj}
$ circle lifetimes3.cxx
safety: during safety checking of const int% Obj::f3(const int%) const % safe
error: lifetimes3.cxx:18:12
return arg;
^ function const int% Obj::f3(const int%) const % safe returns object with lifetime SCC-ref-1, but SCC-ref-1 doesn't outlive SCC-ref-0
The definitions of free function
f1
and non-static member function
f2
compile, because they return
function parameters that constrain the return type: the returned
parameter outlives the returned reference. The non-static
member function f3
fails to compile,
because the returned parameter does not outlive the the return
type. In a non-static member function, only the implicit object
parameter outlives the return type.
f4
returns a function parameter but
compiles; it uses the explicit object syntax to gain the ergonomics of a
non-static member function, but retains the constraint rules of a free
function.
vector3.cxx – (Compiler Explorer)
#feature on safety
template<typename Key, typename Value>
class Map {
public:
// Non-static member functions do not constrain the result object to
// the function parameters.
auto get1(const Key% key) % safe -> Value%;
// Free function do constrain the result object to the function parameters.
auto get2(self%, const Key% key) safe -> Value%;
};
int main() safe {
<float, long> map { };
Map
// Bind the key reference to a materialized temporary.
// The temporary expires at the end of this statement.
long% value1 = mut map.get1(3.14f);
// We can still access value, because it's not constrained on the
// key argument.
*value1 = 1001;
// The call to get2 constrains the returned reference to the lifetime
// of the key temporary.
long% value2 = mut map.get2(1.6186f);
// This is ill-formed, because get2's key argument is out of scope.
*value2 = 1002;
}
$ circle vector3.cxx
safety: during safety checking of int main() safe
borrow checking: vector3.cxx:30:4
*value2 = 1002;
^
use of value2 depends on expired loan
drop of temporary object float between its shared borrow and its use
loan created at vector3.cxx:27:31
long% value2 = mut map.get2(1.6186f); ^
The constraint rules for non-static member functions reflect the idea that resources are owned by class objects. Consider a map data structure that associates values with keys. The map may be specialized a key type that’s expensive to copy, such as a string or another map. We don’t want to compel the user to pass the key by value, because that may require copying this expensive type. Naturally, we pass by const reference.
However, the accessor only needs the key inside the body of the
function. Once it locates the value, it should return a reference to
that, unconstrained by the lifetime of the key argument. Consider
passing a materialized temporary for a key: it goes out of scope at the
end of the full expression. get1
uses the non-static member function constraint rules. The caller can use
the returned reference even after the key temporary goes out of scope.
get2
uses the free function
constraint rules, which constrains the return type to all of its
function parameters. This leaves the program ill-formed when the
returned reference is used after the expiration of the key
temporary.
In this model, lifetime constraints are not generally programmable, but that design still provides a degree of freedom in the form of non-static member functions.
vector4.cxx – (Compiler Explorer)
#feature on safety
template<typename Key, typename Value>
class Map {
public:
// Lifetime elision rules constrain the return by self.
auto get1(self^, const Key^ key) safe -> Value^;
// Use explicit parameterizations for alternate constraints.
auto get2/(a)(self^/a, const Key^/a key) safe -> Value^/a;
};
int main() safe {
<float, long> map { };
Map
// Bind the key reference to a materialized temporary.
// The temporary expires at the end of this statement.
long^ value1 = mut map.get1(3.14f);
// We can still access value, because it's not constrained on the
// key argument.
*value1 = 1001;
// The call to get2 constrains the returned reference to the lifetime
// of the key temporary.
long^ value2 = mut map.get2(1.6186f);
// This is ill-formed, because get2's key argument is out of scope.
*value2 = 1002;
}
$ circle vector4.cxx
safety: during safety checking of int main() safe
borrow checking: vector4.cxx:29:4
*value2 = 1002;
^
use of value2 depends on expired loan
drop of temporary object float between its shared borrow and its use
loan created at vector4.cxx:26:31
long^ value2 = mut map.get2(1.6186f); ^
The general borrow type
T^
has
programmable constraints. The map above declares accessor functions.
get1
relies on lifetime elision to
constrain the result object by the
self
parameter. This is equivalent
to the non-static member function constraint rule. We can call
get1
and use the returned reference
even after the key temporary goes out of scope.
get2
includes lifetime
annotations to constrain the returned reference by both the
self
and
key
parameters. This is like the
free function constraint rules. The program fails borrow checking when
the returned reference value2
is
used after the expiration of its key temporary.
References can be taxonimized into two classes:[second-class]
Parameter-passing directives like
in
and
inout
are equivalent to second-class
references. The mutable value semantics[mutable-value-semantics] model uses
parameter-passing directives to pass objects to functions by reference
without involving the complexity of a borrow checker.
void func(Vec<float>% vec, float% x) safe;
In this fragment, the reference parameters
vec
and
x
serve as second-class
references. The compiler can achieve memory safety without
involving the complexity of borrow checking. Both references point at
data that outlives the duration of the call to
func
. Exclusivity is enforced at the
point of the call, which prevents
vec
and
x
from aliasing. Since
vec
and
x
don’t alias, resizing or clearing
vec
cannot invalidate the
x
reference.
The safe references presented here are more powerful than second-class references. While they don’t support all the capabilities of borrows, they can be returned from functions and made into objects. The compiler must implement borrow checking to support this additional capability.
Borrow checking operates on a function lowering called mid-level IR (MIR). A fresh region variable is provisioned for each local variable with a safe reference type. Dataflow analysis populates each region variable with the liveness of its reference. Assignments and function calls involving references generate lifetime constraints. The compiler solves the constraint equation to find the liveness of each loan. All instructions in the MIR are scanned for conflicting actions with any of the loans in scope at that point. Examples of conflicting actions are stores to places with live shared borrows or loads from places with live mutable borrows. Conflicting actions raise borrow checker errors.
The Hylo[hylo] model is largely equivalent to
this model and it requires borrow checking technology.
let
and
inout
parameter directives use
mutable value semantics to ensure memory safety for objects passed by
reference into functions. But Hylo also supports returning references in
the form of subscripts:
public conformance Array: Collection {
...
public subscript(_ position: Int): Element {
let {
precondition((position >= 0) && (position < count()), "position is out of bounds")
(at: position).unsafe[]
yield pointer_to_element}
inout {
((position >= 0) && (position < count()), "position is out of bounds")
precondition&(pointer_to_element(at: position).unsafe[])
yield }
}
}
Subscripts are reference-returning coroutines. Coroutines
with a single yield point are split into two normal functions: a ramp
function that starts at the top and returns the expression of the yield
statement, and a continuation function which resumes after the yield and
runs to the end. Local state that’s live over the yield point must live
in a coroutine frame so that it’s available to the continuation
function. These Array
subscripts
don’t have instructions after the yield, so the continuation function is
empty and hopefully optimized away.
template<typename T>
struct Vec {
const T% operator[](size_t idx) const % safe;
% operator[](size_t idx) % safe;
T};
The Hylo Array
subscripts are
lowered to reference-returning ramp functions exactly like their C++
Vec
counterparts. For both
languages, the borrow checker relates lifetimes through the function
arguments and out the result object. This isn’t the simple safety of
second-class references/mutable value semantics. This is full-fat live
analysis.
Safe references without lifetime annotations shields users from dealing with a new degree of freedom, but it doesn’t simplify the static analysis that upholds lifetime safety. To prevent use-after-free defects, compilers must still lower functions to mid-level IR, compute non-lexical lifetimes[nll] and solve the constraint equation. When it comes to returning references, in for a penny, in for a pound.
Since Circle has already made the investment in borrow checking, adding simplified safe references was an easy extension. If the community is able to fill in our gaps in knowledge around this sort of reference, the compiler could accommodate those advances as well.
As detailed in the Safe C++[safecpp] proposal, there are four categories of memory safety:
T^
that take
lifetime arguments. Both types can be used in the same translation unit,
and even the same function, without conflict.send
and
sync
interfaces account for which
types can be copied and shared between threads.Most critically, the safe-specifier is added to a function’s type. Inside a safe function, only safe operations may be used, unless escaped by an unsafe-block.
C++ must adopt a new standard library with a safe API, which observes all four categories of safety. We need new tooling. But it’s not the case that we have to rewrite all C++ code. Time has already shaken out most of the vulnerabilities in old code. As demonstrated by the recent Android study on memory safety[android], the benefits of rewriting are often not worth the costs. What we have to prioritize is the transition to safe coding practices[safe-coding] for new code.
The presented design is as far as I could go to address the goal of “memory safety without lifetime parameters.” But safe references aren’t yet powerful enough to replace all the unsafe mechanisms necessary for productivity in C++. We need support for safe versions of idioms that are central to the C++ experience, such as:
string_view
and
span
.Let’s consider RAII types with reference semantics. An example is
std::lock_guard
,
which keeps a reference to a mutex. When the
lock_guard
goes out of scope its
destructor calls unlock
on the
mutex. This is a challenge for safe references, because safe reference
data members aren’t supported. Normally those would require lifetime
parameters on the containing class.
Robust support for user-defined types with reference data members
isn’t just a convenience in a safe C++ system. It’s a necessary part of
interior mutability, the core design pattern for implementing
shared ownership of mutable state (think safe versions of
shared_ptr
).
What are some options for RAII reference semantics?
drop
keyword). The
destructor calls the mutex unlock.It makes sense to strengthen safe references to support current RAII
practice. How do we support safe references as data members? A
reasonable starting point is to declare a class as having safe
reference semantics. class name %;
is a possible syntax. Inside these classes, you can declare data members
and base classes with safe reference semantics: that includes both safe
references and other classes with safe reference semantics.
class lock_guard % {
// Permitted because the containing class has safe reference semantics.
::mutex% mutex;
std2public:
~lock_guard() safe {
.unlock();
mutex}
};
The constraint rules can apply to the new
lock_guard
class exactly as it
applies to safe references. Returning a
lock_guard
constrains its lifetime
by the lifetimes of the function arguments. Transitively, the lifetimes
of the data members are constrained by the lifetime of the containing
class.
Unfortunately, we run into problems immediately upon declaring member functions that take safe reference objects or safe reference parameter types.
class string_view %;
template<typename T>
class info % {
// Has reference semantics, but that's okay because the containing class does.
string_view sv;public:
void swap(info% rhs) % safe;
};
Consider an info
class that has
safe reference semantics and keeps a
string_view
as a data member. The
string_view
also has reference
semantics, so it constrains the underlying string that owns the data.
Declare a non-static member function that binds the implicit object with
the %
ref-qualifier and also takes an
info
by safe reference. This is
uncharted water. The implicit object type
info
has reference semantics, yet
we’re taking a reference to that with
swap
call. We’re also taking a
reference to info
in the function
parameter. How do we deal with references to references? The existing
constraint rules only invent a single lifetime: if we used those, we’d
be clobbering the lifetime of the inner
string_view
member.
There’s a big weakness with the safe reference type
T%
: it’s
under-specified when dealing with references to references. We need a
fix that respects the lifetimes on the class’s data members.
lifetimes5.cxx – (Compiler Explorer)
#feature on safety
class string_view/(a) {
// Keep a borrow to a slice over the string data.
const [char; dyn]^/a p_;
public:
};
class info/(a) {
// The handle has lifetime /a.
/a sv;
string_view
public:
void swap(self^, info^ rhs) safe {
= self->sv;
string_view temp ->sv = rhs->sv;
self->sv = temp;
rhs}
};
void func/(a)(info/a^ lhs, info/a^ rhs) safe {
.swap(rhs);
lhs}
void func2(info^ lhs, info^ rhs) safe {
.swap(rhs);
lhs}
Rust and Safe C++ have a way to keep the lifetime of the
string_view
member distinct from the
lifetimes of the self
and
rhs
references: lifetime parameters.
func
assumes that the
string_view
s of its parameters come
from sources with overlapping lifetimes, so it declares a lifetime
parameter /a
that’s common to both parameters. The lifetimes on the two references
are created implicitly by elision, as they don’t have to be related in
the swap
call.
func
compiles and doesn’t clobber
the lifetimes of the contained
string_view
s.
safety: during safety checking of void func2(info^, info^) safe
error: lifetimes5.cxx:26:12
lhs.swap(rhs);
^
function void func2(info^, info^) safe returns object with lifetime #0, but #0 doesn't outlive #2
error: lifetimes5.cxx:26:3
lhs.swap(rhs);
^ function void func2(info^, info^) safe returns object with lifetime #2, but #2 doesn't outlive #0
Compiling func2
raises borrow
checker errors. Instead of providing explicit lifetime annotations that
relate the lifetimes of the lhs
and
rhs
info
types, lifetime elision create
four distinct lifetimes:
#0
for the
lhs
info
,
#1
for the
lhs
info^
,
#2
for the
rhs
info
and
#3
for the
rhs
info^
. The
lhs.swap(rhs)
call relates the lifetimes of the operands through the common lifetime
/a
. But
these lifetimes aren’t related! The compiler has no information whether
#0
outlives
#2
or vice
versa. Since the lifetimes aren’t related in
func2
’s declaration, the program is
rejected as ill-formed.
This contrasts with the safe reference constraint rules, which would
assign the same lifetime to all four lifetime binders and clobber the
string_view
lifetimes, causing a
borrow checker failure further from the source and leaving the developer
without the possibility of a fix.
If there’s a community-wide research effort among compiler experts to evolve safe references it may be possible to get them into a state to support the abstractions most important for C++. But soundness reasoning is very subtle work. As you increase the indirection capabilty of safe references, you invite networks of dependencies of implied constraints and variances. This increases complexity for the compiler implementation and puts a mental burden on the authors of unsafe code to properly uphold the invariants assumed by safe references. A research project must produce soundness doctrine, which is essential guidance on how to interface safe and unsafe systems while upholding the soundness invariants of the program.
But we don’t have to do the research. There’s already a solution that’s been deployed in a successful production toolchain for a decade: lifetime parameters as used in Rust. The soundness doctrine for writing unsafe code that upholds the invariants established by lifetime parameters is described in the Rustnomicon[rustnomicon].
This is the only known viable solution for first-class safe references without garbage collection. It’s a critical lifeline that addresses an existential problem facing C++. By adopting lifetime parameters, C++ can achieve safety parity with the security community’s favored languages.
Consider common objections to Rust’s lifetime-annotation flavor of borrow checking:
It’s not surprising that the C++ community hasn’t discovered a better way to approach safe references than the lifetime parameter model. After all, there isn’t a well-funded effort to advance C++ language-level lifetime safety. But there is in the Rust community. Rust has made valuable improvements to its lifetime safety design. Lots of effort goes into making borrow checking more permissive: The integration of mid-level IR and non-lexical lifetimes in 2016 revitalized the toolchain. Polonius[polonius] approaches dataflow analysis from the opposite direction, hoping to shake loose more improvements. Ideas like view types[view-types] and the sentinel pattern[sentinel-pattern] are being investigated. But all this activity has not discovered a mechanism that’s superior to lifetime parameters for specifying constraints. If something had been discovered, it would be integrated into the Rust language and I’d be proposing to adopt that into C++. For now, lifetime parameters are the best solution that the world has to offer.
The US government and major players in tech including Google[secure-by-design] and Microsoft[ms-vulnerabilities] are telling industry to transition to memory-safe languages because C++ is too unsafe to use. There’s already a proven safety technology compatible with C++’s goals of performance and manual memory management. If the C++ community rejects this robust safety solution on the grounds of slightly inconvenient lifetime annotations, and allows C++ to limp forward as a memory-unsafe language, can it still claim to care about software quality? If the lifetime model is good enough for a Rust, a safe language that is enjoying snowballing investment in industry, why is it it not good enough for C++?
Finally, adoption of this feature brings a major benefit even if you personally want to get off C++: It’s critical for improving C++/Rust interop. Your C++ project is generating revenue and there’s scant economic incentive to rewrite it. But there is an incentive to pivot to a memory-safe language for new development, because new code is how vulnerabilities get introduced.[android] Bringing C++ closer to Rust with the inclusion of safe-specifier, relocation, choice types, and, importantly, lifetime parameters, reduces the friction of interfacing the two languages. The easier it is to interoperate with Rust, the more options and freedom companies have to fulfill with their security mandate.[rust-interop]