At compile time, No, at best, you'll end up with some nasty template and Macro hacks that will still be severely limited. If your thought is on compile-time, don't read the rest of the answer
From a User level (at run time)? Yes, you can. Though, the Logic is quite simple, you are just to find a way to pragmatically create and maintain the invariants of an Expression Tree. It requires a bit of work to get it actualized.
So, Lets, tackle the Logic...
For simplicity, let us define some basic terms here to fit our intent
- An
operator
is a function that requires at most 2 operands
such that when called, it produces a result which is another operand
- An
operand
is an object of interest, in your case, numbers, more precisely double
. It can be produced by you or an expression
- An
expression
is an object that takes at most 2 operands
and an operator
, and produces a resulting operand
by calling the operator
function.
I did some drawings to illustrate this....

As you can see, the arrows shows the direction of knowledge.
- An
Operand
knows all the expressions its involved in.
- An
Expression
knows the Operands
it produced.
So, Let us give them some identities...

Let us say, you created Operand 1
, Operand 2
, Operand 4
. And you started building up this expression tree in this order:
You created a relationship (an Expression
) between Operand 1
and Operand 2
which is represented by Expression1
.
Expression1
uses the Operator
it was constructed with to produce its result, Operand 3
You combined the resulting Operand 3
with your created Operand 4
into a new expression Expression2
to produce another result, Operand 5
Now, let us see what happens when we decide to modify Operand 1
.

As you can see, the modified operand will recursively go through and update all subexpressions whose result depends on it (whether directly or by proxy).
Now, that we've got this very simple idea, how do we go about it.
There are a number of ways you can implement it, the more generic and flexible it is, the less performant it will likely be (in terms of Memory and Speed)
I did a simple implementation below (Obviously, far from anything Optimal).
template<typename T>
class Operand;
template<typename T>
class Expression {
std::shared_ptr<Operand<T>> m_operand1;
std::shared_ptr<Operand<T>> m_operand2;
std::shared_ptr<Operand<T>> m_result;
T (*m_operator)(const T&, const T&);
friend class Operand<T>;
public:
Expression(
T(*operator_func)(const T&, const T&),
std::shared_ptr<Operand<T>> operand_1,
std::shared_ptr<Operand<T>> operand_2) :
m_operand1(operand_1),
m_operand2(operand_2),
m_result(std::make_shared<Operand<T>>(T{})),
m_operator(operator_func)
{
}
void update(){
m_result->value() = m_operator(m_operand1->value(), m_operand2->value());
m_result->update();
}
std::shared_ptr<Operand<T>>& result() { return m_result; }
};
template<typename T>
class Operand {
T val;
std::vector<std::shared_ptr<Expression<T>>> expressions;
friend class Expression<T>;
public:
Operand(T value) : val(value) {}
T& value() { return val; }
void update(){
for(auto& x : expressions)
x->update();
}
static std::shared_ptr<Operand<T>> make(const T& t){
return std::make_shared<Operand<T>>(t);
}
static std::shared_ptr<Operand<T>>
relate(
T(*operator_func)(const T&, const T&),
std::shared_ptr<Operand<T>> operand_1,
std::shared_ptr<Operand<T>> operand_2
){
auto e = std::make_shared<Expression<T>>(operator_func, operand_1, operand_2);
operand_1->expressions.push_back( e );
operand_2->expressions.push_back( e );
e->update();
return e->result();
}
};
//template<typename T>
//double add(const double& lhs, const double& rhs){ return lhs + rhs; }
template<typename T>
T add(const T& lhs, const T& rhs){ return lhs + rhs; }
template<typename T>
T mul(const T& lhs, const T& rhs){ return lhs * rhs; }
int main()
{
using DOperand = Operand<double>;
auto d1 = DOperand::make(54.64);
auto d2 = DOperand::make(55.36);
auto d3 = DOperand::relate(add<double>, d1, d2);
auto d4 = DOperand::relate(mul<double>, d3, d2);
//---------------PRINT------------------------//
std::cout << "d1 = " << d1->value() << "\nd2 = " << d2->value()
<< "\nd3 = d1 + d2 = " << d3->value() << "\nd4 = d3 * d2 = "
<< d4->value() << std::endl;
//---------------UPDATE ONE VARIABLE------------------------//
std::cout << "\n\n====================\n" << std::endl;
std::cout << "changed d1 from " << d1->value() << " to ";
d1->value() = -863.2436356;
d1->update();
std::cout << d1->value() << "\n\n=======================\n\n";
//---------------PRINT------------------------//
std::cout << "d1 = " << d1->value() << "\nd2 = " << d2->value()
<< "\nd3 = d1 + d2 = " << d3->value() << "\nd4 = d3 * d2 = "
<< d4->value() << std::endl;
// *******************************************
std::cout << "\n\n\n\n\nSizeof(Operand<int>) = " << sizeof(Operand<int>)
<< "\nSizeof(Expression<int>) = " << sizeof(Expression<int>) << std::endl;
}
The Output is:
d1 = 54.64
d2 = 55.36
d3 = d1 + d2 = 110
d4 = d3 * d2 = 6089.6
====================
changed d1 from 54.64 to -863.244
=======================
d1 = -863.244
d2 = 55.36
d3 = d1 + d2 = -807.884
d4 = d3 * d2 = -44724.4
See it Live on Coliru
For simple integral
types, my usage of shared_ptr
was an overkill, I could actually do this with normal pointers. But this implementation tends to generalize on the type of typename T
.
Other things to think of...
- API choices
- Memory usage
- Avoiding Cycle detection (infinitely recursive updates)
- ...etc
Comments, Criticisms and suggestions are welcomed. :-)