158 votes

Comment le modèle StartCoroutine / yield return fonctionne-t-il réellement dans Unity ?

Je comprends le principe des coroutines. Je sais comment obtenir le standard StartCoroutine / yield return pour travailler en C# dans Unity, par exemple invoquer une méthode qui renvoie IEnumerator via StartCoroutine et dans cette méthode faire quelque chose, faire yield return new WaitForSeconds(1); pour attendre une seconde, puis faire autre chose.

Ma question est la suivante : que se passe-t-il réellement en coulisses ? Que fait StartCoroutine vraiment ? Qu'est-ce que IEnumerator es WaitForSeconds de retour ? Comment le StartCoroutine retourner le contrôle à la partie "autre chose" de la méthode appelée ? Comment tout cela interagit-il avec le modèle de concurrence d'Unity (où beaucoup de choses se passent en même temps sans utiliser de coroutines) ?

131voto

James McMahon Points 14356

La référence fréquente Les coroutines d'Unity3D en détail Le lien est mort. Puisqu'il est mentionné dans les commentaires et les réponses, je vais poster le contenu de l'article ici. Ce contenu provient de ce miroir .


Les coroutines d'Unity3D en détail

De nombreux processus dans les jeux se déroulent sur plusieurs images. Il y a les processus "denses", comme le pathfinding, qui travaillent dur à chaque image, mais qui sont répartis sur plusieurs images pour ne pas avoir un impact trop important sur le framerate. Il y a les processus " épars ", comme les déclencheurs de gameplay, qui ne font rien la plupart du temps, mais qui sont occasionnellement appelés à faire un travail critique. Et il y a des processus variés entre les deux.

Lorsque vous créez un processus qui se déroule sur plusieurs images - sans multithreading - vous devez trouver un moyen de diviser le travail en morceaux qui peuvent être exécutés une par image. Pour tout algorithme avec une boucle centrale, c'est assez évident : un pathfinder A*, par exemple, peut être structuré de telle sorte qu'il maintienne ses listes de nœuds de manière semi-permanente, en ne traitant qu'une poignée de nœuds de la liste ouverte à chaque image, au lieu d'essayer de faire tout le travail en une seule fois. Il faut trouver un équilibre pour gérer la latence. Après tout, si vous bloquez votre fréquence d'images à 60 ou 30 images par seconde, votre processus ne prendra que 60 ou 30 étapes par seconde, ce qui peut rendre le processus trop long dans l'ensemble. Une conception soignée pourrait offrir la plus petite unité de travail possible à un niveau - par exemple, traiter un seul nœud A* - et superposer un moyen de regrouper le travail en plus gros morceaux - par exemple, continuer à traiter les nœuds A* pendant X millisecondes. (Certains appellent cela le "timeslicing", mais pas moi).

Pourtant, permettre à l'œuvre d'être divisée de cette manière signifie que vous devez transférer l'état d'un cadre à l'autre. Si vous décomposez un algorithme itératif, vous devez conserver tous les états partagés entre les itérations, ainsi qu'un moyen de savoir quelle itération doit être exécutée ensuite. En général, ce n'est pas trop grave - la conception d'une "classe d'éclaireur A*" est assez évidente - mais il existe d'autres cas moins agréables. Parfois, vous serez confronté à de longs calculs qui effectuent différents types de travail d'une image à l'autre ; l'objet capturant leur état peut se retrouver avec un grand nombre de " locals " semi-utiles, conservés pour le passage des données d'une image à l'autre. Et si vous avez affaire à un processus épars, vous finissez souvent par devoir implémenter une petite machine d'état juste pour savoir quand le travail doit être effectué.

Ne serait-ce pas génial si, au lieu de devoir suivre explicitement tout cet état sur plusieurs images, et au lieu de devoir faire du multithread et gérer la synchronisation et le verrouillage, etc., vous pouviez simplement écrire votre fonction comme un seul morceau de code, et marquer des endroits particuliers où la fonction doit faire une pause et continuer plus tard ?

Unity - ainsi qu'un certain nombre d'autres environnements et langages - fournit cela sous la forme de Coroutines.

