Experimental automatic resource control
or
Recreating Rust from a different direction
A huge range of problems in software development find their root in one cause: Memory is just a big array (to the ISA, at least), and the underlying hardware doesn't have any idea what you are using it for.
There are common ways to handle this:
- Manual memory management (e.g. C, and most pre 1995 languages) - The programmer must keep track of memory use and lifecycles. Any mistakes might cause runtime failure but are not guaranteed to.
- Garbage collection (most post 1995 languages) - The programmer is responsible for allocating memory, but a separate process tries to determine if memory is still in use, and deallocate when needed. This removes a range of errors.
Most GC languages still have manual management for interop with the OS etc, including things like file handles and threads. GC languages are susceptible to leaks, and have various issues with cycle management and strong-vs-weak references.
Far less common is lifecycle tracking, seen primarily in Rust lang. This uses language and compiler features to track the life cycle of BOTH allocated memory and resources. It requires you to be explicit when rules are broken.
This closes off another, wider range of errors. It greatly increases burden of expression on the programmer, and complexity of the language and tooling.
However, ALL of these languages have a second, fully automatic memory management system: the call stack. This shuffles small amounts of data around using the languages' lexical scope to define the memory scope of local primitive variables, function arguments, and return values.
This is an experiment, to see if it is practical to use scope as the only lifecycle management system. We will try to gain Rust's safety without adding management to the language -- but only by taking away some freedoms.
- functions can never return values
- every constructor must have a destructor
- everything is deconstructed when it goes out of scope
- memory addresses are never available
The human-readable language will be a simple S-Expression based one, as the focus is on the memory and resource management.
The runtime is a simple interpreter of the AST for the same reasons.
-
to get a result from a function, you must pass in a container.
-
we must have a way to create something in a container's scope.
-
every non-primitive type must have a constructor and destructor
- there is no way to deallocate a single thing -- everything in scope goes at the end of that scope
- most destructors that don't hold external resources are no-ops.
- inside-scope allocators can be very simple bump-type
-
any external resources must be opened during constructor and closed in destructor
-
instances are only accessible from their container? (Construct into container first, then alias -- aliases cannot be added to containers)
-
every type can be serialised and deserialised perfectly, including containers.
-
instances can only be moved from one container to another by copying.
-
instances can be passed as arguments to a function even if they are in containers.
-
We get some basic type classes:
Primitive
-- bottom value of types. Things like byte, int, float, etc. Could be any value stored in a contiguous series of bytes with a length that is known and is not variable, maybe including serialised forms?Container
-- roughly equivalent to reference types, but holds an internal reference to its scopeAlias
-- refers to a value inside a container. Can be copied to make a primitive, but cannot be added to a container directly
-
We probably don't want to allow declaration of containers without making an instance
-
There is no need for a null value, as empty container does fine.
-
There will need to be some built-in containers, and they need to be good and flexible
map
hashmap (variable size)vec
multi-vector (stack,queue,vector) (variable size)maybe
single-or-none container (fixed size)array
basic pre-allocated list (fixed size)- ???
char
NOT byte, but a uint16/uint32 that can be represented as one or more uint8 (fixed size)
-
What happens during recursion? Repeatedly opening memory scopes should work.
-
Lambda functions should be fine, but only available as
alias
class, preventing them from being stored for future use. -
Spawning threads only makes sense if the caller waits for them to end. This probably works fine with a supervisor->worker model with message passing.
- If a lexical scope does no allocation in itself (does not include calling a container's allocator), then there is no need to start or end a memory scope.
- The actual implementation of containers could be optimised to the way they are used -- i.e. have several types of Vector, and pick based on e.g. do we ever dequeue?
(def
database-query (target-container query)
(set conn connect-to-database) ; creates and opens db connection
. . .
(alias item (target-container new)) ; 'item' is NOT in our scope, as it's part of 'into', and aliased here
(conn :query-into item) ; alias passed in, as a reference to a fixed primitive
; db connection is closed here
)
(
(set x (maybe.new)) ; binds with scope here
(database-query x "SELECT COUNT(*) FROM table")
(if (x hasValue)
:then (log (x value))
:else (log "Could not read value")
)
; 'x' AND all its content gets destroyed here
)
(def work (work-queue)
;...do stuff...
)
(
(set q (vector.new))
;... fill queue...
(set t1 (thread.new method- work data- q))
(set t2 (thread.new method- work data- q))
(set t3 (thread.new method- work data- q))
(thread.join t1 t2 t3)
)
Rwy'n byw yng Nghymru, so I would say it like "Lunar", but you do you.
Syntax/Semantic: Copy assignment and Reference assignment are different and incompatible operations.
OOP langs:
Object x = y; // x becomes a reference to the same object as y
int a = b; // a is allocated as a new int with the same value as b
Lwnr:
(ref x y) ; x becomes a new reference pointing to same object as y
(set a b) ; a is allocated as a new int with the same value as b
(ref a b) ; syntax error
(set x y) ; syntax error
Syntax: implicit container creation. Create a default container for the argument.
other languages
var x = listA.concat(listB);
int c = a + b;
without sugar:
(new list x)
(new int c)
(concat x listA listB)
(+ c a b)
with sugar:
(concat @x listA listB)
(+ @c a b)
https://buttondown.email/hillelwayne/archive/microfeatures-id-like-to-see-in-more-languages/
A tag that can be added to a list/expression without changing how it evaluates, but can still be accessed when not evaluating.
For example, an s-expression for a vector image
(draw
(fill "red")
(circle [0, 0] 42 )
)
could have a tag added to record UI selection:
(draw
(fill "red")
(circle [0, 0] 42 #{:selected=true})
)
A type that acts as the parameter list of a lambda expression.
Maybe not for s-expressions, but it's a combination of lambda and the basic with
statement.
Purpose is to make more natural lambda pipelines.
Example from C#:
// Using query expression syntax.
var query = from word in words
group word.ToUpper() by word.Length into gr
orderby gr.Key
select new { Length = gr.Key, Words = gr };
// Using method-based query syntax.
var query2 = words.
GroupBy(w => w.Length, w => w.ToUpper()).
Select(g => new { Length = g.Key, Words = g }).
OrderBy(o => o.Length);
Note how the 'methods-based' section is full of w => w.
, where the 'query syntax'
carries parameter names between parts.
So, we could do something like:
From(words, :word)
.GroupBy(word.Length, word.ToUpper() :gr)
.Select(new { Length = gr.Key, Words = gr })
.OrderBy(o => o.Length);
or have some kind of syntax sugar for single-parameter lambdas:
words
.GroupBy(@.Length, @.ToUpper())
.Select(new { Length = @.Key, Words = @ })
.OrderBy(@.Length);