Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 31 additions & 33 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,12 @@ jobs:
fail-fast: true
matrix:
include:
# x64 — test two GCC versions and two Clang versions
- { os: ubuntu-24.04, compiler: g++-13, packages: g++-13 }
- { os: ubuntu-24.04, compiler: g++-14, packages: g++-14 }
- { os: ubuntu-24.04, compiler: clang++-17, packages: clang-17 }
- { os: ubuntu-24.04, compiler: clang++-18, packages: clang-18 }
# x64 — older LTS: exercises a different GCC and standard library version
- { os: ubuntu-22.04, compiler: g++-12, packages: g++-12 }
# arm64 — distinct ISA; same compiler version as x64 to isolate architecture
- { os: ubuntu-24.04-arm, compiler: g++-14, packages: g++-14 }
- { os: ubuntu-24.04, compiler: g++-13, packages: g++-13, build_type: Debug }
- { os: ubuntu-24.04, compiler: g++-14, packages: g++-14, build_type: Release }
- { os: ubuntu-24.04, compiler: clang++-18, packages: clang-18, build_type: Release }
- { os: ubuntu-24.04, compiler: clang++-19, packages: clang-19, build_type: Debug }
- { os: ubuntu-22.04, compiler: g++-12, packages: g++-12, build_type: Debug }
- { os: ubuntu-24.04-arm, compiler: g++-14, packages: g++-14, build_type: Debug }

steps:
- uses: actions/checkout@v4
Expand All @@ -52,17 +49,17 @@ jobs:
- name: Set up ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: ${{ matrix.os }}-${{ matrix.compiler }}
key: ${{ matrix.os }}-${{ matrix.compiler }}-${{ matrix.build_type }}

# Use Debug so assertions in non_null are active and exercised by the tests.
- name: Configure, build, and test
env:
CPM_SOURCE_CACHE: ${{ runner.temp }}/cpm-cache
run: |
cmake -B build \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \
-DCMAKE_CXX_COMPILER=${{ matrix.compiler }} \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_STANDARD=23
cmake --build build --parallel
ctest --test-dir build --output-on-failure

Expand All @@ -73,11 +70,10 @@ jobs:
fail-fast: true
matrix:
include:
# windows-2025 (= windows-latest): test both x64 and x86 MSVC targets
- { os: windows-2025, arch: x64 }
- { os: windows-2025, arch: x86 }
# windows-2022: older MSVC toolchain on the same x64 target
- { os: windows-2022, arch: x64 }
# - { os: windows-2025, arch: x64, build_type: Debug }
- { os: windows-2025, arch: x64, build_type: Release }
- { os: windows-2025, arch: x86, build_type: Release }
- { os: windows-2022, arch: x64, build_type: Release }

steps:
- uses: actions/checkout@v4
Expand All @@ -92,22 +88,22 @@ jobs:
- name: Set up sccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: windows-${{ matrix.arch }}
key: windows-${{ matrix.arch }}-${{ matrix.build_type }}
variant: sccache

- name: Set up MSVC
uses: TheMrMilchmann/setup-msvc-dev@v3
with:
arch: ${{ matrix.arch }}

# Ninja is pre-installed on GitHub-hosted Windows runners.
- name: Configure, build, and test
env:
CPM_SOURCE_CACHE: ${{ runner.temp }}/cpm-cache
run: |
cmake -B build -G Ninja `
-DCMAKE_BUILD_TYPE=Release `
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
-DCMAKE_BUILD_TYPE=${{ matrix.build_type }} `
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache `
-DCMAKE_CXX_STANDARD=23
cmake --build build --parallel
ctest --test-dir build --output-on-failure

Expand All @@ -116,15 +112,17 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
max-parallel: 1
matrix:
include:
# arm64 (Apple Silicon M1) — two macOS generations
- { os: macos-14 }
- { os: macos-15 }
# Intel x64 — the only way to cover x64 macOS; Apple Silicon runners above don't test this ISA
- { os: macos-15-intel }
# arm64 preview — macOS 26 (public preview; allowed to fail without blocking CI)
- { os: macos-26, experimental: true }
- { os: macos-14, build_type: Debug }
- { os: macos-14, build_type: Release }
- { os: macos-15, build_type: Debug }
- { os: macos-15, build_type: Release }
- { os: macos-15-intel, build_type: Release }
- { os: macos-26, build_type: Release }
- { os: macos-26, build_type: Debug }

steps:
- uses: actions/checkout@v4
Expand All @@ -139,15 +137,15 @@ jobs:
- name: Set up ccache
uses: hendrikmuhs/ccache-action@v1.2
with:
key: ${{ matrix.os }}
key: ${{ matrix.os }}-${{ matrix.build_type }}

