Tutoriel pour formater correctement les nombres avec la classe NumberFormat

| Netapsys | Joan David |

Cet article s'intéresse à expliquer comment formater en chaîne de caractères un nombre, en utilisant la classe NumberFormat.

Pour réagir à ce tutoriel, un espace de dialogue vous est proposé sur le forum : Commentez

- Titre : Tutoriel pour formater correctement les nombres avec la classe NumberFormat

- Auteurs : Netapsys, Joan David

- Parution : 27 mars 2017

- Licence : Copyright ® 2017 Joan David. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Les valeurs par défaut du NumberFormat.getInstance()

- II-A. La locale

- II-B. Le type de format

- III. Les paramétrages du DecimalFormat

- III-A. Paramétrage des fractions

- III-B. Paramétrage des entiers

- IV. Le type d'arrondi

- V. Le cast en double sur les float

- VI. La possibilité de préciser le pattern

- VII. Remerciements

I - Introduction

Les doubles, les float, les int, les long ou les BigDecimal sont autant de manières différentes de stocker un nombre.

Mais au moment de la présentation sur une page web ou un document généré, il est nécessaire de formater ce nombre en chaîne de caractères. L'API java nous fournit pour cela le NumberFormat et ses différentes déclinaisons. Mais si l'API propose de faire la chose suivante :

NumberFormat.getInstance().format(monNombre);

la résolution par défaut est loin d'être évidente et c'est donc avec précaution et paramétrage qu'il faut manier cette API.

II - Les valeurs par défaut du NumberFormat.getInstance()

II-A - La locale

La première chose à savoir est que par défaut, le NumberFormat.getInstance() utilise la locale par défaut de type FORMAT qui peut être sensiblement différente de la locale par défaut de type DISPLAY en fonction du paramétrage du système. Cette locale permettra notamment d'afficher le chiffre avec une ‘,' pour une locale FRENCH, ou un ‘.' pour une locale ENGLISH.

1. NumberFormat.getInstance(Locale.FRENCH).format(1.235d)); // => " 1,235 "

2. NumberFormat.getInstance(Locale.ENGLISH).format(1.235d)); // => " 1.235 "

II-B - Le type de format

La seconde grande configuration par défaut du NumberFormat.getInstance() est qu'il retourne un DecimalFormat. Donc par défaut, le NumberFormat prévoit de vous retourner un formatage de votre nombre en non entier.

Pour choisir spécifiquement le type de format de retour que vous souhaitez avoir, NumberFormat prévoit des méthodes d'instanciations différentes, toutes avec leur variante paramétrant la locale.

Premier constat, il y a une configuration par défaut en fonction du type de formateur utilisé, du nombre de chiffres après la virgule (3 pour le NumberInstance et 2 pour le CurrencyInstance). Mais ceci n'est que la partie émergée de l'iceberg.

Pour la suite de ce tutoriel, je vous propose de nous concentrer sur le getNumberInstance ou DecimalFormat.

III - Les paramétrages du DecimalFormat

À titre d'exemple dans cette section, nous essaierons d'afficher un montant au centime près avec comme exemple : 1234,56 €.

III-A - Paramétrage des fractions

Comme vu précédemment, par défaut le DecimalFormat formate à 3 chiffres après la virgule. Heureusement l'API propose d'autres paramètres :

- MaximumFractionDigits : qui propose de limiter l'affichage au nième chiffre après la virgule. 3 par défaut. Dans notre cas il nous faut 2 ;

- MinimumFractionDigits : qui propose de combler l'affichage au nième chiffre après la virgule par des zéros. 0 par défaut. Dans notre cas il nous faut 2.

1. final NumberFormat instance = NumberFormat.getNumberInstance();

2. instance.setMinimumFractionDigits(2);

3. instance.setMaximumFractionDigits(2);

4. assert "1,11".equals(instance.format(1.111d));

5. assert "1,10".equals(instance.format(1.1d));

Dans le cas où MaximumFractionDigits< MinimumFractionDigits, l'ordre d'affectation peut changer la donne car c'est le dernier qui a raison :

1. // MaximumFractionDigits override MinimumFractionDigits

2. NumberFormat instance = NumberFormat.getNumberInstance();

3. instance.setMinimumFractionDigits(5);

4. instance.setMaximumFractionDigits(3);

5. assert "1,111".equals(instance.format(1.11111d));

6. // MinimumFractionDigits override MaximumFractionDigits

7. instance.setMaximumFractionDigits(3);

8. instance.setMinimumFractionDigits(5);

9. assert "1,11111".equals(instance.format(1.11111d));

III-B - Paramétrage des entiers

À l'instar des fractions, on va retrouver un paramétrage similaire du nombre d'entier, ainsi qu'un petit paramétrage de mise en forme des milliers.

- MaximumIntegerDigits : qui propose de limiter l'affichage au nième entier. 40 par défaut ;

- MinimumIntegerDigits : qui propose de combler l'affichage au nième entier. 1 par défaut ;

- GroupingUsed : qui définit si l'on groupe les entiers par trinôme ou non. False par défaut. Dans notre cas il faut empêcher le groupement.

1. final NumberFormat instance = NumberFormat.getNumberInstance();

2. instance.setMaximumIntegerDigits(4);

3. instance.setMinimumIntegerDigits(4);

4. instance.setGroupingUsed(true);

5. assert "0 001".equals(instance.format(1D));

6. assert "2 345".equals(instance.format(12345D));

7. instance.setGroupingUsed(false);

8. assert "2345".equals(instance.format(12345D));

Là encore dans le cas où MaximumIntegerDigits< MinimumIntegerDigits, l'ordre d'affectation peut changer la donne.

1. final NumberFormat instance = NumberFormat.getNumberInstance();

2. instance.setMaximumIntegerDigits(1);

3. instance.setMinimumIntegerDigits(4);

4. instance.setGroupingUsed(true);

5. assert "0 001".equals(instance.format(1D));

6. instance.setMaximumIntegerDigits(1);

7. assert "1".equals(instance.format(1D));

IV - Le type d'arrondi

L'arrondi est une question qui se pose souvent et qui est loin d'être triviale.

Travaillant sur des décimales, nous réalisons un test unitaire de notre méthode de formatage validant le type d'arrondi en fonction de valeurs critiques, en combinant les paramétrages précédents :

1. final NumberFormat instance = NumberFormat.getNumberInstance();

2. instance.setMinimumFractionDigits(2);

3. instance.setMaximumFractionDigits(2);

4. // MaximumIntegerDigits - valeur par défaut

5. // MinimumIntegerDigits - valeur par défaut

6. instance.setGroupingUsed(false);

7. assert "1234,56".equals(instance.format(1234.564D));

8. assert "1234,57".equals(instance.format(1234.565D));

Mais si c'était si simple...

1. assert "1234,66".equals(instance.format(1234.664D));

2. assert "1234,67".equals(instance.format(1234.665D)); // => ERREUR : "1234,66"

Il faut là consulter l'implémentation de DecimalFormat pour se rendre compte que par défaut, le DecimalFormat applique une règle d'arrondi un peu particulière : le HALF_EVEN. Il s'agit d'une règle d'arrondi qui vise à lisser les erreurs d'arrondi lors du maniement d'un grand nombre de chiffres en arrondissant à l'inférieur si le dernier chiffre avant l'arrondi est pair, ou au supérieur s'il est impair.

En gros : 1,5 => 2 et 2,5 => 2.

Heureusement, l'API permet de modifier le mode d'arrondi sur le DecimalFormat en paramétrant le RoundingMode avec l'une des valeurs suivantes :

- UP : arrondit systématiquement au nombre supérieur par rapport à 0 : 1,1 => 2 ; -1,1 => -2 ;

- DOWN : arrondit systématiquement au nombre inférieur par rapport à 0 : 1,9 => 1 ; -1,9 => -1 ;

- CEILING : arrondit systématiquement au nombre supérieur par rapport à l'infini positif : 1,1 => 2 ; -1,9 => -1 ;

- FLOOR : arrondit systématiquement au nombre inférieur par rapport à l'infini négatif : 1,9 => 1 ; -1,9 => -1 ;

- HALF_UP : arrondit au plus proche. Dans le cas d'une équidistance (0,5), arrondit au nombre supérieur par rapport à 0 : 1,4 => 1 ; 1.5 => 2 ; -1.4 => -1 ; -1.5 => -2 ;

- HALF_DOWN : arrondit au plus proche. Dans le cas d'une équidistance (0,5), arrondit au nombre inférieur par rapport à 0 : 1,6 => 2 ; 1.5 => 1 ; -1.6 => -2 ; -1.5 => -1 ;

- HALF_EVEN : arrondit au plus proche. Dans le cas d'une équidistance (0,5), se comporte comme un HALF_UP si le chiffre à gauche de la fraction retirée est impair et comme un HALF_DOWN s'il est pair : 1,5 => 2 ; 2.5 => 2 ; -1.5 => -2 ; -2.5 => -2 ;

- UNNECESSARY : retourne une ArithmeticException si le chiffre nécessite un arrondi.

Dans notre cas nous souhaitons arrondir toujours à la moitié supérieure et utilisons donc le paramètre HALF_UP.

V - Le cast en double sur les float

Si nous avons pu voir qu'à partir d'un double nous arrivions à nos fins, un dernier petit grain de sable vient s'ajouter quand on réalise un format à partir d'un float ou d'un Float.

- Dans le cas de la primitive float, c'est java qui cast automatiquement notre nombre en double pour l'appel de la méthode avec la signature : public final String format(double) ;

- Dans le cas de l'objet Float, c'est l'API qui lors de l'appel de la méthode avec la signature public final String format(Object), réalise la transformation par un ((Number)myFloat).doubleValue().

Le problème c'est que lors de ce cast forcé, 1234.565F devient 1234.56494140625D, qui dans notre exemple est alors arrondi au chiffre inférieur.

1. final NumberFormat instance = NumberFormat.getNumberInstance();

2. instance.setMinimumFractionDigits(2);

3. instance.setMaximumFractionDigits(2);

4. // MaximumIntegerDigits - valeur par défaut

5. // MinimumIntegerDigits - valeur par défaut

6. instance.setGroupingUsed(false);

7. instance.setRoundingMode(RoundingMode.HALF_UP);

8. assert "1234,56".equals(instance.format(1234.565f));

9. assert "1234,56".equals(instance.format(Float.valueOf(1234.565f)));

Pour éviter ce problème, il suffit d'encapsuler notre nombre dans un BigDecimal qui lui est traité sans conversion en double primitive.

1. final NumberFormat instance = NumberFormat.getNumberInstance();

2. instance.setMinimumFractionDigits(2);

3. instance.setMaximumFractionDigits(2);

4. // MaximumIntegerDigits - valeur par défaut

5. // MinimumIntegerDigits - valeur par défaut

6. instance.setGroupingUsed(false);

7. instance.setRoundingMode(RoundingMode.HALF_UP);

8. assert "1234,56".equals(instance.format(new BigDecimal(Float.valueOf(1234.564f).toString())));

9. assert "1234,57".equals(instance.format(new BigDecimal(Float.valueOf(1234.565f).toString())));

VI - La possibilité de préciser le pattern

Enfin, le DecimalFormat permet de définir le pattern utilisé si l'on souhaite par exemple ajouter un symbole monétaire (cf. https://docs.oracle.com/javase/tutorial/i18n/format/decimalFormat.html). Si le constructeur du DecimalFormat accepte dans sa signature le String pattern à utiliser, le NumberFormat.getNumberInstance() ne nous le permet pas. Mais il est possible de faire comme suit :

1. final NumberFormat instance = NumberFormat.getNumberInstance();

2. ((DecimalFormat) instance).applyPattern("###.00 €");

3. assert "1234,50 €".equals(instance.format(1234.5D));

VII - Remerciements

Cet article a été publié avec l'aimable autorisation de la société Netapsys.

Nous tenons à remercier KartSeven pour la relecture de cet article et Mickael Baron pour la mise au gabarit.