diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1983509..68eadde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 @@ -92,7 +88,7 @@ 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 @@ -100,14 +96,14 @@ jobs: 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 @@ -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 @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index b11dabb..bfdb946 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 @@ -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 + $<$:-fsanitize=address;-fno-omit-frame-pointer> + $<$:/fsanitize=address> + ) + + target_link_options(nova_nonnull_asan_take_tests PRIVATE + $<$:-fsanitize=address> + $<$:-static-libasan> + ) + + if (CMAKE_BUILD_TYPE STREQUAL "Debug") + add_test(NAME nova_nonnull_asan_take_tests + COMMAND ${CMAKE_COMMAND} -E env $ + ) + set_tests_properties(nova_nonnull_asan_take_tests PROPERTIES + PASS_REGULAR_EXPRESSION ".*AddressSanitizer: use-after-poison.*" + ) + endif() endif() diff --git a/README.md b/README.md index ef96005..6c65e01 100644 --- a/README.md +++ b/README.md @@ -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` | Raw pointer | Assert-checked on construction | +| `non_null>` | `std::unique_ptr` | Move via `take()` only | +| `non_null>` | `std::shared_ptr` | Move emulated by copy | +| `non_null_function` | `std::function` | Move emulated by copy | +| `non_null_move_only_function` | `std::move_only_function` (C++23) | Move via `take()` only | + +## Pointer adapter usage ```cpp #include -// Raw pointers — asserts non-null on construction, checked in debug builds +// Raw pointers — assert-checked in debug builds nova::non_null p(&value); -// Smart pointers — created via factory functions -nova::non_null> u = nova::make_non_null_unique(args...); -nova::non_null> s = nova::make_non_null_shared(args...); +// Smart pointer factories +auto u = nova::make_non_null_unique(args...); // non_null> +auto s = nova::make_non_null_shared(args...); // non_null> -// Safe promotion of nullable pointers -std::optional> 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(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 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 callback) { + callback(42); // no branch for null check +} + +// Move-only callable (C++23) +nova::non_null_move_only_function g(std::move(unique_callable)); +// Extract ownership explicitly: +auto raw = take(std::move(g)); ``` ## API +**`non_null` 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>`; nullopt if null | +| `make_non_null_unique(args...)` | Like `std::make_unique` | +| `make_non_null_shared(args...)` | Like `std::make_shared` | -## Type aliases +**Type aliases:** | Alias | Equivalent | |-------|-----------| | `non_null_unique_ptr` | `non_null>` | | `non_null_shared_ptr` | `non_null>` | -## 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` | ✅ Yes | Reference counting handles move semantics correctly | -| `unique_ptr` | ❌ 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(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`, `non_null_function` | Allowed | Copyable; move is safe | +| `unique_ptr`, `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 diff --git a/include/nova/non_null.hpp b/include/nova/non_null.hpp index 8928354..bd0ff4e 100644 --- a/include/nova/non_null.hpp +++ b/include/nova/non_null.hpp @@ -4,11 +4,27 @@ #pragma once #include +#include +#include #include #include #include #include +#if defined( __has_feature ) +# if __has_feature( address_sanitizer ) +# define NOVA_HAVE_ASAN 1 +# endif +#endif +#if defined( __SANITIZE_ADDRESS__ ) +# define NOVA_HAVE_ASAN 1 +#endif + +#ifdef NOVA_HAVE_ASAN +# include +#endif + + #if defined( __has_cpp_attribute ) # if __has_cpp_attribute( assume ) # define NOVA_ASSUME( expr ) [[assume( expr )]] @@ -45,10 +61,32 @@ # define NOVA_NONNULL_NONTRIVIAL #endif + namespace nova { namespace detail { +#if defined( NOVA_HAVE_ASAN ) +inline void nova_asan_poison( void const* NOVA_NONNULL p, std::size_t s ) noexcept +{ + __asan_poison_memory_region( p, s ); +} +inline void nova_asan_unpoison( void const* NOVA_NONNULL p, std::size_t s ) noexcept +{ + __asan_unpoison_memory_region( p, s ); +} +/* In ASAN builds we cannot keep take() constexpr because it calls runtime + instrumentation functions. Control the constexpr-ness via this macro. */ +# define NOVA_ASAN_CONSTEXPR /* empty */ +#else +inline void nova_asan_poison( void const* NOVA_NONNULL, std::size_t ) noexcept +{} +inline void nova_asan_unpoison( void const* NOVA_NONNULL, std::size_t ) noexcept +{} +# define NOVA_ASAN_CONSTEXPR constexpr +#endif + + template < typename T, typename = void > struct element_type_trait { @@ -79,8 +117,20 @@ constexpr void assume_nonnull( const T& ptr ) noexcept NOVA_ASSUME( is_not_null ); } +template < typename F > +constexpr void assume_not_empty( const F& fn ) noexcept +{ + [[maybe_unused]] const bool is_not_empty = static_cast< bool >( fn ); + assert( is_not_empty && "nova::detail::assume_not_empty: callable cannot be empty" ); + NOVA_ASSUME( is_not_empty ); +} + } // namespace detail +// ============================================================================= +// non_null +// ============================================================================= + /** * @brief A simple non-null wrapper for pointers and smart-pointers. * @@ -131,8 +181,15 @@ class non_null // - For move-only pointers (unique_ptr): move deleted; use take() instead // This prevents accidental moves of move-only types while enabling efficient // moves of copyable types. - non_null( const non_null& ) = default; - non_null& operator=( const non_null& ) = default; + non_null( const non_null& ) = default; + non_null& operator=( const non_null& other ) noexcept + { + // If this object was previously poisoned by take(), ensure we can write + // into ptr_ without ASAN reporting a write to poisoned memory. + detail::nova_asan_unpoison( &ptr_, sizeof( ptr_ ) ); + ptr_ = other.ptr_; + return *this; + } /** * @brief Move constructor, enabled only for copyable pointer types. @@ -144,6 +201,11 @@ class non_null ptr_( std::move( other.ptr_ ) ) {} + ~non_null() + { + detail::nova_asan_unpoison( &ptr_, sizeof( ptr_ ) ); + } + /** * @brief Move assignment, enabled only for copyable pointer types. * Raw pointers and std::shared_ptr support moves; std::unique_ptr does not. @@ -151,6 +213,7 @@ class non_null constexpr non_null& operator=( non_null&& other ) noexcept requires detail::copyable_pointer< T > { + detail::nova_asan_unpoison( &ptr_, sizeof( ptr_ ) ); ptr_ = std::move( other.ptr_ ); return *this; } @@ -173,12 +236,16 @@ class non_null * For copyable pointer types the result can be re-wrapped immediately: * auto nn2 = non_null( take( std::move(nn1) ) ); */ - friend constexpr T NOVA_NONNULL_NONTRIVIAL take( non_null&& nn ) noexcept + friend NOVA_ASAN_CONSTEXPR T NOVA_NONNULL_NONTRIVIAL take( non_null&& nn ) noexcept #if defined( __clang__ ) && ( __clang_major__ >= 20 ) NOVA_RETURNS_NONNULL #endif { - return std::move( nn.ptr_ ); + T tmp = std::move( nn.ptr_ ); + // Poison the source wrapper storage so accidental use-after-take + // triggers ASAN in instrumented builds. + detail::nova_asan_poison( &nn.ptr_, sizeof( nn.ptr_ ) ); + return tmp; } /** @@ -187,6 +254,10 @@ class non_null */ constexpr void swap( non_null& other ) noexcept { + // Unpoison both sides before swapping so the swap operation can write + // into the underlying storage safely under ASAN. + detail::nova_asan_unpoison( &ptr_, sizeof( ptr_ ) ); + detail::nova_asan_unpoison( &other.ptr_, sizeof( other.ptr_ ) ); using std::swap; swap( ptr_, other.ptr_ ); } @@ -464,8 +535,272 @@ inline non_null< std::shared_ptr< T > > make_non_null_shared( Args&&... args ) return non_null( std::make_shared< T >( std::forward< Args >( args )... ) ); } +// ============================================================================= +// non_null_function +// ============================================================================= + +/** + * @brief Primary template declaration — only the function-signature + * specialisation below is defined. + */ +template < typename Signature > +class non_null_function; + +/** + * @brief A non-null wrapper for std::function. + * + * Guarantees the stored callable is never empty (i.e. bool(fn_) == true). + * + * The call operator uses this invariant to let the optimiser eliminate the + * empty-callable check. + */ +template < typename R, typename... Args > +class non_null_function< R( Args... ) > +{ +public: + using result_type = R; + using function_type = std::function< R( Args... ) >; + + /** + * @brief Constructs from any callable that is invocable with (Args...) -> R. + * @param f The callable to wrap. Must not be empty (assert-checked). + */ + template < typename F > + requires std::is_invocable_r_v< R, F, Args... > && (!std::is_same_v< std::decay_t< F >, non_null_function >) + constexpr explicit non_null_function( F&& f ) : + fn_( std::forward< F >( f ) ) + { + detail::assume_not_empty( fn_ ); + } + + ~non_null_function() + { + detail::nova_asan_unpoison( &fn_, sizeof( fn_ ) ); + } + + // Copy ctor and copy assignment are defaulted (std::function is copyable) + non_null_function( const non_null_function& ) = default; + non_null_function& operator=( const non_null_function& other ) + { + // Unpoison target storage in case it was poisoned by a previous take(). + detail::nova_asan_unpoison( &fn_, sizeof( fn_ ) ); + fn_ = other.fn_; + return *this; + } + + // Implicit move: deleted to prevent accidental moves that leave the + // wrapper in an unusable (empty) state. Use take() to transfer ownership explicitly. + non_null_function( non_null_function&& ) = delete; + non_null_function& operator=( non_null_function&& ) = delete; + + // Prevent null assignment / null construction + non_null_function( std::nullptr_t ) = delete; + non_null_function& operator=( std::nullptr_t ) = delete; + + /** + * @brief Invokes the stored callable. + * + */ + template < typename... CallArgs > + R operator()( CallArgs&&... args ) const + { + detail::assume_not_empty( fn_ ); + return fn_( std::forward< CallArgs >( args )... ); + } + + /** + * @brief Returns a const reference to the underlying std::function. + * The rvalue overload is deleted to prevent accidental moves leaving this + * object empty. + */ + constexpr const function_type& underlying() const& noexcept + { + return fn_; + } + function_type underlying() && = delete; + + /** + * @brief Always returns true; the callable is guaranteed non-empty. + */ + constexpr explicit operator bool() const noexcept + { + return true; + } + + /** + * @brief Swaps the managed callables. Both objects remain non-empty. + */ + constexpr void swap( non_null_function& other ) noexcept + { + detail::nova_asan_unpoison( &fn_, sizeof( fn_ ) ); + detail::nova_asan_unpoison( &other.fn_, sizeof( other.fn_ ) ); + fn_.swap( other.fn_ ); + } + + /** + * @brief Explicitly extracts the underlying std::function, consuming the + * non_null_function wrapper. + * + * After this call the non_null_function is in a moved-from state and must + * not be used. + */ + friend function_type take( non_null_function&& nn ) noexcept + { + function_type tmp = std::move( nn.fn_ ); + detail::nova_asan_poison( &nn.fn_, sizeof( nn.fn_ ) ); + return tmp; + } + +private: + function_type NOVA_NONNULL_NONTRIVIAL fn_; +}; + +/** + * @brief Deduction guide: deduce the function signature from a plain function + * pointer. + */ +template < typename R, typename... Args > +non_null_function( R ( *NOVA_NONNULL )( Args... ) ) -> non_null_function< R( Args... ) >; + +/** + * @brief ADL swap for non_null_function. + */ +template < typename Sig > +void swap( non_null_function< Sig >& lhs, non_null_function< Sig >& rhs ) noexcept +{ + lhs.swap( rhs ); +} + +#if defined( __cpp_lib_move_only_function ) && __cpp_lib_move_only_function >= 202110L + +// ============================================================================= +// non_null_move_only_function (C++23) +// ============================================================================= + +/** + * @brief Primary template declaration. + */ +template < typename Signature > +class non_null_move_only_function; + +/** + * @brief A non-null wrapper for std::move_only_function. + * + * Guarantees the stored callable is never empty (i.e. bool(fn_) == true). + * + * The call operator uses this invariant to let the optimiser eliminate the + * empty-callable check. + * + * Note: unlike the name suggests, the function wrapper is not movable. To prevent + * accidental moves that would leave the wrapper empty. Use take() to transfer + * ownership explicitly. + */ +template < typename R, typename... Args > +class non_null_move_only_function< R( Args... ) > +{ +public: + using result_type = R; + using function_type = std::move_only_function< R( Args... ) >; + + /** + * @brief Constructs from any callable that is invocable with (Args...) -> R. + * @param f The callable to wrap. Must not be empty (assert-checked). + */ + template < typename F > + requires std::is_invocable_r_v< R, F, Args... > + && (!std::is_same_v< std::decay_t< F >, non_null_move_only_function >) + constexpr explicit non_null_move_only_function( F&& f ) : + fn_( std::forward< F >( f ) ) + { + detail::assume_not_empty( fn_ ); + } + + // Copy: deleted (move_only_function is not copyable) + non_null_move_only_function( const non_null_move_only_function& ) = delete; + non_null_move_only_function& operator=( const non_null_move_only_function& ) = delete; + + // Implicit move: deleted to prevent accidental moves that leave the + // wrapper in an unusable (empty) state. Use take() to transfer ownership explicitly. + non_null_move_only_function( non_null_move_only_function&& ) = delete; + non_null_move_only_function& operator=( non_null_move_only_function&& ) = delete; + + // Prevent null assignment / null construction + non_null_move_only_function( std::nullptr_t ) = delete; + non_null_move_only_function& operator=( std::nullptr_t ) = delete; + + /** + * @brief Invokes the stored callable. + * + */ + template < typename... CallArgs > + R operator()( CallArgs&&... args ) + { + detail::assume_not_empty( fn_ ); + return fn_( std::forward< CallArgs >( args )... ); + } + + /** + * @brief Returns a const reference to the underlying move_only_function. + */ + constexpr const function_type& underlying() const& noexcept + { + return fn_; + } + function_type underlying() && = delete; + + /** + * @brief Always returns true; the callable is guaranteed non-empty. + */ + constexpr explicit operator bool() const noexcept + { + return true; + } + + /** + * @brief Swaps the managed callables. Both objects remain non-empty. + */ + constexpr void swap( non_null_move_only_function& other ) noexcept + { + detail::nova_asan_unpoison( &fn_, sizeof( fn_ ) ); + detail::nova_asan_unpoison( &other.fn_, sizeof( other.fn_ ) ); + fn_.swap( other.fn_ ); + } + + /** + * @brief Explicitly extracts the underlying move_only_function, consuming + * the non_null_move_only_function wrapper. + * + * After this call the wrapper is in a moved-from state and must not be used. + * This is the only safe way to transfer ownership out of a + * non_null_move_only_function, mirroring take() for non_null. + */ + friend function_type take( non_null_move_only_function&& nn ) noexcept + { + function_type tmp = std::move( nn.fn_ ); + detail::nova_asan_poison( &nn.fn_, sizeof( nn.fn_ ) ); + return tmp; + } + +private: + function_type NOVA_NONNULL_NONTRIVIAL fn_; +}; + +/** + * @brief ADL swap for non_null_move_only_function. + */ +template < typename Sig > +void swap( non_null_move_only_function< Sig >& lhs, non_null_move_only_function< Sig >& rhs ) noexcept +{ + lhs.swap( rhs ); +} + +#endif // __cpp_lib_move_only_function + } // namespace nova #undef NOVA_ASSUME #undef NOVA_RETURNS_NONNULL #undef NOVA_NONNULL +#ifdef NOVA_HAVE_ASAN +# undef NOVA_HAVE_ASAN +#endif diff --git a/tests/asan_take.cpp b/tests/asan_take.cpp new file mode 100644 index 0000000..cfbdfa6 --- /dev/null +++ b/tests/asan_take.cpp @@ -0,0 +1,24 @@ +// Simple ASAN smoke test: use-after-take should be reported by AddressSanitizer. +#include + +#include + +int main() +{ + using namespace nova; + + auto nn = make_non_null_unique< int >( 42 ); + + // Extract the unique_ptr out of the non_null wrapper. + auto up = take( std::move( nn ) ); + + // nn is now moved-from; accessing it should trigger ASAN when built with -fsanitize=address + // Intentional use-after-take: + std::cerr << "About to do use-after-take (expect ASAN)\n"; + // Force a call that will read the wrapper state + volatile auto p = nn.get(); + (void)p; + + (void)up; + return 0; +} diff --git a/tests/test_non_null.cpp b/tests/test_non_null.cpp index 6c28017..6748e4a 100644 --- a/tests/test_non_null.cpp +++ b/tests/test_non_null.cpp @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// SPDX-FileCopyrightText: 2026 Nova Project Contributors +// SPDX-FileCopyrightText: 2026 Tim Blechmann #include #include diff --git a/tests/test_non_null_function.cpp b/tests/test_non_null_function.cpp new file mode 100644 index 0000000..8238ef5 --- /dev/null +++ b/tests/test_non_null_function.cpp @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Tim Blechmann + +#include +#include + +#include + +#include + +// ============================================================================= +// non_null_function tests +// ============================================================================= + +TEST_CASE( "non_null_function - construction from lambda", "[non_null_function]" ) +{ + nova::non_null_function< int( int ) > fn( []( int x ) { + return x * 2; + } ); + CHECK( fn( 21 ) == 42 ); +} + +TEST_CASE( "non_null_function - construction from function pointer", "[non_null_function]" ) +{ + struct Helper + { + static int triple( int x ) + { + return x * 3; + } + }; + + nova::non_null_function< int( int ) > fn( &Helper::triple ); + CHECK( fn( 7 ) == 21 ); +} + +TEST_CASE( "non_null_function - construction from std::function", "[non_null_function]" ) +{ + std::function< int( int ) > base = []( int x ) { + return x + 1; + }; + nova::non_null_function< int( int ) > fn( base ); + CHECK( fn( 41 ) == 42 ); +} + +TEST_CASE( "non_null_function - deduction guide from function pointer", "[non_null_function]" ) +{ + struct Helper + { + static int inc( int x ) + { + return x + 1; + } + }; + + nova::non_null_function fn( &Helper::inc ); + static_assert( std::is_same_v< decltype( fn ), nova::non_null_function< int( int ) > > ); + CHECK( fn( 5 ) == 6 ); +} + +TEST_CASE( "non_null_function - operator bool is always true", "[non_null_function]" ) +{ + nova::non_null_function< void() > fn( [] {} ); + CHECK( static_cast< bool >( fn ) ); +} + +TEST_CASE( "non_null_function - copy construction", "[non_null_function]" ) +{ + int counter = 0; + nova::non_null_function< void() > fn1( [ & ] { + ++counter; + } ); + const nova::non_null_function< void() > fn2( fn1 ); // copy + fn1(); + fn2(); + CHECK( counter == 2 ); +} + +TEST_CASE( "non_null_function - copy assignment", "[non_null_function]" ) +{ + int a = 0, b = 0; + nova::non_null_function< void() > fn1( [ & ] { + ++a; + } ); + nova::non_null_function< void() > fn2( [ & ] { + ++b; + } ); + fn2 = fn1; + fn2(); + CHECK( a == 1 ); + CHECK( b == 0 ); +} + +TEST_CASE( "non_null_function - take() extracts underlying function", "[non_null_function]" ) +{ + nova::non_null_function< int( int ) > fn( []( int x ) { + return x + 10; + } ); + auto raw = take( std::move( fn ) ); + static_assert( std::is_same_v< decltype( raw ), std::function< int( int ) > > ); + CHECK( raw( 32 ) == 42 ); +} + +TEST_CASE( "non_null_function - take() and re-wrap", "[non_null_function]" ) +{ + nova::non_null_function< int() > fn1( [] { + return 7; + } ); + auto fn2 = nova::non_null_function< int() >( take( std::move( fn1 ) ) ); + CHECK( fn2() == 7 ); +} + + +TEST_CASE( "non_null_function - swap", "[non_null_function]" ) +{ + nova::non_null_function< int() > fn1( [] { + return 1; + } ); + nova::non_null_function< int() > fn2( [] { + return 2; + } ); + fn1.swap( fn2 ); + CHECK( fn1() == 2 ); + CHECK( fn2() == 1 ); +} + +TEST_CASE( "non_null_function - ADL swap", "[non_null_function]" ) +{ + nova::non_null_function< int() > fn1( [] { + return 10; + } ); + nova::non_null_function< int() > fn2( [] { + return 20; + } ); + using std::swap; + swap( fn1, fn2 ); + CHECK( fn1() == 20 ); + CHECK( fn2() == 10 ); +} + +TEST_CASE( "non_null_function - underlying() accessor", "[non_null_function]" ) +{ + nova::non_null_function< int( int ) > fn( []( int x ) { + return x; + } ); + const std::function< int( int ) >& underlying = fn.underlying(); + CHECK( underlying( 55 ) == 55 ); +} + +TEST_CASE( "non_null_function - result_type and function_type aliases", "[non_null_function]" ) +{ + using Fn = nova::non_null_function< int( double ) >; + static_assert( std::is_same_v< Fn::result_type, int > ); + static_assert( std::is_same_v< Fn::function_type, std::function< int( double ) > > ); +} + +// Null construction must be deleted (compile-time enforcement) +static_assert( !std::is_constructible_v< nova::non_null_function< int() >, std::nullptr_t > ); + +// ============================================================================= +// non_null_move_only_function tests (C++23 only) +// ============================================================================= + +#if defined( __cpp_lib_move_only_function ) && __cpp_lib_move_only_function >= 202110L + +TEST_CASE( "non_null_move_only_function - construction from lambda", "[non_null_move_only_function]" ) +{ + nova::non_null_move_only_function< int( int ) > fn( []( int x ) { + return x * 2; + } ); + CHECK( fn( 21 ) == 42 ); +} + +TEST_CASE( "non_null_move_only_function - move-only capture", "[non_null_move_only_function]" ) +{ + // Capture a unique_ptr — only possible with move_only_function + auto up = std::make_unique< int >( 99 ); + nova::non_null_move_only_function< int() > fn( [ p = std::move( up ) ]() { + return *p; + } ); + CHECK( fn() == 99 ); +} + +TEST_CASE( "non_null_move_only_function - operator bool is always true", "[non_null_move_only_function]" ) +{ + nova::non_null_move_only_function< void() > fn( [] {} ); + CHECK( static_cast< bool >( fn ) ); +} + +TEST_CASE( "non_null_move_only_function - take() extracts underlying function", "[non_null_move_only_function]" ) +{ + nova::non_null_move_only_function< int( int ) > fn( []( int x ) { + return x + 10; + } ); + auto raw = take( std::move( fn ) ); + static_assert( std::is_same_v< decltype( raw ), std::move_only_function< int( int ) > > ); + CHECK( raw( 32 ) == 42 ); +} + +TEST_CASE( "non_null_move_only_function - take() and re-wrap", "[non_null_move_only_function]" ) +{ + nova::non_null_move_only_function< int() > fn1( [] { + return 7; + } ); + auto fn2 = nova::non_null_move_only_function< int() >( take( std::move( fn1 ) ) ); + CHECK( fn2() == 7 ); +} + +TEST_CASE( "non_null_move_only_function - swap", "[non_null_move_only_function]" ) +{ + nova::non_null_move_only_function< int() > fn1( [] { + return 1; + } ); + nova::non_null_move_only_function< int() > fn2( [] { + return 2; + } ); + fn1.swap( fn2 ); + CHECK( fn1() == 2 ); + CHECK( fn2() == 1 ); +} + +TEST_CASE( "non_null_move_only_function - ADL swap", "[non_null_move_only_function]" ) +{ + nova::non_null_move_only_function< int() > fn1( [] { + return 10; + } ); + nova::non_null_move_only_function< int() > fn2( [] { + return 20; + } ); + using std::swap; + swap( fn1, fn2 ); + CHECK( fn1() == 20 ); + CHECK( fn2() == 10 ); +} + +TEST_CASE( "non_null_move_only_function - underlying() accessor", "[non_null_move_only_function]" ) +{ + nova::non_null_move_only_function< int( int ) > fn( []( int x ) { + return x; + } ); + const std::move_only_function< int( int ) >& underlying = fn.underlying(); + CHECK( const_cast< std::move_only_function< int( int ) >& >( underlying )( 77 ) == 77 ); +} + +TEST_CASE( "non_null_move_only_function - result_type and function_type aliases", "[non_null_move_only_function]" ) +{ + using Fn = nova::non_null_move_only_function< int( double ) >; + static_assert( std::is_same_v< Fn::result_type, int > ); + static_assert( std::is_same_v< Fn::function_type, std::move_only_function< int( double ) > > ); +} + +// Copy must be deleted +static_assert( !std::is_copy_constructible_v< nova::non_null_move_only_function< int() > > ); +static_assert( !std::is_copy_assignable_v< nova::non_null_move_only_function< int() > > ); + +// Implicit move must be deleted (use take() instead) +static_assert( !std::is_move_constructible_v< nova::non_null_move_only_function< int() > > ); +static_assert( !std::is_move_assignable_v< nova::non_null_move_only_function< int() > > ); + +// Null construction must be deleted +static_assert( !std::is_constructible_v< nova::non_null_move_only_function< int() >, std::nullptr_t > ); + +#endif // __cpp_lib_move_only_function