Skip to content

Commit

Permalink
implement type bound operation RFC (#24315)
Browse files Browse the repository at this point in the history
closes nim-lang/RFCs#380, fixes #4773, fixes
#14729, fixes #16755, fixes #18150, fixes #22984, refs #11167 (only some
comments fixed), refs #12620 (needs tiny workaround)

The compiler gains a concept of root "nominal" types (i.e. objects,
enums, distincts, direct `Foo = ref object`s, generic versions of all of
these). Exported top-level routines in the same module as the nominal
types that their parameter types derive from (i.e. with
`var`/`sink`/`typedesc`/generic constraints) are considered attached to
the respective type, as the RFC states. This happens for every argument
regardless of placement.

When a call is overloaded and overload matching starts, for all
arguments in the call that already have a type, we add any operation
with the same name in the scope of the root nominal type of each
argument (if it exists) to the overload match. This also happens as
arguments gradually get typed after every overload match. This restricts
the considered overloads to ones attached to the given arguments, as
well as preventing `untyped` arguments from being forcefully typed due
to unrelated overloads. There are some caveats:

* If no overloads with a name are in scope, type bound ops are not
triggered, i.e. if `foo` is not declared, `foo(x)` will not consider a
type bound op for `x`.
* If overloads in scope do not have enough parameters up to the argument
which needs its type bound op considered, then type bound ops are also
not added. For example, if only `foo()` is in scope, `foo(x)` will not
consider a type bound op for `x`.

In the cases of "generic interfaces" like `hash`, `$`, `items` etc. this
is not really a problem since any code using it will have at least one
typed overload imported. For arbitrary versions of these though, as in
the test case for #12620, a workaround is to declare a temporary
"template" overload that never matches:

```nim
# neither have to be exported, just needed for any use of `foo`:
type Placeholder = object
proc foo(_: Placeholder) = discard
```

I don't know what a "proper" version of this could be, maybe something
to do with the new concepts.

Possible directions:

A limitation with the proposal is that parameters like `a: ref Foo` are
not attached to any type, even if `Foo` is nominal. Fixing this for just
`ptr`/`ref` would be a special case, parameters like `seq[Foo]` would
still not be attached to `Foo`. We could also skip any *structural* type
but this could produce more than one nominal type, i.e. `(Foo, Bar)`
(not that this is hard to implement, it just might be unexpected).

Converters do not use type bound ops, they still need to be in scope to
implicitly convert. But maybe they could also participate in the nominal
type consideration: if `Generic[T] = distinct T` has a converter to `T`,
both `Generic` and `T` can be considered as nominal roots.

The other restriction in the proposal, being in the same scope as the
nominal type, could maybe be worked around by explicitly attaching to
the type, i.e.: `proc foo(x: T) {.attach: T.}`, similar to class
extensions in newer OOP languages. The given type `T` needs to be
obtainable from the type of the given argument `x` however, i.e.
something like `proc foo(x: ref T) {.attach: T.}` doesn't work to fix
the `ref` issue since the compiler never obtains `T` from a given `ref
T` argument. Edit: Since the module is queried now, this is likely not
possible.

---------

Co-authored-by: Andreas Rumpf <rumpf_a@web.de>
  • Loading branch information
