In Progress
Unit 1, Lesson 1
In Progress

Smart Collection

Video transcript & code

In most programming languages, there is a delineation made between "objects" and "data". The line is sharper in some languages than in others, but it nearly always exists in some form.

We often act as if this line exists in Ruby too. On the one hand, there are "objects": things like user objects, http connections, or stream parsers. And on the other hand, there's "plain old data". We could roughly describe the latter category as "things we might deserialize from a JSON stream", things like:

  • Numbers
  • Strings
  • Booleans
  • Arrays
  • and Hashes
42
"fnord"
true
[1, 2, 3]
{ price: 22.50 }

It's the latter two I'm interested in today. Treating arrays and hashes as "data" instead of "objects" has some interesting effects on our object designs.

Consider this collection of classes. What we have here is the beginnings of an object model for a role-playing game of some kind. We have game items, each of which has a name, a weight, and a value in the in-game currency. We have weapons, which are a specialized items which also have an attack value. Then we have characters, which have a name and an inventory of items.

We're using the fattr library to simplify declaring and initializing object attributes. We first saw the fattr gem back in episode #276. In this case, the inventory is simply a Ruby array.

require "fattr"

class GameObject
  def initialize(**attributes)
    attributes.each do |name, value|
      public_send name, value
    end
  end
end

class Item < GameObject
  fattr :name
  fattr :weight, default: 1.0
  fattr :value, default: 1
end

class Weapon < Item
  fattr :attack_value, default: 1
end

class Character < GameObject
  fattr :name
  fattr(:inventory) { [] }
end

Let's create a game character named Lucy. We'll give her a couple of game items, as well as a couple of weapons.

lucy = Character.new name: "Lucy"

lucy.inventory << Item.new(name: "A fresh salmon", weight: 2.0, value: 5)
lucy.inventory << Item.new(name: "A shrubbery", weight: 10.0, value: 3)
lucy.inventory << Weapon.new(name: "A Clown Hammer of Smiting",
                             weight: 15.0,
                             value: 20,
                             attack_value: 12)
lucy.inventory << Weapon.new(name: "The Plunger of Dalek",
                             weight: 8.0,
                             value: 100,
                             attack_value: 37)

As we flesh out the game design, we find the need for various methods to help us sort through and summarize a character's inventory. We end up adding a method for discovering the total weight of the character's inventory; a method to report the total resale value of everything in their inventory; a way to list just the weapons without any other kind of item, and another method for picking the strongest weapon a character currently holds.

require "./character2.rb"

LUCY.inventory_weight           # => 35.0
LUCY.inventory_total_value      # => 128
LUCY.weapons
# => [#<Weapon:0x007f8d8211df20
#      @attack_value=12,
#      @name="A Clown Hammer of Smiting",
#      @value=20,
#      @weight=15.0>,
#     #<Weapon:0x007f8d8211dc78
#      @attack_value=37,
#      @name="The Plunger of Dalek",
#      @value=100,
#      @weight=8.0>]
LUCY.strongest_weapon
# => #<Weapon:0x007f8d8211dc78
#     @attack_value=37,
#     @name="The Plunger of Dalek",
#     @value=100,
#     @weight=8.0>

We also add a method to summarize a character's inventory in a friendly user-readable way.

require "./character2.rb"

puts LUCY.inventory_summary

# >> You are carrying:
# >> A fresh salmon,
# >> A shrubbery,
# >> A Clown Hammer of Smiting,
# >> The Plunger of Dalek.
# >>
# >> Total weight is: 35.0 pounds.
# >> Total resale value is: 128 zorkmids.

Let's take a look at the new, expanded definition of Character. All of these new methods use Array and Enumerable methods to slice and dice the inventory collection in various ways. As a matter of fact, as we look over these methods, we start to catch a whiff of the feature envy code smell. It seems like all of these methods are more interested in attributes of the inventory collection, than in the Character object itself.

Here's another troubling observation: so far, we have 5 Character methods just for dealing with inventory. Chances are, we'll need more of these methods before we're done. And yet, we haven't even begun to address other responsibilities of a Character, such as intrinsic stats, moving around, examining the environment, dialog with other characters, and so on. Just how big is this class going to be when we start adding methods for all those other responsibilities?

require "./game"

class Character
  def inventory_summary
    "You are carrying: " +
      inventory.map(&:name).map{|s| s.prepend("\n")}.join(", ") +
      ".\n\nTotal weight is: " +
      inventory_weight.to_s +
      " pounds.\nTotal resale value is: " +
      inventory_total_value.to_s +
      " zorkmids."
  end

  def inventory_weight
    inventory.map(&:weight).reduce(:+)
  end

  def inventory_total_value
    inventory.map(&:value).reduce(:+)
  end

  def weapons
    inventory.grep(Weapon)
  end

  def strongest_weapon
    weapons.sort_by(&:attack_value).last
  end
end

Chances are, we arrived at this current breakdown of responsibilities because in our mind, we decided that "a Character has-many items". The trouble with an object model of this kind is that it overlooks an important truth. It shows a direct relationship between a character and its items, and leaves out the fact that the items collection is an object in its own right, with identity, state, and behavior.

