Repository: PennyLaneAI/pennylane
Pull request #3769 (2023-02-14 19:40:50)

[bugfix] QNodes can no longer return empty iterables
**Context:**
QNodes are supposed to always return 1 or more measurement processes, and nothing else. Before this change, if a QNode returned an empty iterable, it would decree that "all of the returned values are of some measurement type", which would true! All zero of them, that is, so it would then succeed and return the empty iterable. However, QNodes are supposed to return at least 1 measurement as previously stated, so this should be fixed.

**Description of the Change:**
- Update `QNode.construct` to make sure the qfunc output is truthy (it already otherwise ensured that all return values are measurements)
- Update a `qfunc_transform` docstring example because it would fail after this change (which is how I even found this bug)

**Benefits:**
The QNode fails as expected if it doesn't return any measurement. This will be useful because otherwise, users might accidentally execute QNodes thinking something meaningful is being done when really, nothing is. That would be especially bad if they didn't notice this while running some code on pay-per-use compute resources.

**Possible Drawbacks:**
Perhaps some users have found this undesired behaviour and have grown to expect it? I think it's good to merge.

github-actions[bot]: Hello. You may have forgotten to update the changelog!
Please edit [`doc/releases/changelog-dev.md`](/PennyLaneAI/pennylane/blob/master/doc/releases/changelog-dev.md) with:
* A one-to-two sentence description of the change. You may include a small working example for new features.
* A link back to this PR.
* Your name (or GitHub username) in the contributors section.
codecov[bot]: # [Codecov](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3769?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
> Merging [#3769](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3769?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) (bdaac88) into [master](https://codecov.io/gh/PennyLaneAI/pennylane/commit/023e001cd437e5283a4ffa1b2f73bda38ebf4bc9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) (023e001) will **not change** coverage.
> The diff coverage is `100.00%`.

```diff
@@           Coverage Diff           @@
##           master    #3769   +/-   ##
=======================================
  Coverage   99.81%   99.81%           
=======================================
  Files         331      331           
  Lines       28982    28983    +1     
=======================================
+ Hits        28927    28928    +1     
  Misses         55       55           
```


| [Impacted Files](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3769?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) | Coverage Δ | |
|---|---|---|
| [pennylane/transforms/qfunc\_transforms.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3769?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL3RyYW5zZm9ybXMvcWZ1bmNfdHJhbnNmb3Jtcy5weQ==) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI)


Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 9cfe0f107..064870533 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -791,6 +791,9 @@
   probabilities with the expected wire order.
   [(#3753)](https://github.com/PennyLaneAI/pennylane/pull/3753)
 
+* Ensure that a `QNode` does not return an empty iterable.
+  [(#3769)](https://github.com/PennyLaneAI/pennylane/pull/3769)
+
 <h3>Contributors</h3>
 
 This release contains contributions from (in alphabetical order):
diff --git a/pennylane/qnode.py b/pennylane/qnode.py
index 620983dd8..a32221025 100644
--- a/pennylane/qnode.py
+++ b/pennylane/qnode.py
@@ -753,7 +753,7 @@ class QNode:
         else:
             measurement_processes = self._qfunc_output
 
-        if not all(
+        if not measurement_processes or not all(
             isinstance(m, qml.measurements.MeasurementProcess) for m in measurement_processes
         ):
             raise qml.QuantumFunctionError(
diff --git a/pennylane/transforms/qfunc_transforms.py b/pennylane/transforms/qfunc_transforms.py
index 411f2f49f..89cf7fd74 100644
--- a/pennylane/transforms/qfunc_transforms.py
+++ b/pennylane/transforms/qfunc_transforms.py
@@ -182,10 +182,10 @@ def _create_qfunc_internal_wrapper(fn, tape_transform, transform_args, transform
         tape = make_qscript(fn)(*args, **kwargs)
         tape = tape_transform(tape, *transform_args, **transform_kwargs)
 
-        if len(tape.measurements) == 1:
-            return tape.measurements[0]
-
-        return tape.measurements
+        num_measurements = len(tape.measurements)
+        if num_measurements == 0:
+            return None
+        return tape.measurements[0] if num_measurements == 1 else tape.measurements
 
     return internal_wrapper
 
@@ -236,12 +236,13 @@ def qfunc_transform(tape_transform):
         def qfunc(x):
             qml.Hadamard(wires=0)
             qml.CRX(x, wires=[0, 1])
+            return qml.expval(qml.PauliZ(1))
 
     >>> dev = qml.device("default.qubit", wires=2)
     >>> qnode = qml.QNode(qfunc, dev)
     >>> print(qml.draw(qnode)(2.5))
     0: ──H──────────────────╭Z─┤
-    1: ──RX(1.50)──RY(0.16)─╰●─┤
+    1: ──RX(1.50)──RY(0.16)─╰●─┤  <Z>
 
     The transform weights provided to a qfunc transform are fully differentiable,
     allowing the transform itself to be differentiated and trained. For more details,


Test patch:
diff --git a/tests/test_qnode.py b/tests/test_qnode.py
index a4451e1f5..474541000 100644
--- a/tests/test_qnode.py
+++ b/tests/test_qnode.py
@@ -680,6 +680,19 @@ class TestTapeConstruction:
         ):
             qn(5, 1)
 
+        def func(x, y):
+            qml.RX(x, wires=0)
+            qml.RY(y, wires=1)
+            qml.CNOT(wires=[0, 1])
+            return []
+
+        qn = QNode(func, dev)
+
+        with pytest.raises(
+            qml.QuantumFunctionError, match="must return either a single measurement"
+        ):
+            qn(5, 1)
+
     def test_inconsistent_measurement_order(self):
         """Test that an exception is raised if measurements are returned in an
         order different to how they were queued on the tape"""
diff --git a/tests/tests_passing_pylint.txt b/tests/tests_passing_pylint.txt
index 77fc4b085..c7cc307dc 100644
--- a/tests/tests_passing_pylint.txt
+++ b/tests/tests_passing_pylint.txt
@@ -21,6 +21,7 @@ tests/ops/qubit/test_special_unitary.py
 tests/ops/qubit/test_matrix_ops.py
 tests/ops/op_math/test_exp.py
 tests/transforms/test_decompositions.py
+tests/transforms/test_qfunc_transform.py
 tests/returntypes/gradients/test_hadamard_grad.py
 tests/ops/op_math/test_exp.py
 tests/ops/qubit/test_state_prep.py
diff --git a/tests/transforms/test_qfunc_transform.py b/tests/transforms/test_qfunc_transform.py
index e12638e58..4622333b9 100644
--- a/tests/transforms/test_qfunc_transform.py
+++ b/tests/transforms/test_qfunc_transform.py
@@ -14,6 +14,7 @@
 """
 Unit tests for the qfunc transform decorators.
 """
+# pylint:disable=no-value-for-parameter
 import pytest
 
 import pennylane as qml
@@ -303,6 +304,29 @@ class TestQFuncTransforms:
         assert np.allclose(normal_result, transformed_result)
         assert normal_result.shape == transformed_result.shape
 
+    def test_transform_does_not_add_return_value_to_qnode(self):
+        """Tests that qfunc_transform doesn't add an empty list of measurements to a QNode."""
+
+        @qml.qfunc_transform
+        def expand_hadamards(tape):
+            for op in tape:
+                if op.name == "Hadamard":
+                    qml.RZ(np.pi, wires=op.wires)
+                    qml.RY(np.pi / 2, wires=op.wires)
+                else:
+                    op.queue()
+
+        @expand_hadamards
+        def ansatz():
+            qml.Hadamard(wires=0)
+            qml.CNOT(wires=[0, 1])
+
+        assert ansatz() is None
+
+        qnode = qml.QNode(ansatz, qml.device("default.qubit", wires=2))
+        with pytest.raises(qml.QuantumFunctionError, match="return either a single measurement"):
+            qnode()
+
     def test_sphinx_build(self, monkeypatch):
         """Test that qfunc transforms are not created during Sphinx builds"""
 
@@ -331,7 +355,7 @@ class TestQFuncTransforms:
             qml.RX(x, wires=0)
 
         with pytest.warns(UserWarning, match="qml.tape.make_qscript"):
-            tape = qml.transforms.make_tape(circuit)(0.1)
+            _ = qml.transforms.make_tape(circuit)(0.1)
 
 
 ############################################
@@ -366,7 +390,7 @@ class TestQFuncTransformGradients:
     def circuit(param, *transform_weights):
         """Test QFunc"""
         qml.RX(0.1, wires=0)
-        TestQFuncTransformGradients.my_transform(*transform_weights)(
+        TestQFuncTransformGradients.my_transform(*transform_weights)(  # pylint:disable=not-callable
             TestQFuncTransformGradients.ansatz
         )(param)
         return qml.expval(qml.PauliZ(1))