Skip to content

Concepts and type-checking generics #168

@Araq

Description

@Araq

Concept redesign

Goals:

  • Empower concepts to make generic code type-checkable at declaration time,
    not only at instantiation time.
  • Make concepts easier to understand and easier to implement.
  • Provide an escape hatch in generic code so that e.g. adding a
    debug echo or log statement does not require a far reaching type
    constraint addition.
  • Old code with unconstrainted generic parameters keeps to work.
  • Do not base concept's implementation on system.compiles which is
    under-specified in Nim, very tied to the current implementation, and
    finally, slow.

Non goals:

  • Support every little detail that the old concept design supported. For this
    we have the escape hatch plus the fact that you can leave your T
    underspecified.
  • Support accidentical features. ("Look! With this hack I can specify that the
    proc needs a cdecl calling convention!")
  • Support "cross parameter" constraints like "sizeof(a) == sizeof(b)". I have
    yet to see convincing examples of these cases. In the worst case, we could
    add enableif for this without bloating the concept's design.
  • Turning concepts into "interfaces". This can already be accomplished with
    macros. Having said that, since these concepts are declarative, they are
    easier to process with tooling and arguably easier to process with macros
    as well.

Atoms and containers

Concepts come in two forms: Atoms and containers. A container is a generic
concept like Iterable[T], an atom always lacks any kind of generic
parameter (as in Comparable).

Syntactically a concept consists of a list of proc and iterator
declarations. There are 3 syntatic additions:

  • Self is a builtin type within the concept's body stands for the
    current concept.
  • each is used to introduce a generic parameter T within the
    concept's body that is not listed within the concept's generic
    parameter list.
  • either orelse is used to provide basic support for optional
    procs within a concept.

We will see how these are used in the examples.

Atoms

  type
    Comparable = concept # no T, an atom
      proc cmp(a, b: Self): int

    ToStringable = concept
      proc `$`(a: Self): string

    Hashable = concept
      proc hash(x: Self): int
      proc `==`(x, y: Self): bool

    Swapable = concept
      proc swap(x, y: var Self)

Self stands for the currently defined concept itself. It is used to avoid
a recursion, proc cmp(a, b: Comparable): int is invalid.

Containers

A container has at least one generic parameter (most often called T). The
first syntactic usage of the generic parameter specifies how to infer and bind T.
Other usages of T are then checked to match what it was bound to.

  type
    Indexable[T] = concept # has a T, a collection
      proc `[]`(a: Self; index: int): T # we need to describe how to infer 'T'
      # and then we can use the 'T' and it must match:
      proc `[]=`(a: var Self; index: int; value: T)
      proc len(a: Self): int

Nothing interesting happens when we use multiple generic parameters:

  type
    Dictionary[K, V] = concept
      proc `[]`(a: Self; key: K): V
      proc `[]=`(a: var Self; key: K; value: V)

The usual ": Constraint" syntax can be used to add generic constraints to
the involved generic parameters:

  type
    Dictionary[K: Hashable; V] = concept
      proc `[]`(a: Self; key: K): V
      proc `[]=`(a: var Self; key: K; value: V)

each T

Note: each T is currently not implemented.

each T allows to introduce generic parameters that are not part of a
concept's generic parameter list. It is furthermore a special case to
allow for the common "every field has to fulfill property P" scenario:

  type
    Serializable = concept
      iterator fieldPairs(x: Self): (string, each T)
      proc write(x: T)


  proc writeStuff[T: Serializable](x: T) =
    for name, field in fieldPairs(x):
      write name
      write field

either orelse

Note: either orelse is currently not implemented.

In generic code it's often desirable to specialize the code in an ad-hoc manner.
system.addQuoted is an example of this:

  proc addQuoted[T](dest: var string; x: T) =
    when compiles(dest.add(x)):
      dest.add(x)
    else:
      dest.add($x)

If we want to describe T with a concept we need some way to describe optional
aspects. either orelse can be used:

  type
    Quotable = concept
      either:
        proc `$`(x: Self): string
      orelse:
        proc add(s: var string; elem: self)

  proc addQuoted[T: Quotable](s: var string; x: T) =
    when compiles(s.add(x)):
      s.add(x)
    else:
      s.add($x)

More examples

system.find

It's straight-forward:

  type
    Findable[T] = concept
      iterator items(x: Self): T
      proc `==`(a, b: T): bool

  proc find(x: Findable[T]; elem: T): int =
    var i = 0
    for a in x:
      if a == elem: return i
      inc i
    return -1

Sortable

Note that a declaration like

  type
    Sortable[T] = Indexable[T] and T is Comparable and T is Swapable

is possible but unwise. The reason is that Indexable either contains
too many procs we don't need or accessors that are slightly off as they don't
offer the right kind of mutability access.

Here is the proper definition:

  type
    Sortable[T] = concept
      proc `[]`(a: var Self; b: int): var T
      proc len(a: Self): int
      proc swap(x, y: var T)
      proc cmp(a, b: T): int

Concept matching

A type T matches a concept C if every proc and iterator header
H of C matches an entity E in the current scope.

The matching process is forgiving:

  • If H is a proc, E can be a proc, a func, a method, a template,
    a converter or a macro. E can have more parameters than H as long
    as these parameters have default values. The parameter names do not have
    to match.

  • If H has the form proc p(x: Self): T then E can be a public
    object field of name p and of type T.

  • If H is an iterator, E must be an iterator too, but E's parameter
    names do not have to match and it can have additional default parameters.

Escape hatch

Generic routines that have at least one concept parameter are type-checked at declaration time. To disable type-checking in certain code sections an untyped block can be used:

proc sort(x: var Sortable) = 
   ... 
   # damn this sort doesn't work, let's find out why:
   untyped:
      # no need to change 'Sortable' so that it mentions '$' for the involved
      # element type!
      echo x[i], " ", x[j]

EDITED 2021/03/09 self was renamed to Self and is what the experimental implementation uses.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions