Skip to content

isQ Programming Language

This section mainly introduces isQ's grammar which is divided into the following parts:


Package

Each isQ file belongs to a package. The root of the package is specified with keyword package at the beginning of the file. For example, the first lines of /some/path/aaa/bbb/aaa/ccc.isq are

// /some/path/aaa/bbb/aaa/ccc.isq
package aaa;
It means that the root of the package is /some/path/aaa/bbb/aaa. The root is matched with file or folder name in the absolute path of the isQ file, from lower to higher hierarchies. In this example, there are two folders of the name aaa. The root is the one closer to the file. If the specified package name cannot match any file or folder name, the compiler will report an error. If no package is specified, the default package name is the file name.

The global variables and procedures can be referred to with qualified names from another isQ file, after being imported. Continue with the previous example,

// /some/path/aaa/bbb/aaa/ccc.isq
package aaa;
int g;
The global variable g can be referred to as aaa.ccc.g. This qualified name is constructed with the relative path from the package root (replacing / with .) plus the variable name.


Import

An isQ file can use the variables and procedures defined in another isQ file by importing it. The syntax is like this example:

// /some/path/aaa/bbb/ddd/eee.isq
package ddd;
import aaa.ccc; // /some/path/aaa/bbb/aaa/ccc.isq
import std;     // ${ISQ_ROOT}/lib/std.isq
The root of this file is /some/path/aaa/bbb/ddd which is placed in the same folder (i.e., /some/path/aaa/bbb/) as the example in the Package part. It specifies the imported file with the relative path from the parent folder of the root, replacing / with . and omiting the .isq suffix. Therefore, the above line imports /some/path/aaa/bbb/aaa/ccc.isq. Users can also uses the relative path from the isQ library, which is specified by environmental variable ${ISQ_ROOT}/lib. A standard library file, std.isq, is placed in this folder. It contains the definition of basic gates such as H and X. Therefore, it should be imported by nearly all the isQ files.

Once a file is imported, its global variables and procedures (except main) can be used directly. For example, the eee.isq mentioned above can use g directly in its program. However, if multiple variables that are defined in different imported files share the same name, they have to be referred to using qualified names, e.g., aaa.ccc.g and some.other.g.


Types

primitive types

isQ mainly supports five primitive types:

  • int: a 64-bit signed integer, e.g. -1, 1;
  • bool: a boolean value that can be either true or false;
  • double: a double precision floating point number, e.g. -0.1, 3.1415926;
  • unit: the return type of a procedure that returns nothing;
  • qbit: an opaque type that represents a quantum bit;

Users can use these type to define variables anywhere. All qubits are set to the default value |0>. For example, we could define variables like:

// global variable
int a, b = 4; // int variables
double d = pi; // double variable with initial value 3.14159...
qbit q; // qbit

procedure main() {    
    // local variable with initial value 3 
    int c = 3;
    ...
}
Note that we have defined a keyword pi to represent the value of .

array

All the five primitive types support one-dimensional array. When the array is defined with intialization values, no length should be provided. Otherwise, a length must be provided. The length of a global array must be a positive integer, while for a local variable, any int expression can be used as a length. The length of an array can be obtained using the operator .length.

qbit q[3]; // the length of a global array must be a positive integer

unit main() {
    // when an array is initilized, the length should not be specified
    int a[] = [1, 2]; 

    // a local variable may use expressions as length
    int b[a.length];
    ...
}

A slice of an array can be obtained by appending a [start:end:step] structure after the array. It is a subview of the original array, starting from the start position, increasing with step, and till the end (excluding). For example, a[2:4:1] refers to a[2] and a[3]. The three parts can be omitted, and the default values will be used. The default values of start, end, and step are 0, the length of the array, and 1, respectively. When the step is omitted, the second : can be omitted as well. For example, a[:2] represents the first two elements of a. The step can be negative integers. In that case, any field cannot be omitted. For example, a[2:0:-1] refers to a[2] and a[1], and a[a.length-1:-1:-1] refers to the whole array a in reversed order.


