Collections

Collections are how you group, search, and transform data in Maggie. The three core collection types are Array (an ordered, fixed-size sequence), ArrayList (a growable ordered sequence), and Dictionary (a mutable mapping from keys to values). All support rich iteration protocols -- do:, collect:, select:, inject:into: -- that let you write expressive, functional-style data pipelines without explicit loops.

Arrays use 0-based indexing and are fixed-size once created. When you need to build a collection incrementally, use ArrayList -- its add: method is amortized O(1), whereas Array's copyWith: copies the entire array each time (O(n) per append, O(n^2) total for n appends). Call asArray when you need a fixed-size Array at the end. Dictionaries grow automatically as you add entries and provide O(1) key lookup.

This chapter walks through all three collections, starting with Array creation and access, then ArrayList for incremental building, and finally Dictionary.

Test
#(10 20 30) size >>> 3
#(10 20 30) first >>> 10
Dictionary new size >>> 0

Arrays can be created in two ways. The literal syntax #( ) builds an array directly from a list of values. The expression Array new: n allocates an array of n elements, all initialized to nil. You can then fill it in with at:put:.

The literal form is convenient when you know the contents at write time. Use Array new: when you need a specific size and will populate it programmatically.

Test
#(1 2 3) size >>> 3
#() size >>> 0
(Array new: 4) size >>> 4
(Array new: 3) at: 0 >>> nil

Access individual elements with at: using a 0-based index. Set elements with at:put:, which returns the stored value. The convenience methods first and last give you the endpoints without needing to know the size.

The size message returns the number of elements. isEmpty and notEmpty test whether the array contains anything.

Test
#(10 20 30) at: 0 >>> 10
#(10 20 30) at: 2 >>> 30
#(10 20 30) first >>> 10
#(10 20 30) last >>> 30
#(10 20 30) size >>> 3
#() isEmpty >>> true
#(1 2) notEmpty >>> true

The at:put: message stores a value at a given 0-based index and returns the value that was stored. This mutates the array in place. You typically use this to fill in arrays created with Array new:.

Test
a := Array new: 3.
a at: 0 put: 'x'.
a at: 1 put: 'y'.
a at: 2 put: 'z'.
a at: 0 >>> 'x'
a at: 2 >>> 'z'
a size >>> 3

The do: message evaluates a one-argument block once for each element in the array, in order. It is the basic iteration primitive -- most other iteration methods are built on top of it.

do: is used for side effects (printing, accumulating into a variable). When you want a transformed result, use collect: instead.

Test
sum := 0.
#(1 2 3 4) do: [:each | sum := sum + each].
sum >>> 10

The collect: message applies a block to every element and returns a new array of the results. The original array is unchanged. This is the Maggie equivalent of map in other languages.

Test
#(1 2 3) collect: [:x | x * 2] >>> #(2 4 6 )
#(1 2 3) collect: [:x | x * x] >>> #(1 4 9 )
#(1 4 9) collect: [:x | x + 1] >>> #(2 5 10 )

The select: message returns a new array containing only the elements for which the block returns true. The reject: message does the opposite -- it keeps elements where the block returns false.

Together, select: and reject: give you filtering. They always return a new array; the original is not modified.

Test
#(1 2 3 4 5) select: [:x | x > 3] >>> #(4 5 )
#(1 2 3 4) select: [:x | x even] >>> #(2 4 )
#(1 2 3 4 5) reject: [:x | x > 3] >>> #(1 2 3 )
#(1 2 3 4) reject: [:x | x even] >>> #(1 3 )

The detect: message scans the array left to right and returns the first element for which the block returns true. If no element matches, it returns nil. The two-argument form detect:ifNone: lets you supply a fallback block that is evaluated when nothing matches.

Test
#(1 2 3 4 5) detect: [:x | x > 3] >>> 4
#(1 2 3) detect: [:x | x > 10] >>> nil
#(1 2 3) detect: [:x | x > 10] ifNone: [0] >>> 0
#(1 2 3) detect: [:x | x > 1] ifNone: [0] >>> 2

The inject:into: message accumulates a result by threading a value through each element. It takes an initial value and a two-argument block that receives the accumulator and the current element. The block's return value becomes the new accumulator, and the final accumulator is returned. This is the Maggie equivalent of fold or reduce.

Test
#(1 2 3 4) inject: 0 into: [:sum :x | sum + x] >>> 10
#(1 2 3 4) inject: 1 into: [:prod :x | prod * x] >>> 24
#(1 2 3) inject: 0 into: [:max :x | x > max ifTrue: [x] ifFalse: [max]] >>> 3

The includes: message tests whether any element in the array is equal to the argument, returning true or false. The indexOf: message returns the 0-based index of the first match, or -1 if the element is not found.

Test
#(1 2 3) includes: 2 >>> true
#(1 2 3) includes: 5 >>> false
#(10 20 30) indexOf: 20 >>> 1
#(10 20 30) indexOf: 99 >>> -1

The comma (,) operator concatenates two arrays into a new one. The original arrays are unchanged. You can chain multiple concatenations to build larger arrays.

The copyWith: message returns a new array with a single element appended at the end. It is a convenience wrapper around concatenation.

Test
#(1 2), #(3 4) >>> #(1 2 3 4 )
#(), #(1 2) >>> #(1 2 )
#(1 2) copyWith: 3 >>> #(1 2 3 )
#() copyWith: 42 >>> #(42 )

The copyFrom:to: message extracts a slice of the array. The first argument is the start index (inclusive) and the second is the end index (exclusive), both 0-based. The result is a new array.

This is useful for splitting arrays, taking prefixes or suffixes, or working with windows of data.

Test
#(10 20 30 40 50) copyFrom: 1 to: 4 >>> #(20 30 40 )
#(10 20 30) copyFrom: 0 to: 2 >>> #(10 20 )
#(10 20 30 40 50) copyFrom: 0 to: 5 >>> #(10 20 30 40 50 )

You can chain collection operations together for expressive data pipelines. First filter with select: or reject:, then transform with collect:, and finally reduce with inject:into:. Each step produces a new array, so the original data stays intact.

