Staying true to my comment that I'd value readability above performance, here's a version where it should be clear what's happening (assuming you've used BigDecimals before) without excessive commenting (I believe in self-documenting code), without worrying about performance (since I can't picture a scenario where you'd want to do this so many millions of times that performance even becomes a consideration).
This version:
- uses
BigDecimals for precision and to avoid rounding issues - works for rounding down as requested by the OP
- works for other rounding modes, e.g.
HALF_UP as in the tests - allows you to adjust the precision (change
REQUIRED_PRECISION) - uses an
enum to define the thresholds, i.e. could easily be adjusted to use KB/MB/GB/TB instead of k/m/b/t, etc., and could of course be extended beyond TRILLION if required - comes with thorough unit tests, since the test cases in the question weren't testing the borders
- should work for zero and negative numbers
Threshold.java:
import java.math.BigDecimal; public enum Threshold { TRILLION("1000000000000", 12, 't', null), BILLION("1000000000", 9, 'b', TRILLION), MILLION("1000000", 6, 'm', BILLION), THOUSAND("1000", 3, 'k', MILLION), ZERO("0", 0, null, THOUSAND); private BigDecimal value; private int zeroes; protected Character suffix; private Threshold higherThreshold; private Threshold(String aValueString, int aNumberOfZeroes, Character aSuffix, Threshold aThreshold) { value = new BigDecimal(aValueString); zeroes = aNumberOfZeroes; suffix = aSuffix; higherThreshold = aThreshold; } public static Threshold thresholdFor(long aValue) { return thresholdFor(new BigDecimal(aValue)); } public static Threshold thresholdFor(BigDecimal aValue) { for (Threshold eachThreshold : Threshold.values()) { if (eachThreshold.value.compareTo(aValue) <= 0) { return eachThreshold; } } return TRILLION; // shouldn't be needed, but you might have to extend the enum } public int getNumberOfZeroes() { return zeroes; } public String getSuffix() { return suffix == null ? "" : "" + suffix; } public Threshold getHigherThreshold() { return higherThreshold; } }
NumberShortener.java:
import java.math.BigDecimal; import java.math.RoundingMode; public class NumberShortener { public static final int REQUIRED_PRECISION = 2; public static BigDecimal toPrecisionWithoutLoss(BigDecimal aBigDecimal, int aPrecision, RoundingMode aMode) { int previousScale = aBigDecimal.scale(); int previousPrecision = aBigDecimal.precision(); int newPrecision = Math.max(previousPrecision - previousScale, aPrecision); return aBigDecimal.setScale(previousScale + newPrecision - previousPrecision, aMode); } private static BigDecimal scaledNumber(BigDecimal aNumber, RoundingMode aMode) { Threshold threshold = Threshold.thresholdFor(aNumber); BigDecimal adjustedNumber = aNumber.movePointLeft(threshold.getNumberOfZeroes()); BigDecimal scaledNumber = toPrecisionWithoutLoss(adjustedNumber, REQUIRED_PRECISION, aMode).stripTrailingZeros(); // System.out.println("Number: <" + aNumber + ">, adjusted: <" + adjustedNumber // + ">, rounded: <" + scaledNumber + ">"); return scaledNumber; } public static String shortenedNumber(long aNumber, RoundingMode aMode) { boolean isNegative = aNumber < 0; BigDecimal numberAsBigDecimal = new BigDecimal(isNegative ? -aNumber : aNumber); Threshold threshold = Threshold.thresholdFor(numberAsBigDecimal); BigDecimal scaledNumber = aNumber == 0 ? numberAsBigDecimal : scaledNumber( numberAsBigDecimal, aMode); if (scaledNumber.compareTo(new BigDecimal("1000")) >= 0) { scaledNumber = scaledNumber(scaledNumber, aMode); threshold = threshold.getHigherThreshold(); } String sign = isNegative ? "-" : ""; String printNumber = sign + scaledNumber.stripTrailingZeros().toPlainString() + threshold.getSuffix(); // System.out.println("Number: <" + sign + numberAsBigDecimal + ">, rounded: <" // + sign + scaledNumber + ">, print: <" + printNumber + ">"); return printNumber; } }
(Uncomment the println statements or change to use your favourite logger to see what it's doing.)
And finally, the tests in NumberShortenerTest (plain JUnit 4):
import static org.junit.Assert.*; import java.math.BigDecimal; import java.math.RoundingMode; import org.junit.Test; public class NumberShortenerTest { private static final long[] NUMBERS_FROM_OP = new long[] { 1000, 5821, 10500, 101800, 2000000, 7800000, 92150000, 123200000 }; private static final String[] EXPECTED_FROM_OP = new String[] { "1k", "5.8k", "10k", "101k", "2m", "7.8m", "92m", "123m" }; private static final String[] EXPECTED_FROM_OP_HALF_UP = new String[] { "1k", "5.8k", "11k", "102k", "2m", "7.8m", "92m", "123m" }; private static final long[] NUMBERS_TO_TEST = new long[] { 1, 500, 999, 1000, 1001, 1009, 1049, 1050, 1099, 1100, 12345, 123456, 999999, 1000000, 1000099, 1000999, 1009999, 1099999, 1100000, 1234567, 999999999, 1000000000, 9123456789L, 123456789123L }; private static final String[] EXPECTED_FROM_TEST = new String[] { "1", "500", "999", "1k", "1k", "1k", "1k", "1k", "1k", "1.1k", "12k", "123k", "999k", "1m", "1m", "1m", "1m", "1m", "1.1m", "1.2m", "999m", "1b", "9.1b", "123b" }; private static final String[] EXPECTED_FROM_TEST_HALF_UP = new String[] { "1", "500", "999", "1k", "1k", "1k", "1k", "1.1k", "1.1k", "1.1k", "12k", "123k", "1m", "1m", "1m", "1m", "1m", "1.1m", "1.1m", "1.2m", "1b", "1b", "9.1b", "123b" }; @Test public void testThresholdFor() { assertEquals(Threshold.ZERO, Threshold.thresholdFor(1)); assertEquals(Threshold.ZERO, Threshold.thresholdFor(999)); assertEquals(Threshold.THOUSAND, Threshold.thresholdFor(1000)); assertEquals(Threshold.THOUSAND, Threshold.thresholdFor(1234)); assertEquals(Threshold.THOUSAND, Threshold.thresholdFor(9999)); assertEquals(Threshold.THOUSAND, Threshold.thresholdFor(999999)); assertEquals(Threshold.MILLION, Threshold.thresholdFor(1000000)); } @Test public void testToPrecision() { RoundingMode mode = RoundingMode.DOWN; assertEquals(new BigDecimal("1"), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("1.23456"), 1, mode)); assertEquals(new BigDecimal("1.2"), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("1.23456"), 2, mode)); assertEquals(new BigDecimal("1.23"), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("1.23456"), 3, mode)); assertEquals(new BigDecimal("1.234"), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("1.23456"), 4, mode)); assertEquals(new BigDecimal("999").toPlainString(), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("999"), 4, mode).stripTrailingZeros() .toPlainString()); assertEquals(new BigDecimal("999").toPlainString(), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("999"), 2, mode).stripTrailingZeros() .toPlainString()); assertEquals(new BigDecimal("999").toPlainString(), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("999.9"), 2, mode).stripTrailingZeros() .toPlainString()); mode = RoundingMode.HALF_UP; assertEquals(new BigDecimal("1"), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("1.23456"), 1, mode)); assertEquals(new BigDecimal("1.2"), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("1.23456"), 2, mode)); assertEquals(new BigDecimal("1.23"), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("1.23456"), 3, mode)); assertEquals(new BigDecimal("1.235"), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("1.23456"), 4, mode)); assertEquals(new BigDecimal("999").toPlainString(), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("999"), 4, mode).stripTrailingZeros() .toPlainString()); assertEquals(new BigDecimal("999").toPlainString(), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("999"), 2, mode).stripTrailingZeros() .toPlainString()); assertEquals(new BigDecimal("1000").toPlainString(), NumberShortener.toPrecisionWithoutLoss(new BigDecimal("999.9"), 2, mode) .stripTrailingZeros().toPlainString()); } @Test public void testNumbersFromOP() { for (int i = 0; i < NUMBERS_FROM_OP.length; i++) { assertEquals("Index " + i + ": " + NUMBERS_FROM_OP[i], EXPECTED_FROM_OP[i], NumberShortener.shortenedNumber(NUMBERS_FROM_OP[i], RoundingMode.DOWN)); assertEquals("Index " + i + ": " + NUMBERS_FROM_OP[i], EXPECTED_FROM_OP_HALF_UP[i], NumberShortener.shortenedNumber(NUMBERS_FROM_OP[i], RoundingMode.HALF_UP)); } } @Test public void testBorders() { assertEquals("Zero: " + 0, "0", NumberShortener.shortenedNumber(0, RoundingMode.DOWN)); assertEquals("Zero: " + 0, "0", NumberShortener.shortenedNumber(0, RoundingMode.HALF_UP)); for (int i = 0; i < NUMBERS_TO_TEST.length; i++) { assertEquals("Index " + i + ": " + NUMBERS_TO_TEST[i], EXPECTED_FROM_TEST[i], NumberShortener.shortenedNumber(NUMBERS_TO_TEST[i], RoundingMode.DOWN)); assertEquals("Index " + i + ": " + NUMBERS_TO_TEST[i], EXPECTED_FROM_TEST_HALF_UP[i], NumberShortener.shortenedNumber(NUMBERS_TO_TEST[i], RoundingMode.HALF_UP)); } } @Test public void testNegativeBorders() { for (int i = 0; i < NUMBERS_TO_TEST.length; i++) { assertEquals("Index " + i + ": -" + NUMBERS_TO_TEST[i], "-" + EXPECTED_FROM_TEST[i], NumberShortener.shortenedNumber(-NUMBERS_TO_TEST[i], RoundingMode.DOWN)); assertEquals("Index " + i + ": -" + NUMBERS_TO_TEST[i], "-" + EXPECTED_FROM_TEST_HALF_UP[i], NumberShortener.shortenedNumber(-NUMBERS_TO_TEST[i], RoundingMode.HALF_UP)); } } }
Feel free to point out in the comments if I missed a significant test case or if expected values should be adjusted.
No rounding is necessarythis seems absurd to me. Is it just to complicate things? Wouldn't it be better to rephrase this toRounding is not necessary, but welcome?