Skip to content
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

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open

Conversation

rossberg
Copy link
Contributor

@rossberg rossberg commented Nov 3, 2021

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.

@matthewhammer
Copy link
Contributor

matthewhammer commented Nov 3, 2021

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?:

type wallet = service {
  topup : (amount : nat) -> ();
  forward : (call : () -> ()) -> ();
}

I see three options for the non-unit return type of call and forward:

  1. Blob
  2. dynamic (see Add support for type dynamic #292)
  3. a variant of dynamic that I will call dynamics (e.g., see comment Feature Idea: Generic data #245 (comment))

1. Use Blob

forward : (call : () -> Blob) -> Blob;

And then assume that this Blob holds a candid response sequence, not a single candid value.

Pros: Blob exists today in Candid and Motoko.
Cons: Blob conversions are explicit, and perhaps not ergonomic yet. They could also have type errors.

2. Use dynamic (see #292)

forward : (call : () -> dynamic) -> dynamic;

Pros: Could be a safer option to option 1, since conversions would have dynamic checking and auto trapping (I presume).
Cons: Unfortunately, this API is not quite as "general" as the first, since dynamic cannot encode a sequence of values, but only one value. See option 3.

3. Use dynamics (see comments to #292)

forward : (call : () -> dynamics) -> dynamics;

Pros: Actually solves the problem (I think).
Cons: Furtherest of the options from what we have today.

spec/Candid.md Outdated Show resolved Hide resolved
spec/Candid.md Outdated
```
type wallet = service {
topup : (amount : nat) -> ();
forward : (call : () -> ()) -> ();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
forward : (call : () -> ()) -> ();
forward : (call : func () -> ()) -> ();

syntactically, how do we know if the function has bound arguments? Does the closure supports return type?

Copy link
Contributor Author

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?

Copy link
Contributor

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.

Copy link
Contributor

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
Copy link
Contributor

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?

Copy link
Contributor Author

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...

Copy link
Collaborator

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?

Copy link
Contributor Author

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.

@rossberg
Copy link
Contributor Author

rossberg commented Nov 3, 2021

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?

Copy link
Collaborator

@nomeata nomeata left a 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
Copy link
Collaborator

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

Copy link
Contributor Author

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
Copy link
Collaborator

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.
Copy link
Collaborator

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).

Suggested change
* `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.

Copy link
Contributor Author

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.

@rossberg
Copy link
Contributor Author

rossberg commented Nov 5, 2021

@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 TM function I now introduced; however, I'm not sure we want that – for security reasons, the deserialiser should validate the data anyway to the extend it can, and could separate the individual values right there.)

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.)

Copy link
Collaborator

@nomeata nomeata left a 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 refs)?

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>, .)
Copy link
Collaborator

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

Suggested change
C[func <functype> <: closure <functype'>](func <text>.<id>) = clos(func <text>.<id>, .)
C[func <functype> <: closure <functype'>](f) = clos(f, .)

Copy link
Contributor Author

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>*) =
Copy link
Collaborator

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?

Copy link
Contributor Author

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*)
Copy link
Collaborator

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?

Copy link
Contributor Author

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>)
Copy link
Collaborator

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, changed.

Copy link
Contributor Author

@rossberg rossberg left a 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>, .)
Copy link
Contributor Author

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>)
Copy link
Contributor Author

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>*) =
Copy link
Contributor Author

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*)
Copy link
Contributor Author

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 Show resolved Hide resolved
@nomeata
Copy link
Collaborator

nomeata commented Nov 9, 2021

But that would require a backwards-incompatible change to the encoding of calls, wouldn't it?

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>
@nomeata
Copy link
Collaborator

nomeata commented Nov 9, 2021

(For some reason I can't mark conversations as resolved here)

spec/Candid.md Outdated Show resolved Hide resolved
Co-authored-by: Claudio Russo <claudio@dfinity.org>
ninegua pushed a commit to ninegua/candid that referenced this pull request Apr 22, 2022
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants