← Home

Decoding Conditionals: A Dive into if-else, switch-case, Lookup Tables, and Interfaces

(Last Edited)

Have you ever been confused about when to use if-else statements, switch cases, or other conditionals in your code? This article may help you to clarify your thoughts.

if-else Statements

if-else statements are fundamental constructs in nearly all programming languages. But when should we prefer them? Let's explore their semantic meanings first before considering performance implications:

  • if blocks are executed only when the condition evaluates to true.
  • else if blocks are executed only when the condition evaluates to false, and all preceding if or else if conditions have evaluated to false.
  • else blocks serve as a fallback or default case, executed when no preceding conditions have evaluated to true.

From this, we can observe that if-else statements are designed to handle branching logic that has a hierarchical relationship, since else if statements are equivalent to nested if statements within else clauses.

if (condition_1) {
  // some magic
} else {
    if (condition_2) {
      // some other magic
    }
}

Performance wise, although modern processors employ branch prediction mechanisms, when they fail to guess the correct branch, the program would suffer a significant performance penalty.

Therefore, we can conclude that if-else statements may not be the optimal choice in scenarios where conditions are not in a fallback manner.

switch Cases

Once again, think about switch's semantic meaning first. The switch statement evaluates a given expression and, based on the evaluation, it executes the corresponding case.

We have to agree that switch may work similar to if statements under the hood, both generating a lookup table, when they are simple and involve checking a variable against a range of values. However, it's also worth noting that this optimization is highly dependent on the specific compiler and the nature of the conditions, and is more commonly associated with switch cases.

Note

gcc -O3 enables nearly all optimizations that don't involve a space-speed tradeoff.
cl /O2 enables optimizations for maximum speed.

For instance, x86-64 gcc 13.2 conducts such optimization when the flag is set to -O3.

While x86 msvc v19.38 doesn't, when the flag is set to /O2.

In general, it's best to choose between these two based on their semantic meanings, and let the compiler handle the optimizations:

  • if statements are best used when the branches have hierarchical relationships.
  • switch cases are ideal when the branches don't have hierarchical relationships, and different actions are taken based on a non-fallback manner.

Lookup Tables

As observed, the lookup table derived from switch cases boasts an O(1) time complexity, which is ideal. However, not all types of variables can be used in the condition of a switch statement. For instance, in C++, the condition only accepts integral and enumeration types, as well as types that can be implicitly converted to those two, as stated in the reference. So, what happens when you have non-integral or non-enumeration types?

The solution is quite straightforward. If the compiler doesn't generate a lookup table, we write one ourselves. The following snippet demonstrates how to use a lookup table in conjunction with std::unordered_map, which is commonly implemented as a hash table.

int Handler1(int argc, char **argv) {}
int Handler2(int argc, char **argv) {}
 
int main(int argc, char **argv) {
    std::unordered_map<std::string_view, int (*)(int, char**)> lookup_table = {
        {"command1", Handler1},
        {"command2", Handler2},
    };
 
    if (argc < 2) {
        return EX_USAGE;
    }
 
    try {
        int error_code = lookup_table.at(argv[1])(argc, argv);
        // Handle the error code
    } catch (std::out_of_range &exception) {
        // Handle the exception
        return EX_USAGE;
    }
 
    return EX_OK;
}

Since std::unordered_map is essentially a hash table with an average lookup time complexity of O(1), it's fundamentally identical to the lookup table generated by the compiler.

Thus, custom lookup tables appear to be a viable alternative to switch cases where they are not applicable.

Interfaces

We have explored various conditions so far and have observed that switch cases and lookup tables can be effective when dealing with a large number of branches. However, they are not a panacea. Consider the scenarios where:

  • The logic frequently changes.
  • There are many related types of objects.
  • The code needs to be decoupled.
  • Adherence to the Open/Closed Principle is desired.

In such cases, interfaces come into play. As a reminder, by the Wikipedia contributors:

An interface or protocol is a data type that acts as an abstraction of a class. It describes a set of method signatures, the implementations of which may be provided by multiple classes that are otherwise not necessarily related to each other.

Let's see how interfaces can help with an example.

Instead of this:

enum class HandlerType {
    Handler1,
    Handler2,
} handler_type = HandlerType::Handler1;
 
void Execute(int some_arg) {
    switch (handler_type) {
        case HandlerType::Handler1:
            // Your magic
            break;
        case HandlerType::Handler2:
            // Your other magic
            break;
    }
}

Consider this:

class Interface {
public:
    virtual ~Interface() = default;
    virtual void Execute(int some_arg) = 0;
};
 
class Handler1 : public Interface {
public:
    void Execute(int some_arg) override {
        // Your magic
    }
};
 
class Handler2 : public Interface {
public:
    void Execute(int some_arg) override {
        // Your other magic
    }
};
 
void CallExecute(Interface &handler, int some_arg) {
    handler.Execute(some_arg);
}
 
int main() {
    Handler1 handler1;
    CallExecute(handler1, 1);
    return EX_OK;
}

The benefits are clear:

  • The large switch and cases are eliminated, making the code more readable and maintainable.
  • The new pattern adheres to the Open/Closed Principle. When a new case arises, we can simply add an implementation class instead of modifying the existing logic by creating a new case.

Therefore, in scenarios where multiple types of objects share a common interface but require different actions, this approach is more suitable and scalable than using if-else or switch-cases. This is also a fundamental technique in object-oriented programming.

Summary

In conclusion, there is no universal solution when it comes to choosing conditionals. The most suitable option always depends on the specific circumstances at hand. Here's a guide for quick reference:

  • Use if-else statements when the branches have hierarchical relationships.
  • Opt for switch-cases and lookup tables when the branches don't have hierarchical relationships, and different actions are taken in a non-fallback manner.
  • Employ interfaces when dealing with a large number of related types of objects, and when the code needs to be decoupled, and adhere to the Open/Closed Principle.

Remember, the key is to consider the specific situation and choose the most appropriate means.