Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 1

CMake for Cross-Platform C++: Separate Compilation

Recommended resources

Welcome to the first lesson of your journey into building high-performance AI systems from the ground up. Your goal is to move beyond high-level frameworks and gain the ability to engineer custom AI infrastructure. To do that, a deep understanding of C++ is non-negotiable, and every serious C++ project begins with a robust build system and a clean, scalable structure.

This lesson provides that essential foundation. We will focus on two core components: CMake, the de facto standard for building C++ projects, and the fundamental C++ practice of separating class declarations from their definitions. You'll learn not just how to set up a project, but why it's done this way, establishing the professional habits necessary for building the complex inference engine that is the capstone of this course.

While your experience in Python-based AI might make build systems seem like a solved problem handled by package managers, the C++ world requires a more hands-on approach to compilation and linking. Mastering this process is the first step toward controlling the hardware and unlocking true performance.

1. From Manual Compilation to Automated Builds

In C++, your human-readable source code (.cpp) must go through a two-stage process to become an executable program:

  1. Compilation: A compiler (like GCC, Clang, or MSVC) translates each .cpp file into a machine-readable "object file" (.o or .obj). It does this by referencing "header files" (.h or .hpp) which declare the functions and classes available.
  2. Linking: A linker takes one or more object files and "links" them together, resolving references between them to create a single, final executable.

For a project with a handful of files, you could run the compiler and linker manually from the command line. However, this quickly becomes untenable for any real-world application. This is the problem that build systems were created to solve.

The video below gives an excellent overview, starting with manual compilation using gcc, introducing the classic make utility, and finally explaining why a more powerful tool—CMake—is needed for modern, cross-platform development.

CMake Tutorial for Absolute Beginners - From GCC to CMake including Make and Ninja

Watch this "CMake Tutorial for Absolute Beginners" by Gary Explains. It provides the perfect context for why we need tools like CMake.

Start by watching the section on manual compilation with gcc. This will solidify your understanding of how source files become an executable and why doing it by hand is not scalable. Next, watch the introduction to Makefiles. This shows the first level of automation but also hints at its limitations. Finally, focus on the introduction to CMake. Pay close attention to the explanation of CMake as a build system generator and the critical concept of an out-of-source build.

As the video explains, CMake isn't a build system itself; it's a meta-build system. It reads a high-level configuration file you write, named CMakeLists.txt, and generates the native build files for your specific platform (e.g., Makefiles on Linux, Xcode projects on macOS, or Visual Studio solutions on Windows).

This abstraction is what makes CMake so powerful. It allows you to maintain a single, cross-platform project definition.

This diagram illustrates CMake's core function: transforming a single source tree into platform-specific build projects, which then produce native binaries. This is the key to writing code once and building it anywhere.

2. Your First CMake Project

Let's put this into practice by creating a minimal project. The core of any CMake project is the CMakeLists.txt file. For now, we'll start with a single main.cpp file in our project's root directory.

main.cpp




#include <iostream>

int main() {
    std::cout << "Hello, CMake!" << std::endl;
    return 0;
}

CMakeLists.txt




# Specify the minimum version of CMake required.
# This ensures that you can use modern CMake features.
cmake_minimum_required(VERSION 3.15)




# Define the project name. CMake will use this for the generated
# solution/project files.
project(HelloWorld CXX)




# Add an executable target. CMake will create a program named "hello"
# from the specified source file.
add_executable(hello src/main.cpp)

These three commands are the bare minimum for a simple executable:

  • cmake_minimum_required(): Declares the oldest version of CMake that can correctly process the file. This is crucial for ensuring compatibility and access to modern features.
  • project(): Sets the project name and specifies the language (here, CXX for C++).
  • add_executable(): Defines an executable target and lists the source files required to build it.

The Out-of-Source Build Workflow

A core tenet of modern CMake is the out-of-source build. All generated files (Makefiles, object files, binaries) are kept in a separate directory, leaving your source code pristine. The workflow is always the same:

  1. Create a build directory: mkdir build
  2. Navigate into it: cd build
  3. Configure: Run CMake to generate the build files: cmake .. (the .. points to the parent directory where CMakeLists.txt is located).
  4. Build: Run the actual build command: cmake --build . (this command is a generic wrapper that will call make, ninja, or whatever the native build tool is).

To see this entire process in action, the following clip from the previous video is a perfect demonstration.

CMake Tutorial for Absolute Beginners - From GCC to CMake including Make and Ninja

Now, watch the practical demonstration of building a "Hello World" project using the out-of-source build workflow.

Focus on the segment from bare minimum project. The presenter will execute the exact commands described above, showing how the build directory is populated and how the final executable is produced.


3. Separating Interface from Implementation

In our simple example, the main function was in a single file. In any real project, you will have dozens or hundreds of classes, each with its own logic. Placing all that code into one file would be a maintenance nightmare.

The standard C++ practice is to separate a class into two files:

  • Header File (.h or .hpp): Contains the class declaration. This includes the member variables and the signatures of the member functions (methods). It defines the class's public interface—what other parts of your code can see and use.
  • Source File (.cpp): Contains the class definition. This is the actual implementation of the member functions declared in the header.

This separation provides several benefits:

  • Modularity and Readability: It's easier to understand what a class does by looking at its clean interface in the header.
  • Reusability: Other parts of your code can #include the header to use the class without needing to know about its internal implementation.
  • Faster Compilation: If you only change the implementation in a .cpp file, only that file needs to be recompiled. If you change a header, every file that includes it must be recompiled.

This next video provides a clear, step-by-step guide to this separation.

Header & Implementation File for C++ Classes | C++ Tutorial

This tutorial, "Header & Implementation File for C++ Classes" by OttoBotCode, perfectly demonstrates the concept.

Watch the entire video. It's short and to the point. Pay attention to: The use of #pragma once to prevent a header from being included multiple times. How method definitions in the .cpp file are tied to the class using the scope resolution operator (::), for example void Monster::take_damage(...). The difference between including your own headers (#include "monster.h") and system headers (#include <iostream>).


4. A Professional Project Structure with CMake

Now, let's combine these two ideas: a structured file layout and a CMake configuration that understands it. A common and effective way to organize a project is to separate public headers from source code.

This image shows two common policies for file organization. We will focus on "Policy 2," where public headers are placed in an `include` directory, making the project's public API explicit.

Let's adopt this structure. We'll create a small library for a Greeter class and an executable that uses it.

greeter_project/
├── CMakeLists.txt
│
├── include/
│   └── greeter/
│       └── Greeter.h
│
└── src/
    ├── Greeter.cpp
    └── main.cpp
  • include/greeter/Greeter.h (The Interface)

    
    
    
    #pragma once
    #include <string>
    
    class Greeter {
    public:
        Greeter(std::string name);
        void say_hello() const;
    private:
        std::string name_;
    };
    
  • src/Greeter.cpp (The Implementation)

    
    
    
    #include "greeter/Greeter.h"
    #include <iostream>
    
    Greeter::Greeter(std::string name) : name_(name) {}
    
    void Greeter::say_hello() const {
        std::cout << "Hello, " << name_ << "!" << std::endl;
    }
    
  • src/main.cpp (The Consumer)

    
    
    
    #include "greeter/Greeter.h"
    
    int main() {
        Greeter my_greeter("World");
        my_greeter.say_hello();
        return 0;
    }
    

How do we write a CMakeLists.txt for this? We use a target-based approach. We'll define a greeter_lib target and an app target and then specify the relationship between them.

  • CMakeLists.txt
    cmake_minimum_required(VERSION 3.15)
    project(GreeterProject CXX)
    
    
    
    
    # 1. Create a library target.
    # This compiles Greeter.cpp into a library named "greeter_lib".
    add_library(greeter_lib src/Greeter.cpp)
    
    
    
    
    # 2. Specify the library's public interface.
    # This tells CMake that anyone linking against "greeter_lib" needs
    # to have the "include" directory in their include path to find Greeter.h.
    target_include_directories(greeter_lib PUBLIC include)
    
    
    
    
    # 3. Create an executable target.
    add_executable(app src/main.cpp)
    
    
    
    
    # 4. Link the executable against the library.
    # This connects "app" to "greeter_lib", ensuring they are linked together
    # and that "app" inherits the include directories from "greeter_lib".
    target_link_libraries(app PRIVATE greeter_lib)
    

This CMakeLists.txt file is the blueprint for a modern, modular C++ project. Each command has a clear purpose, defining targets and their properties rather than using global settings. This approach is fundamental to managing the large, multi-component systems you'll be building.

For further reading on best practices for structuring projects and organizing your CMake files, the following resource is excellent.

How to structure your project — Modern CMake

This guide from the "Modern CMake" book provides a concise overview of a conventional and scalable project layout.

Read the introductory section that presents the file structure, focusing on the motivation for the include directory and the nested project-name subdirectory. Also, note the explanation of why CMakeLists.txt files belong in source directories but not include directories.


Your Task

Now it's time to build your first structured C++ project. You will create a very simple, fixed-size 2D vector class and a program to test it.

  1. Create the Project Structure:

    • Create a root directory for your project (e.g., VectorProject).
    • Inside it, create an include/vector_lib/ directory and a src/ directory.
  2. Implement the Vector2D Class:

    • In include/vector_lib/, create Vector2D.h with the class declaration. It should have two double members, x and y, a constructor, and a method print() that prints its coordinates.
    • In src/, create Vector2D.cpp with the implementations of the constructor and the print() method.
  3. Create the Main Executable:

    • In src/, create main.cpp. In the main function, include vector_lib/Vector2D.h, create an instance of your Vector2D class, and call its print() method.
  4. Write the CMakeLists.txt:

    • In the root directory, create a CMakeLists.txt file.
    • Use add_library() to create a vector_lib target from Vector2D.cpp.
    • Use target_include_directories() to expose the include directory as part of the library's public interface.
    • Use add_executable() to create an app target from main.cpp.
    • Use target_link_libraries() to link app against vector_lib.
  5. Build and Run:

    • From your project's root directory, use the out-of-source build workflow (mkdir build, cd build, cmake .., cmake --build .).
    • Run your executable from within the build directory and verify its output.

This task synthesizes everything we've covered and serves as your first step in building C++ projects in a professional, scalable manner.


Conclusion

In this lesson, you have laid the crucial groundwork for all future work in this course. You now understand the role of a build system and why CMake is the tool of choice for modern, cross-platform C++ development. You've also put into practice the fundamental principle of separating a class's interface from its implementation.

Key takeaways include:

  • CMake is a cross-platform build system generator that automates compilation and linking.
  • The out-of-source build is the standard workflow, keeping your source directory clean.
  • Header files (.h) define a class's public interface, while source files (.cpp) provide the private implementation.
  • Modern CMake uses a target-based approach (add_library, target_link_libraries, etc.) to create modular and maintainable projects.

In the next lesson, we will move from project structure to one of the most important topics in high-performance C++: memory management. You will learn the difference between the stack and the heap by implementing a class that manually manages its own dynamic memory. This will be our first concrete step toward understanding the resource management challenges inherent in building a custom inference engine.