metagn and Araq authored Oct 25, 2024
1 parent dd3a4b2 commit 2864830
Show file tree
Hide file tree
Showing 32 changed files with 735 additions and 7 deletions.
34 changes: 34 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,40 @@ rounding guarantees (via the

## Language changes

- An experimental option `--experimental:typeBoundOps` has been added that
implements the RFC https://github.com/nim-lang/RFCs/issues/380.
This makes the behavior of interfaces like `hash`, `$`, `==` etc. more
reliable for nominal types across indirect/restricted imports.

```nim
# objs.nim
import std/hashes
type
Obj* = object
x*, y*: int
z*: string # to be ignored for equality
proc `==`*(a, b: Obj): bool =
a.x == b.x and a.y == b.y
proc hash*(a: Obj): Hash =
$!(hash(a.x) &! hash(a.y))
```

```nim
# main.nim
{.experimental: "typeBoundOps".}
from objs import Obj # objs.hash, objs.`==` not imported
import std/tables
var t: Table[Obj, int]
t[Obj(x: 3, y: 4, z: "debug")] = 34
echo t[Obj(x: 3, y: 4, z: "ignored")] # 34
```

See the [experimental manual](https://nim-lang.github.io/Nim/manual_experimental.html#typeminusbound-overloads)
for more information.

## Compiler changes

Expand Down
1 change: 1 addition & 0 deletions compiler/options.nim
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ type
# alternative to above:
genericsOpenSym
vtables
typeBoundOps

LegacyFeature* = enum
allowSemcheckedAstModification,
Expand Down
63 changes: 62 additions & 1 deletion compiler/semcall.nim
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,39 @@ proc initCandidateSymbols(c: PContext, headSymbol: PNode,
result[0].scope, diagnostics)
best.state = csNoMatch

proc isAttachableRoutineTo(prc: PSym, arg: PType): bool =
result = false
if arg.owner != prc.owner: return false
for i in 1 ..< prc.typ.len:
if prc.typ.n[i].kind == nkSym and prc.typ.n[i].sym.ast != nil:
# has default value, parameter is not considered in type attachment
continue
let t = nominalRoot(prc.typ[i])
if t != nil and t.itemId == arg.itemId:
# parameter `i` is a nominal type in this module
# attachable if the nominal root `t` has the same id as `arg`
return true

proc addTypeBoundSymbols(graph: ModuleGraph, arg: PType, name: PIdent,
filter: TSymKinds, marker: var IntSet,
syms: var seq[tuple[s: PSym, scope: int]]) =
# add type bound ops for `name` based on the argument type `arg`
if arg != nil:
# argument must be typed first, meaning arguments always
# matching `untyped` are ignored
let t = nominalRoot(arg)
if t != nil and t.owner.kind == skModule:
# search module for routines attachable to `t`
let module = t.owner
var iter = default(ModuleIter)
var s = initModuleIter(iter, graph, module, name)
while s != nil:
if s.kind in filter and s.isAttachableRoutineTo(t) and
not containsOrIncl(marker, s.id):
# least priority scope, less than explicit imports:
syms.add((s, -2))
s = nextModuleIter(iter, graph)

proc pickBestCandidate(c: PContext, headSymbol: PNode,
n, orig: PNode,
initialBinding: PNode,
Expand All @@ -88,10 +121,23 @@ proc pickBestCandidate(c: PContext, headSymbol: PNode,
best, alt, o, diagnosticsFlag)
if len(syms) == 0:
return
let allowTypeBoundOps = typeBoundOps in c.features and
# qualified or bound symbols cannot refer to type bound ops
headSymbol.kind in {nkIdent, nkAccQuoted, nkOpenSymChoice, nkOpenSym}
var symMarker = initIntSet()
for s in syms:
symMarker.incl(s.s.id)
# current overload being considered
var sym = syms[0].s
let name = sym.name
var scope = syms[0].scope

if allowTypeBoundOps:
for a in 1 ..< n.len:
# for every already typed argument, add type bound ops
let arg = n[a]
addTypeBoundSymbols(c.graph, arg.typ, name, filter, symMarker, syms)

# starts at 1 because 0 is already done with setup, only needs checking
var nextSymIndex = 1
var z: TCandidate # current candidate
Expand All @@ -106,6 +152,14 @@ proc pickBestCandidate(c: PContext, headSymbol: PNode,
# may introduce new symbols with caveats described in recalc branch
matches(c, n, orig, z)

if allowTypeBoundOps:
# this match may have given some arguments new types,
# in which case add their type bound ops as well
# type bound ops of arguments always matching `untyped` are not considered
for x in z.newlyTypedOperands:
let arg = n[x]
addTypeBoundSymbols(c.graph, arg.typ, name, filter, symMarker, syms)

if z.state == csMatch:
# little hack so that iterators are preferred over everything else:
if sym.kind == skIterator:
Expand Down Expand Up @@ -136,7 +190,14 @@ proc pickBestCandidate(c: PContext, headSymbol: PNode,
# before any further candidate init and compare. SLOW, but rare case.
syms = initCandidateSymbols(c, headSymbol, initialBinding, filter,
best, alt, o, diagnosticsFlag)

symMarker = initIntSet()
for s in syms:
symMarker.incl(s.s.id)
if allowTypeBoundOps:
for a in 1 ..< n.len:
# for every already typed argument, add type bound ops
let arg = n[a]
addTypeBoundSymbols(c.graph, arg.typ, name, filter, symMarker, syms)
# reset counter because syms may be in a new order
symCount = c.currentScope.symbols.counter
nextSymIndex = 0
Expand Down
25 changes: 19 additions & 6 deletions compiler/sigmatch.nim
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ type
inheritancePenalty: int
firstMismatch*: MismatchInfo # mismatch info for better error messages
diagnosticsEnabled*: bool
newlyTypedOperands*: seq[int]
## indexes of arguments that are newly typechecked in this match
## used for type bound op additions

TTypeRelFlag* = enum
trDontBind
Expand Down Expand Up @@ -2728,7 +2731,7 @@ proc setSon(father: PNode, at: int, son: PNode) =
# father[i] = newNodeIT(nkEmpty, son.info, getSysType(tyVoid))

# we are allowed to modify the calling node in the 'prepare*' procs:
proc prepareOperand(c: PContext; formal: PType; a: PNode): PNode =
proc prepareOperand(c: PContext; formal: PType; a: PNode, newlyTyped: var bool): PNode =
if formal.kind == tyUntyped and formal.len != 1:
# {tyTypeDesc, tyUntyped, tyTyped, tyError}:
# a.typ == nil is valid
Expand All @@ -2746,15 +2749,17 @@ proc prepareOperand(c: PContext; formal: PType; a: PNode): PNode =
#elif formal.kind == tyTyped: {efDetermineType, efWantStmt}
#else: {efDetermineType}
result = c.semOperand(c, a, flags)
newlyTyped = true
else:
result = a
considerGenSyms(c, result)
if result.kind != nkHiddenDeref and result.typ.kind in {tyVar, tyLent} and c.matchedConcept == nil:
result = newDeref(result)

proc prepareOperand(c: PContext; a: PNode): PNode =
proc prepareOperand(c: PContext; a: PNode, newlyTyped: var bool): PNode =
if a.typ.isNil:
result = c.semOperand(c, a, {efDetermineType})
newlyTyped = true
else:
result = a
considerGenSyms(c, result)
Expand Down Expand Up @@ -2880,7 +2885,9 @@ proc matchesAux(c: PContext, n, nOrig: PNode, m: var TCandidate, marker: var Int
noMatch()
m.baseTypeMatch = false
m.typedescMatched = false
n[a][1] = prepareOperand(c, formal.typ, n[a][1])
var newlyTyped = false
n[a][1] = prepareOperand(c, formal.typ, n[a][1], newlyTyped)
if newlyTyped: m.newlyTypedOperands.add(a)
n[a].typ() = n[a][1].typ
arg = paramTypesMatch(m, formal.typ, n[a].typ,
n[a][1], n[a][1])
Expand All @@ -2904,7 +2911,9 @@ proc matchesAux(c: PContext, n, nOrig: PNode, m: var TCandidate, marker: var Int
if tfVarargs in m.callee.flags:
# is ok... but don't increment any counters...
# we have no formal here to snoop at:
n[a] = prepareOperand(c, n[a])
var newlyTyped = false
n[a] = prepareOperand(c, n[a], newlyTyped)
if newlyTyped: m.newlyTypedOperands.add(a)
if skipTypes(n[a].typ, abstractVar-{tyTypeDesc}).kind==tyString:
m.call.add implicitConv(nkHiddenStdConv,
getSysType(c.graph, n[a].info, tyCstring),
Expand All @@ -2918,7 +2927,9 @@ proc matchesAux(c: PContext, n, nOrig: PNode, m: var TCandidate, marker: var Int
m.baseTypeMatch = false
m.typedescMatched = false
incl(marker, formal.position)
n[a] = prepareOperand(c, formal.typ, n[a])
var newlyTyped = false
n[a] = prepareOperand(c, formal.typ, n[a], newlyTyped)
if newlyTyped: m.newlyTypedOperands.add(a)
arg = paramTypesMatch(m, formal.typ, n[a].typ,
n[a], nOrig[a])
if arg != nil and m.baseTypeMatch and container != nil:
Expand Down Expand Up @@ -2954,7 +2965,9 @@ proc matchesAux(c: PContext, n, nOrig: PNode, m: var TCandidate, marker: var Int
else:
m.baseTypeMatch = false
m.typedescMatched = false
n[a] = prepareOperand(c, formal.typ, n[a])
var newlyTyped = false
n[a] = prepareOperand(c, formal.typ, n[a], newlyTyped)
if newlyTyped: m.newlyTypedOperands.add(a)
arg = paramTypesMatch(m, formal.typ, n[a].typ,
n[a], nOrig[a])
if arg == nil:
Expand Down
64 changes: 64 additions & 0 deletions compiler/types.nim
Original file line number Diff line number Diff line change
Expand Up @@ -1935,3 +1935,67 @@ proc isCharArrayPtr*(t: PType; allowPointerToChar: bool): bool =
result = false
else:
result = false

proc nominalRoot*(t: PType): PType =
## the "name" type of a given instance of a nominal type,
## i.e. the type directly associated with the symbol where the root
## nominal type of `t` was defined, skipping things like generic instances,
## aliases, `var`/`sink`/`typedesc` modifiers
##
## instead of returning the uninstantiated body of a generic type,
## returns the type of the symbol instead (with tyGenericBody type)
result = nil
case t.kind
of tyAlias, tyVar, tySink:
# varargs?
result = nominalRoot(t.skipModifier)
of tyTypeDesc:
# for proc foo(_: type T)
result = nominalRoot(t.skipModifier)
of tyGenericInvocation, tyGenericInst:
result = t
# skip aliases, so this works in the same module but not in another module:
# type Foo[T] = object
# type Bar[T] = Foo[T]
# proc foo[T](x: Bar[T]) = ... # attached to type
while result.skipModifier.kind in {tyGenericInvocation, tyGenericInst}:
result = result.skipModifier
result = nominalRoot(result[0])
of tyGenericBody:
result = t
# this time skip the aliases but take the generic body
while result.skipModifier.kind in {tyGenericInvocation, tyGenericInst}:
result = result.skipModifier[0]
let val = result.skipModifier
if val.kind in {tyDistinct, tyEnum, tyObject} or
(val.kind in {tyRef, tyPtr} and tfRefsAnonObj in val.flags):
# atomic nominal types, this generic body is attached to them
discard
else:
result = nominalRoot(val)
of tyCompositeTypeClass:
# parameter with type Foo
result = nominalRoot(t.skipModifier)
of tyGenericParam:
if t.genericParamHasConstraints:
# T: Foo
result = nominalRoot(t.genericConstraint)
else:
result = nil
of tyDistinct, tyEnum, tyObject:
result = t
of tyPtr, tyRef:
if tfRefsAnonObj in t.flags:
# in the case that we have `type Foo = ref object` etc
result = t
else:
# we could allow this in general, but there's things like `seq[Foo]`
#result = nominalRoot(t.skipModifier)
result = nil
of tyStatic:
# ?
result = nil
else:
# skips all typeclasses
# is this correct for `concept`?
result = nil
Loading

0 comments on commit 2864830

Please sign in to comment.