Logs
Consultez les logs.
OK
Liste des données
Consultez la liste des données.
OK
Loading...
Formulaire
Saisissez vos données.
Enregistrer
Annuler

Architecture Logicielle C++

Vues
517

Introduction


Lorsque vous créez une application, une bibliothèque ou tout composant logiciel, vous devez réfléchir à l'apparence des éléments que vous écrivez et à la manière dont ils interagiront les uns avec les autres. En d'autres termes, vous les concevez ainsi que leurs relations avec leur environnement. Tout comme avec l'architecture urbaine, il est important de penser à la situation dans son ensemble pour ne pas se retrouver dans un état désordonné. L'architecture logicielle d'un système est l'ensemble des structures nécessaires pour raisonner sur le système, qui comprend les éléments logiciels, les relations entre eux et les propriétés de tous. Cela signifie que pour bien définir une architecture, nous devons y penser sous plusieurs angles au lieu de simplement nous lancer dans l'écriture de code.

image.png


Recommandations techniques


Pour tester les extraits de code dans ce tutoriel, vous aurez besoin des éléments suivants:

Un compilateur compatible C++20 pour compiler tous les extraits.
La plupart d'entre eux sont écrit en C++ 11/14/17.
Cependant, la prise en charge du C++20 est nécessaire
pour expérimenter quelques concepts qui s'en rapporte.
Lien de la bibliothèque GSL: https://github.com/Microsoft/GSL
Lien du compilateur WinLibs sous Windows: https://winlibs.com/
Lien du compilateur TDM-GCC sous Windows: https://jmeubank.github.io/tdm-gcc/
Lien du compilateur MSYS2 sous Windows: https://www.msys2.org/
MSYS2 dispose d'un gestionnaire de packages (pacman).
Lien du compilateur Cygwin sous Windows: https://cygwin.com/
Cygwin est émulateur de l'environnement Linux sous Windows.
Sous Linux: Installer GCC (GNU Compiler Collection) en ligne de commande.


La philosophie du C++


C++ est un langage de programmation compilé multi-paradigme. Il permet la programmation procédurale, la programmation orientée objet et la programmation générique. Il existe depuis quelques décennies. Au cours des années qui ont suivi sa création, il a beaucoup changé. Lorsque C++11 est sorti, Bjarne Stroustrup, le créateur du langage, a déclaré que cela ressemblait à un langage complètement nouveau. La sortie de C++20 marque une autre étape importante dans l'évolution de cette bête, apportant une révolution similaire à la façon dont nous écrivons du code. Une chose, cependant, est restée la même pendant toutes ces années : la philosophie du langage.

La philosophie du C++ peut se résumer en 3 points:

