What do you think about pre and post condition checks in Elixir?
I’ve just started learning Elixir and can see how guards are similar to pre-condition checks. Is there anything that could be used for post-condition checks?
Last night I realised the pipe operator could be used (abused?) to help implement post-condition checks and wrote this marcro:
defmacro ensure input, pattern1, pattern2 do
quote do
case unquote(input) do
unquote(pattern1) -> unquote(input)
unquote(pattern2) -> unquote(input)
_ -> raise PostConditionError
end
end
end
It would be used like:
def is_food(input) do
case input do
"Cheese" -> true
"Beer" -> {:error, "Item is not food"}
"Phone" -> false
end
|> ensure(true, {:error, _reason})
end
A is_food("Phone") call would result in a PostConditionError being raised.
You might be able to create a macro def/3, which allows for a syntax like
def is_foot(input) when is_binary(food) do
case input do
"Cheese" -> true
"Beer" -> {:error, "Item is not food"}
"Phone" -> false
end
after
result == true or match?({:error, _reason}, result)
end
Worth noting this is already valid syntax with very different implications: it creates an implicit try block around the function and ensures your code runs after it whether or not it raised; and does not allow accessing the result of the function (since there may be none since it could have raised).
Playing around with a couple of observations:
What you propose is still actually def/2: the macro receives a call and a keyword list of blocks, so in our example the macro would receive:
defmacro def(head, [do: body, after: postcheck])
Semantically what we want is actually closer to the else block in a try that matches on the result iff no error was raised, allowing for post-checks and result transformation:
def is_foot(input) when is_binary(food) do
case input do
"Cheese" -> true
"Beer" -> {:error, "Item is not food"}
"Phone" -> false
end
else
true -> true
{:error, reason} -> {:error, {:not_foot, reason}}
_ -> raise PostConditionError # possibly implicit, though traditionally not
end
You can’t make up your own block keywords, the parser only allows usage of the existing secondary-block terms after a do: catch, rescue, after and else
The existing implementation of def with an implicit try only uses catch, rescue, and after
Conclusion: We could make a custom def/2 macro that employs an else block and transforms the provided implementation into something traditionally allowed in Kernel.def/2, and else is already the only syntactically valid unused block keyword in Kernel.def/2.
Cons: def(head, do: body, else: postcheck) does not indicate intent nearly as well as def(head, do: body, after: postcheck). But such a macro would definitely be possible!
Alternatively:
Since block arguments must come last, the only form of def/3 the parser would recognize would be:
defmacro def(head, postcheck, [do: body])
This isn’t that great though:
def is_foot(input) when is_binary(food), true or {:error, _reason} do
case input do
"Cheese" -> true
"Beer" -> {:error, "Item is not food"}
"Phone" -> false
end
end
Interestingly we could employ a fictional ensure/1 function that the parser doesn’t choke on, to make the intent more legible:
def is_foot(input) when is_binary(food), ensure true or {:error, _reason} do
case input do
"Cheese" -> true
"Beer" -> {:error, "Item is not food"}
"Phone" -> false
end
end
Cons: certain complicated expressions could make the parser decide it no longer likes this construct, it’s arguably even hackier though it reads well…