constexpr pointer tagging

This paper proposes a new library non-owning pointer type to semantically represent tagged pointer of various schemes. This standardizes existing practice which currently can't be expressed in C++ without triggering undefined behaviour. This proposal also makes this technique available during constant evaluation which allows number of use-cases to be implementable in constexpr.

Revision history

  • R0 → R1: proposing and explaining design based on existing implementation

Introduction and motivation

Pointer tagging is widely known and used technique (Glasgow Haskell Compiler, LLVM's PointerIntPair, PointerUnion, CPython's garbage collector, Objective C / Swift, Chrome's V8 JavaScript engine, GAP, OCaml, PBRT). All major CPU vendors provides mechanism for pointer tagging (Intel's LAM linear address masking, AMD's Upper Address Ignore, ARM's TBI top byte ignore and MTE memory tagging extension). All widely used 64 bit platforms are not using more than 48 or 49 bits of the pointers.

This functionality widely supported can't be expressed in a standard conforming way.

Rust, Dlang, or Zig has an interface for pointer tagging. This is demonstrating demand for the feature and C++ should have it too and it should also work in constexpr.

Generally programmer can consider low-bits used for alignment to be safely used for storing an information. Upper bits are available on different platforms under different conditions (runtime processor setting, CPU generation, ...). This proposal is trying to make a design which will allow vendors to provide their own extension to access this functionality while allowing interop with standard code.

These extensions brings a major adventage as they allow to dereferencing a tagged pointer without any actual unmasking. This approach has performance advantage, but the major usage for pointer tagging is memory size / throughput limitations, not CPU performance. Interface of this proposal has a such fast-path provided.

Use-cases

There are three basic use-cases:

  • marking pointer with an information (in allocators, used as a tiny refcount, or marking source of the pointer)
  • pointee polymorphism (usually in data structures, eg. in trees: next node can be an internal node or a leaf)
  • in-place polymorphism (similarly as variant, some bits stores an information about rest of payload, it can be a pointer, a number, a small string, ...)

This paper aims to solve first two use-cases and give tools to build the third use-case safely to users.

Safety

Pointer tagging is currently implementable only with reinterpret_cast bit manipulating pointers and is prone to be unsafe and hard to debug as it's not expressed clearly in code what is the intention. By giving a name to the tool it allows programmer to express intent clearly and compiler to diagnose better.

Any unsafe operation like accessing raw tagged pointer or not unmasked but valid pointer must be clearly visible in the code, this proposal is trying to keep these points visible.

Examples

Following example is a recursive search implementation for a HAMT (hash-array-mapped-trie) data structure. Where a tag value indicates leaf node.

// nodes are always have at least alignment 2
using hamt_node_pointer = std::tagged_ptr<const void, bool, std::custom_alignment_tag<2>>
static_assert(sizeof(hamt_node_pointer) == sizeof(void *));

constexpr const T * find_value_in_hamt(hamt_node_pointer tptr, uint32_t hash) {
	if (tptr == nullptr) // checks only pointer part
		return nullptr;
	
	if (tptr.tag()) // we found leaf node, tag is boolean as specified
		return *static_cast<const T *>(tptr.pointer());
	
	const auto * node = static_cast<const internal_node *>(tptr.pointer());
	const auto next_node = node[hash & 0b1111u];
	
	return find_value_in_hamt(next_node, hash >> 4); // recursive descend
}

This example shows maybe_owning_ptr type which can be both a reference or an owner:

template <typename T> class maybe_owning_ptr {
  enum class ownership {
    reference,
    owning,
  };
  
  std::tagged_ptr<T, ownership, std::alignment_low_bits_tag> _ptr;
public:
  constexpr maybe_owning_ptr(T* && pointer) noexcept: _ptr{pointer, ownership::owning} { }
  constexpr maybe_owning_ptr(T & ref) noexcept: _ptr{&ref, ownership::reference} { }
  
  constexpr decltype(auto) operator*() const noexcept {
    return *_ptr.pointer();
  }
  
  constexpr T * operator->() const noexcept {
    return _ptr.pointer();
  }
  
  constexpr ~maybe_owning_ptr() noexcept {
    if (_ptr.tag() == ownership::owning) {
      delete _ptr.pointer();
    }
  }
};

static_assert(sizeof(maybe_owning_ptr<int>) == sizeof(int *));

Implementation experience

This proposal has been implemented within libc++ & clang and it is accessible on github and compiler explorer. This functionality can't be implemented as a pure library and needs compiler support in some form.

