Applications of Isotopic Objects

Consider the example of a function that might want to have two modes... one that prints out readable information for the console, and another that keeps the data in easily processable form.

Traditionally you might think of controlling this with a refinement:

>> stats
-------------------
Column 1   Column 2
-------------------
Alpha      10
Beta       20

>> stats/only
== [
    [Alpha 10]
    [Beta 20]
]

But then I wondered: what if there was only one isotopic result...that wrapped the data with functions? So long as things stayed isotopic, you'd be able to get at the functions. But it could decay to be the data.

>> ^ stats
== ~#[object! [... decay: ... form: ...]]~

>> compose [statistics: (spread stats)]
== [statistics: [Alpha 10] [Beta 20]]

>> stats
-------------------
Column 1   Column 2
-------------------
Alpha      10
Beta       20

>> if true [print stats, <PRINT could react to FORM also>]
-------------------
Column 1   Column 2
-------------------
Alpha      10
Beta       20
== <PRINT could react to FORM also>

So PRINT would be special, and instead of allowing its argument to reify normally it would specifically examine isotopic arguments to see if they could FORM.

In this case, the isotopic thing needs to be able to do two things: FORM or DECAY. But maybe it could do more things, if you asked it? And maybe no particular methods are mandatory. Something that doesn't know how to DECAY just won't do that.

This looks kickass.

2 Likes

Definitely kickass.

In looking for a new interface with "informative errors by default", I wanted a non-successful parse to return a value, but not run THEN. ELSE seemed like it wouldn't be a candidate. But by taking advantage of the idea of Isotopic Objects, it's possible to make a novel version of something like JavaScript's "THEN-able Objects":

Thenables in JavaScript - Mastering JS

(Isotopes have a key advantage, in that they fit into a greater story of how not to be mistaken for regular values, as part of a holistic system of what it means to be "isotopic" yet still "meta/quasi/reifiable". Also of course, you can mix them with the ordinary language "keywords" so the syntax can be far more pleasant!)

Under the design I'm using, I've gotten this to work:

>> parse "aaa" [some "a" (1020)] then x -> [print ["Success:" x else ["null"]]]
Success: 1020

>> parse "aaa" [some "a" (null)] then x -> [print ["Success:" x else ["null"]]]
Success: null   ; <-- look, THEN got a NULL!

>> parse "aaa" [some "b" (<not reached>)] else e -> [print ["Fail:" e.message]]
Fail: BLOCK! combinator at tail or not thru  ; look, ELSE got a value! ^--

>> result: parse "aaa" [some "b" (<xxx>)]
** Error: BLOCK! combinator at tail or not thru
** Where: ...
** Near: [...]  ; error information is pretty meaningless at the moment, omitted
** File: ...    ; but returning *any* error vs just NULL or FALSE is a start!
** Line: ...    ; (there's a lot of means for improvement now!)

The case of THEN being able to receive a NULL is handled by means of isotopic mechanisms already discussed elsewhere (a "heavy null", currently represented by an empty block isotope, serves the purpose)

What's new-as-of-today here is:

  • ELSE is able to receive a value. What's happening here is that a failing PARSE returns an isotopic object, and it has an ELSE method on it. THEN knows to skip this object and pass it on. But ELSE will run the method and pass it the branch. It can do additional processing, but in this case what it wants to do is to pass in the error as an argument. So ELSE gets it.

  • If you don't use an ELSE with a failed PARSE showing you knew it didn't match, it will raise an error. This is because the isotopic object returned by PARSE on the failure case has another method... REIFY... for when it's in a context being forced to a value. And it just raises the error at that moment.

I think the example above shows exactly what I'm looking for. This feels like only the beginning of what's going to be possible with object isotopes...

1 Like

If you're curious what the parse wrapper looks like at the moment, it's pretty small.

PARSE* is the core parse that either raises an error isotope or gives you the value(s) returned by the BLOCK! combinator that kicked off the parse.

So that is ENCLOSE'd, and run with DO F:

parse: enclose :parse* func [f <local> synthesized'] [
    [^synthesized']: do f except e -> [
        ;
        ; If the parse failed, we wrap it in a "lazy object" that will
        ; fail if you don't connect it to a then or else branch.
        ;
        return isotopic make object! compose [
            else: '(func [branch [any-branch!]] [if e (:branch)])  ; see [1]
            decay: '([raise e])
        ]
    ]
    ; The common case of wanting to transmit null or void states to a
    ; THEN are solved in a more lightweight fashion using isotopes, that
    ; save the trouble/cost of making objects.
    ;
    switch synthesized' [
        '~ [return/forward ~()~]
        '_ [return/forward ~[]~]
    ]
    return/forward unmeta synthesized'  ; see [2]
]
  1. It uses if e (:branch) to run the branch, because I haven't thought of a good name for doing branches "with" an argument yet. And since errors are always truthy, it will always run that branch (whatever type it is) and pass it the error. Some DO-like routine that took a /WITH argument would be clearer, but there's a lot up in the air with the interface on DO and EVAL so this is what's done for now.

  2. The special RETURN/FORWARD is used for the new multi-returns, when you want to say that you want to return the parameter pack as a pack...vs having it just become the first element of that pack. This new multi-return method is just being tested, but working very well so far!

1 Like

This can also be applied to the example of wanting COLLECT with no KEEPs to have special behavior with THEN and ELSE, while still becoming an empty block in the normal case.

If COLLECT wants to indicate an empty block, it does this:

return isotopic make object! [
    else: branch -> [if copy [] (branch)]  ; again using IF for branch-with-args
    reify: [copy []]
]

So that gives you:

>> collect [print "no keeps (but no ELSE, so it reifies)!"]
no keeps (but no ELSE, so it reifies)!
== []

