nim-lang / RFCs

A repository for your Nim proposals.
135 stars 26 forks source link

User defined proc types #448

Open PMunch opened 2 years ago

PMunch commented 2 years ago

This is something that I've felt would tidy up many different kinds of projects and make Nim syntax even more readable. Essentially I propose that Nim add in some way of creating aliases like func but in user code. func for those not in the know is just an alias for proc {.noSideEffect.}. I propose that we'd be allowed to define aliases like these. Originally I imagined a syntax like:

type func = proc {.noSideEffect.}

But that would obviously be confused with just creating an alias for a procedure so it might not be suitable. I've chosen to use this syntax in the following examples however.

The ability to create aliases for different procedure types which automatically attaches pragmas would allow libraries like async to behave like this:

type async = proc {.async.}

async myAsyncProc(x: int): Future[string] =
  await sleepAsync(1000)
  return x

echo waitFor(myAsyncProc)

Or allowing things like:

# Note that this uses `func` instead of proc
type safe = func {.raises: [].}

safe mySafeProc(x: int): string =
  raise newException(ValueError, "Hello world") # This would fail to compile because this can't be a `safe` procedure

It could of course also be extended to any library which requires the definition of procedure-like constructs and wishes to avoid using pragmas. In my opinion it is much easier to miss a pragma attached to a proc than it would be to miss that it wasn't defined as a proc at all. The existing func could of course then be migrated into a simple type definition in the system module.

de-odex commented 2 years ago

I’d kinda prefer it if it didn’t also remove “proc”, so maybe “safe proc …” and “async proc …” or so, might look nicer for those cases

PMunch commented 2 years ago

The problem with that is that if you define e.g. a pure function the proc (for procedure) keyword is now lying about what you create. You can easily run into similar issues with other pragmas that rewrite the body so it's not strictly speaking a procedure any longer (but still a callable, which is what the syntax actually implicates, take e.g. template/macro/iterator which are all callable like a proc, but isn't a proc).

Araq commented 2 years ago

My original design of func was made in this direction, in my proposal you could define per-module what func means, proc .noSideEffect being the default but it was overridable. Nobody liked it back then. Your proposal does not overload an existing keyword so that is better, however your proposal is really hard to implement with today's parser (and grammar formalism!).

PMunch commented 2 years ago

Yes I was afraid that the parser could pose some issues. ElegantBeef over in the chat proposed a declaration syntax similar to for-loop with a proc that has as its only argument a ProcDef type. This doesn't help at all with parsing the procedure definition part though of course. I'm guessing the parser would actually have to be changed to have a kind of "Callable" type with an extra identifier, and then later on it would resolve whether or not the Callable is a proc/macro/template/custom etc. Not sure if this is feasible though, I guess that might change the entire AST representation..

Varriount commented 2 years ago

I think that making pragma syntax more convenient (by putting it before the procedure like many other languages) would be helpful here.

Clonkk commented 2 years ago

I think that making pragma syntax more convenient (by putting it before the procedure like many other languages) would be helpful here.

So pragma UFCS with a decorator basically ?

PMunch commented 2 years ago

@Clonkk, pragmas are kind of like decorators already. But yes, this would be similar to UFCS. And @Varriount, putting them before is one thing, but replacing the keyword entirely is another. It just feels a bit weird that the Nim syntax is so flexible, but if you want to write something which looks like a procedure it's nigh impossible because that part of the Nim syntax is completely locked off.

metagn commented 2 years ago

The problem with that is that if you define e.g. a pure function the proc (for procedure) keyword is now lying about what you create. You can easily run into similar issues with other pragmas that rewrite the body so it's not strictly speaking a procedure any longer (but still a callable, which is what the syntax actually implicates, take e.g. template/macro/iterator which are all callable like a proc, but isn't a proc).

To be fair they always become some kind of "proc" just only in a way the compiler knows how to work with. Nim macros are supposed to be called procedural macros when also talking about other languages's macros. The template and iterator keywords are like macros builtin to the compiler that generate some kind of procedure while macro, func, method are more similar to an extra property of a proc type.

The routine declaration and expression syntaxes are very easy to unify so async proc ... wouldn't be hard to implement. If the keyword is bad, async do foo(...) -> T: could also be made to work, but people have expressed the ugliness of do notation before. These wouldn't be pretty but they are one possible roundabout part of a solution.

Araq commented 2 years ago

Fwiw I personally don't like async proc at all or other prefixes for that matter, the "async" property is not the most important aspect of a function so it should come later in the declaration, not earlier. Having to read and write the irrelevant first is bad design, no matter how many other languages do it.

juancarlospaco commented 2 years ago

I think we have enought Bugs with the current syntax to grow it even bigger, and that was the whole point of macros as pragmas with metaprogramming, otherwise we will have 1430980943 ways to declare a function...

Varriount commented 2 years ago

@Araq

Fwiw I personally don't like async proc at all or other prefixes for that matter, the "async" property is not the most important aspect of a function so it should come later in the declaration, not earlier. Having to read and write the irrelevant first is bad design, no matter how many other languages do it.

But it is significant! async (and similar pragmas) impact the entire semantics of a procedure, including its arguments. Pragmas have the capability to completely rewrite a procedure or type. They can add or remove arguments, modify the procedure or type's name, or even change the semantics of various constructs (including such as whether a type is distinct or a what a procedure's name or parameters are).

If I'm reading a procedure definition, and suddenly come across a pragma in between the procedure's arguments and body, I have to throw away my current interpretation of the procedure, and then reconsider (and often re-read!) the procedure's name and arguments in the context of what that pragma does. If it's a cimport pragma, I have to reconsider how the arguments are going to be passed (by copy or reference). If it's a deprecated pragma, I have to reconsider whether I'm reading the correct version of the procedure (with the correct set of arguments) or whether I need to look for an alternative with the same name, but a different set of arguments.

