jeudi 9 janvier 2020
Ce billet a été vu pour la première fois sur le blog de Synbioz le 09 January 2020 sous licence CC BY-NC-SA.

Comparaison de chaînes équivalentes en Ruby

Il apparait, à certains moments de la vie d’un développeur, la nécessité de comparer des chaînes de caractères. Cela peut être pour retrouver un mot dans un texte, ou encore pour interpréter une réponse à un questionnaire. Situation somme toute assez classique, mais qui peut néanmoins s’avérer délicate !

Si je vous demande le nom de ce vieux bonhomme barbu et vêtu de rouge qui parcourt le monde à une vitesse supraluminique et se glisse dans les cheminées — et ce malgré un embonpoint non dissimulé — pour en ressortir sans une trace de suie, j’aimerais alors être en mesure d’accepter tout aussi bien « Père Noël », « Pere Noel » que « PERE NOEL ».

En effet, lorsque les chaînes à comparer ne sont pas entièrement maîtrisées — comme votre réponse à la question ci-dessus, par exemple — on peut se retrouver à vouloir considérer celles-ci comme étant équivalentes, sans pour autant qu’elles soient strictement identiques.

Je vous propose, à l’aide de quelques exemples en Ruby, d’explorer quelques situations qu’il peut arriver de rencontrer.

Caractères ASCII (ou à raquettes)

Le cas le plus simple est celui où la casse des caractères diffère.

"Ruby" != "RUBY"

Pourtant, en développeur magnanime, on aimerait bien considérer ces chaînes comme équivalentes. Ruby nous offre pour cela de nombreux outils de manipulation de chaînes, qui nous permettront de comparer ce que l’on appellera, dans cet article, la forme canonique de nos chaînes.

MATH. Qui a les qualités d’un canon, d’une norme dominante. Forme, équation canonique. — CNRTL

L’idée étant de comparer des chaînes comparables en donnant à chacune d’elle une forme adaptée à nos besoins. Pour reprendre l’exemple ci-dessus, on pourrait décider de convertir l’ensemble des caractères en majuscule. Ce qui donnerait ceci :

"Ruby".upcase == "RUBY".upcase

À noter qu’on aurait tout aussi bien pu s’accorder sur une conversion en minuscules.

Unicode mon amour

Tout se complique lorsque l’on quitte les réconfortantes limites d’un clavier QWERTY pour explorer le monde merveilleux de l’Unicode !

En effet, il ne faut pas s’égarer fort longtemps, notamment pour un francophone, avant de rencontrer une lettre accompagnée d’un diacritique. Voyons comment se comporte String#upcase dans ces cas-là :

"Père Noël".upcase
# => "PÈRE NOËL"

Eh oui, les majuscules aussi s’accentuent ! Et Ruby gère ça très bien depuis sa version 2.4… c’est-à-dire depuis à peine 3 ans. Enfin, très bien…

En toute franchise, Unicode c’est un chouïa compliqué, et on arrive très rapidement à des situations tout à fait cocasses. Prenons quelques exemples et essayons de comprendre ce qui se passe.

s1 = ["N", "o", "ë", "l"].join
# => "Noël"
s2 = ["N", "o", "e", "̈", "l"].join
# => "Noël"
s1 == s2
# => false

On s’aperçoit ici que selon la manière dont on a composé notre chaîne de caractères — à partir de caractères précomposés ou composables — Ruby se comportera différemment. Et ce, même en version 2.7.0, sortie ce 25 décembre 2019 !

Encodage et normalisation

C’est là qu’entre en jeu la normalisation Unicode.

Lorsque les implémentations conservent des chaînes de caractères sous une forme normalisée, elles peuvent être assurées que des chaînes de caractères équivalentes ont une représentation binaire unique.

— Unicode® Standard Annex #15, libre traduction

Ruby dispose justement d’une méthode String#unicode_normalize pour ce faire.

s1 = ["N", "o", "ë", "l"].join.unicode_normalize
s2 = ["N", "o", "e", "̈", "l"].join.unicode_normalize
s1 == s2
# => true

Excellent ! Et si on creuse un peu, on découvre qu’il existe en réalité quatre méthodes de normalisation :

Pour les besoins de l’exercice, prenons pour exemple la lettre minuscule latine long s point en chef, notamment utilisée dans les manuscrits gaéliques d’Irlande.

