another attempt
It will come as no surprise to people who know me that using <> as a grouping mechanism for any kind of information is distasteful. It visually conflicts with less than, greater than, etc. It's already visually conflicting with select> and do>. I made all those potential changes in the last post to avoid the visual conflict of | with piping. So why then introduce another visual conflict with < and > ?
So let's give it another go. To solve this all we need to do is free up one of the other grouping symbols. We tried with () previously and it worked.. okay. But let's try something else. We have [] for code and {} for code focused on a new receiver.
Let's change {} to something else. What exactly I'm not sure but let's assume we have solved that and we can now use {} for lists and maps:
{1, 2, 3}
{Integer | 1, 2, 3}
{1}
{}
{Integer|}
{"a": 1, "b": 2}
{String, Integer | "a": 1, "b": 2}
{String, Integer|}
It's clear and succinct. It is close to ANSI Smalltalk {1. 2. 3}. I like it - now we need to solve the object initialisation problem that {Person | name: 'Bob'} let us do.
The first clue is the use of a key that either is or isn't a variable. When we write something like this: {a: 1, b: 2}
what is the type of the key? it's that strangeness that allows us to know we're defining a class, eg:
RGBA32 {
r: UnsignedInteger8,
g: UnsignedInteger8,
b: UnsignedInteger8,
a: UnsignedInteger8 = 255}
If we instead say that they are message sends then we could re-interpret the type field to be a new instance of the typeid, not Array of: typeid. Eg:
{Person | name: 'Bob', height: 2 meters}
Because the keys are not variables, they must be message sends to the receiver. Being inside the {} we have changed receiver implicitly and so we can create a new Person on the stack and initialise it correctly.
If there is no type we are creating an anonymously typed map of information. We cannot assign it to anything but in the right context the compiler can know what to do with it. If there's a type on the left-hand-side then it will work:
bob: Person = {name: 'Bob', height: 2 meters}
If there's an expected type for the method we're calling and there isn't any ambiguity about which method we're calling, it will work:
[self foo: bob] [bob: Person | ...do stuff...].
self foo: {name: 'Bob', height: 2 meters}
Next we add the ability to use this syntax against an expression to reconfigure an object instead of making a new object on the stack. The left hand side can be a variable or a unary-message or a subexpression.
the-person := {Person | name: 'blob'}.
the-person {name: 'Bob'}.
Next up - what if we wanted to programmatically construct keys? well for that we can require an explicit subexpression:
name-key := 'name'.
the-person := {Person | (name-key): 'Bob'}.
Finally we need a way to define classes, enums, etc... this is slightly different in that none of the keys exist so it's not technically method calls either. It's something different again.
The compiler is running in a layer lower than the runtime code will be compiled at, so it can re-interpret the contents of the {} to mean 'create a new type' - the tricky part is reconciling that with other special syntax like public, private, package.
It might be time to look at making something more sophisticated. Consider the idea of a ClassMember - an object used to describe a member of a type, or a variable of a class. It will have the following fields: name, type, scope, default, and may be description.
A method that makes a class could take an Array of: ClassMember as a parameter and then we can write some reasonable clean code:
class: 'RGBA32' members:
{{name: 'r', type: UnsignedInteger8},
{name: 'g', type: UnsignedInteger8},
{name: 'b', type: UnsignedInteger8},
{name: 'a', type: UnsignedInteger8, default: 255}}.
It's a bit wordy but it does solve all the problems and allows us to have pub, private, package scope, default values, etc... so why does it feel so wrong?
Probably because the description of a class should be succinct and easy to write. Like this:
RGBA32 {r, g, b: UnsignedInteger8, a: UnsignedInteger8 = 255}.
One clue we have here is that the thing on the left-hand-side, the 'RGBA32', is a name we don't know. It's not a variable but it could be interpreted as a receiverless method send. That tells us we do need something new to describe types if we want a short hand syntax.
We could start by re-using assignment. But we need types to be unordered so that they can refer to each other in loops. That's essential. One piece of syntax we haven't created yet in STZ is the :: syntax to define something immutable.
This is used in other languages like Jai, Odin, Go, for constants:
pi :: 3.1415
It would be an error to attempt to change the value of pi anywhere else in the program. It can be defined once and only once but it can be used anywhere before or after. Its position in the source code file does not matter.
Color :: {
#requires: {
[self r] [self -> Number | ].
[self g] [self -> Number | ].
[self b] [self -> Number | ].
[self a] [self -> Number | ]}}.
RGB24 :: {...Color, r, g, b: UnsignedInteger8}.
RGBA32 :: {...RGBA24, a: UnsignedInteger8 = 255}.
[rgb a] [RGBA24 -> UnsignedInteger8 | 255].
Subtyping becomes very simple if we use ellipsis include the types we want as part of our new subtype. Now all methods that match to type RGB24 will work for an RGBA32, and methods that match to type Color work for both RGB24 and RGBA32.
There's a different between a parameter to a method that is a copy-on-write for your own use and something like pi or RGBA32 which are defined as constant. Constant cannot be changed after its initial declaration, while a parameter that isn't a reference is implicitly copied if you change it. There's value in being able to specify a parameter to a method is constant to avoid accidentally trying to change it when you didn't intend that as part of your interface.
The usual syntax for declaring a variable is now:
variable_name : type = value
variable_name : type : constant-value
variable_name := type-inferred-value
variable_name :: type-inferred-constant-value
:= allows redefinition, but you cannot change its type, while :: does not allow redefinition. You cannot assign to it with := later either. Anything we see as a class definition between {} can be used in the type section of a method or block definition.
But there's no way to specify that a variable you're declaring is constant. We might need to bastardise the syntax a little bit here and return to it later if it becomes a problem. A constant version of a type is the super-type of the type. For every Person there is a Constant-Person where modifying it is not allowed because the setters were never defined.
If on Person I wanted name to be public but name: to be uncallable because it's meant to be immutable, yet I still need a way to be able to set name initially, that's when I'd set the scope to private, rather than package or public.
All that is to say that specifying a parameter in to a block or method as constant is an entirely different thing to any of that and needs its own way of being specified. We want the Constant-Person, not the Person. Previously we declared these things with the ! operation and I later took that out deciding it wasn't that useful for the language.
Once again we're back at that decision and I think it still holds true. But consider this:
ValueHolder :: {value: Float}.
pi :: {ValueHolder | value: 3.1415}.
[self add-one] [ValueHolder -> ValueHolder | value: value + 1].
new-pi := pi add-one.
pi hasn't changed. It's still the same ValueHolder it was before. We passed in a copy-on-write to add-one and then added 1 to it. new-pi is now 4.1415 and we can use it if we wanted to, but pi is safe.
Consider this though:
ValueHolder :: {value: Float}.
pi :: {ValueHolder | value: 3.1415}.
[self add-one] [&ValueHolder -> ø | value: value + 1].
pi add-one.
Now we really have changed the value inside of pi, something we had declared constant. The problem, then, is that add-one can be called. It should not. It should be a compiler error at that point.
We can rely on the compiler turning it in to a pointer-reference when passed by value in an auto-optimisation for us, or simply passing the value around as-is (since it's a single value inside a class it become transparent at compilation).
The only reason we'd want to specify 'const' to a parameter of a method or block is to stop ourselves thinking we're modifying something when we're not. To explicitly say "I never intended to modify this, stop me if I do". That is a handy tool. It changes nothing in the way the language operates but does help a developer when writing a program.
We'd need a syntax for it. Here are three potentials:
[self add-one] [!ValueHolder -> ø | value: value + 1].
[self add-one] [self: !ValueHolder -> ø | value: value + 1].
[self add-one] [ValueHolder const -> ø | value: value + 1].
[self add-one] [self: ValueHolder const -> ø | value: value + 1].
[self add-one] [::ValueHolder -> ø | value: value + 1].
[self add-one] [self :: ValueHolder -> ø | value: value + 1].
I kind of like the :: but it is exceptional when there's no key before it. The ! we've tried before I really don't like it. Sending const to the type to get a constant version of the type works too but it's not actually what we're doing. We want the variable to be constant, not the type.
So for the moment we will go with :: as the way to explicitly state you do not intend to change the parameter. That gives us three ways to define a parameter (or return value) to a method or block:
// copy-on-write
name: Type
Type
// read-write
name: &Type
&Type
// read-only
name :: Type
::Type
This makes the language slightly more complicated than I had intended and may be in the final cut we leave off constant parameters. But for now we'll include it in the language specification.
This also means we're now ready to update the language. The <> disaster has been avoided and we now have an explicit syntax for lists, maps, and also object-mutation and stack-object-creation.