Arguments par défaut d'une fonction en C++

Il y a une multitude de choses qui font que le C++ est plus que le C. Aujourd'hui, je vais vous parler des arguments par défaut pour une fonction. Quand j'ai commencé le C++ en venant du C et du Java, je trouvais ça super ! Quelques années plus tard, je suis plus mitigé. Dans cet article, je vous présente les bases de cette fonctionnalité, j'essaye de peser le pour et le contre, et je vais vous montrer comment ça marche sous le capot.


Principe

cppreference nous dit que les arguments par défaut permettent l'appel d'une fonction en ne spécifiant pas tous ses arguments :

Allows a function to be called without providing one or more trailing arguments.

Le principe est simple : un ou plusieurs paramètres d'une fonction peuvent avoir une valeur par défaut et le compilateur utilisera ces valeurs comme arguments si on ne les précise pas. Voici un exemple tout simple :

#include <iostream>
#include <string>

void hello(std::string who = "world")
{
    std::cout << "hello, " << who << '\n';
}

int main()
{
    hello("it's me");
    hello();
}

Sortie en console :

hello, it me
hello, world

Si vous séparez la déclaration de la définition, vous ne pouvez pas spécifier les valeurs par défaut deux fois :

#include <iostream>
#include <string>

// Déclaration (souvent dans un hpp)
void hello(std::string who = "world");

// Définition (souvent dans un cpp)
void hello(std::string who = "world")
{
    std::cout << "hello, " << who << '\n';
}

int main()
{
    hello("it's me");
    hello();
}

Ce code compile avec les erreurs suivantes :

$ g++ -std=c++17 -Wall -Wextra example_2.cpp
example_2.cpp:8:37: error: default argument given for parameter 1 of 'void hello(std::__cxx11::string)' [-fpermissive]
 void hello(std::string who = "world") {
                                     ^
example_2.cpp:5:6: note: previous specification in 'void hello(std::__cxx11::string)' here
 void hello(std::string who = "world");
      ^~~~~

Quelques remarques en vrac :

  • Il est possible d'avoir plusieurs arguments par défaut.
  • Les paramètres avec arguments par défaut doivent être précisés après les paramètres obligatoires.
  • Lors d'un appel, on peut expliciter tout ou une partie des arguments par défaut.

Pros & Cons

C'est toujours difficile de rester objectif quand on joue au jeu des "pros & cons", mais je vais faire de mon mieux.

Pros

Les arguments par défaut sont très bien quand les valeurs utilisées pour certains paramètres sont presque toujours les mêmes. Les arguments par défaut permettent à tout le monde de simplifier son code, tout en permettant à certains de modifier le "comportement par défaut".

Un exemple typique, inspiré de PySerial, est l'ouverture d'un port série :

struct Serial {
    void open(unsigned int baudrate,
            bytesize size=bytesize::EIGHTBITS,
            parity parity=Parity::NONE,
            stopbits stop=stopbits::ONE);
};

En général, on précise le baudrate et on utilise les arguments par défaut parce que la plupart des ports séries fonctionne avec ces valeurs. Il y a alors 2 écritures possibles, elles font exactement la même chose :

Serial serial;
serial.open(115200);
serial.open(115200, Serial::bytesize::EIGHTBITS, Serial::parity::NONE, Serial::stopbits::ONE);

D'une certaine manière, ça fait penser à de la surcharge de fonctions. En effet, on aurait pu avoir :

struct Serial {
    void open(unsigned int baudrate);
    void open(unsigned int baudrate, bytesize size, parity parity, stopbits stop);
};

Il y a quand même une différence de taille : avec une telle version de la classe Serial, on doit spécifier tous les arguments. On ne peut pas écrire :

Serial serial;
serial.open(115200, Serial::bytesize::SEVENBITS); // erreur : ne correspond à aucune des surcharges !

On voit donc que les arguments par défaut peuvent apporter de la souplesse et permettre d'alléger notre code.

Cons

Déclaration vs définition

L'impossibilité de répéter les valeurs par défaut entre la déclaration et la définition (dont nous avons parlé plus haut) peut se révéler pénible : il y a forcément un des 2 endroits où on ne voit pas les valeurs par défaut, ni même simplement que certains paramètres sont optionnels !

Rester explicite

Le point important est l'attention qu'il faut porter à la sémantique et au caractère explicite. Voici un code que j'ai fait à mes débuts en C++ et que j'ai regretté. Il s'agissait d'une classe NavigationManager pour naviguer entre les écrans d'une IHM embarquée, avec une fonction membre comme ceci :

void goToNextScreen(bool saveCurrentScreenToHistory = true);

Le problème est qu'on s'est retrouvés avec 3 variantes pour appeler cette fonction :

navigation.goToNextScreen();
navigation.goToNextScreen(true);
navigation.goToNextScreen(false);

A la longue, on s'est rendu compte de trois choses :

  1. L'action par défaut n'était pas forcément logique.
  2. Personne ne savait quelle était l'action par défaut (voir même qu'il y en avait une).
  3. Le paramètre booléen n'était pas du tout explicite.

