Faire des boucles sans "while" ni "for" pour les rendre plus lisibles et maintenables

Le B.A.BA de la programmation, celui que tout le monde, ou presque, commence par apprendre en débutant l'écriture d'algorithmes, ce sont les structures de contrôle en programmation impérative : les tests, les boucles, les sauts...
Mais, avec le temps, cela devient, de moins en moins vrai. Les instructions de sauts ont commencé à disparaitre des langages informatiques quand deux illustres néerlandais et suisse ont évoqué leur nocivité.
En ce qui concerne les structure de tests, il a fallu attendre l'avènement des langages orientés objet pour que le polymorphisme devienne "mainstream". Aujourd'hui, on peut ouvertement se revendiquer anti-if.

La seule structure qui résiste encore bien, ce sont les boucles. Mais la redécouverte de la programmation fonctionnelle à travers sa présence à dose plus ou moins grande dans des langages "en vogue" (python, ruby, javascript, C#, scala...) fait que le développeur lambda[1] peut commencer à entrevoir d'autres manières de décrire des opérations sur des ensembles d'éléments.

Je vais dans ce billet essayer de montrer ce que l'on peut faire en C# sur ce sujet.
Prenons un exemple simple : "afficher les carrés des entiers naturels inférieurs ou égaux à 10"

L'implémentation la plus courante ressemblera à une boucle sur des entiers au sein de laquelle seront mis en oeuvre le calcul et l'affichage adéquats :

for(var i=0; i<=10; i++)
  Console.WriteLine( i*i );

Ca fonctionne, mais ce n'est pas très explicite. L'énoncé du problème comporte 4 exigences distinctes et le code proposé ne permet pas de les expliciter et les implémenter indépendamment les unes des autres :

  1. afficher
  2. les carrés
  3. des entiers naturels
  4. inférieurs ou égaux à 10

Commençons par les points (3) et (4). Pour expliciter les éléments sur lesquels portent l'opération, on pourrait écrire quelque chose dans ce genre :

foreach(var n in Sequence.OfN().TakeWhile( n => n<=10 ))
  Console.WriteLine( n*n );

Sequence.OfN() n'est pas du code standard C#. On va supposer que cela retourne un IEnumerable<int> qui est l'ensemble des entiers naturels. On s'interessera plus tard à son implémentation.
Le TakeWhile( n => n<=10 ) est quelque chose qui devrait être désormais naturel à tous les développeurs C# qui connaissent LINQ et les lambda expressions.

On va maintenant expliciter le point (2) en remontant son calcul dans la définition de l'ensemble de nos éléments :

foreach(var n2 in Sequence.OfN().TakeWhile( n => n<=10 ).Select( n => n*n ))
  Console.WriteLine( n2 );

Pour en finir avec la boucle, il ne reste plus qu'à imaginer un moyen simple pour effectuer un affichage sur un ensemble d'éléments donné :

Sequence.OfN().TakeWhile( n => n<=10 ).Select( n => n*n ).Apply( n2 => { Console.WriteLine(n2); } );

Et voilà... Notre boucle s'est métamorphosée en la concaténation d'opérations indépendantes sur des suites d'éléments :

  1. afficher => Apply( n2 => { Console.WriteLine(n2); } )
  2. les carrés => Select( n => n*n )
  3. des entiers naturels => Sequence.OfN()
  4. inférieurs ou égaux à 10 => TakeWhile( n => n<=10 )

L'avantage d'une telle écriture, c'est que l'on n'est plus condamné à des algorithmes monolithiques où la modification d'une exigence a des impacts sur les autres. On a remplacé cela par des briques de lego que l'on peut facilement recombiner.

Passons maintenant à l'implémentation. Définissons d'abord quelques briques de base sur lesquelles viendront s'appuyer les autres :

  • la définition d'une suite initiale d'éléments IEnumerable<T>. Pour cela, on utilisera 2 méthodes :
    • IEnumerable<T> Of<T>(params T ts) pour une suite définie par la liste explicite de ses éléments
    • IEnumerable<T> Of<T>(Func<T> next) pour une suite définie par une fonction génératrice (chaque appel renvoie l'élément suivant de l'énumération)
  • les opérations de transformation d'un IEnumerable<T> vers un IEnumerable<U> : nul besoin d'écrire quoi que ce soit pour cela, il y a déjà de quoi faire avec le LINQ de base
  • une opération de terminaison void Now<T>(this IEnumerable<T> seq). Toutes les autres opérations étant à évaluation différée, il nous faut une opération qui force l'exécution.

Tout cela fait beaucoup de blabla pour finalement peu de code :

public static class Sequence
{
  public static IEnumerable<T> Of<T>(params T[] ts)
  {
    return ts;
  }

  public static IEnumerable<T> Of<T>(Func<T> next)
  {
    sendNext:
    yield return next();
    goto sendNext; // on aurait pu faire un while(true) mais le goto est plus fun, non ?
    // En tout cas, les codes CLR générés sont identiques.
  }

  public static void Now<T>(this IEnumerable<T> seq)
  {
    seq.Count(); // Demander le compte des éléments est un moyen simple pour forcer l'énumération
  }
}

A partir de là, implémenter notre Sequence.OfN() est une simple formalité. Il suffit d'initier un ensemble d'élément avec une fonction qui renvoie consécutivement les entiers naturels :

public static IEnumerable<int> OfN()
{
  var n=0;
  return Sequence.Of( () => n++ );
}

L'application d'une action sur chaque élément n'est guère plus compliquée :

public static IEnumerable<T> Apply<T>(this IEnumerable<T> seq, Action<T> action)
{
  return seq.Select( item => {action(item);return item;} );
}

Pour être tout à fait précis, le Apply étant défini à évaluation différée, notre exemple initial devrait se terminer par un Now pour forcer l'exécution :

Sequence.OfN().TakeWhile( n => n<=10 ).Select( n => n*n ).Apply( n2 => { Console.WriteLine(n2); } ).Now();

Passons maintenant à quelque chose d'un peu plus évolué. Les carrés des 10 premiers entiers, c'est sympa, mais la réalité est souvent plus complexe.
Prenons, par exemple, l'affichage des 20 premiers termes d'une suite d'entiers définie par récurrence :

var u = 1;
for(var i=0; i<20; i++)
{
  Console.WriteLine( u );
  u = 2*u+1;
}

Notre nouvelle écriture ressemblera à ça :

Sequence.Recurrence(1, x => 2*x+1).Take(20).Apply( n => { Console.WriteLine(n); } ).Now();

La méthode Recurrence est assez facilement définie à partir des autres méthodes d'initialisation :

public static IEnumerable<T> Recurrence<T>(T u0, Func<T,T> f)
{
  var u = u0;
  return Sequence.Of(u0).Concat( Sequence.Of( () => u=f(u) ) );
}

Notre collection ne pourrait pas être complète sans la star des algorithmes de boucles favoris des débutants : la suite de Fibonnacci.
En version "classique" :

var u = 1;
var v = 0;
for(var i=0; i<20; i++)
{
  Console.WriteLine( u );
  var w = u;
  u = u+v;
  v = w;
}

Et en version "moderne" :

Sequence.Fibonnaci().Take(20).Apply( n => { Console.WriteLine(n); } ).Now();

public static IEnumerable<int> Fibonnaci()
{
  return Sequence.Recurrence(new {fib=1,fib2=0}, x => new {fib=x.fib+x.fib2, fib2=x.fib}).Select(x => x.fib);
}

On remarquera au passage l'utilisation de types anonymes statiques.

Et pour finir, un petit exemple avec condition d'arrêt portant sur les élements manipulés avec un autre classique, la division euclidienne :

var a = 34;
var b = 5;

var q = 0;
var r = a;
while(r >= b)
{
  Console.WriteLine( "( q = {0}, r = {1} )", q ,r );
  q++;
  r -= b;
}

var quotientAndRemainder = Sequence
  .Recurrence(new{q=0,r=a}, x => new{ q = x.q+1, r = x.r-b })
  .Apply( x => { Console.WriteLine(x); } )
  .SkipWhile(x => x.r>=b)
  .First();

C'est fini...
J'espère que ce petit aperçu sur la manière d'écrire des boucles "autrement" donnera des envies d'exploration à ceux qui n'avaient jamais envisagé ces techniques d'écriture de code.
Comme d'habitude, les commentaires sont les bienvenus !

Notes

[1] Ce billet n'est pas destiné au développeur qui a SICP comme livre de chevet mais à un hypothétique développeur moyen tout comme on peut parler du "français moyen"

Denis

Je pense que les methodes statiques de Enumerable font l'afffaire pour generer la sequence :

Sequence.OfN().TakeWhile( n => n<=10 ) devient
Enumerable.Range(0,10)

Denis 18 octobre 2011 - 00:06
Oaz

C'est vrai dans l'exemple proposé !

Mais je préfère rester dans un cadre plus général où je ne couple pas génération d'un ensemble, potentiellement "infini", d'éléments et filtrage de ces éléments.

Suis-je trop extremiste ?

Oaz 18 octobre 2011 - 01:20
Loic

Je ne vois pas en quoi "Sequence.OfN().TakeWhile( n => n<=10 ).Select( n => n*n ).Apply( n2 => { Console.WriteLine(n2); } ).Now();" est plus lisible et maintenable que "for(var i=0; i<=10; i++) Console.WriteLine( i*i );" .

L'exercice est intéressant mais remplacer un code clair, qui fonctionne, compréhensible par un enfant de 10 ans par un code abstrait, verbeux et qui nécessite de connaitre par coeur une API ainsi que les caractéristiques les moins connues d'un langage donné.

Il faut 10 secondes pour comprendre la boucle si l'on connait un tout petit peu la programmation. 10 minutes minimum pour comprendre le code si l'on connait très bien le C#.

Sans compter la multiplication des lignes de code et donc du risque d'erreur, et celui d'avoir des codeurs qui ne maitrisent pas bien l'API et qui l'utilisent mal. Par exemple je ne comprends pas le Now().

Je reste fasciné par cet exercice que je trouve passionant et j'en remercie l'auteur mais je reste dubitatif sur son intéret final.

Loic 30 novembre 2011 - 10:16
Loic

Olivier, je suis en fait tombé sur ton blog par hasard, mais je viens de réaliser que nous nous sommes rencontrés lors de l'agile tour -l'exercice ballon de rugby dans lequel j'étais un de tes cobayes ;-) -.

Je pense adhérer au SigmaT et je crois que tu en fais partie, suite à mon précédent commentaire je serai ravi de discuter de vive voix de ce passionnant exercice de suppression des for/while/foreach avec toi un de ces jours ;)

Loic 30 novembre 2011 - 10:21
Oaz

Bonjour Loïc,

Bien évidemment l'écriture que tu reprends est moins lisible que la simple boucle lorsqu'il n'y a pas de complexité supplémentaire.
Comme je l'écris dans le billet, la réalité est souvent plus complexe et il n'est pas toujours facile de représenter cette complexité sur des exemples que l'on souhaite garder assez courts.

Je vais essayer un autre exemple.

Soit un programme qui :

  1. demande à l'utilisateur de choisir une fonction parmi les suivantes :
    - mettre un entier au carré
    - calculer la somme des entiers inférieurs ou égaux à un entier donné
    - calculer le k-ième terme de la suite de Syracuse à partir d'un entier donné, k étant un entier saisi par l'utilisateur
  2. demande à l'utilisateur de choisir un filtre parmi les suivants :
    - prendre les k premiers éléments, k étant un entier saisi par l'utilisateur
    - prendre les éléments inférieurs ou égaux à k, k étant un entier saisi par l'utilisateur
    - prendre les éléments impairs inférieurs ou égaux à k, k étant un entier saisi par l'utilisateur
  3. Lorsque l'utilisateur a fait ces choix, affiche les images par la fonction choisie précédemment des entiers naturels strictement positifs restreints au filtre lui aussi choisi précédemment
