Pointers

A Fortran pointer describes an association with target storage. The pointer descriptor records whether a target is present and, for arrays, its address, shape, and strides. It does not by itself say who owns that target.

Key Concepts

  • A pointer descriptor refers to target storage; it does not own that storage by default.
  • Scalar pointers appear as T | None; array pointers use live Pointer[T[...]] handles.
  • associated describes association, not ownership or target lifetime.
  • NumPy arrays returned by to_numpy() are live views, not copies.
  • Reassociation, resizing, or deallocation can invalidate existing views.
  • associate(other) makes two pointer handles refer to the same target without copying it.
  • Use deallocate() only if this pointer was used to create its current target with allocate(). Otherwise, use nullify().
  • close() releases a returned or caller-created descriptor, not its target.

When To Use A Pointer Handle

Use Pointer[T[...]] when the native callable needs the pointer descriptor:

from prik.contracts import Float64, Pointer

module_values: Pointer[Float64[:]]

def inspect_pointer(values: Pointer[Float64[:]]) -> Float64: ...

Use ordinary T[...] when the callable needs only array data:

def sum_values(values: Float64[:]) -> Float64: ...

An associated pointer handle may satisfy an ordinary array parameter when its dtype, rank, shape, layout, and contiguity meet that parameter's contract. A plain NumPy array cannot satisfy a Pointer[T[...]] parameter because it does not carry a native pointer descriptor.


Pointer Array Handle API

Pointer[T[...]] is the type annotation. At runtime, generated Python APIs use a PointerArray. You can also create an unassociated handle when a routine needs a present pointer descriptor that it will associate:

import prik.contracts as xc

target = xc.Pointer[xc.Float64[:]]()
assert target.associated is False

api.choose_target(target)
assert target.associated is True

The annotation supplies the element dtype and rank. The handle creates its native descriptor storage when first passed to a matching writable argument. It stays the same Python object after the call. Pointer[Float64]() is not supported because scalar pointers cross the Python boundary as values rather than array handles.

Member Type Behavior
associated bool Whether the descriptor currently has a target.
shape tuple[int, ...] \| None Current target dimensions, or None when unassociated.
dtype numpy.dtype Declared target element type.
rank int Declared number of dimensions.
to_numpy() numpy.ndarray \| None A live target view, or None when unassociated.
associate(other) (PointerArray) -> None Makes this pointer's association match other without copying data.
nullify() () -> None Removes the association without destroying the target.
allocate(shape) (int \| Sequence[int]) -> None Creates and associates a target for an unassociated pointer.
deallocate() () -> None Destroys the current target if this pointer was used to allocate it.
resize(shape) (int \| Sequence[int]) -> None Replaces the current target when deallocate() is valid.
close() () -> None Permanently releases returned or caller-created descriptor storage; it does not deallocate the target. It does nothing on a module or field handle.
closed bool Whether a closable handle has been closed.

associate() and nullify() are available by default. A handle may also support allocation, target deallocation, resizing, and NumPy extraction. An unavailable operation raises NotImplementedError.


Associate Two Pointers

p1 = xc.Pointer[xc.Float64[:]]()
p1.associate(p2)

Both pointers must have the same dtype and rank. If p2 is associated, both pointers refer to the same target. If p2 is unassociated, p1 becomes unassociated. No data is copied.

Any previous association of p1 is removed without destroying its old target. If p1 is responsible for a target created with p1.allocate(), call p1.deallocate() before reassociating it. Otherwise, that memory may be left without a pointer that can release it.


Nullify, Deallocate, And Close

Operation What it releases Handle afterward
nullify() This descriptor's association. It does not destroy the target. Open and usable, with associated == False.
deallocate() A target this pointer was used to allocate. Open and usable, with associated == False.
close() This handle's descriptor storage. It does not destroy the target. Permanently closed and unusable.

Returned and caller-created descriptors close automatically when Python no longer uses them. Call close() explicitly only when immediate descriptor release matters. It never destroys the pointer target because the descriptor and target have separate lifetimes.

Calling close() on a module or field pointer handle does nothing. It leaves the descriptor, target, and handle unchanged.


Where Handles Come From

Module Variables And Derived Fields

A module handle observes the live module pointer descriptor. A field handle retains its parent wrapper and observes the live pointer component inside it. Native reassociation is visible through the same Python handle:

p = api.values
assert not p.associated

api.associate_values()
assert p.associated

api.choose_different_values()
print(p.shape)  # reflects the new target

Function Results

A pointer-array function result becomes a returned PointerArray. The handle has persistent descriptor storage, but the target can belong to another object:

p = api.selected_values(True)
if p.associated:
    print(p.shape)

An unassociated native result is a present handle with associated == False. It is not returned as None.

Output And Inout Arguments

A nonoptional pointer-array intent(out) does not consume an incoming association, so it is hidden and returned as a new handle:

p = api.select_values()

An optional intent(out) remains visible so omission can preserve native present(...) behavior. Pointer-array intent(inout) also remains visible because its incoming association is part of the call:

p = api.values
api.reassociate_values(p)
assert p.associated  # the same descriptor was updated in place

For an optional pointer descriptor, omission or None means the native argument is absent. Passing an unassociated handle makes the argument present but unassociated.


Complete Module Example

Create pointers.f90:

module pointers_api
  implicit none
  real(8), target :: storage(6) = [1, 2, 3, 4, 5, 6]
  real(8), pointer :: values(:) => null()
contains

  subroutine associate_values()
    values => storage(1:6:2)
  end subroutine associate_values

  real(8) function sum_pointer(p) result(total)
    real(8), pointer, intent(in) :: p(:)
    if (associated(p)) then
      total = sum(p)
    else
      total = -1.0_8
    end if
  end function sum_pointer

end module pointers_api

Build and use it:

python3 -m prik pointers.f90 --out-dir build/pointers
import sys

sys.path.insert(0, "build/pointers")
import pointers.pointers_api as pointers_api

handle = pointers_api.values
assert not handle.associated
assert pointers_api.sum_pointer(handle) == -1.0

pointers_api.associate_values()
assert handle.associated
assert pointers_api.sum_pointer(handle) == 9.0

handle.nullify()
assert not handle.associated

Contiguous And Strided Targets

A pointer may describe a whole array, a contiguous section, or a strided section:

real(8), target :: storage(6) = [10, 20, 30, 40, 50, 60]
real(8), pointer :: selected(:)

selected => storage(1:6:2)

The NumPy view preserves that layout:

view = api.selected.to_numpy()
print(view)          # [10. 30. 50.]
print(view.shape)    # (3,)
print(view.strides)  # (16,) for eight-byte elements

view[1] = 99.0       # updates storage(3)

A strided pointer can be passed to a pointer-descriptor parameter. Passing the same handle to an ordinary array parameter that requires contiguous data is rejected.


Safety Checklist

Pointer safety depends on the target owner and lifetime, not only on descriptor state.

Check Association And Lifetime

view = p.to_numpy()
view[0] = 1.0  # NOT OK: view may be None
if p.associated:
    view = p.to_numpy()
    if view is not None:
        view[0] = 1.0

Association is necessary but cannot prove that an externally managed target is still alive. Native code must not leave a pointer associated with expired storage.

Do Not Return A Pointer To Expired Local Storage

function invalid_result() result(values)
  real(8), target :: local_values(3)
  real(8), pointer :: values(:)
  values => local_values  ! NOT OK: local_values expires on return
end function invalid_result

Putting the pointer inside a returned derived object does not repair this native lifetime error.

Copy Or Discard Views Before Target Changes

view = p.to_numpy()
saved = None if view is None else view.copy()
api.point_at_different_storage()
current = p.to_numpy()

Do not use view after target deallocation, reassociation, resizing, or reallocation behind the pointer. Extract current for the new target. The independent saved copy remains safe.

Deallocate Only What This Pointer Allocated

p.allocate(10)
p.deallocate()

The allocate() may be called from Python or from a native routine using the same pointer. If the pointer was only associated with existing storage, use nullify() instead:

p.nullify()  # does not destroy the target

Do not use nullify(), associate(), or close() while p is responsible for an allocated target. The target remains allocated and may become unreachable. Use p.deallocate() first.

Use resize() only in the same cases where deallocate() is valid.

Nullifying One Pointer Does Not Change Other Pointers

first = api.first_pointer
second = api.second_pointer
assert first.associated and second.associated

first.nullify()
assert second.associated

nullify() removes only first's association. It does not destroy the target or change second. Deallocating their shared target makes every pointer to that target invalid.

Do Not Keep Using A Closed Handle

view = returned_pointer.to_numpy()
returned_pointer.close()
returned_pointer.shape  # NOT OK: the descriptor has been released

close() releases a returned descriptor but never deallocates its target. An existing NumPy view may still refer to the target, but its safety now depends entirely on that target's separate owner and lifetime. Do not use the closed handle to reason about the view.

Respect Contiguity Requirements

strided = api.selected_slice
api.requires_contiguous_array(strided)  # NOT OK: rejected before the call

Pass the descriptor to a pointer parameter, or make an explicit contiguous copy when ordinary array data is required:

view = strided.to_numpy()
copy = None if view is None else view.copy(order="F")

Synchronize Target Changes

view = p.to_numpy()
# Another thread reassociates or deallocates p here.
value = view[0]  # NOT OK without native synchronization

prik does not lock native pointer association or track outstanding NumPy views. The application must synchronize concurrent native changes.


Scalar Pointers

Scalar pointers appear as T | None values at the Python boundary rather than PointerArray handles. An unassociated projected scalar result becomes None. Scalar values do not expose persistent association, to_numpy(), or pointer descriptor operations.

For an optional scalar pointer argument, omission makes the argument absent. Passing None makes it present but unassociated, while passing a value makes it present with that value. See Optional Arguments.


Next

  • Review Memory Management for the ownership and live view rules shared by all native storage.
  • Compare Allocatables when the native object owns an allocation rather than a pointer association.