syntax-objects / Summer2021

Syntax Parse Bee 2021
11 stars 3 forks source link

`try`(`-with`) - yet another `try`/`catch`/`finally` macro #12

Open eutro opened 3 years ago

eutro commented 3 years ago

Macro

A try/catch/finally macro for "forwards" error handling and finalization. Hello #9 and #10!

Source code: https://github.com/eutro/try-catch-match/blob/master/main.rkt

#lang racket/base

(provide try catch finally
         try-with try-with*)

(require racket/match (for-syntax syntax/parse racket/base))

(begin-for-syntax
  (define ((invalid-expr name) stx)
    (raise-syntax-error name "invalid in expression context" stx)))

(define-syntax catch (invalid-expr 'catch))
(define-syntax finally (invalid-expr 'finally))

(begin-for-syntax
  (define-syntax-class catch-clause
    #:description "catch clause"
    #:literals [catch]
    (pattern (catch binding:expr body:expr ...+)))

  (define-syntax-class finally-clause
    #:description "finally clause"
    #:literals [finally]
    (pattern (finally body:expr ...+)))

  (define-syntax-class body-expr
    #:literals [catch finally]
    (pattern (~and :expr
                   (~not (~or (finally . _)
                              (catch . _)))))))

(define-syntax (try stx)
  (syntax-parse stx
    [(_ body:body-expr ...+)
     #'(let () body ...)]
    [(_ body:body-expr ...+
        catch:catch-clause ...
        finally:finally-clause)
     #'(call-with-continuation-barrier
        (lambda ()
         (dynamic-wind
           void
           (lambda ()
             (try body ... catch ...))
           (lambda ()
             finally.body ...))))]
    [(_ body:body-expr ...+
        catch:catch-clause ...)
     #'(with-handlers
         ([void
           (lambda (e)
             (match e
               [catch.binding catch.body ...] ...
               [_ (raise e)]))])
         body ...)]))

(define-syntax (try-with stx)
  (syntax-parse stx
    [(_ ([name:id val:expr] ...)
        body:body-expr ...+)
     #'(let ([cust (make-custodian)])
         (try
          (define-values (name ...)
            (parameterize ([current-custodian cust])
              (values val ...)))
          body ...
          (finally (custodian-shutdown-all cust))))]))

(define-syntax (try-with* stx)
  (syntax-parse stx
    [(_ ([name:id val:expr] ...)
        body:body-expr ...+)
     #'(let ([cust (make-custodian)])
         (try
          (define-values (name ...)
            (parameterize ([current-custodian cust])
              (define name val) ...
              (values name ...)))
          body ...
          (finally (custodian-shutdown-all cust))))]))

Documentation: https://docs.racket-lang.org/try-catch-match/index.html

try/catch/finally is a common and familiar syntax for handling exceptions, used in many languages such as Java, C++ and Clojure. Errors thrown within the try block may be "caught" by the catch clauses. In any case, whether by normal return or exception, the finally clause is executed.

The try macro achieves a similar result. Any exceptions thrown within the try expression's body will be matched against the catch clauses in succession, returning the result of the catch clause if the exception matches. Then, regardless of means, the finally clause is executed when leaving the dynamic extent of the try expression's body.

The expressiveness of match syntax makes it sufficiently flexible for any case, and grants familiarity to those that are used to it.

The try-with macro (and its cousin try-with*), influenced by with-open from Clojure and the try-with-resources from Java generalises resource cleanup in an exception-safe way.

Example

Occasionally with-handlers is unwieldy. Predicates and handlers have to be wrapped in functions, and the error handling code comes before the code that can cause the error. With try it can instead be declared after, without requiring explicit lambdas:

(try
 (read port)
 (catch (? exn:fail:read?) #f)

Perform cleanup such as decrementing a counter on exit:

(try
 (increment-counter!)
 (do-stuff)
 (finally (decrement-counter!)))

Open a file and close it on exit:

(try-with ([port (open-output-file "file.txt")])
 (displayln "Hello!" port))

Before and After

Exception-handling code is incredibly easy to get wrong. Typically it gets very little testing. with-handlers and dynamic-wind especially can be difficult to understand, and clunky to use. try/catch/finally presents a familiar syntax that is hopefully easy to use and leads to less bugs.

At the time of writing, R16 has two examples of erroneous code that could benefit from try:


This example currently doesn't handle exceptions properly. A thread is notified and a counter incremented. A procedure that was passed in is executed, and the counter is decremented again. However, the counter is not properly decremented for unexpected returns, such as exceptions.

    (define (with-typing-indicator thunk)
      (let ([payload (list client (hash-ref (current-message) 'channel_id))])
        (thread-send typing-thread (cons 1 payload))
        (let ([result (call-with-values thunk list)])
          (thread-send typing-thread (cons -1 payload))
          (apply values result))))

It could be rewritten with dynamic-wind, which may confuse those unfamiliar with it.

    (define (with-typing-indicator thunk)
      (let ([payload (list client (hash-ref (current-message) 'channel_id))])
        (dynamic-wind
          (lambda () (thread-send typing-thread (cons 1 payload)))
          thunk
          (lambda () (thread-send typing-thread (cons -1 payload))))))

Or with try:

    (define (with-typing-indicator thunk)
      (let ([payload (list client (hash-ref (current-message) 'channel_id))])
        (thread-send typing-thread (cons 1 payload))
        (try
          (thunk)
          (finally (thread-send typing-thread (cons -1 payload))))))

This example is a simple mistake made by the author, who forgot to wrap #f in const. A read exception thrown in this causes an error trying to apply #f.

      (define (read-args)
        (with-handlers ([exn:fail:read? #f])
          (sequence->list (in-producer read eof (open-input-string args)))))

It could be rewritten with try, without requiring a const, as:

      (define (read-args)
        (try (sequence->list (in-producer read eof (open-input-string args)))
             (catch (? exn:fail:read?) #f)))

Licence

This code is under the same MIT License that the Racket language uses. https://github.com/eutro/try-catch-match/blob/master/LICENSE The associated text is licensed under the Creative Commons Attribution 4.0 International License http://creativecommons.org/licenses/by/4.0/