Data Types in Rust: Borrowing from Both Worlds

combine_data.png

Last time, we looked at the concept of ownership in Rust. This idea underpins how we manage memory in our Rust programs. It explains why we don't need garbage collection. and it helps a lot with ensuring our program runs efficiently.

This week, we'll study the basics of defining data types. As we've seen so far, Rust combines ideas from both object oriented languages and functional languages. We'll continue to see this trend with how we define data. There will be some ideas we know and love from Haskell. But we'll also see some ideas that come from C++.

For the quickest way to get up to speed with Rust, check out our Rust Video Tutorial! It will walk you through all the basics of Rust, including installation and making a project.

Defining Structs

Haskell has one primary way to declare a new data type: the data keyword. We can also rename types in certain ways with type and newtype, but data is the core of it all. Rust is a little different in that it uses a few different terms to refer to new data types. These all correspond to particular Haskell structures. The first of these terms is struct.

The name struct is a throwback to C and C++. But to start out we can actually think of it as a distinguished product type in Haskell. That is, a type with one constructor and many named fields. Suppose we have a User type with name, email, and age. Here's how we could make this type a struct in Rust:

struct User {
  name: String,
  email: String,
  age: u32,
}

This is very much like the following Haskell definition:

data User = User
  { name :: String
  , email :: string
  , age :: Int
  }

When we initialize a user, we should use braces and name the fields. We access individual fields using the . operator.

let user1 = User {
  name: String::from("James"),
  email: String::from("james@test.com"),
  age: 25,
};

println!("{}", user1.name);

If we declare a struct instance to be mutable, we can also change the value of its fields if we want!

let mut user1 = User {
  name: String::from("James"),
  email: String::from("james@test.com"),
  age: 25,
};

user1.age = 26;

When you're starting out, you shouldn't use references in your structs. Make them own all their data. It's possible to put references in a struct, but it makes things more complicated.

Tuple Structs

Rust also has the notion of a "tuple struct". These are like structs except they do not name their fields. The Haskell version would be an "undistinguished product type". This is a type with a single constructor, many fields, but no names. Consider these:

// Rust
struct User(String, String, u32);

-- Haskell
data User = User String String Int

We can destructure and pattern match on tuple structs. We can also use numbers as indices with the . operator, in place of user field names.

struct User(String, String, u32);

let user1 = User("james", "james@test.com", 25);

// Prints "james@test.com"
println!("{}", user1.1);

Rust also has the idea of a "unit struct". This is a type that has no data attached to it. These seem a little weird, but they can be useful, as in Haskell:

// Rust
struct MyUnitType;

-- Haskell
data MyUnitType = MyUnitType

Enums

The last main way we can create a data type is with an "enum". In Haskell, we typically use this term to refer to a type that has many constructors with no arguments. But in Rust, an enum is the general term for a type with many constructors, no matter how much data each has. Thus it captures the full range of what we can do with data in Haskell. Consider this example:

// Rust
struct Point(f32, f32);

enum Shape {
  Triangle(Point, Point, Point),
  Rectangle(Point, Point, Point, Point),
  Circle(Point, f32),
}

-- Haskell
data Point = Point Float Float

data Shape =
  Triangle Point Point Point |
  Rectangle Point Point Point Point |
  Circle Point Float

Pattern matching isn't quite as easy as in Haskell. We don't make multiple function definitions with different patterns. Instead, Rust uses the match operator to allow us to sort through these. Each match must be exhaustive, though you can use _ as a wildcard, as in Haskell. Expressions in a match can use braces, or not.

fn get_area(shape: Shape) -> f32 {
  match shape {
    Shape::Triangle(pt1, pt2, pt3) => {
      // Calculate 1/2 base * height
    },
    Shape::Rectangle(pt1, pt2, pt3, pt4) => {
      // Calculate base * height
    },
    Shape::Circle(center, radius) => (0.5) * radius * radius * PI
  }
}

Notice we have to namespace the names of the constructors! Namespacing is one element that feels more familiar from C++. Let's look at another.

Implementation Blocks

So far we've only looked at our new types as dumb data, like in Haskell. But unlike Haskell, Rust allows us to attach implementations to structs and enums. These definitions can contain instance methods and other functions. They act like class definitions from C++ or Python. We start off an implementation section with the impl keyword.

As in Python, any "instance" method has a parameter self. In Rust, this reference can be mutable or immutable. (In C++ it's called this, but it's an implicit parameter of instance methods). We call these methods using the same syntax as C++, with the . operator.

impl Shape {
  fn area(&self) -> f32 {
    match self {
      // Implement areas
    }
  }
}

fn main() {
  let shape1 = Shape::Circle(Point(0, 0), 5);
  println!("{}", shape1.area()); 
}

We can also create "associated functions" for our structs and enums. These are functions that don't take self as a parameter. They are like static functions in C++, or any function we would write for a type in Haskell.

impl Shape {
  fn shapes_intersect(s1: &Shape, s2: &Shape) -> bool 


}

fn main() {
  let shape1 = Shape::Circle(Point(0, 0), 5);
  let shape2 = Shape::Circle(Point(10, 0), 6);
  if Shape::shapes_intersect(&shape1, &shape2) {
    println!("They intersect!");
  } else {
    println!("No intersection!");
  };
}

Notice we still need to namespace the function name when we use it!

Generic Types

As in Haskell, we can also use generic parameters for our types. Let's compare the Haskell definition of Maybe with the Rust type Option, which does the same thing.

// Rust
enum Option<T> {
  Some(T),
  None,
}

-- Haskell
 data Maybe a =
  Just a |
  Nothing

Not too much is different here, except for the syntax.

We can also use generic types for functions:

fn compare<T>(t1: &T, t2: &T) -> bool

But, you won't be able to do much with generics unless you know some information about what the type does. This is where traits come in.

Traits

For the final topic of this article, we'll discuss traits. These are like typeclasses in Haskell, or interfaces in other languages. They allow us to define a set of functions. Types can provide an implementation for those functions. Then we can use those types anywhere we need a generic type with that trait.

Let's reconsider our shape example and suppose we have a different type for each of our shapes.

struct Point(f32, f32);

struct Rectangle {
  top_left: Point,
  top_right: Point,
  bottom_right: Point,
  bottom_left: Point,
}

struct Triangle {
  pt1: Point,
  pt2: Point,
  pt3: Point,
}

struct Circle {
  center: Point,
  radius: f32,
}

Now we can make a trait for calculating the area, and let each shape implement that trait! Here's how the syntax looks for defining it and then using it in a generic function. We can constrain what generics a function can use, as in Haskell:

pub trait HasArea {
  fn calculate_area(&self) -> f32;
}

impl HasArea for Circle {
  fn calculate_area(&self) -> f32 {
    self.radius * self.radius * PI
  }
}

fn double_area<T: HasArea>(element: &T) -> f32 {
  2 * element.calculate_area()
}

Also as in Haskell, we can derive certain traits with one line! The Debug trait works like Show:

#[derive(Debug)]
struct Circle

What's Next

This should give us a more complete understanding of how we can define data types in Rust. We see an interesting mix of concepts. Some ideas, like instance methods, come from the object oriented world of C++ or Python. Other ideas, like matchable enumerations, come from more functional languages like Haskell.

Next time, we'll start looking at making a project with Rust. We'll consider how we create a project, how to manage its dependencies, how to run it, and how to test it.

Previous
Previous

Making Rust Projects with Cargo!

Next
Next

Ownership: Managing Memory in Rust