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

{impersonate,chaperone}-procedure should allow reducing procedure arity #2310

Open
lexi-lambda opened this issue Oct 11, 2018 · 2 comments
Open

Comments

@lexi-lambda
Copy link
Member

lexi-lambda commented Oct 11, 2018

The problem

Currently, impersonate-procedure and chaperone-procedure complain if the wrapper procedure has a smaller arity than the procedure being impersonated. On its surface, this sounds like a reasonable decision: it means that two procedures that are equal? will both return the same value for procedure-arity. However, in practice, this doesn’t make any sense, because one of the primary purposes of procedure impersonators and chaperones is to (morally) restrict procedure arity—function contracts regularly implement arity restriction, though they must do it in an ad-hoc way.

For example, consider applying an -> contract to a function with optional arguments:

(define/contract negate (-> number? number?) -)

This negate function is obviously restricted to arity 1, and indeed, applying it to any other number of arguments will produce an exception. However, despite this, procedure-arity reports the wrong thing:

> (procedure-arity negate)
(arity-at-least 1)

This causes problems when interoperating with other parts of Racket that depend on the value of procedure-arity to produce something meaningful. For example, curry will not behave appropriately on negate:

> (curry negate 5)
#<procedure:curried:->
> (curry (procedure-reduce-arity negate 1) 5)
-5

This might seem like a small problem, but there are larger, more subtle issues. For example, the contract system’s error reporting itself is worsened, since it cannot rely on first-order checks, and indeed, it can lead to errors that obscure the problem:

> (define/contract f (-> number? number? ... number?) negate)
> (f 1 2 3)
negate: contract violation
  received: 3 arguments
  expected: 1 non-keyword argument
  in: (-> number? number?)
  contract from: (definition negate)
  blaming: anonymous-module

In contrast, if negate’s arity were restricted, the error would be both immediate and more useful:

> (define/contract f (-> number? number? ... number?) (procedure-reduce-arity negate 1))
f: broke its own contract
  promised: a procedure that accepts 1 non-keyword argument and arbitrarily many more
  produced: #<procedure:->
  - accepts: 1 argument
  in: (-> number? number? ... number?)
  contract from: (definition f)
  blaming: (definition f)

This can even lead to issues with the impersonation system itself:

(define/contract (wrap-unary-proc f)
  (-> (-> any/c any/c) (-> any/c any/c))
  (impersonate-procedure f (λ (x) (add1 x))))

> (wrap-unary-proc -)
impersonate-procedure: arity of wrapper procedure does not cover arity of original procedure
  wrapper: #<procedure:unsaved editor:5:27>
  original: #<procedure:->

The above program seems legal, since wrap-unary-proc restricts its argument to unary procedures, but since impersonate-procedure uses procedure-arity to determine if a wrapper procedure is valid, the above program fails! This can even lead to especially egregious situations in which a party in violation of the contract is not blamed even though they ought to be:

> (define/contract -/2 (-> number? number? number?) -)
> (wrap-unary-proc -/2)
impersonate-procedure: arity of wrapper procedure does not cover arity of original procedure
  wrapper: #<procedure:unsaved editor:5:27>
  original: #<procedure:->

The above call to wrap-unary-proc is illegal, and the caller should be blamed, but they are not due to the inability of the contract system to report the proper arity.

One solution

The solution is straightforward: let the contract system stop lying about procedure arity. The most obvious implementation of this is to allow impersonate-procedure and chaperone-procedure to provide wrapper procedures of any arity, and the resulting procedure will have the intersection of their arities. An alternative solution would be to allow procedure-reduce-arity to maintain equal?, as currently it does not:

> (equal? - (procedure-reduce-arity - 1))
#f

Whichever solution is used, there are many advantages to allowing this, namely that all of the problems described in the previous section disappear. Here’s a short recap:

  • Contract violations can be signaled more eagerly, since first-order checks can catch arity mismatches in higher-order applications. This can also help improve certain runtime errors produced by Typed Racket, such as the one in Downcasting from Procedure produces an unhelpful error message typed-racket#763.

  • Functions that care about procedure arity, like curry, will do the appropriate thing on functions restricted by contracts.

  • More generally, procedure-arity will become more trustworthy, since currently it isn’t reliable—usages of the contract system easily lead to situations where procedure-arity lies.

My guess is that some will raise the counterargument that the loss of (implies (equal? a b) (equal? (procedure-arity a) (procedure-arity b))) for all procedures a and b is a bad thing, but I believe my last bullet above explains why the current situation is actually no better. Any program that depends on a meaningful relationship between equal? and procedure-arity is already wrong, since procedure-arity is untrustworthy.

An alternative solution

Perhaps the previous argument is not compelling to you, and you believe that it is truly unreasonable for two procedures with different arities, as reported by procedure arity, should be equal?. I would not personally disagree… returning to the negate example from the beginning of this issue, while it is true that equality on functions is ill-defined, I think (equal? - negate) returning #t makes little sense. Therefore, one alternate solution to this problem would be to make arity-changing contracts not preserve equal?.

This seems like a more dramatic change. Personally, even if I think that behavior is more reasonable, it seems that ship has sailed—I doubt it’s possible to implement it in Racket today. Still, I think it’s worth mentioning.

@AlexKnauth
Copy link
Member

AlexKnauth commented Oct 14, 2018

The alternative solution, making them not pretend-to-preserve equal?-ness, makes more sense to me.

I doubt it's possible to implement in Racket today

This is already possible if you just decide not to use impersonators, and instead return a new function value from the contract-projection.

An example of this is shown in both the reference for Building New Contract Combinators and the guide with Building New Contracts, with int->int/c as a version of (-> integer? integer?) that restricts arity, by changing the value and not preserving equal?.

In that example, if you define negate as (contract int->int/c - 'positive 'negative), then (equal? - negate) will return #false, and (procedure-arity negate) will return 1.

The Guide specifically says that "this wrapper function does not cooperate with equal?", and then starts to explain how chaperones work, with an assumption that preserving equal? is a good thing. However up to that point it was clear that there was no restriction to use impersonators or chaperones; the contract-projection could return a non-equal value.

What the Guide says afterwards suggests a possible middle-ground that I haven't read/heard about yet:

nor does it let the runtime system know that there is a relationship between the result function and f, the input function.

  • At one extreme: contract-projections usually return chaperones, which are much more restrictive, and have to preserve equal?, and procedure-arity (this is currently the norm)

  • At the other extreme: contract-projections can return whatever they want, don't have to preserve anything (this is already possible, but not as desirable because of the above quote from the Guide)

  • In between: There is some other relation that they preserve, less-strict than equality, which I would call "usable-as"

One thing different about this "usable-as" thing is that it isn't symmetric. For example, - is usable-as negate, but negate is not usable-as -. A procedure f of arity (list 1 2) is usable-as (procedure-reduce-arity f 1), but not the other way around.

A less-strict version of impersonators or chaperones could make sure everything preserved this "usable-as" relation, so that "it lets the runtime system know that there is a relationship" between them.

I don't know exactly how this would work though, but I think it should be possible because both extremes on either side already exist.

@lexi-lambda
Copy link
Member Author

I doubt it's possible to implement in Racket today

This is already possible if you just decide not to use impersonators, and instead return a new function value from the contract-projection.

By “possible”, I really meant “practical”. It’d be easy to implement, but I think it’d be too backwards-incompatible.

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

No branches or pull requests

2 participants