Repository: Qiskit/qiskit

Issue:
Add manual variable support to `expr.Var`
Add support to `expr.Var` for representing classical variables with some fixed storage location as described in https://github.com/Qiskit/RFCs/pull/50.  For the initial pass, we can use some UUID as the backing "data store" link value to use in direct comparisons, which has the advantage of letting QPY round-trip serialisation reproduce the values exactly, whereas requiring singleton object identities for equality has the problem that two circuits serialised separately will end up with spuriously difference variables.


Discussion:

Resolving patch:
diff --git a/qiskit/circuit/classical/expr/__init__.py b/qiskit/circuit/classical/expr/__init__.py
--- a/qiskit/circuit/classical/expr/__init__.py
+++ b/qiskit/circuit/classical/expr/__init__.py
@@ -39,10 +39,11 @@
 
 These objects are mutable and should not be reused in a different location without a copy.
 
-The entry point from general circuit objects to the expression system is by wrapping the object
-in a :class:`Var` node and associating a :class:`~.types.Type` with it.
+The base for dynamic variables is the :class:`Var`, which can be either an arbitrarily typed runtime
+variable, or a wrapper around a :class:`.Clbit` or :class:`.ClassicalRegister`.
 
 .. autoclass:: Var
+    :members: var, name
 
 Similarly, literals used in comparison (such as integers) should be lifted to :class:`Value` nodes
 with associated types.
@@ -86,10 +87,18 @@
 The functions and methods described in this section are a more user-friendly way to build the
 expression tree, while staying close to the internal representation.  All these functions will
 automatically lift valid Python scalar values into corresponding :class:`Var` or :class:`Value`
-objects, and will resolve any required implicit casts on your behalf.
+objects, and will resolve any required implicit casts on your behalf.  If you want to directly use
+some scalar value as an :class:`Expr` node, you can manually :func:`lift` it yourself.
 
 .. autofunction:: lift
 
+Typically you should create memory-owning :class:`Var` instances by using the
+:meth:`.QuantumCircuit.add_var` method to declare them in some circuit context, since a
+:class:`.QuantumCircuit` will not accept an :class:`Expr` that contains variables that are not
+already declared in it, since it needs to know how to allocate the storage and how the variable will
+be initialized.  However, should you want to do this manually, you should use the low-level
+:meth:`Var.new` call to safely generate a named variable for usage.
+
 You can manually specify casts in cases where the cast is allowed in explicit form, but may be
 lossy (such as the cast of a higher precision :class:`~.types.Uint` to a lower precision one).
 
diff --git a/qiskit/circuit/classical/expr/expr.py b/qiskit/circuit/classical/expr/expr.py
--- a/qiskit/circuit/classical/expr/expr.py
+++ b/qiskit/circuit/classical/expr/expr.py
@@ -31,6 +31,7 @@
 import abc
 import enum
 import typing
+import uuid
 
 from .. import types
 
@@ -108,24 +109,56 @@ def __repr__(self):
 
 @typing.final
 class Var(Expr):
-    """A classical variable."""
+    """A classical variable.
 
-    __slots__ = ("var",)
+    These variables take two forms: a new-style variable that owns its storage location and has an
+    associated name; and an old-style variable that wraps a :class:`.Clbit` or
+    :class:`.ClassicalRegister` instance that is owned by some containing circuit.  In general,
+    construction of variables for use in programs should use :meth:`Var.new` or
+    :meth:`.QuantumCircuit.add_var`."""
+
+    __slots__ = ("var", "name")
 
     def __init__(
-        self, var: qiskit.circuit.Clbit | qiskit.circuit.ClassicalRegister, type: types.Type
+        self,
+        var: qiskit.circuit.Clbit | qiskit.circuit.ClassicalRegister | uuid.UUID,
+        type: types.Type,
+        *,
+        name: str | None = None,
     ):
         self.type = type
         self.var = var
