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

Search Results for

    Edit this page

    Why Semantic Pointers?

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

    The Problem with Raw Pointers in C#

    If you've worked with interoperability scenarios in C# or needed to manage memory manually, you've likely used raw pointers. While C# does support pointers through the unsafe keyword, the language treats them as second-class citizens. This limitation stems from a fundamental design choice: C# was built to be a safe, managed language, and pointers are inherently unsafe. When the language does provide them, it offers minimal expressiveness.

    Consider this code:

    unsafe
    {
        void* buffer = ...;
        int* data = (int*)buffer;
        
        if (data != null)  // Is this null check even correct?
        {
            *data = 42;
        }
    }
    

    At first glance, this looks reasonable. But several questions arise that the type system cannot answer:

    • Is data supposed to be nullable? The code checks for null, but the type int* doesn't express this.
    • Can I safely store this pointer for later use, or does its target live only in the current scope?
    • Is the memory writable, or should I only read from it?
    • If I need to access an array of integers, which pointer type should I use?

    In C++, you could express these semantics clearly:

    const int* data;          // Pointer to const int
    int* const data2;         // Const pointer to int
    int* const* array;        // Const pointer to array of pointers to int
    

    C# has no such mechanism. All pointers reduce to T* or void*, and you must rely on comments, naming conventions, and discipline to maintain safety. This approach is fragile and error-prone.

    C# Pointer Limitations

    Let's examine what C# pointers lack:

    No Const-ness

    In C++, const T* prevents modifications to the target. C# has no equivalent. When you see int* in code, you cannot tell if modifications are intended or forbidden. The readonly keyword exists in C#, but it applies to fields, not pointer targets.

    // In C#, both of these look the same to the type system:
    int* writableData = ...;
    int* readOnlyData = ...;  // Is this read-only? How would you know?
    

    No Nullability Distinction

    C# raw pointers can always be null, but the type doesn't express this. You must check manually, and nothing prevents you from dereferencing a null pointer and crashing your application.

    int* data = ...;
    
    // This is valid syntactically, but semantically risky:
    *data = 42;  // What if data is null?
    

    Modern C# has nullable reference types (string? vs string), but this doesn't apply to raw pointers.

    No Scope Safety

    When you use a pointer, the type doesn't communicate whether the target is temporary (valid only in the current scope) or persistent (valid long-term). This is critical in interop scenarios where you might be working with stack-allocated buffers, heap allocations, or pinned objects.

    int* StackBuffer()
    {
        int x = 42;
        return &x;  // Dangling pointer! But the type doesn't express this risk.
    }
    

    No Sequencability Express

    When you have a pointer to a buffer, is it a single element or an array? The type int* doesn't communicate intent. You must rely on documentation or context.

    int* ProcessBuffer(int* data)
    {
        // Can I treat data as an array and access data[5]?
        // The type doesn't say.
        data[0] = 1;
        return data + 1;  // Can I do pointer arithmetic? The type doesn't forbid it.
    }
    

    Introducing Semantic Pointers

    SemPtr addresses these limitations by introducing semantic characteristics to pointer types. Instead of reducing all pointers to T* or void*, SemPtr provides a rich set of pointer types, each with a specific semantic meaning.

    The core idea is simple: the name of the type expresses its intent, and the type system enforces that intent.

    Five Orthogonal Characteristics

    SemPtr defines five characteristics that you can combine independently:

    1. Nullability: Can the pointer be null?

      • Use Pointer (the default) when you need a pointer that shouldn't be null
      • Use NullablePointer when the pointer might be null
    2. Persistency: Does the target outlive the pointer's scope?

      • Use Pointer (the default) for transient pointers with temporary targets
      • Use PersistentPointer for pointers with long-lived targets that outlive the scope
    3. Sequencability: Does it point to a single element or an array?

      • Use Pointer (the default) for single-element access
      • Use SequencePointer for contiguous sequences (buffers or arrays)
      • Note: Typed sequence pointers like SequencePointer<T> allow safe indexing and pointer arithmetic
    4. Accessibility: What operations can be performed on the target?

      • Use Pointer (the default) for unrestricted (i.e., read-write) targets (analogous to C#'s ref parameter)
      • Use PointerReadOnly for read-only targets where modifications are forbidden (analogous to C#'s in/ref readonly parameter)
      • Use PointerUninitialized for uninitialized memory that must be written before reading (analogous to C#'s out parameter)
      • Note: Typed pointers like Pointer<T>, PointerReadOnly<T>, or PointerUninitialized<T> allow direct access to or initialization of their targets
    5. Typeability: Is the target type known at compile-time?

      • Use Pointer (the default, untyped) for generic void*-like behavior where the target type is not known or varies
      • Use Pointer<T> when you know the specific target type at compile-time

    Composing Characteristics

    These five characteristics are fully orthogonal and you can combine them in any way by composing their names. This gives you precise control over pointer semantics:

    • NullablePersistentPointer: a pointer that might be null and has a long-lived target
    • PersistentSequencePointerReadOnly<T>: a long-lived read-only sequence pointer for arrays with targets of type T
    • NullablePointerReadOnly: a nullable, read-only pointer to a single object
    • PointerUninitialized<T>: a pointer to uninitialized memory that must be written with a value of type T first before it can be read

    Every combination is a distinct type with clear semantics. This systematic approach eliminates ambiguity and helps the compiler catch errors that would otherwise require runtime checks or careful documentation.

    How Semantics Solve the Problem

    Let's revisit the original unsafe code with semantic pointers:

    // Correct: NullablePointer<int> expresses nullable intent
    // Use TryGetNonNull() to safely convert to non-nullable
    NullablePointer<int> data = ...;
    if (data.TryGetNonNull(out var nonNull))  // Type-safe null check
    {
        nonNull.Target = 42;
    }
    
    // Alternative: Use HasTarget property for null checking
    // Works on all pointer types
    if (data.HasTarget)  // Check if pointer is non-null
    {
        // Can now safely use the pointer
    }
    
    // You can also use in if conditions directly
    if (data)  // operator true checks HasTarget
    {
        // Pointer is non-null
    }
    

    Here's the key insight: NullablePointer<int> explicitly declares that null is possible, so it provides no direct access to the target. You must use TryGetNonNull() to obtain a non-nullable Pointer<int> that you can then dereference. All pointer types, regardless of nullability, have a HasTarget property and support operator true/operator false for null checks, giving you C-like pointer semantics.

    Warning

    There's a technical reason why all pointer types, even non-nullable ones, have ways to check for null: There's a possibility that even non-nullable pointers may wrap a null pointer due to language limitations and the inability of achieving absolute enforcement at the type system level. This is why you should treat non-nullable pointers at runtime with caution as well.

    Real-World Benefits

    1. Self-Documenting Code: The pointer type tells you what the code expects. No need for comments like "assume this is non-null".

    2. Compile-Time Safety: Many pointer errors are caught at compile-time, not runtime.

    3. Clear Interfaces: When a function parameter is Pointer<int>, you know immediately that:

      • The pointer is non-nullable
      • You can read and write to the target
      • The target is a single integer
    4. Reduced Cognitive Load: You don't need to mentally track pointer invariants. The type system tracks them for you.

    5. Safer Interop: When calling native functions, you can express the exact contract the function expects. A PersistentPointerReadOnly<byte> is very different from a NullablePointer<byte>.

    What About Function Pointers?

    SemPtr also applies semantic characteristics to function pointers. Instead of using delegate*, you can use semantic function pointer types:

    // Raw function pointer: no semantic information
    delegate* unmanaged[Cdecl]<int, int> nativeFunc = ...;
    
    // Semantic function pointer: clearly expresses intent
    FunctionPointer<MyDelegate> callback = ...;  // Non-nullable, can invoke directly
    NullableFunctionPointer<MyDelegate> optionalCallback = ...;  // Must check before invoking
    PersistentFunctionPointer<MyDelegate> persistentCallback = ...;  // Long-lived callback
    

    Function pointers in SemPtr also support calling conventions through delegate type attributes, giving you precise control over how native functions are called.

    What You Get with SemPtr

    SemPtr provides complete implementations for all pointer types in the shipped library. When you add SemPtr to your project, you get immediate access to 48 data pointer types and 8 function pointer types, each fully featured:

    • Nullability checks via the TryGetNonNull() pattern for nullable pointers
    • Pointer arithmetic (++, --, +, -) for sequence pointers
    • Type-safe conversions between compatible pointer types (implicit for safe narrowing, explicit for widening)
    • Comparability operators (<, >, <=, >=, ==, !=) for sequences
    • Access methods (Target property, indexers, AsSpan()) for typed pointers
    • Null checking via HasTarget property and operator true/operator false
    • Equality and formatting support for all types

    Next Steps

    Understanding semantic characteristics is foundational. Here's how to proceed:

    • Understanding Semantic Characteristics provides an in-depth exploration of each characteristic and how they solve real problems
    • Choosing the Right Pointer Type helps you decide which type to use for specific scenarios
    • Getting Started with Data Pointers walks you through practical usage with working code examples

    If you're already familiar with pointers and want to jump into code, start with Getting Started with Data Pointers. If you prefer to understand the "why" first, read Understanding Semantic Characteristics next.

    Why Semantic Pointers Matter

    The fundamental insight is this: the type system is more reliable than comments and conventions. By encoding pointer semantics into types, SemPtr lets the compiler help you write safer code. You get the performance of raw pointers with the safety benefits of semantic typing.

    This is especially valuable in interop scenarios where you're already in unsafe code and need to interface with native libraries. SemPtr won't prevent all pointer mistakes (you still need to be careful), but it will catch many of them early and make your intentions clear to future readers of your code.

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