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