It's the people that matter

Over the past two years, I've been involved with designing and implementing a new feature of rustc, called "externally implementable items". This is not a finished feature, though if you'd like you can try it on nightly already! This is the story of how externally implementable items were invented, implemented, and how some day they might be stabilized. I'm sure that is interesting to some of those who read this.

That's not the main thing this blog post is about, though. Instead, I'm using it as an example, to show you how the Rust project operates. To show how many people are involved with a change, how they learn from each other, how I've learned so much from them myself. An example of how important social interaction is to open source. Much more than writing the code itself.

This is a written-out version of a talk I gave at Rust in Paris 2026.

Press for table of Contents

The problem

This story starts, almost two years ago, in the days after RustNL 2024. An RFC was opened by m-ou-se's github avatar m-ou-se called Externally Implementable Functions, which was actively being discussed. The idea is as follows:

In Rust, the ordering of modules doesn’t matter. As long as a function is visible enough (using pub qualifiers), any function can call any other function, regardless of definition order or location in the module tree. Though it’s probably not a great idea to call into this code, the following code does compiles in rust:

1
fn foo() {
2
bar();
3
}
4
5
fn bar() {
6
foo();
7
}

That’s not the way that, for example, C works. In C, the global scope is not order-independent, and declarations are processed top-top-bottom. That means that you can only call a function after it’s declared. The way to work around this is by using what’s called a forward declaration: you can declare the type of a function first, without giving a definition. Then, other functions can use the declaration as a promise that a definition of a certain type will exist somewhere, and finally the definition can be given satisfying the declaration.

In C, you’d have to write the following to achieve something similar to the earlier Rust example:

1
// forward declaration, no body
2
void bar();
3
4
void foo() {
5
bar(); // use the forward declaration
6
}
7
8
// definition, with a body
9
void bar() {
10
foo();
11
}

In C it is common to group such forward declarations in header files, but in Rust we don’t need header files within a single crate, because the global scope is order-independent.

While rust’s global scope is order-independent, the crate graph is not, which instead must be a directed acyclic graph (DAG). Oops! Now we actually have the exact same problem as C does. You might imagine that a crate would want to “forward declare” a function, which another crate can implement. But in Rust we currently can’t, and that’s what “Externally Implementable Functions” solves, by introducing an extern impl fn:

1
// In a crate called `log`
2
extern impl fn logger() -> Logger;

These can be implemented in another crate using:

1
// In a dependent of `log`
2
impl fn log::logger() -> Logger {
3
Logger::to_stdout().with_colors()
4
}

This proposal caused a lot of opinions. As RFCs sometimes tend to do, what started as a Request For Comments turned into Rustaceans Commenting Forever. Both on the RFC PR and in-person, since so many of us were together at the conference. Only a few days later, this discussion led to another RFC to be opened, called Externally Definable Statics. It proposes introducing an extern static. This would be equally powerful, since a static can contain a function pointer, or alternatively just a struct with methods on it:

1
// In a crate called `log`
2
extern static LOGGER: Logger;

Another crate can then assign to this static:

1
// In a dependent of `log`
2
impl static log::LOGGER = Logger::to_stdout().with_colors();

When a crate defines externally implementable items, either in the form of statics or functions, you could see this as if a crate exports a trait, with several associated items. A downstream crate has to implement that trait for compilation to succeed. Both of the proposals so far even use the same keyword, impl. So, after even more discussion, a third proposal was made, in yet another RFC called Externally Implementable Traits, where a crate can define an extern trait;

1
// In a crate called `log`
2
extern trait GlobalLogger {
3
fn logger();
4
}

Which another crate can then implement:

1
extern impl log::GlobalLogger {
2
fn logger() -> Logger {
3
Logger::to_stdout().with_colors()
4
}
5
}

What this gains us is that it allows externally implementable items to be grouped together.

Avoiding bikeshedding

At this point, I think the discussion saturated a little. None of the three proposals is obviously better, with most of the differences being syntactic. And without an actual implementation, we were mostly bikeshedding, which wouldn’t get us anywhere. So in the summer of 2024, to sidestep that discussion completely, m-ou-se's github avatar m-ou-se and I sat down, came up with a fourth implementation. Yes, yes, competing standards and all that.

But I don’t think requesting more comments at that point were helpful. If we had an implementation to play around with we could have a much more meaningful discussion, instead of speculating about arbitrary trade-offs.

Going forward with option 4

If you didn’t know, there are actually already two externally implementable items in Rust. If you’ve done embedded development before you’ll have certainly encountered them. These are the panic handler, and the global allocator.

Every program in Rust must have a panic handler, which is the function that gets executed when a program panics. panic!() is defined in core, but core doesn’t necessarily know what to do when a panic happens. Instead, one crate in the crate graph must provide a panic handler to core, marked with the #[panic_handler] attribute, like this:

1
#[panic_handler]
2
fn my_panic_handler(pi: &PanicInfo) -> ! {
3
loop {}
4
}

If you’ve done embedded programming you may have seen this, though usually the implementation of the panic handler is given by std. There’s one thing that core does know however, which is the function signature that the panic handler will have.

So what happens is that any code that panics, will call a function with predetermined linker symbol. When rustc finds a function that’s annotated with #[panic_handler], it checks that the signature is correct, and that no other panic handler already exists. Then, it generates that function with that same known linker symbol. Finally, the linker sees the two symbols, and makes sure that the final program runs.

So in a way, core is using a forward declaration of the panic handler.

The allocator works similarly, with the #[global_allocator] attribute. This attribute actually corresponds with four individual “externally implementable functions” that also get known linker symbols:

1
fn __rust_alloc(size: usize, align: usize) -> *mut u8;
2
fn __rust_dealloc(ptr: *mut u8, size: usize, align: usize);
3
fn __rust_realloc(ptr: *mut u8, old_size: usize, align: usize, new_size: usize) -> *mut u8;
4
fn __rust_alloc_zeroed(size: usize, align: usize) -> *mut u8;

Both the panic handler and global allocator have their own, dedicated, code paths in the compiler. So the way we started implementing Externally Implementable Items (which I will from now on call EIIs), was by generalizing these existing two code paths.

What this gains us is twofold: we have less complexity spread throughout the compiler, and the declaration of these EIIs becomes explicit in core. Instead of being compiler magic, we can document the signature, refer to it by name. That last point is important actually, by making EIIs participate in name resolution we can properly mangle their symbol names. They no longer have to be predetermined.

The design

An externally implementable function, is a function signature without a body, plus an attribute that marks another function that provides the body. In the case of the panic handler, core would write:

1
#[eii] // <- mark this signature as externally implementable
2
fn panic_handler(_: &PanicInfo) -> !;

And now other code can write:

1
#[core::panic::panic_handler]
2
fn my_panic_handler(_: &PanicInfo) -> ! {
3
loop {}
4
}

What’s neat about this is that it’s backwards compatible with the existing panic handler attribute (we would add #[panic_handler] to the prelude, i.e. imported from core by default).

#[eii] itself expands into two parts, a foreign function and an attribute macro:

1
unsafe extern "Rust" {
2
safe fn panic_handler(_: &PanicInfo) -> !;
3
}
4
5
macro panic_handler() { /* compiler generated */ }

This macro syntax might be unfamiliar to you. It’s an unstable alternative to macro_rules!, that has more sane name resolution behavior. That’s nice since it’s automatically generated by the #[eii] attribute. We use this syntax in some places in core and std as well, though there’s a chance it will never be stabilized. We might never stabilize that syntax, but inside the compiler it gives us a consistent way to define attribute macros.

Rust allows the same name to be in scope multiple times, as long as they are in different namespaces. So, the fact that there’s now a macro and a function with the same name is actually not a problem. When you call panic_handler, you call the foreign function, and when you write #[panic_handler], you refer to the macro. If you want to explicitly avoid the ambiguity though, we also support #[eii(macro_name)] to explicitly set the macro’s name. For example:

1
#[eii(panic_handler)] // <-- still generates the `#[panic_handler]` attribute
2
fn call_to_panic(_: &PanicInfo) -> !;

This also makes it possible to re-export the macro and not the function, or the other way around.

Shaving a yak while we’re at it

To name resolve EIIs properly, there’s one last step missing: name resolution has to understand that this macro and foreign function belong together somehow. So, the actual expansion looks more like:

1
unsafe extern "Rust" {
2
safe fn function_panic_handler(_: &PanicInfo) -> !;
3
}
4
5
#[eii_macro_for(function_panic_handler)] // link the two together
6
macro macro_panic_handler() { /* compiler generated */ }

It turns out, that implementing #[eii_macro_for()] is anything but simple. In fact, Rust somewhat fundamentally doesn’t support doing name resolution inside an attribute. Attributes themselves can be resolved, but nothing in their contents, since the contents of attributes is represented as raw tokenstreams, similar to what proc macros get.

In Rust, name resolution works by giving every identifier an id, called a node id. We then construct a hashmap that links references to definitions, identified by these ids. Giving every token in a tokenstream a node id turned out to be completely infeasible. We’d need to completely redo how attributes were represented in the compiler.

So, naturally… that’s what we did!

This attempt at avoiding RFC-bikeshedding turned into one of the larger yaks I’ve ever shaved. I’ve given a talk about this project at Eurorust in 2025 if you’re interested.

The initial PR took about a week of implementation work, and then a month of convincing various people to actually go forward with this. That involved finding the right reviewers for this massive change; figuring out in what order to merge things since a single PR was much larger than could be asked from someone to review; convincing people that a rewrite would actually be good, since the initial PRs actually temporarily made the situation worse, which meant promising that I wouldn’t just disappear after the initial PR merged.

At the time of writing, March 2026, we’ve finally converted the parsing infrastructure for all of rust’s roughly 200 different built-in attributes over the course of 250 or so pull requests, created through the combined effort of more than 20 people. What’s awesome, I think, is that this refactor was an amazing source of “good first issues”, tasks that beginners could solve. Of those 20 people, several had never or rarely contributed to the rust compiler before. And through the attribute refactor, one of those ( jonathanbrouwer's github avatar jonathanbrouwer ) is now one of our newest compiler team members, starting their own projects in the rust compiler. I’ve seen other contributions from other first-time contributors as well, and I wouldn’t be surprised if the compiler team will grow more as a result.

I’m not a big fan of large language models, for various reasons. Still, I’m sure some of these smaller issues could have been solved by an LLM. But clearly, that wouldn’t have had the same effect here! Open source contribution isn’t just about solving bugs. Naturally, people come and go at public projects like the rust project, and sometimes that creates problems where there’s sometimes nobody around who knows how to work on some corner of the compiler. Therefore, to keep maintenance feasible, it’s important that new people are constantly being taught about how to work on the compiler.

One of the moments where this happens is in pull request review, which I see as a learning opportunity for both the author and the reviewer. I really enjoyed being added to the review rotation of rustc, because it meant that I got to see some parts of the compiler I had never seen before. If the author is an LLM, or in large parts defers to LLMs, this becomes entirely one-sided. As a reviewer, you’re not teaching a person, you’re talking to an LLM. And it’s the other way around too. If review often defers to an LLM, they don’t learn either.

And sure, it’s different when an author defers all interactions to an LLM, compared to when they just use it as a tool. Maybe to generate some boilerplate code, or to search and summarize something. But I truly believe that if we generate all our boilerplate code with an LLM, we stop investing in tools to reduce having to type boilerplate. And time and time again, it’s been shown that you learn much more from summarizing yourself. That’s true in general, but was also recently shown by the TU Eindhoven in the context of large language models.

I hope this story shows you that we can’t operate like that. That’s not how we can keep maintaining the Rust project, or any open source project for that matter. If we stop learning from each other, the world grinds to a halt. And that’s what makes me more willing to invest my time knowing that another party is learning from it. Maybe that’s why I liked being a teacher before I worked on rustc :3

Linking it all together

Externally implementable items are, fundamentally, a linker feature. In rust, rustc processes each crate one by one, passing down only a .rmeta file down to the next crates with metadata for crates that depend on it. This is why rust’s crate graph has to be a DAG. There’s no way to pass any information back up the graph.

Rust has to deal with this at various points. For example, with generics, rust does what’s called monomorphization. It re-instantiates every generic function with its type parameters, and re-codegens it. Since an upstream crate doesn’t yet know what instantiations will exist downstream, it cannot do codegen for generic functions. Thus, one thing .rmeta files contain is information on how to instantiate and codegen all generic functions of a crate.

EIIs are like forward declarations. An upstream crate has to assume a downstream one provides the item, and worst case it will only find out whether it was provided when linking it all together. To avoid linker errors, we pass information down the crate graph of whether we’ve already seen an implementation. If a crate sees that two different crates provide an EII, then we error out with a nice rust error, avoiding the linker error.

Defaults

This gets slightly trickier when you introduce EII-defaults, Let’s use logging as an example again though; using an EII default would look like simply adding a body to the declaration:

1
#[eii]
2
fn logger() -> Logger {
3
Logger::default()
4
}

If the user never implements the EII, the default is used. This is a feature we’d need for the global allocator. By default, rust uses the system allocator, unless user code overrides that.

What makes this harder is that there needs to be a point, at the end of compilation, where we decide “oh, we haven’t seen an explicit implementation”, and decide to compile the default function instead. At first I hoped this would be as easy as looking at the crate type, classifying them into “final” and “non-final”. A crate that generates a binary is final for example, so when we’re compiling a binary crate, and we haven’t seen an explicit implementation yet, put the default in.

Now that breaks down when you consider that there are other crate types. For example, what do we do for a cdylib? Is it final? We do still intend to link it to other code, but not necessarily with the rust compiler. So if we don’t insert the default, we might not get the chance again. But if we do, it might cause linker errors down the line.

Now this gets worse when you consider that Rust can sometimes, in one go, generate both an rlib and a cdylib. It is very hard to make it such that Rust inserts symbols in only one of the two. It’s either both, or none.

Asking the linker, nicely

Now, so far, these are technically solvable challenges, with more engineering time. But during Rust Week 2025, I sat down with ojeda's github avatar ojeda , from Rust for Linux. They’re using rustc a little bit like a C compiler: generating object files, which they then link themselves to C code from the kernel. Or, I think that’s roughly what was going on. I also learned that this is a concern in other places, amanieu's github avatar amanieu told me google is doing things similarly? In fact, anytime you’re using a custom external build system like bazel or buck2 that wants to be in charge of creating the the final binary, this strategy doesn’t work. The important difference with Rust here is that rust likes to drive the linker, and so if an external build system does this driving it doesn’t work as well!

So our only option seems to be to resolve EII defaults when linking. Fortunately, almost all linkers support what’s called weak linker symbols. Weak symbols are simply de-prioritized by the linker, and when another non-weak symbol of the same name is found it is preferred. This avoids duplicate definition errors, and neatly matches the behavior of defaults for EIIs.

Several months later, I was talking to cramertj's github avatar cramertj . She was interested in how EIIs would interact with foreign function interfaces (FFI). Specifically compatibility with C++. We concluded that it would be extremely desirable if we can guarantee that EIIs desugar to weak linker symbols. That way, code written in C and C++ can easily interact with EIIs, even implement them from over an FFI, since all the resolution actually happens when the C and Rust code are linked together.

These are the kinds of discussions that make it so useful to have the various Rust conferences and to have an all-hands again in May (a couple of days where all the rust project members come together). Furthermore, I think that these stories are actually great news. Where a year earlier we were bikeshedding and speculating, now we are having focused discussions about requirements of the final implementation.

The road to stabilization

At the end of December, this version of EIIs, with weak symbols for defaults, merged into the main branch of the compiler. That means that it’s available on nightly, just add #![feature(extern_item_impls)]. Though keep in mind that it currently only works on linux and macOS. The latter turned out to be a little trickier since Mach-O binaries support code for multiple targets in a single file.

Now that there is a base implementation, it’s also much easier for people to contribute to the project going forward. In fact, there’s a Tracking Issue on the rust-lang/rust repository to track what still needs to happen. And simply by communicating the list of TODOs clearly, just like before with attributes, people indeed started helping out again. And I’ve once again been enjoying that thoroughly, seeing people learn to contribute to new parts of the compiler.

To encourage that more, in December we restarted organising what’s called the “compiler office hours”. Because all the people that work on the compiler live spread-out all over the world, we don’t end up talking to each other too much except in reviews. Unlike working together in an office, rust has no hallway where people talk about what they’ve been busy with today.

So now we have a call, twice a week, with whoever can make it. All kinds of people join, some I had never talked to before. Not the same people every week, but that’s what makes it fun. Every time we meet, I learn a tiny bit of what people are up to, the things that people have been proud of working on, or are completely stuck on. Bits of progress, related to codegen backends, LTO, miri, reflection, the never type, automatic differentiation, pattern matching, rustdoc, or just Ben’s pepper plants. Several of the discussions about how to continue with EIIs as a feature have taken place here.

Windows

For example, I’ve been chatting with bjorn3's github avatar bjorn3 somewhat regularly. His knowledge about compiling for Windows, and linkers and code generation in general has been absolutely invaluable. One of the issues that’s still open with EIIs is how we handle Windows. On Windows, weak symbols are supported, but they work slightly differently than on other platforms.

Weak symbols have to be named explicitly in flags, passed to the linker. Giving those is mostly doable, since Windows allows you to pass linker flags in the headers of binaries. Except, LLVM can apparently sometimes further mangle the names of symbols. And the linker flag must exactly match the final, changed, name that LLVM will generate. So we will likely need to make Rust do the same mangling, and predict those symbol names.

Unfortunately, we will have to work this out before we can switch the global allocator to use EIIs. Though I might try and convert the panic handler to an EII soon-ish. The big difference with the panic handler is that it doesn’t require any defaults.

Dynamic linking

Office hours is also where we first discussed dynamic linking. Rust’s story around dynamic linking isn’t great. In general, Rust can provide very few guarantees about the code that is dynamically linked in. In theory, some of that can be mitigated as long as the dynamic loading happens at program startup (when the program is loaded into memory). But especially with dlopen, it’s easy to create unsoundness.

In fact, setting Rust’s global allocator is in fact currently unsound in combination with dlopen. It becomes possible to allocate an object in one allocator using Box::new, pass it to the dlopened dynamic library, where it will be deallocated on a different allocator.

However, it came up, in the office hours, that it would be cool if we could use EIIs for rustc’s query system. This is the abstraction layer that allows rust to do incremental compilation. And if EIIs don’t support dynamic linking, that would be a problem, because the rust compiler itself is a dynamically linked library. Try it yourself!

Terminal window
1
du -h ~/.rustup/toolchains/<toolchain-version>/bin/rustc

For me returns 644K, which is way too small to be an entire compiler. But if you then run ldd on it:

Terminal window
1
ldd ~/.rustup/toolchains/1.92-x86_64-unknown-linux-gnu/bin/rustc
2
linux-vdso.so.1 (0x00007f9e05e1d000)
3
librustc_driver-d31eb41759495bb2.so => /home/jana/.rustup/toolchains/1.92-x86_64-unknown-linux-gnu/bin/../lib/librustc_driver-d31eb41759495bb2.so (0x00007f9dfd800000)
4
libdl.so.2 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/libdl.so.2 (0x00007f9e05e10000)
5
librt.so.1 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/librt.so.1 (0x00007f9e05e0b000)
6
libpthread.so.0 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/libpthread.so.0 (0x00007f9e05e06000)
7
libc.so.6 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/libc.so.6 (0x00007f9dfd400000)
8
libLLVM.so.21.1-rust-1.92.0-stable => /home/jana/.rustup/toolchains/1.92-x86_64-unknown-linux-gnu/bin/../lib/../lib/libLLVM.so.21.1-rust-1.92.0-stable (0x00007f9df3400000)
9
libgcc_s.so.1 => /nix/store/r7vdyf8pwlqsgd5ydak9fmq3q1i5nm3m-xgcc-15.2.0-libgcc/lib/libgcc_s.so.1 (0x00007f9e05dd7000)
10
libm.so.6 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib/libm.so.6 (0x00007f9e05cdf000)
11
/nix/store/wb6rhpznjfczwlwx23zmdrrw74bayxw4-glibc-2.42-47/lib/ld-linux-x86-64.so.2 => /nix/store/l0l2ll1lmylczj1ihqn351af2kyp5x19-glibc-2.42-51/lib64/ld-linux-x86-64.so.2 (0x00007f9e05e1f000)
12
libz.so.1 => /nix/store/ri9paa3mri4kqakljak8ldvbcp7lpmif-zlib-1.3.1/lib/libz.so.1 (0x00007f9e05cc1000)

The second entry is librustc_driver.so, which is 150MiB large. That is where the compiler lives. Rust works this way so clippy and rustfmt, can also call into the compiler. In addition to librustc_driver.so, Rust uses dlopen to dynamically open codegen backends like cranelift, or gcc. So, if EIIs didn’t support dynamic linking, that would in general be fine-ish, but we wouldn’t be able to use EIIs in the compiler itself.

And office hours was exactly the kind of space where we could chat about various solutions to this. Together with jyn514's github avatar jyn514 , we thought of an approach where we optionally desugar EIIs to thread-locals, and resolve either the default or explicit implementation at runtime. This would trade some amount of performance for full support of dynamic linking, including dlopen!

Going forward

In the end, I must admit that there is a chance we will never even stabilize EIIs. I would be fine with that, actually. I’m certain we’ll use them in the standard library and compiler at some point, since they unify the code paths of the panic handler and the allocator. We might make it easier to change the behavior on specific kinds of panics. Like integer overflow. But we’ll have to decide, after an RFC, whether this is something we want users to interact with directly.

But as I said in the intro, this blog post isn’t really about that. It’s not just about the technical challenges, though maybe you found some of the things I mentioned interesting. Every step of the way. It’s about how many people, from inside and outside the rust project, have all the various bits of knowledge required to come to a single implementation. And how important it therefore is that we maintain this community, where we keep learning from each other, from human to human.