Informations

Date limite Pas de date limite
Limite de soumission Pas de limite

Etiquettes

Se connecter

[6.1] Rappel des concepts [Obligatoire]

Implémentation d'une fonction

Dans la section précédente, nous avons vu ce qu'était une fonction et comment l'appeler mais nous avons été très brefs sur comment écrire son corps.

Pour rappel, une fonction est constituée des éléments suivants :

  • une précondition
  • une postcondition
  • un type de retour
  • un nom (ou identifiant)
  • une liste de paramètres (ou arguments formels)
  • un corps

Quand on écrit le corps d'une fonction, on dit qu'on implémente cette fonction ou qu'on réalise son implémentation.

Par exemple, ci-dessous, on a implémenté la fonction max en utilisant une sélection if...else :

// PRE: /
// POST: renvoie le plus grand des deux entiers a et b.
int max(int a, int b) {
    if (a > b) {
        return a;
    } else {
        return b;
    }
}

Dans une fonction, vous pouvez utiliser n'importe quelle instruction que vous avez déjà vue, y compris des appels à d'autres fonctions.

La seule instruction supplémentaire que vous n'avez pas encore vue en détail est l'instruction return qui permet de renvoyer une valeur. Cette instruction est obligatoire dans les fonctions qui ne sont pas de type void et qui doivent donc renvoyer une valeur. Elle termine immédiatement l'exécution de la fonction et renvoie la valeur indiquée donc il faut faire attention à ne pas mettre du code que l'on souhaite exécuter après cette instruction car ce code ne sera jamais exécuté. Voici un petit exemple pour illustrer cela :

Récursivité

En mathématiques, il existe des fonctions dites récursives qui sont définies en fonction d'elles-mêmes. Par exemple, la fonction factorielle est définie comme suit :

\begin{equation*} fact(n) = \begin{cases} 1 & \text{si } n = 0 \\ n \times fact(n-1) & \text{si } n > 0 \end{cases} \end{equation*}

C'est un comportement qui existe aussi en programmation et qui est appelé récursivité. Une fonction est dite récursive si elle s'appelle elle-même dans son corps. Par exemple, voici une implémentation récursive de la fonction factorielle en C :

Comme vous pouvez le voir, un nouvel environnement d'exécution est créé à chaque appel récursif donc chaque appel a ses propres paramètres et variables locales.

ATTENTION!
Il est important de bien définir un cas de base (n == 0 dans le cas de la factorielle) car sinon, il va y avoir une récursion infinie qui fera crasher votre programme.
Information

La récursivité est un concept fondamental qui est utilisé dans les cours :

Les approches d'abstraction procédurale

Il y a deux approches que l'on peut adopter pour structurer un programme selon le principe d'abstraction procédurale, donc en créant des fonctions. Il est cependant important de noter que ce sont des approches théoriques et qu'il existe un spectre entre les deux dans la pratique. En fonction des problèmes à résoudre, on peut se retrouver à utiliser une approche pour une partie du code et l'autre approche pour une autre partie.

Spécification d'abord, implémentation après (top-down)

C'est l'approche recommandée quand vous devez écrire un programme depuis zéro. L'idée de cette approche est de décomposer le problème en sous-problèmes puis d'écrire les spécifications des fonctions qui vont permettre de résoudre ces sous-problèmes, avant de les implémenter.

Pour ce faire, vous pouvez utiliser du pseudo-code pour vous donner une idée de l'algorithme que vous allez implémenter ainsi que des sous-problèmes que vous allez devoir résoudre. Ensuite, vous allez pouvoir spécifier les fonctions qui vont résoudre ces sous-problèmes en écrivant leurs préconditions et postconditions. Enfin, il sera possible d'implémenter ces fonctions en convertissant le pseudo-code en code C.

Implémentation d'abord, refactoring après (bottom-up)

