Inline + function-local static: one instance across translation units? C++ vs C

By | October 6, 2025

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&amp; Counter() {
    static int value = 0; // one program-wide instance in C++17+
    return value;
}

a.cpp

#include "state.hpp"
void A() {
    auto&amp; c = Counter();
    ++c;
    std::cout &lt;&lt; "A: " &lt;&lt; c &lt;&lt; "\n";
}

b.cpp

#include "state.hpp"
void B() {
    auto&amp; c = Counter();
    ++c;
    std::cout &lt;&lt; "B: " &lt;&lt; c &lt;&lt; "\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 &amp;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

alt text - function

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 &amp;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

alt text

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.

Leave a Reply