The Tessera Book
Chapter 5

Structs and Methods

A struct groups related values under one name, with a name for each part. In this chapter we'll define structs, give them methods, and build bigger structs out of smaller ones with embedding.

Defining a struct

Let's say a program needs rectangles. Two separate numbers would do, but nothing would say they belong together. A struct does:

struct Rect {
    width: Int,
    height: Int,
}

fn area(r: Rect) -> Int {
    r.width * r.height
}

fn main(root: Root) {
    let width = 30
    let r = Rect { width, height: 50 }
    root.stdout.print("{r} has an area of {area(r)}")
    root.stdout.print("it is {r.width} wide")
}
Listing 5-1: A struct and a function that uses it examples/ch05-01-area/main.tsr
$ tessera run main.tsr
Rect { width: 30, height: 50 } has an area of 1500
it is 30 wide

struct Rect { width: Int, height: Int } declares a type with two fields. Rect { width, height: 50 } builds a value of it; every field must be given. When a variable has the same name as the field, as width does here, you can write just the name.

Fields are read with a dot, r.width. Structs print with their type and fields, which is often all the debugging output you need. And, as chapter 4 explained, a struct is a value: passing r to area gives area its own copy.

To change a field, the variable must be mut (r.width = 40), or you make a changed copy with { r with width: 40 }.

Methods

area(r) works, but area is really a property of the rectangle. A function whose first parameter is named self is a method of that parameter's type, and you call it with a dot:

struct Rect {
    width: Int,
    height: Int,
}

fn area(self: Rect) -> Int {
    self.width * self.height
}

fn can_hold(self: Rect, other: Rect) -> Bool {
    self.width > other.width && self.height > other.height
}

fn scaled(self: Rect, by: Int) -> Rect {
    { self with width: self.width * by, height: self.height * by }
}

fn square(size: Int) -> Rect {
    Rect { width: size, height: size }
}

fn main(root: Root) {
    let out = root.stdout
    let big = Rect { width: 30, height: 50 }
    let small = square(10)

    out.print("area: {big.area()}")
    out.print("big holds small: {big.can_hold(small)}")
    out.print("small holds big: {small.can_hold(big)}")
    out.print("small, tripled: {small.scaled(3)}, area {small.scaled(3).area()}")

    let fits_in_big = big.can_hold
    out.print("{[square(5), square(40)].map(fits_in_big)}")
}
Listing 5-2: Methods on Rect examples/ch05-02-methods/main.tsr
$ tessera run main.tsr
area: 1500
big holds small: true
small holds big: false
small, tripled: Rect { width: 30, height: 30 }, area 900
[true, false]

There's no special block that groups a type's methods: a method is a function whose first parameter is self. You can declare them anywhere in the file, in any order, as with functions.

Methods take self by value, like any other parameter. A method that "changes" the value, like scaled, returns a new one, here built with with.

A function without self, like square, is just a function. It's the usual way to provide a constructor with a helpful name.

The last lines show a method value: big.can_hold, without the call, is a function that calls can_hold on big. It holds a copy of big taken right there, so you can pass it to map like any function.

Two methods with the same name can exist for different types; area for Rect and area for Circle never clash, because the type of self decides which one you mean.

Methods on built-in types

You can add methods to the built-in types too, such as Str, Int and List:

// A method on Str, for this file only.
fn shout(self: Str) -> Str {
    self.to_upper() + "!"
}

fn is_even(self: Int) -> Bool {
    self % 2 == 0
}

fn main(root: Root) {
    root.stdout.print("hello".shout())
    root.stdout.print("{[1, 2, 3, 4].filter(|n| n.is_even())}")
}
Listing 5-3: Methods on Str and Int examples/ch05-03-builtin-methods/main.tsr
$ tessera run main.tsr
HELLO!
[2, 4]

Such a method is visible in its own file only. Built-in types belong to no module, so if two libraries each added a shout to Str, they would clash; keeping these methods file-local prevents that. To share a helper on a built-in type, export a plain function instead.

Building structs from structs: embedding

Often one type is "another type, plus a bit". An admin is a user with a permission level. You could give Admin a field called user and write a.user.name everywhere, but Tessera has a shorter way: embedding. Write the type without a field name, and its fields and methods can be used directly on the outer struct:

struct User {
    name: Str,
    email: Str,
}

fn greet(self: User) -> Str {
    "hi, {self.name}"
}

struct Admin {
    User,
    level: Int,
}

fn can_delete(self: Admin) -> Bool {
    self.level >= 2
}

fn main(root: Root) {
    let out = root.stdout
    let mut a = Admin { User: User { name: "ana", email: "[email protected]" }, level: 3 }

    out.print(a.name)
    out.print(a.greet())
    out.print("can delete: {a.can_delete()}")

    a.name = "ana maria"
    out.print(a.User.greet())
    out.print("{a}")
}
Listing 5-4: An Admin embeds a User examples/ch05-04-embedding/main.tsr
$ tessera run main.tsr
ana
hi, ana
can delete: true
hi, ana maria
Admin { User: User { name: "ana maria", email: "[email protected]" }, level: 3 }

Admin has a field named User, holding a whole User. a.name is short for a.User.name, and a.greet() for a.User.greet(). Assignment goes through too: a.name = "ana maria" changes a.User.name.

Lookup starts with the outer struct's own fields and methods, then looks inside its embedded structs. If two embedded structs at the same depth both have a field with the name you used, the compiler asks you to say which one you mean, as in a.Left.x.

Composition, not inheritance

Embedding saves typing, but it doesn't make an Admin a kind of User. A function that takes a User won't accept an Admin:

This program does not compile
struct User {
    name: Str,
}

struct Admin {
    User,
    level: Int,
}

fn welcome(u: User) -> Str {
    "welcome, {u.name}"
}

fn main(root: Root) {
    let a = Admin { User: User { name: "ana" }, level: 3 }
    root.stdout.print(welcome(a))
}
Listing 5-5: Passing an Admin where a User is expected examples/ch05-05-not-a-user/main.tsr
$ tessera check main.tsr
error[E0201]: expected `User`, found `Admin`
  --> main.tsr:16:31
    |
 16 |     root.stdout.print(welcome(a))
    |                               ^

1 error

Pass a.User instead. This is deliberate. With inheritance, code written for a base type can end up running a subclass's overridden method it never knew about. With embedding, a User method always works on a User; there's no overriding and no super. If Admin declares its own greet, that hides User's on Admin values, but a.User.greet() still calls the original, and a User method that calls self.greet() always gets User's.

When you want "any type that can greet", that's what interfaces are for, in chapter 10. Embedded methods count there: an Admin satisfies every interface its User does.

Summary

Structs name a group of values; methods are functions whose first parameter is self; method values like big.can_hold can be passed around; methods on built-in types stay in their file; and embedding builds a struct from others without inheritance. Structs say "this and that". The next chapter is about types that say "this or that": enums.