Photo by Glenn Carstens-Peters on Unsplash
Strategically Thinking Through Coding Exercises
Recognizing the untold stories behind the clean code we admire
Coding can be intimidating. If you do it on your own, it can be overwhelming! Often budding software developers see code produced by others and immediately feel inadequate. I'll never be able to write code like that! is a typical lamentation. You've likely experienced moments like this yourself. Next time this happens, keep in mind that what you see doesn't tell the story of how the author of that code got there. It only shares the final product. In this article, I'm going to share the full story of how I approached, worked through, and solved the SpaceAge exercise in the Ruby track on Exercism.org.
Before beginning, I'd like to point something out. I am a full-time professional software engineer. In the context of Exercism's Ruby track, The SpaceAge problem I work on here is labeled Easy.
I didn't choose to work through this exercise to produce this article alone. I decided to work through it because I want to improve writing code with Object-Oriented Programming design principles. By deliberately decreasing the cognitive challenge of technical problem solving, I increased my bandwidth for critically thinking about software design decisions. Just because we reach certain levels of experience and mastery doesn't mean that immense value can't still be found in the "easy" exercises.
With that idea clarified, let's get started.
Exercise Instructions
Given an age in seconds, calculate how old someone would be on:
Mercury: orbital period 0.2408467 Earth years
Venus: orbital period 0.61519726 Earth years
Earth: orbital period 1.0 Earth years, 365.25 Earth days, or 31557600 seconds
Mars: orbital period 1.8808158 Earth years
Jupiter: orbital period 11.862615 Earth years
Saturn: orbital period 29.447498 Earth years
Uranus: orbital period 84.016846 Earth years
Neptune: orbital period 164.79132 Earth years
So if you were told someone was 1,000,000,000 seconds old, you should be able to say that they're 31.69 Earth-years old.
Below is a sample of the tests all solutions to the SpaceAge problem will be checked against (note: in the actual test file on Exercism, there is a test for each planet):
class SpaceAgeTest < Minitest::Test
# assert_in_delta will pass if the difference
# between the values being compared is less
# than the allowed delta
DELTA = 0.01
def test_age_on_earth
age = SpaceAge.new(1_000_000_000)
assert_in_delta 31.69, age.on_earth, DELTA
end
def test_age_on_mercury
age = SpaceAge.new(2_134_835_688)
assert_in_delta 280.88, age.on_mercury, DELTA
end
def test_age_on_venus
age = SpaceAge.new(189_839_836)
assert_in_delta 9.78, age.on_venus, DELTA
end
...
end
Step 1: Clarify the problem and expected behavior of a valid solution
I've learned over time that one of the most valuable steps in technical problem solving is to take the time before writing a single line of code to make sure I understand the problem and what is expected of my solution. After reading the instructions, my understanding is:
I will be given a value representing age in seconds. I must write a program that can take in that value and, given a specific planet and orbital period, calculate the equivalent age of a being on that planet on earth using earth years as the unit of measurement. Return that value represented as a Float with the fractional part (or "mantissa") to the hundredths-place.
Step 2: Write down the steps I think I will need to take to solve the problem
Confident that I understand what I'm dealing with and what a good solution should look like and do, I still refrain from writing any actual code. Instead, I use human language to write down the steps I think I'll need to take to produce a solution. The goal here is not clairvoyance. My focus is on mapping out my thoughts. For the SpaceAge problem, I anticipate needing to do the following:
# Create a class called SpaceAge
# Allow the SpaceAge class to take in a single argument of type Integer
# Divide the argument by the number of seconds in 1 earth year, and assign the value to a variable
# Create a way to multiply the orbital period of a given planet with the value assigned to the variable to get the product of the two in earth years
# Round the product to the nearest hundredths-place and return
Step 3: Translate the human-readable steps into working code
As with a generic instructional manual, I can now start assembling a solution programmatically by translating each step of the instruction manual I created above one at a time.
Create a class called SpaceAge
# Create a class called SpaceAge
class SpaceAge
end
# Allow the SpaceAge class to take in a single argument of type Integer
class SpaceAge
attr_reader :age_in_seconds
def initialize(age_in_seconds)
@age_in_seconds = age_in_seconds
end
end
# Divide the argument by the number of seconds in 1 earth year, and assign the value to a variable
class SpaceAge
attr_reader :age_in_seconds
def initialize(age_in_seconds)
@age_in_seconds = age_in_seconds
end
def seconds_in_earth_year
age_in_seconds / 60 / 60 / 24 / 365.25
end
end
# Create a way to multiply the orbital period of a given planet with the value assigned to the variable to get the product of the two in earth years
class SpaceAge
attr_reader :age_in_seconds
def initialize(age_in_seconds)
@age_in_seconds = age_in_seconds
end
def seconds_in_earth_year
age_in_seconds / 60 / 60 / 24 / 365.25
end
def on_earth
earth_years = seconds_in_earth_year / 1.0
end
def on_mercury
earth_years = seconds_in_earth_year / 0.2408467
end
def on_venus
earth_years = seconds_in_earth_year / 0.61519726
end
def on_mars
earth_years = seconds_in_earth_year / 1.8808158
end
def on_jupiter
earth_years = seconds_in_earth_year / 11.862615
end
def on_saturn
earth_years = seconds_in_earth_year / 29.447498
end
def on_uranus
earth_years = seconds_in_earth_year / 84.016846
end
def on_neptune
earth_years = seconds_in_earth_year / 164.79132
end
end
# Round the product to the nearest hundredths-place and return
class SpaceAge
attr_reader :age_in_seconds
def initialize(age_in_seconds)
@age_in_seconds = age_in_seconds
end
def seconds_in_earth_year
age_in_seconds / 60 / 60 / 24 / 365.25
end
def on_earth
earth_years = seconds_in_earth_year / 1.0
earth_years.round(2)
end
def on_mercury
earth_years = seconds_in_earth_year / 0.2408467
earth_years.round(2)
end
def on_venus
earth_years = seconds_in_earth_year / 0.61519726
earth_years.round(2)
end
def on_mars
earth_years = seconds_in_earth_year / 1.8808158
earth_years.round(2)
end
def on_jupiter
earth_years = seconds_in_earth_year / 11.862615
earth_years.round(2)
end
def on_saturn
earth_years = seconds_in_earth_year / 29.447498
earth_years.round(2)
end
def on_uranus
earth_years = seconds_in_earth_year / 84.016846
earth_years.round(2)
end
def on_neptune
earth_years = seconds_in_earth_year / 164.79132
earth_years.round(2)
end
end
Running this code against the provided tests shows that this solution works! If our main goal for this exercise is to solve the problem, I could be done. But as I pointed out at the beginning of this article, I chose an easy exercise because I want to "get better at writing code with Object-Oriented Programming design principles in mind." At this point, it's time to do some refactoring!
Step 4: Refactor working code into clean code
Looking over my working solution, I notice that I repeat myself a lot. Each of my planet methods shares a common naming pattern. Each of them divides its unique orbital period value by the seconds_in_earth_year
instance method, assigning the quotient to the same earth_years
variable. And each of them calls the .round
method on the earth_years
variable and passes in the same integer as an argument.
It's pretty evident that my working solution violates the DRY principle of software. DRY stands for don't repeat yourself and aims to reduce the repetition of patterns and code duplication in favor of abstractions and avoid redundancy.
The first repeated pattern I notice is using the seconds_in_earth_year
instance method for division in each on_*
planetary method. Looking back at the seconds_in_earth_year
method definition, I see that the value being returned from converting the age_in_seconds
into equivalent earth_years
will never change for a given instance of the SpaceAge class. The only dynamic part of this method is the age_in_seconds
value passed to the SpaceAge
class through the .new
method when an instance of this class is created.
This sounds like an excellent opportunity to move the seconds_in_earth_year
method into the initialize
method so that the value assigned to the earth_years
variable is calculated whenever I create a new SpaceAge instance. In fact, since the only place I reference the @age_in_seconds
instance variable is when I call the reader method of the same name in the seconds_in_earth_year
method, by moving that method into the initialize
method, I can remove the explicit assignment of that parameter to an instance variable entirely in favor of passing it directly to the seconds_in_earth
method itself.
class SpaceAge
attr_reader :earth_years
def initialize(age_in_seconds)
@earth_years = seconds_in_earth_year(age_in_seconds)
end
def seconds_in_earth_year(seconds)
seconds /60 / 60 / 24 / 365.25
end
def on_earth
(earth_years / 1.0).round(2)
end
def on_mercury
(earth_years / 0.2408467).round(2)
end
def on_venus
(earth_years / 0.61519726).round(2)
end
def on_mars
(earth_years / 1.8808158).round(2)
end
def on_jupiter
(earth_years / 11.862615).round(2)
end
def on_saturn
(earth_years / 29.447498).round(2)
end
def on_uranus
(earth_years / 84.016846).round(2)
end
def on_neptune
(earth_years / 164.79132).round(2)
end
end
That's looking cleaner. But it's still not quite DRY.
Though not identical, the way I've named the def on_*
planetary method definitions is a repeating pattern. From a serpentining Google search, I discovered a strategy I was not familiar with which is appropriate for my use case here: Ruby's define_method
method.
From the Ruby docs, I learned that define_method
can be used to define an instance method on the receiver, which in my case is the SpaceAge class.
Knowing this new tool is available to me, I had the idea of creating a hash that maps each planet name as a key that points to the planet's orbital value.
Perhaps I could then iterate through this hash and, by using string interpolation, dynamically create the on_*
instance methods for each planet without unnecessarily repeating any patterns.
Check it out!
class SpaceAge
attr_reader :earth_years
def initialize(age_in_seconds)
@earth_years = seconds_in_earth_year(age_in_seconds)
end
def seconds_in_earth_year(seconds)
seconds / 60 / 60 / 24 / 365.25
end
PLANETARY_HASH = {
"earth": 1.0,
"mercury": 0.2408467,
"venus": 0.61519726,
"mars": 1.8808158,
"jupiter": 11.862615,
"saturn": 29.447498,
"uranus": 84.016846,
"neptune": 164.79132
}
PLANETARY_HASH.each do |planet, orbital_period|
define_method("on_#{planet}") do
(earth_years / orbital_period).round(2)
end
end
end
To be honest, I wasn't certain this would work (this was my first time experimenting with the define_method
method). Imagine my pleasant surprise when all of the exercise tests passed!
Conclusion
Hopefully, you now see that the code we find ourselves impressed by has such an effect on us as a result of an inelegant story of iteration, experimentation, and discovery. As with every great story, however, there are archetypes to be followed. My archetype includes:
- Clarify the problem and expected behavior of a valid solution
- Write down the steps I think I will need to take to solve the problem
- Translate the human-readable steps into working code, and
- Refactor working code into clean code
It is one of many. Perhaps you have your own! My archetype led me to a valid solution I feel satisfied with. But it may not be the best solution possible. That's something I love about coding. As I continue learning, studying, and deliberately practicing various techniques and principles of software design, what once seemed amazing can later seem basic. What once seemed impossible can later become common practice. Remember that the next time someone else's code shakes your own confidence. Rather than viewing it as a reflection of your relative inadequacy, acknowledge it for what it is, the potential that awaits you in the not-so-distant future.