De quoi ont-ils l'air ? En "Unityscript" (Javascript) :

function LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield;
    }
}

En C# :

IEnumerator LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield return null;
    }
}

Comment fonctionnent-ils ? Laissez-moi vous dire, rapidement, que je ne travaille pas pour Unity Technologies. Je n'ai pas vu le code source d'Unity. Je n'ai jamais vu les entrailles du moteur de coroutine de Unity. Cependant, s'ils l'ont implémenté d'une manière radicalement différente de ce que je suis sur le point de décrire, alors je serai assez surpris. Si quelqu'un d'UT veut intervenir et parler de la façon dont cela fonctionne réellement, ce serait génial.

Les grands indices se trouvent dans la version C#. Tout d'abord, notez que le type de retour de la fonction est IEnumerator. Ensuite, notez que l'une des instructions est yield. return. Cela signifie que yield doit être un mot-clé, et comme le support C# d'Unity est vanilla C# 3.5, ce doit être un mot-clé vanilla C# 3.5. En effet, le voici dans MSDN - parler de quelque chose appelé "blocs itérateurs". Alors, qu'est-ce qui se passe ?

Tout d'abord, il y a ce type IEnumerator. Le type IEnumerator agit comme un curseur sur une séquence, fournissant deux membres importants : Current, qui est une propriété indiquant l'élément sur lequel le curseur se trouve actuellement, et MoveNext(), une fonction qui permet de passer à l'élément suivant de la séquence. Parce que IEnumerator est une interface, elle ne spécifie pas exactement comment ces membres sont implémentés ; MoveNext() pourrait simplement ajouter une valeur à Current, ou elle pourrait charger la nouvelle valeur à partir d'un fichier, ou elle pourrait télécharger une image à partir d'Internet, la hacher et stocker le nouveau hachage dans Current ou elle pourrait même faire une chose pour le premier élément de la séquence, et quelque chose de complètement différent pour le second. Vous pourriez même l'utiliser pour générer une séquence infinie si vous le souhaitez. MoveNext() calcule la valeur suivante dans la séquence (en retournant false s'il n'y a plus de valeurs), et Current récupère la valeur qu'elle a calculée.

Normalement, si vous voulez implémenter une interface, vous devez écrire une classe, implémenter les membres, et ainsi de suite. Les blocs Iterator sont un moyen pratique d'implémenter IEnumerator sans tous ces tracas - il suffit de suivre quelques règles, et l'implémentation de IEnumerator est générée automatiquement par le compilateur.

Un bloc d'itérateur est une fonction régulière qui (a) renvoie IEnumerator, et (b) utilise le mot-clé yield. Que fait réellement le mot-clé yield ? Il déclare quelle est la valeur suivante dans la séquence - ou qu'il n'y a plus de valeurs. Le moment où le code rencontre un yield return X ou yield break est le point où IEnumerator.MoveNext() doit s'arrêter ; un yield return X fait que MoveNext() renvoie vrai et que la valeur X est attribuée àCurrent, tandis qu'un yield break fait en sorte que MoveNext() renvoie false.

Maintenant, voici l'astuce. Les valeurs réelles retournées par la séquence n'ont pas d'importance. Vous pouvez appeler MoveNext() à plusieurs reprises, et ignorer Current ; les calculs seront toujours effectués. À chaque fois que MoveNext() est appelé, votre bloc d'itérateurs se déplace jusqu'à l'instruction 'yield' suivante, quelle que soit l'expression qu'elle renvoie réellement. Vous pouvez donc écrire quelque chose comme :

IEnumerator TellMeASecret()
{
  PlayAnimation("LeanInConspiratorially");
  while(playingAnimation)
    yield return null;

  Say("I stole the cookie from the cookie jar!");
  while(speaking)
    yield return null;

  PlayAnimation("LeanOutRelieved");
  while(playingAnimation)
    yield return null;
}

et ce que vous avez réellement écrit est un bloc itérateur qui génère une longue séquence de valeurs nulles, mais ce qui est significatif, ce sont les effets secondaires du travail qu'il effectue pour les calculer. Vous pourriez exécuter cette coroutine en utilisant une simple boucle comme celle-ci :

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }

Ou, plus utilement, vous pouvez le mélanger à d'autres travaux :

IEnumerator e = TellMeASecret();
while(e.MoveNext()) 
{ 
  // If they press 'Escape', skip the cutscene
  if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}

Tout est dans le timing Comme vous l'avez vu, chaque déclaration yield return doit fournir une expression (comme null) afin que le bloc itérateur ait quelque chose à assigner à IEnumerator.Current. Une longue séquence de nullités n'est pas vraiment utile, mais nous sommes plus intéressés par les effets secondaires. N'est-ce pas ?

Il y a quelque chose de pratique que nous pouvons faire avec cette expression, en fait. Et si, au lieu de donner simplement null et de l'ignorer, nous produisions quelque chose qui nous indique quand nous nous attendons à devoir faire plus de travail ? Souvent, nous aurons besoin de continuer à l'image suivante, bien sûr, mais pas toujours : il y aura de nombreuses fois où nous voudrons continuer après qu'une animation ou un son ait fini de jouer, ou après qu'un certain temps se soit écoulé. Ces while(playingAnimation) yield return null ; sont un peu fastidieuses, vous ne trouvez pas ?

Unity déclare le type de base YieldInstruction, et fournit quelques types dérivés concrets qui indiquent des types particuliers d'attente. Il y a WaitForSeconds, qui reprend la coroutine après que le temps indiqué se soit écoulé. Il y a WaitForEndOfFrame, qui reprend la coroutine à un point particulier plus tard dans la même trame. Vous avez le type Coroutine lui-même, qui, lorsque la coroutine A produit la coroutine B, met en pause la coroutine A jusqu'à ce que la coroutine B soit terminée.

À quoi cela ressemble-t-il du point de vue de l'exécution ? Comme je l'ai dit, je ne travaille pas pour Unity, je n'ai donc jamais vu leur code, mais j'imagine qu'il ressemble un peu à ça :

List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;

foreach(IEnumerator coroutine in unblockedCoroutines)
{
    if(!coroutine.MoveNext())
        // This coroutine has finished
        continue;

    if(!coroutine.Current is YieldInstruction)
    {
        // This coroutine yielded null, or some other value we don't understand; run it next frame.
        shouldRunNextFrame.Add(coroutine);
        continue;
    }

    if(coroutine.Current is WaitForSeconds)
    {
        WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
        shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
    }
    else if(coroutine.Current is WaitForEndOfFrame)
    {
        shouldRunAtEndOfFrame.Add(coroutine);
    }
    else /* similar stuff for other YieldInstruction subtypes */
}

unblockedCoroutines = shouldRunNextFrame;

Il n'est pas difficile d'imaginer comment d'autres sous-types de YieldInstruction pourraient être ajoutés pour gérer d'autres cas - le support des signaux au niveau du moteur, par exemple, pourrait être ajouté, avec une YieldInstruction WaitForSignal("SignalName") le supportant. En ajoutant plus de YieldInstructions, les coroutines elles-mêmes peuvent devenir plus expressives - yield return new WaitForSignal("GameOver") est plus agréable à lire quewhile(!Signals.HasFired("GameOver")) yield return null, si vous voulez mon avis, en dehors du fait que le faire dans le moteur pourrait être plus rapide que de le faire en script.

Quelques ramifications non évidentes Il y a deux ou trois choses utiles dans tout cela que les gens oublient parfois et que j'ai pensé devoir souligner.

Tout d'abord, le rendement est juste le rendement d'une expression - n'importe quelle expression - et YieldInstruction est un type régulier. Cela signifie que vous pouvez faire des choses comme :

YieldInstruction y;

if(something)
 y = null;
else if(somethingElse)
 y = new WaitForEndOfFrame();
else
 y = new WaitForSeconds(1.0f);

yield return y;

Les lignes spécifiques yield return new WaitForSeconds(), yield return new WaitForEndOfFrame(), etc., sont courantes, mais elles ne sont pas vraiment des formes spéciales en soi.

Deuxièmement, comme ces coroutines ne sont que des blocs d'itérateurs, vous pouvez les itérer vous-même si vous le souhaitez - vous n'avez pas besoin que le moteur le fasse pour vous. J'ai déjà utilisé cette méthode pour ajouter des conditions d'interruption à une coroutine :

IEnumerator DoSomething()
{
  /* ... */
}

IEnumerator DoSomethingUnlessInterrupted()
{
  IEnumerator e = DoSomething();
  bool interrupted = false;
  while(!interrupted)
  {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
  }
}

Troisièmement, le fait que vous puissiez céder sur d'autres coroutines peut vous permettre d'implémenter vos propres YieldInstructions, même si elles ne sont pas aussi performantes que si elles étaient implémentées par le moteur. Par exemple :

IEnumerator UntilTrueCoroutine(Func fn)
{
   while(!fn()) yield return null;
}

Coroutine UntilTrue(Func fn)
{
  return StartCoroutine(UntilTrueCoroutine(fn));
}

IEnumerator SomeTask()
{
  /* ... */
  yield return UntilTrue(() => _lives < 3);
  /* ... */
}

Cependant, je ne le recommanderais pas vraiment - le coût de démarrage d'une Coroutine est un peu lourd à mon goût.

Conclusion J'espère que cela clarifie un peu ce qui se passe réellement lorsque vous utilisez une Coroutine dans Unity. Les blocs itérateurs de C# sont une petite construction intéressante, et même si vous n'utilisez pas Unity, vous trouverez peut-être utile d'en tirer parti de la même manière.

108voto

Gazi Alankus Points 447

La première rubrique ci-dessous est une réponse directe à la question. Les deux rubriques suivantes sont plus utiles pour le programmeur de tous les jours.

Détails d'implémentation des coroutines potentiellement ennuyeux

Les coroutines sont expliquées dans Wikipedia et ailleurs. Je me contenterai ici de fournir quelques détails d'un point de vue pratique. IEnumerator , yield etc. sont Caractéristiques du langage C# qui sont utilisés dans un but quelque peu différent dans Unity.

Pour le dire très simplement, un IEnumerator pour disposer d'une collection de valeurs que l'on peut demander une par une, un peu à la manière d'une List . En C#, une fonction avec une signature pour retourner un IEnumerator n'a pas besoin d'en créer et d'en renvoyer un, mais peut laisser C# fournir un modèle implicite de IEnumerator . La fonction peut alors fournir le contenu de ce retour. IEnumerator à l'avenir de manière paresseuse, par le biais de yield return déclarations. Chaque fois que l'appelant demande une autre valeur à partir de cet implicite IEnumerator la fonction s'exécute jusqu'au prochain yield return qui fournit la valeur suivante. En conséquence, la fonction se met en pause jusqu'à ce que la valeur suivante soit demandée.

Dans Unity, nous ne les utilisons pas pour fournir des valeurs futures, nous exploitons le fait que la fonction se met en pause. À cause de cette exploitation, beaucoup de choses concernant les coroutines dans Unity n'ont pas de sens (que fait la fonction IEnumerator ont à voir avec quoi que ce soit ? Quel est yield ? Pourquoi new WaitForSeconds(3) ? etc.). Ce qui se passe "sous le capot", c'est que les valeurs que vous fournissez par l'intermédiaire de l'IEnumerator sont utilisées par les services suivants StartCoroutine() pour décider quand demander la valeur suivante, ce qui détermine quand votre coroutine s'interrompra à nouveau.

Votre jeu Unity est single-threaded (*)

Les coroutines sont pas fils. Il y a une boucle principale de Unity et toutes les fonctions que vous écrivez sont appelées par le même fil principal dans l'ordre. Vous pouvez le vérifier en plaçant un while(true); dans n'importe laquelle de vos fonctions ou coroutines. Cela gèlera tout, même l'éditeur Unity. C'est la preuve que tout fonctionne dans un seul thread principal. Ce lien que Kay a mentionné dans son commentaire ci-dessus est également une excellente ressource.

(*) Unity appelle vos fonctions à partir d'un seul fil. Ainsi, à moins que vous ne créiez vous-même un thread, le code que vous avez écrit est monofilaire. Bien sûr, Unity utilise d'autres threads et vous pouvez créer des threads vous-même si vous le souhaitez.

