Q# Operations and Functions

Q# programs consist of one or more operations that describe side effects quantum operations can have on quantum data, and one or more functions that allow modifications to classical data. In contrast to operations, functions are used to describe purely classical behavior and do not have any effects besides computing classical output values.

Each operation defined in Q# may then call any number of other operations, including the built-in intrinsic operations defined by the language. The particular way in which these intrinsic operations are defined depends on the target machine. When compiled, each operation is represented as a .NET class type that can be provided to target machines.

Defining New Operations

As described above, the most basic building block of a quantum program written in Q# is an operation, which can either be called from classical .NET applications, e.g., using a simulator, or by other operations within Q#. Each operation takes an input, produces an output, and specifies the implementation for one or more operation specializations. For instance, the following operation defines only a default body specialization and takes a single qubit as its input, then calls the built-in X operation on that input:

operation BitFlip(target : Qubit) : Unit {
    X(target);
}

The keyword operation begins the operation definition, and is followed by the name; here, BitFlip. Next, the type of the input is defined as Qubit, along with a name target for referring to the input within the new operation. Similarly, the Unit defines that the operation's output is empty. This is used similarly to void in C# and other imperative languages, and is equivalent to unit in F# and other functional languages.

Note

We will explore this in more detail below, but each operation in Q# takes exactly one input and returns exactly one output. Multiple inputs and outputs are then represented using tuples, which collect multiple values together into a single value. Informally, we say that Q# is a "tuple-in tuple-out" language. Following this concept, () should then be read as the "empty" tuple, which has the type Unit.

Within the new operation, the implementation can be specified directly within the declaration if only the implementation of the default body specialization needs to be specified explicitly. Additionally, it is possible to define the implementations of, for example, one or more functor operations, as elaborated below. In the example above, the only statement is to call the built-in Q# operation X.

Operations can also return more interesting types than Unit. For instance, the M operation returns an output of type Result, representing having performed a measurement. We can either pass the output from an operation to another operation, or can use it with the let keyword to define a new variable.

This allows for representing classical computation that interacts with quantum operations at a low level, such as in superdense coding:

operation Superdense(here : Qubit, there : Qubit) : (Result, Result) {

    CNOT(there, here);
    H(there);

    let firstBit = M(there);
    let secondBit = M(here);

    return (firstBit, secondBit);
}

Functors, adjoint and controlled

If an operation implements a unitary transformation, then it is possible to define how the operation acts when adjointed or controlled. An adjoint specialization of an operation specifies how it acts when run in reverse, while a controlled specialization specifies how an operation acts when applied conditioned on the state of a quantum register. The existence of these specializations can be declared as part of the operation signature: is Adj + Ctl in the following example. The corresponding implementation for each such implicitly declared specialization is then generated by the compiler.

