cswork / libraryOpen practice workspace

Articles

C++ Linking Interview Questions: Undefined References, Duplicate Symbols, and Library Order

Diagnose C++ linker interviews with three worked builds: an undefined reference, duplicate global definition, and GNU static-library order failure.

A C++ file can compile cleanly and still fail to become a program. The compiler accepted each translation unit; the linker has a different job: combine object files and libraries so required external symbols have suitable definitions. In a linking interview, identify the symbol, the object that needs it, and the file expected to define it before changing flags. That three-part trace diagnoses more than the message “undefined reference.”

The three mini-builds below are original interview exercises, not claims that an employer asks these exact prompts. Build commands for archive order explicitly assume GCC driving GNU ld on a conventional Unix-like system; Apple and Microsoft linkers have different defaults and diagnostics. GCC's link-options manual and the GNU linker manual support the tool-specific behavior.

Question 1: Why did a declaration compile but the link fail?

Take three files. The header promises a function; one source uses it; another is supposed to define it.

// parse.hpp
int parse_count(const char* text);

// main.cpp
#include "parse.hpp"
int main() { return parse_count("7") == 7 ? 0 : 1; }

// parse.cpp
#include "parse.hpp"
int parse_count(const char* text) { return text[0] - '0'; }

c++ -c main.cpp -o main.o needs the declaration to type-check the call. It can emit a reference to parse_count(char const*) without the definition being present in that translation unit. If the final command is c++ main.o -o app, the expected outcome is a missing-symbol diagnostic because parse.o is absent. c++ main.o parse.o -o app supplies the definition. The exact spelling of the diagnostic and the mangled symbol depends on the platform's ABI and linker; the reasoning does not.

When explaining this to an interviewer, say which stage passed and which failed. “Add the header” is not a repair: main.cpp already compiled. The relevant question is whether the object containing the definition was built and passed to the final link. If parse.cpp exists but parse.o was never built, repair the build graph. If it was built but omitted from the link line, repair the target's sources or library dependency. If the supposed definition has a different parameter type, namespace, const qualification, or C-versus-C++ language linkage, it may define a different symbol. Inspect, do not guess from the source-level name alone.

nm -C main.o and nm -C parse.o are useful on toolchains that provide nm; they can show an undefined reference in one object and a defined symbol in the other. On platforms with different object formats, use the corresponding symbol-inspection tool. A demangler improves readability, but demangling is not a substitute for checking the exact signature. The test is whether the needed symbol and available definition match under the chosen ABI.

The presence of a declaration is a compile-time promise, not proof that a definition is linked. A header-only template or inline function has different instantiation and definition rules from this ordinary out-of-line function. If the interviewer changes the example to a template, ask where it is instantiated and whether the definition is visible at that point; do not mechanically repeat “add parse.o.”

Question 2: What creates a duplicate symbol across files?

Now change the header itself:

// counter.hpp — intentionally broken
int counter = 0;

// first.cpp
#include "counter.hpp"
void first() { ++counter; }

// second.cpp
#include "counter.hpp"
void second() { ++counter; }

Each source file includes the definition int counter = 0;. After preprocessing, each translation unit contains its own externally linked definition. Both may compile separately, while a final executable link that includes both first.o and second.o can report a multiple-definition or duplicate-symbol error. The mistake is not that the linker “cannot choose the right header.” The build gave it two incompatible claims to one program-level external entity.

For this ordinary global, put extern int counter; in the header and exactly one int counter = 0; in a .cpp file, then link that object. In C++17 or later, an inline variable in a header is another intentional mechanism, with its own language rules. The appropriate repair depends on whether you want one shared variable or independent translation-unit state. Marking it static in the header may eliminate an external duplicate by making separate instances, but that changes semantics; it is not a universal fix for a counter meant to be shared.

The same diagnostic family can come from an ordinary non-inline function defined in a header, duplicate generated sources, or accidentally linking one object twice. Ask for the link command. If the same .o is listed twice, editing the header is the wrong fix. If a function was defined in two source files, remove or rename one definition according to the intended API. If a template or inline function is involved, inspect the language's actual one-definition and linkage rules before declaring a violation.

