Popup YouTube Video
Sheerpower Logo

Cluster Arrays


Working with Cluster Arrays

A cluster array in Sheerpower is a powerful data structure that functions like an in-memory spreadsheet, containing rows and columns of data. They are ideal for handling collections of related information efficiently. Like vectors in other languages, cluster arrays do not require preallocation—they grow automatically as needed. Sheerpower provides a rich set of built-in features for working with this data, allowing operations such as sorting, filtering, and searching, much like you would in a database.

Graphic 1: A Cluster Array as a Spreadsheet

This `inventory` cluster holds multiple records (rows), each with the same set of fields (columns).

item_id$ product_name$ quantity price
WID-001 Standard Widget 150 19.99
GAD-002 Super Gadget 75 29.95
DOO-003 Doodad Pro 200 9.50

With enough RAM and pagefile space, cluster arrays can support up to one billion rows with key lookup speeds of millions per second.

1. Defining and Populating a Cluster Array

First, you define the structure of your cluster. Then, you can add records (rows) to it using the add cluster statement.

! Define the structure for our inventory cluster inventory: item_id$, product_name$, quantity, price ! Add the first record to the cluster array add cluster inventory inventory->item_id$ = "WID-001" inventory->product_name$ = "Standard Widget" inventory->quantity = 150 inventory->price = 19.99 end add ! Add a second record add cluster inventory inventory->item_id$ = "GAD-002" inventory->product_name$ = "Super Gadget" inventory->quantity = 75 inventory->price = 29.95 end add

2. Understanding the "Current Row"

Sheerpower keeps track of a current row within the cluster array. When you add a new record, that new record automatically becomes the current row. To work with a different record, you must first make it current using the set cluster statement.

Graphic 2: Moving the Current Row Pointer

 item_id$product_name$
WID-001Standard Widget
GAD-002Super Gadget
DOO-003Doodad Pro

After set cluster inventory: row 2, the second row is now the current one.

! After adding the Super Gadget, the second row is current print "Current product: "; inventory->product_name$ ! Let's make the first row current and print its name set cluster inventory: row 1 print "First product: "; inventory->product_name$

3. Clusters Accessed as Arrays and as Dictionaries

A cluster field followed by a subscript in parentheses reads or writes one row without moving the current row. A number picks a row, like an array; a key — a string, or field: value — looks a row up, like a dictionary. Reach for it when you want one value from one row; to visit many rows, collect and for each remain the tools, and findrow when you want the found row to become current.

As an array: any row by number

Sometimes you just want to look at another row — the previous one, the last one, row 7 — without disturbing the current row. Put the row number in parentheses after the field name: inventory->product_name$(2) reads row 2's product name, and the current row stays exactly where it was. The row number is the physical row number, the same one set cluster and size() use; (*) means the last row.

set cluster inventory: row 1 // Read row 2's name without leaving row 1 print "Row 2 is: "; inventory->product_name$(2) print "Still on row 1: "; inventory->product_name$ print "Last row: "; inventory->product_name$(*) print "Row read: "; _integer // Compare neighbors: is each price higher than the previous row's? for row_number = 2 to size(inventory) if inventory->price(row_number) > inventory->price(row_number - 1) then print inventory->product_name$(row_number); & " costs more than "; inventory->product_name$(row_number - 1) end if next row_number

The output:

Row 2 is: Super Gadget Still on row 1: Standard Widget Last row: Super Gadget Row read: 2 Super Gadget costs more than Standard Widget

As a dictionary: any row by key

A string subscript looks the row up by the cluster's lookup field. By default that is the first string field declared — here item_id$ — and the match is case-insensitive. If no key matches, a 0 or "" is returned. In addition _integer is set to zero so you can test for no matches if you need to.

set cluster inventory: row 1 print "GAD-002 quantity: "; inventory->quantity("GAD-002") print "Price of WID-001: "; inventory->price("wid-001") print "Unknown: "; inventory->quantity("XYZ-999"); " row: "; _integer print "Still on row 1: "; inventory->product_name$
GAD-002 quantity: 75 Price of WID-001: 19.99 Unknown: 0 row: 0 Still on row 1: Standard Widget

To look up by a different field, name it in front of the key, as field: key. Any field will do — a string field, or a numeric one (with the field named there is nothing to confuse with a row number, so quantity: 75 is a lookup, not row 75). Write the field name exactly as it was declared, including its $ or ? ending if it has one.