+        """A reference to the backing data storage of the :class:`Var` instance.  When lifting
+        old-style :class:`.Clbit` or :class:`.ClassicalRegister` instances into a :class:`Var`,
+        this is exactly the :class:`.Clbit` or :class:`.ClassicalRegister`.  If the variable is a
+        new-style classical variable (one that owns its own storage separate to the old
+        :class:`.Clbit`/:class:`.ClassicalRegister` model), this field will be a :class:`~uuid.UUID`
+        to uniquely identify it."""
+        self.name = name
+        """The name of the variable.  This is required to exist if the backing :attr:`var` attribute
+        is a :class:`~uuid.UUID`, i.e. if it is a new-style variable, and must be ``None`` if it is
+        an old-style variable."""
+
+    @classmethod
+    def new(cls, name: str, type: types.Type) -> typing.Self:
+        """Generate a new named variable that owns its own backing storage."""
+        return cls(uuid.uuid4(), type, name=name)
 
     def accept(self, visitor, /):
         return visitor.visit_var(self)
 
     def __eq__(self, other):
-        return isinstance(other, Var) and self.type == other.type and self.var == other.var
+        return (
+            isinstance(other, Var)
+            and self.type == other.type
+            and self.var == other.var
+            and self.name == other.name
+        )
 
     def __repr__(self):
-        return f"Var({self.var}, {self.type})"
+        if self.name is None:
+            return f"Var({self.var}, {self.type})"
+        return f"Var({self.var}, {self.type}, name='{self.name}')"
 
 
 @typing.final


Test patch:
diff --git a/test/python/circuit/classical/test_expr_properties.py b/test/python/circuit/classical/test_expr_properties.py
--- a/test/python/circuit/classical/test_expr_properties.py
+++ b/test/python/circuit/classical/test_expr_properties.py
@@ -56,3 +56,45 @@ def test_expr_can_be_cloned(self, obj):
         self.assertEqual(obj, copy.copy(obj))
         self.assertEqual(obj, copy.deepcopy(obj))
         self.assertEqual(obj, pickle.loads(pickle.dumps(obj)))
+
+    def test_var_equality(self):
+        """Test that various types of :class:`.expr.Var` equality work as expected both in equal and
+        unequal cases."""
+        var_a_bool = expr.Var.new("a", types.Bool())
+        self.assertEqual(var_a_bool, var_a_bool)
+
+        # Allocating a new variable should not compare equal, despite the name match.  A semantic
+        # equality checker can choose to key these variables on only their names and types, if it
+        # knows that that check is valid within the semantic context.
+        self.assertNotEqual(var_a_bool, expr.Var.new("a", types.Bool()))
+
+        # Manually constructing the same object with the same UUID should cause it compare equal,
+        # though, for serialisation ease.
+        self.assertEqual(var_a_bool, expr.Var(var_a_bool.var, types.Bool(), name="a"))
+
+        # This is a badly constructed variable because it's using a different type to refer to the
+        # same storage location (the UUID) as another variable.  It is an IR error to generate this
+        # sort of thing, but we can't fully be responsible for that and a pass would need to go out
+        # of its way to do this incorrectly, but we can still ensure that the direct equality check
+        # would spot the error.
+        self.assertNotEqual(
+            var_a_bool, expr.Var(var_a_bool.var, types.Uint(8), name=var_a_bool.name)
+        )
+
+        # This is also badly constructed because it uses a different name to refer to the "same"
+        # storage location.
+        self.assertNotEqual(var_a_bool, expr.Var(var_a_bool.var, types.Bool(), name="b"))
+
+        # Obviously, two variables of different types and names should compare unequal.
+        self.assertNotEqual(expr.Var.new("a", types.Bool()), expr.Var.new("b", types.Uint(8)))
+        # As should two variables of the same name but different storage locations and types.
+        self.assertNotEqual(expr.Var.new("a", types.Bool()), expr.Var.new("a", types.Uint(8)))
+
+    def test_var_uuid_clone(self):
+        """Test that :class:`.expr.Var` instances that have an associated UUID and name roundtrip
+        through pickle and copy operations to produce values that compare equal."""
+        var_a_u8 = expr.Var.new("a", types.Uint(8))
+
+        self.assertEqual(var_a_u8, pickle.loads(pickle.dumps(var_a_u8)))
+        self.assertEqual(var_a_u8, copy.copy(var_a_u8))
+        self.assertEqual(var_a_u8, copy.deepcopy(var_a_u8))