Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Lists

A list is an ordered, growable sequence. It holds values of any type, including other lists, and it grows and shrinks as you work with it.

var items = [3, 1, 4, 1, 5]
var mixed = [1, 'two', [3], { four: 4 }]
var empty = []

echo items.length()
echo mixed[3].four
echo empty.is_empty()
5
4
true

Lists carry 39 methods, and they fall into six groups: reading, searching, adding and removing, ordering, transforming, and walking. This section takes them in that order. The one distinction to keep in mind throughout is whether a method mutates the list you called it on or returns a new one, because Zuri’s lists do both and the method names do not tell you which.

Reading

var l = [3, 1, 4, 1, 5]

echo l.length()
echo l.is_empty()
echo l[0]
echo l[-1]
echo l.first()
echo l.last()
echo l[1, 3]
5
false
3
5
3
5
[1, 4]

Indexing

An index counts from zero, and a negative index counts back from the end:

var l = ['a', 'b', 'c', 'd']

echo l[0]
echo l[3]
echo l[-1]
echo l[-4]
a
d
d
a

l[-1] is the last element, which saves writing l[l.length() - 1] everywhere.

Indexing out of range raises rather than returning nil:

var l = [1, 2, 3]

catch {
  echo l[99]
} as e {
  echo e.message
}
index 99 out of bounds (length 3)

get() is the form that can fall back, but only when you give it something to fall back to. get(i) with one argument raises exactly like l[i] does:

var l = [1, 2, 3]

echo l.get(1)
echo l.get(99, 'missing')

catch {
  echo l.get(99)
} as e {
  echo e.message
}
2
missing
list index 99 out of range at get()

Slicing

l[a, b] takes the elements from a up to but not including b, and gives you a new list:

var l = [1, 2, 3, 4, 5]

echo l[1, 3]
echo l[, 3]
echo l[3, ]
echo l[-2, ]
[2, 3]
[1, 2, 3]
[4, 5]
[4, 5]

Either bound may be left out: l[, b] starts at the beginning and l[a, ] runs to the end. Negative bounds count from the end, just as an index does.

A slice checks its bounds, exactly as an index does. Running past the end raises rather than returning what it can:

catch {
  echo [1, 2, 3][1, 99]
} as e {
  echo e.message
}
slice bounds 1..99 out of range (length 3)

The one bound that is always legal is length() itself, since a slice’s upper bound is exclusive. l[0, l.length()] is the whole list, and l[3, 3] on a three-element list is empty rather than an error.

var l = [1, 2, 3]

echo l[0, l.length()]
echo l[3, 3]
[1, 2, 3]
[]

This same rule governs string slicing, which is why Strings uses s[split_at + 1, s.length()] rather than a large number to mean “the rest”.

A slice is a copy. Changing one does not touch the other:

var original = [1, 2, 3]
var part = original[0, 2]

part[0] = 99

echo original
echo part
[1, 2, 3]
[99, 2]

Nested Lists

A list holds lists, and indexes chain:

var grid = [[1, 2], [3, 4], [5, 6]]

echo grid[1]
echo grid[1][0]
echo grid.length()
echo grid[1].length()
[3, 4]
3
3
2

grid.length() counts rows, not elements. There is no two-dimensional index; grid[1, 0] is a slice from row one to row zero, which is empty.

Combining Lists

Concatenation with +

+ joins two lists into a new one, leaving both operands alone:

var a = [1, 2]
var b = [3]

echo a + b
echo a
echo b
[1, 2, 3]
[1, 2]
[3]

That is the difference between + and extend(): a + b builds a third list, while a.extend(b) modifies a in place and returns it. Use + when you want to keep the originals, and extend() when you are accumulating.

Repetition with *

* repeats a list a whole number of times:

echo [1, 2] * 3
echo [0] * 5
[1, 2, 1, 2, 1, 2]
[0, 0, 0, 0, 0]

[0] * 5 is the idiom for a fixed-size list of a starting value, and it is worth knowing before you write the loop.

The count behaves exactly as it does for strings: zero and any negative number both produce an empty list, and a fractional count is truncated.

echo [1] * 0
echo [1] * -3
[]
[]

There is one trap. Repetition copies the elements, and when an element is itself a list, all the copies are the same list:

var grid = [[0, 0]] * 3

grid[0][0] = 9

echo grid
[[9, 0], [9, 0], [9, 0]]

One assignment changed every row, because there is only one row. Build nested structure with a loop, or with map():