print "By name: "; inventory->price(product_name$: "Super Gadget") print "By quantity: "; inventory->product_name$(quantity: 75)
By name: 29.95 By quantity: Super Gadget

A string lookup is case-insensitive. To require an exact-case match on a field, say so once with set on that field; it applies whenever that field is looked up, and nocase turns it back.

set inventory->item_id$: exact // ids must match case from now on print "Exact: "; inventory->quantity("gad-002"); " vs "; inventory->quantity("GAD-002") set inventory->item_id$: nocase // case-blind again
Exact: 0 vs 75

Writing by row or by key

The same subscripts work on the left of =: a number writes that row, a key (with or without a named field) writes the row that matches. The current row is untouched, and _integer is the row written.

inventory->quantity("GAD-002") = 80 inventory->price(1) = 18.50 inventory->price(product_name$: "Super Gadget") = 27.50 print "After writes: "; inventory->quantity(2); " "; inventory->price(1); " "; inventory->price(2)
After writes: 80 18.5 27.5

Writing by key is strict on purpose. A key that matches no row raises a catchable lookupmiss exception — a misspelled key is far more likely than an intent to add a record, and silently adding one would surface weeks later as a phantom row in a report. When you do want “add it if it isn't there”, announce it with create: the row is added with the lookup field set to the key, and the field written. That makes a word count a one-liner:

cluster word_counts: word$, times_seen set word_counts->word$: create // create missing word rows sentence$ = "the cat and the dog and the bird" for word_number = 1 // open-ended: stop when the words run out word$ = getword$(sentence$, word_number) if word$ = "" then exit for word_counts->times_seen(word$) = word_counts->times_seen(word$) + 1 // its row is created next word_number print "distinct words: "; size(word_counts); & " the = "; word_counts->times_seen("the"); & " and = "; word_counts->times_seen("and")
distinct words: 5 the = 3 and = 2

Likewise, a key that matches several rows raises lookupdup — writing “the first one” would leave the others stale. If the first match is genuinely what you want (a “current” row per key), allow it with set inventory->item_id$: duplicates. Reads are always the first match.

The rules in one place:
  • Row numbers are physical — the numbering of set cluster: row n and size(), not the order of a collect. The row must be 1..the highest row, or (*) for the last; anything else raises suboutbnd. Writing by row never adds a row.
  • The current row never moves, so every form is safe inside a for each over the same cluster.
  • After any of them, _integer is the row read or written — 0 for a key that matched nothing or a row removed with reset cluster (which reads as 0 / ""). Read _integer in the next statement, as with val().
  • A bare key looks up the first string field; field: key names any field, string or numeric, and the key must be of that field's type. set cluster->field: exact | nocase, duplicates, create (any order) applies to lookups by that field; exact/nocase are for string fields.
  • Under the hood a key lookup is a hashed findvalue on the lookup field — O(1) per lookup, index built on first use and maintained as rows change. Reads never raise; only writes do.

4. Updating and Deleting Records

Modifying a record is simple: first make the record current, then assign a new value to its field.

! Let's update the quantity of the first item set cluster inventory: row 1 inventory->quantity = 145 ! Update the quantity print "Updated quantity: "; inventory->quantity

Deleting records is also straightforward. For a detailed guide on removing rows, see Reset Cluster.

5. Iterating Over Records: The `collect` and `for each` Pattern

To process every record in a cluster array, you use a two-step pattern. This design is powerful because it separates the act of gathering/filtering data from the act of processing it.

  1. collect ... end collect: This block builds a temporary collection (or view) of your data. This is where you do all your filtering (include/exclude) and sorting.
  2. for each ... next: This block then loops through the temporary collection you just created.
! First, create a collection of items with low stock (< 100), sorted by name collect cluster inventory include inventory->quantity < 100 sort by inventory->product_name$ end collect ! Now, loop through that filtered collection and print each item for each inventory print inventory->product_name$; " (Quantity: "; inventory->quantity; ")" next inventory
Limiting results after sorting:

There is no limit keyword inside collect cluster. This is by design — a limit applied before sorting would select arbitrary rows, not the top or bottom N values.

The correct pattern is to sort in the collect block, then use a counter with exit for in the for each loop:

collect cluster sales sort descending by sales->amount end collect row_count = 0 for each sales row_count++ if row_count > 10 then exit for print sales->name$; ' '; sales->amount next sales

