Inheritance: Derivative Songwriting

Kevin Murphy - Mar 9 '21 - - Dev Community

Setting the Stage

Inheritance sets up a relationship or a taxonomy between classes to allow for code reuse. It is both a commonly reached for and commonly derided tool which has its place, but must be wielded with care. We'll use inheritance to write new songs for our concert setlist, an example which comes from my RubyConf 2020 talk about Ruby's Coverage module.

Song Structure

When you create a song, it needs a name (or at least a working title) and a series of notes. The notes may change over time, and the title may be refined, but for our purposes, we're not calling it a song until there's a bit more than an empty page.

class Song
  def initialize(notes: [], name:)
    @notes = notes
    @name = name
  end
end
Enter fullscreen mode Exit fullscreen mode

When you're writing songs for a band or yourself, you need to be able to play the song. In this example, our song is written for a band that has a known number of instruments.

class Song
  def play
    @notes.map do |note|
      composition = []

      composition << Thread.new { @guitar.play(note) }
      composition << Thread.new { @vocal.sing(note) }
      composition << Thread.new { @drum.hit(note) }
      composition << Thread.new { @keyboardist.program(note) }

      composition.map(&:value)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

For every note (representing a beat or measure of the song) each member of the band needs to play their part simultaneously. All of these instruments playing together note for note comprise the song.

On Repeat

A touring band is going to play the same song many times night after night. For each concert on the tour, the band needs to construct a setlist of all the songs that they'll play that night, and in what order.

class Setlist
  def add_song(song)
    @songs << song
  end
end
Enter fullscreen mode Exit fullscreen mode

Transcribing all the notes for each song over and over again for every concert would be tedious and unnecessary. To save all that work, each song that could appear in the band's setlist is catalogued as a separate class.

class TheLineBeginsToBlur
  def initialize
    @name = "The Line Begins To Blur"
    @notes = verse_1 + chorus + verse_2 + chorus + solo + outro
  end
end
Enter fullscreen mode Exit fullscreen mode

We don't need to accept any arguments for the name of the song or the notes because it's already a fully-formed song. We're not going to change the arrangement in the middle of the tour. However, we do need to be able to play the song. As such, let's copy and paste the play method as something we can do for our specific song here.

class TheLineBeginsToBlur
  def play
    @notes.map do |note|
      composition = []

      composition << Thread.new { @guitar.play(note) }
      composition << Thread.new { @vocal.sing(note) }
      composition << Thread.new { @drum.hit(note) }
      composition << Thread.new { @keyboardist.program(note) }

      composition.map(&:value)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

This is great because we now have a stable of songs we can pull from every night when creating our setlist; however, rewriting the play method in each song is not great. If the implementation of play needs to change, we need to propagate that change across every song. If we forget to add a play method to one of our songs, everyone is going to look foolish when the band is staring blankly at each other, unsure of what to do.

Composing a Song

Taking a note from our earlier post on composition and delegation, we can build a class that's solely responsible for playing the song.

class SongPerformer
  def initialize(notes)
    @notes = notes
  end

  def play
    @notes.map do |note|
      composition = []

      composition << Thread.new { @guitar.play(note) }
      composition << Thread.new { @vocal.sing(note) }
      composition << Thread.new { @drum.hit(note) }
      composition << Thread.new { @keyboardist.program(note) }

      composition.map(&:value)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

All of our songs can then use that performer and delegate the responsibility of playing to it.

class TheLineBeginsToBlur
  def play
    SongPerformer.new(@notes).play
  end
end
Enter fullscreen mode Exit fullscreen mode

We have now isolated the responsibility of playing the song to one place. If we need to change the way in which songs are played in totality, we can do so in the SongPerformer and that change will be reflected in all of our songs. We can even dependency inject the performer class into the song, allowing us to set up different arrangements of the same song. Even with those benefits, we do still have to remember to implement a play method that calls our SongPerformer.

There is another option we can explore: inheritance.

Playing the Hits

We can leverage our existing, generic, Song class and have all of our classes about specific songs inherit the behavior of the Song class.

