Skip to content

Typesafe first class templates #212

@metagn

Description

@metagn

This RFC is a pipe dream for me and also a response to a comment by Araq in a relevant PR, though that PR is more general than this RFC. I'm also not expecting this any time soon, the point of 1.0 version releases should be to promote language stability and usage, not for new language concepts altogether.

Abstract

This RFC proposes the addition of first class compile time template variables, to allow functional AST transforms.

Motivation

Many of the issues are addressed already in nim-lang/Nim#11992, but this is a simpler subset of that PR and the problem can be expressed more simply as well.

The distinction between compile time rewriting and function passing in functional programming idioms like sequtils.mapIt and sequtils.map is annoying for newcomers to grasp. The implementation of idioms like sugar.dup have to be implemented in macros and account for things like syntax sugar due to the lack of a simple compile time function-like transform.

Description

First class templates come in 3 parts: a new template type similar to the proc type and iterator type, a way to treat templates as values, and anonymous templates.

1. The template type

This type will not be available as a runtime value, it will be a compile time value similar to typedesc. It will be denoted like template (a: T): S (nkTemplateTy like nkProcTy), a nullary template returning void as template(), and the generic typeclass representing all template types as simply template. Pragmas will not be part of the type information unlike the proc type.

The limitations of the types of the arguments for a template type are tricky, for now I can say they can take concrete types for arguments. auto/untyped might be available, since they don't require implicit generics, but typed/static/type/typedesc all do use implicit generics, so they might not be available.

2. Templates as values

This is again similar to typedesc, they cannot just be stored in constants or compile time variables, but they can work as routine arguments (or even generic arguments). The way to pass them around is simply to refer to them in an identifier, like procvars. This is backwards incompatible for nullary templates: they will be instantiated if used like this. This is not mentioned in the manual, so it's relatively safe to give it a new meaning, though there should still be a backwards compatibility switch for a transition method with either --useVersion or with a define. Here is an example of the current behaviour:

template bar = echo 3

bar # => 3

template foo(x: int) = echo x

foo # Error: expression 'foo' is of type 'template (x: int)' and has to be discarded; for a function call use ()

It acknowledges that foo where foo has a positive arity has the type of a template, so the Nim compiler is in line with how we want it to treat template name identifiers. There are 2 solutions of how to keep the old behaviour while allowing the new behavior:

Inferred template variables

proc foo(templ: template()) = templ # void context, instantiated
proc templateWithValue[T](templ: template: T) =
  echo(templ) # obviously, we can't echo templates, nullary template variables that bind to any T are instantiated
proc baz(x: int) = echo x

template bar = echo "hi"
template nonVoid: int = 3

foo(bar) # inferred to be a template variable and passed to foo
bar # instantiated
baz(bar) # instantiated, fails with "type mismatch"

nonVoid # instantiated, fails with "has to be discarded"
templateWithValue(nonVoid) # passed as template variable, outputs 3
baz(nonVoid) # instantiated, outputs 3

I don't know if Nim has strong enough type inference for something like this, but I don't see it outside of the realm of possibility.

Explicit template reference

This is a last resort in relation to inferred template variables, and there are multiple ways to do this:

  1. Proc defined as a magic:
# symbol irrelevant here, can be not an operator, feel free to bikeshed
proc `&`(templ: untyped): template {.magic: ExplicitTemplateVar.}
# if number 1 is implemented:
proc `&`[T: template](templ: T): T = templ

proc foo(templ: template()) = templ
proc templateWithValue[T](templ: template: T) =
echo(templ)
proc baz(x: int) = echo x

template bar = echo "hi"
template nonVoid: int = 3

foo(bar) # fails
foo(&bar) # works
bar # also works
baz(bar) # fails the same as foo(bar)
baz(&bar) # type mismatch

templateWithValue(nonVoid) # fails
templateWithValue(&nonVoid) # works
baz(nonVoid) # works
baz(&nonVoid) # type mismatch
  1. Surrounding AST; from Araq, surrounding the identifier with parentheses:
proc foo(templ: template()) = templ
proc templateWithValue[T](templ: template: T) =
echo(templ)
proc baz(x: int) = echo x

template bar = echo "hi"
template nonVoid: int = 3

foo(bar) # fails
foo((bar)) # works
foo (bar) # also works
bar # also works
baz(bar) # fails the same as foo(bar)
baz((bar)) # type mismatch

templateWithValue(nonVoid) # fails
templateWithValue((nonVoid)) # works
baz(nonVoid) # works
baz((nonVoid)) # type mismatch

3. Anonymous templates:

Similar to anonymous procs in syntax. These aren't very hard to explain, except do notation behaviour when accounting for them. If the type of the last argument of a routine being called with do notation is strictly a template, it will turn the do block into an anonymous template. If it has an untyped/auto argument, it will turn it into an anonymous template. If it's anything else, including a generic T that has no bound, or template | proc, it will become a proc.

Examples

Before

import sequtils

let a = @[1, 2, 3, 4, 5]

template increment(x: int): int = x + 1

echo map(a, proc (x: int): int = increment(x))
echo mapIt(a, increment(it))
# 2 separate approaches, one is more efficient but one is typesafe

After

proc map[T, S](s: openarray[T], op: (template (x: T): S) or (proc (x: T): S)): seq[T] =
  newSeq(result, s.len)
  for i in 0 ..< s.len:
    result[i] = op(s[i])

let a = @[1, 2, 3, 4, 5]

template increment(x: int): int = x + 1

echo map(a, increment) # @[2, 3, 4, 5, 6]
echo map(a, template (x: int): int = x * 2) # @[2, 4, 6, 8, 10]
echo map(a, proc (x: int): int = x * 2) # @[2, 4, 6, 8, 10]
# reimplementation of sugar.dup without the sugar
template dup[T](x: T, op: template(it: var T)): T =
  var it = x
  op(it)
  it

let a = @[1, 2, 3, 4, 5, 6, 7, 8, 9]
doAssert(a.dup(template (it: var seq[int]) = # would benefit from type inference here
  it.add(10)
  it.sort()) == @[1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
doAssert a == @[1, 2, 3, 4, 5, 6, 7, 8, 9]
proc get[T](x: Option[T], otherwise: template(): T): T =
  if x.isSome:
    result = unsafeGet(x)
  else:
    result = otherwise()
iterator filter[T](s: openarray[T], op: (template (x: T): bool) or (proc (x: T): bool)): T =
  for it in s:
    if op(it): yield it

let a = @[1, 2, 3, 4, 5]

import sugar

# => here is valid, template(it) is actually template(it: untyped): void and nkTemplateTy
# another operator is fine though like ~>
for n in a.filter(template(it) => bool(it and 1)):
  echo n
  if n == 3: break
# 1
# 3

Backwards incompatibility

As mentioned before, identifiers with template names where the template is nullary will have different behaviour, but not necessarily backwards incompatible. (plus this feature isn't documented well, see nim-lang/Nim#7803)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions