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 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);