The Four Fundamental Operations of Definite Action

All definite actions (computer program) can be defined by four fundamental patterns of combination:

  1. Sequence
  2. Branch
  3. Loop
  4. Parallel

Sequence

Do one thing after another. In joy this is represented by putting two symbols together, juxtaposition:

foo bar

Operations have inputs and outputs. The outputs of foo must be compatible in "arity", type, and shape with the inputs of bar.

Branch

Do one thing or another.

boolean [F] [T] branch


   t [F] [T] branch
----------------------
          T


   f [F] [T] branch
----------------------
      F


branch == unit cons swap pick i

boolean [F] [T] branch
boolean [F] [T] unit cons swap pick i
boolean [F] [[T]] cons swap pick i
boolean [[F] [T]] swap pick i
[[F] [T]] boolean pick i
[F-or-T] i

Given some branch function G:

G == [F] [T] branch

Used in a sequence like so:

foo G bar

The inputs and outputs of F and T must be compatible with the outputs for foo and the inputs of bar, respectively.

foo F bar

foo T bar

ifte

Often it will be easier on the programmer to write branching code with the predicate specified in a quote. The ifte combinator provides this (T for "then" and E for "else"):

[P] [T] [E] ifte

Defined in terms of branch:

ifte == [nullary not] dip branch


In this case, P must be compatible with the stack and return a Boolean value, and T and E both must be compatible with the preceeding and following functions, as described above for F and T. (Note that in the current implementation we are depending on Python for the underlying semantics, so the Boolean value doesn't have to be Boolean because Python's rules for "truthiness" will be used to evaluate it. I reflect this in the structure of the stack effect comment of branch, it will only accept Boolean values, and in the definition of ifte above by including not in the quote, which also has the effect that the subject quotes are in the proper order for branch.)

Loop

Do one thing zero or more times.

boolean [Q] loop


   t [Q] loop
----------------
   Q [Q] loop


   ... f [Q] loop
--------------------
   ...

The loop combinator generates a copy of itself in the true branch. This is the hallmark of recursive defintions. In Thun there is no equivalent to conventional loops. (There is, however, the x combinator, defined as x == dup i, which permits recursive constructs that do not need to be directly self-referential, unlike loop and genrec.)

loop == [] swap [dup dip loop] cons branch

boolean [Q] loop
boolean [Q] [] swap [dup dip loop] cons branch
boolean [] [Q] [dup dip loop] cons branch
boolean [] [[Q] dup dip loop] branch

In action the false branch does nothing while the true branch does:

t [] [[Q] dup dip loop] branch
      [Q] dup dip loop
      [Q] [Q] dip loop
      Q [Q] loop

Because loop expects and consumes a Boolean value, the Q function must be compatible with the previous stack and itself with a boolean flag for the next iteration:

Q == G b

Q [Q] loop
G b [Q] loop
G Q [Q] loop
G G b [Q] loop
G G Q [Q] loop
G G G b [Q] loop
G G G

while

Keep doing B while some predicate P is true. This is convenient as the predicate function is made nullary automatically and the body function can be designed without regard to leaving a Boolean flag for the next iteration:

            [P] [B] while
--------------------------------------
   [P] nullary [B [P] nullary] loop


while == swap [nullary] cons dup dipd concat loop


[P] [B] while
[P] [B] swap [nullary] cons dup dipd concat loop
[B] [P] [nullary] cons dup dipd concat loop
[B] [[P] nullary] dup dipd concat loop
[B] [[P] nullary] [[P] nullary] dipd concat loop
[P] nullary [B] [[P] nullary] concat loop
[P] nullary [B [P] nullary] loop

Parallel

The parallel operation indicates that two (or more) functions do not interfere with each other and so can run in parallel. The main difficulty in this sort of thing is orchestrating the recombining ("join" or "wait") of the results of the functions after they finish.

The current implementaions and the following definitions are not actually parallel (yet), but there is no reason they couldn't be reimplemented in terms of e.g. Python threads. I am not concerned with performance of the system just yet, only the elegance of the code it allows us to write.

cleave

Joy has a few parallel combinators, the main one being cleave:

               ... x [A] [B] cleave
---------------------------------------------------------
   ... [x ...] [A] infra first [x ...] [B] infra first
---------------------------------------------------------
                   ... a b

The cleave combinator expects a value and two quotes and it executes each quote in "separate universes" such that neither can affect the other, then it takes the first item from the stack in each universe and replaces the value and quotes with their respective results.

(I think this corresponds to the "fork" operator, the little upward-pointed triangle, that takes two functions A :: x -> a and B :: x -> b and returns a function F :: x -> (a, b), in Conal Elliott's "Compiling to Categories" paper, et. al.)

Just a thought, if you cleave two jobs and one requires more time to finish than the other you'd like to be able to assign resources accordingly so that they both finish at the same time.

"Apply" Functions

There are also app2 and app3 which run a single quote on more than one value:

                 ... y x [Q] app2
 ---------------------------------------------------------
    ... [y ...] [Q] infra first [x ...] [Q] infra first


        ... z y x [Q] app3
 ---------------------------------
    ... [z ...] [Q] infra first
        [y ...] [Q] infra first
        [x ...] [Q] infra first

Because the quoted program can be i we can define cleave in terms of app2:

cleave == [i] app2 [popd] dip

(I'm not sure why cleave was specified to take that value, I may make a combinator that does the same thing but without expecting a value.)

clv == [i] app2

   [A] [B] clv
------------------
     a b

map

The common map function in Joy should also be though of as a parallel operator:

[a b c ...] [Q] map

There is no reason why the implementation of map couldn't distribute the Q function over e.g. a pool of worker CPUs.

pam

One of my favorite combinators, the pam combinator is just:

pam == [i] map

This can be used to run any number of programs separately on the current stack and combine their (first) outputs in a result list.

   [[A] [B] [C] ...] [i] map
-------------------------------
   [ a   b   c  ...]

Handling Other Kinds of Join

The cleave operators and others all have pretty brutal join semantics: everything works and we always wait for every sub-computation. We can imagine a few different potentially useful patterns of "joining" results from parallel combinators.

first-to-finish

Thinking about variations of pam there could be one that only returns the first result of the first-to-finish sub-program, or the stack could be replaced by its output stack.

The other sub-programs would be cancelled.

"Fulminators"

Also known as "Futures" or "Promises" (by everybody else. "Fulinators" is what I was going to call them when I was thinking about implementing them in Thun.)

The runtime could be amended to permit "thunks" representing the results of in-progress computations to be left on the stack and picked up by subsequent functions. These would themselves be able to leave behind more "thunks", the values of which depend on the eventual resolution of the values of the previous thunks.

In this way you can create "chains" (and more complex shapes) out of normal-looking code that consist of a kind of call-graph interspersed with "asyncronous" ... events?

In any case, until I can find a rigorous theory that shows that this sort of thing works perfectly in Joy code I'm not going to worry about it. (And I think the Categories can deal with it anyhow? Incremental evaluation, yeah?)