Rye Language

So here's another language which doesn't look very Rebol-like, but is by Refaktor who is Rebol-inspired:

Notably, it is written in Go. (I've mentioned that increasingly I have been leaning toward modeling things after Go for a runtime. It would of course be easier to do by actually writing the runtime in Go, vs. trying to reproduce the effect in low-level C code. :frowning: )

It's probably very interesting, but I don't really feel like looking at it right at this moment. Maybe someone else can study it and explain what it's about in a reply here.

The main carry-away I'll take for the moment is the 1:1 language-designer:user ratio that Rebol users are inevitably converging toward.

2 Likes

This Rye language also is inspired by Rebol.
An odd thing that strikes me is that "inspired by" often means that a lot of Rebol is recognisable, and some changes have been made to not resemble Rebol too much, resulting in exchanging good choices by worse. In this case interchange squared brackets with horrible curly's.

I just had an interesting discussion with the creator of Rye about how they implement binding. Itā€™s a little hard to tell, but it sounds like Rye uses basically the same system as R does, with some additional restrictions to keep it more tractable. Itā€™s certainly less flexible about it than most Rebol languages are.

1 Like

Glad to hear there's a move to the environment/inheritance idea. Suggests moving in a direction of greater restraint with binding is a recognized need...at least somewhere else.

Stopping to briefly look over the language further now, I don't really see much Rebol in it. Can't find any dialects, e.g. no PARSE equivalent. No COMPOSE?

It's piping-focused, in a way I can hardly read (English-like readability is supposed to be a goal in Rebol, that's clearly not a goal in Rye). The idea of piping things seems better addressed to me by a construct like:

>> flow [
       [1 2 3]
       reverse _
       map-each x _ [x * 10]
   ]
== [30 20 10]

(Choice of _ here is arbitrary and something you should be able to override.)

It could be useful. In fact, I went ahead and did a little implementation of it.

But...I think people who find data piping is their true quest in programming would go for paradigms that are more mature at that.

More Rye, GUIs this time: https://ryelang.org/cookbook/rye-fyne/examples/.

(The language seems to pop up on Hacker News every so often, Iā€™m not sure why. At least Red has some reason to be posted there.)

1 Like

Thanks for the heads-up. Having my morning caffeine and so I guess I can briefly look at this. But before I can make any sense of the GUI, I need to survey the language a bit.

Three List Types?

It looks like Rye has {...} which it has called a "block". And despite not being present in any of the examples it presumably has (...). But the GUI examples also show use of [...].

I can't find what it calls the bracketed form--the web page is not super informative. The web console crashes on nearly every example, and pretty much anything I type. Going to look at the source...

Okay here we go.

[...] is apparently a BBLOCK. :man_facepalming: Bad naming, but at least he calls (...) GROUP.

Ren-C wins here with [block] ā€¢ (group) ā€¢ {fence}

BUT since all three types exist, it does mean the GUI has more parts. So maybe there will be some ideas about how to leverage that to borrow from.

