C Pointers, Arrays, and Strings

C syntax cannot tell whether double * represents one scalar, an output, or the first element of an array. A source-generated contract therefore starts with runtime-rank caller-owned storage such as Float64[...]. It accepts a zero-dimensional or higher-rank NumPy array without guessing one fixed rank. Edit the semantic .pyi when the Python API should require a scalar value, one exact rank, or a projected result.

Author a contract for pointers and arrays

When a pointer is a NumPy buffer, state its shape and the native call order in the contract:

Create scale.c:

#include <stddef.h>

void scale(size_t count, double *values) {
    for (size_t index = 0; index < count; ++index) {
        values[index] *= 2.0;
    }
}

Create scale.pyi:

from prik.contracts import Arg, Float64, native_call

@native_call([Arg(0).shape[0], Arg(0)])
def scale(values: Float64[:]) -> None: ...

Arg(0).shape[0] provides count; Arg(0) passes the NumPy buffer to double *values.

python3 -m prik --language c scale.pyi \
  --native-c-sources scale.c \
  --compiler cc \
  --out scale \
  --out-dir build
import sys

import numpy as np

sys.path.insert(0, "build")
import scale

values = np.array([1.0, 2.0, 3.0], dtype=np.float64)
scale.scale(values)
print(values)
[2. 4. 6.]

Runtime-rank pointer storage accepts ranks 0 through 15 with primitive non-Boolean elements, and it constrains neither ordering nor strides: a Fortran-ordered array and a strided slice are both accepted. PRIK validates dtype, rank, any declared shape, layout, and writeability before calling C. An explicit shape such as Float64[:] still requires C-contiguous storage.

Choose the pointer contract

Arg(i) uses the annotation's normal C representation: a numeric scalar crosses by value, while rank-zero and array storage cross by address. Use Addr(Arg(i)) only when a scalar must become a C pointer.

Every row below is a valid edit of the generated Float64[...]. Choose the row whose Python column matches the API you want, and the caller passes exactly that:

Python contract Accepted Python value @native_call entry Native effect
value: Float64 np.float64(3.0) Arg(0) Passes double by value, not a pointer.
value: Float64 np.float64(3.0) Addr(Arg(0)) Passes the address of call-local storage; native mutation is discarded unless returned.
value: Float64[()] np.array(3.0) Arg(0) Passes the address of caller-owned rank-zero storage; native mutation is visible.
values: Float64[:] np.array([1.0, 2.0]) Arg(0) Requires rank one, contiguous.
values: Float64[4], Float64[n] a contiguous rank-one array of that extent Arg(0) Requires rank one and validates the declared extent.
values: Float64[:, :] np.ones((2, 3)) Arg(0) Requires rank two, C-contiguous.
values: Float64[...] any of the above except np.float64(3.0) Arg(0) Passes the data address of caller-owned storage of any rank 0 through 15 and any strides.
Annotated[Float64[...], Contiguous] the same, restricted to C-contiguous storage Arg(0) The same runtime rank, narrowed to C-contiguous storage.

The @native_call entry is optional when it would be [Arg(0), Arg(1), ...] in declaration order; write one only to reorder, project, or hide arguments.

Two distinctions decide most edits:

  • Float64 versus Float64[()]. Both correspond to double *. Float64 takes a Python scalar and needs Addr(Arg(0)) to become a pointer, and the native write lands in call-local storage the caller never sees. Float64[()] takes np.array(3.0), is already an address, and the native write is visible in the caller's array.
  • Float64[...] versus an exact shape. Float64[...] accepts rank 0 through 15 and any strides, so it fits a pointer whose meaning the source did not settle. An exact shape states the rank the function actually requires and lets PRIK validate extents and layout for you.

Do not wrap Float64[()] or an array in Addr(...); their native representation is already an address. Addr(Float64) as an annotation is a different form — a Python integer holding a raw native address — and it is not supported on the direct C route; a contract that uses it is rejected with C_DIRECT_RAW_ADDRESS before the build runs. Use Float64 plus Addr(Arg(0)) when you want an address taken for you, or Float64[()] when the caller should own the storage.

Return a modified call-local scalar explicitly:

from prik.contracts import Addr, Arg, Float64, Returns, native_call

@native_call([Addr(Arg(0))])
def scale_scalar(value: Float64) -> Returns["value", Float64]: ...

Float64[...] does not pass a rank or extent to C. When a native parameter needs the total element count, project the array's size:

from prik.contracts import Arg, Float64, native_call

@native_call([Arg(0).size, Arg(0)])
def scale(values: Float64[...]) -> None: ...

Pass a layout the caller chose

Float64[...] accepts whatever strides the caller's array already has, and PRIK passes only the data address. Project the layout the native code needs:

Projection Value the binding materializes
Arg(i).size Total number of elements.
Arg(i).shape[d] Extent of axis d.
Arg(i).strides[d] Stride of axis d in bytes, exactly as ndarray.strides reports it.
from prik.contracts import Arg, Float64, Int64, native_call

@native_call([Arg(0).shape[0], Int64(Arg(0).strides[0]), Arg(0)])
def scale(values: Float64[...]) -> None: ...
void scale(size_t count, long long stride_bytes, double *values);

An axis projection requires the actual to have that axis. Arg(0).shape[0] against rank-zero storage raises TypeError rather than reading past the array's shape, so narrow the annotation to Float64[:] when the function always needs rank one.

A native routine that walks values[index] needs contiguous elements. Either pass the stride the routine should honor, or require contiguity in the contract; Arg(i).size alone does not make a strided view safe to walk contiguously.

from prik.contracts import Annotated, Arg, Contiguous, Float64, native_call

@native_call([Arg(0).size, Arg(0)])
def scale(values: Annotated[Float64[...], Contiguous]) -> None: ...

Contiguous keeps runtime ranks 0 through 15 and rejects an actual that is not C-contiguous.

The contract cannot infer how many elements native code accesses. Keep a native count visible, derive it with .size or .shape[d], or declare an exact shape as appropriate. When the source declares const T *, do not author writable storage or write-back through it.

Pass C strings

Choose the string contract from what the C function does with the pointer:

C parameter Contract Python value
Read-only const char * String Python str
Writable char * String[n][()] or String[...][()] Rank-zero NumPy S array

String borrows the UTF-8 buffer of the Python str; the C function must not write through it. Writable strings use caller-owned NumPy bytes storage. A declared capacity such as String[32][()] validates the array itemsize, while String[...][()] accepts the caller's itemsize.

Create text.c:

#include <stddef.h>
#include <string.h>

int name_length(const char *text) {
    return (int)strlen(text);
}

void shout(const char *text, char *out) {
    size_t index = 0;
    for (; text[index]; ++index) {
        char value = text[index];
        out[index] = (value >= 'a' && value <= 'z') ? (char)(value - 32) : value;
    }
    out[index] = '\0';
}

Create text.pyi:

from prik.contracts import Int32, String

def name_length(text: String) -> Int32: ...

def shout(text: String, out: String[32][()]) -> None: ...
python3 -m prik --language c text.pyi \
  --native-c-sources text.c \
  --compiler cc \
  --out text \
  --out-dir build
import sys

import numpy as np

sys.path.insert(0, "build")
import text

print(text.name_length("hello"))
buffer = np.array(b"", dtype="S32")
text.shout("hello", buffer)
print(buffer[()])
5
b'HELLO'

When C uses an explicit byte length, pass it with Len(Arg(i)) in @native_call(...). The contract does not impose a terminator convention.

Next

Continue with Outputs and Errors when pointer parameters should become Python results or exceptions.