The Beta/One Enfix Covenant

-- GOOD NEWS -- :loudspeaker: -- ENFIX HAS BEEN SOLVED --

I know it's been a long road, but it has reached a point where the design is final for Beta/One. I don't say that lightly!

Some aspects people will be very happy about:

  • It involves embracing Rebol's historical "one unit of evaluation" on the left hand side as the default for enfixed operations.
  • It also involves going back to this rule for comparison operators, so you can say if not x = y [...] and that will mean if not (x = y) [...]. Same for AND and OR, which are still changed to be conditional...but are no longer "weird" in the way that THEN and ELSE are.
  • There is no more #tight parameter convention, and no TIGHTEN operation.

I'm happy about it too, especially that last point!

Note that I was never particularly opposed to having 1 + 2 * 3 be 9 instead of 7, and I preferred the historical not... if all things were equal. I just didn't want to sacrifice features I knew were cool and important to get those behaviors, if the system could be coherently built another way. Now it has that coherence and happens to pass through that point, while making everything I wanted work.

This is absolutely NOT merely dialing back to the past... it's very futuristic!

If you read the below explanation and don't want to take my word for it, I invite you to try patching what is described below onto R3-Alpha. #goodluckwiththat (!)

The solution deeply leverages modern evaluator mechanics which would have been difficult to even think about before what all the fiddling produced. It isn't sacrificing ELSE, THEN, or (new) ALSO... it leverages their unusual handling by "demoting" ordinary infix operators to their rules on an as-needed basis. Their mechanic is at the heart of the trick...most operations just don't do the trick except as a last resort.

:dart: -- THINGS "JUST WORK" --

Remember...no #tight parameters and no TIGHTEN:

>> +: enfix :add
>> *: enfix :multiply

>> 1 + 2 * 3
== 9

A first realization driving the design is that if you make an operator that quotes its left hand side, this changes matters:

defaulter: enfix function [:left right] [
    either null? old: get left [set left right] [old]
]

>> x: null

>> x: defaulter 2 * 3
== 6

