[HN Gopher] C3 solved memory lifetimes with scopes
___________________________________________________________________
C3 solved memory lifetimes with scopes
Author : lerno
Score : 78 points
Date : 2025-07-11 14:27 UTC (2 days ago)
(HTM) web link (c3-lang.org)
(TXT) w3m dump (c3-lang.org)
| Windeycastle wrote:
| Nice read, although a small section on how it's implemented
| exactly would've been nice.
| hrhrdorhrvfbf wrote:
| Rust's interface for using different allocators is janky, and I
| wish they had something like this, or had moved forward with the
| proposal for the mechanism for making it a part of a flexible
| implicit context mechanism that was passed along with function
| calls.
|
| But mentioning the borrow checker raises an obvious question that
| I don't see addressed in this post: what happens if you try to
| take a reference to an object in the temporary allocator, and use
| it outside of the temporary allocator's scope? Is that an error?
| Rust's borrow checker has no runtime behavior, it only exists to
| create errors in cases like that, so the title invites the
| question of how your this mechanism handles that case but doesn't
| answer it.
| lerno wrote:
| A dangling pointer will generally still possible to dereference
| (this is an implementation detail, that might get improved -
| temp allocators aren't using virtual memory on supporting
| platforms yet), but in safe more that data will be scratched
| out with a value, I believe we use 0xAA by default. So as soon
| as this data is used out of scope you'll find out.
|
| This is of course not as good as ASAN or a borrow checker, but
| it interacts very nicely with C.
| Filligree wrote:
| So, would you say the title overstates its case slightly?
| lerno wrote:
| I would say that the title is easily misread. If you open
| the blog post and just read the title and a few lines into
| the intro, I think it's clear it's about C3 not having to
| implement any recently popular language features in order
| to solve the problem of memory lifetimes for temporary
| objects as they arise in a language with C-like semantics.
|
| Now clearly people are misreading the title when it stands
| on its own as "borrow checkers suck, C3 has a way of
| handling memory safety that is much better". That is very
| unfortunate, but chance to fix that title already passed.
|
| It should also be clear from the rest of the blog post that
| it doesn't try to make any claims that it's a novel
| technique (it's something that has been around for a long
| time). What's novel is that it's well integrated into the
| stdlib.
| SkiFire13 wrote:
| > C3 not having to implement any recently popular language
| features in order to solve the problem of memory lifetimes
| for temporary objects as they arise in a language with C-like
| semantics.
|
| But you said it yourself in your previous message:
|
| > A dangling pointer will generally still possible to
| dereference (this is an implementation detail, that might get
| improved - temp allocators aren't using virtual memory on
| supporting platforms yet)
|
| So the issue is clearly not solved.
|
| And to be complete about the answer:
|
| > in safe more that data will be scratched out with a value,
| I believe we use 0xAA by default. So as soon as this data is
| used out of scope you'll find out.
|
| I can see multiple issues with this:
|
| - it's only in safe mode
|
| - it's safe only as long as the memory is never used again
| for a different purpose, which seems to imply that either
| this is not safe (if it's written again) or that it leaks
| massive amounts of memory (if it's never written to again)
|
| > Now clearly people are misreading the title when it stands
| on its own as "borrow checkers suck, C3 has a way of handling
| memory safety that is much better". That is very unfortunate,
| but chance to fix that title already passed.
|
| Am I still misreading the title if I read it as "C3 solves
| the same issues that the borrow checker solves"? To me that
| way of reading seems reasonable, but the title still looks
| plainly wrong.
|
| Heck, even citing the borrow checker *at all* seems wrong,
| this is more about RAII than lifetimes (and RAII in Rust is
| solved with ownership, not the borrow checker).
| lerno wrote:
| > So the issue is clearly not solved.
|
| You can use --sanitize=address to get this today, or use
| the Vmem-based temp allocator (which is only in the 0.7.4
| prerelease and only for 64 bit POSIX) if you're curious how
| it feels and works in practice.
|
| > I can see multiple issues with this:
|
| There is a constant trade-off, and being as safe as
| possible is obviously great, but there is also the question
| of performance.
|
| The context matters though, it's a C-like language, an
| evolution of C. So it doesn't try to be a completely new
| language with new semantics, and that creates a lot of
| constraints.
|
| The "safe-C" C-dialects usually add a lot of additional
| annotations that doesn't seem particularly palatable to
| most developers.
|
| > Am I still misreading the title if I read it as "C3
| solves the same issues that the borrow checker solves"?
|
| Yes I am afraid you do. But that's my fault (since I
| suggested the title, even though I didn't write the
| article), and not yours.
| Philpax wrote:
| I feel like "solved" is a strong word for what's described here.
| This works for some - possibly even many - scenarios, but it does
| not solve memory lifetime in the general case, especially when
| data from different scopes needs to interact.
| hvenev wrote:
| I'm struggling to understand how this has anything to do with
| borrow checking. Borrow checking is a way to reason about
| aliasing, which doesn't seem to be a concern here.
|
| This post is about memory management and doesn't seem to be
| concerned much about safety in any way. In C3, does anything
| prevent me from doing this: fn int* example(int
| input) { @pool() { int*
| temp_variable = mem::tnew(int); *temp_variable =
| input; return temp_variable; }; }
| cayley_graph wrote:
| Yes, this has little to nothing to do with borrow checking or
| memory/concurrency safety in the sense of Rust. Uncharitably,
| the author appears not to have a solid technical grasp of what
| they're writing about, and I'm not sure what this says about
| the rest of the language.
| lerno wrote:
| No, that is quite possible. You will not be able to use that
| memory you just returned though. What actually happens is an
| implementation issue, but it ranges from having the memory
| overwritten (but still being writable) on platforms with the
| least support, to being neither read or writable, to throwing
| an exact error with ASAN on. Crashing on every use is often a
| good sign that there is a bug.
| unscaled wrote:
| It might not be on every use though. The assignment could
| very well be conditional. If a dangling reference could
| escape from the arena in which it was allocated, you cannot
| claim to have memory safety. You can claim that the arena
| prevents memory leaks (if you remember to allocate everything
| correctly within the arena), but it doesn't provide memory
| safety.
| lerno wrote:
| Memory safety as in the full toolset that Rust provides? C3
| clearly doesn't, I fully agree.
| ameliaquining wrote:
| "No more [...] slow compile times with complex ownership
| tracking."
|
| Presumably this is referring to Rust, which has a borrow checker
| and slow compile times. The author is, I assume, under the common
| misconception that these facts are closely related. They're not;
| I think the borrow checker runs in linear time though I can't
| find confirmation of this, and in any event profiling reveals
| that it only accounts for a small fraction of compile times. Rust
| compile times are slow because the language has a bunch of other
| non-borrow-checking-related features that trade off compilation
| speed for other desiderata (monomorphization, LLVM optimization,
| procedural macros, crates as a translation unit). Also because
| the rustc codebase is huge and fairly arcane and not that many
| people understand it well, and while there's a lot of room for
| improvement in principle it's mostly not low-hanging fruit,
| requiring major architectural changes, so it'd require a large
| investment of resources which no one has put up.
| unscaled wrote:
| I know very little about how rustc is implemented, but watching
| what kind of things make make Rust compile times slower, I tend
| to agree with you. The borrow checker rarely seems to be the
| culprit here. It tends to spike up exactly on the things you've
| mentioned: procedural macros use, generics use
| (monomorphization) and release builds (optimization).
|
| There are other legitimate criticisms you can raise at the Rust
| borrow checker such as cognitive load and higher cost of
| refactoring, but the compilation speed argument is just
| baseless.
| SkiFire13 wrote:
| Procedural macros are not really _that_ slow themselves, the
| issue is more that they tend to generate enormous amount of
| code that will then have to be compiled, and _that_'s slow.
| ameliaquining wrote:
| Also the procedural macro library itself and all of its
| dependencies have to be compiled. Though this only really
| affects initial builds, as the library can be cached on
| subsequent ones.
| josh11b wrote:
| https://learning-rust.github.io/docs/lifetimes/
|
| > Lifetime annotations are checked at compile-time. ... This is
| the major reason for slower compilation times in Rust.
|
| This misconception is being perpetuated by Rust tutorials.
| estebank wrote:
| On the phone, so I can't now, but someone should file a
| ticket to that project about that error:
| https://github.com/learning-rust/learning-
| rust.github.io/iss...
|
| Be aware that it is not part of the rust-lang organization,
| it's a third party.
| JoshTriplett wrote:
| https://github.com/learning-rust/learning-
| rust.github.io/pul...
| UncleMeat wrote:
| The core benefit of the borrow checker is not "make sure to
| remember to clean up memory to avoid leaks." The core benefits
| are "make sure that you can't access memory after it has been
| destroyed" and "make sure that you can't mutate something that
| somebody else needs to be constant." This is fundamentally a
| statement about the relationship between many objects, which may
| have different lifetimes and which are allocated in totally
| different parts of the program.
|
| Lexically scoped lifetimes don't address this at all.
| lerno wrote:
| Well, the title (which is poorly worded as has been pointed
| out) refers to C3 being able to implement good handling of
| lifetimes for temporary allocations by baking it into the
| stdlib. And so it doesn't need to reach for any additional
| language features. (There is for example a C superset that
| implements borrowing, but C3 doesn't take that route)
|
| What the C3 solution DOES to provide a way to detect at runtime
| when already freed temporary allocation is used. That's of
| course not the level of compile time checking that Rust does.
| But then Rust has a lot more in the language in order to
| support this.
|
| Conversely C3 does have contracts as a language feature, which
| Rust doesn't have, so C3 is able to do static checking with the
| contracts to reject contract violations at compile time, which
| runtime contracts like some Rust creates provides, can't do.
| SkiFire13 wrote:
| > What the C3 solution DOES to provide a way to detect at
| runtime when already freed temporary allocation is used.
|
| The article makes no mention of this, so in the context of
| the article the title remains very wrong. I could also not
| find a page in the documentation claiming this is supported
| (though I have to admit I did not read all the pages), nor an
| explanation of how this works, especially in relation to the
| performace hit it would result in.
|
| > C3 is able to do static checking with the contracts to
| reject contract violations at compile time
|
| I tries searching how these contracts work in the C3 website
| [1] and these seems to be no guaranteed static checking of
| such contracts. Even worse, violating them when not using
| safe mode results in "unspecified behaviour", but really it's
| undefined behaviour (violating contracts is even their list
| of undefined behaviour! [2])
|
| [1]: https://c3-lang.org/language-common/contracts/
|
| [2]: https://c3-lang.org/language-rules/undefined-
| behaviour/#list...
| lerno wrote:
| > The article makes no mention of this, so in the context
| of the article the title remains very wrong
|
| The temp allocator implementation isn't guaranteed to
| detect it, and the article doesn't go into implementation
| details and guarantees (which is good, because capabilities
| will be added on the road to 1.0).
|
| > I tries searching how these contracts work in the C3
| website [1] and these seems to be no guaranteed static
| checking of such contracts.
|
| No, there is no guarantee at the language level because
| doing so would make a conforming implementation of the
| compiler harder than it needs to be. In addition, setting
| exact limits may hamper innovation of compilers that wish
| to add more analysis but will hesitate to reject code that
| can be statically know to violate contracts.
|
| At higher optimizations, the compiler is allowed to assume
| that the contracts evaluate to true. This means that code
| like `assert(i == 1); if (i != 1) return false;` can be
| reduced to a no-op.
|
| So the danger here is then if you rely on the function
| giving you a valid result even if the indata is not one
| that the function should work with.
|
| And yes, it will be optional to have those "assumes"
| inserted.
|
| Already today in current compiler, doing something trivial
| like writing `foo(0)` to a function that requires that the
| parameter > 1 is caught at compile time. And it's not doing
| any real analysis yet, but it will definitely happen.
| UncleMeat wrote:
| Just my opinion, but I think that having contracts that
| _might_ be checked is a really really really dangerous
| approach. I think it is a much better idea to start with
| a plan for what sorts of things you can check soundly and
| only do those. "Well we missed that one because we only
| have intraprocedural constant propagation" is not going
| to be the sort of thing most users understand and will
| catch people by surprise.
| fanf2 wrote:
| > What the C3 solution DOES to provide a way to detect at
| runtime when already freed temporary allocation is used.
|
| I looked at the allocator source code and there's no use-
| after-free protection beyond zeroing on free, and that is in
| no way sufficient. Many UAF security exploits work by using a
| stale pointer to mutate a new allocation that re-uses memory
| that has been freed, and zeroing on free does nothing to stop
| these exploits.
| cogman10 wrote:
| I really do not see the benefit of this over C++ destructors and
| or facilities like `unique_ptr` and `shared_ptr`.
|
| @pool appears to be exactly what C++ does automatically when
| objects fall out of scope.
| vineethy wrote:
| My first thoughts also
| sirwhinesalot wrote:
| The advantage is that the allocations are grouped: they're
| allocated in the same memory region (good memory locality) and
| freed in bulk. The tradeoff is needing to explicitly create
| these scopes and not being able to have custom deallocation
| logic like you can in a destructor.
|
| (This doesn't seem to have anything to do with borrow checking
| though, which is a memory _safety_ feature not a memory
| _management_ feature. Rust manages memory with affine types
| which is a completely separate thing, you could write an entire
| program without a single reference if you really wanted to)
| ameliaquining wrote:
| You can also do those things in an RAII language with an
| arena library. Is the complaint just that it's too
| syntactically verbose?
| jdcasale wrote:
| I am also struggling to see the difference between this and
| language-level support for an arena allocator with RAII.
| lerno wrote:
| You can certainly do it with RAII. However, what if a
| language lacks RAII because it prioritizes explicit code
| execution? Or simply want to retain simple C semantics?
|
| Because that is the context. It is the constraint that
| C3, C, Odin, Zig etc maintains, where RAII is out of the
| question.
| cogman10 wrote:
| It seems like exactly the same verbosity as what you'd do
| with a custom allocator.
|
| I think the only real grace is you don't have to pass
| around the allocator. But then you run into the issue where
| now anyone allocating needs to know about the lifetimes of
| the pool of the caller. If A -> B (pool) -> C and the
| returned allocation of C ends up in A, now you potentially
| have a pointer to freed memory.
|
| Sending around the explicit allocator would allow C to
| choose when it should allocate globally and when it should
| allocate on the pool sent in.
| sirwhinesalot wrote:
| I think the point is that it is the blessed/default way of
| doing things, rather than opt-in, as in C++ or Rust.
|
| Rust doesn't even have a good allocator interface yet, so
| libraries like bumpalo have a parallel implementation of
| some stdlib types.
| lerno wrote:
| The benefit is that it: (a) works in a language without RAII,
| and C-like languages usually does not have that (b) there are
| no individual heap allocations and frees (c) allocations are
| grouped together.
| littlestymaar wrote:
| > (a) works in a language without RAII
|
| I'm confused: how is it not exactly RAII?
| lerno wrote:
| Well, there are no objects, no constructors and no
| destructors.
| bbminner wrote:
| Ok, now give me an example of a resource manager (eg in a game)
| that has methods for loading resources into memory and also for
| releasing such resources - all of a sudden if a system needs to
| give away pointer access to its buffers, things become more
| complicated and arena allocators are not enough.
| lerno wrote:
| I am not sure how this would be a problem. Certainly the
| resource manager should manage the memory itself in some
| manner.
|
| It has very little to do with trying to manage temporary memory
| lifetimes.
| Calavar wrote:
| For that scenario you can use a pool allocator backed by a
| fixed size allocation from an arena. That gives you the
| flexibility to allocate and free resources on the fly, but with
| a fixed upper limit to the lifetime (e.g. the lifetime of the
| level or chunk). Once you're ready to unload a level or a
| chunk, you can rewind the arena, which is a very cheap
| operation (as opposed to calling free in a loop, which can be
| expensive if the free implementation tries to defragment the
| freelist)
| smcameron wrote:
| Seems overly simplistic and doesn't seem to cover extremely
| common cases such as a thread allocating some memory then putting
| it into a queue to be consumed by other threads which then
| eventually free the memory, or any allocation lifetime that isn't
| simply the scope of the enclosing block.
| lerno wrote:
| Well the latter is covered: you can make temp allocations out
| of order when having nested "@pool"s. There are examples in the
| blog post.
|
| It doesn't solve the case when lifetimes are indeterminate. But
| often they are well know. Consider "foo(bar())" where "bar()"
| returns an allocated object that we wish to free after "foo"
| has used it. In something like C it's easy to accidentally leak
| such a temporary object, and doing it properly means several
| lines of code, which might be bad if it's intended for an `if`
| statement or `while`.
| ltbarcly3 wrote:
| This literally doesn't solve any actual problems. If all memory
| allocation patterns were lexical this is the most easy and most
| obvious thing to do. That is why stack allocation is the default
| and works exactly like this.
| amelius wrote:
| Well, it solves the problem of destructors/deallocation wasting
| a lot of time.
| lerno wrote:
| Imagine we have a function "foo" which returns an allocated
| object Bar, we want to pass this to a function "bar" and then
| have it released.
|
| Now we usually cannot do "bar(foo())" because it then leaks. We
| could allocate a buffer on the stack, and then do
| "bar(foo(&buffer))", but this relies on us safely knowing that
| the buffer does not overflow.
|
| If the language has RAII, we can use that to return an object
| which will release itself after going out of scope e.g.
| std::unique_ptr, but this relies on said RAII and preferably
| move semantics.
|
| If the context is RAII-less semantics, this is not trivial to
| solve. Languages that run into this is C3, Zig, C and Odin.
|
| _With_ the temp allocator solution, we can write `bar(foo())`
| if `foo` always allocates a temp variable, or `bar(foo(tmem))`
| if it takes an allocator.
| ltbarcly3 wrote:
| Wait, you are implying this is some kind of algorithmic
| 'solution' to a long standing problem. It's not. This is
| notable because it's an implementation that works in C++. The
| 'concept' of tracking allocations in a lexical way is
| trivially obvious.
| amelius wrote:
| Smart compilers already do this with escape analysis.
| lerno wrote:
| No, I don't think they do.
|
| Given a function `foo` that is allocating an object "o" and
| returns it to the upper scope, how would you do "escape
| analysis" to determine it should be freed and HOW it should be
| freed? What is the mechanism if you do not have RAII, ARC or
| GC?
| throwawaymaths wrote:
| you track how the variable is used in the compilation unit
| which should have a finite set of possibilities?
| lerno wrote:
| This is about tracking allocated memory, which is
| different. I know V claimed it could solve this with static
| analysis, but in practice it didn't work and had to
| fallback to a GC.
|
| This is true for all similar schemes, that they have
| something for easy for simple-to-track allocations, and
| then have to fallback on something generic.
|
| But even that is usually assuming that the language is
| somehow having a built-in notion of memory allocation and
| freeing.
| dnautics wrote:
| It should be possible in zig! Here's a proof of concept,
| I would guess that if V failed it was because they tried
| to do it at the language level. If you analyse
| intermediate representations the work is much, much
| easier.
|
| https://youtu.be/ZY_Z-aGbYm8?feature=shared
| turnsout wrote:
| Is this different from NSAutoreleasePool, which has been around
| for over 30 years?
| sirwhinesalot wrote:
| Implementation-wise yes, very different, idea-wise not really.
| The author of C3 is a fan of Objective-C.
| lerno wrote:
| NSAutoreleasePool keeps a list of autoreleased objects, that
| are given a "release" message when the pool goes out of scope.
|
| `@pool` flushes the temp allocator and all allocations made by
| the temp allocator are freed when the pool goes out of scope.
|
| There are similarities, but NSAutoreleasePool is for
| refcounting and an object released by the autoreleasepool might
| have other objects retaining it, so it's not necessarily freed.
| timeon wrote:
| I don't think technical writing needs this kind of rage-bait.
| They could have presented just the features of the language.
| Borrow-checker is clearly unrelated here.
| ac130kz wrote:
| The post doesn't even mention how it works/improves DX in a
| multi-threaded environment, borrow checkers are targeting
| specifically that use case.
| unscaled wrote:
| The post's title is quite hyperbolic and I don't think it serves
| the topic right.
|
| Memory arenas/pools have been around for ages, and binding arenas
| to a lexical scope is also not a new concept. C++ was doing this
| with RAAI, and you could implement this in Go with defer and in
| other languages by wrapping the scope with a closure.
|
| This post discusses how arenas are implemented in C3 and what
| they're useful for, but as other people have said this doesn't
| make sense to compare arenas to reference counting or a borrow
| checker. Arenas make memory management simpler in many scenarios,
| and greatly reduce (but don't necessarily eliminate - without
| other accompanying language features) the chances of a memory
| leak. But they contribute very little to memory safety and
| they're not nearly as versatile as a full-fledged borrow checker
| or reference counting.
| rq1 wrote:
| What core type theory is C3 actually built on?
|
| The blog claims that @pool "solves memory lifetimes with scopes"
| yet it looks like a classic region/arena allocator that frees
| everything at the end of a lexical block... a technique that's
| been around for decades.
|
| Where do affine or linear guarantees come in?
|
| From the examples I don't see any restrictions on aliasing or on
| moving data between pools, so how are use-after-free bugs
| prevented once a pointer escapes its region?
|
| And the line about having "solved memory management" for total
| functions::: bravo indeed...
|
| Could you show a non-trivial case where @pool eliminates a leak
| that an ordinary arena allocator wouldn't?
|
| Could you show a non-trivial case, say, a multithreaded game loop
| where entities span multiple frames, or a high-throughput server
| that streams chunked responses, where @pool prevents leaks that a
| plain arena allocator would not?
| sirwhinesalot wrote:
| It is unfortunate that the title mentions borrow checking which
| doesn't actually have anything to do with the idea presented.
| "Forget RAII" would have made more sense.
|
| This doesn't actually do any compile-time checks (it could, but
| it doesn't). It will do runtime checks on supported platforms
| by using page protection features eventually, but that's not
| really the goal.
|
| The goal is actually extremely simple: make working with
| temporary data very easy, which is where most memory management
| messes happen in C.
|
| The main difference between this and a typical arena allocator
| is the clearly scoped nature of it in the language. Temporary
| data that is local to the function is allocated in a new @pool
| scope. Temporary data that is returned to the caller is
| allocated in the parent @pool scope.
|
| Personally I don't like the precise way this works too much
| because the decision of whether returned data is temporary or
| not should be the responsibility of the caller, not the callee.
| I'm guessing it is possible to set the temp allocator to point
| to the global allocator to work around this, but the callee
| will still be grabbing the parent "temp" scope which is just
| wrong to me.
| Sesse__ wrote:
| > "Forget RAII" would have made more sense.
|
| For memory only, which is one of the simplest kinds of
| resource. What about file descriptors? Graphics objects?
| Locks? RAII can keep track of all of those. (So does
| refcounting, too, but tracing GC usually not.)
| caim wrote:
| funny thing is that Malloc also behaves like an arena. When your
| program starts, Malloc reserves a lot of memory, and when your
| program ends, all this memory is released. Memory Leak ends up
| not being a problem with Memory Safety.
|
| So, you will still need a borrow checker for the same reasons
| Rust needs one, and C/C++ also needed.
| codedokode wrote:
| I never heard about this language, so I quickly looked through
| the docs and here is what I didn't like:
|
| - integers use names like "short" instead of names with numbers
| like "i16"
|
| - they use printf-like formatting functions instead of Python's
| f-strings
|
| - it seems that there is no exception in case of integer overflow
| or floating point errors
|
| - it seems that there is no pointer lifetime checking
|
| - functions are public by default
|
| - "if" statement still requires parenthesis around boolean
| expression
|
| Also I don't think scopes solve the problem when you need to add
| and delete objects, for example, in response to requests.
| Alifatisk wrote:
| Wow, this is such a fascinating concept. The syntax can't stop
| reminding me of @autoreleasepool from ObjC. I'll definitely try
| this out on a small project soon.
|
| Also, since D lang usually implements all kinds of possible
| concepts and mechanism from other languages, I would love to see
| those being implemented aswell! D already has a borrow checker no
| so why not also add this, would be very cool to play with it!
___________________________________________________________________
(page generated 2025-07-13 23:00 UTC)