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
endRuby With RuboClaus:
define_function :add do
clauses(
clause([Fixnum, Fixnum], proc { |first, second| first + second }),
catch_all(proc { "Please use numbers" })
)
endIt 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
endRuby 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 })
)
endTo 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
clausesmethod call
-
clauses- N number of
clausemethod calls and/or a singlecatch_allmethod call
- N number of
-
clause|p_clause- Array - list of arguments to pattern match against
- Keywords:
-
:any- among your arguments,:anyrepresents that any data type will be accepted in its position. -
:tail- given an array argument with defined "head" elements and:tailas 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 returnNoPatternMatchErrorif 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
clausepatterns 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_functionis awkward since Ruby uses the termmethodinstead offunction - [ ] Add Benchmarks to see performance implications
- [ ] Support private clauses to enforce a single entry point to a defined function