- name: Configure, build, and test
continue-on-error: ${{ matrix.experimental == true }}
env:
CPM_SOURCE_CACHE: ${{ runner.temp }}/cpm-cache
run: |
cmake -B build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache
cmake --build build --parallel
-DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_STANDARD=23
cmake --build build --parallel 1
ctest --test-dir build --output-on-failure
36 changes: 33 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
cmake_minimum_required(VERSION 3.14)
cmake_minimum_required(VERSION 3.25)

project(nova_nonnull VERSION 0.1.0 LANGUAGES CXX)

if(NOT CMAKE_CXX_STANDARD)
if (NOT CMAKE_CXX_STANDARD)
set(CMAKE_CXX_STANDARD 20)
endif()
set(CMAKE_CXX_STANDARD_REQUIRED ON)

if (NOT CMAKE_MSVC_DEBUG_INFORMATION_FORMAT)
set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "Embedded")
endif()

add_custom_target(nova_nonnull_project_files SOURCES
.clang-tidy
.pre-commit-config.yaml
Expand Down Expand Up @@ -55,9 +59,35 @@ if(NOVA_BUILD_TESTS)
endblock()
endif()

add_executable(nova_nonnull_tests tests/test_non_null.cpp)
add_executable(nova_nonnull_tests
tests/test_non_null.cpp
tests/test_non_null_function.cpp
)
target_link_libraries(nova_nonnull_tests PRIVATE nova::nonnull Catch2::Catch2WithMain)

enable_testing()
add_test(NAME nova_nonnull_tests COMMAND nova_nonnull_tests)

add_executable(nova_nonnull_asan_take_tests
tests/asan_take.cpp
)
target_link_libraries(nova_nonnull_asan_take_tests PRIVATE nova::nonnull )
target_compile_options(nova_nonnull_asan_take_tests PRIVATE
$<$<COMPILE_LANG_AND_ID:CXX,GNU,Clang,AppleClang>:-fsanitize=address;-fno-omit-frame-pointer>
$<$<COMPILE_LANG_AND_ID:CXX,MSVC>:/fsanitize=address>
)

target_link_options(nova_nonnull_asan_take_tests PRIVATE
$<$<COMPILE_LANG_AND_ID:CXX,GNU,Clang,AppleClang>:-fsanitize=address>
$<$<COMPILE_LANG_AND_ID:CXX,GNU>:-static-libasan>
)

if (CMAKE_BUILD_TYPE STREQUAL "Debug")
add_test(NAME nova_nonnull_asan_take_tests
COMMAND ${CMAKE_COMMAND} -E env $<TARGET_FILE:nova_nonnull_asan_take_tests>
)
set_tests_properties(nova_nonnull_asan_take_tests PROPERTIES
PASS_REGULAR_EXPRESSION ".*AddressSanitizer: use-after-poison.*"
)
endif()
endif()
117 changes: 61 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,96 +2,101 @@

[![CI](https://github.com/timblechmann/nova_nonnull/actions/workflows/ci.yml/badge.svg)](https://github.com/timblechmann/nova_nonnull/actions/workflows/ci.yml)

A C++20 non-null pointer wrapper with compiler hints for better code generation. Inspired by
[`gsl::not_null`](https://github.com/microsoft/GSL/blob/main/include/gsl/pointers), which
enforces nullability contracts but lacks compiler attributes for optimization.
Non-null pointer adapters and callable wrappers for C++20, with compiler hints for better code generation. Inspired by [`gsl::not_null`](https://github.com/microsoft/GSL/blob/main/include/gsl/pointers), extended with optimization attributes and callable support.

## Usage
## Components

| Type | Wraps | Notes |
|------|-------|-------|
| `non_null<T*>` | Raw pointer | Assert-checked on construction |
| `non_null<unique_ptr<T>>` | `std::unique_ptr<T>` | Move via `take()` only |
| `non_null<shared_ptr<T>>` | `std::shared_ptr<T>` | Move emulated by copy |
| `non_null_function<Sig>` | `std::function<Sig>` | Move emulated by copy |
| `non_null_move_only_function<Sig>` | `std::move_only_function<Sig>` (C++23) | Move via `take()` only |

## Pointer adapter usage

```cpp
#include <nova/non_null.hpp>

// Raw pointers — asserts non-null on construction, checked in debug builds
// Raw pointers — assert-checked in debug builds
nova::non_null<int*> p(&value);

// Smart pointers — created via factory functions
nova::non_null<std::unique_ptr<Foo>> u = nova::make_non_null_unique<Foo>(args...);
nova::non_null<std::shared_ptr<Foo>> s = nova::make_non_null_shared<Foo>(args...);
// Smart pointer factories
auto u = nova::make_non_null_unique<Foo>(args...); // non_null<unique_ptr<Foo>>
auto s = nova::make_non_null_shared<Foo>(args...); // non_null<shared_ptr<Foo>>

// Safe promotion of nullable pointers
std::optional<nova::non_null<T*>> opt = nova::try_make_non_null(ptr);
if (opt) { /* guaranteed non-null */ }
// Promote nullable pointer (returns std::nullopt if null)
if (auto opt = nova::try_make_non_null(ptr))
(*opt)->do_something();

// Explicit ownership transfer (unique_ptr ownership cannot be copied)
auto nn1 = nova::make_non_null_unique<int>(42);
auto nn2 = nova::non_null(take(std::move(nn1))); // Safe, explicit extraction
// nn1 is now moved-from; implicit moves are compile errors
// Transfer ownership out of a unique_ptr wrapper
auto nn2 = nova::non_null(take(std::move(nn1))); // nn1 must not be used after
```

## non_null_function usage

```cpp
// Wrap any callable — asserts non-empty on construction
nova::non_null_function<int(int)> f = [](int x) { return x * 2; };
int result = f(21); // 42 — no empty-callable check emitted

// Pass as parameter — callee guaranteed a valid callable
void process(nova::non_null_function<void(int)> callback) {
callback(42); // no branch for null check
}

// Move-only callable (C++23)
nova::non_null_move_only_function<void()> g(std::move(unique_callable));
// Extract ownership explicitly:
auto raw = take(std::move(g));
```

## API

**`non_null<T>` members:**

| Member | Notes |
|--------|-------|
| `get()` | Raw pointer; `returns_nonnull` and `_Nonnull` annotated |
| `get()` | Raw pointer; `returns_nonnull` / `_Nonnull` annotated |
| `underlying()` | Stored pointer object (e.g. `unique_ptr`, `shared_ptr`) |
| `*nn` / `nn->` | Standard dereference / member access |
| `swap(other)` | Exchange managed pointers |
| `operator bool()` | Always `true`; enables `if (nn) { ... }` without branching |
| `operator==`, `operator<=>` | Compare raw pointers |

**Smart pointer-specific APIs** (concept-gated):

| Member | Available for | Notes |
|--------|---------------|-------|
| `get_deleter()` | `unique_ptr` | Access the deleter |
| `use_count()` | `shared_ptr` | Shared ownership count |
| `owner_before()` | `shared_ptr` | Ordering by ownership |
| `owner_equal()` | `shared_ptr` | Equality by owner |
| `*nn` / `nn->` | Dereference / member access |
| `swap(other)` | Exchange; both remain non-null |
| `operator bool()` | Always `true` |
| `operator==`, `operator<=>` | Compare by raw pointer |
| `get_deleter()` | `unique_ptr` only |
| `use_count()`, `owner_before()`, `owner_equal()` | `shared_ptr` only |

**Free functions:**

| Function | Notes |
|----------|-------|
| `take(rhs&&)` | Explicitly extract underlying pointer (breaks non-null invariant on rhs) |
| `swap(lhs, rhs)` | ADL-found standard swap |
| `take(rhs&&)` | Extracts underlying pointer; rhs must not be used after |
| `swap(lhs, rhs)` | ADL swap |
| `try_make_non_null(p)` | Returns `optional<non_null<T>>`; nullopt if null |
| `make_non_null_unique<T>(args...)` | Like `std::make_unique` |
| `make_non_null_shared<T>(args...)` | Like `std::make_shared` |

## Type aliases
**Type aliases:**

| Alias | Equivalent |
|-------|-----------|
| `non_null_unique_ptr<T>` | `non_null<std::unique_ptr<T>>` |
| `non_null_shared_ptr<T>` | `non_null<std::shared_ptr<T>>` |

## Move Safety
## Move semantics

Move semantics are **conditionally enabled** based on pointer type:
Move is **conditionally enabled** based on whether the wrapped type is copyable:

| Pointer Type | Move Supported | Rationale |
|--------------|-----------------|-----------|
| Raw pointers (`T*`) | ✅ Yes | Copyable value type; move is safe |
| `shared_ptr<T>` | ✅ Yes | Reference counting handles move semantics correctly |
| `unique_ptr<T>` | ❌ No (use `take()`) | Move-only; implicit move breaks invariant |

**For copyable types**, move is emulated by copying:

```cpp
auto nn1 = nova::non_null(raw_ptr);
auto nn2 = std::move(nn1); // Allowed; raw pointer value copied
```

**For move-only types** (`unique_ptr`), use explicit `take()`:

```cpp
auto nn1 = nova::make_non_null_unique<int>(42);
// auto nn2 = std::move(nn1); // COMPILE ERROR

// Correct — explicit:
auto nn2 = nova::non_null(take(std::move(nn1)));
```
| Type | Move | Rationale |
|------|------|-----------|
| `T*`, `shared_ptr<T>`, `non_null_function` | Allowed | Copyable; move is safe |
| `unique_ptr<T>`, `non_null_move_only_function` | Deleted — use `take()` | Implicit move would leave wrapper empty |

## Requirements

- C++20 (GCC 12+, Clang 17+, MSVC 2022+)
- `non_null_move_only_function` requires C++23
- Header-only; no dependencies

## Build & test
Expand Down
Loading
Loading