Selective include: Regain control of your modules / helpers

by Paweł Świątkowski
15 Sep 2016

Ruby modules are a great way to enhance your classes with some common behaviour. But as we all know, with great power comes great responsibility. And it turns out we are not that great at handling it.

I believe we all have seen those modules which define enormous amount of methods. If not, go to any Rails app which is more that 2 years old and made it into production and check its app/helpers directory. Of course, it is quite easy to blame Rails as a framework for that. I would agree that helpers system is far from perfect, but it’s not the case. This kind of modules lives in every larger Ruby application out there, probably. All we can blame is probably how our brains work.

It is natural for humans to cluster things into logical units. And this is basically what modules are about. And if you already have a “box” for methods related to a specific topic, people are likely to add more related methods there. They will frequently do it without careful consideration whether all those methods are required by all classes which include the module or not. As a result you end up with tangled dependencies and objects that have methods that do not belong to them.

Alternatively, you can define a lot of very focused, small modules, which do only one thing (SRP much). This is better in many ways, but has at least one huge drawback: makes it hard to reason about the class just by examining its definition in the file. It is really hard to tell what methods the class defines after all, as they are scattered in many module files.

Not too long ago I was debugging a class which started like this:

class SomeModel < ActiveRecord::Base
  include NonDeletion::ActiveRecordExtension
  include UpdateVersion::ActiveRecordExtension
  include SomeModelFeatures::OsVersion
  include SomeModelFeatures::FullScreenValidations
  include SomeModelFeatures::MobileValidations
  include SomeModelFeatures::GeneralValidations
  include SomeModelFeatures::CategoriesValidations
  include SomeModelFeatures::CustomSome

I knew it was going to be a lot of fun with jumping between several files, trying to reconstruct the definition. It was. Not. And we, as humans, really have limited capacity of our working memory, which makes this jumping completely inefficient.

What if we had something in between? Maybe something that is familiar from other languages. How about something like:

class MyClass
  include DeletionSupport.only(:delete_with_confirmation)
  include Delivery.only(:deliver_now, :deliver_later)
end

I see at least two benefits of doing it like that:

  • You immediately see from which module does the method come and notice potential clashes in method naming
  • If you need to import too many methods from the module, you are probably doing something wrong. And you are going to spot it quickly, when you need to put for example 20 methods in import statement.

The good news is that it is totally achievable.

Implementation

The idea to do it came to me quite suddenly, but before digging into implementation, I decided to do a little research. It seemed quite unlikely that no one else came up with something like this before. And I was right. I found a gist created by Avdi Grimm more than three years ago, which did exactly what I had in mind. Therefore the credit for the code goes to him, I only proposed a bit different API. Here’s the original gist.

And here it is how it looks like:

module ModuleHelper
  # original method by Avdi Grimm
  def import(source_module, *method_names)
    all_methods = source_module.instance_methods +
      source_module.private_instance_methods
    unwanted_methods = all_methods - method_names
    object = Object.new.extend(source_module)
    Module.new do
      define_singleton_method(:to_s) do
        "ImportedFunctions(#{source_module}: #{method_names.join(', ')})"
      end

      method_names.each do |name|
        define_method(name) do |*args, &block|
          object.send(name, *args, &block)
        end
      end
      private(*method_names)
    end
  end

  module_function :import

  module Only
    def only(*names)
      ModuleHelper.import(self, *names)
    end
  end
end

module SomeHelpers
  extend ModuleHelper::Only

  def aaa; 'aaa'; end
  def bbb; 'bbb'; end
  def ccc; 'ccc'; end
end

class SomeClass
  include SomeHelpers.only(:aaa, :bbb)
  public :aaa
end

Then, after firing up REPL:

[4] pry(main)> object = SomeClass.new
=> #<SomeClass:0x0055b1736ada40>
[5] pry(main)> object.aaa
=> "aaa"
[6] pry(main)> object.bbb
NoMethodError: private method `bbb' called for #<SomeClass:0x0055b1736ada40>
from (pry):6:in `__pry__'
[7] pry(main)> object.send :bbb
=> "bbb"
[8] pry(main)> object.ccc
NoMethodError: undefined method `ccc' for #<SomeClass:0x0055b1736ada40>
from (pry):8:in `__pry__'
[9] pry(main)> SomeClass.included_modules
=> [#<Module:0x0055b1736d3880>, PP::ObjectMixin, Kernel]

As you might see, all methods are imported as private and you need to explicitly made them public. This comes from Avdi’s original implementation and I was a bit hostile towards it at first. But then, after some thinking, I decided that it’s better to declare your public API explicitly like this, than including everything as public and have potential API leaks. So I decided to leave it this way.

Of course, if you examine the code, it becomes obvious that original visibility can be preserved. However, think about the future use. If you know for sure that methods are imported as private and some of them are explicitly set as public, you don’t need to go to another file to check visibility. Your class is even more self-contained.

You can also see that if we call .included_modules or .ancestors, we don’t have a very pretty output. This is something that we need to learn to live with. However we can have a closer examination by explicitly calling .to_s on each array element:

[10] pry(main)> SomeClass.included_modules.map(&:to_s)
=> ["ImportedFunctions(SomeHelpers: aaa, bbb)", "PP::ObjectMixin", "Kernel"]

Considerations

The Only extension I presented above is great (IMHO), but…

You have to manually extend every module in your system to coöperate with it. There is nothing wrong with that and I would personally encourage this kind of explicitness. However, chances are that you are going to do a selective include from external modules too (i.e. the ones you have no control over). Obviously, you could employ some serious monkey patching to resolve it (please don’t). You could even go as far as Module.send(:extend, ModuleHelper::Only), but if any of your modules or any module in gems you use defines a static method only, you’re screwed. And good luck with debugging.

This is why you might want to fall back to less expressive solution Avdi used, which is applicable to every module out there (without monkey patching!):

class SomeClass
  include ModuleHelper.import(SomeHelpers, :aaa, :bbb)
end

In this case you pass module which you import from as first argument, followed by names of methods you want to import. This does exactly the same, so the choice is yours. My only advice: don’t mix up those two styles of selective import. It would be unclear why they differ for people who come to read your code. In fact, I think you should keep them consistent per whole project, not only per single class.

Is there a gem for that?

I don’t think so. And I’m probably not going to create one. There is very little code needed to make this work, so I think it’s better if you take ownership of it. Then you can decide if you want to keep all imported methods private, public, or maintain their original visibility. It will serve you better this way.

Let me know what you think of this solution. Does it have a chance to make your life better? Because I believe it can ;)

end of the article

Tags: ruby

This article was written by me – Paweł Świątkowski – on 15 Sep 2016. I'm on Fediverse (Ruby-flavoured account, Elixir-flavoured account) and also kinda on Twitter. Let's talk.

Related posts: