SemPtr SemPtr
SemPtr SemPtr
Documentation generated with DocFX
Using SingulinkFX as the template

Search Results for

    Edit this page

    Understanding Semantic Characteristics

    Note

    This article was entirely or partially generated by AI. However, its content has been verified and approved, or corrected by a human author prior to publication.

    If you want to know more about the usage of AI in the generation of this documentation and the reason behind it, please see Why AI?.

    Overview

    SemPtr describes a data pointer through five orthogonal characteristics. Each characteristic answers a question that a raw C# pointer leaves to a convention, a comment, or a runtime check. Together, they let a pointer type state its intended contract.

    The core idea is simple: instead of remembering what a pointer is allowed to do, you can state those facts in its type. This article explores each characteristic in depth, including the problem it solves, the contract it expresses, and the interop scenarios where it matters.

    1. Nullability Characteristic

    The Problem

    A raw pointer may be null, but int* does not say whether null is an expected result, an accepted argument, or a programming error. Callers must infer that contract from surrounding code.

    unsafe
    {
        int* result = ...;
    
        if (result != null)
        {
            *result = 42;
        }
    }
    

    The Contract

    SemPtr provides a non-nullable and a nullable variant for each combination of the other characteristics.

    1. Pointer<T> expresses that the API contract expects a target.
    2. NullablePointer<T> expresses that the API contract permits a null pointer and therefore does not expose direct target access.

    For a nullable pointer, TryGetNonNull() checks for a target and produces the corresponding non-nullable type.

    unsafe
    {
        NullablePointer<int> optionalValue = ...;
    
        if (optionalValue.TryGetNonNull(out var value))
        {
            value.Target = 42;
        }
    }
    

    All pointer types also expose HasTarget and support operator true and operator false, so a pointer can be checked with if (pointer.HasTarget) or if (pointer).

    Warning

    The non-nullable distinction is an API and type-system contract, not an absolute runtime proof. Because SemPtr pointer types are value types, a non-nullable instance can still represent a null raw pointer in some circumstances. FromRaw rejects a null raw pointer for non-nullable pointer types, but callers should still treat native memory safely at runtime.

    Interop Contracts

    Nullability is particularly useful when it appears in a native function signature. Here, the return value and one parameter both explicitly permit null.

    [DllImport("mylib.dll")]
    private static extern NullablePointer<int> FindValue(int key);
    
    [DllImport("mylib.dll")]
    private static extern void ClearValue(NullablePointer<int> value);
    
    public static bool TryFindValue(int key, out int value)
    {
        var result = FindValue(key);
    
        if (result.TryGetNonNull(out var nonNullResult))
        {
            value = nonNullResult.Target;
            return true;
        }
    
        value = default;
        return false;
    }
    

    The return type tells callers that FindValue may fail without a target. The parameter type tells callers that ClearValue accepts an absent value. A parameter of Pointer<T>, by contrast, communicates that a target is part of the expected contract.

    2. Persistency Characteristic

    The Problem

    An external API can return a pointer whose target remains valid after the call, or a pointer that must be used before the call context ends. It can likewise accept a pointer for immediate work or retain that pointer for later work. Raw pointer syntax does not record either lifetime contract.

    The C# compiler already rejects returning a pointer to a local stack variable, as it does for an escaping ref. That protection does not answer the interop question: whether a target provided by external code remains valid after the call that supplied it.

    The Contract

    1. Pointer<T> is transient. Its target is valid only for the pointer's current lifetime, so the pointer cannot escape its current scope.
    2. PersistentPointer<T> is persistent. Its target outlives the pointer's initial scope, so the pointer can be stored and used later.

    The distinction is reflected in the C# type system. Transient pointer types are ref struct types and cannot become fields or collection elements. Persistent pointer types are ordinary value types and can be stored by the caller. Persistency does not establish ownership or guarantee how long an external target stays valid. It records the contract that the target survives the initial scope.

    private sealed class NativeHandle
    {
        private PersistentPointer<int> _value;
    
        public NativeHandle(PersistentPointer<int> value)
        {
            _value = value;
        }
    
        public static void UseImmediately(Pointer<int> value)
        {
            value.Target = 42;
        }
    }
    

    Interop Contracts

    These contracts are most useful at the boundary with native code.

    [DllImport("mylib.dll")]
    private static extern Pointer<int> GetScratchValue();
    
    [DllImport("mylib.dll")]
    private static extern PersistentSequencePointer<byte> AllocateBuffer(int length);
    
    [DllImport("mylib.dll")]
    private static extern void UseBufferNow(SequencePointer<byte> buffer, int length);
    
    [DllImport("mylib.dll")]
    private static extern void RetainBuffer(PersistentSequencePointer<byte> buffer, int length);
    

    The return type of GetScratchValue states that its result must be consumed immediately. AllocateBuffer states that its buffer can be retained. UseBufferNow documents an immediate-use parameter, while RetainBuffer documents that native code may keep the pointer after the call returns.

    When converting a raw pointer returned from another API, use FromRaw explicitly. The corresponding Raw property performs the reverse conversion. SemPtr intentionally does not provide raw-pointer conversion operators.

    unsafe
    {
        byte* rawBuffer = GetNativeBuffer();
        var buffer = PersistentSequencePointer<byte>.FromRaw(rawBuffer);
    
        SendToNativeCode(buffer.Raw);
    }
    

    3. Sequencability Characteristic

    The Problem

    int* can point to one int or to the first element of a contiguous buffer. Raw pointer syntax permits indexing and arithmetic in both cases, although only one interpretation may reflect the API contract.

    unsafe
    {
        int* data = ...;
        int value = data[0];
        int* next = data + 1;
    }
    

    The Contract

    1. Pointer<T> represents a single target and intentionally has no indexers or pointer arithmetic.
    2. SequencePointer<T> represents the first target in a contiguous sequence and supports indexing, comparisons, and pointer arithmetic.

    Typed sequence pointers also expose AsSpan() to construct a Span<T> for a caller-supplied range. This operation does not perform bounds checks. It is useful for APIs that operate on contiguous memory, not for LINQ.

    unsafe
    {
        Pointer<int> value = ...;
        // value[0];
        // value + 1;
    
        SequencePointer<int> buffer = ...;
        buffer[0] = 10;
        int sixth = buffer[5];
        SequencePointer<int> next = buffer + 1;
    }
    

    Interop Contracts

    A native API that returns an immutable byte buffer can express all three relevant characteristics directly.

    [DllImport("mylib.dll")]
    private static extern PersistentSequencePointerReadOnly<byte> GetBuffer();
    
    [DllImport("mylib.dll")]
    private static extern int GetBufferLength();
    
    public static string ReadBuffer()
    {
        var buffer = GetBuffer();
        ReadOnlySpan<byte> data = buffer.AsSpan(GetBufferLength());
        return Encoding.UTF8.GetString(data);
    }
    

    PersistentSequencePointerReadOnly<T> expresses a persistent, read-only sequence. Its AsSpan() result is ReadOnlySpan<T>, preserving the pointer's read-only contract.

    4. Accessibility Characteristic

    The Problem

    Raw C# pointers allow reads and writes regardless of whether native memory is intended as input, output, or uninitialized storage. The distinction must otherwise live only in the native signature and the reader's memory.

    The Contract

    SemPtr models three access contracts.

    1. Pointer<T> provides read-write access.
    2. PointerReadOnly<T> provides read-only access.
    3. PointerUninitialized<T> provides write-first access.

    Pointer<T> exposes Target as ref T, so callers can both read and assign through the property. PointerReadOnly<T> exposes Target as ref readonly T, so callers can read it but cannot assign through it.

    An uninitialized pointer exposes neither Target nor sequence indexers. It can only write through InitializeTarget(), which returns the matching read-write pointer after initialization.

    unsafe
    {
        int* rawValue = ...;
    
        var writable = Pointer<int>.FromRaw(rawValue);
        writable.Target = 42;
        int current = writable.Target;
    
        var readOnly = PointerReadOnly<int>.FromRaw(rawValue);
        int observed = readOnly.Target;
        // readOnly.Target = 43;
    
        var uninitialized = PointerUninitialized<int>.FromRaw(rawValue);
        Pointer<int> initialized = uninitialized.InitializeTarget(44);
        int initializedValue = initialized.Target;
    }
    

    Uninitialized sequence pointer types add indexed initialization and can initialize a range from ReadOnlySpan<T>. Each initialization operation returns a read-write pointer with the same remaining characteristics.

    C# Parameter Analogs

    The contracts align with familiar C# parameter modifiers.

    1. Pointer<T> corresponds to ref T.
    2. PointerReadOnly<T> corresponds to in T or ref readonly T.
    3. PointerUninitialized<T> corresponds to out T.

    Interop Contracts

    Native signatures often distinguish input from output memory. SemPtr can carry that distinction into the managed declaration.

    [DllImport("mylib.dll")]
    private static extern void Transform(
        PointerReadOnly<int> input,
        PointerUninitialized<int> output);
    
    public static unsafe int TransformValue(int input)
    {
        int output;
        var inputPointer = PointerReadOnly<int>.FromRaw(&input);
        var outputPointer = PointerUninitialized<int>.FromRaw(&output);
    
        Transform(inputPointer, outputPointer);
        return outputPointer.InitializeTarget(output).Target;
    }
    

    The native function receives a read-only input target and an output target. The managed example uses FromRaw and FromRaw for the explicit raw-pointer boundary. The final InitializeTarget() call makes the managed accessibility transition explicit before reading the result.

    5. Typeability Characteristic

    The Problem

    Interop APIs sometimes expose a generic pointer, similar to C's void*, before the caller knows its element type. Raw pointer casts leave both the type interpretation and its risk implicit.

    The Contract

    1. Pointer is untyped and provides void*-like behaviour.
    2. Pointer<T> is typed and records the target element type as T.

    Typed pointers expose direct access appropriate to their other characteristics. An untyped pointer must first be explicitly converted to an appropriate typed pointer type.

    unsafe
    {
        Pointer untyped = ...;
        Pointer<int> typed = (Pointer<int>)untyped;
    
        int value = typed.Target;
    }
    

    The conversion in this example changes the claimed type of the target, not the native memory itself. Understanding Pointer Conversions explains the conversion rules and type-punning safeguards in detail.

    Why Orthogonality Matters

    The characteristics compose independently, allowing one type name to state a complete contract without inventing a special-purpose abstraction for every case.

    1. Pointer<T> represents a transient, non-nullable, single, read-write typed target.
    2. NullablePointer<T> adds nullable intent.
    3. PersistentSequencePointerReadOnly<T> represents a persistent, non-nullable, read-only typed sequence.
    4. NullablePersistentSequencePointerUninitialized<T> represents a nullable, persistent typed sequence that must be initialized before direct access.

    Each name is systematic because each part represents one independent characteristic. The compiler can then preserve those facts through the APIs and conversions that SemPtr permits.

    Next Steps

    Understanding the five characteristics is the foundation for choosing and using SemPtr pointer types. Here's how to continue:

    • Choosing the Right Pointer Type helps you turn a native API contract into the appropriate pointer type
    • Getting Started with Data Pointers walks through practical usage with working code examples
    • Understanding Pointer Conversions explains how conversions add or remove guarantees
    • The API documentation provides the complete reference for all pointer types and their members

    If you are ready to select types for an API signature, read Choosing the Right Pointer Type next. If you prefer to start working with pointers in code, begin with Getting Started with Data Pointers.

    © 2026 Felix Rüdiger. SemPtr and its documentation are licensed under the MIT license.