Studenckie Koło Naukowe Informatyki

UMCS

# Autor: Filip Koperkiewicz
# 📅 2025-05-05
# ⏳ 16 min

Jak podzielić projekt C++ na kilka plików?

o nagłówkach, kompilatorze g++ i Makefile

Cover Image

Jak podzielić projekt C++ na kilka plików?

Projekt wieloplikowy ma wiele plików źródłowych, ale kompiluje się do jednej binarki.

1. Pisanie kodu

main.cpp
#include <iostream>
#include <cmath>

class Circle {
	double radius;

public:
	Circle(double radius): radius(radius) {}

	double area() {
		return M_PI * radius * radius;
	}

	double getRadius() {
		return radius;
	}
};

void printCircleInfo(const Circle& circle) {
	std::cout << "Okrąg o promieniu " << circle.getRadius()
		  << " i polu " << circle.area() << std::endl;
}

int main() {
	Circle circle(4.5);
	printCircleInfo(circle);
	return 0;
}

Zdecyduj, jakie klasy lub funkcje umieścisz w jakim pliku. Stwórz pliki źródłowe (.cpp) z kodem i odpowiadające im pliki nagłówkowe (.h) zawierające same deklaracje – bez instrukcji.


Circle.h
#pragma once

class Circle {
	double radius;

public:
	Circle(double radius);
	double area();
	double getRadius();
};

void printCircleInfo(const Circle& circle);

#pragma once informuje kompilator, aby nie dołączał (ang. include) tego pliku więcej niż raz.


Circle.cpp
#include "Circle.h"

Circle::Circle(double radius): radius(radius);

double Circle::area() {
	return M_PI * radius * radius;
}

double Circle::getRadius() {
	return radius
}

void printCircleInfo(const Circle& circle) {
	std::cout << "Okrąg o promieniu " << circle.getRadius()
		  << " i polu " << circle.area() << std::endl;
}

Plik źródłowy załącza swój własny nagłówek. Niestety po rozdzieleniu klasy na deklarację i implementację zmienia się składnia – tak działa C++ i próba zmiany tego na siłę nie ma sensu.


main.cpp
#include "Circle.h"

int main() {
	Circle circle(4.5);
	printCircleInfo(circle);
	return 0;
}

Jeżeli zdecydujesz się umieścić pliki w podkatalogach, musisz zaktualizować odwołania include.

2. Kompilacja

  • main.cpp
  • Circle.h
  • Circle.cpp

Kompilatorowi należy podać ścieżki do wszystkich wymaganych plików źródłowych.

# skompiluje do binarki "main"
$ g++ -o main main.cpp Circle.cpp

# skompiluje i uruchomi, jeśli kompilacja się powiedzie
$ g++ -o main main.cpp Circle.cpp && ./main

Kompilacja składa się z 3 etapów:

  1. C++ jest przetwarzany do Assemblera,
  2. Assembler jest przetwarzany do kodu maszynowego (pliki .o – tzw. pliki obiektowe),
  3. Pliki obiektowe są składane w uruchamialną binarkę (tzw. linkowanie)

Linkowanie trwa nieporównywalnie krócej niż pozostałe 2 etapy i można wykonywać je niezależnie.

# skompiluje pojedynczy plik, bez linkowania
$ g++ -o main.o -c main.cpp
$ g++ -o Circle.o -c Circle.cpp

# zlinkuje prekompilowane pliki obiektowe
$ g++ -o main main.o Circle.o

Im więcej kodu, tym dłużej się kompiluje. Podział projektu na pliki i kompilowanie tylko tych, które się zmieniły pozwala programiście zaoszczędzić czasu.

3. Automatyzacja kompilowania

Make – narzędzie do automatyzacji procesu budowania oprogramowania nie tylko w C++, znajduje się w domyślnych repozytoriach twojej dystrybucji. Jest w stanie wykrywać, które pliki należy ponownie skompilować (porównując czasy ostatniej modyfikacji plików wejściowych i wyjściowych).

Stwórz plik Makefile:

Makefile
all: main

main: Circle.o main.o
	g++ -o main main.o Circle.o

Circle.o: Circle.cpp
	g++ -o Circle.o Circle.cpp

main.o: main.cpp
	g++ -o main.o main.cpp

clean:
	rm *.o main

.PHONY: all clean

Makefile składa się z reguł. Na lewo od ':' znajduje się plik (lub pliki) wyjściowe, a na prawo pliki wejściowe, oddzielone spacjami.

all jest regułą domyślną, clean regułą sprzątającą, a .PHONY klauzulą, która uniezależnia nazwę reguły od pliku, aby uniknąć kolizji.

Teraz wystarczy odpalić make lub make main, aby uruchomić procedurę budowania. Zobaczysz po kolei wykonywane komendy.

$ make && ./main

Makefile pozwala definiować złożone makra i stałe. Podzielę się z tobą moim setupem:

CXX = g++
SRC = $(wildcard classes/*.cpp)
OBJDIR = obj
OBJS = $(pathsubst %.cpp, $(OBJDIR)/%.o, $(SRC))
TARGETS = main test

all: $(TARGETS)

$(TARGETS): %: $(OBJS) obj/%.o
	$(CXX) -o $@ $^

$(OBJDIR)/%.o: %.cpp
	@mkdir -p $(dir $@)
	$(CXX) -o $@ -c $<

clean:
	rm -rf $(OBJDIR) $(TARGETS)

.PHONY: all clean

legenda:

  • $(...) - podstawia zmienną lub wyrażenie
  • wildcard classes/.cpp* - rozwija pattern w listę plików
  • pathsubst %.cpp, obj/%.o, $(SRC) - robi „znajdź i zamień” w SRC
  • $@ - aktualnie wytwarzany plik (lewa strona równania)
  • $^ - lista zależności (prawa strona równania)
  • $< - pojedyncza zależność (prawa strona równania)
  • dir $@ - katalog w którym występuje $@
  • obj/%.o: %.cpp - reguła dla każdego pliku pasującego do wzorca, z zależnością typu „znajdź i zamień”
  • main test: %: obj/%.o - reguła z zależnością typu „znajdź i zamień”, ale pod '%' podstawi całą nazwę pliku wyjściowego
  • @... - cicha komenda (nie pokaże się w terminalu)

Dzięki dynamicznym regułom nie ma potrzeby modyfikować Makefile po dodaniu kolejnego pliku, o ile dotychczasowa struktura projektu zostanie zachowana. Powyższy przykład uwzględnia więcej niż jeden target – jest main i test, kompilujące się do dwóch różnych binarek.

Niektórzy stosują bardziej wyspecjalizowane narzędzia, zastępujące bądź uzupełniające make'a, np. CMake generuje Makefile na podstawie pliku konfiguracyjnego – ale to już jest poza granicami tego wpisu.

# programowanie
# c++