Arrays

Basic properties

In Guppy, an array is an ordered collection of objects of the same type, with a size that is fixed and known at compile time. These two properties distinguish arrays from Python lists.

Arrays are mutable: their values can be reassigned at runtime.

An array can be created using the array constructor. The type signature is array[T, n] where T is the type of the data and n is the size of the array.

from guppylang import guppy
from guppylang.std.builtins import array

@guppy
def get_array() -> array[int, 3]:
    return array(0, 2, 4)

Note that in Guppy it is necessary to annotate both the type and the size of an array in function signatures.

Array entries can be changed as follows:

@guppy
def mutate_array() -> array[int, 3]:
    numbers = get_array() # Create array containing 0, 2 and 4
    numbers[0] = 17 # Change first element to 17
    return numbers # Return modified array

mutate_array.check()

Arrays can also be nested, meaning that the elements of an array can themselves be arrays.

@guppy
def get_array_of_arrays() -> array[array[int, 4], 3]:
    return array(array(1, 2, 3, 4), array(2, 4, 6, 8), array(3, 6, 9, 12))

get_array_of_arrays.check()

Frozenarrays

Note that in addition to the standard array type, there is also frozenarray which is immutable.

Currently frozenarrays can only be created when loading a Python list in a comptime or py expression. For more on comptime expressions, see the relevant language guide section.

As frozenarray is immutable we cannot reassign its entries as we can with the array type.

from guppylang.std.array import frozenarray
from guppylang.std.builtins import comptime

@guppy
def mutate_frozenarray() -> frozenarray[int, 5]:
    numbers = comptime([1, 3, 5, 7, 9]) # Create a frozenarray from a Python list
    numbers[0] = 39 # Try to change first element to 39
    return numbers

mutate_frozenarray.check()
Error: Unsupported (at <In[4]>:7:4)
  | 
5 | def mutate_frozenarray() -> frozenarray[int, 5]:
6 |     numbers = comptime([1, 3, 5, 7, 9]) # Create a frozenarray from a Python list
7 |     numbers[0] = 39 # Try to change first element to 39
  |     ^^^^^^^^^^ Subscript assignments to non-arrays are not supported

Guppy compilation failed due to 1 previous error

Note that it is preferable to use frozenarray (rather than a mutable array) where possible for performance reasons. Being immutable, a frozenarray will compile faster and have superior runtime performance when targeting Quantinuum systems hardware and emulators.

An example use case for a frozenarray would be for lookup table decoders in quantum error correction. For example, we could precompute a large numpy array of integers which represent syndromes and their corresponding corresponding corrections. This array can then be loaded into a Guppy context as a comptime list. We can then have read only access to our table during the runtime of our quantum program.

Indexing into arrays

As in Python, Guppy indices start from zero. In the array arr = array(0, 2, 4) we can access the element 0 with arr[0], 4 with arr[2], and so on.

Warning

Although the size of an array is known at compile time, the index may not be. If an index computed at runtime is out of bounds, a runtime error will occur.

If our index is an integer literal, the Guppy compiler can detect when the index is out of bounds and give an error.

from guppylang.std.quantum import h, qubit

@guppy
def index_out_of_bounds1() -> array[qubit, 3]:
    qs = array(qubit() for _ in range(3)) # Allocate an array of length 3
    h(qs[3]) # Access index 3, only (0, 1, 2) indices are within bounds
    return qs

index_out_of_bounds1.check() # Out of bounds error given

Note that there are some limitations to this bounds checking. If we write the index as an expression i.e. qs[2+1] then the compiler is not able to detect that the index is out of bounds. Also if we assign the value 3 to a variable x then qs[x] will pass the type check.

@guppy
def index_out_of_bounds2() -> array[qubit, 3]:
    qs = array(qubit() for _ in range(3)) # Allocate an array of length 3
    x = 3 # Assign 3 to a variable
    h(qs[x]) # Index using the variable x
    h(qs[1 + 2]) # Index is an arithmetic expression
    return qs

index_out_of_bounds2.check() # No out of bounds error given

Array comprehensions

We can use array comprehension to create an array object without specifying all of its elements individually. This is especially useful for dealing with large arrays.

Syntactically, Guppy comprehensions are similar to list comprehensions in Python.

@guppy
def get_first_four_squares() -> array[int, 4]:
    return array(x*x for x in range(4))

get_first_four_squares.check()

Note that as the size of an array has to be statically known we cannot generalize this function using a generic variable.

from guppylang.std.num import nat

n = guppy.nat_var("n")

@guppy
def get_first_n_squares(n: nat) -> array[int, n]:
    return array(x*x for x in range(n))

get_first_n_squares.check()
Error: Array comprehension with nonstatic size (at <In[8]>:7:16)
  | 
5 | @guppy
6 | def get_first_n_squares(n: nat) -> array[int, n]:
7 |     return array(x*x for x in range(n))
  |                 ^^^^^^^^^^^^^^^^^^^^^^^ Cannot infer the size of this array comprehension ...

Note:
  | 
6 | def get_first_n_squares(n: nat) -> array[int, n]:
7 |     return array(x*x for x in range(n))
  |                               -------- since the number of elements yielded by this iterator is not
  |                                        statically known

Guppy compilation failed due to 1 previous error

Note

Note that we can generalize this function provided that the value of n is known at compile time. See the section on comptime arguments.

For more background on Guppy’s static type checker see the section on Static Compilation and Typing.

We cannot use conditional statements in array comprehensions as their values generally can’t be known at compile time.

@guppy
def filter_squares_by_divisor(divisor: int) -> array[int, 3]:
    squares = get_first_four_squares()
    return array(x for x in squares if x % divisor == 0) # Size cannot be determined statically

filter_squares_by_divisor.check()
Error: Array comprehension with nonstatic size (at <In[9]>:4:16)
  | 
2 | def filter_squares_by_divisor(divisor: int) -> array[int, 3]:
3 |     squares = get_first_four_squares()
4 |     return array(x for x in squares if x % divisor == 0) # Size cannot be determined statically
  |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Cannot infer the size of this array comprehension ...

Note:
  | 
3 |     squares = get_first_four_squares()
4 |     return array(x for x in squares if x % divisor == 0) # Size cannot be determined statically
  |                                        ---------------- since it depends on this condition

Guppy compilation failed due to 1 previous error

Array unpacking

The elements within a Guppy array can be accessed via unpacking similarly to Python tuples. To see how unpacking works for Guppy tuples see the tuple unpacking section.

We can use the * operator to unpack multiple elements.

@guppy
def make_array() -> array[int, 4]:
    return array(5, 10, 15, 20)

@guppy
def unpack_tail(arr: array[int, 4]) -> tuple[int, array[int, 3]]:
    first, *tail = make_array()
    return first, tail


unpack_tail.check()

A current limitation of array unpacking is that it is not supported for arrays of generic length.

Note

Note that it in Guppy it is possible to unpack any iterable type.

For example we can unpack a Range as follows first, *tail = range(10).

Moving and copying arrays

Guppy arrays are affine, meaning their value can be used once or not at all. Assignment of arrays does not copy their values into a new array, but just moves the reference.

@guppy
def make_big_array() -> array[int, 96]:
    return array(x*x for x in range(96))

@guppy
def main() -> None:
    arr1 = make_big_array()
    arr2 = arr1 # Move the value arr1 to arr2
    arr1[1] = 17 # Compiler error, arr1 cannot be indexed into after the move

main.check() 
Error: Copy violation (at <In[11]>:9:4)
  | 
7 |     arr1 = make_big_array()
8 |     arr2 = arr1 # Move the value arr1 to arr2
9 |     arr1[1] = 17 # Compiler error, arr1 cannot be indexed into after the move
  |     ^^^^^^^ Variable `arr1` with non-copyable type `array[int, 96]`
  |             cannot be borrowed ...

Note:
  | 
7 |     arr1 = make_big_array()
8 |     arr2 = arr1 # Move the value arr1 to arr2
  |            ---- Variable `arr1` already moved here

Help: Consider copying `arr1` instead of moving it: `arr1.copy()`

Guppy compilation failed due to 1 previous error

Assignment of an array to the new arr2 variable moves the value of arr1 to arr2. The value of arr1 cannot be used after it is moved.

Arrays can still be copied explicitly using the array.copy() method if they contain objects with a copyable type.

@guppy
def main() -> None:
    arr1 = make_big_array()
    arr2 = arr1.copy() # Explicitly copy arr1 and assign to arr2
    arr1[95] = 419 # arr1 can still be used as it hasn't been moved

main.check()  

Explicit copying is a design choice with performance implications. Arrays can be large, and copying can be a significant memory overhead. Array copying therefore has to be explicitly opted into via the array.copy() method rather than done implicitly with variable assignment.

Note that arrays cannot be copied after a move.

@guppy
def main() -> None:
    arr1 = make_big_array()
    arr2 = arr1 # Move the value arr1 to arr2
    arr3 = arr1.copy() # Compiler error

main.check()
Error: Copy violation (at <In[13]>:5:11)
  | 
3 |     arr1 = make_big_array()
4 |     arr2 = arr1 # Move the value arr1 to arr2
5 |     arr3 = arr1.copy() # Compiler error
  |            ^^^^ Variable `arr1` with non-copyable type `array[int, 96]`
  |                 cannot be borrowed ...

Note:
  | 
3 |     arr1 = make_big_array()
4 |     arr2 = arr1 # Move the value arr1 to arr2
  |            ---- Variable `arr1` already moved here

