In this post I’ll show you whether a function-local static inside an inline header yields a single program-wide instance, explain how C++ and C differ, and include a small multi-file demo for GCC/Clang/MSVC.
Let’s assume that you want a single, lazily created global pointer that’s accessible across the whole program, a common pattern is a function that returns a pointer to a pointer stored in a function-local static:
// header-only, C++ inline GLOBAL_STATE** GetGlobalStatePtr() { static GLOBAL_STATE* g_State = nullptr; // function-local static return &g_State; // pointer to the pointer }
Will there be exactly one g_State for the entire program (not one per translation unit) Does this work the same in C and C++?
As usually the answer is not short. For C++17 and later: yes, one shared instance across all translation units (TUs) is guaranteed by the standard. For C++11/14: it works on all major compilers, but the standard wording was less explicit; you relied on common implementation behavior. For C language it doesn’t work. Header-only with a function-local static gives you one instance per TU. Use a single definition in a .c file and extern in a header.
Let’s consider how C and C++ differs. But first, we should understand what is the TU (translation unit) in C and C++ languages. The translation unit (TU) is the result of compiling one source file after preprocessing (#include expansion and macro substitution). Each .c/.cpp plus all included headers becomes one TU and produces one object file. Header-only code is compiled separately in every TU that includes it.
What is ODR in C++? The One Definition Rule says that each program-visible entity must have exactly one definition in the program, with a special allowance for inline functions and templates: they may be defined in multiple translation units as long as those definitions are identical. Since C++17, a static local within an inline function denotes the same object program-wide.
How do linkers realize it? Compilers place identical inline definitions into “link-once” sections (COMDAT in PE/COFF: MSVC, Windows). The linker coalesces them into a single object. If definitions differ – it’s undefined behavior (classic ODR violation). Conversely, C has no ODR unification. C’s inline is about inlining and, with extern inline, about providing/omitting an external definition — but a function-local static inside a header inline remains one per translation unit. There’s no rule that merges those objects across TUs in C.
How ODR works in C++
In C++, the One Definition Rule (ODR) says that each program-visible entity must have exactly one definition in the program. However, there’s a special allowance for inline functions and templates: they may be defined in multiple translation units as long as those definitions are identical. Since C++17, a static local within an inline function denotes the same object program-wide.
How do linkers actually make this work? Compilers place identical inline definitions into “link-once” sections (COMDAT in PE/COFF on MSVC/Windows). The linker then coalesces them into a single object. If definitions differ between translation units, it’s undefined behavior – a classic ODR violation that can lead to subtle bugs.
C is completely different here. C has no ODR unification mechanism. C’s inline keyword is primarily about inlining optimization and, with extern inline, about providing or omitting an external definition. But a function-local static inside a header inline function remains one per translation unit. There’s no rule that merges those objects across TUs in C.
Why this works in C++
When you place the inline function in a header, each TU sees the same definition. The One Definition Rule allows inline functions to be defined in multiple TUs as long as definitions are identical. Since C++17, a function-local static within such an inline function is required to denote the same object program-wide. Implementations realize this via COMDAT/”link-once” coalescing at link time.
The function-local static has static storage duration, meaning it exists for the entire program. In C++11 and later, its initialization is thread-safe the first time control passes through the declaration – no need to worry about race conditions during initialization.
Let’s talk about some important behavior details for C++. The g_State variable is only directly visible inside GetGlobalStatePtr, but it has global lifetime. By returning &g_State, you expose a modifiable handle to the pointer itself (GLOBAL_STATE**), so callers can both read and replace the pointer value. However, while the first initialization of the static local is guaranteed thread-safe in C++11+, the pointer assignment you perform afterward is not magically synchronized – protect it if multiple threads may write.
Reference alternative (C++):
If you prefer references over pointer-to-pointer:
inline GLOBAL_STATE*& GetGlobalStateRef() { static GLOBAL_STATE* g_State = nullptr; return g_State; // reference to the pointer } // usage GetGlobalStateRef() = new GLOBAL_STATE; // assign GLOBAL_STATE* p = GetGlobalStateRef(); // read
When this does NOT work: C language:
- C’s inline is an optimization hint; there is no ODR unifying semantics like in C++.
- If you put the inline function with a function-local static in a header and include it in multiple .c files, each TU gets its own independent static.
Correct C pattern (single shared instance)
- Declare the object in a header with extern, define it in exactly one .c file.
mylib.h
#ifndef MYLIB_H #define MYLIB_H typedef struct GLOBAL_STATE GLOBAL_STATE; extern GLOBAL_STATE* g_State; // declaration only static inline GLOBAL_STATE** GetGlobalStatePtr(void) { return &g_State; // returns address of the single definition } #endif // MYLIB_H
mylib.c
#include "mylib.h" GLOBAL_STATE* g_State = NULL; // single definition
Notes for mixed C/C++ projects
- Compile C sources as C, C++ sources as C++. Don’t compile .c as C++ unless that’s intentional.
- To allow C and C++ to link together, wrap C headers with extern “C” when included by C++:
#ifdef __cplusplus extern "C" { #endif // C declarations here #ifdef __cplusplus } #endif
Practical guidance
- C++ (header-only): The inline + function-local static pattern is fine. Prefer C++17+ for the formal guarantee; C++11+ is commonly OK on major toolchains.
- C (not header-only): Put the storage in one .c file and expose it via extern in the header; you can still provide an inline accessor that returns &g_State.
- Avoid ODR pitfalls (C++): Ensure all TUs see exactly the same inline definition. Changing the function body or the static’s type in one TU is UB.
- Consider API shape: Prefer returning GLOBAL_STATE*& in C++ for readability, unless a pointer-to-pointer fits better with C APIs.
FAQ
- Is initialization of the function-local static thread-safe?
- C++11+: Yes, for the initialization itself. Subsequent uses/assignments are your responsibility to synchronize.
- C: No portability guarantee for dynamic initialization; constant zero-init occurs at load time, but non-trivial first-use initialization is not required to be thread-safe.
- Does this pattern create a leak?
- The static itself does not leak; it has static storage duration. If you allocate GLOBAL_STATE with new/malloc, you’re responsible for destruction at program shutdown (often intentionally omitted for process-lifetime singletons).
TL;DR
- C++17+: Your header-only inline accessor with a function-local static yields a single program-wide instance and initializes it in a thread-safe manner.
- C: Header-only does not unify the instance; define the storage once in a .c file and expose it with extern.
Minimal demo
This section shows the behavior concretely across translation units (TUs).
C++17+ (header-only works: one shared instance)
Files:
state.hpp
#pragma once #include <iostream> inline int& Counter() { static int value = 0; // one program-wide instance in C++17+ return value; }
a.cpp
#include "state.hpp" void A() { auto& c = Counter(); ++c; std::cout << "A: " << c << "\n"; }
b.cpp
#include "state.hpp" void B() { auto& c = Counter(); ++c; std::cout << "B: " << c << "\n"; }
main.cpp
#include "state.hpp" void A(); void B(); int main() { A(); // prints A: 1 B(); // prints B: 2 (same Counter instance) }
Build and run:
- GCC/Clang: g++ -std=c++17 a.cpp b.cpp main.cpp -o demo && ./demo
- MSVC (Developer Prompt): cl /std:c++17 /EHsc a.cpp b.cpp main.cpp && demo.exe
Expected output:
A: 1 B: 2
Why: C++17 requires the function-local static in an inline function to denote the same object across TUs, so increments in A and B affect the same storage.
C (header-only fails: per-TU instances)
Files:
mylib.h
#ifndef MYLIB_H #define MYLIB_H static inline int* get_counter(void) { static int counter = 0; // one per TU in C return &counter } #endif
a.c
#include <stdio.h> #include "mylib.h" void A(void) { int* c = get_counter(); ++*c; printf("A: %d\n", *c); }
b.c
#include <stdio.h> #include "mylib.h" void B(void) { int* c = get_counter(); ++*c; printf("B: %d\n", *c); }
main.c
void A(void); void B(void); int main(void) { A(); // prints A: 1 B(); // prints B: 1 (different counter per TU) }
Expected output:
A: 1 B: 1
Why: In C, inline does not impose ODR-style unification. Each TU gets its own function-local static.
Correct C pattern (single shared instance)
Files:
mylib.h
#ifndef MYLIB_H #define MYLIB_H extern int g_counter; // declaration only static inline int* get_counter(void) { return &g_counter; // returns address of single definition } #endif
mylib.c
int g_counter = 0; // single definition
main.c
#include <stdio.h> #include "mylib.h" static void A(void) { ++*get_counter(); printf("A: %d\n", *get_counter()); } static void B(void) { ++*get_counter(); printf("B: %d\n", *get_counter()); } int main(void) { A(); // A: 1 B(); // B: 2 (same storage) }
Expected output:
A: 1 B: 2
Notes:
- For mixed C/C++, compile C sources as C and C++ sources as C++. Use extern “C” guards in headers shared with C++.
- For C++11/14, major compilers already coalesced the static (COMDAT/link-once), but C++17 made it a guarantee.
That’s all, thank you for reading.