I have 3 simple predicates and 3 simple actions to be taken based on those predicates. In my actual application they are not based on integer arithmetic, and in fact are rather expensive to compute (in comparison to the actions taken), but their dependency chain is the same.
#include <iostream>
#include <tuple>
bool pred1(int x) { return x % 30 == 0; }
bool pred2(int x) { return x % 15 == 0; }
bool pred3(int x) { return x % 5 == 0; }
void fun1(int x) { std::cout << "divisible by 30 \n"; }
void fun2(int x) { std::cout << "divisible by 15 \n"; }
void fun3(int x) { std::cout << "divisible by 5 \n"; }
void fun(int x)
{
if (pred1(x)) {
fun1(x);
fun2(x);
fun3(x);
return;
}
if (pred2(x)) {
fun2(x);
fun3(x);
return;
}
if (pred3(x))
fun3(x);
}
Note that pred1
requires all 3 actions, pred2
the last 2 actions, and pred3
only the 3rd action. I was not happy with this code: while efficient in avoiding extra work, it seems overly repetitive.
However, the following straightforward try at refactoring is a lot more concise but also less efficient as it computes all 3 predicates for all inputs:
// exposition only: will compute pred1, pred2 and pred3 for all inputs
void hun(int x)
{
if (pred1(x)) fun1(x);
if (pred2(x)) fun2(x);
if (pred3(x)) fun3(x);
}
So I came up with the idea of caching the predicate values in a std::tuple
and dispatch the various actions based on that:
using Triple = std::tuple<bool, bool, bool>;
auto preds(int x) -> Triple
{
if (pred1(x)) return Triple{ true, true, true };
if (pred2(x)) return Triple{ false, true, true };
return Triple{ false, false, pred3(x) };
}
void gun(int x)
{
auto const p = preds(x);
if (std::get<0>(p)) fun1(x);
if (std::get<1>(p)) fun2(x);
if (std::get<2>(p)) fun3(x);
}
Live Example.
So the repetitive logic is hidden inside the repeated values of true
in the std::tuple
, and the duplicate predicate computation is now reduced to simple lookups into the std::tuple
.
Looking at the assembler from gcc.godbolt.org, it seems that the compiler even optimizes away all unnecessary calls to std::get
in gun()
(e.g. it sees that the remaining fields of the std::tuple
will be true
if the first one is), essentially reducing the code to that of fun()
.
Questions:
- are they other approaches to simplying the logic of overlapping predicates?
- is the
gun()
version considered more readable and maintainable than the fun()
original?
- how smart are compilers in eliminating unnecessary lookups when the underying predicates and actions are not so trivial as the ones above?