Context managers in OCaml
Context managers
In 2005, Python 2.5 introduced the with
statement, also known as context managers, following a need for a simple way to achieve a pattern that is very common in code that involves dealing with resources: open a resource, maybe perform some initialization, read or write to it, and then make sure it is closed when things are done even when something has failed somewhere.
with open("good_languages.txt", "r") as file:
file.write("JavaScript") # oops, the file is not writable!
Despite the exception being raised, the file will still close cleanly under the hood.
Obviously, Python is far from being the only language with this type of feature. They may have different names, but initializers/destructors are the same idea.
#include <print>
class Resource {
public:
Resource(int x);
~Resource();
};
Resource::Resource(int x) {
std::println("Initializing...");
}
Resource::~Resource() {
std::println("Cleaning up...");
}
int main() {
Resource res(42); // Initializing...
std::println("Doing stuff.");
return 0;
} // Cleaning up...
Binding operators
6 years ago, the binding operator extension (sometimes referred as let-bindings) was added to OCaml in the version 4.08 of the language.
let ( let+ ) = Option.bind
let parse_rgb (raw_contents : string) : (int * int * int) option =
(* parse_* functions are defined somewhere else *)
let+ red = parse_red raw_contents in
let+ green = parse_green raw_contents in
let+ blue = parse_blue raw_contents in
Some (red, green, blue)
While often used similarly to Haskell's
do
notation — that is, for monadic binding — it is actually more general and powerful.
Let-bindings are special operators of the form let<op>
where <op>
is some operator like +
or $*
. You can find the grammar here.
One key thing to remember is their signature. We can look at how they will be used to understand how to define them.
let<op> resource : 'b = (argument : 'a) in
(do_something resource : 'c)
In our case, 'a
will be the argument (for example, the path of the file) to construct the resource object which is 'b
, and 'c
will be whatever is returned at the end of the operations (for example, a JSON object that was deserialized from the file contents).
Okay, let's get more concrete by putting it into code.
Starting with basics
We set up a module for our type's definition. It is a simple example that doesn't involve actual resources, but I think it's still enough for the demonstration.
module Resource = struct
type t = { path : string }
end
As it is a resource type, it needs some basic functions like opening, reading and closing.
(* right below our type definition *)
let open' (path : string) : t option = Some { path }
let close (resource : t) : unit = ()
let read_all (resource : t) : string option = Some "Hello, World!"
Let's try it out:
let maybe_resource = Resource.open' "my_path"
let maybe_contents = Option.bind maybe_resource Resource.read_all
let () =
match maybe_contents with
| None -> Printf.eprintf "Something went wrong :(\n"
| Some contents -> Printf.printf "%s\n" contents
;;
This works fine, we are correctly getting Hello, World!
printed on the screen. But we can do better!
Using let-bindings to emulate context managers
First, I will start by making use of a let-binding for Option
's monadic binding:
(* above all the code *)
let ( let+ ) = Option.bind
Under the module functions, we will add a Notation
submodule where we will define a let-binding that acts like a context manager.
(* in module Resource *)
module Notation = struct
let ( let-> ) (path : string) (func : t -> 'a option) : 'a option =
let+ resource = open' path in
Printf.printf "Initializing...\n";
(* We don't use let+ here! We want to close the resource! *)
let result = func resource in
Printf.printf "Doing some work!\n";
close resource;
Printf.printf "Cleaning up...\n";
result
;;
end
All it does is:
- construct a new object by acquiring the underlying resource
- perform the given operation (
func
) - close the resource.
This is how the use site looks like now:
open Resource.Notation
let maybe_contents =
let-> resource = "my_path" in
Resource.read_all resource
;;
let () =
match maybe_contents with
| None -> Printf.eprintf "Something went wrong :(\n"
| Some contents -> Printf.printf "%s\n" contents
;;
Now, we have something strikingly similar to Python's with
statement and it's nice.
Possible enhancements
Admittedly, this is a pretty basic implementation with some room for improvement. For example, it is completely blind to exceptions, so the file doesn't get closed if func
raises one. Furthermore, if func
accidentally closes the file, it will attempt to do it again without checking, which can be highly problematic.
Full implementation
let ( let+ ) = Option.bind
module Resource = struct
type t = { path : string }
let open' (path : string) : t option = Some { path }
let close (resource : t) : unit = ()
let read_all (resource : t) : string option = Some "Hello, World!"
module Notation = struct
let ( let-> ) (path : string) (func : t -> 'a option) : 'a option =
let+ resource = open' path in
Printf.printf "Initializing...\n";
(* We don't use let+ here! We want to close the resource! *)
let result = func resource in
Printf.printf "Doing some work!\n";
close resource;
Printf.printf "Cleaning up...\n";
result
;;
end
end
open Resource.Notation
let maybe_contents =
let-> resource = "my_path" in
Resource.read_all resource
;;
let () =
match maybe_contents with
| None -> Printf.eprintf "Something went wrong :(\n"
| Some contents -> Printf.printf "%s\n" contents
;;
Thank you for reading through!