In Progress
Unit 1, Lesson 1
In Progress

Create and Extend

In this episode, you’ll learn about two different options for treating complex object assembly as a single expression—and a third, even more concise option for when you’re creating an object for the purpose of mixing in a module.

Video transcript & code

In Episode #481 we were working on extracting parts of a view template into a presenter class.

require "./cards"
require "erb"

class HitPresenter
  def initialize(hand, new_card)
    @hand           = hand
    @new_card       = new_card
    @card_formatter = Object.new
    @card_formatter.extend(CardHelper)
  end

  def render
    ERB.new(<<~'EOF').result(binding)
    New card: <%= long_card(@new_card) %>

        (Now in your hand: <%=
         @hand.map{|card| pretty_card(card)}.join(", ")
         %>)
    EOF
  end

  private

  def pretty_card(card)
    @card_formatter.pretty_card(card)
  end

  def long_card(card)
    @card_formatter.long_card(card)
  end
end

In order to treat an existing helper module as if it were a collaborator object, we hit upon the solution of instantiating a blank object, and then extending it with the helper module.

@card_formatter = Object.new
@card_formatter.extend(CardHelper)

This made our dependency on the CardHelper module a private one, instead of a public one. it also gave us a smooth upgrade path to a future in which we use a CardFormatter class here instead of the old CardHelper module.

But private and encapsulated as it may be, this is still a hardcoded dependency. We have not made the HitPresenter open to extension. We can't swap in a new formatter at will, and we can't easily substitute a test double formatter for verification purposes.

Let's change this. Let's introduce a keyword parameter for setting the card formatter collaborator.

class HitPresenter
  def initialize(hand, new_card, card_formatter: )
    @hand           = hand
    @new_card       = new_card
    @card_formatter = Object.new
    @card_formatter.extend(CardHelper)
  end
  # ...
end

But what will we make it default to? At first, we might try setting it to the same thing we assign to the instance variable in the initializer body, a new plain object. Then we could assign the value of the parameter to the instance variable.

class HitPresenter
  def initialize(hand, new_card, card_formatter: Object.new)
    @hand           = hand
    @new_card       = new_card
    @card_formatter = card_formatter
    @card_formatter.extend(CardHelper)
  end
  # ...
end

But there's a problem here: the next line will always mix the CardHelper module into the object used as the card_formatter.

@card_formatter.extend(CardHelper)

We only want that to happen when we're going with the defaults, not when an alternative collaborator has been passed.

We get another idea: maybe instead of putting the default collaborator object as the parameter default, we'll just put a flag there that means "use the default value". We'll use a convention we've seen in other libraries and use nil for the default flag. Then in the body we can conditionally assign the @card_formatter instance variable based on the truthiness of the parameter.

class HitPresenter
  def initialize(hand, new_card, card_formatter: nil)
    @hand           = hand
    @new_card       = new_card
    @card_formatter = card_formatter || Object.new
    @card_formatter.extend(CardHelper)
  end
  # ...
end

But this still doesn't address the issue that all collaborators will have CardHelper mixed into them, whether they are the default or not.

We need a way to combine the object creation and object extension steps into one expression, which we only evaluate if the card_formatter parameter is nil.

Let's consider a few different ways of doing this.

One option is to combine the creation and extension into a begin…end block as we saw in Episode #227. This syntax allows us to treat an arbitrary sequence of statements as if it were a single expression. We have to be careful to return the object at the end of the block, though.

class HitPresenter
  def initialize(hand, new_card, card_formatter: nil)
    @hand           = hand
    @new_card       = new_card
    @card_formatter = card_formatter || begin
                                          o = Object.new
                                          o.extend(CardHelper)
                                          o
                                        end
    # ...
  end
end

Another option is to use the tap method. We tack it onto the end of the object creation. It will yield the object it was sent to, and we bind that to a variable. Within the block we do the module extension.

class HitPresenter
  def initialize(hand, new_card, card_formatter: nil)
    @hand           = hand
    @new_card       = new_card
    @card_formatter = card_formatter || Object.new.tap do |o|
                                          o.extend(CardHelper)
                                          o
                                        end
    # ...
  end
end

Because tap always returns the object it was sent to, we don't actually need to return the object anymore.

class HitPresenter
  def initialize(hand, new_card, card_formatter: nil)
    @hand           = hand
    @new_card       = new_card
    @card_formatter = card_formatter || Object.new.tap do |o|
                                          o.extend(CardHelper)
                                        end
    # ...
  end
end

Both tap and begin...end are solid options when we need to do some kind of object assembly and we need to treat it as a single expression. But in this particular case, where all we need to do is mix in a module, there's an even more concise option.

To understand this option, let's do a little experiment. First, we'll create a new plain object. Then we'll give it a unique identifying name, say, "Bob". That way we'll know when we're working with Bob the object.

Then we'll extend Bob with the Comparable module. The specific module we use here doesn't matter. What's the result of the extend message send? Why, it's Bob!

o = Object.new
def o.inspect(*)
  "Bob"
end
o                               # => Bob
o.extend(Comparable)            # => Bob

What we can see here is that Ruby's extend method returns the object it was sent to.

At this point, you can probably already see the application this has for our default collaborator object. We can get rid of our tap block, and just directly extend the new object inline.

The return value will still be the new object, so the instance variable is correctly assigned.

class HitPresenter
  def initialize(hand, new_card, card_formatter: nil)
    @hand           = hand
    @new_card       = new_card
    @card_formatter = card_formatter ||
      Object.new.extend(CardHelper)
  end
end

There is one more tweak I would really like to make to this code. however, I think we've seen enough for today. So we'll save it for a future episode. Happy hacking!

Responses