Retour de fonctions ou exceptions ?

| Aaron Lahman | Guillaume Belz |

Traducteur

Alexandre Laurent

Souvent, vous avez hésité entre l'utilisation d'une simple valeur de retour ou d'une exception. Pire, vous trouvez que la mise en place d'un code prévenant toutes erreurs est lourde. Cet article est là pour vous aider à apprendre comment écrire un code simple et sain.

Commentez

- Titre : Retour de fonctions ou exceptions ?

- Auteurs : Aaron Lahman, Guillaume Belz

- Traducteur : Alexandre Laurent

- Parution : 25 juin 2012

- Public visé :

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2013 Aaron Lahman. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Article original

- II. Introduction

- III. Retours de fonction

- IV. Exceptions (ancienne approche)

- V. Exceptions (approche moderne)

- VI. Pensées finales

- VII. Divers

I - Article original

Cet article est la traduction du billet Return-code vs. Exception handling par Aaron Lahman.

II - Introduction

C'est une des guerres de religion dans la théorie des langages de programmation : faut-il choisir de retourner un code d'erreur (cette méthode sera appelée par la suite RCH, pour Return Code Handling) ou utiliser une exception (appelée par la suite EH, pour Exception Handling) ? Premièrement, mon point de vue est biaisé. Deuxièmement, j'essaierai de rester le plus objectif possible, cloisonnant mes observations sur du code utilisant ces mécanismes en fonction de ce que je pense de leur utilité. En général, je préfère utiliser les exceptions et je suis l'adage disant que « les exceptions sont à limiter aux choses exceptionnelles ».

Cela étant dit…

Raymond Chen a écrit, il y a quelque temps, un article sur le sujet. Il y indique avec exactitude quelques faits sur la revue de code suivant l'une ou l'autre approche, concluant qu'il est généralement plus difficile de déterminer la fiabilité d'un code utilisant les retours de fonctions, que celle d'un code utilisant les exceptions pour propager les erreurs(1).

Il a raison. Et il a tort. Son article est incomplet. Commençons par définir le terrain de jeu.

III - Retours de fonction

Ci-dessous, nous avons deux versions d'un code utilisant la valeur de retour de fonction. Toutes les erreurs sont communiquées grâce à un code de retour. La version de gauche est un exemple erroné, les chemins d'erreurs ne sont pas pris en compte. La version de droite corrige cette faute.

BOOL ComputeChecksum(LPCTSTR pszFile, DWORD* pdwResult)
{
HANDLE h = INVALID_HANDLE;
HANDLE hfm = INVALID_HANDLE;
void *pv;
DWORD dwHeaderSum;

h = CreateFile(...);

hfm = CreateFileMapping(h, ...);

pv = MapViewOfFile(hfm, ...);

CheckSumMappedFile(... &dwHeaderSum, pdwResult);

UnmapViewOfFile(pv);
CloseHandle(hfm);
CloseHandle(h);
return TRUE;
}

BOOL ComputeChecksum(LPCTSTR pszFile, DWORD* pdwResult)
{
HANDLE h = INVALID_HANDLE;
HANDLE hfm = INVALID_HANDLE;
void *pv = NULL;
DWORD dwHeaderSum;
BOOL result = FALSE;

if ( (h = CreateFile(...)) == INVALID_HANDLE)
goto cleanup;
if ( (hfm = CreateFileMapping(h, ...)
== INVALID_HANDLE ) )
goto cleanup;
if ( (pv = MapViewOfFile(hfm, ...)) == NULL )
goto cleanup;

CheckSumMappedFile(... &dwHeaderSum, pdwResult);
result = TRUE;

cleanup:
if (pv) UnmapViewOfFile(pv);
if (hfm != INVALID_HANDLE) CloseHandle(hfm);
if (h != INVALID_HANDLE) CloseHandle(h);
return result;
}

