Hacker Newsnew | past | comments | ask | show | jobs | submitlogin

Your supposition about Rust is correct.

I’ll add that—having paid that upfront cost—I am happily reaping the rewards even when I write code in other languages. It turns out the way that Rust “wants” to be written is overall a pretty good way for you to organize the relationships between parts of a program. And even though the borrow checker isn’t there looking out for you in other languages, you can code as if it is!



I had a similar experience with Erlang/Elixir. The primary codebase I work with in $DAYJOB is C++ but structured very OTP-like with message passing and threads that can crash (via exceptions, we’re not catching defaults for example) and restart themselves.

Because of the way we’ve set up the message passing and by ensuring that we don’t share other memory between threads we’ve virtually eliminated most classes of concurrency bugs.


That's the main use-case of Erlang/Elixir anyway: eliminate concurrency / parallelism bugs by copying data in the dangerous places, and make sure not to use locks but message passing instead. These two alone have eliminated most of the bugs I've wrote in other languages.

So, same experience. And it taught me to be a better Golang and Rust programmer, too.


This is why I think cross-training is so important and I should do more of it. Even something relatively minor, like doing the Advent of Code in elixir (nim last year) has made my python significantly easier to reason about. If you quit mutating state so much, you eliminate whole classes of bugs.


That's also how I tend to program after having read Joe Armstrong's dissertation "Making reliable distributed systems in the presence of sodware errors" and having programmned in Go for a few years.


I'm fortunate enough not to have to often write code in other languages anymore, but my experience that writing code in ways that satisfies the compiler actually ends up being code I prefer anyhow. I was somewhat surprised at the first example because I haven't run into something like that, but it's also not really the style I would write that function personally (I'm not a big fan of repetitions like having `Some(x)` repeated both as a capture pattern and a return value), so on a whim I tried what would have been the way I'd write that function, and it doesn't trigger the same error:

    fn double_lookup_mut(map: &mut HashMap<String, String>, mut k: String) -> Option<&mut String> {
        map.get_mut(&k)?;
        k.push_str("-default");
        map.get_mut(&k)
    }
I wouldn't have guessed that this happened to be a way around a compiler error that people might run into with other ways of writing it; it just genuinely feels like a cleaner way for me to implement a function like that.


Isn't that the opposite of the intended implementation? I don't write Rust, but I think your implementation will always return either `None` or the "fallback" value with the `"-default"` key. In the article, the crucial part is that if the first `map.get_mut()` succeeds, that is what is returned.


Whoops, you're definitely right. This is why I shouldn't try to be productive in the morning.


A great example of how "if it compiles, it runs correctly" is bullshit.


You're reaching pretty hard there. Your assertion is a massive strawman, the implication seeming to be that "every problem in your logic won't exist if it compiles" - no one thinks you can't write bad logic in any language.

Rather it's about a robust type system that gives you tooling to cover many cases at compile time.

Even if we ignore logic, Rust has plenty of runtime tooling and runtime issues can still happen as a result. A complaint i often have about Bevy (despite loving it!) is that it has a lot of runtime based plugins/etc which i'd prefer to be compile time. Axum for example has a really good UX while still being heavily compile time (in my experience at least).

"If it compiles it works" is still true despite my complaints. Because i don't believe the statement even remotely implies you can't write bad logic or bad runtime code in Rust.


This particular example explicitly dodges compile time checking for some ad-hoc (but likely safe) runtime behavior. It’s not a strawman at all. It’s a classic example of how sometimes the compiler can’t help you, and even worse, how programmers can defeat their ability to help you.


Right but their statement (as i parsed it) was that the the "if it compiles it works" phrase is bullshit. Since there's some cases where it obviously won't be true.

At best it's ignorant of what that phrase means, in my view.


> Since there's some cases where it obviously won't be true.

That's not how I've seen it used every that I have seen it used.

> At best it's ignorant of what that phrase means, in my view.

I think your opinion on what the phrase means is a minority opinion.


If it was as wide as the OP said then it means errors, panics, and especially Unsafe wouldn't exist. Even if we ignore unclean sources (say network errors/etc), this isn't a Proofed language where programs in it cannot possibly fail.

Besides, it likely is very possible to write programs that cannot fail in Rust. This usually means encoding state into the type system (enums included), but few go through that work. They know what corners they're cutting. Further proving that they know their program can fail.

Hell, Rust itself can fail. I struggle to imagine how this is perceived as some long-con from the Rust PR team to convince people Rust programs cannot be written with incorrect logic.


I think they were highlighting that that phrase is bullshit. It’s trivial to escape many compile time checks.


Yea, but that's my argument - that they're being dense (i imagine on purpose?). The phrase doesn't mean that nothing can fail at runtime. Of course it doesn't.

Rather that we have many tools to write a program that can be written with many compile time checks. For example many representations of state can be describe in compile time checks via enums, type transitions, etc.


> The phrase doesn't mean that nothing can fail at runtime.

That's exactly what that phrase means, you're twisting the actual words in the phrase to be able to arrive at something more acceptable in your own mind. All you're saying is "oh you shouldn't take it literally". I can guarantee most people take that literally and it's bullshit.


So you think everyone using that phrase thinks it's not possible to fail at all at runtime, in any manner?

Unsafe, Panic and Error would like a word.


Honestly, I think the majority of the times I've said that sentence has been after running code that has an obvious mistake (like the code I posted above)!


As someone who is interested in getting more serious with Rust, could you explain the essence of how you should always approach organizing code in Rust as to minimize refactors as the code grows?


In my experience there are two versions of "fighting the borrow checker". The first is where the language has tools it needs you to use that you might not've seen before, like enums, Option::take, Arc/Mutex, channels, etc. The second is where you need to stop using references/lifetimes and start using indexes: https://jacko.io/object_soup.html


>and start using indexes

So basically raw pointers with extra hoops to jump through.


Yep. The array index pattern is unsafe code without the unsafe keyword. Amazing how much trouble Rust people go through to make code "safe" only to undermine this safety by emulating unsafe code with safe code.


It’s not the same. The term “safe” has a specific meaning in rust: memory safety. As in:

- no buffer overflows - no use after free - no data races

These problems lead to security vulnerabilities whose scope extends beyond your application. Buffer overflows have historically been the primary mechanism for taking over entire machines. If you emulate pointers with Rust indices and don’t use “unsafe”, those types of attacks are impossible.

What you’re referring to here is correctness. Safe Rust still allows you to write programs which can be placed in an invalid state, and that may have security implications for your application.

It would be great if the compiler could guarantee that invalid states are unreachable. But those types of guarantees exist on a continuum and no language can do all the work for you.


"Safe" as a colloquial meaning: free from danger. The whole reason we care about memory safety is that memory errors become security issues. Rust does nothing to prevent memory leaks and deadlocks, but it does prevent memory errors becoming arbitrary code execution.

Rust programs may contain memory errors (e.g. improper use of interior mutability and out of bounds array access), but the runtime guarantees that these errors don't become security issues.

This is good.

When you start using array indices to manage objects, you give up some of the protections built into the Rust type system. Yes, you're still safe from some classes of vulnerability, but other kinds of vulnerabilities, ones you thought you abolished because "Rust provides memory safety!!!", reappear.

Rust is a last resort. Just write managed code. And if you insist on Rust, reach for Arc before using the array index hack.


I tend to agree w.r.t. managed languages.

Still, being free from GC is important in some domains. Beyond being able to attach types to scopes via lifetimes, it also provides runtime array bounds checks, reference-counting shared pointers, tagged unions, etc. These are the techniques used by managed languages to achieve memory-safety and correctness!

For me, Rust occupies an in-between space. It gives you more memory-safe tools to describe your problem domain than C. But it is less colloquially "safe" than managed languages because ownership is hard.

Your larger point with indices is true: using them throws away some benefits of lifetimes. The issue is granularity. The allocation assigned to the collection as a whole is governed by rust ownership. The structures you choose to put inside that allocation are not. In your user ID example, the programmer of that system should have used a generational arena such as:

https://github.com/fitzgen/generational-arena

It solves exactly this problem. When you `free` any index, it bumps a counter which is paired with the next allocated index/slot pair. If you want to avoid having to "free" it manually, you'll have to devise a system using `Drop` and a combination of command queues, reference-counted cells, locks, whatever makes sense. Without a GC you need to address the issue of allocating/freeing slots for objects within in an allocation in some way.

Much of the Rust ecosystem is libraries written by people who work hard to think through just these types of problems. They ask: "ok, we've solved memory-safety, now how can we help make code dealing with this other thing more ergonomic and correct by default?".


Absolutely. If I had to use an index model in Rust, I'd use that kind of generational approach. I just worry that people aren't going to be diligent enough to take precautions like this.


'generational-arena' is unmaintained and archived now.


Even when you use array indices, I don't think you give those protections up. Maybe a few, sure, but the situation is still overall improved.

Many of the rules references have to live by, are also applied to arrays:

- You cannot have two owners simultaneously hold a mutable reference to a region of the array (unless they are not overlapping)

- The array itself keeps the Sync/Send traits, providing thread safety

- The compiler cannot do provenance-based optimizations, and thus cannot introduce undefined behavior; most other kinds of undefined behavior are still prevented

- Null dereferences still do not exist and other classes of errors related to pointers still do not exist

Logic errors and security issues will still exist of course, but Rust never claimed guarantees against them; only guarantees against undefined behavior.

I'm not going to argue against managed code. If you can afford a GC, you should absolutely use it. But, compared to C++, if you have to make that choice, safety-wise Rust is overall an improvement.


You can still have use-after-free errors when you use array indices. This can happen if you implement a way to "free" elements stored in the vector. "free" should be interpreted in a wide sense. There's no way for Rust to prevent you from marking an array index as free and later using it.


> There's no way for Rust to prevent you from marking an array index as free and later using it.

I 2/3rds disagree with this. There are three different cases:

- Plain Vec<T>. In this case you just can't remove elements. (At least not without screwing up the indexes of other elements, so not in the cases we're talking about here.)

- Vec<Option<T>>. In this case you can make index reuse mistakes. However, this is less efficient and less convenient than...

- SlotMap<T> or similar. This uses generational indexes to solve the reuse problem, and it provides other nice conveniences. The only real downside is that you need to know about it and take a dependency.


The consequences of use-after-free are different for the two.

In rust it is a logic error, which leads to data corruption or program panics within your application. In C it leads to data corruption and is an attack vector for the entire machine.

And yes, while Rust itself doesn’t help you with this type of error, there are plenty of Rust libraries which do.


The difference is that the semantics of your program are still well-defined, even with bugs in index-based arenas.


The semantics of a POSIX program are well-defined under arbitrary memory corruption too --- just at a low level. Even with a busted heap, execution is deterministic and the every interaction with the kernel has defined behavior --- even if they behavior is SIGSEGV.

Likewise, safe but buggy Rust might be well-defined at one level of abstraction but not another.

Imagine an array index scheme for logged-in-user objects. Suppose we grab an index to an unprivileged user and stuff it in some data structure, letting it dangle. The user logs out. The index is still around. Now a privileged user logs in and reuses the same slot. We do an access check against the old index stored in the data structure. Boom! Security problems of EXACTLY the sort we have in C.

It doesn't matter that the behavior is well-defined at the Rust level: the application still has an escalation of privilege vulnerability arising from a use-after-free even if no part of the program has the word u-n-s-a-f-e.


Undefined behavior in C/C++ has a different meaning than you're using. If a compiler encounters a piece of code that does something whose behavior is undefined in the spec, it can theoretically emit code that does anything and still be compliant with the standards. This could include things like setting the device on fire and launching missiles, but more typically is something seemingly innocuous like ignoring that part of the code entirely.

An example I've seen in actual code: You checked for null before dereferencing a variable, but there is one code path that bypasses the null check. The compiler knows that dereferencing a null pointer is undefined so it concludes that the pointer can never be null and removes the null checks from all of the code paths as an "optimization".

That's the C/C++ foot-gun of undefined behavior. It's very different from memory safety and correctness that you're conflating it with.


From the kernel's POV, there's no undefined behavior in user code. (If the kernel knew a program had violated C's memory rules, it could kill it and we wouldn't have endemic security vulnerabilities.) Likewise, in safe Rust, the access to that array might be well defined with respect to Rust's view of the world (just like even UB in C programs is well defined from the kernel POV), but it can still cause havoc at a higher level of abstraction --- your application. And it's hard to predict what kind of breakage at the application layer might result.


Sort of. But you still get guaranteed-unaliased references when you need them. And generational indexes (SlotMap etc) let you ask "has this pointer been freed" instead of just hoping you never get it wrong.


and you now have unchecked use-after-decommisioning-the-index and double-decommission-the-index errors, which could be security regressions


That's true only if you use Vec<T> instead of a specialized arena, either append only, maybe growable, or generational, where access invalidation is tracked for you on access.


