03 Feb 24
Rust Enums compared to Ruby objects
So far we’ve discussed Strings and Numbers which are relatively comparable in Rust and Ruby. At least both languages have those concepts as first-class citizens. In this post, we are going to discuss Enums.
If you are coming from Ruby, you may not be completely familiar. More than likely you’ve heard of Enums in the context of database validation. Typically, we’d use Enums while deciding if a given value is an acceptable value for a database column. In Ruby, a lot of times this is done using a list of acceptable values.
In our project files, we’ll create types of users. The types will be used to illustrate the use of Enums.
In Ruby, we could do something like this:
class Admin NAME = "Administrator" end class NonAdmin NAME = "Application User" end class User USER_KINDS = [Admin, NonAdmin] attr_accessor :kind def self.admin USER_KINDS.first end def self.nonadmin USER_KINDS.last end end my_user = User.new my_user.kind = User.admin # we could have used USER_KINDS.first puts my_user.kind # prints out Admin puts my_user.kind::NAME # prints out Administrator
We could add validation to this to make sure that User#kind is only ever set to a value included in USER_KINDS, but let’s see what the equivalent is in Rust.
pub struct User { kind: UserKind, } #[derive(Debug)] pub enum UserKind { Admin, NonAdmin, } #[derive(Debug)] pub struct Admin {} impl Admin { const NAME: &str = "Administrator"; } #[derive(Debug)] pub struct NonAdmin {} impl NonAdmin { const NAME: &str = "Application User"; } let my_user = User { kind: UserKind::Admin, }; println!("{:?}", my_user.kind); // prints Admin let kind = my_user.kind; if let UserKind::Admin = kind { println!("name: {}", Admin::NAME); }
You can see we declare an object with type enum on line 6. Enum is a first-class citizen in Rust. It is made up of variants. Our variants are Admin and NonAdmin. Each is a struct with a constant implemented defining a unique name.
Notice when we define our my_user
variable we set a value with a kind. That kind points to the specific variant of the enum. In the example above, that is UserKind::Admin
. One of the first things users will do is try to access the specific variant through my_user
. Notice where we print out the value of my_user.kind
. It says Admin. But if we try my_user.kind::NAME
to try and access the declared constant in the Admin struct, we’ll receive a compilation error. Our value is a pointer to that variant struct, but not an instance of the struct itself. The last 4 lines show a way of accessing the NAME constant value of the Admin class, in relation to our my_user
variable. When you are used to the expressiveness in Ruby, this illustration may feel like a slight backpedal just to access a property of an object.
But, we’re not illustrating the utility of Enums without trying to set the value to something invalid. Let’s set the my_user
kind to just UserKind. The compiler will tell us that it is looking for a value other than an Enum. Ok, that is good check. Let’s try a variant that doesn’t exist. Let’s set my_user
kind to UserKind::Invalid
. The compiler tells us there is no variant by that name for the Enum. Let’s define a new Enum and try a variant from it.
pub enum New { Admin, } let my_user = User { kind: New::Admin };
The compiler will yell at us about mismatched types. This gives us a good idea about Enums and how they are first-class citizens.
There are a few different kinds of variants that can be declared. Above we’ve only used basic variants. Let’s take a look at a tuple variant. In simple terms, a tuple variant is a variant that takes a value.
Let’s add a score property to our User struct and its values will come from a new Enum. The variants will take a value representing either a high or low score for the user.
pub struct User { kind: UserKind, score: UserScore, } #[derive(Debug)] pub enum UserScore { HighScore(i8), LowScore(i8), } let my_user = User { kind: UserKind::Admin, score: UserScore::HighScore(99), }; let score = my_user.score; if let UserScore::HighScore(value) = score { println!("value: {}", value); }
The first thing to notice is that our new Enum has two variants. A high score and a low score and they both expect an i8 value. We know from the previous post on Numbers, that i8 is an 8-bit signed integer. When defining our my_user
variable now, we also assign a score type of UserScore::HighScore(99)
. This uses the HighScore variant of our UserScore Enum and assigns it a value of 99. Then our last three lines illustrate how to later pull that data back out by checking the score type against the HighScore variant with a value placeholder. The placeholder then holds the value we used in our assignment - 99
.
Now let’s talk about struct-like variants. When using tuple variants, things can get a little unorganized with multiple values being passed in. To better support that, Rust supports struct-like variants so we can name the various values being passed in like we do properties of a struct.
Let’s introduce a new Enum called UserContact
that will hold either electronic or physical contact information. We’ll add a contact field to our User struct with a type of UserContact. Then assign our my_user
an electronic type of the variant with two values.
pub struct User { kind: UserKind, score: UserScore, contact: UserContact, } #[derive(Debug)] pub enum UserContact { Electronic { email: String, text: String }, Physical { city: String, state: String }, } let my_user = User { kind: UserKind::Admin, score: UserScore::HighScore(99), contact: UserContact::Electronic { email: String::from("user@example.com"), text: String::from("1235131234"), }, }; let contact = my_user.contact; if let UserContact::Electronic { email, text } = contact { println!("email: {}, text: {}", email, text); }
Notice that the UserContact
variables name the arguments (like we can do in Ruby method definitions). And in our if let
statement on the third to last line, we compare our my_user
contact value to a struct-like variant that includes the named fields of email
and text
. We can then use those values in our second to last line printing out the values.
To further illustrate, let’s accomplish the same thing with Ruby. We’ll make inline comments about any differences that are hard to reach parity on.
class User USER_KINDS = [Admin, NonAdmin] USER_SCORES = [HighScore, LowScore] USER_CONTACTS = [Electronic, Physical] attr_accessor :kind, :high_score, :low_score, :contact # note: we are using class methods to obtain the allowed kinds # one nicety about Rust enums is the compiler applying validation # based on types def self.admin USER_KINDS.first end def self.nonadmin USER_KINDS.last end # note: same as user kind, we are using class methods to return the allowed types def self.high_score(value) USER_SCORES.first.new(value) end def self.low_score(value) USER_SCORES.last.new(value) end # note: we aren't reaching parity here compared to the Rust version # in the Rust version, the user's contact is an enum and the variant determines the type # in the Ruby version, we store either one as the contact # we could reach parity by introducing a few more objects like a UserContact class # that would return one or the other like the two enum variants in Rust def self.contact(email:, text:, city:, state:) if !email.nil? || !text.nil? USER_CONTACTS.first.new(email: email, text: text) elsif !city.nil? || !state.nil? USER_CONTACTS.last.new(city: city, state: state) end end end my_user = User.new my_user.kind = User.admin my_user.low_score = User.low_score(30) my_user.contact = User.contact(email: "user@example.com", text: "1235131234", city: nil, state: nil) puts my_user.kind puts my_user.kind::NAME puts my_user.low_score.score puts my_user.contact.email
You can see how we can achieve very similar things in both Ruby and Rust in the context of Enums, but the execution is quite a bit different. In the end, Ruby doesn’t have a first-class Enum citizen, so we have to create some of the functionality ourselves depending on the needs of our applications.
One thing we didn’t cover here is defining methods on an Enum. The good news is that you can do that Rust. But we’re going to leave implementing methods on objects for the next post where we dive into what Rust calls classes–Structs. We probably should have discussed Structs before Enums, but Enums illustrate a couple of differences between the languages.
Music companion of this post:
Ist Ist — Protagonists
What I am working on currently:
I am multitasking at the moment: expanding the API for my startup–Schemabook and CQRS style events going into Kafka inside a Rust app (converting from Rabbit to Kafka)