Decoding Conditionals: A Dive into if-else
, switch-case
, Lookup Tables, and Interfaces
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.
Understanding 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 condition evaluates totrue
.
else if
blocks are executed only when condition evaluates totrue
and all precedingif
orelse if
conditions have evaluated tofalse
.
else
blocks serve as a fallback or default case, executed when no preceding conditions have evaluated totrue
.
From this, we can observe that if-else
statements are designed to handle branching logic that requires evaluation of conditions. More specifically, they handle conditions that have a hierarchical relationship, as else if
statements are equivalent to nested if
statements within else
blocks.
if (condition1) {
// Your magic
} else {
if (condition2) {
// Your other magic
}
}
In terms of performance, modern processors employ branch prediction mechanisms. However, when these mechanisms fail to guess the correct branch, the program would suffer a significant performance penalty, leading to increased time complexity.
Therefore, we can conclude that if-else
statements may not be the optimal choice in scenarios where conditions do not require a fallback logic.
Exploring switch
Cases
Now, let’s delve into 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
. switch
statements provide an easy way to dispatch execution to different parts of code based on different conditions.
We have to agree that switch
cases may work similar to if
statements under the hood, both generating a lookup table for the conditions, when they are simple and involve checking a variable against a range of values. However, it’s also worth noting that this type of optimization is highly dependent on the specific compiler and the nature of the conditions, and is more commonly associated with switch
statements.
gcc -O3
(GCC) enables nearly all optimizations that don’t involve a space-speed tradeoff.
cl /O2
(MSVC) 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-case
s are ideal when the branches don’t have hierarchical relationships, and different actions are taken based on different conditions but not in a fallback manner.
Embracing Lookup Tables
As observed, the lookup table derived from switch-case
s 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 C++ reference. So, what happens when this is the case?
The solution is quite straightforward — if the compiler doesn’t generate a lookup table, we implement it ourselves. The following snippet demonstrates how to use a lookup table in conjunction with std::unordered_map
.
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;
}
In C++, std::unordered_map
is typically implemented as a hash table and thereby offering an average time complexity of O(1)
, which is fundamentally identical to the lookup table generated by the compiler.
Thus, custom lookup tables appear to be a viable alternative to switch-case
s where they are not applicable.
The Power of Interfaces
We have explored various conditions so far and have observed that switch-case
s and lookup tables can be effective when dealing with a large number of branches. However, they are not a panacea. Consider 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
andcases
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-case
s. This is also a fundamental technique in object-orientated 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-case
s and lookup tables when the branches don’t have hierarchical relationships, and different actions are taken based on different conditions, but not in a 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.
Thank you for taking your time to read! If you have any questions, concerns, or if you spot any issues, please feel free to connect with powersagitar. Your feedback is greatly appreciated.