Best Practices for Working With Classes

Best practices for using classes with object decomposition.

Scroll down...

Content

Resources

Comments

In this lesson we'll cover a grab-bag of additional useful topics and best practices for working with classes in the context of object-oriented programming. This includes object decomposition, which is the practice of breaking down a problem into its component objects.

Object Decomposition

We've previously talked about breaking your Ruby scripts down into individual methods to reduce overall complexity. The same applies with objects. If you have a series of objects that are similar but not quite the same, it probably makes sense to break them apart into separate classes and selectively use inheritance to DRY up any shared attributes.

For instance, let's assume we have a Warrior and a Farmer. They are both Vikings but perform different tasks and have different behaviors.

class Viking
    attr_accessor :strength
    attr_reader :health, :name

    def initialize(name,health,strength)
        @name = name
        @health = health
        @strength = strength
    end

    def travel
        # Move based on strength
    end
end

class Warrior < Viking
    @@strength = 15
    @@health = 150

    def initialize(name)
        super(name, @@health, @@strength)
    end

    def attack(recipient)
        # code to attack
    end

    def sing
        # sing warrior songs
    end

end

class Farmer < Viking
    @@strength = 12
    @@health = 110

    def initialize(name)
        super(name, @@health, @@strength)
    end

    def farm(field)
        # code to farm the field
    end

    def sing
        # sing farming songs
    end
end

In the example above, both Warriors and Farmers share some common behaviors but they also each have their own initialization. Each adds a few methods the other doesn't have and some methods, e.g. sing, are implemented differently.

This is important because it allows you to treat the two classes similarly but results in their intentionally unique behavior:

> stig = Farmer.new("Stig")
#=> #<Farmer:0x007fc20b9efc08 @name="Stig", @health=110, @strength=12> 
> kate = Warrior.new("Kate")
#=> #<Warrior:0x007fc20b9d41d8 @name="Kate", @health=150, @strength=15> 
> [stig,kate].each{|viking| viking.sing}
#Farming, farming, farming, there's nothing sweeter than the summer sun amidst your growing crops!
#Raiding, raiding, raiding, there's nothing sweeter than the summer sun warming the blade of your axe!

Appropriate Granularity

We know classes should be the objects, the "nouns", of your project but how specific should you get when breaking them apart?

In this case, remember the "Single Responsibility Principle" (one of the SOLID Principles). If it's doing more than one thing, it should be broken into a sub-class.

For the example above, if we'd tried to model both Farmers and Warriors using the Viking class, it would have gotten awfully crowded. We would have had a farm method being used by Warriors who didn't care and an attack method available to farmers who didn't need it. We also would have had a bear of a time figuring out how to get them to sing differently. That kind of split focus should scream "break me into a sub-class!".

Use Objects Not Types... Duck Typing!

A common use case, particularly in gaming, is to have a "player 2" who can be either a human or a computer. If you do things the inefficient way, you might constantly find yourself saying "if Player 2 is a human, then do this, otherwise do that."

For instance:

if num_players == 2
  guess = generate_random_computer_guess
else
  guess = get_input
end

This is a waste! If you are using objects, you shouldn't actually care whether player 2 is a human or a computer. Imagine we have two classes called HumanPlayer and ComputerPlayer and each of those classes has a method on it called generate_guess. On your HumanPlayer class, it probably asks you for a guess on the command line. On your ComputerPlayer class, it probably uses some clever code to randomly generate a guess.

The magic comes when it's time to actually tell player 2 to make a guess. Now you don't care whether player 2 is human or computer... you simply tell it to make a guess!

# Let's back up and set our @player2 to the correct 
#   instance type
def initialize( num_players )
  @player1 = HumanPlayer.new
  if num_players == 2
    @player2 = HumanPlayer.new
  else
    @player2 = ComputerPlayer.new
  end
end
...
# Now we can take advantage of this by eliminating
#   annoying if/else logic later in our code
some_method
  ...
  guess = @player2.generate_guess
  ...
end

This is one of the best parts of object orientation! By simply setting up your Player 2 properly in the beginning, you can now tell it to do anything you want regardless of whether it is actually a Human or a Computer. Cool!