C'est une approche qui est parfois utile quand vous voulez créer un prototype rapide et que vous n'avez pas beaucoup de temps ou d'expérience avec le problème à résoudre. L'idée de cette approche est d'implémenter rapidement un programme fonctionnel mais qui n'est pas forcément très bien structuré pour ensuite faire du refactoring, c'est-à-dire réorganiser le code pour le rendre plus lisible et mieux structuré notamment en créant des fonctions pour extraire les fragments de code qui se répètent. C'est une approche qui est plus risquée car si l'étape de refactoring n'est jamais faite, le code peut devenir très difficile à lire et à maintenir.

Bonnes pratiques

Une seule responsabilité par fonction

Le principe de responsabilité unique est une bonne pratique qui nous dit qu'une fonction doit avoir une seule responsabilité. Cela signifie qu'une fonction doit faire une seule chose et la faire bien plutôt que de faire plusieurs choses à la fois, ce qui peut rendre le code plus difficile à lire et à maintenir. Une bonne règle de base est que si vous avez du mal à décrire ce que fait une fonction en une seule phrase, c'est probablement qu'elle fait trop de choses. Dans ce cas, il est souvent possible de diviser la fonction en plusieurs fonctions plus petites. Il est important de noter que faire "une seule chose" peut avoir plusieurs significations en fonction du niveau d'abstraction que l'on prend. Par exemple, si on prend le cas d'un jeu d'échecs, on pourrait avoir les fonctions suivantes :

  • Abstraction basse : une fonction pour déplacer une pièce, une fonction pour capturer une pièce, une fonction pour vérifier si un roi est en échec, etc.
  • Abstraction moyenne : une fonction pour jouer un tour, une fonction pour vérifier si le jeu est terminé, etc.
  • Abstraction haute : une fonction pour jouer une partie, une fonction pour gérer le score, etc.

Identifiants et paramètres explicites

Comme pour les variables, il est important de choisir des noms de fonctions et de paramètres qui sont explicites et qui donnent une idée claire de ce que font les fonctions. En particulier, quand votre fonction utilise des valeurs qui ont des unités physiques comme des mètres, des secondes, des kilogrammes, etc., il peut être utile d'inclure ces unités dans le nom de la fonction ou des paramètres. Par exemple, si vous avez une fonction qui calcule une vitesse moyenne en mètres par seconde sur base d'une distance en mètres et d'une durée en secondes, vous pourriez l'appeler vitesse_moyenne_mps et avoir des paramètres distance_m et temps_s.

De plus, il est préférable de bien choisir l'ordre des paramètres, notamment en mettant les paramètres d'entrée avant les paramètres de sortie.

Refactoring

Même en essayant de spécifier tout à l'avance et de suivre l'approche top-down, il n'est souvent pas possible de tout prévoir et donc il est possible que vous vous retrouviez avec du code qui se répète ou des fonctions qui font trop de choses. Dans ce cas, il est important de faire du refactoring pour améliorer la structure du code.

Gardes

Dans certains cas, notamment quand vous avez besoin de gérer des erreurs, il est possible d'utiliser le fait que l'instruction return termine immédiatement l'exécution d'une fonction pour créer des gardes qui permettent de sortir rapidement d'une fonction si une condition n'est pas remplie. Cela permet de réduire le niveau d'indentation de certains fragments de code et de rendre le code plus lisible. Voici un petit exemple pour illustrer cela :

Sans garde
// PRE: /
// POST: divise a par b et renvoie le résultat si b n'est pas nul, affiche un message d'erreur et renvoie 0 sinon.
int safe_divide(int a, int b) {
    int result;
    if (b != 0) {
        result = a / b;
    } else {
        printf("Erreur : division par zéro\n");
        result = 0;
    }
    return result;
}
Avec une garde
// PRE: /
// POST: divise a par b et renvoie le résultat si b n'est pas nul, affiche un message d'erreur et renvoie 0 sinon.
int safe_divide(int a, int b) {
    if (b == 0) {
        printf("Erreur : division par zéro\n");
        return 0;
    }

    return a / b;
}

