Resource icon

Die Programmiersprache C - Grundlagen

Die Wahl einer Programmiersprache kann gerade für Anfänger recht schwierig sein: Man will eine Sprache lernen, die noch nicht veraltet ist, sodass man gewisse Konzepte in andere Sprachen übertragen kann und einigermassen aktuelle Werkzeuge dafür existieren. Oft bekommt man auf die Frage, mit welcher Programmiersprache man anfangen sollte, die Antwort: "Nimm doch etwas modernes wie Haskell oder OCaml, etwas mit Arbeitsaussichten wie PHP, JS und HTML, da das Internet die Zukunft ist, oder, wenn du ganz altmodisch sein willst, Java oder C++".
Keine dieser Sprachen wurde direkt für die Hardware entworfen, auf der sie laufen sollte (selbst wenn C++ schon sehr nahe herankommt): Es sind alles (teils sehr starke, teils sehr schwache) Abstraktionen.
Warum sollte man sich also heutzutage noch mit einer "alten" Sprache wie C beschäftigen?
Die Antwort ist recht einfach: Keine der oben genannten Sprachen könnte ohne C existieren.

In den 1970ern entworfen entsprang C der Idee, Code zu schreiben, der auf mehreren Maschinentypen laufen kann. Vorher musste man Assembly-Code von Hand schreiben, was nicht nur mühsam war, sondern dadurch auch zu vielen Fehlern führte: Alles waren Zahlen, es gab keine Typsicherheit.
Irgendwann wurde dann diese Art, Code zu schreiben, viel zu teuer, da die Programme riesig wurden (-> Softwarekrise), und C übernahm. Allerdings wäre es ein Irrglaube, anzunehmen, dass ein in C geschriebenes Programm schneller ist als handoptimierter Assemblycode, daher wird noch heute auch Assembly geschrieben, wenn auch sehr viel seltener.

Jedenfalls schliesse ich diese kurze Einführung mit einem Autovergleich:
Die Websprachen fahren sich wie ein Auto mit CVT-Getriebe: Angenehm für den Fahrer (=Programmierer) und für den Alltag ganz nett, für harte Arbeit aber ungeeignet.
Sprachen wie Java und C++ fahren sich wie ein herkömmliches Automatikgetriebe: Es gibt noch die althergebrachten Gänge, die aber automatisch geschaltet werden. Recht angenehm und auch für Sattelschlepper stark genug, aber für die grossen Muldenkipper in den Minen schlicht mit zu viel Verschleiss verbunden.
C und Assembly sind schliesslich die handgeschalteten Fahrzeuge: Nicht wirklich angenehm im alltäglichen Stau, aber sehr spassig, wenn man mal fahren kann und vor allem sind sie die Arbeitstiere; robust und effizient.

C ist eine standardisierte Sprache und in Verbindung mit POSIX müssen Betriebssysteme gewisse Funktionen in den sog. Headern Verfügung stellen. Vergessen wir das alles mal, denn C ist eine sehr kleine Sprache.

Es gibt die arithmetischen Grundoperationen, also Operationen auf Zahlen:
+: Addition
-: Subtraktion
*: Multiplikation
/: Division
%: Modulo (Also Restberechnung)
=: Kopie
+=: Kurzform von a + b = a (a += b)
-=: Kurzform von a - b = a (a -= b)
*=: Kurzform von a * b = a (a *= b)
/=: Kurzform von a / b = a (a /= b)
a++: Post-Increment (Merken als Post-Evaluation-Increment)
++a: Pre-Increment (Merken als Pre-Evaluation-Increment)
a--: Post-Decrement (Merken als Post-Evaluation-Decrement)
--a: Pre-Decrement (Merken als Pre-Evaluation-Decrement)

Es gibt die logischen Grundoperationen (also Operationen auf Bits):
|: Oder
&: Und
^: Exklusives Oder
~: Nicht (also Umkehren der Bits)

Und dann gibt es noch die bool'schen Operationen, also Operationen auf Wahr oder Falsch:
||: Oder
&&: Und

Zuletzt gibt es die Vergleichsoperatoren, welche Bool'sche Werte ergeben:
==: Gleichheit
!=: Ungleichheit
<: Kleiner als
>: Grösser als
<=: Kleiner als oder gleich
>=: Grösser als oder gleich

Das sind schon fast alle Operatoren. Aber was sind die Operanden?

Hier beginnt die Verwirrung der einzelnen Betriebssysteme und Prozessoren (x86 vs amd64), und es wurde spezielle Typen eingeführt, um das Problem zu beheben.
Die Grundlegenden Typen sind:
int: Die ganzen Zahlen ("int" ist die Kurzform von "integer" = "ganz")
unsigned int: Die Natürlichen Zahlen (mit 0)

Fertig. Das sind die einzigen Typen, die eigentlich existieren. Nun hat man aber ein Problem: Speicher ist nicht unendlich, aber die Natürlichen und Ganzen Zahlen sind es. Daher führte man sehr früh noch diese Typen ein:
char: i.d.R. -128 - 127, 8 bits, sollte einen "Character", also ein Zeichen speichern können.
short: i.d.R. -32768 - 32767, 16 bits
int: i.d.R. -2'147'483'648 - 2'147'483'647
long: Keine Regel, ab da wird es zu unterschiedlich.

(mit dem Schlüsselwort (keyword) "unsigned" kann man den Wertebereich vom Negativen ins Positive schieben, bspw. unsigned char: 0 - 256)

Warum i.d.R. (in der Regel)? Nun, der Standard schreibt die exakte Grösse nicht vor.
Die Lösung kam erst später mit der moderneren Notation:
(u)int[#bits]_t.
Also ist der frühere char nun int8_t, ein int normalerweise int32_t und ein unsigned int ist nun uint32_t.

Nun fehlt uns nur noch das, was andere Sprachen meist nicht oder nicht mehr haben: Pointer.
Es gibt viele Philosophien zu Pointern: Manche sagen, dass sie der Ursprung des Bösen seien und sie des nachts aus dem Computer krabbeln und den PC-Besitzer im Schlaf ermorden und danach das Haus anzünden, so z.B. die Sprache Haskell, welche sämtliche Elemente, die damit einhergehen, verabscheut (und sie dann trotzdem eingeführt hat, um doch noch ein bisschen nützlich zu sein).
Andere sagen, dass es einfach nur mühsam ist, sie richtig zu verwalten, und diese Arbeit könne einem der Compiler oder die Runtime direkt abnehmen (Rust, Java).
Und wieder andere sind sich sicher, dass man Pointer nur ein bisschen vor Misshandlungen schützen muss, damit sie sicher sind (C++).
All diese Meinungen sind nicht falsch, aber sie betrachten Pointer von der falschen Seite: Pointer sind nicht mühsam, sie sind extrem mächtig. Und wenn sich der Programmierer, berauscht von all dieser Macht, an Dinge heranwagt, derer er nicht gewachsen ist, dann wenden sich die Pointer gegen ihn.
Ich sage: Wer auf seine Pointer aufpasst, sie hegt und pflegt, dem werden sie nicht zur Last fallen. Jedoch gehört auch ein bisschen Übung dazu; aber keine Angst: Es ist keine Magie.

Aber was sind denn nun Pointer?
Pointer sind im Deutschen schlicht Zeiger. Sie lassen sich recht einfach mit Analoguhren visualisieren, wo ebenfalls Zeiger (nämlich Uhrzeiger) existieren. Dort hat man meist 2 Zeiger: Minuten und Stunden. Folgt man jedem der Zeiger nach aussen, so findet man dort eine Zahl vor, man kann also die Zeit ablesen. Aber die Zeit war nicht dort, wo man sie sich anschaute (nämlich auf dem Zeiger), sondern da, worauf der Zeiger zeigte.
Tönt trivial, abstrakt oder kompliziert?

Hier ein weiteres Beispiel:
Stellen wir uns vor, wir hätten ein Buch. Will man etwas bestimmtes darin nachschlagen, dann gibt es das Inhaltsverzeichnis, wo das Thema meist alphabetisch geordnet ist und daneben die Seitennummer steht, wo der eigentliche Text nachzulesen ist. Es ist also ein Verzeichnis von Zeigern.
Stellen wir uns vor, das gäbe es nicht. Wir haben noch immer alle Daten im Buch, nur das Verzeichnis fehlt. Der einzige Weg nun etwas zu finden wäre, das ganze Buch durchzulesen. Ist das effizient? Sicher nicht.

Der Speicher in Computern ist nun eigentlich ein Buch. Es wurde auch sehr darauf geachtet, alles "bücherig" zu benennen. Es gibt Wörter (words) und Seiten (Pages).
Dies würde ein anderes Tutorial füllen, daher verzichte ich hier auf weitere Erklärungen. Sagen wir einfach, Computerspeicher ist eine beliebig lange Verkettung von Bytes, um es einfacher zu halten.

Wenn ich nun einen Text habe und mir die Position eines gewissen Inhalts herausschreiben will (Referenzierung), wie geht das in C? Recht einfach:
Zeiger = &Inhalt

Und wenn ich von einem Zeiger auf die Position schliessen will, genannt Dereferenzierung?
Inhalt = *Zeiger

Der Zeiger muss aber ja auch irgendwie gespeichert werden. Welchen Datentyp könnte man da nehmen?
Ist int gut?
Nein, was sollte man denn mit den negativen Zahlen?!
Ok, dann unsigned int?
Aber was, wenn der Computer mehr Speicher als unsigned int Bytes hat?

Schlussendlich braucht man einen neuen Datentyp:
void*.
void gibt es schon, aber er macht wenig Sinn: Er ist 0 Bytes gross und kann damit gar keinen Wert annehmen (daher auch der Name "void", was auf deutsch etwa "Leere" bedeutet, wie in "Leere des Weltalls").
Was ist jetzt also void*?
Nun, das Asterisk (*) sagt, dass es sich um einen Pointer handelt. Ein Compiler kann dann immer die richtige Grösse für das aktuelle System verwenden.
Aber warum dann void* und nicht z.B. int*?
Sehr gute Frage.
Zeigen wir mit unserem Zeigefinger mal irgendwo hin (aber vielleicht nicht auf Menschen, das ist ein bisschen unanständig). Worauf zeigen wir?
Ich weiss es nicht, ich sehe den Leser nicht. Der Leser weiss es nicht, er sieht mich nicht. Ich könnte nun immer annehmen, dass der Leser auf einen Computer zeigt. Ist es aber in Wirklichkeit ein Tisch, dann müsste ich mir den Computer immer zuerst zu einem Tisch "umdenken", wenn er von dem spricht, worauf er zeigt.
void* sagt somit nur aus, dass man nicht weiss, was dahinter war, ist, oder sein wird.
void* selbst ist aber meist eine Zahl.

Nun kann es von Vorteil sein, die Grösse der Sache zu kennen, auf welche man verweist (z.B. um mit einem Freund eine Gedichtsstelle zu teilen, denn einfach als Zeiger könnte der Freund meinen, man wolle auf alles, was hinter dieser Position steht, verweisen, und nicht nur auf den einzelnen Vers).

Daher kann man die Grösse der Position dahinter angeben:
Z.B. bei Ganzzahlen: int*.
"Aber das ist doch falsch, vorher stand doch geschrieben, dass es void* ist!".
Das ist ja das Tolle daran: void* zeigt auf alles mögliche und nichts zugleich.

Nun haben wir alles, was wir für unser erstes Programm brauchen - bis auf ein wenig Konvention, welche ich erklären werde:
C:
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[])
{
    int a = 32;
    int b = 10;
    int c = a + b;
    return 0;
}
Die ersten beiden Zeilen sollten wir zuerst einmal überspringen, aber in der Kurzfassung: Dort sind gewisse Definitionen gespeichert, die wir noch nicht, aber bald brauchen werden.

Die 4. Zeile (int main(...)) definiert den Einstiegspunkt. Wenn das Betriebssystem den Auftrag bekommt, ein Programm auszuführen, führt es immer diese Funktion aus. Gelangt das Betriebssystem an ein return, beendet das Programm.
int argc und char* argv[] sind Definitionssache: argc ist "argument count", also die Anzahl der Elemente in argv, dem "argument vector".
char* zeigt nun auf einen Character, das [] am Ende ist eine syntaktische Spielerei. "char* argv[]" ist äquivalent zu "char** argv", ich finde es aber einfacher zu lesen.
Die Syntax bezeichnet, wie sich ein Programm liest. Die Semantik bezeichnet, was das Programm tut. Syntaktische Spielereien (oder "syntactic sugar", "syntaktischer Zucker") bezeichnen gewisse Schönheitsoptionen (daher Zucker, es ist angenehmer), wo die Syntax, nicht aber die Semantik verändert wird.

Somit ist char** argv ein Zeiger auf einen Zeiger auf einen char.
Nun hat man aber ein bisschen getrickst: Ein Pointer ist eigentlich eine Zahl. Was passiert, wenn ich diese Zahl um 1 erhöhe und schaue, was da ist? Was passiert, wenn ich weiter gehe?
Wir sehen also wahrscheinlich ein Zeichen, dann noch ein Zeichen, dann noch ein Zeichen und so weiter. Was haben wir? Wir haben eine Zeichenfolge, also einen "String". Schon haben wir Text, Sätze, Wörter. Aber wie weiss man, wann etwas zu Ende ist?
Hier liegt einer der grössten Kritikpunkte an C:
Die Definition sagt: Ein 0-Byte zeigt das Ende eines Strings an; man liest also bis zur ersten 0. Aber was, wenn es keines gibt? Man liest schlicht (fast) unendlich lange. Aber was ist hinter diesem Programm? Ist es vielleicht dein Passwort? Deine Geheimnisse?
Diese beschränkende 0 zu löschen ist ein gängiger Angriff auf heutige Computersysteme. Aber mehr dazu später.
char** argv ist somit ein Zeiger auf einen odere mehrere Strings, da man ja "String* argv" schreiben könnte.
Und woran sieht man, wie viele Strings man lesen darf?
argc sagt es uns. Warum nicht auch einfach eine 0 als Begrenzer einsetzen? Gute Frage. Es wurde schlicht mal so definiert.

Dann folgt eine '{'. Was ist denn das nun?
Die geschweiften Klammern definieren hier einen Scope, also einen einzeln betrachtbaren Abschnitt. In diesem speziellen Fall definieren sie einen Abschnitt, worin eine Funktion ist, genannt "Funktionsrumpf" oder "function body".
Ein Scope hat 2 Effekte:
1) Variabelnnamen binden immer stärker an die lokalere Deklaration:
C:
int a = 10;
{
    int a = 42;
    //a == 42
}
2) Variablen, welche innerhalb des Scopes deklariert wurden, sind ausserhalb ungültig:
C:
{
    int a = 42;
}
a = 10; //Fehler: Unbekannte Variable

//ABER:
int b = 0;
{
    b = 42;
}
b = 10; //Völlig korrekt

Nun folgt in unserem kleinen Programm ein bisschen Arithmetik.
Wir wissen, dass in c nun die Zahl 42 sein sollte - aber wie geben wir sie aus?
Wir verlassen nun den Bereich der Grundlagen ein bisschen: printf().

C:
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[])
{
    int a = 32;
    int b = 10;
    int c = a + b;
    printf("c ist %d\n", c);
    return 0;
}
Was printf() ist, wie es funktioniert und was es alles kann sei hier nicht von Belang; es gibt schlicht etwas aus. Allerdings kann es sehr beim Verständnis helfen, wenn man die Ergebnisse direkt sehen kann.

C:
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char* argv[])
{
    int a = 32;
    int b = 10;
    int c = a + b;
    printf("a, b, c sind %d, %d, %d\n", a, b, c);

    int* pointerToA = &a;
    int* pointerToB = &b;
    int* pointerToC = &c;

    *pointerToC = *pointerToA - *pointerToB;
    printf("a, b, c sind %d, %d, %d\n", a, b, c);
    pointerToA = pointerToC;
    printf("a, b, c sind %d, %d, %d\n", a, b, c);

    //... Gerne weiter herumspielen ...
    return 0;
}
Hierbei sollte beachtet werden, dass die Typen int* und int nicht gleichgesetzt werden dürfen,
C:
pointerToA = a;
wäre also schlicht falsch (warum?).

Kurz und knackig einige Fakten zum Schluss:
  • Variablen bestehen generell aus einem Typen (z.B. int, char*, etc.) und einem Namen, z.B. "myVar".
  • Nach jedem Statement (~ nach jeder Zeile) steht ein Semikolon (';').
  • Alle Pointertypen sind an ihrem Asterisk (*) zu erkennen.
  • Eine Referenzierung (z.B. &a) fügt genau 1 Asterisk zum Typ hinzu. Jeder Typ kann referenziert werden.
    C:
    int a = 10;
    int* b = &a;
    int** c = &b;
    int*** d = &c;
    //...
  • Eine Dereferenzierung (z.B. *a) entfernt genau 1 Asterisk vom Pointertyp. Ein Typ, der kein Pointer ist, kann nie dereferenziert werden. void* kann ebenfalls nie dereferenziert werden. Z.B.:
    C:
    int*** a;
    int** b = *a;
    int* c = **a;
    int d = ***a;
    int e = ****a; //Error, kann int nicht dereferenzieren: ***a ist kein Pointer
    void* f;
    void e = *f; //Nicht erlaubt
  • Der =-Operator erzeugt immer eine Kopie. Somit gilt:
    C:
    int a = 10;
    int* b = &a; //b zeigt auf a
    int c = *b; //KOPIERE den Wert, worauf b zeigt (nämlich a) nach c
    int d = a; //Semantisch dasselbe wie die Zeile oben (von der anderen Zielvariable abgesehen)
    *b = 42; //Die Variablen b, c, d bleiben unverändert, aber a ist nun 42
    int e = *b; //e ist nun ebenfalls == a == 42
Ich denke, das ist genug fürs Erste.
Generell Hilfe bei allem rund um C/C++ (nicht C#!) findet man hier im Forum: http://www.tutorials.de/c-c

Ich bin sehr dankbar für jegliche (konstruktive) Art der Kritik.
Nach Monaten des Prokrastinierens musste ich einfach mal beginnen; es kann daher durchaus sein, dass nicht alles von Anfang an so klappt, wie ich es mir erhofft habe.
Ich bin mir bewusst, dass es nicht komplett ist, werde aber in (hoffentlich nicht allzu ferner) Zukunft weitere Teile der Einführung schreiben; als nächstes sind wohl Kontrollstrukturen und Bool'sche Operationen / Logik im Detail an der Reihe.
(Falls eine Kategorie für C eingeführt werden könnte, fände ich das recht toll :) )
  • Gefällt mir
Reaktionen: BaraMGB
Autor
cwriter
Aufrufe
7.217
First release
Last update
Bewertung
5,00 Stern(e) 2 Bewertungen

More resources from cwriter

Neueste Bewertungen

Sehr leserlich, humorvoll und für Anfänger nachvollziehbar gehalten. Ich freu mich auf Weiteres!
Zurück