(I can guess why {...} was likely chosen as the basic inert type... to look more appealing to Go programmers. But I'm not sure that's a win--standing out is better to know when you're switching languages. Certainly would have been nice to call that a FENCE instead of a BLOCK.)

Falsey Zeros

if 0 { print "Moon" }
; doesn't print anything

0 being "falsey" breaks the usefulness of ANY and ALL and such, crippling the ability to write idiomatic code:

 value: all [<<condition1>> <<condition 2>> some-number]

I can think of a lot of reasons to make it "truthy", and 0 reasons to make it falsey. If picking things to change about the language I definitely wouldn't change this.

BBLOCK For Datatypes? No...

I managed to get the console to answer a type question, and it gave back what looked like a "BBLOCK". So I wondered if Rye had gone to a system like &[type] in Ren-C.

x> type? "abc"
[Word: string]

But, no. TYPE? returns a WORD!. It's just stylizing its output.

x> type? type? "abc"
[Word: word]

:LEFT-SET-WORD Enables BLOCK-as-Lightweight-Function

You will [see] why later, but Rye also has set-words that take value from the left.

"Jim" :name
12 + 21 :apples + 100 :fruits

So "Jim" :name as a synonym for name: "Jim" ?

This is bizarre, but I dug around and found out why:

loop 3 { :i , prns i }    ; loop function injects loop number into the 
                          ; code block and left set-word can pick it up

So this is Rye's version of repeat 3 i -> [print i], where in Ren-C you would use a GROUP! generally and it wouldn't come out looking too awful comparatively...same # of characters with his example when put side by side:

repeat 3 { :i , print i }
repeat 3 (i -> [print i])

I'm not a fan of the :i set-word-from-the-left concept. But it is true that Ren-C's approach costs you the generation of a fairly lightweight lambda, vs. efficiencies that could be had by using a stylized block directly.

When I've thought of stylizing blocks, I've never thought of "leading GET-WORD!s". I threw around ideas more like:

repeat 3 [[i] print i]
repeat 3 [i | print i]

Though these days we could glue the variables onto the block with a sequence?

repeat 3 i:[print i]

for each [1 2 3 4] [a b]:[
    print ["A is" a]
    print ["B is" b]
]

It's ugly...but I don't find it as ugly as Rye's method, and still gets the variables with the block in one slot without creating a function...if that is a goal.

With EACH and such aiming to become generators, this does cut at the heart of some questions about whether we would want:

for each [1 2 3] ... code and possibly var go here ...
for var each [1 2 3] ... code goes here ...
for-each var [1 2 3] ... code goes here ...

Ren-C's approach here just comes out looking better to me:

for [a b] each [1 2 3 4] [
    print ["A is" a]
    print ["B is" b]
]

for-each [a b] [1 2 3 4] [  ; legacy wrapper, maybe in the box, maybe not
    print ["A is" a]
    print ["B is" b]
]

You get a variable-naming slot you might not need:

handler: [a b] -> [
    print ["A is" a]
    print ["B is" b]
]

for # each [1 2 3 4] handler/

But I feel like I can live with that, especially since you might want to use the slot as a filter spec for what to pass...even if you don't need to name things:

handler: [b] -> [
    print ["B is" b]
]

for [_ #] each [1 2 3 4] handler/
; prints 2, 4

Anyway... status quo wins here for me.

Failures vs. Errors

Failures can be [handled], code errors can only be fixed
Rye distinguishes between a failure of a function to produce a result, which can be handled nicely, without breaking a flow. Or a code Error, which means an unknown state, so the program should generally stop and Error should be fixed in code. An unhandled failure is also a code Error.

So this sounds on the surface like it may arise from an understanding that reacting to abrupt errors is a bad idea, and you can only handle error conditions at the boundary between caller and callee.

But there is nothing in the Failures and Errors section :man_shrugging:

I'd be very surprised if it scratches the surface of what definitional errors are doing.

"Context Paths"

We can use a context path, another literal value type in Rye, to address values inside a context. Evaluator uses cpath in the same manner as it uses words. If a word in a context is bound to a function it evaluates it, if not it returns the value bound to it.

print pos-printer/codepage
; prints: w1250

So he uses backslashes for refinements, and forward slashes for picking things out of contexts. It's almost like the : for CHAIN! refinement vs. . for TUPLE! picking distinction.

Of course I think Ren-C's decisions are superior here. But notable that refinements and member selection are done with different "path" (sequence) types.

Context Protection

No direct changes

We canā€™t use the equivalent of set or mod-words, to change the values inside a context. If you come from Rebol, this is possible there, but Rye tries to keep a much tighter control on modification in general. Rye only allows direct changes in the current context, but not in sub-contexts and not in the parent contexts. You can only send messages i.e. make function calls there.

What you can do, is ā€œsend messagesā€ to other contexts, that means ā€œcall functionsā€ there. But in Rye a limited number of functions that change values in-place (hence can change state) need to end with ā€œ!ā€ (exclamation mark), so the calls to such modifying functions are visible and explicit.

count: 0
; prints: 100

actuator: context { loop 100 { inc! 'count } }

reseter: context { change! 0 'count |if { print "Changed!" } }

print count
; prints: 0

That is pretty heavy-handed.

I'm sure there's usefulness to a policy of controlling access, and saying only some kinds of references can mutate some fields. Definitely worth thinking about, but even as a C++ programmer who believes in abstraction, I think "if a struct will work, use a struct". There are different granularities of concerns.

I get mad when people look at small Ren-C examples that look awkward and miss the bigger picture, so I don't want to do that here...but...initial impression, I don't think this is a way I would want to be forced to work.

Rye Dialect

Do is a general Rye function that does the Rye dialect. A

I'll just point out that Carl was adamant that when you write something like for-each 'x [1 2 3] [print [x]] you are not using "the DO dialect" or "the Rebol dialect".

He said that was distinct from dialects, and was "the Rebol language", in which you implement dialects.

In Ren-C, there might now be grounds for something called "the DO dialect"...in terms of what DO does when you pass it a block... which will not be EVAL. But when you run code, is that the "EVAL dialect?"

I'm certainly not as strongly opinionated on the matter as Carl was. But in deference, I'm willing to just call it "the evaluator".

Extends Object

Function extends accepts another context in addition to a block of code and sets the accepted context as a parent to a newly created one.

Even though I've long hated make (object) [...] because it put an object instance where a datatype would be, I hadn't really thought of extends (object) [...] as the generalized replacement for that. Not sure why I haven't replaced it by now.

Think I'm going to follow Rye's lead here. EXTENDS will be how you extend object, not MAKE.

That's all going to need review in light of how FENCE! winds up being used. Lots of thought needed there.

Context Inheritance

Rye is context oriented language, where words get meaning in specific contexts, and we can construct contexts in a way that makes the most sense. In code below context circle isnā€™t viewed as a subclass of math, but the programmer decided that it makes sense to define context circle with context math as the first parent.

rye .needs { math } ; check if we have math module built-in

circle: extends math { 
     circuference: fn { r } { 2 * pi * r } 
     area: fn { r } { pi * r .sq } 
 }

print circle/area 10
; prints: 314.159265

do\in circle { print circuference 10 }
; prints: 62.831853

I'm a bit confused because I saw another example on the homepage...

; Rye like Rebol or Lisp has no operator precedence.
; But it has Math dialect which has it and more.
math { 2 + 2 * sqrt ( 12 + ( 24 / 3 ) ) }

This makes it look like MATH returns a number. So EXTENDS would be extending the product of a calculation?

PURE Functions

If you probe builtin functions in shell you will see that most of them show something like [Pure BFunction ...]. A function being Pure means that it has no side effects and that it has referential transparency. This means that with the same inputs it will always produce same output.

Function for creating pure function is pfn. Pure functions can only call other pure functions (builtin or normal).

So I've been meaning to add this (first mentioned in 2020). Especially given that typechecking is done through constraint functions. We don't want to do a typecheck...have a value pass the check and then be marked as not needing to be typechecked again...and then have the function return a different result for the same input on the next call. (The word bindings in the typecheck block also have to be permanent/hardened.)

There's just been bigger fish to fry. But when I did make it, it wouldn't be a special generator. I would just write it as x: pure func [...] [...]. I wasn't thinking of it as a property of the function... but rather a property of the reference.

The thing is--we can't pre-emptively analyze a function to know if it's pure or impure--we can only catch it when it tries to do something impure. Given that reality, why not let you exploit your knowledge that a given parameterization of a function is pure? It might only be impure when you call it in certain ways.

Or maybe the person who wrote EVEN? forgot to make it pure, but it was:

 >> run pure even?/ 10
 == ~okay~  ; anti

That might be fine enough for when it came up, but PURE might even be one of these tricky "I interpret my CHAIN!" functions, so you can modify the call:

 >> pure:even? 10
 == ~okay~  ; anti

Then IMPURE could let you switch out of pure mode to do impure things.

impure [print "Logging!", append log [a b c]]

Seems very fun, just haven't gotten around to it.

BFunction Is "Builtin Function" ("Natives")

probe ?join
prints: [Pure BFunction(1): Joins Block or list of values together]

Just for reference on what that is, in case I see this BFunction term again.

Pipe Words and Op Words

Now we will call the same functions with the same arguments, but weā€™ll use op-words instead of regular words. Op-words begin with a dot. In case of an op-word, first argument is taken from the left.

"Call me" .print
; prints: Call me

100 .inc
; returns: 101

Pipe words behave similarly or in simple cases exactly the same. So you will get the same results in these examples if as we got them with op-words:

"Call me" |print
; prints: Call me

100 |inc
; returns: 101

Two different ways of saying it, but the pipe word does what Ren-C calls "postponement":

Op-word applies to the first value it can on the left, and pipe-word lets all expressions on the left evaluate and then takes the result as the first argument.

Ren-C enfix has "normal", "deferred" (one eval step, like THEN and ELSE use), and "postponed" (evaluate entire left hand side).

I would accomplish this desire with an operator:

1 + 2 + 3 |echo  ; what Rye would be like

1 + 2 + 3 |> echo  ; looks fine to me, for cases where I would use this

This reminds me of why I had |> as a postponing SHOVE operator, it was intended to meet this desire. I never used it.

@earl always said that he considered infix to be a slight compromise that should not be pushed on too much... that the language was fundamentally prefix. I've pushed the boundaries a little bit (type of), but it seems that Rye has decided that infix to accomplish piping is the mecca.

I've already said my piece on this: I don't think it looks very good, and I certainly wouldn't waste lexical space on two ways to say it. I wrote FLOW in a couple of minutes and like it better.

Okay, Survey Complete For Now

Now I know enough that I can read the GUI examples and know what's going on.

It has PURE and three list types, calling (...) GROUP!. So at least we appear to agree on something. :slight_smile: Oh, and EXTENDS too...I need to get rid of MAKE on object! instances.

The context inheritance may warrant a more detailed analysis, but I'd need to build and run it...because the web console just doesn't work.

Red hasn't accomplished anything notable in a long time, IMO (besides some of @hiiamboris's stuff). Rye "deserves" to be there as much as anything.

Where Rye has the potential to be most notable--I'd think--is leveraging Go's infrastructure. Here's some of that:

rye/examples/goroutines/channels.rye at main Ā· refaktor/rye Ā· GitHub

There's also one of the examples in the GUI of using the go keyword to start a goroutine. So that's probably some of the more interesting stuff to look at.

1 Like

Thought I would pick apart some native ("BFunction") implementation code in Rye, just to get the gist.

A WHILE loop would be ideal, since I already contrasted R3-Alpha, Red, and Ren-C WHILE

But... uh, it turns out Rye doesn't have a normal WHILE (at least that I can find). It has produce\while ... which is arity-3 and is some kind of folding operation.

produce/while

Accepts a while condition, initial value and a block of code.
Does the block of code number times, injecting the number first
and then result of block.

Close enough. Here is a usage from Project Euler Problem 2

; 1. while with condition and a starting block
; 2. while's body: sums last 2 elements and concats result to block
; 3. last one is already greater than 4 million
; 4. filter to even numbers
; 5. sum them and print
; result: 4613732

produce\while { .last < 4000000 } { 1 2 }      ; [1]
{ :b .tail 2 |sum .concat* b }                 ; [2]
|head -1                                       ; [3]
|filter { .even }                              ; [4]
|sum .print                                    ; [5]

So... PRODUCE\WHILE is arity-3, and starts out with a seed block of { 1 2 } which is fed into the condition... and then the body. On successive calls the body's result is fed to the condition, and then to the body again. (The condition's result is discarded).

The first time it's fed into the body, it's observed that the last value in the block is < 4000000. So you have to read that sort of like:

 while (block -> [(last block) < 4000000]) 

Because there's a sort of "invisible left hand side" being fed into the block, you can either put an infix operation at the head of your block and pick it up implicitly, or use a "left set-word" as :b to capture it into a variable. Here the condition uses a leading dot to turn LAST into an operation that takes its argument from that sneaky left. But the body has to make a variable.

Okay, well, "de gustibus non est disputandum". :man_shrugging:

(I can't be too harsh and say I'd never use sneaky infix capture, because I have... though I'd not base a language on it: Persistence of Memory: The Console Enfix Trick. But @LkpPo got confused by it so I dropped it.)

On To The Go Code...

Here is Rye's "BFunction" for PRODUCE\WHILE, formatted slightly to fit on the page here:

"produce\\while": {
	Argsn: 3,
	Doc: "Accepts a while condition, initial value and a block of code."
         + " Does the block of code number times, injecting the number"
         + " first and then result of block.",
	Fn: func(
        ps *env.ProgramState,
        arg0 env.Object,
        arg1 env.Object, 
        arg2 env.Object,
        arg3 env.Object,
        arg4 env.Object
    ) env.Object {
		switch cond := arg0.(type) {
		case env.Block:
			switch bloc := arg2.(type) {
			case env.Block:
				acc := arg1
				ser := ps.Ser
				for {
					ps.Ser = cond.Series
					ps = EvalBlockInjMultiDialect(ps, acc, true)
					if ps.ErrorFlag {
						return ps.Res
					}
					if !util.IsTruthy(ps.Res) {
						ps.Ser.Reset()
						ps.Ser = ser
						return acc
					}
					ps.Ser.Reset()
					ps.Ser = bloc.Series
					ps = EvalBlockInjMultiDialect(ps, acc, true)
					if ps.ErrorFlag {
						return ps.Res
					}
					ps.Ser = ser
					ps.Ser.Reset()
					acc = ps.Res
				}
			default:
				return MakeArgError(
                   ps, 3, []env.Type{env.BlockType}, "produce\\while")
                )
			}
		default:
			return MakeArgError(
                ps, 1, []env.Type{env.IntegerType}, "produce\\while"
            )
		}
	},
},

So...

  • We know what arg0, arg1 and arg2 are, but I have no clue what arg3 and arg4 are supposed to be. Go apparently doesn't warn you about unused arguments.. I guess it's either there for interface compatibility with something else, or it's just accidental code.

  • As expected, it loops and makes two calls to the evaluator as it does so... one for the condition, and one for the body. When the condition isn't truthy any more, it's finished.

    • Makes a direct call to the evaluator, so not stackless. (My understanding is that Go itself has a conventional stack and is not stackless.)

    • Searching for stackless in the repository turns up a builtins_stackless.go but I don't understand how what's in it would apply to stacklessness.

  • Seems to bubble up errors out of the condition, and the body.

    • As I've said, Rye seems to be at least aware that handling arbitrary unknown abrupt failures emerging from arbitrary code is a dead end...especially in a language that can abruptly fail over a typo. You need to connect the errors to the function you just called, or it has to be a hard fail.

    • I'm not so sure about the meaningfulness of being willing to bubble errors up out of the condition and the body. Ren-C doesn't do that, e.g. a TRY cannot suppress an error from inside the while:

      ren-c>> block: []
      == []
      
      >> while [take block] [print "Not trappable"]
      ** Script Error: Can't TAKE, no value available (consider TRY TAKE)
      
      >> try while [take block] [print "Not trappable"]
      ** Script Error: Can't TAKE, no value available (consider TRY TAKE)
      

      I'll admit to not having a firm grasp on what the whole policy should be. But when errors can come from multiple places, I just get skeptical that you really know what you're handling. The body may make sense, but I'm pretty sure the condition is a bad idea.

EvalBlockInjMultiDialect()

Excerpting a bit here:

acc := arg1
ser := ps.Ser
for {
    ps.Ser = cond.Series
    ps = EvalBlockInjMultiDialect(ps, acc, true)
    if ps.ErrorFlag {
        return ps.Res
    }

All right, so acc is the accumulator, e.g. the thing that being sent into the block evaluations implicitly to be picked up as the left-hand side of an infix operator or assignment.

The "Inj" is presumably for "Inject" related to the fact that it's passing in this parameter to inject into the evaluation. The flag is for "injnow", so inject now. I don't know what injecting later would be like... and every call to EvalBlockInjMultiDialect() passes true.

func EvalBlockInjMultiDialect(
    ps *env.ProgramState, inj env.Object, injnow bool
) *env.ProgramState {
    switch ps.Dialect {
        case env.EyrDialect:
            return Eyr_EvalBlockInside(ps, inj, injnow)
        default:
            return EvalBlockInj(ps, inj, injnow)
    }
}

So the "ProgramState" can have the concept of being "in a dialect". Hence when something like WHILE calls EvalBlockInjMultiDialect() it might evaluate that block differently based on what dialect is in effect. And at the moment, the one dialect supported is Eyr...a sort of Reverse Polish math thing.

But the average case it calls the non-dialected EvalBlockInj()

Reading it... really the "inject now" is just due to the fact that there's no "Optional" state on the injected value to say there's no value. So as it evaluates each expression, it has to pass a pair of values: the left hand side, and whether the left hand side you got passed is meaningful.

I See Where The Left-Set-Word Motivation Comes From Now...

What it seems to come down to is that infix is a sunk cost, and it's an attempt to reuse that.

Hence the thinking here is: "so long as there have to be mechanics to feed a value from a previous evaluation in to the next anyway (for operations like +, *, etc)... why not use that already-existing mechanism to slipstream parameters into block evaluations? It's basically 'free'."

To me...that's the implementation-tail wagging the language-dog. :dog2:

It's a trick that obfuscates what's there, and breaks other tricks. For instance: Ren-C has the ability to ELIDE expressions so they vanish completely.

>> 1 + 2 elide print "like this"
like this
== 3

But if you are running a block that's been seeded with this value, and all your block says is [elide print "I think this block is vanishing"], you'd be leaving the residual of the thing you fed into that block.

If I sat down and looked...I imagine I could rattle off a bunch of other things it breaks. But abstractly speaking, I happen to like having constructs that can detect the "reality" of if there's anything to their left or not.

In the past I've had things like a mode-switching +, that became variadic with nothing on its left:

>> x: 1 + 2
== 3

>> x: (+ 1 2 3 4)
== 10

And so when this infix seeds evaluations with a value, anything like that would be broken too.

I'm empathetic to the idea that when you're sitting down and hacking on a language, you might find a trick like slipstreaming infix into a block evaluation, and think it solves your problem. But when you take off the implementation glasses--and look at what it really does in terms of distorting "what's there" in the code--I don't think it's a very empowering approach.