Pas de variables globales

Il est possible de déclarer des variables globales qui sont accessibles depuis n'importe quelle fonction du programme, par exemple :

#include <stdio.h>

int compteur = 0;  // Variable globale

void incrementer(void) {
    compteur += 1;
}

int main(void) {
    incrementer();

    printf("Compteur : %d\n", compteur);  // Affiche "Compteur : 1"

    return 0;
}

Cependant, il est fortement déconseillé d'utiliser ces variables globales car elles rendent le code plus difficile à comprendre et à maintenir. Il est donc préférable d'utiliser des paramètres et des valeurs de retour pour partager des valeurs entre les fonctions, par exemple :

#include <stdio.h>

void incrementer(int *compteur) {
    *compteur += 1;
}

int main(void) {
    int compteur = 0;  // Variable locale

    incrementer(&compteur);

    printf("Compteur : %d\n", compteur);  // Affiche "Compteur : 1"

    return 0;
}

Ces variables globales ont aussi le problème d'avoir une portée sur le programme entier. Il est donc possible de déclarer une variable locale dans une fonction sans remarquer qu'elle a le même nom qu'une variable globale. Cela ne pose pas de problème car la variable globale sera juste masquée mais c'est déconseillé car cela peut prêter à confusion, par exemple :

#include <stdio.h>

int compteur = 0;  // Variable globale

int main(void) {
    int compteur = 1;  // Variable locale (qui masque la variable globale)

    printf("Compteur : %d\n", compteur);  // Affiche "Compteur : 1"

    return 0;
}

Division en modules et bibliothèques

En plus de diviser le code en fonctions, on peut aussi le diviser en modules et bibliothèques afin de mieux organiser le code.

Module

Un module est composé d'un fichier source .c qui contient du code et d'un fichier d'en-tête .h qui expose des déclarations pour utiliser ce code. Le fichier source contient le même genre de code que vous avez déjà vu à l'exception qu'il ne contient pas de fonction main. Par exemple, dans la section précédente, nous avions utilisé un module utils qui était composé des fichiers utils.c et utils.h et qui contenait les fonctions est_premier et somme_chiffres. Le fichier utils.c contenait l'implémentation des fonctions et le fichier utils.h contenait les déclarations des fonctions pour pouvoir les utiliser dans d'autres fichiers. Un point important est que le fichier d'en-tête doit être inclus dans le fichier source pour que le compilateur puisse vérifier que les déclarations et les définitions correspondent. Cela donnait donc le code suivant dans le fichier utils.c :

#include "utils.h"

#include <stdbool.h>

bool est_premier(int nb) {
    int i = 2;

    while (nb > 1 && nb % i != 0) {
        i++;
    }

    return nb == i;
}

int somme_chiffres(int nb) {
    int somme = 0;

    while (nb > 0) {
        somme += nb % 10;
        nb = nb / 10;
    }

    return somme;
}

Pour utiliser un module, il faut le compiler en un fichier objet .o puis lier ce fichier objet avec le programme principal qui contient la fonction main. Par exemple, si on a un projet composé d'un programme principal main.c et d'un module utils :

main.c
utils.c
utils.h

On peut compiler le module et lier son fichier objet au programme principal avec les commandes suivantes :

gcc -c utils.c -o utils.o  # Compile le module "utils" en un fichier objet "utils.o"
gcc main.c utils.o -o main  # Compile et lie le programme principal "main.c" avec le fichier objet "utils.o" pour créer l'exécutable "main"

Techniquement, le fichier main.c peut aussi être compilé en un fichier objet avant d'être lié avec le module utils :

gcc -c utils.c -o utils.o  # Compile le module "utils" en un fichier objet "utils.o"
gcc -c main.c -o main.o  # Compile le programme principal "main.c" en un fichier objet "main.o"
gcc main.o utils.o -o main  # Lie le fichier objet "main.o" avec le fichier objet "utils.o" pour créer l'exécutable "main"

On peut aussi tout faire en une seule commande :

gcc main.c utils.c -o main  # Compile le programme principal "main.c" et le module "utils" puis lie les deux pour créer l'exécutable "main"
Bibliothèque

Une bibliothèque est un ensemble de modules qui sont compilés en un seul fichier et qui peut être liée avec un programme. Par exemple, si on veut créer une bibliothèque better_math composée de deux modules arithmetic et geometry :

arithmetic.c
arithmetic.h
geometry.c
geometry.h

On peut compiler les deux modules en fichiers objets puis créer une bibliothèque .so avec la commande suivante :

gcc -c arithmetic.c -o arithmetic.o  # Compile le module "arithmetic" en un fichier objet "arithmetic.o"
gcc -c geometry.c -o geometry.o  # Compile le module "geometry" en un fichier objet "geometry.o"
gcc -shared arithmetic.o geometry.o -o libbetter_math.so  # Crée la bibliothèque "libbetter_math.so" à partir des fichiers objets

Pour l'instant, vous avez surtout utilisé la bibliothèque standard du langage C qui est fournie avec le compilateur et qui contient les modules stdio, stdbool, etc. Dans ce cours, vous n'avez pas besoin d'utiliser d'autres bibliothèques et vous n'avez pas à en créer vous-mêmes mais sachez que dans la pratique, c'est très courant d'en créer ou de réutiliser du code qui a déjà été écrit par d'autres personnes en utilisant des bibliothèques.


Question 1: Plus de concepts liés à une fonction
// PRE: exposant >= 0
// POST: renvoie base^exposant.
double puissance(double base, int exposant) {
    double puis = 1.0;

    for (int i = 0; i < exposant; i++) {
        puis *= base;
    }

    return puis;
}

Étant donné le programme ci-dessus, associez chaque élément présent avec le nom qui lui correspond :

Question 2: Valeur de retour

Une fonction renvoie toujours une valeur.

Question 3: Type de retour

Quelle instruction utilise-t-on, généralement à la fin d'une fonction, pour indiquer la valeur renvoyée par cette fonction ?

Question 4: Types de retour et d'arguments

Associez chaque en-tête de fonction avec le corps qui correspond, de sorte que les types de retour et d'arguments formels soient corrects.

ATTENTION!
Certains corps peuvent correspondre à plusieurs en-têtes différentes. Veillez à ne sélectionner que des en-têtes différentes pour chacune des réponses !
Question 5: Récursivité

Est-ce qu'il est possible qu'une fonction récursive fasse crasher un programme ?

Question 6: Environnement d'exécution

Quand une fonction est appelée, un nouvel environnement d'exécution est créé. Que contient cet environnement ?

Question 7: Bonnes pratiques

Lesquelles de ces affirmations sont vraies quand on écrit des fonctions ?

Question 8: Fonction 1
int fonction(int n) {
    int r = 1;
    while (n > 0) {
        r *= n;
        n--;
    }
    return r;
}

Dans le programme ci-dessus, que calcule cette fonction ? (un nom est attendu, pas un identifiant)

Question 9: Fonction 2
double fonction(double n) {
    if (n < 0) {
        n = -n;
    }
    return n;
}

Dans le programme ci-dessus, la fonction calcule et renvoie la ... de n :

Question 10: Fonction 3
double fonction(double a, int b) {
    int r = 1;
    while (b > 0) {
        r *= a;
        b--;
    }
    return r;
}

Dans le programme ci-dessus, la fonction calcule a ... b :

Question 11: Fonction 4
int fonction(int a, int b) {
    int r = 0;
    while (a <= b) {
        r += a;
        a++;
    }
    return r;
}

Dans le programme ci-dessus, la fonction calcule la ... des entiers entre a et b :