Attack of the Activated Actions

Here's something seemingly-simple that a newcomer to Redbol might attempt:

TASK: Write a function TWOSIE? that...

  • returns true if a value is the INTEGER! of 2, or a BLOCK! of length 2
  • returns false otherwise

twosie?: func [x] [          ; Ren-C calls this LAMBDA, due to no RETURN
    to-logic any [
        x = 2                ; will also pick up DECIMAL!, since 2 = 2.0
        all [
            block? x
            2 = length? x    ; length at current position, not total 
        ]
    ]
]

An "experienced" Redbol user would point out what happens when you pass a FUNCTION!.

red>> twosie? :append
*** Script Error: = operator is missing an argument
*** Where: =
*** Stack: twosie? to-logic 

If x turns out to be a FUNCTION!, it is invoked by the references inside the body. Which can cause unwanted side effects, as well as arbitrarily cryptic errors.

How Was This Mitigated?

You could restrict the set of accepted types, and exclude functions. But if you make it only accept INTEGER! and BLOCK! that undermines the aspect that the function's job is to do testing.

Doing it "right" is annoying, putting colons on every access...possibly omitting some when the value is in a context where the program logic would say it can't be a function at that point.

twosie?: func [x] [
    to-logic any [
        :x = 2
        all [
            block? :x
            2 = length? :x       ; :x is optional, as known to be a BLOCK! here
        ]
    ]
]

Sometimes, people would minimize the number of GET-WORD!s needed by short-circuiting a test for FUNCTION! first:

twosie?: func [x] [
    if function? :x [return false]
    to-logic any [
        x = 2
        all [
            block? x
            2 = length? x
        ]
    ]
]

This is fairly unsatisfying as well. It breaks easily if someone reorganizes the code and doesn't realize the test has to come first, or if there has to be additional handling and skipping all the code that uses the variable isn't the desired semantic.

This situation is a tax on users, who are continuously torn between writing obvious code that is broken... vs. cluttered code that winds up being made more brittle due to the maintenance of the clutter.

It would clearly be ideal if the obvious code was also correct.

The Nuanced Compromise Of Isotopic ACTION!s

Something that occurred to me was to ask what would happen if there were two kinds of actions:

  • ACTION! isotopes, which would run if they were referenced via a WORD! or TUPLE!

  • Plain ACTION!s, which would be inert when accessed by WORD!

An obvious good part of this idea would be that a "normal" argument to a function would never be able to be an isotope (solving the problems outlined above).

An obvious questionable part of this idea is introducing another state to worry about.

I've implemented it--though it is a radical change affecting kind of everything. :-/ There are certainly a lot of questions raised and details to come up.

Something to realize is that there's a fundamental complexity coming from the fact that Rebol wants WORD! references to execute actions automatically much of the time. But you still have a lot of places that want to talk about values "as-is". We cannot "wish away" that complexity...only reshape it.

But I think the ability to have obviously-written code in cases like TWOSIE? tips the balance. I don't know that meta-code gets truly any harder to write, it just gets different...while the simple examples work without GET-WORD!s.

I'll use this thread to document differences to know about.

2 Likes

This is the historical behavior of when you have an FUNCTION! value cell in a block, and DO it:

redbol>> do compose [x: (func [] [print "HI" return 10])]
HI
== 10

redbol>> x
== 10

That's because the COMPOSE put a FUNCTION! in the slot, and the behavior of such a cell when encountered via DO is to execute. So it ran, and the resulting value is 10.

If you wanted to actually put the function itself into X, you'd have to use their QUOTE (which I call QUOTE2, e.g. what Ren-C uses THE for.)

redbol>> do compose [x: quote (func [] [print "HI" return 10])]  ; QUOTE2
== #[action! [...]]

redbol>> x
HI
== 10

Ren-C COMPOSE Leverages Generic Quoting

Ren-C has had a fairly interesting answer to the second desire, where you can put a quote mark on the GROUP! and as such get a generic quoting:

>> do compose [x: '(func [] [print "HI"])]
== #[action! {x} []]

>> x
HI
== 10

But that was in the times before action isotopes...

Difference: FUNC-Like Generators Make Isotopes

If you're looking at this kind of thing:

do compose [x: (func [] [print "HI", return 10])]

We've got the two intents we might have had before:

  1. You want to run the function once, and put 10 into X

  2. You want to generate the function and put it into X so it runs when you reference X

But now, there's a new intent:

  1. You want to generate the function and put it into X, where referencing X gives you back the function by value without running it

But no matter which of these three intents you have, FUNC is generating an isotope. And If we know one thing about isotopes, it's that they can't be put in blocks.

And COMPOSE builds blocks ...so COMPOSE hates isotopes.

So you'll get an error unless you do some kind of operation to mitigate the isotope. Here are the solutions for each of the 3 intents

  1. do compose [x: (reify func [] [print "Hi", return 10])]

    produces [x: #[action! []]] ; plain action evaluates as invocation

  2. do compose [x: (meta func [] [print "Hi", return 10])]

    produces [x: ~#[action! []]~] ; quasiform evaluates to isotope action

  3. do compose [x: '(reify func [] [print "Hi", return 10])]

    produces [x: '#[action! []]] ; quoted evaluates by dropping quote to normal action

Nice Property: All 3 Cases Give A Safely Enumerable Block

It does matter which one of these you pick, you can write:

for-each item compose [x: ...] [
   print ["type of item is" type of item]
]

You'll get ITEM as a SET-WORD! and then either an ACTION!, a QUASI!-ACTION!, or a QUOTED!-ACTION!. None of these will be invoked just through a simple word reference.

That peace of mind is part of the goal here.

But The Cost Is Having To Be Explicit...

If you try to COMPOSE and just leave it as an isotope, that's an error by default.

You can make versions of COMPOSE which use a predicate to reify action isotopes automatically--or other decisions. But I don't think those are good defaults.

2 Likes

We see that function parameters become safer, and things in FOR-EACH of a block (which does not allow isotopes) become safer.

We can also make FOR-EACH of an object safer. Not all objects have isotopes, so those would work normally:

>> safeobj: make object! [x: 10, y: 20]

>> for-each [key val] safeobj [print [key "is" mold val]]
x is 10
y is 20

But if you enumerate something with isotopes, then we can warn you:

>> unsafeobj: make object! [x: 10, y: func [] [print "Boo!"]]

>> for-each [key val] unsafeobj [print [key "is" mold val]]
x is 10
** Error: must use ^VAL with FOR-EACH to receive isotopes

When you use the ^META convention, the isotopes become inert:

>> unsafeobj: make object! [x: 10, y: func [] [print "Boo!"]]

>> for-each [key ^val] unsafeobj [print ["meta" key "is" mold val]]
meta x is '10
meta y is #[action! []]

:+1:

Loophole: Function Return Results

But what about other operations like SELECT or DO? they don't have the safety that a function argument or FOR-EACH gets:

>> unsafeobj: make object! [x: 10, y: func [] [print "Boo!"]]

>> value: select unsafeobj 'y
== #[action []]  ; isotope

>> if action? value [print "Same old problem"]
Boo!
** Error: ACTION? does not accept ~ isotope 

We could annotate the assigned word in some way to show it accepted isotopes...

>> value: select unsafeobj 'y
** Error: Result is isotope, use VALUE/: if intended

>> value/: select unsafeobj 'y
== #[action []]  ; isotope

But this would create a kind of widespread ugliness, e.g. with function generators:

foo/: func [] [print "Hello"]

I did have a thought that we might make FUNC return a plain ACTION!, and then activate it with some special operator:

foo: runs func [] [print "Hello"]

Or possibly retake -> for this, and move lambda to => again:

foo: -> func [] [print "Hello"]

The operator could use special mechanics to left-quote FOO: and take away the requirement that it be FOO/: to receive an isotope.

This may seem to introduce some amount of "noise". But it would mean we wouldn't have to do as much sketchy implicit isotope decay. And I think it could be argued that in a world where both inert and activated functions exist, it is best not to try and hide the "activation".

SELECT could refuse to return an isotope result unless the caller requested it as ^META...

We actually do know whether there's a ^META result request in effect (we have to, in order to know whether an isotopic error should be returned normally or raised as an error.)

This could be information a function is allowed to know about its return results, and it could refuse to return an isotopic result unless it was doing so as a ^META.

>> unsafeobj: make object! [x: 10, y: func [] [print "Boo!"]]

>> value: select unsafeobj 'x
== 10

>> value: select unsafeobj 'y
** Error: SELECT will not return isotope value unless result is ^META

>> value': ^ select unsafeobj 'y
== #[action! []]

>> if action? value' [print "Better outcome?"]
Better outcome?

This feels good in a "you get what you pay for" sense. You don't have to go to the ^META level if you're just working with inert data. And you only have to when you'd likely be writing unsafe code otherwise.

But it requires every function like SELECT and DO to mark themselves to need this, where generators like FUNC are exempted.

If it were just me, I'd probably try the -> to activate ACTION!s, and then say all other isotopes required foo/: syntax to accept isotopes as function results.

That seems like it's the most clear way to do it. But at least for the moment, it's probably better to keep things more familiar, add the extra check to SELECT and similar functions, and see how it goes.

2 Likes

Continuing to document the new weird things that arise from the concept of ACTION! isotopes... here's a question about what should happen when they meet something like ANY and ALL.

>> all [x: func [] [print "This is the first behavior we get..."]]
** Script Error: Invalid use of #[action! []] isotope

Previously, there was no such thing as an ACTION! isotope...and ACTION! was considered a "truthy" value.

So it's worth pointing out this behavior for IF:

>> if (x: func [] [print "Hi"]) [print "Truthy?"]
Truthy?

So why did that work and ALL didn't? Well IF takes a normal parameter... and the easiest-seeming decision for how to handle an ACTION! isotope when passed to a normal parameter was to just decay it to a regular action.

But the isotope that appears inside ALL happens during its internal calls to the evaluator, and so we'd have to explicitly decay the action isotopes there.

Easy Enough To Decay... but is it the best answer?

The harm in decaying is that we are not giving back the "true" answer, which gives things different semantics. So this ALL expression which would get a non-isotope RESULT:

result: all [
    condition1
    condition2
    func [] [print "Boo!"]
]

...would be different from this IF expression, which would get an isotope RESULT:

result: if all [condition1, condition2] [
   func [] [print "Boo!"]
]

Decaying silently means people might try rearranging code, and find those rearrangements aren't the same as they would think they should be.

However, I do not think there are all that many cases where people are trying to return a function value from an ALL...and if they were, they'd probably want an inert one.

Because if it's an ALL then they are accepting the idea that there might be a result, and there might not be. So they're going to presumably want to test against NULL to see if it made a function.

Most of the time, I imagine they just want to skip over the creation of a temporary function (because I've done this many times). And there's an answer for that, which is the same answer as when you use a midstream PRINT...which is to ELIDE it:

all [..., elide x: func [] [print "..."], ...code using x...]

So it seems to me that if we left isotopic actions as an error in ANY and ALL, it may be better for understanding what's going on.

It's a little bit of a tough call, though.

So in Rebmake, there's actually an example (using APPLIQUE, which is just a variant of APPLY where it runs a block of code where SET-WORD!s are bound into the frame you're invoking, so you can write more generically structured conditional code inside the APPLY)

applique any [
    :gen-cmd-create :target-platform/gen-cmd-create
] compose [
    cmd: (cmd)
]

You can address this with REIFY:

applique any [
    reify :gen-cmd-create
    reify :target-platform/gen-cmd-create
] compose [
    cmd: (cmd)
]

I've kind of said that's just the way things go when you're constructing blocks, e.g. with COMPOSE.

...but prohibiting isotopes in things like ANY and ALL rule out other potentially interesting things:

append [a b c] maybe all [1 < 2, 3 < 4, spread [d e]]

Note ANY and ALL Aren't Chained To IF's Idea Of Truthy...

It would be a bad idea for ordinary conditionals to do anything but error in response to a void:

either comment "a" [print "Should this run?"] [print "Or should this run?"]

But ANY and ALL skipping over voids makes sense. So it's allowed.

And when we consider the applications, we might reasonably say that non-void isotopes count as "things" that can drop out as results.

  • This runs up against the proposed concept of WORD! isotopes, where they're all neither truthy nor falsey except ~true~ and ~false~. So they'd have their own behavior.

  • It would also be necessary to process isotopic objects to resolve them, otherwise it wouldn't be known if they were truthy or falsey

  • Packs would probably have to be unpacked as well

I'm Going To Give This A Shot

For now, I'm just going to let splices and activations count in ANY and ALL and see how it pans out:

>> all [1 < 2, 3 < 4, spread [d e]]
== ~(d e)~  ; isotope

>> any [1 > 2, :append, 3 > 4]
== ~#[action! {append} [series value /part /dup /line]]~  ; isotope

That looks reasonable, though I'm wary of seeing isotopic acceptance get too wild.

What makes me willing to make a concession here is that ANY and ALL are used kind of like branching constructs, e.g. all [a b] is a stylistic replacement for if a [b]. So it mixes up the character of a conditional with the character of a branch, suggesting that maybe such fluidity is appropriate.

1 Like