Intro
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!
Problem
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 Array
!
Next there's a list of all the methods instances of Array
understand:
> 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 push
, pop
, top
, 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.
Collection types
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: Array
, Set
, Bag
, OrderedCollection
, 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.
Possible Solutions
Don't use Array for everything... have you heard about the Set class?
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!
Metaprogramming to make Arrays more specific
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]
to this:
> 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
By using 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.
Conclusions
- 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.
Top comments (1)
It's not something people say without a reason - extending built-in language facilities can cause clashes with other libraries (this is most often the case with JavaScript, but I believe it's discouraged in Ruby for the same reason).