C voor Rust-programmeurs

Nou, dat ga ik nu veranderen! Ik heb onlangs C en C++ geleerd, en mijn hemel, wat zijn dat eigenzinnige talen. Dit blogbericht is een verzameling van verbazingwekkende details die ik heb ontdekt terwijl ik mezelf C leerde. (Geen C++ vandaag, ik ben nog niet klaar om die doos van Pandora te openen.) Dit is geen vervanging voor een fatsoenlijke C-tutorial; die zul je zelf via Google moeten zoeken. In plaats daarvan is het een lijst met zaken om in je achterhoofd te houden wanneer je in deze taal werkt.

Nu de context is geschept, laten we het gordijn openen en kijken wat C te bieden heeft!

Booleans zijn niet* ingebouwd

De oorspronkelijke versie van C had geen primitief type voor booleans; programma's gebruikten in plaats daarvan de integers 0 en 1. Dit is veranderd in C99, waarin booleans werden toegevoegd in de optionele <stdbool.h> header[1]:

#include <stdbool.h>

int main() {
    bool yes = true;
    bool no = false;
    return 0;
}

Toch zijn true en false geen literals of trefwoorden zoals in andere talen. In plaats daarvan zijn het definities die worden uitgebreid naar 1 en 0:

#define true 1
#define false 0

Dit is opnieuw veranderd in C23[2], waardoor booleans nu echte taalprimitieven zijn. Als je echter compileert voor eerdere versies, moet je <stdbool.h> includeren.

Null-getermineerde strings

Een Rust &str is 16 bytes: 8 bytes voor het geheugenadres en 8 bytes voor de lengte van de string. Dit komt omdat str een type met een dynamische grootte is en pointer-metadata gebruikt om de lengte van de string bij te houden.

// Terwijl een normale referentie slechts 8 bytes gebruikt...
assert_eq!(std::mem::size_of::<&u8>(), 8);

// ...gebruiken strings 16 bytes.
assert_eq!(std::mem::size_of::<&str>(), 16);

Deze aanpak maakt het ophalen van de stringlengte extreem efficiënt, maar vereist meer geheugen per &str-referentie. C gebruikt een andere aanpak: het slaat de grootte van strings niet apart op, maar beëindigt elke string met een null-byte (\0). Dit is een bewuste afweging die tot een aantal zaken leidt:

  • C-programma's hoeven geen extra groottevariabele naast de string bij te houden[3].
  • Elke string heeft aan het einde ruimte nodig voor een null-byte.
  • Dit betekent dat lege strings "" nog steeds één byte aan geheugen innemen!
  • Null-bytes kunnen niet eenvoudig in het midden van een string worden gebruikt zonder de functies van <string.h> te verstoren.
  • Het vergeten van de null-terminator kan leiden tot out-of-bound reads.

In de praktijk betekent dit dat je moet onthouden om extra ruimte te reserveren voor de null-terminator en deze aan het einde van strings te plaatsen. Hier is bijvoorbeeld een programma dat een string in C omkeert:

char* reverse(char* forward) {
    // Bereken de lengte van de string, exclusief de null-terminator.
    unsigned long len = strlen(forward);

    // Reserveer genoeg ruimte voor de string en zijn null-terminator.
    char* reversed = malloc(len + 1);

    for (int i = 0; i < len; i++) {
        reversed[i] = forward[len - 1 - i];
    }

    // Voeg de null-terminator aan het einde toe.
    reversed[len] = '\0';

    return reversed;
}

Let op regel 5 en 12, die speciale maatregelen nemen om rekening te houden met de null-terminator. Ter referentie: de overeenkomstige Rust-functie[4] hoeft dit niet te doen:

fn reverse(forward: &[u8]) -> Box<[u8]> {
    let len = forward.len();
    let mut reversed = Box::<[u8]>::new_uninit_slice(len);
    for i in 0..len {
        reversed[i].write(forward[len - 1 - i]);
    }
    unsafe { reversed.assume_init() }
}

Doelplatform-afhankelijke integer-breedtes

De integer-types van C garanderen niet dat ze een exact aantal bits gebruiken; de breedte varieert afhankelijk van het doelplatform.

TypeC Standaard64-bit Unix64-bit Windows
charminimaal 8 bits8 bits8 bits
shortminimaal 16 bits16 bits16 bits
intminimaal 16 bits32 bits32 bits
longminimaal 32 bits64 bits32 bits
long longminimaal 64 bits64 bits64 bits

Gegevens afkomstig van cppreference.com

Dat long 64 bits is op Unix en 32 bits op Windows irriteert me bijzonder. Ik raad aan om het advies te volgen dat een vriend me een paar jaar geleden gaf: als je geeft om cross-platform compatibiliteit, gebruik dan alleen de fixed-width integers die worden geleverd door <stdint.h>:

RustC
u8uint8_t
u16uint16_t
u32uint32_t
u64uint64_t
usizesize_t[5]
i8int8_t
i16int16_t
i32int32_t
i64int64_t
isizeptrdiff_t[5]

Foutafhandeling is verschrikkelijk

Ik hou echt van de foutafhandeling in Rust. Result dwingt je om fouten aan te pakken, en sum types (enums) en match-statements maken dat erg eenvoudig!

Het verhaal van de foutafhandeling in C is in vergelijking daarmee ronduit tragisch. Het lijkt neer te komen op functies die een "magisch integer" zoals -1 of een null-pointer teruggeven om aan te geven dat er een fout is opgetreden. Je kunt iets meer informatie krijgen door errno te lezen, een thread-lokale integer die kan worden gebruikt om specifieke soorten fouten te controleren, maar wat betreft het verkrijgen van werkelijke foutmeldingen en stack traces is het veel moeilijker.

Bij het schrijven van C is een van de grootste dingen die je zult merken dat de taal je nooit zal dwingen om fouten af te handelen. Het is aan jou om te onthouden dat functies kunnen falen. Hier is bijvoorbeeld een codefragment uit het gedeelte over Null-getermineerde strings:

// Reserveer genoeg ruimte voor de string en zijn null-terminator.
char* reversed = malloc(len + 1);
for (int i = 0; i < len; i++) {
    reversed[i] = forward[len - 1 - i];
}

Voor nieuwe programmeurs is het niet direct duidelijk dat malloc() kan falen en een null-pointer kan teruggeven. Als je machine geen geheugen meer heeft, zal reversed[i] een segfault veroorzaken bij toegang. Om onbehulpzame segfaults te voorkomen, moet het programma controleren op een null-pointer en netjes afsluiten als er een wordt gevonden:

char* reversed = malloc(len + 1);
if (reversed == NULL) {
    perror("Error");
    exit(1);
}

Dit zorgt voor een veel betere ervaring dan een segfault, of erger nog, ander onbedoeld gedrag:

$ ./main
Error: Cannot allocate memory

Natuurlijk is het onthouden om elke toegewezen pointer te controleren geen geweldige ontwikkelaarservaring. Een aanpak die ik heb geprobeerd, was het recreëren van Rust's Result in C met behulp van tagged unions:

struct MallocResult {
    enum Tag { OK, ERROR } tag;
    union Value {
        void* ptr;
        char* error_message;
    } value;
};

Het is echter een complete puinhoop om te gebruiken, en er is nog steeds niets dat je tegenhoudt om direct result.value.ptr te benaderen zonder eerst de fout af te handelen. Het ontbreken van zichtbaarheidsmodifiers zoals private en public, die hiervoor gebruikt zouden kunnen worden, lijkt een bewuste ontwerpbeslissing. C plaatst volledig vertrouwen in de programmeur om dingen correct te doen™ en biedt weinig faciliteiten voor contracten of veilige abstracties.

Ik ben het persoonlijk oneens met deze aanpak. Ik ben geen mastermind-programmeerwizard die nooit fouten maakt. Ik zou veel liever programmavereisten coderen in het typesysteem, zodat de compiler ze voor mij kan controleren[6]! Op die manier kan ik er redelijk zeker van zijn dat als de code compileert, deze correct is geschreven. Maar ik dwaal af.

Syntaxis voor veldtoegang

C heeft twee verschillende operators voor veldtoegang: . voor waarden en -> voor pointers.

struct Foo {
    int field;
};

struct Foo value = { 103 };
struct Foo* ptr = &value;

// Directe toegang tot het veld.
printf("%d\n", value.field);

// Toegang tot het veld via een pointer.
printf("%d\n", ptr->field);

Dit overviel me in het begin, aangezien Rust . voor alles gebruikt. Als je nieuwsgierig bent, biedt een write-up op Stack Overflow interessante geschiedenis over waarom de -> operator bestaat[7].

Arrays worden zomaar pointers

Arrays hebben enkele vreemde nuances. Soms zijn ze gewone waarden waarvan de lengte toegankelijk is via sizeof(), andere keren zijn ze pointers met een onbekende grootte. Over het algemeen wordt dit bepaald door of je de array hanteert binnen de functie waarin deze is gedefinieerd of niet.

Om te laten zien wat ik bedoel, is hier een heel eenvoudig C-programma dat de grootte van twee arrays print:

char declared_size[3] = { 1, 2, 3 };
char inferred_size[] = { 4, 5, 6 };
printf("Declared size (main): %ld bytes\n", sizeof(declared_size));
printf("Inferred size (main): %ld bytes\n", sizeof(inferred_size));

Wanneer dit wordt uitgevoerd, vertelt het programma je dat beide arrays 3 bytes aan geheugen innemen. Goed!

$ ./main
Declared size (main): 3 bytes
Inferred size (main): 3 bytes

Laten we nu een kleine wijziging aanbrengen. Laten we declaredsize en inferredsize door een functie sturen voordat we de grootte printen:

void function(char declared_size[3], char inferred_size[]) {
    printf("Declared size (function): %ld bytes\n", sizeof(declared_size));
    printf("Inferred size (function): %ld bytes\n", sizeof(inferred_size));
}

Er is niets aan de logica veranderd, de arrays zijn identiek aan het vorige voorbeeld, maar nu meldt het programma dat elke array 8 bytes aan geheugen inneemt:

$ ./main
Declared size (function): 8 bytes
Inferred size (function): 8 bytes

Waarom? Omdat elke array die via een functie wordt doorgegeven, impliciet wordt omgezet in een pointer naar het eerste element. Vanuit het perspectief van de C-compiler had de bovenstaande functie eigenlijk de volgende type-signatuur:

void function(char* declared_size, char* inferred_size);

Dit is waarom het verwarrend genoeg leek alsof elke array 8 bytes lang was. De arrays zelf zijn 3 bytes, maar de pointers nemen 8 bytes aan geheugen in beslag! Gelukkig geeft Clang een waarschuwing wanneer je sizeof() uitvoert op de pointer-vorm van een array, waardoor het makkelijker is om deze fout te ontdekken:

$ clang main.c
06-arrays/main.c:5:59: warning: sizeof on array function parameter will return size of 'char *'
instead of 'char[3]' [-Wsizeof-array-argument]
5 |     printf("Declared size (function): %ld bytes\n", sizeof(declared_size));
|                                                           ^
06-arrays/main.c:4:20: note: declared here
4 | void function(char declared_size[3], char inferred_size[]) {
|                    ^
06-arrays/main.c:6:59: warning: sizeof on array function parameter will return size of 'char *'
instead of 'char[]' [-Wsizeof-array-argument]
6 |     printf("Inferred size (function): %ld bytes\n", sizeof(inferred_size));
|                                                           ^
06-arrays/main.c:4:43: note: declared here
4 | void function(char declared_size[3], char inferred_size[]) {
|                                           ^
2 warnings generated.

Oh ja, referenties bestaan niet

Ik zou nalatig zijn als ik het niet zou noemen. C heeft geen borrow checking en geen concept van referenties. Alles zijn ruwe pointers. Dit betekent dat je leuke pointer-aritmetiek kunt doen zoals hier:

int array[] = { 0, 1, 2, 3, 4, 5 };
// Wauw, itereren over pointers in plaats van indexen!
for (int* x = &array[0]; x < &array[6]; x++) {
    *x = 5 - *x;
}

Ik weet echter niet zeker of dat een goed idee is. �

Hoe dan ook, wees voorzichtig wanneer je met pointers werkt. Fouten met geheugen resulteren in buffer overflows en out-of-bounds writes, wat aanzienlijke bedreigingen vormt voor de veiligheid van je code.

Conclusie

Ik hoop dat je dit artikel leuk vond! Ik heb eerlijk gezegd veel plezier gehad bij het leren van C. En hoewel ik twijfel of ik het voor persoonlijke projecten zal gebruiken, is het absoluut een cruciale taal om te kennen als systeemprogrammeur. Alle voorbeelden in dit blog zijn beschikbaar op Github, mocht je er zelf mee willen experimenteren!

Tot de volgende keer,

  • BD103 :)

***

Voetnoten

[1] Technisch gezien kun je _Bool gebruiken zonder de header, maar je hebt deze nog steeds nodig voor de definities van true en false.

[2] Het lijkt erop dat Clang nog steeds slechts gedeeltelijke ondersteuning heeft voor C23, dus je moet mogelijk nog een tijdje wachten voordat je je #include <stdbool.h> statements kunt weggooien.

[3] Zoals eerder beschreven, is dit in wezen wat Rust doet. In C zou dit een ergonomische pijn zijn, maar de taalfuncties van Rust zorgen ervoor dat een programmeur de pointer en de stringlengte nooit als aparte variabelen hoeft te beschouwen.

[4] De overeenkomstige Rust-functie is niet idiomatisch en kan alleen correct ASCII-tekst afhandelen. Als ik een productieversie van deze functie zou schrijven, zou het één regel code zijn: forward.chars().rev().collect::<String>().

[5] usize en isize komen semantisch gezien niet perfect overeen met sizet en ptrdifft. Ik begrijp het verschil niet volledig, dus ik raad aan om zelf onderzoek te doen voordat je deze types gebruikt.

[6] Dit blogbericht op Fearless SIMD geeft een geweldig voorbeeld van het gebruik van het typesysteem van Rust om de correctheid van code te garanderen. Ik raad ten zeerste aan dit te lezen!

[7] Mijn favoriete regel code uit dat bericht is 100->a = 0;, het is zo absurd!