Let's play around with the idea of pushing some of this responsibility onto the inventory collection. This easy to experiment with in Ruby. We can add an initializer to our Character class, being sure to pass along any arguments to the superclass. Then we can simply add some new singleton methods directly to the inventory array. For starters we'll just tack on weight and total_value methods.

Then we'll remove the corresponding methods in Character, and update other methods to reference the inventory versions.

require "./game"

class Character
  def initialize(*)
    super
    def inventory.weight
      map(&:weight).reduce(:+)
    end

    def inventory.total_value
      map(&:value).reduce(:+)
    end
  end

  def inventory_summary
    "You are carrying: " +
      inventory.map(&:name).map{|s| s.prepend("\n")}.join(", ") +
      ".\n\nTotal weight is: " +
      inventory.weight.to_s +
      " pounds.\nTotal resale value is: " +
      inventory.total_value.to_s +
      " zorkmids."
  end

  def weapons
    inventory.grep(Weapon)
  end

  def strongest_weapon
    weapons.sort_by(&:attack_value).last
  end
end

Now if we want to get the total weight or value of everything Lucy is carrying, we send the message to her inventory instead of to the character object.

require "./character3"
require "./setup"
LUCY.inventory.weight           # => 35.0
LUCY.inventory.total_value      # => 128

puts LUCY.inventory_summary

# >> You are carrying:
# >> A fresh salmon,
# >> A shrubbery,
# >> A Clown Hammer of Smiting,
# >> The Plunger of Dalek.
# >>
# >> Total weight is: 35.0 pounds.
# >> Total resale value is: 128 zorkmids.

We like the way this works out, so we decide to go ahead and formalize the arrangement. We create a new class for arrays of items. What to call it? Well, let's just call it ItemArray. We use DelegateClass, which we met back in episode #292, because we always try to prefer composition over inheritance.

We initialize each ItemArray with an internal vanilla Array.

Next, we proceed to move every last inventory-specific method into this class. Then we modify Character's inventory attribute to default to a new ItemArray instead of a vanilla array.

require "./game"
require "delegate"

class ItemArray < DelegateClass(Array)
  def initialize
    super([])
  end

  def weight
    map(&:weight).reduce(:+)
  end

  def total_value
    map(&:value).reduce(:+)
  end

  def weapons
    grep(Weapon)
  end

  def strongest_weapon
    weapons.sort_by(&:attack_value).last
  end

  def summary
    "You are carrying: " +
      map(&:name).map{|s| s.prepend("\n")}.join(", ") +
      ".\n\nTotal weight is: " +
      weight.to_s +
      " pounds.\nTotal resale value is: " +
      total_value.to_s +
      " zorkmids."
  end
end

class Character
  fattr(:inventory) { ItemArray.new }
end

In the end, Character is nearly empty again. All of our inventory-related methods have migrate to the inventory attribute, where they belong. And when we want to ask for a list of weapons of get some other inventory-specific information, we ask the object which has the responsibility of managing inventory.

require "./character4"
require "./setup"
LUCY.inventory.weight           # => 35.0
LUCY.inventory.total_value      # => 128
LUCY.inventory.weapons
# => [#<Weapon:0x007f4b9c61a2f8
#      @attack_value=12,
#      @name="A Clown Hammer of Smiting",
#      @value=20,
#      @weight=15.0>,
#     #<Weapon:0x007f4b9c61a028
#      @attack_value=37,
#      @name="The Plunger of Dalek",
#      @value=100,
#      @weight=8.0>]
LUCY.inventory.strongest_weapon
# => #<Weapon:0x007f4b9c61a028
#     @attack_value=37,
#     @name="The Plunger of Dalek",
#     @value=100,
#     @weight=8.0>

puts LUCY.inventory.summary

# >> You are carrying:
# >> A fresh salmon,
# >> A shrubbery,
# >> A Clown Hammer of Smiting,
# >> The Plunger of Dalek.
# >>
# >> Total weight is: 35.0 pounds.
# >> Total resale value is: 128 zorkmids.

So are we done? There's one more change I think we should make. We called this class ItemArray because that's what it is: a type of Array specialized to hold items. But by treating the array holding inventory items as a legitimate object in its own right, not just as a data structure, we've revealed a distinct role that was previously invisible. Now that we see the role of "inventory" for what it is, it makes sense to rename this class to match the role it was born to play.

require "./game"
require "delegate"

class Inventory < DelegateClass(Array)
  def initialize
    super([])
  end

  def weight
    map(&:weight).reduce(:+)
  end

  def total_value
    map(&:value).reduce(:+)
  end

  def weapons
    grep(Weapon)
  end

  def strongest_weapon
    weapons.sort_by(&:attack_value).last
  end

  def summary
    "You are carrying: " +
      map(&:name).map{|s| s.prepend("\n")}.join(", ") +
      ".\n\nTotal weight is: " +
      weight.to_s +
      " pounds.\nTotal resale value is: " +
      total_value.to_s +
      " zorkmids."
  end
end

class Character
  fattr(:inventory) { Inventory.new }
end

As we build up object models in which some objects "own" collections of other objects, there's always a temptation to treat those collections as just "dumb bags of objects". But collections aren't just data structures in Ruby. And if we can give them the dignity of "real", first-class objects in our model, it can help us to tease out previously hidden roles and clarify our design in the process. Happy hacking!

Responses