From Conditions to Polymorphism

Motivation

The purpose of this article is to explore the ways we can simplify code using polymorphism and other language constructs.

When solving problems using code, different execution flows are usually defined on branches according to one or more conditions that are evaluated at runtime.

Most of the time this approach is good enough for small utilities and programs that are not expected to be extended.

However, in relatively large codebases with changing requirements it is found that incorporating new functionalities into existing code gets harder with its size.

The main reason for this phenomemon in software development is increased coupling. As the codebase grows, inserting new changes involves modifications on more and more locations. This involves knowledge by the programmer on where to insert code, who has to manage to maintain an updated mental map on where things are being done.

This approach works until the cognitive limits of the average programmer are surpassed by the raw volume and complexity of the code generated by 3 to 9 engineers per team.

One of the promises of object oriented programming is to handle this complexity using techniques such as inheritance, polymorphism, encapsulation and others.

On this article, we will consider this complexity to simply reduce the coupling between subsystems in a codebase using polymorphism.

Other practical reasons to abstract away if/else and switch statements from the code is to delegate the verification of the code to the compiler itself and minimize the number of inserted bugs.

Terniary operator

The terniary operator is a functionality provided by many programming languages to express that we want to return two different values depending on the result of a boolean evaluation.

The advantage of this operator is that it forces the programmer to assure that a variable will have a value assigned and avoiding the use of NULL values, considered an antipattern.

This can be very useful when working with optional variables, which may contain a value or not depending on a previous process such as the decoding of a message.

/* Terniary operator in C++
*   condition ? expression1 : expression2;
*/
std::optional<int> opt_value;
// ...
int terniary1 = opt_value.has_value() ? opt_value.value() : 0;

// Equivalent Conditional code.
int conditional;
if (opt_value.has_value())
  conditional = opt_value.has_value();
else
  conditional = 0;

Strategy and factory pattern

As with most things in life, things are better understood when reduced to the point in which a 5-year old can understand, which is why we are going to use animals on the following example.

/*
  This function solves the problem of mapping a range of values to a specific
  string, on this case, representing the sound of an animal.
*/
void conditionalAnimalTalk(int input) {
  if (input <= 1) {
    std::cout << "Meow!" << std::endl;
  }
  else if (input > 1 && input < 3) {
    std::cout << "Wow!" << std::endl;
  }
  else if (input > 3 && input < 6) {
    std::cout << "Muuu!" << std::endl;
  }
  else {
    std::cout << "Hello world!" << std::endl;
  }
}

This code does one thing and does it well. But if we want to add new ranges for new animals, we will need to modify every if/else in the code to incorporate this new condition.

Eventually, the project becomes unmaintainable and must be completely rewritten.

To avoid this, the code can be abstracted away to a map such as:

input -> speech

To obtain speech we need to speak(), and something that speaks, is a Speaker.

We have different kinds of things that can speak(), and this becomes the interface to implement.

Each implementation of this interface each kind of Speaker then becomes a different way of executing the task of speak(). We have different strategies to solve a problem.

In object oriented programming there is a pattern that emerges called Strategy that we can use to structure the code of this problem.

However, this is not enough to solve the problem. We still need some code to determine what kind of Speaker we are dealing with, which will have the responsibility of generating the adequate implementation and returning it to some user code.

This is a creational kind of problem of determining what kind of Speaker to create, we need a SpeakerFactory.

A user code that wants to solve the problem of given an input generate a particular kind of speech, will now need to do the following.

// The Speaker factory will create the right type of speaker.
SpeakerFactory factory;
auto speaker_ptr = factory.createSpeakerFrom(input);

// When the Speaker speak()'s, it will return the right type of output.
std::string talk = speaker_ptr->speak();
std::cout << talk << std::endl;

UML View

Observations about maintenance

To add a new kind of Speaker we need to do three things:

  1. Create a new class implementing the new behaviour or strategy for the interface.
  2. Extend the code located in the speaker factory with the condition that determines when to use the new strategy.
  3. Write a positive and a negative test case.

Within the package speakers, there are only two points of coupling:

  • The Speaker interface.
  • The SpeakerFactory class, which contains the creation rules for each of the descendants.

User code does have knowledge of what kind of descendant it is executing. This can have advantages and disadvantages. On one hand, the code is encapsulated and in the best of cases simply works. On the other, the system is less transparent about how the system works, which might make debugging more difficult.

In any case, the user code can be created in terms of any kind of Speaker. Making the codebase less coupled and centralizing one aspect of the problem to solve within a single module or framework.

Result

You can find the full example in C++ here: https://github.com/jairomer/StrategyAndFactoryPatternInC-

main.cpp

#include <iostream>
#include "Animal.hpp"

/* With conditionals. 
 * 
 * Adding a new animal involves mofifying all conditionals.
 *
 * */
void conditionalAnimalTalk(int input) {
  if (input <= 1) {
    std::cout << "Meow!" << std::endl;
  }
  else if (input > 1 && input < 3) {
    std::cout << "Wow!" << std::endl;
  }
  else if (input > 3 && input < 6) {
    std::cout << "Muuu!" << std::endl;
  }
  else {
    std::cout << "Hello world!" << std::endl;
  }
}

/* With polymorphism. */
void polymorphicAnimalTalk(int input) {
  SpeakerFactory factory;
  auto speaker_ptr = factory.createSpeakerFrom(input);
  std::string talk = speaker_ptr->speak();
  std::cout << talk << std::endl;
}

int main() {

  conditionalAnimalTalk(0);
  polymorphicAnimalTalk(0);

  conditionalAnimalTalk(2);
  polymorphicAnimalTalk(2);

  conditionalAnimalTalk(4);
  polymorphicAnimalTalk(4);

  conditionalAnimalTalk(10);
  polymorphicAnimalTalk(10);

}

Animal.hpp

#ifndef __ANIMAL_HPP__
#define __ANIMAL_HPP__

#include <string>
#include <memory>

/* Polymorophic Speaker
 *
 * How to add a new kind of Speaker:
 * 1. Define new derivative class implementing interface "Speaker".
 * 2. Delegate conditional creation logic to SpeakerFactory::createSpeakerFrom(int)"
 */

class Speaker {
  public:
  // We need to define a virtual function because we want the
  // concrete type of this speaker to be determined at runtime.
  virtual std::string speak() const = 0;
};

/* Behaviour is implemented on its own subclass. */
class Cat : public Speaker {
  public:
    Cat() = default;
    std::string speak() const;
};

class Dog : public Speaker {
  public:
    Dog() = default;
    std::string speak() const;
};

class Cow : public Speaker {
  public:
    Cow() = default;
    std::string speak() const;
};

class Human : public Speaker {
  public:
    Human() = default;
    std::string speak() const;
};

class SpeakerFactory {
  public:
    std::shared_ptr<Speaker> createSpeakerFrom(int i);
};

Animal.cpp

#include "Animal.hpp"
#include <memory>

std::string Cat::speak() const {
  return std::string{"Meow!"};
}

std::string Dog::speak() const {
  return std::string{"Wow!"};
}

std::string Cow::speak() const {
  return std::string{"Muuu!"};
}

std::string Human::speak() const {
  return std::string{"Hello world!"};
}

/* Centralizes conditional logic inside a single method. */
std::shared_ptr<Speaker> SpeakerFactory::createSpeakerFrom(int input) {
  if (input <= 1) {
    return std::make_shared<Cat>();
  }
  else if (input > 1 && input < 3) {
    return std::make_shared<Dog>();
  }
  else if (input > 3 && input < 6) {
    return std::make_shared<Cow>();
  }
  else {
    return std::make_shared<Human>();
  }
}

Sources

whoami

Jaime Romero is a software developer and cybersecurity expert operating in Western Europe.