nim-lang / RFCs

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

Extend call-with-block syntax to allow for passing more than one argument to procedures #277

Open liquidev opened 3 years ago

liquidev commented 3 years ago

Abstract

It is currently possible to pass a block to the last parameter of a procedure. This is a proposal to allow for passing any amount of (potentially named) parameters.

Motivation

Currently, passing lots of parameters to a procedure can get quite messy. This is very apparent when building trees with macros, and the NEP-1 code style guide does not make this much cleaner either, as procedure parameters should be aligned to the call's opening parenthesis ( which creates an awkward indent that's hard to deal with in editors, and pushes parameters very far to the right, which makes things like building trees with macros quite messy.

Description

My idea was to extend the currently available call-with-block syntax to interpret subsequent (non-void) statements as parameters. This would also allow for passing lots of named parameters to procedures in a very elegant way, possibly also help with RFC #264. This would only work with procedures, as getting this to work with templates and macros, which can accept arbitrary code blocks, could be quite difficult, and possibly a breaking change, if all ambiguities need to be taken into account. If anything, only macros and templates that don't accept untyped would be eligible.

Given this procedure:

proc lineRectangle(graphics: Graphics, position, size: Vec2f,
                   thickness: float32, color: Rgba32f) =
  discard

The current way of passing parameters can get messy, especially if a given parameter is some complex expression:

graphics.lineRectangle(vec2f(32, 32), vec2f(32, 32),
                       thickness = 8, color = colGreen)
# now imagine if position and size were controlled by much more complex
# expressions:
graphics.lineRectangle(tilemap.tileSize * (position.vec2f + offset.vec2f),
                       tilemap.tileSize, thickness = 8, color = colGreen)

If an expression grows to the 80-column limit, it can be quite difficult to break it down. The best solution in that case would be polluting the scope with a temporary variable that's only used once.

A different solution would be this:

graphics.lineRectangle(
  tilemap.tileSize * (position.vec2f + offset.vec2f),
  tilemap.tileSize,
  thickness = 8,
  color = colGreen
)

But frankly, the extra ) at the end makes the call look ugly, and this is especially apparent when constructing trees using macros:

newCall(
  bindSym"myProcedure",
  infix(
    newCall(
      bindSym"anotherProcedure",
      newLit(3),
    ),
    "+"
    newCall(
      bindSym"yetAnotherProcedure",
      newLit(5),
    ),
  ),
)

NEP-1 doesn't help this either, usually pushing parameters so far to the right that an extra temporary variable is needed to fit in 80 columns:

newCall(bindSym"myProcedure",
        infix(newCall(bindSym"anotherProcedure", newLit(3)),
              "+"
              newCall(bindSym"yetAnotherProcedure", newLit(5))))

The current call-with-block syntax allows for this:

graphics.lineRectangle(vec2f(32, 32), vec2f(32, 32), thickness = 8):
  colGreen

Which is quite useful when converting a complex expression to a distinct type:

# taken from my ongoing project
let set = TileSideSet:
  connectsTo(dx =  1, dy =  0).ord * tsseRight or
  connectsTo(dx =  0, dy =  1).ord * tsseBottom or
  connectsTo(dx = -1, dy =  0).ord * tsseLeft or
  connectsTo(dx =  0, dy = -1).ord * tsseTop

However, I think such syntax would be equally useful for calling procedures with multiple parameters. Consider the previous lineRectangle example:

# it should still be possible to pass leading parameters in regular () fashion,
# so both graphics.lineRectangle and lineRectangle(graphics) should work
graphics.lineRectangle:
  # every non-void statement is treated as a parameter
  vec2f(32, 32); vec2f(32, 32)
  # assignment to non-existent variables is interpreted as named parameters
  thickness = 8
  color = colGreen

The big upside is that now the same call looks far more natural and doesn't require a huge indent that's hard to handle properly by editors.

The complex expression situation is also much improved:

graphics.lineRectangle:
  position = tilemap.tileSize * (position.vec2f + offset.vec2f)
  size = tilemap.tileSize
  thickness = 8
  color = colGreen

As can be seen in the example above, the position parameter is much easier to fit in the 80 column limit, or at least can be formatted nicely. Another advantage is that users of editors with poor auto-indentation don't have to hassle with deindenting long auto-indented lines manually.

This syntax could also be extended to support object constructors, which is probably a far more common use case where having lots of fields is quite common:

proc initThing(): Thing =
  Thing:
    a: 1
    b: 2
    c: "abc"
    d:
      AnotherThing:
        x: 6
        y: 7

The primary downside of such syntax is that it's yet another thing to learn about, and yet another thing to maintain in the compiler. If it cannot be implemented for templates and macros, it would also lead to an inconsistency where it can only be used with procedure calls.

Examples

Going back to the macro example, let's examine how it would improve if my RFC is implemented.

Before

newCall(
  bindSym"myProcedure",
  infix(
    newCall(
      bindSym"anotherProcedure",
      newLit(3),
    ),
    "+"
    newCall(
      bindSym"yetAnotherProcedure",
      newLit(5),
    ),
  ),
)

After

newCall:
  bindSym"myProcedure"
  infix:
    newCall:
      bindSym"anotherProcedure"
      newLit(3)
    "+"
    newCall:
      bindSym"yetAnotherProcedure"
      newLit(5)

