A declaration introduces a name and describes its type. A definition supplies the entity that the program uses. Linkage determines whether declarations in different scopes or translation units denote the same entity, while storage duration determines how long an object exists. Keeping those four questions separate makes dense C and C++ declarations easier to reason about.
Read a Declarator from Its Name #
A declaration consists of declaration specifiers followed by one or more declarators. Operators in a declarator bind according to the grammar:
- postfix
()and[]bind before prefix*,&, and&&; - parentheses override that binding;
- each comma starts a separate declarator, so the
*does not belong to the base type.
Start at the declared identifier, apply the adjacent operators, and then work outward:
int *pointer;
int value;
const int *pointer_to_const = nullptr;
int *const const_pointer = nullptr;
int (*pointer_to_array)[42] = nullptr;
int *array_of_pointers[42] = {};
int (*binary_operation)(int, int) = nullptr;The declarations mean:
| Name | Type |
|---|---|
pointer_to_const | pointer to const int |
const_pointer | const pointer to int |
pointer_to_array | pointer to an array of 42 int objects |
array_of_pointers | array of 42 pointers to int |
binary_operation | pointer to a function taking two int values and returning int |
const written before or after the base type has the same meaning: const int and int const are equivalent. A cv-qualifier after * qualifies the pointer instead. Aliases can make a public interface clearer when a raw declarator would hide the important distinction:
using Row = int[42];
using BinaryOperation = int(int, int);
Row *row = nullptr;
BinaryOperation *operation = nullptr;For a dynamically allocated two-dimensional C array with a compile-time column count, preserve the row type:
enum { columns = 10 };
int (*rows)[columns] = malloc(row_count * sizeof *rows);sizeof *rows changes with the row type, so the allocation does not repeat that type manually. C++ code should prefer std::vector, std::array, or a matrix type when ownership and bounds belong in the abstraction.
Several declarators are ill-formed because the language cannot construct the resulting type. There are no arrays of references, pointers to references, or references to references as written; C++ reference collapsing is a separate rule applied while forming types from aliases and templates. Function and array parameters also adjust to pointers:
void run(void callback(void));
void run(void (*callback)(void)); /* same parameter type */
void consume(size_t count, int values[count]);
void consume(size_t count, int *values); /* compatible adjusted form */The array bound in a parameter can still document a contract or participate in C-specific rules, but the function does not receive an array by value.
Distinguish Declarations from Definitions #
Every definition is a declaration, but not every declaration is a definition:
extern int request_count; // declaration
int request_count = 0; // definition
int parse(const char *text); // function declaration
int parse(const char *text) { // function definition
return text ? 1 : 0;
}
struct Session; // class declaration
struct Session { int id; }; // class definition
A declaration with extern and no initializer normally does not define an object. Adding an initializer makes it a definition despite extern. A function body, class-specifier, initialized namespace-scope variable, and most declarations without extern are definitions. Important exceptions include a non-inline static data member declared inside its class and a declaration containing only a type alias.
Templates and inline entities need definitions wherever they are instantiated or odr-used, so their definitions commonly live in headers. An ordinary externally linked function or object normally has one definition in one implementation file and declarations in headers.
The one-definition rule (ODR) operates at two levels:
- A translation unit may contain only one definition of any definable item.
- The whole program may contain only one definition of an odr-used non-inline function or variable, with specific exceptions for inline entities, templates, and types whose permitted definitions satisfy the ODR.
A compiler is not required to diagnose every program-wide ODR violation because it may compile translation units independently. Link success therefore does not prove that definitions agree.
Keep Storage Duration Separate from Linkage #
Storage duration describes an object’s minimum lifetime:
| Storage duration | Typical declaration | Lifetime |
|---|---|---|
| automatic | block-local variable without static | from initialization until block exit |
| static | namespace variable or block-local static | for the program |
| thread | thread_local object | for the lifetime of its thread |
| dynamic | object created by new or an allocator | until explicitly released |
Subobjects and reference members inherit storage-duration consequences from their complete object. A reference has its own lifetime, but it does not extend every object to which it can be rebound indirectly; returning a reference to an automatic local still dangles.
Storage-class specifiers influence different properties and must not be treated as one concept. At block scope, static changes storage duration. At namespace scope, static gives a non-template name internal linkage. extern commonly declares an entity defined elsewhere and can establish external linkage. thread_local selects thread storage duration and can be combined with static or extern where the grammar permits.
Understand C++ Linkage #
Linkage answers whether the same name in another scope denotes the same entity:
- No linkage: block-local names not explicitly given linkage, local classes, and most other local declarations.
- Internal linkage: namespace-scope non-template
constobjects by default, names explicitly declaredstatic, and names in unnamed namespaces. - External linkage: ordinary non-const namespace-scope variables, functions, named classes, and names explicitly made external.
- Module linkage: since C++20, a non-exported namespace-scope name attached to a named module can have module linkage when it does not have internal linkage.
Internal-linkage entities are distinct in every translation unit. External-linkage entities can be named from other translation units. Module-linkage entities can be shared among translation units of the same named module but not outside that module.
Header constants illustrate why linkage matters:
// C++14-compatible header: one object per translation unit
constexpr int buffer_size = 4096;
// C++17 header: one program-wide entity
inline constexpr int protocol_version = 3;The first object has internal linkage because a non-template, non-volatile namespace-scope const object has internal linkage unless another rule changes it. The inline variable has external linkage and one address across the program.
Language linkage is different from name linkage. A language-linkage specification such as extern "C" applies a language linkage to function types and to function names with external linkage:
extern "C" int library_open(const char *);
extern "C" {
int library_read(void *, unsigned char *, unsigned long);
void library_close(void *);
}extern "C" does not mean “defined in C,” does not remove type checking, and does not define a stable ABI by itself. It tells the implementation to use its C language linkage, which commonly includes C-compatible symbol naming and calling conventions. Put declarations shared with C behind the conventional #ifdef __cplusplus guard and ensure their parameter and result types also have a C-compatible representation.
Use = default to State Generated Behavior
#
C++ can implicitly declare six special member functions: the default constructor, destructor, copy constructor, copy assignment operator, move constructor, and move assignment operator. Whether each declaration appears, is deleted, or is trivial depends on the class and on which other special members the program declares.
= default requests the implicitly generated definition while making the interface explicit:
struct Record {
Record() = default;
~Record() = default;
Record(const Record &) = default;
Record &operator=(const Record &) = default;
Record(Record &&) = default;
Record &operator=(Record &&) = default;
};An explicitly defaulted function can still be defined as deleted. For example, a generated default constructor cannot initialize a reference member that lacks a default member initializer:
struct View {
int &value;
View() = default; // defined as deleted
};Member and base-class properties propagate. A copy constructor is deleted when a member cannot be copied. A defaulted move operation can be deleted when a subobject cannot perform the required move. Declaring a move constructor or move assignment operator causes implicitly declared copy operations to be defined as deleted, while a user-declared copy operation or destructor prevents implicit declaration of move operations. Such interactions are a reason to prefer the Rule of Zero.
Defaulting on the first declaration can preserve non-user-provided status and, when all other requirements hold, triviality:
struct TrivialValue {
int value;
TrivialValue() = default;
};
struct NonTrivialValue {
NonTrivialValue();
int value;
};
NonTrivialValue::NonTrivialValue() = default; // user-provided
Triviality affects properties such as trivial copyability, implicit lifetime, and ABI choices. Do not move = default out of class merely for visual consistency; doing so can change those properties. Out-of-class defaulting remains useful when a member such as std::unique_ptr<Incomplete> requires an incomplete type to become complete before the generated destructor is instantiated.
Use = delete to Reject Calls During Overload Resolution
#
A deleted function participates in overload resolution, but a program that selects it is ill-formed. The diagnostic therefore points at the prohibited operation:
class Socket {
public:
Socket() = default;
Socket(const Socket &) = delete;
Socket &operator=(const Socket &) = delete;
Socket(Socket &&) noexcept = default;
Socket &operator=(Socket &&) noexcept = default;
};
void parse(int);
void parse(bool) = delete;parse(true) selects the exact-match deleted overload instead of converting bool to int. Deleted overloads can reject unwanted conversions, copying, allocation functions, or template cases. They cannot be called even from friends or member functions, so deletion is stronger and clearer than making a function private and leaving it undefined.
The compiler also deletes special members implicitly when their generated operation would be invalid. Test the resulting interface with traits or concepts when generic code depends on it, but do not use a trait as a substitute for designing the ownership semantics.
Use Inline Definitions for ODR, Not as an Optimization Command #
In C++, inline allows an entity to be defined in more than one translation unit. Every definition must be reachable where the entity is used, and multiple definitions must satisfy the ODR. For an inline function or variable with external linkage, the implementation provides one entity with one address:
// metrics.hpp
#pragma once
#include <atomic>
inline int clamp_nonnegative(int value) {
return value < 0 ? 0 : value;
}
struct Metrics {
static inline std::atomic<unsigned> requests{0};
};Each translation unit can include this header. ODR-safe definitions use the same token sequence and must resolve dependent names consistently under the standard’s detailed exceptions. Configuration macros that produce different function bodies in different translation units create an ODR violation even when the linker silently chooses one body.
A function defined inside a class definition is implicitly inline unless it is attached to a named module under the C++20 module rules. A constexpr or consteval function is implicitly inline. Since C++17, a static data member declared constexpr is implicitly an inline variable; a namespace-scope variable is inline only when declared inline.
The optimizer independently decides whether to expand a call at its call site. Link-time optimization can inline a function that lacks the inline specifier, while recursion, code size, or optimization settings can leave an inline function as a call.
Use an inline definition when a header must define one shared entity. Use an unnamed namespace or namespace-scope static when every translation unit intentionally needs a distinct internal-linkage entity. Accidental per-translation-unit state is a common error:
// Distinct counter in every translation unit:
static unsigned local_counter;
// One counter shared by the program:
inline unsigned shared_counter;Do Not Apply the C++ Inline Model to C #
ISO C inline semantics differ from C++. A header-only helper that should remain private to each C translation unit can use static inline:
/* checksum.h */
static inline unsigned fold_byte(unsigned state, unsigned char byte)
{
return (state << 5) ^ (state >> 2) ^ byte;
}For a function with external linkage, a C inline definition is not automatically the one external definition required by the program. A robust portable arrangement is a normal declaration in the header and one external definition in a .c file:
/* codec.h */
int codec_reset(void);
/* codec.c */
int codec_reset(void)
{
return 0;
}C99-and-later ISO semantics, historical GNU inline semantics, and compiler language modes differ in how plain inline and extern inline provide external definitions. GCC’s -std=gnu89 and gnu_inline behavior therefore cannot be inferred from an ISO C example. Choose a project language mode, document it in the build, and inspect compiler documentation before mixing inline definitions with external linkage.
Review Declarations as Contracts #
For each nontrivial declaration, answer these questions in order:
- What is the declared type after applying the full declarator?
- Is the declaration also a definition?
- If it declares an object, what storage duration governs the object’s lifetime?
- What linkage, if any, connects the name to declarations elsewhere?
- Does the program require one definition, one definition per translation unit, or an ODR-equivalent inline definition?
- For a class, which special members are implicit, defaulted, deleted, trivial, or user-provided?
The answers identify ownership, lifetime, binary boundaries, and ODR obligations before those properties become linker failures or runtime defects.
Comments
Read or comment on GitHub (sign-in required to comment)