-
Notifications
You must be signed in to change notification settings - Fork 81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for bound function arguments #291
base: master
Are you sure you want to change the base?
Conversation
LGTM! My only nit is about the other companion PR, and how to compose this PR with that one in a larger motivating example. Let's say that the wallet call wants to return some data, and we don't know what it is, or even its arity? How would we adapt this?:
I see three options for the non-unit return type of
1. Use
|
spec/Candid.md
Outdated
``` | ||
type wallet = service { | ||
topup : (amount : nat) -> (); | ||
forward : (call : () -> ()) -> (); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
forward : (call : () -> ()) -> (); | |
forward : (call : func () -> ()) -> (); |
syntactically, how do we know if the function has bound arguments? Does the closure supports return type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You mean, know it by the type? We don't, it's always allowed. Do you think that would cause issues for bindings?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. The existing example has a callback
function, which can have bound arguments as well. You are adding another example to illustrate the use of bound arguments, which makes me think there may be syntactic difference for the bound args.
So any function reference can have bound arguments. The return value will be decoded according to the function signature, right? That means we need an abstraction for argument tuples. And if we use the dynamic
type as return type, dynamic
represents argument tuples instead of a single argument.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That means we need an abstraction for argument tuples.
Yep. That's my thinking too, and more agreement in the comments for #292
spec/Candid.md
Outdated
|
||
M(ref(r) : principal) = i8(0) | ||
M(id(v*) : principal) = i8(1) M(v* : vec nat8) | ||
|
||
M* : <val>* -> <datatype>* -> i8* | ||
M*(v^N : <datatype>^N) = leb128(N) M(v : <datatype>)^N |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this will be a breaking change? or the bound argument is stored in R
which was not used before?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, R is only used if there are reference types that need it, as before. The type is determined from M.
But I think this is a breaking change in so far that a receiver not understanding the new encoding of closures would choke on it. The only way to avoid this is by indeed introducing a separate closure type as a future type, it seems. Hm, that would be ugly...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A new type seems to be appropriate. Not breaking existing stuff is part of our value proposition, and given the complexity of this maybe it’s good if a service can say that they really only support plain function references, but not closures?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, introduced a new (future) type for closures and made it a supertype of func.
For the time being I think it's fine if this pattern only worked for functions of fixed arity. If we wanted variadic abstraction, maybe we could make a tuple record coercible to a parameter list and vice versa, analogous to what we do in Motoko. Perhaps n-ary functions as a primitive in Candid were a mistake (as @nomeata always argued, though for different reasons). If we introduced such a coercion, could we retroactively pepper over it? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A method call f(x,y)
now does not just mean “encode (x,y)
via Candid and send”, because f
could be a closure. This requires
- a heap representation of a Candid closure
f
, consisting of (likely)- service reference
- method name
- original type table from the message that carried
f
(pruned to the actual used types? copied as is? Probably pruning is not possible if the bound values are of a future type we don’t understand) - the candid encoded value, kept as an opaque blob (no need to decode, and because of future types we probably can’t)
- the ability to merge that type table with the type table we’d already generate for the explicitly passed
(x,y)
That’s a quite high “implementation complexity price” to be paid, so I am overall quite wairy of this.
spec/Candid.md
Outdated
|
||
M(ref(r) : principal) = i8(0) | ||
M(id(v*) : principal) = i8(1) M(v* : vec nat8) | ||
|
||
M* : <val>* -> <datatype>* -> i8* | ||
M*(v^N : <datatype>^N) = leb128(N) M(v : <datatype>)^N |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where is the type of v
encoded? I’d expect something like
M*(v^N : <datatype>^N) = leb128(N) I(<datatype>^N)^N M(v : <datatype>)^N
to match what we do in B
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, good catch, fixed.
spec/Candid.md
Outdated
|
||
M(ref(r) : principal) = i8(0) | ||
M(id(v*) : principal) = i8(1) M(v* : vec nat8) | ||
|
||
M* : <val>* -> <datatype>* -> i8* | ||
M*(v^N : <datatype>^N) = leb128(N) M(v : <datatype>)^N |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A new type seems to be appropriate. Not breaking existing stuff is part of our value proposition, and given the complexity of this maybe it’s good if a service can say that they really only support plain function references, but not closures?
spec/Candid.md
Outdated
@@ -1064,7 +1076,7 @@ Most Candid values are self-explanatory, except for references. There are two fo | |||
Likewise, there are two forms of Candid values for function references: | |||
|
|||
* `ref(r)` indicates an opaque reference, understood only by the underlying system. | |||
* `pub(s,n)`, indicates the public method name `n` of the service referenced by `s`. | |||
* `pub(s,n,v*:t*)`, indicates the public method name `n` of the service referenced by `s`, possibly followed by a list of type-annotated bound argument values. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don’t want to support binding arguments to references?
Why not make it a recursive definition that allows you to bind arguments to any function reference (whether it’s opaque, public, or itself a closure – after all, these are all values of the same type, so binding to them should be allowed).
* `pub(s,n,v*:t*)`, indicates the public method name `n` of the service referenced by `s`, possibly followed by a list of type-annotated bound argument values. | |
* `closure(f,v*:t*)`, indicates the function reference `f`, followed by a list of type-annotated bound argument values. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yes, that was an oversight. Refactored as you suggest.
@nomeata, fair points about constructing the new call. I would assume that the serialiser would not merge but merely extend the type table it gets from the closure. Since we allow duplication, it could do so blindly. For the argument tuple, if it extended the type table from the closure as just said, then I agree that it suffices to just copy over the serialised blob for each value. (We could even add an leb128 with the length for each value's encoding to the So yes, some work is required, but it doesn't seem too bad? (With hindsight, I actually think it was an oversight that our function types did not allow closures from the beginning. It seems like a glaring omission for a higher-order data format.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, if you put the type table from the closure first, you only have to renumber the type indices you are appending. Still more work than now (where the full type table and thus these indices are pre-computed by the Motoko compiler.)
I still see a problem where you need to pass a closure as an argument to a closure, because now you need to merge and renumber after all. And that's not possible: our future types prevent any kind of operations on type tables or indices.
So bound arguments need their own type table, both in the closure, and in the final call.
So that leads to the nicely simple design where the bound arguments in a closure are simply complete argument sequences with their own type table (i.e. B(args)
, and we allow the concatenation of encoded argument sequences to represent the encoding of the concatenation (yay, distributivity laws :-)).
Overall, it seems that this feature has very narrow use cases on our platform, because of the prevalent authentication via caller
. Canister can really only ever call closures received from fully trusted users. Anything more interesting likely need system-level closures. Maybe worth satisfying the proxy-from-ttusted-user use case without candid support (raw calls), and push for system level closures instead (which then may not even need changes to Candid, since these would be ref
s)?
spec/Candid.md
Outdated
@@ -987,6 +1004,12 @@ C[service <actortype> <: service <actortype'>](service <text>) = service <text> | |||
C[principal <: principal](principal <text>) = principal <text> | |||
``` | |||
|
|||
However, functions can be converted into closures with an empty list of bound arguments: | |||
``` | |||
C[func <functype> <: closure <functype'>](func <text>.<id>) = clos(func <text>.<id>, .) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This equation should hold for all forms of function values
C[func <functype> <: closure <functype'>](func <text>.<id>) = clos(func <text>.<id>, .) | |
C[func <functype> <: closure <functype'>](f) = clos(f, .) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yes.
@@ -1131,7 +1159,10 @@ T : <reftype> -> i8* | |||
T(func (<datatype1>*) -> (<datatype2>*) <funcann>*) = | |||
sleb128(-22) T*(<datatype1>*) T*(<datatype2>*) T*(<funcann>*) // 0x6a | |||
T(service {<methtype>*}) = | |||
sleb128(-23) T*(<methtype>*) // 0x69 | |||
sleb128(-23) T*(<methtype>*) // 0x69 | |||
T(closure (<datatype1>*) -> (<datatype2>*) <funcann>*) = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A helper function for “prepend length for future type” would help here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
spec/Candid.md
Outdated
M(clos(f,v*:t*) : closure <functype>) = | ||
leb128(|i8(2) M(f : func <functype>) TM*(v* : t*)|) | ||
leb128(|R(f : func <functype>) R*(v* : t*)|) | ||
i8(2) M(f : func <functype>) TM*(v* : t*) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dito, a helper function might clarify this somehow?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done'
spec/Candid.md
Outdated
M* : <val>* -> <datatype>* -> i8* | ||
M*(v^N : <datatype>^N) = leb128(N) M(v : <datatype>)^N | ||
TM : <val> -> <datatype> -> i8* | ||
TM(v : <datatype>) = T(<datatype>) M(v : <datatype>) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not I
, as in other places where we refer to types?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, changed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So bound arguments need their own type table, both in the closure, and in the final call.
So that leads to the nicely simple design where the bound arguments in a closure are simply complete argument sequences with their own type table (i.e. B(args), and we allow the concatenation of encoded argument sequences to represent the encoding of the concatenation (yay, distributivity laws :-)).
Interesting point. But that would require a backwards-incompatible change to the encoding of calls, wouldn't it?
I suppose we could circumvent all this by not allowing currying but only complete binding, i.e., thunks instead of partially applied closures. But that perhaps is a bit too specialised...
spec/Candid.md
Outdated
@@ -987,6 +1004,12 @@ C[service <actortype> <: service <actortype'>](service <text>) = service <text> | |||
C[principal <: principal](principal <text>) = principal <text> | |||
``` | |||
|
|||
However, functions can be converted into closures with an empty list of bound arguments: | |||
``` | |||
C[func <functype> <: closure <functype'>](func <text>.<id>) = clos(func <text>.<id>, .) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yes.
spec/Candid.md
Outdated
M* : <val>* -> <datatype>* -> i8* | ||
M*(v^N : <datatype>^N) = leb128(N) M(v : <datatype>)^N | ||
TM : <val> -> <datatype> -> i8* | ||
TM(v : <datatype>) = T(<datatype>) M(v : <datatype>) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, changed.
@@ -1131,7 +1159,10 @@ T : <reftype> -> i8* | |||
T(func (<datatype1>*) -> (<datatype2>*) <funcann>*) = | |||
sleb128(-22) T*(<datatype1>*) T*(<datatype2>*) T*(<funcann>*) // 0x6a | |||
T(service {<methtype>*}) = | |||
sleb128(-23) T*(<methtype>*) // 0x69 | |||
sleb128(-23) T*(<methtype>*) // 0x69 | |||
T(closure (<datatype1>*) -> (<datatype2>*) <funcann>*) = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
spec/Candid.md
Outdated
M(clos(f,v*:t*) : closure <functype>) = | ||
leb128(|i8(2) M(f : func <functype>) TM*(v* : t*)|) | ||
leb128(|R(f : func <functype>) R*(v* : t*)|) | ||
i8(2) M(f : func <functype>) TM*(v* : t*) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done'
Oh, right, of course. But that means we have an impossibly result: we can't merge type tables (because of future types), but we also can't keep them separate (because it wouldn't be backwards compatible). This seems to prevent currying… |
Co-authored-by: Joachim Breitner <mail@joachim-breitner.de>
(For some reason I can't mark conversations as resolved here) |
Co-authored-by: Claudio Russo <claudio@dfinity.org>
* update: changes to agent and authentication packages * update: locking repo to node 12 * fix: typescript type safety * greening tests * modifying node version * updating linting * Dont use typescript 'as string' override in idp-protocol/request (dfinity#291) * use lockfileVersion=2 (npm) (dfinity#292) * implementing feedback from @Bengo Co-authored-by: Benjamin Goering <benjamin.goering@dfinity.org>
This specs out the Candid side of the "closure" extension I suggested here.
WDYT, does this work?
Edit: see also #292, for a complementary extension with type
dynamic
.