>> x: defaulter {^-- see, it wasn't `(x: defaulter 2) * 3`}
== 6

What "tight" behavior actually is in the new model is a resolution of a contention between an operator that wants at least one value's worth of evaluated information on its right, and an operator that wants exactly one value's worth of evaluated information on its left. Left-quoting cuts the knot, so we need not see this as (x: defaulter 2) * 3 just because of (1 + 2) * 3.

The second realization from seeing it as contention resolution is that errors are a great response when you don't know what to do. For instance: if something evaluates its left and right but has more than one argument, hence can't be forced to left completion:

>> arity-three: enfix func [a b c] [-- a b c]

>> 1 arity-three 2 + 3 4
** Script Error: Ambiguous infix expression--use GROUP! to clarify
** Near: [1 arity-three 2 ~~ + 3 4]

>> 1 arity-three (2 + 3) 4
-- a: 1
-- b: 5
-- c: 4

That error won't look familiar to you, but it's the same point that causes this:

>> if true then [<bad>] [print "You can't do this, now"]
** Script Error: Ambiguous infix expression--use GROUP! to clarify
** Near: [if true ~~ then [<bad>] [print "You can't do this, now"]]

>> if (true then [<good>]) [print "This is okay."]
This is okay.

The reason the error is the same is because ordinary enfix operators get "demoted" to deferred ones if there is a point at which the no-lookahead is exercised. They use the same mechanics and code path at that point.

As twist is that an END on the left for an evaluative parameter is considered to not-have-evaluated. That empowers one of my favorite little pet enfix demos to work out of the box:

+: enfix function [left [<end> integer!] right [integer! <...>]] [
    if set? 'left [return add left take right]
    sum: 0
    cycle [
        if tail? right [return sum]
        sum: add sum take right
    ]
]

>> 1 + 2 * 3
== 9

>> (+ 2 * 3 4 * 5 multiply 6 7)
== 68  ; e.g. (2 * 3) + (4 * 5) + (multiply 6 7)

If it were forced to quote left to get the second behavior, it couldn't make the first case work. I think this is probably the most common desire for how evaluative ends on the left should work.

And ELSE, THEN, and (new) ALSO are all still around, with their cool features:

 case [
     1 > 2 [<nope>]
     3 < 4 [<yep>]
 ] then [
     print "One of the above matched!"
 ] else [
     print "Neither of the above matched"
 ]

That prints One of the above matched. So clearly THEN and ELSE have to see more than just the one block on the left. This mechanic has been tuned and tweaked, to narrowly solve the purpose and avoid creating problems

:spiral_notepad: -- Enfix Covenant Cheat Sheet ---

Remember that there's still no such thing as an enfix ACTION!. OP! does not exist, and SET/ENFIX and the itself-enfix ENFIX operator are as you have come to know. GET-ting an ACTION! will always get you a non-enfix one. All that's the same. But other things are being set in stone now:

  • One evaluative unit will be taken to be the "normal" mode for left enfix. Its predictability is a strength, you want to be able to say return x and 'y and not worry what's going to happen, e.g. that become (return x) and 'y.

  • Comparison operators will be restored to historical infix behavior. That means if not x = y [...] will be interpreted as if not (x = y) [...] again, as opposed to if (not x) = y [...]

  • The mode used by THEN, ELSE, and ALSO will thus be the outlier. These will be called deferred enfix, and their rules are being limited to provide support for the specific scenario they want. Notice for instance that return x then [...] is illegal now--you need to say return if x [...] then [...] Being more rigid with this pattern brings more peace of mind to the use of the feature.

  • Variadics won't need to distinguish their last argument TAKE to be consistent with non-variadic argument gathering. This is possible due to how deferred enfix was limited to just solve THEN, ELSE, ALSO, and their ilk. It's no longer trying to also solve AND, +, =, or anything like that...hence it can use a less wily process and keep evaluator complexity in check.

  • There's now no such thing as TIGHTEN or a #tight parameter convention. It's gone. Rejoice.

This was critical to get right

It's been tinkered with so much because I think enfix is very, very important.

Infix makes Rebol a notable minority in the family of languages that it belongs to. But it's a big part of how we build grammars in our head; languages that sacrifice that are missing out on a powerful tool. The Rebol2/Red limitations were not going to work for me, I wanted a solution that expanded the space to basically anything that was semantically coherent.

This is what I was looking for...but it takes a lot of evaluator design to pull it off!

5 Likes

Maybe Ren-c should be renamed to “Smackdown”, because you have been a relentless, maniacal BEAST in tracking down inconsistencies and drag-sinks to make Ren-C as lean and mean as possible, and to eek out the most power from this model of programming language. Wow. Just wow.

2 Likes

For historical reference, here's the note from an old branch I'd been working on that I never pinned down that I can now delete. It was a similar line of thinking--that errors would be needed. But it made variadics the victim, instead of applying limits to the usage of deferred operators. This made sense if comparison operators were deferred, but when they're not it makes more sense to put the parenthesization/error situation on THEN/ELSE/ALSO when you try to use them after a single value while acquiring any argument slot.

Error on ambiguous deferments of variadics
(Feb 2018 Git Commit description, unfinished branch)

One of the tradeoffs of using Ren-C's "one expression's worth of evaluation on the left" for "normal" enfix is that it means there is special treatment of the last parameter of things.

>> fun-arity-2: func [a b] [dump [a b] return "string"]

>> fun-arity-2 1 = 1 2 = 2
a: => true
b: => 2
** Error: cannot compare INTEGER! and STRING!

So above, what's happening is that the first = sign realizes that it cannot get a complete function call out of (fun-arity-2 1), so it goes ahead and interprets its left argument as simply (1). But the second = realizes that fun-arity-2 1 = 1 2 is a complete expression, so it uses that. The function is called, and evaluates
to a string, which is then compared with 2.

It's quirky, but learnable...and it offers great power to a number of expressive tricks (including allowing for ELSE). Parentheses can be used in situations where there's any question about what's happening:

 fun-arity-2 (1 = 1) (2 = 2)

But one issue with "treating the last argument differently" is what the semantics will be with a variadic function. By contract, a variadic takes as much or as little out of the frame as it wants...consuming an argument at a time. Imagine changing the above function to a variadic implementation:

>> fun-variadic-2: func [v] [
    dump [(take v) (take v)]
    return "string"
]

It would seem inconsistent if that function didn't behave the same as FUN-ARITY-2 in terms of how the arguments were handled. Yet without changing variadics to make them more complex (e.g. "announcing" in advance how many arguments they plan to take), there seems to be no way to accomplish that.

This commit tries the next-best thing. If a variadic TAKE is done, it runs the evaluation without taking a deferment (e.g. it would read fun-variadic-2 1 = 1 2 = 2 as fun-variadic-2 (1 = 1) (2 = 2).) But it makes a note if a left normal enfix operator was used in a variadic argument slot, and if another argument isn't taken after that before the function ends then it will give an error.

This "wait-and-see" approach helps prevent a variadic function from behaving differently than its fixed-arity counterparts, but means it will force some kind of parenthesization on some scenarios it would not be required for the fixed arity case.

Note this doesn't affect left-enfix-tight operations (e.g. math ops), just left-enfix-normal (e.g. comparison ops)

It seems lame to dedicate so few words of congrats to such an important part of the project - but congrats, this is looking great!

2 Likes

Glad you're still around to appreciate it!... But you know what would be really non-lame? Coming back and working on things! :slight_smile:

More hands would help make Beta/One this year a reality!

1 Like

...I didn't say anything about how left-vs-right quoting would be resolved above. But I think I have the answer:

Leftward-Quoting Enfix operators will always take priority, unless they are at the end of an array

Hence lit => [print lit + 1] lets the lambda win, vs literally taking the =>, throwing it away, and then evaluating to the ensuing block. But (lit =>) is a situation where the lambda doesn't get a chance to run. If it turns out that lit was just an INTEGER! or something, this would produce an error saying that the evaluator gave the left hand a chance but it didn't take it.

Note that this deference to the left on the at-end situation only applies if the left hand side is a WORD! or a PATH!. It doesn't make sense to drop the opportunity to left quote with (1 left-quoting-op). It would just throw away the 1...what's the point?

It's a simpler rule, that covers known cases

Initial biases were that since the evaluator seemed to generally run left-to-right, that a left-quoting case should win over a right one. While there weren't a ton of examples, help => seemed it should let HELP win. And what was then "LIT" (e.g. QUOTE) seemed it should also win too.

So the rule of left winning over right was in effect at first. But then @gchiu (before he had dedicated his life to roast pork and hot pot) raised an issue about the impact of leftward quoting when you dispatched HELP from a PATH. e.g. HELP/DOC wasn't a word, so it wasn't accounted for. Neither was LIB/HELP, nor LIB/(PICK [PRINT HELP] 1), etc.

A short term hack was added. But something was amiss. This was creating a complexity from how there wasn't a clear prioritization, it depended on the specific pair of things used...as opposed to the state of either one. It was costing computation cycles, and figuring out the path was impossible in the general case...because once you'd evaluated groups in it, you couldn't undo them.

This rule clears the fog. If you have a forward quoting function of arity more than one that quotes its first argument, you can enlist the help of a left-quoting operator to help you: (=> <- quotes-1st-arg arg2 arg3). Don't forget there's APPLY and other ways of getting parameters too, that aren't subject to the straight-line evaluator prioritization.

It gives us help of and lib/help => and help/doc <-, so that's all good!