Stream: ideas

Topic: static dispatch - method/function call syntax


view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 09:26):

Ok, I've had some additional thoughts, taking one more stab at this.

Goals: I want to look for a design that has the same or better weirdness budget as the proposed one for folks coming from a mainstream language, the same or better autocomplete support, the same or better conciseness, and can replace abilities in the same way. But I'd like to avoid a chain operator that scopes based on the value flowing through the chain, to help the code stand on its own in places without editor integration.

Firs step, replace: |> with . and whitespace-calling with paren-calling, but don't change any semantics:

when headers.(List:keepIf \{ name } -> name == "cookie") is
    [reqHeader] ->
        reqHeader.value
        .Str:fromUtf8
        .Result:try(.Str:split("=").List:get(1))
        .Result:try(Str:toI64)
        .Result:mapErr(\_ -> BadCookie)

    _ -> Err(NoSessionCookie)

I also changed the module separator to : as before to avoid ambiguity.

I think so far:

To improve conciseness, let's first make it worse. New rule: all function calls need to be qualified. Local function calls need to be qualified with Self: (alternatives: This:, Here:, Local:, ~:, ...).

Additional rule: A module qualifier counts as 'in scope' for later and nested function calls and doesn't need to be repeated in the same block, until the use of a different module qualifier. Repeated uses are still allowed, but the autoformatter will take them out. With these additional rules, the earlier snippet changes to this:

when headers.(List:keepIf \{ name } -> name == "cookie") is
    [reqHeader] ->
        reqHeader.value
        .Str:fromUtf8
        .Result:try(.Str:split("=").List:get(1))
        .try(Str:toI64)
        .mapErr(\_ -> BadCookie)

    _ -> Err(NoSessionCookie)

That helped some, the repeated calls to Result have dropped out, but a lot of modifiers remain. Still, I think we pruned the noise and kept the qualifiers that support readability.

In code that calls the same module a lot the effect is stronger. For instance, with scoped qualifiers the parsing snippet from the proposal gets even more concise than the code in the original proposal, in a way I don't think hurts readability:

    Parser.const(\str -> Heading(One, str))
    .keep(.chompWhile(Self:notEol).map(.utf8))
    .skip(Self:eol)
    .skip(.strConst("=="))
    .skip(.chompWhile(\b -> Self:notEol(b) && b == '='))

Additionally, scoped module qualifiers would work in a wider variety of scenarios. You could write Html with it and only need to qualify the element in your Html snippet:

Html:nav([], [
    a([Attr:class("link"), href("...")], [ ... ]),
    a(...),
]),

I think with this we might not need import statements anymore (except when needing to pass module params, need to think more about that). Note that scoped qualifiers also implicitly enforce the Elm convention to only have one module using exposing (..) at a time. Using a different qualifier, for instance the Attr: qualified in the example above, means that now Attr: and not Html: is in scope for later calls in that attribute list.

Scoped module qualifiers definitely take from the weirdness budget, but I think they add value too and read pretty intuitively. Plus there's an easy escape hatch: in doubt, add a qualifier (and let the formatter figure out the rest). In the original proposal you could similarly get a function in a chain in a way that "always works" by putting it in .(..), but the formatter won't be able to simplify that for you because it's not type-aware.

I think on conciseneness we're doing pretty good now. Couple additional qualifiers in some places compared to the main proposal, fewer qualifiers in other places. Plus we can drop import statements.

The one thing missing now is some way to do static dispatch for abilities. Many options:

"Hi!".Module:inspect   # Special 'Module:' qualifier
"Hi!".call(.inspect)   # Special calling function
"Hi!".$inspect         # Special operator

Essentially the possibities are the same as the ones we discussed above, only instead of applying it to local function chaining we use that syntax for static dispatch instead. It's a much less important decision in this proposal though, because people will almost never encounter it. Most calls to ability functions are wrapped in an operator (like ==), or another function (like Decode.fromBytesPartial).

So to summarize:

Curious what you think!

view this post on Zulip jan kili (Nov 10 2024 at 14:18):

Wow, I find this really compelling.

I don't see why "The only thing missing now is some way to do static dispatch for abilities." - it seems like . would just work for those methods too. I'm probably missing something. Oh, for Module: you're saying we'd want some way to still qualify the method without naming/knowing its module name?
(Maybe this is a more apt use case for Self:, which I believe is the Pythonic convention for the same concept that many new users would be familiar with. For anyone above who liked _ for the first argument, in foo.bar.Self:my_method(baz), Self could serve the same purpose of showing the missing first arg to the method.)
HOWEVER
One flaw I see is that Module: (or Self:2.0 if my adjustment is well taken) alone can be repeated while the type is changing. If the formatter removes those repeats in an all-methods chain that goes from Str to List Str to List U8 to U8, an IDE would still be required to show that visually. Even if true-method calls did't get the formatter trimming (which would feel inconsistent), you still wouldn't see the type names in the source code.
Since the author knows the qualifying module names anyways, I'd prefer instead to just qualify with the method's own module name, which would simplify the syntax by not needing any Module: equivalent.
Example: [1, 2, 3].List:append(4)