Pour briller en société : Les mathématiques emploient un dernier avatar du s long comme symbole de l’intégrale : ∫. Le co-inventeur du concept de somme intégrale, Leibniz, a utilisé le premier mot de l’expression en latin, summa, somme, écrit ſumma et en a conservé l’initiale.   — Wikipédia

"ẛ".unicode_normalize(:nfd).chars
# => ["ſ", "̇"]

"ẛ".unicode_normalize(:nfc).chars
# => ["ẛ"]

"ẛ".unicode_normalize(:nfkd).chars
# => ["s", "̇"]

"ẛ".unicode_normalize(:nfkc).chars
# => ["ṡ"]

On remarque ici que les formes en D (NFD et NFKD) vont avoir pour effet de décomposer le caractère initial en un caractère de base, suivi d’un ou plusieurs diacritiques.

"ᾇ".unicode_normalize(:nfd).chars
# => ["α", "̔", "͂", "ͅ"]

Tandis que les formes en K (NFKD et NFKC) vont mettre l’accent, si je puis dire, sur la compatibilité. Cela se remarque tout particulièrement sur l’alphabet arabe, dont les symboles voient leur graphie adaptée en fonction de leur positionnement dans le mot !

irb(main):076:0> "ﻍ ﻏﻐﻎ".unicode_normalize(:nfc).chars
# => ["ﻍ", " ", "ﻏ", "ﻐ", "ﻎ"]

irb(main):074:0> "ﻍ ﻏﻐﻎ".unicode_normalize(:nfkc).chars
# => ["غ", " ", "غ", "غ", "غ"]

Rassemblons nos billes

Rappelons-le, notre problématique de base était de pouvoir comparer des chaînes de caractères suffisamment semblables pour pouvoir les considérer comme équivalentes.

Avec ce que nous venons de voir, et une petite expression rationnelle — oui, j’en suis certain, ça vous avait manqué — nous pouvons facilement arriver à nos fins !

La regex en question n’est pas bien complexe. Nous allons nous contenter d’exclure, une fois notre chaîne décomposée, toute accentuation qui y serait présente. C’est-à-dire, toute lettre modificative avec chasse (allant de \u02B0 à \u02FF) ou diacritique (allant de \u0300 à \u036F).

Voici le résultat :

def to_canonical(str)
  return "" if str.empty?

  characters = str.unicode_normalize(:nfkd).chars.reject do |c|
      c =~ /[\u02B0-\u02FF\u0300-\u036F]/
    end
  end

  characters.join.upcase
end

En décomposant les différentes étapes, on obtient ceci :

pn = "Père Noël"
pn.unicode_normalize(:nfkd)
# => "Père Noël"
_.chars
# => ["P", "e", "̀", "r", "e", " ", "N", "o", "e", "̈", "l"]
_.reject { |c| c =~ /[\u02B0-\u02FF\u0300-\u036F]/ }
# => ["P", "e", "r", "e", " ", "N", "o", "e", "l"]
_.join
# => "Pere Noel"
_.upcase
# => "PERE NOEL"

Ainsi, les chaînes suivantes sont équivalentes :

p1 = to_canonical("Pere Noel")
# => "PERE NOEL"
p2 = to_canonical("Père Noël")
# => "PERE NOEL"
p1 == p2
# => true

Ce qu’il faut retenir

La manipulation de chaînes de caractères, malgré les nombreux outils fournis par Ruby en standard, peut rapidement s’avérer complexe dès lors qu’on explore un tant soit peu les possibilités que nous offre Unicode, tant dans son support des nombreuses graphies que l’homme a inventé au cours des siècles, que dans leurs multiples représentations.

Chaque situation trouvera une réponse qui lui est adaptée. Pour peu qu’on ait à l’esprit les écueils que l’on risque de rencontrer, il existera un moyen de les contourner. Mais ce moyen diffèrera selon que l’on aborde la question sous l’angle de l’accessibilité, de la localisation, ou de la sécurité par exemple.

Si vous voulez aller plus loin, vous pouvez consulter la documentation et l’implémentation de la gem twitter_cldr qui se repose depuis 2014 sur la très efficiente gem eprun, à parcourir également.

Et pour poursuivre votre lecture (en anglais) je vous conseille la très excellente suite d’articles de DaftCode dont le dernier volet s’intitule Fixing Unicode for Ruby developers.