I think it does not have to look that way, 'call-with-` style functions can be combined with syntax that makes them look as nice or I would claim even nicer than defer.
defer
Personally I just don't find defer appealing at all. I don't think that having a minimum of 2 statements composes well at all. (because the return values are ignored and in case of the file, the file handle is bound to a name using a define statement)
When (if) I program go then I would use defer too, because lambdas/thunks are more awkward in go and it doesn't match what is expected there.
unnecessary dynamic behaviour (hidden from the user)
What I don't like about your implementation is that it splits the world of functions into 2 worlds: those that have to be used with define-proc
or something similar and those that work without it.
Notice e.g. when I call auto-open-db!
in the repl I get:
> (auto-open-db! "bar")
"Opened bar"
"bar"
Because I have to know that I have to call that function from within a function that uses define-proc
or something similar.
That is what I mean with 2 worlds. Personally I don't like it when I can use a function in a context where its behavior only works partially, without it giving me an error message.
You could add some kind of checking to prevent that, but that would be even more dynamic.
While calling auto-open-db2!
is less "pretty" I prefer that it is explicit and does less juggling underneath:
> (auto-open-db2! "bar" (lambda (x) (printlnf "x: ~a" x) x))
"Opened bar"
"x: bar"
"Closed bar"
"bar"
semantics vs syntax
I also think there are 2 different things being discussed here:
- semantics: what and how the code is executed under the hood
- syntax: having a convenient way to write it, without lots of nesting and the associated right-wards drift.
semantics
Regarding 1. I don't like that your solution, makes the code that ends up being executed more complicated. I don't think that parameters and escape continuations are necessary or desirable to implement this particular thing.
Why I don't like this?
Mostly I find that this makes the expanded code or when you want to understand the actual control-flow unnecessarily difficult and dynamic.
I only use escape continuations when they greatly simplify the code or the code can't be modeled without them, here it is possible without.
syntax
Your example defines add-user2!
with lots of right-wards drift and I agree, I wouldn't want to write a lot of functions that way. Why not start creating a solution right from that code?
As a short aside:
I want to point out that add-user2!
has a handle to db
while add-user!
does not. That needs to be added, without it the comparison isn't fair.
Having this "handle"/parameter is useful, so I am adding it in the 2nd version:
#lang racket
(require syntax/parse/define)
(define (make-lock) (make-semaphore 1))
(define printlnf (compose1 println format))
;; Maybe first lock is logical while the latter
;; protects some internal data structures for the DB.
(define user-table-lock (make-lock))
(define db-integrity-lock (make-lock))
(define-syntax-parser with
[(_ ([func arg]) body ...+)
#'(func arg (lambda () body ...))]
[(_ ([func arg] next ...) body ...+)
#'(func arg (lambda () (with (next ...) body ...)))])
;; my variant of auto-open-db!
(define (database-connection db-name thk)
(printlnf "Opened ~a" db-name)
(begin0
db-name
(thk)
(printlnf "Closed ~a" db-name)))
;; why does add-user2! get an actual handle to the db
;; when add-user! does not? That's not fair...
;; how would add-user! get the db handle?
(define locked call-with-semaphore) ;; renamed so it looks nicer
(define (add-user3! username)
(with ([locked user-table-lock]
[locked db-integrity-lock]
[database-connection "users"])
(printlnf "Added ~a to users" username)))
;; in general I think this code is too much pseudo code,
;; would be better to work with real examples
;; getting some kind of handle could definitely be useful
;; so lets add them; and multiple arguments while we are at it...
(define-syntax-parser with2
#:datum-literals (->)
[(_ ([func:expr (~and args:expr (~not ->)) ...+ (~optional (~seq -> bindings:id ...))] next ...) body ...+)
#'(func args ... (lambda ((~? (~@ bindings ...))) (with2 (next ...) body ...)))]
[(_ () body ...+)
#'(begin body ...)])
(define (database-connection2 db-name something-else thk)
(printlnf "Opened ~a" db-name)
(begin0
db-name
(thk 'db-connection)
(printlnf "Closed ~a" db-name)))
(define (add-user4! username)
(with2 ([locked user-table-lock]
[locked db-integrity-lock]
[database-connection2 "users" 'some-other-data -> db])
(printlnf "Added ~a to users using connection ~a" username db)))
;; maybe you want to write code in between the different function calls
(define-syntax-parser with-block
#:datum-literals (->)
[(_ (~and before (~not #:with)) ... #:with [func:expr (~and args:expr (~not ->)) ...+ (~optional (~seq -> bindings:id ...))] after ...+)
#'(begin before ... (func args ... (lambda ((~? (~@ bindings ...))) (with-block after ...))))]
[(_ body ...+)
#'(begin body ...)])
(define (add-user5! username)
(with-block
#:with (locked user-table-lock)
;; do something with table lock, before acquiring the next lock
#:with (locked db-integrity-lock)
;; do something with db lock, before getting the db connection
#:with (database-connection2 "users" 'some-other-data -> db)
(printlnf "Added ~a to users using connection ~a" username db)))
;; or even integrate it into the define similar to define-proc,
;; but without the "use from wrong context"
(define-syntax-rule (define-with head body ...)
(define head (with-block body ...)))
(define-with (add-user6! username)
#:with (locked user-table-lock)
;; do something with table lock, before acquiring the next lock
#:with (locked db-integrity-lock)
;; do something with db lock, before getting the db connection
#:with (database-connection2 "users" 'some-other-data -> db)
(printlnf "Added ~a to users using connection ~a" username db))
I especially like that doing it this way the macro expands to simple code just a bunch of lambdas, without me having to write it that way.
This following uses the macro steppers macro hiding, but without it, it is very similar just less readable.
(define (database-connection db-name thk)
(printlnf "Opened ~a" db-name)
(begin0 db-name (thk) (printlnf "Closed ~a" db-name)))
(define locked call-with-semaphore)
(define (add-user3! username)
(locked
user-table-lock
(lambda ()
(locked
db-integrity-lock
(lambda () (database-connection "users" (lambda () (printlnf "Added ~a to users" username))))))))
For comparison here is add-user!
:
(define dynamic-return (make-parameter '#f))
(define (defer-thunk thunk)
(let ((old-return (dynamic-return)))
(dynamic-return (λ args (thunk) (apply old-return args)))))
(define (auto-lock! lock-val) (lock! lock-val) (let ([body-thunk (λ () (unlock! lock-val))]) (defer-thunk body-thunk)))
(define (auto-open-db! db-name)
(printlnf "Opened ~a" db-name)
(let ([body-thunk (λ () (printlnf "Closed ~a" db-name))]) (defer-thunk body-thunk))
db-name)
(define (add-user! username)
(let/ec
return-func
(parameterize
([dynamic-return return-func])
(let ([new-return-func (λ args (apply (dynamic-return) args))])
(syntax-parameterize
([return (make-rename-transformer #'new-return-func)])
(auto-lock! user-table-lock)
(auto-lock! db-integrity-lock)
(auto-open-db! "users")
(new-return-func (printlnf "Added ~a to users" username)))))))))
And here is add-user4!
doing some more things, but still a simple expansion:
(define (database-connection2 db-name something-else thk)
(printlnf "Opened ~a" db-name)
(begin0 db-name (thk 'db-connection) (printlnf "Closed ~a" db-name)))
(define (add-user4! username)
(locked
user-table-lock
(lambda ()
(locked
db-integrity-lock
(lambda ()
(database-connection2
"users"
'some-other-data
(lambda (db) (printlnf "Added ~a to users using connection ~a" username db))))))))))
Personally most of the time I would stop with with2
, but some may want to have the syntax integrated into the function declaration syntax and go all the way to define-with
.
Currently with-block
gives a not so great error message when the body is empty an extra case could detect that and state that it isn't allowed explicitly, or alternatively it could default to (void)
.