Le code de gauche semble très propre mais ne vérifie pas les retours de fonction. Donc, selon la méthode de Chen, il est indubitablement incorrect. Toujours selon Chen, le code de gauche est « très facile » à écrire et il est « très facile » de remarquer qu'il est incorrect. L'absence de if toutes les deux lignes est un signal d'alarme. En gros, vous devez tester tous les chemins(2) que ce soit de réussite ou d'échec, même les cas non intéressants.

Passons au code d'exemple utilisant les exceptions du billet de Chen.

IV - Exceptions (ancienne approche)

Ci-dessous se trouve une adaptation du code de son article en utilisant les exceptions. Le code de droite est une correction possible du code de gauche.

NotifyIcon* CreateNotifyIcon()
{
NotifyIcon* icon;

icon = new NotifyIcon();

icon->set_text("Blah blah blah");
icon->set_icon(new Icon(...), GetInfo());
icon->set_visible(true);

return icon;
}

NotifyIcon* CreateNotifyIcon()
{
NotifyIcon* icon = 0;

icon = new NotifyIcon();
try {
icon->set_text("Blah blah blah");
icon->set_visible(true);
Info info = GetInfo();
icon->set_icon(new Icon(...), info);
} catch (...) {
delete icon; throw;
}
return icon;
}

Je suis en accord avec n'importe quelle critique disant que le code ci-dessus est difficile à diagnostiquer. Si l'une des fonctions set_text, set_icon ou set_visible peut lancer une exception, le code de gauche est totalement fichu. Le code sur la droite est délicat… en déplaçant GetInfo() sur une nouvelle ligne et set_icon à la fin, l'objet Icon ne peut pas provoquer de fuites (en supposant que set_icon soit responsable de la libération de Icon en cas d'erreur). Le try-catch supprimera NotifyIcon et propagera l'exception, mais tout cela rend le programme pénible à déboguer : nous allons voir deux exceptions au lieu d'une seule.

Encore pire, demain, ce code pourrait devenir incorrect. Si le constructeur de copie de Info envoie une exception, le code de droite est toujours faux. Je parie que vous n'y aviez pas pensé. Vous croyez être un expert en cela ? Essayez le point numéro 18 du livre Exceptional C++. Je pense que la première fois, j'ai trouvé 16 parcours d'exécution possibles dans trois lignes de code, me faisant entrer dans la catégorie 'Guru', mais cela m'a pris quinze minutes(3). Normalement, vous n'avez que cinq secondes dans une revue de code et vous ne trouverez donc pas les vingt-trois possibilités d'exécution. Vous avez mieux à faire que d'analyser toutes les possibilités.

Si on applique les critères de Raymond Chen, avoir un code correct est 'très dur' et j'espère que mon auditoire est d'accord avec moi. C'est compliqué. N'importe quelle ligne de code, n'importe quelle sous-expression peut lancer une exception et nous devons surveiller toutes les possibilités. La méthode utilisant les retours de fonction semble meilleure. Bien sûr, nous aurons quelque chose comme neuf if pour l'exemple ci-dessus, mais au moins, on verrait que toutes les possibilités d'exécution sont couvertes. Cependant, nous ne pouvons appliquer aucune règle pour voir si le code est trivialement incorrect. Dans la méthode utilisant les retours de fonction, une ligne sans branchement conditionnel est suspecte. Pour la méthode utilisant les exceptions (ancienne méthode), une ligne sans try-catch après une acquisition de ressources est suspecte.

J'appelle cela « approche ancienne » car cela ressemble beaucoup à la façon de procéder en C# ou en C++ des années 1990, sans le support de bibliothèques. Stephan T. Lavavej distingue le C++ 'archaïque' et le C++ 'moderne' et Raymond Chen note à la fin : « Oui, il y a des modèles de programmation comme le RAII et les transactions, mais vous voyez rarement un code d'exemple les utilisant. ». Corrigeons cela.

V - Exceptions (approche moderne)

Sur la gauche, nous avons un mauvais code utilisant les exceptions et le RAII pour reprendre l'exemple de Chen. Sur la droite, la version corrigée :

