Britko modulárnym programovaním v Céčku

Štyri vety symfónie gcc

Všetci vieme, že jednoduché programy v C možno skompilovať jedným riadkom. Napríklad jednoriadková kompilácia jednoriadkového “Ahoj svet”:

gcc -o hello hello.c 

Výsledkom je spustiteľná binárka. V skutočnosti však gcc robí v tomto príkaze prácu za štyroch:

  • preprocesor odstraňuje komentáre, vkladá súbory uvedené v #include, vyhodnocuje makrá (#define) a ďalšie direktívy začínajúce mrežou.
  • kompilátor pochrústa céčkový zdroják a vypľuje kód v assembleri, teda v jazyku symbolických inštrukcií.
  • assembler vezme kód v assembleri a vypľuje objektový súbor. To nemá nič spoločné s objektovo orientovaným programovaním; tento súbor je binárka, teda obsahuje strojový kód, ale v tejto fáze ešte nie je spustiteľný. Obsahuje totiž množstvo odkazov (referencií) na premenné a funkcie z knižníc, ktoré treba vyhodnotiť. Príkladom referencie je už len jednoduché volanie funkcie puts() zo štandardnej knižnice stdio.
  • linker pospája jednotlivé objektové súbory, vzájomne vyhodnotí referencie a následne vyprodukuje spustiteľnú binárku.

Aby sme sa nestratili v detailoch, budeme pod kompiláciou rozumieť prvé tri fázy. Skompilovať céčkový zdroják teda bude znamenať to isté, ako spustiť fázy preprocessingu, kompilácie a assemblingu, inak povedané, kompiláciou získame z C zdrojáku objektový súbor.

Len bokom: gcc má štyri parametre, ktorými možno zaraziť celú mašinériu v konkrétnej fáze: -E vykoná len preprocessing, -S vezme zdroják, preprocessne ho a vypľuje assemblerovský kód a nakoniec -c spustí všetko okrem linkovania.

V prípade jednozdrojákového programu zbehnú všetky štyri vety symfónie gcc naraz bez toho, aby sme čokoľvek museli nastaviť.

To však neplatí pre modulárne programy.

Opus magnum v céčku o dvoch súboroch

Áno, všetci chceme písať modulárne programy. Nakydať všetko do jedného súboru sa síce môže zdať ako spôsob udržovania prehľadnosti („Je to v jednom súbore! Je to prehľadné!“), ale to je možno záležitosť zápočťákov a krátkych jednoúčelových programov v duchu skriptov.

Céčko veľmi ľahko umožňuje vytvárať modulárne programy. Ukážme si to na veľdiele — programe pozostávajúcom z knižnice riešiacej logovanie (najhlúpejším možným spôsobom) a hlavného programu, ktorý zaloguje Ahoj svet.

Logovací modul lomeno knižnica

V logovacej knižnici najprv definujme „interfejs“, teda sadu funkcií, ktoré budú sprístupnené našou knižnicou.

V našom prípade je sada príliš vzletným pomenovaním : zverejníme totiž len jednu funkciu log_info pre zalogovanie jednoduchého reťazca (hlavne nepomenovávajte funkciu ako log, zrazíte sa s funkciou pre logaritmus). Uveďme ich do hlavičkového súboru (header file) logging.h:

void log_info(char * message);  

Hlavičkový súbor obsahuje len hlavičky funkcií: teda žiaden kód.

Opäť bokom: niekedy sa stáva, že knižnica obsahuje interné funkcie, ktoré nechceme zverejniť pre použitie z iných modulov. Na toto slúžia dve kľúčové slová: extern označí funkciu ako „verejnú“, teda sprístupní ju v iných moduloch; static zase vyhlási funkciu za privátnu. Ak označenie metódy vynecháme, použije sa extern.

Ak knižnica používa v zverejnených funkciách vlastné dátové typy, musíme ich tiež uviesť do hlavičkového súboru. Klasickým príkladom by bola knižnica pre komplexné čísla, ktorá do hlavičkového súboru uvedie definíciu dátového typu pre.. nuž… komplexné číslo, čo bude zrejme nejaký typedefnutý struct.

A po tretie: ak funkcie v hlavičkovom súbore vyžadujú dátové typy z iných hlavičkových súborov, musíme ich vzájomne prepojiť cez #include — to však nie je prípad našej logovacej knižnice.

Prejdime teraz k implementácii knižnice, teda k tomu, akým konkrétnym spôsobom budeme logovať. Kód uvedieme do logging.c a keďže nemôžeme byť príliš komplikovaní, uvedieme v ňom jedinú funkciu log_info, ktorá je prakticky rovnaká ako printf (hlavne nepomenovávajte funkciu ako log, zrazíte sa s funkciou pre logaritmus!).

#include "logging.h"
#include <stdio.h>

void log_info(char * message) {
    printf("[INFO] %s", message);
}

Všimnime si, že knižnica includuje hlavičkový súbor k samej sebe: v tomto prípade to síce nie je nutné, ale je to dobrou zvyklosťou. Navyše, ak uvedieme na začiatku zdrojáku hlavičky funkcií (tzv. funkčné prototypy) môžeme potom uvádzať v zdrojáku definície funkcií v ľubovoľnom poradí namiesto striktnej zásady, že funkcia vie volať len tie funkcie, ktoré sú v zdrojáku pred ňou.

Všimnime si ešte, že v súbore používame dva druhy #include-ov. Úvodzovkový #include hovorí preprocesoru, že hlavičkový súbor má hľadať najprv v aktuálnom adresári a potom v ďalších adresároch dohodnutých konvenciou, a na druhej strane „zobákový“ zápis hovorí, že hlavičkový súbor sa má vyhľadávať medzi systémovými hlavičkovými súbormi — napr. v Linuxe sú tieto súbory obvykle v adresári /usr/include. Zvyklosťou v projektoch je uvádzať do úvodzovkovej formy hlavičkové súbory z nášho projektu, a zobákovú formu používať na cudzie knižnice.

Ale teraz ku kompilácii — tá bude prebiehať trochu iným spôsobom. Prvý pokus padne:

gcc logging.c

Kompilátor vypľuje hlášky o tom, že nevie nájsť funkciu main (resp. @WinMain16, ak kódite pod MingW na Windowse).

Ak kompilujeme súbor s knižnicou, musíme preskočiť fázu linkovania do spustiteľnej binárky, pretože naša knižnica nemusí, ale ani nemôže byť spustiteľná sama o sebe.

gcc -c logging.c

Výsledkom kompilácie je objektový súbor logging.o.

Prejdime teraz k hlavnému programu.

Hlavný program

Hlavný program bude rovnako mohutný ako knižnica: zaloguje hlášku Ahoj svet. Vytvorme main.c:

#include "logging.h"

int main() {
    log_info("Hello World!");
    return 0;
}

Samozrejme, kompilácia gcc main.c zlyhá:

gcc main.c
Undefined reference to `log_info`.

Linker totiž nevie vyhodnotiť referenciu na logovaciu funkciu a to preto, že nevie nájsť príslušnú binárku (objektový súbor) s knižnicou. Správna verzia vyžaduje uviesť pri kompilácii zoznam všetkých objektových súborov, ktoré program požaduje

gcc -o main main.c logging.o

Výsledkom je už korektná spustiteľná binárka.

I v tomto prípade vykonal gcc robotu vo všetkých štyroch fázach: nielenže predspracoval, skompiloval a zassembloval main.c, ale ešte ho aj zlinkoval s objektovým súborom logging.o.

Rekapitulácia

Korektná a úplná postupnosť príkazov by mala byť:

gcc -c logging.c
gcc -c main.c
gcc -o main logging.o main.o

V prvom riadku kompilátor vyprodukuje objektový súbor knižnice. V druhom riadku sa vloží do main.c obsah hlavičkového súboru logging.h, ktorý sa nájde v aktuálnom adresári, a skompiluje objektový súbor main.o. Na treťom riadku slávnostne vezmeme dva objektové súbory a zlinkujeme ich do spustiteľného súboru.

Všetky tri príkazy však možno zraziť do jedného riadku:

gcc -o main logging.c main.c

gcc následne automaticky vydedukuje, že treba najprv vykonať preprocessing, kompiláciu a assemblovanie logging.c, následne preprocessnúť, skompilovať a zassemblovať main.c a oba objektové súbory vypadnuté z behu oboch trojfáz zlinkovať dohromady do spustiteľného súboru.

To je všetko v poriadku: viete si však predstaviť, že máte program pozostávajúci z desiatich knižníc? S každou zmenou ktoréhokoľvek súboru potrebujete všetko prekompilovať nanovo. To môže trvať dosť dlho…

… ale riešenie je už mimo kompilátora. Na to možno použiť make, ale to je na samostatný článok.