Document #: | |
Date: | 2024-11-8 |
Project: | Programming Language C++ |
Audience: |
|
Reply-to: |
Sean Baxter <seanbax.circle@gmail.com> |
Companies ship software that contains security vulnerabilites to millions of customers. For C++ products, 70% of those vulnerabilities would be stopped by a memory-safe language. There’s growing pressure to move off memory-unsafe languages and onto safe languages like Rust, Swift, Java and C#. The US Government is calling for safety roadmaps from big vendors outlining how they’ll migrate to memory-safe languages for new code. The deadline for these roadmaps is coming up: January 1 2026.
What can be done to hasten the migration to safe coding?
I proposed the Safe C++ extension. This overhauls Standard C++ with memory safety capabilities. It implements the same borrow-checking technology as featured in Rust. This is one path for C++ projects to start writing memory-safe code.
A second viable path to safety is through improved Rust interop. The recent study Eliminating Memory Safety Vulnerabilities at the Source demonstrates that old production code contains fewer vulnerabilities than new code. Time has debugged it. Consequently, the best way to reduce vulnerabilities is to put existing code in maintenance mode and write new code in a memory-safe language. This document explores an idea for dramatically reducing interop friction between C++ and Rust. If it’s easy to use C++ code from Rust, developers will be more open to making the transition.
This is a proposal about molding C++ to support all of Rust’s vocabulary types to increase the surface area of interop between the languages.
Operating systems expose functionality through C APIs. Standard libraries, for any language, are built atop these system APIs. Interoperability with C is very easy for language developers. There’s no overloading of declarations. There are no templates or generics. Structs have straight-forward layout rules that are no challenge to implement.
For the purpose of compilers, C’s ABI is just the parameter-passing convention of the platform your program targets. Unix-like systems follow the ELF object file conventions. Each CPU architecture has an ELF or System V supplement that specifies struct layouts, parameter passing and object file definitions.
Compilers implement these processor-specific conventions. With
x86-64, for example, this entails recursively categorizing the fields of
structs that appear as function parameters into
POINTER
,
INTEGER
and
SSE
classes and mapping those
classes to available general-purpose and SSE2 registers. It’s fairly
involved and in the weeds, but C abstracts these concerns from the user.
If you code against the C language, your software should compile for
many operating systems and hardware architectures.
Languages provide a way to define C-layout structs. Compilers implement the parameter-passing conventions for each target. Voilà. C interoperability.
To call C functions, you don’t need much. To call C++ functions you need all the intelligence of a C++ frontend. There are a lot of factors that contribute to making C++ interoperability a colossal challenge.
C++ is a big knot that can’t be untangled. If each language feature is hitch or bend, tugging at one concern just tightens the others.
Let’s examine and three facets of a solution to the interop challenge:
choice
types in Safe C++ parlance)
has access to more Rust APIs than a frontend without that support.
Coverage is about supporting the vocabulary types of another
language.Expose compiler functionality through an API. Using the API, point the compiler at a module or header file to parse it and return a metadata tree of declarations. That’s discovery. Submit a query, such as request for the primary, partial or explicit specialization of a class template and retrieve a result. That’s intelligence. This compiler-as-library is a language server. Rust and C++ compilers can share data and intelligence by utilizing one another as language servers.
Extend Rust with lvalue- and rvalue-references. Permit non-trivial
moves by calling a relocation function rather than memcpying the data.
If Rust has the capability to form lvalue and xvalue expressions
(i.e. expressions with lvalue- and rvalue-reference types) and to call
move constructors, it can start utilizing C++ code. Extend C++ with
first-class borrow types, lifetime parameters,
choice
types, Rust traits and a
safe-specifier. This extends the amount of Rust functionality
that can be used from C++. That’s coverage.
Discovery and intelligence is served across toolchains using language servers. Coverage widens capabilities within a toolchain with language extensions.
In the Safe C++ proposal
I introduced a new std2
standard
library. The containers are designed with borrows, lifetime parameters
and relocation semantics to provide rigorous memory safety. But the
excellent Eliminating
Memory Safety Vulnerabilities at the Source study out of Google made
me reconsider this design choice. The study makes a strong case that
rather than worrying about rewriting C++ code, the best strategy for
improving software quality is to focus on a quick transition to
memory-safe languages.
std2
safe standard library.Extending C++ to natively use Rust’s standard library directly improves interoperability.
Consider using the discovery, intelligence and coverage facets to model a toolchain where Rust declarations can be used from directly C++ without bridge code:
#[repr(C)]
are guaranteed compatible with C layout. Therefore, struct layout is
part of the discovery data.Prioritize a list of features to improve C++’s coverage with Rust entities:
T^
and
const T^]
(https://safecpp.org/draft.html#lifetime-safety).^
and ^const
.safe
.self
function
parameter. Enables self-consuming functions.choice
.(T1, T2)
.[T; dyn]
and [T; N]
.trait
and
impl
.These are profound extensions to C++. Existing compilers need all-new mid-level IR subsystems to perform non-lexical initialization analysis and borrow checking. Additionally, ingesting Rust standard library and all Rust code is a big shift for C++ projects.
Let’s go in the other direction and use C++ entities from Rust:
Prioritize a list of features to improve C++’s coverage with Rust entities. This is more modest than the C++ extensions, because there’s a desire maintain the relative simplicity of Rust. The C++ coverage can be considered an interop extension rather than “core language.”
std::vector
has two push_back
overloads. One
takes a const T& value
and the other takes T&& value
.
The former overload copies the parameter and the latter overload moves
it. Efficient usage of C++ requires differentiation between
lvalue-reference accepting functions and rvalue-reference accepting
functions. The addition of lvalue- and rvalue-references don’t imply any
change to Rust’s object model.operator rel
relocation
constructor in Safe C++.We won’t be able to define in Rust all functions previously declared in C++, since some function prototypes involve language entities that extended Rust doesn’t have coverage for. Overloading is supported, but templates aren’t. But that should be okay. You can still use C++ types and functions directly from Rust. The C++ language server is responsible for evaluating the semantics around function calls and template specializations.
The C++ Standard doesn’t prescribe parameter-passing conventions. That’s left to the platform ABI. On Unix-like platforms, the Itanium C++ ABI stipulates that callers are responsible for calling destructors on function arguments.
If the type has a non-trivial destructor, the caller calls that destructor after control returns to it (including when the caller throws an exception).
– Itanium C++ ABI: Non-Trivial Parameters[https://itanium-cxx-abi.github.io/cxx-abi/abi.html#non-trivial-parameters]
fn f1(s:String) {
// s is owned and destroyed by f1.
}
fn f2(s:String) {
// s is owned by f2.
// s is relocated to f1. It's no longer owned by f2.
;
f1(s)
// s is not destroyed because it's not an owned place.
}
Rust performs relocation on objects that are
non-Copy
. Relocating
s
from
f1
into
f2
leaves the
s
parameter uninitialized. Drop
instructions for local objects with non-trivial destructors are emitted
when a function is lowered to MIR, but a subsequent drop
elaboration pass eliminates drops for places that are
uninitialized.
In Rust, callees destroy parameter objects. This is necessary since a parameter may be relocated or dropped before the end of its scope. Calling a Rust function with the C++ convention risks a double-free: from C++, the caller would destroy the argument; from Rust, the callee would destroy the parameter.
C++ needs an alternate calling convention to support Rust’s affine
type system. The Safe C++ draft discusses [function parameter
ownership], proposing a __relocate
calling convention that gives ownership of parameters to callees.
Almost all C++ container types are trivially relocatable without
knowing it. Important types like
unique_ptr
,
shared_ptr
and
vector
are trivially relocatable.
Their declarations could be marked with a [[trivially_relocatable]]
attribute for compatibility with Rust’s relocation semantics.
Unfortunately, the libstdc++ version of
std::string
is not trivially relocatable. It implements a small-string optimization
that maintains a pointer back into its own storage. Move construction
and move assignment reset the small-string pointer back to local
storage. Trivial relocation would leave a dangling pointer. The idea was
to get rid of a branch when calling the std::string::data()
member function. But this optimization makes for a pretty wasteful
implementation.
std::string
weighs 32 bytes
(std::vector
is 24 bytes) but only has a local capacity for strings of 15 characters
or fewer.
This is not the first troublesome string. There was a previous
libstdc++
std::string
that used copy-on-write
to deliver cheap string copies. This was no good, because using the
non-const operator[]
function would technically invalidate the string, spawning a copy of the
data if the string didn’t have exclusive ownership. (A pity. Why are you
even allowed to modify strings like that?)
The move away from the copy-on-write string was one of the few ABI
breaks in C++ history. Rust’s avoidance of a stable ABI makes it easier
to change library implementations to satisfy new requirements. But C++
has a stable ABI, for better or worse, and you have to play it as it
lays. Thanks to
std::string
and the transitive property of containment, non-trivial relocation is a
necessary buy-in for Rust to support move semantics for many C++
types.
The Swift team has been working for several years improving C++ interop. Their effort also embeds a C++ compiler (which is Clang) into the Swift toolchain. There’s no way to interop with C++ without embedding a C++ frontend.
The question of how much C++ coverage to incorporate in Swift is one that the engineers are wrestling with.
- Functions and constructors that use r-value reference types are not yet available in Swift.
- Swift supports calling some C++ function templates.
- Any function or function template that uses a dependent type in its signature, or a universal reference (T &&) is not available in Swift.
- Any function template with non-type template parameters is not available in Swift.
- Variadic function templates are not available in Swift.
The Swift language remains slightly smaller at the cost of not being able to use a large amount of C++. Without access to move semantics, it’s really not able to use any of it efficiently. Is this tradeoff worth it? I don’t think so. I don’t advocate a maximalist approach to extending Rust with C++ capabilities (although I do favor maximalism in the other direction), but I am convinced that a few strategic extensions to Rust will have enormous payoff for a quality interop experience.
- Enumerations that have an enumeration case with more than one associated value [are not yet supported]
Swift didn’t extend its embedded C++ compiler with first-class enum
types. Therefore, the C++ side can’t use Swift enums with more than one
associated value. Enums are a flagship feature for both Rust and Swift.
I think it’s worth it to extend the C++ side to fully support them. Safe
C++ has first-class choice
types with pattern matching. While maintaining these extensions is a
burden for C++ tooling engineers, the goal of interop isn’t to make
their life easier, it’s to make everyone else’s life easier.
C++ exception handling is a major source of friction when dealing
with Rust interop. But it doesn’t have to be. Rust is 99% of the way to
supporting C++ exceptions. When compiled with -C panic=unwind
,
which is the default, Rust functions are all potentially
throwing. When lowered to MIR and then to LLVM, function calls have
a normal edge leading to the next statement and a cleanup
edge that catches the exception, calls the destructor for all
in-scope objects with non-trivial drops, and resumes unwinding.
This is exactly what C++ does.
struct HasDtor {
int i;
~HasDtor() { }
};
// Potentially throwing. (i.e. not noexcept)
void may_throw() { }
int func() {
{ };
HasDtor a
// On the cleanup edge out of may_throw, run a's dtor.
();
may_throw
return 1;
}
define dso_local noundef i32 @func()() #0 personality ptr @__gxx_personality_v0 !dbg !15 {
%1 = alloca %struct.HasDtor, align 4
%2 = alloca ptr, align 8
%3 = alloca i32, align 4
call void @llvm.dbg.declare(metadata ptr %1, metadata !20, metadata !DIExpression()), !dbg !28
%4 = getelementptr inbounds %struct.HasDtor, ptr %1, i32 0, i32 0, !dbg !29
store i32 1, ptr %4, align 4, !dbg !29
invoke void @may_throw()()
to label %5 unwind label %6, !dbg !30
5:
call void @HasDtor::~HasDtor()(ptr noundef nonnull align 4 dereferenceable(4) %1) #4, !dbg !31
ret i32 1, !dbg !31
6:
%7 = landingpad { ptr, i32 }
31
cleanup, !dbg !%8 = extractvalue { ptr, i32 } %7, 0, !dbg !31
store ptr %8, ptr %2, align 8, !dbg !31
%9 = extractvalue { ptr, i32 } %7, 1, !dbg !31
store i32 %9, ptr %3, align 4, !dbg !31
call void @HasDtor::~HasDtor()(ptr noundef nonnull align 4 dereferenceable(4) %1) #4, !dbg !31
br label %10, !dbg !31
10:
%11 = load ptr, ptr %2, align 8, !dbg !31
%12 = load i32, ptr %3, align 4, !dbg !31
%13 = insertvalue { ptr, i32 } poison, ptr %11, 0, !dbg !31
%14 = insertvalue { ptr, i32 } %13, i32 %12, 1, !dbg !31
i32 } %14, !dbg !31
resume { ptr,
}
declare i32 @__gxx_personality_v0(...)
In C++, in-scope objects with non-trivial destructors are destroyed
by the cleanup block. Here the cleanup block is
6
. The
landingpad
instruction advertises
its intent to cleanup
in-scope
objects. The cleanup block copies out a { ptr, i32 }
pair, which indicates the exception object, calls
HasDtor
’s destructor, and
resumes
on that cached pair. Since
the function participates in exception handling it is associated with
__gxx_personality_v0
, C++’s standard
personality function, which abstracts some even lower-level
exception-handling APIs.
struct HasDtor { i: i32 }
impl Drop for HasDtor {
fn drop(&mut self) { }
}
// Potentially throwing. (i.e. not noexcept)
fn may_throw() { }
fn func() -> i32 {
let _a = HasDtor { i: 1 };
// On the cleanup edge out of may_throw, run a's dtor.
;
may_throw()
return 1;
}
define internal i32 @_ZN5throw4func17hd08044f7eb69f50cE() unnamed_addr #1 personality ptr @rust_eh_personality {
start:
%0 = alloca [16 x i8], align 8
%_a = alloca [4 x i8], align 4
store i32 1, ptr %_a, align 4
; invoke throw::may_throw
invoke void @_ZN5throw9may_throw17hb8b8ce4f5b598848E()
to label %bb1 unwind label %cleanup
bb3: ; preds = %cleanup
; invoke core::ptr::drop_in_place<throw::HasDtor>
invoke void @"_ZN4core3ptr35drop_in_place$LT$throw..HasDtor$GT$17hcc21909492c17e73E"(ptr align 4 %_a) #5
to label %bb4 unwind label %terminate
cleanup: ; preds = %start
%1 = landingpad { ptr, i32 }
cleanup%2 = extractvalue { ptr, i32 } %1, 0
%3 = extractvalue { ptr, i32 } %1, 1
store ptr %2, ptr %0, align 8
%4 = getelementptr inbounds i8, ptr %0, i64 8
store i32 %3, ptr %4, align 8
br label %bb3
bb1: ; preds = %start
; call core::ptr::drop_in_place<throw::HasDtor>
call void @"_ZN4core3ptr35drop_in_place$LT$throw..HasDtor$GT$17hcc21909492c17e73E"(ptr align 4 %_a)
ret i32 1
terminate: ; preds = %bb3
%5 = landingpad { ptr, i32 }
0 x ptr] zeroinitializer
filter [%6 = extractvalue { ptr, i32 } %5, 0
%7 = extractvalue { ptr, i32 } %5, 1
; call core::panicking::panic_in_cleanup
call void @_ZN4core9panicking16panic_in_cleanup17hb5e4521fe5c4d68fE() #6
unreachable
bb4: ; preds = %bb3
%8 = load ptr, ptr %0, align 8
%9 = getelementptr inbounds i8, ptr %0, i64 8
%10 = load i32, ptr %9, align 8
%11 = insertvalue { ptr, i32 } poison, ptr %8, 0
%12 = insertvalue { ptr, i32 } %11, i32 %10, 1
i32 } %12
resume { ptr,
}
declare i32 @rust_eh_personality(i32, i32, i64, ptr, ptr) unnamed_addr #1
Rust does all the same cleanup as C++. In fact, it does more
cleanup, because even its destructors are potentially throwing. C++
destructors are implicitly
noexcept
. In
this Rust example, the cleanup block is called
cleanup
. The
landingpad
instruction expresses the
cleanup
handler and caches the same
{ ptr, i32 }
pair. The cleanup code branches to
bb3
which calls
HasDtor
’s destructor. But that
destructor is also potentially throwing. If the destructor
throws, it’s non-recoverable, since we’re already on the cleanup path.
That cleanup edge jumps to the
terminate
block which calls core::panicking::panic_in_cleanup
.
That function prints “panic in a destructor during cleanup” and aborts.
The normal path out of the destructor branches to
bb4
which resumes stack
unwinding.
If you look closely you may one salient difference: Rust uses the
rust_eh_personality
personality
function. This is closely modeled on the C++ version: rust_eh_personality_impl
.
If Rust’s personality function is actually incompatible with
C++ cleanup (I don’t know if it is or not), it can be replaced by
__gxx_personality_v0
. Additionally,
for consistency with C++ exceptions, Rust’s panic objects could be
allocated with
__cxa_allocate_exception
, the same
storage that backs C++ exceptions. That’s part of libc++abi.
struct S { int i; };
void throw_it() {
throw S { 10 };
}
int main() {
try {
();
throw_it
} catch(S s) {
} catch(int i) {
}
}
%struct.S = type { i32 }
$_ZTS1S = comdat any
$_ZTI1S = comdat any
@_ZTVN10__cxxabiv117__class_type_infoE = external global [0 x ptr]
@_ZTS1S = linkonce_odr dso_local constant [3 x i8] c"1S\00", comdat, align 1
@_ZTI1S = linkonce_odr dso_local constant { ptr, ptr } { ptr getelementptr inbounds (ptr, ptr @_ZTVN10__cx
i64 2), ptr @_ZTS1S }, comdat, align 8
xabiv117__class_type_infoE, @_ZTIi = external constant ptr
; Function Attrs: mustprogress noinline optnone uwtable
define dso_local void @_Z8throw_itv() #0 {
%1 = call ptr @__cxa_allocate_exception(i64 4) #4
%2 = getelementptr inbounds %struct.S, ptr %1, i32 0, i32 0
store i32 10, ptr %2, align 16
call void @__cxa_throw(ptr %1, ptr @_ZTI1S, ptr null) #5
unreachable
}
C++ uses RTTI typeinfo data to identify the type of a thrown
exception. The throw-expression passes a pointer to
_ZTS1S
to
__cxa_throw
. That’s the RTTI
typeinfo structure for class S
.
%8 = landingpad { ptr, i32 }
@_ZTI1S
catch ptr @_ZTIi
catch ptr %9 = extractvalue { ptr, i32 } %8, 0
store ptr %9, ptr %2, align 8
%10 = extractvalue { ptr, i32 } %8, 1
store i32 %10, ptr %3, align 4
br label %11
The try-statement in main indicates the RTTI typeinfo data
for all of its catch-clauses. Rust doesn’t exactly conform to
this convention. Does that create interoperability problems? I’m not
sure. It is the case that C++ can’t catch panic objects. But this is
easy to resolve: emit a C++ RTTI typeinfo struct for the Rust panic type
and point __cxa_throw
at that. This
is a very minor change, if it is necessary at all.
We can unstick one of interop’s most irritating sticking points. C++
exceptions will propagate safely through Rust frames, properly
destroying all in-scope objects. As far as the ability to catch C++
exceptions, coverage could be added to Rust. But since that’s already
part of C++, you may as well do it there: write your
catch
/throw
handler on the C++ side. Interop will let you return
Result
or any other Rust type.
It would be useful to have a textual representation for C++ code in
Rust and Rust code in C++. It would be truly ideal for a single
toolchain do everything: imagine implementing a full C++ frontend inside
rustc
. There’d be a single type
system and single AST to express entities from both languages.
But that’s not where we are and absolutely seamless tooling isn’t really the goal. Our aim is to accelerate the migration from memory-unsafe to memory-safe languages. Both the C++ and Rust frontends can gradually push out coverage to be less reliant on one another’s language servers. But in the short term, utilizing two frontends in one toolchain can help reduce interop friction and improve code quality.
Adopting Rust’s standard library as the de-facto standard library in
a two-language project is the right design. While C++’s unsafe standard
library can be used from Rust, perhaps with less-than-seamless
ergonomics (thanks to every function being
unsafe
), the goal is to write
safe code with safe containers, not write more unsafe code with
unsafe containers.
Providing a juiced-up version of C++, which is essentially a superset of Rust, lends developers a lot of freedom for interfacing new and old code. The language boundary isn’t a hard boundary. Rust types can permeate into the C++ side and C++ types can permeate into the Rust side. And that is possible without any interop annotations.
I like the idea of bi-directional language servers. I have a liberal attitude towards bold, comprehensive language extensions to increase interop surface area. Reducing interop friction is the best chance for quickly drawing C++ developers to a memory-safe toolchain.