Une bien meilleure solution aurait été :

enum class Action {
    SAVE_TO_HISTORY, DISCARD
}

void goToNextScreen(Action action); // sans valeur par défaut

Une sémantique forte et explicite est essentielle à tout bon code. Les arguments par défaut sont forcément non explicites, ce qui nous oblige à redoubler de vigilance pour converser une bonne sémantique.

Le grand bazar des redéclarations

Il est possible de déclarer plusieurs fois une fonction en C++. Et à chaque déclaration, on peut rajouter ou enlever des arguments par défaut. Ce qui est sujet à beaucoup d'erreurs de compilation très fun... La page cppreference vous donne tous les détails, mais voici un code qui résume assez bien le bazar que ça peut causer :

#include <iostream>
#include <string>

void function(std::string, int = 42); // un seul argument par défaut

int main()
{
    function("the answer is"); // c'est cohérent avec la déclaration

    void function(std::string, int); // il n'y a plus d'argument par défaut

    // function("the answer is still"); // cette ligne ne compile pas...
    // error: too few arguments to function 'void function(std::__cxx11::string, int)'

    void function(std::string = "the answer is again : ", int = 42); // allez on remet des arguments par défaut \o/

    function(); // c'est cohérent avec la dernière déclaration
}

void function(std::string message, int value)
{
    std::cout << message << " : " << value << '\n';
}

Il compile et affiche :

the answer is : 42
the answer is again : 42

Surprise avec la virtualité

Vous vous êtes déjà posé la question de savoir si on pouvait avoir un argument par défaut sur une fonction virtuelle ?

C'est possible.

Ça fait des trucs très bizarres :

#include <iostream>
#include <string>

struct Base {
    virtual void f(std::string s = "BASE") {
        std::cout << "[Base] " << s << '\n';
    }
};

struct Derived : Base {
    virtual void f(std::string s = "DERIVED") {
        std::cout << "[Derived] " << s << '\n';
    }
};

int main() {
    Base base;
    base.f();

    Derived derived;
    derived.f();

    Base& reference = derived;
    reference.f();
}

Ce code compile parfaitement avec -Wall -Wextra et affiche :

[Base]BASE
[Derived] DERIVED
[Derived] BASE

Les performances

Enfin, les paramètres par défaut ne sont pas gratuits et cela peut impacter la performance de votre programme de manière inattendue. La section suivante explique en détail pourquoi.


Sous le capot

Bon, le vrai but de l'article est surtout de vous montrer ce qu'il se passe sous le capot : pourquoi la surcharge de fonctions n'est pas la même chose que les paramètres par défaut, et pourquoi les paramètres par défaut peuvent vous jouer des tours niveau performance. On veut voir de l'assembleur là !

La raison est très simple : quand vous ne précisez pas un argument, cela ne veut pas dire qu'il n'est pas passé à la fonction. C'est juste que le compilateur passe la valeur par défaut à votre place. Et passer des arguments à une fonction, ce n'est pas gratuit :

  • il faut placer les valeurs dans les registres du processeur
  • s'il n'y a pas assez de registres, il faut utiliser de la pile
  • si ces paramètres sont des classes, il faut peut-être créer des copies (qui peuvent être coûteuses)

