I’ve recently been learning Elixir by building a little command-line app to keep track of invoices for contract work I do on the side. When I first started using the app, I ran into some bugs that were caused by improperly using macros.
One of the more powerful features of Elixir is macros. I haven’t dug too deeply into them yet, but I used to program in Lisp which has a really powerful macro system. The little bit I’ve read about macros in Elixir reminds me quite a bit of Lisp macros.
For my InvoiceTracker application, I found Daniel Perez’ wonderful ex_cli library to make it easy to implement the command-line interface (CLI). ex_cli uses macros to provide a DSL for defining commands and their options and arguments.
One of the convenience features I added to my application is to make it figure out the date of various activities based on my invoicing process. For example, I always invoice on the 1st and 16th of the month, so if I don’t provide a date on the command line, the application figures out the appropriate invoice date based on the current date.
I have a
DefaultDate module that does all of the default date calculation, and I originally used it in my CLI like this:
I thought everything was fine. The unit tests for date defaulting all passed. For repeatability, my end-to-end tests always specify a date, so they don’t test date defaulting, unfortunately. But I did some manual testing on the command-line, and everything looked like it was working great.
A few days later, it was time to record a new invoice. I ran the
record command in the compiled application, allowing it to default the date, and it picked the wrong date! Instead of choosing March 16 like it should have, it fell back to March 1. How did this happen? Everything has been working right all along. How was it suddenly broken?
I wish I could say I had a good, methodical way to debug this and figure out what was going on. But instead, the insight just came to me somehow.
As I mentioned above, ex_cli uses macros to define its DSL. When Elixir compiles your code, it first expands any macros you’re using and then compiles the resulting code. Depending on how the macros are defined, they may evaluate some of your code at compile time instead of run time.
In this case, ex_cli evaluates the value of the
default option at compile time. So it was evaluating the
DefaultDate.for_invoice() call at compile time, using the current date as of that time. Since I had last compiled the app on a date between March 1 and March 15, it compiled in a default date of March 1 instead of calculating the default date based on the day I was running the app.
Here’s the fixed version (full commit here):
I removed the
default setting for the option, and also removed the
required: true setting so the date could be omitted on the command line. Then, when creating the invoice, I do the default date calculation if no date is provided on the command-line. This calculation happens at run-time, using the actual current date and not the date the program was last compiled.
So what’s my point? Should you avoid macros in Elixir because they’re too dangerous? Absolutely not! Macros are a powerful feature that allow you to do things you couldn’t otherwise do.
When using macros, you just have to keep in mind that there are two possible times that your code can be evaluated: at compile-time and at run-time. If you have code that depends on anything external, you need to be aware of when your code will run and whether the external dependencies will be correct at that time.