Stream: ideas

Topic: Ability definition syntax


view this post on Zulip Ayaz Hafiz (Mar 06 2022 at 14:45):

This has been brought up in this thread a couple times before, but I think we should consider another syntax or stricter rules for defining new abilities.

Recall that the current proposed syntax for the definition of an ability Eq is

Eq has { isEq, isNotEq }

isEq : val, val -> Bool where val has Eq

isNotEq : val, val -> Bool where val has Eq

the current rules are just that you need the Eq has ... line, and the functions defining the ability must be defined on the top-level.

This means that definitions can be non-local, which I believe makes it harder for humans and computers (the compiler) to understand. A few concerns the syntax introduces include:

  1. It is harder for both a human and computer to find the relevant definitions of the ability functions "at a glance". That is, when I see Eq, I may have to search the rest of the file to figure out what isEq and what isNotEq look like, since there may be any number of intermediate top-level definitions between them and the Eq has ... line.
  2. A consequence of (1) is that refactoring is harder. A user can't just pull out the Eq has ... definition, they must also find the relevant isEq and isNotEq definitions, which can be cumbersome if they are far away. This is mostly an issue in a plain text editor; we can certainly do better in the editor which is designed to deal with scenario. However,
  3. The editor (and compiler in general) will have a hard(er) time with this. The reason is that if someone asks the question, "what do the functions composing Eq look like?", we wouldn't be able to answer that question just by looking at the Eq has ... definition. In general, we would always have to do more work than if isEq and isNotEq were defined inside Eq itself. Whether this makes a measurable difference during re-analysis between file changes is something we won't know for a while, though.
  4. Ambiguity is more likely to arise, and will be undecidable. I couldn't think of a realistic use case for this, so maybe it's esoteric and non-concerning. But the idea is, let's say I have this code
Embed has { embed }

# other defs here

embed : a, b -> Str where a has embed

# other defs here

embed : a, b -> Str where b has embed

which definition of embed is the one that Embed has { embed } is referencing? The compiler couldn't know, and worse yet, the author may not know. Again I'm not sure if this would actually arise ("default implementations" are more likely to arise, but those are unambiguous), but it's something to consider.

Anyway, I do think 1-3 are large enough concerns that it may be worth considering an alternative here. Here are a couple suggestions:

  1. Require the signature of functions defining an ability to be specified in the ability signature. So we would have something like
Eq has
  {
    isEq : val, val -> Bool,
    isNotEq : val, val -> Bool
  } where val has Eq

or

Eq has
  isEq : val, val -> Bool where val has Eq
  isNotEq : val, val -> Bool where val has Eq

(I prefer the second). This lessens the concerns (1)-(3) and eliminates the ambiguity component - it now enough to parse the Eq has block, and you have all you need to know about what the definition of Eq is.

One evident disadvantage of this is that it makes it less clear that isEq and isNotEq sit on the top-level of the module they are defined in. That might be something we can get away with teaching, but it is a concern.

  1. Require that signatures of functions defining abilities immediately follow the ability definition. So now
Eq has { isEq, isNotEq }

# other defs

isEq : val, val -> Bool where val has Eq

# other defs

isNotEq : val, val -> Bool where val has Eq

is illegal, we must explicitly do

Eq has { isEq, isNotEq }

isEq : val, val -> Bool where val has Eq

isNotEq : val, val -> Bool where val has Eq

This doesn't actually resolve the problems above, it just warns the author that they should order their definitions in this way. The reason I say this is because we'd still want the compiler to do the work to find interspersed and possibly-ambiguous definitions even if they are defined out-of-band, so that user experience doesn't suffer. At the very least, we'd need to invest in good error messages that say something like "Did you mean to host this definition of isNotEq defined <here> up?".

The upside of this is that it still remains very clear that isEq, isNotEq are on the top-level.

Okay, apologies for the wall of text :) I don't intend this to be an armchair exercise - in the design and implementation, I do think it's a legitimate concern. I think we should have the computer do as much work as possible to figure out stuff for the author, but not when the author must also do more work.

view this post on Zulip jan kili (Mar 06 2022 at 16:32):

Maybe-noob-y question: why do/should those ability functions still sit at the top level? Are other things referencing them?

view this post on Zulip Ayaz Hafiz (Mar 06 2022 at 16:44):

So that if Eq is defined in the Bool module, you could do Bool.isEq rather than Bool.Eq.isEq

view this post on Zulip jan kili (Mar 06 2022 at 16:52):

Makes sense, thanks!

view this post on Zulip Richard Feldman (Mar 06 2022 at 22:51):

I moved this from the #ideas > Abilities thread, since that topic ended up going in a different direction

view this post on Zulip Richard Feldman (Mar 06 2022 at 22:51):

so in the initial draft of the abilities doc, I actually had something more like #1 above!

view this post on Zulip Richard Feldman (Mar 06 2022 at 22:52):

so like

Eq has {
    isEq : val, val -> Bool,
    isNotEq : val, val -> Bool
} where val has Eq

