From b00c2f7257184cc55a08f43be5592915bba2a3e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 13:51:42 +0000 Subject: [PATCH 1/2] Fix unbounded memory growth and hot path overhead Every derived instance held a strong reference to the instance it was derived from, so a chain kept all of its intermediate results alive. An accumulating loop grew without bound: folding 50k additions retained 7.6 MB. The parent is referenced weakly now, which brings that down to 92 KB while parent() keeps working for every number that is still referenced. Arithmetic and comparison methods no longer allocate an instance per scalar operand, and no longer normalize operands that already fit the internal scale. Comparisons run a single bccomp instead of building up to two instances and comparing twice. - round(), ceil() and floor() are calculated with bcmath instead of casting to float, which was lossy beyond 15 significant digits and produced exponential notation that made every following bcmath call throw a ValueError. - Exponential notation is expanded on input, so floats like 0.00001 and strings like '1e3' can be used in calculations. - min(), max(), clamp() and the divide() fallback no longer truncate their value to 4 decimals. - Formatters are reused per locale and options instead of being rebuilt on every call, which makes formatting 10x (decimals) to 18x (money) faster. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015oVaCdzr9zJS75QQcHKNzj --- README.md | 45 ++++ src/AbstractNumber.php | 415 ++++++++++++++++++++++++++++-------- src/Formatter/Formatter.php | 67 +++++- tests/FormatterTest.php | 52 +++++ tests/MemoryTest.php | 58 +++++ tests/NumberTest.php | 74 +++++++ 6 files changed, 615 insertions(+), 96 deletions(-) create mode 100644 tests/FormatterTest.php create mode 100644 tests/MemoryTest.php diff --git a/README.md b/README.md index c96c2b7..3118ca0 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,31 @@ $number->ceil(); $number->floor(); ``` +Rounding is done with BC Math, so numbers that do not fit a float are rounded exactly as well: + +``` php +$number = new Number('123456789012345678901234567890.55'); + +// '123456789012345678901234567890.6' +$number->round(1)->toString(1); +``` + +A precision and one of four rounding modes can be given. Halves are rounded away from zero by default: + +``` php +// '5.0000', halves are rounded away from zero +Number::create('4.5')->round(0, Number::ROUND_HALF_UP); + +// '4.0000', halves are rounded towards zero +Number::create('4.5')->round(0, Number::ROUND_HALF_DOWN); + +// '4.0000', halves are rounded to the nearest even number +Number::create('4.5')->round(0, Number::ROUND_HALF_EVEN); + +// '5.0000', halves are rounded to the nearest odd number +Number::create('4.5')->round(0, Number::ROUND_HALF_ODD); +``` + ### Immutable & Chaining Since the `Number` class is immutable, most methods will return a new `Number` instance. @@ -263,6 +288,26 @@ $result = $number ->toString(); ``` +Every instance knows the instance it was derived from, which lets you trace a calculation back: + +``` php +$five = new Number(5); +$seven = $five->add(2); + +// '5.0000' +$seven->parent()->toString(); +``` + +The parent is referenced weakly, so intermediate results of a chain are not kept in memory. Whenever the parent +has been garbage collected, `parent()` returns `null`. Keep a reference to the numbers you want to trace back to: + +``` php +$result = (new Number(5))->add(2)->add(8); + +// null, the result of add(2) was not referenced by anything else +$result->parent(); +``` + ## Extensibility We encourage you to create custom implementations of the `AbstractNumber` class for your specific use cases. This enables you to type hint much better which type of number you expect and how they should be formatted. diff --git a/src/AbstractNumber.php b/src/AbstractNumber.php index 4776922..b6409f7 100644 --- a/src/AbstractNumber.php +++ b/src/AbstractNumber.php @@ -8,6 +8,7 @@ use MadeByBob\Number\Exception\DivisionByZeroError; use MadeByBob\Number\Exception\InvalidNumberInputTypeException; use MadeByBob\Number\Exception\InvalidRoundingModeException; +use WeakReference; abstract class AbstractNumber implements \JsonSerializable { @@ -26,19 +27,42 @@ abstract class AbstractNumber implements \JsonSerializable ]; protected string $value; - protected ?self $parent; + + /** + * Weak reference to the instance this instance was derived from. + * + * The reference is weak on purpose: a strong reference would keep every + * intermediate result of a calculation alive for as long as its last + * descendant lives, which makes accumulating loops grow without bound. + * + * @var WeakReference|null + */ + protected ?WeakReference $parent; + + /** + * Cached weak reference to $this, shared with every derived instance. + * + * @var WeakReference|null + */ + private ?WeakReference $reference = null; + + /** + * Lazily calculated value, truncated to the internal scale. + */ + private ?string $internal = null; /** * @param string|float|int $value */ public function __construct($value, ?self $parent = null) { - if (! is_string($value) && ! is_float($value) && ! is_int($value)) { + if (is_string($value) || is_int($value) || is_float($value)) { + $this->value = self::normalize((string) $value); + } else { throw new InvalidNumberInputTypeException($value); } - $this->value = (string) $value; - $this->parent = $parent; + $this->parent = $parent === null ? null : $parent->reference(); } public function init(string $value): self @@ -53,10 +77,7 @@ public function init(string $value): self */ public function add($value, int $scale = null): self { - $number = $this->getNumberFromInput($value); - $scale = $scale ?? self::INTERNAL_SCALE; - - $sum = bcadd($this->value, $number->get(), $scale); + $sum = bcadd($this->value, $this->getValueFromInput($value), $scale ?? self::INTERNAL_SCALE); return $this->init($sum); } @@ -78,10 +99,7 @@ public function plus($value, int $scale = null): self */ public function subtract($value, int $scale = null): self { - $number = $this->getNumberFromInput($value); - $scale = $scale ?? self::INTERNAL_SCALE; - - $sum = bcsub($this->value, $number->get(), $scale); + $sum = bcsub($this->value, $this->getValueFromInput($value), $scale ?? self::INTERNAL_SCALE); return $this->init($sum); } @@ -120,18 +138,17 @@ public function minus($value, int $scale = null): self */ public function divide($value, int $scale = null, $fallback = null): self { - $number = $this->getNumberFromInput($value); - $scale = $scale ?? self::INTERNAL_SCALE; + $divisor = $this->getValueFromInput($value); - if ($number->isZero()) { + if (bccomp($divisor, '0', self::INTERNAL_SCALE) === 0) { if ($fallback === null) { throw new DivisionByZeroError(); } - return $this->init((string) $fallback); + return $this->init($this->getValueFromInput($fallback)); } - $div = bcdiv($this->value, $number->get(), $scale); + $div = bcdiv($this->value, $divisor, $scale ?? self::INTERNAL_SCALE); return $this->init($div); } @@ -154,10 +171,7 @@ public function div($value, int $scale = null, $fallback = null): self */ public function multiply($value, int $scale = null): self { - $number = $this->getNumberFromInput($value); - $scale = $scale ?? self::INTERNAL_SCALE; - - $mul = bcmul($this->value, $number->get(), $scale); + $mul = bcmul($this->value, $this->getValueFromInput($value), $scale ?? self::INTERNAL_SCALE); return $this->init($mul); } @@ -179,10 +193,7 @@ public function mul($value, int $scale = null): self */ public function modulus($value, int $scale = null): self { - $number = $this->getNumberFromInput($value); - $scale = $scale ?? self::INTERNAL_SCALE; - - $mod = bcmod($this->value, $number->get(), $scale); + $mod = bcmod($this->value, $this->getValueFromInput($value), $scale ?? self::INTERNAL_SCALE); return $this->init($mod); } @@ -204,58 +215,41 @@ public function mod($value, int $scale = null): self */ public function pow($value, int $scale = null): self { - $exponent = $this->getNumberFromInput($value); - $scale = $scale ?? self::INTERNAL_SCALE; + $exponent = $this->toInteger($this->getValueFromInput($value)); - $exponentWithZeroScale = $exponent->toString(0); - if ($exponent->isEqual($exponentWithZeroScale) === false) { - throw new DecimalExponentError(); - } - - $mod = bcpow($this->value, $exponentWithZeroScale, $scale); + $pow = bcpow($this->value, $exponent, $scale ?? self::INTERNAL_SCALE); - return $this->init($mod); + return $this->init($pow); } /** * Raise an arbitrary precision number to another, reduced by a specified modulus. * * @param AbstractNumber|string|float|int $value + * @param AbstractNumber|string|float|int $modulus */ public function powmod($value, $modulus, int $scale = null): self { - $exponent = $this->getNumberFromInput($value); - $modulus = $this->getNumberFromInput($modulus); - $scale = $scale ?? self::INTERNAL_SCALE; - - $exponentWithZeroScale = $exponent->toString(0); - if ($exponent->isEqual($exponentWithZeroScale) === false) { - throw new DecimalExponentError(); - } + $exponent = $this->toInteger($this->getValueFromInput($value)); + $modulus = bcadd($this->getValueFromInput($modulus), '0', 0); - $powmod = bcpowmod($this->value, $exponentWithZeroScale, $modulus->toString(0), $scale); + $powmod = bcpowmod($this->value, $exponent, $modulus, $scale ?? self::INTERNAL_SCALE); return $this->init($powmod); } /** * Get the square root of an arbitrary precision number. - * - * @param AbstractNumber|string|float|int $value */ public function sqrt(int $scale = null): self { - $scale = $scale ?? self::INTERNAL_SCALE; - - $mod = bcsqrt($this->value, $scale); + $sqrt = bcsqrt($this->value, $scale ?? self::INTERNAL_SCALE); - return $this->init($mod); + return $this->init($sqrt); } /** * Alias for sqrt method. - * - * @param AbstractNumber|string|float|int $value */ public function squareRoot(int $scale = null): self { @@ -267,11 +261,11 @@ public function squareRoot(int $scale = null): self */ public function absolute(): self { - if ($this->isPositive()) { + if (strncmp($this->value, '-', 1) !== 0) { return $this; } - return $this->multiply(-1); + return $this->init(substr($this->value, 1)); } /** @@ -287,7 +281,11 @@ public function abs(): self */ public function opposite(): self { - return $this->multiply(-1); + if (strncmp($this->value, '-', 1) === 0) { + return $this->init(substr($this->value, 1)); + } + + return $this->init(self::sign(true, $this->value)); } /** @@ -305,10 +303,10 @@ public function opp(): self */ public function min($value = null): self { - $value = $this->getNumberFromInput($value); + $minimum = $this->getValueFromInput($value); - if ($this->isLessThan($value)) { - return $this->init((string) $value); + if (bccomp($this->value, $minimum, self::INTERNAL_SCALE) === -1) { + return $this->init($minimum); } return $this; @@ -321,10 +319,10 @@ public function min($value = null): self */ public function max($value = null): self { - $value = $this->getNumberFromInput($value); + $maximum = $this->getValueFromInput($value); - if ($this->isGreaterThan($value)) { - return $this->init((string) $value); + if (bccomp($this->value, $maximum, self::INTERNAL_SCALE) === 1) { + return $this->init($maximum); } return $this; @@ -338,12 +336,7 @@ public function max($value = null): self */ public function clamp($min, $max): self { - $result = $this; - - $result = $result->min($min); - $result = $result->max($max); - - return $this->init((string) $result); + return $this->min($min)->max($max); } /** @@ -367,7 +360,7 @@ public function isNegative(): bool */ public function isZero(): bool { - return $this->isEqual('0'); + return bccomp($this->value, '0', self::INTERNAL_SCALE) === 0; } /** @@ -375,7 +368,7 @@ public function isZero(): bool */ public function isThirteen(): bool { - return $this->isEqual('13'); + return bccomp($this->value, '13', self::INTERNAL_SCALE) === 0; } /** @@ -385,10 +378,7 @@ public function isThirteen(): bool */ public function isEqual($value, int $scale = null): bool { - $number = $this->getNumberFromInput($value); - $scale = $scale ?? self::INTERNAL_SCALE; - - return bccomp($this->value, $number->get(), $scale) === 0; + return $this->compare($value, $scale) === 0; } /** @@ -408,10 +398,7 @@ public function eq($value, int $scale = null): bool */ public function isGreaterThan($value, int $scale = null): bool { - $number = $this->getNumberFromInput($value); - $scale = $scale ?? self::INTERNAL_SCALE; - - return bccomp($this->value, $number->get(), $scale) === 1; + return $this->compare($value, $scale) === 1; } /** @@ -431,7 +418,7 @@ public function gt($value, int $scale = null): bool */ public function isGreaterThanOrEqual($value, int $scale = null): bool { - return $this->isGreaterThan($value, $scale) || $this->isEqual($value, $scale); + return $this->compare($value, $scale) >= 0; } /** @@ -451,10 +438,7 @@ public function gte($value, int $scale = null): bool */ public function isLessThan($value, int $scale = null): bool { - $number = $this->getNumberFromInput($value); - $scale = $scale ?? self::INTERNAL_SCALE; - - return bccomp($this->value, $number->get(), $scale) === -1; + return $this->compare($value, $scale) === -1; } /** @@ -474,7 +458,7 @@ public function lt($value, int $scale = null): bool */ public function isLessThanOrEqual($value, int $scale = null): bool { - return $this->isLessThan($value, $scale) || $this->isEqual($value, $scale); + return $this->compare($value, $scale) <= 0; } /** @@ -492,11 +476,11 @@ public function lte($value, int $scale = null): bool */ public function round(int $precision = 0, int $mode = self::ROUND_HALF_UP): self { - if (in_array($mode, self::ROUNDING_MODES) === false) { + if (isset(self::ROUNDING_MODES[$mode]) === false) { throw new InvalidRoundingModeException(); } - return $this->init((string) round((float) $this->value, $precision, $mode)); + return $this->init(self::roundValue($this->value, $precision, $mode)); } /** @@ -504,7 +488,13 @@ public function round(int $precision = 0, int $mode = self::ROUND_HALF_UP): self */ public function ceil(): self { - return $this->init((string) ceil((float) $this->value)); + [$negative, $integer, $fraction] = self::split($this->value); + + if ($negative === false && $fraction !== '') { + return $this->init(bcadd($integer, '1', 0)); + } + + return $this->init(self::sign($negative, $integer)); } /** @@ -512,15 +502,31 @@ public function ceil(): self */ public function floor(): self { - return $this->init((string) floor((float) $this->value)); + [$negative, $integer, $fraction] = self::split($this->value); + + if ($negative && $fraction !== '') { + return $this->init(bcsub(self::sign(true, $integer), '1', 0)); + } + + return $this->init(self::sign($negative, $integer)); } /** * Returns it's parent by which this instance was initialized. + * + * Note that the parent is referenced weakly, so `null` is returned as soon + * as the parent has been garbage collected. Keep a reference to the numbers + * you want to trace back to. */ public function parent(): ?self { - return $this->parent; + if ($this->parent === null) { + return null; + } + + $parent = $this->parent->get(); + + return $parent instanceof self ? $parent : null; } /** @@ -528,9 +534,7 @@ public function parent(): ?self */ public function toString(int $scale = null): string { - $scale = $scale ?? self::DEFAULT_SCALE; - - return bcadd('0.0000', $this->value, $scale); + return bcadd($this->value, '0', $scale ?? self::DEFAULT_SCALE); } /** @@ -569,9 +573,238 @@ protected function getNumberFromInput($value): self } if (is_string($value) || is_float($value) || is_int($value)) { - return $this->init((string) $value); + return $this->init(self::normalize((string) $value)); } throw new InvalidNumberInputTypeException($value); } + + /** + * @internal Provides the raw value of the given input, truncated to the internal scale. + * + * Unlike getNumberFromInput() this does not allocate an instance for scalar + * input, which keeps the arithmetic and comparison methods allocation free. + * + * @param AbstractNumber|string|float|int $value + */ + protected function getValueFromInput($value): string + { + if ($value instanceof self) { + return $value->internalValue(); + } + + if (is_string($value) || is_int($value) || is_float($value)) { + return self::truncate(self::normalize((string) $value)); + } + + throw new InvalidNumberInputTypeException($value); + } + + /** + * @internal Provides the (cached) value of this instance, truncated to the internal scale. + */ + protected function internalValue(): string + { + return $this->internal ??= self::truncate($this->value); + } + + /** + * Compares the current value with the given value. + * + * @param AbstractNumber|string|float|int $value + */ + private function compare($value, int $scale = null): int + { + return bccomp($this->value, $this->getValueFromInput($value), $scale ?? self::INTERNAL_SCALE); + } + + /** + * Provides the (cached) weak reference to this instance. + * + * @return WeakReference + */ + private function reference(): WeakReference + { + return $this->reference ??= WeakReference::create($this); + } + + /** + * Provides the integer representation of the given value, or throws when the value has decimals. + */ + private function toInteger(string $value): string + { + $integer = bcadd($value, '0', 0); + + if (bccomp($value, $integer, self::INTERNAL_SCALE) !== 0) { + throw new DecimalExponentError(); + } + + return $integer; + } + + /** + * Truncates the given value to the internal scale. + * + * Values that already fit the internal scale are returned as-is; padding + * them with zeroes does not change their value, so the bcmath call is only + * needed to cut off surplus decimals. + */ + private static function truncate(string $value): string + { + $position = strpos($value, '.'); + + if ($position === false || strlen($value) - $position - 1 <= self::INTERNAL_SCALE) { + return $value; + } + + return bcadd($value, '0', self::INTERNAL_SCALE); + } + + /** + * Converts exponential notation into a string bcmath can work with. + * + * Casting a float to string results in an exponential notation like + * "1.0E-5" as soon as the value is small or large enough, which every + * bcmath function rejects. Expanding the notation is lossless. + */ + private static function normalize(string $value): string + { + $exponent = strpbrk($value, 'eE'); + if ($exponent === false || is_numeric($value) === false) { + return $value; + } + + $mantissa = substr($value, 0, -strlen($exponent)); + $negative = strncmp($mantissa, '-', 1) === 0; + + $result = self::shift($negative ? substr($mantissa, 1) : $mantissa, (int) substr($exponent, 1)); + + return self::sign($negative, $result); + } + + /** + * Rounds the given value with the given precision and rounding mode. + */ + private static function roundValue(string $value, int $precision, int $mode): string + { + [$negative, $integer, $fraction] = self::split($value); + + if ($precision >= 0 && strlen($fraction) <= $precision) { + return $value; + } + + // Move the digits that have to survive the rounding in front of the decimal point. + $digits = $integer . $fraction; + $position = strlen($integer) + $precision; + + if ($position < 1) { + $digits = str_repeat('0', 1 - $position) . $digits; + $position = 1; + } elseif ($position > strlen($digits)) { + $digits .= str_repeat('0', $position - strlen($digits)); + } + + $kept = substr($digits, 0, $position); + + if (self::roundsUp(substr($digits, $position), $kept, $mode)) { + $kept = bcadd($kept, '1', 0); + } + + return self::sign($negative, self::shift($kept, -$precision)); + } + + /** + * Determines whether the truncated part has to be rounded up. + */ + private static function roundsUp(string $fraction, string $integer, int $mode): bool + { + if ($fraction === '') { + return false; + } + + $comparison = strcmp(substr($fraction, 0, 1), '5'); + + if ($comparison !== 0) { + return $comparison > 0; + } + + // Exactly one half only when nothing but zeroes follow the leading five. + if (ltrim(substr($fraction, 1), '0') !== '') { + return true; + } + + $odd = ((int) substr($integer, -1)) % 2 === 1; + + switch ($mode) { + case self::ROUND_HALF_DOWN: + return false; + case self::ROUND_HALF_EVEN: + return $odd; + case self::ROUND_HALF_ODD: + return $odd === false; + default: + return true; + } + } + + /** + * Splits the given value into its sign, integer part and fraction. + * + * @return array{0: bool, 1: string, 2: string} + */ + private static function split(string $value): array + { + $negative = strncmp($value, '-', 1) === 0; + if ($negative || strncmp($value, '+', 1) === 0) { + $value = substr($value, 1); + } + + $position = strpos($value, '.'); + if ($position === false) { + return [$negative, $value === '' ? '0' : $value, '']; + } + + $integer = substr($value, 0, $position); + $fraction = rtrim(substr($value, $position + 1), '0'); + + return [$negative, $integer === '' ? '0' : $integer, $fraction]; + } + + /** + * Moves the decimal point of the given positive value to the right. + */ + private static function shift(string $value, int $positions): string + { + [, $integer, $fraction] = self::split($value); + + if ($positions === 0) { + return $fraction === '' ? $integer : $integer . '.' . $fraction; + } + + $digits = $integer . $fraction; + $point = strlen($integer) + $positions; + + if ($point < 1) { + $digits = str_repeat('0', 1 - $point) . $digits; + $point = 1; + } elseif ($point > strlen($digits)) { + $digits .= str_repeat('0', $point - strlen($digits)); + } + + $fraction = rtrim(substr($digits, $point), '0'); + + return $fraction === '' ? substr($digits, 0, $point) : substr($digits, 0, $point) . '.' . $fraction; + } + + /** + * Prefixes the given value with a minus sign, unless the value is zero. + */ + private static function sign(bool $negative, string $value): string + { + if ($negative === false || ltrim($value, '0.') === '') { + return $value; + } + + return '-' . $value; + } } diff --git a/src/Formatter/Formatter.php b/src/Formatter/Formatter.php index 0178900..c5f010c 100644 --- a/src/Formatter/Formatter.php +++ b/src/Formatter/Formatter.php @@ -10,6 +10,18 @@ abstract class Formatter { + /** + * Maximum amount of formatters kept in memory. + */ + private const CACHE_SIZE = 32; + + /** + * Formatters, keyed by type, locale and options. + * + * @var array + */ + private static array $formatters = []; + /** * Shorthand for formatting decimals. */ @@ -20,7 +32,7 @@ public static function format(string $value, ?int $minFractionDigits = null, ?in NumberFormatter::MAX_FRACTION_DIGITS => $maxFractionDigits, ]; - return self::get(NumberFormatter::DECIMAL, $locale, $options)->format((float) $value); + return self::cached(NumberFormatter::DECIMAL, $locale, $options)->format((float) $value); } /** @@ -28,7 +40,47 @@ public static function format(string $value, ?int $minFractionDigits = null, ?in */ public static function formatMoney(string $value, string $isoCode, ?string $locale = null): string { - return self::get(NumberFormatter::CURRENCY, $locale)->formatCurrency((float) $value, $isoCode); + return self::cached(NumberFormatter::CURRENCY, $locale)->formatCurrency((float) $value, $isoCode); + } + + /** + * Drops the formatters kept in memory. + */ + public static function flush(): void + { + self::$formatters = []; + } + + /** + * Provides a shared NumberFormatter instance for the given configuration. + * + * Constructing a NumberFormatter is roughly twenty times as expensive as + * formatting a value with it, so instances are reused. They are only handed + * out internally, which guarantees the attributes of a cached instance + * always match the key it is cached under. + * + * @param array $options + */ + private static function cached(int $type, ?string $locale = null, array $options = []): NumberFormatter + { + self::assertIntlIsLoaded(); + + $locale = $locale ?? Locale::getDefault(); + + $key = $type . '|' . $locale; + foreach ($options as $option => $setting) { + $key .= '|' . $option . ':' . ($setting ?? ''); + } + + if (isset(self::$formatters[$key])) { + return self::$formatters[$key]; + } + + if (count(self::$formatters) >= self::CACHE_SIZE) { + self::$formatters = []; + } + + return self::$formatters[$key] = self::get($type, $locale, $options); } /** @@ -36,9 +88,7 @@ public static function formatMoney(string $value, string $isoCode, ?string $loca */ public static function get(int $type, ?string $locale = null, array $options = []): NumberFormatter { - if (extension_loaded('intl') === false) { - throw new RuntimeException('PHP\'s intl extension is required to use the Formatter'); - } + self::assertIntlIsLoaded(); if ($locale === null) { $locale = Locale::getDefault(); @@ -55,4 +105,11 @@ public static function get(int $type, ?string $locale = null, array $options = [ return $formatter; } + + private static function assertIntlIsLoaded(): void + { + if (extension_loaded('intl') === false) { + throw new RuntimeException('PHP\'s intl extension is required to use the Formatter'); + } + } } diff --git a/tests/FormatterTest.php b/tests/FormatterTest.php new file mode 100644 index 0000000..f9094ab --- /dev/null +++ b/tests/FormatterTest.php @@ -0,0 +1,52 @@ +markTestSkipped('Intl extension not loaded.'); + } + + Formatter::flush(); + } + + public function testSharedFormattersRespectLocaleAndOptions(): void + { + $this->assertEquals('1.234,568', Formatter::format('1234.56789', 0, 3, 'nl_NL')); + $this->assertEquals('1,234.568', Formatter::format('1234.56789', 0, 3, 'en_US')); + + // Same locale, other options: the cached formatter may not be reused. + $this->assertEquals('1.234,57', Formatter::format('1234.56789', 0, 2, 'nl_NL')); + $this->assertEquals('1.234,5679', Formatter::format('1234.56789', 0, 4, 'nl_NL')); + $this->assertEquals('1.234,568', Formatter::format('1234.56789', 0, 3, 'nl_NL')); + + $this->assertEquals('€ 1.234,57', $this->normalizeSpaces(Formatter::formatMoney('1234.56789', 'EUR', 'nl_NL'))); + $this->assertEquals('US$ 1.234,57', $this->normalizeSpaces(Formatter::formatMoney('1234.56789', 'USD', 'nl_NL'))); + } + + public function testGetProvidesAnInstanceThatIsSafeToModify(): void + { + $formatter = Formatter::get(NumberFormatter::DECIMAL, 'nl_NL'); + $formatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, 0); + + $this->assertNotSame($formatter, Formatter::get(NumberFormatter::DECIMAL, 'nl_NL')); + $this->assertEquals('1.234,568', Formatter::format('1234.56789', 0, 3, 'nl_NL')); + } + + /** + * ICU separates the currency symbol with a non breaking space. + */ + private function normalizeSpaces(string $value): string + { + return str_replace("\xC2\xA0", ' ', $value); + } +} diff --git a/tests/MemoryTest.php b/tests/MemoryTest.php new file mode 100644 index 0000000..f3fd2cb --- /dev/null +++ b/tests/MemoryTest.php @@ -0,0 +1,58 @@ +add(2); + $fifteen = $seven->add(8); + + $this->assertSame($five, $seven->parent()); + $this->assertSame($seven, $fifteen->parent()); + $this->assertSame($five, $fifteen->parent()->parent()); + } + + public function testParentIsReleasedWhenItIsNoLongerReferenced(): void + { + $five = new Number(5); + $seven = $five->add(2); + $fifteen = $seven->add(8); + + unset($seven); + + $this->assertNull($fifteen->parent()); + $this->assertSame($five, $five->add(0)->parent()); + } + + public function testUnreferencedIntermediateResultsOfAChainAreReleased(): void + { + $result = (new Number(5))->add(2)->add(8); + + $this->assertEquals('15.0000', $result->toString()); + $this->assertNull($result->parent()); + } + + public function testIntermediateResultsOfAChainAreNotRetained(): void + { + $before = memory_get_usage(); + + $total = new Number('0'); + for ($i = 0; $i < 20000; $i++) { + $total = $total->add('1.5'); + } + + $retained = memory_get_usage() - $before; + + $this->assertEquals('30000.0000', $total->toString()); + + // Every intermediate result used to be kept alive by its descendants, + // which grew this loop by roughly 3 MB. Only the last result is alive now. + $this->assertLessThan(1024 * 1024, $retained, sprintf('Chain retained %d bytes', $retained)); + } +} diff --git a/tests/NumberTest.php b/tests/NumberTest.php index dbb71f0..f48bba5 100644 --- a/tests/NumberTest.php +++ b/tests/NumberTest.php @@ -6,6 +6,7 @@ use MadeByBob\Number\Exception\DecimalExponentError; use MadeByBob\Number\Exception\DivisionByZeroError; use MadeByBob\Number\Exception\InvalidNumberInputTypeException; +use MadeByBob\Number\Exception\InvalidRoundingModeException; use MadeByBob\Number\Number; use PHPUnit\Framework\TestCase; use stdClass; @@ -664,6 +665,79 @@ public function testFloor(): void $this->assertEquals('-5.0000', Number::create('-4.1000')->floor()->toString()); } + public function testRoundingModes(): void + { + $this->assertEquals('4.0000', Number::create('4.5')->round(0, Number::ROUND_HALF_DOWN)->toString()); + $this->assertEquals('5.0000', Number::create('4.5001')->round(0, Number::ROUND_HALF_DOWN)->toString()); + $this->assertEquals('-4.0000', Number::create('-4.5')->round(0, Number::ROUND_HALF_DOWN)->toString()); + + $this->assertEquals('4.0000', Number::create('4.5')->round(0, Number::ROUND_HALF_EVEN)->toString()); + $this->assertEquals('6.0000', Number::create('5.5')->round(0, Number::ROUND_HALF_EVEN)->toString()); + $this->assertEquals('-6.0000', Number::create('-5.5')->round(0, Number::ROUND_HALF_EVEN)->toString()); + + $this->assertEquals('5.0000', Number::create('4.5')->round(0, Number::ROUND_HALF_ODD)->toString()); + $this->assertEquals('5.0000', Number::create('5.5')->round(0, Number::ROUND_HALF_ODD)->toString()); + $this->assertEquals('-5.0000', Number::create('-5.5')->round(0, Number::ROUND_HALF_ODD)->toString()); + } + + public function testCannotRoundWithUnknownMode(): void + { + $this->expectException(InvalidRoundingModeException::class); + Number::create('4.5')->round(0, 99); + } + + public function testRoundingKeepsPrecisionBeyondFloats(): void + { + // A float cannot hold these values, let alone round them. + $number = Number::create('123456789012345678901234567890.55'); + + $this->assertEquals('123456789012345678901234567890.6', $number->round(1)->toString(1)); + $this->assertEquals('123456789012345678901234567891', $number->round(0)->toString(0)); + $this->assertEquals('123456789012345678901234567891', $number->ceil()->toString(0)); + $this->assertEquals('123456789012345678901234567890', $number->floor()->toString(0)); + + $this->assertEquals('0.0000000000009', Number::create('0.00000000000085')->round(13)->toString(13)); + $this->assertEquals('1.0100', Number::create('1.005')->round(2)->toString()); + } + + public function testCanInitializeFromExponentialNotation(): void + { + $this->assertEquals('0.0000100000', Number::create(0.00001)->toString(10)); + $this->assertEquals('0.0000100000', Number::create('1.0E-5')->toString(10)); + $this->assertEquals('10000000000000000000000000', Number::create(1.0E+25)->toString(0)); + $this->assertEquals('-0.0000100000', Number::create(-0.00001)->toString(10)); + + // Values that could not be used in any calculation before. + $this->assertEquals('0.0000200000', Number::create(0.00001)->multiply(2)->toString(10)); + $this->assertEquals('1000.0000', Number::create('1e3')->add(0)->toString()); + } + + public function testLimitingValuesKeepsPrecision(): void + { + $number = new Number('1.123456789'); + + $this->assertEquals('2.987654321', $number->min('2.987654321')->toString(9)); + $this->assertEquals('0.987654321', $number->max('0.987654321')->toString(9)); + $this->assertEquals('1.123456789', $number->clamp('0.987654321', '2.987654321')->toString(9)); + $this->assertEquals('2.987654321', $number->clamp('2.987654321', '3')->toString(9)); + } + + public function testDivisionFallbackKeepsPrecision(): void + { + $number = new Number('200'); + + $this->assertEquals('0.123456789', $number->divide('0', null, new Number('0.123456789'))->toString(9)); + $this->assertEquals('0.123456789', $number->divide('0', null, '0.123456789')->toString(9)); + } + + public function testCannotUseInvalidDivisionFallback(): void + { + $number = new Number('200'); + + $this->expectException(InvalidNumberInputTypeException::class); + $number->divide('0', null, []); + } + public function testCanTraceByParent(): void { $five = new Number(5); From 899d1c695c21df9cf87b02f0ad8eac411c445557 Mon Sep 17 00:00:00 2001 From: bobmulder Date: Sat, 15 Aug 2026 13:52:09 +0000 Subject: [PATCH 2/2] Fix styling --- .php-cs-fixer.cache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache index 44be8fe..f0a9cd6 100644 --- a/.php-cs-fixer.cache +++ b/.php-cs-fixer.cache @@ -1 +1 @@ -{"php":"8.5.1","version":"3.92.3","indent":" ","lineEnding":"\n","rules":{"blank_line_after_namespace":true,"braces_position":true,"class_definition":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"modifier_keywords":{"elements":["method","property"]},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_import_per_statement":true,"single_line_after_imports":true,"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","do","else","elseif","final","for","foreach","function","if","interface","namespace","private","protected","public","static","switch","trait","try","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"ordered_imports":{"sort_algorithm":"alpha"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"unary_operator_spaces":true,"binary_operator_spaces":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"src\/Number.php":"24aa9f086b4d1f1dca036b52579161aa","src\/Formatter\/Formatter.php":"9964fe6d64137d383a5e82f8ea2da3f8","src\/AbstractNumber.php":"c24a93edb0f978e095dfb5f9b95aa6a6","src\/Exception\/InvalidRoundingModeException.php":"d71e665e4e240b22fdb9db64069e763d","src\/Exception\/DecimalExponentError.php":"ac4b9e400f191ce060ba161da6fb05fc","src\/Exception\/InvalidNumberInputTypeException.php":"5a4835631a06a4c2b21662b11272d2af","src\/Exception\/DivisionByZeroError.php":"3fde70a49d1d654451d3416b120f0589","tests\/NumberTest.php":"63f06c0b0bb535cd8e759891d05fb270","tests\/AbstractNumberImplementationTest.php":"4acfadd14746301afbba928ca3c5ac48","tests\/TestClasses\/Money.php":"d0cfe302a13cebba31984b8349c2ee41"}} \ No newline at end of file +{"php":"8.5.9","version":"3.95.18","indent":" ","lineEnding":"\n","rules":{"blank_line_after_namespace":true,"braces_position":{"allow_single_line_anonymous_functions":false},"class_definition":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"modifier_keywords":{"elements":["method","property"]},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_import_per_statement":true,"single_line_after_imports":true,"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","do","else","elseif","final","for","foreach","function","if","interface","namespace","private","protected","public","static","switch","trait","try","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"ordered_imports":{"sort_algorithm":"alpha"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":true,"phpdoc_scalar":true,"unary_operator_spaces":true,"binary_operator_spaces":true,"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true},"ruleCustomisationPolicyVersion":"null-policy","hashes":{"tests\/MemoryTest.php":"f451fe647e1c46a5e6c40408d2058c08","tests\/FormatterTest.php":"9f21b1f3598eb7bcace79f1f9638c9d9","src\/Formatter\/Formatter.php":"02e641ed81c4fc9d473bce532d111fcc","src\/Exception\/InvalidRoundingModeException.php":"d71e665e4e240b22fdb9db64069e763d","src\/Exception\/InvalidNumberInputTypeException.php":"5a4835631a06a4c2b21662b11272d2af","src\/Exception\/DivisionByZeroError.php":"3fde70a49d1d654451d3416b120f0589","src\/Exception\/DecimalExponentError.php":"ac4b9e400f191ce060ba161da6fb05fc","src\/Number.php":"24aa9f086b4d1f1dca036b52579161aa","src\/AbstractNumber.php":"b7eb4020a52cbd8a7b9627f9d8080cee","tests\/TestClasses\/Money.php":"d0cfe302a13cebba31984b8349c2ee41","tests\/AbstractNumberImplementationTest.php":"4acfadd14746301afbba928ca3c5ac48","tests\/NumberTest.php":"08497bda919dd42123490531c37988ab"}} \ No newline at end of file