Building Python's 'with' in Ruby
Replicating a neat feature of another language
One of the features I really dig in Python is the with
keyword.
with open("path/to/file" as fp):
do_something(fp)
When the block exits, the file pointer fp
is guaranteed to be closed. More specifically, python’s with
keyword will call __enter__()
on the object it is given, execute the nested block of code, then call __exit__()
on the object even if an exception occurs. This saves a lot of leaky file pointer code and is pretty similar to a similar pattern implemented in Ruby:
File.open("/path/to/file") do |fp|
do_something(fp)
end
The biggest difference is that with
is built into the Python language, while block execution with guaranteed cleanup like the File.open
example has to be implemented by each library or method that wants to use it.
I seem to have a thing for building constructs in other languages in Ruby, so what would it look like to bring a generalized version of this into Ruby?
Right off the bat, let’s agree that __enter__
and __exit__
are very pythonic names (so-called “dunder” methods). We’ll use open
and close
in the Ruby world, inspired by the File
example.
The minimal version of this File
example would actually only have a close
method:
module With
module_function
def [](object)
yield(object)
ensure
object.close
end
end
Used as
With[File.open("/path/to/file")] do |fp|
do_something(fp)
end
The ensure
block is the key here, making sure that close
is called on the object given to the []
method. In this case, File.open
is used in its non-block form, which is how you’d get leaky file pointers. But our With[]
block takes care of it. But now with this With
module, you automatically get this safe-exit block functionality with any object that has a close
method.
I’ve gone with the []
method here, which won out in my opinion over the following alternatives:
- Define
with
as a method onKernel
. This is brandishing our big stick too loudly. Sincewith
is not a protected keyword in Ruby and a very common word in English, it’s reasonable to assume many other classes and modules have awith
method that would override ours in some contexts, and what gives us the right to claim the method namewith
that globally? - Define a more readable method name, such as
block
, to be used asWith.block(...) { do_something }
. For the purpose I find it unnecessarily verbose. The[]
method is reasonably the only thing that thieWith
module will ever want defined. - Make
With
a class and define a method (see the preceding point) or put the functionality in the constructor, as inWith.new(...) { do_something }
. I still find this verbose and don’t love the idea of the instance instantiation for every such exsecution.
So, []
it is!
This may be a sufficient implementation for most use cases. However it’s missing one or two things especially if you prefer a little sanity checking. Let’s add (1) calling the open
method, and (2) raising a sensible error if the object doesn’t have the right methods.
module With
module_function
def [](object)
raise ArgumentError "Object for `With` must respond to `close`" if !object.respond_to(:close)
object.open if object.respond_to(:open) &&
(!object.respond_to(:open?) || !object.open?)
yield(object)
ensure
object.close
end
end
Though I’ve made my feelings about duck typing known, and they haven’t changed, this seems like the only sensible solution for our method.
Here, we’re only checking if object
has the close
method, because that’s the base requirement for it making any sense to use With
. If your object doesn’t have an open
method, no problem. If it is already open and can tell us that be responding to open?
, we won’t try to open it again.
Lastly, it’s worth exploring making this slightly more flexible by adding an option to use a method other than close
.
module With
module_function
def [](object, open: :open, close: :close, is_open: :open?)
raise ArgumentError "Object for `With` must respond to `close`" if !object.respond_to(close)
object.public_send(open) if open &&
object.respond_to(open) &&
!(is_open && object.respond_to(is_open) && object.public_send(is_open))
yield(object)
ensure
object.public_send(close) if close
end
end
The wording is slightly confusing, but for each method open
, close
, and is_open
, we’ve added a check that it has a value other than nil
or false
(allowing the caller to forcibly skip them using open: nil
, for example), and use public_send
to call them methods by the chosen names (which default to the names we used above).
With[temp_record, open: nil, close: :destroy!] do |record|
end
Though, to be honest, I think this is getting a little over-engineered and prefer the first version in this post for its simplicity. It’s not that hard to re-write the ensure
block if you want to use this idea with another kind of object.
And that’s it.