There's far less noise introduced by parentheses and commas, and it's far easier to add new parameters to any of the calls, as you don't have to remember to add a comma after each one.

Let's take a look at another example taken from nimterop.

Before

getHeader(
  "lzma.h",
  giturl = "https://github.com/xz-mirror/xz",
  dlurl = "https://tukaani.org/xz/xz-$1.tar.gz",
  conanuri = "xz_utils",
  jbburi = "xz",
  outdir = baseDir,
  conFlags = "--disable-xz --disable-xzdec --disable-lzmadec --disable-lzmainfo"
)

After

Since getHeader is a macro, this would need the hypothetical implementation for templates and macros, but if that's ever implemented, here's how the above example would improve:

getHeader("lzma.h"):
  giturl = "https://github.com/xz-mirror/xz"
  dlurl = "https://tukaani.org/xz/xz-$1.tar.gz"
  conanuri = "xz_utils"
  jbburi = "xz"
  outdir = baseDir
  conFlags = "--disable-xz --disable-xzdec --disable-lzmadec --disable-lzmainfo"

Without my syntax, the extra ) at the end introduces a weird almost-empty line after the call. This is fixed, and even improved upon by the extended call-with-block syntax, allowing to move the header in question lzma.h to a regular () argument list.

Backward incompatibility

Since the call-with-block syntax is mostly used with untyped templates and macros, and this RFC does not target them, there shouldn't be any backward incompatibilities introduced. The original behavior of interpreting a block as the last parameter will stay the same, as this RFC only makes the call-with-block syntax support passing more than one parameter.

haxscramper commented 3 years ago

While the proposed syntax is really nice, I'm not entirely sure that it is justified to add another way of calling functions.

Example with getHeader actually is ambiguous - how do I know if it is a parameter passing and assignment to some external variable defined elsewhere?

  1. You can use do statement multiple times to pass large blocks expressions to procedure parameters
  2. It is quite easy to implement DSL for passing parameters, without need to change language grammar
import macros, strutils

macro kvCall*(head, nodes: untyped): untyped =
  result = newCall(head)
  for node in nodes:
    result.add nnkExprEqExpr.newTree(node[0], node[1][0])

proc manyLongParams(a, b, c: string) = 
  echo "a: ", a
  echo "b: ", b
  echo "c: ", c

manyLongParams do:
  "first parameter"
do:
  "second parameter"
do:
  "Third parameter"

kvCall manyLongParams:
  a: "first argument"
  c: "C parameter"
  b: "B parameter"
liquidev commented 3 years ago

The getHeader example actually is just as ambiguous as any of my examples presented above. My idea was that the parameter block would introduce a new scope with all the parameters available as pseudo-variables, so assignment to parameters would shadow assignment to variables from outer scopes, however, these pseudo-variables would not be referencable, eg. in the lineRectangle examples position = size would not work (unless size is a variable declared somewhere outside of the block).

liquidev commented 3 years ago

If it could be made into a macro, I think it should land in the stdlib in the sugar module. Otherwise we'll end up with possibly tens of different macros, people reading code will have to refer to each one's documentation. I'd rather have this be standardized.

disruptek commented 3 years ago

I think the current solution of command syntax is sufficient for these examples, but if you don't want to "pollute scope", use a template instead of a variable.


lineRectangle graphics,
  position = tilemap.tileSize * (position.vec2f + offset.vec2f),
  size = tilemap.tileSize,
  thickness = 8,
  color = colGreen

Note that you can name unnamed parameters (and reorder them).

EDIT: added the example for getHeader:

"lzma.h".getHeader
  giturl = "https://github.com/xz-mirror/xz",
  dlurl = "https://tukaani.org/xz/xz-$1.tar.gz",
  conanuri = "xz_utils",
  jbburi = "xz",
  outdir = baseDir,
  conFlags = "--disable-xz --disable-xzdec --disable-lzmadec --disable-lzmainfo"
liquidev commented 3 years ago

If only your getHeader example parsed I wouldn't be opening this RFC :) Allowing for a more elegant syntax like this is the point here. lineRectangle graphics is kind of backwards from what I'd want, but I guess that can also work.

disruptek commented 3 years ago

It's a matter of style, but I prefer to emphasize the most significant part of the statement first; I'm assuming that this is lineRectangle or "lzma.h". You're right, I do kinda wish the getHeader worked. Again, I think a template is a reasonable concession to not altering the syntax.

Araq commented 3 years ago

Yet another call syntax is not negotiable, sorry. Also, you can get what you want via some multiLineCall macro:


let set = multiLineCall(TileSideSet):
  connectsTo(dx =  1, dy =  0).ord * tsseRight or
  connectsTo(dx =  0, dy =  1).ord * tsseBottom or
  connectsTo(dx = -1, dy =  0).ord * tsseLeft or
  connectsTo(dx =  0, dy = -1).ord * tsseTop
Araq commented 3 years ago

If it could be made into a macro, I think it should land in the stdlib in the sugar module. Otherwise we'll end up with possibly tens of different macros, people reading code will have to refer to each one's documentation. I'd rather have this be standardized.

There is some overlap with our existing with macro. Which was developed after people used comparable means for some time. Starting with your ideas as a Nimble package is a good development process.

metagn commented 11 months ago

Starting with your ideas as a Nimble package is a good development process.

Not trying to plug but https://github.com/metagn/spread seems to do what is described here