From 9446fe6501da3f98cbe888d102551e10920d8546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Bouyssi=C3=A9?= <6719996+david-bouyssie@users.noreply.github.com> Date: Tue, 1 Nov 2022 22:19:11 +0100 Subject: [PATCH] Fix #2902: Avoid Array allocation in ieee754tostring implementations (#2917) * Fix #2902: Avoid Array allocation in ieee754tostring implementations * Address reviewer suggestions in PR #2917: * add comment to describe RESULT_STRING_MAX_LENGTH magic numbers * add private method _xxxToCharsNoCheck() (e.g. isNaN) to avoid redundant checks when calling xxxToString() * mark xxxToString() methods as @deprecated (note: this line is commented, as it is currently leading to errors in tests) * add new unit tests in RyuDoubleTest and RyuFloatTest (not asked by the reviewer) * add unit test in StringBuilderTest * renamed and reorganize tests in StringBufferTest * Remove unnecessary @noinline annotations * Remove methods RyuDouble.doubleToString()/RyuFloat.floatToString() and put simplified versions in RyuDoubleTest/RyuFloatTest * PR #2902 improvements: * re-implement Double.toString/Float.toString in order to use RyuDouble.doubleToChars()/RyuFloat.floatToChars() * simplify doubleToString()/floatToString() example wrappers in unit tests * Add a note to highlight `result.length - offset >= RESULT_STRING_MAX_LENGTH` --- .../java/lang/AbstractStringBuilder.scala | 34 ++++++ javalib/src/main/scala/java/lang/Double.scala | 5 +- javalib/src/main/scala/java/lang/Float.scala | 5 +- .../main/scala/java/lang/StringBuffer.scala | 12 +- .../main/scala/java/lang/StringBuilder.scala | 4 +- .../ieee754tostring/ryu/RyuDouble.scala | 96 +++++++++++----- .../ieee754tostring/ryu/RyuFloat.scala | 103 +++++++++++++----- .../ieee754tostring/ryu/RyuDoubleTest.scala | 26 ++++- .../ieee754tostring/ryu/RyuFloatTest.scala | 22 +++- .../scala/javalib/lang/StringBufferTest.scala | 15 ++- .../javalib/lang/StringBuilderTest.scala | 15 ++- 11 files changed, 262 insertions(+), 75 deletions(-) diff --git a/javalib/src/main/scala/java/lang/AbstractStringBuilder.scala b/javalib/src/main/scala/java/lang/AbstractStringBuilder.scala index 7422294984..3cb5157f7a 100644 --- a/javalib/src/main/scala/java/lang/AbstractStringBuilder.scala +++ b/javalib/src/main/scala/java/lang/AbstractStringBuilder.scala @@ -5,7 +5,10 @@ import java.io.InvalidObjectException import java.util.Arrays import scala.util.control.Breaks._ +import scala.scalanative.runtime.ieee754tostring.ryu._ + protected abstract class AbstractStringBuilder private (unit: Unit) { + import AbstractStringBuilder._ protected var value: Array[Char] = _ @@ -108,7 +111,38 @@ protected abstract class AbstractStringBuilder private (unit: Unit) { count += 1 } + // Optimization: use `RyuFloat.floatToChars()` instead of `floatToString()` + protected final def append0(f: scala.Float): Unit = { + + // We first ensure that we have enough space in the backing Array (`value`) + this.ensureCapacity(this.count + RyuFloat.RESULT_STRING_MAX_LENGTH) + + // Then we call `RyuFloat.floatToChars()`, which will append chars to `value` + this.count = RyuFloat.floatToChars( + f, + RyuRoundingMode.Conservative, + value, + this.count + ) + } + + // Optimization: use `RyuFloat.doubleToChars()` instead of `doubleToString()` + protected final def append0(d: scala.Double): Unit = { + + // We first ensure that we have enough space in the backing Array (`value`) + this.ensureCapacity(this.count + RyuDouble.RESULT_STRING_MAX_LENGTH) + + // Then we call `RyuFloat.doubleToChars()`, which will append chars to `value` + this.count = RyuDouble.doubleToChars( + d, + RyuRoundingMode.Conservative, + value, + this.count + ) + } + protected final def append0(string: String): Unit = { + if (string == null) { appendNull() return diff --git a/javalib/src/main/scala/java/lang/Double.scala b/javalib/src/main/scala/java/lang/Double.scala index a6763d7227..4bc9350402 100644 --- a/javalib/src/main/scala/java/lang/Double.scala +++ b/javalib/src/main/scala/java/lang/Double.scala @@ -318,7 +318,10 @@ object Double { } @inline def toString(d: scala.Double): String = { - RyuDouble.doubleToString(d, RyuRoundingMode.Conservative) + val result = new scala.Array[Char](RyuDouble.RESULT_STRING_MAX_LENGTH) + val strLen = + RyuDouble.doubleToChars(d, RyuRoundingMode.Conservative, result, 0) + new _String(0, strLen, result).asInstanceOf[String] } @inline def valueOf(d: scala.Double): Double = diff --git a/javalib/src/main/scala/java/lang/Float.scala b/javalib/src/main/scala/java/lang/Float.scala index 7a0326059c..13baa015ee 100644 --- a/javalib/src/main/scala/java/lang/Float.scala +++ b/javalib/src/main/scala/java/lang/Float.scala @@ -320,7 +320,10 @@ object Float { } def toString(f: scala.Float): String = { - RyuFloat.floatToString(f, RyuRoundingMode.Conservative) + val result = new scala.Array[Char](RyuFloat.RESULT_STRING_MAX_LENGTH) + val strLen = + RyuFloat.floatToChars(f, RyuRoundingMode.Conservative, result, 0) + new _String(0, strLen, result).asInstanceOf[String] } @inline def valueOf(s: String): Float = diff --git a/javalib/src/main/scala/java/lang/StringBuffer.scala b/javalib/src/main/scala/java/lang/StringBuffer.scala index 4f11491a57..b45e7e870d 100644 --- a/javalib/src/main/scala/java/lang/StringBuffer.scala +++ b/javalib/src/main/scala/java/lang/StringBuffer.scala @@ -43,11 +43,15 @@ final class StringBuffer this } - def append(d: scala.Double): StringBuffer = - append(Double.toString(d)) + def append(f: scala.Float): StringBuffer = { + append0(f) + this + } - def append(f: scala.Float): StringBuffer = - append(Float.toString(f)) + def append(d: scala.Double): StringBuffer = { + append0(d) + this + } def append(i: scala.Int): StringBuffer = append(Integer.toString(i)) diff --git a/javalib/src/main/scala/java/lang/StringBuilder.scala b/javalib/src/main/scala/java/lang/StringBuilder.scala index cc6b2696b2..8aa53bad51 100644 --- a/javalib/src/main/scala/java/lang/StringBuilder.scala +++ b/javalib/src/main/scala/java/lang/StringBuilder.scala @@ -50,12 +50,12 @@ final class StringBuilder } def append(f: scala.Float): StringBuilder = { - append0(Float.toString(f)) + append0(f) this } def append(d: scala.Double): StringBuilder = { - append0(Double.toString(d)) + append0(d) this } diff --git a/nativelib/src/main/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuDouble.scala b/nativelib/src/main/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuDouble.scala index 44a7e08950..09b09c7820 100644 --- a/nativelib/src/main/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuDouble.scala +++ b/nativelib/src/main/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuDouble.scala @@ -35,10 +35,12 @@ package scala.scalanative package runtime package ieee754tostring.ryu -import RyuRoundingMode._ - object RyuDouble { + // Scala/Java magic number 24 is derived from original RYU C code magic number 25 (which includes NUL terminator). + // See https://github.com/ulfjack/ryu/blob/6f85836b6389dce334692829d818cdedb28bfa00/ryu/d2s.c#L506 + final val RESULT_STRING_MAX_LENGTH = 24 + final val DOUBLE_MANTISSA_BITS = 52 final val DOUBLE_MANTISSA_MASK = (1L << DOUBLE_MANTISSA_BITS) - 1 @@ -694,26 +696,67 @@ object RyuDouble { // format: on - @noinline def doubleToString( + @inline + private def copyLiteralToCharArray( + literal: String, + literalLength: Int, + result: scala.Array[Char], + offset: Int + ): Int = { + literal.getChars(0, literalLength, result, offset) + offset + literalLength + } + + // See: https://github.com/scala-native/scala-native/issues/2902 + /** Low-level function executing the Ryu algorithm on `Double` value. This + * function allows destination passing style. This means that the result + * destination (`Array[Char]`) has to be passed as an argument. The goal is + * to avoid additional allocations when possible. Warnings: this function + * makes no verification of destination bounds (offset and length are assumed + * to be valid). The caller must thus ensure that `result.length - offset >= + * RESULT_STRING_MAX_LENGTH`. + * + * @param value + * the value to be converted + * @param roundingMode + * customization of Ryu rounding mode + * @param result + * the `Array[Char]` destination of the conversion result + * @param offset + * index in `Array[Char]` destination where new chars will start to be + * written + * @return + * new offset as: old offset + number of created chars (i.e. last modified + * index + 1) + */ + def doubleToChars( value: Double, - roundingMode: RyuRoundingMode - ): String = { + roundingMode: RyuRoundingMode, + result: scala.Array[Char], + offset: Int + ): Int = { + + // Handle all the trivial cases. + if (value.isNaN) + return copyLiteralToCharArray("NaN", 3, result, offset) + if (value == Double.PositiveInfinity) + return copyLiteralToCharArray("Infinity", 8, result, offset) + if (value == Double.NegativeInfinity) + return copyLiteralToCharArray("-Infinity", 9, result, offset) - // Step 1: Decode the floating point number, and unify normalized and - // subnormal cases. - // First, handle all the trivial cases. - if (value.isNaN) return "NaN" - if (value == Double.PositiveInfinity) return "Infinity" - if (value == Double.NegativeInfinity) return "-Infinity" val bits = java.lang.Double.doubleToLongBits(value) - if (bits == 0) return "0.0" - if (bits == 0x8000000000000000L) return "-0.0" + if (bits == 0) + return copyLiteralToCharArray("0.0", 3, result, offset) + if (bits == 0x8000000000000000L) + return copyLiteralToCharArray("-0.0", 4, result, offset) - // Otherwise extract the mantissa and exponent bits and run the full - // algorithm. + // Otherwise extract the mantissa and exponent bits and run the full algorithm. + // Step 1: Decode the floating point number, and unify normalized and subnormal cases. val ieeeExponent = ((bits >>> DOUBLE_MANTISSA_BITS) & DOUBLE_EXPONENT_MASK).toInt val ieeeMantissa = bits & DOUBLE_MANTISSA_MASK + + // By default, the correct mantissa starts with a 1, except for denormal numbers. var e2 = 0 var m2 = 0L if (ieeeExponent == 0) { @@ -732,7 +775,7 @@ object RyuDouble { val mv = 4 * m2 val mp = 4 * m2 + 2 val mmShift = - if (((m2 != (1L << DOUBLE_MANTISSA_BITS)) || (ieeeExponent <= 1))) 1 + if ((m2 != (1L << DOUBLE_MANTISSA_BITS)) || (ieeeExponent <= 1)) 1 else 0 val mm = 4 * m2 - 1 - mmShift e2 -= 2 @@ -786,21 +829,18 @@ object RyuDouble { } } - // Step 4: Find the shortest decimal representation in the interval of - // legal representations. + // Step 4: Find the shortest decimal representation in the interval of legal representations. // // We do some extra work here in order to follow Float/Double.toString // semantics. In particular, that requires printing in scientific format // if and only if the exponent is between -3 and 7, and it requires // printing at least two decimal digits. // - // Above, we moved the decimal dot all the way to the right, so now we - // need to count digits to - // figure out the correct exponent for scientific notation. + // Above, we moved the decimal dot all the way to the right, so now we need to count digits + // to figure out the correct exponent for scientific notation. val vplength = decimalLength(dp) var exp = e10 + vplength - 1 - // Double.toString semantics requires using scientific notation if and - // only if outside this range. + // Double.toString semantics requires using scientific notation if and only if outside this range. val scientificNotation = !((exp >= -3) && (exp < 7)) var removed = 0 var lastRemovedDigit = 0 @@ -868,8 +908,7 @@ object RyuDouble { // Step 5: Print the decimal representation. // We follow Double.toString semantics here. - val result = new scala.Array[Char](24) - var index = 0 + var index = offset if (sign) { result(index) = '-' index += 1 @@ -890,8 +929,7 @@ object RyuDouble { index += 1 } - // Print 'E', the exponent sign, and the exponent, which has at most - // three digits. + // Print 'E', the exponent sign, and the exponent, which has at most three digits. result(index) = 'E' index += 1 if (exp < 0) { @@ -911,7 +949,6 @@ object RyuDouble { } result(index) = ('0' + exp % 10).toChar index += 1 - new String(result, 0, index) } else { // Otherwise follow the Java spec for values in the interval [1E-3, 1E7). if (exp < 0) { @@ -959,8 +996,9 @@ object RyuDouble { } index += olength + 1 } - new String(result, 0, index) } + + index } private def pow5bits(e: Int): Int = ((e * 1217359) >>> 19) + 1 diff --git a/nativelib/src/main/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuFloat.scala b/nativelib/src/main/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuFloat.scala index 7cb323cbbd..9ba6fddd0d 100644 --- a/nativelib/src/main/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuFloat.scala +++ b/nativelib/src/main/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuFloat.scala @@ -35,10 +35,12 @@ package scala.scalanative package runtime package ieee754tostring.ryu -import RyuRoundingMode._ - object RyuFloat { + // Scala/Java magic number 15 is derived from original RYU C code magic number 16 (which includes NUL terminator). + // See: https://github.com/ulfjack/ryu/blob/6f85836b6389dce334692829d818cdedb28bfa00/ryu/f2s.c#L342 + final val RESULT_STRING_MAX_LENGTH = 15 + final val FLOAT_MANTISSA_BITS = 23 final val FLOAT_MANTISSA_MASK = (1 << FLOAT_MANTISSA_BITS) - 1 @@ -172,32 +174,74 @@ object RyuFloat { // format: on - @noinline def floatToString( + @inline + private def copyLiteralToCharArray( + literal: String, + literalLength: Int, + result: scala.Array[scala.Char], + offset: Int + ): Int = { + literal.getChars(0, literalLength, result, offset) + offset + literalLength + } + + // See: https://github.com/scala-native/scala-native/issues/2902 + /** Low-level function executing the Ryu algorithm on `Float`` value. This + * function allows destination passing style. This means that the result + * destination (`Array[Char]`) has to be passed as an argument. The goal is + * to avoid additional allocations when possible. Warnings: this function + * makes no verification of destination bounds (offset and length are assumed + * to be valid). The caller must thus ensure that `result.length - offset >= + * RESULT_STRING_MAX_LENGTH`. + * + * @param value + * the value to be converted + * @param roundingMode + * customization of Ryu rounding mode + * @param result + * the `Array[Char]` destination of the conversion result + * @param offset + * index in `Array[Char]` destination where new chars will start to be + * written + * @return + * new offset as: old offset + number of created chars (i.e. last modified + * index + 1) + */ + def floatToChars( value: Float, - roundingMode: RyuRoundingMode - ): String = { - - // Step 1: Decode the floating point number, and unify normalized and - // subnormal cases. - // First, handle all the trivial cases. - if (value.isNaN) return "NaN" - if (value == Float.PositiveInfinity) return "Infinity" - if (value == Float.NegativeInfinity) return "-Infinity" + roundingMode: RyuRoundingMode, + result: scala.Array[scala.Char], + offset: Int + ): Int = { + + // Handle all the trivial cases. + if (value.isNaN) + return copyLiteralToCharArray("NaN", 3, result, offset) + if (value == Float.PositiveInfinity) + return copyLiteralToCharArray("Infinity", 8, result, offset) + if (value == Float.NegativeInfinity) + return copyLiteralToCharArray("-Infinity", 9, result, offset) + val bits = java.lang.Float.floatToIntBits(value) - if (bits == 0) return "0.0" - if (bits == 0x80000000) return "-0.0" - // Otherwise extract the mantissa and exponent bits and run the full - // algorithm. + if (bits == 0) + return copyLiteralToCharArray("0.0", 3, result, offset) + if (bits == 0x80000000) + return copyLiteralToCharArray("-0.0", 4, result, offset) + + // Otherwise extract the mantissa and exponent bits and run the full algorithm. + // Step 1: Decode the floating point number, and unify normalized and subnormal cases. val ieeeExponent = (bits >> FLOAT_MANTISSA_BITS) & FLOAT_EXPONENT_MASK val ieeeMantissa = bits & FLOAT_MANTISSA_MASK - // By default, the correct mantissa starts with a 1, except for - // denormal numbers. + + // By default, the correct mantissa starts with a 1, except for denormal numbers. var e2 = 0 var m2 = 0 if (ieeeExponent == 0) { + // Denormal number - no implicit leading 1, and the exponent is 1, not 0. e2 = 1 - FLOAT_EXPONENT_BIAS - FLOAT_MANTISSA_BITS m2 = ieeeMantissa } else { + // Add implicit leading 1. e2 = ieeeExponent - FLOAT_EXPONENT_BIAS - FLOAT_MANTISSA_BITS m2 = ieeeMantissa | (1 << FLOAT_MANTISSA_BITS) } @@ -225,6 +269,7 @@ object RyuFloat { if (e2 >= 0) { // Compute m * 2^e_2 / 10^q = m * 2^(e_2 - q) / 5^q val q = (e2 * LOG10_2_NUMERATOR / LOG10_2_DENOMINATOR).toInt + // k = constant + floor(log_2(5^q)) val k = POW5_INV_BITCOUNT + pow5bits(q) - 1 val i = -e2 + q + k dv = mulPow5InvDivPow2(mv, q, i).toInt @@ -265,21 +310,18 @@ object RyuFloat { dmIsTrailingZeros = (if (mm % 2 == 1) 0 else 1) >= q } - // Step 4: Find the shortest decimal representation in the interval of - // legal representations. + // Step 4: Find the shortest decimal representation in the interval of legal representations. // // We do some extra work here in order to follow Float/Double.toString // semantics. In particular, that requires printing in scientific format // if and only if the exponent is between -3 and 7, and it requires // printing at least two decimal digits. // - // Above, we moved the decimal dot all the way to the right, so now we - // need to count digits to - // figure out the correct exponent for scientific notation. + // Above, we moved the decimal dot all the way to the right, so now we need to count digits + // to figure out the correct exponent for scientific notation. val dplength = decimalLength(dp) var exp = e10 + dplength - 1 - // Float.toString semantics requires using scientific notation if and - // only if outside this range. + // Float.toString semantics requires using scientific notation if and only if outside this range. val scientificNotation = !((exp >= -3) && (exp < 7)) var removed = 0 if (dpIsTrailingZeros && !roundingMode.acceptUpperBound(even)) { @@ -329,12 +371,13 @@ object RyuFloat { // Step 5: Print the decimal representation. // We follow Float.toString semantics here. - val result = new scala.Array[Char](15) - var index = 0 + var index = offset if (sign) { result(index) = '-' index += 1 } + + // Values in the interval [1E-3, 1E7) are special. if (scientificNotation) { for (i <- 0 until olength - 1) { val c = output % 10 @@ -348,8 +391,7 @@ object RyuFloat { result(index) = '0' index += 1 } - // Print 'E', the exponent sign, and the exponent, which has at most - // two digits. + // Print 'E', the exponent sign, and the exponent, which has at most two digits. result(index) = 'E' index += 1 if (exp < 0) { @@ -411,7 +453,8 @@ object RyuFloat { index += olength + 1 } } - new String(result, 0, index) + + index } private def pow5bits(e: Int): Int = diff --git a/unit-tests/native/src/test/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuDoubleTest.scala b/unit-tests/native/src/test/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuDoubleTest.scala index 5611cae978..78a4656b11 100644 --- a/unit-tests/native/src/test/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuDoubleTest.scala +++ b/unit-tests/native/src/test/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuDoubleTest.scala @@ -56,9 +56,29 @@ import org.junit.Assert._ class RyuDoubleTest { - private def assertD2sEquals(expected: String, f: scala.Double): Unit = { - val result = f.toString - assertTrue(s"result: $result != expected: $expected", expected == result) + private def assertD2sEquals(expected: String, d: scala.Double): Unit = { + val result = d.toString + assertTrue( + s"result from Double.toString: $result != expected: $expected", + expected == result + ) + + val result2 = doubleToString(d) + assertTrue( + s"result from RyuDouble.doubleToChars: $result2 != expected: $expected", + expected == result2 + ) + } + + def doubleToString( + value: Double + ): String = { + + val result = new scala.Array[Char](RyuDouble.RESULT_STRING_MAX_LENGTH) + val strLen = + RyuDouble.doubleToChars(value, RyuRoundingMode.Conservative, result, 0) + + new String(result, 0, strLen) } @Test def simpleCases(): Unit = { diff --git a/unit-tests/native/src/test/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuFloatTest.scala b/unit-tests/native/src/test/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuFloatTest.scala index e1e6cac00c..d5a9d4e5f9 100644 --- a/unit-tests/native/src/test/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuFloatTest.scala +++ b/unit-tests/native/src/test/scala/scala/scalanative/runtime/ieee754tostring/ryu/RyuFloatTest.scala @@ -58,7 +58,27 @@ class RyuFloatTest { private def assertF2sEquals(expected: String, f: scala.Float): Unit = { val result = f.toString - assertTrue(s"result: $result != expected: $expected", expected == result) + assertTrue( + s"result from Float.toString: $result != expected: $expected", + expected == result + ) + + val result2 = floatToString(f) + assertTrue( + s"result from RyuFloat.floatToChars: $result2 != expected: $expected", + expected == result2 + ) + } + + private def floatToString( + value: Float + ): String = { + + val result = new scala.Array[Char](RyuFloat.RESULT_STRING_MAX_LENGTH) + val strLen = + RyuFloat.floatToChars(value, RyuRoundingMode.Conservative, result, 0) + + new String(result, 0, strLen) } @Test def simpleCases(): Unit = { diff --git a/unit-tests/shared/src/test/scala/javalib/lang/StringBufferTest.scala b/unit-tests/shared/src/test/scala/javalib/lang/StringBufferTest.scala index 572dabdc5a..da51783462 100644 --- a/unit-tests/shared/src/test/scala/javalib/lang/StringBufferTest.scala +++ b/unit-tests/shared/src/test/scala/javalib/lang/StringBufferTest.scala @@ -31,9 +31,20 @@ class StringBufferTest { assertEquals("100000", newBuf.append(100000).toString) } - @Test def appendFloat(): Unit = { + @Test def appendFloats(): Unit = { assertEquals("2.5", newBuf.append(2.5f).toString) + assertEquals( + "2.5 3.5", + newBuf.append(2.5f).append(' ').append(3.5f).toString + ) + } + + @Test def appendDoubles(): Unit = { assertEquals("3.5", newBuf.append(3.5).toString) + assertEquals( + "2.5 3.5", + newBuf.append(2.5).append(' ').append(3.5).toString + ) } @Test def insert(): Unit = { @@ -71,7 +82,7 @@ class StringBufferTest { ) } - @Test def insertFloat(): Unit = { + @Test def insertFloatOrDouble(): Unit = { assertEquals("2.5", newBuf.insert(0, 2.5f).toString) assertEquals("3.5", newBuf.insert(0, 3.5).toString) } diff --git a/unit-tests/shared/src/test/scala/javalib/lang/StringBuilderTest.scala b/unit-tests/shared/src/test/scala/javalib/lang/StringBuilderTest.scala index 8b5e5d25a3..f3d6aab8a0 100644 --- a/unit-tests/shared/src/test/scala/javalib/lang/StringBuilderTest.scala +++ b/unit-tests/shared/src/test/scala/javalib/lang/StringBuilderTest.scala @@ -62,9 +62,20 @@ class StringBuilderTest { assertEquals("100000", newBuilder.append(100000).toString) } - @Test def appendFloat(): Unit = { + @Test def appendFloats(): Unit = { assertEquals("2.5", newBuilder.append(2.5f).toString) + assertEquals( + "2.5 3.5", + newBuilder.append(2.5f).append(' ').append(3.5f).toString + ) + } + + @Test def appendDoubles(): Unit = { assertEquals("3.5", newBuilder.append(3.5).toString) + assertEquals( + "2.5 3.5", + newBuilder.append(2.5).append(' ').append(3.5).toString + ) } @Test def appendShouldNotChangePriorString(): Unit = { @@ -122,7 +133,7 @@ class StringBuilderTest { ) } - @Test def insertFloat(): Unit = { + @Test def insertFloatOrDouble(): Unit = { assertEquals("2.5", newBuilder.insert(0, 2.5f).toString) assertEquals("3.5", newBuilder.insert(0, 3.5).toString) }