Whenever you find yourself spending lots of time creating conditional logic based on what kind of thing an object is supposed to be... just use object orientation to actually make it an instance of that class.

This is a form of "Duck Typing", so-called because "if it quacks like a duck, it must be a duck." More explicitly, you treat player 2 as if it should always have a generate_guess method, regardless of what kind of class instance it really is.

Narrow Interfaces

Your baseline assumption should be that, if you make a method or property public, someone is going to use it (and abuse it). Your goal should be to throttle the public-facing interface of your class (all public methods/variables) as tightly closed as possible.

Here are a few tips for doing so.

Enforce Factory Methods

A "Factory Method" is just a helper method which creates a specific type of instance of a class. For instance, assume you have a Viking class which has a type attribute that can be either :farmer or :warrior. Let's assume you pass that into your initialize method as an option:

class Viking
    def initialize( type )
        @type = type
    end
end

Then you would initialize your class as so:

my_farmer = Viking.new( :farmer )
my_warrior = Viking.new( :warrior )

Now anyone who wants to use your class can pass it any type attribute they want, whether it is valid or not. Yes, if they pass an invalid attribute something will probably break but good design means they shouldn't even be able to pass a bad attribute.

my_cook = Viking.new( :cook ) << oops!

To fix this, build a class method which calls your initialize function itself instead:

class Viking
    def initialize( type )
        @type = type
    end

    def self.build_farmer
        new( :farmer )  # `self` is the class here
    end

    def self.build_warrior
        new( :warrior )
    end
end

Now the user can build their specific type of Viking using just the helper method:

my_warrior = Viking.build_warrior
my_farmer = Viking.build_farmer

This is much cleaner but it still leaves your initialize function wide open for future abuse. A tricky thing you can do is to make the initialize function private using a special method called private_class_method which makes a class method private. You call it after declaring your initialize function:

class Viking
    def initialize( type )
        @type = type
    end

    # Note that the actual method is `new` not `initialize`
    # This is just like a `private` declaration but
    # for class methods instead of instance methods
    private_class_method :new

    def self.build_farmer
        new( :farmer )
    end

    def self.build_warrior
        new( :warrior )
    end
end

Now, anyone trying to build a new Viking using Viking.new would get an error because new is only available from within the class. Our Viking's interface is now limited to ONLY build_farmer and build_warrior.

Send Messages Instead of Accessing Attributes

You probably use instance variables by calling them directly as attributes of your class using the @ symbol:

class Viking

    def initialize( type )
        @type = type
    end

    ...

    def what_type_am_i
        "I am a #{ @type.to_s }"
    end

end

Anyone accessing this variable from outside your instance will need to do so via a getter method like type. If something about the guts of how your type needs to be changed in the future, for instance to be a String instead of a Symbol, it is easy to change your getter.

But if your code internally accesses the instance variable directly, you would have to update every single instance when @type is used if its implementation somehow changed. Doesn't that seem a bit odd?

The solution is a lot easier to change your getter than it is to go in and replace every instance of @type in your code.

So use your getter internally as well!

class Viking

    def initialize( type )
        @type = type
    end

    ...

    def what_type_am_i
        # Note it's `type` not `@type`
        "I am a #{ type }"
    end

    # our reader explicitly written,
    # much easier to update here if things change
    def type
        @type.to_s
    end

end

Of course, if @type is really meant to be private, that's no problem either! Just make your getter private:

class Viking

    ...

    def what_type_am_i
        # Note it's `type` not `@type`
        "I am a #{ type }"
    end

    private

    # no issue here with the reader being private
    def type
        @type.to_s
    end

end

Wrapping Up

The best way to absorb best practices is to see them in use. You can find a lot of good conference talks aimed at advanced beginners which get into things like proper use of objects, object-oriented design and modularity. These are often provided by developers working in consultancies who have a high turnover of code and a strong culture of transferring these best practices. Play with the Internet and dig them up!



Sign up to track your progress for free

There are ( ) additional resources for this lesson. Check them out!

Sorry, comments aren't active just yet!