Ne partez pas du principe que les accesseurs sont rapides

Article original : You shouldn’t assume accessors are fast | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

« Les accesseurs sont censés être rapides. » J’ai entendu ce leitmotiv tellement de fois au cours de ma carrière que je ne pouvais pas ne pas en parler.

Explication

À quoi servent les accesseurs ?

En faisant des recherches pour cet article, j’ai été surprise de constater qu’il y avait une définition formelle des accesseurs1.

Vous pouvez trouverez cette définition dans la section § 18.3.5 Accessor functions de la quatrième édition de The C++ Programming Language (Bjarne Stroustrup).

Pour faire court, la definition stipule que, même s’il est déconseillé d’en abuser, vous pouvez développer une méthode pour lire ou modifier les attributs privés d’une classe pour que votre algorithme (qui a besoin de lire/écrie ces données) reste simple et clair.

Donc, en clair, les accesseurs sont une interface entre les membres privés d’une classe et l’utilisateur de cette classe.

Comme toute interface, les accesseurs peuvent masquer la réelle manière donc la classe accède aux donnée voulues, encapsulant les comportement complexe pour que la classe soit simple à utiliser.

1. Je ne discute pas ici du fait qu’il existe une définition pseudo-consensuelle des accesseurs. Comme cette article vise à déconstruire cette définition, je ne construis pas mon argumentaire dessus. Elle est cependant (implicitement) décrite dans la section suivante, où j’explique pourquoi c’est une mauvaise définition.

Les gens n’utilisent pas les accesseurs correctement…

Tous les jours je vois des gens qui utilisent les accesseurs de manière impropre. Voici quelques exemples :

  • Certaines classes ont des attributs privés mais tous ces attributs ont des accesseurs simple get et set. Si votre class rend accessible toutes ses données sans distinction ni subtilité, alors ce n’est pas une class, c’est une struct2.
  • Certains accesseurs sont déclarés, implémentés, mais jamais utilisés. N’écrivez pas une fonction dont vous n’avez pas besoin, cela pollue la codebase.
  • Certaines personnes, quand elles constatent qu’elles ont besoin d’un accesseur qui n’existe pas, l’implémentent sans réfléchir. Parfois (souvent), si un accesseur n’est pas implémenté, c’est qu’il y a une bonne raison. Réfléchissez bien quand vous faites cela.
  • Certains getters ne sont pas const alors qu’ils le devraient. Par défaut, utilisez const partout, à moins que vous ayez spécifiquement besoin que ce soit non-const.
  • Certains getters sont artificiellement rendus const alors qu’ils ne devraient pas l’être. C’est un cas très rare, mais j’ai déjà vu plusieurs fois un développeur utiliser un const-cast juste pour rendre le getter const, bien que cela ne soit (dans ce cas de figure spécifique) pertinent. Vous devez éviter les const-cast à tout prix.

Il y a pas mal d’autres mauvaises pratiques qui existent mais qui sont trop verbeuses à décrire dans un article qui n’y est pas dédié.

Tout ça pour dire que beaucoup de gens n’utilisent pas les accesseurs correctement.

2. Ici, j’utilise abusivement et volontairement les termes class pour désigner une structure de donnée avec des membres privé et struct pour désigner un simple data bucket.

…ce qui mène à de mauvaises suppositions

Les pratiques incorrectes engendrent des états d’esprit incorrects, et Les états d’esprits incorrects engendrent les mauvaises suppositions.

La mauvaise supposition la plus répandue concernant les accesseurs est la suivante :

Tous les accesseurs doivent être rapides à exécuter

Partir du principe que les accesseurs sont rapide est une contrainte inutile

Sans parler du fait que ça peut être dangereux (si vous appelez une fonction en pensant qu’elle est rapide, le jour où ce n’est pas le cas vous aurez une mauvaise surprise), en faisant cette supposition vous vous infligez une contrainte.

Cette contrainte n’est pas réellement utile (tout ce que vous avez à y gagner, c’est l’économie d’aller vérifiez la documentation de l’accesseur, documentation que vous devriez dans tous les cas consulter). Cela restreint de ce fait votre capacité à innover et faire quelque chose d’utile de cet accesseur.

