Recommandations sur l'usage des exceptions en C++

Page 1

Intégrer les exceptions en C++, par Christophe Addinquy

Intégrer les exceptions en C++ Introduction Les objections qui se manifestent à l'encontre de l'usage des exceptions en C++ relèvent souvent de craintes vis à vis de cette technique ou mettent en avant des faiblesses qui lui sont inhérente et auxquelles il nous faut alors présenter des remèdes. Le texte qui suit fait réponse à un autre document préconisant des mises en œuvres des exceptions C++ que j’avais jugées en partie erronées. Hélas je n’ai plus le document d’origine, mais j’ai gardé les citations d’origine. Dans ce qui suit, j'ai essayé de mettre en avant les forces et les faiblesses des exceptions. Les forces mettent en avant les raisons pour lesquelles je souhaite voir utiliser cette technique. Les faiblesses montrent les aspects fragiles qui doivent être compensés à l'aide d'un guide de style. Ce texte contient donc des éléments utiles à la rédaction d'un tel guide de style, éléments synthétisés à partir de plusieurs sources bibliographiques citées en annexe.

Ce que j'en pense Manifestation des erreurs C'est une des forces des exception : Une exception ne peut pas être ignorée. Cela dit, l'envoi d'une exception qui arrive jusqu'à la sortie (exit avec code retour) doit être considéré comme un échec de mise en place d'une exception. Un logiciel digne de ce nom ne doit JAMAIS engendrer de sortie violente. Donc, toute exception doit être interceptée d'une façon ou d'une autre. Le fait que le mécanisme des exceptions soit spécifiquement conçu pour la remontée des erreurs donne aux développeurs un "faux sentiment de sécurité" [Cargil94], en lui laissant penser qu'envoyer une exception est une fin suffisante. L'argument qui stipule qu'on oblitère le risque d'oublier de traiter l'erreur (ce qui peut se passer avec les codes d'erreur) est une des forces réelles et majeures des exceptions.

Description fine des erreurs "Les exceptions permettent une description fine..." Qu'entends-t-on par fine ? Cette notion un peu floue regroupe plusieurs notions qui peuvent être identifiées comme suit : • Typage de l'erreur : On peut identifier le "type" d'erreur via la classe de l'exception, ainsi que par divers attributs qui peuvent être portés par cette exception (sous-type pour ne pas créer de trop importantes profusion de classes, nature, niveau de gravité, etc...)

1/9


Intégrer les exceptions en C++, par Christophe Addinquy • Contexte de l'erreur : la production d'une erreur est due à une conjonction d'états qui aboutissent à une instabilité logicielle, ou pour le moins sur un état qui rend impossible le traitement demandé. Il est important de noter que les éléments impliqués dans cette instabilité ne sont pas nécessairement regroupés dans des objets "proches". Dans ce cas, rassembler ces informations sur le lieu où l'instance de la classe d'exception sera fabriquée nous obligera à mettre en place une navigabilité possible entre des éléments qui n'ont pas de raisons de se connaitre. De ce fait, essentiellement, je suis défavorable à la constitution d'un contexte d'erreur sur le lieu où une exception est produite.

L'exception, véhicule de l'erreur Je pense qu'il faut considérer les exceptions comme un véhicule d'erreur, comme un signal. Utiliser ces exceptions pour gérer le contexte applicatif associé ou les formes de manifestations de ces erreurs (affichage, log sur disque, etc..), c'est utiliser à contre-emploi les exceptions. Ces responsabilités supplémentaires seront alors mal prises en charge. Les exceptions peuvent néanmoins véhiculer des informations non atomiques. Notons également une caractéristique importante des exceptions en tant que véhicule des erreurs : La présence d'un throw n'a aucun impact en terme de performance sur une exécution qui n'invoque pas l'envoi de l'exception. On ne paie le prix que si l'on envoie réellement l'exception. Généralement, on conçoit les classes d'exception sous une forme simple, plus rarement sous forme d'agrégats et dans ce dernier cas, ils restent d'une complexité limitée. Agréger des clases complexes entre elles pour former des classes d'exception présente un risque : celui d'assembler une ou plusieurs classes elle(s)même susceptible d'envoyer des exceptions. Ceci est bannis par le C++ : Une exception qui envoie une exception provoque l'interruption de la remontée de la pile d'appel et l'appel de la fonction terminate. Ce handler de terminaison (car c'est bien de cela qu'il s'agit) peut toutefois être changé en utilisant la fonction set_terminate(). Notons de plus qu'il est possible de savoir si à un moment donné une exception est en cours de remontée, grâce à la fonction uncaught_exception() ([Stroustrup97]). • Notons un effet pervers des exceptions : Plus on attend pour les intercepter, plus on perd la localisation précise de leur émission. Cet effet est surtout gênant lors du debug. Cet effet négatif peut être contrebalancé par des classes d'exception habilement conçues mémorisant __FILE__ et __LINE__.

Taille du code "L'usage des exceptions diminue la taille du code...". Ce n'et pas nécessairement vrai, mais surtout ce n'est pas le plus important. En réalité, on diminue surtout la complexité cyclomatique (en gros, le nombre de if ou de switch-case) des méthodes. On évite en effet des cascades de gestion des codes de retour en positionnant un ou plusieurs gestionnaires d'erreur derrière le code de traitement normal. Dans tous les cas, la clarté des méthodes est accrue, car il sépare nettement le traitement "normal", qui est celui sur lequel on veut se concentrer, du traitement 2/9


Intégrer les exceptions en C++, par Christophe Addinquy d'erreur. Quand on gère des codes de retour, le traitement normal et le traitement d'erreur sont finement imbriqués et l'on ne peut guère faire autrement.

Très peu d'exceptions ont besoin d'être traitées C'est faux ! On doit TOUTES les traiter, à un moment où à un autre. Grace au mécanisme de remontée automatique de la pile, on peut éviter de traiter une exception directement au niveau de l'appelant. Cependant, cette force constitue aussi une faiblesse : L'exception suit le chemin de la pile d'exécution (en sens inverse), qui ne peut être mappé sur la structuration objet. Plus grand est le nombre d'appel de méthodes entre l'émission de l'exception et son interception, plus grand est le risque de voir se créer de nouveaux chemins de remontée de cette exception, pour une release donnée du logiciel, mais aussi lors des modifications ultérieures de ce logiciel. Un mainteneur n'aura pas forcément l'idée de rechercher dans les tréfonds d'un package ou d'une librairie les exceptions levées (surtout si il lui faut, pour cela, lire un grand volume de code). Il en va de même des tests unitaires : ceux-ci (surtout, justement, s’ils sont unitaires) ne mettront pas en évidence des cas d’envoi d’exception provenant de contextes dépassant la portée du test. Ainsi la mise en place d'un simple appel de méthode peut créer un nouveau chemin le long duquel une ou plusieurs exception pourront remonter sans être interceptées. Localiser la propagation d'exception à une méthode ou une classe, c'est renoncer à l'une des caractéristiques importantes du mécanisme. Mais en acceptant cette propagation étendue, on doit être conscient de la difficulté que représente l'interception ([Reeves96]). La prise en compte doit aussi se faire dans des endroits aussi inattendus que l'opérateur d'affectation (lorsque l'on redéfinît cet opérateur), où une stratégie de mise à jour en deux temps doit être adoptée vis à vis des objets agrégés (si ceux-ci sont susceptibles d'envoyer des exceptions dans leur constructeur copie), sous peine d'occasionner des fuites mémoire [Gillam97]. • Une façon de réduire ce désavantage est d'instaurer l'obligation d'utiliser la liste-throw ou spécification d'exception dans la signature des méthodes. ce choix n'est pas sans impact sur le comportement du logiciel, car si une exception ne correspondant pas à la spécification traverse une méthode, la remontée est interrompue et le gestionnaire unexpected est appelé. C'est l'une des raisons pour lesquelles certains auteurs sont hostiles à son utilisation, dont [Reeves96a] qui estime qu'il s'agit là plus d'une aide à la documentation qu'une aide au debug. Ce dernier point peut-être contre-argumenté : Le gestionnaire terminate peut contenir du code qui nous aidera à identifier les chemins de remonté d'exception non identifiés ou non souhaités.

Exceptions et cycle de développement L'exception a une très forte influence sur le cycle de développement. • Il faut que celui-ci intègre, au niveau de la conception, une gestion de la complétude du traitement des exceptions de façon à ne pas laisser de chemins de remontée libre de

3/9


