FastCast 1.1.1
dotnet add package FastCast --version 1.1.1
NuGet\Install-Package FastCast -Version 1.1.1
<PackageReference Include="FastCast" Version="1.1.1" />
<PackageVersion Include="FastCast" Version="1.1.1" />
<PackageReference Include="FastCast" />
paket add FastCast --version 1.1.1
#r "nuget: FastCast, 1.1.1"
#:package FastCast@1.1.1
#addin nuget:?package=FastCast&version=1.1.1
#tool nuget:?package=FastCast&version=1.1.1
FastCast
fast_cast is a header-only, C++11-compatible, drop-in replacement for
dynamic_cast that caches the result of each cast per thread. Repeated casts of
the same dynamic type become a single pointer comparison plus an addition, which is
35–50× faster than dynamic_cast on a hot path, while returning exactly what
dynamic_cast would return.
#include "fastcast.hpp"
Shape *shape = get_shape();
Circle *c = fast_cast<Circle *>(shape); // nullptr if it is not a Circle
Circle &r = fast_cast<Circle &>(*shape); // throws std::bad_cast if not
auto sp = fast_dynamic_pointer_cast<Circle>(shared_shape); // empty on failure
The code is heavily inspired by FastDynamicCast and is effectively a modern, portable reimplementation of it. The basic idea in one picture:
dynamic_cast walks the RTTI graph every time. But for a given dynamic type (identified by
its vtable pointer) the answer never changes: the target subobject is always at the same
byte offset from the source subobject, or the cast always fails. fast_cast remembers that
offset the first time it sees a vtable and reuses it on every subsequent call. Also read
this reddit post
on why this works on both the Itanium and the MSVC ABI.
Contents
- Features
- Installation
- Usage
- API reference
- Semantics: how it compares to
dynamic_cast - How it works
- Caveats
- Language and compiler support
- Benchmarks
- Building and testing
- Releasing
- Contributing
- License
Features
- Drop-in: pointer, reference, and
std::shared_ptroverloads with the same results, null/throw behaviour and const-correctness asdynamic_cast/std::dynamic_pointer_cast. - Compile-time fast paths: identity, cv-adding and derived-to-base casts compile to a
static_cast(or nothing at all) and areconstexpreven in C++11. - Runtime fast path: per-thread, per-
(From, To)cache of the last vtable seen and the pointer adjustment it needed. A cache hit is a load, a compare and an add. - Failed casts are cached too, so repeated misses are as cheap as repeated hits.
- Works with anything
dynamic_castworks with: multiple inheritance, virtual inheritance, cross-casts between sibling bases, casts tovoid*(most-derived object). - Header-only, C++11 and up, no dependencies, no linking. Only
<type_traits>,<memory>,<cstring>,<limits>and<typeinfo>are included. - Portable: the cached path is used on the Itanium ABI (GCC, Clang, ICC, AppleClang,
MinGW) and on MSVC. On any other ABI every call transparently forwards to
dynamic_cast. - Thread-safe without locks or atomics: each thread owns its own cache.
Installation
Copy the header
Everything is in fastcast.hpp. Copy it into your project and
#include "fastcast.hpp"
CMake: add_subdirectory / FetchContent
include(FetchContent)
FetchContent_Declare(FastCast
GIT_REPOSITORY https://github.com/reach2sayan/FastCast.git
GIT_TAG main) # or a release tag
FetchContent_MakeAvailable(FastCast)
target_link_libraries(my_target PRIVATE FastCast::FastCast)
Tests, examples and benchmarks are only built when FastCast is the top-level project, so consuming it this way never pulls in Google Benchmark.
CMake: install and find_package
cmake -S FastCast -B build -DFASTCAST_BUILD_TESTS=OFF -DFASTCAST_BUILD_EXAMPLES=OFF \
-DFASTCAST_BUILD_BENCHMARKS=OFF -DCMAKE_INSTALL_PREFIX=/opt/fastcast
cmake --build build && cmake --install build
find_package(FastCast 1.1 REQUIRED)
target_link_libraries(my_target PRIVATE FastCast::FastCast)
The header is installed to <prefix>/include/fastcast.hpp, so use #include <fastcast.hpp>.
NuGet (Visual Studio / MSBuild)
The FastCast package carries the header and a
.targets file that adds the include path to every project referencing it. It is
header-only, so it is valid for any platform, architecture and configuration:
Install-Package FastCast
or in the .vcxproj / packages.config:
<PackageReference Include="FastCast" Version="1.1.0" />
Every GitHub release also attaches the .nupkg, for private feeds.
Downloadable tarball
Every GitHub release attaches:
| Asset | Contents |
|---|---|
FastCast-<version>.tar.gz / .zip |
Installable tree: include/fastcast.hpp, lib/cmake/FastCast/ (for find_package), share/doc/FastCast/. Unpack it anywhere and point CMAKE_PREFIX_PATH at it. |
FastCast-<version>-src.tar.gz |
Full source tree with tests, examples and benchmarks. |
FastCast.<version>.nupkg |
The NuGet package. |
fastcast.hpp |
Just the header. |
Usage
All examples below are compiled and run by ctest from examples/basic.cpp.
Pointer casting
struct Shape { virtual ~Shape() = default; };
struct Circle : Shape {};
struct Square : Shape {};
Circle circle;
Shape *shape = &circle;
Circle *c = fast_cast<Circle *>(shape); // == &circle
Square *s = fast_cast<Square *>(shape); // == nullptr
Reference casting
Circle &c = fast_cast<Circle &>(*shape); // ok
try {
Square &s = fast_cast<Square &>(*shape);
} catch (const std::bad_cast &) {
// exactly like dynamic_cast<Square &>
}
std::shared_ptr casting
std::shared_ptr<Shape> owned = std::make_shared<Square>();
std::shared_ptr<Square> square = fast_dynamic_pointer_cast<Square>(owned); // shares ownership
std::shared_ptr<Circle> circle = fast_dynamic_pointer_cast<Circle>(owned); // empty
Upcasts, identity casts and const
Shape *up = fast_cast<Shape *>(&circle); // static_cast, zero cost, constexpr
Circle *same = fast_cast<Circle *>(&circle); // identity, zero cost, constexpr
const Shape *cs = &circle;
const Circle *cc = fast_cast<const Circle *>(cs); // fine
Circle *bad = fast_cast<Circle *>(cs); // compile error: cannot cast away const
Most-derived object (void*)
void *whole = fast_cast<void *>(shape); // same as dynamic_cast<void *>(shape)
Opting out of the global names
The header exports fast_cast and fast_dynamic_pointer_cast into the global namespace
for convenience. Define FASTCAST_NO_GLOBAL_USING before including it to keep them in
namespace fastcast only:
#define FASTCAST_NO_GLOBAL_USING
#include "fastcast.hpp"
auto *c = fastcast::fast_cast<Circle *>(shape);
API reference
Everything lives in namespace fastcast.
| Function | Result on failure | Notes |
|---|---|---|
template <class To, class From> To fast_cast(From *p) noexcept |
nullptr |
To must be a pointer type. constexpr when the cast is static. |
template <class To, class From> To fast_cast(From &r) |
throws std::bad_cast |
To must be a reference type. constexpr from C++14 when the cast is static. |
template <class To, class From> std::shared_ptr<To> fast_dynamic_pointer_cast(const std::shared_ptr<From> &p) noexcept |
empty shared_ptr |
Result shares ownership with p (aliasing constructor), like std::dynamic_pointer_cast. |
Macros:
| Macro | Meaning |
|---|---|
FASTCAST_VERSION_MAJOR / _MINOR / _PATCH |
Version components. |
FASTCAST_VERSION |
MAJOR * 10000 + MINOR * 100 + PATCH, e.g. 10100 for 1.1.0. |
FASTCAST_VERSION_STRING |
"1.1.0". |
FASTCAST_CPLUSPLUS |
Detected language version (__cplusplus, or _MSVC_LANG on MSVC). |
FASTCAST_SUPPORTED |
Defined (to 1) when the cached fast path is active for this ABI. Undefined means every cast forwards to dynamic_cast. |
FASTCAST_NO_GLOBAL_USING |
Define before including to suppress the global using declarations. |
Semantics: how it compares to dynamic_cast
For every well-formed cast, fast_cast<T>(x) returns the same value as dynamic_cast<T>(x)
(or throws the same exception). The test suite asserts this by comparing the two side by side
on simple, multiple, virtual and diamond hierarchies, cold and hot, from many threads, and
under a randomised sequence of dynamic types. The differences are all compile-time:
| Cast | dynamic_cast |
fast_cast |
|---|---|---|
| Downcast / cross-cast of a polymorphic type | runtime check | runtime check, cached |
| Identity, cv-adding, derived-to-base | runtime no-op | static_cast, constexpr |
To void* |
most-derived object | most-derived object (cached) |
| Non-polymorphic source, downcast | compile error | compile error (static_assert) |
Casting away const |
compile error | compile error (static_assert) |
volatile-qualified pointee, downcast |
allowed | not supported |
| From inside a base-class constructor | sees the base's dynamic type | same |
How it works
fast_cast<To>(From *p) is resolved at compile time into one of two paths:
Static path — if
From*converts implicitly toTo(identity, addingconst, derived-to-base) the call is astatic_cast. Nothing happens at runtime.Dynamic path — otherwise
Frommust be polymorphic, and:read the vptr of *p (one memcpy; first word of the object) if vptr == cached_vptr[thread][From,To]: if cached_offset == FAILED: return nullptr return (To)((char*)p + cached_offset) result = dynamic_cast<To>(p) (slow path, once per vtable) cache vptr and (result - p) or FAILED return result
The cache key is the vtable pointer of the From subobject, which identifies both the
most-derived type and which subobject of it p points to. That is enough to make the offset
a constant: an object of a given dynamic type always has its To subobject at the same
distance from its From subobject. Two Base subobjects inside the same object have two
different vtables, so they get two different (correct) offsets.
Each (From, To) pair instantiates its own cache, and each cache is thread_local, so no
synchronisation is needed and there is no false sharing between threads. The cache holds one
entry: the last vtable seen.
Caveats
- Single-entry cache. If one call site alternates between different dynamic types
(
Circle,Square,Circle,Square, …), every call misses and costs adynamic_castplus a few stores. It is never slower thandynamic_castby more than that bookkeeping, but it is not faster either. See the cold vs hot numbers. - RTTI is required. The slow path is a real
dynamic_cast;-fno-rtti//GR-is not supported (it is not supported bydynamic_casteither). - Object layout assumptions. The fast path reads the vptr from the first word of the
object. This is guaranteed by the Itanium ABI and by MSVC, which covers every mainstream
compiler and platform. On anything else
FASTCAST_SUPPORTEDis undefined and the header falls back to plaindynamic_cast. - Shared libraries may end up with more than one copy of a vtable for the same type. That only causes an extra cache miss per copy; results stay correct because the key is the actual vtable address, not the type.
volatilepointees are not supported on the dynamic path.
Language and compiler support
The header requires C++11. Newer standards only add constexpr-ness:
| Standard | What you get |
|---|---|
| C++11 | Everything. The pointer overload is constexpr for static casts. |
| C++14 | The reference overload is also constexpr for static casts. |
| C++17 / C++20 | No changes. |
| C++23 | The dynamic path is declared constexpr too (usable in constant evaluation only for null pointers, since it uses thread_local storage). |
Every commit is built and tested by CI with warnings as errors on:
| Compiler | Standards | Extras |
|---|---|---|
| GCC (Ubuntu) | 11, 14, 17, 20, 23 | ASan + UBSan (incl. -fsanitize=vptr) at 11, 17, 23 |
| Clang (Ubuntu) | 11, 14, 17, 20, 23 | ASan + UBSan at 11, 17, 23 |
| AppleClang (macOS) | 11, 14, 17, 20, 23 | |
| MSVC (Windows, x64) | 14 (11 maps to 14), 17, 20, latest |
plus an install + find_package job and a clang-format check.
Benchmarks
Run on a 16-core 4.768 GHz CPU (mean of 5 repetitions, -DCMAKE_BUILD_TYPE=Release, GCC).
Reproduce with ./measure and regenerate the plots with benchmark/plot_results.py
(see the benchmark directory and Building and testing).
CPU frequency scaling was enabled, so sub-nanosecond figures are throughput-limited and
should be read as "effectively free", not as precise latencies.
Where the speedup comes from: the cache
fast_cast caches one (vtable → offset) entry per (From, To) type pair. The first
cast of a given dynamic type ("cold") falls through to a real dynamic_cast plus the
caching bookkeeping; every subsequent cast of that same type ("hot") is just a load
and a pointer adjustment. The honest comparison therefore has two regimes:
ComplexA* → ComplexB* |
fast_cast | dynamic_cast |
|---|---|---|
| Cold (cache miss, varying types) | 28.4 ns | 28.0 ns |
| Hot (cache hit, repeated type) | 0.56 ns | 20.1 ns |
On a cold call fast_cast is ~as fast as (marginally slower than) dynamic_cast —
it does the same dynamic_cast and then records the result. The win is entirely in the
hot path, where it is ~35–50× faster. If your workload casts wildly varying dynamic
types and never repeats, fast_cast is not faster than dynamic_cast.
Hot-path latency (repeated casts of the same object)
Most real call sites cast the same handful of objects/types repeatedly, which keeps the cache hot:
| Per call (hot) | fast_cast | dynamic_cast |
|---|---|---|
| Pointer success | 0.56 ns | 4.11 ns |
| Pointer failure | 0.56 ns | 8.49 ns |
| Reused reference | 0.56 ns | 4.14 ns |
Failure caching makes repeated misses just as cheap as hits.
Throughput (2,000,000 reference casts, including object construction)
| 2,000,000 casts | fast_cast | dynamic_cast |
|---|---|---|
| Simple hierarchy | 2.28 ms | 9.21 ms |
| Complex hierarchy | 2.43 ms | 52.1 ms |
The complex (virtual + multiple inheritance) hierarchy is where dynamic_cast is most
expensive and the cache pays off most.
Static-safe path
Derived* → Base* is resolved at compile time, so fast_cast, static_cast, and
dynamic_cast all measure ~0.12 ns — identical and effectively free.
Building and testing
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure
CMake options (all default to ON when FastCast is the top-level project, OFF otherwise,
except the last two which default to OFF):
| Option | Effect |
|---|---|
FASTCAST_BUILD_TESTS |
Unit tests (Catch2 v2, vendored as tests/catch.hpp) and compile-failure tests. |
FASTCAST_BUILD_EXAMPLES |
Builds and runs examples/basic.cpp. |
FASTCAST_BUILD_BENCHMARKS |
Fetches Google Benchmark and builds the measure target. |
FASTCAST_INSTALL |
Generates install rules and the FastCastConfig.cmake package. |
FASTCAST_WARNINGS_AS_ERRORS |
-Werror / /WX for tests, examples and benchmarks. |
FASTCAST_ENABLE_SANITIZERS |
Builds tests and examples with ASan + UBSan. |
CMAKE_CXX_STANDARD |
Pick the standard to test under (11 … 23, default 23). |
The test suite has three kinds of tests, selectable with ctest -L <label>:
unit— the Catch2 suite intests/tests.cpp(./build/tests --list-teststo enumerate; tags such as[cache],[threads],[shared_ptr]filter it, e.g../build/tests "[cache]").compile_fail— each file intests/compile_fail/must fail to compile with a specific diagnostic (casting awayconst, non-polymorphic downcast, non-pointer target …).example—examples/basic.cpp, which is also the source of the README snippets.
To check the installed package works, see tests/package/ and the package CI job.
Running the benchmarks
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DFASTCAST_BUILD_BENCHMARKS=ON
cmake --build build --target measure
./build/measure --benchmark_filter='-.*threads:' --benchmark_repetitions=5 \
--benchmark_report_aggregates_only=true \
--benchmark_format=json --benchmark_out=results.json
python3 benchmark/plot_results.py results.json benchmark/plots/
Releasing
A release is a tag. Pushing vX.Y.Z runs release.yml, which:
- checks that the tag names the version in
fastcast.hpp(and stops if not), - builds and tests, then produces the CPack tarballs (
packagejob) and the NuGet package (nugetjob, on Windows withnuget pack contrib/nuget/FastCast.nuspec), - creates the GitHub release with the
CHANGELOG.mdsection for that version as notes (GitHub's generated notes appended) and all assets attached, - publishes the
.nupkgto nuget.org by trusted publishing — the job's OIDC token is exchanged for a short-lived API key byNuGet/login; no long-lived secret is stored.
Releases are cut automatically every Monday by
weekly-release.yml: if main has moved since the
last tag and the CMake workflow passed on it, CI runs scripts/bump_version.py patch
(header macros, the version test, and CHANGELOG.md's Unreleased section become the new
version), commits, tags vX.Y.Z, and pushes with the release deploy key, which triggers
the pipeline above. A week without changes produces nothing. Run workflow does the same
on demand.
To cut one by hand, or for a minor/major bump:
scripts/bump_version.py minor # or patch / major / an explicit X.Y.Z
git commit -am "Release $(scripts/bump_version.py --show)"
git tag -a "v$(scripts/bump_version.py --show)" -m "FastCast $(scripts/bump_version.py --show)"
git push --follow-tags origin main
Write the release notes into the Unreleased section of CHANGELOG.md as you go; the bump
moves them under the new version heading.
"Run workflow" on a branch builds the same artifacts and publishes nothing, which is how a change to the pipeline is tried before a version rides on it.
One-time nuget.org setup (trusted publishing)
- On nuget.org, Account → Trusted Publishing → Add: repository owner
reach2sayan, repositoryFastCast, workflow filerelease.yml, environment left blank. If the package does not exist yet the policy is created as pending and becomes permanent on first push. - In the GitHub repository, Settings → Secrets and variables → Actions → Variables: add
NUGET_USER= your nuget.org profile name. The publish steps are skipped while it is unset, so forks and dry runs never try to push. - For the weekly release, a deploy key with write access, registered on the
mainruleset's bypass list, with its private half in theRELEASE_DEPLOY_KEYsecret:
The key files are gitignored.ssh-keygen -t ed25519 -N "" -C "fastcast release agent" -f fastcast_release_key gh api -X POST repos/reach2sayan/FastCast/keys -f title="release agent" \ -f key="$(cat fastcast_release_key.pub)" -F read_only=false gh secret set RELEASE_DEPLOY_KEY < fastcast_release_key
Contributing
main is protected by a ruleset (.github/rulesets/main.json):
no direct pushes, no force-pushes, no deletion; changes land through a pull request whose
ci check (the aggregate of every CI job) is green and whose branch is up to date. Repository
admins are on the bypass list.
- Format with
clang-format(LLVM style, see.clang-format); CI checks it. - Keep the header and the tests C++11-clean; CI builds every standard from 11 to 23.
- The version is defined once, in the macros at the top of
fastcast.hpp. CMake reads them, so bump those (and the test intests/tests.cpp) and add a line toCHANGELOG.md. - Run the full local matrix before pushing if you can:
for s in 11 14 17 20 23; do cmake -S . -B b$s -DCMAKE_CXX_STANDARD=$s -DFASTCAST_WARNINGS_AS_ERRORS=ON && cmake --build b$s && ctest --test-dir b$s; done
License
MIT, see LICENSE.txt. The vendored tests/catch.hpp is Catch2 v2.13.10,
distributed under the Boost Software License 1.0.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| native | native is compatible. |
This package has no dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.