23 KiB
Lists
Tomo supports lists as a container type that holds a list of elements of any
type in a compact format similar to a C-style array. Lists are immutable by
default, but use copy-on-write semantics to efficiently mutate in place when
possible. Lists are 1-indexed, which means the first item in the list has
index 1
.
Syntax
Lists are written using square brackets and a list of comma-separated elements:
nums := [10, 20, 30]
Each element must have the same type (or be easily promoted to the same type). If you want to have an empty list, you must specify what type goes inside the list like this:
empty : [Int] = []
For type annotations, a list that holds items with type T
is written as [T]
.
List Comprehensions
Lists can also use comprehensions, where you specify how to dynamically create all the elements by iteration instead of manually specifying each:
>> [i*10 for i in (3).to(8)]
= [30, 40, 50, 60, 70, 80]
>> [i*10 for i in (3).to(8) if i != 4]
= [30, 50, 60, 70, 80]
Comprehensions can be combined with regular items or other comprehensions:
>> [-1, i*10 for i in (3).to(8), i for i in 3]
= [-1, 30, 40, 50, 60, 70, 80, 1, 2, 3]
Length
List length can be accessed by the .length
field:
>> [10, 20, 30].length
= 3
Indexing
List values are accessed using square bracket indexing. Since lists are
1-indexed, the index 1
corresponds to the first item in the list. Negative
indices are used to refer to items from the back of the list, so -1
is the
last item, -2
is the second-to-last, and so on.
list := [10, 20, 30, 40]
>> list[1]
= 10
>> list[2]
= 20
>> list[-1]
= 40
>> list[-2]
= 30
If a list index of 0
or any value larger than the length of the list is
used, it will trigger a runtime error that will print what the invalid list
index was, the length of the list, and a stack trace. As a performance
operation, if list bounds checking proves to be a performance hot spot, you
can explicitly disable bounds checking by adding list[i; unchecked]
to the
list access.
Iteration
You can iterate over the items in a list like this:
for item in list
...
for i, item in list
...
List iteration operates over the value of the list when the loop began, so modifying the list during iteration is safe and will not result in the loop iterating over any of the new values.
Concatenation
Lists can be concatenated with the ++
operator, which returns a list that
has the items from one appended to the other. This should not be confused with
the addition operator +
, which does not work with lists.
>> [1, 2] ++ [3, 4]
= [1, 2, 3, 4]
Implementation Details
Under the hood, lists are implemented as a struct that contains a pointer to a
contiguous chunk of memory storing the elements of the list and some other
metadata. Since Tomo has datatypes with different sizes, like Bool
s which
take one byte and struct
s which can take up many bytes, it's worth noting
that lists store the elements compactly and inline, without the need for each
list cell to hold a pointer to where the data actually lives.
The other metadata stored with a list includes its length as well as the
stride of the list. The stride is not exposed to the user, but it's the gap
in bytes between each element in the list. The reason this is mentioned is
that it is possible to create immutable slices of lists in constant time by
creating a new struct that points to the appropriate starting place for the
list items and has the appropriate stride. The upshot is that a method like
list.reversed()
does not actually copy the list, it simply returns a struct
that points to the back of the list with a negative stride. Lists adhere to
copy-on-write semantics, so we can cheaply create many read-only references to
the same data, and only need to do copying if we plan to modify data. After
doing a modification, future modifications can be done in-place as long as
there is only one reference to that data.
Internally, we also take advantage of this inside of tables, which compactly store all of the key/value pairs in a contiguous list and we can return an immutable slice of that list showing only the keys or only the values by choosing the right starting point and stride.
Copy on Write
Lists can be thought of as values that have copy-on-write semantics that use
reference counting to perform efficient in-place mutations instead of copying
as a performance optimization when it wouldn't affect the program's semantics.
Without getting too deep into the details, suffice it to say that when you
create a list, that list can be thought of as a singular "value" in the same
way that 123
is a value. That variable's value will never change unless you
explicitly perform an assignment operation on the variable or call a method on
the variable.
Because it would be tedious to require users to write all list operations as
pure functions like list = list.with_value_at_index(value=x, index=i)
, Tomo
provides the familiar imperative syntax for modifying lists, but keeps the
semantics of the pure functional style. Writing list[i] = x
is
semantically equivalent to list = list.with_value_at_index(value=x, index=i)
, but much more readable and easy to write. Similarly,
list.insert(x)
is semantically equivalent to list = list.with_value_inserted(x)
. We implement these mutating methods as functions
that take a pointer to a list variable, which then either mutate the list's
data in-place (if this is the only thing referencing that data) or construct a
new list and store its value in the memory where the list variable is stored.
When there is only a single reference to a list value, we can perform these modifications in-place (lists typically have a little bit of spare capacity at the end, so appending usually doesn't trigger a reallocation). When there are shared references, we must create a copy of the list's data before modifying it so the other references don't see the effects of the mutation. Here are some simple examples:
nums := [10, 20, 30, 39]
// Efficient in-place mutation because data references are not shared:
nums[4] = 40
// Constant time operation, but increments the reference count:
tmp := nums
>> tmp
= [10, 20, 30, 40]
// Now, a mutation will trigger a copy-on-write,
// which resets the reference count to zero:
nums[4] = 999
>> nums
= [10, 20, 30, 999]
// Because of the copy-on-write, `tmp` is unchanged:
>> tmp
= [10, 20, 30, 40]
// Since the reference count has been reset, we can do more
// mutations without triggering another copy-on-write:
nums[4] = -1
>> nums
= [10, 20, 30, -1]
List reference counting is approximate, but will only ever err on the side of correctness at the expense of performance, not the other way around. Occasionally, unnecessary copying may occur, but you should never experience an list value changing because of some operation performed on a different list value.
List Pointers
Since the normal case of lists is to treat them like immutable values, what do
we do if we actually want to have a shared reference to a list whose contents
change over time? In that case, we want to use the @
operator to create a
pointer to a heap-allocated list and pass that pointer around. This is the same
behavior that you get in Python when you create a list
:
nums := @[10, 20, 30]
tmp := nums
nums.insert(40)
>> tmp
= @[10, 20, 30, 40]
Having multiple pointers to the same heap-allocated list does not cause the list's reference count to increase, because there is only one "value" in play: the one stored on the heap. It's only when we store the "value" in multiple places that we need to increment the reference count:
// Increment the reference count, because `value` now has to hold
// whatever data was at the pointer's location at this point in time:
value := nums[]
The TL;DR is: you can cheaply modify local variables that aren't aliased or
@
-allocated lists, but if you assign a local variable list to another
variable or dereference a heap pointer, it may trigger copy-on-write behavior.
List Methods
func binary_search(list: [T], by: func(x,y:&T->Int32) = T.compare -> Int)
func by(list: [T], step: Int -> [T])
func clear(list: @[T] -> Void)
func counts(list: [T] -> {T=Int})
func find(list: [T], target: T -> Int?)
func first(list: [T], predicate: func(item:&T -> Bool) -> Int)
func from(list: [T], first: Int -> [T])
func has(list: [T] -> Bool)
func heap_pop(list: @[T], by: func(x,y:&T->Int32) = T.compare -> T?)
func heap_push(list: @[T], item: T, by=T.compare -> Void)
func heapify(list: @[T], by: func(x,y:&T->Int32) = T.compare -> Void)
func insert(list: @[T], item: T, at: Int = 0 -> Void)
func insert_all(list: @[T], items: [T], at: Int = 0 -> Void)
func pop(list: &[T], index: Int = -1 -> T?)
func random(list: [T], random: func(min,max:Int64->Int64)? = none -> T)
func remove_at(list: @[T], at: Int = -1, count: Int = 1 -> Void)
func remove_item(list: @[T], item: T, max_count: Int = -1 -> Void)
func reversed(list: [T] -> [T])
func sample(list: [T], count: Int, weights: [Num]? = ![Num], random: func(->Num)? = none -> [T])
func shuffle(list: @[T], random: func(min,max:Int64->Int64)? = none -> Void)
func shuffled(list: [T], random: func(min,max:Int64->Int64)? = none -> [T])
func slice(list: [T], from: Int, to: Int -> [T])
func sort(list: @[T], by=T.compare -> Void)
sorted(list: [T], by=T.compare -> [T])
to(list: [T], last: Int -> [T])
unique(list: [T] -> {T})
binary_search
Performs a binary search on a sorted list.
func binary_search(list: [T], by: func(x,y:&T->Int32) = T.compare -> Int)
list
: The sorted list to search.by
: The comparison function used to determine order. If not specified, the default comparison function for the item type will be used.
Returns:
Assuming the input list is sorted according to the given comparison function,
return the index where the given item would be inserted to maintain the sorted
order. That is, if the item is found, return its index, otherwise return the
place where it would be found if it were inserted and the list were sorted.
Example:
>> [1, 3, 5, 7, 9].binary_search(5)
= 3
>> [1, 3, 5, 7, 9].binary_search(-999)
= 1
>> [1, 3, 5, 7, 9].binary_search(999)
= 6
by
Creates a new list with elements spaced by the specified step value.
func by(list: [T], step: Int -> [T])
list
: The original list.step
: The step value for selecting elements.
Returns:
A new list with every step
-th element from the original list.
Example:
>> [1, 2, 3, 4, 5, 6].by(2)
= [1, 3, 5]
clear
Clears all elements from the list.
func clear(list: @[T] -> Void)
list
: The mutable reference to the list to be cleared.
Returns:
Nothing.
Example:
>> my_list.clear()
counts
Counts the occurrences of each element in the list.
func counts(list: [T] -> {T=Int})
list
: The list to count elements in.
Returns:
A table mapping each element to its count.
Example:
>> [10, 20, 30, 30, 30].counts()
= {10=1, 20=1, 30=3}
find
Finds the index of the first occurrence of an element (if any).
func find(list: [T], target: T -> Int?)
list
: The list to search through.item
: The item to find in the list.
Returns:
The index of the first occurrence or !Int
if not found.
Example:
>> [10, 20, 30, 40, 50].find(20)
= 2 : Int?
>> [10, 20, 30, 40, 50].find(9999)
= none : Int?
first
Find the index of the first item that matches a predicate function (if any).
func first(list: [T], predicate: func(item:&T -> Bool) -> Int)
list
: The list to search through.predicate
: A function that returnsyes
if the item should be returned orno
if it should not.
Returns:
Returns the index of the first item where the predicate is true or !Int
if no
item matches.
Example:
>> [4, 5, 6].find(func(i:&Int): i.is_prime())
= 5 : Int?
>> [4, 6, 8].find(func(i:&Int): i.is_prime())
= none : Int?
from
Returns a slice of the list starting from a specified index.
func from(list: [T], first: Int -> [T])
list
: The original list.first
: The index to start from.
Returns:
A new list starting from the specified index.
Example:
>> [10, 20, 30, 40, 50].from(3)
= [30, 40, 50]
has
Checks if the list has any elements.
func has(list: [T] -> Bool)
list
: The list to check.
Returns:
yes
if the list has elements, no
otherwise.
Example:
>> [10, 20, 30].has(20)
= yes
heap_pop
Removes and returns the top element of a heap or none
if the list is empty.
By default, this is the minimum value in the heap.
func heap_pop(list: @[T], by: func(x,y:&T->Int32) = T.compare -> T?)
list
: The mutable reference to the heap.by
: The comparison function used to determine order. If not specified, the default comparison function for the item type will be used.
Returns:
The removed top element of the heap or none
if the list is empty.
Example:
>> my_heap := [30, 10, 20]
>> my_heap.heapify()
>> my_heap.heap_pop()
= 10
heap_push
Adds an element to the heap and maintains the heap property. By default, this is a minimum heap.
func heap_push(list: @[T], item: T, by=T.compare -> Void)
list
: The mutable reference to the heap.item
: The item to be added.by
: The comparison function used to determine order. If not specified, the default comparison function for the item type will be used.
Returns:
Nothing.
Example:
>> my_heap.heap_push(10)
heapify
Converts a list into a heap.
func heapify(list: @[T], by: func(x,y:&T->Int32) = T.compare -> Void)
list
: The mutable reference to the list to be heapified.by
: The comparison function used to determine order. If not specified, the default comparison function for the item type will be used.
Returns:
Nothing.
Example:
>> my_heap := [30, 10, 20]
>> my_heap.heapify()
insert
Inserts an element at a specified position in the list.
func insert(list: @[T], item: T, at: Int = 0 -> Void)
list
: The mutable reference to the list.item
: The item to be inserted.at
: The index at which to insert the item (default is0
). Since indices are 1-indexed and negative indices mean "starting from the back", an index of0
means "after the last item".
Returns:
Nothing.
Example:
>> list := [10, 20]
>> list.insert(30)
>> list
= [10, 20, 30]
>> list.insert(999, at=2)
>> list
= [10, 999, 20, 30]
insert_all
Inserts a list of items at a specified position in the list.
func insert_all(list: @[T], items: [T], at: Int = 0 -> Void)
list
: The mutable reference to the list.items
: The items to be inserted.at
: The index at which to insert the item (default is0
). Since indices are 1-indexed and negative indices mean "starting from the back", an index of0
means "after the last item".
Returns:
Nothing.
Example:
list := [10, 20]
list.insert_all([30, 40])
>> list
= [10, 20, 30, 40]
list.insert_all([99, 100], at=2)
>> list
= [10, 99, 100, 20, 30, 40]
pop
Removes and returns an item from the list. If the given index is present in the list, the item at that index will be removed and the list will become one element shorter.
func pop(list: &[T], index: Int = -1 -> T?)
list
: The list to remove an item from.index
: The index from which to remove the item (default: the last item).
Returns:
none
if the list is empty or the given index does not exist in the list,
otherwise the item at the given index.
Example:
>> list := [10, 20, 30, 40]
>> list.pop()
= 40
>> list
= &[10, 20, 30]
>> list.pop(index=2)
= 20
>> list
= &[10, 30]
random
Selects a random element from the list.
func random(list: [T], random: func(min,max:Int64->Int64)? = none -> T)
list
: The list from which to select a random element.random
: If provided, this function will be used to get a random index in the list. Returned values must be betweenmin
andmax
(inclusive). (Used for deterministic pseudorandom number generation)
Returns:
A random element from the list.
Example:
>> [10, 20, 30].random()
= 20
remove_at
Removes elements from the list starting at a specified index.
func remove_at(list: @[T], at: Int = -1, count: Int = 1 -> Void)
list
: The mutable reference to the list.at
: The index at which to start removing elements (default is-1
, which means the end of the list).count
: The number of elements to remove (default is1
).
Returns:
Nothing.
Example:
list := [10, 20, 30, 40, 50]
list.remove_at(2)
>> list
= [10, 30, 40, 50]
list.remove_at(2, count=2)
>> list
= [10, 50]
remove_item
Removes all occurrences of a specified item from the list.
func remove_item(list: @[T], item: T, max_count: Int = -1 -> Void)
list
: The mutable reference to the list.item
: The item to be removed.max_count
: The maximum number of occurrences to remove (default is-1
, meaning all occurrences).
Returns:
Nothing.
Example:
list := [10, 20, 10, 20, 30]
list.remove_item(10)
>> list
= [20, 20, 30]
list.remove_item(20, max_count=1)
>> list
= [20, 30]
reversed
Returns a reversed slice of the list.
func reversed(list: [T] -> [T])
list
: The list to be reversed.
Returns:
A slice of the list with elements in reverse order.
Example:
>> [10, 20, 30].reversed()
= [30, 20, 10]
sample
Selects a sample of elements from the list, optionally with weighted probabilities.
func sample(list: [T], count: Int, weights: [Num]? = ![Num], random: func(->Num)? = none -> [T])
list
: The list to sample from.count
: The number of elements to sample.weights
: The probability weights for each element in the list. These values do not need to add up to any particular number, they are relative weights. If no weights are given, elements will be sampled with uniform probability.random
: If provided, this function will be used to get random values for sampling the list. The provided function should return random numbers between0.0
(inclusive) and1.0
(exclusive). (Used for deterministic pseudorandom number generation)
Errors: Errors will be raised if any of the following conditions occurs:
- The given list has no elements and
count >= 1
count < 0
(negative count)- The number of weights provided doesn't match the length of the list.
- Any weight in the weights list is negative, infinite, or
NaN
- The sum of the given weights is zero (zero probability for every element).
Returns:
A list of sampled elements from the list.
Example:
>> [10, 20, 30].sample(2, weights=[90%, 5%, 5%])
= [10, 10]
shuffle
Shuffles the elements of the list in place.
func shuffle(list: @[T], random: func(min,max:Int64->Int64)? = none -> Void)
list
: The mutable reference to the list to be shuffled.random
: If provided, this function will be used to get a random index in the list. Returned values must be betweenmin
andmax
(inclusive). (Used for deterministic pseudorandom number generation)
Returns:
Nothing.
Example:
>> list.shuffle()
shuffled
Creates a new list with elements shuffled.
func shuffled(list: [T], random: func(min,max:Int64->Int64)? = none -> [T])
list
: The list to be shuffled.random
: If provided, this function will be used to get a random index in the list. Returned values must be betweenmin
andmax
(inclusive). (Used for deterministic pseudorandom number generation)
Returns:
A new list with shuffled elements.
Example:
>> [10, 20, 30, 40].shuffled()
= [40, 10, 30, 20]
slice
Returns a slice of the list spanning the given indices (inclusive).
func slice(list: [T], from: Int, to: Int -> [T])
list
: The original list.from
: The first index to include.to
: The last index to include.
Returns:
A new list spanning the given indices. Note: negative indices are counted from
the back of the list, so -1
refers to the last element, -2
the
second-to-last, and so on.
Example:
>> [10, 20, 30, 40, 50].slice(2, 4)
= [20, 30, 40]
>> [10, 20, 30, 40, 50].slice(-3, -2)
= [30, 40]
sort
Sorts the elements of the list in place in ascending order (small to large).
func sort(list: @[T], by=T.compare -> Void)
list
: The mutable reference to the list to be sorted.by
: The comparison function used to determine order. If not specified, the default comparison function for the item type will be used.
Returns:
Nothing.
Example:
list := [40, 10, -30, 20]
list.sort()
>> list
= [-30, 10, 20, 40]
list.sort(func(a,b:&Int): a.abs() <> b.abs())
>> list
= [10, 20, -30, 40]
sorted
Creates a new list with elements sorted.
sorted(list: [T], by=T.compare -> [T])
list
: The list to be sorted.by
: The comparison function used to determine order. If not specified, the default comparison function for the item type will be used.
Returns:
A new list with sorted elements.
Example:
>> [40, 10, -30, 20].sorted()
= [-30, 10, 20, 40]
>> [40, 10, -30, 20].sorted(func(a,b:&Int): a.abs() <> b.abs())
= [10, 20, -30, 40]
to
Returns a slice of the list from the start of the original list up to a specified index (inclusive).
to(list: [T], last: Int -> [T])
list
: The original list.last
: The index up to which elements should be included.
Returns:
A new list containing elements from the start up to the specified index.
Example:
>> [10, 20, 30, 40, 50].to(3)
= [10, 20, 30]
>> [10, 20, 30, 40, 50].to(-2)
= [10, 20, 30, 40]
unique
Returns a Set that contains the unique elements of the list.
unique(list: [T] -> {T})
list
: The list to process.
Returns:
A set containing only unique elements from the list.
Example:
>> [10, 20, 10, 10, 30].unique()
= {10, 20, 30}