Let-assert & define-return

I just released two small modules:

let-assert (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 let/assert expression returns the associated fallback value.

Example of use in racket-audio (under development):

(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))

define-return (define/return, define/contract/return)

The define-return library provides definition forms with an explicit early return. This is useful in small defensive functions, and especially around FFI bindings, where null pointers, error codes, unsupported states, or failed preconditions should leave the function immediately.

A small example, which does nothing special:

(define/return (status->symbol code)
  (unless (number? code)
    (return 'not-a-number))
  (when (= code 0) (return 'ok))
  (when (< code 0) (return 'failed))
  (when (> code 5) (return 'out-of-range))
  (cond
    ((= code 1) 'normal)
    ((>= code 2) (string->symbol (format "code-~a" (* code code))))
    )
  )
1 Like

Interesting. Your implementation seems to use special Racket features.
In Scheme+ for Racket there is an implementation using scheme only. (see doc and code and optionally this code)

2 Likes

Interesting :smiley:. Without those racket-specific designs, however, it’s a lot harder to implement.

1 Like

With only R5RS (no syntax-case), it could be implemented as follows.

(define-syntax define/return
  (syntax-rules ()
    ((_ (name . args) return b1 ...) 
     (define (name . args)
       (call/cc
        (lambda (return)
          b1 ...))))))

(define/return (f x) return
   (cond 
     ((not (number? x)) (return 'not-a-number))
     ((< x 10) (return 'to-small))
     )
   (* x x))

However, this would be about the same as let/ec.

Although my variant goes one step further because the exception also captures this:

> (require define-return)

> (define/return (g x)
    (let ((h (f x)))
      (+ h 1000)))

> (define (f x)
       (cond 
     ((not (number? x)) (return 'not-a-number))
     ((< x 10) (return 'to-small))
     )
   (* x x))

> (g 10)
1100
> (g 8)
'to-small
> (g 'd)
'not-a-number
>

Which is probably less intuĂŻtive by the way...

This makes me reconsider my implementation. I'll probably make it more intuĂŻtive if I choose the call/cc, which then will be more syntactic sugar around let/ec etc.

Honestly the implementation was not of mine,and now with some IA i have this :

#lang racket

;; dynamic parameters
(define current-return (make-parameter #f))      ; local return
(define current-return-rec (make-parameter #f))  ; global return

;; local return (same as before)
(define (return . v)
  (let ([ret (current-return)])
    (unless ret
      (error "return used outside of def"))
    (ret (if (null? v) (void) (car v)))))

;; global return (exits all recursion)
(define (return-rec . v)
  (let ([ret (current-return-rec)])
    (unless ret
      (error "return-rec used outside of def"))
    (ret (if (null? v) (values) (car v))))) ; (values) or (void)





;; def macro (UPDATED)
(define-syntax-rule (def (name . args) body ...)
  (define (name . args)
    (if (current-return-rec)
        ;; recursive call
        (call/cc
         (lambda (local-ret)
           (parameterize ([current-return local-ret])
             body ...)))
        ;; root call
        (call/cc
         (lambda (global-ret)
           (parameterize ([current-return-rec global-ret])
             (call/cc
              (lambda (local-ret)
                (parameterize ([current-return local-ret])
                  body ...)))))))))



(def (f x)
  (display "start\n")
  (when (> x 0)
      (return "positive"))
  (display "after if\n")
  "end")

(def (bar n)
  (if (= n 0)
      (return-rec "finish")
      (bar (- n 1)))
  "should never be here")

(def (foo n)
  (if (= n 0)
      (return "finish")
      (foo (- n 1)))
  "should also be here unless n=0")

(def (g a . L) (when #t (return (cons a L))))

(bar 7)

(foo 7)

(g 3 4 5)

it implements also a return-rec ,escaping of any recursive calls.

It's amazing but IA is just a reformulation of things already existing on forums.

Welcome to DrRacket, version 8.18 [cs].
Language: racket, with debugging; memory limit: 128 MB.
"finish"
"should also be here unless n=0"
'(3 4 5)

but perheaps i will update the previous implementation in my project with this one... (but. note that make-parameter is not very portable)

1 Like

I'm currently working on an 'early-return' syntax-definition, which is more to the point on my use case:

(define-syntax early-return*
  (syntax-rules (-> ? ~ do)
    ((_ () (b1 ...))
     (let () b1 ...))
    ((_ ((do d1 ...)) (b1 ...))
     (let () d1 ...
       (let () b1 ...)))
    ((_ ((do d1 ...) c1 ...) (b1 ...))
     (let () d1 ...
       (early-return* (c1 ...) (b1 ...))))
    ((_ ((v expr ? pred? -> retval ~ cleanup)) (b1 ...))
     (let ((v expr))
       (cond (pred? cleanup retval)
             (else (let () b1 ...)))))
    ((_ ((v expr ? pred? -> retval ~ cleanup) c1 ...) (b1 ...))
     (let ((v expr))
       (cond (pred? cleanup retval)
             (else (early-return* (c1 ...) (b1 ...))))))
    ((_ ((v expr ? pred? -> retval)) (b1 ...))
     (let ((v expr))
       (cond (pred? retval)
             (else (let () b1 ...)))))
    ((_ ((v expr ? pred? -> retval) c1 ...) (b1 ...))
     (let ((v expr))
       (cond (pred? retval)
             (else (early-return* (c1 ...) (b1 ...))))))
    ((_ ((? pred? -> retval)) (b1 ...))
     (cond (pred? retval)
           (else (let () b1 ...))))
    ((_ ((? pred? -> retval) c1 ...) (b1 ...))
     (cond (pred? retval)
           (else (early-return* (c1 ...) (b1 ...)))))
    ((_ ((? pred? -> retval ~ cleanup)) (b1 ...))
     (cond (pred? cleanup retval)
           (else (let () b1 ...))))
    ((_ ((? pred? -> retval ~ cleanup) c1 ...) (b1 ...))
     (cond (pred? cleanup retval)
           (else (early-return* (c1 ...) (b1 ...)))))
    ((_ ((v expr)) (b1 ...))
     (let ((v expr))
       b1 ...))
    ((_ ((v expr) c1 ...) (b1 ...))
     (let ((v expr))
       (early-return* (c1 ...) (b1 ...))))
    )
  )

(define-syntax early-return
  (syntax-rules ()
    ((_ (er1 ...) b1 ...)
     (early-return* (er1 ...) (b1 ...))
     )
    )
  )

Now, I've been able to rewrite e.g. a function in my ffmpeg-definition.rkt.

(define (drain-resampler! self)
  (let* ((dec (fmpg-instance-decoder self))
         (info (fmpg-instance-audio-info self))
         (channels (ais-channels info))
         (sample-rate (ais-rate info))
         (continue (gensym 'continue)))

    (define (drain-once! delay max-bytes produced)
      (early-return
       ((tmp (malloc max-bytes 'raw) ? (eq? tmp #f) -> -1)
        (out-planes (malloc _pointer 1 'raw) 
                    ? (eq? out-planes #f) -> -1 ~ (free tmp))
        (cleanup! (λ ()
                   (unless (eq? out-planes #f) (free out-planes))
                   (unless (eq? tmp #f) (free tmp))))
        (do (ptr-set! out-planes _pointer 0 tmp))
        (out-samples (swr_convert (ds-swr-ctx dec) out-planes delay #f 0)
                     ? (<= out-samples 0) -> produced
                     ~ (cleanup!))
        (used-bytes (av_samples_get_buffer_size #f channels out-samples
                                                FMPG_OUTPUT_FMT 1)
                    ? (< used-bytes 0) -> produced
                    ~ (cleanup!))
        (do
         (when (pcm-empty? dec)
           (ds-start-sample! dec (ds-next-sample-pos dec))
           (ds-timecode! dec (/ (exact->inexact (ds-start-sample dec))
                                (exact->inexact sample-rate)))))
        (appended? (append-bytes! dec tmp used-bytes)  
                    ? (not appended?) -> -1 ~ (cleanup!))
        (do
         (ds-last-samples! dec (+ (ds-last-samples dec) out-samples))
         (ds-next-sample-pos! dec (+ (ds-next-sample-pos dec) out-samples))
         (cleanup!)))

       continue))

    (let loop ((produced 0))
      (early-return
       ((delay (swr_get_delay (ds-swr-ctx dec) sample-rate)
               ? (<= delay 0) -> produced)
        (max-bytes (av_samples_get_buffer_size #f channels delay FMPG_OUTPUT_FMT 1)
                   ? (<= max-bytes 0) -> produced)
        (r (drain-once! delay max-bytes produced)
           ? (not (eq? r continue)) -> r))
       (loop 1)))
    )
  )

No call/cc nor exceptions used anymore. I didn't like the idea of the call/cc overhead.

Simple dumb example:

> (define (h x)
    (early-return
     ((? (not (number? x)) -> 'not-a-number ~ (displayln (format "cleaning up with (h 8): ~a" (h 8))))
      (z (+ x x))
      (do (displayln (format "z = ~a" z)))
      (v (* x x) ? (> v 100) -> 'too-big)
      (do (displayln (format "v = ~a" v)))
      (do (define (f x) (/ x 3)))
      (g (+ v 10) ? (< g 25) -> 'too-small)
      (do (displayln (format "g = ~a" g)))
      )
     (f (+ g v 100 z))))
> (h 34)
z = 68
'too-big
> (h 3)
z = 6
v = 9
'too-small
> (h 8)
z = 16
v = 64
g = 74
84 2/3
> (h 'no-num)
z = 16
v = 64
g = 74
cleaning up with (h 8): 254/3
'not-a-number
>
1 Like

You might be interested in the guard library by @notjack. It is also implemented without continuation-related features.

If you did use continuation-related features, call-with-continuation-prompt/abort-current-continuation could be an alternative to let/ec. However …

You can avoid much of the need for early return by writing more idiomatic Racket.

Unlike the C function of the same name, malloc will only return #f if you have requested an an allocation of size zero. Out-of-memory errors raise an exn:fail:out-of-memory, instead.

Furthermore, you won't need cleanup! if you avoid allocating in 'raw mode: either 'atomic-interior or (IIUC in your case) 'atomic mode will let the memory be GCed normally.

You might be able to go further: a Racket byte string can be used directly as a cpointer?, and I'm not sure if out-planes really needs to be allocated separately from tmp (but maybe I just haven't followed closely enough).

Since much of this is a loop, you might also try to fit it into for/fold, which comes with features like #:break if needed.

1 Like

Interesting, thanks. Couple of remarks/questions.

  • I wasn't aware of the out-of-memory exn, thought malloc was more low level, following C-semantics.
  • I'm currently using malloc 'raw / free, because I know that this memory can be deleted and I suppose this could be more efficient than letting the GC handle it. As this is part of an audio pipeline, I would want is to be as efficient as possible. Is my assumption correct?
  • I'll look into for/fold.
  • Do you have an example of 'idomatic racket'? I'm using early returns, because this checks boxes and leaves me with in a checked state for the 'good path'.

With the caveat that everything is relative and that there’s no substitute for benchmarking: I would expect using 'raw mode would be less efficient than using a malloc mode that cooperates with the GC.

When you call Racket’s malloc in 'raw mode, Racket has to really call the C malloc() function, because the semantics of 'raw mode allow ownership can be transferred to a foreign library that will free() it from C later. Typical C malloc() implementations (there is a whole world of alternative C malloc()s …) are not tuned to make small, short-lived allocations efficiently. This is why C programmers typically avoid heap allocations when possible in favor of stack allocation, especially when performance is a concern. (Racket will also incur a little extra overhead from having to switch to C calling conventions, but this is small and improving: I wouldn’t worry about it.)

In contrast, Chez Scheme’s “automatic storage management system” is tuned to make small, short-lived allocations efficiently. (This discussion is an example of why I’ve come to appreciate Dybvig et al.’s use of “automatic storage management system” when they are talking about more than the GC per se: the allocator matters as much as the collector.) In the optimal case, it will simply increment an allocation pointer, which its custom calling convention even keeps in a machine register on most architectures. If you need to use 'atomic-interior mode (i.e. if the memory must not be moved), there would be some more overhead, but I’d expect it to be more efficient than C malloc(). And reclaiming space from a short-lived allocation just means that it isn’t copied out of the nursery.

My rule of thumb would be to only use 'raw mode when you specifically need to interoperate with free() from C.

Allocation does have costs, and allocating less will typically perform better than allocating more, but cooperating with the automatic storage management system is usually a good idea.

Something else to think about is that, if your whole drain-resampler! function isn’t running in “uninterruptable mode” (or stronger), it might be killed without ever running cleanup!. The ffi/unsafe/alloc library provides abstractions that manage finalization, typically used in conjustion with ffi/unsafe/define for libraries that provide functions like make_foo()/free_foo(). You could use the same tools to abstract over 'raw mode, but that would introduce even more overhead compared to just using 'atomic mode.


Examples are hard: I think it’s useful to see something at a more realistic scale than a tutorial, but real projects may be very big, or may have to design around legacy constraints that wouldn't apply to new construction. Two examples I often look to are the implementation of the db library’s SQLite backend in racket/racket/collects/db/private/sqlite3 at master · racket/racket · GitHub and the implementation of racket/draw in draw/draw-lib/racket/draw/unsafe at master · racket/draw · GitHub (mostly).

A coincidental quirk is that they both use the racket/class object system extensively. The db implementation is IMO a very elegant use: the db library doesn’t expose an object-oriented interface to users, but it uses object orientation internally to help with supporting multiple backends. (For example, the #:use-place argument works the same way for sqlite3-connect and odbc-connect, both being FFI-based rather than wire-based connections.) In racket/draw, on the other hand, some uses of OOP are because the library was formerly implemented in C++: the dc<%> interface makes sense, but I don’t think many of us would advocate making color% a class in a new design.

Another quirk is that db/private/pre, with the SQLite backend, is used to implement package management (specifically, pkg/db) and the local documentation database: that’s why the SQLite backend lives in the main Git repository, instead of in GitHub - racket/db · GitHub. It thus cannot depend on any non-base packages. The constraint for racket/draw is less dramatic, but it’s still in a somewhat different position than a package not limited to dependencies in the main distribution.