Go to Top

Motivation:

Comment .NET gère la mémoire est marqué par  de nombreuses inconnues. Pourquoi Unity me dit que mes value types ne peuvent être modifiées? Boxing une variable ne signifie pas lui en mettre plein la fiole?Non.

Cet article va tenter de clarifier certains points qui nous le souhaitons éclarciront votre manière de programmer.

Ce tutoriel est particulièrement utile pour les programmeurs en C/C++ qui font le dèplacement vers C# pour développer avec Unity. C# manipule la mémoire differement ce qui peut paraitre confus ou magique en premier lieu aussi. Comme tous systèmes C# a ses petits défauts.

Lorsqu'on débute la programmation avec Unity et la programmation en général, nombreux sont ceux qui tombent face à l'utilisation des variables types. Pourquoi certaines variables sont modifiées et d'autres ne le sont pas, pourquoi ma variable a disparu alors que j'en ai encore besoin?

Une autre erreur classique est l'utilisation des variables statiques. Les nombreux tutoriels sur internet vous les font utiliser sans vraiment vous dire ce qu'elles sont et mettent simplement en avant la "facilité d'accès".

Le problème c'est que les variables statiques (aussi appelées variables de classe) ne sont pas un sujet facile.

Certains vous diront tout simplement de les éviter, clamant que ce qui peut être fait avec du statique peut aussi être fait sans. Si cela est vrai, ce n'est pas toujours la meilleure solution.

Avant de commencer, si vous arrivez du langage C et que vous avez peu d'expérience de la programmation orientée-objet, n'oubliez que ce que vous connaissez de la gestion de le mémoire et les mot-clés de stockage ne sont pas totalement similaire en POO. N'essayez pas de lier C avec C#. 

Dans ce tutoriel, nous couvrirons les différentes zones de la mémoire, les value types et reference types, l'allocation dynamique de la mémoire et les variables statiques.

Zones de Mémoire

En lançant un programme, l'OS (operating system,  Windows, Mac ou Linux) réserve une partie de la mémoire de l'ordinateur pour le programme. Avant, il y avait quatre zones principales, le call stack, le heap, les registres et la mémoire statique.

Les développeurs de C# ont été assez gentils de minimiser ce nombre à deux zones seulement, stack et heap. La première est ordonnée, rapide mais limitée, la seconde est aléatoire, large et ainsi plus lente.

Il est possible pour le programmeur de choisir quelle zone sera utilisée pour chaque variable en fonction du but et du destin de la variable.

Il existe trois mot-clés de stockage (C en avait un quatrième, register)  pour le gestion de la mémoire:

  1. auto
  2. static (nous allons couvrir ces deux là)
  3. extern.

Il se peut que vous rencontriez extern, cela signifie simplement que la variable est déclarée en dehors du projet, par exemple dans un DLL écrit dans un langage différent et non .NET.

Encore, si vous venez de C, extern est différent (et similaire). Cela refère à une variable qui est déclarée dans un autre fichier.
extern int number;

Le compilateur n'alloue aucune zone de mémoire pour number et attend que la variable soit trouvée ailleurs. Nous n'allons pas passé plus de temps là-dessus. Pour plus d'info  ici.

Value Types et le Stack

auto signifie une variable automatique. Il se peut que vous n'ayez jamais vu ce mot auparavant puisqu'il n'est plus utilisé en C# cependant le principe existe toujours. Il fut un temps où auto était la variable par défaut:

int variable = 10;
auto int variable = 10;

Ces deux lignes ont le même effet.

Le principe d'une variable automatique est qu'elle est placée sur le call stack. Un stack est une pile de variables, tout comme une pile d'assiettes, vous pouvez retirer celle du dessus ou en ajouter une sur le dessus, mais  vous ne pouvez pas toucher celle du dessous. Voyez plutot:

Stack