This guarantees the sort completes across the full dataset before the top N rows are selected.

Sorting by an Expression

The sort key does not have to be a field — it can be any expression, evaluated once per row. That matters when the interesting order lives in a computed value no single field holds. Here an inventory is ranked by the total value tied up in each item — price times quantity on hand:

cluster inventory: item$, price, quantity ! ... rows added ... collect cluster inventory sort descending by inventory->price * inventory->quantity end collect for each inventory print inventory->item$; ' '; & sprintf$('%.2m', inventory->price * inventory->quantity) next inventory

The output:

Gizmo 3,000.00 Widget 1,000.00 Gadget 450.00 Sprocket 360.00

Notice that Gizmo leads even though it is neither the most expensive item (that is Gadget) nor the largest quantity (that is Widget) — only the computed value reveals where the money actually sits. The expression decides the order and nothing more: no field in the cluster changes, and nothing is stored.

Case-insensitive sorting:

By default, string sorting is case-sensitive: all uppercase letters sort ahead of all lowercase letters, so 'Zebra' comes before 'apple'. Use sort nocase by to ignore case:

collect cluster inventory sort nocase by inventory->product_name$ end collect

The keyword order is fixed: sort [descending] [nocase] by exprnocase goes right before by. Keys that are equal when case is ignored keep their original row order. Case sensitivity is chosen per sort key, so a case-blind major sort can be paired with a case-sensitive minor sort in the same collect.

Conditional Sorting - a Different Sort Key per Row

The sort by statement runs once for each collected row, so it can live inside an if and supply a different expression for different rows. A classic use is a fallback key: sort clients by their home zipcode, but for clients with no home zipcode on file, use their billing zipcode instead:

cluster clients: nm$, home_zip$, bill_zip$ add cluster clients clients->nm$ = 'Baker' clients->home_zip$ = '98101' clients->bill_zip$ = '10001' end add add cluster clients clients->nm$ = 'Adams' clients->home_zip$ = '' clients->bill_zip$ = '30301' end add add cluster clients clients->nm$ = 'Chen' clients->home_zip$ = '02134' clients->bill_zip$ = '90210' end add add cluster clients clients->nm$ = 'Diaz' clients->home_zip$ = '' clients->bill_zip$ = '60601' end add collect cluster clients if clients->home_zip$ = '' then sort by clients->bill_zip$ ! no home zipcode -- fall back else sort by clients->home_zip$ end if end collect for each clients print clients->nm$; ' home='; clients->home_zip$; ' bill='; clients->bill_zip$ next clients

The output:

Chen home=02134 bill=90210 Adams home= bill=30301 Diaz home= bill=60601 Baker home=98101 bill=10001

Every row contributed exactly one key value — whichever zipcode its branch chose — and the rows came out in a single unified zipcode order: 02134, 30301, 60601, 98101.

The one rule: every branch must agree on each sort key's data type, direction, and case sensitivity, and supply the same number of sort keys. Sorting by a string in one branch and a number in another raises a catchable INCONSORT exception (−4112); changing the number of sort keys between rows raises BADSORT (−4111).

Note on sorting: Sheerpower integrates sorting into normal procedural control flow, allowing the sort key for each record to be selected by ordinary executable statements. Sort keys can also be expressions, providing additional flexibility when determining sort order. Sorting is stable: rows with equal keys always keep their relative order.

Statement order inside collect

Statement order inside collect is program order: the statements run top to bottom, once for each row. When an include fails (or an exclude matches), execution of the block stops for that row — statements below it, including sort by, never run for the rejected row, and it contributes no sort key — and therefore never counts toward groupmin or groupmax. So placing include/exclude before sort by skips the key-building work for rows that will be discarded. Filters may appear anywhere in the block — before, between, or after the sort keys — and the result is the same; filtering first simply does less work.

groupmin - Finding Duplicates

The groupmin n attribute is applied after the sort completes: it keeps only groups — rows that are equal on the complete set of sort keys — containing at least n rows. Its most common use is the classic data-cleanup question: which values appear more than once?

Here a contact list is checked for duplicate email addresses. Sorting by email brings equal addresses together; groupmin 2 then keeps only the addresses that occur at least twice — every row of every duplicated address, with the singletons gone:

cluster contacts: cname$, email$ ! ... rows added: seven contacts, where [email protected] was ! entered three times and [email protected] twice ... collect cluster contacts: groupmin 2 sort by contacts->email$ end collect print 'duplicated email rows: '; _extracted for each contacts print contacts->email$; ' '; contacts->cname$ next contacts

The output:

duplicated email rows: 5 [email protected] Baker [email protected] B. Baker [email protected] Robert Baker [email protected] Diaz [email protected] D. Diaz

Each duplicated address arrives with all of its rows, grouped together in sorted order and keeping the order they were added in — exactly what is needed to review or merge the duplicates. The cluster itself is untouched: size() still reports all seven rows.

groupmax n asks the opposite question of the same sort: keep only the groups with at most n rows. groupmax 1 on the same contact list returns the addresses that occur exactly once — the complement of the duplicate report:

collect cluster contacts: groupmax 1 sort by contacts->email$ end collect print 'one-of-a-kind email rows: '; _extracted for each contacts print contacts->email$; ' '; contacts->cname$ next contacts

The output:

one-of-a-kind email rows: 2 [email protected] Adams [email protected] Chen

The two compose: groupmin 2 groupmax 2 keeps exactly the pairs (here, only the two Diaz rows). A natural real-world use of groupmax 1 is reconciliation: load two sources into one cluster with a field marking which source each row came from, sort by the matching key, and groupmax 1 hands you every key that appears in only one source — the orphans — while groupmin 2 on the same sort gives the matched keys. One sort, both reports.

The unifying idea: a sort key defines groups, and groupmin / groupmax let you select groups by how many rows they have without counting anything yourself. groupmin finds the crowded groups, groupmax the sparse ones, and together they find the exactly-right-sized ones — all in the single pass the sort was already doing.

Points worth knowing:

  • groupmin is a collection attribute, written after the colon like cachesize; the two can be combined (collect cluster c: cachesize 4000 groupmin 2).
  • A group is defined by the complete set of sort keys, so sorting by two keys finds rows duplicated on the pair. Under sort nocase by, values differing only in case count as one group.
  • When groupmin or groupmax is used, the sort runs immediately at end collect (instead of being deferred), so _extracted is the filtered row count right away.
  • groupmin 1 keeps everything; a value below 1 raises a catchable GROUPBAD exception (−4114), and groupmin without any sort by is a compile error (GROUPNOSORT, −4113) — groups are defined by the sort keys.
  • groupmax n is the mirror image: it keeps only groups with at most n rows. groupmax 1 answers the opposite question — which values appear exactly once? — and the two compose: collect cluster c: groupmin 2 groupmax 2 keeps exactly the pairs. The same rules apply (sort required, values below 1 raise GROUPBAD), plus one more: a groupmax below the groupmin could never keep a group, so it also raises GROUPBAD.

groupmin is the natural partner of unique (next section): unique answers “what are the distinct values?” with one row per value, while groupmin 2 answers “which values repeat?” with every row of every repeated value.

Fuzzy Selection - Scoring Rows

Because include and sort by are ordinary executable statements, they can work with values your own code computes as each row goes by. That turns a collect block into a scoring model: award points for each desirable trait, keep the rows that score high enough, and rank the survivors best-first. Here house listings are scored on bedrooms and lot position:

cluster listings: addr$, bedrooms, cornerlot$, garage, price, sqft ! ... rows added ... budget = 350_000 collect cluster listings score = 0 if listings->bedrooms >= 2 then score = score + 50 if listings->cornerlot$ = 'y' then score = score + 20 if listings->garage >= 2 then score = score + 15 if listings->price <= budget then score = score + 40 if listings->sqft >= 1_800 then score = score + 10 include score >= 50 sort descending by score end collect print 'matches: '; _extracted for each listings print listings->addr$; ' '; listings->bedrooms; ' br $'; listings->price next listings

The output:

matches: 4 12 Elm St 3 br $ 329000 31 Cedar Ct 2 br $ 405000 48 Oak Ave 1 br $ 289000 7 Pine Rd 2 br $ 415000

Each row is scored fresh (score = 0 runs once per row), include keeps only rows reaching the threshold, and sort descending by score presents the best matches first. 12 Elm St swept every trait (135 points). But look at 48 Oak Ave: a one-bedroom that qualified at 65 points on its garage, price, and size — exactly the near-miss a rigid “must have 2+ bedrooms AND ...” filter would have discarded, and it outranks the two-bedroom at 7 Pine Rd. That is what makes the selection fuzzy: no single condition is required, the traits just have to add up.