Exemples d'utilisation:

L'utilisateur choisit "mise au carré", puis "4 premiers éléments"
Le programme affiche : 1, 4, 9, 16

L'utilisateur choisit "somme des entiers inférieurs ou égaux", puis "impairs inférieurs à 17"
Le programme affiche : 1, 3, 15

L'utilisateur choisit "3ème terme de la suite de Syracuse", puis "éléments inférieurs à 6"
Le programme affiche : 2, 4, 5, 1

Ma proposition d'implémentation d'un tel programme sera dans un prochain billet...

Et j'espère à bientôt lors d'une rencontre SigmaT !

Oaz 30 novembre 2011 - 20:56
jojo76

Cette démonstration de comment transformer une simple boucle en une structure linéaire complexe et illisible ne me semble pas aller vers une amélioration de la programmation et de la lisibilité des programmes. Je ne parle même pas des trésors de pédagogie, qu'il faudrait déployer pour enseigner cette méthode à des étudiants. Enfin, l'utilisation de Goto est un comble. Sa nocivité a été démontrée depuis longtemps par E. Dijkstra. Pour moi, la solution présentée est verbeuse, inutilement complexe.

jojo76 15 décembre 2011 - 09:33
Oaz

@jojo76

Vous avez le droit d'avoir votre propre opinion mais ça serait encore mieux si vous pouviez nous proposer une implémentation avec une simple boucle, lisible et enseignable à des étudiants du programme dont j'ai proposé les spécifications ci-dessus...

