diff --git a/content/blog/hiring-rust-engineers/index.md b/content/blog/hiring-rust-engineers/index.md index 6f1df915..81042e7b 100644 --- a/content/blog/hiring-rust-engineers/index.md +++ b/content/blog/hiring-rust-engineers/index.md @@ -1,7 +1,7 @@ +++ title = "How to Hire Rust Developers" date = 2024-07-24 -updated = 2025-05-19 +updated = 2026-07-20 template = "article.html" [extra] hero = "hero.svg" @@ -13,133 +13,92 @@ credits = [ ## The Rust Hiring Mismatch -There's a curious mismatch in the Rust hiring market where some companies -believe there's a shortage of qualified candidates, while many Rust programmers struggle to find jobs. Why is that? +Rust hiring has a weird split: companies say they cannot find qualified candidates, while Rust programmers say they cannot find Rust jobs. +A big reason is seniority. Most Rust openings target senior engineers. That leaves junior and mid-level developers on the sidelines, even when a few months of training would get them productive. -Most Rust jobs are for senior roles, leaving newcomers and mid-level devs out in the cold. Many of whom would be a great fit for these roles with a little bit of training. +Crypto is the exception. Rust developers are in demand there, but not everyone wants to work in crypto. Those companies have also pushed salaries high enough that teams in other industries struggle to compete. -The crypto industry is a notable exception, where Rust developers are in high demand, but not everyone wants to work in that space. -On top of that, crypto companies have inflated Rust developer salaries to unsustainable levels, making it difficult for other industries to compete for talent. +The result is frustrating for both sides. Companies ask for years of professional Rust experience in a language where that is still rare. Developers who want to work in Rust get filtered out because they are in the wrong location, need employee status instead of contract work, or have built Rust projects outside a paid job. -It's not impossible, though! However, many companies want unicorns with years of Rust experience in an industry where that's rare. Meanwhile, eager developers worldwide are ready to work but face barriers like location restrictions (US only), job security concerns (contract work only), or lack of "professional" Rust experience. It's a classic case of supply and demand completely missing each other, with both sides frustrated and opportunities lost. +## Is This Just the Developer Job Market? -## But Isn't this just the dev job market in general? +Partly. The same pattern exists elsewhere, but Rust makes it easier to see. The language is still young, and some companies add Rust to job ads because it attracts attention. -Partially! However, I think it's more pronounced in Rust because it's a relatively new language and companies add it to their job ads because it generates interest. - -The moment you try to get your foot in the door as a less experienced Rust developer, though, this mismatch becomes apparent. In tough market situations with fewer positions, companies tend to be more conservative and look for "safe bets" which means experts they are sure can hit the ground running – much to the detriment of less experienced developers. +The problem shows up when less experienced Rust developers try to get hired. In a tight market, companies reach for safe bets. They want people who already look like experts and can start producing on day one. That caution makes sense for the company, but it shuts out developers who could do the job after a short ramp-up. ## Why Write This Guide? -As a Rust consultant, it's in my best interest to grow the hiring pool for Rust developers: if we want to grow the real-world usage of Rust, we need to get way more developers into the ecosystem. There are signs that this is happening, but with some tweaking, the process can be highly accelerated. - -The broader consequence is that many companies are hesitant about adopting Rust and are therefore missing out on a rich ecosystem for building reliable and efficient services. This misconception is frustrating, as it often comes up when I talk to companies not yet using Rust. - -**A common statement I hear is: "The community is still too small."** +When I talk to teams about Rust, one concern comes up again and again: "Can we even hire for this?" Behind that question is usually a fear that the community is too small. -In reality, the Rust community has more than tripled in size over the past two years and currently has [3.7M users, of which 0.6M joined in the last six months alone.](https://www.developernation.net/resources/reports/state-of-the-developer-nation-24th-edition-q1-2023) +I don't think that matches the data. The Rust community has more than tripled in size over the past two years and now has [3.7M users, with 0.6M joining in the last six months alone.](https://www.developernation.net/resources/reports/state-of-the-developer-nation-24th-edition-q1-2023) Programming Language Communities Size -In contrast, companies that already use Rust in production rarely struggle with these issues and are generally very happy with their choice. If you're still evaluating Rust for your organization, check out our guide on [Why Rust in Production](/blog/why-rust/). - -Here are some of the quotes from the [season 1 finale of the 'Rust in Production' podcast](/podcast/s01e07-season-finale/?t=23%3A06): - -> I announced that we're working on this new core of the database in November of 2020 in a talk I did. And I said we were hiring and basically like we got a bunch of inbound interestbecause of the fact that it was written in Rust. -> – Paul Dix, Founder and CTO of [InfluxData](https://www.influxdata.com/) - -> Because as a small company, you can attract people because they want to work in Rust. And that's a big incentive to work for you. -> – Micah Wylde, Founder of [Arroyo](https://arroyo.dev/) +The hiring problem is real, but I think companies often look in the wrong place. Teams that have not adopted Rust worry that nobody is available. Teams that already use Rust in production often find that Rust attracts engineers who want to work with the language. If you're still weighing Rust for your organization, see [Why Rust in Production](/blog/why-rust/). -Because I saw many companies make the same mistakes over and over again, I wrote a guide to help companies find Rust talent. +The [season 1 finale of the Rust in Production podcast](/podcast/s01e07-season-finale/?t=23%3A06) had two examples. Paul Dix, Founder and CTO of [InfluxData](https://www.influxdata.com/), said InfluxData received a lot of inbound interest after announcing that its new database core was written in Rust. Micah Wylde, Founder of [Arroyo](https://arroyo.dev/), said Rust helped a small company stand out because people wanted to work with it. -If you are a hiring manager or a team lead looking to hire Rust developers, this guide is for you. Feel free to pass it on internally to help improve your hiring process. +I wrote this guide because I keep seeing companies make the same hiring mistakes. Hiring managers and team leads can use it to tighten job posts and interviews before concluding that Rust talent is impossible to find. ## Setting Talent Expectations #### Avoid Unrealistic Demands -Don't expect 10 years of Rust experience, as Rust is a relatively new language. (Rust 1.0 was released in 2015.) -Such exaggerated expectations can significantly (and unnecessarily) narrow your pool of potential candidates. Even worse, experienced developers might consider this a red flag as it shows a lack of understanding of the Rust community. +Don't ask for 10 years of Rust experience. Rust 1.0 shipped in 2015, so that requirement tells candidates you do not know the ecosystem well. -Instead, it might be enough to look for the *willingness* to learn Rust or, at most, non-production experience with the language. +It also narrows the pool for no good reason. A strong engineer with non-production Rust experience, or a clear desire to learn it, may be a better hire than someone who only checks the exact keyword box. #### Clearly Specify Tasks and Responsibilities -Don't just add 'Rust' as a keyword to attract more candidates. -If you don't (plan to) use Rust in any meaningful way, this will reflect poorly on your company and waste the time of both the candidate and the hiring team. +Don't add `Rust` to a job post just to attract more candidates. If the role will not use Rust in a meaningful way, say so. Otherwise you waste the candidate's time and your team's time. -Will the candidate have to work with other languages or technologies? -Outline the specific tasks and responsibilities that involve Rust. -Ideally, even mention the specific Rust libraries or frameworks you are using. -This helps candidates understand what is expected of them and which other ecosystems they might need to interact with. +Be specific about the work. Will the person write Rust every day, maintain an existing service, build new components, or work across several languages? Name the libraries and frameworks when you can. Candidates should know what Rust work they are signing up for and what other parts of the stack they will touch. ## Finding Candidates -Granted, the Rust job market is still relatively small compared to other languages like Python or JavaScript, but I found that most companies -limit themselves by only looking for senior Rust developers. That's a mistake. +The Rust job market is smaller than Python or JavaScript, but many companies make it smaller than it needs to be by only looking for senior Rust developers. -With some training, you can save a lot of time and money by hiring mid-level developers or smart juniors who are eager to learn Rust. -Yes, [there is a learning curve](/blog/flattening-rusts-learning-curve/), but it's probably less expensive to train a -junior/mid-level developer in Rust compared to hiring a senior/staff Rust developer. For training resources, see our curated list of [Rust Learning Resources](/blog/rust-learning-resources-2026/). +That is often the wrong tradeoff. Training a mid-level engineer, or a junior with strong fundamentals, can be cheaper than waiting for a senior Rust specialist. There is still [a learning curve](/blog/flattening-rusts-learning-curve/), but you can plan for it. For training material, see [Rust Learning Resources](/blog/rust-learning-resources-2026/). -Currently, the best way to find Rust devs is +Start close to home. Look inside your company if you have a training program, then ask your network. Many developers want to move into Rust, so interest is usually not the hard part. -1. in your company (if you have a training program). -2. through your network (ask around). +The hard part is fit. Location rules and salary range can filter out otherwise good candidates. So can the industry, contract terms, or a strict office policy. Senior Rust developers can be especially selective. If you cannot match crypto salaries, compete on the parts of the job you can control: remote work, flexible hours, conference travel, open source time, and stable employment. -From experience, you probably won't have a lot of trouble finding interested candidates as many developers are trying to move into Rust. -However, constraints like location, salary, industry, and job security can be a deal-breaker for many. +For a larger candidate pool, sponsor Rust-focused events, [conferences](/blog/rust-conferences-2025/), or [podcasts](/podcast). Sponsorship is still underused, and it puts your company in front of people who already care about Rust. -For more experienced Rust developers, perks like remote work, flexible hours, and a focus on work-life balance are often as important as the salary. -Being able to work on open source or attend conferences can also be a big bonus. -These folks are in high demand and can afford to be picky. -If you can't pay crypto money, at least give them some of the other perks -and be willing to make compromises. +Post jobs where Rust developers already look. Good starting points are [Filtra.io](https://filtra.io/rust), [RustJobs.dev](https://rustjobs.dev/), [RustJobs.fyi](https://www.rustjobs.fyi/), and [RemoteOK](https://remoteok.com/remote-rust-jobs). The monthly "Who is hiring?" threads on [Hacker News](https://news.ycombinator.com/) and [Reddit](https://www.reddit.com/r/rust/comments/182f6dv/official_rrust_whos_hiring_thread_for_jobseekers/) can also work well. -If you're looking for senior people or require a bigger pool of candidates, you might also want to consider sponsoring Rust-focused events, [conferences](/blog/rust-conferences-2025/), or [podcasts](/podcast). I found that sponsoring is a relatively underutilized channel with a lot of potential. +## Assessing Candidates for Rust Roles -You can post job listings on Rust-specific job boards like [Filtra.io](https://filtra.io/rust), [RustJobs.dev](https://rustjobs.dev/) and [RustJobs.fyi](https://www.rustjobs.fyi/), or boards with a dedicated Rust section like and [RemoteOK](https://remoteok.com/remote-rust-jobs). -Also post in the monthly "who is hiring" threads on [Hacker News](https://news.ycombinator.com/) or [Reddit](https://www.reddit.com/r/rust/comments/182f6dv/official_rrust_whos_hiring_thread_for_jobseekers/). - -## Assessing Candidates for Rust Roles - -Hiring itself is a very complex topic, so don't make it harder than it needs to be. - -**The number one mistake is to just look at raw Rust experience!** -Instead, there are many other indicators that can help you identify great candidates who are a good fit for Rust work, even if they currently don't have much experience with the language. +Hiring is already hard. Do not make it harder by treating raw Rust experience as the only signal. Diagram for assessing Rust candidates - details below -Let's look at the points in more detail: +Look for evidence that the candidate can learn the work in front of them. -* **Adaptability:** Look for candidates who have a proven track record to quickly adapt and learn new technologies. For example, if they worked with many different languages and know more than one programming paradigm, they might be a good fit. +Adaptability matters. A candidate who has learned several languages or moved between different kinds of systems has probably built the habits needed to learn Rust. -* **Systems Understanding:** A strong grasp of fundamental concepts such as stack vs heap, threading, and data structures is a good indicator of a candidate who hits the ground running with Rust. +Systems knowledge matters too. Rust exposes concepts that other languages often hide, so check for comfort with memory layout, threading, data structures, and performance tradeoffs. -* **Evaluate Troubleshooting Skills:** A good proxy for Rust knowledge is the ability to debug and reason about code in general. Assess a candidates' ability to understand and resolve compile errors in Rust, focusing on common issues with ownership and borrowing. Provide scenarios that require light debugging and refactoring of Rust code. +Use troubleshooting as an interview signal. Give candidates a small Rust compile error or ownership problem and ask them to reason through it. You are not only testing whether they know the rule already. You are also testing whether they can read the error, form a hypothesis, and make progress. -* **Rust Reasoning:** For those without Rust experience, test their ability to reason about Rust code. Provide sample Rust code and ask them to explain what it does. Looking up documentation is allowed. Ask clarifying questions to gauge their interest and to see how quickly they can learn. +For candidates without Rust experience, ask them to explain a short Rust program. Let them use the documentation. Good Rust work involves reading docs, checking trait bounds, and understanding examples, so the interview should allow the same behavior. -* **Ask to use the Rust documentation:** Rust takes documentation seriously. Show candidates some Rust documentation and ask follow-up questions to gauge their ability to understand basic concepts. Ask them to document a piece of code themselves or explain it in their own words. +Related language experience can transfer well. C++ and Java teach habits that map to parts of Rust. Kotlin or TypeScript can help with large application work. Haskell and OCaml can help with type-system instincts. Some candidates will bring systems knowledge. Others will bring API design instincts. Both can be useful. -* **Related Languages:** Be open to candidates transitioning from C++, Java, Kotlin, or TypeScript, which have an equally strong emphasis on enterprise-grade software development. Haskell, OCaml, and other functional programming languages can also be a good fit, because of their focus on the type system and correctness. Rust has [functional aspects](/blog/paradigms/), which are similar to these languages. +Domain knowledge also counts. If your Rust work sits in backend systems, infrastructure, real-time data processing, or embedded software, experience in that domain may matter more than years of Rust syntax. -* **Industry and Domain Knowledge:** Consider candidates with experience in Rust's key domains, such as backend development, infrastructure, real-time data processing, and systems programming. Depending on your niche, assess their expertise in these areas. - -* **Open Source Work** (Optional): If a candidate mentions open source work, review the code quality and communication skills in issues and pull requests. Keep in mind that many great developers lack time for open source work, though. +Open source work can help when it exists, but do not require it. If a candidate shares open source work, review the code and the surrounding communication in issues and pull requests. Many strong developers do not have time for public projects. ## Takeaways -**If you're a hiring manager** don't just look at raw Rust expertise; think outside the box and consider related skills and experience. The current hiring market is challenging, with an imbalance between eager developers and limited job opportunities. However, this presents a unique opportunity for forward-thinking companies to invest in talent. People exceed expectations when given trust and opportunity. For a comprehensive guide on adopting Rust in your organization, check out our [Business Adoption Checklist](/blog/successful-rust-business-adoption-checklist/). +Hiring managers should not reduce the search to years of paid Rust experience. Look for people who can learn Rust, reason about systems, and work in your domain. Then write job posts and interviews that test those things directly. -**If you're a professional Rust developer** looking to improve the situation, -work with your hiring manager to help them understand how to hire more Rust devs. Perhaps this guide can help you get started. +Rust developers can help too. If you want the market to improve, help your hiring manager understand these signals. A better hiring process gives more developers a path into Rust and gives companies a better chance of finding the people they need. Good luck with your Rust hiring process! {{ next_steps(context="Building out a Rust team and want to set them up for long-term success?") }} - diff --git a/content/blog/paradigms/index.md b/content/blog/paradigms/index.md index 0eb9becb..02ad9331 100644 --- a/content/blog/paradigms/index.md +++ b/content/blog/paradigms/index.md @@ -1,7 +1,7 @@ +++ title = "Navigating Programming Paradigms in Rust" date = 2023-12-04 -updated = 2025-02-08 +updated = 2027-07-20 template = "article.html" draft = false [extra] @@ -13,51 +13,21 @@ reviews = [ ] +++ -Rust is a multi-paradigm programming language, accommodating imperative, -object-oriented, and functional programming styles. The choice of style often -depends on a developer's background and the specific problem they're addressing. +Rust supports several programming styles. You can write plain imperative code, build APIs around structs and traits, or lean on iterators and algebraic data types. Which style fits best depends on the problem and on the habits you bring from other languages. -With Rust attracting developers from varied backgrounds such as C++, Java, -Python, and Haskell, it has shaped its own *unique* set of styles and idioms. -This diversity is a strength, but it also leads to uncertainty about which style -to use in various scenarios. +That flexibility is useful, but it can also make Rust feel less obvious than languages with one dominant style. A C++ developer, a Java developer, a Python developer, and a Haskell developer may all reach for different Rust patterns. -As the [Rust Book explains](https://doc.rust-lang.org/book/ch17-00-oop.html): -> Many competing definitions describe what Object-Oriented Programming -> is, and by some of these definitions Rust is object-oriented. +The [Rust Book explains](https://doc.rust-lang.org/book/ch17-00-oop.html) that Rust can be called object-oriented under some definitions. It also points out Rust's [functional programming influences](https://doc.rust-lang.org/book/ch13-00-functional-features.html). Both statements are true. Rust borrowed from several traditions, then made its own tradeoffs. -However, it [also states](https://doc.rust-lang.org/book/ch13-00-functional-features.html): -> Rust’s design has taken inspiration from many existing languages and -> techniques, and one significant influence is functional programming. +## Choosing The Right Style -These statements are not contradictory, but they do leave a lot of room for -interpretation and personal preference. +Rust has object-oriented pieces, but they do not look like classical inheritance. You usually compose behavior with data types and impl blocks. Traits provide shared behavior without forcing a class hierarchy. -## Guiding Principles For Choosing The Right Paradigm +Rust also makes functional patterns easy to use. Immutability, iterators, algebraic data types, and pattern matching all show up in everyday Rust. -Rust is certainly influenced by object-oriented programming concepts. One factor -that sets it apart from other object-oriented languages is its composition-based -nature, as opposed to being inheritance-based. Its trait system is a core -component of this object-oriented design, a concept absent in languages like C++ -and Java. +At the same time, Rust is not a pure functional language. Side effects are allowed, mutable state is normal when it helps, and Rust does not enforce [referential transparency](https://en.wikipedia.org/wiki/Referential_transparency). -Similarly, Rust's design encourages patterns that align closely with functional -programming principles: immutability, iterator patterns, algebraic data types, -and pattern matching. - -Just as Rust adopts certain object-oriented principles without being a purely -object-oriented language, it similarly embraces functional programming concepts -without being a purely functional language. It allows side effects everywhere -and does not strictly enforce [referential -transparency](https://en.wikipedia.org/wiki/Referential_transparency) — the -ability to replace an expression with its value without changing the program's -behavior. - -In conclusion, providing some guidance on using different paradigms in Rust -might be helpful, especially for developers transitioning from other languages. -This article explores my personal decision-making process when choosing between -different paradigms in Rust, a process that has by now become almost second -nature. +The useful question is not which camp Rust belongs to. The useful question is which style makes a particular piece of code easier to understand and change. This article shows the decision process I use. ## A Small Example @@ -71,7 +41,7 @@ for i in 0..10 { ``` But even in such a short example, we can see a discrepancy between the problem -we're trying to solve and the code we're writing: The intermediate values of +we're trying to solve and the code we're writing. The intermediate values of `sum` are irrelevant! We only care about the final result. Compare that to a more functional version: @@ -80,28 +50,21 @@ Compare that to a more functional version: let sum: u32 = (0..10).sum(); ``` -In small examples like this, it might not matter much, but when we start working -with nested loops, we see that in the imperative approach, more lines are -dedicated to bookkeeping than to the actual problem. This causes the code's -accidental complexity (the unnecessary complexity we introduce ourselves) -to increase. Complexity, no matter how small, costs attention. +In a small example, this barely matters, but with nested loops, the bookkeeping starts to take over. More lines describe how to move data around than what result we want. That is accidental complexity, and even small amounts cost attention. ## A Slightly Bigger Example: Nested Loops -Let's consider a slightly bigger example. Imagine we had a list of programming -languages, their supported paradigms, and the number of production users for -each language. The task is to find the top five languages that support -functional programming and have the most users. +Let's use a slightly bigger example. Imagine a list of programming languages, the styles they support, and the number of production users for each language. The task is to find the five most-used languages that support functional programming. ```rust // All data is made up for the sake of this example! We love you, Haskell. let languages = vec![ - Language::new("Rust", vec![Paradigm::Functional, Paradigm::ObjectOriented], 100_000), - Language::new("Go", vec![Paradigm::ObjectOriented], 200_000), - Language::new("Haskell", vec![Paradigm::Functional], 5_000), - Language::new("Java", vec![Paradigm::ObjectOriented], 1_000_000), - Language::new("C++", vec![Paradigm::ObjectOriented], 1_000_000), - Language::new("Python", vec![Paradigm::ObjectOriented, Paradigm::Functional], 1_000_000), + Language::new("Rust", vec![Style::Functional, Style::ObjectOriented], 100_000), + Language::new("Go", vec![Style::ObjectOriented], 200_000), + Language::new("Haskell", vec![Style::Functional], 5_000), + Language::new("Java", vec![Style::ObjectOriented], 1_000_000), + Language::new("C++", vec![Style::ObjectOriented], 1_000_000), + Language::new("Python", vec![Style::ObjectOriented, Style::Functional], 1_000_000), ]; ``` @@ -111,7 +74,7 @@ Here is a *painfully* explicit solution using nested `for` loops: // Filter languages to keep only the functional ones let mut functional_languages = vec![]; for language in languages { - if language.paradigms.contains(&Paradigm::Functional) { + if language.styles.contains(&Style::Functional) { functional_languages.push(language); } } @@ -133,9 +96,7 @@ while functional_languages.len() > 5 { ([Rust Playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=f9b28b96acde1f9d11e8dd5957539826)) -This is a *very verbose*, imperative solution. We mutate the vector in-place and -destroy the intermediate results in the process. While it's not incorrect, I -would argue that it's not the most idiomatic Rust code either. +This imperative version is verbose. It mutates the vector in place and throws away intermediate results as it goes. It works, but it is not how I would usually write this in Rust. In practice, you would probably reach for a few more helper methods from the standard library: @@ -143,7 +104,7 @@ standard library: ```rust let mut top_languages = vec![]; for language in languages { - if language.paradigms.contains(&Paradigm::Functional) { + if language.styles.contains(&Style::Functional) { top_languages.push(language); } } @@ -160,13 +121,10 @@ concise when filtering: ```rust let mut top_languages = languages; -top_languages.retain(|language| language.paradigms.contains(&Paradigm::Functional)); +top_languages.retain(|language| language.styles.contains(&Style::Functional)); ``` -We still use a mutable variable, but now the code looks more succinct. `retain` -is a higher-order method that takes a closure as an argument, so the code -naturally became a little more functional. Let's continue down this path and -see where it takes us next. +We still use a mutable variable, but the code is shorter. `retain` takes a closure, so the code has already moved a little toward a functional style. Let's keep going. ```rust let mut top_languages = languages.clone(); @@ -175,7 +133,7 @@ top_languages.sort(); let top_languages: Vec = top_languages .into_iter() // Only keep functional languages - .filter(|language| language.paradigms.contains(&Paradigm::Functional)) + .filter(|language| language.styles.contains(&Style::Functional)) // Keep only the top 5 languages .take(5) // Collect the results into a vector @@ -190,7 +148,7 @@ to chain all intermediate operations: let top_languages: Vec = languages .iter() // Only keep functional languages - .filter(|language| language.paradigms.contains(&Paradigm::Functional)) + .filter(|language| language.styles.contains(&Style::Functional)) // Sort our languages in descending order of popularity. .sorted_by_key(|lang| Reverse(lang.users)) // Keep only the top 5 languages @@ -201,57 +159,29 @@ let top_languages: Vec = languages ([Rust Playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=8c1d45de856b6a7980ea48ebeaf43290)) -Sorting the entire list (even if it's filtered) to extract just the top 5 -elements seems somewhat inefficient. This highlights a limitation in Rust -compared to C++, which offers a -[partial_sort](https://en.cppreference.com/w/cpp/algorithm/partial_sort) -function in its standard library. While Rust doesn't have an equivalent in std, -there are third-party crates. Alternatively, a [BinaryHeap](https://doc.rust-lang.org/std/collections/struct.BinaryHeap.html) -can be used. - -To me, this solution is easier to reason about. The operations are -neatly aligned below each other, and the code reads like a description of what -we're trying to achieve. I do admit, however, that it takes some getting used -to, especially if you're not familiar with functional programming patterns. - -One could say that I hand-picked a problem that is well-suited for functional -programming, and that is certainly the case. The truth is, that this way of -method chaining just feels natural after a while — especially for ad-hoc -transformations on immutable data structures. - -There are a few reasons for this: - -* **Readability**: The steps are easy to follow. -* **Library Support**: The Rust standard library and external crates provide - many helpful combinators for iterators, which play nicely with immutable data structures. -* **Efficiency**: Under the hood, methods like `map` and `filter` create new - iterators that operate on the previous iterator and do not incur any allocations. - The actual computations (like adding 1 or filtering even numbers) are only - executed when the final iterator is consumed, in this case by - the `collect` method. The `collect` method makes a single allocation to store the - results in a new vector. Our higher-level abstractions incur no runtime - overhead. -* **Parallelism**: The functional approach lends itself to parallel computation. - Each chain of operations is independent of the others, allowing them - to be executed simultaneously on modern hardware. - -The result is clean, readable, and efficient code, which is why you'll see this -pattern a lot. - -> No matter what language you work in, programming in a functional style provides -> benefits. You should do it whenever it is convenient, and you should think hard -> about the decision when it isn't convenient. — [John Carmack](https://web.archive.org/web/20120427212006/www.altdevblogaday.com/2012/04/26/functional-programming-in-c/) - -Carmack talks about *convenience* here. What is the tipping point where -functional programming becomes inconvenient? Let's explore that with a more -realistic example. +Sorting the whole filtered list just to keep five elements can be wasteful. C++ has [`partial_sort`](https://en.cppreference.com/w/cpp/algorithm/partial_sort) in its standard library. Rust does not have the same operation in `std`, though third-party crates can fill the gap. A [BinaryHeap](https://doc.rust-lang.org/std/collections/struct.BinaryHeap.html) is another option. + +I still find this version easier to reason about. The operations line up under each other, and the code reads like a description of the result. It can feel strange at first if you are new to iterator-heavy Rust. + +Admittedly, I did pick a problem that fits functional code well. +But I find that method chains start to feel natural when you are doing ad-hoc transformations on immutable data. + +There are a few reasons for that: + +* The steps are easy to follow. +* The standard library and crates provide iterator adapters that work well with immutable data. +* Methods like `map` and `filter` build lazy iterators, so they do not allocate by themselves. Work happens when the iterator is consumed. In this example, `collect` performs the allocation for the final vector. +* Independent chains can often be moved to parallel execution later. + +[John Carmack put the tradeoff well](https://cbarrete.com/carmack.html): "No matter what language you work in, programming in a functional style provides benefits. You should do it whenever it is convenient, and you should think hard about the decision when it isn't convenient." + +But can you take it too far? +Is there a point where functional Rust becomes inconvenient? +Let's try a more realistic example. ## Real-World Example: Filtering a List of Files -Here is a little Rust exercise: how would you list all XML files in a directory? -Before you continue, feel free to try this yourself. See -which style you would naturally lean towards. Why not try different approaches -and see which one you prefer? +Here is a small Rust exercise: how would you list all XML files in a directory? Before reading on, try it yourself and notice which style you reach for first. ### Imperative Style @@ -275,13 +205,7 @@ fn xml_files(p: &Path) -> Result> { ([Rust Playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=f3314816d1584ea372e8cdf6bdccd426)) -Not great, not terrible. - -We have to do some bookkeeping, and there are some minor paper cuts like `let f -= f?;` and the bit about `OsStr::to_str`, but overall it's fine. The paper cuts -are due to the *inherent* complexity of the problem: dealing with the -possibility of errors and the fact that the file extension might not be valid -UTF-8 on all platforms. +This version is fine. It has some bookkeeping, including `let f = f?;`, but that is not accidental complexity. Directory iteration can fail, and file extensions are not guaranteed to be valid UTF-8 on every platform. As the [documentation for `OsStr`](https://doc.rust-lang.org/std/ffi/struct.OsString.html) explains: @@ -291,13 +215,11 @@ As the [documentation for * On Windows, strings are often arbitrary sequences of non-zero 16-bit values, interpreted as UTF-16 when it is valid to do so. -The astute reader might have noticed that we don’t check if the path is -actually a file before we check the extension. This is done in the interest of -brevity. +This example does not check whether the path is a file before checking the extension. I left that out to keep the example short. ### Functional Style -Let's see how we can solve this problem in a more functional style: +Here is the same problem written with iterator adapters: ```rust fn xml_files(p: &Path) -> Result> { @@ -313,13 +235,9 @@ fn xml_files(p: &Path) -> Result> { ([Rust Playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=448b6246dd4da2d1cd9b8cf1f4e1f09e)) -This implementation is arguably more streamlined. It maps directory -entries to paths, filters out non-XML files, and collects the results, all -without needing mutable variables or conditional branching. +This version maps directory entries to paths, filters out non-XML files, and collects the result without a mutable vector. -That said, the solution also has its drawbacks. -Most importantly, it is not equivalent to the imperative version. -That is because `filter_map(Result::ok)` filters out all errors. +It also changes the behavior. `filter_map(Result::ok)` drops every error. {% info(title="What is the difference between `filter` and `filter_map`?") %} In Rust, `filter` takes a closure that returns a `bool` to decide whether to @@ -332,11 +250,7 @@ in the new iterator; if it returns `None`, the element is excluded. Essentially, {% end %} -Whether we want to ignore errors depends on the use case; -it is a tradeoff between correctness and ergonomics. -In production code, we should at least log all errors, though. -We can use [`inspect`](https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.inspect) -to do that: +Ignoring errors may be acceptable for a quick script, but production code should at least log them. We can use [`inspect`](https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.inspect) for that: ```rust fn xml_files(p: &Path) -> Result> { @@ -359,17 +273,13 @@ fn xml_files(p: &Path) -> Result> { ([Rust Playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=f3bd287c8c82ff661b9ec9616b5514a9)) -So far, I would still lean towards the functional version, but let's see -how both approaches hold up as we add more complexity. +At this point I still prefer the iterator version. Now let's add more requirements. ### Making Filtering More Generic -What if we wanted to filter by arbitrary file attributes? -For instance, we might want to find all files with a given prefix or extension. +What if we want to filter by arbitrary file attributes, such as a prefix or extension? -We could introduce a new parameter, `valid`, which would be a function that -takes a `Path` and returns a `bool`. (This is also known as a *predicate* in -functional programming.) +We can add a `valid` parameter: a function that takes a `Path` and returns a `bool`. Functional programmers call this a predicate. ```rust fn filter_files(p: &Path, valid: &F) -> Result> @@ -384,13 +294,9 @@ where } ``` -This is a generic function that can be used for many different use cases. -Higher-order functions like this are a typical pattern in functional programming -and are also available in Rust. +This generic function works for many filters. It also shows that higher-order functions are a normal part of Rust. -The imperative version, while concise, now incorporates a higher-order function, -demonstrating that the line between functional and imperative programming is -often blurry: +The imperative version can take the same `valid` function. The line between imperative and functional Rust is blurry: ```rust fn filter_files(p: &Path, valid: &F) -> Result> @@ -412,12 +318,11 @@ where ### Recursively Filtering Directories For Files -Let's go one more step further. +Let's go one step further. -So far, our solution only works for a single directory. What if we wanted to -*recursively* filter a directory and all its subdirectories for files? +So far, the function only handles one directory. What if we want to recurse into subdirectories? -First, the (mostly) imperative version with mutable state: +Here is the mostly imperative version with mutable state: ```rust fn filter_files(p: &Path, valid: &F) -> Result> @@ -440,10 +345,9 @@ where ([Rust Playground](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=e55738f928e70d9f42e2408e10166e1e)) -While we have one more level of nesting, the imperative version holds up -reasonably well. +This adds one level of nesting, but the imperative version still holds together. -Next, the functional version: +Now compare the iterator version: ```rust fn filter_files(p: &Path, valid: &F) -> Result> @@ -462,23 +366,13 @@ where } ``` -We're dealing with an iterator of iterators here, so we need to flatten it to -get a single iterator of paths with the help of `flat_map`. However, this also -means that we need to return a vector of paths in all cases, even if it's empty. -The `unwrap_or_default` is a symptom of this. - -I will let you be the judge of which version you prefer. +Now we have an iterator that can produce many paths for one input path, so we reach for `flat_map`. That forces each branch to return a collection, even when it has nothing to return. The `unwrap_or_default` is a smell here. -Either way, this is where I feel the flow of logic is in need of improvement. -What I want is better encapsulation and modularity to keep -the complexity in check. Rust allows us to seamlessly transition to an -object-oriented style to do just that. +This is the point where I stop trying to force everything into one chain. The code needs a boundary around the traversal state. Rust gives us a good way to do that with a struct and an `Iterator` implementation. ### Transitioning to Object-Oriented Rust -In contrast to the functional and imperative examples discussed earlier, -let's introduce a new struct, `FileFilter`, which encapsulates the logic for -filtering files and file iteration. +Instead of pushing the traversal into a longer function, introduce a `FileFilter` struct that owns the filtering and iteration state. ```rust pub struct FileFilter { @@ -488,8 +382,7 @@ pub struct FileFilter { } ``` -Each `FileFilter` object carries its state: a collection of predicates for -filtering, a starting path, and a stack of directories for iteration. +Each `FileFilter` value stores the predicates, the starting path, and the directory stack. A predicate is defined like this: @@ -497,22 +390,13 @@ A predicate is defined like this: type Predicate = dyn Fn(&Path) -> bool; ``` -You might be surprised to see a `dyn` here. -In Rust, no two closures, even if identical, have the same type! +You might be surprised to see a `dyn` here. The [Rust Reference](https://doc.rust-lang.org/reference/types/closure.html) says each closure has its own anonymous type. Even two identical closures have different types. -> A closure expression produces a closure value with a unique, anonymous type -> that cannot be written out. - [The Rust Reference](https://doc.rust-lang.org/reference/types/closure.html) - -To accommodate this in a collection like a Vec, we use a trait object with -*dynamic dispatch*. By 'boxing' these closures, we create a `Box` -(essentially `Box bool>`), which allows us to store different -predicate closures in the same `Vec` despite their unique types. +To store several closures in one `Vec`, we use a trait object with dynamic dispatch. Boxing each closure gives us a `Box` (`Box bool>`), so different closure types can live in the same collection. #### Adding Filters -In functional programming, we leveraged the power of iterators and closures to -filter files. In the imperative style, we directly manipulated vectors with -loops and conditions. The FileFilter, however, abstracts these details away. +Earlier versions exposed the filtering loop directly. `FileFilter` hides that detail behind a small API. Consider the `add_filter` method: @@ -523,8 +407,7 @@ pub fn add_filter(mut self, predicate: impl Fn(&Path) -> bool + 'static) -> Self } ``` -This allows us to easily add multiple filters by chaining calls — -something that was previously closely coupled with the iteration logic. +Now callers can add filters by chaining method calls. The filter setup is separate from the traversal code. ```rust let filter = FileFilter::new() @@ -540,8 +423,7 @@ let filter = FileFilter::new() #### Iterator Implementation -What truly showcases the OOP approach in Rust is the implementation of the -`Iterator` trait for `FileFilter`: +The object-oriented part becomes useful when `FileFilter` implements `Iterator`: ```rust impl Iterator for FileFilter { @@ -556,11 +438,7 @@ impl Iterator for FileFilter { } ``` -In doing so, `FileFilter` becomes a building block that neatly integrates -with Rust's powerful iterator ecosystem and can be used in all the same places -as any other iterator. This design allows for complex iteration logic to be -encapsulated -*within* the object, abstracting away the details from the user. +With that implementation, `FileFilter` becomes a normal Rust iterator. Callers can chain it with the rest of the iterator ecosystem while the traversal logic stays inside the struct. You can find the full implementation of `FileFilter` [on GitHub](https://github.com/corrode/filefilter) or [on the Rust @@ -571,59 +449,23 @@ production use. #### Encapsulation and Reusability -The `FileFilter` example illustrates how OOP in Rust can lead to solid -encapsulation and modularity. Unlike the earlier examples where the logic for -filtering files was tightly coupled with iteration, we now separate -the *what* (the predicates) from the *how* (the iteration and filtering logic). -The trait system allows us to easily integrate our custom iterator with the -rest of the ecosystem. -Having these tools at our disposal makes the code more composable and reusable. +The `FileFilter` example shows what object-oriented Rust is good at: encapsulation. We separate the predicates from the traversal machinery, then expose the result as an iterator. The trait system lets that custom iterator work with the rest of Rust. ## Summary -Mixing different styles is not only possible, but encouraged in Rust! This can -also be seen by taking a look at [Rust's key influences on its language -design](https://doc.rust-lang.org/reference/influences.html). Influences as -diverse as C++, Haskell, OCaml, and Erlang have shaped Rust's design. - -In the beginning, Rust was more functional in nature, but it has since evolved -into a more balanced language, supporting a variety of styles. The question is -where to draw the line between different programming paradigms. - -I found that I often use a functional core with an imperative shell. The core -consists of small, composable functions that transform data, while the shell -provides the necessary control flow. I often use object-oriented constructs to -organize larger parts of my application, encapsulating related data and -functions. - -Here are my personal rules of thumb: - -* **Embrace object-oriented patterns for organization.** For organizing larger - parts of your application, consider object-oriented constructs. Using structs or enums can encapsulate related data and functions, providing a clear structure without worrying about the details. -* **Leverage functional patterns for data transformations.** Especially within - smaller scopes like functions and closures, functional methods such as - mapping, filtering, or reducing can make your code both concise and clear. - Use functional programming when you can phrase your problem as a series of transformations over some data. - -* **Use imperative style for granular control.** In scenarios where you're - working close to the hardware, or when you need explicit step-by-step - execution, the imperative style is often a necessity. It allows for precise - control over operations, especially with mutable data. This style can be - particularly useful in performance-critical sections or when interfacing with - external systems where exact sequencing matters. However, always weigh its - performance gains against potential readability trade-offs. If possible, - encapsulate imperative code within a limited scope. -* **Prioritize readability and maintainability.** Regardless of your chosen - paradigm, always write code that's straightforward and easy to maintain. It - benefits not only your future self, but also your colleagues who might work on - the same codebase. -* **Avoid premature optimization.** Don't prematurely optimize for performance - at the cost of readability. The real bottleneck might be elsewhere. Measure - first, then optimize. Elegant solutions can be turned into fast ones, but - the reverse is not always true. - -Lastly, avoid bias towards any particular paradigm. You can write better code if -you test your assumptions every now and then. +Rust works well when you mix styles deliberately. That should not be surprising. The language draws from [C++, Haskell, OCaml, Erlang](https://doc.rust-lang.org/reference/influences.html), and more. + +I often use a functional core with an imperative shell. Small functions transform data. The outer code handles I/O and sequencing. Error reporting sits at the boundary. For larger parts of an application, I use Rust's type system to keep related data and behavior together. + +My rules of thumb: + +* Use object-oriented patterns for organization. Structs and enums are good places to keep related data. Traits are good boundaries between parts of the program. +* Use functional patterns for data transformations. Inside functions and closures, iterator adapters can make the data flow easier to read. +* Use imperative code when sequencing matters. Explicit loops are often clearer near I/O, mutation, performance-sensitive code, or low-level details. Keep that code in a small scope when you can. +* Prefer readability over allegiance to a style. Code that is easy to read and change usually beats code that follows one programming tradition perfectly. +* Measure before optimizing. The bottleneck may not be where you expect, and readable code is easier to tune once you know what matters. + +Do not let your favorite style make the decision for you. Try the obvious version first, then refactor when the code starts to become unwieldy.