Sorry if I'm still misunderstanding your approach to static dispatch or true methods, and/or your different use of Self above. Speaking of that...

Besides the slight extra verbosity (which is debatably an upside), the big downside I see in your examples is the excess verbosity of your Self:/Local:, which seems easily addressed by using $ that has gained consensus above for the same concept! Would that simplify this counter-proposal to mostly just be "YES AND let's qualify every method-style call, change qualification to be :, and have the formatter remove repeated qualifiers"? If so, this stock vim user says ":money_face:".

view this post on Zulip jan kili (Nov 10 2024 at 14:28):

I believe your proposal looks like

dot-with-nonrepeated-qualifiers

pipes = prev.pipes.Local:updatePipes("extra argument").List:appendIfOk(pipe)
#                                               maybe .Module:appendIfOk(pipe)
pipes = prev.pipes.Pipes:update("extra argument").List:appendIfOk(pipe)
#                                               maybe .Module:appendIfOk(pipe)
pipes =
    prev
    .pipes
    .Local:updatePipes("extra argument")
    .List:appendIfOk(pipe) # maybe .Module:appendIfOk(pipe)
pipes =
    prev
    .pipes
    .Pipes:update("extra argument")
    .List:appendIfOk(pipe) # maybe .Module:appendIfOk(pipe)

view this post on Zulip jan kili (Nov 10 2024 at 14:29):

I believe with $s it would look like

dot-dollar-with-nonrepeated-qualifiers

pipes = prev.pipes.$updatePipes("extra argument").List:appendIfOk(pipe)
#                                          maybe .Module:appendIfOk(pipe)
pipes = prev.pipes.Pipes:update("extra argument").List:appendIfOk(pipe)
#                                          maybe .Module:appendIfOk(pipe)
pipes =
    prev
    .pipes
    .$updatePipes("extra argument")
    .List:appendIfOk(pipe) # maybe .Module:appendIfOk(pipe)
pipes =
    prev
    .pipes
    .Pipes:update("extra argument")
    .List:appendIfOk(pipe) # maybe .Module:appendIfOk(pipe)

view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 15:08):

JanCVanB said:

That's what I was thinking, though I don't feel super strongly about what they keyword should be, or if it even should be keyword or some other syntax. I think the important part with regards to the rest of the design is that it's the special case. Because calling a function where you don't know the qualifier is pretty rare (just what's ability-function calls today) we'd not see much of it anyway.

JanCVanB said:

One flaw I see is that Module: (or Self:2.0 if my adjustment is well taken) can be repeated while the type is changing. If the formatter removes those repeats in an all-methods chain that goes from Str to List Str to List U8 to U8, an IDE would still be required to show that visually.

Maybe, though I think that's no different from a chain of local functions calls using |> today. My goal with the proposal wasn't necessarily to mark in the syntax any time the type of the pipeline changes, but to make explicit where these functions that get called are located.

JanCVanB said:

Since the author knows the qualifying module names anyways

I don't think this is the case for the type of generic code that uses abilities today. For instance, suppose we want to write a function that should work for any type that implements Decode. That function might want to call decodeU8 for the type of whatever value is passed in.

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 15:19):

Personally I think removing the module name and just having types with associated methods is one of the biggest benefits. I can simply write two different container types without any abilities. As long as most of the methods are the same, porting between the two is pretty trivial. If the methods are exactly the same. Porting is free.

I do think interfaces or abilities or contracts or something are great for clearly specific common function groupings, but for many things that are more adhoc. A very similar API is enough. Change over with basically no code changes.

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:31):

The only issue I have with it is the name, all other special stuff in Roc is a one-word keyword, and I'd call this a keyword kinda thing

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:31):

So it'd be nice to pick a one-word equivalent

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:32):

But that's aesthetic

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:32):

The behavior makes perfect sense, and as Brendan points out, it's a feature that no one else has

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 15:32):

I think call would probably be the most expected one word

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:32):

Or pipe

view this post on Zulip jan kili (Nov 10 2024 at 15:32):

apply tho

view this post on Zulip Isaac Van Doren (Nov 10 2024 at 15:33):

My vote is for call

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 15:33):

Let's not bike she's the word right now

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 15:33):

Just the idea of it is enough for discussion

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:33):

So even if it's longer than a method call, it's still a good improvement to the standard function changing apparatus in other languages

view this post on Zulip jan kili (Nov 10 2024 at 15:34):

Side note, should I rename this thread to static dispatch - syntax?

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:34):

We should either make another thread for it or wait for now

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:34):

Ehh, this is the thread with the original doc

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:34):

Proposal would be the most accurate suffix

view this post on Zulip jan kili (Nov 10 2024 at 15:35):

Zide note, should I move all messages starting with Jasper's this morning to a new thread called static dispatch - syntax?

view this post on Zulip jan kili (Nov 10 2024 at 15:36):

There are now 3.5 threads on this, so it would fit.

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:36):

sure, maybe something more specific since these are all kinda syntax related?

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:36):

static dispatch - function chaining syntax?

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:37):

Or - function vs. method chaining

view this post on Zulip jan kili (Nov 10 2024 at 15:37):

func/meth call syntax

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 15:38):

we don't need to bikeshed the topic name (though it may be fitting for the syntax thread). You can just move the messaeges

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:38):

lmao

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 15:39):

Sorry if that comes off in the wrong tone. I just read like 150 messages to catch up with this thread and am a bit tired of naming

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:39):

No, it's good to actively push against this stuff

view this post on Zulip Sam Mohr (Nov 10 2024 at 15:40):

Though I know that apology wasn't to me

view this post on Zulip Notification Bot (Nov 10 2024 at 15:41):

34 messages were moved here from #ideas > static dispatch by JanCVanB.

view this post on Zulip Notification Bot (Nov 10 2024 at 15:41):

34 messages were moved from this topic to #ideas > static dispatch - method/function call syntax by JanCVanB.

view this post on Zulip jan kili (Nov 10 2024 at 15:42):

We did it.

view this post on Zulip Sam Mohr (Nov 10 2024 at 16:23):

@Jasper Woudenberg I can't keep my eyes open, let me read this again when I wake up and give more useful thoughts

view this post on Zulip Sam Mohr (Nov 10 2024 at 16:24):

And if I forget, never feel bad pinging me, this is an actual game plan for implementing a complex feature in stages and would be really useful to discuss!

view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 16:28):

Thanks for taking the time to take a look at it, Sam!

view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 16:45):

Brendan Hansknecht said:

Personally I think removing the module name and just having types with associated methods is one of the biggest benefits. I can simply write two different container types without any abilities. As long as most of the methods are the same, porting between the two is pretty trivial. If the methods are exactly the same. Porting is free.

I do think interfaces or abilities or contracts or something are great for clearly specific common function groupings, but for many things that are more adhoc. A very similar API is enough. Change over with basically no code changes.

I think that's definitely a downside in the design I proposed, even with fewer qualifiers. That said though, wouldn't the porting cost of changing one container type with another that has an identical API already be pretty low today? Worst case a couple minutes of surfing compiler errors for an uncommon task, fewer if you can search-and-replace in an isolated bit of code.

Personally I'd prefer to take the hit on on code-writability side here and have tooling mitigate that (maybe some automated refactors), then to take the hit on the code-readability side and require tooling to counteract that (inlay type hints).

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 17:00):

It's more about the diff than the work to port

view this post on Zulip Sky Rose (Nov 10 2024 at 18:27):

I like that this proposal makes it possible to figure out which module you're calling just from reading the code, I think that's important.

I think it's a little awkward that the module is sometimes there and sometimes not. I think it'd be hard to predict in any one case whether it's needed or not. If the formatter will fix it, it's not a huge deal, but I can imagine lots of minor papercuts of forgetting the module when it's needed or being surprised when the formatter removes it when it's not needed.

view this post on Zulip Sky Rose (Nov 10 2024 at 18:27):

As long as most of the methods are the same, porting between the two is pretty trivial.

I don't think being able to change implementations should be prioritized. It's pretty rare, and usually there's an important difference where you want to check every call anyway. Like, you change from a Dict to a List, but now you need to double check your Dict.remove calls to decide whether you want to remove all matching elements or only the first one. Having abilities and matching names are more about being able to use data structures generically in other code and about having predictable understandable features, rather than about being able to do a find and replace in your source code.

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 18:52):

I like that this proposal makes it possible to figure out which module you're calling just from reading the code, I think that's important.

I think I often find it to be more noise. That is the main reason I don't like it. Ignore the ability to change completely. I would still want unqualified method calls for readability. Having List. repeated on essentially every line of a function using a list heavily is just a ton of extra noise.

I really like the name appearing less. In my ideal world, it appears once in the function signature. If the value is created during the function, the type is listed once where the variable is created (e.g. x = Dict.empty). It may also be hinted at or put in the variable name if that makes sense.

view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 19:24):

Brendan, how do you feel about the latest version of the idea I posted in terms of reducing qualifier use? I had the sense I got it to a level where the amount of qualifiers was sometimes a bit more than in Richard's original proposal, sometimes less (check the markdown parsing example). In case of a long chain of List operations it would remove all but the first.

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 19:26):

Ah missed that. Specifically only adding Module: on the first use of a module.

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 19:30):

I'm not particularly a fan of the inconsistency. Like I get there is a rule, but I don't think that all because I switch between string and list it makes sense to add in extra noise.

myStr.toUtf8().List:map(upper).Str:fromUtf8()?

That just reads a lot worse to me than

myStr.toUtf8().map(upper).fromUtf8()?

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 19:31):

It does solve a major case, but I don't feel the noise is needed at all. I will just go to definition in my editor either way.

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 19:31):

Or load up quick reference documentation

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 19:34):

I guess I feel that the standard method dot syntax works just fine in rust and go and etc. People don't generally complain about module scope clarity issues. So it feels like extra noise to solve a problem that isn't going to be a big deal in practice. It just is different from our current state so it feels like a big deal.

Not to belittle any opinions or use cases (without lsp or etc). This is just from my personal flow and what I think most mainstream developers live with.

view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 20:42):

I think you're accidentally hitting on one of the benefits of the approach though. The following isn't valid in the static dispatch proposal:

myStr.toUtf8().map(upper).fromUtf8()?

It'd have to be this:

myStr.toUtf8().map(upper).(Str.fromUtf8())?

Which brings back one qualifier.

Additionally, Rust and Go don't support the above syntax. In such languages you'd have to write something like this:

upperBytes = myStr.toUtf8().map(upper)
Str.fromUtf8(upperBytes)

The static dispatch proposal combines method chaining with pizza chaining with partial application. We'll be able to build much longer (nested) chains without qualifiers than you can in either Go or Rust, and I think the experience is going to be quite different from those languages. Maybe more like Ruby on Rails which has a lot of methods in scope implicitly, though admittedly easier to statically inspect.

view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 20:53):

We will still have qualifiers, like the example above demonstrates. But those qualifiers will appear wherever we need the method a -> b we need happens to live in b's module rather than a's. I think in terms of inconsistency that's way more arbitrary then how qualifiers are placed in my proposal above. And it leeds to more noise in places too, like the repeated use of Parser int he markdown parsing example on Richard's original proposal.

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 21:03):

We would just add a method on list to convert to string if we want that or use a pipe. That is a question of exact API design.

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 21:05):

I still think there are tons of builders and various types that switch module and the extra noise isn't necessary

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 21:07):

Also I would still go for static dispatch proposal without .> or .(Module.fn) or etc. so I probably have a lot of bias here.

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 21:09):

I do agree that it is arbitrary in the proposal, but importantly it is in user API design space instead of a compiler rule.

view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 21:09):

I think I'd prefer the dispatch proposal without .> or .(Module.fn) actually.

view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 21:18):

Brendan Hansknecht said:

I do agree that it is arbitrary in the proposal, but importantly it is in user API design space instead of a compiler rule.

I wonder how much control we'd have. For instance, the roc-unicode package has a split : Str -> Result (List Str) Utf8ParseErr function. We won't be able to move that to the Str module. Unless we decide to pull unicode into the stdlib that is, but there's going to be other modules that will define functions taking a Str.

Kotlin (or maybe it's Java) has a thing called 'Extensions' (link) to solve this problem, allowing library authors to 'extend' a base type and add extra methods onto it. I don't find it great to use though, I've on multiple occasions tried to call a method an instance and got a compiler error saying the method didn't exist even though I saw us calling that same method in another bit of code, then it turned out I wasn't importing a particular extension. I think pizza-chaining saves us from needing that type of crowbar.

view this post on Zulip Jasper Woudenberg (Nov 10 2024 at 21:20):

(deleted)

view this post on Zulip Richard Feldman (Nov 10 2024 at 21:27):

yeah I'm definitely against that category of "extension" designs - Rust has the same thing and I dislike it there too

view this post on Zulip Richard Feldman (Nov 10 2024 at 21:27):

I'd rather we (continue to) not support that

view this post on Zulip Richard Feldman (Nov 10 2024 at 21:29):

also great use of "crowbar" :grinning_face_with_smiling_eyes:

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 22:17):

Yeah extensions are great (for flexibility and convenience) but also lead to very hard to follow code.

view this post on Zulip Brendan Hansknecht (Nov 10 2024 at 22:17):

Jasper Woudenberg said:

I think I'd prefer the dispatch proposal without .> or .(Module.fn) actually.

I would totally go for that as a starting point


Last updated: Jun 16 2026 at 16:19 UTC