Build tools: C++ and Rust

A C++ project names its source files in a build file and the compiler is called once per file. A Rust project names its package once and the compiler finds the modules from the source tree. The same two-file program is the smallest case where the difference shows.

The task in C++

The program has a header, a second translation unit and a main. The Makefile lists the two object files, the rule that links them and a rule per source file with its header as a prerequisite, which is what makes an edit rebuild only what depends on it.

vi main.cpp greet.cpp greet.h Makefile
make
./app

app: main.o greet.o g++ -std=c++23 -Wall -Wextra main.o greet.o -o app main.o: main.cpp greet.h g++ -std=c++23 -Wall -Wextra -c main.cpp greet.o: greet.cpp greet.h g++ -std=c++23 -Wall -Wextra -c greet.cpp clean: rm -f app *.o

#include "greet.h" std::string greeting(const std::string &name) { return "hello " + name; }

#include <iostream> #include "greet.h" int main() { std::cout << greeting("rust") << "\n"; }

g++ -std=c++23 -Wall -Wextra -c main.cpp
g++ -std=c++23 -Wall -Wextra -c greet.cpp
g++ -std=c++23 -Wall -Wextra main.o greet.o -o app
hello rust

Make prints each command it runs, which is how the three steps are visible: two compilations and one link. A second make compares the timestamps and stops at once:

make: 'app' is up to date.

The header is named as a prerequisite, so changing it recompiles both object files. Forgetting that line is the classic Makefile bug: the build succeeds and links against a stale object file.

The same task in Rust

The manifest names the package and the edition, and nothing about files. src/main.rs is the binary, mod greet; in it declares the second file, and cargo finds src/greet.rs by that name.

cargo new --vcs none greet
vi Cargo.toml src/main.rs src/greet.rs
cargo run

[package]
name = "greet"
version = "0.1.0"
edition = "2024"

[dependencies]

mod greet; fn main() { println!("{}", greet::greeting("rust")); }

pub fn greeting(name: &str) -> String { format!("hello {name}") }

   Compiling greet v0.1.0 (/tmp/rw7/CMPB/greet)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.74s
     Running `target/debug/greet`
hello rust

One command builds and starts the program, and the second one reuses the build:

    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
     Running `target/debug/greet`
hello rust

The same words appear in both tools: a compile step per unit, a link step, and a check that skips what has not changed. The difference is who writes the list of units.

What differs and why

Who lists the files: the Makefile is a graph written by hand, and every new source file needs a rule and a place in the link line. Cargo derives the graph from the source: a module is a file whose name matches the declaration, and the compiler resolves it.

Who passes the flags: -std=c++23 -Wall -Wextra is in the Makefile, once per rule, and a missing rule silently drops it. Cargo has them in the profile, and every unit of the package gets them; a flag is written once in the manifest or in RUSTFLAGS.

What a rebuild knows: make compares file times, so a clock skew or a restored checkout can rebuild too little or too much. Cargo records a fingerprint of the flags and the inputs of each unit, which is why a change of --release or of a feature rebuilds and a comment does not.

What the dependency list is: a Makefile has no idea what a header includes, so the prerequisites are a manual approximation. Cargo keeps the dependencies in the manifest and resolves them before a single compile, which is the step that needs the network and the lock file.

The habit that breaks

The habit is to add a source file and forget the build file. In C++ the new file compiles on its own and the link fails with an undefined reference, which is at least a loud failure.

#include "greet.h" int format_code() { return 7; }

The header declares int format_code(); and main calls it, but the Makefile still links only main.o greet.o. g++ compiles main and then the linker cannot find the function.

make

g++ -std=c++23 -Wall -Wextra -c main.cpp
g++ -std=c++23 -Wall -Wextra -c greet.cpp
g++ -std=c++23 -Wall -Wextra main.o greet.o -o app
/usr/lib/gcc/x86_64-alpine-linux-musl/15.2.0/../../../../x86_64-alpine-linux-musl/bin/ld: main.o: in function `main':
main.cpp:(.text+0x7e): undefined reference to `format_code()'
collect2: error: ld returned 1 exit status
make: *** [Makefile:2: app] Error 1

The Rust version of the same forgetfulness is a missing mod line, and the compiler turns it into a suggestion about the file it can see on disk.

fn main() { println!("{}", greet::greeting("rust")); }

cargo build

   Compiling greet v0.1.0 (/tmp/rw7/CMPB/greet)
error[E0433]: cannot find module or crate `greet` in this scope
--> src/main.rs:2:20
|
2 | println!("{}", greet::greeting("rust"));
| ^^^^^ use of unresolved module or unlinked crate `greet`
|
help: to make use of source file src/greet.rs, use `mod greet` in this file to declare the module
|
1 + mod greet;
|

error: could not compile `greet` (bin "greet") due to 1 previous error

The link error names a symbol and the compile error names a file, and the difference is where the list of units lives. Neither tool reads the other's mind, so a new file is a change to the build in both languages: one line in a Makefile, one line in a module tree.

The Rust lesson for this ground is cargo build, run and check, and the C++ lesson is Make. The previous page in this set is Smart pointers.

The C++ programs were built with g++ 15.2.0 and GNU Make, and the Rust programs with rustc 1.96.1 and cargo 1.96.1; both were tested on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev