RuboClaus
RuboClaus is an open source project that gives the Ruby developer a DSL to implement functions with multiple clauses and varying numbers of arguments on a pattern matching paradigm, inspired by functional programming in Elixir and Erlang.
gem install rubo_claus
Note
RuboClaus is still in very early stage of development and thought process. We are still treating this as a proof-of-concept and are still exploring the topic. As such, we don't suggest you use this in any kind of production environment, without first looking at the library code and feeling comfortable with how it works. And, if you would like to continue this thought experiment and provide feedback/suggestions/changes, we would love to hear it.
Rationale
The beauty of multiple function clauses with pattern matching is fewer conditionals and fewer lines of unnecessary defensive logic. Focus on the happy path. Control types as they come in, and handle for edge cases with catch all clauses. It does not work great for simple methods, like this:
Ruby:
def add(first, second)
return "Please use numbers" unless [first, second].all? { |obj| obj.is_a? Fixnum }
first + second
end
Ruby With RuboClaus:
define_function :add do
clauses(
clause([Fixnum, Fixnum], proc { |first, second| first + second }),
catch_all(proc { "Please use numbers" })
)
end
It is cumbersome for problems like add
--in which case we don't recommend using it. But as soon as we add complexity that depends on parameter arity or type, we can see how RuboClaus makes our code more extendible and maintainable. For example:
Ruby:
def handle_response(status, has_body, is_chunked)
if status == 200 && has_body && is_chunked
# ...
else
if status == 200 && has_body && !is_chunked
# ...
else
if status == 200 && !has_body
# ...
else
# ...
end
end
end
end
Ruby with RuboClaus:
define_function :handle_response do
clauses(
clause([200, true, true], proc { |status, has_body, is_chunked| ... }),
clause([200, true, false], proc { |status, has_body, is_chunked| ... }),
clause([200, false], proc { |status, has_body| ... }),
catch_all(proc { return_error })
)
end
To learn more about this style of programming read about function overloading and pattern matching.
Usage
Below are the public API methods and their associated arguments.
-
define_function
- Symbol - name of the method to define
- Block - a single block with a
clauses
method call
-
clauses
- N number of
clause
method calls and/or a singlecatch_all
method call
- N number of
-
clause
|p_clause
- Array - list of arguments to pattern match against
- Keywords:
-
:any
- among your arguments,:any
represents that any data type will be accepted in its position. -
:tail
- given an array argument with defined "head" elements and:tail
as the last element (such as[String, String, :tail]
), this will destructure the head elements and make the tail an array of the non-head elements.
-
- Keywords:
- Proc - method body to execute when this method is matched and executed
- Note on
p_clause
- only visible to other clauses in the function, and will returnNoPatternMatchError
if invoked with matching parameters external to the function. Ideally used when calling the function recursively with different arity than the public api to the method.
- Array - list of arguments to pattern match against
-
catch_all
- Proc - method body that will be executed if the arguments do not match any of the
clause
patterns defined
- Proc - method body that will be executed if the arguments do not match any of the
Clause pattern arguments
The first argument to the clause
method is an array of pattern match options. This array can vary in length, and values depending on your pattern match case.
You can match against specific values:
clause(["foo"], proc {...})
clause([42], proc {...})
clause(["Hello", :darth_vader], proc {...})
You can match against specifc argument types:
clause([String], proc {...})
clause([Fixnum], proc {...})
clause([String, Symbol], proc {...})
You can match against specific values and types:
clause(["Hello", String], proc {...})
clause([42, Fixnum], proc {...})
clause([String, :darth_vader], proc {...})
You also can match against any value or type if you don't have a specific requirement for an argument by using the :any
symbol.
clause(["Hello", :any], proc {...})
clause([:any], proc {...})
clause([42, :any], proc {...})
You also can destructure an array with :tail
.
clause(["Hello", [Fixnum, :tail]], proc { |string, number, tail_array| ... })
clause([Hash, [Fixnum, Fixnum :tail]], proc { |hash, number1, number2, tail_array| ... })
Examples
Please see the examples directory for various example use cases. Most examples include direct comparisons of the Ruby code to a similar implementation in Elixir.
Development
Don't introduce unneeded external dependencies.
Nothing else special to note for development. Just add tests associated to any code changes and make sure they pass.
TODO
- [ ] Rename public API methods?
define_function
is awkward since Ruby uses the termmethod
instead offunction
- [ ] Add Benchmarks to see performance implications
- [ ] Support private clauses to enforce a single entry point to a defined function