Finally, we can make our own types (or data structures)!!
This is supplementary/separate from the Twitch Streams (see sidebar for links), intended for discussion here on lemmy.
The idea being, now that both twitch streams have read Chapters 5 and 6, we can have a discussion here and those from the twitch streams can have a retrospective or re-cap on the topic.
This will be a regular occurrence for each discrete set of topics coming out of The Book as the twitch streams cover them
With Ch 4 on the borrow checker out of the way, chapters 5 & 6 feel like the “inflection point” … the point where we’re ready to actually start programming in rust.
Custom types, data structures, objects with methods, pattern matching, and even dipping into rust’s traits system and it’s quasi answer to class inheritance.
If you’re comfortable enough with the borrow checker, you can really start to program with rust now!
I personally didn’t think this content was difficult, though it prompts some interesting points and topics (which I’ll mention in my own comment below).
- Any thoughts, difficulties or confusions?
- Any quizzes stump you?
- Any major tips or rules of thumb you’ve taken away or generally have about using
structs
andenums
?
For me, the biggest things to take away from these chapters were:
- Enums and pattern matching
- Borrow checker concerns emerging from these new data structures
- Derivable traits
Enums and pattern matching for the win
- That the pattern matching facility is powerful, and that enums can have associated data, structured independently for each variant … really provides a (relatively straight forward and versatile “happy path” in rust IMO.
- I tried to hack together a file differ in rust a couple of months ago (see my post on my solution here, mostly in the comments) and found myself just leaning into enums + pattern matching and rather enjoying the process.
- So much so that major (and celebrated?) features of the language such as
Option
andResult
types are really just applications ofenums
(along with rust’s good type system)
The example in the book of the
IP Address
enum
type is quite a nice demonstration I think:enum IpAddr { V4(u8, u8, u8, u8), V6(String), } let home = IpAddr::V4(127, 0, 0, 1); let loopback = IpAddr::V6(String::from("::1"));
We’re still learning “The Borrow Checker”
- Ownership and borrowing concerns are still alive here in their application to
structs
andenums
and what best practices arise out of it all.
Match statements
- In
match statements
, the concerns are relatively straight forward (I think). Match arms take ownership of the variables they “use/touch” (I’m still unclear on the details there!) … - so if you want a variable to live beyond the match statement, match on a reference.
EG:
let opt: Option<String> = Some(String::from("Hello world")); match &opt { Some(s) => println!("Some: {}", s), None => println!("None!") }; println!("{:?}", opt);
- There’s a slightly tricky thing that happens implicitly here:
- Though the
match
is on, the
s
in the patternSome(s)
is also a reference because rust implicitly “pushes down” the reference from the outer enum to the inner field or associated data. - Seems tricky, but also ergonomically sensible.
- Though the
Borrowing
self
in methodsProbably the trickiest and most relevant part of the two chapters
- the
self
in methods, like any other variable, can be one of three types in terms of ownership:- Owned by the method, like a plain variable
- A reference (
&self
) - A mutable reference (
&mut self
)
struct Rectangle { width: u32, height: u32, } impl Rectangle { fn area(&self) -> u32 { self.width * self.height } fn set_width(&mut self, width: u32) { self.width = width; } fn max(self, other: Rectangle) -> Rectangle { Rectangle { width: self.width.max(other.width), height: self.height.max(other.height), } } }
-
What’s tricky about this is that a method’s signature for
self
has consequences that both reach back to the initial type of the root object (ie, is it mutable or not) and forward to what can be done with the root type afterward.- EG, a method that takes
&mut self
can’t be used on a variable that isn’t initially mutable. - EG, a method that takes ownership of
self
effectively kills the root object, making it unusable after the method is called!!
- EG, a method that takes
-
I’m sure there are a bunch of patterns that emerge out of this (anyone with some wisdom here?) …
-
But the simple answer seems to borrow
self
, and if necessary, mutably borrow. -
Taking ownership of
self
is an interesting way to enforce a certain kind of usage and behaviour though. -
As the object dies, the natural return of an
owning method
would be a new object, probably of the same type. -
Which leads into a sort of functional “pipe-line” or “method chaining” style of usage, not unlike the “Faux-O” idea in Cory Bernhardt’s talk Boundaries. It’s likely not the most performant, but arguably has some desirable qualities.
Derivable Traits
- We haven’t gotten to traits yet, but they come up here.
- Turns out rust has kinda has a system of inheritance for
structs
where atrait
can be easily implemented for astruct
“automagically”:#[derive(Debug)]
EG:
#[derive(Debug)] struct Rectangle { width: u32, height: u32, }
-
This particular trait,
Debug
, allows for the printing of a struct’s full makeup withprintln!
. -
All of the “Derivable” traits (from the std lib) are listed in Appendix C of The Book
-
There aren’t that many, but they’re useful:
Copy
andClone
enable a struct to be copied without having to worry about ownership (though you have to be careful about the types of the fields, as its theircopy
methods that are ultimately relied on)- Four traits that implement methods for comparison and equality operators
Hash
for hashing an objectDefault
for defining default values
-
Of course, when we cover traits we’ll learn how to implement them ourselves for our custom types, but these seem to be fundamental features of the language, and easy enough to use right away.
I’m sure there are a bunch of patterns that emerge out of this (anyone with some wisdom here?) …
The classical one is something that looks like the following:
struct LoggedOut; struct User {name: String}; struct Admin {name: String}; impl LoggedOut { fn login(self, name: String, password: String) -> User { User { name } } fn admin_login(self, name: String) -> Admin { Admin { name } } } impl User { fn log_out(self) -> LoggedOut { LoggedOut {} } } impl Admin { fn log_out(self) -> LoggedOut { LoggedOut {} } } fn fetch_user_preferences(user: User) { /*...*/ } fn do_admin_action(admin_account: Admin) { /* ... */ } fn main() { let mut user_session = LoggedOut {}; /* (get user input) */ match input { "login" => { user_session = user_session.login(name, password); } "admin" => { user_session = user_session.admin_login(name); } } }
This would prevent you from writing code that uses the user’s info / session “object” after they have logged out (and/or before they have logged in). On its own it’s naive and a bit encumbering - I expect an enum would make more sense but then you can’t restrict via types which user session values can and can’t be passed to specific functions. In any case, when you are building a whole system around it it can be very useful to have the compiler enforcing these sorts of things “in the background”.
This is basically what Gary Bernhardt is talking about in the talk you linked.
Yep. And then you realise that “move semantics” aren’t just a safety net that you have to “fight with” but actually a language feature against which you can develop/deploy desirable patterns.
A minor thing I noted reading your code snippets was that I immediately noticed, like at a gestalt level, the lack of ampersands (
&
) and therefore references and could immediately tell that this was a “faux-O”/pipline style system. Not too bad for a syntax often derided as messy/bad.
I want to highlight one of the more subtle-yet-important clarifications made in these 2 chapters: associated functions vs methods, and how method calls are just syntactic sugar for function calls.
Unlike in many other languages, there is no formal distinction (e.g. via separate keywords) between methods vs constructors vs property getters. The first parameter as well as the return type determine if a given associated function is “actually” a constructor or a method (etc.).
Personally, I find this incredibly elegant; it’s a form of “less is more” that gets out of my way when I’m coding while still allowing me to use all of the existing patterns that I know from elsewhere.
Personally, I find this incredibly elegant
I’m not entirely sure I understand exactly what you mean here.
Do you appreciate it as an implementation design for the language (I do too)?
Or do you see some utility in being able to call
MyStruct::my_method(&my_var)
… or both, cuz there’s something assuring in knowing the simple pattern underneath the syntactic sugar is there?
Bit of both, I suppose. Along with my own experience trying to deal with prototypes in JavaScript and how Python handles methods vs “bare” functions internally in terms of v-tables and “where” things exist in memory.
I imagine the fact that both of those are interpreted languages plays somewhat heavily into it.
With regards to being able to write
MyStruct::my_method(&my_var)
, it’s the one-two punch of “I can use that specific syntax to differentiate between ‘inherited’ methods that have the same name” and that the compiler doesn’t treat.method()
calls any differently and just rewrites them as such when doing it’s job.I imagine the fact that both of those are interpreted languages plays somewhat heavily into it.
Yea I’d imagine so too.
Also, on the topic of “having references as fields in
structs
” … see this conversation we had here about that.That conversation started from a post on the users . rust-lang forum, where the ultimate pithy and harsh conclusion about doing this was:
You’re not allowed to use references in structs until you think Rust is easy. They’re the evil-hardmode of Rust that will ruin your day.
😉
Use Box or Arc to store things in structs “by reference”. Temporary borrows don’t do what you think they do.