Extracteur de mots en C# à l’aide de Reactive Extensions (Rx)
Le contexte
Voyez-vous, je suis responsable de la formation avancée donnée aux nouveaux développeurs qui arrivent chez nventive. Là, je dis nouveaux, mais sachez qu’ils ne sont normalement pas nouveaux en programmation. Il m’arrive même souvent d’affirmer que nventive possède un niveau technique anormalement élevé pour une simple entreprise de développement informatique.
Pendant cette dure journée pour les nouveaux, il est d’usage quand ils sortent de la salle que le reste de l’équipe leur demande s’ils veulent toujours travailler pour nous. À date, personne n’a encore démissionné après cette épreuve initiale.
Cette formation consiste principalement à les familiariser avec la technologie Reactive Extensions de Microsoft. Elle n’est pas simple à aborder, mais je prétends avoir trouvé une manière plutôt efficace pour en expliquer les subtilités.
La requête
Suite à cette formation qui a eu lieu hier, précisément, un des nouveaux (Étienne AKA « Razor ») est venu me voir pour me demander comment je m’y prendrais pour faire un extracteur de mots à partir d’une source observable de caractères.
J’ai trouvée l’idée bonne et je me suis même dit que ça constituait un excellent prétexte pour faire une entrée sur ce blog qui moisit tranquillement sur mes serveurs…
Notre au lecteur : si vous n’êtes pas programmeur ou si vous ne connaissez pas Reactive Extensions, il est possible que le reste de cet article vous semble un tas de mots qui ne fait aucun sens. Je m’en excuse d’avance.
La stratégie
Tout d’abord, la source de données est nécessairement exprimable sous la forme suivante :
IObservable<char> chars;
Le résultat à obtenir devra normalement être du type suivant :
IObservable<string> words;
Les critères d’acceptation que je me donne sont les suivant :
- Les seules données qui peuvent être mises en state sont les caractères d’un mot non-complété. Ces données sont spécifiques à une subscription en particulier.
- Aucun processus d’accumulation de caractères ne doit avoir lieu s’il n’y a pas de subscription active.
- Aucun scheduler : tout le processus doit avoir lieu sur le thread où les caractères arrivent. L’utilisateur pourrait toujours faire un .ObserverOn() s’il désire changer le scheduler, mais aucune décision de ce genre n’est prise dans l’implémentation.
- Il doit être possible pour l’appelant de spécifier le discriminant entre un caractère qui fait parti d’un mot et d’un autre qui ne l’est pas. Par défaut, on va utiliser le char.IsLetterOrDigit() pour effectuer cette séparation.
- Le résultat doit être de qualité professionnelle. Plus qu’un code d’exemple, il doit avoir tout ce qu’il faut pour être utilisable en production.
C’est la subscription à ce dernier qui va lancer tout le processus d’écoute et d’accumulation de caractères dans le but de former des mots.
Les Method Extensions
La solution sera la mise en place de la method extension GetWords(this IObservable<char> source) qui se met sur un observable de caractères et permet d’en sortir des observables de chaînes de caractères (string). Voici les déclarations :
public static IObservable<string> GetWords(this IObservable<char> source) { // Use a default discriminator for non-word characters return source.GetWords(c => !char.IsLetterOrDigit(c)); } public static IObservable<string> GetWords( this IObservable<char> source, Func<char, bool> wordSeparatorSelector) { return Observable.Create<string>( observer => new WordExtractorSubscription( observer, source, wordSeparatorSelector)); }
Noter ici l’utilisation de Observable.Create(). Cette méthode, fournie par le framework Reactive Extensions, permet de créer un observable qui va gérer automatiquement les abonnements (subscriptions) tout en déléguant toute la mécanique de gestion de l’observer à une méthode lambda fournie (mise en gras dans le code qui précède).
L’extracteur de mots
Vous l’avez probablement deviné, tout se passe dans la classe WordExtractorSubscription. Cette classe a la responsabilité de gérer l’état d’un abonnement réactif (Subscription) et de traiter les caractères les uns après les autres tout en accumulant les caractères jusqu’à ce qu’un mot complet soit formé. Le fonctionnement en détails est le suivant :
- L’accumulateur (ici un List<char>) est initialisé avec une capacité de départ à 12 caractères. Ça devrait suffir pour la majorité des cas et il pourra grossir en cas de besoins.
- Dès la construction, on crée un abonnement _innerSubscription vers la source de caractères, dans le but d’activer la source de caractères. La classe WordExtractorSubscription agit à titre d’observer dans ce cas-ci. Ce qui lui permet de recevoir les OnNext, OnError et OnCompleted et de les traiter en conséquence.
- Pour chaque caractère qui arrive (via le OnNext), on valide s’il s’agit d’un caractère qui est considéré comme faisant parti d’un mot ou pas.
A) S’il appartient à un mot, on l’accumule. Aucun autre traitement n’est nécessaire.
B) S’il n’appartient pas à un mot, ça veut dire que ce qui est accumulé à date (s’il y en a) est considéré comme un mot et doit être envoyé à l’observer de destination. - Si on reçoit un OnError, on le repasse simplement à notre observer et, suivant la grammaire Reactive Extension, notre abonnement est mort : on peut donc tout cleaner sans autre forme de procès.
- Si on reçoit un OnCompleted, on peut le repasser à notre observer, mais auparavant, il faut d’abord valider s’il y a des caractères accumulés et les notifier comme étant le dernier mot. Toujours selon la grammaire Reactive Extension, l’abonnement est échu et on peut tout nettoyer.
Voici le code de la classe WordExtractorSubscription :
private class WordExtractorSubscription : IDisposable, IObserver<char> { private readonly IObserver<string> _observer; private readonly Func<char, bool> _wordSeparatorSelector; private readonly IDisposable _innerSubscription; private readonly List<char> _accumulatedChars = new List<char>(10); private volatile bool _isDisposed; public WordExtractorSubscription( IObserver<string> observer, IObservable<char> source, Func<char, bool> wordSeparatorSelector) { _observer = observer; _wordSeparatorSelector = wordSeparatorSelector; _innerSubscription = source.Subscribe(this); } public void Dispose() { if(_isDisposed) { return; } _innerSubscription.Dispose(); _isDisposed = true; } public void OnNext(char value) { if (!_wordSeparatorSelector(value)) { // this character is good, accumulate it lock(_accumulatedChars) { _accumulatedChars.Add(value); } return; // prevent completition of accumulate word } // Here, we got a non-word character, // so need to send accumulated chars as a word CompleteWord(); } private void CompleteWord() { string word; lock (_accumulatedChars) { // Any accumulated chars ? if (!_accumulatedChars.Any()) { return; // nope } // Create the word string word = new string(_accumulatedChars.ToArray()); // Clear accumulation list _accumulatedChars.Clear(); } _observer.OnNext(word); } public void OnError(Exception error) { // This subscription is dead now Dispose(); // Send the error to observer _observer.OnError(error); } public void OnCompleted() { // Send any completed word before finishing CompleteWord(); // Terminates everything Dispose(); // Send completed to observer _observer.OnCompleted(); } }
Le test
Afin de valider que tout fonctionne, il importe de tout faire sur un test unitaire. Le test suivant devrait faire le travail rapidement. Il est incorporé à la solution complète de la section suivante.
La solution complète
using System; using System.Collections.Generic; using System.Linq; using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Reactive.Linq; namespace RxWordMachine { [TestClass] public class CharExtensionsFixture { [TestMethod] public void TestGetWords() { // This is the input const string sourceText = @" This-is-THE*string we --------------- want to GET ====>as!!a!/result!!"; // Convert the input as observable of chars IObservable<char> chars = sourceText.ToObservable(); // Get an observable of words IObservable<string> words = chars.GetWords(); // Execute everything and get the result as an array of strings var result = words.ToEnumerable().ToArray(); // Create a result string who join all words var resultString = string.Join(" ", result); Assert.AreEqual( "This is THE string we want to GET as a result", resultString); // it works! } } public static class CharExtensions { public static IObservable<string> GetWords(this IObservable<char> source) { // Use a default discriminator for non-word characters return source.GetWords(c => !char.IsLetterOrDigit(c)); } public static IObservable<string> GetWords( this IObservable<char> source, Func<char, bool> wordSeparatorSelector) { return Observable.Create<string>( observer => new WordExtractorSubscription( observer, source, wordSeparatorSelector)); } private class WordExtractorSubscription : IDisposable, IObserver<char> { private readonly IObserver<string> _observer; private readonly Func<char, bool> _wordSeparatorSelector; private readonly IDisposable _innerSubscription; private readonly List<char> _accumulatedChars = new List<char>(10); private volatile bool _isDisposed; public WordExtractorSubscription( IObserver<string> observer, IObservable<char> source, Func<char, bool> wordSeparatorSelector) { _observer = observer; _wordSeparatorSelector = wordSeparatorSelector; _innerSubscription = source.Subscribe(this); } public void Dispose() { if (_isDisposed) { return; } _innerSubscription.Dispose(); _isDisposed = true; } public void OnNext(char value) { if (!_wordSeparatorSelector(value)) { // this character is good, accumulate it lock (_accumulatedChars) { _accumulatedChars.Add(value); } return; // prevent completition of accumulate word } // Here, we got a non-word character, // so need to send accumulated chars as a word CompleteWord(); } private void CompleteWord() { string word; lock (_accumulatedChars) { // Any accumulated chars ? if (!_accumulatedChars.Any()) { return; // nope } // Create the word string word = new string(_accumulatedChars.ToArray()); // Clear accumulation list _accumulatedChars.Clear(); } _observer.OnNext(word); } public void OnError(Exception error) { // This subscription is dead now Dispose(); // Send the error to observer _observer.OnError(error); } public void OnCompleted() { // Send any completed word before finishing CompleteWord(); // Terminates everything Dispose(); // Send completed to observer _observer.OnCompleted(); } } } }
Optimisations possibles
Le code est assez optimal. Cependant, il y a un petit risque d’utilisation intensive de la mémoire s’il advenait à accumuler un mot particulièrement long : la taille de l’accumulateur pourrait devenir importante et la mémoire prise ne sera pas libérée tant que l’abonnement ne sera pas terminé.
Conclusion
Il est relativement facile, lorsque l’on connait bien Réactive Extension, d’en faire joujou et de se monter une boite à outils d’utilitaires de la sorte. Ils sont hautement réutilisable et particulièrement performants.
Laisser un commentaire