Pourquoi les enums sont à éviter

... et comment les remplacer

Je ne compte plus les projets sur lesquels j'ai travaillé qui utilisaient les enums à tort et à travers. Mais alors quels sont les best practices autour de leur utilisation ? Quand doit-on les utiliser et quand doit-on les éviter comme la peste ?

En voiture Simone, je vais te donner mon avis (après tout t'es là pour ça).

Les enums, à éviter ?

Dans certains cas, les enum sont parfaitement valables. Dans d'autres, c'est se tirer une balle dans le pied, le genou et la hanche.

Je parle par exemple de :

  • La navigation
  • Les events de tracking
  • Les APIs

Ce sont les cas les plus fréquents d'utilisation foireuse que j'ai vu et, comme j'ai vu très peu d'appli sans appel réseau ou tracking, autant te dire que ça se retrouve dans beaucoup de codebase.

Pourquoi ? Pour plein de raisons.

Impossible d'ajouter des case

Plus précisément, il est impossible d'ajouter des case ailleurs que dans la définition de l'enum.

Et ça c'est chiant.

Pour des petites enums, c'est marginal mais si on reprend les exemples du dessus, il n'est pas rare que ça dépasse les dizaines voire centaines de lignes. C'est dans ces cas là qu'on aurait aimé pouvoir splitter l'enum en plusieurs fichiers. Sans parler de l'apport que cela aurait pour le namespacing, encapsulation et j'en passe.

Ajouter des propriétés est une vraie corvée

Très souvent dans les enums, on vient ajouter des propriétés dans lesquels on utilise un switch/case pour spécifier la bonne valeur.

À chaque ajout de propriété, on augmente donc la longueur du fichier par le nombre de case. Et ça peut aller très vite. Même si on aimerait ne l'ajouter que pour quelques cas spécifiques.

De la même manière, si on veut voir les valeurs de toutes les propriétés pour un cas donné, on est bons pour se farcir chaque switch/case de chaque propriété.

Bienvenue en enfer.

Les principes SOLID en sueur

Imagine qu'on a une enum bien grosse, tout plein de proprétés, le cas basique quoi, on est contents.

Maintenant imagine qu'on veut rajouter un nouveau cas, on est moins contents.

Parce qu'il va falloir le rajouter dans TOUTES les propriétés et donc dans tous les switch/case, sans compter les usages extérieurs.

Et ça, le "O" de SOLID, il aime pas des masses.

Pour ceux qui sont fachés avec les principes SOLID, le "O" concerne le "Open/Closed principle". En gros, un objet doit pouvoir être étendu mais pas modifié. Concrètement, tu peux ajouter des propriétés, des méthodes et autres, à ton objet mais tout ça sans venir modifier l'existant.

Or, ici, dans le cas où on veut ajouter un nouveau cas, on vient modifier l'enum de base, ainsi que chacune de ses propriétés.

Le risque c'est qu'on vienne péter quelque chose en faisant ces modifications.

Mais alors on fait quoi ?

Maintenant qu'on a craché sur les enums bien comme il faut, il s'agirait de trouver comment faire mieux. Et ça tombe bien parce que j'ai quelques idées.

Protocol-oriented development

C'est un peu la solution à tous nos problèmes mais encore une fois on va s'en sortir à coup de protocol (mais pas que, trop de suspense).

Prenons l'exemple du tracking et voyons en quoi ça nous aide.

Partons du principe qu'à la base on a cet enum :

enum TrackingEvent {
  case homeViewed
  case homeButtonTapped
  ...
  
  var name: String {
    switch self {
      case .homeViewed: "home-viewed"
      case .homeButtonTapped: "home-button-tapped"
    }
  }
  
  var parameters: [String: Any] {
    ...
  }
}

Et ce tracker :

struct Tracker {
  func track(_ event: TrackingEvent) {
    ...
  }
}

Si ça ressemble à ta codebase, félicitations, tu vas voir comment on peut faire (beaucoup) mieux.

La première chose à faire est de déclarer notre protocol :

protocol TrackingEvent {
  var name: String { get }
  var parameters: [String: Any] { get }
}

Rien de sorcier, c'est plus ou moins une copie de l'enum.

Et c'est là que la magie opère, pour définir nos events de tracking, on a juste à implémenter ce protocol :

struct HomeViewedTrackingEvent: TrackingEvent {
  let name: String = "home-viewed"
  let parameters: [String: Any]
}

Et c'est tout.

On vient de régler notre premier problème, plus d'enum de centaines de lignes. on peut enfin ajouter les events comme on veut, dans différents fichiers, dans différents packages, où on veut, sky is the limit comme disent les ricains.

Plus de souci non plus pour SOLID puisque chaque event vit indépendemment les uns des autres.

Le seul problème qu'il nous reste est l'ajout de propriété, qui est un peu plus touchy. Ajouter une propriété au protocole implique de repasser sur tous les objets qui l'implémente. Ou bien on peut passer par un autre protocol :

protocol LabeledTrackingEvent: TrackingEvent {
    var label: String { get }
}

En modifiant l'event, ça donne :

struct HomeViewedTrackingEvent: LabeledTrackingEvent {
  let name: String = "home-viewed"
  let label: String = "home-viewed-label"
  let parameters: [String: Any]
}

On pourrait se dire qu'à travers cette approche on perd la "dot-syntax" si pratique de l'enum. Mais non. Pour cela on peut passer par des extensions du protocol :

extension TrackingEvent where Self == HomeViewedTrackingEvent {
    static func homeView(param: [String: Any]) -> Self {
        HomeViewedTrackingEvent(parameters: param)
    }
}

Ce qui donnerait comme call-site :

tracker.track(.homeView(param: params))

Nice !

L'avantage de cette solution est qu'elle permet d'avoir différents initializer, stored properties et autres puisque chaque implémentation vit sa propre vie. On a la polyvalence du protocol tout en ayant la simplicité d'utilisation de l'enum.

L'inconvénient c'est que c'est un poil verbeux.

Struct et variables statiques

L'alternative est d'utiliser directement une struct.

Prenons par exemple le cas des fonts si tu es en train d'implémenter un design system.

struct TextStyle {
  let font: Font
  let lineHeight: CGFloat
  let letterSpacing: CGFloat
}

Cette fois on va passer par des variables statiques pour définir les différentes options :

extension TextStyle {
  static let body = TextStyle(font: .system, lineHeight: 12, letterSpacing: 4)
}

On pourra ensuite l'utiliser de cette façon :

Text("Hello world")
  .style(.body)

L'avantage principal par rapport à l'approche avec protocol est que c'est moins verbeux.

Seul souci : impossible de rajouter des stored properties ou des initializers pour chaque "implémentation".

C'est pour cela que je priviligies cette approche dans les cas les plus simples où je n'aurai pas besoin d'avoir d'init custom, avec peu de propriétés.


Comme souvent, il n'y a pas qu'une seule solution à un problème. Le vrai problème est de trouver la bonne solution.

Ce ne sont ici que des exemples que j'utilise au quotidien mais ce ne sont pas les seuls alternatives aux enums.

N'hésite pas à consulter la codebase Swift, elle regorge d'exemple. Après tout c'est open-source alors autant ne pas se priver.

Si tu as des questions ou des remarques n'hésite pas à me contacter sur LinkedIn, je me ferai un plaisir de te répondre.

Cheers.

ARTICLE PRÉCÉDENT →← ARTICLE SUIVANT