string-format: String.format/formatted/printf cheat sheet module (specifier anatomy, rounding vs C printf, locales, Indian lakh/crore grouping Formatter cannot do, format exceptions, %t dates, concat cost), 11 contract tests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b7Sr2zcoRD2watXv1G
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# string-format
|
||||
|
||||
Companion code for the ankurm.com post *"String.format, formatted() and printf in Java: The Complete Cheat Sheet."*
|
||||
Module in `java-core-examples`, the Java-core series.
|
||||
|
||||
## Versions this was built and tested against
|
||||
|
||||
| Component | Version | Notes |
|
||||
|---|---|---|
|
||||
| JDK | 25.0.5+8 (Temurin, LTS) | `String.format` is Java 5; `String.formatted()` arrived in Java 15; `Formatter` parsing was rewritten without regex in Java 17. |
|
||||
| JUnit Jupiter | 5.11.0 | |
|
||||
| Maven | 3.9.11 | |
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
mvn compile
|
||||
scripts/run.sh IndianGroupingDemo # one demo
|
||||
scripts/run-all.sh # regenerate every file in output/
|
||||
```
|
||||
|
||||
## What's in here
|
||||
|
||||
| File | What it shows |
|
||||
|---|---|
|
||||
| `BasicsDemo.java` | Every part of `%[index$][flags][width][.precision]conversion`, changed one at a time. |
|
||||
| `ReportTableDemo.java` | Left/right alignment, zero padding, truncation with `%.Ns`, a fixed-width invoice, `printf`. |
|
||||
| `RoundingDemo.java` | `%f` rounds HALF_UP on the decimal string, so it disagrees with C `printf` and with `BigDecimal(double)`. |
|
||||
| `IndianNumberFormat.java` + `IndianGroupingDemo.java` | **The main exhibit.** Every built-in route to lakh/crore grouping tried and shown to fail, then a 60-line formatter that works. |
|
||||
| `LocaleDemo.java` | The same `%,.2f` under six locales; the default-locale trap; what is *not* localized. |
|
||||
| `FormatErrorsDemo.java` | Twelve format strings that compile and then throw (or, for `"50% off"`, throw something surprising), with the real exception text. |
|
||||
| `FormattedAndPrintfDemo.java` | `format` / `formatted` / `printf` / `Formatter` are one engine; text-block pairing; varargs and arrays. |
|
||||
| `DateTimeDemo.java` | `%tF`, `%tT`, `%tA` on `java.time` values, localized names, and why `%tH` on a `LocalDate` throws. |
|
||||
| `ConcatBenchmarkDemo.java` | Indicative cost of `+`, `StringBuilder`, `formatted`, `format`. A warmed plain loop, not JMH. |
|
||||
| `StringFormatTest.java` | Pins every claim above as an assertion, 11/11 passing. |
|
||||
| `scripts/printf_compare.c` | The same values through C `printf`, for the rounding comparison. |
|
||||
| `output/01-11` | Captured runs of everything above. Timing in `08` drifts between runs. |
|
||||
|
||||
## Notes worth knowing before reading the post
|
||||
|
||||
- **`%,d` cannot produce lakh/crore grouping in any locale.** `en_IN` and `hi_IN` both print `123,456,789`
|
||||
(`output/04`). The `,` flag takes one grouping size from `DecimalFormat.getGroupingSize()`, and a
|
||||
`DecimalFormat` built from `#,##,##0` reports a grouping size of 3.
|
||||
- **`"Discount 50% off"` throws `MissingFormatArgumentException`**, not an unknown-conversion error:
|
||||
`% o` parses as the space flag plus the octal conversion (`output/06`).
|
||||
- **`%f` on a `double` rounds the decimal string, `%f` on a `BigDecimal` rounds the exact value**, so
|
||||
`2.675` formats as `2.68` one way and `2.67` the other (`output/03`).
|
||||
|
||||
## License
|
||||
|
||||
MIT - see the [repo-wide LICENSE](../LICENSE).
|
||||
@@ -0,0 +1,21 @@
|
||||
== conversions: what kind of value ==
|
||||
text | 42 | 3.141590 | ff | A | true
|
||||
== width: minimum characters ==
|
||||
[ ab] [ab ] [ 42] [42 ]
|
||||
== precision: digits after the point (f), or max chars (s) ==
|
||||
[3.14] [4] [ 3.142] [abc]
|
||||
== flags ==
|
||||
[00042] [+42] [-42] [ 42] [(42)] [1,234,567]
|
||||
[0xff] [010] [FF] [-003.142]
|
||||
== argument index: reuse and reorder ==
|
||||
hello world
|
||||
echo, echo and again echo
|
||||
== %n and %% ==
|
||||
100% sure
|
||||
next line
|
||||
== %s accepts anything (calls String.valueOf / toString) ==
|
||||
[null] [7] [[1, 2]] [SHOUT]
|
||||
== %b is not 'is this the string true' ==
|
||||
true true false false
|
||||
== %c takes a char or a code point ==
|
||||
A A 😀
|
||||
@@ -0,0 +1,13 @@
|
||||
Item Qty Price Total
|
||||
------------------------------------------------
|
||||
Notebook 12 45.50 546.00
|
||||
Pen (box of 10) 3 120.00 360.00
|
||||
Stapler 1 350.00 350.00
|
||||
------------------------------------------------
|
||||
Grand total 1,256.00
|
||||
|
||||
== the same table with printf: it prints, it does not return a String ==
|
||||
Notebook | 12|
|
||||
|
||||
== truncation: %.Ns cuts, width pads ==
|
||||
[Pen (b ] [ Pen (b]
|
||||
@@ -0,0 +1,17 @@
|
||||
value %.1f %.2f exact binary value BigDecimal(HALF_EVEN,2)
|
||||
0.125 0.1 0.13 0.125 0.12
|
||||
0.25 0.3 0.25 0.25 0.25
|
||||
0.35 0.4 0.35 0.34999999999999997779 0.35
|
||||
2.675 2.7 2.68 2.67499999999999982236 2.67
|
||||
1.005 1.0 1.01 1.00499999999999989341 1.00
|
||||
0.5 0.5 0.50 0.5 0.50
|
||||
1.5 1.5 1.50 1.5 1.50
|
||||
2.5 2.5 2.50 2.5 2.50
|
||||
|
||||
== %.0f on halves: HALF_UP, away from zero ==
|
||||
1 2 3 -3
|
||||
|
||||
== same number, three routes: %f rounds what it is given ==
|
||||
double 2.675 -> 2.68
|
||||
new BigDecimal("2.675") -> 2.68
|
||||
new BigDecimal(2.675) -> 2.67
|
||||
@@ -0,0 +1,30 @@
|
||||
== what the JDK gives for 123456789 ==
|
||||
String.format(en_IN, "%,d") : 123,456,789
|
||||
String.format(hi_IN, "%,d") : 123,456,789
|
||||
NumberFormat.getInstance(en_IN) : 123,456,789
|
||||
NumberFormat currency (en_IN) : ₹123,456,789.50
|
||||
CLDR pattern for en_IN : #,##0.###
|
||||
|
||||
== a DecimalFormat pattern that LOOKS like it should work ==
|
||||
pattern #,##,##0 -> grouping size : 3
|
||||
pattern #,##,##0 -> output : 123,456,789
|
||||
|
||||
== compact formatting knows crore but drops the digits ==
|
||||
compact (default rounding) : 12Cr
|
||||
compact (2 fraction digits) : 12.35Cr
|
||||
|
||||
== IndianNumberFormat ==
|
||||
0 -> 0
|
||||
999 -> 999
|
||||
1,000 -> 1,000
|
||||
12,345 -> 12,345
|
||||
123,456 -> 1,23,456
|
||||
1,234,567 -> 12,34,567
|
||||
123,456,789 -> 12,34,56,789
|
||||
-123,456,789 -> -12,34,56,789
|
||||
12,34,567.89
|
||||
-1,00,000.00
|
||||
Rs. 12,34,567.50
|
||||
|
||||
== plugging it into a format string through %s ==
|
||||
Balance 9,87,65,432.10
|
||||
@@ -0,0 +1,17 @@
|
||||
default FORMAT locale: en_US
|
||||
|
||||
== %,.2f of 1234567.891 ==
|
||||
en-US 1,234,567.89
|
||||
de-DE 1.234.567,89
|
||||
fr-FR 1^234^567,89
|
||||
en-IN 1,234,567.89
|
||||
de-CH 1’234’567.89
|
||||
und 1,234,567.89
|
||||
|
||||
== the bug: changing the default locale changes %.2f ==
|
||||
default (en_US) : 1234.50
|
||||
default (de_DE) : 1234,50
|
||||
explicit ROOT : 1234.50
|
||||
|
||||
== not localized: hex, NaN, Infinity ==
|
||||
ff NaN Infinity
|
||||
@@ -0,0 +1,12 @@
|
||||
wrong type for %d -> IllegalFormatConversionException: d != java.lang.String
|
||||
too few arguments -> MissingFormatArgumentException: Format specifier '%s'
|
||||
unknown conversion -> UnknownFormatConversionException: Conversion = 'q'
|
||||
precision on an integer -> IllegalFormatPrecisionException: 2
|
||||
flag not valid for %x , -> FormatFlagsConversionMismatchException: Conversion = x, Flags = ,
|
||||
left-justify without width -> MissingFormatWidthException: %-d
|
||||
zero pad AND left-justify -> IllegalFormatFlagsException: Flags = '-0'
|
||||
width on %n -> IllegalFormatWidthException: 5
|
||||
double passed to %d -> IllegalFormatConversionException: d != java.lang.Double
|
||||
int passed to %f -> IllegalFormatConversionException: f != java.lang.Integer
|
||||
a stray % in user text -> MissingFormatArgumentException: Format specifier '% o'
|
||||
extra arguments (no error) -> OK: a
|
||||
@@ -0,0 +1,22 @@
|
||||
== four spellings, one result ==
|
||||
Hi Asha, 3 items, Rs 1499.50
|
||||
Hi Asha, 3 items, Rs 1499.50
|
||||
Hi Asha, 3 items, Rs 1499.50
|
||||
Hi Asha, 3 items, Rs 1499.50
|
||||
format == formatted == Formatter: true
|
||||
|
||||
== formatted() on a text block ==
|
||||
Invoice for Asha
|
||||
items : 3
|
||||
total : 1,499.50
|
||||
|
||||
== printf returns the PrintStream, so calls chain ==
|
||||
one two three
|
||||
|
||||
== %n vs \n ==
|
||||
true on this platform (line.separator is \n)
|
||||
|
||||
== an array argument: spread as the varargs, unless you cast it to Object ==
|
||||
x-1
|
||||
p-q
|
||||
true
|
||||
@@ -0,0 +1,5 @@
|
||||
JDK 25.0.5, 2000000 iterations per variant, best of 5 rounds
|
||||
PLUS 52.6 ns/op
|
||||
BUILDER 66.3 ns/op
|
||||
FORMATTED 292.1 ns/op
|
||||
FORMAT 279.5 ns/op
|
||||
@@ -0,0 +1,3 @@
|
||||
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.153 s -- in com.ankurm.stringformat.StringFormatTest
|
||||
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
@@ -0,0 +1,4 @@
|
||||
C printf %.2f of 2.675 : 2.67
|
||||
C printf %.1f of 0.35 : 0.3
|
||||
C printf %.2f of 1.005 : 1.00
|
||||
C printf %.2f of 0.125 : 0.12
|
||||
@@ -0,0 +1,14 @@
|
||||
== %t suffixes on a LocalDateTime ==
|
||||
2026-11-12 | 09:05:07 | 09:05 | 11/12/26
|
||||
Thursday, 12 November 2026
|
||||
09:05 AM
|
||||
|
||||
== the same suffixes, other locales (names are localized, numbers are not) ==
|
||||
Donnerstag, 12. November 2026
|
||||
jeudi 12 novembre 2026
|
||||
|
||||
== a LocalDate has no hour ==
|
||||
IllegalFormatConversionException: H != java.time.LocalDate
|
||||
|
||||
== DateTimeFormatter does the same job with a reusable, immutable object ==
|
||||
12 Nov 2026 09:05
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>java-core-examples</artifactId>
|
||||
<version>1.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>string-format</artifactId>
|
||||
<name>string-format</name>
|
||||
<description>String.format, formatted() and printf: specifier anatomy, padding and alignment, rounding, locales, Indian lakh/crore grouping (which Formatter cannot do), the exceptions it throws, and what it costs against concatenation.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.11.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.13.0</version>
|
||||
<configuration>
|
||||
<release>25</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<configuration>
|
||||
<argLine>-Dstdout.encoding=UTF-8 -Dfile.encoding=UTF-8</argLine>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
/* The same four values RoundingDemo formats, through C's printf, for comparison with Java's %f. */
|
||||
#include <stdio.h>
|
||||
int main(void) {
|
||||
printf("C printf %%.2f of 2.675 : %.2f\n", 2.675);
|
||||
printf("C printf %%.1f of 0.35 : %.1f\n", 0.35);
|
||||
printf("C printf %%.2f of 1.005 : %.2f\n", 1.005);
|
||||
printf("C printf %%.2f of 0.125 : %.2f\n", 0.125);
|
||||
return 0;
|
||||
}
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file in output/. Timing output (07) is indicative and drifts between runs.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -q -B compile
|
||||
run() { java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.stringformat.$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' > "output/$2.txt"; }
|
||||
run BasicsDemo 01-basics
|
||||
run ReportTableDemo 02-report-table
|
||||
run RoundingDemo 03-rounding
|
||||
run IndianGroupingDemo 04-indian-grouping
|
||||
run LocaleDemo 05-locale
|
||||
run FormatErrorsDemo 06-format-errors
|
||||
run FormattedAndPrintfDemo 07-formatted-printf
|
||||
run ConcatBenchmarkDemo 08-concat-benchmark
|
||||
if command -v gcc >/dev/null; then
|
||||
gcc scripts/printf_compare.c -o /tmp/printf_compare && /tmp/printf_compare > output/10-c-printf.txt
|
||||
fi
|
||||
run DateTimeDemo 11-datetime
|
||||
mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/09-tests.txt
|
||||
echo "regenerated:"; ls output
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run one demo: scripts/run.sh BasicsDemo
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -q -B compile
|
||||
java -Dstdout.encoding=UTF-8 -cp target/classes "com.ankurm.stringformat.${1:?demo class name}"
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
/**
|
||||
* The anatomy of a format specifier: %[argument_index$][flags][width][.precision]conversion.
|
||||
* Each line below changes exactly one part of the specifier so the effect is visible.
|
||||
*/
|
||||
public class BasicsDemo {
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("== conversions: what kind of value ==");
|
||||
System.out.println(String.format("%s | %d | %f | %x | %c | %b", "text", 42, 3.14159, 255, 'A', true));
|
||||
|
||||
System.out.println("== width: minimum characters ==");
|
||||
System.out.println(String.format("[%8s] [%-8s] [%8d] [%-8d]", "ab", "ab", 42, 42));
|
||||
|
||||
System.out.println("== precision: digits after the point (f), or max chars (s) ==");
|
||||
System.out.println(String.format("[%.2f] [%.0f] [%8.3f] [%.3s]", 3.14159, 3.5, 3.14159, "abcdef"));
|
||||
|
||||
System.out.println("== flags ==");
|
||||
System.out.println(String.format("[%05d] [%+d] [%+d] [% d] [%(d] [%,d]", 42, 42, -42, 42, -42, 1234567));
|
||||
System.out.println(String.format("[%#x] [%#o] [%X] [%08.3f]", 255, 8, 255, -3.14159));
|
||||
|
||||
System.out.println("== argument index: reuse and reorder ==");
|
||||
System.out.println(String.format("%2$s %1$s", "world", "hello"));
|
||||
System.out.println(String.format("%1$s, %1$s and again %<s", "echo"));
|
||||
|
||||
System.out.println("== %n and %% ==");
|
||||
System.out.println(String.format("100%% sure%nnext line"));
|
||||
|
||||
System.out.println("== %s accepts anything (calls String.valueOf / toString) ==");
|
||||
System.out.println(String.format("[%s] [%s] [%s] [%S]", null, 7, java.util.List.of(1, 2), "shout"));
|
||||
|
||||
System.out.println("== %b is not 'is this the string true' ==");
|
||||
System.out.println(String.format("%b %b %b %b", "false", 0, null, Boolean.FALSE));
|
||||
|
||||
System.out.println("== %c takes a char or a code point ==");
|
||||
System.out.println(String.format("%c %c %c", 'A', 65, 0x1F600));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
/**
|
||||
* Indicative cost of String.format versus + concatenation versus StringBuilder. This is a
|
||||
* warmed-up plain loop with a sink to defeat dead-code elimination - good enough to show an
|
||||
* order of magnitude, NOT a JMH result. Numbers drift between runs and machines.
|
||||
*/
|
||||
public class ConcatBenchmarkDemo {
|
||||
|
||||
static volatile int sink;
|
||||
|
||||
public static void main(String[] args) {
|
||||
int n = 2_000_000;
|
||||
System.out.println("JDK " + System.getProperty("java.version") + ", " + n + " iterations per variant, best of 5 rounds");
|
||||
|
||||
for (int warm = 0; warm < 3; warm++) {
|
||||
run(Variant.FORMAT, 200_000);
|
||||
run(Variant.FORMATTED, 200_000);
|
||||
run(Variant.PLUS, 200_000);
|
||||
run(Variant.BUILDER, 200_000);
|
||||
}
|
||||
for (Variant v : Variant.values()) {
|
||||
long best = Long.MAX_VALUE;
|
||||
for (int round = 0; round < 5; round++) {
|
||||
best = Math.min(best, run(v, n));
|
||||
}
|
||||
System.out.println(String.format("%-10s %8.1f ns/op", v, best / (double) n));
|
||||
}
|
||||
}
|
||||
|
||||
enum Variant { PLUS, BUILDER, FORMATTED, FORMAT }
|
||||
|
||||
static long run(Variant v, int n) {
|
||||
long t0 = System.nanoTime();
|
||||
int acc = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
String s = switch (v) {
|
||||
case PLUS -> "user=" + i + " score=" + (i * 0.5);
|
||||
case BUILDER -> new StringBuilder().append("user=").append(i).append(" score=").append(i * 0.5).toString();
|
||||
case FORMATTED -> "user=%d score=%.1f".formatted(i, i * 0.5);
|
||||
case FORMAT -> String.format("user=%d score=%.1f", i, i * 0.5);
|
||||
};
|
||||
acc += s.length();
|
||||
}
|
||||
sink = acc;
|
||||
return System.nanoTime() - t0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The %t conversions format java.time values directly. They work, but they are locale-sensitive
|
||||
* and a LocalDate has no time fields, so asking it for an hour is a runtime error.
|
||||
*/
|
||||
public class DateTimeDemo {
|
||||
|
||||
public static void main(String[] args) {
|
||||
LocalDateTime when = LocalDateTime.of(2026, 11, 12, 9, 5, 7);
|
||||
LocalDate day = when.toLocalDate();
|
||||
|
||||
System.out.println("== %t suffixes on a LocalDateTime ==");
|
||||
System.out.println(String.format(Locale.US, "%tF | %tT | %tR | %tD", when, when, when, when));
|
||||
System.out.println(String.format(Locale.US, "%tA, %<te %<tB %<tY", when));
|
||||
System.out.println(String.format(Locale.US, "%tI:%<tM %<Tp", when));
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== the same suffixes, other locales (names are localized, numbers are not) ==");
|
||||
System.out.println(String.format(Locale.GERMANY, "%tA, %<te. %<tB %<tY", when));
|
||||
System.out.println(String.format(Locale.FRANCE, "%tA %<te %<tB %<tY", when));
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== a LocalDate has no hour ==");
|
||||
try {
|
||||
System.out.println(String.format("%tH", day));
|
||||
} catch (RuntimeException e) {
|
||||
System.out.println(e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== DateTimeFormatter does the same job with a reusable, immutable object ==");
|
||||
DateTimeFormatter f = DateTimeFormatter.ofPattern("dd MMM uuuu HH:mm", Locale.US);
|
||||
System.out.println(f.format(when));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Format strings are checked at runtime, not compile time. Every line here compiles and then throws.
|
||||
* This prints the real exception class and message so the symptom is recognisable later.
|
||||
*/
|
||||
public class FormatErrorsDemo {
|
||||
|
||||
public static void main(String[] args) {
|
||||
run("wrong type for %d", () -> String.format("%d", "42"));
|
||||
run("too few arguments", () -> String.format("%s and %s", "one"));
|
||||
run("unknown conversion", () -> String.format("%q", 1));
|
||||
run("precision on an integer", () -> String.format("%.2d", 5));
|
||||
run("flag not valid for %x ,", () -> String.format("%,x", 255));
|
||||
run("left-justify without width", () -> String.format("%-d", 5));
|
||||
run("zero pad AND left-justify", () -> String.format("%-08d", 5));
|
||||
run("width on %n", () -> String.format("%5n"));
|
||||
run("double passed to %d", () -> String.format("%d", 3.0));
|
||||
run("int passed to %f", () -> String.format("%f", 3));
|
||||
run("a stray % in user text", () -> String.format("Discount 50% off"));
|
||||
run("extra arguments (no error)", () -> String.format("%s", "a", "b", "c"));
|
||||
}
|
||||
|
||||
private static void run(String label, Supplier<String> call) {
|
||||
try {
|
||||
System.out.println(String.format("%-28s -> OK: %s", label, call.get()));
|
||||
} catch (RuntimeException e) {
|
||||
System.out.println(String.format("%-28s -> %s: %s", label, e.getClass().getSimpleName(), e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
import java.util.Formatter;
|
||||
|
||||
/**
|
||||
* The four ways to reach the same engine: String.format, String.formatted, PrintStream.printf,
|
||||
* and java.util.Formatter. Plus the text-block pairing that makes formatted() worth using.
|
||||
*/
|
||||
public class FormattedAndPrintfDemo {
|
||||
|
||||
public static void main(String[] args) {
|
||||
String name = "Asha";
|
||||
int items = 3;
|
||||
double total = 1499.5;
|
||||
|
||||
System.out.println("== four spellings, one result ==");
|
||||
String a = String.format("Hi %s, %d items, Rs %.2f", name, items, total);
|
||||
String b = "Hi %s, %d items, Rs %.2f".formatted(name, items, total);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
new Formatter(sb).format("Hi %s, %d items, Rs %.2f", name, items, total);
|
||||
System.out.println(a);
|
||||
System.out.println(b);
|
||||
System.out.println(sb);
|
||||
System.out.printf("Hi %s, %d items, Rs %.2f%n", name, items, total);
|
||||
System.out.println("format == formatted == Formatter: " + (a.equals(b) && b.equals(sb.toString())));
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== formatted() on a text block ==");
|
||||
String invoice = """
|
||||
Invoice for %s
|
||||
items : %5d
|
||||
total : %,10.2f
|
||||
""".formatted(name, items, total);
|
||||
System.out.print(invoice);
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== printf returns the PrintStream, so calls chain ==");
|
||||
System.out.printf("%s ", "one").printf("%s ", "two").printf("%s%n", "three");
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== %n vs \\n ==");
|
||||
System.out.println(String.format("a%nb").equals("a\nb") + " on this platform (line.separator is "
|
||||
+ (System.lineSeparator().equals("\n") ? "\\n" : "not \\n") + ")");
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== an array argument: spread as the varargs, unless you cast it to Object ==");
|
||||
Object[] row = {"x", 1};
|
||||
String[] strings = {"p", "q"};
|
||||
System.out.println(String.format("%s-%s", row));
|
||||
System.out.println(String.format("%s-%s", strings));
|
||||
System.out.println(String.format("%s", (Object) strings).startsWith("[Ljava.lang.String;@"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Tries every built-in route to lakh/crore grouping, shows that none of them produce it,
|
||||
* then uses IndianNumberFormat.
|
||||
*/
|
||||
public class IndianGroupingDemo {
|
||||
|
||||
public static void main(String[] args) {
|
||||
Locale enIN = Locale.of("en", "IN");
|
||||
Locale hiIN = Locale.of("hi", "IN");
|
||||
long amount = 12_34_56_789L; // 12 crore 34 lakh 56 thousand 789
|
||||
|
||||
System.out.println("== what the JDK gives for 123456789 ==");
|
||||
System.out.println("String.format(en_IN, \"%,d\") : " + String.format(enIN, "%,d", amount));
|
||||
System.out.println("String.format(hi_IN, \"%,d\") : " + String.format(hiIN, "%,d", amount));
|
||||
System.out.println("NumberFormat.getInstance(en_IN) : " + NumberFormat.getInstance(enIN).format(amount));
|
||||
System.out.println("NumberFormat currency (en_IN) : " + NumberFormat.getCurrencyInstance(enIN).format(amount + 0.5));
|
||||
System.out.println("CLDR pattern for en_IN : " + ((DecimalFormat) NumberFormat.getInstance(enIN)).toPattern());
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== a DecimalFormat pattern that LOOKS like it should work ==");
|
||||
DecimalFormat df = new DecimalFormat("#,##,##0");
|
||||
System.out.println("pattern #,##,##0 -> grouping size : " + df.getGroupingSize());
|
||||
System.out.println("pattern #,##,##0 -> output : " + df.format(amount));
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== compact formatting knows crore but drops the digits ==");
|
||||
NumberFormat compact = NumberFormat.getCompactNumberInstance(enIN, NumberFormat.Style.SHORT);
|
||||
System.out.println("compact (default rounding) : " + compact.format(amount));
|
||||
compact.setMaximumFractionDigits(2);
|
||||
System.out.println("compact (2 fraction digits) : " + compact.format(amount));
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== IndianNumberFormat ==");
|
||||
for (long v : new long[]{0, 999, 1_000, 12_345, 1_23_456, 12_34_567, 12_34_56_789L, -12_34_56_789L}) {
|
||||
System.out.println(String.format("%,15d -> %s", v, IndianNumberFormat.format(v)));
|
||||
}
|
||||
System.out.println(IndianNumberFormat.format(new BigDecimal("1234567.891"), 2));
|
||||
System.out.println(IndianNumberFormat.format(new BigDecimal("-99999.995"), 2));
|
||||
System.out.println("Rs. " + IndianNumberFormat.format(1234567.5, 2));
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== plugging it into a format string through %s ==");
|
||||
System.out.println(String.format("%-12s %20s", "Balance", IndianNumberFormat.format(new BigDecimal("98765432.10"), 2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* Lakh/crore digit grouping (12,34,56,789) - the Indian system: the last three digits, then groups
|
||||
* of two. java.util.Formatter cannot produce it because its ',' flag takes ONE grouping size from
|
||||
* DecimalFormat.getGroupingSize(), and DecimalFormat keeps only the last group length of a pattern.
|
||||
* See IndianGroupingDemo for the output that proves it.
|
||||
*/
|
||||
public final class IndianNumberFormat {
|
||||
|
||||
private IndianNumberFormat() {}
|
||||
|
||||
/** Groups a whole number: 12345678 -> "1,23,45,678". */
|
||||
public static String format(long value) {
|
||||
String digits = Long.toString(value);
|
||||
boolean negative = digits.startsWith("-");
|
||||
if (negative) {
|
||||
digits = digits.substring(1);
|
||||
}
|
||||
return (negative ? "-" : "") + group(digits);
|
||||
}
|
||||
|
||||
/** Groups the integer part of a decimal and rounds the fraction HALF_UP: 1234567.891, 2 -> "12,34,567.89". */
|
||||
public static String format(BigDecimal value, int fractionDigits) {
|
||||
BigDecimal rounded = value.setScale(fractionDigits, RoundingMode.HALF_UP);
|
||||
String plain = rounded.abs().toPlainString();
|
||||
int dot = plain.indexOf('.');
|
||||
String integerPart = dot < 0 ? plain : plain.substring(0, dot);
|
||||
String fraction = dot < 0 ? "" : plain.substring(dot);
|
||||
String sign = rounded.signum() < 0 ? "-" : "";
|
||||
return sign + group(integerPart) + fraction;
|
||||
}
|
||||
|
||||
/** Convenience for doubles - goes through BigDecimal.valueOf so it rounds the way %f does. */
|
||||
public static String format(double value, int fractionDigits) {
|
||||
return format(BigDecimal.valueOf(value), fractionDigits);
|
||||
}
|
||||
|
||||
private static String group(String digits) {
|
||||
int n = digits.length();
|
||||
if (n <= 3) {
|
||||
return digits;
|
||||
}
|
||||
String head = digits.substring(0, n - 3);
|
||||
String tail = digits.substring(n - 3);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int first = head.length() % 2; // leading group of 1 or 2 digits
|
||||
if (first == 1) {
|
||||
sb.append(head.charAt(0));
|
||||
}
|
||||
for (int i = first; i < head.length(); i += 2) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(',');
|
||||
}
|
||||
sb.append(head, i, i + 2);
|
||||
}
|
||||
return sb.append(',').append(tail).toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The same format string produces different text under different locales. String.format(fmt, ...)
|
||||
* silently uses the default FORMAT locale; String.format(Locale, fmt, ...) does not.
|
||||
*/
|
||||
public class LocaleDemo {
|
||||
|
||||
public static void main(String[] args) {
|
||||
double v = 1234567.891;
|
||||
System.out.println("default FORMAT locale: " + Locale.getDefault(Locale.Category.FORMAT));
|
||||
System.out.println();
|
||||
System.out.println("== %,.2f of 1234567.891 ==");
|
||||
for (Locale l : new Locale[]{Locale.US, Locale.GERMANY, Locale.FRANCE, Locale.of("en", "IN"), Locale.of("de", "CH"), Locale.ROOT}) {
|
||||
String s = String.format(l, "%,.2f", v);
|
||||
System.out.println(String.format("%-8s %s", l.toLanguageTag().isEmpty() ? "ROOT" : l.toLanguageTag(), visible(s)));
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== the bug: changing the default locale changes %.2f ==");
|
||||
Locale saved = Locale.getDefault(Locale.Category.FORMAT);
|
||||
System.out.println("default (" + saved + ") : " + String.format("%.2f", 1234.5));
|
||||
Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY);
|
||||
System.out.println("default (de_DE) : " + String.format("%.2f", 1234.5));
|
||||
System.out.println("explicit ROOT : " + String.format(Locale.ROOT, "%.2f", 1234.5));
|
||||
Locale.setDefault(Locale.Category.FORMAT, saved);
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== not localized: hex, NaN, Infinity ==");
|
||||
System.out.println(String.format(Locale.GERMANY, "%x %f %f", 255, Double.NaN, Double.POSITIVE_INFINITY));
|
||||
}
|
||||
|
||||
/** Makes non-breaking and narrow no-break spaces visible so they are not mistaken for plain spaces. */
|
||||
private static String visible(String s) {
|
||||
return s.replace(' ', '~').replace(' ', '^');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
/**
|
||||
* Padding and alignment in practice: a fixed-width table. Text is left-aligned with a negative
|
||||
* width, numbers are right-aligned so the decimal points line up.
|
||||
*/
|
||||
public class ReportTableDemo {
|
||||
|
||||
record Line(String item, int qty, double price) {}
|
||||
|
||||
public static void main(String[] args) {
|
||||
var lines = java.util.List.of(
|
||||
new Line("Notebook", 12, 45.5),
|
||||
new Line("Pen (box of 10)", 3, 120.0),
|
||||
new Line("Stapler", 1, 349.999));
|
||||
|
||||
String header = String.format("%-18s %5s %10s %12s", "Item", "Qty", "Price", "Total");
|
||||
System.out.println(header);
|
||||
System.out.println("-".repeat(header.length()));
|
||||
double grand = 0;
|
||||
for (Line l : lines) {
|
||||
double total = l.qty() * l.price();
|
||||
grand += total;
|
||||
System.out.println(String.format("%-18s %5d %10.2f %,12.2f", l.item(), l.qty(), l.price(), total));
|
||||
}
|
||||
System.out.println("-".repeat(header.length()));
|
||||
System.out.println(String.format("%-18s %5s %10s %,12.2f", "Grand total", "", "", grand));
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== the same table with printf: it prints, it does not return a String ==");
|
||||
System.out.printf("%-18s|%5d|%n", "Notebook", 12);
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== truncation: %.Ns cuts, width pads ==");
|
||||
System.out.println(String.format("[%-10.6s] [%10.6s]", "Pen (box of 10)", "Pen (box of 10)"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* %f rounds HALF_UP on the decimal string Double.toString produces, not on the exact binary value.
|
||||
* That is why it can disagree with C's printf and with BigDecimal(double).
|
||||
*/
|
||||
public class RoundingDemo {
|
||||
|
||||
public static void main(String[] args) {
|
||||
double[] values = {0.125, 0.25, 0.35, 2.675, 1.005, 0.5, 1.5, 2.5};
|
||||
System.out.println(String.format("%-8s %-8s %-8s %-22s %s", "value", "%.1f", "%.2f", "exact binary value", "BigDecimal(HALF_EVEN,2)"));
|
||||
for (double v : values) {
|
||||
System.out.println(String.format("%-8s %-8s %-8s %-22s %s",
|
||||
v,
|
||||
String.format("%.1f", v),
|
||||
String.format("%.2f", v),
|
||||
new BigDecimal(v).toPlainString().substring(0, Math.min(22, new BigDecimal(v).toPlainString().length())),
|
||||
new BigDecimal(v).setScale(2, RoundingMode.HALF_EVEN)));
|
||||
}
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== %.0f on halves: HALF_UP, away from zero ==");
|
||||
System.out.println(String.format("%.0f %.0f %.0f %.0f", 0.5, 1.5, 2.5, -2.5));
|
||||
|
||||
System.out.println();
|
||||
System.out.println("== same number, three routes: %f rounds what it is given ==");
|
||||
System.out.println(String.format("double 2.675 -> %.2f", 2.675));
|
||||
System.out.println(String.format("new BigDecimal(\"2.675\") -> %.2f", new BigDecimal("2.675")));
|
||||
System.out.println(String.format("new BigDecimal(2.675) -> %.2f", new BigDecimal(2.675)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.ankurm.stringformat;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Pins every claim the post makes as an assertion, so a JDK change that breaks one fails the build. */
|
||||
class StringFormatTest {
|
||||
|
||||
@Test
|
||||
void widthFlagsAndPrecision() {
|
||||
assertEquals("[ ab] [ab ]", String.format("[%8s] [%-8s]", "ab", "ab"));
|
||||
assertEquals("00042", String.format("%05d", 42));
|
||||
assertEquals("3.14", String.format("%.2f", 3.14159));
|
||||
assertEquals("abc", String.format("%.3s", "abcdef"));
|
||||
assertEquals("world hello", String.format("%2$s %1$s", "hello", "world"));
|
||||
assertEquals("a a a", String.format("%1$s %1$s %<s", "a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void formattedEqualsFormat() {
|
||||
assertEquals(String.format("%s=%d", "k", 7), "%s=%d".formatted("k", 7));
|
||||
}
|
||||
|
||||
@Test
|
||||
void booleanConversionIsTruthyForAnyNonNullNonBoolean() {
|
||||
assertEquals("true", String.format("%b", "false"));
|
||||
assertEquals("true", String.format("%b", 0));
|
||||
assertEquals("false", String.format("%b", (Object) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void floatRoundingIsHalfUpOnTheDecimalString() {
|
||||
assertEquals("0.3", String.format("%.1f", 0.25));
|
||||
assertEquals("0.4", String.format("%.1f", 0.35)); // C printf gives 0.3 (binary value is below .35)
|
||||
assertEquals("3", String.format("%.0f", 2.5));
|
||||
assertEquals("-3", String.format("%.0f", -2.5));
|
||||
assertEquals("2.68", String.format("%.2f", 2.675));
|
||||
assertEquals("2.68", String.format("%.2f", new BigDecimal("2.675")));
|
||||
assertEquals("2.67", String.format("%.2f", new BigDecimal(2.675))); // exact binary value is 2.67499...
|
||||
assertEquals("2.67", new BigDecimal(2.675).setScale(2, java.math.RoundingMode.HALF_EVEN).toPlainString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void javaCannotGroupInLakhsAndCrores() {
|
||||
Locale enIN = Locale.of("en", "IN");
|
||||
assertEquals("123,456,789", String.format(enIN, "%,d", 123456789L));
|
||||
assertEquals("123,456,789", NumberFormat.getInstance(enIN).format(123456789L));
|
||||
assertEquals(3, new DecimalFormat("#,##,##0").getGroupingSize());
|
||||
assertEquals("123,456,789", new DecimalFormat("#,##,##0").format(123456789L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void indianFormatGroupsLastThreeThenTwos() {
|
||||
assertEquals("0", IndianNumberFormat.format(0));
|
||||
assertEquals("999", IndianNumberFormat.format(999));
|
||||
assertEquals("1,000", IndianNumberFormat.format(1000));
|
||||
assertEquals("1,23,456", IndianNumberFormat.format(123456));
|
||||
assertEquals("12,34,567", IndianNumberFormat.format(1234567));
|
||||
assertEquals("12,34,56,789", IndianNumberFormat.format(123456789L));
|
||||
assertEquals("-12,34,56,789", IndianNumberFormat.format(-123456789L));
|
||||
assertEquals("92,23,37,20,36,85,47,75,807", IndianNumberFormat.format(Long.MAX_VALUE));
|
||||
assertEquals("12,34,567.89", IndianNumberFormat.format(new BigDecimal("1234567.891"), 2));
|
||||
assertEquals("-1,00,000.00", IndianNumberFormat.format(new BigDecimal("-99999.995"), 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultLocaleChangesNumericOutput() {
|
||||
Locale saved = Locale.getDefault(Locale.Category.FORMAT);
|
||||
try {
|
||||
Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY);
|
||||
assertEquals("1234,50", String.format("%.2f", 1234.5));
|
||||
assertEquals("1234.50", String.format(Locale.ROOT, "%.2f", 1234.5));
|
||||
} finally {
|
||||
Locale.setDefault(Locale.Category.FORMAT, saved);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorsAreRuntimeExceptions() {
|
||||
assertThrows(IllegalFormatConversionException.class, () -> String.format("%d", "42"));
|
||||
assertThrows(IllegalFormatConversionException.class, () -> String.format("%d", 3.0));
|
||||
assertThrows(MissingFormatArgumentException.class, () -> String.format("%s %s", "one"));
|
||||
assertThrows(UnknownFormatConversionException.class, () -> String.format("%q", 1));
|
||||
assertThrows(IllegalFormatPrecisionException.class, () -> String.format("%.2d", 5));
|
||||
assertThrows(FormatFlagsConversionMismatchException.class, () -> String.format("%,x", 255));
|
||||
assertThrows(MissingFormatWidthException.class, () -> String.format("%-d", 5));
|
||||
var stray = assertThrows(MissingFormatArgumentException.class, () -> String.format("Discount 50% off"));
|
||||
assertEquals("Format specifier '% o'", stray.getMessage()); // "% o" = space flag + octal conversion
|
||||
assertEquals("a", String.format("%s", "a", "b", "c")); // extra arguments are ignored silently
|
||||
}
|
||||
|
||||
@Test
|
||||
void arraysSpreadIntoVarargsUnlessCastToObject() {
|
||||
Object[] objects = {"x", 1};
|
||||
assertEquals("x-1", String.format("%s-%s", objects));
|
||||
String[] strings = {"p", "q"};
|
||||
assertEquals("p-q", String.format("%s-%s", strings));
|
||||
assertTrue(String.format("%s", (Object) strings).startsWith("[Ljava.lang.String;@"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyFormatErrorIsAnIllegalFormatException() {
|
||||
for (Runnable bad : new Runnable[]{
|
||||
() -> String.format("%d", "42"),
|
||||
() -> String.format("%s %s", "one"),
|
||||
() -> String.format("%q", 1),
|
||||
() -> String.format("%.2d", 5),
|
||||
() -> String.format("%,x", 255),
|
||||
() -> String.format("%-d", 5),
|
||||
() -> String.format("%-08d", 5),
|
||||
() -> String.format("%5n")}) {
|
||||
assertThrows(IllegalFormatException.class, bad::run);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dateTimeSuffixes() {
|
||||
var when = java.time.LocalDateTime.of(2026, 11, 12, 9, 5, 7);
|
||||
assertEquals("2026-11-12 | 09:05:07", String.format(Locale.US, "%tF | %<tT", when));
|
||||
assertThrows(IllegalFormatConversionException.class,
|
||||
() -> String.format("%tH", when.toLocalDate()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user