Yeah if you go with Vec, you have to accept that you can't delete anything until you're done with the whole collection. A lot of programs (including basically anything that isn't long running) can accept that. The rest need to use SlotMap or similar, which is an easy transition that you can make as needed.


> So basically raw pointers with extra hoops to jump through.

That's one way to look at it.

The other way is: raw pointers, but with mechanical sympathy. Array based data structures crush pointer based data structures in performance.


> Array based data structures crush pointer based data structures in performance

Array[5] And *(&array + 5) generates the same code... Heap based non-contiguous data structures definitely are slower than stackbased contiguous data structures.

How you index into them is unrelated to performance.

Effectively pointers are just indexes into the big array which is system memory... I agree with parent, effectively pointers without any of the checks pointers would give you.


> pointers are just indexes into the big array which is system memory...

I’m sure you are aware but for anyone else reading who might not be, pointers actually index into your very own private array.

On most architectures, the MMU is responsible for mapping pages in your private array to pages in system memory or pages on disk (a page is a subarray of fixed size, usually 4 KiB).

Usually you only get a crash if you access a page that is not currently allocated to your process. Otherwise you get the much more insidious behaviour of silent corruption.


>How you index into them is unrelated to performance.

Not true. If you store u32 indices, that can impose less memory/cache pressure than 64-bit pointers.

Also indices are trivially serializable, which cannot be said for pointers.


I'll happily look at a benchmark which shows that the size of the index has any significant performance implications vs the work done with the data stored at said index, never mind the data actually stored there.

I haven't looked closely at the decompiled code but I wouldn't be surprised if iterating through a contiguous data structure has no cache pressure but is rather just incrementing a register without a load at all other than the first one.

And if you aren't iterating sequentially you are likely blowing the cache regardless purely based on jumping around in memory.

This is an optimisation that may be premature.

EDIT:

> Also indices are trivially serializable, which cannot be said for pointers

Pointers are literally 64bit ints... And converting them to an index is extremely quick if you want to store an offset instead when serialising.

I'm not sure if we are missing each other here. If you want an index then use indices. There is no performance difference when iterating through a data structure, there may be some for other operations but that has nothing to do with the fact they are pointers.

Back to the original parent that spurred this discussion... Replacing a reference (which is basically a pointer with some added suger) with an index into an array is effectively just using raw pointers to get around the borrow checker.


> Pointers are literally 64bit ints... And converting them to an index is extremely quick if you want to store an offset instead when serialising.

I'm not them, but they're saying pointer based structures are just less trivial to serialize. For example, to serialize a linked list, you basically need to copy them into an array of nodes, replacing each pointer to a node with a local offset into this array. You can't convert them into indices just with pointer arithmetic because each allocation was made individually. Pointer arithmetic assumes that they already exist in some array, which would make the use of pointers instead of indices inefficient and redundant.


I understand that entirely, a link list is a non-contiguous heap based data structure.

What I am saying is if you store a reference to an item in a Vec or an index to an item to a Vec it is an implementation detail and looking up the reference or the index generates effectively the same machine code.

Specifically in the case that I'm guessing they are referring to which is the optimisation used in patterns like ECS. The optimisation there is the fact that it is stored contiguously in memory and therefore it is trivial to use SIMD or a GPU to do operations on the data.

In that case whether you are storing a u32 or size_t doesn't exactly matter and on a 32bit arch is literally equivalent. It's going to be dwarfed by loading the data into cache if you are randomly accessing the items or by the actual operations done to the data or both.

As I said, sure use an index but that wasn't the initial discussion. The discussion was doing it to get around the borrow check which is effectively just removing the borrow checker from the equation entirely and you may as well have used a different language.


The main benefit from contiguous storage is it can be a better match to the cache. Modern CPUs read an entire cache line in a burst. So if you're iterating through a contiguous array of items then chances are the data is already in the cache. Also the processor tends to prefetch cache lines when it recognizes a linear access pattern, so it can be fetching the next element in the array while it's working on the one before it.


> Pointers are literally 64bit ints... And converting them to an index is extremely quick if you want to store an offset instead when serialising.

This implies serialisation/deserialisation passes, so you can't really let bigger-than-ram data live on your disk.


> stop using references/lifetimes and start using indexes

Aren't arenas a nicer suggestion? https://docs.rs/bumpalo/latest/bumpalo/ https://docs.rs/typed-arena/latest/typed_arena/

Depending on the use case, another pattern that plays very nicely with Rust is the EC part of ECS: https://github.com/Ralith/hecs


Yes, Slab and SlotMap are the next stop on this train, and ECS is the last stop. But a simple Vec can get you surprisingly far. Most small programs never really need to delete anything.


> It turns out the way that Rust “wants” to be written is overall a pretty good way for you to organize the relationships between parts of a program

That's what it promised not to do, though! Zero cost abstractions aren't zero cost when they force you into a particular design. Several of the cases in the linked article involve actual runtime and code size overhead vs. the obvious legacy/unchecked idioms.


> vs. the obvious legacy/unchecked idioms

You can go crazy with legazy/unchecked/unsafe stuff if you want to in Rust. It's less convenient and more difficult than C in some ways, but 1) it's also safer and more convenient in other ways, and 2) "this will be safe and convenient" isn't exactly the reason we dive into legacy/unchecked/unsafe stuff.

