Publié par
Il y a 1 année · 7 minutes · Back, Craft

Les Lenses, pour y voir plus clair dans ton code

Qui, dans son projet, ne dédie pas une grande partie de son temps à l’écriture du code de manipulation de structures de données ? Ce code, bien que nécessaire, représente rarement un intérêt majeur pour les problématiques fonctionnelles que notre application est censée résoudre. C’est pourquoi nous vous proposons dans cet article une solution pour accéder et mettre à jour ces structures de données.

Dans un logiciel, nos entités (au sens large) vivent au gré de l’exécution, et peuvent être créées, supprimées, mais aussi mises à jour ; elles constituent l’état du programme. Le paradigme fonctionnel « pur » impose l’immutabilité de nos structures de données, et la mise à jour de celles-ci implique donc de les dupliquer en y appliquant la modification désirée.

Prenons l’exemple d’une structure complexe en Scala :

case class Street(number: Int, name: String)
case class Town(postalCode: String, name: String)
case class Address(street: Street, town: Town)
case class Person(firstName: String, lastName: String, address: Address)

def updateStreetNumber(person: Person, streetNumber: Int): Person = person.copy(
  address = person.address.copy(
    street = person.address.street.copy(
      number = streetNumber
    )
  )
)

Cette méthode est ensuite utilisée de cette manière :

"boiler plate function" should "update street number" in {
  val person = Person("John", "Doe", Address(Street(12, "Rue de Picpus"), Town("75012", "Paris")))

  val updatedPerson = updateStreetNumber(person, 45)

  updatedPerson.address.street.number should be(45)
}

Cet exemple souligne la présence de code boilerplate lors de l’écriture d’une fonction de mise à jour de notre structure. En plus d’être rébarbatives à l’écriture, ces opérations de mise à jour ne sont ni très réutilisables, ni composables. Il convient donc d’écrire autant de fonctions qu’on veut mettre à jour de propriétés.

def updateFirstName(person: Person, firstName: String) = person.copy(firstName = firstName)

def updateAddress(person: Person, address: Address): Person = person.copy(address = address)

def updateStreet(person: Person, street: Street): Person = person.copy(
  address = person.address.copy(
    street = street
  )
)

def updateStreetNumber(person: Person, streetNumber: Int): Person = person.copy(
  address = person.address.copy(
    street = person.address.street.copy(
      number = streetNumber
    )
  )
)

Il existe une solution fonctionnelle, élégante, composable et réutilisable. Elle est pourtant assez peu utilisée sur les projets. Il s’agit d’une abstraction appelée Lens, sur les fonctions get et set.

Les lenses

Le Lens (lentille) est une abstraction permettant de zoomer sur une propriété P d’une structure S. Il peut être défini de cette manière :

trait Lens[S, P] {
  def get: S => P
  def set: (S, P) => S
  def update(p: P)(s: S): S = set(s, p)
}

get permet d’accéder à la propriété P à partir de S; set permet de mettre à jour la propriété P de S, et retourne S.

Voici par exemple l’instance de Lens pour lire et mettre à jour le nom d’une personne :

val _personFirstName = new Lens[Person, String] {
  override def get: (Person) => String = _.firstName

  override def set: (Person, String) => Person = {
    case (p, n) => p.copy(firstName = n)
  }
}

Voici l’instance de Lens qui permet de lire et de mettre à jour l’adresse d’une personne :

val _personAddress = new Lens[Person, Address] {
  override def get: (Person) => Address = _.address

  override def set: (Person, Address) => Person = {
    case (p, a) => p.copy(address = a)
  }
}

L’utilisation est simple :

"lenses" should "update values" in {
  val person = Person("John", "Doe", Address(Street(12, "Rue de Picpus"), Town("75012", "Paris")))
  val p1 = _personFirstName.update("Joe")(person)

  _personFirstName.get(p1) should be("Joe")

  val p2 = _personAddress.update(Address(Street(20, "Rue de Montreuil"), Town("75011", "Paris")))(person)
  
  _personAddress.get(p2) should be(Address(Street(20, "Rue de Montreuil"), Town("75011", "Paris")))

  val p3 = _personAddress.update(Address(Street(20, "Rue de Montreuil"), Town("75011", "Paris")))(p1)

  _personAddress.get(p3) should be(Address(Street(20, "Rue de Montreuil"), Town("75011", "Paris")))
}

