Go back Scroll to reddit comments

Custom exceptions via metaprogramming


February 17, 2020

Suppose you want a large number of custom exceptions. And suppose you're not afraid of metaprogramming. A few months back, I found myself wanting to be "more specific" with the exceptions I raised in my ongoing RuneBlog project (which is used to create this blog). I wanted to raise specific exceptions with custom parameterized messages (without repeating myself too much).

Here's an example of the "usual" way to do this:

class MyException < StandardError def initialize(file, dir) msg = "Could not find #{file} under #{dir}" super(msg) end end raise MyException.new("stuff.txt", "/home/willard") # Output: # exc.rb:10:in `<main>': Could not find stuff.txt under /home/willard (MyException)

Now there is nothing particularly wrong with that. But as it happens, I wanted a large number of exceptions with fairly descriptive names. I didn't want to type (or even paste) this stuff over and over.

Here comes the "not afraid of metaprogramming" part. This is what I did:

def make_exception(sym, str, target_class = Object) return if target_class.constants.include?(sym) target_class.const_set(sym, StandardError.dup) define_method(sym) do |*args| msg = str.dup args.each.with_index {|arg, i| msg.sub!("%#{i+1}", arg) } target_class.class_eval(sym.to_s).new(msg) end end

Now, what does this do? The method takes two parameters (or three if you count the target class which defaults to Object). You specify a symbol (for the name of the exception) and a string (for the exception message). It then does two things: It creates an exception class (named after the symbol); and it creates a method of the same name.

Here are examples of calling make_exception:

make_exception(:NotImplemented, "Feature not yet implemented") make_exception(:CantOpen, "Can't open '%1'") make_exception(:InternalError, "Internal error: Method %1 got arg '%2'")

And here are examples of raising these exceptions:

raise NotImplemented raise CantOpen(somefile) raise InternalError(__method__, arg)

You might ask: Why define a method returning an exception object? The answer is that I don't want to write these this way:

raise NotImplemented.new("Feature not yet implemented") raise CantOpen.new("Can't open '#{somefile}'") raise InternalError.new("Internal error: Method #{__method__} got arg '#{arg}'")

I perceive four advantages doing it the first way: I don't have to specify the message string each time; I still get to have a descriptive, unique class name without the message making it seem redundantly; I don't have to interpolate values into the message string explicitly; and I don't have to say .new every time.

Some of you may have two questions: Can you (or should you) really have a capitalized method name? Can a method have the same name as a class without confusing the interpreter?

I'll answer both those questions with an example from the Ruby core: There is an Integer class and an Integer method (naturally related to each other). Ruby distinguishes between these by context. It's not impossible, and I argue it's not a bad practice.

As for the parameters, it's intuitive how they work. A % sign signals a numbered replaceable piece of text, and these are replaced with the parameters specified when the method is called.

That part of the code is not really robust, by the way. As it is written now,you could easily confuse it-- make it misbehave or crash. But you get the idea.

So far, I have defined 23 custom exceptions in this project. Each definition occupies a single line. If I want to change the behavior of all of them in some way, I will go and change the make_exception method (which itself is only 9 lines).

Like it, hate it? Comments welcome.



[Back] [permalink]