A lightweight alternative to Racket contracts
Racket
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Failed to load latest commit information.
.gitignore
README.md
bench.rkt
main.rkt

README.md

Racket contracts are very cool, but can be very slow.

In the real world, a "term sheet" is a casual version of a contract.

This collection provides a lightweight alternative to full-on Racket contracts. You get the declarative convenience of define/contract, but speed comparable to writing checks by hand. The speed is typically 200x faster than define/contract. (It's sometimes even faster than handwritten checks that naively use predicate-creating contracts like and/c.)

Plus, a runtime parameter allows even the fast checks to be skipped. This supports a work flow such as "debug vs. release" builds, albeit at run time not build time.

What's the catch? You lose the "blame" mechanism of contracts, and therefore the error messages are sometimes not as helpful

define/termsheet takes the same form as define/contract, but uses the contract predicates as inline checks. As a result, this runs much faster -- e.g. ~200x faster -- than a normal, chaperoned wrapped procedure.

Because define/termsheet is source compatible with define/contract, it should be possible to use it as a drop-in replacement in existing code via require all-except and rename-in.

Benchmarked examples

Run bench.rkt to see timings.

Change benchmark-enabled? to #t to benchmark the contract versions (I have it set to #f by default because they are so slow.)

With that enabled you may see results like this:

f/raw: cpu time: 9 real time: 9 gc time: 0
f/checked: cpu time: 10 real time: 10 gc time: 0
f/termsheet: cpu time: 17 real time: 18 gc time: 0
f/provide/contracted: cpu time: 433 real time: 435 gc time: 25
f/define/contracted: cpu time: 9167 real time: 9185 gc time: 157

f2/raw: cpu time: 30 real time: 30 gc time: 6
f2/checked: cpu time: 171 real time: 171 gc time: 6
f2/termsheet: cpu time: 71 real time: 71 gc time: 5
f2/provide/contracted: cpu time: 563 real time: 565 gc time: 17
f2/define/contracted: cpu time: 13502 real time: 13535 gc time: 265

f3/raw: cpu time: 10 real time: 10 gc time: 0
f3/checked: cpu time: 156 real time: 156 gc time: 0
f3/termsheet: cpu time: 55 real time: 55 gc time: 0
f3/provide/contracted: cpu time: 530 real time: 531 gc time: 15
f3/define/contracted: cpu time: 13529 real time: 13563 gc time: 267

f4/raw: cpu time: 18 real time: 17 gc time: 0
f4/termsheet: cpu time: 37 real time: 38 gc time: 0
f4/provide/contract: cpu time: 3186 real time: 3193 gc time: 63
f4/define/contract: cpu time: 16710 real time: 16750 gc time: 341

f5/raw: cpu time: 16 real time: 16 gc time: 0
f5/termsheet: cpu time: 219 real time: 220 gc time: 5
f5/provide/contract: cpu time: 669 real time: 672 gc time: 19
f5/define/contract: cpu time: 10378 real time: 10410 gc time: 179

Procedure argument contracts--worth it?

One question is how to handle contracts for arguments that are procedures, such as the (boolean? . -> . boolean?) in

(define/contract (foo a f)
  (boolean? (boolean? . -> . boolean?) . ->  . any)
  (f a))

I experimented with a way that's much faster than real contracts, but is still considerably slower than not checking at all: The define/termsheet macro wraps these in an additional lambda/termsheet. Although much faster than a real chaperon, it's still quite slow compared to no checking.

A simpler approach is to transform procedure contracts into a simple procedure? predicate, which is obviously vastly faster. Granted this loses the deeper checking. However in my experience that's of limited value, especially if the procedure being called already has its own contract (or termsheet). It will check its own input args; checking them twice is of no value. At most, it would be useful to check only that the procedure returns what was expected. Even that is often not necessary, since the calling function checks its return value.

Finally, the cheaper contracts are, the more likely other functions will use them. And my goal is to make termsheets really, really cheap.

As a result, I've settled on the approach of converting procedure contracts into simple procedure? checks.

Why

  • This was perfect timing to apply some things I've learned while writing Fear of Macros.

  • It turned out to force me to really learn how to preserve lexical context in a macro.

  • It's also helping me start to appreciate more of what the full Racket contract system does, beyond my naive understanding ("it writes checks for you").

Perhaps it's possible for full Racket contracts to be faster for simple cases and obviate the practical need for anything like this.