Repository: Pyomo/pyomo

Issue:
Expression objects do not support inplace operators
The following:
```python
m = ConcreteModel()
m.x = Var()
m.y = Var()
m.e = Expression(expr=m.x)
m.e += m.y
```
produces a self-referential Expression:
```python
>>> type(m.e.expr)
<class 'pyomo.core.expr.numeric_expr.SumExpression'>
>>> print([str(_) for _ in m.e.expr.args])
['e', 'y']
```


Discussion:

I recall attempting to disable the in-place operators when I first added Expression, but ran into issue getting magic reference count stuff that triggers cloning to work correctly in certain cases. Perhaps that is no longer an issue, or I just got it wrong. Or maybe I was trying to redirect them to the inner expression.

I’m okay with disabling the operators for the expression object itself, if you think that makes sense.

> On Mar 26, 2021, at 9:03 AM, John Siirola ***@***.***> wrote:
> 
> ﻿
> The following:
> 
> m = ConcreteModel()
> m.x = Var()
> m.y = Var()
> m.e = Expression(expr=m.x)
> m.e += m.y
> produces a self-referential Expression:
> 
> >>> type(m.e.expr)
> <class 'pyomo.core.expr.numeric_expr.SumExpression'>
> >>> print([str(_) for _ in m.e.expr.args])
> ['e', 'y']
> —
> You are receiving this because you are subscribed to this thread.
> Reply to this email directly, view it on GitHub, or unsubscribe.

Personally, I think we should support something like `m.e += m.x`: it is useful in iterative algorithms when you want to augment, e.g., an objective.  Note that the following *does* work correctly:
```python
m.e.expr += m.x
```
If we chose to remove support for `m.e += `, it needs to generate a reasonable error redirecting users to `m.e.expr += `. 

I am mostly filing this so that I remember the issue when we get around to making another pass at cleaning up / refining the expression system.
I would have expected `m.e += m.x` to do `m.e.expr += m.x` under the hood.
I just remembered, there is some ambiguity here that is problematic. Consider this common scenario:
```python
tmp = <starting-expression>
for i in ...:
   tmp += ...
```
It is very easy to write `<starting-expression>` in a way that might simplify down to a single named `Expression` (e.g., `immutable_param[j] + m.named_expression`, where `immutable_param[j] = 0`). Thus, just consider the trivial case:
```
m.e = Expression()

tmp = m.e
for i in ...:
    tmp += ...
```
We can't always assume a user's intention is to modify the internal expression. The in-place operators might be used as part of building an external expression. Also, the internal expression might not be assigned anything yet, which is okay because it is a placeholder. The loop behavior above should be the same whether the starting `tmp` is a Var, Expression, Param, or constant.

Resolving patch:
diff --git a/pyomo/core/base/expression.py b/pyomo/core/base/expression.py
--- a/pyomo/core/base/expression.py
+++ b/pyomo/core/base/expression.py
@@ -195,7 +195,21 @@ def expr(self, expr):
 
     def set_value(self, expr):
         """Set the expression on this expression."""
-        self._expr = as_numeric(expr) if (expr is not None) else None
+        if expr is None:
+            self._expr = None
+            return
+        expr = as_numeric(expr)
+        # In-place operators will leave self as an argument.  We need to
+        # replace that with the current expression in order to avoid
+        # loops in the expression tree.
+        if expr.is_expression_type():
+            _args = expr.args
+            if any(arg is self for arg in _args):
+                new_args = _args.__class__(
+                    arg.expr if arg is self else arg for arg in _args
+                )
+                expr = expr.create_node_with_local_data(new_args)
+        self._expr = expr
 
     def is_constant(self):
         """A boolean indicating whether this expression is constant."""


Test patch:
diff --git a/pyomo/core/tests/unit/test_expression.py b/pyomo/core/tests/unit/test_expression.py
--- a/pyomo/core/tests/unit/test_expression.py
+++ b/pyomo/core/tests/unit/test_expression.py
@@ -17,8 +17,12 @@
 
 import pyomo.common.unittest as unittest
 
-from pyomo.environ import ConcreteModel, AbstractModel, Expression, Var, Set, Param, Objective, value, sum_product
+from pyomo.environ import (
+    ConcreteModel, AbstractModel, Expression, Var, Set, Param, Objective,
+    value, sum_product,
+)
 from pyomo.core.base.expression import _GeneralExpressionData
+from pyomo.core.expr.compare import compare_expressions
 from pyomo.common.tee import capture_output
 
 class TestExpressionData(unittest.TestCase):
@@ -902,6 +906,13 @@ def test_iadd(self):
             expr += v
         self.assertEqual(e.expr, 1)
         self.assertEqual(expr(), 2)
+        # Make sure that using in-place operators on named expressions
+        # do not create loops inthe expression tree (test #1890)
+        m.x = Var()
+        m.y = Var()
+        m.e.expr = m.x
+        m.e += m.y
+        self.assertTrue(compare_expressions(m.e.expr, m.x + m.y))
 
     def test_isub(self):
         # make sure simple for loops that look like they
@@ -919,6 +930,13 @@ def test_isub(self):
             expr -= v
         self.assertEqual(e.expr, 1)
         self.assertEqual(expr(), -2)
+        # Make sure that using in-place operators on named expressions
+        # do not create loops inthe expression tree (test #1890)
+        m.x = Var()
+        m.y = Var()
+        m.e.expr = m.x
+        m.e -= m.y
+        self.assertTrue(compare_expressions(m.e.expr, m.x - m.y))
 
     def test_imul(self):
         # make sure simple for loops that look like they
@@ -936,6 +954,13 @@ def test_imul(self):
             expr *= v
         self.assertEqual(e.expr, 3)
         self.assertEqual(expr(), 6)
+        # Make sure that using in-place operators on named expressions
+        # do not create loops inthe expression tree (test #1890)
+        m.x = Var()
+        m.y = Var()
+        m.e.expr = m.x
+        m.e *= m.y
+        self.assertTrue(compare_expressions(m.e.expr, m.x * m.y))
 
     def test_idiv(self):
         # make sure simple for loops that look like they
@@ -968,6 +993,13 @@ def test_idiv(self):
             expr /= v
         self.assertEqual(e.expr, 3)
         self.assertEqual(expr(), 1.5)
+        # Make sure that using in-place operators on named expressions
+        # do not create loops inthe expression tree (test #1890)
+        m.x = Var()
+        m.y = Var()
+        m.e.expr = m.x
+        m.e /= m.y
+        self.assertTrue(compare_expressions(m.e.expr, m.x / m.y))
 
     def test_ipow(self):
         # make sure simple for loops that look like they
@@ -985,6 +1017,13 @@ def test_ipow(self):
             expr **= v
         self.assertEqual(e.expr, 3)
         self.assertEqual(expr(), 9)
+        # Make sure that using in-place operators on named expressions
+        # do not create loops inthe expression tree (test #1890)
+        m.x = Var()
+        m.y = Var()
+        m.e.expr = m.x
+        m.e **= m.y
+        self.assertTrue(compare_expressions(m.e.expr, m.x ** m.y))
 
 if __name__ == "__main__":
     unittest.main()