FizzBuzz en TypeScript, épisode 1 : vers l'infini et au-delà des listes

Proche de sa sortie, j'ai lu l'article de Gautier Di Folco sur sa résolution du FizzBuzz kata: branchless version.

Outre l'utilisation du pattern matching pour ne pas créer de branche, sa résolution utilise des structures de listes infinies.

On va retrouver :

numbers :: [String]
numbers = show <$> [1..]

Qui représente ici tous les entiers positifs (de 1 à +infini) sous forme de String : c'est un Range

Mais aussi :

fizzs :: [Maybe String]
fizzs = cycle [Nothing, Nothing, Just "Fizz"]

Qui représente ici un cycle pouvant s'apparenter à [Nothing, Nothing, Nothing, Nothing, Just "Buzz", Nothing, Nothing, Nothing, Nothing, Just "Buzz", ..] jusqu'à l'infini.

Et pour de l'agrégation :

zipWith (<>) fizzs buzzs

Qui va permettre de fusionner deux listes élément par élément en une seule en fonction d'une opération : n'hésitez pas à lire le reste de l'article de Gautier pour mieux comprendre.

Cependant, en TypeScript, le langage ne nous propose ni de Range, ni de Cycle, ni de Zip, c'est pourquoi je vous propose de créer ces structures dans ce premier épisode.

Range

Imaginons une structure de List en point d'entrée dans laquelle on va utiliser la méthode range(…) pour fabriquer notre Range :

List.range(0, 2) // 0, 2, 4, 6, 8, 10, ….

export abstract class List<T> {
  abstract at(index: number): T | undefined;

  get values(): T[] {
    // …
  }

  abstract get length();

  static range(start: number, step: number): List<number> {
    return new Range(start, step);
  }
  
  // …
}

Notre Range ressemble alors à :

class Range extends List<number> {
  constructor(
    private readonly start: number,
    private readonly step: number
  ) {
    super();
  }

  get length(): number {
    return +Infinity;
  }

  at(index: number): number | undefined {
    return this.start + index * this.step;
  }
}

Le range est une liste dont la length est infinie et dont on peut retrouver à son index la somme de start avec le produit de la valeur de l'index avec le step :

Pour un Range avec un start de 2 et 4 en step, on a alors :

  • Pour l'index 1 : 2 + 1 × 4 = 6
  • Pour l'index 3 : 2 + 3 × 4 = 14

Mapper

Pour transformer notre liste de number en liste de string, nous avons besoin de .map :

List.of(1, 2, 3).map(number => number.toString()) // '1', '2', '3'.

export abstract class List<T> {
  // …

  map<U>(transform: (value: T) => U): List<U> {
    return new Mapper(this, transform);
  }

  // …
}

Ici, on choisit de retourner un Mapper afin de ne pas évaluer tout de suite la transformation tout en conservant l'opération précédente via le this.

class Mapper<T, U> extends List<U> {
  constructor(
    private readonly list: List<T>,
    private readonly transform: (value: T) => U
  ) {
    super();
  }

  get length(): number {
    return this.list.length;
  }

  at(index: number): U | undefined {
    const toTransform = this.list.at(index);
    if (toTransform === undefined) {
      return undefined;
    }
    return this.transform(toTransform);
  }
}

Lorsque la méthode at(…) sera évaluée, la transformation se fera, mais comme il n'est pas possible de savoir s'il y aura une valeur ou non à un index précis, le transform(…) sera lancé seulement en présence de la valeur (lorsqu'elle n'est pas undefined).

Cycle

Le Cycle permet de répéter un ensemble de valeurs :

List.cycle(1, 2, 3); // 1, 2, 3, 1, 2, 3, 1, 2, 3, …

export abstract class List<T> {
  // …

  static cycle<T>(...values: T[]): List<T> {
    return new Cycle(values);
  }

  // …
}

Avec une classe Cycle :

class Cycle<T> extends List<T> {
  readonly #values: T[];
  constructor(values: T[]) {
    super();
    this.#values = values;
  }

  get length(): number {
    if (this.#values.length === 0) {
      return 0;
    }
    return +Infinity;
  }

  at(index: number) {
    return this.#values[index % this.#values.length];
  }
}

Pour correspondre à une répétition d'un même ensemble de valeurs, on utilise donc un modulo % pour retrouver une valeur par rapport à la taille des valeurs à répéter #values.length.

La taille peut être à 0 s'il n'y a rien à répéter.

Zip

Un Zip permet de regrouper deux listes ensembles avec une opération de transformation basée sur les éléments de chaque List :

Exemple :

const sum = (first: number, second: number): number => first + second;
List.of(1, 2, 3).zipWith(sum, List.of(4, 5, 6));
// 5, 7, 9

Avec :

export abstract class List<T> {
  // …

  zipWith<S, R>(concat: Concat<T, S, R>, other: List<S>): List<R> {
    return new Zip(concat, this, other);
  }

  // …
}

Et la classe Zip :

class Zip<F, S, R> extends List<R> {
  constructor(
    private readonly concat: Concat<F, S, R>,
    private readonly firstList: List<F>,
    private readonly secondList: List<S>
  ) {
    super();
  }

  get length(): number {
    return Math.min(this.firstList.length, this.secondList.length);
  }

  at(index: number): R | undefined {
    const first = this.firstList.at(index);
    const second = this.secondList.at(index);
    if (first === undefined || second === undefined) {
      return undefined;
    }
    return this.concat(first!, second!);
  }
}

La taille d'un Zip correspond ici à la sous liste la plus petite.

L'important c'est les valeurs

Pour récupérer des valeurs de ces listes, on utilisera at qui lance l'évaluation au moment de son appel.

On parle alors d'évaluation paresseuse ou lazy evaluation.

C'est utile dans notre cas parce que ça nous permet de représenter des listes infinies puisqu'on n'a pas à calculer toutes les valeurs.

Pour partitionner nos valeurs et récupérer une liste finie, on peut maintenant utiliser between :

export abstract class List<T> {
  // …

  between(from: number, to: number): List<T> {
    return List.of(...iterate(to - from + 1, from).map((index) => this.at(index)!));
  }

  // ...
}

Ici le to est inclus.

Et pour avoir ce sous ensemble de valeur :

const iterate = (times: number, from = 0): number[] =>
  Array(times)
    .fill(from)
    .map((base, index) => base + index);

export abstract class List<T> {
  // …

  get values(): T[] {
    if (this.length === +Infinity) {
      throw new Error(`${this.name} list is infinite`);
    }
    return iterate(this.length).map((index) => this.at(index)!);
  }

  // ...
}

List.range(10, 1).between(3, 5).values // 13, 14, 15.

Fin du premier épisode

Nous venons de voir les notions suivantes :

  • Quelques concepts derrières des listes infinies ;
  • Les notions de Range, Cycle et Zip ;
  • La lazy evaluation sur les valeurs.

À bientôt pour l'épisode 2…