Considérons ce code et regardons comme il va se comporter sur une cible ARM 32 bits :

using number_t = long long; // 8 bytes sur arm-gcc

void process();
void process(number_t x);
void process(number_t x, number_t y);
void process(number_t x, number_t y, number_t z);

void no_default()
{
    process();
    process(11);
    process(12, 22);
    process(13, 23, 33);
}

void process_with_default(number_t x = 0, number_t y = 0, number_t z = 0);

void with_defaults()
{
    process_with_default();
    process_with_default(101);
    process_with_default(102, 202);
    process_with_default(103, 203, 303);
}

L'ABI des processeurs ARM indique que 4 registres de 32 bits servent à passer les arguments d'une fonction :

The first four registers r0-r3 (a1-a4) are used to pass argument values into a subroutine

A double-word sized type is passed in two consecutive registers (e.g., r0 and r1, or r2 and r3).

Le type number_t a une de taille 8 bytes. Il faut donc 2 registres pour passer un number_t à une fonction. Pour en passer 3, il n'y a pas assez de registres et il faut utiliser de la pile en plus.

Passons notre code dans Compiler Explorer, un outil très pratique pour obtenir l'assembleur d'un code source.

Commençons par regarder ce que donne sans arguments par défaut :

process-no-default

On constate que les appels aux surcharges de process() n'ont pas tous le même coût. Plus il y a d'arguments, plus ça coûte. La version sans paramètre se résume à un BL (branch), sans utilisation de pile ni même de registre, alors que la version avec 3 paramètres utilise les 4 registres et de la pile (avec l'instruction STM).

En revanche, avec des arguments par défaut, tous les appels ont le même coût. Il y a toujours 3 arguments réellement passés, même si on pourrait croire qu'il n'y en a aucun en lisant le code :

default 0default 1default 2 3

Comme à chaque fois qu'on parle d'optimisation et de performance, il faut rester pragmatique et prendre du recul. Si la fonction n'est quasiment jamais appelée, cela n'a pas d'impact. Mais imaginez qu'une telle fonction est appelée dans une boucle de calcule intensive, cela peut faire une différence significative.

Note : si les arguments de mon exemple ont tous des valeurs différentes, c'est pour éviter des optimisations du compilateur : il est obligé de recharger tous les registres à chaque appel puisque les valeurs ont changé. Pour la version avec arguments par défaut, j'ai séparé les analyses car il y a une optimisation possible du code montré au début de cette section, en ne chargeant qu'une seule fois la valeur 300 dans R4 et R5 pour les 3 premiers appels. Oui, un compilateur, ça optimise, c'est son boulot.


Conclusion

Au final, on peut dire que les arguments par défaut ont beaucoup de défauts : sémantique pas toujours explicite, bugs et erreurs de compilation plus ou moins subtils, des possibles surprises niveau performance. Néanmoins, il existe des situations où la lisibilité du code s'en trouve améliorée, même si l'overloading de fonctions sera souvent préférable.

Cette position est aussi celle donnée par le Google C++ Style Guide :

Default arguments are banned on virtual functions, where they don't work properly, and in cases where the specified default might not evaluate to the same value depending on when it was evaluated. (For example, don't write void f(int n = counter++);.)

In some other cases, default arguments can improve the readability of their function declarations enough to overcome the downsides above, so they are allowed. When in doubt, use overloads.

Pierre

Que la vie de Pierre, expert embarqué Younup, serait terne sans les variadic templates et les fold expressions de C++17. Heureusement pour lui, Python a tué l'éternel débat sur l’emplacement de l’accolade : "alors, en fin de la ligne courante ou en début de la ligne suivante ?"

Homme de terrain, il est aussi à l’aise au guidon de son VTT à sillonner les chemins de forêt, dans une salle de concert de black metal ou les mains dans les soudures de sa carte électronique quand il doit déboguer du code (bon ça, il aime moins quand même !)

Son vœu pieux ? Il hésite encore... Faire disparaitre le C embarqué au profit du C++ embarqué ? Ou stopper la génération sans fin d'entropie de son bureau ?

Retours aux publications