Local Variables
This document owns the first-party C++ rules for variable initialization, local type
spelling, redundant top-level const, and use-after-move diagnostics.
There is no engine-specific immutability-by-default rule. Local variables and function or method parameters follow normal C++ mutation semantics and require no annotation when they are written.
Braced initialization
Initialize every variable definition with direct-list initialization wherever an
initializer is permitted: T name {expression};. This applies to locals, fields,
globals and static/thread-local variables. Use empty braces for the intended
default state, including class-type objects: int32_t count {};, string text {};.
There are two exceptions to an initializer at the declaration:
- A class field initialized by its constructors may omit an in-class initializer. Ensure every applicable constructor initializes it before use.
- A local may deliberately defer initialization. Every control-flow path must assign its value before the first read; keep this guarantee visible in the code.
Preserve constructor selection, container contents and size, deduced types and
conversion checks. Braces can select an initializer_list overload: replacing
vector<int32_t> values(count) with vector<int32_t> values {count} changes a
size into an element. Construct the intended value explicitly and use it as the
braced initializer. Use auto value {expression}; for retained deduction;
auto value = {expression}; deduces an initializer list.
Construct a named scoped guard directly when its constructor carries Clang Thread
Safety Analysis acquisition annotations: scoped_lock locker {bufferMutex}; or
ServerConnection::OutBufAccessor output {connection, NetMessage::Ping};.
Initializing it through auto guard {factory()}; or auto guard {Guard {args}};
can lose the analyzer’s acquisition association and produce an unmatched-release
diagnostic. Keep existing full-expression temporary guards as temporaries;
introducing a named guard in the surrounding block extends the lock lifetime.
When storing a try-lock result for a later condition, use bool acquired {};
followed by acquired = lock->TryLockStateMutex();. Clang can lose the
try-acquire association through a braced boolean initializer. Preserve the
existing check and unlock order.
Review this rule and its two exceptions in source review. The analyzer checks described below retain their existing type, constness and use-after-move scope.
Redundant local const
Do not add top-level const to an automatic local when removing it leaves the
type contract unchanged:
int32_t count {GetCount()};
item_ptr item {FindItem()};
Preserve constness that is part of the value’s actual type or semantics:
const Item* item {FindItem()}; // const pointee
const Item& item {GetItem()}; // const referent
const int32_t values[] {1, 2}; // const elements
constexpr int32_t limit {10}; // required by constexpr
int32_t* const pointer has removable top-level const and is diagnosed.
Parameters are not part of this check.
The rare intentional top-level local qualifier can be suppressed on the diagnostic line or the preceding line:
const int32_t value {SelectOverload()}; // FO_REDUNDANT_CONST_SUPPRESS: const is required for overload selection
The reason is mandatory and must contain at least eight characters.
Explicit simple local types
Replace local auto only when all of these are true:
- Clang resolves one accessible, unqualified
snake_casetype name. - The type has no visible template arguments.
- The type is not nested or namespace-qualified.
- Writing the explicit type does not introduce or hide a conversion.
- The declaration is not a structured binding, lambda, or dependent deduction.
When Clang exposes only a canonical template type, an unqualified alias is
eligible if that alias is explicitly spelled in the initializer, is the only
simple alias for the exact same desugared type, and is accessible at the
declaration. This covers calls such as glm::translate(mat44 {...}, offset)
without admitting library-internal names such as iterator or value_type.
Examples:
int32_t count {GetCount()};
float32_t factor {GetFactor()};
hstring name {GetName()};
Keep auto for cases such as:
auto values {vector<int32_t> {}};
auto iter {values.begin()}; // nested iterator type
auto handle {MakeHandle<void>()}; // template spelling
auto pointer {GetRawPointer()}; // explicit target may convert
auto [left, right] {Split(value)}; // structured binding
When an eligible deduced numeric primitive is written explicitly, use the engine’s sized spelling:
| Deduced type | Explicit spelling |
|---|---|
short |
int16_t |
int |
int32_t |
float |
float32_t |
double |
float64_t |
Existing fixed-width signed and unsigned types keep their spelling.
See Enforcement for how this rule is checked.
Use after move
Moving from a local does not make it mutable and needs no annotation. The
independent bugprone-use-after-move gate rejects subsequent use until a valid
reinitialization:
value_type value {BuildValue()};
Consume(std::move(value));
Intentional inspection of a moved-from object must state the contract:
CHECK(value.empty()); // FO_USE_AFTER_MOVE_SUPPRESS: test verifies the moved-from container contract
The reason is mandatory and must contain at least eight characters.
Enforcement
The type, constness and use-after-move rules use a Clang 20+ compile_commands.json analyzer:
an explicit-type checker plus clang-query / clang-tidy checks for redundant
local constness and use after move. The engine owns only the rules above and the
FO_REDUNDANT_CONST_SUPPRESS / FO_USE_AFTER_MOVE_SUPPRESS markers. An
embedding project owns analyzer implementation, compile-database generation,
scope selection, and CI policy.
Run all three checks over changed engine sources before publication. A project may extend the same gate to its native extensions, but project paths, task names, and workflow jobs are not part of this reusable contract.
Both clang-query and clang-tidy must be present on the host running the
gate, and the compile database must support a real parse. Generated headers
reachable from the engine’s common include chain must exist before analysis;
otherwise translation units fail on missing includes before any rule is
evaluated.
Prune the compilation database to one command per translation unit before passing it to Clang tooling. CMake emits one entry for every target that compiles a file; an Engine source may therefore appear dozens of times. Clang runs its action once per entry, and clang-query retains every built AST, so duplicates multiply both wall time and memory. One Engine AST is roughly half a gigabyte; substantially larger usage usually indicates an unpruned database rather than an inherent analyzer cost.