This is the place to ponder the syntax things that I kindasorta dislike or want to think more about. The list so far:
sum
token for sum types, it's not uncommon to have variables named sum
imo, but enum
is reserved for things that are actually enumerable and there really aren't any other good words for itstruct x: T, y: T end
, rather ugly to put inline as the type in let
exprs and such..T^[4]
vs [4]^T
for array[4] of pointer to T
. To me the former Feels Better, but the latter is slightly easier to parse (I think?) and maps better to how you would write it in English.Vec(T)
vs Vec[T]
. The former looks like a function call, the latter looks like an array index, and I'm 100% happy with neither. If we do the latter it gets weird with our array syntax as well, and raises the prefix/postfix question again; one way or another we need some kind of nesting. For a complex thought experiment, Result(T^[4], E)^
is "pointer to a Result( Array[4] of pointer to T, E)`. ...In the end I think I'll keep the postfix composition.let foo: T = ...
vs let foo T = ...
. And, more significantly, fn foo(x: T, y: T2): T3
vs fn foo(x T, y T2) T3
. I originally had colons, then I said "aren't these actually unnecessary clutter?" and took them away, and lo and behold it works fine.fn(T, T2) T3
for the type which is... okay but kinda noisy, and fn(x T, y T2) T3 = ... end
for a function expression which is a lot more noisy when you're going to have these things inline in a function call or something. So, we need a shortcut syntax or something. But I kinda hate the Rust/Ruby |x| expr
style for uncertain reasons -- they don't nest well, they don't look like other function decls, doing pattern matching in the args results in |(a, b), c|
which my eye dislikes, etc. But I haven't bothered putting a lot of thought into what might be better.Asked various people about postfix vs. prefix type composition. Zig and Go both do prefix, so it's maybe worth doing that and making it a trend. Rust does somewhat bonkers prefix+delimited, which is consistent but which I'd like to avoid, though Zesterer had some semi-sane points in its favor. The conclusion seems to just be be "prefix matches how you would speak the type in English" and "anything is better than C's spiral of madness".
I should probably stick with what Go and Zig do, and just write Zig code until it no longer feels like it's backwards.
Import syntax keywords! From #52: matklad proposes "promote pub(crate) visibility, it absolutely should be one keyword. I would suggest the following set of visibility:
api
for "visible outside the current CU", equivalent to Rust'spub
with-Dunreachable-pub
,pub
for "visible throughout CU" (= pub(crate)), and nothing for "visible in current file"."So basically:
-- private fn foo() = ... end -- public to the crate/lib/whatever pub fn bar() = ... end -- part of the exposed API api fn bop() = ... end
I love it. BUT, it takes
pub
/public
which is used for "part of the exposed API" in basically every other language that uses it, and makes it mean something else. I might ponder usingpub
for "visible outside the current CU" because that's what it means in lots of other langage, and maybeloc
(local) or something forpub(crate)
. But the siren song ofapi
as a keyword is strong, especially with its semantic link to signature files using the .api extension. So that would look like:-- private fn foo() = ... end -- public to the crate/lib/whatever loc fn bar() = ... end -- part of the exposed API pub fn bop() = ... end
Can we split the difference by just not using
pub
at all anywhere?-- private fn foo() = ... end -- public to the crate/lib/whatever loc fn bar() = ... end -- part of the exposed API api fn bop() = ... end
This seems like a decent local minimum, albeit a little weirdly foreign-looking. Still, worth pondering to see if we can find a better local minimum.
Prefix types implemented in https://hg.sr.ht/~icefox/garnet/rev/337fed657920b1f1f08d93dbcc78ef65ad3b05be . Easier than I expected and tbh a little nicer to parse.
Syntax for type parameters passed to functions or declared in functions is
fn foo(|T| x T, y I32)
and is calledfoo(|Bool| thing, thing2)
. I would quite like to get rid of the preceeding|
but I tried in commit bb0c04c9db21 and hoo boy does it turn out bad. Our type syntax is actually quite close to our expression syntax, withFoo(thing)
being a function call or a type constructor and[4]Bool
being a single-element array followed by an ident or an array of 4 bools. In contrast, having||
as delimiters is literally trivial. So if some parser genius comes by and figures out how we can call itfoo(Bool | thing, thing)
then I'd be a fan, but until then I will stick to my "do absolutely nothing fancy" guns and just put up with the slightly cluttery syntax.This relates to the "syntax for type parameters, say
Vec(T)
vsVec[T]
" bullet-point as well, since that's part of what makes this hard. I want to keepVec(T)
for now but you could imagine it beingVec|T|
if you try hard enough. Don't especially want it right now.
Added a Rust-y implicit function return type of unit in commit b081842f4b88, so
fn foo() {} = ... end
can now just befn foo() = ... end
. I've been thinking about it for long enough that it's probably a good idea.Thonk for closure syntax: Haskell-y
\a, b -> c = ... end
? The->
and lack of parens is foreign.\(a I32, b F32) c = ... end
maybe? That'd basically allow\
orlambda
maybe to be a synonym forfn
. Allow the types of the params to be inferred? This is super convenient but it is also a gigantic pain in the ass to actually do, and is occasionally somewhat error-prone. Allow theend
to be elided if it's only one expr?Not sure I have any good solutions there.
Re:
api
/loc
: FWIW IIUC in Go, theloc
kinda got calledinternal
. This sounds reasonable to me, in that it marks an entity as "internal to the module/package/library/crate/whatever".
Hmmm,
internal
is reasonable, though a little long for something that's going to be written frequently. And the obvious shorthand ofint
is... a bit too ambiguous.I'm probably just being too hard to please.
We need a closure syntax more and more and https://brevzin.github.io/c++/2020/06/18/lambda-lambda-lambda/ is a bit of a nice case study. Looking at it, I still for some reason like Haskell's
\a, b -> ...
or something along those lines, of all things. And we don't need anend
at the end of it either, we just do the Rust-ish thing and make it take one expression that can be ado ... end
block; this is a case where consistency should bow to convenience.Here's a thonk about implicits, which always scare me: what if calling a function with implicit args used different syntax than without? So say you have:
fn foo(implicit x Thing, y Bool) = ... let my_thing = Thing ... foo(my_thing, true) -- No implicit args passed \foo(true) -- equivalent to above call
The goal being that the caller (or really the reader) knows whether or not there's implicit arguments being given to the function.
Another take on it might be annotating the values that are allowed to be used for implicits. (thanks thegreatcatadorer). So the above code would instead be:
implicit my_thing = Thing ... let other_thing = Thing ... foo(my_thing, true) -- No implicit args passed foo(true) -- calls foo(my_thing, true), instead of failing with a resolution error.
Hmmm, so for lambdas, what if we had
fn(...)
require a block but\...
be for a single expression? So you have:fn(x I32) I32 = x end \(x I32) I32 = x
The
=
might be ambiguous or confusing if you have something like:let mut y = thing let set_y = \(x I32) I32 = y = x
though that's a little insane anyway, but one might consider
->
instead a lalet mut y = thing let set_y = \(x I32) I32 -> y = x
which is a nice nod to the ML/Haskell-y origin as well. I'm not sure whether I would want to elide the
()
and just have:let set_y = \x I32, other F32 I32 -> y = x
but instead maybe add a delimiter?
let set_y = \x I32, other F32: I32 -> y = x
That starts feeling un-Garnet-ish though, functions are always
()
and types are alwaysval Type
instead ofval: Type
as it used to be. Maybe do something like Rust closures and infer type params:let set_y = \x, other -> y = x
but that also kinda feels un-Garnet-ish.
So for now let's keep it as:
let set_y = \(x I32, other F32) I32 -> y = x
and maybe remain open to trimming down type annotations and punctuation further in the future.
Right now type parameters are written as
Foo(T1, T2)
, which gets rather visually cluttered to me when you end up writing lots of them. Can we make them beFoo[T1, T2]
without being ambiguous with[]T
and[3]T
for arrays?Let's see:
[]Foo[T]
[3]Foo[T]
Foo[[]T]
Foo[Bar[[3]^Something[T]]]
You know... I actually think that works.
Edit: Implemented this, nothing has exploded yet. Straw poll on /r/pldev discord suggests most people like it.
So I was playing around with interfaces for conversions and hypothetical associated types, using Rust's
From
andTryFrom
traits as design places to start:--- Infallible conversions. --- Don't worry about the annoyance of having to repeat [In, Self], that'll get inferred again someday. type From[In, Self] = struct[In, Self] from: fn(In) Self end --- Version with associated types... type From[In] = struct[In] type Self from: fn(In) Self end --- Fallible conversions. type TryFrom[In, Self, E] = struct[In, Self, E] try_from: fn(In) Result[Self, E] end --- Version with associated types type TryFrom[In] = struct[In] type Self type E try_from: fn(In) Result[Self, E] end
And then we try to implement these interfaces for some simple types:
--- Example impl... const FromI32U32 From[I32, U32] = struct { .from: fn(input I32) U32 = bitcast(|U32| self) end } --- Impl with associated types... const FromI32U32_assoc_types From[U32] = struct { type Self = I32 .from: fn(input U32) Self = bitcast(|I32| input) end } --- Functor to implement TryFrom for anything that impl's From. fn try_from_from(|In, Self| from_impl From[In, Self]) TryFrom[In, Self, Never] = TryFrom(|In, Self, Never| { .try_from: fn(input In) Result[Self, Never] = Result.Ok(from_impl.from(input)) end }) end --- Version with associated types --- This probably doesn't work 'cause we kinda don't specify what Self and Err are in the output type... --- Not quite sure how to best express that. fn try_from_from_assoc_types(|In| from_impl From[In]) TryFrom[In] = TryFrom(|In| { type Self = from_impl.Self type Err = Never .try_from = fn(input In) Result[Self, Err] = Result.Ok(from_impl.from(input)) end }) end
And this morning, almost literally in a dream, I finally got around to asking myself the question "what would a dedicated syntax for these look like?" The result was something like this:
interface From[In, Self] = from: fn(In) Self end interface From[In] = type Self from: fn(In) Self end interface TryFrom[In, Self, E] = try_from: fn(In) Result[Self, E] end interface TryFrom[In] = type Self type E try_from: fn(In) Result[Self, E] end
Not much difference there, so probably not worth it, but for the impl's we could do something like:
impl FromI32U32 From[I32, U32] = fn from(input I32) U32 = bitcast(|U32| self) end end impl FromI32U32_assoc_types From[U32] = type Self = I32 fn from(input: U32) Self = bitcast(|I32| input) end end impl fn TryFromFrom(|In, Self| from_impl From[In, Self]) TryFrom[In, Self, Never] = fn try_from(input In) Result[Self, Never] = Result.Ok(from_impl.from(input)) end end impl fn TryFromFrom(|In| from_impl From[In]) TryFrom[In] = type Self = from_impl.Self type Err = Never fn try_from(input In) Result[Self, Err] = Result.Ok(from_impl.from(input)) end end
So the conventional "method enclosed in block" syntax is fine and dandy and not a huge deal; I've considered putting that into the normal struct syntax more than once. The non-functor
impl
blocks really are almost identical to the existing syntax and thus irrelevant. Making functor names look like existing type constructors by convention (CamelCase instead of snake_case) is probably already a good idea. So with that all that is really happening is a shortcut for making a function that does nothing but return a single struct. Which sounds kinda nice to have anyway tbh! So what if we just renamedimpl
toconst
here, and hadimpl
be a shortcut for "this is just making some struct, figure it out"? Something like this:const fn TryFromFrom(|In| from_impl From[In]) TryFrom[In] = impl type Self = from_impl.Self type Err = Never fn try_from(input In) Result[Self, Err] = Result.Ok(from_impl.from(input)) end end
Pretty minor difference tbh, and upon consideration I like the previous version a little more 'cause reading
impl
first tells you more about what the hell is going on. Still! it's not terrible~
Also it might be time to give in and make
fn TryFromFrom(|In| from_impl From[In]) ...
just befn TryFromFrom[In](from_impl From[In])
. I still worry about making my type syntax ambiguous though (once bitten twice shy), and breaking the very real analogy of type parameters to function arguments. Butfn Foo[T]() Rettype
andFoo[T]
and[3]T
are probably still fine. (Fuckit, maybe just make arrays beArr[3,T]
but that'll probably get annoying fast.) It does still fail at my low-key goal of "make typing generics require less fiddling", but that may be a bit of a lost cause in this kind of syntax anyway.Also, while it breaks the "function args and rettypes use the same syntax" a little, the arrow in
fn Foo() -> Rettype
does give the eyes something extra to grab on to while scanning function signatures. Contrast:impl fn TryFromFrom[In](from_impl From[In]) TryFrom[In] = type Self = from_impl.Self type Err = Never fn try_from(input In) Result[Self, Err] = Result.Ok(from_impl.from(input)) end end impl fn TryFromFrom[In](from_impl From[In]) -> TryFrom[In] = type Self = from_impl.Self type Err = Never fn try_from(input In) -> Result[Self, Err] = Result.Ok(from_impl.from(input)) end end
Might be better once syntax highlighting exists. Just gotta avoid the Rust problem of making pattern matches then need
=>
because fuck that shit. I think using the bar as a delimiter rather than a comma as a separator solves that:match foo with | X {a, b} -> ... | Y {foo: 3, bar: thing} -> ... end
Heck, I think using
{}
instead of()
for tuples solves it already, but let's pay some respect to our OCaml roots here; I like the prefix-bar-per-match-arm already.
FWIW, Go has no arrow in function decls/defs, and personally I never saw this as a problem:
func fooBar(arg1 string, arg2 []int, arg3 MyType) string { }
Also FWIW, personally I like Go's "non-embedded" method definitions, because
- reducing rightwards drift (both when writing and reading)
- for longer method bodies (which tends to happen In Real Life™), personally I find it easier to find out what type the method belongs to in such case when reading, only needing to find the method's beginning, vs. having to find the beginning of the whole block where the method was defined (and possibly need to jump over many method, field, and subtype definitions to do that). Sure, an IDE might help with that in theory (though haven't encountered one that actually would - usually need either some folding/unfolding, or to find a separate window with "project structure" and still scroll up in it).
type MyType struct { .... fields .... } func (t *MyType) Method(arg1 string, arg2 OtherType) (RetType, error) { .... body .... }
That said, obviously that's just a Random Internet Person's bikeshedding opinion ;)
Hey, that's exactly what this issue is for! It's fine to bikeshed cosmetics as long as it doesn't get in the way of other stuff.
reducing rightwards drift (both when writing and reading)
Valid! I've definitely hit some narsty rightward drift when experimenting with stuff, and I am not a fan:
const fn ArrayIterator[T](): ITERATOR = struct { Self: SliceIterator[T], Item: T, next: fn(self: Self^uniq): Option[item] = if self.idx == self.slice.len then None else let itm = self.slice[self.idx] self.idx += 1 itm end end } end
ugh
for longer method bodies (which tends to happen In Real Life™), personally I find it easier to find out what type the method belongs to in such case when reading, only needing to find the method's beginning, vs. having to find the beginning of the whole block where the method was defined
Also valid, but I think Go and Garnet are kinda different here 'cause afaik Go will always let you call a method through dynamic dispatch. Or rather, type-driven dispatch in general. In Garnet interfaces, you don't really call a method on a type, you call a function contained in a struct, and that struct is very much a real object that acts like any other function arg. So being able to define what fields actually exist in this struct in multiple pieces spread around your code Feels Wrong. Both of them turn into vtables; Go assembles this vtable for you under the hood (at runtime, sometimes), and in Garnet you define the vtable and get to treat it as a first-class object. But, as long as those definitions can't cross compilation units, there's nothing actually stopping us from doing that.
Syntax-wise, this could be something like C++ style methods, so you can write:
fn MyType.method(arg1 String, arg2 OtherType) = ... end
It contains pretty much the same information as the Go approach, in a somewhat more Garnetish way.
Ah, by the way... not strictly "syntax" in the narrow sense, but more like wider "language UX" thing: I'd be extremely grateful if you made sure to allow code written in "top-to-bottom" order. I.e. "main" function first, then helper functions, then their helper functions, etc.
I learned this order of coding in Go, and I find it the most useful/practical for the people reading the code: the "main" function is both the app's entry point, so it is helpful and fitting to have its implementation be the first thing seen when reading a file, but it also becomes the app's "map"/"table of contents". So that, when exploring a new (or old...) codebase, I can start from "the map" of the highest-level function's body, then decide into which area I want to dig down - and hopefully continue along the same path.
Conversely, in languages which make it non-default/hard/impossible (Lua, OCaml, Python, Nix), I often need to waste some time to even find where the entry point of a codebase/file/module is.
The only other thing somewhat orthogonal to it is whether to put definitions of a struct/class fields before, or after its methods. Personally (again), I believe putting data first is the better choice here; this fortunately tends to be rather common in most languages I can recall now.
FWIW/FTR, my code tends to be thus usually ordered more or less like:
- imports from stdlib (least touchable code)
- imports from 3rd-party libs (moderately touchable code)
- imports from company-internal libs (code presumably somewhat more touchable)
- imports from current project (presumed to change often and most easily tweaked)
- main function
- helpers for main function, in "chronological" order (either as mentioned in the main function, or in order of execution)
- their helpers... here order becomes somewhat more messy vs. locality to the caller function's body
- generic helper/utility functions that are used by code above, but look universal enough that they might feel ripe for extraction to a utility library
- class/struct 1 with fields
- constructor of class/struct 1 (if needed) - "by definition" will be chronologically the first thing to be ever called for the class/struct
- other public methods of the class/struct, preferably in a "recommended chronological order of usage of the API" - though e.g. Close method might arguably land near to the constructor for easier cross-checking of fields list, as well as visibility in the API; again a "chronological" order matches the one used for functions called by main above, but also makes it easier to reason how to navigate in the codebase when reading/modifying it (esp. when bugfixing...)
- class/struct 2 with fields...
- etc. methods of class/struct 2...
The order above tends to become less strict and more messy in the middle of the file, because whether I still can maintain chronological order vs. whether locality or the "specific first, universal last" might take priority; but - just kinda speaking out loud here, and sharing some thoughts...
...made sure to allow code written in "top-to-bottom" order...
Agreed... though I tend to do things in the exact opposite order of you, with the main function at the bottom and things getting smaller and narrower as you go towards the start of the file. :-P But I get equally miffed with things like OCaml that forces me to always have things in that order, especially if they involve forward declarations. C'mon, everyone learns a topological sort function in their algorithms class! Are we really forcing humans to do this work instead of computers?!
There's semi-good reasons why someone would want to build their language this way, but I don't care about those reasons for Garnet. It's not like the order independence is especially hard to implement.
Thoughts on something I've been meaning to do for a little while now, make a nicer/more declarative syntax for interfaces.
To declare an interface type:
type Show[Self] = struct[Self] show: fn(Self) String, end
not too bad.
To implement the interface on a particular type:
const IntShow Show[I32] = Show { .show = fn(x I32) String = ... end }
Not terrible, but not great either. Making
fn show() ... end
be syntactic sugar for.show = fn() ... end
has been on my "should get around to this someday" list for a while. As above you could theoretically also write it as.show = \(x I32) String -> ...
but that's not really much different; the lambda syntax is there mainly for when you want to have a one-liner with a bit less gunk.Then if you want to implement a generic functor on it, this might change soon but currently you can't just do something like:
const My Show[T] = Show { .show = fn(x T) String = dbg(x); ... end }
For one thing you need a way to take another impl of
Show
as an argument here. So you have to write a function for it:fn my_show(|T| parent: Show[T]) Show[T] = Show { .show = fn(x T) String = dbg(x); parent.show(x) end } end const MyShowImpl Show[I32] = my_show(IntShow)
This is... technically 100% correct but really not the most fun to do, and gets like 10x worse when you start nesting them. So someday it would be nice to have nicer sugar for them, maybe something like:
-- Define static impl with no input parameters impl Show[I32] with IntShow = fn show(x I32) String = ... end end -- Define functor impl, takes an input parameter impl Show[T] with MyShow[T](parent: Show[T]) = fn show(x T) String = dbg(x); parent.show(x) end end const MyShowImpl Show[I32] = MyShow(IntShow)
Not sure where to best write it - sorry if that's not the right thread and maybe some other is better - not a problem for me to delete from here and move elsewhere (assuming sr.ht allows for deleting posts?)
So, just wanted to say, as far as possible inspiration/related lanugages go, I just discovered that the Teal language (formely "typed Lua") had a new major release recently, with some new features that might (or might not) be of some interest to you - in case you also missed it, the link is: https://github.com/teal-language/tl/blob/v0.24.4/CHANGELOG.md#0240---teal-spring-24
https://soc.me/languages/lower-bar-of-rust-2.html has some interesting/opinionated thoughts on syntax bikeshedding; I think we hit most of them already but it's worth thinking through them all.