var grid = 0..3.to_list().map(@(i) => [0, 0])

grid[0][0] = 9

echo grid
[[9, 0], [0, 0], [0, 0]]

Comparing Lists

== compares lists by value, element by element, however deeply they nest:

echo [1, 2] == [1, 2]
echo [1, [2, 3]] == [1, [2, 3]]
echo [1, 2] == [2, 1]
echo [1, 2] == [1, 2, 3]
true
true
false
false

Order matters and length matters. To compare two lists as sets, sort copies of them first, or use the set module from Chapter 13.

Searching

var l = [3, 1, 4, 1, 5]

echo l.contains(4)
echo l.index_of(1)
echo l.count(1)
true
1
2

index_of() gives -1 when the value is absent. last_index_of() searches from the other end:

var l = [3, 1, 4, 1, 5]

echo l.index_of(1)
echo l.last_index_of(1)
echo l.last_index_of(1, 2)
echo l.last_index_of(9)
1
3
1
-1

Both take a second argument bounding where a match may sit, so the pair splits the list at one index: index_of(x, n) finds the first match at or after n, last_index_of(x, n) the last one at or before it. Both compare by value, so a list of dictionaries can be searched with a dictionary literal.

Adding and Removing

These mutate the list in place:

MethodEffect
append(v)add to the end
insert(v, at)insert at a position
extend(other)append every element of another list
pop()remove and return the last element
shift()remove and return the first element
remove(v)remove the first element equal to v
remove_at(i)remove by position
delete(from, to)remove an inclusive range, return how many went
clear()empty it
var l = [3, 1, 4]

l.insert(9, 1)
echo l
echo l.shift()
echo l
[3, 9, 1, 4]
3
[9, 1, 4]
var l = [1, 2, 3, 4, 5]
echo l.delete(1, 3)
echo l
3
[1, 5]

Note that delete() takes a from and a to, both inclusive, and returns the number of elements removed rather than the list.

Ordering

var l = [3, 1, 2]

echo l.sort()
echo l
echo l.reverse()
echo l
[1, 2, 3]
[1, 2, 3]
[3, 2, 1]
[1, 2, 3]

Look closely at those two. sort() mutates and returns the list. reverse() returns a new list and leaves the original alone. That asymmetry is the single most common source of list bugs in Zuri code.

sort() takes no comparator. To sort by a computed key, decorate, sort, and undecorate:

var people = [{ name: 'Ada', age: 36 }, { name: 'Bob', age: 24 }]

var by_age = people
  .map(@(p) => [p.age, p.name])
  .sort()
  .map(@(pair) => pair[1])

echo by_age
[Bob, Ada]

Transforming

Every one of these returns a new list and leaves the receiver untouched:

var l = [1, 2, 3, 4]

echo l.map(@(x) => x * 2)
echo l.filter(@(x) => x > 2)
echo l.unique()
echo l.compact()
echo l.take(2)
echo l.clone()
[2, 4, 6, 8]
[3, 4]
[1, 2, 3, 4]
[1, 2, 3, 4]
[1, 2]
[1, 2, 3, 4]

compact() drops nil entries:

echo [1, nil, 2, nil].compact()
[1, 2]

reduce() folds the list down to one value. With no initial value it starts from the first element:

echo [1, 2, 3].reduce(@(acc, x) => acc + x)
echo [1, 2, 3].reduce(@(acc, x) => acc + x, 100)
6
106

partition() splits in one pass, matches first:

echo [1, 2, 3, 4].partition(@(x) => x % 2 == 0)
[[2, 4], [1, 3]]

Finding

var l = [1, 2, 3]

echo l.find(@(x) => x > 1)
echo l.find_index(@(x) => x > 1)
echo l.find_last(@(x) => x > 1)
echo l.find_last_index(@(x) => x > 1)
echo l.find_all(@(x) => x > 1)
2
1
3
2
[2, 3]

Asking About Every Element

echo [1, 2, 3].every(@(x) => x > 0)
echo [1, 2, 3].some(@(x) => x > 2)
true
true

some() stops at the first match; every() stops at the first failure.

Walking

There are four ways to visit every element, and they are not interchangeable. Pick by what you need in the body.

for, When You Want the Values

for value in ['a', 'b', 'c'] {
  echo value
}
a
b
c

This is the default. Reach for it whenever the position does not matter.

for With Two Variables, When You Want the Index Too

