CodeGym /Courses /C++ SELF /Separate compilation: -c, object files and linking

Separate compilation: -c, object files and linking

C++ SELF
Level 33, Lesson 2
Available

1. Separate compilation

If you are just starting to program, separate compilation looks like a strange ritual: “Why not just always run g++ main.cpp -o app and be done?” On tiny examples you can. But as soon as a project grows to 5–20 files, you start noticing that building feels like making borscht: even if you added one pinch of salt, somehow you're asked to re-grow the beet.

Separate compilation solves exactly this problem. The idea is simple: compile each .cpp separately into an intermediate artifact (an object file), and then “stitch” them together with the linker. Then, when you change one .cpp you don't need to rebuild everything: you rebuild only one object file and relink the result.

This saves time and also helps you understand better where the error happened: during the compilation of a specific file or during linking all parts into one program.

2. Object file and the -c flag

What an object file .o is

An object file (.o) is the result of compiling a single .cpp file. Inside there is machine code (or close to it), plus symbol tables: which functions and variables this file defines, and which it expects to get from elsewhere.

Imagine a .cpp as a separate LEGO piece. The compiler turns it into a “finished piece” (an object file), but it may have holes where other pieces need to fit. The linker is the one who takes all the pieces and assembles one big ship, checking that all holes match and nothing got lost under the sofa.

Important to remember: an object file is not a program yet. You cannot “run” it. It is an intermediate result.

The -c flag: “compile but do not link”

The -c flag literally tells the compiler: “Do everything related to compilation, but stop before the linking stage.” So the compiler will take your .cpp, turn it into an object file .o — and stop there.

Typical command form looks like this:

g++ -std=c++23 -c file.cpp

If you do not specify -o, the compiler usually produces an object file with the predictable name file.o. But when you build a project from several files, it's good practice to explicitly name output artifacts:

g++ -std=c++23 -c file.cpp -o file.o

3. Mini‑project TextStats

To avoid discussing separate compilation in a vacuum, let's build a small application from several files. It will read a line and print: the number of letter characters and the number of spaces. The logic is simple, but it's convenient to extract it into a separate module.

File structure

flowchart LR
  A[main.cpp] -->|#include 'text_stats.hpp'| B[text_stats.hpp]
  C[text_stats.cpp] -->|#include 'text_stats.hpp'| B
  A -->|calls functions| C

text_stats.hpp: function declarations

When we start splitting a project into files, it's important to mentally separate two roles: a header .hpp tells “what exists” (declarations), and a .cpp contains “how it actually works” (definitions). It's like a cafe menu: the menu promises that “pizza exists”, but you get the pizza from the kitchen, not the menu.

Let's create the header text_stats.hpp:

#pragma once
#include <string>

int count_letters(const std::string& s);
int count_spaces(const std::string& s);

Note: there is no implementation here, only signatures. And that's fine.

text_stats.cpp: function definitions

Now create text_stats.cpp, which implements the functions. It's important that the signatures match exactly what was declared in .hpp.

#include "text_stats.hpp"
#include <cctype>

int count_letters(const std::string& s) {
    int cnt = 0;
    for (char c : s) {
        if (std::isalpha(static_cast<unsigned char>(c))) ++cnt;
    }
    return cnt;
}

And the second function (we intentionally keep examples small):

#include "text_stats.hpp"

int count_spaces(const std::string& s) {
    int cnt = 0;
    for (char c : s) if (c == ' ') ++cnt;
    return cnt;
}

Yes, here #include "text_stats.hpp" appears twice. In a real project you would keep both functions in a single text_stats.cpp, but for learning it's useful to see that each .cpp file is a compilable unit on its own.

main.cpp: using the functions

In main.cpp we read a line and print the stats:

#include <iostream>
#include <string>
#include "text_stats.hpp"

int main() {
    std::string s;
    std::getline(std::cin, s);

    std::cout << "letters=" << count_letters(s) << '\n';
    std::cout << "spaces=" << count_spaces(s) << '\n';
}

Example input/output (to feel the result):

input:  Hello world
output: letters=10
        spaces=1

4. Manual build: .cpp → .o → executable

Now for the tastiest part: how to build this by hand.

Step 1: compile each .cpp into an object file

First compile each source file separately:

g++ -std=c++23 -c text_stats.cpp -o text_stats.o
g++ -std=c++23 -c main.cpp       -o main.o

At this stage nothing is running. You just get files text_stats.o and main.o.

Step 2: link object files into a program

Now you need to “stitch” object files into an executable. This is done by calling g++ again, but without -c, and the inputs are the .o files:

g++ main.o text_stats.o -o textstats

After that you get textstats (or textstats.exe on Windows-like environments). And now you can run it:

./textstats

5. Incremental rebuild

Now imagine a typical situation: you decided to improve count_letters and add handling for some characters. You change only text_stats.cpp.

With separate compilation you do this:

  1. rebuild only the single object file:

    g++ -std=c++23 -c text_stats.cpp -o text_stats.o
  2. relink:

    g++ main.o text_stats.o -o textstats

main.cpp remains untouched, main.o stays the same. This is the practical win: the project can be large, and you change a small piece — you rebuild only that piece.