view this post on Zulip Richard Feldman (Mar 06 2022 at 22:52):

here's the problem though: how does that interact with exposes?

view this post on Zulip Richard Feldman (Mar 06 2022 at 22:52):

for example:

interface Eq
    exposes [ Eq, isEq, isNotEq ]
    imports []

Eq has {
    isEq : val, val -> Bool,
    isNotEq : val, val -> Bool
} where val has Eq

view this post on Zulip Richard Feldman (Mar 06 2022 at 22:53):

if I can write exposes [ isEq ] then that must mean isEq is in scope at the top level, right?

view this post on Zulip Richard Feldman (Mar 06 2022 at 22:54):

which in turn means I should be able to call it from the top level, like so:

interface Eq
    exposes [ Eq, isEq, isNotEq ]
    imports []

Eq has {
    isEq : val, val -> Bool,
    isNotEq : val, val -> Bool
} where val has Eq

blah = isEq foo bar

view this post on Zulip Richard Feldman (Mar 06 2022 at 22:55):

so at that point, we effectively have isEq being essentially a top-level declaration for all intents and purposes except syntactically :sweat_smile:

view this post on Zulip Richard Feldman (Mar 06 2022 at 22:55):

that said, maybe that's fine!

view this post on Zulip Ayaz Hafiz (Mar 06 2022 at 23:05):

yeah that’s really the only downside of this approach i could think of

view this post on Zulip Ayaz Hafiz (Mar 06 2022 at 23:06):

but tbh i feel like it’s fine, we only need to teach it once and it kind of aligns with type classes in haskell (though i’m not saying that’s the model we should shoot for)

view this post on Zulip Ayaz Hafiz (Mar 06 2022 at 23:06):

I think the readability and refactoring advantages of this outweigh the downside of being “weird” that it’s on the top level

view this post on Zulip Ayaz Hafiz (Mar 06 2022 at 23:07):

also another option is the indented form, which could syntactically look more like the functions are on the top level compared to the record-like syntax

view this post on Zulip Yorye Nathan (Mar 07 2022 at 01:04):

Regarding this syntax:

Eq has {
    isEq : val, val -> Bool,
    isNotEq : val, val -> Bool
} where val has Eq

I suggest an alternative to the where clause - a scoped alias:

Eq has
  alias val = has Eq
  isEq : val, val -> Bool
  isNotEq : val, val -> Bool

Here it aliases val to a type-declaration, in a way, but it can also do simple name aliasing (e.g. alias L = List).
The same syntax can be useful in a function, for example to shorten a long type name because you're gonna be mentioning it a lot.
It makes it feel and look more like a variable rather than a constraint on the definition, and I do think of it as a variable - not of data but of a type.

view this post on Zulip Yorye Nathan (Mar 07 2022 at 01:04):

also the {}'s can be dropped, that's nice

view this post on Zulip Richard Feldman (Mar 07 2022 at 01:17):

I like this one from earlier:

Eq has
    isEq : val, val -> Bool | val has Eq
    isNotEq : val, val -> Bool | val has Eq

a nice thing about it is that we have the exact type signature that can be used in documentation

view this post on Zulip Richard Feldman (Mar 07 2022 at 01:19):

here's another possible direction:

{
    isEq : val, val -> Bool | val has Eq.
    isNotEq : val, val -> Bool | val has Eq,
} => Eq

view this post on Zulip Richard Feldman (Mar 07 2022 at 01:20):

kinda weird to have the thing you're defining at the end though :sweat_smile:

view this post on Zulip Derek Gustafson (Mar 07 2022 at 01:22):

The more I think about it, the weirder it seems that we use Eq in the definition of Eq

view this post on Zulip Richard Feldman (Mar 07 2022 at 01:23):

recursion! :smiley:

view this post on Zulip Derek Gustafson (Mar 07 2022 at 01:24):

But we aren't defining something recursive!

view this post on Zulip Derek Gustafson (Mar 07 2022 at 01:24):

If it was recursive, I'd be okay with it

view this post on Zulip jan kili (Mar 07 2022 at 01:28):

Is this a useful idea?

Eq a has
    isEq : a, a -> Bool
    isNotEq : a, a -> Bool

Here, a signifies "some type with the Eq ability"

view this post on Zulip jan kili (Mar 07 2022 at 01:28):

(inspired by type parameters... is this an ability parameter? Idk why there would ever be any quantity other than one...)

view this post on Zulip Richard Feldman (Mar 07 2022 at 01:28):

haha in some sense it is recursive though!
the definition of Eq literally involves providing two functions that both involve the Eq ability

view this post on Zulip Richard Feldman (Mar 07 2022 at 01:29):

I think Eq a makes it look like Eq has a type parameter though :sweat_smile:

view this post on Zulip jan kili (Mar 07 2022 at 01:49):

Syntax solution? Eq for a means ...? hehehe semi-joking

view this post on Zulip Ayaz Hafiz (Mar 07 2022 at 01:56):

I like “for a” or “of a”

view this post on Zulip Yorye Nathan (Mar 07 2022 at 01:57):