for index, value in ['a', 'b', 'c'] {
  echo '${index}: ${value}'
}
0: a
1: b
2: c

With two variables you get the key first and the value second, and for a list the key is the index. No call to length(), no manual counter.

iter, When You Need Control of the Position

var items = ['a', 'b', 'c']

iter var i = 0; i < items.length(); i++ {
  echo '${i}: ${items[i]}'
}
0: a
1: b
2: c

iter costs more typing and earns it only when the traversal is not one step forward per pass: walking backwards, walking in twos, comparing an element with its neighbour, or advancing the index from inside the body.

var items = ['a', 'b', 'c', 'd']

iter var i = items.length() - 1; i >= 0; i-- {
  echo items[i]
}

iter var i = 0; i < items.length(); i += 2 {
  echo items[i]
}
d
c
b
a
a
c

each(), When You Have a Function Already

[10, 20].each(@(value, index) {
  echo '${index}: ${value}'
})
0: 10
1: 20

each() takes a function, which makes it the one that composes: it chains onto a map() or a filter() without a temporary variable, and it accepts a function you already have by name.

Watch the argument order. each() hands the callback the value first and the index second, which is the opposite of for. Every list callback in this chapter follows the same rule — map, filter, find, some, every all take (value, index) — so it is one thing to remember rather than several. for is a loop over key-value pairs; each is a callback over values that happens to tell you where it is.

break and continue work inside for and iter. They do not exist inside an each() callback; return there ends that one call, not the walk. When you need to stop early, use a loop, or find() / some(), which stop on their own.

Combining

echo ['a', 'b'].zip([1, 2])
echo [1, 2, 3].zip_from([[4, 5, 6]])
[[a, 1], [b, 2]]
[[1, 4], [2, 5], [3, 6]]

zip() pairs with one other list. zip_from() takes a list of lists and zips across all of them at once.

Lists Are References

Assigning a list does not copy it:

var a = [1, 2]
var b = a
b.append(3)
echo a
[1, 2, 3]

Use clone() when you need an independent copy. The clone is shallow: nested lists inside it are still shared.

var original = [[1, 2], 3]
var copy = original.clone()

copy[1] = 99
copy[0].append(4)

echo original
echo copy
[[1, 2, 4], 3]
[[1, 2, 4], 99]

Replacing the top-level 3 affected only the copy. Appending to the nested list affected both, because both lists point at the same inner list. When you need a deep copy, copy the levels you care about yourself.

Mutating or Not: The Summary

This is the table to come back to.

Mutates the receiverReturns a new list
append, insert, extendmap, filter, find_all
pop, shift, remove, remove_atunique, compact, take
delete, clearreverse, clone, zip, zip_from
sortpartition

sort() is the one that catches people out: it is in the left column, and it also returns the list, so var sorted = items.sort() leaves items sorted too. If you need both orders, clone first:

var items = [3, 1, 2]
var sorted = items.clone().sort()

echo items
echo sorted
[3, 1, 2]
[1, 2, 3]

A Worked Example

A tiny report generator: take a list of records, drop the incomplete ones, group what is left, and print a summary. It uses filter, map, reduce, sort and each together, which is how they usually show up.

var sales = [
  { region: 'north', amount: 120 },
  { region: 'south', amount: 80 },
  { region: 'north', amount: 45 },
  { region: 'east', amount: nil },
  { region: 'south', amount: 200 },
]

def totals_by_region(records) {
  var totals = {}

  records
    .filter(@(r) => r.amount != nil)
    .each(@(r) {
      totals[r.region] = totals.get(r.region, 0) + r.amount
    })

  return totals
}

var totals = totals_by_region(sales)
var grand = totals.values().reduce(@(acc, n) => acc + n, 0)

totals
  .to_list()[0]
  .sort()
  .each(@(region) {
    echo '${region.rpad(6)} ${totals[region]}'
  })

echo 'total  ${grand}'
north  165
south  280
total  445

east is absent from the report, not present with a zero, because its one record was filtered out before any accumulating happened and nothing ever created the key. That is usually what you want from a report; when it is not, seed the dictionary with every region first.

Two other details are worth pulling out. totals.get(r.region, 0) supplies a starting value for a key that does not exist yet, which is what turns a dictionary into an accumulator. And totals.to_list()[0] takes the keys — to_list() returns keys and values as two parallel lists — which are then sorted so the report comes out in a stable order rather than in whatever order the records happened to arrive.