DEV Community

loading...

Ruby's Array: a Swiss Army Knife?

ngarbezza profile image Nahuel Garbezza ・4 min read

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.

Discussion (1)

Collapse
stojakovic99 profile image
Nikola Stojaković • Edited

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.

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).

Forem Open with the Forem app