One good use of continuation passing style is to manage dynamic resources. The resource allocation function is written in continuation passing style and it takes a callback that it invokes once it has allocated and initialized the resource. When the callback exits, the resource is uninitialized and deallocated.
(defun call-with-resource (receiver) (let ((resource nil)) (unwind-protect (progn (setq resource (allocate-resource)) (funcall receiver resource)) (when resource (deallocate-resource resource))))) ;; example usage: (call-with-resource (lambda (res) (do-something-with res))) ;;; In Lisp, we would provide a convenient WITH- macro (defmacro with-resource ((resource) &body body) ‘(CALL-WITH-RESOURCE (LAMBDA (,resource) ,@body))) ;; example usage: (with-resource (res) (do-something-with res))
This pattern of usage separates and abstracts the resource usage
from the resource management. Notice how
the unwind-protect
is hidden
inside call-with-resource
so that the user of the
resource doesn’t have to remember to deallocate the resource.
The with-resource
macro is idiomatic to Lisp. You obviously can’t
provide such a macro in a language without macros, but you can still
provide the call-with-resource
function.
Continuation passing style for resource management can be used in
other languages, but it often requires some hairier syntax.
Because call-with-resource
takes a callback argument,
it is actually a higher-order function. The syntax for passing
higher-order functions in many languages is quite cumbersome. The
return value of the callback becomes the return value
of call-with-resource
, so the return type of the
callback must be compatible with the return type of the function.
(Hence the type of call-with-resource
is actually
parameterized on the return value of the callback.)
Languages without sophisticated type inference may balk at this.
Another advantage of the functional call-with-resource
pattern is that you can dynamically select the resource allocator.
Here is an example. I want to resolve git hashes against a git
repository. The git repository is large, so I don’t want to clone
it unless I have to. So I write two resource
allocators: CallCloningGitRepository
, which takes the
URL of the repository to clone,
and CallOpeningGitRepository
which takes the pathname
of an already cloned repository. The "cloning" allocator will clone
the repository to a temporary directory and delete the repository
when it is done. The "opening" allocator will open the repository
and close it when it is done. The callback that will be invoked
won’t care which allocator was used.
Here is what this looks like in golang:
// Invoke receiver with a temporary directory, removing the directory when receiver returns. func CallWithTemporaryDirectory(dir string, pattern string, receiver func(dir string) any) any { dir, err := os.MkdirTemp(dir, pattern) CheckErr(err) defer os.RemoveAll(dir) return receiver(dir) } // Invoke receiver with open git repository. func CallOpeningGitRepository(repodir string, receiver func(string, *git.Repository) any) any { repo, err := git.PlainOpen(repodir) if err != nil { log.Fatal(err) } return receiver(repodir, repo) } // Invoke receiver with a cloned git repository, removing the repository when receiver returns. func CallCloningGitRepository(dir string, pattern string, url string, receiver func(tmpdir string, repo *git.Repository) any) any { if url == "" { return nil } return CallWithTemporaryDirectory( dir, pattern, func(tempdir string) any { log.Print("Cloning " + url + " into " + tempdir) repo, err := git.PlainClone(tempdir, true, &git.CloneOptions{ Auth: &gitHttp.BasicAuth{ Username: username, Password: password, }, URL: url, Progress: os.Stdout, }) CheckErr(err) log.Print("Cloned.") return receiver(tempdir, repo) }) }
You specify a repository either with a URL or a pathname. We select the appropriate resource allocator based on whether the specifier begins with "https".
func RepositoryGetter (specifier string) func (receiver func(_ string, repo *git.Repository) any) any { if strings.EqualFold(specifier[0:5], "https") { return GetRemoteGitRepository (specifier) } else { return GetLocalGitRepository (specifier) } } func GetRemoteGitRepository(url string) func(receiver func(_ string, repo *git.Repository) any) any { return func(receiver func(_ string, repo *git.Repository) any) any { return CallCloningGitRepository("", "git", url, receiver) } } func GetLocalGitRepository(repodir string) func(receiver func(_ string, repo *git.Repository) any) any { return func(receiver func(_ string, repo *git.Repository) any) any { return CallOpeningGitRepository(repodir, receiver) } }
To open a repository, we
call RepositoryGetter(specifier)
to get
a getRepository
function. Then we invoke the
computed getRepository
function on a receiver callback
that accepts the local pathname and the repository:
getRepository := RepositoryGetter(specifier) return getRepository( func (_ string, repo *git.Repository) any { // resolve git hashes against the repository .... return nil })
If given a URL, this code will clone the repo into a temporary directory and open the cloned repo. If given a pathname, it will just open the repo at the pathname. It runs the callback and does the necessary cleanup when the callback returns.
The biggest point of confusion in this code (at least to me) are the type specifiers of the functions that manipulate the resource allocators. Static types don’t seem to mix well with continuation passing style.
No comments:
Post a Comment