Illustration avec un pattern utile

Je vais vous montrer un pattern que j’aime beaucoup car il peut, dans certains cas, s’avérer très pratique.

Considérez cela : vous devez implémenter une classe qui, selon un certain nombre d’entrées, fournit plusieurs sorties (deux dans notre exemple). Vous devez utiliser cette classe pour remplir deux tableaux avec ces sorties. Cependant, les paramètres ne varient pas toujours, donc parfois, refaire le calcul est inutile. Comme le calcul à effectuer est coûteux (en temps d’exécution), vous ne voulez pas le relancer quand c’est inutile3.

Une façon (naïve) de le faire est ainsi (code complet à https://godbolt.org/z/a4x769jra) :

class FooBarComputer
{
public:
    FooBarComputer();
 
    // These update the m_has_changed attribute if relevant
    void set_alpha(int alpha);
    void set_beta(int beta);
    void set_gamma(int gamma);
 
    int get_foo() const;
    int get_bar() const;
 
    bool has_changed() const;
    void reset_changed();
 
    void compute();
 
private:
    int  m_alpha, m_beta, m_gamma;
    int m_foo, m_bar;
    bool m_has_changed;
};
 
 
//  ...
 
 
//==============================
bool FooBarComputer::has_changed() const
{
    return m_has_changed;
}
 
void FooBarComputer::reset_changed()
{
    m_has_changed = false;
}
 
 
//==============================
// Output getters
int FooBarComputer::get_foo() const
{
    return m_foo;
}
 
int FooBarComputer::get_bar() const
{
    return m_bar;
}
 
 
//  ...
 
 
//==============================
// main loop
int main()
{
    std::vector<int> foo_vect, bar_vect;
    FooBarComputer fbc;
 
    for (int i = 0 ; i < LOOP_SIZE ; ++i)
    {
        fbc.set_alpha( generate_alpha() );
        fbc.set_beta( generate_beta() );
        fbc.set_gamma( generate_gamma() );
 
        if ( fbc.has_changed() )
        {
            fbc.compute();
            fbc.reset_changed();
        }
 
        foo_vect.push_back( fbc.get_foo() );
        bar_vect.push_back( fbc.get_bar() );
    }
}

Cependant, il est possible d’écrire une meilleure version de cette classe en modifiant la manière dont les getters sont implémentés, comme ceci (code complet à https://godbolt.org/z/aqznsr6KP) :

class FooBarComputer
{
public:
    FooBarComputer();
 
    // These update the m_has_changed attribute if relevant
    void set_alpha(int alpha);
    void set_beta(int beta);
    void set_gamma(int gamma);
 
    int get_foo();
    int get_bar();
 
private:
    void check_change();
    void compute();
 
    int  m_alpha, m_beta, m_gamma;
    int m_foo, m_bar;
    bool m_has_changed;
};
 
 
//  ...
 
 
//==============================
void FooBarComputer::check_change()
{
    if (m_has_changed)
    {
        compute();
        m_has_changed = false;
    }
}
 
 
//==============================
// Output getters
int FooBarComputer::get_foo()
{
    check_change();
    return m_foo;
}
 
int FooBarComputer::get_bar()
{
    check_change();
    return m_bar;
}
 
 
//  ...
 
 
//==============================
// main loop
int main()
{
    std::vector<int> foo_vect, bar_vect;
    FooBarComputer fbc;
 
    for (int i = 0 ; i < LOOP_SIZE ; ++i)
    {
        fbc.set_alpha( generate_alpha() );
        fbc.set_beta( generate_beta() );
        fbc.set_gamma( generate_gamma() );
 
        foo_vect.push_back( fbc.get_foo() );
        bar_vect.push_back( fbc.get_bar() );
    }
}

Voici les avantages de la seconde version :

  • Le code du main est plus concis est clair.
  • Le compute() n’a plus besoin d’être public.
  • Vous n’avez plus besoin du has_changed(), à la place vous avez check_change() mais qui est privé.
  • L’utilisateur de votre classe sera moins enclin à mal l’utiliser. Il ne pourra pas appeler le compute() à tout bout de champs, puisque celui-ci est devenu paresseux.

C’est ce dernier point qui est le plus important. N’importe quel utilisateur, dans la première version, aurait pu omettre la conditionnelle et appeler le compute() à chaque tour de boucle la rendant inefficace.

3. Si vous le souhaitez, vous pouvez imaginer que les paramètres changent environ une fois sur cent. Ainsi, il est important de ne pas refaire le calcul quand celui-ci est inutile.

N’est-il pas possible de faire autrement ?

L’argument contre cette pratique que j’ai le plus entendu est le suivant : « Et bien, pourquoi tu ne renomme pas tout simplement le getter ? Comme computeFoo() par exemple ? ».

Et bien, le getter ne fait pas toujours un compute(), c’est donc impropre de l’appeler ainsi. De plus, sémantiquement, le mot compute signifie « faire une opération », avec comme sous-entendu qu’elle ne retourne pas de valeur (au mieux un code d’erreur). Et même si certains développeurs le font, je n’aime pas utiliser ce mot ainsi.

« Dans ce cas, appelle-la computeAndGetFoo() ! »

Sauf que, une fois encore, elle ne fait pas toujours un conpute(). Si on voulait être exhaustif (ce qu’on doit toujours essayer d’être d’après moi), on devrait l’appeler sometimesComputeAndAlwaysGetFoo(), ce qui juste ridicule pour une méthode aussi simple.

« Alors vas-y, trouve un nom adéquat ! »

C’est chose faite. Ce nom est getFoo(). C’est exactement ce que ça fait : ça get le foo. Le fait que le compute() est paresseux ne change rien au fait que c’est un getter. De plus, il est mentionné en commentaire que le get peut être coûteux à exécuter, donc lit la documentation et tout se passera bien.

« Et on ne pourrait pas mettre la vérification dans compute() au lieu de dans le getter ? »

On pourrait, mais quel serait l’intérêt ? Rendre le compute() public est inutile puisqu’on a forcément besoin du getter pour accéder aux données, et on ne veut exécuter le compute() que si on s’en sert.

Il est possible de faire autrement…

À vrai dire, on peut effectivement faire autrement, ce qui est très pratique si, par exemple, on veut ajouter un getter pour lequel veut être sûr qu’il n’appellera jamais le compute() (nécessaire si vous avez besoin d’un getter constant).

Dans ce cas, je conseille d’utiliser deux noms différents pour les deux getters, car ils ne font pas tout à fait la même chose. Leur donner le même nom (avec seulement le mot-clé const comme différence) sera déroutant.

Personnellement, j’utilise cette graphie :

  • getActiveFoo()
  • getPassiveFoo()

J’aime bien cette façon d’écrire car on indique explicitement que le getter est potentiellement coûteux ou pas. De plus, cela indique implicitement que le getter passif peut vous donner une valeur périmée de foo.

En outre, quiconque tente d’appeler getFoo() se confrontera à une erreur de compilation et devra choisir entre une des deux version, le forçant à réfléchir. C’est toujours une bonne chose de forcer l’utilisateur à réfléchir.

Le plus important : propagez la bonne parole

Puisqu’il est possible de mettre du code lent dans des getters, vous trouverez forcement des développeurs qui le feront.

Le comportement le plus dangereux à adopter et d’ignorer ce fait et laisser le gens penser que les accesseurs sont rapide en exécution.

Vous devriez prévenir vos collègues que les accesseurs sont, au final, comme n’importe quelle autre méthode : ils peuvent être lent et tout le monde doit lire leur documentation avant de les utiliser.

Dans cet article, je parle surtout de temps d’exécution, mais mon propos s’applique aussi à toutes les autres formes de performances : taille mémoire, accès disque, accès réseau, etc.

Merci de votre attention et à la semaine prochaine !

Article original : You shouldn’t assume accessors are fast | Belay the C++ (belaycpp.com)
Traductrice : Chloé Lourseyre

Laisser un commentaire