Notice too that the model reads budget — an ordinary variable set before the block — so the same scoring code serves any buyer. The weights are ordinary numbers your code controls: refining the model is editing a few if lines, not restructuring a query.

And because every stats$*() function accepts an optional trailing true meaning “over the collection”, the matches can be analyzed as a group without another pass:

print 'average price of the matches: '; & sprintf$('%.0m', stats$mean(listings->price, true))
average price of the matches: 359,500

See Cluster Statistics for the full rules of the collection flag.

unique - One Row per Distinct Value

Use unique with collect cluster to create a collected view containing one row for each distinct value of a field.

collect cluster clustername: unique clustername->fieldname ... ... ... end collect

For each distinct field value, the first matching row in cluster file order becomes the representative row for that group.

The underlying cluster is not changed. The size() function still returns the total number of rows in the cluster, including duplicates. To obtain the number of distinct values, use _extracted after the end collect statement.

How unique Finds Distinct Values

Distinct values and their occurrence counts come directly from the field's hash index. As rows are added to the cluster, the index maintains a count for each indexed value.

Because this information already exists, unique does not need to scan the cluster or count duplicate rows at collect time. Its work does not increase merely because a value occurs many times.

Inside the collect cluster block, _extracted contains the occurrence count for the current group. It tells how many cluster rows have the current distinct field value.

After end collect, _extracted contains the number of distinct values retained in the collected view.

Creating a Word-Frequency View

The following example creates one collected row for each distinct word and stores the word's frequency in the representative row:

collect cluster words: unique words->word$ words->count = _extracted sort descending by words->count end collect distinct_words = _extracted

Inside the block, _extracted is the number of times the current word occurs. After the block, it is the number of distinct words in the collected view.

That single block performs the equivalent of a GROUP BY operation together with a COUNT for every group.

Filtering Groups

Unique grouping takes place before include and exclude expressions are evaluated. With unique, the block runs once per distinct value, with that group's representative row current; statement order within each pass works as described above.

Each expression is evaluated once for each distinct value, using its representative row. The group's complete occurrence count is available as _extracted. This makes group-level filtering a one-line operation.

For example, the following collection retains one representative row for every city name that occurs more than once:

collect cluster population: unique population->city$ include _extracted > 1 end collect print 'city names that are repeated: '; _extracted

After end collect, the collected view contains one row for each repeated city name. At that position, _extracted contains the number of repeated city names retained in the view.

6. Built-in Functions for Instant Data Aggregation and Statistics

Sheerpower provides built-in functions that can instantly calculate aggregate values across an entire cluster array field, eliminating the need for manual loops.

print "Total items in stock: "; stats$sum(inventory->quantity) print "Most expensive item: $"; stats$max(inventory->price) print "Cheapest item: $"; stats$min(inventory->price) print "Average price: $"; stats$mean(inventory->price) print "Number of different products: "; size(inventory)

In addition, over 45 statistical functions are provided. See Cluster Statistics.

7. High-Speed Searching with `findrow()`

The findrow() function is highly optimized to perform millions of lookups per second. It returns the row number of the first record that matches your search criteria and makes the row current, or 0 if no match is found. The findrow() function works with any Sheerpower data type.

! Let's find the "Super Gadget" item_to_find$ = "Super Gadget" row_num = findrow(inventory->product_name$, item_to_find$) if row_num > 0 then print "Found "; item_to_find$; " at row "; row_num print "Its price is $"; inventory->price else print item_to_find$; " not found." end if
Note: When you use findrow(), the field you search on is automatically optimized as a key field for future lookups, making subsequent searches even faster. This happens dynamically without any need for manual index configuration. A successful findrow() automatically makes the row current.

For a detailed guide on findrow(), see High-speed Lookups with Clusters: FINDROW(), FINDVALUE(), and FINDVALUE$().

Summary: Cluster arrays provide efficient data handling in Sheerpower. They combine the simplicity of a spreadsheet with the power of high-speed, database-like operations. By mastering their use, you can build applications that are both powerful and easy to maintain.
(Show/Hide Sheerpower Cluster Arrays Takeaways)
Hide Description

    

       


      

Enter or modify the code below, and then click on RUN

Looking for the full power of Sheerpower?
Check out the Sheerpower website. Free to download. Free to use.