On 2025-03-21, bart <
bc@freeuk.com> wrote:
On 20/03/2025 23:45, Kaz Kylheku wrote:
On 2025-03-20, Waldek Hebisch <antispam@fricas.org> wrote:
bart <bc@freeuk.com> wrote:
>
In this case, just write it like that, and only adjust it for the
somewhat different syntax:
>
func foo:int =
let int c := c1(10)
let int b := c + c2(2)
let int a := b+c3(c)
bar()
baz()
return c
end
>
>> In your description you wrote that declarations can be written
"out of order" and compiler will rearrange them in correct
order. That looked like great opportunity to write obfuscated
code.
I made a language feature like that: mlet.
https://www.nongnu.org/txr/txr-manpage.html#N-2B3072E9
This allows for circular references in order to support
the construction of lazy objects:
1> (mlet ((a (lcons 1 b))
(b (lcons 0 a)))
(take 20 a))
(1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0)
>
I don't understand what's going on above; the example here is a bit
clearer, other than that z at the end:
What's going on is that
1. A variable that is not accessed is not initialized at all.
When a variable is used for the first time (dynamically;
determined at run time as you correcly observe) the
initializing expression is evaluated then. Here, we don't
see the (print 42) side effedt in the case where x is not
used:
1> (mlet ((x (prinl 42))) x)
42
42
2> (mlet ((x (prinl 42))))
nil
2. lcons is also a lazy construct; it is not a function but
a macro operator. It constructs and returns a lazy cons
cell which is associated with a lambda function that will
fill the car and cdr of the lazy cons cell when either of
those fields are accessed for the first time.
So (lcons 1 b) returns a lazy cons which is uninitialized.
When we access it, 1 is put into the car, and b is evaluated
and put into the cdr.
3. The first thing to be evaluated is the body expression (take 20 a).
This causes (lcons 1 b) to be evaluated and a to take on that
value. The take function will traverse into that cell, triggering
b's initializer (lcons 0 a) being evaluated.
When the take function steps into that second cell, triggering
its lazy initialization, variable a already has a value,
pointing to the first cell. So the circular list is closed.
take then just keeps walking the finished circular list, until
it obtains 20 items.
(mlet ((x (+ y 1))
(y (+ z 1))
(z (+ x 1)))
z)
>
But this looks like something that goes on at runtime. In the language
above, it's all dealt with at compile time. If I create a similar example:
Serious functional languages which are single-mindedly dedicated to lazy
semantics will hoist a lot of this kind of processing to compile time.
I could make a non-lazy binding macro which determines the dependencies among
the expressions, detects and diagnoses cycles, and then emits a regular let
based on a topological sort of the dependencies.
The tools are there; there is an API by which a macro can ask what are the free
variable references emanating from a piece of code, so we can know exactly
which of the variables in the circular let construct are being referenced by
which initializing expressions, and build a graph from that.
There is just not use for such a thing. Programs already require us to jump
backwards and forwards to understand the flow due to the presence of functiond
definitions, and looping constructs; we don't need that at the statement level.
mlet allows any order because that's a side effect of handling the mutually
referencing lazy definitions, not because that's the motivating use case.
>
const x = y + 1,
y = z + 1,
z = x + 1
>
then it is the compiler that reports a circular reference.
>
Otherwise, in my dynamic (and non-lazy) language, circular references
are allowed at runtime, but it is object references rather than names:
>
a ::= (1,2,3) # create two short lists (::= makes a mutable copy)
b ::= (4,5,6)
>
a &:= b # append each to the other (concatenate is
b &:= a # well-behaved)
So, if you had a category of list cells that are lazy, you could
bring about this circularity without ever perpetrating a mutating
assignment. (Because it's hidden in the implementation: when the lazy
cell is accessed, its fields are de facto assigned then, but in the abstract
semantics, it's understood as not being initialized.)
1> (let (a b)
(set a (lcons 1 b))
(set b (lcons 0 a))
(take 20 a))
(1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0)
Here we still have assignments to bring about the circularity, but the
assignments are just of lexical variables, not of the list structure.
The mlet solves the problem of hiding the variable assignments,
in its own way.
There is a cheaper way to solve the problem for the above case;
we can have a "letrec" construct (similar to what exists in Scheme) which
literally does what we did above; i.e. one which takes:
(letrec ((a (lcons 1 b))
(b (lcons 0 a))
...body...)
and writes, effectively, this code:
(let (a b)
(set a (lcons 1 b))
(set b (lcons 0 a))
...body...)
that is to say, the initializing forms have all the variables in scope, and are
evaluated from left to right. An earlier initform evaluating a later variable
will see a nil value. But here the lcons forms are lazy, and so do not evaluate
the variables. In other words, we don't need the laziness of mlet; lcons is
enough.
-- TXR Programming Language: http://nongnu.org/txrCygnal: Cygwin Native Application Library: http://kylheku.com/cygnalMastodon: @Kazinator@mstdn.ca