Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Ruby semantics for accepting an object or its id as an argument

Tags:

ruby

I'm trying to work on the principle of least surprise here...

Let's say you've got a method that accepts two objects. The method needs these to be object instances, but in the place where you initialize the class you may only have reference IDs. This would be common in a router / controller in a web service, for example. The setup might look something like this:

post "/:foo_id/add_bar/:bar_id" do
  AddFooToBar.call(...)
end

There are many different ways that this could be solved. To me the most 'idomatic' here is something like this:

def AddFooToBar.call(foo:nil,foo_id:nil,bar:nil,bar_id:nil)
  @foo = foo || Foo[foo_id]
  @bar = bar || Bar[bar_id]
  ...
end

Then when you call the method, you could call it like:

AddFooToBar.call(foo: a_foo, bar: a_bar)
AddFooToBar.call(foo_id: 1, bar_id: 2)

This creates a pretty clear interface, but the implementation is a little verbose, particularly if there are more than 2 objects and their names are longer than foo and bar.

You could use a good old fashioned hash instead...

def AddFooToBar.call(input={})
  @foo = input[:foo] || Foo[ input[:foo_id] ]
  @bar = input[:bar] || Bar[ input[:bar_id ]
end

The method signature is super simple now, but it loses a lot of clarity compared to what you get using keyword arguments.

You could just use a single key instead, especially if both inputs are required:

def AddFooToBar.call(foo:,bar:)
  @foo = foo.is_a?(Foo) ? foo : Foo[foo]
  @bar = bar.is_a?(Bar) ? bar : Bar[bar]
end

The method signature is simple, though it's a little weird to pass just an ID using the same argument name you'd pass an object instance to. The lookup in the method definition is also a little uglier and less easy to read.

You could just decide not to internalize this at all and require the caller to initialize instances before passing them in.

post "/:foo_id/add_bar/:bar_id" do
  foo = Foo[ params[:foo_id] ]
  bar = Bar[ params[:bar_id] ]
  AddFooToBar.call(foo: foo, bar: bar)
end

This is quite clear, but it means that every place that calls the method needs to know how to initialize the required objects first, rather than having the option to encapsulate that behavior in the method that needs the objects.

Lastly, you could do the inverse, and only allow object ids to be passed in, ensuring the objects will be looked up in the method. This may cause double lookups though, in case you sometimes have instances already existing that you want to pass in. It's also harder to test since you can't just inject a mock.

I feel like this is a pretty common issue in Ruby, particularly when building web services, but I haven't been able to find much writing about it. So my questions are:

  1. Which of the above approaches (or something else) would you expect as more conventional Ruby? (POLS)

  2. Are there any other gotchas or concerns around one of the approaches above that I didn't list which should influence which one works best, or experiences you've had that led you to choose one option over the others?

Thanks!

like image 832
Andrew Avatar asked Jan 16 '15 20:01

Andrew


People also ask

Can you pass an object as an argument?

To pass an object as an argument we write the object name as the argument while calling the function the same way we do it for other variables. Syntax: function_name(object_name); Example: In this Example there is a class which has an integer variable 'a' and a function 'add' which takes an object as argument.

How do you define a method that can accept a block as an argument?

We can explicitly accept a block in a method by adding it as an argument using an ampersand parameter (usually called &block ). Since the block is now explicit, we can use the #call method directly on the resulting object instead of relying on yield .

What is difference between == === EQL and equal?

The == operator, also known as equality or double equal, will return true if both objects are equal and false if they are not. The != operator, also known as inequality, is the opposite of ==. It will return true if both objects are not equal and false if they are equal.


1 Answers

I would go with allowing either the objects or the ids indistinctively. However, I would not do like you did:

def AddFooToBar.call(foo:,bar:)
  @foo = foo.is_a?(Foo) ? foo : Foo[foo]
  @bar = bar.is_a?(Bar) ? bar : Bar[foo]
end

In fact, I do not understand why you have Bar[foo] and not Bar[bar]. But besides this, I would put the conditions built-in within the [] method:

def Foo.[] arg
  case arg
  when Foo then arg
  else ...what_you_originally_had...
  end
end

Then, I would have the method in question to be defined like:

def AddFooToBar.call foo:, bar:
  @foo, @bar = Foo[foo], Bar[bar]
end
like image 172
sawa Avatar answered Oct 22 '22 19:10

sawa