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:
Claude
2026-11-12 11:00:22 +05:30
parent db129fb1ce
commit 319697c345
29 changed files with 859 additions and 0 deletions
@@ -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()));
}
}