7.4 Contracts: A Thorough Example
returns the first element in the list lst that maximizes the result of proc.
> (argmax add1 (list 1 2 3)) 3
> (argmax sqrt (list 0.4 0.9 0.16)) 0.9
> (argmax second '((a 2) (b 3) (c 4) (d 1) (e 4))) '(c 4)
version 1
#lang racket (define (argmax f lov) ...) (provide (contract-out [argmax (-> (-> any/c real?) (and/c pair? list?) any/c)]))
version 2
#lang racket (define (argmax f lov) ...) (provide (contract-out [argmax (->i ([f (-> any/c real?)] [lov (and/c pair? list?)]) () (r (f lov) (lambda (r) (define f@r (f r)) (for/and ([v lov]) (>= f@r (f v))))))]))
version 2 rev. a
#lang racket (define (argmax f lov) ...) (provide (contract-out [argmax (->i ([f (-> any/c real?)] [lov (and/c pair? list?)]) () (r (f lov) (lambda (r) (define f@r (f r)) (and (memq r lov) (for/and ([v lov]) (>= f@r (f v)))))))]))
version 3
#lang racket (define (argmax f lov) ...) (provide (contract-out [argmax (->i ([f (-> any/c real?)] [lov (and/c pair? list?)]) () (r (f lov) (lambda (r) (define f@r (f r)) (and (for/and ([v lov]) (>= f@r (f v))) (eq? (first (memf (lambda (v) (= (f v) f@r)) lov)) r)))))]))
This second refinement step introduces two problems. First, both conditions recompute the values of f for all elements of lov. Second, the contract is now quite difficult to read. Contracts should have a concise formulation that a client can comprehend with a simple scan. Let us eliminate the readability problem with two auxiliary functions that have reasonably meaningful names:
version 3 rev. a
#lang racket (define (argmax f lov) ...) (provide (contract-out [argmax (->i ([f (-> any/c real?)] [lov (and/c pair? list?)]) () (r (f lov) (lambda (r) (define f@r (f r)) (and (is-first-max? r f@r f lov) (dominates-all f@r f lov)))))])) ; where ; f@r is greater or equal to all (f v) for v in lov (define (dominates-all f@r f lov) (for/and ([v lov]) (>= f@r (f v)))) ; r is eq? to the first element v of lov for which (pred? v) (define (is-first-max? r f@r f lov) (eq? (first (memf (lambda (v) (= (f v) f@r)) lov)) r))
This step leaves us with the problem of the newly introduced inefficiency. To avoid the recomputation of (f v) for all v on lov, we change the contract so that it computes these values and reuses them as needed:
version 3 rev. b
#lang racket (define (argmax f lov) ...) (provide (contract-out [argmax (->i ([f (-> any/c real?)] [lov (and/c pair? list?)]) () (r (f lov) (lambda (r) (define f@r (f r)) (define flov (map f lov)) (and (is-first-max? r f@r (map list lov flov)) (dominates-all f@r flov)))))])) ; where ; f@r is greater or equal to all f@v in flov (define (dominates-all f@r flov) (for/and ([f@v flov]) (>= f@r f@v))) ; r is (first x) for the first x in lov+flov s.t. (= (second x) f@r) (define (is-first-max? r f@r lov+flov) (define fst (first lov+flov)) (if (= (second fst) f@r) (eq? (first fst) r) (is-first-max? r f@r (rest lov+flov))))
The word "eager" comes from the literature on the linguistics of contracts.
Version 3 may still be too eager when it comes to calling f. While Racket’s argmax always calls f no matter how many items lov contains, let us imagine for illustrative purposes that our own implementation first checks whether the list is a singleton. If so, the first element would be the only element of lov and in that case there would be no need to compute (f r). The argmax of Racket implicitly argues that it not only promises the first value that maximizes f over lov but also that f produces/produced a value for the result. As a matter of fact, since f may diverge or raise an exception for some inputs, argmax should avoid calling f when possible.
The following contract demonstrates how a higher-order dependent contract needs to be adjusted so as to avoid being over-eager:
version 4
#lang racket (define (argmax f lov) (if (empty? (rest lov)) (first lov) ...)) (provide (contract-out [argmax (->i ([f (-> any/c real?)] [lov (and/c pair? list?)]) () (r (f lov) (lambda (r) (cond [(empty? (rest lov)) (eq? (first lov) r)] [else (define f@r (f r)) (define flov (map f lov)) (and (is-first-max? r f@r (map list lov flov)) (dominates-all f@r flov))]))))])) ; where ; f@r is greater or equal to all f@v in flov (define (dominates-all f@r lov) ...) ; r is (first x) for the first x in lov+flov s.t. (= (second x) f@r) (define (is-first-max? r f@r lov+flov) ...)
The problem of diverging or exception-raising functions should alert the reader to the even more general problem of functions with side-effects. If the given function f has visible effects – say it logs its calls to a file – then the clients of argmax will be able to observe two sets of logs for each call to argmax. To be precise, if the list of values contains more than one element, the log will contain two calls of f per value on lov. If f is expensive to compute, doubling the calls imposes a high cost.
To avoid this cost and to signal problems with overly eager contracts, a contract system could record the i/o of contracted function arguments and use these hashtables in the dependency specification. This is a topic of on-going research in PLT. Stay tuned.