ModProg 15-16, Vorl. 12 Richard Grzibovski Jan. 20, 2016 1 / 41 Übersicht Übersicht 1 Rekursive Funktionen 2 3 Suchalgorithmen Sortieralgorithmen Insertionsort Quicksort 4 Zeiger auf Funktionen 5 Bibliotheksfunktionen zum Sortieren und Suchen 6 Nichttriviale Deklarationen 7 Mehrere Quelldateien 2 / 41 Rekursive Funktionen Als Rekursion (lat. recurrere ”zurücklaufen”) bezeichnet man die Technik in Mathematik, Logik und Informatik, eine Funktion durch sich selbst zu definieren. In C sind rekursive Funktionen erlaubt, d.h. eine Funktion darf sich selbst aufrufen. 3 / 41 Beispiel: Fakultät Definition: sei n ∈ N, dann n! = n · (n − 1) · . . . · 2 · 1, 0! = 1. 1 2 3 4 5 6 7 8 9 10 unsigned long fak ( unsigned long n ) { unsigned long i , f=1; for ( i=2;i<=n ; i++) f∗=i ; return f ; } unsigned long fakr ( unsigned long n ) { if ( n<=1) return 1 ; return n∗ fakr ( n −1); } 4 / 41 Beispiel: Binomialkoeffizienten Definition: sei n, k ∈ N, n ≥ k ≥ 0, dann n! n = k k!(n − k)! Naive Implementierung: 1 2 3 unsigned long bink ( unsigned long n , unsigned long k ) { return fak ( n ) / fak ( k ) / fak ( n−k ) ; } Bessere Methode wäre, die Rekursion zu verwenden: n n−1 n−1 = + k k −1 k 5 / 41 Beispiel: Binomialkoeffizienten 1 2 3 4 5 6 7 8 9 n k = n−1 k −1 + n−1 k int binomial ( int n , int k ) { if ( n < k ) { return 0 ; } if(0== n | | 0== k | | n == k ) { return 1 ; } else return binomial ( n−1, k−1) + binomial ( n−1, k ) ; } 6 / 41 Rekursion: allgemeine Bemerkungen Bei Implementierung ist darauf zu achten, dass die Rekursion stets auf einen elementar zu behandelnden Fall führt bzw. auf einen von mehreren solcher Fälle, alle elementar lösbaren Fälle, die eintreten können, auch korrekt behandelt werden. Rekursive Algorithmen können mit Hilfe rekursiver Funktionen implementiert werden. Sehr oft sind alternative iterative Realisierungen möglich, d.h. solche, die Schleifen verwenden. 7 / 41 Suchalgorithmen Eine häufig zu lösende Aufgabe besteht darin herauszufinden, ob ein bestimmter Wert in einem Feld (oder einer Liste) vorkommt und wenn ja, wo. Die einfachste Vorgehensweise besteht natürlich darin, das Feld von vorne beginnend abzusuchen, wie im folgenden Beispiel für ein Feld mit int-Komponenten gezeigt: 1 2 3 4 5 6 int einfachsuche ( int ∗feld , int len , int wert ) { int i ; for ( i =0; i < len ; i ++) if ( feld [ i ] == wert ) break ; return i ; } Die Vorgehensweise hat die folgenden Eigenschaften: Positiv: Die Methode funktioniert für unsortierte Felder und Listen. Negativ: Die Laufzeit wächst linear mit der Feldlänge an. 8 / 41 Suchalgorithmen Ist das Feld aufsteigend sortiert, so kann man die Suche mit Hilfe der folgenden Strategie wesentlich schneller durchführen: Beispiel: Binäre Suche (engl. binary search) in einem aufsteigend sortierten Feld . 1 Vergleiche den gesuchten Wert mit dem in der Mitte des Feldes. 2 Bei Gleichheit: n/2 ist die gesuchte Stelle. Abbruch. Andernfalls: 3 Ist der gesuchte Wert größer, wiederhole das Verfahren für das Teilfeld rechts von der Feldmitte. Ist der gesuchte Wert kleiner, wiederhole das Verfahren für das Teilfeld links von der Feldmitte. bis die Feldlänge 0 ist. In diesem Fall befindet sich der Wert nicht im Feld. 9 / 41 Suchalgorithmen Durch vollständige Induktion kann man zeigen: Op(n) = c(1 + log2 (n)). Damit gilt Op(n) = O(log2 n), d.h die binäre Suche hat logarithmische Komplexität. Ein Beispiel aus dem Alltag für die Anwendung dieses Verfahrens ist das Suchen in Telefonbüchern. 10 / 41 Sortieralgorithmen: Problemstellung Gegeben: Ein Feld F der Länge n von Zahlen. Zu finden: Eine Permutation F 0 von F , wobei die Einträge in F 0 sortiert sind. Beispiel: F= 4 3 8 1 5 F’= 1 3 4 5 8 11 / 41 Sortieralgorithmen: Sonderfälle Bemerkung: Jedes Feld der Länge 1 ist bereits sortiert. Definition Ein Feld der Länge n heißt ein Z-Feld, wenn die ersten n − 1 Einträge sortiert sind. Bemerkung: Jedes Feld der Länge 2 ist ein Z-Feld. Algorithmus: Sortierung eines Z-Feldes der Länge n ≥ 2 1 Setze i := n. 2 Wenn i == 1, gehe nach Schritt 4. Wenn F (i) < F (i − 1), 3 (a) vertausche F (i) und F (i − 1) (b) setze i := i − 1. (c) Wiederhole Schritt 2. 4 Ende. 12 / 41 Sortieralgorithmen: Sonderfälle Algorithmus: Sortierung eines Z-Feldes der Länge n ≥ 2 1 Setze i := n. 2 Wenn i == 1, gehe nach Schritt 4. Wenn F (i) < F (i − 1), 3 (a) vertausche F (i) und F (i − 1) (b) setze i := i − 1. (c) Wiederhole Schritt 2. 4 Ende. Beispiel: 1 3 5 8 4 1 3 4 5 8 13 / 41 Sortieralgorithmen: Sonderfälle Algorithmus: Sortierung eines Z-Feldes der Länge n ≥ 2 1 Setze i := n. 2 Wenn i == 1, gehe nach Schritt 4. Wenn F (i) < F (i − 1), 3 (a) vertausche F (i) und F (i − 1) (b) setze i := i − 1. (c) Wiederhole Schritt 2. 4 Ende. Laufzeitkomplexität: Im schlimmsten Fall n − 1 Vergleiche und n − 1 Vertauschoperationen ⇒ Op(n) = 2(n − 1) = O(n), im besten Fall 1 Vergleich ⇒ Op(n) = 1. 14 / 41 Insertionsort Algorithmus: Insertionsort Sortierung eines Feldes der Länge n ≥ 2. 1 Für i := 2 bis n Sortiere das Z-Feld {F (1) . . . F (i)} P Laufzeitkomplexität: Op(n) = ni=2 (Z-Sort(i)) Im schlimmsten Fall Op(n) = n(n − 1) = O(n2 ), im besten Fall Op(n) = (n − 1) = O(n). 15 / 41 Insertionsort: Beisplel i F 4 3 8 1 5 2 4 3 8 1 5 3 3 4 8 1 5 4 3 4 8 1 5 5 1 3 4 8 5 1 3 4 5 8 16 / 41 Quicksort Algorithmus: Quicksort Sortierung eines Feldes F = {fi }ni=1 . 1 Felder mit n < 2 sind bereits sortiert. 2 Wähle ein Vergleichselement (auch Pivotelement genannt) fk aus dem Feld aus. 3 Teile das Feld auf in Elemente {≤ fk } und {> fk } F := [{fi ∈ F̃ : fi ≤ fk }, fk , {fi ∈ F̃ : fi > fk }], wobei F̃ = F \ {fk }. 4 Wende den Algorithmus rekursiv auf die Teilfelder links und rechts des Pivotelements an. Bezeichne: [{fi ∈ F̃ : fi ≤ fk }, fk , {fi ∈ F̃ : fi > fk }] = teilung(F , k). 17 / 41 Quicksort: Teilung [{fi ∈ F̃ : fi ≤ fk }, fk , {fi ∈ F̃ : fi > fk }] = teilung(F , k). 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 int teilung ( ft ∗ feld , int beg , int end , int pIdx ) { int tIdx , i ; ft pVal ; pVal= feld [ pIdx ] ; vertausche ( feld [ pIdx ] , feld [ end ] ) ; // piv --> end tIdx = beg ; for ( i=beg ; i<end ; i++) { if ( feld [ i ] <= pVal ) { vertausche ( feld [ i ] , feld [ tIdx ] ) ; tIdx++; } } vertausche ( feld [ tIdx ] , feld [ end ] ) ; // piv --> tIdx return tIdx ; } 18 / 41 Quicksort: Teilung: Beisplel 3 7 8 5 2 1 9 5 4 piv−>end 3 7 8 5 2 1 9 5 4 for−Schleife beg=0 pIdx=4 end=8 3 7 8 4 2 1 9 5 5 3 4 2 7 8 1 9 5 5 piv−>tIdx 3 4 2 1 5 7 9 8 5 Ergebnis 3 4 2 1 5 5 9 8 7 19 / 41 Quicksort: Implementierung 1 2 3 4 5 6 7 8 9 void quicksort ( ft ∗ feld , int beg , int end ) { if ( end > beg ) { int pIdx ; pIdx = beg + ( end − beg ) / 2 ; pIdx = teilung ( feld , beg , end , pIdx ) ; quicksort ( feld , beg , pIdx − 1 ) ; quicksort ( feld , pIdx + 1 , end ); } } Laufzeitkomplexität: Im schlimmsten Fall Op(n) = O(n2 ), im besten Fall, auch im durchschnittlichen Fall, Op(n) = O(n log n). 20 / 41 Quicksort: Bemerkungen Existieren iterative Implementierungen von Quicksort Es gibt Algorithmen, beispielsweise Heapsort, deren Laufzeit auch im schlimmsten Fall durch O(n log n) beschränkt sind. In der Praxis wird aber trotzdem Quicksort eingesetzt, da angenommen wird, dass bei Quicksort der schlimmste Fall nur sehr selten auftritt und im mittleren Fall schneller als Heapsort ist. Dies ist jedoch noch Forschungsgegenstand. Quicksort wurde ca. 1960 von C. Antony R. Hoare in seiner Grundform entwickelt und seitdem von vielen Forschern verbessert. 21 / 41 Zeiger auf Funktionen: Motivation Beispiel 1: Sei f : [a, b] 7→ R eine stetige Funktion mit f (a)f (b) < 0. Dann gibt es eine Nullstelle x0 (d.h. f (x0 ) = 0). Wir haben ein Algorithmus implementiert, der die Nullstelle mit gegebener Genauigkeit ε findet (Übungsblatt 3, Übung 3). double finde_nullstelle(double a, double b, double eps); Die Funktion f ist als double func(double x); implementiert. Es wäre schön, den Bisektionalgorithmus unabhängig von func zu implementieren: double finde_nullstelle(“funktion f ” , double a, double b, double eps); Somit könte man einmal implementieren und lebenslang für alle Funktionen benutzen. 22 / 41 Zeiger auf Funktionen: Motivation Beispiel 2: Sei f : [a, b] 7→ R eine beschränkte Funktion. Man kann ein Algorithmus implementieren, der das Integral Rb I (f , a, b) = a f (x)dx approximiert: N b−aX f I (f , a, b) ≈ N i=1 b−a a+i N . double integriere(double a, double b, int N); Die Funktion f ist als double func(double x); implementiert. Es wäre schön, den Integrationsalgorithmus unabhängig von func zu implementieren: double integriere(“funktion f ” , double a, double b, int N); Somit könte man einmal implementieren und lebenslang für alle Funktionen benutzen. 23 / 41 Zeiger auf Funktionen: Motivation Beispiel 3: Die Sortieralgorithmen, die wir betrachten haben, brauchen nur zwei Operationen: 1 Vertauschoperation: vertauscht Feldelemente fi und fj . 2 Vergleichoperation: ist das Feldelement fi größe als das, kleiner als das oder gleich dem Feldelement fj . Wenn man die zwei Operationen für ein Feld hat, kann man das Feld sortieren. Da die Feldeinträge in Speicher liegen, ist die Vertauschoperation äquvivalent zum Kopieren. Man braucht nur die Größen von fi (oder von fj ) zu wissen. Eine Funktion, die die Vergleichoperation durchgeführt, muss man dem Sortieralgorithmus als Argument übergeben. 24 / 41 Zeiger auf Funktionen: Deklaration Zunächst zur Deklaration eines Funktionszeigers: Ruckgabetyp (∗ Zeigername)(Liste der Funktionsparametertypen); Betrachten wir folgende einfache Beispiele: double (∗ fptr)(double ); fptr ist ein Zeiger auf eine Funktion, die ein Argument vom Typ double entgegennimmt und einen double-Wert zurückliefert. char∗ (∗ fptr2)(int, int); fptr2 ist ein Zeiger auf eine Funktion, die zwei Argumente vom Typ int übernimmt und einen String (Zeiger auf char) zurückliefert. Die häufigste Anwendung von Funktionszeigern besteht darin, dass man mit ihrer Hilfe Funktionen als Argumente an andere Funktionen übergeben kann. 25 / 41 Zeiger auf Funktionen: Beispiel 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #include<s t d i o . h> #include<math . h> void zeige_fwert ( double x , double ( ∗ funk ) ( double ) ) ; double SQR ( double x ) ; void zeige_fwert ( double x , double ( ∗ funk ) ( double ) ) { printf ( "Wert fur x= %e : %.15e\n" , x , ( ∗ funk ) ( x ) ) ; } double SQR ( double x ) { return x∗x ; } int main ( ) { zeige_fwert ( 2 . 0 , & SQR ) ; zeige_fwert ( 2 . 0 , & sin ) ; return 0 ; } 26 / 41 Zeiger auf Funktionen: Bemerkung Bemerkung Bei vielen C-Compilern (auch bei GCC) verhalten sich Funktionszeiger wie Feldbezeichner. Ein Funktionsname ist gleichzeitig auch Zeiger auf die Funktion. Der C-Standard stellt dies den Compilerautoren frei. Im Zweifel sollte man jedoch streng zwischen Funktionsnamen und -zeigern unterscheiden. 27 / 41 Zeiger auf Funktionen: Bemerkung Der Funktionsname allein – ohne ”()” – steht für die Adresse der Funktion im Code-Teil des Programms: Funktionsname == Zeiger auf Funktionsanfang Im Falle der Deklarationen (Prototyp-Angaben) double sin(double x); int f(double x, char c); können die Aufrufe x = sin(3.5); k = f(2.9, ’a’); auch durch x = (∗sin)(3.5); k = (∗f)(2.9, ’a’); ersetzt werden (und umgekehrt). 28 / 41 Zeiger auf Funktionen: Beispiel 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #include<s t d i o . h> #include<math . h> void zeige_fwert ( double x , double ( ∗ funk ) ( double ) ) ; double SQR ( double x ) ; void zeige_fwert ( double x , double ( ∗ funk ) ( double ) ) { printf ( "Wert fur x= %e : %.15e\n" , x , funk ( x ) ) ; } double SQR ( double x ) { return x∗x ; } int main ( ) { zeige_fwert ( 2 . 0 , SQR ) ; zeige_fwert ( 2 . 0 , sin ) ; return 0 ; } 29 / 41 Bibliotheksfunktionen zum Sortieren und Suchen void qsort(void ∗anfang, size_t laenge, size_t groesse, int (∗verglfunkzgr)(const void ∗, const void ∗)); Die Funktion ist in <stdlib.h> deklariert und ist für beliebige Datentypen einsetzbar. qsort sortiert ein Feld ab der Stelle, auf die anfang zeigt, in aufsteigender Reihenfolge und führt die Sortierung für laenge Einträge durch, deren Datengröße jeweils groesse Bytes beträgt. Der Größenvergleich wird mit Hilfe der Funktion durchgeführt, auf die verglfunkzgr zeigt. Der Rückgabewert der Vergleichsfunktion muss sich wie folgt verhalten: er ist negativ, wenn das erste Argument kleiner als das zweite ist, 0, wenn beide Argumente gleich sind, positiv, wenn das erste Argument größer als das zweite ist. 30 / 41 Bibliotheksfunktionen zum Sortieren und Suchen void ∗bsearch(const void ∗wert, const void ∗anfang, size_t laenge, size_t groesse, int (∗verglfunkzgr)(const void ∗, const void ∗)); Die Funktion ist in <stdlib.h> deklariert und ist für beliebige Datentypen einsetzbar. bsearch sucht in einem aufsteigend sortierten Feld nach dem Wert, auf den wert zeigt. Die Suche beginnt ab der Stelle im Feld, auf die anfang zeigt und umfasst laenge Einträge , deren Datengröße jeweils groesse Bytes beträgt. Auch hier wird zum Vergleich die Funktion verwendet, auf die verglfunkzgr zeigt. Für deren Rückgabewert gilt natürlich das bereits zur Funktion qsort Gesagte. bsearch gibt einen Zeiger auf einen passenden Feldelement oder NULL, wenn keine Übereinstimmung gefunden wird. 31 / 41 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #include<s t d i o . h> #include<math . h> #include< s t d l i b . h> int intvergl ( const void ∗ a , const void ∗ b ) { int A , B ; A =∗(( int ∗ ) a ) ; B =∗(( int ∗ ) b ) ; return A − B ; } int main ( void ) { int i , laenge =6; int feld [ ] = {3 , 1 , 7 , 4 , 8 , 2 } ; printf ( "Vor Sortieren :\n" ) ; for ( i=0;i<laenge ; i++) printf ( " %i" , feld [ i ] ) ; printf ( "\n" ) ; qsort ( feld , laenge , sizeof ( int ) , intvergl ) ; printf ( "Nach Sortieren :\n" ) ; for ( i=0;i<laenge ; i++) printf ( " %i" , feld [ i ] ) ; printf ( "\n" ) ; return 0 ; } 32 / 41 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ... ... int main ( void ) { int i , laenge =6, ∗wert , ∗ pos ; int feld [ ] = {3 , 1 , 7 , 4 , 8 , 2 } ; printf ( "Vor Sortieren :\n" ) ; for ( i=0;i<laenge ; i++) printf ( " %i" , feld [ i ] ) ; printf ( "\n" ) ; qsort ( feld , laenge , sizeof ( int ) , intvergl ) ; printf ( "Nach Sortieren :\n" ) ; for ( i=0;i<laenge ; i++) printf ( " %i" , feld [ i ] ) ; printf ( "\n" ) ; wert=&i ; printf ( " Geben Sie eine ganze Zahl ein:" ) ; scanf ( "%i" , wert ) ; pos=bsearch ( wert , feld , laenge , sizeof ( int ) , intvergl ) ; if ( pos==NULL ) printf ( "%i nicht gefunden .\n " , ∗ wert ) ; else printf ( "%i an %i. Stelle im Feld .\n " , ∗ wert , pos−feld +1); return 0 ; 33 / 41 } Nichttriviale Deklarationen Wir entwickeln eine nichttriviale Deklaration Schritt für Schritt: 1 char fzgr; fzgr ist ein Zeichen. 2 char ∗fzgr; fzgr ist ein Zeiger auf ein Zeichen, also ein String. 3 char ∗∗fzgr; fzgr ist ein Doppelzeiger auf ein Zeichen, also ein Zeiger auf einen String. 4 char ∗∗fzgr(char ∗, const char ∗); fzgr ist eine Funktion, die zwei Stringargumente entgegennimmt (wobei das zweite Argument geschützt ist) und einen Zeiger auf einen String zurückliefert. 5 char ∗(∗fzgr)(char ∗, const char ∗); fzgr ist ein Zeiger auf eine Funktion, die zwei Stringargumente entgegennimmt (wobei das zweite Argument geschützt ist) und einen String zurückliefert. 34 / 41 Nichttriviale Deklarationen Wir entwickeln eine nichttriviale Deklaration Schritt für Schritt: 4 char ∗∗fzgr(char ∗, const char ∗); fzgr ist eine Funktion, die zwei Stringargumente entgegennimmt (wobei das zweite Argument geschützt ist) und einen Zeiger auf einen String zurückliefert. 5 char ∗(∗fzgr)(char ∗, const char ∗); fzgr ist ein Zeiger auf eine Funktion, die zwei Stringargumente entgegennimmt (wobei das zweite Argument geschützt ist) und einen String zurückliefert. 6 char ∗ (∗(∗fzgr)(void))(char ∗, const char ∗); fzgr ist ein Zeiger auf eine Funktion, die kein Argument besitzt und einen Zeiger auf eine Funktion zurückliefert, die wiederum zwei Stringargumente entgegennimmt (wobei das zweite Argument geschützt ist) und einen String zurückliefert. 35 / 41 Deklarationen lesen mit der ”Schneckenmethode” Um kompliziertere Deklarationen bzw. Typvereinbarungen zu verstehen, ist die so genannte Schnecken- oder Spiralmethode sehr hilfreich: Beginnend mit dem Bezeichner des Datenobjekts bzw. -typs arbeitet man sich spiralförmig gegen den Uhrzeigersinn von innen nach außen. Entscheidend für das Gelingen ist, dass man zu Beginn darauf achtet, ob unmittelbar rechts vom Bezeichner ein Klammernpaar steht, denn diese haben ja Vorrang vor *. Ist dies der Fall, so beginnt die Schnecke nach unten rechts. Ist der Bezeichner mit einem * in Klammern zusammengefasst, so handelt es sich um einen Zeiger und die Schnecke beginnt nach oben links. 36 / 41 Deklarationen lesen mit der ”Schneckenmethode” 37 / 41 Mehrere Quelldateien C sieht vor, dass der Quelltext eines Programms aus mehreren Dateien bestehen darf. Es sprechen u. a. folgende Gründe für die Aufteilung des Quelltextes auf mehrere Dateien: Übersichtlichkeit: Jeder Quelltextbaustein (z.B. die Definition einer Funktion) wird in einer eigenen Datei gehalten. Diese wird in den allermeisten Fällen recht kurz sein. Mehrköpfige Entwicklungsteams möglich: Jede/r Entwickler/in übernimmt die Implementierung eines oder mehrerer Bausteine des Programms. Wiederverwendbarkeit: Einzelne Quelltextbausteine können – sofern sie hinreichend allgemein programmiert sind – in anderen Projekten ohne Veränderung eingesetzt werden. Leichte Wartung: An einzelnen Quelltextdateien können Verbesserungen und Korrekturen vorgenommen werden, ohne dass andere Dateien hiervon betroffen sind. 38 / 41 Mehrere Quelldateien: Allgemeine Aspekte Eine wichtige Regel für die Verteilung des Quellcodes vorweg: Nur eine Quelltextdatei darf main enthalten. Damit ein lauffähiges Programm entsteht, muss eine Quelltextdatei main enthalten. Eine Funktion kann sich nicht über mehrere Dateien erstrecken. Ansonsten ist man hinsichtlich der Verteilung des Quelltextes weitgehend frei. Es empfiehlt sich jedoch, die folgenden Richtlinien zu befolgen: Deklarationen von Funktionen und globalen Variablen fasst man in eigenen Headerdateien (Dateiendung .h) zusammen. Definitionen von Funktionen erfolgen in eigenen Quelltextdateien mit Endung .c. Dabei können mehrere kurze Funktionsdefinitionen in einer Datei erfolgen. 39 / 41 Mehrere Quelldateien: Allgemeine Aspekte Die Übersetzung erfolgt in zwei Stufen: 1 Die einzelnen C-Quelltextdateien werden in einem ersten Schritt jeweils mit gcc −c dateiname.c in Objektdateien übersetzt (somit wird dateiname.o erzeugt). 2 Anschließend folgt das Linken zu einem ausführbaren Programm, wobei ggf. erforderliche Bibliotheken berücksichtigt werden, die jeweils mit einer eigenen -l-Option angegeben werden: gcc −o Programmname Liste der Objektdateien [ −lBibliothek] 40 / 41 Mehrere Quelldateien: Beispiel f_null.c #include<stdio.h> #include"f_null.h" double finde_nullstelle( double (*func)(double), double a, double b, double eps) { ... return nstelle; } f_null.h double finde_nullstelle( double (*func)(double), double a, double b, double eps); prog.c #include<stdio.h> #include"f_null.h" double fn(double y) { return y*y−0.5; } int main(void) { ... x=finde_nullstelle(fn, 0,1,1e−6); ... return 0; } gcc −c f_null.c ergibt f_null.o gcc −c prog.c ergibt prog.o gcc −o prog prog.o f_null.o −lm ergibt prog 41 / 41