Classical Operation

isQ supports the following operators for classical types:

  • Arithmetic operators: +, -, *, /, ** (power), %
  • Comparison operators: ==, !=, >, <, >=, <=
  • Logical operators: &&, and, ||, or, !, not
  • Bitwise operators: &, |||, ^(XOR)
  • Shift operators: >>, <<

Their semantics, operand types and precedence are consistent with common programming languages such as C and Python.

isQ has built-in automatic type conversion, which can convert bool to int and int to double. Specifically, true is converted to 1 and false to 0. Moreover, isQ provides a print command to print int and double values. Users can do arithmetic and print like this:

unit main() {
    int a = 2 * (3 + 4) % 3;
    double d = 3.14 * a;

    print a;  // print 2
    print d;  // print 6.28
}

In addition, isQ provides an assert command, which operates with a bool value. If the value evaluates true during runtime, this command is omitted. Otherwise, the program aborts and reports this event. For example,

unit main() {
    assert true;    // OK
    assert(3 == 4); // Causing a program abort
}
Note that putting parentheses around a bool value results in the same bool value.

Pure classical expressions

Warning

This feature is highly experimental and subject to changes. In particular, evaluation of compile-time pure classical expressions may not terminate.

Since 0.2.7, isQ introduces a special type of pure (i.e. side-effect-free) classical computation, enabling users to write classical computation in a functional-programming style, in particular, enabling users to express more powerful compile-time computation.

The grammar of pure classical expressions is inspired by languages from ML family. Basic arithmetic operations are supported. Moreover, let-binding is allowed but the types must be annotated explicitly.

// isqc/tests/input/lambda/let_add.isq
unit main(){
    int a = let b: int = 1 in b+b;
    print a; // print 2.
}

Pure classical expressions allows users to define inline, recursive lambdas (a.k.a. functions) by using letrec. Again, all types must be annotated explicitly. By now lambdas are second-class and cannot capture any closure.

// isqc/tests/input/lambda/factorial.isq
unit main()
{
    int a = letrec factorial(n : int) -> int = 
            if n <= 0 then 1
            else n * factorial(n - 1)
        in factorial(4);
    print a;
}

What makes pure classical expressions powerful is that they can be treated as compilation-time constants, therefore be used in combination with templates.

unit fun<int N>(){
    print(N);
}
unit fun::<6>(){
    print(6666);
}
unit main() {
    fun::<(letrec collatz(x: int, n: int)-> int = 
        if x==1 
            then n 
            else if (x%2==0)
                then collatz(x/2, n+1)
                else collatz(x*3+1, n+1)
        in collatz(10, 0))>(); // template argument evaluates to 6.
    // 6666 will be printed.
}


Quantum Operation

The quantum operation in isQ is simple. Users can apply a gate or do measurement on qubits in a way similar to function call. All quantum operations are inside procedures and all qubits used are defined beforehand.

basic operation

isQ supports some basic gates: X, Y, Z, H, S, T, Rx, Ry, Rz, CNOT, Toffoli, U3 (see this link for details), and a non-unitary operation: M(measure). The measurement result of a qubit is a bool value. Users can directly use these gates like this:

qbit q[2];
unit main() {
    H(q[0]);
    CNOT(q[0], q[1]);
    bool x = M(q[0]);
    bool y = M(q[1]);
}

qbit array and slice can be used as the parameters of quantum gates and measurement. In this case, it represents applying the gates or measurement to all the qubits in the array or slice. For multiple-qubit gates, it means applying gates to each group of qubits. The number of gates depends on the shortest qbit array or slice. For example:

    qbit p[3], q[3];
    H(p);           // = H(p[0]); H(p[1]); H(p[2]);
    CNOT(p, q[:2]); // = CNOT(p[0], q[0]); CNOT(p[1], q[1]);

The measurement result of a qbit array or slice is an int value by interpreting the measurement outcomes as a binary number, where the first qbit's result is used as the least significant bit. For example:

    qbit p[3];
    X(p[0]);                         // p[0]=|1>, p[1]=|0>, p[2]=|0>   
    int x = M(p);                    // x = 1 (001)
    int y = M(p[p.length-1:-1:-1]);  // y = 4 (100)
    int z = M(p[1:-1:-1]);           // z = 2 (10)

defgate

isQ allows users to define gate by using keyword defgate and a matrix. An element in the matrix can be an int/double/complex value or an arithmetic expression. Complex value can be written in a way similar to Python. There are three points to note:

  • The user-defined gate should be unitary and its size should be a power of 2.
  • Users should define gate outside procedures and then use it inside.
  • A user-defined gate's name can not conflict with the names of built-in gates

Note that when a multi-qubit gate is called, the first qbit in the argument represents the most significant bit. For example, we can define a 2-qubit gate and use it like this:

// define MyCNOT
defgate MyCNOT = [
    1,0,0,0;
    0,1,0,0;
    0,0,0,1;
    0,0,1,0
];

qbit q[2];

unit main() {
    X(q[0]);             // q[0]=|1>, q[1]=|0>
    MyCNOT(q[0], q[1]);
    bool x = M(q[0]);    // q[0]=|1>
    bool y = M(q[1]);    // q[1]=|1>
}

If all the non-zero elements in the unitary is 1, i.e., the gate maps computational basis to a permutation of computational basis, you can use a simplified version with keyword perm. For example, the Toffoli gate can be defined as

defgate Tof(3) = perm [0, 1, 2, 3, 4, 5, 7, 6];

The syntax means that the 3-qubit gate Tof maps $|i\rangle$ to $|i\rangle$ for $0\leq i\leq5$, $|6\rangle$ to $|7\rangle$, and $|7\rangle$ to $|6\rangle$.

Now let us consider a slightly more complex example:

defgate MyGate(3) = perm [0, 3, 2, 5, 4, 1, 7, 6];

qbit q[3];

unit main() {
    X(q[2]);                   // q[0]=|0>, q[1]=|0>, q[2]=|1>
    MyGate(q[0], q[1], q[2]);  // |001> -> |011>  (perm[1]=3)
    bool x = M(q[0]);          // x = 0 ( q[0]=|0> )
    bool y = M(q[1]);          // y = 1 ( q[1]=|1> )
    bool z = M(q[2]);          // z = 1 ( q[2]=|1> )
    int w = M(q);              // w = 6 (110)
}

quantum state preparation

You can prepare a quantum state using an assignment. The left side of the assignment should be a qbit array with a fixed length. The right side can be two forms:

  • A complex number array that represents the amplitude of the desired state. This array will be padded with 0 if its length is smaller than the Hilbert space dimension. After that, the amplitudes will be normalized.

  • An expression consisting of kets such as (0.5+0.5j)|2>. Only computational basis states are supported currently.

import std;

procedure main()
{
    qbit q[2];

    // Initialize q in a Bell state using an array
    q = [1, 0, 0, -1];

    // or using ket expressions.
    q = |0> - |3>;
}

Note that the computational basis is coded in little endian. For example, qbit q[2]; q=|2>; results that q[1] is in $|1\rangle$ and q[0] in $|0\rangle$.


Procedure

The main body of isQ program is composed of procedures, which is similar to functions in C. The entry procedure is named main, which has no parameters and output.

Users can define their own procedures, and procedures may have no output or return with classical type. When a procedure has no output, it uses keyword procedure or unit at the beginning of definition, otherwise, it uses classical type. User-procedure can has parameters with any type mentioned above, and can be called by others or by itself.

// procedure with two qbit as parameters and has no return
// parameter can also be written like (a: qbit, b: qbit)
unit swap(qbit a, qbit b) {
    ...
}

// procedure with two classical parameters and return with a double
double compute(int a, double b[]) {
    double s = 0.0;
    ...
    return s;
}

// entry procedure
unit main() {
    qbit q[2];
    // call swap
    swap(q[0], q[1]);

    double b[2];
    // call compute
    double c = compute(1, b);
}

In addition, procedure itself can also serve as a parameter to another procedure. Such a parameter is specified as returnType identifier(TypeList), in which returnType and TypeList are the return type and the argument types of the procedure. For example,

// procedure with two qubits and a two-qubit-gate procedure as parameters and has no return.
// parameters can also be written like (a: qbit, b: qbit, two_bit_gate: (qbit, qbit)->unit)
unit circuit(qbit a, qbit b, unit two_bit_gate(qbit, qbit)) {
    H(a);
    H(b);
    two_bit_gate(a, b);
}

// two qubit gate procedure1
unit two_bit_1(qbit a, qbit b){
    ...
}

// two qubit gate procedure2
unit two_bit_2(qbit a, qbit b){
    ...
}

// entry procedure
unit main() {
    qbit q[2];
    // call circuit with two_bit_1
    circuit(q[0], q[1], two_bit_1);
    // call circuit with two_bit_2
    circuit(q[0], q[1], two_bit_2);
}

deriving

User-procedure can be purely quantum. Such kind of procedures work as gates, and we can do some operations on these procedures like on ordinary quantum gates, like modifiers.

isQ provides a keyword deriving gate, converting a procedure to a quantum gate, so that users can add modifiers when calling procedure. For example, if we need a controlled-swap, we can write codes like:

// pure quantum procedure that swap two qbit
unit swap(qbit a, qbit b) {
    CNOT(b, a);
    CNOT(a, b);
    CNOT(b, a);
} deriving gate

unit main() {
    qbit q[3];
    // set q[0]=|1>, q[1]=|1>, q[2]=|0>
    X(q[0]);
    X(q[1]);
    // controlled-swap, set q[0]=|1>, q[1]=|0>, q[2]=|1>
    ctrl swap(q[0], q[1], q[2])
}

When a qbit procedure argument is of an array type, its length must be fixed. For example, qbit a[3] is legal but qbit a[] is not.

template

A template represents a group of procedures. The syntax for defining and using a template is shown in the following example:

unit fun<int N>(){
    print N;
}

unit main(){
    fun::<2>();
    fun::<(3 + 1)>();
}

Note that expressions with operators must be wrapped with parenthesis ().

A normal procedure is generated based on the instantiation value before other compilation processes. Therefore, it may bypass some limitations of deriving gate, e.g.,

unit g<int N>(qbit a[N]){
    ...
} deriving gate

In addition, we can define some procedures for instantiating a template with particular values. This feature is helpful in defining the base case of recursive templates. For example, the factorial function can be implemented as follows.

int factorial<int N>() {
    return N * factorial::<(N - 1)>();
}

int factorial::<0>() {
    return 1;
}

When calling factorial::<0>(), the second procedure will be used instead of the first.


Control Flow

isQ provides four kinds of classical control flow:

  • if-statement
  • for-loop
  • while-loop
  • switch-case

if-else

If-else statement in isQ is similar to that in C. However, the braces {...} cannot be omitted, even if there is single statement following the condition.

unit main() {
    int a = 1;
    int b;
    if (a > 0) {
        b = 1;
    } else {
        b = -1;
    }

    bool c = true;
    if (c) {
        b = 2;
    }
}

for-loop

For-loop in isQ is similar to that in python. Users can employ an undefined variable as the iterator, and specify two integer values a and b as loop boundaries (inclusive start, exclusive end). The step size is optional and defaults to 1. Negative integers are also allowed as valid step sizes. Like for-loop in other languages, keywords break and continue are supported.

unit main() {
    int a = 0;

    for i in 0:10 {
        a = a + 1;
        if (a > 5) {
            break;   
        }
    }

    for j in 0:10:3 {
        a = a - 1;
    }
}

isQ also supports array iteration. For example,

unit main() {
    int a[] = [2, 3, 4];
    for v in a {
        print v;
    }
}
Printed results should be "2", "3", and "4".

while-loop

