Advanced

More relation generators

Relation generators can take the place of functions, to split programs into smaller, re-usable components.

Some built-in routines are implemented this way. For example, the maths module has a relation named square_root which uses code to generate its values but, to the outside world, behaves like a normal relation.

import("maths")
print(maths.square_root(s:=25.0))
┌───────┐
│ r     │
├───────┤
│ -5.00 │
│  5.00 │
└───────┘

Some differences from most programming languages to notice:

First, this square root function returns both of the answers: i.e. -5 and +5.

Second, this square root function can accept multiple arguments at once. This:

  1. Avoids caller loops, both syntax-wise and call overhead-wise, both in passing arguments and unpacking results. The user can apply the function to a whole set of data at once (since the call is effectively a join)
  2. Gives the calculation engine the opportunity to use parallel/GPU methods to calculate the answers

Third, this square root function can work in both directions: if we pass it a root it can return the square. This is why it's named square_root and not sqrt, as a reminder of the two parts (with the mnemonic parameter names s and r).

import("maths")
print(maths.square_root(r:=-5.0))
┌───────┐
│ s     │
├───────┤
│ 25.00 │
└───────┘

This might not be so useful in some cases but for other functions this can greatly reduce the number of variations of them that we need.

For example, the str_rel function can be used to split a string into a relation, with a character per tuple by default.

print(str_rel(s:="hello world"))
┌─────┬──────────────┐
│ sep │ r            │
├─────┼──────────────┤
│     │ ┌─────┬────┐ │
│     │ │ seq │ ss │ │
│     │ ├─────┼────┤ │
│     │ │   0 │ h  │ │
│     │ │   1 │ e  │ │
│     │ │   2 │ l  │ │
│     │ │   3 │ l  │ │
│     │ │   4 │ o  │ │
│     │ │   5 │    │ │
│     │ │   6 │ w  │ │
│     │ │   7 │ o  │ │
│     │ │   8 │ r  │ │
│     │ │   9 │ l  │ │
│     │ │  10 │ d  │ │
│     │ └─────┴────┘ │
└─────┴──────────────┘

And it can also be used to convert a relation of characters into a string.

print(str_rel(r:={seq:int,ss:str}{[0,"h"],[1,"e"],[2,"l"],[3,"l"],[4,"o"],
[5," "],[6,"w"],[7,"o"],[8,"r"],[9,"l"],[10,"d"],}))
┌─────────────┬─────┐
│ s           │ sep │
├─────────────┼─────┤
│ hello world │     │
└─────────────┴─────┘

If fact, the same function can also be used to split a string into words or to join many words into one string (by passing an argument for sep, e.g. " ").

print(str_rel(r:={seq:int,ss:str}{[0,"hello"],[1,"world"],}, sep:=" "))
┌─────────────┐
│ s           │
├─────────────┤
│ hello world │
└─────────────┘

This way, a single function can do the work of four or more, and can be called once (many ways depending on which arguments it receives) for a set of data instead of repeatedly for each instance.

The three differences listed above allow us to compose relations to re-shape and transform data en-bloc, reducing the need for code. Less code = fewer bugs, and leads to systems that are easier to reason about and change.

In the examples above, we were using the compose and relation-creating short-hand to effectively join a relation containing our single argument with the maths.square_root relation. If we use the longer version we can pass multiple tuples at once, i.e. a relation:

import("maths")
print(maths.square_root & {s:float}{[25.0], [49.0]})
┌───────┬───────┐
│ s     │ r     │
├───────┼───────┤
│ 25.00 │ -5.00 │
│ 25.00 │  5.00 │
│ 49.00 │ -7.00 │
│ 49.00 │  7.00 │
└───────┴───────┘

Instead of joining, we could use compose and then the results wouldn't include the arguments we pass. This is often what's needed when using such functions e.g.

import("maths")
maths.square_root(S{*, r:=to_float(STATUS)}){*, STATUS_SQUARED:=s}
SNOSNAMESTATUSCITYSTATUS_SQUARED
S1Smith20London400
S2Jones10Paris100
S3Blake30Paris900
S4Clark20London400
S5Adams30Athens900