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:
- C++ jest przetwarzany do Assemblera,
- Assembler jest przetwarzany do kodu maszynowego (pliki .o – tzw. pliki obiektowe),
- 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:
Makefileall: 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.