And of course the greatest strength of the whole Rust language is that folks who want to do crazy unsafe stuff can package it up in a safe interface for the rest of us to use.


> crazy unsafe stuff

The first example in the linked article is checking if a value is stored in a container and doing something different if it's not than if it is. Hardly "crazy unsafe stuff".


I think there's an important distinction here. A systems programming language needs to have all the max speed / minimum overhead / UB-prone stuff available, but that stuff doesn't need to be the default / most convenient way of doing things. Rust heavily (both syntactically and culturally) encourages safe patterns that sometimes involve runtime overhead, like checked indexing, but this isn't the same as "forcing" you into these patterns.


And I can only repeat verbatim: The first example in the linked article is checking if a value is stored in a container and doing something different if it's not than if it is.

Hardly "max speed / minimum overhead / UB-prone stuff"



It’s sad to see your comment this far in the thread after having had go be coxed to make it and after all the usual defensive arguments from the Rust crowd. It’s a real issue that it’s so hard to make the Rust community admits that obvious flaws actually exist.

As someone who spent a significant time working on static analysers and provers, it annoys me to no end how most of the Rust community will happily take what the borrow checker imposes on them as some form of gospel and never questions the tool. It’s bordering on Stockholm syndrome sometimes.


I think a lot of pushback is because people are talking past each other.

The reality:

- the borrow checker has limitations that doesn't accept some constructs that could be be proved safe, given Rust's own rules

- the borrow checker is a net positive as it does push you towards better constructs, and the (rare) times it forbids you from doing something that could be safe, you have safe (sometimes not zero-cost) and unsafe escape hatches

But these are then understood by the intransigent as:

- the borrow checker is always detrimental

- the borrow checker can do nothing wrong

At that point, no one can understand why the other person is being obtuse, and you end up with... well, the comment section under every Rust article.


Rust absolutely forces you into a particular design - that's what the borrow checker is about.

It's definitely possible to write correct code (in unsafe Rust or other languages) that wouldn't satisfy the borrow checker.

Rust restricts you, and in turn gives you guarantees. Zero cost only means that you don't pay extra (CPU/memory) at runtime, whereas in interpreted/GC languages you do.


> Zero cost only means that you don't pay extra (CPU/memory) at runtime, whereas in interpreted/GC languages you do.

But... again, you do. The examples in the linked articles have runtime overhead. Likewise every time you wrap something in with Box or vec or Arc to massage the lifetime analysis, you're incurring heap overhead that is actually going to be worse than what a GC would see (because GC's don't need the extra layer of indirection or to deal with reference counts).

It's fine to explain this as acceptable or good design or better than the alternatives. It's not fine to call it "Zero Cost" and then engage in apologia and semantic arguments when challenged.


We should distinguish code that's correct using the borrowing metaphor but won't pass borrowck in current Rust (such code will inevitably exist thanks to Rice's Theorem) from code that's not correct under this metaphor but would actually work, under some other model for reference or pointer types.

Because Rust is intended for systems programming it is comfortable (in unsafe) expressing ideas which cannot be modelled at all. Miri has no idea how the MMIO registers for the GPIO controller work, so, too bad, Rust can't help you achieve assurance that your GPIO twiddling code is correct in your 1200 byte firmware. But, it can help you when you're talking about things it does have a model for, such as its own data structures, which obey Rust's rules (in this case the strict provenance rule) not "memory" that's actually a physical device register.


It sounds like the ideal, then, would be to detect the problematic patterns earlier so people wouldn't need to bang their heads against it.


Why would you cling to some cockamamie memory management model, where it is not required or enforced?

That's like Stockholm Syndrome.




Guidelines | FAQ | Lists | API | Security | Legal | Apply to YC | Contact

Search: