Kapitel 08 - Eigene Datenstrukturen

Peter Ulbrich

🚀 by Decker

Einleitung

  • Bisher: Elementare Datentypen wie int, float, 

  • Problem: Viele Informationen sind Kombinationen von mehreren Variablen
    • Beispiel Koordinate: Ein Paar aus zwei ganzen Zahlen
    • Beispiel Student: Matrikelnummer, Vorname, Nachname, 

  • Naiver Ansatz: Einfach zwei Variablen deklarieren/initialisieren
    • FĂŒr eine Instanz trivial: int x, int y;
    • Was aber, wenn zwei, drei, 
, N Koordinaten benötigt werden?
  • In Kapitel 2 kurz vorgestellt: SchlĂŒsselwort struct
struct Koordinate {
    int x;
    int y;
};

Eigene Datentypen

  • Selbstdefinierte Datenstrukturen (structs) werden aus anderen Datentypen zusammengesetzt
  • Deklaration umfasst Namen und Komponenten
    • Einzelne Komponenten heißen Member
    • Keine Speicherbelegung bei der Deklaration!
    → Geschieht erst spĂ€ter
struct Adresse {
    unsigned int plz;
    char ort[30];
    char strasse[30];
};
int main() {
    struct Adresse addr = { 44227,
        "Dortmund", "OH 14" };
    return 0;
}
  • SchlĂŒsselwort struct kennzeichnet eigene Datentypen, gefolgt von Typbezeichner

Aufbau von Strukturen

  • Member-Deklaration innerhalb eines Codeblocks {}

  • Deklaration wird per Semikolon terminiert

    fĂŒr Member und Datenstruktur

  • Zugriff auf einzelnen Member mit Punktoperator(.)

    • Gilt fĂŒr Lesen und Schreiben gleichermaßen
#include <stdio.h>
struct Adresse {
    unsigned int plz;
    char ort[30];
    char strasse[30];
};
int main() {
    struct Adresse addr = { 44227,
        "Dortmund", "OH 14" };
    addr.plz = 4711;
    printf("PLZ: %d", addr.plz);
    return 0;
}

Verschachelte Datenstrukturen

  • Strukturen dĂŒrfen wieder Strukturen enthalten
  • Aber: Keine zirkulĂ€ren AbhĂ€ngigkeiten
    • Ausnahme: Zeiger auf Datentyp (→ nĂ€chstes Kapitel)
    • Gilt auch fĂŒr kreisförmige AbhĂ€ngigkeiten
struct Foo { struct Foo f; }; // Error
struct Foo { struct Bar b; };
struct Bar { struct Foo f; }; // Error
struct Adresse {
    unsigned int plz;
    char ort[30];
    char strasse[30];
};
struct Student {
    unsigned int mat_num;
    char name[30];
    struct Adresse addr;
};
int main() {
    struct Adresse addr = { 44227,
        "Dortmund", "OH 14" };
    struct Student stud = { 254711,
        "Musterstudent", addr };
    return 0;
}

Initialisierung von Datenstrukturen

  • Erst bei der Initialisierung wird Speicher angelegt
  • Daher: Rekursive Definition einer Struktur verboten
    • UnbeschrĂ€nkte Rekursion bei der Initialisierung
    • GrĂ¶ĂŸe nicht bestimmbar
  • Initialisierung ĂŒber Initializer List:
    • Auflistung der Memberwerte in Reihenfolge der Deklaration
    • Kommasepariert, von Klammern ({}) umfasst (wie Array-Initialisierung)
    • struct Adresse addr = { 44227, "Dortmund", "Otto-Hahn-Straße 14" };

Unterschiedliche Art der Initialisierung

  • Keine Initialisierung
struct Adresse addr;
  • Explizite Initializer List
    • Initialisierung in Reihenfolge der Deklaration
    • Der Speicher nicht genannter Member wird mit 0 initialisiert
struct Adresse addr = { 44227, "Dortmund", "Otto-Hahn-Straße 14" };
struct Adresse addr = { 44227, "Dortmund", }; // Ok (strasse mit 30 Null-Bytes)
  • Leere Initializer List
    • Initialisiert alle Werte des Structs zu 0
    • In C++ in allen Versionen möglich, in C erst seit C23
Adresse addr = {};

Designated Initializer List

  • Initialisierung einzelner Member durch explizite Namensnennung (Designation)
  • Initialisierung in beliebiger Reihenfolge
  • Möglich ab C99 bzw. C++20
  • Nicht genannte Member werden ebenfalls mit 0 initialisiert
#include <stdio.h>
struct Adresse {
    unsigned int plz;
    char ort[30];
    char strasse[30];
};

int main() {
    struct Adresse addr = {
        .ort = "Dortmund",
        .strasse = "OH 14", };
    printf("Adresse: %d %s, %s\n",
           addr.plz, addr.ort,
           addr.strasse);
    return 0;
}
C

Einschub: typedef

  • Verwendung von SchlĂŒsselwort struct in C Pflicht
  • Realisierung mittels SchlĂŒsselwort typedef
typedef struct { int x; int y; } Koordinate;
Koordinate k { 1, 3};

Einschub: typedef

  • FĂŒhrt einen neuen Namen (Alias) fĂŒr bestehenden Datentyp ein

  • HĂ€ufiger Anwendungszweck: Vereinfachen langer oder komplizierter Typen

  • Funktionsweise Ă€hnlich wie #define, aber nicht Teil des PrĂ€prozessors

    → Teil des Compilers (und auf Typen beschrĂ€nkt)

  • Beispiel:

typedef __INT64_TYPE__ int64_t;
typedef __UINT8_TYPE__ uint8_t;

(Auszug aus den Quelldateien des Clang-Compilers)

Einschub: typedef

  • Einsatz bei Datenstruktur auf zwei Weisen
  1. Aliases fĂŒr existierende, benannte Datenstruktur (Tagged Struct)
  2. Definierung eines anonymen Datenstruktur (Anonymous Struct)
#include <stdio.h>
#include <stdint.h>
struct Int{ uint32_t value; };
typedef struct Int Int;
typedef struct { uint32_t value; } Int2;

int main () {
    // Kein 'struct' nötig
    Int i = { 3 };
    Int2 i2 = { 2 };
    printf("%d, %d\n", i.value, i2.value);
    return 0;
}
C

Gleichzeitige Deklaration und Initialisierung

  • Auch möglich: Gleichzeitige Deklaration und Initialisierung möglich
  • Zweck: Struktur mit lokaler BeschrĂ€nkung
    • Wird nur im aktuellen Kontext verwendet
  • Nachteil: Deklaration ist nicht allgemein verwendbar
    • Nur die aktuelle Variable hat die deklarierte Struktur
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

int main() {
    struct { // Anonyme Deklaration
        bool enabled;
        uint8_t value;
    } cfg = { true, 33 }; // Init
    printf("Intensity: %d\n", cfg.value);
    return 0;
}
C

→ FĂŒr hĂ€ufiger eingesetzte Strukturen lohnt sich separate Deklaration als Tagged Struct

Unions

  • Ene weitere Datenstruktur: union
  • Prinzipiell sehr Ă€hnlich zu struct
  • Unterschied: Member teilen sich den Speicher
  • Speicherplatz ergibt sich aus grĂ¶ĂŸtem Element
  • Adressierung der Member ist frei möglich
#include <stdint.h>
#include <stdio.h>

union IntChar {
    uint32_t value;
    uint8_t arr[4];
};

int main() {
    union IntChar u1 = { 65 }; // 'A'
    printf("%c\n", u1.arr[0]);
    printf("%d\n", u1.value);
    u1.arr[3] = 55;
    printf("%c\n", u1.arr[0]);
    printf("%d\n", u1.value);
    return 0;
}
C

Unions

  • Besonderheit: Explizite Initialisierung gilt nur fĂŒr das erste Element
  • FĂŒr alle anderen gilt: Initialisierung mit designierter Variable
#include <stdint.h>
union IntChar {
    uint32_t value;
    uint8_t arr[4];
};

int main() {
    IntChar u1 = { 15 }; // Ok
    IntChar u2 = { .arr = {'a','b','c','d'}}; // Ok
    // IntChar u3 = { {'a','b','c','d'} }; // Error
    return 0;
}
C

Bitfelder

  • Bisher: Datenstrukturelemente belegen ganze Bytes

  • Jetzt: EinschrĂ€nkung auf einzelne Bits

  • Sogenannte Bitfelder (Bit-fields) belegen nur \(N\) Bits

  • Notation ist simpel: Bitbreite folgt auf Elementdeklaration

    → Typ Bezeichner: Breite

#include <stdint.h>
struct Byte {
    uint8_t lower_half: 4;
    uint8_t upper_half: 4;
};

int main() {
    struct Byte b { 0x7, 0xF};
    b.lower_half = 1;
}
C

Bitfelder

  • Achtung: Wertebereich ist auf Bitbreite beschrĂ€nkt
  • Achtung: Compiler darf Bitfeld im Speicher selbst verteilen!
  • Lösung: Im Compiler das Zusammenpacken erzwingen
#include <stdint.h>
#include <stdio.h>
struct Byte {
    uint8_t lower_half: 4;
    uint8_t upper_half: 4;
};

int main() {
    struct Byte b = { 0x47, 0x11};
    printf("Lower: %d, Upper: %d\n",
           b.lower_half, b.upper_half);
    return 0;
}
C

Wozu braucht man Bitfelder?

  • Sind sehr nĂŒtzlich in der hardwarenahen Programmierung
  • Erlauben Abbildung von Konfigurationen auf Datenstrukturen
    • Konfigurationen sind bitcodiert, können direkt in Speicherregister geschrieben werden
    • Bessere Alternative zu mĂŒhseligen Bitoperationen
    • Compiler zur kompakten Speicherung zwingen __attribute__((packed))
      • Funktioniert fĂŒr GCC- und Clang-Compiler
    • FĂŒr MSVC: #pragma pack()

Beispiel – Bitfelder fĂŒr ein Mikrofon

#include <stdint.h>
#include <stdio.h>
#define MIC_CFG_REGISTER 0x4711

void write_register (size_t address, uint16_t value) { }

union {
    uint16_t value;
    struct {
        uint16_t enabled     :1;  // Bit 0
        uint16_t sensitivity :6;  // Bit 1-6
        uint16_t gain        :6;  // Bit 7-12
        uint16_t             :3;  // Leer, reserviert
    } __attribute__((packed));
} mic_cfg;

int main() {
    mic_cfg.value = 0; // Alles nullen (Init)
    mic_cfg.sensitivity = 0x20;
    mic_cfg.gain = 0x03;
    printf("Sensitivity: %#x, Gain: %#x\n",
           mic_cfg.sensitivity, mic_cfg.gain);
    write_register(MIC_CFG_REGISTER, mic_cfg.value);
    printf("Value: %#x\n", mic_cfg.value);
    return 0;
}
C

AufzÀhlungen (Enumerations)

  • Neben Strukturen und Unions weiteren selbstdefinierbaren Datentyp:

    AufzÀhlungen (Enumerations)

  • SchlĂŒsselwort enum

  • Hauptverwendungszweck: BĂŒndelung von ZustĂ€nden bzw. konstanten Werten

  • Besser geeignet als #define

enum Animal { AFFE, KATZE, MAUS };

Animal a = KATZE;

switch (a) {
    case AFFE: break;
    case KATZE: break;
    case MAUS: break;
    default: break;
}

ReprÀsentation von AufzÀhlungen

  • enum-Typen sind intern standardmĂ€ĂŸig vom Typ int
  • Nummerierung beginnt 0:
  • Folgendes ist daher identisch:
enum Animal { AFFE, KATZE, MAUS };
enum Animal { AFFE = 0, KATZE = 1, MAUS = 2 };
}
  • Eigene Werte auch möglich, mĂŒssen aber explizit genannt werden:
enum Animal { AFFE = 0xAFFE, KATZE = 0xBAD, MAUS = 0xDEAD };
}

ReprÀsentation von AufzÀhlungen

  • Seit C++11 bzw. C23: Statt int anderer zugrundeliegender Typ möglich
enum Animal: uint8_t { HUND, KATZE, MAUS };
  • Alle Werte von Animal werden nun intern als uint8_t abgelegt
  • enum-Variablen erlauben alle Operationen eines int đŸ«€

→ Haben ebenfalls den gleichen Typ (genau wie bei #define)

Rechnen mit AufzÀhlungen

#include <stdio.h>

enum Animal {HUND, KATZE, MAUS};

int main() {
    enum Animal a = KATZE;
    a = a + 1;

    printf("Animal: %d\n", a);
    return 0;
}
C

Einschub: Enum-Klassen

  • C++ versucht, diese SchwĂ€che zu umgehen

  • EinfĂŒhrung von neuem Typ enum struct bzw. enum class

  • Intern immer noch int, aber keine implizite Umwandlung mehr möglich

    (Compiler verhindert dies)

enum struct Animal { AFFE, KATZE, MAUS };
Animal a = Animal::AFFE;
if (a == Animal::KATZE) { /* ... */ }
  • Typauswahl nicht mehr wie in C, sondern mit Namespace-PrĂ€fix (→ spĂ€ter mehr)

Zwischenstand

  • Bisher: selbstdefinierte Datentypen - struct, union, enum
  • Was noch fehlt: Dynamisches Anlegen von Speicher
    • Bisher: Speicherbelegung bei Deklaration
    • Jetzt: Speicherbelegung zu beliebigen Zeitpunkten
  • Anmerkung: Manuelle Verwaltung von Speicher → nĂ€chstes Kapitel

→ Hier nur die Grundlagen

Dynamische Speicherallokation

  • Dynamisches Anlegen von Speicher (Allokation) ist wichtig
  1. Strukturen können sehr groß sein
    • Problematisch als Funktionsparameter → muss immer kopiert werden
    • GrĂ¶ĂŸe des „Programms” ist beschrĂ€nkt
  1. Daten sollen global verfĂŒgbar sein
    • Lokale Variablen hören auf zu existieren
  1. Speicher wird eventuell erst spÀter benötigt
    • Wir bestimmen den Zeitpunkt
    • Wichtig bei limitierten Ressourcen

🚹 Achtung: Speicherallokation eine der grĂ¶ĂŸten StĂ€rken, gleichzeitig grĂ¶ĂŸte SchwĂ€che

Speicher anfordern

  • Speicheranforderung in C mit malloc()
    • Speicher wird auf dem Heap angelegt (→ nĂ€chstes Kapitel)
    • Nach der Anlegung bleibt Speicher unbegrenzt belegt
    • Explizite Freigabe mit free() erforderlich
    • Sowohl malloc() als auch free() Teil von stdlib.h
    • Speicher bei Anlegung nicht initialisiert → Aufgabe des Programmierer:in
#include <stdlib.h>
int main() {
void * memory = malloc(4);  // 4 Bytes angelegt
free(memory);               // Freigabe
    return 0;
}

Speicher anfordern

#include <stdlib.h>
int main() {
    void * memory = malloc(4);  // 4 Bytes angelegt
    free(memory);               // Freigabe
    return 0;
}
C
  • Typ void * bezeichnet typischerweise nicht genauer definierten Speicher
    • RĂŒckgabetyp von malloc()
    • Symbolisiert Speicher ohne Typ
  • Mehr Details zu Speicher im nĂ€chsten Kapitel, nur das Wichtigste:
    • Stern-Operator * bedeutet, dass auf den Beginn eines Speicherblocks gezeigt wird (wie Index 0 eines Arrays)

Speicher anfordern

  • Beispiel noch nicht besonders nĂŒtzlich
    • Speicher wurde noch nicht befĂŒllt
    • Hat noch unbekannten Typ void *
  • Sinnvoller: Umwandlung des RĂŒckgabewertes auf Zieltyp
  • Beispiel:
char * addr = (char *) (malloc(sizeof(char) * 3));
  • Operator sizeof() gibt GrĂ¶ĂŸe des angegebenen Objekts in Bytes zurĂŒck
    • Beispiel: sizeof(char); // == 1

Weitere Allokationsfunktionen

  • Neben malloc() noch weitere Allokationsfunktionen verfĂŒgbar
  • RĂŒckgabewert ist Zeiger auf den Beginn eines Speicherbereichs
  • calloc():
    • Legt Array von Elementen an, kein manuelles Berechnen wie bei malloc() nötig
    • Alle Bytes des Speichers werden mit 0 initialisiert
    • Beispiel:
int num = 5;
int * memory = (int *) calloc(num, sizeof(int)); // 5 * 4 Bytes, alle Wert 0

Weitere Allokationsfunktionen

  • realloc():
    • VerĂ€ndert bereits angelegten Speicher
    • Startpunkt ist das erste Byte des vorhandenen Speichers
      • VergrĂ¶ĂŸerung → HinzufĂŒgen an das Ende des vorhandenen Speichers (ohne Initialisierung)
      • Verkleinerung → „Abschneiden“ am Ende des Speichers
    • Beispiel:
void * memory = malloc(20);
memory = realloc(memory, 10);

Zwischenstand

  • Bisher: Reihe von Funktionen zur Speicheranforderung
  • Aber wie kann dieser Speicher befĂŒllt werden?

→ Reihe von Speicheroperationen in string.h

memcpy, memcmp,memmove, memset

Operationen mit dynamisch angelegtem Speicher

  • FĂŒr alle gilt: Bytes werden als unsigned char interpretiert
  • memcpy: Kopiert size Bytes von Speicher src nach dest
    • memcpy(dest, src, size);
  • memmove: Verschiebt size Bytes von Speicher src nach dest
    • memmove(dest, src, size);
  • memcmp: Vergleicht die ersten size Bytes von Speicher src mit dest
    • memcmp(dest, src, size);
  • memset: Kopiert das Zeichen c in die ersten size Bytes dest Speichers dest
    • memset(dest, ch, size);

AufrÀumen ist wichtig!

  • Wir können jetzt sowohl Speicher anlegen als auch beschreiben
  • Eines fehlt noch: Freigabe von Speicher
  • Manuelle Speicherverwaltung ist Fluch und Segen zugleich:
    • Der angeforderte Speicher muss wieder freigegeben werden
    • Freigabe liegt vollstĂ€ndig in der Verantwortung der Programmierer:in🚹
    • Sehr einfach, dies versehentlich zu vergessen
  • Achtung: Ist der Zeigerwert weg → Speicherort nicht mehr benutzbar
    • Geschieht automatisch bei Verlassen eines Code-Blocks
    • Konsequenz: Speicherleck (Memory Leak)

Speicherleck

  • Einfaches Beispiel fĂŒr ein Speicherleck:
#include <stdlib.h>

int main() {
    {
        void * memory = malloc(4);
        /* Viele Zeilen Code */
        // free(memory);  wurde vergessen
    } // memory ab hier nicht mehr benutzbar
    return 0;
}
C
  • free() kann sehr leicht vergessen werden, besonders bei langen Funktionen

Wenn möglich, fĂŒr jedes malloc() direkt free()-Statement schreiben

Fehlerbehandlung

  • Abschließend: Was ist, wenn Speicher ausgeht?
  • malloc() signalisiert Fehler ĂŒber RĂŒckgabewert

  • Tritt ein Fehler auf, gibt malloc() NULL zurĂŒck

    → Nach dem Aufruf auf NULL prĂŒfen und Fehlerbehandlung durchfĂŒhren

void *memory = malloc(42);
if (memory == NULL) {
    printf("Fehler!\n");
    exit(1);
}

Beispiel - Fehlerbehandlung von malloc()

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

int main() {
    while (true) {
        void * memory = malloc(1024 * 1024); // 1 KiByte
        if (memory == nullptr) {
            break;
        }
    }
    printf("Speicher voll!\n");
    return 0;
}
C

Zusammenfassung

  • Nicht alles mit elementaren Datentypen darstellbar
  • C bietet drei Möglichkeiten, Datentypen selbst zu definieren
    • struct: Verbund von Datentypen
    • union: Vereinigung von Datentypen
    • enum: AufzĂ€hlung
  • Dynamische Speicherverwaltung
    • erlaubt beliebigen Speicher zu beliebigen Zeitpunkten zu allozieren
    • VollstĂ€ndig in der Hand der Programmierer:in
    • Freigabe ist zwingend erforderlich
Logo Sys
Kapitel 08 - Eigene Datenstrukturen