By doing this, our different songs don't need to implement the play method. They'll get this behavior from Song.

class TheLineBeginsToBlur < Song
  def initialize
    super(
      name: "The Line Begins To Blur",
      notes: verse_1 + chorus + verse_2 + chorus + solo + outro,
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

We denote that we're inheriting from the Song class with < Song. Song is our "base class". In our constructor, we then call Song's constructor with super, passing in the title of the song and the notes that should be played with the song. TheLineBeginsToBlur has no reference to play in its class definition. It still responds to it because Song does, and we're inheriting all of Songs behavior.

When we discussed composition, we mentioned Sandi Metz's Practical Object-Oriented Design In Ruby for her recommendation to use composition when modeling a has a relationship. In that same section, she recommends using inheritance when you encounter an is a relationship. In our case, a particular song is a specialized version of our Song class.

Inheritance is a common design choice in Object-Oriented languages. Specifically in Ruby, if you've worked with Rails, then you've likely used inheritance all over the place. All of your models inherit from ApplicationRecord (ultimately inheriting from ActiveRecord::Base) and all of your controllers inherit from ApplicationController (ultimately inheriting from ActionController::Base).

A Measured Approach

Inheritance does come with some drawbacks. Enough that it's commonly recommended to avoid. You may have encountered the phrase, "prefer composition over inheritance" before. Let's discuss why that is.

Transparency

Inheritance makes it more difficult to know what behaviors a particular class has. None of our song classes that inherit from Song have a play method in their class definition. However, because they all inherit from Song, they all respond to play. Determining that is not obvious based on a quick reading of the class.

Limitations of Base Class

Any inheriting classes shouldn't necessarily do things differently than how the base class does. Of course, you can do this, but it should be used very judiciously. We could redefine the play method in a particular class - sharing
the rest of the behavior and redefining play for our one-off special exception. The issue is that these exceptions start to pile up, we end up chipping away at the commonality, and the shared understanding of what it means to inherit from the base class gets eroded with each change that seems small
on its own.

For our songs, if we suddenly need to write a song for a string quartet, our Song class isn't helpful. It assumes a guitar, vocalist, drummer, and keyboardist. While particularly in Ruby we have an out by being able to redefine any method definition, from a design perspective, we should be willing to accept the limitations that inheritance places on us within the scope of our domain. If those limitations cannot be respected, then consider another organizational structure, like composition.

Future Inflexibility

It's often impossible to know how your system will evolve over time. Inheritance can lock you in to a very specific representation of how your system should be modeled, and the assumptions that went into developing that structure may not hold true as features are needed to be added and the needs that the application must serve grow.

This rigidity over time ends up getting pushed and strained enough that maintaining inheritance structures becomes difficult. In my opinion, it is this long-view perspective that becomes the principal reason why inheritance is sparsely recommended by practitioners. It can work great as long as you have perfect knowledge about both the current and future state of your system. The reality is, it's extremely rare to be in that situation.

In this example, our application is modeling a concert tour for one band, the members and makeup of which shouldn't change throughout the course of the tour. We've made the bet that even if the guitarist we start the tour with is replaced, there will still be a guitarist, and we will not have picked up a french horn player along the way to play two of the songs. From a practicality standpoint, it's reasonable to be tied to this rigid structure of how to play each of these songs on stage in the context of this application. However, from the onset, we've already identified one way in which this structure may come back to haunt us.

Rock On

Inheritance is often reached for as a quick and easy way to achieve code reuse. It does just that; however, it imposes limitations and constraints on your system that can make it more difficult or painful to change over time. Those limitations may be intentional and required guardrails - but often times, they end up being factors that cause pain, tears, multiple "code spikes", and "technical debt sprints" to allow for needed future functionality. Inheritance shouldn't be avoided wholesale based on this, but it should be carefully and judiciously applied in your systems.

Our next post will move a little further from theory and explore how to build an interface to Sonic Pi, so that these principles can work together to actually make sounds on your computer.

This post originally published on The Gnar Company blog.

Learn more about how The Gnar builds Ruby on Rails applications.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .