To ensure safer run-time behavior of our functions, we’ll add contracts. A contract wraps around a function and ensures its input arguments and return value meet requirements that we specify. If the function tries to accept arguments or return a value that doesn’t meet these requirements, the contract raises an error.
Of course, we can write these kind of input & output checks by hand. But contracts have three benefits:
They give us a compact, consistent notation for writing these checks.
They take care of the underlying housekeeping of creating and raising a detailed error.
They make functions more readable, because they make the input & output expectations explicit.
For instance, consider our-div, a division function that allows 0 as a denom argument. Bad input produces a generic error:
1 | /: division by zero |
We can add a check to denom that raises a more specific error:
1 | our-div: denom argument needs to be nonzero |
But we can do even better with a contract. To add a contract to our function, first we import racket/contract. The define is the same as usual. But we’ll change our provide to use contract-out, which lets us attach a contract to the exported function:
1 2 3 4 5 | (require racket/contract) (define (our-div num denom) (/ num denom)) (provide (contract-out [our-div ···])) ;; contract will go here |
To make the contract itself, we use -> (also known as a contract combinator). Each argument to -> is a function that performs a test (also known as a predicate). The last argument to -> tests the return value, and the other arguments test the input arguments.
For our-div, we want a contract that tests that num is a number?, denom is not zero?, and the result is also a number?. We write this contract like so (where not/c is a contract combinator that inverts the zero? predicate):
To make the separation between input and output predicates more readable, Racketeers typically use infix notation in a contract. Function calls are idiomatically written with prefix notation. But we can optionally surround the function name with . dots . and move it anywhere within the S-expression, and the function call will work the same way. Infix notation lets us relocate the -> to a more logical place: + The dots aren’t identifiers. They’re just extra notation that the Racket reader knows how to parse.
Now we can add our contract to our provide:
This time, the input-argument error is raised by the contract:
1 2 3 4 5 6 7 8 9 | our-div: contract violation expected: (not/c zero?) given: 0 in: the 2nd argument of (-> number? (not/c zero?) number?) contract from: (anonymous-module our-submod) blaming: anonymous-module (assuming the contract is correct) at: unsaved-editor:7.13 |
To show how the return-value contract works, let’s change our function so it returns a string, and call our-div with two valid numerical arguments:
This time, the error message changes:
1 2 3 4 5 6 7 8 9 | our-div: broke its own contract promised: number? produced: "a string" in: the range of (-> number? (not/c zero?) number?) contract from: (anonymous-module our-submod) blaming: (anonymous-module our-submod) (assuming the contract is correct) at: unsaved-editor:7.13 |
Instead of referring to “expected” arguments, the error refers to the “promised” return value, and the blame is likewise shifted to our-div itself (“broke its own contract”).
Curious characters might be wondering why our sample code is inside a submodule, rather than at the top level of our source file. The answer is that contracts are always associated with some boundary within the code. A contract is invoked only when the function is used across that boundary. In this case, because the contract is attached within our provide, it only applies across the module boundary of our-submod. Thus, if we move (our-div 42 0) inside our-submod:
The contract won’t be triggered, and we’ll get our generic error again:
1 | /: division by zero |
Let’s open "reader.rkt" so we can add a contract to read-syntax:
1 2 3 4 5 6 7 8 9 | #lang br/quicklang (require "tokenizer.rkt" "parser.rkt") (define (read-syntax path port) (define parse-tree (parse path (make-tokenizer port))) (define module-datum `(module jsonic-module jsonic/expander ,parse-tree)) (datum->syntax #f module-datum)) (provide read-syntax) |
Recall that read-syntax takes two arguments: a path to a file and an input port pointing at that file. The path argument can be any data type, so we’ll use the universal contract any/c. We need the port to be of the type port?. At the end, read-syntax returns a syntax object (which we can test with syntax?). So the whole contract looks like this:
As before, we import racket/contract, and change our provide to use contract-out with our new contract:
1 2 3 4 5 6 7 8 9 10 | #lang br/quicklang (require "tokenizer.rkt" "parser.rkt" racket/contract) (define (read-syntax path port) (define parse-tree (parse path (make-tokenizer port))) (define module-datum `(module jsonic-module jsonic/expander ,parse-tree)) (datum->syntax #f module-datum)) (provide (contract-out [read-syntax (any/c port? . -> . syntax?)])) |
Now, if we run our test file:
1 2 3 4 5 6 7 8 9 10 11 | #lang jsonic // a line comment [ @$ 'null $@, @$ (* 6 7) $@, @$ (= 2 (+ 1 1)) $@, @$ (list "array" "of" "strings") $@, @$ (hash 'key-1 'null 'key-2 (even? 3) 'key-3 (hash 'subkey 21)) $@ ] |
It still works normally:
1 2 3 4 5 6 7 | [ null, 42, true, ["array","of","strings"], {"key-1":null,"key-3":{"subkey":21},"key-2":false} ] |
Which is what we expected. But this is a special situation. We already knew that the function was correct.
Most of the time—especially while we’re developing our language—we don’t. So aside from ensuring run-time safety, a contract can be a useful development tool. It lets us plant a flag when we start to write a function. As soon as we get off track, it lets us know. (The other half of this equation is unit testing, which we’ll cover in the next section.)
To that end, it’s wise to write the narrowest possible contracts. That way, there’s less chance of “false positives”—values that meet the contract tests, but that aren’t actually acceptable.
For instance, the port? predicate returns #t for both input ports and output ports. But the port argument to read-syntax can only be an input port. Therefore, we should use the narrower input-port? predicate in our contract:
1 2 3 4 5 6 7 8 9 10 | #lang br/quicklang (require "tokenizer.rkt" "parser.rkt" racket/contract) (define (read-syntax path port) (define parse-tree (parse path (make-tokenizer port))) (define module-datum `(module jsonic-module jsonic/expander ,parse-tree)) (datum->syntax #f module-datum)) (provide (contract-out [read-syntax (any/c input-port? . -> . syntax?)])) |
Let’s now open "tokenizer.rkt", so we can add a contract to make-tokenizer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | #lang br/quicklang (require brag/support) (define (make-tokenizer port) (define (next-token) (define jsonic-lexer (lexer [(from/to "//" "\n") (next-token)] [(from/to "@$" "$@") (token 'SEXP-TOK (trim-ends "@$" lexeme "$@"))] [any-char (token 'CHAR-TOK lexeme)])) (jsonic-lexer port)) next-token) (provide make-tokenizer) |
Like read-syntax, make-tokenizer takes one input port as an argument. As we learned, we can test this with input-port?.
But how about next-token, the return value? next-token is a function. We could use the generic procedure? predicate, which returns #t for all functions.
1 | (input-port? . -> . procedure?) |
That’s not wrong. But we can do better.
If we need a contract to test a function, we can make a second contract with -> and nest it inside the main contract. In this case, next-token takes no arguments, and returns either eof (which we can test with eof-object?) or a token structure (token-struct?). For simplicity, we encapsulate these tests in a new jsonic-token? predicate:
1 2 | (define (jsonic-token? x) (or (eof-object? x) (token-struct? x))) |
Thus, our contract for next-token looks like this (since we don’t have any input arguments, we don’t need infix notation):
1 | (-> jsonic-token?) |
And our whole contract for make-tokenizer looks like this:
1 | (input-port? . -> . (-> jsonic-token?)) |
Putting it all together, we again start by importing racket/contract. We add our definition for jsonic-token?. And we insert the new contract inside the provide for make-tokenizer using contract-out:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | #lang br/quicklang (require brag/support racket/contract) (define (jsonic-token? x) (or (eof-object? x) (token-struct? x))) (define (make-tokenizer port) (define (next-token) (define jsonic-lexer (lexer [(from/to "//" "\n") (next-token)] [(from/to "@$" "$@") (token 'SEXP-TOK (trim-ends "@$" lexeme "$@"))] [any-char (token 'CHAR-TOK lexeme)])) (jsonic-lexer port)) next-token) (provide (contract-out [make-tokenizer (input-port? . -> . (-> jsonic-token?))])) |
A contract operates at run time. So it necessarily imposes a performance cost on a program, however small. When we use a contract, we should make sure that the incremental safety is worth the incremental cost.
For instance, a contract on read-syntax is probably unnecessary. The arguments are being passed in by Racket itself. We can trust that they’re correct.
On the other hand, a contract on read-syntax is only called once for every program (because read-syntax itself is only called once). So it’s unlikely to gum up the works, either.
Beyond run-time safety, the best reason to use contracts is that they’re a great debugging tool. Those who value their time will likely find that their investment in contracts is quickly repaid by rapidly pinpointing bugs related to incorrect data types.
We’ll include contracts in the new functions we add during this tutorial.