6. The undefined reference error: what it means

Compilation errors are usually fairly clear: they show a line, a column, what was expected. Linker errors initially look like “a curse in an ancient language.” But they have a very clear logic.

The phrase undefined reference to ... means: somewhere there is a use of a function/variable, but among what you gave to the linker there is no matching definition.

The most common cause at our level is you forgot to add the required object file (or the corresponding .cpp) to the linking step.

Let's intentionally build incorrectly. You did:

g++ -std=c++23 -c main.cpp -o main.o
g++ main.o -o textstats

So you did not link text_stats.o. What will happen? Compilation of main.cpp will succeed because the header text_stats.hpp provided declarations. But the linker, when assembling the program, will say: “Okay, main.o has a call to count_letters, but where is the implementation? I do not see it.”

And here is a super-important rule:

#include helps compilation (visibility of declarations), but it does not help linking (providing implementations).

Psychologically this is a newbie trap: “But I did include the header!” Yes, you included it. But a header is not a .cpp and not a .o.

7. How to diagnose undefined reference by file list

When you see undefined reference, it's tempting to start witchcraft: reorder files, add #include everywhere, rename everything. It's entertaining, but usually ineffective.

Instead keep a calm algorithm in mind.

First determine that it's a linking problem

Linker errors usually contain words like undefined reference, ld, linker, collect2. The compiler may already have produced .o.

If it's linking, the problem is almost always one of three:

  1. you didn't add the required .o (or .cpp) to the link;
  2. you declared one thing but defined another (signatures didn't match);
  3. you defined something "too many times" (that is multiple definition, which we'll only touch on briefly today).

If an object file is missing, ask one question

Ask: in which .cpp should the code for that function live?

For example, if the error mentions count_spaces, the obvious candidate is text_stats.cpp. So the linking step must include text_stats.o.

That is, the link command should list object files such that it includes the one containing the definition.

If the object file is present but the symbol is still missing

This is a trickier case: you do link text_stats.o, but the error remains. Then it's likely the compiler and linker see two different functions:

  • in main.cpp you call int count_letters(const std::string&),
  • but in text_stats.cpp you accidentally wrote int count_letters(std::string) (by value) or omitted const.

For a human this is “almost the same”. For the linker these are two different symbols. It is not obliged to guess your intention (and fortunately it does not).

Relation to ODR and inline

We won't dive deep into theory today, but it's useful to know that many linking problems are tied to the One Definition Rule (ODR). Even in standard notes they emphasize that inline is primarily a tool that allows multiple declarations to satisfy ODR, not just “a hint to the optimizer”.

Put plainly: if you start stuffing function definitions into headers without understanding, you can easily get multiple definition. So at this stage stick to a simple rule: declarations in .hpp, definitions in .cpp.

Cheat sheet of commands

It's sometimes useful to have a short map to avoid remembering everything.

Goal Command Result
Compile one file but do not link
g++ -std=c++23 -c a.cpp -o a.o
object file
a.o
Link a program from object files
g++ a.o b.o -o app
executable
app
Rebuild only the changed module
g++ -std=c++23 -c b.cpp -o b.o
updated
b.o
Quickly understand undefined reference check that the required
.o
is present in the link
error disappears

8. Typical mistakes

Mistake #1: expecting an executable after a command with -c.
-c disables linking. The compiler honestly produces an object file and stops. If after g++ -c main.cpp you try to run the result — you are trying to run a “half-finished product”. It's like trying to eat dough because “I already mixed flour and water”.

Mistake #2: thinking that #include “brings in the implementation”.
#include pastes the header text into your .cpp at the preprocessor stage. It helps the compiler see declarations, but it does not add any code to the link. Therefore undefined reference is fixed not by adding #include, but by adding the required .o (or .cpp) to the link command.

Mistake #3: not linking all object files of the project.
This is the most common cause of undefined reference at the command-line level. You compiled main.o, compiled text_stats.o, but in the link you wrote only g++ main.o -o app. The linker is not a mind reader: if a file is not listed, it effectively does not exist.

Mistake #4: mismatch between declaration and definition signatures.
A sneaky situation is when the header has int f(const std::string&);, but in the .cpp you wrote int f(std::string);. Compilation of each file separately may succeed, but linking will fail. The linker looks for an exact symbol match. For it these are different functions, even if to you it seems “almost the same”.

Mistake #5: “quick fix” by moving the function definition into the header and getting new problems.
Sometimes after undefined reference a newbie thinks: “Ah! So the implementation must be visible everywhere” — and inserts the function body into the .hpp. On a small project this might “accidentally work”, but in a real project it easily leads to multiple definition (because the same code ends up in multiple translation units). At our current level it's safer to keep basic discipline: implementation in .cpp, declarations in the header.

1
Task
C++ SELF, level 33, lesson 2
Locked
Greeting module
Greeting module
1
Task
C++ SELF, level 33, lesson 2
Locked
Power of modules
Power of modules
1
Task
C++ SELF, level 33, lesson 2
Locked
Fast rebuild
Fast rebuild
1
Task
C++ SELF, level 33, lesson 2
Locked
Missing object file
Missing object file
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION