- La réponse Java
humanReadableByteCount, publiée en 2010, a été identifiée dans une étude de 2018 comme le snippet Stack Overflow le plus copié, mais elle produisait des résultats erronés sur les valeurs limites du formatage des tailles en octets
- Ce code utilisait le fait que des préfixes comme
kB, MB et GB sont des puissances de 1000 ou 1024, et choisissait l’unité via un calcul logarithmique au lieu d’une boucle
- Le bug principal était un problème de seuil d’arrondi :
999,999 bytes s’affichait en mode SI comme "1000.0 kB", alors que, si la spécification impose une plage numérique de 1 à 999.9, le bon résultat est "1.0 MB"
- Sur des valeurs plus grandes, les limites de précision en virgule flottante de
double s’ajoutaient au problème, si bien que l’entrée 999,949,999,999,999,999 donnait 1000.0 PB ; la correction nécessitait un calcul de seuil, une réduction d’échelle, une correction du motif de bits et strictfp
- Le code final gère aussi les valeurs négatives et
Long.MIN_VALUE, mais a perdu la simplicité d’origine ; copier du code depuis Stack Overflow exige aussi des tests sur les cas limites et l’attribution de la source
La simplification visée par la réponse de 2010
- Le problème consistait à formater un nombre d’octets en chaîne lisible par un humain
- Exemple : afficher
123,456,789 bytes sous la forme "123.5 MB"
- La spécification implicite était que la partie numérique de la chaîne de sortie soit comprise entre 1 et 999.9, avec le suffixe d’unité approprié
- Les réponses existantes adoptaient une approche basée sur une boucle, en parcourant
EB, PB, TB, GB, MB, kB, B depuis la plus grande unité et en choisissant la première inférieure au nombre d’octets
- La nouvelle réponse utilisait
Math.log et Math.pow pour réduire le nombre de boucles et de branchements
- En mode SI, l’unité est
1000
- En notation binaire, l’unité est
1024
- La valeur
exp = log(bytes) / log(unit) est convertie en entier pour servir d’indice de préfixe
- Les préfixes utilisés sont
"kMGTPE" en SI et "KMGTPE" en binaire, avec ajout de "i" en binaire
L’ampleur des copies et l’épisode OpenJDK
- L’article de Sebastian Baltes, Usage and Attribution of Stack Overflow Code Snippets in GitHub Projects, analyse la façon dont les snippets Stack Overflow sont utilisés dans des projets GitHub et comment leur source est attribuée
- La méthode consistait à extraire des snippets du dump de données Stack Overflow puis à les comparer avec le code de dépôts GitHub publics
- La question centrale était de savoir si l’attribution exigée par la licence CC BY-SA 3.0 de Stack Overflow était respectée
- En pratique, la plupart des utilisateurs n’incluaient pas d’attribution correcte
- La réponse ID 3758880 figurait en tête du tableau de l’étude et totalisait alors plusieurs centaines de milliers de vues et plus de 1 000 upvotes
- Une recherche de
humanReadableByteCount sur GitHub faisait apparaître des milliers d’usages ; dans un dépôt local, on peut vérifier avec la commande suivante
git grep humanReadableByteCount
- Un cas correspondant a aussi été trouvé dans le dépôt OpenJDK
- Le code ne contenait aucune attribution, et la licence d’OpenJDK n’était pas compatible avec CC BY-SA 3.0
- Sebastian Baltes a demandé sur la liste de diffusion de développement OpenJDK si le code avait été copié de Stack Overflow vers OpenJDK, ou l’inverse
- L’auteur de la réponse n’était pas encore entré chez Oracle avant la fusion de ce commit et n’avait pas contribué à ce patch
- Un ticket a ensuite été ouvert et le code a été supprimé
Premier bug : les valeurs limites remplies de 999
- Les problèmes qui semblaient suspects au premier abord n’étaient pas la vraie cause
- La valeur maximale de
long est 2^63 - 1, soit environ 9.2 × 10^18, donc on ne dépasse pas les unités au-delà de EB
- Quand
bytes < unit, le premier if traite le cas, donc exp ne vaut pas 0 au point de faire échouer charAt(exp - 1)
- Le vrai problème était un seuil d’arrondi
- L’entrée
999,999 bytes devenait "1000.0 kB" en mode SI
- Si la spécification impose que la partie numérique reste entre 1 et 999.9, le résultat correct est
"1.0 MB"
- Au moment de la rédaction, les 22 réponses publiées, y compris celles s’appuyant sur Apache Commons ou des bibliothèques Android, avaient ce bug ou une variante
- Le cœur de la correction consistait à définir le seuil à partir duquel
exp doit passer à l’unité suivante
- Le passage de
k à M se produit quand la valeur est plus proche de 1 MB que de 999.9 k, soit 999,950
- Le passage de
M à G se produit à 999,950,000
- En mode binaire, le seuil n’est pas un entier, donc
ceil est nécessaire
if (bytes >= Math.ceil(Math.pow(unit, exp) * (unit - 0.05)))
exp++;
Deuxième bug : la limite de précision de double
- Même après cette correction, l’entrée
999,949,999,999,999,999 s’affichait comme 1000.0 PB, alors que le bon résultat était 999.9 PB
- La cause n’était pas la formule mathématique elle-même, mais la limite de précision de
double
- Dans la représentation IEEE 754, les nombres en virgule flottante sont très denses près de 0, mais beaucoup plus espacés pour les grandes valeurs
- Avec un
double très grand, soustraire Long.MAX_VALUE peut ne rien changer à la valeur
double a = Double.MAX_VALUE;
double b = a - Long.MAX_VALUE;
System.err.println(a == b); // prints true
- Le problème apparaissait à deux endroits
- Dans la division effectuée pour l’argument de
String.format
- Dans le calcul du seuil servant à décider s’il faut incrémenter
exp
- Le premier problème a été traité en réduisant la valeur intermédiaire de
bytes dans une plage où la précision est meilleure, puis en ajustant exp
- L’idée est que le résultat final est de toute façon arrondi, donc on peut se permettre de jeter les chiffres de rang inférieur
if (exp > 4) {
bytes /= unit;
exp--;
}
- Dans le deuxième problème, les bits de poids faible comptaient réellement
999,949,99…9 et 999,950,00…0 devaient être classés avec des exposants différents
- Il existait 12 seuils possibles en combinant SI et binaire, et un seul produisait un mauvais résultat
- Le cas fautif a été identifié par un motif de bits se terminant par
D00, puis corrigé
- Comme la correction dépend du motif binaire d’un résultat flottant précis,
strictfp a été ajouté
Entrées négatives et code final
- Comme Java ne dispose pas de
long non signé, la gestion des tailles en octets négatives a aussi été ajoutée
- Avant cela, l’entrée
-10,000 s’affichait comme -10000 B
- Une variable
absBytes a été introduite pour effectuer les calculs liés à exp sur la valeur absolue
Long.MIN_VALUE demandait un traitement spécial
- Parce que
-Long.MIN_VALUE == Long.MIN_VALUE
- Ainsi, si
bytes == Long.MIN_VALUE, le code utilise Long.MAX_VALUE, sinon Math.abs(bytes)
- La version finale inclut
strictfp, la correction des seuils, le traitement de Long.MIN_VALUE et la réduction d’échelle pour les grands exposants
- Le code qui voulait éviter les boucles et les branchements excessifs est devenu, après traitement de tous les cas limites, plus difficile à lire que la version d’origine
- Pour un code moderne de qualité production, on peut consulter l’article séparé Formatting byte size to human readable format
Les enseignements pratiques
- Un snippet Stack Overflow peut contenir des bugs, même avec des milliers d’upvotes
- Le code copié nécessite des tests sur les cas limites, en particulier
- L’arithmétique en virgule flottante est difficile à maîtriser sur les valeurs limites et les grands nombres
- Lorsqu’on copie du code, une attribution correcte est nécessaire, faute de quoi cela peut devenir un vrai problème
1 commentaires
Avis sur Hacker News
Il est intéressant de voir que les réponses qui utilisent des valeurs codées en dur et des instructions
if(ouwhile) font toutes au maximum 5 comparaisons.Si les unités ne vont que de B à KiB, MiB, GiB, TiB et EiB, on peut aussi résoudre le problème avec au plus 3 instructions
if. En vérifiant si c’est au moins GiB, on sait que ce n’est pas B/KiB/MiB, donc la recherche dichotomique l’emporte.Même en étendant à ZiB et YiB, 3 comparaisons au maximum suffisent, tandis que l’approche codée en dur monte jusqu’à 7. Si je devais l’écrire moi-même, je n’utiliserais pas
log/pow/les nombres flottants, car le risque d’erreur est trop grand ; je coderais desifen dur, mais sous forme de recherche dichotomique.Avec ce genre de code, on fait beaucoup d’efforts pour obtenir un code plus lent, plus complexe, et plus difficile à tester comme à relire.
(2019) Discussions précédentes :
https://news.ycombinator.com/item?id=21693431
https://news.ycombinator.com/item?id=21698619
https://news.ycombinator.com/item?id=27533684
The most copied StackOverflow snippet of all time is flawed (2019) - https://news.ycombinator.com/item?id=27533684 - juin 2021, 334 commentaires
The most copied StackOverflow snippet of all time is flawed - https://news.ycombinator.com/item?id=21698619 - décembre 2019, 88 commentaires
The most copied StackOverflow snippet of all time is flawed - https://news.ycombinator.com/item?id=21693431 - décembre 2019, 3 commentaires
Je ne comprends pas. S’il y a 7 suffixes, il suffit de choisir le bon avec une recherche dichotomique, et 3 comparaisons suffisent. Ou alors, en restant tout simple, cela fait seulement 6 comparaisons.
Je ne vois pas en quoi utiliser deux fois
log(), une foispow()etceil()serait mieux que la méthode simple. Le bug décrit ici est justement un exemple parfait de ce qui arrive quand on veut être trop malin.Cela dit, comme elle tient compte du bug d’arrondi, c’est tout de même un peu mieux que le premier exemple de code de l’article original.
Et 6 comparaisons, c’est seulement dans le cas de la valeur maximale ; en usage réel, cela semble peu probable. Si la plupart des valeurs sont dans la plage B ou KB, une approche linéaire peut être meilleure.
C’est de la pub assumée, mais au lieu de copier depuis S/O, pour formater rapidement et correctement des tailles dans un format lisible par un humain, vous pouvez aussi utiliser notre bibliothèque open source PrettySize. Il y en a une pour Rust [0] et une pour .NET [1], et elles rendent aussi les opérations logiques type-safe sur les tailles de fichiers sûres et faciles.
L’extrait S/O fait 4 lignes, mais ces bibliothèques sont bien plus complètes et incluent des tests, des options de formatage de sortie, des conversions de taille, etc.
[0]: https://github.com/neosmart/prettysize-rs
[1]: https://github.com/neosmart/PrettySize.net
Par pure curiosité, y a-t-il vraiment beaucoup de développeurs qui se contentent de copier du code non fiable de Stack Overflow pour le coller dans leurs applications ?
L’idée que les gens copient simplement depuis Stack Overflow est célèbre, mais je pensais que c’était plus ou moins une blague tant que je ne l’avais pas vu faire par quelqu’un. Moi aussi, quand je résous un problème dans un domaine que je connais mal, j’utilise Stack Overflow comme point de départ, mais je n’ai jamais copié le code tel quel.
En général, un bout de code ne fait pas exactement ce dont j’ai besoin, donc il faut examiner l’API et construire ma propre solution à partir de l’approche décrite. En Python en particulier, Stack Overflow m’a souvent orienté vers des API de niche utiles.
C’était littéralement : Google → cliquer sur le premier lien Stack Overflow visible → copier/coller le premier bloc de code visible, et parfois ce n’était même pas le même langage. En pair programming, il fallait lui enlever physiquement le périphérique de saisie. Si on lui disait que c’était faux, avant même qu’on ait fini notre phrase, il était déjà en train de coller le deuxième bout de code de la page, et il était étrangement rapide.
C’est un cas extrême, mais il y a beaucoup de développeurs qui raisonnent comme ça : « il me faut du code ; il y a du code sur Stack Overflow ; problème résolu ! », sans réfléchir du tout à savoir si c’est la bonne solution.
Après tout, nous utilisons sans cesse du code de bibliothèques écrit par des inconnus pour les parties de plomberie auxquelles nous ne tenons pas particulièrement. Si l’on veut creuser et comprendre, on a plus de chances de l’écrire soi-même ; mais si l’on veut juste que cette partie « fonctionne » et continuer le projet, cela devient du développement piloté par les erreurs du compilateur.
Je change aussi les noms de variables, parce qu’il y a souvent trop de
foo,bar,baz, ce qui rend le code difficile à lire pour un humain. Et si je retombe sur le même problème, je me souviens plus facilement de ce que j’ai fait que si j’avais copié aveuglément.Je ne comprends pas pourquoi utiliser un logarithme en virgule flottante quand on a besoin de
log 2.Sauf si quelque chose m’échappe, l’expression ci-dessous donne exactement
floor(log2(value))pour les valeurs positives inférieures à 2^63 octets, et elle est beaucoup plus rapide :Long.bitCount( (Long.highestOneBit(value) << 1) - 1) - 1Dès que j’ai vu le bout de code, avec une opération
logen virgule flottante et une division sur des entiers, je l’ai immédiatement écarté mentalement comme du code trop malin, donc intrinsèquement susceptible d’être bogué.La chaîne du savoir descend jusqu’au bout. Cela montre à quel point il est difficile de remettre à sa place même une minuscule connaissance une fois qu’elle a été sortie.
Alors que Stack Exchange perd rapidement ses contributeurs actifs, je me demande ce qu’il faudra pour corriger les réponses de tireur rapide qui s’avèrent ensuite être à côté de la plaque. Et je me demande aussi ce que cela signifie pour notre savoir collectif lorsque ce genre de réponses « légèrement fausses » se fige de plus en plus dans l’historique des recherches, puis dans l’histoire des LLM.
Ça me rappelle l’instruction militaire de base. Les instructeurs donnaient délibérément aux recrues des tâches que personne ne savait faire, sans consignes, puis s’en allaient.
Alors quelqu’un commençait toujours de la mauvaise manière, et tous les autres le suivaient.
Il se passe quelque chose de similaire dans les prévisions économiques publiques. Celui qui se trompe seul alors que les autres ont vu juste est traité bien plus durement que ceux qui se trompent tous ensemble.
Je ne considérerais pas forcément les erreurs en virgule flottante dans ce genre d’algorithmes comme un « défaut ». Si le code définit une solution logique et mathématiquement correcte, je le considère en soi comme « juste ».
Corriger les erreurs en virgule flottante est un niveau au-dessus, et c’est quelque chose qu’on ne fait que lorsque c’est réellement important. On peut imaginer un langage de programmation futur parfait où les erreurs en virgule flottante n’existent pas et n’ont donc pas besoin d’être prises en compte ; 99 % de mes algorithmes visent en quelque sorte ce langage-là.