On dit d'un stack qu'il est de type LIFO (Last-In-First-Out, dernier arrivé-premier parti), ce qui arrive en dernier part en premier.
[/one_half_last]Pour savoir où se trouve le dessus de la pile, le programme utilise un pointeur de stack (stack pointer) qui est une variable dans le CPU (processeur) qui garde une trace de la position dans le stack représentée sur notre dessin par la flèche noire. Si nous ajoutons une variable, le pointeur est poussé vers le haut (en fait il se peut qu'il descende en fonction de votre OS) et inversement lorsqu'on retire une variable.

Le stack est utilisé pour les appels de fonction, si vous réfléchissez deux secondes, le programme est une fonction principale qui lance une boucle sans fin (jusqu'à la fermeture du programme...) dans laquelle les fonctions Updates sont appelées à leur tour et elles appellent d'autres fonctions. Ainsi il est logique que toutes les variables de votre programme soient automatiques si elles ne sont pas définies autrement.

Il se peut que vous rencontriez le concept de stack frame, une collection de variables allouées localement par la fonction en cours (et contient également l'adresse de retour qui est le point d'instruction qui sera exécutée immédiatement après le retour de la fonction). Le compilateur maintient un pointeur vers la fin du stack frame puis utilise un décalage négatif de ce pointeur pour trouver la mémoire que vos variables locales occupent. Le résultat pratique de ceci est qu'après qu'une fonction ait retourné, alors aucun espace utilisé par ses variables locales est encore utilisé ou alloué en aucune façon mais plutot sauvegardées de manière à être disponible pour le prochain l'appel de la fonction.

Si vous écrivez une fonction récursive (une fonction qui s'appelle elle-même) vous pouvez potentiellement manquer d'espace dans le stack. Voici un exemple de fonction recursive:

void Fonction(int num){
   int temp;
   temp = num+1;
   Fonction(temp);
   print(temp);
}

Comme vous pouvez le constater, la fonction place num  sur le stack puis une variable temp puis s'appelle et le processus se répète. Notre fonction n'a aucune "sentinelle" et va infiniment se répéter jusqu'à ce qu'un stack overflow crash le programme. Si votre programme comporte une tel bug, le compilateur va probablement lancer l'avertissement de stack overflow.

Le temps de vie et la portée d'une variable fonctionnent ensemble. La vie d'une variable commence avec sa déclaration jusqu'à l'accolade correspondant à l'accolade qui s'ouvre au dessus. Mmm, une clarification s'impose.

void Update(){
    int number = 10;
    Fct(number);
    print(number);
} 

void Fct(int n){
    int inside=20;
    n = n + inside;
    print(n);
}

Stack function

Update est appelée et la première instruction est une déclaration de variable. Le stack pointer est poussé vers le haut et la valeur de number est placée en mémoire.

La seconde commande est un appel de fonction. Lors d'un appel de fonction, le programme s'arrête et saute vers l'adresse de mémoire où la fonction est stockée. Certaines données sont placées sur le stack de manière à pouvoir reprendre le programme en fin de fonction. Les paramètres (s'il y en a) sont alors poussés sur le stack en créant une copie de l'original.

La variable number n'est pas poussée sur le stack pour être utilisée dans la fonction mais une nouvelle variable n est créée, placée sur le stack et la valeur de number y est placée. On dit que la variable number est passée par copie.

Nous avons alors une variable n dans la fonction qui a la valeur de number mais number n'existe pas dans Fct() et de ce fait ne peut être vue ni atteinte. Dans la fonction, la variable inside est créée. Le stack ressemble en quelque sorte à l'image au-dessus ( largement simplifié).

En fin de fonction, n est imprimée et a la valeur 30. Toutes les variables créées dans la fonction sont détruites/perdues. Cela inclut n et inside. Bien que les variables soient toujours physiquement en mémoire, elles sont simplement ignorées par le système et finiront probablement écrasées.

De retour dans Update, nous imprimons la variable number qui n'a pas changée sa valeur de 10. Le stack pointer a perdu la location de n et inside, si nous tentons de les utiliser dans Update, le compilateur renverrait une erreur que la variable n'existe pas dans cette partie du programme (scope).

Nous venons de voir que la portée d'une variable automatique est définie par les { } dans laquelle elle fut déclarée. En dehors, la variable n'existe plus et n'est plus visible. Ces accolades marquent le scope d'une fonction, d'une commande if, une boucle,...Il est en fait possible de créer un scope de la manière suivante:

void Update(){
  int a = 10;
   {
     int b = a;
   }
   // b n'existe plus
}

Temps de vie et scope

Le temps de vie (lifetime) signifie le moment du programme pendant lequel la variable existe, scope definit la zone du programme où la variable est visible. Dans le cas des variables automatiques, les deux sont identiques.

Toutes les variables value type sont placées sur le stack, celles ci sont:

  • int
  • unsigned
  • float
  • double
  • char
  • struct
  • bool
  • byte
  • enum
  • long
  • short

Plus ou moins les variables héritées de C (excepté bool).

Pour une variable automatique, sa vie est prise en charge par le compilateur. La fin du scope dans lequel elle fut déclarée marque aussi la fin de  la variable comme nous l'avons vu dans l'exemple précédent. Le programmeur n'a pas à se soucier de libérer la mémoire occupée par la variable, cela est fait automatiquement.

Reference Type et le Heap

Une variable reference type est un objet stocké en mémoire comme référence. Lorsqu'on crée une nouvelle instance de la classe, les données sont stockées dans la zone de mémoire appelée le heap. Une référence pointant vers ces données est placée sur le stack. Cette référence  a comme valeur l'adresse de l'objet. Pour créer une instance d'une classe, le mot-clé new est utilisé. C'est un appel à l'OS pour obtenir de l'espace de mémoire correspondant au type de l'objet et une instruction pour lancer les codes nécessaires à l'initialisation de l'objet. Voyez ci-dessous comment créer une instance d'un objet de type Dog.

public class Dog{
     public string name { get; set;}
     public int age { get; set;}
     public Dog(string s, int n){
           name = s;
           age = n;
    }                          
}
public class Test : MonoBehaviour {
    Dog dog1 = new Dog("Rufus",10);
    Dog dog2 = new Dog("Brutus",8);

    void Update (){                                                  
        if(Input.GetKeyDown(KeyCode.Alpha1))
            print ("Le dog1 est nommé "+dog1.name+" et a  "+dog1.age+" ans.");
        if(Input.GetKeyDown(KeyCode.Alpha2))
             print ("Le dog2 est nommé "+dog2.name+" et a "+dog2.age+" ans.");
    }
}

Nos variables dog1 et dog2 sont des références vers des  locations en mémoire où les données concernées sont stockées. Ces références étant placées sur le stack, elles seront détruites en fin de scope.

Class

Voyons maintenant la fonction Instantiate. Imaginons une prefab nommée Enemy qui inclut un modèle 3D, des composants et des scripts.

using UnityEngine;
using System.Collections;

public class Test : MonoBehaviour{
    public GameObject enemyPrefab;

    void Update(){
        if(Input.GeyKeyDown(KeyCode.Space))
               Instantiate(enemyPrefab,new Vector3(0,0,0), Quaternion.identity);
    }
}

Nous venons de créer une instance de enemyPrefab et elle se porte bien. Un problème cependant, nous n'avons aucune référence pour l'utiliser. Si nous devons appliquer des changements ou des actions à notre prefab, nous avons besoin d'une référence à cet objet.

void Update(){
    if(Input.GeyKeyDown(KeyCode.Space))
       GameObject enemyRef = Instantiate(enemyPrefab, new Vector3(0,0,0), Quaternion.identity);
        enemyRef.AddComponent(Rigidbody);
        Destroy(enemyRef);
     }
}

Dans cet exemple, la variable enemyRef est déclarée et assignée l'adresse du nouvel objet de type GameObject. enemyRef est désormais une référence à cet objet. Voyez comment il nous est possible d'ajouter un component (Rigidbody dans notre cas) puisque enemyRef sait où se trouve l'objet en mémoire. Sur la dernière ligne, l'objet est détruit, ce qui est plutot inhabituel de créer et détruire dans la foulée mais c'est juste pour l'exemple.

La référence est une variable automatique est par conséquent ne vit qu'entre les {} de la commande if. En dehors, la variable référence n'existe plus. Pour récuperer notre objet, nous devons le chercher ainsi:

GameObject enemyRefAgain = GameObject.Find(“Enemy”);

Toutes les variables reference type sont stockées dans le heap. Celles ci sont:

  • class (classe, interface, classe abstraite, delegate)
  • object
  • string (bien qu'il n'y paraisse pas, c'est une instance de la classe String)

Les principales différences entre une variable value type et une variable reference type sont l'addition d'une variable pour pointer vers la donnée et la zone de stockage en mémoire. Cette référence doit d'abord être accéder avant de pouvoir accéder aux données. Cela rend les classes légèrement plus lentes que les structures qui peuvent être accédées directement. L'autre différence entre struct et class, les structures ne peuvent inhériter et sont passées par copie. Cela siginifie que lorsqu'on passe une structure en tant que paramètre d'une fonction, si la structure a une taille de 50 octets, on copie 50 octets de données sur le stack, une classe copie seulement sa variable référence et on peut accéder les données via cette référence.

Un autre problème:

using UnityEngine;
using System.Collections;

public class Memory : MonoBehaviour {
    DogC dogC1 = new DogC();
    DogC dogC2 = new DogC();
    int number1;
    int number2;

    void Update () {
        if(Input.GetKeyDown(KeyCode.Space)){
           number2=number1 = 10;
           number1 = 20;
           print (number1+" "+number2);
           dogC1.name = "Rufus";
           dogC1.age = 12;
           dogC2 = dogC1;
           dogC1.name = "Brutus";
           print (dogC1.name+" "+dogC1.age+" "+dogC2.name+" "+dogC2.age);
        }
    }
}

Vous remarquerez que les deux number sont indépendants, quand l'un change l'autre n'en voit rien.

Par contre, assigner un objet à un autre crée une connection et modifier l'un modifiera l'autre.

La raison est simple, avec dogC2 = dogC1; nous assignons l'adresse de dogC1 dans la variable référence dogC2. L'adresse original de dogC2 est perdue en mémoire et modifier dogC1 ou dogC2 revient au même. Nous avons alors deux références pour la même location.

Class reference

Temps de vie, Scope et Garbage Collection

Dans le cas d'une variable reference type, le temps de vie est un peu trouble puisque c'est le job du Garbage Collector (GC) de vérifier et retirer la variable si nécessaire. Du temps de C et C++, c'était le boulot du programmeur de s'assurer que les variables allouées dynamiquement soient libérées (la fonction free() en C, ~ et delete en C++).

En C#, le GC vérifie si la variable est encore utilisée ou accessible, si aucune référence  n'est trouvée à partir des codes, il marque l'emplacement comme libre pour être remplacé. Les programmeurs familiers des interface COM ou autre système de décompte des références trouve souvent le GC un peu étrange comme concept. Ceci parce que dans un système de décompte des références, si deux objets se réfèrent l'un l'autre (comme un parent qui a une référence à un child et ce child lui même a une référence à son parent) peut avoir comme résultat que la mémoire n'est jamais libérée. Le GC n'a pas ce genre de problème puisqu'il considére que bien que les deux se réfèrent l'un l'autre, aucun des deux n'est accessible pour le reste du programme.

Le GC est un gros sujet et Unity a ses propres bizarreries. Si vous êtes habitué du C# classique, vous vous attendez à ce que le GC fasse les choses simplement pour les objets à court temps de vie, ce n'est pas le cas avec Unity.

Chaque fois qu'un bloc de mémoire se remplit, le système doit vérifier l'accessibilité de tous les objets alloués sur le heap. Cela implique que si l'objet peut être accédé à partir de codes en cours ou de codes qui peuvent être lancés  alors il est gardé, dans l'autre cas il est libéré. Un .NET GC normal utilise un principeé de generation qui consiste à tenter de réduire le temps de travail associé à l'accessibilité en essayant d'abord les objets récemment créés et en continuant vers les plus anciens si l'espace de mémoire vient à manquer. Simplement, un object qui vient d'être créer est placé en generation 0, si il passe le premier passage de GC alors il est poussé vers la génération 1 et si il passe la génération 1 alors il finit en génération 2 qui est la dernière. Ce principe est basé sur la probabilité qu'un objet récemment créé est plus enclin à ne plus être utilisé.

Il semble que Unity préfère tester tous les objets, de ce fait le GC devient une surcharge importante, bien plus importante que ce qui serait attendu avec un langage .NET.

Le test d'accessibilité crée toute la magie derrière le GC, il permet au collecteur  de déterminer si un code qui est actuellement en cours d'exécution ou qui pourrait être en cours d'exécution, parce qu'il existe une référence active vers ce code, a une référence à l'objet qui est un candidat pour le GC. Cela inclut l'utilisation des closures dans une fonction qui sont très compliquées, très utiles mais aussi assez lent.
Une closure est créé avec une référence vers des variables locales ou des paramètres dans une fonction anonyme définit à l'intérieur d'une fonction. La valeur de la variable est préservée et si la fonction anonyme est appelée encore une fois, alors la variable préserve sa valeur. Les programmeurs en C y verront une similarité avec les variables statiques dans une fonction.

List<Action> actionsToPerformLater = new List<Action>();

void DisplayAMessage(string message)
{
      var t = System.DateTime.Now;
      actionsToPerformLater.Add(()=>{
          Debug.Log(message + " @ " + t.ToString());
      });
}

void SomeOtherFunction()
{
      DisplayAMessage("Hello");
      DisplayAMessage("World");
      SomeFunctionThatTakesAWhile();
      DisplayAMessage("From Unity Gems");

      foreach(var a in actionsToPerformLater)
      {
           a();
      }

}

Dans ce code, chaque appel à DisplayAMessage forme une closure entre le message passé et le temps actuel du système. Lorsque la boucle foreach tourne, le message de Debug montre que le message contient les valeurs de  t au moment de l'appel de la fonction. Les closures sont particulièrement importantes et sont plus précisement décrites ici.

Le Gc se lance quand le système considère qu'il y a un manque d'espace de mémoire pour stocker une donnée. Cela signifie qu'un objet qui n'est plus utilisé peut cependant rester en mémoire pour un moment.Il est donc pratique courante de fermer explicitement les connexions externes ou de libérer des ressources externes quand un objet n'est plus nécessaire.

Il n'est pas nécessaire de libérer explicitement les objets internes (classes de votre projet), mais cela devient nécessaire lorsque vous utilisez des fichiers ou autres en dehors de votre projet, y compris Streams (fichiers) et les connexions de base de données qui pourrait avoir un certain impact sur le système. Par exemple ne pas fermer un fichier peut faire échouer une action ultérieure qui accèdent à ce fichier car le fichier est toujours ouvert par un objet qui n'existe plus.

Le GC est une opération coûteuse et il est donc logique de la déclencher manuellement quand il ne fait aucune différence pour le jeu - entre deux niveaux par exemple, lorsque le joueur entre dans un menu de pause ou à n'importe quel moment qui n'altererait pas le jeu pour le joueur. Vous pouvez déclencher manuellement le GC avec System.GC.Collect ();

En ce qui concerne la portée d'une variable de type référence, elle peut être consultée n'importe où dans le programme à condition d'effectuer l'action appropriée pour la trouver, à l'aide GameObject.Find (), par exemple, qui renvoie une référence à l'objet dans tous les scripts.

Voir notre tutoriel GetComponent pour plus de détails sur la recherche des instances de classes au cours du jeu.

Struct vs Class

Laquelle doit on utiliser et dans quelle situations? Nous avons découvert qu'utiliser une classe ajoute une surcharge, la référence. Si nous devions créer un millier d'objets, nous obtenons un milliers de surcharges alors que notre structure n'en a pas.

Voyez cet exemple:

public class DogC{
    public string name { get; set;}
    public int age { get; set;}
}

public struct DogS {
    public string name;
    public int age;
}

using UnityEngine;
using System.Collections;

public class Memory : MonoBehaviour {
    DogC dogC = new DogC();
    DogS dogS = new DogS();

    void Update () {
        if(Input.GetKeyDown(KeyCode.Alpha1))
            Print (dogS);
        if(Input.GetKeyDown(KeyCode.Alpha2))
            Print (dogC);
        if(Input.GetKeyDown(KeyCode.Alpha3))
            print ("Struct: The dog's name is "+dogS.name +" and is "+dogS.age);
        if(Input.GetKeyDown(KeyCode.Alpha4))
            print ("Class: The dog's name is "+dogC.name +" and is "+dogC.age);
     }

    void Print(DogS d){
        d.age = 10;
        d.name = "Rufus";
        print ("Struct:The dog's name is "+d.name +" and is "+d.age);
     }

    void Print(DogC d){
        d.age = 10;
        d.name = "Rufus";
        print ("Class:The dog's name is "+d.name +" and is "+d.age);
    }
}

Tout d'abord, vous remarquerez que la troisième entrée n'imprime pas ce qui est attendu, le nom et l'âge du chien en structure ont disparu. D'autre part, la classe conserve ses valeurs.

Rappelez-vous ce que nous avons dit, une structure est un value type  quand une classe est un reference type. Lorsque vous appelez la fonction avec la structure, nous passons une copie de la structure, mais la copie n'est pas l'original. Lorsque vous modifiez les données, nous  modifions la copies sur la pile. Celle-ci est perdue à la fin de la fonction (nous pouvons retourners la structure en dehors de la fonction qui est alors copiée sur une autre structure).

La classe est passée en utilisant une référence qui peut accéder aux données d'origine et les modifier au sein de la fonction, ces modifications demeure même lorsque la fonction est terminée puisque c'est l'original qui est modifiée.

Qu'est-ce que cela devrait nous faire penser? Si ma structure est vraiment grande avec par exemple 10 variables, quand elle est passée à la fonction, la pile grossit de 10 emplacements. Maintenant, si je passe un tableau de structures avec 50 d'entre elles, la pile a maintenant 500 emplacements, la pile est limitée en taille et un débordement de pile (stack overflow) pourrait arriver si la structure et le tableau sont encore plus grands.

En passant la référence de ma classe, je passe une seule variable. Avec 50 instances, je passe 50 variables seulement.

Struct vs Class

Donc, les classes économisent de la mémoire, mais les structures sont plus rapides (sauf en cas de boxing sur lequel nous reviendrons plus tard). Donc, il s'agit de trouver un équilibre entre les deux. Envisagez d'utiliser des structures quand vous avez, par exemple de 5 à 8 variables. Au-delà, utiliser une classe.

Vous devez également garder à l'esprit que chaque fois que vous accédez à une variable qui est une structure (par exemple en faisant transform.position, transform est une class qui contient la structure position), vous créez une copie de cette structure sur la pile - cette copie prend du temps pour être créer.

Chaque fois que vous créez une classe vous allouez de la mémoire sur le heap qui va rapidement conduire à un GC  pour les variables rejetées - les structures sont toujours allouées sur la pile et donc ne causeront jamais de Garbage Collection.

Vous remarquerez que dans Unity, les structures sont souvent utilisés pour 3 à 4 variables comme position, color, quaternion, rotation, scale, ..., toutes sont des structures.

Les objets de courte durée et souvent  alloués sont de bons candidats pour des structures parce qu'ils ne causent pas de GC. Les objets de longue durée de vie et les gros objets sont de bons candidats pour les classes parce qu'ils ne seront pas copiés à chaque fois qu'ils sont utilisés et leur longue existence ne causera pas la nécessité d'un GC régulier. Vous verrez que beaucoup de petits objets comme Vector3 et RaycastHit sont des structures dans Unity pour cette raison.

Un dernier point avant de passer à la suite, nous avons gardé pour la fin la cerise sur le gâteau. Il est possible de passer une structure par réf´rence pour que nous puissions modifier notre structure originales.

Vous ne pouvez pas maintenir une référence à une structure une fois que la fonction qui a défini la référence a quitté. Il existe de nombreuses façons pour qu'elles causent un problème telle que l'utilisation de closures (formées par des fonctions lambda ou fonctions inline), mais ce sont des techniques très avancés.
void PrintRef(ref DogS d){
    d.age = 10;
    d.name = "Rufus";
    print ("Structure:Le nom du chien est "+d.name +" et il a "+d.age+" ans.");
}

On appelle la fonction ainsi:

void Update(){
    PrintRef(ref dogS);
    print ("Structure:Le nom du chien est "+d.name +" et il a "+d.age+" ans.");
}

Vous avez remarqué le mot clé ref, cela dit au compilateur de créer une référence à la structure, au lieu de la copier sur la pile. En dehors de la fonction, la structure a gardé les valeurs données à l'intérieur de celle-ci. Ce mot-clé peut être utilisé avec n'importe quelle value type. C# a un deuxième mot-clé similaire out. La différence entre les deux est que out nécessite que la variable soit modifié dans la fonction.

Créer une Référence

Donc, nous venons de voir qu'il est possible de créer une référence à une variable value type. Considérons que nous voulons créer une variabe integer dans une fonction, mais nous voulons la garder pour une utilisation ultérieure. En déclarant simplement à l'intérieur de la fonction la variable automatique est perdue à la fin de la fonction.

Nous pouvons créer une variable allouée dynamiquement qui sera stocké dans le heap avec une référence dans notre script.

using UnityEngine;
using System.Collections;

public class Test:MonoBehaviour{
    int number;

    void Update () {
        if(Input.GetKeyDown (KeyCode.C))
            CreateVariable();
        if(Input.GetKeyDown (KeyCode.Space))
            PrintVariable(number);
    }

    void CreateVariable(){
        //int number = new int();
        number = new int();
        number = 10;
        print ("In function "+number);
    }

    void PrintVariable(int n){
        print (n);
        n+=10;
    }
}

Nous avons d'abord déclaré une variable number de type int qui est global à notre script. Nous utilisons cette variable à l'intérieur CreateVariable et lui attribue une référence à une variable integer  en utilisant new int ();

Reference for automatic

Pour clarifier cette situation, le mot clé new renvoie une référence à la variable qui est créé dans la section heap. Cette référence est stockée dans number qui contient maintenant l'adresse de la variable nouvellement créée. Utiliser number c'est utiliser indirectement la nouvelle variable.

Considérons la partie commenté, si nous avions fait de cette façon, nous aurions perdu notre référence à la fin de la fonction. Sur son cycle de nettoyage suivant, le garbage collector aurait trouvé notre donnée sans aucune référence et la retirerait de la mémoire. Considérez que lorsque vous déclarez une variable locale, comme nous l'avons fait ici à l'intérieur de la fonction, int number; aurait caché la variable global int number.

Lorsque vous maintenez une variable primitive, comme un integer, à l'intérieur d'une référence, le compilateur exécute une fonction appelée boxing qui consiste essentiellement à envelopper la variable primitive à l'intérieur d'une classe. Les variables déclarées de cette manière sont alloués sur le heap et nécessite le GC pour être libérées. Elles prennent également plus de mémoire que la valeur primitive par défaut.

Prenons une situation basique de boxing(emboitâge...si cela a un sens...). Lorsque vous utilisez la fonction Debug.Log (objet);, le paramètre est de type object qui signifie que tout type de variable peut être passé à la fonction. Mais en passant un integer, le compilateur va boxer l'integer dans un objet. Il est alors possible d'optimiser le boxing en anticipant le comme ceci:

void PrintingManyTimes(int n){
    object obj = n;
    for(int i = 0;i<500;i++)
        Debug.Log(obj);
}

De cette manière, le compilateur n'a pas besoin de lancer un boxing à chaque appel. C'est fait une fois pour tous les 500 appels.

Static

Nous entrons maintenant dans la dernière partie de notre tutoriel. Encore une fois, si vous avez un passé en C, oubliez ce que vous savez à propos de l'utilisation de static.

Une variable statique en . NET est stockée dans une partie spéciale du heap appelée le heap à haute fréquence (high frequency heap).

Comme nous l'avons mentionné précédemment, nous rencontrons souvent des variables statiques dans des tutoriels pour débutants où seule la facilité d'utilisation est mise en avant. Le gentil tuteur ne prend souvent pas le temps de bien expliquer les avantages et les dangers des variables statiques. Nous allons essayer de clarifier ce qu'est un objet statique, comment l'utiliser, où l'utiliser et l'inverse.

Classes Statiques

Une classe statique est une classe qui ne possède pas d'instanciation d'objet, vous ne pourrez pas créer un objet d'une classe statique.

En conséquence, il n'y a qu'une instanciation et elle est faite par le compilateur. Vous pourriez être confus puisque je viens de dire qu'il ne peut y avoir aucune instance de celle-ci. Le programme alloue de la mémoire dans le heap pour cet objet et ne permettra aucun instanciation par l'utilisateur.

Les objets statiques sont créés à leur premier appel et seront détruits à la fin du programme (ou de son crash ...).

public static class GameManager{
    public static int score;  
    public static float amountOfTime;
}

Notez que si la classe est statique, les membres doivent l'être aussi.

Vous ne pourrez pas créer d'instance de GameManager.

GameManager GM = new GameManager();

renverra une erreur.

Vous pouvez accéder les membres de la classe statique simplement comme ceci:

GameManager.score = 10;

Cela peut se faire n'importe où et n'importe quand dans le programme. Une classe statique et ses membres ont une portée dans chaque fichier et un temps de vie de la durée du programme.

Exemple d'utilisation d'une classe statique

Lors du chargement d'une nouvelle scène, toutes les variables de la scène précédente sont détruites. Si nous voulons préserver une variable comme le score, par exemple, nous pouvons utiliser:

DontDestroyOnLoad(score);

Une grande utilité pour la classe statique réside dans leur temps de vie. Comme elles ne meurent pas, si nous voulons que notre score survive à la nouvelle scène, nous pouvons simplement utiliser une classe statique. À la fin de notre niveau, nous passons la valeur à l'élément statique, à partir de notre nouvelle scène nous pouvons faire l'opération inverse.

if(newLevel){
    GameManager.score = tempScore;
    Application.LoadLevel(nextLevel);
}

Et dans le script du nouveau niveau:

void Start(){
    scoreTemp = GameManager.score;
}

Un petit truc pour cet usage, ne pas utiliser GameManager.score pendant le niveau mais plutot une variable "classique" et seulement en fin de niveau passer les informations vers la statique.

Pensez à un jeu avec 10 niveaux où vous obtenez au maximum 200pts par niveau.

Un joueur termine le jeu en une seule fois et obtient 2000pts.

Un autre joueur termine le jeu en plusieurs coups et obtient 5000pts.

Le second joueur n'est pas aussi bon mais obtient plus de points parce qu'il a essayé chaque niveau plusieurs fois et accumuler des points. Si nous attendons le chargement du niveau suivant pour passer les points à la variable statique, les nouvelles tentatives annulent les points accumulés durant le niveau.

A la sortie du jeu, les données de la classe statique peuvent être envoyé à une sauvegarde, voyez plutot ici.

Les classes statiques et les variables statiques sont allouées lors de la première rencontre durant l'exécution du programme. Une classe statique n'est jamais créée, jusqu'à ce que vous accédiez un membre ou une fonction.

Variables Statiques

De nombreux débutants font cette erreur:

public static int health;
void Start(){
    health=100;
}

Dans un autre script, vous pourriez trouver:

void OnCollisionEnter(Collision other){
    if (other.gameObject.tag == “Enemy”){
        EnemyScript.health-=10;
        if(EnemyScript.health <=0)Destroy(other);
    }
}

Ils essayent le jeu avec un ennemi et réussissent à le tuer. Mais ils ajoutent plus d'ennemis et se demande pourquoi tout le monde meurt subitement.

La raison en est simple, vous avez plusieurs instances de la classe ennemie (qui n'est pas statique), mais vous n'avez qu'une seule variable health pour toutes les instances. Tuer un, c'est tous les tuer!

En fait, une variable statique déclarée dans une classe n'appartient pas aux objets, mais à la classe elle-même. C'est pourquoi nous y accédons en utilisant le nom de la classe et pas le nom de l'instance.

Cela n'a pas fonctionné pour la santé de l' ennemi parce que chaque ennemi doit avoir sa propre variable health.

Une variable statique de la même façon que la classe statique est instanciée lorsque la classe qui la définit est accédée pour le première fois. Cela signifie que même si il n'y avait aucune instance de la classe dans le jeu, la variable existerait si vous avez essayé d'y accéder.

Considérons un compteur d'ennemi, nous voulons garder la trace de la quantité d'ennemis que nous avons dans le jeu.

static var counter;

public void CreateEnemy(){
    GameObject obj=Instantiate(enemyPrefab, new Vector3(0,0,0).Quaternion.identity);

    if(obj)counter++;
}

public void DestroyEnemy(){
    Destroy(gameObject);
    counter--;
}

Ce script est attaché à chaque ennemi. Ils partagent tous la même variable compteur, même si ils ont tous un script différent.

Grâce à cette fonction dans notre jeu nous pouvons garder une trace de notre compteur d'ennemis. Notez l'utilisation d'une variable de référence, dans le cas où  Instantiate ne réussirait pas à créer l'objet, Instantiate retournerait une référence null. Si nous augmentons notre compteur sans vérifier, si pour quelque raison une instanciation échoue, nous avons un compteur qui tourne mal. Maintenant, si nous utilisons ce compteur à la fin de notre niveau, nous pouvons lancer le nouveau niveau comme tel:

if(counter<=0)Application.LoadLevel(nextLevel);

Sans la vérification, si nous avions un échec, après avoir tué tous les ennemis, nous aurions un compteur de 1, notre jeu a un bug.

Qu'en est-il de notre joueur, puis-je utiliser une variable health statique? Oui, vous pouvez utiliser une variable statique pour la santé du joueur puisqu'il n'y a qu'un seul joueur. De plus, l'accès à une variable statique est plus rapide que l'accès à une variable non-statique.

Lorsque vous appelez le membre d'une instance, le compilateur a besoin d'appeler une petite fonction qui vérifie l'existence de l'objet. C'est logique puisque vous ne devriez pas être en mesure de modifier une donnée qui n'existe pas. Une variable statique existera en toute circonstance. Du coup, la vérification n'est pas utile. Le gain est faible cependant.

Fonctions Statiques

Une fonction statique peut être implémentée dans une classe non statique, dans ce cas, seuls les membres statiques de cette classe peuvent être consultés à l'intérieur de la fonction. Ce qui est logique puisque la fonction statique est appelée par la classe et non par une instance, il serait difficile d'essayer d'accéder à des membres qui ne sont pas sûrs d'être en vigueur.

D'autre part, il est possible de passer des paramètres et vous avez probablement utilisé ce genre de fonction.

Vector3.Distance(vec1.vec2);
vec1.Normalize();

Application.LoadLevel (1);

if (GUI.Button(Rect(10,10,50,50),btnTexture)){}

La liste pourrait s'allonger encore et encore. Notez que le deuxième exemple n'est pas une méthode statique. Elle est appelée par l'instance de vec1 de type Vector3. Les autres exemples montrent que la classe est utilisée pour appeler la fonction statique.

Une fonction statique tend à être plus rapide que les non-statiques pour les mêmes raisons expliquées plus tot avec les variables statiques.

Un autre avantage des classes statiques est que leur existence est permanente et connue par le compilateur, par conséquent, elles peuvent être utilisées, en C #, afin de définir les méthodes d'extension - ce sont des méthodes qui semblent être des fonctions supplémentaires ajoutées à tout un autre type de variable. Les classes étendues de cette façon ne sont pas réellement ouvertes (leurs variables privées et protégées ne sont pas disponibles pour les méthodes d'extension), mais cela peut être une belle façon de donner de nouvelles fonctions à des objets existants qui conduisent souvent à une plus grande lisibilité des codes et une API plus simples pour les développeurs. Par exemple, vous pouvez  ajouter une nouvelle fonction pour la  Transform

transform.MyWeirdNewFunction(x);

Exemple de fonction d'extension:

Nous voulons créer une fonction pour les tableaux qui prolongeront celles qui existent déjà (OrderBy, Sort, ...), nous pouvons déclarer une fonction statique qui permettra d'étendre la liste.

using UnityEngine;
using System.Collections;

public static class ExtensionTest {

	public static int BiggestOfAll(this int[] integer){
		int length = integer.Length;
		int biggest = integer[0];
		for(int i = 1;i<length;i++){
			if(integer[i]>biggest)biggest = integer[i];
		}
		return biggest;
	}
}

Nous déclarons une fonction statique, voyez comment le paramètre passé est l'instance qui appelle la fonction. En modifiant le type du paramètre, nous pouvons travailler avec d'autres types d'objets.

Déclarons et remplissons un tableau:

using UnityEngine;
using System.Collections;

public class Test : MonoBehaviour {

	int[] array = {1,25,12,120,12,6};

	void Start () {
		print(array.BiggestOfAll());
        }
}

Alors que vous tapez le point après le nom de votre tableau, vous verrez votre nouvelle fonction apparaître dans la liste.

Si vous décidez de créer une fonction plus générique qui accepte n'importe quel tableau quel que soit son type, vous mettriez en place une méthode d'extension statique générique.

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public static class ExtensionTest {

	public static T TypelessBiggestOfAll<T>(this T[] t){
		int length = t.Length;
		var biggest = t[0];
		for(int i = 1;i<length;i++){
			biggest =((Comparer<T>.Default.Compare(t[i], biggest)>0)?t[i]:biggest);
		}
		return biggest;
	}
}

Notez l'ajout de l'aide System.Collections.Generic;. Comme le compilateur ne sais pas quel est le type de T, on ne peut pas se contenter de comparer les valeurs avec < ou >. Mais nous pouvons utiliser Compare <T>. Dans l'exemple, l'opérateur ternaire est utilisé. Il tend à rendre le code un peu déroutant au début, mais  aussi à sauver quelques lignes.

L'opérateur ternaire :       a ? b : c;

Si a retourne vrai, b est retourné, si a retourne faux, c est envoyé hors de la commande.

L'avantage par rapport à un if normal réside dans la valeur retournée par la comparaison.        result = a ? b : c;           result recevra la valeur b ou c en fonction de a.

Il est maintenant possible de déclarer différents types de tableaux et utiliser la même fonction.

using UnityEngine;
using System.Collections;

public class Test : MonoBehaviour {

	int[] arrayInt = {1, 25, 12, 120, 12, 6};
	float[] arrayFl = {0.5f, 52.456f, 654.25f, 41.2f};
	double[]arrayDb = {0.1254, -15487.258, 654, 8795.25, -2};

	void Start () {
		print(arrayInt.TypelessBiggestOfAll());
		print (arrayFl.TypelessBiggestOfAll());	
		print (arrayDb.TypelessBiggestOfAll());
       }
}

Cela imprime  120, 654.25, 8795.25 .

De nombreux utilisateurs de Unity posent des questions sur la façon de contraindre un objet à l'intérieur d'une zone. Par exemple si vous voulez garder un objet à l'intérieur de l'écran, vous pouvez restraindre la transform de cet objet à l'aide de cette fonction:

using UnityEngine;
using System.Collections;

public static class GameManager{

	public static void ClampTransform(this Transform tr,Vector3 move, Vector3 min, Vector3 max){
		Vector3 pos = tr.position + move;
		if(pos.x < min.x ) pos.x = min.x;
		else if(pos.x > max.x) pos.x = max.x;

		if(pos.y  < min.y ) pos.y = min.y;
		else if(pos.y> max.y) pos.y = max.y;

		if(pos.z < min.z) pos.z = min.z;
		else if(pos.z > max.z) pos.z = max.z;

		tr.position = pos;
	}
}

Et voici comment l'utiliser sur n'importe quel objet:

Vector3 minVector = new Vector3(-10,0,-10);
Vector3 maxVector = new Vector3(10,0,10);
transform.ClampTransform(move,minVector,maxVector);

Dans cet exemple, l' objet est restreint dans un environnement 2D (y reste 0) dans un carré de 20*20  cenntré à (0,0,0).

Heap Fragmentation, Garbage Collection et Object Pooling

Le heap est une grande zone de mémoire dans laquelle les données sont stockées de manière aléatoire. En fait, elle n'est pas si aléatoire, l'OS va se pencher sur la mémoire et rechercher la première zone assez grande pour accueillir les données requises. La taille et l'emplacement du heap varie en fonction de la plate-forme que vous avez sélectionné.

Par exemple, lorsque nous demandons un integer, le système d'exploitation recherche 4 octets consécutifs de mémoire libre. Le fait est que pendant que le programme s'exécute, les emplacements de mémoire sont utilisés et  libérés sans véritable logique et votre programme pourrait se retrouver avec une fragmentation du heap.

Heap fragmentation
La photo montre un exemple de fragmentation du heap, de toute évidence celui-ci est radicale et met l'accent sur l'idée. Vous remarquerez peut-être que nous essayons d'allouer une data3 mais au state1 nous avions une datas3 qui a été détruite au state2.

Notez qu'aucune donnée n'a été ajoutée, il ya à la fin la même quantité de données qu'au state1. Seuls les mouvements ont créé une fragmentation du heap.

Unity utilise la mémoire managée par .NET. Dans un programme en C, la fragmentation du heap pourrait créer une situation où il serait impossible d'allouer un bloc de mémoire car aucun bloc contigu d'une taille appropriée n'a pu être trouvée, même s'il y avait effectivement assez de mémoire libre sur l'ensemble - .NET ne souffre pas de cette limitation. Si un bloc de mémoire ne peut pas être trouvé la gestion de la mémoire. NET et les GC peuvent être utilisés pour éliminer la fragmentation du heap en déplaçant les éléments. Il s'agit, bien sûr, d'une opération relativement longue qui peut entraîner des baisses de rendement dans un jeu qui exige une performance élevée et constante.

Une solution pour le programmeur peut être d'utiliser l'object pooling. Au lieu de détruire notre data3 en state1 nous la désactivons pour une utilisation ultérieure. Cela aurait évité notre fragmentation du heap.

Nous n'avons pas à détruire les données, nous les conservons inactives et en cas de besoin, nous les réveillons.

Il y a de nombreux avantages pour cette solution, tout d'abord nous évitons le cas vu plus haut d'appel du GC et de remaniement de la mémoire, d'autre part nous avons également éviter d'appeler Instantiate et Destroy, nous avons seulement besoin d'utiliser gameObject.SetActive(true / false);

Enfin, puisque nous ne détruisons pas, nous n'appelons pas le garbage collector qui est une action coûteuse.

ObjectScript.cs
using UnityEngine;
using System.Collections;

public class Test : MonoBehaviour {
	public GameObject prefab;
	GameObject[] objectArray = new GameObject[5];  
        public static int counter; 

         void Start(){
		for(int i = 0;i<objectArray.Length;i++){
			objectArray[i] = (GameObject)Instantiate(prefab,new Vector3(0,0,0),Quaternion.identity);
			objectArray[i].SetActive(false);
		}
        }
        void Createobject(){
		if(counter < objectArray.Length){    
                // iterate through array
			for(int i = 0;i<objectArray.Length;i++){  
                                // Find an inactive object                                     
				if(!objectArray[i].active){    
                               // Increase the counter, activate the object, position it                                              
					counter++;					                            
					objectArray[i].SetActive(true);	           
					objectArray[i].transform.position = new Vector3(0,0,0);  
					return;
				}
			}return;
		}else return;
	}
}
void OnCollisionEnter(CollisionEnter){
    if(other.gameObject.tag=="Object"){
        other.gameobject.SetActive(false);       //Deactivate the object
        ObjectScript.counter--;                        //Decrease counter

La scène n'a jamais  plus de 5 objets en même temps, quand l'un est désactivé, il peut être réactivé quand nous le voulons. Le GC n'est pas nécessaire.

Vous pouvez utiliser cette astuce pour vos ennemis, si vous avez une vague d'ennemis venant à vous, au lieu de détruire un ennemi tué, repositionnez-le à l'arrière, vous êtes sur le point de tuer le même gars plusieurs fois sans vous en apercevoir.

Idem pour vos munitions, au lieu de détruire chaque balle que vous ou les NPC tirez  (Non-Player-Character, les objets guidés par IA) , désactivez-les. Réactivez-les quand vous tirez à nouveau avec la position en face de votre arme et la vitesse adéquate.

Notez que pooling quand on parle de mémoire est un processus différent géré par le système d'exploitation qui consiste à réserver toujours la même quantité de mémoire quelle que soit la taille de l'objet. En conséquence, il n'y aura jamais de petits emplacements de mémoire discéminés en mémoire.

Réutilisation des tableaux

En plus de la réutilisation des objets, il est également utile d'envisager la création de zones tampons qui sont réutilisées par votre code entre les différentes demandes ou répétées dans le même appel.

void Update()
{
      if(readyToFire && Input.GetKeyDown(KeyCode.F))
      {
          var _cinqPlusProches = new Enemy[5];

          //Faire quelque chose pour remplir une liste d'ennemies

          TargetEnemies( _cinqPlusProches);
      }
}

Dans cet exemple, nous ciblons les 5 ennemis les plus proches  et nous tirons une arme automatique dans leur direction. Le problème est que nous allouons de la mémoire chaque fois que nous appuyons sur le bouton de tir. Si, au contraire, nous avions fait un tableau avec une longue durée de vie pour les ennemis que nous pouvons utiliser n'importe où dans notre code, nous n'aurions pas  à allouer de la mémoire et donc d'éviter le ralentissement.

Voici un exemple simple:

Enemy[] _cinqPlusProches = new Enemy[5];

void Update()
{
      if(readyToFire && Input.GetKeyDown(KeyCode.F))
      {
          //Faire quelque chose pour remplir une liste d'ennemies

          TargetEnemies( _cinqPlusProches);
      }
}

Mais peut-être que nous pourrions faire encore plus générale, si nous avons souvent besoin de tableaux d'ennemis:

Enemy[] _enemies = new Enemy[100];

void Update()
{
      if(readyToFire && Input.GetKeyDown(KeyCode.F))
      {
          //Faire quelque chose pour remplir une liste d'ennemies

          TargetEnemies(_enemies, 5);
      }
}

En réécrivant notre code pour TargetEnemies, on peut effectivement utiliser un tampon à usage général pour les ennemis partout dans notre code et d'éviter des allocations inutiles.

Le deuxième exemple ici est un cas assez extrême, et vous ne devriez faire cela que si vous avez vraiment d'énormes collections qui pourraient causer un problème de mémoire - en général, vous devriez toujours écrire des codes lisibles et compréhensibles plutot que de sauver quelques octets de mémoires.

Conclusion

Nous arrivons à la fin de notre article. Nous voila en mesure de mieux comprendre comment la mémoire de notre ordinateur fonctionne et plus particulièrement sous Unity et le .NET framework.

Nous avons aussi abordé comment et où utiliser des cas statiques en prenant en compte qu'ils ne fonctionnent pas pour tous les cas d'objets.

Voici deux vidéos qui démontrent des cas traités dans cet article Use of static variables et Demonstration of Memory Management (en anglais).

, , , , , ,

6 Responses to "Gestion de la mémoire"

  • TetedeBUG
    December 30, 2012 - 8:39 pm Reply

    Merci, article très enrichissant, j’ai pu gagner environs 10% de perf et j’ai pas encore tout revu en optimisant l’utilisation de mes variables dans mon jeu.

    j’ai par exemple passé une variable importante en static (en prenant soins de la reinit) ce qui ma permis d’éliminer un grand nombre de getcomponent vers cette variable.

  • Tepec
    January 30, 2013 - 1:36 pm Reply

    Merci pour cet article plein de bon sens, qui fait réviser certaines règles basiques et permet de garder en tête des bonnes pratiques “de base” !
    Par contre, je me permets de faire remarquer que le grand nombre de fautes et de maladresses gêne parfois un peu la compréhension de ce qui est énoncé ; n’hésitez pas à m’envoyer un mail si vous souhaitez une relecture, je me ferai un plaisir de vous aider !
    Bonne continuation,

    • fafase
      January 30, 2013 - 7:35 pm Reply

      Des erreurs? Faites nous savoir, on est jamais à l’abri. Sachez tout de même que si c’est de l’ordre de la grammaire ou l’orthographe c’est etre pointilleu sur dé détails vu kon cherche pa un prix pour lé Dico d’or.

      Si c’est de l’ordre technique, je me permets d’émettre un doute vu que l’article fut supervisé par une personne ayant une décénie de .Net derrière lui. D’autant que sur les milliers de vues ang/fr vous êtes le premier. Mais là encore on est jamais à l’abri…

      Donc énoncez et nous analyserons ensemble.

      • Tepec
        January 31, 2013 - 8:33 am Reply

        Salut !

        Comme vous vous en doutez, les erreurs que j’évoque ne sont pas d’ordre technique mais syntaxique, et s’il ne s’agit pas d’un problème de fond, il me paraît néanmoins pertinent d’avoir une forme qui permet de bien comprendre le sujet !
        J’ai fait cette (petite) remarque car certaines formulations m’ont parfois mis le doute tant l’interprétation que l’on pouvait en faire passait du tout au tout. Encore une fois, je pense que c’est la seule chose qui nuit à la grande qualité de ces articles, et c’est pour ça qu’en toute humilité je me propose pour effectuer un travail de relecture sur ceux-ci.
        Merci encore pour ces articles,

        • jus2poubelle
          May 16, 2013 - 3:27 pm Reply

          je confirme les propos de Tepec.
          L’article est intéressant mais les fautes d’orthographes et de grammaires sont plus que gênantes (et je suis pas une grammar nazi). On se croirait presque devant un traducteur automatique par moment.
          Ca mériterait une sérieuse relecture.

          merci tout de même pour tout ce travail. Je ne comprend pas tout techniquement mais ca m’aide bien.

          • fafase
            May 16, 2013 - 7:00 pm

            ” On se croirait presque devant un traducteur automatique par moment”, c’est parce que par moment vous êtes devant un traducteur automatique.

            Google fait la moitié du boulot, je fais l’autre moitié mais après une heure de relecture on est moi attentif…

            Bon, a part ca, j’ai revu l’article, ca devrait être plus clair. SI ya encore des fautes, beh je ferais un troisième passage.

            Fafase

Leave a Reply