Implementation in the library

Library is providing a "smart" non-owning pointer type named tagged_ptr which allows user to provide pointee type, tag type, and tagging scheme how the tag is stored.

There are various schemas provided for various tagging approaches to allow the library interact with them as this is intended as low-level tool to safely abstract existign code.

In terms of library design there is nothing surprising, and it's pretty straightforward wrapper which encode and decode tagged pointer on its boundaries and provides basic pointer functionality.

Accessing raw tagged pointers

Library has support to access dirty and aliasing pointers. Dirty pointer is a pointer which can't be dereferenced or otherwise manipulated with, it's an opaque value usable only with legacy tagging interface or it can be used to construct tagged_ptr back from it. Existence of this interface allows ability to store such pointers in existing interfaces (atomic, other smart pointers).

Aliasing pointer is a pointer which can be dereferenced and points at original pointed object, but can have different representation and hence comparing it with original pointer will yield tagging scheme specific result. This is implemented as a fast-track (non-default) interface on platforms with special tagging schemes (Aarch64 upper byte of pointer can be safely changed.)

Available tagging schemas

Schemas provided in the library are covering all types of pointer tagging (deduced alignment bits, custom alignment bits, pointer shifting, custom bitmask), default is deduced alignment bits schema. Intention is to allow users / vendors to build specific schemas on standard provided one.

Implementation in the compiler

The implementation is providing C-like builtins to manipulating raw pointers and isn't mean to be used by end-users, only to allow library functionality.

Compiler builtins

Implementation needs to manipulate pointers without casting them to integers and back. To do so the provided set of builtins is designed to store/load a value (with size of few bits) into/from unimportant/unused bits of a pointer without revealing actual pointer representation.

Constexpr support

These builints are trivial to implement semanticaly identical behaviour for the constant evaluation. Pointers in clang are not represented as addresses but as symbols (original AST variable, allocation, static object + path to subobject, its provenance), there is no address to manipulate. Actual tag value in "pointer" is stored in a metadata of the pointer itself and builtins only provide access to it.

Any attempt to deference or otherwise manipulate such pointer, which would be unsafe in runtime, is detected and reported by the interpreter. Only the provided builtins can access original pointer and tag value.

Provenance

Easiest way to implement builtins is to do the same thing reinterpret_cast is doing, which was my first implementation approach. But this approach leads to loosing pointer's provenance and compiler loosing information which otherwise should be accessible for optimizer to use.

For unmasking there is already ptr.mask LLVM's builtin, but there is no similar intrinsic to do the tagging. Hence the builtins needs to interact with backend and be implemented with a low level backend intrinsic to do the right thing. This shows how actually unimplementable pointer tagging is in existing language.

Alternative constexpr approach