Test
#(1 2 3 4 5 6 7 8 9 10) select: [:n | n even] >>> #(2 4 6 8 10 )
(#(1 2 3 4 5) select: [:n | n odd]) collect: [:n | n * n] >>> #(1 9 25 )
(#(1 2 3 4 5) collect: [:n | n * n]) inject: 0 into: [:s :x | s + x] >>> 55

When you need to build up a collection element-by-element, use ArrayList instead of Array. ArrayList is backed by a Go slice, so add: is amortized O(1). Array's copyWith: copies the entire array each time, making it O(n) per append -- a loop of n appends becomes O(n^2). ArrayList avoids this entirely.

Create an ArrayList, add elements with add:, then call asArray if you need a fixed-size Array at the end.

Test
list := ArrayList new.
list add: 10.
list add: 20.
list add: 30.
list size >>> 3
list first >>> 10
list last >>> 30
list asArray >>> #(10 20 30 )

ArrayList supports the same iteration protocol as Array: do:, collect:, select:, reject:, detect:, and inject:into:. The difference is that collect:, select:, and reject: return a new ArrayList instead of an Array, so you can keep chaining add: without penalty.

Test
list := ArrayList withAll: #(1 2 3 4 5 6 7 8 9 10).
(list select: [:n | n even]) asArray >>> #(2 4 6 8 10 )
(list collect: [:n | n * n]) asArray >>> #(1 4 9 16 25 36 49 64 81 100 )
list inject: 0 into: [:sum :n | sum + n] >>> 55

ArrayList also supports removeLast (O(1)), removeAt: (O(n) shift), clear, indexOf:, includes:, and copy. Use withAll: to create an ArrayList from an existing Array.

Test
list := ArrayList withAll: #(10 20 30 40).
list removeLast >>> 40
list size >>> 3
list removeAt: 0 >>> 10
list asArray >>> #(20 30 )

Dictionaries are mutable key-value maps. The easiest way to create one is with the literal syntax #{key -> value}, where pairs are separated by periods:

Test
#{#name -> 'Alice'. #age -> 30} size >>> 2
(#{#x -> 10} at: #x) >>> 10

Keys can be symbols, strings, numbers, or any object that supports equality. Symbols are the most common key type.

You can also build dictionaries incrementally with Dictionary new and at:put::

Test
d := Dictionary new.
d size >>> 0
d at: #name put: 'Alice'.
d at: #age put: 30.
d size >>> 2
d at: #name >>> 'Alice'
d at: #age >>> 30
d at: #missing >>> nil

The at: message retrieves a value by key, returning nil if the key is not present. The size message returns the number of entries.

The includesKey: message checks whether a key exists in the dictionary, returning true or false. The at:ifAbsent: message retrieves a value by key, but if the key is missing it evaluates the supplied block and returns that result instead. This is safer than at: when you need a default value.

Test
d := #{#x -> 42}.
d includesKey: #x >>> true
d includesKey: #y >>> false
d at: #x ifAbsent: [0] >>> 42
d at: #y ifAbsent: [0] >>> 0

The keys message returns an array of all keys in the dictionary. The values message returns an array of all values. Note that dictionary iteration order is not guaranteed -- it depends on the internal hashing -- so the order of keys and values may vary.

The isEmpty and notEmpty messages test whether the dictionary has any entries.

Test
d := Dictionary new.
d isEmpty >>> true
d at: #a put: 1.
d isEmpty >>> false
d notEmpty >>> true
d keys size >>> 1
d values size >>> 1

The do: message on a dictionary iterates over the values (not the keys). If you need both key and value, use keysAndValuesDo: which passes each key-value pair to a two-argument block.

Since dictionary iteration order is non-deterministic, avoid writing code that depends on a specific ordering. If you need ordered output, collect the keys and sort them first.

Test
d := Dictionary new.
d at: #x put: 10.
d at: #y put: 20.
total := 0.
d do: [:val | total := total + val].
total >>> 30

The keysAndValuesDo: message passes each key and its corresponding value to a two-argument block. This is the most common way to walk over every entry in a dictionary when you need access to both halves.

The at:ifAbsentPut: message is a convenience for caching patterns: it returns the existing value if the key is present, otherwise evaluates the block, stores the result, and returns it.

Test
d := Dictionary new.
d at: #a put: 1.
count := 0.
d keysAndValuesDo: [:k :v | count := count + 1].
count >>> 1
d at: #b ifAbsentPut: [99] >>> 99
d at: #b >>> 99
d at: #b ifAbsentPut: [0] >>> 99

The removeKey: message deletes an entry from the dictionary and returns the value that was stored. If the key is not present, it returns nil. The removeKey:ifAbsent: variant evaluates a block when the key is missing.

The copy message creates a shallow duplicate of the dictionary. Changes to the copy do not affect the original.

Test
d := Dictionary new.
d at: #x put: 10.
d removeKey: #x >>> 10
d size >>> 0
d removeKey: #y >>> nil
c := Dictionary new.
c at: #a put: 1.
c2 := c copy.
c2 at: #a >>> 1
c2 at: #b put: 2.
c size >>> 1

Arrays and dictionaries work together naturally. You can use an array of keys to build a dictionary, or extract dictionary values into an array for further processing with collect: or select:.

Here is a pattern that counts how often each element appears in an array, accumulating results into a dictionary.

Test
counts := Dictionary new.
#(1 2 1 3 2 1) do: [:each | prev := counts at: each ifAbsent: [0]. counts at: each put: prev + 1].
counts at: 1 >>> 3
counts at: 2 >>> 2
counts at: 3 >>> 1