Une description pratique des coroutines pour les programmeurs de jeux vidéo

En gros, lorsque vous appelez StartCoroutine(MyCoroutine()) c'est exactement comme un appel de fonction ordinaire à MyCoroutine() jusqu'à ce que le premier yield return XX est quelque chose comme null , new WaitForSeconds(3) , StartCoroutine(AnotherCoroutine()) , break etc. C'est à ce moment qu'elle commence à différer d'une fonction. L'unité "met en pause" cette fonction à ce moment précis. yield return X la ligne, poursuit ses activités et quelques cadres passent, et quand le moment est venu, Unity reprend cette fonction juste après cette ligne. Il se souvient des valeurs de toutes les variables locales de la fonction. De cette façon, vous pouvez avoir une for qui boucle toutes les deux secondes, par exemple.

Le moment où Unity reprendra votre coroutine dépend de ce que X était dans votre yield return X . Par exemple, si vous avez utilisé yield return new WaitForSeconds(3); il reprend au bout de 3 secondes. Si vous avez utilisé yield return StartCoroutine(AnotherCoroutine()) il reprend après AnotherCoroutine() est entièrement fait, ce qui vous permet d'imbriquer les comportements dans le temps. Si vous avez juste utilisé un yield return null; il reprend à l'image suivante.

7voto

Joe Blow Points 3618

C'est on ne peut plus simple :

Unity (et tous les moteurs de jeu) sont basé sur le cadre .

Le point central, la raison d'être de Unity, c'est qu'il est basé sur des cadres. Le moteur fait des choses "à chaque image" pour vous. (Animation, rendu d'objets, physique, etc.).

Vous pourriez vous demander : "Oh, c'est génial. Et si je veux que le moteur fasse quelque chose pour moi à chaque image ? Comment puis-je dire au moteur de faire telle ou telle chose dans un cadre ?"

La réponse est ...

C'est exactement ce à quoi sert une "coroutine".

C'est aussi simple que cela.

Une remarque sur la fonction "Mise à jour"...

Tout simplement, tout ce que vous mettez dans "Update" est fait. chaque cadre . C'est littéralement la même chose, aucune différence du tout, à partir de la syntaxe coroutine-yield.

void Update()
 {
 this happens every frame,
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 }

...in a coroutine...
 while(true)
 {
 this happens every frame.
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 yield return null;
 }

Il n'y a absolument aucune différence.

Les fils n'ont absolument aucun lien avec les cadres/coroutines, de quelque manière que ce soit. Il n'y a aucun lien, quel qu'il soit.

Les images dans un moteur de jeu ont aucun lien avec les fils en aucune façon. Ce sont des questions complètement, totalement, complètement, sans rapport.

(Vous entendez souvent dire que "Unity est single-threaded !" Notez que même cette déclaration est très confuse. Les frames/coroutines n'ont absolument aucun rapport avec le threading. Si Unity était multithreadé, hyperthreadé, ou fonctionnait sur un ordinateur quantique ! !! ... il aurait juste aucune connexion à des cadres/coroutines. Il s'agit d'un problème complètement, totalement, absolument, sans rapport).

Si Unity était multithread, hyperthread, ou fonctionnait sur un ordinateur quantique ! !! ... il aurait juste aucune connexion aux cadres/coroutines. Il s'agit d'un problème complètement, totalement, absolument, sans rapport.

Donc, en résumé...

Ainsi, les coroutines/yield sont simplement la façon dont vous accédez aux cadres dans Unity. C'est tout.

(Et en effet, c'est absolument la même chose que la fonction Update() fournie par Unity).

C'est tout ce qu'il y a à faire, c'est aussi simple que ça.

Pourquoi IEnumerator ?

On ne pourrait pas faire plus simple : IEnumerator renvoie les choses "encore et encore".

(Cette liste de choses peut soit avoir une longueur spécifique, comme "10 choses", soit être interminable).

Ainsi, de toute évidence, un IEnumerator est ce que vous devez utiliser.

Partout dans .Net où vous voulez "retourner encore et encore", IEnumerator existe dans ce but.

Toute l'informatique basée sur les trames, avec .Net, utilise bien sûr IEnumerator pour retourner chaque trame. Que pourrait-il utiliser d'autre ?

(Si vous êtes novice en C#, notez que IEnumerator est également utilisé pour renvoyer des éléments "ordinaires" un par un, comme simplement les éléments d'un tableau, etc.)

5voto

Geri Points 3572

J'ai creusé dans ce domaine récemment, j'ai écrit un post ici - http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/ - qui font la lumière sur les aspects internes (avec des exemples de code denses), la IEnumerator et comment elle est utilisée pour les coroutines.

L'utilisation des énumérateurs de collections à cette fin me semble encore un peu bizarre. C'est l'inverse de ce pour quoi les énumérateurs sont conçus. L'intérêt des énumérateurs est la valeur retournée à chaque accès, mais l'intérêt des Coroutines est le code entre les retours de valeur. La valeur réelle retournée est inutile dans ce contexte.

3voto

Daniel Loureiro Points 312

Sur Unity 2017+ vous pouvez utiliser le langage natif C# async / await des mots-clés pour le code asynchrone, mais avant cela, C# n'avait pas de moyen natif d'implémenter du code asynchrone. .

Unity a dû utiliser une solution de contournement pour le code asynchrone. Ils y sont parvenus en exploiter les itérateurs de C# qui était une technique asynchrone populaire à l'époque.

Un aperçu des itérateurs C#

Disons que vous avez ce code :

IEnumerable SomeNumbers() {
  yield return 3;
  yield return 5;
  yield return 8;
}

Si vous le faites passer dans une boucle, en l'appelant comme s'il s'agissait d'un tableau, vous obtiendrez 3 5 8 :

// Output: 3 5 8
foreach (int number in SomeNumbers()) {
  Console.Write(number);
}

Si vous n'êtes pas familier avec les itérateurs (la plupart des langages en ont pour implémenter les listes et les collections), ils fonctionnent comme un tableau. La différence est qu'un callback génère les valeurs.

Comment fonctionnent-ils ?

Lorsque l'on boucle sur un itérateur en C#, on utilise MoveNext pour passer à la valeur suivante.

Dans l'exemple, nous utilisons foreach qui appelle cette méthode sous le capot.

Quand nous appelons MoveNext l'itérateur exécute tout jusqu'à sa prochaine étape. yield . L'appelant parent reçoit la valeur retournée par yield . Ensuite, le code de l'itérateur se met en pause, en attendant le prochain MoveNext appeler.

En raison de leur capacité "paresseuse", les programmeurs C# ont utilisé les itérateurs pour exécuter du code asynchrone.

Programmation asynchrone en C# à l'aide d'itérateurs

Avant 2012, l'utilisation des itérateurs était un hack populaire pour effectuer des opérations asynchrones en C#.

Exemple - Fonction de téléchargement asynchrone :

IEnumerable DownloadAsync(string URL) {
  WebRequest  req      = HttpWebRequest.Create(url);
  WebResponse response = req.GetResponseAsync();
  yield return response;

  Stream resp = response.Result.GetResponseStream();
  string html = resp.ReadToEndAsync().ExecuteAsync();
  yield return html;

  Console.WriteLine(html.Result);
}

PS : Le code ci-dessus est tiré de cet excellent, mais vieux, article sur la programmation asynchrone utilisant des itérateurs : http://tomasp.net/blog/csharp-async.aspx/

Dois-je utiliser async au lieu de StartCoroutine ?

En ce qui concerne 2021, les documents officiels d'Unity utilisent des coroutines dans leurs exemples et non pas des async .

De plus, la communauté semble être plus favorable aux coroutines qu'à l'asynchronisme :

  • Les développeurs connaissent bien les coroutines ;
  • Les coroutines sont intégrées à Unity ;
  • Et d'autres ;

Je recommande cette conférence de l'Unité de 2019, " Les meilleures pratiques : Async vs. coroutines - Unite Copenhagen 2019 " : https://youtu.be/7eKi6NKri6I


PS : C'est une vieille question de 2012, mais j'y réponds parce qu'elle est toujours d'actualité en 2021.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X