Chaque Lens contient la logique permettant d’accéder et de mettre à jour la propriété désirée. Ainsi on accroit la maintenabilité du composant, mais également la manière de le composer.

trait Lens[S, P] {
  self =>

  def and[_P](next: Lens[P, _P]): Lens[S, _P] = new Lens[S, _P] {
    override def get: (S) => _P = s => next.get(self.get(s))

    override def set: (S, _P) => S = {
      case (s, _p) => self.set(s, next.set(self.get(s), _p))
    }
  }
}

Cette méthode permet de composer les Lenses, et c’est ici que réside toute la puissance de cette abstraction :

val _addressStreet = new Lens[Address, Street] {
  override def get: (Address) => Street = _.street

  override def set: (Address, Street) => Address = {
    case (a, s) => a.copy(street = s)
  }
}

val _streetNumber = new Lens[Street, Int] {
  override def get: (Street) => Int = _.number

  override def set: (Street, Int) => Street = {
    case (s, n) => s.copy(number = n)
  }
}

val _streetName = new Lens[Street, String] {
  override def get: (Street) => String = _.name

  override def set: (Street, String) => Street = {
    case (s, n) => s.copy(name = n)
  }
}

val _personStreetNumber: Lens[Person, Int] = _personAddress.and(_addressStreet).and(_streetNumber)
val _personStreetName: Lens[Person, String] = _personAddress.and(_addressStreet).and(_streetName)

Voici une utilisation du Lens composé qui permet d’accéder au numéro de la rue en partant d’une personne :

"lenses composition" should "update street number" in {
  val person = Person("John", "Doe", Address(Street(12, "Rue de Picpus"), Town("75012", "Paris")))

  val updatedPerson = _personStreetNumber.update(45)(person)

  updatedPerson.address.street.number should be(45)
}

Les Lenses proposent donc une abstraction claire, élégante et composable pour la récupération et à la mise à jour de propriétés. Nous avons vu jusqu’à maintenant une implémentation personnelle de cette abstraction. Or il existe des bibliothèques proposant des implémentations optimisées, notamment Shapeless, Scalaz ou encore Monocle. C’est cette dernière bibliothèque qui sera utilisée dans la suite de cet article.

Monocle

Monocle est une bibliothèque d’Optics écrite en Scala par Julien Truffaut. Elle s’inspire fortement de la bibliothèque Haskell Lens. Son but est de proposer différents Optics : Iso, Prism, Lens, Optional et d’autres. Cet article continuera de se concentrer exclusivement sur les Lens. Pour plus d’information sur les autres Optics, cette vidéo, enregistrée à Scala World 2015, est grandement pédagogique.

La création d’un Lens se fait facilement, soit programmatiquement :

val _address = Lens[Person, Address](_.address)(a => p => p.copy(address = a))

soit par l’intermédiaire de macros :

@Lenses ("_")
case class Person(firstName: String, lastName: String, address: Address)

Dans le second cas, un Lens par propriété sera généré, et ces instances seront dans l’objet compagnon de la case class.

Il est évidemment possible de les composer entre eux, mais également de les composer avec d’autres Optics :

val _address = Lens[Person, Address](_.address)(a => p => p.copy(address = a))
val _street = Lens[Address, Street](_.street)(s => a => a.copy(street = s))

val _addressStreet: Lens[Person, Street] = _address composeLens _street

Les opérateurs sont également disponibles pour utiliser une syntaxe plus concise :

val _address = Lens[Person, Address](_.address)(a => p => p.copy(address = a))
val _street = Lens[Address, Street](_.street)(s => a => a.copy(street = s))

val _addressStreet: Lens[Person, Street] = _address ^|-> _street

Enfin, la méthode first permet de transformer un Lens[A, B] en Lens[(A, O), (B, O)], et la méthode second transforme un Lens[A, B] en Lens[(O, A), (O, B)]. Combinées aux Iso, ces méthodes trouvent tout leur sens. Les Iso permettent de transformer un type A en un type B (et vice versa), sans perte d’information (isomorphisme). Ils seront peut-être le sujet d’un futur article.

Pour résumer

Relativement peu utilisés sur les projets sur lesquels j’ai eu l’opportunité de travailler, les Lenses (et plus globalement les Optics) sont une alternative pour une meilleure composabilité et une expressivité du code. La bibliothèque Monocle propose beaucoup de structures qui permettent de couvrir un grand nombre de cas d’utilisations.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *