Skip to content

Instantly share code, notes, and snippets.

@rygo6
Last active June 18, 2026 04:57
Show Gist options
  • Select an option

  • Save rygo6/012a215cea087714df0526d8bdb8ba88 to your computer and use it in GitHub Desktop.

Select an option

Save rygo6/012a215cea087714df0526d8bdb8ba88 to your computer and use it in GitHub Desktop.

C+ (CPLUS)

A stricter subset of C++ for projects that want C-like compile times and binary size while keeping the productive syntax of modern C++. It also keeps the full set of C99 / C-style features (designated initializers, compound literals, VLAs) available, so you lose nothing from the C side either.

Note: C+ targets clang and GCC only. MSVC is not supported — it lacks direct equivalents for these flags, and its runtime surface and strip-down options diverge enough that this approach doesn't translate. Use a GCC-family toolchain.

clang deliberately adopted GCC's GNU C/C++ surface — they share the same -f* flags, the __builtin_* intrinsics, and the GNU C/C++ extensions (__attribute__, statement expressions, designated initializers, compound literals, VLAs, etc.) — so a single C+ configuration works across both. MSVC supports none of this: it grew up on a separate lineage with its own intrinsics, __declspec in place of __attribute__, and no GNU-extension support, and never aimed for GNU compatibility — which is why this strip-down has no faithful translation there.

Beyond the technical mismatch: any self-respecting programmer should make an effort to be Microsoft-free in every way possible. Sticking to clang/GCC and an open toolchain isn't just what C+ requires — it's the right default.

Philosophy

C++ pulls in a lot of runtime surface by default: exception unwinding tables, RTTI, libstdc++ symbols, semantic interposition, threadsafe-statics guards. Most of that is irrelevant for executables that:

  • never throw exceptions
  • never use dynamic_cast or typeid
  • run only in-process (no .so plugin loading)
  • want predictable codegen

C+ strips the runtime surface but keeps the parts of C++ that produce code as cheap as C. Templates, namespaces, lambdas, default struct values, struct methods, header-only STL helpers (std::initializer_list, std::type_traits), and C++-specific attributes all remain available.

It also pulls the other direction: the C99 / C-style features that C++ accepts only as warned "extensions" — designated initializers (including out-of-order and array designators), compound literals, and VLAs — are deliberately re-enabled by silencing those warnings. The goal is a language that is a strict superset of the productive parts of both C and C++, not a compromise that drops features from either side. See C99 / C-style features.

The result: code that compiles about as fast as C and produces a binary about as small as C, as long as you stay away from heavyweight library facilities (containers, iostreams, exceptions, polymorphic class hierarchies).

Compile-side flags (CPLUS)

These go in CXXFLAGS.

Flag Effect
-fno-exceptions Disables exception support. Compiler still parses try / catch but emits no unwinding tables or throw machinery.
-fno-rtti Disables runtime type info. No dynamic_cast, no typeid on polymorphic types.
-fno-unwind-tables Drops .eh_frame stack-unwinding metadata. Safe with exceptions off, cuts binary size.
-fno-asynchronous-unwind-tables Same as above for the async variant used by signal handlers.
-fvisibility=hidden All symbols default to hidden. The right choice for an executable that exports nothing.
-fno-semantic-interposition Tells the optimizer the binary's own functions cannot be replaced at runtime, enabling more aggressive inlining and devirtualization.
-fno-math-errno math.h functions don't set errno on overflow. Allows vectorization of sinf / cosf / sqrtf loops.
-fno-trapping-math FP ops don't raise traps. Matches typical shader-language semantics.

Link-side flags (LDPLUS)

These go in LDFLAGS.

Flag Effect
-static-libgcc Statically links libgcc helpers. Removes libgcc_s.so.1 from the runtime dependency list.
-nostdlib++ Drops libstdc++ from the link line entirely. Makes the next section's guardrail load-bearing.

Deliberately NOT included

-fno-threadsafe-statics stays enabled. This is the structural guardrail: it exists to stop you from accidentally doing things that automatically create hidden mutexes. A thread-safe function-local static silently emits a guard around its lazy initialization — a hidden lock you never asked for — and keeping this flag on turns that into a link error instead of buried runtime synchronization.

A function-local static with runtime initialization (e.g. static auto x = compute();) makes the compiler emit calls to __cxa_guard_acquire, __cxa_guard_release, and __cxa_guard_abort. Those symbols live in libstdc++. Since -nostdlib++ drops libstdc++ from the link line, any code that introduces such a static fails to link with an unresolved-symbol error.

That failure is the point. It catches the common pattern of putting a heavy static inside a function, which quietly pulls in library state, atomic operations, and lazy initialization. The link error forces you to either lift the value to a constexpr or file-scope static (no runtime init, no guard emitted), or to confront whether the runtime init is really what you want.

What still works

  • Templates and template metaprogramming.
  • Namespaces.
  • Lambdas, including generic and capturing.
  • Range-based for loops.
  • Default member initializers in structs / classes.
  • Member functions, constructors, destructors.
  • auto, decltype, structured bindings.
  • constexpr evaluation, constinit, consteval.
  • Header-only STL helpers: std::initializer_list, std::type_traits, std::numeric_limits, tuple-style metaprogramming, etc.
  • Attributes: [[nodiscard]], [[likely]], [[unlikely]], [[gnu::format(printf, ...)]], [[gnu::always_inline]].

C99 / C-style features

C+ leans toward C, and several genuinely useful C99/C-style constructs are accepted by clang and GCC in C++ mode but emit "extension" warnings by default. Suppressing that handful of warnings re-enables the full set of C99 functionality in C+ with no downside — the constructs already compile, the compiler is just being pedantic about portability you don't care about here.

The warnings to suppress:

