In Progress
Unit 1, Lesson 21
In Progress

Observed Attribute

Video transcript & code

Let's return to an example we used a couple episodes back. Once again we're going to talk about a role-playing adventurer and their bag of stuff. But this time, we're going to talk about a slightly different aspect of this scenario.

Here's our adventurer, Bob the Bold. Like many adventurers, he has an improbably large inventory of stuff he carries with him.

Item = Struct.new(:name, :weight)

class Character
  attr_reader :name
  attr_reader :inventory

  def initialize(name)
    @name = name
    @inventory = []
  end
end

bob = Character.new("Bob the Bold")
bob.inventory << Item.new("The Sort-of Sword of Sordov", 20.0)
bob.inventory << Item.new("Boiled Cabbage Armor", 30.0)
bob.inventory << Item.new("A Ham Sandwich", 1.0)

As we are merrily stuffing things into Bob's inventory, we suddenly realize that he is watching us with an annoyed look on his face. We ask him what the problem is, and he proceeds to clue us in. He says:

Forsooth, in yon un-rippl'd pool appear'd A vision fair: the goddess Demeter Caution and good counsel I took from her Her law armed me for dangers much affear'd.

Bob always talks like this. We ask him what his supposed divine patron told him, and this is what he recites:

"Guard thine wallet well, when dost converse With passing folk. Accept with noble grace Gifts given to they hands; but turn thy face From rogues who would lay hands upon thy purse."

Bob may have a roundabout way of saying it, but he has a point. Instead of giving bob items to do with as he sees fit, we're taking his bag and stuffing things into it for him. This isn't just rude; it's also problematic from the point of view of evolving our code.

Consider this. Items in this world have weight. At some point, Bob should be overburdened, and he should tell us so. But right now Bob has no way of knowing what changes we've made to his bag until after the fact.

A typical way to address this issue is to make Bob's inventory a completely private affair. Instead of allowing other objects to access it directly, we instead add a set of methods for adding and removing items.

Of course, then we have to go and change everywhere we add items to a character.

Item = Struct.new(:name, :weight)

class Character
  attr_reader :name

  def initialize(name)
    @name = name
    @inventory = []
  end

  def add_item(item)
    @inventory << item
  end

  def remove_item(item)
    @inventory.delete(item)
  end
end

bob = Character.new("Bob the Bold")
bob.add_item(Item.new("The Sort-of Sword of Sordov", 20.0))
bob.add_item(Item.new("Boiled Cabbage Armor", 30.0))
bob.add_item(Item.new("A Ham Sandwich", 1.0))

But at least now we can easily add a guard clause to the #add_item method, which checks that the added item won't overburden the character. This is the only place where an item can be added from outside the character, so it's a handy "choke point" at which to perform this check. Now when we try to give him item too many, Bob lets us know.

Item = Struct.new(:name, :weight)

class Character
  attr_reader :name

  def initialize(name)
    @name = name
    @inventory = []
  end

  def add_item(item)
    @inventory << item
    if @inventory.map(&:weight).reduce(0,:+) > 50
      puts "I am over-burdened!"
    end
  end

  def remove_item(item)
    @inventory.delete(item)
  end
end

bob = Character.new("Bob the Bold")
bob.add_item(Item.new("The Sort-of Sword of Sordov", 20.0))
bob.add_item(Item.new("Boiled Cabbage Armor", 30.0))
bob.add_item(Item.new("A Ham Sandwich", 1.0))

# >> I am over-burdened!

A while later, we start adding a magic system to this game. We decide to encapsulate a character's list of spells in much the same way that we encapsulated their inventory. As before we add methods for adding and removing.

Item = Struct.new(:name, :weight)

class Character
  attr_reader :name

  def initialize(name)
    @name = name
    @inventory = []
    @spells    = []
  end

  def add_item(item)
    @inventory << item
    if @inventory.map(&:weight).reduce(0,:+) > 50
      puts "I am over-burdened!"
    end
  end

  def remove_item(item)
    @inventory << item
  end

  def new_spell(spell)
    @spells << spell
  end

  def delete_spell(spell)
    @spells.delete(spell)
  end
end

Let's take a step back and look at where we stand. Every time we add a new "owned collection" to Character, we add a couple new methods for updating it. And really, this is kind of unrealistic, since we haven't even added any methods for listing or otherwise accessing spells or items. If this were real-world code, we'd probably be adding three to five or even more methods for every collection that a Character manages.

If we want to add or remove an item or a spell, we can't fall back on our ingrained knowledge of Ruby Arrays. We have to look up the Character API to remember exactly which method we need to use.

But that's not all. The inventory and spells collections were added at different points in time, and we weren't paying careful attention. As a result, the naming conventions aren't even consistent between the two collections. We "add" and "remove" items, whereas for spells we send #new_spell and #delete_spell.

bob.add_item(item)
bob.remove_item(item)

bob.new_spell(spell)
bob.delete_spell(spell)

To sum up: while keeping these collections well-encapsulated, we have made the Character interface cluttered and inconsistent.

What we'd really like to do is expose these attributes in a way that would enable us to interact with them like normal Ruby collections. But, we also want the Character object to be aware of any changes made to them. We need attributes that the Character can keep an eye on. Or to put it another, we need observable attributes.

Let's see if we can create an observable Array object.

We start, as we often do, with a class inheriting from a DelegateClass. Then we go through the Array documentation and come up with a list of all of the methods that might change, or mutate, the array. This turns out to be quite a long list.

Next we add a method called on_change. This method takes a block, and stows the block away for later use in a variable called @change_observer.

Now we do a little metaprogramming. We loop through our list of mutating method names. For each one, we dynamically generate a new method inside our wrapper class with the same name. First, it delegates to the original method, making use of a DelegateClass built-in behavior. We are careful to preserve the return value of the original method in a local variable.

Then once the method has executed, we notify the @change_observer that a change occurred by calling it. Finally, we return the original result.

One thing to notice in this simple example is that we aren't verifying that the collection has actually been modified before issuing this notification. It wouldn't be too hard to modify this code to compare the "before" and "after" states of the array, but I'll leave that as an exercise for you if you want to try it.

require "delegate"

class ObservableArray < DelegateClass(Array)
  MUTATING_METHODS = %i[
    << []= clear collect! compact! concat delete delete_at delete_if
    fill flatten! initialize_copy insert map! pop push reject! replace
    reverse! rotate! select! shuffle! slice! sort! sort_by! uniq! unshift
  ]

  def on_change(&change_observer)
    @change_observer = change_observer
  end

  MUTATING_METHODS.each do |method_name|
    define_method(method_name) do |*args, &block|
      result = super(*args, &block)
      @change_observer.call if @change_observer
      result
    end
  end
end

Now let's use this class in our Character class. We re-add accessor methods for inventory and spells. We wrap the @inventory and @spells arrays in ObservableArray objects. And we add an on_change handler to the @inventory. Inside the handler, we put the code for issuing a warning when the character is carrying too much weight.

Then we get rid of all the old, specialized collection update methods.

Now we are able to go back to using the old, familiar "shovel" operator for adding new items to Bob's inventory. But when we add one too many items, we immediately get a warning. Despite the fact that we are interacting directly with his inventory collection, Bob is able to keep a sharp eye on what is happening, and knows immediately when we've given him too much stuff.

require "./observable_array"
Item = Struct.new(:name, :weight)

class Character
  attr_reader :name, :inventory, :spells

  def initialize(name)
    @name = name
    @inventory = ObservableArray.new([])
    @inventory.on_change do
      if @inventory.map(&:weight).reduce(0,:+) > 50
        puts "I am over-burdened!"
      end
    end
    @spells    = ObservableArray.new([])
  end
end
bob = Character.new("Bob the Bold")
bob.inventory << Item.new("The Sort-of Sword of Sordov", 20.0)
bob.inventory << Item.new("Boiled Cabbage Armor", 30.0)
bob.inventory << Item.new("A Ham Sandwich", 1.0)

# >> I am over-burdened!

If you remember from a couple episodes ago, we took an ordinary collection and gave it some smarts. What we've done today is really just a different angle on the same type of transformation. And the same overall lesson applies:

In an object-oriented program, there is no reason to treat collections owned by an object as just "dumb bags of objects". And this goes for any attribute. Attributes of objects can be true collaborators, not just possessions. Like enchanted items in a fantasy role-playing game, they can have their own intelligence, and they can notify their owning object when noteworthy events happen to them.

And that's all for today. Happy hacking!

Responses