Il y a des bugs qui plantent bruyamment, et il y a des bugs qui se taisent. Les pires sont les seconds : ils renvoient un résultat qui a l’air correct, et personne ne va vérifier un chiffre qui a l’air correct. C’est exactement ce qu’on vient de corriger dans BioPHP, en passant les séquences biologiques (ADN, ARN, protéines) du statut de simples string à celui de véritables value objects.
Le symptôme : un poids moléculaire faux, sans aucune alerte
Dans BioPHP historique, une séquence ADN, une séquence ARN et une séquence protéique sont toutes les trois… des string. Rien ne les distingue au niveau du type. Résultat : rien n’empêche non plus de passer une séquence invalide à une fonction qui l’attend propre.
public function molwt(string $sLimit, string $sSequence, string $sMolType, int $iNALen) : float { $this->cleanSequence($sSequence, $sMolType); // valeur de retour jetée ... $aMwt[$iLowLimit] += $na_wts[$sNABase]; // symbole inconnu = clé absente = 0
cleanSequence() détecte pourtant très bien les symboles invalides et retourne false le cas échéant. Le problème, c’est que personne ne lit ce retour. En pratique :
molwt("upperlimit", "ATGC", "DNA", 4); // 1253.945 molwt("upperlimit", "ATGZ", "DNA", 4); // 964.73
Z n’est pas une base valide. Il est compté pour zéro. Le poids est faux de 289 daltons, et rien ne le signale — pas d’exception, pas de log, pas de false. C’est un chiffre faux qui part en production avec toute l’apparence d’un chiffre juste.
À côté de ça, la validation de cleanSequence() ne couvrait que l’ADN et l’ARN (les protéines n’avaient droit à rien), et une séquence GenBank réelle — minuscules, découpée en blocs de dix séparés par des espaces — plantait telle quelle sur complement(), obligeant chaque appelant à penser à normaliser à la main.
Le point commun à ces trois bugs : le type de molécule voyage à côté de la donnée, en paramètre séparé. Rien ne garantit que les deux restent cohérents.
Le remède : rendre les états invalides irreprésentables
La réponse classique en conception orientée objet, c’est le value object (VO). Trois critères le définissent :
- Pas d’identité. Deux
DnaSequencecontenant"ATGC"sont interchangeables, exactement comme deux billets de 10 €. À l’inverse, l’entitéSequence(l’enregistrementNM_031438, avec son numéro d’accession) garde une identité propre : deux enregistrements différents peuvent porter la même suite de symboles sans être la même chose. Le VO ne remplace donc pas l’entité, il s’occupe uniquement des symboles. - Immuable.
$adn->complement()ne modifie pas$adn, il retourne un nouvel objet — comme5 + 1ne change pas5, il produit6. - Égalité par contenu. Deux instances distinctes en mémoire, mais avec la même valeur, sont égales.
Le bénéfice le plus concret pour du code PHP : un type devient une garantie plutôt qu’une convention. Écrire function analyser(DnaSequence $adn) rend une séquence ARN impossible à passer en argument — pas déconseillée par une convention de nommage ou un commentaire, refusée par PHP lui-même. Et comme l’objet ne peut exister qu’après avoir passé la validation à la construction, le reste du code n’a plus besoin de revalider : on valide une fois, à l’entrée du domaine.
Concrètement, ici, DnaSequence, RnaSequence et AminoAcidSequence valident leur alphabet à la construction — pas après, pas « si on y pense ».
// avant : le type voyage à côté, la normalisation est à votre charge $s = strtoupper(str_replace(" ", "", "aagactgcat ccggctccag")); $complement = $sequenceManager->complement($s, "DNA"); // après : le type porte l'information, normalisation et validation acquises $adn = new DnaSequence("aagactgcat ccggctccag"); $complement = $adn->complement()->getValue();
Sur une saisie fautive, l’erreur remonte immédiatement, avec le symbole et sa position — pas 200 lignes plus loin sous forme de poids moléculaire silencieusement faux :
new DnaSequence("ATGUC");// InvalidSequenceException: Invalid DNA symbol "U" at position 3.
Un point mérite d’être signalé : les entrées mRNA de GenBank/EMBL sont écrites avec de la thymine, par convention ADNc. Un mapping naïf mRNA → RnaSequence aurait planté sur des données bien réelles. La factory retient donc la règle « en cas de contradiction, les symboles gagnent » : présence de T → ADN, présence de U → ARN, le type déclaré ne tranchant qu’en dernier recours.
Côté intégration, une seule méthode a été ajoutée sur SequenceBuilder, et uniquement sur la classe — pas sur l’interface publique, pour ne pas casser amelaye/biotools qui consomme ces classes directement. Aucune signature existante touchée.
Cinq bugs déterrés par le passage au typage fort
Une fois les VO en place, le reste des bugs est devenu visible tout seul. Le plus vicieux : les codes IUPAC dégénérés (N, R, Y…) passaient la validation mais n’avaient pas d’entrée dans la table des poids — comptés pour zéro, eux aussi, ce qui rendait au passage les paramètres lowerlimit/upperlimit totalement décoratifs puisque les deux accumulateurs recevaient la même valeur. Le legacy, lui, gérait ça correctement avec une paire [min, max] par symbole dégénéré — logique qui a été rétablie, mais recalculée plutôt que recopiée, et vérifiée pour reproduire exactement la table historique.
Un dernier bug s’est révélé être le complice invisible du précédent : SequenceBuilder transmettait le mauvais paramètre à molwt() (le type de molécule à la place de la limite demandée), donc upperlimit et lowerlimit ne changeaient jamais rien — invisible tant que les deux valeurs étaient de toute façon égales à cause du bug des symboles dégénérés. Corriger l’un a rendu l’autre observable. Classique.
Aucune signature publique n’a changé sur l’ensemble de ces corrections. Un appelant qui passait des données propres obtient exactement les mêmes résultats qu’avant.
Un chantier annexe : sortir les parseurs de Service/
En parallèle, Domain/Database/Service/ avait fini par contenir six parseurs de formats (GenBank, Swiss-Prot, EMBL, PDB, PROSITE, ExPASy) en plus des deux vrais services du dossier — un dossier qui avait grossi sans qu’on lui redemande son avis. Déplacement vers Domain/Parser/ via git mv, pour garder l’historique.
Le déplacement a surtout mis en lumière un défaut plus profond : le savoir sur un format était éclaté sur trois switch dans deux factories différentes (quelle classe instancier, comment reconnaître un début d’entrée, comment extraire l’identifiant). Ajouter un format demandait de toucher quatre endroits, et un case manquant compilait sans broncher. Chaque parseur déclare désormais lui-même son format et sa logique via des méthodes statiques (getFormat(), isEntryStart(), getEntryId()), et un nouveau registre — DatabaseParserFactory — remplace les trois switch. Bilan : 160 lignes tombent à 23.
Ce qui reste ouvert
Les VO existent, mais SequenceManager continue de travailler sur des string en interne — les faire accepter un AbstractMolecularSequence en entrée est la suite logique, mais c’est un changement de signature, donc à réserver à une version majeure. Et d’autres candidats VO existent dans le domaine — le codon, l’intervalle start/end d’une feature, le numéro d’accession — laissés de côté pour cette session.
Bilan chiffré : 51 nouveaux tests, suite complète à 319 tests / 790 assertions (0 échec), aucune signature publique cassée sur l’ensemble des trois chantiers.