In one of the lectures about OOP I usually give at university we go through design patterns and antipatterns. When we first studied the Swiss Army Knife antipattern, the students asked me for an example and I didn't hesitate for a second. I immediately thought of the
Array class in Ruby!
On a very questionable design decision, Ruby has an
Array class that can be used as any type of collection. Want a fixed-size collection? use
Array. Want a queue or a stack? use
Array! Want a simple ordered collection? Use
Next there's a list of all the methods instances of
> Array.instance_methods(false) => [:transpose, :fill, :assoc, :rassoc, :uniq, :uniq!, :compact, :compact!, :to_h, :flatten, :flatten!, :shuffle!, :include?, :permut ation, :combination, :sample, :repeated_combination, :shuffle, :product, :bsearch, :bsearch_index, :repeated_permutation, :map!, :&, :*, :+, :-, :sort, :count, :find_index, :select, :reject, :collect, :map, :pack, :first, :any?, :reverse_each, :zip, :take, :take_whi le, :drop, :drop_while, :cycle, :insert, :|, :index, :rindex, :replace, :clear, :<=>, :<<, :==, :, :=, :reverse, :empty?, :eql?, :concat, :reverse!, :inspect, :delete, :length, :size, :each, :slice, :slice!, :to_ary, :to_a, :to_s, :dig, :hash, :at, :fetch, :last , :push, :pop, :shift, :frozen?, :unshift, :each_index, :join, :rotate, :rotate!, :sort_by!, :collect!, :sort!, :select!, :keep_if, : values_at, :delete_at, :delete_if, :reject!]
And I'm being a nice fella and didn't include the extension methods added in ActiveSupport (remember #forty_two?) ;-)
Array's multipurpose use goes against several object-oriented principles. There are no single responsibilities and there are groups of messages only valid for certain contexts. The class loses its essence just to become an object that tries to make everyone happy. The different collection types are not reified at all. Furthermore, if I want to use a stack in my program, I would expect a
Stack class with an essential protocol of
empty? and nothing else. I'm definitely sure I wouldn't want to retrieve the third element of the stack, for instance. Or to add an element to the bottom.
The idea of specific collection types is not new: Smalltalk-80 specification includes (as of today) the best, in my opinion, collection hierarchy with clearly defined collection types:
SortedCollection and so on. Even though this collection hierarchy has some design problems, it allows the developers to choose a collection type that fits the domain they are working with. You can use those collections in pretty much any Smalltalk dialect, such as Squeak, Cuis or Pharo.
Ruby has a
Set class! Unfortunately, I rarely see it on Ruby programs, maybe because it's easier to create an
Array. Just type
require 'set' and you'll be set (pun intended) to go. Now that you know about it, it's time to use it!
Let's say I want to enforce a list to be used as a fixed size collection. What I can do, given I don't want to implement a brand new collection class, is to "select" just the methods we want, depending on the type of collection we need.
So I want to change default
Array’s behavior (and the way I want to use it) from this:
> my_kind_of_array = [1,2,3] => [1, 2, 3] > my_kind_of_array << 4 => [1, 2, 3, 4]
> my_true_array = [1,2,3].as_array => [1, 2, 3] > my_true_array << 4 NoMethodError: undefined method `<<' for [1, 2, 3]:Array
This would indicate that I want to use my list as a fixed size collection (that's the purpose of the
as_array message) and therefore, every method to add/remove elements should not be understood on that object. That way I can have (in a dynamic way and still using native
Array methods) the exact essential protocol I need.
Here’s a way to implement this:
class Array def as_array instance_eval do undef :<< # and do the same for other methods that add/remove elements end self end end
instance_eval I'm able to change that particular instance I'm using, and
undef makes sure that the method is completely removed, and for the end-user, it seems the message never existed at all.
I can even change the semantics of some of the messages. For instance,
<< on a queue could mean "enqueue", and on a stack could mean "push". Everything you want could happen inside an
instance_eval block: removing methods, adding new ones, and even changing some semantics. Here’s an example of a change in the semantics of
<< to have an array working as a set.
class Array def as_set instance_eval do undef :, :=, :at, :first # and more def <<(object) super(object) unless include?(object) end end self end end
This is just an experiment, the implementation is not complete but you might get an idea of the final goal.
- We still have some antipatterns living in our daily languages and tools. But sometimes, as in Ruby, we have the power to modify the language and/or extend it to suit our needs. This is an option we don't usually consider (maybe for historical reasons, and most people say it's "bad" without strong arguments) but it's perfectly valid.
- Metaprogramming is very helpful in this case, not to add new behavior but to "turn" the swiss army knife in a single, one-purpose tool.
- If collection classes only have essential behavior, developers get more "educated" and they are "forced" to think which kind of collection they want and use it consistently. This would prevent some unexpected hacks to happen.