Intégrer les exceptions en C++, par Christophe Addinquy tout gestionnaire ([Muller96]). Un gestionnaire devant être placé à un niveau où c'est pertinent, et pas quand il est trop tard. • Il faut, au niveau des règles de développement, intégrer les contraintes associées à la remontée possible d'exceptions : • Les instances allouées sur le heap doivent être associées à des attributs ou à des auto_ptr, jamais à des pointeurs locaux. Cette dernière option engendre en effet des fuites mémoire potentielles lors de la remontée des exceptions. La mise en place des exceptions nécessite une "certificaction du code" à cet égard, en ce qui concerne le code existant, avec éventuellement une mise à niveau.

Les ressources, pour être exception-safe, doivent être associées à une classe (la libération de ces ressources est alors assurée par le destructeur), ou à un bloc, en "variable automatique". Dans ce cas, la sortie du bloc assure la libération de la ressource ([Muller96], rule 4 et [Lippman97] p.247). Quoi qu'il en soit, la gestion "symétrique" des ressources augmente leur chance de bonne libération ([Muller96], rule 5). Au delà des fuites de ressources, il y a le problème des méthodes quittées durant leur exécution, avant la fin. Outre des fuites de ressources, cela peut engendrer des instabilités de ces objets. Le principe du contrôle opératoire stipule que la stabilité de l'objet doit être vérifiée à l'entrée des méthodes publiques (pré-conditions + invariants) ([Meyer91] p. 143 et p. 156) et à la sortie des méthodes publiques (postconditions + invariants). L'entrée et la sortie des méthodes privées ou protégées ne requièrent que la vérification des pré-conditions et post-conditions, respectivement. Les étapes d'exécution intermédiaires entre l'entrée et la sortie d'une méthode publique n'exigent pas les conditions de stabilités stipulées par les invariants. Si l'on quitte une méthode publique par le biais d'une exception (exception qui peut être levée plus bas, mais non interceptée par la méthode publique), on ne peut être sûr que l'objet est dans l'état de stabilité requis par les invariants. On ne peut d'ailleurs tout simplement pas vérifier que l'objet est dans ses conditions d'invariance après chaque appel de fonction qu'il exécute, et cela n'est peut-être pas souhaité non plus. Questions : Comment identifier les objets laissés instables lorsque les conditions anormales à l'origine de l'exception auront été rectifiées ? Comment remettre ces objets d'aplomb ? Je ne pense pas qu'il faille travailler avec l'exceptions en terme d'erreurs qui ne seront de toute façon pas récupérées. Au contraire, on doit penser à ce mécanisme uniquement en terme de véhicule d'erreur, et chercher à rattraper des conditions anormales autant que faire se peut, afin : • De remédier automatiquement au problème si c'est possible. • De proposer des solutions à l'utilisateur, si le remède automatique s'avère impossible. • De proposer le fonctionnement dans un mode dégradé, si c'est possible et si cela a un sens. • De présenter le contexte de l'erreur le plus finement possible, dans tous les cas, de façon interactive si c'est possible. • De sortir en douceur si il n'y a plus d'autre option (boite de dialogue d'avertissement pour les applications interactives, déconnexion des clients pour les serveurs). [Weir98] Propose une l'encapsulation de l'appel des invariants dans une construction utilisant

4/9


Intégrer les exceptions en C++, par Christophe Addinquy classes, templates, pointeurs sur fonctions et macros, permettant la vérification de l'invariant lors de la sortie de la méthode, quelque soit le pont de sortie. • Toujours spécifier des liste throw vides sur les destructeurs. [Stroustrup98], p.373 explique pourquoi les exceptions ne doivent jamais traverser un destructeur. Si un tel cas survient, la remontée de la pile est interrompue, et la fonction std::terminate est appelée. • Les exceptions doivent être envoyées par valeur et interceptées par référence ([Meyers96], p.68). l'envoi d'exception par valeur est la seule technique permettant de garantir l'absence de fuite mémoire provenant de l'objet exception lui même et la validité de cet objet exception quand il est adressé par le gestionnaire d'exception. L'interception par référence est la seule technique qui permettent d'éviter le phénomène du type-slicing tout en restant compatible avec la remontée d'exception par valeur. • On peut aligner plusieurs gestionnaires catch derrière un bloc try. Il faut, dans ce cas, penser que ces gestionnaires sont invoqués séquentiellement, jusqu'à ce qu'un gestionnaire capable de traiter l'exception soit trouvé. Les implications sont les suivantes : • Si aucun gestionnaire d'exception n'est trouvé, la remontée de la pile d'exécution se poursuit. • Un gestionnaire d'exception de portée « large » (i.e. un gestionnaire captant une classe de base des exceptions située très haut dans la hiérarchie) masque les gestionnaires d'exception interceptant les classes dérivées de celle-ci, si ces gestionnaires sont placés derrière. A l'extrême, Un gestionnaire (...) masque tout gestionnaire d'exception situé derrière. • Les gestionnaires (...) sont à éviter : Ils n'opèrent pas réellement de filtrage des erreurs tels qu'ils sont possibles avec le mécanisme standard d'exception, et de plus ils ne permettent pas de manipuler l'objet exception remonté. Il y a donc une importante perte d'information • Les exceptions envoyées ou traversées dans un constructeur n'invoquent pas destructeur de la classe ([Stroustrup90] p.358, [Stroustrup94] p.390, [Cargil94] et [Meyers96] p.52). Ceci implique une gestion du code situé dans ces constructeurs. [Stroustrup98] p.373 recommande d'encadrer le code complet du constructeur, y compris la liste d'initialisation, dans un gestionnaire d'exception afin d'éradiquer ce problème. Cette technique, qui s'appelle function-block-try, possède cependant des limite (entre autre, dans un constructeur, elle ne permet pas la destruction des objets agrégés), mais elle permet au moins d'envoyer une autre exception. Cette recommandation est également celle de [Meyers96] p.54, qui prône l'interception globale suivie d'une propagation, dans les constructeurs. Par ailleurs, l'envoi d'une exception lors d'une construction défectueuse de l'objet est la seule méthode permettant le traitement "en une fois" d'une erreur à la construction. Toutes les autres méthodes opèrent en 2 fois et s'appuie donc en partie sur la rigueur du client à respecter correctement le protocole de construction de la classe. Les exemple de ces protocole en 2 temps sont : • Construction (éventuellement vide) + 1 méthode d'initialisation capable de renvoyer un code d'erreur. • Construction positionnant un code d'état sur l'objet + consultation de l'état de l'objet après la construction. [Fontaine97] p. 26 aborde ces alternatives.

5/9


Intégrer les exceptions en C++, par Christophe Addinquy • L'envoi d'exception est le mécanisme généralement convenu pour les méthodes renvoyant des références dans le "return". En effet, on ne renvoie pas de référence sur un objet "nul" ou sur un objet particulier invalide. Même si le langage permet de telles acrobaties, cela ne se fait simplement pas. Une référence est réputée toujours initialisée et toujours valide. Donc, dans ce cas, si l'on incapable de renvoyer une référence valide, on "couvre" le retour de valeur d'une exception. rappelons que certaines méthodes doivent toujours renvoyer des références (operator=, opérator+=, etc...) • Les classes d'exceptions ne doivent pas envoyer d'exception ; toutes les méthodes de ces classes doivent spécifier des listes throw vides. • Le mécanisme des exception est très lié aux RTTI, dans la norme ANSI. Certains compilateurs, c'est le cas du compilateur Solaris v3 ne s'appuient pas sur ce mécanisme, mais selon la norme, le gestionnaire catch utilise les informations de rtti de l'objet intercepté pour savoir si il corresponds à un objet qu'il doit intercepter. Contrairement à ce que la syntaxe laisse penser, le mécanisme du catch n'a aucune filiation avec le mécanisme d'appel de méthodes ([Meyers96] p.61) . L'appel de méthode est résolu au compile-time et peut s'appuyer sur une vérification de signature. Ce n'est pas le cas du catch qui peut recevoir au run-time n'importe quel type d'exception et ne peut déterminer si il doit traiter celles-ci qu'à ce moment là. Les conséquences (dans le cas d'une implémentation c++ standard) sont : • La compilation doit générer les informations RTTI pour rendre le mécanisme d'exception opérationnel. Le surcout en volume de code est de l'ordre de 5% • Les classes d'exceptions doivent posséder au moins une méthode virtuelle de façon à forcer le compilateur à fabriquer les informations RRTI pour ces classes. Il n'y a pas d'informations RTTI fabriquées sur une classe qui ne possède pas sur elle-même ou sur l'une de ces classes de base directes ou indirectes, au moins une méthode virtuelle, et ce même si le commutateur de génération des informations RTTI est activé. • Les classes d'exception doivent spécifier une sémantique de copie valide. • Classes templates : La spécification d'exception (liste throw) ne peut pas être assurée de façon consistante par le compilateur pour les classes templates. Il n'y a pas moyen de savoir quoi que ce soit sur le comportement des paramètres templates (des méthodes que l'on invoque sur ces paramètres templates) au moment où l'on écrit cette classe template ([Cargil94]). Le risque de se retrouver en face d'une interception unexpected est donc beaucoup plus grand que dans le cas d'une classe non template ([Meyers96] p.74. • Je recommande la lecture de "more effective C++" de Scott Meyers [Meyers96], qui consacre un excellent chapitre sur le sujet des exceptions.

L'aspect règle de programmation ci-dessus est important : Beaucoup de développeurs C++ ne maîtrisent pas tous ces aspects, souvent en ignorant que cette connaissance est nécessaire.

Gestion des erreurs et contrôle opératoire Il ne faut pas confondre gestion d'erreur et contrôle opératoire. Stroustrup ([Stroustrup94], p.384) déclare explicitement que le mécanisme des exceptions ne fait pas office de politique de gestion des erreurs. Il est intéressant de gérer pré6/9


Intégrer les exceptions en C++, par Christophe Addinquy conditions post-conditions et invariants, mais ces contrôles peuvent (et doivent) se limiter à un mode "diagnostic" qui ne s'applique pas à la mise en production, mais juste à des fins de tests. Le mode diagnostic peut être agrégé au mode "debug", mais je ne le conseille pas. On utilisera profitablement 2 macros distinctes. La vérification des préconditions, des postsconditions et invariants peut se faire aussi via des exceptions, mais je préfère personnellement dans ce cas l'utilisation d'assertions. [Stroustrup94] p.398 propose une implémentation d'assert pour la vérification des invariants utilisant templates et exceptions. De même [Reeves96a] préconise les exceptions par rapport aux assertions, dans la mesure où justement elles ne provoquent pas l'arrêt immédiat du programme, et permettent donc l'insertion occasionnelle de handlers. L'auteur propose à cette occasion, la création d'handles contenant tous les éléments de l'assert. L'auteur argumente de plus son choix par la capacité qu'ont les debuggers actuels à travailler avec les exceptions (arrêt sur occurrence d'une exception, non dépilement de la pile d'appel sur un stop) • Il deviendra de plus en plus difficile de développer en C++ hors exceptions. Celles-cis sont d'ors et déjà mises en œuvre au cœur du C++ standard : • L'opérateur dynamic_cast<>, lorsqu'il doit retourner une référence lance une exception std::bad_cast si le cast demandé n'est pas possible. • L'opérateur new standard enverra désormais une exception std::bad_alloc si il ne peut allouer ce qui est demandé, en lieu et place de la valeur 0. Toutefois, une version dite nothrow reste disponible. • L'exception n'est pas un mécanisme gratuit. On a tendance a voir celles-cis comme un mécanisme évènementiel, mais en fait l'envoi d'exceptions par valeur (j'ai signalé que c'était le mécanisme recommandé) induit une remontée lente de la pile d'exécution, car elle engendre une recopie de l'objet exception à chaque niveau. Ceci est sans conséquence lorsqu'il s'agit de signaler un dysfonctionnement, car à ce stade, les choses allant déjà mal, une moindre efficacité à l'exécution est généralement sans importance. Cet aspect doit être pris en compte si l'on a pour objectif d'utiliser les exceptions dans le cadre d'un traitement normal. comme je l'ai signalé précédemment, l'un des grands intérêts des exceptions, c'est d'être d'un coût pratiquement nul lorsqu'elles ne sont pas envoyées. [Reeves96] propose l'emploi des exceptions, outre pour les "erreurs runtime", donc graves, pour les erreurs "logiques" applicatives. Pour ce dernier usage, on peut considéré les exceptions comme un "canal de transmission privilégié" pour les erreurs, canal dont l'un des avantages est de ne pas exigé la connaissance intrinsèque du type d'erreur de la part des couches assurant la transmission de cette erreur. Cette caractéristique peut s'avérer intéressante lorsque l'on se couple à une librairie susceptible de remonter des exceptions.

Conclusions Plusieurs actions sont à mener pour introduire et utiliser avec succès les exceptions dans ce projet. • Former les développeurs aux aspects avancés du C++ concernant les exceptions. Cela peut se faire à l'aide de sessions de formation d'une demi journée pour tout le monde,

7/9


Intégrer les exceptions en C++, par Christophe Addinquy en complément à une formation de base pour les développeurs les moins aguerris en C++. • Etablir des recommandations de développement. Elles doivent intégrer à la fois les règles d'écritures C++ et le scope d'utilisation des exceptions. Entre autre chose, les exceptions ne doivent pas se substituer à une gestion des évènements utilisateur & feedback des erreurs. Ces recommandations devront également adresser les faiblesses du mécanisme mises en avant par le groupe des "hostiles". Enfin, elles devront prendre en compte la nécessite de couvrir l'interception de toutes les exceptions émises à un moment ou à un autre. • Auditer le code existant afin de le valider "exception-safe" ou de le faire migrer le cas échéant.

Remerciements A Frédéric Doueb. Je me suis permis de prendre en compte les remarques qu'il m'avait faite afin de compléter ce document. A Laurent Sarrazin qui m'a communiqué des références documentaires qui m'ont permis de compléter ce document.

Références bibliographiques [Cargil94] Tom Cargill - Exception handling : A false sense of security - C++ report Dec. 1994 (vol. 6 ; n° 9) p. 21 - 24 [Fontaine97] Alain Bernard Fontaine - La bibliothèque Standard STL du C++ Interédition 1997 [Gillam97] Richard Gillam - The anatomy of the assignment operator, a whirlwind tour of the finer points of the C++ memory management - C++ Report Nov-Dec 1997 (vol. 9, n° 10) p. 14 [Leary96] Sean Leary - C++ Exceptions handling in multithreaded programs - C++ Report Feb 1996 (vol. 8, n° 2) [Lippman96] Stanley B. Lippman - Le modèle objet du C++ - International Thomson Publishing 1997 (Addison Wesley 1997) [Meyer91] Bertrand Meyer - Conception et programmation par objet, pour du logiciel de qualité - Interédition 1991 [Meyers96] Scott Meyers - More effective C++ - Addison Wesley 1996 [Stroustrup90] Bjarne Stroustrup & Margaret Ellis - The Annotated C++ Reference manual - Addison Wesley 1990 [Stroustrup94] Bjarne Stroustrup - The Design and Evolution of C++ - Addison Wesley 1994 [Stroustrup98] Bjarne Stroustrup - The C++ programming language, third edition Addison Wesley 1998 [Muller96] Harald M. Müller - Ten rules for handling exception handling successfully - C++ Report Jan 96 (vol.8 ; n°1) p.23 - 36 [Reeves96] Jack W. Reeves - Coping with exceptions - C++ Report March 96 (vol. 8 ; n°3) 8/9


Intégrer les exceptions en C++, par Christophe Addinquy [Reeves96a] Jack W. Reeves - (B)Leading edge : Exceptions and Debugging - C++ Report Nov-Dec 96 (vol. 8 ; n°10) p. 63 [Reeves96b] Jack W. Reeves - (B)Leading edge : Exceptions and standards - C++ Report May 1996 (Vol. 8 ; n° 5) p. 56 [Reeves96c] Jack W. Reeves - (B)Leading edge : Ten guidelines for exception specifications C++ Report July 1996 (vol. 8 ; n°7) p. 64 [Reeves97] Jack W. Reeves - (B)Leading edge : Refections on exceptions - C++ Report March 1997 (vol. 9 ; n° 3) p. 57 [Stewart98] Rocky Stewart, shubh Bhattacharya & dave Rai - Distributed exception handling in CORBA-Based C++ applications - C++ Report March 1998 (vol. 10, n° 3) [Weir98] Charles Weir - Code that test itself, using assertions and invariants to debug your code - C++ Report April 1998 (vol. 10, n° 4)

9/9


Turn static files into dynamic content formats.

Create a flipbook
Issuu converts static files into: digital portfolios, online yearbooks, online catalogs, digital photo albums and more. Sign up and create your flipbook.