Ne pas avoir de langage sous C++ (sauf l'assembleur).
Ne payez des coûts que pour ce que vous utilisez.
Offrir des abstractions de haut niveau à faible coût (objectif coût zéro).

Les abstractions de haut niveau sont ce qui différencie le C++ des langages de niveau inférieur tels que le C ou l'assembleur. Elles permettent d'exprimer des idées et des intentions directement dans le code source, ce qui joue très bien avec la sécurité de type du langage (type safety).

Considérons l'extrait de code suivant qui manipule des délais en ms.

Approche 1:

//===============================================
struct GDuration {
    int m_delayMs;
};
//===============================================
void GTest::runPhilo(int _argc, char** _argv) {
    auto d = GDuration{};
    d.m_delayMs = 1000;
    auto lTimeout = 1000;
    d.m_delayMs = lTimeout;
}
//===============================================

Une bien meilleure idée serait de tirer parti des fonctionnalités de sécurité de type offertes par le langage, ce qui conduit à une seconde approche:

Approche 2:

//===============================================
struct GDuration2 {
    std::chrono::milliseconds m_delayMs;
};
//===============================================
void GTest::runPhilo(int _argc, char** _argv) {
    using namespace std::literals::chrono_literals;
    auto d = GDuration2{};
    d.m_delayMs = 1000ms;
    auto lTimeout = 1s;
    d.m_delayMs = lTimeout;
}
//===============================================

Approche 3:

//===============================================
struct GDuration2 {
    std::chrono::milliseconds m_delayMs;
};
//===============================================
void GTest::runPhilo(int _argc, char** _argv) {
    using namespace std::literals::chrono_literals;
    auto lDuration = GDuration2{};
    lDuration.m_delayMs = std::chrono::milliseconds(1000);
    auto lTimeout = std::chrono::seconds(1);
    lDuration.m_delayMs = lTimeout;
}
//===============================================

Ce faisant, le code assembleur généré serait le même que pour le premier exemple. C'est pourquoi on l'appelle une abstraction à coût nul. Parfois, C++ nous permet d'utiliser des abstractions qui donnent en fait un meilleur code que si elles n'étaient pas utilisées.

Un exemple de fonctionnalité de langage qui, lorsqu'elle est utilisée, peut souvent entraîner un tel avantage sont les coroutines de C++20. Un autre grand ensemble d'abstractions, offert par la bibliothèque standard, sont les algorithmes.

Considérons le code suivant qui tente de compter le nombre de caractère "." dans une chaine.

Approche 1:

//===============================================
int GDot::countDot(const char* _str, std::size_t _size) {
    int count = 0;
    for (std::size_t i = 0; i < _size; ++i) {
        if(_str[i] == '.') count++;
    }
    return count;
}
//===============================================
void GTest::runPhilo(int _argc, char** _argv) {
    GDot lDot;
    const char* lMsg = "Bonjour.Tout.Le.Monde.";
    int lCount = lDot.countDot(lMsg, strlen(lMsg));
    std::cout << lCount << "\n";
}
//===============================================

Approche 2:

//===============================================
int GDot2::countDot(const std::string_view& _str) {
    return std::count(std::begin(_str), std::end(_str), '.');
}
//===============================================
void GTest::runPhilo(int _argc, char** _argv) {
    GDot2 lDot;
    int lCount = lDot.countDot("Bonjour.Tout.Le.Monde.");
    std::cout << lCount << "\n";
}
//===============================================

L'utilisation d'abstractions de niveau supérieur conduit à un code plus simple et plus maintenable. Le langage C++ s'est efforcé de fournir des abstractions à coût nul depuis sa création, alors appuyez vous dessus au lieu de repenser la roue en utilisant des niveaux d'abstraction inférieurs.


Suivre les principes SOLID et DRY


Il existe de nombreux principes à garder à l'esprit lors de l'écriture de code. Lors de l'écriture de code orienté objet, vous devez être familiarisé avec le quartet abstraction, encapsulation, héritage et polymorphisme. Indépendamment du fait que vous écriviez du C++ d'une manière principalement orientée objet ou non, vous devez garder à l'esprit les principes basés sur les deux acronymes : SOLID et DRY.

SOLID est un ensemble de pratiques qui peuvent vous aider à écrire des logiciels plus propres et moins sujets aux bogues. C'est un acronyme composé des premières lettres des cinq concepts qui le caractérise:

Principe de responsabilité unique SRP (Single Responsibility Principle)
Principe ouvert-fermé OCP (Open-Closed Principle)
Principe de substitution de Liskov LSP (Liskov Substitution Principle)
Principe de la ségrégation d'interface ISP (Interface Segregation Principle)
Principe de l'inversion de dépendance DIP (Dependency Inversion Principle)

Si vous écrivez du code orienté performances (et vous l'êtes probablement si vous avez choisi C++), vous devez savoir que l'utilisation du polymorphisme dynamique peut être une mauvaise idée en termes de performances, en particulier sur les séquences d'instructions exécutées très fréquemment (hot path).

Le patron de modèle curieusement récurrent CRTP (Curiously Recurring Template Pattern) permet d'écrire des classes statiquement polymorphes.


Le principe de responsabilité unique SRP


Le principe de responsabilité unique SRP (Single Responsibility Principle) signifie que chaque unité de code doit avoir exactement une responsabilité. Cela signifie écrire des fonctions qui ne font qu'une seule chose, créer des types qui sont responsables d'une seule chose et créer des composants de niveau supérieur qui se concentrent sur un seul aspect.

Cela signifie que si votre classe gère un certain type de ressources, telles que des descripteurs de fichiers, elle ne devrait faire que cela, laissant leur analyse, par exemple, à un autre type.

Très souvent, si vous voyez une fonction avec un "Et" ou un "And" dans son nom, elle enfreint le SRP et doit être refactorisée. Un autre signe est lorsqu'une fonction a des commentaires indiquant ce que fait chaque section de la fonction. Chacune de ces sections serait probablement mieux en tant que fonction distincte.

Un principe similaire est le principe de connaissance minimale LKP (Least Knowledge Principle) qui stipule qu'aucun objet ne devrait en savoir plus que nécessaire sur les autres objets, de sorte qu'il ne dépend d'aucun de leurs éléments internes, par exemple. Son application conduit à un code plus maintenable avec moins d'interdépendances entre les composants.


Le principe ouvert-fermé OCP


Le principe ouvert-fermé OCP (Open-Closed Principle) signifie que le code doit être ouvert pour extension mais fermé pour modification. Ouvert pour extension signifie que nous pourrions facilement étendre la liste des types pris en charge par le code. Fermé pour modification signifie que le code existant ne doit pas changer, car cela peut souvent provoquer des bogues ailleurs dans le système.

Une grande fonctionnalité de C++ démontrant ce principe est l'opérateur d'affichage (operator<<) de ostream. Pour l'étendre afin qu'il prenne en charge votre classe personnalisée, il vous suffit d'écrire un code similaire à celui-ci :

Approche 1:

//===============================================
std::ostream& operator<<(std::ostream& _stream, const GPerson& _person);
//===============================================
std::ostream& operator<<(std::ostream& _stream, const GPerson& _person) {
    _stream << "id : " << _person.getId() << "\n";
    _stream << "nom : " << _person.getName() << "\n";
    return _stream;
}
//===============================================
void GTest::runOcp(int _argc, char** _argv) {
    GPerson lPerson;
    lPerson.setId(123);
    lPerson.setName("Gerard");
    std::cout << lPerson;
}
//===============================================

Notez que notre implémentation de l'opérateur d'affichage (operator<<) est une fonction libre (non membre). Vous devriez les préférer aux fonctions membres si possible car cela aide réellement l'encapsulation.

Si vous ne souhaitez pas fournir un accès public à un champ que vous souhaitez imprimer sur ostream, vous pouvez faire de l'opérateur d'affichage (operator<<) une fonction amie, comme ceci :

Approche 2:

//===============================================
class GPerson2 {
public:
    GPerson2();
    ~GPerson2();
    void setId(int _id);
    void setName(const GString& _name);

    friend std::ostream& operator<<(std::ostream& _stream, const GPerson2& _person);

private:
    int m_id;
    GString m_name;
};
//===============================================
void GPerson2::setId(int _id) {
    m_id = _id;
}
//===============================================
void GPerson2::setName(const GString& _name) {
    m_name = _name;
}
//===============================================
std::ostream& operator<<(std::ostream& _stream, const GPerson2& _person) {
    _stream << "id : " << _person.m_id << "\n";
    _stream << "nom : " << _person.m_name << "\n";
    return _stream;
}
//===============================================
void GTest::runOcp(int _argc, char** _argv) {
    GPerson2 lPerson;
    lPerson.setId(123);
    lPerson.setName("Gerard");
    std::cout << lPerson;
}
//===============================================

Notez que cette définition de l'OCP est légèrement différente de celle liée au polymorphisme. Ce dernier consiste à créer des classes de base qui ne peuvent pas être modifiées elles-mêmes, mais qui sont ouvertes à d'autres pour en hériter.


Le principe de substitution de Liskov LSP


Le principe de substitution de Liskov LSP (Liskov Substitution Principle) stipule que si une fonction travaille avec un pointeur ou une référence à un objet de base, elle doit également travailler avec un pointeur ou une référence à l'un de ses objets dérivés.

Cette règle est parfois enfreinte car les techniques que nous appliquons dans le code source ne fonctionnent pas toujours dans les abstractions du monde réel.

Un exemple célèbre est un carré et un rectangle. Mathématiquement parlant, le premier est une spécialisation du second, il y a donc une relation de l'un à l'autre. Cela nous donne envie de créer une classe Carré qui hérite de la classe Rectangle. Ainsi, nous pourrions nous retrouver avec un code comme celui-ci :

Approche 1:

//===============================================
class GRectangle {
public:
    GRectangle();
    virtual ~GRectangle() = default;
    virtual double getArea() const;
    virtual void setWidth(double _width);
    virtual void setHeight(double _height);

private:
    double m_width;
    double m_height;
};
//===============================================
class GSquare : public GRectangle {
public:
    GSquare();
    double getArea() const override;
    void setWidth(double _width) override;
    void setHeight(double _height) override;
};
//===============================================
double GRectangle::getArea() const {
    return m_width * m_height;
}
//===============================================
void GRectangle::setWidth(double _width) {
    m_width = _width;
}
//===============================================
void GRectangle::setHeight(double _height) {
    m_height = _height;
}
//===============================================
double GSquare::getArea() const {
    return GRectangle::getArea();
}
//===============================================
void GSquare::setWidth(double _width) {
    GRectangle::setWidth(_width);
    GRectangle::setHeight(_width);
}
//===============================================
void GSquare::setHeight(double _height) {
    setWidth(_height);
}
//===============================================
void GTest::runLsp(int _argc, char** _argv) {
    GRectangle lRect;
    lRect.setWidth(4);
    lRect.setHeight(3);
    std::cout << "aire rectangle : " << lRect.getArea() << "\n";

    GSquare lSquare;
    lSquare.setWidth(4);
    std::cout << "aire carré : " << lSquare.getArea() << "\n";
}
//===============================================

Comment implémenter les membres de la classe Square ? Si nous voulons suivre le LSP et épargner aux utilisateurs de telles classes des surprises, nous ne pouvons pas: notre carré cesserait d'être un carré si nous appelions setWidth. Nous pouvons soit arrêter d'avoir un carré (non exprimable en utilisant le code précédent), soit modifier également la hauteur, rendant ainsi le carré différent d'un rectangle.

Si votre code enfreint le LSP, il est probable que vous utilisiez une abstraction incorrecte. Dans notre cas, le carré ne devrait pas hériter du rectangle.

Une meilleure approche pourrait consister à faire en sorte que les deux implémentent une interface (GShape).


Le principe de ségrégation d'interface ISP


Le principe de ségrégation d'interface ISP (Interface Segregation Principle) est un peu comme son nom l'indique.

Il est formulé comme suit:

Aucun client ne devrait être contraint de dépendre des méthodes qu'il n'utilise pas. Cela semble assez évident, mais cela a des connotations qui ne sont pas si évidentes.

Tout d'abord, vous devez préférer des interfaces plus nombreuses mais plus petites à une seule grande.

Deuxièmement, lorsque vous ajoutez une classe dérivée ou que vous étendez les fonctionnalités d'une classe existante, vous devez réfléchir avant d'étendre l'interface que la classe implémente.

Montrons cela sur un exemple qui viole ce principe, en commençant par l'interface suivante:

Approche 1:

//===============================================
class GFoodProcessorI {
public:
    virtual ~GFoodProcessorI() = default;
    virtual void blend() = 0;
};
//===============================================
class GBlend : public GFoodProcessorI {
public:
    GBlend();
    void blend() override;
};
//===============================================
void GBlend::blend() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GTest::runIsp(int _argc, char** _argv) {
    GBlend lBlend;
    lBlend.blend();
}
//===============================================

Nous pouvons avoir une classe simple (GBlend) qui l'implémente l'interface (GFoodProcessorI).

Jusqu'ici, tout va bien. Supposons maintenant que nous voulions modéliser un autre robot culinaire plus avancé et que nous essayions imprudemment d'ajouter plus de méthodes à notre interface:

Approche 2:

//===============================================
class GFoodProcessorI2 {
public:
    virtual ~GFoodProcessorI2() = default;
    virtual void blend() = 0;
    virtual void slice() = 0;
    virtual void dice() = 0;
};
//===============================================
class GFood : public GFoodProcessorI2 {
public:
    GFood();
    void blend() override;
    void slice() override;
    void dice() override;
};
//===============================================
void GFood::blend() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GFood::slice() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GFood::dice() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GTest::runIsp(int _argc, char** _argv) {
    GFood lFood;
    lFood.blend();
    lFood.slice();
    lFood.dice();
}
//===============================================

Maintenant, nous avons un problème avec la classe (GBlender) car elle ne prend pas en charge cette nouvelle interface, il n'y a pas de moyen approprié de l'implémenter. Nous pouvons essayer de bidouiller une solution de contournement ou lancer std::logic_error, mais une bien meilleure solution serait simplement de diviser l'interface en deux, chacune avec une responsabilité distincte.

Approche 3:

//===============================================
class GBlendI {
public:
    virtual ~GBlendI() = default;
    virtual void blend() = 0;
};
//===============================================
class GFoodI {
public:
    virtual ~GFoodI() = default;
    virtual void slice() = 0;
    virtual void dice() = 0;
};
//===============================================
class GBlend2 : public GBlendI {
public:
    GBlend2();
    void blend() override;
};
//===============================================
class GFood2 : public GBlendI, public GFoodI {
public:
    GFood2();
    void blend() override;
    void slice() override;
    void dice() override;
};
//===============================================
void GBlend2::blend() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GFood2::blend() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GFood2::slice() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GFood2::dice() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GTest::runIsp(int _argc, char** _argv) {
    GBlend2 lBlend;
    lBlend.blend();

    GFood2 lFood;
    lFood.blend();
    lFood.slice();
    lFood.dice();
}
//===============================================

Désormais, notre classe (GFood) peut simplement implémenter les deux interfaces (GBlendI, GFoodI), et nous n'avons pas besoin de modifier l'implémentation de notre robot culinaire existant (GBlend).


Le principe de l'inversion de dépendance DIP


Le principe de l'inversion de dépendance DIP (Dependency Inversion Principle) est un principe utile pour le découplage. Essentiellement, cela signifie que les modules de haut niveau ne doivent pas dépendre des modules de niveau inférieur.

Au lieu de cela, les deux devraient dépendre d'abstractions.

C++ permet deux manières d'inverser les dépendances entre vos classes. La première est l'approche régulière et polymorphe et la seconde utilise des modèles.

Supposons que vous modélisez un projet de développement logiciel censé avoir des développeurs frontend et backend.

Une approche simple serait de l'écrire comme ceci:

Approche 1:

//===============================================
class GBackend {
public:
    GBackend();
    void developBackend();
};
//===============================================
class GFrontend {
public:
    GFrontend();
    void developFrontend();
};
//===============================================
class GProject {
public:
    GProject();
    void deliver();

private:
    GBackend m_backend;
    GFrontend m_frontend;
};
//===============================================
void GBackend::developBackend() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GFrontend::developFrontend() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GProject::deliver() {
    printf("%s...\n", __PRETTY_FUNCTION__);
    m_backend.developBackend();
    m_frontend.developFrontend();
}
//===============================================
void GTest::runDip(int _argc, char** _argv) {
    GProject lProject;
    lProject.deliver();
}
//===============================================

Chaque développeur (GFontend, GBackend) est construit par la classe projet (GProject). Cette approche n'est cependant pas idéale, car le concept de niveau supérieur (GProject) dépend désormais de ceux de niveau inférieur du processus de développement (GFrontend, GBackend).

Voyons comment l'application de l'inversion de dépendance à l'aide du polymorphisme change cela.

Nous pouvons définir nos développeurs (GFrontend, GBackend) pour qu'ils dépendent d'une interface (GDeveloper) comme suit:

Approche 2:

//===============================================
class GDeveloper {
public:
    virtual ~GDeveloper() = default;
    virtual void develop() = 0;
};
//===============================================
class GBackend2 : public GDeveloper {
public:
    GBackend2();
    void develop() override;

private:
    void developBackend();
};
//===============================================
class GFrontend2 : public GDeveloper {
public:
    GFrontend2();
    void develop() override;

private:
    void developFrontend();
};
//===============================================
class GProject2 {
public:
    using GDeveloperPtr = std::unique_ptr<GDeveloper>;
    using GDeveloperMap = std::vector<GDeveloperPtr>;

public:
    GProject2();
    void addDeveloper(GDeveloperPtr _developer);
    void deliver();

private:
    GDeveloperMap m_developers;
};
//===============================================
void GFrontend2::develop() {
    printf("%s...\n", __PRETTY_FUNCTION__);
    developFrontend();
}
//===============================================
void GFrontend2::developFrontend() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GBackend2::develop() {
    printf("%s...\n", __PRETTY_FUNCTION__);
    developBackend();
}
//===============================================
void GBackend2::developBackend() {
    printf("%s...\n", __PRETTY_FUNCTION__);
}
//===============================================
void GProject2::addDeveloper(GDeveloperPtr _developer) {
    m_developers.push_back(std::move(_developer));
}
//===============================================
void GProject2::deliver() {
    printf("%s...\n", __PRETTY_FUNCTION__);
    for (auto& lDeveloper : m_developers) {
        lDeveloper->develop();
    }
}
//===============================================
void GTest::runDip(int _argc, char** _argv) {
    GProject2 lProject;
    GProject2::GDeveloperPtr lBackend = std::make_unique<GBackend2>();
    GProject2::GDeveloperPtr lFrontend = std::make_unique<GFrontend2>();
    lProject.addDeveloper(std::move(lBackend));
    lProject.addDeveloper(std::move(lFrontend));
    lProject.deliver();
}
//===============================================

Dans cette approche, le projet (GProject) est découplé des implémentations concrètes et dépend uniquement de l'interface polymorphe des développeur (GDeveloper). Les classes concrètes "de niveau inférieur" dépendent également de cette interface. Cela peut vous aider à raccourcir votre temps de construction et permet des tests unitaires beaucoup plus faciles. Maintenant vous pouvez facilement passer des mocks comme arguments dans votre code de test.


À suivre...

À suivre...