Search code examples
ruby-on-railsmethodsparameters

Rails method parameter call convention problems


I have following method with lots of parameters. I have converted this method from parameter list to Hash to avoid mess with default parameters.

 def foo_bar(options = {})
    Rails.logger.info("Got params #{options.inspect}")

    good_name = options[:foo1]
    another_param = options[:foo2]
    yet_another = options[:foo3] || "yes"
    still_one = options[:foo3]
    valid_name = options[:foo4]
    easy_name = options[:foo5]
    mode = options[:foo6]
..

Calling this method is now easy, using just params that are necessary in context, that is good

    foo_bar(foo1: my_foo1, foo3: my_foo3, foo6: my_mode)

But my question is, since Rails is often very intuitive language where you have many shortcuts and ways to do things, how to make this method param signature more clear so

  • I don't have to extract params from hash, they would automatically be assigned to correcponding local var without those "extra" lines where assignment now happens from options hash.
  • There is not a long list of params with default values

want to avoid this since cannot freely choose which params I want assign value to when calling this method. Also call would not be clear which params is which since they are not referenced by name, only by value and order.

def foo_bar(good_name, another_param = nil, yet_another = "yes", still_one = "xx", valid_name = nil, easy_name = nil, foo6 = "std")

Method overloading is not working here either tried that too. That is not implemented like in C# or Java.

I have tried: Method overloading Parameters as hash Parameters "normal way"


Solution

  • The modern way of declaring the method would be to use keyword arguments instead of a hash as a positional argument.

    def foo_bar(**options)
      # ...
    end
    

    One of the issues with the positional argument approach is that you can actually pass input that's not a hash and it will blow up inside the method instead of getting an argument error which will immediately tell you what's wrong.

    ** is a double splat and means that we are slurping up any keyword arguments passed to the method.

    There are many ways of handling default values. If you don't want to list them explicitly in the method definition (def foo(bar: :Baz, ...)) you can have a private method which produces the default options:

    class Foo
      def bar(**kwargs)
        options = kwargs.reverse_merge(default_options)
        # ...
      end
    
      private
    
      def default_options
        { 
          a: 1,
          b: 2
        }
      end
    end
    

    This lets subclasses override the method and is found all over the Rails codebase.

    reverse_merge is from ActiveSupport, you can do the same thing with merge in plain Ruby but in the reverse order.

    I don't have to extract params from hash, they would automatically be assigned to correcponding local var

    This pattern is commonly referred to as mass assignment:

    class Thing
      attr_accessor :foo
      attr_accessor :bar
    
      def initialize(**attributes)
        attributes.each do |key, value|
          send("#{key}=", value)
        end 
      end 
    end 
    
    Thing.new(foo: "a", bar: "b")
    

    For every keyword argument passed to the method we attempt to call a setter metod with the same name as the key. While you can call instance_variable_set and set Ivars directly that doesn't allow you to control what can be assigned and how.

    In Rails you get this for free in your models from ActiveModel::AttributeAssignment and it can be mixed into any class if desired.