>> collect [print "no keeps!"] else [print "and the else branch ran!" copy [x]]
no keeps!
and the else branch ran!
== [x]

>> collect [keep <keeping>] else [print "So no else branch!"]
== [<keeping>]

It's all very new and I'm trying to put together enough examples to make solid reasoning about it (what if you put an isotopic object inside a multi-return pack? what if something reifies to another isotopic object or multi-return?)

So no shortage of questions. But that doesn't mean those questions can't have reasonable answers for what's legal and what isn't, and I think it points in a very promising direction.

1 Like

TL;DR: This establishes a very simple rule for THEN and ELSE. If you don't like the consequences of the rule, you make an object that spells out what you want the THEN and ELSE behavior to be. That object can also say how it will vaporize itself when not used with THENs or ELSEs...and when it does vanish in a puff of smoke it can turn into a multiple-return-value pack.


Building on the emerging art of ~[multi return packs]~ and isotopic objects, I'm coming up with what may be a resolution to the THEN and ELSE issues surrounding NULL and VOID.

  • As a default behavior, a "pure null" and a "pure void" are not reacted to by THEN. It will pass them through and allow an ELSE to act on them.

  • If a NULL or VOID appear as part of a multi-return parameter pack, they do not count as "nothing having happened". So THEN will react to a multi-return parameter pack--even if its first element is null or void.

    • This means those writing multi-return functions intended to work with ELSE and THEN should not set their multi-returns if the overall result is VOID or NULL.

    • I've implemented this as the default behavior for return void and return null when using the auto-proxying features of function. FUNC will not do the proxying--just as it does not do when you return a definitional (raised) error.

      • You can think of this as being consistent with NULL being thought of as "soft failure"... if the function "fails" then the outputs are not written.
  • The base case of parameter packs of ~[~null~]~ and ~[']~ being single packs of "meta null" and "meta void" are also THEN-reactive and not ELSE-reactive, regardless of not having other parameters in the pack:

    >> if true [null]
    == ~[~null~]~  ; isotope
    
    >> x: if true [null]
    == ~null~  ; isotope
    
    >> if true [null] then [print "NULL-in-multi-pack is not a NULL to THEN"]
    NULL-in-multi-pack is not a NULL to THEN
    
  • These cheap mechanical rules should work for most cases... but lazy objects (object isotopes) can step in to fill in the gaps if there's a good ergonomic reason to bend these rules.

Solving A Tricky Case: TRAP

The way I had defined TRAP, it's a good example of something that didn't want to follow these new rules.

TRAP was a multi-return function, whose main result is an ERROR!, or NULL if not an error. But in the case of it being not an error, then a secondary return result would come back from it:

>> [error result]: trap [1 / 0]
== make error! [...zero-divide...]

>> result
; null

>> [error result]: trap [1000 + 20]
; null

>> result
== 1020

Expecting that breaks the rule: you can't return any multi-return results if you are returning pure null. But if it returned a heavy NULL you couldn't write code like this:

trap [1 / 0] then e -> [print ["Had an error" e.id"]]

Because if your code didn't have an error, it would still run the THEN branch:

>> trap [1000 + 20]
== ~[~null~]~  ; isotope

>> trap [1000 + 20] then [print "No error, but we still get TRAP THEN"]
No error, but we still get TRAP THEN

So to get a TRAP that will work in this fashion, you need a lazy object. And you can even make a lazy object that if asked to resolve itself, creates a parameter pack so you get the [error result]: assignability.

Here we can build that on top of ENTRAP (which returns a single value that's either an ERROR!, or the ^META result value)

trap: func [
    return: [<opt> any-value!]
    code [block!]
    <local> result
][
    if error? result: entrap code [
        return/forward pack [result null]  ; /FORWARD stops decay to result
    ]

    return isotopic make object! [
        else: branch -> [
            (pack [unmeta result]) then (:branch)  ; packs null, so it would run
        ]
        decay: [pack [null unmeta result]]
    ]
]

Believe it or not, this actually does work. With THEN and ELSE:

 >> trap [1000 + 20] then e -> [print ["E" e.id]] else r -> [print ["R" r]]
 R 1020

 >> trap [1 / 0] then e -> [print ["E" e.id]] else r -> [print ["R" r]]
 E zero-divide

And for unpacking the values:

>> [error result]: trap [1 / 0]
== make error! [
    type: 'Math
    id: 'zero-divide
    message: "attempt to divide by zero"
    near: [1 / 0 **]
    where: [/ entrap trap+ console]
    file: _
    line: 1
]

>> result
== ~null~  ; isotope

>> [error result]: trap [1000 + 20]
== ~null~  ; isotope

>> result
== 1020

So @rgchris can be happy because that is usable in a historically conventional way:

either [error result]: trap [
   ... your code here ...
][
   ... code that reads ERROR here...
][
   ... code that reads RESULT here...
]

What's great here is how ENTRAP is an agnostic building block, which can then be wrapped and packaged to one's liking.

Not Trivial To Understand, But Also Not Rocket Science

These are concerns for the implementers of things like TRAP and PARSE--not for the users. So it's important to see it in that light. It's power for those who need it to get the usage patterns they want.

The RETURN/FORWARD is simply to address the normal behavior of decay of multi-return packs to the first item.

When you write x: some-function ... then you're only getting the first parameter in a pack. We don't want return some-function ... to automatically turn your function into a multi-return function just because the SOME-FUNCTION you call happened to offer more outputs.

(I use the term /FORWARD in the sense of "forwarding"... whatever the results were, return them verbatim.)

Hopefully I don't discover any fatal flaws, because it's a really neat design--and I think these examples are only scratching the surface.

1 Like