Narzędzia niedoceniane: make

2020-04-11

Czym jest make? Jest to narzędzie wykonujące reguły. Takie reguły mogą zależeć od plików lub on innych reguł, i make analizuje je, sprawdzając, czy pliki, od których dana reguła zależy zostały zmienione (a, co za tym idzie, regułę trzeba uruchomić ponownie), albo te pliki nie istnieją, ale make zna inną regułę, która potrafi je stworzyć.

Dla programistów dobre będzie określenie, że make to dość prymitywny system budowania, który nie zakłada wykorzystywania żadnego języka programowania - chociaż kilka reguł jest domyślnie dostarczonych, to tak naprawdę siła make polega na tym, że te reguły najczęściej musimy napisać sami.

Czy uważam, że make to najlepszy wybór dla dużych, skomplikowanych projektów? Nie, w takich przypadkach należy rozejrzeć się za innymi build systemami, które zrobią za nas więcej. Jednak do prostych zastosowań, zwłaszcza, kiedy chcemy zrobić coś, do czego nikt nie stworzył wygodnego systemu budowania, make jest niezastąpione.

Istnieje wiele implementacji make, ale my będziemy korzystać z GNU Make. Jest to implementacja dostępna dla większości systemów operacyjnych i można ją znaleźć w większości dystrybucji Linuksa.

Niepraktyczny, wymyślony przykład

Załóżmy, że często łączymy dwa pliki, po czym zamieniamy w pliku wynikowym takiego złączenia wszystkie litery na małe. Używamy do tego następujących dwóch poleceń:

$ cat wejscie1.txt wejscie2.txt > wyjscie.mixed.txt
$ tr '[A-Z]' '[a-z]' < wyjscie.mixed.txt > wyjscie.upper.txt

Oczywiście, maszyny zostały stworzone po to, żebyśmy nie musieli ciągle powtarzać tego samego - one są w tym o wiele lepsze. Spróbujmy więc zautomatyzować naszą pracę.

Pozornie najłatwiejsza opcja: skrypt bashowy

Zakładając, że pliki wejściowe wejscie1.txt i wejscie2.txt znajdują się w aktualnym katalogu, to wszystko, czego potrzebujemy to następujący skrypt:

#!/usr/bin/env bash

cat wejscie1.txt wejscie2.txt > wyjscie.mixed.txt
tr '[A-Z]' '[a-z]' < wyjscie.mixed.txt > wyjscie.upper.txt

Jakie są minusy takiego rozwiązania? Przede wszystkim, jeśli mamy więcej niż jeden plik wejściowy, to będziemy musieli zrobić dużo kopiuj-wklej. Dodatkowo, zdecydowanym problemem w takim podejściu jest to, że skrypt bashowy nie sprawdza, które pliki się zmieniły, i które trzeba ponownie wygenerować.

Dla przykładu, jeśli wyjscie.upper.txt zostało usunięte, to aby ponownie wygenerować ten plik skrypt bashowy będzie musiał wykonać też wcześniejszy krok - nawet, jeśli wyjscie.mixed.txt jest aktualny i wystarczyłby tylko drugi krok. Spróbujmy więc napisać prosty Makefile, który zajmie się tym za nas.

Makefile: opcja moim zdaniem przyjemniejsza

Polecenie make domyślnie przyjmuje reguły zapisane w pliku o nazwie Makefile (wielkość liter ma znaczenie). Pliki Makefile mają dość prostą budowę:

CEL: ŹRÓDŁO1 ŹRODŁO2
	polecenie lub polecenia generujące CEL na podstawie ŹRÓDEŁ
  • CEL jest nazwą celu, który tworzy dana reguła. Zwykle jest to nazwa pliku, który zostanie stworzony, ale jest też możliwość tworzenia celów nie tworzących żadnych plików (a jedynie np. uruchamiających jakiś program albo czyszczących środowisko).
  • ŹRODŁO1 i ŹRÓDŁO2 to nazwy plików, od których zależy wygenerowanie CELu. Pliki te muszą albo istnieć, albo musi być zdefiniowana reguła, która je tworzy - w innym wypadku dostaniemy błąd:
    make: *** Brak reguł do zrobienia obiektu 'ŹRÓDŁO', wymaganego przez 'CEL'. Stop.
  • W następnej linijce, po tabulatorze podane są polecenia generujące CEL, np. wywołanie kompilatora.
    • Ważne: Przed poleceniem musi znajdować się jeden tabulator. Jeśli zamiast tabulatora znajdą się tam spacje, reguła nie będzie działać.
    • Można podać więcej niż jedną komendę, kolejne komendy są podawane w następnych linijkach. Ważne: każda taka linijka jest wykonywana w osobnym procesie. Dlatego np. zmienne ustawione w jednej linijce nie będą dostępne w następnej.

Stwórzmy więc na tej podstawie taki prosty plik odpowiadający skryptowi bashowemu napisanemu wcześniej:

wyjscie.upper.txt: wyjscie.mixed.txt
	tr '[A-Z]' '[a-z]' < wyjscie.mixed.txt > wyjscie.upper.txt

wyjscie.mixed.txt: wejscie1.txt wejscie2.txt
	cat wejscie1.txt wejscie2.txt > wyjscie.mixed.txt

W tym pliku widzimy zdefiniowane dwa cele: wyjscie.upper.txt i wyjscie.mixed.txt, z czego ten pierwszy zależy od drugiego, a drugi zależy od dwóch kolejnych plików. Jeśli teraz wywołamy make w linii poleceń, to make spróbuje stworzyć pierwszy cel zdefiniowany w pliku Makefile za pomocą dostępnych reguł. Co prawda wyjscie.mixed.txt nie istnieje, ale make wie, jak je stworzyć, po czym tworzy wyjscie.upper.txt. Jeśli skasujemy wyjscie.upper.txt i wywołamy ponownie make, to polecenia służące do generowania wyjscie.mixed.txt nie zostaną wywołane - bo nie ma takiej potrzeby (o czym make dowiaduje się porównując czas modyfikacji plików).

Co jednak dla mnie ważne, w pliku Makefile – w odróżnieniu od skryptu bashowego – wyraźnie widzimy poszczególne kroki, które należy podjąć przy budowaniu naszego pliku.

Upraszczamy Makefile stosując wbudowane zmienne

W naszym przykładzie dość często się powtarzamy. Skorzystajmy ze zmiennych automatycznie dostarczanych przez make, a konkretnie z:

  • $@ - nazwa CELu
  • $< - nazwa pierwszego ŹRÓDŁA
  • $^ - nazwy wszystkich ŹRÓDEŁ, ze spacjami pomiędzy nimi

Posiadając tę wiedzę, możemy uprościć nasz Makefile w następujący sposób:

wyjscie.upper.txt: wyjscie.mixed.txt
	tr '[A-Z]' '[a-z]' < $< > $@

wyjscie.mixed.txt: wejscie1.txt wejscie2.txt
	cat $^ > $@

Z pewnością nie są to najbardziej przyjazne nazwy zmiennych, ale niezaprzeczalnie są przydatne. Dzięki nim nie musimy się powtarzać, ale daje nam to jeszcze jedną ważną rzecz - reguły są w stanie działać nawet wtedy, kiedy nazwy CELów albo ŹRóDEŁ się zmieniają.

Dodajemy trochę generyczności do naszego Makefile

Tutaj wykorzystamy kolejną użyteczną funkcję make - dopasowywanie wzorców (pattern matching). Dzięki niej możemy napisać regułę, której make będzie w stanie automatycznie, gdy będzie potrzebował pliku, który (jeszcze) nie istnieje.

make dopasowując wzorzec wykorzystuje znak % otoczony stałym prefiksem i/lub sufiksem. Podczas analizy pliku Makefile próbuje znaleźć najdokładniejszy wzorzec, czyli taki, gdzie za % może być podstawiona najmniejsza liczba znaków.

Spróbujmy więc tego użyć:

%.upper.txt: %.mixed.txt
	tr '[A-Z]' '[a-z]' < $< > $@

wyjscie.mixed.txt: wejscie1.txt wejscie2.txt
	cat $^ > $@

Jednak po uruchomieniu make widzimy, że stworzony został plik wyjscie.mixed.txt, ale nie wyjscie.upper.txt. Nic dziwnego, w końcu make nie wie na podstawie takiego Makefile jaki plik jest nam potrzebny, a pierwsza reguła, która to określa buduje właśnie wyjscie.mixed.txt.

Dajmy więc znać w naszym Makefile, że to właśnie tego pliku potrzebujemy.

Cele bez plików wynikowych

Aby móc skorzystać z generycznej reguły napisanej przed chwilą, musimy w jakiejś innej regule wymagać pliku, który ona mogłaby stworzyć. W tym celu możemy stworzyć regułę z celem, który nie jest plikiem:

.PHONY: all

all: wyjscie.upper.txt

%.upper.txt: %.mixed.txt
	tr '[A-Z]' '[a-z]' < $< > $@

wyjscie.mixed.txt: wejscie1.txt wejscie2.txt
	cat $^ > $@

W tym wypadku all nie jest nazwą pliku. Aby jednak make dobrze poradził sobie w takiej sytuacji, należy dodać taki cel do zmiennej .PHONY - dzięki temu make wie, że nie należy spodziewać pliku wynikowego, ale jeśli taki plik kiedykolwiek się pojawi - to go zignoruje.

Dzięki temu, jeśli będziemy chcieli stworzyć więcej niż jeden cel w taki sam sposób, możemy dodawać do zależności all kolejne pliki pasujące do wzorca %.upper.txt, a make będzie w stanie automatycznie domyśleć się, w jaki sposób taki plik zbudować, pod warunkiem, że wszystkie zależności są spełnione.

Drabinka zależności

make bez problemu potrafi samodzielnie przeanalizować dostępne reguły i wybrać te, które są potrzebne do wygenerowania potrzebnego pliku. Dodajmy nową regułę do naszego Makefile:

%.hello.txt: %.upper.txt
	echo "Hello, " > $@
	cat $< > $@

Jeśli teraz zamienimy cel all tak, aby wymagał %.hello.txt zamiast %.upper.txt, to make będzie znał wszystkie kroki, od wejscie1.txt i wejscie2.txt (które istnieją), aż do wyjscie.hello.txt, którego potrzebujemy.

Sprzątanie po sobie

Przydałaby się jeszcze reguła, która sprząta wszystkie automatycznie wygenerowane pliki. Niestety, make nie jest w stanie wygenerować takiej reguły dla nas - musimy napisać ją samodzielnie.

.PHONY: all clean

all: wyjscie.hello.txt

%.hello.txt: %.upper.txt
	echo "Hello, " > $@
	cat $< > $@

%.upper.txt: %.mixed.txt
	tr '[A-Z]' '[a-z]' < $< > $@

wyjscie.mixed.txt: wejscie1.txt wejscie2.txt
	cat $^ > $@

clean:
	rm wyjscie.mixed.txt
	rm wyjscie.upper.txt
	rm wyjscie.hello.txt

Jak widać, clean jest regułą, która nie tworzy żadnych plików, jak również żadnych nie wymaga. Aby ją uruchomić, należy podać make w linii poleceń nazwę celu, który chcemy zbudować - inaczej taki cel nigdy nie zostanie wywołany:

$ make clean

Więcej informacji

Na początek to jest wystarczające, żeby napisać proste pliki Makefile, które dość często przydają się w codziennym życiu. Jeśli potrzebujesz więcej informacji, polecam:

  • Manual GNU Make zawiera bardzo dużo informacji, a jednocześnie jest zaskakująco przystępny,
  • Your Makefiles are Wrong Jacoba Davis-Hanssona zawiera dość dużo ciekawych hacków.
    • Samodzielnie nie ze wszystkich korzystam - zmiana tabulacji na coś innego psuje kolorowanie składni, ale chociażby to, co zostało tam nazwane jako sentinel files - tego używam regularnie.