The Algebra of Loans in Rust
Key topics
Diving into the intricacies of Rust's borrowing system, a recent blog post sparked a lively debate about the "algebra of loans" and proposed new reference types like `&own` and `&uninit`. Commenters clarified that these types aren't yet part of Rust, but have been discussed in the context of in-place construction and the Rust-in-Linux-kernel project. As some users pointed out, Rust's current guarantees around allocation and panics are limited, with others drawing comparisons to other systems languages like C, C++, and Zig, which handle errors and allocations differently. The discussion highlights the ongoing quest for better memory management and error handling in Rust.
Snapshot generated from the HN discussion
Discussion Activity
Very active discussionFirst comment
6m
Peak period
74
84-96h
Avg / period
22.4
Based on 112 loaded comments
Key moments
- 01Story posted
Dec 22, 2025 at 2:25 PM EST
11 days ago
Step 01 - 02First comment
Dec 22, 2025 at 2:31 PM EST
6m after posting
Step 02 - 03Peak activity
74 comments in 84-96h
Hottest window of the conversation
Step 03 - 04Latest activity
Dec 28, 2025 at 1:29 AM EST
5d ago
Step 04
Generating AI Summary...
Analyzing up to 500 comments to identify key contributors and discussion patterns
Want the full context?
Jump to the original sources
Read the primary article or dive into the live Hacker News thread when you're ready.
Incidentally, I think this is one of Rust's best features, and I sorely miss it in Python, JS and other languages. They keep me guessing whether a function will mutate the parent structure, or a local copy in those languages!
Incidentally, I recently posted in another thread here how I just discovered the 'named loop/scope feature, and how I thought it was great, but took a while to discover. A reply was along the effect of "That's not new; it's a common feature". Maybe I don't really know rust, but a dialect of it...
I don't know 100% for sure. It's a bit confusing...
I have seen &pin being proposed recently [1], first time I'm seeing the others.
[1] https://blog.rust-lang.org/2025/11/19/project-goals-update-o...
> What’s with all these new reference types? > All of these are speculative ideas
makes it pretty clear to me that they are indeed not yet part of Rust but instead something people have been thinking about adding. The rest of the post discusses how these would work if they were implemented.
It's a shame that you can't quite do this with a lint, because they can't recurse to check the definitions of functions you call. That would seem to me to be ideal, maintain it as an application-level discipline so as not to complicate the base language, but automate it.
Typically no... which is another way of saying occasionally yes.
> What would you do if you needed to call `unreachable!()`?
Probably one of e.g.:
Which are of course the wrong habits to form! (More seriously: in the contexts where such no-panic colors become useful, it's because you need to not call `unreachable!()`.)> It's a shame that you can't quite do this with a lint, because they can't recurse to check the definitions of functions you call. That would seem to me to be ideal, maintain it as an application-level discipline so as not to complicate the base language, but automate it.
Indeed. You can mark a crate e.g. #![deny(clippy::panic)] and isolate that way, but it's not quite the rock solid guarantees Rust typically spoils us with.
You might be able to avoid generating panic handling landing pads if you know that a function does not call panic (transitively). Inlining and LTO often help, but there is no guarantee that it will be possible to elide, it depends on the whims of the optimiser.
Knowing that panicking doesn't happen can also enable other optimisations that wouldn't have been correct if a panic were to happen.
All of that is usually very minor, but in a hot loop it could matter, and it will help with code size and density.
(Note that this is assuming SysV ABI as used by everyone except Windows, I have no clue how SEH exceptions on Windows work.)
> Indeed. You can mark a crate e.g. #![deny(clippy::panic)] and isolate that way, but it's not quite the rock solid guarantees Rust typically spoils us with.
Also, there are many things in Rust which can panic apart from actual calls to panic or unwrap: indexing out of bounds, integer overflow (in debug), various std functions if misused, ...
Zig might be an option in the future, and it does give more control over allocations. I don't know what the exception story is there, and it isn't memory safe and doesn't have RAII so I'm not that interested myself at this point.
I guess Ada could be an option too, but I don't know nearly enough about it to say much.
i never got this point. whats stopping me from writing a function like this in zig?
the only thing explicit about zig approach is having ready-to-use allocator definitons in the std library. if you excluded std library and write your own allocators, you could have an even better api in rust compared to zig thanks to actual shared behaviour features (traits). explicit allocation is a library feature, not a language feature.i use neither of those languages, so don't ask me for technical details :D
Sure, it would be nice to get an error, but usually the biggest threat to your system as a whole is the unapologetic OOM Killer
C++ has a way to tell to compiler that the function would raise no exceptions. Obviously it is not a guarantee that at runtime exception will not happen. In that case the program would just terminate. So it is up to a programmer to turn on some brain activity to decide should they mark function as one or not.
1. Always keep the language reference with you. It's absolutely not a replacement for a good introductory textbook. But it's an unusually effective resource for anybody who has crossed that milestone. It's very effective in spontaneously uncovering new language features and in refining your understanding of the language semantics.
What we need to do with it is to refer it occasionally for even constructs that you're familiar with - for loops, for example. I wish that it was available as auto popups in code editors.
2. Use clippy, the linter. I don't have much to add here. Your code will work without it. But for some reason, clippy is an impeccable tutor into idiomatic Rust coding. And you get the advantage of the fact that it stays in sync with the latest language features. So it's yet another way to keep yourself automatically updated with the language features.
Rust has an unusually short release cycle, but each release tends to have fewer things in it. So that is probably about the same when it comes to new features per year in Python or C++.
But sure, C moves slower (and is smaller to begin with). If that is what you want to compare against. But all the languages I work with on a daily basis (C++, Python and Rust) are sprawling.
I don't have enough experience to speak about other languages in depth, but as I understand it Haskell for example has a lot of extensions. And the typescript/node ecosystem seems to move crazy fast and require a ton of different moving pieces to get anything done (especially when it comes to the build system with bundlers, minifiers and what not).
What programming language(s) satisfy this criteria, if any?
On the other hand, I did find what I think are the relevant docs [0] while looking more into things, so I got to learn something!
[0]: https://docs.adacore.com/gnat_rm-docs/html/gnat_rm/gnat_rm/c...
I can't think of any established language that doesn't fit that exact criteria.
The last major language breakage I'm aware of was either the .Net 2 to 3 or Python 2 to 3 changes (not sure which came first). Otherwise, pretty much every language that makes a break will make it in a small fashion that's well documented.
PHP has had breaking changes [1].
Ruby has had breaking changes [2] (at the very least under "Compatibility issues")
Not entirely sure whether this counts, but ECMAScript has had breaking changes [3].
[0]: https://go.dev/blog/loopvar-preview
[1]: https://www.php.net/manual/en/migration80.incompatible.php
[2]: https://www.ruby-lang.org/en/news/2025/12/25/ruby-4-0-0-rele...
[3]: https://tc39.es/ecma262/2025/#sec-additions-and-changes-that...
*1: A great many examples of synthetic code were contrived to argue against the change, but none of them ever corresponded to Go code anyone would actually write organically, and an extensive period of investigation turned up nothing
*2: As in, the original behavior of the code was actually incorrect, but this wasn't discovered until after the loopvar change caused e.g. some tests to fail, prompting manual review of the relevant code -- raising some interesting questions about the real-world usefulness of some tests, which seem to have been conformed to pass the code under test rather than vice versa
Java is very good here, but (and not totally it's fault) it did expose internal APIs to the userbase which have caused a decent amount of heartburn. If your old codebase has a route to `sun.misc.unsafe` then you'll have more of a headache making an upgrade.
Anyone that's been around for a while and dealt with the 8->9 transition has been bit here. 11->17 wasn't without a few hiccups. 17->21 and 21->25 have been uneventful.
[0]: https://stackoverflow.com/q/1654923
[1]: https://news.ycombinator.com/item?id=28542853
Thry do reserve the right to do breaking changes for security fixes, soundness fixes and inference changes (i.e. you may need to add an explicit type that was previously inferred but is now ambiguous). These are quite rare and usually quite small.
I wasn't particularly commenting on Rust's backward compatibility story so if you're not sure what I was arguing about then why did you feel the need to defend Rust from accusations that weren't made in the first place?
Even adding a new keyword will break some code out there that used that as a variable name or something. Perfect backward compatibility means you can never improve anything, ever, lest it causes someone a nonzero amount of porting effort.
C and personal computing hit their stride at roughly the same time, your choices were (if you didn't feel like spending a fortune) Assembly, C, Pascal and BASIC for most systems that mere mortals could afford. BASIC was terribly slow, Pascal and C a good match and assembler only for those with absolutely iron discipline. Which one of the two won out (C or Pascal) was a toss up, Pascal had it's own quirks and it was mostly a matter of which of the two won out in terms of critical mass. Some people still swear by Pascal (and usually that makes them Delphi programmers, which will be around until the end because the code for the heat-death of the universe was writting in it).
For me it was Mark Williams C that clinched it, excellent documentation, good UNIX (and later Posix) compatibility and whatever I wrote on the ST could usually be easily ported to the PC. And once that critical mass took over there was really no looking back, it was C or bust. But mistakes were made, and we're paying the price for that in many ways. Ironically, C enabled the internet to come into existence and the internet then exposed mercilessly all of the inherent flaws in C.
A small language but with the ability to extend it (like Lisp) is probably the sweet spot, but lol look at what you have actually achieved - your own dialect that you have to reinvent for each project - also which other people have had to reinvent time after time.
Let languages and thought be large, but only used what is needed.
If I try the same with a python project that I wrote less than five years ago I'm very, very lucky if I don't end up with a broken system by the time all of the conflicts are resolved. For a while we had Anaconda which solved all of the pain points but it too seems to suffer from dependency hell now.
George Orwell was a writer of English books, not a programmer and whatever he showed us he definitely did not show us that small programming languages constrain our thinking.
What you could say is that a programming languages' 'expressivity' is a major factor in how efficient it is in taking ideas and having them expressed in a particular language. If you take that to an extreme (APL) you end up with executable line-noise. If you take it to the other extreme you end up some of the worst of Java (widget factory factories). There are a lot of good choices to be found in the middle.
You mean:
> small languages constrain our thinking.
:)
Many people encounter these algorithms after many other people have written large libraries and codebases. It’s much easier to slightly extend the language than start over or (if possible) implement the algorithm in an ugly way that uses existing features. But enough extensions (and glue to handle when they overlap) and even a language which was initially designed to be simple, is no longer.
e.g., Go used to be much simpler. But in particular, lack of generics kept coming up as a pain point in many projects. Now Go has generics, but arguably isn’t simple anymore.
> I doubt that anybody truly knows <language>.
> Always keep the language reference with you.
> Use <tool>, the linter.
seem like they apply to all languages (and I agree that they're great advice!).
Murderous, vile and wretched Rust proponents will seek to censor, downplay and distract from this.
Python at least is very clear about this: lists, class instances, dicts, and tuples are all passed by reference (but tuples are immutable so it doesn't matter).
The function could mutate foo to be empty, if foo is mutable, but it can’t make it not exist.
No mention of references!
I don't care about references to foo. I don't care about facades to foo. I don't care about decorators of foo. I don't care about memory segments of foo.
"Did someone eat my lunch in the work in fridge?"
"Well at least you wrote your name in permanent marker on your lunchbox, so that should help narrow it down"
>> I sorely miss it in Python, JS and other languages. They keep me guessing whether a function will mutate the parent structure, or a local copy in those languages!
> Python at least is very clear about this ... everything, lists, class instances, dicts, tuples, strings, ints, floats ... are all passed by object reference. (Of course it's not relevant for tuples and scalars, which are immutable.)
Then let me just FTFY based on what you've said later:
Python will not be very clear about this ... everything, lists, class instances, dicts, tuples, strings, ints, floats, they all require the programmer to read and write documentation every time.
I assume I'm the one who taught you this, and for the edification of others, you can do labeled break not only in Rust, but also C#, Java, and JavaScript. An even more powerful version of function-local labels and goto is available in Go (yes, in Go!), as well as, naturally, C and C++.
The point being, the existence of obscure features does not a large or baroque language make, unless you're willing to call Go a large language.
Yes, from the purely theoretical standpoint, you can always rewrite the code to use flags inside the loop conditions. And it even allows formal analysis by treating this condition as a loop invariant.
But that's in theory. And we all know the difference between the theory and practice.
Moreover, "continue" has been invented only in 1974, as one of the few novel features of the C programming language.
Both simple "break" and "continue" are useful, because unlike "goto" they do not need labels, yet the flow of control caused by them is obvious.
Some languages have versions of "break" and "continue" that can break or continue multiple nested loops. In my opinion, unlike simple "break" and "continue" the multi-loop "break" and "continue" are worse than a restricted "goto" instruction. The reason is that they must use labels, exactly like "goto", but the labels are put at the beginning of a loop, far away from its end , which makes difficult to follow the flow of control caused by them, as the programmer must find first where the loop begins, then search for its end.
Instead of having multi-loop break and continue, it is better to have a restricted "goto", which is also useful for handling errors. Restricted "goto" means that it can jump only forwards, not backwards, and that it can only exit from blocks, not enter inside blocks.
There's a huge difference between reining in real world chaos vs theoretical inelagancies (ESPECIALLY if fixing that would introduce other complexity to work around the lack of it).
And I don’t see anything bad about this!
After 11 years of full-time Rust, I have never needed to use Pin once, and it’s only having to do FFI have I even had to reach for unsafe.
Unless you memorise the Rust Reference Manual and constantly level up with each release, you’ll never “know” the whole language… but IMHO this shouldn’t stop you from enjoying your small self-dialect - TMTOWTDI!
As someone else who has learned (and forgotten) a great deal of Perl and C++ arcana: The badness is that it makes it harder for one person to understand another person's code.
These aren't included in the article because they are not borrow checked, but you're right that if someone was trying to cover 100% of pointer types in Rust, raw pointers would be missing.
Python for example has a ton of stuff that can be done with classes using sunder methods and other magic. I'm aware of it, but in all the years I've been writing Python I've never actually needed it. The only time I've had to directly interact with it was when trying to figure out how the fuck OpenAPI generates FastAPI server code. Which fairly deep into a framework and code generation tool.
Many "other languages", particularly ones that compile to native code in traditional way have fairly explicit ways of specifying how said parameters to be treated
The correct solution for the semantics of function parameters is the one described in the "DoD requirements for high order computer programming languages: IRONMAN" (January 1977, revised in July 1977), which have been implemented in the language Ada and in a few other languages inspired by Ada.
According to" IRONMAN", the formal parameters of a function must be designated as belonging to one of 3 classes, input parameters, output parameters and input-output parameters.
This completely defines the behavior of the parameters, without constraining in any way the implementation, i.e. any kind of parameters may be passed by value or by reference, whichever the compiler chooses for each individual case. (An input-output parameter where the compiler chooses to pass it by value will be copied twice, which can still be better than passing it by reference, e.g. when the parameter is passed in a register.)
When a programming language of the 21st century still requires for the programmer to specify whether it is passed by value or by reference, that is a serious defect for the language, because in general the programmer does not have the information needed to make a correct choice and this is an implementation detail with which the programmer should not be burdened.
The fact that C++ lacks this tripartite classification of the formal parameters of a function has lead to the ugliest complications of C++, which have been invented as bad workarounds for this defect, i.e. the fact that constructors are not normal functions, the fact that there exist several kinds of superfluous constructors which would not have been needed otherwise (e.g. the copy constuctor), the fact that C++ 2011 had to add some features like the "move" semantics to fix performance problems of the original C++.
1. Why isn’t there a variant of &mut that doesn’t allow swapping the value? I feel like it ought to be possible to lend out permission to mutate some object but not to replace it. Pinning the object works, but that’s rather extreme.
2. Would it be safe to lend the reference type above to a pinned object? After all, if a function promises to return with the passed-in parameter intact in its original location and not to swap it with a different value/place, then its address must stay intact.
3. Why is pinning a weird sticky property of a reference? Shouldn’t non-movability of an object be a property of the object’s type? Is it just a historical artifact that it works the way it does or is this behavior actually desirable?
4. Wouldn’t it be cool if there was a reference type that gave no permissions at all but still guaranteed that the referred-to object would continue to exist? It might make more sense to use with RefCell-like objects than plain &. This new reference type could exist concurrently with &mut.
For 3, some objects only need to be pinned under certain circumstances, e.g. futures only need to be pinned after they're polled for the first time, but not before. So it's convenient to separate the pinnability property to allow them to be moved freely beforehand.
I don't quite understand the usecase you have in mind for 4.
Privacy. If an object has fields I can’t access, but I have an &mut reference, I can indirectly modify them by swapping the object.
More generally, there are a handful of special-seeming things one can do to an object: dropping it, swapping it, forgetting it, and leaking it. Rust does not offer especially strong controls for these except for pinned objects, and even then it feels like the controls are mostly a side effect of pinning.
> For 3, some objects only need to be pinned under certain circumstances, e.g. futures only need to be pinned after they're polled for the first time, but not before.
Is this actually useful in practice? (This is a genuine question, not a rhetorical question. But maybe let’s pretend that Rust had the cool ability to farm out initialization if uninitialized objects described in the OP: allowing access before pinning sounds a bit like allowing references to uninitialized data before initializing it.)
For #4, I’m not sure I have a real use case. Maybe I’ll try contemplating a bit more. Most I think that shared ^ exclusive is a neat concept but that maybe there’s room to extend it a little bit, and there isn’t any fundamental reason that a holder of an &mut reference needs to ensure that no one else can even identify the object while the &mut reference is live.
It's required to do any intialization, particularly for compound futures (e.g. a "join" or "select" type of combinator), since you need to be able to move the future from where it's created to where it's eventually used/polled. I assume some of those cases could be subsumed by &uninit if that existed yeah.
But if you told me that some strongly typed language wanted to have coroutines and futures, that coroutine bodies would not execute at all until first polled, and that it was okay to move the thing you have before polling not the thing you had after polling, and I hadn’t seen Rust, I would maybe suggest:
1. Creating a Future (i.e. logically calling an async function) would return an object that is, conceptually, a NotYetPolledFuture. That object is movable or relocatable or whatever you call it (or it’s movable if and only if the parameters you passed are).
2. Later you exchange that object for a LiveFuture, which cannot be moved.
Rust has two limitations that would make this awkward:
- Rust doesn’t actually have immovable objects per se.
- The exchange is spelled fn exchange(val: Type1) -> Type2, which doesn’t work if Type2 is immovable.
But the &uninit/&own proposal in the OP is actually secretly a complex scheme using lifetimes to somewhat awkwardly do an in-place exchange from an uninitialized type to an initialized and owned type. Maybe that proposal could be extended a little bit to allow type exchanges. (Maybe it already does, sort of? You could pass an &uninit Future and a FutureToken and get out an &own Future, with the caveat that this would force the fields in the token to be moved unless the optimizer did something truly heroic.)
This is a very insightful observation, and Niko Matsakis (leading influence of Rust's borrow checker) would likely agree with you that this is an instance where Rust's default borrowing rules are probably too permissive, in the sense that being more restrictive by default regarding the "swappability" of &mut could lead to Rust being able to provide more interesting static guarantees. See his blog post here: https://smallcultfollowing.com/babysteps/blog/2024/09/26/ove...
> Why is pinning a weird sticky property of a reference? Shouldn’t non-movability of an object be a property of the object’s type?
See this blog post from withoutboats: https://without.boats/blog/pinned-places/ for arguments as to why pinning is properly modeled as a property of a place rather than a type (particularly the section "Comparison to immovable types"), as well as this post from Niko that ties this point in with the above point regarding swappability: https://smallcultfollowing.com/babysteps/blog/2024/10/14/ove...
> One could imagine an alternative design in which instead of places being unpinned by default and opting into pinning, places are pinned (or perhaps “immovable”) by default, and have to opt into supporting the ability to move out of them. This would make it so that by default places have the least power (can only access via shared reference) and they gain a monotonically increasing set of powers (can assign to them, can move out of them).
> In addition to places having to opt into moving, there would be three reference types instead of two: immutable, mutable, and movable references.
A type that might require stable pointers, like async{}, might want to be movable prior to use, so you don't want the type to require the value be pinned immediately. Or if you do, you need a construction like pinned-init that offers `&pin out T` - a pinned place that can be written to on initialisation of the type.
&own, &pin, and &uninit are proposals for additional pointer types. They don't actually exist in the type system right now, but other parts of the compiler do have to care about them. Another blog post that floated around here about a month ago called these "inconceivable types"[0]; adding them to the type system would allow formally extending these behaviors across function boundaries.
Like, right now, implementers of Drop can't actually move anything out of the value that's about to be destroyed. The Drop trait gets a &mut, but what we really want is to say "destroy this value over there". Rust's type system cannot understand that you own the value but not the place it lives in. What you need is an "owned reference" - i.e. &own, where the borrow checker knows that you can safely move out of it because it's going to get destroyed anyway.
Rust also can't support constructors, for the same reason. What we really have are factory functions: you call them, they return a value, you put it somewhere. This is good enough that Rust users just treat factory functions as if they were constructors, but we can't do "placement new" type construction with them, or partial initialization. At least not in a way the type system can actually check.
&pin is a first-class version of Pin<T>. Rust was originally designed under the assumption that any type can be memcpy'd at any time; but it turns out not being able to move types is actually super useful. Fortunately, it also turned out you could use smart pointers to pin types, which was 'good enough' for what it was being used for - async code.
Actually, the blog post that coined "inconceivable types" was specifically talking about writing async functions without async. It turns out Future impls encode a lot of details Rust's type system can't handle - notably, self-borrows. If a value is borrowed across an await, what's the type of the variable that got borrowed from? It's really a negative type: borrowing T to get &T also turns T into !'a T that you can't access until 'a ends. Each borrowed reference is paired to a debt that needs to be paid back, and to do that you need lifetime variables and syntax to explicitly say "pay back this debt by ending this borrow's lifetime".
How much of this complexity is actually needed is another question. There's a problem that each and every one of these reference types (or, anti-types) is intended to solve. Obviously if we added all of them, we'd overcomplicate the type system. But at the same time, the current Rust type system is already known to be oversimplified, to the point where we had to hack in pinning for async. And it's already kind of ridiculous to say "Well, async is all special compiler magic" because it prevents even reasonable-sounding tweaks to the system[1].
[0] https://blog.polybdenum.com/2024/06/07/the-inconceivable-typ...
[1] For example, async does not currently have a way to represent "ambient context" - i.e. things we want the function to be able to access but NOT hold onto across yields. That would require a new Future trait with a different poll method signature, which the current Rust compiler doesn't know how to fill or desugar to. So you have to use the core Future trait and signature which doesn't support this kind of context borrow.
To work around this limiation involves a lot of unnecessarily verbose code to drop and regain context between awaits, i.e. https://github.com/ruffle-rs/ruffle/blob/b5732b9783dce5d2311...
> For example, if I have a &own T I can reborrow it into a &mut T but not a &pin own T.
From the table can't you do both? Maybe they mean "not a &pin mut T" ?