Tracemain.o or first objectOther inputDiagnosis
Missing parse_countNeeds parse_count(char const*)parse.o absent or mismatchedFind or create the matching definition, then include its object in the link.
Duplicate counterDefines counterAnother object also defines counterEstablish one intended definition or a valid inline-variable contract.
Archive orderLater archive introduces a needEarlier archive had the definitionUnder GNU archive scanning, put the user before its provider or model the dependency.

This ledger is an original debugging aid. It deliberately keeps compile errors, link errors, and runtime loader errors separate. If the executable links but fails to launch because a shared library cannot be found, inspect the runtime loader path and packaged shared objects; the linker's earlier success does not guarantee deployment is complete.

Question 3: Why can -lcodec -lparse fail while the reverse works?

Assume main.o calls parse(). A member of libparse.a defines parse() but calls decode(). A member of libcodec.a defines decode(). Under ordinary GNU ld archive handling, archive members are extracted when they satisfy undefined references known at that point in the command. GNU ld documents that an archive is normally searched once at its position. GCC's -l documentation explicitly warns that placement relative to object files matters.

g++ main.o -L. -lcodec -lparse -o app   # likely unresolved decode()
g++ main.o -L. -lparse -lcodec -o app   # parse need, then decode need

In the first command, main.o makes parse() unresolved. The linker scans libcodec.a while no object yet needs decode(), so the member defining it may not be extracted. It then scans libparse.a, pulls the parse() definition, and discovers the new decode() need too late for the earlier archive. Reversing the two archives presents the provider after the user. The exact result still depends on whether the selected files are static archives or shared libraries, linker flags, and the actual symbol graph. Do not call every reversed -l pair a guaranteed error.

For circular dependencies between static archives, one option under GNU ld is an archive group with --start-group and --end-group, which rescans group members; the linker manual notes a performance cost. Another is to restructure the libraries or repeat a necessary archive on the line. In a CMake project, express the target dependency first and inspect the generated link line; CMake may place or repeat static libraries according to its graph and version-specific strategy. A build-system declaration is preferable to scattering manual -l flags in unrelated targets.

An interview answer should include a trace, not only the rule “put libraries last.” Object files and archives are processed differently, linkers differ, and shared-library options such as --as-needed add context. State the linker assumption, list the currently unresolved symbols after each relevant input, and show why the second command changes the result.

A four-column failure triage that prevents random fixes

Record the failing stage, symbol evidence, command input, and next experiment. If compilation reports “not declared,” inspect headers, namespaces, and include paths. If linking reports an undefined symbol, inspect matching definitions, object inclusion, signature/ABI, and then archive order. If linking reports a duplicate, count definitions and check whether the same object is passed twice. If launching reports a missing .so or .dylib, inspect runtime dependencies and loader search paths. Each category asks a different question.

On a GNU-style system, g++ -### ... can reveal the driver-selected link invocation without executing it; a verbose build command can reveal the real order of libraries. nm -C, readelf, or objdump may help inspect symbols and dependencies where supported. On macOS, otool and nm have different platform details; on Windows, use the appropriate PE/COFF tools and account for import libraries. Your answer should be about evidence from the actual toolchain, not a command copied from an unrelated platform.

Also distinguish missing file from missing symbol in a file that is present. If the link command fails to find -lcodec, investigate library search directories, spelling, architecture, and artifact production. If it finds the archive but cannot resolve decode(), inspect the archive's members and the expected mangled symbol. A C++ name-mangling mismatch across compiler or ABI boundaries can look like an absent definition even when a similarly named function exists.

A strong closing answer

“I would first capture the exact link command and the missing or duplicated symbol. Then I would identify which object references it and which object or archive defines it. For an undefined reference, I would check that a matching definition was built, passed to the link, and compatible with the ABI; for a duplicate, I would count definitions and inspect repeated inputs. Only after that would I consider archive order. With GNU ld, a static archive is normally searched at its command-line position, so the library that uses another archive should usually come first. I would verify that assumption against the actual linker and whether the files are static or shared.”

That response gives the interviewer a falsifiable method. Rehearse it by intentionally omitting parse.o, defining counter in two translation units, and reversing the two archives. Then use Trace Source Code to an Executable to explain why the failed build got as far as it did.

Sources and Further Reading