Simple8

Fonctions

Une fonction regroupe des instructions sous un nom. On l'écrit une fois, on l'appelle autant de fois qu'il faut. C'est l'outil qui permet à un programme de grandir sans devenir illisible.

Pourquoi des fonctions ?

Au chapitre 3, le tableau de score écrivait un nombre avec cinq lignes presque identiques :

vic.putChar((ubyte)('0' + .score / 10000));
vic.putChar((ubyte)('0' + (.score / 1000) % 10));
vic.putChar((ubyte)('0' + (.score / 100) % 10));
vic.putChar((ubyte)('0' + (.score / 10) % 10));
vic.putChar((ubyte)('0' + .score % 10));

Pour écrire aussi les vies et le niveau, il faudrait recopier ce bloc, et corriger chaque copie à la moindre erreur. Avec une fonction, on l'écrit une seule fois, et on l'appelle par son nom :

ecrireNombre(.score, 5);
ecrireNombre(.vies, 1);

Les fonctions ont trois avantages :

Vous en utilisez déjà : vic.print ou vdc.setCursor sont des fonctions, écrites dans les bibliothèques.

Déclarer une fonction

uint addition(uint a, uint b) {
    return a + b;
}
PartieIciRôle
le type du résultatuintle type de la valeur que la fonction renvoie ; void si elle ne renvoie rien
le nomadditionpour l'appeler
les paramètres(uint a, uint b)les valeurs qu'elle reçoit, chacune avec son type ; () s'il n'y en a pas
le corps{ return a + b; }les instructions exécutées à chaque appel

Les fonctions se déclarent dans la unit, après ses variables, avant ou après main(). main() est elle-même une fonction, un peu à part : elle n'a pas de type, et c'est elle que boot.asm appelle.

unit {
    uint score;                 // les variables d'abord

    main() {
        afficher();
    }

    void afficher() {           // une fonction qui ne renvoie rien
        // ...
    }
}

Appeler une fonction

On appelle une fonction par son nom, suivi des valeurs de ses paramètres entre parenthèses. Une fonction de la unit s'appelle sans point ; une fonction de bibliothèque, avec le nom de la bibliothèque :

afficher();                     // une fonction de la unit
.total = addition(40, 2);       // son résultat dans une variable : 42
vic.setCursor(10, 5);           // une fonction de bibliothèque

Une fonction peut être appelée avant l'endroit où elle est écrite : main(), en haut de la unit, appelle volontiers des fonctions écrites plus bas.

Une faute de frappe dans un nom de fonction n'est repérée qu'à la fin de la compilation, par l'assembleur : Undefined symbol: Main_inconnue. Le nom de la fonction apparaît après Main_, le nom de la unit.

Les paramètres

Une copie de la valeur

Un paramètre reçoit une copie de la valeur donnée à l'appel. La fonction peut le modifier : la variable de l'appelant, elle, ne change pas.

main() {
    ubyte x = 5;
    doubler(x);
    // x vaut toujours 5
}

void doubler(ubyte v) {
    v = v * 2;                  // v vaut 10, mais seulement dans doubler()
}

Pour qu'une fonction transmette un résultat, elle le renvoie avec return, ou elle l'écrit dans une variable de la unit.

Combien de paramètres ?

Le nombre d'arguments doit être celui de la déclaration :

Function 'addition' takes 2 parameter(s), called with 1 argument(s)

Les machines 8 bits passent les paramètres dans une petite zone de mémoire fixe. Sur C64 et PC Engine, elle en contient 4 au plus :

Function Main_c has too many parameters (5 > 4): the parameter zone holds 4 of them
(maxParams in config.json or in simple8-config.json).

Une fonction qui aurait besoin de plus de valeurs peut les lire dans des variables de la unit. On peut aussi relever la limite, avec la clé maxParams, à deux endroits :

{ "maxParams": 6 }