This kind of cognitive significance is why all those languages put pragmas (or their respective equivalents) before type and procedure definitions. It places the reader's mind in the context required to accurately interpret the entire definition. Having to switch from "this is a plain old procedure", to "this is an async procedure" after one has already started reading the procedure's definition is jarring. The alternative, blindly scanning for the pragma section, reading it, then jumping back to the beginning of the definition, is not much better.

mratsim commented 2 years ago

If we want to make pragmas more visible, I think this proposal here https://github.com/nim-lang/RFCs/issues/15#issuecomment-558160311 was the best that worked with current parser. Note: the whole thread was closed due to the need of stability before 1.0

How about

+[async]
proc foo(x: int): string =
   ...

Visually familiar to rustaceans, removes the signature clutter, and feels like adding a "property" to a proc. Kinda reminiscent of org-mode options too. Would this cause any syntactic clashes?

metagn commented 2 years ago

That one specifically doesn't work with the current parser since it's valid syntax for a standalone statement. The special casing to implement it would be insane.

import macros

dumpTree:
  +[async]
  proc foo(x: int): string

#StmtList
#  Prefix
#    Ident "+"
#    Bracket
#      Ident "async"
#  ProcDef
#    Ident "foo"
#    ...

On the other hand, [. .] (. .) are still unused reserved tokens.

[.async.]
proc foo(x: int): string =
   ...

I'm not advocating for or against this, just thinking this makes more sense.

Araq commented 2 years ago

This kind of cognitive significance is why all those languages put pragmas (or their respective equivalents) before type and procedure definitions.

Well, no. They don't merely put it "before" the declaration because it's so important, they put it in the line above in order to not clutter the real/important parts of the definition. Both Nim's and C#'s syntax "agree" that these annotations are "clutter", they simply deal with it in a different way.

Varriount commented 2 years ago

This kind of cognitive significance is why all those languages put pragmas (or their respective equivalents) before type and procedure definitions.

Well, no. They don't merely put it "before" the declaration because it's so important, they put it in the line above in order to not clutter the real/important parts of the definition.

So "being able to impact the entire meaning of the definition" isn't important? If that's the case, we should probably discuss moving the position of the template, proc, iterator, etc. keywords elsewhere, since they certainly have the same amount of impact pragmas have.

I'd argue that placing pragmas above the definition is less about the fact that they're "clutter" and more about the fact that:

  1. A definition can have a variable number of pragmas.
  2. Pragmas can impact the entire definition. They don't have a strict relation to any single part of a definition, unlike arguments and return type (input & output), or type name and base type (child & parent).

Both Nim's and C#'s syntax "agree" that these annotations are "clutter", they simply deal with it in a different way.

But sure, let us consider the case where pragmas are clutter (completely ignoring the arguments I have made to the contrary). Is Nim's way of dealing with them actually better? What benefits does placing pragmas in the middle of a definition have over placing them before the definition? I'm honestly curious, because other than "looking nice when there's no definition body" and "looking nice when there's only a couple of argument-less pragmas", I can't think of anything. I can think of how it's worse though.

As an addition to my previous argument, let's consider the case where pragmas have relatively minor effects that a reader wants to ignore. Nim places the "cluttering" pragmas in the middle of a definition, where a reader must frequently skip over them when moving between either "sides" of the definition split by the pragma section. This is a minor annoyance when there's only one pragma, but it becomes frustrating when you have a procedure with multiple pragmas (and downright infuriating when they take arguments!).

As an aside, I put more than just links to C#. Python and Java also have similar mechanisms. Especially in Python's case, I doubt one can claim they are mere "clutter".

Araq commented 2 years ago

I don't "ignore" your arguments but for me there is a big difference between async def foo and @async\n def foo (note the newline please).

This is a minor annoyance when there's only one pragma, but it becomes frustrating when you have a procedure with multiple pragmas (and downright infuriating when they take arguments!).

The hyperbole weakens your otherwise good arguments.

As an aside, I put more than just links to C#. Python and Java also have similar mechanisms. Especially in Python's case, I doubt one can claim they are mere "clutter".

Well but they are positioned in a way that suggests that they are clutter and should not disturb the def name(args) part.

But it doesn't matter if "super important" or "clutter", we both seem to be fine with the syntax:: annotations \n declaration

juancarlospaco commented 2 years ago

If you think for a moment, this does not make too much sense..., because in Nim a function can be async and sync at the same time, like {.multisync.}, async def foo can be sync blocking code. 🤷

Clonkk commented 2 years ago

But it doesn't matter if "super important" or "clutter", we both seem to be fine with the syntax:: annotations \n declaration

So if I understand correctly, you propose to move pragmas before the declaration like (or keep the {. .} but before the declaration; whatever works best) :

[. async, gcsafe.]
def fooBar() = discard
Araq commented 2 years ago

I'm not proposing it but I don't mind it. And I do mind async noSideEffect proc name(args) = body enough to veto against it.

PMunch commented 2 years ago

So this RFC has drifted way of target for what I proposed.. async noSideEffect proc name(args) = body is terrible, and I agree that it shouldn't be allowed. What I was proposing was some way of doing mycallable name(args) = body and be able to modify that procedure body. Whether that is with something similar to for-loop macros with a macro with a single ProcStmt type or through simply converting it to proc name(args) {.mycallable.} = body is irrelevant. The pragma approach just hit me as the simplest way of implementing this.