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

Difference: ACTION!s Evaluate To Isotopes

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

rebol2/red/r3-alpha>> do compose [x: (func [] [print "HI"])]
HI
** Script Error: x needs a value

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 UNSET! couldn't be assigned to X.

The new behavior might seem more sensible:

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

>> x
HI

We see during the COMPOSE, the ACTION! did not execute. What actually happened in this model is that the evaluation of the ACTION! produced an "action isotope", and that was stored in X.

There's a subtle bit of isotopic decay here... in that for your convenience, COMPOSE was willing to turn the ACTION! isotope returned by FUNC into a plain ACTION! so it could be put into the block.

(This is in line with a general concept behind isotopes... if they would raise an error in a situation but that error is likely to be useless... they just decay.)

Now let's say that instead of DO-ing that block, we had decided to enumerate it:

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

>> for-each item block [print form type of item]
set-word!
action!

Since we know that BLOCK!s can't store isotopes, there's no worry about getting one. So ITEM can be used instead of :ITEM.

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.