using call/cc with let-assert

This commit is contained in:
2026-05-11 17:00:46 +02:00
parent 9d4b4fff33
commit 20b54b8447
2 changed files with 187 additions and 127 deletions
+185 -125
View File
@@ -3,196 +3,256 @@
@(require (for-label racket/base
"../main.rkt"))
@title{let-assert}
@title{let/assert}
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
@defmodule[let-assert]
This module provides @racket[let/assert], a small sequential binding form
with local assertions. It is useful for defensive programming around FFI
bindings: checks for null pointers, exit codes, and similar failure values can
be kept close to the binding that produced them, while the body stays on the
happy path. When an assertion fails, the whole @racket[let/assert] expression
returns the associated fallback value.
This module provides @racket[let/assert], a small sequential binding
form for defensive programming. It is especially useful around FFI calls,
where failure is often reported by ordinary values: @racket[#f] for a
null pointer, a negative integer for an error code, or a non-zero integer
for a failed C-style status result.
@section{Binding with assertions}
The form is implemented with @racket[call/cc]. A failed assertion does
not raise an exception. Instead, it invokes an internal escape
continuation and returns the supplied failure value as the result of the
whole @racket[let/assert] expression.
@defform[
(let/assert (binding ...) body ...+)
#:grammar
([binding [id expr]
[id expr predicate-expr fallback-expr]])]{
(let/assert
((id expr)
(id expr assert-expr failure-expr)
...)
body ...)
]{
The form expands to an internal @racket[let*] structure. Bindings are
therefore evaluated from left to right, and later bindings may refer to
earlier ones.
A binding of the form @racket[[id expr]] simply binds @racket[id] to
@racket[expr].
Evaluates the bindings from left to right, using @racket[let*]
semantics. Later bindings may refer to earlier ones.
A binding of the form
@racket[[id expr predicate-expr fallback-expr]] evaluates @racket[expr] once,
applies @racket[predicate-expr] to the result, and binds @racket[id] to that
result when the predicate accepts it. If the predicate returns @racket[#f],
the body is not evaluated and the whole @racket[let/assert] form returns
@racket[fallback-expr].
The fallback expression is evaluated only when the assertion fails. Internally,
the failing assertion raises a private exception carrying that value; the
exception is caught by @racket[let/assert] itself.
@racketblock[
(id expr)
]
is a plain sequential binding.
A binding of the form
@racketblock[
(id expr assert-expr failure-expr)
]
evaluates @racket[expr] once and applies @racket[assert-expr] to the
result. If the assertion succeeds, @racket[id] is bound to the original
value. If the assertion fails, @racket[failure-expr] is returned as the
result of the whole @racket[let/assert] form.
The escape continuation is internal. It is not part of the surface syntax
and is not visible in the body.
@racketblock[
(define (half-positive n)
(let/assert
((x n a->0? 'not-positive))
(/ x 2)))
]
The result is @racket[5] for @racket[(half-positive 10)] and
@racket['not-positive] for @racket[(half-positive 0)].
Because @racket[let/assert] uses @racket[call/cc], failure is local
control flow. There is no exception object, no exception handler, and no
dynamic catching of unrelated failures.
}
@racketblock[
(let/assert ([x 10 (a->? 0) 'too-small]
[y (+ x 2) (a-=? 12) 'wrong-value])
y)
]
The example returns @racket[12]. The second binding can use @racket[x],
because @racket[let/assert] has @racket[let*] scoping.
@section{FFI-style examples}
FFI libraries often report errors by returning a null pointer or a negative
integer status code. With @racket[let/assert], those checks can be written
next to the operation that may fail.
@racketblock[
(define (open-ffmpeg-instance)
(let/assert ([fh (fmpg-init) a-!nullptr? #f])
fh))
]
Here @racket[fmpg-init] is expected to return either a valid native handle or
@racket[#f]. If the handle is @racket[#f], the complete
@racket[let/assert] form returns @racket[#f]. Otherwise the valid handle is
returned.
A slightly larger example can combine a pointer check with an FFmpeg-style
return-code check:
@racketblock[
(define (decode-next! ds)
(let/assert ([pkt (av-packet-alloc) a-!nullptr? 'packet-allocation-failed]
[ret (read-selected-audio-packet! ds pkt)
(a->=? 0)
'read-packet-failed]
[pcm (receive-available-frames! ds)
bytes?
'decode-failed])
pcm))
]
The body contains only the successful path. If packet allocation fails, the
result is @racket['packet-allocation-failed]. If reading the packet returns a
negative status code, the result is @racket['read-packet-failed]. If decoding
does not produce bytes, the result is @racket['decode-failed].
@section{Creating assertion factories}
@section{Assertion constructors}
@defform[(make-assert name not-name pred)]{
Defines two assertion factories in a definition context.
Defines two assertion-constructor forms.
The form @racket[(name constant)] expands to a unary predicate that applies
@racket[pred] to the value being checked and @racket[constant]. The form
@racket[(not-name constant)] expands to the negated variant.
For example:
The generated @racket[name] form creates a predicate that compares its
argument with a constant using @racket[pred]:
@racketblock[
(make-assert a-eq? a-!eq? eq?)
((name const) value)
]
defines @racket[a-eq?] and @racket[a-!eq?]. Consequently,
@racket[(a-eq? #f)] produces a predicate that accepts values that are
@racket[eq?] to @racket[#f], while @racket[(a-!eq? #f)] accepts values that
are not @racket[eq?] to @racket[#f].
is equivalent to:
@racketblock[
(λ (x) (pred x const))
]
The generated @racket[not-name] form creates the negated predicate:
@racketblock[
(λ (x) (not (pred x const)))
]
For example, the module defines:
@racketblock[
(make-assert a-eq? a-!eq? eq?)
(make-assert a->? a-<=? >)
(make-assert a->=? a-<? >=)
(make-assert a-=? a-!=? =)
]
The negated forms are logical negations of the positive predicate.
}
@section{Assertion factories}
@defform[(a-eq? constant)]{
Produces a unary predicate that accepts values for which
@racket[(eq? value constant)] is true.
@defform[(a-eq? const)]{
Creates a predicate that accepts values @racket[eq?] to @racket[const].
}
@defform[(a-!eq? constant)]{
Produces a unary predicate that accepts values for which
@racket[(eq? value constant)] is false.
@defform[(a-!eq? const)]{
Creates a predicate that accepts values not @racket[eq?] to
@racket[const].
}
@defform[(a->? constant)]{
Produces a unary predicate that accepts values greater than
@racket[constant].
@defform[(a->? const)]{
Creates a predicate that accepts values greater than @racket[const].
}
@defform[(a-<=? constant)]{
Produces a unary predicate that rejects values greater than
@racket[constant].
@defform[(a-<=? const)]{
Creates the negation of @racket[(a->? const)].
}
@defform[(a->=? constant)]{
Produces a unary predicate that accepts values greater than or equal to
@racket[constant].
@defform[(a->=? const)]{
Creates a predicate that accepts values greater than or equal to
@racket[const].
}
@defform[(a-<? constant)]{
Produces a unary predicate that rejects values greater than or equal to
@racket[constant].
@defform[(a-<? const)]{
Creates the negation of @racket[(a->=? const)].
}
@defform[(a-=? constant)]{
Produces a unary predicate that accepts values numerically equal to
@racket[constant].
@defform[(a-=? const)]{
Creates a predicate that accepts values numerically equal to
@racket[const].
}
@defform[(a-!=? constant)]{
Produces a unary predicate that accepts values not numerically equal to
@racket[constant].
@defform[(a-!=? const)]{
Creates the negation of @racket[(a-=? const)].
}
@section{Ready-made predicates}
@section{Built-in predicates}
@defproc[(a-=0? [x number?]) boolean?]{
Returns @racket[#t] when @racket[x] is numerically equal to zero.
Accepts values numerically equal to zero.
}
@defproc[(a-!=0? [x number?]) boolean?]{
Returns @racket[#t] when @racket[x] is not numerically equal to zero.
Accepts values not numerically equal to zero.
}
@defproc[(a->0? [x real?]) boolean?]{
Returns @racket[#t] when @racket[x] is greater than zero.
Accepts values greater than zero.
}
@defproc[(a->=0? [x real?]) boolean?]{
Returns @racket[#t] when @racket[x] is greater than or equal to zero.
Accepts values greater than or equal to zero.
}
@defproc[(a-<0? [x real?]) boolean?]{
Returns @racket[#t] when @racket[x] is less than zero.
Accepts values less than zero.
}
@defproc[(a-<=0? [x real?]) boolean?]{
Returns @racket[#t] when @racket[x] is less than or equal to zero.
Accepts values less than or equal to zero.
}
@defthing[a-true? procedure?]{
A predicate equivalent to @racket[(a-eq? #t)].
A predicate that accepts only @racket[#t].
}
@defthing[a-false? procedure?]{
A predicate equivalent to @racket[(a-eq? #f)].
A predicate that accepts only @racket[#f].
}
@defthing[a-nullptr? procedure?]{
A predicate equivalent to @racket[(a-eq? #f)]. The name is intended for FFI
code where @racket[#f] is used to represent a null pointer.
A predicate that accepts @racket[#f]. This is intended for FFI bindings
where a native null pointer is represented as @racket[#f].
}
@defthing[a-!nullptr? procedure?]{
A predicate equivalent to @racket[(a-!eq? #f)]. It accepts values that are
not @racket[#f], which is often the success case for pointer-returning FFI
calls.
}
A predicate that accepts values that are not @racket[#f]. This is useful
for checking successful pointer allocation in FFI code.
}
@section{Examples}
A simple status-code check:
@racketblock[
(define (open-status r)
(let/assert
((status r a-=0? 'open-failed))
'opened))
]
Here @racket[(open-status 0)] returns @racket['opened], while
@racket[(open-status -1)] returns @racket['open-failed].
A pointer-style check:
@racketblock[
(define (use-context maybe-ctx)
(let/assert
((ctx maybe-ctx a-!nullptr? 'no-context))
ctx))
]
Here @racket[#f] is treated as a failed native pointer result.
Plain bindings and asserted bindings may be mixed. Since the form uses
@racket[let*] semantics, later expressions can refer to earlier values:
@racketblock[
(define (make-scaled-size width height scale)
(let/assert
((w width a->0? 'bad-width)
(h height a->0? 'bad-height)
(s scale a->0? 'bad-scale)
(area (* w h)))
(* area s)))
]
The expression @racket[area] is only evaluated after @racket[w] and
@racket[h] have passed their assertions.
@subsection{FFmpeg-style null pointers}
Many FFmpeg functions return a native pointer, where @racket[#f]
represents a null pointer. Since @racket[let/assert] uses sequential
@racket[let*] semantics, later bindings may depend on earlier checked
pointer results.
@racketblock[
(define (make-stream-codec-context stream)
(let/assert
((codecpar (AVStream-codecpar stream) a-!nullptr?
'missing-codec-parameters)
(codec (avcodec-find-decoder
(AVCodecParameters-codec-id codecpar))
a-!nullptr?
'decoder-not-found)
(ctx (avcodec-alloc-context3 codec) a-!nullptr?
'alloc-codec-context-failed))
ctx))
]
If @racket[avcodec-find-decoder] returns @racket[#f], the whole
@racket[let/assert] expression returns @racket['decoder-not-found].
The second binding is then not evaluated.
If @racket[codec] is a valid pointer, it is used by the next binding.
If @racket[avcodec-alloc-context3] returns @racket[#f], the expression
returns @racket['alloc-codec-context-failed]. Otherwise @racket[ctx] is
bound to the allocated context and returned by the body.