operation PrepareEntangledPair(here : Qubit, there : Qubit) : Unit {
is Adj + Ctl { // implies the existence of an adjoint, a controlled, and a controlled adjoint specialization
    H(here);
    CNOT(here, there);
}

Note

Many operations in Q# represent unitary gates. If $U$ is the unitary gate represented by an operation U, then Adjoint U represents the unitary gate $U^\dagger$.

In the case where the implementation cannot be generated by the compiler, it can be explicitly specified. Such explicit specialization declarations can consist of a suitable generation directive or a user defined implementation. The code in PrepareEntangledPair above for example is equivalent to the code below containing explicit specialization declarations:

operation PrepareEntangledPair(here : Qubit, there : Qubit) : Unit {
    body (...) { // default body specialization
        H(here);
        CNOT(here, there);
    }

    adjoint auto; // auto-generate adjoint specialization
    controlled auto; // auto-generate controlled specialization
    controlled adjoint auto; // auto-generate controlled adjoint specialization
}

The keyword auto indicates that the compiler should determine how to generate the specialization implementation. If the compiler cannot generate the implementation for a certain specialization automatically, or if a more efficient implementation can be given, then the implementation may also be manually defined.

operation PrepareEntangledPair(here : Qubit, there : Qubit) : Unit
is Ctl + Adj {
    body (...) { // default body specialization
        H(here);
        CNOT(here, there);
    }

    controlled (cs, ...) { // user defined implementation for the controlled specialization
        Controlled H(cs, here);
        Controlled X(cs + [here], there);
    }

    adjoint invert; 
    controlled adjoint invert; 
}

In the example above, adjoint invert; indicates that the adjoint specialization is to be generated by inverting the body implementation, and controlled adjoint invert; indicates that the controlled adjoint specialization is to be generated by inverting the given implementation of the controlled specialization.

We will see more examples of this in Higher-Order Control Flow.

To call a specialization of an operation, use the Adjoint or Controlled keywords. For example, the superdense coding example above can be written more compactly by using the adjoint of PrepareEntangledPair to transform the entangled state back into an unentangled pair of qubits:

operation Superdense(here : Qubit, there : Qubit) : (Result, Result) {
    Adjoint PrepareEntangledPair(there, here);

    let firstBit = M(there);
    let secondBit = M(here);

    return (firstBit, secondBit);
}

There are a number of important limitations to consider when designing operations for use with functors. Most critically, specializations for an operation that uses the output value of any other operation cannot be auto-generated by the compiler, as it is ambiguous how to reorder the statements in such an operation to obtain the same effect.

Defining New Functions

Q# also allows for defining functions, which are distinct from operations in that they are not allowed to have any effects beyond calculating an output value. In particular, functions cannot call operations, act on qubits, sample random numbers, or otherwise depend on state beyond the input value to a function. As a consequence, Q# functions are pure, in that they always map the same input values to the same output values. This allows the Q# compiler to safely reorder how and when functions are called when generating operation specializations.

Defining a function works similarly to defining an operation, except that no adjoint or controlled specializations can be defined for a function. For instance:

function Square(x : Double) : (Double) {
    return x * x;
}

Whenever it is possible to do so, it is helpful to write out classical logic in terms of functions rather than operations, so that it can be more readily used from within operations. If we had written Square as an operation, for example, then the compiler would not have been able to guarantee that calling it with the same input would consistently produce the same outputs.

To underscore the difference between functions and operations, consider the problem of classically sampling a random number from within a Q# operation:

operation U(target : Qubit) : Unit {

    let angle = RandomReal()
    Rz(angle, target)
}

Each time that U is called, it will have a different action on target. In particular, the compiler cannot guarantee that if we added an adjoint auto specialization declaration to U, then U(target); Adjoint U(target); acts as identity (that is, as a no-op). This violates the definition of the adjoint that we saw in Vectors and Matrices, such that allowing to auto-generate an adjoint specialization in an operation where we have called the operation RandomReal would break the guarantees provided by the compiler; RandomReal is an operation for which no adjoint or controlled version exists.

On the other hand, allowing function calls such as Square is safe, in that the compiler can be assured that it only needs to preserve the input to Square in order to keep its output stable. Thus, isolating as much classical logic as possible into functions makes it easy to reuse that logic in other functions and operations alike.

Control Flow

Within an operation or function, each statement executes in order, similar to most common imperative classical languages. This flow of control can be modified, however, in three distinct ways:

  • if Statements
  • for Loops
  • repeat-until Loops

We defer discussion of the latter until we discuss Repeat Until Success (RUS) circuits. The if and for control flow constructs, however, proceed in a familiar sense to most classical programming languages. In particular, an if statement can take a condition, may be followed by one or more elif statements, and may end with an else:

if (pauli == PauliX) {
    X(qubit);
} elif (pauli == PauliY) {
    Y(qubit);
} elif (pauli == PauliZ) {
    Z(qubit);
} else {
    fail "Cannot use PauliI here.";
}

Similarly, for loops indicate iteration over a range of integers or over the elements of an array:

for (idxQubit in 0..nQubits - 1) {
    // Do something to idxQubit...
}

Importantly, for loops and if statements can even be used in operations for which specializations are auto-generated. In that case the adjoint of a for loop reverses the direction and takes the adjoint of each iteration. This follows the "shoes-and-socks" principle: if you wish to undo putting on socks and then shoes, you must undo putting on shoes and then undo putting on socks. It works decidedly less well to try and take your socks off while you're still wearing your shoes!

Operations and Functions as First-Class Values

One critical technique for reasoning about control flow and classical logic using functions rather than operations is to utilize that operations and functions in Q# are first-class. That is, they are each values in the language in their own right. For instance, the following is perfectly valid Q# code, if a little indirect:

operation FirstClassExample(target : Qubit) : Unit {
    let ourH = H;
    ourH(target);
}

The value of the variable ourH in the snippet above is then the operation H, such that we can call that value like any other operation. This allows us to write operations that take operations as a part of their input, forming higher-order control flow concepts. For instance, we could imagine wanting to "square" an operation by applying it twice to the same target qubit.

operation ApplyTwice(op : (Qubit => Unit), target : Qubit) : Unit {
    op(target);
    op(target);
}

In this example, the => arrow that appears in the type (Qubit => Unit) denotes that the input field op is an operation which takes as its input the type Qubit and produces an empty tuple as its output. Additionally we specify the characteristics of that operation type, which contain the information about which functors are supported. An operation of type (Qubit => Unit) supports neither the Adjoint nor the Controlled functor. If we want to indicate that an operation of that type has to support e.g. the Adjoint functor, we have to declare it as being adjointable. This is done by using the annotation is Adj to the type. Similarly, (Qubit => Unit is Ctl) denotes that an operation of that type supports the Controlled functor. We will explore this further when we discuss [types in Q#] (xref:microsoft.quantum.language.type-model) more generally.

For now, we emphasize that we can also return operations as a part of outputs, such that we can isolate some kinds of classical conditional logic as a classical function which returns a description of a quantum program in the form of an operation. As a simple example, consider the teleportation example, in which the party receiving a two-bit classical message needs to use the message to decode their qubit into the proper teleported state. We could write this in terms of a function that takes those two classical bits and returns the proper decoding operation.

function TeleporationDecoderForMessage(hereBit : Result, thereBit : Result)
: (Qubit => Unit is Adj + Ctl) {

    if (hereBit == Zero && thereBit == Zero) {
        return I;
    } elif (hereBit == One && thereBit == Zero) {
        return X;
    } elif (hereBit == Zero && thereBit == One) {
        return Z;
    } else {
        return Y;
    }
}

This new function is indeed a function, in that if we call it with the same values of hereBit and thereBit, we will always get back the same operation. Thus, the decoder can be safely run inside operations without having to reason about how the decoding logic interacts with the definitions of the different operation specializations. That is, we have isolated the classical logic inside a function, guaranteeing to the compiler that the function call can be reordered with impunity so long as the input is preserved.

We can also treat functions as first-class values, as we will see in more detail when we discuss operations and function types.

Partially Applying Operations and Functions

We can do significantly more with functions that return operations by using partial application, in which we can provide one or more parts of the input to a function or operation without actually calling it. For example, recalling the ApplyTwice example above, we can indicate that we don't want to specify, right away, to which qubit the input operation should apply:

operation PartialApplicationExample(op : (Qubit => Unit), target : Qubit) : Unit {
    let twiceOp = ApplyTwice(op, _);
    twiceOp(target);
}

In this case, the local variable twiceOp holds the partially applied operation ApplyTwice(op, _), where parts of the input that have not yet been specified are indicated by _. When we actually call twiceOp in the next line, we pass as input to the partially applied operation all of the remaining parts of the input to the original operation. Thus, the above snippet is effectively identical to having called ApplyTwice(op, target) directly, save for that we have introduced a new local variable that allows us to delay the call while providing some parts of the input.

Since an operation that has been partially applied is not actually called until its entire input has been provided, we can safely partially apply operations even from within functions.

function SquareOperation(op : (Qubit => Unit)) : (Qubit => Unit) {
    return ApplyTwice(op, _);
}

In principle, the classical logic within SquareOperation could have been much more involved, but it is still isolated from the rest of an operation by the guarantees that the compiler can offer about functions. This approach will be used throughout the Q# standard library for expressing classical control flow in a way that can be readily used within quantum programs.