5 ms·
Two Years of Rust
- ratmice 2y ago> with cargo you praise the absences: there’s no gotchas, no footguns, no lore you have to learn in anger, no weirdness, no environment variables Suppose the author doesn't use build.rs, which appears to have been composed of the listed things almost entirely.
- rcxdude 2y agobuild.rs is a useful escape hatch for if you need to do something more complicated, but the nice thing about cargo is that for the most part the defaults work. Generally build.rs only comes in if you have to deal with C, C++ or some other external ecosystem. A pure rust crate basically never needs to touch it, across multiple platforms and build configurations.
- ratmice 2y agoGenerally yes, but there are things like lalrpop which are pure rust, I personally fall within the pure rust build.rs user purview...
- dhruvrajvanshi 2y agoWell I wouldn't call lalrpop "Pure rust". It's a separate language that gets compiled to rust. It's within the rust ecosystem though. Perhaps cargo could expose a simpler way to use code generators.
- meltyness 2y agoNice to have when you still refuse to make learn
- vvern 2y agoMy biggest issue with rust after two years is just as you highlight: the mod/crate divide is bad! I want it to be easier to have more crates. The overhead of converting a module tree into a new crate is high. Modules get to have hierarchy, but crates end up being flat. Some of this is a direct result of the flat crate namespace. A lot of the toil ends up coming from the need to muck with toml files and the fact that rust-analyzer can’t do it for me. I want to have refactoring tools to turn module trees into crates easily. I feel like when I want to do that, I have to play this game of copying files then playing whack-a-mole until I get all the dependencies right. I wish dependencies were expressed in the code files themselves a la go. I think go did a really nice job with the packaging and dependency structure. It’s what I miss most.
- movpasd 2y agoIt's a surprising choice that Rust made to have the unit of compilation and unit of distribution coincide. I say surprising, because one of the tacit design principles I've seen and really appreciated in Rust is the disaggregation of orthogonal features. For example, classical object-oriented programming uses classes both as an encapsulation boundary (where invariants are maintained and information is hidden) and a data boundary, whereas in Rust these are separated into the module system and structs separately. This allows for complex invariants cutting across types, whereas a private member of a class can only ever be accessed within that class, including by its siblings within a module. Another example is the trait object (dyn Trait), which allows the client of a trait to decide whether dynamic dispatch is necessary, instead of baking it into the specification of the type with virtual functions. Notice also the compositionality: if you do want to mandate dynamic dispatch, you can use the module system to either only ever issue trait objects, or opaquely hide one in a struct. So there is no loss of expressivity.
- vvern 2y agoHow would you compare that to, say, go? I think the unit of distribution in go is a module, and the unit of compilation is a package. That being said, by using `internal` packages and interfaces you can similarly create the same sort of opaque encapsulation.
- mamcx 2y agoHard agree. Is retrospect I think the model of Delphi, where you must assemble `manually` a `pkg` so you can export to the world should have been used instead. It also have solved the problem where you ended doing a lot of `public` not because the logic dictated it, but as only way to share across crates. It should have been all modules (even main.rs with mandatory `lib.rs` or whatever) and `crate` should have been a re-exported interface.
- steveklabnik 2y ago> Hard agree. Is retrospect I think the model of Delphi, where you must assemble `manually` a `pkg` so you can export to the world should have been used instead. Very very old Rust had "crate files" which were this https://github.com/rust-lang/rust/blob/a8eeec1dbd7e06bc811e55c641b6c282807997a5/src/test/run-pass/native.rc https://github.com/rust-lang/rust/blob/a8eeec1dbd7e06bc811e5... .rc standing for "rust crate" There's pros and cons here. I'm of two minds.
- meltyness 2y agoGripes about the borrow checker I think would be cured with the following surprising fact, and interesting "problem solving" approach to the language's design: In Rust there's at least 5 types of everything, in order of strength: - Value / unqualified / "owned" - Generically, T - Optionally mutable - Mutable Reference - &mut T - you can only have one of these for a given value - Reference / Shared reference - &T - you can have arbitrarily many for a given value - Raw constant pointer - *const T - you can have arbitrarily many, and they're not liveness checked - Raw mutable pointer - *mut T - you can have arbitrarily many, and they're not liveness checked Now I say at least because things get appreciably more complicated when you find yourself dealing with lifetimes which apply to "References", those are indeed themselves types, but ultimately represent a compiler-executed calculus regarding liveness relative to some Value. They also can 'fan out' like a multiple-dereference pointer, but the tricky part is how the APIs for Types conform to these, for example; Since there are 3 different types of things in a collection, then there are 3 different ways to iterate over them `iter()`, `iter_mut()`, `into_iter()` in increasing order of strength. Most of the breadth or early complexity arises from the urge to treat these as a distraction, rather than a fundamental aspect of systems code. Crates / modules are a bit of a meme: https://www.reddit.com/r/rust/comments/ujry0b/media_how_to_create_a_module_hierarchy_in_rust/ https://www.reddit.com/r/rust/comments/ujry0b/media_how_to_c... Bevy has done some work investigating build performance: https://bevyengine.org/learn/quick-start/getting-started/setup/#enable-fast-compiles-optional https://bevyengine.org/learn/quick-start/getting-started/set...
- nathan_douglas 2y agoI feel like it's intuitive for me to think about this stuff as just a second type system rather than to think about the details of how the compiler works or how it'll function at runtime. A given value exists in a kind of superposition, and I pick the form I want it to collapse into (value, reference, mutable reference, etc) based on the tradeoffs I need at that moment. I don't know exactly why this is helpful, or if it's helpful (or even coherent) to anyone but me. It might also be damning me to some conceptual outer darkness where I'll hear the pounding of accursed drums and the wailing of hideous flutes forevermore.
- nusaru 2y ago> Personally I didn’t have borrow checker problems, but that’s because before I started using Rust at work I’d designed and built my own borrow checker. I don’t know if that’s a scalable pedagogy. Yeah... maybe not, but I can see this being a project in an undergraduate course.
- williamcotton 2y agoPlease don't take this the wrong way, it's just the editor in me. There's a typo at the end of the Error Handling section: When you need to explicitly handle an error, you omit the question mark operator and use thw Result value directly.
- zetalyrae 2y agoThank you! Fixed.
- ninetyninenine 2y agoThe mock example looked pointless. IO can’t be unit tested hence why you mock it. But his code didn’t do anything but confirm his mock worked. He’s writing mocks and testing mocks. The functionality he referenced is just inherently not unit testable. Again, If you try to mock it and test things you end up testing your mocked code. That’s it. I’ve seen this strange testing philosophy pop up time and time again where test code misses a shit load of errors because it’s just confirming that the mocks work. For this area you need to move to integration tests if you want to confirm it works. This comes with the pain of rewriting tests should the implementations change but testing just mocks isn’t solving this problem. Your unit tests only really matter if you’re doing a lot of big algorithm stuff and not much IO. Mocking helps if you have some IO sprinkled into a unit computation. In the example he gave every operation was IO and every operation had to be mocked so wtf was he thinking to want to place that in a unit test?
- Joker_vD 2y ago> IO can’t be unit tested hence why you mock it. Say, I have this module that uses a private MongoDB as a cache. Its unit tests spin up a standard MongoDB container and use it (and then tear it down). Are they still unit tests or should I start calling them "integration tests"?
- ninetyninenine 2y agoIntegration. Unit tests should just test code units. All the external stuff should be mocked or not tested. The example in the post is a unit test. It’s good to keep it separate as unit tests are really easy to run and less complicated and much faster. Integration tests are much more complicated and often sort of freeze your code as it locks in the implementation.
- joshfee 2y agoHow you define "external stuff" matters though. As soon as your function is calling another function, your test can be argued to be an "integration test", as you're now implicitly also testing the logic of the other function. Alternatively you mock _everything_ and then your "unit test" ends up just being a tautological test asserting that "the code I wrote executes in the way I wrote it". (Not to mention that every time you mock something you are also implicitly asserting what the expected behavior of that thing is). The only truly reliable tests are E2E tests, but they are too expensive to cover all possible permutations as there are just too many. This is the catch 22 with testing, and we're always forced to make pragmatic choices about where to draw our boundaries to maximize the value (i.e. actual bugs caught) of them.
- apitman 2y ago> The way I would summarize Rust is: it’s a better Go, or a faster Python That's an interesting take. I feel like all three of these languages fit into pretty discrete lanes that the others don't. Python for quick hacking or scientific stuff, Go for web services and self-contained programs, Rust for portability (specifically sharing code as C ABI or WASM) and safety. > It’s not hard to learn I agree Rust is easy to learn. I've done it 4 or 5 times now.
- dominicrose 2y agoPython requires less lines of code (much less?). Comparing Ruby with Python wouldn't shock me.
- steveklabnik 2y agoReally depends on what you're doing, and how you write the Rust. I'd never claim this is always true, but Rust can be pretty concise depending on what you're doing. See this previous comment of mine, which links to some others with an example I've evolved over time on this forum: https://news.ycombinator.com/item?id=42312721 https://news.ycombinator.com/item?id=42312721
- kelafoja 2y agoI agree, there are only superficial similarities. Like they're all 3 C-based. And Go and Rust both compile to machine code. I believe once one of the creators of Go mentioned that it felt to some users "like a faster Python". But I have no clue how Python relates to Rust in any sense, I fail to see any similarities. In fact, I would almost be inclined to say that Python and Rust have more differences than similarities.
- woadwarrior01 2y ago> I would almost be inclined to say that Python and Rust have more differences than similarities. This is somewhat of a stretch: dyn Traits in Rust are sort of like compile time duck typing. OTOH, interfaces in Go and virtual functions in C++ are the same thing.
- Dowwie 2y agoUse dependency injection and mock behaviors. This technique works in several programming languages, including Rust. Rust has modules, crates and workspaces. To optimize builds, you'll eventually move shared resources to their own crate(s).
- BlackFly 2y agoI feel in rust you want to be a lot more judicious in where you introduce and deal with traits than in other languages with interfaces. Author blames lifetimes for this but I think the truth is that it is because there is no garbage collector so not everything is a fat pointer and fat pointers cannot have generic methods anyways because generic methods are monomorphized so they feel a bit lame even if you would reach for them. Thus you almost certainly need parametric polymorphism whereas other languages described would use implementation/interface/inheritance/duck polymorphism. Parametric polymorphism explodes rapidly if you aren't judicious and it doesn't feel very agile. Once you are dealing in traits, does that trait have a copy bound or am I going to need to take a borrow and also grab a lifetime next to my trait parameter? Or should I just hide it all by slapping my mock with an `impl Trait for Arc<RefCell<Mock>>` or equivalent?
- steveklabnik 2y agoHere's how I currently am doing it: I use the repository pattern. I use a trait: pub trait LibraryRepository: Send + Sync + 'static { async fn create_supplier( &self, request: supplier::CreateRequest, ) -> Result<Supplier, supplier::CreateError>; I am splitting things "vertically" (aka by feature) rather than "horizontally" (aka by layer). So "library" is a feature of my app, and "suppliers" are a concept within that feature. This call ultimately takes the information in a CreateRequest and inserts it into a database. My implementation looks something like this: impl LibraryRepository for Arc<Sqlite> { async fn create_supplier( &self, request: supplier::CreateRequest, ) -> Result<Supplier, supplier::CreateError> { let mut tx = self .pool .begin() .await .map_err(|e| anyhow!(e).context("failed to start SQLite transaction"))?; let name = request.name().clone(); let supplier = self.create_supplier(&mut tx, request).await.map_err(|e| { anyhow!(e).context(format!("failed to save supplier with name {name:?}")) })?; tx.commit() .await .map_err(|e| anyhow!(e).context("failed to commit SQLite transaction"))?; Ok(supplier) } where Sqlite is #[derive(Debug, Clone)] pub struct Sqlite { pool: sqlx::SqlitePool, } You'll notice this basically: 1. starts a transaction 2. delegates to an inherent method with the same name 3. finishes the transaction The inherent method has this signature: impl Sqlite { async fn create_supplier( self: &Arc<Self>, tx: &mut Transaction<'_, sqlx::Sqlite>, request: supplier::CreateRequest, ) -> Result<Supplier, sqlx::Error> { So, I can choose how I want to test: with a real database, or without. If I want to write a test using a real database, I can do so, by testing the inherent method and passing it a transaction my test harness has prepared. sqlx makes this really nice. If I'm testing some other function, and I want to mock the database, I create a mock implementation of LibraryService, and inject it there. Won't ever interact with the database at all. In practice, my application is 95% end-to-end tests right now because a lot of it is CRUD with little logic, but the structure means that when I've wanted to do some more fine-grained tests, it's been trivial. The tradeoff is that there's a lot of boilerplate at the moment. I'm considering trying to reduce it, but I'm okay with it right now, as it's the kind that's pretty boring: the worst thing that's happened is me copy/pasting one of these implementations of a method and forgetting to change the message in that format!. I am also not 100% sure if I like using anyhow! here, as I think I'm erasing too much of the error context. But it's working well enough for now. I got this idea from https://www.howtocodeit.com/articles/master-hexagonal-architecture-rust https://www.howtocodeit.com/articles/master-hexagonal-archit..., which I am very interested to see the final part of. (and also, I find the tone pretty annoying, but the ideas are good, and it's thorough.) I'm not 100% sure that I like every aspect of this specific implementation, but it's served me pretty well so far.
- q3k 2y ago> Error Handling I've yet to see anyone demonstrate the elegance Rust error handling for anything but the simplest of cases. It's all fun and games and question marks... until you hit this: $ ./app called `Result::unwrap()` on an `Err` value: no such file or directory And then you start investigating and it turns out that the error value comes from somewhere deep in an unknown callstack that got discard by the authors using '?' everywhere. Yes, I know about anyhow and thiserror and eyre and ... ; point is none of this is ever shown in these 'look how elegant error handling is' posts. Come on, let's be a bit more honest with ourselves about Result<T, E> and '?' - it's not a full solution to error handling. After two years I'm sure you've hit this.
- constantcrying 2y ago?ing Errors and never actually handling them is just a terrible practice. In fact it is just as bad as not doing error checking at all. Misusing a mechanism is not a point against the language. What makes this error checking good is that you can use it correctly and it is less cumbersome than the try/catch from C++. >Come on, let's be a bit more honest with ourselves about Result<T, E> and '?' - it's not a full solution to error handling. After two years I'm sure you've hit this. Nothing is ever a full solution. But it is meaningless to talk about this without doing comparisons. Do you think try/catch was the superior solution all along?
- q3k 2y ago> ?ing Errors and never actually handling them is just a terrible practice. I agree, but that's kind of my point - that's all these Rust praise articles are ever showing :). > But it is meaningless to talk about this without doing comparisons. Not comparing to other languages/approaches however allows a discussion to stay about Rust and how to make things better instead of yet another fruitless discussion about which language or approach is better in a vacuum. I'm not interested in demonstrating that Rust is better than C++ or vice versa, I'm interested in Rust being good on its own merits.
- constantcrying 2y ago
- deleted 2y ago[deleted]
- dfee 2y agoOn mocking your database transaction: I was there, and on behalf of an LLM I reasoned with, I refactored that bit out to be an actor that carries the transaction, and passing messages (alongside a oneshot channel for return value). The benefit isn’t just that you’re not managing a transaction, but also that you don’t need to coordinate the transaction between multiple async paths where lifetimes and ownership really blow up! I didn’t use an actor framework, for better or worse, but rolled my own. Great write up!
- windward 2y ago>Rather than mentally implementing something in C/C++, and then thinking, “how do I translate this to Rust in a way that satisfies the borrow-checker?”, it’s better to think, “how can I accomplish the goal within the semantics of Rust, thinking in terms of linearity and lifetimes?”. I still do think about it all in C++ terms. Borrowing and ownership are just specific terminology for things that you must know to create a correct C++ program of any useful complexity and performance. Two common anti-Rust arguments are: - It's hard to learn - You should just git gud at C++ But the Euler diagram of people who struggle with the borrow checker and people who are gud enough at C++ has no overlap. Likewise String and &str. I also think it means the performance user story is, somehow, underappreciated, especially for naive users. Immutable borrows and moves are just part of the development experience, and copying is the unobvious path. And if you still struggle you can often just toss in `rayon` in ways that you never could with `std::execution::par`
- kmaliszewski 2y ago"The two areas where it’s not yet a good fit are web frontends (though you can try) and native macOS apps." Could you please elaborate?
- steveklabnik 2y agoAt Oxide, we named the company after Rust, but we use TypeScript on the frontend, not Rust. Rust is our default technology choice for most new code, but TypeScript is for frontend code. Rust on web frontends is just not super mature. You can do things with it, and it's very cool, but TypeScript is a very mature technology at this point, and gives a lot of similar benefits to Rust. And it can live natively in a browser context, without complex bindings. I don't work on native macOS apps, but I'm assuming it's similar: Objective-C or Swift are expected, so you end up needing to bind to APIs that aren't always natural feeling. I could see why you'd want to do something similar: write your core in Rust, but make the UI stuff be in Swift, and call into it from there.
- gr4vityWall 2y agolove to hear from Oxide folk :) y'all do great stuff and great write-ups.
- steveklabnik 2y agoThanks!
- hwj 2y ago@zetalyrae, given you implemented Austral in OCaml I'd really like to know what you think about Rust vs. OCaml ;-)