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 begining 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 abosolute path of the isQ file, from lower to higher hierachies. 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 qulified 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. The user 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 defination 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 direclty 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 four 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 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 four primitive type supports 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 negtive integers. In that case, any field cannot be omitted. For example, a[2:0:-1] refers to a[2] and a[1].


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 d;
}

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 paratheses around a bool value results in the same bool value.


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. When doing measurement, the result must be stored in an int variable.

basic operation

isQ supports some basic gate: X, Y, Z, H, S, T, Rx, Ry, Rz, CNOT, Toffoli, U3 (see this link for details), and two non-unitary operation: M(measure), |0>(set qubit to |0>). 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]);
    int x = M(q);

The measurement result of a qbit array or slice is an int value where the measurement result of the first qbit is used as the lowest-order bit. In the previous example, if the measurement result of q[0], q[1], and q[2] are true, true, and false, respectively, x would be 3 (i.e., 011).

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

For example, we can define a 2-qubit gate and use it like this:

// define gate Rs
defgate Rs = [
    0.5+0.8660254j,0,0,0;
    0,1,0,0;
    0,0,1,0;
    0,0,0,1
];

qbit q[2];

unit main() {
    // apply Rs on q;
    Rs(q[0], q[1]);
}

If all the non-zero elements in the unitary is 1, i.e., the gate maps compuatational bases to a permutation of compuatational 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$.

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 bases 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>;
}


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 paramters 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.

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

// proceduer with two classical paramters 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,

// proceduer with two qubits and a two-qubit-gate procedure as paramters 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 quantum gate, so that user 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> -> |110>
    X(q[0]);
    X(q[1]);
    // controlled-swap, set |q> -> |101> 
    ctrl swap(q[0], q[1], q[2])
}

When a qbit procedure argument is of an arry 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. User can uses a non-defined variable as the iterating variable, and use two integer value a, b as loop range [a, b), where a must be less than b. The step size is optional and defaulted to 1. 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 correspoding 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. But it requires users to calculate the whole matrix. To simplify this work, isQ provide gate modifiers like some other quantum programming languages.

There are three gate modifiers in isQ:

  • ctrl: modify gate to controlled-gate, can has a paramter representing the number of control qubits(defualt is 1).
  • nctrl: modify gate to neg-controlled-gate, can has a paramter representing the number of control qubits(defualt 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);
}

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 Rx(pi, q[1], q[0], p);

Oracle

value table

isQ supports simple quantum oracle definitions. For those simple oracles whose function like $f: \{0,1\}^n \rightarrow \{0,1\}^m$, 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