Undefined-Symbol Policies When Linking Executables and Shared Libraries
GNU ld distinguishes undefined symbols by where the reference originates. A
reference originating from a regular object file used to construct the current
output is controlled independently from a reference already present in an
input shared library. This distinction applies even when the input shared
library is reached transitively through DT_NEEDED.
This article explains the effective GNU ld defaults when linking executables
and shared libraries, the option pair controlling each category, and the
special case in which a regular object file needs a symbol defined only by an
indirect shared-library dependency. It then applies those rules to pure virtual
interfaces and provider-owned C++ objects.
- Effective defaults
- Policy for current-target object files
- Policy for depended shared libraries
- A symbol required by
.obut defined only indirectly - Pure virtual interfaces and executable linking
- Pure-virtual semantics and provider-owned instances
- Why a pure virtual destructor still requires a definition
Effective defaults
| Output | Regular .o undefined symbols |
Input/transitive .so undefined symbols |
Effective policy |
|---|---|---|---|
| Executable | Rejected | Rejected | --no-undefined + --no-allow-shlib-undefined |
| Shared library | Allowed | Allowed | -z undefs + --allow-shlib-undefined |
These are effective policies; the compiler driver does not necessarily pass those exact options explicitly.
Policy for current-target object files
This policy applies only to unresolved references originating from regular
.o files used to construct the current output:
--no-undefined / -z defs
Reject unresolved references from regular .o inputs.
-z undefs
Permit unresolved references from regular .o inputs.
| Output | Policy selection | Unresolved symbol from current target’s .o files |
|---|---|---|
| Executable | Default: --no-undefined |
Rejected |
| Executable | Non-default: -z undefs |
Allowed by the static linker |
| Shared library | Default: -z undefs |
Allowed and retained as UND |
| Shared library | Non-default: --no-undefined |
Rejected |
--no-undefined does not control unresolved references already contained in
input shared libraries.
Policy for depended shared libraries
This policy applies only to unresolved references originating from directly or
transitively depended .so files:
--no-allow-shlib-undefined
Reject unresolved references found in depended shared libraries.
--allow-shlib-undefined
Permit unresolved references found in depended shared libraries.
| Output | Policy selection | Unresolved symbol from direct/transitive .so |
|---|---|---|
| Executable | Default: --no-allow-shlib-undefined |
Rejected |
| Executable | Non-default: --allow-shlib-undefined |
Allowed |
| Shared library | Default: --allow-shlib-undefined |
Allowed |
| Shared library | Non-default: --no-allow-shlib-undefined |
Rejected |
--allow-shlib-undefined does not control unresolved references originating
from regular .o inputs.
The origin of the reference selects the policy:
Reference originates from target.o
-> controlled by --no-undefined / -z undefs
Reference originates from an input or transitive .so
-> controlled by --[no-]allow-shlib-undefined
A symbol required by .o but defined only indirectly
Consider this dependency relationship:
target.o
-> requires function_b()
libA.so
-> DT_NEEDED: libB.so
-> defines function_b()
The link command specifies only A:
g++ target.o -lA ...
Although B contains the required symbol, modern GNU ld does not normally use
transitive DT_NEEDED libraries as direct providers for references originating
from target.o. For an executable, the default link therefore fails. For a
shared-library output, the default link can succeed only because undefined
references from regular .o inputs are permitted; function_b remains UND
in the resulting shared library rather than being resolved by the static
linker.
| Output and policy | Symbol-resolution result | Resulting DT_NEEDED relationship |
|---|---|---|
| Executable, default | Fails because B is not accepted as a direct provider | No output is produced |
Executable with --allow-shlib-undefined |
Still fails because the reference originates from target.o, not from a .so |
No output is produced |
| Shared library, default | Succeeds, but function_b remains UND |
B is not recorded directly; if A is retained, B remains reachable only through A’s DT_NEEDED entry |
Shared library with --allow-shlib-undefined |
Same as the default: succeeds with function_b still UND |
B is not recorded directly; if A is retained, B remains reachable only through A’s DT_NEEDED entry |
Shared library with --no-undefined |
Fails because the reference from target.o remains unresolved |
No output is produced |
Either output with B explicitly linked using -lB |
Succeeds and resolves the reference against a direct dependency | B can be recorded as a direct DT_NEEDED dependency |
Either output with --copy-dt-needed-entries |
Can recursively use B to resolve the reference | B can be copied into the output as a direct DT_NEEDED dependency |
With --as-needed, A may also be omitted if it does not directly satisfy any
required reference and has no other reason to be retained. In that case, the
output can retain function_b as UND without recording either A or B. Using
--no-as-needed can retain A, but explicitly linking -lB expresses the
actual direct dependency more accurately.
The normal solution is to declare B as a direct link dependency because
target.o directly consumes B’s symbol:
g++ target.o -lA -lB ...
Pure virtual interfaces and executable linking
A virtual call through a pure interface is not an ordinary direct symbol reference:
class Task
{
public:
virtual ~Task() = default;
virtual void run() = 0;
};
void execute(Task& task)
{
task.run();
}
The compiler can generate execute(Task&) without knowing any derived class.
The generated code follows the virtual-call ABI:
load the object's vptr
-> load the run() address from its vtable slot
-> call that address indirectly
Consequently, the caller’s object file does not normally contain an undefined
reference to a concrete override such as ConcreteTask::run(). The caller
never names that symbol. It contains the complete dispatch instructions
instead.
An executable containing only this interface and call site can therefore link successfully without linking a concrete provider:
executable
defines: execute(Task&)
undefined: no ConcreteTask::run() reference
DT_NEEDED: no provider required by this virtual call site
A concrete implementation becomes necessary when the program needs a real object. It can be supplied by a normally linked library or obtained later:
dlopen(provider)
-> dlsym(factory)
-> factory returns Task*
-> object carries the provider's vtable
-> execute(*task) reaches ConcreteTask::run() indirectly
A pure virtual interface supplies a complete dispatch contract, not a concrete function body. A call through the interface does not create an undefined reference to a particular concrete override, so that override does not participate in normal executable linking unless other code names it directly.
This conclusion applies specifically to the override reached through virtual dispatch. Other odr-used interface members can still require definitions:
class Task
{
public:
virtual ~Task(); // Requires a definition when odr-used.
virtual void run() = 0;
void stop(); // Requires a definition when called.
};
An explicit call to a base implementation, interface static data, an out-of-line key function, or code constructing a named derived class can likewise introduce ordinary symbol dependencies.
Pure-virtual semantics and provider-owned instances
The = 0 marker changes the class contract, not the ordinary virtual-call
instructions:
class Task
{
public:
virtual ~Task() = default;
virtual void run() = 0;
};
It has two related effects:
Taskis abstract and cannot be instantiated as a complete object.- A derived class cannot be instantiated until the inheritance hierarchy
provides a final overrider for
run().
An immediate derived class does not have to implement run(); it may remain
abstract. The requirement applies when a class is instantiated:
class IntermediateTask : public Task
{
// Still abstract.
};
class ConcreteTask final : public IntermediateTask
{
public:
void run() override {}
};
ConcreteTask task; // Valid: run() now has a final overrider.
Purity and the existence of a function body are separate. A pure virtual function may have an out-of-line definition and still remain pure:
void Task::run()
{
// Optional base behavior, callable explicitly as Task::run().
}
A derived class must still override it to become concrete. A pure virtual destructor is the important special case: it makes the class abstract but still requires a definition because derived destruction invokes it.
Abstract classes and vtables
Although an abstract class cannot exist as a complete object, its base subobject exists inside derived objects. Under common GCC/Clang ABIs, the abstract class can therefore have vtable and RTTI data. Conceptually:
Task vtable
run -> pure slot
stop -> Task::stop(), if stop() is non-pure
Under the Itanium C++ ABI, a pure slot may contain __cxa_pure_virtual, a
wrapper around it, or a null pointer. It is not a usable implementation.
A concrete derived class has a corresponding table whose pure slot is replaced:
ConcreteTask vtable
run -> ConcreteTask::run()
stop -> Task::stop()
The compiler and linker form class vtables as shared static data. Construction does not normally populate every function entry separately for each object. Instead, construction associates the object with the appropriate table by initializing its vptr:
construct Task base subobject
-> vptr refers to Task's construction-stage table
construct ConcreteTask portion
-> vptr refers to ConcreteTask's table
The client/provider boundary
A client-side virtual call contains a complete dispatch implementation:
void execute(Task& task)
{
task.run();
}
It does not contain the concrete behavior. The object supplied at runtime provides the vptr whose vtable slot selects that behavior:
client machine code provider-created runtime object
------------------- -------------------------------
load vptr + vptr -> ConcreteTask vtable
load run slot run -> ConcreteTask::run()
indirect call
The concrete function is compiled earlier on the provider side; runtime does not generate it. Runtime construction associates an object with its class-specific table, and invocation selects the address through that table.
To preserve implementation isolation, the provider should own construction:
// Public contract
class Task
{
public:
virtual ~Task() = default;
virtual void run() = 0;
};
extern "C" Task* create_task();
extern "C" void destroy_task(Task* task);
The provider alone knows and constructs the concrete type:
class ConcreteTask final : public Task
{
public:
void run() override;
};
extern "C" Task* create_task()
{
return new ConcreteTask;
}
extern "C" void destroy_task(Task* task)
{
delete task;
}
The client obtains only the abstract pointer:
Task* task = create_task();
task->run();
destroy_task(task);
Provider-side construction is not required by the C++ language. A client that
knows ConcreteTask may construct it directly. It is an architectural
requirement when the goal is to keep the concrete type, layout, constructor,
and override symbols outside the client.
Creation and destruction must be designed together. A virtual destructor allows polymorphic deletion, but a strict binary boundary may prefer paired provider-owned create/destroy functions so allocation and deallocation occur in the same module.
With ordinary shared linking, the client may have an undefined reference and
DT_NEEDED entry for the factory library, while still having no direct
reference to ConcreteTask::run(). With explicit loading, dlopen() and
dlsym() can remove even the factory from the executable’s normal link and
startup dependency graph.
The resulting isolation is:
provider-owned concrete type
-> provider constructs object
-> object carries provider vtable
-> client receives Task*
-> client performs fixed indirect dispatch
-> provider-owned override executes
The central conclusion is:
The pure virtual marker enforces an abstract contract, while virtual dispatch lets client code compile without a symbolic dependency on a concrete implementation. To preserve that separation in an SDK, the provider creates the concrete object and exposes only the interface and a safe lifetime contract to the client.
Why a pure virtual destructor still requires a definition
A pure virtual destructor combines two independent properties:
class Task
{
public:
virtual ~Task() = 0;
};
Task::~Task() = default;
virtualpermits destruction through a base pointer to begin with the most-derived destructor.= 0makesTaskabstract.- The definition supplies the code required to destroy the
Taskbase subobject.
Providing the definition does not remove purity. Task remains abstract and
cannot be instantiated as a complete object.
Every derived object contains its base-class subobject. Its destruction must therefore execute the complete destructor chain:
ConcreteTask::~ConcreteTask()
-> destroy ConcreteTask members
-> Task::~Task()
-> destroy Task members
The derived destructor’s call to the base destructor is mandatory. It is not an optional virtual operation selected only when user code calls it. Even an empty base destructor needs a callable definition so the generated destruction chain has a valid target.
Without that definition:
class Task
{
public:
virtual ~Task() = 0; // Declaration only: no Task::~Task() definition.
};
class ConcreteTask final : public Task
{
public:
~ConcreteTask() override = default;
};
int main()
{
ConcreteTask task;
}
compilation may succeed, but linking normally fails with an error such as:
undefined reference to Task::~Task()
A virtual destructor also supports deletion through the interface:
Task* task = create_task();
delete task;
Conceptually, two mechanisms participate:
delete Task*
-> virtual dispatch selects ConcreteTask::~ConcreteTask()
-> normal destructor chaining invokes Task::~Task()
A strict SDK boundary may still prefer a provider-owned
destroy_task(Task*) function so allocation and deallocation remain in the
same binary module.
This differs from an ordinary pure virtual operation:
virtual void run() = 0;
If every concrete derived class overrides run(), normal virtual dispatch
never needs a body for Task::run(). Destruction is structurally different:
the language always destroys every base subobject after destroying the derived
portion.
The essential rule is:
A pure virtual destructor makes the class abstract, but every derived-object destruction must still invoke the base destructor. Therefore the pure base destructor must have a definition.