Help: Consider copying `arr1` instead of moving it: `arr1.copy()`

Guppy compilation failed due to 1 previous error

Arrays of non-copyable types, such as qubits, cannot be copied. Also if an array contains qubits, it cannot be implicitly discarded. It must be discarded explicitly with the discard_array function to avoid violating linearity.

Nested arrays cannot be copied directly. A two-dimensional array can be copied via comprehension as follows.

@guppy
def make_2d_array() -> array[array[int, 3], 3]:
    return array(array(1, 2, 3), array(1, 4, 9), array(1, 8, 27))

@guppy
def main() -> None:
    arr = make_2d_array()
    # arr.copy() # would give a compiler error
    copied_arr = array(inner.copy() for inner in arr)
    copied_arr[1][1] = 31
    

main.check()

Note that for loops currently take ownership of the iterable, which is useful to keep in mind when you are iterating directly over arrays as opposed to using subscripts:

from guppylang.std.builtins import owned

m = guppy.nat_var("m")

@guppy
def f(x: int) -> None: 
    pass

@guppy
def apply_f(xs: array[int, m] @owned) -> array[int, m]:
    for x in xs:
        f(x)
    return xs

apply_f.check()
Error: Copy violation (at <In[15]>:13:11)
   | 
11 |     for x in xs:
12 |         f(x)
13 |     return xs
   |            ^^ Variable `xs` with non-copyable type `array[int, m]` cannot
   |               be returned ...

Note:
   | 
10 | def apply_f(xs: array[int, m] @owned) -> array[int, m]:
11 |     for x in xs:
   |              -- Variable `xs` already consumed here

Help: Consider copying `xs` instead of moving it: `xs.copy()`

Guppy compilation failed due to 1 previous error

Explicit copying can come in handy here, if it is possible to do with the array that is being iterated over:

@guppy
def apply_f(xs: array[int, m] @owned) -> array[int, m]:
    for x in xs.copy():
        f(x)
    return xs

apply_f.check()

A frozenarray can be copied with the frozenarray.mutable_copy method.

from guppylang.std.array import frozenarray
from guppylang.std.builtins import comptime

@guppy
def main() -> None:
    # Create a frozenarray using a comptime expression
    frozen_arr = comptime([1, 11, 21])

    # Copy the frozenarray
    arr_copy: array[int, 3] = frozen_arr.mutable_copy()

    # The arr_copy object is mutable
    arr_copy[0] = 171 

main.check()

Note that the return type of frozenarray.mutable_copy is of type array.

Extracting individual elements

There are cases where you may want to work with individual array elements in a way that requires ownership, without consuming the entire array. One approach is to use arrays of options, see the T Factory and Option type example.

Another approach is to use the take and put operations. Regular array indexing only allows borrowing an element, while take extracts an element and transfers ownership to the caller. The complementary operation, put, inserts an element back into the array. These operations are inherently unsafe and may panic at runtime if you try to access an element that was previously taken, or if you try to put an element at an index that already contains one. They also panic if the provided index is negative or out of bounds.

from guppylang.std.quantum import measure

@guppy
def main(qs: array[qubit, 5]) -> None:
    q = qs.take(3)
    measure(q)
    qs.put(qubit(), 3)
    h(qs[3])

main.check()

To reduce the risk of panics, you can use try_take and try_put. The try_take function returns an Option: instead of panicking when an element has already been taken out, it returns nothing (some on success). The try_put function returns a Result: you get an err value if there is already an element at the given index (ok on success). The is_borrowed function is useful for checking whether an element has been taken out of an array, either through take / try_take or because a non-copyable element is currently borrowed.

from guppylang.std.quantum import discard, discard_array

@guppy
def main() -> None:
    qs = array(qubit() for _ in range(10))
    result("init", qs.is_borrowed(3))  # False

    # We can't put anything at an index that hasn't been borrowed
    q = qubit()
    # So `try_put` returns `q` unchanged inside an error result value
    q = qs.try_put(q, 3).unwrap_err()
    discard(q)

    # We can't take out stuff that's already been borrowed
    q = qs.take(3)
    result("after_take", qs.is_borrowed(3))  # True
    # So `try_take` returns nothing because of the `take` above
    qs.try_take(3).unwrap_nothing()
    measure(q)

    # But we can put something back at a borrowed index
    qs.put(qubit(), 3)
    result("after_put", qs.is_borrowed(3))  # False
    h(qs[3])

    discard_array(qs)


main.emulator(11).coinflip_sim().run().results[0].entries
[('init', 0), ('after_take', 1), ('after_put', 0)]

Example usage of arrays

To see some uses of arrays in practice, refer to the following examples:

  1. Canonical Quantum Phase Estimation

  2. GHZ and Graph State Preparation

  3. T Factory