Chaque paramètre de plus prend 2 octets de mémoire vive dans la zone des paramètres (la clé paramWidth fixe cette largeur, 16 bits par défaut). Le compilateur décale lui-même ce qui suit la zone.

Un texte en paramètre

Un texte entre guillemets donné à une fonction est rangé dans le programme, et la fonction reçoit son adresse, un nombre de 16 bits. Le paramètre est donc un uint, que l'on passe tel quel à print :

void titre(uint texte) {
    vic.setCursor(0, 0);
    vic.print(texte);
}

titre("BONJOUR");

Renvoyer une valeur : return

return termine la fonction et renvoie une valeur, du type annoncé dans sa déclaration. Il peut apparaître plusieurs fois : le premier atteint l'emporte.

ubyte plusGrand(ubyte a, ubyte b) {
    if (a > b) {
        return a;               // on s'arrête ici si a est le plus grand
    }
    return b;
}

.max = plusGrand(3, 9);         // 9

Dans une fonction void, return; sans valeur sert à sortir plus tôt :

void retirerVie() {
    if (.vies == 0) {
        return;                 // déjà plus de vies : rien à faire
    }
    .vies--;
}

Les variables locales

Les variables déclarées dans une fonction, comme ses paramètres, n'appartiennent qu'à elle. Deux fonctions peuvent avoir chacune un i : ce sont deux variables différentes. Une fonction ne peut pas lire les variables locales d'une autre :

main() {
    ubyte secret = 3;
    lire();
}

void lire() {
    .valeur = secret;           // erreur : secret appartient à main()
}
Variable 'secret' not found !

Pour partager une valeur entre fonctions, on la passe en paramètre, ou on la range dans une variable de la unit.

Une fonction qui s'appelle elle-même

Une fonction récursive s'appelle elle-même. La factorielle en est l'exemple classique : 5! = 5 × 4 × 3 × 2 × 1 = 120.

uint factorielle(uint n) {
    if (n <= 1) {
        return 1;
    }
    return n * factorielle(n - 1);
}

Par défaut, Simple8 refuse ce programme :

Direct recursion: Main_factorielle -> Main_factorielle. Recursion needs a stack:
set "parameterStack" to "software" or "hardware" in config.json (or in simple8-config.json).

Pour aller vite, chaque paramètre et chaque variable locale a une adresse fixe en mémoire. Or factorielle(5) appelle factorielle(4) avant d'avoir fini : les deux appels voudraient le même n. Il faut alors une pile, qui garde le n de chaque appel en cours. Elle se règle dans un fichier simple8-config.json, à la racine du projet :

{ "parameterStack": { "mode": "software", "frameSize": 4, "maxDepth": 8 } }

Le programme compile alors, et factorielle(5) donne 120. Simple8 IDE règle aussi cette pile, dans Configuration → Simple8.

À réserver aux vrais besoins. La pile coûte de la mémoire et du temps à chaque appel récursif, et les machines 8 bits en ont peu. Une boucle fait souvent le même travail : la factorielle se calcule aussi avec un simple for.

Plusieurs fonctions du même nom

Deux fonctions peuvent porter le même nom si leurs paramètres diffèrent : c'est la surcharge. Le compilateur choisit la bonne d'après les arguments de l'appel :

uint aire(uint c) {                    // un carré
    return c * c;
}

uint aire(uint l, uint h) {             // un rectangle
    return l * h;
}

main() {
    .carre = aire(4);                   // 16 : la première
    .rectangle = aire(4, 6);            // 24 : la seconde
}

Un exemple : le tableau de score en fonctions

Le tableau de score du chapitre 3, découpé en fonctions. main() se lit maintenant comme un résumé du programme ; chaque fonction fait une seule chose.

// Fonctions : le tableau de score, découpé en fonctions (Commodore 64)
architecture commodore.c64;

addlib vic.base;
addlib vic.screentext;

unit {
    const uint POINTS_PAR_GEMME = 250;
    ubyte vies = 3;
    uint score;

    main() {
        ubyte gemmes = 7;

        .score = points(gemmes);
        .vies--;

        preparerEcran();
        ecrireLigne(10, "SCORE  ", .score, 5);
        ecrireLigne(12, "VIES   ", .vies, 1);
        ecrireLigne(14, "GEMMES ", gemmes, 2);
    }

    // Les points rapportés par des gemmes
    uint points(ubyte gemmes) {
        return .POINTS_PAR_GEMME * gemmes;
    }

    // L'écran : fond bleu, texte blanc
    void preparerEcran() {
        vic.setBorderColor(0);
        vic.setBackgroundColor(6);
        vic.initScreen();
        vic.clearScreen();
        vic.setFontColor(1);
    }

    // Un nombre, sur un nombre de chiffres donné (avec des zéros devant)
    void ecrireNombre(uint valeur, ubyte chiffres) {
        uint diviseur = 1;
        for (ubyte i = 1; i < chiffres; i++) {
            diviseur = diviseur * 10;       // 10, 100, 1000...
        }
        while (diviseur > 0) {
            vic.putChar((ubyte)('0' + (valeur / diviseur) % 10));
            diviseur = diviseur / 10;
        }
    }

    // Un texte puis un nombre, à la ligne y
    void ecrireLigne(ubyte y, uint texte, uint valeur, ubyte chiffres) {
        vic.setCursor(13, y);
        vic.print(texte);
        ecrireNombre(valeur, chiffres);
    }
}
Écran du Commodore 64 : SCORE 01750, VIES 2, GEMMES 07
Le tableau de score sur Commodore 64.
La version PC Engine

Seules preparerEcran(), le nom de la bibliothèque et la position du texte changent. main(), points() et la logique de ecrireNombre() sont identiques : c'est l'intérêt de bien découper.

// Fonctions : le tableau de score, découpé en fonctions (PC Engine)
architecture nec.pce;

addlib sys.resource;
addlib vce.base;
addlib vdc.base;
addlib vdc.screentext;

unit {
    const int font = addResource("font.bin");
    const uint POINTS_PAR_GEMME = 250;
    ubyte vies = 3;
    uint score;

    main() {
        ubyte gemmes = 7;

        .score = points(gemmes);
        .vies--;

        preparerEcran();
        ecrireLigne(11, "SCORE  ", .score, 5);
        ecrireLigne(13, "VIES   ", .vies, 1);
        ecrireLigne(15, "GEMMES ", gemmes, 2);
    }

    // Les points rapportés par des gemmes
    uint points(ubyte gemmes) {
        return .POINTS_PAR_GEMME * gemmes;
    }

    // L'écran, la police et les couleurs : fond bleu, texte blanc
    void preparerEcran() {
        int source;
        int length;
        vdc.setBATSize(vdc.BAT_32x32);
        vdc.initScreen();
        resource.mapToMPR2(.font);
        source = 0x4000 + resource.getOffset(.font);
        length = resource.getLength(.font);
        vdc.writeToVRAM(source, 0x1000, length);
        vce.setPalette(0, 0, RGB(0, 0, 7));
        vce.setPalette(0, 1, RGB(7, 7, 7));
        vdc.clearScreen();
        vdc.setFontColor(0);
    }

    // Un nombre, sur un nombre de chiffres donné (avec des zéros devant)
    void ecrireNombre(uint valeur, ubyte chiffres) {
        uint diviseur = 1;
        for (ubyte i = 1; i < chiffres; i++) {
            diviseur = diviseur * 10;       // 10, 100, 1000...
        }
        while (diviseur > 0) {
            vdc.putChar((ubyte)('0' + (valeur / diviseur) % 10));
            diviseur = diviseur / 10;
        }
    }

    // Un texte puis un nombre, à la ligne y
    void ecrireLigne(ubyte y, uint texte, uint valeur, ubyte chiffres) {
        vdc.setCursor(9, y);
        vdc.print(texte);
        ecrireNombre(valeur, chiffres);
    }
}
Écran de la PC Engine : SCORE 01750, VIES 2, GEMMES 07
Le tableau de score sur PC Engine.

À retenir

À vous de jouer

  1. Ajoutez une ligne NIVEAU au tableau, avec un seul appel de ecrireLigne().
    Voir la solutionMasquer la solution

    ecrireLigne() sait déjà écrire un texte suivi d'un nombre, à une ligne donnée. Pour le niveau, il suffit d'une variable de la unit et d'un appel de plus, deux lignes sous les gemmes :

    uint score;
    ubyte niveau = 4;
    main() {
        ubyte gemmes = 7;
    
        .score = points(gemmes);
        .vies--;
    
        preparerEcran();
        ecrireLigne(10, "SCORE  ", .score, 5);
        ecrireLigne(12, "VIES   ", .vies, 1);
        ecrireLigne(14, "GEMMES ", gemmes, 2);
        ecrireLigne(16, "NIVEAU ", .niveau, 2);
    }
    Écran du Commodore 64 : SCORE 01750, VIES 2, GEMMES 07, NIVEAU 04
    La ligne NIVEAU, ajoutée par un seul appel.

    Sur PC Engine, la ligne est la même, à la ligne 17 : ecrireLigne(17, "NIVEAU ", .niveau, 2);. Aucune fonction n'a eu besoin d'être modifiée : c'est l'avantage d'avoir écrit ecrireLigne() une fois pour toutes.

  2. Écrivez une fonction uint bonus(ubyte vies) qui renvoie 1 000 points par vie restante, et ajoutez son résultat au score.
    Voir la solutionMasquer la solution

    La fonction reçoit un nombre de vies et renvoie le bonus avec return. Son résultat est un uint, assez grand pour plusieurs milliers de points. Dans main(), on l'appelle après avoir retiré une vie, et on ajoute son résultat au score avec +=.

    // Le bonus de fin de niveau : 1000 points par vie restante
    uint bonus(ubyte vies) {
        return 1000 * vies;
    }
    main() {
        ubyte gemmes = 7;
    
        .score = points(gemmes);
        .vies--;
        .score += bonus(.vies);         // 1750 + 2 x 1000 = 3750
    
        preparerEcran();
        ecrireLigne(10, "SCORE  ", .score, 5);
        ecrireLigne(12, "VIES   ", .vies, 1);
        ecrireLigne(14, "GEMMES ", gemmes, 2);
    }
    Écran du Commodore 64 : SCORE 03750, VIES 2, GEMMES 07
    1750 points de gemmes et 2 × 1000 points de bonus : 3750.

    Le paramètre s'appelle vies, comme la variable de la unit : ce n'est pas gênant. Dans la fonction, vies sans point est le paramètre, .vies serait la variable de la unit. On aurait pu écrire bonus() sans paramètre, en lisant .vies ; avec un paramètre, la fonction ne dépend que de ce qu'on lui donne, et elle peut servir pour n'importe quel nombre de vies.

  3. Modifiez ecrireNombre() pour ne pas écrire les zéros de tête : 01750 devient 1750. Indice : une variable locale qui retient si un chiffre non nul a déjà été écrit.
    Voir la solutionMasquer la solution

    On parcourt toujours les chiffres de gauche à droite, mais on n'écrit un chiffre que dans trois cas : il n'est pas nul, ou un chiffre a déjà été écrit (les zéros au milieu, comme dans 1050, doivent rester), ou c'est le dernier chiffre (pour que la valeur 0 s'écrive 0 et non rien). La variable locale commence retient si l'écriture a commencé : elle part de 0 et passe à 1 au premier chiffre écrit.

    // Un nombre, sans les zéros de tête (au plus "chiffres" chiffres)
    void ecrireNombre(uint valeur, ubyte chiffres) {
        uint diviseur = 1;
        ubyte chiffre;
        ubyte commence = 0;                 // 1 dès qu'un chiffre a été écrit
        for (ubyte i = 1; i < chiffres; i++) {
            diviseur = diviseur * 10;       // 10, 100, 1000...
        }
        while (diviseur > 0) {
            chiffre = (ubyte)((valeur / diviseur) % 10);
            // on écrit si le chiffre n'est pas nul, si on a déjà commencé,
            // ou si c'est le dernier (pour que 0 s'écrive 0)
            if (chiffre != 0 || commence || diviseur == 1) {
                vic.putChar((ubyte)('0' + chiffre));
                commence = 1;
            }
            diviseur = diviseur / 10;
        }
    }
    Écran du Commodore 64 : SCORE 1750, VIES 2, GEMMES 7
    Les nombres sans leurs zéros de tête.

    Le chiffre est rangé dans une variable, chiffre, parce qu'on s'en sert deux fois : pour le test et pour l'écrire. La condition utilise commence seul : 0 est faux, 1 est vrai. Nous avons vérifié aussi le cas de la valeur 0 : avec une seule vie au départ, la ligne VIES affiche bien 0. Sur PC Engine, la fonction est la même avec vdc.putChar.

    Les nombres sont maintenant alignés à gauche : 1750 prend 4 cases au lieu de 5. Pour les garder alignés à droite, comme dans un vrai tableau de score, on écrirait une espace (putChar(' ')) à la place de chaque zéro de tête.

  4. Ajoutez la colonne x en paramètre de ecrireLigne(). Que dit le compilateur ? Comment s'en sortir ?
    Voir la solutionMasquer la solution

    Avec la colonne x, ecrireLigne() a cinq paramètres :

    // Un texte puis un nombre, à la colonne x et à la ligne y
    void ecrireLigne(ubyte x, ubyte y, uint texte, uint valeur, ubyte chiffres) {
        vic.setCursor(x, y);
        vic.print(texte);
        ecrireNombre(valeur, chiffres);
    }

    Le compilateur refuse, car la zone des paramètres du C64 n'en contient que 4 :

    Function Main_ecrireLigne has too many parameters (5 > 4): the parameter zone holds 4 of them (maxParams in config.json or in simple8-config.json).

    Première solution : relever la limite. Le message l'indique : un fichier simple8-config.json, à la racine du projet, avec la clé maxParams. Le réglage ne vaut que pour ce projet. Le programme compile alors tel quel, et l'écran est le même qu'avant. Le prix : 2 octets de mémoire vive de plus pour la zone des paramètres.

    { "maxParams": 5 }

    Seconde solution : un paramètre de moins. On sort la position de la fonction : l'appelant place le curseur avec vic.setCursor(x, y), et ecrireLigne() écrit là où se trouve le curseur. Elle n'a plus que trois paramètres, et la colonne se choisit librement à chaque appel.

    main() {
        ubyte gemmes = 7;
    
        .score = points(gemmes);
        .vies--;
    
        preparerEcran();
        vic.setCursor(13, 10);
        ecrireLigne("SCORE  ", .score, 5);
        vic.setCursor(13, 12);
        ecrireLigne("VIES   ", .vies, 1);
        vic.setCursor(13, 14);
        ecrireLigne("GEMMES ", gemmes, 2);
    }
    // Un texte puis un nombre, à la position du curseur
    void ecrireLigne(uint texte, uint valeur, ubyte chiffres) {
        vic.print(texte);
        ecrireNombre(valeur, chiffres);
    }

    Les deux versions compilent et affichent le même tableau. La seconde ne demande aucun réglage, ni mémoire en plus ; la première garde des appels plus courts. Une troisième voie serait de lire la colonne dans une variable de la unit, mais une fonction qui reçoit tout ce dont elle a besoin en paramètres est plus facile à comprendre.