Skip to content

Commit 4483602

Browse files
Introduce 'Idiomatic Rust' learning module (#2800)
This PR introduces: - A new section for the "Idiomatic Rust" learning module - (The beginning of) the section on newtype patterns --------- Co-authored-by: Dmitri Gribenko <[email protected]>
1 parent c2ffbd7 commit 4483602

File tree

10 files changed

+425
-1
lines changed

10 files changed

+425
-1
lines changed

mdbook-course/src/replacements.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ pub fn replace(
4848
["course", "outline"] if course.is_some() => {
4949
course.unwrap().schedule()
5050
}
51-
["course", "outline", course_name] => {
51+
["course", "outline", course_name @ ..] => {
52+
let course_name = course_name.join(" ");
5253
let Some(course) = courses.find_course(course_name) else {
5354
return format!("not found - {}", captures[0].to_string());
5455
};

src/SUMMARY.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,17 @@
429429

430430
---
431431

432+
# Idiomatic Rust
433+
434+
- [Welcome](idiomatic/welcome.md)
435+
- [Leveraging the Type System](idiomatic/leveraging-the-type-system.md)
436+
- [Newtype Pattern](idiomatic/leveraging-the-type-system/newtype-pattern.md)
437+
- [Semantic Confusion](idiomatic/leveraging-the-type-system/newtype-pattern/semantic-confusion.md)
438+
- [Parse, Don't Validate](idiomatic/leveraging-the-type-system/newtype-pattern/parse-don-t-validate.md)
439+
- [Is It Encapsulated?](idiomatic/leveraging-the-type-system/newtype-pattern/is-it-encapsulated.md)
440+
441+
---
442+
432443
# Final Words
433444

434445
- [Thanks!](thanks.md)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
minutes: 5
3+
---
4+
5+
# Leveraging the Type System
6+
7+
Rust's type system is _expressive_: you can use types and traits to build
8+
abstractions that make your code harder to misuse.
9+
10+
In some cases, you can go as far as enforcing correctness at _compile-time_,
11+
with no runtime overhead.
12+
13+
Types and traits can model concepts and constraints from your business domain.
14+
With careful design, you can improve the clarity and maintainability of the
15+
entire codebase.
16+
17+
<details>
18+
19+
Additional items speaker may mention:
20+
21+
- Rust's type system borrows a lot of ideas from functional programming
22+
languages.
23+
24+
For example, Rust's enums are known as "algebraic data types" in languages
25+
like Haskell and OCaml. You can take inspiration from learning material geared
26+
towards functional languages when looking for guidance on how to design with
27+
types. ["Domain Modeling Made Functional"][1] is a great resource on the
28+
topic, with examples written in F#.
29+
30+
- Despite Rust's functional roots, not all functional design patterns can be
31+
easily translated to Rust.
32+
33+
For example, you must have a solid grasp on a broad selection of advanced
34+
topics to design APIs that leverage higher-order functions and higher-kinded
35+
types in Rust.
36+
37+
Evaluate, on a case-by-case basis, whether a more imperative approach may be
38+
easier to implement. Consider using in-place mutation, relying on Rust's
39+
borrow-checker and type system to control what can be mutated, and where.
40+
41+
- The same caution should be applied to object-oriented design patterns. Rust
42+
doesn't support inheritance, and object decomposition should take into account
43+
the constraints introduced by the borrow checker.
44+
45+
- Mention that type-level programming can be often used to create "zero-cost
46+
abstractions", although the label can be misleading: the impact on compile
47+
times and code complexity may be significant.
48+
49+
</details>
50+
51+
{{%segment outline}}
52+
53+
[1]: https://pragprog.com/titles/swdddf/domain-modeling-made-functional/
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
minutes: 5
3+
---
4+
5+
# Newtype Pattern
6+
7+
A _newtype_ is a wrapper around an existing type, often a primitive:
8+
9+
```rust
10+
/// A unique user identifier, implemented as a newtype around `u64`.
11+
pub struct UserId(u64);
12+
```
13+
14+
Unlike type aliases, newtypes aren't interchangeable with the wrapped type:
15+
16+
```rust,compile_fail
17+
# pub struct UserId(u64);
18+
fn double(n: u64) -> u64 {
19+
n * 2
20+
}
21+
22+
double(UserId(1)); // 🛠️❌
23+
```
24+
25+
The Rust compiler won't let you use methods or operators defined on the
26+
underlying type either:
27+
28+
```rust,compile_fail
29+
# pub struct UserId(u64);
30+
assert_ne!(UserId(1), UserId(2)); // 🛠️❌
31+
```
32+
33+
<details>
34+
35+
- Students should have encountered the newtype pattern in the "Fundamentals"
36+
course, when they learned about
37+
[tuple structs](../../user-defined-types/tuple-structs.md).
38+
39+
- Run the example to show students the error message from the compiler.
40+
41+
- Modify the example to use a typealias instead of a newtype, such as
42+
`type MessageId = u64`. The modified example should compile, thus highlighting
43+
the differences between the two approaches.
44+
45+
- Stress that newtypes, out of the box, have no behaviour attached to them. You
46+
need to be intentional about which methods and operators you are willing to
47+
forward from the underlying type. In our `UserId` example, it is reasonable to
48+
allow comparisons between `UserId`s, but it wouldn't make sense to allow
49+
arithmetic operations like addition or subtraction.
50+
51+
</details>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
minutes: 5
3+
---
4+
5+
# Is It Truly Encapsulated?
6+
7+
You must evaluate _the entire API surface_ exposed by a newtype to determine if
8+
invariants are indeed bullet-proof. It is crucial to consider all possible
9+
interactions, including trait implementations, that may allow users to bypass
10+
validation checks.
11+
12+
```rust
13+
pub struct Username(String);
14+
15+
impl Username {
16+
pub fn new(username: String) -> Result<Self, InvalidUsername> {
17+
// Validation checks...
18+
Ok(Self(username))
19+
}
20+
}
21+
22+
impl std::ops::DerefMut for Username { // ‼️
23+
fn deref_mut(&mut self) -> &mut Self::Target {
24+
&mut self.0
25+
}
26+
}
27+
# impl std::ops::Deref for Username {
28+
# type Target = str;
29+
#
30+
# fn deref(&self) -> &Self::Target {
31+
# &self.0
32+
# }
33+
# }
34+
# pub struct InvalidUsername;
35+
```
36+
37+
<details>
38+
39+
- `DerefMut` allows users to get a mutable reference to the wrapped value.
40+
41+
The mutable reference can be used to modify the underlying data in ways that
42+
may violate the invariants enforced by `Username::new`!
43+
44+
- When auditing the API surface of a newtype, you can narrow down the review
45+
scope to methods and traits that provide mutable access to the underlying
46+
data.
47+
48+
- Remind students of privacy boundaries.
49+
50+
In particular, functions and methods defined in the same module of the newtype
51+
can access its underlying data directly. If possible, move the newtype
52+
definition to its own separate module to reduce the scope of the audit.
53+
54+
</details>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
minutes: 5
3+
---
4+
5+
# Parse, Don't Validate
6+
7+
The newtype pattern can be leveraged to enforce _invariants_.
8+
9+
```rust
10+
pub struct Username(String);
11+
12+
impl Username {
13+
pub fn new(username: String) -> Result<Self, InvalidUsername> {
14+
if username.is_empty() {
15+
return Err(InvalidUsername::CannotBeEmpty)
16+
}
17+
if username.len() > 32 {
18+
return Err(InvalidUsername::TooLong { len: username.len() })
19+
}
20+
// Other validation checks...
21+
Ok(Self(username))
22+
}
23+
24+
pub fn as_str(&self) -> &str {
25+
&self.0
26+
}
27+
}
28+
# pub enum InvalidUsername {
29+
# CannotBeEmpty,
30+
# TooLong { len: usize },
31+
# }
32+
```
33+
34+
<details>
35+
36+
- The newtype pattern, combined with Rust's module and visibility system, can be
37+
used to _guarantee_ that instances of a given type satisfy a set of
38+
invariants.
39+
40+
In the example above, the raw `String` stored inside the `Username` struct
41+
can't be accessed directly from other modules or crates, since it's not marked
42+
as `pub` or `pub(in ...)`. Consumers of the `Username` type are forced to use
43+
the `new` method to create instances. In turn, `new` performs validation, thus
44+
ensuring that all instances of `Username` satisfy those checks.
45+
46+
- The `as_str` method allows consumers to access the raw string representation
47+
(e.g., to store it in a database). However, consumers can't modify the
48+
underlying value since `&str`, the returned type, restricts them to read-only
49+
access.
50+
51+
- Type-level invariants have second-order benefits.
52+
53+
The input is validated once, at the boundary, and the rest of the program can
54+
rely on the invariants being upheld. We can avoid redundant validation and
55+
"defensive programming" checks throughout the program, reducing noise and
56+
improving performance.
57+
58+
</details>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
minutes: 5
3+
---
4+
5+
# Semantic Confusion
6+
7+
When a function takes multiple arguments of the same type, call sites are
8+
unclear:
9+
10+
```rust
11+
# struct LoginError;
12+
pub fn login(username: &str, password: &str) -> Result<(), LoginError> {
13+
// [...]
14+
# Ok(())
15+
}
16+
17+
# let password = "password";
18+
# let username = "username";
19+
// In another part of the codebase, we swap arguments by mistake.
20+
// Bug (best case), security vulnerability (worst case)
21+
login(password, username);
22+
```
23+
24+
The newtype pattern can prevent this class of errors at compile time:
25+
26+
```rust,compile_fail
27+
pub struct Username(String);
28+
pub struct Password(String);
29+
# struct LoginError;
30+
31+
pub fn login(username: &Username, password: &Password) -> Result<(), LoginError> {
32+
// [...]
33+
# Ok(())
34+
}
35+
36+
# let password = Password("password".into());
37+
# let username = Username("username".into());
38+
login(password, username); // 🛠️❌
39+
```
40+
41+
<details>
42+
43+
- Run both examples to show students the successful compilation for the original
44+
example, and the compiler error returned by the modified example.
45+
46+
- Stress the _semantic_ angle. The newtype pattern should be leveraged to use
47+
distinct types for distinct concepts, thus ruling out this class of errors
48+
entirely.
49+
50+
- Nonetheless, note that there are legitimate scenarios where a function may
51+
take multiple arguments of the same type. In those scenarios, if correctness
52+
is of paramount importance, consider using a struct with named fields as
53+
input:
54+
```rust
55+
pub struct LoginArguments<'a> {
56+
pub username: &'a str,
57+
pub password: &'a str,
58+
}
59+
# fn login(i: LoginArguments) {}
60+
# let password = "password";
61+
# let username = "username";
62+
63+
// No need to check the definition of the `login` function to spot the issue.
64+
login(LoginArguments {
65+
username: password,
66+
password: username,
67+
})
68+
```
69+
Users are forced, at the callsite, to assign values to each field, thus
70+
increasing the likelihood of spotting bugs.
71+
<!-- TODO: Link to the relevant section in "Foundations of API design" when that chapter is written -->
72+
73+
</details>

0 commit comments

Comments
 (0)