While-loop in isQ is similar to that in C. However, the braces {...} cannot be omitted, even if there is single statement following the condition.

unit main() {
    bool a = true;
    qbit q;
    while (a) {
        H(q);
        a = M(q);
    }
}

switch-case

Based on the value of an int-type variable, a switch-case selects a corresponding branch. The execution terminates before another case or default keyword. For example,

unit main() {
    int a = 1, b;
    switch a {
    case 1:
        b = 3;
    case 2:
        b = 4;
    default:
        b = 5;
    }
}
That is, the switch-case statement does not have the fall through semantics in some other languages such as C. Therefore, the final value of b would be 3, not 5. The switch-case statement does not support break as it is not needed.


Modifiers

Controlled gates are common in quantum circuit. As we introduced before, users can define gate, so theoretically, any controlled gate can be defined in this way. However, it requires users to calculate the whole matrix. To simplify this work, isQ provides gate modifiers like some other quantum programming languages.

There are three gate modifiers in isQ:

  • ctrl: modify gate to controlled-gate, can have a parameter representing the number of control qubits(default is 1).
  • nctrl: modify gate to neg-controlled-gate, can have a parameter representing the number of control qubits(default is 1).
  • inv: modify gate to conjugate transpose.

Controlled gates can be easily defined by ctrl and nctrl. And of course, you can compound these modifiers. For example:

unit main() {
    qbit p, q, r;

    // p ---●---
    //      |  
    // q ---S---
    ctrl S(p, q);

    // p ---●---
    //      |  
    // q ---●---
    //      |  
    // r ---S---
    ctrl<2> S(p, q, r);

    // q ---S^{-1}---
    inv S(q);

    // p --X--●--X---
    //        |  
    // q ---S^{-1}---
    nctrl inv S(p, q);

    // p --X--●--X---
    //        |  
    // q -----●------
    //        |  
    // r ---Rx(pi)---
    nctrl ctrl Rx(pi, p, q, r);
}

When a group of gates share the same control qubits, sometimes it is better to use a switch-case statement. For example:

    qbit q[2], p, r;
    switch q {
    case |3>:
        ctrl Rx(pi, r, p);
    default:
        H(p);
    }
It is equivalent to the following statements:
    nctrl nctrl H(q[1], q[0], p);
    nctrl ctrl H(q[1], q[0], p);
    ctrl nctrl H(q[1], q[0], p);
    ctrl ctrl ctrl Rx(pi, q[1], q[0], r, p);

Oracle

value table

isQ supports simple quantum oracle definitions. For those simple oracles whose function is like , keyword oracle can be used like this:

oracle g(2, 1) = [0, 1, 0, 0];

qbit q[3];

unit main() {
    ...
    g(q[2], q[1], q[0]);
    ...
}

In the above example, g is the oracle name, and there are two integer parameters n, m, where n represents the number of work qubits and m represents the number of ancilla qubits. The value list is $f(i)$ for $i$ in $[0, 2^n)$, and every $f(i)$ must be in $[0, 2^m)$. Usually, $m$ is 1, and the list is a truth table like above.

oracle function

Alternatively, you can define an oracle using a Boolean function. This function accepts one or more parameters with bool array type, and returns a bool array. For example, we can rewrite the oracle g as follows:

oracle bool[1] g(bool x[2]) {
    bool res[] = [x[0] && !x[1]];
    return res; 
}

The derived quantum gate applies to qbit arrays. For example:

unit main() {
    qbit p[2], q[1];
    ...
    g(p, q);
    ...
}

Parameter

isQ supports compiling with parameters and passing values at runtime. If you want to do this, you can define two arrays in the parameter list of the procedure main, and you can use them in the function body, like this:

unit main(int i_par[], double d_par[]) {
    ...
    Rx(d_par[0], q);
    if (i_par[1] == 2){...}
    ...
}

When simulating, you can pass values by -i or -d. isQ will gather values and generate int and double arrays (can be empty). For the above example, you can pass values like this:

isqc simulate -i 1 -i 2 -d 1.3 xxx.isq