shared_ptr<NotifyIcon>
CreateNotifyIcon()
{
NotifyIcon*
icon( new NotifyIcon() );

icon->set_text("Blah blah blah");

icon->set_icon(
shared_ptr<Icon>( new Icon(...) ),
GetInfo());

icon->set_visible(true);

return icon;
}

shared_ptr<NotifyIcon>
CreateNotifyIcon()
{
shared_ptr<NotifyIcon>
icon( new NotifyIcon() );

icon->set_text("Blah blah blah");

shared_ptr<Icon>
inner( new Icon(...) );
icon->set_icon(inner, GetInfo());

icon->set_visible(true);

return icon;
}

Je change les règles. Je ne me soucie plus du nombre de branchements, que ce soit lors d'échecs ou de réussites. Par contre, je me préoccupe énormément du fait qu'à chaque instant, il n'y a qu'un seul propriétaire pour chaque ressource et qu'en cas d'échec, ce propriétaire gérera la ressource.

C'est un changement fondamental. L'approche moderne utilisant le RAII n'a pas toujours un code explicite pour les conditions d'échecs. Les échecs courants sont toujours gérés non localement. La ressource locale est immédiatement prise en charge par des objets automatiques, grâce au RAII(4), c'est-à-dire qui seront détruits à la sortie du bloc. N'importe quelle ressource brute (comme l'allocation d'un nouvel objet) doit toujours être déléguée à un objet RAII.

Détaillons ce que j'ai trouvé de mauvais dans l'exemple de gauche :

- à la première ligne, nous avons un pointeur nu, mauvais. Règle du RAII : pointeur nu = code suspect ;

- à la deuxième ligne, set_text. Aucune ressource brute, bien ;

- à la troisième ligne, shared_ptr est mélangé avec un autre appel, mauvais. À déplacer sur une autre ligne ;

- à la dernière ligne, set_visible, également correct.

Comment savons-nous que nous avons fini ? Lorsqu'il n'y a que deux sortes de lignes : les initialisations d'objets RAII et le code utile qui ne crée pas de ressources non encapsulées. Maintenant, il est vrai qu'il est dur d'utiliser correctement des classes RAII dès le début, mais cela doit être fait seulement une fois par classe au lieu d'une fois par utilisation.

Bien qu'il soit compliqué de voir toutes les possibilités d'exécution, il devient vraiment facile de vérifier si le développeur a terminé avec l'utilisation des exceptions. N'importe quelle allocation de ressource sans RAII dira que le développeur n'a pas terminé. Bien sûr, un mauvais code utilisant les retours de fonction apparaît comme une rose dans un jardin de mauvaises herbes. Mais avec le RAII, vous pouvez normalement éviter d'écrire du mauvais code dès le départ : passez directement aux shared_ptr<T> ou shared_factory<HANDLE, close_handle>::type ou score_guard<T>, etc. Vous pouvez ainsi éviter d'écrire du mauvais code et gérer toutes les erreurs avec le RAII(5).

VI - Pensées finales

Nous avons donc un nouveau tableau pour reconnaître le code :

Différences entre bon et mauvais code utilisant RCH

- Y a-t-il un if pour chaque appel de fonction qui peut échouer ?
- Y a-t-il un bloc de nettoyage et des goto ?
- Est-ce que chaque ressource est libérée dans le bloc de nettoyage ?

Différences entre bon et mauvais code utilisant EH (ancienne approche)

- Quelles lignes peuvent lancer une exception ?
Le nombre de throws = le nombre de if dans RCH.
- Y a-t-il un bloc catch-rethrow pour chaque ressource nécessitant d'être libérée ?
- Assurez-vous qu'il n'y ait pas de 'goto' : ils cassent les destructeurs C++.

Différences entre bon et mauvais code utilisant EH (approche méthode)

- Est-ce que les objets natifs sont directement stockés dans des objets RAII ?
- Est-ce que l'initialisation RAII est effectuée sur une ligne distincte ?
- Suspectez toute utilisation de delete, ou l'utilisation d'un new pour les tableaux (préférez un vector à la place).

Enfin, voici mon avis sur comment une utilisation moderne des exceptions et du RAII rentre dans le tableau de Raymond Chen :

Domaine

Facile

Difficile

Très difficile

Faire du mauvais code avec RCH ?

X

Différenciation bon code mauvais code ?

X

Faire du bon code avec RCH ?

X

Faire du mauvais code avec les EH (ancienne approche)

X

Différenciation bon code mauvais code ?

X

Faire du bon code avec EH (ancienne approche) ?

X

Faire du mauvais code avec EH (approche moderne)

X

Différenciation bon code mauvais code ?

X

Faire du bon code avec EH (approche moderne) ?

X

Finalement, voici mon code moderne utilisant les retours de fonction ou les exceptions.

HRESULT
CreateNotifyIcon(NotifyIcon** ppResult)
{
NotifyIcon*      icon = 0;
Icon*            inner = 0;
const wchar_t *  tmp1 = 0;
HRESULT          hr = S_OK;

if ( SUCCEEDED(hr) ) {
icon = new (nothrow) NotifyIcon();
if ( !icon ) hr = E_OUTOFMEM;
}

if ( SUCCEEDED(hr) )
hr = icon->set_text("Blah blah blah");

if ( SUCCEEDED(hr) ) {
inner = new (nothrow) Icon(...);
if ( !inner )
hr = E_OUTOFMEM;
else {
Info info;
hr = GetInfo( &info );

if ( SUCCEEDED(hr) )
hr = icon->set_icon(inner, info);
if ( SUCCEEDED(hr) )
inner = NULL;
}
}
if ( SUCCEEDED(hr) )
hr = icon->set_visible(true);

if ( SUCCEEDED(hr) ) {
*ppResult = icon;
icon = NULL;
} else {
*ppResult = NULL;
}

cleanup:
if ( inner ) delete inner;
if ( icon ) delete icon;
return hr;
}

shared_ptr<NotifyIcon>
CreateNotifyIcon()
{
shared_ptr<NotifyIcon> icon;
shared_ptr<Icon>       inner;

icon.reset( new NotifyIcon() );
icon->set_text("Blah blah blah");

inner.reset( new Icon(...) );
icon->set_icon(inner, GetInfo());
icon->set_visible(true);

return icon;
}

(6)

VII - Divers

Merci à gbdivers et Luc Hermitte pour leur aide lors de la traduction de cet article. Merci à Gurdil le nain, jacques_jean, FirePrawn et ClaudeLELOUP pour leur relecture orthographique !

(1)Note de traduction : Raymond Chen conclut en fait toute autre chose : qu'il est plus simple de reconnaître un code non robuste quand il suit l'approche des RCH. En effet, un tel code ne présente pas un if toutes les deux lignes. Il est donc indubitablement mauvais.

(2)Note de traduction : on appelle un chemin, chaque cas d'exécution possible du code

(3)Note de traduction : l'exercice 18 consiste à trouver le maximum de chemins d'exécution dans un code de trois lignes. Selon votre résultat, vous appartenez aux catégories suivantes : « Average » pour moins de trois, « Exception-aware » pour un score de quatre à quatorze et « Guru material » pour un score de quinze à vingt-trois

(4)Note de traduction : « automatique » réfère ici au type de stockage de l'objet (automatique, statique, dynamique et thread local en C++11, FDIS : §3.7.3)

(5)Certains remarqueront qu'auto_ptr (ou unique_ptr en C++0x) peut s'avérer plus efficace et s'autoconvertit sans risques en shared_ptr. C'est une alternative possible. N'utilisez juste pas de pointeurs nus et assurez-vous de relire le chapitre sur les auto_ptr du livre Effective C++.

(6)Note du traducteur : dorénavant, l'utilisation de std::make_shared est à privilégier.