Flag Re-enables
-Wno-c99-designator C99 designated initializers, including out-of-order and nested/array designators that ISO C++ does not allow.
-Wno-c23-extensions C23-era constructs used from C++ (e.g. #embed, newer literal forms).
-Wno-vla-cxx-extension C99 variable-length arrays (T arr[n] with a runtime n).
-Wno-address-of-temporary Taking the address of a compound literal / temporary (&(struct Foo){ ... }).
-Wno-missing-field-initializers Aggregate / designated init that leaves trailing fields to zero-initialize.

Available C / C99 features

With those warnings off, the following C-style constructs are available in C+:

  • Designated initializersFoo f = { .a = 1, .c = 3 };, including out-of-order and nested/array designators that ISO C++ otherwise rejects.
  • Compound literals&(Foo){ .a = 1 }, an unnamed object built in place (mind the lifetime warning below).
  • Variable-length arraysT arr[n] with a runtime n (cold paths only — see below).
  • Statement expressions({ ...; result; }), a GNU extension where a brace block yields the value of its last expression; ideal for multi-line macro bodies that need to return a value.
  • __attribute__((...)) — the GNU attribute syntax (packed, aligned, always_inline, cold, format, etc.), alongside the [[gnu::...]] spelling.
  • __builtin_* intrinsics__builtin_expect, __builtin_trap, __builtin_unreachable, __builtin_memcpy, and the rest of the GNU builtin set.
  • Other C idioms — anonymous structs/unions, __typeof__, __restrict, and the usual libc surface.

All of these are shared by clang and GCC; MSVC supports none of them.

Warning — compound literal lifetime. A compound literal ((struct Foo){ ... }) does not have the lifetime of its enclosing scope. It is a temporary whose lifetime ends at the end of the full expression it appears in — not at the end of the block, unlike a named local. Storing a pointer or reference to a compound literal and using it after that expression is a dangling-pointer use-after-free. If you need the object to outlive the statement, give it a name (a real local variable) instead of using a compound literal. This differs from C, where a compound literal at block scope has automatic storage duration lasting the whole enclosing block.

Warning — VLAs only on cold paths. Variable-length arrays are fine in cold-path setup/teardown code (init, enumeration, one-shot configuration) where the runtime size is genuinely dynamic and the convenience is worth it. Keep them out of hot paths: a VLA introduces a runtime-variable stack frame and alloca-style bookkeeping that defeats the predictable codegen and breaks the optimizations (fixed frame layout, vectorization, inlining) that C+ exists to preserve. On any per-frame / per-iteration hot path use a fixed-size StaticArray, a preallocated buffer, or an arena instead. Rule of thumb: VLA inside a Create* / Initialize* is OK; VLA inside hot per-frame work is not.

What breaks

  • std::vector, std::string, std::map, std::unordered_map, std::function, std::shared_ptr, anything with runtime allocation backed by libstdc++.
  • std::cout, std::cerr, std::cin, iostreams. Use <cstdio> instead.
  • throw, try, catch. Will not work as intended.
  • dynamic_cast, typeid on polymorphic types.
  • Function-local static T x = runtime_init();. Lift to file-scope or constexpr.
  • Most third-party C++ libraries that assume -lstdc++ is on the link line.

libc remains fully usable (<cstdio>, <cstdlib>, <cstring>, <cmath>, <ctime>, POSIX) and any C library.

Compile-time vs link-time

Subtle but important: the STL headers are still included at compile time. <type_traits>, <initializer_list>, <utility>, etc. work because their content is header-only. -nostdlib++ only affects the link stage. So you can write std::is_same_v<T, U> freely; you just cannot use facilities that need libstdc++ at link time.

On clang, -stdlib=libstdc++ selects which standard-library implementation's headers to use. Default on Linux is typically libstdc++. Both libstdc++ and libc++ have substantial header-only subsets that are compatible with this setup.

Usable standard headers (verified)

The sections above give the principle. This is the per-header breakdown, verified by linking representative usage at -O0 under the flags above. Verify at -O0, never -O2: optimization deletes unused code before it references the runtime symbol, so -O2 reports false passes. Put the usage inside main, not at namespace scope.

The trigger that decides it

A header is usable unless the code you instantiate hits one of three things -nostdlib++ does not provide:

  1. Heap allocation: operator new / operator new[] / operator delete.
  2. A throw path: std::__throw_length_error, __throw_bad_alloc, __throw_logic_error, __throw_bad_function_call, __throw_system_error, __throw_out_of_range, etc. You never write throw under -fno-exceptions, but library inline templates contain throw expressions, and instantiating one references these symbols.
  3. Out-of-line library symbols or static init: iostreams (std::cout, ios_base::Init), locale.

Pure template / constexpr / inline headers with none of those are fully supported.

Fully supported

<type_traits> <utility> <initializer_list> <tuple> <array> <span>
<string_view>* <optional>* <variant>* <bit> <bitset> <limits>
<concepts> <compare> <numbers> <ratio> <algorithm>** <atomic>
<chrono> <version> <source_location>

C-compat wrappers, backed by libc/libm (already linked):

<cstdint> <cstddef> <cstring> <cmath> <cstdarg> <climits> <cfloat>
<cinttypes> <cstdlib>

* Supported except their throwing accessors: optional::value(), variant's std::get on type mismatch, string_view::at() / substr() on bad position pull std::__throw_*. Use *opt, std::get_if, operator[] instead.

** <algorithm>: min / max / clamp / sort over your own data are fine (no alloc, no throw). Note std::min is not a keyword; it needs <algorithm>, so include it explicitly rather than relying on a transitive include.

Conditionally supported (header fine, specific uses fail)

Header Supported Fails to link
<functional> std::invoke, std::ref, std::hash, std::less / plus, lambdas std::function (__throw_bad_function_call)
<new> placement new, std::nothrow, std::launder global new[] / delete[] (no allocator)
<memory> std::addressof, std::uninitialized_* on your own buffers make_unique / make_shared / unique_ptr<T[]> (operator new + __throw_bad_alloc)

Unsupported (need libstdc++)

<vector> <string> <map> <unordered_map> <set> <deque> (allocate + throw); <iostream> <sstream> <fstream> (out-of-line symbols + static init); <mutex> (__throw_system_error); <regex> <locale>. Example: <vector> alone pulls __throw_length_error + __throw_bad_array_new_length + __throw_bad_alloc + operator new.

To use the allocating/throwing containers anyway, you must provide the missing pieces yourself: operator new / operator delete plus the handful of std::__throw_* functions (as [[noreturn]] to __builtin_trap()). That is a deliberate decision, not a default.

Containers and replacements

When you hit an unsupported facility, it is usually preferable to have an AI generate a small, purpose-built equivalent — a derivative container, string, or helper tailored to exactly what you need — rather than pulling in and linking a whole heavyweight library. A bespoke Vector that does only what your code requires is smaller, faster to compile, has no hidden allocation or throw paths, and stays within the C+ constraints by construction. Reach for a full third-party library only when the functionality is genuinely too large or subtle to reproduce.

Avoid std::string specifically. Beyond the allocation and throw paths it shares with the other containers, std::basic_string is an exponential template-instantiation minefield: it drags in char_traits, allocator machinery, and a web of inline templates whose instantiations balloon compile time and code size, and a single use tends to pull the whole graph in transitively. It is one of the worst offenders for the C-like compile times and binary size C+ is trying to preserve. Use <cstring> with plain char* buffers, a std::string_view over storage you own, or a small purpose-built string type instead.

Boilerplate

Keep the build simple: use a Makefile, a build.sh, or a build.bat. C+ deliberately avoids meta build systems like CMake and Meson — they add a layer of indirection over the exact compiler and linker flags that this approach depends on being explicit and visible. A plain script or Makefile keeps the flags in front of you where they belong.

Makefile

CPLUS  := -fno-unwind-tables \
          -fno-asynchronous-unwind-tables \
          -fvisibility=hidden \
          -fno-semantic-interposition \
          -fno-math-errno \
          -fno-trapping-math

# Re-enable C99 / C-style features (designated initializers, compound
# literals, VLAs) by silencing their "extension" warnings.
C99    := -Wno-c99-designator \
          -Wno-c23-extensions \
          -Wno-vla-cxx-extension \
          -Wno-address-of-temporary \
          -Wno-missing-field-initializers

LDPLUS := -static-libgcc \
          -nostdlib++

CXXFLAGS += -std=c++23 -fno-exceptions -fno-rtti $(CPLUS) $(C99)
LDFLAGS  += $(LDPLUS)

(-fno-exceptions -fno-rtti are kept outside CPLUS because they are core language toggles, not strip-down decorations. Group them however suits your taste.)

build.sh

#!/bin/sh
set -e

CPLUS="-fno-unwind-tables -fno-asynchronous-unwind-tables \
       -fvisibility=hidden -fno-semantic-interposition \
       -fno-math-errno -fno-trapping-math"
# Re-enable C99 / C-style features (designated initializers, compound literals, VLAs).
C99="-Wno-c99-designator -Wno-c23-extensions -Wno-vla-cxx-extension \
     -Wno-address-of-temporary -Wno-missing-field-initializers"
LDPLUS="-static-libgcc -nostdlib++"

clang++ -std=c++23 -fno-exceptions -fno-rtti $CPLUS $C99 $LDPLUS \
        -o your_target src/*.cpp

build.bat

@echo off
set CPLUS=-fno-unwind-tables -fno-asynchronous-unwind-tables ^
 -fvisibility=hidden -fno-semantic-interposition ^
 -fno-math-errno -fno-trapping-math
rem Re-enable C99 / C-style features (designated initializers, compound literals, VLAs).
set C99=-Wno-c99-designator -Wno-c23-extensions -Wno-vla-cxx-extension ^
 -Wno-address-of-temporary -Wno-missing-field-initializers
set LDPLUS=-static-libgcc -nostdlib++

clang++ -std=c++23 -fno-exceptions -fno-rtti %CPLUS% %C99% %LDPLUS% ^
        -o your_target.exe src\*.cpp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment