Description
This was sitting in my Obsidian notes for a while so might have some issues.
Abstract
Allow declaring routines with an attached (atomic) concept/interface type that they implement for certain types.
Motivation
Kind of the same problem as #380: It's possible to have types in scope, but not their adjacent implementations of common interfaces like $
or hash
. This causes problems for generic procs that need these implementations in scope in order to use them. Instead of making this a problem of scope, we could use the type system to link these implementations with atomic descriptions of these interfaces. Currently in the language this can be represented with the "atomic" concept types added in 1.6.
type Hashable = concept
proc hash(x: Self): Hash
In addition to this, there is the problem of concepts depending on specific routine names. This can cause problems when different concepts use the same name for different intended behavior. It would be nice to include disambiguating information at the declaration site for situations like these.
Description
Say we have a Hashable
concept as above. Then, allow writing something like (syntax for this whole post is temporary):
proc hash(x: T): Hash for Hashable =
...
# or
proc Hashable.hash(x: T): Hash =
...
# the dot version might be misleading because we should still be able to use the proc without qualifying
# but the idea is that it's "the `hash` proc from the `Hashable` concept"
The conditions for this to compile are:
- this is an implementation of a component routine of the attached concept type (in this case
hash
) for some typeT
- either: the concept type is declared in the current scope/package, the type
T
implementing the concept is a nominal type declared in the current scope/package, or is a type "containing" such a nominal type (i.e.ref T
,seq[T]
,Atomic[T]
,(int, T)
)- if the proc is generic, this also extends to generic constraints, i.e.
proc hash[T: Nominal](x: seq[T])
- type classes like
Table[int | float, T]
,seq[T and Comparable]
,seq[T | U]
whereU
is another such nominal type are fine, butTable[int | T, string]
,seq[not T]
are not
- if the proc is generic, this also extends to generic constraints, i.e.
- this is not a redefinition in the "namespace" of the concept type
This proc can be used like any other proc, with a few additional behaviors:
- this proc is added to the "namespace" of the attached concept, i.e. stored in an internal list of procs attached to the concept type
- procs with the same signature but different implementations can be defined for different concept types
Then, when we do something like Hashable.hash(x)
, the hash
overloads in the "namespace" of Hashable
are considered as well as the procs in scope to find a matching overload of hash
for x
, with the procs in scope receiving priority (the syntax might be misleading for this though).
proc `[]`[K, V](t: Table[K, V], k: K): V =
let h = Hashable.hash(k)
...
How this is different from #380:
- Both attaching and using procs with this is explicit rather than automatic. This is a minor productivity hit but helps with clarity.
- By using concepts, we don't need as many special rules for which type the proc is implemented for. So it doesn't have to be the first parameter, can be a nested complex type/typeclass etc.
- We can also have "default" implementations at the concept declaration.
- Pretty compatible with the existing overload mechanism and symbol resolution in generics, shouldn't be difficult to implement or impact compiler performance. Also dead easy to cache.
The compiler can even make use of this to simplify and expose certain builtin overloading mechanisms if we declare special concepts in system
that the compiler recognizes. Use cases might be:
- Implicit
items
andpairs
iterators - Lifetime hooks (
=destroy
,=copy
,=sink
...), without the "scope" behavior - Converters in general, maybe saving a keyword
default
if we needed it
Yes this is like traits/typeclasses in other languages. But the meat of the feature is still Nim's overloading. We don't need it for every place that we use overloads.
Examples
# system.nim
type Stringable* = concept
proc `$`(x: Self): string
proc echo*(args: varargs[typed, Stringable.`$`]) {.magic: Echo.}
# a.nim
type Foo* = ref object
proc `$`*(x: Foo): string for Stringable =
"Foo"
# b.nim
import a # note `Foo` is not exported
proc getFoo*(): Foo =
Foo()
# c.nim
import b # note `Foo` is not imported
echo getFoo() # Foo