~icefox/garnet#53: 
Syntax bikeshed

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 it
  • Struct decls via struct x: T, y: T end, rather ugly to put inline as the type in let exprs and such..
  • Postfix vs prefix type composition; 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.
  • Related, syntax for type parameters. Say 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.
  • Colons for type annotations; 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.
  • Closure and function type syntax. Right now it's 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.
Status
REPORTED
Submitter
~icefox
Assigned to
No-one
Submitted
1 year, 10 months ago
Updated
7 hours ago
Labels
T-DESIGN

~icefox 1 year, 10 months ago*

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.

~icefox 1 year, 10 months ago

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's pub 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 using pub for "visible outside the current CU" because that's what it means in lots of other langage, and maybe loc (local) or something for pub(crate). But the siren song of api 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.

~icefox 1 year, 10 months ago

Prefix types implemented in https://hg.sr.ht/~icefox/garnet/rev/337fed657920b1f1f08d93dbcc78ef65ad3b05be . Easier than I expected and tbh a little nicer to parse.

~icefox 1 year, 9 months ago*

Syntax for type parameters passed to functions or declared in functions is fn foo(|T| x T, y I32) and is called foo(|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, with Foo(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 it foo(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) vs Vec[T]" bullet-point as well, since that's part of what makes this hard. I want to keep Vec(T) for now but you could imagine it being Vec|T| if you try hard enough. Don't especially want it right now.

~icefox 1 year, 9 months ago

We now have syntax-significant newlines! See #48 for details and #38 for motivation.

~icefox referenced this from #35 1 year, 9 months ago

~icefox 1 year, 9 months ago

Added a Rust-y implicit function return type of unit in commit b081842f4b88, so fn foo() {} = ... end can now just be fn 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 \ or lambda maybe to be a synonym for fn. 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 the end to be elided if it's only one expr?

Not sure I have any good solutions there.

~akavel 1 year, 5 months ago*

Re: api/loc: FWIW IIUC in Go, the loc kinda got called internal. This sounds reasonable to me, in that it marks an entity as "internal to the module/package/library/crate/whatever".

~icefox 1 year, 5 months ago

Hmmm, internal is reasonable, though a little long for something that's going to be written frequently. And the obvious shorthand of int is... a bit too ambiguous.

I'm probably just being too hard to please.

~icefox referenced this from #44 1 year, 5 months ago

~icefox 1 year, 2 months ago*

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 an end at the end of it either, we just do the Rust-ish thing and make it take one expression that can be a do ... 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.

~icefox 1 year, 1 month ago

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 la

let 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 always val Type instead of val: 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.

~icefox 10 months ago*

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 be Foo[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.

~icefox 7 months ago*

So I was playing around with interfaces for conversions and hypothetical associated types, using Rust's From and TryFrom 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 renamed impl to const here, and had impl 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~

~icefox 7 months ago*

Also it might be time to give in and make fn TryFromFrom(|In| from_impl From[In]) ... just be fn 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. But fn Foo[T]() Rettype and Foo[T] and [3]T are probably still fine. (Fuckit, maybe just make arrays be Arr[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.

~akavel 7 months ago

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

  1. reducing rightwards drift (both when writing and reading)
  2. 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 ;)

~icefox 7 months ago*

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.

~akavel 6 months ago

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...

~icefox 6 months ago

...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.

~icefox 5 months ago

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)

~akavel 2 months ago

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

~icefox 7 hours ago

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.

Register here or Log in to comment, or comment via email.