Protocols¶
Protocols were introduced to Guppy in version 1.0.
A protocol is a named set of typed methods that a type must implement. This was introduced to Python to support “duck typing”, wherein all that is required to call a method on a Python object is for it to implement the method at the right type.
Defining a protocol¶
In Guppy, protocols are defined using a decorator around a Python class with no fields. The methods required by the protocol should be added to the class, wrapped with a @guppy.require decorator.
from guppylang import guppy
from guppylang.std.builtins import nat, owned
from typing import Self
@guppy.protocol
class MyProto:
@guppy.require
def foo(self, nat) -> nat: ...
Note that all protocol methods must take a self arg - the class that implements the protocol - as a first argument.
Protocols can take type arguments, as can their required methods:
@guppy.protocol
class MyProto[T]:
@guppy.require
def foo[S, T](self, S) -> T: ...
Note
The bodies of the methods declared with @guppy.require must be empty (literally ...). Default implementations of protocol methods is not currently supported in Guppy.
Implementing a protocol¶
Any Guppy type with implementations of all of a protocol’s required methods implements the protocol. No explicit syntax needs to be added to register a class as implementing a protocol. This flexibility is referred to as structural polymorphism.
For example:
@guppy.protocol
class Duck:
@guppy.require
def quack(self) -> str: ...
Any class that implements the quack method with the right type signature will then automatically implement the Duck protocol.
@guppy.struct(frozen=True)
class LittleGrebe:
@guppy
def quack(self) -> str:
return "weet-weet-weet"
Here, since LittleGrebe implements all of the required methods of the Duck protocol with the right signature, we can pass it into any function requiring a Duck
@guppy
def consume_duck_and_quack[T: Duck](t: T) -> str:
return t.quack()
@guppy
def main() -> str:
return consume_duck_and_quack(LittleGrebe())
main.check()
Builtin methods¶
Protocol methods can be implemented by existing methods on built-in types too, including dunder methods. For example:
@guppy.protocol
class Addable:
@guppy.require
def __add__(self, other: Self) -> Self: ...
@guppy.require
def __radd__(self, other: Self) -> Self: ...
@guppy
def add_two[T: Addable](x: T, y: T) -> T:
return x.__add__(y)
@guppy
def main() -> None:
add_two(1.0, 2.0) # Works automatically for `float`
add_two(3, 4) # Works automatically for `int`
Note
Unfortunately, this doesn’t allow us to use the syntactic sugar x + y in add_two.
Taking a protocol arguments¶
As shown above, protocols are written as bounds on type arguments using type parameter syntax. Naturally this means we can add multiple protocol bounds to a type argument, like
@guppy
def foo[T: (ProtoA, ProtoB, Copy, Drop)](t: T) -> ...
Single protocols can also be written in place of types as a form of syntax sugar, like
@guppy
def foo(T: MyProto) -> ...
which will be translated by Guppy into
@guppy
def foo[T: MyProto](t: T) -> ...
Example: State Preparation¶
Let’s see an example of using protocols to make an interface for the state preparation part of an experiment:
from guppylang import guppy
from guppylang.std.builtins import array, Function, nat, output, qubit
from guppylang.std.quantum import measure_array, x
@guppy.protocol
class StatePrep[N: nat]:
@guppy.require
def prep(self) -> array[qubit, N]: ...
@guppy
def process[N: nat](
p: StatePrep[N], hadamard_test: Function[[array[qubit, N]], None]
) -> None:
"""Run the algorithm using the given state prep circuit."""
qs = p.prep()
hadamard_test(qs)
b = measure_array(qs)[0]
output("ancilla_measurement", b.read())
# A default state prep routine that does nothing to the qubits
@guppy.struct(frozen=True)
class DummyStatePrep[N: nat]:
@guppy
def prep(self) -> array[qubit, N]:
return array(qubit() for _ in range(N))
@guppy.struct(frozen=True)
class MyStatePrep:
@guppy
def prep(self) -> array[qubit, 4]:
qs: array[qubit, 4] = array(qubit() for _ in range(4))
x(qs[0])
x(qs[1])
return qs
@guppy
def run_experiment(hadamard_test: Function[[array[qubit, 4]], None]) -> None:
process(DummyStatePrep[4](), hadamard_test)
process(MyStatePrep(), hadamard_test)
run_experiment.check()
Callable¶
As of Guppy v1.0, the Callable type in guppy is treated as an interface.
This means that taking a Callable argument in a function will have that function require a type parameter that implements the Callable protocol.
This protocol behaves differently to user-defined protocols because it’s not currently possible in guppy to correctly write the type signature of __call__.
Thus, there are special cases for guppy functions (e.g. which have type Function[[A,B],C]), and modified versions of those functions.