Skip to content

9. Generic Types

We've already seen generic types when working with arrays and vectors, but here we'll learn how they work and how to define new ones.

As we saw with generic functions, the idea behind generics is that you can define a broad template for how something works without specifying exact types, and then you can provide the type information later. We will introduce generic types through two examples using Regions and Handles.

Implementing Regions and Handles

Serene does not have references or pointers. So how does one object refer to another object? The idiom that is most commonly used in Serene is region-based memory management, with the types Region and Handle.

If you have a bunch of objects of the same type that all refer to each other (say, in a data structure like a linked list), then the typical way to handle it is to store all of the objects inside one Region. Then an object can access another object by storing its Handle in one of its fields. The other object would be accessed with an indexing operator, like my_region[my_handle]. Note that the indexing operator returns an Option type here because it is possible that there is no valid object for that handle.

Region and Handle are part of the standard library, but here's a sample of how they could be implemented.

type Handle{MyRegion: type} with
~ constructor(MyRegion: type, index: Int) {
    var self.index private = index
}
~ friend Region

// Issue: should Handles be nullable?


type Region{T: type} with
~ constructor(T: type) {
    var self.vector private = Vector(Option{T})
}
~ specifics {
    method add(new_value: T) -> Handle{Region{T}} {
        run self.vector.append(NewValue)
        const handle = Handle(Region{T}, vector.length() - 1)
        return handle
    }

    method delete(index_to_delete: Handle{Region{T}}) {
        set self.vector[index_to_delete] = None
    }

    // Implements the indexing operator on this type
    subscript get(my_handle: Handle{Region{T}}) -> Option{T} {
        return self.vector[my_handle.index]
    }
}

Doubly Linked Lists

We've seen a singly linked list implementation, but doubly linked lists can be a bit more difficult in a language with single ownership of objects. However, Regions and Handles make this task manageable, as we can store all of the elements in a Region, and we can use Handles as indexes into that Region, so there is no need for pointers or shared mutability. We also use generics to allow you to specify the type of the data.

// Potentially some nullability (Option) issues here

type Node{MyHandle: type, Data: type} with
~ constructor(prev: Option{MyHandle}, data: Data, next: Option{MyHandle}) {
    var self.prev = prev
    var self.data = data
    var self.next = next
}

type DoublyLinkedList{Data: type} with
~ constructor(Data: type) {
    var self.nodes private = Region(Data)
    type self.Handle private = self.nodes.Handle
    var self.head_handle: Option(Handle) private = None
    var self.tail_handle: Option(Handle) private = None
}

~ specifics {
    method addFirst(a: Data) {
        set self.head_handle = self.nodes.add!(Node(None, a, self.head_handle))
    }

    method addLast(a: Data) {
        set self.tail_handle = self.nodes.add!(Node(self.tail_handle, a, None))
    }

    method deleteFirst() {
        if (self.head_handle is None) return
        const x = self.head_handle
        match (self.nodes[self.head_handle].next) {
            Some(y) -> set self.head_handle = y
            None -> return
        }
        run self.nodes.delete!(x)
    }

    method deleteLast() {
        if (self.tail_handle is None) return
        const x = self.tail_handle
        match (self.nodes[self.tail_handle].prev) {
            Some(y) -> set self.tail_handle = y
            None -> return
        }
        run self.nodes.delete!(x)
    }

    subscript get(h: Handle) -> Option{Data} {
        match (self.nodes[h]) {
            Some(x) -> return x.data
            None -> return None
        }
    }
}