Defining an Enum
Enums allow you to define a type by enumerating its possible variants. Where structs
give you a way of grouping together related fields and data, enums give you a way
of saying a value is one of a possible set of values. For example, we may want to
say that Shape is one of a set of possible shapes that also includes Circle and
Triangle. Oxide lets us express these possibilities as an enum.
Let's look at a situation we might want to express in code and see why enums are useful and more appropriate than structs in this case. Say we need to work with IP addresses. Currently, two major standards are used for IP addresses: version four and version six. Because these are the only possibilities for an IP address that our program will come across, we can enumerate all possible variants, which is where enumeration gets its name.
Any IP address can be either a version four or a version six address, but not both at the same time. That property of IP addresses makes the enum data structure appropriate because an enum value can only be one of its variants. Both version four and version six addresses are still fundamentally IP addresses, so they should be treated as the same type when the code is handling situations that apply to any kind of IP address.
We can express this concept in code by defining an IpAddrKind enumeration and
listing the possible kinds an IP address can be, V4 and V6. These are the
variants of the enum:
enum IpAddrKind {
V4,
V6,
}
IpAddrKind is now a custom data type that we can use elsewhere in our code.
Enum Values
We can create instances of each of the two variants of IpAddrKind like this:
let four = IpAddrKind.V4
let six = IpAddrKind.V6
Note that the variants of the enum are namespaced under its identifier using dot
notation: IpAddrKind.V4 and IpAddrKind.V6. This is useful because now both
values are of the same type: IpAddrKind. We can then, for instance, define a
function that takes any IpAddrKind:
fn route(ipKind: IpAddrKind) {
// handle routing
}
And we can call this function with either variant:
route(IpAddrKind.V4)
route(IpAddrKind.V6)
Using enums has even more advantages. Thinking more about our IP address type, at the moment we don't have a way to store the actual IP address data; we only know what kind it is. Given that you just learned about structs, you might be tempted to tackle this problem with structs:
enum IpAddrKind {
V4,
V6,
}
struct IpAddr {
kind: IpAddrKind,
address: String,
}
let home = IpAddr {
kind: IpAddrKind.V4,
address: "127.0.0.1".toString(),
}
let loopback = IpAddr {
kind: IpAddrKind.V6,
address: "::1".toString(),
}
Here, we've defined a struct IpAddr that has two fields: a kind field that is
of type IpAddrKind (the enum we defined previously) and an address field of
type String. We have two instances of this struct.
However, representing the same concept using just an enum is more concise: rather
than an enum inside a struct, we can put data directly into each enum variant.
This new definition of the IpAddr enum says that both V4 and V6 variants
will have associated String values:
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr.V4("127.0.0.1".toString())
let loopback = IpAddr.V6("::1".toString())
We attach data to each variant of the enum directly, so there is no need for an
extra struct. Here, it's also easier to see another detail of how enums work: the
name of each enum variant that we define also becomes a function that constructs
an instance of the enum. That is, IpAddr.V4() is a function call that takes a
String argument and returns an instance of the IpAddr type.
There's another advantage to using an enum rather than a struct: each variant can
have different types and amounts of associated data. Version four IP addresses
will always have four numeric components that will have values between 0 and 255.
If we wanted to store V4 addresses as four UInt8 values but still express V6
addresses as one String value, we wouldn't be able to with a struct. Enums
handle this case with ease:
enum IpAddr {
V4(UInt8, UInt8, UInt8, UInt8),
V6(String),
}
let home = IpAddr.V4(127, 0, 0, 1)
let loopback = IpAddr.V6("::1".toString())
We've shown several different ways to define data structures to store version four
and version six IP addresses. However, as it turns out, wanting to store IP
addresses and encode which kind they are is so common that the standard library
has a definition we can use! Let's look at how the standard library defines
IpAddr: it has the exact enum and variants that we've defined and used, but it
embeds the address data inside the variants in the form of two different structs,
which are defined differently for each variant.
Enums with Named Fields
Enum variants can also have named fields, similar to structs:
#[derive(Debug, Clone)]
public enum Message {
Quit,
Move { x: Int, y: Int },
Write(String),
ChangeColor(Int, Int, Int),
}
This enum has four variants with different types:
Quithas no data associated with it at all.Movehas named fields, like a struct.Writeincludes a singleString.ChangeColorincludes threeIntvalues.
Defining an enum with variants such as the ones above is similar to defining
different kinds of struct definitions, except the enum doesn't use the struct
keyword and all the variants are grouped together under the Message type.
Creating instances of these variants:
let quit = Message.Quit
let moveMsg = Message.Move { x: 10, y: 20 }
let write = Message.Write("hello".toString())
let color = Message.ChangeColor(255, 128, 0)
Defining Methods on Enums
We're also able to define methods on enums using extension blocks. Here's a
method named call that we could define on our Message enum:
extension Message {
fn call() {
match self {
Message.Quit -> println!("Quit"),
Message.Move { x, y } -> println!("Move to (\(x), \(y))"),
Message.Write(text) -> println!("Write: \(text)"),
Message.ChangeColor(r, g, b) -> println!("Color: (\(r), \(g), \(b))"),
}
}
}
let m = Message.Write("hello".toString())
m.call()
The body of the method uses self to get the value that we called the method on.
In this example, we've created a variable m that has the value
Message.Write("hello".toString()), and that is what self will be in the body
of the call method when m.call() runs.
The Nullable Type: Using T? Instead of Option
Oxide provides a built-in way to express the concept of a value being present or
absent using nullable types. Instead of writing Option<T> as you would in Rust,
Oxide uses the more concise T? syntax. This is so common and useful that it's
built into the language itself.
The nullable type encodes the very common scenario in which a value could be something or it could be nothing. For example, if you request the first item of a non-empty list, you would get a value. If you request the first item of an empty list, you would get nothing.
Expressing this concept in terms of the type system means the compiler can check whether you've handled all the cases you should be handling; this functionality can prevent bugs that are extremely common in other programming languages.
Here's how you use nullable types in Oxide:
let someNumber: Int? = Some(5)
let someString: String? = Some("a string".toString())
let absentNumber: Int? = null
let absentString: String? = null
The type of someNumber is Int?. The type of someString is String?.
Because we've specified a type annotation, Oxide knows these are nullable types.
When we have a Some value, we know that a value is present and the value is
held within the Some. When we have a null value, in some sense it means the
same thing as null in other languages: we don't have a valid value.
So why is T? any better than having null? In short, because Int? and Int
are different types, the compiler won't let us use an Int? value as if it were
definitely an Int. For example, this code won't compile because it's trying
to add an Int? to an Int:
let x: Int = 5
let y: Int? = Some(5)
let sum = x + y // Error! Can't add Int and Int?
When we have a value of a type like Int in Oxide, the compiler will ensure we
always have a valid value. We can proceed confidently without having to check
for null before using that value. Only when we have a Int? do we have to
worry about possibly not having a value, and the compiler will make sure we
handle that case before using the value.
In other words, you have to convert a T? to a T before you can perform T
operations with it. Generally, this helps catch one of the most common issues
with null: assuming that something isn't null when it actually is.
Eliminating the risk of incorrectly assuming a not-null value helps you to be
more confident in your code. In order to have a value that can possibly be null,
you must explicitly opt in by making the type of that value T?. Then, when
you use that value, you are required to explicitly handle the case when the
value is null. Everywhere that a value has a type that isn't a T?, you can
safely assume that the value isn't null.
So how do you get the T value out of a Some variant when you have a value of
type T? so that you can use that value? The T? type has a large number of
methods that are useful in a variety of situations; you can find them in the
Rust documentation for Option<T>. Becoming familiar with the methods on
Option<T> will be extremely useful in your journey with Oxide.
In general, in order to use a T? value, you want to have code that will handle
each variant. You want some code that will run only when you have a Some(T)
value, and this code is allowed to use the inner T. You want some other code
to run only if you have a null value, and that code doesn't have a T value
available. The match expression is a control flow construct that does just
this when used with enums: it will run different code depending on which variant
of the enum it has, and that code can use the data inside the matching variant.