Alternative way to implement constexpr support (for compiler which don't have heavy pointer representation in their interprets) is inserting a hidden intermediate object holding the metadata and pointer to original object. This allows exactly same semantic as the metadata approach.

Design of the proposed functionality

Main objective of this design is to allow various tagging schemas for storing a few bits of information inside a pointer. Some more advanced use-cases can be build on top of current design and my intention is to provide a safe way for tagging, not solve every use-case there is.

template <typename Pointee, typename TagType, typename Schema> class tagged_ptr {
	dirty_pointer _pointer{nullptr};
public:
	using schema = typename Schema::schema<Pointee, TagType>;
	using clean_pointer = schema::clean_pointer;
	using dirty_pointer = schema::dirty_pointer; 
	using tag_type = schema::tag_type;
	
	using difference_type = std::pointer_traits<clean_pointer>::difference_type;
	using element_type = std::pointer_traits<clean_pointer>::element_type;
	
	// constructors
	tagged_ptr() = default;
	constexpr tagged_ptr(nullptr_t) noexcept;
	tagged_ptr(const tagged_ptr &) = default;
	tagged_ptr(tagged_ptr &&) = default;
	
	// to construct from already tagged pointer from external facility
	constexpr tagged_ptr(std::already_tagged_t, dirty_pointer ptr) noexcept;
	
	// to store and tag pointer (only if eligible)
	constexpr tagged_ptr(clean_pointer ptr, tag_type tag);
	
	// to store and tag pointer for over-aligned pointers which wouldn't be eligible otherwise
	template <size_t Alignment> constexpr tagged_ptr(std::overaligned_pointer_t<Alignment>, clean_pointer ptr, tag_type tag);
	
	// to communicate knowledge about unused bits in pointer to make it eligible
	template <uintptr_t UnusedBits> constexpr tagged_ptr(std::known_unused_bits_t<UnusedBits>, clean_pointer ptr, tag_type tag);
	
	// destructor
	~tagged_ptr() = default;
	
	// accessors
	constexpr dirty_pointer unsafe_dirty_pointer() const noexcept;
	constexpr clean_pointer aliasing_pointer() const noexcept; // fast-path if available otherwise .pointer()
	constexpr clean_pointer pointer() const noexcept;
	constexpr tag_type tag() const noexcept;
	
	constexpr decltype(auto) operator*() const noexcept; // *pointer()
	constexpr clean_pointer operator->() const noexcept; // *pointer()
	constexpr decltype(auto) operator[](auto... args) const noexcept; // only for multidimensional arrays
	constexpr decltype(auto) operator[](difference_type diff) const noexcept; // only for arrays
	
	// support for tuple protocol to access [pointer(), tag()]
	template <size_t I> friend constexpr decltype(auto) get(tagged_ptr _pair) noexcept; 
	
	constexpr explicit operator bool() const noexcept; // pointer() != nullptr
	
	// swap
	friend constexpr void swap(tagged_ptr & lhs, tagged_ptr & rhs) noexcept;
	
	// all the things which makes this ordinary-ish pointer
	constexpr auto & operator++() noexcept;
	constexpr auto operator++(int) noexcept;
	constexpr auto & operator--() noexcept;
	constexpr auto operator--(int) noexcept;
	constexpr auto & operator+=(difference_type diff) noexcept;
	constexpr auto & operator-=(difference_type diff) noexcept;
	friend constexpr auto operator+(tagged_ptr lhs, difference_type diff) noexcept;
	friend constexpr auto operator+(difference_type diff, tagged_ptr rhs) noexcept;
	friend constexpr auto operator-(tagged_ptr lhs, difference_type diff) noexcept;
	friend constexpr auto operator-(difference_type diff, tagged_ptr rhs) noexcept;
	
	// difference only of pointer() - pointer()
	friend constexpr ptrdiff_t operator-(tagged_ptr lhs, tagged_ptr rhs) noexcept;
	
	// comparing {pointer(), tag()} <=> {pointer(), tag()}
	friend constexpr auto operator<=>(tagged_ptr lhs, tagged_ptr rhs) noexcept;
	friend bool operator==(tagged_ptr, tagged_ptr) = default;
	
	// comparing only pointer() part
	friend constexpr auto operator<=>(tagged_ptr lhs, clean_pointer rhs) noexcept;
	friend constexpr bool operator==(tagged_ptr lhs, clean_pointer rhs) noexcept;
	friend constexpr bool operator==(tagged_ptr lhs, nullptr_t rhs) noexcept; // same as operator bool
};
	

There is more, like pointer_traits, iterator_traits, tuple_size, and tuple_element but these are all straight-forward for a pointer-like object.

Schema design

Schema is trait-like type which provides functionality of encoding pointer, recovering pointer and tag out of encoded pointer. As all functionality is depending on pointee type and tag type, everything is a template inside of other type. This approach is important as symbol name of tagged_ptr will visible contain its semantic type, not implementation details (eg. tagged_ptr<int, bool, alignment_low_bits_tag>).

To explain properly let's start with simple design of a no-op tagging schema (not proposed):

struct no_tag {
  template <typename Pointee, typename Tag> struct schema {
    using clean_pointer = Pointee *;
    using dirty_pointer = uintptr_t; // or void * or clean_pointer itself
    using tag_type = Tag;

		// inform tagged_ptr about which bits are used
    static constexpr auto used_bits = 0u;

    static constexpr dirty_pointer encode_pointer_with_tag(clean_pointer ptr, tag_type) noexcept {
      return magic_constexpr_reinterpret_cast<dirty_pointer>(ptr);
    }
		
    static constexpr clean_pointer recover_pointer(dirty_pointer tptr) noexcept {
      return magic_constexpr_reinterpret_cast<clean_pointer>(tptr);
    }
		
		// there is no fast-path, it's not needed to be provided
    // static constexpr clean_pointer recover_aliasing_pointer(dirty_pointer tptr) noexcept {
    //   return recover_pointer(tptr); 
    // }
		
    static constexpr tag_type recover_tag(dirty_pointer) noexcept {
      return tag_type{}; // 0 bit of information here
    }
  };
};
	

Each schema has its typedefs which allows to change types on boundaries of tagged_ptr type. Schema also provides information about used bits so tagged_ptr's constructors can have static checks to protect users.

Main functionality are functions: encode_pointer_with_tag, recover_pointer, recover_tag, and optional recover_aliasing_pointer. These functions are building blocks for tagged_ptr which only provides additional safety and name for different type of pointer, but it's not doing anything special.

Actually useful schemas

To provide any actual value, library needs to provide some building blocks. To be used by tagged_ptr or even some other user-code (for unforeseen and extended use-cases.

template <uintptr_t Mask> struct bitmask_tag {
  template <typename Pointee, typename Tag> struct schema {
    using clean_pointer = Pointee *;
    using dirty_pointer = void *; // it's still pointer, but you can't dereference it
    using tag_type = Tag;
    
    static constexpr auto used_bits = Mask;

    static constexpr dirty_pointer encode_pointer_with_tag(clean_pointer ptr, tag_type value) noexcept {
      #if __has_builtin(magical_pointer_tagging_builtin)
        return static_cast<dirty_pointer>(magical_pointer_tagging_builtin(ptr, static_cast<uintptr_t>(value), Mask));
      #else
        return reinterpret_cast<dirty_pointer>((reinterpret_cast<uintptr_t>(ptr) & static_cast<uintptr_t>(Mask)) | (static_cast<uintptr_t>(value) & ~static_cast<uintptr_t>(Mask)));
      #endif
    }
    static constexpr clean_pointer recover_pointer(dirty_pointer tptr) noexcept {
      #if __has_builtin(magical_pointer_recovering_builtin)
        return static_cast<clean_pointer>(magical_pointer_recovering_builtin(tptr, ~Mask));
      #else
        return reinterpret_cast<clean_pointer>(reinterpret_cast<uintptr_t>(tptr) & ~static_cast<uintptr_t>(Mask));
      #endif
    }
    static constexpr tag_type recover_tag(dirty_pointer tptr) noexcept {
      #if __has_builtin(magical_tag_recovering_builtin)
        return static_cast<tag_type>(magical_tag_recovering_builtin(tptr, Mask));
      #else
        return static_cast<tag_type>(reinterpret_cast<uintptr_t>(tptr) & static_cast<uintptr_t>(Mask));
      #endif
    }
  };
};

This is the most important schema providing most of the functionality, but it's not user-friendly to provide specific bits. Sometimes you want to provide specific alignment instead:

template <unsigned Alignment> struct custom_alignment_tag {
  static_assert(std::has_single_bit(Alignment), "alignment must be power of 2");
  static constexpr uintptr_t mask = static_cast<uintptr_t>(Alignment) - 1u;
  
  template <typename Pointee, typename Tag> struct schema: bitmask_tag<mask>::template schema<Pointee, Tag> {
    using _underlying_schema = bitmask_tag<mask>::template schema<Pointee, Tag>;
    
    using typename _underlying_schema::clean_pointer;
    using typename _underlying_schema::dirty_pointer;
    using typename _underlying_schema::tag_type;
  
    static constexpr dirty_pointer encode_pointer_with_tag(clean_pointer _ptr, tag_type _value) {
      // we can add precondition the pointer is actually aligned to be compatible with this schema
      _LIBCPP_ASSERT_ARGUMENT_WITHIN_DOMAIN(std::__is_ptr_aligned(_ptr, Alignment), "Pointer must be aligned by provided alignment for tagging");
      return _underlying_schema::encode_pointer_with_tag(_ptr, _value);
    }
  };
};

This schema shows how easily you can build on top existing schemas. But we can go further...

struct alignment_low_bits_tag {
  template <typename Pointee, typename Tag> using schema = typename custom_alignment_tag<alignof(Pointee)>::template schema<Pointee, Tag>;
};

And just like this we gave a name to a tagging schema which clearly communicates it uses pointer's alignment low bits for tagging.

But in order to be functionaly complete we also need to support pointer shifting tagging schema:

template <unsigned Bits> struct left_shift_tag {
  static constexpr unsigned _shift = Bits;
  static constexpr uintptr_t _mask = (uintptr_t{1u} << _shift) - 1u;

  template <typename T, typename Tag> struct schema {
    using clean_pointer = T *;
    using dirty_pointer = void *;
    using tag_type = Tag;
    
    static constexpr uintptr_t used_bits = ~((~uintptr_t{0}) >> _shift);

    static constexpr dirty_pointer encode_pointer_with_tag(clean_pointer ptr, tag_type value) noexcept {
      #if __has_builtin(magical_pointer_shifting_and_tagging_builtin)
        return static_cast<dirty_pointer>(magical_pointer_shifting_and_tagging_builtin((ptr), static_cast<uintptr_t>(value), _shift));
      #else
        return reinterpret_cast<dirty_pointer>((reinterpret_cast<uintptr_t>(ptr) << _shift) | (static_cast<uintptr_t>(value) & ((1ull << static_cast<uintptr_t>(_shift)) - 1ull)));
      #endif
    }
    static constexpr clean_pointer recover_pointer(dirty_pointer tpptr) noexcept {
      #if __has_builtin(magical_pointer_unshiftinf_builtin)
        return static_cast<clean_pointer>(magical_pointer_unshiftinf_builtin(tpptr, _shift));
      #else
        return reinterpret_cast<clean_pointer>(reinterpret_cast<uintptr_t>(tpptr) >> _shift);
      #endif
    }
    static constexpr tag_type recover_tag(dirty_pointer tptr) noexcept {
      #if __has_builtin(magical_tag_recovering_builtin)
        return static_cast<tag_type>(magical_tag_recovering_builtin(tptr, _mask));
      #else
        return static_cast<tag_type>(reinterpret_cast<uintptr_t>(tptr) & static_cast<uintptr_t>(_mask));
      #endif      
    }
  };
};

With this generic pointer tagging schema we can build a named one:

struct low_byte_tag {
  template <typename T, typename Tag> using schema = typename left_shift_tag<8>::template schema<T, Tag>;
};

These were all proposed tagging schemas, but vendors can decide to provide useful tagging schema for AArch64 platform:

struct upper_byte_tag {
  template <typename T> static constexpr unsigned _shift = sizeof(T *) * 8ull - 8ull;
  template <typename T> static constexpr uintptr_t _mask = 0b1111'1111ull << _shift<T>;
  
  template <typename T, typename Tag> struct schema: bitmask_tag<_mask<T>>::template schema<T, Tag> {
    using _underlying_schema = bitmask_tag<_mask<T>>::template schema<T, Tag>;
    
    using typename _underlying_schema::clean_pointer;
    using typename _underlying_schema::dirty_pointer;
    using typename _underlying_schema::tag_type;
  
    // only difference against the mask is a presence of fast path 
    // functionality specific for target platform
    static constexpr clean_pointer recover_aliasing_pointer(dirty_pointer _ptr) noexcept {
      return (clean_pointer)_ptr;
    }
    
    using _underlying_schema::encode_pointer_with_tag;
    using _underlying_schema::recover_pointer;
    using _underlying_schema::recover_tag;
  };
};

Recovering clean pointer

On certain platforms it's expected from pointers to have some bits set to specific values to be dereferencable, this is supposed to happen in schema's pointer recovery, not just masking with zeros.

Constructor safety

Biggest problems is we don't know what bits are available for tagging other than alignment. I propose to add a static implementation specific value in pointer_traits::free_bits for raw pointers communicating what bits are guarantee to be available.

constexpr tagged_ptr(pointer ptr, tag_type tag) noexcept;
template <size_t Alignment> constexpr tagged_ptr(std::overaligned_pointer_t<Alignment>, clean_pointer ptr, tag_type tag) noexcept;
template <uintptr_t UnusedBits> constexpr tagged_ptr(std::known_unused_bits_t<UnusedBits>, clean_pointer ptr, tag_type tag) noexcept;

First constructor tagged_ptr(pointer ptr, tag_type tag) will be available only if pointer has free bits compatible with tagged_ptr's tagging schema.

Second constructor allows user to communicate over-alignment, provided bits calculated from Alignment are added before checking if the pointer is eligible.

Last constructor allows user to communicate specific bits of pointer are available for tagging, this API is there so user can use schemas which are depending on runtime properties and pointer_traits free bits information can't be guaranteed.

Preconditions of constructors

In addition to static safety checks, constructors will have preconditions requiring the original pointer to be recoverable with call of .pointer() and tag of identical value to be recoverable with call of .tag()

Interaction with sanitizers

When building with an upper bits using sanitizer enabled, library should mask out some bits from pointer_traits::free_bits.

Pointer casting

Design provides same casting mechanism as shared_ptr:

template <typename DesiredPointee, typename Pointee, typename TagType, typename Schema>
constexpr auto const_pointer_cast(tagged_ptr<Pointee, TagType, Schema> in) {
  return tagged_ptr<DesiredPointee, TagType, Schema>{const_cast<DesiredPointee*>(in.pointer()), in.tag()};
}

template <typename DesiredPointee, typename Pointee, typename TagType, typename Schema>
constexpr auto static_pointer_cast(tagged_ptr<Pointee, TagType, Schema> in) {
  return tagged_ptr<DesiredPointee, TagType, Schema>{static_cast<DesiredPointee*>(in.pointer()), in.tag()};
}

template <typename DesiredPointee, typename Pointee, typename TagType, typename Schema>
constexpr auto dynamic_pointer_cast(tagged_ptr<Pointee, TagType, Schema> in) {
  return tagged_ptr<DesiredPointee, TagType, Schema>{dynamic_cast<DesiredPointee*>(in.pointer()), in.tag()};
}

// not constexpr
template <typename DesiredPointee, typename Pointee, typename TagType, typename Schema>
auto reinterpret_pointer_cast(tagged_ptr<Pointee, TagType, Schema> in) {
  return tagged_ptr<DesiredPointee, TagType, Schema>{reinterpret_cast<DesiredPointee*>(in.pointer()), in.tag()};
}

template <typename DesiredSchema, typename Pointee, typename TagType, typename Schema>
constexpr auto scheme_pointer_cast(tagged_ptr<Pointee, TagType, Schema> in) noexcept {
  return tagged_ptr<Pointee, TagType, DesiredSchema>{in.pointer(), in.tag()};
}

pointer_traits, iterator_traits, tuple protocol

template <typename _T, typename _Tag, typename _Schema> 
static constexpr auto to_address(tagged_ptr<_T, _Tag, _Schema> p) noexcept -> tagged_ptr<_T, _Tag, _Schema>::element_type * {
  return p.pointer();
}

// iterator traits
template <typename _T, typename _Tag, typename _Schema>
struct iterator_traits<tagged_ptr<_T, _Tag, _Schema>> {
  using _tagged_ptr = tagged_ptr<_T, _Tag, _Schema>;
  
  using iterator_category = std::random_access_iterator_tag;
  using iterator_concept = std::contiguous_iterator_tag;
  
  using value_type = _tagged_ptr::element_type;
  using reference = value_type &;
  using pointer = _tagged_ptr::clean_pointer;
  using difference_type = _tagged_ptr::difference_type;
};

// pointer traits
template <typename _T>
struct pointer_traits<_T *> {
  // everything is same just add this:
  static constexpr uintptr_t free_bits = /* implementation specific value */;
};

template <typename _T, typename _Tag, typename _Schema>
struct pointer_traits<tagged_ptr<_T, _Tag, _Schema>> {
  using _tagged_ptr = tagged_ptr<_T, _Tag, _Schema>;
  using pointer = _tagged_ptr::clean_pointer;
  using element_type = _tagged_ptr::element_type;
  using difference_type = _tagged_ptr::difference_type;

  template <typename _Up> using rebind = typename _tagged_ptr::template rebind<_Up>;

  static constexpr uintptr_t free_bits = _tagged_ptr::dirty_pointer_free_bits;

public:
  constexpr static _tagged_ptr pointer_to(pointer ptr) _NOEXCEPT {
    return _tagged_ptr{ptr};
  }
};

// support for tuple protocol so we can split tagged pointer to structured bindings:
// auto [ptr, tag] = tagged_ptr
template <typename _T, typename _Tag, typename _Schema>
struct tuple_size<tagged_ptr<_T, _Tag, _Schema>>: std::integral_constant<std::size_t, 2> {};

template <std::size_t I, typename _T, typename _Tag, typename _Schema>
struct tuple_element<I, tagged_ptr<_T, _Tag, _Schema>> {
  using _pair_type = tagged_ptr<_T, _Tag, _Schema>;
  using type = std::conditional_t<I == 0, typename _pair_type::clean_pointer, typename _pair_type::tag_type>;
};

Impact on existing code

None, this is purely an API extension. It allows to express semantic clearly for a compiler instead of using an unsafe reinterpret_cast based techniques. Integral part of the proposed design is ability to interact with such existing code and migrate away from it.

Proposed changes to wording

TBD after design review.

Feature test macro

17.3.2 Header <version> synopsis [version.syn]

#define __cpp_lib_pointer_tagging 2024??L