The | constraints syntax is great for a one-off or situations where constraints are just different across your methods, the alias syntax is great for when the same constraint is reused around a lot.
We can even take this a step further and say it aliases the whole signature (but then naming gets a bit hard):

Eq has
  alias FnSig = val, val -> Bool | val has Eq
  isEq : FnSig
  isNotEq : FnSig

view this post on Zulip Ayaz Hafiz (Mar 07 2022 at 01:58):

In any case it sounds like we’re roughly in agreement about the definition of function signatures within the ability definition so I’ll proceed with that. that’s the biggest impact to the language implementation/user ergonomics, the other stuff we can change pretty easily without impacting semantics

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:05):

yeah I'd go with this syntax for now:

Eq has
    isEq : val, val -> Bool | val has Eq
    isNotEq : val, val -> Bool | val has Eq

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:06):

I don't want to optimize for ability definition syntax being as concise as possible; I'd rather optimize for quality of documentation (e.g. the signature you write out is exactly what appears in the docs)

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:06):

especially because defining new abilities should be one of the rarest things people do in Roc

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:07):

I think the typical project should define 0 new abilities

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:07):

and that's true whether it's a library or application!

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:08):

(0 is the number for all Elm code bases, and that ecosystem is the nicest I've ever used...my #1 biggest fear with introducing Abilities to Roc is that the feature will be overused in a way that degrades the ecosystem!)

view this post on Zulip Yorye Nathan (Mar 07 2022 at 02:11):

it adds information to the signature but it still doesn't contain all the information you might need, like what is Bool or Eq (not the best example here)

view this post on Zulip Yorye Nathan (Mar 07 2022 at 02:14):

saying the constraints are more important for the docs than what data Bool has (imagine its Person instead) because they are specified on the method might not always be true

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:16):

sorry, I don't follow - can you write out a code example?

view this post on Zulip Yorye Nathan (Mar 07 2022 at 02:17):

sure

view this post on Zulip Yorye Nathan (Mar 07 2022 at 02:28):

Hashable has
    getHash : -> Hash    # not sure if thats correct syntax for 0 arguments

HashableWithSauce has
    getHash : sauce -> Hash | sauce has Hashable

The docs for HashableWithSauce would make me look at what is Hash and what is Hashable anyways. If Hash is a concept worth naming it is probably worth being intoduced to as well (per project). On the same logic, if the | sauce has Hashable constraint was something that was used a lot and was worth naming Sauce one way or another, one would need to be introduced to the Sauce concept. Then reading the docs you either know Sauce or you look it up, just like with Hash or Hashable. Here the concept is only used once so those details are indeed relevant to the method HashableWithSauce.getHash itself, so I would want to see the whole signature of it including the constraints. The docs are as specific as the signature.

view this post on Zulip Yorye Nathan (Mar 07 2022 at 02:30):

but if the constraint IS used a lot throughout the ability, it would become noisy in the docs instead of being introduced to some named concept once

view this post on Zulip Yorye Nathan (Mar 07 2022 at 02:33):

my point is that naming a constraint is useful

view this post on Zulip Yorye Nathan (Mar 07 2022 at 02:34):

(or defining it for the whole scope, like with the where or alias, so it only has to be mentioned once)

view this post on Zulip Yorye Nathan (Mar 07 2022 at 02:35):

supporting both things seems good

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:48):

so 0-argument functions don't actually exist in Roc, because a pure function with 0 arguments always gives the same answer...so the only reason to ever have one is to delay evaluation in case the function takes a long time to compute that one answer :big_smile:

Hashable has
    getHash : {} -> Hash

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:49):

you could do that

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:49):

but I don't understand what that ability would do :thinking:

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:50):

importantly, there's no implicit this or self involved here - not sure if that was clear!

view this post on Zulip Richard Feldman (Mar 07 2022 at 02:52):

in fact I think the compiler should ideally give an error for any ability function that doesn't have a type variable with that ability constraining it (e.g. a in the Eq example above) because then the function isn't (and can't possibly be) using the ability at all!

view this post on Zulip Yorye Nathan (Mar 07 2022 at 03:13):

yeah it wasn't clear to me hehe.
anyhow the logic of that ability isn't too relevant either (plus I'm horrible at examples), the point was how you'd interact with the docs around constraints, and how often its worth mentioning them.

view this post on Zulip Yorye Nathan (Mar 07 2022 at 03:14):

sometimes its worth per function, but other times not

view this post on Zulip Yorye Nathan (Mar 07 2022 at 03:18):

for example in the isEq and isNotEq example I would expect those functions to have the same constraints by nature, whatever they are. and I'm thinking about cases where more than just 2 functions would have some [partially] common constraints - if the coder decided to make things more concise, that decision should probably be carried out to the docs as well.

view this post on Zulip Yorye Nathan (Mar 07 2022 at 03:19):

so supporting the where or alias as a way to cut down on repetition over constraints can be beneficial to the docs too


Last updated: Jun 16 2026 at 16:19 UTC