Par ailleurs, votre référence à EWD concernant mon goto pour faire une boucle infinie sur un générateur me laisse perplexe...

Oaz 18 décembre 2011 - 19:22
Jeannette

Je comprends l'intention et la démarche.
Ce qui me déplais c'est l'approche "à l'anglaise", cad que ce qu'on dit en langage naturel arrive à la fin ( en premier je veux... afficher ).
C'est dommage de vouloir casser les codes pour en mettre d'autres qui sont de toute façon très éloigné du langage naturel.
Ce qui me chagrine aussi c'est d'essayer de changer des habitudes et des usages qui, certes sont perfectibles, qui fonctionnent très bien et depuis longtemps.
Alors qu'il y a de tels chantiers à lancer ! ( par exemple un vrai langage pour le web, des passerelles concrètes pour passer de la conception au code, de l'harmonie entre les langages etc. )

Jeannette 25 janvier 2012 - 16:23
Oaz

Pour la syntaxe "à l'anglaise", j'en suis désolé mais c'est le lot des langages orientés objet... Ca ne choque plus grand monde d'écrire "document.ouvrir()", non ?

Par ailleurs, pour clarifier les choses, je n'ai pas pour objectif ici de me rapprocher du langage naturel de telle ou telle langue.
Ecrire du code, c'est manipuler des concepts de manière concise. A mon avis, l'ambiguité inhérente aux langages naturels fait qu'il ne pourront jamais réellement servir à écrire du code.

Le but de l'exercice présenté ici (et poursuivi par là) est de conserver dans le code les séparations claires et nettes déjà présentes dans les concepts que l'on veut exprimer.
Rien de plus...

Pour le reste, je suis bien d'accord que le développement logiciel est un métier encore jeune et qu'il reste beaucoup à faire...

Oaz 25 janvier 2012 - 23:24

Fil des commentaires de ce billet

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.